import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { buildApp } from '../src/server/index.js'; import { init } from '../src/cli/commands/init.js'; import { eventBus } from '../src/server/events.js'; import { readTaskLog } from '../src/core/services/taskLogService.js'; import { remoteClient } from '../src/cli/remoteClient.js'; describe('task live console (TSK-0074)', () => { let cwd: string; let app: ReturnType; beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), 'ah-tasklog-')); init(cwd, { projectName: 'tasklog-test', yes: true }); app = buildApp(cwd); }); afterEach(() => { eventBus.removeAllListeners('log'); eventBus.removeAllListeners('change'); rmSync(cwd, { recursive: true, force: true }); }); it('PATCH in_progress appends a status log line + emits exactly one task-log event (no double change)', async () => { await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } }); const logs: Array<{ taskId?: string; text?: string; level?: string }> = []; const changes: unknown[] = []; const onLog = (p: { taskId?: string; text?: string; level?: string }) => logs.push(p); const onChange = (e: unknown) => changes.push(e); eventBus.on('log', onLog); eventBus.on('change', onChange); await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } }); eventBus.off('log', onLog); eventBus.off('change', onChange); // Exactly one task-log SSE event, carrying the task id + a "Claimed by" line. expect(logs).toHaveLength(1); expect(logs[0].taskId).toBe('TSK-0001'); expect(logs[0].text).toContain('Claimed by codex'); expect(logs[0].level).toBe('status'); // No double-emission on the change channel (one emitChange per PATCH). expect(changes).toHaveLength(1); // Persisted to the task's .log file. const persisted = readTaskLog(cwd, 'TSK-0001'); expect(persisted.some((e) => e.text.includes('Claimed by codex') && e.level === 'status')).toBe(true); }); it('logs every status transition (review, done)', async () => { await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } }); await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } }); await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } }); await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } }); const texts = readTaskLog(cwd, 'TSK-0001').map((e) => e.text); expect(texts.some((t) => t.startsWith('Claimed by'))).toBe(true); expect(texts.some((t) => t.startsWith('Submitted for review'))).toBe(true); expect(texts.some((t) => t.startsWith('Approved — done'))).toBe(true); }); it('task detail HTML renders the Live Console with historic lines', async () => { await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks/TSK-0001/log', payload: { text: 'wrote failing test', agent: 'codex', level: 'info' } }); const res = await app.inject({ method: 'GET', url: '/tasks/TSK-0001', headers: { accept: 'text/html' } }); expect(res.statusCode).toBe(200); expect(res.payload).toContain('Live Console'); expect(res.payload).toContain('wrote failing test'); // Live tail wires to the named task-log SSE event, scoped to this task id. expect(res.payload).toContain("addEventListener('task-log'"); expect(res.payload).toContain('"TSK-0001"'); }); it('POST /tasks/:id/log returns the stored entry', async () => { await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); const res = await app.inject({ method: 'POST', url: '/tasks/TSK-0001/log', payload: { text: 'green: 12 tests', level: 'status' } }); expect(res.statusCode).toBe(200); const entry = JSON.parse(res.payload) as { text: string; level: string; ts: string }; expect(entry.text).toBe('green: 12 tests'); expect(entry.level).toBe('status'); expect(entry.ts).toBeTruthy(); }); it('remoteClient.appendTaskLog POSTs to /tasks/:id/log', async () => { vi.restoreAllMocks(); const stored = { ts: new Date().toISOString(), text: 'progress', level: 'info' }; globalThis.fetch = vi.fn().mockResolvedValueOnce({ ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(stored)), } as Response); const res = await remoteClient.appendTaskLog('http://localhost:3377', 'TSK-0001', { text: 'progress', agent: 'codex' }); expect(res.text).toBe('progress'); const call = vi.mocked(fetch).mock.calls[0]; expect(String(call[0])).toBe('http://localhost:3377/tasks/TSK-0001/log'); expect((call[1] as RequestInit).method).toBe('POST'); }); });