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

166
src/test/utils/misc.test.ts Normal file
View File

@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { can } from "../../utils/permissions.ts";
import { canProxyImage, proxyImageUrl } from "../../utils/proxyImage.ts";
import { relativeTime } from "../../utils/relativeTime.ts";
import {
hueFromString,
initialsFor,
isSafeHex,
tintFor,
} from "../../utils/thumbnailTint.ts";
import {
dumpFileUrl,
dumpThumbnailUrl,
dumpUrl,
normalizeUrl,
playlistUrl,
} from "../../utils/urls.ts";
import { API_URL } from "../../config/api.ts";
import { i18n } from "../../i18n.ts";
describe("can", () => {
test("admins can do everything, moderators moderate, users nothing", () => {
expect(can({ role: "admin" }, "category:manage")).toBe(true);
expect(can({ role: "admin" }, "user:manage")).toBe(true);
expect(can({ role: "moderator" }, "dump:moderate")).toBe(true);
expect(can({ role: "moderator" }, "chat:moderate")).toBe(true);
expect(can({ role: "moderator" }, "category:manage")).toBe(false);
expect(can({ role: "user" }, "dump:moderate")).toBe(false);
});
test("no user, no permission", () => {
expect(can(null, "dump:moderate")).toBe(false);
expect(can(undefined, "dump:moderate")).toBe(false);
});
});
describe("proxyImageUrl", () => {
test("http images go through the proxy; https ones don't", () => {
expect(proxyImageUrl("http://img.test/a.png")).toBe(
`${API_URL}/api/proxy-image?url=${encodeURIComponent("http://img.test/a.png")}`,
);
expect(proxyImageUrl("https://img.test/a.png")).toBe(
"https://img.test/a.png",
);
});
test("force proxies https too", () => {
expect(proxyImageUrl("https://img.test/a.png", { force: true })).toContain(
"/api/proxy-image?url=",
);
});
test("relative, local and non-http URLs are left alone", () => {
expect(proxyImageUrl("/api/files/x")).toBe("/api/files/x");
expect(proxyImageUrl("http://localhost:8000/a.png")).toBe(
"http://localhost:8000/a.png",
);
expect(proxyImageUrl("http://127.0.0.1/a.png")).toBe("http://127.0.0.1/a.png");
expect(proxyImageUrl("data:image/png;base64,AA")).toBe(
"data:image/png;base64,AA",
);
});
});
describe("canProxyImage", () => {
test("third-party http(s) only", () => {
expect(canProxyImage("https://elsewhere.test/a.png")).toBe(true);
expect(canProxyImage(`${API_URL}/api/files/x`)).toBe(false);
expect(canProxyImage("/relative.png")).toBe(false);
expect(canProxyImage("data:image/png;base64,AA")).toBe(false);
expect(canProxyImage("http://[bad")).toBe(false);
});
});
describe("relativeTime", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-15T12:00:00Z"));
});
afterEach(() => vi.useRealTimers());
const ago = (s: number) => new Date(Date.now() - s * 1000);
test("picks the largest sensible unit", () => {
expect(relativeTime(ago(0))).toBe("now");
expect(relativeTime(ago(30))).toBe("30 seconds ago");
expect(relativeTime(ago(120))).toBe("2 minutes ago");
expect(relativeTime(ago(3 * 3600))).toBe("3 hours ago");
expect(relativeTime(ago(86400))).toBe("yesterday");
expect(relativeTime(ago(14 * 86400))).toBe("2 weeks ago");
expect(relativeTime(ago(60 * 86400))).toBe("2 months ago");
expect(relativeTime(ago(800 * 86400))).toBe("2 years ago");
});
test("follows the active locale", () => {
try {
i18n.load("fr", {});
i18n.activate("fr");
expect(relativeTime(ago(120))).toBe("il y a 2 minutes");
} finally {
i18n.activate("en");
}
});
});
describe("thumbnail tint", () => {
test("isSafeHex accepts only canonical #rrggbb", () => {
expect(isSafeHex("#a1b2c3")).toBe(true);
expect(isSafeHex("#A1B2C3")).toBe(false);
expect(isSafeHex("#abc")).toBe(false);
expect(isSafeHex("red")).toBe(false);
expect(isSafeHex("#000000;background:url(x)")).toBe(false);
expect(isSafeHex(undefined)).toBe(false);
});
test("hueFromString is stable and in range", () => {
expect(hueFromString("example.com")).toBe(hueFromString("example.com"));
const hue = Number(/hsl\((\d+)/.exec(hueFromString("x"))![1]);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
test("tintFor prefers the page colour, then the hostname sans www", () => {
expect(tintFor({ accentColor: "#112233", url: "https://a.b" })).toBe(
"#112233",
);
expect(tintFor({ accentColor: "evil", url: "https://www.a.b/x" })).toBe(
hueFromString("a.b"),
);
expect(tintFor({ seed: "file.mp3" })).toBe(hueFromString("file.mp3"));
expect(tintFor({ url: "not a url" })).toBe(hueFromString("not a url"));
});
test("initialsFor takes the first letter or digit", () => {
expect(initialsFor(" bandcamp", undefined)).toBe("B");
expect(initialsFor(undefined, "https://www.youtube.com/x")).toBe("Y");
expect(initialsFor("«Élan»", undefined)).toBe("É");
expect(initialsFor("", "")).toBe("?");
});
});
describe("urls", () => {
test("dumpFileUrl versions by size and carries the token", () => {
expect(dumpFileUrl({ id: "d1", fileSize: 42 })).toBe(
`${API_URL}/api/files/d1?v=42`,
);
expect(dumpFileUrl({ id: "d1" }, "t o+k")).toBe(
`${API_URL}/api/files/d1?v=0&token=t%20o%2Bk`,
);
});
test("dumpThumbnailUrl", () => {
expect(dumpThumbnailUrl({ id: "d1" })).toBe(`${API_URL}/api/thumbnails/d1`);
expect(dumpThumbnailUrl({ id: "d1" }, "tk")).toBe(
`${API_URL}/api/thumbnails/d1?token=tk`,
);
});
test("normalizeUrl adds a scheme when there's none", () => {
expect(normalizeUrl(" example.com/x ")).toBe("https://example.com/x");
expect(normalizeUrl("//cdn.test/a")).toBe("https://cdn.test/a");
expect(normalizeUrl("HTTP://A.B")).toBe("HTTP://A.B");
expect(normalizeUrl(" ")).toBe("");
});
test("slug beats id in page URLs", () => {
expect(dumpUrl({ id: "1", slug: "nice-title" })).toBe("/dumps/nice-title");
expect(dumpUrl({ id: "1" })).toBe("/dumps/1");
expect(playlistUrl({ id: "2", slug: "mix" })).toBe("/playlists/mix");
expect(playlistUrl({ id: "2" })).toBe("/playlists/2");
});
});