From 6cba12caaec02b03033ac3b3612791dfffdbd14d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 23:48:18 +0100 Subject: [PATCH] test: add docker e2e planner guards --- scripts/check-docker-e2e-boundaries.mjs | 64 +++++++++++++++- scripts/check-openclaw-package-tarball.mjs | 61 +++++++++++++++ scripts/docker-e2e.mjs | 17 +++++ scripts/package-openclaw-for-docker.mjs | 3 + test/scripts/docker-e2e-plan.test.ts | 86 ++++++++++++++++++++++ 5 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 scripts/check-openclaw-package-tarball.mjs create mode 100644 test/scripts/docker-e2e-plan.test.ts diff --git a/scripts/check-docker-e2e-boundaries.mjs b/scripts/check-docker-e2e-boundaries.mjs index 885b248de14..99ef52d0d06 100644 --- a/scripts/check-docker-e2e-boundaries.mjs +++ b/scripts/check-docker-e2e-boundaries.mjs @@ -5,9 +5,13 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { laneResources, laneWeight } from "./lib/docker-e2e-plan.mjs"; +import { allReleasePathLanes, mainLanes, tailLanes } from "./lib/docker-e2e-scenarios.mjs"; const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const errors = []; +const packageJson = JSON.parse(readText("package.json")); +const packageScripts = new Set(Object.keys(packageJson.scripts ?? {})); function readText(relativePath) { return fs.readFileSync(path.join(ROOT_DIR, relativePath), "utf8"); @@ -43,9 +47,67 @@ if (/^\s*(?:COPY|ADD)\s+\.\s+\/app(?:\s|$)/imu.test(dockerfile)) { errors.push("scripts/e2e/Dockerfile: do not copy the source checkout into /app"); } +function validateUniqueLanes(label, lanes) { + const seen = new Set(); + for (const lane of lanes) { + if (seen.has(lane.name)) { + errors.push(`${label}: duplicate Docker E2E lane '${lane.name}'`); + } + seen.add(lane.name); + } +} + +function validateLane(label, lane) { + if (!lane.name || typeof lane.name !== "string") { + errors.push(`${label}: Docker E2E lane is missing a string name`); + } + if (!lane.command || typeof lane.command !== "string") { + errors.push(`${label}: Docker E2E lane '${lane.name}' is missing a string command`); + return; + } + if (lane.e2eImageKind && lane.e2eImageKind !== "bare" && lane.e2eImageKind !== "functional") { + errors.push( + `${label}: Docker E2E lane '${lane.name}' has invalid image kind '${lane.e2eImageKind}'`, + ); + } + if (lane.live && lane.e2eImageKind) { + errors.push(`${label}: live Docker E2E lane '${lane.name}' must not require a package image`); + } + if (!lane.live && !lane.e2eImageKind) { + errors.push(`${label}: package Docker E2E lane '${lane.name}' must declare an e2e image kind`); + } + if (laneWeight(lane) < 1) { + errors.push(`${label}: Docker E2E lane '${lane.name}' must have positive weight`); + } + if (!laneResources(lane).includes("docker")) { + errors.push(`${label}: Docker E2E lane '${lane.name}' must include the docker resource`); + } + + for (const match of lane.command.matchAll(/\bpnpm\s+([^\s]+)/gu)) { + const script = match[1]; + if (!packageScripts.has(script)) { + errors.push( + `${label}: Docker E2E lane '${lane.name}' references missing package script '${script}'`, + ); + } + } +} + +const releasePathLanes = allReleasePathLanes({ includeOpenWebUI: true }); +for (const [label, lanes] of [ + ["release-path", releasePathLanes], + ["main", mainLanes], + ["tail", tailLanes], +]) { + validateUniqueLanes(label, lanes); + for (const lane of lanes) { + validateLane(label, lane); + } +} + if (errors.length > 0) { console.error(errors.join("\n")); process.exit(1); } -console.log("Docker E2E package boundary guard passed."); +console.log("Docker E2E package boundary/catalog guard passed."); diff --git a/scripts/check-openclaw-package-tarball.mjs b/scripts/check-openclaw-package-tarball.mjs new file mode 100644 index 00000000000..e7275e1e61c --- /dev/null +++ b/scripts/check-openclaw-package-tarball.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// Validates the npm tarball Docker E2E lanes install. +// This is intentionally tarball-only: the check proves Docker lanes consume the +// prebuilt package artifact with dist inventory, not a source checkout. +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; + +function usage() { + return "Usage: node scripts/check-openclaw-package-tarball.mjs "; +} + +function fail(message) { + console.error(message); + process.exit(1); +} + +const tarball = process.argv[2]; +if (!tarball || process.argv.length > 3) { + fail(usage()); +} +if (!fs.existsSync(tarball)) { + fail(`OpenClaw package tarball does not exist: ${tarball}`); +} + +const list = spawnSync("tar", ["-tf", tarball], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], +}); +if (list.status !== 0) { + fail(`tar -tf failed for ${tarball}: ${list.stderr || list.status}`); +} + +const entries = list.stdout + .split(/\r?\n/u) + .map((entry) => entry.trim()) + .filter(Boolean); +const normalized = entries.map((entry) => entry.replace(/^package\//u, "")); +const entrySet = new Set(normalized); +const errors = []; + +for (const entry of normalized) { + if (entry.startsWith("/") || entry.split("/").includes("..")) { + errors.push(`unsafe tar entry: ${entry}`); + } +} + +if (!entrySet.has("package.json")) { + errors.push("missing package.json"); +} +if (!normalized.some((entry) => entry.startsWith("dist/"))) { + errors.push("missing dist/ entries"); +} +if (!entrySet.has("dist/postinstall-inventory.json")) { + errors.push("missing dist/postinstall-inventory.json"); +} + +if (errors.length > 0) { + fail(`OpenClaw package tarball integrity failed:\n${errors.join("\n")}`); +} + +console.log("OpenClaw package tarball integrity passed."); diff --git a/scripts/docker-e2e.mjs b/scripts/docker-e2e.mjs index 753e720b56d..13ff391f1d6 100644 --- a/scripts/docker-e2e.mjs +++ b/scripts/docker-e2e.mjs @@ -8,6 +8,7 @@ function usage() { "Usage:", " node scripts/docker-e2e.mjs github-outputs ", " node scripts/docker-e2e.mjs summary ", + " node scripts/docker-e2e.mjs failed-reruns <summary.json>", ].join("\n"); } @@ -65,9 +66,23 @@ function summaryMarkdown(summary, title) { ); } } + const failedReruns = failedRerunCommands(summary); + if (failedReruns.length > 0) { + lines.push("", "Failed lane reruns:", ""); + for (const command of failedReruns) { + lines.push(`- ${inlineCode(command)}`); + } + } return lines.join("\n"); } +function failedRerunCommands(summary) { + const lanes = Array.isArray(summary.lanes) ? summary.lanes : []; + return lanes + .filter((lane) => lane.status !== 0 && lane.rerunCommand) + .map((lane) => lane.rerunCommand); +} + const [command, file, ...args] = process.argv.slice(2); if (!command || !file) { throw new Error(usage()); @@ -81,6 +96,8 @@ if (command === "github-outputs") { throw new Error(usage()); } process.stdout.write(`${summaryMarkdown(readJson(file), title)}\n`); +} else if (command === "failed-reruns") { + process.stdout.write(`${failedRerunCommands(readJson(file)).join("\n")}\n`); } else { throw new Error(`unknown command: ${command}\n${usage()}`); } diff --git a/scripts/package-openclaw-for-docker.mjs b/scripts/package-openclaw-for-docker.mjs index 80c5afbeba2..0d7003b4ff4 100644 --- a/scripts/package-openclaw-for-docker.mjs +++ b/scripts/package-openclaw-for-docker.mjs @@ -139,6 +139,9 @@ async function main() { } } + console.error("==> Checking OpenClaw package tarball"); + await run("node", ["scripts/check-openclaw-package-tarball.mjs", tarball]); + process.stdout.write(`${tarball}\n`); } diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts new file mode 100644 index 00000000000..a762809c445 --- /dev/null +++ b/test/scripts/docker-e2e-plan.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_LIVE_RETRIES, + RELEASE_PATH_PROFILE, + resolveDockerE2ePlan, +} from "../../scripts/lib/docker-e2e-plan.mjs"; + +const orderLanes = <T>(lanes: T[]) => lanes; + +function planFor( + overrides: Partial<Parameters<typeof resolveDockerE2ePlan>[0]> = {}, +): ReturnType<typeof resolveDockerE2ePlan>["plan"] { + return resolveDockerE2ePlan({ + includeOpenWebUI: false, + liveMode: "all", + liveRetries: DEFAULT_LIVE_RETRIES, + orderLanes, + planReleaseAll: false, + profile: "all", + releaseChunk: "core", + selectedLaneNames: [], + timingStore: undefined, + ...overrides, + }).plan; +} + +describe("scripts/lib/docker-e2e-plan", () => { + it("plans the full release path against package-backed e2e images", () => { + const plan = planFor({ + includeOpenWebUI: false, + planReleaseAll: true, + profile: RELEASE_PATH_PROFILE, + }); + + expect(plan.needs).toMatchObject({ + bareImage: true, + e2eImage: true, + functionalImage: true, + liveImage: false, + package: true, + }); + expect(plan.credentials).toEqual(["anthropic", "openai"]); + expect(plan.lanes.map((lane) => lane.name)).toContain("install-e2e"); + expect(plan.lanes.map((lane) => lane.name)).toContain("mcp-channels"); + expect(plan.lanes.map((lane) => lane.name)).not.toContain("openwebui"); + }); + + it("plans a live-only selected lane without package e2e images", () => { + const plan = planFor({ selectedLaneNames: ["live-models"] }); + + expect(plan.lanes.map((lane) => lane.name)).toEqual(["live-models"]); + expect(plan.needs).toMatchObject({ + bareImage: false, + e2eImage: false, + functionalImage: false, + liveImage: true, + package: false, + }); + }); + + it("plans Open WebUI as a functional-image lane with OpenAI credentials", () => { + const plan = planFor({ + includeOpenWebUI: true, + selectedLaneNames: ["openwebui"], + }); + + expect(plan.credentials).toEqual(["openai"]); + expect(plan.lanes).toEqual([ + expect.objectContaining({ + imageKind: "functional", + live: false, + name: "openwebui", + }), + ]); + expect(plan.needs).toMatchObject({ + functionalImage: true, + package: true, + }); + }); + + it("rejects unknown selected lanes with the available lane names", () => { + expect(() => planFor({ selectedLaneNames: ["missing-lane"] })).toThrow( + /OPENCLAW_DOCKER_ALL_LANES unknown lane\(s\): missing-lane/u, + ); + }); +});