diff --git a/scripts/lib/docker-e2e-plan.d.mts b/scripts/lib/docker-e2e-plan.d.mts index 25da1b524667..a151e597d24d 100644 --- a/scripts/lib/docker-e2e-plan.d.mts +++ b/scripts/lib/docker-e2e-plan.d.mts @@ -19,6 +19,7 @@ export type DockerE2ePlanLane = { export type DockerE2ePlanOptions = { allowFrozenTargetScenarioOmissions?: boolean; + candidatePackageRoot?: string; 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 7b81d74c4418..8e9a0fc3a3ca 100644 --- a/scripts/lib/docker-e2e-plan.mjs +++ b/scripts/lib/docker-e2e-plan.mjs @@ -497,6 +497,63 @@ function applyLiveRetries(poolLanes, retries) { return poolLanes.map((poolLane) => (poolLane.live ? { ...poolLane, retries } : poolLane)); } +const PNPM_NON_SCRIPT_COMMANDS = new Set([ + "add", + "audit", + "config", + "dlx", + "exec", + "fetch", + "install", + "pack", + "publish", + "rebuild", + "remove", +]); + +function candidatePackageScripts(candidatePackageRoot) { + if (!candidatePackageRoot) { + return undefined; + } + const packageJson = JSON.parse( + readFileSync(resolve(candidatePackageRoot, "package.json"), "utf8"), + ); + if (!packageJson || typeof packageJson !== "object" || Array.isArray(packageJson)) { + throw new Error("Candidate package manifest must be an object"); + } + if ( + packageJson.scripts !== undefined && + (!packageJson.scripts || + typeof packageJson.scripts !== "object" || + Array.isArray(packageJson.scripts)) + ) { + throw new Error("Candidate package manifest has an invalid scripts field"); + } + return new Set( + Object.entries(packageJson.scripts ?? {}) + .filter(([, command]) => typeof command === "string") + .map(([name]) => name), + ); +} + +function requiredPackageScripts(poolLane) { + return [...poolLane.command.matchAll(/\bpnpm\s+(?:run\s+)?([a-z][a-z0-9:-]*)/giu)] + .map(([, script]) => script) + .filter((script) => !PNPM_NON_SCRIPT_COMMANDS.has(script)); +} + +function filterUnavailableCandidateScriptLanes(poolLanes, candidatePackageRoot) { + const scripts = candidatePackageScripts(candidatePackageRoot); + if (!scripts) { + return poolLanes; + } + // The trusted catalog can add lanes before a frozen candidate has their scripts. + // Only schedule package-script commands the selected candidate can execute. + return poolLanes.filter((poolLane) => + requiredPackageScripts(poolLane).every((script) => scripts.has(script)), + ); +} + export function laneWeight(poolLane) { return Math.max(1, poolLane.weight ?? 1); } @@ -726,8 +783,16 @@ export function resolveDockerE2ePlan(options) { : options.liveMode === "only" ? [] : applyLiveMode(retriedTailLanes, options.liveMode); - const orderedLanes = options.orderLanes(configuredLanes, options.timingStore); - const orderedTailLanes = options.orderLanes(configuredTailLanes, options.timingStore); + const availableLanes = filterUnavailableCandidateScriptLanes( + configuredLanes, + options.candidatePackageRoot, + ); + const availableTailLanes = filterUnavailableCandidateScriptLanes( + configuredTailLanes, + options.candidatePackageRoot, + ); + const orderedLanes = options.orderLanes(availableLanes, options.timingStore); + const orderedTailLanes = options.orderLanes(availableTailLanes, options.timingStore); return { omittedUnsupportedLaneNames: [...omittedUnsupportedLaneNames], orderedLanes, diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index edc68f5f39ac..b9840a951c71 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -1538,6 +1538,7 @@ async function main() { upgradeSurvivorScenarios: process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS, upgradeSurvivorTargetRoot: process.env.OPENCLAW_UPGRADE_SURVIVOR_TARGET_ROOT, allowFrozenTargetScenarioOmissions, + candidatePackageRoot: ROOT_DIR, }); 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 7ffe3f9ea3e6..4bbdac3f7d7d 100644 --- a/test/scripts/docker-all-scheduler.test.ts +++ b/test/scripts/docker-all-scheduler.test.ts @@ -168,6 +168,7 @@ describe("scripts/test-docker-all scheduler", () => { const scriptsDir = path.join(root, "scripts"); const libDir = path.join(scriptsDir, "lib"); mkdirSync(libDir, { recursive: true }); + copyFileSync("package.json", path.join(root, "package.json")); 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)); diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index a4776a62f267..cf82cbb7d0dd 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -54,6 +54,12 @@ function planFor( }).plan; } +function writeCandidatePackage(scripts: Record): string { + const root = tempDirs.make("openclaw-docker-plan-candidate-"); + writeFileSync(join(root, "package.json"), JSON.stringify({ scripts })); + return root; +} + function requireFirstLane(plan: ReturnType) { const [lane] = plan.lanes; if (!lane) { @@ -120,6 +126,47 @@ function bundledPluginSweepLane(index: number): ReturnType } describe("scripts/lib/docker-e2e-plan", () => { + it("omits a package-script lane unavailable from the candidate", () => { + const plan = planFor({ + candidatePackageRoot: writeCandidatePackage({}), + selectedLaneNames: ["update-run-package-self-upgrade"], + }); + + expect(plan.lanes).toEqual([]); + }); + + it("keeps a package-script lane available from the candidate", () => { + const plan = planFor({ + candidatePackageRoot: writeCandidatePackage({ + "test:docker:update-run-package-self-upgrade": "node test.mjs", + }), + selectedLaneNames: ["update-run-package-self-upgrade"], + }); + + expect(plan.lanes.map((lane) => lane.name)).toEqual(["update-run-package-self-upgrade"]); + }); + + it("fails when the selected candidate package manifest is missing", () => { + expect(() => + planFor({ + candidatePackageRoot: tempDirs.make("openclaw-docker-plan-missing-package-"), + selectedLaneNames: ["update-run-package-self-upgrade"], + }), + ).toThrow(/package\.json/); + }); + + it("fails when the selected candidate package manifest is malformed", () => { + const root = tempDirs.make("openclaw-docker-plan-malformed-package-"); + writeFileSync(join(root, "package.json"), "{"); + + expect(() => + planFor({ + candidatePackageRoot: root, + selectedLaneNames: ["update-run-package-self-upgrade"], + }), + ).toThrow(SyntaxError); + }); + it("finds a named lane through the expanded catalog", () => { expect(findLaneByName("plugin-binding-command-escape")?.name).toBe( "plugin-binding-command-escape",