Files
OwnCord/Client/tauri-client/tests/unit/main-page.test.ts
T
J3vbandClaude b8b7a2a1f9 fix: correctness fixes across LiveKit voice, client session and transport paths (#1374)
* fix: enhance bugfix workflow documentation with detailed clustering and staging instructions

* fix(voice): 6 defect(s) (OC-0001, OC-0006, OC-0009, OC-0010, OC-0015, OC-0029)

* fix(voice): 1 defect(s) (OC-0005)

* fix(client): 1 defect(s) (OC-0007)

* fix(client): 1 defect(s) (OC-0011)

* fix(client): 1 defect(s) (OC-0012)

* fix(admin): 1 defect(s) (OC-0013)

* fix(client): 3 defect(s) (OC-0014, OC-0024, OC-0031)

* fix(voice): 1 defect(s) (OC-0018)

* fix(voice): 1 defect(s) (OC-0019)

* fix(client): 1 defect(s) (OC-0021)

* fix(client): 1 defect(s) (OC-0025)

* fix(ws): 1 defect(s) (OC-0026)

* fix(client): 1 defect(s) (OC-0027)

* fix(client): 1 defect(s) (OC-0028)

* fix(identity): 1 defect(s) (OC-0030)

* fix(voice): 1 defect(s) (OC-0016)

* fix(client): 2 defect(s) (OC-0002, OC-0020)

OC-0002: chain offer handling behind the announce chain so an offer that
arrives immediately behind its sender's announce is not dropped as an
unknown peer.

OC-0020: retire a departing peer's ECDH key on participant-left so a
replayed pre-leave announce cannot overwrite the fresh key they rejoined
with.

* fix(voice): 1 defect(s) (OC-0008)

handleVoiceJoin handed the client its LiveKit token before checking whether
the join had been superseded by a concurrent eviction (moderator kick/move,
the CONNECT_VOICE revocation sweep, CleanupVoiceForChannel). Those evictors
delete the voice_states row, clear the client's in-memory state, and call
RemoveParticipant — which no-ops because the join has not reached the SFU
yet. The client was left holding a live 5-minute RoomJoin credential for a
membership the server had just torn down.

Re-check the client's voice state immediately after GenerateToken and
withhold the credential if the join was superseded, with a best-effort
RemoveParticipant to match every other eviction path.

* fix(ws): 2 defect(s) (OC-0017, OC-0022)

OC-0017: sweepStaleVoiceStates re-checks the live client immediately before
deleting a snapshotted-stale voice_states row. voice_join commits the row
before calling c.setVoiceState, so a join that lands inside that window was
snapshotted as a ghost and had its just-committed row deleted, leaving the
client in voice in memory with no DB row.

OC-0022: CleanupVoiceForChannel resolves its voice_leave audience with a
variant of channelReadAudience that skips the archived short-circuit. Both
production callers archive the channel before evicting, so the plain
resolver always returned an empty audience and only the evicted
participants learned the call ended.

* fix(voice): 1 defect(s) (OC-0023)

Camera and screenshare now draw from the same per-channel voice_max_video
budget. handleVoiceScreenshareV2 performed no cap check at all, and the
camera gate's slot-count subquery counted only `camera = 1` rows, so a
screensharing occupant was invisible to it. Both gates now count
`camera = 1 OR screenshare = 1` via a shared enableVideoSlot helper.

* fix(client): 2 defect(s) (OC-0032, OC-0033)

OC-0033: voice_disconnected staleness guard swallowed the kick toast when
the sibling voice_leave had already cleared currentChannelId. Treat a
cleared store as not-stale.

OC-0032: VIDEO_LIMIT rollback assumed the camera, tearing down a working
camera and leaving refused screen tracks published. Correlate by envelope
id and roll back the kind that was actually refused.

* fix(voice): 1 defect(s) (OC-0034)

* fix(client): 1 defect(s) (OC-0035)

A superseded video-enable id makes rollbackPendingVideo return undefined.
The dispatcher's ternary treated undefined as "not screen" and called
disableCamera(), tearing down a working camera the user never touched.
Return early instead: undefined means there is nothing to roll back.

* fix(voice): 1 defect(s) (OC-0036)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-15 12:57:51 +02:00

681 lines
24 KiB
TypeScript

/**
* MainPage's activeChannelId subscriber (finding: a deleted/closed active
* channel left its message list and composer mounted, because the
* subscriber had no else branch to tear them down).
*
* MainPage.ts is excluded from unit coverage (vitest.config.ts) — it has
* lots of heavy child components (SidebarArea, ChatArea, ChannelController)
* that are extracted specifically to be independently testable, so those are
* mocked out here and only the wiring under test (the store subscription) is
* exercised for real.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@lib/logger", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock("@lib/livekitSession", () => ({
cleanupAll: vi.fn(),
setOnRemoteVideo: vi.fn(),
setOnRemoteVideoRemoved: vi.fn(),
clearOnRemoteVideo: vi.fn(),
setWsClient: vi.fn(),
setServerHost: vi.fn(),
setOnError: vi.fn(),
leaveVoice: vi.fn(),
setMuted: vi.fn(),
setDeafened: vi.fn(),
enableCamera: vi.fn().mockResolvedValue(undefined),
disableCamera: vi.fn().mockResolvedValue(undefined),
enableScreenshare: vi.fn().mockResolvedValue(undefined),
disableScreenshare: vi.fn().mockResolvedValue(undefined),
getLocalCameraStream: vi.fn(() => null),
getLocalScreenshareStream: vi.fn(() => null),
}));
vi.mock("@lib/notifications", () => ({
startRingChime: vi.fn(),
stopRingChime: vi.fn(),
}));
const { mockSetAudioVolumeHost } = vi.hoisted(() => ({
mockSetAudioVolumeHost: vi.fn(),
}));
// Real audioElements.ts pulls in livekit-client (unmocked elsewhere in this
// file's graph) purely to hold the AudioElements class this page never
// touches directly — mock out the one export MainPage actually calls.
vi.mock("@lib/audioElements", () => ({
setAudioVolumeHost: mockSetAudioVolumeHost,
}));
const { capturedAutoIdleOptions } = vi.hoisted(() => ({
capturedAutoIdleOptions: {
current: null as null | { onStatusChange: (status: string) => void },
},
}));
vi.mock("@lib/autoIdle", () => ({
startAutoIdle: (options: { onStatusChange: (status: string) => void }) => {
capturedAutoIdleOptions.current = options;
return { destroy: vi.fn() };
},
}));
const {
mockMountChannel,
mockDestroyChannel,
mockCreateChannelController,
mockCreateSidebarArea,
mockCreateChatArea,
capturedChatAreaRef,
} = vi.hoisted(() => ({
mockMountChannel: vi.fn(),
mockDestroyChannel: vi.fn(),
mockCreateChannelController: vi.fn(),
mockCreateSidebarArea: vi.fn(),
mockCreateChatArea: vi.fn(),
// The ChatArea mock below builds fresh DOM elements per call (a plain
// return value can't be read back from a vi.fn() call site) — this is how
// tests get at the actual slots/dmProfileSlot MainPage is wiring against.
capturedChatAreaRef: {
current: null as null | {
slots: {
messagesSlot: HTMLDivElement;
typingSlot: HTMLDivElement;
inputSlot: HTMLDivElement;
videoGridSlot: HTMLDivElement;
};
dmProfileSlot: HTMLDivElement;
},
},
}));
vi.mock("../../src/pages/main-page/ChannelController", () => ({
createChannelController: (...args: unknown[]) => {
mockCreateChannelController(...args);
return {
mountChannel: mockMountChannel,
destroyChannel: mockDestroyChannel,
openFilePicker: vi.fn(),
currentChannelId: 0,
messageList: null,
};
},
}));
vi.mock("../../src/pages/main-page/SidebarArea", () => ({
createSidebarArea: (...args: unknown[]) => {
mockCreateSidebarArea(...args);
return {
sidebarWrapper: document.createElement("div"),
children: [],
unsubscribers: [],
openQuickSwitch: vi.fn(),
};
},
}));
vi.mock("../../src/pages/main-page/ChatArea", () => ({
createChatArea: (...args: unknown[]) => {
mockCreateChatArea(...args);
const slots = {
messagesSlot: document.createElement("div"),
typingSlot: document.createElement("div"),
inputSlot: document.createElement("div"),
videoGridSlot: document.createElement("div"),
};
const dmProfileSlot = document.createElement("div");
capturedChatAreaRef.current = { slots, dmProfileSlot };
return {
chatArea: document.createElement("div"),
slots,
videoGrid: {
addStream: vi.fn(),
removeStream: vi.fn(),
clearStreams: vi.fn(),
hasStreams: vi.fn(() => false),
setFocusedTile: vi.fn(),
getFocusedTileId: vi.fn(() => null),
mount: vi.fn(),
destroy: vi.fn(),
},
chatHeaderName: document.createElement("span"),
chatHeaderRefs: {
hashEl: document.createElement("span"),
nameEl: document.createElement("span"),
topicEl: document.createElement("span"),
callBtn: document.createElement("button"),
},
searchCtrl: { open: vi.fn(), cleanup: vi.fn() },
dmProfileSlot,
children: [],
unsubscribers: [],
};
},
}));
import { createMainPage } from "../../src/pages/MainPage";
import { channelsStore, setChannels, setActiveChannel } from "../../src/stores/channels.store";
import { authStore } from "../../src/stores/auth.store";
import { uiStore } from "../../src/stores/ui.store";
import { voiceStore } from "../../src/stores/voice.store";
import { dmStore } from "../../src/stores/dm.store";
import type { WsClient, WsListener, ConnectionState } from "../../src/lib/ws";
import type { ApiClient } from "../../src/lib/api";
import type { ServerMessage } from "../../src/lib/types";
import { openImageLightbox } from "../../src/components/message-list/media";
import { saveUserStatus } from "../../src/lib/userStatus";
function resetStores(): void {
channelsStore.setState(() => ({ channels: new Map(), activeChannelId: null, roles: [] }));
authStore.setState(() => ({
token: "t",
user: { id: 1, username: "alice", avatar: null, role: "member" },
serverName: null,
motd: null,
isAuthenticated: true,
}));
uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected", settingsOpen: false }));
voiceStore.setState(() => ({
currentChannelId: null,
voiceUsers: new Map(),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
dmStore.setState(() => ({ channels: [] }));
}
type FakeWsClient = WsClient & {
/** Test-only: drive a registered `ws.on(type, ...)` listener directly. */
emit: (type: ServerMessage["type"], payload: unknown) => void;
};
function fakeWs(): FakeWsClient {
const listeners = new Map<string, Set<WsListener<ServerMessage["type"]>>>();
return {
connect: vi.fn(),
disconnect: vi.fn(),
send: vi.fn(() => "id"),
on<T extends ServerMessage["type"]>(type: T, listener: WsListener<T>) {
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type)!.add(listener as unknown as WsListener<ServerMessage["type"]>);
return () => {
listeners.get(type)?.delete(listener as unknown as WsListener<ServerMessage["type"]>);
};
},
emit(type: ServerMessage["type"], payload: unknown) {
for (const listener of listeners.get(type) ?? []) {
(listener as (p: unknown, id?: string) => void)(payload);
}
},
onStateChange: vi.fn(() => () => {}),
onSendFailure: vi.fn(() => () => {}),
onCertMismatch: vi.fn(() => () => {}),
onCertFirstUse: vi.fn(() => () => {}),
startCertListener: vi.fn(async () => {}),
acceptCertFingerprint: vi.fn(async () => {}),
getState: vi.fn(() => "connected" as ConnectionState),
isReplaying: vi.fn(() => false),
_getWs: vi.fn(() => null),
};
}
function fakeApi(): ApiClient {
return {
getConfig: () => ({ host: "" }),
getReactionUsers: vi.fn(async () => ({ users: [] })),
} as unknown as ApiClient;
}
function textChannel(id: number, name: string, position = 0) {
return {
id,
name,
type: "text" as const,
category: null,
position,
unreadCount: 0,
mentionCount: 0,
lastMessageId: null,
canSend: true,
topic: "",
slowMode: 0,
nsfw: false,
voiceMaxUsers: 0,
voiceMaxVideo: 0,
};
}
describe("MainPage — activeChannelId subscriber", () => {
let container: HTMLDivElement;
let page: ReturnType<typeof createMainPage>;
beforeEach(() => {
resetStores();
mockMountChannel.mockClear();
mockDestroyChannel.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
page?.destroy?.();
container.remove();
});
it("mounts the channel when an active channel is set", () => {
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(1, textChannel(1, "general"));
return { ...prev, channels: ch, activeChannelId: 1 };
});
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
page.mount(container);
expect(mockMountChannel).toHaveBeenCalledWith(1, "general", "text");
});
it("destroys the mounted channel when the active channel is cleared (deleted/closed while offline)", () => {
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(1, textChannel(1, "general"));
return { ...prev, channels: ch, activeChannelId: 1 };
});
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
page.mount(container);
expect(mockMountChannel).toHaveBeenCalledWith(1, "general", "text");
// The channel is gone and nothing replaces it as active — this is what
// dispatcher.ts's ready handler now does when the previously-active
// channel is absent from a fresh snapshot.
setActiveChannel(null);
channelsStore.flush();
expect(mockDestroyChannel).toHaveBeenCalledOnce();
});
it("mounts the newly active channel when switching between two channels", () => {
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(1, textChannel(1, "general"));
ch.set(2, textChannel(2, "random", 1));
return { ...prev, channels: ch, activeChannelId: 1 };
});
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
page.mount(container);
setActiveChannel(2);
channelsStore.flush();
expect(mockMountChannel).toHaveBeenCalledWith(2, "random", "text");
// Switching to a real channel remounts rather than destroying.
expect(mockDestroyChannel).not.toHaveBeenCalled();
});
});
function dmChannel(id: number, name: string, position = 0) {
return { ...textChannel(id, name, position), type: "dm" as const };
}
describe("MainPage — video grid, DM profile panel, calls, settings", () => {
let container: HTMLDivElement;
let page: ReturnType<typeof createMainPage>;
beforeEach(() => {
resetStores();
mockMountChannel.mockClear();
mockDestroyChannel.mockClear();
mockCreateChatArea.mockClear();
capturedChatAreaRef.current = null;
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
page?.destroy?.();
container.remove();
});
it("dismisses the video grid when switching to a non-voice channel (dm/announcement), not just 'text'", () => {
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(1, textChannel(1, "general"));
ch.set(2, dmChannel(2, "dm-with-bob"));
return { ...prev, channels: ch, activeChannelId: 1 };
});
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
page.mount(container);
// Put the grid into video mode via a local camera start while in voice
// (the real, unmocked VideoModeController drives this off voiceStore).
voiceStore.setState((prev) => ({
...prev,
currentChannelId: 9,
voiceUsers: new Map([[9, new Map()]]),
localCamera: true,
}));
voiceStore.flush();
expect(capturedChatAreaRef.current!.slots.messagesSlot.style.display).toBe("none");
// Switch to a DM while the grid is up — a dm channel mounts a chat
// surface just like text/announcement do, so the grid must not survive
// the switch and hide it behind an unrelated video grid.
setActiveChannel(2);
channelsStore.flush();
expect(capturedChatAreaRef.current!.slots.messagesSlot.style.display).toBe("");
});
it("does not open the 1:1 profile panel for a group DM header click", () => {
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(50, dmChannel(50, "Group Chat"));
return { ...prev, channels: ch, activeChannelId: 50 };
});
dmStore.setState(() => ({
channels: [
{
channelId: 50,
recipient: { id: 10, username: "alice", avatar: "", status: "online" },
participants: [
{ id: 10, username: "alice", avatar: "", status: "online" },
{ id: 11, username: "bob", avatar: "", status: "online" },
],
name: "Group Chat",
isGroup: true,
lastMessageId: null,
lastMessage: "",
lastMessageAt: "",
unreadCount: 0,
mentionCount: 0,
},
],
}));
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
page.mount(container);
const chatAreaOpts = mockCreateChatArea.mock.calls[0]![0];
chatAreaOpts.onToggleDmProfile();
// dm.store.ts documents .recipient as "the first of participants" for a
// group, with the explicit instruction that group-correct code reads
// .participants instead — a 1:1 panel built from .recipient would show
// one arbitrary member's identity as if it were the whole conversation.
expect(
capturedChatAreaRef.current!.dmProfileSlot.querySelector(
'[data-testid="dm-profile-sidebar"]',
),
).toBeNull();
});
it("does not ring or announce 'Calling…' when starting a call while the socket is reconnecting", () => {
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(50, dmChannel(50, "dm-alice"));
return { ...prev, channels: ch, activeChannelId: 50 };
});
uiStore.setState((prev) => ({ ...prev, connectionStatus: "reconnecting" }));
const ws = fakeWs();
page = createMainPage({ ws, api: fakeApi() });
page.mount(container);
const chatAreaOpts = mockCreateChatArea.mock.calls[0]![0];
chatAreaOpts.onStartCall();
// onVoiceJoin already silently refuses to join while the socket is down
// (VoiceCallbacks.ts's socketLive() guard) — startCall must not still
// ring and tell the user "Calling…" over a call nobody can hear.
expect(ws.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: "call_ring" }));
});
it("keeps an incoming ring alive when Accept is clicked while the socket is reconnecting", () => {
const ws = fakeWs();
uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected" }));
page = createMainPage({ ws, api: fakeApi() });
page.mount(container);
ws.emit("call_incoming", { channel_id: 50, from_user: 10, username: "alice" });
const banner = document.querySelector('[data-testid="incoming-call-banner"]') as HTMLElement;
expect(banner.style.display).not.toBe("none");
// The socket drops mid-ring.
uiStore.setState((prev) => ({ ...prev, connectionStatus: "reconnecting" }));
const acceptBtn = document.querySelector('[data-testid="incoming-call-accept"]') as HTMLElement;
acceptBtn.click();
// Accepting while reconnecting must not silently consume the ring (the
// banner clearing means ringCtrl.accept() ran and nothing rejoins) — the
// ring has to survive so the user can accept again once reconnected.
expect(banner.style.display).not.toBe("none");
});
it("does not cancel an incoming ring when a fellow group-DM callee declines, only when the actual ringer does (OC-0114)", () => {
const ws = fakeWs();
uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected" }));
page = createMainPage({ ws, api: fakeApi() });
page.mount(container);
// Alice (10) rings a group DM; this client is a third participant.
ws.emit("call_incoming", { channel_id: 50, from_user: 10, username: "alice" });
const banner = document.querySelector('[data-testid="incoming-call-banner"]') as HTMLElement;
expect(banner.style.display).not.toBe("none");
// Bob (11), a different callee in the same group DM, declines. The
// server addresses call_declined to every other participant (not just
// the caller — it holds no call state to target with), so this client
// receives it too, but it must not silence a ring it is still deciding
// on: Bob declining is not Alice hanging up.
ws.emit("call_declined", { channel_id: 50, from_user: 11, username: "bob" });
expect(banner.style.display).not.toBe("none");
// The actual ringer's own call_declined (e.g. a glare decline) still
// cancels it.
ws.emit("call_declined", { channel_id: 50, from_user: 10, username: "alice" });
expect(banner.style.display).toBe("none");
});
it("does not cancel an incoming ring on a voice_leave for a different channel from the ringer (OC-0011)", () => {
const ws = fakeWs();
uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected" }));
page = createMainPage({ ws, api: fakeApi() });
page.mount(container);
// Alice (10) rings this client's DM (channel 50).
ws.emit("call_incoming", { channel_id: 50, from_user: 10, username: "alice" });
const banner = document.querySelector('[data-testid="incoming-call-banner"]') as HTMLElement;
expect(banner.style.display).not.toBe("none");
// Alice also happens to be sitting in an unrelated server voice channel
// (99) and leaves it. Same user, wrong channel: this must not silence
// the DM ring — only a voice_leave for the ring's own channel (50) may.
ws.emit("voice_leave", { channel_id: 99, user_id: 10 });
expect(banner.style.display).not.toBe("none");
// Alice leaving the ring's own channel (the DM she was calling from)
// still cancels it.
ws.emit("voice_leave", { channel_id: 50, user_id: 10 });
expect(banner.style.display).toBe("none");
});
it("clears settingsOpen on destroy so the next page (e.g. ConnectPage after logout) doesn't inherit a stale open overlay", () => {
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
page.mount(container);
uiStore.setState((prev) => ({ ...prev, settingsOpen: true }));
page.destroy?.();
expect(uiStore.getState().settingsOpen).toBe(false);
});
it("scopes per-user volume prefs to the connected host, like channel mutes and the NSFW gate (B3-6)", () => {
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
page.mount(container);
expect(mockSetAudioVolumeHost).toHaveBeenCalledWith("");
});
it("closes an open image lightbox on destroy so it doesn't survive onto the next page (B6-15)", () => {
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
page.mount(container);
openImageLightbox("https://example.com/pic.png", "pic");
expect(document.body.querySelector(".image-lightbox")).not.toBeNull();
page.destroy?.();
expect(document.body.querySelector(".image-lightbox")).toBeNull();
});
it("scopes DM profile notes to the connected host, like channel mutes and the NSFW gate (OC-0143)", () => {
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(60, dmChannel(60, "dm-carol"));
return { ...prev, channels: ch, activeChannelId: 60 };
});
dmStore.setState(() => ({
channels: [
{
channelId: 60,
recipient: { id: 5, username: "carol", avatar: "", status: "online" },
participants: [{ id: 5, username: "carol", avatar: "", status: "online" }],
name: "carol",
isGroup: false,
lastMessageId: null,
lastMessage: "",
lastMessageAt: "",
unreadCount: 0,
mentionCount: 0,
},
],
}));
const hostedApi = {
getConfig: () => ({ host: "chat.example.com" }),
getReactionUsers: vi.fn(async () => ({ users: [] })),
} as unknown as ApiClient;
page = createMainPage({ ws: fakeWs(), api: hostedApi });
page.mount(container);
const chatAreaOpts = mockCreateChatArea.mock.calls[0]![0];
chatAreaOpts.onToggleDmProfile();
const noteEl = capturedChatAreaRef.current!.dmProfileSlot.querySelector(
'[data-testid="dps-note"]',
) as HTMLTextAreaElement;
noteEl.value = "owes me money";
noteEl.dispatchEvent(new Event("input"));
try {
// Server A's note about user 5 must land under a key scoped to server A
// — the same host scoping already applied to channel mutes, the NSFW
// gate and per-user volume (setChannelMutesHost/setNsfwGateHost/
// setAudioVolumeHost, all called with apiConfig.host above).
expect(localStorage.getItem("owncord:dm-note:chat.example.com:5")).toBe("owes me money");
// And it must not have gone to the legacy unscoped key, which server
// B's unrelated user 5 would also read from.
expect(localStorage.getItem("owncord:dm-note:5")).toBeNull();
} finally {
localStorage.removeItem("owncord:dm-note:chat.example.com:5");
localStorage.removeItem("owncord:dm-note:5");
}
});
});
describe("MainPage — presence", () => {
let container: HTMLDivElement;
let page: ReturnType<typeof createMainPage>;
beforeEach(() => {
resetStores();
// Match the client's own idea of the signed-in user's status to the
// freshly-reset (default "online") saved preference, so the mount-time
// restoreSavedPresence() call (MainPage.ts:340) is the no-op it
// documents itself as being, and doesn't spend the single presence
// token before this test gets to exercise applyPresence directly.
authStore.setState((prev) => ({
...prev,
user: prev.user === null ? null : { ...prev.user, status: "online" },
}));
capturedAutoIdleOptions.current = null;
container = document.createElement("div");
document.body.appendChild(container);
vi.useFakeTimers();
});
afterEach(() => {
page?.destroy?.();
container.remove();
vi.useRealTimers();
localStorage.removeItem("owncord:settings:userStatus");
localStorage.removeItem("owncord:settings:userStatusOrigin");
});
it("retries the return-to-online presence_update once the limiter's window reopens instead of dropping it forever (OC-0111)", () => {
const ws = fakeWs();
page = createMainPage({ ws, api: fakeApi() });
page.mount(container);
const onStatusChange = capturedAutoIdleOptions.current!.onStatusChange;
// Auto-idle fires idle after ten quiet minutes; this consumes the single
// presence token (1 per 10s, rate-limiter.ts).
saveUserStatus("idle", "auto");
onStatusChange("idle");
expect(ws.send).toHaveBeenCalledWith({
type: "presence_update",
payload: { status: "idle" },
});
(ws.send as ReturnType<typeof vi.fn>).mockClear();
// The very first mouse event after that flips back to online milliseconds
// later (autoIdle.ts's unthrottled return-to-activity path) — the token
// is still gone, so the frame cannot go out immediately.
saveUserStatus("online", "manual");
onStatusChange("online");
expect(ws.send).not.toHaveBeenCalled();
// Once the limiter's 10s window reopens, the deferred "online" frame must
// still go out — without a retry the server and every other client stay
// stuck on "idle" forever with no further trigger to correct it.
vi.advanceTimersByTime(10_000);
expect(ws.send).toHaveBeenCalledWith({
type: "presence_update",
payload: { status: "online" },
});
});
});