diff --git a/extensions/qa-lab/src/live-transports/shared/live-transport-scenarios.ts b/extensions/qa-lab/src/live-transports/shared/live-transport-scenarios.ts index 646cf969f11..17e83fc5146 100644 --- a/extensions/qa-lab/src/live-transports/shared/live-transport-scenarios.ts +++ b/extensions/qa-lab/src/live-transports/shared/live-transport-scenarios.ts @@ -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; diff --git a/extensions/qa-lab/src/qa-transport-scenarios.test.ts b/extensions/qa-lab/src/qa-transport-scenarios.test.ts deleted file mode 100644 index 7f5b7a057a1..00000000000 --- a/extensions/qa-lab/src/qa-transport-scenarios.test.ts +++ /dev/null @@ -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]); - }); -}); diff --git a/extensions/qa-lab/src/qa-transport-scenarios.ts b/extensions/qa-lab/src/qa-transport-scenarios.ts deleted file mode 100644 index 15dccde9194..00000000000 --- a/extensions/qa-lab/src/qa-transport-scenarios.ts +++ /dev/null @@ -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 = - LiveTransportScenarioDefinition & { - transportRequirements: QaTransportScenarioRequirements; - }; - -export type QaTransportScenarioDefinitionInput = - LiveTransportScenarioDefinition & { - 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; - -function uniqueInOrder(values: readonly T[]) { - const seen = new Set(); - 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( - input: QaTransportScenarioDefinitionInput, -): QaTransportScenarioDefinition { - const standardRequirements = input.standardId - ? qaTransportRequirementsForStandardScenario(input.standardId) - : {}; - return { - ...input, - transportRequirements: mergeQaTransportScenarioRequirements([ - standardRequirements, - input.transportRequirements ?? {}, - ]), - }; -} - -export function findUnsupportedQaTransportScenarioRequirements(params: { - scenario: QaTransportScenarioDefinition; - transport: Pick; -}): 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; -}) { - 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; -}): TScenario[] { - return params.scenarios.filter((scenario) => { - const unsupported = findUnsupportedQaTransportScenarioRequirements({ - scenario, - transport: params.transport, - }); - return unsupported.actions.length === 0 && unsupported.capabilities.length === 0; - }); -} diff --git a/qa/scenarios/plugins/clawhub-marketplace-list.yaml b/qa/scenarios/plugins/clawhub-marketplace-list.yaml index 78a44420c80..5868efeea25 100644 --- a/qa/scenarios/plugins/clawhub-marketplace-list.yaml +++ b/qa/scenarios/plugins/clawhub-marketplace-list.yaml @@ -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 diff --git a/qa/scenarios/runtime/docker-agent-bundle-mcp-tools.yaml b/qa/scenarios/runtime/docker-agent-bundle-mcp-tools.yaml index 0fb3cf522d7..5140f0ee787 100644 --- a/qa/scenarios/runtime/docker-agent-bundle-mcp-tools.yaml +++ b/qa/scenarios/runtime/docker-agent-bundle-mcp-tools.yaml @@ -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 diff --git a/qa/scenarios/runtime/docker-agents-delete-shared-workspace.yaml b/qa/scenarios/runtime/docker-agents-delete-shared-workspace.yaml index 82d90e11615..ebcc059822c 100644 --- a/qa/scenarios/runtime/docker-agents-delete-shared-workspace.yaml +++ b/qa/scenarios/runtime/docker-agents-delete-shared-workspace.yaml @@ -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 diff --git a/qa/scenarios/runtime/docker-crestodian-first-run.yaml b/qa/scenarios/runtime/docker-crestodian-first-run.yaml index ff0ef638f6d..4fb13e11898 100644 --- a/qa/scenarios/runtime/docker-crestodian-first-run.yaml +++ b/qa/scenarios/runtime/docker-crestodian-first-run.yaml @@ -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 diff --git a/qa/scenarios/runtime/docker-gateway-network.yaml b/qa/scenarios/runtime/docker-gateway-network.yaml index 2e4944705bb..cb7ec2201f9 100644 --- a/qa/scenarios/runtime/docker-gateway-network.yaml +++ b/qa/scenarios/runtime/docker-gateway-network.yaml @@ -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 diff --git a/qa/scenarios/runtime/docker-npm-onboard-channel-agent.yaml b/qa/scenarios/runtime/docker-npm-onboard-channel-agent.yaml index bbad507fcb9..25d0f5f6ec1 100644 --- a/qa/scenarios/runtime/docker-npm-onboard-channel-agent.yaml +++ b/qa/scenarios/runtime/docker-npm-onboard-channel-agent.yaml @@ -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 diff --git a/qa/scenarios/runtime/docker-release-upgrade-user-journey.yaml b/qa/scenarios/runtime/docker-release-upgrade-user-journey.yaml index c1bf6326c4e..1c44c7e1e56 100644 --- a/qa/scenarios/runtime/docker-release-upgrade-user-journey.yaml +++ b/qa/scenarios/runtime/docker-release-upgrade-user-journey.yaml @@ -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 diff --git a/qa/scenarios/runtime/docker-release-user-journey.yaml b/qa/scenarios/runtime/docker-release-user-journey.yaml index f77d416c51e..51ac66a8d0c 100644 --- a/qa/scenarios/runtime/docker-release-user-journey.yaml +++ b/qa/scenarios/runtime/docker-release-user-journey.yaml @@ -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 diff --git a/qa/scenarios/runtime/docker-update-channel-switch.yaml b/qa/scenarios/runtime/docker-update-channel-switch.yaml index 0fe9f4201e4..be754872719 100644 --- a/qa/scenarios/runtime/docker-update-channel-switch.yaml +++ b/qa/scenarios/runtime/docker-update-channel-switch.yaml @@ -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 diff --git a/qa/scenarios/runtime/docker-update-migration.yaml b/qa/scenarios/runtime/docker-update-migration.yaml index afe54da8bb6..87dc6d3b866 100644 --- a/qa/scenarios/runtime/docker-update-migration.yaml +++ b/qa/scenarios/runtime/docker-update-migration.yaml @@ -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 diff --git a/qa/scenarios/runtime/docker-update-restart-auth.yaml b/qa/scenarios/runtime/docker-update-restart-auth.yaml index 8ace35a4247..aa99cb17c00 100644 --- a/qa/scenarios/runtime/docker-update-restart-auth.yaml +++ b/qa/scenarios/runtime/docker-update-restart-auth.yaml @@ -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 diff --git a/qa/scenarios/runtime/docker-upgrade-survivor.yaml b/qa/scenarios/runtime/docker-upgrade-survivor.yaml index 161a4f86dac..58647d863d1 100644 --- a/qa/scenarios/runtime/docker-upgrade-survivor.yaml +++ b/qa/scenarios/runtime/docker-upgrade-survivor.yaml @@ -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 diff --git a/scripts/qa/docker-e2e-lane.ts b/scripts/qa/docker-e2e-lane.ts deleted file mode 100644 index 79182b723c3..00000000000 --- a/scripts/qa/docker-e2e-lane.ts +++ /dev/null @@ -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; - script: string; -}; - -const lanes: Record = { - "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); diff --git a/test/e2e/qa-lab/runtime/docker-e2e-lane.fixture.test.ts b/test/e2e/qa-lab/runtime/docker-e2e-lane.fixture.test.ts new file mode 100644 index 00000000000..5bea8455717 --- /dev/null +++ b/test/e2e/qa-lab/runtime/docker-e2e-lane.fixture.test.ts @@ -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 "); + 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(); + }); +}); diff --git a/test/e2e/qa-lab/runtime/docker-e2e-lane.fixture.ts b/test/e2e/qa-lab/runtime/docker-e2e-lane.fixture.ts new file mode 100644 index 00000000000..1e1bf83ed1c --- /dev/null +++ b/test/e2e/qa-lab/runtime/docker-e2e-lane.fixture.ts @@ -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; + 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; + +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 `, + "", + "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); +} diff --git a/test/e2e/qa-lab/runtime/docker-e2e-lane.ts b/test/e2e/qa-lab/runtime/docker-e2e-lane.ts new file mode 100644 index 00000000000..c99df27661d --- /dev/null +++ b/test/e2e/qa-lab/runtime/docker-e2e-lane.ts @@ -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);