diff --git a/package.json b/package.json index 7422fcd739d6..dcae99386bf6 100644 --- a/package.json +++ b/package.json @@ -1761,7 +1761,7 @@ "test:restart:gateway": "node --import tsx scripts/bench-gateway-restart.ts", "test:startup:memory": "node scripts/ensure-cli-startup-build.mjs && node scripts/check-cli-startup-memory.mjs", "test:ui": "pnpm ui:i18n:check && pnpm lint:ui:no-raw-window-open && pnpm --dir ui test", - "test:ui:e2e": "node scripts/run-vitest.mjs run --config test/vitest/vitest.ui-e2e.config.ts --configLoader runner", + "test:ui:e2e": "node scripts/ensure-playwright-chromium.mjs && node scripts/run-vitest.mjs run --config test/vitest/vitest.ui-e2e.config.ts --configLoader runner", "test:unit": "pnpm test:unit:fast && node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts", "test:unit:fast": "node scripts/run-vitest.mjs run --config test/vitest/vitest.unit-fast.config.ts", "test:unit:fast:audit": "node scripts/test-unit-fast-audit.mjs", diff --git a/scripts/ensure-playwright-chromium.mjs b/scripts/ensure-playwright-chromium.mjs new file mode 100644 index 000000000000..0a625f1f32af --- /dev/null +++ b/scripts/ensure-playwright-chromium.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import { spawnSync as spawnSyncImpl } from "node:child_process"; +import { existsSync as existsSyncImpl, realpathSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; +import { resolvePnpmRunner } from "./pnpm-runner.mjs"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const playwrightInstallArgs = ["--dir", "ui", "exec", "playwright", "install", "chromium"]; + +export function resolvePlaywrightInstallRunner(options = {}) { + const env = options.env ?? process.env; + return resolvePnpmRunner({ + comSpec: options.comSpec ?? env.ComSpec ?? env.COMSPEC, + npmExecPath: env.npm_execpath, + platform: options.platform, + pnpmArgs: playwrightInstallArgs, + }); +} + +export function isDirectScriptExecution( + argvEntry = process.argv[1], + modulePath = fileURLToPath(import.meta.url), + realpath = realpathSync.native, +) { + if (!argvEntry) { + return false; + } + try { + return realpath(argvEntry) === realpath(modulePath); + } catch { + return resolve(argvEntry) === resolve(modulePath); + } +} + +export function ensurePlaywrightChromium(options = {}) { + const env = options.env ?? process.env; + const executablePath = options.executablePath ?? chromium.executablePath(); + const existsSync = options.existsSync ?? existsSyncImpl; + const log = options.log ?? console.error; + const spawnSync = options.spawnSync ?? spawnSyncImpl; + + if (existsSync(executablePath)) { + return 0; + } + + if (env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1") { + log( + `[ui-e2e] Playwright Chromium is missing at ${executablePath}; OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 leaves the lane skipped.`, + ); + return 0; + } + + log(`[ui-e2e] Playwright Chromium is missing at ${executablePath}; installing chromium.`); + const runner = resolvePlaywrightInstallRunner({ + comSpec: options.comSpec, + env, + platform: options.platform, + }); + const result = spawnSync(runner.command, runner.args, { + cwd: options.cwd ?? repoRoot, + env, + shell: runner.shell, + stdio: options.stdio ?? "inherit", + windowsVerbatimArguments: runner.windowsVerbatimArguments, + }); + const status = result.status ?? 1; + if (status !== 0) { + return status; + } + + if (!existsSync(executablePath)) { + log(`[ui-e2e] Playwright install completed but Chromium is still missing at ${executablePath}.`); + return 1; + } + return 0; +} + +if (isDirectScriptExecution()) { + process.exitCode = ensurePlaywrightChromium(); +} diff --git a/test/scripts/ensure-playwright-chromium.test.ts b/test/scripts/ensure-playwright-chromium.test.ts new file mode 100644 index 000000000000..654f89988c4c --- /dev/null +++ b/test/scripts/ensure-playwright-chromium.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; +import { + ensurePlaywrightChromium, + resolvePlaywrightInstallRunner, +} from "../../scripts/ensure-playwright-chromium.mjs"; + +describe("ensurePlaywrightChromium", () => { + it("does nothing when the browser binary exists", () => { + const spawnSync = vi.fn(); + + expect( + ensurePlaywrightChromium({ + executablePath: "/cache/chromium/chrome", + existsSync: () => true, + spawnSync, + }), + ).toBe(0); + expect(spawnSync).not.toHaveBeenCalled(); + }); + + it("preserves the intentional missing-browser skip mode", () => { + const logs: string[] = []; + const spawnSync = vi.fn(); + + expect( + ensurePlaywrightChromium({ + env: { OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM: "1" }, + executablePath: "/cache/chromium/chrome", + existsSync: () => false, + log: (line: string) => logs.push(line), + spawnSync, + }), + ).toBe(0); + expect(spawnSync).not.toHaveBeenCalled(); + expect(logs.join("\n")).toContain("leaves the lane skipped"); + }); + + it("installs Chromium through the UI Playwright package when missing", () => { + const spawnSync = vi.fn(() => ({ status: 0 })); + let existsCalls = 0; + + expect( + ensurePlaywrightChromium({ + cwd: "/repo", + env: { PATH: "/bin" }, + executablePath: "/cache/chromium/chrome", + existsSync: () => ++existsCalls > 1, + platform: "linux", + spawnSync, + stdio: "pipe", + }), + ).toBe(0); + expect(spawnSync).toHaveBeenCalledWith( + "pnpm", + ["--dir", "ui", "exec", "playwright", "install", "chromium"], + { + cwd: "/repo", + env: { PATH: "/bin" }, + shell: false, + stdio: "pipe", + windowsVerbatimArguments: undefined, + }, + ); + }); + + it("returns the installer status when Playwright install fails", () => { + expect( + ensurePlaywrightChromium({ + executablePath: "/cache/chromium/chrome", + existsSync: () => false, + spawnSync: vi.fn(() => ({ status: 23 })), + stdio: "pipe", + }), + ).toBe(23); + }); + + it("wraps the pnpm command shim on Windows", () => { + expect( + resolvePlaywrightInstallRunner({ + comSpec: "C:\\Windows\\System32\\cmd.exe", + env: {}, + platform: "win32", + }), + ).toEqual({ + args: [ + "/d", + "/s", + "/c", + 'pnpm.cmd --dir ui exec playwright install chromium', + ], + command: "C:\\Windows\\System32\\cmd.exe", + shell: false, + windowsVerbatimArguments: true, + }); + }); +}); diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 22bb13d841d1..548a5b4b87dc 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -1,4 +1,5 @@ import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; import { createServer as createNetServer } from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -7,6 +8,9 @@ import { createServer, type ViteDevServer } from "vite"; import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "../../../src/gateway/control-ui-contract.js"; import { PROTOCOL_VERSION } from "../../../src/gateway/protocol/version.js"; +const require = createRequire(import.meta.url); +const json5EsmPath = require.resolve("json5/dist/index.mjs"); + export type MockGatewayRequest = { id: string; method: string; @@ -69,9 +73,14 @@ export async function startControlUiE2eServer(): Promise { }, logLevel: "error", optimizeDeps: { - include: ["lit/directives/repeat.js"], + include: ["ipaddr.js", "lit/directives/repeat.js", "markdown-it-task-lists"], }, publicDir: path.join(uiRoot, "public"), + resolve: { + alias: { + json5: json5EsmPath, + }, + }, root: uiRoot, server: { host: "127.0.0.1", diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 6ec209f76a37..0c6d4d316851 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; +import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig, type Plugin } from "vite"; @@ -8,6 +9,8 @@ import { controlUiManualChunk } from "./config/control-ui-chunking.ts"; const here = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(here, ".."); const outDir = path.resolve(here, "../dist/control-ui"); +const require = createRequire(import.meta.url); +const json5EsmPath = require.resolve("json5/dist/index.mjs"); function normalizeBase(input: string): string { const trimmed = input.trim(); @@ -93,7 +96,12 @@ export default defineConfig(() => { }, publicDir: path.resolve(here, "public"), optimizeDeps: { - include: ["lit/directives/repeat.js"], + include: ["ipaddr.js", "lit/directives/repeat.js", "markdown-it-task-lists"], + }, + resolve: { + alias: { + json5: json5EsmPath, + }, }, build: { outDir,