Skip to content

Commit a43ede2

Browse files
committed
Add mock gateway server and e2e test for gateway integration
1 parent 6b9ed8d commit a43ede2

3 files changed

Lines changed: 334 additions & 17 deletions

File tree

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import {createServer, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse} from "node:http";
2+
import type {AddressInfo, Socket} from "node:net";
3+
4+
const LOOPBACK_HOST = "127.0.0.1";
5+
const CONTENT_TYPE_JSON = "application/json";
6+
const CONTENT_TYPE_SSE = "text/event-stream";
7+
8+
export interface CapturedRequest {
9+
readonly method: string;
10+
readonly url: string;
11+
readonly headers: IncomingHttpHeaders;
12+
readonly body: string;
13+
}
14+
15+
export interface MockGatewayServerOptions {
16+
readonly replyText?: string;
17+
}
18+
19+
export class MockGatewayServer {
20+
private readonly server: Server;
21+
private readonly capturedRequests: CapturedRequest[];
22+
private readonly openSockets: Set<Socket>;
23+
private readonly url: string;
24+
25+
private constructor(server: Server, url: string, capturedRequests: CapturedRequest[], openSockets: Set<Socket>) {
26+
this.server = server;
27+
this.url = url;
28+
this.capturedRequests = capturedRequests;
29+
this.openSockets = openSockets;
30+
}
31+
32+
static async start(options: MockGatewayServerOptions = {}): Promise<MockGatewayServer> {
33+
const capturedRequests: CapturedRequest[] = [];
34+
const openSockets = new Set<Socket>();
35+
36+
const server = createServer((req, res) => handleRequest(req, res, capturedRequests, options));
37+
server.on("connection", (socket) => {
38+
openSockets.add(socket);
39+
socket.once("close", () => openSockets.delete(socket));
40+
});
41+
42+
await new Promise<void>((resolve, reject) => {
43+
server.once("error", reject);
44+
server.listen(0, LOOPBACK_HOST, () => {
45+
server.off("error", reject);
46+
resolve();
47+
});
48+
});
49+
50+
const address = server.address() as AddressInfo;
51+
return new MockGatewayServer(server, `http://${LOOPBACK_HOST}:${address.port}`, capturedRequests, openSockets);
52+
}
53+
54+
get baseUrl(): string {
55+
return this.url;
56+
}
57+
58+
get requests(): readonly CapturedRequest[] {
59+
return this.capturedRequests;
60+
}
61+
62+
async stop(): Promise<void> {
63+
for (const socket of this.openSockets) {
64+
socket.destroy();
65+
}
66+
this.openSockets.clear();
67+
await new Promise<void>((resolve) => {
68+
this.server.close(() => resolve());
69+
});
70+
}
71+
}
72+
73+
function handleRequest(
74+
req: IncomingMessage,
75+
res: ServerResponse,
76+
requests: CapturedRequest[],
77+
options: MockGatewayServerOptions,
78+
): void {
79+
const chunks: Buffer[] = [];
80+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
81+
req.on("end", () => {
82+
requests.push({
83+
method: req.method ?? "",
84+
url: req.url ?? "",
85+
headers: req.headers,
86+
body: Buffer.concat(chunks).toString("utf8"),
87+
});
88+
89+
if (options.replyText !== undefined) {
90+
writeResponsesSseReply(res, options.replyText);
91+
} else {
92+
res.writeHead(500, {"Content-Type": CONTENT_TYPE_JSON});
93+
res.end('{"error":"mock gateway"}');
94+
}
95+
});
96+
}
97+
98+
function writeResponsesSseReply(res: ServerResponse, replyText: string): void {
99+
res.writeHead(200, {
100+
"Content-Type": CONTENT_TYPE_SSE,
101+
"Cache-Control": "no-cache",
102+
"Connection": "keep-alive",
103+
});
104+
105+
const responseId = "resp_mock";
106+
const messageId = "msg_mock";
107+
const createdAt = 1_700_000_000;
108+
const model = "test-model";
109+
const messageItem = {
110+
id: messageId,
111+
type: "message",
112+
role: "assistant",
113+
status: "completed",
114+
content: [{type: "output_text", text: replyText, annotations: []}],
115+
};
116+
117+
const events: Array<{event: string; data: Record<string, unknown>}> = [
118+
{
119+
event: "response.created",
120+
data: {
121+
type: "response.created",
122+
sequence_number: 1,
123+
response: {
124+
id: responseId,
125+
object: "response",
126+
status: "in_progress",
127+
created_at: createdAt,
128+
model,
129+
output: [],
130+
},
131+
},
132+
},
133+
{
134+
event: "response.in_progress",
135+
data: {
136+
type: "response.in_progress",
137+
sequence_number: 2,
138+
response: {id: responseId, status: "in_progress", output: []},
139+
},
140+
},
141+
{
142+
event: "response.output_item.added",
143+
data: {
144+
type: "response.output_item.added",
145+
sequence_number: 3,
146+
output_index: 0,
147+
item: {id: messageId, type: "message", role: "assistant", status: "in_progress", content: []},
148+
},
149+
},
150+
{
151+
event: "response.content_part.added",
152+
data: {
153+
type: "response.content_part.added",
154+
sequence_number: 4,
155+
item_id: messageId,
156+
output_index: 0,
157+
content_index: 0,
158+
part: {type: "output_text", text: "", annotations: []},
159+
},
160+
},
161+
{
162+
event: "response.output_text.delta",
163+
data: {
164+
type: "response.output_text.delta",
165+
sequence_number: 5,
166+
item_id: messageId,
167+
output_index: 0,
168+
content_index: 0,
169+
delta: replyText,
170+
},
171+
},
172+
{
173+
event: "response.output_text.done",
174+
data: {
175+
type: "response.output_text.done",
176+
sequence_number: 6,
177+
item_id: messageId,
178+
output_index: 0,
179+
content_index: 0,
180+
text: replyText,
181+
},
182+
},
183+
{
184+
event: "response.content_part.done",
185+
data: {
186+
type: "response.content_part.done",
187+
sequence_number: 7,
188+
item_id: messageId,
189+
output_index: 0,
190+
content_index: 0,
191+
part: {type: "output_text", text: replyText, annotations: []},
192+
},
193+
},
194+
{
195+
event: "response.output_item.done",
196+
data: {
197+
type: "response.output_item.done",
198+
sequence_number: 8,
199+
output_index: 0,
200+
item: messageItem,
201+
},
202+
},
203+
{
204+
event: "response.completed",
205+
data: {
206+
type: "response.completed",
207+
sequence_number: 9,
208+
response: {
209+
id: responseId,
210+
object: "response",
211+
status: "completed",
212+
created_at: createdAt,
213+
model,
214+
output: [messageItem],
215+
usage: {input_tokens: 1, output_tokens: 1, total_tokens: 2},
216+
},
217+
},
218+
},
219+
];
220+
221+
for (const {event, data} of events) {
222+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
223+
}
224+
res.end();
225+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {afterEach, expect, it} from "vitest";
2+
import {
3+
createGatewayFixture,
4+
describeE2E,
5+
type SpawnedAgentFixture,
6+
} from "./acp-e2e-test-utils";
7+
import {MockGatewayServer} from "./MockGatewayServer";
8+
9+
describeE2E("E2E gateway tests", () => {
10+
let fixture: SpawnedAgentFixture | null = null;
11+
let mockGateway: MockGatewayServer | null = null;
12+
13+
afterEach(async () => {
14+
if (fixture) {
15+
await fixture.dispose();
16+
fixture = null;
17+
}
18+
if (mockGateway) {
19+
await mockGateway.stop();
20+
mockGateway = null;
21+
}
22+
});
23+
24+
it('routes prompts to the configured gateway and it replies back', async () => {
25+
const promptText = "hello from tests";
26+
const replyText = "hello from gateway";
27+
const customHeaderName = "X-Test-Header";
28+
const customHeaderValue = "test-value";
29+
30+
mockGateway = await MockGatewayServer.start({replyText});
31+
fixture = await createGatewayFixture({
32+
baseUrl: mockGateway.baseUrl,
33+
headers: {[customHeaderName]: customHeaderValue},
34+
});
35+
const session = await fixture.createSession();
36+
37+
await session.expectPromptText(promptText, (text) => {
38+
expect(text).toContain(replyText);
39+
}, 1_000);
40+
41+
const request = mockGateway.requests[0];
42+
if (!request) throw new Error("Expected gateway to capture at least one request");
43+
expect(request.method).toBe("POST");
44+
expect(request.url).toMatch(/\/responses\b/);
45+
expect(request.headers["x-client-feature-id"]).toBe("codex");
46+
expect(request.headers[customHeaderName.toLowerCase()]).toBe(customHeaderValue);
47+
expect(request.headers["content-type"]).toMatch(/application\/json/);
48+
expect(request.body).toContain(promptText);
49+
});
50+
});

src/__tests__/CodexACPAgent/e2e/acp-e2e-test-utils.ts

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,64 @@ export async function createAuthenticatedFixture(
7171
runtimePaths = createTemporaryRuntimePaths()
7272
): Promise<SpawnedAgentFixture> {
7373
const apiKey = requireLiveApiKey();
74+
return await createSpawnedFixture(async (connection, authMethods) => {
75+
if (!authMethods.some((method) => method.id === "api-key")) {
76+
throw new Error("API key authentication is not available.");
77+
}
78+
79+
await connection.authenticate({
80+
methodId: "api-key",
81+
_meta: {
82+
"api-key": {
83+
apiKey,
84+
},
85+
},
86+
});
87+
88+
const authenticationStatus = await getAuthenticationStatus(connection);
89+
if (authenticationStatus["type"] !== "api-key") {
90+
throw new Error(`Unexpected authentication status: ${JSON.stringify(authenticationStatus)}`);
91+
}
92+
}, runtimePaths);
93+
}
94+
95+
export interface GatewayFixtureOptions {
96+
readonly baseUrl: string;
97+
readonly headers?: Record<string, string>;
98+
}
99+
100+
export async function createGatewayFixture(
101+
options: GatewayFixtureOptions,
102+
runtimePaths = createTemporaryRuntimePaths(),
103+
): Promise<SpawnedAgentFixture> {
104+
return await createSpawnedFixture(async (connection, authMethods) => {
105+
if (!authMethods.some((method) => method.id === "gateway")) {
106+
throw new Error("Gateway authentication is not available.");
107+
}
108+
109+
await connection.authenticate({
110+
methodId: "gateway",
111+
_meta: {
112+
gateway: {
113+
baseUrl: options.baseUrl,
114+
headers: options.headers ?? {},
115+
},
116+
},
117+
});
118+
119+
const authenticationStatus = await getAuthenticationStatus(connection);
120+
if (authenticationStatus["type"] !== "gateway" || authenticationStatus["name"] !== "custom-gateway") {
121+
throw new Error(`Unexpected authentication status: ${JSON.stringify(authenticationStatus)}`);
122+
}
123+
}, runtimePaths);
124+
}
125+
126+
type Authenticator = (connection: acp.ClientSideConnection, authMethods: acp.AuthMethod[]) => Promise<void>;
127+
128+
async function createSpawnedFixture(
129+
authenticate: Authenticator,
130+
runtimePaths: RuntimePaths,
131+
): Promise<SpawnedAgentFixture> {
74132
const agentProcess = spawn("npm", ["run", "--silent", "start"], {
75133
cwd: process.cwd(),
76134
env: {
@@ -101,23 +159,7 @@ export async function createAuthenticatedFixture(
101159
throw new Error(`Unexpected protocol version: ${initializeResponse.protocolVersion}`);
102160
}
103161

104-
if (!initializeResponse.authMethods?.some((method) => method.id === "api-key")) {
105-
throw new Error("API key authentication is not available.");
106-
}
107-
108-
await connection.authenticate({
109-
methodId: "api-key",
110-
_meta: {
111-
"api-key": {
112-
apiKey,
113-
},
114-
},
115-
});
116-
117-
const authenticationStatus = await getAuthenticationStatus(connection);
118-
if (authenticationStatus["type"] !== "api-key") {
119-
throw new Error(`Unexpected authentication status: ${JSON.stringify(authenticationStatus)}`);
120-
}
162+
await authenticate(connection, initializeResponse.authMethods ?? []);
121163

122164
const createSession = async (): Promise<SpawnedSessionFixture> => {
123165
const newSessionResponse = await connection.newSession({

0 commit comments

Comments
 (0)