test(FN-2179): complete Step 6 dev server hook and component tests

This commit is contained in:
Fusion
2026-04-20 04:36:38 -07:00
committed by gsxdsm
parent 9a3155416a
commit 7b7883434f
2 changed files with 366 additions and 238 deletions

View File

@@ -1,5 +1,6 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DevServerState } from "../../api";
import { DevServerView } from "../DevServerView";
const mockUseDevServer = vi.fn();
@@ -8,130 +9,203 @@ vi.mock("../../hooks/useDevServer", () => ({
useDevServer: (...args: unknown[]) => mockUseDevServer(...args),
}));
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
vi.mock("lucide-react", () => ({
ExternalLink: () => <span data-testid="icon-external-link" />,
Loader2: () => <span data-testid="icon-loader" />,
Monitor: () => <span data-testid="icon-monitor" />,
Play: () => <span data-testid="icon-play" />,
RotateCw: () => <span data-testid="icon-rotate" />,
Square: () => <span data-testid="icon-square" />,
}));
function createState(overrides: Partial<DevServerState> = {}): DevServerState {
return {
...actual,
fetchScripts: vi.fn().mockResolvedValue({ dev: "pnpm dev" }),
id: "default",
name: "default",
status: "stopped",
command: "pnpm dev",
scriptName: "dev",
cwd: ".",
logs: [],
...overrides,
};
});
}
function createHookState(overrides: Record<string, unknown> = {}) {
return {
state: {
serverKey: "default",
status: "stopped",
command: "pnpm dev",
scriptName: "dev",
cwd: "/repo",
pid: null,
startedAt: null,
updatedAt: "2026-04-19T10:00:00.000Z",
previewUrl: null,
previewProtocol: null,
previewHost: null,
previewPort: null,
previewPath: null,
exitCode: 0,
exitSignal: null,
exitedAt: "2026-04-19T10:00:00.000Z",
failureReason: null,
},
logs: [
candidates: [
{
serverKey: "default",
source: "stdout",
message: "ready",
timestamp: "2026-04-19T10:00:01.000Z",
name: "dev",
command: "pnpm dev",
scriptName: "dev",
cwd: ".",
label: "project · dev (root)",
},
],
loading: false,
error: null,
connectionState: "connected",
serverState: createState(),
logs: ["ready"],
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
restart: vi.fn().mockResolvedValue(undefined),
refresh: vi.fn().mockResolvedValue(undefined),
manualPreviewUrl: "",
setManualPreviewUrl: vi.fn(),
effectivePreviewUrl: null,
setPreviewUrl: vi.fn().mockResolvedValue(undefined),
loading: false,
error: null,
...overrides,
};
}
describe("DevServerView", () => {
const addToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockUseDevServer.mockReturnValue(createHookState());
});
it("renders status and log panels", () => {
render(<DevServerView projectId="project-a" />);
it("renders without crashing", () => {
render(<DevServerView addToast={addToast} projectId="project-a" />);
expect(screen.getByTestId("dev-server-status-panel")).toBeInTheDocument();
expect(screen.getByTestId("dev-server-logs-panel")).toBeInTheDocument();
expect(screen.getByText("ready")).toBeInTheDocument();
expect(screen.getByTestId("dev-server-view")).toBeInTheDocument();
});
it("applies action button states for running and stopped statuses", () => {
mockUseDevServer.mockReturnValue(createHookState({
state: {
...createHookState().state,
status: "running",
},
}));
it.each([
["stopped", "Stopped"],
["running", "Running"],
["starting", "Starting..."],
["failed", "Failed"],
] as const)("shows %s status badge", (status, label) => {
mockUseDevServer.mockReturnValue(
createHookState({
serverState: createState({ status }),
}),
);
const { rerender } = render(<DevServerView projectId="project-a" />);
render(<DevServerView addToast={addToast} projectId="project-a" />);
expect(screen.getByTestId("dev-server-start-btn")).toBeDisabled();
expect(screen.getByTestId("dev-server-stop-btn")).not.toBeDisabled();
mockUseDevServer.mockReturnValue(createHookState({
state: {
...createHookState().state,
status: "failed",
},
}));
rerender(<DevServerView projectId="project-a" />);
expect(screen.getByTestId("dev-server-start-btn")).not.toBeDisabled();
expect(screen.getByTestId("dev-server-stop-btn")).toBeDisabled();
expect(screen.getByTestId("dev-server-status-badge")).toHaveTextContent(label);
});
it("supports manual preview URL override input", () => {
const setManualPreviewUrl = vi.fn();
mockUseDevServer.mockReturnValue(createHookState({
manualPreviewUrl: "https://override.local",
setManualPreviewUrl,
effectivePreviewUrl: "https://override.local",
}));
it("disables Start when server is running or starting", () => {
mockUseDevServer.mockReturnValue(
createHookState({
serverState: createState({ status: "running" }),
}),
);
render(<DevServerView projectId="project-a" />);
const { rerender } = render(<DevServerView addToast={addToast} projectId="project-a" />);
const input = screen.getByTestId("dev-server-preview-url-input");
fireEvent.change(input, { target: { value: "https://preview.local" } });
expect(setManualPreviewUrl).toHaveBeenCalledWith("https://preview.local");
expect(screen.getByTestId("dev-server-start-button")).toBeDisabled();
mockUseDevServer.mockReturnValue(
createHookState({
serverState: createState({ status: "starting" }),
}),
);
rerender(<DevServerView addToast={addToast} projectId="project-a" />);
expect(screen.getByTestId("dev-server-start-button")).toBeDisabled();
});
it("shows iframe blocked fallback messaging", () => {
it("disables Stop when server is stopped", () => {
render(<DevServerView addToast={addToast} projectId="project-a" />);
expect(screen.getByTestId("dev-server-stop-button")).toBeDisabled();
});
it("clicking Start calls start from the hook", () => {
const start = vi.fn().mockResolvedValue(undefined);
mockUseDevServer.mockReturnValue(createHookState({ start }));
render(<DevServerView addToast={addToast} projectId="project-a" />);
fireEvent.click(screen.getByTestId("dev-server-start-button"));
expect(start).toHaveBeenCalledTimes(1);
});
it("renders log entries from hook logs", () => {
mockUseDevServer.mockReturnValue(
createHookState({
logs: ["first line", "second line"],
}),
);
render(<DevServerView addToast={addToast} projectId="project-a" />);
expect(screen.getByText("first line")).toBeInTheDocument();
expect(screen.getByText("second line")).toBeInTheDocument();
});
it("renders preview iframe when preview URL is set", () => {
mockUseDevServer.mockReturnValue(
createHookState({
serverState: createState({
status: "running",
previewUrl: "http://localhost:5173",
}),
}),
);
render(<DevServerView addToast={addToast} projectId="project-a" />);
const iframe = screen.getByTestId("dev-server-preview-iframe");
expect(iframe).toBeInTheDocument();
expect(iframe).toHaveAttribute("src", "http://localhost:5173");
});
it("shows CSP fallback message when embedding times out", async () => {
vi.useFakeTimers();
mockUseDevServer.mockReturnValue(createHookState({
state: {
...createHookState().state,
status: "running",
previewUrl: "http://127.0.0.1:5173",
},
effectivePreviewUrl: "http://127.0.0.1:5173",
}));
render(<DevServerView projectId="project-a" />);
mockUseDevServer.mockReturnValue(
createHookState({
serverState: createState({
status: "running",
previewUrl: "http://localhost:5173",
}),
}),
);
act(() => {
vi.advanceTimersByTime(3000);
render(<DevServerView addToast={addToast} projectId="project-a" />);
await act(async () => {
vi.advanceTimersByTime(5000);
await Promise.resolve();
});
expect(screen.getByTestId("dev-server-preview-fallback")).toBeInTheDocument();
expect(screen.getByText(/Preview cannot be embedded/i)).toBeInTheDocument();
vi.useRealTimers();
});
it("shows open in new tab button when preview URL exists", () => {
mockUseDevServer.mockReturnValue(
createHookState({
serverState: createState({
status: "running",
previewUrl: "http://localhost:5173",
}),
}),
);
render(<DevServerView addToast={addToast} projectId="project-a" />);
expect(screen.getByTestId("dev-server-open-preview")).toBeInTheDocument();
});
it("shows empty-state message when no candidates and server is stopped", () => {
mockUseDevServer.mockReturnValue(
createHookState({
candidates: [],
serverState: createState({ status: "stopped" }),
}),
);
render(<DevServerView addToast={addToast} projectId="project-a" />);
expect(screen.getByTestId("dev-server-empty-candidates")).toHaveTextContent(
"No dev server scripts detected.",
);
});
});

View File

@@ -1,221 +1,275 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
connectDevServerStream,
fetchDevServerHistory,
fetchDevServerCandidates,
fetchDevServerStatus,
getDevServerLogsStreamUrl,
restartDevServer,
setDevServerPreviewUrl,
startDevServer,
stopDevServer,
type DevServerLogEntry,
type DevServerSnapshot,
type DevServerCandidate,
type DevServerState,
} from "../../api";
import { MockEventSource } from "../../../vitest.setup";
import { useDevServer } from "../useDevServer";
vi.mock("../../api", () => ({
fetchDevServerCandidates: vi.fn(),
fetchDevServerStatus: vi.fn(),
fetchDevServerHistory: vi.fn(),
startDevServer: vi.fn(),
stopDevServer: vi.fn(),
restartDevServer: vi.fn(),
connectDevServerStream: vi.fn(),
setDevServerPreviewUrl: vi.fn(),
getDevServerLogsStreamUrl: vi.fn(),
}));
const mockFetchDevServerCandidates = vi.mocked(fetchDevServerCandidates);
const mockFetchDevServerStatus = vi.mocked(fetchDevServerStatus);
const mockFetchDevServerHistory = vi.mocked(fetchDevServerHistory);
const mockStartDevServer = vi.mocked(startDevServer);
const mockStopDevServer = vi.mocked(stopDevServer);
const mockRestartDevServer = vi.mocked(restartDevServer);
const mockConnectDevServerStream = vi.mocked(connectDevServerStream);
const mockSetDevServerPreviewUrl = vi.mocked(setDevServerPreviewUrl);
const mockGetDevServerLogsStreamUrl = vi.mocked(getDevServerLogsStreamUrl);
function createState(overrides: Partial<DevServerState> = {}): DevServerState {
return {
serverKey: "default",
id: "default",
name: "default",
status: "stopped",
command: "pnpm dev",
scriptName: "dev",
cwd: "/repo",
pid: null,
startedAt: null,
updatedAt: "2026-04-19T10:00:00.000Z",
previewUrl: null,
previewProtocol: null,
previewHost: null,
previewPort: null,
previewPath: null,
exitCode: 0,
exitSignal: null,
exitedAt: "2026-04-19T10:00:00.000Z",
failureReason: null,
cwd: ".",
logs: [],
...overrides,
};
}
function createLog(message: string, timestamp: string): DevServerLogEntry {
function createCandidate(overrides: Partial<DevServerCandidate> = {}): DevServerCandidate {
return {
serverKey: "default",
source: "stdout",
message,
timestamp,
name: "dev",
command: "pnpm dev",
scriptName: "dev",
cwd: ".",
label: "project · dev (root)",
...overrides,
};
}
async function flushMicrotasks(): Promise<void> {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe("useDevServer", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchDevServerStatus.mockResolvedValue({
state: createState(),
logs: [],
} satisfies DevServerSnapshot);
mockFetchDevServerHistory.mockResolvedValue({ logs: [] });
mockStartDevServer.mockResolvedValue({ state: createState({ status: "running", pid: 1234 }) });
mockStopDevServer.mockResolvedValue({ state: createState({ status: "stopped", pid: null }) });
mockRestartDevServer.mockResolvedValue({ state: createState({ status: "running", pid: 4567 }) });
mockConnectDevServerStream.mockImplementation((_projectId, handlers) => {
return {
close: vi.fn(),
isConnected: () => true,
};
});
vi.useRealTimers();
mockFetchDevServerCandidates.mockResolvedValue([createCandidate()]);
mockFetchDevServerStatus.mockResolvedValue(createState());
mockStartDevServer.mockResolvedValue(createState({ status: "running", pid: 1234 }));
mockStopDevServer.mockResolvedValue(createState({ status: "stopped", pid: undefined }));
mockRestartDevServer.mockResolvedValue(createState({ status: "running", pid: 4567 }));
mockSetDevServerPreviewUrl.mockResolvedValue(createState({ manualPreviewUrl: "https://localhost:5173" }));
mockGetDevServerLogsStreamUrl.mockReturnValue("/api/dev-server/logs/stream?projectId=project-a");
});
it("hydrates persisted status/history before attaching live stream", async () => {
const statusLog = createLog("from-status", "2026-04-19T10:00:01.000Z");
const historyLog = createLog("from-history", "2026-04-19T10:00:02.000Z");
let resolveStatus: ((value: DevServerSnapshot) => void) | null = null;
let resolveHistory: ((value: { logs: DevServerLogEntry[] }) => void) | null = null;
mockFetchDevServerStatus.mockImplementationOnce(() => new Promise((resolve) => {
resolveStatus = resolve;
}));
mockFetchDevServerHistory.mockImplementationOnce(() => new Promise((resolve) => {
resolveHistory = resolve;
}));
it("starts with loading state and triggers initial fetches", async () => {
const { result } = renderHook(() => useDevServer("project-a"));
expect(mockConnectDevServerStream).not.toHaveBeenCalled();
act(() => {
resolveStatus?.({
state: createState({ status: "running", pid: 1200 }),
logs: [statusLog],
});
resolveHistory?.({ logs: [statusLog, historyLog] });
});
expect(result.current.loading).toBe(true);
expect(result.current.candidates).toEqual([]);
expect(result.current.serverState).toBeNull();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.state.status).toBe("running");
expect(result.current.logs.map((entry) => entry.message)).toEqual(["from-status", "from-history"]);
expect(mockConnectDevServerStream).toHaveBeenCalledTimes(1);
expect(mockFetchDevServerCandidates).toHaveBeenCalledWith("project-a");
expect(mockFetchDevServerStatus).toHaveBeenCalledWith("project-a");
});
it("deduplicates replayed log events after reconnect", async () => {
const replayLog = createLog("replayed", "2026-04-19T10:00:03.000Z");
mockFetchDevServerStatus.mockResolvedValueOnce({
state: createState({ status: "running" }),
logs: [replayLog],
});
mockFetchDevServerHistory.mockResolvedValueOnce({ logs: [replayLog] });
let capturedHandlers: Parameters<typeof connectDevServerStream>[1] | null = null;
mockConnectDevServerStream.mockImplementationOnce((_projectId, handlers) => {
capturedHandlers = handlers;
return {
close: vi.fn(),
isConnected: () => true,
};
});
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.logs).toHaveLength(1);
act(() => {
capturedHandlers?.onLog?.(replayLog);
capturedHandlers?.onLog?.(createLog("new-log", "2026-04-19T10:00:04.000Z"));
});
expect(result.current.logs.map((entry) => entry.message)).toEqual(["replayed", "new-log"]);
});
it("clears stale state and reconnects when project changes", async () => {
const closeA = vi.fn();
mockFetchDevServerStatus
.mockResolvedValueOnce({
state: createState({ status: "running", previewUrl: "http://localhost:3001" }),
logs: [createLog("project-a", "2026-04-19T10:10:00.000Z")],
})
.mockResolvedValueOnce({
state: createState({ status: "stopped", previewUrl: null }),
logs: [createLog("project-b", "2026-04-19T10:20:00.000Z")],
});
mockFetchDevServerHistory
.mockResolvedValueOnce({ logs: [createLog("project-a", "2026-04-19T10:10:00.000Z")] })
.mockResolvedValueOnce({ logs: [createLog("project-b", "2026-04-19T10:20:00.000Z")] });
mockConnectDevServerStream
.mockImplementationOnce(() => ({ close: closeA, isConnected: () => true }))
.mockImplementationOnce(() => ({ close: vi.fn(), isConnected: () => true }));
const { result, rerender } = renderHook(
({ projectId }) => useDevServer(projectId),
{ initialProps: { projectId: "project-a" as string | undefined } },
it("populates candidates and server state from initial fetch", async () => {
mockFetchDevServerCandidates.mockResolvedValueOnce([
createCandidate({ name: "start", scriptName: "start", command: "npm run start" }),
]);
mockFetchDevServerStatus.mockResolvedValueOnce(
createState({ status: "running", pid: 4321, logs: ["ready"] }),
);
await waitFor(() => {
expect(result.current.logs.map((entry) => entry.message)).toEqual(["project-a"]);
});
act(() => {
result.current.setManualPreviewUrl("https://manual-preview.local");
});
expect(result.current.effectivePreviewUrl).toBe("https://manual-preview.local");
rerender({ projectId: "project-b" });
await waitFor(() => {
expect(result.current.logs.map((entry) => entry.message)).toEqual(["project-b"]);
});
expect(closeA).toHaveBeenCalledTimes(1);
expect(result.current.manualPreviewUrl).toBe("");
expect(result.current.effectivePreviewUrl).toBeNull();
});
it("exposes start/stop/restart actions", async () => {
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.start({ command: "pnpm dev", scriptName: "dev" });
expect(result.current.candidates).toHaveLength(1);
expect(result.current.serverState?.status).toBe("running");
expect(result.current.logs).toEqual(["ready"]);
});
it("creates EventSource and appends SSE log events", async () => {
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockStartDevServer).toHaveBeenCalledWith({ command: "pnpm dev", scriptName: "dev" }, "project-a");
expect(result.current.state.status).toBe("running");
expect(mockGetDevServerLogsStreamUrl).toHaveBeenCalledWith("project-a");
expect(MockEventSource.instances).toHaveLength(1);
const source = MockEventSource.instances[0];
act(() => {
source._emit("history", { lines: ["history line"] });
});
await waitFor(() => {
expect(result.current.logs).toEqual(["history line"]);
});
act(() => {
source._emit("log", { line: "new line" });
});
await waitFor(() => {
expect(result.current.logs).toEqual(["history line", "new line"]);
});
});
it("calls start API for candidate and direct command arguments", async () => {
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const candidate = createCandidate({ command: "pnpm run dev", cwd: "apps/web" });
await act(async () => {
await result.current.restart({ command: "pnpm dev" });
await result.current.start(candidate);
});
expect(mockStartDevServer).toHaveBeenCalledWith(
{ command: "pnpm run dev", scriptName: "dev", cwd: "apps/web" },
"project-a",
);
await act(async () => {
await result.current.start({ command: "npm run start", scriptName: "start", cwd: "." });
});
expect(mockStartDevServer).toHaveBeenCalledWith(
{ command: "npm run start", scriptName: "start", cwd: "." },
"project-a",
);
});
it("calls stop, restart, and setPreviewUrl APIs", async () => {
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockRestartDevServer).toHaveBeenCalledWith({ command: "pnpm dev" }, "project-a");
await act(async () => {
await result.current.stop();
});
expect(mockStopDevServer).toHaveBeenCalledWith("project-a");
expect(result.current.state.status).toBe("stopped");
await act(async () => {
await result.current.restart();
});
expect(mockRestartDevServer).toHaveBeenCalledWith("project-a");
await act(async () => {
await result.current.setPreviewUrl("https://localhost:3000");
});
expect(mockSetDevServerPreviewUrl).toHaveBeenCalledWith({ url: "https://localhost:3000" }, "project-a");
});
it("sets error when API operations fail", async () => {
mockStartDevServer.mockRejectedValueOnce(new Error("start failed"));
const { result } = renderHook(() => useDevServer("project-a"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
try {
await result.current.start({ command: "pnpm dev", scriptName: "dev" });
} catch {
// expected
}
});
expect(result.current.error).toBe("start failed");
});
it("polls status while running and stops polling when status becomes stopped", async () => {
vi.useFakeTimers();
mockFetchDevServerStatus
.mockResolvedValueOnce(createState({ status: "running" }))
.mockResolvedValueOnce(createState({ status: "running" }))
.mockResolvedValueOnce(createState({ status: "stopped" }))
.mockResolvedValue(createState({ status: "stopped" }));
renderHook(() => useDevServer("project-a"));
await flushMicrotasks();
expect(mockFetchDevServerStatus).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(3000);
});
await flushMicrotasks();
expect(mockFetchDevServerStatus).toHaveBeenCalledTimes(2);
await act(async () => {
vi.advanceTimersByTime(3000);
});
await flushMicrotasks();
expect(mockFetchDevServerStatus).toHaveBeenCalledTimes(3);
await act(async () => {
vi.advanceTimersByTime(9000);
});
await flushMicrotasks();
expect(mockFetchDevServerStatus).toHaveBeenCalledTimes(3);
});
it("cleans up EventSource and polling on unmount", async () => {
vi.useFakeTimers();
mockFetchDevServerStatus.mockResolvedValue(createState({ status: "running" }));
const { unmount } = renderHook(() => useDevServer("project-a"));
await flushMicrotasks();
expect(MockEventSource.instances).toHaveLength(1);
const source = MockEventSource.instances[0];
const closeSpy = vi.spyOn(source, "close");
unmount();
expect(closeSpy).toHaveBeenCalled();
const callsBefore = mockFetchDevServerStatus.mock.calls.length;
await act(async () => {
vi.advanceTimersByTime(6000);
});
await flushMicrotasks();
expect(mockFetchDevServerStatus.mock.calls.length).toBe(callsBefore);
});
});