test(FN-2179): complete Step 6 dev server hook and component tests
This commit is contained in:
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user