QA Matrix: exit cleanly on failure

Make the Matrix QA CLI single-shot exit contract symmetric: artifact-backed failures now print the preserved error, flush stdio, and exit with code 1 instead of waiting on Matrix native handles.

Keep an opt-out for direct test harnesses with OPENCLAW_QA_MATRIX_DISABLE_FORCE_EXIT.
This commit is contained in:
Gustavo Madeira Santana
2026-04-16 21:23:47 -04:00
parent 7e659e168b
commit 42805d26cf
2 changed files with 92 additions and 9 deletions

View File

@@ -1,8 +1,55 @@
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { runQaMatrixCommand } = vi.hoisted(() => ({
runQaMatrixCommand: vi.fn(),
}));
vi.mock("./cli.runtime.js", () => ({
runQaMatrixCommand,
}));
import { matrixQaCliRegistration } from "./cli.js";
function mockProcessWrite(
_chunk: string | Uint8Array,
encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void),
callback?: (err?: Error | null) => void,
) {
if (typeof encodingOrCallback === "function") {
encodingOrCallback();
} else {
callback?.();
}
return true;
}
describe("matrix qa cli registration", () => {
const originalDisableForceExit = process.env.OPENCLAW_QA_MATRIX_DISABLE_FORCE_EXIT;
let exitSpy: ReturnType<typeof vi.spyOn>;
let stderrSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
runQaMatrixCommand.mockReset();
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit(${String(code)})`);
});
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(mockProcessWrite);
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(mockProcessWrite);
});
afterEach(() => {
if (originalDisableForceExit === undefined) {
delete process.env.OPENCLAW_QA_MATRIX_DISABLE_FORCE_EXIT;
} else {
process.env.OPENCLAW_QA_MATRIX_DISABLE_FORCE_EXIT = originalDisableForceExit;
}
exitSpy.mockRestore();
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
});
it("keeps disposable Matrix lane flags focused", () => {
const qa = new Command();
@@ -26,4 +73,27 @@ describe("matrix qa cli registration", () => {
expect(optionNames).not.toContain("--credential-source");
expect(optionNames).not.toContain("--credential-role");
});
it("exits with failure after Matrix artifacts are written for a failed run", async () => {
const qa = new Command();
matrixQaCliRegistration.register(qa);
runQaMatrixCommand.mockRejectedValue(new Error("Matrix QA failed.\nreport: /tmp/report.md"));
await expect(qa.parseAsync(["node", "openclaw", "matrix"])).rejects.toThrow("process.exit(1)");
expect(runQaMatrixCommand).toHaveBeenCalledOnce();
expect(stderrSpy).toHaveBeenCalledWith("Matrix QA failed.\nreport: /tmp/report.md\n");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("can disable the forced exit for direct test harnesses", async () => {
process.env.OPENCLAW_QA_MATRIX_DISABLE_FORCE_EXIT = "1";
const qa = new Command();
matrixQaCliRegistration.register(qa);
runQaMatrixCommand.mockRejectedValue(new Error("scenario failed"));
await expect(qa.parseAsync(["node", "openclaw", "matrix"])).rejects.toThrow("scenario failed");
expect(exitSpy).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,5 @@
import type { Command } from "commander";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
createLazyCliRuntimeLoader,
createLiveTransportQaCliRegistration,
@@ -8,6 +9,8 @@ import {
type MatrixQaCliRuntime = typeof import("./cli.runtime.js");
const DISABLE_MATRIX_QA_FORCE_EXIT_ENV = "OPENCLAW_QA_MATRIX_DISABLE_FORCE_EXIT";
const loadMatrixQaCliRuntime = createLazyCliRuntimeLoader<MatrixQaCliRuntime>(
() => import("./cli.runtime.js"),
);
@@ -25,16 +28,26 @@ async function flushProcessStream(stream: NodeJS.WriteStream) {
});
}
async function exitMatrixQaCommand(code: number): Promise<never> {
// Matrix crypto native handles can outlive the QA run even after every
// client/gateway/harness has been stopped. This command is single-shot, so
// artifact completion should terminate deterministically on both pass and fail.
await Promise.all([flushProcessStream(process.stdout), flushProcessStream(process.stderr)]);
process.exit(code);
}
async function runQaMatrix(opts: LiveTransportQaCommandOptions) {
const runtime = await loadMatrixQaCliRuntime();
await runtime.runQaMatrixCommand(opts);
if (process.env.OPENCLAW_QA_MATRIX_DISABLE_SUCCESS_EXIT !== "1") {
// Matrix crypto native handles can outlive the QA run even after every
// client/gateway/harness has been stopped. This command is single-shot, so
// a successful artifact write should terminate deterministically instead of
// waiting for external timeout cleanup.
await Promise.all([flushProcessStream(process.stdout), flushProcessStream(process.stderr)]);
process.exit(0);
if (process.env[DISABLE_MATRIX_QA_FORCE_EXIT_ENV] === "1") {
await runtime.runQaMatrixCommand(opts);
return;
}
try {
await runtime.runQaMatrixCommand(opts);
await exitMatrixQaCommand(0);
} catch (error) {
process.stderr.write(`${formatErrorMessage(error)}\n`);
await exitMatrixQaCommand(1);
}
}