mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(ci): harden ARM smoke and browser checks
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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> | 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);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({
|
||||
hookRunner: undefined as
|
||||
| {
|
||||
hasHooks: ReturnType<typeof vi.fn>;
|
||||
runResolveExecEnv: ReturnType<typeof vi.fn>;
|
||||
runResolveExecEnv?: ReturnType<typeof vi.fn>;
|
||||
runBeforeToolCall?: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
| 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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -109,6 +109,34 @@ function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/gu, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function runCleanupDefaultPlatform(env: Record<string, string>, 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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string>, 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", () => {
|
||||
|
||||
29
ui/src/test-helpers/control-ui-e2e.test.ts
Normal file
29
ui/src/test-helpers/control-ui-e2e.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<ControlUiE2eServer> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user