summaryrefslogtreecommitdiff
path: root/web/test/running-view.test.mjs
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/running-view.test.mjs
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/running-view.test.mjs')
-rw-r--r--web/test/running-view.test.mjs295
1 files changed, 295 insertions, 0 deletions
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);
+ });
+});