Files
fusion/packages/dashboard/app/components/__tests__/SessionNotificationBanner.test.tsx
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

318 lines
10 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import type { AiSessionSummary } from "../../api";
import { SessionNotificationBanner, dismissedIds } from "../SessionNotificationBanner";
function buildSession(overrides: Partial<AiSessionSummary>): AiSessionSummary {
return {
id: overrides.id ?? "session-1",
type: overrides.type ?? "planning",
status: overrides.status ?? "awaiting_input",
title: overrides.title ?? "Draft implementation plan",
projectId: overrides.projectId ?? "proj-1",
lockedByTab: overrides.lockedByTab ?? null,
updatedAt: overrides.updatedAt ?? new Date().toISOString(),
};
}
describe("SessionNotificationBanner", () => {
beforeEach(() => {
dismissedIds.clear();
});
it("renders nothing when no sessions need input", () => {
const { container } = render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "a", status: "generating" }),
buildSession({ id: "b", status: "complete" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(container.firstChild).toBeNull();
});
it("renders banner with correct awaiting_input count", () => {
render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "a", title: "First", status: "awaiting_input" }),
buildSession({ id: "b", title: "Second", status: "awaiting_input" }),
buildSession({ id: "c", title: "Done", status: "complete" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(screen.getByText("2 AI sessions need your input")).toBeInTheDocument();
expect(screen.getByText("First")).toBeInTheDocument();
expect(screen.getByText("Second")).toBeInTheDocument();
expect(screen.queryByText("Done")).not.toBeInTheDocument();
});
it("does not render sessions that are generating or complete", () => {
render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "planning", type: "planning", title: "Planning Session", status: "awaiting_input" }),
buildSession({ id: "gen", title: "Generating", status: "generating" }),
buildSession({ id: "complete", title: "Complete", status: "complete" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(screen.getByText("Planning Session")).toBeInTheDocument();
expect(screen.queryByText("Generating")).not.toBeInTheDocument();
expect(screen.queryByText("Complete")).not.toBeInTheDocument();
});
it("calls onResumeSession with the selected session", () => {
const onResumeSession = vi.fn();
const planningSession = buildSession({
id: "planning-1",
type: "planning",
status: "awaiting_input",
title: "Plan checkout flow",
});
render(
<SessionNotificationBanner
sessions={[planningSession]}
onResumeSession={onResumeSession}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
expect(onResumeSession).toHaveBeenCalledWith(planningSession);
});
it("calls onDismissSession with the session id", () => {
const onDismissSession = vi.fn();
render(
<SessionNotificationBanner
sessions={[buildSession({ id: "dismiss-1", title: "Dismiss me" })]}
onResumeSession={vi.fn()}
onDismissSession={onDismissSession}
onDismissAll={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Dismiss Dismiss me" }));
expect(onDismissSession).toHaveBeenCalledWith("dismiss-1");
});
it("calls onDismissAll when clicking dismiss all", () => {
const onDismissAll = vi.fn();
render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "a", title: "A" }),
buildSession({ id: "b", title: "B" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={onDismissAll}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /dismiss all/i }));
expect(onDismissAll).toHaveBeenCalledTimes(1);
});
it("shows type labels and icons for planning, subtask, and mission interview", () => {
const { container } = render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "planning", type: "planning", title: "Plan" }),
buildSession({ id: "subtask", type: "subtask", title: "Breakdown" }),
buildSession({ id: "mission", type: "mission_interview", title: "Mission" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(screen.getByText("Planning")).toBeInTheDocument();
expect(screen.getByText("Subtask Breakdown")).toBeInTheDocument();
expect(screen.getByText("Mission Interview")).toBeInTheDocument();
expect(container.querySelector(".lucide-lightbulb")).toBeTruthy();
expect(container.querySelector(".lucide-layers")).toBeTruthy();
expect(container.querySelector(".lucide-target")).toBeTruthy();
});
it("removes dismissed sessions from the banner", () => {
render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "first", title: "First Session" }),
buildSession({ id: "second", title: "Second Session" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Dismiss First Session" }));
expect(screen.queryByText("First Session")).not.toBeInTheDocument();
expect(screen.getByText("Second Session")).toBeInTheDocument();
});
it("renders error sessions in the banner", () => {
render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "error-session", type: "mission_interview", title: "Failed Mission", status: "error" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(screen.getByText("Failed Mission")).toBeInTheDocument();
expect(screen.getByText("Failed")).toBeInTheDocument();
});
it("shows 'Retry' button for error sessions instead of 'Resume'", () => {
const errorSession = buildSession({
id: "error-1",
type: "mission_interview",
status: "error",
title: "Error Session",
});
render(
<SessionNotificationBanner
sessions={[errorSession]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Resume" })).not.toBeInTheDocument();
});
it("calls onResumeSession when clicking Retry on error session", () => {
const onResumeSession = vi.fn();
const errorSession = buildSession({
id: "error-retry",
type: "mission_interview",
status: "error",
title: "Error to Retry",
});
render(
<SessionNotificationBanner
sessions={[errorSession]}
onResumeSession={onResumeSession}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(onResumeSession).toHaveBeenCalledWith(errorSession);
});
it("shows combined header text for both awaiting_input and error sessions", () => {
render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "awaiting", status: "awaiting_input", title: "Awaiting Input" }),
buildSession({ id: "error", status: "error", title: "Error Session" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(screen.getByText("1 AI session needs your input, 1 failed")).toBeInTheDocument();
});
it("shows error count only when no awaiting_input sessions", () => {
render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "error1", status: "error", title: "Error 1" }),
buildSession({ id: "error2", status: "error", title: "Error 2" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(screen.getByText("2 AI sessions failed")).toBeInTheDocument();
});
it("dismisses error sessions from the banner", () => {
const onDismissSession = vi.fn();
render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "error-dismiss", status: "error", title: "Error to Dismiss" }),
]}
onResumeSession={vi.fn()}
onDismissSession={onDismissSession}
onDismissAll={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Dismiss Error to Dismiss" }));
expect(onDismissSession).toHaveBeenCalledWith("error-dismiss");
});
it("preserves dismissed error sessions when session status changes", () => {
const { rerender } = render(
<SessionNotificationBanner
sessions={[
buildSession({ id: "session-1", status: "error", title: "Error Session" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
// Dismiss the error session
fireEvent.click(screen.getByRole("button", { name: "Dismiss Error Session" }));
expect(screen.queryByText("Error Session")).not.toBeInTheDocument();
// Simulate session status changing to complete (should NOT reappear)
rerender(
<SessionNotificationBanner
sessions={[
buildSession({ id: "session-1", status: "complete", title: "Error Session" }),
]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
// Dismissed session should still be hidden even though status changed
expect(screen.queryByText("Error Session")).not.toBeInTheDocument();
});
});