refactor(qa-lab): split suite execution (#113974)

* refactor(qa-lab): split suite execution

* refactor(qa-lab): tighten suite module contracts

* refactor(qa-lab): keep suite environment private
This commit is contained in:
Peter Steinberger
2026-07-25 20:43:51 -07:00
committed by GitHub
parent 5d3405915d
commit c66f0d340b
9 changed files with 1884 additions and 1569 deletions

View File

@@ -235,7 +235,6 @@ extensions/qa-lab/src/runtime-tool-fixture.ts
extensions/qa-lab/src/scorecard-taxonomy.ts
extensions/qa-lab/src/suite-launch.runtime.test.ts
extensions/qa-lab/src/suite-launch.runtime.ts
extensions/qa-lab/src/suite.ts
extensions/qa-lab/src/test-file-scenario-runner.test.ts
extensions/qa-lab/web/src/app.ts
extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts

View File

@@ -0,0 +1,285 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawCrablineChannelDriverSelection } from "@openclaw/crabline";
import { assertQaSuiteArtifactWritten } from "./artifact-assertion.js";
import {
hasQaCrablineArtifactPath,
resolveQaCrablineChannelDriverArtifactPaths,
type QaSuiteChannelDriverSelection,
} from "./crabline-artifacts.js";
import { buildQaSuiteEvidenceSummary, QA_EVIDENCE_FILENAME } from "./evidence-summary.js";
import type { QaProviderMode } from "./model-selection.js";
import type { QaTransportAdapter } from "./qa-transport.js";
import { renderQaMarkdownReport, type QaReportScenario } from "./report.js";
import type { RuntimeId } from "./runtime-parity.js";
import type { QaSeedScenarioWithSource } from "./scenario-catalog.js";
import type { QaScorecardChannelDriver, QaScorecardEvidenceMode } from "./scorecard-taxonomy.js";
import { splitModelRef } from "./suite-planning.js";
import { countQaSuiteFailedScenarios, type QaSuiteSummaryJson } from "./suite-summary.js";
import { createQaSuiteReportNotes } from "./suite-support.js";
import type { QaSuiteScenarioResult } from "./suite-types.js";
type QaCrablineRuntime = typeof import("@openclaw/crabline");
type QaCrablineChannelDriverSmokeResult = Awaited<
ReturnType<QaCrablineRuntime["runOpenClawCrablineChannelDriverSmoke"]>
>;
export type QaSuiteSummaryJsonParams = {
scenarios: QaSuiteScenarioResult[];
startedAt: Date;
finishedAt: Date;
metrics?: QaSuiteSummaryJson["metrics"];
evidence?: QaSuiteSummaryJson["evidence"];
providerMode: QaProviderMode;
primaryModel: string;
alternateModel: string;
fastMode: boolean;
concurrency: number;
channelDriver?: QaScorecardChannelDriver | null;
channelDriverSelection?: QaSuiteChannelDriverSelection | null;
scenarioIds?: readonly string[];
runtimePair?: [RuntimeId, RuntimeId];
};
/**
* Strongly-typed shape of `qa-suite-summary.json`. The GPT-5.6 Luna parity gate
* (agentic-parity-report.ts, #64441) and any future parity wrapper can
* import this type instead of re-declaring the shape, so changes to the
* summary schema propagate through to every consumer at type-check time.
*/
export type QaSuiteGatewayRssSample = NonNullable<
NonNullable<QaSuiteSummaryJson["metrics"]>["gatewayProcessRssSamples"]
>[number];
export type QaSuiteGatewayHeapSnapshot = NonNullable<
NonNullable<QaSuiteSummaryJson["metrics"]>["gatewayHeapSnapshots"]
>[number];
/**
* Pure-ish JSON builder for qa-suite-summary.json. Exported so the GPT-5.6 Luna
* parity gate (agentic-parity-report.ts, #64441) and any future parity
* runner can assert-and-trust the provider/model that produced a given
* summary instead of blindly accepting the caller's candidateLabel /
* baselineLabel. Without the `run` block, a maintainer who swaps candidate
* and baseline summary paths could silently produce a mislabeled verdict.
*
* `scenarioIds` is only recorded when the caller passed a non-empty array
* (an explicit scenario selection). A missing or empty array means "no
* filter, full lane-selected catalog", which the summary encodes as `null`
* so parity/report tooling doesn't mistake a full run for an explicit
* empty selection.
*/
export function buildQaSuiteSummaryJson(params: QaSuiteSummaryJsonParams): QaSuiteSummaryJson {
const primarySplit = splitModelRef(params.primaryModel);
const alternateSplit = splitModelRef(params.alternateModel);
return {
scenarios: params.scenarios,
counts: {
total: params.scenarios.length,
passed: params.scenarios.filter((scenario) => scenario.status === "pass").length,
failed: countQaSuiteFailedScenarios(params.scenarios),
skipped: params.scenarios.filter((scenario) => scenario.status === "skip").length,
},
...(params.metrics ? { metrics: params.metrics } : {}),
...(params.evidence ? { evidence: params.evidence } : {}),
run: {
startedAt: params.startedAt.toISOString(),
finishedAt: params.finishedAt.toISOString(),
providerMode: params.providerMode,
primaryModel: params.primaryModel,
primaryProvider: primarySplit?.provider ?? null,
primaryModelName: primarySplit?.model ?? null,
alternateModel: params.alternateModel,
alternateProvider: alternateSplit?.provider ?? null,
alternateModelName: alternateSplit?.model ?? null,
fastMode: params.fastMode,
concurrency: params.concurrency,
channelDriver: params.channelDriver ?? params.channelDriverSelection?.channelDriver ?? null,
channel: params.channelDriverSelection?.channel ?? null,
channelCapabilityMatrixPath: params.channelDriverSelection?.capabilityMatrixPath ?? null,
channelDriverSmokePath: params.channelDriverSelection?.smokeArtifactPath ?? null,
scenarioIds:
params.scenarioIds && params.scenarioIds.length > 0 ? [...params.scenarioIds] : null,
runtimePair: params.runtimePair ?? null,
},
};
}
export async function writeQaSuiteArtifacts(params: {
repoRoot?: string;
outputDir: string;
startedAt: Date;
finishedAt: Date;
scenarios: QaSuiteScenarioResult[];
scenarioDefinitions?: readonly QaSeedScenarioWithSource[];
evidenceMode?: QaScorecardEvidenceMode;
metrics?: QaSuiteSummaryJson["metrics"];
transport: QaTransportAdapter;
// Reuse the canonical QaProviderMode union instead of re-declaring it
// inline. Loop 6 already unified `QaSuiteSummaryJsonParams.providerMode`
// on this type; keeping the writer in sync prevents drift when model-
// selection.ts adds a new provider mode.
providerMode: QaProviderMode;
primaryModel: string;
alternateModel: string;
fastMode: boolean;
concurrency: number;
channelDriver?: QaScorecardChannelDriver | null;
channelDriverSelection?: OpenClawCrablineChannelDriverSelection | null;
isolatedWorkers?: boolean;
scenarioIds?: readonly string[];
runtimePair?: [RuntimeId, RuntimeId];
writeEvidenceFile?: boolean;
runCrablineChannelDriverSmoke?: (
params: Parameters<QaCrablineRuntime["runOpenClawCrablineChannelDriverSmoke"]>[0],
) => Promise<QaCrablineChannelDriverSmokeResult>;
}) {
const reportPath = path.join(params.outputDir, "qa-suite-report.md");
const summaryPath = path.join(params.outputDir, "qa-suite-summary.json");
const evidencePath = path.join(params.outputDir, QA_EVIDENCE_FILENAME);
const crablineChannelDriverSelection = params.channelDriverSelection;
// Non-Crabline package acceptance mounts this source without plugin-local
// dependencies. Keep the owner runtime outside every unrelated live path.
const crablineRuntime = crablineChannelDriverSelection
? await import("@openclaw/crabline")
: undefined;
let crablineChannelDriverSmoke: QaCrablineChannelDriverSmokeResult | undefined;
if (crablineChannelDriverSelection) {
const runCrablineChannelDriverSmoke =
params.runCrablineChannelDriverSmoke ??
crablineRuntime?.runOpenClawCrablineChannelDriverSmoke;
if (!runCrablineChannelDriverSmoke) {
throw new Error("Crabline runtime did not provide its channel-driver smoke helper.");
}
crablineChannelDriverSmoke = await runCrablineChannelDriverSmoke({
outputDir: params.outputDir,
selection: crablineChannelDriverSelection,
});
}
const crablineChannelDriverArtifactPaths = resolveQaCrablineChannelDriverArtifactPaths({
result: crablineChannelDriverSmoke,
selection: crablineChannelDriverSelection,
});
const effectiveChannelDriverSelection: QaSuiteChannelDriverSelection | null | undefined =
crablineChannelDriverSelection && crablineChannelDriverArtifactPaths
? {
...crablineChannelDriverSelection,
...crablineChannelDriverArtifactPaths,
}
: crablineChannelDriverSelection;
const report = renderQaMarkdownReport({
title: "OpenClaw QA Scenario Suite",
startedAt: params.startedAt,
finishedAt: params.finishedAt,
checks: [],
scenarios: params.scenarios.map((scenario) => ({
name: scenario.name,
status: scenario.status,
details: scenario.details,
steps: scenario.steps,
})) satisfies QaReportScenario[],
notes: createQaSuiteReportNotes({
...params,
channelDriverSelection: effectiveChannelDriverSelection,
createCrablineChannelReportNotes: crablineRuntime?.createOpenClawCrablineChannelReportNotes,
}),
});
const evidence =
params.scenarioDefinitions && params.scenarioDefinitions.length > 0
? buildQaSuiteEvidenceSummary({
artifactPaths: [
{ kind: "summary", path: path.basename(summaryPath) },
{ kind: "report", path: path.basename(reportPath) },
...(effectiveChannelDriverSelection
? [
{
kind: "channel-capability-matrix",
path: effectiveChannelDriverSelection.capabilityMatrixPath,
},
{
kind: "channel-driver-smoke",
path: effectiveChannelDriverSelection.smokeArtifactPath,
},
]
: []),
],
evidenceMode: params.evidenceMode,
channelId: params.channelDriverSelection?.channel ?? params.transport.id,
channelDriver: params.channelDriver ?? params.channelDriverSelection?.channelDriver,
env: process.env,
generatedAt: params.finishedAt.toISOString(),
primaryModel: params.primaryModel,
providerMode: params.providerMode,
repoRoot: params.repoRoot,
scenarioDefinitions: params.scenarioDefinitions,
scenarioResults: params.scenarios,
})
: undefined;
if (
crablineChannelDriverSelection &&
crablineChannelDriverSmoke &&
!hasQaCrablineArtifactPath(crablineChannelDriverSmoke.capabilityMatrixPath)
) {
await fs.writeFile(
path.join(params.outputDir, crablineChannelDriverSelection.capabilityMatrixPath),
`${JSON.stringify(
{
version: 1,
source: "openclaw/crabline",
channelDriver: crablineChannelDriverSelection.channelDriver,
selectedChannel: crablineChannelDriverSelection.channel,
manifestPath: crablineChannelDriverSmoke.manifestPath,
report: crablineChannelDriverSmoke.capabilityReport,
},
null,
2,
)}\n`,
"utf8",
);
}
if (
crablineChannelDriverSelection &&
crablineChannelDriverSmoke &&
!hasQaCrablineArtifactPath(crablineChannelDriverSmoke.smokeArtifactPath)
) {
await fs.writeFile(
path.join(params.outputDir, crablineChannelDriverSelection.smokeArtifactPath),
`${JSON.stringify(
{
version: 1,
source: "openclaw/crabline",
channelDriver: crablineChannelDriverSelection.channelDriver,
selectedChannel: crablineChannelDriverSelection.channel,
manifestPath: crablineChannelDriverSmoke.manifestPath,
smoke: crablineChannelDriverSmoke.smoke,
},
null,
2,
)}\n`,
"utf8",
);
}
const writeEvidenceFile = params.writeEvidenceFile ?? true;
await fs.writeFile(reportPath, report, "utf8");
if (evidence && writeEvidenceFile) {
await fs.writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, "utf8");
}
await fs.writeFile(
summaryPath,
`${JSON.stringify(
buildQaSuiteSummaryJson({
...params,
channelDriverSelection: effectiveChannelDriverSelection,
}),
null,
2,
)}\n`,
"utf8",
);
await assertQaSuiteArtifactWritten("report", reportPath);
await assertQaSuiteArtifactWritten("summary", summaryPath);
if (evidence && writeEvidenceFile) {
await assertQaSuiteArtifactWritten("evidence", evidencePath);
}
return { evidence, evidencePath, report, reportPath, summaryPath };
}

View File

@@ -0,0 +1,332 @@
import path from "node:path";
import { disposeRegisteredAgentHarnesses } from "openclaw/plugin-sdk/agent-harness";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { QaLabLatestReport, QaLabScenarioOutcome } from "./lab-server.types.js";
import { sanitizeQaProgressValue as sanitizeQaSuiteProgressValue } from "./progress-format.js";
import { writeQaSuiteArtifacts } from "./suite-artifacts.js";
import { mapQaSuiteWithConcurrency, resolveQaSuiteWorkerStartStaggerMs } from "./suite-planning.js";
import { buildQaIsolatedScenarioWorkerParams } from "./suite-support.js";
import type {
QaSuiteResolvedRunContext,
QaSuiteResult,
QaSuiteRunner,
QaSuiteRunParams,
QaSuiteScenarioResult,
} from "./suite-types.js";
import {
createQaSuiteTransportAdapter,
requireQaSuiteStartLab,
runQaSuiteCleanupSteps,
throwQaSuiteCleanupErrors,
writeQaSuiteProgress,
} from "./suite.js";
export async function runQaFlowSuiteIsolated(
params: QaSuiteRunParams | undefined,
context: QaSuiteResolvedRunContext,
runQaFlowSuite: QaSuiteRunner,
): Promise<QaSuiteResult> {
const {
startedAt,
repoRoot,
outputDir,
transportId,
selectedScenarios,
providerMode,
primaryModel,
alternateModel,
fastMode,
concurrency,
progressEnabled,
} = context;
const ownsLab = !params?.lab;
const startLab = requireQaSuiteStartLab(params?.startLab);
const lab =
params?.lab ??
(await startLab({
repoRoot,
host: "127.0.0.1",
port: 0,
embeddedGateway: "disabled",
}));
const transportFactoryResult = await createQaSuiteTransportAdapter({
adapterFactories: params?.adapterFactories,
channelDriver: params?.channelDriver,
channelId: params?.channelId,
channelDriverSelection: params?.channelDriverSelection,
adapterOptions: {
...params?.adapterOptions,
scenarioIds: selectedScenarios.map((scenario) => scenario.id),
},
cleanupOnFailure: ownsLab ? () => lab.stop() : undefined,
outputDir,
state: lab.state,
transportId,
});
const transport = transportFactoryResult.adapter;
const liveScenarioOutcomes: QaLabScenarioOutcome[] = selectedScenarios.map((scenario) => ({
id: scenario.id,
name: scenario.title,
status: "pending",
}));
const updateScenarioRun = () =>
lab.setScenarioRun({
kind: "suite",
status: "running",
startedAt: startedAt.toISOString(),
scenarios: [...liveScenarioOutcomes],
});
const completedScenarioResults: Array<QaSuiteScenarioResult | undefined> = Array.from({
length: selectedScenarios.length,
});
let artifactWriteQueue = Promise.resolve();
const writePartialArtifacts = () => {
const partialScenarios = completedScenarioResults.filter(
(scenario): scenario is QaSuiteScenarioResult => scenario !== undefined,
);
const completedScenarioDefinitions = completedScenarioResults.flatMap((scenario, index) =>
scenario === undefined || selectedScenarios[index] === undefined
? []
: [selectedScenarios[index]],
);
if (partialScenarios.length === 0) {
return;
}
artifactWriteQueue = artifactWriteQueue
.then(async () => {
const partialFinishedAt = new Date();
const { report, reportPath } = await writeQaSuiteArtifacts({
repoRoot,
outputDir,
startedAt,
finishedAt: partialFinishedAt,
scenarios: partialScenarios,
scenarioDefinitions: completedScenarioDefinitions,
evidenceMode: params?.evidenceMode,
transport,
providerMode,
primaryModel,
alternateModel,
fastMode,
concurrency,
channelDriver: params?.channelDriver,
channelDriverSelection: params?.channelDriverSelection,
isolatedWorkers: true,
writeEvidenceFile: params?.writeEvidenceFile,
scenarioIds:
params?.scenarioIds && params.scenarioIds.length > 0
? selectedScenarios.map((scenario) => scenario.id)
: undefined,
});
lab.setLatestReport({
outputPath: reportPath,
markdown: report,
generatedAt: partialFinishedAt.toISOString(),
} satisfies QaLabLatestReport);
})
.catch((error: unknown) => {
writeQaSuiteProgress(
progressEnabled,
`partial artifact write failed: ${sanitizeQaSuiteProgressValue(formatErrorMessage(error))}`,
);
});
};
let isolatedRunFailed = false;
let isolatedRunError: unknown;
let parentTransportCleaned = false;
try {
if (params?.channelDriver === "live") {
// The parent only renders aggregate artifacts. Release its live credentials
// before child workers acquire the same exclusive transport lease.
parentTransportCleaned = true;
await transportFactoryResult.cleanupWithoutGateway();
}
updateScenarioRun();
const workerStartStaggerMs =
params?.workerStartStaggerMs ?? resolveQaSuiteWorkerStartStaggerMs(concurrency);
writeQaSuiteProgress(progressEnabled, `scenario start stagger=${workerStartStaggerMs}ms`);
const scenarios: QaSuiteScenarioResult[] = await mapQaSuiteWithConcurrency(
selectedScenarios,
concurrency,
async (scenario, index): Promise<QaSuiteScenarioResult> => {
const scenarioIdForLog = sanitizeQaSuiteProgressValue(scenario.id);
writeQaSuiteProgress(
progressEnabled,
`scenario start (${index + 1}/${selectedScenarios.length}): ${scenarioIdForLog}`,
);
liveScenarioOutcomes[index] = {
id: scenario.id,
name: scenario.title,
status: "running",
startedAt: new Date().toISOString(),
};
updateScenarioRun();
try {
const scenarioOutputDir = path.join(outputDir, "scenarios", scenario.id);
const result: QaSuiteResult = await runQaFlowSuite(
buildQaIsolatedScenarioWorkerParams({
repoRoot,
outputDir: scenarioOutputDir,
providerMode,
transportId,
channelDriver: params?.channelDriver,
channelDriverSelection: params?.channelDriverSelection,
primaryModel,
alternateModel,
fastMode,
startLab,
scenario,
input: params,
}),
);
const scenarioResult: QaSuiteScenarioResult =
result.scenarios[0] ??
({
name: scenario.title,
status: "fail",
details: "isolated scenario run returned no scenario result",
steps: [
{
name: "isolated scenario worker",
status: "fail",
details: "isolated scenario run returned no scenario result",
},
],
} satisfies QaSuiteScenarioResult);
liveScenarioOutcomes[index] = {
id: scenario.id,
name: scenario.title,
status: scenarioResult.status,
details: scenarioResult.details,
steps: scenarioResult.steps,
startedAt: liveScenarioOutcomes[index]?.startedAt,
finishedAt: new Date().toISOString(),
};
updateScenarioRun();
writeQaSuiteProgress(
progressEnabled,
`scenario ${scenarioResult.status} (${index + 1}/${selectedScenarios.length}): ${scenarioIdForLog}`,
);
completedScenarioResults[index] = scenarioResult;
writePartialArtifacts();
return scenarioResult;
} catch (error) {
const details = formatErrorMessage(error);
const scenarioResult = {
name: scenario.title,
status: "fail",
details,
steps: [
{
name: "isolated scenario worker",
status: "fail",
details,
},
],
} satisfies QaSuiteScenarioResult;
liveScenarioOutcomes[index] = {
id: scenario.id,
name: scenario.title,
status: "fail",
details,
steps: scenarioResult.steps,
startedAt: liveScenarioOutcomes[index]?.startedAt,
finishedAt: new Date().toISOString(),
};
updateScenarioRun();
writeQaSuiteProgress(
progressEnabled,
`scenario fail (${index + 1}/${selectedScenarios.length}): ${scenarioIdForLog}`,
);
completedScenarioResults[index] = scenarioResult;
writePartialArtifacts();
return scenarioResult;
}
},
{
startStaggerMs: workerStartStaggerMs,
shouldStop: (result) => params?.failFast === true && result.status === "fail",
},
);
await artifactWriteQueue;
const finishedAt = new Date();
const failedCount = scenarios.filter((scenario) => scenario.status === "fail").length;
const skippedCount = scenarios.filter((scenario) => scenario.status === "skip").length;
lab.setScenarioRun({
kind: "suite",
status: "completed",
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
scenarios: [...liveScenarioOutcomes],
});
const { evidence, evidencePath, report, reportPath, summaryPath } = await writeQaSuiteArtifacts(
{
repoRoot,
outputDir,
startedAt,
finishedAt,
scenarios,
scenarioDefinitions: selectedScenarios,
evidenceMode: params?.evidenceMode,
transport,
providerMode,
primaryModel,
alternateModel,
fastMode,
concurrency,
channelDriver: params?.channelDriver,
channelDriverSelection: params?.channelDriverSelection,
isolatedWorkers: true,
writeEvidenceFile: params?.writeEvidenceFile,
// When the caller supplied an explicit non-empty --scenario filter,
// record the executed (post-selectQaFlowSuiteScenarios-normalized) ids
// so the summary matches what actually ran. When the caller passed
// nothing or an empty array ("no filter, full lane catalog"),
// preserve the unfiltered = null semantic so the summary stays
// distinguishable from an explicit all-scenarios selection.
scenarioIds:
params?.scenarioIds && params.scenarioIds.length > 0
? selectedScenarios.map((scenario) => scenario.id)
: undefined,
},
);
lab.setLatestReport({
outputPath: reportPath,
markdown: report,
generatedAt: finishedAt.toISOString(),
} satisfies QaLabLatestReport);
writeQaSuiteProgress(
progressEnabled,
`run complete: passed=${scenarios.length - failedCount - skippedCount} failed=${failedCount} skipped=${skippedCount} total=${scenarios.length}`,
);
return {
outputDir,
evidence,
evidencePath,
reportPath,
summaryPath,
report,
scenarios,
watchUrl: lab.baseUrl,
} satisfies QaSuiteResult;
} catch (error) {
isolatedRunFailed = true;
isolatedRunError = error;
throw error;
} finally {
const cleanupSteps: Array<() => Promise<void>> = [
...(!parentTransportCleaned ? [() => transportFactoryResult.cleanupWithoutGateway()] : []),
() => disposeRegisteredAgentHarnesses(),
];
if (ownsLab) {
cleanupSteps.push(() => lab.stop());
}
const cleanupErrors = await runQaSuiteCleanupSteps(cleanupSteps);
throwQaSuiteCleanupErrors({
cleanupErrors,
runFailed: isolatedRunFailed,
runError: isolatedRunError,
});
}
}

View File

@@ -0,0 +1,427 @@
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { disposeRegisteredAgentHarnesses } from "openclaw/plugin-sdk/agent-harness";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { startQaGatewayChild } from "./gateway-child.js";
import type { QaLabLatestReport, QaLabScenarioOutcome } from "./lab-server.types.js";
import { sanitizeQaProgressValue as sanitizeQaSuiteProgressValue } from "./progress-format.js";
import { startQaProviderServer } from "./providers/server-runtime.js";
import { captureRuntimeParityCell } from "./runtime-parity.js";
import {
type QaSuiteGatewayHeapSnapshot,
type QaSuiteGatewayRssSample,
writeQaSuiteArtifacts,
} from "./suite-artifacts.js";
import { applyQaMergePatch, collectQaSuiteTransportPolicy } from "./suite-planning.js";
import { runQaSuiteRoundTripProbe } from "./suite-round-trip.js";
import { waitForGatewayHealthy, waitForTransportReady } from "./suite-runtime-gateway.js";
import {
buildQaGatewayHeapCheckpointRuntimeEnvPatch,
buildQaRuntimeEnvPatch,
mergeQaRuntimeEnvPatches,
runQaScenarioWithFlakeRetry,
} from "./suite-support.js";
import type {
QaSuiteEnvironment,
QaSuiteResolvedRunContext,
QaSuiteResult,
QaSuiteRunParams,
QaSuiteScenarioRunner,
QaSuiteScenarioResult,
} from "./suite-types.js";
import {
createQaSuiteTransportAdapter,
buildQaSuiteRuntimeMetrics,
captureGatewayHeapSnapshotCheckpoint,
requireQaSuiteStartLab,
resolveQaSuiteTransportReadyTimeoutMs,
runQaFlowSuiteCleanupPlan,
throwQaSuiteCleanupErrors,
waitForQaLabReadyOrStopOwned,
writeQaSuiteProgress,
} from "./suite.js";
import { closeQaWebSessions } from "./web-runtime.js";
export async function runQaFlowSuiteStandard(
params: QaSuiteRunParams | undefined,
context: QaSuiteResolvedRunContext,
runScenarioDefinition: QaSuiteScenarioRunner,
): Promise<QaSuiteResult> {
const {
startedAt,
repoRoot,
outputDir,
transportId,
selectedScenarios,
providerMode,
primaryModel,
alternateModel,
fastMode,
enabledPluginIds,
gatewayConfigPatch,
gatewayRuntimeOptions,
concurrency,
progressEnabled,
gatewayHeapCheckpointsEnabled,
} = context;
const ownsLab = !params?.lab;
const startLab = params?.startLab;
writeQaSuiteProgress(progressEnabled, "lab start");
const lab =
params?.lab ??
(await requireQaSuiteStartLab(startLab)({
repoRoot,
host: "127.0.0.1",
port: 0,
embeddedGateway: "disabled",
}));
writeQaSuiteProgress(progressEnabled, `lab ready: ${sanitizeQaSuiteProgressValue(lab.baseUrl)}`);
await waitForQaLabReadyOrStopOwned({ lab, ownsLab });
const transportFactoryResult = await createQaSuiteTransportAdapter({
adapterFactories: params?.adapterFactories,
channelDriver: params?.channelDriver,
channelId: params?.channelId,
channelDriverSelection: params?.channelDriverSelection,
adapterOptions: {
...params?.adapterOptions,
scenarioIds: selectedScenarios.map((scenario) => scenario.id),
},
cleanupOnFailure: ownsLab ? () => lab.stop() : undefined,
outputDir,
transportPolicy: collectQaSuiteTransportPolicy(selectedScenarios),
state: lab.state,
transportId,
});
const transport = transportFactoryResult.adapter;
let mock: Awaited<ReturnType<typeof startQaProviderServer>> | undefined;
let gateway: Awaited<ReturnType<typeof startQaGatewayChild>> | undefined;
let env: QaSuiteEnvironment | undefined;
let preserveGatewayRuntimeDir: string | undefined;
let runFailed = false;
let runError: unknown;
try {
writeQaSuiteProgress(progressEnabled, `provider start: ${providerMode}`);
const activeMock = await startQaProviderServer(providerMode, {
modelRefs: [primaryModel, alternateModel],
});
mock = activeMock;
writeQaSuiteProgress(
progressEnabled,
`provider ready: ${sanitizeQaSuiteProgressValue(activeMock?.baseUrl ?? "live")}`,
);
writeQaSuiteProgress(progressEnabled, "gateway start");
const activeGateway = await startQaGatewayChild({
repoRoot,
command: params?.sutOpenClawCommand,
providerBaseUrl: activeMock ? `${activeMock.baseUrl}/v1` : undefined,
transport,
transportBaseUrl: lab.listenUrl,
controlUiAllowedOrigins: [lab.listenUrl],
providerMode,
primaryModel,
alternateModel,
fastMode,
thinkingDefault: params?.thinkingDefault,
forcedRuntime: params?.forcedRuntime,
claudeCliAuthMode: params?.claudeCliAuthMode,
controlUiEnabled: params?.controlUiEnabled ?? true,
enabledPluginIds,
forwardHostHome: gatewayRuntimeOptions?.forwardHostHome,
mutateConfig: gatewayConfigPatch
? (cfg) => applyQaMergePatch(cfg, gatewayConfigPatch) as OpenClawConfig
: undefined,
runtimeEnvPatch: mergeQaRuntimeEnvPatches(
buildQaRuntimeEnvPatch({
providerMode,
forcedRuntime: params?.forcedRuntime,
mockBaseUrl: activeMock?.baseUrl,
}),
transport.createRuntimeEnvPatch?.(),
buildQaGatewayHeapCheckpointRuntimeEnvPatch(),
),
});
gateway = activeGateway;
writeQaSuiteProgress(
progressEnabled,
`gateway ready: ${sanitizeQaSuiteProgressValue(activeGateway.baseUrl)}`,
);
lab.setControlUi({
controlUiProxyTarget: activeGateway.baseUrl,
controlUiProxyToken: activeGateway.token,
});
const activeEnv: QaSuiteEnvironment = {
lab,
mock: activeMock,
gateway: activeGateway,
outputDir,
// YAML scenarios should see the full staged gateway config, not just
// the transport fragment. Routing/session/plugin assertions depend on it.
cfg: activeGateway.cfg,
transport,
repoRoot,
providerMode,
primaryModel,
alternateModel,
webSessionIds: new Set(),
};
env = activeEnv;
const transportReadyTimeoutMs = resolveQaSuiteTransportReadyTimeoutMs(
params?.transportReadyTimeoutMs,
);
// The gateway child already waits for /readyz before returning, but the
// selected transport can still be finishing account startup. Pay that
// readiness cost once here so the first scenario does not race bootstrap.
await waitForTransportReady(activeEnv, transportReadyTimeoutMs).catch(async () => {
await waitForGatewayHealthy(activeEnv, transportReadyTimeoutMs);
await waitForTransportReady(activeEnv, transportReadyTimeoutMs);
});
await sleep(1_000);
const scenarios: QaSuiteScenarioResult[] = [];
const liveScenarioOutcomes: QaLabScenarioOutcome[] = selectedScenarios.map((scenario) => ({
id: scenario.id,
name: scenario.title,
status: "pending",
}));
lab.setScenarioRun({
kind: "suite",
status: "running",
startedAt: startedAt.toISOString(),
scenarios: liveScenarioOutcomes,
});
const gatewayProcessRssSamples: QaSuiteGatewayRssSample[] = [];
const sampleGatewayProcessRss = (label: string) => {
const gatewayProcessRssBytes = activeGateway.getProcessRssBytes?.() ?? null;
if (gatewayProcessRssBytes !== null) {
gatewayProcessRssSamples.push({
label,
at: new Date().toISOString(),
gatewayProcessRssBytes,
});
}
return gatewayProcessRssBytes;
};
const gatewayProcessCpuStartMs = activeGateway.getProcessCpuMs?.() ?? null;
const gatewayProcessRssStartBytes = sampleGatewayProcessRss("suite-start");
const gatewayHeapSnapshots: QaSuiteGatewayHeapSnapshot[] = [];
const captureGatewayHeapCheckpoint = async (label: string) => {
if (!gatewayHeapCheckpointsEnabled) {
return;
}
const snapshot = await captureGatewayHeapSnapshotCheckpoint({
gateway: activeGateway,
outputDir,
label,
});
if (snapshot) {
gatewayHeapSnapshots.push(snapshot);
}
};
await captureGatewayHeapCheckpoint("suite-start");
for (const [index, scenario] of selectedScenarios.entries()) {
const scenarioIdForLog = sanitizeQaSuiteProgressValue(scenario.id);
writeQaSuiteProgress(
progressEnabled,
`scenario start (${index + 1}/${selectedScenarios.length}): ${scenarioIdForLog}`,
);
sampleGatewayProcessRss(`scenario:${scenario.id}:start`);
liveScenarioOutcomes[index] = {
id: scenario.id,
name: scenario.title,
status: "running",
startedAt: new Date().toISOString(),
};
lab.setScenarioRun({
kind: "suite",
status: "running",
startedAt: startedAt.toISOString(),
scenarios: [...liveScenarioOutcomes],
});
const runSelectedScenario = () => runScenarioDefinition(activeEnv, scenario);
const scenarioRetryCount =
scenario.execution.kind === "flow" ? scenario.execution.retryCount : undefined;
let result: QaSuiteScenarioResult =
scenarioRetryCount === 0
? await runSelectedScenario()
: await runQaScenarioWithFlakeRetry(runSelectedScenario, () =>
writeQaSuiteProgress(
progressEnabled,
`scenario retry (${index + 1}/${selectedScenarios.length}): ${scenarioIdForLog}`,
),
);
if (result.status === "pass" && params?.roundTripProbe?.scenarioId === scenario.id) {
const probeResult = await runQaSuiteRoundTripProbe({
probe: params.roundTripProbe,
transport,
});
const probePassed = probeResult.passed >= params.roundTripProbe.count;
result = {
...result,
status: probePassed ? "pass" : "fail",
details: [result.details, probeResult.details].filter(Boolean).join(" | "),
timing: probeResult.timing,
steps: [
...result.steps,
{
name: "Round-trip samples",
status: probePassed ? "pass" : "fail",
details: probeResult.details,
},
],
};
}
sampleGatewayProcessRss(`scenario:${scenario.id}:finish`);
scenarios.push(result);
writeQaSuiteProgress(
progressEnabled,
`scenario ${result.status} (${index + 1}/${selectedScenarios.length}): ${scenarioIdForLog}`,
);
liveScenarioOutcomes[index] = {
id: scenario.id,
name: scenario.title,
status: result.status,
details: result.details,
steps: result.steps,
startedAt: liveScenarioOutcomes[index]?.startedAt,
finishedAt: new Date().toISOString(),
};
lab.setScenarioRun({
kind: "suite",
status: "running",
startedAt: startedAt.toISOString(),
scenarios: [...liveScenarioOutcomes],
});
if (params?.failFast === true && result.status === "fail") {
break;
}
}
const runtimeParityScenario = scenarios[0];
const runtimeParityCell =
params?.captureRuntimeParityCell &&
params.forcedRuntime &&
selectedScenarios.length === 1 &&
runtimeParityScenario
? await captureRuntimeParityCell({
runtime: params.forcedRuntime,
gateway: activeGateway,
scenarioResult: runtimeParityScenario,
wallClockMs: Math.max(1, Date.now() - startedAt.getTime()),
mockBaseUrl: activeMock?.baseUrl,
})
: undefined;
const finishedAt = new Date();
await captureGatewayHeapCheckpoint("suite-finish");
const metrics = buildQaSuiteRuntimeMetrics({
startedAt,
finishedAt,
gatewayProcessCpuStartMs,
gatewayProcessCpuEndMs: activeGateway.getProcessCpuMs?.() ?? null,
gatewayProcessRssStartBytes,
gatewayProcessRssEndBytes: sampleGatewayProcessRss("suite-finish"),
gatewayProcessRssSamples,
gatewayHeapSnapshots,
});
const failedCount = scenarios.filter((scenario) => scenario.status === "fail").length;
const skippedCount = scenarios.filter((scenario) => scenario.status === "skip").length;
if (
scenarios.some((scenario) => scenario.status === "fail") ||
gatewayRuntimeOptions?.preserveDebugArtifacts === true
) {
preserveGatewayRuntimeDir = path.join(outputDir, "artifacts", "gateway-runtime");
}
lab.setScenarioRun({
kind: "suite",
status: "completed",
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
scenarios: [...liveScenarioOutcomes],
});
const { evidence, evidencePath, report, reportPath, summaryPath } = await writeQaSuiteArtifacts(
{
repoRoot,
outputDir,
startedAt,
finishedAt,
scenarios,
metrics,
scenarioDefinitions: selectedScenarios,
evidenceMode: params?.evidenceMode,
transport,
providerMode,
primaryModel,
alternateModel,
fastMode,
concurrency,
channelDriver: params?.channelDriver,
channelDriverSelection: params?.channelDriverSelection,
isolatedWorkers: false,
writeEvidenceFile: params?.writeEvidenceFile,
// Same "filtered → executed list, unfiltered → null" convention as
// the concurrent-path writeQaSuiteArtifacts call above.
scenarioIds:
params?.scenarioIds && params.scenarioIds.length > 0
? selectedScenarios.map((scenario) => scenario.id)
: undefined,
},
);
const latestReport = {
outputPath: reportPath,
markdown: report,
generatedAt: finishedAt.toISOString(),
} satisfies QaLabLatestReport;
lab.setLatestReport(latestReport);
writeQaSuiteProgress(
progressEnabled,
`run complete: passed=${scenarios.length - failedCount - skippedCount} failed=${failedCount} skipped=${skippedCount} total=${scenarios.length}`,
);
return {
outputDir,
evidence,
evidencePath,
reportPath,
summaryPath,
report,
scenarios,
watchUrl: lab.baseUrl,
...(runtimeParityCell ? { runtimeParityCell } : {}),
} satisfies QaSuiteResult;
} catch (error) {
runFailed = true;
runError = error;
preserveGatewayRuntimeDir = path.join(outputDir, "artifacts", "gateway-runtime");
throw error;
} finally {
const activeEnv = env;
const keepTemp = process.env.OPENCLAW_QA_KEEP_TEMP === "1" || false;
const activeGateway = gateway;
const activeMock = mock;
const cleanupErrors = await runQaFlowSuiteCleanupPlan({
closeWebSessions: activeEnv ? () => closeQaWebSessions(activeEnv.webSessionIds) : undefined,
cleanupTransportBeforeGatewayStop: () => transportFactoryResult.cleanupBeforeGatewayStop(),
cleanupTransportAfterGatewayStop: () => transportFactoryResult.cleanupAfterGatewayStop(),
stopGateway: activeGateway
? () =>
activeGateway.stop({
keepTemp,
preserveToDir: keepTemp ? undefined : preserveGatewayRuntimeDir,
})
: undefined,
disposeAgentHarnesses: () => disposeRegisteredAgentHarnesses(),
stopProvider: activeMock ? () => activeMock.stop() : undefined,
finishLab: ownsLab
? () => lab.stop()
: async () => {
lab.setControlUi({
controlUiUrl: null,
controlUiProxyTarget: null,
});
},
});
throwQaSuiteCleanupErrors({ cleanupErrors, runFailed, runError });
}
}

View File

@@ -0,0 +1,157 @@
import path from "node:path";
import {
defaultQaSuiteConcurrencyForTransport,
normalizeQaTransportId,
} from "./qa-transport-registry.js";
import { readQaBootstrapScenarioCatalog } from "./scenario-catalog.js";
import {
resolveRequestedQaSuiteModels,
resolveSelectedQaSuiteModels,
} from "./suite-model-selection.js";
import {
collectQaSuiteGatewayConfigPatch,
collectQaSuiteGatewayRuntimeOptions,
collectQaSuitePluginIds,
normalizeQaSuiteConcurrency,
resolveQaSuiteOutputDir,
selectQaFlowSuiteScenarios,
} from "./suite-planning.js";
import { runQaFlowSuiteIsolated } from "./suite-run-isolated.js";
import { runQaFlowSuiteStandard } from "./suite-run-standard.js";
import { runQaRuntimeParitySuite } from "./suite-runtime-parity-runner.js";
import { shouldCaptureGatewayHeapCheckpoints } from "./suite-support.js";
import type { QaSuiteResolvedRunContext, QaSuiteResult, QaSuiteRunParams } from "./suite-types.js";
import {
formatQaSuiteRunStartProgress,
runQaSuiteScenarioDefinitionForRuntime,
shouldLogQaSuiteProgress,
shouldRunQaSuiteWithIsolatedScenarioWorkers,
writeQaSuiteProgress,
} from "./suite.js";
export async function runQaFlowSuiteFromRuntime(params?: QaSuiteRunParams): Promise<QaSuiteResult> {
const startedAt = new Date();
const repoRoot = path.resolve(params?.repoRoot ?? process.cwd());
const requestedModels = resolveRequestedQaSuiteModels(params ?? {});
const transportId = normalizeQaTransportId(params?.transportId);
const outputDir = await resolveQaSuiteOutputDir(repoRoot, params?.outputDir);
const catalog = readQaBootstrapScenarioCatalog();
const channelDriver = params?.channelDriver ?? params?.channelDriverSelection?.channelDriver;
const selectedScenarios = selectQaFlowSuiteScenarios({
scenarios: catalog.scenarios,
scenarioIds: params?.scenarioIds,
providerMode: requestedModels.providerMode,
primaryModel: requestedModels.primaryModel,
channelDriver,
channel: params?.channelId ?? params?.channelDriverSelection?.channel,
claudeCliAuthMode: params?.claudeCliAuthMode,
});
const { alternateModel, fastMode, primaryModel, providerMode } = resolveSelectedQaSuiteModels({
alternateModelExplicit: params?.alternateModel !== undefined,
fastMode: params?.fastMode,
primaryModelExplicit: params?.primaryModel !== undefined,
requested: requestedModels,
scenarios: selectedScenarios,
});
if (
params?.roundTripProbe &&
!selectedScenarios.some((scenario) => scenario.id === params.roundTripProbe?.scenarioId)
) {
throw new Error(
`QA round-trip probe scenario is not selected: ${params.roundTripProbe.scenarioId}`,
);
}
if (params?.roundTripProbe && params.runtimePair) {
throw new Error("QA round-trip probes are not supported with runtime-pair runs.");
}
const enabledPluginIds = [
...new Set([
...collectQaSuitePluginIds(selectedScenarios),
...(params?.enabledPluginIds ?? []).map((pluginId) => pluginId.trim()).filter(Boolean),
...(params?.forcedRuntime && params.forcedRuntime !== "openclaw"
? [params.forcedRuntime]
: []),
]),
];
const gatewayConfigPatch = collectQaSuiteGatewayConfigPatch(
selectedScenarios,
params?.adapterOptions?.sutAccountId?.trim() ||
(channelDriver === "crabline" ? "default" : "sut"),
);
const gatewayRuntimeOptions = collectQaSuiteGatewayRuntimeOptions(selectedScenarios);
const concurrency = params?.failFast
? 1
: normalizeQaSuiteConcurrency(
params?.concurrency,
selectedScenarios.length,
params?.channelDriverSelection ? 1 : defaultQaSuiteConcurrencyForTransport(transportId),
);
const progressEnabled = shouldLogQaSuiteProgress();
const context: QaSuiteResolvedRunContext = {
startedAt,
repoRoot,
outputDir,
transportId,
selectedScenarios,
providerMode,
primaryModel,
alternateModel,
fastMode,
channelDriver,
enabledPluginIds,
gatewayConfigPatch,
gatewayRuntimeOptions,
concurrency,
progressEnabled,
gatewayHeapCheckpointsEnabled: shouldCaptureGatewayHeapCheckpoints(),
};
writeQaSuiteProgress(
progressEnabled,
formatQaSuiteRunStartProgress({
selectedScenarioCount: selectedScenarios.length,
concurrency,
transportId,
channelDriver: params?.channelDriver,
channelDriverSelection: params?.channelDriverSelection,
}),
);
const useIsolatedScenarioWorkers = shouldRunQaSuiteWithIsolatedScenarioWorkers({
scenarios: selectedScenarios,
concurrency,
lab: params?.lab,
startLab: params?.startLab,
});
if (params?.runtimePair) {
return await runQaRuntimeParitySuite({
runQaFlowSuite: runQaFlowSuiteFromRuntime,
adapterFactories: params.adapterFactories,
channelId: params.channelId,
adapterOptions: params.adapterOptions,
evidenceMode: params.evidenceMode,
repoRoot,
outputDir,
startedAt,
providerMode,
transportId,
channelDriverSelection: params.channelDriverSelection,
channelDriver: params.channelDriver,
primaryModel,
alternateModel,
fastMode,
thinkingDefault: params.thinkingDefault,
claudeCliAuthMode: params.claudeCliAuthMode,
enabledPluginIds: params.enabledPluginIds,
concurrency,
selectedScenarios,
startLab: params.startLab,
lab: params.lab,
progressEnabled,
scenarioIds: params.scenarioIds,
runtimePair: params.runtimePair,
writeEvidenceFile: params.writeEvidenceFile,
});
}
return useIsolatedScenarioWorkers
? await runQaFlowSuiteIsolated(params, context, runQaFlowSuiteFromRuntime)
: await runQaFlowSuiteStandard(params, context, runQaSuiteScenarioDefinitionForRuntime);
}

View File

@@ -0,0 +1,346 @@
import path from "node:path";
import type { OpenClawCrablineChannelDriverSelection } from "@openclaw/crabline";
import type { QaCliBackendAuthMode } from "./gateway-child.js";
import type {
QaLabLatestReport,
QaLabScenarioOutcome,
QaLabServerHandle,
} from "./lab-server.types.js";
import type { QaProviderMode } from "./model-selection.js";
import { sanitizeQaProgressValue as sanitizeQaSuiteProgressValue } from "./progress-format.js";
import type { QaThinkingLevel } from "./qa-gateway-config.js";
import type { QaTransportAdapterFactory, QaTransportId } from "./qa-transport-registry.js";
import {
isRuntimeParityResultPass,
runRuntimeParityScenario,
type RuntimeId,
type RuntimeParityCell,
type RuntimeParityResult,
} from "./runtime-parity.js";
import { readQaBootstrapScenarioCatalog } from "./scenario-catalog.js";
import type { QaScorecardChannelDriver, QaScorecardEvidenceMode } from "./scorecard-taxonomy.js";
import { writeQaSuiteArtifacts } from "./suite-artifacts.js";
import {
collectQaSuiteTransportPolicy,
mapQaSuiteWithConcurrency,
resolveQaSuiteWorkerStartStaggerMs,
scenarioRequiresControlUi,
} from "./suite-planning.js";
import { remapModelRefForForcedRuntime } from "./suite-support.js";
import type {
QaSuiteRunParams,
QaSuiteRunner,
QaSuiteScenarioResult,
QaSuiteStartLabFn,
QaSuiteResult,
} from "./suite-types.js";
import {
createQaSuiteTransportAdapter,
requireQaSuiteStartLab,
writeQaSuiteProgress,
} from "./suite.js";
function isRuntimeParityPass(result: RuntimeParityResult) {
return isRuntimeParityResultPass(result);
}
function formatRuntimeParityCellDetails(cell: RuntimeParityCell) {
const errors = [cell.transportErrorClass, cell.runtimeErrorClass].filter(Boolean).join(", ");
const sentinels = cell.sentinelFindings?.map((finding) => finding.kind).join(", ");
return [
`runtime=${cell.runtime}`,
`wallMs=${cell.wallClockMs}`,
`toolCalls=${cell.toolCalls.length}`,
`finalChars=${cell.finalText.length}`,
`tokens=${cell.usage.totalTokens}`,
...(errors ? [`errors=${errors}`] : []),
...(sentinels ? [`sentinels=${sentinels}`] : []),
].join(" ");
}
function buildRuntimeParityScenarioResult(params: {
scenarioName: string;
result: RuntimeParityResult;
}): QaSuiteScenarioResult {
const driftStepStatus = isRuntimeParityPass(params.result) ? "pass" : "fail";
const openclawCell = params.result.cells.openclaw;
return {
name: params.scenarioName,
status: driftStepStatus,
details: params.result.driftDetails ?? `runtime drift classified as ${params.result.drift}`,
steps: [
{
name: openclawCell.runtime,
status:
openclawCell.runtimeErrorClass || openclawCell.transportErrorClass ? "fail" : "pass",
details: formatRuntimeParityCellDetails(openclawCell),
},
{
name: params.result.cells.codex.runtime,
status:
params.result.cells.codex.runtimeErrorClass ||
params.result.cells.codex.transportErrorClass
? "fail"
: "pass",
details: formatRuntimeParityCellDetails(params.result.cells.codex),
},
{
name: "runtime drift",
status: driftStepStatus,
details: params.result.driftDetails ?? params.result.drift,
},
],
runtimeParity: params.result,
};
}
export async function runQaRuntimeParitySuite(params: {
runQaFlowSuite: QaSuiteRunner;
adapterOptions?: QaSuiteRunParams["adapterOptions"];
adapterFactories?: readonly QaTransportAdapterFactory[];
channelId?: string;
evidenceMode?: QaScorecardEvidenceMode;
repoRoot: string;
outputDir: string;
startedAt: Date;
providerMode: QaProviderMode;
transportId: QaTransportId;
primaryModel: string;
alternateModel: string;
fastMode: boolean;
thinkingDefault?: QaThinkingLevel;
claudeCliAuthMode?: QaCliBackendAuthMode;
enabledPluginIds?: string[];
channelDriver?: QaScorecardChannelDriver | null;
channelDriverSelection?: OpenClawCrablineChannelDriverSelection | null;
concurrency: number;
selectedScenarios: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"];
startLab?: QaSuiteStartLabFn;
lab?: QaLabServerHandle;
progressEnabled: boolean;
scenarioIds?: readonly string[];
runtimePair: [RuntimeId, RuntimeId];
writeEvidenceFile?: boolean;
}) {
const ownsLab = !params.lab;
const startLab = requireQaSuiteStartLab(params.startLab);
const lab =
params.lab ??
(await startLab({
repoRoot: params.repoRoot,
host: "127.0.0.1",
port: 0,
embeddedGateway: "disabled",
}));
const transportFactoryResult = await createQaSuiteTransportAdapter({
adapterFactories: params.adapterFactories,
channelDriver: params.channelDriver,
channelId: params.channelId,
channelDriverSelection: params.channelDriverSelection,
adapterOptions: params.adapterOptions,
cleanupOnFailure: ownsLab ? () => lab.stop() : undefined,
outputDir: params.outputDir,
transportPolicy: collectQaSuiteTransportPolicy(params.selectedScenarios),
state: lab.state,
transportId: params.transportId,
});
const transport = transportFactoryResult.adapter;
const liveScenarioOutcomes: QaLabScenarioOutcome[] = params.selectedScenarios.map((scenario) => ({
id: scenario.id,
name: scenario.title,
status: "pending",
}));
lab.setScenarioRun({
kind: "suite",
status: "running",
startedAt: params.startedAt.toISOString(),
scenarios: [...liveScenarioOutcomes],
});
try {
const scenarios = await mapQaSuiteWithConcurrency(
params.selectedScenarios,
params.concurrency,
async (scenario, index): Promise<QaSuiteScenarioResult> => {
const scenarioIdForLog = sanitizeQaSuiteProgressValue(scenario.id);
writeQaSuiteProgress(
params.progressEnabled,
`runtime pair start (${index + 1}/${params.selectedScenarios.length}): ${scenarioIdForLog}`,
);
liveScenarioOutcomes[index] = {
id: scenario.id,
name: scenario.title,
status: "running",
startedAt: new Date().toISOString(),
};
lab.setScenarioRun({
kind: "suite",
status: "running",
startedAt: params.startedAt.toISOString(),
scenarios: [...liveScenarioOutcomes],
});
const parity = await runRuntimeParityScenario({
scenarioId: scenario.id,
runtimeParityUsage: scenario.runtimeParityUsage,
runCell: async (runtime) => {
const cellOutputDir = path.join(
params.outputDir,
"runtime-cells",
scenario.id,
runtime,
);
const cellStartedAt = Date.now();
const cellResult = await params.runQaFlowSuite({
adapterFactories: params.adapterFactories,
channelId: params.channelId,
adapterOptions: params.adapterOptions,
repoRoot: params.repoRoot,
outputDir: cellOutputDir,
providerMode: params.providerMode,
transportId: params.transportId,
channelDriver: params.channelDriver ?? undefined,
channelDriverSelection: params.channelDriverSelection,
primaryModel: remapModelRefForForcedRuntime({
modelRef: params.primaryModel,
providerMode: params.providerMode,
forcedRuntime: runtime,
}),
alternateModel: remapModelRefForForcedRuntime({
modelRef: params.alternateModel,
providerMode: params.providerMode,
forcedRuntime: runtime,
}),
fastMode: params.fastMode,
thinkingDefault: params.thinkingDefault,
claudeCliAuthMode: params.claudeCliAuthMode,
scenarioIds: [scenario.id],
concurrency: 1,
enabledPluginIds: params.enabledPluginIds,
startLab,
controlUiEnabled: scenarioRequiresControlUi(scenario),
forcedRuntime: runtime,
captureRuntimeParityCell: true,
writeEvidenceFile: params.writeEvidenceFile,
});
const scenarioResult =
cellResult.scenarios[0] ??
({
name: scenario.title,
status: "fail",
details: "runtime parity cell returned no scenario result",
steps: [
{
name: "runtime parity cell",
status: "fail",
details: "runtime parity cell returned no scenario result",
},
],
} satisfies QaSuiteScenarioResult);
const fallbackCell = {
runtime,
transcriptBytes: "",
toolCalls: [],
finalText: "",
usage: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
},
wallClockMs: Math.max(1, Date.now() - cellStartedAt),
runtimeErrorClass: "capture-missing",
bootStateLines: [],
} satisfies RuntimeParityCell;
return {
scenarioStatus: scenarioResult.status === "pass" ? "pass" : "fail",
scenarioDetails: scenarioResult.details,
cell: cellResult.runtimeParityCell ?? fallbackCell,
};
},
});
const result = buildRuntimeParityScenarioResult({
scenarioName: scenario.title,
result: parity,
});
liveScenarioOutcomes[index] = {
id: scenario.id,
name: scenario.title,
status: result.status,
details: result.details,
steps: result.steps,
startedAt: liveScenarioOutcomes[index]?.startedAt,
finishedAt: new Date().toISOString(),
};
lab.setScenarioRun({
kind: "suite",
status: "running",
startedAt: params.startedAt.toISOString(),
scenarios: [...liveScenarioOutcomes],
});
writeQaSuiteProgress(
params.progressEnabled,
`runtime pair ${result.status} (${index + 1}/${params.selectedScenarios.length}): ${scenarioIdForLog}`,
);
return result;
},
{
startStaggerMs: resolveQaSuiteWorkerStartStaggerMs(params.concurrency),
},
);
const finishedAt = new Date();
const { evidence, evidencePath, report, reportPath, summaryPath } = await writeQaSuiteArtifacts(
{
repoRoot: params.repoRoot,
outputDir: params.outputDir,
startedAt: params.startedAt,
finishedAt,
scenarios,
scenarioDefinitions: params.selectedScenarios,
evidenceMode: params.evidenceMode,
transport,
providerMode: params.providerMode,
primaryModel: params.primaryModel,
alternateModel: params.alternateModel,
fastMode: params.fastMode,
concurrency: params.concurrency,
channelDriver: params.channelDriver,
channelDriverSelection: params.channelDriverSelection,
scenarioIds:
params.scenarioIds && params.scenarioIds.length > 0
? params.selectedScenarios.map((scenario) => scenario.id)
: undefined,
runtimePair: params.runtimePair,
writeEvidenceFile: params.writeEvidenceFile,
},
);
lab.setLatestReport({
outputPath: reportPath,
markdown: report,
generatedAt: finishedAt.toISOString(),
} satisfies QaLabLatestReport);
lab.setScenarioRun({
kind: "suite",
status: "completed",
startedAt: params.startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
scenarios: [...liveScenarioOutcomes],
});
return {
outputDir: params.outputDir,
evidence,
evidencePath,
reportPath,
summaryPath,
report,
scenarios,
watchUrl: lab.baseUrl,
} satisfies QaSuiteResult;
} finally {
await transportFactoryResult.cleanupWithoutGateway();
if (ownsLab) {
await lab.stop();
}
}
}

View File

@@ -0,0 +1,183 @@
import type { OpenClawCrablineChannelDriverSelection } from "@openclaw/crabline";
import type { QaSuiteChannelDriverSelection } from "./crabline-artifacts.js";
import type { QaProviderMode } from "./model-selection.js";
import { parseQaProgressBooleanEnv as parseQaSuiteBooleanEnv } from "./progress-format.js";
import type { QaTransportId } from "./qa-transport-registry.js";
import type { QaTransportAdapter } from "./qa-transport.js";
import type { RuntimeId } from "./runtime-parity.js";
import { readQaBootstrapScenarioCatalog } from "./scenario-catalog.js";
import type { QaScorecardChannelDriver } from "./scorecard-taxonomy.js";
import { scenarioRequiresControlUi, splitModelRef } from "./suite-planning.js";
import type { QaSuiteRunParams, QaSuiteScenarioResult, QaSuiteStartLabFn } from "./suite-types.js";
type QaCrablineRuntime = typeof import("@openclaw/crabline");
/**
* One bounded retry for live-model flake: flow scenarios time out under model
* latency spikes, so a first failure gets a single rerun. A retry pass keeps
* the first attempt visible in details; a retry failure keeps the original
* diagnostics so deterministic regressions still fail the suite.
*/
export async function runQaScenarioWithFlakeRetry(
run: () => Promise<QaSuiteScenarioResult>,
onRetry?: () => void,
): Promise<QaSuiteScenarioResult> {
const first = await run();
if (first.status !== "fail") {
return first;
}
onRetry?.();
const second = await run();
if (second.status !== "pass") {
return first;
}
return {
...second,
details: [second.details, `passed on retry; first attempt: ${first.details ?? "failed"}`]
.filter(Boolean)
.join(" | "),
};
}
export function createQaSuiteReportNotes(params: {
transport: QaTransportAdapter;
channelDriverSelection?: QaSuiteChannelDriverSelection | null;
providerMode: QaProviderMode;
primaryModel: string;
alternateModel: string;
fastMode: boolean;
concurrency: number;
isolatedWorkers?: boolean;
createCrablineChannelReportNotes?: QaCrablineRuntime["createOpenClawCrablineChannelReportNotes"];
}) {
return [
...params.transport.createReportNotes(params),
// Crabline reports completed generation paths through this filename-narrowed selection.
...(params.createCrablineChannelReportNotes?.(
params.channelDriverSelection as OpenClawCrablineChannelDriverSelection | null | undefined,
) ?? []),
];
}
export function buildQaIsolatedScenarioWorkerParams(params: {
repoRoot: string;
outputDir: string;
providerMode: QaProviderMode;
transportId: QaTransportId;
channelDriver?: QaScorecardChannelDriver;
channelDriverSelection?: OpenClawCrablineChannelDriverSelection | null;
primaryModel: string;
alternateModel: string;
fastMode: boolean;
scenario: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"][number];
input?: QaSuiteRunParams;
startLab: QaSuiteStartLabFn;
}): QaSuiteRunParams {
return {
adapterFactories: params.input?.adapterFactories,
adapterOptions: params.input?.adapterOptions,
channelId: params.input?.channelId,
repoRoot: params.repoRoot,
sutOpenClawCommand: params.input?.sutOpenClawCommand,
outputDir: params.outputDir,
providerMode: params.providerMode,
transportId: params.transportId,
channelDriver: params.channelDriver,
channelDriverSelection: params.channelDriverSelection,
primaryModel: params.primaryModel,
alternateModel: params.alternateModel,
fastMode: params.fastMode,
thinkingDefault: params.input?.thinkingDefault,
claudeCliAuthMode: params.input?.claudeCliAuthMode,
scenarioIds: [params.scenario.id],
enabledPluginIds: params.input?.enabledPluginIds,
concurrency: 1,
startLab: params.startLab,
controlUiEnabled: scenarioRequiresControlUi(params.scenario),
transportReadyTimeoutMs: params.input?.transportReadyTimeoutMs,
workerStartStaggerMs: params.input?.workerStartStaggerMs,
forcedRuntime: params.input?.forcedRuntime,
roundTripProbe:
params.input?.roundTripProbe?.scenarioId === params.scenario.id
? params.input.roundTripProbe
: undefined,
writeEvidenceFile: params.input?.writeEvidenceFile,
};
}
export function remapModelRefForForcedRuntime(params: {
modelRef: string;
providerMode: QaProviderMode;
forcedRuntime?: RuntimeId;
}) {
if (params.forcedRuntime !== "codex" || params.providerMode !== "mock-openai") {
return params.modelRef;
}
const split = splitModelRef(params.modelRef);
if (!split || split.provider !== "mock-openai") {
return params.modelRef;
}
return `openai/${split.model}`;
}
export function buildQaRuntimeEnvPatch(params: {
providerMode: QaProviderMode;
forcedRuntime?: RuntimeId;
mockBaseUrl?: string;
}): NodeJS.ProcessEnv | undefined {
const patch: NodeJS.ProcessEnv = {};
if (params.forcedRuntime) {
patch.OPENCLAW_BUILD_PRIVATE_QA = "1";
patch.OPENCLAW_QA_FORCE_RUNTIME = params.forcedRuntime;
}
if (params.forcedRuntime !== "codex" || params.providerMode !== "mock-openai") {
return Object.keys(patch).length > 0 ? patch : undefined;
}
let mockBaseUrl = params.mockBaseUrl?.trim();
while (mockBaseUrl?.endsWith("/")) {
mockBaseUrl = mockBaseUrl.slice(0, -1);
}
if (!mockBaseUrl) {
return Object.keys(patch).length > 0 ? patch : undefined;
}
// The forced codex lane uses the Codex app-server's native OpenAI provider
// path, so pin the managed app-server to the QA mock endpoint instead of
// leaking to the maintainer's real OpenAI config.
patch.OPENCLAW_CODEX_APP_SERVER_ARGS = `app-server -c openai_base_url=${mockBaseUrl}/v1 --listen stdio://`;
patch.OPENAI_API_KEY = "qa-mock-openai-key";
patch.CODEX_API_KEY = "qa-mock-openai-key";
return patch;
}
export function appendNodeOption(raw: string | undefined, option: string) {
const parts = (raw ?? "").split(/\s+/u).filter(Boolean);
return parts.includes(option) ? parts.join(" ") : [...parts, option].join(" ");
}
export function shouldCaptureGatewayHeapCheckpoints(env: NodeJS.ProcessEnv = process.env) {
return parseQaSuiteBooleanEnv(env.OPENCLAW_QA_GATEWAY_HEAP_CHECKPOINTS) === true;
}
export function buildQaGatewayHeapCheckpointRuntimeEnvPatch(
env: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv | undefined {
if (!shouldCaptureGatewayHeapCheckpoints(env)) {
return undefined;
}
return {
NODE_OPTIONS: appendNodeOption(env.NODE_OPTIONS, "--heapsnapshot-signal=SIGUSR2"),
};
}
export function mergeQaRuntimeEnvPatches(
...patches: Array<NodeJS.ProcessEnv | undefined>
): NodeJS.ProcessEnv | undefined {
const merged: NodeJS.ProcessEnv = {};
for (const patch of patches) {
if (!patch) {
continue;
}
Object.assign(merged, patch);
}
return Object.keys(merged).length > 0 ? merged : undefined;
}

View File

@@ -0,0 +1,112 @@
import type { OpenClawCrablineChannelDriverSelection } from "@openclaw/crabline";
import type { QaEvidenceTiming, QaEvidenceSummaryJson } from "./evidence-summary.js";
import type { QaCliBackendAuthMode, QaGatewayChildCommand } from "./gateway-child.js";
import type { QaLabServerHandle, QaLabServerStartParams } from "./lab-server.types.js";
import type { QaProviderMode } from "./model-selection.js";
import type { QaThinkingLevel } from "./qa-gateway-config.js";
import type {
QaTransportAdapterFactory,
QaTransportFactoryContext,
QaTransportId,
} from "./qa-transport-registry.js";
import type { QaReportCheck } from "./report.js";
import type { RuntimeId, RuntimeParityCell, RuntimeParityResult } from "./runtime-parity.js";
import type { QaScorecardChannelDriver, QaScorecardEvidenceMode } from "./scorecard-taxonomy.js";
import type { QaSuiteRoundTripProbe } from "./suite-round-trip.js";
import type { QaSuiteRuntimeEnv } from "./suite-runtime-types.js";
export type QaSuiteScenarioResult = {
name: string;
status: "pass" | "fail" | "skip";
steps: QaReportCheck[];
details?: string;
timing?: QaEvidenceTiming;
runtimeParity?: RuntimeParityResult;
};
export type QaSuiteEnvironment = {
lab: QaLabServerHandle;
webSessionIds: Set<string>;
} & QaSuiteRuntimeEnv;
export type QaSuiteStartLabFn = (params?: QaLabServerStartParams) => Promise<QaLabServerHandle>;
export type QaSuiteRunParams = {
adapterOptions?: QaTransportFactoryContext["adapterOptions"];
adapterFactories?: readonly QaTransportAdapterFactory[];
channelId?: string;
evidenceMode?: QaScorecardEvidenceMode;
repoRoot?: string;
sutOpenClawCommand?: QaGatewayChildCommand;
outputDir?: string;
providerMode?: QaProviderMode;
transportId?: QaTransportId;
channelDriver?: QaScorecardChannelDriver;
channelDriverSelection?: OpenClawCrablineChannelDriverSelection | null;
primaryModel?: string;
alternateModel?: string;
fastMode?: boolean;
failFast?: boolean;
thinkingDefault?: QaThinkingLevel;
claudeCliAuthMode?: QaCliBackendAuthMode;
scenarioIds?: string[];
lab?: QaLabServerHandle;
startLab?: QaSuiteStartLabFn;
concurrency?: number;
enabledPluginIds?: string[];
controlUiEnabled?: boolean;
transportReadyTimeoutMs?: number;
workerStartStaggerMs?: number;
forcedRuntime?: RuntimeId;
runtimePair?: [RuntimeId, RuntimeId];
captureRuntimeParityCell?: boolean;
roundTripProbe?: QaSuiteRoundTripProbe;
// Unified suite partitions consume child evidence in memory; only the
// parent should write the aggregate qa-evidence.json artifact.
writeEvidenceFile?: boolean;
};
export type QaSuiteResult = {
evidence?: QaEvidenceSummaryJson;
outputDir: string;
evidencePath: string;
reportPath: string;
summaryPath: string;
report: string;
scenarios: QaSuiteScenarioResult[];
watchUrl: string;
runtimeParityCell?: RuntimeParityCell;
};
export type QaSuiteRunner = (params?: QaSuiteRunParams) => Promise<QaSuiteResult>;
export type QaSuiteScenarioRunner = (
env: QaSuiteEnvironment,
scenario: ReturnType<
typeof import("./scenario-catalog.js").readQaBootstrapScenarioCatalog
>["scenarios"][number],
) => Promise<QaSuiteScenarioResult>;
export type QaSuiteResolvedRunContext = {
startedAt: Date;
repoRoot: string;
outputDir: string;
transportId: QaTransportId;
selectedScenarios: ReturnType<
typeof import("./scenario-catalog.js").readQaBootstrapScenarioCatalog
>["scenarios"];
providerMode: QaProviderMode;
primaryModel: string;
alternateModel: string;
fastMode: boolean;
channelDriver?: QaScorecardChannelDriver;
enabledPluginIds: string[];
gatewayConfigPatch: ReturnType<
typeof import("./suite-planning.js").collectQaSuiteGatewayConfigPatch
>;
gatewayRuntimeOptions: ReturnType<
typeof import("./suite-planning.js").collectQaSuiteGatewayRuntimeOptions
>;
concurrency: number;
progressEnabled: boolean;
gatewayHeapCheckpointsEnabled: boolean;
};

File diff suppressed because it is too large Load Diff