diff --git a/docs/concepts/qa-e2e-automation.md b/docs/concepts/qa-e2e-automation.md index 16c3ba241dd..8943e47653d 100644 --- a/docs/concepts/qa-e2e-automation.md +++ b/docs/concepts/qa-e2e-automation.md @@ -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=` to a repo-local log file. For a transport-real Telegram smoke lane, run: diff --git a/docs/help/testing.md b/docs/help/testing.md index 66a44eb4419..2242c30c79f 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -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. diff --git a/extensions/qa-matrix/src/cli.runtime.test.ts b/extensions/qa-matrix/src/cli.runtime.test.ts index 81a91e3ee9e..24c04c38d20 100644 --- a/extensions/qa-matrix/src/cli.runtime.test.ts +++ b/extensions/qa-matrix/src/cli.runtime.test.ts @@ -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"); }); }); diff --git a/extensions/qa-matrix/src/cli.runtime.ts b/extensions/qa-matrix/src/cli.runtime.ts index 485f0ff023d..a79aa9764b2 100644 --- a/extensions/qa-matrix/src/cli.runtime.ts +++ b/extensions/qa-matrix/src/cli.runtime.ts @@ -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; + }; + 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; + } } diff --git a/extensions/qa-matrix/src/cli.ts b/extensions/qa-matrix/src/cli.ts index 6a81d8a5502..f95776850f7 100644 --- a/extensions/qa-matrix/src/cli.ts +++ b/extensions/qa-matrix/src/cli.ts @@ -12,9 +12,30 @@ const loadMatrixQaCliRuntime = createLazyCliRuntimeLoader( () => import("./cli.runtime.js"), ); +async function flushProcessStream(stream: NodeJS.WriteStream) { + if (stream.destroyed || !stream.writable) { + return; + } + await new Promise((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 = diff --git a/extensions/qa-matrix/src/shared/live-transport-cli.runtime.test.ts b/extensions/qa-matrix/src/shared/live-transport-cli.runtime.test.ts new file mode 100644 index 00000000000..71836c696b6 --- /dev/null +++ b/extensions/qa-matrix/src/shared/live-transport-cli.runtime.test.ts @@ -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; + } + }); +}); diff --git a/extensions/qa-matrix/src/shared/live-transport-cli.runtime.ts b/extensions/qa-matrix/src/shared/live-transport-cli.runtime.ts index b840b2d6712..ffea62128d6 100644 --- a/extensions/qa-matrix/src/shared/live-transport-cli.runtime.ts +++ b/extensions/qa-matrix/src/shared/live-transport-cli.runtime.ts @@ -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((resolve, reject) => { + output.once("error", reject); + output.end(resolve); + }); + if (outputError) { + throw outputError; + } + }, + }; +} diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index f956f308592..99503b8c4c3 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -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) { diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 4a10a94f25c..e8b37288ea6 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -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; + 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[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[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 }> = []; + 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[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, {