// tests/sse-run-recorder.test.ts import { describe, expect, test } from 'bun:test'; import { createJobResponse, type RunRecorder } from '../src/lib/server/sse'; import { getJob } from '../src/lib/server/jobs'; /** A fake RunRecorder that records every line() call and resolves `ended` when end() fires. */ function createFakeRecorder() { const lines: string[] = []; let resolveEnded!: (v: { ok: boolean; exitCode?: number; error?: string }) => void; const ended = new Promise<{ ok: boolean; exitCode?: number; error?: string }>((resolve) => { resolveEnded = resolve; }); const recorder: RunRecorder = { line(line: string) { lines.push(line); }, addSecrets() { // Not exercised by this file's tests -- they cover createJobResponse's own // line()/end() plumbing, not the F4 addSecrets() wiring (see // deploy-run-record.test.ts and the route-level regression tests for that). }, setContentHashes() {}, async end(ok: boolean, exitCode?: number, error?: string) { resolveEnded({ ok, exitCode, error }); } }; return { recorder, lines, ended }; } /** Sends a status line (not recordable), two progress lines (recordable), and an * unredacted result (never recordable) -- exercises shouldRecord's real filter. */ const runOperation = async (send: (event: string, data: unknown) => void) => { send('progress', { status: 'Deploying stack...' }); send('progress', { type: 'line', line: 'Container web Started' }); send('progress', { type: 'line', line: 'Container web Healthy' }); send('result', { success: true, output: 'DB_PASSWORD=hunter2' }); }; describe('createJobResponse RunRecorder hook', () => { test('records qualifying lines on the JSON path (Accept: application/json)', async () => { const { recorder, lines, ended } = createFakeRecorder(); const request = new Request('http://x', { headers: { Accept: 'application/json' } }); const response = createJobResponse(runOperation, request, recorder); await response.json(); const end = await ended; expect(lines).toEqual(['Container web Started', 'Container web Healthy']); expect(end.ok).toBe(true); expect(end.error).toBeUndefined(); }); test('records the SAME qualifying lines on the streaming path (no Accept header)', async () => { const { recorder, lines, ended } = createFakeRecorder(); const request = new Request('http://x'); createJobResponse(runOperation, request, recorder); await ended; expect(lines).toEqual(['Container web Started', 'Container web Healthy']); }); test('both send() implementations feed the recorder identical lines', async () => { const jsonSide = createFakeRecorder(); const streamSide = createFakeRecorder(); const jsonResponse = createJobResponse( runOperation, new Request('http://x', { headers: { Accept: 'application/json' } }), jsonSide.recorder ); await jsonResponse.json(); await jsonSide.ended; createJobResponse(runOperation, new Request('http://x'), streamSide.recorder); await streamSide.ended; expect(streamSide.lines).toEqual(jsonSide.lines); expect(jsonSide.lines).toEqual(['Container web Started', 'Container web Healthy']); }); test('streaming path: end() reports ok=false with the error message when the operation throws', async () => { const { recorder, ended } = createFakeRecorder(); createJobResponse( async () => { throw new Error('boom'); }, new Request('http://x'), recorder ); const end = await ended; expect(end.ok).toBe(false); expect(end.error).toBe('boom'); }); test('streaming path: end() reports ok=false with the error message on a failed result that RESOLVES (no throw) -- this is the path every browser call takes, since neither +page.svelte nor StackModal.svelte set an Accept header', async () => { const { recorder, ended } = createFakeRecorder(); const request = new Request('http://x'); // no Accept header -> fire-and-forget path createJobResponse( async (send) => { // A failed docker compose up does NOT throw -- it sends a failed result and // returns normally. That lands this in the .then() branch below, never .catch(). send('result', { success: false, error: 'compose up failed: exit 1' }); }, request, recorder ); const end = await ended; expect(end.ok).toBe(false); expect(end.error).toBe('compose up failed: exit 1'); }); // Companion to the test above: a resolving (non-throwing) SUCCESS must still end up // ok=true on the streaming path, so the fix doesn't flip the polarity for the common case. test('streaming path: end() reports ok=true on a successful result that resolves (no throw)', async () => { const { recorder, ended } = createFakeRecorder(); const request = new Request('http://x'); createJobResponse( async (send) => { send('result', { success: true, output: 'done' }); }, request, recorder ); const end = await ended; expect(end.ok).toBe(true); expect(end.error).toBeUndefined(); }); test('JSON path: end() reports ok=false with the error message on a failed result', async () => { const { recorder, ended } = createFakeRecorder(); const request = new Request('http://x', { headers: { Accept: 'application/json' } }); const response = createJobResponse( async (send) => { send('result', { success: false, error: 'compose failed' }); }, request, recorder ); await response.json(); const end = await ended; expect(end.ok).toBe(false); expect(end.error).toBe('compose failed'); }); test('streaming path: a recorder.end() that itself throws is never called twice', async () => { // Guards RunRecorder's "end() is called exactly once" contract from sse.ts's own // side: if the .then() branch's recorder.end() call throws, that rejection must // not cause .catch() to invoke end() again. let calls = 0; const recorder: RunRecorder = { line() {}, addSecrets() {}, setContentHashes() {}, async end() { calls++; throw new Error('recorder failure'); } }; const request = new Request('http://x'); // no Accept header -> fire-and-forget path createJobResponse( async (send) => { send('result', { success: true, output: 'done' }); }, request, recorder ); // Let the .then()/.catch() microtasks (including the rejected recorder.end() call // and its handling) settle before asserting. await new Promise((resolve) => setTimeout(resolve, 20)); expect(calls).toBe(1); }); test('JSON path: a recorder.end() that throws does NOT break the response body', async () => { // The recorder is a best-effort side effect (writes the run log); a failure to // close it must never prevent the client from getting its JSON result. const recorder: RunRecorder = { line() {}, addSecrets() {}, setContentHashes() {}, async end() { throw new Error('log write failed'); } }; const request = new Request('http://x', { headers: { Accept: 'application/json' } }); const response = createJobResponse( async (send) => { send('result', { success: true, output: 'ok' }); }, request, recorder ); const body = await response.json(); expect(body).toEqual({ success: true, output: 'ok' }); }); test('streaming path: a recorder.end() that throws does NOT flip a successful job to failed', async () => { // A throwing end() in the .then() branch must not reach .catch() (which would // failJob a run that actually succeeded) -- end() is best-effort. Asserting the // job STATUS (not just the end() call count) is what distinguishes the fix: the // pre-existing recorderEnded guard already kept calls===1, but without the // try/catch the throw propagated to .catch() and failJob flipped 'done' -> 'error'. const recorder: RunRecorder = { line() {}, addSecrets() {}, setContentHashes() {}, async end(ok: boolean) { if (ok) throw new Error('log write failed'); // only the success close throws } }; const request = new Request('http://x'); // fire-and-forget path const response = createJobResponse( async (send) => { send('result', { success: true, output: 'done' }); }, request, recorder ); const { jobId } = await response.json(); await new Promise((resolve) => setTimeout(resolve, 20)); const job = getJob(jobId); expect(job?.status).toBe('done'); const result = job?.lines.findLast((l) => l.event === 'result')?.data as { success?: boolean } | undefined; expect(result?.success).toBe(true); }); test('omitting the recorder changes nothing (backward compat)', async () => { const request = new Request('http://x', { headers: { Accept: 'application/json' } }); const response = createJobResponse(runOperation, request); const body = await response.json(); expect(body).toEqual({ success: true, output: 'DB_PASSWORD=hunter2' }); }); });