refactor: share QA runtime helpers (#87412)

* refactor: share QA runtime helpers

* refactor: keep QA helpers private

* refactor: keep QA helpers on private runtime seam

* chore: prune stale QA duplicate ignores

* fix: align qa runtime boundary alias

* fix: avoid startup memory lint conversion
This commit is contained in:
Dallin Romney
2026-05-27 21:16:24 -07:00
committed by GitHub
parent 96b8df75d5
commit 8d21ac3f6e
32 changed files with 656 additions and 954 deletions
+5 -1
View File
@@ -47,7 +47,11 @@ export {
QA_BASE_RUNTIME_PLUGIN_IDS,
type QaThinkingLevel,
} from "./src/qa-gateway-config.js";
export { type QaReportCheck, type QaReportScenario, renderQaMarkdownReport } from "./src/report.js";
export {
renderQaMarkdownReport,
type QaReportCheck,
type QaReportScenario,
} from "openclaw/plugin-sdk/qa-runtime";
export {
type QaScenarioDefinition,
type QaScenarioResult,
+18 -274
View File
@@ -1,277 +1,21 @@
import { execFile } from "node:child_process";
import { createServer } from "node:net";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
createQaDockerRuntime,
type QaDockerFetchLike as FetchLike,
type QaDockerRunCommand as RunCommand,
} from "openclaw/plugin-sdk/qa-runtime";
export type RunCommand = (
command: string,
args: string[],
cwd: string,
) => Promise<{ stdout: string; stderr: string }>;
export type { FetchLike, RunCommand };
export type FetchLike = (input: string) => Promise<{ ok: boolean }>;
const dockerRuntime = createQaDockerRuntime({
auditContext: "qa-lab-docker-health-check",
commandTimeoutMs: null,
});
export async function fetchHealthUrl(url: string): Promise<{ ok: boolean }> {
const { response, release } = await fetchWithSsrFGuard({
url,
init: {
signal: AbortSignal.timeout(2_000),
},
policy: { allowPrivateNetwork: true },
auditContext: "qa-lab-docker-health-check",
});
try {
return { ok: response.ok };
} finally {
await release();
}
}
function describeError(error: unknown) {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
return JSON.stringify(error);
}
async function isPortFree(port: number) {
return await new Promise<boolean>((resolve) => {
const server = createServer();
server.once("error", () => resolve(false));
server.listen(port, "127.0.0.1", () => {
server.close(() => resolve(true));
});
});
}
async function findFreePort() {
return await new Promise<number>((resolve, reject) => {
const server = createServer();
server.once("error", reject);
server.listen(0, () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("failed to find free port"));
return;
}
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(address.port);
});
});
});
}
export async function resolveHostPort(preferredPort: number, pinned: boolean) {
if (pinned || (await isPortFree(preferredPort))) {
return preferredPort;
}
return await findFreePort();
}
function trimCommandOutput(output: string) {
const trimmed = output.trim();
if (!trimmed) {
return "";
}
const lines = trimmed.split("\n");
return lines.length <= 120 ? trimmed : lines.slice(-120).join("\n");
}
export async function execCommand(command: string, args: string[], cwd: string) {
return await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
execFile(
command,
args,
{ cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 },
(error, stdout, stderr) => {
if (error) {
const renderedStdout = trimCommandOutput(stdout);
const renderedStderr = trimCommandOutput(stderr);
reject(
new Error(
[
`Command failed: ${[command, ...args].join(" ")}`,
renderedStderr ? `stderr:\n${renderedStderr}` : "",
renderedStdout ? `stdout:\n${renderedStdout}` : "",
]
.filter(Boolean)
.join("\n\n"),
),
);
return;
}
resolve({ stdout, stderr });
},
);
});
}
export async function waitForHealth(
url: string,
deps: {
label?: string;
composeFile?: string;
fetchImpl: FetchLike;
sleepImpl: (ms: number) => Promise<unknown>;
timeoutMs?: number;
pollMs?: number;
},
) {
const timeoutMs = deps.timeoutMs ?? 360_000;
const pollMs = deps.pollMs ?? 1_000;
const startMs = Date.now();
const deadline = startMs + timeoutMs;
let lastError: unknown = null;
while (Date.now() < deadline) {
try {
const response = await deps.fetchImpl(url);
if (response.ok) {
return;
}
lastError = new Error(`Health check returned non-OK for ${url}`);
} catch (error) {
lastError = error;
}
await deps.sleepImpl(pollMs);
}
const elapsedSec = Math.round((Date.now() - startMs) / 1000);
const service = deps.label ?? url;
const lines = [
`${service} did not become healthy within ${elapsedSec}s (limit ${Math.round(timeoutMs / 1000)}s).`,
lastError ? `Last error: ${describeError(lastError)}` : "",
`Hint: check container logs with \`docker compose -f ${deps.composeFile ?? "<compose-file>"} logs\` and verify the port is not already in use.`,
];
throw new Error(lines.filter(Boolean).join("\n"));
}
async function isHealthy(url: string, fetchImpl: FetchLike) {
try {
const response = await fetchImpl(url);
return response.ok;
} catch {
return false;
}
}
function normalizeDockerServiceStatus(row?: { Health?: string; State?: string }) {
const health = row?.Health?.trim();
if (health) {
return health;
}
const state = row?.State?.trim();
if (state) {
return state;
}
return "unknown";
}
function parseDockerComposePsRows(stdout: string) {
const trimmed = stdout.trim();
if (!trimmed) {
return [] as Array<{ Health?: string; State?: string }>;
}
try {
const parsed = JSON.parse(trimmed) as
| Array<{ Health?: string; State?: string }>
| { Health?: string; State?: string };
if (Array.isArray(parsed)) {
return parsed;
}
return [parsed];
} catch {
return normalizeStringEntries(trimmed.split("\n")).map(
(line) => JSON.parse(line) as { Health?: string; State?: string },
);
}
}
export async function waitForDockerServiceHealth(
service: string,
composeFile: string,
repoRoot: string,
runCommand: RunCommand,
sleepImpl: (ms: number) => Promise<unknown>,
timeoutMs = 360_000,
pollMs = 1_000,
) {
const startMs = Date.now();
const deadline = startMs + timeoutMs;
let lastStatus = "unknown";
while (Date.now() < deadline) {
try {
const { stdout } = await runCommand(
"docker",
["compose", "-f", composeFile, "ps", "--format", "json", service],
repoRoot,
);
const rows = parseDockerComposePsRows(stdout);
const row = rows[0];
lastStatus = normalizeDockerServiceStatus(row);
if (lastStatus === "healthy" || lastStatus === "running") {
return;
}
} catch (error) {
lastStatus = describeError(error);
}
await sleepImpl(pollMs);
}
const elapsedSec = Math.round((Date.now() - startMs) / 1000);
throw new Error(
[
`${service} did not become healthy within ${elapsedSec}s (limit ${Math.round(timeoutMs / 1000)}s).`,
`Last status: ${lastStatus}`,
`Hint: check container logs with \`docker compose -f ${composeFile} logs ${service}\`.`,
].join("\n"),
);
}
export async function resolveComposeServiceUrl(
service: string,
port: number,
composeFile: string,
repoRoot: string,
runCommand: RunCommand,
fetchImpl?: FetchLike,
) {
const { stdout: containerStdout } = await runCommand(
"docker",
["compose", "-f", composeFile, "ps", "-q", service],
repoRoot,
);
const containerId = containerStdout.trim();
if (!containerId) {
return null;
}
const { stdout: ipStdout } = await runCommand(
"docker",
[
"inspect",
"--format",
"{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}",
containerId,
],
repoRoot,
);
const ip = ipStdout.trim();
if (!ip) {
return null;
}
const baseUrl = `http://${ip}:${port}/`;
if (!fetchImpl) {
return baseUrl;
}
return (await isHealthy(`${baseUrl}healthz`, fetchImpl)) ? baseUrl : null;
}
export const {
execCommand,
fetchHealthUrl,
resolveComposeServiceUrl,
resolveHostPort,
waitForDockerServiceHealth,
waitForHealth,
} = dockerRuntime;
@@ -1,8 +1,6 @@
import { printLiveTransportQaArtifacts } from "openclaw/plugin-sdk/qa-runtime";
import type { LiveTransportQaCommandOptions } from "../shared/live-transport-cli.js";
import {
printLiveTransportQaArtifacts,
resolveLiveTransportQaRunOptions,
} from "../shared/live-transport-cli.runtime.js";
import { resolveLiveTransportQaRunOptions } from "../shared/live-transport-cli.runtime.js";
import { runDiscordQaLive } from "./discord-live.runtime.js";
export async function runQaDiscordCommand(opts: LiveTransportQaCommandOptions) {
@@ -10,6 +10,10 @@ import {
import { DEFAULT_EMOJIS } from "openclaw/plugin-sdk/channel-feedback";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
appendQaLiveLaneIssue as appendLiveLaneIssue,
buildQaLiveLaneArtifactsError as buildLiveLaneArtifactsError,
} from "openclaw/plugin-sdk/qa-runtime";
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { chromium } from "playwright-core";
@@ -27,7 +31,6 @@ import {
type QaCredentialRole,
} from "../shared/credential-lease.runtime.js";
import { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
import { appendLiveLaneIssue, buildLiveLaneArtifactsError } from "../shared/live-lane-helpers.js";
import {
collectLiveTransportStandardScenarioCoverage,
selectLiveTransportScenarios,
@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { appendQaLiveLaneIssue as appendLiveLaneIssue } from "openclaw/plugin-sdk/qa-runtime";
import {
startQaGatewayChild,
type QaCliBackendAuthMode,
@@ -7,7 +8,6 @@ import {
import type { QaProviderMode } from "../../model-selection.js";
import { startQaProviderServer } from "../../providers/server-runtime.js";
import type { QaThinkingLevel } from "../../qa-gateway-config.js";
import { appendLiveLaneIssue } from "./live-lane-helpers.js";
async function stopQaLiveLaneResources(
resources: {
@@ -1,18 +0,0 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
export function appendLiveLaneIssue(issues: string[], label: string, error: unknown) {
issues.push(`${label}: ${formatErrorMessage(error)}`);
}
export function buildLiveLaneArtifactsError(params: {
heading: string;
artifacts: Record<string, string>;
details?: string[];
}) {
return [
params.heading,
...(params.details ?? []),
"Artifacts:",
...Object.entries(params.artifacts).map(([label, filePath]) => `- ${label}: ${filePath}`),
].join("\n");
}
@@ -37,12 +37,3 @@ export function resolveLiveTransportQaRunOptions(
credentialRole: opts.credentialRole?.trim(),
};
}
export function printLiveTransportQaArtifacts(
laneLabel: string,
artifacts: Record<string, string>,
) {
for (const [label, filePath] of Object.entries(artifacts)) {
process.stdout.write(`${laneLabel} ${label}: ${filePath}\n`);
}
}
@@ -1,8 +1,6 @@
import { printLiveTransportQaArtifacts } from "openclaw/plugin-sdk/qa-runtime";
import type { LiveTransportQaCommandOptions } from "../shared/live-transport-cli.js";
import {
printLiveTransportQaArtifacts,
resolveLiveTransportQaRunOptions,
} from "../shared/live-transport-cli.runtime.js";
import { resolveLiveTransportQaRunOptions } from "../shared/live-transport-cli.runtime.js";
import { runSlackQaLive } from "./slack-live.runtime.js";
export async function runQaSlackCommand(opts: LiveTransportQaCommandOptions) {
@@ -5,6 +5,10 @@ import { createSlackWebClient, createSlackWriteClient } from "@openclaw/slack/ap
import type { WebClient } from "@slack/web-api";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
appendQaLiveLaneIssue as appendLiveLaneIssue,
buildQaLiveLaneArtifactsError as buildLiveLaneArtifactsError,
} from "openclaw/plugin-sdk/qa-runtime";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { z } from "zod";
import { startQaGatewayChild } from "../../gateway-child.js";
@@ -20,7 +24,6 @@ import {
type QaCredentialRole,
} from "../shared/credential-lease.runtime.js";
import { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
import { appendLiveLaneIssue, buildLiveLaneArtifactsError } from "../shared/live-lane-helpers.js";
import {
collectLiveTransportStandardScenarioCoverage,
selectLiveTransportScenarios,
@@ -1,8 +1,6 @@
import { printLiveTransportQaArtifacts } from "openclaw/plugin-sdk/qa-runtime";
import type { LiveTransportQaCommandOptions } from "../shared/live-transport-cli.js";
import {
printLiveTransportQaArtifacts,
resolveLiveTransportQaRunOptions,
} from "../shared/live-transport-cli.runtime.js";
import { resolveLiveTransportQaRunOptions } from "../shared/live-transport-cli.runtime.js";
import { listTelegramQaScenarioCatalog, runTelegramQaLive } from "./telegram-live.runtime.js";
export async function runQaTelegramCommand(opts: LiveTransportQaCommandOptions) {
@@ -5,6 +5,10 @@ import path from "node:path";
import { promisify } from "node:util";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
appendQaLiveLaneIssue as appendLiveLaneIssue,
buildQaLiveLaneArtifactsError as buildLiveLaneArtifactsError,
} from "openclaw/plugin-sdk/qa-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { isRecord, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
@@ -23,7 +27,6 @@ import {
type QaCredentialRole,
} from "../shared/credential-lease.runtime.js";
import { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
import { appendLiveLaneIssue, buildLiveLaneArtifactsError } from "../shared/live-lane-helpers.js";
import {
collectLiveTransportStandardScenarioCoverage,
selectLiveTransportScenarios,
@@ -1,8 +1,6 @@
import { printLiveTransportQaArtifacts } from "openclaw/plugin-sdk/qa-runtime";
import type { LiveTransportQaCommandOptions } from "../shared/live-transport-cli.js";
import {
printLiveTransportQaArtifacts,
resolveLiveTransportQaRunOptions,
} from "../shared/live-transport-cli.runtime.js";
import { resolveLiveTransportQaRunOptions } from "../shared/live-transport-cli.runtime.js";
import { runWhatsAppQaLive } from "./whatsapp-live.runtime.js";
export async function runQaWhatsAppCommand(opts: LiveTransportQaCommandOptions) {
@@ -7,6 +7,10 @@ import { startWhatsAppQaDriverSession } from "@openclaw/whatsapp/api.js";
import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
appendQaLiveLaneIssue as appendLiveLaneIssue,
buildQaLiveLaneArtifactsError as buildLiveLaneArtifactsError,
} from "openclaw/plugin-sdk/qa-runtime";
import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { z } from "zod";
@@ -23,7 +27,6 @@ import {
type QaCredentialRole,
} from "../shared/credential-lease.runtime.js";
import { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
import { appendLiveLaneIssue, buildLiveLaneArtifactsError } from "../shared/live-lane-helpers.js";
import {
collectLiveTransportStandardScenarioCoverage,
selectLiveTransportScenarios,
-51
View File
@@ -1,51 +0,0 @@
import { describe, expect, it } from "vitest";
import { renderQaMarkdownReport } from "./report.js";
describe("renderQaMarkdownReport", () => {
it("renders multiline scenario details in fenced blocks", () => {
const report = renderQaMarkdownReport({
title: "QA",
startedAt: new Date("2026-04-08T10:00:00.000Z"),
finishedAt: new Date("2026-04-08T10:00:02.000Z"),
scenarios: [
{
name: "Character vibes: Gollum improv",
status: "pass",
steps: [
{
name: "records transcript",
status: "pass",
details: "USER Alice: hello\n\nASSISTANT OpenClaw: my precious build",
},
],
},
],
});
expect(report).toBe(`# QA
- Started: 2026-04-08T10:00:00.000Z
- Finished: 2026-04-08T10:00:02.000Z
- Duration ms: 2000
- Passed: 1
- Failed: 0
## Scenarios
### Character vibes: Gollum improv
- Status: pass
- Steps:
- [x] records transcript
- Details:
\`\`\`text
USER Alice: hello
ASSISTANT OpenClaw: my precious build
\`\`\`
`);
});
});
-100
View File
@@ -1,100 +0,0 @@
export type QaReportCheck = {
name: string;
status: "pass" | "fail" | "skip";
details?: string;
};
export type QaReportScenario = {
name: string;
status: "pass" | "fail" | "skip";
details?: string;
steps?: QaReportCheck[];
};
function pushDetailsBlock(lines: string[], label: string, details: string, indent = "") {
if (!details.includes("\n")) {
lines.push(`${indent}- ${label}: ${details}`);
return;
}
lines.push(`${indent}- ${label}:`);
lines.push("", "```text", details, "```");
}
export function renderQaMarkdownReport(params: {
title: string;
startedAt: Date;
finishedAt: Date;
checks?: QaReportCheck[];
scenarios?: QaReportScenario[];
timeline?: string[];
notes?: string[];
}) {
const checks = params.checks ?? [];
const scenarios = params.scenarios ?? [];
const passCount =
checks.filter((check) => check.status === "pass").length +
scenarios.filter((scenario) => scenario.status === "pass").length;
const failCount =
checks.filter((check) => check.status === "fail").length +
scenarios.filter((scenario) => scenario.status === "fail").length;
const lines = [
`# ${params.title}`,
"",
`- Started: ${params.startedAt.toISOString()}`,
`- Finished: ${params.finishedAt.toISOString()}`,
`- Duration ms: ${params.finishedAt.getTime() - params.startedAt.getTime()}`,
`- Passed: ${passCount}`,
`- Failed: ${failCount}`,
"",
];
if (checks.length > 0) {
lines.push("## Checks", "");
for (const check of checks) {
lines.push(`- [${check.status === "pass" ? "x" : " "}] ${check.name}`);
if (check.details) {
pushDetailsBlock(lines, "Details", check.details, " ");
}
}
}
if (scenarios.length > 0) {
lines.push("", "## Scenarios", "");
for (const scenario of scenarios) {
lines.push(`### ${scenario.name}`);
lines.push("");
lines.push(`- Status: ${scenario.status}`);
if (scenario.details) {
pushDetailsBlock(lines, "Details", scenario.details);
}
if (scenario.steps?.length) {
lines.push("- Steps:");
for (const step of scenario.steps) {
lines.push(` - [${step.status === "pass" ? "x" : " "}] ${step.name}`);
if (step.details) {
pushDetailsBlock(lines, "Details", step.details, " ");
}
}
}
lines.push("");
}
}
if (params.timeline && params.timeline.length > 0) {
lines.push("## Timeline", "");
for (const item of params.timeline) {
lines.push(`- ${item}`);
}
}
if (params.notes && params.notes.length > 0) {
lines.push("", "## Notes", "");
for (const note of params.notes) {
lines.push(`- ${note}`);
}
}
lines.push("");
return lines.join("\n");
}
+1 -1
View File
@@ -1,9 +1,9 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { renderQaMarkdownReport } from "openclaw/plugin-sdk/qa-runtime";
import type { QaBusState } from "./bus-state.js";
import { createQaTransportAdapter, type QaTransportId } from "./qa-transport-registry.js";
import { renderQaMarkdownReport } from "./report.js";
import { runQaScenario, type QaScenarioResult } from "./scenario.js";
import { createQaSelfCheckScenario } from "./self-check-scenario.js";
+5 -1
View File
@@ -4,6 +4,11 @@ import { setTimeout as sleep } from "node:timers/promises";
import { disposeRegisteredAgentHarnesses } from "openclaw/plugin-sdk/agent-harness";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
renderQaMarkdownReport,
type QaReportCheck,
type QaReportScenario,
} from "openclaw/plugin-sdk/qa-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { startQaGatewayChild, type QaCliBackendAuthMode } from "./gateway-child.js";
import type {
@@ -28,7 +33,6 @@ import {
type QaTransportId,
} from "./qa-transport-registry.js";
import type { QaTransportAdapter } from "./qa-transport.js";
import { renderQaMarkdownReport, type QaReportCheck, type QaReportScenario } from "./report.js";
import { defaultQaModelForMode } from "./run-config.js";
import {
captureRuntimeParityCell,
+4 -4
View File
@@ -1,10 +1,10 @@
import { runMatrixQaLive } from "./runners/contract/runtime.js";
import type { LiveTransportQaCommandOptions } from "./shared/live-transport-cli.js";
import {
printLiveTransportQaArtifacts,
resolveLiveTransportQaRunOptions,
startLiveTransportQaOutputTee,
} from "./shared/live-transport-cli.runtime.js";
} from "openclaw/plugin-sdk/qa-runtime";
import { runMatrixQaLive } from "./runners/contract/runtime.js";
import type { LiveTransportQaCommandOptions } from "./shared/live-transport-cli.js";
import { resolveLiveTransportQaRunOptions } from "./shared/live-transport-cli.runtime.js";
const RUN_NODE_OUTPUT_LOG_ENV = "OPENCLAW_RUN_NODE_OUTPUT_LOG";
+17 -271
View File
@@ -1,274 +1,20 @@
import { createServer } from "node:net";
import { runExec } from "openclaw/plugin-sdk/process-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
createQaDockerRuntime,
type QaDockerFetchLike as FetchLike,
type QaDockerRunCommand as RunCommand,
} from "openclaw/plugin-sdk/qa-runtime";
const DEFAULT_DOCKER_COMMAND_TIMEOUT_MS = 120_000;
export type { FetchLike, RunCommand };
export type RunCommand = (
command: string,
args: string[],
cwd: string,
) => Promise<{ stdout: string; stderr: string }>;
const dockerRuntime = createQaDockerRuntime({
auditContext: "qa-matrix-docker-health-check",
});
export type FetchLike = (input: string) => Promise<{ ok: boolean }>;
export async function fetchHealthUrl(url: string): Promise<{ ok: boolean }> {
const { response, release } = await fetchWithSsrFGuard({
url,
init: {
signal: AbortSignal.timeout(2_000),
},
policy: { allowPrivateNetwork: true },
auditContext: "qa-matrix-docker-health-check",
});
try {
return { ok: response.ok };
} finally {
await release();
}
}
function describeError(error: unknown) {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
return JSON.stringify(error);
}
async function isPortFree(port: number) {
return await new Promise<boolean>((resolve) => {
const server = createServer();
server.once("error", () => resolve(false));
server.listen(port, "127.0.0.1", () => {
server.close(() => resolve(true));
});
});
}
async function findFreePort() {
return await new Promise<number>((resolve, reject) => {
const server = createServer();
server.once("error", reject);
server.listen(0, () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("failed to find free port"));
return;
}
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(address.port);
});
});
});
}
export async function resolveHostPort(preferredPort: number, pinned: boolean) {
if (pinned || (await isPortFree(preferredPort))) {
return preferredPort;
}
return await findFreePort();
}
function trimCommandOutput(output: string) {
const trimmed = output.trim();
if (!trimmed) {
return "";
}
const lines = trimmed.split("\n");
return lines.length <= 120 ? trimmed : lines.slice(-120).join("\n");
}
export async function execCommand(command: string, args: string[], cwd: string) {
try {
return await runExec(command, args, {
cwd,
maxBuffer: 10 * 1024 * 1024,
timeoutMs: DEFAULT_DOCKER_COMMAND_TIMEOUT_MS,
});
} catch (error) {
const failedProcess = error as Error & { stdout?: string; stderr?: string };
const renderedStdout = trimCommandOutput(failedProcess.stdout ?? "");
const renderedStderr = trimCommandOutput(failedProcess.stderr ?? "");
throw new Error(
[
`Command failed: ${[command, ...args].join(" ")}`,
renderedStderr ? `stderr:\n${renderedStderr}` : "",
renderedStdout ? `stdout:\n${renderedStdout}` : "",
]
.filter(Boolean)
.join("\n\n"),
{ cause: error },
);
}
}
export async function waitForHealth(
url: string,
deps: {
label?: string;
composeFile?: string;
fetchImpl: FetchLike;
sleepImpl: (ms: number) => Promise<unknown>;
timeoutMs?: number;
pollMs?: number;
},
) {
const timeoutMs = deps.timeoutMs ?? 360_000;
const pollMs = deps.pollMs ?? 1_000;
const startMs = Date.now();
const deadline = startMs + timeoutMs;
let lastError: unknown = null;
while (Date.now() < deadline) {
try {
const response = await deps.fetchImpl(url);
if (response.ok) {
return;
}
lastError = new Error(`Health check returned non-OK for ${url}`);
} catch (error) {
lastError = error;
}
await deps.sleepImpl(pollMs);
}
const elapsedSec = Math.round((Date.now() - startMs) / 1000);
const service = deps.label ?? url;
const lines = [
`${service} did not become healthy within ${elapsedSec}s (limit ${Math.round(timeoutMs / 1000)}s).`,
lastError ? `Last error: ${describeError(lastError)}` : "",
`Hint: check container logs with \`docker compose -f ${deps.composeFile ?? "<compose-file>"} logs\` and verify the port is not already in use.`,
];
throw new Error(lines.filter(Boolean).join("\n"));
}
async function isHealthy(url: string, fetchImpl: FetchLike) {
try {
const response = await fetchImpl(url);
return response.ok;
} catch {
return false;
}
}
function normalizeDockerServiceStatus(row?: { Health?: string; State?: string }) {
const health = row?.Health?.trim();
if (health) {
return health;
}
const state = row?.State?.trim();
if (state) {
return state;
}
return "unknown";
}
function parseDockerComposePsRows(stdout: string) {
const trimmed = stdout.trim();
if (!trimmed) {
return [] as Array<{ Health?: string; State?: string }>;
}
try {
const parsed = JSON.parse(trimmed) as
| Array<{ Health?: string; State?: string }>
| { Health?: string; State?: string };
if (Array.isArray(parsed)) {
return parsed;
}
return [parsed];
} catch {
return normalizeStringEntries(trimmed.split("\n")).map(
(line) => JSON.parse(line) as { Health?: string; State?: string },
);
}
}
export async function waitForDockerServiceHealth(
service: string,
composeFile: string,
repoRoot: string,
runCommand: RunCommand,
sleepImpl: (ms: number) => Promise<unknown>,
timeoutMs = 360_000,
pollMs = 1_000,
) {
const startMs = Date.now();
const deadline = startMs + timeoutMs;
let lastStatus = "unknown";
while (Date.now() < deadline) {
try {
const { stdout } = await runCommand(
"docker",
["compose", "-f", composeFile, "ps", "--format", "json", service],
repoRoot,
);
const rows = parseDockerComposePsRows(stdout);
const row = rows[0];
lastStatus = normalizeDockerServiceStatus(row);
if (lastStatus === "healthy" || lastStatus === "running") {
return;
}
} catch (error) {
lastStatus = describeError(error);
}
await sleepImpl(pollMs);
}
const elapsedSec = Math.round((Date.now() - startMs) / 1000);
throw new Error(
[
`${service} did not become healthy within ${elapsedSec}s (limit ${Math.round(timeoutMs / 1000)}s).`,
`Last status: ${lastStatus}`,
`Hint: check container logs with \`docker compose -f ${composeFile} logs ${service}\`.`,
].join("\n"),
);
}
export async function resolveComposeServiceUrl(
service: string,
port: number,
composeFile: string,
repoRoot: string,
runCommand: RunCommand,
fetchImpl?: FetchLike,
) {
const { stdout: containerStdout } = await runCommand(
"docker",
["compose", "-f", composeFile, "ps", "-q", service],
repoRoot,
);
const containerId = containerStdout.trim();
if (!containerId) {
return null;
}
const { stdout: ipStdout } = await runCommand(
"docker",
[
"inspect",
"--format",
"{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}",
containerId,
],
repoRoot,
);
const ip = ipStdout.trim();
if (!ip) {
return null;
}
const baseUrl = `http://${ip}:${port}/`;
if (!fetchImpl) {
return baseUrl;
}
return (await isHealthy(`${baseUrl}healthz`, fetchImpl)) ? baseUrl : null;
}
export const {
execCommand,
fetchHealthUrl,
resolveComposeServiceUrl,
resolveHostPort,
waitForDockerServiceHealth,
waitForHealth,
} = dockerRuntime;
-100
View File
@@ -1,100 +0,0 @@
export type QaReportCheck = {
name: string;
status: "pass" | "fail" | "skip";
details?: string;
};
type QaReportScenario = {
name: string;
status: "pass" | "fail" | "skip";
details?: string;
steps?: QaReportCheck[];
};
function pushDetailsBlock(lines: string[], label: string, details: string, indent = "") {
if (!details.includes("\n")) {
lines.push(`${indent}- ${label}: ${details}`);
return;
}
lines.push(`${indent}- ${label}:`);
lines.push("", "```text", details, "```");
}
export function renderQaMarkdownReport(params: {
title: string;
startedAt: Date;
finishedAt: Date;
checks?: QaReportCheck[];
scenarios?: QaReportScenario[];
timeline?: string[];
notes?: string[];
}) {
const checks = params.checks ?? [];
const scenarios = params.scenarios ?? [];
const passCount =
checks.filter((check) => check.status === "pass").length +
scenarios.filter((scenario) => scenario.status === "pass").length;
const failCount =
checks.filter((check) => check.status === "fail").length +
scenarios.filter((scenario) => scenario.status === "fail").length;
const lines = [
`# ${params.title}`,
"",
`- Started: ${params.startedAt.toISOString()}`,
`- Finished: ${params.finishedAt.toISOString()}`,
`- Duration ms: ${params.finishedAt.getTime() - params.startedAt.getTime()}`,
`- Passed: ${passCount}`,
`- Failed: ${failCount}`,
"",
];
if (checks.length > 0) {
lines.push("## Checks", "");
for (const check of checks) {
lines.push(`- [${check.status === "pass" ? "x" : " "}] ${check.name}`);
if (check.details) {
pushDetailsBlock(lines, "Details", check.details, " ");
}
}
}
if (scenarios.length > 0) {
lines.push("", "## Scenarios", "");
for (const scenario of scenarios) {
lines.push(`### ${scenario.name}`);
lines.push("");
lines.push(`- Status: ${scenario.status}`);
if (scenario.details) {
pushDetailsBlock(lines, "Details", scenario.details);
}
if (scenario.steps?.length) {
lines.push("- Steps:");
for (const step of scenario.steps) {
lines.push(` - [${step.status === "pass" ? "x" : " "}] ${step.name}`);
if (step.details) {
pushDetailsBlock(lines, "Details", step.details, " ");
}
}
}
lines.push("");
}
}
if (params.timeline && params.timeline.length > 0) {
lines.push("## Timeline", "");
for (const item of params.timeline) {
lines.push(`- ${item}`);
}
}
if (params.notes && params.notes.length > 0) {
lines.push("", "## Notes", "");
for (const note of params.notes) {
lines.push(`- ${note}`);
}
}
lines.push("");
return lines.join("\n");
}
@@ -1,6 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { renderQaMarkdownReport } from "openclaw/plugin-sdk/qa-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderQaMarkdownReport } from "../../report.js";
import { testing as liveTesting } from "./runtime.js";
afterEach(() => {
@@ -5,13 +5,13 @@ import { setTimeout as sleep } from "node:timers/promises";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { loadQaRuntimeModule } from "openclaw/plugin-sdk/qa-runner-runtime";
import type { QaReportCheck } from "../../report.js";
import { renderQaMarkdownReport } from "../../report.js";
import { normalizeQaProviderMode, type QaProviderModeInput } from "../../run-config.js";
import {
appendLiveLaneIssue,
buildLiveLaneArtifactsError,
} from "../../shared/live-lane-helpers.js";
appendQaLiveLaneIssue as appendLiveLaneIssue,
buildQaLiveLaneArtifactsError as buildLiveLaneArtifactsError,
renderQaMarkdownReport,
type QaReportCheck,
} from "openclaw/plugin-sdk/qa-runtime";
import { normalizeQaProviderMode, type QaProviderModeInput } from "../../run-config.js";
import { buildMatrixQaObservedEventsArtifact } from "../../substrate/artifacts.js";
import { provisionMatrixQaRoom, type MatrixQaProvisionResult } from "../../substrate/client.js";
import {
@@ -1,18 +0,0 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
export function appendLiveLaneIssue(issues: string[], label: string, error: unknown) {
issues.push(`${label}: ${formatErrorMessage(error)}`);
}
export function buildLiveLaneArtifactsError(params: {
heading: string;
artifacts: Record<string, string>;
details?: string[];
}) {
return [
params.heading,
...(params.details ?? []),
"Artifacts:",
...Object.entries(params.artifacts).map(([label, filePath]) => `- ${label}: ${filePath}`),
].join("\n");
}
@@ -1,8 +1,8 @@
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { startLiveTransportQaOutputTee } from "openclaw/plugin-sdk/qa-runtime";
import { afterEach, describe, expect, it } from "vitest";
import { startLiveTransportQaOutputTee } from "./live-transport-cli.runtime.js";
const tmpDirs: string[] = [];
@@ -1,5 +1,3 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { resolveRepoRelativeOutputDir } from "../cli-paths.js";
import type { QaProviderMode } from "../run-config.js";
@@ -35,74 +33,3 @@ export function resolveLiveTransportQaRunOptions(
credentialRole: opts.credentialRole?.trim(),
};
}
export function printLiveTransportQaArtifacts(
laneLabel: string,
artifacts: Record<string, string>,
) {
for (const [label, filePath] of Object.entries(artifacts)) {
process.stdout.write(`${laneLabel} ${label}: ${filePath}\n`);
}
}
type ProcessWriteCallback = (err?: Error | null) => void;
export async function startLiveTransportQaOutputTee(params: {
fileName: string;
outputDir: string;
}) {
await fsp.mkdir(params.outputDir, { recursive: true });
const outputPath = path.join(params.outputDir, params.fileName);
const output = fs.createWriteStream(outputPath, {
encoding: "utf8",
flags: "a",
mode: 0o600,
});
let outputError: Error | null = null;
output.on("error", (error) => {
outputError ??= error;
});
const originalStdoutWrite = Reflect.get(process.stdout, "write");
const originalStderrWrite = Reflect.get(process.stderr, "write");
const boundStdoutWrite = originalStdoutWrite.bind(process.stdout);
const boundStderrWrite = originalStderrWrite.bind(process.stderr);
let stopped = false;
const tee = (originalWrite: typeof process.stdout.write) =>
function writeWithTee(
this: NodeJS.WriteStream,
chunk: string | Uint8Array,
encodingOrCallback?: BufferEncoding | ProcessWriteCallback,
callback?: ProcessWriteCallback,
) {
if (!stopped && !outputError) {
output.write(chunk);
}
return Reflect.apply(originalWrite, this, [chunk, encodingOrCallback, callback]) as boolean;
};
process.stdout.write = tee(boundStdoutWrite) as typeof process.stdout.write;
process.stderr.write = tee(boundStderrWrite) as typeof process.stderr.write;
return {
outputPath,
async stop() {
if (stopped) {
return;
}
stopped = true;
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
if (outputError) {
throw outputError;
}
await new Promise<void>((resolve, reject) => {
output.once("error", reject);
output.end(resolve);
});
if (outputError) {
throw outputError;
}
},
};
}
@@ -93,6 +93,7 @@
"openclaw/plugin-sdk/qa-channel-protocol": [
"../dist/plugin-sdk/src/plugin-sdk/qa-channel-protocol.d.ts"
],
"openclaw/plugin-sdk/qa-runtime": ["../dist/plugin-sdk/src/plugin-sdk/qa-runtime.d.ts"],
"@openclaw/plugin-sdk/*": ["../dist/plugin-sdk/*.d.ts"]
}
}
+1
View File
@@ -98,6 +98,7 @@
"openclaw/plugin-sdk/qa-channel-protocol": [
"../../dist/plugin-sdk/src/plugin-sdk/qa-channel-protocol.d.ts"
],
"openclaw/plugin-sdk/qa-runtime": ["../../dist/plugin-sdk/src/plugin-sdk/qa-runtime.d.ts"],
"@openclaw/plugin-sdk/*": ["../../dist/plugin-sdk/*.d.ts"],
"@openclaw/anthropic-vertex/api.js": ["./.boundary-stubs/anthropic-vertex-api.d.ts"],
"@openclaw/ollama/api.js": ["./.boundary-stubs/ollama-api.d.ts"],
+7 -2
View File
@@ -35,13 +35,18 @@ function readPositiveNumberEnv(name, fallback, env = process.env) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function readNonEmptyEnv(name) {
const value = process.env[name];
return value === undefined || value.length === 0 ? null : value;
}
function parseArgs(argv) {
const options = {
jsonPath:
process.env.OPENCLAW_STARTUP_MEMORY_JSON_PATH ||
readNonEmptyEnv("OPENCLAW_STARTUP_MEMORY_JSON_PATH") ??
path.join(repoRoot, ".artifacts", "startup-memory", "startup-memory.json"),
summaryPath:
process.env.OPENCLAW_STARTUP_MEMORY_SUMMARY_PATH ||
readNonEmptyEnv("OPENCLAW_STARTUP_MEMORY_SUMMARY_PATH") ??
path.join(repoRoot, ".artifacts", "startup-memory", "summary.md"),
};
for (let index = 0; index < argv.length; index += 1) {
-2
View File
@@ -32,8 +32,6 @@ const intentionallyUnscannedPrefixes = [".agents/", "vendor/"];
const generatedIgnores = [
"extensions/qa-matrix/src/shared/**",
"extensions/qa-matrix/src/report.ts",
"extensions/qa-matrix/src/docker-runtime.ts",
"extensions/qa-matrix/src/cli-paths.ts",
"**/node_modules/**",
"**/dist/**",
@@ -55,6 +55,7 @@ export const EXTENSION_PACKAGE_BOUNDARY_BASE_PATHS = {
"openclaw/plugin-sdk/qa-channel-protocol": [
"../dist/plugin-sdk/src/plugin-sdk/qa-channel-protocol.d.ts",
],
"openclaw/plugin-sdk/qa-runtime": ["../dist/plugin-sdk/src/plugin-sdk/qa-runtime.d.ts"],
"@openclaw/plugin-sdk/*": ["../dist/plugin-sdk/*.d.ts"],
} as const;
+74
View File
@@ -65,4 +65,78 @@ describe("plugin-sdk qa-runtime", () => {
expect(module.isQaRuntimeAvailable()).toBe(false);
});
it("renders shared QA markdown reports with multiline details", async () => {
const module = await import("./qa-runtime.js");
const report = module.renderQaMarkdownReport({
title: "QA Report",
startedAt: new Date("2026-01-01T00:00:00.000Z"),
finishedAt: new Date("2026-01-01T00:00:02.000Z"),
checks: [{ name: "preflight", status: "pass" }],
scenarios: [
{
name: "transport reply",
status: "fail",
details: "line one\nline two",
steps: [{ name: "send", status: "pass", details: "ok" }],
},
],
timeline: ["sent request"],
notes: ["kept artifacts"],
});
expect(report).toContain("# QA Report");
expect(report).toContain("- Duration ms: 2000");
expect(report).toContain("- Passed: 1");
expect(report).toContain("- Failed: 1");
expect(report).toContain("```text\nline one\nline two\n```");
expect(report).toContain("- [x] send");
expect(report).toContain("## Timeline");
});
it("builds shared live-lane artifact errors", async () => {
const module = await import("./qa-runtime.js");
expect(
module.buildQaLiveLaneArtifactsError({
heading: "Matrix QA failed.",
details: ["cleanup: ok"],
artifacts: {
report: "/tmp/report.md",
summary: "/tmp/summary.json",
},
}),
).toBe(
[
"Matrix QA failed.",
"cleanup: ok",
"Artifacts:",
"- report: /tmp/report.md",
"- summary: /tmp/summary.json",
].join("\n"),
);
});
it("shares Docker health parsing across array and jsonl compose output", async () => {
const module = await import("./qa-runtime.js");
const runtime = module.createQaDockerRuntime({ auditContext: "qa-test" });
const dockerPsOutputs = ['[{"Health":"starting"}]', '{"State":"running"}\n'];
const runCommand = vi.fn(async () => ({
stdout: dockerPsOutputs.shift() ?? '{"State":"running"}',
stderr: "",
}));
const sleepImpl = vi.fn(async () => {});
await runtime.waitForDockerServiceHealth(
"homeserver",
"/tmp/docker-compose.yml",
"/repo",
runCommand,
sleepImpl,
);
expect(runCommand).toHaveBeenCalledTimes(2);
expect(sleepImpl).toHaveBeenCalledTimes(1);
});
});
+489
View File
@@ -1,5 +1,13 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import { createServer } from "node:net";
import path from "node:path";
import { formatErrorMessage } from "./error-runtime.js";
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js";
import { resolvePrivateQaBundledPluginsEnv } from "./private-qa-bundled-env.js";
import { runExec } from "./process-runtime.js";
import { fetchWithSsrFGuard } from "./ssrf-runtime.js";
import { normalizeStringEntries } from "./string-coerce-runtime.js";
type QaRuntimeSurface = {
defaultQaRuntimeModelForMode: (
@@ -40,3 +48,484 @@ export function isQaRuntimeAvailable(): boolean {
throw error;
}
}
export type QaReportCheck = {
name: string;
status: "pass" | "fail" | "skip";
details?: string;
};
export type QaReportScenario = {
name: string;
status: "pass" | "fail" | "skip";
details?: string;
steps?: QaReportCheck[];
};
export type QaDockerRunCommand = (
command: string,
args: string[],
cwd: string,
) => Promise<{ stdout: string; stderr: string }>;
export type QaDockerFetchLike = (input: string) => Promise<{ ok: boolean }>;
const DEFAULT_QA_DOCKER_COMMAND_TIMEOUT_MS = 120_000;
function pushQaReportDetailsBlock(lines: string[], label: string, details: string, indent = "") {
if (!details.includes("\n")) {
lines.push(`${indent}- ${label}: ${details}`);
return;
}
lines.push(`${indent}- ${label}:`);
lines.push("", "```text", details, "```");
}
export function renderQaMarkdownReport(params: {
title: string;
startedAt: Date;
finishedAt: Date;
checks?: QaReportCheck[];
scenarios?: QaReportScenario[];
timeline?: string[];
notes?: string[];
}) {
const checks = params.checks ?? [];
const scenarios = params.scenarios ?? [];
const passCount =
checks.filter((check) => check.status === "pass").length +
scenarios.filter((scenario) => scenario.status === "pass").length;
const failCount =
checks.filter((check) => check.status === "fail").length +
scenarios.filter((scenario) => scenario.status === "fail").length;
const lines = [
`# ${params.title}`,
"",
`- Started: ${params.startedAt.toISOString()}`,
`- Finished: ${params.finishedAt.toISOString()}`,
`- Duration ms: ${params.finishedAt.getTime() - params.startedAt.getTime()}`,
`- Passed: ${passCount}`,
`- Failed: ${failCount}`,
"",
];
if (checks.length > 0) {
lines.push("## Checks", "");
for (const check of checks) {
lines.push(`- [${check.status === "pass" ? "x" : " "}] ${check.name}`);
if (check.details) {
pushQaReportDetailsBlock(lines, "Details", check.details, " ");
}
}
}
if (scenarios.length > 0) {
lines.push("", "## Scenarios", "");
for (const scenario of scenarios) {
lines.push(`### ${scenario.name}`);
lines.push("");
lines.push(`- Status: ${scenario.status}`);
if (scenario.details) {
pushQaReportDetailsBlock(lines, "Details", scenario.details);
}
if (scenario.steps?.length) {
lines.push("- Steps:");
for (const step of scenario.steps) {
lines.push(` - [${step.status === "pass" ? "x" : " "}] ${step.name}`);
if (step.details) {
pushQaReportDetailsBlock(lines, "Details", step.details, " ");
}
}
}
lines.push("");
}
}
if (params.timeline && params.timeline.length > 0) {
lines.push("## Timeline", "");
for (const item of params.timeline) {
lines.push(`- ${item}`);
}
}
if (params.notes && params.notes.length > 0) {
lines.push("", "## Notes", "");
for (const note of params.notes) {
lines.push(`- ${note}`);
}
}
lines.push("");
return lines.join("\n");
}
export function appendQaLiveLaneIssue(issues: string[], label: string, error: unknown) {
issues.push(`${label}: ${formatErrorMessage(error)}`);
}
export function buildQaLiveLaneArtifactsError(params: {
heading: string;
artifacts: Record<string, string>;
details?: string[];
}) {
return [
params.heading,
...(params.details ?? []),
"Artifacts:",
...Object.entries(params.artifacts).map(([label, filePath]) => `- ${label}: ${filePath}`),
].join("\n");
}
export function printLiveTransportQaArtifacts(
laneLabel: string,
artifacts: Record<string, string>,
) {
for (const [label, filePath] of Object.entries(artifacts)) {
process.stdout.write(`${laneLabel} ${label}: ${filePath}\n`);
}
}
function describeQaDockerError(error: unknown) {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
return JSON.stringify(error);
}
async function isQaDockerPortFree(port: number) {
return await new Promise<boolean>((resolve) => {
const server = createServer();
server.once("error", () => resolve(false));
server.listen(port, "127.0.0.1", () => {
server.close(() => resolve(true));
});
});
}
async function findFreeQaDockerPort() {
return await new Promise<number>((resolve, reject) => {
const server = createServer();
server.once("error", reject);
server.listen(0, () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("failed to find free port"));
return;
}
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(address.port);
});
});
});
}
export async function resolveQaDockerHostPort(preferredPort: number, pinned: boolean) {
if (pinned || (await isQaDockerPortFree(preferredPort))) {
return preferredPort;
}
return await findFreeQaDockerPort();
}
function trimQaDockerCommandOutput(output: string) {
const trimmed = output.trim();
if (!trimmed) {
return "";
}
const lines = trimmed.split("\n");
return lines.length <= 120 ? trimmed : lines.slice(-120).join("\n");
}
function renderQaDockerCommandFailure(command: string, args: string[], error: unknown) {
const failedProcess = error as Error & { stdout?: string; stderr?: string };
const renderedStdout = trimQaDockerCommandOutput(failedProcess.stdout ?? "");
const renderedStderr = trimQaDockerCommandOutput(failedProcess.stderr ?? "");
return new Error(
[
`Command failed: ${[command, ...args].join(" ")}`,
renderedStderr ? `stderr:\n${renderedStderr}` : "",
renderedStdout ? `stdout:\n${renderedStdout}` : "",
]
.filter(Boolean)
.join("\n\n"),
{ cause: error },
);
}
function normalizeDockerServiceStatus(row?: { Health?: string; State?: string }) {
const health = row?.Health?.trim();
if (health) {
return health;
}
const state = row?.State?.trim();
if (state) {
return state;
}
return "unknown";
}
function parseDockerComposePsRows(stdout: string) {
const trimmed = stdout.trim();
if (!trimmed) {
return [] as Array<{ Health?: string; State?: string }>;
}
try {
const parsed = JSON.parse(trimmed) as
| Array<{ Health?: string; State?: string }>
| { Health?: string; State?: string };
if (Array.isArray(parsed)) {
return parsed;
}
return [parsed];
} catch {
return normalizeStringEntries(trimmed.split("\n")).map(
(line) => JSON.parse(line) as { Health?: string; State?: string },
);
}
}
async function isQaDockerHealthy(url: string, fetchImpl: QaDockerFetchLike) {
try {
const response = await fetchImpl(url);
return response.ok;
} catch {
return false;
}
}
export function createQaDockerRuntime(params: {
auditContext: string;
commandTimeoutMs?: number | null;
}) {
const commandTimeoutMs =
params.commandTimeoutMs === undefined
? DEFAULT_QA_DOCKER_COMMAND_TIMEOUT_MS
: params.commandTimeoutMs;
const fetchHealthUrl = async (url: string): Promise<{ ok: boolean }> => {
const { response, release } = await fetchWithSsrFGuard({
url,
init: {
signal: AbortSignal.timeout(2_000),
},
policy: { allowPrivateNetwork: true },
auditContext: params.auditContext,
});
try {
return { ok: response.ok };
} finally {
await release();
}
};
const execCommand: QaDockerRunCommand = async (command, args, cwd) => {
try {
return await runExec(command, args, {
cwd,
maxBuffer: 10 * 1024 * 1024,
...(commandTimeoutMs === null ? {} : { timeoutMs: commandTimeoutMs }),
});
} catch (error) {
throw renderQaDockerCommandFailure(command, args, error);
}
};
const waitForHealth = async (
url: string,
deps: {
label?: string;
composeFile?: string;
fetchImpl: QaDockerFetchLike;
sleepImpl: (ms: number) => Promise<unknown>;
timeoutMs?: number;
pollMs?: number;
},
) => {
const timeoutMs = deps.timeoutMs ?? 360_000;
const pollMs = deps.pollMs ?? 1_000;
const startMs = Date.now();
const deadline = startMs + timeoutMs;
let lastError: unknown = null;
while (Date.now() < deadline) {
try {
const response = await deps.fetchImpl(url);
if (response.ok) {
return;
}
lastError = new Error(`Health check returned non-OK for ${url}`);
} catch (error) {
lastError = error;
}
await deps.sleepImpl(pollMs);
}
const elapsedSec = Math.round((Date.now() - startMs) / 1000);
const service = deps.label ?? url;
const lines = [
`${service} did not become healthy within ${elapsedSec}s (limit ${Math.round(timeoutMs / 1000)}s).`,
lastError ? `Last error: ${describeQaDockerError(lastError)}` : "",
`Hint: check container logs with \`docker compose -f ${deps.composeFile ?? "<compose-file>"} logs\` and verify the port is not already in use.`,
];
throw new Error(lines.filter(Boolean).join("\n"));
};
const waitForDockerServiceHealth = async (
service: string,
composeFile: string,
repoRoot: string,
runCommand: QaDockerRunCommand,
sleepImpl: (ms: number) => Promise<unknown>,
timeoutMs = 360_000,
pollMs = 1_000,
) => {
const startMs = Date.now();
const deadline = startMs + timeoutMs;
let lastStatus = "unknown";
while (Date.now() < deadline) {
try {
const { stdout } = await runCommand(
"docker",
["compose", "-f", composeFile, "ps", "--format", "json", service],
repoRoot,
);
const row = parseDockerComposePsRows(stdout)[0];
lastStatus = normalizeDockerServiceStatus(row);
if (lastStatus === "healthy" || lastStatus === "running") {
return;
}
} catch (error) {
lastStatus = describeQaDockerError(error);
}
await sleepImpl(pollMs);
}
const elapsedSec = Math.round((Date.now() - startMs) / 1000);
throw new Error(
[
`${service} did not become healthy within ${elapsedSec}s (limit ${Math.round(timeoutMs / 1000)}s).`,
`Last status: ${lastStatus}`,
`Hint: check container logs with \`docker compose -f ${composeFile} logs ${service}\`.`,
].join("\n"),
);
};
const resolveComposeServiceUrl = async (
service: string,
port: number,
composeFile: string,
repoRoot: string,
runCommand: QaDockerRunCommand,
fetchImpl?: QaDockerFetchLike,
) => {
const { stdout: containerStdout } = await runCommand(
"docker",
["compose", "-f", composeFile, "ps", "-q", service],
repoRoot,
);
const containerId = containerStdout.trim();
if (!containerId) {
return null;
}
const { stdout: ipStdout } = await runCommand(
"docker",
[
"inspect",
"--format",
"{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}",
containerId,
],
repoRoot,
);
const ip = ipStdout.trim();
if (!ip) {
return null;
}
const baseUrl = `http://${ip}:${port}/`;
if (!fetchImpl) {
return baseUrl;
}
return (await isQaDockerHealthy(`${baseUrl}healthz`, fetchImpl)) ? baseUrl : null;
};
return {
execCommand,
fetchHealthUrl,
resolveComposeServiceUrl,
resolveHostPort: resolveQaDockerHostPort,
waitForDockerServiceHealth,
waitForHealth,
};
}
type ProcessWriteCallback = (err?: Error | null) => void;
export async function startLiveTransportQaOutputTee(params: {
fileName: string;
outputDir: string;
}) {
await fsp.mkdir(params.outputDir, { recursive: true });
const outputPath = path.join(params.outputDir, params.fileName);
const output = fs.createWriteStream(outputPath, {
encoding: "utf8",
flags: "a",
mode: 0o600,
});
let outputError: Error | null = null;
output.on("error", (error) => {
outputError ??= error;
});
const originalStdoutWrite = Reflect.get(process.stdout, "write");
const originalStderrWrite = Reflect.get(process.stderr, "write");
const boundStdoutWrite = originalStdoutWrite.bind(process.stdout);
const boundStderrWrite = originalStderrWrite.bind(process.stderr);
let stopped = false;
const tee = (originalWrite: typeof process.stdout.write) =>
function writeWithTee(
this: NodeJS.WriteStream,
chunk: string | Uint8Array,
encodingOrCallback?: BufferEncoding | ProcessWriteCallback,
callback?: ProcessWriteCallback,
) {
if (!stopped && !outputError) {
output.write(chunk);
}
return Reflect.apply(originalWrite, this, [chunk, encodingOrCallback, callback]) as boolean;
};
process.stdout.write = tee(boundStdoutWrite) as typeof process.stdout.write;
process.stderr.write = tee(boundStderrWrite) as typeof process.stderr.write;
return {
outputPath,
async stop() {
if (stopped) {
return;
}
stopped = true;
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
if (outputError) {
throw outputError;
}
await new Promise<void>((resolve, reject) => {
output.once("error", reject);
output.end(resolve);
});
if (outputError) {
throw outputError;
}
},
};
}