diff --git a/extensions/qa-lab/src/suite-launch.runtime.test.ts b/extensions/qa-lab/src/suite-launch.runtime.test.ts index 880968c9761f..0ac53e5dfea5 100644 --- a/extensions/qa-lab/src/suite-launch.runtime.test.ts +++ b/extensions/qa-lab/src/suite-launch.runtime.test.ts @@ -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()), + 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 }), ); diff --git a/extensions/qa-lab/src/suite-launch.runtime.ts b/extensions/qa-lab/src/suite-launch.runtime.ts index 1fa4d311f988..7523c5819f70 100644 --- a/extensions/qa-lab/src/suite-launch.runtime.ts +++ b/extensions/qa-lab/src/suite-launch.runtime.ts @@ -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 { @@ -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(); + let preparedScriptEnv: Readonly | 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, 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, ]; diff --git a/extensions/qa-lab/src/test-file-scenario-docker-batch.ts b/extensions/qa-lab/src/test-file-scenario-docker-batch.ts index e1b922e5b035..41feeb93e4b0 100644 --- a/extensions/qa-lab/src/test-file-scenario-docker-batch.ts +++ b/extensions/qa-lab/src/test-file-scenario-docker-batch.ts @@ -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; @@ -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 | 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; + runCommand: typeof runQaScenarioCommandLifecycle; scenarios: readonly QaDockerScenario[]; }): Promise { 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>; try { commandResult = await params.runCommand({ command: process.execPath, diff --git a/extensions/qa-lab/src/test-file-scenario-runner.test.ts b/extensions/qa-lab/src/test-file-scenario-runner.test.ts index 8baac9a69ea7..5fabc9f87521 100644 --- a/extensions/qa-lab/src/test-file-scenario-runner.test.ts +++ b/extensions/qa-lab/src/test-file-scenario-runner.test.ts @@ -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[] = []; diff --git a/extensions/qa-lab/src/test-file-scenario-runner.ts b/extensions/qa-lab/src/test-file-scenario-runner.ts index e328c88ab5e0..9b23dca9ec03 100644 --- a/extensions/qa-lab/src/test-file-scenario-runner.ts +++ b/extensions/qa-lab/src/test-file-scenario-runner.ts @@ -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) : []; diff --git a/scripts/prepublish-plugin-registry-artifact.mjs b/scripts/prepublish-plugin-registry-artifact.mjs index c422e7cb53ed..b9b897de69ea 100644 --- a/scripts/prepublish-plugin-registry-artifact.mjs +++ b/scripts/prepublish-plugin-registry-artifact.mjs @@ -78,6 +78,9 @@ function readTarballPackageJson(tarball) { } } +export function inspectNpmPackageTarball(tarball) { + return { packageJson: readTarballPackageJson(tarball), sha256: sha256File(tarball) }; +} function validateManifestShape(manifest) { if ( !manifest || diff --git a/scripts/test-docker-all.mts b/scripts/test-docker-all.mts index 02d7508a1e0f..67af4b1a9b93 100644 --- a/scripts/test-docker-all.mts +++ b/scripts/test-docker-all.mts @@ -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; +type DockerCandidatePlan = ReturnType["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=]", "", "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 = { 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. diff --git a/test/scripts/docker-all-scheduler.test.ts b/test/scripts/docker-all-scheduler.test.ts index 24a3f0e1a613..c5d865c64755 100644 --- a/test/scripts/docker-all-scheduler.test.ts +++ b/test/scripts/docker-all-scheduler.test.ts @@ -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[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) { + 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, + 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="); 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="); 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 });