fix(qa): reuse one immutable Docker candidate (#121253)

Punchcard-Session: amber-workshop-workshop-36

Co-authored-by: Dallin Romney <6581799+RomneyDa@users.noreply.github.com>
This commit is contained in:
Vincent Koc
2026-08-10 10:14:24 +08:00
committed by GitHub
parent 3b641a145e
commit 4ee008a026
8 changed files with 938 additions and 42 deletions
@@ -11,8 +11,14 @@ import type {
QaTestFileScenarioRunResult,
} from "./test-file-scenario-runner.js";
const { crablineRuntimeLoads, runQaFlowSuite, runQaTestFileScenarios } = vi.hoisted(() => ({
const {
crablineRuntimeLoads,
prepareDockerE2eEnvironment,
runQaFlowSuite,
runQaTestFileScenarios,
} = vi.hoisted(() => ({
crablineRuntimeLoads: vi.fn(),
prepareDockerE2eEnvironment: vi.fn(),
runQaFlowSuite: vi.fn(),
runQaTestFileScenarios: vi.fn(),
}));
@@ -32,6 +38,11 @@ vi.mock("./test-file-scenario-runner.js", async (importOriginal) => ({
runQaTestFileScenarios,
}));
vi.mock("./test-file-scenario-docker-batch.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./test-file-scenario-docker-batch.js")>()),
prepareDockerE2eEnvironment,
}));
import { runQaSuite, runQaSuiteWithInfraRetry } from "./suite-launch.runtime.js";
const tempRoots: string[] = [];
@@ -113,6 +124,8 @@ describe("qa suite runtime launcher", () => {
beforeEach(() => {
runQaFlowSuite.mockReset();
runQaTestFileScenarios.mockReset();
prepareDockerE2eEnvironment.mockReset();
prepareDockerE2eEnvironment.mockResolvedValue(undefined);
runQaFlowSuite.mockImplementation(
async (
params:
@@ -1986,6 +1999,8 @@ describe("qa suite runtime launcher", () => {
const serial = createDeferred();
const parallel = createDeferred();
const started: string[] = [];
const preparedEnv = Object.freeze({ OPENCLAW_CURRENT_PACKAGE_TGZ: "/tmp/candidate.tgz" });
const scriptEnvs: unknown[] = [];
const parallelScriptIds: string[] = [];
let activeParallelScripts = 0;
let maxActiveParallelScripts = 0;
@@ -1994,6 +2009,10 @@ describe("qa suite runtime launcher", () => {
await flow.promise;
return await defaultFlowImplementation(params);
});
prepareDockerE2eEnvironment.mockImplementationOnce(async () => {
started.push("prep");
return preparedEnv;
});
runQaTestFileScenarios.mockImplementation(async (params) => {
const scenarioIds = params.scenarios.map((scenario: QaTestFileScenario) => scenario.id);
const kind = params.scenarios[0]?.execution.kind;
@@ -2001,9 +2020,11 @@ describe("qa suite runtime launcher", () => {
started.push("native");
await native.promise;
} else if (scenarioIds.includes("docker-npm-onboard-channel-agent")) {
scriptEnvs.push(params.env);
started.push("serial");
await serial.promise;
} else {
scriptEnvs.push(params.env);
parallelScriptIds.push(...scenarioIds);
activeParallelScripts += 1;
maxActiveParallelScripts = Math.max(maxActiveParallelScripts, activeParallelScripts);
@@ -2038,6 +2059,7 @@ describe("qa suite runtime launcher", () => {
native.resolve();
await vi.waitFor(() => expect(started).toContain("serial"));
expect(started.slice(0, 4)).toEqual(["flow", "native", "prep", "serial"]);
expect(parallelScriptIds).toEqual([]);
serial.resolve();
@@ -2047,6 +2069,8 @@ describe("qa suite runtime launcher", () => {
parallel.resolve();
await runPromise;
expect(prepareDockerE2eEnvironment).toHaveBeenCalledTimes(1);
expect(scriptEnvs.every((env) => env === preparedEnv)).toBe(true);
expect(parallelScriptIds.slice(0, 3)).toEqual(
expect.arrayContaining(["remote-log-tailing", "gateway-smoke", "logging-file-boundary"]),
);
@@ -2054,6 +2078,68 @@ describe("qa suite runtime launcher", () => {
expect(maxActiveParallelScripts).toBe(3);
});
it("records Docker preparation failure without starting a script partition", async () => {
const repoRoot = await makeTempRepo("qa-suite-docker-prep-failure-");
prepareDockerE2eEnvironment.mockRejectedValueOnce(new Error("candidate pack failed"));
const result = await runQaSuite({
repoRoot,
scenarioIds: ["docker-npm-onboard-channel-agent", "gateway-smoke"],
});
expect(runQaTestFileScenarios).not.toHaveBeenCalled();
expect(result.result.scenarios).toHaveLength(2);
expect(result.result.scenarios).toEqual(
expect.arrayContaining([
expect.objectContaining({
status: "fail",
details: expect.stringContaining("candidate pack failed"),
}),
]),
);
});
it("reuses the prepared Docker env object when a script partition retries", async () => {
const repoRoot = await makeTempRepo("qa-suite-docker-prep-retry-");
const preparedEnv = Object.freeze({ OPENCLAW_CURRENT_PACKAGE_TGZ: "/tmp/candidate.tgz" });
const defaultImplementation = runQaTestFileScenarios.getMockImplementation();
if (!defaultImplementation) {
throw new Error("expected default QA test-file mock implementation");
}
prepareDockerE2eEnvironment.mockResolvedValueOnce(preparedEnv);
runQaTestFileScenarios
.mockRejectedValueOnce(new QaSuiteInfraError("transport_ready_timeout", "retry"))
.mockImplementationOnce(defaultImplementation);
await runQaSuite({ repoRoot, scenarioIds: ["docker-npm-onboard-channel-agent"] });
expect(runQaTestFileScenarios).toHaveBeenCalledTimes(2);
expect(runQaTestFileScenarios.mock.calls.map(([params]) => params.env)).toEqual([
preparedEnv,
preparedEnv,
]);
});
it("skips Docker preparation after a fail-fast concurrent failure", async () => {
const repoRoot = await makeTempRepo("qa-suite-docker-prep-fail-fast-");
runQaFlowSuite.mockRejectedValueOnce(new Error("flow failed"));
await runQaSuite({
repoRoot,
failFast: true,
scenarioIds: ["channel-chat-baseline", "docker-npm-onboard-channel-agent"],
});
expect(prepareDockerE2eEnvironment).not.toHaveBeenCalled();
expect(runQaTestFileScenarios).not.toHaveBeenCalled();
});
it("does not prepare a Docker candidate for ordinary scripts", async () => {
const repoRoot = await makeTempRepo("qa-suite-no-docker-prep-");
await runQaSuite({ repoRoot, scenarioIds: ["gateway-smoke"] });
expect(prepareDockerE2eEnvironment).not.toHaveBeenCalled();
expect(runQaTestFileScenarios).toHaveBeenCalledTimes(1);
});
it("keeps selected evidence order and successful siblings when a parallel script rejects", async () => {
const repoRoot = await makeTempRepo("qa-suite-parallel-script-rejection-");
const defaultTestFileImplementation = runQaTestFileScenarios.getMockImplementation();
@@ -2128,9 +2214,11 @@ describe("qa suite runtime launcher", () => {
throw new Error("expected default QA test-file mock implementation");
}
const first = createDeferred();
const preparedEnv = Object.freeze({ OPENCLAW_CURRENT_PACKAGE_TGZ: "/tmp/candidate.tgz" });
const started: string[] = [];
let active = 0;
let maxActive = 0;
prepareDockerE2eEnvironment.mockResolvedValueOnce(preparedEnv);
runQaTestFileScenarios.mockImplementation(async (params) => {
const scenario = params.scenarios[0] as QaTestFileScenario | undefined;
if (!scenario) {
@@ -2176,6 +2264,9 @@ describe("qa suite runtime launcher", () => {
expect(started).toEqual(["remote-log-tailing", "docker-npm-onboard-channel-agent"]);
expect(maxActive).toBe(1);
expect(runQaTestFileScenarios).toHaveBeenCalledTimes(2);
expect(runQaTestFileScenarios.mock.calls.every(([params]) => params.env === preparedEnv)).toBe(
true,
);
expect(runQaTestFileScenarios).toHaveBeenLastCalledWith(
expect.objectContaining({ failFast: true }),
);
+30 -4
View File
@@ -45,6 +45,7 @@ import {
type QaSuiteScenarioResult,
type QaSuiteSummaryJson,
} from "./suite.js";
import * as dockerBatch from "./test-file-scenario-docker-batch.js";
import {
isQaTestFileScenario,
runQaTestFileScenarios,
@@ -409,6 +410,7 @@ async function resolveSuiteExecutionPlan(
};
}
async function runQaTestFileSuiteFromRuntime(params: {
env?: NodeJS.ProcessEnv;
runParams: QaSuiteRunParams | undefined;
scenarios: readonly QaTestFileScenario[];
}): Promise<QaTestFileScenarioRunResult> {
@@ -428,6 +430,7 @@ async function runQaTestFileSuiteFromRuntime(params: {
const primaryModel = runParams?.primaryModel?.trim() || defaultQaModelForMode(providerMode);
return await runQaTestFileScenarios({
evidenceMode: runParams?.evidenceMode,
...(params.env ? { env: params.env, envMode: "replace" as const } : {}),
...(runParams?.failFast ? { failFast: true } : {}),
repoRoot,
outputDir,
@@ -813,6 +816,7 @@ async function runUnifiedQaSuite(params: {
const serialScriptPartitionTasks: QaUnifiedPartitionTask[] = [];
const parallelScriptPartitionTasks: QaUnifiedPartitionTask[] = [];
const unavailableChannelCredentialDetails = new Map<string, string>();
let preparedScriptEnv: Readonly<NodeJS.ProcessEnv> | undefined;
if (params.plan.channelGroups.length > 0) {
const channelGroups = params.plan.channelGroups;
const runFlowSuite = await loadQaFlowSuiteRuntime();
@@ -1075,6 +1079,7 @@ async function runUnifiedQaSuite(params: {
),
);
const result = await runQaTestFileSuiteFromRuntime({
env: kind === "script" ? preparedScriptEnv : undefined,
runParams: {
...params.runParams,
outputDir: suitePartitionOutputDir(outputDir, kind),
@@ -1263,8 +1268,9 @@ async function runUnifiedQaSuite(params: {
return partition.startedScenarioIds.some((scenarioId) => !returnedScenarioIds.has(scenarioId));
};
const capturePartitionFailure = (
task: QaUnifiedPartitionTask,
task: Pick<QaUnifiedPartitionTask, "channelId" | "scenarios">,
error: unknown,
started = true,
): QaUnifiedPartitionResult => {
const scenarios = task.scenarios;
const details = `suite partition failed: ${formatErrorMessage(error)}`;
@@ -1293,7 +1299,7 @@ async function runUnifiedQaSuite(params: {
}),
],
scenarioResults,
startedScenarioIds: scenarios.map((scenario) => scenario.id),
startedScenarioIds: started ? scenarios.map((scenario) => scenario.id) : [],
submittedScenarioIds: task.scenarios.map((scenario) => scenario.id),
};
};
@@ -1319,14 +1325,33 @@ async function runUnifiedQaSuite(params: {
: await runWeightedUnifiedPartitionTasks(retryingTasks, maxWeight);
};
const concurrentPartitionResults = await runPartitionTasks(concurrentPartitionTasks, concurrency);
const concurrentFailed = failFast && concurrentPartitionResults.some(partitionFailed);
let scriptPreparationFailure: QaUnifiedPartitionResult | undefined;
if (!concurrentFailed && scriptScenarios?.some(dockerBatch.isDockerE2eScenario)) {
try {
preparedScriptEnv = await dockerBatch.prepareDockerE2eEnvironment({
env: process.env,
outputDir,
repoRoot,
scenarios: scriptScenarios,
});
} catch (error) {
scriptPreparationFailure = capturePartitionFailure(
{ channelId: transportId, scenarios: scriptScenarios },
new Error(`Docker candidate preparation failed: ${formatErrorMessage(error)}`),
false,
);
progress?.recordResults(scriptPreparationFailure.scenarioResults);
}
}
// Unmarked scripts may rebuild shared checkout state. Run them exclusively
// after every flow and native partition settles, then start only audited peers.
const serialScriptPartitionResults =
failFast && concurrentPartitionResults.some(partitionFailed)
concurrentFailed || scriptPreparationFailure
? []
: await runPartitionTasks(serialScriptPartitionTasks, 1);
const parallelScriptPartitionResults =
failFast && serialScriptPartitionResults.some(partitionFailed)
scriptPreparationFailure || (failFast && serialScriptPartitionResults.some(partitionFailed))
? []
: await runPartitionTasks(
parallelScriptPartitionTasks,
@@ -1334,6 +1359,7 @@ async function runUnifiedQaSuite(params: {
);
const partitionResults = [
...concurrentPartitionResults,
...(scriptPreparationFailure ? [scriptPreparationFailure] : []),
...serialScriptPartitionResults,
...parallelScriptPartitionResults,
];
@@ -1,14 +1,36 @@
import fs from "node:fs/promises";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { z } from "zod";
import type { QaSeedScenarioWithSource } from "./scenario-catalog.js";
import { shellQuote } from "./shell-quote.js";
import type {
QaScenarioCommandExecution,
QaScenarioCommandResult,
} from "./test-file-scenario-command-lifecycle.js";
import { runQaScenarioCommandLifecycle } from "./test-file-scenario-command-lifecycle.js";
const QA_DOCKER_E2E_LANE_SCRIPT = "test/e2e/qa-lab/runtime/docker-e2e-lane.ts";
const DOCKER_CANDIDATE_ENV_KEY =
/^(?:OPENCLAW_DOCKER_E2E_SELECTED_SHA|OPENCLAW_CURRENT_PACKAGE_(?:TGZ|VERSION|SHA256)|OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_(?:DIR|CANDIDATE_VERSION|MANIFEST_SHA256))$/u;
const dockerRegistrySchema = z.strictObject({
dir: z.string(),
candidateVersion: z.string(),
manifestSha256: z.string(),
});
const dockerPackageSchema = z.strictObject({
path: z.string(),
name: z.literal("openclaw"),
version: z.string(),
sha256: z.string(),
});
const dockerCandidateManifestSchema = z.strictObject({
schema: z.literal("openclaw.qa-docker-candidate/v1"),
schemaVersion: z.literal(1),
sourceSha: z.string(),
candidate: z
.strictObject({
package: dockerPackageSchema,
registry: dockerRegistrySchema.nullable(),
})
.nullable(),
});
type QaDockerScenario = QaSeedScenarioWithSource & {
execution: Extract<QaSeedScenarioWithSource["execution"], { kind: "script" }>;
@@ -42,6 +64,74 @@ export function isDockerE2eScenario(
return dockerE2eLaneName(scenario) !== undefined;
}
export async function prepareDockerE2eEnvironment(params: {
env: NodeJS.ProcessEnv;
outputDir: string;
repoRoot: string;
runCommand?: typeof runQaScenarioCommandLifecycle;
scenarios: readonly QaSeedScenarioWithSource[];
}): Promise<Readonly<NodeJS.ProcessEnv> | undefined> {
const laneNames = [
...new Set(params.scenarios.flatMap((scenario) => dockerE2eLaneName(scenario) ?? [])),
];
if (laneNames.length === 0) {
return undefined;
}
const prepDir = path.join(params.outputDir, "docker-candidate");
const manifestPath = path.join(prepDir, "manifest.json");
const env = { ...params.env };
for (const key of Object.keys(env)) {
if (
key.startsWith("OPENCLAW_DOCKER_ALL_") ||
DOCKER_CANDIDATE_ENV_KEY.test(key) ||
key === "DOCKER_E2E_LANES"
) {
delete env[key];
}
}
await fs.mkdir(prepDir, { recursive: true });
await fs.rm(manifestPath, { force: true });
const result = await (params.runCommand ?? runQaScenarioCommandLifecycle)({
command: process.execPath,
args: ["scripts/test-docker-all.mjs", `--prepare-only=${manifestPath}`],
cwd: params.repoRoot,
env: {
...env,
OPENCLAW_DOCKER_ALL_LANES: laneNames.join(","),
OPENCLAW_DOCKER_ALL_LOG_DIR: prepDir,
OPENCLAW_DOCKER_E2E_REPO_ROOT: params.repoRoot,
},
});
if (result.exitCode !== 0) {
throw new Error(
result.failureMessage || result.stderr.trim() || "Docker candidate prep failed",
);
}
const manifest = dockerCandidateManifestSchema.parse(
JSON.parse(await fs.readFile(manifestPath, "utf8")),
);
env.OPENCLAW_DOCKER_E2E_REPO_ROOT = params.repoRoot;
if (manifest.candidate === null) {
return Object.freeze(env);
}
const { package: packageCandidate, registry } = manifest.candidate;
return Object.freeze(
Object.assign(env, {
OPENCLAW_DOCKER_E2E_SELECTED_SHA: manifest.sourceSha,
OPENCLAW_CURRENT_PACKAGE_TGZ: packageCandidate.path,
OPENCLAW_CURRENT_PACKAGE_VERSION: packageCandidate.version,
OPENCLAW_CURRENT_PACKAGE_SHA256: packageCandidate.sha256,
...(registry
? {
OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR: registry.dir,
OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION: registry.candidateVersion,
OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256: registry.manifestSha256,
}
: {}),
}),
);
}
function laneMatches(
selectedLane: string,
resultLane: string | undefined,
@@ -63,7 +153,7 @@ export async function runDockerE2eBatch(params: {
env: NodeJS.ProcessEnv;
outputDir: string;
repoRoot: string;
runCommand: (command: QaScenarioCommandExecution) => Promise<QaScenarioCommandResult>;
runCommand: typeof runQaScenarioCommandLifecycle;
scenarios: readonly QaDockerScenario[];
}): Promise<QaDockerBatchResult[]> {
const selected = params.scenarios.map((scenario) => ({
@@ -77,7 +167,7 @@ export async function runDockerE2eBatch(params: {
await fs.mkdir(dockerOutputDir, { recursive: true });
const summaryPath = path.join(dockerOutputDir, "summary.json");
await fs.rm(summaryPath, { force: true });
let commandResult: QaScenarioCommandResult;
let commandResult: Awaited<ReturnType<typeof runQaScenarioCommandLifecycle>>;
try {
commandResult = await params.runCommand({
command: process.execPath,
@@ -7,7 +7,10 @@ import { validateQaEvidenceSummaryJson } from "./evidence-summary.js";
import type { QaSeedScenarioWithSource } from "./scenario-catalog.js";
import { createTempDirHarness } from "./temp-dir.test-helper.js";
import { runQaScenarioCommandLifecycle } from "./test-file-scenario-command-lifecycle.js";
import { dockerE2eLaneName } from "./test-file-scenario-docker-batch.js";
import {
dockerE2eLaneName,
prepareDockerE2eEnvironment,
} from "./test-file-scenario-docker-batch.js";
import {
qaTestFileScenarioRunnerTesting,
runQaTestFileScenarios,
@@ -98,6 +101,18 @@ function makeDockerE2eScenario(id: string, lane: string): QaSeedScenarioWithSour
};
}
async function writeDockerCandidateManifest(
command: QaScenarioCommandExecution,
manifest: unknown,
) {
const manifestArg = command.args.find((arg) => arg.startsWith("--prepare-only="));
if (!manifestArg) {
throw new Error("missing prep-only manifest argument");
}
await fs.writeFile(manifestArg.slice("--prepare-only=".length), `${JSON.stringify(manifest)}\n`);
return { exitCode: 0, stdout: "", stderr: "" };
}
it("only batches the canonical Docker lane argument shape", () => {
const scenario = makeDockerE2eScenario("docker-lane", "gateway-network");
if (scenario.execution.kind !== "script") {
@@ -112,6 +127,118 @@ it("only batches the canonical Docker lane argument shape", () => {
).toBeUndefined();
});
it("prepares the exact Docker lane union in a sanitized bound environment", async () => {
const repoRoot = await makeTempRepo("qa-docker-candidate-");
const outputDir = path.join(repoRoot, "out");
const packagePath = path.join(repoRoot, "openclaw.tgz");
const registryDir = path.join(repoRoot, "registry");
const runCommand = vi.fn(async (command: QaScenarioCommandExecution) => {
expect(command.env).toMatchObject({
KEEP_ME: "yes",
OPENCLAW_DOCKER_ALL_LANES: "gateway-network,openai-chat-tools",
OPENCLAW_DOCKER_E2E_REPO_ROOT: repoRoot,
});
expect(command.env).not.toHaveProperty("OPENCLAW_DOCKER_ALL_BUILD");
expect(command.env).not.toHaveProperty("OPENCLAW_CURRENT_PACKAGE_TGZ");
expect(command.env).not.toHaveProperty("OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR");
return await writeDockerCandidateManifest(command, {
schema: "openclaw.qa-docker-candidate/v1",
schemaVersion: 1,
sourceSha: "a".repeat(40),
candidate: {
package: {
path: packagePath,
name: "openclaw",
version: "2026.8.1",
sha256: "b".repeat(64),
},
registry: {
dir: registryDir,
candidateVersion: "2026.8.1",
manifestSha256: "c".repeat(64),
},
},
});
});
const env = await prepareDockerE2eEnvironment({
env: {
KEEP_ME: "yes",
OPENCLAW_DOCKER_ALL_BUILD: "1",
OPENCLAW_CURRENT_PACKAGE_TGZ: "/stale.tgz",
OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR: "/stale-registry",
},
outputDir,
repoRoot,
runCommand,
scenarios: [
makeDockerE2eScenario("one", "gateway-network"),
makeDockerE2eScenario("duplicate", "gateway-network"),
makeDockerE2eScenario("two", "openai-chat-tools"),
],
});
expect(runCommand).toHaveBeenCalledTimes(1);
expect(Object.isFrozen(env)).toBe(true);
expect(env).toEqual({
KEEP_ME: "yes",
OPENCLAW_DOCKER_E2E_REPO_ROOT: repoRoot,
OPENCLAW_DOCKER_E2E_SELECTED_SHA: "a".repeat(40),
OPENCLAW_CURRENT_PACKAGE_TGZ: packagePath,
OPENCLAW_CURRENT_PACKAGE_VERSION: "2026.8.1",
OPENCLAW_CURRENT_PACKAGE_SHA256: "b".repeat(64),
OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR: registryDir,
OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION: "2026.8.1",
OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256: "c".repeat(64),
});
});
it("returns a sanitized bound env for a package-free candidate", async () => {
const repoRoot = await makeTempRepo("qa-docker-candidate-null-");
const env = await prepareDockerE2eEnvironment({
env: {
KEEP_ME: "yes",
OPENCLAW_DOCKER_ALL_BUILD: "1",
OPENCLAW_CURRENT_PACKAGE_TGZ: "/stale.tgz",
},
outputDir: path.join(repoRoot, "out"),
repoRoot,
runCommand: (command) =>
writeDockerCandidateManifest(command, {
schema: "openclaw.qa-docker-candidate/v1",
schemaVersion: 1,
sourceSha: "a".repeat(40),
candidate: null,
}),
scenarios: [makeDockerE2eScenario("one", "gateway-network")],
});
expect(env).toEqual({ KEEP_ME: "yes", OPENCLAW_DOCKER_E2E_REPO_ROOT: repoRoot });
expect(Object.isFrozen(env)).toBe(true);
});
it.each([
{ label: "extra field", patch: { extra: true } },
{ label: "malformed candidate", patch: { candidate: { package: null, registry: null } } },
])("rejects a $label in the Docker candidate manifest", async ({ patch }) => {
const repoRoot = await makeTempRepo("qa-docker-candidate-invalid-");
await expect(
prepareDockerE2eEnvironment({
env: process.env,
outputDir: path.join(repoRoot, "out"),
repoRoot,
runCommand: (command) =>
writeDockerCandidateManifest(command, {
schema: "openclaw.qa-docker-candidate/v1",
schemaVersion: 1,
sourceSha: "a".repeat(40),
candidate: null,
...patch,
}),
scenarios: [makeDockerE2eScenario("one", "gateway-network")],
}),
).rejects.toThrow();
});
async function makeTempRepo(prefix: string) {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempRoots.push(repoRoot);
@@ -224,6 +351,7 @@ async function writeScriptProducerEvidence(params: {
describe("qa test file scenario runner", () => {
afterEach(async () => {
vi.unstubAllEnvs();
qaTestFileScenarioRunnerTesting.resetTimeoutCleanupTimings();
await Promise.all([
cleanupTempDirs(),
@@ -231,6 +359,70 @@ describe("qa test file scenario runner", () => {
]);
});
it.each([
{ label: "package", candidate: "package" as const },
{ label: "package-free", candidate: "none" as const },
])("keeps hostile inherited Docker state out of a prepared $label run", async ({ candidate }) => {
const repoRoot = await makeTempRepo("qa-docker-replace-env-");
const packagePath = path.join(repoRoot, "openclaw.tgz");
vi.stubEnv("OPENCLAW_DOCKER_ALL_POISON", "hostile");
vi.stubEnv("OPENCLAW_CURRENT_PACKAGE_TGZ", "/hostile.tgz");
vi.stubEnv("OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR", "/hostile-registry");
const prepared = await prepareDockerE2eEnvironment({
env: process.env,
outputDir: path.join(repoRoot, "prep"),
repoRoot,
runCommand: (command) =>
writeDockerCandidateManifest(command, {
schema: "openclaw.qa-docker-candidate/v1",
schemaVersion: 1,
sourceSha: "a".repeat(40),
candidate:
candidate === "package"
? {
package: {
path: packagePath,
name: "openclaw",
version: "2026.8.1",
sha256: "b".repeat(64),
},
registry: null,
}
: null,
}),
scenarios: [makeDockerE2eScenario("one", "gateway-network")],
});
await runQaTestFileScenarios({
env: prepared,
envMode: "replace",
outputDir: path.join(repoRoot, "run"),
primaryModel: "mock-openai/gpt-5.6-luna",
providerMode: "mock-openai",
repoRoot,
scenarios: [makeDockerE2eScenario("one", "gateway-network")],
runCommand: async (command) => {
expect(command.env.OPENCLAW_DOCKER_ALL_POISON).toBeUndefined();
expect(command.env.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR).toBeUndefined();
expect(command.env.OPENCLAW_CURRENT_PACKAGE_TGZ).toBe(
candidate === "package" ? packagePath : undefined,
);
expect(command.env.OPENCLAW_DOCKER_E2E_REPO_ROOT).toBe(repoRoot);
const logDir = command.env.OPENCLAW_DOCKER_ALL_LOG_DIR!;
await fs.mkdir(logDir, { recursive: true });
await fs.writeFile(
path.join(logDir, "summary.json"),
JSON.stringify({
failures: [],
lanes: [{ elapsedSeconds: 1, name: "gateway-network", status: 0 }],
selectedLanes: ["gateway-network"],
}),
);
return { exitCode: 0, stdout: "", stderr: "" };
},
});
});
it("runs Playwright scenarios with the repo UI e2e command and writes Playwright evidence", async () => {
const repoRoot = await makeTempRepo("qa-playwright-scenario-");
const commands: QaScenarioCommandExecution[] = [];
@@ -49,6 +49,7 @@ type QaTestFileScenarioRunParams = {
commandTimeoutMs?: number;
evidenceMode?: QaScorecardEvidenceMode;
env?: NodeJS.ProcessEnv;
envMode?: "replace";
failFast?: boolean;
outputDir: string;
primaryModel: string;
@@ -570,10 +571,7 @@ export async function runQaTestFileScenarios(
params.commandTimeoutMs,
DEFAULT_QA_TEST_FILE_COMMAND_TIMEOUT_MS,
);
const env = {
...process.env,
...params.env,
};
const env = params.envMode === "replace" ? (params.env ?? {}) : { ...process.env, ...params.env };
const results: QaTestFileScenarioResult[] = [];
const dockerBatchScenarios =
kind === "script" && !params.failFast ? scenarios.filter(isDockerE2eScenario) : [];
@@ -78,6 +78,9 @@ function readTarballPackageJson(tarball) {
}
}
export function inspectNpmPackageTarball(tarball) {
return { packageJson: readTarballPackageJson(tarball), sha256: sha256File(tarball) };
}
function validateManifestShape(manifest) {
if (
!manifest ||
+155 -23
View File
@@ -2,7 +2,8 @@
// Builds shared Docker images, prepares one OpenClaw npm tarball, assigns lanes
// to bare/functional images, and runs lanes through weighted resource pools.
import { spawn, type ChildProcess } from "node:child_process";
import assert from "node:assert/strict";
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
import fs from "node:fs";
import { mkdir, open, readFile } from "node:fs/promises";
import path from "node:path";
@@ -31,7 +32,11 @@ import {
} from "./lib/docker-e2e-plan.mts";
import type { DockerE2eLane } from "./lib/docker-e2e-scenarios.mts";
import { sleep } from "./lib/sleep.mjs";
import { validatePrepublishPluginRegistryArtifact } from "./prepublish-plugin-registry-artifact.mjs";
import {
createPrepublishPluginRegistryArtifact,
inspectNpmPackageTarball,
validatePrepublishPluginRegistryArtifact,
} from "./prepublish-plugin-registry-artifact.mjs";
const SCRIPT_ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const ROOT_DIR = path.resolve(process.env.OPENCLAW_DOCKER_E2E_REPO_ROOT || SCRIPT_ROOT_DIR);
@@ -53,8 +58,17 @@ const SHELL_PROCESS_GROUP_EXIT_POLL_MS = 25;
const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
const DEFAULT_TIMINGS_FILE = path.join(ROOT_DIR, ".artifacts/docker-tests/lane-timings.json");
const DEFAULT_GITHUB_WORKFLOW = "openclaw-live-and-e2e-checks-reusable.yml";
const CANDIDATE_ENV_KEYS =
"OPENCLAW_DOCKER_E2E_SELECTED_SHA OPENCLAW_CURRENT_PACKAGE_TGZ OPENCLAW_CURRENT_PACKAGE_VERSION OPENCLAW_CURRENT_PACKAGE_SHA256".split(
" ",
);
const REGISTRY_ENV_KEYS =
"OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256".split(
" ",
);
type SchedulerLimits = ReturnType<typeof parseSchedulerOptions>;
type DockerCandidatePlan = ReturnType<typeof resolveDockerE2ePlan>["plan"];
type SchedulerActiveState = {
count: number;
@@ -135,10 +149,11 @@ const IS_MAIN = (() => {
function dockerAllUsage() {
return [
"Usage: node scripts/test-docker-all.mjs [--plan-json]",
"Usage: node scripts/test-docker-all.mjs [--plan-json | --prepare-only=<manifest>]",
"",
"Options:",
" --plan-json Print the resolved Docker E2E plan as JSON and exit.",
" --prepare-only Prepare one immutable candidate manifest and exit.",
" -h, --help Show this help.",
"",
"Lane selection and scheduler settings are configured with OPENCLAW_DOCKER_ALL_* env vars.",
@@ -146,23 +161,29 @@ function dockerAllUsage() {
}
export function parseDockerAllCliArgs(argv: readonly string[]) {
const options = {
const options: { help: boolean; planJson: boolean; prepareOnly?: string } = {
help: false,
planJson: false,
};
for (const arg of argv) {
if (arg === "--plan-json") {
options.planJson = true;
} else if (arg.startsWith("--prepare-only=")) {
options.prepareOnly = arg.slice("--prepare-only=".length);
if (!options.prepareOnly) {
throw new Error(`--prepare-only requires a manifest path\n\n${dockerAllUsage()}`);
}
} else if (arg === "--help" || arg === "-h") {
options.help = true;
} else {
throw new Error(`unknown argument: ${arg}\n\n${dockerAllUsage()}`);
}
}
assert(!(options.planJson && options.prepareOnly), "conflicting plan/prep options");
return options;
}
let cliOptions = {
let cliOptions: ReturnType<typeof parseDockerAllCliArgs> = {
help: false,
planJson: false,
};
@@ -361,6 +382,75 @@ function commandEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
return env;
}
function gitOutput(repoRoot: string, args: string[]) {
return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" }).trim();
}
function rootPackageVersion(repoRoot: string) {
return JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version as string;
}
function readCompleteTuple(env: NodeJS.ProcessEnv, keys: readonly string[], label: string) {
const entries = keys.flatMap((key) => (env[key] ? [[key, env[key]]] : []));
const complete = entries.length === keys.length;
assert(!entries.length || complete, `${label} fields must be complete`);
return complete ? Object.fromEntries(entries) : undefined;
}
function validateRegistryEnvironment(baseEnv: NodeJS.ProcessEnv, plan: DockerCandidatePlan) {
validatePrepublishPluginRegistryArtifact({
artifactDir: baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR!,
expectedCandidateVersion: baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION!,
expectedManifestSha256: baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256!,
expectedSourceSha: baseEnv.OPENCLAW_DOCKER_E2E_SELECTED_SHA!,
requiredPackages: plan.requiredPrepublishPluginPackages,
});
}
export function validateDockerCandidateEnvironment(
baseEnv: NodeJS.ProcessEnv,
plan: DockerCandidatePlan,
repoRoot = ROOT_DIR,
) {
const strictCandidate = CANDIDATE_ENV_KEYS.slice(2).some((key) => baseEnv[key]);
if (!strictCandidate || !plan.needs.package) {
baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ &&= path.resolve(baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ);
const registryDir = baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR;
if (!plan.needs.prepublishPluginRegistry || !registryDir) {
delete baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR;
return;
}
baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR = path.resolve(registryDir);
validateRegistryEnvironment(baseEnv, plan);
return;
}
const candidate = readCompleteTuple(baseEnv, CANDIDATE_ENV_KEYS, "Docker candidate")!;
const registry = readCompleteTuple(baseEnv, REGISTRY_ENV_KEYS, "Docker candidate registry");
const packagePath = candidate.OPENCLAW_CURRENT_PACKAGE_TGZ;
if (
!path.isAbsolute(packagePath) ||
gitOutput(repoRoot, ["rev-parse", "HEAD"]) !== candidate.OPENCLAW_DOCKER_E2E_SELECTED_SHA
) {
throw new Error("Docker candidate path must be absolute and selected SHA must equal HEAD");
}
const packed = inspectNpmPackageTarball(packagePath);
if (
packed.packageJson.name !== "openclaw" ||
packed.packageJson.version !== rootPackageVersion(repoRoot) ||
packed.packageJson.version !== candidate.OPENCLAW_CURRENT_PACKAGE_VERSION ||
packed.sha256 !== candidate.OPENCLAW_CURRENT_PACKAGE_SHA256
) {
throw new Error("Docker candidate package identity differs from the immutable tuple");
}
if (registry) {
const registryDir = registry.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR;
assert(path.isAbsolute(registryDir), "Docker candidate registry path must be absolute");
validateRegistryEnvironment(baseEnv, plan);
} else if (plan.needs.prepublishPluginRegistry) {
throw new Error("Docker plan requires a prepublish plugin registry tuple");
}
}
function shellQuote(value: string) {
return `'${value.replaceAll("'", "'\\''")}'`;
}
@@ -431,7 +521,7 @@ export function githubWorkflowRerunCommand(
return fields.join(" ");
}
function buildLaneRerunCommand(name: string, baseEnv: NodeJS.ProcessEnv) {
export function buildLaneRerunCommand(name: string, baseEnv: NodeJS.ProcessEnv) {
const poolLane = findLaneByName(name);
const build = name.startsWith("live-") ? "1" : "0";
const image = poolLane ? e2eImageForLane(poolLane, baseEnv) : baseEnv.OPENCLAW_DOCKER_E2E_IMAGE;
@@ -443,8 +533,8 @@ function buildLaneRerunCommand(name: string, baseEnv: NodeJS.ProcessEnv) {
["OPENCLAW_DOCKER_E2E_IMAGE", image || DEFAULT_E2E_FUNCTIONAL_IMAGE],
["OPENCLAW_DOCKER_E2E_BARE_IMAGE", baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE],
["OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE", baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE],
["OPENCLAW_CURRENT_PACKAGE_TGZ", baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ],
["OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR", baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR],
...CANDIDATE_ENV_KEYS.map((key) => [key, baseEnv[key]] as const),
...REGISTRY_ENV_KEYS.map((key) => [key, baseEnv[key]] as const),
["OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC],
["OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS],
["OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS],
@@ -1101,7 +1191,7 @@ async function prepareOpenClawPackage(baseEnv: NodeJS.ProcessEnv, logDir: string
const packageTgz = path.join(packDir, "openclaw-current.tgz");
await runForeground(
"Prepare OpenClaw package once",
`node scripts/package-openclaw-for-docker.mjs --allow-unreleased-changelog --output-dir ${shellQuote(packDir)} --output-name openclaw-current.tgz`,
`node ${shellQuote(path.join(ROOT_DIR, "scripts/package-openclaw-for-docker.mjs"))} --source-dir ${shellQuote(ROOT_DIR)} --allow-unreleased-changelog --output-dir ${shellQuote(packDir)} --output-name openclaw-current.tgz`,
baseEnv,
);
await fs.promises.access(packageTgz);
@@ -1114,6 +1204,57 @@ async function prepareOpenClawPackage(baseEnv: NodeJS.ProcessEnv, logDir: string
console.log(`==> OpenClaw package: ${baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ}`);
}
async function prepareDockerCandidate(
plan: DockerCandidatePlan,
logDir: string,
manifestPath: string,
) {
const sourceSha = gitOutput(ROOT_DIR, ["rev-parse", "HEAD"]);
let candidate = null;
if (plan.needs.package) {
if (gitOutput(ROOT_DIR, ["status", "--porcelain=v1"])) {
throw new Error("repository has working-tree changes; refusing to prepare Docker candidate");
}
const candidateEnv = commandEnv();
for (const key of [...CANDIDATE_ENV_KEYS, ...REGISTRY_ENV_KEYS]) {
delete candidateEnv[key];
}
await prepareOpenClawPackage(candidateEnv, logDir);
const packagePath = candidateEnv.OPENCLAW_CURRENT_PACKAGE_TGZ!;
const packed = inspectNpmPackageTarball(packagePath);
const version = rootPackageVersion(ROOT_DIR);
if (packed.packageJson.name !== "openclaw" || packed.packageJson.version !== version) {
throw new Error("packed Docker candidate name or version differs from the root package");
}
let registry = null;
if (plan.needs.prepublishPluginRegistry) {
const registryDir = path.join(logDir, "prepublish-plugin-registry");
fs.rmSync(registryDir, { force: true, recursive: true });
const artifact = createPrepublishPluginRegistryArtifact({
repoRoot: ROOT_DIR,
outputDir: registryDir,
sourceSha,
candidateVersion: version,
requiredPackages: plan.requiredPrepublishPluginPackages,
});
registry = {
dir: registryDir,
candidateVersion: version,
manifestSha256: artifact.manifestSha256,
};
}
candidate = {
package: { path: packagePath, name: packed.packageJson.name, version, sha256: packed.sha256 },
registry,
};
}
await mkdir(path.dirname(manifestPath), { recursive: true });
fs.writeFileSync(
manifestPath,
`${JSON.stringify({ schema: "openclaw.qa-docker-candidate/v1", schemaVersion: 1, sourceSha, candidate }, null, 2)}\n`,
);
}
function e2eImageForLane(poolLane: DockerE2eLane, baseEnv: NodeJS.ProcessEnv) {
if (poolLane.e2eImageKind === "bare") {
return baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE;
@@ -1664,20 +1805,6 @@ async function main() {
allowFrozenTargetScenarioOmissions,
candidatePackageRoot: ROOT_DIR,
});
const prepublishPluginRegistryDir = process.env.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR;
if (plan.needs.prepublishPluginRegistry && prepublishPluginRegistryDir) {
baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR = path.resolve(prepublishPluginRegistryDir);
validatePrepublishPluginRegistryArtifact({
artifactDir: baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR,
expectedCandidateVersion:
process.env.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION ?? "",
expectedManifestSha256: process.env.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256 ?? "",
expectedSourceSha: process.env.OPENCLAW_DOCKER_E2E_SELECTED_SHA ?? "",
requiredPackages: plan.requiredPrepublishPluginPackages,
});
} else {
delete baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR;
}
if (omittedUnsupportedLaneNames.length > 0 && !allowFrozenTargetScenarioOmissions) {
throw new Error(
`frozen target scenario omissions require trusted workflow opt-in: ${omittedUnsupportedLaneNames.join(", ")}`,
@@ -1703,6 +1830,10 @@ async function main() {
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
return;
}
if (cliOptions.prepareOnly) {
await prepareDockerCandidate(plan, logDir, path.resolve(cliOptions.prepareOnly));
return;
}
await mkdir(logDir, { recursive: true });
console.log(`==> Docker test logs: ${logDir}`);
@@ -1754,6 +1885,7 @@ async function main() {
console.log("==> Dry run complete");
return;
}
validateDockerCandidateEnvironment(baseEnv, plan);
// Planning can report unsupported scenarios, but execution cannot pass when
// frozen-target omissions leave no selected lane to run.
+367 -3
View File
@@ -1,5 +1,6 @@
// Docker All Scheduler tests cover docker all scheduler script behavior.
import { spawn, spawnSync } from "node:child_process";
import { execFileSync, spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmodSync,
copyFileSync,
@@ -19,6 +20,7 @@ import { parse } from "yaml";
import { DEFAULT_RESOURCE_LIMITS } from "../../scripts/lib/docker-e2e-plan.mts";
import {
appendBoundedShellCapture,
buildLaneRerunCommand,
canStartSchedulerLane,
describeDockerSchedulerLimits,
dockerPreflightContainerNames,
@@ -32,6 +34,7 @@ import {
runShellCommand,
SHELL_CAPTURE_MAX_CHARS,
tailFile,
validateDockerCandidateEnvironment,
writeRunSummary,
} from "../../scripts/test-docker-all.mts";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
@@ -48,6 +51,39 @@ const posixIt = process.platform === "win32" ? it.skip : it;
const { createTempDir } = createScriptTestHarness();
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const LIVE_E2E_WORKFLOW = ".github/workflows/openclaw-live-and-e2e-checks-reusable.yml";
type DockerCandidatePlan = Parameters<typeof validateDockerCandidateEnvironment>[1];
function candidatePlan({
needsPackage = true,
requiredPackages = [],
}: {
needsPackage?: boolean;
requiredPackages?: string[];
} = {}): DockerCandidatePlan {
return {
chunk: undefined,
credentials: [],
imageKinds: [],
includeOpenWebUI: false,
lanes: [],
mainLanes: [],
needs: {
bareImage: false,
e2eImage: needsPackage,
functionalImage: false,
liveImage: false,
package: needsPackage,
prepublishPluginRegistry: requiredPackages.length > 0,
},
omittedUnsupportedLanes: [],
profile: "all",
releaseProfile: "full",
requiredPrepublishPluginPackages: requiredPackages,
selectedLanes: [],
tailLanes: [],
version: 1,
};
}
function writeFrozenScenarioContract(root: string, scenarios: string[]): string {
const assertionsFile = path.join(root, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
@@ -89,6 +125,137 @@ function activePool({
};
}
function sha256(file: string) {
return createHash("sha256").update(readFileSync(file)).digest("hex");
}
function writePackageTarball(
root: string,
name: string,
version: string,
fileName = "openclaw.tgz",
) {
const packageRoot = path.join(root, `package-${fileName}`);
const packageDir = path.join(packageRoot, "package");
mkdirSync(packageDir, { recursive: true });
writeFileSync(path.join(packageDir, "package.json"), JSON.stringify({ name, version }));
const tarball = path.join(root, fileName);
execFileSync("tar", ["-czf", tarball, "-C", packageRoot, "package"]);
return tarball;
}
function candidateFixture(packageName = "openclaw", packageVersion = "2026.8.1") {
const root = tempDirs.make("openclaw-docker-candidate-");
const version = "2026.8.1";
const packagePath = writePackageTarball(
tempDirs.make("openclaw-docker-package-"),
packageName,
packageVersion,
);
writeFileSync(
path.join(root, "package.json"),
JSON.stringify({
name: "openclaw",
version,
scripts: { "test:docker:gateway-network": "true" },
}),
);
writeFakePackScript(root, packagePath);
execFileSync("git", ["init", "-q"], { cwd: root });
execFileSync("git", ["add", "package.json", "scripts/package-openclaw-for-docker.mjs"], {
cwd: root,
});
execFileSync(
"git",
["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "fixture"],
{ cwd: root },
);
const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: root,
encoding: "utf8",
}).trim();
return {
root,
sourceSha,
version,
packagePath,
env: {
OPENCLAW_DOCKER_E2E_SELECTED_SHA: sourceSha,
OPENCLAW_CURRENT_PACKAGE_TGZ: packagePath,
OPENCLAW_CURRENT_PACKAGE_VERSION: version,
OPENCLAW_CURRENT_PACKAGE_SHA256: sha256(packagePath),
},
};
}
function writeFakePackScript(root: string, sourceTarball: string) {
const script = path.join(root, "scripts/package-openclaw-for-docker.mjs");
mkdirSync(path.dirname(script), { recursive: true });
writeFileSync(
script,
`import fs from "node:fs"; import path from "node:path";
const outputDir = process.argv[process.argv.indexOf("--output-dir") + 1];
const outputName = process.argv[process.argv.indexOf("--output-name") + 1];
fs.mkdirSync(outputDir, { recursive: true });
fs.copyFileSync(${JSON.stringify(sourceTarball)}, path.join(outputDir, outputName));\n`,
);
}
function runCandidatePrep(fixture: ReturnType<typeof candidateFixture>) {
const manifestPath = path.join(fixture.root, "candidate.json");
const result = spawnSync(
process.execPath,
["scripts/test-docker-all.mjs", `--prepare-only=${manifestPath}`],
{
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
OPENCLAW_DOCKER_ALL_LANES: "gateway-network",
OPENCLAW_DOCKER_ALL_LOG_DIR: path.join(fixture.root, "logs"),
OPENCLAW_DOCKER_ALL_TIMINGS: "0",
OPENCLAW_DOCKER_E2E_REPO_ROOT: fixture.root,
},
},
);
return { manifestPath, result };
}
function addRegistry(
fixture: ReturnType<typeof candidateFixture>,
packageNames = ["@openclaw/discord", "@openclaw/feishu"],
) {
const registryDir = path.join(fixture.root, "registry");
mkdirSync(registryDir);
const packages = packageNames.toSorted().map((name, index) => {
const tarball = `plugin-${String(index)}.tgz`;
const tarballPath = path.join(registryDir, tarball);
copyFileSync(writePackageTarball(fixture.root, name, fixture.version, tarball), tarballPath);
return { name, version: fixture.version, tarball, sha256: sha256(tarballPath) };
});
const manifestPath = path.join(registryDir, "prepublish-plugin-registry.json");
writeFileSync(
manifestPath,
`${JSON.stringify(
{
schema: "openclaw.prepublish-plugin-registry/v1",
schemaVersion: 1,
sourceSha: fixture.sourceSha,
candidateVersion: fixture.version,
packages,
},
null,
2,
)}\n`,
);
return {
...fixture.env,
OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR: registryDir,
OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION: fixture.version,
OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256: sha256(manifestPath),
};
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
@@ -137,6 +304,14 @@ describe("scripts/test-docker-all scheduler", () => {
help: true,
planJson: false,
});
expect(parseDockerAllCliArgs(["--prepare-only=/tmp/candidate.json"])).toEqual({
help: false,
planJson: false,
prepareOnly: "/tmp/candidate.json",
});
expect(() =>
parseDockerAllCliArgs(["--plan-json", "--prepare-only=/tmp/candidate.json"]),
).toThrow("conflicting plan/prep options");
});
it("prints CLI help without a stack trace", () => {
@@ -147,7 +322,7 @@ describe("scripts/test-docker-all scheduler", () => {
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout).toContain("Usage: node scripts/test-docker-all.mjs [--plan-json]");
expect(result.stdout).toContain("--prepare-only=<manifest>");
expect(result.stdout).toContain("OPENCLAW_DOCKER_ALL_* env vars");
});
@@ -160,10 +335,199 @@ describe("scripts/test-docker-all scheduler", () => {
expect(result.status).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("unknown argument: --bogus");
expect(result.stderr).toContain("Usage: node scripts/test-docker-all.mjs [--plan-json]");
expect(result.stderr).toContain("--prepare-only=<manifest>");
expect(result.stderr).not.toContain("at ");
});
it("writes a package-free prep-only manifest without Docker work", () => {
const root = tempDirs.make("openclaw-docker-package-free-");
const manifestPath = path.join(root, "candidate.json");
const result = spawnSync(
process.execPath,
["scripts/test-docker-all.mjs", `--prepare-only=${manifestPath}`],
{
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
OPENCLAW_DOCKER_ALL_LANES: "live-gateway",
OPENCLAW_DOCKER_ALL_LOG_DIR: path.join(root, "logs"),
OPENCLAW_DOCKER_ALL_TIMINGS: "0",
},
},
);
expect(result.status, result.stderr).toBe(0);
expect(JSON.parse(readFileSync(manifestPath, "utf8"))).toMatchObject({
schemaVersion: 1,
candidate: null,
});
expect(result.stdout).not.toContain("Docker preflight");
expect(result.stdout).not.toContain("Build shared Docker images");
});
it("prepares one immutable package candidate before Docker work and rejects dirty source", () => {
const fixture = candidateFixture();
const { manifestPath, result } = runCandidatePrep(fixture);
expect(result.status, result.stderr).toBe(0);
expect(JSON.parse(readFileSync(manifestPath, "utf8"))).toMatchObject({
sourceSha: fixture.sourceSha,
candidate: {
package: {
name: "openclaw",
version: fixture.version,
sha256: sha256(fixture.packagePath),
},
registry: null,
},
});
expect(result.stdout).not.toContain("Docker preflight");
expect(result.stdout).not.toContain("Build shared Docker images");
writeFileSync(
path.join(fixture.root, "package.json"),
JSON.stringify({
name: "openclaw",
version: "dirty",
scripts: { "test:docker:gateway-network": "true" },
}),
);
const dirty = runCandidatePrep(fixture).result;
expect(dirty.status).toBe(1);
expect(dirty.stderr).toContain("working-tree changes");
});
it("rejects untracked source before package preparation", () => {
const fixture = candidateFixture();
writeFileSync(path.join(fixture.root, "untracked-source.ts"), "export {};\n");
const result = runCandidatePrep(fixture).result;
expect(result.status).toBe(1);
expect(result.stderr).toContain("working-tree changes");
});
it.each([
{ name: "wrong-name", packageName: "not-openclaw", version: "2026.8.1" },
{ name: "wrong-version", packageName: "openclaw", version: "0.0.0" },
])("rejects a $name packed candidate", ({ packageName, version }) => {
const fixture = candidateFixture(packageName, version);
const result = runCandidatePrep(fixture).result;
expect(result.status).toBe(1);
expect(result.stderr).toContain("name or version");
});
it("validates complete candidate fields against HEAD and tarball bytes", () => {
const fixture = candidateFixture();
const plan = candidatePlan();
expect(() => validateDockerCandidateEnvironment({}, plan, fixture.root)).not.toThrow();
expect(() => validateDockerCandidateEnvironment(fixture.env, plan, fixture.root)).not.toThrow();
expect(() =>
validateDockerCandidateEnvironment(
{ OPENCLAW_CURRENT_PACKAGE_TGZ: fixture.packagePath },
plan,
fixture.root,
),
).not.toThrow();
for (const field of [
"OPENCLAW_CURRENT_PACKAGE_VERSION",
"OPENCLAW_CURRENT_PACKAGE_SHA256",
] as const) {
const env: NodeJS.ProcessEnv = { ...fixture.env };
delete env[field];
expect(() => validateDockerCandidateEnvironment(env, plan, fixture.root)).toThrow(
"must be complete",
);
}
for (const env of [
{ ...fixture.env, OPENCLAW_CURRENT_PACKAGE_TGZ: "relative.tgz" },
{ ...fixture.env, OPENCLAW_DOCKER_E2E_SELECTED_SHA: "a".repeat(40) },
{ ...fixture.env, OPENCLAW_CURRENT_PACKAGE_SHA256: "b".repeat(64) },
{ ...fixture.env, OPENCLAW_CURRENT_PACKAGE_VERSION: "0.0.0" },
]) {
expect(() => validateDockerCandidateEnvironment(env, plan, fixture.root)).toThrow();
}
});
it("preserves legacy release package and registry inputs", () => {
const fixture = candidateFixture();
const registryDir = path.join(fixture.root, "registry");
const env: NodeJS.ProcessEnv = addRegistry(fixture);
delete env.OPENCLAW_CURRENT_PACKAGE_VERSION;
delete env.OPENCLAW_CURRENT_PACKAGE_SHA256;
env.OPENCLAW_CURRENT_PACKAGE_TGZ = path.relative(process.cwd(), fixture.packagePath);
env.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR = path.relative(process.cwd(), registryDir);
expect(() =>
validateDockerCandidateEnvironment(
env,
candidatePlan({ requiredPackages: ["@openclaw/discord"] }),
fixture.root,
),
).not.toThrow();
expect(env.OPENCLAW_CURRENT_PACKAGE_TGZ).toBe(fixture.packagePath);
expect(env.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR).toBe(registryDir);
});
it("does not inspect package files for package-free plans", () => {
const fixture = candidateFixture();
expect(() =>
validateDockerCandidateEnvironment(
{ ...fixture.env, OPENCLAW_CURRENT_PACKAGE_TGZ: path.join(fixture.root, "missing.tgz") },
candidatePlan({ needsPackage: false }),
fixture.root,
),
).not.toThrow();
});
it("validates complete registry tuples as plan-specific subsets", () => {
const fixture = candidateFixture();
const env = addRegistry(fixture);
expect(() =>
validateDockerCandidateEnvironment(
env,
candidatePlan({ requiredPackages: ["@openclaw/discord"] }),
fixture.root,
),
).not.toThrow();
expect(() =>
validateDockerCandidateEnvironment(env, candidatePlan(), fixture.root),
).not.toThrow();
expect(() =>
validateDockerCandidateEnvironment(
fixture.env,
candidatePlan({ requiredPackages: ["@openclaw/discord"] }),
fixture.root,
),
).toThrow("requires a prepublish plugin registry tuple");
expect(() =>
validateDockerCandidateEnvironment(
{ ...fixture.env, OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR: "/tmp/partial" },
candidatePlan(),
fixture.root,
),
).toThrow("must be complete");
writeFileSync(path.join(env.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR, "extra"), "extra");
expect(() => validateDockerCandidateEnvironment(env, candidatePlan(), fixture.root)).toThrow(
"missing, extra, or non-file",
);
});
it("serializes complete candidate and registry tuples in lane reruns", () => {
const fixture = candidateFixture();
const env = addRegistry(fixture);
const command = buildLaneRerunCommand("gateway-network", env);
for (const key of [
"OPENCLAW_DOCKER_E2E_SELECTED_SHA",
"OPENCLAW_CURRENT_PACKAGE_TGZ",
"OPENCLAW_CURRENT_PACKAGE_VERSION",
"OPENCLAW_CURRENT_PACKAGE_SHA256",
"OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR",
"OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION",
"OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256",
] as const) {
expect(command).toContain(`${key}='${env[key]}'`);
}
});
it("plans from an isolated release harness with source-checkout TypeScript support", () => {
const artifactRoot = path.resolve(".artifacts");
mkdirSync(artifactRoot, { recursive: true });