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