mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
243 lines
8.3 KiB
TypeScript
243 lines
8.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { AppServerClient } from "../../src/app-server/connection/client";
|
|
import {
|
|
type AppServerClientFactory,
|
|
ConnectionManager,
|
|
type ConnectionManagerHandlers,
|
|
StaleConnectionError,
|
|
} from "../../src/app-server/connection/connection-manager";
|
|
import type { RpcOutboundMessage, ServerNotification } from "../../src/app-server/connection/rpc-messages";
|
|
import type { AppServerTransport, AppServerTransportHandlers } from "../../src/app-server/connection/transport";
|
|
import type { InitializeParams } from "../../src/generated/app-server/InitializeParams";
|
|
|
|
const TEST_INITIALIZE_PARAMS: InitializeParams = {
|
|
clientInfo: {
|
|
name: "test_client",
|
|
title: "Test Client",
|
|
version: "0.0.0",
|
|
},
|
|
capabilities: {
|
|
experimentalApi: false,
|
|
requestAttestation: false,
|
|
},
|
|
};
|
|
|
|
class SilentTransport implements AppServerTransport {
|
|
readonly sent: RpcOutboundMessage[] = [];
|
|
running = false;
|
|
|
|
constructor(private readonly handlers: AppServerTransportHandlers) {}
|
|
|
|
start(): void {
|
|
this.running = true;
|
|
}
|
|
|
|
send(message: RpcOutboundMessage): void {
|
|
this.sent.push(message);
|
|
}
|
|
|
|
stop(): void {
|
|
this.running = false;
|
|
}
|
|
|
|
isRunning(): boolean {
|
|
return this.running;
|
|
}
|
|
|
|
emitExit(): void {
|
|
this.running = false;
|
|
this.handlers.onExit(null, null);
|
|
}
|
|
|
|
emitLine(message: unknown): void {
|
|
this.handlers.onLine(JSON.stringify(message));
|
|
}
|
|
}
|
|
|
|
describe("ConnectionManager", () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal("window", {
|
|
clearTimeout,
|
|
setTimeout,
|
|
});
|
|
});
|
|
|
|
it("disconnects clients whose initialization fails", async () => {
|
|
let transport!: SilentTransport;
|
|
const manager = new ConnectionManager(
|
|
() => "/bin/codex",
|
|
"/vault",
|
|
TEST_INITIALIZE_PARAMS,
|
|
testClientFactory({ requestTimeoutMs: 5, onTransport: (next) => (transport = next) }),
|
|
);
|
|
|
|
await expect(manager.connect(silentConnectionHandlers())).rejects.toThrow("Codex app-server request timed out: initialize");
|
|
|
|
expect(transport.running).toBe(false);
|
|
expect(manager.currentClient()).toBeNull();
|
|
});
|
|
|
|
it("shares an in-flight connection attempt", async () => {
|
|
let transport!: SilentTransport;
|
|
const manager = new ConnectionManager(
|
|
() => "/bin/codex",
|
|
"/vault",
|
|
TEST_INITIALIZE_PARAMS,
|
|
testClientFactory({ onTransport: (next) => (transport = next) }),
|
|
);
|
|
|
|
const first = manager.connect(silentConnectionHandlers());
|
|
const second = manager.connect(silentConnectionHandlers());
|
|
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } });
|
|
|
|
await expect(first).resolves.toMatchObject({ codexHome: "/tmp/codex" });
|
|
await expect(second).resolves.toMatchObject({ codexHome: "/tmp/codex" });
|
|
expect(transport.sent).toHaveLength(2);
|
|
expect(manager.currentClient()).toBeInstanceOf(AppServerClient);
|
|
});
|
|
|
|
it("does not reuse a connected client after the command context changes", async () => {
|
|
let codexPath = "/bin/codex-a";
|
|
const transports: SilentTransport[] = [];
|
|
const manager = new ConnectionManager(
|
|
() => codexPath,
|
|
"/vault",
|
|
TEST_INITIALIZE_PARAMS,
|
|
testClientFactory({ onTransport: (transport) => transports.push(transport) }),
|
|
);
|
|
|
|
const first = manager.connect(silentConnectionHandlers());
|
|
transports[0]?.emitLine({ id: 1, result: { codexHome: "/tmp/codex-a" } });
|
|
await expect(first).resolves.toMatchObject({ codexHome: "/tmp/codex-a" });
|
|
expect(manager.currentClient()).toBeInstanceOf(AppServerClient);
|
|
expect(manager.currentConnectionContext()).toEqual({ codexPath: "/bin/codex-a", cwd: "/vault", generation: 0 });
|
|
|
|
codexPath = "/bin/codex-b";
|
|
|
|
expect(manager.currentClient()).toBeNull();
|
|
expect(manager.currentConnectionContext()).toBeNull();
|
|
const second = manager.connect(silentConnectionHandlers());
|
|
transports[1]?.emitLine({ id: 1, result: { codexHome: "/tmp/codex-b" } });
|
|
|
|
await expect(second).resolves.toMatchObject({ codexHome: "/tmp/codex-b" });
|
|
expect(transports).toHaveLength(2);
|
|
expect(transports[0]?.running).toBe(false);
|
|
expect(manager.currentClient()).toBeInstanceOf(AppServerClient);
|
|
expect(manager.currentConnectionContext()).toEqual({ codexPath: "/bin/codex-b", cwd: "/vault", generation: 0 });
|
|
});
|
|
|
|
it("labels inbound events with the context captured when the connection was created", async () => {
|
|
let codexPath = "/bin/codex-a";
|
|
let transport!: SilentTransport;
|
|
const onNotification = vi.fn();
|
|
const manager = new ConnectionManager(
|
|
() => codexPath,
|
|
"/vault",
|
|
TEST_INITIALIZE_PARAMS,
|
|
testClientFactory({ onTransport: (next) => (transport = next) }),
|
|
);
|
|
|
|
const connecting = manager.connect({ ...silentConnectionHandlers(), onNotification });
|
|
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex-a" } });
|
|
await connecting;
|
|
codexPath = "/bin/codex-b";
|
|
const notification = {
|
|
method: "thread/name/updated",
|
|
params: { threadId: "thread-a", threadName: "A" },
|
|
} satisfies Extract<ServerNotification, { method: "thread/name/updated" }>;
|
|
|
|
transport.emitLine(notification);
|
|
|
|
expect(onNotification).toHaveBeenCalledWith(notification, { codexPath: "/bin/codex-a", cwd: "/vault", generation: 0 });
|
|
});
|
|
|
|
it("does not reuse a connected client across generations of the same raw context", async () => {
|
|
let contextGeneration = 1;
|
|
const transports: SilentTransport[] = [];
|
|
const manager = new ConnectionManager(
|
|
() => "/bin/codex",
|
|
"/vault",
|
|
TEST_INITIALIZE_PARAMS,
|
|
testClientFactory({ onTransport: (transport) => transports.push(transport) }),
|
|
() => contextGeneration,
|
|
);
|
|
const first = manager.connect(silentConnectionHandlers());
|
|
transports[0]?.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } });
|
|
await first;
|
|
|
|
contextGeneration = 3;
|
|
|
|
expect(manager.currentClient()).toBeNull();
|
|
const second = manager.connect(silentConnectionHandlers());
|
|
transports[1]?.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } });
|
|
await second;
|
|
expect(manager.currentConnectionContext()).toEqual({ codexPath: "/bin/codex", cwd: "/vault", generation: 3 });
|
|
expect(transports[0]?.running).toBe(false);
|
|
});
|
|
|
|
it("rejects app-server exit during initialization without reporting a connected-client exit", async () => {
|
|
let transport!: SilentTransport;
|
|
const onExit = vi.fn();
|
|
const manager = new ConnectionManager(
|
|
() => "/bin/codex",
|
|
"/vault",
|
|
TEST_INITIALIZE_PARAMS,
|
|
testClientFactory({ onTransport: (next) => (transport = next) }),
|
|
);
|
|
|
|
const connecting = manager.connect({ ...silentConnectionHandlers(), onExit });
|
|
transport.emitExit();
|
|
|
|
await expect(connecting).rejects.toThrow("Codex app-server exited: unknown");
|
|
expect(onExit).not.toHaveBeenCalled();
|
|
expect(manager.currentClient()).toBeNull();
|
|
});
|
|
|
|
it("marks initialization completed after disconnect as stale", async () => {
|
|
let transport!: SilentTransport;
|
|
const onExit = vi.fn();
|
|
const manager = new ConnectionManager(
|
|
() => "/bin/codex",
|
|
"/vault",
|
|
TEST_INITIALIZE_PARAMS,
|
|
testClientFactory({ onTransport: (next) => (transport = next) }),
|
|
);
|
|
|
|
const connecting = manager.connect({ ...silentConnectionHandlers(), onExit });
|
|
manager.disconnect();
|
|
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } });
|
|
|
|
await expect(connecting).rejects.toBeInstanceOf(StaleConnectionError);
|
|
expect(onExit).not.toHaveBeenCalled();
|
|
expect(manager.currentClient()).toBeNull();
|
|
});
|
|
});
|
|
|
|
function silentConnectionHandlers(): ConnectionManagerHandlers {
|
|
return {
|
|
onNotification: () => undefined,
|
|
onServerRequest: () => undefined,
|
|
onLog: () => undefined,
|
|
onExit: () => undefined,
|
|
};
|
|
}
|
|
|
|
function testClientFactory(options: {
|
|
requestTimeoutMs?: number;
|
|
onTransport: (transport: SilentTransport) => void;
|
|
}): AppServerClientFactory {
|
|
return (codexPath, cwd, handlers) =>
|
|
new AppServerClient({
|
|
codexPath,
|
|
cwd,
|
|
handlers,
|
|
initializeParams: TEST_INITIALIZE_PARAMS,
|
|
requestTimeoutMs: options.requestTimeoutMs ?? 500,
|
|
transportFactory: (transportHandlers) => {
|
|
const transport = new SilentTransport(transportHandlers);
|
|
options.onTransport(transport);
|
|
return transport;
|
|
},
|
|
});
|
|
}
|