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,198 @@
import { describe, expect, test } from "vitest";
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useParams } from "react-router";
import { Index } from "../../pages/Index.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makeCategory, makeDump, makeSession, page, wire } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
import { FakeIntersectionObserver } from "../setup.ts";
const HOUR = 3_600_000;
const music = makeCategory({ id: "c-music", slug: "music", name: "Music" });
function Scoped() {
const { scope } = useParams();
const categorySlug = scope === "~" ? undefined : scope;
return <Index key={scope} categorySlug={categorySlug} />;
}
function setup(
url: string,
{ session = null as ReturnType<typeof makeSession> | null } = {},
) {
const user = userEvent.setup();
renderApp(<Scoped />, {
route: url,
path: "/:scope/:feedTab",
session,
categories: [music],
});
const socket = FakeWebSocket.last;
socket.welcome();
return { user, socket };
}
const titles = () =>
Array.from(document.querySelectorAll(".dump-card-title, .journal-card-title"))
.map((a) => a.textContent);
describe("Index feeds", () => {
const older = makeDump({ id: "old", title: "Old but loved", voteCount: 30, createdAt: new Date(Date.now() - 5 * HOUR) });
const newer = makeDump({ id: "new", title: "Brand new", voteCount: 0, createdAt: new Date(Date.now() - 60_000) });
test("New sorts by date, Hot by score", async () => {
route("GET /api/dumps/", () => page([older, newer]));
const { user } = setup("/~/new");
await screen.findByText("Brand new");
expect(titles()).toEqual(["Brand new", "Old but loved"]);
await user.click(screen.getAllByRole("button", { name: "Hot" })[0]);
expect(currentPath()).toBe("/~/hot");
expect(titles()).toEqual(["Old but loved", "Brand new"]);
expect(screen.getByText("You've reached the end.")).toBeInTheDocument();
// One fetch: switching tabs re-sorts what's loaded.
expect(calls("GET /api/dumps/")).toHaveLength(1);
});
test("Journal lays the feed out as a mosaic", async () => {
route("GET /api/dumps/", () => page([older, newer]));
setup("/~/journal");
await screen.findByText("Brand new");
expect(document.querySelector(".journal-grid")).not.toBeNull();
});
test("an empty feed and a failed one", async () => {
route("GET /api/dumps/", () => page([]));
const first = setup("/~/new");
await screen.findByText("No dumps yet. Be the first!");
first.socket.drop();
});
test("a failed load shows an error and disables posting", async () => {
route("GET /api/dumps/", () => fail("boom", 500));
setup("/~/new", { session: makeSession() });
expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load");
expect(screen.getByRole("button", { name: /Dump/ })).toBeDisabled();
});
test("sends the token so your own private dumps come back", async () => {
const session = makeSession();
route("GET /api/dumps/", () => page([]));
setup("/~/new", { session });
await screen.findByText("No dumps yet. Be the first!");
expect(calls("GET /api/dumps/")[0].headers.get("Authorization")).toBe(`Bearer ${session.token}`);
});
test("scrolling to the end loads the next page", async () => {
route("GET /api/dumps/", (req) =>
req.url.searchParams.get("page") === "1"
? page([newer], true)
: page([older], false));
setup("/~/new");
await screen.findByText("Brand new");
const observer = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!;
act(() => observer.callback([{ isIntersecting: true } as IntersectionObserverEntry], observer as never));
await screen.findByText("Old but loved");
expect(calls("GET /api/dumps/")[1].url.searchParams.get("page")).toBe("2");
});
test("live: a new dump goes on top; a deleted one goes away", async () => {
route("GET /api/dumps/", () => page([older]));
const { socket } = setup("/~/new");
await screen.findByText("Old but loved");
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "live", title: "Just now", createdAt: new Date() })) });
expect(titles()[0]).toBe("Just now");
socket.receive({ type: "dump_deleted", dumpId: "old" });
expect(screen.queryByText("Old but loved")).not.toBeInTheDocument();
});
test("live votes update the count", async () => {
route("GET /api/dumps/", () => page([older]));
const { socket } = setup("/~/new");
await screen.findByText("Old but loved");
socket.receive({ type: "votes_update", dumpId: "old", voteCount: 31, voterId: "x", action: "cast" });
expect(screen.getByRole("button", { name: "Upvote" })).toHaveTextContent("31");
});
test("guests can't vote", async () => {
route("GET /api/dumps/", () => page([older]));
setup("/~/new");
await screen.findByText("Old but loved");
expect(screen.getByRole("button", { name: "Upvote" })).toBeDisabled();
});
test("a reconnect backfills what was missed", async () => {
route("GET /api/dumps/", () => page([older]), { once: true });
route("GET /api/dumps/", () => page([newer, older]));
const { socket } = setup("/~/new");
await screen.findByText("Old but loved");
socket.drop();
await act(() => new Promise((r) => setTimeout(r, 600)));
FakeWebSocket.last.welcome();
await screen.findByText("Brand new");
});
test("a category scopes the feed, and live arrivals to it", async () => {
route("GET /api/categories/:slug/dumps", () => page([makeDump({ title: "A song", categoryIds: ["c-music"] })]));
const { socket } = setup("/music/new");
await screen.findByText("A song");
expect(calls("GET /api/categories/:slug/dumps")[0].params.slug).toBe("music");
await waitFor(() => expect(document.title).toMatch(/^Music · /));
socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Elsewhere", createdAt: new Date() })) });
socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Another song", categoryIds: ["c-music"], createdAt: new Date() })) });
expect(screen.queryByText("Elsewhere")).not.toBeInTheDocument();
expect(screen.getByText("Another song")).toBeInTheDocument();
});
test("an unknown category is a 404", async () => {
route("GET /api/categories/:slug/dumps", () => fail("nope", 404));
setup("/nonsense/new");
expect(await screen.findByRole("heading", { name: "404" })).toBeInTheDocument();
});
test("a guest on the followed tab gets the default feed, not a blank page", async () => {
route("GET /api/dumps/", () => page([older]));
setup("/~/followed");
await screen.findByText("Old but loved");
});
test("the Android share sheet opens the new-dump form with the link", async () => {
route("GET /api/dumps/", () => page([]));
route("GET /api/preview", () => ({ reached: false, richContent: null }));
route("GET /api/dumps/by-url", () => []);
setup("/~/new?share_url=https%3A%2F%2Fshared.test%2F", { session: makeSession() });
const dialog = await screen.findByRole("dialog", { name: "New dump" });
expect(within(dialog).getByPlaceholderText("Paste a link…")).toHaveValue("https://shared.test/");
});
});
describe("Followed feed", () => {
test("from people and from playlists", async () => {
const session = makeSession();
route("GET /api/follows/feed/users", () => page([makeDump({ title: "By a friend" })]));
route("GET /api/follows/feed/playlists", () => page([]));
const { user } = setup("/~/followed", { session });
await screen.findByText("By a friend");
await user.click(screen.getByRole("button", { name: "From playlists" }));
expect(screen.getByText("Follow some public playlists to see their dumps here.")).toBeInTheDocument();
expect(calls("GET /api/follows/feed/users")[0].headers.get("Authorization")).toBe(`Bearer ${session.token}`);
});
test("a category narrows it", async () => {
route("GET /api/follows/feed/users", () => page([]));
route("GET /api/follows/feed/playlists", () => page([]));
setup("/music/followed", { session: makeSession() });
await screen.findByText("Follow some users to see their dumps here.");
expect(calls("GET /api/follows/feed/users")[0].url.searchParams.get("category")).toBe("music");
});
test("a server error reads as one, not as a lost connection", async () => {
route("GET /api/follows/feed/users", () => fail("Internal error", 500));
route("GET /api/follows/feed/playlists", () => page([]));
setup("/~/followed", { session: makeSession() });
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Internal error");
expect(alert).not.toHaveTextContent(/Could not connect/);
});
});