// 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); }); });