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