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

489 lines
16 KiB
TypeScript

/**
* useInsights Hook Tests
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useInsights, INSIGHT_CATEGORIES } from "../useInsights";
import type { InsightCategory, InsightStatus } from "@fusion/core";
// Mock the API module
vi.mock("../../api", () => ({
fetchInsights: vi.fn(),
dismissInsight: vi.fn(),
triggerInsightRun: vi.fn(),
fetchInsightRuns: vi.fn(),
getInsightCreateTaskData: vi.fn(),
}));
// Mock lucide-react icons used in the view
vi.mock("lucide-react", () => ({
RefreshCw: () => "RefreshCw",
Play: () => "Play",
X: () => "X",
Plus: () => "Plus",
AlertCircle: () => "AlertCircle",
CheckCircle: () => "CheckCircle",
Sparkles: () => "Sparkles",
Lightbulb: () => "Lightbulb",
Building: () => "Building",
Users: () => "Users",
LineChart: () => "LineChart",
TrendingUp: () => "TrendingUp",
MoreVertical: () => "MoreVertical",
ExternalLink: () => "ExternalLink",
Archive: () => "Archive",
Clock: () => "Clock",
FileText: () => "FileText",
}));
import {
fetchInsights,
dismissInsight,
triggerInsightRun,
fetchInsightRuns,
getInsightCreateTaskData,
} from "../../api";
const mockFetchInsights = vi.mocked(fetchInsights);
const mockDismissInsight = vi.mocked(dismissInsight);
const mockTriggerInsightRun = vi.mocked(triggerInsightRun);
const mockFetchInsightRuns = vi.mocked(fetchInsightRuns);
const mockGetInsightCreateTaskData = vi.mocked(getInsightCreateTaskData);
describe("useInsights", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("initial state", () => {
it("should start with loading state", async () => {
mockFetchInsights.mockImplementation(() => new Promise(() => {})); // Never resolves
mockFetchInsightRuns.mockImplementation(() => new Promise(() => {}));
const { result } = renderHook(() => useInsights("project-1"));
expect(result.current.loading).toBe(true);
expect(result.current.sections).toHaveLength(INSIGHT_CATEGORIES.length);
expect(result.current.isRunInFlight).toBe(false);
});
it("should initialize with all canonical sections", async () => {
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
const { result } = renderHook(() => useInsights());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.sections).toHaveLength(INSIGHT_CATEGORIES.length);
expect(result.current.sections.map((s) => s.category)).toEqual(INSIGHT_CATEGORIES);
});
});
describe("fetching insights", () => {
it("should fetch and group insights by category", async () => {
const mockInsights = [
{
id: "INS-1",
projectId: "project-1",
title: "Feature Insight 1",
content: "Content 1",
category: "features" as InsightCategory,
status: "generated" as InsightStatus,
fingerprint: "fp1",
provenance: { trigger: "manual" },
lastRunId: "RUN-1",
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
{
id: "INS-2",
projectId: "project-1",
title: "Architecture Insight 1",
content: "Content 2",
category: "architecture" as InsightCategory,
status: "confirmed" as InsightStatus,
fingerprint: "fp2",
provenance: { trigger: "manual" },
lastRunId: "RUN-1",
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
];
mockFetchInsights.mockResolvedValue({ insights: mockInsights, count: 2 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
const { result } = renderHook(() => useInsights("project-1"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const featuresSection = result.current.sections.find((s) => s.category === "features");
const architectureSection = result.current.sections.find((s) => s.category === "architecture");
expect(featuresSection?.items).toHaveLength(1);
expect(featuresSection?.items[0].id).toBe("INS-1");
expect(architectureSection?.items).toHaveLength(1);
expect(architectureSection?.items[0].id).toBe("INS-2");
});
it("should filter out dismissed insights", async () => {
const mockInsights = [
{
id: "INS-1",
projectId: "project-1",
title: "Active Insight",
content: "Content",
category: "features" as InsightCategory,
status: "generated" as InsightStatus,
fingerprint: "fp1",
provenance: { trigger: "manual" },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
{
id: "INS-2",
projectId: "project-1",
title: "Dismissed Insight",
content: "Content",
category: "features" as InsightCategory,
status: "dismissed" as InsightStatus,
fingerprint: "fp2",
provenance: { trigger: "manual" },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
];
mockFetchInsights.mockResolvedValue({ insights: mockInsights, count: 1 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
const { result } = renderHook(() => useInsights());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const featuresSection = result.current.sections.find((s) => s.category === "features");
expect(featuresSection?.items).toHaveLength(1);
expect(featuresSection?.items[0].id).toBe("INS-1");
});
it("should handle fetch errors", async () => {
mockFetchInsights.mockRejectedValue(new Error("Network error"));
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
const { result } = renderHook(() => useInsights());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe("Network error");
});
});
describe("runInsights", () => {
it("should trigger manual insight run and refresh when run completes", async () => {
const completedRun = {
id: "RUN-1",
projectId: "project-1",
trigger: "manual" as const,
status: "completed" as const,
summary: "Generated insights",
error: null,
insightsCreated: 3,
insightsUpdated: 1,
inputMetadata: {},
outputMetadata: {},
createdAt: "2024-01-01T00:00:00Z",
startedAt: "2024-01-01T00:00:10Z",
completedAt: "2024-01-01T00:01:00Z",
};
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockTriggerInsightRun.mockResolvedValue(completedRun);
const { result } = renderHook(() => useInsights("project-1"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.runInsights();
});
expect(mockTriggerInsightRun).toHaveBeenCalledWith("manual", undefined, "project-1");
expect(result.current.latestRun?.status).toBe("completed");
expect(result.current.isRunInFlight).toBe(false);
expect(mockFetchInsights.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(mockFetchInsightRuns.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it("should surface failed run errors from triggerInsightRun response", async () => {
const failedRun = {
id: "RUN-1",
projectId: "project-1",
trigger: "manual" as const,
status: "failed" as const,
summary: null,
error: "No working memory to analyze",
insightsCreated: 0,
insightsUpdated: 0,
inputMetadata: {},
outputMetadata: {},
createdAt: "2024-01-01T00:00:00Z",
startedAt: "2024-01-01T00:00:10Z",
completedAt: "2024-01-01T00:01:00Z",
};
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockTriggerInsightRun.mockResolvedValue(failedRun);
const { result } = renderHook(() => useInsights("project-1"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.runInsights();
});
expect(result.current.runError).toBe("No working memory to analyze");
expect(result.current.isRunInFlight).toBe(false);
// Initial load only; failed run should not trigger refresh.
expect(mockFetchInsights).toHaveBeenCalledTimes(1);
});
it("should propagate trigger errors and always clear in-flight state", async () => {
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockTriggerInsightRun.mockRejectedValue(new Error("Run failed"));
const { result } = renderHook(() => useInsights());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
let thrown: unknown = null;
await act(async () => {
try {
await result.current.runInsights();
} catch (error) {
thrown = error;
}
});
expect(thrown).toBeInstanceOf(Error);
expect((thrown as Error).message).toBe("Run failed");
expect(result.current.runError).toBe("Run failed");
expect(result.current.isRunInFlight).toBe(false);
});
});
describe("dismiss", () => {
it("should remove dismissed insight from local state", async () => {
const mockInsights = [
{
id: "INS-1",
projectId: "project-1",
title: "Test Insight",
content: "Content",
category: "features" as InsightCategory,
status: "generated" as InsightStatus,
fingerprint: "fp1",
provenance: { trigger: "manual" },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
];
mockFetchInsights.mockResolvedValue({ insights: mockInsights, count: 1 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockDismissInsight.mockResolvedValue({ ...mockInsights[0], status: "dismissed" });
const { result } = renderHook(() => useInsights("project-1"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.dismiss("INS-1");
});
expect(mockDismissInsight).toHaveBeenCalledWith("INS-1", "project-1");
const featuresSection = result.current.sections.find((s) => s.category === "features");
expect(featuresSection?.items).toHaveLength(0);
});
it("should set error state on dismiss failure", async () => {
mockFetchInsights.mockResolvedValue({
insights: [
{
id: "INS-1",
projectId: "project-1",
title: "Test",
content: "Content",
category: "features" as InsightCategory,
status: "generated" as InsightStatus,
fingerprint: "fp1",
provenance: { trigger: "manual" },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
],
count: 1,
});
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockDismissInsight.mockRejectedValue(new Error("Dismiss failed"));
const { result } = renderHook(() => useInsights());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Test that dismiss throws
await expect(result.current.dismiss("INS-1")).rejects.toThrow("Dismiss failed");
});
});
describe("createTask", () => {
it("should return task data from insight", async () => {
const mockInsight = {
id: "INS-1",
projectId: "project-1",
title: "Implement Feature X",
content: "Detailed description of the feature",
category: "features" as const,
status: "generated" as const,
fingerprint: "fp1",
provenance: { trigger: "manual" },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
};
mockFetchInsights.mockResolvedValue({ insights: [mockInsight], count: 1 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockGetInsightCreateTaskData.mockResolvedValue({
success: true,
insight: mockInsight,
suggestedTitle: "Implement Feature X",
suggestedDescription: "Detailed description of the feature",
});
const { result } = renderHook(() => useInsights("project-1"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
let taskData: { title: string; description: string } | null = null;
await act(async () => {
taskData = await result.current.createTask("INS-1");
});
expect(mockGetInsightCreateTaskData).toHaveBeenCalledWith("INS-1", "project-1");
expect(taskData).toEqual({
title: "Implement Feature X",
description: "Detailed description of the feature",
});
});
it("should set error state on createTask failure", async () => {
mockFetchInsights.mockResolvedValue({
insights: [
{
id: "INS-1",
projectId: "project-1",
title: "Test",
content: "Content",
category: "features" as InsightCategory,
status: "generated" as InsightStatus,
fingerprint: "fp1",
provenance: { trigger: "manual" },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
],
count: 1,
});
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockGetInsightCreateTaskData.mockRejectedValue(new Error("Create task failed"));
const { result } = renderHook(() => useInsights());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Test that createTask throws
await expect(result.current.createTask("INS-1")).rejects.toThrow("Create task failed");
});
});
describe("refresh", () => {
it("should reload insights data", async () => {
mockFetchInsights
.mockResolvedValueOnce({ insights: [], count: 0 })
.mockResolvedValueOnce({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
const { result } = renderHook(() => useInsights());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Update mock for refresh
mockFetchInsights.mockResolvedValueOnce({
insights: [
{
id: "INS-1",
projectId: "project-1",
title: "New Insight",
content: "Content",
category: "features" as InsightCategory,
status: "generated" as InsightStatus,
fingerprint: "fp1",
provenance: { trigger: "manual" },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
],
count: 1,
});
await act(async () => {
await result.current.refresh();
});
expect(mockFetchInsights).toHaveBeenCalledTimes(2);
});
});
});