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>
255 lines
7.2 KiB
TypeScript
255 lines
7.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { renderHook, act, waitFor } from "@testing-library/react";
|
|
import { useDocuments } from "../useDocuments";
|
|
import type { TaskDocumentWithTask } from "@fusion/core";
|
|
import type { MarkdownFileEntry } from "../../api";
|
|
|
|
function mockFetchResponse(
|
|
ok: boolean,
|
|
body: unknown,
|
|
status = ok ? 200 : 500,
|
|
) {
|
|
const bodyText = JSON.stringify(body);
|
|
return Promise.resolve({
|
|
ok,
|
|
status,
|
|
statusText: ok ? "OK" : "Error",
|
|
headers: {
|
|
get: (name: string) =>
|
|
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
},
|
|
json: () => Promise.resolve(body),
|
|
text: () => Promise.resolve(bodyText),
|
|
} as unknown as Response);
|
|
}
|
|
|
|
function createDocumentsFetchMock(options: {
|
|
documents: TaskDocumentWithTask[];
|
|
projectFiles: MarkdownFileEntry[];
|
|
failProjectFiles?: boolean;
|
|
failDocuments?: boolean;
|
|
}) {
|
|
return vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
|
|
if (url.includes("/files/markdown-list")) {
|
|
if (options.failProjectFiles) {
|
|
return mockFetchResponse(false, { error: "Project files failed" }, 500);
|
|
}
|
|
return mockFetchResponse(true, { files: options.projectFiles });
|
|
}
|
|
|
|
if (url.includes("/documents")) {
|
|
if (options.failDocuments) {
|
|
return mockFetchResponse(false, { error: "Documents failed" }, 500);
|
|
}
|
|
return mockFetchResponse(true, options.documents);
|
|
}
|
|
|
|
throw new Error(`Unexpected fetch URL: ${url}`);
|
|
});
|
|
}
|
|
|
|
describe("useDocuments", () => {
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
const mockDocuments: TaskDocumentWithTask[] = [
|
|
{
|
|
id: "doc-1",
|
|
taskId: "KB-001",
|
|
key: "plan",
|
|
content: "Plan content",
|
|
revision: 1,
|
|
author: "user",
|
|
createdAt: "2024-01-01T00:00:00.000Z",
|
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
|
taskTitle: "Task One",
|
|
taskColumn: "triage",
|
|
},
|
|
{
|
|
id: "doc-2",
|
|
taskId: "KB-002",
|
|
key: "notes",
|
|
content: "Notes content",
|
|
revision: 1,
|
|
author: "agent",
|
|
createdAt: "2024-01-02T00:00:00.000Z",
|
|
updatedAt: "2024-01-02T00:00:00.000Z",
|
|
taskTitle: "Task Two",
|
|
taskColumn: "in-progress",
|
|
},
|
|
];
|
|
|
|
const mockProjectFiles: MarkdownFileEntry[] = [
|
|
{
|
|
path: "README.md",
|
|
name: "README.md",
|
|
size: 1024,
|
|
mtime: "2024-01-03T00:00:00.000Z",
|
|
},
|
|
{
|
|
path: "docs/CONTRIBUTING.md",
|
|
name: "CONTRIBUTING.md",
|
|
size: 900,
|
|
mtime: "2024-01-04T00:00:00.000Z",
|
|
},
|
|
];
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("loads task documents and project markdown files on mount", async () => {
|
|
globalThis.fetch = createDocumentsFetchMock({
|
|
documents: mockDocuments,
|
|
projectFiles: mockProjectFiles,
|
|
});
|
|
|
|
const { result } = renderHook(() => useDocuments());
|
|
|
|
expect(result.current.loading).toBe(true);
|
|
expect(result.current.documents).toEqual([]);
|
|
expect(result.current.projectFiles).toEqual([]);
|
|
|
|
await act(async () => {
|
|
await vi.runAllTimersAsync();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.error).toBeNull();
|
|
expect(result.current.documents).toHaveLength(2);
|
|
expect(result.current.projectFiles).toHaveLength(2);
|
|
expect(result.current.projectFiles[0].name).toBe("README.md");
|
|
});
|
|
|
|
it("continues rendering documents when project file fetch fails", async () => {
|
|
globalThis.fetch = createDocumentsFetchMock({
|
|
documents: mockDocuments,
|
|
projectFiles: [],
|
|
failProjectFiles: true,
|
|
});
|
|
|
|
const { result } = renderHook(() => useDocuments());
|
|
|
|
await act(async () => {
|
|
await vi.runAllTimersAsync();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.documents).toHaveLength(2);
|
|
expect(result.current.projectFiles).toEqual([]);
|
|
expect(result.current.error).toBeNull();
|
|
});
|
|
|
|
it("surfaces task document fetch errors", async () => {
|
|
globalThis.fetch = createDocumentsFetchMock({
|
|
documents: [],
|
|
projectFiles: mockProjectFiles,
|
|
failDocuments: true,
|
|
});
|
|
|
|
const { result } = renderHook(() => useDocuments());
|
|
|
|
await act(async () => {
|
|
await vi.runAllTimersAsync();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.error).toBe("Documents failed");
|
|
expect(result.current.documents).toEqual([]);
|
|
expect(result.current.projectFiles).toHaveLength(2);
|
|
});
|
|
|
|
it("passes search query to document endpoint and filters project files client-side", async () => {
|
|
globalThis.fetch = createDocumentsFetchMock({
|
|
documents: mockDocuments,
|
|
projectFiles: mockProjectFiles,
|
|
});
|
|
|
|
const { rerender } = renderHook(
|
|
({ searchQuery }) => useDocuments({ searchQuery }),
|
|
{ initialProps: { searchQuery: undefined as string | undefined } },
|
|
);
|
|
|
|
await act(async () => {
|
|
await vi.runAllTimersAsync();
|
|
});
|
|
|
|
rerender({ searchQuery: "readme" });
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
const mockFetch = globalThis.fetch as ReturnType<typeof vi.fn>;
|
|
const urls = mockFetch.mock.calls.map((call: unknown[]) => String(call[0]));
|
|
expect(urls.some((url) => url.includes("/documents?q=readme"))).toBe(true);
|
|
expect(urls.some((url) => url.includes("/files/markdown-list") && !url.includes("q=readme"))).toBe(true);
|
|
expect(urls.some((url) => url.includes("/project-files/md"))).toBe(false);
|
|
});
|
|
});
|
|
|
|
it("uses projectId for both document and project file requests", async () => {
|
|
globalThis.fetch = createDocumentsFetchMock({
|
|
documents: mockDocuments,
|
|
projectFiles: mockProjectFiles,
|
|
});
|
|
|
|
renderHook(() => useDocuments({ projectId: "proj-123" }));
|
|
|
|
await act(async () => {
|
|
await vi.runAllTimersAsync();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
const mockFetch = globalThis.fetch as ReturnType<typeof vi.fn>;
|
|
const urls = mockFetch.mock.calls.map((call: unknown[]) => String(call[0]));
|
|
expect(urls.some((url) => url.includes("/documents") && url.includes("projectId=proj-123"))).toBe(true);
|
|
expect(urls.some((url) => url.includes("/files/markdown-list") && url.includes("projectId=proj-123"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("cancels in-flight request on unmount", async () => {
|
|
const abortMock = vi.fn();
|
|
const originalAbortController = globalThis.AbortController;
|
|
|
|
globalThis.AbortController = vi.fn().mockImplementation(() => ({
|
|
signal: {},
|
|
abort: abortMock,
|
|
})) as unknown as typeof AbortController;
|
|
|
|
globalThis.fetch = vi.fn().mockReturnValue(
|
|
new Promise(() => {
|
|
// Keep pending to simulate in-flight requests
|
|
}),
|
|
);
|
|
|
|
const { unmount } = renderHook(() => useDocuments());
|
|
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
unmount();
|
|
|
|
expect(abortMock).toHaveBeenCalled();
|
|
|
|
globalThis.AbortController = originalAbortController;
|
|
});
|
|
});
|