mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:30:42 +00:00
QA Matrix: capture full runner output
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user