diff --git a/scripts/e2e/lib/plugin-update/probe.mjs b/scripts/e2e/lib/plugin-update/probe.mjs index 11e001be35b..4876ca1b8d4 100644 --- a/scripts/e2e/lib/plugin-update/probe.mjs +++ b/scripts/e2e/lib/plugin-update/probe.mjs @@ -132,9 +132,6 @@ function assertCorruptPluginResult(pluginJsonPath, pluginId) { function assertCorruptPluginTolerated(plugins, pluginId) { const evidence = collectPluginEvidence(plugins, pluginId); if (plugins.status === "ok") { - if (isCorruptPluginDisabledAfterUpdate(evidence, pluginId)) { - return; - } assertCorruptPluginCleanOrRepaired(evidence); return; } @@ -174,9 +171,10 @@ function assertCorruptPluginCleanOrRepaired(evidence) { function assertCorruptPluginDetails(plugins, pluginId) { const evidence = collectPluginEvidence(plugins, pluginId); const outcome = evidence.outcome; + const disabledAfterFailure = isCorruptPluginDisabledAfterUpdate(evidence, pluginId); if ( !outcome || - (outcome.status !== "error" && !isCorruptPluginDisabledAfterUpdate(evidence, pluginId)) + (outcome.status !== "error" && !disabledAfterFailure) ) { throw new Error( `expected error or disabled-after-failure outcome for ${pluginId}, got ${JSON.stringify({ @@ -187,21 +185,28 @@ function assertCorruptPluginDetails(plugins, pluginId) { })}`, ); } - if (isCorruptPluginDisabledAfterUpdate(evidence, pluginId)) { - return; - } const warning = evidence.warning; if (!warning) { throw new Error( `expected warning for ${pluginId}, got ${JSON.stringify(plugins.warnings ?? [])}`, ); } - const text = JSON.stringify({ outcome, warning }); - for (const expected of [ - "package.json is missing", - "Run openclaw doctor --fix to attempt automatic repair.", - `Run openclaw plugins inspect ${pluginId} --runtime --json for details.`, - ]) { + const text = [outcome.message, warning.reason, warning.message, ...(warning.guidance ?? [])] + .filter(Boolean) + .join(" "); + const expectedFragments = disabledAfterFailure + ? [ + `Disabled "${pluginId}" after plugin update failure`, + "OpenClaw will continue without it", + "Run openclaw doctor --fix to attempt automatic repair.", + `Run openclaw plugins inspect ${pluginId} --runtime --json for details.`, + ] + : [ + "package.json is missing", + "Run openclaw doctor --fix to attempt automatic repair.", + `Run openclaw plugins inspect ${pluginId} --runtime --json for details.`, + ]; + for (const expected of expectedFragments) { if (!text.includes(expected)) { throw new Error(`expected update output to include ${expected}: ${text}`); } diff --git a/test/scripts/plugin-update-unchanged-docker.test.ts b/test/scripts/plugin-update-unchanged-docker.test.ts index b3ffeb5826f..4d5f3566ed4 100644 --- a/test/scripts/plugin-update-unchanged-docker.test.ts +++ b/test/scripts/plugin-update-unchanged-docker.test.ts @@ -1,9 +1,49 @@ -import { readFileSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; const PLUGIN_UPDATE_DOCKER_SCRIPT = "scripts/e2e/plugin-update-unchanged-docker.sh"; const PLUGIN_UPDATE_SCENARIO_SCRIPT = "scripts/e2e/lib/plugin-update/unchanged-scenario.sh"; const PLUGIN_UPDATE_PROBE_SCRIPT = "scripts/e2e/lib/plugin-update/probe.mjs"; +const CORRUPT_PLUGIN_ID = "demo-corrupt-plugin"; + +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 }); + } +} describe("plugin update unchanged Docker E2E", () => { it("seeds current plugin install ledger state before checking config stability", () => { @@ -31,4 +71,41 @@ describe("plugin update unchanged Docker E2E", () => { expect(script).toContain('"--- plugin update output ---"'); expect(script).toContain('"--- local registry output ---"'); }); + + it("requires disabled-after-failure corrupt plugin updates to stay warnings", () => { + const disabledAfterFailure = { + status: "ok", + npm: { + outcomes: [ + { + pluginId: CORRUPT_PLUGIN_ID, + status: "skipped", + message: + `Disabled "${CORRUPT_PLUGIN_ID}" after plugin update failure; OpenClaw will continue without it. Failed to update ${CORRUPT_PLUGIN_ID}: registry timeout`, + }, + ], + }, + }; + + const acceptedOkResult = runProbeStatus("assert-corrupt-plugin-result", disabledAfterFailure); + + expect(acceptedOkResult.status).not.toBe(0); + expect(acceptedOkResult.stderr).toContain("expected clean or repaired corrupt plugin state"); + expect(() => + runProbe("assert-corrupt-plugin-result", { + ...disabledAfterFailure, + status: "warning", + warnings: [ + { + pluginId: CORRUPT_PLUGIN_ID, + message: + `Plugin "${CORRUPT_PLUGIN_ID}" could not be processed after the core update: ` + + disabledAfterFailure.npm.outcomes[0].message + + " Run openclaw doctor --fix to attempt automatic repair. " + + `Run openclaw plugins inspect ${CORRUPT_PLUGIN_ID} --runtime --json for details.`, + }, + ], + }), + ).not.toThrow(); + }); });