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 { MemoryRouter, Route, Routes, useLocation } from "react-router"; import type { AuthResponse, Category } from "../model.ts"; import { AuthContext } from "../contexts/AuthContext.ts"; import { AuthProvider } from "../contexts/AuthProvider.tsx"; import { defaultWSChatValue, defaultWSPresenceValue, defaultWSValue, WSChatContext, type WSChatContextValue, WSContext, type WSContextValue, WSPresenceContext, type WSPresenceContextValue, } from "../contexts/WSContext.ts"; import { WSProvider } from "../contexts/WSProvider.tsx"; import { defaultFollowValue, FollowContext, type FollowContextValue, } from "../contexts/FollowContext.ts"; import { FollowProvider } from "../contexts/FollowProvider.tsx"; import { CategoriesContext, type CategoriesContextValue, } from "../contexts/CategoriesContext.ts"; import { CategoriesProvider } from "../contexts/CategoriesProvider.tsx"; import { defaultPlayerTimeValue, defaultPlayerValue, PlayerContext, type PlayerContextValue, PlayerTimeContext, type PlayerTimeValue, } from "../contexts/PlayerContext.ts"; import { PlayerProvider } from "../contexts/PlayerProvider.tsx"; import { ThemeProvider } from "../contexts/ThemeProvider.tsx"; import { ChatProvider } from "../contexts/ChatProvider.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). */ route?: string | string[]; /** 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?: AuthResponse | null; /** Any of the socket's three contexts' fields; each goes to its own. */ ws?: Partial; follows?: Partial; categories?: Category[] | Partial; player?: Partial; } function StubAuth( { session, children }: { session: AuthResponse | 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, byId: new Map(list.map((x) => [x.id, x])), 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, ) { // 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?: AuthResponse | 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/follows/status", () => ({ followedUserIds: [], followedPlaylistIds: [], }), fallback); // Standing, not fallbacks: a page test's own `GET /api/users/:u` would // otherwise answer for "me" and hand the session someone else's role. route( "GET /api/users/me", () => JSON.parse(JSON.stringify(session?.user ?? null)), ); route("GET /api/users/me/media-token", () => ({ mediaToken: "media-token" })); } /** * 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 }); }