import { describe, test, expect, beforeAll, afterEach } from "bun:test"; import { ServerManager, ServerState } from "../src/server/ServerManager"; import { OpenCodeSettings } from "../src/types"; // Test configuration const TEST_PORT_BASE = 15000; const TEST_TIMEOUT_MS = 10000; // 10 seconds for server startup in tests const PROJECT_DIR = process.cwd(); let currentPort = TEST_PORT_BASE; function getNextPort(): number { return currentPort++; } function createTestSettings(port: number): OpenCodeSettings { return { port, hostname: "127.0.0.1", autoStart: false, opencodePath: "opencode", projectDirectory: "", startupTimeout: process.platform === "win32" ? 15000 : TEST_TIMEOUT_MS, defaultViewLocation: "sidebar", injectWorkspaceContext: true, maxNotesInContext: 20, maxSelectionLength: 2000, customCommand: "", useCustomCommand: false, }; } // Track current manager for cleanup let currentManager: ServerManager | null = null; // Verify opencode binary is available before running tests beforeAll(async () => { const proc = Bun.spawn(["opencode", "--version"], { stdout: "pipe", stderr: "pipe", }); const exitCode = await proc.exited; if (exitCode !== 0) { throw new Error( "opencode binary not found or not executable. " + "Please ensure 'opencode' is installed and available in PATH." ); } }); // Cleanup after each test afterEach(async () => { if (currentManager) { await currentManager.stop(); // Give process time to fully terminate await new Promise((resolve) => setTimeout(resolve, 500)); currentManager = null; } }); describe("ServerManager", () => { describe("happy path", () => { test("starts server and transitions to running state", async () => { const port = getNextPort(); const settings = createTestSettings(port); const stateHistory: ServerState[] = []; currentManager = new ServerManager(settings, PROJECT_DIR); currentManager.on("stateChange", (state: ServerState) => { stateHistory.push(state); }); expect(currentManager.getState()).toBe("stopped"); const success = await currentManager.start(); expect(success).toBe(true); expect(currentManager.getState()).toBe("running"); expect(stateHistory).toContain("starting"); expect(stateHistory).toContain("running"); }, 30000); // Increased timeout for database migration on first run test("reports correct server URL with encoded project directory", async () => { const port = getNextPort(); const settings = createTestSettings(port); currentManager = new ServerManager(settings, PROJECT_DIR); const url = currentManager.getUrl(); const expectedBase = `http://127.0.0.1:${port}`; const expectedPath = Buffer.from(PROJECT_DIR).toString('base64'); expect(url).toBe(`${expectedBase}/${expectedPath}`); }); test("stops server gracefully and transitions to stopped state", async () => { const port = getNextPort(); const settings = createTestSettings(port); const stateHistory: ServerState[] = []; currentManager = new ServerManager(settings, PROJECT_DIR); currentManager.on("stateChange", (state: ServerState) => { stateHistory.push(state); }); await currentManager.start(); expect(currentManager.getState()).toBe("running"); await currentManager.stop(); expect(currentManager.getState()).toBe("stopped"); expect(stateHistory).toContain("stopped"); }); test("state callbacks fire in correct order: starting -> running", async () => { const port = getNextPort(); const settings = createTestSettings(port); const stateHistory: ServerState[] = []; currentManager = new ServerManager(settings, PROJECT_DIR); currentManager.on("stateChange", (state: ServerState) => { stateHistory.push(state); }); await currentManager.start(); // Verify order: first starting, then running const startingIndex = stateHistory.indexOf("starting"); const runningIndex = stateHistory.indexOf("running"); expect(startingIndex).toBeGreaterThanOrEqual(0); expect(runningIndex).toBeGreaterThan(startingIndex); }); test("can restart after stop", async () => { const port = getNextPort(); const settings = createTestSettings(port); currentManager = new ServerManager(settings, PROJECT_DIR); // First start const firstStart = await currentManager.start(); expect(firstStart).toBe(true); expect(currentManager.getState()).toBe("running"); // Stop await currentManager.stop(); expect(currentManager.getState()).toBe("stopped"); // Wait for process to fully terminate await new Promise((resolve) => setTimeout(resolve, 1000)); // Restart const secondStart = await currentManager.start(); expect(secondStart).toBe(true); expect(currentManager.getState()).toBe("running"); }); test("returns true immediately if already running", async () => { const port = getNextPort(); const settings = createTestSettings(port); currentManager = new ServerManager(settings, PROJECT_DIR); // First start await currentManager.start(); expect(currentManager.getState()).toBe("running"); // Second start should return true immediately without state changes const stateHistory: ServerState[] = []; const onStateChange = (state: ServerState) => { stateHistory.push(state); }; currentManager.on("stateChange", onStateChange); const result = await currentManager.start(); expect(result).toBe(true); expect(currentManager.getState()).toBe("running"); // Should not have triggered any state changes expect(stateHistory).toEqual([]); }); test("health check endpoint is accessible when running", async () => { const port = getNextPort(); const settings = createTestSettings(port); currentManager = new ServerManager(settings, PROJECT_DIR); await currentManager.start(); // Verify we can hit the health endpoint const url = currentManager.getUrl(); const healthUrl = `${url}/global/health`; const response = await fetch(healthUrl, { signal: AbortSignal.timeout(2000), }); expect(response.ok).toBe(true); }); }); describe("async stop behavior", () => { test("stop returns immediately when no process", async () => { const port = getNextPort(); const settings = createTestSettings(port); const stateHistory: ServerState[] = []; currentManager = new ServerManager(settings, PROJECT_DIR); currentManager.on("stateChange", (state: ServerState) => { stateHistory.push(state); }); // Stop without starting - should not throw and set state await currentManager.stop(); expect(currentManager.getState()).toBe("stopped"); }); test("stop completes within timeout when process exits quickly", async () => { const port = getNextPort(); const settings = createTestSettings(port); currentManager = new ServerManager(settings, PROJECT_DIR); await currentManager.start(); expect(currentManager.getState()).toBe("running"); // Stop should complete within 5 seconds (2s SIGTERM wait + 3s SIGKILL wait) const stopStart = Date.now(); await currentManager.stop(); const stopDuration = Date.now() - stopStart; expect(currentManager.getState()).toBe("stopped"); // Should complete well before 5 second timeout expect(stopDuration).toBeLessThan(6000); }); test("process is fully terminated after stop completes", async () => { const port = getNextPort(); const settings = createTestSettings(port); currentManager = new ServerManager(settings, PROJECT_DIR); await currentManager.start(); const url = currentManager.getUrl(); await currentManager.stop(); // Wait a bit then verify server is not accessible await new Promise((resolve) => setTimeout(resolve, 1000)); try { const response = await fetch(`${url}/global/health`, { signal: AbortSignal.timeout(1000), }); // If we get here, server is still running - test should fail expect(response.ok).toBe(false); } catch (e) { // Expected - server should not be accessible expect(e).toBeDefined(); } }); }); describe("error handling", () => { test("handles double stop gracefully", async () => { const port = getNextPort(); const settings = createTestSettings(port); currentManager = new ServerManager(settings, PROJECT_DIR); await currentManager.start(); expect(currentManager.getState()).toBe("running"); // First stop await currentManager.stop(); expect(currentManager.getState()).toBe("stopped"); // Second stop should not throw await currentManager.stop(); expect(currentManager.getState()).toBe("stopped"); }); }); describe("Unicode path support", () => { test("getUrl handles Chinese characters in project directory", () => { const settings = createTestSettings(getNextPort()); const chinesePath = "C:/用户/Notes"; const manager = new ServerManager(settings, chinesePath); const url = manager.getUrl(); expect(url).toContain("http://127.0.0.1:"); expect(url).toContain(Buffer.from(chinesePath).toString('base64')); }); test("getUrl handles Japanese characters in project directory", () => { const settings = createTestSettings(getNextPort()); const japanesePath = "/home/ユーザー/ノート"; const manager = new ServerManager(settings, japanesePath); const url = manager.getUrl(); expect(url).toContain(Buffer.from(japanesePath).toString('base64')); }); test("getUrl handles emoji in project directory", () => { const settings = createTestSettings(getNextPort()); const emojiPath = "/home/user/📁Notes"; const manager = new ServerManager(settings, emojiPath); const url = manager.getUrl(); expect(url).toContain(Buffer.from(emojiPath).toString('base64')); }); }); });