mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
[SDK] Cover the publisher, the local media and the participation
The publisher is driven through a real LocalParticipant whose device and signalling calls are faked, so that LiveKit's own pending-publication bookkeeping is part of what is tested: the cases ported from Element Call's Publisher tests (one permission prompt for both devices, nothing created while muted, the upstream held back until the member publishes, a mute that lands while the track is still being created) plus the publish, unpublish and destroy paths. The local media runs on a fake connection map: publishing before and after the connection exists, republishing on a new connection, mutes remembered across connections, the first publish error kept, and the output device applied to the current room and the ones to come. The participation runs on a fake media backend: the join options, the state machine through connecting, connected, reconnecting, failed and left, publishing only while joined, the delayed leave delegation, the member ids per mode, the key replay and the data channel's sender check.
This commit is contained in:
@@ -6,11 +6,21 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "events";
|
||||
import { ConnectionState, type Room as LivekitRoom } from "livekit-client";
|
||||
import {
|
||||
ConnectionState,
|
||||
LocalParticipant,
|
||||
type LocalTrack,
|
||||
type LocalTrackPublication,
|
||||
ParticipantEvent,
|
||||
type Room as LivekitRoom,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Publisher } from "./Publisher";
|
||||
import { type PublishRequest } from "../../media-api";
|
||||
import { flushPromises } from "../../utils/test";
|
||||
import { type DesiredMedia, Publisher } from "./Publisher";
|
||||
|
||||
describe("Publisher", () => {
|
||||
describe("setAudioOutputDevice", () => {
|
||||
@@ -43,6 +53,261 @@ describe("Publisher", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The publisher drives a real LocalParticipant, so that LiveKit's own
|
||||
// bookkeeping of pending publications is part of what is tested; only the
|
||||
// device access and the signalling are faked.
|
||||
describe("publishing", () => {
|
||||
let participant: LocalParticipant;
|
||||
let room: LivekitRoom;
|
||||
let desired: DesiredMedia;
|
||||
let publisher: Publisher;
|
||||
// Resolved by default; a test takes hold of it to stretch the track creation
|
||||
let createTracksLock: Promise<void>;
|
||||
|
||||
const microphone: PublishRequest = { source: "microphone" };
|
||||
const camera: PublishRequest = { source: "camera" };
|
||||
|
||||
function want(...requests: PublishRequest[]): void {
|
||||
for (const request of requests)
|
||||
desired.set(request.source, { request, enabled: true });
|
||||
}
|
||||
|
||||
function track(source: Track.Source): LocalTrack | undefined {
|
||||
return participant.getTrackPublication(source)?.track;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
createTracksLock = Promise.resolve();
|
||||
participant = fakeLocalParticipant(async () => createTracksLock);
|
||||
room = {
|
||||
options: {},
|
||||
setE2EEEnabled: () => undefined,
|
||||
localParticipant: participant,
|
||||
} as unknown as LivekitRoom;
|
||||
desired = new Map();
|
||||
publisher = new Publisher(room, desired, logger);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await publisher.destroy();
|
||||
});
|
||||
|
||||
it("creates no tracks where everything starts muted, to spare the permission prompt", async () => {
|
||||
want(microphone, camera);
|
||||
desired.get("microphone")!.enabled = false;
|
||||
desired.get("camera")!.enabled = false;
|
||||
|
||||
publisher.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(participant.createTracks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks for the camera and the microphone in one call", async () => {
|
||||
const enableBoth = vi.spyOn(participant, "enableCameraAndMicrophone");
|
||||
want(microphone, camera);
|
||||
|
||||
publisher.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(enableBoth).toHaveBeenCalledOnce();
|
||||
expect(participant.createTracks).toHaveBeenCalledOnce();
|
||||
expect(participant.createTracks).toHaveBeenCalledWith({
|
||||
audio: true,
|
||||
video: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ enabled: microphone, muted: camera, options: { audio: true } },
|
||||
{ enabled: camera, muted: microphone, options: { video: true } },
|
||||
])(
|
||||
"creates only the $enabled.source where the $muted.source is muted",
|
||||
async ({ enabled, muted, options }) => {
|
||||
want(enabled, muted);
|
||||
desired.get(muted.source)!.enabled = false;
|
||||
|
||||
publisher.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(participant.createTracks).toHaveBeenCalledOnce();
|
||||
expect(participant.createTracks).toHaveBeenCalledWith(options);
|
||||
},
|
||||
);
|
||||
|
||||
it("starts again at most once", async () => {
|
||||
want(microphone);
|
||||
|
||||
publisher.start();
|
||||
publisher.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(participant.createTracks).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps the upstream paused until the member publishes", async () => {
|
||||
want(microphone);
|
||||
|
||||
publisher.start();
|
||||
await flushPromises();
|
||||
|
||||
const microphoneTrack = track(Track.Source.Microphone);
|
||||
expect(microphoneTrack).toBeDefined();
|
||||
expect(microphoneTrack!.pauseUpstream).toHaveBeenCalled();
|
||||
expect(microphoneTrack!.isUpstreamPaused).toBe(true);
|
||||
});
|
||||
|
||||
it("resumes the upstream once the member publishes", async () => {
|
||||
want(camera);
|
||||
publisher.start();
|
||||
await flushPromises();
|
||||
expect(track(Track.Source.Camera)!.isUpstreamPaused).toBe(true);
|
||||
|
||||
await publisher.startPublishing();
|
||||
|
||||
expect(track(Track.Source.Camera)!.isUpstreamPaused).toBe(false);
|
||||
});
|
||||
|
||||
it("pauses the upstream again when the member stops publishing", async () => {
|
||||
want(camera);
|
||||
publisher.start();
|
||||
await flushPromises();
|
||||
await publisher.startPublishing();
|
||||
|
||||
await publisher.stopPublishing();
|
||||
|
||||
expect(track(Track.Source.Camera)!.isUpstreamPaused).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a track published while it publishes, paused or not", async () => {
|
||||
want(microphone);
|
||||
await publisher.startPublishing();
|
||||
|
||||
publisher.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(track(Track.Source.Microphone)!.isUpstreamPaused).toBe(false);
|
||||
});
|
||||
|
||||
it("mutes and unmutes a track", async () => {
|
||||
want(microphone);
|
||||
publisher.start();
|
||||
await flushPromises();
|
||||
await publisher.startPublishing();
|
||||
|
||||
await expect(publisher.setEnabled("microphone", false)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
expect(track(Track.Source.Microphone)!.isMuted).toBe(true);
|
||||
|
||||
await expect(publisher.setEnabled("microphone", true)).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
expect(track(Track.Source.Microphone)!.isMuted).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the upstream paused when a track is unmuted before the member publishes", async () => {
|
||||
want(microphone);
|
||||
publisher.start();
|
||||
await flushPromises();
|
||||
await publisher.setEnabled("microphone", false);
|
||||
|
||||
await publisher.setEnabled("microphone", true);
|
||||
|
||||
expect(track(Track.Source.Microphone)!.isUpstreamPaused).toBe(true);
|
||||
});
|
||||
|
||||
it("reports the state LiveKit is left in where a mute fails", async () => {
|
||||
want(camera);
|
||||
publisher.start();
|
||||
await flushPromises();
|
||||
vi.spyOn(participant, "setCameraEnabled").mockRejectedValueOnce(
|
||||
new Error("device gone"),
|
||||
);
|
||||
|
||||
await expect(publisher.setEnabled("camera", false)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("reports a screen share that was never published as muted", async () => {
|
||||
await expect(publisher.setEnabled("screenShare", true)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// Creating the tracks takes a while, and the host may mute in the
|
||||
// meantime: LiveKit's setMicrophoneEnabled(false) does not see a track
|
||||
// that is still pending, so the publisher has to apply the mute once the
|
||||
// track is published.
|
||||
it("mutes a track that was muted while it was being created", async () => {
|
||||
want(microphone);
|
||||
const lock = Promise.withResolvers<void>();
|
||||
createTracksLock = lock.promise;
|
||||
|
||||
publisher.start();
|
||||
await publisher.startPublishing();
|
||||
desired.get("microphone")!.enabled = false;
|
||||
void publisher.setEnabled("microphone", false);
|
||||
await flushPromises();
|
||||
expect(track(Track.Source.Microphone)).toBeUndefined();
|
||||
|
||||
lock.resolve();
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
const microphoneTrack = track(Track.Source.Microphone);
|
||||
expect(microphoneTrack).toBeDefined();
|
||||
expect(microphoneTrack!.isMuted).toBe(true);
|
||||
});
|
||||
|
||||
it("publishes a request on its own once started", async () => {
|
||||
want(microphone);
|
||||
publisher.start();
|
||||
await flushPromises();
|
||||
|
||||
const publication = await publisher.publish(camera);
|
||||
|
||||
expect(publication?.source).toBe(Track.Source.Camera);
|
||||
expect(track(Track.Source.Camera)!.isUpstreamPaused).toBe(true);
|
||||
});
|
||||
|
||||
it("unpublishes a source and stops its track", async () => {
|
||||
want(microphone, camera);
|
||||
publisher.start();
|
||||
await flushPromises();
|
||||
const cameraTrack = track(Track.Source.Camera)!;
|
||||
|
||||
await publisher.unpublish("camera");
|
||||
|
||||
expect(participant.unpublishTrack).toHaveBeenCalledWith(
|
||||
cameraTrack,
|
||||
true,
|
||||
);
|
||||
expect(track(Track.Source.Camera)).toBeUndefined();
|
||||
expect(track(Track.Source.Microphone)).toBeDefined();
|
||||
});
|
||||
|
||||
it("unpublishes a screen share through LiveKit, so that its audio goes with it", async () => {
|
||||
await publisher.unpublish("screenShare");
|
||||
expect(participant.setScreenShareEnabled).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("stops every track on destroy", async () => {
|
||||
want(microphone, camera);
|
||||
publisher.start();
|
||||
await flushPromises();
|
||||
const tracks = [
|
||||
track(Track.Source.Microphone),
|
||||
track(Track.Source.Camera),
|
||||
];
|
||||
|
||||
await publisher.destroy();
|
||||
|
||||
for (const t of tracks)
|
||||
expect(participant.unpublishTrack).toHaveBeenCalledWith(t, true);
|
||||
expect(participant.trackPublications.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** The slice of a LiveKit room the publisher touches at construction and for the output device. */
|
||||
@@ -61,3 +326,114 @@ function fakeRoom(
|
||||
} as unknown as LivekitRoom;
|
||||
return { room, switchActiveDevice };
|
||||
}
|
||||
|
||||
function fakeLocalTrack(source: Track.Source): LocalTrack {
|
||||
const state = { isMuted: false, isUpstreamPaused: false };
|
||||
const track = {
|
||||
source,
|
||||
get isMuted() {
|
||||
return state.isMuted;
|
||||
},
|
||||
get isUpstreamPaused() {
|
||||
return state.isUpstreamPaused;
|
||||
},
|
||||
stop: vi.fn(),
|
||||
mute: vi.fn(async () => {
|
||||
state.isMuted = true;
|
||||
return Promise.resolve(track);
|
||||
}),
|
||||
unmute: vi.fn(async () => {
|
||||
state.isMuted = false;
|
||||
return Promise.resolve(track);
|
||||
}),
|
||||
pauseUpstream: vi.fn(async () => {
|
||||
state.isUpstreamPaused = true;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
resumeUpstream: vi.fn(async () => {
|
||||
state.isUpstreamPaused = false;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
};
|
||||
return track as unknown as LocalTrack;
|
||||
}
|
||||
|
||||
/**
|
||||
* A real LocalParticipant on a fake engine: the track creation, publication
|
||||
* and unpublication are replaced so that no device or signalling is touched,
|
||||
* while the enable/disable logic and the pending publications stay LiveKit's.
|
||||
*/
|
||||
function fakeLocalParticipant(
|
||||
createTracksLock: () => Promise<void>,
|
||||
): LocalParticipant {
|
||||
const engine = {
|
||||
client: { sendUpdateLocalMetadata: vi.fn() },
|
||||
on: vi.fn().mockReturnThis(),
|
||||
sendDataPacket: vi.fn(),
|
||||
};
|
||||
const participant = new LocalParticipant(
|
||||
"local-sid",
|
||||
"local-identity",
|
||||
// @ts-expect-error - an engine that answers nothing is enough here
|
||||
engine,
|
||||
{
|
||||
adaptiveStream: true,
|
||||
dynacast: false,
|
||||
audioCaptureDefaults: {},
|
||||
videoCaptureDefaults: {},
|
||||
stopLocalTrackOnUnpublish: true,
|
||||
reconnectPolicy: "always",
|
||||
disconnectOnPageLeave: true,
|
||||
},
|
||||
new Map(),
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
);
|
||||
let nextSid = 0;
|
||||
|
||||
vi.spyOn(participant, "createTracks").mockImplementation(async (opts) => {
|
||||
const tracks: LocalTrack[] = [];
|
||||
if (opts?.audio) tracks.push(fakeLocalTrack(Track.Source.Microphone));
|
||||
if (opts?.video) tracks.push(fakeLocalTrack(Track.Source.Camera));
|
||||
await createTracksLock();
|
||||
return tracks;
|
||||
});
|
||||
|
||||
vi.spyOn(participant, "publishTrack").mockImplementation(async (t) => {
|
||||
const track = t as LocalTrack;
|
||||
const publication = {
|
||||
track,
|
||||
source: track.source,
|
||||
trackSid: `TR_${nextSid++}`,
|
||||
get isMuted() {
|
||||
return track.isMuted;
|
||||
},
|
||||
mute: track.mute,
|
||||
unmute: track.unmute,
|
||||
} as Partial<LocalTrackPublication> as LocalTrackPublication;
|
||||
participant.trackPublications.set(publication.trackSid, publication);
|
||||
participant.emit(ParticipantEvent.LocalTrackPublished, publication);
|
||||
return Promise.resolve(publication);
|
||||
});
|
||||
|
||||
vi.spyOn(participant, "unpublishTrack").mockImplementation(async (t) => {
|
||||
const publication = [...participant.trackPublications.values()].find(
|
||||
(p) => p.track === t,
|
||||
);
|
||||
if (publication === undefined) return undefined;
|
||||
participant.trackPublications.delete(publication.trackSid);
|
||||
participant.emit(ParticipantEvent.LocalTrackUnpublished, publication);
|
||||
return Promise.resolve(publication);
|
||||
});
|
||||
|
||||
vi.spyOn(participant, "getTrackPublication").mockImplementation((source) =>
|
||||
[...participant.trackPublications.values()].find(
|
||||
(p) => p.source === source,
|
||||
),
|
||||
);
|
||||
|
||||
vi.spyOn(participant, "setScreenShareEnabled").mockResolvedValue(undefined);
|
||||
|
||||
return participant;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user