diff --git a/packages/core/src/components/action-panel/ActionPanel.test.tsx b/packages/core/src/components/action-panel/ActionPanel.test.tsx index 83243d8a1..08f964c87 100644 --- a/packages/core/src/components/action-panel/ActionPanel.test.tsx +++ b/packages/core/src/components/action-panel/ActionPanel.test.tsx @@ -158,13 +158,13 @@ describe("ActionPanel", () => { }); }); -describe("ActionPanel context registration", () => { - const ActionsReader = ({ onActions }: { onActions: (a: unknown[]) => void }) => { - const actions = useCommandPaletteActions(); - onActions(actions); - return null; - }; +const ActionsReader = ({ onActions }: { onActions: (a: unknown[]) => void }) => { + const actions = useCommandPaletteActions(); + onActions(actions); + return null; +}; +describe("ActionPanel context registration", () => { it("registers actions to CommandPaletteContext on mount", () => { const onActions = vi.fn(); diff --git a/packages/core/src/components/ai-chat/ai-chat.test.tsx b/packages/core/src/components/ai-chat/ai-chat.test.tsx index 0ed5fcf11..0bfe8a9e8 100644 --- a/packages/core/src/components/ai-chat/ai-chat.test.tsx +++ b/packages/core/src/components/ai-chat/ai-chat.test.tsx @@ -12,6 +12,7 @@ afterEach(() => { const textbox = () => screen.getByRole("textbox", { name: "Message" }) as HTMLTextAreaElement; const sendButton = () => screen.getByRole("button", { name: "Send" }) as HTMLButtonElement; +type AIChatStatus = "ready" | "submitted" | "streaming" | "error"; // A tree whose transcript changes per render, with one child the root rejects. const strayChildTree = (token: string) => ( @@ -21,6 +22,24 @@ const strayChildTree = (token: string) => ( ); +const WrappedComposer = () => ; + +function renderComposer( + composerProps: Partial[0]> = {}, + status?: AIChatStatus, +) { + const onSubmit = vi.fn(); + const view = render( + + +
+ + + , + ); + return { ...view, onSubmit }; +} + describe("AIChat", () => { describe("snapshots", () => { it("default", () => { @@ -156,7 +175,6 @@ describe("AIChat", () => { it("names the direct-child requirement so a wrapped region is diagnosable", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const WrappedComposer = () => ; render( @@ -218,22 +236,6 @@ describe("AIChat", () => { }); describe("composer", () => { - function renderComposer( - composerProps: Partial[0]> = {}, - status?: "ready" | "submitted" | "streaming" | "error", - ) { - const onSubmit = vi.fn(); - const view = render( - - -
- - - , - ); - return { ...view, onSubmit }; - } - it("submits the trimmed message on Enter and clears the draft", async () => { const user = userEvent.setup(); const { onSubmit } = renderComposer(); diff --git a/packages/core/src/components/autocomplete/autocomplete.test.tsx b/packages/core/src/components/autocomplete/autocomplete.test.tsx index 63c0a3b66..ab58f3e32 100644 --- a/packages/core/src/components/autocomplete/autocomplete.test.tsx +++ b/packages/core/src/components/autocomplete/autocomplete.test.tsx @@ -9,6 +9,13 @@ afterEach(() => { const suggestions = ["Apple", "Banana", "Cherry", "Date", "Elderberry"]; +/** Advance timers and flush microtasks. */ +async function advanceAndFlush(ms: number) { + await act(async () => { + vi.advanceTimersByTime(ms); + }); +} + function SimpleAutocomplete(props: { onValueChange?: (value: string, event: unknown) => void; defaultValue?: string; @@ -206,13 +213,6 @@ describe("useAsync", () => { vi.useRealTimers(); }); - /** Advance timers and flush microtasks. */ - async function advanceAndFlush(ms: number) { - await act(async () => { - vi.advanceTimersByTime(ms); - }); - } - it("returns correct shape with mapped property names", () => { const fetcher = vi.fn(async () => []); const { result } = renderHook(() => useAsync({ fetcher })); diff --git a/packages/core/src/components/combobox/combobox.test.tsx b/packages/core/src/components/combobox/combobox.test.tsx index 2c8dbd676..159944d38 100644 --- a/packages/core/src/components/combobox/combobox.test.tsx +++ b/packages/core/src/components/combobox/combobox.test.tsx @@ -9,6 +9,13 @@ afterEach(() => { const fruits = ["Apple", "Banana", "Cherry", "Date", "Elderberry"]; +/** Advance timers and flush microtasks. */ +async function advanceAndFlush(ms: number) { + await act(async () => { + vi.advanceTimersByTime(ms); + }); +} + function SimpleCombobox(props: { onValueChange?: (value: string | null, event: unknown) => void; defaultValue?: string; @@ -1341,13 +1348,6 @@ describe("useAsync", () => { vi.useRealTimers(); }); - /** Advance timers and flush microtasks. */ - async function advanceAndFlush(ms: number) { - await act(async () => { - vi.advanceTimersByTime(ms); - }); - } - it("returns correct shape", () => { const fetcher = vi.fn(async () => []); const { result } = renderHook(() => useAsync({ fetcher })); diff --git a/packages/core/src/components/command-palette/command-palette-hook.test.tsx b/packages/core/src/components/command-palette/command-palette-hook.test.tsx index 337ccc320..549b1d00f 100644 --- a/packages/core/src/components/command-palette/command-palette-hook.test.tsx +++ b/packages/core/src/components/command-palette/command-palette-hook.test.tsx @@ -45,6 +45,19 @@ vi.mock("react-router", async (importOriginal) => { // Wrapper for hooks that need router context const wrapper = ({ children }: { children: ReactNode }) => {children}; +const createMixedSources = (): Array => [ + { + prefix: "ORD", + title: "Orders", + search: vi.fn().mockResolvedValue([]), + }, + { + prefix: "CU", + title: "Customers", + search: vi.fn().mockResolvedValue([]), + }, +]; + describe("useCommandPalette", () => { const renderCommandPaletteHook = (routes = createTestRoutes()) => { return renderHook( @@ -916,19 +929,6 @@ describe("useCommandPalette with actions, routes, and searchSources combined", ( }, ]; - const createMixedSources = (): Array => [ - { - prefix: "ORD", - title: "Orders", - search: vi.fn().mockResolvedValue([]), - }, - { - prefix: "CU", - title: "Customers", - search: vi.fn().mockResolvedValue([]), - }, - ]; - const renderMixed = ( routes = createTestRoutes2(), contextualActions = createMixedActions(), diff --git a/packages/core/src/components/data-table/data-table.test.tsx b/packages/core/src/components/data-table/data-table.test.tsx index e9b235f71..8676fd268 100644 --- a/packages/core/src/components/data-table/data-table.test.tsx +++ b/packages/core/src/components/data-table/data-table.test.tsx @@ -1,5 +1,13 @@ import { afterEach, describe, it, expect, expectTypeOf, vi } from "vitest"; -import { act, cleanup, render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { + act, + cleanup, + render, + renderHook, + screen, + fireEvent, + waitFor, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router"; import { StrictMode, type ReactNode } from "react"; @@ -96,6 +104,32 @@ function TestDataTable(props: { ); } +function SortableHeaderHarness({ control }: { control: CollectionControl }) { + const table = useDataTable({ + columns: [ + { + label: "Name", + sort: { field: "name", type: "string" }, + header: (ctx) => + ctx.sortable ? ( + + ) : null, + render: (row) => row.name, + }, + ], + data: testData, + control, + }); + + return ( + + + + ); +} + const wrapper = createAppShellWrapper("en"); function createTimeZoneWrapper(timeZone: string) { @@ -167,6 +201,13 @@ const strictWrapper = ({ children }: { children: ReactNode }) => { ); }; +// Opens the kebab menu of the row at `index` and returns its "Activate" item. +async function activateItemFor(index: number) { + const user = userEvent.setup(); + await user.click(screen.getAllByRole("button", { name: "Row actions" })[index]); + return screen.findByRole("menuitem", { name: "Activate" }); +} + describe("DataTable", () => { it("renders a basic data table with headers and rows", () => { render(, { wrapper }); @@ -836,33 +877,7 @@ describe("DataTable", () => { sortStates: [{ field: "name", direction: "Asc" }], }); - function Harness() { - const table = useDataTable({ - columns: [ - { - label: "Name", - sort: { field: "name", type: "string" }, - header: (ctx) => - ctx.sortable ? ( - - ) : null, - render: (row) => row.name, - }, - ], - data: testData, - control, - }); - - return ( - - - - ); - } - - render(, { wrapper }); + render(, { wrapper }); fireEvent.click(screen.getByRole("button", { name: "Name Asc" })); @@ -1903,59 +1918,43 @@ describe("DataTable", () => { // Every built-in affordance fires exactly one toggle per commit, so this // is only reachable through the public API. const onSelectionChange = vi.fn(); - let api!: UseDataTableReturn; - function Harness() { - const table = useDataTable({ + const { result } = renderHook(() => + useDataTable({ columns: testColumns, data: testData, onSelectionChange, - }); - api = table; - return ( - - - - ); - } - render(, { wrapper }); + }), + ); act(() => { - api.toggleRowSelection?.(testData.rows[0]); - api.toggleRowSelection?.(testData.rows[1]); + result.current.toggleRowSelection?.(testData.rows[0]); + result.current.toggleRowSelection?.(testData.rows[1]); }); - expect(api.selectedIds).toEqual(["1", "2"]); + expect(result.current.selectedIds).toEqual(["1", "2"]); expect(onSelectionChange).toHaveBeenLastCalledWith(["1", "2"]); }); it("applies clearSelection before a toggle in the same commit", () => { const onSelectionChange = vi.fn(); - let api!: UseDataTableReturn; - function Harness() { - const table = useDataTable({ + const { result } = renderHook(() => + useDataTable({ columns: testColumns, data: testData, onSelectionChange, - }); - api = table; - return ( - - - - ); - } - render(, { wrapper }); + }), + ); - act(() => api.selectAllRows?.()); - expect(api.selectedIds).toEqual(["1", "2"]); + act(() => result.current.selectAllRows?.()); + expect(result.current.selectedIds).toEqual(["1", "2"]); // "replace the selection with just this row" act(() => { - api.clearSelection?.(); - api.toggleRowSelection?.(testData.rows[1]); + result.current.clearSelection?.(); + result.current.toggleRowSelection?.(testData.rows[1]); }); - expect(api.selectedIds).toEqual(["2"]); + expect(result.current.selectedIds).toEqual(["2"]); expect(onSelectionChange).toHaveBeenLastCalledWith(["2"]); }); @@ -2016,13 +2015,6 @@ describe("DataTable", () => { ); } - // Opens the kebab menu of the row at `index` and returns its "Activate" item. - async function activateItemFor(index: number) { - const user = userEvent.setup(); - await user.click(screen.getAllByRole("button", { name: "Row actions" })[index]); - return screen.findByRole("menuitem", { name: "Activate" }); - } - it("disables an action for rows where canApply returns false", async () => { const onClick = vi.fn(); render( @@ -2735,59 +2727,43 @@ describe("DataTable", () => { it("composes several expansion toggles dispatched in one commit", () => { const onChange = vi.fn(); - let api!: UseDataTableReturn; - function Harness() { - const table = useDataTable({ + const { result } = renderHook(() => + useDataTable({ columns: testColumns, data: testData, rowExpansion: { render: detail, onChange }, - }); - api = table; - return ( - - - - ); - } - render(, { wrapper }); + }), + ); act(() => { - api.toggleRowExpansion?.(testData.rows[0]); - api.toggleRowExpansion?.(testData.rows[1]); + result.current.toggleRowExpansion?.(testData.rows[0]); + result.current.toggleRowExpansion?.(testData.rows[1]); }); - expect(api.expandedIds).toEqual(["1", "2"]); + expect(result.current.expandedIds).toEqual(["1", "2"]); expect(onChange).toHaveBeenLastCalledWith(["1", "2"]); }); it("applies collapseAllRows before a toggle in the same commit", () => { - let api!: UseDataTableReturn; - function Harness() { - const table = useDataTable({ + const { result } = renderHook(() => + useDataTable({ columns: testColumns, data: testData, rowExpansion: { render: detail }, - }); - api = table; - return ( - - - - ); - } - render(, { wrapper }); + }), + ); act(() => { - api.toggleRowExpansion?.(testData.rows[0]); - api.toggleRowExpansion?.(testData.rows[1]); + result.current.toggleRowExpansion?.(testData.rows[0]); + result.current.toggleRowExpansion?.(testData.rows[1]); }); act(() => { - api.collapseAllRows?.(); - api.toggleRowExpansion?.(testData.rows[1]); + result.current.collapseAllRows?.(); + result.current.toggleRowExpansion?.(testData.rows[1]); }); - expect(api.expandedIds).toEqual(["2"]); + expect(result.current.expandedIds).toEqual(["2"]); }); it("keeps toggleRowExpansion stable across an expansion", () => { diff --git a/packages/core/src/components/date-field/date-field.test.tsx b/packages/core/src/components/date-field/date-field.test.tsx index dbc87c2c2..6f2c3b327 100644 --- a/packages/core/src/components/date-field/date-field.test.tsx +++ b/packages/core/src/components/date-field/date-field.test.tsx @@ -43,6 +43,29 @@ function getEnabledCalendarCells() { ); } +function ControlledField({ onChange }: { onChange: (v: unknown) => void }) { + const [v, setV] = useState(null); + return ( + <> + { + setV(nv as CalendarDate | null); + onChange(nv); + }} + /> + + + ); +} + +async function lastEmit(onChange: ReturnType, expected: string) { + await waitFor(() => { + expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe(expected); + }); +} + // ─── Snapshots ────────────────────────────────────────────────────────────── // Visual-structure snapshots per the add-component convention. Inputs are // pinned (fixed `defaultValue`, no live "today" in view) so output is stable. @@ -297,23 +320,6 @@ describe("DateField", () => { // A controlled field round-trips every emit through the parent's `value`, so // these guard the clamp against external-sync interference (the uncontrolled // cases above don't exercise that path). - function ControlledField({ onChange }: { onChange: (v: unknown) => void }) { - const [v, setV] = useState(null); - return ( - <> - { - setV(nv as CalendarDate | null); - onChange(nv); - }} - /> - - - ); - } - it("clamps an impossible day on blur even when controlled (29/02/2026)", async () => { const user = userEvent.setup(); const onChange = vi.fn(); @@ -493,12 +499,6 @@ describe("DateField", () => { // derive from that same basis. Locale here is "en" (weeks start Sunday). describe("DateField keyboard shortcuts", () => { - async function lastEmit(onChange: ReturnType, expected: string) { - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe(expected); - }); - } - it("'t' jumps to today (case-insensitive)", async () => { const user = userEvent.setup(); const onChange = vi.fn(); @@ -1098,6 +1098,23 @@ describe("DatePicker", () => { // ─── Keyboard: popover focus ───────────────────────────────────────────────── +function ReopenControlledDatePicker() { + const [value, setValue] = useState(new CalendarDate(2025, 6, 15)); + return ( + <> + + + + + ); +} + +const calendarTrigger = () => screen.getByRole("button", { name: "Open calendar" }); + describe("DatePicker keyboard", () => { it("moves focus into the calendar grid when the popover opens", async () => { const user = userEvent.setup(); @@ -1142,22 +1159,8 @@ describe("DatePicker keyboard", () => { it("re-lands the calendar on the current value each time it opens", async () => { const user = userEvent.setup(); - function Controlled() { - const [value, setValue] = useState(new CalendarDate(2025, 6, 15)); - return ( - <> - - - - - ); - } - render(); - const trigger = () => screen.getByRole("button", { name: "Open calendar" }); + render(); + const trigger = calendarTrigger; // Page away, dismiss, change the value externally — reopening follows the value. await user.click(trigger()); @@ -1362,11 +1365,12 @@ describe("RHF integration", () => { }); }); +const segText = (name: string) => + screen.getByRole("spinbutton", { name }).getAttribute("aria-valuetext"); + describe("12-hour time display", () => { const TZ = "Asia/Tokyo"; const zoned = (iso: string) => parseZonedDateTime(`${iso}[${TZ}]`); - const segText = (name: string) => - screen.getByRole("spinbutton", { name }).getAttribute("aria-valuetext"); it("shows a PM value as 06 PM, not 18 AM", () => { render( diff --git a/packages/core/src/components/date-field/date-range-picker.test.tsx b/packages/core/src/components/date-field/date-range-picker.test.tsx index c499456d9..52ec70259 100644 --- a/packages/core/src/components/date-field/date-range-picker.test.tsx +++ b/packages/core/src/components/date-field/date-range-picker.test.tsx @@ -41,6 +41,7 @@ async function typeDate(user: ReturnType, field: Field, const focusedLabel = () => document.activeElement?.getAttribute("aria-label") ?? ""; const normalizeIds = (html: string) => html.replace(/id="base-ui-[^"]+"/g, 'id="base-ui-ID"'); +const gridCell = (labelPart: RegExp) => screen.getByRole("button", { name: labelPart }); // ─── Snapshot ───────────────────────────────────────────────────────────────── @@ -413,7 +414,6 @@ describe("DateRangePicker form validation", () => { describe("DateRangePicker popover", () => { const defaultRange = { start: d("2025-03-05"), end: d("2025-03-08") }; - const gridCell = (labelPart: RegExp) => screen.getByRole("button", { name: labelPart }); it("stays open after the first pick and closes when the range completes", async () => { const user = userEvent.setup(); diff --git a/packages/core/src/components/sidebar/sidebar.test.tsx b/packages/core/src/components/sidebar/sidebar.test.tsx index cf0bfce34..aca91aef7 100644 --- a/packages/core/src/components/sidebar/sidebar.test.tsx +++ b/packages/core/src/components/sidebar/sidebar.test.tsx @@ -30,6 +30,21 @@ afterEach(() => { vi.unstubAllGlobals(); }); +const mockMobileViewport = () => { + vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ + matches: query.includes("max-width"), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + + vi.stubGlobal("innerWidth", 375); +}; + /** * Force a desktop viewport (not the env's default tablet width) so groups render * their inline collapsible submenu rather than the icon-rail hover flyout. @@ -424,21 +439,6 @@ describe("Mobile sidebar", () => { vi.restoreAllMocks(); }); - const mockMobileViewport = () => { - vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ - matches: query.includes("max-width"), - media: query, - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - dispatchEvent: vi.fn(), - })); - - vi.stubGlobal("innerWidth", 375); - }; - const renderMobileSidebar = (sidebarContent: React.ReactNode, initialPath = "/") => { mockMobileViewport(); diff --git a/packages/core/src/components/tooltip/tooltip.test.tsx b/packages/core/src/components/tooltip/tooltip.test.tsx index a0092e280..a79e10b6e 100644 --- a/packages/core/src/components/tooltip/tooltip.test.tsx +++ b/packages/core/src/components/tooltip/tooltip.test.tsx @@ -109,7 +109,7 @@ describe("Tooltip", () => { render( - } data-testid="trigger"> + } data-testid="trigger"> Focus me Tooltip text diff --git a/packages/core/src/components/with-guard/with-guard.test.tsx b/packages/core/src/components/with-guard/with-guard.test.tsx index 5f1159e3d..09793cd4f 100644 --- a/packages/core/src/components/with-guard/with-guard.test.tsx +++ b/packages/core/src/components/with-guard/with-guard.test.tsx @@ -26,6 +26,20 @@ const testConfig: RootConfiguration = { locale: "en", errorBoundary: , }; +const alwaysPass: Guard = () => pass(); +const alwaysHidden: Guard = () => hidden(); +const guard1: Guard = () => pass(); +const guard2: Guard = () => hidden(); +const guard3: Guard = () => pass(); +const asyncPass: Guard = async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return pass(); +}; +const asyncHidden: Guard = async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return hidden(); +}; +const redirectGuard: Guard = () => redirectTo("/login"); /** * Wrapper to render WithGuard with required providers. @@ -41,8 +55,6 @@ const renderWithProviders = (ui: React.ReactNode, contextData: TestContextData) describe("WithGuard", () => { describe("synchronous guards", () => { it("renders children when guard passes", async () => { - const alwaysPass: Guard = () => pass(); - renderWithProviders(
Protected Content
@@ -56,8 +68,6 @@ describe("WithGuard", () => { }); it("renders fallback when guard returns hidden", async () => { - const alwaysHidden: Guard = () => hidden(); - renderWithProviders( Access Denied
}>
Protected Content
@@ -72,8 +82,6 @@ describe("WithGuard", () => { }); it("renders nothing when guard returns hidden and no fallback provided", async () => { - const alwaysHidden: Guard = () => hidden(); - const { container } = renderWithProviders(
Protected Content
@@ -121,10 +129,6 @@ describe("WithGuard", () => { }); it("stops on first non-pass guard", async () => { - const guard1: Guard = () => pass(); - const guard2: Guard = () => hidden(); - const guard3: Guard = () => pass(); - renderWithProviders( Blocked
}>
Protected Content
@@ -141,11 +145,6 @@ describe("WithGuard", () => { describe("asynchronous guards", () => { it("renders children when async guard passes", async () => { - const asyncPass: Guard = async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - return pass(); - }; - renderWithProviders(
Async Protected
@@ -159,11 +158,6 @@ describe("WithGuard", () => { }); it("renders fallback when async guard returns hidden", async () => { - const asyncHidden: Guard = async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - return hidden(); - }; - renderWithProviders( Async Denied}>
Async Protected
@@ -244,8 +238,6 @@ describe("WithGuard", () => { describe("redirectTo handling", () => { it("treats redirectTo as hidden (renders fallback)", async () => { - const redirectGuard: Guard = () => redirectTo("/login"); - renderWithProviders( Redirecting...}>
Protected
diff --git a/packages/core/src/contexts/auth-context.test.tsx b/packages/core/src/contexts/auth-context.test.tsx index 7a10eaf26..e5d5e5d89 100644 --- a/packages/core/src/contexts/auth-context.test.tsx +++ b/packages/core/src/contexts/auth-context.test.tsx @@ -31,62 +31,84 @@ afterEach(() => { const LoadingGuard = () =>
Loading...
; const LoginGuard = () =>
Please log in
; -describe("AuthProvider", () => { - // Create a mock auth client with a stable state object - // useSyncExternalStore requires getSnapshot to return the same reference if the state hasn't changed - const createMockAuthClient = ( - initialState?: { - isAuthenticated: boolean; - error: string | null; - isReady: boolean; - }, - overrides?: Partial, - ): EnhancedAuthClient => { - // Use a stable reference for the state object - const state = initialState ?? { - isAuthenticated: false, - error: null, - isReady: false, - }; +// Create a mock auth client with a stable state object. useSyncExternalStore +// requires getSnapshot to return the same reference if state hasn't changed. +const createMockAuthClient = ( + initialState?: { + isAuthenticated: boolean; + error: string | null; + isReady: boolean; + }, + overrides?: Partial, +): EnhancedAuthClient => { + const state = initialState ?? { + isAuthenticated: false, + error: null, + isReady: false, + }; - const baseHandleCallback = overrides?.handleCallback ?? vi.fn(); - let handleCallbackInFlight: Promise | null = null; - const handleCallback = vi.fn(() => { - if (handleCallbackInFlight) { - return handleCallbackInFlight; - } + const baseHandleCallback = overrides?.handleCallback ?? vi.fn(); + let handleCallbackInFlight: Promise | null = null; + const handleCallback = vi.fn(() => { + if (handleCallbackInFlight) return handleCallbackInFlight; - const callbackPromise = Promise.resolve(baseHandleCallback()).finally(() => { - handleCallbackInFlight = null; - }); - handleCallbackInFlight = callbackPromise; - return callbackPromise; + const callbackPromise = Promise.resolve(baseHandleCallback()).finally(() => { + handleCallbackInFlight = null; }); + handleCallbackInFlight = callbackPromise; + return callbackPromise; + }); - const { handleCallback: _ignoredHandleCallback, ...otherOverrides } = overrides ?? {}; + const { handleCallback: _ignoredHandleCallback, ...otherOverrides } = overrides ?? {}; - return { - getState: vi.fn(() => state), - login: vi.fn(), - logout: vi.fn(), - getAuthUrl: vi.fn(), - checkAuthStatus: vi.fn().mockResolvedValue({ - isAuthenticated: false, - error: null, - isReady: true, - }), - refreshTokens: vi.fn(), - ready: vi.fn(() => Promise.resolve()), - configure: vi.fn(), - addEventListener: vi.fn(() => () => {}), - getAuthHeaders: vi.fn(), - fetch: vi.fn(), - getAppUri: vi.fn(() => "https://api.test.com"), - ...otherOverrides, - handleCallback, - } as EnhancedAuthClient; - }; + return { + getState: vi.fn(() => state), + login: vi.fn(), + logout: vi.fn(), + getAuthUrl: vi.fn(), + checkAuthStatus: vi.fn().mockResolvedValue({ + isAuthenticated: false, + error: null, + isReady: true, + }), + refreshTokens: vi.fn(), + ready: vi.fn(() => Promise.resolve()), + configure: vi.fn(), + addEventListener: vi.fn(() => () => {}), + getAuthHeaders: vi.fn(), + fetch: vi.fn(), + getAppUri: vi.fn(() => "https://api.test.com"), + ...otherOverrides, + handleCallback, + } as EnhancedAuthClient; +}; + +const AuthSuspenseContent = () => { + const { isAuthenticated } = useAuthSuspense(); + return
Content Loaded: {isAuthenticated ? "authenticated" : "not authenticated"}
; +}; + +const makeBaseClient = (mockHandleCallback: ReturnType) => ({ + handleCallback: mockHandleCallback, + getState: vi.fn(() => ({ + isAuthenticated: false, + error: null, + isReady: false, + })), + login: vi.fn(), + logout: vi.fn(), + getAuthUrl: vi.fn(), + checkAuthStatus: vi.fn(), + refreshTokens: vi.fn(), + ready: vi.fn(() => Promise.resolve()), + configure: vi.fn(), + addEventListener: vi.fn(() => () => {}), + getAuthHeaders: vi.fn(), + fetch: vi.fn(), + getAuthHeadersForQuery: vi.fn(), +}); +describe("AuthProvider", () => { describe("useEnsureAuthInitialized", () => { it("should initialize auth status on mount", async () => { const state = { @@ -554,32 +576,16 @@ describe("AuthProvider", () => { ready: mockReady, }); - let suspenseTriggered = false; - - const TestComponent = () => { - try { - useAuthSuspense(); - return
Content Loaded
; - } catch (error) { - if (error instanceof Promise) { - suspenseTriggered = true; - throw error; - } - throw error; - } - }; - render( Loading...}> - + , ); - // Initially should show loading + // Initially should show loading, which proves the hook suspended. expect(screen.getByText("Loading...")).toBeDefined(); - expect(suspenseTriggered).toBe(true); }); it("should resolve suspense when auth becomes ready", async () => { @@ -608,15 +614,10 @@ describe("AuthProvider", () => { ready: mockReady, }); - const TestComponent = () => { - const { isAuthenticated } = useAuthSuspense(); - return
Content Loaded: {isAuthenticated ? "authenticated" : "not authenticated"}
; - }; - render( Loading...}> - + , ); @@ -652,22 +653,17 @@ describe("AuthProvider", () => { }; const mockClient = createMockAuthClient(state); - const TestComponent = () => { - const { isAuthenticated } = useAuthSuspense(); - return
Loaded: {isAuthenticated ? "authenticated" : "unauthenticated"}
; - }; - render( Suspense Loading...}> - + , ); // Should show unauthenticated state immediately (not suspended) await waitFor(() => { - expect(screen.getByText("Loaded: unauthenticated")).toBeDefined(); + expect(screen.getByText("Content Loaded: not authenticated")).toBeDefined(); }); }); }); @@ -1012,26 +1008,6 @@ describe("AuthProvider", () => { }); describe("createAuthClient", () => { - const makeBaseClient = (mockHandleCallback: ReturnType) => ({ - handleCallback: mockHandleCallback, - getState: vi.fn(() => ({ - isAuthenticated: false, - error: null, - isReady: false, - })), - login: vi.fn(), - logout: vi.fn(), - getAuthUrl: vi.fn(), - checkAuthStatus: vi.fn(), - refreshTokens: vi.fn(), - ready: vi.fn(() => Promise.resolve()), - configure: vi.fn(), - addEventListener: vi.fn(() => () => {}), - getAuthHeaders: vi.fn(), - fetch: vi.fn(), - getAuthHeadersForQuery: vi.fn(), - }); - it("calls handleCallback immediately when URL contains OAuth callback parameters", () => { window.history.replaceState({}, "", "/?code=auth-code-123"); diff --git a/packages/core/src/contexts/scroll-container-context.test.tsx b/packages/core/src/contexts/scroll-container-context.test.tsx index d70920c33..5c81fdf2e 100644 --- a/packages/core/src/contexts/scroll-container-context.test.tsx +++ b/packages/core/src/contexts/scroll-container-context.test.tsx @@ -1,6 +1,6 @@ -import { render, cleanup, waitFor } from "@testing-library/react"; +import { renderHook, cleanup, waitFor } from "@testing-library/react"; import { describe, it, expect, afterEach, vi } from "vitest"; -import { useEffect, type RefObject } from "react"; +import { useEffect, type ReactNode } from "react"; import { SidebarLayout } from "@/components/sidebar"; import { AppShell } from "@/components/appshell"; import { defineModule } from "@/resource"; @@ -13,63 +13,61 @@ afterEach(() => { window.history.replaceState({}, "", "/"); }); -// Captures the ref returned by the hook so assertions can inspect its `current` -// after the layout has mounted (refs populate during commit, before effects). -let captured: RefObject | null = null; - -function Probe() { - const ref = useAppShellScrollContainer(); - captured = ref; - return
probe
; -} - -const renderInsideLayout = () => { - captured = null; +const insideLayoutWrapper = () => { window.history.pushState({}, "", "/dashboard"); - return render( - }, - component: () => , - resources: [], - }), - ]} - > - - , - ); + return function InsideLayoutWrapper({ children }: { children: ReactNode }) { + return ( + }, + component: () => <>{children}, + resources: [], + }), + ]} + > + + + ); + }; }; describe("useAppShellScrollContainer", () => { it("marks the content scroll region with data-appshell-scroll-container", async () => { - renderInsideLayout(); + renderHook(() => useAppShellScrollContainer(), { + wrapper: insideLayoutWrapper(), + }); + await waitFor(() => { expect(document.querySelector("[data-appshell-scroll-container]")).not.toBeNull(); }); }); it("resolves to the content scroll region element for pages inside the layout", async () => { - renderInsideLayout(); + const { result } = renderHook(() => useAppShellScrollContainer(), { + wrapper: insideLayoutWrapper(), + }); + // Assert inside waitFor: StrictMode's mount→unmount→remount detaches and // reattaches the ref, so `current` is briefly null right after first paint. await waitFor(() => { const el = document.querySelector("[data-appshell-scroll-container]"); expect(el).not.toBeNull(); - expect(captured?.current).toBe(el); + expect(result.current.current).toBe(el); }); }); it("exposes an element that dispatches scroll events to listeners", async () => { - renderInsideLayout(); + renderHook(() => useAppShellScrollContainer(), { + wrapper: insideLayoutWrapper(), + }); + await waitFor(() => expect(document.querySelector("[data-appshell-scroll-container]")).not.toBeNull(), ); - // Read the element from the DOM (stable, in-document) rather than through - // the ref, whose `current` toggles during the StrictMode remount. const el = document.querySelector("[data-appshell-scroll-container]")!; const onScroll = vi.fn(); el.addEventListener("scroll", onScroll); @@ -78,13 +76,10 @@ describe("useAppShellScrollContainer", () => { el.removeEventListener("scroll", onScroll); }); - it("returns a ref whose current is null when used outside a SidebarLayout", async () => { - captured = null; - render(); - await waitFor(() => expect(document.querySelector('[data-testid="probe"]')).not.toBeNull()); + it("returns a ref whose current is null when used outside a SidebarLayout", () => { + const { result } = renderHook(() => useAppShellScrollContainer()); - expect(captured).not.toBeNull(); - expect(captured!.current).toBeNull(); + expect(result.current.current).toBeNull(); }); }); @@ -94,42 +89,23 @@ describe("useAppShellScrollContainer", () => { describe("useAppShellScrollContainer (consumer subscription pattern)", () => { it("lets a page attach a scroll listener via an effect", async () => { const onScroll = vi.fn(); - captured = null; - window.history.pushState({}, "", "/dashboard"); - - function Subscriber() { - const scrollRef = useAppShellScrollContainer(); - useEffect(() => { - const el = scrollRef.current; - if (!el) return; - el.addEventListener("scroll", onScroll, { passive: true }); - return () => el.removeEventListener("scroll", onScroll); - }, [scrollRef]); - return
sub
; - } - - render( - }, - component: () => , - resources: [], - }), - ]} - > - - , + const { result } = renderHook( + () => { + const scrollRef = useAppShellScrollContainer(); + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + el.addEventListener("scroll", onScroll, { passive: true }); + return () => el.removeEventListener("scroll", onScroll); + }, [scrollRef]); + return scrollRef; + }, + { wrapper: insideLayoutWrapper() }, ); - await waitFor(() => - expect(document.querySelector('[data-testid="subscriber"]')).not.toBeNull(), - ); + await waitFor(() => expect(result.current.current).not.toBeNull()); - const el = document.querySelector("[data-appshell-scroll-container]")!; - el.dispatchEvent(new Event("scroll")); + result.current.current!.dispatchEvent(new Event("scroll")); expect(onScroll).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/core/src/contexts/theme-context.test.tsx b/packages/core/src/contexts/theme-context.test.tsx index 2067106d7..2052a3993 100644 --- a/packages/core/src/contexts/theme-context.test.tsx +++ b/packages/core/src/contexts/theme-context.test.tsx @@ -1,7 +1,8 @@ -import { cleanup, render, act, waitFor } from "@testing-library/react"; +import { cleanup, renderHook, act, waitFor } from "@testing-library/react"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ReactNode } from "react"; -import { ThemeProvider, useTheme } from "./theme-context"; +import { ThemeProvider, useTheme, type ColorTheme } from "./theme-context"; /** happy-dom / Node can omit a full `localStorage`; ThemeProvider persists via it. */ function installLocalStorageStub() { @@ -52,6 +53,10 @@ function installMatchMediaStub(matches: boolean) { }); } +const wrapper = + (props?: { defaultColorTheme?: ColorTheme }) => + ({ children }: { children: ReactNode }) => {children}; + let storageMap: Map; beforeAll(() => { @@ -69,52 +74,36 @@ afterEach(() => { cleanup(); }); -function Probe() { - const { theme, resolvedTheme } = useTheme(); - return ( -
- {theme} - {resolvedTheme} -
- ); -} - describe("ThemeProvider — storage validation", () => { it("falls back to defaultColorTheme for an unrecognized stored color theme", () => { storageMap.set("appshell-ui-theme", "totally-not-a-mode"); - const { getByTestId } = render( - - - , - ); + const { result } = renderHook(() => useTheme(), { + wrapper: wrapper({ defaultColorTheme: "dark" }), + }); - expect(getByTestId("color-theme").textContent).toBe("dark"); + expect(result.current.theme).toBe("dark"); }); it("useTheme returns color theme values", () => { storageMap.set("appshell-ui-theme", "dark"); - const { getByTestId } = render( - - - , - ); + const { result } = renderHook(() => useTheme(), { + wrapper: wrapper(), + }); - expect(getByTestId("color-theme").textContent).toBe("dark"); - expect(getByTestId("resolvedTheme").textContent).toBe("dark"); + expect(result.current.theme).toBe("dark"); + expect(result.current.resolvedTheme).toBe("dark"); }); it("reads a valid stored color theme and applies it as the html class", async () => { storageMap.set("appshell-ui-theme", "dark"); - const { getByTestId } = render( - - - , - ); + const { result } = renderHook(() => useTheme(), { + wrapper: wrapper(), + }); - expect(getByTestId("color-theme").textContent).toBe("dark"); + expect(result.current.theme).toBe("dark"); await waitFor(() => { expect(document.documentElement.classList.contains("dark")).toBe(true); }); @@ -126,14 +115,12 @@ describe("ThemeProvider — system color theme resolution", () => { installMatchMediaStub(true); storageMap.set("appshell-ui-theme", "system"); - const { getByTestId } = render( - - - , - ); + const { result } = renderHook(() => useTheme(), { + wrapper: wrapper(), + }); - expect(getByTestId("color-theme").textContent).toBe("system"); - expect(getByTestId("resolvedTheme").textContent).toBe("dark"); + expect(result.current.theme).toBe("system"); + expect(result.current.resolvedTheme).toBe("dark"); await waitFor(() => { expect(document.documentElement.classList.contains("dark")).toBe(true); }); @@ -143,13 +130,11 @@ describe("ThemeProvider — system color theme resolution", () => { installMatchMediaStub(false); storageMap.set("appshell-ui-theme", "system"); - const { getByTestId } = render( - - - , - ); + const { result } = renderHook(() => useTheme(), { + wrapper: wrapper(), + }); - expect(getByTestId("resolvedTheme").textContent).toBe("light"); + expect(result.current.resolvedTheme).toBe("light"); await waitFor(() => { expect(document.documentElement.classList.contains("light")).toBe(true); }); @@ -159,22 +144,19 @@ describe("ThemeProvider — system color theme resolution", () => { installMatchMediaStub(false); storageMap.set("appshell-ui-theme", "system"); - const { getByTestId } = render( - - - , - ); + const { result } = renderHook(() => useTheme(), { + wrapper: wrapper(), + }); - expect(getByTestId("resolvedTheme").textContent).toBe("light"); + expect(result.current.resolvedTheme).toBe("light"); - // Simulate OS dark mode toggle act(() => { for (const listener of matchMediaListeners) { listener({ matches: true } as MediaQueryListEvent); } }); - expect(getByTestId("resolvedTheme").textContent).toBe("dark"); + expect(result.current.resolvedTheme).toBe("dark"); await waitFor(() => { expect(document.documentElement.classList.contains("dark")).toBe(true); }); @@ -184,11 +166,9 @@ describe("ThemeProvider — system color theme resolution", () => { describe("provider guards", () => { it("throws when useTheme is called outside ThemeProvider", () => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); - const TP = () => { - useTheme(); - return null; - }; - expect(() => render()).toThrow(/useTheme must be used within a ThemeProvider/); + expect(() => renderHook(() => useTheme())).toThrow( + /useTheme must be used within a ThemeProvider/, + ); spy.mockRestore(); }); }); diff --git a/packages/core/src/hooks/use-override-breadcrumb.test.tsx b/packages/core/src/hooks/use-override-breadcrumb.test.tsx index 53440fbb6..ed2ae2477 100644 --- a/packages/core/src/hooks/use-override-breadcrumb.test.tsx +++ b/packages/core/src/hooks/use-override-breadcrumb.test.tsx @@ -1,93 +1,82 @@ import { renderHook, waitFor } from "@testing-library/react"; import { describe, it, expect } from "vitest"; +import { type ReactNode } from "react"; import { MemoryRouter } from "react-router"; import { BreadcrumbOverrideProvider, useBreadcrumbOverride } from "@/contexts/breadcrumb-context"; import { useOverrideBreadcrumb } from "./use-override-breadcrumb"; -let contextSnapshot: Map | undefined; - -const Spy = () => { - contextSnapshot = useBreadcrumbOverride().overrides; - return null; -}; - const wrapper = (path: string) => - ({ children }: { children: React.ReactNode }) => ( + ({ children }: { children: ReactNode }) => ( - - {children} - - + {children} ); +const renderOverride = (path: string, title: string | undefined) => + renderHook( + ({ title: currentTitle }: { title: string | undefined }) => { + useOverrideBreadcrumb(currentTitle); + return useBreadcrumbOverride().overrides; + }, + { + initialProps: { title }, + wrapper: wrapper(path), + }, + ); + describe("useOverrideBreadcrumb", () => { it("registers an override when title is provided", async () => { - renderHook(() => useOverrideBreadcrumb("Order #123"), { - wrapper: wrapper("/orders/123"), - }); + const { result } = renderOverride("/orders/123", "Order #123"); + await waitFor(() => { - expect(contextSnapshot?.get("/orders/123")).toBe("Order #123"); + expect(result.current.get("/orders/123")).toBe("Order #123"); }); }); it("does not register when title is undefined", async () => { - renderHook(() => useOverrideBreadcrumb(undefined), { - wrapper: wrapper("/orders/123"), - }); + const { result } = renderOverride("/orders/123", undefined); + await waitFor(() => { - expect(contextSnapshot?.has("/orders/123")).toBe(false); + expect(result.current.has("/orders/123")).toBe(false); }); }); it("updates override when title changes", async () => { - const { rerender } = renderHook( - ({ title }: { title: string | undefined }) => useOverrideBreadcrumb(title), - { - initialProps: { title: "Order #123" }, - wrapper: wrapper("/orders/123"), - }, - ); + const { result, rerender } = renderOverride("/orders/123", "Order #123"); + await waitFor(() => { - expect(contextSnapshot?.get("/orders/123")).toBe("Order #123"); + expect(result.current.get("/orders/123")).toBe("Order #123"); }); rerender({ title: "Order #456" }); await waitFor(() => { - expect(contextSnapshot?.get("/orders/123")).toBe("Order #456"); + expect(result.current.get("/orders/123")).toBe("Order #456"); }); }); it("cleans up override on unmount", async () => { - const { unmount } = renderHook(() => useOverrideBreadcrumb("Order #123"), { - wrapper: wrapper("/orders/123"), - }); + const { result, unmount } = renderOverride("/orders/123", "Order #123"); + await waitFor(() => { - expect(contextSnapshot?.get("/orders/123")).toBe("Order #123"); + expect(result.current.get("/orders/123")).toBe("Order #123"); }); - // unmount tears down the entire tree (including the Spy), so we cannot - // observe the state change via contextSnapshot. Verifying that unmount - // completes without error confirms the cleanup effect ran. + // Unmount tears down the provider too, so the observable contract here is + // simply that cleanup completes without throwing. expect(() => unmount()).not.toThrow(); }); it("removes override when title changes from string to undefined", async () => { - const { rerender } = renderHook( - ({ title }: { title: string | undefined }) => useOverrideBreadcrumb(title), - { - initialProps: { title: "Order #123" as string | undefined }, - wrapper: wrapper("/orders/123"), - }, - ); + const { result, rerender } = renderOverride("/orders/123", "Order #123"); + await waitFor(() => { - expect(contextSnapshot?.get("/orders/123")).toBe("Order #123"); + expect(result.current.get("/orders/123")).toBe("Order #123"); }); rerender({ title: undefined }); await waitFor(() => { - expect(contextSnapshot?.has("/orders/123")).toBe(false); + expect(result.current.has("/orders/123")).toBe(false); }); }); });