From 8c74fd4e2359d3c7f90e8d5555f5c26bdedf1b0f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 4 Jun 2026 05:01:06 +0200 Subject: [PATCH] fix(e2e): keep parallels json output parseable --- CHANGELOG.md | 1 + scripts/e2e/parallels/host-command.ts | 46 +++++++++++++--------- scripts/e2e/parallels/linux-smoke.ts | 4 +- scripts/e2e/parallels/macos-smoke.ts | 6 ++- scripts/e2e/parallels/npm-update-smoke.ts | 6 ++- scripts/e2e/parallels/windows-smoke.ts | 6 ++- test/scripts/parallels-smoke-model.test.ts | 21 ++++++++++ 7 files changed, 67 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ea4234db188..98f0ef00b892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Release/CI/E2E: fail secret-provider proof startup immediately when the gateway exits by signal instead of waiting for the readiness timeout. - Release/CI/E2E: report plugin gateway gauntlet command-log write failures as failed rows instead of crashing the harness from child-process callbacks. - Release/CI/E2E: abort stalled Kitchen Sink RPC readiness probes as soon as the gateway exits so proof failures return promptly. +- Release/CI/E2E: keep Parallels JSON-mode progress on stderr so macOS, Linux, Windows, and aggregate update smoke summaries stay parseable on stdout. ## 2026.6.1 diff --git a/scripts/e2e/parallels/host-command.ts b/scripts/e2e/parallels/host-command.ts index 6df53b755e8f..aa3ddc3f78c6 100644 --- a/scripts/e2e/parallels/host-command.ts +++ b/scripts/e2e/parallels/host-command.ts @@ -15,6 +15,7 @@ const HOST_COMMAND_WRAPPER_BACKSTOP_MS = 5_000; const HOST_COMMAND_CHILD_PID_PREFIX = "__OPENCLAW_HOST_COMMAND_CHILD_PID__"; const HOST_COMMAND_SPAWN_ERROR_PREFIX = "__OPENCLAW_HOST_COMMAND_SPAWN_ERROR__"; const HOST_COMMAND_TIMEOUT_PREFIX = "__OPENCLAW_HOST_COMMAND_TIMEOUT__"; +let progressStderrDepth = 0; type HostCommandInvocation = { args: string[]; @@ -42,13 +43,23 @@ function hostInvocationFromRunner(runner: HostCommandInvocation): HostCommandInv } export function say(message: string): void { - process.stdout.write(`==> ${message}\n`); + const stream = progressStderrDepth > 0 ? process.stderr : process.stdout; + stream.write(`==> ${message}\n`); } export function warn(message: string): void { process.stderr.write(`warn: ${message}\n`); } +export async function withProgressOnStderr(fn: () => Promise): Promise { + progressStderrDepth++; + try { + return await fn(); + } finally { + progressStderrDepth--; + } +} + export function die(message: string): never { process.stderr.write(`error: ${message}\n`); process.exit(1); @@ -68,9 +79,7 @@ function signalHostCommandProcess(pid: number | undefined, signal: NodeJS.Signal const code = (error as NodeJS.ErrnoException).code; if (code !== "ESRCH") { warn( - `failed to send ${signal} to timed host command process ${pid}: ${ - code ?? String(error) - }`, + `failed to send ${signal} to timed host command process ${pid}: ${code ?? String(error)}`, ); } } @@ -279,21 +288,20 @@ export function run(command: string, args: string[], options: RunOptions = {}): const env = { ...process.env, ...options.env }; const invocation = resolveHostCommandInvocation(command, args, { env }); const usesPosixTimedWrapper = process.platform !== "win32" && options.timeoutMs !== undefined; - const result = - usesPosixTimedWrapper - ? runPosixTimedCommandSync(invocation, env, options) - : spawnSync(invocation.command, invocation.args, { - cwd: options.cwd ?? repoRoot, - encoding: "utf8", - env: invocation.env ?? env, - input: options.input, - killSignal: "SIGKILL", - maxBuffer: HOST_COMMAND_MAX_BUFFER_BYTES, - stdio: options.quiet ? ["pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe"], - shell: invocation.shell, - timeout: options.timeoutMs, - windowsVerbatimArguments: invocation.windowsVerbatimArguments, - }); + const result = usesPosixTimedWrapper + ? runPosixTimedCommandSync(invocation, env, options) + : spawnSync(invocation.command, invocation.args, { + cwd: options.cwd ?? repoRoot, + encoding: "utf8", + env: invocation.env ?? env, + input: options.input, + killSignal: "SIGKILL", + maxBuffer: HOST_COMMAND_MAX_BUFFER_BYTES, + stdio: options.quiet ? ["pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe"], + shell: invocation.shell, + timeout: options.timeoutMs, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }); let wrapperTimedOut = false; if (usesPosixTimedWrapper) { diff --git a/scripts/e2e/parallels/linux-smoke.ts b/scripts/e2e/parallels/linux-smoke.ts index 5f182e84acb4..e32daa39da76 100755 --- a/scripts/e2e/parallels/linux-smoke.ts +++ b/scripts/e2e/parallels/linux-smoke.ts @@ -22,6 +22,7 @@ import { say, shellQuote, warn, + withProgressOnStderr, writeJson, writeSummaryMarkdown, type Mode, @@ -828,5 +829,6 @@ fi`, if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { const options = parseArgs(process.argv.slice(2)); await mkdir(repoRoot, { recursive: true }); - await new LinuxSmoke(options).run(); + const runSmoke = () => new LinuxSmoke(options).run(); + await (options.json ? withProgressOnStderr(runSmoke) : runSmoke()); } diff --git a/scripts/e2e/parallels/macos-smoke.ts b/scripts/e2e/parallels/macos-smoke.ts index 26385040982f..80eb890266b9 100755 --- a/scripts/e2e/parallels/macos-smoke.ts +++ b/scripts/e2e/parallels/macos-smoke.ts @@ -27,6 +27,7 @@ import { shellQuote, startHostServer, warn, + withProgressOnStderr, writeJson, writeSummaryMarkdown, type HostServer, @@ -1213,7 +1214,10 @@ fi`, } if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { - await new MacosSmoke(parseArgs(process.argv.slice(2))).run().catch((error: unknown) => { + const options = parseArgs(process.argv.slice(2)); + const runSmoke = () => new MacosSmoke(options).run(); + const runPromise = options.json ? withProgressOnStderr(runSmoke) : runSmoke(); + await runPromise.catch((error: unknown) => { die(error instanceof Error ? error.message : String(error)); }); } diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts index 53a5a9d1f46d..bea64da68eb0 100755 --- a/scripts/e2e/parallels/npm-update-smoke.ts +++ b/scripts/e2e/parallels/npm-update-smoke.ts @@ -22,6 +22,7 @@ import { say, shellQuote, startHostServer, + withProgressOnStderr, writeSummaryMarkdown, writeJson, type HostServer, @@ -1307,7 +1308,10 @@ export class NpmUpdateSmoke { } if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { - await new NpmUpdateSmoke(parseArgs(process.argv.slice(2))).run().catch((error: unknown) => { + const options = parseArgs(process.argv.slice(2)); + const runSmoke = () => new NpmUpdateSmoke(options).run(); + const runPromise = options.json ? withProgressOnStderr(runSmoke) : runSmoke(); + await runPromise.catch((error: unknown) => { die(error instanceof Error ? error.message : String(error)); }); } diff --git a/scripts/e2e/parallels/windows-smoke.ts b/scripts/e2e/parallels/windows-smoke.ts index 28822640ae95..fb8b92572f6d 100755 --- a/scripts/e2e/parallels/windows-smoke.ts +++ b/scripts/e2e/parallels/windows-smoke.ts @@ -16,6 +16,7 @@ import { run, say, warn, + withProgressOnStderr, writeSummaryMarkdown, writeJson, type Mode, @@ -821,7 +822,10 @@ if (-not $agentOk) { throw 'openclaw agent finished without OK response' }`, } if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { - await new WindowsSmoke(parseArgs(process.argv.slice(2))).run().catch((error: unknown) => { + const options = parseArgs(process.argv.slice(2)); + const runSmoke = () => new WindowsSmoke(options).run(); + const runPromise = options.json ? withProgressOnStderr(runSmoke) : runSmoke(); + await runPromise.catch((error: unknown) => { die(error instanceof Error ? error.message : String(error)); }); } diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index bc67ffedd273..eda5c90ba745 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -26,6 +26,7 @@ import { resolveWindowsProviderAuth, run, shellQuote, + withProgressOnStderr, } from "../../scripts/e2e/parallels/common.ts"; import { resolveHostCommandInvocation } from "../../scripts/e2e/parallels/host-command.ts"; import { testing as hostServerTesting } from "../../scripts/e2e/parallels/host-server.ts"; @@ -327,6 +328,26 @@ describe("Parallels smoke model selection", () => { expect(retained).toBe(`${"a".repeat(2)}${"b".repeat(10)}`); }); + it("keeps JSON-mode progress off stdout", async () => { + const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + await withProgressOnStderr(async () => { + const { say } = await import("../../scripts/e2e/parallels/common.ts"); + say("progress"); + process.stdout.write('{"ok":true}\n'); + }); + + expect(stdoutWrite).toHaveBeenCalledTimes(1); + expect(stdoutWrite).toHaveBeenCalledWith('{"ok":true}\n'); + expect(JSON.parse(String(stdoutWrite.mock.calls[0]?.[0]))).toEqual({ ok: true }); + expect(stderrWrite).toHaveBeenCalledWith("==> progress\n"); + } finally { + stdoutWrite.mockRestore(); + stderrWrite.mockRestore(); + } + }); + it("waits for host artifact server exit after SIGKILL before stop resolves", async () => { vi.useFakeTimers(); try {