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,220 @@
import { describe, expect, test, vi } from "vitest";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Route } from "react-router";
import { PlaylistDetail } from "../../pages/PlaylistDetail.tsx";
import type { Dump, Playlist } from "../../model.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeDump, makePlaylist, makeSession, wire } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const owner = makeSession({ id: "owner", username: "owner" });
const visitor = makeSession({ id: "visitor" });
const dumps = () => [
makeDump({ id: "a", title: "Alpha" }),
makeDump({ id: "b", title: "Bravo" }),
makeDump({ id: "c", title: "Charlie" }),
];
function open(
{ session = owner as ReturnType<typeof makeSession> | null, playlist = {} as Partial<Playlist>, items = dumps() as Dump[] } = {},
) {
const pl = makePlaylist({
id: "p1",
slug: "mix",
title: "Mix",
userId: "owner",
ownerUsername: "owner",
description: "Good **tunes**",
...playlist,
});
route("GET /api/playlists/:id", () => wire({ ...pl, dumps: items }));
const user = userEvent.setup();
renderApp(<PlaylistDetail />, {
route: "/playlists/mix",
path: "/playlists/:playlistId",
session,
extraRoutes: <Route path="/" element={<p>home</p>} />,
});
const socket = FakeWebSocket.last;
socket.welcome();
return { user, socket, pl };
}
const titles = () =>
Array.from(document.querySelectorAll(".dump-card-title")).map((a) => a.textContent);
describe("PlaylistDetail", () => {
test("shows the playlist and its dumps in order", async () => {
open({ session: null });
expect(await screen.findByRole("heading", { name: "Mix" })).toBeInTheDocument();
expect(screen.getByText("tunes").tagName).toBe("STRONG");
expect(screen.getByRole("link", { name: "@owner" })).toHaveAttribute("href", "/users/owner");
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
await waitFor(() => expect(document.title).toMatch(/^Mix · /));
expect(screen.queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
});
test("not found", async () => {
route("GET /api/playlists/:id", () => fail("nope", 404));
renderApp(<PlaylistDetail />, { route: "/playlists/x", path: "/playlists/:playlistId" });
expect(await screen.findByRole("alert")).toHaveTextContent("Playlist not found");
});
test("a visitor can follow a public playlist, not edit it", async () => {
open({ session: visitor });
await screen.findByRole("heading", { name: "Mix" });
expect(screen.getByRole("button", { name: "Follow playlist" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not.toBeInTheDocument();
});
test("the cover is versioned on the last edit", async () => {
open({ playlist: { imageMime: "image/png", updatedAt: new Date(5000) } });
await screen.findByRole("heading", { name: "Mix" });
expect(document.querySelector(".playlist-detail-img")).toHaveAttribute(
"src",
"http://localhost:8000/api/playlists/p1/image?v=5000",
);
});
test("editing the description shows at once, without waiting for the socket", async () => {
route("PUT /api/playlists/:id", () =>
wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner", description: "Better tunes" })));
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
const box = screen.getByPlaceholderText("Description (optional)");
await user.clear(box);
await user.type(box, "Better tunes");
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("Better tunes")).toBeInTheDocument();
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({ description: "Better tunes", isPublic: true });
expect(currentPath()).toBe("/playlists/mix");
});
test("a new title moves to the new slug", async () => {
route("PUT /api/playlists/:id", () =>
wire(makePlaylist({ id: "p1", slug: "remix", title: "Remix", userId: "owner" })));
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
const title = screen.getByPlaceholderText("Playlist title");
await user.clear(title);
await user.type(title, "Remix");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(currentPath()).toBe("/playlists/remix"));
});
test("a new cover shows once saved; a refused one says why", async () => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:preview");
route("PUT /api/playlists/:id", () => wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner" })));
route("POST /api/playlists/:id/image", () => fail("Not an image", 400), { once: true });
route("POST /api/playlists/:id/image", () =>
wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner", imageMime: "image/png", updatedAt: new Date(9000) })));
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
const picker = document.querySelector<HTMLInputElement>('.img-picker input[type="file"]')!;
await user.upload(picker, new File([new Uint8Array(3)], "c.png", { type: "image/png" }));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Not an image");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(document.querySelector(".playlist-detail-img")).toHaveAttribute(
"src",
"http://localhost:8000/api/playlists/p1/image?v=9000",
)
);
});
test("Escape leaves the editor", async () => {
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
await user.type(screen.getByPlaceholderText("Playlist title"), "{Escape}");
expect(screen.getByRole("heading", { name: "Mix" })).toBeInTheDocument();
});
test("removing a dump fades it out, with an undo", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
route("DELETE /api/playlists/:pid/dumps/:did", () => new Response(null, { status: 204 }));
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
route("PUT /api/playlists/:pid/order", () => ({}));
const { user } = open();
await screen.findByText("Bravo");
const bravo = screen.getByText("Bravo").closest(".playlist-dump-item") as HTMLElement;
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" }));
expect(calls("DELETE /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/mix/dumps/b");
await user.click(within(bravo).getByRole("button", { name: "Cancel removal" }));
await waitFor(() => expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1));
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ dumpIds: ["a", "b", "c"] });
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" }));
act(() => vi.advanceTimersByTime(2_400));
expect(screen.queryByText("Bravo")).not.toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
test("a refused removal puts the dump back and says so", async () => {
route("DELETE /api/playlists/:pid/dumps/:did", () => fail("Forbidden", 403));
const { user } = open();
await screen.findByText("Bravo");
const bravo = screen.getByText("Bravo").closest(".playlist-dump-item") as HTMLElement;
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" }));
expect(await screen.findByRole("alert")).toHaveTextContent("couldn't be removed");
expect(within(bravo).getByRole("button", { name: "Remove from playlist" })).toBeInTheDocument();
});
test("dragging reorders, and a refused reorder reloads the real order", async () => {
route("PUT /api/playlists/:pid/order", () => fail("Forbidden", 403));
open();
await screen.findByText("Charlie");
const items = () => Array.from(document.querySelectorAll<HTMLElement>(".playlist-dump-item"));
for (const el of items()) {
el.getBoundingClientRect = () => ({ top: 0, height: 100 }) as DOMRect;
}
const [a, , c] = items();
fireEvent.dragStart(a);
fireEvent.dragOver(c, { clientY: 90 });
expect(titles()).toEqual(["Bravo", "Charlie", "Alpha"]);
fireEvent.dragEnd(items()[2]);
await waitFor(() => expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1));
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ dumpIds: ["b", "c", "a"] });
await waitFor(() => expect(calls("GET /api/playlists/:id")).toHaveLength(2));
await waitFor(() => expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]));
});
test("deleting the playlist asks, then goes home", async () => {
route("DELETE /api/playlists/:id", () => new Response(null, { status: 204 }));
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
expect(await screen.findByText("home")).toBeInTheDocument();
});
test("a refused delete stays and says why", async () => {
route("DELETE /api/playlists/:id", () => fail("Forbidden", 403));
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Forbidden");
expect(currentPath()).toBe("/playlists/mix");
});
test("live: added dumps appear, removed ones fade, a deleted playlist sends you home", async () => {
route("GET /api/dumps/:id", () => wire(makeDump({ id: "d", title: "Delta" })));
const { socket } = open({ session: visitor });
await screen.findByText("Alpha");
socket.receive({ type: "playlist_dumps_updated", playlistId: "p1", dumpIds: ["d", "a", "b", "c"] });
expect(await screen.findByText("Delta")).toBeInTheDocument();
socket.receive({ type: "playlist_updated", playlist: wire(makePlaylist({ id: "p1", title: "Renamed", userId: "owner" })) });
expect(screen.getByRole("heading", { name: "Renamed" })).toBeInTheDocument();
socket.receive({ type: "dump_deleted", dumpId: "a" });
expect(screen.queryByText("Alpha")).not.toBeInTheDocument();
socket.receive({ type: "playlist_deleted", playlistId: "p1", userId: "owner" });
expect(await screen.findByText("home")).toBeInTheDocument();
});
});