v3: the frontend gets a test suite, and the bugs it turned up are fixed — deno task test:web runs 612 tests over src/ in about 11s: Vitest on jsdom with Testing Library, built on the app's own Vite config so the SWC/Lingui transform and the .po loader are what ship. src/test/ holds the harness: a routing fetch stub (queued once-responses, fallback defaults), a FakeWebSocket the tests open, feed and drop, jsdom polyfills, fixtures, and two render helpers — one over hand-set context values, one over the real provider tree (auth, socket, player, follows, categories, chat). It covers every util and hook, every provider (the socket protocol end to end: votes and likes with ack/timeout/error rollback, chat sends/echoes/retries/read markers/reconcile, reconnect backfill), the components (header and search history, cards, Markdown, thumbnails, media and global player, the text editor's mentions/emoji/uploads, comments, the new-dump modal, the chat), and every page.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
Fixed along the way. Dumps: clearing a dump's note sent `comment: undefined`, which JSON drops, so the server kept the old one; it's sent as null now. Replacing a file dropped a visibility change made in the same save (the file endpoint doesn't read it); it follows as a separate update. Renaming a dump onto a new slug kept showing the old title until reload. The thumbnail picker and delete on the edit page failed without a word, the latter as an unhandled rejection. Playlists: an edit that kept the slug waited on the socket's echo to show, and a new cover never appeared (the upload's answer was ignored, a refused one said nothing); covers were also served stale for an hour, since `max-age=3600` met an unversioned URL — setPlaylistImage now bumps updated_at and every cover URL carries `?v=` (playlistImageUrl). Removing a dump, reordering, and deleting a playlist ignored non-OK answers, leaving page and server apart; they restore, reload or report. Profile: clearing "Who am I?" sent undefined and came back on reload; your own upvoted list (and the upvoted page) was empty until the socket's welcome, and forever without one; "View all" under Following linked to a route that doesn't exist and is a Load more now. Chat: Escape on "Delete this message?" closed the whole chat — ConfirmModal takes Escape first, in the capture phase. Player: a pause or play from a media key, headset or the OS wasn't reflected (MediaPlayer follows the element's own play/pause events), and the seek bar tracked dragging with mouse events only, so on touch the thumb fought timeupdate. Feeds: a guest on /~/followed got a blank page; a server error in the followed feed read as "Could not connect". Search: a failed later page threw away the results shown. Notifications: a failed mark-read replaced a loaded list with an error. Silent failures now say so: deleting a comment, the voters/likers "Load more" (stuck on "Loading…"), and the password-reset request, which said "sent" whatever happened. Notifications also stop hiding what was new: the bell still clears on opening the page, but the dots and the "N new" count stay for the visit instead of vanishing the moment the mark-read lands, and one arriving while the page is open is marked read too, so the bell doesn't count it. Accessibility: Modal and ConfirmModal are dialogs (role, aria-modal, labelled/described); playlist membership rows are checkboxes that work from the keyboard; a dump's title, and the profile's email and description, can be opened for editing from the keyboard (the pencil shows on focus); the category editor's ✓ and the global player's video button have names. Player, rich-content and download labels were hard-coded English and are translated, with the new French strings. Lint: fast-refresh and render-purity rules are off for src/test/ only, where probes hand their context out on purpose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
292
src/test/components/buttons.test.tsx
Normal file
292
src/test/components/buttons.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user