Files
gerbeur/src/test/render.tsx
khannurien 3e15e9a4f2
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 57s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 28s
v4: /follow/playlists opens the followed playlists' feed, and a page's test opens it through the app's routes — the path was a 404 since the two views of the followed feed became pages: the route table held each feed tab as an exact path, /follow, so /follow/playlists fell to /:handle/*, which read "follow" as a category and "playlists" as a tab it hasn't. /music/follow/playlists, which goes through that route, worked. The tabs' routes now take what follows them (/${tab}/*), which Feed already reads and refuses when it names no view.
No test saw it because each page's test mounted the page on a route pattern of its own, a copy of App's, with stand-in pages where a redirect lands: Index's parsed the path itself, and passed on one the app doesn't serve. renderPage (src/test/render.tsx) now opens a URL through AppRoutes, which App.tsx exports for it, inside the real providers, and every page's test uses it: the page is the one the app routes that URL to, behind its guard, and a redirect is checked by the path it lands on. An entry may be a location, to arrive with the state a link hands over. Every page being loaded with its route, the first query after opening one waits. src/test/App.test.tsx tests the table itself: the paths that are a feed, those that are not found, those that send a guest to sign in and back, and those that send a signed-in user home.

The profile's test held /@ann/dumps/x to be a path the profile answers as not found, which only its own pattern made true: in the app it is a dump's page. It now asks for /@ann/dumps/x/y. The tests of the two guards still mount them around a stand-in child, being tests of the guard alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:59:52 +00:00

305 lines
10 KiB
TypeScript

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 (
<div
hidden
data-testid="location"
data-state={JSON.stringify(loc.state ?? null)}
>
{loc.pathname + loc.search}
</div>
);
}
/** 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 (
<MemoryRouter initialEntries={entries} initialIndex={entries.length - 1}>
<Routes>
<Route path={path} element={children} />
{extraRoutes}
</Routes>
<LocationProbe />
</MemoryRouter>
);
}
// ── 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<WSContextValue & WSChatContextValue & WSPresenceContextValue>;
/** The bus the socket's events come from: a test emits on it. */
events?: WSEventBus;
follows?: Partial<FollowContextValue>;
categories?: Category[] | Partial<CategoriesContextValue>;
player?: Partial<PlayerContextValue & PlayerTimeValue>;
}
function StubAuth(
{ session, children }: { session: Session | null; children: ReactNode },
) {
const [authResponse, setAuthResponse] = useState(session);
return (
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
{children}
</AuthContext.Provider>
);
}
/** `defaults`, with whichever of its keys `overrides` sets. */
function pick<T extends object>(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 }) => (
<I18nProvider i18n={i18n}>
<StubAuth session={opts.session ?? null}>
<PlayerContext.Provider
value={{ ...defaultPlayerValue, ...opts.player }}
>
<PlayerTimeContext.Provider
value={{
currentTime: opts.player?.currentTime ??
defaultPlayerTimeValue.currentTime,
duration: opts.player?.duration ??
defaultPlayerTimeValue.duration,
}}
>
<WSContext.Provider value={pick(defaultWSValue, opts.ws)}>
<WSEventsContext.Provider value={events}>
<WSChatContext.Provider
value={pick(defaultWSChatValue, opts.ws)}
>
<WSPresenceContext.Provider
value={pick(defaultWSPresenceValue, opts.ws)}
>
<FollowContext.Provider
value={{
...defaultFollowValue,
isLoaded: true,
...opts.follows,
}}
>
<CategoriesContext.Provider
value={categoriesValue(opts.categories)}
>
<Router {...opts}>{children}</Router>
</CategoriesContext.Provider>
</FollowContext.Provider>
</WSPresenceContext.Provider>
</WSChatContext.Provider>
</WSEventsContext.Provider>
</WSContext.Provider>
</PlayerTimeContext.Provider>
</PlayerContext.Provider>
</StubAuth>
</I18nProvider>
);
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<AppOptions, "session" | "categories"> = {},
) {
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 }) => (
<I18nProvider i18n={i18n}>
<AuthProvider>
<ThemeProvider>
<PlayerProvider>
<WSProvider>
<FollowProvider>
<CategoriesProvider>
<MemoryRouter
initialEntries={entries}
initialIndex={entries.length - 1}
>
<ChatProvider>
<Routes>
<Route path={path} element={children} />
{extraRoutes}
</Routes>
<LocationProbe />
</ChatProvider>
</MemoryRouter>
</CategoriesProvider>
</FollowProvider>
</WSProvider>
</PlayerProvider>
</ThemeProvider>
</AuthProvider>
</I18nProvider>
);
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<AppOptions["route"]>,
opts: Pick<AppOptions, "session" | "categories"> = {},
) {
return renderApp(<AppRoutes />, { ...opts, route: url });
}