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