Skip to content

Commit 0b01478

Browse files
xesrevinuGit Agent
andcommitted
test(add integration test helpers)
- Add helper functions for file system operations with Effect - Implement mock LLM and gitignore HTTP servers for testing - Provide reusable functions to create git and jj repositories - Develop utilities to run CLI commands in isolated environment - Include utilities for reading, writing, and checking files - Add functions for committing changes with git and jj This commit adds a comprehensive suite of integration test helpers to the tests scope. It introduces utilities for managing temporary directories, file system I/O, and running git and jj commands. Mock servers simulate external services such as LLM APIs and gitignore template servers for controlled testing scenarios. Additional helpers enable executing the CLI in isolated environments. These additions facilitate writing robust integration tests by providing common reusable tools and mocks. Co-Authored-By: Git Agent <noreply@git-agent.dev>
1 parent bcd7010 commit 0b01478

1 file changed

Lines changed: 387 additions & 0 deletions

File tree

tests/integration/helpers.ts

Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
import { Effect, FileSystem, Path } from "effect";
2+
import { createServer, type IncomingMessage, type Server } from "node:http";
3+
import { chmod, mkdtemp, rm } from "node:fs/promises";
4+
import { tmpdir } from "node:os";
5+
import { dirname, join } from "node:path";
6+
import { fileURLToPath } from "node:url";
7+
import { runProcess, type ProcessResult } from "../../src/shared/process";
8+
9+
const cliEntry = fileURLToPath(new URL("../../src/cli.ts", import.meta.url));
10+
11+
export interface CliOptions {
12+
readonly cwd: string;
13+
readonly env?: Record<string, string | undefined>;
14+
readonly allowFailure?: boolean;
15+
}
16+
17+
export interface MockLlmRequest {
18+
readonly path: string;
19+
readonly model: string;
20+
readonly systemPrompt: string;
21+
readonly userPrompt: string;
22+
}
23+
24+
export interface MockLlmResponse {
25+
readonly content:
26+
| string
27+
| Record<string, unknown>
28+
| ((request: MockLlmRequest) => string | Record<string, unknown>);
29+
}
30+
31+
export interface MockLlmServer {
32+
readonly baseUrl: string;
33+
readonly requests: Array<MockLlmRequest>;
34+
readonly remainingResponses: () => number;
35+
}
36+
37+
export interface MockGitignoreServer {
38+
readonly baseUrl: string;
39+
readonly requests: Array<string>;
40+
}
41+
42+
const closeServer = (server: Server) =>
43+
Effect.promise(
44+
() =>
45+
new Promise<void>((resolve, reject) => {
46+
server.close((error) => {
47+
if (error != null) {
48+
reject(error);
49+
return;
50+
}
51+
resolve();
52+
});
53+
}),
54+
).pipe(Effect.catchDefect(() => Effect.void));
55+
56+
const listenServer = (server: Server) =>
57+
Effect.promise(
58+
() =>
59+
new Promise<number>((resolve, reject) => {
60+
const onError = (error: Error) => {
61+
server.off("error", onError);
62+
reject(error);
63+
};
64+
server.on("error", onError);
65+
server.listen(0, "127.0.0.1", () => {
66+
server.off("error", onError);
67+
const address = server.address();
68+
if (address == null || typeof address === "string") {
69+
reject(new Error("mock llm server did not expose a tcp port"));
70+
return;
71+
}
72+
resolve(address.port);
73+
});
74+
}),
75+
);
76+
77+
const readRequestBody = (request: IncomingMessage) =>
78+
new Promise<string>((resolve, reject) => {
79+
request.setEncoding("utf8");
80+
let body = "";
81+
request.on("data", (chunk: string) => {
82+
body += chunk;
83+
});
84+
request.on("end", () => resolve(body));
85+
request.on("error", reject);
86+
});
87+
88+
const readInputText = (content: unknown): string => {
89+
if (typeof content === "string") {
90+
return content;
91+
}
92+
if (!Array.isArray(content)) {
93+
return "";
94+
}
95+
return content
96+
.map((part) =>
97+
typeof part === "object" && part != null && "text" in part && typeof part.text === "string"
98+
? part.text
99+
: "",
100+
)
101+
.join("");
102+
};
103+
104+
const removeDirectory = (dir: string) =>
105+
Effect.promise(() => rm(dir, { recursive: true, force: true })).pipe(
106+
Effect.catchDefect(() => Effect.void),
107+
);
108+
109+
const createTempDir = (prefix: string) =>
110+
Effect.acquireRelease(
111+
Effect.promise(() => mkdtemp(join(tmpdir(), prefix))),
112+
removeDirectory,
113+
);
114+
115+
const run = (command: string, args: ReadonlyArray<string>, cwd: string, allowFailure = false) =>
116+
runProcess({
117+
command,
118+
args,
119+
cwd,
120+
allowFailure,
121+
});
122+
123+
export const writeTextFile = Effect.fn(function* (
124+
root: string,
125+
relativePath: string,
126+
content: string,
127+
mode = 420,
128+
) {
129+
const fs = yield* FileSystem.FileSystem;
130+
const path = yield* Path.Path;
131+
const fullPath = path.join(root, relativePath);
132+
yield* fs.makeDirectory(path.dirname(fullPath), { recursive: true });
133+
yield* fs.writeFileString(fullPath, content, { mode });
134+
return fullPath;
135+
});
136+
137+
export const readTextFile = Effect.fn(function* (root: string, relativePath: string) {
138+
const fs = yield* FileSystem.FileSystem;
139+
const path = yield* Path.Path;
140+
return yield* fs.readFileString(path.join(root, relativePath), "utf8");
141+
});
142+
143+
export const fileExists = Effect.fn(function* (root: string, relativePath: string) {
144+
const fs = yield* FileSystem.FileSystem;
145+
const path = yield* Path.Path;
146+
return yield* fs.exists(path.join(root, relativePath));
147+
});
148+
149+
export const chmodFile = (pathValue: string, mode: number) =>
150+
Effect.promise(() => chmod(pathValue, mode));
151+
152+
export const createGitRepo = Effect.fn(function* () {
153+
const fs = yield* FileSystem.FileSystem;
154+
const path = yield* Path.Path;
155+
const root = yield* createTempDir("git-agent-git-");
156+
const dir = path.join(root, "repo");
157+
yield* fs.makeDirectory(dir, { recursive: true });
158+
yield* run("git", ["init"], dir);
159+
yield* run("git", ["config", "user.name", "Git Agent Test"], dir);
160+
yield* run("git", ["config", "user.email", "git-agent@example.com"], dir);
161+
return dir;
162+
});
163+
164+
export const createJjRepo = Effect.fn(function* () {
165+
const fs = yield* FileSystem.FileSystem;
166+
const path = yield* Path.Path;
167+
const root = yield* createTempDir("git-agent-jj-");
168+
const dir = path.join(root, "repo");
169+
yield* fs.makeDirectory(dir, { recursive: true });
170+
yield* run("jj", ["git", "init", "."], dir);
171+
yield* run("jj", ["config", "set", "--repo", "user.name", "Git Agent Test"], dir);
172+
yield* run("jj", ["config", "set", "--repo", "user.email", "git-agent@example.com"], dir);
173+
return dir;
174+
});
175+
176+
export const git = (
177+
cwd: string,
178+
args: ReadonlyArray<string>,
179+
allowFailure = false,
180+
): Effect.Effect<ProcessResult, unknown, any> => run("git", args, cwd, allowFailure);
181+
182+
export const jj = (
183+
cwd: string,
184+
args: ReadonlyArray<string>,
185+
allowFailure = false,
186+
): Effect.Effect<ProcessResult, unknown, any> => run("jj", args, cwd, allowFailure);
187+
188+
export const gitCommitAll = Effect.fn(function* (cwd: string, message: string) {
189+
yield* git(cwd, ["add", "-A"]);
190+
yield* git(cwd, ["commit", "-m", message]);
191+
});
192+
193+
export const jjCommitAll = (cwd: string, message: string, files: ReadonlyArray<string>) =>
194+
jj(cwd, ["commit", "-m", message, ...files]).pipe(Effect.asVoid);
195+
196+
export const runCli = (args: ReadonlyArray<string>, options: CliOptions) => {
197+
const isolatedConfigHome = join(dirname(options.cwd), ".xdg");
198+
return runProcess({
199+
command: "bun",
200+
args: [cliEntry, ...args],
201+
cwd: options.cwd,
202+
allowFailure: options.allowFailure ?? true,
203+
env: {
204+
PWD: options.cwd,
205+
XDG_CONFIG_HOME: isolatedConfigHome,
206+
OPENAI_COMPACT_API_KEY: undefined,
207+
OPENAI_COMPACT_API_BASE_URL: undefined,
208+
OPENAI_COMPACT_MODEL: undefined,
209+
GIT_AGENT_BUILD_API_KEY: undefined,
210+
GIT_AGENT_BUILD_BASE_URL: undefined,
211+
GIT_AGENT_BUILD_MODEL: undefined,
212+
...options.env,
213+
},
214+
});
215+
};
216+
217+
export const startMockLlmServer = (responses: ReadonlyArray<MockLlmResponse>) =>
218+
Effect.acquireRelease(
219+
Effect.gen(function* () {
220+
const requests: Array<MockLlmRequest> = [];
221+
let index = 0;
222+
223+
const server = createServer(async (request, response) => {
224+
if (
225+
request.method !== "POST" ||
226+
request.url == null ||
227+
!request.url.endsWith("/responses")
228+
) {
229+
response.writeHead(404, { "content-type": "application/json" });
230+
response.end(JSON.stringify({ error: { message: "not found" } }));
231+
return;
232+
}
233+
234+
const rawBody = await readRequestBody(request);
235+
const parsed = JSON.parse(rawBody) as {
236+
model?: string;
237+
input?: Array<{ role?: string; content?: unknown }>;
238+
tool_choice?: unknown;
239+
temperature?: number | null;
240+
top_p?: number | null;
241+
};
242+
const input = Array.isArray(parsed.input) ? parsed.input : [];
243+
const requestInfo = {
244+
path: request.url,
245+
model: typeof parsed.model === "string" ? parsed.model : "",
246+
systemPrompt: readInputText(
247+
input.find((message) => message.role === "system" || message.role === "developer")
248+
?.content,
249+
),
250+
userPrompt: readInputText(input.find((message) => message.role === "user")?.content),
251+
} satisfies MockLlmRequest;
252+
requests.push(requestInfo);
253+
254+
const next = responses[index];
255+
index += 1;
256+
257+
if (next == null) {
258+
response.writeHead(500, { "content-type": "application/json" });
259+
response.end(JSON.stringify({ error: { message: "no mock response configured" } }));
260+
return;
261+
}
262+
263+
const content =
264+
typeof next.content === "function" ? next.content(requestInfo) : next.content;
265+
response.writeHead(200, { "content-type": "application/json" });
266+
const text = typeof content === "string" ? content : JSON.stringify(content);
267+
response.end(
268+
JSON.stringify({
269+
id: `resp_${index}`,
270+
object: "response",
271+
created_at: Math.floor(Date.now() / 1000),
272+
status: "completed",
273+
error: null,
274+
incomplete_details: null,
275+
instructions: null,
276+
metadata: null,
277+
model: typeof parsed.model === "string" ? parsed.model : "test-model",
278+
temperature: parsed.temperature ?? null,
279+
top_p: parsed.top_p ?? null,
280+
tools: [],
281+
tool_choice:
282+
parsed.tool_choice === "auto" ||
283+
parsed.tool_choice === "required" ||
284+
parsed.tool_choice === "none"
285+
? parsed.tool_choice
286+
: "none",
287+
input,
288+
output: [
289+
{
290+
id: `msg_${index}`,
291+
type: "message",
292+
role: "assistant",
293+
status: "completed",
294+
content: [
295+
{
296+
type: "output_text",
297+
text,
298+
annotations: [],
299+
logprobs: [],
300+
},
301+
],
302+
},
303+
],
304+
output_text: text,
305+
usage: {
306+
input_tokens: 0,
307+
input_tokens_details: {
308+
cached_tokens: 0,
309+
},
310+
output_tokens: 0,
311+
output_tokens_details: {
312+
reasoning_tokens: 0,
313+
},
314+
total_tokens: 0,
315+
},
316+
parallel_tool_calls: false,
317+
}),
318+
);
319+
});
320+
321+
const port = yield* listenServer(server);
322+
return {
323+
baseUrl: `http://127.0.0.1:${port}/v1`,
324+
requests,
325+
remainingResponses: () => responses.length - index,
326+
server,
327+
};
328+
}),
329+
({ server }) => closeServer(server),
330+
).pipe(
331+
Effect.map(({ baseUrl, requests, remainingResponses }) => ({
332+
baseUrl,
333+
requests,
334+
remainingResponses,
335+
})),
336+
);
337+
338+
export const startMockGitignoreServer = (templates: Record<string, string>) =>
339+
Effect.acquireRelease(
340+
Effect.gen(function* () {
341+
const requests: Array<string> = [];
342+
const server = createServer((request, response) => {
343+
const url = request.url ?? "/";
344+
requests.push(url);
345+
const key = url.replace(/^\//, "");
346+
const template = templates[key];
347+
348+
if (template == null) {
349+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
350+
response.end("missing template");
351+
return;
352+
}
353+
354+
response.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
355+
response.end(template);
356+
});
357+
358+
const port = yield* listenServer(server);
359+
return {
360+
baseUrl: `http://127.0.0.1:${port}`,
361+
requests,
362+
server,
363+
};
364+
}),
365+
({ server }) => closeServer(server),
366+
).pipe(
367+
Effect.map(({ baseUrl, requests }) => ({
368+
baseUrl,
369+
requests,
370+
})),
371+
);
372+
373+
export const projectScopesConfig = (scopes: ReadonlyArray<readonly [string, string?]>) =>
374+
[
375+
"scopes:",
376+
...scopes.flatMap(([name, description]) =>
377+
description == null
378+
? [` - name: ${name}`]
379+
: [` - name: ${name}`, ` description: ${description}`],
380+
),
381+
].join("\n") + "\n";
382+
383+
export const trimmedLines = (text: string) =>
384+
text
385+
.split("\n")
386+
.map((line) => line.trim())
387+
.filter((line) => line.length > 0);

0 commit comments

Comments
 (0)