diff options
| author | Claudomator Agent <agent@claudomator.local> | 2026-07-06 05:26:24 +0000 |
|---|---|---|
| committer | Claudomator Agent <agent@claudomator.local> | 2026-07-06 05:26:24 +0000 |
| commit | d2cbe9aa07afdc0aac33c959803d6a6aaef69b8e (patch) | |
| tree | c7861cc4c1a8472d48dff13be98b7d634090191a /web | |
| parent | d9db12a25a42d00e0f64ac87e10ac0b612b841a8 (diff) | |
fix(web): correct elapsed timer class/location, log-tail coverage, and tests (task 4 reviewer findings)
- Move elapsed timer span from card header to after meta block; add
task-elapsed class alongside running-elapsed (Finding 1)
- Replace PENDING/QUEUED-only log-tail div with full if/else: placeholder
div.task-log-tail-placeholder for queue states, div.task-log-tail with
dataset.logTarget for all other states (Finding 2)
- Delete web/test/task-card-di.test.mjs; append correct tests to
web/test/tasks-board.test.mjs covering log-tail and elapsed timer
contracts (Finding 3)
- Thread doc parameter through createEditForm, renderSubtaskRollup, and
renderQuestionFooter so createTaskCard is fully testable without a
browser DOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'web')
| -rw-r--r-- | web/app.js | 92 | ||||
| -rw-r--r-- | web/test/task-card-di.test.mjs | 169 | ||||
| -rw-r--r-- | web/test/tasks-board.test.mjs | 81 |
3 files changed, 132 insertions, 210 deletions
@@ -294,13 +294,6 @@ export function createTaskCard(task, doc = document) { badge.textContent = task.state.replace(/_/g, ' '); header.append(name, badge); - if (task.state === 'RUNNING') { - const elapsed = doc.createElement('span'); - elapsed.className = 'running-elapsed'; - elapsed.dataset.startedAt = task.updated_at ?? ''; - elapsed.textContent = formatElapsed(task.updated_at); - header.append(elapsed); - } card.appendChild(header); // Meta: priority + created_at @@ -324,6 +317,17 @@ export function createTaskCard(task, doc = document) { } if (meta.children.length) card.appendChild(meta); + // Elapsed timer for RUNNING tasks (no separate ticking interval — updated + // on each poll tick alongside the rest of the board, same convention the + // old Running-tab card used). + if (task.state === 'RUNNING') { + const elapsed = doc.createElement('span'); + elapsed.className = 'task-elapsed running-elapsed'; + elapsed.dataset.startedAt = task.updated_at ?? ''; + elapsed.textContent = formatElapsed(task.updated_at); + card.appendChild(elapsed); + } + // Description (truncated via CSS) if (task.description) { const desc = doc.createElement('div'); @@ -370,14 +374,21 @@ export function createTaskCard(task, doc = document) { if (depBadge) card.appendChild(depBadge); } - // Log-tail placeholder for PENDING/QUEUED tasks (no execution exists yet). + // Inline log tail (no click required) — see docs/superpowers/specs/2026-07-06-tasks-board-design.md + // section 4. PENDING/QUEUED tasks have no execution row yet, so there is + // nothing to tail; every other state gets a .task-log-tail element that + // Task 6's ensureTaskLogStream() attaches an SSE stream to. if (task.state === 'PENDING' || task.state === 'QUEUED') { + const placeholder = doc.createElement('div'); + placeholder.className = 'task-log-tail-placeholder'; + placeholder.textContent = 'Waiting to start…'; + card.appendChild(placeholder); + } else { const logTail = doc.createElement('div'); - logTail.className = 'task-log-tail'; - const placeholder = doc.createElement('p'); - placeholder.className = 'task-log-placeholder'; - placeholder.textContent = 'Waiting to start\u2026'; - logTail.appendChild(placeholder); + logTail.className = task.state === 'RUNNING' || task.state === 'BLOCKED' + ? 'task-log-tail running-log' + : 'task-log-tail'; + logTail.dataset.logTarget = task.id; card.appendChild(logTail); } @@ -409,7 +420,7 @@ export function createTaskCard(task, doc = document) { }); footer.appendChild(btn); } else if (task.state === 'READY') { - renderSubtaskRollup(task, footer); + renderSubtaskRollup(task, footer, doc); const acceptBtn = doc.createElement('button'); acceptBtn.className = 'btn-accept'; acceptBtn.textContent = 'Accept'; @@ -428,9 +439,9 @@ export function createTaskCard(task, doc = document) { footer.appendChild(rejectBtn); } else if (task.state === 'BLOCKED') { if (task.question) { - renderQuestionFooter(task, footer); + renderQuestionFooter(task, footer, doc); } else { - renderSubtaskRollup(task, footer); + renderSubtaskRollup(task, footer, doc); } const cancelBtn = doc.createElement('button'); cancelBtn.className = 'btn-cancel'; @@ -478,7 +489,7 @@ export function createTaskCard(task, doc = document) { if (EDITABLE_STATES.has(task.state)) { card.classList.add('task-card--editable'); - const editForm = createEditForm(task); + const editForm = createEditForm(task, doc); editForm.hidden = true; card.appendChild(editForm); card.addEventListener('click', () => { editForm.hidden = !editForm.hidden; }); @@ -950,21 +961,20 @@ async function updateTask(taskId, body) { return res.json(); } -function createEditForm(task) { +function createEditForm(task, doc = document) { const a = task.agent || {}; - const form = document.createElement('div'); + const form = doc.createElement('div'); form.className = 'task-inline-edit'; // Prevent card-level click from toggling this form while user interacts inside it. form.addEventListener('click', (e) => e.stopPropagation()); function makeField(labelText, tag, attrs) { - const label = document.createElement('label'); + const label = doc.createElement('label'); label.textContent = labelText; - const el = document.createElement(tag); + const el = doc.createElement(tag); for (const [k, v] of Object.entries(attrs)) { - if (k === 'value') el.value = v; - else el.setAttribute(k, v); + el[k] = v; } label.appendChild(el); return label; @@ -973,17 +983,17 @@ function createEditForm(task) { form.appendChild(makeField('Name', 'input', { type: 'text', name: 'name', value: task.name || '' })); form.appendChild(makeField('Description', 'textarea', { name: 'description', rows: '2', value: task.description || '' })); form.appendChild(makeField('Instructions', 'textarea', { name: 'instructions', rows: '4', value: a.instructions || '' })); - + form.appendChild(makeField('Project Directory', 'input', { type: 'text', name: 'project_dir', value: a.project_dir || a.working_dir || '', placeholder: '/path/to/repo' })); form.appendChild(makeField('Max Budget (USD)', 'input', { type: 'number', name: 'max_budget_usd', step: '0.01', value: a.max_budget_usd != null ? String(a.max_budget_usd) : '1.00' })); form.appendChild(makeField('Timeout', 'input', { type: 'text', name: 'timeout', value: formatDurationForInput(task.timeout) || '15m', placeholder: '15m' })); - const prioLabel = document.createElement('label'); + const prioLabel = doc.createElement('label'); prioLabel.textContent = 'Priority'; - const prioSel = document.createElement('select'); + const prioSel = doc.createElement('select'); prioSel.name = 'priority'; for (const val of ['high', 'normal', 'low']) { - const opt = document.createElement('option'); + const opt = doc.createElement('option'); opt.value = val; opt.textContent = val.charAt(0).toUpperCase() + val.slice(1); if (val === (task.priority || 'normal')) opt.selected = true; @@ -992,20 +1002,20 @@ function createEditForm(task) { prioLabel.appendChild(prioSel); form.appendChild(prioLabel); - const errEl = document.createElement('div'); + const errEl = doc.createElement('div'); errEl.className = 'inline-edit-error'; errEl.hidden = true; form.appendChild(errEl); - const actions = document.createElement('div'); + const actions = doc.createElement('div'); actions.className = 'inline-edit-actions'; - const cancelBtn = document.createElement('button'); + const cancelBtn = doc.createElement('button'); cancelBtn.type = 'button'; cancelBtn.textContent = 'Cancel'; cancelBtn.addEventListener('click', () => { form.hidden = true; }); - const saveBtn = document.createElement('button'); + const saveBtn = doc.createElement('button'); saveBtn.type = 'button'; saveBtn.className = 'btn-primary btn-sm'; saveBtn.textContent = 'Save'; @@ -1059,7 +1069,7 @@ async function handleEditSave(taskId, form, saveBtn) { } } -function renderQuestionFooter(task, footer) { +function renderQuestionFooter(task, footer, doc = document) { // Prevent any tap inside the question footer from opening the detail panel. footer.addEventListener('click', (e) => e.stopPropagation()); @@ -1068,14 +1078,14 @@ function renderQuestionFooter(task, footer) { try { question = JSON.parse(task.question); } catch {} } - const questionEl = document.createElement('p'); + const questionEl = doc.createElement('p'); questionEl.className = 'task-question-text'; questionEl.textContent = question.text; footer.appendChild(questionEl); if (question.options && question.options.length > 0) { question.options.forEach(opt => { - const btn = document.createElement('button'); + const btn = doc.createElement('button'); btn.className = 'btn-answer'; btn.textContent = opt; btn.addEventListener('click', (e) => { @@ -1085,13 +1095,13 @@ function renderQuestionFooter(task, footer) { footer.appendChild(btn); }); } else { - const row = document.createElement('div'); + const row = doc.createElement('div'); row.className = 'task-answer-row'; - const input = document.createElement('input'); + const input = doc.createElement('input'); input.type = 'text'; input.className = 'task-answer-input'; input.placeholder = 'Your answer…'; - const btn = document.createElement('button'); + const btn = doc.createElement('button'); btn.className = 'btn-answer'; btn.textContent = 'Submit'; btn.addEventListener('click', (e) => { @@ -1115,9 +1125,9 @@ const STATE_EMOJI = { READY: '👀', BLOCKED: '⏸', }; -async function renderSubtaskRollup(task, footer) { +async function renderSubtaskRollup(task, footer, doc = document) { footer.addEventListener('click', (e) => e.stopPropagation()); - const container = document.createElement('div'); + const container = doc.createElement('div'); container.className = 'subtask-rollup'; footer.prepend(container); @@ -1129,10 +1139,10 @@ async function renderSubtaskRollup(task, footer) { container.textContent = blurb ? truncateToWordBoundary(blurb) : 'Waiting for subtasks…'; return; } - const ul = document.createElement('ul'); + const ul = doc.createElement('ul'); ul.className = 'subtask-list'; for (const st of subtasks) { - const li = document.createElement('li'); + const li = doc.createElement('li'); li.className = `subtask-item subtask-${st.state.toLowerCase()}`; li.textContent = `${STATE_EMOJI[st.state] || '•'} ${st.name}`; ul.appendChild(li); diff --git a/web/test/task-card-di.test.mjs b/web/test/task-card-di.test.mjs deleted file mode 100644 index e5dff74..0000000 --- a/web/test/task-card-di.test.mjs +++ /dev/null @@ -1,169 +0,0 @@ -// task-card-di.test.mjs — Unit tests for the createTaskCard DI doc param, -// log-tail placeholder (PENDING/QUEUED), and elapsed timer (RUNNING). -// -// Run with: node --test web/test/task-card-di.test.mjs - -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { createTaskCard } from '../app.js'; - -// ── Mock DOM ─────────────────────────────────────────────────────────────────── - -function makeEl(tag) { - return { - tag, - className: '', - textContent: '', - title: '', - hidden: false, - dataset: {}, - children: [], - classList: { add() {} }, - append(...nodes) { - for (const n of nodes) { - if (n != null && typeof n === 'object') this.children.push(n); - } - }, - appendChild(child) { - if (child != null) this.children.push(child); - return child; - }, - addEventListener() {}, - setAttribute() {}, - getAttribute() { return null; }, - }; -} - -function makeDoc() { - return { - createElement(tag) { return makeEl(tag); }, - }; -} - -// Depth-first search for first element with the given class name. -function findByClass(el, cls) { - for (const child of el.children || []) { - if (typeof child.className === 'string' && - child.className.split(' ').includes(cls)) return child; - const found = findByClass(child, cls); - if (found) return found; - } - return null; -} - -// ── Tests: DI doc param ──────────────────────────────────────────────────────── - -describe('createTaskCard doc DI param', () => { - it('accepts a custom doc and uses it for createElement', () => { - const created = []; - const doc = { - createElement(tag) { - const el = makeEl(tag); - created.push(tag); - return el; - }, - }; - createTaskCard({ id: 't1', state: 'QUEUED', name: 'Test Task' }, doc); - assert.ok(created.length > 0, 'doc.createElement should have been called'); - assert.ok(created.includes('div'), 'should create at least one div'); - assert.ok(created.includes('span'), 'should create at least one span'); - }); - - it('returns a card element whose tag is div', () => { - const card = createTaskCard({ id: 't1', state: 'QUEUED', name: 'Test' }, makeDoc()); - assert.equal(card.tag, 'div'); - assert.equal(card.className, 'task-card'); - assert.equal(card.dataset.taskId, 't1'); - }); -}); - -// ── Tests: log-tail placeholder (QUEUED) ────────────────────────────────────── - -describe('log-tail placeholder for queue column', () => { - it('QUEUED task has a task-log-placeholder element', () => { - const card = createTaskCard({ id: 't1', state: 'QUEUED', name: 'Test' }, makeDoc()); - const placeholder = findByClass(card, 'task-log-placeholder'); - assert.ok(placeholder, 'QUEUED card should have a task-log-placeholder element'); - }); - - it('QUEUED task log-tail placeholder text is "Waiting to start\u2026"', () => { - const card = createTaskCard({ id: 't1', state: 'QUEUED', name: 'Test' }, makeDoc()); - const placeholder = findByClass(card, 'task-log-placeholder'); - assert.equal(placeholder.textContent, 'Waiting to start\u2026'); - }); - - it('QUEUED task has a task-log-tail wrapper containing the placeholder', () => { - const card = createTaskCard({ id: 't1', state: 'QUEUED', name: 'Test' }, makeDoc()); - const logTail = findByClass(card, 'task-log-tail'); - assert.ok(logTail, 'QUEUED card should have a task-log-tail wrapper'); - const placeholder = findByClass(logTail, 'task-log-placeholder'); - assert.ok(placeholder, 'task-log-tail should contain task-log-placeholder'); - }); - - it('RUNNING task does not have a task-log-placeholder', () => { - const card = createTaskCard({ id: 't1', state: 'RUNNING', name: 'Test' }, makeDoc()); - const placeholder = findByClass(card, 'task-log-placeholder'); - assert.equal(placeholder, null, 'RUNNING card should not have a task-log-placeholder'); - }); - - it('COMPLETED task does not have a task-log-placeholder', () => { - const card = createTaskCard({ id: 't1', state: 'COMPLETED', name: 'Test' }, makeDoc()); - const placeholder = findByClass(card, 'task-log-placeholder'); - assert.equal(placeholder, null, 'COMPLETED card should not have a task-log-placeholder'); - }); -}); - -// ── Tests: elapsed timer (RUNNING) ──────────────────────────────────────────── - -describe('elapsed timer for RUNNING tasks', () => { - it('RUNNING task has a running-elapsed span', () => { - const card = createTaskCard( - { id: 't1', state: 'RUNNING', name: 'Test', updated_at: '2024-01-01T00:00:00Z' }, - makeDoc(), - ); - const elapsed = findByClass(card, 'running-elapsed'); - assert.ok(elapsed, 'RUNNING card should have a running-elapsed element'); - }); - - it('RUNNING task running-elapsed has data-started-at from task.updated_at', () => { - const card = createTaskCard( - { id: 't1', state: 'RUNNING', name: 'Test', updated_at: '2024-06-15T12:00:00Z' }, - makeDoc(), - ); - const elapsed = findByClass(card, 'running-elapsed'); - assert.equal(elapsed.dataset.startedAt, '2024-06-15T12:00:00Z'); - }); - - it('RUNNING task running-elapsed data-started-at is empty string when updated_at is absent', () => { - const card = createTaskCard( - { id: 't1', state: 'RUNNING', name: 'Test' }, - makeDoc(), - ); - const elapsed = findByClass(card, 'running-elapsed'); - assert.equal(elapsed.dataset.startedAt, ''); - }); - - it('running-elapsed is inside the card header', () => { - const card = createTaskCard( - { id: 't1', state: 'RUNNING', name: 'Test', updated_at: '2024-01-01T00:00:00Z' }, - makeDoc(), - ); - // The header is the first child of the card (a div.task-card-header). - const header = findByClass(card, 'task-card-header'); - assert.ok(header, 'card should have a task-card-header'); - const elapsed = findByClass(header, 'running-elapsed'); - assert.ok(elapsed, 'running-elapsed should be inside the task-card-header'); - }); - - it('QUEUED task does not have a running-elapsed span', () => { - const card = createTaskCard({ id: 't1', state: 'QUEUED', name: 'Test' }, makeDoc()); - const elapsed = findByClass(card, 'running-elapsed'); - assert.equal(elapsed, null, 'QUEUED card should not have a running-elapsed element'); - }); - - it('COMPLETED task does not have a running-elapsed span', () => { - const card = createTaskCard({ id: 't1', state: 'COMPLETED', name: 'Test' }, makeDoc()); - const elapsed = findByClass(card, 'running-elapsed'); - assert.equal(elapsed, null, 'COMPLETED card should not have a running-elapsed element'); - }); -}); diff --git a/web/test/tasks-board.test.mjs b/web/test/tasks-board.test.mjs index 560e8c8..db61880 100644 --- a/web/test/tasks-board.test.mjs +++ b/web/test/tasks-board.test.mjs @@ -10,6 +10,7 @@ import { TASK_COLUMNS, columnForTaskState, groupTasksByColumn, + createTaskCard, } from '../app.js'; describe('columnForTaskState', () => { @@ -146,3 +147,83 @@ describe('groupTasksByColumn', () => { } }); }); + +// ── 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); + }); +}); |
