fix(ui): harden control e2e browser setup

This commit is contained in:
Vincent Koc
2026-05-25 22:58:38 +02:00
parent 0f67dfd074
commit 4424dafe64
5 changed files with 198 additions and 3 deletions
+1 -1
View File
@@ -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",
+82
View File
@@ -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();
}
@@ -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,
});
});
});
+10 -1
View File
@@ -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<ControlUiE2eServer> {
},
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",
+9 -1
View File
@@ -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,