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

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:
khannurien
2026-09-27 14:35:41 +00:00
parent 25601edaf8
commit 54e86c4366
78 changed files with 10816 additions and 691 deletions

213
src/test/render.tsx Normal file
View 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 });
}