Files
fusion/packages/dashboard/app/hooks/__tests__/useDevServer.test.ts
Fusion 7bcc360400 feat(FN-2188): harden dev server view lifecycle and mobile nav
- Update the dashboard dev-server API client for compatibility with multiple backend response shapes
- Refine useDevServer polling and SSE lifecycle handling to improve status/action reliability
- Gate dev server navigation in header/mobile overflow and fix mobile preview header wrapping styles
- Expand DevServerView and useDevServer test coverage with isolated mocks and log/status assertions
- Document the devServerView experimental feature flag in settings reference
2026-04-20 23:07:28 -07:00

311 lines
9.2 KiB
TypeScript

import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fetchDevServerCandidates,
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", () => ({
fetchDevServerCandidates: 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 mockFetchDevServerCandidates = vi.mocked(fetchDevServerCandidates);
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?projectId=project-a");
mockFetchDevServerCandidates.mockResolvedValue([createCandidate()]);
mockFetchDevServerStatus.mockResolvedValue(createState());
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("initially exposes loading state", () => {
const { result } = renderHook(() => useDevServer("project-a"));
expect(result.current.isLoading).toBe(true);
expect(result.current.candidates).toEqual([]);
expect(result.current.serverState).toBeNull();
});
it("fetches candidates and status on mount", async () => {
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(mockFetchDevServerCandidates).toHaveBeenCalledWith("project-a");
expect(mockFetchDevServerStatus).toHaveBeenCalledWith("project-a");
expect(result.current.candidates).toHaveLength(1);
expect(result.current.serverState?.status).toBe("stopped");
});
it("opens SSE stream on mount", async () => {
renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(mockSubscribeSse).toHaveBeenCalledWith(
"/api/dev-server/logs/stream?projectId=project-a",
expect.any(Object),
);
});
});
it("polls every 3s while running", async () => {
vi.useFakeTimers();
mockFetchDevServerStatus.mockResolvedValue(createState({ status: "running" }));
renderHook(() => useDevServer("project-a"));
await flushMicrotasks();
expect(mockFetchDevServerStatus).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(3000);
await Promise.resolve();
});
expect(mockFetchDevServerStatus).toHaveBeenCalledTimes(2);
});
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(9000);
await Promise.resolve();
});
expect(mockFetchDevServerStatus).toHaveBeenCalledTimes(1);
});
it("appends logs from SSE events", async () => {
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(mockSubscribeSse).toHaveBeenCalledTimes(1);
});
act(() => {
activeSubscription?.events?.["dev-server:log"]?.({
data: JSON.stringify({ line: "from namespaced log" }),
} as MessageEvent<string>);
activeSubscription?.events?.log?.({
data: JSON.stringify({ line: "from log" }),
} as MessageEvent<string>);
});
await waitFor(() => {
expect(result.current.logs).toEqual(["from namespaced log", "from log"]);
});
});
it("updates state from dev-server:status SSE events", async () => {
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(mockSubscribeSse).toHaveBeenCalledTimes(1);
});
act(() => {
activeSubscription?.events?.["dev-server:status"]?.({
data: JSON.stringify(createState({ status: "running", pid: 9876 })),
} as MessageEvent<string>);
});
await waitFor(() => {
expect(result.current.serverState?.status).toBe("running");
expect(result.current.serverState?.pid).toBe(9876);
});
});
it("start() accepts candidate and custom payload inputs", async () => {
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.start(createCandidate({ cwd: "apps/web", packagePath: "apps/web" }));
});
expect(mockStartDevServer).toHaveBeenCalledWith(
{
command: "pnpm dev",
cwd: "apps/web",
scriptName: "dev",
packagePath: "apps/web",
},
"project-a",
);
await act(async () => {
await result.current.start({ command: "pnpm start", scriptName: "start", cwd: "apps/api" });
});
expect(mockStartDevServer).toHaveBeenLastCalledWith(
{
command: "pnpm start",
cwd: "apps/api",
scriptName: "start",
packagePath: "apps/api",
},
"project-a",
);
});
it("stop(), restart(), and setPreviewUrl() call APIs", async () => {
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.stop();
await result.current.restart();
await result.current.setPreviewUrl("http://localhost:3000");
});
expect(mockStopDevServer).toHaveBeenCalledWith("project-a");
expect(mockRestartDevServer).toHaveBeenCalledWith("project-a");
expect(mockSetDevServerPreviewUrl).toHaveBeenCalledWith({ url: "http://localhost:3000" }, "project-a");
});
it("sets error when API operations fail", async () => {
mockStartDevServer.mockRejectedValueOnce(new Error("boom"));
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await expect(result.current.start({ command: "pnpm dev", scriptName: "dev" })).rejects.toThrow("boom");
});
expect(result.current.error).toBe("boom");
});
it("detect() refreshes candidate list", async () => {
mockFetchDevServerCandidates
.mockResolvedValueOnce([createCandidate()])
.mockResolvedValueOnce([createCandidate({ scriptName: "start", command: "pnpm start" })]);
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.detect();
});
expect(mockFetchDevServerCandidates).toHaveBeenCalledTimes(2);
expect(result.current.candidates).toEqual([
expect.objectContaining({ scriptName: "start", command: "pnpm start" }),
]);
});
it("cleans up SSE on unmount", async () => {
const { unmount } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(mockSubscribeSse).toHaveBeenCalledTimes(1);
});
unmount();
expect(unsubscribeSpy).toHaveBeenCalledTimes(1);
});
});