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,
|
QaTestFileScenarioRunResult,
|
||||||
} from "./test-file-scenario-runner.js";
|
} from "./test-file-scenario-runner.js";
|
||||||
|
|
||||||
const { crablineRuntimeLoads, runQaFlowSuite, runQaTestFileScenarios } = vi.hoisted(() => ({
|
const {
|
||||||
|
crablineRuntimeLoads,
|
||||||
|
prepareDockerE2eEnvironment,
|
||||||
|
runQaFlowSuite,
|
||||||
|
runQaTestFileScenarios,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
crablineRuntimeLoads: vi.fn(),
|
crablineRuntimeLoads: vi.fn(),
|
||||||
|
prepareDockerE2eEnvironment: vi.fn(),
|
||||||
runQaFlowSuite: vi.fn(),
|
runQaFlowSuite: vi.fn(),
|
||||||
runQaTestFileScenarios: vi.fn(),
|
runQaTestFileScenarios: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -32,6 +38,11 @@ vi.mock("./test-file-scenario-runner.js", async (importOriginal) => ({
|
|||||||
runQaTestFileScenarios,
|
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";
|
import { runQaSuite, runQaSuiteWithInfraRetry } from "./suite-launch.runtime.js";
|
||||||
|
|
||||||
const tempRoots: string[] = [];
|
const tempRoots: string[] = [];
|
||||||
@@ -113,6 +124,8 @@ describe("qa suite runtime launcher", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
runQaFlowSuite.mockReset();
|
runQaFlowSuite.mockReset();
|
||||||
runQaTestFileScenarios.mockReset();
|
runQaTestFileScenarios.mockReset();
|
||||||
|
prepareDockerE2eEnvironment.mockReset();
|
||||||
|
prepareDockerE2eEnvironment.mockResolvedValue(undefined);
|
||||||
runQaFlowSuite.mockImplementation(
|
runQaFlowSuite.mockImplementation(
|
||||||
async (
|
async (
|
||||||
params:
|
params:
|
||||||
@@ -1986,6 +1999,8 @@ describe("qa suite runtime launcher", () => {
|
|||||||
const serial = createDeferred();
|
const serial = createDeferred();
|
||||||
const parallel = createDeferred();
|
const parallel = createDeferred();
|
||||||
const started: string[] = [];
|
const started: string[] = [];
|
||||||
|
const preparedEnv = Object.freeze({ OPENCLAW_CURRENT_PACKAGE_TGZ: "/tmp/candidate.tgz" });
|
||||||
|
const scriptEnvs: unknown[] = [];
|
||||||
const parallelScriptIds: string[] = [];
|
const parallelScriptIds: string[] = [];
|
||||||
let activeParallelScripts = 0;
|
let activeParallelScripts = 0;
|
||||||
let maxActiveParallelScripts = 0;
|
let maxActiveParallelScripts = 0;
|
||||||
@@ -1994,6 +2009,10 @@ describe("qa suite runtime launcher", () => {
|
|||||||
await flow.promise;
|
await flow.promise;
|
||||||
return await defaultFlowImplementation(params);
|
return await defaultFlowImplementation(params);
|
||||||
});
|
});
|
||||||
|
prepareDockerE2eEnvironment.mockImplementationOnce(async () => {
|
||||||
|
started.push("prep");
|
||||||
|
return preparedEnv;
|
||||||
|
});
|
||||||
runQaTestFileScenarios.mockImplementation(async (params) => {
|
runQaTestFileScenarios.mockImplementation(async (params) => {
|
||||||
const scenarioIds = params.scenarios.map((scenario: QaTestFileScenario) => scenario.id);
|
const scenarioIds = params.scenarios.map((scenario: QaTestFileScenario) => scenario.id);
|
||||||
const kind = params.scenarios[0]?.execution.kind;
|
const kind = params.scenarios[0]?.execution.kind;
|
||||||
@@ -2001,9 +2020,11 @@ describe("qa suite runtime launcher", () => {
|
|||||||
started.push("native");
|
started.push("native");
|
||||||
await native.promise;
|
await native.promise;
|
||||||
} else if (scenarioIds.includes("docker-npm-onboard-channel-agent")) {
|
} else if (scenarioIds.includes("docker-npm-onboard-channel-agent")) {
|
||||||
|
scriptEnvs.push(params.env);
|
||||||
started.push("serial");
|
started.push("serial");
|
||||||
await serial.promise;
|
await serial.promise;
|
||||||
} else {
|
} else {
|
||||||
|
scriptEnvs.push(params.env);
|
||||||
parallelScriptIds.push(...scenarioIds);
|
parallelScriptIds.push(...scenarioIds);
|
||||||
activeParallelScripts += 1;
|
activeParallelScripts += 1;
|
||||||
maxActiveParallelScripts = Math.max(maxActiveParallelScripts, activeParallelScripts);
|
maxActiveParallelScripts = Math.max(maxActiveParallelScripts, activeParallelScripts);
|
||||||
@@ -2038,6 +2059,7 @@ describe("qa suite runtime launcher", () => {
|
|||||||
|
|
||||||
native.resolve();
|
native.resolve();
|
||||||
await vi.waitFor(() => expect(started).toContain("serial"));
|
await vi.waitFor(() => expect(started).toContain("serial"));
|
||||||
|
expect(started.slice(0, 4)).toEqual(["flow", "native", "prep", "serial"]);
|
||||||
expect(parallelScriptIds).toEqual([]);
|
expect(parallelScriptIds).toEqual([]);
|
||||||
|
|
||||||
serial.resolve();
|
serial.resolve();
|
||||||
@@ -2047,6 +2069,8 @@ describe("qa suite runtime launcher", () => {
|
|||||||
|
|
||||||
parallel.resolve();
|
parallel.resolve();
|
||||||
await runPromise;
|
await runPromise;
|
||||||
|
expect(prepareDockerE2eEnvironment).toHaveBeenCalledTimes(1);
|
||||||
|
expect(scriptEnvs.every((env) => env === preparedEnv)).toBe(true);
|
||||||
expect(parallelScriptIds.slice(0, 3)).toEqual(
|
expect(parallelScriptIds.slice(0, 3)).toEqual(
|
||||||
expect.arrayContaining(["remote-log-tailing", "gateway-smoke", "logging-file-boundary"]),
|
expect.arrayContaining(["remote-log-tailing", "gateway-smoke", "logging-file-boundary"]),
|
||||||
);
|
);
|
||||||
@@ -2054,6 +2078,68 @@ describe("qa suite runtime launcher", () => {
|
|||||||
expect(maxActiveParallelScripts).toBe(3);
|
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 () => {
|
it("keeps selected evidence order and successful siblings when a parallel script rejects", async () => {
|
||||||
const repoRoot = await makeTempRepo("qa-suite-parallel-script-rejection-");
|
const repoRoot = await makeTempRepo("qa-suite-parallel-script-rejection-");
|
||||||
const defaultTestFileImplementation = runQaTestFileScenarios.getMockImplementation();
|
const defaultTestFileImplementation = runQaTestFileScenarios.getMockImplementation();
|
||||||
@@ -2128,9 +2214,11 @@ describe("qa suite runtime launcher", () => {
|
|||||||
throw new Error("expected default QA test-file mock implementation");
|
throw new Error("expected default QA test-file mock implementation");
|
||||||
}
|
}
|
||||||
const first = createDeferred();
|
const first = createDeferred();
|
||||||
|
const preparedEnv = Object.freeze({ OPENCLAW_CURRENT_PACKAGE_TGZ: "/tmp/candidate.tgz" });
|
||||||
const started: string[] = [];
|
const started: string[] = [];
|
||||||
let active = 0;
|
let active = 0;
|
||||||
let maxActive = 0;
|
let maxActive = 0;
|
||||||
|
prepareDockerE2eEnvironment.mockResolvedValueOnce(preparedEnv);
|
||||||
runQaTestFileScenarios.mockImplementation(async (params) => {
|
runQaTestFileScenarios.mockImplementation(async (params) => {
|
||||||
const scenario = params.scenarios[0] as QaTestFileScenario | undefined;
|
const scenario = params.scenarios[0] as QaTestFileScenario | undefined;
|
||||||
if (!scenario) {
|
if (!scenario) {
|
||||||
@@ -2176,6 +2264,9 @@ describe("qa suite runtime launcher", () => {
|
|||||||
expect(started).toEqual(["remote-log-tailing", "docker-npm-onboard-channel-agent"]);
|
expect(started).toEqual(["remote-log-tailing", "docker-npm-onboard-channel-agent"]);
|
||||||
expect(maxActive).toBe(1);
|
expect(maxActive).toBe(1);
|
||||||
expect(runQaTestFileScenarios).toHaveBeenCalledTimes(2);
|
expect(runQaTestFileScenarios).toHaveBeenCalledTimes(2);
|
||||||
|
expect(runQaTestFileScenarios.mock.calls.every(([params]) => params.env === preparedEnv)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
expect(runQaTestFileScenarios).toHaveBeenLastCalledWith(
|
expect(runQaTestFileScenarios).toHaveBeenLastCalledWith(
|
||||||
expect.objectContaining({ failFast: true }),
|
expect.objectContaining({ failFast: true }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
type QaSuiteScenarioResult,
|
type QaSuiteScenarioResult,
|
||||||
type QaSuiteSummaryJson,
|
type QaSuiteSummaryJson,
|
||||||
} from "./suite.js";
|
} from "./suite.js";
|
||||||
|
import * as dockerBatch from "./test-file-scenario-docker-batch.js";
|
||||||
import {
|
import {
|
||||||
isQaTestFileScenario,
|
isQaTestFileScenario,
|
||||||
runQaTestFileScenarios,
|
runQaTestFileScenarios,
|
||||||
@@ -409,6 +410,7 @@ async function resolveSuiteExecutionPlan(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
async function runQaTestFileSuiteFromRuntime(params: {
|
async function runQaTestFileSuiteFromRuntime(params: {
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
runParams: QaSuiteRunParams | undefined;
|
runParams: QaSuiteRunParams | undefined;
|
||||||
scenarios: readonly QaTestFileScenario[];
|
scenarios: readonly QaTestFileScenario[];
|
||||||
}): Promise<QaTestFileScenarioRunResult> {
|
}): Promise<QaTestFileScenarioRunResult> {
|
||||||
@@ -428,6 +430,7 @@ async function runQaTestFileSuiteFromRuntime(params: {
|
|||||||
const primaryModel = runParams?.primaryModel?.trim() || defaultQaModelForMode(providerMode);
|
const primaryModel = runParams?.primaryModel?.trim() || defaultQaModelForMode(providerMode);
|
||||||
return await runQaTestFileScenarios({
|
return await runQaTestFileScenarios({
|
||||||
evidenceMode: runParams?.evidenceMode,
|
evidenceMode: runParams?.evidenceMode,
|
||||||
|
...(params.env ? { env: params.env, envMode: "replace" as const } : {}),
|
||||||
...(runParams?.failFast ? { failFast: true } : {}),
|
...(runParams?.failFast ? { failFast: true } : {}),
|
||||||
repoRoot,
|
repoRoot,
|
||||||
outputDir,
|
outputDir,
|
||||||
@@ -813,6 +816,7 @@ async function runUnifiedQaSuite(params: {
|
|||||||
const serialScriptPartitionTasks: QaUnifiedPartitionTask[] = [];
|
const serialScriptPartitionTasks: QaUnifiedPartitionTask[] = [];
|
||||||
const parallelScriptPartitionTasks: QaUnifiedPartitionTask[] = [];
|
const parallelScriptPartitionTasks: QaUnifiedPartitionTask[] = [];
|
||||||
const unavailableChannelCredentialDetails = new Map<string, string>();
|
const unavailableChannelCredentialDetails = new Map<string, string>();
|
||||||
|
let preparedScriptEnv: Readonly<NodeJS.ProcessEnv> | undefined;
|
||||||
if (params.plan.channelGroups.length > 0) {
|
if (params.plan.channelGroups.length > 0) {
|
||||||
const channelGroups = params.plan.channelGroups;
|
const channelGroups = params.plan.channelGroups;
|
||||||
const runFlowSuite = await loadQaFlowSuiteRuntime();
|
const runFlowSuite = await loadQaFlowSuiteRuntime();
|
||||||
@@ -1075,6 +1079,7 @@ async function runUnifiedQaSuite(params: {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const result = await runQaTestFileSuiteFromRuntime({
|
const result = await runQaTestFileSuiteFromRuntime({
|
||||||
|
env: kind === "script" ? preparedScriptEnv : undefined,
|
||||||
runParams: {
|
runParams: {
|
||||||
...params.runParams,
|
...params.runParams,
|
||||||
outputDir: suitePartitionOutputDir(outputDir, kind),
|
outputDir: suitePartitionOutputDir(outputDir, kind),
|
||||||
@@ -1263,8 +1268,9 @@ async function runUnifiedQaSuite(params: {
|
|||||||
return partition.startedScenarioIds.some((scenarioId) => !returnedScenarioIds.has(scenarioId));
|
return partition.startedScenarioIds.some((scenarioId) => !returnedScenarioIds.has(scenarioId));
|
||||||
};
|
};
|
||||||
const capturePartitionFailure = (
|
const capturePartitionFailure = (
|
||||||
task: QaUnifiedPartitionTask,
|
task: Pick<QaUnifiedPartitionTask, "channelId" | "scenarios">,
|
||||||
error: unknown,
|
error: unknown,
|
||||||
|
started = true,
|
||||||
): QaUnifiedPartitionResult => {
|
): QaUnifiedPartitionResult => {
|
||||||
const scenarios = task.scenarios;
|
const scenarios = task.scenarios;
|
||||||
const details = `suite partition failed: ${formatErrorMessage(error)}`;
|
const details = `suite partition failed: ${formatErrorMessage(error)}`;
|
||||||
@@ -1293,7 +1299,7 @@ async function runUnifiedQaSuite(params: {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
scenarioResults,
|
scenarioResults,
|
||||||
startedScenarioIds: scenarios.map((scenario) => scenario.id),
|
startedScenarioIds: started ? scenarios.map((scenario) => scenario.id) : [],
|
||||||
submittedScenarioIds: task.scenarios.map((scenario) => scenario.id),
|
submittedScenarioIds: task.scenarios.map((scenario) => scenario.id),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1319,14 +1325,33 @@ async function runUnifiedQaSuite(params: {
|
|||||||
: await runWeightedUnifiedPartitionTasks(retryingTasks, maxWeight);
|
: await runWeightedUnifiedPartitionTasks(retryingTasks, maxWeight);
|
||||||
};
|
};
|
||||||
const concurrentPartitionResults = await runPartitionTasks(concurrentPartitionTasks, concurrency);
|
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
|
// Unmarked scripts may rebuild shared checkout state. Run them exclusively
|
||||||
// after every flow and native partition settles, then start only audited peers.
|
// after every flow and native partition settles, then start only audited peers.
|
||||||
const serialScriptPartitionResults =
|
const serialScriptPartitionResults =
|
||||||
failFast && concurrentPartitionResults.some(partitionFailed)
|
concurrentFailed || scriptPreparationFailure
|
||||||
? []
|
? []
|
||||||
: await runPartitionTasks(serialScriptPartitionTasks, 1);
|
: await runPartitionTasks(serialScriptPartitionTasks, 1);
|
||||||
const parallelScriptPartitionResults =
|
const parallelScriptPartitionResults =
|
||||||
failFast && serialScriptPartitionResults.some(partitionFailed)
|
scriptPreparationFailure || (failFast && serialScriptPartitionResults.some(partitionFailed))
|
||||||
? []
|
? []
|
||||||
: await runPartitionTasks(
|
: await runPartitionTasks(
|
||||||
parallelScriptPartitionTasks,
|
parallelScriptPartitionTasks,
|
||||||
@@ -1334,6 +1359,7 @@ async function runUnifiedQaSuite(params: {
|
|||||||
);
|
);
|
||||||
const partitionResults = [
|
const partitionResults = [
|
||||||
...concurrentPartitionResults,
|
...concurrentPartitionResults,
|
||||||
|
...(scriptPreparationFailure ? [scriptPreparationFailure] : []),
|
||||||
...serialScriptPartitionResults,
|
...serialScriptPartitionResults,
|
||||||
...parallelScriptPartitionResults,
|
...parallelScriptPartitionResults,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,14 +1,36 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||||
|
import { z } from "zod";
|
||||||
import type { QaSeedScenarioWithSource } from "./scenario-catalog.js";
|
import type { QaSeedScenarioWithSource } from "./scenario-catalog.js";
|
||||||
import { shellQuote } from "./shell-quote.js";
|
import { shellQuote } from "./shell-quote.js";
|
||||||
import type {
|
import { runQaScenarioCommandLifecycle } from "./test-file-scenario-command-lifecycle.js";
|
||||||
QaScenarioCommandExecution,
|
|
||||||
QaScenarioCommandResult,
|
|
||||||
} from "./test-file-scenario-command-lifecycle.js";
|
|
||||||
|
|
||||||
const QA_DOCKER_E2E_LANE_SCRIPT = "test/e2e/qa-lab/runtime/docker-e2e-lane.ts";
|
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 & {
|
type QaDockerScenario = QaSeedScenarioWithSource & {
|
||||||
execution: Extract<QaSeedScenarioWithSource["execution"], { kind: "script" }>;
|
execution: Extract<QaSeedScenarioWithSource["execution"], { kind: "script" }>;
|
||||||
@@ -42,6 +64,74 @@ export function isDockerE2eScenario(
|
|||||||
return dockerE2eLaneName(scenario) !== undefined;
|
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(
|
function laneMatches(
|
||||||
selectedLane: string,
|
selectedLane: string,
|
||||||
resultLane: string | undefined,
|
resultLane: string | undefined,
|
||||||
@@ -63,7 +153,7 @@ export async function runDockerE2eBatch(params: {
|
|||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
outputDir: string;
|
outputDir: string;
|
||||||
repoRoot: string;
|
repoRoot: string;
|
||||||
runCommand: (command: QaScenarioCommandExecution) => Promise<QaScenarioCommandResult>;
|
runCommand: typeof runQaScenarioCommandLifecycle;
|
||||||
scenarios: readonly QaDockerScenario[];
|
scenarios: readonly QaDockerScenario[];
|
||||||
}): Promise<QaDockerBatchResult[]> {
|
}): Promise<QaDockerBatchResult[]> {
|
||||||
const selected = params.scenarios.map((scenario) => ({
|
const selected = params.scenarios.map((scenario) => ({
|
||||||
@@ -77,7 +167,7 @@ export async function runDockerE2eBatch(params: {
|
|||||||
await fs.mkdir(dockerOutputDir, { recursive: true });
|
await fs.mkdir(dockerOutputDir, { recursive: true });
|
||||||
const summaryPath = path.join(dockerOutputDir, "summary.json");
|
const summaryPath = path.join(dockerOutputDir, "summary.json");
|
||||||
await fs.rm(summaryPath, { force: true });
|
await fs.rm(summaryPath, { force: true });
|
||||||
let commandResult: QaScenarioCommandResult;
|
let commandResult: Awaited<ReturnType<typeof runQaScenarioCommandLifecycle>>;
|
||||||
try {
|
try {
|
||||||
commandResult = await params.runCommand({
|
commandResult = await params.runCommand({
|
||||||
command: process.execPath,
|
command: process.execPath,
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import { validateQaEvidenceSummaryJson } from "./evidence-summary.js";
|
|||||||
import type { QaSeedScenarioWithSource } from "./scenario-catalog.js";
|
import type { QaSeedScenarioWithSource } from "./scenario-catalog.js";
|
||||||
import { createTempDirHarness } from "./temp-dir.test-helper.js";
|
import { createTempDirHarness } from "./temp-dir.test-helper.js";
|
||||||
import { runQaScenarioCommandLifecycle } from "./test-file-scenario-command-lifecycle.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 {
|
import {
|
||||||
qaTestFileScenarioRunnerTesting,
|
qaTestFileScenarioRunnerTesting,
|
||||||
runQaTestFileScenarios,
|
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", () => {
|
it("only batches the canonical Docker lane argument shape", () => {
|
||||||
const scenario = makeDockerE2eScenario("docker-lane", "gateway-network");
|
const scenario = makeDockerE2eScenario("docker-lane", "gateway-network");
|
||||||
if (scenario.execution.kind !== "script") {
|
if (scenario.execution.kind !== "script") {
|
||||||
@@ -112,6 +127,118 @@ it("only batches the canonical Docker lane argument shape", () => {
|
|||||||
).toBeUndefined();
|
).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) {
|
async function makeTempRepo(prefix: string) {
|
||||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
tempRoots.push(repoRoot);
|
tempRoots.push(repoRoot);
|
||||||
@@ -224,6 +351,7 @@ async function writeScriptProducerEvidence(params: {
|
|||||||
|
|
||||||
describe("qa test file scenario runner", () => {
|
describe("qa test file scenario runner", () => {
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
qaTestFileScenarioRunnerTesting.resetTimeoutCleanupTimings();
|
qaTestFileScenarioRunnerTesting.resetTimeoutCleanupTimings();
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
cleanupTempDirs(),
|
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 () => {
|
it("runs Playwright scenarios with the repo UI e2e command and writes Playwright evidence", async () => {
|
||||||
const repoRoot = await makeTempRepo("qa-playwright-scenario-");
|
const repoRoot = await makeTempRepo("qa-playwright-scenario-");
|
||||||
const commands: QaScenarioCommandExecution[] = [];
|
const commands: QaScenarioCommandExecution[] = [];
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type QaTestFileScenarioRunParams = {
|
|||||||
commandTimeoutMs?: number;
|
commandTimeoutMs?: number;
|
||||||
evidenceMode?: QaScorecardEvidenceMode;
|
evidenceMode?: QaScorecardEvidenceMode;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
|
envMode?: "replace";
|
||||||
failFast?: boolean;
|
failFast?: boolean;
|
||||||
outputDir: string;
|
outputDir: string;
|
||||||
primaryModel: string;
|
primaryModel: string;
|
||||||
@@ -570,10 +571,7 @@ export async function runQaTestFileScenarios(
|
|||||||
params.commandTimeoutMs,
|
params.commandTimeoutMs,
|
||||||
DEFAULT_QA_TEST_FILE_COMMAND_TIMEOUT_MS,
|
DEFAULT_QA_TEST_FILE_COMMAND_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
const env = {
|
const env = params.envMode === "replace" ? (params.env ?? {}) : { ...process.env, ...params.env };
|
||||||
...process.env,
|
|
||||||
...params.env,
|
|
||||||
};
|
|
||||||
const results: QaTestFileScenarioResult[] = [];
|
const results: QaTestFileScenarioResult[] = [];
|
||||||
const dockerBatchScenarios =
|
const dockerBatchScenarios =
|
||||||
kind === "script" && !params.failFast ? scenarios.filter(isDockerE2eScenario) : [];
|
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) {
|
function validateManifestShape(manifest) {
|
||||||
if (
|
if (
|
||||||
!manifest ||
|
!manifest ||
|
||||||
|
|||||||
+155
-23
@@ -2,7 +2,8 @@
|
|||||||
// Builds shared Docker images, prepares one OpenClaw npm tarball, assigns lanes
|
// Builds shared Docker images, prepares one OpenClaw npm tarball, assigns lanes
|
||||||
// to bare/functional images, and runs lanes through weighted resource pools.
|
// 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 fs from "node:fs";
|
||||||
import { mkdir, open, readFile } from "node:fs/promises";
|
import { mkdir, open, readFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -31,7 +32,11 @@ import {
|
|||||||
} from "./lib/docker-e2e-plan.mts";
|
} from "./lib/docker-e2e-plan.mts";
|
||||||
import type { DockerE2eLane } from "./lib/docker-e2e-scenarios.mts";
|
import type { DockerE2eLane } from "./lib/docker-e2e-scenarios.mts";
|
||||||
import { sleep } from "./lib/sleep.mjs";
|
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 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);
|
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 MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
|
||||||
const DEFAULT_TIMINGS_FILE = path.join(ROOT_DIR, ".artifacts/docker-tests/lane-timings.json");
|
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 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 SchedulerLimits = ReturnType<typeof parseSchedulerOptions>;
|
||||||
|
type DockerCandidatePlan = ReturnType<typeof resolveDockerE2ePlan>["plan"];
|
||||||
|
|
||||||
type SchedulerActiveState = {
|
type SchedulerActiveState = {
|
||||||
count: number;
|
count: number;
|
||||||
@@ -135,10 +149,11 @@ const IS_MAIN = (() => {
|
|||||||
|
|
||||||
function dockerAllUsage() {
|
function dockerAllUsage() {
|
||||||
return [
|
return [
|
||||||
"Usage: node scripts/test-docker-all.mjs [--plan-json]",
|
"Usage: node scripts/test-docker-all.mjs [--plan-json | --prepare-only=<manifest>]",
|
||||||
"",
|
"",
|
||||||
"Options:",
|
"Options:",
|
||||||
" --plan-json Print the resolved Docker E2E plan as JSON and exit.",
|
" --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.",
|
" -h, --help Show this help.",
|
||||||
"",
|
"",
|
||||||
"Lane selection and scheduler settings are configured with OPENCLAW_DOCKER_ALL_* env vars.",
|
"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[]) {
|
export function parseDockerAllCliArgs(argv: readonly string[]) {
|
||||||
const options = {
|
const options: { help: boolean; planJson: boolean; prepareOnly?: string } = {
|
||||||
help: false,
|
help: false,
|
||||||
planJson: false,
|
planJson: false,
|
||||||
};
|
};
|
||||||
for (const arg of argv) {
|
for (const arg of argv) {
|
||||||
if (arg === "--plan-json") {
|
if (arg === "--plan-json") {
|
||||||
options.planJson = true;
|
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") {
|
} else if (arg === "--help" || arg === "-h") {
|
||||||
options.help = true;
|
options.help = true;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`unknown argument: ${arg}\n\n${dockerAllUsage()}`);
|
throw new Error(`unknown argument: ${arg}\n\n${dockerAllUsage()}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
assert(!(options.planJson && options.prepareOnly), "conflicting plan/prep options");
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cliOptions = {
|
let cliOptions: ReturnType<typeof parseDockerAllCliArgs> = {
|
||||||
help: false,
|
help: false,
|
||||||
planJson: false,
|
planJson: false,
|
||||||
};
|
};
|
||||||
@@ -361,6 +382,75 @@ function commandEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
|
|||||||
return env;
|
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) {
|
function shellQuote(value: string) {
|
||||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||||
}
|
}
|
||||||
@@ -431,7 +521,7 @@ export function githubWorkflowRerunCommand(
|
|||||||
return fields.join(" ");
|
return fields.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLaneRerunCommand(name: string, baseEnv: NodeJS.ProcessEnv) {
|
export function buildLaneRerunCommand(name: string, baseEnv: NodeJS.ProcessEnv) {
|
||||||
const poolLane = findLaneByName(name);
|
const poolLane = findLaneByName(name);
|
||||||
const build = name.startsWith("live-") ? "1" : "0";
|
const build = name.startsWith("live-") ? "1" : "0";
|
||||||
const image = poolLane ? e2eImageForLane(poolLane, baseEnv) : baseEnv.OPENCLAW_DOCKER_E2E_IMAGE;
|
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_IMAGE", image || DEFAULT_E2E_FUNCTIONAL_IMAGE],
|
||||||
["OPENCLAW_DOCKER_E2E_BARE_IMAGE", baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE],
|
["OPENCLAW_DOCKER_E2E_BARE_IMAGE", baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE],
|
||||||
["OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE", baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE],
|
["OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE", baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE],
|
||||||
["OPENCLAW_CURRENT_PACKAGE_TGZ", baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ],
|
...CANDIDATE_ENV_KEYS.map((key) => [key, baseEnv[key]] as const),
|
||||||
["OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR", baseEnv.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR],
|
...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_SPEC", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC],
|
||||||
["OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS],
|
["OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS],
|
||||||
["OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS", baseEnv.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS],
|
["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");
|
const packageTgz = path.join(packDir, "openclaw-current.tgz");
|
||||||
await runForeground(
|
await runForeground(
|
||||||
"Prepare OpenClaw package once",
|
"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,
|
baseEnv,
|
||||||
);
|
);
|
||||||
await fs.promises.access(packageTgz);
|
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}`);
|
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) {
|
function e2eImageForLane(poolLane: DockerE2eLane, baseEnv: NodeJS.ProcessEnv) {
|
||||||
if (poolLane.e2eImageKind === "bare") {
|
if (poolLane.e2eImageKind === "bare") {
|
||||||
return baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE;
|
return baseEnv.OPENCLAW_DOCKER_E2E_BARE_IMAGE;
|
||||||
@@ -1664,20 +1805,6 @@ async function main() {
|
|||||||
allowFrozenTargetScenarioOmissions,
|
allowFrozenTargetScenarioOmissions,
|
||||||
candidatePackageRoot: ROOT_DIR,
|
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) {
|
if (omittedUnsupportedLaneNames.length > 0 && !allowFrozenTargetScenarioOmissions) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`frozen target scenario omissions require trusted workflow opt-in: ${omittedUnsupportedLaneNames.join(", ")}`,
|
`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`);
|
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (cliOptions.prepareOnly) {
|
||||||
|
await prepareDockerCandidate(plan, logDir, path.resolve(cliOptions.prepareOnly));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await mkdir(logDir, { recursive: true });
|
await mkdir(logDir, { recursive: true });
|
||||||
console.log(`==> Docker test logs: ${logDir}`);
|
console.log(`==> Docker test logs: ${logDir}`);
|
||||||
@@ -1754,6 +1885,7 @@ async function main() {
|
|||||||
console.log("==> Dry run complete");
|
console.log("==> Dry run complete");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
validateDockerCandidateEnvironment(baseEnv, plan);
|
||||||
|
|
||||||
// Planning can report unsupported scenarios, but execution cannot pass when
|
// Planning can report unsupported scenarios, but execution cannot pass when
|
||||||
// frozen-target omissions leave no selected lane to run.
|
// frozen-target omissions leave no selected lane to run.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Docker All Scheduler tests cover docker all scheduler script behavior.
|
// 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 {
|
import {
|
||||||
chmodSync,
|
chmodSync,
|
||||||
copyFileSync,
|
copyFileSync,
|
||||||
@@ -19,6 +20,7 @@ import { parse } from "yaml";
|
|||||||
import { DEFAULT_RESOURCE_LIMITS } from "../../scripts/lib/docker-e2e-plan.mts";
|
import { DEFAULT_RESOURCE_LIMITS } from "../../scripts/lib/docker-e2e-plan.mts";
|
||||||
import {
|
import {
|
||||||
appendBoundedShellCapture,
|
appendBoundedShellCapture,
|
||||||
|
buildLaneRerunCommand,
|
||||||
canStartSchedulerLane,
|
canStartSchedulerLane,
|
||||||
describeDockerSchedulerLimits,
|
describeDockerSchedulerLimits,
|
||||||
dockerPreflightContainerNames,
|
dockerPreflightContainerNames,
|
||||||
@@ -32,6 +34,7 @@ import {
|
|||||||
runShellCommand,
|
runShellCommand,
|
||||||
SHELL_CAPTURE_MAX_CHARS,
|
SHELL_CAPTURE_MAX_CHARS,
|
||||||
tailFile,
|
tailFile,
|
||||||
|
validateDockerCandidateEnvironment,
|
||||||
writeRunSummary,
|
writeRunSummary,
|
||||||
} from "../../scripts/test-docker-all.mts";
|
} from "../../scripts/test-docker-all.mts";
|
||||||
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
||||||
@@ -48,6 +51,39 @@ const posixIt = process.platform === "win32" ? it.skip : it;
|
|||||||
const { createTempDir } = createScriptTestHarness();
|
const { createTempDir } = createScriptTestHarness();
|
||||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||||
const LIVE_E2E_WORKFLOW = ".github/workflows/openclaw-live-and-e2e-checks-reusable.yml";
|
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 {
|
function writeFrozenScenarioContract(root: string, scenarios: string[]): string {
|
||||||
const assertionsFile = path.join(root, "scripts/e2e/lib/upgrade-survivor/assertions.mjs");
|
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 {
|
function isProcessAlive(pid: number): boolean {
|
||||||
try {
|
try {
|
||||||
process.kill(pid, 0);
|
process.kill(pid, 0);
|
||||||
@@ -137,6 +304,14 @@ describe("scripts/test-docker-all scheduler", () => {
|
|||||||
help: true,
|
help: true,
|
||||||
planJson: false,
|
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", () => {
|
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.status).toBe(0);
|
||||||
expect(result.stderr).toBe("");
|
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");
|
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.status).toBe(1);
|
||||||
expect(result.stdout).toBe("");
|
expect(result.stdout).toBe("");
|
||||||
expect(result.stderr).toContain("unknown argument: --bogus");
|
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 ");
|
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", () => {
|
it("plans from an isolated release harness with source-checkout TypeScript support", () => {
|
||||||
const artifactRoot = path.resolve(".artifacts");
|
const artifactRoot = path.resolve(".artifacts");
|
||||||
mkdirSync(artifactRoot, { recursive: true });
|
mkdirSync(artifactRoot, { recursive: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user