mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(qa): report cleanup failures truthfully (#119727)
* fix(qa): report cleanup failures truthfully Punchcard-Session: silver-valley-valley-dt * fix(qa): satisfy cleanup runner static checks Punchcard-Session: silver-meadow-lantern-vx
This commit is contained in:
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { QaSuiteInfraError } from "./errors.js";
|
import { QaSuiteInfraError } from "./errors.js";
|
||||||
import type { QaLabServerHandle } from "./lab-server.types.js";
|
import type { QaLabServerHandle } from "./lab-server.types.js";
|
||||||
import type { QaSuiteScenarioResult } from "./suite.js";
|
import type { QaSuiteScenarioResult } from "./suite.js";
|
||||||
|
import { throwQaSuiteCleanupErrors } from "./suite.js";
|
||||||
import type {
|
import type {
|
||||||
QaTestFileScenario,
|
QaTestFileScenario,
|
||||||
QaTestFileScenarioRunResult,
|
QaTestFileScenarioRunResult,
|
||||||
@@ -31,7 +32,7 @@ vi.mock("./test-file-scenario-runner.js", async (importOriginal) => ({
|
|||||||
runQaTestFileScenarios,
|
runQaTestFileScenarios,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { runQaSuite } from "./suite-launch.runtime.js";
|
import { runQaSuite, runQaSuiteWithInfraRetry } from "./suite-launch.runtime.js";
|
||||||
|
|
||||||
const tempRoots: string[] = [];
|
const tempRoots: string[] = [];
|
||||||
|
|
||||||
@@ -251,6 +252,34 @@ describe("qa suite runtime launcher", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("retries a cleanup-only ECONNRESET through its preserved cause", async () => {
|
||||||
|
const cleanupError = Object.assign(new Error("cleanup socket reset"), {
|
||||||
|
code: "ECONNRESET",
|
||||||
|
});
|
||||||
|
const stderrWrite = vi.spyOn(process.stderr, "write").mockReturnValue(true);
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runQaSuiteWithInfraRetry(async () => {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts === 1) {
|
||||||
|
throwQaSuiteCleanupErrors({
|
||||||
|
cleanupFailures: [{ phase: "lab stop", error: cleanupError }],
|
||||||
|
runFailed: false,
|
||||||
|
runError: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return "retried";
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
expect(result).toBe("retried");
|
||||||
|
expect(attempts).toBe(2);
|
||||||
|
expect(stderrWrite.mock.calls.flat().join("")).toContain("[qa-suite] infra retry 1/1:");
|
||||||
|
} finally {
|
||||||
|
stderrWrite.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("partitions flow-only suites that request isolated workers", async () => {
|
it("partitions flow-only suites that request isolated workers", async () => {
|
||||||
const repoRoot = await makeTempRepo("qa-suite-flow-only-isolated-");
|
const repoRoot = await makeTempRepo("qa-suite-flow-only-isolated-");
|
||||||
const result = await runQaSuite({
|
const result = await runQaSuite({
|
||||||
|
|||||||
@@ -1,10 +1,61 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createQaBusState } from "./bus-state.js";
|
import { createQaBusState } from "./bus-state.js";
|
||||||
import type { QaLabServerHandle } from "./lab-server.types.js";
|
import type { QaLabServerHandle } from "./lab-server.types.js";
|
||||||
import type { QaTransportAdapterFactory } from "./qa-transport-registry.js";
|
import type { QaTransportAdapterFactory } from "./qa-transport-registry.js";
|
||||||
import { runQaFlowSuiteIsolated } from "./suite-run-isolated.js";
|
import { runQaFlowSuiteIsolated } from "./suite-run-isolated.js";
|
||||||
|
import { runQaFlowSuiteStandard } from "./suite-run-standard.js";
|
||||||
import { makeQaSuiteTestScenario } from "./suite-test-helpers.js";
|
import { makeQaSuiteTestScenario } from "./suite-test-helpers.js";
|
||||||
import type { QaSuiteResolvedRunContext, QaSuiteRunner } from "./suite-types.js";
|
import type {
|
||||||
|
QaSuiteResolvedRunContext,
|
||||||
|
QaSuiteRunner,
|
||||||
|
QaSuiteScenarioRunner,
|
||||||
|
} from "./suite-types.js";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
disposeRegisteredAgentHarnesses: vi.fn(async () => {}),
|
||||||
|
fetchWithSsrFGuard: vi.fn(async () => ({
|
||||||
|
response: new Response(null, { status: 204 }),
|
||||||
|
release: vi.fn(async () => {}),
|
||||||
|
})),
|
||||||
|
startQaGatewayChild: vi.fn(async () => ({
|
||||||
|
baseUrl: "http://127.0.0.1:18789",
|
||||||
|
token: "qa-test-token",
|
||||||
|
cfg: {},
|
||||||
|
getProcessCpuMs: () => null,
|
||||||
|
getProcessRssBytes: () => null,
|
||||||
|
stop: vi.fn(async () => {}),
|
||||||
|
})),
|
||||||
|
writeQaSuiteArtifacts: vi.fn(async () => ({
|
||||||
|
evidence: undefined,
|
||||||
|
evidencePath: "/qa-output/qa-evidence.json",
|
||||||
|
report: "",
|
||||||
|
reportPath: "/qa-output/qa-suite-report.md",
|
||||||
|
summaryPath: "/qa-output/qa-suite-summary.json",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/agent-harness", () => ({
|
||||||
|
disposeRegisteredAgentHarnesses: mocks.disposeRegisteredAgentHarnesses,
|
||||||
|
}));
|
||||||
|
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||||
|
fetchWithSsrFGuard: mocks.fetchWithSsrFGuard,
|
||||||
|
}));
|
||||||
|
vi.mock("./gateway-child.js", () => ({
|
||||||
|
startQaGatewayChild: mocks.startQaGatewayChild,
|
||||||
|
}));
|
||||||
|
vi.mock("./providers/server-runtime.js", () => ({
|
||||||
|
startQaProviderServer: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
vi.mock("./suite-artifacts.js", () => ({
|
||||||
|
writeQaSuiteArtifacts: mocks.writeQaSuiteArtifacts,
|
||||||
|
}));
|
||||||
|
vi.mock("./suite-runtime-gateway.js", () => ({
|
||||||
|
waitForGatewayHealthy: vi.fn(async () => {}),
|
||||||
|
waitForTransportReady: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
vi.mock("./web-runtime.js", () => ({
|
||||||
|
closeQaWebSessions: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
function createCleanupTestLab(): QaLabServerHandle {
|
function createCleanupTestLab(): QaLabServerHandle {
|
||||||
return {
|
return {
|
||||||
@@ -41,6 +92,132 @@ function createCleanupTestContext(): QaSuiteResolvedRunContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("isolated QA suite transport cleanup", () => {
|
describe("isolated QA suite transport cleanup", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.disposeRegisteredAgentHarnesses.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains passing artifacts and finishes owned cleanup before reporting teardown failure", async () => {
|
||||||
|
const lab = createCleanupTestLab();
|
||||||
|
const release = vi.fn(async () => {});
|
||||||
|
const factory: QaTransportAdapterFactory = {
|
||||||
|
id: "leased",
|
||||||
|
matches: ({ channelId, driver }) => channelId === "leased" && driver === "live",
|
||||||
|
async create() {
|
||||||
|
return {
|
||||||
|
id: "leased",
|
||||||
|
label: "Leased channel",
|
||||||
|
accountId: "sut",
|
||||||
|
requiredPluginIds: [],
|
||||||
|
supportedActions: [],
|
||||||
|
sendInbound: async (input) => lab.state.addInboundMessage(input),
|
||||||
|
createGatewayConfig: () => ({}),
|
||||||
|
async waitReady() {},
|
||||||
|
buildAgentDelivery: ({ target }) => ({
|
||||||
|
channel: "leased",
|
||||||
|
to: target,
|
||||||
|
replyChannel: "leased",
|
||||||
|
replyTo: target,
|
||||||
|
}),
|
||||||
|
async handleAction() {},
|
||||||
|
createReportNotes: () => [],
|
||||||
|
cleanup: release,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const cleanupError = new Error("agent harness disposal failed");
|
||||||
|
mocks.disposeRegisteredAgentHarnesses.mockRejectedValueOnce(cleanupError);
|
||||||
|
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||||
|
const runChild = vi.fn<QaSuiteRunner>().mockResolvedValue({
|
||||||
|
outputDir: "/qa-child",
|
||||||
|
evidencePath: "/qa-child/qa-evidence.json",
|
||||||
|
reportPath: "/qa-child/qa-suite-report.md",
|
||||||
|
summaryPath: "/qa-child/qa-suite-summary.json",
|
||||||
|
report: "",
|
||||||
|
scenarios: [{ name: "leased-channel-scenario", status: "pass", steps: [] }],
|
||||||
|
watchUrl: lab.baseUrl,
|
||||||
|
});
|
||||||
|
const context = createCleanupTestContext();
|
||||||
|
context.progressEnabled = true;
|
||||||
|
|
||||||
|
const thrown = await runQaFlowSuiteIsolated(
|
||||||
|
{
|
||||||
|
adapterFactories: [factory],
|
||||||
|
channelDriver: "live",
|
||||||
|
channelId: "leased",
|
||||||
|
startLab: async () => lab,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
runChild,
|
||||||
|
).catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(release).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.disposeRegisteredAgentHarnesses).toHaveBeenCalledOnce();
|
||||||
|
expect(lab.stop).toHaveBeenCalledOnce();
|
||||||
|
expect(lab.setLatestReport).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ outputPath: "/qa-output/qa-suite-report.md" }),
|
||||||
|
);
|
||||||
|
expect((thrown as Error).message.split("\n")[0]).toBe(
|
||||||
|
"QA scenarios passed, but cleanup failed",
|
||||||
|
);
|
||||||
|
expect((thrown as Error).message).toContain(
|
||||||
|
"failed cleanup phases: agent harnesses: agent harness disposal failed",
|
||||||
|
);
|
||||||
|
expect((thrown as Error).message).toContain(
|
||||||
|
"retained artifacts: output=/qa-output report=/qa-output/qa-suite-report.md summary=/qa-output/qa-suite-summary.json",
|
||||||
|
);
|
||||||
|
expect((thrown as Error).cause).toBe(cleanupError);
|
||||||
|
expect(stderrWrite.mock.calls.flat().join("")).not.toContain("run complete");
|
||||||
|
stderrWrite.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints one generic completion after a real nested standard run and parent cleanup", async () => {
|
||||||
|
const parentLab = createCleanupTestLab();
|
||||||
|
const childLab = createCleanupTestLab();
|
||||||
|
const startLab = vi
|
||||||
|
.fn<() => Promise<QaLabServerHandle>>()
|
||||||
|
.mockResolvedValueOnce(parentLab)
|
||||||
|
.mockResolvedValueOnce(childLab);
|
||||||
|
const context = createCleanupTestContext();
|
||||||
|
context.channelDriver = undefined;
|
||||||
|
context.progressEnabled = true;
|
||||||
|
const runScenario = vi
|
||||||
|
.fn<QaSuiteScenarioRunner>()
|
||||||
|
.mockResolvedValue({ name: "leased-channel-scenario", status: "pass", steps: [] });
|
||||||
|
const runChild: QaSuiteRunner = async (childParams) => {
|
||||||
|
if (!childParams) {
|
||||||
|
throw new Error("expected nested standard run params");
|
||||||
|
}
|
||||||
|
return await runQaFlowSuiteStandard(
|
||||||
|
childParams,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
startedAt: new Date("2026-08-04T00:00:01.000Z"),
|
||||||
|
outputDir: childParams.outputDir ?? "/qa-output/scenarios/leased-channel-scenario",
|
||||||
|
concurrency: 1,
|
||||||
|
},
|
||||||
|
runScenario,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runQaFlowSuiteIsolated({ startLab }, context, runChild);
|
||||||
|
|
||||||
|
const completionLines = stderrWrite.mock.calls
|
||||||
|
.flat()
|
||||||
|
.join("")
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.startsWith("[qa-suite] run complete"));
|
||||||
|
expect(completionLines).toEqual(["[qa-suite] run complete"]);
|
||||||
|
expect(runScenario).toHaveBeenCalledOnce();
|
||||||
|
expect(childLab.stop).toHaveBeenCalledOnce();
|
||||||
|
expect(parentLab.stop).toHaveBeenCalledOnce();
|
||||||
|
} finally {
|
||||||
|
stderrWrite.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it.each(["cleanup", "cleanupAfterGatewayStop"] as const)(
|
it.each(["cleanup", "cleanupAfterGatewayStop"] as const)(
|
||||||
"retries a failed parent %s phase before disposing its owned lab",
|
"retries a failed parent %s phase before disposing its owned lab",
|
||||||
async (cleanupPhase) => {
|
async (cleanupPhase) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
} from "./suite-types.js";
|
} from "./suite-types.js";
|
||||||
import {
|
import {
|
||||||
createQaSuiteTransportAdapter,
|
createQaSuiteTransportAdapter,
|
||||||
|
markQaSuiteNestedRun,
|
||||||
requireQaSuiteStartLab,
|
requireQaSuiteStartLab,
|
||||||
runQaSuiteCleanupSteps,
|
runQaSuiteCleanupSteps,
|
||||||
throwQaSuiteCleanupErrors,
|
throwQaSuiteCleanupErrors,
|
||||||
@@ -135,6 +136,9 @@ export async function runQaFlowSuiteIsolated(
|
|||||||
let isolatedRunFailed = false;
|
let isolatedRunFailed = false;
|
||||||
let isolatedRunError: unknown;
|
let isolatedRunError: unknown;
|
||||||
let parentTransportCleaned = false;
|
let parentTransportCleaned = false;
|
||||||
|
let result: QaSuiteResult | undefined;
|
||||||
|
let completionProgress: string | undefined;
|
||||||
|
let evidenceWritten = false;
|
||||||
try {
|
try {
|
||||||
if (params?.channelDriver === "live") {
|
if (params?.channelDriver === "live") {
|
||||||
// The parent only renders aggregate artifacts. Release its live credentials
|
// The parent only renders aggregate artifacts. Release its live credentials
|
||||||
@@ -164,24 +168,26 @@ export async function runQaFlowSuiteIsolated(
|
|||||||
updateScenarioRun();
|
updateScenarioRun();
|
||||||
try {
|
try {
|
||||||
const scenarioOutputDir = path.join(outputDir, "scenarios", scenario.id);
|
const scenarioOutputDir = path.join(outputDir, "scenarios", scenario.id);
|
||||||
const result: QaSuiteResult = await runQaFlowSuite(
|
const childSuiteResult: QaSuiteResult = await runQaFlowSuite(
|
||||||
buildQaIsolatedScenarioWorkerParams({
|
markQaSuiteNestedRun(
|
||||||
repoRoot,
|
buildQaIsolatedScenarioWorkerParams({
|
||||||
outputDir: scenarioOutputDir,
|
repoRoot,
|
||||||
providerMode,
|
outputDir: scenarioOutputDir,
|
||||||
transportId,
|
providerMode,
|
||||||
channelDriver: params?.channelDriver,
|
transportId,
|
||||||
channelDriverSelection: params?.channelDriverSelection,
|
channelDriver: params?.channelDriver,
|
||||||
primaryModel,
|
channelDriverSelection: params?.channelDriverSelection,
|
||||||
alternateModel,
|
primaryModel,
|
||||||
fastMode,
|
alternateModel,
|
||||||
startLab,
|
fastMode,
|
||||||
scenario,
|
startLab,
|
||||||
input: params,
|
scenario,
|
||||||
}),
|
input: params,
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const scenarioResult: QaSuiteScenarioResult =
|
const scenarioResult: QaSuiteScenarioResult =
|
||||||
result.scenarios[0] ??
|
childSuiteResult.scenarios[0] ??
|
||||||
({
|
({
|
||||||
name: scenario.title,
|
name: scenario.title,
|
||||||
status: "fail",
|
status: "fail",
|
||||||
@@ -246,13 +252,12 @@ export async function runQaFlowSuiteIsolated(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
startStaggerMs: workerStartStaggerMs,
|
startStaggerMs: workerStartStaggerMs,
|
||||||
shouldStop: (result) => params?.failFast === true && result.status === "fail",
|
shouldStop: (scenarioResult) =>
|
||||||
|
params?.failFast === true && scenarioResult.status === "fail",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await artifactWriteQueue;
|
await artifactWriteQueue;
|
||||||
const finishedAt = new Date();
|
const finishedAt = new Date();
|
||||||
const failedCount = scenarios.filter((scenario) => scenario.status === "fail").length;
|
|
||||||
const skippedCount = scenarios.filter((scenario) => scenario.status === "skip").length;
|
|
||||||
lab.setScenarioRun({
|
lab.setScenarioRun({
|
||||||
kind: "suite",
|
kind: "suite",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
@@ -296,11 +301,9 @@ export async function runQaFlowSuiteIsolated(
|
|||||||
markdown: report,
|
markdown: report,
|
||||||
generatedAt: finishedAt.toISOString(),
|
generatedAt: finishedAt.toISOString(),
|
||||||
} satisfies QaLabLatestReport);
|
} satisfies QaLabLatestReport);
|
||||||
writeQaSuiteProgress(
|
completionProgress = "run complete";
|
||||||
progressEnabled,
|
evidenceWritten = evidence !== undefined && (params?.writeEvidenceFile ?? true);
|
||||||
`run complete: passed=${scenarios.length - failedCount - skippedCount} failed=${failedCount} skipped=${skippedCount} total=${scenarios.length}`,
|
result = {
|
||||||
);
|
|
||||||
return {
|
|
||||||
outputDir,
|
outputDir,
|
||||||
evidence,
|
evidence,
|
||||||
evidencePath,
|
evidencePath,
|
||||||
@@ -315,18 +318,27 @@ export async function runQaFlowSuiteIsolated(
|
|||||||
isolatedRunError = error;
|
isolatedRunError = error;
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
const cleanupSteps: Array<() => Promise<void>> = [
|
const cleanupSteps = [
|
||||||
...(!parentTransportCleaned ? [() => transportFactoryResult.cleanupWithoutGateway()] : []),
|
...(!parentTransportCleaned
|
||||||
() => disposeRegisteredAgentHarnesses(),
|
? [{ phase: "parent transport", run: () => transportFactoryResult.cleanupWithoutGateway() }]
|
||||||
|
: []),
|
||||||
|
{ phase: "agent harnesses", run: () => disposeRegisteredAgentHarnesses() },
|
||||||
];
|
];
|
||||||
if (ownsLab) {
|
if (ownsLab) {
|
||||||
cleanupSteps.push(() => lab.stop());
|
cleanupSteps.push({ phase: "lab stop", run: () => lab.stop() });
|
||||||
}
|
}
|
||||||
const cleanupErrors = await runQaSuiteCleanupSteps(cleanupSteps);
|
const cleanupFailures = await runQaSuiteCleanupSteps(cleanupSteps);
|
||||||
throwQaSuiteCleanupErrors({
|
throwQaSuiteCleanupErrors({
|
||||||
cleanupErrors,
|
cleanupFailures,
|
||||||
runFailed: isolatedRunFailed,
|
runFailed: isolatedRunFailed,
|
||||||
runError: isolatedRunError,
|
runError: isolatedRunError,
|
||||||
|
result,
|
||||||
|
evidenceWritten,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (!result || !completionProgress) {
|
||||||
|
throw new Error("QA suite completed without terminal result metadata");
|
||||||
|
}
|
||||||
|
writeQaSuiteProgress(progressEnabled, completionProgress);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
QaSuiteScenarioResult,
|
QaSuiteScenarioResult,
|
||||||
QaSuiteScenarioRunner,
|
QaSuiteScenarioRunner,
|
||||||
} from "./suite-types.js";
|
} from "./suite-types.js";
|
||||||
|
import type { runQaFlowSuiteCleanupPlan } from "./suite.js";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
captureRuntimeParityCell: vi.fn(async (params: { runtime: "codex"; wallClockMs: number }) => ({
|
captureRuntimeParityCell: vi.fn(async (params: { runtime: "codex"; wallClockMs: number }) => ({
|
||||||
@@ -44,6 +45,8 @@ const mocks = vi.hoisted(() => ({
|
|||||||
})),
|
})),
|
||||||
waitForGatewayHealthy: vi.fn(async () => {}),
|
waitForGatewayHealthy: vi.fn(async () => {}),
|
||||||
waitForTransportReady: vi.fn(async () => {}),
|
waitForTransportReady: vi.fn(async () => {}),
|
||||||
|
runQaFlowSuiteCleanupPlan: vi.fn<typeof runQaFlowSuiteCleanupPlan>(async () => []),
|
||||||
|
writeQaSuiteProgress: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk/agent-harness", () => ({
|
vi.mock("openclaw/plugin-sdk/agent-harness", () => ({
|
||||||
@@ -65,7 +68,8 @@ vi.mock("./suite-runtime-gateway.js", () => ({
|
|||||||
waitForGatewayHealthy: mocks.waitForGatewayHealthy,
|
waitForGatewayHealthy: mocks.waitForGatewayHealthy,
|
||||||
waitForTransportReady: mocks.waitForTransportReady,
|
waitForTransportReady: mocks.waitForTransportReady,
|
||||||
}));
|
}));
|
||||||
vi.mock("./suite.js", () => ({
|
vi.mock("./suite.js", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("./suite.js")>()),
|
||||||
buildQaSuiteRuntimeMetrics: vi.fn(() => ({ wallMs: 1 })),
|
buildQaSuiteRuntimeMetrics: vi.fn(() => ({ wallMs: 1 })),
|
||||||
captureGatewayHeapSnapshotCheckpoint: vi.fn(async () => undefined),
|
captureGatewayHeapSnapshotCheckpoint: vi.fn(async () => undefined),
|
||||||
createQaSuiteTransportAdapter: vi.fn(async () => ({
|
createQaSuiteTransportAdapter: vi.fn(async () => ({
|
||||||
@@ -75,10 +79,9 @@ vi.mock("./suite.js", () => ({
|
|||||||
})),
|
})),
|
||||||
requireQaSuiteStartLab: vi.fn(),
|
requireQaSuiteStartLab: vi.fn(),
|
||||||
resolveQaSuiteTransportReadyTimeoutMs: vi.fn(() => 1_000),
|
resolveQaSuiteTransportReadyTimeoutMs: vi.fn(() => 1_000),
|
||||||
runQaFlowSuiteCleanupPlan: vi.fn(async () => []),
|
runQaFlowSuiteCleanupPlan: mocks.runQaFlowSuiteCleanupPlan,
|
||||||
throwQaSuiteCleanupErrors: vi.fn(),
|
|
||||||
waitForQaLabReadyOrStopOwned: vi.fn(async () => {}),
|
waitForQaLabReadyOrStopOwned: vi.fn(async () => {}),
|
||||||
writeQaSuiteProgress: vi.fn(),
|
writeQaSuiteProgress: mocks.writeQaSuiteProgress,
|
||||||
}));
|
}));
|
||||||
vi.mock("./web-runtime.js", () => ({
|
vi.mock("./web-runtime.js", () => ({
|
||||||
closeQaWebSessions: vi.fn(async () => {}),
|
closeQaWebSessions: vi.fn(async () => {}),
|
||||||
@@ -128,6 +131,7 @@ function makeRetryTestResult(status: "pass" | "fail"): QaSuiteScenarioResult {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mocks.runQaFlowSuiteCleanupPlan.mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("QA suite Control UI ownership", () => {
|
describe("QA suite Control UI ownership", () => {
|
||||||
@@ -196,6 +200,45 @@ describe("QA suite Control UI ownership", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("QA runtime parity scenario retry isolation", () => {
|
describe("QA runtime parity scenario retry isolation", () => {
|
||||||
|
it("does not report terminal success when cleanup fails after writing artifacts", async () => {
|
||||||
|
const lab = makeRetryTestLab();
|
||||||
|
const cleanupError = Object.assign(new Error("gateway shutdown socket reset"), {
|
||||||
|
code: "ECONNRESET",
|
||||||
|
});
|
||||||
|
mocks.runQaFlowSuiteCleanupPlan.mockResolvedValueOnce([
|
||||||
|
{ phase: "gateway stop", error: cleanupError },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const thrown = await runQaFlowSuiteStandard(
|
||||||
|
{ lab },
|
||||||
|
makeRetryTestContext(),
|
||||||
|
vi.fn<QaSuiteScenarioRunner>().mockResolvedValue(makeRetryTestResult("pass")),
|
||||||
|
).catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(thrown).toBeInstanceOf(AggregateError);
|
||||||
|
expect((thrown as Error).message.split("\n")[0]).toBe(
|
||||||
|
"QA scenarios passed, but cleanup failed",
|
||||||
|
);
|
||||||
|
expect((thrown as Error).message).toContain(
|
||||||
|
"scenario counts: passed=1 failed=0 skipped=0 total=1",
|
||||||
|
);
|
||||||
|
expect((thrown as Error).message).toContain(
|
||||||
|
"failed cleanup phases: gateway stop: gateway shutdown socket reset",
|
||||||
|
);
|
||||||
|
expect((thrown as Error).message).toContain(
|
||||||
|
"retained artifacts: output=/qa-output report=/qa-output/qa-suite-report.md summary=/qa-output/qa-suite-summary.json",
|
||||||
|
);
|
||||||
|
expect((thrown as Error).cause).toBe(cleanupError);
|
||||||
|
expect(lab.setLatestReport).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ outputPath: "/qa-output/qa-suite-report.md" }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
mocks.writeQaSuiteProgress.mock.calls.filter(([, message]) =>
|
||||||
|
String(message).startsWith("run complete"),
|
||||||
|
),
|
||||||
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{ forcedRuntime: undefined, expectedRuntime: "openclaw" },
|
{ forcedRuntime: undefined, expectedRuntime: "openclaw" },
|
||||||
{ forcedRuntime: "codex" as const, expectedRuntime: "codex" },
|
{ forcedRuntime: "codex" as const, expectedRuntime: "codex" },
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
createQaSuiteTransportAdapter,
|
createQaSuiteTransportAdapter,
|
||||||
buildQaSuiteRuntimeMetrics,
|
buildQaSuiteRuntimeMetrics,
|
||||||
captureGatewayHeapSnapshotCheckpoint,
|
captureGatewayHeapSnapshotCheckpoint,
|
||||||
|
isQaSuiteNestedRun,
|
||||||
requireQaSuiteStartLab,
|
requireQaSuiteStartLab,
|
||||||
resolveQaSuiteTransportReadyTimeoutMs,
|
resolveQaSuiteTransportReadyTimeoutMs,
|
||||||
runQaFlowSuiteCleanupPlan,
|
runQaFlowSuiteCleanupPlan,
|
||||||
@@ -107,6 +108,9 @@ export async function runQaFlowSuiteStandard(
|
|||||||
let preserveGatewayRuntimeDir: string | undefined;
|
let preserveGatewayRuntimeDir: string | undefined;
|
||||||
let runFailed = false;
|
let runFailed = false;
|
||||||
let runError: unknown;
|
let runError: unknown;
|
||||||
|
let result: QaSuiteResult | undefined;
|
||||||
|
let completionProgress: string | undefined;
|
||||||
|
let evidenceWritten = false;
|
||||||
try {
|
try {
|
||||||
writeQaSuiteProgress(progressEnabled, `provider start: ${providerMode}`);
|
writeQaSuiteProgress(progressEnabled, `provider start: ${providerMode}`);
|
||||||
const activeMock = await startQaProviderServer(providerMode, {
|
const activeMock = await startQaProviderServer(providerMode, {
|
||||||
@@ -267,7 +271,7 @@ export async function runQaFlowSuiteStandard(
|
|||||||
};
|
};
|
||||||
const scenarioRetryCount =
|
const scenarioRetryCount =
|
||||||
scenario.execution.kind === "flow" ? scenario.execution.retryCount : undefined;
|
scenario.execution.kind === "flow" ? scenario.execution.retryCount : undefined;
|
||||||
let result: QaSuiteScenarioResult =
|
let scenarioResult: QaSuiteScenarioResult =
|
||||||
params?.captureRuntimeParityCell || scenarioRetryCount === 0
|
params?.captureRuntimeParityCell || scenarioRetryCount === 0
|
||||||
? await runSelectedScenario()
|
? await runSelectedScenario()
|
||||||
: await runQaScenarioWithFlakeRetry(runSelectedScenario, () =>
|
: await runQaScenarioWithFlakeRetry(runSelectedScenario, () =>
|
||||||
@@ -276,19 +280,19 @@ export async function runQaFlowSuiteStandard(
|
|||||||
`scenario retry (${index + 1}/${selectedScenarios.length}): ${scenarioIdForLog}`,
|
`scenario retry (${index + 1}/${selectedScenarios.length}): ${scenarioIdForLog}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (result.status === "pass" && params?.roundTripProbe?.scenarioId === scenario.id) {
|
if (scenarioResult.status === "pass" && params?.roundTripProbe?.scenarioId === scenario.id) {
|
||||||
const probeResult = await runQaSuiteRoundTripProbe({
|
const probeResult = await runQaSuiteRoundTripProbe({
|
||||||
probe: params.roundTripProbe,
|
probe: params.roundTripProbe,
|
||||||
transport,
|
transport,
|
||||||
});
|
});
|
||||||
const probePassed = probeResult.passed >= params.roundTripProbe.count;
|
const probePassed = probeResult.passed >= params.roundTripProbe.count;
|
||||||
result = {
|
scenarioResult = {
|
||||||
...result,
|
...scenarioResult,
|
||||||
status: probePassed ? "pass" : "fail",
|
status: probePassed ? "pass" : "fail",
|
||||||
details: [result.details, probeResult.details].filter(Boolean).join(" | "),
|
details: [scenarioResult.details, probeResult.details].filter(Boolean).join(" | "),
|
||||||
timing: probeResult.timing,
|
timing: probeResult.timing,
|
||||||
steps: [
|
steps: [
|
||||||
...result.steps,
|
...scenarioResult.steps,
|
||||||
{
|
{
|
||||||
name: "Round-trip samples",
|
name: "Round-trip samples",
|
||||||
status: probePassed ? "pass" : "fail",
|
status: probePassed ? "pass" : "fail",
|
||||||
@@ -306,17 +310,17 @@ export async function runQaFlowSuiteStandard(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
sampleGatewayProcessRss(`scenario:${scenario.id}:finish`);
|
sampleGatewayProcessRss(`scenario:${scenario.id}:finish`);
|
||||||
scenarios.push(result);
|
scenarios.push(scenarioResult);
|
||||||
writeQaSuiteProgress(
|
writeQaSuiteProgress(
|
||||||
progressEnabled,
|
progressEnabled,
|
||||||
`scenario ${result.status} (${index + 1}/${selectedScenarios.length}): ${scenarioIdForLog}`,
|
`scenario ${scenarioResult.status} (${index + 1}/${selectedScenarios.length}): ${scenarioIdForLog}`,
|
||||||
);
|
);
|
||||||
liveScenarioOutcomes[index] = {
|
liveScenarioOutcomes[index] = {
|
||||||
id: scenario.id,
|
id: scenario.id,
|
||||||
name: scenario.title,
|
name: scenario.title,
|
||||||
status: result.status,
|
status: scenarioResult.status,
|
||||||
details: result.details,
|
details: scenarioResult.details,
|
||||||
steps: result.steps,
|
steps: scenarioResult.steps,
|
||||||
startedAt: liveScenarioOutcomes[index]?.startedAt,
|
startedAt: liveScenarioOutcomes[index]?.startedAt,
|
||||||
finishedAt: new Date().toISOString(),
|
finishedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -326,7 +330,7 @@ export async function runQaFlowSuiteStandard(
|
|||||||
startedAt: startedAt.toISOString(),
|
startedAt: startedAt.toISOString(),
|
||||||
scenarios: [...liveScenarioOutcomes],
|
scenarios: [...liveScenarioOutcomes],
|
||||||
});
|
});
|
||||||
if (params?.failFast === true && result.status === "fail") {
|
if (params?.failFast === true && scenarioResult.status === "fail") {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -407,12 +411,9 @@ export async function runQaFlowSuiteStandard(
|
|||||||
generatedAt: finishedAt.toISOString(),
|
generatedAt: finishedAt.toISOString(),
|
||||||
} satisfies QaLabLatestReport;
|
} satisfies QaLabLatestReport;
|
||||||
lab.setLatestReport(latestReport);
|
lab.setLatestReport(latestReport);
|
||||||
writeQaSuiteProgress(
|
completionProgress = `run complete: passed=${scenarios.length - failedCount - skippedCount} failed=${failedCount} skipped=${skippedCount} total=${scenarios.length}`;
|
||||||
progressEnabled,
|
evidenceWritten = evidence !== undefined && (params?.writeEvidenceFile ?? true);
|
||||||
`run complete: passed=${scenarios.length - failedCount - skippedCount} failed=${failedCount} skipped=${skippedCount} total=${scenarios.length}`,
|
result = {
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
outputDir,
|
outputDir,
|
||||||
evidence,
|
evidence,
|
||||||
evidencePath,
|
evidencePath,
|
||||||
@@ -433,7 +434,7 @@ export async function runQaFlowSuiteStandard(
|
|||||||
const keepTemp = process.env.OPENCLAW_QA_KEEP_TEMP === "1" || false;
|
const keepTemp = process.env.OPENCLAW_QA_KEEP_TEMP === "1" || false;
|
||||||
const activeGateway = gateway;
|
const activeGateway = gateway;
|
||||||
const activeMock = mock;
|
const activeMock = mock;
|
||||||
const cleanupErrors = await runQaFlowSuiteCleanupPlan({
|
const cleanupFailures = await runQaFlowSuiteCleanupPlan({
|
||||||
closeWebSessions: activeEnv ? () => closeQaWebSessions(activeEnv.webSessionIds) : undefined,
|
closeWebSessions: activeEnv ? () => closeQaWebSessions(activeEnv.webSessionIds) : undefined,
|
||||||
cleanupTransportBeforeGatewayStop: () => transportFactoryResult.cleanupBeforeGatewayStop(),
|
cleanupTransportBeforeGatewayStop: () => transportFactoryResult.cleanupBeforeGatewayStop(),
|
||||||
cleanupTransportAfterGatewayStop: () => transportFactoryResult.cleanupAfterGatewayStop(),
|
cleanupTransportAfterGatewayStop: () => transportFactoryResult.cleanupAfterGatewayStop(),
|
||||||
@@ -457,6 +458,13 @@ export async function runQaFlowSuiteStandard(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
throwQaSuiteCleanupErrors({ cleanupErrors, runFailed, runError });
|
throwQaSuiteCleanupErrors({ cleanupFailures, runFailed, runError, result, evidenceWritten });
|
||||||
}
|
}
|
||||||
|
if (!result || !completionProgress) {
|
||||||
|
throw new Error("QA suite completed without terminal result metadata");
|
||||||
|
}
|
||||||
|
if (!params?.captureRuntimeParityCell && !isQaSuiteNestedRun(params)) {
|
||||||
|
writeQaSuiteProgress(progressEnabled, completionProgress);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,84 @@ import {
|
|||||||
createQaTransportAdapter,
|
createQaTransportAdapter,
|
||||||
type QaTransportAdapterFactory,
|
type QaTransportAdapterFactory,
|
||||||
} from "./qa-transport-registry.js";
|
} from "./qa-transport-registry.js";
|
||||||
|
import { runQaFlowSuiteStandard } from "./suite-run-standard.js";
|
||||||
import { runQaRuntimeParitySuite } from "./suite-runtime-parity-runner.js";
|
import { runQaRuntimeParitySuite } from "./suite-runtime-parity-runner.js";
|
||||||
import { makeQaSuiteTestScenario } from "./suite-test-helpers.js";
|
import { makeQaSuiteTestScenario } from "./suite-test-helpers.js";
|
||||||
import type { QaSuiteRunner } from "./suite-types.js";
|
import type {
|
||||||
|
QaSuiteResolvedRunContext,
|
||||||
|
QaSuiteRunner,
|
||||||
|
QaSuiteScenarioRunner,
|
||||||
|
} from "./suite-types.js";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
captureRuntimeParityCell: vi.fn(
|
||||||
|
async (params: { runtime: "openclaw" | "codex"; wallClockMs: number }) => ({
|
||||||
|
runtime: params.runtime,
|
||||||
|
transcriptBytes: "",
|
||||||
|
toolCalls: [],
|
||||||
|
finalText: "ok",
|
||||||
|
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
||||||
|
cacheDiagnostics: {
|
||||||
|
assistantTurns: 1,
|
||||||
|
cacheTelemetryTurns: 1,
|
||||||
|
cacheHitTurns: 0,
|
||||||
|
cacheWriteTurns: 0,
|
||||||
|
cacheMisses: [],
|
||||||
|
cacheMissInputTokens: 0,
|
||||||
|
unmeasuredPostWarmTurns: [],
|
||||||
|
},
|
||||||
|
wallClockMs: params.wallClockMs,
|
||||||
|
bootStateLines: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
disposeRegisteredAgentHarnesses: vi.fn(async () => {}),
|
||||||
|
fetchWithSsrFGuard: vi.fn(async () => ({
|
||||||
|
response: new Response(null, { status: 204 }),
|
||||||
|
release: vi.fn(async () => {}),
|
||||||
|
})),
|
||||||
|
startQaGatewayChild: vi.fn(async () => ({
|
||||||
|
baseUrl: "http://127.0.0.1:18789",
|
||||||
|
token: "qa-test-token",
|
||||||
|
cfg: {},
|
||||||
|
getProcessCpuMs: () => null,
|
||||||
|
getProcessRssBytes: () => null,
|
||||||
|
stop: vi.fn(async () => {}),
|
||||||
|
})),
|
||||||
|
writeQaSuiteArtifacts: vi.fn(async () => ({
|
||||||
|
evidence: { kind: "test" },
|
||||||
|
evidencePath: "/qa-output/qa-evidence.json",
|
||||||
|
report: "",
|
||||||
|
reportPath: "/qa-output/qa-suite-report.md",
|
||||||
|
summaryPath: "/qa-output/qa-suite-summary.json",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/agent-harness", () => ({
|
||||||
|
disposeRegisteredAgentHarnesses: mocks.disposeRegisteredAgentHarnesses,
|
||||||
|
}));
|
||||||
|
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||||
|
fetchWithSsrFGuard: mocks.fetchWithSsrFGuard,
|
||||||
|
}));
|
||||||
|
vi.mock("./gateway-child.js", () => ({
|
||||||
|
startQaGatewayChild: mocks.startQaGatewayChild,
|
||||||
|
}));
|
||||||
|
vi.mock("./providers/server-runtime.js", () => ({
|
||||||
|
startQaProviderServer: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
vi.mock("./runtime-parity.js", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("./runtime-parity.js")>()),
|
||||||
|
captureRuntimeParityCell: mocks.captureRuntimeParityCell,
|
||||||
|
}));
|
||||||
|
vi.mock("./suite-artifacts.js", () => ({
|
||||||
|
writeQaSuiteArtifacts: mocks.writeQaSuiteArtifacts,
|
||||||
|
}));
|
||||||
|
vi.mock("./suite-runtime-gateway.js", () => ({
|
||||||
|
waitForGatewayHealthy: vi.fn(async () => {}),
|
||||||
|
waitForTransportReady: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
vi.mock("./web-runtime.js", () => ({
|
||||||
|
closeQaWebSessions: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
function createCleanupTestLab(): QaLabServerHandle {
|
function createCleanupTestLab(): QaLabServerHandle {
|
||||||
return {
|
return {
|
||||||
@@ -62,6 +137,7 @@ function createCleanupTestFactory(
|
|||||||
function runCleanupTestSuite(params: {
|
function runCleanupTestSuite(params: {
|
||||||
factory: QaTransportAdapterFactory;
|
factory: QaTransportAdapterFactory;
|
||||||
lab: QaLabServerHandle;
|
lab: QaLabServerHandle;
|
||||||
|
progressEnabled?: boolean;
|
||||||
runChild: QaSuiteRunner;
|
runChild: QaSuiteRunner;
|
||||||
}) {
|
}) {
|
||||||
return runQaRuntimeParitySuite({
|
return runQaRuntimeParitySuite({
|
||||||
@@ -80,12 +156,148 @@ function runCleanupTestSuite(params: {
|
|||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
selectedScenarios: [makeQaSuiteTestScenario("runtime-cleanup")],
|
selectedScenarios: [makeQaSuiteTestScenario("runtime-cleanup")],
|
||||||
startLab: async () => params.lab,
|
startLab: async () => params.lab,
|
||||||
progressEnabled: false,
|
progressEnabled: params.progressEnabled ?? false,
|
||||||
runtimePair: ["openclaw", "codex"],
|
runtimePair: ["openclaw", "codex"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("runtime parity suite transport cleanup", () => {
|
describe("runtime parity suite transport cleanup", () => {
|
||||||
|
it("keeps parent artifacts discoverable when owned lab cleanup fails", async () => {
|
||||||
|
const cleanupError = Object.assign(new Error("owned lab shutdown reset"), {
|
||||||
|
code: "ECONNRESET",
|
||||||
|
});
|
||||||
|
const setLatestReport = vi.fn<QaLabServerHandle["setLatestReport"]>();
|
||||||
|
const stopLab = vi.fn<QaLabServerHandle["stop"]>(async () => {
|
||||||
|
throw cleanupError;
|
||||||
|
});
|
||||||
|
const lab = createCleanupTestLab();
|
||||||
|
lab.setLatestReport = setLatestReport;
|
||||||
|
lab.stop = stopLab;
|
||||||
|
const cleanup = vi.fn(async () => {});
|
||||||
|
const factory = createCleanupTestFactory(lab, () => ({ cleanup }));
|
||||||
|
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||||
|
const runChild = vi.fn<QaSuiteRunner>().mockImplementation(async (params) => ({
|
||||||
|
outputDir: "/qa-child",
|
||||||
|
evidencePath: "/qa-child/qa-evidence.json",
|
||||||
|
reportPath: "/qa-child/qa-suite-report.md",
|
||||||
|
summaryPath: "/qa-child/qa-suite-summary.json",
|
||||||
|
report: "",
|
||||||
|
scenarios: [{ name: "runtime-cleanup", status: "pass", steps: [] }],
|
||||||
|
watchUrl: lab.baseUrl,
|
||||||
|
runtimeParityCell: {
|
||||||
|
runtime: params?.forcedRuntime ?? "openclaw",
|
||||||
|
transcriptBytes: "",
|
||||||
|
toolCalls: [],
|
||||||
|
finalText: "ok",
|
||||||
|
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
||||||
|
wallClockMs: 1,
|
||||||
|
bootStateLines: [],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const thrown = await runCleanupTestSuite({
|
||||||
|
factory,
|
||||||
|
lab,
|
||||||
|
progressEnabled: true,
|
||||||
|
runChild,
|
||||||
|
}).catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(cleanup).toHaveBeenCalledOnce();
|
||||||
|
expect(setLatestReport).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ outputPath: "/qa-output/qa-suite-report.md" }),
|
||||||
|
);
|
||||||
|
expect(setLatestReport.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
stopLab.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
||||||
|
);
|
||||||
|
expect((thrown as Error).message.split("\n")[0]).toBe(
|
||||||
|
"QA scenarios passed, but cleanup failed",
|
||||||
|
);
|
||||||
|
expect((thrown as Error).message).toContain(
|
||||||
|
"failed cleanup phases: lab stop: owned lab shutdown reset",
|
||||||
|
);
|
||||||
|
expect((thrown as Error).message).toContain(
|
||||||
|
"retained artifacts: output=/qa-output report=/qa-output/qa-suite-report.md summary=/qa-output/qa-suite-summary.json evidence=/qa-output/qa-evidence.json",
|
||||||
|
);
|
||||||
|
expect((thrown as Error).cause).toBe(cleanupError);
|
||||||
|
expect(stderrWrite.mock.calls.flat().join("")).not.toContain("run complete");
|
||||||
|
} finally {
|
||||||
|
stderrWrite.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints one generic completion after real nested standard cells and parent cleanup", async () => {
|
||||||
|
const scenario = makeQaSuiteTestScenario("runtime-cleanup");
|
||||||
|
const parentLab = createCleanupTestLab();
|
||||||
|
const openClawLab = createCleanupTestLab();
|
||||||
|
const codexLab = createCleanupTestLab();
|
||||||
|
const startLab = vi
|
||||||
|
.fn<() => Promise<QaLabServerHandle>>()
|
||||||
|
.mockResolvedValueOnce(parentLab)
|
||||||
|
.mockResolvedValueOnce(openClawLab)
|
||||||
|
.mockResolvedValueOnce(codexLab);
|
||||||
|
const runScenario = vi
|
||||||
|
.fn<QaSuiteScenarioRunner>()
|
||||||
|
.mockResolvedValue({ name: scenario.title, status: "pass", steps: [] });
|
||||||
|
const runChild: QaSuiteRunner = async (childParams) => {
|
||||||
|
if (!childParams) {
|
||||||
|
throw new Error("expected nested standard run params");
|
||||||
|
}
|
||||||
|
const context: QaSuiteResolvedRunContext = {
|
||||||
|
startedAt: new Date("2026-08-04T00:00:01.000Z"),
|
||||||
|
repoRoot: childParams.repoRoot ?? "/qa-repo",
|
||||||
|
outputDir: childParams.outputDir ?? "/qa-output/runtime-cell",
|
||||||
|
transportId: childParams.transportId ?? "qa-channel",
|
||||||
|
selectedScenarios: [scenario],
|
||||||
|
providerMode: childParams.providerMode ?? "mock-openai",
|
||||||
|
primaryModel: childParams.primaryModel ?? "mock-openai/test-model",
|
||||||
|
alternateModel: childParams.alternateModel ?? "mock-openai/test-model-alt",
|
||||||
|
fastMode: childParams.fastMode ?? true,
|
||||||
|
channelDriver: childParams.channelDriver,
|
||||||
|
enabledPluginIds: childParams.enabledPluginIds ?? [],
|
||||||
|
gatewayConfigPatch: undefined,
|
||||||
|
gatewayRuntimeOptions: undefined,
|
||||||
|
concurrency: 1,
|
||||||
|
progressEnabled: true,
|
||||||
|
gatewayHeapCheckpointsEnabled: false,
|
||||||
|
};
|
||||||
|
return await runQaFlowSuiteStandard(childParams, context, runScenario);
|
||||||
|
};
|
||||||
|
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runQaRuntimeParitySuite({
|
||||||
|
runQaFlowSuite: runChild,
|
||||||
|
repoRoot: "/qa-repo",
|
||||||
|
outputDir: "/qa-output",
|
||||||
|
startedAt: new Date("2026-08-04T00:00:00.000Z"),
|
||||||
|
providerMode: "mock-openai",
|
||||||
|
transportId: "qa-channel",
|
||||||
|
primaryModel: "mock-openai/test-model",
|
||||||
|
alternateModel: "mock-openai/test-model-alt",
|
||||||
|
fastMode: true,
|
||||||
|
concurrency: 1,
|
||||||
|
selectedScenarios: [scenario],
|
||||||
|
startLab,
|
||||||
|
progressEnabled: true,
|
||||||
|
runtimePair: ["openclaw", "codex"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const completionLines = stderrWrite.mock.calls
|
||||||
|
.flat()
|
||||||
|
.join("")
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.startsWith("[qa-suite] run complete"));
|
||||||
|
expect(completionLines).toEqual(["[qa-suite] run complete"]);
|
||||||
|
expect(runScenario).toHaveBeenCalledTimes(2);
|
||||||
|
expect(openClawLab.stop).toHaveBeenCalledOnce();
|
||||||
|
expect(codexLab.stop).toHaveBeenCalledOnce();
|
||||||
|
expect(parentLab.stop).toHaveBeenCalledOnce();
|
||||||
|
} finally {
|
||||||
|
stderrWrite.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves the scenario error when its owned lab cleanup fails", async () => {
|
it("preserves the scenario error when its owned lab cleanup fails", async () => {
|
||||||
const lab = createCleanupTestLab();
|
const lab = createCleanupTestLab();
|
||||||
const scenarioError = new Error("runtime scenario failed");
|
const scenarioError = new Error("runtime scenario failed");
|
||||||
@@ -98,7 +310,9 @@ describe("runtime parity suite transport cleanup", () => {
|
|||||||
const runChild = vi.fn<QaSuiteRunner>().mockRejectedValueOnce(scenarioError);
|
const runChild = vi.fn<QaSuiteRunner>().mockRejectedValueOnce(scenarioError);
|
||||||
|
|
||||||
await expect(runCleanupTestSuite({ factory, lab, runChild })).rejects.toMatchObject({
|
await expect(runCleanupTestSuite({ factory, lab, runChild })).rejects.toMatchObject({
|
||||||
message: "QA suite and cleanup failed",
|
message: expect.stringContaining(
|
||||||
|
"failed cleanup phases: lab stop: owned lab shutdown failed",
|
||||||
|
),
|
||||||
cause: scenarioError,
|
cause: scenarioError,
|
||||||
errors: [scenarioError, cleanupError],
|
errors: [scenarioError, cleanupError],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ export async function runQaRuntimeParitySuite(params: {
|
|||||||
let runFailed = false;
|
let runFailed = false;
|
||||||
let runError: unknown;
|
let runError: unknown;
|
||||||
let parentTransportCleaned = false;
|
let parentTransportCleaned = false;
|
||||||
|
let result: QaSuiteResult | undefined;
|
||||||
|
let evidenceWritten = false;
|
||||||
try {
|
try {
|
||||||
if (params.channelDriver === "live") {
|
if (params.channelDriver === "live") {
|
||||||
// The parent only contributes aggregate metadata; release its exclusive
|
// The parent only contributes aggregate metadata; release its exclusive
|
||||||
@@ -217,16 +219,16 @@ export async function runQaRuntimeParitySuite(params: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = buildRuntimeParityScenarioResult({
|
const parityScenarioResult = buildRuntimeParityScenarioResult({
|
||||||
scenarioName: scenario.title,
|
scenarioName: scenario.title,
|
||||||
result: parity,
|
result: parity,
|
||||||
});
|
});
|
||||||
liveScenarioOutcomes[index] = {
|
liveScenarioOutcomes[index] = {
|
||||||
id: scenario.id,
|
id: scenario.id,
|
||||||
name: scenario.title,
|
name: scenario.title,
|
||||||
status: result.status,
|
status: parityScenarioResult.status,
|
||||||
details: result.details,
|
details: parityScenarioResult.details,
|
||||||
steps: result.steps,
|
steps: parityScenarioResult.steps,
|
||||||
startedAt: liveScenarioOutcomes[index]?.startedAt,
|
startedAt: liveScenarioOutcomes[index]?.startedAt,
|
||||||
finishedAt: new Date().toISOString(),
|
finishedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -238,9 +240,9 @@ export async function runQaRuntimeParitySuite(params: {
|
|||||||
});
|
});
|
||||||
writeQaSuiteProgress(
|
writeQaSuiteProgress(
|
||||||
params.progressEnabled,
|
params.progressEnabled,
|
||||||
`runtime pair ${result.status} (${index + 1}/${params.selectedScenarios.length}): ${scenarioIdForLog}`,
|
`runtime pair ${parityScenarioResult.status} (${index + 1}/${params.selectedScenarios.length}): ${scenarioIdForLog}`,
|
||||||
);
|
);
|
||||||
return result;
|
return parityScenarioResult;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
startStaggerMs: resolveQaSuiteWorkerStartStaggerMs(params.concurrency),
|
startStaggerMs: resolveQaSuiteWorkerStartStaggerMs(params.concurrency),
|
||||||
@@ -285,7 +287,8 @@ export async function runQaRuntimeParitySuite(params: {
|
|||||||
finishedAt: finishedAt.toISOString(),
|
finishedAt: finishedAt.toISOString(),
|
||||||
scenarios: [...liveScenarioOutcomes],
|
scenarios: [...liveScenarioOutcomes],
|
||||||
});
|
});
|
||||||
return {
|
evidenceWritten = evidence !== undefined && (params.writeEvidenceFile ?? true);
|
||||||
|
result = {
|
||||||
outputDir: params.outputDir,
|
outputDir: params.outputDir,
|
||||||
evidence,
|
evidence,
|
||||||
evidencePath,
|
evidencePath,
|
||||||
@@ -300,10 +303,17 @@ export async function runQaRuntimeParitySuite(params: {
|
|||||||
runError = error;
|
runError = error;
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
const cleanupErrors = await runQaSuiteCleanupSteps([
|
const cleanupFailures = await runQaSuiteCleanupSteps([
|
||||||
...(!parentTransportCleaned ? [() => transportFactoryResult.cleanupWithoutGateway()] : []),
|
...(!parentTransportCleaned
|
||||||
...(ownsLab ? [() => lab.stop()] : []),
|
? [{ phase: "parent transport", run: () => transportFactoryResult.cleanupWithoutGateway() }]
|
||||||
|
: []),
|
||||||
|
...(ownsLab ? [{ phase: "lab stop", run: () => lab.stop() }] : []),
|
||||||
]);
|
]);
|
||||||
throwQaSuiteCleanupErrors({ cleanupErrors, runFailed, runError });
|
throwQaSuiteCleanupErrors({ cleanupFailures, runFailed, runError, result, evidenceWritten });
|
||||||
}
|
}
|
||||||
|
if (!result) {
|
||||||
|
throw new Error("QA runtime parity suite completed without a result");
|
||||||
|
}
|
||||||
|
writeQaSuiteProgress(params.progressEnabled, "run complete");
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { QA_EVIDENCE_FILENAME, QA_EVIDENCE_SUMMARY_KIND } from "./evidence-summa
|
|||||||
import type { QaLabServerHandle } from "./lab-server.types.js";
|
import type { QaLabServerHandle } from "./lab-server.types.js";
|
||||||
import type { QaTransportAdapter } from "./qa-transport.js";
|
import type { QaTransportAdapter } from "./qa-transport.js";
|
||||||
import { makeQaSuiteTestScenario } from "./suite-test-helpers.js";
|
import { makeQaSuiteTestScenario } from "./suite-test-helpers.js";
|
||||||
|
import type { QaSuiteResult } from "./suite-types.js";
|
||||||
import { qaSuiteProgressTesting, runQaFlowSuite } from "./suite.js";
|
import { qaSuiteProgressTesting, runQaFlowSuite } from "./suite.js";
|
||||||
import { createTempDirHarness } from "./temp-dir.test-helper.js";
|
import { createTempDirHarness } from "./temp-dir.test-helper.js";
|
||||||
|
|
||||||
@@ -39,7 +40,8 @@ function makeQaSuiteTestLabHandle(): QaLabServerHandle {
|
|||||||
describe("qa suite", () => {
|
describe("qa suite", () => {
|
||||||
it("runs the production cleanup plan in dependency order after a failure", async () => {
|
it("runs the production cleanup plan in dependency order after a failure", async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const failure = new Error("transport close failed");
|
const transportFailure = new Error("transport close failed");
|
||||||
|
const providerFailure = new Error("provider close failed");
|
||||||
const step = (name: string, error?: Error) => async () => {
|
const step = (name: string, error?: Error) => async () => {
|
||||||
calls.push(name);
|
calls.push(name);
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -47,13 +49,13 @@ describe("qa suite", () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const errors = await qaSuiteProgressTesting.runQaFlowSuiteCleanupPlan({
|
const failures = await qaSuiteProgressTesting.runQaFlowSuiteCleanupPlan({
|
||||||
closeWebSessions: step("web sessions"),
|
closeWebSessions: step("web sessions"),
|
||||||
cleanupTransportBeforeGatewayStop: step("transport before gateway", failure),
|
cleanupTransportBeforeGatewayStop: step("transport before gateway", transportFailure),
|
||||||
cleanupTransportAfterGatewayStop: step("transport after gateway"),
|
cleanupTransportAfterGatewayStop: step("transport after gateway"),
|
||||||
stopGateway: step("gateway"),
|
stopGateway: step("gateway"),
|
||||||
disposeAgentHarnesses: step("agent harnesses"),
|
disposeAgentHarnesses: step("agent harnesses"),
|
||||||
stopProvider: step("provider"),
|
stopProvider: step("provider", providerFailure),
|
||||||
finishLab: step("lab"),
|
finishLab: step("lab"),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,19 +68,97 @@ describe("qa suite", () => {
|
|||||||
"provider",
|
"provider",
|
||||||
"lab",
|
"lab",
|
||||||
]);
|
]);
|
||||||
expect(errors).toEqual([failure]);
|
expect(failures).toEqual([
|
||||||
|
{ phase: "transport before gateway stop", error: transportFailure },
|
||||||
|
{ phase: "provider stop", error: providerFailure },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the primary suite error as the cause of aggregated cleanup failures", () => {
|
it("keeps the primary suite error as the cause of aggregated cleanup failures", () => {
|
||||||
const runError = new Error("gateway infrastructure failed");
|
const runError = new Error("gateway infrastructure failed");
|
||||||
|
const cleanupError = new Error("transport cleanup failed");
|
||||||
|
|
||||||
expect(() =>
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
qaSuiteProgressTesting.throwQaSuiteCleanupErrors({
|
qaSuiteProgressTesting.throwQaSuiteCleanupErrors({
|
||||||
cleanupErrors: [new Error("transport cleanup failed")],
|
cleanupFailures: [{ phase: "transport before gateway stop", error: cleanupError }],
|
||||||
runFailed: true,
|
runFailed: true,
|
||||||
runError,
|
runError,
|
||||||
}),
|
});
|
||||||
).toThrow(expect.objectContaining({ cause: runError }));
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(thrown).toMatchObject({
|
||||||
|
cause: runError,
|
||||||
|
errors: [runError, cleanupError],
|
||||||
|
});
|
||||||
|
expect((thrown as Error).message.split("\n")[0]).toBe("QA suite and cleanup failed");
|
||||||
|
expect((thrown as Error).message).toContain(
|
||||||
|
"failed cleanup phases: transport before gateway stop: transport cleanup failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports cleanup failure before scenarios completed when no result exists", () => {
|
||||||
|
const cleanupError = new Error("stop failed");
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
qaSuiteProgressTesting.throwQaSuiteCleanupErrors({
|
||||||
|
cleanupFailures: [{ phase: "lab stop", error: cleanupError }],
|
||||||
|
runFailed: false,
|
||||||
|
runError: undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect((thrown as Error).message.split("\n")[0]).toBe(
|
||||||
|
"QA suite cleanup failed before scenarios completed",
|
||||||
|
);
|
||||||
|
expect((thrown as Error).cause).toBe(cleanupError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports completed counts, labeled failures, and only written artifact paths", () => {
|
||||||
|
const result = {
|
||||||
|
outputDir: "/qa-output\nretained",
|
||||||
|
evidencePath: "/qa-output/qa-evidence.json",
|
||||||
|
reportPath: "/qa-output/qa-suite-report.md",
|
||||||
|
summaryPath: "/qa-output/qa-suite-summary.json",
|
||||||
|
report: "",
|
||||||
|
scenarios: [
|
||||||
|
{ name: "pass", status: "pass", steps: [] },
|
||||||
|
{ name: "fail", status: "fail", steps: [] },
|
||||||
|
{ name: "skip", status: "skip", steps: [] },
|
||||||
|
],
|
||||||
|
watchUrl: "http://127.0.0.1:43123",
|
||||||
|
} satisfies QaSuiteResult;
|
||||||
|
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
qaSuiteProgressTesting.throwQaSuiteCleanupErrors({
|
||||||
|
cleanupFailures: [
|
||||||
|
{ phase: "agent\nharnesses", error: new Error("dispose failed") },
|
||||||
|
{ phase: "lab stop", error: new Error("stop failed") },
|
||||||
|
],
|
||||||
|
runFailed: false,
|
||||||
|
runError: undefined,
|
||||||
|
result,
|
||||||
|
evidenceWritten: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect((thrown as Error).message).toBe(
|
||||||
|
[
|
||||||
|
"QA scenarios completed, but cleanup failed",
|
||||||
|
"scenario counts: passed=1 failed=1 skipped=1 total=3",
|
||||||
|
"failed cleanup phases: agent harnesses: dispose failed; lab stop: stop failed",
|
||||||
|
"retained artifacts: output=/qa-output retained report=/qa-output/qa-suite-report.md summary=/qa-output/qa-suite-summary.json",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
expect("cause" in (thrown as object)).toBe(false);
|
||||||
|
expect((thrown as Error).message).not.toContain("evidence=");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not release transport credentials when gateway teardown fails", async () => {
|
it("does not release transport credentials when gateway teardown fails", async () => {
|
||||||
@@ -91,7 +171,7 @@ describe("qa suite", () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const errors = await qaSuiteProgressTesting.runQaFlowSuiteCleanupPlan({
|
const failures = await qaSuiteProgressTesting.runQaFlowSuiteCleanupPlan({
|
||||||
cleanupTransportBeforeGatewayStop: step("transport before gateway"),
|
cleanupTransportBeforeGatewayStop: step("transport before gateway"),
|
||||||
cleanupTransportAfterGatewayStop: step("transport after gateway"),
|
cleanupTransportAfterGatewayStop: step("transport after gateway"),
|
||||||
stopGateway: step("gateway", gatewayFailure),
|
stopGateway: step("gateway", gatewayFailure),
|
||||||
@@ -100,7 +180,7 @@ describe("qa suite", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(calls).toEqual(["transport before gateway", "gateway", "agent harnesses", "lab"]);
|
expect(calls).toEqual(["transport before gateway", "gateway", "agent harnesses", "lab"]);
|
||||||
expect(errors).toEqual([gatewayFailure]);
|
expect(failures).toEqual([{ phase: "gateway stop", error: gatewayFailure }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unsupported transport ids before starting the lab", async () => {
|
it("rejects unsupported transport ids before starting the lab", async () => {
|
||||||
|
|||||||
@@ -202,6 +202,16 @@ export function writeQaSuiteProgress(enabled: boolean, message: string) {
|
|||||||
process.stderr.write(`[qa-suite] ${message}\n`);
|
process.stderr.write(`[qa-suite] ${message}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const qaSuiteNestedRuns = new WeakSet<object>();
|
||||||
|
|
||||||
|
export function markQaSuiteNestedRun<T extends object>(params: T): T {
|
||||||
|
qaSuiteNestedRuns.add(params);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isQaSuiteNestedRun = (params: object | undefined) =>
|
||||||
|
params !== undefined && qaSuiteNestedRuns.has(params);
|
||||||
|
|
||||||
export function formatQaSuiteRunStartProgress(params: {
|
export function formatQaSuiteRunStartProgress(params: {
|
||||||
selectedScenarioCount: number;
|
selectedScenarioCount: number;
|
||||||
concurrency: number;
|
concurrency: number;
|
||||||
@@ -270,16 +280,19 @@ export async function waitForQaLabReadyOrStopOwned(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runQaSuiteCleanupSteps(steps: ReadonlyArray<() => Promise<void>>) {
|
type QaSuiteCleanupStep = { phase: string; run: () => Promise<void> };
|
||||||
const errors: unknown[] = [];
|
type QaSuiteCleanupFailure = { phase: string; error: unknown };
|
||||||
|
|
||||||
|
export async function runQaSuiteCleanupSteps(steps: readonly QaSuiteCleanupStep[]) {
|
||||||
|
const failures: QaSuiteCleanupFailure[] = [];
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
try {
|
try {
|
||||||
await step();
|
await step.run();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(error);
|
failures.push({ phase: step.phase, error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return errors;
|
return failures;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runQaFlowSuiteCleanupPlan(params: {
|
export async function runQaFlowSuiteCleanupPlan(params: {
|
||||||
@@ -291,47 +304,81 @@ export async function runQaFlowSuiteCleanupPlan(params: {
|
|||||||
stopProvider?: () => Promise<void>;
|
stopProvider?: () => Promise<void>;
|
||||||
finishLab: () => Promise<void>;
|
finishLab: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const errors = await runQaSuiteCleanupSteps([
|
const stopGateway = params.stopGateway;
|
||||||
...(params.closeWebSessions ? [params.closeWebSessions] : []),
|
let gatewayStopped = !stopGateway;
|
||||||
|
const stopGatewayAndMark = async () => {
|
||||||
|
await stopGateway?.();
|
||||||
|
gatewayStopped = true;
|
||||||
|
};
|
||||||
|
const cleanupTransportAfterGatewayStop = async () => {
|
||||||
|
if (gatewayStopped) {
|
||||||
|
await params.cleanupTransportAfterGatewayStop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return runQaSuiteCleanupSteps([
|
||||||
|
...(params.closeWebSessions ? [{ phase: "web sessions", run: params.closeWebSessions }] : []),
|
||||||
// Drain transport HTTP work before stopping the gateway; otherwise a completed suite can
|
// Drain transport HTTP work before stopping the gateway; otherwise a completed suite can
|
||||||
// emit an unhandled response-close rejection during delivery.
|
// emit an unhandled response-close rejection during delivery.
|
||||||
params.cleanupTransportBeforeGatewayStop,
|
{ phase: "transport before gateway stop", run: params.cleanupTransportBeforeGatewayStop },
|
||||||
|
...(stopGateway ? [{ phase: "gateway stop", run: stopGatewayAndMark }] : []),
|
||||||
|
// Never release a credential-backed transport until gateway teardown proves
|
||||||
|
// that the isolated runtime reached its terminal boundary.
|
||||||
|
{ phase: "transport after gateway stop", run: cleanupTransportAfterGatewayStop },
|
||||||
|
{ phase: "agent harnesses", run: params.disposeAgentHarnesses },
|
||||||
|
...(params.stopProvider ? [{ phase: "provider stop", run: params.stopProvider }] : []),
|
||||||
|
{ phase: "lab finish", run: params.finishLab },
|
||||||
]);
|
]);
|
||||||
let gatewayStopped = !params.stopGateway;
|
|
||||||
if (params.stopGateway) {
|
|
||||||
const gatewayErrors = await runQaSuiteCleanupSteps([params.stopGateway]);
|
|
||||||
errors.push(...gatewayErrors);
|
|
||||||
gatewayStopped = gatewayErrors.length === 0;
|
|
||||||
}
|
|
||||||
errors.push(
|
|
||||||
...(await runQaSuiteCleanupSteps([
|
|
||||||
// Never release a credential-backed transport until gateway teardown proves
|
|
||||||
// that the isolated runtime reached its terminal boundary.
|
|
||||||
...(gatewayStopped ? [params.cleanupTransportAfterGatewayStop] : []),
|
|
||||||
params.disposeAgentHarnesses,
|
|
||||||
...(params.stopProvider ? [params.stopProvider] : []),
|
|
||||||
params.finishLab,
|
|
||||||
])),
|
|
||||||
);
|
|
||||||
return errors;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function throwQaSuiteCleanupErrors(params: {
|
export function throwQaSuiteCleanupErrors(params: {
|
||||||
cleanupErrors: unknown[];
|
cleanupFailures: readonly QaSuiteCleanupFailure[];
|
||||||
runFailed: boolean;
|
runFailed: boolean;
|
||||||
runError: unknown;
|
runError: unknown;
|
||||||
|
result?: QaSuiteResult;
|
||||||
|
evidenceWritten?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (params.cleanupErrors.length === 0) {
|
if (params.cleanupFailures.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (params.cleanupErrors.length === 1 && !params.runFailed) {
|
const result = params.result;
|
||||||
throw params.cleanupErrors[0];
|
const scenarios = result?.scenarios ?? [];
|
||||||
|
const failed = scenarios.filter((scenario) => scenario.status === "fail").length;
|
||||||
|
const skipped = scenarios.filter((scenario) => scenario.status === "skip").length;
|
||||||
|
const passed = scenarios.length - failed - skipped;
|
||||||
|
const cleanupHeadline = !result
|
||||||
|
? "QA suite cleanup failed before scenarios completed"
|
||||||
|
: failed === 0 && skipped === 0
|
||||||
|
? "QA scenarios passed, but cleanup failed"
|
||||||
|
: "QA scenarios completed, but cleanup failed";
|
||||||
|
const message = [
|
||||||
|
params.runFailed ? "QA suite and cleanup failed" : cleanupHeadline,
|
||||||
|
...(result
|
||||||
|
? [
|
||||||
|
`scenario counts: passed=${passed} failed=${failed} skipped=${skipped} total=${scenarios.length}`,
|
||||||
|
]
|
||||||
|
: params.runFailed
|
||||||
|
? ["scenarios did not complete"]
|
||||||
|
: []),
|
||||||
|
`failed cleanup phases: ${params.cleanupFailures
|
||||||
|
.map(
|
||||||
|
({ phase, error }) =>
|
||||||
|
`${sanitizeQaSuiteProgressValue(phase)}: ${sanitizeQaSuiteProgressValue(formatErrorMessage(error))}`,
|
||||||
|
)
|
||||||
|
.join("; ")}`,
|
||||||
|
...(result
|
||||||
|
? [
|
||||||
|
`retained artifacts: output=${sanitizeQaSuiteProgressValue(result.outputDir)} report=${sanitizeQaSuiteProgressValue(result.reportPath)} summary=${sanitizeQaSuiteProgressValue(result.summaryPath)}${params.evidenceWritten ? ` evidence=${sanitizeQaSuiteProgressValue(result.evidencePath)}` : ""}`,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
].join("\n");
|
||||||
|
const errors = params.cleanupFailures.map((failure) => failure.error);
|
||||||
|
if (params.runFailed) {
|
||||||
|
throw new AggregateError([params.runError, ...errors], message, { cause: params.runError });
|
||||||
}
|
}
|
||||||
throw new AggregateError(
|
if (errors.length === 1) {
|
||||||
params.runFailed ? [params.runError, ...params.cleanupErrors] : params.cleanupErrors,
|
throw new AggregateError(errors, message, { cause: errors[0] });
|
||||||
params.runFailed ? "QA suite and cleanup failed" : "QA suite cleanup failed",
|
}
|
||||||
params.runFailed ? { cause: params.runError } : undefined,
|
throw new AggregateError(errors, message);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requireQaSuiteStartLab(startLab: QaSuiteStartLabFn | undefined): QaSuiteStartLabFn {
|
export function requireQaSuiteStartLab(startLab: QaSuiteStartLabFn | undefined): QaSuiteStartLabFn {
|
||||||
|
|||||||
Reference in New Issue
Block a user