From 90a22b4f50226b13735e77dde81a92340ae724cf Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 30 Jul 2026 23:20:34 +0800 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 4 ++ .../chrome-extension/page-share.e2e.test.ts | 59 ++++++++++++---- .../chrome-extension/sidepanel.e2e-support.ts | 68 ++++++++++++++----- .../chrome-extension/sidepanel.e2e.test.ts | 30 ++++---- package.json | 1 + scripts/ci-changed-scope.mjs | 6 +- scripts/ensure-playwright-chromium.d.mts | 2 + scripts/ensure-playwright-chromium.mjs | 24 +++++-- .../ci-changed-scope.control-ui.test.ts | 13 ++++ src/scripts/ci-changed-scope.test.ts | 4 +- test/package-scripts.test.ts | 6 ++ test/scripts/ci-workflow-guards.test.ts | 6 ++ .../ensure-playwright-chromium.test.ts | 54 +++++++++++++++ 13 files changed, 221 insertions(+), 56 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47276f966eda..272a8ab9eb82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/extensions/browser/chrome-extension/page-share.e2e.test.ts b/extensions/browser/chrome-extension/page-share.e2e.test.ts index 59caa8e22b5c..87c25aa14878 100644 --- a/extensions/browser/chrome-extension/page-share.e2e.test.ts +++ b/extensions/browser/chrome-extension/page-share.e2e.test.ts @@ -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): Promise>; + update(tabId: number, update: { active: boolean }): Promise; + }; + windows: { + update(windowId: number, update: { focused: boolean }): Promise; }; }; @@ -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(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( 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 diff --git a/extensions/browser/chrome-extension/sidepanel.e2e-support.ts b/extensions/browser/chrome-extension/sidepanel.e2e-support.ts index 95809376e714..6e4474b0fc62 100644 --- a/extensions/browser/chrome-extension/sidepanel.e2e-support.ts +++ b/extensions/browser/chrome-extension/sidepanel.e2e-support.ts @@ -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, +): 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 { +// 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 { 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 { + 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 { + 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: { diff --git a/extensions/browser/chrome-extension/sidepanel.e2e.test.ts b/extensions/browser/chrome-extension/sidepanel.e2e.test.ts index 65775a70d153..0b0fc4c2bec2 100644 --- a/extensions/browser/chrome-extension/sidepanel.e2e.test.ts +++ b/extensions/browser/chrome-extension/sidepanel.e2e.test.ts @@ -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 diff --git a/package.json b/package.json index 68710a62dbcb..bc9aa23a03c6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index 6f6b6588d986..ec44edfb9035 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -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; } diff --git a/scripts/ensure-playwright-chromium.d.mts b/scripts/ensure-playwright-chromium.d.mts index 372fc9b73851..866fb019dd8d 100644 --- a/scripts/ensure-playwright-chromium.d.mts +++ b/scripts/ensure-playwright-chromium.d.mts @@ -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; diff --git a/scripts/ensure-playwright-chromium.mjs b/scripts/ensure-playwright-chromium.mjs index 68e75702be3d..4c2f0e23c800 100644 --- a/scripts/ensure-playwright-chromium.mjs +++ b/scripts/ensure-playwright-chromium.mjs @@ -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(), }); } diff --git a/src/scripts/ci-changed-scope.control-ui.test.ts b/src/scripts/ci-changed-scope.control-ui.test.ts index 15600c9ec2f3..a6193e21f705 100644 --- a/src/scripts/ci-changed-scope.control-ui.test.ts +++ b/src/scripts/ci-changed-scope.control-ui.test.ts @@ -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); + }, +); diff --git a/src/scripts/ci-changed-scope.test.ts b/src/scripts/ci-changed-scope.test.ts index f8c7e1d8e4bb..b363d15373c4 100644 --- a/src/scripts/ci-changed-scope.test.ts +++ b/src/scripts/ci-changed-scope.test.ts @@ -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, }); }); diff --git a/test/package-scripts.test.ts b/test/package-scripts.test.ts index a5af07e0ff40..19489f535d58 100644 --- a/test/package-scripts.test.ts +++ b/test/package-scripts.test.ts @@ -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", diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 30a5fa9d77ea..c959742ecc4e 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -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"); }); diff --git a/test/scripts/ensure-playwright-chromium.test.ts b/test/scripts/ensure-playwright-chromium.test.ts index 9d11e3b96d41..078ae2a98fd6 100644 --- a/test/scripts/ensure-playwright-chromium.test.ts +++ b/test/scripts/ensure-playwright-chromium.test.ts @@ -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); + }); });