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

200 lines
6.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useRemoteNodeEvents } from "../useRemoteNodeEvents";
// Mock subscribeSse from sse-bus
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn(),
}));
import { subscribeSse, type SseSubscription } from "../../sse-bus";
describe("useRemoteNodeEvents", () => {
// Captured subscribeSse calls for inspection
let capturedConfigs: Array<{
url: string;
config: SseSubscription;
unsubscribe: ReturnType<typeof subscribeSse>;
}> = [];
const createMockUnsubscribe = () => vi.fn();
const mockSubscribeSse = vi.mocked(subscribeSse);
beforeEach(() => {
capturedConfigs = [];
mockSubscribeSse.mockImplementation((url: string, config: SseSubscription = {}) => {
const unsubscribe = createMockUnsubscribe();
capturedConfigs.push({ url, config, unsubscribe });
return unsubscribe;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("when nodeId is null", () => {
it("returns disconnected state without calling subscribeSse", () => {
const { result } = renderHook(() => useRemoteNodeEvents(null));
expect(result.current.isConnected).toBe(false);
expect(result.current.lastEvent).toBe(null);
expect(mockSubscribeSse).not.toHaveBeenCalled();
});
});
describe("when nodeId is provided", () => {
it("calls subscribeSse with proxy SSE endpoint URL", () => {
renderHook(() => useRemoteNodeEvents("node_abc"));
expect(mockSubscribeSse).toHaveBeenCalledTimes(1);
expect(mockSubscribeSse).toHaveBeenCalledWith(
"/api/proxy/node_abc/events",
expect.objectContaining({ events: expect.any(Object) }),
);
});
it("properly encodes nodeId with special characters", () => {
renderHook(() => useRemoteNodeEvents("node/abc+test"));
expect(mockSubscribeSse).toHaveBeenCalledWith(
"/api/proxy/node%2Fabc%2Btest/events",
expect.any(Object),
);
});
it("returns disconnected initially", () => {
const { result } = renderHook(() => useRemoteNodeEvents("node_abc"));
expect(result.current.isConnected).toBe(false);
expect(result.current.lastEvent).toBe(null);
});
it("sets isConnected to true when onOpen callback is called", () => {
renderHook(() => useRemoteNodeEvents("node_abc"));
const { config } = capturedConfigs[0];
act(() => {
config.onOpen?.();
});
// Note: isConnected is managed inside the hook, but we can verify
// the callback was registered by checking the hook state after calling it
const { result } = renderHook(() => useRemoteNodeEvents("node_abc"));
// The hook starts disconnected
expect(result.current.isConnected).toBe(false);
});
it("stores last event when task:created event handler is invoked", () => {
const { result } = renderHook(() => useRemoteNodeEvents("node_abc"));
const { config } = capturedConfigs[0];
act(() => {
config.events?.["task:created"]?.({ data: '{"id":"FN-001","title":"Test"}' } as MessageEvent);
});
expect(result.current.lastEvent).toEqual({
type: "task:created",
data: '{"id":"FN-001","title":"Test"}',
});
});
it("stores last event for each event type", () => {
const { result } = renderHook(() => useRemoteNodeEvents("node_abc"));
const { config } = capturedConfigs[0];
// Test task:moved
act(() => {
config.events?.["task:moved"]?.({ data: '{"task":"FN-001","to":"in-progress"}' } as MessageEvent);
});
expect(result.current.lastEvent?.type).toBe("task:moved");
// Test task:updated
act(() => {
config.events?.["task:updated"]?.({ data: '{"id":"FN-001","title":"Updated"}' } as MessageEvent);
});
expect(result.current.lastEvent?.type).toBe("task:updated");
// Test task:deleted
act(() => {
config.events?.["task:deleted"]?.({ data: '{"id":"FN-001"}' } as MessageEvent);
});
expect(result.current.lastEvent?.type).toBe("task:deleted");
// Test task:merged
act(() => {
config.events?.["task:merged"]?.({ data: '{"id":"FN-001"}' } as MessageEvent);
});
expect(result.current.lastEvent?.type).toBe("task:merged");
});
it("calls unsubscribe on unmount", () => {
const { unmount } = renderHook(() => useRemoteNodeEvents("node_abc"));
const { unsubscribe } = capturedConfigs[0];
expect(unsubscribe).not.toHaveBeenCalled();
unmount();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
it("closes previous subscription when nodeId changes", () => {
const { rerender } = renderHook(
({ nodeId }: { nodeId: string | null }) => useRemoteNodeEvents(nodeId),
{ initialProps: { nodeId: "node_abc" } },
);
const { unsubscribe: firstUnsubscribe } = capturedConfigs[0];
// Change nodeId
rerender({ nodeId: "node_xyz" });
// First subscription should have been cleaned up
expect(firstUnsubscribe).toHaveBeenCalledTimes(1);
// New subscription should have been created
expect(capturedConfigs.length).toBe(2);
});
it("cleans up subscription when component unmounts with error", () => {
const { result, unmount } = renderHook(() => useRemoteNodeEvents("node_abc"));
const { config, unsubscribe } = capturedConfigs[0];
// Simulate error
act(() => {
config.onError?.({} as Event);
});
expect(result.current.isConnected).toBe(false);
unmount();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});
describe("event handler registration", () => {
it("registers all required event handlers", () => {
renderHook(() => useRemoteNodeEvents("node_abc"));
const { config } = capturedConfigs[0];
expect(config.events).toHaveProperty("task:created");
expect(config.events).toHaveProperty("task:moved");
expect(config.events).toHaveProperty("task:updated");
expect(config.events).toHaveProperty("task:deleted");
expect(config.events).toHaveProperty("task:merged");
});
it("registers onOpen and onError callbacks", () => {
renderHook(() => useRemoteNodeEvents("node_abc"));
const { config } = capturedConfigs[0];
expect(config.onOpen).toBeDefined();
expect(config.onError).toBeDefined();
});
});
});