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:
242
src/test/components/player.test.tsx
Normal file
242
src/test/components/player.test.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
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("/");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user