diff options
Diffstat (limited to 'web/test/running-view.test.mjs')
| -rw-r--r-- | web/test/running-view.test.mjs | 295 |
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); + }); +}); |
