test(qa): expand docker lane adapter (#97937)

* test(qa): expand docker lane adapter

* Extract QA Docker lane fixture
This commit is contained in:
Dallin Romney 2026-06-29 17:08:43 -07:00 committed by GitHub
parent 18b2ff683f
commit fd3f354f46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 293 additions and 411 deletions

View file

@ -15,19 +15,6 @@ export {
type LiveTransportScenarioDefinition,
type LiveTransportStandardScenarioId,
} from "openclaw/plugin-sdk/qa-live-transport-scenarios";
export {
assertQaTransportSupportsScenario,
defineQaTransportScenario,
filterQaTransportScenariosForTransport,
findUnsupportedQaTransportScenarioRequirements,
mergeQaTransportScenarioRequirements,
qaTransportRequirementsForStandardScenario,
type QaTransportCapabilityName,
type QaTransportScenarioDefinition,
type QaTransportScenarioDefinitionInput,
type QaTransportScenarioRequirements,
type QaTransportScenarioUnsupportedRequirements,
} from "../../qa-transport-scenarios.js";
export type LiveTransportCoverageMember = {
scenarioId?: string;

View file

@ -1,139 +0,0 @@
// Qa Lab tests cover transport-backed scenario metadata.
import { describe, expect, it } from "vitest";
import { createQaBusState } from "./bus-state.js";
import { createQaChannelTransport } from "./qa-channel-transport.js";
import {
assertQaTransportSupportsScenario,
defineQaTransportScenario,
filterQaTransportScenariosForTransport,
findUnsupportedQaTransportScenarioRequirements,
mergeQaTransportScenarioRequirements,
qaTransportRequirementsForStandardScenario,
} from "./qa-transport-scenarios.js";
import type { QaTransportAdapter } from "./qa-transport.js";
type ScenarioTransportFixture = Pick<
QaTransportAdapter,
"capabilities" | "id" | "supportedActions"
>;
function createScenarioTransportFixture(
supportedActions: QaTransportAdapter["supportedActions"],
): ScenarioTransportFixture {
const transport = createQaChannelTransport(createQaBusState());
return {
capabilities: transport.capabilities,
id: "crabline",
supportedActions,
};
}
describe("qa transport scenarios", () => {
it("maps standard live transport buckets onto existing transport capabilities", () => {
expect(qaTransportRequirementsForStandardScenario("canary")).toEqual({
capabilities: ["assertNoFailureReplies", "sendInboundMessage", "waitForOutboundMessage"],
});
expect(qaTransportRequirementsForStandardScenario("restart-resume")).toEqual({
capabilities: [
"assertNoFailureReplies",
"sendInboundMessage",
"waitForOutboundMessage",
"waitForReady",
],
});
});
it("merges standard and explicit requirements without duplicating names", () => {
const scenario = defineQaTransportScenario({
id: "slack-thread-follow-up",
standardId: "thread-follow-up",
timeoutMs: 30_000,
title: "Thread follow-up",
transportRequirements: {
actions: ["thread-create", "thread-create"],
capabilities: ["waitForOutboundMessage", "waitForCondition"],
},
});
expect(scenario.transportRequirements).toEqual({
actions: ["thread-create"],
capabilities: [
"assertNoFailureReplies",
"sendInboundMessage",
"waitForOutboundMessage",
"waitForCondition",
],
});
});
it("can merge requirements independently for shared scenario builders", () => {
expect(
mergeQaTransportScenarioRequirements([
{ actions: ["react"], capabilities: ["sendInboundMessage"] },
{ actions: ["react", "delete"], capabilities: ["sendInboundMessage", "waitForReady"] },
]),
).toEqual({
actions: ["react", "delete"],
capabilities: ["sendInboundMessage", "waitForReady"],
});
});
it("validates a scenario against a real qa-channel transport", () => {
const transport = createQaChannelTransport(createQaBusState());
const scenario = defineQaTransportScenario({
id: "qa-channel-reaction",
timeoutMs: 30_000,
title: "Reaction action",
transportRequirements: {
actions: ["react"],
capabilities: ["executeGenericAction", "waitForCondition"],
},
});
expect(() => assertQaTransportSupportsScenario({ scenario, transport })).not.toThrow();
});
it("reports unsupported transport actions without executing the action", () => {
const transport = createScenarioTransportFixture([]);
const scenario = defineQaTransportScenario({
id: "reaction-action",
timeoutMs: 30_000,
title: "Reaction action",
transportRequirements: {
actions: ["react"],
capabilities: ["executeGenericAction"],
},
});
expect(findUnsupportedQaTransportScenarioRequirements({ scenario, transport })).toEqual({
actions: ["react"],
capabilities: [],
});
expect(() => assertQaTransportSupportsScenario({ scenario, transport })).toThrow(
"QA transport crabline cannot run scenario reaction-action; unsupported actions: react",
);
});
it("filters scenarios by the supplied transport contract", () => {
const transport = createScenarioTransportFixture([]);
const canary = defineQaTransportScenario({
id: "canary",
standardId: "canary",
timeoutMs: 30_000,
title: "Canary",
});
const reaction = defineQaTransportScenario({
id: "reaction",
timeoutMs: 30_000,
title: "Reaction",
transportRequirements: { actions: ["react"] },
});
expect(
filterQaTransportScenariosForTransport({
scenarios: [canary, reaction],
transport,
}),
).toEqual([canary]);
});
});

View file

@ -1,157 +0,0 @@
// Qa Lab plugin module defines scenario metadata for transport-backed QA runs.
import type {
LiveTransportScenarioDefinition,
LiveTransportStandardScenarioId,
} from "openclaw/plugin-sdk/qa-live-transport-scenarios";
import type {
QaTransportActionName,
QaTransportAdapter,
QaTransportCapabilities,
} from "./qa-transport.js";
export type QaTransportCapabilityName = keyof QaTransportCapabilities;
export type QaTransportScenarioRequirements = {
actions?: readonly QaTransportActionName[];
capabilities?: readonly QaTransportCapabilityName[];
};
export type QaTransportScenarioDefinition<TId extends string = string> =
LiveTransportScenarioDefinition<TId> & {
transportRequirements: QaTransportScenarioRequirements;
};
export type QaTransportScenarioDefinitionInput<TId extends string = string> =
LiveTransportScenarioDefinition<TId> & {
transportRequirements?: QaTransportScenarioRequirements;
};
export type QaTransportScenarioUnsupportedRequirements = {
actions: QaTransportActionName[];
capabilities: QaTransportCapabilityName[];
};
const TEXT_REPLY_CAPABILITIES = [
"assertNoFailureReplies",
"sendInboundMessage",
"waitForOutboundMessage",
] as const satisfies readonly QaTransportCapabilityName[];
const NO_REPLY_CAPABILITIES = [
"assertNoFailureReplies",
"sendInboundMessage",
"waitForCondition",
] as const satisfies readonly QaTransportCapabilityName[];
const STANDARD_SCENARIO_REQUIREMENTS = {
canary: { capabilities: TEXT_REPLY_CAPABILITIES },
"mention-gating": { capabilities: NO_REPLY_CAPABILITIES },
"allowlist-block": { capabilities: NO_REPLY_CAPABILITIES },
"top-level-reply-shape": { capabilities: TEXT_REPLY_CAPABILITIES },
"quote-reply": { capabilities: TEXT_REPLY_CAPABILITIES },
"restart-resume": {
capabilities: [...TEXT_REPLY_CAPABILITIES, "waitForReady"],
},
"thread-follow-up": { capabilities: TEXT_REPLY_CAPABILITIES },
"thread-isolation": {
capabilities: [...TEXT_REPLY_CAPABILITIES, "waitForCondition"],
},
"reaction-observation": {
capabilities: ["getNormalizedMessageState", "waitForCondition"],
},
"help-command": { capabilities: TEXT_REPLY_CAPABILITIES },
} as const satisfies Record<LiveTransportStandardScenarioId, QaTransportScenarioRequirements>;
function uniqueInOrder<T extends string>(values: readonly T[]) {
const seen = new Set<T>();
const unique: T[] = [];
for (const value of values) {
if (!seen.has(value)) {
seen.add(value);
unique.push(value);
}
}
return unique;
}
export function mergeQaTransportScenarioRequirements(
requirements: readonly QaTransportScenarioRequirements[],
): QaTransportScenarioRequirements {
return {
actions: uniqueInOrder(requirements.flatMap((requirement) => requirement.actions ?? [])),
capabilities: uniqueInOrder(
requirements.flatMap((requirement) => requirement.capabilities ?? []),
),
};
}
export function qaTransportRequirementsForStandardScenario(
standardId: LiveTransportStandardScenarioId,
): QaTransportScenarioRequirements {
return STANDARD_SCENARIO_REQUIREMENTS[standardId];
}
export function defineQaTransportScenario<TId extends string>(
input: QaTransportScenarioDefinitionInput<TId>,
): QaTransportScenarioDefinition<TId> {
const standardRequirements = input.standardId
? qaTransportRequirementsForStandardScenario(input.standardId)
: {};
return {
...input,
transportRequirements: mergeQaTransportScenarioRequirements([
standardRequirements,
input.transportRequirements ?? {},
]),
};
}
export function findUnsupportedQaTransportScenarioRequirements(params: {
scenario: QaTransportScenarioDefinition;
transport: Pick<QaTransportAdapter, "capabilities" | "supportedActions">;
}): QaTransportScenarioUnsupportedRequirements {
const supportedActions = new Set(params.transport.supportedActions);
return {
actions: (params.scenario.transportRequirements.actions ?? []).filter(
(action) => !supportedActions.has(action),
),
capabilities: (params.scenario.transportRequirements.capabilities ?? []).filter(
(capability) => typeof params.transport.capabilities[capability] !== "function",
),
};
}
export function assertQaTransportSupportsScenario(params: {
scenario: QaTransportScenarioDefinition;
transport: Pick<QaTransportAdapter, "capabilities" | "id" | "supportedActions">;
}) {
const unsupported = findUnsupportedQaTransportScenarioRequirements(params);
const problems = [
unsupported.capabilities.length > 0
? `missing capabilities: ${unsupported.capabilities.join(", ")}`
: undefined,
unsupported.actions.length > 0
? `unsupported actions: ${unsupported.actions.join(", ")}`
: undefined,
].filter((problem): problem is string => Boolean(problem));
if (problems.length > 0) {
throw new Error(
`QA transport ${params.transport.id} cannot run scenario ${params.scenario.id}; ${problems.join("; ")}`,
);
}
}
export function filterQaTransportScenariosForTransport<
TScenario extends QaTransportScenarioDefinition,
>(params: {
scenarios: readonly TScenario[];
transport: Pick<QaTransportAdapter, "capabilities" | "supportedActions">;
}): TScenario[] {
return params.scenarios.filter((scenario) => {
const unsupported = findUnsupportedQaTransportScenarioRequirements({
scenario,
transport: params.transport,
});
return unsupported.actions.length === 0 && unsupported.capabilities.length === 0;
});
}

View file

@ -25,7 +25,7 @@ scenario:
- scripts/e2e/lib/release-scenarios/write-marketplace.mjs
execution:
kind: script
path: scripts/qa/docker-e2e-lane.ts
path: test/e2e/qa-lab/runtime/docker-e2e-lane.ts
summary: Runs the release-plugin-marketplace Docker E2E lane as QA Lab evidence for ClawHub marketplace list coverage.
args:
- --lane

View file

@ -21,7 +21,7 @@ scenario:
- test/e2e/qa-lab/runtime/agent-bundle-mcp-tools-docker-client.ts
execution:
kind: script
path: scripts/qa/docker-e2e-lane.ts
path: test/e2e/qa-lab/runtime/docker-e2e-lane.ts
summary: Runs the existing agent-bundle-mcp-tools Docker E2E lane for Docker-backed agent tooling coverage.
args:
- --lane

View file

@ -21,7 +21,7 @@ scenario:
- scripts/e2e/lib/fixture.mjs
execution:
kind: script
path: scripts/qa/docker-e2e-lane.ts
path: test/e2e/qa-lab/runtime/docker-e2e-lane.ts
summary: Runs the existing agents-delete-shared-workspace Docker E2E lane for Docker-hosted agent workspace safety coverage.
args:
- --lane

View file

@ -25,7 +25,7 @@ scenario:
- scripts/e2e/crestodian-first-run-spec.json
execution:
kind: script
path: scripts/qa/docker-e2e-lane.ts
path: test/e2e/qa-lab/runtime/docker-e2e-lane.ts
summary: Runs the existing Crestodian first-run Docker E2E lane and records primary Docker first-run onboarding evidence; the Raspberry Pi link is secondary because this is generic Linux/container first-run proof, not Pi hardware proof.
args:
- --lane

View file

@ -21,7 +21,7 @@ scenario:
- scripts/e2e/lib/gateway-network/client.mjs
execution:
kind: script
path: scripts/qa/docker-e2e-lane.ts
path: test/e2e/qa-lab/runtime/docker-e2e-lane.ts
summary: Runs the existing gateway-network Docker E2E lane for compose-style container network access coverage.
args:
- --lane

View file

@ -24,7 +24,7 @@ scenario:
- scripts/e2e/lib/npm-onboard-channel-agent/assertions.mjs
execution:
kind: script
path: scripts/qa/docker-e2e-lane.ts
path: test/e2e/qa-lab/runtime/docker-e2e-lane.ts
summary: Runs the existing npm-onboard-channel-agent Docker E2E lane for package-installed Docker agent setup and turn coverage.
args:
- --lane

View file

@ -23,7 +23,7 @@ scenario:
- scripts/e2e/lib/release-user-journey/assertions.mjs
execution:
kind: script
path: scripts/qa/docker-e2e-lane.ts
path: test/e2e/qa-lab/runtime/docker-e2e-lane.ts
summary: Runs the existing release-upgrade-user-journey Docker E2E lane and records QA Lab evidence for release-path upgrade coverage.
args:
- --lane

View file

@ -22,7 +22,7 @@ scenario:
- scripts/e2e/lib/release-user-journey/assertions.mjs
execution:
kind: script
path: scripts/qa/docker-e2e-lane.ts
path: test/e2e/qa-lab/runtime/docker-e2e-lane.ts
summary: Runs the existing release-user-journey Docker E2E lane and records QA Lab evidence for release-path install coverage.
args:
- --lane

View file

@ -24,7 +24,7 @@ scenario:
- scripts/e2e/lib/update-channel-switch/assertions.mjs
execution:
kind: script
path: scripts/qa/docker-e2e-lane.ts
path: test/e2e/qa-lab/runtime/docker-e2e-lane.ts
summary: Runs the existing update-channel-switch Docker E2E lane and records QA Lab evidence for install-kind switching and update status coverage.
args:
- --lane

View file

@ -24,7 +24,7 @@ scenario:
- src/cli/update-cli/post-core-plugin-convergence.ts
execution:
kind: script
path: scripts/qa/docker-e2e-lane.ts
path: test/e2e/qa-lab/runtime/docker-e2e-lane.ts
summary: Runs the existing update-migration Docker E2E lane and records QA Lab evidence for plugin convergence coverage.
args:
- --lane

View file

@ -24,7 +24,7 @@ scenario:
- scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh
execution:
kind: script
path: scripts/qa/docker-e2e-lane.ts
path: test/e2e/qa-lab/runtime/docker-e2e-lane.ts
summary: Runs the existing update-restart-auth Docker E2E lane and records QA Lab evidence for managed Gateway restart coverage.
args:
- --lane

View file

@ -24,7 +24,7 @@ scenario:
- scripts/e2e/lib/upgrade-survivor/assertions.mjs
execution:
kind: script
path: scripts/qa/docker-e2e-lane.ts
path: test/e2e/qa-lab/runtime/docker-e2e-lane.ts
summary: Runs the existing upgrade-survivor Docker E2E lane and records QA Lab evidence for update status/RPC survival coverage.
args:
- --lane

View file

@ -1,90 +0,0 @@
// Runs an existing Docker E2E lane through the QA Lab script scenario contract.
import { spawnSync } from "node:child_process";
type Lane = {
env?: Record<string, string>;
script: string;
};
const lanes: Record<string, Lane> = {
"agent-bundle-mcp-tools": {
script: "scripts/e2e/agent-bundle-mcp-tools-docker.sh",
},
"agents-delete-shared-workspace": {
script: "scripts/e2e/agents-delete-shared-workspace-docker.sh",
},
"crestodian-first-run": {
script: "scripts/e2e/crestodian-first-run-docker.sh",
},
"gateway-network": {
script: "scripts/e2e/gateway-network-docker.sh",
},
"npm-onboard-channel-agent": {
script: "scripts/e2e/npm-onboard-channel-agent-docker.sh",
},
"release-upgrade-user-journey": {
script: "scripts/e2e/release-upgrade-user-journey-docker.sh",
},
"release-plugin-marketplace": {
script: "scripts/e2e/release-plugin-marketplace-docker.sh",
},
"release-user-journey": {
script: "scripts/e2e/release-user-journey-docker.sh",
},
"update-channel-switch": {
script: "scripts/e2e/update-channel-switch-docker.sh",
},
"update-migration": {
env: {
OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE: "1",
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:
process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC ?? "openclaw@2026.4.23",
OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:
process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIO ?? "plugin-deps-cleanup",
},
script: "scripts/e2e/upgrade-survivor-docker.sh",
},
"update-restart-auth": {
env: {
OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE: "auto-auth",
OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:
process.env.OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT ?? "1500s",
},
script: "scripts/e2e/upgrade-survivor-docker.sh",
},
"upgrade-survivor": {
script: "scripts/e2e/upgrade-survivor-docker.sh",
},
};
function laneArg(argv: string[]) {
const index = argv.indexOf("--lane");
if (index === -1) {
throw new Error("--lane is required");
}
const lane = argv[index + 1];
if (!lane || lane.startsWith("-")) {
throw new Error("--lane requires a value");
}
return lane;
}
const laneName = laneArg(process.argv.slice(2));
const lane = lanes[laneName];
if (!lane) {
throw new Error(`unknown Docker E2E lane: ${laneName}`);
}
const result = spawnSync("bash", [lane.script], {
env: { ...process.env, ...lane.env },
stdio: "inherit",
});
if (result.error) {
console.error(result.error);
process.exit(1);
}
if (result.signal) {
process.kill(process.pid, result.signal);
}
process.exit(result.status ?? 1);

View file

@ -0,0 +1,84 @@
// Docker E2E lane fixture tests keep QA scenario dispatch policy reusable.
import { describe, expect, it, vi } from "vitest";
import {
formatQaDockerE2eLaneUsage,
listQaDockerE2eLaneNames,
parseQaDockerE2eLaneArgs,
resolveQaDockerE2eLane,
runQaDockerE2eLane,
} from "./docker-e2e-lane.fixture.ts";
describe("QA Docker E2E lane fixture", () => {
it("lists known Docker lanes for scenario wrappers", () => {
expect(listQaDockerE2eLaneNames()).toEqual(
expect.arrayContaining([
"agent-bundle-mcp-tools",
"crestodian-first-run",
"gateway-network",
"release-plugin-marketplace",
"update-migration",
"update-restart-auth",
]),
);
expect(listQaDockerE2eLaneNames()).toEqual([...listQaDockerE2eLaneNames()].sort());
});
it("parses help, list, and lane arguments", () => {
expect(parseQaDockerE2eLaneArgs(["--help"])).toEqual({ kind: "help" });
expect(parseQaDockerE2eLaneArgs(["--list"])).toEqual({ kind: "list" });
expect(parseQaDockerE2eLaneArgs(["--lane", "gateway-network"])).toEqual({
kind: "run",
laneName: "gateway-network",
});
expect(() => parseQaDockerE2eLaneArgs([])).toThrow("--lane is required");
expect(() => parseQaDockerE2eLaneArgs(["--lane"])).toThrow("--lane requires a value");
});
it("renders usage from the shared lane registry", () => {
const usage = formatQaDockerE2eLaneUsage("node qa-docker.js");
expect(usage).toContain("Usage: node qa-docker.js --lane <name>");
expect(usage).toContain(" - gateway-network");
expect(usage).toContain(" - update-restart-auth");
});
it("resolves lane-specific environment overlays at run time", () => {
const updateMigration = resolveQaDockerE2eLane("update-migration", {
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: "openclaw@custom",
OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: "custom-scenario",
});
expect(updateMigration.script).toBe("scripts/e2e/upgrade-survivor-docker.sh");
expect(updateMigration.env.OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE).toBe("1");
expect(updateMigration.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC).toBe("openclaw@custom");
expect(updateMigration.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIO).toBe("custom-scenario");
const updateRestartAuth = resolveQaDockerE2eLane("update-restart-auth", {});
expect(updateRestartAuth.env.OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE).toBe("auto-auth");
expect(updateRestartAuth.env.OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT).toBe("1500s");
});
it("dispatches through bash without running Docker in fixture tests", () => {
const spawn = vi.fn(() => ({ signal: null, status: 0 }));
expect(runQaDockerE2eLane("gateway-network", { env: { EXTRA: "1" }, spawn })).toEqual({
signal: null,
status: 0,
});
expect(spawn).toHaveBeenCalledWith("bash", ["scripts/e2e/gateway-network-docker.sh"], {
env: { EXTRA: "1" },
stdio: "inherit",
});
});
it("rejects unknown lanes before spawning", () => {
const spawn = vi.fn(() => ({ signal: null, status: 0 }));
expect(() => runQaDockerE2eLane("missing-lane", { spawn })).toThrow(
"unknown Docker E2E lane: missing-lane",
);
expect(spawn).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,169 @@
// Shared QA Lab fixture for dispatching existing Docker E2E lanes.
import { spawnSync } from "node:child_process";
export type QaDockerE2eLaneDefinition = {
env?: (env: NodeJS.ProcessEnv) => Record<string, string>;
script: string;
};
type QaDockerE2eLaneRunResult = {
error?: Error;
signal: NodeJS.Signals | null;
status: number | null;
};
type SpawnQaDockerE2eLane = (
command: string,
args: string[],
options: { env: NodeJS.ProcessEnv; stdio: "inherit" },
) => QaDockerE2eLaneRunResult;
export const QA_DOCKER_E2E_LANES = {
"agent-bundle-mcp-tools": {
script: "scripts/e2e/agent-bundle-mcp-tools-docker.sh",
},
"agents-delete-shared-workspace": {
script: "scripts/e2e/agents-delete-shared-workspace-docker.sh",
},
"bundled-plugin-install-uninstall": {
script: "scripts/e2e/bundled-plugin-install-uninstall-docker.sh",
},
"crestodian-first-run": {
script: "scripts/e2e/crestodian-first-run-docker.sh",
},
"docker-build-image": {
script: "scripts/e2e/build-image.sh",
},
"gateway-network": {
script: "scripts/e2e/gateway-network-docker.sh",
},
"npm-onboard-channel-agent": {
script: "scripts/e2e/npm-onboard-channel-agent-docker.sh",
},
"openai-chat-tools": {
script: "scripts/e2e/openai-chat-tools-docker.sh",
},
"openai-web-search-minimal": {
script: "scripts/e2e/openai-web-search-minimal-docker.sh",
},
openwebui: {
script: "scripts/e2e/openwebui-docker.sh",
},
"plugin-lifecycle-matrix": {
script: "scripts/e2e/plugin-lifecycle-matrix-docker.sh",
},
"release-plugin-marketplace": {
script: "scripts/e2e/release-plugin-marketplace-docker.sh",
},
"release-upgrade-user-journey": {
script: "scripts/e2e/release-upgrade-user-journey-docker.sh",
},
"release-user-journey": {
script: "scripts/e2e/release-user-journey-docker.sh",
},
"update-channel-switch": {
script: "scripts/e2e/update-channel-switch-docker.sh",
},
"update-migration": {
env: (env) => ({
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:
env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC ?? "openclaw@2026.4.23",
OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE: "1",
OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:
env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIO ?? "plugin-deps-cleanup",
}),
script: "scripts/e2e/upgrade-survivor-docker.sh",
},
"update-restart-auth": {
env: (env) => ({
OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:
env.OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT ?? "1500s",
OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE: "auto-auth",
}),
script: "scripts/e2e/upgrade-survivor-docker.sh",
},
"upgrade-survivor": {
script: "scripts/e2e/upgrade-survivor-docker.sh",
},
} satisfies Record<string, QaDockerE2eLaneDefinition>;
export type QaDockerE2eLaneName = keyof typeof QA_DOCKER_E2E_LANES;
export type QaDockerE2eLaneArgs =
| { kind: "help" }
| { kind: "list" }
| { kind: "run"; laneName: string };
export type ResolvedQaDockerE2eLane = {
env: NodeJS.ProcessEnv;
name: QaDockerE2eLaneName;
script: string;
};
export function listQaDockerE2eLaneNames(): QaDockerE2eLaneName[] {
return Object.keys(QA_DOCKER_E2E_LANES).toSorted() as QaDockerE2eLaneName[];
}
export function formatQaDockerE2eLaneUsage(
entrypoint = "node --import tsx test/e2e/qa-lab/runtime/docker-e2e-lane.ts",
): string {
return [
`Usage: ${entrypoint} --lane <name>`,
"",
"Known lanes:",
...listQaDockerE2eLaneNames().map((lane) => ` - ${lane}`),
"",
].join("\n");
}
export function parseQaDockerE2eLaneArgs(argv: string[]): QaDockerE2eLaneArgs {
if (argv.includes("--help") || argv.includes("-h")) {
return { kind: "help" };
}
if (argv.includes("--list")) {
return { kind: "list" };
}
const index = argv.indexOf("--lane");
if (index === -1) {
throw new Error("--lane is required");
}
const laneName = argv[index + 1];
if (!laneName || laneName.startsWith("-")) {
throw new Error("--lane requires a value");
}
return { kind: "run", laneName };
}
export function resolveQaDockerE2eLane(
laneName: string,
env: NodeJS.ProcessEnv = process.env,
): ResolvedQaDockerE2eLane {
if (!isQaDockerE2eLaneName(laneName)) {
throw new Error(`unknown Docker E2E lane: ${laneName}\n\n${formatQaDockerE2eLaneUsage()}`);
}
const lane = QA_DOCKER_E2E_LANES[laneName];
return {
env: { ...env, ...lane.env?.(env) },
name: laneName,
script: lane.script,
};
}
export function runQaDockerE2eLane(
laneName: string,
deps: {
env?: NodeJS.ProcessEnv;
spawn?: SpawnQaDockerE2eLane;
} = {},
): QaDockerE2eLaneRunResult {
const lane = resolveQaDockerE2eLane(laneName, deps.env);
const spawn = deps.spawn ?? spawnSync;
return spawn("bash", [lane.script], {
env: lane.env,
stdio: "inherit",
});
}
function isQaDockerE2eLaneName(laneName: string): laneName is QaDockerE2eLaneName {
return Object.hasOwn(QA_DOCKER_E2E_LANES, laneName);
}

View file

@ -0,0 +1,28 @@
// Runs an existing Docker E2E lane through the QA Lab script scenario contract.
import {
formatQaDockerE2eLaneUsage,
listQaDockerE2eLaneNames,
parseQaDockerE2eLaneArgs,
runQaDockerE2eLane,
} from "./docker-e2e-lane.fixture.ts";
const args = parseQaDockerE2eLaneArgs(process.argv.slice(2));
if (args.kind === "help") {
console.log(formatQaDockerE2eLaneUsage());
process.exit(0);
}
if (args.kind === "list") {
console.log(listQaDockerE2eLaneNames().join("\n"));
process.exit(0);
}
const result = runQaDockerE2eLane(args.laneName);
if (result.error) {
console.error(result.error);
process.exit(1);
}
if (result.signal) {
process.kill(process.pid, result.signal);
}
process.exit(result.status ?? 1);