Files
openclaw/extensions/codex/app-server/client.test.ts
2026-04-10 21:22:16 +01:00

192 lines
5.9 KiB
TypeScript

import { EventEmitter } from "node:events";
import { PassThrough, Writable } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
CodexAppServerClient,
listCodexAppServerModels,
resetSharedCodexAppServerClientForTests,
} from "./client.js";
function createClientHarness() {
const stdout = new PassThrough();
const stderr = new PassThrough();
const writes: string[] = [];
const stdin = new Writable({
write(chunk, _encoding, callback) {
writes.push(chunk.toString());
callback();
},
});
const process = Object.assign(new EventEmitter(), {
stdin,
stdout,
stderr,
killed: false,
kill: vi.fn(() => {
process.killed = true;
}),
});
const client = CodexAppServerClient.fromTransportForTests(process);
return {
client,
writes,
send(message: unknown) {
stdout.write(`${JSON.stringify(message)}\n`);
},
};
}
describe("CodexAppServerClient", () => {
const clients: CodexAppServerClient[] = [];
afterEach(() => {
resetSharedCodexAppServerClientForTests();
vi.restoreAllMocks();
for (const client of clients) {
client.close();
}
clients.length = 0;
});
it("routes request responses by id", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const request = harness.client.request("model/list", {});
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number; method?: string };
harness.send({ id: outbound.id, result: { models: [] } });
await expect(request).resolves.toEqual({ models: [] });
expect(outbound.method).toBe("model/list");
});
it("initializes with the required client version", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const initializing = harness.client.initialize();
const outbound = JSON.parse(harness.writes[0] ?? "{}") as {
id?: number;
method?: string;
params?: { clientInfo?: { name?: string; title?: string; version?: string } };
};
harness.send({ id: outbound.id, result: {} });
await expect(initializing).resolves.toBeUndefined();
expect(outbound).toMatchObject({
method: "initialize",
params: {
clientInfo: {
name: "openclaw",
title: "OpenClaw",
version: expect.any(String),
},
},
});
expect(outbound.params?.clientInfo?.version).not.toBe("");
expect(JSON.parse(harness.writes[1] ?? "{}")).toEqual({ method: "initialized" });
});
it("answers server-initiated requests with the registered handler result", async () => {
const harness = createClientHarness();
clients.push(harness.client);
harness.client.addRequestHandler((request) => {
if (request.method === "item/tool/call") {
return { contentItems: [{ type: "inputText", text: "ok" }], success: true };
}
return undefined;
});
harness.send({ id: "srv-1", method: "item/tool/call", params: { tool: "message" } });
await vi.waitFor(() => expect(harness.writes.length).toBe(1));
expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({
id: "srv-1",
result: { contentItems: [{ type: "inputText", text: "ok" }], success: true },
});
});
it("fails closed for unhandled native app-server approvals", async () => {
const harness = createClientHarness();
clients.push(harness.client);
harness.send({
id: "approval-1",
method: "item/commandExecution/requestApproval",
params: { threadId: "thread-1", turnId: "turn-1", itemId: "cmd-1", command: "pnpm test" },
});
await vi.waitFor(() => expect(harness.writes.length).toBe(1));
expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({
id: "approval-1",
result: { decision: "decline" },
});
});
it("fails closed with legacy review decisions for legacy app-server approvals", async () => {
const harness = createClientHarness();
clients.push(harness.client);
harness.send({
id: "approval-legacy",
method: "execCommandApproval",
params: { conversationId: "thread-1", callId: "cmd-1", command: ["pnpm", "test"] },
});
await vi.waitFor(() => expect(harness.writes.length).toBe(1));
expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({
id: "approval-legacy",
result: { decision: "denied" },
});
});
it("lists app-server models through the typed helper", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const listPromise = listCodexAppServerModels({ limit: 12, timeoutMs: 1000 });
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({ id: initialize.id, result: {} });
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3));
const list = JSON.parse(harness.writes[2] ?? "{}") as { id?: number; method?: string };
expect(list.method).toBe("model/list");
harness.send({
id: list.id,
result: {
data: [
{
id: "gpt-5.4",
model: "gpt-5.4",
displayName: "gpt-5.4",
inputModalities: ["text", "image"],
supportedReasoningEfforts: [
{ reasoningEffort: "low", description: "fast" },
{ reasoningEffort: "xhigh", description: "deep" },
],
defaultReasoningEffort: "medium",
isDefault: true,
},
],
nextCursor: null,
},
});
await expect(listPromise).resolves.toEqual({
models: [
{
id: "gpt-5.4",
model: "gpt-5.4",
displayName: "gpt-5.4",
inputModalities: ["text", "image"],
supportedReasoningEfforts: ["low", "xhigh"],
defaultReasoningEffort: "medium",
isDefault: true,
},
],
});
startSpy.mockRestore();
});
});