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:
95
src/test/setup.ts
Normal file
95
src/test/setup.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// Runs before every test file: DOM matchers, the English catalog, and the
|
||||
// browser APIs jsdom leaves out that the app reaches for at render time.
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, configure } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { messages } from "../locales/en.po";
|
||||
import { FakeWebSocket } from "./ws.ts";
|
||||
import { resetFetch } from "./fetch.ts";
|
||||
|
||||
// Lazy chunks and the app's own debounces (a link preview waits 600ms) make
|
||||
// the default 1s too tight once every file runs in parallel.
|
||||
configure({ asyncUtilTimeout: 4000 });
|
||||
|
||||
i18n.load("en", messages);
|
||||
i18n.activate("en");
|
||||
|
||||
class FakeIntersectionObserver {
|
||||
static instances: FakeIntersectionObserver[] = [];
|
||||
elements = new Set<Element>();
|
||||
callback: IntersectionObserverCallback;
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.callback = callback;
|
||||
FakeIntersectionObserver.instances.push(this);
|
||||
}
|
||||
observe(el: Element) {
|
||||
this.elements.add(el);
|
||||
}
|
||||
unobserve(el: Element) {
|
||||
this.elements.delete(el);
|
||||
}
|
||||
disconnect() {
|
||||
this.elements.clear();
|
||||
}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
class FakeResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
Object.assign(globalThis, {
|
||||
IntersectionObserver: FakeIntersectionObserver,
|
||||
ResizeObserver: FakeResizeObserver,
|
||||
});
|
||||
|
||||
if (!globalThis.matchMedia) {
|
||||
globalThis.matchMedia = (query: string) =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
dispatchEvent: () => false,
|
||||
}) as MediaQueryList;
|
||||
}
|
||||
|
||||
Element.prototype.scrollIntoView ??= function () {};
|
||||
Element.prototype.animate ??= function () {
|
||||
return { cancel() {}, finished: Promise.resolve() } as unknown as Animation;
|
||||
};
|
||||
Element.prototype.getAnimations ??= () => [];
|
||||
globalThis.scrollTo = () => {};
|
||||
HTMLMediaElement.prototype.play = function () {
|
||||
return Promise.resolve();
|
||||
};
|
||||
HTMLMediaElement.prototype.pause = function () {};
|
||||
HTMLMediaElement.prototype.load = function () {};
|
||||
// The emoji picker probes emoji support on a canvas; jsdom has none.
|
||||
HTMLCanvasElement.prototype.getContext = (() => null) as never;
|
||||
// jsdom has no layout; execCommand("insertText") is how the editor types.
|
||||
document.execCommand ??= () => false;
|
||||
|
||||
beforeEach(() => {
|
||||
resetFetch();
|
||||
FakeWebSocket.reset();
|
||||
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||
FakeIntersectionObserver.instances = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
document.documentElement.removeAttribute("style");
|
||||
});
|
||||
|
||||
export { FakeIntersectionObserver };
|
||||
Reference in New Issue
Block a user