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 { Session } from "../utils/auth/storage.ts";
import type { Category } from "../../shared/category.ts";
import { AuthContext } from "../contexts/auth/AuthContext.ts";
import { AuthProvider } from "../contexts/auth/AuthProvider.tsx";
import {
defaultWSChatValue,
defaultWSPresenceValue,
defaultWSValue,
WSChatContext,
type WSChatContextValue,
WSContext,
type WSContextValue,
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 { 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). */
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?: Session | 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: 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,
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?: 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/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 });
}