Files
openclaw/src/agents/tool-search.stream-errors.test.ts
T
Peter Steinberger 9497450511 refactor: eliminate dead-export baseline (#108376)
Burn the grandfathered unused-export baseline to zero and enforce a hard-zero Knip gate.
2026-07-15 17:05:07 +01:00

130 lines
4.5 KiB
TypeScript

// Regression tests for code-mode child stderr stream errors in Tool Search.
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
import { EventEmitter } from "node:events";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
type MockSpawnChild = EventEmitter & {
stderr?: EventEmitter & { setEncoding?: (enc: string) => void };
send?: (message: unknown, callback?: (error?: Error | null) => void) => boolean;
connected?: boolean;
kill?: (signal?: string) => void;
};
function createMockSpawnChild() {
const child = new EventEmitter() as MockSpawnChild;
const stderr = new EventEmitter() as MockSpawnChild["stderr"];
stderr!.setEncoding = vi.fn();
child.stderr = stderr;
child.connected = true;
child.kill = vi.fn();
child.send = vi.fn(() => true);
return { child, stderr };
}
vi.mock("node:child_process", async () => {
const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks");
const spawnLocal = vi.fn(
(_command: string, _args: readonly string[], _options: SpawnOptions): ChildProcess => {
const { child } = createMockSpawnChild();
return child as unknown as ChildProcess;
},
);
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:child_process")>("node:child_process"),
{
spawn: spawnLocal as unknown as typeof import("node:child_process").spawn,
},
);
});
const spawnMock = vi.mocked(spawn);
let toolSearch: typeof import("./tool-search.js");
let testing: (typeof import("./tool-search.test-support.js"))["testing"];
describe("tool-search code-mode stream errors", () => {
beforeAll(async () => {
toolSearch = await import("./tool-search.js");
testing = (await import("./tool-search.test-support.js")).testing;
});
afterEach(() => {
testing.setToolSearchCodeModeSupportedForTest(undefined);
testing.setToolSearchMinCodeTimeoutMsForTest(undefined);
});
it("rejects stderr errors and leaves the unused stdout unpiped", async () => {
testing.setToolSearchCodeModeSupportedForTest(true);
testing.setToolSearchMinCodeTimeoutMsForTest(1000);
let spawnedChild: MockSpawnChild | undefined;
spawnMock.mockImplementationOnce(
(_command: string, _args: readonly string[], _options: SpawnOptions): ChildProcess => {
const { child, stderr } = createMockSpawnChild();
spawnedChild = child;
process.nextTick(() => {
stderr?.emit("error", new Error("stderr read failed"));
});
return child as unknown as ChildProcess;
},
);
const runtime = new toolSearch.ToolSearchRuntime({}, toolSearch.resolveToolSearchConfig({}));
await expect(
testing.runCodeModeChild({
code: "return 1;",
config: toolSearch.resolveToolSearchConfig({}),
logs: [],
parentToolCallId: "call-stderr-error",
runtime,
}),
).rejects.toThrow("stderr read failed");
expect(spawnMock).toHaveBeenCalledOnce();
expect(spawnMock.mock.calls[0]?.[2]).toMatchObject({
stdio: ["ignore", "ignore", "pipe", "ipc"],
});
expect(spawnedChild?.kill).toHaveBeenCalledOnce();
});
it("keeps stderr tail in exit error messages valid at UTF-16 boundaries", async () => {
testing.setToolSearchCodeModeSupportedForTest(true);
testing.setToolSearchMinCodeTimeoutMsForTest(1000);
spawnMock.mockImplementationOnce(
(_command: string, _args: readonly string[], _options: SpawnOptions): ChildProcess => {
const { child, stderr } = createMockSpawnChild();
process.nextTick(() => {
stderr?.emit("data", `${"a".repeat(500)}😀${"a".repeat(499)}`);
process.nextTick(() => {
child.emit("exit", 1, null);
});
});
return child as unknown as ChildProcess;
},
);
const runtime = new toolSearch.ToolSearchRuntime({}, toolSearch.resolveToolSearchConfig({}));
let caught: Error | undefined;
try {
await testing.runCodeModeChild({
code: "return 1;",
config: toolSearch.resolveToolSearchConfig({}),
logs: [],
parentToolCallId: "call-stderr-utf16",
runtime,
});
} catch (error) {
caught = error instanceof Error ? error : new Error(String(error));
}
expect(caught).toBeDefined();
expect(caught?.message).toMatch(/tool_search_code child exited with 1/);
expect(caught?.message).not.toMatch(
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/,
);
expect(caught?.message.endsWith("a".repeat(499))).toBe(true);
});
});