Files
openclaw/src/cli/update-cli/update-command.test.ts
Vincent Koc 2014c2327b fix(plugins): sync official plugin installs during update (#78065)
* fix(plugins): sync official npm installs during update

* fix(plugins): sync official clawhub installs during update

* test(update): mock official plugin sync helpers

---------

Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
2026-05-05 17:27:32 -07:00

547 lines
17 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
buildGatewayInstallEntrypointCandidates as resolveGatewayInstallEntrypointCandidates,
resolveGatewayInstallEntrypoint,
} from "../../daemon/gateway-entrypoint.js";
import {
collectMissingPluginInstallPayloads,
recoverInstalledLaunchAgentAfterUpdate,
recoverLaunchAgentAndRecheckGatewayHealth,
resolvePostInstallDoctorEnv,
shouldPrepareUpdatedInstallRestart,
resolveUpdatedGatewayRestartPort,
shouldUseLegacyProcessRestartAfterUpdate,
} from "./update-command.js";
describe("resolveGatewayInstallEntrypointCandidates", () => {
it("prefers index.js before legacy entry.js", () => {
expect(resolveGatewayInstallEntrypointCandidates("/tmp/openclaw-root")).toEqual([
path.join("/tmp/openclaw-root", "dist", "index.js"),
path.join("/tmp/openclaw-root", "dist", "index.mjs"),
path.join("/tmp/openclaw-root", "dist", "entry.js"),
path.join("/tmp/openclaw-root", "dist", "entry.mjs"),
]);
});
});
describe("resolveGatewayInstallEntrypoint", () => {
it("prefers dist/index.js over dist/entry.js when both exist", async () => {
const root = "/tmp/openclaw-root";
const indexPath = path.join(root, "dist", "index.js");
const entryPath = path.join(root, "dist", "entry.js");
await expect(
resolveGatewayInstallEntrypoint(
root,
async (candidate) => candidate === indexPath || candidate === entryPath,
),
).resolves.toBe(indexPath);
});
it("falls back to dist/entry.js when index.js is missing", async () => {
const root = "/tmp/openclaw-root";
const entryPath = path.join(root, "dist", "entry.js");
await expect(
resolveGatewayInstallEntrypoint(root, async (candidate) => candidate === entryPath),
).resolves.toBe(entryPath);
});
});
describe("shouldPrepareUpdatedInstallRestart", () => {
it("prepares package update restarts when the service is installed but stopped", () => {
expect(
shouldPrepareUpdatedInstallRestart({
updateMode: "npm",
serviceInstalled: true,
serviceLoaded: false,
}),
).toBe(true);
});
it("does not install a new service for package updates when no service exists", () => {
expect(
shouldPrepareUpdatedInstallRestart({
updateMode: "npm",
serviceInstalled: false,
serviceLoaded: false,
}),
).toBe(false);
});
it("keeps non-package updates tied to the loaded service state", () => {
expect(
shouldPrepareUpdatedInstallRestart({
updateMode: "git",
serviceInstalled: true,
serviceLoaded: false,
}),
).toBe(false);
expect(
shouldPrepareUpdatedInstallRestart({
updateMode: "git",
serviceInstalled: true,
serviceLoaded: true,
}),
).toBe(true);
});
});
describe("resolveUpdatedGatewayRestartPort", () => {
it("uses the managed service port ahead of the caller environment", () => {
expect(
resolveUpdatedGatewayRestartPort({
config: { gateway: { port: 19000 } } as never,
processEnv: { OPENCLAW_GATEWAY_PORT: "19001" },
serviceEnv: { OPENCLAW_GATEWAY_PORT: "19002" },
}),
).toBe(19002);
});
it("falls back to the post-update config when no service port is available", () => {
expect(
resolveUpdatedGatewayRestartPort({
config: { gateway: { port: 19000 } } as never,
processEnv: {},
serviceEnv: {},
}),
).toBe(19000);
});
});
describe("resolvePostInstallDoctorEnv", () => {
it("uses the managed service profile paths for post-install doctor", () => {
const env = resolvePostInstallDoctorEnv({
invocationCwd: "/srv/openclaw",
baseEnv: {
PATH: "/bin",
OPENCLAW_STATE_DIR: "/wrong/state",
OPENCLAW_CONFIG_PATH: "/wrong/openclaw.json",
OPENCLAW_PROFILE: "wrong",
},
serviceEnv: {
OPENCLAW_STATE_DIR: "daemon-state",
OPENCLAW_CONFIG_PATH: "daemon-state/openclaw.json",
OPENCLAW_PROFILE: "work",
},
});
expect(env.PATH).toBe("/bin");
expect(env.NODE_DISABLE_COMPILE_CACHE).toBe("1");
expect(env.OPENCLAW_STATE_DIR).toBe(path.join("/srv/openclaw", "daemon-state"));
expect(env.OPENCLAW_CONFIG_PATH).toBe(
path.join("/srv/openclaw", "daemon-state", "openclaw.json"),
);
expect(env.OPENCLAW_PROFILE).toBe("work");
});
it("keeps the caller env when no managed service env is available", () => {
const env = resolvePostInstallDoctorEnv({
baseEnv: {
PATH: "/bin",
OPENCLAW_STATE_DIR: "/caller/state",
OPENCLAW_PROFILE: "caller",
},
});
expect(env.PATH).toBe("/bin");
expect(env.NODE_DISABLE_COMPILE_CACHE).toBe("1");
expect(env.OPENCLAW_STATE_DIR).toBe("/caller/state");
expect(env.OPENCLAW_PROFILE).toBe("caller");
});
});
describe("collectMissingPluginInstallPayloads", () => {
it("reports tracked npm install records whose package payload is absent", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-plugin-payload-"));
const presentDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "present");
const missingDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "missing");
const noPackageJsonDir = path.join(
tmpDir,
"state",
"npm",
"node_modules",
"@openclaw",
"no-package-json",
);
try {
await fs.mkdir(presentDir, { recursive: true });
await fs.writeFile(path.join(presentDir, "package.json"), '{"name":"@openclaw/present"}\n');
await fs.mkdir(noPackageJsonDir, { recursive: true });
await expect(
collectMissingPluginInstallPayloads({
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
records: {
present: {
source: "npm",
spec: "@openclaw/present@beta",
installPath: presentDir,
},
missing: {
source: "npm",
spec: "@openclaw/missing@beta",
installPath: missingDir,
},
"no-package-json": {
source: "npm",
spec: "@openclaw/no-package-json@beta",
installPath: noPackageJsonDir,
},
"missing-install-path": {
source: "npm",
spec: "@openclaw/missing-install-path@beta",
},
local: {
source: "path",
sourcePath: "/not/checked",
installPath: "/not/checked",
},
},
}),
).resolves.toEqual([
{
pluginId: "missing",
installPath: missingDir,
reason: "missing-package-dir",
},
{
pluginId: "missing-install-path",
reason: "missing-install-path",
},
{
pluginId: "no-package-json",
installPath: noPackageJsonDir,
reason: "missing-package-json",
},
]);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("skips disabled tracked records when requested", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-plugin-payload-"));
const missingDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "missing");
try {
await expect(
collectMissingPluginInstallPayloads({
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
skipDisabledPlugins: true,
config: {
plugins: {
entries: {
missing: {
enabled: false,
},
},
},
},
records: {
missing: {
source: "npm",
spec: "@openclaw/missing@beta",
installPath: missingDir,
},
},
}),
).resolves.toEqual([]);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("keeps disabled trusted official npm records eligible for payload repair when requested", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-plugin-payload-"));
const missingDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "codex");
try {
await expect(
collectMissingPluginInstallPayloads({
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
skipDisabledPlugins: true,
syncOfficialPluginInstalls: true,
config: {
plugins: {
entries: {
codex: {
enabled: false,
},
},
},
},
records: {
codex: {
source: "npm",
spec: "@openclaw/codex@2026.5.3",
resolvedName: "@openclaw/codex",
resolvedSpec: "@openclaw/codex@2026.5.3",
installPath: missingDir,
},
},
}),
).resolves.toEqual([
{
pluginId: "codex",
installPath: missingDir,
reason: "missing-package-dir",
},
]);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("keeps disabled trusted official ClawHub records eligible for payload repair when requested", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-plugin-payload-"));
const missingDir = path.join(tmpDir, "state", "clawhub", "diagnostics-otel");
try {
await expect(
collectMissingPluginInstallPayloads({
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
skipDisabledPlugins: true,
syncOfficialPluginInstalls: true,
config: {
plugins: {
entries: {
"diagnostics-otel": {
enabled: false,
},
},
},
},
records: {
"diagnostics-otel": {
source: "clawhub",
spec: "clawhub:@openclaw/diagnostics-otel@2026.5.3",
installPath: missingDir,
},
},
}),
).resolves.toEqual([
{
pluginId: "diagnostics-otel",
installPath: missingDir,
reason: "missing-package-dir",
},
]);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});
describe("shouldUseLegacyProcessRestartAfterUpdate", () => {
it("never restarts package updates through the pre-update process", () => {
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "npm" })).toBe(false);
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "pnpm" })).toBe(false);
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "bun" })).toBe(false);
});
it("keeps the in-process restart path for non-package updates", () => {
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "git" })).toBe(true);
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "unknown" })).toBe(true);
});
});
describe("recoverInstalledLaunchAgentAfterUpdate", () => {
it("re-bootstraps an installed-but-not-loaded macOS LaunchAgent after update", async () => {
const service = {} as never;
const serviceEnv = { OPENCLAW_PROFILE: "stomme" } as NodeJS.ProcessEnv;
const recoveredEnv = { ...serviceEnv, OPENCLAW_PORT: "18790" } as NodeJS.ProcessEnv;
const readState = vi.fn(async () => ({
installed: true,
loaded: false,
running: false,
env: recoveredEnv,
command: null,
runtime: { status: "unknown", missingSupervision: true },
}));
const recover = vi.fn(async () => ({
result: "restarted" as const,
loaded: true as const,
message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.",
}));
await expect(
recoverInstalledLaunchAgentAfterUpdate({
service,
env: serviceEnv,
deps: {
platform: "darwin",
readState: readState as never,
recover: recover as never,
},
}),
).resolves.toEqual({
attempted: true,
recovered: true,
message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.",
});
expect(readState).toHaveBeenCalledWith(service, { env: serviceEnv });
expect(recover).toHaveBeenCalledWith({ result: "restarted", env: recoveredEnv });
});
it("does not touch non-macOS service managers", async () => {
const readState = vi.fn();
const recover = vi.fn();
await expect(
recoverInstalledLaunchAgentAfterUpdate({
service: {} as never,
deps: {
platform: "linux",
readState: readState as never,
recover: recover as never,
},
}),
).resolves.toEqual({ attempted: false, recovered: false });
expect(readState).not.toHaveBeenCalled();
expect(recover).not.toHaveBeenCalled();
});
it("does not recover a loaded LaunchAgent", async () => {
const readState = vi.fn(async () => ({
installed: true,
loaded: true,
running: true,
env: { OPENCLAW_PROFILE: "stomme" } as NodeJS.ProcessEnv,
command: null,
runtime: { status: "running" },
}));
const recover = vi.fn();
await expect(
recoverInstalledLaunchAgentAfterUpdate({
service: {} as never,
deps: {
platform: "darwin",
readState: readState as never,
recover: recover as never,
},
}),
).resolves.toEqual({ attempted: false, recovered: false });
expect(recover).not.toHaveBeenCalled();
});
it("returns an explicit failed recovery state when bootstrap repair fails", async () => {
const readState = vi.fn(async () => ({
installed: true,
loaded: false,
running: false,
env: { OPENCLAW_PROFILE: "stomme" } as NodeJS.ProcessEnv,
command: null,
runtime: { status: "unknown", missingSupervision: true },
}));
const recover = vi.fn(async () => null);
await expect(
recoverInstalledLaunchAgentAfterUpdate({
service: {} as never,
deps: {
platform: "darwin",
readState: readState as never,
recover: recover as never,
},
}),
).resolves.toEqual({
attempted: true,
recovered: false,
detail:
"LaunchAgent was installed but not loaded; automatic bootstrap/kickstart recovery failed.",
});
});
});
describe("recoverLaunchAgentAndRecheckGatewayHealth", () => {
it("does not report recovered update health until the gateway passes the post-recovery wait", async () => {
const service = {} as never;
const unhealthy = {
runtime: { status: "stopped" },
portUsage: { port: 18790, status: "free", listeners: [], hints: [] },
healthy: false,
staleGatewayPids: [],
waitOutcome: "stopped-free",
} as never;
const healthy = {
runtime: { status: "running", pid: 4242 },
portUsage: { port: 18790, status: "busy", listeners: [{ pid: 4242 }], hints: [] },
healthy: true,
staleGatewayPids: [],
gatewayVersion: "2026.5.3",
waitOutcome: "healthy",
} as never;
const recoverLaunchAgent = vi.fn(async () => ({
attempted: true as const,
recovered: true as const,
message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.",
}));
const waitForHealthy = vi.fn(async () => healthy);
await expect(
recoverLaunchAgentAndRecheckGatewayHealth({
health: unhealthy,
service,
port: 18790,
expectedVersion: "2026.5.3",
env: { OPENCLAW_PROFILE: "stomme", OPENCLAW_PORT: "18790" },
deps: { recoverLaunchAgent, waitForHealthy },
}),
).resolves.toEqual({
health: healthy,
launchAgentRecovery: {
attempted: true,
recovered: true,
message:
"Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.",
},
});
expect(waitForHealthy).toHaveBeenCalledWith({
service,
port: 18790,
expectedVersion: "2026.5.3",
env: { OPENCLAW_PROFILE: "stomme", OPENCLAW_PORT: "18790" },
});
});
it("keeps the update unhealthy when LaunchAgent repair succeeds but health does not recover", async () => {
const service = {} as never;
const unhealthySnapshot = {
runtime: { status: "stopped" },
portUsage: { port: 18790, status: "free", listeners: [], hints: [] },
healthy: false,
staleGatewayPids: [],
waitOutcome: "stopped-free",
};
const unhealthy = unhealthySnapshot as never;
const stillUnhealthy = {
...unhealthySnapshot,
waitOutcome: "timeout",
} as never;
const recoverLaunchAgent = vi.fn(async () => ({
attempted: true as const,
recovered: true as const,
message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.",
}));
const waitForHealthy = vi.fn(async () => stillUnhealthy);
await expect(
recoverLaunchAgentAndRecheckGatewayHealth({
health: unhealthy,
service,
port: 18790,
expectedVersion: "2026.5.3",
deps: { recoverLaunchAgent, waitForHealthy },
}),
).resolves.toMatchObject({
health: { healthy: false, waitOutcome: "timeout" },
launchAgentRecovery: { attempted: true, recovered: true },
});
});
});