v3: the frontend gets a test suite, and the bugs it turned up are fixed — deno task test:web runs 612 tests over src/ in about 11s: Vitest on jsdom with Testing Library, built on the app's own Vite config so the SWC/Lingui transform and the .po loader are what ship. src/test/ holds the harness: a routing fetch stub (queued once-responses, fallback defaults), a FakeWebSocket the tests open, feed and drop, jsdom polyfills, fixtures, and two render helpers — one over hand-set context values, one over the real provider tree (auth, socket, player, follows, categories, chat). It covers every util and hook, every provider (the socket protocol end to end: votes and likes with ack/timeout/error rollback, chat sends/echoes/retries/read markers/reconcile, reconnect backfill), the components (header and search history, cards, Markdown, thumbnails, media and global player, the text editor's mentions/emoji/uploads, comments, the new-dump modal, the chat), and every page.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
Fixed along the way. Dumps: clearing a dump's note sent `comment: undefined`, which JSON drops, so the server kept the old one; it's sent as null now. Replacing a file dropped a visibility change made in the same save (the file endpoint doesn't read it); it follows as a separate update. Renaming a dump onto a new slug kept showing the old title until reload. The thumbnail picker and delete on the edit page failed without a word, the latter as an unhandled rejection. Playlists: an edit that kept the slug waited on the socket's echo to show, and a new cover never appeared (the upload's answer was ignored, a refused one said nothing); covers were also served stale for an hour, since `max-age=3600` met an unversioned URL — setPlaylistImage now bumps updated_at and every cover URL carries `?v=` (playlistImageUrl). Removing a dump, reordering, and deleting a playlist ignored non-OK answers, leaving page and server apart; they restore, reload or report. Profile: clearing "Who am I?" sent undefined and came back on reload; your own upvoted list (and the upvoted page) was empty until the socket's welcome, and forever without one; "View all" under Following linked to a route that doesn't exist and is a Load more now. Chat: Escape on "Delete this message?" closed the whole chat — ConfirmModal takes Escape first, in the capture phase. Player: a pause or play from a media key, headset or the OS wasn't reflected (MediaPlayer follows the element's own play/pause events), and the seek bar tracked dragging with mouse events only, so on touch the thumb fought timeupdate. Feeds: a guest on /~/followed got a blank page; a server error in the followed feed read as "Could not connect". Search: a failed later page threw away the results shown. Notifications: a failed mark-read replaced a loaded list with an error. Silent failures now say so: deleting a comment, the voters/likers "Load more" (stuck on "Loading…"), and the password-reset request, which said "sent" whatever happened. Notifications also stop hiding what was new: the bell still clears on opening the page, but the dots and the "N new" count stay for the visit instead of vanishing the moment the mark-read lands, and one arriving while the page is open is marked read too, so the bell doesn't count it. Accessibility: Modal and ConfirmModal are dialogs (role, aria-modal, labelled/described); playlist membership rows are checkboxes that work from the keyboard; a dump's title, and the profile's email and description, can be opened for editing from the keyboard (the pencil shows on focus); the category editor's ✓ and the global player's video button have names. Player, rich-content and download labels were hard-coded English and are translated, with the new French strings. Lint: fast-refresh and render-purity rules are off for src/test/ only, where probes hand their context out on purpose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
213
src/test/render.tsx
Normal file
213
src/test/render.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
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 { WSContext, type WSContextValue } from "../contexts/WSContext.ts";
|
||||
import { WSProvider } from "../contexts/WSProvider.tsx";
|
||||
import { 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 { PlayerContext, type PlayerContextValue } 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 (
|
||||
<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). */
|
||||
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 (
|
||||
<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?: AuthResponse | null;
|
||||
ws?: Partial<WSContextValue>;
|
||||
follows?: Partial<FollowContextValue>;
|
||||
categories?: Category[] | Partial<CategoriesContextValue>;
|
||||
player?: Partial<PlayerContextValue>;
|
||||
}
|
||||
|
||||
function StubAuth(
|
||||
{ session, children }: { session: AuthResponse | null; children: ReactNode },
|
||||
) {
|
||||
const [authResponse, setAuthResponse] = useState(session);
|
||||
return (
|
||||
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/** A context's default value — what a consumer outside any provider gets.
|
||||
* React keeps it on the context object; there is no public accessor. */
|
||||
export function defaultOf<T>(context: React.Context<T>): T {
|
||||
return (context as unknown as { _currentValue: T })._currentValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
) {
|
||||
const defaultWS = defaultOf(WSContext);
|
||||
const defaultFollows = defaultOf(FollowContext);
|
||||
const defaultPlayer = defaultOf(PlayerContext);
|
||||
// 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={{ ...defaultPlayer, ...opts.player }}>
|
||||
<WSContext.Provider value={{ ...defaultWS, ...opts.ws }}>
|
||||
<FollowContext.Provider
|
||||
value={{ ...defaultFollows, isLoaded: true, ...opts.follows }}
|
||||
>
|
||||
<CategoriesContext.Provider value={categoriesValue(opts.categories)}>
|
||||
<Router {...opts}>{children}</Router>
|
||||
</CategoriesContext.Provider>
|
||||
</FollowContext.Provider>
|
||||
</WSContext.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?: 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<AppOptions, "session" | "categories"> = {},
|
||||
) {
|
||||
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 }) => (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<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>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
return render(ui, { wrapper: Wrapper });
|
||||
}
|
||||
Reference in New Issue
Block a user