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