diff --git a/.agents/skills/openclaw-release-maintainer/SKILL.md b/.agents/skills/openclaw-release-maintainer/SKILL.md index 1aa40aa0a2b..561ba1c88ee 100644 --- a/.agents/skills/openclaw-release-maintainer/SKILL.md +++ b/.agents/skills/openclaw-release-maintainer/SKILL.md @@ -72,9 +72,20 @@ pnpm test:install:smoke For a non-root smoke path: ```bash -OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke + OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke ``` +After npm publish, run: + +```bash +node --import tsx scripts/openclaw-npm-postpublish-verify.ts +``` + +- This verifies the published registry install path in a fresh temp prefix. +- For stable correction releases like `YYYY.M.D-N`, it also verifies the + upgrade path from `YYYY.M.D` to `YYYY.M.D-N` so a correction publish cannot + silently leave existing global installs on the old base stable payload. + ## Check all relevant release builds - Always validate the OpenClaw npm release path before creating the tag. diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 946e534279d..efa23c319b1 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -42,6 +42,14 @@ OpenClaw has three public release lanes: - Run `pnpm release:check` before every tagged release - Run `RELEASE_TAG=vYYYY.M.D node --import tsx scripts/openclaw-npm-release-check.ts` (or the matching beta/correction tag) before approval +- After npm publish, run + `node --import tsx scripts/openclaw-npm-postpublish-verify.ts YYYY.M.D` + (or the matching beta/correction version) to verify the published registry + install path in a fresh temp prefix +- For stable correction releases like `YYYY.M.D-N`, the post-publish verifier + also checks the same temp-prefix upgrade path from `YYYY.M.D` to `YYYY.M.D-N` + so release corrections cannot silently leave older global installs on the + base stable payload - npm release preflight fails closed unless the tarball includes both `dist/control-ui/index.html` and a non-empty `dist/control-ui/assets/` payload so we do not ship an empty browser dashboard again diff --git a/package.json b/package.json index 39d021e3eb7..fdbfada18b6 100644 --- a/package.json +++ b/package.json @@ -684,6 +684,7 @@ "protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts", "release:check": "pnpm config:docs:check && pnpm plugin-sdk:api:check && node scripts/stage-bundled-plugin-runtime-deps.mjs && pnpm ui:build && node --import tsx scripts/release-check.ts", "release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts", + "release:openclaw:npm:verify-published": "node --import tsx scripts/openclaw-npm-postpublish-verify.ts", "release:plugins:npm:check": "node --import tsx scripts/plugin-npm-release-check.ts", "release:plugins:npm:plan": "node --import tsx scripts/plugin-npm-release-plan.ts", "stage:bundled-plugin-runtime-deps": "node scripts/stage-bundled-plugin-runtime-deps.mjs", diff --git a/scripts/openclaw-npm-postpublish-verify.ts b/scripts/openclaw-npm-postpublish-verify.ts new file mode 100644 index 00000000000..35568d398b3 --- /dev/null +++ b/scripts/openclaw-npm-postpublish-verify.ts @@ -0,0 +1,157 @@ +#!/usr/bin/env -S node --import tsx + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { parseReleaseVersion, resolveNpmCommandInvocation } from "./openclaw-npm-release-check.ts"; + +const REQUIRED_RUNTIME_SIDECARS = [ + "dist/extensions/whatsapp/light-runtime-api.js", + "dist/extensions/whatsapp/runtime-api.js", + "dist/extensions/matrix/helper-api.js", + "dist/extensions/matrix/runtime-api.js", + "dist/extensions/matrix/thread-bindings-runtime.js", + "dist/extensions/msteams/runtime-api.js", +] as const; + +type InstalledPackageJson = { + version?: string; +}; + +export type PublishedInstallScenario = { + name: string; + installSpecs: string[]; + expectedVersion: string; +}; + +export function buildPublishedInstallScenarios(version: string): PublishedInstallScenario[] { + const parsed = parseReleaseVersion(version); + if (parsed === null) { + throw new Error(`Unsupported release version "${version}".`); + } + + const exactSpec = `openclaw@${version}`; + const scenarios: PublishedInstallScenario[] = [ + { + name: "fresh-exact", + installSpecs: [exactSpec], + expectedVersion: version, + }, + ]; + + if (parsed.channel === "stable" && parsed.correctionNumber !== undefined) { + scenarios.push({ + name: "upgrade-from-base-stable", + installSpecs: [`openclaw@${parsed.baseVersion}`, exactSpec], + expectedVersion: version, + }); + } + + return scenarios; +} + +export function collectInstalledPackageErrors(params: { + expectedVersion: string; + installedVersion: string; + packageRoot: string; +}): string[] { + const errors: string[] = []; + + if (params.installedVersion !== params.expectedVersion) { + errors.push( + `installed package version mismatch: expected ${params.expectedVersion}, found ${params.installedVersion || ""}.`, + ); + } + + for (const relativePath of REQUIRED_RUNTIME_SIDECARS) { + if (!existsSync(join(params.packageRoot, relativePath))) { + errors.push(`installed package is missing required bundled runtime sidecar: ${relativePath}`); + } + } + + return errors; +} + +function npmExec(args: string[], cwd: string): string { + const invocation = resolveNpmCommandInvocation({ + npmExecPath: process.env.npm_execpath, + nodeExecPath: process.execPath, + platform: process.platform, + }); + + return execFileSync(invocation.command, [...invocation.args, ...args], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function resolveGlobalRoot(prefixDir: string, cwd: string): string { + return npmExec(["root", "-g", "--prefix", prefixDir], cwd); +} + +function installSpec(prefixDir: string, spec: string, cwd: string): void { + npmExec(["install", "-g", "--prefix", prefixDir, spec, "--no-fund", "--no-audit"], cwd); +} + +function verifyScenario(version: string, scenario: PublishedInstallScenario): void { + const workingDir = mkdtempSync(join(tmpdir(), `openclaw-postpublish-${scenario.name}.`)); + const prefixDir = join(workingDir, "prefix"); + + try { + for (const spec of scenario.installSpecs) { + installSpec(prefixDir, spec, workingDir); + } + + const globalRoot = resolveGlobalRoot(prefixDir, workingDir); + const packageRoot = join(globalRoot, "openclaw"); + const pkg = JSON.parse( + readFileSync(join(packageRoot, "package.json"), "utf8"), + ) as InstalledPackageJson; + const errors = collectInstalledPackageErrors({ + expectedVersion: scenario.expectedVersion, + installedVersion: pkg.version?.trim() ?? "", + packageRoot, + }); + + if (errors.length > 0) { + throw new Error(`${scenario.name} failed:\n- ${errors.join("\n- ")}`); + } + + console.log(`openclaw-npm-postpublish-verify: ${scenario.name} OK (${version})`); + } finally { + rmSync(workingDir, { force: true, recursive: true }); + } +} + +function main(): void { + const version = process.argv[2]?.trim(); + if (!version) { + throw new Error( + "Usage: node --import tsx scripts/openclaw-npm-postpublish-verify.ts ", + ); + } + + const scenarios = buildPublishedInstallScenarios(version); + for (const scenario of scenarios) { + verifyScenario(version, scenario); + } + + console.log( + `openclaw-npm-postpublish-verify: verified published npm install paths for ${version}.`, + ); +} + +const entrypoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : null; +if (entrypoint !== null && import.meta.url === entrypoint) { + try { + main(); + } catch (error) { + console.error( + `openclaw-npm-postpublish-verify: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + } +} diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index ae08bbbacd9..148da0b1527 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -28,6 +28,14 @@ const pathExists = vi.fn(); const syncPluginsForUpdateChannel = vi.fn(); const updateNpmInstalledPlugins = vi.fn(); const { defaultRuntime: runtimeCapture, resetRuntimeCapture } = createCliRuntimeCapture(); +const REQUIRED_BUNDLED_RUNTIME_SIDECARS = [ + "dist/extensions/whatsapp/light-runtime-api.js", + "dist/extensions/whatsapp/runtime-api.js", + "dist/extensions/matrix/helper-api.js", + "dist/extensions/matrix/runtime-api.js", + "dist/extensions/matrix/thread-bindings-runtime.js", + "dist/extensions/msteams/runtime-api.js", +] as const; vi.mock("@clack/prompts", () => ({ confirm, @@ -615,6 +623,58 @@ describe("update-cli", () => { } }); + it("fails package updates when the installed correction version does not match the requested target", async () => { + const tempDir = createCaseDir("openclaw-update"); + const nodeModules = path.join(tempDir, "node_modules"); + const pkgRoot = path.join(nodeModules, "openclaw"); + mockPackageInstallStatus(tempDir); + await fs.mkdir(pkgRoot, { recursive: true }); + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.3.23" }), + "utf-8", + ); + for (const relativePath of REQUIRED_BUNDLED_RUNTIME_SIDECARS) { + const absolutePath = path.join(pkgRoot, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, "export {};\n", "utf-8"); + } + readPackageVersion.mockResolvedValue("2026.3.23"); + pathExists.mockImplementation(async (candidate: string) => + REQUIRED_BUNDLED_RUNTIME_SIDECARS.some( + (relativePath) => candidate === path.join(pkgRoot, relativePath), + ), + ); + vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { + return { + stdout: nodeModules, + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + } + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + }); + + await updateCommand({ yes: true, tag: "2026.3.23-2" }); + + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + expect(writeConfigFile).not.toHaveBeenCalled(); + const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); + expect(logs.join("\n")).toContain("global install verify"); + expect(logs.join("\n")).toContain("expected installed version 2026.3.23-2, found 2026.3.23"); + }); + it("prepends portable Git PATH for package updates on Windows", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); const tempDir = createCaseDir("openclaw-update"); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index d96cc7a4a84..eff1fbb06f8 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -24,10 +24,12 @@ import { checkUpdateStatus, } from "../../infra/update-check.js"; import { + collectInstalledGlobalPackageErrors, canResolveRegistryVersionForPackageTarget, createGlobalInstallEnv, cleanupGlobalRenameDirs, globalInstallArgs, + resolveExpectedInstalledVersionFromSpec, resolveGlobalInstallSpec, resolveGlobalPackageRoot, } from "../../infra/update-global.js"; @@ -343,9 +345,27 @@ async function runPackageInstallUpdate(params: { const steps = [updateStep]; let afterVersion = beforeVersion; - if (pkgRoot) { - afterVersion = await readPackageVersion(pkgRoot); - const entryPath = path.join(pkgRoot, "dist", "entry.js"); + const verifiedPackageRoot = + (await resolveGlobalPackageRoot(manager, runCommand, params.timeoutMs)) ?? pkgRoot; + if (verifiedPackageRoot) { + afterVersion = await readPackageVersion(verifiedPackageRoot); + const expectedVersion = resolveExpectedInstalledVersionFromSpec(packageName, installSpec); + const verificationErrors = await collectInstalledGlobalPackageErrors({ + packageRoot: verifiedPackageRoot, + expectedVersion, + }); + if (verificationErrors.length > 0) { + steps.push({ + name: "global install verify", + command: `verify ${verifiedPackageRoot}`, + cwd: verifiedPackageRoot, + durationMs: 0, + exitCode: 1, + stderrTail: verificationErrors.join("\n"), + stdoutTail: null, + }); + } + const entryPath = path.join(verifiedPackageRoot, "dist", "entry.js"); if (await pathExists(entryPath)) { const doctorStep = await runUpdateStep({ name: `${CLI_NAME} doctor`, @@ -361,7 +381,7 @@ async function runPackageInstallUpdate(params: { return { status: failedStep ? "error" : "ok", mode: manager, - root: pkgRoot ?? params.root, + root: verifiedPackageRoot ?? params.root, reason: failedStep ? failedStep.name : undefined, before: { version: beforeVersion }, after: { version: afterVersion }, diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index e0dc9045f67..0083cb1714a 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { pathExists } from "../utils.js"; +import { readPackageVersion } from "./package-json.js"; import { applyPathPrepend } from "./path-prepend.js"; export type GlobalInstallManager = "npm" | "pnpm" | "bun"; @@ -15,6 +16,14 @@ const PRIMARY_PACKAGE_NAME = "openclaw"; const ALL_PACKAGE_NAMES = [PRIMARY_PACKAGE_NAME] as const; const GLOBAL_RENAME_PREFIX = "."; export const OPENCLAW_MAIN_PACKAGE_SPEC = "github:openclaw/openclaw#main"; +const REQUIRED_BUNDLED_RUNTIME_SIDECARS = [ + "dist/extensions/whatsapp/light-runtime-api.js", + "dist/extensions/whatsapp/runtime-api.js", + "dist/extensions/matrix/helper-api.js", + "dist/extensions/matrix/runtime-api.js", + "dist/extensions/matrix/thread-bindings-runtime.js", + "dist/extensions/msteams/runtime-api.js", +] as const; const NPM_GLOBAL_INSTALL_QUIET_FLAGS = ["--no-fund", "--no-audit", "--loglevel=error"] as const; const NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS = [ "--omit=optional", @@ -41,6 +50,47 @@ export function isExplicitPackageInstallSpec(value: string): boolean { ); } +export function resolveExpectedInstalledVersionFromSpec( + packageName: string, + spec: string, +): string | null { + const normalizedPackageName = packageName.trim(); + const normalizedSpec = normalizePackageTarget(spec); + if (!normalizedPackageName || !normalizedSpec.startsWith(`${normalizedPackageName}@`)) { + return null; + } + const rawVersion = normalizedSpec.slice(normalizedPackageName.length + 1).trim(); + if ( + !rawVersion || + rawVersion.includes("/") || + rawVersion.includes(":") || + rawVersion.includes("#") || + /^(latest|beta|next|main)$/i.test(rawVersion) + ) { + return null; + } + return rawVersion; +} + +export async function collectInstalledGlobalPackageErrors(params: { + packageRoot: string; + expectedVersion?: string | null; +}): Promise { + const errors: string[] = []; + const installedVersion = await readPackageVersion(params.packageRoot); + if (params.expectedVersion && installedVersion !== params.expectedVersion) { + errors.push( + `expected installed version ${params.expectedVersion}, found ${installedVersion ?? ""}`, + ); + } + for (const relativePath of REQUIRED_BUNDLED_RUNTIME_SIDECARS) { + if (!(await pathExists(path.join(params.packageRoot, relativePath)))) { + errors.push(`missing bundled runtime sidecar ${relativePath}`); + } + } + return errors; +} + export function canResolveRegistryVersionForPackageTarget(value: string): boolean { const trimmed = normalizePackageTarget(value); if (!trimmed) { diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 4d0436eb5ba..f8f6d2abcd5 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -9,6 +9,14 @@ import { runGatewayUpdate } from "./update-runner.js"; type CommandResponse = { stdout?: string; stderr?: string; code?: number | null }; type CommandResult = { stdout: string; stderr: string; code: number | null }; +const REQUIRED_BUNDLED_RUNTIME_SIDECARS = [ + "dist/extensions/whatsapp/light-runtime-api.js", + "dist/extensions/whatsapp/runtime-api.js", + "dist/extensions/matrix/helper-api.js", + "dist/extensions/matrix/runtime-api.js", + "dist/extensions/matrix/thread-bindings-runtime.js", + "dist/extensions/msteams/runtime-api.js", +] as const; function createRunner(responses: Record) { const calls: string[] = []; @@ -185,6 +193,7 @@ describe("runGatewayUpdate", () => { JSON.stringify({ name: "openclaw", version }), "utf-8", ); + await writeBundledRuntimeSidecars(pkgRoot); } async function writeGlobalPackageVersion(pkgRoot: string, version = "2.0.0") { @@ -193,6 +202,15 @@ describe("runGatewayUpdate", () => { JSON.stringify({ name: "openclaw", version }), "utf-8", ); + await writeBundledRuntimeSidecars(pkgRoot); + } + + async function writeBundledRuntimeSidecars(pkgRoot: string) { + for (const relativePath of REQUIRED_BUNDLED_RUNTIME_SIDECARS) { + const absolutePath = path.join(pkgRoot, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, "export {};\n", "utf-8"); + } } async function createGlobalPackageFixture(rootDir: string) { @@ -660,11 +678,7 @@ describe("runGatewayUpdate", () => { return { stdout: "", stderr: "node-gyp failed", code: 1 }; }, onOmitOptionalInstall: async () => { - await fs.writeFile( - path.join(pkgRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2.0.0" }), - "utf-8", - ); + await writeGlobalPackageVersion(pkgRoot); return { stdout: "ok", stderr: "", code: 0 }; }, }); @@ -680,6 +694,47 @@ describe("runGatewayUpdate", () => { ]); }); + it("fails global npm update when the installed version misses the requested correction", async () => { + const { calls, result } = await runNpmGlobalUpdateCase({ + expectedInstallCommand: "npm i -g openclaw@2026.3.23-2 --no-fund --no-audit --loglevel=error", + tag: "2026.3.23-2", + }); + + expect(result.status).toBe("error"); + expect(result.reason).toBe("global install verify"); + expect(result.after?.version).toBe("2.0.0"); + expect(result.steps.at(-1)?.stderrTail).toContain( + "expected installed version 2026.3.23-2, found 2.0.0", + ); + expect(calls).toContain("npm i -g openclaw@2026.3.23-2 --no-fund --no-audit --loglevel=error"); + }); + + it("fails global npm update when bundled runtime sidecars are missing after install", async () => { + const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir); + const expectedInstallCommand = "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error"; + const { runCommand } = createGlobalInstallHarness({ + pkgRoot, + npmRootOutput: nodeModules, + installCommand: expectedInstallCommand, + onInstall: async () => { + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2.0.0" }), + "utf-8", + ); + await fs.rm(path.join(pkgRoot, "dist"), { recursive: true, force: true }); + }, + }); + + const result = await runWithCommand(runCommand, { cwd: pkgRoot }); + + expect(result.status).toBe("error"); + expect(result.reason).toBe("global install verify"); + expect(result.steps.at(-1)?.stderrTail).toContain( + "missing bundled runtime sidecar dist/extensions/whatsapp/light-runtime-api.js", + ); + }); + it("prepends portable Git PATH for global Windows npm updates", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); const localAppData = path.join(tempDir, "local-app-data"); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 373fe42e768..90df1408706 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -21,12 +21,15 @@ import { } from "./update-channels.js"; import { compareSemverStrings } from "./update-check.js"; import { + collectInstalledGlobalPackageErrors, cleanupGlobalRenameDirs, createGlobalInstallEnv, detectGlobalInstallManagerForRoot, globalInstallArgs, globalInstallFallbackArgs, + resolveExpectedInstalledVersionFromSpec, resolveGlobalInstallSpec, + resolveGlobalPackageRoot, } from "./update-global.js"; export type UpdateStepResult = { @@ -1045,12 +1048,34 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< } } - const afterVersion = await readPackageVersion(pkgRoot); + const verifiedPackageRoot = + (await resolveGlobalPackageRoot(globalManager, runCommand, timeoutMs)) ?? pkgRoot; + const expectedVersion = resolveExpectedInstalledVersionFromSpec(packageName, spec); + const verificationErrors = await collectInstalledGlobalPackageErrors({ + packageRoot: verifiedPackageRoot, + expectedVersion, + }); + if (verificationErrors.length > 0) { + steps.push({ + name: "global install verify", + command: `verify ${verifiedPackageRoot}`, + cwd: verifiedPackageRoot, + durationMs: 0, + exitCode: 1, + stderrTail: verificationErrors.join("\n"), + }); + } + const afterVersion = await readPackageVersion(verifiedPackageRoot); + const failedStep = + finalStep.exitCode !== 0 + ? finalStep + : (steps.find((step) => step.name === "global install verify" && step.exitCode !== 0) ?? + null); return { - status: finalStep.exitCode === 0 ? "ok" : "error", + status: failedStep ? "error" : "ok", mode: globalManager, - root: pkgRoot, - reason: finalStep.exitCode === 0 ? undefined : finalStep.name, + root: verifiedPackageRoot, + reason: failedStep ? failedStep.name : undefined, before: { version: beforeVersion }, after: { version: afterVersion }, steps, diff --git a/test/openclaw-npm-postpublish-verify.test.ts b/test/openclaw-npm-postpublish-verify.test.ts new file mode 100644 index 00000000000..577ebf924a2 --- /dev/null +++ b/test/openclaw-npm-postpublish-verify.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + buildPublishedInstallScenarios, + collectInstalledPackageErrors, +} from "../scripts/openclaw-npm-postpublish-verify.ts"; + +describe("buildPublishedInstallScenarios", () => { + it("uses a single fresh scenario for plain stable releases", () => { + expect(buildPublishedInstallScenarios("2026.3.23")).toEqual([ + { + name: "fresh-exact", + installSpecs: ["openclaw@2026.3.23"], + expectedVersion: "2026.3.23", + }, + ]); + }); + + it("adds a stable-to-correction upgrade scenario for correction releases", () => { + expect(buildPublishedInstallScenarios("2026.3.23-2")).toEqual([ + { + name: "fresh-exact", + installSpecs: ["openclaw@2026.3.23-2"], + expectedVersion: "2026.3.23-2", + }, + { + name: "upgrade-from-base-stable", + installSpecs: ["openclaw@2026.3.23", "openclaw@2026.3.23-2"], + expectedVersion: "2026.3.23-2", + }, + ]); + }); +}); + +describe("collectInstalledPackageErrors", () => { + it("flags version mismatches and missing runtime sidecars", () => { + expect( + collectInstalledPackageErrors({ + expectedVersion: "2026.3.23-2", + installedVersion: "2026.3.23", + packageRoot: "/tmp/empty-openclaw", + }), + ).toEqual([ + "installed package version mismatch: expected 2026.3.23-2, found 2026.3.23.", + "installed package is missing required bundled runtime sidecar: dist/extensions/whatsapp/light-runtime-api.js", + "installed package is missing required bundled runtime sidecar: dist/extensions/whatsapp/runtime-api.js", + "installed package is missing required bundled runtime sidecar: dist/extensions/matrix/helper-api.js", + "installed package is missing required bundled runtime sidecar: dist/extensions/matrix/runtime-api.js", + "installed package is missing required bundled runtime sidecar: dist/extensions/matrix/thread-bindings-runtime.js", + "installed package is missing required bundled runtime sidecar: dist/extensions/msteams/runtime-api.js", + ]); + }); +});