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>
200 lines
6.4 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|