Files
fusion/packages/dashboard/app/hooks/__tests__/useDevServer.test.ts
Fusion f21ed51b96 feat(FN-2182): add dashboard dev server management view
- Add DevServerView with command detection, start/stop/restart controls, live logs, and preview URL management
- Implement useDevServer with SSE log streaming, running-state polling, manual URL updates, and candidate detection support
- Extend dashboard API client mappings for dev-server candidate metadata, status fields, and preview URL helpers
- Integrate dev server navigation in header/mobile nav, normalize devserver view state persistence, and add feature-flag wiring
- Add/refresh DevServerView and useDevServer tests and replace legacy dev-server CSS with the new component styles
2026-04-20 15:09:56 -07:00

368 lines
11 KiB
TypeScript

import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
detectDevServer,
fetchDevServerStatus,
getDevServerLogsStreamUrl,
restartDevServer,
setDevServerPreviewUrl,
startDevServer,
stopDevServer,
type DevServerCandidate,
type DevServerState,
} from "../../api";
import { subscribeSse, type SseSubscription } from "../../sse-bus";
import { __resetUseDevServerForTests, useDevServer } from "../useDevServer";
vi.mock("../../api", () => ({
detectDevServer: vi.fn(),
fetchDevServerStatus: vi.fn(),
getDevServerLogsStreamUrl: vi.fn(),
startDevServer: vi.fn(),
stopDevServer: vi.fn(),
restartDevServer: vi.fn(),
setDevServerPreviewUrl: vi.fn(),
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn(),
}));
const mockDetectDevServer = vi.mocked(detectDevServer);
const mockFetchDevServerStatus = vi.mocked(fetchDevServerStatus);
const mockGetDevServerLogsStreamUrl = vi.mocked(getDevServerLogsStreamUrl);
const mockStartDevServer = vi.mocked(startDevServer);
const mockStopDevServer = vi.mocked(stopDevServer);
const mockRestartDevServer = vi.mocked(restartDevServer);
const mockSetDevServerPreviewUrl = vi.mocked(setDevServerPreviewUrl);
const mockSubscribeSse = vi.mocked(subscribeSse);
let activeSubscription: SseSubscription | null = null;
let unsubscribeSpy = vi.fn();
function createCandidate(overrides: Partial<DevServerCandidate> = {}): DevServerCandidate {
return {
scriptName: "dev",
command: "pnpm dev",
packagePath: ".",
confidence: 1,
name: "dev",
cwd: ".",
source: "root",
label: "project · dev (root)",
...overrides,
};
}
function createState(overrides: Partial<DevServerState> = {}): DevServerState {
return {
id: "default",
name: "default",
status: "stopped",
command: "pnpm dev",
scriptName: "dev",
cwd: ".",
detectedUrl: "http://localhost:5173",
manualUrl: undefined,
logs: [],
...overrides,
};
}
async function flushMicrotasks(): Promise<void> {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe("useDevServer", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
__resetUseDevServerForTests();
activeSubscription = null;
unsubscribeSpy = vi.fn();
mockGetDevServerLogsStreamUrl.mockReturnValue("/api/dev-server/logs/stream");
mockFetchDevServerStatus.mockResolvedValue(createState());
mockDetectDevServer.mockResolvedValue([createCandidate()]);
mockStartDevServer.mockResolvedValue(createState({ status: "running", pid: 1111 }));
mockStopDevServer.mockResolvedValue(createState({ status: "stopped", pid: undefined }));
mockRestartDevServer.mockResolvedValue(createState({ status: "running", pid: 2222 }));
mockSetDevServerPreviewUrl.mockResolvedValue(createState({ manualUrl: "http://localhost:3000" }));
mockSubscribeSse.mockImplementation((_url, sub) => {
activeSubscription = sub;
return unsubscribeSpy;
});
});
it("fetches status on mount", async () => {
const { result } = renderHook(() => useDevServer("project-a"));
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(mockFetchDevServerStatus).toHaveBeenCalledWith("project-a");
expect(result.current.status).toBe("stopped");
expect(result.current.detectedUrl).toBe("http://localhost:5173");
});
it("starts polling while running", async () => {
vi.useFakeTimers();
mockFetchDevServerStatus
.mockResolvedValueOnce(createState({ status: "running" }))
.mockResolvedValue(createState({ status: "running" }));
renderHook(() => useDevServer("project-a"));
await flushMicrotasks();
expect(mockFetchDevServerStatus).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(5000);
await Promise.resolve();
});
expect(mockFetchDevServerStatus).toHaveBeenCalledTimes(2);
await act(async () => {
vi.advanceTimersByTime(5000);
await Promise.resolve();
});
expect(mockFetchDevServerStatus).toHaveBeenCalledTimes(3);
});
it("does not poll while stopped", async () => {
vi.useFakeTimers();
mockFetchDevServerStatus.mockResolvedValue(createState({ status: "stopped" }));
renderHook(() => useDevServer("project-a"));
await flushMicrotasks();
expect(mockFetchDevServerStatus).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(15000);
await Promise.resolve();
});
expect(mockFetchDevServerStatus).toHaveBeenCalledTimes(1);
});
it("subscribes to SSE while running and unsubscribes when stopped", async () => {
mockFetchDevServerStatus.mockResolvedValueOnce(createState({ status: "running" }));
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(mockSubscribeSse).toHaveBeenCalledTimes(1);
});
await act(async () => {
await result.current.stop();
});
expect(unsubscribeSpy).toHaveBeenCalledTimes(1);
});
it("appends logs from SSE events", async () => {
mockFetchDevServerStatus.mockResolvedValueOnce(createState({ status: "running", logs: [] }));
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(mockSubscribeSse).toHaveBeenCalledTimes(1);
});
act(() => {
activeSubscription?.events?.history?.({ data: JSON.stringify({ lines: ["from history"] }) } as MessageEvent<string>);
activeSubscription?.events?.log?.({ data: JSON.stringify({ line: "from log" }) } as MessageEvent<string>);
activeSubscription?.events?.["dev-server:output"]?.({
data: JSON.stringify({ text: "stderr line", stream: "stderr" }),
} as MessageEvent<string>);
});
await waitFor(() => {
expect(result.current.logs).toEqual(["from history", "from log", "[stderr] stderr line"]);
});
});
it("caps logs to 500 entries", async () => {
mockFetchDevServerStatus.mockResolvedValueOnce(createState({ status: "running", logs: [] }));
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(mockSubscribeSse).toHaveBeenCalledTimes(1);
});
act(() => {
for (let index = 1; index <= 510; index += 1) {
activeSubscription?.events?.log?.({ data: JSON.stringify({ line: `line-${index}` }) } as MessageEvent<string>);
}
});
await waitFor(() => {
expect(result.current.logs).toHaveLength(500);
expect(result.current.logs[0]).toBe("line-11");
expect(result.current.logs[499]).toBe("line-510");
});
});
it("start() calls API and sets status to starting", async () => {
let resolveStart: ((state: DevServerState) => void) | null = null;
const pendingStart = new Promise<DevServerState>((resolve) => {
resolveStart = resolve;
});
mockFetchDevServerStatus
.mockResolvedValueOnce(createState({ status: "stopped" }))
.mockResolvedValueOnce(createState({ status: "running" }));
mockStartDevServer.mockReturnValueOnce(pendingStart);
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
act(() => {
void result.current.start("pnpm dev", "apps/web", "dev", "apps/web");
});
expect(result.current.status).toBe("starting");
act(() => {
resolveStart?.(createState({ status: "running" }));
});
await waitFor(() => {
expect(mockStartDevServer).toHaveBeenCalledWith(
{
command: "pnpm dev",
cwd: "apps/web",
scriptName: "dev",
packagePath: "apps/web",
},
"project-a",
);
});
});
it("stop() calls API and sets status stopped", async () => {
mockFetchDevServerStatus.mockResolvedValueOnce(createState({ status: "running" }));
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(result.current.status).toBe("running");
});
await act(async () => {
await result.current.stop();
});
expect(mockStopDevServer).toHaveBeenCalledWith("project-a");
expect(result.current.status).toBe("stopped");
});
it("restart() calls API and sets status starting", async () => {
let resolveRestart: ((state: DevServerState) => void) | null = null;
const pendingRestart = new Promise<DevServerState>((resolve) => {
resolveRestart = resolve;
});
mockFetchDevServerStatus
.mockResolvedValueOnce(createState({ status: "running" }))
.mockResolvedValueOnce(createState({ status: "running" }));
mockRestartDevServer.mockReturnValueOnce(pendingRestart);
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(result.current.status).toBe("running");
});
act(() => {
void result.current.restart();
});
expect(result.current.status).toBe("starting");
act(() => {
resolveRestart?.(createState({ status: "running" }));
});
await waitFor(() => {
expect(mockRestartDevServer).toHaveBeenCalledWith("project-a");
});
});
it("setManualUrl() calls API and updates local manual url", async () => {
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.setManualUrl("http://localhost:3000");
});
expect(mockSetDevServerPreviewUrl).toHaveBeenCalledWith({ url: "http://localhost:3000" }, "project-a");
expect(result.current.manualUrl).toBe("http://localhost:3000");
});
it("detect() loads command candidates", async () => {
mockDetectDevServer.mockResolvedValueOnce([
createCandidate({ scriptName: "start", command: "pnpm start", packagePath: "apps/web" }),
]);
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.detect();
});
expect(mockDetectDevServer).toHaveBeenCalledWith("project-a");
expect(result.current.candidates).toEqual([
expect.objectContaining({ scriptName: "start", command: "pnpm start" }),
]);
});
it("cleans up SSE and polling on unmount", async () => {
vi.useFakeTimers();
mockFetchDevServerStatus.mockResolvedValue(createState({ status: "running" }));
const { unmount } = renderHook(() => useDevServer("project-a"));
await flushMicrotasks();
expect(mockSubscribeSse).toHaveBeenCalledTimes(1);
unmount();
expect(unsubscribeSpy).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(10000);
await Promise.resolve();
});
expect(mockFetchDevServerStatus).toHaveBeenCalledTimes(1);
});
});