test(voice): pin voiceStatus transitions, widget indicators, and control freeze

- livekit-session: assert joining→securing→connected on join, idle on leave,
  connected after auto-reconnect
- voice-widget: assert each status label + secured badge visibility, and controls
  disabled with reason while the socket is down, re-enabled on reconnect
- voice-callbacks: assert join/leave/disconnect do not send over a down socket
- voice.store: assert join seeds joining, leave resets idle, setter writes status
- thread voiceStatus through existing full-state fixtures; keep an active-call
  socket live in widget fixtures so control-click tests still operate enabled

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-20 09:31:27 +02:00
co-authored by Claude Fable 5
parent 827eea77ed
commit ff55da7161
12 changed files with 313 additions and 1 deletions
@@ -59,6 +59,7 @@ const VOICE_INITIAL: VoiceState = {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
};
const UI_INITIAL: UiState = {
@@ -134,6 +134,7 @@ function resetAllStores(): void {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
authStore.setState(() => ({
token: null,
@@ -59,6 +59,7 @@ function resetStores(): void {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
membersStore.setState(() => ({
members: new Map(),
@@ -1072,6 +1073,7 @@ describe("ChannelSidebar", () => {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebarWithWatch.mount(container);
@@ -1116,6 +1118,7 @@ describe("ChannelSidebar", () => {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebarWithWatch.mount(container);
@@ -1166,6 +1169,7 @@ describe("ChannelSidebar", () => {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebar.mount(container);
@@ -1207,6 +1211,7 @@ describe("ChannelSidebar", () => {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebarWithWatch.mount(container);
@@ -1248,6 +1253,7 @@ describe("ChannelSidebar", () => {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebar.mount(container);
@@ -130,6 +130,7 @@ describe("WS Dispatcher", () => {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
dmStore.setState(() => ({ channels: [] }));
uiStore.setState((prev) => ({ ...prev, transientError: null }));
@@ -85,6 +85,7 @@ vi.mock("@stores/voice.store", () => ({
setSpeakers: vi.fn(),
leaveVoiceChannel: vi.fn(),
setListenOnly: vi.fn(),
setVoiceStatus: vi.fn(),
}));
const mockInvoke = vi.hoisted(() =>
@@ -149,6 +150,7 @@ import {
setLocalScreenshare,
setListenOnly,
leaveVoiceChannel,
setVoiceStatus,
} from "@stores/voice.store";
import {
isVoiceConnected,
@@ -651,6 +653,59 @@ describe("LiveKitSession", () => {
});
});
describe("voiceStatus transitions (voice-and-e2ee.md §12)", () => {
function statusCalls(): string[] {
return (setVoiceStatus as any).mock.calls.map((c: unknown[]) => c[0] as string);
}
it("writes joining → securing → connected on a successful join", async () => {
session.setServerHost("localhost:7880");
session.setWsClient({ send: vi.fn() } as any);
(setVoiceStatus as any).mockClear();
await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true);
const calls = statusCalls();
expect(calls).toContain("joining");
expect(calls).toContain("securing");
expect(calls).toContain("connected");
// Ordering: joining before securing before connected.
expect(calls.indexOf("joining")).toBeLessThan(calls.indexOf("securing"));
expect(calls.indexOf("securing")).toBeLessThan(calls.indexOf("connected"));
});
it("writes idle on leaveVoice", () => {
(setVoiceStatus as any).mockClear();
session.leaveVoice(false);
expect(setVoiceStatus).toHaveBeenCalledWith("idle");
});
it("writes connected after a successful auto-reconnect", async () => {
(session as any)._state = {
type: "reconnecting",
channelId: 7,
latestToken: "reconnect-token",
lastUrl: "/livekit",
lastDirectUrl: "ws://localhost:7880",
ac: new AbortController(),
};
(setVoiceStatus as any).mockClear();
const ac = new AbortController();
const reconnectPromise = (session as any).attemptAutoReconnect(
"reconnect-token",
"/livekit",
7,
"ws://localhost:7880",
ac.signal,
);
await vi.advanceTimersByTimeAsync(3100);
await reconnectPromise;
expect(setVoiceStatus).toHaveBeenCalledWith("connected");
});
});
describe("handleVoiceTokenRefresh", () => {
it("stores the token and restarts the timer", () => {
session.handleVoiceTokenRefresh("new-token");
@@ -36,6 +36,7 @@ import { createVoiceWidget } from "@components/VoiceWidget";
import { voiceStore } from "@stores/voice.store";
import { channelsStore } from "@stores/channels.store";
import { membersStore } from "@stores/members.store";
import { setConnectionStatus } from "@stores/ui.store";
function resetStores(): void {
voiceStore.setState(() => ({
@@ -48,6 +49,7 @@ function resetStores(): void {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
channelsStore.setState(() => ({
channels: new Map(),
@@ -58,6 +60,8 @@ function resetStores(): void {
members: new Map(),
typingUsers: new Map(),
}));
// Voice controls freeze while the socket is down; an active call is live.
setConnectionStatus("connected");
}
function setVoiceConnected(screenshare = false): void {
@@ -215,6 +215,7 @@ function resetStores(): void {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
localStorage.removeItem("owncord:member-list-height");
localStorage.removeItem("owncord:member-list-collapsed");
@@ -15,6 +15,7 @@ const {
mockDisableCamera,
mockEnableScreenshare,
mockDisableScreenshare,
mockUiGetState,
} = vi.hoisted(() => ({
mockVoiceStoreGetState: vi.fn(),
mockJoinVoiceChannel: vi.fn(),
@@ -26,6 +27,7 @@ const {
mockDisableCamera: vi.fn(() => Promise.resolve()),
mockEnableScreenshare: vi.fn(() => Promise.resolve()),
mockDisableScreenshare: vi.fn(() => Promise.resolve()),
mockUiGetState: vi.fn(() => ({ connectionStatus: "connected" })),
}));
vi.mock("@lib/logger", () => ({
@@ -43,6 +45,10 @@ vi.mock("@stores/voice.store", () => ({
leaveVoiceChannel: mockLeaveVoiceChannel,
}));
vi.mock("@stores/ui.store", () => ({
uiStore: { getState: mockUiGetState },
}));
vi.mock("@lib/livekitSession", () => ({
leaveVoice: mockVoiceSessionLeave,
setMuted: mockSetMuted,
@@ -105,6 +111,7 @@ describe("createVoiceWidgetCallbacks", () => {
beforeEach(() => {
vi.clearAllMocks();
mockVoiceStoreGetState.mockReturnValue(makeVoiceState());
mockUiGetState.mockReturnValue({ connectionStatus: "connected" });
});
describe("onDisconnect", () => {
@@ -128,6 +135,17 @@ describe("createVoiceWidgetCallbacks", () => {
expect(ws.send).not.toHaveBeenCalled();
});
it("does not send over a down socket while reconnecting", () => {
mockUiGetState.mockReturnValue({ connectionStatus: "reconnecting" });
const ws = makeWs();
const cbs = createVoiceWidgetCallbacks(ws, makeLimiters());
cbs.onDisconnect();
expect(mockVoiceSessionLeave).not.toHaveBeenCalled();
expect(ws.send).not.toHaveBeenCalled();
});
});
describe("onMuteToggle", () => {
@@ -276,6 +294,7 @@ describe("createVoiceWidgetCallbacks", () => {
describe("createSidebarVoiceCallbacks", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUiGetState.mockReturnValue({ connectionStatus: "connected" });
});
it("onVoiceJoin sends voice_join and updates store", () => {
@@ -301,4 +320,26 @@ describe("createSidebarVoiceCallbacks", () => {
expect(mockLeaveVoiceChannel).toHaveBeenCalled();
expect(ws.send).toHaveBeenCalledWith({ type: "voice_leave", payload: {} });
});
it("onVoiceJoin does not send over a down socket", () => {
mockUiGetState.mockReturnValue({ connectionStatus: "disconnected" });
const ws = makeWs();
const cbs = createSidebarVoiceCallbacks(ws);
cbs.onVoiceJoin(42);
expect(mockJoinVoiceChannel).not.toHaveBeenCalled();
expect(ws.send).not.toHaveBeenCalled();
});
it("onVoiceLeave does not send over a down socket", () => {
mockUiGetState.mockReturnValue({ connectionStatus: "reconnecting" });
const ws = makeWs();
const cbs = createSidebarVoiceCallbacks(ws);
cbs.onVoiceLeave();
expect(mockVoiceSessionLeave).not.toHaveBeenCalled();
expect(ws.send).not.toHaveBeenCalled();
});
});
@@ -33,6 +33,7 @@ function resetStores(): void {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
membersStore.setState(() => ({
members: new Map(),
@@ -23,6 +23,7 @@ function resetStores(): void {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
authStore.setState(() => ({
token: null,
@@ -34,9 +34,10 @@ vi.mock("@lib/connectionStats", () => ({
}));
import { createVoiceWidget } from "../../src/components/VoiceWidget";
import { voiceStore } from "../../src/stores/voice.store";
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 {
@@ -50,6 +51,7 @@ function resetStores(): void {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
channelsStore.setState(() => ({
channels: new Map(),
@@ -60,6 +62,14 @@ function resetStores(): void {
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 {
@@ -624,4 +634,170 @@ describe("VoiceWidget", () => {
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?.();
});
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?.();
});
});
@@ -16,6 +16,7 @@ import {
setSpeakers,
setVoiceConfig,
getChannelVoiceUsers,
setVoiceStatus,
} from "../../src/stores/voice.store";
import type { ReadyVoiceState, VoiceStatePayload, VoiceLeavePayload } from "../../src/lib/types";
import { authStore } from "../../src/stores/auth.store";
@@ -31,6 +32,7 @@ function resetStore(): void {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
}
@@ -476,6 +478,28 @@ describe("voice store", () => {
});
});
describe("voiceStatus", () => {
it("seeds 'joining' optimistically on a fresh join", () => {
expect(voiceStore.getState().voiceStatus).toBe("idle");
joinVoiceChannel(42);
expect(voiceStore.getState().voiceStatus).toBe("joining");
});
it("resets to 'idle' on leaveVoiceChannel", () => {
joinVoiceChannel(42);
setVoiceStatus("connected");
leaveVoiceChannel();
expect(voiceStore.getState().voiceStatus).toBe("idle");
});
it("setVoiceStatus writes the given status", () => {
setVoiceStatus("securing");
expect(voiceStore.getState().voiceStatus).toBe("securing");
setVoiceStatus("reconnecting");
expect(voiceStore.getState().voiceStatus).toBe("reconnecting");
});
});
describe("leaveVoiceChannel — clears user from voiceUsers", () => {
it("removes current user from the channel's voiceUsers map", () => {
authStore.setState(() => ({