feat(FN-2178): add dashboard dev server management flow

- Add DevServerManager service to start/stop/restart dev processes, persist state, stream logs, and detect preview URLs
- Add dev server candidate detection across root/workspace package scripts with caching and invalidation helpers
- Expose /api/dev-server routes for candidates, status, lifecycle actions, manual preview URL, and SSE log/status stream
- Initialize and tear down dev server managers from dashboard server lifecycle
- Add unit and route tests covering detection, manager lifecycle, URL parsing/fallback probes, and API validation
This commit is contained in:
Fusion
2026-04-19 14:13:02 -07:00
committed by gsxdsm
parent f588301d8e
commit ff2813ce4a
7 changed files with 1797 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
// @vitest-environment node
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, utimesSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
detectDevServerCandidates,
EXCLUDED_SCRIPT_NAMES,
invalidateDetectionCache,
PREFERRED_SCRIPT_NAMES,
} from "../dev-server-detect.js";
import { FALLBACK_PORTS } from "../dev-server-manager.js";
function writeJson(filePath: string, value: unknown): void {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
}
describe("detectDevServerCandidates", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(path.join(os.tmpdir(), "fn-dev-detect-"));
invalidateDetectionCache();
});
afterEach(() => {
invalidateDetectionCache();
rmSync(tempDir, { recursive: true, force: true });
});
it("detects preferred scripts from root package.json and excludes lint scripts", async () => {
writeJson(path.join(tempDir, "package.json"), {
scripts: {
dev: "vite",
start: "next dev",
lint: "eslint .",
},
});
const candidates = await detectDevServerCandidates(tempDir);
expect(candidates.map((candidate) => candidate.scriptName)).toEqual(["dev", "start"]);
expect(candidates[0]).toMatchObject({
scriptName: "dev",
label: "Root > dev",
cwd: tempDir,
});
expect(candidates[1]).toMatchObject({
scriptName: "start",
label: "Root > start",
cwd: tempDir,
});
});
it("returns empty array when package.json is missing", async () => {
await expect(detectDevServerCandidates(tempDir)).resolves.toEqual([]);
});
it("returns empty array when package.json has invalid json", async () => {
writeFileSync(path.join(tempDir, "package.json"), "{ invalid json", "utf-8");
await expect(detectDevServerCandidates(tempDir)).resolves.toEqual([]);
});
it("returns empty array when scripts field is absent", async () => {
writeJson(path.join(tempDir, "package.json"), { name: "demo" });
await expect(detectDevServerCandidates(tempDir)).resolves.toEqual([]);
});
it("detects workspace candidates from pnpm-workspace.yaml", async () => {
writeJson(path.join(tempDir, "package.json"), { name: "repo" });
writeFileSync(path.join(tempDir, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n", "utf-8");
const workspaceDir = path.join(tempDir, "packages", "web");
mkdirSync(workspaceDir, { recursive: true });
writeJson(path.join(workspaceDir, "package.json"), {
scripts: {
dev: "vite",
},
});
const candidates = await detectDevServerCandidates(tempDir);
expect(candidates).toContainEqual(
expect.objectContaining({
scriptName: "dev",
label: "packages/web > dev",
cwd: workspaceDir,
}),
);
});
it("detects workspace candidates from npm workspaces array", async () => {
writeJson(path.join(tempDir, "package.json"), {
workspaces: ["apps/*"],
});
const clientDir = path.join(tempDir, "apps", "client");
mkdirSync(clientDir, { recursive: true });
writeJson(path.join(clientDir, "package.json"), {
scripts: {
dev: "vite",
},
});
const candidates = await detectDevServerCandidates(tempDir);
expect(candidates).toContainEqual(
expect.objectContaining({
scriptName: "dev",
label: "apps/client > dev",
cwd: clientDir,
}),
);
});
it("detects workspace candidates from npm workspaces object", async () => {
writeJson(path.join(tempDir, "package.json"), {
workspaces: {
packages: ["apps/*"],
},
});
const clientDir = path.join(tempDir, "apps", "client");
mkdirSync(clientDir, { recursive: true });
writeJson(path.join(clientDir, "package.json"), {
scripts: {
dev: "vite",
},
});
const candidates = await detectDevServerCandidates(tempDir);
expect(candidates).toContainEqual(
expect.objectContaining({
scriptName: "dev",
label: "apps/client > dev",
cwd: clientDir,
}),
);
});
it("orders candidates by preferred script priority", async () => {
writeJson(path.join(tempDir, "package.json"), {
scripts: {
preview: "vite preview",
storybook: "storybook dev -p 6006",
frontend: "vite",
web: "vite",
serve: "vite serve",
start: "next start",
dev: "vite dev",
},
});
const candidates = await detectDevServerCandidates(tempDir);
expect(candidates.map((candidate) => candidate.scriptName)).toEqual([...PREFERRED_SCRIPT_NAMES]);
});
it("invalidates cache when package.json mtime changes", async () => {
const packageJsonPath = path.join(tempDir, "package.json");
writeJson(packageJsonPath, { scripts: { dev: "vite" } });
const firstRun = await detectDevServerCandidates(tempDir);
expect(firstRun.map((candidate) => candidate.scriptName)).toEqual(["dev"]);
writeJson(packageJsonPath, { scripts: { start: "next dev" } });
const now = new Date();
utimesSync(packageJsonPath, now, new Date(now.getTime() + 5_000));
const secondRun = await detectDevServerCandidates(tempDir);
expect(secondRun.map((candidate) => candidate.scriptName)).toEqual(["start"]);
});
it("supports explicit cache invalidation", async () => {
const packageJsonPath = path.join(tempDir, "package.json");
writeJson(packageJsonPath, { scripts: { dev: "vite" } });
await detectDevServerCandidates(tempDir);
writeJson(packageJsonPath, { scripts: { serve: "vite" } });
invalidateDetectionCache(tempDir);
const refreshed = await detectDevServerCandidates(tempDir);
expect(refreshed.map((candidate) => candidate.scriptName)).toEqual(["serve"]);
});
it("never includes reserved dashboard port 4040 in fallback ports", () => {
expect(EXCLUDED_SCRIPT_NAMES.has("lint")).toBe(true);
expect(FALLBACK_PORTS.includes(4040 as never)).toBe(false);
});
});

View File

@@ -0,0 +1,408 @@
// @vitest-environment node
import { EventEmitter } from "node:events";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChildProcess } from "node:child_process";
import {
DevServerManager,
FALLBACK_PORTS,
MAX_LOG_LINES,
destroyAllDevServerManagers,
parseLineForUrl,
} from "../dev-server-manager.js";
vi.mock("node:child_process", () => ({
spawn: vi.fn(),
}));
class MockChildProcess extends EventEmitter {
public readonly stdout = new EventEmitter();
public readonly stderr = new EventEmitter();
public readonly kill = vi.fn((signal: NodeJS.Signals = "SIGTERM") => {
this.killSignals.push(signal);
if (signal === "SIGTERM" && this.ignoreSigterm) {
return true;
}
queueMicrotask(() => {
this.emit("exit", 0);
});
return true;
});
public readonly killSignals: NodeJS.Signals[] = [];
constructor(
public readonly pid: number,
private readonly ignoreSigterm = false,
) {
super();
}
emitStdout(text: string): void {
this.stdout.emit("data", Buffer.from(`${text}\n`, "utf-8"));
}
emitStderr(text: string): void {
this.stderr.emit("data", Buffer.from(`${text}\n`, "utf-8"));
}
emitExit(code: number | null): void {
this.emit("exit", code);
}
emitError(message: string): void {
this.emit("error", new Error(message));
}
}
const spawnMock = vi.mocked(spawn);
async function waitForCondition(check: () => boolean | Promise<boolean>, timeoutMs = 1500): Promise<void> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (await check()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error("Timed out waiting for condition");
}
describe("DevServerManager", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(path.join(os.tmpdir(), "fn-dev-server-manager-"));
spawnMock.mockReset();
});
afterEach(async () => {
destroyAllDevServerManagers();
vi.restoreAllMocks();
vi.useRealTimers();
await rm(tempDir, { recursive: true, force: true });
});
it("handles start lifecycle transitions and captures logs", async () => {
const child = new MockChildProcess(12345);
spawnMock.mockReturnValue(child as unknown as ChildProcess);
const manager = new DevServerManager(tempDir);
const startState = await manager.start("echo hello", "test", tempDir);
expect(startState.status).toBe("starting");
expect(startState.command).toBe("echo hello");
expect(startState.scriptName).toBe("test");
expect(startState.cwd).toBe(tempDir);
expect(startState.pid).toBe(12345);
expect(typeof startState.startedAt).toBe("string");
child.emitStdout("hello from stdout");
expect(manager.getState().status).toBe("running");
expect(manager.getLogs()).toContain("hello from stdout");
child.emitExit(0);
await waitForCondition(() => manager.getState().status === "stopped");
expect(manager.getState().exitCode).toBe(0);
});
it("throws when start is called while already active", async () => {
const child = new MockChildProcess(12121);
spawnMock.mockReturnValue(child as unknown as ChildProcess);
const manager = new DevServerManager(tempDir);
await manager.start("npm run dev", "dev", tempDir);
child.emitStdout("running");
await expect(manager.start("npm run dev", "dev", tempDir)).rejects.toThrow("Dev server is already running");
});
it("stops a running process with SIGTERM", async () => {
const child = new MockChildProcess(23456);
spawnMock.mockReturnValue(child as unknown as ChildProcess);
const manager = new DevServerManager(tempDir);
await manager.start("node server.js", "dev", tempDir);
child.emitStdout("booting");
const state = await manager.stop();
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
expect(state.status).toBe("stopped");
await waitForCondition(() => manager.getState().status === "stopped");
});
it("sends SIGKILL fallback when process ignores SIGTERM", async () => {
vi.useFakeTimers();
const child = new MockChildProcess(34567, true);
spawnMock.mockReturnValue(child as unknown as ChildProcess);
const manager = new DevServerManager(tempDir);
await manager.start("node stubborn.js", "dev", tempDir);
child.emitStdout("running");
await manager.stop();
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
await vi.advanceTimersByTimeAsync(5_100);
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
});
it("restarts using stored command/scriptName/cwd", async () => {
const first = new MockChildProcess(45678);
const second = new MockChildProcess(56789);
spawnMock
.mockReturnValueOnce(first as unknown as ChildProcess)
.mockReturnValueOnce(second as unknown as ChildProcess);
const manager = new DevServerManager(tempDir);
await manager.start("npm run dev", "dev", tempDir);
first.emitStdout("ready");
const restarted = await manager.restart();
expect(first.kill).toHaveBeenCalledWith("SIGTERM");
expect(spawnMock).toHaveBeenNthCalledWith(
2,
"npm run dev",
[],
expect.objectContaining({ cwd: tempDir, shell: true }),
);
expect(restarted.pid).toBe(56789);
});
it("throws when restart is called before initial start", async () => {
const manager = new DevServerManager(tempDir);
await expect(manager.restart()).rejects.toThrow("Cannot restart dev server before it has been started once");
});
it("returns all server states", () => {
const manager = new DevServerManager(tempDir);
const states = manager.getAllStates();
expect(states).toHaveLength(1);
expect(states[0]?.id).toBe("default");
});
it("parses known URL log patterns", () => {
expect(parseLineForUrl(" > Local: http://localhost:5173/")).toEqual({
url: "http://localhost:5173",
port: 5173,
});
expect(parseLineForUrl("ready on http://localhost:3000")).toEqual({
url: "http://localhost:3000",
port: 3000,
});
expect(parseLineForUrl("Local: http://localhost:6006")).toEqual({
url: "http://localhost:6006",
port: 6006,
});
expect(parseLineForUrl("http://0.0.0.0:8080")).toEqual({
url: "http://localhost:8080",
port: 8080,
});
expect(parseLineForUrl("listening on 127.0.0.1:4173")).toEqual({
url: "http://localhost:4173",
port: 4173,
});
expect(parseLineForUrl("no url here")).toBeNull();
});
it("detects URL from output and emits url-detected event", async () => {
const child = new MockChildProcess(67890);
spawnMock.mockReturnValue(child as unknown as ChildProcess);
const manager = new DevServerManager(tempDir);
const onDetected = vi.fn();
manager.on("url-detected", onDetected);
await manager.start("npm run dev", "dev", tempDir);
child.emitStdout("Local: http://localhost:5173");
await waitForCondition(() => manager.getState().previewUrl === "http://localhost:5173");
expect(manager.getState().detectedPort).toBe(5173);
expect(onDetected).toHaveBeenCalledWith({
serverId: "default",
url: "http://localhost:5173",
port: 5173,
});
});
it("schedules fallback probing after 10 seconds when no URL is detected", async () => {
vi.useFakeTimers();
const child = new MockChildProcess(78901);
spawnMock.mockReturnValue(child as unknown as ChildProcess);
const manager = new DevServerManager(tempDir);
const probeSpy = vi.spyOn(manager as unknown as { probeFallbackPorts: () => void }, "probeFallbackPorts");
await manager.start("npm run dev", "dev", tempDir);
child.emitStdout("server booted");
await vi.advanceTimersByTimeAsync(10_100);
expect(probeSpy).toHaveBeenCalled();
expect(FALLBACK_PORTS.includes(4040 as never)).toBe(false);
});
it("detects a running server through fallback port probing", async () => {
const child = new MockChildProcess(78902);
spawnMock.mockReturnValue(child as unknown as ChildProcess);
const server = createServer((_req, res) => {
res.statusCode = 200;
res.end("ok");
});
await new Promise<void>((resolve) => server.listen(3000, () => resolve()));
try {
const manager = new DevServerManager(tempDir);
await manager.start("npm run dev", "dev", tempDir);
child.emitStdout("server booted");
(manager as unknown as { probeFallbackPorts: () => void }).probeFallbackPorts();
await waitForCondition(() => manager.getState().detectedPort === 3000, 3_000);
expect(manager.getState().previewUrl).toBe("http://localhost:3000");
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
});
it("persists state and reconnects when existing PID is alive", async () => {
const child = new MockChildProcess(89012);
spawnMock.mockReturnValue(child as unknown as ChildProcess);
const manager = new DevServerManager(tempDir);
await manager.start("npm run dev", "dev", tempDir);
const stateFile = path.join(tempDir, ".fusion", "dev-server.json");
await waitForCondition(async () => {
try {
await readFile(stateFile, "utf-8");
return true;
} catch {
return false;
}
});
const killSpy = vi.spyOn(process, "kill").mockImplementation(((pid: number, signal?: number | NodeJS.Signals) => {
if (signal === 0 && pid === 89012) {
return true;
}
throw Object.assign(new Error("ESRCH"), { code: "ESRCH" });
}) as typeof process.kill);
const reloaded = new DevServerManager(tempDir);
await reloaded.stop();
expect(reloaded.getState().status).toBe("running");
expect(reloaded.getState().pid).toBe(89012);
killSpy.mockRestore();
});
it("marks persisted dead PID as stopped", async () => {
const stateFile = path.join(tempDir, ".fusion", "dev-server.json");
await mkdir(path.dirname(stateFile), { recursive: true });
await writeFile(
stateFile,
JSON.stringify({
id: "default",
name: "default",
command: "npm run dev",
scriptName: "dev",
cwd: tempDir,
pid: 999999,
}),
"utf-8",
);
const killSpy = vi.spyOn(process, "kill").mockImplementation(((pid: number, signal?: number | NodeJS.Signals) => {
if (signal === 0 && pid === 999999) {
throw Object.assign(new Error("ESRCH"), { code: "ESRCH" });
}
return true;
}) as typeof process.kill);
const manager = new DevServerManager(tempDir);
await manager.stop();
expect(manager.getState().status).toBe("stopped");
expect(manager.getState().pid).toBeUndefined();
killSpy.mockRestore();
});
it("supports manual preview URL override and reset", async () => {
const child = new MockChildProcess(90123);
spawnMock.mockReturnValue(child as unknown as ChildProcess);
const manager = new DevServerManager(tempDir);
await manager.start("npm run dev", "dev", tempDir);
child.emitStdout("ready on http://localhost:5173");
await waitForCondition(() => manager.getState().detectedPort === 5173);
manager.setManualPreviewUrl("http://example.com:9999");
expect(manager.getState().manualPreviewUrl).toBe("http://example.com:9999");
expect(manager.getState().previewUrl).toBe("http://example.com:9999");
manager.setManualPreviewUrl(null);
expect(manager.getState().manualPreviewUrl).toBeUndefined();
expect(manager.getState().previewUrl).toBe("http://localhost:5173");
});
it("keeps logs in a 500-line ring buffer", async () => {
const child = new MockChildProcess(11223);
spawnMock.mockReturnValue(child as unknown as ChildProcess);
const manager = new DevServerManager(tempDir);
await manager.start("npm run dev", "dev", tempDir);
for (let index = 0; index < MAX_LOG_LINES + 25; index += 1) {
child.emitStdout(`line-${index}`);
}
const logs = manager.getLogs();
expect(logs).toHaveLength(MAX_LOG_LINES);
expect(logs[0]).toBe("line-25");
expect(logs.at(-1)).toBe(`line-${MAX_LOG_LINES + 24}`);
expect(manager.getLogs(10)).toHaveLength(10);
});
it("destroy() kills running processes and clears internal timers/maps", async () => {
vi.useFakeTimers();
const child = new MockChildProcess(22334, true);
spawnMock.mockReturnValue(child as unknown as ChildProcess);
const manager = new DevServerManager(tempDir);
await manager.start("npm run dev", "dev", tempDir);
child.emitStdout("running");
manager.destroy();
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
expect((manager as unknown as { processes: Map<string, ChildProcess> }).processes.size).toBe(0);
expect((manager as unknown as { servers: Map<string, unknown> }).servers.size).toBe(0);
await vi.advanceTimersByTimeAsync(5_100);
expect(child.kill).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,297 @@
// @vitest-environment node
import express from "express";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { TaskStore } from "@fusion/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApiRoutes } from "../routes.js";
import { get as performGet, request as performRequest } from "../test-request.js";
import { detectDevServerCandidates, invalidateDetectionCache } from "../dev-server-detect.js";
import { getDevServerManager } from "../dev-server-manager.js";
vi.mock("../dev-server-manager.js", () => ({
getDevServerManager: vi.fn(),
destroyAllDevServerManagers: vi.fn(),
}));
vi.mock("../dev-server-detect.js", () => ({
detectDevServerCandidates: vi.fn(),
invalidateDetectionCache: vi.fn(),
}));
type MockDevServerState = {
id: string;
name: string;
status: "stopped" | "starting" | "running" | "failed";
command: string;
scriptName: string;
cwd: string;
logs: string[];
previewUrl?: string;
manualPreviewUrl?: string;
};
function createMockStore(rootDir: string): TaskStore {
return {
getRootDir: vi.fn().mockReturnValue(rootDir),
} as unknown as TaskStore;
}
function buildApp(store: TaskStore) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
async function GET(app: express.Express, pathName: string) {
return performGet(app, pathName);
}
async function POST_JSON(app: express.Express, pathName: string, payload: unknown) {
return performRequest(app, "POST", pathName, JSON.stringify(payload), {
"content-type": "application/json",
});
}
describe("dev-server routes", () => {
let tempDir: string;
let app: express.Express;
let state: MockDevServerState;
let manager: {
getState: ReturnType<typeof vi.fn>;
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
restart: ReturnType<typeof vi.fn>;
setManualPreviewUrl: ReturnType<typeof vi.fn>;
on: ReturnType<typeof vi.fn>;
off: ReturnType<typeof vi.fn>;
};
const detectCandidatesMock = vi.mocked(detectDevServerCandidates);
const invalidateDetectionCacheMock = vi.mocked(invalidateDetectionCache);
const getDevServerManagerMock = vi.mocked(getDevServerManager);
beforeEach(() => {
tempDir = mkdtempSync(path.join(os.tmpdir(), "fn-dev-server-routes-"));
writeFileSync(
path.join(tempDir, "package.json"),
JSON.stringify({ scripts: { dev: "vite" } }, null, 2),
"utf-8",
);
state = {
id: "default",
name: "default",
status: "stopped",
command: "",
scriptName: "",
cwd: tempDir,
logs: [],
};
manager = {
getState: vi.fn(() => structuredClone(state)),
start: vi.fn(async (command: string, scriptName: string, cwd?: string) => {
state = {
...state,
status: "starting",
command,
scriptName,
cwd: cwd ?? tempDir,
};
return structuredClone(state);
}),
stop: vi.fn(async () => {
state = {
...state,
status: "stopped",
};
return structuredClone(state);
}),
restart: vi.fn(async () => {
state = {
...state,
status: "running",
};
return structuredClone(state);
}),
setManualPreviewUrl: vi.fn((url: string | null) => {
state = {
...state,
manualPreviewUrl: url ?? undefined,
previewUrl: url ?? undefined,
};
return structuredClone(state);
}),
on: vi.fn(),
off: vi.fn(),
};
detectCandidatesMock.mockReset();
invalidateDetectionCacheMock.mockReset();
getDevServerManagerMock.mockReset();
detectCandidatesMock.mockResolvedValue([
{
name: "dev",
command: "vite",
scriptName: "dev",
cwd: tempDir,
label: "Root > dev",
},
]);
getDevServerManagerMock.mockReturnValue(manager as never);
app = buildApp(createMockStore(tempDir));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it("GET /api/dev-server/candidates returns detected candidates", async () => {
const res = await GET(app, "/api/dev-server/candidates");
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body).toContainEqual(
expect.objectContaining({
scriptName: "dev",
}),
);
expect(detectCandidatesMock).toHaveBeenCalledWith(tempDir);
});
it("GET /api/dev-server/status returns server state", async () => {
const res = await GET(app, "/api/dev-server/status");
expect(res.status).toBe(200);
expect(res.body).toEqual(
expect.objectContaining({
id: "default",
name: "default",
status: expect.any(String),
command: expect.any(String),
logs: expect.any(Array),
}),
);
});
it("POST /api/dev-server/start starts a server", async () => {
const res = await POST_JSON(app, "/api/dev-server/start", {
command: "echo test",
scriptName: "test",
});
expect(res.status).toBe(200);
expect(res.body.status).not.toBe("stopped");
expect(res.body.command).toBe("echo test");
expect(manager.start).toHaveBeenCalledWith("echo test", "test", undefined);
expect(invalidateDetectionCacheMock).toHaveBeenCalledWith(tempDir);
});
it("POST /api/dev-server/start validates missing command", async () => {
const res = await POST_JSON(app, "/api/dev-server/start", {
scriptName: "test",
});
expect(res.status).toBe(400);
});
it("POST /api/dev-server/start validates missing scriptName", async () => {
const res = await POST_JSON(app, "/api/dev-server/start", {
command: "echo test",
});
expect(res.status).toBe(400);
});
it("POST /api/dev-server/stop stops the server", async () => {
await POST_JSON(app, "/api/dev-server/start", {
command: "echo test",
scriptName: "test",
});
const res = await POST_JSON(app, "/api/dev-server/stop", {});
expect(res.status).toBe(200);
expect(res.body.status).toBe("stopped");
expect(manager.stop).toHaveBeenCalled();
});
it("POST /api/dev-server/restart restarts the server", async () => {
await POST_JSON(app, "/api/dev-server/start", {
command: "echo test",
scriptName: "test",
});
const res = await POST_JSON(app, "/api/dev-server/restart", {});
expect(res.status).toBe(200);
expect(res.body.status).toBe("running");
expect(manager.restart).toHaveBeenCalled();
});
it("POST /api/dev-server/preview-url sets manual preview URL", async () => {
const res = await POST_JSON(app, "/api/dev-server/preview-url", {
url: "http://localhost:9999",
});
expect(res.status).toBe(200);
expect(res.body.manualPreviewUrl).toBe("http://localhost:9999");
});
it("POST /api/dev-server/preview-url accepts null URL", async () => {
const res = await POST_JSON(app, "/api/dev-server/preview-url", {
url: null,
});
expect(res.status).toBe(200);
expect(res.body.manualPreviewUrl).toBeUndefined();
});
it("POST /api/dev-server/preview-url validates URL type", async () => {
const res = await POST_JSON(app, "/api/dev-server/preview-url", {
url: 123,
});
expect(res.status).toBe(400);
});
it("GET /api/dev-server/logs/stream exposes SSE headers and connected event", async () => {
const server = app.listen(0);
try {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Failed to resolve test server address");
}
const response = await fetch(`http://127.0.0.1:${address.port}/api/dev-server/logs/stream`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/event-stream");
const reader = response.body?.getReader();
const firstChunk = await reader?.read();
const text = firstChunk?.value ? new TextDecoder().decode(firstChunk.value) : "";
expect(text).toContain("event: connected");
await reader?.cancel();
} finally {
await new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
});
});