246 lines
8.6 KiB
TypeScript
246 lines
8.6 KiB
TypeScript
// 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' });
|
|
});
|
|
});
|