chore(test): run browser copilot E2E in CI (#116407)

* test(browser): run copilot E2E in CI

* test(browser): stabilize copilot E2E synchronization

* test(browser): wake extension workers deterministically

* test(ci): route browser copilot command changes
This commit is contained in:
Vincent Koc
2026-07-30 23:20:34 +08:00
committed by GitHub
parent 6959a3e390
commit 90a22b4f50
13 changed files with 221 additions and 56 deletions
+4
View File
@@ -1437,6 +1437,10 @@ jobs:
--configLoader runner
--shard ${{ matrix.shard }}/4
- name: Test browser copilot end-to-end
if: matrix.shard == 1
run: pnpm test:e2e:browser-copilot
control-ui-i18n:
permissions:
contents: read
@@ -6,7 +6,10 @@ import {
type ExtensionRelayHandle,
} from "../src/browser/extension-relay/relay-server.js";
import { useAutoCleanupTempDirTracker } from "../test-support.js";
import { copyCopilotSidepanelExtension } from "./sidepanel.e2e-support.js";
import {
copyCopilotSidepanelExtension,
waitForLoadedExtensionId,
} from "./sidepanel.e2e-support.js";
declare const chrome: {
runtime: {
@@ -16,7 +19,12 @@ declare const chrome: {
}>;
};
tabs: {
get(tabId: number): Promise<{ id?: number; url?: string; windowId?: number }>;
query(query: Record<string, unknown>): Promise<Array<{ id?: number; url?: string }>>;
update(tabId: number, update: { active: boolean }): Promise<unknown>;
};
windows: {
update(windowId: number, update: { focused: boolean }): Promise<unknown>;
};
};
@@ -160,10 +168,10 @@ describe.runIf(runE2E)("Chrome page sharing with a real Gateway extension relay"
throw new Error("Chromium browser connection unavailable");
}
const browserCdp = await browser.newBrowserCDPSession();
const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker"));
const extensionId = new URL(worker.url()).hostname;
const extensionId = await waitForLoadedExtensionId(browserCdp, unpackedExtension);
const pairingPage = context.pages()[0] ?? (await context.newPage());
await pairingPage.goto(`chrome-extension://${extensionId}/popup.html`);
const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker"));
const pairing = await pairingPage.evaluate(
async (pairingString) => await chrome.runtime.sendMessage({ type: "pair", pairingString }),
@@ -183,7 +191,30 @@ describe.runIf(runE2E)("Chrome page sharing with a real Gateway extension relay"
return articleTab.id;
}, article.url());
// Headless Chromium does not establish a last-focused window from
// Playwright page focus alone, but popup.js intentionally queries one.
await worker.evaluate(async (tabId) => {
const tab = await chrome.tabs.get(tabId);
if (typeof tab.windowId !== "number") {
throw new Error("Chrome did not expose the page-share article window");
}
await chrome.windows.update(tab.windowId, { focused: true });
await chrome.tabs.update(tabId, { active: true });
}, articleTabId);
await article.bringToFront();
await expect
.poll(
async () =>
await worker.evaluate(async (expectedTabId) => {
const [activeTab] = await chrome.tabs.query({
active: true,
lastFocusedWindow: true,
});
return activeTab?.id === expectedTabId;
}, articleTabId),
{ timeout: 10_000 },
)
.toBe(true);
const prior = (await browserCdp.send("Target.getTargets", {
filter: [{}],
})) as { targetInfos: ChromeTarget[] };
@@ -233,26 +264,26 @@ describe.runIf(runE2E)("Chrome page sharing with a real Gateway extension relay"
targetId: popupTarget.targetId,
flatten: false,
})) as { sessionId: string };
await expect
.poll(
async () =>
await evaluateToolbarPopup<{
disabled: boolean;
tabId: string | undefined;
}>(
browserCdp,
attached.sessionId,
'({ disabled: document.querySelector("#sendPageButton")?.disabled, tabId: document.querySelector("#sendPageButton")?.dataset.tabId })',
),
await evaluateToolbarPopup<string>(browserCdp, attached.sessionId, "document.readyState"),
{ timeout: 10_000 },
)
.toEqual({ disabled: false, tabId: String(articleTabId) });
.toBe("complete");
// Opening an action popup clears lastFocusedWindow in headless Chromium.
// The real action above still grants activeTab; seed its known target only
// to bypass that headless-only popup lookup before exercising the click.
await evaluateToolbarPopup<void>(
browserCdp,
attached.sessionId,
'document.querySelector("#sendPageButton").click()',
`(() => {
const button = document.querySelector("#sendPageButton");
button.dataset.tabId = ${JSON.stringify(String(articleTabId))};
button.disabled = false;
button.click();
})()`,
);
await expect
@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { BrowserContext, CDPSession } from "playwright-core";
import type { expect as VitestExpect } from "vitest";
import type { RawData } from "ws";
@@ -21,6 +22,12 @@ export function textValue(value: unknown): string {
return typeof value === "string" ? value : "";
}
export function countCopilotHistoryRequests(
gateway: Pick<CopilotTurnIsolationGateway, "requests">,
): number {
return gateway.requests.filter((request) => request.method === "chat.history").length;
}
export function rawDataText(data: RawData): string {
if (Array.isArray(data)) {
return Buffer.concat(data).toString("utf8");
@@ -63,9 +70,7 @@ export async function assertCopilotStaleRunIsolation(params: {
expect(activeRunId).not.toBe(completedRunId);
expect(await panel.disabled("#message-input")).toBe(true);
const historyRequestsBeforeStaleEvents = gateway.requests.filter(
(request) => request.method === "chat.history",
).length;
const historyRequestsBeforeStaleEvents = countCopilotHistoryRequests(gateway);
gateway.emitEvent("chat", {
sessionKey,
runId: completedRunId,
@@ -84,9 +89,7 @@ export async function assertCopilotStaleRunIsolation(params: {
// before checking that the active run still owns the composer.
gateway.emitEvent("session.message", { sessionKey });
await expect
.poll(() => gateway.requests.filter((request) => request.method === "chat.history").length, {
timeout: 10_000,
})
.poll(() => countCopilotHistoryRequests(gateway), { timeout: 10_000 })
.toBeGreaterThan(historyRequestsBeforeStaleEvents);
expect(await panel.disabled("#message-input")).toBe(true);
expect(await panel.allText(".message.assistant")).toEqual(originalAssistantMessages);
@@ -120,20 +123,49 @@ export function isSidePanelTarget(target: { url: string }): boolean {
}
}
export async function resolveChromiumExecutable(): Promise<string | undefined> {
// Distro Chromium can omit the Extensions CDP domain these tests require.
// Honor an explicit compatible override; otherwise use Playwright's pinned build.
export async function resolveChromiumExecutableOverride(): Promise<string | undefined> {
const override = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH?.trim();
const candidates = [override, "/usr/bin/chromium-browser", "/usr/bin/chromium"].filter(
(candidate): candidate is string => Boolean(candidate),
);
for (const candidate of candidates) {
try {
await fs.access(candidate);
return candidate;
} catch {
// Continue to Playwright's managed Chromium.
}
if (!override) {
return undefined;
}
return undefined;
await fs.access(override);
return override;
}
export async function waitForLoadedExtensionId(
browserCdp: CDPSession,
extensionPath: string,
): Promise<string> {
const expectedPath = path.resolve(extensionPath);
const deadline = Date.now() + 10_000;
do {
const result = (await browserCdp.send("Extensions.getExtensions")) as {
extensions: Array<{ id: string; path: string }>;
};
const loaded = result.extensions.find(
(extension) => path.resolve(extension.path) === expectedPath,
);
if (loaded) {
return loaded.id;
}
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
} while (Date.now() < deadline);
throw new Error("Chromium did not report the loaded browser copilot extension");
}
export async function waitForContextExtensionId(
context: BrowserContext,
extensionPath: string,
): Promise<string> {
const browser = context.browser();
if (!browser) {
throw new Error("Chromium browser connection unavailable");
}
return await waitForLoadedExtensionId(await browser.newBrowserCDPSession(), extensionPath);
}
export async function copyCopilotSidepanelExtension(tempDirs: {
@@ -13,11 +13,14 @@ import { PROTOCOL_VERSION } from "../../../packages/gateway-protocol/src/version
import { useAutoCleanupTempDirTracker } from "../test-support.js";
import {
assertCopilotStaleRunIsolation,
countCopilotHistoryRequests,
copyCopilotSidepanelExtension,
isSidePanelTarget,
rawDataText,
resolveChromiumExecutable,
resolveChromiumExecutableOverride,
textValue,
waitForContextExtensionId,
waitForLoadedExtensionId,
} from "./sidepanel.e2e-support.js";
declare const chrome: {
@@ -568,18 +571,18 @@ describe.runIf(runE2E)("browser copilot Chromium side panel", () => {
it("returns one error response when a panel's tab disappears", async () => {
const unpackedExtension = await copyCopilotSidepanelExtension(tempDirs);
const userDataDir = tempDirs.make("openclaw-copilot-missing-tab-profile-");
const executablePath = await resolveChromiumExecutable();
const executablePath = await resolveChromiumExecutableOverride();
const context = await chromium.launchPersistentContext(userDataDir, {
...(executablePath ? { executablePath } : { channel: "chromium" }),
headless: true,
args: [
"--enable-unsafe-extension-debugging",
`--disable-extensions-except=${unpackedExtension}`,
`--load-extension=${unpackedExtension}`,
],
});
cleanups.push(async () => await context.close());
const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker"));
const extensionId = new URL(worker.url()).hostname;
const extensionId = await waitForContextExtensionId(context, unpackedExtension);
const popup = context.pages()[0] ?? (await context.newPage());
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
@@ -629,11 +632,12 @@ describe.runIf(runE2E)("browser copilot Chromium side panel", () => {
cleanups.push(fixture.close);
const unpackedExtension = await copyCopilotSidepanelExtension(tempDirs);
const userDataDir = tempDirs.make("openclaw-copilot-profile-");
const executablePath = await resolveChromiumExecutable();
const executablePath = await resolveChromiumExecutableOverride();
const context = await chromium.launchPersistentContext(userDataDir, {
...(executablePath ? { executablePath } : { channel: "chromium" }),
headless: true,
args: [
"--enable-unsafe-extension-debugging",
`--disable-extensions-except=${unpackedExtension}`,
`--load-extension=${unpackedExtension}`,
],
@@ -644,10 +648,10 @@ describe.runIf(runE2E)("browser copilot Chromium side panel", () => {
throw new Error("Chromium browser connection unavailable");
}
const browserCdp = await browser.newBrowserCDPSession();
const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker"));
const extensionId = new URL(worker.url()).hostname;
const extensionId = await waitForLoadedExtensionId(browserCdp, unpackedExtension);
const alphaTab = context.pages()[0] ?? (await context.newPage());
await alphaTab.goto(`chrome-extension://${extensionId}/e2e-launcher.html`);
const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker"));
await alphaTab.evaluate(
async ({ gatewayPort, relayPort }) =>
await chrome.runtime.sendMessage({
@@ -910,6 +914,7 @@ describe.runIf(runE2E)("browser copilot Chromium side panel", () => {
).toBe(true);
await reopenedBetaPanel.fill("#message-input", "after reconnect marker");
await expect.poll(async () => !(await reopenedBetaPanel.disabled("#send-button"))).toBe(true);
const historiesBeforeReconnectTurn = countCopilotHistoryRequests(gateway);
await reopenedBetaPanel.click("#send-button");
await expect.poll(() => gateway.chatSends.length, { timeout: 10_000 }).toBe(4);
await expect
@@ -917,15 +922,16 @@ describe.runIf(runE2E)("browser copilot Chromium side panel", () => {
timeout: 10_000,
})
.toContain("Isolated reply: after reconnect marker");
await expect
.poll(() => countCopilotHistoryRequests(gateway), { timeout: 10_000 })
.toBeGreaterThan(historiesBeforeReconnectTurn);
await reopenedBetaPanel.fill("#message-input", "panel linger marker");
await expect.poll(async () => !(await reopenedBetaPanel.disabled("#send-button"))).toBe(true);
await reopenedBetaPanel.click("#send-button");
await expect.poll(() => gateway.chatSends.length, { timeout: 10_000 }).toBe(5);
const panelRunId = textValue(gateway.chatSends[4]?.idempotencyKey);
const historiesBeforeNavigation = gateway.requests.filter(
(request) => request.method === "chat.history",
).length;
const historiesBeforeNavigation = countCopilotHistoryRequests(gateway);
await betaTab.goto(`${fixture.baseUrl}/beta?during-run=1`);
await expect
.poll(
@@ -939,9 +945,7 @@ describe.runIf(runE2E)("browser copilot Chromium side panel", () => {
await new Promise((resolve) => {
setTimeout(resolve, 250);
});
expect(gateway.requests.filter((request) => request.method === "chat.history")).toHaveLength(
historiesBeforeNavigation,
);
expect(countCopilotHistoryRequests(gateway)).toBe(historiesBeforeNavigation);
gateway.failNextAbort();
await disableTabPanel(worker, betaTabId);
await expect
+1
View File
@@ -1802,6 +1802,7 @@
"test:skip-inventory:report": "node --import tsx scripts/test-skip-inventory.ts",
"test:type-suppression-inventory:report": "node --import tsx scripts/type-suppression-inventory.ts",
"test:e2e": "pnpm test:e2e:gateway && pnpm test:ui:e2e",
"test:e2e:browser-copilot": "node scripts/run-with-env.mjs PLAYWRIGHT_BROWSERS_PATH=.artifacts/playwright-browsers -- node scripts/ensure-playwright-chromium.mjs --require-playwright-chromium && node scripts/run-with-env.mjs PLAYWRIGHT_BROWSERS_PATH=.artifacts/playwright-browsers OPENCLAW_BROWSER_COPILOT_E2E=1 OPENCLAW_E2E_WORKERS=1 -- node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts extensions/browser/chrome-extension/page-share.e2e.test.ts extensions/browser/chrome-extension/sidepanel.e2e.test.ts",
"test:e2e:gateway": "node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts",
"test:e2e:openshell": "node scripts/run-with-env.mjs OPENCLAW_E2E_OPENSHELL=1 -- node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts extensions/openshell/src/backend.e2e.test.ts",
"test:e2e:status-corrupt-plugin-deps": "bash scripts/e2e/status-corrupt-plugin-deps.sh",
+3 -3
View File
@@ -64,8 +64,8 @@ const RELEASE_BRANCH_RE = /^release\/\d{4}\.\d+\.\d+$/;
export class ControlUiGeneratedArtifactsMixedError extends Error {}
export class NativeGeneratedArtifactsMixedError extends Error {}
const CONTROL_UI_TEST_SCOPE_RE =
/^(ui\/|test\/vitest\/vitest\.(?:shared|ui-e2e)\.config\.ts$|scripts\/ensure-playwright-chromium\.mjs$)/;
const CHROMIUM_UI_TEST_SCOPE_RE =
/^(ui\/|extensions\/browser\/chrome-extension\/|test\/vitest\/vitest\.(?:shared|ui-e2e)\.config\.ts$|scripts\/ensure-playwright-chromium\.mjs$|package\.json$|\.github\/workflows\/ci\.yml$)/;
const NATIVE_I18N_SCOPE_RE =
/^(?:apps\/\.i18n\/|apps\/android\/(?:app\/src\/(?:main|play|thirdParty)\/|wear\/src\/main\/)|apps\/ios\/|apps\/macos\/Sources\/|apps\/shared\/OpenClawKit\/Sources\/|scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.ts$|test\/scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.test\.ts$|\.github\/workflows\/(?:ci|native-app-locale-refresh)\.yml$)/;
// Android base resources are co-owned: source PRs edit their English content,
@@ -175,7 +175,7 @@ export function detectChangedScope(changedPaths) {
runControlUiI18n = true;
}
if (CONTROL_UI_TEST_SCOPE_RE.test(path)) {
if (CHROMIUM_UI_TEST_SCOPE_RE.test(path)) {
runUiTests = true;
}
+2
View File
@@ -49,7 +49,9 @@ export function isDirectScriptExecution(
export function ensurePlaywrightChromium(
options?: ChromiumInstallOptions & {
ensureFfmpeg?: boolean;
requirePlaywrightChromium?: boolean;
systemExecutablePath?: string;
},
): number;
export function shouldEnsureFfmpegFromArgv(argv?: readonly string[]): boolean;
export function shouldRequirePlaywrightChromiumFromArgv(argv?: readonly string[]): boolean;
+18 -6
View File
@@ -175,6 +175,7 @@ export function isDirectScriptExecution(
*/
export function ensurePlaywrightChromium(options = {}) {
const env = options.env ?? process.env;
const requirePlaywrightChromium = options.requirePlaywrightChromium ?? false;
const executableOverride =
typeof env[executableOverrideEnvKey] === "string" ? env[executableOverrideEnvKey].trim() : "";
const executablePath = options.executablePath ?? chromium.executablePath();
@@ -199,6 +200,10 @@ export function ensurePlaywrightChromium(options = {}) {
return result.status ?? 1;
};
const useLinuxSystemChromiumPackage = () => {
if (requirePlaywrightChromium) {
log(`[ui-e2e] This lane requires Playwright-managed Chromium; refusing system fallback.`);
return 1;
}
log(`[ui-e2e] Playwright install is unavailable; installing a system Chromium package.`);
const installStatus = installLinuxSystemChromiumPackage({
cwd: options.cwd,
@@ -234,7 +239,7 @@ export function ensurePlaywrightChromium(options = {}) {
return status;
};
if (executableOverride) {
if (!requirePlaywrightChromium && executableOverride) {
if (existsSync(executableOverride) && canRunChromiumExecutable(executableOverride, spawnSync)) {
return ensureFfmpeg();
}
@@ -248,11 +253,13 @@ export function ensurePlaywrightChromium(options = {}) {
return ensureFfmpeg();
}
const systemExecutablePath =
options.systemExecutablePath ?? resolveSystemChromiumExecutablePath(existsSync, spawnSync);
if (systemExecutablePath && canRunChromiumExecutable(systemExecutablePath, spawnSync)) {
log(`[ui-e2e] Using system Chromium at ${systemExecutablePath}.`);
return ensureFfmpeg();
if (!requirePlaywrightChromium) {
const systemExecutablePath =
options.systemExecutablePath ?? resolveSystemChromiumExecutablePath(existsSync, spawnSync);
if (systemExecutablePath && canRunChromiumExecutable(systemExecutablePath, spawnSync)) {
log(`[ui-e2e] Using system Chromium at ${systemExecutablePath}.`);
return ensureFfmpeg();
}
}
if (env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1") {
@@ -315,8 +322,13 @@ export function shouldEnsureFfmpegFromArgv(argv = process.argv) {
return !argv.includes("--skip-ffmpeg");
}
export function shouldRequirePlaywrightChromiumFromArgv(argv = process.argv) {
return argv.includes("--require-playwright-chromium");
}
if (isDirectScriptExecution()) {
process.exitCode = ensurePlaywrightChromium({
ensureFfmpeg: shouldEnsureFfmpegFromArgv(),
requirePlaywrightChromium: shouldRequirePlaywrightChromiumFromArgv(),
});
}
@@ -14,3 +14,16 @@ it("skips control-ui localization checks for test-only UI source", () => {
false,
);
});
it("runs Chromium UI tests for browser copilot extension changes", () => {
expect(detectChangedScope(["extensions/browser/chrome-extension/sidepanel.ts"]).runUiTests).toBe(
true,
);
});
it.each(["package.json", ".github/workflows/ci.yml"])(
"runs Chromium UI tests when %s can change the browser copilot CI route",
(changedPath) => {
expect(detectChangedScope([changedPath]).runUiTests).toBe(true);
},
);
+2 -2
View File
@@ -365,7 +365,7 @@ describe("detectChangedScope", () => {
});
});
it("keeps native platform lanes scoped when the CI workflow changes", () => {
it("runs CI-owned platform lanes when the CI workflow changes", () => {
expect(detectChangedScope([".github/workflows/ci.yml"])).toEqual({
runNode: true,
runMacos: false,
@@ -375,7 +375,7 @@ describe("detectChangedScope", () => {
runSkillsPython: false,
runChangedSmoke: false,
runControlUiI18n: false,
runUiTests: false,
runUiTests: true,
});
});
+6
View File
@@ -140,6 +140,12 @@ describe("package scripts", () => {
);
});
it("runs browser copilot E2E against real Chromium", () => {
expect(readPackageJson().scripts["test:e2e:browser-copilot"]).toBe(
"node scripts/run-with-env.mjs PLAYWRIGHT_BROWSERS_PATH=.artifacts/playwright-browsers -- node scripts/ensure-playwright-chromium.mjs --require-playwright-chromium && node scripts/run-with-env.mjs PLAYWRIGHT_BROWSERS_PATH=.artifacts/playwright-browsers OPENCLAW_BROWSER_COPILOT_E2E=1 OPENCLAW_E2E_WORKERS=1 -- node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts extensions/browser/chrome-extension/page-share.e2e.test.ts extensions/browser/chrome-extension/sidepanel.e2e.test.ts",
);
});
it("gives the plugin SDK usage scan enough heap for repository-wide analysis", () => {
expect(readPackageJson().scripts["plugin-sdk:usage"]).toBe(
"node --max-old-space-size=8192 --import tsx scripts/analyze-plugin-sdk-usage.ts",
+6
View File
@@ -4423,6 +4423,12 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
expect(scenario.run).toBe(
"node scripts/run-vitest.mjs run --config test/vitest/vitest.ui-e2e.config.ts --configLoader runner --shard ${{ matrix.shard }}/4",
);
const browserCopilot = expectDefined(
uiE2e.steps.find((step: WorkflowStep) => step.name === "Test browser copilot end-to-end"),
"browser copilot E2E suite",
);
expect(browserCopilot.if).toBe("matrix.shard == 1");
expect(browserCopilot.run).toBe("pnpm test:e2e:browser-copilot");
expect(JSON.stringify(uiE2e)).not.toContain("OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM");
});
@@ -6,6 +6,7 @@ import {
resolvePlaywrightInstallRunner,
shouldEnsureFfmpegFromArgv,
shouldInstallPlaywrightSystemDependencies,
shouldRequirePlaywrightChromiumFromArgv,
} from "../../scripts/ensure-playwright-chromium.mjs";
describe("ensurePlaywrightChromium", () => {
@@ -77,6 +78,46 @@ describe("ensurePlaywrightChromium", () => {
expect(logs.join("\n")).toContain("Using system Chromium at /usr/bin/chromium-browser");
});
it("installs Playwright Chromium when the lane requires its pinned browser", () => {
let managedChromiumInstalled = false;
const spawnSync = vi.fn((command: string, args: string[]) => {
if (command === "pnpm" && args.includes("chromium")) {
managedChromiumInstalled = true;
return { status: 0 };
}
if (command === "/cache/chromium/chrome") {
return { status: managedChromiumInstalled ? 0 : 127 };
}
if (command === "/usr/bin/chromium-browser") {
return { status: 0 };
}
return { status: 1 };
});
expect(
ensurePlaywrightChromium({
cwd: "/repo",
env: { PATH: "/bin" },
executablePath: "/cache/chromium/chrome",
existsSync: (path: string) =>
path === "/usr/bin/chromium-browser" ||
(managedChromiumInstalled && path === "/cache/chromium/chrome"),
requirePlaywrightChromium: true,
spawnSync,
stdio: "pipe",
systemExecutablePath: "/usr/bin/chromium-browser",
}),
).toBe(0);
expect(spawnSync).toHaveBeenCalledWith(
"pnpm",
["--dir", "ui", "exec", "playwright", "install", "chromium"],
expect.objectContaining({ cwd: "/repo", stdio: "pipe" }),
);
expect(spawnSync).not.toHaveBeenCalledWith("/usr/bin/chromium-browser", ["--version"], {
stdio: "ignore",
});
});
it("installs Playwright ffmpeg when recorded UI tests request it", () => {
const logs: string[] = [];
const spawnSync = vi.fn(() => ({ status: 0 }));
@@ -497,4 +538,17 @@ describe("ensurePlaywrightChromium", () => {
]),
).toBe(false);
});
it("parses the pinned Playwright Chromium requirement", () => {
expect(
shouldRequirePlaywrightChromiumFromArgv([
"node",
"scripts/ensure-playwright-chromium.mjs",
"--require-playwright-chromium",
]),
).toBe(true);
expect(
shouldRequirePlaywrightChromiumFromArgv(["node", "scripts/ensure-playwright-chromium.mjs"]),
).toBe(false);
});
});