mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 11:11:10 +00:00
fix: keep frozen Docker planning dependency-free (#108340)
* fix(release): keep frozen Docker planning dependency-free * fix(release): gate frozen scenario command execution
This commit is contained in:
committed by
GitHub
parent
b2e42e3645
commit
688a129ed0
@@ -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();
|
||||
|
||||
@@ -18,6 +18,7 @@ export type DockerE2ePlanLane = {
|
||||
};
|
||||
|
||||
export type DockerE2ePlanOptions = {
|
||||
allowFrozenTargetScenarioOmissions?: boolean;
|
||||
includeOpenWebUI: boolean;
|
||||
liveMode: "all" | "only" | "skip";
|
||||
liveRetries: number;
|
||||
|
||||
@@ -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([<string literals>])`,
|
||||
);
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, unknown> } };
|
||||
@@ -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",
|
||||
|
||||
@@ -22,10 +22,25 @@ const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
|
||||
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<Parameters<typeof resolveDockerE2ePlan>[0]> = {},
|
||||
): ReturnType<typeof resolveDockerE2ePlan>["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([<string literals>])");
|
||||
).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([<string literals>])");
|
||||
});
|
||||
|
||||
it("skips plugin dependency cleanup for baselines without packaged plugin dirs", () => {
|
||||
const plan = planFor({
|
||||
selectedLaneNames: ["published-upgrade-survivor"],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user