diff --git a/scripts/e2e/parallels/phase-runner.ts b/scripts/e2e/parallels/phase-runner.ts index aff2b3c00a47..02fdfee365c2 100644 --- a/scripts/e2e/parallels/phase-runner.ts +++ b/scripts/e2e/parallels/phase-runner.ts @@ -3,6 +3,7 @@ import { appendFileSync } from "node:fs"; import { writeFile } from "node:fs/promises"; import path from "node:path"; import { clampTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; +import { tailText } from "../lib/text-file-utils.mjs"; import { say, warn } from "./host-command.ts"; const PHASE_LOG_TAIL_MAX_BYTES = 512 * 1024; @@ -15,8 +16,8 @@ function appendTextTail(current: string, chunk: string, maxBytes: number): strin } const marker = `[phase log tail truncated to last ${maxBytes} bytes]\n`; const tailBytes = Math.max(0, maxBytes - Buffer.byteLength(marker)); - const tail = Buffer.from(combined).subarray(-tailBytes).toString("utf8"); - return `${marker}${tail}`; + // tailText owns the UTF-8-safe byte truncation; the marker keeps the tail self-describing. + return `${marker}${tailText(combined, tailBytes)}`; } function resolvePhaseTimeoutMs(timeoutSeconds: number): number { diff --git a/test/scripts/parallels-phase-runner.test.ts b/test/scripts/parallels-phase-runner.test.ts new file mode 100644 index 000000000000..d8a025a57faf --- /dev/null +++ b/test/scripts/parallels-phase-runner.test.ts @@ -0,0 +1,43 @@ +// Parallels Phase Runner tests cover bounded in-memory phase log tails. +import { afterEach, expect, it, vi } from "vitest"; +import { PhaseRunner } from "../../scripts/e2e/parallels/phase-runner.ts"; +import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js"; + +const tempRoots: string[] = []; + +function makeTempRoot() { + return makeTempDir(tempRoots, "openclaw-parallels-phase-runner-"); +} + +afterEach(() => { + cleanupTempDirs(tempRoots); + vi.restoreAllMocks(); +}); + +async function captureFailedPhaseTail(runner: PhaseRunner, text: string): Promise { + const written: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + written.push(String(chunk)); + return true; + }); + await expect( + runner.phase("utf8-tail", 60, () => { + runner.append(text); + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + return written.join(""); +} + +it("keeps the truncated phase tail UTF-8 safe when the byte cut splits a character", async () => { + // 134 bytes total; the retained window starts one byte into the 4-byte + // emoji, so a byte-naive decode would emit replacement characters. + const tail = await captureFailedPhaseTail( + new PhaseRunner(makeTempRoot(), 128), + `${"x".repeat(50)}😀${"y".repeat(79)}`, + ); + + expect(tail).toContain("[phase log tail truncated to last 128 bytes]"); + expect(tail).not.toContain("�"); + expect(tail).toContain("y".repeat(79)); +});