Files
OwnCord/Client/tauri-client/tests/unit/screen-share-tracks.test.ts
T
J3vbandClaude Fable 5 b1fb56511d fix(client): batch of 15 client correctness fixes (#1366)
* fix(message-list): rebuild virtual window when scroll leaves the rendered range

Scroll-driven renderWindow calls previously never rebuilt the DOM, so
scrolling past the overscan showed only spacer blank space until an
unrelated data change forced a full re-render. The window now rebuilds
whenever the computed visible range is not fully contained in the
rendered one, keeping the no-op (and the existing rebuild rate limiter)
for ranges that are already rendered.

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

* fix(messages): refetch channel tail when revisiting a previously loaded channel

The server only delivers live message broadcasts for the focused channel,
so a channel's loaded window stops updating once the user switches away.
Switching channels now drops the left channel's loaded flag so the next
visit refetches the live tail, while keeping the old rows rendered until
the refetch merges in (pending/failed rows are preserved).

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

* fix(channel-sidebar): assign distinct slots when reordering channels with tied positions

Categories whose channels share a position value (the server does not
enforce uniqueness, and new channels default to position 0) previously
produced an empty or partial reorder on drop, leaving the final order
ambiguous. Tied slots are now nudged into a strictly increasing sequence
before being reassigned, while already-distinct groups keep their range.

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

* fix(embeds): keep the link-preview abort timer armed until the body is read

The link-preview fetch previously cleared its 5 s abort timer as soon as
response headers arrived, so reading the response body was unbounded in
time and size. The timer is now cleared in a finally after the body read,
so the timeout covers the whole request and the 50 KB parse cap applies
as documented.

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

* fix(message-list): anchor scroll-to-bottom and jump-to-present controls outside the scroller

The two floating controls were appended inside the overflow scroller, so
they were part of its scrollable overflow and translated away with the
content whenever the user scrolled up — precisely when they become
visible. They now anchor to a position:relative frame that wraps the
scroller, keeping them pinned to the viewport edge.

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

* fix(voice): reset the pinned input device before cycling the mic to default

Selecting the default microphone (or losing the selected one to a hot
unplug) only muted and unmuted the existing track, which kept capturing
from the previously pinned device. The shared cycle now resets the
capture device to the system default first so both paths actually reach
it.

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

* fix(voice): re-check supersession after camera publish before announcing camera on

A camera disable that completes while the enable's publishTrack call is
still in flight now causes the enable to unpublish and stop its track and
skip the enabled announcement, so the server's last word matches the local
state instead of reporting a stopped camera as on.

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

* fix(voice): re-check supersession across the screenshare publish loop

A screenshare disable that completes while a publish in the enable loop is
still in flight now stops the loop before the remaining tracks are
published; the enable attempt unpublishes and stops all of its tracks and
skips the enabled announcement, so tracks held only by that attempt are
released and the server's last word matches the local state.

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

* fix(sidebar): rethrow channel modal API failures so modals can recover

The create/edit/delete channel callbacks caught API errors and only
showed a toast, so the awaiting modal never saw the failure and left its
submit button disabled with the in-flight label. The callbacks now
rethrow after toasting, letting each modal re-enable its button and
render its inline error so the user can retry without losing the form.

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

* fix(messages): carry pending and failed rows across the prepend trim

When a scroll-up page pushed a channel past the per-channel cap,
prependMessages trimmed the tail wholesale, deleting pending/failed
optimistic rows that hold the only copy of the user's composed text.
The trim now carries those rows across, matching the other
window-replacing writers.

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

* fix(channel-sidebar): re-resolve the drop container when the sidebar re-renders mid-drag

A store-driven sidebar re-render while a drag is in flight rebuilds the
channel rows, detaching the container captured at mousedown; detached
rows report all-zero rects, so the drop and the hover indicator could
never resolve. The global handlers now re-target the live row, its
container, and the store's current group snapshot before hit-testing.

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

* fix(delete-channel-modal): re-arm the confirm button when a delete fails

The confirm button was only restored from the catch block, so a caller
that handled the failure itself and resolved left the button disabled on
'Deleting...' with no way to retry. Restoration now runs in a finally
block whenever the modal is still open, regardless of how the callback
settled.

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

* fix(dm-sidebar): keep the presence dot when an avatar image loads

The avatar swap cleared the whole circle before inserting the fetched
image, which also removed the online/idle/dnd/offline dot on 1:1 rows.
The initial now lives in its own node and only that node is replaced,
so the presence dot survives the image arriving.

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

* fix(formatting): compute the yesterday boundary from the calendar date

The relative-day fence post was derived by subtracting a fixed 24 hours
from local midnight, which lands inside the wrong calendar day when a
DST transition makes the local day 23 or 25 hours long. It is now built
from the calendar date directly, so hover and expanded message
timestamps keep the correct Yesterday label around transitions.

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

* fix(message-input): keep the empty-edit guard active while attachments are queued

Edits are text-only, so a queued attachment no longer bypasses the
empty-content guard while editing. Submitting an edit whose text was
cleared is now refused with edit mode intact, matching the behavior
when nothing is attached.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-14 05:55:00 +02:00

861 lines
31 KiB
TypeScript

/**
* Tests for the track-publishing half of src/lib/screenShare.ts.
*
* The module sat at 61.1% statements: screen-share-fps.test.ts covers the
* preset/bitrate helpers, but enableCamera, disableCamera, enableScreenshare,
* disableScreenshare, the stopManual* helpers and the stream getters had no
* coverage at all.
*
* The failure paths are the point. BUG-100 (stop the created track when publish
* fails, or the camera light stays on with nothing published) and BUG-101
* (honour the OS "Stop sharing" button) are both regressions that a happy-path
* test would sail straight past.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Track } from "livekit-client";
import type { LocalTrack, LocalVideoTrack, Room } from "livekit-client";
import type { WsClient } from "@lib/ws";
const createLocalVideoTrack = vi.fn();
const createLocalScreenTracks = vi.fn();
const loadPref = vi.fn();
vi.mock("livekit-client", async (importOriginal) => {
const actual = await importOriginal<typeof import("livekit-client")>();
return {
...actual,
createLocalVideoTrack: (...args: unknown[]) => createLocalVideoTrack(...args) as unknown,
createLocalScreenTracks: (...args: unknown[]) => createLocalScreenTracks(...args) as unknown,
};
});
vi.mock("@components/settings/helpers", () => ({
loadPref: (...args: unknown[]) => loadPref(...args) as unknown,
savePref: vi.fn(),
}));
const {
disableCamera,
disableScreenshare,
enableCamera,
enableScreenshare,
getLocalCameraStream,
getLocalScreenshareStream,
getRemoteVideoStream,
stopManualCameraTrack,
stopManualScreenTracks,
rollbackPendingVideo,
} = await import("@lib/screenShare");
type VideoTrackDeps = Parameters<typeof enableCamera>[1];
const { voiceStore } = await import("@stores/voice.store");
// ── fakes ──────────────────────────────────────────────────────────────────
function fakeMediaStreamTrack(): MediaStreamTrack {
const listeners = new Map<string, EventListener>();
return {
addEventListener: (type: string, cb: EventListener) => listeners.set(type, cb),
dispatch: (type: string) => listeners.get(type)?.(new Event(type)),
} as unknown as MediaStreamTrack;
}
function fakeVideoTrack(): LocalVideoTrack & { stop: ReturnType<typeof vi.fn> } {
return {
kind: Track.Kind.Video,
mediaStreamTrack: fakeMediaStreamTrack(),
stop: vi.fn(),
} as unknown as LocalVideoTrack & { stop: ReturnType<typeof vi.fn> };
}
function fakeAudioTrack(): LocalTrack & { stop: ReturnType<typeof vi.fn> } {
return {
kind: Track.Kind.Audio,
mediaStreamTrack: fakeMediaStreamTrack(),
stop: vi.fn(),
} as unknown as LocalTrack & { stop: ReturnType<typeof vi.fn> };
}
interface RoomRig {
room: Room;
publishTrack: ReturnType<typeof vi.fn>;
unpublishTrack: ReturnType<typeof vi.fn>;
setCameraEnabled: ReturnType<typeof vi.fn>;
setScreenShareEnabled: ReturnType<typeof vi.fn>;
}
function fakeRoom(): RoomRig {
const publishTrack = vi.fn().mockResolvedValue(undefined);
const unpublishTrack = vi.fn().mockResolvedValue(undefined);
const setCameraEnabled = vi.fn().mockResolvedValue(undefined);
const setScreenShareEnabled = vi.fn().mockResolvedValue(undefined);
const room = {
localParticipant: {
publishTrack,
unpublishTrack,
setCameraEnabled,
setScreenShareEnabled,
getTrackPublication: () => undefined,
},
remoteParticipants: new Map(),
} as unknown as Room;
return { room, publishTrack, unpublishTrack, setCameraEnabled, setScreenShareEnabled };
}
/**
* Builds a VideoTrackDeps with spies attached. `wsSend` is surfaced directly
* rather than reached through `getWs()`, which narrows to `never` once a test
* passes `hasWs: false`.
*/
function fakeDeps(
room: Room | null,
hasWs = true,
): VideoTrackDeps & {
wsSend: ReturnType<typeof vi.fn>;
onError: ReturnType<typeof vi.fn>;
reapplyAudioPipeline: ReturnType<typeof vi.fn>;
} {
const wsSend = vi.fn();
const ws = hasWs ? ({ send: wsSend } as unknown as WsClient) : null;
return {
getRoom: () => room,
getWs: () => ws,
onError: vi.fn(),
reapplyAudioPipeline: vi.fn(),
wsSend,
};
}
beforeEach(() => {
createLocalVideoTrack.mockReset();
createLocalScreenTracks.mockReset();
loadPref.mockReset().mockReturnValue("");
voiceStore.setState((prev) => ({ ...prev, localCamera: false, localScreenshare: false }));
vi.stubGlobal(
"MediaStream",
class {
tracks: unknown[];
constructor(tracks: unknown[] = []) {
this.tracks = tracks;
}
},
);
});
// ── stopManualCameraTrack ──────────────────────────────────────────────────
describe("stopManualCameraTrack", () => {
it("unpublishes and stops the track", () => {
const rig = fakeRoom();
const track = fakeVideoTrack();
const state = { manualCameraTrack: track };
stopManualCameraTrack(state, rig.room);
expect(rig.unpublishTrack).toHaveBeenCalledWith(track.mediaStreamTrack);
expect(track.stop).toHaveBeenCalled();
expect(state.manualCameraTrack).toBeNull();
});
it("is a no-op with no track", () => {
const rig = fakeRoom();
stopManualCameraTrack({ manualCameraTrack: null }, rig.room);
expect(rig.unpublishTrack).not.toHaveBeenCalled();
});
it("is a no-op with no room", () => {
const track = fakeVideoTrack();
const state = { manualCameraTrack: track };
stopManualCameraTrack(state, null);
expect(track.stop).not.toHaveBeenCalled();
expect(state.manualCameraTrack).toBe(track);
});
it("still stops the track when unpublish throws", () => {
const rig = fakeRoom();
rig.unpublishTrack.mockImplementation(() => {
throw new Error("already unpublished");
});
const track = fakeVideoTrack();
stopManualCameraTrack({ manualCameraTrack: track }, rig.room);
// Stopping is what releases the hardware; it must not be skipped because
// the unpublish leg failed.
expect(track.stop).toHaveBeenCalled();
});
});
// ── enableCamera ───────────────────────────────────────────────────────────
describe("enableCamera", () => {
it("publishes the camera track and announces it over the websocket", async () => {
const rig = fakeRoom();
const deps = fakeDeps(rig.room);
const track = fakeVideoTrack();
createLocalVideoTrack.mockResolvedValue(track);
const state = { manualCameraTrack: null as LocalVideoTrack | null };
await enableCamera(state, deps);
expect(rig.publishTrack).toHaveBeenCalledWith(
track,
expect.objectContaining({ source: Track.Source.Camera }),
);
expect(deps.wsSend).toHaveBeenCalledWith({
type: "voice_camera",
payload: { enabled: true },
});
expect(deps.reapplyAudioPipeline).toHaveBeenCalled();
expect(state.manualCameraTrack).toBe(track);
expect(voiceStore.getState().localCamera).toBe(true);
});
it("uses the saved video input device when one is set", async () => {
const rig = fakeRoom();
loadPref.mockReturnValue("cam-2");
createLocalVideoTrack.mockResolvedValue(fakeVideoTrack());
await enableCamera({ manualCameraTrack: null }, fakeDeps(rig.room));
expect(createLocalVideoTrack).toHaveBeenCalledWith(
expect.objectContaining({ deviceId: "cam-2" }),
);
});
it("omits deviceId when no device is saved", async () => {
const rig = fakeRoom();
createLocalVideoTrack.mockResolvedValue(fakeVideoTrack());
await enableCamera({ manualCameraTrack: null }, fakeDeps(rig.room));
expect(createLocalVideoTrack.mock.calls[0]?.[0]).not.toHaveProperty("deviceId");
});
it("refuses when there is no voice session", async () => {
const deps = fakeDeps(null);
await enableCamera({ manualCameraTrack: null }, deps);
expect(deps.onError).toHaveBeenCalledWith("Join a voice channel first");
expect(createLocalVideoTrack).not.toHaveBeenCalled();
expect(voiceStore.getState().localCamera).toBe(false);
});
it("refuses when the websocket is gone", async () => {
const rig = fakeRoom();
const deps = fakeDeps(rig.room, false);
await enableCamera({ manualCameraTrack: null }, deps);
expect(deps.onError).toHaveBeenCalledWith("Join a voice channel first");
});
it("releases the created track when publishing fails (BUG-100)", async () => {
const rig = fakeRoom();
const track = fakeVideoTrack();
createLocalVideoTrack.mockResolvedValue(track);
rig.publishTrack.mockRejectedValue(new Error("publish failed"));
const deps = fakeDeps(rig.room);
const state = { manualCameraTrack: null as LocalVideoTrack | null };
await enableCamera(state, deps);
// Without this the camera indicator light stays on with nothing published.
expect(track.stop).toHaveBeenCalled();
expect(state.manualCameraTrack).toBeNull();
expect(voiceStore.getState().localCamera).toBe(false);
expect(deps.onError).toHaveBeenCalledWith("Failed to start camera");
});
it.each([
["NotAllowedError", "Camera permission denied"],
["NotFoundError", "No camera found"],
["OverconstrainedError", "Failed to start camera"],
])("maps a %s to a specific message", async (name, message) => {
const rig = fakeRoom();
createLocalVideoTrack.mockRejectedValue(new DOMException("nope", name));
const deps = fakeDeps(rig.room);
await enableCamera({ manualCameraTrack: null }, deps);
expect(deps.onError).toHaveBeenCalledWith(message);
});
it("stops a previously published track before publishing a new one", async () => {
const rig = fakeRoom();
const old = fakeVideoTrack();
createLocalVideoTrack.mockResolvedValue(fakeVideoTrack());
await enableCamera({ manualCameraTrack: old }, fakeDeps(rig.room));
expect(old.stop).toHaveBeenCalled();
});
it("discards the track and does not publish when disableCamera runs during device acquisition", async () => {
const rig = fakeRoom();
const deps = fakeDeps(rig.room);
const track = fakeVideoTrack();
let resolveTrack!: (t: LocalVideoTrack) => void;
createLocalVideoTrack.mockReturnValue(
new Promise((resolve) => {
resolveTrack = resolve;
}),
);
const state = { manualCameraTrack: null as LocalVideoTrack | null };
const enabling = enableCamera(state, deps);
// A concurrent disable runs to completion while createLocalVideoTrack is
// still awaiting the permission prompt / device handshake.
await disableCamera(state, deps);
resolveTrack(track);
await enabling;
expect(rig.publishTrack).not.toHaveBeenCalled();
expect(track.stop).toHaveBeenCalled();
expect(state.manualCameraTrack).toBeNull();
// disableCamera already set this false — the superseded enable must not
// resurrect it.
expect(voiceStore.getState().localCamera).toBe(false);
});
it("does not announce the camera when disableCamera runs during the publish round-trip", async () => {
const rig = fakeRoom();
const deps = fakeDeps(rig.room);
const track = fakeVideoTrack();
createLocalVideoTrack.mockResolvedValue(track);
let resolvePublish!: () => void;
rig.publishTrack.mockReturnValue(
new Promise<void>((resolve) => {
resolvePublish = resolve;
}),
);
const state = { manualCameraTrack: null as LocalVideoTrack | null };
const enabling = enableCamera(state, deps);
await vi.waitFor(() => {
expect(rig.publishTrack).toHaveBeenCalled();
});
// A concurrent disable runs to completion while publishTrack is still in
// flight — it announces voice_camera(false) and stops the track.
await disableCamera(state, deps);
resolvePublish();
await enabling;
// The superseded enable must not announce voice_camera(true) after the
// disable's voice_camera(false), or every peer renders a camera tile for
// a stopped track while the local store says off.
expect(deps.wsSend).not.toHaveBeenCalledWith({
type: "voice_camera",
payload: { enabled: true },
});
expect(rig.unpublishTrack).toHaveBeenCalledWith(track.mediaStreamTrack);
expect(track.stop).toHaveBeenCalled();
expect(state.manualCameraTrack).toBeNull();
expect(voiceStore.getState().localCamera).toBe(false);
});
});
// ── disableCamera ──────────────────────────────────────────────────────────
describe("disableCamera", () => {
it("stops the track, disables the camera and announces it", async () => {
const rig = fakeRoom();
const deps = fakeDeps(rig.room);
const track = fakeVideoTrack();
const state = { manualCameraTrack: track as LocalVideoTrack | null };
await disableCamera(state, deps);
expect(track.stop).toHaveBeenCalled();
expect(rig.setCameraEnabled).toHaveBeenCalledWith(false);
expect(deps.wsSend).toHaveBeenCalledWith({
type: "voice_camera",
payload: { enabled: false },
});
expect(voiceStore.getState().localCamera).toBe(false);
});
it("still clears local state when the room call throws", async () => {
const rig = fakeRoom();
rig.setCameraEnabled.mockRejectedValue(new Error("disconnected"));
const deps = fakeDeps(rig.room);
voiceStore.setState((prev) => ({ ...prev, localCamera: true }));
await disableCamera({ manualCameraTrack: null }, deps);
// The finally block matters: a failed teardown must not leave the UI
// showing a camera that is not publishing.
expect(voiceStore.getState().localCamera).toBe(false);
expect(deps.wsSend).toHaveBeenCalledWith({
type: "voice_camera",
payload: { enabled: false },
});
});
it("works with no room", async () => {
const deps = fakeDeps(null);
await disableCamera({ manualCameraTrack: null }, deps);
expect(voiceStore.getState().localCamera).toBe(false);
});
it("skips the websocket notice when the socket is gone", async () => {
const rig = fakeRoom();
await expect(
disableCamera({ manualCameraTrack: null }, fakeDeps(rig.room, false)),
).resolves.toBeUndefined();
});
});
// ── stopManualScreenTracks ─────────────────────────────────────────────────
describe("stopManualScreenTracks", () => {
it("unpublishes and stops every track", () => {
const rig = fakeRoom();
const video = fakeVideoTrack();
const audio = fakeAudioTrack();
const state = { manualScreenTracks: [video, audio] as LocalTrack[] };
stopManualScreenTracks(state, rig.room);
expect(rig.unpublishTrack).toHaveBeenCalledTimes(2);
expect(video.stop).toHaveBeenCalled();
expect(audio.stop).toHaveBeenCalled();
expect(state.manualScreenTracks).toEqual([]);
});
it("is a no-op with no tracks", () => {
const rig = fakeRoom();
stopManualScreenTracks({ manualScreenTracks: [] }, rig.room);
expect(rig.unpublishTrack).not.toHaveBeenCalled();
});
it("is a no-op with no room", () => {
const video = fakeVideoTrack();
const state = { manualScreenTracks: [video] as LocalTrack[] };
stopManualScreenTracks(state, null);
expect(video.stop).not.toHaveBeenCalled();
});
it("keeps stopping the remaining tracks when one unpublish throws", () => {
const rig = fakeRoom();
rig.unpublishTrack.mockImplementationOnce(() => {
throw new Error("gone");
});
const video = fakeVideoTrack();
const audio = fakeAudioTrack();
stopManualScreenTracks({ manualScreenTracks: [video, audio] as LocalTrack[] }, rig.room);
expect(video.stop).toHaveBeenCalled();
expect(audio.stop).toHaveBeenCalled();
});
});
// ── enableScreenshare ──────────────────────────────────────────────────────
describe("enableScreenshare", () => {
it("publishes video and audio tracks with the right sources", async () => {
const rig = fakeRoom();
const video = fakeVideoTrack();
const audio = fakeAudioTrack();
createLocalScreenTracks.mockResolvedValue([video, audio]);
const deps = fakeDeps(rig.room);
await enableScreenshare({ manualScreenTracks: [] }, deps);
expect(rig.publishTrack).toHaveBeenCalledWith(
video,
expect.objectContaining({ source: Track.Source.ScreenShare }),
);
expect(rig.publishTrack).toHaveBeenCalledWith(
audio,
expect.objectContaining({ source: Track.Source.ScreenShareAudio }),
);
expect(deps.wsSend).toHaveBeenCalledWith({
type: "voice_screenshare",
payload: { enabled: true },
});
expect(voiceStore.getState().localScreenshare).toBe(true);
});
it("sets a video encoding on the video track only", async () => {
const rig = fakeRoom();
const video = fakeVideoTrack();
const audio = fakeAudioTrack();
createLocalScreenTracks.mockResolvedValue([video, audio]);
await enableScreenshare({ manualScreenTracks: [] }, fakeDeps(rig.room));
const videoOpts = rig.publishTrack.mock.calls.find((c) => c[0] === video)?.[1] as Record<
string,
unknown
>;
const audioOpts = rig.publishTrack.mock.calls.find((c) => c[0] === audio)?.[1] as Record<
string,
unknown
>;
expect(videoOpts).toHaveProperty("videoEncoding");
expect(audioOpts).not.toHaveProperty("videoEncoding");
});
it("tears down when the OS stop-sharing button ends the track (BUG-101)", async () => {
const rig = fakeRoom();
const video = fakeVideoTrack();
createLocalScreenTracks.mockResolvedValue([video]);
const deps = fakeDeps(rig.room);
const state = { manualScreenTracks: [] as LocalTrack[] };
await enableScreenshare(state, deps);
deps.wsSend.mockClear();
// Simulate the browser/OS ending the capture without going through the app.
(video.mediaStreamTrack as unknown as { dispatch: (t: string) => void }).dispatch("ended");
await vi.waitFor(() => {
expect(deps.wsSend).toHaveBeenCalledWith({
type: "voice_screenshare",
payload: { enabled: false },
});
});
expect(voiceStore.getState().localScreenshare).toBe(false);
});
it("refuses when there is no voice session", async () => {
const deps = fakeDeps(null);
await enableScreenshare({ manualScreenTracks: [] }, deps);
expect(deps.onError).toHaveBeenCalledWith("Join a voice channel first");
expect(createLocalScreenTracks).not.toHaveBeenCalled();
});
it("releases created tracks when publishing fails (BUG-100)", async () => {
const rig = fakeRoom();
const video = fakeVideoTrack();
createLocalScreenTracks.mockResolvedValue([video]);
rig.publishTrack.mockRejectedValue(new Error("publish failed"));
const deps = fakeDeps(rig.room);
const state = { manualScreenTracks: [] as LocalTrack[] };
await enableScreenshare(state, deps);
// Otherwise the OS keeps showing "screen is being shared" forever.
expect(video.stop).toHaveBeenCalled();
expect(state.manualScreenTracks).toEqual([]);
expect(voiceStore.getState().localScreenshare).toBe(false);
expect(deps.onError).toHaveBeenCalledWith("Failed to start screen sharing");
});
it("reports a denied picker separately", async () => {
const rig = fakeRoom();
createLocalScreenTracks.mockRejectedValue(new DOMException("no", "NotAllowedError"));
const deps = fakeDeps(rig.room);
await enableScreenshare({ manualScreenTracks: [] }, deps);
expect(deps.onError).toHaveBeenCalledWith("Screen sharing permission denied");
});
it("tolerates a capture with no video track", async () => {
const rig = fakeRoom();
createLocalScreenTracks.mockResolvedValue([fakeAudioTrack()]);
const deps = fakeDeps(rig.room);
await enableScreenshare({ manualScreenTracks: [] }, deps);
expect(deps.onError).not.toHaveBeenCalled();
});
it("unpublishes a track that already published before a later one in the batch fails", async () => {
// All quality presets request audio alongside video — a second publish
// call (audio) can reject after the first (video) already succeeded.
const rig = fakeRoom();
const video = fakeVideoTrack();
const audio = fakeAudioTrack();
createLocalScreenTracks.mockResolvedValue([video, audio]);
rig.publishTrack.mockImplementation((track: unknown) =>
track === audio ? Promise.reject(new Error("publish failed")) : Promise.resolve(undefined),
);
const deps = fakeDeps(rig.room);
const state = { manualScreenTracks: [] as LocalTrack[] };
await enableScreenshare(state, deps);
// track.stop() is programmatic and never fires the DOM "ended" event, so
// LiveKit's ended-driven auto-unpublish never runs — without an explicit
// unpublish the video track stays live in the room while nothing in this
// client can find its publication to clean it up later.
expect(rig.unpublishTrack).toHaveBeenCalledWith(video.mediaStreamTrack);
expect(video.stop).toHaveBeenCalled();
expect(audio.stop).toHaveBeenCalled();
expect(state.manualScreenTracks).toEqual([]);
});
it("discards the tracks and does not publish when disableScreenshare runs during capture", async () => {
const rig = fakeRoom();
const deps = fakeDeps(rig.room);
const video = fakeVideoTrack();
let resolveTracks!: (t: LocalTrack[]) => void;
createLocalScreenTracks.mockReturnValue(
new Promise((resolve) => {
resolveTracks = resolve;
}),
);
const state = { manualScreenTracks: [] as LocalTrack[] };
const enabling = enableScreenshare(state, deps);
// A concurrent disable runs to completion while the OS picker is still up.
await disableScreenshare(state, deps);
resolveTracks([video]);
await enabling;
expect(rig.publishTrack).not.toHaveBeenCalled();
expect(video.stop).toHaveBeenCalled();
expect(state.manualScreenTracks).toEqual([]);
expect(voiceStore.getState().localScreenshare).toBe(false);
});
it("does not announce the share when disableScreenshare runs during the publish loop", async () => {
const rig = fakeRoom();
const deps = fakeDeps(rig.room);
const video = fakeVideoTrack();
const audio = fakeAudioTrack();
createLocalScreenTracks.mockResolvedValue([video, audio]);
let resolveFirstPublish!: () => void;
rig.publishTrack
.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveFirstPublish = resolve;
}),
)
.mockResolvedValue(undefined);
const state = { manualScreenTracks: [] as LocalTrack[] };
const enabling = enableScreenshare(state, deps);
await vi.waitFor(() => {
expect(rig.publishTrack).toHaveBeenCalledTimes(1);
});
// A concurrent disable runs to completion while the first publish is
// still in flight — it announces voice_screenshare(false), stops both
// tracks and empties state.manualScreenTracks.
await disableScreenshare(state, deps);
resolveFirstPublish();
await enabling;
// The superseded enable must not publish the remaining track — after the
// disable emptied the state, only this attempt can still reach it — and
// must not announce voice_screenshare(true) after the disable's false.
expect(rig.publishTrack).toHaveBeenCalledTimes(1);
expect(deps.wsSend).not.toHaveBeenCalledWith({
type: "voice_screenshare",
payload: { enabled: true },
});
expect(rig.unpublishTrack).toHaveBeenCalledWith(video.mediaStreamTrack);
expect(video.stop).toHaveBeenCalled();
expect(audio.stop).toHaveBeenCalled();
expect(state.manualScreenTracks).toEqual([]);
expect(voiceStore.getState().localScreenshare).toBe(false);
});
});
// ── disableScreenshare ─────────────────────────────────────────────────────
describe("disableScreenshare", () => {
it("stops tracks, disables sharing and announces it", async () => {
const rig = fakeRoom();
const deps = fakeDeps(rig.room);
const video = fakeVideoTrack();
await disableScreenshare({ manualScreenTracks: [video] as LocalTrack[] }, deps);
expect(video.stop).toHaveBeenCalled();
expect(rig.setScreenShareEnabled).toHaveBeenCalledWith(false);
expect(deps.wsSend).toHaveBeenCalledWith({
type: "voice_screenshare",
payload: { enabled: false },
});
expect(voiceStore.getState().localScreenshare).toBe(false);
});
it("still clears local state when the room call throws", async () => {
const rig = fakeRoom();
rig.setScreenShareEnabled.mockRejectedValue(new Error("disconnected"));
voiceStore.setState((prev) => ({ ...prev, localScreenshare: true }));
await disableScreenshare({ manualScreenTracks: [] }, fakeDeps(rig.room));
expect(voiceStore.getState().localScreenshare).toBe(false);
});
it("works with no room", async () => {
await disableScreenshare({ manualScreenTracks: [] }, fakeDeps(null));
expect(voiceStore.getState().localScreenshare).toBe(false);
});
});
// ── rollbackPendingVideo ───────────────────────────────────────────────────
//
// dispatcher.ts's ERROR handler correlates a server refusal (FORBIDDEN,
// RATE_LIMITED, INTERNAL, ...) of a voice_camera/voice_screenshare enable
// with the already-published track via the envelope id ws.send() returns.
describe("rollbackPendingVideo", () => {
it("registers a camera enable's envelope id and resolves it once", async () => {
const rig = fakeRoom();
createLocalVideoTrack.mockResolvedValue(fakeVideoTrack());
const deps = fakeDeps(rig.room);
deps.wsSend.mockReturnValue("req-camera-1");
await enableCamera({ manualCameraTrack: null }, deps);
expect(rollbackPendingVideo("req-camera-1")).toBe("camera");
// Single-use: a second refusal echoing the same id must not roll back
// whatever is live by then.
expect(rollbackPendingVideo("req-camera-1")).toBeUndefined();
});
it("registers a screenshare enable's envelope id", async () => {
const rig = fakeRoom();
createLocalScreenTracks.mockResolvedValue([fakeVideoTrack()]);
const deps = fakeDeps(rig.room);
deps.wsSend.mockReturnValue("req-screen-1");
await enableScreenshare({ manualScreenTracks: [] }, deps);
expect(rollbackPendingVideo("req-screen-1")).toBe("screen");
});
it("returns undefined for an id that was never registered", () => {
expect(rollbackPendingVideo("never-seen")).toBeUndefined();
});
it("supersedes the previous camera enable's id when a new one starts", async () => {
const rig = fakeRoom();
createLocalVideoTrack.mockResolvedValue(fakeVideoTrack());
const deps = fakeDeps(rig.room);
deps.wsSend.mockReturnValueOnce("req-1").mockReturnValueOnce("req-2");
const state = { manualCameraTrack: null as LocalVideoTrack | null };
await enableCamera(state, deps);
await enableCamera(state, deps);
// Only the latest in-flight camera enable can still be refused.
expect(rollbackPendingVideo("req-1")).toBeUndefined();
expect(rollbackPendingVideo("req-2")).toBe("camera");
});
});
// ── stream getters ─────────────────────────────────────────────────────────
describe("stream getters", () => {
function roomWithLocalPub(source: Track.Source, track: unknown): Room {
return {
localParticipant: {
getTrackPublication: (s: Track.Source) => (s === source ? { track } : undefined),
},
remoteParticipants: new Map(),
} as unknown as Room;
}
it("getLocalCameraStream returns null without a room", () => {
expect(getLocalCameraStream(null)).toBeNull();
});
it("getLocalCameraStream returns null when nothing is published", () => {
expect(getLocalCameraStream(fakeRoom().room)).toBeNull();
});
it("getLocalCameraStream wraps the published camera track", () => {
const room = roomWithLocalPub(Track.Source.Camera, { mediaStreamTrack: {} });
expect(getLocalCameraStream(room)).not.toBeNull();
});
it("getLocalScreenshareStream returns null without a room", () => {
expect(getLocalScreenshareStream(null)).toBeNull();
});
it("getLocalScreenshareStream wraps the published screenshare track", () => {
const room = roomWithLocalPub(Track.Source.ScreenShare, { mediaStreamTrack: {} });
expect(getLocalScreenshareStream(room)).not.toBeNull();
});
it("getLocalScreenshareStream returns null when nothing is published", () => {
expect(getLocalScreenshareStream(fakeRoom().room)).toBeNull();
});
});
describe("getRemoteVideoStream", () => {
function roomWithRemote(identity: string, source: Track.Source, track: unknown): Room {
return {
localParticipant: { getTrackPublication: () => undefined },
remoteParticipants: new Map([
[
identity,
{
identity,
getTrackPublication: (s: Track.Source) => (s === source ? { track } : undefined),
},
],
]),
} as unknown as Room;
}
it("returns null without a room", () => {
expect(getRemoteVideoStream(null, 7, "camera")).toBeNull();
});
it("matches an identity carrying a join-token suffix", () => {
// getParticipantByIdentity would miss this, which is why the module scans.
const room = roomWithRemote("user-42:abc123", Track.Source.Camera, { mediaStreamTrack: {} });
expect(getRemoteVideoStream(room, 42, "camera")).not.toBeNull();
});
it("matches a bare identity", () => {
const room = roomWithRemote("user-42", Track.Source.Camera, { mediaStreamTrack: {} });
expect(getRemoteVideoStream(room, 42, "camera")).not.toBeNull();
});
it("selects the screenshare source when asked", () => {
const room = roomWithRemote("user-42:tok", Track.Source.ScreenShare, { mediaStreamTrack: {} });
expect(getRemoteVideoStream(room, 42, "screenshare")).not.toBeNull();
expect(getRemoteVideoStream(room, 42, "camera")).toBeNull();
});
it("returns null for a user who is not in the room", () => {
const room = roomWithRemote("user-42:tok", Track.Source.Camera, { mediaStreamTrack: {} });
expect(getRemoteVideoStream(room, 99, "camera")).toBeNull();
});
it("does not confuse user 4 with user 42", () => {
const room = roomWithRemote("user-42:tok", Track.Source.Camera, { mediaStreamTrack: {} });
expect(getRemoteVideoStream(room, 4, "camera")).toBeNull();
});
it("ignores participants with an unparseable identity", () => {
const room = roomWithRemote("anonymous", Track.Source.Camera, { mediaStreamTrack: {} });
expect(getRemoteVideoStream(room, 42, "camera")).toBeNull();
});
});