Skip to content

Commit 5956348

Browse files
committed
🤖 feat: add mux-server integration tests
Add comprehensive integration tests for the mux-server HTTP/WebSocket API: - Health and version endpoint tests - Authentication tests (bearer token for HTTP, query param for WebSocket) - IPC channel routing tests (project:list, providers:list, etc.) - WebSocket subscription tests (workspace:metadata, workspace:activity) - --add-project CLI flag test Tests use real server instances with isolated MUX_ROOT directories. Each test suite gets its own server on a random port. Test infrastructure in serverTestUtils.ts provides: - Server lifecycle management (start/stop) - Test directory setup with optional git repo - IPC request helpers - WebSocket connection and message helpers Run with: TEST_INTEGRATION=1 bun x jest tests/server/server.test.ts _Generated with mux_
1 parent 8f162fb commit 5956348

File tree

3 files changed

+649
-0
lines changed

3 files changed

+649
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,4 @@ test_hot_reload.sh
123123
docs/theme/pagetoc.css
124124
docs/theme/pagetoc.js
125125
mobile/.expo/
126+
tests/server/tmp/

tests/server/server.test.ts

Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
1+
/**
2+
* Integration tests for mux-server HTTP/WebSocket functionality
3+
*
4+
* These tests spin up actual server instances and verify:
5+
* - Health check endpoint
6+
* - Authentication (when configured)
7+
* - IPC channel routing
8+
* - WebSocket connections and subscriptions
9+
* - Project listing/management
10+
*/
11+
// Uses Jest globals injected by test runner (see jest.config.js)
12+
import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants";
13+
import {
14+
type ServerTestContext,
15+
startServer,
16+
stopServer,
17+
prepareTestMuxRoot,
18+
prepareTestMuxRootWithProject,
19+
cleanupTestMuxRoot,
20+
ipcRequest,
21+
createWebSocket,
22+
waitForWsOpen,
23+
} from "./serverTestUtils";
24+
25+
// Each test gets a unique ID to avoid directory conflicts
26+
let testCounter = 0;
27+
function getTestId(): string {
28+
return `server-test-${Date.now()}-${++testCounter}`;
29+
}
30+
31+
describe("mux-server", () => {
32+
describe("health endpoint", () => {
33+
let ctx: ServerTestContext;
34+
let muxRoot: string;
35+
36+
beforeAll(async () => {
37+
muxRoot = prepareTestMuxRoot(getTestId());
38+
ctx = await startServer({ muxRoot });
39+
});
40+
41+
afterAll(async () => {
42+
await stopServer(ctx);
43+
cleanupTestMuxRoot(muxRoot);
44+
});
45+
46+
test("returns 200 OK", async () => {
47+
const response = await fetch(`${ctx.baseUrl}/health`);
48+
expect(response.ok).toBe(true);
49+
});
50+
51+
test("returns status ok", async () => {
52+
const response = await fetch(`${ctx.baseUrl}/health`);
53+
const data = await response.json();
54+
expect(data).toEqual({ status: "ok" });
55+
});
56+
});
57+
58+
describe("version endpoint", () => {
59+
let ctx: ServerTestContext;
60+
let muxRoot: string;
61+
62+
beforeAll(async () => {
63+
muxRoot = prepareTestMuxRoot(getTestId());
64+
ctx = await startServer({ muxRoot });
65+
});
66+
67+
afterAll(async () => {
68+
await stopServer(ctx);
69+
cleanupTestMuxRoot(muxRoot);
70+
});
71+
72+
test("returns version info", async () => {
73+
const response = await fetch(`${ctx.baseUrl}/version`);
74+
expect(response.ok).toBe(true);
75+
const data = await response.json();
76+
// Version info has git_describe, git_commit, buildTime, mode
77+
expect(data).toHaveProperty("git_describe");
78+
expect(data).toHaveProperty("mode", "server");
79+
});
80+
});
81+
82+
describe("authentication", () => {
83+
const AUTH_TOKEN = "test-secret-token-12345";
84+
let ctx: ServerTestContext;
85+
let muxRoot: string;
86+
87+
beforeAll(async () => {
88+
muxRoot = prepareTestMuxRoot(getTestId());
89+
ctx = await startServer({ muxRoot, authToken: AUTH_TOKEN });
90+
});
91+
92+
afterAll(async () => {
93+
await stopServer(ctx);
94+
cleanupTestMuxRoot(muxRoot);
95+
});
96+
97+
test("health endpoint is public (no auth required)", async () => {
98+
const response = await fetch(`${ctx.baseUrl}/health`);
99+
expect(response.ok).toBe(true);
100+
});
101+
102+
test("IPC endpoint rejects requests without auth", async () => {
103+
const channel = encodeURIComponent(IPC_CHANNELS.PROJECT_LIST);
104+
const response = await fetch(`${ctx.baseUrl}/ipc/${channel}`, {
105+
method: "POST",
106+
headers: { "Content-Type": "application/json" },
107+
body: JSON.stringify({ args: [] }),
108+
});
109+
expect(response.status).toBe(401);
110+
});
111+
112+
test("IPC endpoint rejects requests with wrong auth", async () => {
113+
const channel = encodeURIComponent(IPC_CHANNELS.PROJECT_LIST);
114+
const response = await fetch(`${ctx.baseUrl}/ipc/${channel}`, {
115+
method: "POST",
116+
headers: {
117+
"Content-Type": "application/json",
118+
Authorization: "Bearer wrong-token",
119+
},
120+
body: JSON.stringify({ args: [] }),
121+
});
122+
expect(response.status).toBe(401);
123+
});
124+
125+
test("IPC endpoint accepts requests with correct auth", async () => {
126+
const channel = encodeURIComponent(IPC_CHANNELS.PROJECT_LIST);
127+
const response = await fetch(`${ctx.baseUrl}/ipc/${channel}`, {
128+
method: "POST",
129+
headers: {
130+
"Content-Type": "application/json",
131+
Authorization: `Bearer ${AUTH_TOKEN}`,
132+
},
133+
body: JSON.stringify({ args: [] }),
134+
});
135+
expect(response.ok).toBe(true);
136+
});
137+
138+
test("WebSocket rejects connection without auth", async () => {
139+
const ws = createWebSocket({ ...ctx, authToken: undefined });
140+
// Server accepts the connection but immediately closes with 1008 (Policy Violation)
141+
await waitForWsOpen(ws);
142+
const closePromise = new Promise<number>((resolve) => {
143+
ws.on("close", (code) => resolve(code));
144+
});
145+
const closeCode = await closePromise;
146+
expect(closeCode).toBe(1008); // Policy Violation = Unauthorized
147+
});
148+
149+
test("WebSocket accepts connection with correct auth", async () => {
150+
const ws = createWebSocket(ctx);
151+
await waitForWsOpen(ws);
152+
expect(ws.readyState).toBe(ws.OPEN);
153+
ws.close();
154+
});
155+
});
156+
157+
describe("IPC channels", () => {
158+
let ctx: ServerTestContext;
159+
let muxRoot: string;
160+
161+
beforeAll(async () => {
162+
muxRoot = prepareTestMuxRoot(getTestId());
163+
ctx = await startServer({ muxRoot });
164+
});
165+
166+
afterAll(async () => {
167+
await stopServer(ctx);
168+
cleanupTestMuxRoot(muxRoot);
169+
});
170+
171+
test("project:list returns empty array for fresh config", async () => {
172+
const result = await ipcRequest(ctx, IPC_CHANNELS.PROJECT_LIST);
173+
expect(result.success).toBe(true);
174+
expect(Array.isArray(result.data)).toBe(true);
175+
expect(result.data).toHaveLength(0);
176+
});
177+
178+
test("providers:getConfig returns provider configuration", async () => {
179+
const result = await ipcRequest(ctx, IPC_CHANNELS.PROVIDERS_GET_CONFIG);
180+
expect(result.success).toBe(true);
181+
expect(result.data).toBeDefined();
182+
});
183+
184+
test("providers:list returns available providers", async () => {
185+
const result = await ipcRequest(ctx, IPC_CHANNELS.PROVIDERS_LIST);
186+
expect(result.success).toBe(true);
187+
expect(Array.isArray(result.data)).toBe(true);
188+
// Should have at least anthropic provider
189+
const providers = result.data as string[];
190+
expect(providers).toContain("anthropic");
191+
});
192+
193+
test("unknown IPC channel returns 404", async () => {
194+
const response = await fetch(`${ctx.baseUrl}/ipc/unknown:channel`, {
195+
method: "POST",
196+
headers: { "Content-Type": "application/json" },
197+
body: JSON.stringify({ args: [] }),
198+
});
199+
expect(response.status).toBe(404);
200+
});
201+
});
202+
203+
describe("project operations with git repo", () => {
204+
let ctx: ServerTestContext;
205+
let muxRoot: string;
206+
let projectPath: string;
207+
208+
beforeAll(async () => {
209+
const setup = await prepareTestMuxRootWithProject(getTestId());
210+
muxRoot = setup.muxRoot;
211+
projectPath = setup.projectPath;
212+
ctx = await startServer({ muxRoot });
213+
});
214+
215+
afterAll(async () => {
216+
await stopServer(ctx);
217+
cleanupTestMuxRoot(muxRoot);
218+
});
219+
220+
test("project:list returns configured project", async () => {
221+
const result = await ipcRequest(ctx, IPC_CHANNELS.PROJECT_LIST);
222+
expect(result.success).toBe(true);
223+
const projects = result.data as Array<[string, unknown]>;
224+
expect(projects.length).toBe(1);
225+
expect(projects[0][0]).toBe(projectPath);
226+
});
227+
228+
test("project:listBranches returns branches for project", async () => {
229+
const result = await ipcRequest(ctx, IPC_CHANNELS.PROJECT_LIST_BRANCHES, [projectPath]);
230+
expect(result.success).toBe(true);
231+
// Returns { branches: string[], recommendedTrunk: string }
232+
const data = result.data as { branches: string[]; recommendedTrunk: string };
233+
expect(Array.isArray(data.branches)).toBe(true);
234+
expect(data.branches.length).toBeGreaterThan(0);
235+
expect(typeof data.recommendedTrunk).toBe("string");
236+
});
237+
});
238+
239+
describe("WebSocket subscriptions", () => {
240+
let ctx: ServerTestContext;
241+
let muxRoot: string;
242+
243+
beforeAll(async () => {
244+
muxRoot = prepareTestMuxRoot(getTestId());
245+
ctx = await startServer({ muxRoot });
246+
});
247+
248+
afterAll(async () => {
249+
await stopServer(ctx);
250+
cleanupTestMuxRoot(muxRoot);
251+
});
252+
253+
test("can subscribe to workspace:metadata", async () => {
254+
const ws = createWebSocket(ctx);
255+
await waitForWsOpen(ws);
256+
257+
// Send subscribe message
258+
ws.send(
259+
JSON.stringify({
260+
type: "subscribe",
261+
channel: "workspace:metadata",
262+
})
263+
);
264+
265+
// Give server time to process subscription
266+
await new Promise((resolve) => setTimeout(resolve, 100));
267+
268+
// Connection should still be open (no errors)
269+
expect(ws.readyState).toBe(ws.OPEN);
270+
ws.close();
271+
});
272+
273+
test("can subscribe to workspace:activity", async () => {
274+
const ws = createWebSocket(ctx);
275+
await waitForWsOpen(ws);
276+
277+
ws.send(
278+
JSON.stringify({
279+
type: "subscribe",
280+
channel: "workspace:activity",
281+
})
282+
);
283+
284+
await new Promise((resolve) => setTimeout(resolve, 100));
285+
expect(ws.readyState).toBe(ws.OPEN);
286+
ws.close();
287+
});
288+
289+
test("can unsubscribe from channels", async () => {
290+
const ws = createWebSocket(ctx);
291+
await waitForWsOpen(ws);
292+
293+
// Subscribe
294+
ws.send(
295+
JSON.stringify({
296+
type: "subscribe",
297+
channel: "workspace:metadata",
298+
})
299+
);
300+
301+
await new Promise((resolve) => setTimeout(resolve, 50));
302+
303+
// Unsubscribe
304+
ws.send(
305+
JSON.stringify({
306+
type: "unsubscribe",
307+
channel: "workspace:metadata",
308+
})
309+
);
310+
311+
await new Promise((resolve) => setTimeout(resolve, 100));
312+
expect(ws.readyState).toBe(ws.OPEN);
313+
ws.close();
314+
});
315+
});
316+
317+
describe("--add-project flag", () => {
318+
test("creates project from git repository path", async () => {
319+
const testId = getTestId();
320+
const setup = await prepareTestMuxRootWithProject(testId);
321+
322+
// Start server with --add-project pointing to a new git repo
323+
// The setup already created one, but let's create another one for this test
324+
const { execSync } = await import("child_process");
325+
const fs = await import("fs");
326+
const path = await import("path");
327+
328+
const newProjectPath = path.join(setup.muxRoot, "fixtures", "added-project");
329+
fs.mkdirSync(newProjectPath, { recursive: true });
330+
execSync("git init", { cwd: newProjectPath, stdio: "ignore" });
331+
execSync("git config user.email test@test.com", { cwd: newProjectPath, stdio: "ignore" });
332+
execSync("git config user.name Test", { cwd: newProjectPath, stdio: "ignore" });
333+
fs.writeFileSync(path.join(newProjectPath, "README.md"), "# Added Project\n");
334+
execSync("git add .", { cwd: newProjectPath, stdio: "ignore" });
335+
execSync('git commit -m "initial"', { cwd: newProjectPath, stdio: "ignore" });
336+
337+
// Reset config to empty
338+
fs.writeFileSync(
339+
path.join(setup.muxRoot, "config.json"),
340+
JSON.stringify({ projects: [] }, null, 2)
341+
);
342+
343+
const ctx = await startServer({
344+
muxRoot: setup.muxRoot,
345+
addProject: newProjectPath,
346+
});
347+
348+
try {
349+
// Wait a bit for project creation to complete
350+
await new Promise((resolve) => setTimeout(resolve, 500));
351+
352+
// Verify project was added
353+
const result = await ipcRequest(ctx, IPC_CHANNELS.PROJECT_LIST);
354+
expect(result.success).toBe(true);
355+
const projects = result.data as Array<[string, unknown]>;
356+
expect(projects.length).toBe(1);
357+
expect(projects[0][0]).toBe(newProjectPath);
358+
} finally {
359+
await stopServer(ctx);
360+
cleanupTestMuxRoot(setup.muxRoot);
361+
}
362+
});
363+
});
364+
});

0 commit comments

Comments
 (0)