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:
166
src/test/utils/misc.test.ts
Normal file
166
src/test/utils/misc.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user