test: harden Parallels fresh install smoke

This commit is contained in:
Peter Steinberger
2026-04-21 05:28:06 +01:00
parent b485ee7e36
commit 6d409a6182
12 changed files with 205 additions and 53 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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",
});

View File

@@ -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 () => {

View File

@@ -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";

View File

@@ -29,7 +29,7 @@ describe("execSchtasks", () => {
});
expect(runCommandWithTimeout).toHaveBeenCalledWith(["schtasks", "/Query"], {
timeoutMs: 15_000,
noOutputTimeoutMs: 5_000,
noOutputTimeoutMs: 30_000,
});
});

View File

@@ -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[],

View File

@@ -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();

View File

@@ -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 });

View File

@@ -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");

View File

@@ -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");
}
}