Files
OwnCord/Client/tauri-client/tests/unit/voice-widget.test.ts
T
J3vbandClaude Opus 5 ea0430c5b0 fix: batch of correctness fixes across server and client (#1375)
* fix(client): 1 defect(s) (OC-0201)

* fix(service): 1 defect(s) (OC-0202)

HandleTyping built the per-user-per-channel rate-limit key before resolving the channel or checking read permission, so forged channel ids could pin unbounded dead entries in the shared process-wide RateLimiter.

* fix(client): 2 defect(s) (OC-0203, OC-0224)

* fix(server): 1 defect(s) (OC-0204)

* fix(ws): 2 defect(s) (OC-0205, OC-0211)

* fix(admin): 2 defect(s) (OC-0209, OC-0212)

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

* fix(db): 1 defect(s) (OC-0213)

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

Route handler-driven PresenceEvent through BroadcastToAll instead of BroadcastToAllLow so every source of a user's presence shares one ordered per-client FIFO.

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

PATCH /users/{id} combining banned + role_id committed and broadcast the ban before authorizing the role change, so a refused role change returned an error while leaving the target banned. Authorize the role change up front via the new ModerationService.AuthorizeRoleChange.

* fix(db): 1 defect(s) (OC-0216)

LinkAttachmentsToMessage no longer claims an attachment that is a user's live avatar (users.avatar points at it). Once message_id is set, handleServeFile's avatar branch (gated on ChannelID == nil) is unreachable and the file falls under the message's channel ACL / soft-delete state, permanently disagreeing with users.avatar about who may read it.

* fix(emoji): 1 defect(s) (OC-0217)

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

The data-copy phase of an HTTP proxy tunnel was unbounded. Steps 1-2 of
handle_connection (header read, TCP connect, TLS handshake) each run under
a 10s guard, but step 3 called io::copy_bidirectional with no deadline. A
remote that completes the TLS handshake and then neither responds nor
closes parks the spawned connection task, the loopback socket and the
remote TLS session indefinitely: copy_bidirectional only resolves once
BOTH directions finish, so closing the local side alone does not free it.

Wrap the copy in copy_with_deadline, a generic helper bounded by
DATA_PHASE_TIMEOUT (600s). The bound is deliberately far looser than the
10s setup guards because this phase carries the REST body, including
attachment and avatar uploads, so it must reclaim only genuinely stuck
connections rather than merely slow ones. The helper is generic over the
stream types so it can be exercised without a live TLS connection.

Regression test drives two in-memory duplex pairs whose far ends stay
alive, so neither half ever observes EOF and raw copy_bidirectional would
block forever; the test asserts the call resolves on its own deadline with
ErrorKind::TimedOut.

Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

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

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

UpdateNotifier scheduled its deferred update check with a setTimeout whose
handle was never retained, so destroy() could not cancel it. A component torn
down inside the 3s window (page swap / logout) still fired performCheck() and
issued a network update check against the old server URL. Retain the timer
handle and clear it in destroy().

* fix(dm): 1 defect(s) (OC-0222)

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

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

The Grant-Microphone retry's .finally hardcoded grantMicBtn.disabled = false, undoing updateFrozen()'s socket-down freeze when the WS socket dropped while the mic permission request was in flight. Delegate the state back to render().

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

handleApplyUpdate broadcasts a 'restarting in 5s' notice before the on-disk
swap. Every failure path in the swap returned silently, leaving clients
counting down to a restart that never happened. Extract the swap into
applyStagedUpdate and send a corrective 'update_aborted' broadcast from a
deferred guard on every path that does not reach the respawn.

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

PATCH /channels/{id} accepted a blank or whitespace-only name, leaving the
channel unidentifiable in clients. updateChannelRequest.validate() now
rejects it the way handleCreateChannel already did.

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

* fix(admin): run deferred cleanup before the update restart exits

The fix batch left three golangci-lint findings and two prettier findings
that CI gates on.

applyStagedUpdate called os.Exit(0) in the same function that defers both
staged.Close() and the corrective "update_aborted" broadcast, so neither
ran (gocritic exitAfterDefer). Return a bool instead and let the caller
exit once those defers have run — on Windows, releasing the staged binary's
file handle is the reason the restart exists at all, so this is a real fix
rather than a lint appeasement. The exported test hook calls the function as
a statement, so the added result does not affect it.

Also modernize a bulk-insert loop to range-over-int, compare backup bytes
with bytes.Equal, and reflow two test files to prettier's output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

* test(ws): pin the live presence path against the invisible custom-status leak

OC-0207 and OC-0211 are the same defect at two emitters: hub_broadcast.go's
BroadcastPresence (connect/reconnect) and event.go's presenceEvents (live
presence_update). The fix for OC-0211 closed both sites in one change, but
only the hub_broadcast side got a regression test.

This pins the event.go sibling: an invisible user's real custom status must
be blanked on the PresenceOthersEvent frame while the owner's own
PresenceSelfEvent still carries it. Without it, a later change could reopen
the live path while the committed test kept passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

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

* test(ws): silence a contextcheck false positive in the reconnect race test

RefreshChannelVisibility takes no context by design — it is reached through
the admin HubBroadcaster interface, which carries none, so it builds its own
internally. contextcheck flags the call only because the test closure around
it holds a ctx for its override write, so there is nothing to propagate.
Suppress at the call site rather than widen a production interface (and its
mocks) to satisfy a lint in a test.

golangci-lint v2.11.3 (the version ci.yml pins) now reports 0 issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-15 16:30:05 +02:00

904 lines
26 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Must mock before imports
const mockRetryMicPermission = vi.fn().mockResolvedValue(undefined);
vi.mock("@lib/livekitSession", () => ({
getRoomForStats: vi.fn().mockReturnValue(null),
retryMicPermission: (...args: unknown[]) => mockRetryMicPermission(...args),
joinVoice: vi.fn(),
leaveVoice: vi.fn(),
toggleMute: vi.fn(),
toggleDeafen: vi.fn(),
}));
vi.mock("@lib/connectionStats", () => ({
createConnectionStatsPoller: vi.fn().mockReturnValue({
start: vi.fn(),
stop: vi.fn(),
getStats: vi.fn().mockReturnValue({
rtt: 0,
quality: "excellent",
outRate: 0,
inRate: 0,
outPackets: 0,
inPackets: 0,
totalUp: 0,
totalDown: 0,
}),
onUpdate: vi.fn().mockReturnValue(() => {}),
onQualityChanged: vi.fn().mockReturnValue(() => {}),
}),
formatBytes: vi.fn((v: number) => `${v} B`),
formatRate: vi.fn((v: number) => `${v} B/s`),
formatBitrate: vi.fn((v: number) => `${v} bps`),
}));
import { createVoiceWidget } from "../../src/components/VoiceWidget";
import { voiceStore, type VoiceStatus } from "../../src/stores/voice.store";
import { channelsStore } from "../../src/stores/channels.store";
import { membersStore } from "../../src/stores/members.store";
import { uiStore, setConnectionStatus } from "../../src/stores/ui.store";
import type { VoiceUser } from "../../src/stores/voice.store";
function resetStores(): void {
voiceStore.setState(() => ({
currentChannelId: null,
voiceUsers: new Map(),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
channelsStore.setState(() => ({
channels: new Map(),
activeChannelId: null,
roles: [],
}));
membersStore.setState(() => ({
members: new Map(),
typingUsers: new Map(),
}));
// Voice controls freeze when the socket is down; keep it live by default so
// the interaction tests below operate on enabled controls.
setConnectionStatus("connected");
}
function setVoiceStatus(status: VoiceStatus): void {
voiceStore.setState((prev) => ({ ...prev, voiceStatus: status }));
voiceStore.flush();
}
function setVoiceChannel(channelId: number, users: VoiceUser[]): void {
const userMap = new Map<number, VoiceUser>();
for (const u of users) {
userMap.set(u.userId, u);
}
const voiceUsers = new Map<number, ReadonlyMap<number, VoiceUser>>();
voiceUsers.set(channelId, userMap);
voiceStore.setState((prev) => ({
...prev,
currentChannelId: channelId,
voiceUsers,
}));
}
describe("VoiceWidget", () => {
let container: HTMLDivElement;
beforeEach(() => {
resetStores();
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("renders hidden when not connected to a voice channel", () => {
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const root = container.querySelector('[data-testid="voice-widget"]');
expect(root).not.toBeNull();
expect(root!.classList.contains("visible")).toBe(false);
widget.destroy?.();
});
it("shows visible when connected to a voice channel", () => {
channelsStore.setState((prev) => {
const channels = new Map(prev.channels);
channels.set(1, {
id: 1,
name: "Voice Lobby",
type: "voice",
category: null,
position: 0,
unreadCount: 0,
mentionCount: 0,
lastMessageId: null,
canSend: true,
topic: "",
slowMode: 0,
nsfw: false,
voiceMaxUsers: 0,
voiceMaxVideo: 0,
});
return { ...prev, channels };
});
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const root = container.querySelector('[data-testid="voice-widget"]');
expect(root!.classList.contains("visible")).toBe(true);
widget.destroy?.();
});
it("displays channel name", () => {
channelsStore.setState((prev) => {
const channels = new Map(prev.channels);
channels.set(1, {
id: 1,
name: "Voice Lobby",
type: "voice",
category: null,
position: 0,
unreadCount: 0,
mentionCount: 0,
lastMessageId: null,
canSend: true,
topic: "",
slowMode: 0,
nsfw: false,
voiceMaxUsers: 0,
voiceMaxVideo: 0,
});
return { ...prev, channels };
});
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const channelName = container.querySelector(".vw-channel");
expect(channelName?.textContent).toBe("Voice Lobby");
widget.destroy?.();
});
it("does not render voice users (users only shown in sidebar)", () => {
channelsStore.setState((prev) => {
const channels = new Map(prev.channels);
channels.set(1, {
id: 1,
name: "Voice Lobby",
type: "voice",
category: null,
position: 0,
unreadCount: 0,
mentionCount: 0,
lastMessageId: null,
canSend: true,
topic: "",
slowMode: 0,
nsfw: false,
voiceMaxUsers: 0,
voiceMaxVideo: 0,
});
return { ...prev, channels };
});
setVoiceChannel(1, [
{
userId: 10,
username: "Alice",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
},
]);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const userItems = container.querySelectorAll(".voice-user-item");
expect(userItems.length).toBe(0);
widget.destroy?.();
});
it("calls onMuteToggle when mute button is clicked", () => {
const onMuteToggle = vi.fn();
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle,
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const muteBtn = container.querySelector('[aria-label="Mute"]') as HTMLButtonElement;
expect(muteBtn).not.toBeNull();
muteBtn.click();
expect(onMuteToggle).toHaveBeenCalledOnce();
widget.destroy?.();
});
it("calls onDisconnect when disconnect button is clicked", () => {
const onDisconnect = vi.fn();
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect,
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const disconnectBtn = container.querySelector('[aria-label="Disconnect"]') as HTMLButtonElement;
expect(disconnectBtn).not.toBeNull();
disconnectBtn.click();
expect(onDisconnect).toHaveBeenCalledOnce();
widget.destroy?.();
});
it("toggles mute active state based on store", () => {
setVoiceChannel(1, []);
voiceStore.setState((prev) => ({ ...prev, localMuted: true }));
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const muteBtn = container.querySelector('[aria-label="Mute"]') as HTMLButtonElement;
expect(muteBtn.classList.contains("active-ctrl")).toBe(true);
widget.destroy?.();
});
it("toggles screenshare active state based on store", () => {
setVoiceChannel(1, []);
voiceStore.setState((prev) => ({ ...prev, localScreenshare: true }));
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const screenshareBtn = container.querySelector(
'[aria-label="Screenshare"]',
) as HTMLButtonElement;
expect(screenshareBtn).not.toBeNull();
expect(screenshareBtn.classList.contains("active-ctrl")).toBe(true);
expect(screenshareBtn.getAttribute("aria-pressed")).toBe("true");
widget.destroy?.();
});
it("cleans up on destroy", () => {
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const root = container.querySelector('[data-testid="voice-widget"]');
expect(root).not.toBeNull();
widget.destroy?.();
expect(container.querySelector('[data-testid="voice-widget"]')).toBeNull();
});
it("calls onDeafenToggle when deafen button is clicked", () => {
const onDeafenToggle = vi.fn();
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle,
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const deafenBtn = container.querySelector('[aria-label="Deafen"]') as HTMLButtonElement;
expect(deafenBtn).not.toBeNull();
deafenBtn.click();
expect(onDeafenToggle).toHaveBeenCalledOnce();
widget.destroy?.();
});
it("calls onCameraToggle when camera button is clicked", () => {
const onCameraToggle = vi.fn();
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle,
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const cameraBtn = container.querySelector('[aria-label="Camera"]') as HTMLButtonElement;
expect(cameraBtn).not.toBeNull();
cameraBtn.click();
expect(onCameraToggle).toHaveBeenCalledOnce();
widget.destroy?.();
});
it("calls onScreenshareToggle when screenshare button is clicked", () => {
const onScreenshareToggle = vi.fn();
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle,
});
widget.mount(container);
const shareBtn = container.querySelector('[aria-label="Screenshare"]') as HTMLButtonElement;
expect(shareBtn).not.toBeNull();
shareBtn.click();
expect(onScreenshareToggle).toHaveBeenCalledOnce();
widget.destroy?.();
});
it("toggles deafen active state based on store", () => {
setVoiceChannel(1, []);
voiceStore.setState((prev) => ({ ...prev, localDeafened: true }));
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const deafenBtn = container.querySelector('[aria-label="Deafen"]') as HTMLButtonElement;
expect(deafenBtn.classList.contains("active-ctrl")).toBe(true);
expect(deafenBtn.getAttribute("aria-pressed")).toBe("true");
widget.destroy?.();
});
it("toggles camera active state based on store", () => {
setVoiceChannel(1, []);
voiceStore.setState((prev) => ({ ...prev, localCamera: true }));
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const cameraBtn = container.querySelector('[aria-label="Camera"]') as HTMLButtonElement;
expect(cameraBtn.classList.contains("active-ctrl")).toBe(true);
expect(cameraBtn.getAttribute("aria-pressed")).toBe("true");
widget.destroy?.();
});
it("falls back to 'Voice Channel' when channel is not in store", () => {
// Don't add channel to channelsStore
setVoiceChannel(999, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const channelName = container.querySelector(".vw-channel");
expect(channelName?.textContent).toBe("Voice Channel");
widget.destroy?.();
});
it("shows 'Grant Microphone' button when in listen-only mode", () => {
setVoiceChannel(1, []);
voiceStore.setState((prev) => ({ ...prev, listenOnly: true }));
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const grantBtn = container.querySelector(".vw-grant-mic") as HTMLButtonElement;
expect(grantBtn).not.toBeNull();
expect(grantBtn.style.display).toBe("block");
expect(grantBtn.textContent).toBe("Grant Microphone");
widget.destroy?.();
});
it("hides 'Grant Microphone' button when not in listen-only mode", () => {
setVoiceChannel(1, []);
voiceStore.setState((prev) => ({ ...prev, listenOnly: false }));
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const grantBtn = container.querySelector(".vw-grant-mic") as HTMLButtonElement;
expect(grantBtn.style.display).toBe("none");
widget.destroy?.();
});
it("clicking 'Grant Microphone' calls retryMicPermission", async () => {
setVoiceChannel(1, []);
voiceStore.setState((prev) => ({ ...prev, listenOnly: true }));
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const grantBtn = container.querySelector(".vw-grant-mic") as HTMLButtonElement;
grantBtn.click();
expect(mockRetryMicPermission).toHaveBeenCalledOnce();
// Wait for the promise to resolve
await vi.waitFor(() => {
expect(grantBtn.textContent).toBe("Grant Microphone");
expect(grantBtn.disabled).toBe(false);
});
widget.destroy?.();
});
it("displays stats pane toggle on signal icon click", () => {
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const statsPane = container.querySelector(".vw-stats") as HTMLDivElement;
expect(statsPane).not.toBeNull();
// Initially hidden
expect(statsPane.classList.contains("visible")).toBe(false);
// Click signal icon to toggle stats visibility
const signalWrap = container.querySelector(".vw-signal") as HTMLDivElement;
signalWrap.click();
expect(statsPane.classList.contains("visible")).toBe(true);
// Click again to hide
signalWrap.click();
expect(statsPane.classList.contains("visible")).toBe(false);
widget.destroy?.();
});
it("displays elapsed timer element with initial 00:00", () => {
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const timerEl = container.querySelector(".vw-timer");
expect(timerEl).not.toBeNull();
// Timer starts and updates; with joinedAt=null the timer shows "00:00"
// or if joinedAt is set, shows elapsed
expect(timerEl?.textContent).toMatch(/^\d{2}:\d{2}/);
widget.destroy?.();
});
it("hides widget and stops timer when voice channel is left", () => {
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const root = container.querySelector('[data-testid="voice-widget"]') as HTMLDivElement;
expect(root.classList.contains("visible")).toBe(true);
// Leave voice channel
voiceStore.setState((prev) => ({ ...prev, currentChannelId: null }));
voiceStore.flush();
expect(root.classList.contains("visible")).toBe(false);
widget.destroy?.();
});
it("contains transport stats labels (Outgoing, Incoming, Session Totals)", () => {
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const statsPane = container.querySelector(".vw-stats") as HTMLDivElement;
expect(statsPane.textContent).toContain("Transport Statistics");
expect(statsPane.textContent).toContain("Outgoing");
expect(statsPane.textContent).toContain("Incoming");
expect(statsPane.textContent).toContain("Session Totals");
widget.destroy?.();
});
it("reacts to voice store changes for mute toggle", () => {
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const muteBtn = container.querySelector('[aria-label="Mute"]') as HTMLButtonElement;
expect(muteBtn.classList.contains("active-ctrl")).toBe(false);
// Toggle mute in store
voiceStore.setState((prev) => ({ ...prev, localMuted: true }));
voiceStore.flush();
expect(muteBtn.classList.contains("active-ctrl")).toBe(true);
expect(muteBtn.getAttribute("aria-pressed")).toBe("true");
widget.destroy?.();
});
// --- E2EE / voice-session status (docs/architecture/ux/voice-and-e2ee.md §2) ---
it("shows 'Connecting…' during the joining phase, no secured badge", () => {
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
setVoiceStatus("joining");
const status = container.querySelector('[data-testid="vw-status"]');
const secured = container.querySelector('[data-testid="vw-secured"]') as HTMLElement;
expect(status?.textContent).toBe("Connecting…");
expect(secured.style.display).toBe("none");
widget.destroy?.();
});
it("shows 'Securing…' while E2EE key exchange runs, no secured badge yet", () => {
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
setVoiceStatus("securing");
const status = container.querySelector('[data-testid="vw-status"]') as HTMLElement;
const secured = container.querySelector('[data-testid="vw-secured"]') as HTMLElement;
expect(status.textContent).toBe("Securing…");
expect(status.classList.contains("vw-securing")).toBe(true);
expect(secured.style.display).toBe("none");
widget.destroy?.();
});
it("shows a persistent secured indicator once connected", () => {
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
setVoiceStatus("connected");
const status = container.querySelector('[data-testid="vw-status"]');
const secured = container.querySelector('[data-testid="vw-secured"]') as HTMLElement;
expect(status?.textContent).toBe("Voice Connected");
expect(secured.style.display).toBe("inline-flex");
expect(secured.textContent).toContain("Secured");
widget.destroy?.();
});
// OC-0002: the badge was derived purely from voiceStatus === "connected",
// never from the SDK's live encryption state — a dead E2EE worker that
// fails asynchronously (after the key exchange and voiceStatus already
// reached "connected") had no way to ever un-light the badge.
it("shows a not-secured warning instead of the Secured badge once encryption degrades", () => {
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
setVoiceStatus("connected");
const secured = container.querySelector('[data-testid="vw-secured"]') as HTMLElement;
expect(secured.textContent).toContain("Secured");
voiceStore.setState((prev) => ({ ...prev, encryptionDegraded: true }));
voiceStore.flush();
// Still shown (so the warning is not silently missed), but no longer
// claiming "Secured" — a dead worker must never look like a live one.
expect(secured.style.display).toBe("inline-flex");
expect(secured.textContent).not.toContain("Secured");
widget.destroy?.();
});
it("shows 'Reconnecting voice…' during a voice reconnect", () => {
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
setVoiceStatus("reconnecting");
const status = container.querySelector('[data-testid="vw-status"]') as HTMLElement;
const secured = container.querySelector('[data-testid="vw-secured"]') as HTMLElement;
expect(status.textContent).toBe("Reconnecting voice…");
expect(status.classList.contains("vw-reconnecting")).toBe(true);
expect(secured.style.display).toBe("none");
widget.destroy?.();
});
// --- Freeze controls during WS reconnect (docs/architecture/ux/README.md §3) ---
it("disables voice controls with a reason while the WS socket is reconnecting", () => {
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
setConnectionStatus("reconnecting");
uiStore.flush();
const muteBtn = container.querySelector('[aria-label="Mute"]') as HTMLButtonElement;
const disconnectBtn = container.querySelector('[aria-label="Disconnect"]') as HTMLButtonElement;
expect(muteBtn.disabled).toBe(true);
expect(muteBtn.title).toBe("Reconnecting…");
expect(disconnectBtn.disabled).toBe(true);
widget.destroy?.();
});
it("shows 'Not connected' reason while the WS socket is disconnected", () => {
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
setConnectionStatus("disconnected");
uiStore.flush();
const muteBtn = container.querySelector('[aria-label="Mute"]') as HTMLButtonElement;
expect(muteBtn.disabled).toBe(true);
expect(muteBtn.title).toBe("Not connected");
widget.destroy?.();
});
it("re-enables voice controls when the WS socket reconnects", () => {
setVoiceChannel(1, []);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
setConnectionStatus("reconnecting");
uiStore.flush();
const muteBtn = container.querySelector('[aria-label="Mute"]') as HTMLButtonElement;
expect(muteBtn.disabled).toBe(true);
setConnectionStatus("connected");
uiStore.flush();
expect(muteBtn.disabled).toBe(false);
expect(muteBtn.title).toBe("");
widget.destroy?.();
});
// OC-0225: the Grant-Microphone retry's `.finally` used to hardcode
// `grantMicBtn.disabled = false`, undoing updateFrozen's socket-down
// freeze if the WS socket dropped while the permission request was in
// flight.
it("keeps 'Grant Microphone' frozen if the WS socket drops while a mic request is in flight", async () => {
setVoiceChannel(1, []);
voiceStore.setState((prev) => ({ ...prev, listenOnly: true }));
let resolveMic: () => void = () => {};
mockRetryMicPermission.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveMic = resolve;
}),
);
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const grantBtn = container.querySelector(".vw-grant-mic") as HTMLButtonElement;
grantBtn.click();
expect(grantBtn.disabled).toBe(true);
// WS socket drops while the permission request (OS/browser prompt) is
// still pending.
setConnectionStatus("reconnecting");
uiStore.flush();
expect(grantBtn.disabled).toBe(true);
expect(grantBtn.title).toBe("Reconnecting…");
// Permission request settles (retryMicPermission always resolves, even
// on a denied prompt, per its internal try/catch).
resolveMic();
await vi.waitFor(() => {
expect(grantBtn.textContent).toBe("Grant Microphone");
});
// The socket is still down: the button must stay frozen with the
// reconnecting reason, not silently re-enabled.
expect(grantBtn.disabled).toBe(true);
expect(grantBtn.title).toBe("Reconnecting…");
widget.destroy?.();
});
});