QA Matrix: capture full runner output

This commit is contained in:
Gustavo Madeira Santana
2026-04-16 16:14:27 -04:00
parent 988447ca24
commit 56a9fd4b34
9 changed files with 622 additions and 61 deletions

View File

@@ -62,7 +62,10 @@ That lane provisions a disposable Tuwunel homeserver in Docker, registers
temporary driver, SUT, and observer users, creates one private room, then runs
the real Matrix plugin inside a QA gateway child. The live transport lane keeps
the child config scoped to the transport under test, so Matrix runs without
`qa-channel` in the child config.
`qa-channel` in the child config. It writes the structured report artifacts and
a combined stdout/stderr log into the selected Matrix QA output directory. To
capture the outer `scripts/run-node.mjs` build/launcher output too, set
`OPENCLAW_RUN_NODE_OUTPUT_LOG=<path>` to a repo-local log file.
For a transport-real Telegram smoke lane, run:

View File

@@ -74,7 +74,7 @@ These commands sit beside the main test suites when you need QA-lab realism:
- Provisions three temporary Matrix users (`driver`, `sut`, `observer`) plus one private room, then starts a QA gateway child with the real Matrix plugin as the SUT transport.
- Uses the pinned stable Tuwunel image `ghcr.io/matrix-construct/tuwunel:v1.5.1` by default. Override with `OPENCLAW_QA_MATRIX_TUWUNEL_IMAGE` when you need to test a different image.
- Matrix does not expose shared credential-source flags because the lane provisions disposable users locally.
- Writes a Matrix QA report, summary, and observed-events artifact under `.artifacts/qa-e2e/...`.
- Writes a Matrix QA report, summary, observed-events artifact, and combined stdout/stderr output log under `.artifacts/qa-e2e/...`.
- `pnpm openclaw qa telegram`
- Runs the Telegram live QA lane against a real private group using the driver and SUT bot tokens from env.
- Requires `OPENCLAW_QA_TELEGRAM_GROUP_ID`, `OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN`, and `OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN`. The group id must be the numeric Telegram chat id.

View File

@@ -1,14 +1,37 @@
import { describe, expect, it, vi } from "vitest";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const runMatrixQaLive = vi.hoisted(() => vi.fn());
const closeGlobalDispatcher = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("./runners/contract/runtime.js", () => ({
runMatrixQaLive,
}));
vi.mock("undici", () => ({
getGlobalDispatcher: () => ({
close: closeGlobalDispatcher,
}),
}));
import { runQaMatrixCommand } from "./cli.runtime.js";
const tmpDirs: string[] = [];
describe("matrix qa cli runtime", () => {
const originalRunNodeOutputLog = process.env.OPENCLAW_RUN_NODE_OUTPUT_LOG;
afterEach(async () => {
vi.clearAllMocks();
if (originalRunNodeOutputLog === undefined) {
delete process.env.OPENCLAW_RUN_NODE_OUTPUT_LOG;
} else {
process.env.OPENCLAW_RUN_NODE_OUTPUT_LOG = originalRunNodeOutputLog;
}
await Promise.all(tmpDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
it("rejects non-env credential sources for the disposable Matrix lane", async () => {
await expect(
runQaMatrixCommand({
@@ -18,26 +41,95 @@ describe("matrix qa cli runtime", () => {
});
it("passes through default env credential source options", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-cli-"));
tmpDirs.push(repoRoot);
runMatrixQaLive.mockResolvedValue({
reportPath: "/tmp/matrix-report.md",
summaryPath: "/tmp/matrix-summary.json",
observedEventsPath: "/tmp/matrix-events.json",
});
const originalStdoutWrite = process.stdout.write;
process.stdout.write = (() => true) as typeof process.stdout.write;
await runQaMatrixCommand({
repoRoot: "/tmp/openclaw",
outputDir: ".artifacts/qa-e2e/matrix",
providerMode: "mock-openai",
credentialSource: "env",
});
try {
await runQaMatrixCommand({
repoRoot,
outputDir: ".artifacts/qa-e2e/matrix",
providerMode: "mock-openai",
credentialSource: "env",
});
} finally {
process.stdout.write = originalStdoutWrite;
}
expect(runMatrixQaLive).toHaveBeenCalledWith(
expect.objectContaining({
repoRoot: "/tmp/openclaw",
outputDir: "/tmp/openclaw/.artifacts/qa-e2e/matrix",
repoRoot,
outputDir: path.join(repoRoot, ".artifacts/qa-e2e/matrix"),
providerMode: "mock-openai",
credentialSource: "env",
}),
);
expect(closeGlobalDispatcher).toHaveBeenCalledTimes(1);
});
it("reuses a run-node output log instead of installing a nested tee", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-cli-"));
tmpDirs.push(repoRoot);
const outputPath = path.join(repoRoot, "run-node-output.log");
process.env.OPENCLAW_RUN_NODE_OUTPUT_LOG = outputPath;
runMatrixQaLive.mockResolvedValue({
reportPath: "/tmp/matrix-report.md",
summaryPath: "/tmp/matrix-summary.json",
observedEventsPath: "/tmp/matrix-events.json",
});
const originalStdoutWrite = process.stdout.write;
process.stdout.write = vi.fn(() => true) as unknown as typeof process.stdout.write;
try {
await runQaMatrixCommand({
repoRoot,
outputDir: ".artifacts/qa-e2e/matrix",
providerMode: "mock-openai",
credentialSource: "env",
});
} finally {
process.stdout.write = originalStdoutWrite;
}
expect(runMatrixQaLive).toHaveBeenCalledOnce();
await expect(readFile(outputPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
});
it("preserves the Matrix QA failure when output log cleanup also fails", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-cli-"));
tmpDirs.push(repoRoot);
const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "matrix");
await mkdir(path.join(outputDir, "matrix-qa-output.log"), { recursive: true });
runMatrixQaLive.mockRejectedValue(new Error("scenario failed"));
const stderrChunks: string[] = [];
const originalStdoutWrite = process.stdout.write;
const originalStderrWrite = process.stderr.write;
process.stdout.write = (() => true) as typeof process.stdout.write;
process.stderr.write = ((chunk: string | Buffer) => {
stderrChunks.push(String(chunk));
return true;
}) as typeof process.stderr.write;
try {
await expect(
runQaMatrixCommand({
repoRoot,
outputDir: ".artifacts/qa-e2e/matrix",
providerMode: "mock-openai",
credentialSource: "env",
}),
).rejects.toThrow("scenario failed");
} finally {
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
}
expect(stderrChunks.join("")).toContain("Matrix QA output log error");
});
});

View File

@@ -3,8 +3,50 @@ import type { LiveTransportQaCommandOptions } from "./shared/live-transport-cli.
import {
printLiveTransportQaArtifacts,
resolveLiveTransportQaRunOptions,
startLiveTransportQaOutputTee,
} from "./shared/live-transport-cli.runtime.js";
const RUN_NODE_OUTPUT_LOG_ENV = "OPENCLAW_RUN_NODE_OUTPUT_LOG";
async function closeMatrixQaCommandFetchHandles() {
try {
const { getGlobalDispatcher } = await import("undici");
const dispatcher = getGlobalDispatcher() as {
close?: () => Promise<void> | void;
};
await dispatcher.close?.();
} catch {
// Best-effort cleanup for short-lived QA commands. The command result and
// artifacts are already written; stale fetch keep-alive handles should not
// turn a green run into a failure.
}
}
function formatMatrixQaOutputTeeError(error: unknown) {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
return "unknown error";
}
async function createMatrixQaCommandOutputTee(outputDir: string) {
const inheritedOutputPath = process.env[RUN_NODE_OUTPUT_LOG_ENV]?.trim();
if (inheritedOutputPath) {
return {
outputPath: inheritedOutputPath,
async stop() {},
};
}
return await startLiveTransportQaOutputTee({
fileName: "matrix-qa-output.log",
outputDir,
});
}
export async function runQaMatrixCommand(opts: LiveTransportQaCommandOptions) {
const runOptions = resolveLiveTransportQaRunOptions(opts);
const credentialSource = runOptions.credentialSource?.toLowerCase();
@@ -14,10 +56,36 @@ export async function runQaMatrixCommand(opts: LiveTransportQaCommandOptions) {
);
}
const result = await runMatrixQaLive(runOptions);
printLiveTransportQaArtifacts("Matrix QA", {
report: result.reportPath,
summary: result.summaryPath,
"observed events": result.observedEventsPath,
});
const outputTee = await createMatrixQaCommandOutputTee(runOptions.outputDir);
let primaryError: unknown;
let outputTeeError: unknown;
try {
process.stdout.write(`Matrix QA output: ${outputTee.outputPath}\n`);
const result = await runMatrixQaLive(runOptions);
printLiveTransportQaArtifacts("Matrix QA", {
report: result.reportPath,
summary: result.summaryPath,
"observed events": result.observedEventsPath,
});
} catch (error) {
primaryError = error;
} finally {
try {
await outputTee.stop();
} catch (error) {
outputTeeError = error;
}
await closeMatrixQaCommandFetchHandles();
}
if (primaryError) {
if (outputTeeError) {
process.stderr.write(
`Matrix QA output log error: ${formatMatrixQaOutputTeeError(outputTeeError)}\n`,
);
}
throw primaryError;
}
if (outputTeeError) {
throw outputTeeError;
}
}

View File

@@ -12,9 +12,30 @@ const loadMatrixQaCliRuntime = createLazyCliRuntimeLoader<MatrixQaCliRuntime>(
() => import("./cli.runtime.js"),
);
async function flushProcessStream(stream: NodeJS.WriteStream) {
if (stream.destroyed || !stream.writable) {
return;
}
await new Promise<void>((resolve) => {
try {
stream.write("", () => resolve());
} catch {
resolve();
}
});
}
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);
}
}
export const matrixQaCliRegistration: LiveTransportQaCliRegistration =

View File

@@ -0,0 +1,68 @@
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { startLiveTransportQaOutputTee } from "./live-transport-cli.runtime.js";
const tmpDirs: string[] = [];
describe("live transport CLI runtime", () => {
afterEach(async () => {
await Promise.all(tmpDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
it("tees stdout and stderr into an output artifact", async () => {
const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-output-"));
tmpDirs.push(outputDir);
const originalStdoutWrite = process.stdout.write;
const originalStderrWrite = process.stderr.write;
process.stdout.write = (() => true) as typeof process.stdout.write;
process.stderr.write = (() => true) as typeof process.stderr.write;
const tee = await startLiveTransportQaOutputTee({
fileName: "matrix-qa-output.log",
outputDir,
});
try {
process.stdout.write("stdout marker\n");
process.stderr.write("stderr marker\n");
await tee.stop();
} finally {
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
}
expect(process.stdout.write).toBe(originalStdoutWrite);
expect(process.stderr.write).toBe(originalStderrWrite);
await expect(readFile(tee.outputPath, "utf8")).resolves.toContain("stdout marker\n");
await expect(readFile(tee.outputPath, "utf8")).resolves.toContain("stderr marker\n");
});
it("surfaces output artifact stream errors after restoring process writes", async () => {
const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-output-"));
tmpDirs.push(outputDir);
await rm(path.join(outputDir, "matrix-qa-output.log"), { recursive: true, force: true });
await mkdir(path.join(outputDir, "matrix-qa-output.log"), { recursive: true });
const originalStdoutWrite = process.stdout.write;
const originalStderrWrite = process.stderr.write;
const mutedStdoutWrite = (() => true) as typeof process.stdout.write;
const mutedStderrWrite = (() => true) as typeof process.stderr.write;
process.stdout.write = mutedStdoutWrite;
process.stderr.write = mutedStderrWrite;
try {
const tee = await startLiveTransportQaOutputTee({
fileName: "matrix-qa-output.log",
outputDir,
});
process.stdout.write("stdout marker\n");
await expect(tee.stop()).rejects.toMatchObject({ code: "EISDIR" });
expect(process.stdout.write).toBe(mutedStdoutWrite);
expect(process.stderr.write).toBe(mutedStderrWrite);
} finally {
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
}
});
});

View File

@@ -1,3 +1,5 @@
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";
@@ -7,15 +9,17 @@ import type { LiveTransportQaCommandOptions } from "./live-transport-cli.js";
export function resolveLiveTransportQaRunOptions(
opts: LiveTransportQaCommandOptions,
): LiveTransportQaCommandOptions & {
outputDir: string;
repoRoot: string;
providerMode: QaProviderMode;
} {
const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
const outputDir =
resolveRepoRelativeOutputDir(repoRoot, opts.outputDir) ??
path.join(repoRoot, ".artifacts", "qa-e2e", `matrix-${Date.now().toString(36)}`);
return {
repoRoot: path.resolve(opts.repoRoot ?? process.cwd()),
outputDir: resolveRepoRelativeOutputDir(
path.resolve(opts.repoRoot ?? process.cwd()),
opts.outputDir,
),
repoRoot,
outputDir,
providerMode:
opts.providerMode === undefined
? "live-frontier"
@@ -38,3 +42,65 @@ export function printLiveTransportQaArtifacts(
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;
}
},
};
}

View File

@@ -283,11 +283,60 @@ const isSignalKey = (signal) => Object.hasOwn(SIGNAL_EXIT_CODES, signal);
const getSignalExitCode = (signal) => (isSignalKey(signal) ? SIGNAL_EXIT_CODES[signal] : 1);
const RUN_NODE_OUTPUT_LOG_ENV = "OPENCLAW_RUN_NODE_OUTPUT_LOG";
const resolveRunNodeOutputLogPath = (deps) => {
const outputLog = deps.env[RUN_NODE_OUTPUT_LOG_ENV]?.trim();
if (!outputLog) {
return null;
}
return path.resolve(deps.cwd, outputLog);
};
const createRunNodeOutputTee = (deps) => {
const outputLogPath = resolveRunNodeOutputLogPath(deps);
if (!outputLogPath) {
return null;
}
deps.fs.mkdirSync(path.dirname(outputLogPath), { recursive: true });
const stream = deps.fs.createWriteStream(outputLogPath, {
flags: "a",
mode: 0o600,
});
let streamError = null;
stream.on("error", (error) => {
streamError = error;
});
deps.env[RUN_NODE_OUTPUT_LOG_ENV] = outputLogPath;
return {
outputLogPath,
write(chunk) {
if (!streamError) {
stream.write(chunk);
}
},
async close() {
if (streamError) {
throw streamError;
}
await new Promise((resolve, reject) => {
stream.once("error", reject);
stream.end(resolve);
});
if (streamError) {
throw streamError;
}
},
};
};
const logRunner = (message, deps) => {
if (deps.env.OPENCLAW_RUNNER_LOG === "0") {
return;
}
deps.stderr.write(`[openclaw] ${message}\n`);
const line = `[openclaw] ${message}\n`;
deps.stderr.write(line);
deps.outputTee?.write(line);
};
const waitForSpawnedProcess = async (childProcess, deps) => {
@@ -337,22 +386,60 @@ const waitForSpawnedProcess = async (childProcess, deps) => {
}
};
const runOpenClaw = async (deps) => {
const nodeProcess = deps.spawn(deps.execPath, ["openclaw.mjs", ...deps.args], {
cwd: deps.cwd,
env: deps.env,
stdio: "inherit",
});
const res = await waitForSpawnedProcess(nodeProcess, deps);
const getInterruptedSpawnExitCode = (res) => {
if (res.exitSignal) {
return getSignalExitCode(res.exitSignal);
}
if (res.forwardedSignal) {
return getSignalExitCode(res.forwardedSignal);
}
return null;
};
const runOpenClaw = async (deps) => {
const nodeProcess = deps.spawn(deps.execPath, ["openclaw.mjs", ...deps.args], {
cwd: deps.cwd,
env: deps.env,
stdio: deps.outputTee ? ["inherit", "pipe", "pipe"] : "inherit",
});
pipeSpawnedOutput(nodeProcess, deps);
const res = await waitForSpawnedProcess(nodeProcess, deps);
const interruptedExitCode = getInterruptedSpawnExitCode(res);
if (interruptedExitCode !== null) {
return interruptedExitCode;
}
return res.exitCode ?? 1;
};
const pipeSpawnedOutput = (childProcess, deps) => {
if (!deps.outputTee) {
return;
}
childProcess.stdout?.on("data", (chunk) => {
deps.stdout.write(chunk);
deps.outputTee.write(chunk);
});
childProcess.stderr?.on("data", (chunk) => {
deps.stderr.write(chunk);
deps.outputTee.write(chunk);
});
};
const closeRunNodeOutputTee = async (deps, exitCode) => {
if (!deps.outputTee) {
return exitCode;
}
try {
await deps.outputTee.close();
} catch (error) {
deps.stderr.write(
`[openclaw] Failed to write output log: ${error?.message ?? "unknown error"}\n`,
);
return exitCode === 0 ? 1 : exitCode;
}
return exitCode;
};
const syncRuntimeArtifacts = (deps) => {
try {
deps.runRuntimePostBuild({ cwd: deps.cwd });
@@ -387,6 +474,7 @@ export async function runNodeMain(params = {}) {
spawnSync: params.spawnSync ?? spawnSync,
fs: params.fs ?? fs,
stderr: params.stderr ?? process.stderr,
stdout: params.stdout ?? process.stdout,
process: params.process ?? process,
execPath: params.execPath ?? process.execPath,
cwd: params.cwd ?? process.cwd(),
@@ -408,42 +496,50 @@ export async function runNodeMain(params = {}) {
deps.env.OPENCLAW_BUILD_PRIVATE_QA = "1";
deps.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1";
}
deps.outputTee = createRunNodeOutputTee(deps);
const buildRequirement = resolveBuildRequirement(deps);
if (!buildRequirement.shouldBuild) {
if (!shouldSkipCleanWatchRuntimeSync(deps) && !syncRuntimeArtifacts(deps)) {
return 1;
try {
let exitCode = 1;
const buildRequirement = resolveBuildRequirement(deps);
if (!buildRequirement.shouldBuild) {
if (!shouldSkipCleanWatchRuntimeSync(deps) && !syncRuntimeArtifacts(deps)) {
return await closeRunNodeOutputTee(deps, 1);
}
exitCode = await runOpenClaw(deps);
return await closeRunNodeOutputTee(deps, exitCode);
}
return await runOpenClaw(deps);
}
logRunner(
`Building TypeScript (dist is stale: ${buildRequirement.reason} - ${formatBuildReason(buildRequirement.reason)}).`,
deps,
);
const buildCmd = deps.execPath;
const buildArgs = compilerArgs;
const build = deps.spawn(buildCmd, buildArgs, {
cwd: deps.cwd,
env: deps.env,
stdio: "inherit",
});
logRunner(
`Building TypeScript (dist is stale: ${buildRequirement.reason} - ${formatBuildReason(buildRequirement.reason)}).`,
deps,
);
const buildCmd = deps.execPath;
const buildArgs = compilerArgs;
const build = deps.spawn(buildCmd, buildArgs, {
cwd: deps.cwd,
env: deps.env,
stdio: deps.outputTee ? ["inherit", "pipe", "pipe"] : "inherit",
});
pipeSpawnedOutput(build, deps);
const buildRes = await waitForSpawnedProcess(build, deps);
if (buildRes.exitSignal) {
return getSignalExitCode(buildRes.exitSignal);
const buildRes = await waitForSpawnedProcess(build, deps);
const interruptedExitCode = getInterruptedSpawnExitCode(buildRes);
if (interruptedExitCode !== null) {
return await closeRunNodeOutputTee(deps, interruptedExitCode);
}
if (buildRes.exitCode !== 0 && buildRes.exitCode !== null) {
return await closeRunNodeOutputTee(deps, buildRes.exitCode);
}
if (!syncRuntimeArtifacts(deps)) {
return await closeRunNodeOutputTee(deps, 1);
}
writeBuildStamp(deps);
exitCode = await runOpenClaw(deps);
return await closeRunNodeOutputTee(deps, exitCode);
} catch (error) {
await closeRunNodeOutputTee(deps, 1);
throw error;
}
if (buildRes.forwardedSignal) {
return getSignalExitCode(buildRes.forwardedSignal);
}
if (buildRes.exitCode !== 0 && buildRes.exitCode !== null) {
return buildRes.exitCode;
}
if (!syncRuntimeArtifacts(deps)) {
return 1;
}
writeBuildStamp(deps);
return await runOpenClaw(deps);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {

View File

@@ -50,6 +50,34 @@ function createExitedProcess(code: number | null, signal: string | null = null)
};
}
function createPipedExitedProcess(params: {
code?: number | null;
signal?: string | null;
stderr?: string;
stdout?: string;
}) {
const stdout = new EventEmitter();
const stderr = new EventEmitter();
return {
stdout,
stderr,
on: (event: string, cb: (code: number | null, signal: string | null) => void) => {
if (event === "exit") {
queueMicrotask(() => {
if (params.stdout) {
stdout.emit("data", Buffer.from(params.stdout));
}
if (params.stderr) {
stderr.emit("data", Buffer.from(params.stderr));
}
cb(params.code ?? 0, params.signal ?? null);
});
}
return undefined;
},
};
}
function createFakeProcess() {
return Object.assign(new EventEmitter(), {
pid: 4242,
@@ -322,6 +350,125 @@ describe("run-node script", () => {
});
});
it("tees launcher output into the requested generic output log", async () => {
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
await setupTrackedProject(tmp);
const outputPath = path.join(tmp, ".artifacts", "qa-e2e", "matrix", "output.log");
const spawnCalls: Array<{
args: string[];
env: Record<string, string | undefined>;
stdio: unknown;
}> = [];
const spawn = (_cmd: string, args: string[], options?: unknown) => {
const opts = options as { env?: NodeJS.ProcessEnv; stdio?: unknown } | undefined;
spawnCalls.push({
args,
env: { ...opts?.env },
stdio: opts?.stdio,
});
return createPipedExitedProcess({
stdout: args[0] === "openclaw.mjs" ? "child stdout\n" : "",
stderr: args[0] === "openclaw.mjs" ? "child stderr\n" : "",
});
};
const mutedStream = {
write: () => true,
} as unknown as NodeJS.WriteStream;
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_FORCE_BUILD: "1",
OPENCLAW_RUNNER_LOG: "1",
OPENCLAW_RUN_NODE_OUTPUT_LOG: outputPath,
},
spawn,
stderr: mutedStream,
stdout: mutedStream,
execPath: process.execPath,
platform: process.platform,
} as Parameters<typeof runNodeMain>[0] & { stdout: NodeJS.WriteStream });
expect(exitCode).toBe(0);
await expect(fs.readFile(outputPath, "utf-8")).resolves.toContain("child stdout\n");
await expect(fs.readFile(outputPath, "utf-8")).resolves.toContain("child stderr\n");
await expect(fs.readFile(outputPath, "utf-8")).resolves.toContain("[openclaw]");
expect(spawnCalls.at(-1)?.args).toEqual(["openclaw.mjs", "status"]);
expect(spawnCalls.at(-1)?.env.OPENCLAW_RUN_NODE_OUTPUT_LOG).toBe(outputPath);
expect(spawnCalls.at(-1)?.stdio).toEqual(["inherit", "pipe", "pipe"]);
});
});
it("surfaces generic output log stream errors", async () => {
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
await setupTrackedProject(tmp);
const outputPath = path.join(tmp, ".artifacts", "qa-e2e", "matrix", "output.log");
await fs.mkdir(outputPath, { recursive: true });
const spawn = () => createPipedExitedProcess({ stdout: "child stdout\n" });
const stderrChunks: string[] = [];
const mutedStream = {
write: (chunk: string | Buffer) => {
stderrChunks.push(String(chunk));
return true;
},
} as unknown as NodeJS.WriteStream;
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
OPENCLAW_RUN_NODE_OUTPUT_LOG: outputPath,
},
spawn,
stderr: mutedStream,
stdout: mutedStream,
execPath: process.execPath,
platform: process.platform,
} as Parameters<typeof runNodeMain>[0] & { stdout: NodeJS.WriteStream });
expect(exitCode).toBe(1);
expect(stderrChunks.join("")).toContain("Failed to write output log");
});
});
it("does not mutate Matrix QA args when no generic output log is requested", async () => {
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
await setupTrackedProject(tmp);
const spawnCalls: Array<{ args: string[]; env: Record<string, string | undefined> }> = [];
const spawn = (_cmd: string, args: string[], options?: unknown) => {
const opts = options as { env?: NodeJS.ProcessEnv } | undefined;
spawnCalls.push({ args, env: { ...opts?.env } });
return createPipedExitedProcess({});
};
const mutedStream = {
write: () => true,
} as unknown as NodeJS.WriteStream;
const exitCode = await runNodeMain({
cwd: tmp,
args: ["qa", "matrix"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
stderr: mutedStream,
stdout: mutedStream,
execPath: process.execPath,
platform: process.platform,
} as Parameters<typeof runNodeMain>[0] & { stdout: NodeJS.WriteStream });
expect(exitCode).toBe(0);
const childArgs = spawnCalls.at(-1)?.args ?? [];
expect(childArgs).toEqual(["openclaw.mjs", "qa", "matrix"]);
expect(spawnCalls.at(-1)?.env.OPENCLAW_RUN_NODE_OUTPUT_LOG).toBeUndefined();
});
});
it("skips rebuilding when dist is current and the source tree is clean", async () => {
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
await setupTrackedProject(tmp, {