Files
openclaw/test/scripts/openclaw-cross-os-release-checks.test.ts
2026-04-26 09:08:54 +01:00

541 lines
19 KiB
TypeScript

import { mkdirSync, mkdtempSync, rmSync, 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 {
agentOutputHasExpectedOkMarker,
buildWindowsDevUpdateToolchainCheckScript,
buildWindowsFreshShellVersionCheckScript,
buildWindowsPathBootstrapScript,
canConnectToLoopbackPort,
buildDiscordSmokeGuildsConfig,
buildRealUpdateEnv,
isImmutableReleaseRef,
looksLikeReleaseVersionRef,
normalizeRequestedRef,
normalizeWindowsCommandShimPath,
normalizeWindowsInstalledCliPath,
parseArgs,
packageHasScript,
readInstalledVersion,
readRunnerOverrideEnv,
resolveExplicitBaselineVersion,
resolveDevUpdateVerificationRef,
resolveInstalledPrefixDirFromCliPath,
resolvePublishedInstallerUrl,
resolveRequestedSuites,
resolveRunnerMatrix,
resolveStaticFileContentType,
shouldExerciseManagedGatewayLifecycleAfterInstall,
shouldSkipInstallerDaemonHealthCheck,
shouldStopManagedGatewayBeforeManualFallback,
shouldRunMainChannelDevUpdate,
shouldUseManagedGatewayForInstallerRuntime,
shouldUseManagedGatewayService,
verifyDevUpdateStatus,
writePackageDistInventoryForCandidate,
} from "../../scripts/openclaw-cross-os-release-checks.ts";
describe("scripts/openclaw-cross-os-release-checks", () => {
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("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("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",
}),
);
});
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("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("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("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",
OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL: "1",
}),
).toEqual({
FOO: "bar",
});
});
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 bundled runtime-deps 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 bundled-runtime-deps install staging debris");
} 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");
});
});