From b37a6f37f5e2519bd6b7ea5b1744e565e7785eaa Mon Sep 17 00:00:00 2001 From: Kris <2040188027@qq.com> Date: Tue, 20 Oct 2026 16:25:43 +0800 Subject: [PATCH] fix(runners): preserve UTF-8 across child output chunks --- evals/browser/lib/process.mjs | 8 ++--- evals/browser/tests/process.test.mjs | 35 +++++++++++++++++++ .../dsh-plugin-browserskill/src/runner.ts | 9 +++-- .../tests/runner.test.ts | 24 +++++++++++++ 4 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 evals/browser/tests/process.test.mjs diff --git a/evals/browser/lib/process.mjs b/evals/browser/lib/process.mjs index d322e95da..37cf79374 100644 --- a/evals/browser/lib/process.mjs +++ b/evals/browser/lib/process.mjs @@ -21,11 +21,11 @@ export function runProcess(command, args, { cwd, env, timeoutMs = 300_000 } = {} stdio: ["ignore", "pipe", "pipe"], }); - child.stdout.on("data", (chunk) => { - stdout = appendCaptured(stdout, chunk.toString()); + child.stdout.setEncoding("utf8").on("data", (chunk) => { + stdout = appendCaptured(stdout, chunk); }); - child.stderr.on("data", (chunk) => { - stderr = appendCaptured(stderr, chunk.toString()); + child.stderr.setEncoding("utf8").on("data", (chunk) => { + stderr = appendCaptured(stderr, chunk); }); const timeout = setTimeout(() => { diff --git a/evals/browser/tests/process.test.mjs b/evals/browser/tests/process.test.mjs new file mode 100644 index 000000000..a884d7110 --- /dev/null +++ b/evals/browser/tests/process.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { runProcess } from "../lib/process.mjs"; + +test("process output preserves UTF-8 characters split across pipe writes", async () => { + const expected = "中文🙂"; + const source = ` + const bytes = Buffer.from(${JSON.stringify(expected)}); + let offset = 0; + const timer = setInterval(() => { + const chunk = bytes.subarray(offset, ++offset); + process.stdout.write(chunk); + process.stderr.write(chunk); + if (offset === bytes.length) clearInterval(timer); + }, 10); + `; + const result = await runProcess(process.execPath, ["-e", source]); + + assert.equal(result.exitCode, 0); + assert.equal(result.stdout, expected); + assert.equal(result.stderr, expected); +}); + +test("process output flushes an incomplete final UTF-8 sequence", async () => { + const source = ` + process.stdout.write(Buffer.from([0xe4, 0xb8])); + process.stderr.write(Buffer.from([0xf0, 0x9f])); + `; + const result = await runProcess(process.execPath, ["-e", source]); + + assert.equal(result.exitCode, 0); + assert.equal(result.stdout, "�"); + assert.equal(result.stderr, "�"); +}); diff --git a/packages/dsh-plugin-browserskill/src/runner.ts b/packages/dsh-plugin-browserskill/src/runner.ts index 3b67a6f8c..cfbfc7eed 100644 --- a/packages/dsh-plugin-browserskill/src/runner.ts +++ b/packages/dsh-plugin-browserskill/src/runner.ts @@ -6,6 +6,7 @@ */ import { type ChildProcess, type SpawnOptionsWithoutStdio, spawn } from "node:child_process"; +import { StringDecoder } from "node:string_decoder"; /** Shape of the JSON error envelope `bsk --json` prints on failure. */ export interface BskErrorBody { @@ -154,14 +155,16 @@ export function createBskRunner(bskPath: string, spawnImpl: SpawnImpl = spawn): let stdout = ""; let stderr = ""; + const stdoutDecoder = new StringDecoder("utf8"); + const stderrDecoder = new StringDecoder("utf8"); let timedOut = false; let aborted = false; const onStdout = (chunk: Buffer | string) => { - stdout += chunk; + stdout += stdoutDecoder.write(typeof chunk === "string" ? Buffer.from(chunk) : chunk); extendDrain(); }; const onStderr = (chunk: Buffer | string) => { - stderr += chunk; + stderr += stderrDecoder.write(typeof chunk === "string" ? Buffer.from(chunk) : chunk); extendDrain(); }; child.stdout?.on("data", onStdout); @@ -187,6 +190,8 @@ export function createBskRunner(bskPath: string, spawnImpl: SpawnImpl = spawn): const finish = (code: number | null) => { if (settled) return; settled = true; + stdout += stdoutDecoder.end(); + stderr += stderrDecoder.end(); settle(); release(); resolve({ code, stdout, stderr, timedOut, aborted }); diff --git a/packages/dsh-plugin-browserskill/tests/runner.test.ts b/packages/dsh-plugin-browserskill/tests/runner.test.ts index d56799fc6..ba1072898 100644 --- a/packages/dsh-plugin-browserskill/tests/runner.test.ts +++ b/packages/dsh-plugin-browserskill/tests/runner.test.ts @@ -66,6 +66,30 @@ afterEach(() => { }); describe("createBskRunner", () => { + it("preserves UTF-8 characters split across output buffers", async () => { + const child = new FakeChild(); + const runner = createBskRunner("bsk", fakeSpawn([child])); + const promise = runner.run(["snapshot"]); + const stdout = JSON.stringify({ text: "中文🙂" }); + const stderr = "错误🚫"; + for (const byte of Buffer.from(stdout)) child.stdout.emit("data", Buffer.from([byte])); + for (const byte of Buffer.from(stderr)) child.stderr.emit("data", Buffer.from([byte])); + child.finish(0); + + expect(await promise).toMatchObject({ stdout, stderr }); + }); + + it("flushes an incomplete final UTF-8 sequence when output collection ends", async () => { + const child = new FakeChild(); + const runner = createBskRunner("bsk", fakeSpawn([child])); + const promise = runner.run(["snapshot"]); + child.stdout.emit("data", Buffer.from([0xe4, 0xb8])); + child.stderr.emit("data", Buffer.from([0xf0, 0x9f])); + child.finish(0); + + expect(await promise).toMatchObject({ stdout: "�", stderr: "�" }); + }); + it("appends --json and collects stdout", async () => { const child = new FakeChild(); const seen: string[][] = [];