diff --git a/scripts/ensure-playwright-chromium.mjs b/scripts/ensure-playwright-chromium.mjs index a81de0146cd5..2ee810f60788 100644 --- a/scripts/ensure-playwright-chromium.mjs +++ b/scripts/ensure-playwright-chromium.mjs @@ -7,7 +7,23 @@ 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"]; +const playwrightInstallArgs = [ + "--dir", + "ui", + "exec", + "playwright", + "install", + "chromium", +]; +const playwrightInstallWithDepsArgs = [ + "--dir", + "ui", + "exec", + "playwright", + "install", + "--with-deps", + "chromium", +]; const executableOverrideEnvKey = "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH"; export const systemChromiumExecutableCandidates = [ "/snap/bin/chromium", @@ -17,8 +33,22 @@ export const systemChromiumExecutableCandidates = [ "/usr/bin/google-chrome-stable", ]; -export function resolveSystemChromiumExecutablePath(existsSync = existsSyncImpl) { - return systemChromiumExecutableCandidates.find((candidate) => existsSync(candidate)) ?? ""; +export function canRunChromiumExecutable(executablePath, spawnSync = spawnSyncImpl) { + const result = spawnSync(executablePath, ["--version"], { + stdio: "ignore", + }); + return result.status === 0; +} + +export function resolveSystemChromiumExecutablePath( + existsSync = existsSyncImpl, + spawnSync = spawnSyncImpl, +) { + return ( + systemChromiumExecutableCandidates.find( + (candidate) => existsSync(candidate) && canRunChromiumExecutable(candidate, spawnSync), + ) ?? "" + ); } export function resolvePlaywrightInstallRunner(options = {}) { @@ -27,10 +57,23 @@ export function resolvePlaywrightInstallRunner(options = {}) { comSpec: options.comSpec ?? env.ComSpec ?? env.COMSPEC, npmExecPath: env === process.env ? env.npm_execpath : (env.npm_execpath ?? ""), platform: options.platform, - pnpmArgs: playwrightInstallArgs, + pnpmArgs: options.withDeps ? playwrightInstallWithDepsArgs : playwrightInstallArgs, }); } +export function shouldInstallPlaywrightSystemDependencies(options = {}) { + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const getuid = options.getuid ?? process.getuid; + if (platform !== "linux") { + return false; + } + if (typeof getuid === "function" && getuid() === 0) { + return true; + } + return env.CI === "true" || env.GITHUB_ACTIONS === "true"; +} + export function isDirectScriptExecution( argvEntry = process.argv[1], modulePath = fileURLToPath(import.meta.url), @@ -56,22 +99,25 @@ export function ensurePlaywrightChromium(options = {}) { const spawnSync = options.spawnSync ?? spawnSyncImpl; if (executableOverride) { - if (existsSync(executableOverride)) { + if (existsSync(executableOverride) && canRunChromiumExecutable(executableOverride, spawnSync)) { return 0; } log( - `[ui-e2e] ${executableOverrideEnvKey} points to ${executableOverride}, but that browser does not exist.`, + `[ui-e2e] ${executableOverrideEnvKey} points to ${executableOverride}, but that browser is not runnable.`, ); return 1; } - if (existsSync(executablePath)) { + if (existsSync(executablePath) && canRunChromiumExecutable(executablePath, spawnSync)) { return 0; } const systemExecutablePath = - options.systemExecutablePath ?? resolveSystemChromiumExecutablePath(existsSync); - if (systemExecutablePath) { + options.systemExecutablePath ?? resolveSystemChromiumExecutablePath(existsSync, spawnSync); + if ( + systemExecutablePath && + canRunChromiumExecutable(systemExecutablePath, spawnSync) + ) { log(`[ui-e2e] Using system Chromium at ${systemExecutablePath}.`); return 0; } @@ -83,7 +129,7 @@ export function ensurePlaywrightChromium(options = {}) { return 0; } - log(`[ui-e2e] Playwright Chromium is missing at ${executablePath}; installing chromium.`); + log(`[ui-e2e] Playwright Chromium is not runnable at ${executablePath}; installing chromium.`); const runner = resolvePlaywrightInstallRunner({ comSpec: options.comSpec, env, @@ -101,9 +147,38 @@ export function ensurePlaywrightChromium(options = {}) { return status; } - if (!existsSync(executablePath)) { + if (!existsSync(executablePath) || !canRunChromiumExecutable(executablePath, spawnSync)) { + if (shouldInstallPlaywrightSystemDependencies({ + env, + getuid: options.getuid, + platform: options.platform, + })) { + log( + `[ui-e2e] Chromium is installed but still cannot start; installing Linux system dependencies.`, + ); + const depsRunner = resolvePlaywrightInstallRunner({ + comSpec: options.comSpec, + env, + platform: options.platform, + withDeps: true, + }); + const depsResult = spawnSync(depsRunner.command, depsRunner.args, { + cwd: options.cwd ?? repoRoot, + env, + shell: depsRunner.shell, + stdio: options.stdio ?? "inherit", + windowsVerbatimArguments: depsRunner.windowsVerbatimArguments, + }); + const depsStatus = depsResult.status ?? 1; + if (depsStatus !== 0) { + return depsStatus; + } + if (existsSync(executablePath) && canRunChromiumExecutable(executablePath, spawnSync)) { + return 0; + } + } log( - `[ui-e2e] Playwright install completed but Chromium is still missing at ${executablePath}.`, + `[ui-e2e] Playwright install completed but Chromium is still not runnable at ${executablePath}.`, ); return 1; } diff --git a/scripts/test-cleanup-docker.sh b/scripts/test-cleanup-docker.sh index 743811487c6d..61301bd7438f 100755 --- a/scripts/test-cleanup-docker.sh +++ b/scripts/test-cleanup-docker.sh @@ -5,9 +5,37 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "$ROOT_DIR/scripts/lib/docker-build.sh" source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh" IMAGE_NAME="${OPENCLAW_CLEANUP_SMOKE_IMAGE:-openclaw-cleanup-smoke:local}" -PLATFORM="${OPENCLAW_CLEANUP_SMOKE_PLATFORM:-linux/amd64}" DOCKER_COMMAND_TIMEOUT="${DOCKER_COMMAND_TIMEOUT:-${OPENCLAW_CLEANUP_SMOKE_DOCKER_TIMEOUT:-600s}}" +resolve_default_cleanup_platform() { + local host_arch + if [[ -n "${OPENCLAW_CLEANUP_SMOKE_PLATFORM:-}" ]]; then + printf "%s" "$OPENCLAW_CLEANUP_SMOKE_PLATFORM" + return + fi + host_arch="$(uname -m)" + if [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then + case "$host_arch" in + arm64 | aarch64) + printf "linux/arm64" + return + ;; + esac + printf "linux/amd64" + return + fi + case "$host_arch" in + arm64 | aarch64) + printf "linux/arm64" + ;; + *) + printf "linux/amd64" + ;; + esac +} + +PLATFORM="$(resolve_default_cleanup_platform)" + echo "==> Build image: $IMAGE_NAME" docker_build_run cleanup-build \ -t "$IMAGE_NAME" \ diff --git a/scripts/test-install-sh-docker.sh b/scripts/test-install-sh-docker.sh index 8da6feec38ef..ff08b3313f80 100755 --- a/scripts/test-install-sh-docker.sh +++ b/scripts/test-install-sh-docker.sh @@ -14,23 +14,30 @@ run_install_smoke_container() { } resolve_default_smoke_platform() { - local host_os local host_arch if [[ -n "${OPENCLAW_INSTALL_SMOKE_PLATFORM:-}" ]]; then printf "%s" "$OPENCLAW_INSTALL_SMOKE_PLATFORM" return fi + host_arch="$(uname -m)" if [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then + case "$host_arch" in + arm64 | aarch64) + printf "linux/arm64" + return + ;; + esac printf "linux/amd64" return fi - host_os="$(uname -s)" - host_arch="$(uname -m)" - if [[ "$host_os" == "Darwin" && "$host_arch" == "arm64" ]]; then - printf "linux/arm64" - return - fi - printf "linux/amd64" + case "$host_arch" in + arm64 | aarch64) + printf "linux/arm64" + ;; + *) + printf "linux/amd64" + ;; + esac } print_pack_audit() { diff --git a/src/agents/bash-tools.exec-gateway-approval.e2e.test.ts b/src/agents/bash-tools.exec-gateway-approval.e2e.test.ts index cc8b56258ace..83e81d3b3461 100644 --- a/src/agents/bash-tools.exec-gateway-approval.e2e.test.ts +++ b/src/agents/bash-tools.exec-gateway-approval.e2e.test.ts @@ -30,6 +30,8 @@ const TEST_ENV_KEYS = [ "OPENCLAW_SKIP_PROVIDERS", "OPENCLAW_TEST_MINIMAL_GATEWAY", ]; +const GATEWAY_CONNECT_TIMEOUT_MS = 120_000; +const EXEC_APPROVAL_E2E_TIMEOUT_MS = 180_000; type Cleanup = () => Promise | void; @@ -128,7 +130,8 @@ describe("gateway-hosted exec approvals", () => { clientDisplayName: "approval operator", mode: GATEWAY_CLIENT_MODES.TEST, scopes: [ADMIN_SCOPE], - timeoutMs: 60_000, + requestTimeoutMs: GATEWAY_CONNECT_TIMEOUT_MS, + timeoutMs: GATEWAY_CONNECT_TIMEOUT_MS, }); cleanup.push(() => disconnectGatewayClient(operator)); @@ -171,5 +174,5 @@ describe("gateway-hosted exec approvals", () => { expect(outcome.status).toBe("completed"); expect(outcome.exitCode).toBe(0); expect(outcome.aggregated).toBe("smoke"); - }, 120_000); + }, EXEC_APPROVAL_E2E_TIMEOUT_MS); }); diff --git a/src/agents/bash-tools.exec.resolve-env-hook.test.ts b/src/agents/bash-tools.exec.resolve-env-hook.test.ts index 4e8390e97a8d..fb5f7139edfb 100644 --- a/src/agents/bash-tools.exec.resolve-env-hook.test.ts +++ b/src/agents/bash-tools.exec.resolve-env-hook.test.ts @@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({ hookRunner: undefined as | { hasHooks: ReturnType; - runResolveExecEnv: ReturnType; + runResolveExecEnv?: ReturnType; runBeforeToolCall?: ReturnType; } | undefined, @@ -245,7 +245,7 @@ describe("exec resolve_exec_env hook wiring", () => { expect(mocks.beforeToolCallParams[0]?.env).toEqual({ EXISTING: "request", }); - expect(mocks.hookRunner.runResolveExecEnv).toHaveBeenCalledTimes(1); + expect(mocks.hookRunner.runResolveExecEnv!).toHaveBeenCalledTimes(1); expect(mocks.gatewayParams[0]?.requestedEnv).toEqual({ EXISTING: "request", PLUGIN_SAFE: "yes", @@ -293,7 +293,7 @@ describe("exec resolve_exec_env hook wiring", () => { expect(mocks.beforeToolCallParams[0]?.env).toEqual({ REQUEST_SAFE: "request", }); - expect(mocks.hookRunner.runResolveExecEnv).toHaveBeenCalledTimes(1); + expect(mocks.hookRunner.runResolveExecEnv!).toHaveBeenCalledTimes(1); expect(mocks.gatewayParams[0]?.requestedEnv).toEqual({ LAZY_PLUGIN_SAFE: "yes", REQUEST_SAFE: "request", @@ -335,13 +335,13 @@ describe("exec resolve_exec_env hook wiring", () => { testExtensionContext, ); - expect(mocks.hookRunner.runResolveExecEnv).toHaveBeenCalledTimes(2); - expect(mocks.hookRunner.runResolveExecEnv).toHaveBeenNthCalledWith( + expect(mocks.hookRunner.runResolveExecEnv!).toHaveBeenCalledTimes(2); + expect(mocks.hookRunner.runResolveExecEnv!).toHaveBeenNthCalledWith( 1, expect.objectContaining({ host: "gateway" }), expect.anything(), ); - expect(mocks.hookRunner.runResolveExecEnv).toHaveBeenNthCalledWith( + expect(mocks.hookRunner.runResolveExecEnv!).toHaveBeenNthCalledWith( 2, expect.objectContaining({ host: "node" }), expect.anything(), @@ -386,7 +386,29 @@ describe("exec resolve_exec_env hook wiring", () => { testExtensionContext, ); - expect(mocks.hookRunner.runResolveExecEnv).not.toHaveBeenCalled(); + expect(mocks.hookRunner.runResolveExecEnv!).not.toHaveBeenCalled(); + expect(mocks.gatewayParams[0]?.requestedEnv).toEqual({ + REQUEST_SAFE: "request", + }); + }); + + it("skips stale hook runners that report resolve_exec_env without the runner method", async () => { + mocks.hookRunner = { + hasHooks: vi.fn((hookName: string) => hookName === "resolve_exec_env"), + }; + + const tool = createExecTool({ + host: "gateway", + security: "full", + ask: "off", + sessionKey: "agent:main:telegram:chat-1", + }); + await tool.execute("call-stale-hook-runner", { + command: "echo ok", + env: { REQUEST_SAFE: "request" }, + yieldMs: 120_000, + }); + expect(mocks.gatewayParams[0]?.requestedEnv).toEqual({ REQUEST_SAFE: "request", }); @@ -431,7 +453,7 @@ describe("exec resolve_exec_env hook wiring", () => { expect(mocks.beforeToolCallParams[0]?.env).toEqual({ REQUEST_SAFE: "request", }); - expect(mocks.hookRunner.runResolveExecEnv).toHaveBeenCalledTimes(1); + expect(mocks.hookRunner.runResolveExecEnv!).toHaveBeenCalledTimes(1); expect(mocks.gatewayParams[0]?.requestedEnv).toEqual({ PLUGIN_SAFE: "yes", REQUEST_SAFE: "request", diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 858da4260394..1b56b89a77d9 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -1411,7 +1411,10 @@ export function createExecTool( return markResolveExecEnvPrepared(params); } const hookRunner = getGlobalHookRunner(); - if (!hookRunner?.hasHooks("resolve_exec_env")) { + if ( + !hookRunner?.hasHooks("resolve_exec_env") || + typeof hookRunner.runResolveExecEnv !== "function" + ) { return markResolveExecEnvPrepared(params); } let host: ExecHost; diff --git a/src/gateway/test-helpers.e2e.ts b/src/gateway/test-helpers.e2e.ts index e0cba5ed09d9..a670bc658c5d 100644 --- a/src/gateway/test-helpers.e2e.ts +++ b/src/gateway/test-helpers.e2e.ts @@ -46,6 +46,7 @@ export async function connectGatewayClient(params: { deviceIdentity?: DeviceIdentity; onEvent?: (evt: { event?: string; payload?: unknown }) => void; connectChallengeTimeoutMs?: number; + requestTimeoutMs?: number; timeoutMs?: number; timeoutMessage?: string; }) { @@ -90,6 +91,7 @@ export async function connectGatewayClient(params: { ...(params.connectChallengeTimeoutMs !== undefined ? { connectChallengeTimeoutMs: params.connectChallengeTimeoutMs } : {}), + ...(params.requestTimeoutMs !== undefined ? { requestTimeoutMs: params.requestTimeoutMs } : {}), clientName: params.clientName ?? GATEWAY_CLIENT_NAMES.TEST, clientDisplayName: params.clientDisplayName ?? "vitest", clientVersion: params.clientVersion ?? "dev", diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 1c778028c2c6..9c0d43573d66 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -109,6 +109,34 @@ function shellQuote(value: string): string { return `'${value.replace(/'/gu, `'\\''`)}'`; } +function runCleanupDefaultPlatform(env: Record, hostArch: string): string { + const script = readFileSync(CLEANUP_DOCKER_SMOKE_PATH, "utf8"); + const match = script.match( + /(resolve_default_cleanup_platform\(\) \{[\s\S]*?\n\})\n\nPLATFORM=/u, + ); + if (!match) { + throw new Error("resolve_default_cleanup_platform was not found"); + } + return execFileSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + `${match[1]}\nuname() { if [[ "\${1:-}" == "-m" ]]; then printf "%s" "$FAKE_UNAME_ARCH"; else command uname "$@"; fi; }\nresolve_default_cleanup_platform`, + ], + { + encoding: "utf8", + env: { + HOME: "/tmp", + PATH: process.env.PATH ?? "", + FAKE_UNAME_ARCH: hostArch, + ...env, + }, + }, + ); +} + describe("docker build helper", () => { it("forces BuildKit for centralized Docker builds", () => { const helper = readFileSync(HELPER_PATH, "utf8"); @@ -156,6 +184,15 @@ describe("docker build helper", () => { expect(installE2eSmoke).not.toContain("docker run --rm \\"); }); + it("runs cleanup smoke on the native ARM platform instead of pulling an amd64 tag", () => { + expect(runCleanupDefaultPlatform({ CI: "true" }, "aarch64")).toBe("linux/arm64"); + expect(runCleanupDefaultPlatform({ GITHUB_ACTIONS: "true" }, "x86_64")).toBe("linux/amd64"); + expect(runCleanupDefaultPlatform({}, "arm64")).toBe("linux/arm64"); + expect( + runCleanupDefaultPlatform({ OPENCLAW_CLEANUP_SMOKE_PLATFORM: "linux/s390x" }, "x86_64"), + ).toBe("linux/s390x"); + }); + it("lets Testbox fall back to building when a reused Docker image is missing", () => { const helper = readFileSync(HELPER_PATH, "utf8"); const e2eImageHelper = readFileSync(DOCKER_E2E_IMAGE_HELPER_PATH, "utf8"); diff --git a/test/scripts/ensure-playwright-chromium.test.ts b/test/scripts/ensure-playwright-chromium.test.ts index e8164654ada0..986415e0b748 100644 --- a/test/scripts/ensure-playwright-chromium.test.ts +++ b/test/scripts/ensure-playwright-chromium.test.ts @@ -2,11 +2,12 @@ import { describe, expect, it, vi } from "vitest"; import { ensurePlaywrightChromium, resolvePlaywrightInstallRunner, + shouldInstallPlaywrightSystemDependencies, } from "../../scripts/ensure-playwright-chromium.mjs"; describe("ensurePlaywrightChromium", () => { - it("does nothing when the browser binary exists", () => { - const spawnSync = vi.fn(); + it("does nothing when the browser binary exists and runs", () => { + const spawnSync = vi.fn(() => ({ status: 0 })); expect( ensurePlaywrightChromium({ @@ -15,11 +16,13 @@ describe("ensurePlaywrightChromium", () => { spawnSync, }), ).toBe(0); - expect(spawnSync).not.toHaveBeenCalled(); + expect(spawnSync).toHaveBeenCalledWith("/cache/chromium/chrome", ["--version"], { + stdio: "ignore", + }); }); it("uses an explicit Chromium executable override", () => { - const spawnSync = vi.fn(); + const spawnSync = vi.fn(() => ({ status: 0 })); expect( ensurePlaywrightChromium({ @@ -29,7 +32,9 @@ describe("ensurePlaywrightChromium", () => { spawnSync, }), ).toBe(0); - expect(spawnSync).not.toHaveBeenCalled(); + expect(spawnSync).toHaveBeenCalledWith("/snap/bin/chromium", ["--version"], { + stdio: "ignore", + }); }); it("fails when the explicit Chromium executable override is missing", () => { @@ -53,7 +58,7 @@ describe("ensurePlaywrightChromium", () => { it("uses a system Chromium binary when Playwright Chromium is missing", () => { const logs: string[] = []; - const spawnSync = vi.fn(); + const spawnSync = vi.fn(() => ({ status: 0 })); expect( ensurePlaywrightChromium({ @@ -63,10 +68,36 @@ describe("ensurePlaywrightChromium", () => { spawnSync, }), ).toBe(0); - expect(spawnSync).not.toHaveBeenCalled(); + expect(spawnSync).toHaveBeenCalledWith("/usr/bin/chromium-browser", ["--version"], { + stdio: "ignore", + }); expect(logs.join("\n")).toContain("Using system Chromium at /usr/bin/chromium-browser"); }); + it("skips a broken system Chromium binary and uses the first runnable candidate", () => { + const logs: string[] = []; + const spawnSync = vi.fn((path: string) => ({ + status: path === "/usr/bin/google-chrome" ? 0 : 127, + })); + + expect( + ensurePlaywrightChromium({ + executablePath: "/cache/chromium/chrome", + existsSync: (path: string) => + path === "/snap/bin/chromium" || path === "/usr/bin/google-chrome", + log: (line: string) => logs.push(line), + spawnSync, + }), + ).toBe(0); + expect(spawnSync).toHaveBeenCalledWith("/snap/bin/chromium", ["--version"], { + stdio: "ignore", + }); + expect(spawnSync).toHaveBeenCalledWith("/usr/bin/google-chrome", ["--version"], { + stdio: "ignore", + }); + expect(logs.join("\n")).toContain("Using system Chromium at /usr/bin/google-chrome"); + }); + it("preserves the intentional missing-browser skip mode", () => { const logs: string[] = []; const spawnSync = vi.fn(); @@ -113,6 +144,116 @@ describe("ensurePlaywrightChromium", () => { ); }); + it("installs Linux system dependencies when Chromium still cannot start in a root lane", () => { + const spawnSync = vi + .fn() + .mockReturnValueOnce({ status: 127 }) + .mockReturnValueOnce({ status: 0 }) + .mockReturnValueOnce({ status: 127 }) + .mockReturnValueOnce({ status: 0 }) + .mockReturnValueOnce({ status: 0 }); + + expect( + ensurePlaywrightChromium({ + cwd: "/repo", + env: { PATH: "/bin" }, + executablePath: "/cache/chromium/chrome", + existsSync: () => true, + getuid: () => 0, + platform: "linux", + spawnSync, + stdio: "pipe", + systemExecutablePath: "", + }), + ).toBe(0); + expect(spawnSync).toHaveBeenNthCalledWith( + 2, + "pnpm", + ["--dir", "ui", "exec", "playwright", "install", "chromium"], + { + cwd: "/repo", + env: { PATH: "/bin" }, + shell: false, + stdio: "pipe", + windowsVerbatimArguments: undefined, + }, + ); + expect(spawnSync).toHaveBeenNthCalledWith( + 4, + "pnpm", + ["--dir", "ui", "exec", "playwright", "install", "--with-deps", "chromium"], + { + cwd: "/repo", + env: { PATH: "/bin" }, + shell: false, + stdio: "pipe", + windowsVerbatimArguments: undefined, + }, + ); + }); + + it("does not install Linux system dependencies for an unprivileged local lane", () => { + const spawnSync = vi + .fn() + .mockReturnValueOnce({ status: 127 }) + .mockReturnValueOnce({ status: 0 }) + .mockReturnValueOnce({ status: 127 }); + + expect( + ensurePlaywrightChromium({ + cwd: "/repo", + env: { PATH: "/bin" }, + executablePath: "/cache/chromium/chrome", + existsSync: () => true, + getuid: () => 501, + platform: "linux", + spawnSync, + stdio: "pipe", + systemExecutablePath: "", + }), + ).toBe(1); + expect(spawnSync).toHaveBeenCalledTimes(3); + }); + + it("reinstalls Chromium when the cached executable exists but cannot start", () => { + const spawnSync = vi + .fn() + .mockReturnValueOnce({ status: 127 }) + .mockReturnValueOnce({ status: 0 }) + .mockReturnValueOnce({ status: 0 }); + + expect( + ensurePlaywrightChromium({ + cwd: "/repo", + env: { PATH: "/bin" }, + executablePath: "/cache/chromium/chrome", + existsSync: () => true, + platform: "linux", + spawnSync, + stdio: "pipe", + systemExecutablePath: "", + }), + ).toBe(0); + expect(spawnSync).toHaveBeenNthCalledWith(1, "/cache/chromium/chrome", ["--version"], { + stdio: "ignore", + }); + expect(spawnSync).toHaveBeenNthCalledWith( + 2, + "pnpm", + ["--dir", "ui", "exec", "playwright", "install", "chromium"], + { + cwd: "/repo", + env: { PATH: "/bin" }, + shell: false, + stdio: "pipe", + windowsVerbatimArguments: undefined, + }, + ); + expect(spawnSync).toHaveBeenNthCalledWith(3, "/cache/chromium/chrome", ["--version"], { + stdio: "ignore", + }); + }); + it("returns the installer status when Playwright install fails", () => { expect( ensurePlaywrightChromium({ @@ -133,10 +274,46 @@ describe("ensurePlaywrightChromium", () => { platform: "win32", }), ).toEqual({ - args: ["/d", "/s", "/c", "pnpm.cmd --dir ui exec playwright install chromium"], + args: [ + "/d", + "/s", + "/c", + "pnpm.cmd --dir ui exec playwright install chromium", + ], command: "C:\\Windows\\System32\\cmd.exe", shell: false, windowsVerbatimArguments: true, }); }); + + it("wraps the dependency install command shim on Windows", () => { + expect( + resolvePlaywrightInstallRunner({ + comSpec: "C:\\Windows\\System32\\cmd.exe", + env: {}, + platform: "win32", + withDeps: true, + }), + ).toEqual({ + args: [ + "/d", + "/s", + "/c", + "pnpm.cmd --dir ui exec playwright install --with-deps chromium", + ], + command: "C:\\Windows\\System32\\cmd.exe", + shell: false, + windowsVerbatimArguments: true, + }); + }); + + it("allows dependency installation for Linux CI lanes", () => { + expect( + shouldInstallPlaywrightSystemDependencies({ + env: { CI: "true" }, + getuid: () => 501, + platform: "linux", + }), + ).toBe(true); + }); }); diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index 1a632a11bb51..18a7e3fd1ceb 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -61,14 +61,45 @@ function runNonrootNodePreflight(version: string, options: { sqlite?: boolean } } } -describe("test-install-sh-docker", () => { - it("defaults local Apple Silicon smoke runs to native arm64 while keeping CI on amd64", () => { - const script = readFileSync(SCRIPT_PATH, "utf8"); +function runDefaultSmokePlatform(env: Record, hostArch: string): string { + const script = readFileSync(SCRIPT_PATH, "utf8"); + const match = script.match( + /(resolve_default_smoke_platform\(\) \{[\s\S]*?\n\})\n\nprint_pack_audit/u, + ); + if (!match) { + throw new Error("resolve_default_smoke_platform was not found"); + } + const result = spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + `${match[1]}\nuname() { if [[ "\${1:-}" == "-m" ]]; then printf "%s" "$FAKE_UNAME_ARCH"; else command uname "$@"; fi; }\nresolve_default_smoke_platform`, + ], + { + encoding: "utf8", + env: { + HOME: "/tmp", + PATH: process.env.PATH ?? "", + FAKE_UNAME_ARCH: hostArch, + ...env, + }, + }, + ); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + return result.stdout; +} - expect(script).toContain("resolve_default_smoke_platform"); - expect(script).toContain('printf "linux/amd64"'); - expect(script).toContain('[[ "$host_os" == "Darwin" && "$host_arch" == "arm64" ]]'); - expect(script).toContain('printf "linux/arm64"'); +describe("test-install-sh-docker", () => { + it("defaults ARM hosts to native arm64 while keeping x64 CI on amd64", () => { + expect(runDefaultSmokePlatform({ CI: "true" }, "aarch64")).toBe("linux/arm64"); + expect(runDefaultSmokePlatform({ GITHUB_ACTIONS: "true" }, "x86_64")).toBe("linux/amd64"); + expect(runDefaultSmokePlatform({}, "arm64")).toBe("linux/arm64"); + expect( + runDefaultSmokePlatform({ OPENCLAW_INSTALL_SMOKE_PLATFORM: "linux/s390x" }, "x86_64"), + ).toBe("linux/s390x"); }); it("supports npm update package specs without a separate expected-version env", () => { diff --git a/ui/src/test-helpers/control-ui-e2e.test.ts b/ui/src/test-helpers/control-ui-e2e.test.ts new file mode 100644 index 000000000000..6b5008bce78a --- /dev/null +++ b/ui/src/test-helpers/control-ui-e2e.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + resolvePlaywrightChromiumExecutablePath, + systemChromiumExecutableCandidates, +} from "./control-ui-e2e.ts"; + +describe("resolvePlaywrightChromiumExecutablePath", () => { + it("uses a runnable system Chromium when the cached Playwright executable cannot start", () => { + const systemExecutable = systemChromiumExecutableCandidates[1]; + + expect( + resolvePlaywrightChromiumExecutablePath( + "/cache/chromium/chrome", + {}, + (candidate) => candidate === systemExecutable, + ), + ).toBe(systemExecutable); + }); + + it("keeps explicit Chromium overrides authoritative", () => { + expect( + resolvePlaywrightChromiumExecutablePath( + "/cache/chromium/chrome", + { PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH: " /custom/chromium " }, + () => false, + ), + ).toBe("/custom/chromium"); + }); +}); diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 3b9b10585560..d12ac13a7dcd 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -1,3 +1,4 @@ +import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { createRequire } from "node:module"; import { createServer as createNetServer } from "node:net"; @@ -83,7 +84,7 @@ export type MockGatewayControls = { }; const chromiumExecutableOverrideEnvKey = "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH"; -const systemChromiumExecutableCandidates = [ +export const systemChromiumExecutableCandidates = [ "/snap/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/chromium", @@ -99,22 +100,26 @@ function resolveRepoRoot(): string { export function resolvePlaywrightChromiumExecutablePath( defaultExecutablePath: string, env: NodeJS.ProcessEnv = process.env, + canRun: (chromiumExecutablePath: string) => boolean = canRunPlaywrightChromium, ): string { const executableOverride = env[chromiumExecutableOverrideEnvKey]?.trim(); if (executableOverride) { return executableOverride; } - if (existsSync(defaultExecutablePath)) { + if (canRun(defaultExecutablePath)) { return defaultExecutablePath; } return ( - systemChromiumExecutableCandidates.find((candidate) => existsSync(candidate)) ?? + systemChromiumExecutableCandidates.find((candidate) => canRun(candidate)) ?? defaultExecutablePath ); } export function canRunPlaywrightChromium(chromiumExecutablePath: string): boolean { - return existsSync(chromiumExecutablePath); + if (!existsSync(chromiumExecutablePath)) { + return false; + } + return spawnSync(chromiumExecutablePath, ["--version"], { stdio: "ignore" }).status === 0; } export async function startControlUiE2eServer(): Promise { diff --git a/ui/src/ui/e2e/chat-flow.e2e.test.ts b/ui/src/ui/e2e/chat-flow.e2e.test.ts index 7451dba12645..ee989fb2e0c7 100644 --- a/ui/src/ui/e2e/chat-flow.e2e.test.ts +++ b/ui/src/ui/e2e/chat-flow.e2e.test.ts @@ -168,7 +168,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { beforeAll(async () => { if (!chromiumAvailable) { throw new Error( - `Playwright Chromium is not installed at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install chromium\`, set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a compatible browser, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`, + `Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a compatible browser, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`, ); } server = await startControlUiE2eServer(); diff --git a/ui/src/ui/e2e/chat-picker-pagination.e2e.test.ts b/ui/src/ui/e2e/chat-picker-pagination.e2e.test.ts index 3c7c1ece78c9..e56ce6548547 100644 --- a/ui/src/ui/e2e/chat-picker-pagination.e2e.test.ts +++ b/ui/src/ui/e2e/chat-picker-pagination.e2e.test.ts @@ -90,7 +90,7 @@ describeControlUiE2e("Control UI chat picker mocked Gateway E2E", () => { beforeAll(async () => { if (!chromiumAvailable) { throw new Error( - `Playwright Chromium is not installed at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`, + `Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`, ); } server = await startControlUiE2eServer(); diff --git a/ui/src/ui/e2e/cron-filters.e2e.test.ts b/ui/src/ui/e2e/cron-filters.e2e.test.ts index 57b55873dbab..78cba5357f30 100644 --- a/ui/src/ui/e2e/cron-filters.e2e.test.ts +++ b/ui/src/ui/e2e/cron-filters.e2e.test.ts @@ -115,7 +115,7 @@ describeControlUiE2e("Control UI cron mocked Gateway E2E", () => { beforeAll(async () => { if (!chromiumAvailable) { throw new Error( - `Playwright Chromium is not installed at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`, + `Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`, ); } server = await startControlUiE2eServer();