Files
open-design/e2e/tests/packaged/failure-evidence.test.ts
lefarcenandGitHub 25475defab fix(e2e): stop the packaged mac first-run gate failing on budget alone (#7899)
The `[P0] cold first Home run` packaged mac case has been red on almost
every beta build for eleven days (1 pass in 12 attempts) and nobody saw
it, because release-beta's smoke step is `continue-on-error: true`.

Four changes, in the order they matter:

1. Capture evidence before cleanup. A failing case uninstalled the app
   and the next case deleted the runtime namespace, so the only surviving
   trace was `logs: {skipped: true}`. The failure path now writes the
   desktop log, a raw `/api/runs` diagnostics snapshot, and the error into
   the release report before anything is torn down.

2. Give the wait a budget that matches the chain, split in two. 15s had to
   cover cold project creation through rendered output; it is now 60s for
   "the daemon owns a finished run" plus 30s for "the output surfaced", so
   a timeout says which half broke instead of leaving three observations
   that cannot falsify each other. Polling moved inside the page: every
   sample used to cost a `tools-pack mac inspect` process, which ate about
   a third of the old budget on its own.

3. Drop the injected 503. `POST /api/runs` does not answer
   WORKSPACE_AUTHORITY_UNAVAILABLE — local run creation is deliberately
   independent of Workspace membership availability — so the case spent
   an extra create round trip plus a 500ms backoff on a branch production
   cannot reach. Run-create retry semantics stay covered by
   apps/web/tests/providers/sse.test.ts.

4. Make the exempted red audible. The exemption stays (at a 1/12 pass rate
   a blocking gate would stall the daily beta), but `tools-release
   write-report` now turns a failed smoke into a run annotation and a
   caution banner at the top of the step summary.
2026-09-08 11:38:41 +00:00

113 lines
4.3 KiB
TypeScript

import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
capturePackagedFailureEvidence,
packagedEvidenceRelpath,
PACKAGED_FAILURE_EVIDENCE_DIR,
} from '@/vitest/packaged-failure-evidence';
import { createReport } from '@/vitest/report';
const scratchRoots: string[] = [];
async function createScratchReport() {
const root = await mkdtemp(join(tmpdir(), 'od-packaged-evidence-'));
scratchRoots.push(root);
return { report: await createReport(root), root };
}
afterEach(async () => {
while (scratchRoots.length > 0) {
const root = scratchRoots.pop();
if (root != null) await rm(root, { force: true, recursive: true });
}
});
describe('packaged failure evidence paths', () => {
it('keeps every capture inside the report root even for hostile names', () => {
expect(packagedEvidenceRelpath(PACKAGED_FAILURE_EVIDENCE_DIR, 'runs.json'))
.toBe('first-run-failure/runs.json');
expect(packagedEvidenceRelpath('first-run-failure', '../../escape.log'))
.toBe('first-run-failure/escape.log');
expect(packagedEvidenceRelpath('first-run-failure', '/etc/passwd'))
.toBe('first-run-failure/etc-passwd');
expect(packagedEvidenceRelpath('first-run-failure', '')).toBe('first-run-failure/unnamed');
});
});
describe('packaged failure evidence capture', () => {
it('writes every source into the report and records them in a manifest', async () => {
const { report, root } = await createScratchReport();
const entries = await capturePackagedFailureEvidence(report, PACKAGED_FAILURE_EVIDENCE_DIR, [
{ name: 'error.txt', read: async () => 'stage timed out\n' },
{ name: 'runs.json', read: async () => '{"runs":[]}\n' },
{ name: 'desktop-latest.log', read: async () => Uint8Array.from([104, 105]) },
]);
expect(entries.map((entry) => entry.status)).toEqual(['saved', 'saved', 'saved']);
expect(await readFile(join(root, 'first-run-failure', 'error.txt'), 'utf8'))
.toBe('stage timed out\n');
expect(await readFile(join(root, 'first-run-failure', 'desktop-latest.log'), 'utf8')).toBe('hi');
const manifest = JSON.parse(
await readFile(join(root, 'first-run-failure', 'index.json'), 'utf8'),
) as { entries: Array<{ name: string; relpath: string }> };
expect(manifest.entries.map((entry) => entry.relpath)).toEqual([
'first-run-failure/error.txt',
'first-run-failure/runs.json',
'first-run-failure/desktop-latest.log',
]);
});
it('keeps capturing after an unreadable source and records why it failed', async () => {
// A failed packaged case is usually failing *because* something is broken,
// so the first source that cannot be read is the normal case, not the
// exceptional one. Losing the remaining evidence to it would reproduce the
// exact blind spot this capture exists to remove.
const { report, root } = await createScratchReport();
const entries = await capturePackagedFailureEvidence(report, PACKAGED_FAILURE_EVIDENCE_DIR, [
{
name: 'runs.json',
read: async () => {
throw new Error('desktop IPC socket is gone');
},
},
{ name: 'error.txt', read: async () => 'stage timed out\n' },
]);
expect(entries[0]).toMatchObject({ name: 'runs.json', status: 'failed' });
expect(entries[0]?.detail).toContain('desktop IPC socket is gone');
expect(entries[1]).toMatchObject({ name: 'error.txt', status: 'saved' });
expect(await readFile(join(root, 'first-run-failure', 'error.txt'), 'utf8'))
.toBe('stage timed out\n');
});
it('never throws out of the cleanup path, even when the report itself is broken', async () => {
const brokenReport = {
json: async () => {
throw new Error('report root is read-only');
},
save: async () => {
throw new Error('report root is read-only');
},
};
const entries = await capturePackagedFailureEvidence(brokenReport, PACKAGED_FAILURE_EVIDENCE_DIR, [
{ name: 'error.txt', read: async () => 'stage timed out\n' },
]);
expect(entries).toEqual([
{
detail: 'Error: report root is read-only',
name: 'error.txt',
relpath: 'first-run-failure/error.txt',
status: 'failed',
},
]);
});
});