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>
311 lines
9.2 KiB
TypeScript
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);
|
|
});
|
|
});
|