From f6ffe89df6541fbf9e153c8ff716c2762098a643 Mon Sep 17 00:00:00 2001 From: maweibin Date: Tue, 7 Jul 2026 19:32:21 +0800 Subject: [PATCH] fix(control-ui-assets): keep emoji / surrogate pairs intact during last-line truncation (#101591) * fix(control-ui-assets): keep emoji / surrogate pairs intact during last-line truncation .slice(0, 239) counts UTF-16 code units; an emoji straddling the cut point produces a lone surrogate rendering as in WebUI assets. Use [...last] spread to count full code points instead. Co-Authored-By: Claude Opus 4.8 * fix(control-ui-assets): preserve diagnostic length cap --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Peter Steinberger --- src/infra/control-ui-assets.test.ts | 40 +++++++++++++++++++++++++++++ src/infra/control-ui-assets.ts | 3 ++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/infra/control-ui-assets.test.ts b/src/infra/control-ui-assets.test.ts index 16b6ff06ec3c..31dbb0e400e6 100644 --- a/src/infra/control-ui-assets.test.ts +++ b/src/infra/control-ui-assets.test.ts @@ -8,6 +8,7 @@ type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" }; const state = vi.hoisted(() => ({ entries: new Map(), realpaths: new Map(), + runCommandWithTimeout: vi.fn(), })); const abs = (p: string) => path.resolve(p); @@ -69,7 +70,11 @@ vi.mock("./openclaw-root.js", () => ({ resolveOpenClawPackageRoot: vi.fn(async () => null), resolveOpenClawPackageRootSync: vi.fn(() => null), })); +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: state.runCommandWithTimeout, +})); +let ensureControlUiAssetsBuilt: typeof import("./control-ui-assets.js").ensureControlUiAssetsBuilt; let resolveControlUiRepoRoot: typeof import("./control-ui-assets.js").resolveControlUiRepoRoot; let resolveControlUiDistIndexPath: typeof import("./control-ui-assets.js").resolveControlUiDistIndexPath; let resolveControlUiDistIndexHealth: typeof import("./control-ui-assets.js").resolveControlUiDistIndexHealth; @@ -81,6 +86,7 @@ let openclawRoot: typeof import("./openclaw-root.js"); describe("control UI assets helpers (fs-mocked)", () => { beforeAll(async () => { ({ + ensureControlUiAssetsBuilt, resolveControlUiRepoRoot, resolveControlUiDistIndexPath, resolveControlUiDistIndexHealth, @@ -94,6 +100,7 @@ describe("control UI assets helpers (fs-mocked)", () => { beforeEach(() => { state.entries.clear(); state.realpaths.clear(); + state.runCommandWithTimeout.mockReset(); vi.clearAllMocks(); }); @@ -179,6 +186,39 @@ describe("control UI assets helpers (fs-mocked)", () => { }); }); + it("keeps a truncated build failure diagnostic within its UTF-16 limit", async () => { + const root = abs("fixtures/build-failure"); + const argv1 = path.join(root, "src", "index.ts"); + const originalArgv1 = process.argv[1]; + setFile(path.join(root, "ui", "vite.config.ts"), "export {};\n"); + setFile(path.join(root, "scripts", "ui.js"), ""); + state.runCommandWithTimeout.mockResolvedValueOnce({ + stdout: "", + stderr: `${"y".repeat(238)}🚀xx`, + code: 1, + signal: null, + killed: false, + termination: "exit", + }); + process.argv[1] = argv1; + + try { + const result = await ensureControlUiAssetsBuilt({ + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }); + + expect(result).toEqual({ + ok: false, + built: false, + message: `Control UI build failed: ${"y".repeat(238)}…`, + }); + } finally { + process.argv[1] = originalArgv1; + } + }); + it("resolves control-ui root from override file or directory", () => { const root = abs("fixtures/override"); const uiDir = path.join(root, "dist", "control-ui"); diff --git a/src/infra/control-ui-assets.ts b/src/infra/control-ui-assets.ts index 7d5b1f1f81a2..4c197dbb72f9 100644 --- a/src/infra/control-ui-assets.ts +++ b/src/infra/control-ui-assets.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { runCommandWithTimeout } from "../process/exec.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import * as controlUiFsRuntime from "./control-ui-assets.fs.runtime.js"; @@ -285,7 +286,7 @@ function summarizeCommandOutput(text: string): string | undefined { if (!last) { return undefined; } - return last.length > 240 ? `${last.slice(0, 239)}…` : last; + return last.length > 240 ? `${truncateUtf16Safe(last, 239)}…` : last; } export async function ensureControlUiAssetsBuilt(