import { type ReactElement, type ReactNode, useState } from "react"; import { render, type RenderOptions } from "@testing-library/react"; import { I18nProvider } from "@lingui/react"; import { i18n } from "@lingui/core"; import { type InitialEntry, MemoryRouter, Route, Routes, useLocation, } from "react-router"; import type { Session } from "../utils/auth/storage.ts"; import type { Category } from "../../shared/category.ts"; import { DEFAULT_SETTINGS } from "../../shared/settings.ts"; import { AuthContext } from "../contexts/auth/AuthContext.ts"; import { AuthProvider } from "../contexts/auth/AuthProvider.tsx"; import { createWSEventBus, defaultWSChatValue, defaultWSPresenceValue, defaultWSValue, WSChatContext, type WSChatContextValue, WSContext, type WSContextValue, type WSEventBus, WSEventsContext, WSPresenceContext, type WSPresenceContextValue, } from "../contexts/ws/WSContext.ts"; import { WSProvider } from "../contexts/ws/WSProvider.tsx"; import { defaultFollowValue, FollowContext, type FollowContextValue, } from "../contexts/social/FollowContext.ts"; import { FollowProvider } from "../contexts/social/FollowProvider.tsx"; import { CategoriesContext, type CategoriesContextValue, } from "../contexts/category/CategoriesContext.ts"; import { CategoriesProvider } from "../contexts/category/CategoriesProvider.tsx"; import { defaultPlayerTimeValue, defaultPlayerValue, PlayerContext, type PlayerContextValue, PlayerTimeContext, type PlayerTimeValue, } from "../contexts/player/PlayerContext.ts"; import { PlayerProvider } from "../contexts/player/PlayerProvider.tsx"; import { ThemeProvider } from "../contexts/theme/ThemeProvider.tsx"; import { ChatProvider } from "../contexts/chat/ChatProvider.tsx"; import { AppRoutes } from "../App.tsx"; import { route } from "./fetch.ts"; import { storeSession } from "./fixtures.ts"; /** Renders the current location into the DOM, for asserting navigation. */ export function LocationProbe() { const loc = useLocation(); return ( ); } /** The path the probe last saw. */ export function currentPath(): string { return document.querySelector('[data-testid="location"]')?.textContent ?? ""; } interface RouterOptions { /** * Initial URL, or several for a history stack (the last is current). An * entry may be a location, to arrive with the `state` a link hands over. */ route?: InitialEntry | InitialEntry[]; /** Route pattern the element is mounted on, for useParams. */ path?: string; /** Extra routes to mount next to it, e.g. a redirect target. */ extraRoutes?: ReactNode; } function Router( { children, route = "/", path = "*", extraRoutes }: RouterOptions & { children: ReactNode; }, ) { const entries = Array.isArray(route) ? route : [route]; return ( {extraRoutes} ); } // ── Stubbed contexts ───────────────────────────────────────────────────────── export interface StubOptions extends RouterOptions { session?: Session | null; /** Any of the socket's three contexts' fields; each goes to its own. */ ws?: Partial; /** The bus the socket's events come from: a test emits on it. */ events?: WSEventBus; follows?: Partial; categories?: Category[] | Partial; player?: Partial; } function StubAuth( { session, children }: { session: Session | null; children: ReactNode }, ) { const [authResponse, setAuthResponse] = useState(session); return ( {children} ); } /** `defaults`, with whichever of its keys `overrides` sets. */ function pick(defaults: T, overrides: object = {}): T { const out = { ...defaults }; for (const key of Object.keys(defaults) as (keyof T)[]) { if (key in overrides) { out[key] = (overrides as T)[key]; } } return out; } function categoriesValue( c: StubOptions["categories"], ): CategoriesContextValue { const list = Array.isArray(c) ? c : c?.categories ?? []; return { categories: list, isLoaded: true, refresh: async () => {}, ...(Array.isArray(c) ? {} : c), }; } /** * Renders `ui` with context values supplied directly — no network, no socket. * For components whose inputs are the contexts they read. */ export function renderWithContexts( ui: ReactElement, opts: StubOptions = {}, renderOptions?: RenderOptions, ) { // One bus for every render, as `WSProvider` keeps one. const events = opts.events ?? createWSEventBus(); // A wrapper rather than one tree, so `rerender(ui)` keeps the contexts. const Wrapper = ({ children }: { children: ReactNode }) => ( {children} ); return render(ui, { ...renderOptions, wrapper: Wrapper }); } // ── The real providers ─────────────────────────────────────────────────────── export interface AppOptions extends RouterOptions { /** Signed in as this session (stored before mount, as after a login). */ session?: Session | null; categories?: Category[]; } /** The routes every provider tree calls on mount, as fallbacks: a test's * own routes for the same paths win, whenever they were added. */ export function defaultRoutes( { session, categories = [] }: Pick = {}, ) { const fallback = { fallback: true }; route( "GET /api/categories", () => categories.map((c) => JSON.parse(JSON.stringify(c))), fallback, ); route("GET /api/me/follow", () => ({ followedUserIds: [], followedPlaylistIds: [], }), fallback); route( "GET /api/me", () => JSON.parse(JSON.stringify(session?.user ?? null)), ); route("GET /api/me/media-token", () => ({ mediaToken: "media-token" })); route( "GET /api/me/settings", () => ({ ...DEFAULT_SETTINGS }), fallback, ); } /** * Renders `ui` inside the same provider tree as App — real auth, socket (a * FakeWebSocket), follows, categories, player and chat — over a memory router. * Register routes the page needs with `route()` before or after calling this; * the defaults for the providers' own requests are installed first so a test * can override them. */ export function renderApp(ui: ReactElement, opts: AppOptions = {}) { if (opts.session) storeSession(opts.session); defaultRoutes(opts); const { route: initial = "/", path = "*", extraRoutes } = opts; const entries = Array.isArray(initial) ? initial : [initial]; const Wrapper = ({ children }: { children: ReactNode }) => ( {extraRoutes} ); return render(ui, { wrapper: Wrapper }); } /** * Opens `url` as a visit does: `App`'s own route table inside its providers * (`renderApp`), so the page under test is whichever one the app routes that * URL to, behind its guard, and a redirect lands on the real page. A page's * test opens it this way; mounting the page on a pattern typed in the test * says nothing of whether the app serves that path. Every page is loaded with * its route, so the first query after it waits (`findBy`, `waitFor`). */ export function renderPage( url: NonNullable, opts: Pick = {}, ) { return renderApp(, { ...opts, route: url }); }