fix(e2e): keep phase log tails UTF-8 safe (#109923)

* fix(e2e): keep phase log tails UTF-8 safe

The phase runner keeps a bounded in-memory log tail per phase and writes it
to stderr when a phase fails. The byte-based cut can land inside a multi-byte
UTF-8 character, and decoding from that offset emits U+FFFD replacement
characters at the start of the diagnostics tail. Reuse the decodeUtf8Tail
helper from scripts/e2e/lib/text-file-utils.mjs (#109669) so the retained
window starts on a character boundary.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(e2e): declare decodeUtf8Tail in text-file-utils types

phase-runner.ts imports decodeUtf8Tail from text-file-utils.mjs; the
adjacent declaration file must export it or check-test-types fails with
TS2305.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(e2e): reuse tailText wrapper for phase log tails

Co-Authored-By: Claude <noreply@anthropic.com>

* test(e2e): focus phase tail regression

Co-authored-by: wangmiao0668000666 <wang.miao86@xydigit.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
wangmiao0668000666
2026-07-21 10:45:35 +08:00
committed by GitHub
parent f8380a8324
commit 998f0ed27d
2 changed files with 46 additions and 2 deletions

View File

@@ -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 {

View File

@@ -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<string> {
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("<22>");
expect(tail).toContain("y".repeat(79));
});