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>
305 lines
10 KiB
TypeScript
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 });
|
|
}
|