diff --git a/extensions/qa-lab/src/suite-runtime-flow.test.ts b/extensions/qa-lab/src/suite-runtime-flow.test.ts index 16d60b187e5a..8ab9b5d02483 100644 --- a/extensions/qa-lab/src/suite-runtime-flow.test.ts +++ b/extensions/qa-lab/src/suite-runtime-flow.test.ts @@ -303,7 +303,7 @@ describe("qa suite runtime flow", () => { }); await call.deps.webOpenPage({ url: "https://openclaw.ai" }); - expect(webOpenPage).toHaveBeenCalledWith({ url: "https://openclaw.ai" }); + expect(webOpenPage).toHaveBeenCalledWith({ url: "https://openclaw.ai", repoRoot: "/repo" }); expect(env.webSessionIds.has("page-1")).toBe(true); }); }); diff --git a/extensions/qa-lab/src/suite-runtime-flow.ts b/extensions/qa-lab/src/suite-runtime-flow.ts index c6f775ebbadc..523f477d7836 100644 --- a/extensions/qa-lab/src/suite-runtime-flow.ts +++ b/extensions/qa-lab/src/suite-runtime-flow.ts @@ -186,7 +186,7 @@ function createQaSuiteScenarioDeps(params: QaSuiteScenarioDepsParams) { browserSnapshot: qaBrowserSnapshot, browserAct: qaBrowserAct, webOpenPage: async (webParams: Parameters[0]) => { - const opened = await qaWebOpenPage(webParams); + const opened = await qaWebOpenPage({ ...webParams, repoRoot: params.env.repoRoot }); params.env.webSessionIds.add(opened.pageId); return opened; }, diff --git a/extensions/qa-lab/src/web-runtime.test.ts b/extensions/qa-lab/src/web-runtime.test.ts index ef7e2abb7b1a..41accdc50824 100644 --- a/extensions/qa-lab/src/web-runtime.test.ts +++ b/extensions/qa-lab/src/web-runtime.test.ts @@ -1,12 +1,13 @@ // Qa Lab tests cover web runtime plugin behavior. import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { bodyLocator, browserClose, contextClose, contextNewPage, + existsSync, goto, launch, locatorFill, @@ -17,6 +18,7 @@ const { pageUrl, pageWaitForFunction, pageWaitForSelector, + spawnSync, } = vi.hoisted(() => ({ bodyLocator: { waitFor: vi.fn(async () => undefined), @@ -25,6 +27,7 @@ const { browserClose: vi.fn(async () => undefined), contextClose: vi.fn(async () => undefined), contextNewPage: vi.fn(), + existsSync: vi.fn((_candidate: unknown) => false), goto: vi.fn(async () => undefined), launch: vi.fn(), locatorFill: vi.fn(async () => undefined), @@ -35,6 +38,16 @@ const { pageUrl: vi.fn(() => "http://127.0.0.1:3000/chat"), pageWaitForFunction: vi.fn(async () => undefined), pageWaitForSelector: vi.fn(async () => undefined), + spawnSync: vi.fn(() => ({ status: 0 })), +})); + +vi.mock("node:child_process", () => ({ + spawnSync, +})); + +vi.mock("node:fs", async (importOriginal) => ({ + ...(await importOriginal()), + existsSync, })); vi.mock("playwright-core", () => ({ @@ -85,6 +98,12 @@ beforeEach(async () => { contextNewPage.mockResolvedValue(page); launch.mockResolvedValue(browser); vi.clearAllMocks(); + existsSync.mockReturnValue(false); + spawnSync.mockReturnValue({ status: 0 }); +}); + +afterEach(() => { + vi.unstubAllEnvs(); }); function requireLaunchOptions() { @@ -116,7 +135,13 @@ describe("qa web runtime", () => { await closeQaWebSessions(); const launchOptions = requireLaunchOptions(); - expect(launchOptions?.channel).toBe("chrome"); + expect(spawnSync).toHaveBeenCalledWith( + process.execPath, + ["scripts/ensure-playwright-chromium.mjs", "--skip-ffmpeg"], + expect.objectContaining({ cwd: process.cwd(), stdio: "inherit" }), + ); + expect(launchOptions?.channel).toBeUndefined(); + expect(launchOptions?.executablePath).toBeUndefined(); expect(launchOptions?.headless).toBe(true); expect(goto).toHaveBeenCalledWith("http://127.0.0.1:3000/chat", { waitUntil: "domcontentloaded", @@ -132,6 +157,44 @@ describe("qa web runtime", () => { expect(browserClose).toHaveBeenCalledTimes(1); }); + it("launches an explicit Chromium executable override when configured", async () => { + vi.stubEnv("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH", "/custom/chromium"); + existsSync.mockImplementation((candidate) => candidate === "/custom/chromium"); + + await qaWebOpenPage({ url: "http://127.0.0.1:3000/chat" }); + + const launchOptions = requireLaunchOptions(); + expect(spawnSync).toHaveBeenCalledWith("/custom/chromium", ["--version"], { + stdio: "ignore", + }); + expect(launchOptions?.channel).toBeUndefined(); + expect(launchOptions?.executablePath).toBe("/custom/chromium"); + await closeQaWebSessions(); + }); + + it("launches detected system Chromium without requiring branded Chrome", async () => { + existsSync.mockImplementation((candidate) => candidate === "/usr/bin/chromium"); + + await qaWebOpenPage({ url: "http://127.0.0.1:3000/chat" }); + + const launchOptions = requireLaunchOptions(); + expect(spawnSync).toHaveBeenCalledWith("/usr/bin/chromium", ["--version"], { + stdio: "ignore", + }); + expect(launchOptions?.channel).toBeUndefined(); + expect(launchOptions?.executablePath).toBe("/usr/bin/chromium"); + await closeQaWebSessions(); + }); + + it("keeps an explicit browser channel request explicit", async () => { + await qaWebOpenPage({ url: "http://127.0.0.1:3000/chat", channel: "chrome" }); + + const launchOptions = requireLaunchOptions(); + expect(launchOptions?.channel).toBe("chrome"); + expect(launchOptions?.executablePath).toBeUndefined(); + await closeQaWebSessions(); + }); + it("can close only selected page sessions", async () => { const first = await qaWebOpenPage({ url: "http://127.0.0.1:3000/one" }); const second = await qaWebOpenPage({ url: "http://127.0.0.1:3000/two" }); diff --git a/extensions/qa-lab/src/web-runtime.ts b/extensions/qa-lab/src/web-runtime.ts index 850b324a2b7a..b8727d926edb 100644 --- a/extensions/qa-lab/src/web-runtime.ts +++ b/extensions/qa-lab/src/web-runtime.ts @@ -1,5 +1,7 @@ +import { spawnSync } from "node:child_process"; // Qa Lab plugin module implements web runtime behavior. import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; import { resolvePositiveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import { chromium, type Browser, type BrowserContext, type Page } from "playwright-core"; @@ -19,6 +21,7 @@ type QaWebOpenPageParams = { url: string; headless?: boolean; channel?: "chrome"; + repoRoot?: string; timeoutMs?: number; viewport?: { width: number; height: number }; }; @@ -54,6 +57,14 @@ const sessions = new Map(); const DEFAULT_WEB_TIMEOUT_MS = 20_000; const MAX_DIAGNOSTIC_ENTRIES = 50; const MAX_DIAGNOSTIC_TEXT_CHARS = 2_000; +const PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH_ENV = "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH"; +const SYSTEM_CHROMIUM_EXECUTABLE_CANDIDATES = [ + "/snap/bin/chromium", + "/usr/bin/chromium-browser", + "/usr/bin/chromium", + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", +] as const; function appendDiagnostic(diagnostics: QaWebDiagnosticEntry[], entry: QaWebDiagnosticEntry): void { diagnostics.push({ @@ -77,12 +88,61 @@ function resolveSession(pageId: string): QaWebSession { return session; } +function canRunChromiumExecutable(executablePath: string): boolean { + const result = spawnSync(executablePath, ["--version"], { stdio: "ignore" }); + return result.status === 0; +} + +function resolveRunnableChromiumExecutablePath(): string | undefined { + const executableOverride = process.env[PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH_ENV]?.trim(); + if (executableOverride) { + return existsSync(executableOverride) && canRunChromiumExecutable(executableOverride) + ? executableOverride + : undefined; + } + return SYSTEM_CHROMIUM_EXECUTABLE_CANDIDATES.find( + (candidate) => existsSync(candidate) && canRunChromiumExecutable(candidate), + ); +} + +function ensureChromiumAvailable(repoRoot: string) { + const result = spawnSync( + process.execPath, + ["scripts/ensure-playwright-chromium.mjs", "--skip-ffmpeg"], + { + cwd: repoRoot, + env: process.env, + stdio: "inherit", + }, + ); + if ((result.status ?? 1) !== 0) { + throw new Error(`failed to ensure Playwright Chromium; status=${result.status ?? "unknown"}`); + } +} + +function buildChromiumLaunchOptions(params: QaWebOpenPageParams) { + const baseOptions = { + headless: params.headless ?? true, + }; + if (params.channel) { + return { + ...baseOptions, + channel: params.channel, + }; + } + const executablePath = resolveRunnableChromiumExecutablePath(); + return executablePath + ? { + ...baseOptions, + executablePath, + } + : baseOptions; +} + export async function qaWebOpenPage(params: QaWebOpenPageParams) { const timeoutMs = resolveTimeoutMs(params.timeoutMs); - const browser = await chromium.launch({ - channel: params.channel ?? "chrome", - headless: params.headless ?? true, - }); + ensureChromiumAvailable(params.repoRoot ?? process.cwd()); + const browser = await chromium.launch(buildChromiumLaunchOptions(params)); const context = await browser.newContext({ ignoreHTTPSErrors: true, viewport: params.viewport ?? { width: 1440, height: 1080 },