mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 04:06:27 +00:00
* refactor: extract agent core package Introduce packages/agent-core as the OpenClaw-owned home for reusable agent loop, harness, session, prompt, and runtime dependency contracts. * refactor: extract shared llm runtime Move provider model registries, stream wrappers, OAuth helpers, and LLM utilities into src/llm with plugin-sdk barrels instead of depending on the old embedded runtime layout. * refactor: remove pi runtime internals Rename remaining Pi-shaped agent surfaces to OpenClaw agent runtime names, delete obsolete Pi docs and package graph checks, and add the third-party notice for incorporated code. * refactor: tighten agent session runtime Make agent-core/runtime dependencies explicit, consolidate compaction and session transcript helpers, and move model/session helpers behind OpenClaw-owned contracts. * refactor: remove static model and pi auth paths Drop static model catalogs and Pi auth bridges, move model/provider facts to manifest-owned runtime contracts, and harden internal embedded-agent utilities. * refactor: remove legacy provider compat paths * docs: remove agent parity notes * fix: skip provider wildcard metadata parsing * refactor: share session extension sdk loading * refactor: inline acpx proxy error formatter * refactor: fold edit recovery into edit tool * fix: accept extension batch separator * test: align startup provider plugin expectations * fix: restore provider-scoped release discovery * test: align static asset packaging expectations * fix: run static provider catalogs during scoped discovery * fix: add provider entry catalogs for scoped live discovery * fix: load lightweight provider catalog entries * fix: refresh provider-scoped plugin metadata * fix: keep provider catalog entries on release live path * fix: keep static manifest models in release live checks * fix: harden release model discovery * fix: reduce OpenAI live cache probe reasoning * fix: disable OpenAI cache probe reasoning * ci: extend OpenAI gateway live timeout * fix: extend live gateway model budget * fix: stabilize release validation regressions * fix: honor provider aliases in model rows * fix: stabilize release validation lanes * fix: stabilize release memory qa * ci: stabilize release validation lanes * ci: prefer ipv4 for live docker node calls * fix: restore shared tool-call stream wrapper * ci: remove legacy pi test shard alias * fix: clean up embedded agent test drift * fix: stabilize runtime alias status * fix: clean up embedded agent ci drift * fix: restore release ci invariants * fix: clean up post-rebase runtime drift * fix: restore release ci checks * fix: restore release ci after rebase * fix: remove stale pi runtime path * test: align compaction runtime expectations * test: update plugin prerelease expectations * fix: handle claude live tool approvals * fix: stabilize release validation gates * fix: finish agent runtime import * test: finish post-rebase agent runtime mocks * fix: keep codex compaction native * fix: stabilize codex app-server hook tests * test: isolate codex diagnostic active run * test: remove codex diagnostic completion race # Conflicts: # extensions/codex/src/app-server/run-attempt.test.ts * ci: fix full release manifest performance run id * refactor: narrow llm plugin sdk boundary * chore: drop generated google boundary stamps * fix: repair rebase fallout * fix: clean up rebased runtime references * fix: decode codex jwt payloads as base64url * fix: preserve shipped pi runtime alias * fix: add scoped sdk virtual modules * fix: decode llm codex oauth jwt as base64url * fix: avoid stale vertex adc negative cache * fix: harden tool arg decoding and codeql path * fix: keep vertex adc negative checks live * refactor: consolidate codex jwt and edit helpers * fix: await codex oauth node runtime imports * fix: preserve sdk tool and notice contracts * fix: preserve shipped compat config boundaries * fix: align codex oauth callback host * fix: terminate agent-core loop streams on failure * fix: keep codex oauth callback alive during fallback * ci: include session tools in critical codeql scans * fix: keep Cloudflare Anthropic provider auth header * docs: redirect legacy pi runtime pages * fix: honor bundled web provider compat discovery * fix: protect session output spill files * fix: keep legacy agent dir env blocked * fix: contain auto-discovered skill symlinks * fix: harden agent core sdk proxy surfaces * fix: restore approval reaction sdk compat * fix: keep live docker runs bounded * fix: keep codex oauth redirect host aligned * fix: resolve post-rebase agent runtime drift * fix: redact anthropic oauth parse failures * fix: preserve responses strict tool shaping * fix: repair agent runtime rebase cleanup * docs: redirect retired parity pages * fix: bound auto-discovered resources to roots * fix: repair post-rebase agent test drift * fix: preserve bundled provider allowlist migration * fix: preserve manifest-owned provider aliases * fix: declare photon image dependency * fix: keep provider headers out of proxy body * fix: preserve shipped env aliases * fix: refresh control ui i18n generated state * fix: quote read fallback paths * fix: preview edits through configured backend * test: satisfy core test typecheck * fix: preserve ZAI usage auth fallback * test: repair codex diagnostic test * fix: repair agent runtime rebase drift * test: finish embedded runner import rename * fix: repair agent runtime rebase integrations * test: align compaction oauth fallback expectations * fix: allow sdk-auth session models * fix: update doctor tool schema import * fix: preserve bedrock plugin region * fix: stream harmony-like prose immediately * ci: include session runtime in codeql shards * fix: repair latest rebase integrations * fix: honor explicit codex websocket transport * fix: keep openai-compatible credentials provider-scoped * fix: refresh sdk api baseline after rebase * fix: route cli runtime aliases through openclaw harness * test: rename stale harness mock expectation * test: rename embedded agent overflow calls * test: clean embedded auth test wording * test: use openclaw stream types in deepinfra cache test * fix: refresh sdk api baseline on latest main * fix: honor bundled discovery compat allowlists * fix: refresh sdk api baseline after latest rebase * fix: remove stale rebase imports * test: rename stale model catalog mock * test: mock renamed doctor runtime modules * fix: map canonical kimi env auth * fix: use internal model registry in bench script * fix: migrate deepinfra provider catalog entry * fix: enforce builtin tool suppression * fix: route compaction auth and proxy payloads safely * refactor: prune unused llm registry leftovers * test: update codex hooks session import * test: fix model picker ci coverage * test: align model picker auth mock types
1335 lines
46 KiB
TypeScript
1335 lines
46 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, win32 } 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,
|
|
agentTurnUsedEmbeddedFallback,
|
|
buildCrossOsReleaseSmokePluginAllowlist,
|
|
buildPackagedUpgradeUpdateArgs,
|
|
buildReleaseOnboardArgs,
|
|
buildWindowsDevUpdateToolchainCheckScript,
|
|
buildWindowsFreshShellVersionCheckScript,
|
|
buildInstalledBrowserOverrideImportProbeScript,
|
|
buildNpmGlobalInstallArgs,
|
|
buildGatewayStatusArgsFromHelpText,
|
|
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,
|
|
maybeBuildOptionalAgentTurnSkipResult,
|
|
parseCrossOsSuiteFilter,
|
|
parseArgs,
|
|
packageHasScript,
|
|
readInstalledVersion,
|
|
readRunnerOverrideEnv,
|
|
resolveCrossOsAgentTurnOptional,
|
|
runCommand,
|
|
resolveCommandSpawnInvocation,
|
|
resolveExplicitBaselineVersion,
|
|
resolveInstalledCliInvocation,
|
|
resolveInstalledPackageRootFromCliPath,
|
|
resolveProviderConfig,
|
|
resolveDevUpdateVerificationRef,
|
|
resolveInstalledPrefixDirFromCliPath,
|
|
resolvePublishedInstallerUrl,
|
|
resolveRequestedSuites,
|
|
resolveRunnerMatrix,
|
|
resolveStaticFileContentType,
|
|
startStaticFileServer,
|
|
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("keeps gateway status RPC probing when help probing is unavailable", () => {
|
|
expect(buildGatewayStatusArgsFromHelpText("--require-rpc")).toEqual([
|
|
"gateway",
|
|
"status",
|
|
"--require-rpc",
|
|
"--timeout",
|
|
String(CROSS_OS_GATEWAY_STATUS_RPC_TIMEOUT_MS),
|
|
]);
|
|
expect(buildGatewayStatusArgsFromHelpText("Usage: openclaw gateway status")).toEqual([
|
|
"gateway",
|
|
"status",
|
|
]);
|
|
expect(
|
|
buildGatewayStatusArgsFromHelpText("--require-rpc", {
|
|
requireRpc: false,
|
|
}),
|
|
).toEqual(["gateway", "status"]);
|
|
});
|
|
|
|
it("gives the Windows packaged updater wrapper enough headroom for OpenClaw timeout output", () => {
|
|
expect(CROSS_OS_WINDOWS_PACKAGED_UPGRADE_STEP_TIMEOUT_SECONDS).toBeLessThanOrEqual(10 * 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(2 * 60 * 1000);
|
|
expect(CROSS_OS_WINDOWS_PACKAGED_UPGRADE_WRAPPER_TIMEOUT_MS).toBeLessThanOrEqual(
|
|
12 * 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("records packaged-fresh phase timings for release-check summaries", () => {
|
|
const source = readFileSync("scripts/openclaw-cross-os-release-checks.ts", "utf8");
|
|
const freshLaneSource = source.slice(
|
|
source.indexOf("async function runFreshLane"),
|
|
source.indexOf("async function runUpgradeLane"),
|
|
);
|
|
|
|
expect(freshLaneSource).toContain('runTimedLanePhase(lane, "install-candidate"');
|
|
expect(freshLaneSource).toContain('runTimedLanePhase(lane, "agent-turn"');
|
|
expect(freshLaneSource).toContain("phaseTimings: lane.phaseTimings");
|
|
});
|
|
|
|
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);
|
|
expect(
|
|
shouldRetryCrossOsAgentTurnError(
|
|
new Error("Agent turn used embedded fallback instead of gateway."),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("requires explicit opt-in before cross-OS agent turns become optional", () => {
|
|
expect(resolveCrossOsAgentTurnOptional({})).toBe(false);
|
|
expect(resolveCrossOsAgentTurnOptional({ OPENCLAW_CROSS_OS_AGENT_TURN_OPTIONAL: "1" })).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
resolveCrossOsAgentTurnOptional({ OPENCLAW_CROSS_OS_AGENT_TURN_OPTIONAL: "false" }),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("detects embedded fallback agent turns as non-gateway proof", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "openclaw-cross-os-agent-fallback-"));
|
|
const logPath = join(dir, "agent.log");
|
|
expect(
|
|
agentTurnUsedEmbeddedFallback({
|
|
stdout: JSON.stringify({ payloads: [{ text: "OK" }] }),
|
|
stderr: "EMBEDDED FALLBACK: Gateway agent failed; running embedded agent: gateway closed",
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
agentTurnUsedEmbeddedFallback({
|
|
stdout: JSON.stringify({ payloads: [{ text: "OK" }] }),
|
|
stderr: "",
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
agentTurnUsedEmbeddedFallback(
|
|
{ stdout: "", stderr: "" },
|
|
{ logText: 'EMBEDDED FALLBACK: Gateway agent failed\n{"payloads":[{"text":"OK"}]}' },
|
|
),
|
|
).toBe(true);
|
|
try {
|
|
writeFileSync(logPath, "EMBEDDED FALLBACK: Gateway agent failed\n");
|
|
expect(agentTurnUsedEmbeddedFallback({ stdout: "", stderr: "" }, { logPath })).toBe(true);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: 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("only skips opted-in cross-OS live agent turns after retry exhaustion", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "openclaw-cross-os-agent-skip-retry-"));
|
|
try {
|
|
const logPath = join(dir, "agent.log");
|
|
const error = new Error("gateway request timeout for agent after 210000ms");
|
|
|
|
expect(
|
|
maybeBuildOptionalAgentTurnSkipResult(error, logPath, {
|
|
attempt: 1,
|
|
maxAttempts: 2,
|
|
optional: true,
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
maybeBuildOptionalAgentTurnSkipResult(error, logPath, {
|
|
attempt: 2,
|
|
maxAttempts: 2,
|
|
optional: false,
|
|
}),
|
|
).toBeNull();
|
|
|
|
const skipped = maybeBuildOptionalAgentTurnSkipResult(error, logPath, {
|
|
attempt: 2,
|
|
maxAttempts: 2,
|
|
optional: true,
|
|
});
|
|
|
|
expect(skipped?.status).toBe(0);
|
|
expect(JSON.parse(skipped?.stdout ?? "{}")).toEqual({
|
|
status: "skipped",
|
|
reason: "cross-os live agent turn unavailable after retry",
|
|
});
|
|
expect(readFileSync(logPath, "utf8")).toContain(
|
|
"skipping optional cross-OS live agent turn after retryable failure",
|
|
);
|
|
} 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.5");
|
|
});
|
|
|
|
it("keeps release cross-OS OpenAI smoke on GPT-5.5", () => {
|
|
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.5' }}",
|
|
);
|
|
expect(releaseChecks).toContain("openai_model: openai/gpt-5.5");
|
|
});
|
|
|
|
it("keeps release smoke plugin allowlists focused on agent-turn essentials", () => {
|
|
const allowlist = buildCrossOsReleaseSmokePluginAllowlist({ extensionId: "openai" });
|
|
|
|
expect(allowlist).toEqual([
|
|
"openai",
|
|
"acpx",
|
|
"bonjour",
|
|
"browser",
|
|
"device-pair",
|
|
"phone-control",
|
|
"talk-voice",
|
|
]);
|
|
});
|
|
|
|
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 "off"');
|
|
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('agentRuntime: { id: "openclaw" }');
|
|
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)');
|
|
const agentTurnArgCalls = source.match(/buildReleaseAgentTurnArgs\(sessionId\)/g) ?? [];
|
|
expect(agentTurnArgCalls.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.find((entry) => entry.os_id === "windows" && entry.suite === "dev-update"),
|
|
).toEqual({
|
|
artifact_name: "windows",
|
|
display_name: "Windows",
|
|
lane: "upgrade",
|
|
os_id: "windows",
|
|
runner: "blacksmith-32vcpu-windows-2025",
|
|
suite: "dev-update",
|
|
suite_label: "dev update",
|
|
});
|
|
expect(
|
|
matrix.include.find((entry) => entry.os_id === "ubuntu" && entry.suite === "installer-fresh"),
|
|
).toEqual({
|
|
artifact_name: "linux",
|
|
display_name: "Linux",
|
|
lane: "fresh",
|
|
os_id: "ubuntu",
|
|
runner: "blacksmith-8vcpu-ubuntu-2404",
|
|
suite: "installer-fresh",
|
|
suite_label: "installer fresh",
|
|
});
|
|
expect(
|
|
matrix.include.find((entry) => entry.os_id === "macos" && entry.suite === "packaged-fresh"),
|
|
).toEqual({
|
|
artifact_name: "macos",
|
|
display_name: "macOS",
|
|
lane: "fresh",
|
|
os_id: "macos",
|
|
runner: "blacksmith-6vcpu-macos-latest",
|
|
suite: "packaged-fresh",
|
|
suite_label: "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([
|
|
{
|
|
artifact_name: "windows",
|
|
display_name: "Windows",
|
|
lane: "upgrade",
|
|
os_id: "windows",
|
|
runner: "blacksmith-32vcpu-windows-2025",
|
|
suite: "packaged-upgrade",
|
|
suite_label: "packaged 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.map((entry) => entry.suite)).toEqual([
|
|
"packaged-fresh",
|
|
"packaged-fresh",
|
|
"packaged-fresh",
|
|
]);
|
|
});
|
|
|
|
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("streams release artifacts from the static file server", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "openclaw-cross-os-static-server-"));
|
|
const filePath = join(dir, "openclaw-2026.4.14.tgz");
|
|
const logPath = join(dir, "server.log");
|
|
let server: Awaited<ReturnType<typeof startStaticFileServer>> | undefined;
|
|
|
|
try {
|
|
const payload = Buffer.from(`artifact-head\n${"x".repeat(1024 * 1024)}\nartifact-tail`);
|
|
writeFileSync(filePath, payload);
|
|
|
|
server = await startStaticFileServer({ filePath, logPath });
|
|
const response = await fetch(server.url);
|
|
const body = Buffer.from(await response.arrayBuffer());
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("content-length")).toBe(String(payload.length));
|
|
expect(response.headers.get("content-type")).toBe("application/octet-stream");
|
|
expect(body.equals(payload)).toBe(true);
|
|
expect(readFileSync(logPath, "utf8")).toContain(`GET /${filePath.split(/[/\\]/u).at(-1)}`);
|
|
} finally {
|
|
await server?.close();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not preload static release artifacts before serving them", () => {
|
|
const source = readFileSync("scripts/openclaw-cross-os-release-checks.ts", "utf8");
|
|
const serverSource = source.slice(
|
|
source.indexOf("export async function startStaticFileServer"),
|
|
source.indexOf("export function resolveStaticFileContentType"),
|
|
);
|
|
|
|
expect(serverSource).toContain("createReadStream(params.filePath)");
|
|
expect(serverSource).not.toContain("readFileSync(params.filePath)");
|
|
});
|
|
|
|
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("wraps Windows cmd shims without Node shell argv", () => {
|
|
expect(
|
|
resolveCommandSpawnInvocation(
|
|
String.raw`C:\Program Files\nodejs\npm.cmd`,
|
|
["view", "openclaw@latest", "version"],
|
|
{
|
|
comSpec: String.raw`C:\Windows\System32\cmd.exe`,
|
|
platform: "win32",
|
|
},
|
|
),
|
|
).toEqual({
|
|
command: String.raw`C:\Windows\System32\cmd.exe`,
|
|
args: [
|
|
"/d",
|
|
"/s",
|
|
"/c",
|
|
String.raw`""C:\Program Files\nodejs\npm.cmd" view openclaw@latest version"`,
|
|
],
|
|
shell: false,
|
|
windowsVerbatimArguments: true,
|
|
});
|
|
});
|
|
|
|
it("wraps installed Windows CLI cmd fallbacks without Node shell argv", () => {
|
|
expect(
|
|
resolveInstalledCliInvocation(
|
|
win32.join(String.raw`C:\OpenClaw Prefix`, "openclaw.cmd"),
|
|
["gateway", "run", "--port", "1234"],
|
|
{
|
|
comSpec: String.raw`C:\Windows\System32\cmd.exe`,
|
|
platform: "win32",
|
|
},
|
|
),
|
|
).toEqual({
|
|
command: String.raw`C:\Windows\System32\cmd.exe`,
|
|
args: [
|
|
"/d",
|
|
"/s",
|
|
"/c",
|
|
String.raw`""C:\OpenClaw Prefix\openclaw.cmd" gateway run --port 1234"`,
|
|
],
|
|
shell: false,
|
|
windowsVerbatimArguments: true,
|
|
});
|
|
});
|
|
|
|
it("runs resolved command invocations and writes command logs", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "openclaw-cross-os-run-command-"));
|
|
try {
|
|
const logPath = join(dir, "command.log");
|
|
const result = await runCommand(process.execPath, ["-e", "process.stdout.write('ok')"], {
|
|
cwd: dir,
|
|
env: process.env,
|
|
logPath,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
exitCode: 0,
|
|
stdout: "ok",
|
|
stderr: "",
|
|
});
|
|
expect(readFileSync(logPath, "utf8")).toContain("start command=");
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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",
|
|
OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS: "1",
|
|
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",
|
|
},
|
|
}),
|
|
),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
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" },
|
|
),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
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" },
|
|
),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
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" },
|
|
),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|