/** * @vitest-environment jsdom */ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types"; import type { MutableDaemonConfig } from "@server/shared/messages"; const { theme, snapshotState, configState, patchConfigMock, refreshMock } = vi.hoisted(() => ({ theme: { spacing: { 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24 }, iconSize: { sm: 14, md: 20 }, fontSize: { xs: 11, sm: 13, base: 15 }, fontWeight: { normal: "400" }, borderRadius: { lg: 8 }, opacity: { 50: 0.5 }, colors: { surface1: "#111", surface2: "#222", surface3: "#333", foreground: "#fff", foregroundMuted: "#aaa", border: "#555", accent: "#0a84ff", statusSuccess: "#00ff00", statusWarning: "#ff9500", statusDanger: "#ff0000", palette: { red: { 300: "#ff6b6b" }, white: "#fff" }, }, }, snapshotState: { entries: undefined as ProviderSnapshotEntry[] | undefined, isLoading: false, isRefreshing: false, }, configState: { config: null as MutableDaemonConfig | null, }, patchConfigMock: vi.fn(async () => undefined), refreshMock: vi.fn(async () => {}), })); vi.mock("react-native", () => ({ View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => React.createElement("div", { "data-testid": testID }, children), Text: ({ children }: { children?: React.ReactNode }) => React.createElement("span", null, children), Pressable: ({ children, onPress, onHoverIn, onHoverOut, accessibilityRole, accessibilityLabel, disabled, testID, }: { children?: | React.ReactNode | ((state: { pressed: boolean; hovered: boolean }) => React.ReactNode); onPress?: (event: React.MouseEvent) => void; onHoverIn?: () => void; onHoverOut?: () => void; accessibilityRole?: string; accessibilityLabel?: string; disabled?: boolean; testID?: string; }) => React.createElement( "div", { role: accessibilityRole, "aria-label": accessibilityLabel, "aria-disabled": disabled ? "true" : undefined, "data-testid": testID, onClick: disabled ? undefined : onPress, onMouseEnter: onHoverIn, onMouseLeave: onHoverOut, }, typeof children === "function" ? children({ pressed: false, hovered: false }) : children, ), ActivityIndicator: () => React.createElement("span", { "data-testid": "activity-indicator" }), })); vi.mock("react-native-unistyles", () => ({ StyleSheet: { create: (factory: unknown) => typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, }, useUnistyles: () => ({ theme }), })); vi.mock("lucide-react-native", () => { const icon = (name: string) => () => React.createElement("span", { "data-icon": name }); return { ChevronRight: icon("ChevronRight"), Plus: icon("Plus"), RotateCw: icon("RotateCw"), }; }); vi.mock("@/components/ui/switch", () => ({ Switch: ({ value, onValueChange, disabled, accessibilityLabel, testID, }: { value: boolean; onValueChange?: (next: boolean) => void; disabled?: boolean; accessibilityLabel?: string; testID?: string; }) => React.createElement("div", { role: "switch", "aria-checked": value ? "true" : "false", "aria-disabled": disabled ? "true" : undefined, "aria-label": accessibilityLabel, "data-testid": testID ?? "provider-switch", onClick: (event: React.MouseEvent) => { event.stopPropagation(); if (disabled) return; onValueChange?.(!value); }, }), })); vi.mock("@/components/ui/loading-spinner", () => ({ LoadingSpinner: () => React.createElement("span", { "data-testid": "loading-spinner" }), })); vi.mock("@/components/provider-icons", () => ({ getProviderIcon: (provider: string) => () => React.createElement("span", { "data-icon": `provider-${provider}` }), })); vi.mock("@/components/provider-diagnostic-sheet", () => ({ ProviderDiagnosticSheet: ({ provider }: { provider: string }) => React.createElement("div", { "data-testid": "provider-diagnostic-sheet", "data-provider": provider, }), })); vi.mock("@/components/add-provider-modal", () => ({ AddProviderModal: () => null, })); vi.mock("@/hooks/use-providers-snapshot", () => ({ useProvidersSnapshot: () => ({ entries: snapshotState.entries, isLoading: snapshotState.isLoading, isFetching: false, isRefreshing: snapshotState.isRefreshing, error: null, supportsSnapshot: true, refresh: refreshMock, refetchIfStale: vi.fn(), }), })); vi.mock("@/hooks/use-daemon-config", () => ({ useDaemonConfig: () => ({ config: configState.config, isLoading: false, patchConfig: patchConfigMock, }), })); vi.mock("@/runtime/host-runtime", () => ({ useHostRuntimeIsConnected: () => true, })); import { ProvidersSection } from "./providers-section"; const claudeEntry: ProviderSnapshotEntry = { provider: "claude", status: "ready", enabled: true, label: "Claude", description: "Claude Code", defaultModeId: null, modes: [], models: [ { provider: "claude", id: "claude-opus-4-7", label: "Claude Opus 4.7" }, { provider: "claude", id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, { provider: "claude", id: "claude-haiku-4-5", label: "Claude Haiku 4.5" }, ], }; const disabledCodexEntry: ProviderSnapshotEntry = { provider: "codex", status: "unavailable", enabled: false, label: "Codex", description: "OpenAI Codex", defaultModeId: null, modes: [], }; function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig { return { mcp: { injectIntoAgents: false }, providers, autoArchiveAfterMerge: false }; } function descendants(el: HTMLElement): HTMLElement[] { return Array.from(el.querySelectorAll("*")); } function indexOfMatches(nodes: HTMLElement[], selector: string): number { return nodes.findIndex((node) => node.matches(selector)); } function indexOfText(nodes: HTMLElement[], text: string): number { return nodes.findIndex((node) => node.textContent?.trim() === text); } describe("ProvidersSection", () => { let root: Root | null = null; let container: HTMLElement | null = null; beforeEach(() => { vi.stubGlobal("React", React); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); snapshotState.entries = undefined; snapshotState.isLoading = false; snapshotState.isRefreshing = false; configState.config = null; patchConfigMock.mockReset(); patchConfigMock.mockResolvedValue(undefined); refreshMock.mockReset(); refreshMock.mockResolvedValue(undefined); }); afterEach(() => { if (root) { act(() => { root?.unmount(); }); } root = null; container?.remove(); container = null; vi.unstubAllGlobals(); }); function render(): void { act(() => { root?.render(); }); } function findRow(accessibilityLabel: string): HTMLElement { const row = container?.querySelector( `[role="button"][aria-label="${accessibilityLabel}"]`, ); if (!row) throw new Error(`Expected row with aria-label "${accessibilityLabel}"`); return row; } it("renders the disabled provider with its server-provided label in snapshot order", () => { snapshotState.entries = [claudeEntry, disabledCodexEntry]; configState.config = makeConfig({ codex: { enabled: false } }); render(); const rows = Array.from( container?.querySelectorAll('[role="button"][aria-label$="provider details"]') ?? [], ); expect(rows.map((row) => row.getAttribute("aria-label"))).toEqual([ "Claude provider details", "Codex provider details", ]); const codexRow = findRow("Codex provider details"); const codexNodes = descendants(codexRow); expect(indexOfText(codexNodes, "Codex")).toBeGreaterThanOrEqual(0); expect(indexOfText(codexNodes, "codex")).toBe(-1); expect(indexOfText(codexNodes, "Disabled")).toBeGreaterThanOrEqual(0); }); it("composes the row as chevron, icon, label, status, model count, then switch", () => { snapshotState.entries = [claudeEntry]; configState.config = makeConfig(); render(); const row = findRow("Claude provider details"); const nodes = descendants(row); const chevron = indexOfMatches(nodes, '[data-icon="ChevronRight"]'); const icon = indexOfMatches(nodes, '[data-icon="provider-claude"]'); const label = indexOfText(nodes, "Claude"); const status = indexOfText(nodes, "Available"); const modelCount = indexOfText(nodes, "3 models"); const switchEl = indexOfMatches(nodes, '[role="switch"]'); expect(chevron).toBeGreaterThanOrEqual(0); expect(icon).toBeGreaterThan(chevron); expect(label).toBeGreaterThan(icon); expect(status).toBeGreaterThan(label); expect(modelCount).toBeGreaterThan(status); expect(switchEl).toBeGreaterThan(modelCount); }); it("opens the diagnostic sheet when the outer row is pressed for a disabled provider", () => { snapshotState.entries = [disabledCodexEntry]; configState.config = makeConfig({ codex: { enabled: false } }); render(); expect(container?.querySelector('[data-testid="provider-diagnostic-sheet"]')).toBeNull(); const row = findRow("Codex provider details"); act(() => { row.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); }); const sheet = container?.querySelector('[data-testid="provider-diagnostic-sheet"]'); expect(sheet).not.toBeNull(); expect(sheet?.getAttribute("data-provider")).toBe("codex"); }); it("toggles the provider enabled flag through patchConfig when the switch is pressed", async () => { snapshotState.entries = [claudeEntry]; configState.config = makeConfig(); render(); const row = findRow("Claude provider details"); const switchEl = row.querySelector('[role="switch"]'); expect(switchEl).not.toBeNull(); expect(switchEl?.getAttribute("aria-checked")).toBe("true"); await act(async () => { switchEl?.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); }); expect(patchConfigMock).toHaveBeenCalledTimes(1); expect(patchConfigMock).toHaveBeenCalledWith({ providers: { claude: { enabled: false } }, }); expect(refreshMock).not.toHaveBeenCalled(); expect(container?.querySelector('[data-testid="provider-diagnostic-sheet"]')).toBeNull(); }); it("forces a provider snapshot refresh from the settings refresh action", async () => { snapshotState.entries = [claudeEntry]; configState.config = makeConfig(); render(); const refreshButton = container?.querySelector( '[role="button"][aria-label="Refresh providers"]', ); expect(refreshButton).not.toBeNull(); await act(async () => { refreshButton?.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); }); expect(refreshMock).toHaveBeenCalledTimes(1); expect(patchConfigMock).not.toHaveBeenCalled(); }); });