Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions packages/webui/test/browser/acceptance-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string, string | null>();

/** 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;

Expand Down Expand Up @@ -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);
}

Expand All @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<boolean> {
for (const candidate of ["/usr/bin/google-chrome", "/usr/bin/chromium"]) {
try {
Expand Down Expand Up @@ -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 ------------------------------------------
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -798,5 +813,5 @@ describe.skipIf(!available)("the acceptance of the workspace browser tab model",
).resolves.toMatchObject({ accepted: true });
},
);
});
}, TIMEOUT_MS);
});
Loading