mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* 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>
681 lines
24 KiB
TypeScript
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" },
|
|
});
|
|
});
|
|
});
|