Files
fusion/packages/dashboard/app/hooks/__tests__/useDevServerLogs.test.ts
Fusion 99cafa3a44 feat(FN-2170): add dev-server preview detection and log viewer flow
- Add dev-server port detection utilities and wire detected preview state into process/manager lifecycle
- Normalize dev-server status API responses with preview URL, detected port, and manual override compatibility
- Introduce useDevServerLogs and DevServerLogViewer, and integrate them into the DevServer dashboard view
- Expand dashboard test coverage for detection edges, process/routes behavior, log history handling, and CSS regressions
2026-04-20 15:37:32 -07:00

359 lines
10 KiB
TypeScript

import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MAX_LOG_ENTRIES, useDevServerLogs } from "../useDevServerLogs";
import { fetchDevServerLogHistory, getDevServerLogsStreamUrl } from "../../api";
import { subscribeSse } from "../../sse-bus";
vi.mock("../../api", () => ({
fetchDevServerLogHistory: vi.fn().mockResolvedValue({ lines: [], totalLines: 0 }),
getDevServerLogsStreamUrl: vi.fn((projectId?: string) =>
projectId
? `/api/dev-server/logs/stream?projectId=${encodeURIComponent(projectId)}`
: "/api/dev-server/logs/stream"
),
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn(),
}));
const mockFetchDevServerLogHistory = vi.mocked(fetchDevServerLogHistory);
const mockGetDevServerLogsStreamUrl = vi.mocked(getDevServerLogsStreamUrl);
const mockSubscribeSse = vi.mocked(subscribeSse);
type SubscriptionRecord = {
url: string;
options: {
events?: Record<string, (event: MessageEvent) => void>;
onReconnect?: () => void;
};
unsubscribe: ReturnType<typeof vi.fn>;
};
const subscriptions: SubscriptionRecord[] = [];
function emit(subscription: SubscriptionRecord, eventName: string, payload: unknown): void {
const handler = subscription.options.events?.[eventName];
if (!handler) {
throw new Error(`No handler registered for event: ${eventName}`);
}
act(() => {
handler({ data: JSON.stringify(payload) } as MessageEvent);
});
}
beforeEach(() => {
subscriptions.length = 0;
vi.clearAllMocks();
mockFetchDevServerLogHistory.mockResolvedValue({ lines: [], totalLines: 0 });
mockSubscribeSse.mockImplementation((url, options = {}) => {
const unsubscribe = vi.fn();
subscriptions.push({
url,
options,
unsubscribe,
});
return unsubscribe;
});
});
describe("useDevServerLogs", () => {
it("loads initial history on mount when enabled", async () => {
mockFetchDevServerLogHistory.mockResolvedValueOnce({
lines: [{ id: 1, text: "Server started", stream: "stdout", timestamp: "2026-04-19T10:30:00Z" }],
totalLines: 1,
});
const { result } = renderHook(() => useDevServerLogs("proj-1", true));
await waitFor(() => {
expect(result.current.entries).toHaveLength(1);
expect(result.current.entries[0]?.text).toBe("Server started");
});
expect(result.current.total).toBe(1);
expect(result.current.hasMore).toBe(false);
expect(mockFetchDevServerLogHistory).toHaveBeenCalledWith({ maxLines: 100 }, "proj-1");
expect(mockGetDevServerLogsStreamUrl).toHaveBeenCalledWith("proj-1");
});
it("subscribes to SSE stream and appends live entries", async () => {
mockFetchDevServerLogHistory.mockResolvedValueOnce({
lines: [{ id: 1, text: "Boot", stream: "stdout", timestamp: "2026-04-19T10:30:00Z" }],
totalLines: 1,
});
const { result } = renderHook(() => useDevServerLogs("proj-1", true));
await waitFor(() => {
expect(subscriptions).toHaveLength(1);
});
const sub = subscriptions[0]!;
expect(sub.url).toContain("/api/dev-server/logs/stream");
emit(sub, "dev-server:log", {
id: 2,
text: "Compiled successfully",
stream: "stdout",
timestamp: "2026-04-19T10:31:00Z",
});
emit(sub, "log", {
line: "Legacy log line",
stream: "stderr",
timestamp: "2026-04-19T10:31:01Z",
});
await waitFor(() => {
expect(result.current.entries.map((entry) => entry.text)).toEqual([
"Boot",
"Compiled successfully",
"Legacy log line",
]);
});
expect(result.current.entries[2]?.stream).toBe("stderr");
});
it("handles history SSE events for legacy string payloads", async () => {
const { result } = renderHook(() => useDevServerLogs("proj-legacy", true));
await waitFor(() => {
expect(subscriptions).toHaveLength(1);
});
emit(subscriptions[0]!, "history", { lines: ["line 1", "line 2"] });
await waitFor(() => {
expect(result.current.entries).toHaveLength(2);
});
expect(result.current.entries[0]).toMatchObject({ id: 1, text: "line 1", stream: "stdout" });
expect(result.current.entries[1]).toMatchObject({ id: 2, text: "line 2", stream: "stdout" });
});
it("caps entries at MAX_LOG_ENTRIES", async () => {
const initial = Array.from({ length: MAX_LOG_ENTRIES }, (_, index) => ({
id: index + 1,
text: `line-${index + 1}`,
stream: "stdout" as const,
timestamp: "",
}));
mockFetchDevServerLogHistory.mockResolvedValueOnce({
lines: initial,
totalLines: MAX_LOG_ENTRIES,
});
const { result } = renderHook(() => useDevServerLogs("proj-cap", true));
await waitFor(() => {
expect(result.current.entries).toHaveLength(MAX_LOG_ENTRIES);
});
const sub = subscriptions[0]!;
for (let index = 0; index < 5; index += 1) {
emit(sub, "dev-server:log", {
id: MAX_LOG_ENTRIES + index + 1,
text: `new-${index}`,
stream: "stdout",
timestamp: "",
});
}
expect(result.current.entries).toHaveLength(MAX_LOG_ENTRIES);
expect(result.current.entries[0]?.id).toBe(6);
expect(result.current.entries[MAX_LOG_ENTRIES - 1]?.id).toBe(MAX_LOG_ENTRIES + 5);
});
it("deduplicates entries by id", async () => {
mockFetchDevServerLogHistory.mockResolvedValueOnce({
lines: [{ id: 1, text: "first", stream: "stdout", timestamp: "" }],
totalLines: 1,
});
const { result } = renderHook(() => useDevServerLogs("proj-dedupe", true));
await waitFor(() => {
expect(result.current.entries).toHaveLength(1);
});
const sub = subscriptions[0]!;
emit(sub, "dev-server:log", { id: 2, text: "live", stream: "stdout", timestamp: "" });
emit(sub, "dev-server:log", { id: 2, text: "live duplicate", stream: "stdout", timestamp: "" });
expect(result.current.entries).toHaveLength(2);
expect(result.current.entries[1]?.text).toBe("live");
});
it("clears entries and opens new subscription on project change", async () => {
mockFetchDevServerLogHistory.mockImplementation((_, projectId) => {
if (projectId === "A") {
return Promise.resolve({
lines: [{ id: 1, text: "A-log", stream: "stdout", timestamp: "" }],
totalLines: 1,
});
}
return Promise.resolve({
lines: [{ id: 10, text: "B-log", stream: "stdout", timestamp: "" }],
totalLines: 1,
});
});
const { result, rerender } = renderHook(
({ projectId }) => useDevServerLogs(projectId, true),
{ initialProps: { projectId: "A" } },
);
await waitFor(() => {
expect(result.current.entries[0]?.text).toBe("A-log");
});
const oldSub = subscriptions[0]!;
rerender({ projectId: "B" });
expect(result.current.entries).toEqual([]);
await waitFor(() => {
expect(result.current.entries[0]?.text).toBe("B-log");
});
expect(subscriptions).toHaveLength(2);
emit(oldSub, "dev-server:log", {
id: 2,
text: "stale-A",
stream: "stdout",
timestamp: "",
});
expect(result.current.entries.map((entry) => entry.text)).not.toContain("stale-A");
});
it("unsubscribes from SSE on unmount", async () => {
const { unmount } = renderHook(() => useDevServerLogs("proj-unmount", true));
await waitFor(() => {
expect(subscriptions).toHaveLength(1);
});
const unsubscribe = subscriptions[0]!.unsubscribe;
unmount();
expect(unsubscribe).toHaveBeenCalledOnce();
});
it("loadMore fetches older entries and prepends them", async () => {
mockFetchDevServerLogHistory
.mockResolvedValueOnce({
lines: Array.from({ length: 100 }, (_, index) => ({
id: index + 101,
text: `new-${index + 101}`,
stream: "stdout" as const,
timestamp: "",
})),
totalLines: 200,
})
.mockResolvedValueOnce({
lines: Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
text: `old-${index + 1}`,
stream: "stdout" as const,
timestamp: "",
})),
totalLines: 200,
});
const { result } = renderHook(() => useDevServerLogs("proj-load", true));
await waitFor(() => {
expect(result.current.entries).toHaveLength(100);
expect(result.current.hasMore).toBe(true);
});
await act(async () => {
await result.current.loadMore();
});
expect(mockFetchDevServerLogHistory).toHaveBeenNthCalledWith(2, { maxLines: 100, offset: 100 }, "proj-load");
expect(result.current.entries).toHaveLength(200);
expect(result.current.entries[0]?.id).toBe(1);
expect(result.current.entries[199]?.id).toBe(200);
});
it("does not fetch when disabled", () => {
const { result } = renderHook(() => useDevServerLogs("proj-disabled", false));
expect(mockFetchDevServerLogHistory).not.toHaveBeenCalled();
expect(mockSubscribeSse).not.toHaveBeenCalled();
expect(result.current.entries).toEqual([]);
});
it("clear callback removes all entries", async () => {
mockFetchDevServerLogHistory.mockResolvedValueOnce({
lines: [{ id: 1, text: "one", stream: "stdout", timestamp: "" }],
totalLines: 1,
});
const { result } = renderHook(() => useDevServerLogs("proj-clear", true));
await waitFor(() => {
expect(result.current.entries).toHaveLength(1);
});
act(() => {
result.current.clear();
});
expect(result.current.entries).toEqual([]);
});
it("fetches reconnect catch-up using lastEventId", async () => {
mockFetchDevServerLogHistory
.mockResolvedValueOnce({
lines: [{ id: 1, text: "one", stream: "stdout", timestamp: "" }],
totalLines: 2,
})
.mockResolvedValueOnce({
lines: [{ id: 3, text: "three", stream: "stdout", timestamp: "" }],
totalLines: 3,
});
const { result } = renderHook(() => useDevServerLogs("proj-reconnect", true));
await waitFor(() => {
expect(subscriptions).toHaveLength(1);
});
emit(subscriptions[0]!, "dev-server:log", {
id: 2,
text: "two",
stream: "stdout",
timestamp: "",
});
await act(async () => {
subscriptions[0]!.options.onReconnect?.();
});
await waitFor(() => {
expect(mockFetchDevServerLogHistory).toHaveBeenNthCalledWith(
2,
{ lastEventId: 2, maxLines: 50 },
"proj-reconnect",
);
});
expect(result.current.entries.map((entry) => entry.id)).toEqual([1, 2, 3]);
});
});