Skip to content

Commit 6cba12c

Browse files
committed
test: add docker e2e planner guards
1 parent a08b65a commit 6cba12c

5 files changed

Lines changed: 230 additions & 1 deletion

File tree

scripts/check-docker-e2e-boundaries.mjs

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55
import fs from "node:fs";
66
import path from "node:path";
77
import { fileURLToPath } from "node:url";
8+
import { laneResources, laneWeight } from "./lib/docker-e2e-plan.mjs";
9+
import { allReleasePathLanes, mainLanes, tailLanes } from "./lib/docker-e2e-scenarios.mjs";
810

911
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
1012
const errors = [];
13+
const packageJson = JSON.parse(readText("package.json"));
14+
const packageScripts = new Set(Object.keys(packageJson.scripts ?? {}));
1115

1216
function readText(relativePath) {
1317
return fs.readFileSync(path.join(ROOT_DIR, relativePath), "utf8");
@@ -43,9 +47,67 @@ if (/^\s*(?:COPY|ADD)\s+\.\s+\/app(?:\s|$)/imu.test(dockerfile)) {
4347
errors.push("scripts/e2e/Dockerfile: do not copy the source checkout into /app");
4448
}
4549

50+
function validateUniqueLanes(label, lanes) {
51+
const seen = new Set();
52+
for (const lane of lanes) {
53+
if (seen.has(lane.name)) {
54+
errors.push(`${label}: duplicate Docker E2E lane '${lane.name}'`);
55+
}
56+
seen.add(lane.name);
57+
}
58+
}
59+
60+
function validateLane(label, lane) {
61+
if (!lane.name || typeof lane.name !== "string") {
62+
errors.push(`${label}: Docker E2E lane is missing a string name`);
63+
}
64+
if (!lane.command || typeof lane.command !== "string") {
65+
errors.push(`${label}: Docker E2E lane '${lane.name}' is missing a string command`);
66+
return;
67+
}
68+
if (lane.e2eImageKind && lane.e2eImageKind !== "bare" && lane.e2eImageKind !== "functional") {
69+
errors.push(
70+
`${label}: Docker E2E lane '${lane.name}' has invalid image kind '${lane.e2eImageKind}'`,
71+
);
72+
}
73+
if (lane.live && lane.e2eImageKind) {
74+
errors.push(`${label}: live Docker E2E lane '${lane.name}' must not require a package image`);
75+
}
76+
if (!lane.live && !lane.e2eImageKind) {
77+
errors.push(`${label}: package Docker E2E lane '${lane.name}' must declare an e2e image kind`);
78+
}
79+
if (laneWeight(lane) < 1) {
80+
errors.push(`${label}: Docker E2E lane '${lane.name}' must have positive weight`);
81+
}
82+
if (!laneResources(lane).includes("docker")) {
83+
errors.push(`${label}: Docker E2E lane '${lane.name}' must include the docker resource`);
84+
}
85+
86+
for (const match of lane.command.matchAll(/\bpnpm\s+([^\s]+)/gu)) {
87+
const script = match[1];
88+
if (!packageScripts.has(script)) {
89+
errors.push(
90+
`${label}: Docker E2E lane '${lane.name}' references missing package script '${script}'`,
91+
);
92+
}
93+
}
94+
}
95+
96+
const releasePathLanes = allReleasePathLanes({ includeOpenWebUI: true });
97+
for (const [label, lanes] of [
98+
["release-path", releasePathLanes],
99+
["main", mainLanes],
100+
["tail", tailLanes],
101+
]) {
102+
validateUniqueLanes(label, lanes);
103+
for (const lane of lanes) {
104+
validateLane(label, lane);
105+
}
106+
}
107+
46108
if (errors.length > 0) {
47109
console.error(errors.join("\n"));
48110
process.exit(1);
49111
}
50112

51-
console.log("Docker E2E package boundary guard passed.");
113+
console.log("Docker E2E package boundary/catalog guard passed.");
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#!/usr/bin/env node
2+
// Validates the npm tarball Docker E2E lanes install.
3+
// This is intentionally tarball-only: the check proves Docker lanes consume the
4+
// prebuilt package artifact with dist inventory, not a source checkout.
5+
import { spawnSync } from "node:child_process";
6+
import fs from "node:fs";
7+
8+
function usage() {
9+
return "Usage: node scripts/check-openclaw-package-tarball.mjs <openclaw.tgz>";
10+
}
11+
12+
function fail(message) {
13+
console.error(message);
14+
process.exit(1);
15+
}
16+
17+
const tarball = process.argv[2];
18+
if (!tarball || process.argv.length > 3) {
19+
fail(usage());
20+
}
21+
if (!fs.existsSync(tarball)) {
22+
fail(`OpenClaw package tarball does not exist: ${tarball}`);
23+
}
24+
25+
const list = spawnSync("tar", ["-tf", tarball], {
26+
encoding: "utf8",
27+
stdio: ["ignore", "pipe", "pipe"],
28+
});
29+
if (list.status !== 0) {
30+
fail(`tar -tf failed for ${tarball}: ${list.stderr || list.status}`);
31+
}
32+
33+
const entries = list.stdout
34+
.split(/\r?\n/u)
35+
.map((entry) => entry.trim())
36+
.filter(Boolean);
37+
const normalized = entries.map((entry) => entry.replace(/^package\//u, ""));
38+
const entrySet = new Set(normalized);
39+
const errors = [];
40+
41+
for (const entry of normalized) {
42+
if (entry.startsWith("/") || entry.split("/").includes("..")) {
43+
errors.push(`unsafe tar entry: ${entry}`);
44+
}
45+
}
46+
47+
if (!entrySet.has("package.json")) {
48+
errors.push("missing package.json");
49+
}
50+
if (!normalized.some((entry) => entry.startsWith("dist/"))) {
51+
errors.push("missing dist/ entries");
52+
}
53+
if (!entrySet.has("dist/postinstall-inventory.json")) {
54+
errors.push("missing dist/postinstall-inventory.json");
55+
}
56+
57+
if (errors.length > 0) {
58+
fail(`OpenClaw package tarball integrity failed:\n${errors.join("\n")}`);
59+
}
60+
61+
console.log("OpenClaw package tarball integrity passed.");

scripts/docker-e2e.mjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ function usage() {
88
"Usage:",
99
" node scripts/docker-e2e.mjs github-outputs <plan.json>",
1010
" node scripts/docker-e2e.mjs summary <summary.json> <title>",
11+
" node scripts/docker-e2e.mjs failed-reruns <summary.json>",
1112
].join("\n");
1213
}
1314

@@ -65,9 +66,23 @@ function summaryMarkdown(summary, title) {
6566
);
6667
}
6768
}
69+
const failedReruns = failedRerunCommands(summary);
70+
if (failedReruns.length > 0) {
71+
lines.push("", "Failed lane reruns:", "");
72+
for (const command of failedReruns) {
73+
lines.push(`- ${inlineCode(command)}`);
74+
}
75+
}
6876
return lines.join("\n");
6977
}
7078

79+
function failedRerunCommands(summary) {
80+
const lanes = Array.isArray(summary.lanes) ? summary.lanes : [];
81+
return lanes
82+
.filter((lane) => lane.status !== 0 && lane.rerunCommand)
83+
.map((lane) => lane.rerunCommand);
84+
}
85+
7186
const [command, file, ...args] = process.argv.slice(2);
7287
if (!command || !file) {
7388
throw new Error(usage());
@@ -81,6 +96,8 @@ if (command === "github-outputs") {
8196
throw new Error(usage());
8297
}
8398
process.stdout.write(`${summaryMarkdown(readJson(file), title)}\n`);
99+
} else if (command === "failed-reruns") {
100+
process.stdout.write(`${failedRerunCommands(readJson(file)).join("\n")}\n`);
84101
} else {
85102
throw new Error(`unknown command: ${command}\n${usage()}`);
86103
}

scripts/package-openclaw-for-docker.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ async function main() {
139139
}
140140
}
141141

142+
console.error("==> Checking OpenClaw package tarball");
143+
await run("node", ["scripts/check-openclaw-package-tarball.mjs", tarball]);
144+
142145
process.stdout.write(`${tarball}\n`);
143146
}
144147

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
DEFAULT_LIVE_RETRIES,
4+
RELEASE_PATH_PROFILE,
5+
resolveDockerE2ePlan,
6+
} from "../../scripts/lib/docker-e2e-plan.mjs";
7+
8+
const orderLanes = <T>(lanes: T[]) => lanes;
9+
10+
function planFor(
11+
overrides: Partial<Parameters<typeof resolveDockerE2ePlan>[0]> = {},
12+
): ReturnType<typeof resolveDockerE2ePlan>["plan"] {
13+
return resolveDockerE2ePlan({
14+
includeOpenWebUI: false,
15+
liveMode: "all",
16+
liveRetries: DEFAULT_LIVE_RETRIES,
17+
orderLanes,
18+
planReleaseAll: false,
19+
profile: "all",
20+
releaseChunk: "core",
21+
selectedLaneNames: [],
22+
timingStore: undefined,
23+
...overrides,
24+
}).plan;
25+
}
26+
27+
describe("scripts/lib/docker-e2e-plan", () => {
28+
it("plans the full release path against package-backed e2e images", () => {
29+
const plan = planFor({
30+
includeOpenWebUI: false,
31+
planReleaseAll: true,
32+
profile: RELEASE_PATH_PROFILE,
33+
});
34+
35+
expect(plan.needs).toMatchObject({
36+
bareImage: true,
37+
e2eImage: true,
38+
functionalImage: true,
39+
liveImage: false,
40+
package: true,
41+
});
42+
expect(plan.credentials).toEqual(["anthropic", "openai"]);
43+
expect(plan.lanes.map((lane) => lane.name)).toContain("install-e2e");
44+
expect(plan.lanes.map((lane) => lane.name)).toContain("mcp-channels");
45+
expect(plan.lanes.map((lane) => lane.name)).not.toContain("openwebui");
46+
});
47+
48+
it("plans a live-only selected lane without package e2e images", () => {
49+
const plan = planFor({ selectedLaneNames: ["live-models"] });
50+
51+
expect(plan.lanes.map((lane) => lane.name)).toEqual(["live-models"]);
52+
expect(plan.needs).toMatchObject({
53+
bareImage: false,
54+
e2eImage: false,
55+
functionalImage: false,
56+
liveImage: true,
57+
package: false,
58+
});
59+
});
60+
61+
it("plans Open WebUI as a functional-image lane with OpenAI credentials", () => {
62+
const plan = planFor({
63+
includeOpenWebUI: true,
64+
selectedLaneNames: ["openwebui"],
65+
});
66+
67+
expect(plan.credentials).toEqual(["openai"]);
68+
expect(plan.lanes).toEqual([
69+
expect.objectContaining({
70+
imageKind: "functional",
71+
live: false,
72+
name: "openwebui",
73+
}),
74+
]);
75+
expect(plan.needs).toMatchObject({
76+
functionalImage: true,
77+
package: true,
78+
});
79+
});
80+
81+
it("rejects unknown selected lanes with the available lane names", () => {
82+
expect(() => planFor({ selectedLaneNames: ["missing-lane"] })).toThrow(
83+
/OPENCLAW_DOCKER_ALL_LANES unknown lane\(s\): missing-lane/u,
84+
);
85+
});
86+
});

0 commit comments

Comments
 (0)