// tasks-board.test.mjs — Unit tests for the Tasks tab's pure column logic: // column-mapping completeness, fallback-state behaviour, and per-column // grouping/sorting correctness. // // Run with: node --test web/test/tasks-board.test.mjs import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { TASK_COLUMNS, columnForTaskState, groupTasksByColumn, createTaskCard, cardContentSignature, } from '../app.js'; describe('columnForTaskState', () => { it('maps every documented task state to a column', () => { assert.equal(columnForTaskState('PENDING'), 'queue'); assert.equal(columnForTaskState('QUEUED'), 'queue'); assert.equal(columnForTaskState('RUNNING'), 'running'); assert.equal(columnForTaskState('BLOCKED'), 'running'); assert.equal(columnForTaskState('READY'), 'ready'); assert.equal(columnForTaskState('FAILED'), 'interrupted'); assert.equal(columnForTaskState('TIMED_OUT'), 'interrupted'); assert.equal(columnForTaskState('CANCELLED'), 'interrupted'); assert.equal(columnForTaskState('BUDGET_EXCEEDED'), 'interrupted'); assert.equal(columnForTaskState('COMPLETED'), 'done'); }); it('falls back to queue for an unrecognized/empty state', () => { assert.equal(columnForTaskState(''), 'queue'); assert.equal(columnForTaskState(undefined), 'queue'); assert.equal(columnForTaskState('SOME_FUTURE_STATE'), 'queue'); }); it('every column key is unique and covers all 10 task states', () => { const keys = TASK_COLUMNS.map(c => c.key); assert.equal(new Set(keys).size, keys.length, 'column keys must be unique'); const allStates = TASK_COLUMNS.flatMap(c => c.states); for (const s of [ 'PENDING', 'QUEUED', 'RUNNING', 'BLOCKED', 'READY', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED', 'COMPLETED', ]) { assert.ok(allStates.includes(s), `${s} missing from any column`); } assert.equal(new Set(allStates).size, allStates.length, 'a state must map to exactly one column'); }); }); describe('groupTasksByColumn', () => { it('returns an object with a key per column, all initially empty arrays', () => { const groups = groupTasksByColumn([]); for (const col of TASK_COLUMNS) { assert.ok(Array.isArray(groups[col.key]), `groups.${col.key} should be an array`); assert.equal(groups[col.key].length, 0); } }); it('places tasks in the correct column', () => { const tasks = [ { id: '1', state: 'PENDING', created_at: '2024-01-01T00:00:00Z' }, { id: '2', state: 'RUNNING', created_at: '2024-01-02T00:00:00Z' }, { id: '3', state: 'READY', created_at: '2024-01-03T00:00:00Z' }, { id: '4', state: 'FAILED', created_at: '2024-01-04T00:00:00Z' }, { id: '5', state: 'COMPLETED', created_at: '2024-01-05T00:00:00Z' }, ]; const groups = groupTasksByColumn(tasks); assert.equal(groups.queue.length, 1); assert.equal(groups.queue[0].id, '1'); assert.equal(groups.running.length, 1); assert.equal(groups.running[0].id, '2'); assert.equal(groups.ready.length, 1); assert.equal(groups.ready[0].id, '3'); assert.equal(groups.interrupted.length, 1); assert.equal(groups.interrupted[0].id, '4'); assert.equal(groups.done.length, 1); assert.equal(groups.done[0].id, '5'); }); it('sorts queue oldest-first (ascending created_at)', () => { const tasks = [ { id: 'newer', state: 'QUEUED', created_at: '2024-02-01T00:00:00Z' }, { id: 'older', state: 'PENDING', created_at: '2024-01-01T00:00:00Z' }, ]; const groups = groupTasksByColumn(tasks); assert.deepEqual(groups.queue.map(t => t.id), ['older', 'newer']); }); it('sorts ready oldest-first (ascending created_at)', () => { const tasks = [ { id: 'newer', state: 'READY', created_at: '2024-02-01T00:00:00Z' }, { id: 'older', state: 'READY', created_at: '2024-01-01T00:00:00Z' }, ]; const groups = groupTasksByColumn(tasks); assert.deepEqual(groups.ready.map(t => t.id), ['older', 'newer']); }); it('sorts interrupted newest-first (descending created_at)', () => { const tasks = [ { id: 'older', state: 'FAILED', created_at: '2024-01-01T00:00:00Z' }, { id: 'newer', state: 'CANCELLED', created_at: '2024-02-01T00:00:00Z' }, ]; const groups = groupTasksByColumn(tasks); assert.deepEqual(groups.interrupted.map(t => t.id), ['newer', 'older']); }); it('sorts done newest-first (descending created_at)', () => { const tasks = [ { id: 'older', state: 'COMPLETED', created_at: '2024-01-01T00:00:00Z' }, { id: 'newer', state: 'COMPLETED', created_at: '2024-02-01T00:00:00Z' }, ]; const groups = groupTasksByColumn(tasks); assert.deepEqual(groups.done.map(t => t.id), ['newer', 'older']); }); it('sorts running newest-first (descending created_at)', () => { const tasks = [ { id: 'older', state: 'RUNNING', created_at: '2024-01-01T00:00:00Z' }, { id: 'newer', state: 'BLOCKED', created_at: '2024-02-01T00:00:00Z' }, ]; const groups = groupTasksByColumn(tasks); assert.deepEqual(groups.running.map(t => t.id), ['newer', 'older']); }); it('sorts a task with no created_at LAST in an ascending column (queue)', () => { const tasks = [ { id: 'no-date', state: 'PENDING' }, { id: 'has-date', state: 'QUEUED', created_at: '2024-01-01T00:00:00Z' }, ]; const groups = groupTasksByColumn(tasks); assert.deepEqual(groups.queue.map(t => t.id), ['has-date', 'no-date']); }); it('sorts a task with no created_at LAST in a descending column (interrupted)', () => { const tasks = [ { id: 'no-date', state: 'FAILED' }, { id: 'has-date', state: 'CANCELLED', created_at: '2024-01-01T00:00:00Z' }, ]; const groups = groupTasksByColumn(tasks); assert.deepEqual(groups.interrupted.map(t => t.id), ['has-date', 'no-date']); }); it('handles null/undefined gracefully', () => { const groups = groupTasksByColumn(null); for (const col of TASK_COLUMNS) { assert.equal(groups[col.key].length, 0); } }); }); // ── createTaskCard: log-tail placeholder + elapsed timer ──────────────────── // // createTaskCard uses the real global `document` by default (existing // convention throughout app.js — e.g. renderEventTimeline(events, doc = // document)) but accepts an injectable `doc` for Node-based unit testing // without jsdom, matching the hand-rolled mock-DOM convention already used // by web/test/task-panel-summary.test.mjs and web/test/render-dedup.test.mjs. function makeMockDoc() { function makeEl(tag) { return { tag, className: '', classList: { _set: new Set(), add(...cls) { cls.forEach(c => this._set.add(c)); }, toggle(cls, on) { on ? this._set.add(cls) : this._set.delete(cls); }, contains(cls) { return this._set.has(cls); }, }, textContent: '', title: '', hidden: false, dataset: {}, children: [], _listeners: {}, appendChild(child) { this.children.push(child); return child; }, append(...nodes) { nodes.forEach(n => this.children.push(n)); }, prepend(...nodes) { this.children.unshift(...nodes); }, addEventListener(type, fn) { this._listeners[type] = fn; }, querySelector(sel) { const cls = sel.split(',')[0].trim().replace(/^\./, ''); const search = (el) => { if (el.className && el.className.split(' ').includes(cls)) return el; if (el.dataset && sel.includes('[data-started-at]') && el.className.includes('task-elapsed') && 'startedAt' in el.dataset) return el; for (const c of el.children) { const found = search(c); if (found) return found; } return null; }; return search(this); }, }; } return { createElement: (tag) => makeEl(tag) }; } describe('createTaskCard log-tail placeholder', () => { it('shows a "waiting to start" placeholder for PENDING/QUEUED tasks (no execution exists yet)', () => { const doc = makeMockDoc(); for (const state of ['PENDING', 'QUEUED']) { const card = createTaskCard({ id: 't1', name: 'Task', state }, doc); const placeholder = card.querySelector('.task-log-tail-placeholder'); const tail = card.querySelector('.task-log-tail'); assert.ok(placeholder, `expected a placeholder for ${state}`); assert.equal(tail, null, `expected no .task-log-tail element for ${state}`); } }); it('includes a .task-log-tail element for every non-Queue state', () => { const doc = makeMockDoc(); for (const state of ['RUNNING', 'BLOCKED', 'READY', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED', 'COMPLETED']) { const card = createTaskCard({ id: 't1', name: 'Task', state }, doc); const tail = card.querySelector('.task-log-tail'); assert.ok(tail, `expected .task-log-tail for ${state}`); } }); }); describe('createTaskCard elapsed timer', () => { it('includes a .task-elapsed[data-started-at] element only for RUNNING tasks', () => { const doc = makeMockDoc(); const running = createTaskCard({ id: 't1', name: 'Task', state: 'RUNNING', updated_at: '2026-01-01T00:00:00Z' }, doc); assert.ok(running.querySelector('.task-elapsed[data-started-at]')); const ready = createTaskCard({ id: 't2', name: 'Task', state: 'READY' }, doc); assert.equal(ready.querySelector('.task-elapsed[data-started-at]'), null); }); }); // ── renderTasksIntoContainer: log-tail preservation across re-renders ────── describe('cardContentSignature', () => { it('excludes .task-log-tail content from the signature', () => { const doc = makeMockDoc(); const cardA = createTaskCard({ id: 't1', name: 'Task', state: 'READY' }, doc); const cardB = createTaskCard({ id: 't1', name: 'Task', state: 'READY' }, doc); // Simulate cardB having accumulated live log lines that cardA (freshly built) doesn't have. const tailB = cardB.querySelector('.task-log-tail'); tailB.children.push({ tag: 'div', className: 'log-line', textContent: 'some streamed output', children: [] }); assert.equal(cardContentSignature(cardA), cardContentSignature(cardB)); }); it('still differs when a non-log field changes', () => { const doc = makeMockDoc(); const cardA = createTaskCard({ id: 't1', name: 'Task', state: 'READY' }, doc); const cardB = createTaskCard({ id: 't1', name: 'Task', state: 'FAILED' }, doc); assert.notEqual(cardContentSignature(cardA), cardContentSignature(cardB)); }); }); // ── ensureTaskLogStream: stream lifecycle (reuse vs. reopen vs. no-op) ───── import { ensureTaskLogStream, taskLogStreams } from '../app.js'; function makeFakeEventSource() { const instances = []; function FakeEventSource(url) { this.url = url; this.closed = false; this._listeners = {}; instances.push(this); } FakeEventSource.prototype.close = function () { this.closed = true; }; FakeEventSource.prototype.addEventListener = function (type, fn) { this._listeners[type] = fn; }; Object.defineProperty(FakeEventSource.prototype, 'onmessage', { writable: true, value: null }); Object.defineProperty(FakeEventSource.prototype, 'onerror', { writable: true, value: null }); FakeEventSource.instances = instances; return FakeEventSource; } function makeFakeLogArea() { return { children: [], appendChild(c) { this.children.push(c); }, removeChild(c) { this.children = this.children.filter(x => x !== c); }, get childElementCount() { return this.children.length; }, get firstElementChild() { return this.children[0]; }, get innerHTML() { return ''; }, set innerHTML(_) { this.children = []; }, // mirrors real DOM: assigning innerHTML clears children scrollTop: 0, scrollHeight: 0, clientHeight: 0, addEventListener() {}, }; } describe('ensureTaskLogStream', () => { it('does nothing when the task has no executions yet (Queue)', async () => { const fetchFn = async () => ({ ok: true, json: async () => [] }); const FakeES = makeFakeEventSource(); const logArea = makeFakeLogArea(); await ensureTaskLogStream('task-queue-1', logArea, { fetchFn, EventSourceImpl: FakeES, apiBase: '' }); assert.equal(FakeES.instances.length, 0); }); it('opens a stream for a task with an execution', async () => { const fetchFn = async () => ({ ok: true, json: async () => [{ id: 'exec-1' }] }); const FakeES = makeFakeEventSource(); const logArea = makeFakeLogArea(); await ensureTaskLogStream('task-1', logArea, { fetchFn, EventSourceImpl: FakeES, apiBase: '' }); assert.equal(FakeES.instances.length, 1); assert.match(FakeES.instances[0].url, /\/api\/executions\/exec-1\/logs\/stream/); assert.equal(taskLogStreams['task-1'].execId, 'exec-1'); }); it('does not reopen a stream already attached to the same execution', async () => { const fetchFn = async () => ({ ok: true, json: async () => [{ id: 'exec-2' }] }); const FakeES = makeFakeEventSource(); const logArea = makeFakeLogArea(); await ensureTaskLogStream('task-2', logArea, { fetchFn, EventSourceImpl: FakeES, apiBase: '' }); await ensureTaskLogStream('task-2', logArea, { fetchFn, EventSourceImpl: FakeES, apiBase: '' }); assert.equal(FakeES.instances.length, 1, 'should not open a second stream for the same execution'); }); it('closes the old stream and opens a new one when the execution id changes', async () => { let call = 0; const fetchFn = async () => { call++; return { ok: true, json: async () => [{ id: call === 1 ? 'exec-a' : 'exec-b' }] }; }; const FakeES = makeFakeEventSource(); const logArea = makeFakeLogArea(); await ensureTaskLogStream('task-3', logArea, { fetchFn, EventSourceImpl: FakeES, apiBase: '' }); await ensureTaskLogStream('task-3', logArea, { fetchFn, EventSourceImpl: FakeES, apiBase: '' }); assert.equal(FakeES.instances.length, 2); assert.ok(FakeES.instances[0].closed, 'old stream should be closed'); assert.equal(taskLogStreams['task-3'].execId, 'exec-b'); }); });