Files
gerbeur/src/test/components/player.test.tsx
khannurien 54e86c4366
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
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.
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>
2026-09-27 14:35:41 +00:00

243 lines
10 KiB
TypeScript

import { describe, expect, test, vi } from "vitest";
import { act, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useContext } from "react";
import { GlobalPlayer } from "../../components/GlobalPlayer.tsx";
import { JournalCard } from "../../components/JournalCard.tsx";
import { PlaylistCard } from "../../components/PlaylistCard.tsx";
import { PlayerProvider } from "../../contexts/PlayerProvider.tsx";
import { PlayerContext, type PlayerItem } from "../../contexts/PlayerContext.ts";
import { makeDump, makePlaylist } from "../fixtures.ts";
import { currentPath, renderWithContexts } from "../render.tsx";
import { markPlaylistVisited } from "../../utils/visited.ts";
let player!: React.ContextType<typeof PlayerContext>;
function Probe() {
player = useContext(PlayerContext);
return null;
}
function renderPlayer() {
return renderWithContexts(
<PlayerProvider>
<Probe />
<GlobalPlayer />
</PlayerProvider>,
);
}
const track = (i: number): PlayerItem => ({
kind: "stream",
type: "bandcamp",
streamUrl: `https://s/${i}`,
resolveUrl: "https://a.bandcamp.com/album/x",
resolveIndex: i,
resolvedAt: Date.now(),
title: `Song ${i}`,
trackNum: i + 1,
duration: 61,
subtitle: "Band",
dumpHref: "/dumps/album",
});
describe("GlobalPlayer", () => {
test("hidden until something plays; the body makes room for it", () => {
renderPlayer();
expect(document.querySelector(".global-player")).toBeNull();
act(() => player.play({ kind: "embed", embedUrl: "https://w.soundcloud.com/player/?url=x", type: "soundcloud", title: "Mix" }));
expect(document.querySelector(".global-player--soundcloud")).not.toBeNull();
expect(document.body).toHaveClass("has-player");
act(() => player.stop());
expect(document.querySelector(".global-player")).toBeNull();
expect(document.body).not.toHaveClass("has-player");
});
test("an embed autoplays on a fresh play only", () => {
localStorage.setItem("player", JSON.stringify({
v: 2,
queue: [{ kind: "embed", embedUrl: "https://www.youtube.com/embed/abc", type: "youtube" }],
index: 0,
time: 0,
owner: null,
}));
renderPlayer();
const iframe = () => document.querySelector("iframe")!;
expect(iframe().src).toBe("https://www.youtube.com/embed/abc");
act(() => player.play({ kind: "embed", embedUrl: "https://www.youtube.com/embed/def", type: "youtube" }));
expect(new URL(iframe().src).searchParams.get("autoplay")).toBe("1");
act(() => player.play({ kind: "embed", embedUrl: "https://w.soundcloud.com/player/?url=y", type: "soundcloud" }));
expect(new URL(iframe().src).searchParams.get("auto_play")).toBe("true");
act(() => player.play({ kind: "embed", embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1", type: "bandcamp" }));
expect(iframe().src).toBe("https://bandcamp.com/EmbeddedPlayer/album=1");
});
test("a YouTube playlist embed gets the JS API parameters", () => {
renderPlayer();
act(() => player.play({ kind: "embed", embedUrl: "https://www.youtube.com/embed/videoseries?list=PL", type: "youtube" }));
const src = new URL(document.querySelector("iframe")!.src);
expect(src.searchParams.get("enablejsapi")).toBe("1");
expect(src.searchParams.get("list")).toBe("PL");
});
test("a queue: title links to the dump, tracklist, transport", async () => {
const user = userEvent.setup();
renderPlayer();
act(() => player.playQueue([track(0), track(1), track(2)], 0));
expect(screen.getByRole("link", { name: "Song 0" })).toHaveAttribute("href", "/dumps/album");
expect(screen.getByText("Band")).toBeInTheDocument();
expect(screen.getByText("1 / 3")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Previous track" })).toBeDisabled();
const list = screen.getByRole("list");
expect(within(list).getAllByRole("button")).toHaveLength(3);
expect(within(list).getAllByText("1:01")).toHaveLength(3);
await user.click(screen.getByRole("button", { name: "Next track" }));
expect(screen.getByText("2 / 3")).toBeInTheDocument();
await user.click(within(list).getByRole("button", { name: /Song 2/ }));
expect(screen.getByText("3 / 3")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Next track" })).toBeDisabled();
expect(list.querySelectorAll(".is-current")).toHaveLength(1);
});
test("collapse hides the tracklist; a new item opens it again", async () => {
const user = userEvent.setup();
renderPlayer();
act(() => player.playQueue([track(0), track(1)]));
await user.click(screen.getByRole("button", { name: "Collapse player" }));
expect(screen.queryByRole("list")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Expand player" })).toBeInTheDocument();
act(() => player.playQueue([track(2), track(3)]));
expect(screen.getByRole("list")).toBeInTheDocument();
});
test("the close button stops playback", async () => {
const user = userEvent.setup();
renderPlayer();
act(() => player.play(track(0)));
await user.click(screen.getByRole("button", { name: "Close player" }));
expect(player.current).toBeNull();
});
test("a file plays in the built-in player", () => {
renderPlayer();
act(() => player.play({ kind: "file", fileUrl: "http://localhost:8000/api/files/x", mimeType: "video/mp4", title: "Clip" }));
expect(document.querySelector(".global-player--file-video video")).not.toBeNull();
expect(screen.getByText("Clip")).toBeInTheDocument();
});
test("the resolving notice", async () => {
renderPlayer();
act(() => player.play(track(0)));
// Force a re-resolve that never lands.
const { route, deferred } = await import("../fetch.ts");
const pending = deferred();
route("GET /api/bandcamp/tracks", () => pending.promise);
act(() => player.onError());
expect(screen.getByText("Refreshing stream…")).toBeInTheDocument();
});
});
describe("JournalCard", () => {
const base = { voteCount: 1, voted: false, canVote: true, castVote: vi.fn(), removeVote: vi.fn() };
test("image, quote and text modes", () => {
const { container, unmount } = renderWithContexts(
<ul>
<JournalCard
dump={makeDump({ richContent: { type: "link", url: "u", thumbnailUrl: "https://i.test/a.png" } })}
shape="feature"
{...base}
/>
<JournalCard dump={makeDump({ comment: "A thought" })} shape="square" {...base} />
<JournalCard dump={makeDump()} shape="square" {...base} />
</ul>,
);
const cards = container.querySelectorAll("li");
expect(cards[0]).toHaveClass("journal-card--image", "journal-card--feature");
expect(cards[1]).toHaveClass("journal-card--quote");
expect(within(cards[1] as HTMLElement).getByText("A thought")).toBeInTheDocument();
expect(cards[2]).toHaveClass("journal-card--text");
expect(cards[2]).toHaveTextContent("🔗");
unmount();
});
test("a playable image card plays; an unplayable one navigates", async () => {
const play = vi.fn();
const user = userEvent.setup();
const yt = makeDump({
id: "y",
richContent: {
type: "youtube",
url: "https://youtu.be/y",
thumbnailUrl: "https://i.test/y.png",
embedUrl: "https://www.youtube.com/embed/y",
},
});
const pic = makeDump({ id: "p", kind: "file", fileMime: "image/png", fileName: "p.png", url: undefined });
const { container } = renderWithContexts(
<ul>
<JournalCard dump={yt} shape="wide" {...base} />
<JournalCard dump={pic} shape="square" {...base} />
</ul>,
{ player: { play } },
);
const cards = container.querySelectorAll("li");
await user.click(cards[0].querySelector(".journal-card-image")!);
expect(play).toHaveBeenCalledWith(expect.objectContaining({ embedUrl: "https://www.youtube.com/embed/y" }));
expect(currentPath()).toBe("/");
await user.click(cards[1].querySelector(".journal-card-image")!);
expect(currentPath()).toBe("/dumps/p");
});
test("a video file shows its generated still", () => {
const { container } = renderWithContexts(
<ul>
<JournalCard dump={makeDump({ id: "v", kind: "file", fileMime: "video/mp4", url: undefined })} shape="tall" {...base} />
</ul>,
);
expect(container.querySelector("img")!.getAttribute("src")).toBe("http://localhost:8000/api/thumbnails/v");
});
});
describe("PlaylistCard", () => {
test("title, badge, owner, count; clicking opens it and marks it seen", async () => {
const user = userEvent.setup();
const p = makePlaylist({ id: "p1", slug: "mix", title: "Mix", ownerUsername: "ann", dumpCount: 1, isPublic: false, description: "Tunes" });
const { container } = renderWithContexts(<ul><PlaylistCard playlist={p} /></ul>);
expect(screen.getByRole("link", { name: /Mix/ })).toHaveAttribute("href", "/playlists/mix");
expect(screen.getByText("private")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "@ann" })).toHaveAttribute("href", "/users/ann");
expect(screen.getByText("1 dump")).toBeInTheDocument();
expect(screen.getByText("Tunes")).toBeInTheDocument();
await user.click(container.querySelector(".playlist-card-inner")!);
expect(currentPath()).toBe("/playlists/mix");
});
test("the owner doesn't see their own name, or an unread dot", () => {
const p = makePlaylist({ ownerUsername: "me", createdAt: new Date() });
const { container } = renderWithContexts(<ul><PlaylistCard playlist={p} isOwner /></ul>);
expect(screen.queryByText("@me")).not.toBeInTheDocument();
expect(container.querySelector(".unread-dot")).toBeNull();
});
test("a new playlist has a dot until visited", () => {
const p = makePlaylist({ id: "fresh", createdAt: new Date() });
const first = renderWithContexts(<ul><PlaylistCard playlist={p} /></ul>);
expect(first.container.querySelector(".unread-dot")).not.toBeNull();
first.unmount();
markPlaylistVisited("fresh");
const again = renderWithContexts(<ul><PlaylistCard playlist={p} /></ul>);
expect(again.container.querySelector(".unread-dot")).toBeNull();
});
test("delete doesn't open the playlist", async () => {
const onDelete = vi.fn();
const user = userEvent.setup();
renderWithContexts(<ul><PlaylistCard playlist={makePlaylist()} onDelete={onDelete} /></ul>);
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
expect(onDelete).toHaveBeenCalled();
expect(currentPath()).toBe("/");
});
});