diff --git a/scripts/e2e/lib/upgrade-survivor/assertions.mjs b/scripts/e2e/lib/upgrade-survivor/assertions.mjs index 72a60513fb9d..921f959d913a 100644 --- a/scripts/e2e/lib/upgrade-survivor/assertions.mjs +++ b/scripts/e2e/lib/upgrade-survivor/assertions.mjs @@ -691,7 +691,9 @@ function assertStatusJson([file]) { assert(/running|connected|ok|ready/u.test(text), "gateway status did not report a healthy state"); } -if (command === "seed") { +if (command === "list-scenarios") { + process.stdout.write(`${JSON.stringify([...SCENARIOS])}\n`); +} else if (command === "seed") { seedState(); } else if (command === "assert-config") { assertConfigSurvived(); diff --git a/scripts/lib/docker-e2e-plan.d.mts b/scripts/lib/docker-e2e-plan.d.mts index f37be045a777..60ceabe57710 100644 --- a/scripts/lib/docker-e2e-plan.d.mts +++ b/scripts/lib/docker-e2e-plan.d.mts @@ -18,6 +18,7 @@ export type DockerE2ePlanLane = { }; export type DockerE2ePlanOptions = { + allowFrozenTargetScenarioOmissions?: boolean; includeOpenWebUI: boolean; liveMode: "all" | "only" | "skip"; liveRetries: number; diff --git a/scripts/lib/docker-e2e-plan.mjs b/scripts/lib/docker-e2e-plan.mjs index 0f4959805778..2694cf698e5e 100644 --- a/scripts/lib/docker-e2e-plan.mjs +++ b/scripts/lib/docker-e2e-plan.mjs @@ -1,9 +1,10 @@ // Docker E2E scheduler planning helpers. // This module turns the scenario catalog plus env-driven inputs into a concrete // lane plan. It intentionally does not define scenario commands. +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; -import ts from "typescript"; import { BUNDLED_PLUGIN_INSTALL_UNINSTALL_SHARDS, DEFAULT_LIVE_RETRIES, @@ -94,7 +95,111 @@ const UPGRADE_SURVIVOR_SCENARIO_ALIASES = new Map([ ["far-reaching", UPGRADE_SURVIVOR_SCENARIOS], ]); -function filterUpgradeSurvivorScenariosForTarget(scenarios, targetRoot) { +// Pre-protocol catalogs are content-addressed. Unknown legacy blocks fail +// closed instead of requiring a dependency or reimplementing a JavaScript parser. +const LEGACY_UPGRADE_SURVIVOR_SCENARIO_CATALOGS = new Map([ + [ + "755557a6ea609e5b9d9fe9d61beb7e75651641e26a3f0ef2fe1fc3a973b398c8", + "base feishu-channel bootstrap-persona channel-post-core-restore plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path versioned-runtime-deps", + ], + [ + "2657b5cc7b94cf46b9900f9259ee4cef0033837ee71a673e6ed09e7ee36684e8", + "base feishu-channel bootstrap-persona tilde-log-path versioned-runtime-deps", + ], + [ + "0fefb170b4131932c7403f7f0dcdd1371e300cdcc1c033b9bd2bd6513e08d5e4", + "base feishu-channel bootstrap-persona plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path versioned-runtime-deps", + ], + [ + "5a2c3a6e3d2a7166025333d4f1a77de0576847f9da7e902055160d0e5f20d4c5", + "base feishu-channel bootstrap-persona plugin-deps-cleanup configured-plugin-installs tilde-log-path versioned-runtime-deps", + ], + [ + "f226c05636dfb4e759558b5127d1c684d28a609292f4e110ff656c0e4f95ad06", + "base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path versioned-runtime-deps", + ], + [ + "d9c9edcb27aca88a0b11c72e85592001cba732cf0f96bb8a73c1c0243c8f3678", + "base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path versioned-runtime-deps", + ], + [ + "8ac0113158bfe1ebde77272fb1ffb740c281378a170fbc1e9e281d4e44677f02", + "base feishu-channel bootstrap-persona plugin-deps-cleanup tilde-log-path versioned-runtime-deps", + ], +]); + +function readLegacyFrozenScenarioContract(assertionsFile) { + const source = readFileSync(assertionsFile, "utf8"); + const startMarker = "const SCENARIOS = new Set(["; + const start = source.indexOf(startMarker); + if (start < 0 || source.lastIndexOf(startMarker) !== start) { + return undefined; + } + const end = source.indexOf("\n]);", start + startMarker.length); + if (end < 0) { + return undefined; + } + const block = source.slice(start, end + 4); + const digest = createHash("sha256").update(block).digest("hex"); + return LEGACY_UPGRADE_SURVIVOR_SCENARIO_CATALOGS.get(digest)?.split(" "); +} + +function readFrozenScenarioContract(assertionsFile, targetRoot, allowExecutableContract) { + if (!allowExecutableContract) { + const inertScenarios = readLegacyFrozenScenarioContract(assertionsFile); + if (inertScenarios) { + return inertScenarios; + } + throw new Error( + `cannot read frozen upgrade-survivor scenarios from ${assertionsFile}: unrecognized scenario contract; require trusted workflow opt-in before executing target code`, + ); + } + // Canonical frozen refs may expose a dependency-free catalog command. Run it + // only after the release workflow explicitly establishes the trust boundary. + const result = spawnSync(process.execPath, [assertionsFile, "list-scenarios"], { + cwd: targetRoot, + encoding: "utf8", + }); + if (result.status !== 0) { + const errorLines = result.stderr.trim().split("\n"); + if (result.stderr.includes("unknown upgrade-survivor assertion command: list-scenarios")) { + const legacyScenarios = readLegacyFrozenScenarioContract(assertionsFile); + if (legacyScenarios) { + return legacyScenarios; + } + } + const detail = + errorLines.find((line) => line.startsWith("Error:")) || + errorLines.at(-1) || + "scenario command failed"; + throw new Error( + `cannot read frozen upgrade-survivor scenarios from ${assertionsFile}: ${detail}`, + ); + } + let parsed; + try { + parsed = JSON.parse(result.stdout); + } catch { + throw new Error( + `cannot read frozen upgrade-survivor scenarios from ${assertionsFile}: list-scenarios did not return JSON`, + ); + } + if ( + !Array.isArray(parsed) || + parsed.length === 0 || + parsed.some( + (scenario) => typeof scenario !== "string" || !/^[a-z0-9][a-z0-9-]*$/u.test(scenario), + ) || + new Set(parsed).size !== parsed.length + ) { + throw new Error( + `cannot read frozen upgrade-survivor scenarios from ${assertionsFile}: list-scenarios returned an invalid catalog`, + ); + } + return parsed; +} + +function filterUpgradeSurvivorScenariosForTarget(scenarios, targetRoot, allowExecutableContract) { if (!targetRoot) { return scenarios; } @@ -102,54 +207,12 @@ function filterUpgradeSurvivorScenariosForTarget(scenarios, targetRoot) { if (!existsSync(assertionsFile)) { return []; } - const assertionsSource = readFileSync(assertionsFile, "utf8"); - const sourceFile = ts.createSourceFile( + const targetScenarios = readFrozenScenarioContract( assertionsFile, - assertionsSource, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.JS, + targetRoot, + allowExecutableContract, ); - const parseDiagnostic = sourceFile.parseDiagnostics[0]; - if (parseDiagnostic) { - throw new Error( - `cannot parse frozen upgrade-survivor scenarios from ${assertionsFile}: ${ts.flattenDiagnosticMessageText(parseDiagnostic.messageText, "\n")}`, - ); - } - let scenarioDeclaration; - for (const statement of sourceFile.statements) { - if ( - !ts.isVariableStatement(statement) || - (statement.declarationList.flags & ts.NodeFlags.Const) === 0 - ) { - continue; - } - scenarioDeclaration = statement.declarationList.declarations.find( - (declaration) => ts.isIdentifier(declaration.name) && declaration.name.text === "SCENARIOS", - ); - if (scenarioDeclaration) { - break; - } - } - const initializer = scenarioDeclaration?.initializer; - const scenarioArray = - initializer && - ts.isNewExpression(initializer) && - ts.isIdentifier(initializer.expression) && - initializer.expression.text === "Set" && - initializer.arguments?.length === 1 && - ts.isArrayLiteralExpression(initializer.arguments[0]) - ? initializer.arguments[0] - : undefined; - if ( - !scenarioArray || - scenarioArray.elements.some((element) => !ts.isStringLiteralLike(element)) - ) { - throw new Error( - `cannot read frozen upgrade-survivor scenarios from ${assertionsFile}: expected const SCENARIOS = new Set([])`, - ); - } - const supportedScenarios = new Set(scenarioArray.elements.map((element) => element.text)); + const supportedScenarios = new Set(targetScenarios); return scenarios.filter((scenario) => supportedScenarios.has(scenario)); } @@ -280,6 +343,7 @@ function expandUpgradeSurvivorBaselineLanes( rawBaselineSpecs, targetRoot, rawScenarios = "", + allowExecutableContract = false, ) { const hasUpgradeSurvivorLane = poolLanes.some( (poolLane) => @@ -297,6 +361,7 @@ function expandUpgradeSurvivorBaselineLanes( const supportedScenarios = filterUpgradeSurvivorScenariosForTarget( requestedScenarios, targetRoot, + allowExecutableContract, ); const supportedScenarioSet = new Set(supportedScenarios); const unsupportedScenarios = targetRoot @@ -556,6 +621,7 @@ export function resolveDockerE2ePlan(options) { upgradeSurvivorBaselines, options.upgradeSurvivorTargetRoot, upgradeSurvivorScenarios, + options.allowFrozenTargetScenarioOmissions, ); for (const laneName of expansion.omittedLaneNames) { omittedUnsupportedLaneNames.add(laneName); @@ -612,6 +678,7 @@ export function resolveDockerE2ePlan(options) { upgradeSurvivorBaselines, options.upgradeSurvivorTargetRoot, upgradeSurvivorScenarios, + options.allowFrozenTargetScenarioOmissions, ); const supportedLane = targetExpansion.lanes.find( (poolLane) => poolLane.name === selectedName, diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index dc0869ae6c35..0073d3e35708 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -1530,6 +1530,7 @@ async function main() { upgradeSurvivorBaselines: process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS, upgradeSurvivorScenarios: process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS, upgradeSurvivorTargetRoot: process.env.OPENCLAW_UPGRADE_SURVIVOR_TARGET_ROOT, + allowFrozenTargetScenarioOmissions, }); if (omittedUnsupportedLaneNames.length > 0 && !allowFrozenTargetScenarioOmissions) { throw new Error( diff --git a/test/scripts/docker-all-scheduler.test.ts b/test/scripts/docker-all-scheduler.test.ts index ea61c29a72a0..40b8b77b49ba 100644 --- a/test/scripts/docker-all-scheduler.test.ts +++ b/test/scripts/docker-all-scheduler.test.ts @@ -2,6 +2,7 @@ import { spawn, spawnSync } from "node:child_process"; import { chmodSync, + copyFileSync, existsSync, mkdirSync, mkdtempSync, @@ -47,6 +48,16 @@ const { createTempDir } = createScriptTestHarness(); const tempDirs = useAutoCleanupTempDirTracker(afterEach); const LIVE_E2E_WORKFLOW = ".github/workflows/openclaw-live-and-e2e-checks-reusable.yml"; +function writeFrozenScenarioContract(root: string, scenarios: string[]): string { + const assertionsFile = path.join(root, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); + mkdirSync(path.dirname(assertionsFile), { recursive: true }); + writeFileSync( + assertionsFile, + `process.stdout.write(${JSON.stringify(`${JSON.stringify(scenarios)}\n`)});\n`, + ); + return assertionsFile; +} + function expectDeclaredDispatchInputs(command: string): void { const workflow = parse(readFileSync(LIVE_E2E_WORKFLOW, "utf8")) as { on?: { workflow_dispatch?: { inputs?: Record } }; @@ -152,6 +163,35 @@ describe("scripts/test-docker-all scheduler", () => { expect(result.stderr).not.toContain("at "); }); + it("plans from an isolated release harness without installed dependencies", () => { + const root = tempDirs.make("openclaw-docker-plan-isolated-harness-"); + const scriptsDir = path.join(root, "scripts"); + const libDir = path.join(scriptsDir, "lib"); + mkdirSync(libDir, { recursive: true }); + copyFileSync("scripts/test-docker-all.mjs", path.join(scriptsDir, "test-docker-all.mjs")); + for (const fileName of ["docker-e2e-plan.mjs", "docker-e2e-scenarios.mjs", "sleep.mjs"]) { + copyFileSync(path.join("scripts/lib", fileName), path.join(libDir, fileName)); + } + + const result = spawnSync( + process.execPath, + [path.join(scriptsDir, "test-docker-all.mjs"), "--plan-json"], + { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + OPENCLAW_DOCKER_ALL_PLAN_RELEASE_ALL: "1", + OPENCLAW_DOCKER_ALL_PROFILE: "release-path", + OPENCLAW_UPGRADE_SURVIVOR_TARGET_ROOT: process.cwd(), + }, + }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ profile: "release-path" }); + }); + it("rejects loose numeric runner env vars without a stack trace", () => { const result = spawnSync(process.execPath, ["scripts/test-docker-all.mjs", "--plan-json"], { cwd: process.cwd(), @@ -305,10 +345,17 @@ describe("scripts/test-docker-all scheduler", () => { it("rejects candidate-controlled survivor omissions without trusted opt-in", () => { const root = tempDirs.make("openclaw-docker-all-untrusted-filter-"); - const assertionsFile = path.join(root, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); try { - mkdirSync(path.dirname(assertionsFile), { recursive: true }); - writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n'); + const assertionsFile = writeFrozenScenarioContract(root, ["unrelated"]); + const executionMarker = path.join(root, "candidate-contract-executed"); + writeFileSync( + assertionsFile, + [ + 'import { writeFileSync } from "node:fs";', + `writeFileSync(${JSON.stringify(executionMarker)}, "executed");`, + 'process.stdout.write("[\\"unrelated\\"]\\n");', + ].join("\n"), + ); const result = spawnSync(process.execPath, ["scripts/test-docker-all.mjs"], { cwd: process.cwd(), encoding: "utf8", @@ -325,6 +372,7 @@ describe("scripts/test-docker-all scheduler", () => { expect(result.status).toBe(1); expect(result.stderr).toContain("require trusted workflow opt-in"); expect(result.stdout).not.toContain("Dry run complete"); + expect(existsSync(executionMarker)).toBe(false); } finally { rmSync(root, { force: true, recursive: true }); } @@ -333,10 +381,8 @@ describe("scripts/test-docker-all scheduler", () => { it("writes a passing summary when a frozen target cannot run selected survivor lanes", () => { const root = tempDirs.make("openclaw-docker-all-filtered-"); const logDir = path.join(root, "logs"); - const assertionsFile = path.join(root, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); try { - mkdirSync(path.dirname(assertionsFile), { recursive: true }); - writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n'); + writeFrozenScenarioContract(root, ["unrelated"]); const result = spawnSync(process.execPath, ["scripts/test-docker-all.mjs"], { cwd: process.cwd(), encoding: "utf8", @@ -369,10 +415,8 @@ describe("scripts/test-docker-all scheduler", () => { it("reports omitted frozen-target lanes when another selected lane remains runnable", () => { const root = tempDirs.make("openclaw-docker-all-mixed-filtered-"); - const assertionsFile = path.join(root, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); try { - mkdirSync(path.dirname(assertionsFile), { recursive: true }); - writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n'); + writeFrozenScenarioContract(root, ["unrelated"]); const result = spawnSync(process.execPath, ["scripts/test-docker-all.mjs"], { cwd: process.cwd(), encoding: "utf8", diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index 4b2c4db0dce7..6adaa9a41628 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -22,10 +22,25 @@ const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as { scripts?: Record; }; +function writeFrozenScenarioContract(targetRoot: string, scenarios: string[]): string { + const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); + mkdirSync(dirname(assertionsFile), { recursive: true }); + writeFileSync( + assertionsFile, + [ + `const scenarios = ${JSON.stringify(scenarios)};`, + 'if (process.argv[2] !== "list-scenarios") throw new Error("unknown command");', + "process.stdout.write(`${JSON.stringify(scenarios)}\\n`);", + ].join("\n"), + ); + return assertionsFile; +} + function planFor( overrides: Partial[0]> = {}, ): ReturnType["plan"] { return resolveDockerE2ePlan({ + allowFrozenTargetScenarioOmissions: true, includeOpenWebUI: false, liveMode: "all", liveRetries: DEFAULT_LIVE_RETRIES, @@ -772,24 +787,17 @@ describe("scripts/lib/docker-e2e-plan", () => { it("omits trusted-current scenarios unsupported by a frozen target harness", () => { const targetRoot = tempDirs.make("openclaw-frozen-upgrade-harness-"); - const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); - mkdirSync(dirname(assertionsFile), { recursive: true }); - writeFileSync( - assertionsFile, - `const SCENARIOS = new Set([\n${[ - "base", - "feishu-channel", - "bootstrap-persona", - "channel-post-core-restore", - "plugin-deps-cleanup", - "configured-plugin-installs", - "stale-source-plugin-shadow", - "tilde-log-path", - "versioned-runtime-deps", - ] - .map((scenario) => `'${scenario}'`) - .join(",\n")}\n]);\nconst unrelated = "acpx-openclaw-tools-bridge";\n`, - ); + writeFrozenScenarioContract(targetRoot, [ + "base", + "feishu-channel", + "bootstrap-persona", + "channel-post-core-restore", + "plugin-deps-cleanup", + "configured-plugin-installs", + "stale-source-plugin-shadow", + "tilde-log-path", + "versioned-runtime-deps", + ]); const plan = planFor({ selectedLaneNames: ["published-upgrade-survivor"], upgradeSurvivorBaselines: "2026.6.11", @@ -813,11 +821,46 @@ describe("scripts/lib/docker-e2e-plan", () => { ]); }); + it("reads content-addressed scenario catalogs from pre-command frozen targets", () => { + const targetRoot = tempDirs.make("openclaw-legacy-frozen-upgrade-harness-"); + const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); + const legacyScenarios = [ + "base", + "feishu-channel", + "bootstrap-persona", + "channel-post-core-restore", + "plugin-deps-cleanup", + "configured-plugin-installs", + "stale-source-plugin-shadow", + "tilde-log-path", + "versioned-runtime-deps", + ]; + mkdirSync(dirname(assertionsFile), { recursive: true }); + writeFileSync( + assertionsFile, + [ + "const SCENARIOS = new Set([", + ...legacyScenarios.map((scenario) => ` "${scenario}",`), + "]);", + 'throw new Error("unknown upgrade-survivor assertion command: list-scenarios");', + ].join("\n"), + ); + + const plan = planFor({ + selectedLaneNames: ["published-upgrade-survivor"], + upgradeSurvivorBaselines: "2026.6.11", + upgradeSurvivorScenarios: "reported-issues", + upgradeSurvivorTargetRoot: targetRoot, + }); + + expect(plan.omittedUnsupportedLanes).toEqual([ + "published-upgrade-survivor-2026.6.11-acpx-openclaw-tools-bridge", + ]); + }); + it("omits survivor lanes when the target exposes none of the requested scenarios", () => { const targetRoot = tempDirs.make("openclaw-frozen-empty-upgrade-harness-"); - const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); - mkdirSync(dirname(assertionsFile), { recursive: true }); - writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n'); + writeFrozenScenarioContract(targetRoot, ["unrelated"]); const plan = planFor({ selectedLaneNames: ["published-upgrade-survivor"], @@ -836,9 +879,7 @@ describe("scripts/lib/docker-e2e-plan", () => { it("omits baseline-only survivor lanes when the target lacks the implicit base scenario", () => { const targetRoot = tempDirs.make("openclaw-frozen-no-base-upgrade-harness-"); - const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); - mkdirSync(dirname(assertionsFile), { recursive: true }); - writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n'); + writeFrozenScenarioContract(targetRoot, ["unrelated"]); const plan = planFor({ selectedLaneNames: ["published-upgrade-survivor"], @@ -852,9 +893,7 @@ describe("scripts/lib/docker-e2e-plan", () => { it("omits an unconfigured survivor lane when the target lacks the implicit base scenario", () => { const targetRoot = tempDirs.make("openclaw-frozen-default-base-harness-"); - const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); - mkdirSync(dirname(assertionsFile), { recursive: true }); - writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n'); + writeFrozenScenarioContract(targetRoot, ["unrelated"]); const plan = planFor({ selectedLaneNames: ["published-upgrade-survivor"], @@ -867,9 +906,7 @@ describe("scripts/lib/docker-e2e-plan", () => { it("reports an unsupported survivor lane beside runnable selected lanes", () => { const targetRoot = tempDirs.make("openclaw-frozen-mixed-upgrade-harness-"); - const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); - mkdirSync(dirname(assertionsFile), { recursive: true }); - writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n'); + writeFrozenScenarioContract(targetRoot, ["unrelated"]); const plan = planFor({ selectedLaneNames: ["published-upgrade-survivor", "plugin-binding-command-escape"], @@ -887,9 +924,7 @@ describe("scripts/lib/docker-e2e-plan", () => { it("reports an explicitly selected expanded survivor lane as unsupported", () => { const targetRoot = tempDirs.make("openclaw-frozen-expanded-upgrade-harness-"); - const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); - mkdirSync(dirname(assertionsFile), { recursive: true }); - writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n'); + writeFrozenScenarioContract(targetRoot, ["unrelated"]); const selectedLane = "published-upgrade-survivor-2026.6.11"; const plan = planFor({ @@ -904,9 +939,7 @@ describe("scripts/lib/docker-e2e-plan", () => { it("omits unsupported scenario-only survivor lanes without explicit baselines", () => { const targetRoot = tempDirs.make("openclaw-frozen-scenario-only-harness-"); - const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); - mkdirSync(dirname(assertionsFile), { recursive: true }); - writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n'); + writeFrozenScenarioContract(targetRoot, ["unrelated"]); const plan = planFor({ selectedLaneNames: ["published-upgrade-survivor"], @@ -919,9 +952,7 @@ describe("scripts/lib/docker-e2e-plan", () => { it("does not fall back to base when an unsupported scenario is baseline-incompatible", () => { const targetRoot = tempDirs.make("openclaw-frozen-incompatible-scenario-harness-"); - const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); - mkdirSync(dirname(assertionsFile), { recursive: true }); - writeFileSync(assertionsFile, 'const SCENARIOS = new Set(["unrelated"]);\n'); + writeFrozenScenarioContract(targetRoot, ["unrelated"]); const plan = planFor({ selectedLaneNames: ["published-upgrade-survivor"], @@ -934,13 +965,16 @@ describe("scripts/lib/docker-e2e-plan", () => { expect(plan.omittedUnsupportedLanes).toEqual([]); }); - it("fails closed when a frozen target scenario contract is not a literal set", () => { - const targetRoot = tempDirs.make("openclaw-frozen-indirect-scenario-harness-"); + it("fails closed when an unknown legacy scenario catalog lacks the command", () => { + const targetRoot = tempDirs.make("openclaw-frozen-failed-scenario-harness-"); const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); mkdirSync(dirname(assertionsFile), { recursive: true }); writeFileSync( assertionsFile, - 'const supported = ["base"];\nconst SCENARIOS = new Set(supported);\n', + [ + 'const SCENARIOS = new Set(["base"]);', + 'throw new Error("unknown upgrade-survivor assertion command: list-scenarios");', + ].join("\n"), ); expect(() => @@ -949,17 +983,44 @@ describe("scripts/lib/docker-e2e-plan", () => { upgradeSurvivorScenarios: "reported-issues", upgradeSurvivorTargetRoot: targetRoot, }), - ).toThrow("expected const SCENARIOS = new Set([])"); + ).toThrow("unknown upgrade-survivor assertion command: list-scenarios"); + }); + + it("fails closed when a frozen target scenario command returns non-JSON output", () => { + const targetRoot = tempDirs.make("openclaw-frozen-non-json-scenario-harness-"); + const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); + mkdirSync(dirname(assertionsFile), { recursive: true }); + writeFileSync(assertionsFile, 'process.stdout.write("base");\n'); + + expect(() => + planFor({ + selectedLaneNames: ["published-upgrade-survivor"], + upgradeSurvivorScenarios: "reported-issues", + upgradeSurvivorTargetRoot: targetRoot, + }), + ).toThrow("list-scenarios did not return JSON"); + }); + + it("fails closed when a frozen target scenario command returns an invalid catalog", () => { + const targetRoot = tempDirs.make("openclaw-frozen-invalid-scenario-harness-"); + const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); + mkdirSync(dirname(assertionsFile), { recursive: true }); + writeFileSync(assertionsFile, 'process.stdout.write("[\\"base\\",\\"base\\"]");\n'); + + expect(() => + planFor({ + selectedLaneNames: ["published-upgrade-survivor"], + upgradeSurvivorScenarios: "reported-issues", + upgradeSurvivorTargetRoot: targetRoot, + }), + ).toThrow("list-scenarios returned an invalid catalog"); }); it("does not inspect a frozen survivor contract for unrelated selected lanes", () => { const targetRoot = tempDirs.make("openclaw-frozen-unrelated-lane-harness-"); const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); mkdirSync(dirname(assertionsFile), { recursive: true }); - writeFileSync( - assertionsFile, - 'const supported = ["base"];\nconst SCENARIOS = new Set(supported);\n', - ); + writeFileSync(assertionsFile, 'throw new Error("must not run");\n'); const plan = planFor({ selectedLaneNames: ["plugin-binding-command-escape"], @@ -971,21 +1032,6 @@ describe("scripts/lib/docker-e2e-plan", () => { expect(plan.omittedUnsupportedLanes).toEqual([]); }); - it("fails closed when a frozen target scenario contract is mutable", () => { - const targetRoot = tempDirs.make("openclaw-frozen-mutable-scenario-harness-"); - const assertionsFile = join(targetRoot, "scripts/e2e/lib/upgrade-survivor/assertions.mjs"); - mkdirSync(dirname(assertionsFile), { recursive: true }); - writeFileSync(assertionsFile, 'let SCENARIOS = new Set(["base"]);\n'); - - expect(() => - planFor({ - selectedLaneNames: ["published-upgrade-survivor"], - upgradeSurvivorScenarios: "reported-issues", - upgradeSurvivorTargetRoot: targetRoot, - }), - ).toThrow("expected const SCENARIOS = new Set([])"); - }); - it("skips plugin dependency cleanup for baselines without packaged plugin dirs", () => { const plan = planFor({ selectedLaneNames: ["published-upgrade-survivor"], diff --git a/test/scripts/upgrade-survivor-assertions.test.ts b/test/scripts/upgrade-survivor-assertions.test.ts index 7b43ce75f164..c7f2ad444200 100644 --- a/test/scripts/upgrade-survivor-assertions.test.ts +++ b/test/scripts/upgrade-survivor-assertions.test.ts @@ -155,6 +155,18 @@ function assertConfiguredPluginState(params: { installPath?: string } = {}): voi } describe("upgrade survivor assertions", () => { + it("lists the dependency-free scenario contract", () => { + const scenarios = JSON.parse( + execFileSync(process.execPath, [ASSERTIONS_PATH, "list-scenarios"], { + encoding: "utf8", + }), + ) as string[]; + + expect(scenarios).toContain("base"); + expect(scenarios).toContain("acpx-openclaw-tools-bridge"); + expect(new Set(scenarios).size).toBe(scenarios.length); + }); + it("accepts the ACPX OpenClaw tools bridge scenario during seed", () => { const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-survivor-acpx-")); try {