Files
openclaw/src/cli/logs-cli.runtime.test.ts
Ben.Li afcc93bfc7 fix(cli): keep UTF-8 log tails valid (#101029)
* fix(cli): keep UTF-8 log tails valid

* fix(cli): share UTF-8-safe byte tails

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-06 19:47:22 +01:00

95 lines
3.3 KiB
TypeScript
Raw Blame History

import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { execFileUtf8Tail } from "./logs-cli.runtime.js";
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }));
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawn: spawnMock,
};
});
function mockSpawnedChild() {
const stdout = new EventEmitter();
const stderr = new EventEmitter();
const kill = vi.fn(() => true);
const child = Object.assign(new EventEmitter(), { kill, stderr, stdout });
spawnMock.mockReturnValue(child as unknown as ChildProcess);
return { child, kill, stderr, stdout };
}
describe("execFileUtf8Tail", () => {
beforeEach(() => {
spawnMock.mockReset();
});
it.each(["stdout", "stderr"] as const)(
"terminates the child when %s emits an error",
async (streamName) => {
const { kill, stderr, stdout } = mockSpawnedChild();
const resultPromise = execFileUtf8Tail("journalctl", ["--no-pager"], { maxBytes: 1024 });
stdout.emit("data", Buffer.from("partial output"));
const streamError = new Error(`${streamName} read failed`);
(streamName === "stdout" ? stdout : stderr).emit("error", streamError);
await expect(resultPromise).resolves.toEqual({
code: 1,
stderr: streamError.message,
stdout: "partial output",
truncated: false,
});
expect(kill).toHaveBeenCalledOnce();
},
);
it("does not kill the child when spawning fails", async () => {
const { child, kill } = mockSpawnedChild();
const resultPromise = execFileUtf8Tail("journalctl", ["--no-pager"], { maxBytes: 1024 });
child.emit("error", new Error("spawn failed"));
await expect(resultPromise).resolves.toMatchObject({ code: 1, stderr: "spawn failed" });
expect(kill).not.toHaveBeenCalled();
});
it.each([
{ label: "two-byte", text: "¢z", maxBytes: 2, expected: "z" },
{ label: "three-byte", text: "€z", maxBytes: 3, expected: "z" },
{ label: "four-byte", text: "😀z", maxBytes: 4, expected: "z" },
{ label: "complete", text: "a¢z", maxBytes: 3, expected: "¢z" },
])("decodes a $label character at the stdout tail boundary", async (testCase) => {
const { child, stdout } = mockSpawnedChild();
const resultPromise = execFileUtf8Tail("journalctl", ["--no-pager"], {
maxBytes: testCase.maxBytes,
});
stdout.emit("data", Buffer.from(testCase.text, "utf8"));
child.emit("close", 0);
await expect(resultPromise).resolves.toEqual({
code: 0,
stderr: "",
stdout: testCase.expected,
truncated: true,
});
});
it("decodes a truncated stderr tail at a UTF-8 boundary", async () => {
const { child, stderr } = mockSpawnedChild();
const resultPromise = execFileUtf8Tail("journalctl", ["--no-pager"], { maxBytes: 1024 });
stderr.emit("data", Buffer.concat([Buffer.from("😀"), Buffer.alloc(64 * 1024 - 3, "x")]));
child.emit("close", 1);
const result = await resultPromise;
expect(result.stderr).toBe("x".repeat(64 * 1024 - 3));
expect(result.stderr).not.toContain("<22>");
expect(result.truncated).toBe(false);
});
});