Files
fusion/packages/dashboard/app/hooks/__tests__/useDocuments.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

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;
});
});