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:
452
src/test/hooks/misc.test.tsx
Normal file
452
src/test/hooks/misc.test.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
|
||||
import { type ReactNode, useRef } from "react";
|
||||
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from "react-router";
|
||||
import { useFading } from "../../hooks/useFading.ts";
|
||||
import { useFeedCache } from "../../hooks/useFeedCache.ts";
|
||||
import { useTabParam } from "../../hooks/useTabParam.ts";
|
||||
import {
|
||||
setTitleBadge,
|
||||
SITE_NAME,
|
||||
useDocumentTitle,
|
||||
} from "../../hooks/useDocumentTitle.ts";
|
||||
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll.ts";
|
||||
import { useScrollLock } from "../../hooks/useScrollLock.ts";
|
||||
import { useScrollSave } from "../../hooks/useScrollSave.ts";
|
||||
import { useDefaultFeedTab } from "../../hooks/useDefaultFeedTab.ts";
|
||||
import { useSoundsEnabled } from "../../hooks/useSoundsEnabled.ts";
|
||||
import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts";
|
||||
import { useAnchoredFloat } from "../../hooks/useAnchoredFloat.ts";
|
||||
import { useHeightTransition } from "../../hooks/useHeightTransition.ts";
|
||||
import { isDumpVisited } from "../../utils/visited.ts";
|
||||
import { FakeIntersectionObserver } from "../setup.ts";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("useFading", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
|
||||
test("cooldown for 2s, then dismissing for 350ms, then gone", () => {
|
||||
const { result } = renderHook(useFading);
|
||||
act(() => result.current.startFading("a"));
|
||||
expect(result.current.fading).toEqual({ a: "cooldown" });
|
||||
act(() => vi.advanceTimersByTime(2000));
|
||||
expect(result.current.fading).toEqual({ a: "dismissing" });
|
||||
act(() => vi.advanceTimersByTime(350));
|
||||
expect(result.current.fading).toEqual({});
|
||||
});
|
||||
|
||||
test("starting twice is one animation", () => {
|
||||
const { result } = renderHook(useFading);
|
||||
act(() => result.current.startFading("a"));
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
act(() => result.current.startFading("a"));
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
expect(result.current.fading).toEqual({ a: "dismissing" });
|
||||
});
|
||||
|
||||
test("cancel during cooldown and during dismissal", () => {
|
||||
const { result } = renderHook(useFading);
|
||||
act(() => result.current.startFading("a"));
|
||||
act(() => result.current.cancelFading("a"));
|
||||
expect(result.current.fading).toEqual({});
|
||||
act(() => vi.advanceTimersByTime(5000));
|
||||
expect(result.current.fading).toEqual({});
|
||||
|
||||
act(() => result.current.startFading("b"));
|
||||
act(() => vi.advanceTimersByTime(2100));
|
||||
act(() => result.current.cancelFading("b"));
|
||||
expect(result.current.fading).toEqual({});
|
||||
// Cancelled means it can start over.
|
||||
act(() => result.current.startFading("b"));
|
||||
expect(result.current.fading).toEqual({ b: "cooldown" });
|
||||
});
|
||||
|
||||
test("cancelAll", () => {
|
||||
const { result } = renderHook(useFading);
|
||||
act(() => {
|
||||
result.current.startFading("a");
|
||||
result.current.startFading("b");
|
||||
});
|
||||
act(() => result.current.cancelAll());
|
||||
expect(result.current.fading).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFeedCache", () => {
|
||||
// A MemoryRouter's first entry is a POP, as after Back.
|
||||
const wrapper = () => ({ children }: { children: ReactNode }) => (
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
);
|
||||
|
||||
test("restores a saved feed on a back/forward (POP) visit", () => {
|
||||
const first = renderHook(() => useFeedCache<{ n: number }>("k", (x) => x), {
|
||||
wrapper: wrapper(),
|
||||
});
|
||||
act(() => first.result.current.saveState([{ n: 1 }], 2, true, 300));
|
||||
const second = renderHook(
|
||||
() => useFeedCache<{ n: number }>("k", (x) => ({ n: x.n * 10 })),
|
||||
{ wrapper: wrapper() },
|
||||
);
|
||||
expect(second.result.current.cached).toMatchObject({
|
||||
items: [{ n: 10 }],
|
||||
page: 2,
|
||||
hasMore: true,
|
||||
scrollY: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test("an expired entry is ignored", () => {
|
||||
vi.useFakeTimers();
|
||||
const first = renderHook(() => useFeedCache("k", (x) => x), {
|
||||
wrapper: wrapper(),
|
||||
});
|
||||
act(() => first.result.current.saveState([], 1, false, 0));
|
||||
vi.advanceTimersByTime(11 * 60 * 1000);
|
||||
const second = renderHook(() => useFeedCache("k", (x) => x), {
|
||||
wrapper: wrapper(),
|
||||
});
|
||||
expect(second.result.current.cached).toBeNull();
|
||||
});
|
||||
|
||||
test("a fresh (PUSH) visit ignores the cache", () => {
|
||||
sessionStorage.setItem(
|
||||
"k",
|
||||
JSON.stringify({ items: [1], page: 1, hasMore: false, scrollY: 0, savedAt: Date.now() }),
|
||||
);
|
||||
function Probe() {
|
||||
const { cached } = useFeedCache("k", (x) => x);
|
||||
return <p>{cached ? "cached" : "fresh"}</p>;
|
||||
}
|
||||
function Home() {
|
||||
const navigate = useNavigate();
|
||||
return <button type="button" onClick={() => navigate("/feed")}>go</button>;
|
||||
}
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/feed" element={<Probe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("go"));
|
||||
expect(screen.getByText("fresh")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("corrupt storage reads as no cache; a full quota doesn't throw", () => {
|
||||
sessionStorage.setItem("k", "{");
|
||||
const { result } = renderHook(() => useFeedCache("k", (x) => x), {
|
||||
wrapper: wrapper(),
|
||||
});
|
||||
expect(result.current.cached).toBeNull();
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("QuotaExceededError");
|
||||
});
|
||||
expect(() => result.current.saveState([], 1, false, 0)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTabParam", () => {
|
||||
const TABS = ["dumps", "playlists", "votes"] as const;
|
||||
function setup(path: string, state?: unknown) {
|
||||
let loc!: ReturnType<typeof useLocation>;
|
||||
const hook = renderHook(() => {
|
||||
loc = useLocation();
|
||||
return useTabParam(TABS, "dumps");
|
||||
}, {
|
||||
wrapper: ({ children }) => (
|
||||
<MemoryRouter initialEntries={[{ pathname: path.split("?")[0], search: path.includes("?") ? "?" + path.split("?")[1] : "", state }]}>
|
||||
{children}
|
||||
</MemoryRouter>
|
||||
),
|
||||
});
|
||||
return { hook, loc: () => loc };
|
||||
}
|
||||
|
||||
test("reads the tab after /~/", () => {
|
||||
expect(setup("/users/ann/~/playlists").hook.result.current[0]).toBe("playlists");
|
||||
expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe("playlists");
|
||||
expect(setup("/users/ann").hook.result.current[0]).toBe("dumps");
|
||||
expect(setup("/users/ann/~/bogus").hook.result.current[0]).toBe("dumps");
|
||||
});
|
||||
|
||||
test("the default tab collapses to the base path", () => {
|
||||
const { hook, loc } = setup("/users/ann/~/votes");
|
||||
act(() => hook.result.current[1]("dumps"));
|
||||
expect(loc().pathname).toBe("/users/ann");
|
||||
act(() => hook.result.current[1]("playlists"));
|
||||
expect(loc().pathname).toBe("/users/ann/~/playlists");
|
||||
});
|
||||
|
||||
test("at the root the default stays explicit", () => {
|
||||
const { hook, loc } = setup("/~/votes");
|
||||
act(() => hook.result.current[1]("dumps"));
|
||||
expect(loc().pathname).toBe("/~/dumps");
|
||||
});
|
||||
|
||||
test("keeps the query string and the entry's state", () => {
|
||||
const { hook, loc } = setup("/search?q=cats", { back: "/x" });
|
||||
act(() => hook.result.current[1]("votes"));
|
||||
expect(loc().pathname).toBe("/search/~/votes");
|
||||
expect(loc().search).toBe("?q=cats");
|
||||
expect(loc().state).toEqual({ back: "/x" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDocumentTitle", () => {
|
||||
test("composes page, site name and unread badge", () => {
|
||||
const { rerender } = renderHook(
|
||||
({ title }: { title: string | null | undefined }) => useDocumentTitle(title),
|
||||
{ initialProps: { title: "Cats" as string | null | undefined } },
|
||||
);
|
||||
expect(document.title).toBe(`Cats · ${SITE_NAME}`);
|
||||
act(() => setTitleBadge(3));
|
||||
expect(document.title).toBe(`(3) Cats · ${SITE_NAME}`);
|
||||
act(() => setTitleBadge(150));
|
||||
expect(document.title).toBe(`(99+) Cats · ${SITE_NAME}`);
|
||||
rerender({ title: null });
|
||||
expect(document.title).toBe(`(99+) ${SITE_NAME}`);
|
||||
act(() => setTitleBadge(0));
|
||||
expect(document.title).toBe(SITE_NAME);
|
||||
// undefined leaves it be
|
||||
rerender({ title: undefined });
|
||||
expect(document.title).toBe(SITE_NAME);
|
||||
});
|
||||
|
||||
test("the site name defaults to gerbeur when unsubstituted", () => {
|
||||
expect(SITE_NAME).toBe("gerbeur");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useInfiniteScroll", () => {
|
||||
function Feed({ onMore, enabled }: { onMore: () => void; enabled: boolean }) {
|
||||
const ref = useInfiniteScroll(onMore, enabled);
|
||||
return <div ref={ref} data-testid="sentinel" />;
|
||||
}
|
||||
const intersect = (isIntersecting: boolean) => {
|
||||
const obs = FakeIntersectionObserver.instances.at(-1)!;
|
||||
act(() =>
|
||||
obs.callback(
|
||||
[{ isIntersecting } as IntersectionObserverEntry],
|
||||
obs as unknown as IntersectionObserver,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
test("loads more when the sentinel shows, only while enabled", () => {
|
||||
const onMore = vi.fn();
|
||||
const { rerender } = render(<Feed onMore={onMore} enabled />);
|
||||
expect(FakeIntersectionObserver.instances.at(-1)!.elements.size).toBe(1);
|
||||
intersect(false);
|
||||
expect(onMore).not.toHaveBeenCalled();
|
||||
intersect(true);
|
||||
expect(onMore).toHaveBeenCalledTimes(1);
|
||||
rerender(<Feed onMore={onMore} enabled={false} />);
|
||||
intersect(true);
|
||||
expect(onMore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useScrollLock", () => {
|
||||
function Lock() {
|
||||
useScrollLock();
|
||||
return null;
|
||||
}
|
||||
test("counts nested locks", () => {
|
||||
const root = document.documentElement;
|
||||
const a = render(<Lock />);
|
||||
const b = render(<Lock />);
|
||||
expect(root.style.overflowY).toBe("hidden");
|
||||
a.unmount();
|
||||
expect(root.style.overflowY).toBe("hidden");
|
||||
b.unmount();
|
||||
expect(root.style.overflowY).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useScrollSave", () => {
|
||||
test("debounces scroll positions while enabled", () => {
|
||||
vi.useFakeTimers();
|
||||
const onSave = vi.fn();
|
||||
const { rerender, unmount } = renderHook(
|
||||
({ on }) => useScrollSave(on, onSave),
|
||||
{ initialProps: { on: true } },
|
||||
);
|
||||
globalThis.dispatchEvent(new Event("scroll"));
|
||||
globalThis.dispatchEvent(new Event("scroll"));
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
rerender({ on: false });
|
||||
globalThis.dispatchEvent(new Event("scroll"));
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stored preferences", () => {
|
||||
test("useDefaultFeedTab validates and persists", () => {
|
||||
localStorage.setItem("default-feed-tab", "bogus");
|
||||
const { result } = renderHook(useDefaultFeedTab);
|
||||
expect(result.current[0]).toBe("journal");
|
||||
act(() => result.current[1]("new"));
|
||||
expect(result.current[0]).toBe("new");
|
||||
expect(localStorage.getItem("default-feed-tab")).toBe("new");
|
||||
expect(renderHook(useDefaultFeedTab).result.current[0]).toBe("new");
|
||||
});
|
||||
|
||||
test("useSoundsEnabled", () => {
|
||||
const { result } = renderHook(useSoundsEnabled);
|
||||
expect(result.current[0]).toBe(true);
|
||||
act(() => result.current[1](false));
|
||||
expect(result.current[0]).toBe(false);
|
||||
expect(localStorage.getItem("sounds")).toBe("off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDumpCardLink", () => {
|
||||
function Card() {
|
||||
const link = useDumpCardLink({ id: "d1", slug: "hello" });
|
||||
return (
|
||||
<div data-testid="card" {...link.cardProps}>
|
||||
<a href="/elsewhere">inner link</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
if (!link.handledAsNewTab(e)) (e.currentTarget.dataset.played = "1");
|
||||
}}
|
||||
>
|
||||
play
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function setup() {
|
||||
const open = vi.spyOn(globalThis, "open").mockImplementation(() => null);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Card />} />
|
||||
<Route path="/dumps/:id" element={<p>dump page</p>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
return { open };
|
||||
}
|
||||
|
||||
test("a click navigates and marks visited", () => {
|
||||
setup();
|
||||
fireEvent.click(screen.getByTestId("card"));
|
||||
expect(screen.getByText("dump page")).toBeInTheDocument();
|
||||
expect(isDumpVisited("d1")).toBe(true);
|
||||
});
|
||||
|
||||
test("ctrl/cmd/shift-click opens a new tab", () => {
|
||||
const { open } = setup();
|
||||
fireEvent.click(screen.getByTestId("card"), { ctrlKey: true });
|
||||
expect(open).toHaveBeenCalledWith("/dumps/hello", "_blank", "noopener");
|
||||
});
|
||||
|
||||
test("a middle click opens a new tab, except over a link", () => {
|
||||
const { open } = setup();
|
||||
const mouseDown = new MouseEvent("mousedown", { button: 1, bubbles: true, cancelable: true });
|
||||
screen.getByTestId("card").dispatchEvent(mouseDown);
|
||||
expect(mouseDown.defaultPrevented).toBe(true);
|
||||
fireEvent(screen.getByTestId("card"), new MouseEvent("auxclick", { button: 1, bubbles: true }));
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
fireEvent(screen.getByText("inner link"), new MouseEvent("auxclick", { button: 1, bubbles: true }));
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
fireEvent(screen.getByTestId("card"), new MouseEvent("auxclick", { button: 2, bubbles: true }));
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a modified click on the play button opens the dump instead of playing", () => {
|
||||
const { open } = setup();
|
||||
const play = screen.getByText("play");
|
||||
fireEvent.click(play, { metaKey: true });
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(play.dataset.played).toBeUndefined();
|
||||
fireEvent.click(play);
|
||||
expect(play.dataset.played).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAnchoredFloat", () => {
|
||||
function Float({ open, top }: { open: boolean; top: number }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const style = useAnchoredFloat(ref, open, {
|
||||
width: 300,
|
||||
chromeHeight: 40,
|
||||
minContentHeight: 100,
|
||||
maxContentHeight: 400,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={(el) => {
|
||||
ref.current = el;
|
||||
if (el) {
|
||||
el.getBoundingClientRect = () =>
|
||||
({ top, bottom: top + 20, left: 900, right: 1000, width: 100, height: 20 }) as DOMRect;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<output data-testid="style">{JSON.stringify(style)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const styleOf = () => JSON.parse(screen.getByTestId("style").textContent!);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("innerWidth", 1024);
|
||||
vi.stubGlobal("innerHeight", 768);
|
||||
});
|
||||
|
||||
test("null while closed", () => {
|
||||
render(<Float open={false} top={100} />);
|
||||
expect(styleOf()).toBeNull();
|
||||
});
|
||||
|
||||
test("opens below an anchor near the top, clamped into the viewport", () => {
|
||||
render(<Float open top={100} />);
|
||||
const s = styleOf();
|
||||
expect(s.position).toBe("fixed");
|
||||
expect(s.top).toBe(124);
|
||||
expect(s.bottom).toBe("auto");
|
||||
expect(s.left).toBe(1024 - 300 - 8);
|
||||
expect(s["--float-max-h"]).toBe("400px");
|
||||
});
|
||||
|
||||
test("flips above an anchor near the bottom", () => {
|
||||
render(<Float open top={700} />);
|
||||
const s = styleOf();
|
||||
expect(s.bottom).toBe(768 - 700 + 4);
|
||||
expect(s.top).toBe("auto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useHeightTransition", () => {
|
||||
test("animates from the old height to the new one when watch changes", () => {
|
||||
const animate = vi.spyOn(Element.prototype, "animate");
|
||||
let height = 100;
|
||||
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(
|
||||
() => ({ height }) as DOMRect,
|
||||
);
|
||||
function Box({ open }: { open: boolean }) {
|
||||
const ref = useHeightTransition<HTMLDivElement>(open);
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
const { rerender } = render(<Box open={false} />);
|
||||
height = 250;
|
||||
rerender(<Box open />);
|
||||
expect(animate).toHaveBeenCalledTimes(1);
|
||||
const [frames] = animate.mock.calls[0] as unknown as [Keyframe[]];
|
||||
expect(frames[0].height).toBe("100px");
|
||||
expect(frames[1].height).toBe("250px");
|
||||
|
||||
// No change in height, no animation.
|
||||
rerender(<Box open={false} />);
|
||||
expect(animate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user