mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
qa: harden parity gate execution (#70045)
This commit is contained in:
8
.github/workflows/parity-gate.yml
vendored
8
.github/workflows/parity-gate.yml
vendored
@@ -41,6 +41,7 @@ jobs:
|
||||
# followthrough gate that expects a fast post-approval read within a 30s
|
||||
# agent.wait timeout.
|
||||
QA_PARITY_CONCURRENCY: "1"
|
||||
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
|
||||
OPENAI_API_KEY: ""
|
||||
ANTHROPIC_API_KEY: ""
|
||||
OPENCLAW_LIVE_OPENAI_KEY: ""
|
||||
@@ -70,6 +71,13 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
run: pnpm build
|
||||
|
||||
- name: Run parity preflight
|
||||
run: |
|
||||
pnpm openclaw qa suite \
|
||||
--provider-mode mock-openai \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model anthropic/claude-opus-4-6 \
|
||||
--preflight
|
||||
- name: Run GPT-5.4 lane
|
||||
run: |
|
||||
pnpm openclaw qa suite \
|
||||
|
||||
@@ -83,9 +83,30 @@ import type { QaProviderModeInput } from "./run-config.js";
|
||||
|
||||
describe("qa cli runtime", () => {
|
||||
let stdoutWrite: ReturnType<typeof vi.spyOn>;
|
||||
let stderrWrite: ReturnType<typeof vi.spyOn>;
|
||||
let suiteArtifactsDir: string;
|
||||
let suiteReportPath: string;
|
||||
let suiteSummaryPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
suiteArtifactsDir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-suite-runtime-"));
|
||||
suiteReportPath = path.join(suiteArtifactsDir, "qa-suite-report.md");
|
||||
suiteSummaryPath = path.join(suiteArtifactsDir, "qa-suite-summary.json");
|
||||
await fs.writeFile(suiteReportPath, "# QA Suite Report\n", "utf8");
|
||||
await fs.writeFile(
|
||||
suiteSummaryPath,
|
||||
JSON.stringify({
|
||||
counts: {
|
||||
total: 1,
|
||||
passed: 1,
|
||||
failed: 0,
|
||||
},
|
||||
scenarios: [],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
stdoutWrite = vi.spyOn(process.stdout, "write").mockReturnValue(true);
|
||||
stderrWrite = vi.spyOn(process.stderr, "write").mockReturnValue(true);
|
||||
runQaSuiteFromRuntime.mockReset();
|
||||
runQaCharacterEval.mockReset();
|
||||
runQaManualLane.mockReset();
|
||||
@@ -101,8 +122,8 @@ describe("qa cli runtime", () => {
|
||||
);
|
||||
runQaSuiteFromRuntime.mockResolvedValue({
|
||||
watchUrl: "http://127.0.0.1:43124",
|
||||
reportPath: "/tmp/report.md",
|
||||
summaryPath: "/tmp/summary.json",
|
||||
reportPath: suiteReportPath,
|
||||
summaryPath: suiteSummaryPath,
|
||||
scenarios: [],
|
||||
});
|
||||
runQaCharacterEval.mockResolvedValue({
|
||||
@@ -153,9 +174,11 @@ describe("qa cli runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
stdoutWrite.mockRestore();
|
||||
stderrWrite.mockRestore();
|
||||
vi.clearAllMocks();
|
||||
await fs.rm(suiteArtifactsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("resolves suite repo-root-relative paths before dispatching", async () => {
|
||||
@@ -307,10 +330,22 @@ describe("qa cli runtime", () => {
|
||||
it("sets a failing exit code when host suite scenarios fail", async () => {
|
||||
const priorExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
await fs.writeFile(
|
||||
suiteSummaryPath,
|
||||
JSON.stringify({
|
||||
counts: {
|
||||
total: 1,
|
||||
passed: 0,
|
||||
failed: 1,
|
||||
},
|
||||
scenarios: [{ name: "channel chat baseline", status: "fail" }],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
runQaSuiteFromRuntime.mockResolvedValueOnce({
|
||||
watchUrl: "http://127.0.0.1:43124",
|
||||
reportPath: "/tmp/report.md",
|
||||
summaryPath: "/tmp/summary.json",
|
||||
reportPath: suiteReportPath,
|
||||
summaryPath: suiteSummaryPath,
|
||||
scenarios: [
|
||||
{
|
||||
name: "channel chat baseline",
|
||||
@@ -333,10 +368,22 @@ describe("qa cli runtime", () => {
|
||||
it("keeps host suite exit code clear when --allow-failures is set", async () => {
|
||||
const priorExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
await fs.writeFile(
|
||||
suiteSummaryPath,
|
||||
JSON.stringify({
|
||||
counts: {
|
||||
total: 1,
|
||||
passed: 0,
|
||||
failed: 1,
|
||||
},
|
||||
scenarios: [{ name: "channel chat baseline", status: "fail" }],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
runQaSuiteFromRuntime.mockResolvedValueOnce({
|
||||
watchUrl: "http://127.0.0.1:43124",
|
||||
reportPath: "/tmp/report.md",
|
||||
summaryPath: "/tmp/summary.json",
|
||||
reportPath: suiteReportPath,
|
||||
summaryPath: suiteSummaryPath,
|
||||
scenarios: [
|
||||
{
|
||||
name: "channel chat baseline",
|
||||
@@ -357,6 +404,203 @@ describe("qa cli runtime", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("retries host suite runs once for retryable infra failures", async () => {
|
||||
runQaSuiteFromRuntime
|
||||
.mockRejectedValueOnce(new Error("agent.wait timeout while waiting for transport ready"))
|
||||
.mockResolvedValueOnce({
|
||||
watchUrl: "http://127.0.0.1:43124",
|
||||
reportPath: suiteReportPath,
|
||||
summaryPath: suiteSummaryPath,
|
||||
scenarios: [],
|
||||
});
|
||||
|
||||
await runQaSuiteCommand({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
});
|
||||
|
||||
expect(runQaSuiteFromRuntime).toHaveBeenCalledTimes(2);
|
||||
expect(stderrWrite).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[qa-suite] infra retry 1/1: agent.wait timeout"),
|
||||
);
|
||||
});
|
||||
|
||||
it("retries host suite runs once for qa-channel readiness timeouts", async () => {
|
||||
runQaSuiteFromRuntime
|
||||
.mockRejectedValueOnce(
|
||||
new Error(
|
||||
"timed out after 180000ms waiting for qa-channel ready; last status: no qa-channel accounts reported",
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
watchUrl: "http://127.0.0.1:43124",
|
||||
reportPath: suiteReportPath,
|
||||
summaryPath: suiteSummaryPath,
|
||||
scenarios: [],
|
||||
});
|
||||
|
||||
await runQaSuiteCommand({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
});
|
||||
|
||||
expect(runQaSuiteFromRuntime).toHaveBeenCalledTimes(2);
|
||||
expect(stderrWrite).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"[qa-suite] infra retry 1/1: timed out after 180000ms waiting for qa-channel ready",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not retry host suite runs for generic timeout wording", async () => {
|
||||
runQaSuiteFromRuntime.mockRejectedValueOnce(
|
||||
new Error("approval-turn timed out waiting for post-approval read"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runQaSuiteCommand({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
}),
|
||||
).rejects.toThrow("approval-turn timed out waiting for post-approval read");
|
||||
|
||||
expect(runQaSuiteFromRuntime).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry host suite runs for semantic failures", async () => {
|
||||
const priorExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
await fs.writeFile(
|
||||
suiteSummaryPath,
|
||||
JSON.stringify({
|
||||
counts: {
|
||||
total: 1,
|
||||
passed: 0,
|
||||
failed: 1,
|
||||
},
|
||||
scenarios: [{ name: "channel chat baseline", status: "fail" }],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
runQaSuiteFromRuntime.mockResolvedValueOnce({
|
||||
watchUrl: "http://127.0.0.1:43124",
|
||||
reportPath: suiteReportPath,
|
||||
summaryPath: suiteSummaryPath,
|
||||
scenarios: [
|
||||
{
|
||||
name: "channel chat baseline",
|
||||
status: "fail",
|
||||
steps: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await runQaSuiteCommand({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
});
|
||||
expect(runQaSuiteFromRuntime).toHaveBeenCalledTimes(1);
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
process.exitCode = priorExitCode;
|
||||
}
|
||||
});
|
||||
|
||||
it("runs a host-only parity preflight against the sentinel scenario", async () => {
|
||||
await runQaSuiteCommand({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
providerMode: "mock-openai",
|
||||
primaryModel: "openai/gpt-5.4",
|
||||
alternateModel: "anthropic/claude-opus-4-6",
|
||||
preflight: true,
|
||||
});
|
||||
|
||||
expect(runQaSuiteFromRuntime).toHaveBeenCalledWith({
|
||||
repoRoot: path.resolve("/tmp/openclaw-repo"),
|
||||
outputDir: expect.stringMatching(
|
||||
/^\/tmp\/openclaw-repo\/\.artifacts\/qa-e2e\/preflight\/suite-/,
|
||||
),
|
||||
transportId: "qa-channel",
|
||||
providerMode: "mock-openai",
|
||||
primaryModel: "openai/gpt-5.4",
|
||||
alternateModel: "anthropic/claude-opus-4-6",
|
||||
scenarioIds: ["approval-turn-tool-followthrough"],
|
||||
concurrency: 1,
|
||||
});
|
||||
expect(stdoutWrite).toHaveBeenCalledWith(
|
||||
expect.stringContaining("QA parity preflight summary:"),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when parity preflight finds a failing sentinel scenario", async () => {
|
||||
await fs.writeFile(
|
||||
suiteSummaryPath,
|
||||
JSON.stringify({
|
||||
counts: {
|
||||
total: 1,
|
||||
passed: 0,
|
||||
failed: 1,
|
||||
},
|
||||
scenarios: [{ name: "approval turn tool followthrough", status: "fail" }],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
runQaSuiteFromRuntime.mockResolvedValueOnce({
|
||||
watchUrl: "http://127.0.0.1:43124",
|
||||
reportPath: suiteReportPath,
|
||||
summaryPath: suiteSummaryPath,
|
||||
scenarios: [{ name: "approval turn tool followthrough", status: "fail", steps: [] }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runQaSuiteCommand({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
preflight: true,
|
||||
}),
|
||||
).rejects.toThrow("QA parity preflight failed with 1 failing scenario.");
|
||||
});
|
||||
|
||||
it("keeps parity preflight exit code clear when --allow-failures is set", async () => {
|
||||
const priorExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
await fs.writeFile(
|
||||
suiteSummaryPath,
|
||||
JSON.stringify({
|
||||
counts: {
|
||||
total: 1,
|
||||
passed: 0,
|
||||
failed: 1,
|
||||
},
|
||||
scenarios: [{ name: "approval turn tool followthrough", status: "fail" }],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
runQaSuiteFromRuntime.mockResolvedValueOnce({
|
||||
watchUrl: "http://127.0.0.1:43124",
|
||||
reportPath: suiteReportPath,
|
||||
summaryPath: suiteSummaryPath,
|
||||
scenarios: [{ name: "approval turn tool followthrough", status: "fail", steps: [] }],
|
||||
});
|
||||
|
||||
try {
|
||||
await runQaSuiteCommand({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
preflight: true,
|
||||
allowFailures: true,
|
||||
});
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
} finally {
|
||||
process.exitCode = priorExitCode;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects preflight on the multipass runner", async () => {
|
||||
await expect(
|
||||
runQaSuiteCommand({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
runner: "multipass",
|
||||
preflight: true,
|
||||
}),
|
||||
).rejects.toThrow("--preflight requires --runner host.");
|
||||
});
|
||||
|
||||
it("passes host suite CLI auth mode through", async () => {
|
||||
await runQaSuiteCommand({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
type QaCredentialRecord,
|
||||
} from "./qa-credentials-admin.runtime.js";
|
||||
import { normalizeQaThinkingLevel, type QaThinkingLevel } from "./qa-gateway-config.js";
|
||||
import { normalizeQaTransportId } from "./qa-transport-registry.js";
|
||||
import { normalizeQaTransportId, type QaTransportId } from "./qa-transport-registry.js";
|
||||
import {
|
||||
defaultQaModelForMode,
|
||||
normalizeQaProviderMode,
|
||||
@@ -41,6 +41,8 @@ import { readQaScenarioPack } from "./scenario-catalog.js";
|
||||
import { runQaSuiteFromRuntime } from "./suite-launch.runtime.js";
|
||||
import { readQaSuiteFailedScenarioCountFromSummary } from "./suite-summary.js";
|
||||
|
||||
const QA_SUITE_INFRA_RETRY_LIMIT = 1;
|
||||
|
||||
type InterruptibleServer = {
|
||||
baseUrl: string;
|
||||
stop(): Promise<void>;
|
||||
@@ -152,6 +154,98 @@ async function readQaFailedScenarioCountFromSummary(summaryPath: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function isQaSuiteInfraRetryableError(error: unknown) {
|
||||
const message = formatErrorMessage(error).toLowerCase();
|
||||
return (
|
||||
message.includes("agent.wait timeout") ||
|
||||
message.includes("qa cli timed out") ||
|
||||
message.includes("readyz") ||
|
||||
message.includes("gateway healthy") ||
|
||||
message.includes("transport ready") ||
|
||||
message.includes("waiting for qa-channel ready") ||
|
||||
message.includes("econnreset") ||
|
||||
message.includes("econnrefused") ||
|
||||
message.includes("socket hang up") ||
|
||||
message.includes("could not read qa summary json") ||
|
||||
message.includes("could not parse qa summary json") ||
|
||||
message.includes("did not include counts.failed or scenarios[].status") ||
|
||||
message.includes("did not produce report artifact")
|
||||
);
|
||||
}
|
||||
|
||||
async function assertQaSuiteArtifacts(result: { reportPath: string; summaryPath: string }) {
|
||||
try {
|
||||
await fs.access(result.reportPath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`QA suite did not produce report artifact at ${result.reportPath}: ${formatErrorMessage(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
await readQaFailedScenarioCountFromSummary(result.summaryPath);
|
||||
}
|
||||
|
||||
async function runQaSuiteFromRuntimeWithInfraRetry(
|
||||
params: Parameters<typeof runQaSuiteFromRuntime>[0],
|
||||
maxRetries = QA_SUITE_INFRA_RETRY_LIMIT,
|
||||
) {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
||||
try {
|
||||
const result = await runQaSuiteFromRuntime(params);
|
||||
await assertQaSuiteArtifacts(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const retryable = isQaSuiteInfraRetryableError(error);
|
||||
if (!retryable || attempt >= maxRetries) {
|
||||
throw error;
|
||||
}
|
||||
process.stderr.write(
|
||||
`[qa-suite] infra retry ${attempt + 1}/${maxRetries}: ${formatErrorMessage(error)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable qa suite retry state");
|
||||
}
|
||||
|
||||
async function runQaParityPreflight(params: {
|
||||
repoRoot: string;
|
||||
transportId: QaTransportId;
|
||||
providerMode: QaProviderMode;
|
||||
primaryModel?: string;
|
||||
alternateModel?: string;
|
||||
allowFailures?: boolean;
|
||||
}) {
|
||||
const outputDir = path.join(
|
||||
params.repoRoot,
|
||||
".artifacts",
|
||||
"qa-e2e",
|
||||
"preflight",
|
||||
`suite-${Date.now().toString(36)}`,
|
||||
);
|
||||
const result = await runQaSuiteFromRuntimeWithInfraRetry({
|
||||
repoRoot: params.repoRoot,
|
||||
outputDir,
|
||||
transportId: params.transportId,
|
||||
providerMode: params.providerMode,
|
||||
primaryModel: params.primaryModel,
|
||||
alternateModel: params.alternateModel,
|
||||
scenarioIds: ["approval-turn-tool-followthrough"],
|
||||
concurrency: 1,
|
||||
});
|
||||
process.stdout.write(`QA parity preflight watch: ${result.watchUrl}\n`);
|
||||
process.stdout.write(`QA parity preflight report: ${result.reportPath}\n`);
|
||||
process.stdout.write(`QA parity preflight summary: ${result.summaryPath}\n`);
|
||||
const failedScenarioCount = await readQaFailedScenarioCountFromSummary(result.summaryPath);
|
||||
if (failedScenarioCount > 0) {
|
||||
if (params.allowFailures === true) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`QA parity preflight failed with ${failedScenarioCount} failing scenario${failedScenarioCount === 1 ? "" : "s"}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseQaCliBackendAuthMode(value: string | undefined): QaCliBackendAuthMode | undefined {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
@@ -365,6 +459,7 @@ export async function runQaSuiteCommand(opts: {
|
||||
cpus?: number;
|
||||
memory?: string;
|
||||
disk?: string;
|
||||
preflight?: boolean;
|
||||
}) {
|
||||
const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
|
||||
const transportId = normalizeQaTransportId(opts.transportId);
|
||||
@@ -379,6 +474,9 @@ export async function runQaSuiteCommand(opts: {
|
||||
}
|
||||
const providerMode = normalizeQaProviderMode(opts.providerMode);
|
||||
const claudeCliAuthMode = parseQaCliBackendAuthMode(opts.cliAuthMode);
|
||||
if (opts.preflight === true && runner !== "host") {
|
||||
throw new Error("--preflight requires --runner host.");
|
||||
}
|
||||
if (
|
||||
runner === "host" &&
|
||||
(opts.image !== undefined ||
|
||||
@@ -423,7 +521,18 @@ export async function runQaSuiteCommand(opts: {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const result = await runQaSuiteFromRuntime({
|
||||
if (opts.preflight === true) {
|
||||
await runQaParityPreflight({
|
||||
repoRoot,
|
||||
transportId,
|
||||
providerMode,
|
||||
primaryModel: opts.primaryModel,
|
||||
alternateModel: opts.alternateModel,
|
||||
allowFailures,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await runQaSuiteFromRuntimeWithInfraRetry({
|
||||
repoRoot,
|
||||
outputDir: resolveRepoRelativeOutputDir(repoRoot, opts.outputDir),
|
||||
transportId,
|
||||
|
||||
@@ -45,6 +45,7 @@ async function runQaSuite(opts: {
|
||||
cpus?: number;
|
||||
memory?: string;
|
||||
disk?: string;
|
||||
preflight?: boolean;
|
||||
}) {
|
||||
const runtime = await loadQaLabCliRuntime();
|
||||
await runtime.runQaSuiteCommand(opts);
|
||||
@@ -239,6 +240,7 @@ export function registerQaLabCli(program: Command) {
|
||||
.option("--concurrency <count>", "Scenario worker concurrency", (value: string) =>
|
||||
Number(value),
|
||||
)
|
||||
.option("--preflight", "Run a single-scenario bootstrap preflight and stop", false)
|
||||
.option(
|
||||
"--allow-failures",
|
||||
"Write artifacts without setting a failing exit code when scenarios fail",
|
||||
@@ -268,6 +270,7 @@ export function registerQaLabCli(program: Command) {
|
||||
cpus?: number;
|
||||
memory?: string;
|
||||
disk?: string;
|
||||
preflight?: boolean;
|
||||
}) => {
|
||||
await runQaSuite({
|
||||
repoRoot: opts.repoRoot,
|
||||
@@ -287,6 +290,7 @@ export function registerQaLabCli(program: Command) {
|
||||
cpus: opts.cpus,
|
||||
memory: opts.memory,
|
||||
disk: opts.disk,
|
||||
preflight: opts.preflight,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -63,6 +63,38 @@ describe("qa channel transport", () => {
|
||||
expect(call).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("surfaces the last reported qa-channel account status on timeout", async () => {
|
||||
const transport = createQaChannelTransport(createQaBusState());
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
channelAccounts: {
|
||||
"qa-channel": [{ accountId: "default", running: false, restartPending: true }],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
transport.waitReady({
|
||||
gateway: { call },
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'timed out after 5ms waiting for qa-channel ready; last status: {"accountId":"default","running":false,"restartPending":true}',
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces the last probe error on timeout", async () => {
|
||||
const transport = createQaChannelTransport(createQaBusState());
|
||||
const call = vi.fn().mockRejectedValue(new Error("channels.status exploded"));
|
||||
|
||||
await expect(
|
||||
transport.waitReady({
|
||||
gateway: { call },
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
}),
|
||||
).rejects.toThrow("last probe error: channels.status exploded");
|
||||
});
|
||||
|
||||
it("inherits the shared normalized message capabilities", async () => {
|
||||
const transport = createQaChannelTransport(createQaBusState());
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { QaBusState } from "./bus-state.js";
|
||||
import { getQaProvider } from "./providers/index.js";
|
||||
import { QaStateBackedTransportAdapter, waitForQaTransportCondition } from "./qa-transport.js";
|
||||
import { QaStateBackedTransportAdapter } from "./qa-transport.js";
|
||||
import type {
|
||||
QaTransportActionName,
|
||||
QaTransportGatewayConfig,
|
||||
@@ -20,33 +22,54 @@ async function waitForQaChannelReady(params: {
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
}) {
|
||||
await waitForQaTransportCondition(
|
||||
async () => {
|
||||
try {
|
||||
const payload = (await params.gateway.call(
|
||||
"channels.status",
|
||||
{ probe: false, timeoutMs: 2_000 },
|
||||
{ timeoutMs: 5_000 },
|
||||
)) as {
|
||||
channelAccounts?: Record<
|
||||
string,
|
||||
Array<{
|
||||
accountId?: string;
|
||||
running?: boolean;
|
||||
restartPending?: boolean;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
const accounts = payload.channelAccounts?.[QA_CHANNEL_ID] ?? [];
|
||||
const account =
|
||||
accounts.find((entry) => entry.accountId === QA_CHANNEL_ACCOUNT_ID) ?? accounts[0];
|
||||
return account?.running && account.restartPending !== true ? true : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
const timeoutMs = params.timeoutMs ?? 45_000;
|
||||
const pollIntervalMs = params.pollIntervalMs ?? 500;
|
||||
const startedAt = Date.now();
|
||||
let lastAccountStatus = "no qa-channel accounts reported";
|
||||
let lastProbeError: string | null = null;
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const payload = (await params.gateway.call(
|
||||
"channels.status",
|
||||
{ probe: false, timeoutMs: 2_000 },
|
||||
{ timeoutMs: 5_000 },
|
||||
)) as {
|
||||
channelAccounts?: Record<
|
||||
string,
|
||||
Array<{
|
||||
accountId?: string;
|
||||
running?: boolean;
|
||||
restartPending?: boolean;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
const accounts = payload.channelAccounts?.[QA_CHANNEL_ID] ?? [];
|
||||
const account =
|
||||
accounts.find((entry) => entry.accountId === QA_CHANNEL_ACCOUNT_ID) ?? accounts[0];
|
||||
lastProbeError = null;
|
||||
lastAccountStatus = account
|
||||
? JSON.stringify({
|
||||
accountId: account.accountId ?? null,
|
||||
running: account.running ?? null,
|
||||
restartPending: account.restartPending ?? null,
|
||||
})
|
||||
: "no qa-channel accounts reported";
|
||||
if (account?.running && account.restartPending !== true) {
|
||||
return;
|
||||
}
|
||||
},
|
||||
params.timeoutMs ?? 45_000,
|
||||
params.pollIntervalMs ?? 500,
|
||||
} catch (error) {
|
||||
lastProbeError = formatErrorMessage(error);
|
||||
}
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
`timed out after ${timeoutMs}ms waiting for qa-channel ready`,
|
||||
`last status: ${lastAccountStatus}`,
|
||||
...(lastProbeError ? [`last probe error: ${lastProbeError}`] : []),
|
||||
].join("; "),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,23 @@ describe("qa suite", () => {
|
||||
expect(qaSuiteProgressTesting.shouldLogQaSuiteProgress({ CI: "false" })).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves transport-ready timeout from params and env", () => {
|
||||
expect(qaSuiteProgressTesting.resolveQaSuiteTransportReadyTimeoutMs(undefined, {})).toBe(
|
||||
120_000,
|
||||
);
|
||||
expect(
|
||||
qaSuiteProgressTesting.resolveQaSuiteTransportReadyTimeoutMs(undefined, {
|
||||
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000",
|
||||
}),
|
||||
).toBe(180_000);
|
||||
expect(
|
||||
qaSuiteProgressTesting.resolveQaSuiteTransportReadyTimeoutMs(undefined, {
|
||||
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "bad",
|
||||
}),
|
||||
).toBe(120_000);
|
||||
expect(qaSuiteProgressTesting.resolveQaSuiteTransportReadyTimeoutMs(90_000, {})).toBe(90_000);
|
||||
});
|
||||
|
||||
it("applies OPENCLAW_QA_SUITE_PROGRESS override and falls back on invalid values", () => {
|
||||
expect(
|
||||
qaSuiteProgressTesting.shouldLogQaSuiteProgress({
|
||||
|
||||
@@ -83,6 +83,7 @@ export type QaSuiteRunParams = {
|
||||
startLab?: QaSuiteStartLabFn;
|
||||
concurrency?: number;
|
||||
controlUiEnabled?: boolean;
|
||||
transportReadyTimeoutMs?: number;
|
||||
};
|
||||
|
||||
function parseQaSuiteBooleanEnv(value: string | undefined): boolean | undefined {
|
||||
@@ -107,6 +108,28 @@ function shouldLogQaSuiteProgress(env: NodeJS.ProcessEnv = process.env) {
|
||||
return parseQaSuiteBooleanEnv(env.CI) === true;
|
||||
}
|
||||
|
||||
function resolveQaSuiteTransportReadyTimeoutMs(
|
||||
explicitTimeoutMs?: number,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
if (
|
||||
typeof explicitTimeoutMs === "number" &&
|
||||
Number.isFinite(explicitTimeoutMs) &&
|
||||
explicitTimeoutMs > 0
|
||||
) {
|
||||
return Math.floor(explicitTimeoutMs);
|
||||
}
|
||||
const raw = env.OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS;
|
||||
if (!raw) {
|
||||
return 120_000;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
return 120_000;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function writeQaSuiteProgress(enabled: boolean, message: string) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
@@ -628,12 +651,15 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
|
||||
|
||||
let preserveGatewayRuntimeDir: string | undefined;
|
||||
try {
|
||||
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(env, 120_000).catch(async () => {
|
||||
await waitForGatewayHealthy(env, 120_000);
|
||||
await waitForTransportReady(env, 120_000);
|
||||
await waitForTransportReady(env, transportReadyTimeoutMs).catch(async () => {
|
||||
await waitForGatewayHealthy(env, transportReadyTimeoutMs);
|
||||
await waitForTransportReady(env, transportReadyTimeoutMs);
|
||||
});
|
||||
await sleep(1_000);
|
||||
const scenarios: QaSuiteScenarioResult[] = [];
|
||||
@@ -769,6 +795,7 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
|
||||
|
||||
export const qaSuiteProgressTesting = {
|
||||
parseQaSuiteBooleanEnv,
|
||||
resolveQaSuiteTransportReadyTimeoutMs,
|
||||
sanitizeQaSuiteProgressValue,
|
||||
shouldLogQaSuiteProgress,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user