v3: src/ gets one layout rule and keeps to it — each layer holds one kind of module, and every layer is split into the same domains — the previous pass sorted components into folders but left hooks, helpers and a barrel inside them, kept hooks/, utils/ and contexts/ flat, and still grouped most tests by theme. The rule: components/ holds only components (.tsx), contexts/ holds FooContext.ts + FooProvider.tsx pairs, hooks/ every hook, utils/ every non-React module, pages/ one component per route and nothing else. Each of components/, contexts/, hooks/ and utils/ is split into the same domains — auth, category, chat, comment, dump, editor, feed, form, layout, player, playlist, profile, social, theme, ws — plus shared/ for what belongs to no one domain, so the chat is components/chat, contexts/chat, hooks/chat and utils/chat. components/ui becomes components/shared to match. Out of components/: useApiForm goes to hooks/form, chat's message.ts and time.ts to utils/chat (with chatDraft and chatText, now draft.ts and text.ts), the emoji skin tone to utils/editor, the remembered volume to utils/player; player/icons.tsx is PlayerIcons.tsx. components/form/index.ts is gone: everything is imported from its own file, FormProvider straight from react-hook-form, and expectOk sits beside friendlyFetchError in utils/shared/api.ts (apiError.ts), since pages that have no form used it too. Out of pages/: the index's feed views, tab bar and presence row move to components/feed; HotFeed and NewFeed, identical but for the sort, are one DumpFeed with an order prop, which also takes over MainFeedProps from pages/index/types.ts. App is a named export like everything else. Tests mirror the tree one file per module — test/<layer>/<domain>/<Module>.test.tsx — so the themed files (hooks/misc, data, editor, sync; utils/misc, text, storage, remote, journal; contexts/providers; pages/auth and UserSubpages; layout/Fabs) are split by subject, the setup each piece needs kept and the rest dropped; the floating-button test becomes one test per button, 627 tests now. The README states the rule. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
837
src/test/contexts/ws/WSProvider.test.tsx
Normal file
837
src/test/contexts/ws/WSProvider.test.tsx
Normal file
@@ -0,0 +1,837 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useContext } from "react";
|
||||
import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
|
||||
import { WSProvider } from "../../../contexts/ws/WSProvider.tsx";
|
||||
import { AvatarOverrideContext } from "../../../contexts/ws/AvatarOverrideContext.ts";
|
||||
import { useWS, useWSChat, useWSPresence } from "../../../hooks/ws/useWS.ts";
|
||||
import { useAuth } from "../../../hooks/auth/useAuth.ts";
|
||||
import { FakeWebSocket } from "../../ws.ts";
|
||||
import { defaultRoutes } from "../../render.tsx";
|
||||
import {
|
||||
makeChatMessage,
|
||||
makeComment,
|
||||
makeDump,
|
||||
makeNotification,
|
||||
makePlaylist,
|
||||
makeSession,
|
||||
makeUser,
|
||||
storeSession,
|
||||
wire,
|
||||
} from "../../fixtures.ts";
|
||||
|
||||
const me = makeSession({ id: "user-me", username: "me" });
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<WSProvider>{children}</WSProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function setup({ signedIn = true } = {}) {
|
||||
if (signedIn) storeSession(me);
|
||||
defaultRoutes({ session: signedIn ? me : null });
|
||||
const hook = renderHook(
|
||||
() => ({
|
||||
ws: { ...useWS(), ...useWSChat(), ...useWSPresence() },
|
||||
auth: useAuth(),
|
||||
avatars: useContext(AvatarOverrideContext),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
const socket = FakeWebSocket.last;
|
||||
return { hook, socket, ws: () => hook.result.current.ws };
|
||||
}
|
||||
|
||||
beforeEach(() =>
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] })
|
||||
);
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("connection", () => {
|
||||
test("connects with the session token, and without one for guests", () => {
|
||||
const { socket } = setup();
|
||||
expect(socket.url).toBe(
|
||||
`ws://localhost:8000/ws?token=${encodeURIComponent(me.token)}`,
|
||||
);
|
||||
FakeWebSocket.reset();
|
||||
localStorage.clear();
|
||||
const guest = setup({ signedIn: false });
|
||||
expect(guest.socket.url).toBe("ws://localhost:8000/ws");
|
||||
});
|
||||
|
||||
test("connecting → connected on open", () => {
|
||||
const { socket, ws } = setup();
|
||||
expect(ws().wsStatus).toBe("connecting");
|
||||
socket.open();
|
||||
expect(ws().wsStatus).toBe("connected");
|
||||
expect(ws().wsErrorMessage).toBeNull();
|
||||
});
|
||||
|
||||
test("a socket stuck connecting times out with a message", () => {
|
||||
const { ws } = setup();
|
||||
act(() => vi.advanceTimersByTime(2_500));
|
||||
expect(ws().wsStatus).toBe("disconnected");
|
||||
expect(ws().wsErrorMessage).toMatch(/Can't connect/);
|
||||
});
|
||||
|
||||
test("reconnects with exponential backoff after a drop", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.drop();
|
||||
expect(ws().wsStatus).toBe("disconnected");
|
||||
expect(ws().wsErrorMessage).toMatch(/temporarily disconnected/);
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
act(() => vi.advanceTimersByTime(500));
|
||||
expect(FakeWebSocket.instances).toHaveLength(2);
|
||||
FakeWebSocket.last.drop();
|
||||
act(() => vi.advanceTimersByTime(999));
|
||||
expect(FakeWebSocket.instances).toHaveLength(2);
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
expect(FakeWebSocket.instances).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("answers pings", () => {
|
||||
const { socket } = setup();
|
||||
socket.open();
|
||||
socket.receive({ type: "ping" });
|
||||
expect(socket.messages).toEqual([{ type: "pong" }]);
|
||||
});
|
||||
|
||||
test("ignores garbage frames", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.open();
|
||||
socket.receiveRaw("not json");
|
||||
socket.receiveRaw(JSON.stringify({ nope: 1 }));
|
||||
socket.receiveRaw("null");
|
||||
expect(ws().wsStatus).toBe("connected");
|
||||
});
|
||||
|
||||
test("welcome loads presence, votes, likes and unread counts", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome({
|
||||
users: [{ userId: "u1", username: "a", hasAvatar: false }],
|
||||
myVotes: ["d1"],
|
||||
myCommentLikes: ["c1"],
|
||||
unreadNotificationCount: 4,
|
||||
unreadChatCount: 2,
|
||||
chatReadAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(ws().onlineUsers).toHaveLength(1);
|
||||
expect(ws().myVotes.has("d1")).toBe(true);
|
||||
expect(ws().myLikes.has("c1")).toBe(true);
|
||||
expect(ws().unreadNotificationCount).toBe(4);
|
||||
expect(ws().unreadChatCount).toBe(2);
|
||||
expect(ws().chatReadAt).toEqual(new Date("2026-01-01T00:00:00.000Z"));
|
||||
expect(document.title).toMatch(/^\(6\)/);
|
||||
});
|
||||
|
||||
test("a reconnect bumps the epoch; the first connect doesn't", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
expect(ws().connectionEpoch).toBe(0);
|
||||
socket.drop();
|
||||
act(() => vi.advanceTimersByTime(500));
|
||||
FakeWebSocket.last.welcome();
|
||||
expect(ws().connectionEpoch).toBe(1);
|
||||
});
|
||||
|
||||
test("force_logout signs out", () => {
|
||||
const { socket, hook } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({ type: "force_logout" });
|
||||
expect(hook.result.current.auth.user).toBeNull();
|
||||
});
|
||||
|
||||
test("signing out resets live state and reconnects as a guest", () => {
|
||||
const { socket, hook, ws } = setup();
|
||||
socket.welcome({ myVotes: ["d1"], unreadNotificationCount: 3 });
|
||||
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "x" })) });
|
||||
act(() => hook.result.current.auth.logout());
|
||||
expect(ws().myVotes.size).toBe(0);
|
||||
expect(ws().recentDumps).toEqual([]);
|
||||
expect(ws().unreadNotificationCount).toBe(0);
|
||||
expect(socket.readyState).toBe(FakeWebSocket.CLOSED);
|
||||
expect(FakeWebSocket.last.url).toBe("ws://localhost:8000/ws");
|
||||
});
|
||||
|
||||
test("unmounting closes the socket for good", () => {
|
||||
const { socket, hook } = setup();
|
||||
socket.welcome();
|
||||
hook.unmount();
|
||||
expect(socket.readyState).toBe(FakeWebSocket.CLOSED);
|
||||
act(() => vi.advanceTimersByTime(60_000));
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("votes", () => {
|
||||
test("cast is optimistic, sent, and confirmed by the ack", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().castVote("d1", 4));
|
||||
expect(ws().myVotes.has("d1")).toBe(true);
|
||||
expect(ws().voteCounts.d1).toBe(5);
|
||||
expect(socket.messages).toContainEqual({ type: "vote_cast", dumpId: "d1" });
|
||||
socket.receive({
|
||||
type: "vote_ack",
|
||||
dumpId: "d1",
|
||||
action: "cast",
|
||||
voteCount: 7,
|
||||
});
|
||||
expect(ws().voteCounts.d1).toBe(7);
|
||||
act(() => vi.advanceTimersByTime(10_000));
|
||||
expect(ws().myVotes.has("d1")).toBe(true);
|
||||
});
|
||||
|
||||
test("an unconfirmed vote rolls back after the ack timeout", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().castVote("d1", 2));
|
||||
act(() => vi.advanceTimersByTime(5_000));
|
||||
expect(ws().myVotes.has("d1")).toBe(false);
|
||||
expect(ws().voteCounts.d1).toBe(2);
|
||||
});
|
||||
|
||||
test("a server error naming the dump rolls back at once", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome({ myVotes: ["d1"] });
|
||||
act(() => ws().removeVote("d1", 3));
|
||||
expect(ws().voteCounts.d1).toBe(2);
|
||||
socket.receive({
|
||||
type: "error",
|
||||
message: "nope",
|
||||
dumpId: "d1",
|
||||
action: "remove",
|
||||
});
|
||||
expect(ws().myVotes.has("d1")).toBe(true);
|
||||
expect(ws().voteCounts.d1).toBe(3);
|
||||
});
|
||||
|
||||
test("casting twice or removing an absent vote does nothing", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome({ myVotes: ["d1"] });
|
||||
act(() => ws().castVote("d1", 1));
|
||||
act(() => ws().removeVote("d2", 1));
|
||||
expect(socket.messages).toEqual([]);
|
||||
});
|
||||
|
||||
test("remove never goes below zero", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome({ myVotes: ["d1"] });
|
||||
act(() => ws().removeVote("d1", 0));
|
||||
expect(ws().voteCounts.d1).toBe(0);
|
||||
});
|
||||
|
||||
test("offline, the vote is shown then rolled back", () => {
|
||||
const { ws } = setup();
|
||||
act(() => ws().castVote("d1", 0));
|
||||
expect(ws().voteCounts.d1).toBe(1);
|
||||
act(() => vi.advanceTimersByTime(5_000));
|
||||
expect(ws().voteCounts.d1).toBe(0);
|
||||
});
|
||||
|
||||
test("votes_update from another tab of mine syncs myVotes", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "votes_update",
|
||||
dumpId: "d9",
|
||||
voteCount: 1,
|
||||
voterId: "user-me",
|
||||
action: "cast",
|
||||
});
|
||||
expect(ws().myVotes.has("d9")).toBe(true);
|
||||
expect(ws().lastVoteEvent).toEqual({
|
||||
dumpId: "d9",
|
||||
voterId: "user-me",
|
||||
action: "cast",
|
||||
});
|
||||
socket.receive({
|
||||
type: "votes_update",
|
||||
dumpId: "d9",
|
||||
voteCount: 2,
|
||||
voterId: "other",
|
||||
action: "cast",
|
||||
});
|
||||
expect(ws().voteCounts.d9).toBe(2);
|
||||
socket.receive({
|
||||
type: "votes_update",
|
||||
dumpId: "d9",
|
||||
voteCount: 1,
|
||||
voterId: "user-me",
|
||||
action: "remove",
|
||||
});
|
||||
expect(ws().myVotes.has("d9")).toBe(false);
|
||||
});
|
||||
|
||||
test("a welcome supersedes pending rollbacks", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().castVote("d1", 0));
|
||||
socket.receive({
|
||||
type: "welcome",
|
||||
users: [],
|
||||
myVotes: ["d1"],
|
||||
myCommentLikes: [],
|
||||
unreadNotificationCount: 0,
|
||||
unreadChatCount: 0,
|
||||
});
|
||||
act(() => vi.advanceTimersByTime(10_000));
|
||||
expect(ws().myVotes.has("d1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("comment likes", () => {
|
||||
test("like, ack, unlike, error rollback", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().castCommentLike("c1", 0));
|
||||
expect(ws().likeCounts.c1).toBe(1);
|
||||
expect(socket.messages).toContainEqual({
|
||||
type: "comment_like_cast",
|
||||
commentId: "c1",
|
||||
});
|
||||
socket.receive({
|
||||
type: "comment_like_ack",
|
||||
commentId: "c1",
|
||||
action: "cast",
|
||||
likeCount: 1,
|
||||
});
|
||||
act(() => ws().removeCommentLike("c1"));
|
||||
expect(ws().likeCounts.c1).toBe(0);
|
||||
expect(socket.messages).toContainEqual({
|
||||
type: "comment_like_remove",
|
||||
commentId: "c1",
|
||||
});
|
||||
socket.receive({ type: "error", commentId: "c1" });
|
||||
expect(ws().myLikes.has("c1")).toBe(true);
|
||||
expect(ws().likeCounts.c1).toBe(1);
|
||||
});
|
||||
|
||||
test("times out like votes do", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().castCommentLike("c1", 5));
|
||||
act(() => vi.advanceTimersByTime(5_000));
|
||||
expect(ws().likeCounts.c1).toBe(5);
|
||||
expect(ws().myLikes.has("c1")).toBe(false);
|
||||
});
|
||||
|
||||
test("comment_likes_update from my other tab", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "comment_likes_update",
|
||||
commentId: "c2",
|
||||
likeCount: 3,
|
||||
likerId: "user-me",
|
||||
action: "cast",
|
||||
});
|
||||
expect(ws().myLikes.has("c2")).toBe(true);
|
||||
expect(ws().likeCounts.c2).toBe(3);
|
||||
expect(ws().lastLikeEvent?.commentId).toBe("c2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dumps, playlists, comments, users", () => {
|
||||
test("created dumps go on top, deduplicated, and undo a deletion", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({ type: "dump_deleted", dumpId: "a" });
|
||||
expect(ws().deletedDumpIds.has("a")).toBe(true);
|
||||
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a" })) });
|
||||
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "b" })) });
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
dump: wire(makeDump({ id: "a", title: "again" })),
|
||||
});
|
||||
expect(ws().recentDumps.map((d) => d.id)).toEqual(["a", "b"]);
|
||||
expect(ws().recentDumps[0].title).toBe("again");
|
||||
expect(ws().recentDumps[0].createdAt).toBeInstanceOf(Date);
|
||||
expect(ws().deletedDumpIds.has("a")).toBe(false);
|
||||
});
|
||||
|
||||
test("the live buffer is capped at 100", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
for (let i = 0; i < 105; i++) {
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
dump: wire(makeDump({ id: `d${i}` })),
|
||||
});
|
||||
}
|
||||
expect(ws().recentDumps).toHaveLength(100);
|
||||
expect(ws().recentDumps[0].id).toBe("d104");
|
||||
});
|
||||
|
||||
test("an update refreshes a live entry but doesn't make it an arrival", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
dump: wire(makeDump({ id: "a", title: "one" })),
|
||||
});
|
||||
socket.receive({
|
||||
type: "dump_updated",
|
||||
dump: wire(makeDump({ id: "a", title: "two" })),
|
||||
});
|
||||
socket.receive({
|
||||
type: "dump_updated",
|
||||
dump: wire(makeDump({ id: "old" })),
|
||||
});
|
||||
expect(ws().recentDumps.map((d) => [d.id, d.title])).toEqual([[
|
||||
"a",
|
||||
"two",
|
||||
]]);
|
||||
expect(ws().lastDumpEvent?.id).toBe("old");
|
||||
});
|
||||
|
||||
test("deletion removes from the live buffer", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a" })) });
|
||||
socket.receive({ type: "dump_deleted", dumpId: "a" });
|
||||
expect(ws().recentDumps).toEqual([]);
|
||||
});
|
||||
|
||||
test("injectDump adds my own new dump once", () => {
|
||||
const { ws } = setup();
|
||||
const d = makeDump({ id: "mine" });
|
||||
act(() => ws().injectDump(d));
|
||||
act(() => ws().injectDump(d));
|
||||
expect(ws().recentDumps).toEqual([d]);
|
||||
});
|
||||
|
||||
test("playlist events", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
const p = makePlaylist({ id: "p" });
|
||||
socket.receive({ type: "playlist_created", playlist: wire(p) });
|
||||
expect(ws().lastPlaylistEvent).toMatchObject({
|
||||
type: "created",
|
||||
playlistId: "p",
|
||||
});
|
||||
socket.receive({ type: "playlist_updated", playlist: wire(p) });
|
||||
expect(ws().lastPlaylistEvent?.type).toBe("updated");
|
||||
socket.receive({
|
||||
type: "playlist_dumps_updated",
|
||||
playlistId: "p",
|
||||
dumpIds: ["x"],
|
||||
dumpCount: 2,
|
||||
});
|
||||
expect(ws().lastPlaylistEvent).toEqual({
|
||||
type: "dumps_updated",
|
||||
playlistId: "p",
|
||||
dumpIds: ["x"],
|
||||
dumpCount: 2,
|
||||
});
|
||||
socket.receive({ type: "playlist_deleted", playlistId: "p", userId: "u" });
|
||||
expect(ws().deletedPlaylistIds.has("p")).toBe(true);
|
||||
expect(ws().lastPlaylistEvent).toEqual({
|
||||
type: "deleted",
|
||||
playlistId: "p",
|
||||
userId: "u",
|
||||
});
|
||||
});
|
||||
|
||||
test("comment events", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
const c = makeComment({ id: "c", dumpId: "d" });
|
||||
socket.receive({ type: "comment_created", comment: wire(c) });
|
||||
expect(ws().lastCommentEvent).toMatchObject({
|
||||
type: "created",
|
||||
dumpId: "d",
|
||||
});
|
||||
expect(ws().lastCommentEvent?.comment?.createdAt).toBeInstanceOf(Date);
|
||||
socket.receive({ type: "comment_updated", comment: wire(c) });
|
||||
expect(ws().lastCommentEvent?.type).toBe("updated");
|
||||
socket.receive({ type: "comment_deleted", commentId: "c", dumpId: "d" });
|
||||
expect(ws().lastCommentEvent).toEqual({
|
||||
type: "deleted",
|
||||
dumpId: "d",
|
||||
commentId: "c",
|
||||
});
|
||||
});
|
||||
|
||||
test("user_updated overrides that user's avatar everywhere", () => {
|
||||
const { socket, hook } = setup();
|
||||
socket.welcome();
|
||||
const u = makeUser({
|
||||
id: "u1",
|
||||
avatarMime: "image/png",
|
||||
updatedAt: new Date(5000),
|
||||
});
|
||||
socket.receive({ type: "user_updated", user: wire(u) });
|
||||
expect(hook.result.current.avatars.u1).toEqual({
|
||||
hasAvatar: true,
|
||||
version: 5000,
|
||||
});
|
||||
expect(hook.result.current.ws.lastUserEvent?.user.id).toBe("u1");
|
||||
});
|
||||
|
||||
test("presence updates", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "presence_update",
|
||||
users: [{ userId: "x", username: "x", hasAvatar: false }],
|
||||
});
|
||||
expect(ws().onlineUsers.map((u) => u.userId)).toEqual(["x"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifications", () => {
|
||||
test("a new one bumps the count and is remembered", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome({ unreadNotificationCount: 1 });
|
||||
socket.receive({
|
||||
type: "notification_created",
|
||||
notification: wire(makeNotification({ id: "n" })),
|
||||
});
|
||||
expect(ws().unreadNotificationCount).toBe(2);
|
||||
expect(ws().lastNotification?.id).toBe("n");
|
||||
act(() => ws().clearUnreadNotifications());
|
||||
expect(ws().unreadNotificationCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat", () => {
|
||||
test("messages are buffered once, and count as unread unless mine", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
const m = makeChatMessage({ id: "m1", userId: "other" });
|
||||
socket.receive({ type: "chat_message", message: wire(m) });
|
||||
socket.receive({ type: "chat_message_updated", message: wire(m) });
|
||||
socket.receive({
|
||||
type: "chat_message",
|
||||
message: wire(makeChatMessage({ id: "m2", userId: "user-me" })),
|
||||
});
|
||||
expect(ws().chatMessages.map((x) => x.id)).toEqual(["m1", "m2"]);
|
||||
expect(ws().unreadChatCount).toBe(1);
|
||||
});
|
||||
|
||||
test("the buffer is capped at 200", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
for (let i = 0; i < 205; i++) {
|
||||
socket.receive({
|
||||
type: "chat_message",
|
||||
message: wire(makeChatMessage({ id: `m${i}`, userId: "user-me" })),
|
||||
});
|
||||
}
|
||||
expect(ws().chatMessages).toHaveLength(200);
|
||||
expect(ws().chatMessages[0].id).toBe("m5");
|
||||
});
|
||||
|
||||
test("with the chat open and visible, arrivals aren't unread", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().setChatOpen(true));
|
||||
expect(socket.messages).toContainEqual({ type: "chat_focus", open: true });
|
||||
socket.receive({ type: "chat_message", message: wire(makeChatMessage()) });
|
||||
expect(ws().unreadChatCount).toBe(0);
|
||||
});
|
||||
|
||||
test("an open chat in a hidden tab still counts unread", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().setChatOpen(true));
|
||||
vi.spyOn(document, "hidden", "get").mockReturnValue(true);
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
expect(socket.messages.at(-1)).toEqual({ type: "chat_focus", open: false });
|
||||
socket.receive({ type: "chat_message", message: wire(makeChatMessage()) });
|
||||
expect(ws().unreadChatCount).toBe(1);
|
||||
});
|
||||
|
||||
test("sending shows a pending message, retired by its echo", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() =>
|
||||
ws().sendChatMessage(" hello ", { id: "r", author: "a", body: "b" })
|
||||
);
|
||||
const [pending] = ws().pendingChatMessages;
|
||||
expect(pending).toMatchObject({ body: "hello", status: "sending" });
|
||||
expect(socket.messages).toContainEqual({
|
||||
type: "chat_send",
|
||||
body: "hello",
|
||||
nonce: pending.nonce,
|
||||
replyToId: "r",
|
||||
});
|
||||
socket.receive({
|
||||
type: "chat_message",
|
||||
message: wire(makeChatMessage({ userId: "user-me", body: "hello" })),
|
||||
nonce: pending.nonce,
|
||||
});
|
||||
expect(ws().pendingChatMessages).toEqual([]);
|
||||
act(() => vi.advanceTimersByTime(20_000));
|
||||
expect(ws().pendingChatMessages).toEqual([]);
|
||||
});
|
||||
|
||||
test("blank messages aren't sent", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().sendChatMessage(" "));
|
||||
expect(ws().pendingChatMessages).toEqual([]);
|
||||
expect(socket.messages).toEqual([]);
|
||||
});
|
||||
|
||||
test("offline sends fail at once", () => {
|
||||
const { ws } = setup();
|
||||
act(() => ws().sendChatMessage("hi"));
|
||||
expect(ws().pendingChatMessages[0]).toMatchObject({ status: "failed" });
|
||||
expect(ws().pendingChatMessages[0].error).toMatch(/offline/);
|
||||
});
|
||||
|
||||
test("no echo in time marks the send failed; retry uses a fresh nonce", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().sendChatMessage("hi"));
|
||||
const first = ws().pendingChatMessages[0].nonce;
|
||||
act(() => vi.advanceTimersByTime(10_000));
|
||||
expect(ws().pendingChatMessages[0]).toMatchObject({ status: "failed" });
|
||||
act(() => ws().retryChatMessage(first));
|
||||
const retry = ws().pendingChatMessages[0];
|
||||
expect(retry.status).toBe("sending");
|
||||
expect(retry.nonce).not.toBe(first);
|
||||
expect(socket.messages.filter((m) => m.type === "chat_send")).toHaveLength(
|
||||
2,
|
||||
);
|
||||
// The first attempt's late echo doesn't retire the retry.
|
||||
socket.receive({
|
||||
type: "chat_message",
|
||||
message: wire(makeChatMessage({ userId: "user-me", body: "hi" })),
|
||||
nonce: first,
|
||||
});
|
||||
expect(ws().pendingChatMessages).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a server rejection fails the pending send; discard drops it", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().sendChatMessage("hi"));
|
||||
const { nonce } = ws().pendingChatMessages[0];
|
||||
socket.receive({
|
||||
type: "error",
|
||||
scope: "chat",
|
||||
nonce,
|
||||
message: "Too long",
|
||||
});
|
||||
expect(ws().pendingChatMessages[0]).toMatchObject({
|
||||
status: "failed",
|
||||
error: "Too long",
|
||||
});
|
||||
act(() => ws().discardChatMessage(nonce));
|
||||
expect(ws().pendingChatMessages).toEqual([]);
|
||||
});
|
||||
|
||||
test("a chat error without a nonce is shown until dismissed", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({ type: "error", scope: "chat" });
|
||||
expect(ws().chatError?.message).toBe("Something went wrong");
|
||||
socket.receive({ type: "error", scope: "chat", message: "Not yours" });
|
||||
expect(ws().chatError?.message).toBe("Not yours");
|
||||
act(() => ws().dismissChatError());
|
||||
expect(ws().chatError).toBeNull();
|
||||
});
|
||||
|
||||
test("a dropped socket fails in-flight sends", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().sendChatMessage("hi"));
|
||||
socket.drop();
|
||||
expect(ws().pendingChatMessages[0].error).toMatch(/Connection lost/);
|
||||
});
|
||||
|
||||
test("edits and deletes", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().editChatMessage("m1", " new "));
|
||||
act(() => ws().editChatMessage("m1", " "));
|
||||
act(() => ws().deleteChatMessage("m1"));
|
||||
expect(socket.messages).toEqual([
|
||||
{ type: "chat_edit", id: "m1", body: "new" },
|
||||
{ type: "chat_delete", id: "m1" },
|
||||
]);
|
||||
const m = makeChatMessage({ id: "m1", body: "old" });
|
||||
socket.receive({ type: "chat_message", message: wire(m) });
|
||||
socket.receive({
|
||||
type: "chat_message_updated",
|
||||
message: wire({ ...m, body: "new" }),
|
||||
});
|
||||
expect(ws().chatMessages[0].body).toBe("new");
|
||||
socket.receive({
|
||||
type: "chat_message_updated",
|
||||
message: wire(makeChatMessage({ id: "m2" })),
|
||||
});
|
||||
expect(ws().chatMessages).toHaveLength(2);
|
||||
socket.receive({ type: "chat_message_deleted", id: "m1" });
|
||||
expect(ws().chatMessages.map((x) => x.id)).toEqual(["m2"]);
|
||||
expect(ws().deletedChatIds.has("m1")).toBe(true);
|
||||
});
|
||||
|
||||
test("typing: sent and received", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().setChatTyping(true));
|
||||
expect(socket.messages).toEqual([{ type: "chat_typing", typing: true }]);
|
||||
socket.receive({
|
||||
type: "chat_typing_update",
|
||||
users: [{ userId: "x", username: "x", hasAvatar: false }],
|
||||
});
|
||||
expect(ws().typingUsers).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("the read marker clears the badge at once and is throttled to the server", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome({ unreadChatCount: 3 });
|
||||
act(() => ws().markChatRead(new Date(1_000_000)));
|
||||
expect(ws().unreadChatCount).toBe(0);
|
||||
act(() => vi.advanceTimersByTime(0));
|
||||
expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
act(() => ws().markChatRead(new Date(2_000_000)));
|
||||
act(() => ws().markChatRead(new Date(3_000_000)));
|
||||
act(() => vi.advanceTimersByTime(999));
|
||||
expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
const reads = socket.messages.filter((m) => m.type === "chat_read");
|
||||
expect(reads).toHaveLength(2);
|
||||
expect(reads[1].at).toBe(new Date(3_000_000).toISOString());
|
||||
// Older than what's been sent: ignored.
|
||||
act(() => ws().markChatRead(new Date(10)));
|
||||
act(() => vi.advanceTimersByTime(5_000));
|
||||
expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("a marker set offline goes out with the next welcome", () => {
|
||||
const { socket, ws } = setup();
|
||||
act(() => ws().markChatRead(new Date(5_000_000)));
|
||||
act(() => vi.advanceTimersByTime(2_000));
|
||||
socket.welcome();
|
||||
expect(socket.messages).toContainEqual({
|
||||
type: "chat_read",
|
||||
at: new Date(5_000_000).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
test("chat_read_update from another tab moves the marker", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome({ unreadChatCount: 5 });
|
||||
socket.receive({
|
||||
type: "chat_read_update",
|
||||
readAt: "2026-05-01T00:00:00.000Z",
|
||||
unread: 1,
|
||||
});
|
||||
expect(ws().unreadChatCount).toBe(1);
|
||||
expect(ws().chatReadAt).toEqual(new Date("2026-05-01T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
test("reconcile replaces stale copies, drops deleted ones, retires landed sends", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
const t0 = Date.now();
|
||||
const at = (s: number) => new Date(t0 + s * 1000);
|
||||
const early = makeChatMessage({ id: "early", createdAt: at(-100) });
|
||||
const edited = makeChatMessage({
|
||||
id: "edited",
|
||||
body: "old",
|
||||
createdAt: at(-50),
|
||||
});
|
||||
const gone = makeChatMessage({ id: "gone", createdAt: at(-40) });
|
||||
for (const m of [early, edited, gone]) {
|
||||
socket.receive({ type: "chat_message", message: wire(m) });
|
||||
}
|
||||
act(() => ws().sendChatMessage("did it land?"));
|
||||
socket.drop();
|
||||
expect(ws().pendingChatMessages[0].status).toBe("failed");
|
||||
|
||||
act(() =>
|
||||
ws().reconcileChat(
|
||||
[
|
||||
{ ...edited, body: "new" },
|
||||
makeChatMessage({
|
||||
id: "echo",
|
||||
userId: "user-me",
|
||||
body: "did it land?",
|
||||
createdAt: at(1),
|
||||
}),
|
||||
],
|
||||
at(-60),
|
||||
at(60),
|
||||
)
|
||||
);
|
||||
expect(ws().chatMessages.map((m) => [m.id, m.body])).toEqual([
|
||||
["early", early.body],
|
||||
["edited", "new"],
|
||||
]);
|
||||
expect(ws().pendingChatMessages).toEqual([]);
|
||||
});
|
||||
|
||||
test("a reconnect with the chat closed drops the stale buffer", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({ type: "chat_message", message: wire(makeChatMessage()) });
|
||||
socket.drop();
|
||||
act(() => vi.advanceTimersByTime(500));
|
||||
FakeWebSocket.last.welcome();
|
||||
expect(ws().chatMessages).toEqual([]);
|
||||
});
|
||||
|
||||
test("a reconnect with the chat open keeps it, and re-announces focus", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().setChatOpen(true));
|
||||
socket.receive({ type: "chat_message", message: wire(makeChatMessage()) });
|
||||
socket.drop();
|
||||
act(() => vi.advanceTimersByTime(500));
|
||||
FakeWebSocket.last.welcome();
|
||||
expect(ws().chatMessages).toHaveLength(1);
|
||||
expect(FakeWebSocket.last.messages).toContainEqual({
|
||||
type: "chat_focus",
|
||||
open: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("split contexts", () => {
|
||||
test("chat, typing and presence leave the feed's context alone", () => {
|
||||
storeSession(me);
|
||||
defaultRoutes({ session: me });
|
||||
const feeds: unknown[] = [];
|
||||
const chats: unknown[] = [];
|
||||
renderHook(() => {
|
||||
feeds.push(useWS());
|
||||
chats.push(useWSChat());
|
||||
}, { wrapper });
|
||||
const socket = FakeWebSocket.last;
|
||||
socket.welcome();
|
||||
const feed = feeds.at(-1);
|
||||
const chat = chats.at(-1);
|
||||
socket.receive({
|
||||
type: "chat_typing_update",
|
||||
users: [{ userId: "x", username: "x", hasAvatar: false }],
|
||||
});
|
||||
socket.receive({
|
||||
type: "presence_update",
|
||||
users: [{ userId: "x", username: "x", hasAvatar: false }],
|
||||
});
|
||||
socket.receive({ type: "chat_message", message: wire(makeChatMessage()) });
|
||||
// The feed's value is the same object: its consumers had no reason to
|
||||
// re-render. The chat's moved on.
|
||||
expect(feeds.at(-1)).toBe(feed);
|
||||
expect(chats.at(-1)).not.toBe(chat);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user