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,197 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { resolveBandcampQueue } from "../../utils/bandcamp.ts";
import {
isStreamStale,
resolveStreamQueue,
STREAM_STALE_MS,
} from "../../utils/streamSources.ts";
import {
embedListId,
fetchPlaylistTracks,
withJsApi,
} from "../../utils/youtube.ts";
import { extractPeaks } from "../../utils/waveform.ts";
import { calls, fail, route } from "../fetch.ts";
import type { PlayerItem } from "../../contexts/PlayerContext.ts";
import { playerItemKey } from "../../contexts/PlayerContext.ts";
afterEach(() => vi.useRealTimers());
const album = {
sourceUrl: "https://artist.bandcamp.com/album/a",
itemType: "album",
title: "A",
artist: "Artist",
artworkUrl: "https://f4.bcbits.com/a.jpg",
resolvedAt: 1000,
tracks: [
{ index: 0, trackNum: 1, title: "One", duration: 100, streamUrl: "https://s/1", streamable: true, capped: false },
{ index: 1, trackNum: 2, title: "Two", streamUrl: null, streamable: false, capped: true },
{ index: 2, trackNum: 3, title: "Three", duration: 50, streamUrl: "https://s/3", streamable: true, capped: false },
],
};
describe("resolveBandcampQueue", () => {
test("turns the streamable tracks into a queue", async () => {
route("GET /api/bandcamp/tracks", () => album);
const items = await resolveBandcampQueue(album.sourceUrl, {
dumpHref: "/dumps/x",
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
});
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
kind: "stream",
type: "bandcamp",
streamUrl: "https://s/1",
title: "One",
subtitle: "Artist",
trackNum: 1,
duration: 100,
artworkUrl: album.artworkUrl,
dumpHref: "/dumps/x",
resolveUrl: album.sourceUrl,
resolveIndex: 0,
resolvedAt: 1000,
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
});
expect(items[1]).toMatchObject({ resolveIndex: 2 });
const [req] = calls("GET /api/bandcamp/tracks");
expect(req.url.searchParams.get("url")).toBe(album.sourceUrl);
expect(req.url.searchParams.has("force")).toBe(false);
});
test("force asks the server to skip its cache", async () => {
route("GET /api/bandcamp/tracks", () => album);
await resolveBandcampQueue(album.sourceUrl, {}, { force: true });
expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force"))
.toBe("1");
});
test("falls back to the given artwork", async () => {
route("GET /api/bandcamp/tracks", () => ({ ...album, artworkUrl: undefined }));
const [item] = await resolveBandcampQueue(album.sourceUrl, {
fallbackArtworkUrl: "https://mine/art.png",
});
expect(item.artworkUrl).toBe("https://mine/art.png");
});
test("throws on an HTTP error, an error envelope, or nothing to play", async () => {
route("GET /api/bandcamp/tracks", () => fail("down", 502), { once: true });
await expect(resolveBandcampQueue("u")).rejects.toThrow(/502/);
route("GET /api/bandcamp/tracks", () => fail("nope", 200), { once: true });
await expect(resolveBandcampQueue("u")).rejects.toThrow(/no data/);
route("GET /api/bandcamp/tracks", () => ({
...album,
tracks: [album.tracks[1]],
}));
await expect(resolveBandcampQueue("u")).rejects.toThrow(/no streamable/);
});
});
describe("stream sources", () => {
const stream: Extract<PlayerItem, { kind: "stream" }> = {
kind: "stream",
type: "bandcamp",
streamUrl: "https://s/1",
resolveUrl: album.sourceUrl,
resolveIndex: 0,
resolvedAt: Date.now(),
artworkUrl: "https://old/art.png",
dumpHref: "/dumps/x",
};
test("re-resolving a bandcamp stream forces a fresh lookup and keeps the art", async () => {
route("GET /api/bandcamp/tracks", () => ({ ...album, artworkUrl: undefined }));
const items = await resolveStreamQueue(stream);
expect(items[0].artworkUrl).toBe("https://old/art.png");
expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force"))
.toBe("1");
});
test("an unknown stream type has no resolver", async () => {
await expect(resolveStreamQueue({ ...stream, type: "other" })).rejects
.toThrow(/no resolver/);
});
test("isStreamStale after the window", () => {
vi.useFakeTimers();
vi.setSystemTime(10 * STREAM_STALE_MS);
expect(isStreamStale({ ...stream, resolvedAt: Date.now() - 1000 })).toBe(false);
expect(
isStreamStale({ ...stream, resolvedAt: Date.now() - STREAM_STALE_MS - 1 }),
).toBe(true);
});
test("playerItemKey ignores the signed URL", () => {
expect(playerItemKey(stream)).toBe(
playerItemKey({ ...stream, streamUrl: "https://s/refreshed" }),
);
expect(playerItemKey({ kind: "file", fileUrl: "f", mimeType: "audio/mpeg" }))
.toBe("file:f");
expect(playerItemKey({ kind: "embed", embedUrl: "e", type: "youtube" }))
.toBe("embed:e");
});
});
describe("youtube", () => {
test("embedListId reads ?list= from either embed shape", () => {
expect(embedListId("https://www.youtube.com/embed/videoseries?list=PL1"))
.toBe("PL1");
expect(embedListId("https://www.youtube.com/embed/abc?list=PL2")).toBe("PL2");
expect(embedListId("https://www.youtube.com/embed/abc")).toBeNull();
expect(embedListId("nonsense")).toBeNull();
});
test("withJsApi adds the API parameters and our origin", () => {
const url = new URL(withJsApi("https://www.youtube.com/embed/abc?list=PL"));
expect(url.searchParams.get("enablejsapi")).toBe("1");
expect(url.searchParams.get("origin")).toBe(location.origin);
expect(url.searchParams.get("list")).toBe("PL");
expect(withJsApi("bad url")).toBe("bad url");
});
test("fetchPlaylistTracks", async () => {
route("GET /api/youtube/playlist", (req) =>
req.url.searchParams.get("list") === "PL"
? { tracks: [{ videoId: "a", title: "A", duration: 3 }] }
: { tracks: [] });
await expect(fetchPlaylistTracks("PL")).resolves.toEqual([
{ videoId: "a", title: "A", duration: 3 },
]);
await expect(fetchPlaylistTracks("EMPTY")).rejects.toThrow(/no tracks/);
route("GET /api/youtube/playlist", () => fail("x", 500));
await expect(fetchPlaylistTracks("PL")).rejects.toThrow(/500/);
});
});
describe("extractPeaks", () => {
test("decodes once per URL and takes each block's peak", async () => {
const data = new Float32Array([0.1, -0.5, 0.2, 0.9, -0.3, 0.4]);
const decode = vi.fn(async () => ({ getChannelData: () => data }));
const close = vi.fn();
vi.stubGlobal(
"AudioContext",
class {
decodeAudioData = decode;
close = close;
},
);
route("GET /api/files/:id", () => new Response(new Uint8Array([1, 2])));
const peaks = await extractPeaks("http://localhost:8000/api/files/w1", 3);
expect([...peaks].map((p) => +p.toFixed(2))).toEqual([0.5, 0.9, 0.4]);
expect(close).toHaveBeenCalled();
await extractPeaks("http://localhost:8000/api/files/w1", 3);
expect(decode).toHaveBeenCalledTimes(1);
expect(calls("GET /api/files/:id")).toHaveLength(1);
});
test("a failed fetch throws", async () => {
route("GET /api/files/:id", () => fail("gone", 404));
await expect(extractPeaks("http://localhost:8000/api/files/w2", 3)).rejects
.toThrow(/404/);
});
});