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

101
src/test/ws.ts Normal file
View File

@@ -0,0 +1,101 @@
import type { IncomingWSMessage } from "../model.ts";
import { act } from "@testing-library/react";
/**
* Stands in for the browser WebSocket. Every socket the app opens is kept in
* `FakeWebSocket.instances`; a test opens it, feeds it server messages and
* reads back what the client sent.
*/
export class FakeWebSocket extends EventTarget {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
static instances: FakeWebSocket[] = [];
static reset() {
FakeWebSocket.instances = [];
}
/** The newest socket the app opened. */
static get last(): FakeWebSocket {
const ws = FakeWebSocket.instances.at(-1);
if (!ws) throw new Error("no WebSocket was opened");
return ws;
}
readyState = FakeWebSocket.CONNECTING;
sent: string[] = [];
onopen: ((e: Event) => void) | null = null;
onmessage: ((e: MessageEvent) => void) | null = null;
onclose: ((e: CloseEvent) => void) | null = null;
onerror: ((e: Event) => void) | null = null;
url: string;
constructor(url: string) {
super();
this.url = url;
FakeWebSocket.instances.push(this);
}
send(data: string) {
if (this.readyState !== FakeWebSocket.OPEN) {
throw new Error("send on a socket that isn't open");
}
this.sent.push(data);
}
close() {
if (this.readyState === FakeWebSocket.CLOSED) return;
this.readyState = FakeWebSocket.CLOSED;
this.onclose?.(new Event("close") as CloseEvent);
}
/** What the client sent, parsed. */
get messages(): Array<Record<string, unknown>> {
return this.sent.map((s) => JSON.parse(s));
}
// ── Server side ───────────────────────────────────────────────────────────
open() {
act(() => {
this.readyState = FakeWebSocket.OPEN;
this.onopen?.(new Event("open"));
});
}
receive(msg: IncomingWSMessage | Record<string, unknown>) {
act(() => {
this.onmessage?.(
new MessageEvent("message", { data: JSON.stringify(msg) }),
);
});
}
receiveRaw(data: string) {
act(() => {
this.onmessage?.(new MessageEvent("message", { data }));
});
}
/** The server drops the connection. */
drop() {
act(() => this.close());
}
/** Opens the socket and sends a welcome, as the server does on connect. */
welcome(overrides: Partial<Extract<IncomingWSMessage, { type: "welcome" }>> = {}) {
this.open();
this.receive({
type: "welcome",
users: [],
myVotes: [],
myCommentLikes: [],
unreadNotificationCount: 0,
unreadChatCount: 0,
...overrides,
});
}
}