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 <noreply@anthropic.com>

* fix(control-ui-assets): preserve diagnostic length cap

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
maweibin
2026-07-07 19:32:21 +08:00
committed by GitHub
parent 1805f7ee17
commit f6ffe89df6
2 changed files with 42 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ type FakeFsEntry = { kind: "file"; content: string } | { kind: "dir" };
const state = vi.hoisted(() => ({
entries: new Map<string, FakeFsEntry>(),
realpaths: new Map<string, string>(),
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");

View File

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