Files
openclaw/src/agents/agent-bundle-lsp-runtime.test.ts
Peter Steinberger aaf5ab910c fix: land ten small reliability fixes (#100483)
* fix(agents): harden LSP process failures

Source: #100450

Co-authored-by: morluto <williamlin1327@gmail.com>

* fix(sandbox): report effective workspace layout

Sources: #100435, #100439

Co-authored-by: Aniruddha Adak <aniruddhaadak80@users.noreply.github.com>

Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com>

* fix(security): fail install checks on stream errors

Source: #100413

Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>

* fix(android): normalize all-day calendar events

Source: #100032

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* fix(ios): serialize push-to-talk lifecycle

Source: #99942

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* fix(talk): reject inherited provider names

Source: #99849

Co-authored-by: zenglingbiao <zeng.lingbiao@xydigit.com>

* fix(android): stop voice capture in background

Source: #99840

Co-authored-by: xialonglee <li.xialong@xydigit.com>

* fix(cron): preserve fallback result classification

Source: #99913

Co-authored-by: jincheng-xydt <xu.jincheng@xydigit.com>

* fix(google): bound Vertex response decompression

Source: #99812

Co-authored-by: 黄剑雄0668001315 <huang.jianxiong@xydigit.com>

* fix(plugins): report malformed discovery JSON

Source: #99892

Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>

* test(sandbox): configure non-default workspace fixture

* test: fix small-fix batch validation

---------

Co-authored-by: morluto <williamlin1327@gmail.com>
Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com>
Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>
Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>
Co-authored-by: zenglingbiao <zeng.lingbiao@xydigit.com>
Co-authored-by: xialonglee <li.xialong@xydigit.com>
Co-authored-by: jincheng-xydt <xu.jincheng@xydigit.com>
Co-authored-by: 黄剑雄0668001315 <huang.jianxiong@xydigit.com>
2026-07-06 00:08:51 +01:00

278 lines
9.4 KiB
TypeScript

/** Tests embedded LSP runtime JSON-RPC, tool behavior, and cleanup. */
import { EventEmitter } from "node:events";
import { PassThrough, Writable } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
const spawnMock = vi.hoisted(() => vi.fn());
const killProcessTreeMock = vi.hoisted(() => vi.fn());
const loadEmbeddedAgentLspConfigMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async () => ({
...(await vi.importActual<typeof import("node:child_process")>("node:child_process")),
spawn: spawnMock,
}));
vi.mock("../process/kill-tree.js", () => ({
killProcessTree: killProcessTreeMock,
}));
vi.mock("./embedded-agent-lsp.js", () => ({
loadEmbeddedAgentLspConfig: loadEmbeddedAgentLspConfigMock,
}));
vi.mock("../logger.js", () => ({
logDebug: vi.fn(),
logWarn: vi.fn(),
}));
function encodeLspMessage(body: unknown): string {
const json = JSON.stringify(body);
return `Content-Length: ${Buffer.byteLength(json, "utf-8")}\r\n\r\n${json}`;
}
function parseWrittenLspBody(text: string): Record<string, unknown> | null {
const bodyStart = text.indexOf("\r\n\r\n");
if (bodyStart === -1) {
return null;
}
return JSON.parse(text.slice(bodyStart + 4)) as Record<string, unknown>;
}
class MockChildProcess extends EventEmitter {
exitCode: number | null = null;
signalCode: NodeJS.Signals | null = null;
killed = false;
pid = 4321;
readonly stdout = new PassThrough();
readonly stderr = new PassThrough();
readonly stdin: Writable;
constructor(
private readonly initializeResponsePrefix = "",
private readonly respondMethods?: ReadonlySet<string>,
) {
super();
this.stdin = new Writable({
write: (chunk, _encoding, callback) => {
this.respondToRequest(chunk.toString("utf8"));
callback();
},
});
}
kill = vi.fn((signal: NodeJS.Signals = "SIGTERM") => {
this.killed = true;
this.signalCode = signal;
this.emit("exit", null, signal);
this.emit("close", null, signal);
return true;
});
private respondToRequest(text: string): void {
const body = parseWrittenLspBody(text);
if (!body || typeof body.id !== "number" || typeof body.method !== "string") {
return;
}
if (this.respondMethods && !this.respondMethods.has(body.method)) {
return;
}
const result = body.method === "initialize" ? { capabilities: { hoverProvider: true } } : null;
queueMicrotask(() => {
this.stdout.write(
`${this.initializeResponsePrefix}${encodeLspMessage({ jsonrpc: "2.0", id: body.id, result })}`,
);
});
}
}
function configureSingleLspServer(): void {
loadEmbeddedAgentLspConfigMock.mockReturnValue({
lspServers: {
typescript: {
command: "typescript-language-server",
args: ["--stdio"],
},
},
diagnostics: [],
});
}
describe("bundle LSP runtime", () => {
afterEach(async () => {
const { disposeAllBundleLspRuntimes } = await import("./agent-bundle-lsp-runtime.js");
await disposeAllBundleLspRuntimes();
spawnMock.mockReset();
killProcessTreeMock.mockReset();
loadEmbeddedAgentLspConfigMock.mockReset();
});
it("starts LSP servers in a disposable process group", async () => {
configureSingleLspServer();
const child = new MockChildProcess();
spawnMock.mockReturnValue(child);
const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js");
const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" });
expect(spawnMock).toHaveBeenCalledTimes(1);
const [command, args, options] = spawnMock.mock.calls.at(0) ?? [];
expect(command).toBe("typescript-language-server");
expect(args).toEqual(["--stdio"]);
expect(options?.detached).toBe(process.platform !== "win32");
expect(options?.stdio).toEqual(["pipe", "pipe", "pipe"]);
expect(options?.windowsHide).toBe(process.platform === "win32");
expect(runtime.tools.map((tool) => tool.name)).toContain("lsp_hover_typescript");
await runtime.dispose();
expect(killProcessTreeMock).toHaveBeenCalledWith(4321, { graceMs: 1000 });
});
it("fails LSP startup immediately when the child process cannot spawn", async () => {
configureSingleLspServer();
const child = new MockChildProcess();
spawnMock.mockImplementation(() => {
queueMicrotask(() => child.emit("error", new Error("spawn ENOENT")));
return child;
});
const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js");
const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" });
expect(runtime.sessions).toEqual([]);
expect(runtime.tools).toEqual([]);
expect(killProcessTreeMock).toHaveBeenCalledWith(4321, { graceMs: 1000 });
});
it.each([
{
name: "stdout fails",
fail: (child: MockChildProcess) => child.stdout.emit("error", new Error("stdout failed")),
message: "stdout failed",
},
{
name: "stdin fails",
fail: (child: MockChildProcess) => child.stdin.emit("error", new Error("stdin failed")),
message: "stdin failed",
},
])("rejects pending and future LSP requests when $name", async ({ fail, message }) => {
configureSingleLspServer();
const child = new MockChildProcess("", new Set(["initialize"]));
spawnMock.mockReturnValue(child);
const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js");
const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" });
const hoverTool = runtime.tools.find((tool) => tool.name === "lsp_hover_typescript");
if (!hoverTool) {
throw new Error("expected hover tool");
}
const hoverParams = {
uri: "file:///tmp/workspace/index.ts",
line: 0,
character: 0,
};
const request = hoverTool.execute("call-1", hoverParams);
fail(child);
await expect(request).rejects.toThrow(message);
await expect(hoverTool.execute("call-2", hoverParams)).rejects.toThrow(message);
await runtime.dispose();
});
it("blocks new LSP requests on exit while allowing a final stdout response to drain", async () => {
configureSingleLspServer();
const child = new MockChildProcess("", new Set(["initialize"]));
spawnMock.mockReturnValue(child);
const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js");
const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" });
const hoverTool = runtime.tools.find((tool) => tool.name === "lsp_hover_typescript");
if (!hoverTool) {
throw new Error("expected hover tool");
}
const hoverParams = {
uri: "file:///tmp/workspace/index.ts",
line: 0,
character: 0,
};
const pendingRequest = hoverTool.execute("call-1", hoverParams);
child.exitCode = 1;
child.emit("exit", 1, null);
await expect(hoverTool.execute("call-2", hoverParams)).rejects.toThrow(
'LSP server "typescript" exited (1)',
);
child.stdout.write(
encodeLspMessage({ jsonrpc: "2.0", id: 2, result: { contents: "final hover" } }),
);
await expect(pendingRequest).resolves.toMatchObject({
details: { lspServer: "typescript", lspMethod: "hover" },
});
child.emit("close", 1, null);
await runtime.dispose();
});
it("rejects undrained LSP requests when the exited process closes", async () => {
configureSingleLspServer();
const child = new MockChildProcess("", new Set(["initialize"]));
spawnMock.mockReturnValue(child);
const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js");
const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" });
const hoverTool = runtime.tools.find((tool) => tool.name === "lsp_hover_typescript");
if (!hoverTool) {
throw new Error("expected hover tool");
}
const request = hoverTool.execute("call-1", {
uri: "file:///tmp/workspace/index.ts",
line: 0,
character: 0,
});
child.exitCode = 1;
child.emit("exit", 1, null);
child.emit("close", 1, null);
await expect(request).rejects.toThrow('LSP server "typescript" exited (1)');
await runtime.dispose();
});
it("keeps LSP framing aligned after multibyte messages in the same chunk", async () => {
configureSingleLspServer();
const prefix = encodeLspMessage({
jsonrpc: "2.0",
method: "window/logMessage",
params: { message: "ready té" },
});
const child = new MockChildProcess(prefix);
spawnMock.mockReturnValue(child);
const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js");
const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" });
expect(runtime.tools.map((tool) => tool.name)).toContain("lsp_hover_typescript");
await runtime.dispose();
});
it("disposes active LSP sessions from the global shutdown sweep", async () => {
configureSingleLspServer();
const child = new MockChildProcess();
spawnMock.mockReturnValue(child);
const { createBundleLspToolRuntime, disposeAllBundleLspRuntimes } =
await import("./agent-bundle-lsp-runtime.js");
const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" });
await disposeAllBundleLspRuntimes();
expect(killProcessTreeMock).toHaveBeenCalledWith(4321, { graceMs: 1000 });
killProcessTreeMock.mockClear();
await runtime.dispose();
expect(killProcessTreeMock).not.toHaveBeenCalled();
});
});