From 6d409a61820c008bd5cf64c0c22dee31a3792bb7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 05:28:06 +0100 Subject: [PATCH] test: harden Parallels fresh install smoke --- scripts/e2e/parallels-linux-smoke.sh | 10 +- scripts/e2e/parallels-windows-smoke.sh | 12 +- src/cli/daemon-cli/lifecycle.test.ts | 42 +++++-- src/cli/daemon-cli/lifecycle.ts | 16 ++- src/cli/daemon-cli/restart-health.test.ts | 7 +- src/cli/daemon-cli/restart-health.ts | 2 +- src/daemon/schtasks-exec.test.ts | 2 +- src/daemon/schtasks-exec.ts | 2 +- src/daemon/schtasks.startup-fallback.test.ts | 15 +++ src/daemon/schtasks.ts | 2 +- src/plugins/bundled-runtime-deps.test.ts | 115 +++++++++++++++++-- src/plugins/bundled-runtime-deps.ts | 33 +++--- 12 files changed, 205 insertions(+), 53 deletions(-) diff --git a/scripts/e2e/parallels-linux-smoke.sh b/scripts/e2e/parallels-linux-smoke.sh index 2002bc827d6..968f6e3ebcf 100644 --- a/scripts/e2e/parallels-linux-smoke.sh +++ b/scripts/e2e/parallels-linux-smoke.sh @@ -36,7 +36,7 @@ TIMEOUT_INSTALL_S=420 TIMEOUT_VERIFY_S=90 TIMEOUT_ONBOARD_S=180 TIMEOUT_AGENT_S=180 -TIMEOUT_GATEWAY_S=90 +TIMEOUT_GATEWAY_S=240 FRESH_MAIN_STATUS="skip" FRESH_MAIN_VERSION="skip" @@ -473,6 +473,10 @@ else: PY } +source_tree_dirty_for_build() { + [[ -n "$(git status --porcelain -- src ui packages extensions package.json pnpm-lock.yaml 'tsconfig*.json' 2>/dev/null)" ]] +} + acquire_build_lock() { local owner_pid="" while ! mkdir "$BUILD_LOCK_DIR" 2>/dev/null; do @@ -500,7 +504,7 @@ ensure_current_build() { acquire_build_lock head="$(git rev-parse HEAD)" build_commit="$(current_build_commit)" - if [[ "$build_commit" == "$head" ]]; then + if [[ "$build_commit" == "$head" ]] && ! source_tree_dirty_for_build; then release_build_lock return fi @@ -866,8 +870,8 @@ run_fresh_main_lane() { phase_run "fresh.install-main" "$TIMEOUT_INSTALL_S" install_main_tgz "$host_ip" "openclaw-main-fresh.tgz" FRESH_MAIN_VERSION="$(extract_last_version "$(phase_log_path fresh.install-main)")" phase_run "fresh.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version - phase_run "fresh.inject-bad-plugin" "$TIMEOUT_VERIFY_S" inject_bad_plugin_fixture phase_run "fresh.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard + phase_run "fresh.inject-bad-plugin" "$TIMEOUT_VERIFY_S" inject_bad_plugin_fixture phase_run "fresh.gateway-start" "$TIMEOUT_GATEWAY_S" start_gateway_background phase_run "fresh.bad-plugin-diagnostic" "$TIMEOUT_VERIFY_S" verify_bad_plugin_diagnostic phase_run "fresh.gateway-status" "$TIMEOUT_VERIFY_S" show_gateway_status_compat diff --git a/scripts/e2e/parallels-windows-smoke.sh b/scripts/e2e/parallels-windows-smoke.sh index a5e362816b5..ef7552f79a1 100644 --- a/scripts/e2e/parallels-windows-smoke.sh +++ b/scripts/e2e/parallels-windows-smoke.sh @@ -44,11 +44,11 @@ TIMEOUT_INSTALL_S=420 TIMEOUT_UPDATE_S=300 TIMEOUT_UPDATE_POLL_GRACE_S=60 TIMEOUT_VERIFY_S=120 -TIMEOUT_ONBOARD_S=240 +TIMEOUT_ONBOARD_S=600 TIMEOUT_ONBOARD_PHASE_S=$((TIMEOUT_ONBOARD_S + 120)) # verify_gateway_reachable runs six 30s probes plus short retry sleeps. -TIMEOUT_GATEWAY_S=240 -TIMEOUT_AGENT_S=360 +TIMEOUT_GATEWAY_S=420 +TIMEOUT_AGENT_S=600 FRESH_MAIN_STATUS="skip" FRESH_MAIN_VERSION="skip" @@ -860,6 +860,10 @@ else: PY } +source_tree_dirty_for_build() { + [[ -n "$(git status --porcelain -- src ui packages extensions package.json pnpm-lock.yaml 'tsconfig*.json' 2>/dev/null)" ]] +} + acquire_build_lock() { local owner_pid="" while ! mkdir "$BUILD_LOCK_DIR" 2>/dev/null; do @@ -887,7 +891,7 @@ ensure_current_build() { acquire_build_lock head="$(git rev-parse HEAD)" build_commit="$(current_build_commit)" - if [[ "$build_commit" == "$head" ]]; then + if [[ "$build_commit" == "$head" ]] && ! source_tree_dirty_for_build; then release_build_lock return fi diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index 266d85e0775..952eb3561e3 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -39,17 +39,16 @@ const resolveGatewayPort = vi.hoisted(() => vi.fn((_cfg?: unknown, _env?: unknow const findVerifiedGatewayListenerPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []); const signalVerifiedGatewayPidSync = vi.fn<(pid: number, signal: "SIGTERM" | "SIGUSR1") => void>(); const formatGatewayPidList = vi.fn<(pids: number[]) => string>((pids) => pids.join(", ")); -const probeGateway = - vi.fn< - (opts: { - url: string; - auth?: { token?: string; password?: string }; - timeoutMs: number; - }) => Promise<{ - ok: boolean; - configSnapshot: unknown; - }> - >(); +const probeGateway = vi.fn< + (opts: { + url: string; + auth?: { token?: string; password?: string }; + timeoutMs: number; + }) => Promise<{ + ok: boolean; + configSnapshot: unknown; + }> +>(); const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true); const loadConfig = vi.hoisted(() => vi.fn(() => ({}))); const recoverInstalledLaunchAgent = vi.hoisted(() => vi.fn()); @@ -290,6 +289,27 @@ describe("runDaemonRestart health checks", () => { expect(renderRestartDiagnostics).toHaveBeenCalledTimes(1); }); + it("waits longer for Windows gateway restart health", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + waitForGatewayHealthyRestart.mockResolvedValue({ + healthy: true, + staleGatewayPids: [], + runtime: { status: "running" }, + portUsage: { port: 18789, status: "busy", listeners: [], hints: [] }, + }); + + await runDaemonRestart({ json: true }); + + expect(waitForGatewayHealthyRestart).toHaveBeenCalledWith( + expect.objectContaining({ + attempts: 360, + delayMs: 500, + includeUnknownListenersAsStale: true, + port: 18789, + }), + ); + }); + it("fails restart with a stopped-free message when the waiter exits early", async () => { const { formatCliCommand } = await import("../command-format.js"); const unhealthy: RestartHealthSnapshot = { diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 306c1d73ce1..b4324107f44 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -33,6 +33,13 @@ import type { DaemonLifecycleOptions } from "./types.js"; const POST_RESTART_HEALTH_ATTEMPTS = DEFAULT_RESTART_HEALTH_ATTEMPTS; const POST_RESTART_HEALTH_DELAY_MS = DEFAULT_RESTART_HEALTH_DELAY_MS; +const WINDOWS_POST_RESTART_HEALTH_TIMEOUT_MS = 180_000; + +function postRestartHealthAttempts(): number { + return process.platform === "win32" + ? Math.ceil(WINDOWS_POST_RESTART_HEALTH_TIMEOUT_MS / POST_RESTART_HEALTH_DELAY_MS) + : POST_RESTART_HEALTH_ATTEMPTS; +} function formatRestartFailure(params: { health: GatewayRestartSnapshot; @@ -183,7 +190,8 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi const restartPort = await resolveGatewayLifecyclePort(service).catch(() => resolveGatewayPortFallback(), ); - const restartWaitMs = POST_RESTART_HEALTH_ATTEMPTS * POST_RESTART_HEALTH_DELAY_MS; + const restartHealthAttempts = postRestartHealthAttempts(); + const restartWaitMs = restartHealthAttempts * POST_RESTART_HEALTH_DELAY_MS; const restartWaitSeconds = Math.round(restartWaitMs / 1000); return await runServiceRestart({ @@ -204,7 +212,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi if (restartedWithoutServiceManager) { const health = await waitForGatewayHealthyListener({ port: restartPort, - attempts: POST_RESTART_HEALTH_ATTEMPTS, + attempts: restartHealthAttempts, delayMs: POST_RESTART_HEALTH_DELAY_MS, }); if (health.healthy) { @@ -233,7 +241,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi let health = await waitForGatewayHealthyRestart({ service, port: restartPort, - attempts: POST_RESTART_HEALTH_ATTEMPTS, + attempts: restartHealthAttempts, delayMs: POST_RESTART_HEALTH_DELAY_MS, includeUnknownListenersAsStale: process.platform === "win32", }); @@ -254,7 +262,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi health = await waitForGatewayHealthyRestart({ service, port: restartPort, - attempts: POST_RESTART_HEALTH_ATTEMPTS, + attempts: restartHealthAttempts, delayMs: POST_RESTART_HEALTH_DELAY_MS, includeUnknownListenersAsStale: process.platform === "win32", }); diff --git a/src/cli/daemon-cli/restart-health.test.ts b/src/cli/daemon-cli/restart-health.test.ts index d5438ce3eaf..9f0dbf17f82 100644 --- a/src/cli/daemon-cli/restart-health.test.ts +++ b/src/cli/daemon-cli/restart-health.test.ts @@ -89,6 +89,7 @@ async function inspectAmbiguousOwnershipWithProbe( } async function waitForStoppedFreeGatewayRestart() { + const attempts = process.platform === "win32" ? 360 : 120; const service = makeGatewayService({ status: "stopped" }); inspectPortUsage.mockResolvedValue({ port: 18789, @@ -101,7 +102,7 @@ async function waitForStoppedFreeGatewayRestart() { return waitForGatewayHealthyRestart({ service, port: 18789, - attempts: 120, + attempts, delayMs: 500, }); } @@ -292,9 +293,9 @@ describe("inspectGatewayRestart", () => { runtime: { status: "stopped" }, portUsage: { status: "free" }, waitOutcome: "stopped-free", - elapsedMs: 27_500, + elapsedMs: 92_500, }); - expect(sleep).toHaveBeenCalledTimes(55); + expect(sleep).toHaveBeenCalledTimes(185); }); it("annotates timeout waits when the health loop exhausts all attempts", async () => { diff --git a/src/cli/daemon-cli/restart-health.ts b/src/cli/daemon-cli/restart-health.ts index b326f01af38..4c9a7ddbf1e 100644 --- a/src/cli/daemon-cli/restart-health.ts +++ b/src/cli/daemon-cli/restart-health.ts @@ -20,7 +20,7 @@ export const DEFAULT_RESTART_HEALTH_ATTEMPTS = Math.ceil( DEFAULT_RESTART_HEALTH_TIMEOUT_MS / DEFAULT_RESTART_HEALTH_DELAY_MS, ); const STOPPED_FREE_EARLY_EXIT_GRACE_MS = 10_000; -const WINDOWS_STOPPED_FREE_EARLY_EXIT_GRACE_MS = 25_000; +const WINDOWS_STOPPED_FREE_EARLY_EXIT_GRACE_MS = 90_000; export type GatewayRestartWaitOutcome = "healthy" | "stale-pids" | "stopped-free" | "timeout"; diff --git a/src/daemon/schtasks-exec.test.ts b/src/daemon/schtasks-exec.test.ts index 339ec2f4fd8..8f86f363386 100644 --- a/src/daemon/schtasks-exec.test.ts +++ b/src/daemon/schtasks-exec.test.ts @@ -29,7 +29,7 @@ describe("execSchtasks", () => { }); expect(runCommandWithTimeout).toHaveBeenCalledWith(["schtasks", "/Query"], { timeoutMs: 15_000, - noOutputTimeoutMs: 5_000, + noOutputTimeoutMs: 30_000, }); }); diff --git a/src/daemon/schtasks-exec.ts b/src/daemon/schtasks-exec.ts index cf27d927341..9036c5003ac 100644 --- a/src/daemon/schtasks-exec.ts +++ b/src/daemon/schtasks-exec.ts @@ -1,7 +1,7 @@ import { runCommandWithTimeout } from "../process/exec.js"; const SCHTASKS_TIMEOUT_MS = 15_000; -const SCHTASKS_NO_OUTPUT_TIMEOUT_MS = 5_000; +const SCHTASKS_NO_OUTPUT_TIMEOUT_MS = 30_000; export async function execSchtasks( args: string[], diff --git a/src/daemon/schtasks.startup-fallback.test.ts b/src/daemon/schtasks.startup-fallback.test.ts index 04d4ef11e6b..fd4b7176933 100644 --- a/src/daemon/schtasks.startup-fallback.test.ts +++ b/src/daemon/schtasks.startup-fallback.test.ts @@ -220,6 +220,21 @@ describe("Windows startup fallback", () => { }); }); + it("falls back to a Startup-folder launcher when schtasks availability is slow", async () => { + await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { + schtasksResponses.push( + { code: 124, stdout: "", stderr: "schtasks produced no output for 30000ms" }, + { code: 124, stdout: "", stderr: "schtasks produced no output for 30000ms" }, + { code: 124, stdout: "", stderr: "schtasks produced no output for 30000ms" }, + ); + + await installGatewayScheduledTask(env); + + await expect(fs.access(resolveStartupEntryPath(env))).resolves.toBeUndefined(); + expectStartupFallbackSpawn(env); + }); + }); + it("launches the task script directly when schtasks /Run is accepted but never starts the task", async () => { await withWindowsEnv("openclaw-win-startup-", async ({ env }) => { fastForwardTaskStartWait(); diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 2df5875b95d..93c7020a7bb 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -578,7 +578,7 @@ async function writeScheduledTaskScript({ scriptPath: string; taskDescription: string; }> { - await assertSchtasksAvailable(); + await assertSchtasksAvailable().catch(() => undefined); const scriptPath = resolveTaskScriptPath(env); await fs.mkdir(path.dirname(scriptPath), { recursive: true }); const taskDescription = resolveGatewayServiceDescription({ env, environment, description }); diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 715b92fffe4..d91ed800b6b 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -1,12 +1,19 @@ +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { ensureBundledPluginRuntimeDeps, + installBundledRuntimeDeps, resolveBundledRuntimeDepsNpmRunner, } from "./bundled-runtime-deps.js"; +vi.mock("node:child_process", () => ({ + spawnSync: vi.fn(), +})); + +const spawnSyncMock = vi.mocked(spawnSync); const tempDirs: string[] = []; function makeTempDir(): string { @@ -16,12 +23,29 @@ function makeTempDir(): string { } afterEach(() => { + vi.restoreAllMocks(); + spawnSyncMock.mockReset(); for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); describe("resolveBundledRuntimeDepsNpmRunner", () => { + it("uses npm_execpath through node on Windows when available", () => { + const runner = resolveBundledRuntimeDepsNpmRunner({ + env: { npm_execpath: "C:\\node\\node_modules\\npm\\bin\\npm-cli.js" }, + execPath: "C:\\Program Files\\nodejs\\node.exe", + existsSync: (candidate) => candidate === "C:\\node\\node_modules\\npm\\bin\\npm-cli.js", + npmArgs: ["install", "acpx@0.5.3"], + platform: "win32", + }); + + expect(runner).toEqual({ + command: "C:\\Program Files\\nodejs\\node.exe", + args: ["C:\\node\\node_modules\\npm\\bin\\npm-cli.js", "install", "acpx@0.5.3"], + }); + }); + it("uses the Node-adjacent npm CLI on Windows", () => { const execPath = "C:\\Program Files\\nodejs\\node.exe"; const npmCliPath = path.win32.resolve( @@ -43,16 +67,20 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => { }); }); - it("does not fall back to bare npm on Windows", () => { - expect(() => - resolveBundledRuntimeDepsNpmRunner({ - env: {}, - execPath: "C:\\Program Files\\nodejs\\node.exe", - existsSync: () => false, - npmArgs: ["install"], - platform: "win32", - }), - ).toThrow("failed to resolve a toolchain-local npm"); + it("falls back to npm.cmd through shell on Windows", () => { + const runner = resolveBundledRuntimeDepsNpmRunner({ + env: {}, + execPath: "C:\\Program Files\\nodejs\\node.exe", + existsSync: () => false, + npmArgs: ["install"], + platform: "win32", + }); + + expect(runner).toEqual({ + command: "npm.cmd", + args: ["install"], + shell: true, + }); }); it("prefixes PATH with the active Node directory on POSIX", () => { @@ -74,7 +102,72 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => { }, }); }); +}); +describe("installBundledRuntimeDeps", () => { + it("uses the npm cmd shim on Windows", () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + vi.spyOn(fs, "existsSync").mockReturnValue(false); + spawnSyncMock.mockReturnValue({ + pid: 123, + output: [], + stdout: "", + stderr: "", + signal: null, + status: 0, + }); + + installBundledRuntimeDeps({ + installRoot: "C:\\openclaw", + missingSpecs: ["acpx@0.5.3"], + env: { npm_config_prefix: "C:\\prefix", PATH: "C:\\node" }, + }); + + expect(spawnSyncMock).toHaveBeenCalledWith( + "npm.cmd", + [ + "install", + "--prefix", + "C:\\openclaw", + "--omit=dev", + "--no-save", + "--package-lock=false", + "--ignore-scripts", + "--legacy-peer-deps", + "acpx@0.5.3", + ], + expect.objectContaining({ + cwd: "C:\\openclaw", + shell: true, + env: expect.not.objectContaining({ + npm_config_prefix: expect.any(String), + }), + }), + ); + }); + + it("includes spawn errors in install failures", () => { + spawnSyncMock.mockReturnValue({ + pid: 0, + output: [], + stdout: "", + stderr: "", + signal: null, + status: null, + error: new Error("spawn npm ENOENT"), + }); + + expect(() => + installBundledRuntimeDeps({ + installRoot: "/tmp/openclaw", + missingSpecs: ["browser-runtime@1.0.0"], + env: {}, + }), + ).toThrow("spawn npm ENOENT"); + }); +}); + +describe("ensureBundledPluginRuntimeDeps", () => { it("installs all direct plugin runtime deps when one is missing", () => { const packageRoot = makeTempDir(); const extensionsRoot = path.join(packageRoot, "dist", "extensions"); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 38bd7c75522..ff1a99d52e4 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -30,6 +30,7 @@ export type BundledRuntimeDepsNpmRunner = { command: string; args: string[]; env?: NodeJS.ProcessEnv; + shell?: boolean; }; function dependencySentinelPath(depName: string): string { @@ -99,12 +100,18 @@ export function resolveBundledRuntimeDepsNpmRunner(params: { const platform = params.platform ?? process.platform; const pathImpl = platform === "win32" ? path.win32 : path.posix; const nodeDir = pathImpl.dirname(execPath); + const npmExecPath = normalizeOptionalLowercaseString(env.npm_execpath) + ? env.npm_execpath + : undefined; const npmCliCandidates = [ + npmExecPath, pathImpl.resolve(nodeDir, "../lib/node_modules/npm/bin/npm-cli.js"), pathImpl.resolve(nodeDir, "node_modules/npm/bin/npm-cli.js"), - ]; - const npmCliPath = npmCliCandidates.find((candidate) => existsSync(candidate)); + ].filter((candidate): candidate is string => Boolean(candidate)); + const npmCliPath = npmCliCandidates.find( + (candidate) => pathImpl.isAbsolute(candidate) && existsSync(candidate), + ); if (npmCliPath) { return { command: execPath, @@ -120,10 +127,11 @@ export function resolveBundledRuntimeDepsNpmRunner(params: { args: params.npmArgs, }; } - throw new Error( - `failed to resolve a toolchain-local npm next to ${execPath}. ` + - `Checked: ${[...npmCliCandidates, npmExePath].join(", ")}.`, - ); + return { + command: "npm.cmd", + args: params.npmArgs, + shell: true, + }; } const pathKey = resolvePathEnvKey(env, platform); @@ -140,7 +148,6 @@ export function resolveBundledRuntimeDepsNpmRunner(params: { }, }; } - function readBundledPluginChannels(pluginDir: string): string[] { const manifest = readJsonObject(path.join(pluginDir, "openclaw.plugin.json")); const channels = manifest?.channels; @@ -370,13 +377,13 @@ export function installBundledRuntimeDeps(params: { encoding: "utf8", env: createNestedNpmInstallEnv(npmRunner.env ?? params.env), stdio: "pipe", - shell: false, + shell: npmRunner.shell ?? false, }); - if (result.error) { - throw result.error; - } - if (result.status !== 0) { - const output = [result.stderr, result.stdout].filter(Boolean).join("\n").trim(); + if (result.status !== 0 || result.error) { + const output = [result.error?.message, result.stderr, result.stdout] + .filter(Boolean) + .join("\n") + .trim(); throw new Error(output || "npm install failed"); } }