test: add docker e2e planner guards

This commit is contained in:
Peter Steinberger
2026-04-26 23:48:18 +01:00
parent a08b65a90a
commit 6cba12caae
5 changed files with 230 additions and 1 deletions

View File

@@ -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.");

View File

@@ -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 <openclaw.tgz>";
}
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.");

View File

@@ -8,6 +8,7 @@ function usage() {
"Usage:",
" node scripts/docker-e2e.mjs github-outputs <plan.json>",
" node scripts/docker-e2e.mjs summary <summary.json> <title>",
" 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()}`);
}

View File

@@ -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`);
}

View File

@@ -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,
);
});
});