Files
fusion/packages/dashboard/app/hooks/__tests__/useDevServer.test.ts
gsxdsm 3031d05a68 fix(dashboard): clear all 661 test-file type errors
Production typecheck (tsconfig.json + tsconfig.app.json) was already
clean, but a third config that includes test files surfaced 661 errors
across 60+ test files — accumulated drift between mock fixtures and
production types. Six parallel typescript-pro agents fixed every one
without touching production code.

Per-scope before/after (errors → 0):
  ChatView                                                     183
  Mailbox + Agent suite (5 files)                              156
  Task / Modal suite (6 files)                                 127
  App + small components (12 files)                             96
  Hooks + api/auth (8 files)                                    48
  Long tail (32 files)                                          51
  -----------------------------------------------------------------
  Total                                                        661

Major fix categories:
- Untyped state objects inferring `never[]` / `null` literals (root
  cause of ~120 errors in ChatView alone — added a single
  `UseChatReturn` annotation)
- Mock objects missing fields that became required: `WorkflowStep.mode`,
  `ChatMessage.thinkingOutput / metadata`, `ChatSession.projectId`,
  `Task.log`, `ProjectHealth` fields, `PtyTerminalSessionInfo.createdAt`,
  `Agent.metadata`, `InboxResponse.total`, etc.
- Mock objects with stale fields that no longer exist:
  `AgentBudgetStatus.budgetPeriod`, `truncated` on log responses,
  `OutboxResponse.unreadCount`, `MergeResult.source/target/details`
- Modal props that became required (e.g. `PlanningModeModal.onTasksCreated`)
- String literals not in narrowed unions (`Column`, `WorkflowStepPhase`,
  `InsightStatus`, `AgentLogType`, etc.)
- `querySelector` returning `Element` cast to `HTMLElement` for
  `@testing-library/react`'s `within()`
- Vitest mock typing: `.mock.calls` access needing `vi.mocked(...)`,
  zero-param tuple handling, generic `vi.fn(() => [])` inferring
  `never[]`

Helpers introduced in test files (no shared infra):
- `makeSettings(overrides)` in ModelSelectorTab.test.tsx
- `makePromptOverrides(overrides)` in AgentPromptsManager.test.tsx
- `FileBrowserTestOverrides` type alias in FileBrowser.test.tsx
- `makeInboxResponse / makeOutboxResponse` in MailboxView.test.tsx

Verification:
- tsc -p tsconfig.json:        exit 0
- tsc -p tsconfig.app.json:    exit 0
- tsc -p tsconfig.test-check.json (new — includes test files): exit 0
- vitest run:                  9639 / 9641 (2 pre-existing failures
                               in terminal-mobile-keyboard-layout.test.ts
                               unrelated to this work; verified via
                               `git stash` + run on clean HEAD)

Adds packages/dashboard/tsconfig.test-check.json to keep this regression
guard available locally — same as tsconfig.app.json minus the test
exclude.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 19:59:52 -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 ?? null;
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);
});
});