Files
element-call/src/lotus/lotusScreenshareWatch.test.ts
T
Lotus CIandClaude Opus 5 9f472fd710 fix(lotus): report an ended screenshare even when LiveKit unpublishes first (#39)
LiveKit's own ended handler runs before ours and unpublishes the share;
a listener removed during that dispatch never fires. Decide from the
track's readyState on LocalTrackUnpublished as well. Verified end to end:
closing the shared source now toasts in the host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:51:43 -04:00

172 lines
5.2 KiB
TypeScript

/*
Copyright 2026 Lotus Guild
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { RoomEvent, Track } from "livekit-client";
import { BehaviorSubject } from "rxjs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import {
ALONE_MS,
NO_FRAMES_MS,
startLotusScreenshareWatch,
} from "./lotusScreenshareWatch";
const sent: unknown[] = [];
vi.mock("../widget", () => ({ widget: { api: {} } }));
vi.mock("./lotusWidget", () => ({
lotusSendToHost: (action: string, data: unknown): boolean => {
sent.push({ action, data });
return true;
},
}));
class FakeTrack extends EventTarget {}
const makeRoom = (): {
room: Record<string, unknown>;
emit: (event: string, ...args: unknown[]) => void;
remote: Map<string, unknown>;
} => {
const handlers = new Map<string, ((...args: unknown[]) => void)[]>();
const remote = new Map<string, unknown>();
const room = {
remoteParticipants: remote,
localParticipant: { trackPublications: new Map() },
on: (event: string, h: (...args: unknown[]) => void): void => {
handlers.set(event, [...(handlers.get(event) ?? []), h]);
},
off: (event: string, h: (...args: unknown[]) => void): void => {
handlers.set(
event,
(handlers.get(event) ?? []).filter((x) => x !== h),
);
},
};
return {
room,
remote,
emit: (event, ...args): void => {
(handlers.get(event) ?? []).forEach((h) => h(...args));
},
};
};
describe("startLotusScreenshareWatch", () => {
let clock = 0;
beforeEach(() => {
sent.length = 0;
clock = 1_000_000;
vi.useFakeTimers();
});
afterEach(() => vi.useRealTimers());
const start = (): ReturnType<typeof makeRoom> & { stop: () => void } => {
const fake = makeRoom();
const connections = new BehaviorSubject({
getConnections: () => [{ livekitRoom: fake.room }],
});
const stop = startLotusScreenshareWatch(
{ allConnections$: connections } as unknown as CallViewModel,
() => clock,
);
return { ...fake, stop };
};
it("reports a share whose track ended", () => {
const { emit, stop } = start();
const mst = new FakeTrack();
emit(RoomEvent.LocalTrackPublished, {
source: Track.Source.ScreenShare,
track: { mediaStreamTrack: mst },
});
mst.dispatchEvent(new Event("ended"));
expect(sent).toEqual([
{ action: "io.lotus.screenshare_notice", data: { kind: "ended" } },
]);
stop();
});
it("reports no frames after a sustained mute, once, and not after an unmute", () => {
const { emit, stop } = start();
const mst = new FakeTrack();
emit(RoomEvent.LocalTrackPublished, {
source: Track.Source.ScreenShare,
track: { mediaStreamTrack: mst },
});
mst.dispatchEvent(new Event("mute"));
vi.advanceTimersByTime(NO_FRAMES_MS / 2);
mst.dispatchEvent(new Event("unmute"));
vi.advanceTimersByTime(NO_FRAMES_MS);
expect(sent).toEqual([]);
mst.dispatchEvent(new Event("mute"));
vi.advanceTimersByTime(NO_FRAMES_MS);
mst.dispatchEvent(new Event("mute"));
vi.advanceTimersByTime(NO_FRAMES_MS);
expect(sent).toEqual([
{ action: "io.lotus.screenshare_notice", data: { kind: "no-frames" } },
]);
stop();
});
it("nudges after 30 min of sharing with nobody else, never while others are present", () => {
const { emit, remote, stop } = start();
emit(RoomEvent.LocalTrackPublished, {
source: Track.Source.ScreenShare,
track: { mediaStreamTrack: new FakeTrack() },
});
remote.set("bob", {});
clock += ALONE_MS + 60_000;
vi.advanceTimersByTime(60_000);
expect(sent).toEqual([]);
remote.clear();
vi.advanceTimersByTime(60_000);
vi.advanceTimersByTime(60_000);
expect(sent).toEqual([
{ action: "io.lotus.screenshare_notice", data: { kind: "alone" } },
]);
stop();
});
it("reports ended from the unpublish when LiveKit's handler ran first", () => {
const { emit, stop } = start();
const share = new FakeTrack() as FakeTrack & { readyState: string };
share.readyState = "ended";
const pub = {
source: Track.Source.ScreenShare,
track: { mediaStreamTrack: share },
};
emit(RoomEvent.LocalTrackPublished, pub);
emit(RoomEvent.LocalTrackUnpublished, pub);
share.dispatchEvent(new Event("ended"));
expect(sent).toEqual([
{ action: "io.lotus.screenshare_notice", data: { kind: "ended" } },
]);
stop();
});
it("ignores non-screenshare publications and stops watching on unpublish", () => {
const { emit, stop } = start();
const cam = new FakeTrack();
emit(RoomEvent.LocalTrackPublished, {
source: Track.Source.Camera,
track: { mediaStreamTrack: cam },
});
cam.dispatchEvent(new Event("ended"));
const share = new FakeTrack();
const pub = {
source: Track.Source.ScreenShare,
track: { mediaStreamTrack: share },
};
emit(RoomEvent.LocalTrackPublished, pub);
emit(RoomEvent.LocalTrackUnpublished, pub);
share.dispatchEvent(new Event("ended"));
expect(sent).toEqual([]);
stop();
});
});