From 5956348cf065e7a008c3271acc8702fbc95f0ff2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 3 Dec 2025 07:35:06 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20mux-server=20i?= =?UTF-8?q?ntegration=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_ --- .gitignore | 1 + tests/server/server.test.ts | 364 ++++++++++++++++++++++++++++++++ tests/server/serverTestUtils.ts | 284 +++++++++++++++++++++++++ 3 files changed, 649 insertions(+) create mode 100644 tests/server/server.test.ts create mode 100644 tests/server/serverTestUtils.ts diff --git a/.gitignore b/.gitignore index 3ac5274b06..30ddd3a11a 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,4 @@ test_hot_reload.sh docs/theme/pagetoc.css docs/theme/pagetoc.js mobile/.expo/ +tests/server/tmp/ diff --git a/tests/server/server.test.ts b/tests/server/server.test.ts new file mode 100644 index 0000000000..28eabd715d --- /dev/null +++ b/tests/server/server.test.ts @@ -0,0 +1,364 @@ +/** + * Integration tests for mux-server HTTP/WebSocket functionality + * + * These tests spin up actual server instances and verify: + * - Health check endpoint + * - Authentication (when configured) + * - IPC channel routing + * - WebSocket connections and subscriptions + * - Project listing/management + */ +// Uses Jest globals injected by test runner (see jest.config.js) +import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; +import { + type ServerTestContext, + startServer, + stopServer, + prepareTestMuxRoot, + prepareTestMuxRootWithProject, + cleanupTestMuxRoot, + ipcRequest, + createWebSocket, + waitForWsOpen, +} from "./serverTestUtils"; + +// Each test gets a unique ID to avoid directory conflicts +let testCounter = 0; +function getTestId(): string { + return `server-test-${Date.now()}-${++testCounter}`; +} + +describe("mux-server", () => { + describe("health endpoint", () => { + let ctx: ServerTestContext; + let muxRoot: string; + + beforeAll(async () => { + muxRoot = prepareTestMuxRoot(getTestId()); + ctx = await startServer({ muxRoot }); + }); + + afterAll(async () => { + await stopServer(ctx); + cleanupTestMuxRoot(muxRoot); + }); + + test("returns 200 OK", async () => { + const response = await fetch(`${ctx.baseUrl}/health`); + expect(response.ok).toBe(true); + }); + + test("returns status ok", async () => { + const response = await fetch(`${ctx.baseUrl}/health`); + const data = await response.json(); + expect(data).toEqual({ status: "ok" }); + }); + }); + + describe("version endpoint", () => { + let ctx: ServerTestContext; + let muxRoot: string; + + beforeAll(async () => { + muxRoot = prepareTestMuxRoot(getTestId()); + ctx = await startServer({ muxRoot }); + }); + + afterAll(async () => { + await stopServer(ctx); + cleanupTestMuxRoot(muxRoot); + }); + + test("returns version info", async () => { + const response = await fetch(`${ctx.baseUrl}/version`); + expect(response.ok).toBe(true); + const data = await response.json(); + // Version info has git_describe, git_commit, buildTime, mode + expect(data).toHaveProperty("git_describe"); + expect(data).toHaveProperty("mode", "server"); + }); + }); + + describe("authentication", () => { + const AUTH_TOKEN = "test-secret-token-12345"; + let ctx: ServerTestContext; + let muxRoot: string; + + beforeAll(async () => { + muxRoot = prepareTestMuxRoot(getTestId()); + ctx = await startServer({ muxRoot, authToken: AUTH_TOKEN }); + }); + + afterAll(async () => { + await stopServer(ctx); + cleanupTestMuxRoot(muxRoot); + }); + + test("health endpoint is public (no auth required)", async () => { + const response = await fetch(`${ctx.baseUrl}/health`); + expect(response.ok).toBe(true); + }); + + test("IPC endpoint rejects requests without auth", async () => { + const channel = encodeURIComponent(IPC_CHANNELS.PROJECT_LIST); + const response = await fetch(`${ctx.baseUrl}/ipc/${channel}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ args: [] }), + }); + expect(response.status).toBe(401); + }); + + test("IPC endpoint rejects requests with wrong auth", async () => { + const channel = encodeURIComponent(IPC_CHANNELS.PROJECT_LIST); + const response = await fetch(`${ctx.baseUrl}/ipc/${channel}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer wrong-token", + }, + body: JSON.stringify({ args: [] }), + }); + expect(response.status).toBe(401); + }); + + test("IPC endpoint accepts requests with correct auth", async () => { + const channel = encodeURIComponent(IPC_CHANNELS.PROJECT_LIST); + const response = await fetch(`${ctx.baseUrl}/ipc/${channel}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${AUTH_TOKEN}`, + }, + body: JSON.stringify({ args: [] }), + }); + expect(response.ok).toBe(true); + }); + + test("WebSocket rejects connection without auth", async () => { + const ws = createWebSocket({ ...ctx, authToken: undefined }); + // Server accepts the connection but immediately closes with 1008 (Policy Violation) + await waitForWsOpen(ws); + const closePromise = new Promise((resolve) => { + ws.on("close", (code) => resolve(code)); + }); + const closeCode = await closePromise; + expect(closeCode).toBe(1008); // Policy Violation = Unauthorized + }); + + test("WebSocket accepts connection with correct auth", async () => { + const ws = createWebSocket(ctx); + await waitForWsOpen(ws); + expect(ws.readyState).toBe(ws.OPEN); + ws.close(); + }); + }); + + describe("IPC channels", () => { + let ctx: ServerTestContext; + let muxRoot: string; + + beforeAll(async () => { + muxRoot = prepareTestMuxRoot(getTestId()); + ctx = await startServer({ muxRoot }); + }); + + afterAll(async () => { + await stopServer(ctx); + cleanupTestMuxRoot(muxRoot); + }); + + test("project:list returns empty array for fresh config", async () => { + const result = await ipcRequest(ctx, IPC_CHANNELS.PROJECT_LIST); + expect(result.success).toBe(true); + expect(Array.isArray(result.data)).toBe(true); + expect(result.data).toHaveLength(0); + }); + + test("providers:getConfig returns provider configuration", async () => { + const result = await ipcRequest(ctx, IPC_CHANNELS.PROVIDERS_GET_CONFIG); + expect(result.success).toBe(true); + expect(result.data).toBeDefined(); + }); + + test("providers:list returns available providers", async () => { + const result = await ipcRequest(ctx, IPC_CHANNELS.PROVIDERS_LIST); + expect(result.success).toBe(true); + expect(Array.isArray(result.data)).toBe(true); + // Should have at least anthropic provider + const providers = result.data as string[]; + expect(providers).toContain("anthropic"); + }); + + test("unknown IPC channel returns 404", async () => { + const response = await fetch(`${ctx.baseUrl}/ipc/unknown:channel`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ args: [] }), + }); + expect(response.status).toBe(404); + }); + }); + + describe("project operations with git repo", () => { + let ctx: ServerTestContext; + let muxRoot: string; + let projectPath: string; + + beforeAll(async () => { + const setup = await prepareTestMuxRootWithProject(getTestId()); + muxRoot = setup.muxRoot; + projectPath = setup.projectPath; + ctx = await startServer({ muxRoot }); + }); + + afterAll(async () => { + await stopServer(ctx); + cleanupTestMuxRoot(muxRoot); + }); + + test("project:list returns configured project", async () => { + const result = await ipcRequest(ctx, IPC_CHANNELS.PROJECT_LIST); + expect(result.success).toBe(true); + const projects = result.data as Array<[string, unknown]>; + expect(projects.length).toBe(1); + expect(projects[0][0]).toBe(projectPath); + }); + + test("project:listBranches returns branches for project", async () => { + const result = await ipcRequest(ctx, IPC_CHANNELS.PROJECT_LIST_BRANCHES, [projectPath]); + expect(result.success).toBe(true); + // Returns { branches: string[], recommendedTrunk: string } + const data = result.data as { branches: string[]; recommendedTrunk: string }; + expect(Array.isArray(data.branches)).toBe(true); + expect(data.branches.length).toBeGreaterThan(0); + expect(typeof data.recommendedTrunk).toBe("string"); + }); + }); + + describe("WebSocket subscriptions", () => { + let ctx: ServerTestContext; + let muxRoot: string; + + beforeAll(async () => { + muxRoot = prepareTestMuxRoot(getTestId()); + ctx = await startServer({ muxRoot }); + }); + + afterAll(async () => { + await stopServer(ctx); + cleanupTestMuxRoot(muxRoot); + }); + + test("can subscribe to workspace:metadata", async () => { + const ws = createWebSocket(ctx); + await waitForWsOpen(ws); + + // Send subscribe message + ws.send( + JSON.stringify({ + type: "subscribe", + channel: "workspace:metadata", + }) + ); + + // Give server time to process subscription + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Connection should still be open (no errors) + expect(ws.readyState).toBe(ws.OPEN); + ws.close(); + }); + + test("can subscribe to workspace:activity", async () => { + const ws = createWebSocket(ctx); + await waitForWsOpen(ws); + + ws.send( + JSON.stringify({ + type: "subscribe", + channel: "workspace:activity", + }) + ); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(ws.readyState).toBe(ws.OPEN); + ws.close(); + }); + + test("can unsubscribe from channels", async () => { + const ws = createWebSocket(ctx); + await waitForWsOpen(ws); + + // Subscribe + ws.send( + JSON.stringify({ + type: "subscribe", + channel: "workspace:metadata", + }) + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Unsubscribe + ws.send( + JSON.stringify({ + type: "unsubscribe", + channel: "workspace:metadata", + }) + ); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(ws.readyState).toBe(ws.OPEN); + ws.close(); + }); + }); + + describe("--add-project flag", () => { + test("creates project from git repository path", async () => { + const testId = getTestId(); + const setup = await prepareTestMuxRootWithProject(testId); + + // Start server with --add-project pointing to a new git repo + // The setup already created one, but let's create another one for this test + const { execSync } = await import("child_process"); + const fs = await import("fs"); + const path = await import("path"); + + const newProjectPath = path.join(setup.muxRoot, "fixtures", "added-project"); + fs.mkdirSync(newProjectPath, { recursive: true }); + execSync("git init", { cwd: newProjectPath, stdio: "ignore" }); + execSync("git config user.email test@test.com", { cwd: newProjectPath, stdio: "ignore" }); + execSync("git config user.name Test", { cwd: newProjectPath, stdio: "ignore" }); + fs.writeFileSync(path.join(newProjectPath, "README.md"), "# Added Project\n"); + execSync("git add .", { cwd: newProjectPath, stdio: "ignore" }); + execSync('git commit -m "initial"', { cwd: newProjectPath, stdio: "ignore" }); + + // Reset config to empty + fs.writeFileSync( + path.join(setup.muxRoot, "config.json"), + JSON.stringify({ projects: [] }, null, 2) + ); + + const ctx = await startServer({ + muxRoot: setup.muxRoot, + addProject: newProjectPath, + }); + + try { + // Wait a bit for project creation to complete + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Verify project was added + const result = await ipcRequest(ctx, IPC_CHANNELS.PROJECT_LIST); + expect(result.success).toBe(true); + const projects = result.data as Array<[string, unknown]>; + expect(projects.length).toBe(1); + expect(projects[0][0]).toBe(newProjectPath); + } finally { + await stopServer(ctx); + cleanupTestMuxRoot(setup.muxRoot); + } + }); + }); +}); diff --git a/tests/server/serverTestUtils.ts b/tests/server/serverTestUtils.ts new file mode 100644 index 0000000000..c92eadef59 --- /dev/null +++ b/tests/server/serverTestUtils.ts @@ -0,0 +1,284 @@ +/** + * Test utilities for mux-server integration tests + * These tests verify the HTTP/WebSocket server functionality + */ +import { spawn, type ChildProcess } from "child_process"; +import fs from "fs"; +import path from "path"; +import WebSocket from "ws"; + +export interface ServerTestContext { + serverProcess: ChildProcess; + port: number; + host: string; + muxRoot: string; + authToken?: string; + baseUrl: string; +} + +export interface StartServerOptions { + port?: number; + host?: string; + authToken?: string; + addProject?: string; + muxRoot: string; +} + +const APP_ROOT = path.resolve(__dirname, "..", ".."); +const DEFAULT_PORT = 13000; // Use high port to avoid conflicts +const DEFAULT_HOST = "127.0.0.1"; +const SERVER_STARTUP_TIMEOUT_MS = 15_000; + +/** + * Prepare a test MUX_ROOT directory with minimal config + */ +export function prepareTestMuxRoot(testId: string): string { + const testRoot = path.join(APP_ROOT, "tests", "server", "tmp", testId); + fs.rmSync(testRoot, { recursive: true, force: true }); + fs.mkdirSync(path.join(testRoot, "sessions"), { recursive: true }); + fs.mkdirSync(path.join(testRoot, "src"), { recursive: true }); + + // Write minimal config + fs.writeFileSync(path.join(testRoot, "config.json"), JSON.stringify({ projects: [] }, null, 2)); + + return testRoot; +} + +/** + * Prepare a test MUX_ROOT with a git project for project-dependent tests + */ +export async function prepareTestMuxRootWithProject(testId: string): Promise<{ + muxRoot: string; + projectPath: string; + workspacePath: string; +}> { + const testRoot = prepareTestMuxRoot(testId); + const projectPath = path.join(testRoot, "fixtures", "test-repo"); + const workspacePath = path.join(testRoot, "src", "test-repo", "main"); + + // Create project directory as a git repo + fs.mkdirSync(projectPath, { recursive: true }); + fs.mkdirSync(workspacePath, { recursive: true }); + + // Initialize git repo + const { execSync } = await import("child_process"); + execSync("git init", { cwd: projectPath, stdio: "ignore" }); + execSync("git config user.email test@test.com", { cwd: projectPath, stdio: "ignore" }); + execSync("git config user.name Test", { cwd: projectPath, stdio: "ignore" }); + fs.writeFileSync(path.join(projectPath, "README.md"), "# Test Repo\n"); + execSync("git add .", { cwd: projectPath, stdio: "ignore" }); + execSync('git commit -m "initial"', { cwd: projectPath, stdio: "ignore" }); + + // Update config to include project + const config = { + projects: [[projectPath, { workspaces: [{ path: workspacePath }] }]], + }; + fs.writeFileSync(path.join(testRoot, "config.json"), JSON.stringify(config, null, 2)); + + return { muxRoot: testRoot, projectPath, workspacePath }; +} + +/** + * Clean up test directory + */ +export function cleanupTestMuxRoot(muxRoot: string): void { + fs.rmSync(muxRoot, { recursive: true, force: true }); +} + +/** + * Start the mux-server process + * Uses the built server from dist/cli/server.js (requires `make build-main` first) + */ +export async function startServer(options: StartServerOptions): Promise { + const port = options.port ?? DEFAULT_PORT + Math.floor(Math.random() * 1000); + const host = options.host ?? DEFAULT_HOST; + + // Use built server - must run `make build-main` before tests + const serverPath = path.join(APP_ROOT, "dist", "cli", "server.js"); + if (!fs.existsSync(serverPath)) { + throw new Error(`Server not built. Run 'make build-main' first. Expected: ${serverPath}`); + } + + const args = [serverPath, "--host", host, "--port", String(port)]; + + if (options.authToken) { + args.push("--auth-token", options.authToken); + } + + if (options.addProject) { + args.push("--add-project", options.addProject); + } + + const serverProcess = spawn("node", args, { + cwd: APP_ROOT, + env: { + ...process.env, + MUX_ROOT: options.muxRoot, + NODE_ENV: "test", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + const baseUrl = `http://${host}:${port}`; + + // Collect stderr for debugging + let stderr = ""; + serverProcess.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + // Wait for server to be ready + const startTime = Date.now(); + while (Date.now() - startTime < SERVER_STARTUP_TIMEOUT_MS) { + try { + const response = await fetch(`${baseUrl}/health`); + if (response.ok) { + return { + serverProcess, + port, + host, + muxRoot: options.muxRoot, + authToken: options.authToken, + baseUrl, + }; + } + } catch { + // Server not ready yet + } + await sleep(100); + } + + // Server failed to start + serverProcess.kill(); + throw new Error( + `Server failed to start within ${SERVER_STARTUP_TIMEOUT_MS}ms. Stderr: ${stderr}` + ); +} + +/** + * Stop the server process + */ +export async function stopServer(ctx: ServerTestContext): Promise { + if (ctx.serverProcess.exitCode === null) { + ctx.serverProcess.kill("SIGTERM"); + // Give it time to shut down gracefully + await sleep(500); + if (ctx.serverProcess.exitCode === null) { + ctx.serverProcess.kill("SIGKILL"); + } + } +} + +/** + * Make an IPC request to the server + */ +export async function ipcRequest( + ctx: ServerTestContext, + channel: string, + args: unknown[] = [] +): Promise<{ success: boolean; data?: unknown; error?: string }> { + const headers: Record = { + "Content-Type": "application/json", + }; + + if (ctx.authToken) { + headers["Authorization"] = `Bearer ${ctx.authToken}`; + } + + const response = await fetch(`${ctx.baseUrl}/ipc/${encodeURIComponent(channel)}`, { + method: "POST", + headers, + body: JSON.stringify({ args }), + }); + + return response.json(); +} + +/** + * Create a WebSocket connection to the server + * Uses query param authentication (most reliable for ws library) + */ +export function createWebSocket(ctx: ServerTestContext): WebSocket { + let wsUrl = ctx.baseUrl.replace("http://", "ws://") + "/ws"; + + // Use query param auth - more reliable than headers with ws library + if (ctx.authToken) { + wsUrl += `?token=${encodeURIComponent(ctx.authToken)}`; + } + + return new WebSocket(wsUrl); +} + +/** + * Wait for WebSocket to open + */ +export function waitForWsOpen(ws: WebSocket, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`WebSocket connection timeout after ${timeoutMs}ms`)); + }, timeoutMs); + + ws.once("open", () => { + clearTimeout(timeout); + resolve(); + }); + + ws.once("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + }); +} + +/** + * Wait for a WebSocket message matching a predicate + */ +export function waitForWsMessage( + ws: WebSocket, + predicate: (data: unknown) => data is T, + timeoutMs = 5000 +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error(`Timeout waiting for WebSocket message after ${timeoutMs}ms`)); + }, timeoutMs); + + const onMessage = (data: WebSocket.RawData) => { + try { + const parsed = JSON.parse(data.toString()); + if (predicate(parsed)) { + cleanup(); + resolve(parsed); + } + } catch { + // Ignore parse errors + } + }; + + const onError = (err: Error) => { + cleanup(); + reject(err); + }; + + const onClose = () => { + cleanup(); + reject(new Error("WebSocket closed while waiting for message")); + }; + + const cleanup = () => { + clearTimeout(timeout); + ws.off("message", onMessage); + ws.off("error", onError); + ws.off("close", onClose); + }; + + ws.on("message", onMessage); + ws.once("error", onError); + ws.once("close", onClose); + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} From fd1beffabe40db3c959b3713c5f18252b5bc18a4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:51:05 +0000 Subject: [PATCH 2/4] fix: guard server tests behind TEST_INTEGRATION flag Address Codex review - wrap test suite in describeIntegration to skip when TEST_INTEGRATION is not set, matching the pattern used by other integration tests in tests/ipcMain/. --- tests/server/server.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/server/server.test.ts b/tests/server/server.test.ts index 28eabd715d..09546afc62 100644 --- a/tests/server/server.test.ts +++ b/tests/server/server.test.ts @@ -7,9 +7,12 @@ * - IPC channel routing * - WebSocket connections and subscriptions * - Project listing/management + * + * Run with: TEST_INTEGRATION=1 bun x jest tests/server/server.test.ts + * Requires: make build-main (tests use built server from dist/cli/server.js) */ -// Uses Jest globals injected by test runner (see jest.config.js) import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; +import { shouldRunIntegrationTests } from "../testUtils"; import { type ServerTestContext, startServer, @@ -22,13 +25,16 @@ import { waitForWsOpen, } from "./serverTestUtils"; +// Skip all tests if TEST_INTEGRATION is not set +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + // Each test gets a unique ID to avoid directory conflicts let testCounter = 0; function getTestId(): string { return `server-test-${Date.now()}-${++testCounter}`; } -describe("mux-server", () => { +describeIntegration("mux-server", () => { describe("health endpoint", () => { let ctx: ServerTestContext; let muxRoot: string; From 57b6c99428d54aefcc3ba25da79468124afca0ef Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:54:00 +0000 Subject: [PATCH 3/4] fix: use @/ alias for ipc-constants import Fix TypeScript path resolution in CI where relative paths may not resolve correctly across different workspace configurations. --- tests/server/server.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/server/server.test.ts b/tests/server/server.test.ts index 09546afc62..dffe98171f 100644 --- a/tests/server/server.test.ts +++ b/tests/server/server.test.ts @@ -11,7 +11,7 @@ * Run with: TEST_INTEGRATION=1 bun x jest tests/server/server.test.ts * Requires: make build-main (tests use built server from dist/cli/server.js) */ -import { IPC_CHANNELS } from "../../src/common/constants/ipc-constants"; +import { IPC_CHANNELS } from "@/common/constants/ipc-constants"; import { shouldRunIntegrationTests } from "../testUtils"; import { type ServerTestContext, From 5b4d1082fd3710fa73392bd6980602addf6d7318 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:59:06 +0000 Subject: [PATCH 4/4] remove: delete outdated server tests The mux-server architecture changed from IPC to ORPC. Server tests already exist in src/cli/server.test.ts covering the new ORPC endpoints. These tests were based on the old IPC-based server which no longer exists. --- tests/server/server.test.ts | 370 -------------------------------- tests/server/serverTestUtils.ts | 284 ------------------------ 2 files changed, 654 deletions(-) delete mode 100644 tests/server/server.test.ts delete mode 100644 tests/server/serverTestUtils.ts diff --git a/tests/server/server.test.ts b/tests/server/server.test.ts deleted file mode 100644 index dffe98171f..0000000000 --- a/tests/server/server.test.ts +++ /dev/null @@ -1,370 +0,0 @@ -/** - * Integration tests for mux-server HTTP/WebSocket functionality - * - * These tests spin up actual server instances and verify: - * - Health check endpoint - * - Authentication (when configured) - * - IPC channel routing - * - WebSocket connections and subscriptions - * - Project listing/management - * - * Run with: TEST_INTEGRATION=1 bun x jest tests/server/server.test.ts - * Requires: make build-main (tests use built server from dist/cli/server.js) - */ -import { IPC_CHANNELS } from "@/common/constants/ipc-constants"; -import { shouldRunIntegrationTests } from "../testUtils"; -import { - type ServerTestContext, - startServer, - stopServer, - prepareTestMuxRoot, - prepareTestMuxRootWithProject, - cleanupTestMuxRoot, - ipcRequest, - createWebSocket, - waitForWsOpen, -} from "./serverTestUtils"; - -// Skip all tests if TEST_INTEGRATION is not set -const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; - -// Each test gets a unique ID to avoid directory conflicts -let testCounter = 0; -function getTestId(): string { - return `server-test-${Date.now()}-${++testCounter}`; -} - -describeIntegration("mux-server", () => { - describe("health endpoint", () => { - let ctx: ServerTestContext; - let muxRoot: string; - - beforeAll(async () => { - muxRoot = prepareTestMuxRoot(getTestId()); - ctx = await startServer({ muxRoot }); - }); - - afterAll(async () => { - await stopServer(ctx); - cleanupTestMuxRoot(muxRoot); - }); - - test("returns 200 OK", async () => { - const response = await fetch(`${ctx.baseUrl}/health`); - expect(response.ok).toBe(true); - }); - - test("returns status ok", async () => { - const response = await fetch(`${ctx.baseUrl}/health`); - const data = await response.json(); - expect(data).toEqual({ status: "ok" }); - }); - }); - - describe("version endpoint", () => { - let ctx: ServerTestContext; - let muxRoot: string; - - beforeAll(async () => { - muxRoot = prepareTestMuxRoot(getTestId()); - ctx = await startServer({ muxRoot }); - }); - - afterAll(async () => { - await stopServer(ctx); - cleanupTestMuxRoot(muxRoot); - }); - - test("returns version info", async () => { - const response = await fetch(`${ctx.baseUrl}/version`); - expect(response.ok).toBe(true); - const data = await response.json(); - // Version info has git_describe, git_commit, buildTime, mode - expect(data).toHaveProperty("git_describe"); - expect(data).toHaveProperty("mode", "server"); - }); - }); - - describe("authentication", () => { - const AUTH_TOKEN = "test-secret-token-12345"; - let ctx: ServerTestContext; - let muxRoot: string; - - beforeAll(async () => { - muxRoot = prepareTestMuxRoot(getTestId()); - ctx = await startServer({ muxRoot, authToken: AUTH_TOKEN }); - }); - - afterAll(async () => { - await stopServer(ctx); - cleanupTestMuxRoot(muxRoot); - }); - - test("health endpoint is public (no auth required)", async () => { - const response = await fetch(`${ctx.baseUrl}/health`); - expect(response.ok).toBe(true); - }); - - test("IPC endpoint rejects requests without auth", async () => { - const channel = encodeURIComponent(IPC_CHANNELS.PROJECT_LIST); - const response = await fetch(`${ctx.baseUrl}/ipc/${channel}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ args: [] }), - }); - expect(response.status).toBe(401); - }); - - test("IPC endpoint rejects requests with wrong auth", async () => { - const channel = encodeURIComponent(IPC_CHANNELS.PROJECT_LIST); - const response = await fetch(`${ctx.baseUrl}/ipc/${channel}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer wrong-token", - }, - body: JSON.stringify({ args: [] }), - }); - expect(response.status).toBe(401); - }); - - test("IPC endpoint accepts requests with correct auth", async () => { - const channel = encodeURIComponent(IPC_CHANNELS.PROJECT_LIST); - const response = await fetch(`${ctx.baseUrl}/ipc/${channel}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${AUTH_TOKEN}`, - }, - body: JSON.stringify({ args: [] }), - }); - expect(response.ok).toBe(true); - }); - - test("WebSocket rejects connection without auth", async () => { - const ws = createWebSocket({ ...ctx, authToken: undefined }); - // Server accepts the connection but immediately closes with 1008 (Policy Violation) - await waitForWsOpen(ws); - const closePromise = new Promise((resolve) => { - ws.on("close", (code) => resolve(code)); - }); - const closeCode = await closePromise; - expect(closeCode).toBe(1008); // Policy Violation = Unauthorized - }); - - test("WebSocket accepts connection with correct auth", async () => { - const ws = createWebSocket(ctx); - await waitForWsOpen(ws); - expect(ws.readyState).toBe(ws.OPEN); - ws.close(); - }); - }); - - describe("IPC channels", () => { - let ctx: ServerTestContext; - let muxRoot: string; - - beforeAll(async () => { - muxRoot = prepareTestMuxRoot(getTestId()); - ctx = await startServer({ muxRoot }); - }); - - afterAll(async () => { - await stopServer(ctx); - cleanupTestMuxRoot(muxRoot); - }); - - test("project:list returns empty array for fresh config", async () => { - const result = await ipcRequest(ctx, IPC_CHANNELS.PROJECT_LIST); - expect(result.success).toBe(true); - expect(Array.isArray(result.data)).toBe(true); - expect(result.data).toHaveLength(0); - }); - - test("providers:getConfig returns provider configuration", async () => { - const result = await ipcRequest(ctx, IPC_CHANNELS.PROVIDERS_GET_CONFIG); - expect(result.success).toBe(true); - expect(result.data).toBeDefined(); - }); - - test("providers:list returns available providers", async () => { - const result = await ipcRequest(ctx, IPC_CHANNELS.PROVIDERS_LIST); - expect(result.success).toBe(true); - expect(Array.isArray(result.data)).toBe(true); - // Should have at least anthropic provider - const providers = result.data as string[]; - expect(providers).toContain("anthropic"); - }); - - test("unknown IPC channel returns 404", async () => { - const response = await fetch(`${ctx.baseUrl}/ipc/unknown:channel`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ args: [] }), - }); - expect(response.status).toBe(404); - }); - }); - - describe("project operations with git repo", () => { - let ctx: ServerTestContext; - let muxRoot: string; - let projectPath: string; - - beforeAll(async () => { - const setup = await prepareTestMuxRootWithProject(getTestId()); - muxRoot = setup.muxRoot; - projectPath = setup.projectPath; - ctx = await startServer({ muxRoot }); - }); - - afterAll(async () => { - await stopServer(ctx); - cleanupTestMuxRoot(muxRoot); - }); - - test("project:list returns configured project", async () => { - const result = await ipcRequest(ctx, IPC_CHANNELS.PROJECT_LIST); - expect(result.success).toBe(true); - const projects = result.data as Array<[string, unknown]>; - expect(projects.length).toBe(1); - expect(projects[0][0]).toBe(projectPath); - }); - - test("project:listBranches returns branches for project", async () => { - const result = await ipcRequest(ctx, IPC_CHANNELS.PROJECT_LIST_BRANCHES, [projectPath]); - expect(result.success).toBe(true); - // Returns { branches: string[], recommendedTrunk: string } - const data = result.data as { branches: string[]; recommendedTrunk: string }; - expect(Array.isArray(data.branches)).toBe(true); - expect(data.branches.length).toBeGreaterThan(0); - expect(typeof data.recommendedTrunk).toBe("string"); - }); - }); - - describe("WebSocket subscriptions", () => { - let ctx: ServerTestContext; - let muxRoot: string; - - beforeAll(async () => { - muxRoot = prepareTestMuxRoot(getTestId()); - ctx = await startServer({ muxRoot }); - }); - - afterAll(async () => { - await stopServer(ctx); - cleanupTestMuxRoot(muxRoot); - }); - - test("can subscribe to workspace:metadata", async () => { - const ws = createWebSocket(ctx); - await waitForWsOpen(ws); - - // Send subscribe message - ws.send( - JSON.stringify({ - type: "subscribe", - channel: "workspace:metadata", - }) - ); - - // Give server time to process subscription - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Connection should still be open (no errors) - expect(ws.readyState).toBe(ws.OPEN); - ws.close(); - }); - - test("can subscribe to workspace:activity", async () => { - const ws = createWebSocket(ctx); - await waitForWsOpen(ws); - - ws.send( - JSON.stringify({ - type: "subscribe", - channel: "workspace:activity", - }) - ); - - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(ws.readyState).toBe(ws.OPEN); - ws.close(); - }); - - test("can unsubscribe from channels", async () => { - const ws = createWebSocket(ctx); - await waitForWsOpen(ws); - - // Subscribe - ws.send( - JSON.stringify({ - type: "subscribe", - channel: "workspace:metadata", - }) - ); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - // Unsubscribe - ws.send( - JSON.stringify({ - type: "unsubscribe", - channel: "workspace:metadata", - }) - ); - - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(ws.readyState).toBe(ws.OPEN); - ws.close(); - }); - }); - - describe("--add-project flag", () => { - test("creates project from git repository path", async () => { - const testId = getTestId(); - const setup = await prepareTestMuxRootWithProject(testId); - - // Start server with --add-project pointing to a new git repo - // The setup already created one, but let's create another one for this test - const { execSync } = await import("child_process"); - const fs = await import("fs"); - const path = await import("path"); - - const newProjectPath = path.join(setup.muxRoot, "fixtures", "added-project"); - fs.mkdirSync(newProjectPath, { recursive: true }); - execSync("git init", { cwd: newProjectPath, stdio: "ignore" }); - execSync("git config user.email test@test.com", { cwd: newProjectPath, stdio: "ignore" }); - execSync("git config user.name Test", { cwd: newProjectPath, stdio: "ignore" }); - fs.writeFileSync(path.join(newProjectPath, "README.md"), "# Added Project\n"); - execSync("git add .", { cwd: newProjectPath, stdio: "ignore" }); - execSync('git commit -m "initial"', { cwd: newProjectPath, stdio: "ignore" }); - - // Reset config to empty - fs.writeFileSync( - path.join(setup.muxRoot, "config.json"), - JSON.stringify({ projects: [] }, null, 2) - ); - - const ctx = await startServer({ - muxRoot: setup.muxRoot, - addProject: newProjectPath, - }); - - try { - // Wait a bit for project creation to complete - await new Promise((resolve) => setTimeout(resolve, 500)); - - // Verify project was added - const result = await ipcRequest(ctx, IPC_CHANNELS.PROJECT_LIST); - expect(result.success).toBe(true); - const projects = result.data as Array<[string, unknown]>; - expect(projects.length).toBe(1); - expect(projects[0][0]).toBe(newProjectPath); - } finally { - await stopServer(ctx); - cleanupTestMuxRoot(setup.muxRoot); - } - }); - }); -}); diff --git a/tests/server/serverTestUtils.ts b/tests/server/serverTestUtils.ts deleted file mode 100644 index c92eadef59..0000000000 --- a/tests/server/serverTestUtils.ts +++ /dev/null @@ -1,284 +0,0 @@ -/** - * Test utilities for mux-server integration tests - * These tests verify the HTTP/WebSocket server functionality - */ -import { spawn, type ChildProcess } from "child_process"; -import fs from "fs"; -import path from "path"; -import WebSocket from "ws"; - -export interface ServerTestContext { - serverProcess: ChildProcess; - port: number; - host: string; - muxRoot: string; - authToken?: string; - baseUrl: string; -} - -export interface StartServerOptions { - port?: number; - host?: string; - authToken?: string; - addProject?: string; - muxRoot: string; -} - -const APP_ROOT = path.resolve(__dirname, "..", ".."); -const DEFAULT_PORT = 13000; // Use high port to avoid conflicts -const DEFAULT_HOST = "127.0.0.1"; -const SERVER_STARTUP_TIMEOUT_MS = 15_000; - -/** - * Prepare a test MUX_ROOT directory with minimal config - */ -export function prepareTestMuxRoot(testId: string): string { - const testRoot = path.join(APP_ROOT, "tests", "server", "tmp", testId); - fs.rmSync(testRoot, { recursive: true, force: true }); - fs.mkdirSync(path.join(testRoot, "sessions"), { recursive: true }); - fs.mkdirSync(path.join(testRoot, "src"), { recursive: true }); - - // Write minimal config - fs.writeFileSync(path.join(testRoot, "config.json"), JSON.stringify({ projects: [] }, null, 2)); - - return testRoot; -} - -/** - * Prepare a test MUX_ROOT with a git project for project-dependent tests - */ -export async function prepareTestMuxRootWithProject(testId: string): Promise<{ - muxRoot: string; - projectPath: string; - workspacePath: string; -}> { - const testRoot = prepareTestMuxRoot(testId); - const projectPath = path.join(testRoot, "fixtures", "test-repo"); - const workspacePath = path.join(testRoot, "src", "test-repo", "main"); - - // Create project directory as a git repo - fs.mkdirSync(projectPath, { recursive: true }); - fs.mkdirSync(workspacePath, { recursive: true }); - - // Initialize git repo - const { execSync } = await import("child_process"); - execSync("git init", { cwd: projectPath, stdio: "ignore" }); - execSync("git config user.email test@test.com", { cwd: projectPath, stdio: "ignore" }); - execSync("git config user.name Test", { cwd: projectPath, stdio: "ignore" }); - fs.writeFileSync(path.join(projectPath, "README.md"), "# Test Repo\n"); - execSync("git add .", { cwd: projectPath, stdio: "ignore" }); - execSync('git commit -m "initial"', { cwd: projectPath, stdio: "ignore" }); - - // Update config to include project - const config = { - projects: [[projectPath, { workspaces: [{ path: workspacePath }] }]], - }; - fs.writeFileSync(path.join(testRoot, "config.json"), JSON.stringify(config, null, 2)); - - return { muxRoot: testRoot, projectPath, workspacePath }; -} - -/** - * Clean up test directory - */ -export function cleanupTestMuxRoot(muxRoot: string): void { - fs.rmSync(muxRoot, { recursive: true, force: true }); -} - -/** - * Start the mux-server process - * Uses the built server from dist/cli/server.js (requires `make build-main` first) - */ -export async function startServer(options: StartServerOptions): Promise { - const port = options.port ?? DEFAULT_PORT + Math.floor(Math.random() * 1000); - const host = options.host ?? DEFAULT_HOST; - - // Use built server - must run `make build-main` before tests - const serverPath = path.join(APP_ROOT, "dist", "cli", "server.js"); - if (!fs.existsSync(serverPath)) { - throw new Error(`Server not built. Run 'make build-main' first. Expected: ${serverPath}`); - } - - const args = [serverPath, "--host", host, "--port", String(port)]; - - if (options.authToken) { - args.push("--auth-token", options.authToken); - } - - if (options.addProject) { - args.push("--add-project", options.addProject); - } - - const serverProcess = spawn("node", args, { - cwd: APP_ROOT, - env: { - ...process.env, - MUX_ROOT: options.muxRoot, - NODE_ENV: "test", - }, - stdio: ["ignore", "pipe", "pipe"], - }); - - const baseUrl = `http://${host}:${port}`; - - // Collect stderr for debugging - let stderr = ""; - serverProcess.stderr?.on("data", (chunk) => { - stderr += chunk.toString(); - }); - - // Wait for server to be ready - const startTime = Date.now(); - while (Date.now() - startTime < SERVER_STARTUP_TIMEOUT_MS) { - try { - const response = await fetch(`${baseUrl}/health`); - if (response.ok) { - return { - serverProcess, - port, - host, - muxRoot: options.muxRoot, - authToken: options.authToken, - baseUrl, - }; - } - } catch { - // Server not ready yet - } - await sleep(100); - } - - // Server failed to start - serverProcess.kill(); - throw new Error( - `Server failed to start within ${SERVER_STARTUP_TIMEOUT_MS}ms. Stderr: ${stderr}` - ); -} - -/** - * Stop the server process - */ -export async function stopServer(ctx: ServerTestContext): Promise { - if (ctx.serverProcess.exitCode === null) { - ctx.serverProcess.kill("SIGTERM"); - // Give it time to shut down gracefully - await sleep(500); - if (ctx.serverProcess.exitCode === null) { - ctx.serverProcess.kill("SIGKILL"); - } - } -} - -/** - * Make an IPC request to the server - */ -export async function ipcRequest( - ctx: ServerTestContext, - channel: string, - args: unknown[] = [] -): Promise<{ success: boolean; data?: unknown; error?: string }> { - const headers: Record = { - "Content-Type": "application/json", - }; - - if (ctx.authToken) { - headers["Authorization"] = `Bearer ${ctx.authToken}`; - } - - const response = await fetch(`${ctx.baseUrl}/ipc/${encodeURIComponent(channel)}`, { - method: "POST", - headers, - body: JSON.stringify({ args }), - }); - - return response.json(); -} - -/** - * Create a WebSocket connection to the server - * Uses query param authentication (most reliable for ws library) - */ -export function createWebSocket(ctx: ServerTestContext): WebSocket { - let wsUrl = ctx.baseUrl.replace("http://", "ws://") + "/ws"; - - // Use query param auth - more reliable than headers with ws library - if (ctx.authToken) { - wsUrl += `?token=${encodeURIComponent(ctx.authToken)}`; - } - - return new WebSocket(wsUrl); -} - -/** - * Wait for WebSocket to open - */ -export function waitForWsOpen(ws: WebSocket, timeoutMs = 5000): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error(`WebSocket connection timeout after ${timeoutMs}ms`)); - }, timeoutMs); - - ws.once("open", () => { - clearTimeout(timeout); - resolve(); - }); - - ws.once("error", (err) => { - clearTimeout(timeout); - reject(err); - }); - }); -} - -/** - * Wait for a WebSocket message matching a predicate - */ -export function waitForWsMessage( - ws: WebSocket, - predicate: (data: unknown) => data is T, - timeoutMs = 5000 -): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - cleanup(); - reject(new Error(`Timeout waiting for WebSocket message after ${timeoutMs}ms`)); - }, timeoutMs); - - const onMessage = (data: WebSocket.RawData) => { - try { - const parsed = JSON.parse(data.toString()); - if (predicate(parsed)) { - cleanup(); - resolve(parsed); - } - } catch { - // Ignore parse errors - } - }; - - const onError = (err: Error) => { - cleanup(); - reject(err); - }; - - const onClose = () => { - cleanup(); - reject(new Error("WebSocket closed while waiting for message")); - }; - - const cleanup = () => { - clearTimeout(timeout); - ws.off("message", onMessage); - ws.off("error", onError); - ws.off("close", onClose); - }; - - ws.on("message", onMessage); - ws.once("error", onError); - ws.once("close", onClose); - }); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -}