258 lines
7.4 KiB
TypeScript
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");
|
|
});
|
|
});
|