512 lines
17 KiB
TypeScript
512 lines
17 KiB
TypeScript
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<string, unknown>,
|
|
focusId = undefined as string | undefined,
|
|
} = {},
|
|
) {
|
|
route("GET /api/chat", () => wire(history));
|
|
const onClose = vi.fn();
|
|
const user = userEvent.setup();
|
|
renderApp(<ChatModal onClose={onClose} focusId={focusId} />, { 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(<ChatModal onClose={() => {}} />, { 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(<ChatModal onClose={() => {}} />, { 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(),
|
|
);
|
|
});
|
|
});
|