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,304 @@
import { describe, expect, test, vi } from "vitest";
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChatModal } from "../../components/ChatModal.tsx";
import type { AuthResponse, ChatMessage } from "../../model.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeChatMessage, makeSession, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const me = makeSession({ id: "me", username: "me" });
async function setup(
history: ChatMessage[] = [],
{ session = me, welcome = {} as Record<string, unknown> } = {},
) {
route("GET /api/chat", () => wire(history));
const onClose = vi.fn();
const user = userEvent.setup();
renderApp(<ChatModal onClose={onClose} />, { session });
const socket = FakeWebSocket.last;
socket.welcome(welcome);
await screen.findByRole("dialog", { name: "Chat" });
await waitFor(() =>
expect(screen.queryByText("Loading messages…")).not.toBeInTheDocument()
);
const composer = screen.getByPlaceholderText("Type a message…");
return { user, socket, onClose, composer };
}
const sent = (socket: FakeWebSocket, type: string) =>
socket.messages.filter((m) => m.type === type);
describe("ChatModal: history", () => {
test("shows the latest messages under a day separator, grouping runs", async () => {
const t0 = Date.now() - 60_000;
await setup([
makeChatMessage({ id: "1", userId: "ann", authorUsername: "ann", body: "one", createdAt: new Date(t0) }),
makeChatMessage({ id: "2", userId: "ann", authorUsername: "ann", body: "two", createdAt: new Date(t0 + 1000) }),
makeChatMessage({ id: "3", userId: "bob", authorUsername: "bob", body: "three", createdAt: new Date(t0 + 2000) }),
]);
expect(screen.getByText("Today")).toBeInTheDocument();
// "two" continues ann's run: no second author link.
expect(screen.getAllByRole("link", { name: "ann" })).toHaveLength(1);
expect(screen.getByText("two").closest(".chat-message")).toHaveClass("chat-message--grouped");
expect(calls("GET /api/chat")[0].url.searchParams.get("limit")).toBe("50");
});
test("empty and failed states", async () => {
route("GET /api/chat", () => fail("down", 500), { once: true });
route("GET /api/chat", () => []);
const user = userEvent.setup();
renderApp(<ChatModal onClose={() => {}} />, { session: me });
await screen.findByText("Couldn't load the chat.");
await user.click(screen.getByRole("button", { name: "Try again" }));
await screen.findByText("No messages yet. Say hello!");
});
test("a full page offers older messages", async () => {
const page = Array.from({ length: 50 }, (_, i) =>
makeChatMessage({ id: `m${i + 10}`, body: `msg ${i + 10}`, createdAt: new Date(Date.now() - (50 - i) * 1000) })
);
route("GET /api/chat", (req) =>
req.url.searchParams.has("before")
? wire([makeChatMessage({ id: "m0", body: "the oldest", createdAt: new Date(Date.now() - 999_000) })])
: wire(page));
const user = userEvent.setup();
renderApp(<ChatModal onClose={() => {}} />, { session: me });
await user.click(await screen.findByRole("button", { name: "Load older messages" }));
await screen.findByText("the oldest");
const req = calls("GET /api/chat").find((r) => r.url.searchParams.has("before"))!;
expect(req.url.searchParams.get("beforeId")).toBe("m10");
expect(screen.queryByRole("button", { name: "Load older messages" })).not.toBeInTheDocument();
});
test("the 'new' divider sits before the first unread message from someone else", async () => {
const readAt = new Date(Date.now() - 30_000);
await setup([
makeChatMessage({ id: "old", body: "seen", createdAt: new Date(Date.now() - 60_000) }),
makeChatMessage({ id: "new1", body: "unseen", createdAt: new Date(Date.now() - 10_000) }),
], { welcome: { chatReadAt: readAt.toISOString() } });
const divider = document.querySelector(".chat-new-sep")!;
expect(divider).toHaveTextContent("New");
expect(divider.nextElementSibling).toHaveTextContent("unseen");
});
test("reading at the bottom moves the read marker", async () => {
const { socket } = await setup([makeChatMessage({ body: "hi" })]);
await waitFor(() => expect(sent(socket, "chat_read")).toHaveLength(1));
});
test("tells the server the chat is open, and closed", async () => {
const { socket } = await setup();
expect(sent(socket, "chat_focus")).toContainEqual({ type: "chat_focus", open: true });
});
});
describe("ChatModal: sending", () => {
test("Enter sends; the message shows as sending until its echo", async () => {
const { user, socket, composer } = await setup();
await user.type(composer, "hello there{Enter}");
expect(composer).toHaveValue("");
const [send] = sent(socket, "chat_send");
expect(send).toMatchObject({ body: "hello there" });
expect(screen.getByText("Sending…")).toBeInTheDocument();
socket.receive({
type: "chat_message",
nonce: send.nonce,
message: wire(makeChatMessage({ id: "echo", userId: "me", authorUsername: "me", body: "hello there", createdAt: new Date() })),
});
expect(screen.queryByText("Sending…")).not.toBeInTheDocument();
expect(screen.getByText("hello there")).toBeInTheDocument();
});
test("Shift+Enter is a new line; blank and over-long messages don't send", async () => {
const { user, socket, composer } = await setup();
await user.type(composer, "line one{Shift>}{Enter}{/Shift}line two");
expect(composer).toHaveValue("line one\nline two");
await user.clear(composer);
await user.type(composer, " {Enter}");
expect(sent(socket, "chat_send")).toHaveLength(0);
await user.clear(composer);
await user.click(composer);
await user.paste("x".repeat(2001));
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText("2001 / 2000")).toBeInTheDocument();
});
test("a failed send can be retried, taken back or dropped", async () => {
const { user, socket, composer } = await setup();
await user.type(composer, "oops{Enter}");
const [send] = sent(socket, "chat_send");
socket.receive({ type: "error", scope: "chat", nonce: send.nonce, message: "Slow down" });
const failed = screen.getByText("Slow down").closest(".chat-message")! as HTMLElement;
await user.click(within(failed).getByRole("button", { name: "Retry" }));
expect(sent(socket, "chat_send")).toHaveLength(2);
const [, retry] = sent(socket, "chat_send");
socket.receive({ type: "error", scope: "chat", nonce: retry.nonce, message: "Still no" });
await user.type(composer, "draft");
await user.click(screen.getByRole("button", { name: "Edit" }));
expect(composer).toHaveValue("draft\n\noops");
expect(screen.queryByText("Still no")).not.toBeInTheDocument();
});
test("typing is announced, and its end too", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"], shouldAdvanceTime: true });
try {
const { socket, composer } = await setup();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await user.type(composer, "abc");
expect(sent(socket, "chat_typing")).toEqual([{ type: "chat_typing", typing: true }]);
act(() => vi.advanceTimersByTime(3_600));
expect(sent(socket, "chat_typing").at(-1)).toEqual({ type: "chat_typing", typing: false });
} finally {
vi.useRealTimers();
}
});
test("the draft and reply outlive the chat", async () => {
const first = await setup([makeChatMessage({ id: "a", authorUsername: "ann", body: "question?" })]);
await first.user.type(first.composer, "half a thought");
const stored = JSON.parse(sessionStorage.getItem("chat-draft:me")!);
expect(stored.draft).toBe("half a thought");
});
});
describe("ChatModal: replies, edits, deletes", () => {
const theirs = () =>
makeChatMessage({ id: "t1", userId: "ann", authorUsername: "ann", body: "ask me **anything**", createdAt: new Date(Date.now() - 20_000) });
const mine = () =>
makeChatMessage({ id: "m1", userId: "me", authorUsername: "me", body: "my words", createdAt: new Date(Date.now() - 10_000) });
test("replying: the bar, the reference, and Escape cancelling just the reply", async () => {
const { user, socket, composer, onClose } = await setup([theirs()]);
const msg = screen.getByText("anything").closest(".chat-message")! as HTMLElement;
await user.click(within(msg).getByRole("button", { name: "Reply" }));
expect(screen.getByText("Replying to")).toBeInTheDocument();
expect(document.querySelector(".chat-reply-bar-snippet")).toHaveTextContent("ask me anything");
await user.keyboard("{Escape}");
expect(screen.queryByText("Replying to")).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
await user.click(within(msg).getByRole("button", { name: "Reply" }));
await user.type(composer, "sure{Enter}");
expect(sent(socket, "chat_send")[0]).toMatchObject({ body: "sure", replyToId: "t1" });
});
test("only your own messages can be edited and deleted", async () => {
await setup([theirs(), mine()]);
const t = screen.getByText("anything").closest(".chat-message")! as HTMLElement;
const m = screen.getByText("my words").closest(".chat-message")! as HTMLElement;
expect(within(t).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
expect(within(m).getByRole("button", { name: "Edit" })).toBeInTheDocument();
expect(within(m).getByRole("button", { name: "Delete" })).toBeInTheDocument();
});
test("moderators can delete anyone's", async () => {
await setup([theirs()], { session: makeSession({ id: "mod", role: "moderator" }) as AuthResponse });
const t = screen.getByText("anything").closest(".chat-message")! as HTMLElement;
expect(within(t).getByRole("button", { name: "Delete" })).toBeInTheDocument();
});
test("Up in an empty composer edits your last message; Enter saves", async () => {
const { user, socket, composer } = await setup([theirs(), mine()]);
await user.click(composer);
await user.keyboard("{ArrowUp}");
const box = await screen.findByDisplayValue("my words");
await user.clear(box);
await user.type(box, "better words{Enter}");
expect(sent(socket, "chat_edit")).toEqual([{ type: "chat_edit", id: "m1", body: "better words" }]);
expect(screen.queryByDisplayValue("better words")).not.toBeInTheDocument();
});
test("an unchanged edit sends nothing; Escape leaves the edit, not the chat", async () => {
const { user, socket, onClose } = await setup([mine()]);
const m = screen.getByText("my words").closest(".chat-message")! as HTMLElement;
await user.click(within(m).getByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(sent(socket, "chat_edit")).toHaveLength(0);
await user.click(within(m).getByRole("button", { name: "Edit" }));
await user.keyboard("{Escape}");
expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
test("deleting asks first; Escape on the question keeps the chat open", async () => {
const { user, socket, onClose } = await setup([mine()]);
const m = screen.getByText("my words").closest(".chat-message")! as HTMLElement;
await user.click(within(m).getByRole("button", { name: "Delete" }));
expect(screen.getByRole("alertdialog")).toHaveTextContent("Delete this message?");
await user.keyboard("{Escape}");
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
await user.click(within(m).getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" }));
expect(sent(socket, "chat_delete")).toEqual([{ type: "chat_delete", id: "m1" }]);
socket.receive({ type: "chat_message_deleted", id: "m1" });
expect(screen.queryByText("my words")).not.toBeInTheDocument();
});
test("a reply to a deleted message says so", async () => {
const { socket } = await setup([
theirs(),
makeChatMessage({ id: "r", body: "a reply", replyToId: "t1", replyToAuthor: "ann", replyToBody: "ask me" }),
]);
socket.receive({ type: "chat_message_deleted", id: "t1" });
expect(screen.getByText("deleted message").closest("button")).toBeDisabled();
});
});
describe("ChatModal: live", () => {
test("new messages arrive, and a mention of me stands out", async () => {
const { socket } = await setup();
socket.receive({
type: "chat_message",
message: wire(makeChatMessage({ id: "x", body: "hey @me look", createdAt: new Date() })),
});
expect(screen.getByText(/look/).closest(".chat-message")).toHaveClass("chat-message--mention");
});
test("an emoji-only message is shown large", async () => {
await setup([makeChatMessage({ body: "🎉🎉" })]);
expect(document.querySelector(".chat-message-text--jumbo")).toHaveTextContent("🎉🎉");
});
test("who's typing", async () => {
const { socket } = await setup();
socket.receive({ type: "chat_typing_update", users: [{ userId: "ann", username: "ann", hasAvatar: false }] });
expect(screen.getByText("ann is typing…")).toBeInTheDocument();
socket.receive({
type: "chat_typing_update",
users: [
{ userId: "ann", username: "ann", hasAvatar: false },
{ userId: "bob", username: "bob", hasAvatar: false },
],
});
expect(screen.getByText("ann and bob are typing…")).toBeInTheDocument();
socket.receive({ type: "chat_typing_update", users: [{ userId: "me", username: "me", hasAvatar: false }] });
expect(screen.queryByText(/typing…/)).not.toBeInTheDocument();
});
test("a failed edit or delete is shown until dismissed", async () => {
const { user, socket } = await setup();
socket.receive({ type: "error", scope: "chat", message: "Not allowed" });
expect(screen.getByRole("alert")).toHaveTextContent("Not allowed");
await user.click(screen.getByRole("button", { name: "Dismiss" }));
expect(screen.queryByText("Not allowed")).not.toBeInTheDocument();
});
test("disconnected, it says so; a reconnect resyncs from the oldest loaded message", async () => {
const old = makeChatMessage({ id: "o", body: "kept", createdAt: new Date(Date.now() - 50_000) });
const edited = makeChatMessage({ id: "e", body: "before", createdAt: new Date(Date.now() - 40_000) });
const { socket } = await setup([old, edited]);
route("GET /api/chat", () => wire([old, { ...edited, body: "after" }]));
socket.drop();
expect(screen.getByText("Disconnected — reconnecting…")).toBeInTheDocument();
await act(() => new Promise((r) => setTimeout(r, 600)));
FakeWebSocket.last.welcome();
await screen.findByText("after");
const resync = calls("GET /api/chat").at(-1)!;
expect(resync.url.searchParams.get("after")).toBe(old.createdAt.toISOString());
});
});

View File

@@ -0,0 +1,200 @@
import { describe, expect, test, vi } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { CommentThread } from "../../components/CommentThread.tsx";
import type { AuthResponse, Comment } from "../../model.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeComment, makeSession, wire } from "../fixtures.ts";
import { renderWithContexts } from "../render.tsx";
const me = makeSession({ id: "me", username: "me" });
const mod = makeSession({ id: "mod", username: "mod", role: "moderator" });
function setup(initial: Comment[], session: AuthResponse | null = me, ws = {}) {
const user = userEvent.setup();
function Harness() {
const [comments, setComments] = useState(initial);
return (
<CommentThread
dumpId="d1"
comments={comments}
currentUser={session?.user ?? null}
token={session?.token ?? null}
onCommentCreated={(c) => setComments((prev) => [...prev, c])}
onCommentDeleted={(id) =>
setComments((prev) => prev.map((c) => c.id === id ? { ...c, deleted: true } : c))}
onCommentUpdated={(c) =>
setComments((prev) => prev.map((x) => x.id === c.id ? c : x))}
/>
);
}
renderWithContexts(<Harness />, { session, ws });
return user;
}
const created = (over: Partial<Comment>) =>
(req: { body: unknown }) =>
wire(makeComment({ dumpId: "d1", userId: "me", authorUsername: "me", body: (req.body as { body: string }).body, ...over }));
describe("CommentThread", () => {
test("counts visible comments and nests replies", () => {
setup([
makeComment({ id: "a", body: "top" }),
makeComment({ id: "b", parentId: "a", body: "reply" }),
makeComment({ id: "c", deleted: true, body: "" }),
]);
expect(screen.getByRole("heading", { name: "2 comments" })).toBeInTheDocument();
const top = screen.getByText("top").closest("li")!;
expect(within(top).getByText("reply")).toBeInTheDocument();
expect(screen.getByText("[deleted]")).toBeInTheDocument();
});
test("guests read but can't write, reply or like", () => {
setup([makeComment({ body: "hello" })], null);
expect(screen.queryByPlaceholderText("Add a comment…")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reply" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Like" })).toBeDisabled();
});
test("posting a comment", async () => {
route("POST /api/dumps/:id/comments", created({ id: "new" }));
const user = setup([]);
const box = screen.getByPlaceholderText("Add a comment…");
expect(screen.getByRole("button", { name: "Post comment" })).toBeDisabled();
await user.type(box, "First!");
await user.click(screen.getByRole("button", { name: "Post comment" }));
await screen.findByText("First!", { selector: ".comment-body *" });
expect(box).toHaveValue("");
expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({ body: "First!" });
expect(screen.getByRole("heading", { name: "1 comment" })).toBeInTheDocument();
});
test("Ctrl+Enter posts too; blank text doesn't", async () => {
route("POST /api/dumps/:id/comments", created({}));
const user = setup([]);
const box = screen.getByPlaceholderText("Add a comment…");
await user.type(box, " ");
await user.keyboard("{Control>}{Enter}{/Control}");
expect(calls("POST /api/dumps/:id/comments")).toHaveLength(0);
await user.type(box, "ok");
await user.keyboard("{Control>}{Enter}{/Control}");
await waitFor(() => expect(calls("POST /api/dumps/:id/comments")).toHaveLength(1));
});
test("a failed post keeps the text and says why", async () => {
route("POST /api/dumps/:id/comments", () => fail("Comment too long", 400));
const user = setup([]);
const box = screen.getByPlaceholderText("Add a comment…");
await user.type(box, "text");
await user.click(screen.getByRole("button", { name: "Post comment" }));
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Failed to post comment");
expect(alert).toHaveTextContent("Comment too long");
expect(box).toHaveValue("text");
});
test("the top-level Cancel only shows with text, and clears it", async () => {
const user = setup([]);
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
await user.type(screen.getByPlaceholderText("Add a comment…"), "draft");
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.getByPlaceholderText("Add a comment…")).toHaveValue("");
});
test("replying", async () => {
route("POST /api/dumps/:id/comments", created({ id: "r", parentId: "a" }));
const user = setup([makeComment({ id: "a", body: "parent" })]);
await user.click(screen.getByRole("button", { name: "Reply" }));
const box = screen.getByPlaceholderText("Write a reply…");
await waitFor(() => expect(box).toHaveFocus());
await user.type(box, "child");
await user.click(screen.getByRole("button", { name: "Post reply" }));
await waitFor(() => expect(screen.queryByPlaceholderText("Write a reply…")).not.toBeInTheDocument());
expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({ body: "child", parentId: "a" });
const parent = screen.getByText("parent").closest("li")!;
expect(within(parent).getByText("child")).toBeInTheDocument();
});
test("Escape backs out of a reply", async () => {
const user = setup([makeComment({ id: "a" })]);
await user.click(screen.getByRole("button", { name: "Reply" }));
await user.type(screen.getByPlaceholderText("Write a reply…"), "nah{Escape}");
expect(screen.queryByPlaceholderText("Write a reply…")).not.toBeInTheDocument();
});
test("editing your own comment", async () => {
route("PATCH /api/comments/:id", (req) =>
wire(makeComment({ id: "a", userId: "me", authorUsername: "me", body: (req.body as { body: string }).body, updatedAt: new Date() })));
const user = setup([makeComment({ id: "a", userId: "me", authorUsername: "me", body: "old" })]);
await user.click(screen.getByRole("button", { name: "Edit" }));
const box = screen.getByDisplayValue("old");
await user.clear(box);
await user.type(box, "new");
await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("new");
expect(screen.getByText(/^edited/)).toBeInTheDocument();
});
test("saving an edit unchanged doesn't stamp it edited", async () => {
const user = setup([makeComment({ id: "a", userId: "me", body: "same" })]);
await user.click(screen.getByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument());
expect(calls("PATCH /api/comments/:id")).toHaveLength(0);
});
test("only the author or a moderator can edit and delete", () => {
const comments = [
makeComment({ id: "mine", userId: "me", body: "mine" }),
makeComment({ id: "theirs", userId: "them", body: "theirs" }),
];
setup(comments);
const theirs = screen.getByText("theirs").closest("li")!;
expect(within(theirs).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
expect(within(theirs).queryByRole("button", { name: "Delete" })).not.toBeInTheDocument();
const mine = screen.getByText("mine").closest("li")!;
expect(within(mine).getByRole("button", { name: "Delete" })).toBeInTheDocument();
});
test("a moderator can delete anyone's", () => {
setup([makeComment({ userId: "them", body: "theirs" })], mod);
expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
});
test("deleting asks first", async () => {
route("DELETE /api/comments/:id", () => new Response(null, { status: 204 }));
const user = setup([makeComment({ id: "a", userId: "me", body: "bye" })]);
await user.click(screen.getByRole("button", { name: "Delete" }));
const dialog = screen.getByRole("alertdialog");
expect(dialog).toHaveTextContent("Delete this comment?");
await user.click(within(dialog).getByRole("button", { name: "Delete" }));
await screen.findByText("[deleted]");
});
test("a failed delete says so", async () => {
route("DELETE /api/comments/:id", () => fail("Forbidden", 403));
const user = setup([makeComment({ id: "a", userId: "me", body: "stays" })]);
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" }));
expect(await screen.findByRole("alert")).toHaveTextContent(/couldn't be deleted/i);
expect(screen.getByText("stays")).toBeInTheDocument();
});
test("likes come from the live counts", async () => {
const castCommentLike = vi.fn();
const user = setup([makeComment({ id: "a", likeCount: 1 })], me, {
likeCounts: { a: 5 },
castCommentLike,
});
expect(screen.getByText("5")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Like" }));
expect(castCommentLike).toHaveBeenCalledWith("a", 5);
});
test("the timestamp links to the comment", () => {
setup([makeComment({ id: "a" })]);
expect(screen.getByRole("link", { name: /ago/ })).toHaveAttribute("href", "/dumps/d1#comment-a");
});
});

View File

@@ -0,0 +1,336 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DumpCreateModal } from "../../components/DumpCreateModal.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makeCategory, makeDump, makePlaylist, makeSession, wire } from "../fixtures.ts";
import { currentPath, renderWithContexts } from "../render.tsx";
const session = makeSession({ username: "me" });
const categories = [makeCategory({ id: "c1", name: "Music" })];
function setup(props: Partial<React.ComponentProps<typeof DumpCreateModal>> = {}) {
const onClose = vi.fn();
const injectDump = vi.fn();
const user = userEvent.setup();
renderWithContexts(<DumpCreateModal onClose={onClose} {...props} />, {
session,
categories,
ws: { injectDump },
});
return { user, onClose, injectDump };
}
function previewRoutes({ title = "Page title", reached = true, dupes = [] as unknown[] } = {}) {
route("GET /api/preview", (req) => ({
reached,
richContent: reached
? { type: "link", url: req.url.searchParams.get("url"), title, siteName: "Site" }
: { type: "link", url: req.url.searchParams.get("url") },
}));
route("GET /api/dumps/by-url", () => dupes);
}
const nextBtn = () => screen.getByRole("button", { name: "Next →" });
const linkBox = () => screen.getByPlaceholderText("Paste a link…");
describe("DumpCreateModal: a link", () => {
test("Next stays disabled until there is a link or a file", async () => {
const { user } = setup();
expect(screen.getByRole("dialog", { name: "New dump" })).toBeInTheDocument();
expect(nextBtn()).toBeDisabled();
await user.type(linkBox(), "not a url at all");
expect(nextBtn()).toBeDisabled();
});
test("previews the page and suggests its title", async () => {
previewRoutes();
const { user } = setup();
await user.type(linkBox(), "example.com/a");
expect(await screen.findByText("Fetching preview…")).toBeInTheDocument();
// The suggestion is written in an effect just after the field appears.
const title = await screen.findByLabelText("Title");
await waitFor(() => expect(title).toHaveValue("Page title"));
expect(title).toBeDisabled();
const [req] = calls("GET /api/preview");
expect(req.url.searchParams.get("url")).toBe("https://example.com/a");
expect(req.headers.get("Authorization")).toMatch(/^Bearer /);
});
test("an unreachable page says so, but can still be posted", async () => {
previewRoutes({ reached: false });
const { user } = setup();
await user.type(linkBox(), "https://down.test/");
expect(await screen.findByText(/Couldn't load a preview/)).toBeInTheDocument();
expect(nextBtn()).toBeEnabled();
});
test("warns about an earlier dump of the same link", async () => {
previewRoutes({
dupes: [
{ id: "old", slug: "old-one", title: "Old one", username: "ann", createdAt: new Date(Date.now() - 3600_000).toISOString(), voteCount: 1, commentCount: 0 },
{ id: "older", title: "Older", username: "bob", createdAt: new Date(0).toISOString(), voteCount: 0, commentCount: 0 },
],
});
const { user, onClose } = setup();
await user.type(linkBox(), "https://x.test/");
const notice = await screen.findByText(/Already dumped by ann/);
expect(notice.closest("[role=status]")).toHaveTextContent("and 1 earlier dump");
await user.click(screen.getByRole("link", { name: "Old one" }));
expect(currentPath()).toBe("/dumps/old-one");
expect(onClose).toHaveBeenCalled();
});
test("the whole way through: post, then add to a playlist", async () => {
previewRoutes();
const created = makeDump({ id: "new", slug: "page-title", title: "Page title", userId: session.user.id });
route("POST /api/dumps", () => wire(created));
const mix = makePlaylist({ id: "p1", title: "Mix" });
route("GET /api/playlists/by-dump/:id/memberships", () => [{ playlist: wire(mix), hasDump: false }]);
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
const { user, injectDump, onClose } = setup();
await user.type(linkBox(), "example.com/a");
await screen.findByLabelText("Title");
await user.click(nextBtn());
expect(screen.getByText(/example\.com\/a|Page title/)).toBeInTheDocument();
const why = screen.getByLabelText("Why?");
await waitFor(() => expect(why).toHaveFocus());
await user.type(why, " Because ");
await user.click(screen.getByRole("button", { name: "Music" }));
await user.click(screen.getByRole("button", { name: "Private" }));
await user.click(screen.getByRole("button", { name: "Dump it" }));
await screen.findByRole("dialog", { name: "Add to playlist" });
expect(calls("POST /api/dumps")[0].body).toEqual({
url: "https://example.com/a",
comment: "Because",
isPrivate: true,
categoryIds: ["c1"],
});
expect(injectDump).toHaveBeenCalledWith(expect.objectContaining({ id: "new" }));
expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull();
const row = await screen.findByRole("checkbox", { name: /Mix/ });
expect(row).toHaveAttribute("aria-checked", "false");
row.focus();
await user.keyboard(" ");
await waitFor(() => expect(row).toHaveAttribute("aria-checked", "true"));
expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/p1/dumps/new");
await user.click(screen.getByRole("button", { name: "Done" }));
expect(onClose).toHaveBeenCalled();
});
test("a refused playlist toggle leaves the box as it was", async () => {
previewRoutes();
route("POST /api/dumps", () => wire(makeDump({ id: "new" })));
route("GET /api/playlists/by-dump/:id/memberships", () => [{ playlist: wire(makePlaylist({ title: "Mix" })), hasDump: false }]);
route("POST /api/playlists/:pid/dumps/:did", () => fail("Forbidden", 403));
const { user } = setup();
await user.type(linkBox(), "https://a.test/{Enter}");
await user.click(await screen.findByRole("button", { name: "Dump it" }));
const row = await screen.findByRole("checkbox", { name: /Mix/ });
await user.click(row);
await act(async () => {});
expect(row).toHaveAttribute("aria-checked", "false");
});
test("a title typed by the poster is sent; a suggested one isn't", async () => {
previewRoutes();
route("POST /api/dumps", () => wire(makeDump()));
route("GET /api/playlists/by-dump/:id/memberships", () => []);
const { user } = setup();
await user.type(linkBox(), "https://a.test/");
await waitFor(() => expect(screen.getByLabelText("Title")).toHaveValue("Page title"));
await user.click(screen.getByRole("button", { name: "Edit title" }));
const title = screen.getByLabelText("Title");
await waitFor(() => expect(title).toHaveFocus());
await user.clear(title);
await user.type(title, "My title");
// The suggestion can be had back.
await user.click(screen.getByRole("button", { name: "Reset to the suggested title" }));
expect(title).toHaveValue("Page title");
await user.click(screen.getByRole("button", { name: "Edit title" }));
await user.clear(title);
await user.type(title, "Mine after all");
await user.click(nextBtn());
await user.click(screen.getByRole("button", { name: "Dump it" }));
await waitFor(() => expect(calls("POST /api/dumps")).toHaveLength(1));
expect(calls("POST /api/dumps")[0].body).toMatchObject({ title: "Mine after all" });
});
test("Enter in the link field goes to the next step rather than posting", async () => {
previewRoutes();
const { user } = setup();
await user.type(linkBox(), "https://a.test/{Enter}");
expect(screen.getByLabelText("Why?")).toBeInTheDocument();
expect(calls("POST /api/dumps")).toHaveLength(0);
await user.click(screen.getByRole("button", { name: "← Back" }));
expect(linkBox()).toHaveValue("https://a.test/");
});
test("a failed post shows the server's message and stays put", async () => {
previewRoutes();
route("POST /api/dumps", () => fail("That URL isn't allowed", 400));
const { user } = setup();
await user.type(linkBox(), "https://a.test/{Enter}");
await user.click(screen.getByRole("button", { name: "Dump it" }));
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Failed to post");
expect(alert).toHaveTextContent("That URL isn't allowed");
expect(screen.getByRole("dialog", { name: "New dump" })).toBeInTheDocument();
});
test("starts from a shared URL", async () => {
previewRoutes();
setup({ initialUrl: "https://shared.test/x" });
expect(linkBox()).toHaveValue("https://shared.test/x");
await screen.findByLabelText("Title");
});
test("a link pasted anywhere in the modal lands in the link field", async () => {
previewRoutes();
setup();
act(() => {
const e = new Event("paste", { bubbles: true }) as ClipboardEvent;
Object.defineProperty(e, "clipboardData", {
value: { files: [], getData: () => "pasted.test/page" },
});
screen.getByRole("dialog").dispatchEvent(e);
});
expect(linkBox()).toHaveValue("https://pasted.test/page");
});
});
describe("DumpCreateModal: drafts", () => {
test("a dismissed draft comes back, and can be dropped", async () => {
localStorage.setItem("gerbeur.dumpDraft", JSON.stringify({
url: "https://draft.test/",
title: "",
titleFromUser: false,
comment: "half-written",
isPublic: false,
categoryIds: ["c1"],
}));
previewRoutes();
const { user } = setup();
expect(screen.getByText("Picked up where you left off.")).toBeInTheDocument();
expect(linkBox()).toHaveValue("https://draft.test/");
await user.click(nextBtn());
expect(screen.getByLabelText("Why?")).toHaveValue("half-written");
expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute("aria-pressed", "true");
await user.click(screen.getByRole("button", { name: "← Back" }));
await user.click(screen.getByRole("button", { name: "Start fresh" }));
expect(linkBox()).toHaveValue("");
expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull();
});
test("what's typed is kept as it's typed", async () => {
previewRoutes();
const { user } = setup();
await user.type(linkBox(), "https://keep.test/");
expect(JSON.parse(localStorage.getItem("gerbeur.dumpDraft")!).url).toBe("https://keep.test/");
});
test("a shared URL starts clean rather than merging an old draft", () => {
localStorage.setItem("gerbeur.dumpDraft", JSON.stringify({ url: "https://old.test/", comment: "old why" }));
previewRoutes();
setup({ initialUrl: "https://new.test/" });
expect(screen.queryByText("Picked up where you left off.")).not.toBeInTheDocument();
expect(linkBox()).toHaveValue("https://new.test/");
});
});
describe("DumpCreateModal: a file", () => {
class FakeXHR {
static last: FakeXHR;
status = 0;
statusText = "";
responseText = "";
body: FormData | null = null;
private l: Record<string, Array<(e: unknown) => void>> = {};
private u: Record<string, Array<(e: unknown) => void>> = {};
upload = { addEventListener: (t: string, f: (e: unknown) => void) => (this.u[t] ??= []).push(f) };
constructor() {
FakeXHR.last = this;
}
open() {}
setRequestHeader() {}
addEventListener(t: string, f: (e: unknown) => void) {
(this.l[t] ??= []).push(f);
}
send(body: FormData) {
this.body = body;
}
progress(f: number) {
this.u.progress?.forEach((fn) => fn({ loaded: f * 100, total: 100, lengthComputable: true }));
}
respond(status: number, data: unknown) {
this.status = status;
this.responseText = JSON.stringify(data);
this.l.load?.forEach((fn) => fn({}));
}
}
afterEach(() => vi.unstubAllGlobals());
const song = () => new File([new Uint8Array(10)], "my_song-final.mp3", { type: "audio/mpeg" });
test("a dropped file names the dump and uploads with progress", async () => {
vi.stubGlobal("XMLHttpRequest", FakeXHR);
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
route("GET /api/playlists/by-dump/:id/memberships", () => []);
const { user } = setup();
fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, {
dataTransfer: { files: [song()] },
});
expect(screen.queryByPlaceholderText("Paste a link…")).not.toBeInTheDocument();
expect(screen.getByText("my_song-final.mp3")).toBeInTheDocument();
expect(screen.getByLabelText("Title")).toHaveValue("my song-final");
await user.click(nextBtn());
await user.click(screen.getByRole("button", { name: "Dump it" }));
act(() => FakeXHR.last.progress(0.42));
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "42");
expect(screen.getByRole("button", { name: "Uploading 42%" })).toBeDisabled();
const fd = FakeXHR.last.body!;
expect(fd.get("title")).toBe("my song-final");
expect(fd.get("isPrivate")).toBe("false");
expect(fd.get("categoryIds")).toBe("[]");
await act(async () => FakeXHR.last.respond(201, { success: true, data: wire(makeDump({ id: "f" })) }));
await screen.findByRole("dialog", { name: "Add to playlist" });
});
test("closing with a file attached asks first", async () => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
const { user, onClose } = setup();
fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, {
dataTransfer: { files: [song()] },
});
await user.keyboard("{Escape}");
expect(onClose).not.toHaveBeenCalled();
const confirm = screen.getByText(/The attached file won't be kept/).closest("[role=alert]")!;
await user.click(within(confirm as HTMLElement).getByRole("button", { name: "Keep editing" }));
expect(screen.queryByText(/The attached file won't be kept/)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Cancel" }));
await user.click(screen.getByRole("button", { name: "Close anyway" }));
expect(onClose).toHaveBeenCalled();
});
test("without a file, closing just closes", async () => {
const { user, onClose } = setup();
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalled();
});
test("removing the file offers the link field again", async () => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
const { user } = setup();
fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, {
dataTransfer: { files: [song()] },
});
await user.click(screen.getByRole("button", { name: "Remove file" }));
expect(linkBox()).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,105 @@
import { describe, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { I18nProvider } from "@lingui/react";
import { i18n } from "@lingui/core";
import { useState } from "react";
import { Modal } from "../../components/Modal.tsx";
import { ConfirmModal } from "../../components/ConfirmModal.tsx";
const wrap = (ui: React.ReactNode) =>
render(<I18nProvider i18n={i18n}>{ui}</I18nProvider>);
describe("Modal", () => {
test("is a labelled dialog that locks the page's scroll", () => {
wrap(<Modal title="Settings" onClose={() => {}}>body</Modal>);
expect(screen.getByRole("dialog", { name: "Settings" })).toHaveTextContent(
"body",
);
expect(document.documentElement.style.overflowY).toBe("hidden");
});
test("closes on Escape, the backdrop and the ✕ — not a click inside", async () => {
const onClose = vi.fn();
const user = userEvent.setup();
wrap(<Modal title="T" onClose={onClose}><button type="button">inside</button></Modal>);
await user.click(screen.getByText("inside"));
expect(onClose).not.toHaveBeenCalled();
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "Close" }));
await user.click(document.querySelector(".modal-backdrop")!);
expect(onClose).toHaveBeenCalledTimes(3);
});
test("onBeforeClose can refuse", async () => {
const onClose = vi.fn();
const user = userEvent.setup();
wrap(<Modal title="T" onClose={onClose} onBeforeClose={() => false}>x</Modal>);
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).not.toHaveBeenCalled();
});
test("an Escape something inside already handled is left alone", async () => {
const onClose = vi.fn();
const user = userEvent.setup();
wrap(
<Modal title="T" onClose={onClose}>
<input onKeyDown={(e) => e.key === "Escape" && e.preventDefault()} />
</Modal>,
);
await user.click(screen.getByRole("textbox"));
await user.keyboard("{Escape}");
expect(onClose).not.toHaveBeenCalled();
});
});
describe("ConfirmModal", () => {
test("asks, confirms and cancels", async () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
const user = userEvent.setup();
wrap(
<ConfirmModal message="Really?" onConfirm={onConfirm} onCancel={onCancel} />,
);
const dialog = screen.getByRole("alertdialog");
expect(dialog).toHaveAccessibleDescription("Really?");
await user.click(screen.getByRole("button", { name: "Delete" }));
expect(onConfirm).toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Cancel" }));
await user.keyboard("{Escape}");
await user.click(document.querySelector(".modal-backdrop")!);
expect(onCancel).toHaveBeenCalledTimes(3);
});
test("takes a custom label", () => {
wrap(
<ConfirmModal message="m" confirmLabel="Discard" onConfirm={() => {}} onCancel={() => {}} />,
);
expect(screen.getByRole("button", { name: "Discard" })).toBeInTheDocument();
});
test("Escape on a confirm over a modal closes only the confirm", async () => {
const onClose = vi.fn();
const user = userEvent.setup();
function Stacked() {
const [confirming, setConfirming] = useState(true);
return (
<Modal title="Chat" onClose={onClose}>
{confirming && (
<ConfirmModal
message="Delete this message?"
onConfirm={() => {}}
onCancel={() => setConfirming(false)}
/>
)}
</Modal>
);
}
wrap(<Stacked />);
await user.keyboard("{Escape}");
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: "Chat" })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,295 @@
import { describe, expect, test, vi } from "vitest";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createRef, useState } from "react";
import { TextEditor, type TextEditorHandle } from "../../components/TextEditor.tsx";
import { calls, deferred, fail, route } from "../fetch.ts";
import { makeSession } from "../fixtures.ts";
import { renderWithContexts } from "../render.tsx";
import { serveEmojibase } from "../emojibase.ts";
type Props = Partial<React.ComponentProps<typeof TextEditor>>;
function setup(props: Props = {}, initial = "") {
const onChange = vi.fn();
function Harness() {
const [value, setValue] = useState(initial);
return (
<TextEditor
value={value}
onChange={(v) => {
setValue(v);
onChange(v);
}}
{...props}
/>
);
}
const user = userEvent.setup();
renderWithContexts(<Harness />, { session: makeSession() });
const box = screen.getByRole("textbox") as HTMLTextAreaElement;
return { user, box, onChange };
}
function imageFile(name = "a.png", type = "image/png") {
return new File([new Uint8Array([1, 2, 3])], name, { type });
}
describe("TextEditor basics", () => {
test("types through to onChange", async () => {
const { user, box, onChange } = setup({ placeholder: "Say something" });
expect(box).toHaveAttribute("placeholder", "Say something");
await user.type(box, "hello");
expect(onChange).toHaveBeenLastCalledWith("hello");
expect(box).toHaveValue("hello");
});
test("Ctrl/Cmd+Enter submits; plain Enter goes to onKeyDown", async () => {
const onSubmit = vi.fn();
const onKeyDown = vi.fn();
const { user, box } = setup({ onSubmit, onKeyDown });
await user.type(box, "x");
await user.keyboard("{Control>}{Enter}{/Control}");
expect(onSubmit).toHaveBeenCalledTimes(1);
await user.keyboard("{Meta>}{Enter}{/Meta}");
expect(onSubmit).toHaveBeenCalledTimes(2);
await user.keyboard("{Enter}");
expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" }));
});
test("an Enter confirming an IME candidate does nothing else", () => {
const onSubmit = vi.fn();
const onKeyDown = vi.fn();
const { box } = setup({ onSubmit, onKeyDown });
fireEvent.keyDown(box, { key: "Enter", ctrlKey: true, isComposing: true });
fireEvent.keyDown(box, { key: "Enter", keyCode: 229 });
expect(onSubmit).not.toHaveBeenCalled();
expect(onKeyDown).not.toHaveBeenCalled();
});
test("Escape cancels when there's nothing of its own to close", async () => {
const onCancel = vi.fn();
const { user, box } = setup({ onCancel });
await user.click(box);
await user.keyboard("{Escape}");
expect(onCancel).toHaveBeenCalled();
});
test("the counter shows from 80% of the limit and flags going over", async () => {
const { user, box } = setup({ maxLength: 10 }, "1234567");
expect(screen.queryByText(/\/ 10/)).not.toBeInTheDocument();
await user.type(box, "8");
expect(screen.getByText("8 / 10")).toBeInTheDocument();
await user.type(box, "901");
expect(screen.getByText("11 / 10")).toHaveClass("text-editor-count--danger");
expect(box).toHaveAttribute("aria-invalid", "true");
});
test("countFrom overrides when the counter appears", () => {
setup({ maxLength: 100, countFrom: 0 });
expect(screen.getByText("0 / 100")).toBeInTheDocument();
});
test("the handle focuses at the end", () => {
const ref = createRef<TextEditorHandle>();
renderWithContexts(<TextEditor ref={ref} value="abc" onChange={() => {}} />);
act(() => ref.current!.focus({ atEnd: true }));
const box = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(box).toHaveFocus();
expect(box.selectionStart).toBe(3);
});
test("disabled", () => {
setup({ disabled: true });
expect(screen.getByRole("textbox")).toBeDisabled();
expect(screen.getByRole("button", { name: "Insert emoji" })).toBeDisabled();
});
});
describe("mentions", () => {
const users = [
{ id: "1", username: "alice", avatarMime: null },
{ id: "2", username: "alex", avatarMime: null },
];
test("@ opens a listbox; Enter picks and adds a space", async () => {
route("GET /api/users/search", () => users);
const { user, box } = setup();
await user.type(box, "hi @al");
const list = await screen.findByRole("listbox");
expect(within(list).getAllByRole("option")).toHaveLength(2);
expect(box).toHaveAttribute("aria-activedescendant");
await user.keyboard("{ArrowDown}{Enter}");
expect(box).toHaveValue("hi @alex ");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
test("clicking a suggestion picks it", async () => {
route("GET /api/users/search", () => users);
const { user, box } = setup();
await user.type(box, "@ali");
await user.click(await screen.findByRole("option", { name: /alice/ }));
expect(box).toHaveValue("@alice ");
});
test("Escape closes the list, and only a second Escape cancels", async () => {
route("GET /api/users/search", () => users);
const onCancel = vi.fn();
const { user, box } = setup({ onCancel });
await user.type(box, "@al");
await screen.findByRole("listbox");
await user.keyboard("{Escape}");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
expect(onCancel).not.toHaveBeenCalled();
await user.keyboard("{Escape}");
expect(onCancel).toHaveBeenCalled();
});
test("Enter with the list open picks rather than submitting", async () => {
route("GET /api/users/search", () => users);
const onKeyDown = vi.fn();
const { user, box } = setup({ onKeyDown });
await user.type(box, "@al");
await screen.findByRole("listbox");
await user.keyboard("{Enter}");
expect(onKeyDown).not.toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" }));
expect(box).toHaveValue("@alice ");
});
test("leaving the field closes the list", async () => {
route("GET /api/users/search", () => users);
const { user, box } = setup();
await user.type(box, "@al");
await screen.findByRole("listbox");
fireEvent.blur(box);
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
describe("image upload", () => {
test("a pasted image is uploaded and inserted as markdown", async () => {
route("POST /api/attachments", () => ({ id: "att1" }));
const { user, box } = setup({}, "look: ");
await user.click(box);
box.setSelectionRange(6, 6);
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } });
await waitFor(() =>
expect(box).toHaveValue("look: ![](http://localhost:8000/api/attachments/att1)")
);
const [req] = calls("POST /api/attachments");
expect(req.body).toBeInstanceOf(FormData);
expect(req.headers.get("Authorization")).toMatch(/^Bearer /);
});
test("a paste carrying text pastes the text", () => {
const { box } = setup();
const event = fireEvent.paste(box, {
clipboardData: { types: ["text/plain", "Files"], files: [imageFile()] },
});
expect(event).toBe(true); // not prevented
expect(calls("POST /api/attachments")).toHaveLength(0);
});
test("while uploading: read-only, says so, and holds Enter", async () => {
const pending = deferred();
route("POST /api/attachments", () => pending.promise);
const onKeyDown = vi.fn();
const { box } = setup({ onKeyDown });
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } });
expect(await screen.findByRole("status")).toHaveTextContent("Uploading image…");
expect(box).toHaveAttribute("readonly");
expect(box).toHaveAttribute("aria-busy", "true");
fireEvent.keyDown(box, { key: "Enter" });
expect(onKeyDown).not.toHaveBeenCalled();
await act(async () =>
pending.resolve(new Response(JSON.stringify({ success: true, data: { id: "z" } })))
);
await waitFor(() => expect(box).not.toHaveAttribute("readonly"));
});
test("a failed upload is reported", async () => {
route("POST /api/attachments", () => fail("Too big", 413));
const { box } = setup();
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } });
expect(await screen.findByRole("alert")).toHaveTextContent("The image couldn't be uploaded.");
expect(box).toHaveValue("");
expect(box).toHaveAttribute("aria-describedby");
});
test("dropping a non-image is caught and explained", () => {
const { box } = setup();
const dataTransfer = { types: ["Files"], files: [imageFile("a.pdf", "application/pdf")], dropEffect: "" };
const over = fireEvent.dragOver(box, { dataTransfer });
expect(over).toBe(false); // prevented: the browser mustn't open the file
fireEvent.drop(box, { dataTransfer });
expect(screen.getByRole("alert")).toHaveTextContent("Only images can be dropped here.");
expect(calls("POST /api/attachments")).toHaveLength(0);
});
test("dropped images upload; dragged text is left to the browser", async () => {
route("POST /api/attachments", (req) => ({ id: `n${calls("POST /api/attachments").indexOf(req)}` }));
const { box } = setup();
const textDrag = fireEvent.dragOver(box, { dataTransfer: { types: ["text/plain"], files: [] } });
expect(textDrag).toBe(true);
fireEvent.drop(box, {
dataTransfer: { types: ["Files"], files: [imageFile("a.png"), imageFile("b.png")] },
});
await waitFor(() => expect(box.value).toContain("attachments/n1"));
expect(box.value.split("\n")).toHaveLength(2);
});
});
describe("emoji", () => {
test("':' and two letters opens the picker with that query; Escape closes only it", async () => {
serveEmojibase();
const onCancel = vi.fn();
const { user, box } = setup({ onCancel });
await user.type(box, "hi :astro");
const search = await screen.findByRole("searchbox", { name: "Search emoji" });
expect(search).toHaveValue("astro");
expect(box).toHaveFocus();
await user.keyboard("{Escape}");
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument();
expect(onCancel).not.toHaveBeenCalled();
expect(box).toHaveValue("hi :astro");
});
test("Enter takes the best match and replaces the :query", async () => {
serveEmojibase();
const { user, box } = setup();
await user.type(box, "go :astronaut");
await screen.findByRole("gridcell", { name: "Astronaut" });
await user.keyboard("{Enter}");
expect(box).toHaveValue("go 🧑‍🚀 ");
});
test("emoticons and times don't open it", async () => {
serveEmojibase();
const { user, box } = setup();
await user.type(box, "ok :D at 12:30");
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument();
});
test("the button browses and inserts at the caret", async () => {
serveEmojibase();
const { user, box } = setup({}, "ab");
await user.click(box);
box.setSelectionRange(1, 1);
await user.click(screen.getByRole("button", { name: "Insert emoji" }));
expect(screen.getByRole("button", { name: "Insert emoji" })).toHaveAttribute("aria-expanded", "true");
const search = await screen.findByRole("searchbox", { name: "Search emoji" });
await waitFor(() => expect(search).toHaveFocus());
await user.type(search, "astronaut");
await user.click(await screen.findByRole("gridcell", { name: "Astronaut" }));
expect(box).toHaveValue("a🧑‍🚀b");
});
test("a press outside puts the picker away", async () => {
serveEmojibase();
const { user } = setup();
await user.click(screen.getByRole("button", { name: "Insert emoji" }));
await screen.findByRole("searchbox", { name: "Search emoji" });
await user.click(document.body);
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,292 @@
import { describe, expect, test, vi } from "vitest";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRef } from "react";
import { Avatar } from "../../components/Avatar.tsx";
import { VoteButton } from "../../components/VoteButton.tsx";
import { LikeButton } from "../../components/LikeButton.tsx";
import { FollowPlaylistButton, FollowUserButton } from "../../components/FollowButton.tsx";
import { UserListPopover } from "../../components/UserListPopover.tsx";
import { NotificationBell } from "../../components/NotificationBell.tsx";
import { ChatButton } from "../../components/ChatButton.tsx";
import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts";
import { ChatContext } from "../../contexts/ChatContext.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeNotification, makeSession, makeUser, page } from "../fixtures.ts";
import { currentPath, defaultOf, renderWithContexts } from "../render.tsx";
import { WSContext } from "../../contexts/WSContext.ts";
describe("Avatar", () => {
test("an image when the user has one, versioned", () => {
renderWithContexts(<Avatar userId="u1" username="ann" hasAvatar version={7} />);
expect(screen.getByRole("img", { name: "ann" })).toHaveAttribute(
"src",
"http://localhost:8000/api/avatars/u1?v=7",
);
});
test("initials otherwise, and after the image fails", () => {
renderWithContexts(<Avatar userId="u1" username="bob" hasAvatar />);
fireEvent.error(screen.getByRole("img"));
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(screen.getByTitle("bob")).toHaveTextContent("B");
});
test("a live override wins over stale props, and retries a failed image", () => {
const { rerender } = renderWithContexts(
<AvatarOverrideContext.Provider value={{ u1: { hasAvatar: false, version: 1 } }}>
<Avatar userId="u1" username="cy" hasAvatar />
</AvatarOverrideContext.Provider>,
);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
rerender(
<AvatarOverrideContext.Provider value={{ u1: { hasAvatar: true, version: 2 } }}>
<Avatar userId="u1" username="cy" hasAvatar={false} />
</AvatarOverrideContext.Provider>,
);
// rerender replaces the tree under the same router wrapper
expect(screen.getByRole("img")).toHaveAttribute("src", expect.stringContaining("?v=2"));
});
});
describe("VoteButton / LikeButton", () => {
test("upvote and remove, passing the count on screen", async () => {
const onCast = vi.fn();
const onRemove = vi.fn();
const user = userEvent.setup();
const { rerender } = renderWithContexts(
<VoteButton dumpId="d" count={0} voted={false} onCast={onCast} onRemove={onRemove} />,
);
await user.click(screen.getByRole("button", { name: "Upvote" }));
expect(onCast).toHaveBeenCalledWith("d", 0);
rerender(<VoteButton dumpId="d" count={3} voted onCast={onCast} onRemove={onRemove} />);
await user.click(screen.getByRole("button", { name: "Remove vote" }));
expect(onRemove).toHaveBeenCalledWith("d", 3);
});
test("disabled for guests, with a hint", () => {
renderWithContexts(
<VoteButton dumpId="d" count={1} voted={false} disabled onCast={() => {}} onRemove={() => {}} />,
);
const btn = screen.getByRole("button", { name: "Upvote" });
expect(btn).toBeDisabled();
expect(btn).toHaveAttribute("title", "Log in to vote");
});
test("the count opens who voted, without voting", async () => {
const onCast = vi.fn();
route("GET /api/dumps/:id/voters", () => page([makeUser({ username: "fan" })]));
const user = userEvent.setup();
renderWithContexts(
<VoteButton dumpId="d1" count={1} voted={false} onCast={onCast} onRemove={() => {}} />,
);
await user.click(screen.getByText("1"));
expect(onCast).not.toHaveBeenCalled();
expect(await screen.findByRole("menuitem", { name: /fan/ })).toHaveAttribute(
"href",
"/users/fan",
);
});
test("likes work the same way", async () => {
const onLike = vi.fn();
const onUnlike = vi.fn();
route("GET /api/comments/:id/likers", () => page([]));
const user = userEvent.setup();
const { rerender } = renderWithContexts(
<LikeButton commentId="c" count={2} liked={false} onLike={onLike} onUnlike={onUnlike} />,
);
await user.click(screen.getByRole("button", { name: "Like" }));
expect(onLike).toHaveBeenCalledWith("c", 2);
await user.click(screen.getByText("2"));
expect(await screen.findByText("No one yet.")).toBeInTheDocument();
rerender(<LikeButton commentId="c" count={2} liked onLike={onLike} onUnlike={onUnlike} />);
await user.click(screen.getByRole("button", { name: "Remove like" }));
expect(onUnlike).toHaveBeenCalledWith("c", 2);
});
});
describe("UserListPopover", () => {
function Harness({ onClose = () => {} }: { onClose?: () => void }) {
const ref = useRef<HTMLSpanElement>(null);
return (
<>
<span ref={ref}>anchor</span>
<button type="button">outside</button>
<UserListPopover anchorRef={ref} onClose={onClose} fetchUrl="http://localhost:8000/api/x/voters" />
</>
);
}
test("pages through the list", async () => {
route("GET /api/x/voters", (req) =>
req.url.searchParams.get("page") === "1"
? page([makeUser({ username: "a" })], true)
: page([makeUser({ username: "b" })], false));
const user = userEvent.setup();
renderWithContexts(<Harness />, { session: makeSession() });
expect(screen.getByText("Loading…")).toBeInTheDocument();
await screen.findByText("a");
await user.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("b");
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
expect(calls("GET /api/x/voters")[0].headers.get("Authorization")).toMatch(/^Bearer /);
});
test("shows the server's error, and a network one", async () => {
route("GET /api/x/voters", () => fail("Dump not found", 404), { once: true });
const first = renderWithContexts(<Harness />);
await screen.findByText("Dump not found");
first.unmount();
route("GET /api/x/voters", () => new TypeError("offline"));
renderWithContexts(<Harness />);
await screen.findByText("Could not load.");
});
test("a failed next page lets you try again", async () => {
route("GET /api/x/voters", () => page([makeUser({ username: "a" })], true), { once: true });
route("GET /api/x/voters", () => new TypeError("offline"), { once: true });
route("GET /api/x/voters", () => page([makeUser({ username: "b" })], false));
const user = userEvent.setup();
renderWithContexts(<Harness />);
await screen.findByText("a");
await user.click(screen.getByRole("button", { name: "Load more" }));
const retry = await screen.findByRole("button", { name: "Load more" });
expect(retry).toBeEnabled();
await user.click(retry);
await screen.findByText("b");
});
test("closes on Escape, an outside press and a scroll — not on the anchor", async () => {
route("GET /api/x/voters", () => page([]));
const onClose = vi.fn();
const user = userEvent.setup();
renderWithContexts(<Harness onClose={onClose} />);
await screen.findByText("No one yet.");
await user.click(screen.getByText("anchor"));
await user.click(screen.getByText("No one yet."));
expect(onClose).not.toHaveBeenCalled();
await user.keyboard("{Escape}");
await user.click(screen.getByText("outside"));
act(() => {
globalThis.dispatchEvent(new Event("scroll"));
});
expect(onClose).toHaveBeenCalledTimes(3);
});
});
describe("FollowButton", () => {
const me = makeSession({ id: "me" });
test("hidden for guests and on yourself", () => {
const { unmount } = renderWithContexts(
<FollowUserButton targetUserId="x" targetUsername="x" />,
);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
unmount();
renderWithContexts(<FollowUserButton targetUserId="me" targetUsername="me" />, { session: me });
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
test("follow and unfollow a user", async () => {
const followUser = vi.fn();
const unfollowUser = vi.fn();
const user = userEvent.setup();
renderWithContexts(
<FollowUserButton targetUserId="u" targetUsername="ann" />,
{ session: me, follows: { followUser, unfollowUser } },
);
const btn = screen.getByRole("button", { name: "Follow ann" });
expect(btn).toHaveTextContent("Follow");
await user.click(btn);
expect(followUser).toHaveBeenCalledWith("u");
expect(unfollowUser).not.toHaveBeenCalled();
});
test("shows Following, and unfollows", async () => {
const unfollowUser = vi.fn();
const user = userEvent.setup();
renderWithContexts(<FollowUserButton targetUserId="u" targetUsername="ann" />, {
session: me,
follows: { followedUserIds: new Set(["u"]), unfollowUser },
});
const btn = screen.getByRole("button", { name: "Unfollow ann" });
expect(btn).toHaveTextContent("Following");
await user.click(btn);
expect(unfollowUser).toHaveBeenCalledWith("u");
});
test("disabled until follows have loaded", () => {
renderWithContexts(<FollowUserButton targetUserId="u" targetUsername="ann" />, {
session: me,
follows: { isLoaded: false },
});
expect(screen.getByRole("button")).toBeDisabled();
});
test("playlists: only public ones can be followed", async () => {
const followPlaylist = vi.fn();
const unfollowPlaylist = vi.fn();
const user = userEvent.setup();
const priv = renderWithContexts(
<FollowPlaylistButton targetPlaylistId="p" isPublic={false} />,
{ session: me },
);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
priv.unmount();
renderWithContexts(<FollowPlaylistButton targetPlaylistId="p" isPublic />, {
session: me,
follows: { followPlaylist, unfollowPlaylist, followedPlaylistIds: new Set(["p"]) },
});
await user.click(screen.getByRole("button", { name: "Unfollow playlist" }));
expect(unfollowPlaylist).toHaveBeenCalledWith("p");
});
});
describe("NotificationBell / ChatButton", () => {
test("the bell shows the unread count and goes to notifications", async () => {
const user = userEvent.setup();
renderWithContexts(<NotificationBell />, { ws: { unreadNotificationCount: 120 } });
const bell = screen.getByRole("button", { name: "Notifications (120 unread)" });
expect(bell).toHaveTextContent("99+");
await user.click(bell);
expect(currentPath()).toBe("/notifications");
});
test("the bell rings for a new notification, not for one already there", () => {
vi.useFakeTimers();
try {
const n1 = makeNotification({ id: "n1" });
const withLast = (n: ReturnType<typeof makeNotification>) => (
<WSContext.Provider value={{ ...defaultOf(WSContext), lastNotification: n }}>
<NotificationBell />
</WSContext.Provider>
);
const { rerender } = renderWithContexts(withLast(n1));
const bell = () => screen.getByRole("button", { name: "Notifications" });
act(() => vi.advanceTimersByTime(10));
expect(bell()).not.toHaveClass("notification-bell--ringing");
rerender(withLast(makeNotification({ id: "n2" })));
act(() => vi.advanceTimersByTime(10));
expect(bell()).toHaveClass("notification-bell--ringing");
act(() => vi.advanceTimersByTime(700));
expect(bell()).not.toHaveClass("notification-bell--ringing");
} finally {
vi.useRealTimers();
}
});
test("the chat button opens the chat and shows unread", async () => {
const openChat = vi.fn();
const user = userEvent.setup();
renderWithContexts(
<ChatContext.Provider value={{ isChatOpen: false, openChat, closeChat: () => {} }}>
<ChatButton />
</ChatContext.Provider>,
{ ws: { unreadChatCount: 4 } },
);
await user.click(screen.getByRole("button", { name: "Chat (4 unread)" }));
expect(openChat).toHaveBeenCalled();
await waitFor(() => expect(screen.getByText("4")).toBeInTheDocument());
});
});

View File

@@ -0,0 +1,307 @@
import { describe, expect, test, vi } from "vitest";
import { fireEvent, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Markdown } from "../../components/Markdown.tsx";
import Thumbnail from "../../components/Thumbnail.tsx";
import ThumbnailPlaceholder from "../../components/ThumbnailPlaceholder.tsx";
import RichContentCard from "../../components/RichContentCard.tsx";
import { DumpCard } from "../../components/DumpCard.tsx";
import { hueFromString } from "../../utils/thumbnailTint.ts";
import { markDumpVisited } from "../../utils/visited.ts";
import { makeCategory, makeDump, makeSession } from "../fixtures.ts";
import { currentPath, renderWithContexts } from "../render.tsx";
describe("Markdown", () => {
const md = (text: string, inline = false) =>
renderWithContexts(<Markdown inline={inline}>{text}</Markdown>);
test("renders GFM", () => {
md("**bold** ~~gone~~\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done");
expect(screen.getByText("bold").tagName).toBe("STRONG");
expect(screen.getByText("gone").tagName).toBe("DEL");
expect(screen.getByRole("table").parentElement).toHaveClass("md-table-wrap");
expect(screen.getByRole("checkbox")).toBeChecked();
});
test("@mentions link to the profile, in-app", async () => {
md("hi @ann and bob@mail.com");
const link = screen.getByRole("link", { name: "@ann" });
expect(link).toHaveAttribute("href", "/users/ann");
expect(link).not.toHaveAttribute("target");
// GFM autolinks the address itself; it isn't a mention of "mail".
expect(screen.getByRole("link", { name: "bob@mail.com" })).toHaveAttribute(
"href",
"mailto:bob@mail.com",
);
expect(document.querySelector('a[href="/users/mail"]')).toBeNull();
await userEvent.click(link);
expect(currentPath()).toBe("/users/ann");
});
test("mentions in code and inside URLs are left alone", () => {
md("`@ann` and https://mastodon.social/@bob");
expect(screen.getByText("@ann").tagName).toBe("CODE");
expect(screen.getByRole("link")).toHaveAttribute(
"href",
"https://mastodon.social/@bob",
);
});
test("external links open in a new tab; internal and same-origin ones don't", () => {
md(
`[ext](https://elsewhere.test) [in](/dumps/x) [same](${location.origin}/playlists/p?x=1#y) [file](/api/files/1)`,
);
expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute("target", "_blank");
expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute("rel", "noopener noreferrer");
expect(screen.getByRole("link", { name: "in" })).not.toHaveAttribute("target");
expect(screen.getByRole("link", { name: "same" })).toHaveAttribute("href", "/playlists/p?x=1#y");
expect(screen.getByRole("link", { name: "file" })).toHaveAttribute("target", "_blank");
});
test("a single newline is a line break", () => {
const { container } = md("one\ntwo\nthree");
expect(container.querySelectorAll("br")).toHaveLength(2);
});
test("code blocks keep their newlines", () => {
const { container } = md("```\na\nb\n```");
expect(container.querySelector("pre")!.textContent).toBe("a\nb\n");
expect(container.querySelectorAll("br")).toHaveLength(0);
});
test("images load lazily; inline teasers drop them", () => {
const full = md("![cat](https://x.test/cat.png)");
expect(screen.getByRole("img", { name: "cat" })).toHaveAttribute("loading", "lazy");
full.unmount();
md("![cat](https://x.test/cat.png) text", true);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(screen.getByText("text")).toBeInTheDocument();
});
test("raw HTML is not rendered", () => {
const { container } = md("<script>alert(1)</script><b>x</b>");
expect(container.querySelector("script")).toBeNull();
expect(container.querySelector("b")).toBeNull();
});
test("javascript: links are neutralised", () => {
md("[click](javascript:alert(1))");
const link = screen.getByText("click").closest("a");
expect(link?.getAttribute("href") ?? "").not.toMatch(/^javascript:/i);
});
});
describe("Thumbnail", () => {
const placeholder = { url: "https://site.test/page", siteName: "Site" };
test("direct, then proxied, then the placeholder", () => {
renderWithContexts(
<Thumbnail src="https://img.test/a.png" alt="art" placeholder={placeholder} />,
);
const img = screen.getByRole("img", { name: "art" });
expect(img).toHaveAttribute("src", "https://img.test/a.png");
expect(img).toHaveAttribute("referrerpolicy", "no-referrer");
fireEvent.error(img);
expect(screen.getByRole("img", { name: "art" }).getAttribute("src")).toContain(
"/api/proxy-image?url=",
);
fireEvent.error(screen.getByRole("img", { name: "art" }));
expect(screen.queryByRole("img", { name: "art" })).not.toBeInTheDocument();
expect(screen.getByText("S")).toBeInTheDocument();
});
test("our own images skip the proxy retry", () => {
renderWithContexts(
<Thumbnail src="http://localhost:8000/api/thumbnails/d" alt="t" placeholder={placeholder} />,
);
fireEvent.error(screen.getByRole("img", { name: "t" }));
expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument();
});
test("no src: straight to the placeholder; a new src tries again", () => {
const { rerender } = renderWithContexts(<Thumbnail placeholder={placeholder} alt="t" />);
expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument();
rerender(<Thumbnail src="https://img.test/b.png" placeholder={placeholder} alt="t" />);
expect(screen.getByRole("img", { name: "t" })).toBeInTheDocument();
});
});
describe("ThumbnailPlaceholder", () => {
test("tints by the page colour, or by hostname", () => {
const { container, rerender } = renderWithContexts(
<ThumbnailPlaceholder url="https://www.site.test/x" accentColor="#123456" />,
);
const box = () => container.querySelector<HTMLElement>(".thumb-placeholder")!;
expect(box().style.getPropertyValue("--thumb-tint")).toBe("#123456");
rerender(<ThumbnailPlaceholder url="https://www.site.test/x" accentColor="url(evil)" />);
expect(box().style.getPropertyValue("--thumb-tint")).toBe(hueFromString("site.test"));
});
test("favicon, then the initial when it fails; a glyph wins over the initial", () => {
const { container, rerender } = renderWithContexts(
<ThumbnailPlaceholder url="https://site.test" faviconUrl="https://site.test/f.ico" />,
);
const icon = container.querySelector("img")!;
fireEvent.error(icon);
expect(container.querySelector("img")).toBeNull();
expect(screen.getByText("S")).toBeInTheDocument();
rerender(<ThumbnailPlaceholder seed="clip.mp4" glyph="🎬" />);
expect(screen.getByText("🎬")).toBeInTheDocument();
});
});
describe("RichContentCard", () => {
const yt = {
type: "youtube",
url: "https://youtube.com/watch?v=x",
title: "A video",
siteName: "YouTube",
description: "About it",
thumbnailUrl: "https://i.ytimg.com/x.jpg",
embedUrl: "https://www.youtube.com/embed/x",
};
test("full card: play button, source link and details", async () => {
const play = vi.fn();
renderWithContexts(<RichContentCard richContent={yt} dumpHref="/dumps/d" />, {
player: { play },
});
expect(screen.getByRole("link", { name: /A video/ })).toHaveAttribute("href", yt.url);
expect(screen.getByText("About it")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play).toHaveBeenCalledWith(expect.objectContaining({
kind: "embed",
embedUrl: yt.embedUrl,
dumpHref: "/dumps/d",
}));
});
test("compact: shows Pause while this card plays", () => {
renderWithContexts(<RichContentCard richContent={yt} compact />, {
player: {
playing: true,
current: { kind: "embed", embedUrl: yt.embedUrl, type: "youtube" },
},
});
expect(screen.getByRole("button", { name: "Pause" })).toHaveClass("is-playing");
});
test("compact and unplayable: a link to the source", () => {
renderWithContexts(
<RichContentCard richContent={{ type: "link", url: "https://blog.test/p", siteName: "Blog" }} compact />,
);
expect(screen.getByRole("link", { name: "Open on Blog" })).toHaveAttribute(
"href",
"https://blog.test/p",
);
});
test("a dump thumbnail overrides the provider's", () => {
renderWithContexts(
<RichContentCard richContent={yt} thumbnailOverrideUrl="http://localhost:8000/api/thumbnails/d" />,
);
expect(screen.getByRole("img", { name: "A video" })).toHaveAttribute(
"src",
"http://localhost:8000/api/thumbnails/d",
);
});
test("a native stream that is the active item toggles instead of restarting", async () => {
const togglePlay = vi.fn();
const play = vi.fn();
const bc = { type: "bandcamp", url: "https://a.bandcamp.com/album/x", embedUrl: "https://bandcamp.com/EmbeddedPlayer/1" };
renderWithContexts(<RichContentCard richContent={bc} compact />, {
player: {
togglePlay,
play,
playing: true,
current: {
kind: "stream",
type: "bandcamp",
streamUrl: "s",
resolveUrl: bc.url,
resolveIndex: 0,
resolvedAt: 0,
},
},
});
await userEvent.click(screen.getByRole("button", { name: "Pause" }));
expect(togglePlay).toHaveBeenCalled();
expect(play).not.toHaveBeenCalled();
});
});
describe("DumpCard", () => {
const props = {
voteCount: 3,
voted: false,
canVote: true,
castVote: vi.fn(),
removeVote: vi.fn(),
};
test("title, teaser, date, comment count and categories", () => {
const dump = makeDump({
id: "d1",
slug: "nice",
title: "Nice find",
comment: "Look **here**",
commentCount: 2,
categoryIds: ["c1", "missing"],
});
renderWithContexts(<ul><DumpCard dump={dump} {...props} /></ul>, {
categories: [makeCategory({ id: "c1", slug: "music", name: "Music" })],
});
expect(screen.getByRole("link", { name: /Nice find/ })).toHaveAttribute("href", "/dumps/nice");
expect(screen.getByText("here").tagName).toBe("STRONG");
expect(screen.getByText("2 comments")).toBeInTheDocument();
expect(screen.getByText("1 minute ago")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute("href", "/music");
});
test("an unread dot until visited, never on your own", () => {
const dump = makeDump({ id: "d2" });
const first = renderWithContexts(<ul><DumpCard dump={dump} {...props} /></ul>);
expect(first.container.querySelector(".unread-dot")).not.toBeNull();
first.unmount();
const own = renderWithContexts(<ul><DumpCard dump={dump} {...props} isOwner /></ul>);
expect(own.container.querySelector(".unread-dot")).toBeNull();
own.unmount();
markDumpVisited("d2");
const seen = renderWithContexts(<ul><DumpCard dump={dump} {...props} /></ul>);
expect(seen.container.querySelector(".unread-dot")).toBeNull();
});
test("the private badge shows to the owner", () => {
renderWithContexts(
<ul><DumpCard dump={makeDump({ isPrivate: true })} {...props} isOwner /></ul>,
);
expect(screen.getByText("private")).toBeInTheDocument();
});
test("clicking the card opens the dump; voting doesn't", async () => {
const castVote = vi.fn();
const dump = makeDump({ id: "d3" });
renderWithContexts(
<ul><DumpCard dump={dump} {...props} castVote={castVote} /></ul>,
{ session: makeSession() },
);
await userEvent.click(screen.getByRole("button", { name: "Upvote" }));
expect(castVote).toHaveBeenCalledWith("d3", 3);
expect(currentPath()).toBe("/");
await userEvent.click(screen.getByText("1 minute ago"));
expect(currentPath()).toBe("/dumps/d3");
});
test("file dumps get a file preview; bare URLs a placeholder", () => {
const { container } = renderWithContexts(
<ul>
<DumpCard dump={makeDump({ kind: "file", fileName: "a.pdf", fileMime: "application/pdf", fileSize: 10, url: undefined })} {...props} />
<DumpCard dump={makeDump({ url: "https://plain.test" })} {...props} />
</ul>,
);
expect(container.querySelectorAll(".thumb-placeholder").length).toBeGreaterThan(0);
const cards = container.querySelectorAll(".dump-card");
expect(within(cards[1] as HTMLElement).getByText("P")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,430 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { I18nProvider } from "@lingui/react";
import { i18n } from "@lingui/core";
import { BrowserRouter, Route, Routes, useLocation } from "react-router";
import { useState } from "react";
import { SearchBar } from "../../components/SearchBar.tsx";
import { UserMenu } from "../../components/UserMenu.tsx";
import { PresenceRow } from "../../components/PresenceRow.tsx";
import { CategorySwitcher } from "../../components/CategorySwitcher.tsx";
import { CategorySelect } from "../../components/CategorySelect.tsx";
import { TabBar } from "../../components/TabBar.tsx";
import { FeedTabBar } from "../../components/FeedTabBar.tsx";
import { AppHeader } from "../../components/AppHeader.tsx";
import { ChatFab } from "../../components/ChatFab.tsx";
import { DumpFab } from "../../components/DumpFab.tsx";
import { Tooltip } from "../../components/Tooltip.tsx";
import { CountedInput } from "../../components/CountedInput.tsx";
import { PageError } from "../../components/PageError.tsx";
import { AuthContext } from "../../contexts/AuthContext.ts";
import { ChatContext } from "../../contexts/ChatContext.ts";
import { WSContext } from "../../contexts/WSContext.ts";
import type { AuthResponse, Category } from "../../model.ts";
import { makeCategory, makeSession, makeUser } from "../fixtures.ts";
import { currentPath, defaultOf, renderWithContexts } from "../render.tsx";
const categories: Category[] = [
makeCategory({ id: "c1", slug: "music", name: "Music" }),
makeCategory({ id: "c2", slug: "cinema", name: "Cinema" }),
];
describe("SearchBar", () => {
function Harness(props: Partial<Parameters<typeof SearchBar>[0]>) {
const [value, setValue] = useState("");
return (
<SearchBar
expanded
value={value}
onValueChange={setValue}
onSubmit={() => {}}
onCancel={() => {}}
{...props}
/>
);
}
test("submits the trimmed query; ignores a blank one", async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
renderWithContexts(<Harness onSubmit={onSubmit} />);
const input = screen.getByRole("searchbox", { name: "Search" });
await user.type(input, " {Enter}");
expect(onSubmit).not.toHaveBeenCalled();
await user.type(input, " cats {Enter}");
expect(onSubmit).toHaveBeenCalledWith("cats");
expect(input).not.toHaveFocus();
});
test("Escape cancels instead of clearing", async () => {
const onCancel = vi.fn();
const user = userEvent.setup();
renderWithContexts(<Harness onCancel={onCancel} />);
await user.type(screen.getByRole("searchbox"), "dogs{Escape}");
expect(onCancel).toHaveBeenCalled();
expect(screen.getByRole("searchbox")).toHaveValue("dogs");
});
test("takes focus when it opens, not when it mounts open", () => {
const props = { value: "", onValueChange: () => {}, onSubmit: () => {}, onCancel: () => {} };
const { rerender } = renderWithContexts(<SearchBar expanded {...props} />);
expect(screen.getByRole("searchbox")).not.toHaveFocus();
rerender(<SearchBar expanded={false} {...props} />);
expect(screen.getByRole("searchbox")).toHaveAttribute("tabindex", "-1");
rerender(<SearchBar expanded {...props} />);
expect(screen.getByRole("searchbox")).toHaveFocus();
rerender(<SearchBar expanded={false} {...props} />);
expect(screen.getByRole("searchbox")).not.toHaveFocus();
});
});
describe("UserMenu", () => {
test("opens, links to profile and playlists, closes on Escape and outside", async () => {
const user = userEvent.setup();
renderWithContexts(
<>
<UserMenu user={makeUser({ username: "ann" })} />
<p>outside</p>
</>,
);
const trigger = screen.getByRole("button", { name: "User menu" });
await user.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("menuitem", { name: "@ann" })).toHaveAttribute("href", "/users/ann");
expect(screen.getByRole("menuitem", { name: "Playlists" })).toHaveAttribute(
"href",
"/users/ann/playlists",
);
await user.keyboard("{Escape}");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
await user.click(trigger);
await user.click(screen.getByText("outside"));
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
await user.click(trigger);
await user.click(screen.getByRole("menuitem", { name: "@ann" }));
expect(currentPath()).toBe("/users/ann");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
});
describe("PresenceRow", () => {
const online = (n: number) =>
Array.from({ length: n }, (_, i) => ({
userId: `u${i}`,
username: `user${i}`,
hasAvatar: false,
}));
test("nothing when nobody's online", () => {
renderWithContexts(<PresenceRow />);
expect(document.querySelector(".index-presence")).toBeNull();
});
test("up to six seats, the rest folded into a counter", () => {
renderWithContexts(<PresenceRow />, { ws: { onlineUsers: online(9) } });
expect(screen.getAllByRole("link")).toHaveLength(5);
const more = screen.getByRole("img", { name: /4 more online/ });
expect(more).toHaveTextContent("+4");
expect(more).toHaveAttribute("title", "user5, user6, user7, user8");
});
test("six or fewer all show", () => {
renderWithContexts(<PresenceRow />, { ws: { onlineUsers: online(6) } });
expect(screen.getAllByRole("link")).toHaveLength(6);
expect(screen.queryByText(/^\+/)).not.toBeInTheDocument();
});
test("gives up seats to the tabs beside it, keeping at least an avatar and the counter", () => {
const width = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(100);
renderWithContexts(
<div className="bar">
<div className="tabs"><span>a</span></div>
<PresenceRow fitWithin=".bar" fitBeside=".tabs" />
</div>,
{ ws: { onlineUsers: online(9) } },
);
expect(screen.getAllByRole("link")).toHaveLength(3);
width.mockReturnValue(0);
act(() => {
globalThis.dispatchEvent(new Event("resize"));
});
width.mockRestore();
});
});
describe("CategorySwitcher", () => {
test("hidden with no categories", () => {
renderWithContexts(<CategorySwitcher />, { route: "/~/hot", path: "/:x/:feedTab" });
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
test("switches scope, keeping the tab", async () => {
const user = userEvent.setup();
renderWithContexts(<CategorySwitcher />, {
categories,
route: "/music/new",
path: "/:categorySlug/:feedTab",
});
const trigger = screen.getByRole("button", { name: "Music" });
await user.click(trigger);
expect(screen.getByRole("option", { name: "Music" })).toHaveAttribute("aria-selected", "true");
await user.click(screen.getByRole("option", { name: "Cinema" }));
expect(currentPath()).toBe("/cinema/new");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
test("All is the ~ scope; an unknown slug shows as itself", async () => {
const user = userEvent.setup();
renderWithContexts(<CategorySwitcher />, {
categories,
route: "/gone/hot",
path: "/:categorySlug/:feedTab",
});
await user.click(screen.getByRole("button", { name: "gone" }));
await user.click(screen.getByRole("option", { name: "All" }));
expect(currentPath()).toBe("/~/hot");
});
test("Escape closes the menu", async () => {
const user = userEvent.setup();
renderWithContexts(<CategorySwitcher />, { categories, route: "/~/hot", path: "/~/:feedTab" });
await user.click(screen.getByRole("button", { name: "All" }));
await user.keyboard("{Escape}");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
describe("CategorySelect", () => {
test("toggle chips reflect the selection", async () => {
const onToggle = vi.fn();
const user = userEvent.setup();
renderWithContexts(<CategorySelect selected={new Set(["c2"])} onToggle={onToggle} />, {
categories,
});
expect(screen.getByRole("button", { name: "Cinema" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute("aria-pressed", "false");
await user.click(screen.getByRole("button", { name: "Music" }));
expect(onToggle).toHaveBeenCalledWith("c1");
});
test("nothing without categories", () => {
renderWithContexts(<CategorySelect selected={new Set()} onToggle={() => {}} />);
expect(screen.queryByText("Categories")).not.toBeInTheDocument();
});
});
describe("TabBar / FeedTabBar", () => {
test("TabBar marks the active tab and reports clicks", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
renderWithContexts(
<TabBar
tabs={[{ key: "a", label: "A" }, { key: "b", label: "B" }]}
activeTab="b"
onChange={onChange}
/>,
);
expect(screen.getByRole("button", { name: "B" })).toHaveClass("active");
await user.click(screen.getByRole("button", { name: "A" }));
expect(onChange).toHaveBeenCalledWith("a");
});
test("guests don't get Followed; tabs navigate within the scope", async () => {
const user = userEvent.setup();
renderWithContexts(<FeedTabBar />, { route: "/music/hot", path: "/:categorySlug/:feedTab", categories });
expect(screen.queryByRole("button", { name: "Followed" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Hot" })).toHaveClass("active");
await user.click(screen.getByRole("button", { name: "Journal" }));
expect(currentPath()).toBe("/music/journal");
});
test("signed in, Followed is there", () => {
renderWithContexts(<FeedTabBar />, {
session: makeSession(),
route: "/~/followed",
path: "/~/:feedTab",
});
expect(screen.getByRole("button", { name: "Followed" })).toHaveClass("active");
});
});
describe("AppHeader", () => {
test("guests get a login button", async () => {
const user = userEvent.setup();
renderWithContexts(<AppHeader />);
await user.click(screen.getByRole("button", { name: "Log in" }));
expect(currentPath()).toBe("/login");
});
test("members get their links, chat, bell and the new-dump button", () => {
renderWithContexts(<AppHeader disableNew />, { session: makeSession({ username: "ann" }) });
expect(screen.getByRole("link", { name: "ann" })).toHaveAttribute("href", "/users/ann");
expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument();
const dump = screen.getByRole("button", { name: /Dump/ });
expect(dump).toBeDisabled();
expect(dump).toHaveAttribute("title", "Server unreachable");
});
test("shows the live-updates banner when disconnected", () => {
renderWithContexts(<AppHeader />, {
ws: { wsStatus: "disconnected", wsErrorMessage: "Trying to reconnect…" },
});
expect(screen.getByRole("alert")).toHaveTextContent(
"Live updates unavailable. Trying to reconnect…",
);
});
test("the search toggle opens and closes, giving focus back", async () => {
const user = userEvent.setup();
renderWithContexts(<AppHeader />);
const toggle = screen.getByRole("button", { name: "Search" });
await user.click(toggle);
expect(screen.getByRole("button", { name: "Cancel search" })).toHaveAttribute(
"aria-expanded",
"true",
);
const [input] = screen.getAllByRole("searchbox");
await user.type(input, "x");
await user.keyboard("{Escape}");
expect(screen.getByRole("button", { name: "Search" })).toHaveFocus();
expect(screen.getAllByRole("searchbox")[0]).toHaveValue("");
});
// Leaving search needs the real history stack (React Router keeps its
// index in history.state), so these use a BrowserRouter.
function Browser({ session }: { session?: AuthResponse }) {
const [authResponse, setAuthResponse] = useState(session ?? null);
function Loc() {
const l = useLocation();
return <output data-testid="loc">{l.pathname + l.search}</output>;
}
return (
<I18nProvider i18n={i18n}>
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
<WSContext.Provider value={defaultOf(WSContext)}>
<BrowserRouter>
<Routes>
<Route path="*" element={<AppHeader />} />
</Routes>
<Loc />
</BrowserRouter>
</WSContext.Provider>
</AuthContext.Provider>
</I18nProvider>
);
}
const loc = () => screen.getByTestId("loc").textContent;
afterEach(() => window.history.replaceState(null, "", "/"));
test("several searches, then ✕, return to where search began", async () => {
window.history.replaceState(null, "", "/start");
const user = userEvent.setup();
render(<Browser />);
await user.click(screen.getByRole("button", { name: "Search" }));
await user.type(screen.getAllByRole("searchbox")[0], "cats{Enter}");
expect(loc()).toBe("/search?q=cats");
const bar = () => screen.getAllByRole("searchbox")[0];
expect(bar()).toHaveValue("cats");
await user.clear(bar());
await user.type(bar(), "dogs{Enter}");
expect(loc()).toBe("/search?q=dogs");
await user.click(screen.getByRole("button", { name: "Cancel search" }));
await waitFor(() => expect(loc()).toBe("/start"));
});
test("Escape on the results page reverts the query instead of leaving", async () => {
window.history.replaceState(null, "", "/search?q=cats");
const user = userEvent.setup();
render(<Browser />);
const bar = screen.getAllByRole("searchbox")[0];
expect(bar).toHaveValue("cats");
await user.type(bar, "zzz{Escape}");
expect(bar).toHaveValue("cats");
expect(loc()).toBe("/search?q=cats");
});
test("✕ on results that opened the session goes home", async () => {
window.history.replaceState(null, "", "/search?q=cats");
const user = userEvent.setup();
render(<Browser />);
await user.click(screen.getByRole("button", { name: "Cancel search" }));
expect(loc()).toBe("/");
});
});
describe("floating buttons", () => {
function withChat(openChat = vi.fn()) {
return (ui: React.ReactNode) => (
<ChatContext.Provider value={{ isChatOpen: false, openChat, closeChat: () => {} }}>
{ui}
</ChatContext.Provider>
);
}
test("guests get neither", () => {
renderWithContexts(withChat()(<><ChatFab /><DumpFab /></>));
expect(screen.queryByRole("button", { hidden: true })).not.toBeInTheDocument();
});
test("they show once the header scrolls away", () => {
const openChat = vi.fn();
renderWithContexts(
withChat(openChat)(
<>
<header className="app-header" />
<ChatFab />
<DumpFab />
</>,
),
{ session: makeSession(), ws: { unreadChatCount: 2 } },
);
const header = document.querySelector(".app-header")!;
// jsdom lays nothing out: put the header on screen first.
header.getBoundingClientRect = () => ({ bottom: 60 }) as DOMRect;
act(() => {
globalThis.dispatchEvent(new Event("scroll"));
});
const chat = document.querySelector<HTMLButtonElement>(".chat-fab")!;
expect(chat).toHaveAttribute("aria-label", "Chat (2 unread)");
expect(chat).toHaveAttribute("aria-hidden", "true");
expect(chat).toHaveAttribute("tabindex", "-1");
expect(document.body).toHaveClass("has-fab");
header.getBoundingClientRect = () => ({ bottom: -10 }) as DOMRect;
act(() => {
globalThis.dispatchEvent(new Event("scroll"));
});
expect(chat).toHaveAttribute("aria-hidden", "false");
expect(chat).toHaveClass("chat-fab--visible");
act(() => chat.click());
expect(openChat).toHaveBeenCalled();
expect(screen.getByRole("button", { name: "New dump" })).toHaveClass("dump-fab--visible");
});
});
describe("small pieces", () => {
test("Tooltip shows on hover", async () => {
const user = userEvent.setup();
renderWithContexts(<Tooltip text="Explained"><span>hover me</span></Tooltip>);
await user.hover(screen.getByText("hover me"));
expect(screen.getByText("Explained")).toBeInTheDocument();
await user.unhover(screen.getByText("hover me"));
expect(screen.queryByText("Explained")).not.toBeInTheDocument();
});
test("CountedInput counts toward its limit", () => {
renderWithContexts(<CountedInput value={"x".repeat(90)} maxLength={100} onChange={() => {}} />);
const count = screen.getByText("90 / 100");
expect(count).toHaveClass("text-editor-count--warn");
expect(screen.getByRole("textbox")).toHaveAttribute("maxlength", "100");
});
test("PageError wraps an alert in the page shell", () => {
renderWithContexts(<PageError message="Gone" />);
expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong");
expect(screen.getByRole("alert")).toHaveTextContent("Gone");
expect(document.querySelector(".app-header")).not.toBeNull();
});
});

View File

@@ -0,0 +1,225 @@
import { describe, expect, test, vi } from "vitest";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MediaPlayer } from "../../components/MediaPlayer.tsx";
import FilePreview from "../../components/FilePreview.tsx";
import { makeDump, makeSession } from "../fixtures.ts";
import { renderWithContexts } from "../render.tsx";
function media(container: HTMLElement) {
return container.querySelector<HTMLMediaElement>("audio, video")!;
}
function setTime(el: HTMLMediaElement, currentTime: number, duration: number) {
Object.defineProperty(el, "duration", { configurable: true, value: duration });
el.currentTime = currentTime;
fireEvent(el, new Event("durationchange"));
fireEvent(el, new Event("timeupdate"));
}
describe("MediaPlayer", () => {
test("play and pause through the button, reporting state", async () => {
const onPlayStateChange = vi.fn();
const play = vi.spyOn(HTMLMediaElement.prototype, "play");
const pause = vi.spyOn(HTMLMediaElement.prototype, "pause");
renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" onPlayStateChange={onPlayStateChange} />,
);
await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play).toHaveBeenCalled();
expect(onPlayStateChange).toHaveBeenLastCalledWith(true);
await userEvent.click(screen.getByRole("button", { name: "Pause" }));
expect(pause).toHaveBeenCalled();
expect(onPlayStateChange).toHaveBeenLastCalledWith(false);
});
test("autoplays when asked", async () => {
const onPlayStateChange = vi.fn();
renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" autoplay onPlayStateChange={onPlayStateChange} />,
);
await waitFor(() => expect(onPlayStateChange).toHaveBeenCalledWith(true));
expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument();
});
test("a play refused by the browser stays paused", async () => {
vi.spyOn(HTMLMediaElement.prototype, "play").mockRejectedValue(new Error("NotAllowedError"));
renderWithContexts(<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" autoplay />);
await act(async () => {});
expect(screen.getByRole("button", { name: "Play" })).toBeInTheDocument();
});
test("a pause from outside the page (a media key) is reflected", async () => {
const onPlayStateChange = vi.fn();
const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" onPlayStateChange={onPlayStateChange} />,
);
await userEvent.click(screen.getByRole("button", { name: "Play" }));
fireEvent(media(container), new Event("pause"));
expect(screen.getByRole("button", { name: "Play" })).toBeInTheDocument();
expect(onPlayStateChange).toHaveBeenLastCalledWith(false);
fireEvent(media(container), new Event("play"));
expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument();
expect(onPlayStateChange).toHaveBeenLastCalledWith(true);
});
test("time, duration, seeking and the end", async () => {
const onTimeUpdate = vi.fn();
const onEnded = vi.fn();
const { container } = renderWithContexts(
<MediaPlayer
src="https://s/a.mp3"
kind="audio"
trackStyle="progress"
onTimeUpdate={onTimeUpdate}
onEnded={onEnded}
/>,
);
const el = media(container);
setTime(el, 65, 200);
expect(screen.getByText("1:05")).toBeInTheDocument();
expect(screen.getByText("3:20")).toBeInTheDocument();
expect(onTimeUpdate).toHaveBeenLastCalledWith(65, 200);
fireEvent.change(screen.getByRole("slider", { name: "Seek" }), { target: { value: "120" } });
expect(el.currentTime).toBe(120);
fireEvent(el, new Event("ended"));
expect(onEnded).toHaveBeenCalled();
});
test("while the seek bar is held, playback doesn't move the thumb — by touch too", () => {
const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" />,
);
const el = media(container);
setTime(el, 10, 100);
const seek = screen.getByRole("slider", { name: "Seek" });
fireEvent.pointerDown(seek, { pointerType: "touch" });
fireEvent.change(seek, { target: { value: "50" } });
setTime(el, 11, 100);
expect(screen.getByText("0:50")).toBeInTheDocument();
fireEvent.pointerUp(seek, { pointerType: "touch" });
setTime(el, 51, 100);
expect(screen.getByText("0:51")).toBeInTheDocument();
});
test("an error is reported for re-resolving", () => {
const onError = vi.fn();
const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" onError={onError} />,
);
fireEvent(media(container), new Event("error"));
expect(onError).toHaveBeenCalled();
});
test("volume and mute are remembered", async () => {
const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" />,
);
fireEvent.change(screen.getByRole("slider", { name: "Volume" }), { target: { value: "0.4" } });
expect(media(container).volume).toBeCloseTo(0.4);
await userEvent.click(screen.getByRole("button", { name: "Mute" }));
expect(media(container).muted).toBe(true);
expect(JSON.parse(localStorage.getItem("player-volume")!)).toEqual({ volume: 0.4, muted: true });
expect(screen.getByRole("slider", { name: "Volume" })).toHaveValue("0");
// Raising the volume unmutes.
fireEvent.change(screen.getByRole("slider", { name: "Volume" }), { target: { value: "0.7" } });
expect(media(container).muted).toBe(false);
});
test("registers seek and toggle handles for the global player", async () => {
const seekRef = { current: null as ((t: number) => void) | null };
const toggleRef = { current: null as (() => void) | null };
const { container, unmount } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" seekRef={seekRef} toggleRef={toggleRef} />,
);
act(() => seekRef.current!(33));
expect(media(container).currentTime).toBe(33);
await act(async () => toggleRef.current!());
expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument();
unmount();
expect(seekRef.current).toBeNull();
});
test("resumes at a start offset once metadata is in", () => {
const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" startTime={42} />,
);
fireEvent(media(container), new Event("loadedmetadata"));
expect(media(container).currentTime).toBe(42);
});
test("video has a fullscreen button", () => {
renderWithContexts(<MediaPlayer src="https://s/a.mp4" kind="video" />);
expect(screen.getByRole("button", { name: "Fullscreen" })).toBeInTheDocument();
});
});
describe("FilePreview", () => {
const session = makeSession();
const file = (mime: string, over = {}) =>
makeDump({ id: "f1", kind: "file", fileName: "thing", fileMime: mime, fileSize: 2048, url: undefined, ...over });
test("images show inline, with the media token", () => {
renderWithContexts(<FilePreview dump={file("image/png")} />, { session });
expect(screen.getByRole("img", { name: "thing" })).toHaveAttribute(
"src",
`http://localhost:8000/api/files/f1?v=2048&token=${session.mediaToken}`,
);
});
test("other files are a download link", () => {
renderWithContexts(<FilePreview dump={file("application/zip")} />);
const link = screen.getByRole("link", { name: /Download thing/ });
expect(link).toHaveAttribute("download", "thing");
expect(link).toHaveTextContent("(2.0 KB)");
});
test("compact audio plays in the global player", async () => {
const play = vi.fn();
renderWithContexts(<FilePreview dump={file("audio/mpeg")} compact />, { player: { play } });
await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play).toHaveBeenCalledWith(expect.objectContaining({
kind: "file",
mimeType: "audio/mpeg",
title: "Dump f1",
subtitle: "thing",
dumpHref: "/dumps/f1",
artworkUrl: undefined,
}));
});
test("compact video claims its generated still as artwork", async () => {
const play = vi.fn();
renderWithContexts(<FilePreview dump={file("video/mp4")} compact />, { player: { play } });
await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play.mock.calls[0][0].artworkUrl).toBe("http://localhost:8000/api/thumbnails/f1");
});
test("the global video button toggles when it's the one playing", async () => {
const togglePlay = vi.fn();
const d = file("video/mp4");
renderWithContexts(<FilePreview dump={d} global />, {
player: {
togglePlay,
playing: true,
current: { kind: "file", fileUrl: "http://localhost:8000/api/files/f1?v=2048", mimeType: "video/mp4" },
},
});
await userEvent.click(screen.getByRole("button", { name: "Pause" }));
expect(togglePlay).toHaveBeenCalled();
});
test("the global audio waveform starts playback", async () => {
const play = vi.fn();
route("GET /api/files/:id", () => new TypeError("no audio in jsdom"));
renderWithContexts(<FilePreview dump={file("audio/ogg")} global />, { player: { play } });
await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play).toHaveBeenCalledWith(expect.objectContaining({ kind: "file", mimeType: "audio/ogg" }));
});
test("compact PDFs and text get an icon", () => {
const { container } = renderWithContexts(<FilePreview dump={file("application/pdf")} compact />);
expect(container).toHaveTextContent("📄");
});
});
import { route } from "../fetch.ts";

View 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("/");
});
});