test(qa): add gateway CPU scenario pack

This commit is contained in:
Vincent Koc
2026-04-28 13:19:40 -07:00
parent 5e8d3130c6
commit 4509420dd4
13 changed files with 544 additions and 5 deletions

View File

@@ -22,6 +22,7 @@ import { formatQaGatewayLogsForError, redactQaGatewayDebugText } from "./gateway
import { startQaGatewayRpcClient } from "./gateway-rpc-client.js";
import { splitQaModelRef, type QaProviderMode } from "./model-selection.js";
import { resolveQaNodeExecPath } from "./node-exec.js";
import { readProcessTreeCpuMs } from "./process-tree-cpu.js";
import {
normalizeQaProviderModeEnv,
QA_LIVE_PROVIDER_CONFIG_PATH_ENV,
@@ -825,6 +826,7 @@ export async function startQaGatewayChild(params: {
baseUrl,
wsUrl,
pid: child.pid ?? null,
getProcessCpuMs: () => readProcessTreeCpuMs(activeChild.pid ?? null),
token: gatewayToken,
workspaceDir,
tempRoot,

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { parsePsCpuTimeMs } from "./process-tree-cpu.js";
describe("process tree CPU helpers", () => {
it("parses ps CPU time strings", () => {
expect(parsePsCpuTimeMs("00:01")).toBe(1_000);
expect(parsePsCpuTimeMs("01:02")).toBe(62_000);
expect(parsePsCpuTimeMs("01:02:03")).toBe(3_723_000);
});
it("rejects malformed ps CPU time strings", () => {
expect(parsePsCpuTimeMs("")).toBeNull();
expect(parsePsCpuTimeMs("nope")).toBeNull();
expect(parsePsCpuTimeMs("1:2:3:4")).toBeNull();
});
});

View File

@@ -0,0 +1,72 @@
import { spawnSync } from "node:child_process";
export function parsePsCpuTimeMs(raw: string): number | null {
const parts = raw.trim().split(":").map(Number);
if (parts.some((part) => !Number.isFinite(part) || part < 0)) {
return null;
}
if (parts.length === 2) {
return Math.round((parts[0] * 60 + parts[1]) * 1000);
}
if (parts.length === 3) {
return Math.round((parts[0] * 60 * 60 + parts[1] * 60 + parts[2]) * 1000);
}
return null;
}
export function readProcessTreeCpuMs(rootPid: number | null | undefined): number | null {
if (
typeof rootPid !== "number" ||
!Number.isInteger(rootPid) ||
rootPid <= 0 ||
process.platform === "win32"
) {
return null;
}
const result = spawnSync("ps", ["-eo", "pid=,ppid=,time="], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
if (result.status !== 0) {
return null;
}
const childrenByParent = new Map<number, number[]>();
const cpuByPid = new Map<number, number>();
for (const line of result.stdout.split("\n")) {
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)$/u);
if (!match) {
continue;
}
const [, pidRaw, ppidRaw, cpuRaw] = match;
const pid = Number(pidRaw);
const ppid = Number(ppidRaw);
const cpuMs = parsePsCpuTimeMs(cpuRaw ?? "");
if (!Number.isInteger(pid) || !Number.isInteger(ppid) || cpuMs === null) {
continue;
}
cpuByPid.set(pid, cpuMs);
const children = childrenByParent.get(ppid) ?? [];
children.push(pid);
childrenByParent.set(ppid, children);
}
if (!cpuByPid.has(rootPid)) {
return null;
}
let totalCpuMs = 0;
const seen = new Set<number>();
const stack: number[] = [rootPid];
while (stack.length > 0) {
const pid = stack.pop();
if (pid === undefined || seen.has(pid)) {
continue;
}
seen.add(pid);
totalCpuMs += cpuByPid.get(pid) ?? 0;
for (const childPid of childrenByParent.get(pid) ?? []) {
stack.push(childPid);
}
}
return totalCpuMs;
}

View File

@@ -7,6 +7,7 @@ export type QaRuntimeGatewayClient = {
tempRoot: string;
workspaceDir: string;
runtimeEnv: NodeJS.ProcessEnv;
getProcessCpuMs?: () => number | null;
restartAfterStateMutation?: (
mutateState: (context: {
configPath: string;

View File

@@ -14,6 +14,11 @@ export type QaSuiteSummaryJson = {
passed: number;
failed: number;
};
metrics?: {
wallMs: number;
gatewayProcessCpuMs?: number | null;
gatewayCpuCoreRatio?: number | null;
};
run: {
startedAt: string;
finishedAt: string;

View File

@@ -98,4 +98,20 @@ describe("buildQaSuiteSummaryJson", () => {
failed: 1,
});
});
it("records optional runtime metrics when provided", () => {
const json = buildQaSuiteSummaryJson({
...baseParams,
metrics: {
wallMs: 12_000,
gatewayProcessCpuMs: 3_400,
gatewayCpuCoreRatio: 0.283,
},
});
expect(json.metrics).toEqual({
wallMs: 12_000,
gatewayProcessCpuMs: 3_400,
gatewayCpuCoreRatio: 0.283,
});
});
});

View File

@@ -277,6 +277,7 @@ export type QaSuiteSummaryJsonParams = {
scenarios: QaSuiteScenarioResult[];
startedAt: Date;
finishedAt: Date;
metrics?: QaSuiteSummaryJson["metrics"];
providerMode: QaProviderMode;
primaryModel: string;
alternateModel: string;
@@ -317,6 +318,7 @@ export function buildQaSuiteSummaryJson(params: QaSuiteSummaryJsonParams): QaSui
passed: params.scenarios.filter((scenario) => scenario.status === "pass").length,
failed: countQaSuiteFailedScenarios(params.scenarios),
},
...(params.metrics ? { metrics: params.metrics } : {}),
run: {
startedAt: params.startedAt.toISOString(),
finishedAt: params.finishedAt.toISOString(),
@@ -340,6 +342,7 @@ async function writeQaSuiteArtifacts(params: {
startedAt: Date;
finishedAt: Date;
scenarios: QaSuiteScenarioResult[];
metrics?: QaSuiteSummaryJson["metrics"];
transport: QaTransportAdapter;
// Reuse the canonical QaProviderMode union instead of re-declaring it
// inline. Loop 6 already unified `QaSuiteSummaryJsonParams.providerMode`
@@ -376,6 +379,27 @@ async function writeQaSuiteArtifacts(params: {
return { report, reportPath, summaryPath };
}
function buildQaSuiteRuntimeMetrics(params: {
startedAt: Date;
finishedAt: Date;
gatewayProcessCpuStartMs: number | null;
gatewayProcessCpuEndMs: number | null;
}): QaSuiteSummaryJson["metrics"] {
const wallMs = Math.max(1, params.finishedAt.getTime() - params.startedAt.getTime());
if (params.gatewayProcessCpuStartMs === null || params.gatewayProcessCpuEndMs === null) {
return { wallMs };
}
const gatewayProcessCpuMs = Math.max(
0,
params.gatewayProcessCpuEndMs - params.gatewayProcessCpuStartMs,
);
return {
wallMs,
gatewayProcessCpuMs,
gatewayCpuCoreRatio: Math.round((gatewayProcessCpuMs / wallMs) * 1000) / 1000,
};
}
export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResult> {
const startedAt = new Date();
const repoRoot = path.resolve(params?.repoRoot ?? process.cwd());
@@ -730,6 +754,7 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
scenarios: liveScenarioOutcomes,
});
const gatewayProcessCpuStartMs = gateway.getProcessCpuMs?.() ?? null;
for (const [index, scenario] of selectedCatalogScenarios.entries()) {
const scenarioIdForLog = sanitizeQaSuiteProgressValue(scenario.id);
writeQaSuiteProgress(
@@ -773,6 +798,12 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
}
const finishedAt = new Date();
const metrics = buildQaSuiteRuntimeMetrics({
startedAt,
finishedAt,
gatewayProcessCpuStartMs,
gatewayProcessCpuEndMs: gateway.getProcessCpuMs?.() ?? null,
});
const failedCount = scenarios.filter((scenario) => scenario.status === "fail").length;
if (scenarios.some((scenario) => scenario.status === "fail")) {
preserveGatewayRuntimeDir = path.join(outputDir, "artifacts", "gateway-runtime");
@@ -789,6 +820,7 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
startedAt,
finishedAt,
scenarios,
metrics,
transport,
providerMode,
primaryModel,