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 (
{loc.pathname + loc.search}
);
}
/** 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 });
}