Test and document the MemberMedia track list

Unit tests for tracks$ and trackBySource$, an end-to-end check of isActive$ through a mute cycle (skipped on Chromium, whose fake microphone the SFU never hears), the architecture document brought in line with the track list, and the changed behaviour recorded in the plan.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Timo K.
2026-10-08 13:53:57 +02:00
co-authored by Claude Fable 5.1
parent 18e0065086
commit 63ccfa50be
5 changed files with 280 additions and 15 deletions
+101
View File
@@ -0,0 +1,101 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { EventEmitter } from "events";
import {
ParticipantEvent,
type RemoteParticipant,
type Room as LivekitRoom,
Track,
type TrackPublication,
} from "livekit-client";
import { describe, expect, it } from "vitest";
import { E2eeType } from "../encryption";
import { testScope } from "../utils/test";
import { createLivekitMemberMedia } from "./LivekitMemberMedia";
describe("createLivekitMemberMedia", () => {
it("lists one stable track per publication, in publication order", () => {
const { participant, publications, room, emitter } = fakes();
const media = createLivekitMemberMedia(testScope(), participant, room, {
kind: E2eeType.NONE,
});
expect(media.tracks$.value).toEqual([]);
const microphone = publish(publications, "TR_1", Track.Source.Microphone);
const camera = publish(publications, "TR_2", Track.Source.Camera);
emitter.emit(ParticipantEvent.TrackPublished, camera);
const [first, second] = media.tracks$.value;
expect(first.source).toBe("microphone");
expect(second.source).toBe("camera");
// An unrelated media event keeps the same objects
microphone.isMuted = true;
emitter.emit(ParticipantEvent.TrackMuted, microphone);
expect(media.tracks$.value[0]).toBe(first);
expect(media.tracks$.value[1]).toBe(second);
publications.delete("TR_1");
emitter.emit(ParticipantEvent.TrackUnpublished, microphone);
expect(media.tracks$.value).toEqual([second]);
});
it("includes a track published without a source", () => {
const { participant, publications, room, emitter } = fakes();
const media = createLivekitMemberMedia(testScope(), participant, room, {
kind: E2eeType.NONE,
});
emitter.emit(
ParticipantEvent.TrackPublished,
publish(publications, "TR_1", Track.Source.Unknown),
);
expect(media.tracks$.value.map((t) => t.source)).toEqual(["unknown"]);
});
});
type FakePublication = { isMuted: boolean } & Omit<TrackPublication, "isMuted">;
function publish(
publications: Map<string, TrackPublication>,
trackSid: string,
source: Track.Source,
): FakePublication {
const publication = Object.assign(new EventEmitter(), {
trackSid,
source,
kind:
source === Track.Source.Camera || source === Track.Source.ScreenShare
? Track.Kind.Video
: Track.Kind.Audio,
isMuted: false,
isEncrypted: true,
track: undefined,
}) as unknown as FakePublication;
publications.set(trackSid, publication as unknown as TrackPublication);
return publication;
}
function fakes(): {
participant: RemoteParticipant;
publications: Map<string, TrackPublication>;
room: LivekitRoom;
emitter: EventEmitter;
} {
const emitter = new EventEmitter();
const publications = new Map<string, TrackPublication>();
const participant = Object.assign(emitter, {
isLocal: false,
isSpeaking: false,
identity: "@alice:example.org:DEVICE",
trackPublications: publications,
getTrackPublication: (source: Track.Source) =>
[...publications.values()].find((p) => p.source === source),
}) as unknown as RemoteParticipant;
const room = new EventEmitter() as unknown as LivekitRoom;
return { participant, publications, room, emitter };
}
+41
View File
@@ -0,0 +1,41 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { BehaviorSubject } from "rxjs";
import { describe, expect, it } from "vitest";
import { type MediaTrack } from "../api";
import { testScope } from "./test";
import { trackBySource$ } from "./tracks";
const camera = { source: "camera", kind: "video", id: "1" } as MediaTrack;
const microphone = {
source: "microphone",
kind: "audio",
id: "2",
} as MediaTrack;
describe("trackBySource$", () => {
it("picks the track of a source and follows the list", () => {
const tracks$ = new BehaviorSubject<MediaTrack[]>([]);
const camera$ = trackBySource$(testScope(), tracks$, "camera");
expect(camera$.value).toBeUndefined();
tracks$.next([microphone, camera]);
expect(camera$.value).toBe(camera);
tracks$.next([microphone]);
expect(camera$.value).toBeUndefined();
});
it("does not emit when the list changes elsewhere", () => {
const tracks$ = new BehaviorSubject<MediaTrack[]>([camera]);
const camera$ = trackBySource$(testScope(), tracks$, "camera");
const emissions: unknown[] = [];
camera$.subscribe((t) => emissions.push(t));
tracks$.next([camera, microphone]);
expect(emissions).toEqual([camera]);
});
});