v3: dependencies upgraded and deno audit clean, every API response typed end to end, no explicit any left — and the site search stops handing out password hashes — deno audit reported 19 advisories (11 high); none remain. nodemailer 9 → 10.0.10 closes four of them (a quadratic address parser, two recipient-domain allow-list bypasses, resolveContent ignoring disableFileAccess/disableUrlAccess); 10's one break is Node ≥ 20, and 10.0.11 is a day too young for the minimum release age. react-router 8.0.1 → 8.4.0 closes the RSC-mode CSRF bypass. Refreshing the lockfile clears the transitive ones that came in through Vite, ESLint and Lingui: postcss, nanoid, picomatch, browserslist, brace-expansion and baseline-browser-mapping. Everything else moves to its latest too — Lingui 6.8 (its SWC plugin 6.7), React 19.3, Vite 8.3.1, react-hook-form 7.89, marked, jose, ESLint 10.11, typescript-eslint 8.70, the @types — and frimousse 0.3 → 0.4, whose declarations only add (custom emoji data resolvers, a cache). TypeScript stays on 6.0: typescript-eslint supports < 6.1, so 7.0 would stop the lint. allowScripts names @swc/core 1.16.2 and drops esbuild, which Vite 8 no longer installs.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s

Search: GET /api/search sent each matching user's full row — password hash and email included — to anyone, signed in or not, because searchUsersGlobal returned User[] and the route passed it through. It returns profiles only now, stripped where the rows are read, and the search test fails on the old code. Types alone wouldn't have caught it (a User is structurally a PublicUser), so the strip is explicit and tested.

Typed routes. Every JSON response is declared as APIResponse<T>, the way the typed routes already did, with the missing shapes named in api/model/interfaces.ts: PublicUser (no hash, no email) and SelfUser (your own profile, with email) plus toPublicUser/toSelfUser in place of ten hand-written destructurings, AuthTokens, AuthResponse, MediaTokenResponse, InviteResponse, AttachmentResponse, SearchResults, LinkPreview, UserSuggestion and Invitee. Request bodies are `unknown` and narrowed by the existing guards, or by a new fieldOf(obj, key) where a route reads one field — no more reading off Oak's `any` or casting to Record<string, unknown>. The password-reset routes answer `{ success: true, data: null }` rather than a bare `{ success: true }`, so every success fits the envelope; the frontend only reads `success`.

Socket protocol: routes/ws.ts had its own send() taking Record<string, unknown>, so nothing checked what it sent. It takes ServerToClientMessage now, which turned up two mismatches: `pong` was missing from the union although the server answers every client ping with one, and vote_ack carried a `success: true` no type declares and no client reads. The union has PongMessage and vote_ack lost the field.

No explicit any. The YouTube playlist parse reads ytInitialData as `unknown` through a small at(node, …path) that checks each step, instead of `type Json = any`. The backend test helpers type the response body as the server's envelope: request<T>() and get/post/put/patch/del<T>() name what `data` holds, res.data throws with the server's message when the call didn't succeed and res.error when it didn't fail, and each test says which API type it expects, so a test reading a field the route doesn't send no longer compiles. Assertions that a field is absent are `assertFalse("passwordHash" in …)` rather than reading a field the type says isn't there. The socket tests' messages are ServerToClientMessage, with next()/all() keyed by type so each returns that message's shape.

No double casts in src/ either. window.YT and onYouTubeIframeAPIReady are declared on Window; the test's fake player is a class forwarding to the spies the tests inspect. WSContext, FollowContext and PlayerContext export their defaults (defaultWSValue, defaultFollowValue, defaultPlayerValue), which the tests import instead of reading React's private _currentValue. handleMentionKeyDown takes a MentionKey — the key fields it reads — so the test builds a plain object; useUserDumpFeed declares sentinelRef as the callback ref it is. In src/test/setup.ts the IntersectionObserver fake implements IntersectionObserver, and report(isIntersecting, top?) builds complete entries in place of partial ones cast in five files; the Animation fake implements Animation; the canvas getContext stub goes in through defineProperty. The XHR fake's last request is optional behind a getter that fails if none was made, the keyframes are checked with matchers, and the roleless legacy session is built by leaving the role out. One test changes meaning slightly: "leaving the view…" relied on entries with no position at all, and now leaves and re-enters at the same height, which is what it describes. The one cast left is in api/test/ws.test.ts: deno.json's "dom" lib hides Deno's WebSocket overload that takes handshake headers, and dropping that lib would break the editor's view of src/; it's commented and names the real signature.

Also: the notifications test answered a Response-typed promise with a plain object, which failed `tsc -b`; it uses the fetch stub's deferred() now. ESLint didn't read the `deno-lint-ignore no-explicit-any` comments, so `eslint .` failed on three files; those anys are gone. `deno fmt` over api/ and src/ (not the compiled src/locales/*.js); the reflow left all 332 message ids intact, and the .po files only have their source line references updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 16:05:29 +00:00
parent c8bf0d583c
commit 364c498a24
116 changed files with 5266 additions and 2503 deletions

View File

@@ -35,14 +35,34 @@ 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) }),
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(screen.getByText("two").closest(".chat-message")).toHaveClass(
"chat-message--grouped",
);
expect(calls("GET /api/chat")[0].url.searchParams.get("limit")).toBe("50");
});
@@ -57,27 +77,55 @@ describe("ChatModal: history", () => {
});
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) })
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),
);
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 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"))!;
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();
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) }),
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");
@@ -91,7 +139,10 @@ describe("ChatModal: history", () => {
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 });
expect(sent(socket, "chat_focus")).toContainEqual({
type: "chat_focus",
open: true,
});
});
});
@@ -106,7 +157,15 @@ describe("ChatModal: sending", () => {
socket.receive({
type: "chat_message",
nonce: send.nonce,
message: wire(makeChatMessage({ id: "echo", userId: "me", authorUsername: "me", body: "hello there", createdAt: new Date() })),
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();
@@ -130,13 +189,25 @@ describe("ChatModal: sending", () => {
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;
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" });
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");
@@ -144,21 +215,32 @@ describe("ChatModal: sending", () => {
});
test("typing is announced, and its end too", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"], shouldAdvanceTime: true });
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 }]);
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 });
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?" })]);
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");
@@ -167,38 +249,68 @@ describe("ChatModal: sending", () => {
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) });
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) });
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;
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");
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" });
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();
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();
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();
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 () => {
@@ -208,33 +320,51 @@ describe("ChatModal: replies, edits, deletes", () => {
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(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;
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(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;
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?");
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" }]);
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();
});
@@ -242,10 +372,17 @@ describe("ChatModal: replies, edits, deletes", () => {
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" }),
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();
expect(screen.getByText("deleted message").closest("button"))
.toBeDisabled();
});
});
@@ -254,19 +391,31 @@ describe("ChatModal: live", () => {
const { socket } = await setup();
socket.receive({
type: "chat_message",
message: wire(makeChatMessage({ id: "x", body: "hey @me look", createdAt: new Date() })),
message: wire(
makeChatMessage({
id: "x",
body: "hey @me look",
createdAt: new Date(),
}),
),
});
expect(screen.getByText(/look/).closest(".chat-message")).toHaveClass("chat-message--mention");
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("🎉🎉");
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 }] });
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",
@@ -276,7 +425,10 @@ describe("ChatModal: live", () => {
],
});
expect(screen.getByText("ann and bob are typing…")).toBeInTheDocument();
socket.receive({ type: "chat_typing_update", users: [{ userId: "me", username: "me", hasAvatar: false }] });
socket.receive({
type: "chat_typing_update",
users: [{ userId: "me", username: "me", hasAvatar: false }],
});
expect(screen.queryByText(/typing…/)).not.toBeInTheDocument();
});
@@ -289,16 +441,27 @@ describe("ChatModal: live", () => {
});
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 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();
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());
expect(resync.url.searchParams.get("after")).toBe(
old.createdAt.toISOString(),
);
});
});

View File

@@ -23,7 +23,9 @@ function setup(initial: Comment[], session: AuthResponse | null = me, ws = {}) {
token={session?.token ?? null}
onCommentCreated={(c) => setComments((prev) => [...prev, c])}
onCommentDeleted={(id) =>
setComments((prev) => prev.map((c) => c.id === id ? { ...c, deleted: true } : c))}
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))}
/>
@@ -33,9 +35,16 @@ function setup(initial: Comment[], session: AuthResponse | null = me, 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 }));
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", () => {
@@ -44,7 +53,8 @@ describe("CommentThread", () => {
makeComment({ id: "b", parentId: "a", body: "reply" }),
makeComment({ id: "c", deleted: true, body: "" }),
]);
expect(screen.getByRole("heading", { name: "2 comments" })).toBeInTheDocument();
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();
@@ -52,8 +62,10 @@ describe("CommentThread", () => {
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.queryByPlaceholderText("Add a comment…")).not
.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reply" })).not
.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Like" })).toBeDisabled();
});
@@ -66,8 +78,11 @@ describe("CommentThread", () => {
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();
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 () => {
@@ -79,7 +94,9 @@ describe("CommentThread", () => {
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));
await waitFor(() =>
expect(calls("POST /api/dumps/:id/comments")).toHaveLength(1)
);
});
test("a failed post keeps the text and says why", async () => {
@@ -96,7 +113,8 @@ describe("CommentThread", () => {
test("the top-level Cancel only shows with text, and clears it", async () => {
const user = setup([]);
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
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("");
@@ -110,8 +128,14 @@ describe("CommentThread", () => {
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" });
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();
});
@@ -119,14 +143,31 @@ describe("CommentThread", () => {
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();
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" })]);
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);
@@ -140,7 +181,10 @@ describe("CommentThread", () => {
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());
await waitFor(() =>
expect(screen.queryByRole("button", { name: "Save" })).not
.toBeInTheDocument()
);
expect(calls("PATCH /api/comments/:id")).toHaveLength(0);
});
@@ -151,10 +195,13 @@ describe("CommentThread", () => {
];
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();
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();
expect(within(mine).getByRole("button", { name: "Delete" }))
.toBeInTheDocument();
});
test("a moderator can delete anyone's", () => {
@@ -163,7 +210,10 @@ describe("CommentThread", () => {
});
test("deleting asks first", async () => {
route("DELETE /api/comments/:id", () => new Response(null, { status: 204 }));
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");
@@ -176,8 +226,14 @@ describe("CommentThread", () => {
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);
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();
});
@@ -194,7 +250,9 @@ describe("CommentThread", () => {
test("the timestamp links to the comment", () => {
setup([makeComment({ id: "a" })]);
expect(screen.getByRole("link", { name: /ago/ })).toHaveAttribute("href", "/dumps/d1#comment-a");
expect(screen.getByRole("link", { name: /ago/ })).toHaveAttribute(
"href",
"/dumps/d1#comment-a",
);
});
});

View File

@@ -1,15 +1,29 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
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 {
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>> = {}) {
function setup(
props: Partial<React.ComponentProps<typeof DumpCreateModal>> = {},
) {
const onClose = vi.fn();
const injectDump = vi.fn();
const user = userEvent.setup();
@@ -21,11 +35,18 @@ function setup(props: Partial<React.ComponentProps<typeof DumpCreateModal>> = {}
return { user, onClose, injectDump };
}
function previewRoutes({ title = "Page title", reached = true, dupes = [] as unknown[] } = {}) {
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"),
title,
siteName: "Site",
}
: { type: "link", url: req.url.searchParams.get("url") },
}));
route("GET /api/dumps/by-url", () => dupes);
@@ -37,7 +58,8 @@ 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(screen.getByRole("dialog", { name: "New dump" }))
.toBeInTheDocument();
expect(nextBtn()).toBeDisabled();
await user.type(linkBox(), "not a url at all");
expect(nextBtn()).toBeDisabled();
@@ -61,21 +83,39 @@ describe("DumpCreateModal: a link", () => {
previewRoutes({ reached: false });
const { user } = setup();
await user.type(linkBox(), "https://down.test/");
expect(await screen.findByText(/Couldn't load a preview/)).toBeInTheDocument();
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 },
{
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");
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();
@@ -83,10 +123,18 @@ describe("DumpCreateModal: a link", () => {
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 });
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(
"GET /api/playlists/by-dump/:id/memberships",
() => [{ playlist: wire(mix), hasDump: false }],
);
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
const { user, injectDump, onClose } = setup();
@@ -109,7 +157,9 @@ describe("DumpCreateModal: a link", () => {
isPrivate: true,
categoryIds: ["c1"],
});
expect(injectDump).toHaveBeenCalledWith(expect.objectContaining({ id: "new" }));
expect(injectDump).toHaveBeenCalledWith(
expect.objectContaining({ id: "new" }),
);
expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull();
const row = await screen.findByRole("checkbox", { name: /Mix/ });
@@ -117,7 +167,9 @@ describe("DumpCreateModal: a link", () => {
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");
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();
@@ -126,7 +178,13 @@ describe("DumpCreateModal: a link", () => {
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(
"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}");
@@ -143,14 +201,18 @@ describe("DumpCreateModal: a link", () => {
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 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" }));
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);
@@ -158,7 +220,9 @@ describe("DumpCreateModal: a link", () => {
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" });
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 () => {
@@ -180,7 +244,8 @@ describe("DumpCreateModal: a link", () => {
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();
expect(screen.getByRole("dialog", { name: "New dump" }))
.toBeInTheDocument();
});
test("starts from a shared URL", async () => {
@@ -206,21 +271,28 @@ describe("DumpCreateModal: a link", () => {
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"],
}));
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(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");
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("");
@@ -231,14 +303,20 @@ describe("DumpCreateModal: drafts", () => {
previewRoutes();
const { user } = setup();
await user.type(linkBox(), "https://keep.test/");
expect(JSON.parse(localStorage.getItem("gerbeur.dumpDraft")!).url).toBe("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" }));
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(screen.queryByText("Picked up where you left off.")).not
.toBeInTheDocument();
expect(linkBox()).toHaveValue("https://new.test/");
});
});
@@ -252,7 +330,10 @@ describe("DumpCreateModal: a file", () => {
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) };
upload = {
addEventListener: (t: string, f: (e: unknown) => void) =>
(this.u[t] ??= []).push(f),
};
constructor() {
FakeXHR.last = this;
}
@@ -265,7 +346,9 @@ describe("DumpCreateModal: a file", () => {
this.body = body;
}
progress(f: number) {
this.u.progress?.forEach((fn) => fn({ loaded: f * 100, total: 100, lengthComputable: true }));
this.u.progress?.forEach((fn) =>
fn({ loaded: f * 100, total: 100, lengthComputable: true })
);
}
respond(status: number, data: unknown) {
this.status = status;
@@ -275,7 +358,8 @@ describe("DumpCreateModal: a file", () => {
}
afterEach(() => vi.unstubAllGlobals());
const song = () => new File([new Uint8Array(10)], "my_song-final.mp3", { type: "audio/mpeg" });
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);
@@ -286,19 +370,29 @@ describe("DumpCreateModal: a file", () => {
fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, {
dataTransfer: { files: [song()] },
});
expect(screen.queryByPlaceholderText("Paste a link…")).not.toBeInTheDocument();
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();
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 act(async () =>
FakeXHR.last.respond(201, {
success: true,
data: wire(makeDump({ id: "f" })),
})
);
await screen.findByRole("dialog", { name: "Add to playlist" });
});
@@ -310,9 +404,16 @@ describe("DumpCreateModal: a file", () => {
});
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();
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();

View File

@@ -22,7 +22,11 @@ describe("Modal", () => {
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>);
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}");
@@ -34,7 +38,9 @@ describe("Modal", () => {
test("onBeforeClose can refuse", async () => {
const onClose = vi.fn();
const user = userEvent.setup();
wrap(<Modal title="T" onClose={onClose} onBeforeClose={() => false}>x</Modal>);
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();
@@ -60,7 +66,11 @@ describe("ConfirmModal", () => {
const onCancel = vi.fn();
const user = userEvent.setup();
wrap(
<ConfirmModal message="Really?" onConfirm={onConfirm} onCancel={onCancel} />,
<ConfirmModal
message="Really?"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
const dialog = screen.getByRole("alertdialog");
expect(dialog).toHaveAccessibleDescription("Really?");
@@ -74,7 +84,12 @@ describe("ConfirmModal", () => {
test("takes a custom label", () => {
wrap(
<ConfirmModal message="m" confirmLabel="Discard" onConfirm={() => {}} onCancel={() => {}} />,
<ConfirmModal
message="m"
confirmLabel="Discard"
onConfirm={() => {}}
onCancel={() => {}}
/>,
);
expect(screen.getByRole("button", { name: "Discard" })).toBeInTheDocument();
});

View File

@@ -1,8 +1,17 @@
import { describe, expect, test, vi } from "vitest";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
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 {
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";
@@ -54,7 +63,9 @@ describe("TextEditor basics", () => {
await user.keyboard("{Meta>}{Enter}{/Meta}");
expect(onSubmit).toHaveBeenCalledTimes(2);
await user.keyboard("{Enter}");
expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" }));
expect(onKeyDown).toHaveBeenCalledWith(
expect.objectContaining({ key: "Enter" }),
);
});
test("an Enter confirming an IME candidate does nothing else", () => {
@@ -81,7 +92,9 @@ describe("TextEditor basics", () => {
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(screen.getByText("11 / 10")).toHaveClass(
"text-editor-count--danger",
);
expect(box).toHaveAttribute("aria-invalid", "true");
});
@@ -92,7 +105,9 @@ describe("TextEditor basics", () => {
test("the handle focuses at the end", () => {
const ref = createRef<TextEditorHandle>();
renderWithContexts(<TextEditor ref={ref} value="abc" onChange={() => {}} />);
renderWithContexts(
<TextEditor ref={ref} value="abc" onChange={() => {}} />,
);
act(() => ref.current!.focus({ atEnd: true }));
const box = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(box).toHaveFocus();
@@ -152,7 +167,9 @@ describe("mentions", () => {
await user.type(box, "@al");
await screen.findByRole("listbox");
await user.keyboard("{Enter}");
expect(onKeyDown).not.toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" }));
expect(onKeyDown).not.toHaveBeenCalledWith(
expect.objectContaining({ key: "Enter" }),
);
expect(box).toHaveValue("@alice ");
});
@@ -172,9 +189,13 @@ describe("image upload", () => {
const { user, box } = setup({}, "look: ");
await user.click(box);
box.setSelectionRange(6, 6);
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } });
fireEvent.paste(box, {
clipboardData: { types: ["Files"], files: [imageFile()] },
});
await waitFor(() =>
expect(box).toHaveValue("look: ![](http://localhost:8000/api/attachments/att1)")
expect(box).toHaveValue(
"look: ![](http://localhost:8000/api/attachments/att1)",
)
);
const [req] = calls("POST /api/attachments");
expect(req.body).toBeInstanceOf(FormData);
@@ -195,14 +216,20 @@ describe("image upload", () => {
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…");
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" } })))
pending.resolve(
new Response(JSON.stringify({ success: true, data: { id: "z" } })),
)
);
await waitFor(() => expect(box).not.toHaveAttribute("readonly"));
});
@@ -210,29 +237,47 @@ describe("image upload", () => {
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.");
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 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(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)}` }));
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: [] } });
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")] },
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);
@@ -245,11 +290,14 @@ describe("emoji", () => {
const onCancel = vi.fn();
const { user, box } = setup({ onCancel });
await user.type(box, "hi :astro");
const search = await screen.findByRole("searchbox", { name: "Search emoji" });
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(screen.queryByRole("searchbox", { name: "Search emoji" })).not
.toBeInTheDocument();
expect(onCancel).not.toHaveBeenCalled();
expect(box).toHaveValue("hi :astro");
});
@@ -267,7 +315,8 @@ describe("emoji", () => {
serveEmojibase();
const { user, box } = setup();
await user.type(box, "ok :D at 12:30");
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument();
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not
.toBeInTheDocument();
});
test("the button browses and inserts at the caret", async () => {
@@ -276,11 +325,16 @@ describe("emoji", () => {
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" });
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" }));
await user.click(
await screen.findByRole("gridcell", { name: "Astronaut" }),
);
expect(box).toHaveValue("a🧑‍🚀b");
});
@@ -290,6 +344,7 @@ describe("emoji", () => {
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();
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not
.toBeInTheDocument();
});
});

View File

@@ -5,7 +5,10 @@ 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 {
FollowPlaylistButton,
FollowUserButton,
} from "../../components/FollowButton.tsx";
import { UserListPopover } from "../../components/UserListPopover.tsx";
import { NotificationBell } from "../../components/NotificationBell.tsx";
import { ChatButton } from "../../components/ChatButton.tsx";
@@ -13,12 +16,14 @@ 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";
import { currentPath, renderWithContexts } from "../render.tsx";
import { defaultWSValue, 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} />);
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",
@@ -34,18 +39,25 @@ describe("Avatar", () => {
test("a live override wins over stale props, and retries a failed image", () => {
const { rerender } = renderWithContexts(
<AvatarOverrideContext.Provider value={{ u1: { hasAvatar: false, version: 1 } }}>
<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 } }}>
<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"));
expect(screen.getByRole("img")).toHaveAttribute(
"src",
expect.stringContaining("?v=2"),
);
});
});
@@ -55,18 +67,39 @@ describe("VoteButton / LikeButton", () => {
const onRemove = vi.fn();
const user = userEvent.setup();
const { rerender } = renderWithContexts(
<VoteButton dumpId="d" count={0} voted={false} onCast={onCast} onRemove={onRemove} />,
<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} />);
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={() => {}} />,
<VoteButton
dumpId="d"
count={1}
voted={false}
disabled
onCast={() => {}}
onRemove={() => {}}
/>,
);
const btn = screen.getByRole("button", { name: "Upvote" });
expect(btn).toBeDisabled();
@@ -75,17 +108,27 @@ describe("VoteButton / LikeButton", () => {
test("the count opens who voted, without voting", async () => {
const onCast = vi.fn();
route("GET /api/dumps/:id/voters", () => page([makeUser({ username: "fan" })]));
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={() => {}} />,
<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",
);
expect(await screen.findByRole("menuitem", { name: /fan/ }))
.toHaveAttribute(
"href",
"/users/fan",
);
});
test("likes work the same way", async () => {
@@ -94,13 +137,27 @@ describe("VoteButton / LikeButton", () => {
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} />,
<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} />);
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);
});
@@ -113,28 +170,40 @@ describe("UserListPopover", () => {
<>
<span ref={ref}>anchor</span>
<button type="button">outside</button>
<UserListPopover anchorRef={ref} onClose={onClose} fetchUrl="http://localhost:8000/api/x/voters" />
<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));
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 /);
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 });
route("GET /api/x/voters", () => fail("Dump not found", 404), {
once: true,
});
const first = renderWithContexts(<Harness />);
await screen.findByText("Dump not found");
first.unmount();
@@ -144,9 +213,16 @@ describe("UserListPopover", () => {
});
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",
() => 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));
route(
"GET /api/x/voters",
() => page([makeUser({ username: "b" })], false),
);
const user = userEvent.setup();
renderWithContexts(<Harness />);
await screen.findByText("a");
@@ -184,7 +260,10 @@ describe("FollowButton", () => {
);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
unmount();
renderWithContexts(<FollowUserButton targetUserId="me" targetUsername="me" />, { session: me });
renderWithContexts(
<FollowUserButton targetUserId="me" targetUsername="me" />,
{ session: me },
);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
@@ -206,10 +285,13 @@ describe("FollowButton", () => {
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 },
});
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);
@@ -217,10 +299,13 @@ describe("FollowButton", () => {
});
test("disabled until follows have loaded", () => {
renderWithContexts(<FollowUserButton targetUserId="u" targetUsername="ann" />, {
session: me,
follows: { isLoaded: false },
});
renderWithContexts(
<FollowUserButton targetUserId="u" targetUsername="ann" />,
{
session: me,
follows: { isLoaded: false },
},
);
expect(screen.getByRole("button")).toBeDisabled();
});
@@ -236,7 +321,11 @@ describe("FollowButton", () => {
priv.unmount();
renderWithContexts(<FollowPlaylistButton targetPlaylistId="p" isPublic />, {
session: me,
follows: { followPlaylist, unfollowPlaylist, followedPlaylistIds: new Set(["p"]) },
follows: {
followPlaylist,
unfollowPlaylist,
followedPlaylistIds: new Set(["p"]),
},
});
await user.click(screen.getByRole("button", { name: "Unfollow playlist" }));
expect(unfollowPlaylist).toHaveBeenCalledWith("p");
@@ -246,8 +335,12 @@ describe("FollowButton", () => {
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)" });
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");
@@ -258,7 +351,9 @@ describe("NotificationBell / ChatButton", () => {
try {
const n1 = makeNotification({ id: "n1" });
const withLast = (n: ReturnType<typeof makeNotification>) => (
<WSContext.Provider value={{ ...defaultOf(WSContext), lastNotification: n }}>
<WSContext.Provider
value={{ ...defaultWSValue, lastNotification: n }}
>
<NotificationBell />
</WSContext.Provider>
);
@@ -280,7 +375,9 @@ describe("NotificationBell / ChatButton", () => {
const openChat = vi.fn();
const user = userEvent.setup();
renderWithContexts(
<ChatContext.Provider value={{ isChatOpen: false, openChat, closeChat: () => {} }}>
<ChatContext.Provider
value={{ isChatOpen: false, openChat, closeChat: () => {} }}
>
<ChatButton />
</ChatContext.Provider>,
{ ws: { unreadChatCount: 4 } },

View File

@@ -19,7 +19,9 @@ describe("Markdown", () => {
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("table").parentElement).toHaveClass(
"md-table-wrap",
);
expect(screen.getByRole("checkbox")).toBeChecked();
});
@@ -51,11 +53,25 @@ describe("Markdown", () => {
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");
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", () => {
@@ -71,7 +87,10 @@ describe("Markdown", () => {
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");
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();
@@ -96,15 +115,20 @@ describe("Thumbnail", () => {
test("direct, then proxied, then the placeholder", () => {
renderWithContexts(
<Thumbnail src="https://img.test/a.png" alt="art" placeholder={placeholder} />,
<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=",
);
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();
@@ -112,16 +136,28 @@ describe("Thumbnail", () => {
test("our own images skip the proxy retry", () => {
renderWithContexts(
<Thumbnail src="http://localhost:8000/api/thumbnails/d" alt="t" placeholder={placeholder} />,
<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" />);
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" />);
rerender(
<Thumbnail
src="https://img.test/b.png"
placeholder={placeholder}
alt="t"
/>,
);
expect(screen.getByRole("img", { name: "t" })).toBeInTheDocument();
});
});
@@ -129,17 +165,31 @@ describe("Thumbnail", () => {
describe("ThumbnailPlaceholder", () => {
test("tints by the page colour, or by hostname", () => {
const { container, rerender } = renderWithContexts(
<ThumbnailPlaceholder url="https://www.site.test/x" accentColor="#123456" />,
<ThumbnailPlaceholder
url="https://www.site.test/x"
accentColor="#123456"
/>,
);
const box = () => container.querySelector<HTMLElement>(".thumb-placeholder")!;
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"));
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" />,
<ThumbnailPlaceholder
url="https://site.test"
faviconUrl="https://site.test/f.ico"
/>,
);
const icon = container.querySelector("img")!;
fireEvent.error(icon);
@@ -163,10 +213,16 @@ describe("RichContentCard", () => {
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);
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({
@@ -183,12 +239,21 @@ describe("RichContentCard", () => {
current: { kind: "embed", embedUrl: yt.embedUrl, type: "youtube" },
},
});
expect(screen.getByRole("button", { name: "Pause" })).toHaveClass("is-playing");
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 />,
<RichContentCard
richContent={{
type: "link",
url: "https://blog.test/p",
siteName: "Blog",
}}
compact
/>,
);
expect(screen.getByRole("link", { name: "Open on Blog" })).toHaveAttribute(
"href",
@@ -198,7 +263,10 @@ describe("RichContentCard", () => {
test("a dump thumbnail overrides the provider's", () => {
renderWithContexts(
<RichContentCard richContent={yt} thumbnailOverrideUrl="http://localhost:8000/api/thumbnails/d" />,
<RichContentCard
richContent={yt}
thumbnailOverrideUrl="http://localhost:8000/api/thumbnails/d"
/>,
);
expect(screen.getByRole("img", { name: "A video" })).toHaveAttribute(
"src",
@@ -209,7 +277,11 @@ describe("RichContentCard", () => {
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" };
const bc = {
type: "bandcamp",
url: "https://a.bandcamp.com/album/x",
embedUrl: "https://bandcamp.com/EmbeddedPlayer/1",
};
renderWithContexts(<RichContentCard richContent={bc} compact />, {
player: {
togglePlay,
@@ -249,32 +321,57 @@ describe("DumpCard", () => {
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");
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");
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>);
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>);
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>);
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>,
<ul>
<DumpCard dump={makeDump({ isPrivate: true })} {...props} isOwner />
</ul>,
);
expect(screen.getByText("private")).toBeInTheDocument();
});
@@ -283,7 +380,9 @@ describe("DumpCard", () => {
const castVote = vi.fn();
const dump = makeDump({ id: "d3" });
renderWithContexts(
<ul><DumpCard dump={dump} {...props} castVote={castVote} /></ul>,
<ul>
<DumpCard dump={dump} {...props} castVote={castVote} />
</ul>,
{ session: makeSession() },
);
await userEvent.click(screen.getByRole("button", { name: "Upvote" }));
@@ -296,11 +395,21 @@ describe("DumpCard", () => {
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({
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);
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

@@ -20,10 +20,10 @@ 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 { defaultWSValue, 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";
import { currentPath, renderWithContexts } from "../render.tsx";
const categories: Category[] = [
makeCategory({ id: "c1", slug: "music", name: "Music" }),
@@ -67,7 +67,12 @@ describe("SearchBar", () => {
});
test("takes focus when it opens, not when it mounts open", () => {
const props = { value: "", onValueChange: () => {}, onSubmit: () => {}, onCancel: () => {} };
const props = {
value: "",
onValueChange: () => {},
onSubmit: () => {},
onCancel: () => {},
};
const { rerender } = renderWithContexts(<SearchBar expanded {...props} />);
expect(screen.getByRole("searchbox")).not.toHaveFocus();
rerender(<SearchBar expanded={false} {...props} />);
@@ -91,7 +96,10 @@ describe("UserMenu", () => {
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: "@ann" })).toHaveAttribute(
"href",
"/users/ann",
);
expect(screen.getByRole("menuitem", { name: "Playlists" })).toHaveAttribute(
"href",
"/users/ann/playlists",
@@ -136,10 +144,13 @@ describe("PresenceRow", () => {
});
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);
const width = vi.spyOn(HTMLElement.prototype, "clientWidth", "get")
.mockReturnValue(100);
renderWithContexts(
<div className="bar">
<div className="tabs"><span>a</span></div>
<div className="tabs">
<span>a</span>
</div>
<PresenceRow fitWithin=".bar" fitBeside=".tabs" />
</div>,
{ ws: { onlineUsers: online(9) } },
@@ -155,7 +166,10 @@ describe("PresenceRow", () => {
describe("CategorySwitcher", () => {
test("hidden with no categories", () => {
renderWithContexts(<CategorySwitcher />, { route: "/~/hot", path: "/:x/:feedTab" });
renderWithContexts(<CategorySwitcher />, {
route: "/~/hot",
path: "/:x/:feedTab",
});
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
@@ -168,7 +182,10 @@ describe("CategorySwitcher", () => {
});
const trigger = screen.getByRole("button", { name: "Music" });
await user.click(trigger);
expect(screen.getByRole("option", { name: "Music" })).toHaveAttribute("aria-selected", "true");
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();
@@ -188,7 +205,11 @@ describe("CategorySwitcher", () => {
test("Escape closes the menu", async () => {
const user = userEvent.setup();
renderWithContexts(<CategorySwitcher />, { categories, route: "/~/hot", path: "/~/:feedTab" });
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();
@@ -199,17 +220,28 @@ 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");
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={() => {}} />);
renderWithContexts(
<CategorySelect selected={new Set()} onToggle={() => {}} />,
);
expect(screen.queryByText("Categories")).not.toBeInTheDocument();
});
});
@@ -232,8 +264,13 @@ describe("TabBar / FeedTabBar", () => {
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();
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");
@@ -245,7 +282,9 @@ describe("TabBar / FeedTabBar", () => {
route: "/~/followed",
path: "/~/:feedTab",
});
expect(screen.getByRole("button", { name: "Followed" })).toHaveClass("active");
expect(screen.getByRole("button", { name: "Followed" })).toHaveClass(
"active",
);
});
});
@@ -258,10 +297,16 @@ describe("AppHeader", () => {
});
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");
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();
expect(screen.getByRole("button", { name: "Notifications" }))
.toBeInTheDocument();
const dump = screen.getByRole("button", { name: /Dump/ });
expect(dump).toBeDisabled();
expect(dump).toHaveAttribute("title", "Server unreachable");
@@ -281,10 +326,11 @@ describe("AppHeader", () => {
renderWithContexts(<AppHeader />);
const toggle = screen.getByRole("button", { name: "Search" });
await user.click(toggle);
expect(screen.getByRole("button", { name: "Cancel search" })).toHaveAttribute(
"aria-expanded",
"true",
);
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}");
@@ -303,7 +349,7 @@ describe("AppHeader", () => {
return (
<I18nProvider i18n={i18n}>
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
<WSContext.Provider value={defaultOf(WSContext)}>
<WSContext.Provider value={defaultWSValue}>
<BrowserRouter>
<Routes>
<Route path="*" element={<AppHeader />} />
@@ -357,15 +403,25 @@ describe("AppHeader", () => {
describe("floating buttons", () => {
function withChat(openChat = vi.fn()) {
return (ui: React.ReactNode) => (
<ChatContext.Provider value={{ isChatOpen: false, openChat, closeChat: () => {} }}>
<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();
renderWithContexts(
withChat()(
<>
<ChatFab />
<DumpFab />
</>,
),
);
expect(screen.queryByRole("button", { hidden: true })).not
.toBeInTheDocument();
});
test("they show once the header scrolls away", () => {
@@ -400,14 +456,20 @@ describe("floating buttons", () => {
expect(chat).toHaveClass("chat-fab--visible");
act(() => chat.click());
expect(openChat).toHaveBeenCalled();
expect(screen.getByRole("button", { name: "New dump" })).toHaveClass("dump-fab--visible");
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>);
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"));
@@ -415,7 +477,13 @@ describe("small pieces", () => {
});
test("CountedInput counts toward its limit", () => {
renderWithContexts(<CountedInput value={"x".repeat(90)} maxLength={100} onChange={() => {}} />);
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");

View File

@@ -10,7 +10,10 @@ 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 });
Object.defineProperty(el, "duration", {
configurable: true,
value: duration,
});
el.currentTime = currentTime;
fireEvent(el, new Event("durationchange"));
fireEvent(el, new Event("timeupdate"));
@@ -22,7 +25,12 @@ describe("MediaPlayer", () => {
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} />,
<MediaPlayer
src="https://s/a.mp3"
kind="audio"
trackStyle="progress"
onPlayStateChange={onPlayStateChange}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play).toHaveBeenCalled();
@@ -35,15 +43,30 @@ describe("MediaPlayer", () => {
test("autoplays when asked", async () => {
const onPlayStateChange = vi.fn();
renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" autoplay onPlayStateChange={onPlayStateChange} />,
<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 />);
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();
});
@@ -51,7 +74,12 @@ describe("MediaPlayer", () => {
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} />,
<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"));
@@ -79,7 +107,9 @@ describe("MediaPlayer", () => {
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" } });
fireEvent.change(screen.getByRole("slider", { name: "Seek" }), {
target: { value: "120" },
});
expect(el.currentTime).toBe(120);
fireEvent(el, new Event("ended"));
expect(onEnded).toHaveBeenCalled();
@@ -104,7 +134,12 @@ describe("MediaPlayer", () => {
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} />,
<MediaPlayer
src="https://s/a.mp3"
kind="audio"
trackStyle="progress"
onError={onError}
/>,
);
fireEvent(media(container), new Event("error"));
expect(onError).toHaveBeenCalled();
@@ -114,14 +149,21 @@ describe("MediaPlayer", () => {
const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" />,
);
fireEvent.change(screen.getByRole("slider", { name: "Volume" }), { target: { value: "0.4" } });
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(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" } });
fireEvent.change(screen.getByRole("slider", { name: "Volume" }), {
target: { value: "0.7" },
});
expect(media(container).muted).toBe(false);
});
@@ -129,7 +171,13 @@ describe("MediaPlayer", () => {
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} />,
<MediaPlayer
src="https://s/a.mp3"
kind="audio"
trackStyle="progress"
seekRef={seekRef}
toggleRef={toggleRef}
/>,
);
act(() => seekRef.current!(33));
expect(media(container).currentTime).toBe(33);
@@ -141,7 +189,12 @@ describe("MediaPlayer", () => {
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} />,
<MediaPlayer
src="https://s/a.mp3"
kind="audio"
trackStyle="progress"
startTime={42}
/>,
);
fireEvent(media(container), new Event("loadedmetadata"));
expect(media(container).currentTime).toBe(42);
@@ -149,14 +202,23 @@ describe("MediaPlayer", () => {
test("video has a fullscreen button", () => {
renderWithContexts(<MediaPlayer src="https://s/a.mp4" kind="video" />);
expect(screen.getByRole("button", { name: "Fullscreen" })).toBeInTheDocument();
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 });
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 });
@@ -175,7 +237,9 @@ describe("FilePreview", () => {
test("compact audio plays in the global player", async () => {
const play = vi.fn();
renderWithContexts(<FilePreview dump={file("audio/mpeg")} compact />, { player: { play } });
renderWithContexts(<FilePreview dump={file("audio/mpeg")} compact />, {
player: { play },
});
await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play).toHaveBeenCalledWith(expect.objectContaining({
kind: "file",
@@ -189,9 +253,13 @@ describe("FilePreview", () => {
test("compact video claims its generated still as artwork", async () => {
const play = vi.fn();
renderWithContexts(<FilePreview dump={file("video/mp4")} compact />, { player: { play } });
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");
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 () => {
@@ -201,7 +269,11 @@ describe("FilePreview", () => {
player: {
togglePlay,
playing: true,
current: { kind: "file", fileUrl: "http://localhost:8000/api/files/f1?v=2048", mimeType: "video/mp4" },
current: {
kind: "file",
fileUrl: "http://localhost:8000/api/files/f1?v=2048",
mimeType: "video/mp4",
},
},
});
await userEvent.click(screen.getByRole("button", { name: "Pause" }));
@@ -211,13 +283,19 @@ describe("FilePreview", () => {
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 } });
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" }));
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 />);
const { container } = renderWithContexts(
<FilePreview dump={file("application/pdf")} compact />,
);
expect(container).toHaveTextContent("📄");
});
});

View File

@@ -6,7 +6,10 @@ 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 {
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";
@@ -43,7 +46,14 @@ 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" }));
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());
@@ -52,27 +62,58 @@ describe("GlobalPlayer", () => {
});
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,
}));
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" }));
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" }));
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" }));
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" }));
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");
@@ -82,10 +123,14 @@ describe("GlobalPlayer", () => {
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.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();
expect(screen.getByRole("button", { name: "Previous track" }))
.toBeDisabled();
const list = screen.getByRole("list");
expect(within(list).getAllByRole("button")).toHaveLength(3);
@@ -105,7 +150,8 @@ describe("GlobalPlayer", () => {
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();
expect(screen.getByRole("button", { name: "Expand player" }))
.toBeInTheDocument();
act(() => player.playQueue([track(2), track(3)]));
expect(screen.getByRole("list")).toBeInTheDocument();
});
@@ -120,8 +166,16 @@ describe("GlobalPlayer", () => {
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();
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();
});
@@ -138,24 +192,44 @@ describe("GlobalPlayer", () => {
});
describe("JournalCard", () => {
const base = { voteCount: 1, voted: false, canVote: true, castVote: vi.fn(), removeVote: vi.fn() };
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" } })}
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({ 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[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(within(cards[1] as HTMLElement).getByText("A thought"))
.toBeInTheDocument();
expect(cards[2]).toHaveClass("journal-card--text");
expect(cards[2]).toHaveTextContent("🔗");
unmount();
@@ -173,7 +247,13 @@ describe("JournalCard", () => {
embedUrl: "https://www.youtube.com/embed/y",
},
});
const pic = makeDump({ id: "p", kind: "file", fileMime: "image/png", fileName: "p.png", url: undefined });
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} />
@@ -183,7 +263,9 @@ describe("JournalCard", () => {
);
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(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");
@@ -192,21 +274,50 @@ describe("JournalCard", () => {
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} />
<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");
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");
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.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")!);
@@ -215,28 +326,43 @@ describe("PlaylistCard", () => {
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>);
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>);
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>);
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>);
renderWithContexts(
<ul>
<PlaylistCard playlist={makePlaylist()} onDelete={onDelete} />
</ul>,
);
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
expect(onDelete).toHaveBeenCalled();
expect(currentPath()).toBe("/");
});
});

View File

@@ -3,7 +3,10 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { type ReactNode, useContext, useState } from "react";
import { AuthContext } from "../../contexts/AuthContext.ts";
import { PlayerProvider } from "../../contexts/PlayerProvider.tsx";
import { PlayerContext, type PlayerItem } from "../../contexts/PlayerContext.ts";
import {
PlayerContext,
type PlayerItem,
} from "../../contexts/PlayerContext.ts";
import type { AuthResponse } from "../../model.ts";
import { STREAM_STALE_MS } from "../../utils/streamSources.ts";
import { calls, deferred, fail, route } from "../fetch.ts";
@@ -205,7 +208,13 @@ describe("persistence", () => {
test("a saved session comes back paused at its offset", () => {
localStorage.setItem(
"player",
JSON.stringify({ v: 2, queue: [embed(1), embed(2)], index: 1, time: 17, owner: null }),
JSON.stringify({
v: 2,
queue: [embed(1), embed(2)],
index: 1,
time: 17,
owner: null,
}),
);
const { p } = setup();
expect(p().queueIndex).toBe(1);
@@ -223,7 +232,13 @@ describe("persistence", () => {
test("another account's queue isn't restored", () => {
localStorage.setItem(
"player",
JSON.stringify({ v: 2, queue: [file(1)], index: 0, time: 0, owner: "someone-else" }),
JSON.stringify({
v: 2,
queue: [file(1)],
index: 0,
time: 0,
owner: "someone-else",
}),
);
const { p } = setup(makeSession({ id: "me" }));
expect(p().current).toBeNull();
@@ -232,7 +247,10 @@ describe("persistence", () => {
test("an ownerless session holding file URLs isn't restored; one of embeds is", () => {
localStorage.setItem("player", JSON.stringify({ item: file(1), time: 0 }));
expect(setup().p().current).toBeNull();
localStorage.setItem("player", JSON.stringify({ queue: [embed(1)], index: 0, time: 0 }));
localStorage.setItem(
"player",
JSON.stringify({ queue: [embed(1)], index: 0, time: 0 }),
);
expect(setup().p().current).toEqual(embed(1));
});
@@ -241,7 +259,12 @@ describe("persistence", () => {
expect(setup().p().current).toBeNull();
localStorage.setItem(
"player",
JSON.stringify({ v: 2, queue: [{ kind: "weird" }], index: 0, owner: null }),
JSON.stringify({
v: 2,
queue: [{ kind: "weird" }],
index: 0,
owner: null,
}),
);
expect(setup().p().current).toBeNull();
});
@@ -330,7 +353,9 @@ describe("expiring streams", () => {
act(() => p().play(embed(9)));
await act(async () =>
pending.resolve(
new Response(JSON.stringify({ success: true, data: albumResponse([0]) })),
new Response(
JSON.stringify({ success: true, data: albumResponse([0]) }),
),
)
);
expect(p().current).toEqual(embed(9));
@@ -359,7 +384,9 @@ describe("expiring streams", () => {
}),
);
const { p } = setup();
await waitFor(() => expect(p().current).toMatchObject({ streamUrl: "https://fresh/1" }));
await waitFor(() =>
expect(p().current).toMatchObject({ streamUrl: "https://fresh/1" })
);
expect(p().autoplay).toBe(false);
expect(p().startTime).toBe(25);
});

View File

@@ -46,7 +46,9 @@ function setup({ signedIn = true } = {}) {
return { hook, socket, ws: () => hook.result.current.ws };
}
beforeEach(() => vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }));
beforeEach(() =>
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] })
);
afterEach(() => vi.useRealTimers());
describe("connection", () => {
@@ -174,7 +176,12 @@ describe("votes", () => {
expect(ws().myVotes.has("d1")).toBe(true);
expect(ws().voteCounts.d1).toBe(5);
expect(socket.messages).toContainEqual({ type: "vote_cast", dumpId: "d1" });
socket.receive({ type: "vote_ack", dumpId: "d1", action: "cast", voteCount: 7 });
socket.receive({
type: "vote_ack",
dumpId: "d1",
action: "cast",
voteCount: 7,
});
expect(ws().voteCounts.d1).toBe(7);
act(() => vi.advanceTimersByTime(10_000));
expect(ws().myVotes.has("d1")).toBe(true);
@@ -194,7 +201,12 @@ describe("votes", () => {
socket.welcome({ myVotes: ["d1"] });
act(() => ws().removeVote("d1", 3));
expect(ws().voteCounts.d1).toBe(2);
socket.receive({ type: "error", message: "nope", dumpId: "d1", action: "remove" });
socket.receive({
type: "error",
message: "nope",
dumpId: "d1",
action: "remove",
});
expect(ws().myVotes.has("d1")).toBe(true);
expect(ws().voteCounts.d1).toBe(3);
});
@@ -225,12 +237,34 @@ describe("votes", () => {
test("votes_update from another tab of mine syncs myVotes", () => {
const { socket, ws } = setup();
socket.welcome();
socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 1, voterId: "user-me", action: "cast" });
socket.receive({
type: "votes_update",
dumpId: "d9",
voteCount: 1,
voterId: "user-me",
action: "cast",
});
expect(ws().myVotes.has("d9")).toBe(true);
expect(ws().lastVoteEvent).toEqual({ dumpId: "d9", voterId: "user-me", action: "cast" });
socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 2, voterId: "other", action: "cast" });
expect(ws().lastVoteEvent).toEqual({
dumpId: "d9",
voterId: "user-me",
action: "cast",
});
socket.receive({
type: "votes_update",
dumpId: "d9",
voteCount: 2,
voterId: "other",
action: "cast",
});
expect(ws().voteCounts.d9).toBe(2);
socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 1, voterId: "user-me", action: "remove" });
socket.receive({
type: "votes_update",
dumpId: "d9",
voteCount: 1,
voterId: "user-me",
action: "remove",
});
expect(ws().myVotes.has("d9")).toBe(false);
});
@@ -239,8 +273,12 @@ describe("votes", () => {
socket.welcome();
act(() => ws().castVote("d1", 0));
socket.receive({
type: "welcome", users: [], myVotes: ["d1"], myCommentLikes: [],
unreadNotificationCount: 0, unreadChatCount: 0,
type: "welcome",
users: [],
myVotes: ["d1"],
myCommentLikes: [],
unreadNotificationCount: 0,
unreadChatCount: 0,
});
act(() => vi.advanceTimersByTime(10_000));
expect(ws().myVotes.has("d1")).toBe(true);
@@ -253,11 +291,22 @@ describe("comment likes", () => {
socket.welcome();
act(() => ws().castCommentLike("c1", 0));
expect(ws().likeCounts.c1).toBe(1);
expect(socket.messages).toContainEqual({ type: "comment_like_cast", commentId: "c1" });
socket.receive({ type: "comment_like_ack", commentId: "c1", action: "cast", likeCount: 1 });
expect(socket.messages).toContainEqual({
type: "comment_like_cast",
commentId: "c1",
});
socket.receive({
type: "comment_like_ack",
commentId: "c1",
action: "cast",
likeCount: 1,
});
act(() => ws().removeCommentLike("c1"));
expect(ws().likeCounts.c1).toBe(0);
expect(socket.messages).toContainEqual({ type: "comment_like_remove", commentId: "c1" });
expect(socket.messages).toContainEqual({
type: "comment_like_remove",
commentId: "c1",
});
socket.receive({ type: "error", commentId: "c1" });
expect(ws().myLikes.has("c1")).toBe(true);
expect(ws().likeCounts.c1).toBe(1);
@@ -275,7 +324,13 @@ describe("comment likes", () => {
test("comment_likes_update from my other tab", () => {
const { socket, ws } = setup();
socket.welcome();
socket.receive({ type: "comment_likes_update", commentId: "c2", likeCount: 3, likerId: "user-me", action: "cast" });
socket.receive({
type: "comment_likes_update",
commentId: "c2",
likeCount: 3,
likerId: "user-me",
action: "cast",
});
expect(ws().myLikes.has("c2")).toBe(true);
expect(ws().likeCounts.c2).toBe(3);
expect(ws().lastLikeEvent?.commentId).toBe("c2");
@@ -290,7 +345,10 @@ describe("dumps, playlists, comments, users", () => {
expect(ws().deletedDumpIds.has("a")).toBe(true);
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a" })) });
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "b" })) });
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a", title: "again" })) });
socket.receive({
type: "dump_created",
dump: wire(makeDump({ id: "a", title: "again" })),
});
expect(ws().recentDumps.map((d) => d.id)).toEqual(["a", "b"]);
expect(ws().recentDumps[0].title).toBe("again");
expect(ws().recentDumps[0].createdAt).toBeInstanceOf(Date);
@@ -301,7 +359,10 @@ describe("dumps, playlists, comments, users", () => {
const { socket, ws } = setup();
socket.welcome();
for (let i = 0; i < 105; i++) {
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: `d${i}` })) });
socket.receive({
type: "dump_created",
dump: wire(makeDump({ id: `d${i}` })),
});
}
expect(ws().recentDumps).toHaveLength(100);
expect(ws().recentDumps[0].id).toBe("d104");
@@ -310,10 +371,22 @@ describe("dumps, playlists, comments, users", () => {
test("an update refreshes a live entry but doesn't make it an arrival", () => {
const { socket, ws } = setup();
socket.welcome();
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a", title: "one" })) });
socket.receive({ type: "dump_updated", dump: wire(makeDump({ id: "a", title: "two" })) });
socket.receive({ type: "dump_updated", dump: wire(makeDump({ id: "old" })) });
expect(ws().recentDumps.map((d) => [d.id, d.title])).toEqual([["a", "two"]]);
socket.receive({
type: "dump_created",
dump: wire(makeDump({ id: "a", title: "one" })),
});
socket.receive({
type: "dump_updated",
dump: wire(makeDump({ id: "a", title: "two" })),
});
socket.receive({
type: "dump_updated",
dump: wire(makeDump({ id: "old" })),
});
expect(ws().recentDumps.map((d) => [d.id, d.title])).toEqual([[
"a",
"two",
]]);
expect(ws().lastDumpEvent?.id).toBe("old");
});
@@ -338,14 +411,31 @@ describe("dumps, playlists, comments, users", () => {
socket.welcome();
const p = makePlaylist({ id: "p" });
socket.receive({ type: "playlist_created", playlist: wire(p) });
expect(ws().lastPlaylistEvent).toMatchObject({ type: "created", playlistId: "p" });
expect(ws().lastPlaylistEvent).toMatchObject({
type: "created",
playlistId: "p",
});
socket.receive({ type: "playlist_updated", playlist: wire(p) });
expect(ws().lastPlaylistEvent?.type).toBe("updated");
socket.receive({ type: "playlist_dumps_updated", playlistId: "p", dumpIds: ["x"], dumpCount: 2 });
expect(ws().lastPlaylistEvent).toEqual({ type: "dumps_updated", playlistId: "p", dumpIds: ["x"], dumpCount: 2 });
socket.receive({
type: "playlist_dumps_updated",
playlistId: "p",
dumpIds: ["x"],
dumpCount: 2,
});
expect(ws().lastPlaylistEvent).toEqual({
type: "dumps_updated",
playlistId: "p",
dumpIds: ["x"],
dumpCount: 2,
});
socket.receive({ type: "playlist_deleted", playlistId: "p", userId: "u" });
expect(ws().deletedPlaylistIds.has("p")).toBe(true);
expect(ws().lastPlaylistEvent).toEqual({ type: "deleted", playlistId: "p", userId: "u" });
expect(ws().lastPlaylistEvent).toEqual({
type: "deleted",
playlistId: "p",
userId: "u",
});
});
test("comment events", () => {
@@ -353,27 +443,44 @@ describe("dumps, playlists, comments, users", () => {
socket.welcome();
const c = makeComment({ id: "c", dumpId: "d" });
socket.receive({ type: "comment_created", comment: wire(c) });
expect(ws().lastCommentEvent).toMatchObject({ type: "created", dumpId: "d" });
expect(ws().lastCommentEvent).toMatchObject({
type: "created",
dumpId: "d",
});
expect(ws().lastCommentEvent?.comment?.createdAt).toBeInstanceOf(Date);
socket.receive({ type: "comment_updated", comment: wire(c) });
expect(ws().lastCommentEvent?.type).toBe("updated");
socket.receive({ type: "comment_deleted", commentId: "c", dumpId: "d" });
expect(ws().lastCommentEvent).toEqual({ type: "deleted", dumpId: "d", commentId: "c" });
expect(ws().lastCommentEvent).toEqual({
type: "deleted",
dumpId: "d",
commentId: "c",
});
});
test("user_updated overrides that user's avatar everywhere", () => {
const { socket, hook } = setup();
socket.welcome();
const u = makeUser({ id: "u1", avatarMime: "image/png", updatedAt: new Date(5000) });
const u = makeUser({
id: "u1",
avatarMime: "image/png",
updatedAt: new Date(5000),
});
socket.receive({ type: "user_updated", user: wire(u) });
expect(hook.result.current.avatars.u1).toEqual({ hasAvatar: true, version: 5000 });
expect(hook.result.current.avatars.u1).toEqual({
hasAvatar: true,
version: 5000,
});
expect(hook.result.current.ws.lastUserEvent?.user.id).toBe("u1");
});
test("presence updates", () => {
const { socket, ws } = setup();
socket.welcome();
socket.receive({ type: "presence_update", users: [{ userId: "x", username: "x", hasAvatar: false }] });
socket.receive({
type: "presence_update",
users: [{ userId: "x", username: "x", hasAvatar: false }],
});
expect(ws().onlineUsers.map((u) => u.userId)).toEqual(["x"]);
});
});
@@ -382,7 +489,10 @@ describe("notifications", () => {
test("a new one bumps the count and is remembered", () => {
const { socket, ws } = setup();
socket.welcome({ unreadNotificationCount: 1 });
socket.receive({ type: "notification_created", notification: wire(makeNotification({ id: "n" })) });
socket.receive({
type: "notification_created",
notification: wire(makeNotification({ id: "n" })),
});
expect(ws().unreadNotificationCount).toBe(2);
expect(ws().lastNotification?.id).toBe("n");
act(() => ws().clearUnreadNotifications());
@@ -397,7 +507,10 @@ describe("chat", () => {
const m = makeChatMessage({ id: "m1", userId: "other" });
socket.receive({ type: "chat_message", message: wire(m) });
socket.receive({ type: "chat_message_updated", message: wire(m) });
socket.receive({ type: "chat_message", message: wire(makeChatMessage({ id: "m2", userId: "user-me" })) });
socket.receive({
type: "chat_message",
message: wire(makeChatMessage({ id: "m2", userId: "user-me" })),
});
expect(ws().chatMessages.map((x) => x.id)).toEqual(["m1", "m2"]);
expect(ws().unreadChatCount).toBe(1);
});
@@ -406,7 +519,10 @@ describe("chat", () => {
const { socket, ws } = setup();
socket.welcome();
for (let i = 0; i < 205; i++) {
socket.receive({ type: "chat_message", message: wire(makeChatMessage({ id: `m${i}`, userId: "user-me" })) });
socket.receive({
type: "chat_message",
message: wire(makeChatMessage({ id: `m${i}`, userId: "user-me" })),
});
}
expect(ws().chatMessages).toHaveLength(200);
expect(ws().chatMessages[0].id).toBe("m5");
@@ -437,7 +553,9 @@ describe("chat", () => {
test("sending shows a pending message, retired by its echo", () => {
const { socket, ws } = setup();
socket.welcome();
act(() => ws().sendChatMessage(" hello ", { id: "r", author: "a", body: "b" }));
act(() =>
ws().sendChatMessage(" hello ", { id: "r", author: "a", body: "b" })
);
const [pending] = ws().pendingChatMessages;
expect(pending).toMatchObject({ body: "hello", status: "sending" });
expect(socket.messages).toContainEqual({
@@ -482,7 +600,9 @@ describe("chat", () => {
const retry = ws().pendingChatMessages[0];
expect(retry.status).toBe("sending");
expect(retry.nonce).not.toBe(first);
expect(socket.messages.filter((m) => m.type === "chat_send")).toHaveLength(2);
expect(socket.messages.filter((m) => m.type === "chat_send")).toHaveLength(
2,
);
// The first attempt's late echo doesn't retire the retry.
socket.receive({
type: "chat_message",
@@ -497,8 +617,16 @@ describe("chat", () => {
socket.welcome();
act(() => ws().sendChatMessage("hi"));
const { nonce } = ws().pendingChatMessages[0];
socket.receive({ type: "error", scope: "chat", nonce, message: "Too long" });
expect(ws().pendingChatMessages[0]).toMatchObject({ status: "failed", error: "Too long" });
socket.receive({
type: "error",
scope: "chat",
nonce,
message: "Too long",
});
expect(ws().pendingChatMessages[0]).toMatchObject({
status: "failed",
error: "Too long",
});
act(() => ws().discardChatMessage(nonce));
expect(ws().pendingChatMessages).toEqual([]);
});
@@ -534,9 +662,15 @@ describe("chat", () => {
]);
const m = makeChatMessage({ id: "m1", body: "old" });
socket.receive({ type: "chat_message", message: wire(m) });
socket.receive({ type: "chat_message_updated", message: wire({ ...m, body: "new" }) });
socket.receive({
type: "chat_message_updated",
message: wire({ ...m, body: "new" }),
});
expect(ws().chatMessages[0].body).toBe("new");
socket.receive({ type: "chat_message_updated", message: wire(makeChatMessage({ id: "m2" })) });
socket.receive({
type: "chat_message_updated",
message: wire(makeChatMessage({ id: "m2" })),
});
expect(ws().chatMessages).toHaveLength(2);
socket.receive({ type: "chat_message_deleted", id: "m1" });
expect(ws().chatMessages.map((x) => x.id)).toEqual(["m2"]);
@@ -548,7 +682,10 @@ describe("chat", () => {
socket.welcome();
act(() => ws().setChatTyping(true));
expect(socket.messages).toEqual([{ type: "chat_typing", typing: true }]);
socket.receive({ type: "chat_typing_update", users: [{ userId: "x", username: "x", hasAvatar: false }] });
socket.receive({
type: "chat_typing_update",
users: [{ userId: "x", username: "x", hasAvatar: false }],
});
expect(ws().typingUsers).toHaveLength(1);
});
@@ -558,11 +695,15 @@ describe("chat", () => {
act(() => ws().markChatRead(new Date(1_000_000)));
expect(ws().unreadChatCount).toBe(0);
act(() => vi.advanceTimersByTime(0));
expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(1);
expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(
1,
);
act(() => ws().markChatRead(new Date(2_000_000)));
act(() => ws().markChatRead(new Date(3_000_000)));
act(() => vi.advanceTimersByTime(999));
expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(1);
expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(
1,
);
act(() => vi.advanceTimersByTime(1));
const reads = socket.messages.filter((m) => m.type === "chat_read");
expect(reads).toHaveLength(2);
@@ -570,7 +711,9 @@ describe("chat", () => {
// Older than what's been sent: ignored.
act(() => ws().markChatRead(new Date(10)));
act(() => vi.advanceTimersByTime(5_000));
expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(2);
expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(
2,
);
});
test("a marker set offline goes out with the next welcome", () => {
@@ -587,7 +730,11 @@ describe("chat", () => {
test("chat_read_update from another tab moves the marker", () => {
const { socket, ws } = setup();
socket.welcome({ unreadChatCount: 5 });
socket.receive({ type: "chat_read_update", readAt: "2026-05-01T00:00:00.000Z", unread: 1 });
socket.receive({
type: "chat_read_update",
readAt: "2026-05-01T00:00:00.000Z",
unread: 1,
});
expect(ws().unreadChatCount).toBe(1);
expect(ws().chatReadAt).toEqual(new Date("2026-05-01T00:00:00.000Z"));
});
@@ -598,7 +745,11 @@ describe("chat", () => {
const t0 = Date.now();
const at = (s: number) => new Date(t0 + s * 1000);
const early = makeChatMessage({ id: "early", createdAt: at(-100) });
const edited = makeChatMessage({ id: "edited", body: "old", createdAt: at(-50) });
const edited = makeChatMessage({
id: "edited",
body: "old",
createdAt: at(-50),
});
const gone = makeChatMessage({ id: "gone", createdAt: at(-40) });
for (const m of [early, edited, gone]) {
socket.receive({ type: "chat_message", message: wire(m) });
@@ -611,7 +762,12 @@ describe("chat", () => {
ws().reconcileChat(
[
{ ...edited, body: "new" },
makeChatMessage({ id: "echo", userId: "user-me", body: "did it land?", createdAt: at(1) }),
makeChatMessage({
id: "echo",
userId: "user-me",
body: "did it land?",
createdAt: at(1),
}),
],
at(-60),
at(60),
@@ -643,6 +799,9 @@ describe("chat", () => {
act(() => vi.advanceTimersByTime(500));
FakeWebSocket.last.welcome();
expect(ws().chatMessages).toHaveLength(1);
expect(FakeWebSocket.last.messages).toContainEqual({ type: "chat_focus", open: true });
expect(FakeWebSocket.last.messages).toContainEqual({
type: "chat_focus",
open: true,
});
});
});

View File

@@ -51,7 +51,10 @@ describe("AuthProvider", () => {
test("a role changed on the server is picked up", async () => {
const session = makeSession({ role: "user" });
storeSession(session);
route("GET /api/users/me", () => wire({ ...session.user, role: "moderator" }));
route(
"GET /api/users/me",
() => wire({ ...session.user, role: "moderator" }),
);
const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(result.current.user?.role).toBe("moderator"));
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.role).toBe(
@@ -99,13 +102,17 @@ describe("AuthProvider", () => {
const session = makeSession({ username: "elsewhere" });
storeSession(session);
act(() => {
globalThis.dispatchEvent(new StorageEvent("storage", { key: AUTH_STORAGE_KEY }));
globalThis.dispatchEvent(
new StorageEvent("storage", { key: AUTH_STORAGE_KEY }),
);
});
expect(result.current.user?.username).toBe("elsewhere");
localStorage.removeItem(AUTH_STORAGE_KEY);
act(() => {
globalThis.dispatchEvent(new StorageEvent("storage", { key: "unrelated" }));
globalThis.dispatchEvent(
new StorageEvent("storage", { key: "unrelated" }),
);
});
expect(result.current.user).not.toBeNull();
act(() => {
@@ -132,7 +139,9 @@ describe("AuthProvider", () => {
});
describe("FollowProvider", () => {
function setup(status = { followedUserIds: ["u1"], followedPlaylistIds: ["p1"] }) {
function setup(
status = { followedUserIds: ["u1"], followedPlaylistIds: ["p1"] },
) {
const session = makeSession();
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
@@ -260,7 +269,9 @@ describe("ThemeProvider", () => {
expect(result.current.style).toBe("nyt");
expect(result.current.colorScheme).toBe("light");
localStorage.setItem("style", "default");
expect(renderHook(useTheme, { wrapper }).result.current.style).toBe("smooth");
expect(renderHook(useTheme, { wrapper }).result.current.style).toBe(
"smooth",
);
});
});
@@ -310,4 +321,3 @@ describe("ChatProvider", () => {
expect(chat.isChatOpen).toBe(false);
});
});

View File

@@ -4,8 +4,20 @@ import enMessages from "emojibase-data/en/messages.json";
/** Serves the emoji picker's data as the app does, from /emojibase/<ver>/. */
export function serveEmojibase() {
route("GET /emojibase/:version/en/data.json", () => new Response(JSON.stringify(enData)));
route("GET /emojibase/:version/en/messages.json", () => new Response(JSON.stringify(enMessages)));
route("HEAD /emojibase/:version/en/data.json", () => new Response(null, { headers: { ETag: "x" } }));
route("HEAD /emojibase/:version/en/messages.json", () => new Response(null, { headers: { ETag: "y" } }));
route(
"GET /emojibase/:version/en/data.json",
() => new Response(JSON.stringify(enData)),
);
route(
"GET /emojibase/:version/en/messages.json",
() => new Response(JSON.stringify(enMessages)),
);
route(
"HEAD /emojibase/:version/en/data.json",
() => new Response(null, { headers: { ETag: "x" } }),
);
route(
"HEAD /emojibase/:version/en/messages.json",
() => new Response(null, { headers: { ETag: "y" } }),
);
}

View File

@@ -24,11 +24,7 @@ describe("usePlayRichContent", () => {
const playQueue = vi.fn();
const wrapper = ({ children }: { children: ReactNode }) => (
<ctxMod.PlayerContext.Provider
value={{
...(ctxMod.PlayerContext as unknown as { _currentValue: object })._currentValue,
play,
playQueue,
} as never}
value={{ ...ctxMod.defaultPlayerValue, play, playQueue }}
>
{children}
</ctxMod.PlayerContext.Provider>
@@ -90,7 +86,13 @@ describe("usePlayRichContent", () => {
sourceUrl: bc.url,
itemType: "album",
resolvedAt: 1,
tracks: [{ index: 0, title: "T", streamUrl: "https://s/0", streamable: true, capped: false }],
tracks: [{
index: 0,
title: "T",
streamUrl: "https://s/0",
streamable: true,
capped: false,
}],
}));
const { hook, playQueue, canPlay } = await load("native");
expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(true);
@@ -108,7 +110,9 @@ describe("usePlayRichContent", () => {
const { hook, play, playQueue } = await load("native");
await act(() => hook.result.current.playRichContent(bc));
expect(playQueue).not.toHaveBeenCalled();
expect(play).toHaveBeenCalledWith(expect.objectContaining({ kind: "embed" }));
expect(play).toHaveBeenCalledWith(
expect.objectContaining({ kind: "embed" }),
);
});
test("an unknown mode reads as embed", async () => {
@@ -119,7 +123,9 @@ describe("usePlayRichContent", () => {
describe("useUserDumpFeed", () => {
const ann = makeUser({ username: "ann" });
function setup(opts: { session?: ReturnType<typeof makeSession> | null } = {}) {
function setup(
opts: { session?: ReturnType<typeof makeSession> | null } = {},
) {
const session = opts.session ?? null;
function Wrapper({ children }: { children: ReactNode }) {
const [authResponse, setAuthResponse] = useState(session);
@@ -147,7 +153,10 @@ describe("useUserDumpFeed", () => {
const session = makeSession();
const { result } = setup({ session });
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
const s = result.current.state as Extract<typeof result.current.state, { status: "loaded" }>;
const s = result.current.state as Extract<
typeof result.current.state,
{ status: "loaded" }
>;
expect(s.profileUser.username).toBe("ann");
expect(s.items[0].createdAt).toBeInstanceOf(Date);
expect(s.hasMore).toBe(true);
@@ -167,35 +176,51 @@ describe("useUserDumpFeed", () => {
route("GET /api/users/:u", () => new TypeError("Failed to fetch"));
route("GET /api/users/:u/dumps", () => page([]));
const { result } = setup();
await waitFor(() => expect(result.current.state).toMatchObject({
status: "error",
error: expect.stringMatching(/Could not connect/),
}));
await waitFor(() =>
expect(result.current.state).toMatchObject({
status: "error",
error: expect.stringMatching(/Could not connect/),
})
);
});
test("scrolling to the sentinel loads the next page", async () => {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/dumps", (req) =>
req.url.searchParams.get("page") === "1"
? page([makeDump({ id: "a" })], true)
: page([makeDump({ id: "b" })], false));
route(
"GET /api/users/:u/dumps",
(req) =>
req.url.searchParams.get("page") === "1"
? page([makeDump({ id: "a" })], true)
: page([makeDump({ id: "b" })], false),
);
const { result } = setup();
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
const sentinel = document.createElement("div");
act(() => (result.current.sentinelRef as (el: HTMLDivElement) => void)(sentinel));
act(() => {
result.current.sentinelRef(sentinel);
});
const obs = FakeIntersectionObserver.instances.at(-1)!;
act(() => obs.callback([{ isIntersecting: true } as IntersectionObserverEntry], obs as never));
act(() => obs.report(true));
await waitFor(() => {
const s = result.current.state;
expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual(["a", "b"]);
expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual([
"a",
"b",
]);
});
const s = result.current.state as Extract<typeof result.current.state, { status: "loaded" }>;
const s = result.current.state as Extract<
typeof result.current.state,
{ status: "loaded" }
>;
expect(s.hasMore).toBe(false);
expect(s.page).toBe(2);
});
test("changing user resets to loading", async () => {
route("GET /api/users/:u", (req) => wire(makeUser({ username: req.params.u })));
route(
"GET /api/users/:u",
(req) => wire(makeUser({ username: req.params.u })),
);
route("GET /api/users/:u/dumps", () => page([]));
const { result, rerender } = setup();
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
@@ -206,17 +231,26 @@ describe("useUserDumpFeed", () => {
test("setItems edits the loaded list", async () => {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/dumps", () => page([makeDump({ id: "a" }), makeDump({ id: "b" })]));
route(
"GET /api/users/:u/dumps",
() => page([makeDump({ id: "a" }), makeDump({ id: "b" })]),
);
const { result } = setup();
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
act(() => result.current.setItems((prev) => prev.slice(1)));
const s = result.current.state as Extract<typeof result.current.state, { status: "loaded" }>;
const s = result.current.state as Extract<
typeof result.current.state,
{ status: "loaded" }
>;
expect(s.items.map((d) => d.id)).toEqual(["b"]);
});
});
describe("useYouTubePlaylist", () => {
type Events = { onReady?: () => void; onStateChange?: (e: { data: number }) => void };
type Events = {
onReady?: () => void;
onStateChange?: (e: { data: number }) => void;
};
function installYT(ids = ["a", "b", "c"]) {
const player = {
events: {} as Events,
@@ -231,23 +265,33 @@ describe("useYouTubePlaylist", () => {
},
destroy: vi.fn(),
};
(globalThis as unknown as { YT: unknown }).YT = {
Player: class {
constructor(_el: unknown, opts: { events: Events }) {
player.events = opts.events;
return player;
}
},
};
// Forwards to `player`, which the tests inspect and steer.
class Player {
constructor(_el: HTMLIFrameElement, opts: { events: Events }) {
player.events = opts.events;
}
playVideo = player.playVideo;
playVideoAt = player.playVideoAt;
nextVideo = player.nextVideo;
previousVideo = player.previousVideo;
getPlaylist = player.getPlaylist;
getPlaylistIndex = () => player.index;
destroy = player.destroy;
}
window.YT = { Player };
return player;
}
// The IFrame API loads once per document and is cached in the module.
async function setup(embedUrl: string | null) {
vi.resetModules();
const { useYouTubePlaylist } = await import("../../hooks/useYouTubePlaylist.ts");
const { useYouTubePlaylist } = await import(
"../../hooks/useYouTubePlaylist.ts"
);
return renderHook(({ url }) => {
const ref = useRef<HTMLIFrameElement | null>(document.createElement("iframe"));
const ref = useRef<HTMLIFrameElement | null>(
document.createElement("iframe"),
);
return useYouTubePlaylist(url, ref);
}, { initialProps: { url: embedUrl } });
}
@@ -261,7 +305,9 @@ describe("useYouTubePlaylist", () => {
{ videoId: "c", title: "C", duration: 30 },
],
}));
const { result } = await setup("https://www.youtube.com/embed/videoseries?list=PL1");
const { result } = await setup(
"https://www.youtube.com/embed/videoseries?list=PL1",
);
await waitFor(() => expect(result.current.rows).toHaveLength(2));
expect(result.current.ready).toBe(false);
await waitFor(() => expect(player.events.onReady).toBeDefined());
@@ -285,12 +331,17 @@ describe("useYouTubePlaylist", () => {
test("a pause as the tab goes to the background is undone, a few times at most", async () => {
const player = installYT();
route("GET /api/youtube/playlist", () => ({ tracks: [{ videoId: "a", title: "A" }] }));
route(
"GET /api/youtube/playlist",
() => ({ tracks: [{ videoId: "a", title: "A" }] }),
);
await setup("https://www.youtube.com/embed/x?list=PL2");
await waitFor(() => expect(player.events.onStateChange).toBeDefined());
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
document.dispatchEvent(new Event("visibilitychange"));
for (let i = 0; i < 5; i++) act(() => player.events.onStateChange!({ data: 2 }));
for (let i = 0; i < 5; i++) {
act(() => player.events.onStateChange!({ data: 2 }));
}
expect(player.playVideo).toHaveBeenCalledTimes(3);
hidden.mockReturnValue(false);
document.dispatchEvent(new Event("visibilitychange"));
@@ -307,8 +358,13 @@ describe("useYouTubePlaylist", () => {
test("a new embed drops the old list; unmount destroys the player", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const player = installYT();
route("GET /api/youtube/playlist", () => ({ tracks: [{ videoId: "a", title: "A" }] }));
const { result, rerender, unmount } = await setup("https://www.youtube.com/embed/x?list=PL3");
route(
"GET /api/youtube/playlist",
() => ({ tracks: [{ videoId: "a", title: "A" }] }),
);
const { result, rerender, unmount } = await setup(
"https://www.youtube.com/embed/x?list=PL3",
);
await waitFor(() => expect(result.current.rows).toHaveLength(1));
await waitFor(() => expect(player.events.onReady).toBeDefined());
route("GET /api/youtube/playlist", () => fail("x", 500));

View File

@@ -1,11 +1,13 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type { KeyboardEvent } from "react";
import { useEmojiTrigger } from "../../hooks/useEmojiTrigger.ts";
import { useMentionAutocomplete } from "../../hooks/useMentionAutocomplete.ts";
import {
type MentionKey,
useMentionAutocomplete,
} from "../../hooks/useMentionAutocomplete.ts";
import { calls, deferred, route } from "../fetch.ts";
function key(k: string, mods: Partial<KeyboardEvent> = {}) {
function key(k: string, mods: Partial<MentionKey> = {}): MentionKey {
return {
key: k,
ctrlKey: false,
@@ -15,7 +17,7 @@ function key(k: string, mods: Partial<KeyboardEvent> = {}) {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...mods,
} as unknown as KeyboardEvent<HTMLTextAreaElement>;
};
}
describe("useEmojiTrigger", () => {
@@ -125,13 +127,20 @@ describe("useMentionAutocomplete", () => {
}
test("looks up the @query after a pause and opens with results", async () => {
route("GET /api/users/search", (req) =>
users.filter((u) => u.username.startsWith(req.url.searchParams.get("q")!)));
route(
"GET /api/users/search",
(req) =>
users.filter((u) =>
u.username.startsWith(req.url.searchParams.get("q")!)
),
);
const { hook, type } = setup();
await type("hi @al");
expect(hook.result.current.mentionOpen).toBe(true);
expect(hook.result.current.mentionResults).toHaveLength(3);
expect(calls("GET /api/users/search")[0].url.searchParams.get("q")).toBe("al");
expect(calls("GET /api/users/search")[0].url.searchParams.get("q")).toBe(
"al",
);
});
test("debounces keystrokes into one lookup", async () => {
@@ -168,7 +177,9 @@ describe("useMentionAutocomplete", () => {
const { hook, type } = setup();
await type("@al");
await type("@alz");
await act(async () => slow.resolve(new Response(JSON.stringify({ success: true, data: users }))));
await act(async () =>
slow.resolve(new Response(JSON.stringify({ success: true, data: users })))
);
expect(hook.result.current.mentionOpen).toBe(false);
});
@@ -231,7 +242,9 @@ describe("useMentionAutocomplete", () => {
test("keys pass through while closed", () => {
const { hook } = setup();
expect(hook.result.current.handleMentionKeyDown(key("ArrowDown"))).toBe(false);
expect(hook.result.current.handleMentionKeyDown(key("ArrowDown"))).toBe(
false,
);
});
test("moving the caret off the mention closes it", async () => {

View File

@@ -1,7 +1,19 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
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 {
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";
@@ -112,7 +124,13 @@ describe("useFeedCache", () => {
test("a fresh (PUSH) visit ignores the cache", () => {
sessionStorage.setItem(
"k",
JSON.stringify({ items: [1], page: 1, hasMore: false, scrollY: 0, savedAt: Date.now() }),
JSON.stringify({
items: [1],
page: 1,
hasMore: false,
scrollY: 0,
savedAt: Date.now(),
}),
);
function Probe() {
const { cached } = useFeedCache("k", (x) => x);
@@ -120,7 +138,11 @@ describe("useFeedCache", () => {
}
function Home() {
const navigate = useNavigate();
return <button type="button" onClick={() => navigate("/feed")}>go</button>;
return (
<button type="button" onClick={() => navigate("/feed")}>
go
</button>
);
}
render(
<MemoryRouter>
@@ -156,7 +178,13 @@ describe("useTabParam", () => {
return useTabParam(TABS, "dumps");
}, {
wrapper: ({ children }) => (
<MemoryRouter initialEntries={[{ pathname: path.split("?")[0], search: path.includes("?") ? "?" + path.split("?")[1] : "", state }]}>
<MemoryRouter
initialEntries={[{
pathname: path.split("?")[0],
search: path.includes("?") ? "?" + path.split("?")[1] : "",
state,
}]}
>
{children}
</MemoryRouter>
),
@@ -165,8 +193,12 @@ describe("useTabParam", () => {
}
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/~/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");
});
@@ -197,7 +229,8 @@ describe("useTabParam", () => {
describe("useDocumentTitle", () => {
test("composes page, site name and unread badge", () => {
const { rerender } = renderHook(
({ title }: { title: string | null | undefined }) => useDocumentTitle(title),
({ title }: { title: string | null | undefined }) =>
useDocumentTitle(title),
{ initialProps: { title: "Cats" as string | null | undefined } },
);
expect(document.title).toBe(`Cats · ${SITE_NAME}`);
@@ -226,12 +259,7 @@ describe("useInfiniteScroll", () => {
}
const intersect = (isIntersecting: boolean) => {
const obs = FakeIntersectionObserver.instances.at(-1)!;
act(() =>
obs.callback(
[{ isIntersecting } as IntersectionObserverEntry],
obs as unknown as IntersectionObserver,
)
);
act(() => obs.report(isIntersecting));
};
test("loads more when the sentinel shows, only while enabled", () => {
@@ -269,7 +297,16 @@ describe("useInfiniteScroll", () => {
}
delays.push(waited);
}
expect(delays).toEqual([1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000]);
expect(delays).toEqual([
1000,
2000,
4000,
8000,
16000,
30000,
30000,
30000,
]);
});
test("re-rendering doesn't restart the wait", () => {
@@ -292,18 +329,13 @@ describe("useInfiniteScroll", () => {
render(<Feed onMore={onMore} enabled />);
const at = (top: number) => {
const obs = FakeIntersectionObserver.instances.at(-1)!;
act(() =>
obs.callback(
[{ isIntersecting: true, boundingClientRect: { top } } as IntersectionObserverEntry],
obs as unknown as IntersectionObserver,
)
);
act(() => obs.report(true, top));
};
at(500);
at(500);
expect(onMore).toHaveBeenCalledTimes(1); // waiting
intersect(false);
intersect(true);
act(() => FakeIntersectionObserver.instances.at(-1)!.report(false, 500));
at(500);
expect(onMore).toHaveBeenCalledTimes(2);
at(500);
at(900); // the page grew: that load worked
@@ -395,7 +427,7 @@ describe("useDumpCardLink", () => {
<button
type="button"
onClick={(e) => {
if (!link.handledAsNewTab(e)) (e.currentTarget.dataset.played = "1");
if (!link.handledAsNewTab(e)) e.currentTarget.dataset.played = "1";
}}
>
play
@@ -431,14 +463,27 @@ describe("useDumpCardLink", () => {
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 });
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 }));
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 }));
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 }));
fireEvent(
screen.getByTestId("card"),
new MouseEvent("auxclick", { button: 2, bubbles: true }),
);
expect(open).toHaveBeenCalledTimes(1);
});
@@ -469,7 +514,14 @@ describe("useAnchoredFloat", () => {
ref.current = el;
if (el) {
el.getBoundingClientRect = () =>
({ top, bottom: top + 20, left: 900, right: 1000, width: 100, height: 20 }) as DOMRect;
({
top,
bottom: top + 20,
left: 900,
right: 1000,
width: 100,
height: 20,
}) as DOMRect;
}
}}
/>
@@ -522,9 +574,10 @@ describe("useHeightTransition", () => {
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");
expect(animate.mock.calls[0][0]).toEqual([
expect.objectContaining({ height: "100px" }),
expect.objectContaining({ height: "250px" }),
]);
// No change in height, no animation.
rerender(<Box open={false} />);

View File

@@ -1,24 +1,30 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { type ReactNode, useState } from "react";
import { WSContext, type WSContextValue } from "../../contexts/WSContext.ts";
import {
defaultWSValue,
WSContext,
type WSContextValue,
} from "../../contexts/WSContext.ts";
import type { Dump, Playlist } from "../../model.ts";
import { useDumpListSync } from "../../hooks/useDumpListSync.ts";
import { usePlaylistListSync } from "../../hooks/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../../hooks/usePositionAwareSync.ts";
import { makeDump, makePlaylist } from "../fixtures.ts";
import { defaultOf } from "../render.tsx";
/** A WS context whose value a test can push new events into. */
function liveWS() {
let push: (patch: Partial<WSContextValue>) => void = () => {};
const base = defaultOf(WSContext);
const base = defaultWSValue;
function Wrapper({ children }: { children: ReactNode }) {
const [value, setValue] = useState(base);
push = (patch) => setValue((v) => ({ ...v, ...patch }));
return <WSContext.Provider value={value}>{children}</WSContext.Provider>;
}
return { Wrapper, emit: (patch: Partial<WSContextValue>) => act(() => push(patch)) };
return {
Wrapper,
emit: (patch: Partial<WSContextValue>) => act(() => push(patch)),
};
}
function useList<T>(initial: T[]) {
@@ -29,7 +35,10 @@ function useList<T>(initial: T[]) {
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
describe("useDumpListSync", () => {
function setup(initial: Dump[], opts?: Parameters<typeof useDumpListSync>[1]) {
function setup(
initial: Dump[],
opts?: Parameters<typeof useDumpListSync>[1],
) {
const ws = liveWS();
const hook = renderHook(() => {
const list = useList(initial);
@@ -40,7 +49,10 @@ describe("useDumpListSync", () => {
}
test("drops deleted dumps", () => {
const { hook, emit } = setup([makeDump({ id: "a" }), makeDump({ id: "b" })]);
const { hook, emit } = setup([
makeDump({ id: "a" }),
makeDump({ id: "b" }),
]);
emit({ deletedDumpIds: new Set(["a"]) });
expect(ids(hook.result.current.items)).toEqual(["b"]);
});
@@ -116,15 +128,25 @@ describe("usePlaylistListSync", () => {
test("prepends a created playlist once", () => {
const { hook, emit } = setup([makePlaylist({ id: "a" })]);
const p = makePlaylist({ id: "b" });
emit({ lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p } });
emit({ lastPlaylistEvent: { type: "created", playlistId: "b", playlist: { ...p } } });
emit({
lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p },
});
emit({
lastPlaylistEvent: {
type: "created",
playlistId: "b",
playlist: { ...p },
},
});
expect(ids(hook.result.current.items)).toEqual(["b", "a"]);
});
test("created: filtered by owner, privacy and noNewEntries", () => {
const p = makePlaylist({ id: "p", userId: "them" });
const byOwner = setup([], { ownerId: "me" });
byOwner.emit({ lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p } });
byOwner.emit({
lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
});
expect(byOwner.hook.result.current.items).toEqual([]);
const priv = setup([]);
@@ -138,7 +160,9 @@ describe("usePlaylistListSync", () => {
expect(priv.hook.result.current.items).toEqual([]);
const followed = setup([], { noNewEntries: true });
followed.emit({ lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p } });
followed.emit({
lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
});
expect(followed.hook.result.current.items).toEqual([]);
});
@@ -146,15 +170,27 @@ describe("usePlaylistListSync", () => {
const p = makePlaylist({ id: "p", title: "one" });
const { hook, emit } = setup([p]);
emit({
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: { ...p, title: "two" } },
lastPlaylistEvent: {
type: "updated",
playlistId: "p",
playlist: { ...p, title: "two" },
},
});
expect(hook.result.current.items[0].title).toBe("two");
emit({
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: { ...p, isPublic: false } },
lastPlaylistEvent: {
type: "updated",
playlistId: "p",
playlist: { ...p, isPublic: false },
},
});
expect(hook.result.current.items).toEqual([]);
emit({
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: { ...p, title: "back" } },
lastPlaylistEvent: {
type: "updated",
playlistId: "p",
playlist: { ...p, title: "back" },
},
});
expect(hook.result.current.items[0].title).toBe("back");
});
@@ -163,7 +199,9 @@ describe("usePlaylistListSync", () => {
const p = makePlaylist({ id: "p" });
for (const opts of [{ skipReinsert: true }, { noNewEntries: true }]) {
const { hook, emit } = setup([], opts);
emit({ lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: p } });
emit({
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: p },
});
expect(hook.result.current.items).toEqual([]);
}
});
@@ -182,7 +220,11 @@ describe("usePlaylistListSync", () => {
test("dumps_updated sets the count", () => {
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 1 })]);
emit({
lastPlaylistEvent: { type: "dumps_updated", playlistId: "a", dumpIds: ["x", "y", "z"] },
lastPlaylistEvent: {
type: "dumps_updated",
playlistId: "a",
dumpIds: ["x", "y", "z"],
},
});
expect(hook.result.current.items[0].dumpCount).toBe(3);
});
@@ -190,7 +232,12 @@ describe("usePlaylistListSync", () => {
test("dumps_updated counts the members this viewer can't see too", () => {
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 3 })]);
emit({
lastPlaylistEvent: { type: "dumps_updated", playlistId: "a", dumpIds: ["x", "y"], dumpCount: 4 },
lastPlaylistEvent: {
type: "dumps_updated",
playlistId: "a",
dumpIds: ["x", "y"],
dumpCount: 4,
},
});
expect(hook.result.current.items[0].dumpCount).toBe(4);
});
@@ -230,7 +277,13 @@ describe("usePositionAwareSync", () => {
const hook = renderHook(() => {
const { items, setItems } = useList(initial);
const [event, setEvent] = useState<Playlist | null>(null);
usePositionAwareSync(items, setItems, event, (p) => !p.isPublic, (p) => p.isPublic);
usePositionAwareSync(
items,
setItems,
event,
(p) => !p.isPublic,
(p) => p.isPublic,
);
return { items, setItems, setEvent };
});
const c = initial[2];
@@ -240,7 +293,9 @@ describe("usePositionAwareSync", () => {
expect(ids(hook.result.current.items)).toEqual(["a", "c"]);
// Nothing saved any more: a second public event is left alone.
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
act(() => hook.result.current.setEvent({ ...c, isPublic: true, title: "x" }));
act(() =>
hook.result.current.setEvent({ ...c, isPublic: true, title: "x" })
);
expect(ids(hook.result.current.items)).toEqual(["a"]);
});
});

View File

@@ -20,7 +20,8 @@ function wrapperFor(initial: AuthResponse | null) {
};
}
const stored = () => JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY) ?? "null");
const stored = () =>
JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY) ?? "null");
describe("useAuth", () => {
test("exposes the session", () => {
@@ -98,7 +99,9 @@ describe("authFetch", () => {
method: "POST",
body: new FormData(),
});
expect(calls("POST /api/upload")[0].headers.has("Content-Type")).toBe(false);
expect(calls("POST /api/upload")[0].headers.has("Content-Type")).toBe(
false,
);
});
test("no Authorization header when signed out", async () => {
@@ -135,7 +138,12 @@ describe("authFetch", () => {
describe("authUpload", () => {
class FakeXHR {
static last: FakeXHR;
static last: FakeXHR | undefined;
/** The request just made; a test that expects one fails without it. */
static get current(): FakeXHR {
if (!FakeXHR.last) throw new Error("No XMLHttpRequest was made");
return FakeXHR.last;
}
method = "";
url = "";
headers: Record<string, string> = {};
@@ -196,7 +204,7 @@ describe("authUpload", () => {
body,
(f) => progress.push(f),
);
const xhr = FakeXHR.last;
const xhr = FakeXHR.current;
expect(xhr.method).toBe("POST");
expect(xhr.headers.Authorization).toBe(`Bearer ${session.token}`);
expect(xhr.body).toBe(body);
@@ -215,9 +223,14 @@ describe("authUpload", () => {
const { result } = renderHook(useAuth, {
wrapper: wrapperFor(makeSession()),
});
const promise = result.current.authUpload("u", new FormData(), () => {}, "PUT");
expect(FakeXHR.last.method).toBe("PUT");
FakeXHR.last.error();
const promise = result.current.authUpload(
"u",
new FormData(),
() => {},
"PUT",
);
expect(FakeXHR.current.method).toBe("PUT");
FakeXHR.current.error();
await expect(promise).rejects.toBeInstanceOf(TypeError);
});
@@ -227,14 +240,14 @@ describe("authUpload", () => {
wrapper: wrapperFor(makeSession()),
});
const promise = result.current.authUpload("u", new FormData(), () => {});
act(() => FakeXHR.last.respond(401, ""));
act(() => FakeXHR.current.respond(401, ""));
await promise;
await waitFor(() => expect(result.current.user).toBeNull());
});
test("an expired token short-circuits", async () => {
vi.stubGlobal("XMLHttpRequest", FakeXHR);
FakeXHR.last = undefined as unknown as FakeXHR;
FakeXHR.last = undefined;
const session = { ...makeSession(), token: makeToken({ expiresIn: -5 }) };
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
const res = await act(() =>
@@ -258,4 +271,3 @@ describe("useRequiredAuth", () => {
expect(result.current.user.username).toBe("z");
});
});

View File

@@ -22,23 +22,40 @@ const owner = makeSession({ id: "owner", username: "owner" });
const other = makeSession({ id: "other", username: "other" });
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
function dumpRoutes(dump = makeDump({ id: "d1", slug: "a-find", title: "A find", userId: "owner", comment: "Because **reasons**", categoryIds: ["c1"] })) {
function dumpRoutes(
dump = makeDump({
id: "d1",
slug: "a-find",
title: "A find",
userId: "owner",
comment: "Because **reasons**",
categoryIds: ["c1"],
}),
) {
route("GET /api/dumps/:id", () => wire(dump));
route("GET /api/users/by-id/:id", () => wire(makeUser({ id: "owner", username: "owner" })));
route(
"GET /api/users/by-id/:id",
() => wire(makeUser({ id: "owner", username: "owner" })),
);
route("GET /api/dumps/:id/comments", () => []);
route("GET /api/dumps/:id/related", () => []);
route("GET /api/dumps/:id/playlists", () => []);
return dump;
}
function openDump(session: ReturnType<typeof makeSession> | null = null, url = "/dumps/a-find") {
function openDump(
session: ReturnType<typeof makeSession> | null = null,
url = "/dumps/a-find",
) {
const user = userEvent.setup();
renderApp(<Dump />, {
route: url,
path: "/dumps/:selectedDump",
session,
categories: [music],
extraRoutes: <Route path="/dumps/:selectedDump/edit" element={<p>edit page</p>} />,
extraRoutes: (
<Route path="/dumps/:selectedDump/edit" element={<p>edit page</p>} />
),
});
const socket = FakeWebSocket.last;
socket.welcome();
@@ -49,9 +66,16 @@ describe("Dump page", () => {
test("shows the dump, its author, categories and note", async () => {
dumpRoutes();
openDump();
expect(await screen.findByRole("heading", { name: "A find" })).toBeInTheDocument();
expect(await screen.findByRole("link", { name: "owner" })).toHaveAttribute("href", "/users/owner");
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute("href", "/music");
expect(await screen.findByRole("heading", { name: "A find" }))
.toBeInTheDocument();
expect(await screen.findByRole("link", { name: "owner" })).toHaveAttribute(
"href",
"/users/owner",
);
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute(
"href",
"/music",
);
expect(screen.getByText("reasons").tagName).toBe("STRONG");
await waitFor(() => expect(document.title).toMatch(/^A find · /));
expect(calls("GET /api/dumps/:id")[0].params.id).toBe("a-find");
@@ -63,21 +87,38 @@ describe("Dump page", () => {
route("GET /api/dumps/:id/related", () => []);
route("GET /api/dumps/:id/playlists", () => []);
openDump();
expect(await screen.findByRole("alert")).toHaveTextContent("Dump not found");
expect(screen.getByRole("button", { name: "← Back to all dumps" })).toBeInTheDocument();
expect(await screen.findByRole("alert")).toHaveTextContent(
"Dump not found",
);
expect(screen.getByRole("button", { name: "← Back to all dumps" }))
.toBeInTheDocument();
});
test("the owner can rename it in place, following the new slug", async () => {
dumpRoutes();
route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1", slug: "a-better-find", title: "A better find", userId: "owner" })));
route(
"PUT /api/dumps/:id",
() =>
wire(
makeDump({
id: "d1",
slug: "a-better-find",
title: "A better find",
userId: "owner",
}),
),
);
const { user } = openDump(owner);
await user.click(await screen.findByRole("heading", { name: "A find" }));
const input = screen.getByDisplayValue("A find");
await user.clear(input);
await user.type(input, "A better find{Enter}");
expect(await screen.findByRole("heading", { name: "A better find" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "A better find" }))
.toBeInTheDocument();
await waitFor(() => expect(currentPath()).toBe("/dumps/a-better-find"));
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({ title: "A better find" });
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({
title: "A better find",
});
});
test("a refused rename says why; Escape backs out", async () => {
@@ -96,7 +137,8 @@ describe("Dump page", () => {
const { user } = openDump(other);
await user.click(await screen.findByRole("heading", { name: "A find" }));
expect(screen.queryByDisplayValue("A find")).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Edit" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Edit" })).not
.toBeInTheDocument();
});
test("the owner gets an Edit link", async () => {
@@ -116,33 +158,54 @@ describe("Dump page", () => {
});
test("votes go through the socket", async () => {
dumpRoutes(makeDump({ id: "d1", slug: "a-find", title: "A find", voteCount: 2 }));
dumpRoutes(
makeDump({ id: "d1", slug: "a-find", title: "A find", voteCount: 2 }),
);
const { user, socket } = openDump(other);
await user.click(await screen.findByRole("button", { name: "Upvote" }));
expect(socket.messages).toContainEqual({ type: "vote_cast", dumpId: "d1" });
expect(screen.getByRole("button", { name: "Remove vote" })).toHaveTextContent("3");
expect(screen.getByRole("button", { name: "Remove vote" }))
.toHaveTextContent("3");
});
test("comments load, and live ones arrive", async () => {
dumpRoutes();
route("GET /api/dumps/:id/comments", () => wire([makeComment({ id: "c1", dumpId: "d1", body: "first" })]));
route(
"GET /api/dumps/:id/comments",
() => wire([makeComment({ id: "c1", dumpId: "d1", body: "first" })]),
);
const { socket } = openDump(other);
await screen.findByText("first");
socket.receive({ type: "comment_created", comment: wire(makeComment({ id: "c2", dumpId: "d1", body: "second" })) });
socket.receive({
type: "comment_created",
comment: wire(makeComment({ id: "c2", dumpId: "d1", body: "second" })),
});
expect(screen.getByText("second")).toBeInTheDocument();
socket.receive({ type: "comment_deleted", commentId: "c1", dumpId: "d1" });
expect(screen.getByText("[deleted]")).toBeInTheDocument();
// Another dump's comment isn't shown here.
socket.receive({ type: "comment_created", comment: wire(makeComment({ id: "c3", dumpId: "zzz", body: "elsewhere" })) });
socket.receive({
type: "comment_created",
comment: wire(
makeComment({ id: "c3", dumpId: "zzz", body: "elsewhere" }),
),
});
expect(screen.queryByText("elsewhere")).not.toBeInTheDocument();
});
test("related dumps and the playlists it's in", async () => {
dumpRoutes();
route("GET /api/dumps/:id/related", () => wire([makeDump({ title: "Links here" })]));
route("GET /api/dumps/:id/playlists", () => wire([makePlaylist({ title: "Best of" })]));
route(
"GET /api/dumps/:id/related",
() => wire([makeDump({ title: "Links here" })]),
);
route(
"GET /api/dumps/:id/playlists",
() => wire([makePlaylist({ title: "Best of" })]),
);
openDump();
expect(await screen.findByRole("heading", { name: "Related" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "Related" }))
.toBeInTheDocument();
expect(screen.getByText("Links here")).toBeInTheDocument();
expect(await screen.findByText("Best of")).toBeInTheDocument();
});
@@ -151,16 +214,26 @@ describe("Dump page", () => {
const d = dumpRoutes();
const { socket } = openDump();
await screen.findByRole("heading", { name: "A find" });
socket.receive({ type: "dump_updated", dump: wire({ ...d, title: "Retitled" }) });
expect(screen.getByRole("heading", { name: "Retitled" })).toBeInTheDocument();
socket.receive({
type: "dump_updated",
dump: wire({ ...d, title: "Retitled" }),
});
expect(screen.getByRole("heading", { name: "Retitled" }))
.toBeInTheDocument();
});
test("adding to a playlist", async () => {
dumpRoutes();
route("GET /api/playlists/by-dump/:id/memberships", () => [
{ playlist: wire(makePlaylist({ id: "p", title: "Mine" })), hasDump: true },
{
playlist: wire(makePlaylist({ id: "p", title: "Mine" })),
hasDump: true,
},
]);
route("DELETE /api/playlists/:pid/dumps/:did", () => new Response(null, { status: 204 }));
route(
"DELETE /api/playlists/:pid/dumps/:did",
() => new Response(null, { status: 204 }),
);
const { user } = openDump(owner);
await user.click(await screen.findByRole("button", { name: "+ Playlist" }));
const row = await screen.findByRole("checkbox", { name: /Mine/ });
@@ -172,24 +245,44 @@ describe("Dump page", () => {
test("a new playlist can be made from there, with the dump in it", async () => {
dumpRoutes();
route("GET /api/playlists/by-dump/:id/memberships", () => []);
route("POST /api/playlists", (req) => wire(makePlaylist({ id: "np", title: (req.body as { title: string }).title })));
route(
"POST /api/playlists",
(req) =>
wire(
makePlaylist({
id: "np",
title: (req.body as { title: string }).title,
}),
),
);
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
const { user } = openDump(owner);
await user.click(await screen.findByRole("button", { name: "+ Playlist" }));
await user.click(await screen.findByRole("button", { name: "+ New playlist" }));
await user.click(
await screen.findByRole("button", { name: "+ New playlist" }),
);
await user.type(screen.getByPlaceholderText("Title"), "Fresh{Enter}");
const row = await screen.findByRole("checkbox", { name: /Fresh/ });
expect(row).toHaveAttribute("aria-checked", "true");
expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/np/dumps/d1");
expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe(
"/api/playlists/np/dumps/d1",
);
});
test("scrolls to a linked comment", async () => {
dumpRoutes();
route("GET /api/dumps/:id/comments", () => wire([makeComment({ id: "c9", dumpId: "d1", body: "linked" })]));
route(
"GET /api/dumps/:id/comments",
() => wire([makeComment({ id: "c9", dumpId: "d1", body: "linked" })]),
);
const scroll = vi.spyOn(Element.prototype, "scrollIntoView");
openDump(null, "/dumps/a-find#comment-c9");
await screen.findByText("linked");
await waitFor(() => expect(document.getElementById("comment-c9")).toHaveClass("comment-node--highlight"));
await waitFor(() =>
expect(document.getElementById("comment-c9")).toHaveClass(
"comment-node--highlight",
)
);
expect(scroll).toHaveBeenCalled();
});
});
@@ -199,7 +292,9 @@ describe("DumpEdit page", () => {
route("GET /api/dumps/:id", () => wire(dump));
const user = userEvent.setup();
renderApp(
<RestrictedLoggedIn><DumpEdit /></RestrictedLoggedIn>,
<RestrictedLoggedIn>
<DumpEdit />
</RestrictedLoggedIn>,
{
route: `/dumps/${dump.slug ?? dump.id}/edit`,
path: "/dumps/:selectedDump/edit",
@@ -226,11 +321,18 @@ describe("DumpEdit page", () => {
url: "https://a.test/",
comment: "old why",
categoryIds: [],
richContent: { type: "link", url: "https://a.test/", title: "Page's own title" },
richContent: {
type: "link",
url: "https://a.test/",
title: "Page's own title",
},
});
test("saves title, note, categories and visibility", async () => {
route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1", slug: "new-title" })));
route(
"PUT /api/dumps/:id",
() => wire(makeDump({ id: "d1", slug: "new-title" })),
);
const user = openEdit(urlDump());
const title = await screen.findByLabelText("Title");
expect(document.title).toMatch(/^Editing A find/);
@@ -264,7 +366,8 @@ describe("DumpEdit page", () => {
const title = await screen.findByLabelText("Title");
await user.click(screen.getByRole("button", { name: "Reset to default" }));
expect(title).toHaveValue("Page's own title");
expect(screen.getByRole("button", { name: "Reset to default" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Reset to default" }))
.toBeDisabled();
});
test("a refused save stays on the form", async () => {
@@ -277,20 +380,51 @@ describe("DumpEdit page", () => {
});
test("refreshing the metadata", async () => {
route("POST /api/dumps/:id/refresh-metadata", () =>
wire({ ...urlDump(), richContent: { type: "link", url: "https://a.test/", title: "Fresh title", description: "New blurb" } }));
route(
"POST /api/dumps/:id/refresh-metadata",
() =>
wire({
...urlDump(),
richContent: {
type: "link",
url: "https://a.test/",
title: "Fresh title",
description: "New blurb",
},
}),
);
const user = openEdit(urlDump());
await user.click(await screen.findByRole("button", { name: "Refresh metadata" }));
await user.click(
await screen.findByRole("button", { name: "Refresh metadata" }),
);
expect(await screen.findByText("New blurb")).toBeInTheDocument();
});
test("replacing a file sends a visibility change in the same request", async () => {
const fileDump = makeDump({ id: "f1", kind: "file", title: "Song", userId: "owner", fileName: "song.mp3", fileMime: "audio/mpeg", fileSize: 10, url: undefined, isPrivate: false });
route("PUT /api/dumps/:id/file", () => wire({ ...fileDump, fileName: "new.mp3", isPrivate: true }));
const fileDump = makeDump({
id: "f1",
kind: "file",
title: "Song",
userId: "owner",
fileName: "song.mp3",
fileMime: "audio/mpeg",
fileSize: 10,
url: undefined,
isPrivate: false,
});
route(
"PUT /api/dumps/:id/file",
() => wire({ ...fileDump, fileName: "new.mp3", isPrivate: true }),
);
const user = openEdit(fileDump);
await screen.findByText("song.mp3");
const input = document.querySelector<HTMLInputElement>('.fdz input[type="file"]')!;
await user.upload(input, new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" }));
const input = document.querySelector<HTMLInputElement>(
'.fdz input[type="file"]',
)!;
await user.upload(
input,
new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" }),
);
await user.click(screen.getByRole("button", { name: "Private" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("dump page");
@@ -302,15 +436,31 @@ describe("DumpEdit page", () => {
});
test("a failed file replacement stays on the form, with nothing half-saved", async () => {
const fileDump = makeDump({ id: "f1", kind: "file", title: "Song", userId: "owner", fileName: "song.mp3", fileMime: "audio/mpeg", fileSize: 10, url: undefined, isPrivate: false });
const fileDump = makeDump({
id: "f1",
kind: "file",
title: "Song",
userId: "owner",
fileName: "song.mp3",
fileMime: "audio/mpeg",
fileSize: 10,
url: undefined,
isPrivate: false,
});
route("PUT /api/dumps/:id/file", () => fail("File too large (max 50 MB)"));
const user = openEdit(fileDump);
await screen.findByText("song.mp3");
const input = document.querySelector<HTMLInputElement>('.fdz input[type="file"]')!;
await user.upload(input, new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" }));
const input = document.querySelector<HTMLInputElement>(
'.fdz input[type="file"]',
)!;
await user.upload(
input,
new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" }),
);
await user.click(screen.getByRole("button", { name: "Private" }));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("File too large (max 50 MB)")).toBeInTheDocument();
expect(await screen.findByText("File too large (max 50 MB)"))
.toBeInTheDocument();
expect(screen.queryByText("dump page")).not.toBeInTheDocument();
expect(calls("PUT /api/dumps/:id/file")).toHaveLength(1);
expect(calls("PUT /api/dumps/:id")).toHaveLength(0);
@@ -319,36 +469,66 @@ describe("DumpEdit page", () => {
test("deleting asks, then goes home", async () => {
route("DELETE /api/dumps/:id", () => new Response(null, { status: 204 }));
const user = openEdit(urlDump());
await user.click(await screen.findByRole("button", { name: "Delete dump" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete dump" }));
await user.click(
await screen.findByRole("button", { name: "Delete dump" }),
);
await user.click(
within(screen.getByRole("alertdialog")).getByRole("button", {
name: "Delete dump",
}),
);
await screen.findByText("home");
expect(JSON.parse(document.querySelector('[data-testid="location"]')!.getAttribute("data-state")!))
expect(
JSON.parse(
document.querySelector('[data-testid="location"]')!.getAttribute(
"data-state",
)!,
),
)
.toEqual({ deletedDumpId: "d1" });
});
test("a delete that can't reach the server says so", async () => {
route("DELETE /api/dumps/:id", () => new TypeError("Failed to fetch"));
const user = openEdit(urlDump());
await user.click(await screen.findByRole("button", { name: "Delete dump" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete dump" }));
expect(await screen.findByRole("alert")).toHaveTextContent(/Could not connect/);
await user.click(
await screen.findByRole("button", { name: "Delete dump" }),
);
await user.click(
within(screen.getByRole("alertdialog")).getByRole("button", {
name: "Delete dump",
}),
);
expect(await screen.findByRole("alert")).toHaveTextContent(
/Could not connect/,
);
});
test("a thumbnail that won't upload says so", async () => {
route("PUT /api/dumps/:id/thumbnail", () => fail("Not an image", 400));
const user = openEdit(urlDump());
await screen.findByLabelText("Title");
const picker = document.querySelector<HTMLInputElement>('.dump-edit-thumbnail-row input[type="file"]')!;
await user.upload(picker, new File([new Uint8Array(3)], "t.png", { type: "image/png" }));
const picker = document.querySelector<HTMLInputElement>(
'.dump-edit-thumbnail-row input[type="file"]',
)!;
await user.upload(
picker,
new File([new Uint8Array(3)], "t.png", { type: "image/png" }),
);
expect(await screen.findByText("Not an image")).toBeInTheDocument();
});
test("guests are sent to log in", () => {
renderApp(<RestrictedLoggedIn><DumpEdit /></RestrictedLoggedIn>, {
route: "/dumps/x/edit",
path: "/dumps/:selectedDump/edit",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
});
renderApp(
<RestrictedLoggedIn>
<DumpEdit />
</RestrictedLoggedIn>,
{
route: "/dumps/x/edit",
path: "/dumps/:selectedDump/edit",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
},
);
expect(screen.getByText("login page")).toBeInTheDocument();
});
});

View File

@@ -4,7 +4,13 @@ import userEvent from "@testing-library/user-event";
import { useParams } from "react-router";
import { Index } from "../../pages/Index.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makeCategory, makeDump, makeSession, page, wire } from "../fixtures.ts";
import {
makeCategory,
makeDump,
makeSession,
page,
wire,
} from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
import { FakeIntersectionObserver } from "../setup.ts";
@@ -39,8 +45,18 @@ const titles = () =>
.map((a) => a.textContent);
describe("Index feeds", () => {
const older = makeDump({ id: "old", title: "Old but loved", voteCount: 30, createdAt: new Date(Date.now() - 5 * HOUR) });
const newer = makeDump({ id: "new", title: "Brand new", voteCount: 0, createdAt: new Date(Date.now() - 60_000) });
const older = makeDump({
id: "old",
title: "Old but loved",
voteCount: 30,
createdAt: new Date(Date.now() - 5 * HOUR),
});
const newer = makeDump({
id: "new",
title: "Brand new",
voteCount: 0,
createdAt: new Date(Date.now() - 60_000),
});
test("New sorts by date, Hot by score", async () => {
route("GET /api/dumps/", () => page([older, newer]));
@@ -72,7 +88,9 @@ describe("Index feeds", () => {
test("a failed load shows an error and disables posting", async () => {
route("GET /api/dumps/", () => fail("boom", 500));
setup("/~/new", { session: makeSession() });
expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Failed to load",
);
expect(screen.getByRole("button", { name: /Dump/ })).toBeDisabled();
});
@@ -81,18 +99,25 @@ describe("Index feeds", () => {
route("GET /api/dumps/", () => page([]));
setup("/~/new", { session });
await screen.findByText("No dumps yet. Be the first!");
expect(calls("GET /api/dumps/")[0].headers.get("Authorization")).toBe(`Bearer ${session.token}`);
expect(calls("GET /api/dumps/")[0].headers.get("Authorization")).toBe(
`Bearer ${session.token}`,
);
});
test("scrolling to the end loads the next page", async () => {
route("GET /api/dumps/", (req) =>
req.url.searchParams.get("page") === "1"
? page([newer], true)
: page([older], false));
route(
"GET /api/dumps/",
(req) =>
req.url.searchParams.get("page") === "1"
? page([newer], true)
: page([older], false),
);
setup("/~/new");
await screen.findByText("Brand new");
const observer = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!;
act(() => observer.callback([{ isIntersecting: true } as IntersectionObserverEntry], observer as never));
const observer = FakeIntersectionObserver.instances.findLast((o) =>
o.elements.size > 0
)!;
act(() => observer.report(true));
await screen.findByText("Old but loved");
expect(calls("GET /api/dumps/")[1].url.searchParams.get("page")).toBe("2");
});
@@ -101,7 +126,12 @@ describe("Index feeds", () => {
route("GET /api/dumps/", () => page([older]));
const { socket } = setup("/~/new");
await screen.findByText("Old but loved");
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "live", title: "Just now", createdAt: new Date() })) });
socket.receive({
type: "dump_created",
dump: wire(
makeDump({ id: "live", title: "Just now", createdAt: new Date() }),
),
});
expect(titles()[0]).toBe("Just now");
socket.receive({ type: "dump_deleted", dumpId: "old" });
expect(screen.queryByText("Old but loved")).not.toBeInTheDocument();
@@ -111,8 +141,16 @@ describe("Index feeds", () => {
route("GET /api/dumps/", () => page([older]));
const { socket } = setup("/~/new");
await screen.findByText("Old but loved");
socket.receive({ type: "votes_update", dumpId: "old", voteCount: 31, voterId: "x", action: "cast" });
expect(screen.getByRole("button", { name: "Upvote" })).toHaveTextContent("31");
socket.receive({
type: "votes_update",
dumpId: "old",
voteCount: 31,
voterId: "x",
action: "cast",
});
expect(screen.getByRole("button", { name: "Upvote" })).toHaveTextContent(
"31",
);
});
test("guests can't vote", async () => {
@@ -134,13 +172,30 @@ describe("Index feeds", () => {
});
test("a category scopes the feed, and live arrivals to it", async () => {
route("GET /api/categories/:slug/dumps", () => page([makeDump({ title: "A song", categoryIds: ["c-music"] })]));
route(
"GET /api/categories/:slug/dumps",
() => page([makeDump({ title: "A song", categoryIds: ["c-music"] })]),
);
const { socket } = setup("/music/new");
await screen.findByText("A song");
expect(calls("GET /api/categories/:slug/dumps")[0].params.slug).toBe("music");
expect(calls("GET /api/categories/:slug/dumps")[0].params.slug).toBe(
"music",
);
await waitFor(() => expect(document.title).toMatch(/^Music · /));
socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Elsewhere", createdAt: new Date() })) });
socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Another song", categoryIds: ["c-music"], createdAt: new Date() })) });
socket.receive({
type: "dump_created",
dump: wire(makeDump({ title: "Elsewhere", createdAt: new Date() })),
});
socket.receive({
type: "dump_created",
dump: wire(
makeDump({
title: "Another song",
categoryIds: ["c-music"],
createdAt: new Date(),
}),
),
});
expect(screen.queryByText("Elsewhere")).not.toBeInTheDocument();
expect(screen.getByText("Another song")).toBeInTheDocument();
});
@@ -148,7 +203,8 @@ describe("Index feeds", () => {
test("an unknown category is a 404", async () => {
route("GET /api/categories/:slug/dumps", () => fail("nope", 404));
setup("/nonsense/new");
expect(await screen.findByRole("heading", { name: "404" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "404" }))
.toBeInTheDocument();
});
test("a guest on the followed tab gets the default feed, not a blank page", async () => {
@@ -161,22 +217,32 @@ describe("Index feeds", () => {
route("GET /api/dumps/", () => page([]));
route("GET /api/preview", () => ({ reached: false, richContent: null }));
route("GET /api/dumps/by-url", () => []);
setup("/~/new?share_url=https%3A%2F%2Fshared.test%2F", { session: makeSession() });
setup("/~/new?share_url=https%3A%2F%2Fshared.test%2F", {
session: makeSession(),
});
const dialog = await screen.findByRole("dialog", { name: "New dump" });
expect(within(dialog).getByPlaceholderText("Paste a link…")).toHaveValue("https://shared.test/");
expect(within(dialog).getByPlaceholderText("Paste a link…")).toHaveValue(
"https://shared.test/",
);
});
});
describe("Followed feed", () => {
test("from people and from playlists", async () => {
const session = makeSession();
route("GET /api/follows/feed/users", () => page([makeDump({ title: "By a friend" })]));
route(
"GET /api/follows/feed/users",
() => page([makeDump({ title: "By a friend" })]),
);
route("GET /api/follows/feed/playlists", () => page([]));
const { user } = setup("/~/followed", { session });
await screen.findByText("By a friend");
await user.click(screen.getByRole("button", { name: "From playlists" }));
expect(screen.getByText("Follow some public playlists to see their dumps here.")).toBeInTheDocument();
expect(calls("GET /api/follows/feed/users")[0].headers.get("Authorization")).toBe(`Bearer ${session.token}`);
expect(
screen.getByText("Follow some public playlists to see their dumps here."),
).toBeInTheDocument();
expect(calls("GET /api/follows/feed/users")[0].headers.get("Authorization"))
.toBe(`Bearer ${session.token}`);
});
test("a category narrows it", async () => {
@@ -184,7 +250,9 @@ describe("Followed feed", () => {
route("GET /api/follows/feed/playlists", () => page([]));
setup("/music/followed", { session: makeSession() });
await screen.findByText("Follow some users to see their dumps here.");
expect(calls("GET /api/follows/feed/users")[0].url.searchParams.get("category")).toBe("music");
expect(
calls("GET /api/follows/feed/users")[0].url.searchParams.get("category"),
).toBe("music");
});
test("a server error reads as one, not as a lost connection", async () => {

View File

@@ -3,7 +3,7 @@ import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Notifications } from "../../pages/Notifications.tsx";
import type { Notification } from "../../model.ts";
import { calls, fail, route } from "../fetch.ts";
import { calls, deferred, fail, route } from "../fetch.ts";
import { makeNotification, makeSession, page, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
@@ -12,33 +12,122 @@ const me = makeSession({ id: "me" });
const DAY = 86_400_000;
function open(items: Notification[], { hasMore = false } = {}) {
route("GET /api/notifications", (req) =>
req.url.searchParams.get("page") === "1" ? page(items, hasMore) : page([]));
route(
"GET /api/notifications",
(req) =>
req.url.searchParams.get("page") === "1"
? page(items, hasMore)
: page([]),
);
route("POST /api/notifications/read-all", () => ({}));
const user = userEvent.setup();
renderApp(<Notifications />, { session: me, route: "/notifications" });
const socket = FakeWebSocket.last;
socket.welcome({ unreadNotificationCount: items.filter((n) => !n.read).length });
socket.welcome({
unreadNotificationCount: items.filter((n) => !n.read).length,
});
return { user, socket };
}
const n = {
follow: makeNotification({ type: "user_followed", data: { followerId: "f", followerUsername: "fan" } }),
playlistFollow: makeNotification({ type: "playlist_followed", data: { followerId: "f", followerUsername: "fan", playlistId: "p1", playlistTitle: "Mix" } }),
posted: makeNotification({ type: "user_dump_posted", data: { dumperId: "a", dumperUsername: "ann", dumpId: "d1", dumpTitle: "Song" } }),
added: makeNotification({ type: "playlist_dump_added", data: { dumpId: "d2", dumpTitle: "Tune", playlistId: "p", playlistTitle: "Mix" } }),
upvoted: makeNotification({ type: "dump_upvoted", data: { voterId: "v", voterUsername: "bob", dumpId: "d3", dumpTitle: "Pic" } }),
commented: makeNotification({ type: "dump_commented", data: { commenterId: "c", commenterUsername: "cy", commentId: "c1", dumpId: "d4", dumpTitle: "Post" } }),
liked: makeNotification({ type: "comment_liked", data: { likerId: "l", likerUsername: "lu", commentId: "c2", dumpId: "d5", dumpTitle: "Thing" } }),
mentionComment: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "comment", contextId: "c3", contextTitle: "", dumpId: "d6" } }),
mentionChat: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "chat", contextId: "x", contextTitle: "" } }),
mentionPlaylist: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "playlist", contextId: "p9", contextTitle: "Faves" } }),
follow: makeNotification({
type: "user_followed",
data: { followerId: "f", followerUsername: "fan" },
}),
playlistFollow: makeNotification({
type: "playlist_followed",
data: {
followerId: "f",
followerUsername: "fan",
playlistId: "p1",
playlistTitle: "Mix",
},
}),
posted: makeNotification({
type: "user_dump_posted",
data: {
dumperId: "a",
dumperUsername: "ann",
dumpId: "d1",
dumpTitle: "Song",
},
}),
added: makeNotification({
type: "playlist_dump_added",
data: {
dumpId: "d2",
dumpTitle: "Tune",
playlistId: "p",
playlistTitle: "Mix",
},
}),
upvoted: makeNotification({
type: "dump_upvoted",
data: {
voterId: "v",
voterUsername: "bob",
dumpId: "d3",
dumpTitle: "Pic",
},
}),
commented: makeNotification({
type: "dump_commented",
data: {
commenterId: "c",
commenterUsername: "cy",
commentId: "c1",
dumpId: "d4",
dumpTitle: "Post",
},
}),
liked: makeNotification({
type: "comment_liked",
data: {
likerId: "l",
likerUsername: "lu",
commentId: "c2",
dumpId: "d5",
dumpTitle: "Thing",
},
}),
mentionComment: makeNotification({
type: "user_mentioned",
data: {
mentionerId: "m",
mentionerUsername: "mo",
contextType: "comment",
contextId: "c3",
contextTitle: "",
dumpId: "d6",
},
}),
mentionChat: makeNotification({
type: "user_mentioned",
data: {
mentionerId: "m",
mentionerUsername: "mo",
contextType: "chat",
contextId: "x",
contextTitle: "",
},
}),
mentionPlaylist: makeNotification({
type: "user_mentioned",
data: {
mentionerId: "m",
mentionerUsername: "mo",
contextType: "playlist",
contextId: "p9",
contextTitle: "Faves",
},
}),
};
describe("Notifications", () => {
test("every kind reads right and links to its subject", async () => {
open(Object.values(n));
const link = async (text: RegExp) => (await screen.findByText(text)).closest("a")!.getAttribute("href");
const link = async (text: RegExp) =>
(await screen.findByText(text)).closest("a")!.getAttribute("href");
expect(await link(/started following you/)).toBe("/users/fan");
expect(await link(/followed your playlist/)).toBe("/playlists/p1");
expect(await link(/^posted$/)).toBe("/dumps/d1");
@@ -46,8 +135,14 @@ describe("Notifications", () => {
expect(await link(/^upvoted$/)).toBe("/dumps/d3");
expect(await link(/commented on/)).toBe("/dumps/d4#comment-c1");
expect(await link(/liked your comment on/)).toBe("/dumps/d5#comment-c2");
const mentions = screen.getAllByText(/mentioned you in/).map((el) => el.closest("a")!.getAttribute("href"));
expect(mentions).toEqual(["/dumps/d6#comment-c3", "/notifications?chat=open", "/playlists/p9"]);
const mentions = screen.getAllByText(/mentioned you in/).map((el) =>
el.closest("a")!.getAttribute("href")
);
expect(mentions).toEqual([
"/dumps/d6#comment-c3",
"/notifications?chat=open",
"/playlists/p9",
]);
expect(screen.getByText("a comment")).toBeInTheDocument();
expect(screen.getByText("the chat")).toBeInTheDocument();
expect(screen.getByText("Faves")).toBeInTheDocument();
@@ -59,28 +154,37 @@ describe("Notifications", () => {
makeNotification({ createdAt: new Date(Date.now() - DAY - 60_000) }),
makeNotification({ createdAt: new Date(Date.now() - 10 * DAY) }),
]);
expect(await screen.findByRole("heading", { name: "Today" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Earlier" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "Today" }))
.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Earlier" }))
.toBeInTheDocument();
});
test("opening them marks them read and clears the bell, but still shows which were new", async () => {
open([n.follow, { ...n.upvoted, read: true }]);
await screen.findByText(/started following you/);
await waitFor(() => expect(calls("POST /api/notifications/read-all")).toHaveLength(1));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument()
expect(calls("POST /api/notifications/read-all")).toHaveLength(1)
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Notifications" }))
.toBeInTheDocument()
);
expect(document.querySelectorAll(".notif-dot")).toHaveLength(1);
expect(screen.getByText(/started following you/).closest("li"))
.toHaveClass("notification-item--unread");
expect(document.querySelector(".notifications-unread-pill")).toHaveTextContent("1 new");
expect(document.querySelector(".notifications-unread-pill"))
.toHaveTextContent("1 new");
});
test("a list that loaded stays shown if marking it read fails", async () => {
route("POST /api/notifications/read-all", () => new TypeError("offline"));
open([n.follow]);
expect(await screen.findByText(/started following you/)).toBeInTheDocument();
await waitFor(() => expect(calls("POST /api/notifications/read-all")).toHaveLength(1));
expect(await screen.findByText(/started following you/))
.toBeInTheDocument();
await waitFor(() =>
expect(calls("POST /api/notifications/read-all")).toHaveLength(1)
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByText(/started following you/)).toBeInTheDocument();
});
@@ -102,31 +206,45 @@ describe("Notifications", () => {
test("a new one arrives at the top while the page is open", async () => {
const { socket } = open([n.follow]);
await screen.findByText(/started following you/);
socket.receive({ type: "notification_created", notification: wire(n.upvoted) });
socket.receive({
type: "notification_created",
notification: wire(n.upvoted),
});
const items = document.querySelectorAll(".notification-item");
expect(items[0]).toHaveTextContent("bob upvoted Pic");
expect(items[0]).toHaveClass("notification-item--unread");
// Seen as it arrives: marked read, so the bell doesn't count it.
await waitFor(() => expect(calls("POST /api/notifications/read-all")).toHaveLength(2));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument()
expect(calls("POST /api/notifications/read-all")).toHaveLength(2)
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Notifications" }))
.toBeInTheDocument()
);
});
test("one that arrives while the list is loading isn't lost", async () => {
let answer!: (r: Response) => void;
route("GET /api/notifications", () => new Promise<Response>((r) => (answer = r)));
const list = deferred<unknown>();
route("GET /api/notifications", () => list.promise);
route("POST /api/notifications/read-all", () => ({}));
renderApp(<Notifications />, { session: me, route: "/notifications" });
const socket = FakeWebSocket.last;
socket.welcome({ unreadNotificationCount: 1 });
await waitFor(() => expect(calls("GET /api/notifications")).toHaveLength(1));
await waitFor(() =>
expect(calls("GET /api/notifications")).toHaveLength(1)
);
// Created after the list was read, so the list won't have it — but the
// read-all after the load will mark it read.
socket.receive({ type: "notification_created", notification: wire(n.upvoted) });
socket.receive({
type: "notification_created",
notification: wire(n.upvoted),
});
// And one the list does have, sent while it was on its way: shown once.
socket.receive({ type: "notification_created", notification: wire(n.follow) });
answer(page([n.follow]));
socket.receive({
type: "notification_created",
notification: wire(n.follow),
});
list.resolve(page([n.follow]));
await screen.findByText(/started following you/);
const items = document.querySelectorAll(".notification-item");
expect(items).toHaveLength(2);
@@ -135,15 +253,19 @@ describe("Notifications", () => {
});
test("load more", async () => {
route("GET /api/notifications", (req) =>
req.url.searchParams.get("page") === "1"
? page([n.follow], true)
: page([n.upvoted], false));
route(
"GET /api/notifications",
(req) =>
req.url.searchParams.get("page") === "1"
? page([n.follow], true)
: page([n.upvoted], false),
);
route("POST /api/notifications/read-all", () => ({}));
const user = userEvent.setup();
renderApp(<Notifications />, { session: me });
await user.click(await screen.findByRole("button", { name: "Load more" }));
await screen.findByText(/^upvoted$/);
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Load more" })).not
.toBeInTheDocument();
});
});

View File

@@ -1,5 +1,11 @@
import { describe, expect, test, vi } from "vitest";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import {
act,
fireEvent,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Route } from "react-router";
import { PlaylistDetail } from "../../pages/PlaylistDetail.tsx";
@@ -19,7 +25,11 @@ const dumps = () => [
];
function open(
{ session = owner as ReturnType<typeof makeSession> | null, playlist = {} as Partial<Playlist>, items = dumps() as Dump[] } = {},
{
session = owner as ReturnType<typeof makeSession> | null,
playlist = {} as Partial<Playlist>,
items = dumps() as Dump[],
} = {},
) {
const pl = makePlaylist({
id: "p1",
@@ -44,30 +54,44 @@ function open(
}
const titles = () =>
Array.from(document.querySelectorAll(".dump-card-title")).map((a) => a.textContent);
Array.from(document.querySelectorAll(".dump-card-title")).map((a) =>
a.textContent
);
describe("PlaylistDetail", () => {
test("shows the playlist and its dumps in order", async () => {
open({ session: null });
expect(await screen.findByRole("heading", { name: "Mix" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "Mix" }))
.toBeInTheDocument();
expect(screen.getByText("tunes").tagName).toBe("STRONG");
expect(screen.getByRole("link", { name: "@owner" })).toHaveAttribute("href", "/users/owner");
expect(screen.getByRole("link", { name: "@owner" })).toHaveAttribute(
"href",
"/users/owner",
);
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
await waitFor(() => expect(document.title).toMatch(/^Mix · /));
expect(screen.queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Edit" })).not
.toBeInTheDocument();
});
test("not found", async () => {
route("GET /api/playlists/:id", () => fail("nope", 404));
renderApp(<PlaylistDetail />, { route: "/playlists/x", path: "/playlists/:playlistId" });
expect(await screen.findByRole("alert")).toHaveTextContent("Playlist not found");
renderApp(<PlaylistDetail />, {
route: "/playlists/x",
path: "/playlists/:playlistId",
});
expect(await screen.findByRole("alert")).toHaveTextContent(
"Playlist not found",
);
});
test("a visitor can follow a public playlist, not edit it", async () => {
open({ session: visitor });
await screen.findByRole("heading", { name: "Mix" });
expect(screen.getByRole("button", { name: "Follow playlist" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Follow playlist" }))
.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not
.toBeInTheDocument();
});
test("the cover is versioned on the last edit", async () => {
@@ -80,8 +104,19 @@ describe("PlaylistDetail", () => {
});
test("editing the description shows at once, without waiting for the socket", async () => {
route("PUT /api/playlists/:id", () =>
wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner", description: "Better tunes" })));
route(
"PUT /api/playlists/:id",
() =>
wire(
makePlaylist({
id: "p1",
slug: "mix",
title: "Mix",
userId: "owner",
description: "Better tunes",
}),
),
);
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
const box = screen.getByPlaceholderText("Description (optional)");
@@ -89,13 +124,26 @@ describe("PlaylistDetail", () => {
await user.type(box, "Better tunes");
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("Better tunes")).toBeInTheDocument();
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({ description: "Better tunes", isPublic: true });
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({
description: "Better tunes",
isPublic: true,
});
expect(currentPath()).toBe("/playlists/mix");
});
test("a new title moves to the new slug", async () => {
route("PUT /api/playlists/:id", () =>
wire(makePlaylist({ id: "p1", slug: "remix", title: "Remix", userId: "owner" })));
route(
"PUT /api/playlists/:id",
() =>
wire(
makePlaylist({
id: "p1",
slug: "remix",
title: "Remix",
userId: "owner",
}),
),
);
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
const title = screen.getByPlaceholderText("Playlist title");
@@ -107,14 +155,44 @@ describe("PlaylistDetail", () => {
test("a new cover shows once saved; a refused one says why", async () => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:preview");
route("PUT /api/playlists/:id", () => wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner" })));
route("POST /api/playlists/:id/image", () => fail("Not an image", 400), { once: true });
route("POST /api/playlists/:id/image", () =>
wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner", imageMime: "image/png", updatedAt: new Date(9000) })));
route(
"PUT /api/playlists/:id",
() =>
wire(
makePlaylist({
id: "p1",
slug: "mix",
title: "Mix",
userId: "owner",
}),
),
);
route("POST /api/playlists/:id/image", () => fail("Not an image", 400), {
once: true,
});
route(
"POST /api/playlists/:id/image",
() =>
wire(
makePlaylist({
id: "p1",
slug: "mix",
title: "Mix",
userId: "owner",
imageMime: "image/png",
updatedAt: new Date(9000),
}),
),
);
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
const picker = document.querySelector<HTMLInputElement>('.img-picker input[type="file"]')!;
await user.upload(picker, new File([new Uint8Array(3)], "c.png", { type: "image/png" }));
const picker = document.querySelector<HTMLInputElement>(
'.img-picker input[type="file"]',
)!;
await user.upload(
picker,
new File([new Uint8Array(3)], "c.png", { type: "image/png" }),
);
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Not an image");
await user.click(screen.getByRole("button", { name: "Save" }));
@@ -136,19 +214,36 @@ describe("PlaylistDetail", () => {
test("removing a dump fades it out, with an undo", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
route("DELETE /api/playlists/:pid/dumps/:did", () => new Response(null, { status: 204 }));
route(
"DELETE /api/playlists/:pid/dumps/:did",
() => new Response(null, { status: 204 }),
);
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
route("PUT /api/playlists/:pid/order", () => ({}));
const { user } = open();
await screen.findByText("Bravo");
const bravo = screen.getByText("Bravo").closest(".playlist-dump-item") as HTMLElement;
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" }));
expect(calls("DELETE /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/mix/dumps/b");
await user.click(within(bravo).getByRole("button", { name: "Cancel removal" }));
await waitFor(() => expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1));
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ dumpIds: ["a", "b", "c"] });
const bravo = screen.getByText("Bravo").closest(
".playlist-dump-item",
) as HTMLElement;
await user.click(
within(bravo).getByRole("button", { name: "Remove from playlist" }),
);
expect(calls("DELETE /api/playlists/:pid/dumps/:did")[0].path).toBe(
"/api/playlists/mix/dumps/b",
);
await user.click(
within(bravo).getByRole("button", { name: "Cancel removal" }),
);
await waitFor(() =>
expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1)
);
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({
dumpIds: ["a", "b", "c"],
});
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" }));
await user.click(
within(bravo).getByRole("button", { name: "Remove from playlist" }),
);
act(() => vi.advanceTimersByTime(2_400));
expect(screen.queryByText("Bravo")).not.toBeInTheDocument();
} finally {
@@ -157,20 +252,31 @@ describe("PlaylistDetail", () => {
});
test("a refused removal puts the dump back and says so", async () => {
route("DELETE /api/playlists/:pid/dumps/:did", () => fail("Forbidden", 403));
route(
"DELETE /api/playlists/:pid/dumps/:did",
() => fail("Forbidden", 403),
);
const { user } = open();
await screen.findByText("Bravo");
const bravo = screen.getByText("Bravo").closest(".playlist-dump-item") as HTMLElement;
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" }));
expect(await screen.findByRole("alert")).toHaveTextContent("couldn't be removed");
expect(within(bravo).getByRole("button", { name: "Remove from playlist" })).toBeInTheDocument();
const bravo = screen.getByText("Bravo").closest(
".playlist-dump-item",
) as HTMLElement;
await user.click(
within(bravo).getByRole("button", { name: "Remove from playlist" }),
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"couldn't be removed",
);
expect(within(bravo).getByRole("button", { name: "Remove from playlist" }))
.toBeInTheDocument();
});
test("dragging reorders, and a refused reorder reloads the real order", async () => {
route("PUT /api/playlists/:pid/order", () => fail("Forbidden", 403));
open();
await screen.findByText("Charlie");
const items = () => Array.from(document.querySelectorAll<HTMLElement>(".playlist-dump-item"));
const items = () =>
Array.from(document.querySelectorAll<HTMLElement>(".playlist-dump-item"));
for (const el of items()) {
el.getBoundingClientRect = () => ({ top: 0, height: 100 }) as DOMRect;
}
@@ -179,18 +285,33 @@ describe("PlaylistDetail", () => {
fireEvent.dragOver(c, { clientY: 90 });
expect(titles()).toEqual(["Bravo", "Charlie", "Alpha"]);
fireEvent.dragEnd(items()[2]);
await waitFor(() => expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1));
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ dumpIds: ["b", "c", "a"] });
await waitFor(() => expect(calls("GET /api/playlists/:id")).toHaveLength(2));
await waitFor(() => expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]));
await waitFor(() =>
expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1)
);
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({
dumpIds: ["b", "c", "a"],
});
await waitFor(() =>
expect(calls("GET /api/playlists/:id")).toHaveLength(2)
);
await waitFor(() =>
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"])
);
});
test("deleting the playlist asks, then goes home", async () => {
route("DELETE /api/playlists/:id", () => new Response(null, { status: 204 }));
route(
"DELETE /api/playlists/:id",
() => new Response(null, { status: 204 }),
);
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
await user.click(
within(screen.getByRole("alertdialog")).getByRole("button", {
name: "Delete playlist",
}),
);
expect(await screen.findByText("home")).toBeInTheDocument();
});
@@ -199,22 +320,44 @@ describe("PlaylistDetail", () => {
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
await user.click(
within(screen.getByRole("alertdialog")).getByRole("button", {
name: "Delete playlist",
}),
);
expect(await screen.findByRole("alert")).toHaveTextContent("Forbidden");
expect(currentPath()).toBe("/playlists/mix");
});
test("live: added dumps appear, removed ones fade, a deleted playlist sends you home", async () => {
route("GET /api/dumps/:id", () => wire(makeDump({ id: "d", title: "Delta" })));
route(
"GET /api/dumps/:id",
() => wire(makeDump({ id: "d", title: "Delta" })),
);
const { socket } = open({ session: visitor });
await screen.findByText("Alpha");
socket.receive({ type: "playlist_dumps_updated", playlistId: "p1", dumpIds: ["d", "a", "b", "c"], dumpCount: 4 });
socket.receive({
type: "playlist_dumps_updated",
playlistId: "p1",
dumpIds: ["d", "a", "b", "c"],
dumpCount: 4,
});
expect(await screen.findByText("Delta")).toBeInTheDocument();
socket.receive({ type: "playlist_updated", playlist: wire(makePlaylist({ id: "p1", title: "Renamed", userId: "owner" })) });
expect(screen.getByRole("heading", { name: "Renamed" })).toBeInTheDocument();
socket.receive({
type: "playlist_updated",
playlist: wire(
makePlaylist({ id: "p1", title: "Renamed", userId: "owner" }),
),
});
expect(screen.getByRole("heading", { name: "Renamed" }))
.toBeInTheDocument();
socket.receive({ type: "dump_deleted", dumpId: "a" });
expect(screen.queryByText("Alpha")).not.toBeInTheDocument();
socket.receive({ type: "playlist_deleted", playlistId: "p1", userId: "owner" });
socket.receive({
type: "playlist_deleted",
playlistId: "p1",
userId: "owner",
});
expect(await screen.findByText("home")).toBeInTheDocument();
});
});

View File

@@ -3,21 +3,39 @@ import { act, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Search } from "../../pages/Search.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makeDump, makePlaylist, makeSession, makeUser, wire } from "../fixtures.ts";
import {
makeDump,
makePlaylist,
makeSession,
makeUser,
wire,
} from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeIntersectionObserver } from "../setup.ts";
function results(
{ dumps = [] as ReturnType<typeof makeDump>[], hasMore = false, users = [] as ReturnType<typeof makeUser>[], playlists = [] as ReturnType<typeof makePlaylist>[] } = {},
{
dumps = [] as ReturnType<typeof makeDump>[],
hasMore = false,
users = [] as ReturnType<typeof makeUser>[],
playlists = [] as ReturnType<typeof makePlaylist>[],
} = {},
) {
return {
dumps: { items: wire(dumps), total: dumps.length + (hasMore ? 1 : 0), hasMore },
dumps: {
items: wire(dumps),
total: dumps.length + (hasMore ? 1 : 0),
hasMore,
},
users: wire(users),
playlists: wire(playlists),
};
}
function open(url: string, session: ReturnType<typeof makeSession> | null = null) {
function open(
url: string,
session: ReturnType<typeof makeSession> | null = null,
) {
const user = userEvent.setup();
renderApp(<Search />, { route: url, path: "/search/*", session });
return user;
@@ -44,7 +62,10 @@ describe("Search", () => {
expect(req.headers.get("Authorization")).toMatch(/^Bearer /);
await user.click(screen.getByRole("button", { name: "Users (1)" }));
expect(currentPath()).toBe("/search/~/users?q=cat");
expect(screen.getByRole("link", { name: /@catlover/ })).toHaveAttribute("href", "/users/catlover");
expect(screen.getByRole("link", { name: /@catlover/ })).toHaveAttribute(
"href",
"/users/catlover",
);
await user.click(screen.getByRole("button", { name: "Playlists (1)" }));
expect(screen.getByText("Cats")).toBeInTheDocument();
});
@@ -52,7 +73,8 @@ describe("Search", () => {
test("nothing found", async () => {
route("GET /api/search", () => results());
const user = open("/search?q=zzz");
expect(await screen.findByText('No dumps match "zzz".')).toBeInTheDocument();
expect(await screen.findByText('No dumps match "zzz".'))
.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Users (0)" }));
expect(screen.getByText('No users match "zzz".')).toBeInTheDocument();
});
@@ -66,45 +88,73 @@ describe("Search", () => {
});
test("more dumps on scroll; a failed page keeps what's shown", async () => {
route("GET /api/search", () => results({ dumps: [makeDump({ title: "First" })], hasMore: true }), { once: true });
route(
"GET /api/search",
() => results({ dumps: [makeDump({ title: "First" })], hasMore: true }),
{ once: true },
);
route("GET /api/search", () => new TypeError("offline"), { once: true });
route("GET /api/search", () => results({ dumps: [makeDump({ title: "Second" })] }));
route(
"GET /api/search",
() => results({ dumps: [makeDump({ title: "Second" })] }),
);
open("/search?q=x");
await screen.findByText("First");
const scroll = () => {
const obs = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!;
act(() => obs.callback([{ isIntersecting: true } as IntersectionObserverEntry], obs as never));
const obs = FakeIntersectionObserver.instances.findLast((o) =>
o.elements.size > 0
)!;
act(() => obs.report(true));
};
scroll();
await act(() => new Promise((r) => setTimeout(r, 50)));
expect(screen.getByText("First")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
// Scrolling away and back tries again at once.
const obs = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!;
act(() => obs.callback([{ isIntersecting: false } as IntersectionObserverEntry], obs as never));
const obs = FakeIntersectionObserver.instances.findLast((o) =>
o.elements.size > 0
)!;
act(() => obs.report(false));
scroll();
await screen.findByText("Second");
expect(calls("GET /api/search").at(-1)!.url.searchParams.get("page")).toBe("2");
expect(calls("GET /api/search").at(-1)!.url.searchParams.get("page")).toBe(
"2",
);
});
test("a later page that keeps failing isn't asked for in a loop", async () => {
route("GET /api/search", () => results({ dumps: [makeDump({ title: "First" })], hasMore: true }), { once: true });
route(
"GET /api/search",
() => results({ dumps: [makeDump({ title: "First" })], hasMore: true }),
{ once: true },
);
route("GET /api/search", () => fail("down", 500));
// A short page: the sentinel is on screen and stays there.
FakeIntersectionObserver.inView = true;
open("/search?q=x");
await screen.findByText("First");
// In short steps: React holds what an act() schedules until it ends.
for (let i = 0; i < 20; i++) await act(() => new Promise((r) => setTimeout(r, 100)));
const pages = calls("GET /api/search").filter((c) => c.url.searchParams.get("page") !== "1");
for (let i = 0; i < 20; i++) {
await act(() => new Promise((r) => setTimeout(r, 100)));
}
const pages = calls("GET /api/search").filter((c) =>
c.url.searchParams.get("page") !== "1"
);
// Once at once, once more a second later; the next is 2s after that.
expect(pages).toHaveLength(2);
expect(screen.getByText("First")).toBeInTheDocument();
});
test("a new query replaces the results", async () => {
route("GET /api/search", (req) =>
results({ dumps: [makeDump({ title: `About ${req.url.searchParams.get("q")}` })] }));
route(
"GET /api/search",
(req) =>
results({
dumps: [
makeDump({ title: `About ${req.url.searchParams.get("q")}` }),
],
}),
);
const user = open("/search?q=one");
await screen.findByText("About one");
const [box] = screen.getAllByRole("searchbox");

View File

@@ -3,7 +3,13 @@ import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UserPlaylists } from "../../pages/UserPlaylists.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makePlaylist, makeSession, makeUser, page, wire } from "../fixtures.ts";
import {
makePlaylist,
makeSession,
makeUser,
page,
wire,
} from "../fixtures.ts";
import { renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
@@ -12,12 +18,29 @@ const annSession = makeSession({ id: "ann", username: "ann" });
function open(session: ReturnType<typeof makeSession> | null = null) {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/playlists", () =>
page([makePlaylist({ id: "p1", title: "Her mix", userId: "ann" })], true));
route("GET /api/users/:u/followed-playlists", () =>
page([makePlaylist({ id: "f1", title: "Someone's mix", userId: "bob", ownerUsername: "bob" })]));
route(
"GET /api/users/:u/playlists",
() =>
page([makePlaylist({ id: "p1", title: "Her mix", userId: "ann" })], true),
);
route(
"GET /api/users/:u/followed-playlists",
() =>
page([
makePlaylist({
id: "f1",
title: "Someone's mix",
userId: "bob",
ownerUsername: "bob",
}),
]),
);
const user = userEvent.setup();
renderApp(<UserPlaylists />, { route: "/users/ann/playlists", path: "/users/:username/playlists", session });
renderApp(<UserPlaylists />, {
route: "/users/ann/playlists",
path: "/users/:username/playlists",
session,
});
return { user, socket: FakeWebSocket.last };
}
@@ -25,15 +48,27 @@ describe("UserPlaylists", () => {
test("created and followed playlists", async () => {
open();
expect(await screen.findByText("Her mix")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Created (1+)" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Followed (1)" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Created (1+)" }))
.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Followed (1)" }))
.toBeInTheDocument();
expect(screen.getByText("Someone's mix")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Delete playlist" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Delete playlist" })).not
.toBeInTheDocument();
});
test("the owner creates one and it goes on top", async () => {
route("POST /api/playlists", (req) =>
wire(makePlaylist({ id: "new", title: (req.body as { title: string }).title, userId: "ann" })));
route(
"POST /api/playlists",
(req) =>
wire(
makePlaylist({
id: "new",
title: (req.body as { title: string }).title,
userId: "ann",
}),
),
);
const { user } = open(annSession);
await screen.findByText("Her mix");
await user.click(screen.getByRole("button", { name: /New playlist/ }));
@@ -42,16 +77,26 @@ describe("UserPlaylists", () => {
await user.click(within(dialog).getByRole("button", { name: "Private" }));
await user.click(within(dialog).getByRole("button", { name: "Create" }));
expect(await screen.findByText("Road trip")).toBeInTheDocument();
expect(calls("POST /api/playlists")[0].body).toEqual({ title: "Road trip", isPublic: false });
expect(calls("POST /api/playlists")[0].body).toEqual({
title: "Road trip",
isPublic: false,
});
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
test("the owner deletes one, after confirming", async () => {
route("DELETE /api/playlists/:id", () => new Response(null, { status: 204 }));
route(
"DELETE /api/playlists/:id",
() => new Response(null, { status: 204 }),
);
const { user } = open(annSession);
await screen.findByText("Her mix");
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
await user.click(
within(screen.getByRole("alertdialog")).getByRole("button", {
name: "Delete playlist",
}),
);
await screen.findByText("No playlists yet.");
});
@@ -60,7 +105,11 @@ describe("UserPlaylists", () => {
const { user } = open(annSession);
await screen.findByText("Her mix");
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
await user.click(
within(screen.getByRole("alertdialog")).getByRole("button", {
name: "Delete playlist",
}),
);
expect(await screen.findByRole("alert")).toHaveTextContent("Forbidden");
expect(screen.getByText("Her mix")).toBeInTheDocument();
});
@@ -69,9 +118,24 @@ describe("UserPlaylists", () => {
const { socket } = open();
await screen.findByText("Her mix");
socket.welcome();
socket.receive({ type: "playlist_created", playlist: wire(makePlaylist({ id: "p2", title: "Brand new mix", userId: "ann" })) });
socket.receive({
type: "playlist_created",
playlist: wire(
makePlaylist({ id: "p2", title: "Brand new mix", userId: "ann" }),
),
});
expect(screen.getByText("Brand new mix")).toBeInTheDocument();
socket.receive({ type: "playlist_updated", playlist: wire(makePlaylist({ id: "p1", title: "Her mix", userId: "ann", isPublic: false })) });
socket.receive({
type: "playlist_updated",
playlist: wire(
makePlaylist({
id: "p1",
title: "Her mix",
userId: "ann",
isPublic: false,
}),
),
});
expect(screen.queryByText("Her mix")).not.toBeInTheDocument();
});
});

View File

@@ -17,8 +17,17 @@ import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
const ann = makeUser({ id: "ann", username: "ann", description: "I **dump** things", invitedByUsername: "root" });
const annSession = makeSession({ id: "ann", username: "ann", email: "ann@x.test" });
const ann = makeUser({
id: "ann",
username: "ann",
description: "I **dump** things",
invitedByUsername: "root",
});
const annSession = makeSession({
id: "ann",
username: "ann",
email: "ann@x.test",
});
function open(
{
@@ -33,7 +42,10 @@ function open(
route("GET /api/users/:u", () => wire(user));
route("GET /api/users/:u/dumps", () => page(dumps));
route("GET /api/users/:u/votes", () => page(votes));
route("GET /api/users/:u/playlists", () => page([makePlaylist({ title: "Her mix", userId: "ann" })]));
route(
"GET /api/users/:u/playlists",
() => page([makePlaylist({ title: "Her mix", userId: "ann" })]),
);
const u = userEvent.setup();
renderApp(<UserPublicProfile />, {
route: url,
@@ -47,14 +59,20 @@ function open(
describe("UserPublicProfile", () => {
test("header, description, and the dumps tab", async () => {
open();
expect(await screen.findByRole("heading", { name: "ann" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "@root" })).toHaveAttribute("href", "/users/root");
expect(await screen.findByRole("heading", { name: "ann" }))
.toBeInTheDocument();
expect(screen.getByRole("link", { name: "@root" })).toHaveAttribute(
"href",
"/users/root",
);
expect(screen.getByText("dump").tagName).toBe("STRONG");
expect(screen.getByText("Her dump")).toBeInTheDocument();
expect(screen.getByText("She liked this")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Dumps (1)" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Dumps (1)" }))
.toBeInTheDocument();
await waitFor(() => expect(document.title).toMatch(/^ann · /));
expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Settings" })).not
.toBeInTheDocument();
});
test("an unknown user", async () => {
@@ -62,8 +80,13 @@ describe("UserPublicProfile", () => {
route("GET /api/users/:u/dumps", () => page([]));
route("GET /api/users/:u/votes", () => page([]));
route("GET /api/users/:u/playlists", () => page([]));
renderApp(<UserPublicProfile />, { route: "/users/nobody", path: "/users/:username/*" });
expect(await screen.findByRole("alert")).toHaveTextContent("User not found");
renderApp(<UserPublicProfile />, {
route: "/users/nobody",
path: "/users/:username/*",
});
expect(await screen.findByRole("alert")).toHaveTextContent(
"User not found",
);
});
test("a role chip for moderators, and none for users", async () => {
@@ -80,40 +103,61 @@ describe("UserPublicProfile", () => {
});
test("followed: people and playlists, with more people on demand", async () => {
route("GET /api/users/:u/followed-users", (req) =>
req.url.searchParams.get("page") === "1"
? page([makeUser({ username: "bob" })], true)
: page([makeUser({ username: "cy" })]));
route("GET /api/users/:u/followed-playlists", () => page([makePlaylist({ title: "Their mix" })]));
route(
"GET /api/users/:u/followed-users",
(req) =>
req.url.searchParams.get("page") === "1"
? page([makeUser({ username: "bob" })], true)
: page([makeUser({ username: "cy" })]),
);
route(
"GET /api/users/:u/followed-playlists",
() => page([makePlaylist({ title: "Their mix" })]),
);
const { user } = open({ url: "/users/ann/~/followed" });
expect(await screen.findByRole("link", { name: /@bob/ })).toHaveAttribute("href", "/users/bob");
expect(await screen.findByRole("link", { name: /@bob/ })).toHaveAttribute(
"href",
"/users/bob",
);
expect(screen.getByText("Their mix")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Load more" }));
expect(await screen.findByRole("link", { name: /@cy/ })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
expect(await screen.findByRole("link", { name: /@cy/ }))
.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Load more" })).not
.toBeInTheDocument();
});
test("invitees as a tree", async () => {
route("GET /api/users/:u/invitees", () => wire([
{ id: "b", username: "bob", invitedById: "ann", createdAt: new Date() },
{ id: "c", username: "cy", invitedById: "b", createdAt: new Date() },
]));
route("GET /api/users/:u/invitees", () =>
wire([
{ id: "b", username: "bob", invitedById: "ann", createdAt: new Date() },
{ id: "c", username: "cy", invitedById: "b", createdAt: new Date() },
]));
open({ url: "/users/ann/~/invitees" });
const bob = (await screen.findByRole("link", { name: /@bob/ })).closest("li")!;
const bob = (await screen.findByRole("link", { name: /@bob/ })).closest(
"li",
)!;
expect(within(bob).getByRole("link", { name: /@cy/ })).toBeInTheDocument();
});
test("visitors can follow; signed in, a settings tab is only on your own profile", async () => {
open({ session: makeSession({ id: "bob" }) });
expect(await screen.findByRole("button", { name: "Follow ann" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Follow ann" }))
.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Settings" })).not
.toBeInTheDocument();
});
describe("your own profile", () => {
test("edit the description; clearing it really clears it", async () => {
route("PATCH /api/users/me", (req) => wire({ ...ann, ...(req.body as object) }));
route(
"PATCH /api/users/me",
(req) => wire({ ...ann, ...(req.body as object) }),
);
const { user } = open({ session: annSession });
const view = (await screen.findByText("dump")).closest("[title='Edit description']") as HTMLElement;
const view = (await screen.findByText("dump")).closest(
"[title='Edit description']",
) as HTMLElement;
view.focus();
await user.keyboard("{Enter}");
const box = screen.getByPlaceholderText("Who am I?");
@@ -124,35 +168,56 @@ describe("UserPublicProfile", () => {
});
test("edit the email", async () => {
route("PATCH /api/users/me", (req) => wire({ ...ann, ...(req.body as object) }));
route(
"PATCH /api/users/me",
(req) => wire({ ...ann, ...(req.body as object) }),
);
const { user } = open({ session: annSession });
await user.click(await screen.findByRole("button", { name: /ann@x\.test/ }));
await user.click(
await screen.findByRole("button", { name: /ann@x\.test/ }),
);
const input = screen.getByDisplayValue("ann@x.test");
await user.clear(input);
await user.type(input, "new@x.test{Enter}");
expect(await screen.findByRole("button", { name: /new@x\.test/ })).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email).toBe("new@x.test");
expect(await screen.findByRole("button", { name: /new@x\.test/ }))
.toBeInTheDocument();
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email)
.toBe("new@x.test");
});
test("a refused email says why", async () => {
route("PATCH /api/users/me", () => fail("Email already in use", 409));
const { user } = open({ session: annSession });
await user.click(await screen.findByRole("button", { name: /ann@x\.test/ }));
await user.click(
await screen.findByRole("button", { name: /ann@x\.test/ }),
);
await user.type(screen.getByDisplayValue("ann@x.test"), "x{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent("Email already in use");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Email already in use",
);
});
test("upload an avatar", async () => {
route("POST /api/avatars/me", () => wire({ ...ann, avatarMime: "image/png", updatedAt: new Date(7000) }));
route(
"POST /api/avatars/me",
() =>
wire({ ...ann, avatarMime: "image/png", updatedAt: new Date(7000) }),
);
const { user } = open({ session: annSession });
await screen.findByRole("heading", { name: "ann" });
const input = document.querySelector<HTMLInputElement>(".avatar-change-overlay input")!;
await user.upload(input, new File([new Uint8Array(3)], "me.png", { type: "image/png" }));
const input = document.querySelector<HTMLInputElement>(
".avatar-change-overlay input",
)!;
await user.upload(
input,
new File([new Uint8Array(3)], "me.png", { type: "image/png" }),
);
await waitFor(() =>
expect(document.querySelector(".profile-avatar-wrapper img")).toHaveAttribute(
"src",
"http://localhost:8000/api/avatars/ann?v=7000",
)
expect(document.querySelector(".profile-avatar-wrapper img"))
.toHaveAttribute(
"src",
"http://localhost:8000/api/avatars/ann?v=7000",
)
);
});
@@ -160,21 +225,33 @@ describe("UserPublicProfile", () => {
route("POST /api/avatars/me", () => fail("Not an image", 400));
const { user } = open({ session: annSession });
await screen.findByRole("heading", { name: "ann" });
const input = document.querySelector<HTMLInputElement>(".avatar-change-overlay input")!;
await user.upload(input, new File([new Uint8Array(3)], "me.png", { type: "image/png" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Not an image");
const input = document.querySelector<HTMLInputElement>(
".avatar-change-overlay input",
)!;
await user.upload(
input,
new File([new Uint8Array(3)], "me.png", { type: "image/png" }),
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Not an image",
);
});
test("invite someone: a link to copy", async () => {
route("POST /api/invites", () => ({ token: "tok123" }));
const { user } = open({ session: annSession });
await user.click(await screen.findByRole("button", { name: "+ Invite someone" }));
await user.click(
await screen.findByRole("button", { name: "+ Invite someone" }),
);
const link = await screen.findByRole("textbox", { name: "Invite link" });
expect(link).toHaveValue(`${location.origin}/register?token=tok123`);
await user.click(screen.getByRole("button", { name: "Copy" }));
// user-event stands in for the clipboard.
expect(await navigator.clipboard.readText()).toBe(`${location.origin}/register?token=tok123`);
expect(screen.getByRole("button", { name: "Copied!" })).toBeInTheDocument();
expect(await navigator.clipboard.readText()).toBe(
`${location.origin}/register?token=tok123`,
);
expect(screen.getByRole("button", { name: "Copied!" }))
.toBeInTheDocument();
});
test("your upvotes show before the live connection welcomes you", async () => {
@@ -189,19 +266,34 @@ describe("UserPublicProfile", () => {
const { socket } = open({ session: annSession });
await screen.findByText("She liked this");
socket.welcome({ myVotes: ["v1"] });
socket.receive({ type: "vote_ack", dumpId: "v1", action: "remove", voteCount: 0 });
expect(screen.getByText("She liked this").closest(".dump-card")).toHaveClass("dump-card--fading");
socket.receive({
type: "vote_ack",
dumpId: "v1",
action: "remove",
voteCount: 0,
});
expect(screen.getByText("She liked this").closest(".dump-card"))
.toHaveClass("dump-card--fading");
} finally {
vi.useRealTimers();
}
});
test("settings: theme, colour scheme, default tab and sounds", async () => {
const { user } = open({ session: annSession, url: "/users/ann/~/settings" });
await user.click(await screen.findByRole("button", { name: "Brutalist" }));
expect(document.documentElement.getAttribute("data-style")).toBe("brutalist");
const { user } = open({
session: annSession,
url: "/users/ann/~/settings",
});
await user.click(
await screen.findByRole("button", { name: "Brutalist" }),
);
expect(document.documentElement.getAttribute("data-style")).toBe(
"brutalist",
);
await user.click(screen.getByRole("button", { name: "Dark" }));
expect(document.documentElement.getAttribute("data-color-scheme")).toBe("dark");
expect(document.documentElement.getAttribute("data-color-scheme")).toBe(
"dark",
);
await user.click(screen.getByRole("button", { name: "New" }));
expect(localStorage.getItem("default-feed-tab")).toBe("new");
await user.click(screen.getByRole("button", { name: "Off" }));
@@ -211,19 +303,45 @@ describe("UserPublicProfile", () => {
});
test("change password keeps you signed in with the fresh tokens", async () => {
route("POST /api/users/me/change-password", () => ({ token: "fresh-token", mediaToken: "fresh-media" }));
const { user } = open({ session: annSession, url: "/users/ann/~/settings" });
await user.click(await screen.findByRole("button", { name: "Change password…" }));
route(
"POST /api/users/me/change-password",
() => ({ token: "fresh-token", mediaToken: "fresh-media" }),
);
const { user } = open({
session: annSession,
url: "/users/ann/~/settings",
});
await user.click(
await screen.findByRole("button", { name: "Change password…" }),
);
const dialog = screen.getByRole("dialog", { name: "Change password" });
await user.type(within(dialog).getByLabelText("Current password"), "oldpassword");
await user.type(within(dialog).getByLabelText("New password"), "newpassword");
await user.type(within(dialog).getByLabelText("Confirm new password"), "different");
expect(await within(dialog).findByText("Passwords do not match")).toBeInTheDocument();
await user.type(
within(dialog).getByLabelText("Current password"),
"oldpassword",
);
await user.type(
within(dialog).getByLabelText("New password"),
"newpassword",
);
await user.type(
within(dialog).getByLabelText("Confirm new password"),
"different",
);
expect(await within(dialog).findByText("Passwords do not match"))
.toBeInTheDocument();
await user.clear(within(dialog).getByLabelText("Confirm new password"));
await user.type(within(dialog).getByLabelText("Confirm new password"), "newpassword");
await user.click(within(dialog).getByRole("button", { name: "Change password" }));
expect(await within(dialog).findByText("Password changed successfully.")).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe("fresh-token");
await user.type(
within(dialog).getByLabelText("Confirm new password"),
"newpassword",
);
await user.click(
within(dialog).getByRole("button", { name: "Change password" }),
);
expect(await within(dialog).findByText("Password changed successfully."))
.toBeInTheDocument();
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe(
"fresh-token",
);
expect(calls("POST /api/users/me/change-password")[0].body).toEqual({
currentPassword: "oldpassword",
newPassword: "newpassword",
@@ -231,14 +349,33 @@ describe("UserPublicProfile", () => {
});
test("a wrong current password is reported", async () => {
route("POST /api/users/me/change-password", () => fail("Current password is incorrect", 400));
const { user } = open({ session: annSession, url: "/users/ann/~/settings" });
await user.click(await screen.findByRole("button", { name: "Change password…" }));
route(
"POST /api/users/me/change-password",
() => fail("Current password is incorrect", 400),
);
const { user } = open({
session: annSession,
url: "/users/ann/~/settings",
});
await user.click(
await screen.findByRole("button", { name: "Change password…" }),
);
const dialog = screen.getByRole("dialog", { name: "Change password" });
await user.type(within(dialog).getByLabelText("Current password"), "wrong");
await user.type(within(dialog).getByLabelText("New password"), "newpassword");
await user.type(within(dialog).getByLabelText("Confirm new password"), "newpassword{Enter}");
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Current password is incorrect");
await user.type(
within(dialog).getByLabelText("Current password"),
"wrong",
);
await user.type(
within(dialog).getByLabelText("New password"),
"newpassword",
);
await user.type(
within(dialog).getByLabelText("Confirm new password"),
"newpassword{Enter}",
);
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
"Current password is incorrect",
);
});
test("log out", async () => {
@@ -250,25 +387,53 @@ describe("UserPublicProfile", () => {
describe("manage", () => {
test("an admin can change someone's role", async () => {
route("PATCH /api/users/:id/role", (req) => wire({ ...ann, role: (req.body as { role: string }).role }));
const { user } = open({ session: makeSession({ id: "admin", role: "admin" }), url: "/users/ann/~/manage" });
await user.click(await screen.findByRole("button", { name: "Moderator" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Moderator" })).toHaveClass("active"));
expect(calls("PATCH /api/users/:id/role")[0].path).toBe("/api/users/ann/role");
expect(document.querySelector(".profile-role-chip")).toHaveTextContent("Moderator");
route(
"PATCH /api/users/:id/role",
(req) => wire({ ...ann, role: (req.body as { role: string }).role }),
);
const { user } = open({
session: makeSession({ id: "admin", role: "admin" }),
url: "/users/ann/~/manage",
});
await user.click(
await screen.findByRole("button", { name: "Moderator" }),
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Moderator" })).toHaveClass(
"active",
)
);
expect(calls("PATCH /api/users/:id/role")[0].path).toBe(
"/api/users/ann/role",
);
expect(document.querySelector(".profile-role-chip")).toHaveTextContent(
"Moderator",
);
});
test("a refused role change says why", async () => {
route("PATCH /api/users/:id/role", () => fail("Cannot demote the last admin", 400));
const { user } = open({ session: makeSession({ id: "admin", role: "admin" }), url: "/users/ann/~/manage" });
route(
"PATCH /api/users/:id/role",
() => fail("Cannot demote the last admin", 400),
);
const { user } = open({
session: makeSession({ id: "admin", role: "admin" }),
url: "/users/ann/~/manage",
});
await user.click(await screen.findByRole("button", { name: "Admin" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Cannot demote the last admin");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Cannot demote the last admin",
);
});
test("non-admins get no manage tab", async () => {
open({ session: makeSession({ id: "mod", role: "moderator" }), url: "/users/ann/~/manage" });
open({
session: makeSession({ id: "mod", role: "moderator" }),
url: "/users/ann/~/manage",
});
await screen.findByRole("heading", { name: "ann" });
expect(screen.queryByRole("button", { name: "Manage" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Manage" })).not
.toBeInTheDocument();
expect(screen.getByText("Her dump")).toBeInTheDocument();
});
@@ -286,31 +451,66 @@ describe("UserPublicProfile", () => {
return null;
});
const admin = makeSession({ id: "ann", username: "ann", role: "admin" });
const { user } = open({ session: admin, url: "/users/ann/~/manage", user: { ...ann, role: "admin" } });
await user.click(await screen.findByRole("button", { name: "Add category" }));
const { user } = open({
session: admin,
url: "/users/ann/~/manage",
user: { ...ann, role: "admin" },
});
await user.click(
await screen.findByRole("button", { name: "Add category" }),
);
await user.type(screen.getByRole("textbox", { name: "Name" }), "Cinema");
await user.type(screen.getByRole("textbox", { name: "URL slug" }), "cinema");
await user.type(
screen.getByRole("textbox", { name: "URL slug" }),
"cinema",
);
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("button", { name: "Cinema" })).toBeInTheDocument();
expect(calls("POST /api/categories")[0].body).toEqual({ name: "Cinema", slug: "cinema" });
expect(await screen.findByRole("button", { name: "Cinema" }))
.toBeInTheDocument();
expect(calls("POST /api/categories")[0].body).toEqual({
name: "Cinema",
slug: "cinema",
});
await user.click(screen.getByRole("button", { name: "Music" }));
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete category" }));
await waitFor(() => expect(screen.queryByRole("button", { name: "Music" })).not.toBeInTheDocument());
await user.click(
within(screen.getByRole("alertdialog")).getByRole("button", {
name: "Delete category",
}),
);
await waitFor(() =>
expect(screen.queryByRole("button", { name: "Music" })).not
.toBeInTheDocument()
);
});
test("a refused category is reported, and Escape closes the editor", async () => {
route("GET /api/categories", () => []);
route("POST /api/categories", () => fail("Slug already taken", 409));
const admin = makeSession({ id: "ann", username: "ann", role: "admin" });
const { user } = open({ session: admin, url: "/users/ann/~/manage", user: { ...ann, role: "admin" } });
await user.click(await screen.findByRole("button", { name: "Add category" }));
const { user } = open({
session: admin,
url: "/users/ann/~/manage",
user: { ...ann, role: "admin" },
});
await user.click(
await screen.findByRole("button", { name: "Add category" }),
);
await user.type(screen.getByRole("textbox", { name: "Name" }), "X");
await user.type(screen.getByRole("textbox", { name: "URL slug" }), "x{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent("Slug already taken");
await user.type(screen.getByRole("textbox", { name: "Name" }), "{Escape}");
expect(screen.getByRole("button", { name: "Add category" })).toBeInTheDocument();
await user.type(
screen.getByRole("textbox", { name: "URL slug" }),
"x{Enter}",
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Slug already taken",
);
await user.type(
screen.getByRole("textbox", { name: "Name" }),
"{Escape}",
);
expect(screen.getByRole("button", { name: "Add category" }))
.toBeInTheDocument();
});
});
});

View File

@@ -13,19 +13,30 @@ const annSession = makeSession({ id: "ann", username: "ann" });
function openDumps(session: ReturnType<typeof makeSession> | null = null) {
const user = userEvent.setup();
renderApp(<UserDumps />, { route: "/users/ann/dumps", path: "/users/:username/dumps", session });
renderApp(<UserDumps />, {
route: "/users/ann/dumps",
path: "/users/:username/dumps",
session,
});
return { user, socket: FakeWebSocket.last };
}
describe("UserDumps", () => {
test("lists a user's dumps under a header back to the profile", async () => {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/dumps", () => page([makeDump({ title: "Hers", userId: "ann" })]));
route(
"GET /api/users/:u/dumps",
() => page([makeDump({ title: "Hers", userId: "ann" })]),
);
openDumps();
expect(await screen.findByText("Hers")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Dumps" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "← ann" })).toHaveAttribute("href", "/users/ann");
expect(screen.queryByRole("button", { name: /New dump/ })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "← ann" })).toHaveAttribute(
"href",
"/users/ann",
);
expect(screen.queryByRole("button", { name: /New dump/ })).not
.toBeInTheDocument();
});
test("an unknown user", async () => {
@@ -33,7 +44,8 @@ describe("UserDumps", () => {
route("GET /api/users/:u/dumps", () => page([]));
openDumps();
expect(await screen.findByRole("alert")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "← Back to profile" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "← Back to profile" }))
.toBeInTheDocument();
});
test("on your own page: a new-dump button, and your dumps going private stay", async () => {
@@ -43,43 +55,64 @@ describe("UserDumps", () => {
const { user, socket } = openDumps(annSession);
await screen.findByText("Mine");
socket.welcome();
socket.receive({ type: "dump_updated", dump: wire({ ...mine, isPrivate: true }) });
socket.receive({
type: "dump_updated",
dump: wire({ ...mine, isPrivate: true }),
});
expect(screen.getByText("Mine")).toBeInTheDocument();
route("GET /api/preview", () => ({ reached: false, richContent: null }));
await user.click(screen.getByRole("button", { name: /New dump/ }));
expect(screen.getByRole("dialog", { name: "New dump" })).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: "New dump" }))
.toBeInTheDocument();
});
test("a visitor loses a dump that goes private, and gets it back where it was", async () => {
route("GET /api/users/:u", () => wire(ann));
const list = ["x", "y", "z"].map((id) => makeDump({ id, title: `T-${id}`, userId: "ann" }));
const list = ["x", "y", "z"].map((id) =>
makeDump({ id, title: `T-${id}`, userId: "ann" })
);
route("GET /api/users/:u/dumps", () => page(list));
const { socket } = openDumps();
await screen.findByText("T-z");
socket.welcome();
socket.receive({ type: "dump_updated", dump: wire({ ...list[1], isPrivate: true }) });
socket.receive({
type: "dump_updated",
dump: wire({ ...list[1], isPrivate: true }),
});
expect(screen.queryByText("T-y")).not.toBeInTheDocument();
socket.receive({ type: "dump_updated", dump: wire({ ...list[1], isPrivate: false }) });
const titles = Array.from(document.querySelectorAll(".dump-card-title")).map((a) => a.textContent);
socket.receive({
type: "dump_updated",
dump: wire({ ...list[1], isPrivate: false }),
});
const titles = Array.from(document.querySelectorAll(".dump-card-title"))
.map((a) => a.textContent);
expect(titles).toEqual(["T-x", "T-y", "T-z"]);
});
});
describe("UserUpvoted", () => {
const voted = () => [makeDump({ id: "v1", title: "Liked one" }), makeDump({ id: "v2", title: "Liked two" })];
const voted = () => [
makeDump({ id: "v1", title: "Liked one" }),
makeDump({ id: "v2", title: "Liked two" }),
];
function openVotes(session: ReturnType<typeof makeSession> | null = null) {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/votes", () => page(voted()));
const user = userEvent.setup();
renderApp(<UserUpvoted />, { route: "/users/ann/upvoted", path: "/users/:username/upvoted", session });
renderApp(<UserUpvoted />, {
route: "/users/ann/upvoted",
path: "/users/:username/upvoted",
session,
});
return { user, socket: FakeWebSocket.last };
}
test("lists what they upvoted", async () => {
openVotes();
expect(await screen.findByText("Liked one")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Upvoted" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Upvoted" }))
.toBeInTheDocument();
});
test("your own list shows even before the live connection says what you voted", async () => {
@@ -95,8 +128,16 @@ describe("UserUpvoted", () => {
const { socket } = openVotes(annSession);
await screen.findByText("Liked one");
socket.welcome({ myVotes: ["v1", "v2"] });
socket.receive({ type: "votes_update", dumpId: "v1", voteCount: 0, voterId: "ann", action: "remove" });
expect(screen.getByText("Liked one").closest(".dump-card")).toHaveClass("dump-card--fading");
socket.receive({
type: "votes_update",
dumpId: "v1",
voteCount: 0,
voterId: "ann",
action: "remove",
});
expect(screen.getByText("Liked one").closest(".dump-card")).toHaveClass(
"dump-card--fading",
);
act(() => vi.advanceTimersByTime(2_400));
expect(screen.queryByText("Liked one")).not.toBeInTheDocument();
} finally {
@@ -105,13 +146,24 @@ describe("UserUpvoted", () => {
});
test("their new upvote appears at the top", async () => {
route("GET /api/dumps/:id", () => wire(makeDump({ id: "v3", title: "Fresh like" })));
route(
"GET /api/dumps/:id",
() => wire(makeDump({ id: "v3", title: "Fresh like" })),
);
const { socket } = openVotes();
await screen.findByText("Liked one");
socket.welcome();
socket.receive({ type: "votes_update", dumpId: "v3", voteCount: 1, voterId: "ann", action: "cast" });
socket.receive({
type: "votes_update",
dumpId: "v3",
voteCount: 1,
voterId: "ann",
action: "cast",
});
await waitFor(() =>
expect(document.querySelector(".dump-card-title")).toHaveTextContent("Fresh like")
expect(document.querySelector(".dump-card-title")).toHaveTextContent(
"Fresh like",
)
);
});
});

View File

@@ -18,7 +18,11 @@ const home = <Route path="/" element={<p>home page</p>} />;
describe("UserLogin", () => {
function setup() {
const user = userEvent.setup();
renderWithContexts(<UserLogin />, { route: "/login", path: "/login", extraRoutes: home });
renderWithContexts(<UserLogin />, {
route: "/login",
path: "/login",
extraRoutes: home,
});
return user;
}
@@ -30,16 +34,27 @@ describe("UserLogin", () => {
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "hunter22{Enter}");
await screen.findByText("home page");
expect(calls("POST /api/users/login")[0].body).toEqual({ username: "ann", password: "hunter22" });
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.username).toBe("ann");
expect(calls("POST /api/users/login")[0].body).toEqual({
username: "ann",
password: "hunter22",
});
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.username)
.toBe("ann");
});
test("both fields are required", async () => {
const user = setup();
// The header has a "Log in" button too; this is the form's.
await user.click(document.querySelector<HTMLButtonElement>(".auth-card button[type=submit]")!);
await user.click(
document.querySelector<HTMLButtonElement>(
".auth-card button[type=submit]",
)!,
);
expect(calls("POST /api/users/login")).toHaveLength(0);
expect(screen.getByPlaceholderText("Username")).toHaveAttribute("aria-invalid", "true");
expect(screen.getByPlaceholderText("Username")).toHaveAttribute(
"aria-invalid",
"true",
);
});
test("shows the server's refusal", async () => {
@@ -58,7 +73,9 @@ describe("UserLogin", () => {
const user = setup();
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent(/Could not connect/);
expect(await screen.findByRole("alert")).toHaveTextContent(
/Could not connect/,
);
});
test("the button says so while logging in", async () => {
@@ -67,8 +84,13 @@ describe("UserLogin", () => {
const user = setup();
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
expect(await screen.findByRole("button", { name: "Logging in…" })).toBeDisabled();
pending.resolve(new Response(JSON.stringify({ success: true, data: wire(makeSession()) })));
expect(await screen.findByRole("button", { name: "Logging in…" }))
.toBeDisabled();
pending.resolve(
new Response(
JSON.stringify({ success: true, data: wire(makeSession()) }),
),
);
await screen.findByText("home page");
});
@@ -76,18 +98,32 @@ describe("UserLogin", () => {
route("POST /api/users/request-password-reset", () => ({}));
const user = setup();
await user.click(screen.getByRole("button", { name: "Forgot password?" }));
await user.type(screen.getByPlaceholderText("Your email address"), " a@b.c {Enter}");
await user.type(
screen.getByPlaceholderText("Your email address"),
" a@b.c {Enter}",
);
await screen.findByText(/you'll receive a reset link/);
expect(calls("POST /api/users/request-password-reset")[0].body).toEqual({ email: "a@b.c" });
expect(calls("POST /api/users/request-password-reset")[0].body).toEqual({
email: "a@b.c",
});
});
test("a failed reset request is reported, not claimed as sent", async () => {
route("POST /api/users/request-password-reset", () => fail("Internal error", 500));
route(
"POST /api/users/request-password-reset",
() => fail("Internal error", 500),
);
const user = setup();
await user.click(screen.getByRole("button", { name: "Forgot password?" }));
await user.type(screen.getByPlaceholderText("Your email address"), "a@b.c{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent("Request failed");
expect(screen.queryByText(/you'll receive a reset link/)).not.toBeInTheDocument();
await user.type(
screen.getByPlaceholderText("Your email address"),
"a@b.c{Enter}",
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Request failed",
);
expect(screen.queryByText(/you'll receive a reset link/)).not
.toBeInTheDocument();
});
});
@@ -122,7 +158,10 @@ describe("UserRegister", () => {
const user = setup();
await user.type(await screen.findByPlaceholderText("Username"), "new_one");
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
await user.type(screen.getByPlaceholderText("Password (min. 8 characters)"), "longenough{Enter}");
await user.type(
screen.getByPlaceholderText("Password (min. 8 characters)"),
"longenough{Enter}",
);
await screen.findByText("home page");
expect(calls("POST /api/users/register")[0].body).toEqual({
username: "new_one",
@@ -136,22 +175,37 @@ describe("UserRegister", () => {
test("validates the username and password length", async () => {
route("GET /api/invites/:t", () => ({}));
const user = setup();
await user.type(await screen.findByPlaceholderText("Username"), "bad name!");
await user.type(
await screen.findByPlaceholderText("Username"),
"bad name!",
);
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
await user.type(screen.getByPlaceholderText("Password (min. 8 characters)"), "short{Enter}");
expect(await screen.findByText(/letters, numbers, or underscores/)).toBeInTheDocument();
await user.type(
screen.getByPlaceholderText("Password (min. 8 characters)"),
"short{Enter}",
);
expect(await screen.findByText(/letters, numbers, or underscores/))
.toBeInTheDocument();
expect(screen.getByText("At least 8 characters")).toBeInTheDocument();
expect(calls("POST /api/users/register")).toHaveLength(0);
});
test("shows a server refusal (username taken)", async () => {
route("GET /api/invites/:t", () => ({}));
route("POST /api/users/register", () => fail("Username already taken", 409));
route(
"POST /api/users/register",
() => fail("Username already taken", 409),
);
const user = setup();
await user.type(await screen.findByPlaceholderText("Username"), "taken");
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
await user.type(screen.getByPlaceholderText("Password (min. 8 characters)"), "longenough{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent("Username already taken");
await user.type(
screen.getByPlaceholderText("Password (min. 8 characters)"),
"longenough{Enter}",
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Username already taken",
);
});
});
@@ -168,15 +222,23 @@ describe("ResetPassword", () => {
test("no token: an invalid link", () => {
setup("");
expect(screen.getByRole("heading", { name: "Invalid link" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Back to login" })).toHaveAttribute("href", "/login");
expect(screen.getByRole("heading", { name: "Invalid link" }))
.toBeInTheDocument();
expect(screen.getByRole("link", { name: "Back to login" })).toHaveAttribute(
"href",
"/login",
);
});
test("the two passwords must match", async () => {
const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough");
await user.type(screen.getByPlaceholderText("Confirm new password"), "different");
expect(await screen.findByText("Passwords do not match")).toBeInTheDocument();
await user.type(
screen.getByPlaceholderText("Confirm new password"),
"different",
);
expect(await screen.findByText("Passwords do not match"))
.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Set new password" }));
expect(calls("POST /api/users/reset-password")).toHaveLength(0);
});
@@ -185,46 +247,75 @@ describe("ResetPassword", () => {
route("POST /api/users/reset-password", () => ({}));
const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough");
await user.type(screen.getByPlaceholderText("Confirm new password"), "longenough{Enter}");
await user.type(
screen.getByPlaceholderText("Confirm new password"),
"longenough{Enter}",
);
await screen.findByRole("heading", { name: "Password updated" });
expect(calls("POST /api/users/reset-password")[0].body).toEqual({ token: "rst", newPassword: "longenough" });
expect(calls("POST /api/users/reset-password")[0].body).toEqual({
token: "rst",
newPassword: "longenough",
});
await user.click(screen.getByRole("button", { name: "Go to login" }));
expect(screen.getByText("login page")).toBeInTheDocument();
});
test("an expired token is reported", async () => {
route("POST /api/users/reset-password", () => fail("Reset link expired", 400));
route(
"POST /api/users/reset-password",
() => fail("Reset link expired", 400),
);
const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough");
await user.type(screen.getByPlaceholderText("Confirm new password"), "longenough{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent("Reset link expired");
await user.type(
screen.getByPlaceholderText("Confirm new password"),
"longenough{Enter}",
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Reset link expired",
);
});
});
describe("route guards and 404", () => {
test("guest-only pages send members home", () => {
renderWithContexts(<RestrictedGuest><p>form</p></RestrictedGuest>, {
session: makeSession(),
route: "/login",
path: "/login",
extraRoutes: home,
});
renderWithContexts(
<RestrictedGuest>
<p>form</p>
</RestrictedGuest>,
{
session: makeSession(),
route: "/login",
path: "/login",
extraRoutes: home,
},
);
expect(screen.getByText("home page")).toBeInTheDocument();
});
test("member-only pages send guests to login", () => {
renderWithContexts(<RestrictedLoggedIn><p>secret</p></RestrictedLoggedIn>, {
route: "/notifications",
path: "/notifications",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
});
renderWithContexts(
<RestrictedLoggedIn>
<p>secret</p>
</RestrictedLoggedIn>,
{
route: "/notifications",
path: "/notifications",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
},
);
expect(screen.getByText("login page")).toBeInTheDocument();
});
test("each guard lets the right people through", () => {
renderWithContexts(<RestrictedLoggedIn><p>secret</p></RestrictedLoggedIn>, {
session: makeSession(),
});
renderWithContexts(
<RestrictedLoggedIn>
<p>secret</p>
</RestrictedLoggedIn>,
{
session: makeSession(),
},
);
expect(screen.getByText("secret")).toBeInTheDocument();
});
@@ -232,6 +323,9 @@ describe("route guards and 404", () => {
renderWithContexts(<NotFound />, { route: "/nope" });
expect(screen.getByRole("heading", { name: "404" })).toBeInTheDocument();
await waitFor(() => expect(document.title).toMatch(/^Page not found/));
expect(screen.getByRole("link", { name: "Go home" })).toHaveAttribute("href", "/");
expect(screen.getByRole("link", { name: "Go home" })).toHaveAttribute(
"href",
"/",
);
});
});

View File

@@ -7,16 +7,28 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router";
import type { AuthResponse, Category } from "../model.ts";
import { AuthContext } from "../contexts/AuthContext.ts";
import { AuthProvider } from "../contexts/AuthProvider.tsx";
import { WSContext, type WSContextValue } from "../contexts/WSContext.ts";
import {
defaultWSValue,
WSContext,
type WSContextValue,
} from "../contexts/WSContext.ts";
import { WSProvider } from "../contexts/WSProvider.tsx";
import { FollowContext, type FollowContextValue } from "../contexts/FollowContext.ts";
import {
defaultFollowValue,
FollowContext,
type FollowContextValue,
} from "../contexts/FollowContext.ts";
import { FollowProvider } from "../contexts/FollowProvider.tsx";
import {
CategoriesContext,
type CategoriesContextValue,
} from "../contexts/CategoriesContext.ts";
import { CategoriesProvider } from "../contexts/CategoriesProvider.tsx";
import { PlayerContext, type PlayerContextValue } from "../contexts/PlayerContext.ts";
import {
defaultPlayerValue,
PlayerContext,
type PlayerContextValue,
} from "../contexts/PlayerContext.ts";
import { PlayerProvider } from "../contexts/PlayerProvider.tsx";
import { ThemeProvider } from "../contexts/ThemeProvider.tsx";
import { ChatProvider } from "../contexts/ChatProvider.tsx";
@@ -27,7 +39,11 @@ import { storeSession } from "./fixtures.ts";
export function LocationProbe() {
const loc = useLocation();
return (
<div hidden data-testid="location" data-state={JSON.stringify(loc.state ?? null)}>
<div
hidden
data-testid="location"
data-state={JSON.stringify(loc.state ?? null)}
>
{loc.pathname + loc.search}
</div>
);
@@ -98,12 +114,6 @@ function categoriesValue(
};
}
/** A context's default value — what a consumer outside any provider gets.
* React keeps it on the context object; there is no public accessor. */
export function defaultOf<T>(context: React.Context<T>): T {
return (context as unknown as { _currentValue: T })._currentValue;
}
/**
* Renders `ui` with context values supplied directly — no network, no socket.
* For components whose inputs are the contexts they read.
@@ -113,19 +123,20 @@ export function renderWithContexts(
opts: StubOptions = {},
renderOptions?: RenderOptions,
) {
const defaultWS = defaultOf(WSContext);
const defaultFollows = defaultOf(FollowContext);
const defaultPlayer = defaultOf(PlayerContext);
// A wrapper rather than one tree, so `rerender(ui)` keeps the contexts.
const Wrapper = ({ children }: { children: ReactNode }) => (
<I18nProvider i18n={i18n}>
<StubAuth session={opts.session ?? null}>
<PlayerContext.Provider value={{ ...defaultPlayer, ...opts.player }}>
<WSContext.Provider value={{ ...defaultWS, ...opts.ws }}>
<PlayerContext.Provider
value={{ ...defaultPlayerValue, ...opts.player }}
>
<WSContext.Provider value={{ ...defaultWSValue, ...opts.ws }}>
<FollowContext.Provider
value={{ ...defaultFollows, isLoaded: true, ...opts.follows }}
value={{ ...defaultFollowValue, isLoaded: true, ...opts.follows }}
>
<CategoriesContext.Provider value={categoriesValue(opts.categories)}>
<CategoriesContext.Provider
value={categoriesValue(opts.categories)}
>
<Router {...opts}>{children}</Router>
</CategoriesContext.Provider>
</FollowContext.Provider>

View File

@@ -15,8 +15,28 @@ configure({ asyncUtilTimeout: 4000 });
i18n.load("en", messages);
i18n.activate("en");
class FakeIntersectionObserver {
function rectAt(top: number): DOMRectReadOnly {
return {
x: 0,
y: top,
top,
right: 0,
bottom: top,
left: 0,
width: 0,
height: 0,
toJSON() {
return this;
},
};
}
class FakeIntersectionObserver implements IntersectionObserver {
static instances: FakeIntersectionObserver[] = [];
readonly root = null;
readonly rootMargin = "0px";
readonly scrollMargin = "0px";
readonly thresholds = [0];
/**
* Whether observed elements are on screen. When set, a new observer reports
* its element as intersecting right after observe(), as a browser's does —
@@ -34,12 +54,35 @@ class FakeIntersectionObserver {
if (!FakeIntersectionObserver.inView) return;
setTimeout(() => {
if (!this.elements.has(el)) return;
this.callback(
[{ isIntersecting: true, target: el } as unknown as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
);
this.callback([this.entry(el, true, 0)], this);
});
}
/**
* Reports the observed elements in or out of view, `top` px down the
* viewport, as a browser would on a scroll.
*/
report(isIntersecting: boolean, top = 0) {
const targets = this.elements.size ? [...this.elements] : [document.body];
this.callback(
targets.map((el) => this.entry(el, isIntersecting, top)),
this,
);
}
private entry(
target: Element,
isIntersecting: boolean,
top: number,
): IntersectionObserverEntry {
return {
target,
isIntersecting,
intersectionRatio: isIntersecting ? 1 : 0,
boundingClientRect: rectAt(top),
intersectionRect: rectAt(top),
rootBounds: null,
time: performance.now(),
};
}
unobserve(el: Element) {
this.elements.delete(el);
}
@@ -77,9 +120,34 @@ if (!globalThis.matchMedia) {
}
Element.prototype.scrollIntoView ??= function () {};
Element.prototype.animate ??= function () {
return { cancel() {}, finished: Promise.resolve() } as unknown as Animation;
};
// An animation that has already finished: the app only cancels them or waits
// for them to end.
class FakeAnimation extends EventTarget implements Animation {
currentTime: CSSNumberish | null = null;
effect: AnimationEffect | null = null;
readonly finished: Promise<Animation> = Promise.resolve(this);
readonly ready: Promise<Animation> = Promise.resolve(this);
id = "";
oncancel = null;
onfinish = null;
onremove = null;
readonly overallProgress = 1;
readonly pending = false;
readonly playState = "finished";
playbackRate = 1;
readonly replaceState = "active";
startTime: CSSNumberish | null = null;
timeline: AnimationTimeline | null = null;
cancel() {}
commitStyles() {}
finish() {}
pause() {}
persist() {}
play() {}
reverse() {}
updatePlaybackRate() {}
}
Element.prototype.animate ??= () => new FakeAnimation();
Element.prototype.getAnimations ??= () => [];
globalThis.scrollTo = () => {};
HTMLMediaElement.prototype.play = function () {
@@ -88,7 +156,9 @@ HTMLMediaElement.prototype.play = function () {
HTMLMediaElement.prototype.pause = function () {};
HTMLMediaElement.prototype.load = function () {};
// The emoji picker probes emoji support on a canvas; jsdom has none.
HTMLCanvasElement.prototype.getContext = (() => null) as never;
Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
value: () => null,
});
// jsdom has no layout; execCommand("insertText") is how the editor types.
document.execCommand ??= () => false;

View File

@@ -29,7 +29,9 @@ function tile(shapes: JournalShape[]): boolean[][] {
for (let dr = 0; dr < h; dr++) {
for (let dc = 0; dc < w; dc++) {
if (c + dc >= 4) throw new Error(`${shape} overflows at ${r},${c}`);
if (row(r + dr)[c + dc]) throw new Error(`overlap at ${r + dr},${c + dc}`);
if (row(r + dr)[c + dc]) {
throw new Error(`overlap at ${r + dr},${c + dc}`);
}
row(r + dr)[c + dc] = true;
}
}
@@ -126,12 +128,14 @@ describe("composeJournal", () => {
}
});
for (const [name, art] of [
["all art", () => true],
["no art", () => false],
["mixed", (i: number) => i % 2 === 0],
["sparse", (i: number) => i % 7 === 0],
] as const) {
for (
const [name, art] of [
["all art", () => true],
["no art", () => false],
["mixed", (i: number) => i % 2 === 0],
["sparse", (i: number) => i % 7 === 0],
] as const
) {
test(`tiles without gaps or overlap (${name})`, () => {
for (const n of [1, 2, 3, 7, 13, 25, 60]) {
const blocks = composeJournal(feed(n, art));
@@ -139,7 +143,9 @@ describe("composeJournal", () => {
for (const block of blocks) {
const grid = tile(block.entries.map((e) => e.shape));
// Every row but the last closes flush.
grid.slice(0, -1).forEach((r) => expect(r).toEqual([true, true, true, true]));
grid.slice(0, -1).forEach((r) =>
expect(r).toEqual([true, true, true, true])
);
}
}
});

View File

@@ -36,7 +36,9 @@ describe("can", () => {
describe("proxyImageUrl", () => {
test("http images go through the proxy; https ones don't", () => {
expect(proxyImageUrl("http://img.test/a.png")).toBe(
`${API_URL}/api/proxy-image?url=${encodeURIComponent("http://img.test/a.png")}`,
`${API_URL}/api/proxy-image?url=${
encodeURIComponent("http://img.test/a.png")
}`,
);
expect(proxyImageUrl("https://img.test/a.png")).toBe(
"https://img.test/a.png",
@@ -52,7 +54,9 @@ describe("proxyImageUrl", () => {
expect(proxyImageUrl("http://localhost:8000/a.png")).toBe(
"http://localhost:8000/a.png",
);
expect(proxyImageUrl("http://127.0.0.1/a.png")).toBe("http://127.0.0.1/a.png");
expect(proxyImageUrl("http://127.0.0.1/a.png")).toBe(
"http://127.0.0.1/a.png",
);
expect(proxyImageUrl("data:image/png;base64,AA")).toBe(
"data:image/png;base64,AA",
);

View File

@@ -25,9 +25,32 @@ const album = {
artworkUrl: "https://f4.bcbits.com/a.jpg",
resolvedAt: 1000,
tracks: [
{ index: 0, trackNum: 1, title: "One", duration: 100, streamUrl: "https://s/1", streamable: true, capped: false },
{ index: 1, trackNum: 2, title: "Two", streamUrl: null, streamable: false, capped: true },
{ index: 2, trackNum: 3, title: "Three", duration: 50, streamUrl: "https://s/3", streamable: true, capped: false },
{
index: 0,
trackNum: 1,
title: "One",
duration: 100,
streamUrl: "https://s/1",
streamable: true,
capped: false,
},
{
index: 1,
trackNum: 2,
title: "Two",
streamUrl: null,
streamable: false,
capped: true,
},
{
index: 2,
trackNum: 3,
title: "Three",
duration: 50,
streamUrl: "https://s/3",
streamable: true,
capped: false,
},
],
};
@@ -68,7 +91,10 @@ describe("resolveBandcampQueue", () => {
});
test("falls back to the given artwork", async () => {
route("GET /api/bandcamp/tracks", () => ({ ...album, artworkUrl: undefined }));
route(
"GET /api/bandcamp/tracks",
() => ({ ...album, artworkUrl: undefined }),
);
const [item] = await resolveBandcampQueue(album.sourceUrl, {
fallbackArtworkUrl: "https://mine/art.png",
});
@@ -103,7 +129,10 @@ describe("stream sources", () => {
};
test("re-resolving a bandcamp stream forces a fresh lookup and keeps the art", async () => {
route("GET /api/bandcamp/tracks", () => ({ ...album, artworkUrl: undefined }));
route(
"GET /api/bandcamp/tracks",
() => ({ ...album, artworkUrl: undefined }),
);
const items = await resolveStreamQueue(stream);
expect(items[0].artworkUrl).toBe("https://old/art.png");
expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force"))
@@ -118,9 +147,14 @@ describe("stream sources", () => {
test("isStreamStale after the window", () => {
vi.useFakeTimers();
vi.setSystemTime(10 * STREAM_STALE_MS);
expect(isStreamStale({ ...stream, resolvedAt: Date.now() - 1000 })).toBe(false);
expect(isStreamStale({ ...stream, resolvedAt: Date.now() - 1000 })).toBe(
false,
);
expect(
isStreamStale({ ...stream, resolvedAt: Date.now() - STREAM_STALE_MS - 1 }),
isStreamStale({
...stream,
resolvedAt: Date.now() - STREAM_STALE_MS - 1,
}),
).toBe(true);
});
@@ -128,7 +162,9 @@ describe("stream sources", () => {
expect(playerItemKey(stream)).toBe(
playerItemKey({ ...stream, streamUrl: "https://s/refreshed" }),
);
expect(playerItemKey({ kind: "file", fileUrl: "f", mimeType: "audio/mpeg" }))
expect(
playerItemKey({ kind: "file", fileUrl: "f", mimeType: "audio/mpeg" }),
)
.toBe("file:f");
expect(playerItemKey({ kind: "embed", embedUrl: "e", type: "youtube" }))
.toBe("embed:e");
@@ -139,7 +175,9 @@ describe("youtube", () => {
test("embedListId reads ?list= from either embed shape", () => {
expect(embedListId("https://www.youtube.com/embed/videoseries?list=PL1"))
.toBe("PL1");
expect(embedListId("https://www.youtube.com/embed/abc?list=PL2")).toBe("PL2");
expect(embedListId("https://www.youtube.com/embed/abc?list=PL2")).toBe(
"PL2",
);
expect(embedListId("https://www.youtube.com/embed/abc")).toBeNull();
expect(embedListId("nonsense")).toBeNull();
});
@@ -153,10 +191,13 @@ describe("youtube", () => {
});
test("fetchPlaylistTracks", async () => {
route("GET /api/youtube/playlist", (req) =>
req.url.searchParams.get("list") === "PL"
? { tracks: [{ videoId: "a", title: "A", duration: 3 }] }
: { tracks: [] });
route(
"GET /api/youtube/playlist",
(req) =>
req.url.searchParams.get("list") === "PL"
? { tracks: [{ videoId: "a", title: "A", duration: 3 }] }
: { tracks: [] },
);
await expect(fetchPlaylistTracks("PL")).resolves.toEqual([
{ videoId: "a", title: "A", duration: 3 },
]);

View File

@@ -123,11 +123,14 @@ describe("playChime", () => {
});
test("no Web Audio, no crash", async () => {
vi.stubGlobal("AudioContext", class {
constructor() {
throw new Error("unsupported");
}
});
vi.stubGlobal(
"AudioContext",
class {
constructor() {
throw new Error("unsupported");
}
},
);
const s = await load();
expect(() => s.playChime("notify", "x")).not.toThrow();
expect(() => s.previewChime("chat")).not.toThrow();

View File

@@ -63,11 +63,12 @@ describe("stored session", () => {
expect(readStoredSession()).toBeNull();
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
const legacy = wire(makeSession()) as unknown as {
user: { role?: string };
};
delete legacy.user.role;
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(legacy));
const stored = wire(makeSession());
const { role: _role, ...roleless } = stored.user;
localStorage.setItem(
AUTH_STORAGE_KEY,
JSON.stringify({ ...stored, user: roleless }),
);
expect(readStoredSession()).toBeNull();
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
});

View File

@@ -86,7 +86,9 @@ export class FakeWebSocket extends EventTarget {
}
/** Opens the socket and sends a welcome, as the server does on connect. */
welcome(overrides: Partial<Extract<IncomingWSMessage, { type: "welcome" }>> = {}) {
welcome(
overrides: Partial<Extract<IncomingWSMessage, { type: "welcome" }>> = {},
) {
this.open();
this.receive({
type: "welcome",