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

View File

@@ -0,0 +1,98 @@
import { describe, expect, test } from "vitest";
import { act, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Search } from "../../pages/Search.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makeDump, makePlaylist, makeSession, makeUser, wire } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeIntersectionObserver } from "../setup.ts";
function results(
{ dumps = [] as ReturnType<typeof makeDump>[], hasMore = false, users = [] as ReturnType<typeof makeUser>[], playlists = [] as ReturnType<typeof makePlaylist>[] } = {},
) {
return {
dumps: { items: wire(dumps), total: dumps.length + (hasMore ? 1 : 0), hasMore },
users: wire(users),
playlists: wire(playlists),
};
}
function open(url: string, session: ReturnType<typeof makeSession> | null = null) {
const user = userEvent.setup();
renderApp(<Search />, { route: url, path: "/search/*", session });
return user;
}
describe("Search", () => {
test("no query: a prompt, no request", () => {
open("/search");
expect(screen.getByText("Enter a query to search.")).toBeInTheDocument();
expect(calls("GET /api/search")).toHaveLength(0);
});
test("results by tab, with counts", async () => {
route("GET /api/search", () =>
results({
dumps: [makeDump({ title: "Cat video" })],
users: [makeUser({ username: "catlover", description: "meow" })],
playlists: [makePlaylist({ title: "Cats" })],
}));
const user = open("/search?q=cat", makeSession());
await screen.findByText("Cat video");
const [req] = calls("GET /api/search");
expect(req.url.searchParams.get("q")).toBe("cat");
expect(req.headers.get("Authorization")).toMatch(/^Bearer /);
await user.click(screen.getByRole("button", { name: "Users (1)" }));
expect(currentPath()).toBe("/search/~/users?q=cat");
expect(screen.getByRole("link", { name: /@catlover/ })).toHaveAttribute("href", "/users/catlover");
await user.click(screen.getByRole("button", { name: "Playlists (1)" }));
expect(screen.getByText("Cats")).toBeInTheDocument();
});
test("nothing found", async () => {
route("GET /api/search", () => results());
const user = open("/search?q=zzz");
expect(await screen.findByText('No dumps match "zzz".')).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Users (0)" }));
expect(screen.getByText('No users match "zzz".')).toBeInTheDocument();
});
test("a failed search says why", async () => {
route("GET /api/search", () => fail("Query too short", 400));
open("/search?q=a");
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Search failed");
expect(alert).toHaveTextContent("Query too short");
});
test("more dumps on scroll; a failed page keeps what's shown", async () => {
route("GET /api/search", () => results({ dumps: [makeDump({ title: "First" })], hasMore: true }), { once: true });
route("GET /api/search", () => new TypeError("offline"), { once: true });
route("GET /api/search", () => results({ dumps: [makeDump({ title: "Second" })] }));
open("/search?q=x");
await screen.findByText("First");
const scroll = () => {
const obs = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!;
act(() => obs.callback([{ isIntersecting: true } as IntersectionObserverEntry], obs as never));
};
scroll();
await act(() => new Promise((r) => setTimeout(r, 50)));
expect(screen.getByText("First")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
scroll();
await screen.findByText("Second");
expect(calls("GET /api/search").at(-1)!.url.searchParams.get("page")).toBe("2");
});
test("a new query replaces the results", async () => {
route("GET /api/search", (req) =>
results({ dumps: [makeDump({ title: `About ${req.url.searchParams.get("q")}` })] }));
const user = open("/search?q=one");
await screen.findByText("About one");
const [box] = screen.getAllByRole("searchbox");
await user.clear(box);
await user.type(box, "two{Enter}");
await screen.findByText("About two");
expect(screen.queryByText("About one")).not.toBeInTheDocument();
});
});