Files
fusion/packages/dashboard/src/__tests__/dev-server-routes.test.ts
gsxdsm 1e6714e4df chore(test): add cross-worktree test lock and clean up dev-server test child
- scripts/test-with-lock.mjs: pnpm test:locked acquires an exclusive
  ~/.fusion/test.lock (O_EXLOCK) before running pnpm test, so several
  Claude Code worktrees on the same machine serialize their vitest
  fan-out instead of saturating the box. Prints the holding PID and
  worktree path while waiting.
- dev-server-manager test children now park on stdin instead of
  setInterval so manager.shutdown() can deterministically exit them
  via stdin close, preventing orphaned node processes outliving the
  test run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:57:18 -07:00

500 lines
15 KiB
TypeScript

// @vitest-environment node
import express from "express";
import http from "node:http";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import { join } from "node:path";
import type { Socket } from "node:net";
import { PassThrough } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
import { request } from "../test-request.js";
import {
createDevServerRouter,
destroyAllDevServerManagers,
getActiveProcessManagers,
} from "../dev-server-routes.js";
import { loadDevServerStore } from "../dev-server-store.js";
import * as detectModule from "../dev-server-detect.js";
function createProjectRoot(): string {
return mkdtempSync(join(os.tmpdir(), "fn-dev-server-routes-"));
}
function buildApp(projectRoot: string): express.Express {
const app = express();
app.use(express.json());
app.use("/api/dev-server", createDevServerRouter({ projectRoot }));
return app;
}
async function waitFor(predicate: () => boolean, timeoutMs = 4_000): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error("Timed out waiting for condition");
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
class MockSocket extends PassThrough {
public writable = true;
public readable = true;
public remoteAddress = "127.0.0.1";
public encrypted = false;
setTimeout(): this {
return this;
}
setNoDelay(): this {
return this;
}
setKeepAlive(): this {
return this;
}
destroySoon(): void {
this.destroy();
}
}
async function openSseStream(app: express.Express, path: string) {
const socket = new MockSocket();
const req = new http.IncomingMessage(socket as unknown as Socket);
const res = new http.ServerResponse(req);
const chunks: Buffer[] = [];
req.method = "GET";
req.url = path;
req.httpVersion = "1.1";
req.headers = { host: "127.0.0.1" };
res.assignSocket(socket as unknown as Socket);
const originalWrite = res.write.bind(res);
res.write = ((chunk: string | Buffer, encoding?: BufferEncoding | ((error?: Error | null) => void), cb?: (error?: Error | null) => void) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof encoding === "string" ? encoding : undefined));
return originalWrite(chunk as never, encoding as never, cb);
}) as typeof res.write;
app(req, res);
await new Promise((resolve) => process.nextTick(resolve));
req.complete = true;
req.emit("end");
return {
status: res.statusCode,
headers: res.getHeaders(),
readText() {
return Buffer.concat(chunks).toString("utf8");
},
close() {
req.emit("close");
res.emit("close");
socket.destroy();
},
};
}
describe("createDevServerRouter", () => {
const tempDirs: string[] = [];
afterEach(async () => {
vi.restoreAllMocks();
await destroyAllDevServerManagers();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("GET /api/dev-server/detect returns candidates", async () => {
const root = createProjectRoot();
tempDirs.push(root);
vi.spyOn(detectModule, "detectDevServerScripts").mockResolvedValue({
candidates: [
{
name: "dev",
command: "vite",
source: "root",
confidence: 0.9,
},
],
});
const app = buildApp(root);
const res = await request(app, "GET", "/api/dev-server/detect");
expect(res.status).toBe(200);
expect((res.body as { candidates: unknown[] }).candidates).toHaveLength(1);
});
it("GET /api/dev-server/status returns default state", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
const res = await request(app, "GET", "/api/dev-server/status");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
status: "stopped",
command: "",
cwd: "",
logHistory: [],
previewUrl: null,
detectedPort: null,
manualPreviewUrl: null,
isRunning: false,
});
});
it("GET /api/dev-server/status exposes previewUrl, detectedPort, and manualPreviewUrl", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const store = await loadDevServerStore(root);
await store.updateState({
detectedUrl: "http://localhost:5173",
detectedPort: 5173,
manualUrl: "https://localhost:3000",
});
const app = buildApp(root);
const res = await request(app, "GET", "/api/dev-server/status");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
previewUrl: "https://localhost:3000",
detectedPort: 5173,
manualPreviewUrl: "https://localhost:3000",
});
});
it("GET /api/dev-server/status falls back to detected URL when no manual override exists", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const store = await loadDevServerStore(root);
await store.updateState({
detectedUrl: "http://localhost:4321",
detectedPort: 4321,
manualUrl: undefined,
});
const app = buildApp(root);
const res = await request(app, "GET", "/api/dev-server/status");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
previewUrl: "http://localhost:4321",
detectedPort: 4321,
manualPreviewUrl: null,
});
});
it("POST /api/dev-server/start validates required command", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
const res = await request(
app,
"POST",
"/api/dev-server/start",
JSON.stringify({ cwd: root }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
});
it("POST /api/dev-server/start validates required cwd", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
const res = await request(
app,
"POST",
"/api/dev-server/start",
JSON.stringify({ command: "node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
});
it("POST /api/dev-server/start starts process and returns running state", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
const res = await request(
app,
"POST",
"/api/dev-server/start",
JSON.stringify({ command: "node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", cwd: root }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
status: "running",
cwd: root,
});
});
it("POST /api/dev-server/start returns 409 when already running", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
await request(
app,
"POST",
"/api/dev-server/start",
JSON.stringify({ command: "node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", cwd: root }),
{ "Content-Type": "application/json" },
);
const secondStart = await request(
app,
"POST",
"/api/dev-server/start",
JSON.stringify({ command: "node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", cwd: root }),
{ "Content-Type": "application/json" },
);
expect(secondStart.status).toBe(409);
});
it("POST /api/dev-server/stop stops a running process", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
await request(
app,
"POST",
"/api/dev-server/start",
JSON.stringify({ command: "node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", cwd: root }),
{ "Content-Type": "application/json" },
);
const stopRes = await request(app, "POST", "/api/dev-server/stop");
expect(stopRes.status).toBe(200);
expect(stopRes.body).toMatchObject({ status: "stopped" });
});
it("POST /api/dev-server/stop returns current state when nothing is running", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
const stopRes = await request(app, "POST", "/api/dev-server/stop");
expect(stopRes.status).toBe(200);
expect(stopRes.body).toMatchObject({ status: "stopped" });
});
it("POST /api/dev-server/restart restarts with stored command", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
await request(
app,
"POST",
"/api/dev-server/start",
JSON.stringify({ command: "node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", cwd: root, scriptId: "dev" }),
{ "Content-Type": "application/json" },
);
const restartRes = await request(app, "POST", "/api/dev-server/restart");
expect(restartRes.status).toBe(200);
expect(restartRes.body).toMatchObject({
status: "running",
command: "node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"",
scriptId: "dev",
});
});
it("POST /api/dev-server/restart returns 400 without stored command", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
const restartRes = await request(app, "POST", "/api/dev-server/restart");
expect(restartRes.status).toBe(400);
});
it("PUT /api/dev-server/preview-url sets manual URL", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
const res = await request(
app,
"PUT",
"/api/dev-server/preview-url",
JSON.stringify({ url: "https://localhost:5173" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ manualUrl: "https://localhost:5173" });
});
it("PUT /api/dev-server/preview-url validates URL format", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
const res = await request(
app,
"PUT",
"/api/dev-server/preview-url",
JSON.stringify({ url: "localhost:5173" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
});
it("PUT /api/dev-server/preview-url clears override with empty string", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
await request(
app,
"PUT",
"/api/dev-server/preview-url",
JSON.stringify({ url: "https://localhost:5173" }),
{ "Content-Type": "application/json" },
);
const cleared = await request(
app,
"PUT",
"/api/dev-server/preview-url",
JSON.stringify({ url: "" }),
{ "Content-Type": "application/json" },
);
expect(cleared.status).toBe(200);
expect(cleared.body).not.toHaveProperty("manualUrl");
});
it("GET /api/dev-server/logs/stream returns SSE headers and initial history", async () => {
const root = createProjectRoot();
tempDirs.push(root);
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "demo" }), "utf-8");
const store = await loadDevServerStore(root);
await store.appendLog("history line");
const app = buildApp(root);
const stream = await openSseStream(app, "/api/dev-server/logs/stream");
expect(stream.status).toBe(200);
expect(String(stream.headers["content-type"])).toContain("text/event-stream");
const chunkText = stream.readText();
expect(chunkText).toContain(": connected");
expect(chunkText).toContain("event: history");
expect(chunkText).toContain("history line");
stream.close();
});
it("SSE stream receives new log events when process outputs", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
const stream = await openSseStream(app, "/api/dev-server/logs/stream");
await waitFor(() => getActiveProcessManagers().length > 0);
const manager = getActiveProcessManagers()[0];
expect(manager).toBeDefined();
manager.emit("output", {
line: "stream-line",
stream: "stdout",
timestamp: new Date().toISOString(),
});
await waitFor(() => stream.readText().includes("stream-line"));
const buffered = stream.readText();
expect(buffered).toContain("event: log");
expect(buffered).toContain("stream-line");
stream.close();
});
it("SSE stream forwards url-detected events with the documented payload", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
const stream = await openSseStream(app, "/api/dev-server/logs/stream");
await waitFor(() => getActiveProcessManagers().length > 0);
const manager = getActiveProcessManagers()[0];
expect(manager).toBeDefined();
manager.emit("url-detected", {
url: "http://localhost:5173",
port: 5173,
source: "generic-url",
detectedAt: new Date().toISOString(),
});
await waitFor(() => stream.readText().includes("event: dev-server:url-detected"));
const buffered = stream.readText();
expect(buffered).toContain("event: dev-server:url-detected");
const payloadMatch = buffered.match(/event: dev-server:url-detected\ndata: (.+)/);
expect(payloadMatch).toBeTruthy();
const payload = JSON.parse(payloadMatch?.[1] ?? "{}");
expect(payload).toMatchObject({
url: "http://localhost:5173",
port: 5173,
source: "generic-url",
});
expect(typeof payload.detectedAt).toBe("string");
expect(Number.isNaN(Date.parse(payload.detectedAt))).toBe(false);
expect(Object.keys(payload).sort()).toEqual(["detectedAt", "port", "source", "url"]);
stream.close();
});
it("SSE stream cleans up listeners on client disconnect", async () => {
const root = createProjectRoot();
tempDirs.push(root);
const app = buildApp(root);
const stream = await openSseStream(app, "/api/dev-server/logs/stream");
await waitFor(() => getActiveProcessManagers().length > 0);
await waitFor(() => {
const manager = getActiveProcessManagers()[0];
return (manager?.listenerCount("output") ?? 0) > 0
&& (manager?.listenerCount("url-detected") ?? 0) > 0;
});
stream.close();
await waitFor(() => {
const manager = getActiveProcessManagers()[0];
return (manager?.listenerCount("output") ?? 0) === 0
&& (manager?.listenerCount("url-detected") ?? 0) === 0;
});
});
});