mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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 }),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user