diff --git a/packages/webui/test/browser/acceptance-record.ts b/packages/webui/test/browser/acceptance-record.ts index edb3ec08d..54a41ee95 100644 --- a/packages/webui/test/browser/acceptance-record.ts +++ b/packages/webui/test/browser/acceptance-record.ts @@ -18,7 +18,7 @@ // run, so a re-run cannot leave a case behind from the previous one, and the // suites cannot overwrite each other's records. -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { release } from "node:os"; import { resolve } from "node:path"; @@ -79,6 +79,55 @@ const PLAN_DIRECTORIES = { "workspace-browser-tab": "../../../../.plan/workspace-browser-tab/complete", } as const; +/** + * The record files as the checkout had them. + * + * A record file is **tracked**: the plan's `complete/` directory is where the + * accepted evidence lives, and the file in the checkout is the record of the + * run that was accepted. A run rewrites it — every record carries the time + * and the machine it ran on — so the working tree is dirty once the suite is + * over. + * + * That dirt is not a source change, and the release packaging refuses to run + * with a dirty tree ("Packaging requires a clean committed working tree"), so + * on CI the run puts the files back when it is over. + * + * Locally the records stay: they are what the run just produced, and the + * published matrix is written from them. A suite that recorded into a file + * the checkout does not have leaves no file behind either. + */ +const originals = new Map(); + +/** Remembers a record file's content the first time the run writes to it. */ +function rememberOriginal(path: string): void { + if (originals.has(path)) return; + originals.set(path, existsSync(path) ? readFileSync(path, "utf8") : null); +} + +/** + * Puts the record files back the way the checkout had them. + * + * On CI. A record file is tracked, so a run leaves the working tree dirty — + * every record carries the time and the machine it ran on — and the release + * packaging refuses to run with a dirty tree ("Packaging requires a clean + * committed working tree"). That dirt is the run's own timestamps, not a + * source change, so the suite puts the files back when it is done. + * + * Locally the records stay: they are what the run just produced, and the + * published matrix is written from them. + */ +export function restoreAcceptanceRecords(): void { + if (process.env.CI !== "true") return; + for (const [path, content] of originals) { + if (content === null) { + rmSync(path, { force: true }); + continue; + } + writeFileSync(path, content, "utf8"); + } + originals.clear(); +} + /** A plan whose acceptance results are recorded. */ export type WebuiAcceptancePlan = keyof typeof PLAN_DIRECTORIES; @@ -119,7 +168,9 @@ export function acceptanceRecordsFor(plan: WebuiAcceptancePlan): AcceptanceRecor */ function beginSuite(suite: string): void { mkdirSync(recordDir, { recursive: true }); - writeFileSync(resolve(recordDir, `${suite}.acceptance.json`), "[]\n", "utf8"); + const path = resolve(recordDir, `${suite}.acceptance.json`); + rememberOriginal(path); + writeFileSync(path, "[]\n", "utf8"); started.add(suite); } @@ -139,6 +190,7 @@ export function acceptanceRecordsFor(plan: WebuiAcceptancePlan): AcceptanceRecor }; mkdirSync(recordDir, { recursive: true }); const path = resolve(recordDir, `${suite}.acceptance.json`); + rememberOriginal(path); if (!started.has(suite)) { started.add(suite); writeFileSync(path, "[]\n", "utf8"); diff --git a/packages/webui/test/browser/browser-bridge-real-browser.spec.ts b/packages/webui/test/browser/browser-bridge-real-browser.spec.ts index 6f03d3748..36680085d 100644 --- a/packages/webui/test/browser/browser-bridge-real-browser.spec.ts +++ b/packages/webui/test/browser/browser-bridge-real-browser.spec.ts @@ -24,7 +24,7 @@ import { rm } from "node:fs/promises"; import { expect, test } from "@playwright/test"; -import { beginAcceptanceSuite, observeAcceptance } from "./acceptance-record.js"; +import { beginAcceptanceSuite, observeAcceptance, restoreAcceptanceRecords } from "./acceptance-record.js"; import { browserVersionOf, bridgeSettings, @@ -275,4 +275,10 @@ test.describe("the browser bridge, against a real browser", () => { } } }); + + // On CI the record file is tracked and the run's own timestamps are not a + // source change, so the file goes back the way the checkout had it. + test.afterAll(() => { + restoreAcceptanceRecords(); + }); }); diff --git a/packages/webui/test/browser/browser-bridge-real-external.spec.ts b/packages/webui/test/browser/browser-bridge-real-external.spec.ts index 61059be9c..d8cc3129a 100644 --- a/packages/webui/test/browser/browser-bridge-real-external.spec.ts +++ b/packages/webui/test/browser/browser-bridge-real-external.spec.ts @@ -50,7 +50,7 @@ async function extensionLastError( } import type { WebuiBrowserTargetState } from "../../src/shared/contracts/browser.js"; -import { beginAcceptanceSuite, observeAcceptance } from "./acceptance-record.js"; +import { beginAcceptanceSuite, observeAcceptance, restoreAcceptanceRecords } from "./acceptance-record.js"; import { browserVersionOf, bridgeSettings, @@ -327,6 +327,12 @@ test.describe("the external bridge, against a real browser and a real page", () } }); } + + // On CI the record file is tracked and the run's own timestamps are not a + // source change, so the file goes back the way the checkout had it. + test.afterAll(() => { + restoreAcceptanceRecords(); + }); }); /** Waits for the extension to announce a tab. */ diff --git a/packages/webui/test/browser/browser-bridge-real-packaged.spec.ts b/packages/webui/test/browser/browser-bridge-real-packaged.spec.ts index 7df409790..ced171e27 100644 --- a/packages/webui/test/browser/browser-bridge-real-packaged.spec.ts +++ b/packages/webui/test/browser/browser-bridge-real-packaged.spec.ts @@ -31,7 +31,7 @@ import { resolve } from "node:path"; import { expect, test } from "@playwright/test"; -import { beginAcceptanceSuite, observeAcceptance } from "./acceptance-record.js"; +import { beginAcceptanceSuite, observeAcceptance, restoreAcceptanceRecords } from "./acceptance-record.js"; import { browserVersionOf, bridgeSettings, @@ -262,4 +262,10 @@ test.describe("the packaged production entry, against a real browser", () => { } }); } + + // On CI the record file is tracked and the run's own timestamps are not a + // source change, so the file goes back the way the checkout had it. + test.afterAll(() => { + restoreAcceptanceRecords(); + }); }); diff --git a/packages/webui/test/integration/workspace-browser-tab-acceptance.test.ts b/packages/webui/test/integration/workspace-browser-tab-acceptance.test.ts index 7e965d9b3..02aea1ec9 100644 --- a/packages/webui/test/integration/workspace-browser-tab-acceptance.test.ts +++ b/packages/webui/test/integration/workspace-browser-tab-acceptance.test.ts @@ -41,7 +41,7 @@ import { SEND_BROWSER_COMMAND_OPERATION_NAME, SEND_BROWSER_INPUT_OPERATION_NAME, } from "../../src/shared/operation-names.js"; -import { acceptanceRecordsFor } from "../browser/acceptance-record.js"; +import { acceptanceRecordsFor, restoreAcceptanceRecords } from "../browser/acceptance-record.js"; import type { WebuiBrowserAuthorization, WebuiBrowserTargetId, @@ -54,6 +54,18 @@ const SUITE = "workspace-browser-tab"; /** How long a page is given to settle after a move. */ const SETTLE_MS = 750; +/** + * How long a case may take. + * + * A case drives a real browser: the first one launches it, opens a tab and + * waits for the strip to gain it. The default budget (5s) is smaller than a + * cold browser start on a loaded runner, and a case that runs out of time + * leaves the browser half launched — the next case then finds a browser that + * exited during startup and fails too. The sibling suite + * (`browser-panel-live-navigation.test.ts`) has always given its cases 30s. + */ +const TIMEOUT_MS = 30_000; + async function chromeIsAvailable(): Promise { for (const candidate of ["/usr/bin/google-chrome", "/usr/bin/chromium"]) { try { @@ -123,6 +135,9 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model", afterAll(async () => { await service?.close(); if (dataDir) await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + // On CI the record file is tracked and the run's own timestamps are not a + // source change, so the file goes back the way the checkout had it. + restoreAcceptanceRecords(); }); // -- the paths the cases drive ------------------------------------------ @@ -300,7 +315,7 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model", expect(state.tabs[0]?.content.kind).toBe("web"); }, ); - }); + }, TIMEOUT_MS); it("the Agent navigates after opening, and the picture follows", async () => { await record( @@ -340,7 +355,7 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model", expect(frames.length).toBeGreaterThan(0); }, ); - }); + }, TIMEOUT_MS); it("the Agent acts on a page it did not open, and a stale reference is refused", async () => { await record( @@ -377,7 +392,7 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model", ).rejects.toMatchObject({ code: "browser_target_stale" }); }, ); - }); + }, TIMEOUT_MS); it("a person opens a tab while the Agent works, and the two pages differ", async () => { await record( @@ -408,7 +423,7 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model", expect(await readTitle(personPage!)).toContain("person"); }, ); - }); + }, TIMEOUT_MS); it("a person opens a tab while the run has no selection, and the run gets it", async () => { await record( @@ -437,7 +452,7 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model", expect(await readTitle(page!)).toContain("acted on"); }, ); - }); + }, TIMEOUT_MS); it("a person types into their own tab while the Agent works", async () => { await record( @@ -505,7 +520,7 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model", expect(await readField(agentPage)).toBe(""); }, ); - }); + }, TIMEOUT_MS); it("a person types a url in the launcher, and the tab becomes that page", async () => { await record( @@ -537,7 +552,7 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model", expect((commands[1] as { readonly type: string }).type).toBe("open-web-tab"); }, ); - }); + }, TIMEOUT_MS); it("a person closes a web tab, and the page is released", async () => { await record( @@ -577,7 +592,7 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model", expect(await runSelection("run-6")).toBeUndefined(); }, ); - }); + }, TIMEOUT_MS); it("back and forward move through the page's own history", async () => { await record( @@ -625,7 +640,7 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model", expect(await readTitle(back)).toContain("history two"); }, ); - }); + }, TIMEOUT_MS); it("the surface's switch is off, and both paths are refused", async () => { await record( @@ -671,7 +686,7 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model", } }, ); - }); + }, TIMEOUT_MS); it("a run with no selection is refused every action but open_tab", async () => { await record( @@ -695,7 +710,7 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model", expect(opened).toBeDefined(); }, ); - }); + }, TIMEOUT_MS); it("a session ends, and its pages are released", async () => { await record( @@ -719,7 +734,7 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model", expect(await runSelection("run-8")).toBeUndefined(); }, ); - }); + }, TIMEOUT_MS); it("a second web tab in one session is addressable, and the strip shows both", async () => { await record( @@ -759,7 +774,7 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model", expect(webuiWorkspaceTabStripReconcile([], published)).toHaveLength(2); }, ); - }); + }, TIMEOUT_MS); it("a page navigates, and a stale reference is refused on every tab", async () => { await record( @@ -798,5 +813,5 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model", ).resolves.toMatchObject({ accepted: true }); }, ); - }); + }, TIMEOUT_MS); });