Skip to content

Commit 88f4ec4

Browse files
committed
refactor
1 parent 00f11ab commit 88f4ec4

File tree

9 files changed

+604
-113
lines changed

9 files changed

+604
-113
lines changed
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
import {
2+
test,
3+
afterEach,
4+
expect,
5+
describe,
6+
setDefaultTimeout,
7+
beforeAll,
8+
} from "bun:test";
9+
import path from "path";
10+
import {
11+
execContainer,
12+
findResourceInstance,
13+
removeContainer,
14+
runContainer,
15+
runTerraformApply,
16+
runTerraformInit,
17+
writeCoder,
18+
writeFileContainer,
19+
} from "~test";
20+
21+
let cleanupFunctions: (() => Promise<void>)[] = [];
22+
23+
const registerCleanup = (cleanup: () => Promise<void>) => {
24+
cleanupFunctions.push(cleanup);
25+
};
26+
27+
// Cleanup logic depends on the fact that bun's built-in test runner
28+
// runs tests sequentially.
29+
// https://bun.sh/docs/test/discovery#execution-order
30+
// Weird things would happen if tried to run tests in parallel.
31+
// One test could clean up resources that another test was still using.
32+
afterEach(async () => {
33+
// reverse the cleanup functions so that they are run in the correct order
34+
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
35+
cleanupFunctions = [];
36+
for (const cleanup of cleanupFnsCopy) {
37+
try {
38+
await cleanup();
39+
} catch (error) {
40+
console.error("Error during cleanup:", error);
41+
}
42+
}
43+
});
44+
45+
const setupContainer = async ({
46+
image,
47+
vars,
48+
}: {
49+
image?: string;
50+
vars?: Record<string, string>;
51+
} = {}) => {
52+
const state = await runTerraformApply(import.meta.dir, {
53+
agent_id: "foo",
54+
...vars,
55+
});
56+
const coderScript = findResourceInstance(state, "coder_script");
57+
const id = await runContainer(image ?? "codercom/enterprise-node:latest");
58+
registerCleanup(() => removeContainer(id));
59+
return { id, coderScript };
60+
};
61+
62+
const loadTestFile = async (...relativePath: string[]) => {
63+
return await Bun.file(
64+
path.join(import.meta.dir, "testdata", ...relativePath),
65+
).text();
66+
};
67+
68+
const writeExecutable = async ({
69+
containerId,
70+
filePath,
71+
content,
72+
}: {
73+
containerId: string;
74+
filePath: string;
75+
content: string;
76+
}) => {
77+
await writeFileContainer(containerId, filePath, content, {
78+
user: "root",
79+
});
80+
await execContainer(
81+
containerId,
82+
["bash", "-c", `chmod 755 ${filePath}`],
83+
["--user", "root"],
84+
);
85+
};
86+
87+
const writeAgentAPIMockControl = async ({
88+
containerId,
89+
content,
90+
}: {
91+
containerId: string;
92+
content: string;
93+
}) => {
94+
await writeFileContainer(containerId, "/tmp/agentapi-mock.control", content, {
95+
user: "coder",
96+
});
97+
};
98+
99+
interface SetupProps {
100+
skipAgentAPIMock?: boolean;
101+
skipClaudeMock?: boolean;
102+
}
103+
104+
const projectDir = "/home/coder/project";
105+
106+
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
107+
const { id, coderScript } = await setupContainer({
108+
vars: {
109+
experiment_report_tasks: "true",
110+
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
111+
install_claude_code: "false",
112+
agentapi_version: "preview",
113+
folder: projectDir,
114+
},
115+
});
116+
await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]);
117+
// the module script assumes that there is a coder executable in the PATH
118+
await writeCoder(id, await loadTestFile("coder-mock.js"));
119+
if (!props?.skipAgentAPIMock) {
120+
await writeExecutable({
121+
containerId: id,
122+
filePath: "/usr/bin/agentapi",
123+
content: await loadTestFile("agentapi-mock.js"),
124+
});
125+
}
126+
if (!props?.skipClaudeMock) {
127+
await writeExecutable({
128+
containerId: id,
129+
filePath: "/usr/bin/claude",
130+
content: await loadTestFile("claude-mock.js"),
131+
});
132+
}
133+
await writeExecutable({
134+
containerId: id,
135+
filePath: "/home/coder/script.sh",
136+
content: coderScript.script,
137+
});
138+
return { id };
139+
};
140+
141+
const expectAgentAPIStarted = async (id: string) => {
142+
const resp = await execContainer(id, [
143+
"bash",
144+
"-c",
145+
`curl -fs -o /dev/null "http://localhost:3284/status"`,
146+
]);
147+
if (resp.exitCode !== 0) {
148+
console.log("agentapi not started");
149+
console.log(resp.stdout);
150+
console.log(resp.stderr);
151+
}
152+
expect(resp.exitCode).toBe(0);
153+
};
154+
155+
const execModuleScript = async (id: string) => {
156+
const resp = await execContainer(id, [
157+
"bash",
158+
"-c",
159+
`set -o errexit; set -o pipefail; cd /home/coder && ./script.sh 2>&1 | tee /home/coder/script.log`,
160+
]);
161+
if (resp.exitCode !== 0) {
162+
console.log(resp.stdout);
163+
console.log(resp.stderr);
164+
}
165+
return resp;
166+
};
167+
168+
// increase the default timeout to 60 seconds
169+
setDefaultTimeout(60 * 1000);
170+
171+
// we don't run these tests in CI because they take too long and make network
172+
// calls. they are dedicated for local development.
173+
describe("claude-code", async () => {
174+
beforeAll(async () => {
175+
await runTerraformInit(import.meta.dir);
176+
});
177+
178+
// test that the script runs successfully if claude starts without any errors
179+
test("happy-path", async () => {
180+
const { id } = await setup();
181+
182+
const resp = await execContainer(id, [
183+
"bash",
184+
"-c",
185+
"sudo /home/coder/script.sh",
186+
]);
187+
expect(resp.exitCode).toBe(0);
188+
189+
await expectAgentAPIStarted(id);
190+
});
191+
192+
// test that the script removes lastSessionId from the .claude.json file
193+
test("last-session-id-removed", async () => {
194+
const { id } = await setup();
195+
196+
await writeFileContainer(
197+
id,
198+
"/home/coder/.claude.json",
199+
JSON.stringify({
200+
projects: {
201+
[projectDir]: {
202+
lastSessionId: "123",
203+
},
204+
},
205+
}),
206+
);
207+
208+
const catResp = await execContainer(id, [
209+
"bash",
210+
"-c",
211+
"cat /home/coder/.claude.json",
212+
]);
213+
expect(catResp.exitCode).toBe(0);
214+
expect(catResp.stdout).toContain("lastSessionId");
215+
216+
const respModuleScript = await execModuleScript(id);
217+
expect(respModuleScript.exitCode).toBe(0);
218+
219+
await expectAgentAPIStarted(id);
220+
221+
const catResp2 = await execContainer(id, [
222+
"bash",
223+
"-c",
224+
"cat /home/coder/.claude.json",
225+
]);
226+
expect(catResp2.exitCode).toBe(0);
227+
expect(catResp2.stdout).not.toContain("lastSessionId");
228+
});
229+
230+
// test that the script handles a .claude.json file that doesn't contain
231+
// a lastSessionId field
232+
test("last-session-id-not-found", async () => {
233+
const { id } = await setup();
234+
235+
await writeFileContainer(
236+
id,
237+
"/home/coder/.claude.json",
238+
JSON.stringify({
239+
projects: {
240+
"/home/coder": {},
241+
},
242+
}),
243+
);
244+
245+
const respModuleScript = await execModuleScript(id);
246+
expect(respModuleScript.exitCode).toBe(0);
247+
248+
await expectAgentAPIStarted(id);
249+
250+
const catResp = await execContainer(id, [
251+
"bash",
252+
"-c",
253+
"cat /home/coder/.claude-module/agentapi-start.log",
254+
]);
255+
expect(catResp.exitCode).toBe(0);
256+
expect(catResp.stdout).toContain(
257+
"No lastSessionId found in .claude.json - nothing to do",
258+
);
259+
});
260+
261+
// test that if claude fails to run with the --continue flag and returns a
262+
// no conversation found error, then the module script retries without the flag
263+
test("no-conversation-found", async () => {
264+
const { id } = await setup();
265+
await writeAgentAPIMockControl({
266+
containerId: id,
267+
content: "no-conversation-found",
268+
});
269+
// check that mocking works
270+
const respAgentAPI = await execContainer(id, [
271+
"bash",
272+
"-c",
273+
"agentapi --continue",
274+
]);
275+
expect(respAgentAPI.exitCode).toBe(1);
276+
expect(respAgentAPI.stderr).toContain("No conversation found to continue");
277+
278+
const respModuleScript = await execModuleScript(id);
279+
expect(respModuleScript.exitCode).toBe(0);
280+
281+
await expectAgentAPIStarted(id);
282+
});
283+
284+
test("install-agentapi", async () => {
285+
const { id } = await setup({ skipAgentAPIMock: true });
286+
287+
const respModuleScript = await execModuleScript(id);
288+
expect(respModuleScript.exitCode).toBe(0);
289+
290+
await expectAgentAPIStarted(id);
291+
const respAgentAPI = await execContainer(id, [
292+
"bash",
293+
"-c",
294+
"agentapi --version",
295+
]);
296+
expect(respAgentAPI.exitCode).toBe(0);
297+
});
298+
299+
// the coder binary should be executed with specific env vars
300+
// that are set by the module script
301+
test("coder-env-vars", async () => {
302+
const { id } = await setup();
303+
304+
const respModuleScript = await execModuleScript(id);
305+
expect(respModuleScript.exitCode).toBe(0);
306+
307+
const respCoderMock = await execContainer(id, [
308+
"bash",
309+
"-c",
310+
"cat /home/coder/coder-mock-output.json",
311+
]);
312+
if (respCoderMock.exitCode !== 0) {
313+
console.log(respCoderMock.stdout);
314+
console.log(respCoderMock.stderr);
315+
}
316+
expect(respCoderMock.exitCode).toBe(0);
317+
expect(JSON.parse(respCoderMock.stdout)).toEqual({
318+
statusSlug: "ccw",
319+
agentApiUrl: "http://localhost:3284",
320+
});
321+
});
322+
});

0 commit comments

Comments
 (0)