import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { appEvents } from "../events"; import { RunnerService } from "../runner"; // Simple test implementation that doesn't require mocking Bun.spawn describe("RunnerService", () => { let runner: RunnerService; let events: Array<{ event: string; data: any }> = []; beforeEach(() => { runner = new RunnerService(); events = []; // Listen to all runner events and collect them appEvents.on("runner:started", (data) => { events.push({ event: "runner:started", data }); }); appEvents.on("runner:stopped", (data) => { events.push({ event: "runner:stopped", data }); }); appEvents.on("runner:output", (data) => { events.push({ event: "runner:output", data }); }); appEvents.on("runner:error", (data) => { events.push({ event: "runner:error", data }); }); appEvents.on("runner:status", (data) => { events.push({ event: "runner:status", data }); }); appEvents.on("runner:restarted", (data) => { events.push({ event: "runner:restarted", data }); }); appEvents.on("runner:info-updated", (data) => { events.push({ event: "runner:info-updated", data }); }); appEvents.on("runner:get-status", (data) => { events.push({ event: "runner:get-status", data }); }); appEvents.on("runner:restart", (data) => { events.push({ event: "runner:restart", data }); }); }); afterEach(async () => { try { await runner.stopAll(); } catch (error) { // Ignore cleanup errors in tests } appEvents.removeAllListeners(); events = []; }); describe("basic functionality", () => { it("should create a RunnerService instance", () => { expect(runner).toBeDefined(); expect(runner).toBeInstanceOf(RunnerService); }); it("should have expected methods", () => { expect(typeof runner.start).toBe("function"); expect(typeof runner.stop).toBe("function"); expect(typeof runner.getServerInfo).toBe("function"); expect(typeof runner.getAllProcesses).toBe("function"); expect(typeof runner.isRunning).toBe("function"); expect(typeof runner.stopAll).toBe("function"); expect(typeof runner.restart).toBe("function"); expect(typeof runner.updateProcessInfo).toBe("function"); }); it("should return empty processes initially", () => { const processes = runner.getAllProcesses(); expect(processes).toEqual([]); }); it("should return null for non-existent process info", () => { const info = runner.getServerInfo("non-existent-id"); expect(info).toBeNull(); }); it("should return false for non-existent process running check", () => { const isRunning = runner.isRunning("non-existent-id"); expect(isRunning).toBe(false); }); it("should return false when stopping non-existent process", async () => { const result = await runner.stop("non-existent-id"); expect(result).toBe(false); }); it("should return false when restarting non-existent process", async () => { const result = await runner.restart("non-existent-id"); expect(result).toBe(false); }); it("should return false when updating non-existent process info", () => { const result = runner.updateProcessInfo("non-existent-id", { name: "test", }); expect(result).toBe(false); }); }); describe("server info validation", () => { it("should validate RunnerOptions interface requirements", () => { // Test that the required command field is enforced by TypeScript // This is a compile-time check, but we can verify the structure const validOptions = { command: "echo hello", }; expect(validOptions.command).toBe("echo hello"); }); it("should validate ServerInfo interface structure", () => { const serverInfo = { serverUrl: "http://localhost:3000", name: "test-server", status: "active" as const, os: "Ubuntu 22.04", shell: "/bin/bash", cwd: "/home/user", command: "npm run dev", pid: 12345, startTime: new Date(), uptime: 1000, }; expect(serverInfo.serverUrl).toBe("http://localhost:3000"); expect(serverInfo.name).toBe("test-server"); expect(serverInfo.status).toBe("active"); expect(serverInfo.os).toBe("Ubuntu 22.04"); expect(serverInfo.shell).toBe("/bin/bash"); expect(serverInfo.cwd).toBe("/home/user"); expect(serverInfo.command).toBe("npm run dev"); expect(serverInfo.pid).toBe(12345); expect(serverInfo.startTime).toBeInstanceOf(Date); expect(serverInfo.uptime).toBe(1000); }); }); // Integration test with actual echo command (safe and fast) describe("real process integration", () => { it("should start and manage a simple echo process", async () => { try { const processId = await runner.start({ command: "echo Hello, World!", }); expect(processId).toBeDefined(); expect(typeof processId).toBe("string"); // Check if process was registered const processes = runner.getAllProcesses(); expect(processes).toHaveLength(1); expect(processes[0].id).toBe(processId); expect(processes[0].info.name).toBe("echo-process"); expect(processes[0].info.command).toBe("echo Hello, World!"); expect(processes[0].info.status).toBe("active"); // Check server info const serverInfo = runner.getServerInfo(processId); expect(serverInfo).not.toBeNull(); expect(serverInfo?.name).toBe("echo-process"); expect(serverInfo?.command).toBe("echo Hello, World!"); expect(serverInfo?.status).toBe("active"); expect(serverInfo?.pid).toBeGreaterThan(0); expect(serverInfo?.startTime).toBeInstanceOf(Date); // Check if running expect(runner.isRunning(processId)).toBe(true); // Wait a moment and check if process completed (echo exits quickly) await new Promise((resolve) => setTimeout(resolve, 100)); // Events should have been emitted expect(events.length).toBeGreaterThan(0); const statusEvents = events.filter((e) => e.event === "runner:status"); expect(statusEvents.length).toBeGreaterThan(0); const startedEvents = events.filter( (e) => e.event === "runner:started" ); expect(startedEvents.length).toBe(1); expect(startedEvents[0].data.processId).toBe(processId); expect(startedEvents[0].data.serverInfo.name).toBe("echo-process"); // Clean up await runner.stop(processId); } catch (error) { // Skip test if echo command is not available console.warn("Skipping echo test:", error); } }, 5000); it("should handle process that doesn't exist", async () => { try { await runner.start({ command: "non-existent-command-12345", }); // Should not reach here expect(true).toBe(false); } catch (error) { expect(error).toBeDefined(); // Should have emitted error event const errorEvents = events.filter((e) => e.event === "runner:error"); expect(errorEvents.length).toBeGreaterThan(0); } }); }); describe("event system", () => { it("should emit events in correct format", async () => { // Test event structure without actual process execution const testData = { processId: "test-id", serverInfo: { name: "test-server", command: "test command", cwd: "/test", }, }; // Manually emit events to test event listener setup appEvents.emit("runner:started", { processId: testData.processId, serverInfo: { ...testData.serverInfo, pid: 12345, serverUrl: "http://localhost:3000", os: "Test OS", shell: "/bin/test", startTime: new Date(), }, }); appEvents.emit("runner:output", { processId: testData.processId, type: "stdout", chunk: "test output", command: testData.serverInfo.command, cwd: testData.serverInfo.cwd, }); appEvents.emit("runner:error", { processId: testData.processId, error: "test error", serverInfo: testData.serverInfo, }); appEvents.emit("runner:stopped", { processId: testData.processId, exitCode: 0, serverInfo: { ...testData.serverInfo, uptime: 1000, }, }); appEvents.emit("runner:status", { processId: testData.processId, status: "stopped", serverInfo: { ...testData.serverInfo, pid: 12345, uptime: 1000, }, }); // Verify all events were captured expect(events).toHaveLength(5); const startedEvent = events.find((e) => e.event === "runner:started"); expect(startedEvent).toBeDefined(); expect(startedEvent?.data.processId).toBe("test-id"); expect(startedEvent?.data.serverInfo.name).toBe("test-server"); const outputEvent = events.find((e) => e.event === "runner:output"); expect(outputEvent).toBeDefined(); expect(outputEvent?.data.type).toBe("stdout"); expect(outputEvent?.data.chunk).toBe("test output"); const errorEvent = events.find((e) => e.event === "runner:error"); expect(errorEvent).toBeDefined(); expect(errorEvent?.data.error).toBe("test error"); const stoppedEvent = events.find((e) => e.event === "runner:stopped"); expect(stoppedEvent).toBeDefined(); expect(stoppedEvent?.data.exitCode).toBe(0); const statusEvent = events.find((e) => e.event === "runner:status"); expect(statusEvent).toBeDefined(); expect(statusEvent?.data.status).toBe("stopped"); }); }); describe("error handling", () => { it("should handle malformed options gracefully", async () => { try { // @ts-expect-error - Intentionally testing error case await runner.start({}); expect(true).toBe(false); // Should not reach here } catch (error) { expect(error).toBeDefined(); } try { // @ts-expect-error - Intentionally testing error case await runner.start({ command: null }); expect(true).toBe(false); // Should not reach here } catch (error) { expect(error).toBeDefined(); } }); it("should auto-detect process names based on command", async () => { const commands = [ { command: "npm run dev", expectedName: "npm-dev-server" }, { command: "bun run dev", expectedName: "bun-dev-server" }, { command: "next dev", expectedName: "next-dev-server" }, { command: "vite --port 3000", expectedName: "vite-dev-server" }, { command: "nodemon app.js", expectedName: "nodemon-server" }, { command: "npx serve dist", expectedName: "serve-static-server" }, { command: "npx live-server --no-browser .", expectedName: "live-server", }, { command: "unknown-command", expectedName: "unknown-process" }, ]; for (const { command, expectedName } of commands) { try { const processId = await runner.start({ command }); const info = runner.getServerInfo(processId); expect(info?.name).toBe(expectedName); await runner.stop(processId); } catch (error) { // Some commands might not exist, that's OK for this test console.warn(`Command ${command} not available:`, error); } } }); }); describe("restart functionality", () => { it("should handle restart with same command", async () => { try { const processId = await runner.start({ command: "echo test1", }); // Wait for process to complete await new Promise((resolve) => setTimeout(resolve, 100)); const restarted = await runner.restart(processId); expect(restarted).toBe(true); // Check that restart event was emitted const restartEvents = events.filter( (e) => e.event === "runner:restarted" ); expect(restartEvents.length).toBeGreaterThan(0); expect(restartEvents[0].data.processId).toBe(processId); expect(restartEvents[0].data.oldCommand).toBe("echo test1"); expect(restartEvents[0].data.newCommand).toBe("echo test1"); } catch (error) { console.warn("Skipping restart test:", error); } }); it("should handle restart with new command", async () => { try { const processId = await runner.start({ command: "echo test1", }); // Wait for process to complete await new Promise((resolve) => setTimeout(resolve, 100)); const restarted = await runner.restart(processId, "echo test2"); expect(restarted).toBe(true); // Check that restart event was emitted with new command const restartEvents = events.filter( (e) => e.event === "runner:restarted" ); expect(restartEvents.length).toBeGreaterThan(0); expect(restartEvents[0].data.processId).toBe(processId); expect(restartEvents[0].data.oldCommand).toBe("echo test1"); expect(restartEvents[0].data.newCommand).toBe("echo test2"); } catch (error) { console.warn("Skipping restart with new command test:", error); } }); }); describe("update process info functionality", () => { it("should update process name", async () => { try { const processId = await runner.start({ command: "echo test", }); const updated = runner.updateProcessInfo(processId, { name: "custom-process-name", }); expect(updated).toBe(true); const info = runner.getServerInfo(processId); expect(info?.name).toBe("custom-process-name"); // Check that update event was emitted const updateEvents = events.filter( (e) => e.event === "runner:info-updated" ); expect(updateEvents.length).toBeGreaterThan(0); expect(updateEvents[0].data.processId).toBe(processId); expect(updateEvents[0].data.updates.name).toBe("custom-process-name"); } catch (error) { console.warn("Skipping update name test:", error); } }); it("should update server URL", async () => { try { const processId = await runner.start({ command: "echo test", }); const updated = runner.updateProcessInfo(processId, { serverUrl: "http://localhost:3000", }); expect(updated).toBe(true); const info = runner.getServerInfo(processId); expect(info?.serverUrl).toBe("http://localhost:3000"); // Check that update event was emitted const updateEvents = events.filter( (e) => e.event === "runner:info-updated" ); expect(updateEvents.length).toBeGreaterThan(0); expect(updateEvents[0].data.processId).toBe(processId); expect(updateEvents[0].data.updates.serverUrl).toBe( "http://localhost:3000" ); } catch (error) { console.warn("Skipping update URL test:", error); } }); it("should update both name and URL", async () => { try { const processId = await runner.start({ command: "echo test", }); const updated = runner.updateProcessInfo(processId, { name: "my-server", serverUrl: "https://localhost:8080", }); expect(updated).toBe(true); const info = runner.getServerInfo(processId); expect(info?.name).toBe("my-server"); expect(info?.serverUrl).toBe("https://localhost:8080"); // Check that update event was emitted const updateEvents = events.filter( (e) => e.event === "runner:info-updated" ); expect(updateEvents.length).toBeGreaterThan(0); expect(updateEvents[0].data.processId).toBe(processId); expect(updateEvents[0].data.updates.name).toBe("my-server"); expect(updateEvents[0].data.updates.serverUrl).toBe( "https://localhost:8080" ); } catch (error) { console.warn("Skipping update both test:", error); } }); }); describe("event-based control", () => { it("should handle get-status requests for specific process", async () => { try { const processId = await runner.start({ command: "echo test", }); // Wait for process to start await new Promise((resolve) => setTimeout(resolve, 100)); // Clear events to focus on status request events = []; // Request status for specific process const { requestStatus } = await import("../runner"); requestStatus(processId); // Wait for event processing await new Promise((resolve) => setTimeout(resolve, 100)); // Check that get-status event was emitted const getStatusEvents = events.filter( (e) => e.event === "runner:get-status" ); expect(getStatusEvents.length).toBe(1); expect(getStatusEvents[0].data.processId).toBe(processId); // Check that runner:status event was emitted in response const statusEvents = events.filter((e) => e.event === "runner:status"); expect(statusEvents.length).toBeGreaterThan(0); const statusEvent = statusEvents.find( (e) => e.data.processId === processId ); expect(statusEvent).toBeDefined(); expect(statusEvent?.data.serverInfo.name).toBe("echo-process"); } catch (error) { console.warn("Skipping event-based status test:", error); } }); it("should handle get-status requests for all processes", async () => { try { const processId1 = await runner.start({ command: "echo test1" }); const processId2 = await runner.start({ command: "echo test2" }); // Wait for processes to start await new Promise((resolve) => setTimeout(resolve, 100)); // Clear events events = []; // Request status for all processes const { requestStatus } = await import("../runner"); requestStatus(); // No processId = all processes // Wait for event processing await new Promise((resolve) => setTimeout(resolve, 100)); // Check that get-status event was emitted const getStatusEvents = events.filter( (e) => e.event === "runner:get-status" ); expect(getStatusEvents.length).toBe(1); expect(getStatusEvents[0].data.processId).toBeUndefined(); // Check that runner:status events were emitted for all processes const statusEvents = events.filter((e) => e.event === "runner:status"); expect(statusEvents.length).toBeGreaterThanOrEqual(2); } catch (error) { console.warn("Skipping event-based all status test:", error); } }); it("should handle restart requests via events", async () => { try { const processId = await runner.start({ command: "echo test1", }); // Wait for process to complete await new Promise((resolve) => setTimeout(resolve, 200)); // Clear events events = []; // Request restart with new command const { requestRestart } = await import("../runner"); requestRestart(processId, "echo test2"); // Wait for restart processing await new Promise((resolve) => setTimeout(resolve, 1500)); // Check that restart event was emitted const restartRequestEvents = events.filter( (e) => e.event === "runner:restart" ); expect(restartRequestEvents.length).toBe(1); expect(restartRequestEvents[0].data.processId).toBe(processId); expect(restartRequestEvents[0].data.newCommand).toBe("echo test2"); // Check that runner:restarted event was emitted const restartedEvents = events.filter( (e) => e.event === "runner:restarted" ); expect(restartedEvents.length).toBeGreaterThan(0); const restartedEvent = restartedEvents.find( (e) => e.data.processId === processId ); expect(restartedEvent).toBeDefined(); expect(restartedEvent?.data.oldCommand).toBe("echo test1"); expect(restartedEvent?.data.newCommand).toBe("echo test2"); } catch (error) { console.warn("Skipping event-based restart test:", error); } }); it("should handle restart requests with same command", async () => { try { const processId = await runner.start({ command: "echo test", }); // Wait for process to complete await new Promise((resolve) => setTimeout(resolve, 200)); // Clear events events = []; // Request restart without new command const { requestRestart } = await import("../runner"); requestRestart(processId); // No new command = use same command // Wait for restart processing await new Promise((resolve) => setTimeout(resolve, 1500)); // Check that restart event was emitted const restartRequestEvents = events.filter( (e) => e.event === "runner:restart" ); expect(restartRequestEvents.length).toBe(1); expect(restartRequestEvents[0].data.processId).toBe(processId); expect(restartRequestEvents[0].data.newCommand).toBeUndefined(); // Check that runner:restarted event was emitted const restartedEvents = events.filter( (e) => e.event === "runner:restarted" ); expect(restartedEvents.length).toBeGreaterThan(0); const restartedEvent = restartedEvents.find( (e) => e.data.processId === processId ); expect(restartedEvent).toBeDefined(); expect(restartedEvent?.data.oldCommand).toBe("echo test"); expect(restartedEvent?.data.newCommand).toBe("echo test"); } catch (error) { console.warn("Skipping event-based same restart test:", error); } }); it("should emit error for failed restart requests", async () => { // Clear events events = []; // Request restart for non-existent process const { requestRestart } = await import("../runner"); requestRestart("non-existent-id1", "echo test"); // Wait for error processing await new Promise((resolve) => setTimeout(resolve, 100)); // Check that restart event was emitted const restartRequestEvents = events.filter( (e) => e.event === "runner:restart" ); expect(restartRequestEvents.length).toBe(1); // Check that error event was emitted const errorEvents = events.filter((e) => e.event === "runner:error"); expect(errorEvents.length).toBeGreaterThan(0); const errorEvent = errorEvents.find( (e) => e.data.processId === "non-existent-id1" ); expect(errorEvent).toBeDefined(); expect(errorEvent?.data.error).toBe("Failed to restart process"); }); }); }); describe("RunnerService singleton", () => { it("should export a singleton instance", async () => { const { runnerService } = await import("../runner"); expect(runnerService).toBeDefined(); expect(runnerService).toBeInstanceOf(RunnerService); }); });