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:
198
src/test/pages/Index.test.tsx
Normal file
198
src/test/pages/Index.test.tsx
Normal 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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user