Files
openclaw/test/scripts/openclaw-cross-os-release-checks.test.ts

1082 lines
38 KiB
TypeScript

import {
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { createServer as createNetServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { describe, expect, it } from "vitest";
import { LOCAL_BUILD_METADATA_DIST_PATHS } from "../../scripts/lib/local-build-metadata-paths.mjs";
import {
agentOutputHasExpectedOkMarker,
buildCrossOsReleaseSmokePluginAllowlist,
buildPackagedUpgradeUpdateArgs,
buildReleaseOnboardArgs,
buildWindowsDevUpdateToolchainCheckScript,
buildWindowsFreshShellVersionCheckScript,
buildInstalledBrowserOverrideImportProbeScript,
buildNpmGlobalInstallArgs,
buildWindowsPathBootstrapScript,
canConnectToLoopbackPort,
buildDiscordSmokeGuildsConfig,
buildRealUpdateEnv,
CROSS_OS_GATEWAY_READY_TIMEOUT_MS,
CROSS_OS_GATEWAY_STATUS_COMMAND_TIMEOUT_MS,
CROSS_OS_GATEWAY_STATUS_RPC_TIMEOUT_MS,
CROSS_OS_RELEASE_SMOKE_TOOLS_PROFILE,
CROSS_OS_WINDOWS_GATEWAY_READY_TIMEOUT_MS,
CROSS_OS_WINDOWS_PACKAGED_UPGRADE_STEP_TIMEOUT_SECONDS,
CROSS_OS_WINDOWS_PACKAGED_UPGRADE_WRAPPER_TIMEOUT_MS,
CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS,
CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS,
CROSS_OS_AGENT_TURN_TIMEOUT_SECONDS,
CROSS_OS_COMMAND_HEARTBEAT_SECONDS,
isImmutableReleaseRef,
isRecoverableWindowsPackagedUpgradeSwapCleanupFailure,
isRecoverableWindowsPackagedUpgradeTimeoutError,
looksLikeReleaseVersionRef,
normalizeRequestedRef,
normalizeWindowsCommandShimPath,
normalizeWindowsInstalledCliPath,
parseCrossOsSuiteFilter,
parseArgs,
packageHasScript,
readInstalledVersion,
readRunnerOverrideEnv,
resolveExplicitBaselineVersion,
resolveInstalledPackageRootFromCliPath,
resolveProviderConfig,
resolveDevUpdateVerificationRef,
resolveInstalledPrefixDirFromCliPath,
resolvePublishedInstallerUrl,
resolveRequestedSuites,
resolveRunnerMatrix,
resolveStaticFileContentType,
shouldExerciseManagedGatewayLifecycleAfterInstall,
shouldRunPackagedUpgradeStatusProbe,
shouldRunWindowsInstalledBrowserOverrideImportSmoke,
shouldSkipInstallerDaemonHealthCheck,
shouldStopManagedGatewayBeforeManualFallback,
shouldRunMainChannelDevUpdate,
shouldRetryCrossOsAgentTurnError,
shouldSkipOptionalCrossOsAgentTurnError,
shouldUseManagedGatewayForInstallerRuntime,
shouldUseManagedGatewayService,
verifyDevUpdateStatus,
verifyPackagedUpgradeUpdateResult,
writePackageDistInventoryForCandidate,
} from "../../scripts/openclaw-cross-os-release-checks.ts";
describe("scripts/openclaw-cross-os-release-checks", () => {
it("keeps dashboard smoke patient enough for cold packaged gateway startup", () => {
expect(CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS).toBeGreaterThanOrEqual(120_000);
expect(CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS).toBeGreaterThanOrEqual(10_000);
});
it("keeps gateway RPC status probes patient enough for live release startup", () => {
expect(CROSS_OS_GATEWAY_STATUS_RPC_TIMEOUT_MS).toBeGreaterThanOrEqual(30_000);
expect(CROSS_OS_GATEWAY_STATUS_COMMAND_TIMEOUT_MS).toBeGreaterThan(
CROSS_OS_GATEWAY_STATUS_RPC_TIMEOUT_MS,
);
expect(CROSS_OS_GATEWAY_READY_TIMEOUT_MS).toBeGreaterThanOrEqual(180_000);
expect(CROSS_OS_WINDOWS_GATEWAY_READY_TIMEOUT_MS).toBeGreaterThanOrEqual(300_000);
});
it("gives the Windows packaged updater wrapper enough headroom for OpenClaw timeout output", () => {
expect(CROSS_OS_WINDOWS_PACKAGED_UPGRADE_STEP_TIMEOUT_SECONDS).toBeGreaterThanOrEqual(20 * 60);
expect(CROSS_OS_WINDOWS_PACKAGED_UPGRADE_WRAPPER_TIMEOUT_MS).toBeGreaterThan(
CROSS_OS_WINDOWS_PACKAGED_UPGRADE_STEP_TIMEOUT_SECONDS * 1000,
);
expect(
CROSS_OS_WINDOWS_PACKAGED_UPGRADE_WRAPPER_TIMEOUT_MS -
CROSS_OS_WINDOWS_PACKAGED_UPGRADE_STEP_TIMEOUT_SECONDS * 1000,
).toBeGreaterThanOrEqual(5 * 60 * 1000);
});
it("prints command heartbeats before long release commands hit job timeouts", () => {
expect(CROSS_OS_COMMAND_HEARTBEAT_SECONDS).toBeGreaterThan(0);
expect(CROSS_OS_COMMAND_HEARTBEAT_SECONDS).toBeLessThanOrEqual(60);
});
it("accepts OK agent output from the captured log when stdout is empty", () => {
const dir = mkdtempSync(join(tmpdir(), "openclaw-cross-os-agent-output-"));
try {
const logPath = join(dir, "agent.log");
writeFileSync(
logPath,
[
"2026-04-24T15:00:00.000Z command stdout",
JSON.stringify({
finalAssistantVisibleText: "OK",
payloads: [{ type: "text", text: "OK" }],
}),
].join("\n"),
);
expect(agentOutputHasExpectedOkMarker("", { logPath })).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("retries transient agent-turn failures", () => {
expect(
shouldRetryCrossOsAgentTurnError(
new Error("Agent output did not contain the expected OK marker."),
),
).toBe(true);
expect(
shouldRetryCrossOsAgentTurnError(
new Error(
"The model did not produce a response before the model idle timeout. Please try again.",
),
),
).toBe(true);
expect(
shouldRetryCrossOsAgentTurnError(
new Error("gateway request timeout for agent after 210000ms"),
),
).toBe(true);
expect(
shouldRetryCrossOsAgentTurnError(
new Error("Command timed out and could not be terminated cleanly"),
),
).toBe(true);
});
it("skips optional live agent turns only for model availability failures", () => {
const dir = mkdtempSync(join(tmpdir(), "openclaw-cross-os-agent-skip-"));
try {
const logPath = join(dir, "agent.log");
writeFileSync(
logPath,
JSON.stringify({
status: "timeout",
result: {
payloads: [
{
text: "Request timed out before a response was generated.",
},
],
},
}),
);
expect(
shouldSkipOptionalCrossOsAgentTurnError(
new Error("Agent output did not contain the expected OK marker."),
logPath,
),
).toBe(true);
expect(
shouldSkipOptionalCrossOsAgentTurnError(
new Error("document-extract: failed to install bundled runtime deps"),
logPath,
),
).toBe(false);
expect(
shouldSkipOptionalCrossOsAgentTurnError(
new Error("Agent output did not contain the expected OK marker."),
join(dir, "missing.log"),
),
).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("allows cross-OS provider smoke models to use faster CI overrides", () => {
expect(
resolveProviderConfig("openai", {
OPENCLAW_CROSS_OS_OPENAI_MODEL: "openai/gpt-5.4-mini",
})?.model,
).toBe("openai/gpt-5.4-mini");
expect(
resolveProviderConfig("openai", {
OPENCLAW_CROSS_OS_MODEL: "openai/gpt-5.4-nano",
})?.model,
).toBe("openai/gpt-5.4-nano");
expect(resolveProviderConfig("openai", {})?.model).toBe("openai/gpt-5.4");
});
it("keeps release cross-OS OpenAI smoke on GPT-5.4", () => {
const workflow = readFileSync(
".github/workflows/openclaw-cross-os-release-checks-reusable.yml",
"utf8",
);
const releaseChecks = readFileSync(".github/workflows/openclaw-release-checks.yml", "utf8");
expect(workflow).toContain(
"OPENCLAW_CROSS_OS_OPENAI_MODEL: ${{ inputs.openai_model || vars.OPENCLAW_CROSS_OS_OPENAI_MODEL || 'openai/gpt-5.4' }}",
);
expect(releaseChecks).toContain("openai_model: openai/gpt-5.4");
});
it("keeps release smoke plugin allowlists focused on agent-turn essentials", () => {
const allowlist = buildCrossOsReleaseSmokePluginAllowlist({ extensionId: "openai" });
expect(allowlist).toEqual(expect.arrayContaining(["openai", "acpx"]));
expect(allowlist).not.toContain("memory-core");
expect(allowlist).not.toContain("document-extract");
expect(allowlist).not.toContain("microsoft");
expect(allowlist).not.toContain("web-readability");
});
it("can stage packaged-upgrade baselines without npm lifecycle scripts", () => {
expect(buildNpmGlobalInstallArgs("openclaw@2026.5.2", { ignoreScripts: true })).toEqual([
"install",
"-g",
"openclaw@2026.5.2",
"--omit=dev",
"--no-fund",
"--no-audit",
"--ignore-scripts",
"--loglevel=notice",
]);
});
it("keeps packaged-upgrade release updates out of service restart flow", () => {
const args = buildPackagedUpgradeUpdateArgs("http://127.0.0.1:49152/openclaw-current.tgz");
expect(args.slice(0, 6)).toEqual([
"update",
"--tag",
"http://127.0.0.1:49152/openclaw-current.tgz",
"--yes",
"--json",
"--no-restart",
]);
expect(args.at(-2)).toBe("--timeout");
});
it("keeps cross-OS live smoke agent turns on GPT-5-safe timeouts and minimal context", () => {
const source = readFileSync("scripts/openclaw-cross-os-release-checks.ts", "utf8");
const providerOverride = "models.providers.${params.providerConfig.extensionId}";
expect(CROSS_OS_RELEASE_SMOKE_TOOLS_PROFILE).toBe("minimal");
expect(source).toContain('"--thinking",\n "minimal"');
expect(source.match(/"tools\.profile", CROSS_OS_RELEASE_SMOKE_TOOLS_PROFILE/g)).toHaveLength(2);
expect(CROSS_OS_AGENT_TURN_TIMEOUT_SECONDS).toBeGreaterThanOrEqual(600);
expect(source).toContain("buildReleaseProviderConfigOverride");
expect(source).toContain("models: []");
expect(source).toContain('"--merge"');
expect(source).toContain(providerOverride);
expect(source).not.toContain("models.providers.${params.providerConfig.extensionId}.baseUrl");
expect(source).toContain('"--timeout",\n String(CROSS_OS_AGENT_TURN_TIMEOUT_SECONDS)');
expect(source.match(/buildReleaseAgentTurnArgs\(sessionId\)/g)?.length).toBeGreaterThanOrEqual(
2,
);
});
it("treats explicit empty-string args as values instead of boolean flags", () => {
expect(parseArgs(["--ubuntu-runner", "", "--mode", "both"])).toEqual({
"ubuntu-runner": "",
mode: "both",
});
});
it("detects release refs and keeps branch refs out of release-only logic", () => {
expect(looksLikeReleaseVersionRef("2026.4.5")).toBe(true);
expect(looksLikeReleaseVersionRef("refs/tags/v2026.4.5-beta.1")).toBe(true);
expect(looksLikeReleaseVersionRef("v2026.4.5-beta.1")).toBe(true);
expect(looksLikeReleaseVersionRef("refs/tags/v2026.4.5-alpha.1")).toBe(true);
expect(looksLikeReleaseVersionRef("v2026.4.5-alpha.1")).toBe(true);
expect(looksLikeReleaseVersionRef("v2026.4.7-1")).toBe(true);
expect(looksLikeReleaseVersionRef("main")).toBe(false);
expect(looksLikeReleaseVersionRef("codex/cross-os-release-checks")).toBe(false);
});
it("normalizes full Git refs before suite and update decisions", () => {
expect(normalizeRequestedRef(" refs/heads/main ")).toBe("main");
expect(normalizeRequestedRef("refs/tags/v2026.4.14")).toBe("v2026.4.14");
expect(isImmutableReleaseRef("refs/tags/test-tag")).toBe(true);
expect(resolveRequestedSuites("both", "refs/tags/v2026.4.14")).toEqual([
"packaged-fresh",
"installer-fresh",
"packaged-upgrade",
]);
expect(resolveRequestedSuites("both", "refs/tags/test-tag")).toEqual([
"packaged-fresh",
"installer-fresh",
"packaged-upgrade",
]);
expect(shouldRunMainChannelDevUpdate("refs/heads/main")).toBe(true);
expect(shouldRunMainChannelDevUpdate("refs/tags/main")).toBe(false);
});
it("skips the dev-update suite for immutable release refs", () => {
expect(resolveRequestedSuites("both", "v2026.4.5")).toEqual([
"packaged-fresh",
"installer-fresh",
"packaged-upgrade",
]);
});
it("skips dev-update for non-main branch validation refs", () => {
expect(resolveRequestedSuites("both", "codex/cross-os-release-checks")).toEqual([
"packaged-fresh",
"installer-fresh",
"packaged-upgrade",
]);
});
it("keeps dev-update enabled for main validation refs", () => {
expect(resolveRequestedSuites("both", "main")).toEqual([
"packaged-fresh",
"installer-fresh",
"packaged-upgrade",
"dev-update",
]);
});
it("skips dev-update for pinned commit refs", () => {
expect(resolveRequestedSuites("both", "08753a1d793c040b101c8a26c43445dbbab14995")).toEqual([
"packaged-fresh",
"installer-fresh",
"packaged-upgrade",
]);
});
it("builds a suite-aware runner matrix with the beefy Windows default", () => {
const matrix = resolveRunnerMatrix({
mode: "both",
ref: "main",
ubuntuRunner: "",
windowsRunner: "",
macosRunner: "",
varUbuntuRunner: "",
varWindowsRunner: "",
varMacosRunner: "",
});
expect(matrix.include).toHaveLength(12);
expect(matrix.include).toContainEqual(
expect.objectContaining({
os_id: "windows",
runner: "blacksmith-32vcpu-windows-2025",
suite: "dev-update",
lane: "upgrade",
}),
);
expect(matrix.include).toContainEqual(
expect.objectContaining({
os_id: "ubuntu",
suite: "installer-fresh",
lane: "fresh",
}),
);
expect(matrix.include).toContainEqual(
expect.objectContaining({
os_id: "macos",
runner: "blacksmith-6vcpu-macos-latest",
suite: "packaged-fresh",
}),
);
});
it("keeps matrix resolution independent of package dependency imports", () => {
const source = readFileSync("scripts/openclaw-cross-os-release-checks.ts", "utf8");
const topLevelImports = source.slice(0, source.indexOf("const SCRIPT_PATH"));
expect(topLevelImports).not.toContain("package-dist-inventory");
expect(source).toContain("function assertNoLegacyPluginDependencyStagingDebris(packageRoot)");
});
it("filters the cross-OS runner matrix to a focused OS suite", () => {
const matrix = resolveRunnerMatrix({
mode: "both",
ref: "main",
suiteFilter: "windows/packaged-upgrade",
ubuntuRunner: "",
windowsRunner: "",
macosRunner: "",
varUbuntuRunner: "",
varWindowsRunner: "",
varMacosRunner: "",
});
expect(matrix.include).toEqual([
expect.objectContaining({
os_id: "windows",
suite: "packaged-upgrade",
lane: "upgrade",
}),
]);
});
it("filters the cross-OS runner matrix by suite across platforms", () => {
const matrix = resolveRunnerMatrix({
mode: "both",
ref: "main",
suiteFilter: "packaged-fresh",
ubuntuRunner: "",
windowsRunner: "",
macosRunner: "",
varUbuntuRunner: "",
varWindowsRunner: "",
varMacosRunner: "",
});
expect(matrix.include).toHaveLength(3);
expect(matrix.include.map((entry) => entry.os_id).toSorted()).toEqual([
"macos",
"ubuntu",
"windows",
]);
expect(matrix.include.every((entry) => entry.suite === "packaged-fresh")).toBe(true);
});
it("rejects unsupported cross-OS suite filter tokens", () => {
expect(() => parseCrossOsSuiteFilter("windows/nope")).toThrow(
/Unsupported cross_os_suite_filter/u,
);
});
it("can rebuild the Windows PATH with or without current-process entries", () => {
expect(buildWindowsPathBootstrapScript()).toContain("@($userPath, $machinePath, $env:Path)");
const persistedOnlyScript = buildWindowsPathBootstrapScript({
includeCurrentProcessPath: false,
});
expect(persistedOnlyScript).toContain("@($userPath, $machinePath)");
expect(persistedOnlyScript).not.toContain("@($userPath, $machinePath, $env:Path)");
});
it("prefers the freshly installed Windows CLI under npm's prefix before PATH lookup", () => {
const script = buildWindowsFreshShellVersionCheckScript({
expectedNeedle: "2026.4.14",
});
expect(script).toContain(buildWindowsPathBootstrapScript());
expect(script).not.toContain(
buildWindowsPathBootstrapScript({ includeCurrentProcessPath: false }),
);
expect(script).toContain("Get-Command npm.cmd -ErrorAction SilentlyContinue");
expect(script).toContain('$env:Path = "$npmPrefix;$env:Path"');
expect(script).toContain("(Join-Path $npmPrefix 'openclaw.cmd')");
expect(script).toContain("$cmd = Get-Command openclaw -ErrorAction Stop");
});
it("keeps Windows dev-update toolchain checks compatible with setup-node PATH shims", () => {
const script = buildWindowsDevUpdateToolchainCheckScript();
expect(script).toContain(buildWindowsPathBootstrapScript());
expect(script).not.toContain(
buildWindowsPathBootstrapScript({ includeCurrentProcessPath: false }),
);
expect(script).toContain("$pnpmPath = Resolve-CommandPath 'pnpm'");
expect(script).toContain("$corepackPath = Resolve-CommandPath 'corepack'");
expect(script).toContain("$npmPath = Resolve-CommandPath 'npm'");
});
it("prefers workflow-injected runner override env names over legacy ones", () => {
expect(
readRunnerOverrideEnv({
VAR_UBUNTU_RUNNER: "workflow-linux",
VAR_WINDOWS_RUNNER: "workflow-windows",
VAR_MACOS_RUNNER: "workflow-macos",
OPENCLAW_RELEASE_CHECKS_UBUNTU_RUNNER: "legacy-linux",
OPENCLAW_RELEASE_CHECKS_WINDOWS_RUNNER: "legacy-windows",
OPENCLAW_RELEASE_CHECKS_MACOS_RUNNER: "legacy-macos",
}),
).toEqual({
varUbuntuRunner: "workflow-linux",
varWindowsRunner: "workflow-windows",
varMacosRunner: "workflow-macos",
});
});
it("falls back to legacy runner override env names when workflow vars are blank", () => {
expect(
readRunnerOverrideEnv({
VAR_UBUNTU_RUNNER: "",
VAR_WINDOWS_RUNNER: " ",
VAR_MACOS_RUNNER: "",
OPENCLAW_RELEASE_CHECKS_UBUNTU_RUNNER: "legacy-linux",
OPENCLAW_RELEASE_CHECKS_WINDOWS_RUNNER: "legacy-windows",
OPENCLAW_RELEASE_CHECKS_MACOS_RUNNER: "legacy-macos",
}),
).toEqual({
varUbuntuRunner: "legacy-linux",
varWindowsRunner: "legacy-windows",
varMacosRunner: "legacy-macos",
});
});
it("serves installer scripts as UTF-8 text and package payloads as binary", () => {
expect(resolveStaticFileContentType("scripts/install.sh")).toBe("text/plain; charset=utf-8");
expect(resolveStaticFileContentType("scripts/install.ps1")).toBe("text/plain; charset=utf-8");
expect(resolveStaticFileContentType("openclaw-2026.4.14.tgz")).toBe("application/octet-stream");
});
it("uses the published installer URLs for native installer lanes", () => {
expect(resolvePublishedInstallerUrl("darwin")).toBe("https://openclaw.ai/install.sh");
expect(resolvePublishedInstallerUrl("linux")).toBe("https://openclaw.ai/install.sh");
expect(resolvePublishedInstallerUrl("win32")).toBe("https://openclaw.ai/install.ps1");
});
it("uses managed gateway services only on native Windows runners", () => {
expect(shouldUseManagedGatewayService("win32")).toBe(true);
expect(shouldUseManagedGatewayService("darwin")).toBe(false);
expect(shouldUseManagedGatewayService("linux")).toBe(false);
});
it("skips workspace bootstrap during release onboarding", () => {
expect(
buildReleaseOnboardArgs({
authChoice: "openai-api-key",
gatewayPort: 34111,
skipHealth: true,
}),
).toEqual([
"onboard",
"--non-interactive",
"--mode",
"local",
"--auth-choice",
"openai-api-key",
"--secret-input-mode",
"ref",
"--gateway-port",
"34111",
"--gateway-bind",
"loopback",
"--skip-skills",
"--skip-bootstrap",
"--accept-risk",
"--json",
"--skip-health",
]);
});
it("keeps the Windows installer runtime on the manual gateway after managed lifecycle checks", () => {
expect(shouldExerciseManagedGatewayLifecycleAfterInstall("win32")).toBe(true);
expect(shouldUseManagedGatewayForInstallerRuntime("win32")).toBe(false);
expect(shouldExerciseManagedGatewayLifecycleAfterInstall("darwin")).toBe(false);
expect(shouldUseManagedGatewayForInstallerRuntime("darwin")).toBe(false);
});
it("stops the managed gateway before the manual fallback only on Windows", () => {
expect(shouldStopManagedGatewayBeforeManualFallback("win32")).toBe(true);
expect(shouldStopManagedGatewayBeforeManualFallback("darwin")).toBe(false);
expect(shouldStopManagedGatewayBeforeManualFallback("linux")).toBe(false);
});
it("skips daemon health during installed onboarding only on native Windows", () => {
expect(shouldSkipInstallerDaemonHealthCheck("win32")).toBe(true);
expect(shouldSkipInstallerDaemonHealthCheck("darwin")).toBe(false);
expect(shouldSkipInstallerDaemonHealthCheck("linux")).toBe(false);
});
it("runs the installed browser override import smoke only on native Windows", () => {
expect(shouldRunWindowsInstalledBrowserOverrideImportSmoke("win32")).toBe(true);
expect(shouldRunWindowsInstalledBrowserOverrideImportSmoke("darwin")).toBe(false);
expect(shouldRunWindowsInstalledBrowserOverrideImportSmoke("linux")).toBe(false);
const script = buildInstalledBrowserOverrideImportProbeScript();
expect(script).toContain('from "openclaw/plugin-sdk/plugin-runtime"');
expect(script).toContain('overrideEnvVar: "OPENCLAW_BROWSER_CONTROL_MODULE"');
expect(script).toContain("startBrowserControlService");
expect(script).toContain("stopBrowserControlService");
expect(script).toContain("Browser control override start sentinel was not written.");
const installedScript = buildInstalledBrowserOverrideImportProbeScript(
"file:///C:/Users/runner/AppData/Roaming/npm/node_modules/openclaw/dist/plugin-sdk/plugin-runtime.js",
);
expect(installedScript).toContain(
'from "file:///C:/Users/runner/AppData/Roaming/npm/node_modules/openclaw/dist/plugin-sdk/plugin-runtime.js"',
);
expect(readFileSync("scripts/openclaw-cross-os-release-checks.ts", "utf8")).toContain(
"OPENCLAW_BROWSER_CONTROL_MODULE: pathToFileURL(overridePath).href",
);
});
it("normalizes Windows installed CLI paths to the cmd shim", () => {
expect(
normalizeWindowsInstalledCliPath(
String.raw`C:\Users\runner\AppData\Roaming\npm\openclaw.ps1`,
),
).toBe(String.raw`C:\Users\runner\AppData\Roaming\npm\openclaw.cmd`);
expect(
normalizeWindowsInstalledCliPath(
String.raw`C:\Users\runner\AppData\Roaming\npm\openclaw.cmd`,
),
).toBe(String.raw`C:\Users\runner\AppData\Roaming\npm\openclaw.cmd`);
});
it("normalizes generic Windows PowerShell shims to cmd shims", () => {
expect(normalizeWindowsCommandShimPath(String.raw`C:\Program Files\nodejs\pnpm.ps1`)).toBe(
String.raw`C:\Program Files\nodejs\pnpm.cmd`,
);
expect(normalizeWindowsCommandShimPath(String.raw`C:\Program Files\nodejs\corepack.ps1`)).toBe(
String.raw`C:\Program Files\nodejs\corepack.cmd`,
);
expect(normalizeWindowsCommandShimPath(String.raw`C:\Program Files\nodejs\node.exe`)).toBe(
String.raw`C:\Program Files\nodejs\node.exe`,
);
});
it("derives the installed prefix from resolved CLI paths", () => {
expect(
resolveInstalledPrefixDirFromCliPath(
String.raw`C:\Users\runner\AppData\Roaming\npm\openclaw.ps1`,
"win32",
),
).toBe(String.raw`C:\Users\runner\AppData\Roaming\npm`);
expect(
resolveInstalledPrefixDirFromCliPath("/Users/runner/.npm-global/bin/openclaw", "darwin"),
).toBe("/Users/runner/.npm-global");
});
it("resolves Linux npm package roots when the CLI is a user-local shim", () => {
const homeDir = mkdtempSync(join(tmpdir(), "openclaw-cross-os-linux-home-"));
try {
const packageRoot = join(homeDir, ".npm-global", "lib", "node_modules", "openclaw");
const distDir = join(packageRoot, "dist");
const cliDir = join(homeDir, ".local", "bin");
mkdirSync(distDir, { recursive: true });
mkdirSync(cliDir, { recursive: true });
writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "openclaw" }));
writeFileSync(join(distDir, "entry.js"), "#!/usr/bin/env node\n");
expect(
resolveInstalledPackageRootFromCliPath(join(cliDir, "openclaw"), "linux", {
HOME: homeDir,
}),
).toBe(packageRoot);
rmSync(join(cliDir, "openclaw"), { force: true });
symlinkSync(join(distDir, "entry.js"), join(cliDir, "openclaw"));
expect(
resolveInstalledPackageRootFromCliPath(join(cliDir, "openclaw"), "linux", {
HOME: homeDir,
}),
).toBe(realpathSync(packageRoot));
} finally {
rmSync(homeDir, { recursive: true, force: true });
}
});
it("detects whether a managed gateway listener is still reachable on loopback", async () => {
const server = createNetServer();
await new Promise((resolvePromise) => {
server.listen(0, "127.0.0.1", resolvePromise);
});
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
expect(await canConnectToLoopbackPort(port)).toBe(true);
await new Promise((resolvePromise) => {
server.close(resolvePromise);
});
for (let attempt = 0; attempt < 20; attempt += 1) {
if (!(await canConnectToLoopbackPort(port, 100))) {
return;
}
await delay(25);
}
expect(await canConnectToLoopbackPort(port, 100)).toBe(false);
});
it("writes Discord smoke config using the strict guild channel schema", () => {
expect(buildDiscordSmokeGuildsConfig("guild-123", "channel-456")).toEqual({
"guild-123": {
channels: {
"channel-456": {
enabled: true,
requireMention: false,
},
},
},
});
});
it("keeps the dev-update lane for main only", () => {
expect(shouldRunMainChannelDevUpdate("main")).toBe(true);
expect(shouldRunMainChannelDevUpdate("08753a1d793c040b101c8a26c43445dbbab14995")).toBe(false);
expect(shouldRunMainChannelDevUpdate(" codex/cross-os-release-checks-full-native-e2e ")).toBe(
false,
);
expect(shouldRunMainChannelDevUpdate("v2026.4.14")).toBe(false);
});
it("verifies main dev updates against the prepared source sha when available", () => {
expect(resolveDevUpdateVerificationRef("main")).toBe("main");
expect(
resolveDevUpdateVerificationRef("main", "08753a1d793c040b101c8a26c43445dbbab14995"),
).toBe("08753a1d793c040b101c8a26c43445dbbab14995");
expect(
resolveDevUpdateVerificationRef(
"refs/heads/main",
"08753a1d793c040b101c8a26c43445dbbab14995",
),
).toBe("08753a1d793c040b101c8a26c43445dbbab14995");
expect(resolveDevUpdateVerificationRef("codex/cross-os-release-checks-full-native-e2e")).toBe(
"codex/cross-os-release-checks-full-native-e2e",
);
});
it("drops the bundled plugin postinstall disable flag for real updater calls", () => {
expect(
buildRealUpdateEnv({
FOO: "bar",
NODE_COMPILE_CACHE: "/tmp/stale-openclaw-cache",
OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL: "1",
}),
).toEqual({
FOO: "bar",
NODE_DISABLE_COMPILE_CACHE: "1",
});
});
it("rejects a successful packaged update followed by an old self-swapped process import miss", () => {
expect(() =>
verifyPackagedUpgradeUpdateResult(
{
exitCode: 1,
stdout: JSON.stringify({
status: "ok",
after: { version: "2026.4.27" },
steps: [{ name: "global update", exitCode: 0 }],
}),
stderr:
"[openclaw] Failed to start CLI: Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/tmp/prefix/lib/node_modules/openclaw/dist/memory-state-old.js'",
},
{ candidateVersion: "2026.4.27" },
),
).toThrow(/Packaged upgrade failed/u);
});
it("rejects packaged update failures before the candidate package lands", () => {
expect(() =>
verifyPackagedUpgradeUpdateResult(
{
exitCode: 1,
stdout: JSON.stringify({
status: "ok",
after: { version: "2026.4.26" },
steps: [{ name: "global update", exitCode: 0 }],
}),
stderr:
"[openclaw] Failed to start CLI: Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/tmp/prefix/lib/node_modules/openclaw/dist/memory-state-old.js'",
},
{ candidateVersion: "2026.4.27" },
),
).toThrow(/Packaged upgrade failed/u);
});
it("rejects packaged update failures with unsuccessful update steps", () => {
expect(() =>
verifyPackagedUpgradeUpdateResult(
{
exitCode: 1,
stdout: JSON.stringify({
status: "ok",
after: { version: "2026.4.27" },
steps: [{ name: "global update", exitCode: 1 }],
}),
stderr:
"[openclaw] Failed to start CLI: Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/tmp/prefix/lib/node_modules/openclaw/dist/memory-state-old.js'",
},
{ candidateVersion: "2026.4.27" },
),
).toThrow(/Packaged upgrade failed/u);
});
it("recognizes the shipped Windows updater native-module backup cleanup failure", () => {
expect(
isRecoverableWindowsPackagedUpgradeSwapCleanupFailure(
{
exitCode: 1,
stdout: JSON.stringify({
status: "error",
reason: "global install swap",
after: { version: "2026.5.2" },
steps: [
{
name: "global install swap",
exitCode: 1,
stderrTail:
"EPERM: operation not permitted, unlink 'C:\\Users\\runner\\prefix\\node_modules\\.openclaw-5748-1777776287462\\node_modules\\@mariozechner\\clipboard-win32-x64-msvc\\clipboard.win32-x64-msvc.node'",
},
],
}),
stderr: "",
},
"win32",
),
).toBe(true);
});
it("recognizes the shipped Windows updater packaged-upgrade timeout", () => {
const error = new Error(
"Command timed out: C:\\hostedtoolcache\\windows\\node\\24.15.0\\x64\\node.exe C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\openclaw-upgrade-q9DsA7\\prefix\\node_modules\\openclaw\\openclaw.mjs update --tag http://127.0.0.1:49951/openclaw-2026.5.4-beta.1.tgz --yes --json --no-restart --timeout 1500",
);
expect(isRecoverableWindowsPackagedUpgradeTimeoutError(error, "win32")).toBe(true);
expect(
isRecoverableWindowsPackagedUpgradeTimeoutError(
new Error(
"Command timed out: C:\\prefix\\node_modules\\openclaw\\openclaw.mjs update --tag http://127.0.0.1:49951/openclaw-current.tgz --yes --json --timeout 1500",
),
"win32",
),
).toBe(true);
expect(isRecoverableWindowsPackagedUpgradeTimeoutError(error, "linux")).toBe(false);
expect(
isRecoverableWindowsPackagedUpgradeTimeoutError(
new Error("Command timed out: node openclaw.mjs update --tag openclaw@beta"),
"win32",
),
).toBe(false);
});
it("skips the packaged upgrade status probe after the Windows fallback install", () => {
expect(
shouldRunPackagedUpgradeStatusProbe({
platform: "win32",
usedWindowsPackagedUpgradeFallback: true,
}),
).toBe(false);
expect(
shouldRunPackagedUpgradeStatusProbe({
platform: "win32",
usedWindowsPackagedUpgradeFallback: false,
}),
).toBe(true);
expect(
shouldRunPackagedUpgradeStatusProbe({
platform: "linux",
usedWindowsPackagedUpgradeFallback: true,
}),
).toBe(true);
});
it("does not recover unrelated packaged update failures", () => {
expect(
isRecoverableWindowsPackagedUpgradeSwapCleanupFailure(
{
exitCode: 1,
stdout: JSON.stringify({
status: "error",
reason: "global install swap",
steps: [{ name: "global install swap", exitCode: 1, stderrTail: "ENOENT: missing" }],
}),
stderr: "",
},
"win32",
),
).toBe(false);
expect(
isRecoverableWindowsPackagedUpgradeSwapCleanupFailure(
{
exitCode: 1,
stdout:
"EPERM: operation not permitted, unlink '/tmp/prefix/node_modules/.openclaw-1-2/native.node'",
stderr: "",
},
"linux",
),
).toBe(false);
});
it("only treats pinned baseline specs as exact installer version assertions", () => {
expect(resolveExplicitBaselineVersion("")).toBe("");
expect(resolveExplicitBaselineVersion("openclaw@latest")).toBe("");
expect(resolveExplicitBaselineVersion("openclaw@2026.4.10")).toBe("2026.4.10");
expect(resolveExplicitBaselineVersion("2026.4.10")).toBe("2026.4.10");
});
it("reads an installed baseline version without requiring build metadata", () => {
const prefixDir = mkdtempSync(join(tmpdir(), "openclaw-cross-os-installed-version-"));
try {
const packageRoot =
process.platform === "win32"
? join(prefixDir, "node_modules", "openclaw")
: join(prefixDir, "lib", "node_modules", "openclaw");
mkdirSync(packageRoot, { recursive: true });
writeFileSync(
join(packageRoot, "package.json"),
JSON.stringify({
name: "openclaw",
version: "2026.4.10",
}),
"utf8",
);
expect(readInstalledVersion(prefixDir)).toBe("2026.4.10");
} finally {
rmSync(prefixDir, { recursive: true, force: true });
}
});
it("treats missing package scripts as optional in older refs", () => {
const packageRoot = mkdtempSync(join(tmpdir(), "openclaw-cross-os-scripts-"));
try {
writeFileSync(
join(packageRoot, "package.json"),
JSON.stringify({
name: "openclaw",
scripts: {
build: "pnpm build",
},
}),
"utf8",
);
expect(packageHasScript(packageRoot, "build")).toBe(true);
expect(packageHasScript(packageRoot, "ui:build")).toBe(false);
} finally {
rmSync(packageRoot, { recursive: true, force: true });
}
});
it("rejects legacy plugin dependency staging debris before candidate inventory generation", async () => {
const packageRoot = mkdtempSync(join(tmpdir(), "openclaw-cross-os-stage-debris-"));
try {
mkdirSync(
join(packageRoot, "dist", "Extensions", "demo", ".OpenClaw-Install-Stage", "node_modules"),
{ recursive: true },
);
writeFileSync(
join(packageRoot, "dist", "Extensions", "demo", ".OpenClaw-Install-Stage", "package.json"),
"{}\n",
"utf8",
);
await expect(
writePackageDistInventoryForCandidate({
sourceDir: packageRoot,
logPath: join(packageRoot, "npm-pack-dry-run.log"),
}),
).rejects.toThrow("unexpected legacy plugin dependency staging debris");
} finally {
rmSync(packageRoot, { recursive: true, force: true });
}
});
it("omits local build metadata from candidate package inventories", async () => {
const packageRoot = mkdtempSync(join(tmpdir(), "openclaw-cross-os-local-stamps-"));
try {
mkdirSync(join(packageRoot, "dist"), { recursive: true });
writeFileSync(
join(packageRoot, "package.json"),
JSON.stringify({ name: "openclaw-fixture", version: "0.0.0", files: ["dist/"] }),
"utf8",
);
writeFileSync(join(packageRoot, "dist", "index.js"), "export {};\n", "utf8");
for (const relativePath of LOCAL_BUILD_METADATA_DIST_PATHS) {
writeFileSync(join(packageRoot, relativePath), "{}\n", "utf8");
}
await writePackageDistInventoryForCandidate({
sourceDir: packageRoot,
logPath: join(packageRoot, "npm-pack-dry-run.log"),
});
expect(
JSON.parse(readFileSync(join(packageRoot, "dist", "postinstall-inventory.json"), "utf8")),
).toEqual(["dist/index.js"]);
} finally {
rmSync(packageRoot, { recursive: true, force: true });
}
});
it("accepts a git main dev-channel update status payload", () => {
expect(() =>
verifyDevUpdateStatus(
JSON.stringify({
update: {
installKind: "git",
git: {
branch: "main",
},
},
channel: {
value: "dev",
},
}),
),
).not.toThrow();
});
it("accepts a git dev-channel payload for a requested non-main branch", () => {
expect(() =>
verifyDevUpdateStatus(
JSON.stringify({
update: {
installKind: "git",
git: {
branch: "codex/cross-os-release-checks-full-native-e2e",
sha: "08753a1d793c040b101c8a26c43445dbbab14995",
},
},
channel: {
value: "dev",
},
}),
{ ref: "codex/cross-os-release-checks-full-native-e2e" },
),
).not.toThrow();
});
it("accepts a git dev-channel payload pinned to a prepared source sha", () => {
expect(() =>
verifyDevUpdateStatus(
JSON.stringify({
update: {
installKind: "git",
git: {
branch: "main",
sha: "08753a1d793c040b101c8a26c43445dbbab14995",
},
},
channel: {
value: "dev",
},
}),
{ ref: "08753a1d793c040b101c8a26c43445dbbab14995" },
),
).not.toThrow();
});
it("accepts uppercase requested commit shas when update status reports lowercase", () => {
expect(() =>
verifyDevUpdateStatus(
JSON.stringify({
update: {
installKind: "git",
git: {
sha: "08753a1d793c040b101c8a26c43445dbbab14995",
},
},
channel: {
value: "dev",
},
}),
{ ref: "08753A1D793C040B101C8A26C43445DBBAB14995" },
),
).not.toThrow();
});
it("rejects update status payloads that are not on dev/main git", () => {
expect(() =>
verifyDevUpdateStatus(
JSON.stringify({
update: {
installKind: "package",
git: {
branch: "release",
},
},
channel: {
value: "stable",
},
}),
),
).toThrow("git install");
});
});