Files
mission-control/tests/lib/queries.test.tsx
teernisse f5ce8a9091 feat(followup): implement PLAN-FOLLOWUP.md gap fixes
Complete implementation of 7 slices addressing E2E testing gaps:

Slice 0+1: Wire Actions + ReasonPrompt
- FocusView now uses useActions hook instead of direct act() calls
- Added pendingAction state pattern for skip/defer/complete actions
- ReasonPrompt integration with proper confirm/cancel flow
- Tags support in DecisionEntry interface

Slice 2: Drag Reorder UI
- Installed @dnd-kit (core, sortable, utilities)
- QueueView with DndContext, SortableContext, verticalListSortingStrategy
- SortableQueueItem wrapper component using useSortable hook
- pendingReorder state with ReasonPrompt for reorder reasons
- Cmd+Up/Down keyboard shortcuts for accessibility
- Fixed: Store item ID in PendingReorder to avoid stale queue reference

Slice 3: System Tray Integration
- tray.rs with TrayState, setup_tray, toggle_window_visibility
- Menu with Show/Quit items
- Left-click toggles window visibility
- update_tray_badge command updates tooltip with item count
- Frontend wiring in AppShell

Slice 4: E2E Test Updates
- Fixed test selectors for InboxView, Queue badge
- Exposed inbox store for test seeding

Slice 5: Staleness Visualization
- Already implemented in computeStaleness() with tests

Slice 6: Quick Wiring
- onStartBatch callback wired to QueueView
- SyncStatus rendered in nav area
- SettingsView renders Settings component

Slice 7: State Persistence
- settings-store with hydrate/update methods
- Tauri backend integration via read_settings/write_settings
- AppShell hydrates settings on mount

Bug fixes from code review:
- close_bead now has error isolation (try/catch) so decision logging
  and queue advancement continue even if bead close fails
- PendingReorder stores item ID to avoid stale queue reference

E2E tests for all ACs (tests/e2e/followup-acs.spec.ts):
- AC-F1: Drag reorder (4 tests)
- AC-F2: ReasonPrompt integration (7 tests)
- AC-F5: Staleness visualization (3 tests)
- AC-F6: Batch mode (2 tests)
- AC-F7: SyncStatus (2 tests)
- ReasonPrompt behavior (3 tests)

Tests: 388 frontend + 119 Rust + 32 E2E all passing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 17:28:28 -05:00

596 lines
14 KiB
TypeScript

/**
* Tests for TanStack Query data fetching layer.
*
* Tests query hooks for lore data, bridge status, and mutations
* for sync/reconcile operations.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode } from "react";
import {
useLoreStatus,
useBridgeStatus,
useSyncNow,
useReconcile,
createQueryClient,
} from "@/lib/queries";
import {
invoke,
resetMocks,
setMockResponse,
simulateEvent,
} from "../mocks/tauri-api";
import type { LoreStatus, BridgeStatus, SyncResult } from "@/lib/types";
// --- Test Fixtures ---
const mockLoreStatus: LoreStatus = {
last_sync: "2026-02-26T10:00:00Z",
is_healthy: true,
message: "Synced successfully",
summary: {
open_issues: 5,
authored_mrs: 3,
reviewing_mrs: 2,
},
};
const mockBridgeStatus: BridgeStatus = {
mapping_count: 42,
pending_count: 3,
suspect_count: 1,
last_sync: "2026-02-26T10:00:00Z",
last_reconciliation: "2026-02-25T08:00:00Z",
};
const mockSyncResult: SyncResult = {
created: 5,
skipped: 10,
closed: 2,
healed: 1,
errors: [],
};
// --- Test Wrapper ---
function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Infinity,
},
mutations: {
retry: false,
},
},
});
}
function createWrapper(queryClient: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
// --- QueryClient Setup Tests ---
describe("createQueryClient", () => {
it("creates a QueryClient with appropriate defaults", () => {
const client = createQueryClient();
expect(client).toBeInstanceOf(QueryClient);
const defaults = client.getDefaultOptions();
expect(defaults.queries?.retry).toBe(1);
expect(defaults.queries?.refetchOnWindowFocus).toBe(true);
});
});
// --- useLoreStatus Tests ---
describe("useLoreStatus", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = createTestQueryClient();
resetMocks();
});
afterEach(() => {
queryClient.clear();
});
it("fetches lore status successfully", async () => {
setMockResponse("get_lore_status", mockLoreStatus);
const { result } = renderHook(() => useLoreStatus(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockLoreStatus);
expect(invoke).toHaveBeenCalledWith("get_lore_status");
});
it("shows loading state initially", () => {
const { result } = renderHook(() => useLoreStatus(), {
wrapper: createWrapper(queryClient),
});
expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBeUndefined();
});
it("handles lore unavailable error", async () => {
const mockError = {
code: "LORE_UNAVAILABLE",
message: "lore CLI not found",
recoverable: true,
};
invoke.mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useLoreStatus(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
it("invalidates on lore-data-changed event", async () => {
setMockResponse("get_lore_status", mockLoreStatus);
const { result } = renderHook(() => useLoreStatus(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
// Initial fetch
expect(invoke).toHaveBeenCalledTimes(1);
// Simulate event
act(() => {
simulateEvent("lore-data-changed", undefined);
});
// Should refetch
await waitFor(() => {
expect(invoke).toHaveBeenCalledTimes(2);
});
});
it("uses staleTime of 30 seconds", async () => {
setMockResponse("get_lore_status", mockLoreStatus);
const { result: result1 } = renderHook(() => useLoreStatus(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(result1.current.isSuccess).toBe(true);
});
// Second hook should use cached data, not refetch
const { result: result2 } = renderHook(() => useLoreStatus(), {
wrapper: createWrapper(queryClient),
});
// Data should be immediately available (from cache)
expect(result2.current.data).toEqual(mockLoreStatus);
// Still only one invoke call
expect(invoke).toHaveBeenCalledTimes(1);
});
});
// --- useBridgeStatus Tests ---
describe("useBridgeStatus", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = createTestQueryClient();
resetMocks();
});
afterEach(() => {
queryClient.clear();
});
it("fetches bridge status successfully", async () => {
setMockResponse("get_bridge_status", mockBridgeStatus);
const { result } = renderHook(() => useBridgeStatus(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockBridgeStatus);
expect(invoke).toHaveBeenCalledWith("get_bridge_status");
});
it("shows loading state initially", () => {
const { result } = renderHook(() => useBridgeStatus(), {
wrapper: createWrapper(queryClient),
});
expect(result.current.isLoading).toBe(true);
});
it("handles bridge error", async () => {
const mockError = {
code: "BRIDGE_LOCKED",
message: "Bridge is locked by another process",
recoverable: true,
};
invoke.mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useBridgeStatus(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
it("invalidates on lore-data-changed event", async () => {
setMockResponse("get_bridge_status", mockBridgeStatus);
const { result } = renderHook(() => useBridgeStatus(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(invoke).toHaveBeenCalledTimes(1);
// Simulate event
act(() => {
simulateEvent("lore-data-changed", undefined);
});
await waitFor(() => {
expect(invoke).toHaveBeenCalledTimes(2);
});
});
});
// --- useSyncNow Mutation Tests ---
describe("useSyncNow", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = createTestQueryClient();
resetMocks();
});
afterEach(() => {
queryClient.clear();
});
it("triggers sync and returns result", async () => {
setMockResponse("sync_now", mockSyncResult);
const { result } = renderHook(() => useSyncNow(), {
wrapper: createWrapper(queryClient),
});
expect(result.current.isPending).toBe(false);
act(() => {
result.current.mutate();
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(invoke).toHaveBeenCalledWith("sync_now");
expect(result.current.data).toEqual(mockSyncResult);
});
it("invalidates lore and bridge queries on success", async () => {
setMockResponse("sync_now", mockSyncResult);
setMockResponse("get_lore_status", mockLoreStatus);
setMockResponse("get_bridge_status", mockBridgeStatus);
// First, set up some cached data
const { result: loreResult } = renderHook(() => useLoreStatus(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(loreResult.current.isSuccess).toBe(true);
});
const { result: bridgeResult } = renderHook(() => useBridgeStatus(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(bridgeResult.current.isSuccess).toBe(true);
});
// Clear call counts after initial fetches
invoke.mockClear();
// Now trigger sync
const { result: syncResult } = renderHook(() => useSyncNow(), {
wrapper: createWrapper(queryClient),
});
await act(async () => {
await syncResult.current.mutateAsync();
});
// Should have called sync_now plus refetched both status queries
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith("sync_now");
expect(invoke).toHaveBeenCalledWith("get_lore_status");
expect(invoke).toHaveBeenCalledWith("get_bridge_status");
});
});
it("handles sync failure", async () => {
const mockError = {
code: "BRIDGE_SYNC_FAILED",
message: "Sync failed",
recoverable: true,
};
invoke.mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useSyncNow(), {
wrapper: createWrapper(queryClient),
});
act(() => {
result.current.mutate();
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
});
// --- useReconcile Mutation Tests ---
describe("useReconcile", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = createTestQueryClient();
resetMocks();
});
afterEach(() => {
queryClient.clear();
});
it("triggers reconciliation and returns result", async () => {
const reconcileResult: SyncResult = {
...mockSyncResult,
closed: 5, // More closures in full reconcile
healed: 3,
};
setMockResponse("reconcile", reconcileResult);
const { result } = renderHook(() => useReconcile(), {
wrapper: createWrapper(queryClient),
});
act(() => {
result.current.mutate();
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(invoke).toHaveBeenCalledWith("reconcile");
expect(result.current.data).toEqual(reconcileResult);
});
it("invalidates queries on success", async () => {
setMockResponse("reconcile", mockSyncResult);
setMockResponse("get_lore_status", mockLoreStatus);
setMockResponse("get_bridge_status", mockBridgeStatus);
// Set up cached queries
const { result: loreResult } = renderHook(() => useLoreStatus(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(loreResult.current.isSuccess).toBe(true);
});
invoke.mockClear();
// Trigger reconcile
const { result } = renderHook(() => useReconcile(), {
wrapper: createWrapper(queryClient),
});
await act(async () => {
await result.current.mutateAsync();
});
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith("reconcile");
expect(invoke).toHaveBeenCalledWith("get_lore_status");
});
});
it("handles reconcile failure", async () => {
const mockError = {
code: "LORE_FETCH_FAILED",
message: "Failed to fetch from lore",
recoverable: true,
};
invoke.mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useReconcile(), {
wrapper: createWrapper(queryClient),
});
act(() => {
result.current.mutate();
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
});
// --- useLoreItems Tests ---
describe("useLoreItems", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = createTestQueryClient();
resetMocks();
});
afterEach(() => {
queryClient.clear();
});
it("fetches and transforms lore items successfully", async () => {
const mockItemsResponse = {
items: [
{
id: "mr_review:group::repo:200",
title: "Review this MR",
item_type: "mr_review",
project: "group/repo",
url: "https://gitlab.com/group/repo/-/merge_requests/200",
iid: 200,
updated_at: "2026-02-26T10:00:00Z",
requested_by: "alice",
},
{
id: "issue:group::repo:42",
title: "Fix the bug",
item_type: "issue",
project: "group/repo",
url: "https://gitlab.com/group/repo/-/issues/42",
iid: 42,
updated_at: "2026-02-26T09:00:00Z",
requested_by: null,
},
],
success: true,
error: null,
};
setMockResponse("get_lore_items", mockItemsResponse);
// Import dynamically to avoid circular dependency in test setup
const { useLoreItems } = await import("@/lib/queries");
const { result } = renderHook(() => useLoreItems(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toBeDefined();
expect(result.current.data?.length).toBe(2);
// Verify transformation to FocusItem format
const firstItem = result.current.data?.[0];
expect(firstItem?.id).toBe("mr_review:group::repo:200");
expect(firstItem?.title).toBe("Review this MR");
expect(firstItem?.type).toBe("mr_review");
expect(firstItem?.requestedBy).toBe("alice");
});
it("returns empty array when lore fetch fails", async () => {
const mockFailedResponse = {
items: [],
success: false,
error: "lore CLI not found",
};
setMockResponse("get_lore_items", mockFailedResponse);
const { useLoreItems } = await import("@/lib/queries");
const { result } = renderHook(() => useLoreItems(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
it("invalidates on lore-data-changed event", async () => {
const mockItemsResponse = {
items: [],
success: true,
error: null,
};
setMockResponse("get_lore_items", mockItemsResponse);
const { useLoreItems } = await import("@/lib/queries");
const { result } = renderHook(() => useLoreItems(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(invoke).toHaveBeenCalledTimes(1);
// Simulate event
act(() => {
simulateEvent("lore-data-changed", undefined);
});
await waitFor(() => {
expect(invoke).toHaveBeenCalledTimes(2);
});
});
});