mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-31 17:21:47 +00:00
Summary: - The PR updates the CLI post-update gateway recovery formatter and tests to show Linux, macOS, Windows, or generic service-manager guidance, plus a changelog entry. - Reproducibility: yes. Source inspection gives a high-confidence reproduction path: current main reaches a fo ... hAgent recovery text, while the platform contract says Linux uses systemd and Windows uses Scheduled Tasks. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(update): tailor gateway recovery hints by platform Validation: - ClawSweeper review passed for head0cf2a0c5a7. - Required merge gates passed before the squash merge. Prepared head SHA:0cf2a0c5a7Review: https://github.com/openclaw/openclaw/pull/83191#issuecomment-4471471293 Co-authored-by: Rubén Cuevas <hi@rubencu.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
675 lines
23 KiB
TypeScript
675 lines
23 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 type { UpdateRunResult } from "../../infra/update-runner.js";
|
|
import {
|
|
buildInvalidConfigPostCoreUpdateResult,
|
|
collectMissingPluginInstallPayloads,
|
|
formatPostUpdateGatewayRecoveryInstructions,
|
|
recoverInstalledLaunchAgentAfterUpdate,
|
|
recoverLaunchAgentAndRecheckGatewayHealth,
|
|
resolvePostCoreUpdateChildStdio,
|
|
resolvePostInstallDoctorEnv,
|
|
shouldPrepareUpdatedInstallRestart,
|
|
resolveUpdatedGatewayRestartPort,
|
|
shouldUseLegacyProcessRestartAfterUpdate,
|
|
updatePluginsAfterCoreUpdate,
|
|
} 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.toStrictEqual([]);
|
|
} 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("formatPostUpdateGatewayRecoveryInstructions", () => {
|
|
const result: UpdateRunResult = {
|
|
status: "error",
|
|
mode: "git",
|
|
steps: [],
|
|
durationMs: 0,
|
|
};
|
|
|
|
it("uses systemd wording on Linux instead of macOS LaunchAgent instructions", () => {
|
|
const [line] = formatPostUpdateGatewayRecoveryInstructions(result, "linux");
|
|
|
|
expect(line).toContain("the systemd user service");
|
|
expect(line).toContain("openclaw gateway restart");
|
|
expect(line).toContain("openclaw gateway install --force");
|
|
expect(line).toContain("openclaw gateway status --deep");
|
|
expect(line).not.toContain("Linux reports");
|
|
expect(line).not.toContain("macOS");
|
|
expect(line).not.toContain("LaunchAgent");
|
|
});
|
|
|
|
it("keeps LaunchAgent recovery wording on macOS", () => {
|
|
const [line] = formatPostUpdateGatewayRecoveryInstructions(result, "darwin");
|
|
|
|
expect(line).toContain("the LaunchAgent is installed but not loaded");
|
|
expect(line).toContain("logged-in macOS user session");
|
|
});
|
|
|
|
it("uses Windows service-manager wording on Windows", () => {
|
|
const [line] = formatPostUpdateGatewayRecoveryInstructions(result, "win32");
|
|
|
|
expect(line).toContain("the gateway Scheduled Task or Windows login item");
|
|
expect(line).not.toContain("LaunchAgent");
|
|
expect(line).not.toContain("Startup-folder");
|
|
});
|
|
|
|
it("uses generic service-manager wording for unsupported Node platforms", () => {
|
|
const [line] = formatPostUpdateGatewayRecoveryInstructions(result, "freebsd");
|
|
|
|
expect(line).toContain("local service manager");
|
|
expect(line).not.toContain("systemd");
|
|
expect(line).not.toContain("LaunchAgent");
|
|
expect(line).not.toContain("Scheduled Task");
|
|
});
|
|
});
|
|
|
|
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);
|
|
|
|
const result = await recoverLaunchAgentAndRecheckGatewayHealth({
|
|
health: unhealthy,
|
|
service,
|
|
port: 18790,
|
|
expectedVersion: "2026.5.3",
|
|
deps: { recoverLaunchAgent, waitForHealthy },
|
|
});
|
|
expect(result.health.healthy).toBe(false);
|
|
expect(result.health.waitOutcome).toBe("timeout");
|
|
expect(result.launchAgentRecovery?.attempted).toBe(true);
|
|
expect(result.launchAgentRecovery?.recovered).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("resolvePostCoreUpdateChildStdio", () => {
|
|
it('returns "pipe" on Windows so the child never inherits the parent console handles', () => {
|
|
// On Windows, stdio:"inherit" passes the parent's console HANDLE to the child process.
|
|
// PowerShell/CMD will not return the prompt until every holder of those handles exits,
|
|
// causing the terminal to hang after `openclaw update` completes (#78445).
|
|
expect(resolvePostCoreUpdateChildStdio("win32")).toBe("pipe");
|
|
});
|
|
|
|
it('returns "inherit" on non-Windows platforms', () => {
|
|
expect(resolvePostCoreUpdateChildStdio("linux")).toBe("inherit");
|
|
expect(resolvePostCoreUpdateChildStdio("darwin")).toBe("inherit");
|
|
});
|
|
});
|
|
|
|
describe("updatePluginsAfterCoreUpdate (invalid config end-to-end)", () => {
|
|
it("returns status:error (not skipped) when configSnapshot is invalid, so the pre-restart gate fires", async () => {
|
|
// The pre-restart gate in `updateCommand` is literally
|
|
// if (postCorePluginUpdate?.status === "error") { exit(1) }
|
|
// so asserting that this function returns status:"error" on invalid
|
|
// config is sufficient to prove the gate fires end-to-end. We pass
|
|
// `json: true` to suppress logging side-effects without mocking.
|
|
const result = await updatePluginsAfterCoreUpdate({
|
|
root: "/tmp/openclaw-test",
|
|
channel: "stable",
|
|
configSnapshot: {
|
|
valid: false,
|
|
issues: [],
|
|
legacyIssues: [],
|
|
} as unknown as Awaited<
|
|
ReturnType<typeof import("../../config/io.js").readConfigFileSnapshot>
|
|
>,
|
|
opts: { json: true } as never,
|
|
timeoutMs: 1000,
|
|
});
|
|
expect(result.status).toBe("error");
|
|
expect(result.reason).toBe("invalid-config");
|
|
expect(result.changed).toBe(false);
|
|
expect(result.warnings).toStrictEqual([
|
|
{
|
|
reason: "invalid-config",
|
|
message:
|
|
"Plugin post-update convergence skipped because the config is invalid; refusing to restart the gateway with an unverified plugin set.",
|
|
guidance: [
|
|
"Run `openclaw doctor` to inspect the config validation errors.",
|
|
"Once the config parses, rerun `openclaw update`.",
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("buildInvalidConfigPostCoreUpdateResult", () => {
|
|
it("returns status:error so the existing pre-restart gate exits 1 instead of restarting on invalid config", () => {
|
|
const built = buildInvalidConfigPostCoreUpdateResult();
|
|
expect(built.result.status).toBe("error");
|
|
expect(built.result.reason).toBe("invalid-config");
|
|
expect(built.result.changed).toBe(false);
|
|
});
|
|
|
|
it("surfaces actionable repair guidance in both the structural warnings and the message string", () => {
|
|
const built = buildInvalidConfigPostCoreUpdateResult();
|
|
expect(built.guidance).toStrictEqual([
|
|
"Run `openclaw doctor` to inspect the config validation errors.",
|
|
"Once the config parses, rerun `openclaw update`.",
|
|
]);
|
|
expect(built.result.warnings).toStrictEqual([
|
|
{
|
|
reason: "invalid-config",
|
|
message: built.message,
|
|
guidance: built.guidance,
|
|
},
|
|
]);
|
|
expect(built.message).toBe(
|
|
"Plugin post-update convergence skipped because the config is invalid; refusing to restart the gateway with an unverified plugin set.",
|
|
);
|
|
});
|
|
});
|