openclaw/test/scripts/plugin-update-unchanged-docker.test.ts

510 lines
19 KiB
TypeScript

// Plugin Update Unchanged Docker tests cover plugin update unchanged docker script behavior.
import { execFileSync, spawn, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import { loadInstalledPluginIndex } from "../../src/plugins/installed-plugin-index.js";
import { createInstalledPluginOwnershipResolver } from "../../src/plugins/installed-plugin-package-ownership.js";
import { closeOpenClawStateDatabaseByPath } from "../../src/state/openclaw-state-db-cache.js";
import { openOpenClawStateDatabase } from "../../src/state/openclaw-state-db.js";
const PLUGIN_UPDATE_SCENARIO_SCRIPT = "scripts/e2e/lib/plugin-update/unchanged-scenario.sh";
const CORRUPT_UPDATE_SCENARIO_SCRIPT = "scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh";
const CORRUPT_UPDATE_DOCKER_SCRIPT = "scripts/e2e/update-corrupt-plugin-docker.sh";
const PLUGIN_UPDATE_PROBE_SCRIPT = "scripts/e2e/lib/plugin-update/probe.mjs";
const PLUGIN_UPDATE_REGISTRY_SCRIPT = "scripts/e2e/lib/plugin-update/registry-server.mjs";
const CORRUPT_PLUGIN_ID = "demo-corrupt-plugin";
const PLUGIN_INDEX_MODULE_URL = pathToFileURL(
path.resolve("scripts/e2e/lib/plugin-index-sqlite.mjs"),
).href;
function seedInstallState(root: string, initialized: boolean) {
const stateDir = path.join(root, ".openclaw");
const configPath = path.join(stateDir, "openclaw.json");
const env = {
...process.env,
HOME: root,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_VERSION: "2026.8.1",
VITEST: "true",
};
if (initialized) {
const database = openOpenClawStateDatabase({ env });
closeOpenClawStateDatabaseByPath(database.path);
}
execFileSync("node", [PLUGIN_UPDATE_PROBE_SCRIPT, "seed"], {
encoding: "utf8",
env,
stdio: "pipe",
});
return { configPath, env, stateDir };
}
function runProbe(command: string, payload: unknown): void {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-update-probe-"));
const payloadPath = path.join(root, "payload.json");
try {
writeFileSync(payloadPath, `${JSON.stringify(payload, null, 2)}\n`);
execFileSync("node", [PLUGIN_UPDATE_PROBE_SCRIPT, command, payloadPath, CORRUPT_PLUGIN_ID], {
encoding: "utf8",
stdio: "pipe",
});
} finally {
rmSync(root, { recursive: true, force: true });
}
}
function runProbeStatus(
command: string,
payload: unknown,
): { status: number | null; stderr: string } {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-update-probe-"));
const payloadPath = path.join(root, "payload.json");
try {
writeFileSync(payloadPath, `${JSON.stringify(payload, null, 2)}\n`);
const result = spawnSync(
"node",
[PLUGIN_UPDATE_PROBE_SCRIPT, command, payloadPath, CORRUPT_PLUGIN_ID],
{
encoding: "utf8",
stdio: "pipe",
},
);
return { status: result.status, stderr: result.stderr };
} finally {
rmSync(root, { recursive: true, force: true });
}
}
function corruptPolicyConfig(
allow: unknown,
codexEnabled = false,
corruptEntry = { enabled: false },
) {
return {
plugins: {
allow,
entries: { [CORRUPT_PLUGIN_ID]: corruptEntry, codex: { enabled: codexEnabled } },
},
};
}
function runProbeFileStatus(
command: string,
filePath: string,
): { status: number | null; stderr: string } {
const result = spawnSync("node", [PLUGIN_UPDATE_PROBE_SCRIPT, command, filePath], {
encoding: "utf8",
stdio: "pipe",
});
return { status: result.status, stderr: result.stderr };
}
function runCorruptUpdateDockerBaseline(env: Record<string, string>) {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-corrupt-update-docker-"));
const binDir = path.join(root, "bin");
const dockerArgsPath = path.join(root, "docker-args");
const packagePath = path.join(root, "candidate.tgz");
try {
mkdirSync(binDir);
writeFileSync(packagePath, "fake package");
writeFileSync(
path.join(binDir, "docker"),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "$1" == "run" ]]; then
printf '%s\n' "$@" > "$DOCKER_ARGS_PATH"
fi
`,
{ mode: 0o755 },
);
const result = spawnSync("bash", [CORRUPT_UPDATE_DOCKER_SCRIPT], {
encoding: "utf8",
env: {
...process.env,
DOCKER_ARGS_PATH: dockerArgsPath,
OPENCLAW_CURRENT_PACKAGE_TGZ: packagePath,
OPENCLAW_SKIP_DOCKER_BUILD: "1",
PATH: `${binDir}:${process.env.PATH ?? ""}`,
...env,
},
});
const dockerArgs = existsSync(dockerArgsPath) ? readFileSync(dockerArgsPath, "utf8") : "";
return {
baseline: dockerArgs
.split("\n")
.find((entry) => entry.startsWith("OPENCLAW_UPDATE_CORRUPT_PLUGIN_BASELINE=")),
result,
};
} finally {
rmSync(root, { recursive: true, force: true });
}
}
async function waitForPortFile(portFile: string): Promise<number> {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (existsSync(portFile)) {
const port = Number.parseInt(readFileSync(portFile, "utf8"), 10);
if (Number.isInteger(port) && port > 0) {
return port;
}
}
await delay(50);
}
throw new Error("registry did not write a port file");
}
describe("plugin update unchanged Docker E2E", () => {
it.each([false, true])(
"seeds plugin ownership with initialized state=%s",
async (initialized) => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-update-seed-"));
try {
const { configPath, env, stateDir } = seedInstallState(root, initialized);
const config = JSON.parse(readFileSync(configPath, "utf8")) as {
plugins?: Record<string, unknown>;
};
expect(config).toEqual({ plugins: {} });
expect(
JSON.parse(
execFileSync("node", [PLUGIN_UPDATE_PROBE_SCRIPT, "snapshot"], {
encoding: "utf8",
env,
}),
),
).toMatchObject({ source: "npm", resolvedVersion: "0.9.0" });
const { readPluginInstallIndex } = await import(PLUGIN_INDEX_MODULE_URL);
const persisted = readPluginInstallIndex({ configPath, stateDir });
expect(persisted.installRecords).toMatchObject({
"lossless-claw": {
source: "npm",
installPath: "~/.openclaw/extensions/lossless-claw",
},
});
expect(persisted.plugins).toEqual([
expect.objectContaining({
pluginId: "lossless-claw",
installOwner: "lossless-claw",
rootDir: path.join(stateDir, "extensions", "lossless-claw"),
}),
]);
const database = openOpenClawStateDatabase({ env });
closeOpenClawStateDatabaseByPath(database.path);
const liveIndex = loadInstalledPluginIndex({
config,
env,
stateDir,
});
expect(
createInstalledPluginOwnershipResolver(liveIndex, env).resolvePackage("lossless-claw"),
).toMatchObject({
ok: true,
value: {
installOwner: "lossless-claw",
pluginIds: ["lossless-claw"],
},
});
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
it("bounds the update command and prints diagnostics on hangs", () => {
const script = readFileSync(PLUGIN_UPDATE_SCENARIO_SCRIPT, "utf8");
expect(script).toContain("OPENCLAW_PLUGIN_UPDATE_TIMEOUT_SECONDS");
expect(script).toContain("registry_port_file=/tmp/openclaw-e2e-registry.port");
expect(script).toContain(
'node scripts/e2e/lib/plugin-update/registry-server.mjs "$registry_port_file"',
);
expect(script).toContain(
'export NPM_CONFIG_REGISTRY="http://127.0.0.1:$(cat "$registry_port_file")"',
);
expect(script).toContain('export npm_config_registry="$NPM_CONFIG_REGISTRY"');
expect(script).toContain(
"openclaw_e2e_read_positive_int_env OPENCLAW_PLUGIN_UPDATE_TIMEOUT_SECONDS 180",
);
expect(script).toContain(
'openclaw_e2e_maybe_timeout "${plugin_update_timeout_seconds}s" node "$entry" plugins update',
);
expect(script).not.toContain(
'plugin_update_timeout_seconds="${OPENCLAW_PLUGIN_UPDATE_TIMEOUT_SECONDS:-180}"',
);
expect(script).not.toMatch(
/^\s*timeout "\$\{plugin_update_timeout_seconds\}s" node "\$entry"/mu,
);
expect(script).toContain('"--- plugin update output ---"');
expect(script).toContain('"--- local registry output ---"');
expect(script).toContain("openclaw_e2e_print_log /tmp/plugin-update-output.log");
expect(script).toContain("openclaw_e2e_print_log /tmp/openclaw-e2e-registry.log");
expect(script).not.toContain("cat /tmp/plugin-update-output.log");
expect(script).not.toContain("cat /tmp/openclaw-e2e-registry.log");
});
it("serves plugin metadata from an ephemeral registry port", async () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-update-registry-"));
const portFile = path.join(root, "registry.port");
const child = spawn("node", [PLUGIN_UPDATE_REGISTRY_SCRIPT, portFile], {
stdio: "ignore",
});
try {
const port = await waitForPortFile(portFile);
const response = await fetch(`http://127.0.0.1:${port}/@example%2flossless-claw`);
expect(response.status).toBe(200);
const metadata = (await response.json()) as {
versions?: Record<string, { dist?: { tarball?: string } }>;
};
expect(metadata.versions?.["0.9.0"]?.dist?.tarball).toBe(
`http://127.0.0.1:${port}/@example/lossless-claw/-/lossless-claw-0.9.0.tgz`,
);
} finally {
child.kill("SIGTERM");
rmSync(root, { recursive: true, force: true });
}
});
it("bounds assert-output diagnostics to the saved command log tail", () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-update-probe-"));
const logPath = path.join(root, "plugin-update-output.log");
try {
writeFileSync(
logPath,
`DO_NOT_PRINT_OLD_PLUGIN_UPDATE_LOG\n${"filler line\n".repeat(12 * 1024)}missing marker tail`,
"utf8",
);
const result = runProbeFileStatus("assert-output", logPath);
expect(result.status).toBe(1);
expect(result.stderr).toContain("Expected up-to-date output missing");
expect(result.stderr).toContain("Output tail:");
expect(result.stderr).toContain("missing marker tail");
expect(result.stderr).not.toContain("DO_NOT_PRINT_OLD_PLUGIN_UPDATE_LOG");
expect(result.stderr.length).toBeLessThan(80 * 1024);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("detects unexpected download output before a large log tail", () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-update-probe-"));
const logPath = path.join(root, "plugin-update-output.log");
try {
writeFileSync(
logPath,
[
"Downloading @example/lossless-claw",
"filler line\n".repeat(12 * 1024),
"lossless-claw is up to date (0.9.0).",
].join("\n"),
"utf8",
);
const result = runProbeFileStatus("assert-output", logPath);
expect(result.status).toBe(1);
expect(result.stderr).toContain("Unexpected npm download/reinstall path");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("waits for the local registry process during cleanup", () => {
const script = readFileSync(PLUGIN_UPDATE_SCENARIO_SCRIPT, "utf8");
expect(script).toContain('openclaw_e2e_stop_process "${registry_pid:-}"');
expect(script).not.toContain('kill "$registry_pid"');
});
it("bounds corrupt plugin update commands and prints diagnostics on hangs", () => {
const script = readFileSync(CORRUPT_UPDATE_SCENARIO_SCRIPT, "utf8");
const nonCodexRoute =
'node "$entry" config set agents.defaults.model anthropic/claude-sonnet-4-6 >/dev/null';
const codexOptOut = 'node "$entry" config set plugins.entries.codex.enabled false >/dev/null';
expect(script).toContain('plugins install "npm:@openclaw/demo-corrupt-plugin@0.0.1" --force');
expect(script).toContain("config set plugins.allow '[\"demo-corrupt-plugin\"]'");
expect(script).toContain(nonCodexRoute);
expect(script.indexOf(nonCodexRoute)).toBeLessThan(script.indexOf(codexOptOut));
expect(script).toContain("OPENCLAW_UPDATE_CORRUPT_PLUGIN_TIMEOUT_SECONDS");
expect(script).toContain(
"openclaw_e2e_read_positive_int_env OPENCLAW_UPDATE_CORRUPT_PLUGIN_TIMEOUT_SECONDS 900",
);
expect(script).toContain("OPENCLAW_UPDATE_CORRUPT_PLUGIN_STEP_TIMEOUT_SECONDS");
expect(script).toContain(
"default_update_step_timeout_seconds=$((10#$update_timeout_seconds - 30))",
);
expect(script).not.toContain(
'update_timeout_seconds="${OPENCLAW_UPDATE_CORRUPT_PLUGIN_TIMEOUT_SECONDS:-900}"',
);
expect(
script.match(/openclaw_e2e_maybe_timeout "\$\{update_timeout_seconds\}s" \\/gu)?.length,
).toBe(1);
expect(script).toContain("--channel beta");
expect(script.match(/--timeout "\$update_step_timeout_seconds"/g)).toHaveLength(1);
expect(script).not.toContain("OPENCLAW_UPDATE_POST_CORE=1");
expect(script).not.toContain(
'node "$entry" update --channel beta --tag "${OPENCLAW_CURRENT_PACKAGE_TGZ',
);
expect(script).toContain(
'OPENCLAW_NPM_REGISTRY_UPSTREAM="${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_URL:-https://registry.npmjs.org/}"',
);
expect(script).toContain(
"openclaw update failed or timed out after ${update_timeout_seconds}s",
);
expect(script.match(/openclaw_e2e_print_log \/tmp\/openclaw-update-corrupt-/g)).toHaveLength(5);
expect(script).not.toContain("cat /tmp/openclaw-update-corrupt-");
});
it.each(["2026.9.2", "2026.8.2"])(
"keeps a historical %s override out of the same-schema repair lane",
(version) => {
const result = runCorruptUpdateDockerBaseline({
OPENCLAW_UPDATE_CORRUPT_PLUGIN_BASELINE: `openclaw@${version}`,
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: `openclaw@${version}`,
});
expect(result.result.status, result.result.stderr).toBe(0);
expect(result.baseline).toBeUndefined();
},
);
it.each([
["explicit disable", { enabled: false }, [CORRUPT_PLUGIN_ID]],
["typed quarantine", { enabled: true }, [CORRUPT_PLUGIN_ID]],
["target-owned additions", { enabled: false }, [CORRUPT_PLUGIN_ID, "memory-core", "codex"]],
])("preserves the explicit allow policy after %s recovery", (_recovery, entry, allow) => {
expect(() =>
runProbe("assert-corrupt-policy-preserved", corruptPolicyConfig(allow, false, entry)),
).not.toThrow();
});
it.each([
["non-array allow policy", CORRUPT_PLUGIN_ID, false, "plugins.allow to be an array"],
["missing fixture membership", ["memory-core"], false, "exactly once"],
["duplicate fixture membership", [CORRUPT_PLUGIN_ID, CORRUPT_PLUGIN_ID], false, "exactly once"],
[
"loss of the Codex opt-out",
[CORRUPT_PLUGIN_ID],
true,
"explicit Codex opt-out to survive, got true",
],
])("rejects corrupt update recovery with %s", (_case, allow, codexEnabled, expectedError) => {
const result = runProbeStatus(
"assert-corrupt-policy-preserved",
corruptPolicyConfig(allow, codexEnabled),
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(expectedError);
});
it.each([
"warning",
"core failure",
"core skipped",
"missing warning",
"wrong plugin",
"missing guidance",
"unsafe recovery",
] as const)(
"requires a successful core update and named unavailable-plugin notice: %s",
(outcome) => {
const warnedPluginId = outcome === "wrong plugin" ? "another-plugin" : CORRUPT_PLUGIN_ID;
const result = runProbeStatus("assert-corrupt-unavailable", {
status:
outcome === "core failure" ? "error" : outcome === "core skipped" ? "skipped" : "ok",
...(outcome === "unsafe recovery" ? { recovery: { serviceRestartSafe: false } } : {}),
postUpdate: {
plugins: {
status: "warning",
warnings:
outcome === "missing warning"
? []
: [
{
pluginId: warnedPluginId,
reason: "package.json is missing",
message: `Plugin "${warnedPluginId}" could not be loaded. Run \`openclaw doctor --fix\` to check and repair the load problem.`,
guidance: outcome === "missing guidance" ? [] : ["openclaw doctor --fix"],
},
],
},
},
});
expect(result.status).toBe(outcome === "warning" ? 0 : 1);
if (outcome !== "warning") {
expect(result.stderr).toContain(
"expected successful core update with a named plugin repair notice",
);
}
},
);
it.each(["clean", "updated", "unchanged", "repaired after error", "unrelated warning"])(
"accepts completed corrupt plugin repair: %s",
(outcome) => {
expect(() =>
runProbe("assert-corrupt-plugin-result", {
status: outcome === "unrelated warning" ? "warning" : "ok",
npm: {
outcomes:
outcome === "clean"
? []
: [
...(outcome === "repaired after error"
? [{ pluginId: CORRUPT_PLUGIN_ID, status: "error" }]
: []),
{
pluginId: CORRUPT_PLUGIN_ID,
status: outcome === "unchanged" ? "unchanged" : "updated",
},
],
},
warnings:
outcome === "unrelated warning"
? [{ pluginId: "another-plugin", message: "Retry another plugin." }]
: [],
}),
).not.toThrow();
},
);
it.each(["disabled", "quarantined", "still missing"])(
"rejects unresolved corrupt plugin repair: %s",
(outcome) => {
const result = runProbeStatus("assert-corrupt-plugin-result", {
status: "warning",
npm: {
outcomes: [
{
pluginId: CORRUPT_PLUGIN_ID,
status:
outcome === "disabled"
? "skipped"
: outcome === "quarantined"
? "error"
: "updated",
},
],
},
warnings:
outcome === "still missing"
? [{ pluginId: CORRUPT_PLUGIN_ID, reason: "package.json is missing" }]
: [],
});
expect(result.status).toBe(1);
expect(result.stderr).toContain(
`expected ${CORRUPT_PLUGIN_ID} restored without unresolved plugin errors or warnings`,
);
},
);
});