summaryrefslogtreecommitdiff
path: root/web/test
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-06 23:55:07 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-06 23:55:07 +0000
commitfd42a54d96fcd3342941caaeb61a4b0d5d3f1b4f (patch)
tree0b9ef3b7f0ac3981aa310435d014c9f5e21089d4 /web/test
parent7d4890cde802974b94db24071f63e7733c3670fd (diff)
recover: restore untracked work from recovery branch (no Gemini changes)
Recovered files with no Claude→Agent contamination: - docs/adr/002-task-state-machine.md - internal/api/logs.go/logs_test.go: task-level log streaming endpoint - internal/api/validate.go/validate_test.go: POST /api/tasks/validate - internal/api/server_test.go, storage/db_test.go: expanded test coverage - scripts/reset-failed-tasks, reset-running-tasks - web/app.js, index.html, style.css: frontend improvements - web/test/: active-tasks-tab, delete-button, filter-tabs, sort-tasks tests Manually applied from server.go diff (skipping Claude→Agent rename): - taskLogStore field + validateCmdPath field - DELETE /api/tasks/{id} route + handleDeleteTask - GET /api/tasks/{id}/logs/stream route - POST /api/tasks/{id}/resume route + handleResumeTimedOutTask - handleCancelTask: allow cancelling PENDING/QUEUED tasks directly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'web/test')
-rw-r--r--web/test/active-tasks-tab.test.mjs68
-rw-r--r--web/test/delete-button.test.mjs60
-rw-r--r--web/test/filter-tabs.test.mjs90
-rw-r--r--web/test/running-view.test.mjs295
-rw-r--r--web/test/sort-tasks.test.mjs88
-rw-r--r--web/test/task-actions.test.mjs29
6 files changed, 626 insertions, 4 deletions
diff --git a/web/test/active-tasks-tab.test.mjs b/web/test/active-tasks-tab.test.mjs
new file mode 100644
index 0000000..7b68c6f
--- /dev/null
+++ b/web/test/active-tasks-tab.test.mjs
@@ -0,0 +1,68 @@
+// active-tasks-tab.test.mjs — TDD contract tests for filterActiveTasks
+//
+// filterActiveTasks is imported from app.js. Tests are RED until the function
+// is exported from app.js.
+//
+// Run with: node --test web/test/active-tasks-tab.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { filterActiveTasks } from '../app.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+
+function makeTask(state) {
+ return { id: state, name: `task-${state}`, state };
+}
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+
+describe('filterActiveTasks', () => {
+ it('returns only RUNNING tasks when given mixed states', () => {
+ const tasks = [makeTask('RUNNING'), makeTask('COMPLETED'), makeTask('PENDING')];
+ const result = filterActiveTasks(tasks);
+ assert.equal(result.length, 1);
+ assert.equal(result[0].state, 'RUNNING');
+ });
+
+ it('returns only READY tasks', () => {
+ const tasks = [makeTask('READY'), makeTask('FAILED'), makeTask('TIMED_OUT')];
+ const result = filterActiveTasks(tasks);
+ assert.equal(result.length, 1);
+ assert.equal(result[0].state, 'READY');
+ });
+
+ it('returns only QUEUED tasks', () => {
+ const tasks = [makeTask('QUEUED'), makeTask('CANCELLED'), makeTask('PENDING')];
+ const result = filterActiveTasks(tasks);
+ assert.equal(result.length, 1);
+ assert.equal(result[0].state, 'QUEUED');
+ });
+
+ it('returns only BLOCKED tasks', () => {
+ const tasks = [makeTask('BLOCKED'), makeTask('BUDGET_EXCEEDED'), makeTask('COMPLETED')];
+ const result = filterActiveTasks(tasks);
+ assert.equal(result.length, 1);
+ assert.equal(result[0].state, 'BLOCKED');
+ });
+
+ it('returns all four active states together, excludes PENDING/COMPLETED/FAILED/TIMED_OUT/CANCELLED/BUDGET_EXCEEDED', () => {
+ const allStates = [
+ 'RUNNING', 'READY', 'QUEUED', 'BLOCKED',
+ 'PENDING', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED',
+ ];
+ const tasks = allStates.map(makeTask);
+ const result = filterActiveTasks(tasks);
+ assert.equal(result.length, 4, 'exactly 4 active-state tasks should be returned');
+ for (const state of ['RUNNING', 'READY', 'QUEUED', 'BLOCKED']) {
+ assert.ok(result.some(t => t.state === state), `${state} should be included`);
+ }
+ for (const state of ['PENDING', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED']) {
+ assert.ok(!result.some(t => t.state === state), `${state} should be excluded`);
+ }
+ });
+
+ it('returns empty array for empty input', () => {
+ assert.deepEqual(filterActiveTasks([]), []);
+ });
+});
diff --git a/web/test/delete-button.test.mjs b/web/test/delete-button.test.mjs
new file mode 100644
index 0000000..b82b487
--- /dev/null
+++ b/web/test/delete-button.test.mjs
@@ -0,0 +1,60 @@
+// delete-button.test.mjs — visibility logic for the Delete button on task cards
+//
+// Run with: node --test web/test/delete-button.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+// ── Logic under test ──────────────────────────────────────────────────────────
+// A delete button should be shown for any task that is not actively executing.
+// RUNNING and QUEUED tasks cannot be deleted via the API (409), so we hide the button.
+
+const NON_DELETABLE_STATES = new Set(['RUNNING', 'QUEUED']);
+
+function showDeleteButton(state) {
+ return !NON_DELETABLE_STATES.has(state);
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe('delete button visibility', () => {
+ it('shows for PENDING', () => {
+ assert.equal(showDeleteButton('PENDING'), true);
+ });
+
+ it('shows for COMPLETED', () => {
+ assert.equal(showDeleteButton('COMPLETED'), true);
+ });
+
+ it('shows for FAILED', () => {
+ assert.equal(showDeleteButton('FAILED'), true);
+ });
+
+ it('shows for CANCELLED', () => {
+ assert.equal(showDeleteButton('CANCELLED'), true);
+ });
+
+ it('shows for TIMED_OUT', () => {
+ assert.equal(showDeleteButton('TIMED_OUT'), true);
+ });
+
+ it('shows for BUDGET_EXCEEDED', () => {
+ assert.equal(showDeleteButton('BUDGET_EXCEEDED'), true);
+ });
+
+ it('shows for READY', () => {
+ assert.equal(showDeleteButton('READY'), true);
+ });
+
+ it('shows for BLOCKED', () => {
+ assert.equal(showDeleteButton('BLOCKED'), true);
+ });
+
+ it('hides for RUNNING', () => {
+ assert.equal(showDeleteButton('RUNNING'), false);
+ });
+
+ it('hides for QUEUED', () => {
+ assert.equal(showDeleteButton('QUEUED'), false);
+ });
+});
diff --git a/web/test/filter-tabs.test.mjs b/web/test/filter-tabs.test.mjs
new file mode 100644
index 0000000..44cfaf6
--- /dev/null
+++ b/web/test/filter-tabs.test.mjs
@@ -0,0 +1,90 @@
+// filter-tabs.test.mjs — TDD contract tests for filterTasksByTab
+//
+// filterTasksByTab is defined inline here to establish expected behaviour.
+// Once filterTasksByTab is exported from web/app.js, remove the inline
+// definition and import it instead.
+//
+// Run with: node --test web/test/filter-tabs.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { filterTasksByTab } from '../app.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+
+function makeTask(state) {
+ return { id: state, name: `task-${state}`, state };
+}
+
+const ALL_STATES = [
+ 'PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED',
+ 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED',
+];
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+
+describe('filterTasksByTab — active tab', () => {
+ it('includes PENDING, QUEUED, RUNNING, READY, BLOCKED', () => {
+ const tasks = ALL_STATES.map(makeTask);
+ const result = filterTasksByTab(tasks, 'active');
+ for (const state of ['PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED']) {
+ assert.ok(result.some(t => t.state === state), `${state} should be included`);
+ }
+ });
+
+ it('excludes COMPLETED, FAILED, TIMED_OUT, CANCELLED, BUDGET_EXCEEDED', () => {
+ const tasks = ALL_STATES.map(makeTask);
+ const result = filterTasksByTab(tasks, 'active');
+ for (const state of ['COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED']) {
+ assert.ok(!result.some(t => t.state === state), `${state} should be excluded`);
+ }
+ });
+
+ it('returns empty array for empty input', () => {
+ assert.deepEqual(filterTasksByTab([], 'active'), []);
+ });
+});
+
+describe('filterTasksByTab — done tab', () => {
+ it('includes COMPLETED, FAILED, TIMED_OUT, CANCELLED, BUDGET_EXCEEDED', () => {
+ const tasks = ALL_STATES.map(makeTask);
+ const result = filterTasksByTab(tasks, 'done');
+ for (const state of ['COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED']) {
+ assert.ok(result.some(t => t.state === state), `${state} should be included`);
+ }
+ });
+
+ it('excludes PENDING, QUEUED, RUNNING, READY, BLOCKED', () => {
+ const tasks = ALL_STATES.map(makeTask);
+ const result = filterTasksByTab(tasks, 'done');
+ for (const state of ['PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED']) {
+ assert.ok(!result.some(t => t.state === state), `${state} should be excluded`);
+ }
+ });
+
+ it('returns empty array for empty input', () => {
+ assert.deepEqual(filterTasksByTab([], 'done'), []);
+ });
+});
+
+describe('filterTasksByTab — all tab', () => {
+ it('returns all tasks unchanged', () => {
+ const tasks = ALL_STATES.map(makeTask);
+ const result = filterTasksByTab(tasks, 'all');
+ assert.equal(result.length, ALL_STATES.length);
+ assert.strictEqual(result, tasks, 'should return the same array reference');
+ });
+
+ it('returns empty array for empty input', () => {
+ assert.deepEqual(filterTasksByTab([], 'all'), []);
+ });
+});
+
+describe('filterTasksByTab — unknown tab', () => {
+ it('returns all tasks as defensive fallback', () => {
+ const tasks = ALL_STATES.map(makeTask);
+ const result = filterTasksByTab(tasks, 'unknown-tab');
+ assert.equal(result.length, ALL_STATES.length);
+ assert.strictEqual(result, tasks, 'should return the same array reference');
+ });
+});
diff --git a/web/test/running-view.test.mjs b/web/test/running-view.test.mjs
new file mode 100644
index 0000000..88419bc
--- /dev/null
+++ b/web/test/running-view.test.mjs
@@ -0,0 +1,295 @@
+// running-view.test.mjs — pure function tests for the Running tab
+//
+// Tests:
+// filterRunningTasks(tasks) — returns only tasks where state === RUNNING
+// formatElapsed(startISO) — returns elapsed string like "2m 30s", "1h 5m"
+// fetchRecentExecutions(basePath, fetchFn) — calls /api/executions?since=24h
+// formatDuration(startISO, endISO) — returns duration string for history table
+//
+// Run with: node --test web/test/running-view.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+// ── Inline implementations ─────────────────────────────────────────────────────
+
+function extractLogLines(lines, max = 500) {
+ if (lines.length <= max) return lines;
+ return lines.slice(lines.length - max);
+}
+
+function filterRunningTasks(tasks) {
+ return tasks.filter(t => t.state === 'RUNNING');
+}
+
+function formatElapsed(startISO) {
+ if (startISO == null) return '';
+ const elapsed = Math.floor((Date.now() - new Date(startISO).getTime()) / 1000);
+ if (elapsed < 0) return '0s';
+ const h = Math.floor(elapsed / 3600);
+ const m = Math.floor((elapsed % 3600) / 60);
+ const s = elapsed % 60;
+ if (h > 0) return `${h}h ${m}m`;
+ if (m > 0) return `${m}m ${s}s`;
+ return `${s}s`;
+}
+
+async function fetchRecentExecutions(basePath, fetchFn) {
+ const res = await fetchFn(`${basePath}/api/executions?since=24h`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+}
+
+// formatDuration: returns human-readable duration between two ISO timestamps.
+// If endISO is null/undefined, uses now (for in-progress tasks).
+// If startISO is null/undefined, returns '--'.
+function formatDuration(startISO, endISO) {
+ if (startISO == null) return '--';
+ const start = new Date(startISO).getTime();
+ const end = endISO != null ? new Date(endISO).getTime() : Date.now();
+ const elapsed = Math.max(0, Math.floor((end - start) / 1000));
+ const h = Math.floor(elapsed / 3600);
+ const m = Math.floor((elapsed % 3600) / 60);
+ const s = elapsed % 60;
+ if (h > 0) return `${h}h ${m}m`;
+ if (m > 0) return `${m}m ${s}s`;
+ return `${s}s`;
+}
+
+// ── Tests: filterRunningTasks ─────────────────────────────────────────────────
+
+describe('filterRunningTasks', () => {
+ it('returns only RUNNING tasks from mixed list', () => {
+ const tasks = [
+ { id: '1', state: 'RUNNING' },
+ { id: '2', state: 'COMPLETED' },
+ { id: '3', state: 'RUNNING' },
+ { id: '4', state: 'QUEUED' },
+ ];
+ const result = filterRunningTasks(tasks);
+ assert.equal(result.length, 2);
+ assert.ok(result.every(t => t.state === 'RUNNING'));
+ });
+
+ it('returns empty array when no tasks are RUNNING', () => {
+ const tasks = [
+ { id: '1', state: 'COMPLETED' },
+ { id: '2', state: 'QUEUED' },
+ ];
+ assert.deepEqual(filterRunningTasks(tasks), []);
+ });
+
+ it('handles empty task list', () => {
+ assert.deepEqual(filterRunningTasks([]), []);
+ });
+
+ it('does not include QUEUED tasks', () => {
+ const tasks = [{ id: '1', state: 'QUEUED' }];
+ assert.deepEqual(filterRunningTasks(tasks), []);
+ });
+
+ it('does not include READY tasks', () => {
+ const tasks = [{ id: '1', state: 'READY' }];
+ assert.deepEqual(filterRunningTasks(tasks), []);
+ });
+});
+
+// ── Tests: formatElapsed ──────────────────────────────────────────────────────
+
+describe('formatElapsed', () => {
+ it('returns empty string for null', () => {
+ assert.equal(formatElapsed(null), '');
+ });
+
+ it('returns empty string for undefined', () => {
+ assert.equal(formatElapsed(undefined), '');
+ });
+
+ it('returns Xs format for elapsed under a minute', () => {
+ const start = new Date(Date.now() - 45 * 1000).toISOString();
+ assert.equal(formatElapsed(start), '45s');
+ });
+
+ it('returns Xm Ys format for 2 minutes 30 seconds ago', () => {
+ const start = new Date(Date.now() - (2 * 60 + 30) * 1000).toISOString();
+ assert.equal(formatElapsed(start), '2m 30s');
+ });
+
+ it('returns Xh Ym format for over an hour', () => {
+ const start = new Date(Date.now() - (1 * 3600 + 5 * 60) * 1000).toISOString();
+ assert.equal(formatElapsed(start), '1h 5m');
+ });
+
+ it('returns 0s for future timestamp', () => {
+ const start = new Date(Date.now() + 60 * 1000).toISOString();
+ assert.equal(formatElapsed(start), '0s');
+ });
+});
+
+// ── Tests: fetchRecentExecutions ──────────────────────────────────────────────
+
+describe('fetchRecentExecutions', () => {
+ it('calls /api/executions?since=24h with basePath prefix', async () => {
+ let calledUrl;
+ const mockFetch = async (url) => {
+ calledUrl = url;
+ return { ok: true, json: async () => [] };
+ };
+ await fetchRecentExecutions('/claudomator', mockFetch);
+ assert.equal(calledUrl, '/claudomator/api/executions?since=24h');
+ });
+
+ it('calls with empty basePath', async () => {
+ let calledUrl;
+ const mockFetch = async (url) => {
+ calledUrl = url;
+ return { ok: true, json: async () => [] };
+ };
+ await fetchRecentExecutions('', mockFetch);
+ assert.equal(calledUrl, '/api/executions?since=24h');
+ });
+
+ it('returns parsed JSON response', async () => {
+ const data = [{ id: 'exec-1', task_id: 't-1', status: 'COMPLETED' }];
+ const mockFetch = async () => ({ ok: true, json: async () => data });
+ const result = await fetchRecentExecutions('', mockFetch);
+ assert.deepEqual(result, data);
+ });
+
+ it('throws on non-OK HTTP status', async () => {
+ const mockFetch = async () => ({ ok: false, status: 500 });
+ await assert.rejects(
+ () => fetchRecentExecutions('', mockFetch),
+ /HTTP 500/,
+ );
+ });
+});
+
+// ── Tests: formatDuration ─────────────────────────────────────────────────────
+
+describe('formatDuration', () => {
+ it('returns -- for null startISO', () => {
+ assert.equal(formatDuration(null, null), '--');
+ });
+
+ it('returns -- for undefined startISO', () => {
+ assert.equal(formatDuration(undefined, null), '--');
+ });
+
+ it('returns Xs for duration under a minute', () => {
+ const start = new Date(Date.now() - 45 * 1000).toISOString();
+ const end = new Date().toISOString();
+ assert.equal(formatDuration(start, end), '45s');
+ });
+
+ it('returns Xm Ys for duration between 1 and 60 minutes', () => {
+ const start = new Date(Date.now() - (3 * 60 + 15) * 1000).toISOString();
+ const end = new Date().toISOString();
+ assert.equal(formatDuration(start, end), '3m 15s');
+ });
+
+ it('returns Xh Ym for duration over an hour', () => {
+ const start = new Date(Date.now() - (2 * 3600 + 30 * 60) * 1000).toISOString();
+ const end = new Date().toISOString();
+ assert.equal(formatDuration(start, end), '2h 30m');
+ });
+
+ it('uses current time when endISO is null', () => {
+ // Start was 10s ago, no end → should return ~10s
+ const start = new Date(Date.now() - 10 * 1000).toISOString();
+ const result = formatDuration(start, null);
+ assert.match(result, /^\d+s$/);
+ });
+});
+
+// ── sortExecutionsDesc: inline implementation ─────────────────────────────────
+
+function sortExecutionsDesc(executions) {
+ return [...executions].sort((a, b) =>
+ new Date(b.started_at).getTime() - new Date(a.started_at).getTime(),
+ );
+}
+
+// ── Tests: sortExecutionsDesc ─────────────────────────────────────────────────
+
+describe('sortExecutionsDesc', () => {
+ it('sorts executions newest first', () => {
+ const execs = [
+ { id: 'a', started_at: '2024-01-01T00:00:00Z' },
+ { id: 'b', started_at: '2024-01-03T00:00:00Z' },
+ { id: 'c', started_at: '2024-01-02T00:00:00Z' },
+ ];
+ const result = sortExecutionsDesc(execs);
+ assert.equal(result[0].id, 'b');
+ assert.equal(result[1].id, 'c');
+ assert.equal(result[2].id, 'a');
+ });
+
+ it('returns empty array for empty input', () => {
+ assert.deepEqual(sortExecutionsDesc([]), []);
+ });
+
+ it('does not mutate the original array', () => {
+ const execs = [
+ { id: 'a', started_at: '2024-01-01T00:00:00Z' },
+ { id: 'b', started_at: '2024-01-03T00:00:00Z' },
+ ];
+ const copy = [execs[0], execs[1]];
+ sortExecutionsDesc(execs);
+ assert.deepEqual(execs, copy);
+ });
+
+ it('returns single-element array unchanged', () => {
+ const execs = [{ id: 'a', started_at: '2024-01-01T00:00:00Z' }];
+ assert.equal(sortExecutionsDesc(execs)[0].id, 'a');
+ });
+});
+
+// ── Tests: filterRunningTasks (explicit empty input check) ─────────────────────
+
+describe('filterRunningTasks (empty input)', () => {
+ it('returns [] for empty input', () => {
+ assert.deepEqual(filterRunningTasks([]), []);
+ });
+});
+
+// ── Tests: extractLogLines ────────────────────────────────────────────────────
+
+describe('extractLogLines', () => {
+ it('returns lines unchanged when count is below max', () => {
+ const lines = ['a', 'b', 'c'];
+ assert.deepEqual(extractLogLines(lines, 500), lines);
+ });
+
+ it('returns lines unchanged when count equals max', () => {
+ const lines = Array.from({ length: 500 }, (_, i) => `line${i}`);
+ assert.equal(extractLogLines(lines, 500).length, 500);
+ assert.equal(extractLogLines(lines, 500)[0], 'line0');
+ });
+
+ it('truncates to last max lines when count exceeds max', () => {
+ const lines = Array.from({ length: 600 }, (_, i) => `line${i}`);
+ const result = extractLogLines(lines, 500);
+ assert.equal(result.length, 500);
+ assert.equal(result[0], 'line100');
+ assert.equal(result[499], 'line599');
+ });
+
+ it('uses default max of 500', () => {
+ const lines = Array.from({ length: 501 }, (_, i) => `line${i}`);
+ const result = extractLogLines(lines);
+ assert.equal(result.length, 500);
+ assert.equal(result[0], 'line1');
+ });
+
+ it('returns empty array for empty input', () => {
+ assert.deepEqual(extractLogLines([]), []);
+ });
+
+ it('does not mutate the original array', () => {
+ const lines = Array.from({ length: 600 }, (_, i) => `line${i}`);
+ const copy = [...lines];
+ extractLogLines(lines, 500);
+ assert.deepEqual(lines, copy);
+ });
+});
diff --git a/web/test/sort-tasks.test.mjs b/web/test/sort-tasks.test.mjs
new file mode 100644
index 0000000..fe47702
--- /dev/null
+++ b/web/test/sort-tasks.test.mjs
@@ -0,0 +1,88 @@
+// sort-tasks.test.mjs — TDD contract tests for sortTasksByDate
+//
+// sortTasksByDate is defined inline here to establish expected behaviour.
+// Once sortTasksByDate is exported from web/app.js or a shared module,
+// remove the inline definition and import it instead.
+//
+// Run with: node --test web/test/sort-tasks.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+// ── Implementation under contract ─────────────────────────────────────────────
+// Remove this block once sortTasksByDate is available from app.js.
+
+function sortTasksByDate(tasks) {
+ return [...tasks].sort((a, b) => {
+ if (!a.created_at && !b.created_at) return 0;
+ if (!a.created_at) return 1;
+ if (!b.created_at) return -1;
+ return new Date(a.created_at) - new Date(b.created_at);
+ });
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+
+function makeTask(id, created_at, state = 'PENDING') {
+ return { id, name: `task-${id}`, state, created_at };
+}
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+
+describe('sortTasksByDate', () => {
+ it('sorts tasks oldest-first by created_at', () => {
+ const tasks = [
+ makeTask('c', '2026-03-06T12:00:00Z'),
+ makeTask('a', '2026-03-04T08:00:00Z'),
+ makeTask('b', '2026-03-05T10:00:00Z'),
+ ];
+ const result = sortTasksByDate(tasks);
+ assert.equal(result[0].id, 'a', 'oldest should be first');
+ assert.equal(result[1].id, 'b');
+ assert.equal(result[2].id, 'c', 'newest should be last');
+ });
+
+ it('returns a new array (does not mutate input)', () => {
+ const tasks = [
+ makeTask('b', '2026-03-05T10:00:00Z'),
+ makeTask('a', '2026-03-04T08:00:00Z'),
+ ];
+ const original = [...tasks];
+ const result = sortTasksByDate(tasks);
+ assert.notStrictEqual(result, tasks, 'should return a new array');
+ assert.deepEqual(tasks, original, 'input should not be mutated');
+ });
+
+ it('returns an empty array when given an empty array', () => {
+ assert.deepEqual(sortTasksByDate([]), []);
+ });
+
+ it('returns a single-element array unchanged', () => {
+ const tasks = [makeTask('x', '2026-03-01T00:00:00Z')];
+ const result = sortTasksByDate(tasks);
+ assert.equal(result.length, 1);
+ assert.equal(result[0].id, 'x');
+ });
+
+ it('places tasks with null created_at after tasks with a date', () => {
+ const tasks = [
+ makeTask('no-date', null),
+ makeTask('has-date', '2026-03-01T00:00:00Z'),
+ ];
+ const result = sortTasksByDate(tasks);
+ assert.equal(result[0].id, 'has-date', 'task with date should come first');
+ assert.equal(result[1].id, 'no-date', 'task without date should come last');
+ });
+
+ it('works with mixed states (not just PENDING)', () => {
+ const tasks = [
+ makeTask('r', '2026-03-06T00:00:00Z', 'RUNNING'),
+ makeTask('p', '2026-03-04T00:00:00Z', 'PENDING'),
+ makeTask('q', '2026-03-05T00:00:00Z', 'QUEUED'),
+ ];
+ const result = sortTasksByDate(tasks);
+ assert.equal(result[0].id, 'p');
+ assert.equal(result[1].id, 'q');
+ assert.equal(result[2].id, 'r');
+ });
+});
diff --git a/web/test/task-actions.test.mjs b/web/test/task-actions.test.mjs
index 2df6523..36c0e8b 100644
--- a/web/test/task-actions.test.mjs
+++ b/web/test/task-actions.test.mjs
@@ -1,4 +1,4 @@
-// task-actions.test.mjs — button visibility logic for Cancel/Restart actions
+// task-actions.test.mjs — button visibility logic for Cancel/Restart/Resume actions
//
// Run with: node --test web/test/task-actions.test.mjs
@@ -7,16 +7,23 @@ import assert from 'node:assert/strict';
// ── Logic under test ──────────────────────────────────────────────────────────
-const RESTART_STATES = new Set(['FAILED', 'TIMED_OUT', 'CANCELLED']);
+const RESTART_STATES = new Set(['FAILED', 'CANCELLED']);
function getCardAction(state) {
if (state === 'PENDING') return 'run';
if (state === 'RUNNING') return 'cancel';
if (state === 'READY') return 'approve';
+ if (state === 'TIMED_OUT') return 'resume';
if (RESTART_STATES.has(state)) return 'restart';
return null;
}
+function getApiEndpoint(state) {
+ if (state === 'TIMED_OUT') return '/resume';
+ if (RESTART_STATES.has(state)) return '/run';
+ return null;
+}
+
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('task card action buttons', () => {
@@ -32,8 +39,8 @@ describe('task card action buttons', () => {
assert.equal(getCardAction('FAILED'), 'restart');
});
- it('shows Restart button for TIMED_OUT', () => {
- assert.equal(getCardAction('TIMED_OUT'), 'restart');
+ it('shows Resume button for TIMED_OUT', () => {
+ assert.equal(getCardAction('TIMED_OUT'), 'resume');
});
it('shows Restart button for CANCELLED', () => {
@@ -56,3 +63,17 @@ describe('task card action buttons', () => {
assert.equal(getCardAction('BUDGET_EXCEEDED'), null);
});
});
+
+describe('task action API endpoints', () => {
+ it('TIMED_OUT uses /resume endpoint', () => {
+ assert.equal(getApiEndpoint('TIMED_OUT'), '/resume');
+ });
+
+ it('FAILED uses /run endpoint', () => {
+ assert.equal(getApiEndpoint('FAILED'), '/run');
+ });
+
+ it('CANCELLED uses /run endpoint', () => {
+ assert.equal(getApiEndpoint('CANCELLED'), '/run');
+ });
+});