Files
fusion/packages/dashboard/src/__tests__/devserver-preview-detect.test.ts
2026-04-22 13:46:53 -07:00

258 lines
7.4 KiB
TypeScript

// @vitest-environment node
import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DevServerManager, destroyAllDevServerManagers } from "../devserver-manager.js";
import { createDevServerId, type DevServerConfig } from "../devserver-types.js";
const { spawnMock, createConnectionMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
createConnectionMock: vi.fn(),
}));
vi.mock("node:child_process", () => ({
spawn: spawnMock,
}));
vi.mock("node:net", () => ({
createConnection: createConnectionMock,
}));
interface MockSocket extends EventEmitter {
setTimeout: (ms: number) => void;
destroy: () => void;
end: () => void;
}
interface MockChildProcess extends EventEmitter {
pid: number;
killed: boolean;
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
}
function createMockSocket(): MockSocket {
const socket = new EventEmitter() as MockSocket;
socket.setTimeout = vi.fn();
socket.destroy = vi.fn();
socket.end = vi.fn();
return socket;
}
function createMockChildProcess(pid: number): MockChildProcess {
const child = new EventEmitter() as MockChildProcess;
child.pid = pid;
child.killed = false;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = vi.fn(() => {
child.killed = true;
return true;
});
return child;
}
function makeConfig(id = "preview-test"): DevServerConfig {
return {
id: createDevServerId(id),
name: id,
command: "npm run dev",
cwd: "/repo",
};
}
describe("devserver-preview-detect", () => {
let manager: DevServerManager;
let nextPid: number;
let children: MockChildProcess[];
beforeEach(() => {
nextPid = 1000;
children = [];
spawnMock.mockReset();
createConnectionMock.mockReset();
spawnMock.mockImplementation(() => {
const child = createMockChildProcess(nextPid++);
children.push(child);
return child;
});
createConnectionMock.mockImplementation(() => {
const socket = createMockSocket();
queueMicrotask(() => {
socket.emit("error", new Error("closed"));
});
return socket;
});
manager = new DevServerManager("/project");
});
afterEach(() => {
manager.destroy();
destroyAllDevServerManagers();
vi.useRealTimers();
});
it("detects URL from stdout with localhost:3000", async () => {
const config = makeConfig();
await manager.startServer(config);
children[0].stdout.emit("data", "Server ready at http://localhost:3000");
expect(manager.getSession(config.id)?.previewUrl).toBe("http://localhost:3000");
});
it("detects URL from stdout with 127.0.0.1:5173", async () => {
const config = makeConfig();
await manager.startServer(config);
children[0].stdout.emit("data", "Vite ready: http://127.0.0.1:5173");
expect(manager.getSession(config.id)?.previewUrl).toBe("http://127.0.0.1:5173");
});
it("detects URL from stderr output", async () => {
const config = makeConfig();
await manager.startServer(config);
children[0].stderr.emit("data", "Listening on http://localhost:4000");
expect(manager.getSession(config.id)?.previewUrl).toBe("http://localhost:4000");
});
it("first URL wins when multiple URLs are present", async () => {
const config = makeConfig();
await manager.startServer(config);
children[0].stdout.emit(
"data",
"Ready at http://localhost:3000 then proxy at http://localhost:3001",
);
// First URL should win
expect(manager.getSession(config.id)?.previewUrl).toBe("http://localhost:3000");
});
it("setPreviewUrl updates the preview URL manually", async () => {
const config = makeConfig();
await manager.startServer(config);
// Set manual URL
manager.setPreviewUrl(config.id, "http://custom:9999");
expect(manager.getSession(config.id)?.previewUrl).toBe("http://custom:9999");
});
it("port probing fallback sets URL when no URL is detected", async () => {
vi.useFakeTimers();
createConnectionMock.mockReset();
createConnectionMock.mockImplementation((options: { port: number }) => {
const socket = createMockSocket();
queueMicrotask(() => {
if (options.port === 3000) {
socket.emit("connect");
} else {
socket.emit("error", new Error("closed"));
}
});
return socket;
});
const config = makeConfig();
await manager.startServer(config);
// No URL output, just some generic output
children[0].stdout.emit("data", "started");
// Advance timers past the probe delay (10s)
await vi.advanceTimersByTimeAsync(10_001);
expect(manager.getSession(config.id)?.previewUrl).toBe("http://localhost:3000");
});
it("URL stays null when port probing finds nothing", async () => {
vi.useFakeTimers();
createConnectionMock.mockReset();
// All connection attempts fail
createConnectionMock.mockImplementation(() => {
const socket = createMockSocket();
queueMicrotask(() => {
socket.emit("error", new Error("closed"));
});
return socket;
});
const config = makeConfig();
await manager.startServer(config);
// No URL output
children[0].stdout.emit("data", "started");
// Advance timers past the probe delay
await vi.advanceTimersByTimeAsync(10_001);
expect(manager.getSession(config.id)?.previewUrl).toBeUndefined();
});
it("clearing manual override reveals auto-detected URL", async () => {
const config = makeConfig();
await manager.startServer(config);
// Set manual override
manager.setPreviewUrl(config.id, "http://custom:9999");
// Then clear it
manager.setPreviewUrl(config.id, null);
// Should be undefined now (since no auto-detection happened)
expect(manager.getSession(config.id)?.previewUrl).toBeUndefined();
// Now emit auto-detection
children[0].stdout.emit("data", "Server at http://localhost:3000");
expect(manager.getSession(config.id)?.previewUrl).toBe("http://localhost:3000");
});
it("emits preview event on URL detection", async () => {
const config = makeConfig();
const onPreview = vi.fn();
manager.on("preview", onPreview);
await manager.startServer(config);
children[0].stdout.emit("data", "Ready at http://localhost:3000");
expect(onPreview).toHaveBeenCalledTimes(1);
expect(onPreview).toHaveBeenCalledWith(config.id, "http://localhost:3000");
});
it("emits preview event on manual override", async () => {
const config = makeConfig();
const onPreview = vi.fn();
manager.on("preview", onPreview);
await manager.startServer(config);
manager.setPreviewUrl(config.id, "http://manual:8888");
expect(onPreview).toHaveBeenCalledTimes(1);
expect(onPreview).toHaveBeenCalledWith(config.id, "http://manual:8888");
});
it("handles URL in the middle of log line", async () => {
const config = makeConfig();
await manager.startServer(config);
children[0].stdout.emit(
"data",
"[INFO] Application started on http://localhost:5173 and ready for connections",
);
expect(manager.getSession(config.id)?.previewUrl).toBe("http://localhost:5173");
});
it("detects URL with explicit port", async () => {
const config = makeConfig();
await manager.startServer(config);
children[0].stdout.emit("data", "Running at http://localhost:3000");
expect(manager.getSession(config.id)?.previewUrl).toBe("http://localhost:3000");
});
});