Files
Piik/tests/sfu-client.test.ts
T

2447 lines
82 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QUALITY_PROFILES } from "../src/client/media/quality.ts";
import { sfuPublisherQualityEvidenceFromMetrics } from "../src/client/media/sender-quality-evidence.ts";
import { SfuPublisher } from "../src/client/sfu/publisher.ts";
import { SfuSubscriber } from "../src/client/sfu/subscriber.ts";
import type { ConnectionMetrics } from "../src/client/types.ts";
import { mergeStatsReports } from "../src/client/webrtc/stats.ts";
type EventHandler = (...args: unknown[]) => void;
const livekit = vi.hoisted(() => {
class FakeSender {
track: MediaStreamTrack;
failNextSetParameters = false;
deferNextSetParameters = false;
private releaseSetParameters: (() => void) | null = null;
parameters: RTCRtpSendParameters = {
codecs: [],
encodings: [{}],
headerExtensions: [],
rtcp: { cname: "fake", reducedSize: true },
transactionId: "fake",
};
constructor(track: MediaStreamTrack) {
this.track = track;
}
readonly getParameters = vi.fn(() => this.parameters);
readonly setParameters = vi.fn(
async (parameters: RTCRtpSendParameters): Promise<void> => {
if (this.failNextSetParameters) {
this.failNextSetParameters = false;
throw new Error("audio parameters rejected");
}
const failure = state.nextSenderParameterError;
if (failure) {
state.nextSenderParameterError = null;
throw failure;
}
if (this.deferNextSetParameters) {
this.deferNextSetParameters = false;
await new Promise<void>((resolve) => {
this.releaseSetParameters = resolve;
});
}
this.parameters = parameters;
},
);
readonly getStats = vi.fn(
async (): Promise<RTCStatsReport> =>
new Map() as unknown as RTCStatsReport,
);
releaseDeferredSetParameters(): void {
this.releaseSetParameters?.();
this.releaseSetParameters = null;
}
}
class FakeLocalTrack {
currentTrack: MediaStreamTrack;
sender: FakeSender;
publishOptions?: Record<string, unknown>;
savedDegradationPreference: RTCDegradationPreference | null = null;
constructor(track: MediaStreamTrack) {
this.currentTrack = track;
this.sender = new FakeSender(track);
}
get mediaStreamTrack(): MediaStreamTrack {
return this.currentTrack;
}
readonly replaceTrack = vi.fn(
async (nextTrack: MediaStreamTrack): Promise<void> => {
this.currentTrack = nextTrack;
this.sender.track = nextTrack;
},
);
readonly setDegradationPreference = vi.fn(
async (preference: RTCDegradationPreference): Promise<void> => {
this.savedDegradationPreference = preference;
},
);
replaceSenderForTest(): FakeSender {
this.sender = new FakeSender(this.currentTrack);
return this.sender;
}
}
class FakeLocalParticipant {
readonly publications: Array<{
track: FakeLocalTrack;
videoTrack?: FakeLocalTrack;
audioTrack?: FakeLocalTrack;
rawTrack: MediaStreamTrack;
options: Record<string, unknown>;
}> = [];
readonly publishTrack = vi.fn(
async (
rawTrack: MediaStreamTrack,
options: Record<string, unknown>,
) => {
const localTrack = new FakeLocalTrack(rawTrack);
localTrack.publishOptions = options;
const audioPreset = options.audioPreset;
if (
rawTrack.kind === "audio" &&
typeof audioPreset === "object" &&
audioPreset !== null &&
"maxBitrate" in audioPreset &&
typeof audioPreset.maxBitrate === "number"
) {
localTrack.sender.parameters.encodings = [
{ maxBitrate: audioPreset.maxBitrate },
];
}
const screenShareEncoding = options.screenShareEncoding;
if (
rawTrack.kind === "video" &&
options.simulcast === false &&
typeof screenShareEncoding === "object" &&
screenShareEncoding !== null
) {
const encoding = screenShareEncoding as {
maxBitrate?: number;
maxFramerate?: number;
};
localTrack.sender.parameters.encodings = [
{
maxBitrate: encoding.maxBitrate,
maxFramerate: encoding.maxFramerate,
scaleResolutionDownBy: 1,
},
];
}
const publication = {
track: localTrack,
videoTrack: rawTrack.kind === "video" ? localTrack : undefined,
audioTrack: rawTrack.kind === "audio" ? localTrack : undefined,
rawTrack,
options,
};
this.publications.push(publication);
return publication;
},
);
getTrackPublication(source: string) {
return this.publications.find(
(publication) => publication.options.source === source,
);
}
async republishForReconnect(): Promise<void> {
const previous = [...this.publications];
this.publications.length = 0;
for (const publication of previous) {
delete publication.videoTrack;
delete publication.audioTrack;
await this.publishTrack(publication.rawTrack, {
...publication.options,
});
}
}
republishedOptions: Array<Record<string, unknown>> = [];
readonly republishAllTracks = vi.fn(async (): Promise<void> => {
this.republishedOptions = this.publications.map((publication) => ({
...publication.options,
}));
});
readonly unpublishTrack = vi.fn(
async (_track: FakeLocalTrack, _stopOnUnpublish: boolean) => undefined,
);
}
class FakeRemotePublication {
subscribed = false;
readonly setSubscribed = vi.fn((subscribed: boolean) => {
this.subscribed = subscribed;
});
constructor(
readonly trackSid: string,
readonly source: string,
) {}
}
class FakeRemoteParticipant {
readonly trackPublications = new Map<string, FakeRemotePublication>();
constructor(readonly identity: string) {}
add(publication: FakeRemotePublication): this {
this.trackPublications.set(publication.trackSid, publication);
return this;
}
}
class FakeRoom {
readonly localParticipant = new FakeLocalParticipant();
readonly remoteParticipants = new Map<string, FakeRemoteParticipant>();
readonly handlers = new Map<string, EventHandler[]>();
readonly connect = vi.fn(
async (
_url: string,
_token: string,
_options: Record<string, unknown>,
): Promise<void> => {
await state.connectGate;
},
);
readonly disconnect = vi.fn(async (_stopTracks?: boolean) => undefined);
constructor(readonly options: Record<string, unknown> = {}) {
state.rooms.push(this);
}
on(event: string, handler: EventHandler): this {
const handlers = this.handlers.get(event) ?? [];
handlers.push(handler);
this.handlers.set(event, handlers);
return this;
}
emit(event: string, ...args: unknown[]): void {
for (const handler of this.handlers.get(event) ?? []) {
handler(...args);
}
}
}
const state: {
rooms: FakeRoom[];
connectGate: Promise<void> | null;
nextSenderParameterError: Error | null;
} = {
rooms: [],
connectGate: null,
nextSenderParameterError: null,
};
const AudioPresets = {
musicStereo: { maxBitrate: 64_000 },
musicHighQualityStereo: { maxBitrate: 128_000 },
} as const;
return {
AudioPresets,
FakeRemoteParticipant,
FakeRemotePublication,
FakeRoom,
state,
};
});
const RoomEvent = {
Disconnected: "disconnected",
ParticipantConnected: "participant-connected",
ParticipantDisconnected: "participant-disconnected",
Reconnected: "reconnected",
Reconnecting: "reconnecting",
TrackPublished: "track-published",
TrackSubscribed: "track-subscribed",
TrackUnpublished: "track-unpublished",
TrackUnsubscribed: "track-unsubscribed",
} as const;
const Track = {
Source: {
Camera: "camera",
ScreenShare: "screen-share",
ScreenShareAudio: "screen-share-audio",
},
} as const;
vi.mock("livekit-client", () => ({
AudioPresets: livekit.AudioPresets,
Room: livekit.FakeRoom,
RoomEvent,
Track,
}));
class FakeMediaStream {
constructor(private readonly tracks: MediaStreamTrack[] = []) {}
addTrack(track: MediaStreamTrack): void {
if (!this.tracks.includes(track)) this.tracks.push(track);
}
removeTrack(track: MediaStreamTrack): void {
const index = this.tracks.indexOf(track);
if (index >= 0) this.tracks.splice(index, 1);
}
getTracks(): MediaStreamTrack[] {
return [...this.tracks];
}
getVideoTracks(): MediaStreamTrack[] {
return this.tracks.filter((item) => item.kind === "video");
}
getAudioTracks(): MediaStreamTrack[] {
return this.tracks.filter((item) => item.kind === "audio");
}
}
const connection = {
url: "wss://sfu.example.test",
token: "short-lived-token",
};
const qualityProfile = {
resolution: "1080p",
maxFramerate: 60,
maxBitrate: 8_000_000,
degradationPreference: "maintain-resolution",
} as const;
function track(kind: "video" | "audio", id: string): MediaStreamTrack {
const mediaTrack = Object.assign(new EventTarget(), {
id,
kind,
contentHint: "",
enabled: true,
getSettings: () => ({}),
applyConstraints: vi.fn(async () => undefined),
stop: vi.fn(),
}) as unknown as MediaStreamTrack;
mediaTrack.clone = vi.fn(() => {
const clone = track(kind, id);
clone.getSettings = mediaTrack.getSettings.bind(mediaTrack);
return clone;
});
return mediaTrack;
}
function remoteTrack(
mediaStreamTrack: MediaStreamTrack,
getStats: () => RTCStatsReport | Promise<RTCStatsReport> = () =>
new Map() as unknown as RTCStatsReport,
) {
return {
mediaStreamTrack,
getRTCStatsReport: vi.fn(async () => getStats()),
};
}
function statsReport(
records: Array<Record<string, unknown> & { id: string }>,
): RTCStatsReport {
return new Map(
records.map((record) => [record.id, record]),
) as unknown as RTCStatsReport;
}
function receiverReport(kind: "video" | "audio"): RTCStatsReport {
return statsReport([
{
id: `${kind}-in`,
type: "inbound-rtp",
timestamp: 1_000,
kind,
trackIdentifier: `${kind}-1`,
estimatedPlayoutTimestamp: kind === "video" ? 10_000 : 10_012,
},
]);
}
function senderReport(
trackId: string,
timestamp: number,
bytesSent: number,
framesEncoded: number,
): RTCStatsReport {
return statsReport([
{
id: "video-out",
type: "outbound-rtp",
timestamp,
kind: "video",
transportId: "transport",
codecId: "codec",
mediaSourceId: "video-source",
bytesSent,
framesEncoded,
framesPerSecond: 57,
frameWidth: 1920,
frameHeight: 1080,
totalEncodeTime: framesEncoded * 0.004,
qualityLimitationReason: "bandwidth",
encoderImplementation: "ExternalEncoder",
powerEfficientEncoder: true,
},
{
id: "video-source",
type: "media-source",
timestamp,
trackIdentifier: trackId,
framesPerSecond: 59,
},
{
id: "transport",
type: "transport",
timestamp,
},
{
id: "codec",
type: "codec",
timestamp,
transportId: "transport",
mimeType: "video/H264",
sdpFmtpLine: "profile-level-id=42e01f;packetization-mode=1",
},
]);
}
function simulcastSenderReport(
trackId: string,
timestamp: number,
lowBytesSent: number,
highBytesSent: number,
): RTCStatsReport {
const layer = (id: string, bytesSent: number, framesEncoded: number) => ({
id,
type: "outbound-rtp",
timestamp,
kind: "video",
mediaSourceId: "video-source",
bytesSent,
framesEncoded,
qualityLimitationReason: "none",
qualityLimitationDurations: {
none: timestamp / 1_000,
bandwidth: 0,
cpu: 0,
other: 0,
},
});
return statsReport([
layer("video-low", lowBytesSent, timestamp / 40),
layer("video-high", highBytesSent, timestamp / 20),
{
id: "video-source",
type: "media-source",
timestamp,
trackIdentifier: trackId,
},
]);
}
function audioSenderReport(
trackId: string,
timestamp: number,
bytesSent: number,
): RTCStatsReport {
return statsReport([
{
id: "audio-out",
type: "outbound-rtp",
timestamp,
kind: "audio",
ssrc: 202,
transportId: "audio-transport",
mediaSourceId: "audio-source",
codecId: "audio-codec",
bytesSent,
},
{
id: "audio-source",
type: "media-source",
timestamp,
kind: "audio",
trackIdentifier: trackId,
},
{
id: "audio-transport",
type: "transport",
timestamp,
},
{
id: "audio-codec",
type: "codec",
timestamp,
transportId: "audio-transport",
mimeType: "audio/opus",
},
]);
}
function stream(...tracks: MediaStreamTrack[]): MediaStream {
return new FakeMediaStream(tracks) as unknown as MediaStream;
}
function deferred(): {
promise: Promise<void>;
resolve: () => void;
} {
let resolve = (): void => undefined;
const promise = new Promise<void>((next) => {
resolve = next;
});
return { promise, resolve };
}
beforeEach(() => {
livekit.state.rooms.length = 0;
livekit.state.connectGate = null;
livekit.state.nextSenderParameterError = null;
vi.stubGlobal("MediaStream", FakeMediaStream);
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("SfuPublisher", () => {
it("reports the aggregate bitrate of active simulcast layers", async () => {
vi.useFakeTimers();
const updates: Array<ConnectionMetrics | null> = [];
const publisher = new SfuPublisher({
onStats: (metrics) => updates.push(metrics),
});
const video = track("video", "video-1");
await publisher.connect(connection);
await publisher.activate(stream(video), qualityProfile);
const sender = livekit.state.rooms[0].localParticipant.publications[0].track
.sender;
sender.getStats
.mockResolvedValueOnce(
simulcastSenderReport(video.id, 1_000, 100_000, 200_000),
)
.mockResolvedValueOnce(
simulcastSenderReport(video.id, 3_000, 300_000, 600_000),
);
await vi.advanceTimersByTimeAsync(2_000);
expect(updates.at(-1)?.bitrateKbps).toBeNull();
expect(
sfuPublisherQualityEvidenceFromMetrics(
updates.at(-1)!,
7,
"simulcast_publication_12345678",
),
).toMatchObject({ state: "unknown", sampleTimestampMs: null });
await vi.advanceTimersByTimeAsync(2_000);
expect(updates.at(-1)?.bitrateKbps).toBe(2_400);
expect(updates.at(-1)?.sampleTimestampMs).toBe(3_000);
expect(
sfuPublisherQualityEvidenceFromMetrics(
updates.at(-1)!,
7,
"simulcast_publication_12345678",
),
).toMatchObject({
state: "healthy",
sampleTimestampMs: 3_000,
});
});
it("enables balanced SFU adaptation after startup frames", async () => {
vi.useFakeTimers();
const publisher = new SfuPublisher();
const video = track("video", "startup-video");
const balancedProfile = {
...qualityProfile,
degradationPreference: "balanced",
} as const;
await publisher.connect(connection);
await publisher.activate(stream(video), balancedProfile);
const localTrack = livekit.state.rooms[0].localParticipant.publications[0]
.track;
const sender = localTrack.sender;
expect(localTrack.savedDegradationPreference).toBe("maintain-resolution");
expect(sender.parameters.degradationPreference).toBe(
"maintain-resolution",
);
sender.getStats
.mockResolvedValueOnce(senderReport(video.id, 1_000, 10_000, 4))
.mockResolvedValueOnce(senderReport(video.id, 2_000, 20_000, 5));
await vi.advanceTimersByTimeAsync(2_000);
expect(localTrack.savedDegradationPreference).toBe("maintain-resolution");
expect(sender.setParameters).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(2_000);
await vi.waitFor(() =>
expect(localTrack.savedDegradationPreference).toBe("balanced"),
);
expect(sender.parameters.degradationPreference).toBe("balanced");
expect(sender.setParameters).toHaveBeenCalledTimes(2);
});
it("samples only the current published sender and clears replaced evidence", async () => {
vi.useFakeTimers();
const updates: Array<ConnectionMetrics | null> = [];
const publisher = new SfuPublisher({
onStats: (metrics) => updates.push(metrics),
});
const previousVideo = track("video", "video-1");
previousVideo.getSettings = () => ({
width: 1920,
height: 1080,
frameRate: 60,
});
await publisher.connect(connection);
await publisher.activate(stream(previousVideo), qualityProfile);
const sender = livekit.state.rooms[0].localParticipant.publications[0].track
.sender;
sender.getStats.mockResolvedValueOnce(
senderReport(previousVideo.id, 1_000, 100_000, 60),
);
await vi.advanceTimersByTimeAsync(2_000);
expect(updates.at(-1)).toMatchObject({
captureWidth: 1920,
captureHeight: 1080,
captureFramesPerSecond: 60,
trackIdentifier: previousVideo.id,
rtpRid: null,
mediaSourceFramesPerSecond: 59,
framesPerSecond: 57,
resolution: "1920x1080",
codec: "video/H264",
codecProfile: "profile-level-id=42e01f",
encoderImplementation: "ExternalEncoder",
powerEfficientEncoder: true,
qualityLimitationReason: "bandwidth",
nativeEdgeQualityState: "unknown",
});
sender.getStats.mockResolvedValueOnce(
senderReport(previousVideo.id, 3_000, 300_000, 180),
);
await vi.advanceTimersByTimeAsync(2_000);
expect(updates.at(-1)).toMatchObject({
bitrateKbps: 800,
intervalFramesEncoded: 120,
intervalEncodeTimeMs: 480,
intervalEncodeMs: 4,
});
let resolveOld!: (report: RTCStatsReport) => void;
sender.getStats.mockImplementationOnce(
() => new Promise<RTCStatsReport>((resolve) => { resolveOld = resolve; }),
);
await vi.advanceTimersByTimeAsync(2_000);
const nextVideo = track("video", "video-2");
nextVideo.getSettings = () => ({
width: 1280,
height: 720,
frameRate: 30,
});
await expect(publisher.replaceStream(stream(nextVideo))).resolves.toBe(true);
expect(updates.at(-1)).toBeNull();
resolveOld(senderReport(previousVideo.id, 5_000, 500_000, 300));
await Promise.resolve();
expect(updates.at(-1)).toBeNull();
sender.getStats.mockResolvedValueOnce(
senderReport(nextVideo.id, 4_000, 400_000, 240),
);
await vi.advanceTimersByTimeAsync(2_000);
expect(updates.at(-1)?.trackIdentifier).toBe(nextVideo.id);
const updateCount = updates.length;
const localTrack = livekit.state.rooms[0].localParticipant.publications[0]
.track;
const replacementSender = localTrack.replaceSenderForTest();
replacementSender.getStats.mockResolvedValueOnce(
senderReport(nextVideo.id, 6_000, 500_000, 270),
);
await vi.advanceTimersByTimeAsync(2_000);
expect(updates[updateCount]).toBeNull();
expect(updates.at(-1)).toMatchObject({
trackIdentifier: nextVideo.id,
framesPerSecond: 57,
});
await publisher.deactivate();
expect(updates.at(-1)).toBeNull();
});
it("merges the current audio sender report and resets after its sender changes", async () => {
vi.useFakeTimers();
const updates: Array<ConnectionMetrics | null> = [];
const publisher = new SfuPublisher({
onStats: (metrics) => updates.push(metrics),
});
const video = track("video", "video-1");
const audio = track("audio", "audio-1");
await publisher.connect(connection);
await publisher.activate(stream(video, audio), qualityProfile);
const room = livekit.state.rooms[0];
const videoSender = room.localParticipant.publications[0].track.sender;
const audioTrack = room.localParticipant.publications[1].track;
const audioSender = audioTrack.sender;
videoSender.getStats
.mockResolvedValueOnce(senderReport(video.id, 1_000, 100_000, 60))
.mockResolvedValueOnce(senderReport(video.id, 3_000, 300_000, 180));
audioSender.getStats
.mockResolvedValueOnce(audioSenderReport(audio.id, 1_000, 10_000))
.mockResolvedValueOnce(audioSenderReport(audio.id, 3_000, 50_000));
await vi.advanceTimersByTimeAsync(2_000);
expect(updates.at(-1)).toMatchObject({
audioBitrateKbps: null,
audioCodec: "audio/opus",
});
await vi.advanceTimersByTimeAsync(2_000);
expect(updates.at(-1)).toMatchObject({
audioBitrateKbps: 160,
audioCodec: "audio/opus",
});
const replacementSender = audioTrack.replaceSenderForTest();
videoSender.getStats
.mockResolvedValueOnce(senderReport(video.id, 5_000, 500_000, 300))
.mockResolvedValueOnce(senderReport(video.id, 7_000, 700_000, 420));
replacementSender.getStats
.mockResolvedValueOnce(audioSenderReport(audio.id, 5_000, 90_000))
.mockResolvedValueOnce(audioSenderReport(audio.id, 7_000, 130_000));
await vi.advanceTimersByTimeAsync(2_000);
expect(updates.at(-2)).toBeNull();
expect(updates.at(-1)?.audioBitrateKbps).toBeNull();
await vi.advanceTimersByTimeAsync(2_000);
expect(updates.at(-1)?.audioBitrateKbps).toBe(160);
expect(audioSender.getStats).toHaveBeenCalledTimes(2);
expect(replacementSender.getStats).toHaveBeenCalledTimes(2);
});
it("enables Dynacast while preparing SFU fallback", async () => {
const publisher = new SfuPublisher();
await expect(publisher.connect(connection)).resolves.toBe(true);
const room = livekit.state.rooms[0];
expect(room.options).toEqual({
disconnectOnPageLeave: false,
dynacast: true,
stopLocalTrackOnUnpublish: false,
});
expect(room.connect).toHaveBeenCalledWith(connection.url, connection.token, {
autoSubscribe: false,
rtcConfig: { iceServers: [] },
});
expect(room.localParticipant.publishTrack).not.toHaveBeenCalled();
await publisher.disconnect();
await publisher.disconnect();
expect(room.disconnect).toHaveBeenCalledOnce();
expect(room.disconnect).toHaveBeenCalledWith(false);
});
it("retains the bounded stage after connection setup fails", async () => {
livekit.state.connectGate = Promise.reject(new Error("connect failed"));
const publisher = new SfuPublisher();
await expect(publisher.connect(connection)).rejects.toThrow("connect failed");
expect(publisher.getFailureStage()).toBe("connect");
expect(livekit.state.rooms[0]?.disconnect).toHaveBeenCalledWith(false);
});
it("leaves VP8 screen-share simulcast layers to LiveKit defaults", async () => {
const publisher = new SfuPublisher();
const video = track("video", "video-1");
const audio = track("audio", "audio-1");
await publisher.connect(connection);
await expect(
publisher.activate(stream(video, audio), qualityProfile),
).resolves.toBe(true);
const room = livekit.state.rooms[0];
const sender = room.localParticipant.publications[0].track.sender;
const publishedVideo = room.localParticipant.publishTrack.mock.calls[0]?.[0];
expect(publishedVideo).not.toBe(video);
expect(publishedVideo?.id).toBe(video.id);
expect(room.localParticipant.publishTrack).toHaveBeenNthCalledWith(1, publishedVideo, {
source: Track.Source.ScreenShare,
backupCodec: false,
videoCodec: "vp8",
screenShareEncoding: {
maxBitrate: 8_000_000,
maxFramerate: 60,
},
degradationPreference: "maintain-resolution",
});
expect(
room.localParticipant.publishTrack.mock.calls[0]?.[1],
).not.toHaveProperty("simulcast");
expect(room.localParticipant.publishTrack).toHaveBeenNthCalledWith(2, audio, {
source: Track.Source.ScreenShareAudio,
audioPreset: { maxBitrate: 128_000 },
forceStereo: true,
dtx: false,
red: false,
});
expect(sender.setParameters).toHaveBeenCalledOnce();
expect(sender.parameters.encodings).toEqual([
{
maxBitrate: 8_000_000,
maxFramerate: 60,
scaleResolutionDownBy: 1,
},
]);
expect(sender.parameters.encodings[0]).not.toHaveProperty("rid");
expect(publisher.getSenderParameters()).toEqual({
requested: {
maxBitrate: 8_000_000,
maxFramerate: 60,
scaleResolutionDownBy: 1,
degradationPreference: "maintain-resolution",
scalabilityMode: null,
},
applied: {
maxBitrate: 8_000_000,
maxFramerate: 60,
scaleResolutionDownBy: 1,
degradationPreference: "maintain-resolution",
scalabilityMode: null,
},
mismatches: [],
});
await expect(publisher.deactivate()).resolves.toBe(true);
expect(room.localParticipant.unpublishTrack).toHaveBeenCalledTimes(2);
expect(publishedVideo?.stop).toHaveBeenCalledOnce();
expect(video.stop).not.toHaveBeenCalled();
expect(room.disconnect).not.toHaveBeenCalled();
});
it("owns SFU video clone controls without stopping the capture source", async () => {
const publisher = new SfuPublisher();
const sourceVideo = track("video", "capture-video");
sourceVideo.contentHint = "motion";
await publisher.connect(connection);
await publisher.activate(stream(sourceVideo), qualityProfile);
const publishedVideo = livekit.state.rooms[0].localParticipant.publications[0]
.rawTrack;
expect(publishedVideo).not.toBe(sourceVideo);
expect(publishedVideo.contentHint).toBe("motion");
publisher.setPaused(true);
expect(publishedVideo.enabled).toBe(false);
expect(sourceVideo.enabled).toBe(true);
publisher.setPaused(false);
await expect(
publisher.updateProfile(QUALITY_PROFILES["720p30"]),
).resolves.toBe(true);
expect(publishedVideo.applyConstraints).toHaveBeenLastCalledWith({
width: { ideal: 1280, max: 1280 },
height: { ideal: 720, max: 720 },
frameRate: { ideal: 30, max: 30 },
});
await publisher.disconnect();
expect(publishedVideo.stop).toHaveBeenCalledOnce();
expect(sourceVideo.stop).not.toHaveBeenCalled();
});
it.each([
["saver", 64_000],
["music", 128_000],
["very-high", 192_000],
] as const)("maps the %s audio preset to %i bps", async (screenAudioQuality, bitrate) => {
const publisher = new SfuPublisher();
const video = track("video", `video-${screenAudioQuality}`);
const audio = track("audio", `audio-${screenAudioQuality}`);
await publisher.connect(connection);
await expect(
publisher.activate(stream(video, audio), {
...qualityProfile,
screenAudioQuality,
}),
).resolves.toBe(true);
expect(
livekit.state.rooms[0]?.localParticipant.publishTrack,
).toHaveBeenNthCalledWith(2, audio, {
source: Track.Source.ScreenShareAudio,
audioPreset: { maxBitrate: bitrate },
forceStereo: true,
dtx: false,
red: false,
});
});
it("fails closed when initial sender configuration is rejected", async () => {
const disconnected = vi.fn();
const publisher = new SfuPublisher({ onDisconnected: disconnected });
const sourceVideo = track("video", "video-1");
await publisher.connect(connection);
livekit.state.nextSenderParameterError = new Error("parameters rejected");
await expect(
publisher.activate(stream(sourceVideo), qualityProfile),
).rejects.toThrow("parameters rejected");
const publishedVideo = livekit.state.rooms[0].localParticipant.publications[0]
.rawTrack;
expect(publishedVideo.stop).toHaveBeenCalledOnce();
expect(sourceVideo.stop).not.toHaveBeenCalled();
expect(livekit.state.rooms[0].disconnect).toHaveBeenCalledWith(false);
expect(disconnected).toHaveBeenCalledOnce();
expect(publisher.getSenderParameters()).toBeNull();
expect(publisher.getFailureStage()).toBe("sender-config");
});
it("records an unexpected active transport disconnect", async () => {
const disconnected = vi.fn();
const publisher = new SfuPublisher({ onDisconnected: disconnected });
await publisher.connect(connection);
await publisher.activate(stream(track("video", "video-1")), qualityProfile);
livekit.state.rooms[0].emit(RoomEvent.Disconnected);
expect(publisher.getFailureStage()).toBe("transport");
expect(disconnected).toHaveBeenCalledOnce();
});
it("updates the active sender profile without republishing", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(stream(track("video", "video-1")), qualityProfile);
const room = livekit.state.rooms[0];
const videoPublication = room.localParticipant.publications[0];
await expect(
publisher.updateProfile({
resolution: "720p",
maxFramerate: 30,
maxBitrate: 3_000_000,
degradationPreference: "balanced",
}),
).resolves.toBe(true);
expect(videoPublication.track.sender.setParameters).toHaveBeenCalledWith(
expect.objectContaining({
degradationPreference: "balanced",
encodings: [
expect.objectContaining({
maxBitrate: 3_000_000,
maxFramerate: 30,
scaleResolutionDownBy: 1,
}),
],
}),
);
expect(room.localParticipant.publishTrack).toHaveBeenCalledOnce();
expect(publisher.getQualityWarning()).toBeNull();
});
it("updates an active audio sender and retained options without republishing", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(
stream(track("video", "video-1"), track("audio", "audio-1")),
{ ...qualityProfile, screenAudioQuality: "saver" },
);
const room = livekit.state.rooms[0];
const videoPublication = room.localParticipant.publications[0];
const audioPublication = room.localParticipant.publications[1];
await expect(
publisher.updateProfile({
...qualityProfile,
screenAudioQuality: "very-high",
}),
).resolves.toBe(true);
expect(videoPublication.track.sender.setParameters).toHaveBeenCalledOnce();
expect(audioPublication.track.sender.setParameters).toHaveBeenCalledTimes(2);
expect(audioPublication.track.sender.parameters.encodings[0]?.maxBitrate).toBe(
192_000,
);
expect(audioPublication.options).toMatchObject({
audioPreset: { maxBitrate: 192_000 },
forceStereo: true,
dtx: false,
red: false,
});
expect(publisher.getAudioSenderParameters()).toEqual({
requestedMaxBitrate: 192_000,
appliedMaxBitrate: 192_000,
mismatch: false,
});
expect(room.localParticipant.publishTrack).toHaveBeenCalledTimes(2);
await room.localParticipant.republishAllTracks();
expect(room.localParticipant.republishedOptions[1]).toMatchObject({
audioPreset: { maxBitrate: 192_000 },
forceStereo: true,
dtx: false,
red: false,
});
});
it("publishes one H264 source without a backup codec", async () => {
const publisher = new SfuPublisher();
const video = track("video", "video-h264");
await publisher.connect(connection);
await expect(
publisher.activate(stream(video), qualityProfile, "h264"),
).resolves.toBe(true);
const publication = livekit.state.rooms[0].localParticipant.publications[0];
expect(publication.options).toMatchObject({
source: Track.Source.ScreenShare,
backupCodec: false,
videoCodec: "h264",
});
await expect(
publisher.updateProfile(QUALITY_PROFILES["720p30"]),
).resolves.toBe(true);
expect(publication.options.videoCodec).toBe("h264");
});
it("keeps media and prior audio readback when a live update fails", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(
stream(track("video", "video-1"), track("audio", "audio-1")),
{ ...qualityProfile, screenAudioQuality: "music" },
);
const room = livekit.state.rooms[0];
const videoPublication = room.localParticipant.publications[0];
const audioPublication = room.localParticipant.publications[1];
audioPublication.track.sender.failNextSetParameters = true;
await expect(
publisher.updateProfile({
...qualityProfile,
screenAudioQuality: "very-high",
}),
).resolves.toBe(false);
expect(publisher.getAudioSenderParameters()?.appliedMaxBitrate).toBe(
128_000,
);
expect(publisher.getQualityWarning()).toContain(
"应用 SFU 音频发送参数失败",
);
expect(audioPublication.track.sender.parameters.encodings[0]?.maxBitrate).toBe(
128_000,
);
expect(audioPublication.options).toMatchObject({
audioPreset: { maxBitrate: 192_000 },
});
expect(videoPublication.track.sender.setParameters).toHaveBeenCalledOnce();
expect(room.disconnect).not.toHaveBeenCalled();
expect(room.localParticipant.publishTrack).toHaveBeenCalledTimes(2);
await expect(
publisher.updateProfile({
...qualityProfile,
screenAudioQuality: "very-high",
}),
).resolves.toBe(true);
expect(publisher.getAudioSenderParameters()?.appliedMaxBitrate).toBe(
192_000,
);
expect(publisher.getQualityWarning()).toBeNull();
});
it("keeps rapid SFU audio updates last-wins", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(
stream(track("video", "video-1"), track("audio", "audio-1")),
{ ...qualityProfile, screenAudioQuality: "music" },
);
const room = livekit.state.rooms[0];
const audioPublication = room.localParticipant.publications[1];
const audioSender = audioPublication.track.sender;
audioSender.deferNextSetParameters = true;
const saver = publisher.updateProfile({
...qualityProfile,
screenAudioQuality: "saver",
});
await vi.waitFor(() =>
expect(audioSender.setParameters).toHaveBeenCalledTimes(2),
);
const music = publisher.updateProfile({
...qualityProfile,
screenAudioQuality: "music",
});
expect(audioPublication.options).toMatchObject({
audioPreset: { maxBitrate: 128_000 },
});
audioSender.releaseDeferredSetParameters();
await expect(saver).resolves.toBe(false);
await expect(music).resolves.toBe(true);
expect(audioSender.parameters.encodings[0]?.maxBitrate).toBe(128_000);
expect(publisher.getAudioSenderParameters()?.appliedMaxBitrate).toBe(
128_000,
);
expect(room.localParticipant.republishAllTracks).not.toHaveBeenCalled();
});
it("reapplies the latest audio ceiling to a replacement sender after reconnect", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(
stream(track("video", "video-1"), track("audio", "audio-1")),
{ ...qualityProfile, screenAudioQuality: "music" },
);
const room = livekit.state.rooms[0];
const audioPublication = room.localParticipant.publications[1];
const oldSender = audioPublication.track.sender;
oldSender.deferNextSetParameters = true;
const updating = publisher.updateProfile({
...qualityProfile,
screenAudioQuality: "very-high",
});
await vi.waitFor(() =>
expect(oldSender.setParameters).toHaveBeenCalledTimes(2),
);
const replacementSender = audioPublication.track.replaceSenderForTest();
room.emit(RoomEvent.Reconnected);
oldSender.releaseDeferredSetParameters();
await expect(updating).resolves.toBe(false);
await vi.waitFor(() =>
expect(replacementSender.setParameters).toHaveBeenCalledOnce(),
);
expect(replacementSender.parameters.encodings[0]?.maxBitrate).toBe(192_000);
expect(publisher.getAudioSenderParameters()?.appliedMaxBitrate).toBe(
192_000,
);
expect(room.localParticipant.republishAllTracks).not.toHaveBeenCalled();
});
it("rebinds republished tracks after a full reconnect", async () => {
const publisher = new SfuPublisher();
const sourceVideo = track("video", "video-1");
await publisher.connect(connection);
await publisher.activate(
stream(sourceVideo, track("audio", "audio-1")),
{ ...qualityProfile, screenAudioQuality: "music" },
);
await expect(
publisher.updateProfile({
...qualityProfile,
screenAudioQuality: "very-high",
}),
).resolves.toBe(true);
const room = livekit.state.rooms[0];
const oldVideoPublication = room.localParticipant.publications[0];
const oldAudioPublication = room.localParticipant.publications[1];
const oldPublishedVideo = oldVideoPublication.rawTrack;
await room.localParticipant.republishForReconnect();
const videoPublication = room.localParticipant.getTrackPublication(
Track.Source.ScreenShare,
)!;
const audioPublication = room.localParticipant.getTrackPublication(
Track.Source.ScreenShareAudio,
)!;
expect(videoPublication).not.toBe(oldVideoPublication);
expect(audioPublication).not.toBe(oldAudioPublication);
room.emit(RoomEvent.Reconnected);
await vi.waitFor(() =>
expect(videoPublication.track.currentTrack).not.toBe(oldPublishedVideo),
);
expect(publisher.getAudioSenderParameters()).not.toBeNull();
expect(videoPublication.track.currentTrack.id).toBe(sourceVideo.id);
expect(oldPublishedVideo.stop).toHaveBeenCalledOnce();
expect(sourceVideo.stop).not.toHaveBeenCalled();
expect(audioPublication.track.sender.setParameters).not.toHaveBeenCalled();
expect(audioPublication.track.sender.parameters.encodings[0]?.maxBitrate).toBe(
192_000,
);
expect(publisher.getAudioSenderParameters()?.appliedMaxBitrate).toBe(
192_000,
);
expect(publisher.getSenderParameters()).toBeNull();
expect(publisher.getQualityWarning()).toBeNull();
});
it("reconciles pause state onto a video clone committed after reconnect", async () => {
const publisher = new SfuPublisher();
const sourceVideo = track("video", "video-pause-race");
await publisher.connect(connection);
await publisher.activate(stream(sourceVideo), qualityProfile);
publisher.setPaused(true);
sourceVideo.enabled = false;
const room = livekit.state.rooms[0];
await room.localParticipant.republishForReconnect();
const publication = room.localParticipant.getTrackPublication(
Track.Source.ScreenShare,
)!;
const replaceGate = deferred();
publication.track.replaceTrack.mockImplementationOnce(
async (nextTrack: MediaStreamTrack) => {
await replaceGate.promise;
publication.track.currentTrack = nextTrack;
publication.track.sender.track = nextTrack;
},
);
room.emit(RoomEvent.Reconnected);
await vi.waitFor(() =>
expect(publication.track.replaceTrack).toHaveBeenCalledOnce(),
);
sourceVideo.enabled = true;
publisher.setPaused(false);
replaceGate.resolve();
await vi.waitFor(() =>
expect(publication.track.currentTrack).not.toBe(publication.rawTrack),
);
expect(publication.track.currentTrack.enabled).toBe(true);
});
it("retains video sender readback across a signal-only reconnect", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(
stream(track("video", "video-1"), track("audio", "audio-1")),
{ ...qualityProfile, screenAudioQuality: "music" },
);
const room = livekit.state.rooms[0];
const videoSender = room.localParticipant.publications[0].track.sender;
const audioSender = room.localParticipant.publications[1].track.sender;
videoSender.setParameters.mockImplementationOnce(async (parameters) => {
videoSender.parameters = {
...parameters,
encodings: parameters.encodings.map((encoding) => ({
...encoding,
maxBitrate: 2_000_000,
})),
};
});
await expect(
publisher.updateProfile({
...qualityProfile,
maxBitrate: 5_000_000,
degradationPreference: "balanced",
screenAudioQuality: "music",
}),
).resolves.toBe(true);
const senderParameters = publisher.getSenderParameters();
const qualityWarning = publisher.getQualityWarning();
expect(senderParameters).not.toBeNull();
expect(qualityWarning).not.toBeNull();
const audioUpdates = audioSender.setParameters.mock.calls.length;
room.emit(RoomEvent.Reconnected);
await vi.waitFor(() =>
expect(publisher.getAudioSenderParameters()?.appliedMaxBitrate).toBe(
128_000,
),
);
expect(audioSender.setParameters).toHaveBeenCalledTimes(audioUpdates);
expect(publisher.getSenderParameters()).toBe(senderParameters);
expect(publisher.getQualityWarning()).toBe(qualityWarning);
expect(room.disconnect).not.toHaveBeenCalled();
});
it("keeps publisher stats continuous across a signal-only reconnect", async () => {
vi.useFakeTimers();
const updates: Array<ConnectionMetrics | null> = [];
const publisher = new SfuPublisher({
onStats: (metrics) => updates.push(metrics),
});
const video = track("video", "video-1");
await publisher.connect(connection);
await publisher.activate(stream(video), qualityProfile);
const room = livekit.state.rooms[0];
const sender = room.localParticipant.publications[0].track.sender;
sender.getStats
.mockResolvedValueOnce(senderReport(video.id, 1_000, 100_000, 60))
.mockResolvedValueOnce(senderReport(video.id, 3_000, 300_000, 180))
.mockResolvedValueOnce(senderReport(video.id, 5_000, 500_000, 300));
await vi.advanceTimersByTimeAsync(4_000);
const resets = updates.filter((update) => update === null).length;
room.emit(RoomEvent.Reconnected);
await Promise.resolve();
await vi.advanceTimersByTimeAsync(2_000);
expect(updates.filter((update) => update === null)).toHaveLength(resets);
expect(updates.at(-1)).toMatchObject({
bitrateKbps: 800,
intervalFramesEncoded: 120,
});
});
it.each(["publication", "sender"] as const)(
"fails the SFU route when republished audio loses its %s",
async (missing) => {
const disconnected = vi.fn();
const publisher = new SfuPublisher({ onDisconnected: disconnected });
await publisher.connect(connection);
await publisher.activate(
stream(track("video", "video-1"), track("audio", "audio-1")),
{ ...qualityProfile, screenAudioQuality: "music" },
);
const room = livekit.state.rooms[0];
await room.localParticipant.republishForReconnect();
const audioPublication = room.localParticipant.getTrackPublication(
Track.Source.ScreenShareAudio,
)!;
if (missing === "publication") {
room.localParticipant.publications.splice(
room.localParticipant.publications.indexOf(audioPublication),
1,
);
} else {
delete audioPublication.audioTrack;
}
room.emit(RoomEvent.Reconnected);
await vi.waitFor(() => expect(room.disconnect).toHaveBeenCalledWith(false));
expect(publisher.getFailureStage()).toBe("transport");
expect(disconnected).toHaveBeenCalledOnce();
},
);
it("retains the active profile for LiveKit track restart and republish", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(stream(track("video", "video-1")), qualityProfile);
const publication = livekit.state.rooms[0].localParticipant.publications[0];
const localTrack = publication.track;
await expect(
publisher.updateProfile({
resolution: "720p",
maxFramerate: 30,
maxBitrate: 3_000_000,
degradationPreference: "maintain-framerate",
}),
).resolves.toBe(true);
expect(localTrack.setDegradationPreference).toHaveBeenLastCalledWith(
"maintain-framerate",
);
expect(
localTrack.setDegradationPreference.mock.invocationCallOrder.at(-1)!,
).toBeLessThan(
localTrack.sender.setParameters.mock.invocationCallOrder.at(-1)!,
);
expect(localTrack.savedDegradationPreference).toBe("maintain-framerate");
expect(localTrack.sender.parameters).toMatchObject({
degradationPreference: "maintain-framerate",
encodings: [
{
maxBitrate: 3_000_000,
maxFramerate: 30,
scaleResolutionDownBy: 1,
},
],
});
expect(localTrack.publishOptions).toBe(publication.options);
expect(publication.options).toMatchObject({
source: Track.Source.ScreenShare,
backupCodec: false,
screenShareEncoding: {
maxBitrate: 3_000_000,
maxFramerate: 30,
},
degradationPreference: "maintain-framerate",
});
await livekit.state.rooms[0].localParticipant.republishAllTracks();
expect(
livekit.state.rooms[0].localParticipant.republishedOptions[0],
).toMatchObject({
screenShareEncoding: {
maxBitrate: 3_000_000,
maxFramerate: 30,
},
degradationPreference: "maintain-framerate",
});
});
it("restores LiveKit retained state when saving a new profile fails", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(stream(track("video", "video-1")), qualityProfile);
const publication = livekit.state.rooms[0].localParticipant.publications[0];
const localTrack = publication.track;
localTrack.setDegradationPreference.mockRejectedValueOnce(
new Error("preference save failed"),
);
await expect(
publisher.updateProfile({
resolution: "720p",
maxFramerate: 30,
maxBitrate: 3_000_000,
degradationPreference: "maintain-framerate",
}),
).resolves.toBe(false);
expect(localTrack.setDegradationPreference).toHaveBeenLastCalledWith(
"maintain-resolution",
);
expect(localTrack.savedDegradationPreference).toBe("maintain-resolution");
expect(localTrack.publishOptions).toBe(publication.options);
expect(publication.options).toMatchObject({
screenShareEncoding: {
maxBitrate: 8_000_000,
maxFramerate: 60,
},
degradationPreference: "maintain-resolution",
});
expect(publisher.getSenderParameters()?.applied).toMatchObject({
maxBitrate: 8_000_000,
maxFramerate: 60,
degradationPreference: "maintain-resolution",
});
expect(publisher.getQualityWarning()).toContain("应用 SFU 发送参数失败");
expect(publisher.getQualityWarning()).not.toContain("preference save failed");
});
it("does not retain an update from a disconnected publisher generation", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(stream(track("video", "video-1")), qualityProfile);
const localTrack = livekit.state.rooms[0].localParticipant.publications[0].track;
const publication = livekit.state.rooms[0].localParticipant.publications[0];
const gate = deferred();
localTrack.setDegradationPreference.mockImplementationOnce(
async (preference) => {
await gate.promise;
localTrack.savedDegradationPreference = preference;
},
);
const updating = publisher.updateProfile({
resolution: "720p",
maxFramerate: 30,
maxBitrate: 3_000_000,
degradationPreference: "maintain-framerate",
});
await vi.waitFor(() =>
expect(localTrack.setDegradationPreference).toHaveBeenCalledTimes(2),
);
await publisher.disconnect();
gate.resolve();
await expect(updating).resolves.toBe(false);
expect(publisher.getSenderParameters()).toBeNull();
expect(publisher.getQualityWarning()).toBeNull();
expect(localTrack.savedDegradationPreference).toBe("maintain-resolution");
expect(localTrack.sender.setParameters).toHaveBeenCalledTimes(1);
expect(publication.options).toMatchObject({
screenShareEncoding: {
maxBitrate: 8_000_000,
maxFramerate: 60,
},
degradationPreference: "maintain-resolution",
});
});
it("retains a visible warning when the SFU sender rewrites a parameter", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(stream(track("video", "video-1")), qualityProfile);
const sender = livekit.state.rooms[0].localParticipant.publications[0].track
.sender;
sender.setParameters.mockImplementationOnce(async (parameters) => {
sender.parameters = {
...parameters,
encodings: parameters.encodings.map((encoding) => ({
...encoding,
maxBitrate: 2_000_000,
})),
};
});
await expect(
publisher.updateProfile({
resolution: "1080p",
maxFramerate: 30,
maxBitrate: 5_000_000,
degradationPreference: "balanced",
}),
).resolves.toBe(true);
expect(publisher.getQualityWarning()).toContain("码率上限");
});
it("retains a visible warning when SFU sender parameters are rejected", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(stream(track("video", "video-1")), qualityProfile);
const publication = livekit.state.rooms[0].localParticipant.publications[0];
const localTrack = publication.track;
const sender = localTrack.sender;
sender.setParameters.mockRejectedValueOnce(new Error("unsupported"));
await expect(
publisher.updateProfile({
resolution: "720p",
maxFramerate: 30,
maxBitrate: 3_000_000,
degradationPreference: "maintain-framerate",
}),
).resolves.toBe(false);
expect(localTrack.setDegradationPreference).toHaveBeenLastCalledWith(
"maintain-resolution",
);
expect(localTrack.savedDegradationPreference).toBe("maintain-resolution");
expect(publication.options).toMatchObject({
screenShareEncoding: {
maxBitrate: 8_000_000,
maxFramerate: 60,
},
degradationPreference: "maintain-resolution",
});
expect(sender.parameters).toMatchObject({
degradationPreference: "maintain-resolution",
encodings: [
expect.objectContaining({
maxBitrate: 8_000_000,
maxFramerate: 60,
}),
],
});
expect(publisher.getQualityWarning()).toBe("应用 SFU 发送参数失败");
expect(publisher.getQualityWarning()).not.toContain("unsupported");
});
it("retains a sender rewrite warning after rolling back parameters", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(stream(track("video", "video-1")), qualityProfile);
const sender = livekit.state.rooms[0].localParticipant.publications[0].track
.sender;
sender.setParameters
.mockRejectedValueOnce(new Error("unsupported"))
.mockImplementationOnce(async (parameters) => {
sender.parameters = {
...parameters,
encodings: parameters.encodings.map((encoding) => ({
...encoding,
maxBitrate: 1_500_000,
})),
};
});
await expect(
publisher.updateProfile({
resolution: "720p",
maxFramerate: 30,
maxBitrate: 3_000_000,
degradationPreference: "balanced",
}),
).resolves.toBe(false);
expect(publisher.getSenderParameters()).toMatchObject({
requested: { maxBitrate: 8_000_000 },
applied: { maxBitrate: 1_500_000 },
mismatches: ["maxBitrate"],
});
expect(publisher.getQualityWarning()).toContain("应用 SFU 发送参数失败");
expect(publisher.getQualityWarning()).not.toContain("unsupported");
expect(publisher.getQualityWarning()).toContain("码率上限");
});
it("restores the previous video after a partially applied replacement fails", async () => {
const previousVideo = track("video", "video-1");
const nextVideo = track("video", "video-2");
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(stream(previousVideo), qualityProfile);
const localTrack = livekit.state.rooms[0].localParticipant.publications[0].track;
const previousPublishedVideo = localTrack.currentTrack;
localTrack.replaceTrack.mockImplementationOnce(async (replacement) => {
localTrack.currentTrack = replacement;
throw new Error("sender swap failed after applying");
});
await expect(publisher.replaceStream(stream(nextVideo))).resolves.toBe(false);
const nextPublishedVideo = localTrack.replaceTrack.mock.calls[0]?.[0];
expect(nextPublishedVideo).not.toBe(nextVideo);
expect(nextPublishedVideo?.id).toBe(nextVideo.id);
expect(localTrack.replaceTrack).toHaveBeenNthCalledWith(
2,
previousPublishedVideo,
);
expect(localTrack.currentTrack).toBe(previousPublishedVideo);
expect(nextPublishedVideo?.stop).toHaveBeenCalledOnce();
expect(previousPublishedVideo.stop).not.toHaveBeenCalled();
});
it("applies pause authority to an SFU source replacement while it is in flight", async () => {
const publisher = new SfuPublisher();
const previousAudio = track("audio", "audio-1");
const nextVideo = track("video", "video-2");
const nextAudio = track("audio", "audio-2");
await publisher.connect(connection);
await publisher.activate(
stream(track("video", "video-1"), previousAudio),
qualityProfile,
);
const room = livekit.state.rooms[0];
const videoPublication = room.localParticipant.publications[0].track;
const replaceGate = deferred();
videoPublication.replaceTrack.mockImplementationOnce(
async (replacement: MediaStreamTrack) => {
await replaceGate.promise;
videoPublication.currentTrack = replacement;
videoPublication.sender.track = replacement;
},
);
const replacing = publisher.replaceStream(stream(nextVideo, nextAudio));
await vi.waitFor(() =>
expect(videoPublication.replaceTrack).toHaveBeenCalledOnce(),
);
const replacementVideo = videoPublication.replaceTrack.mock.calls[0]![0]!;
publisher.setPaused(true);
expect(replacementVideo.enabled).toBe(false);
expect(nextAudio.enabled).toBe(false);
replaceGate.resolve();
await expect(replacing).resolves.toBe(true);
expect(videoPublication.currentTrack.enabled).toBe(false);
expect(room.localParticipant.publications[1].track.currentTrack.enabled).toBe(
false,
);
});
it("uses the screen audio preset when a replacement adds audio", async () => {
const publisher = new SfuPublisher();
const audio = track("audio", "audio-2");
await publisher.connect(connection);
await publisher.activate(stream(track("video", "video-1")), {
...qualityProfile,
screenAudioQuality: "very-high",
});
const room = livekit.state.rooms[0];
await expect(
publisher.replaceStream(stream(track("video", "video-2"), audio)),
).resolves.toBe(true);
expect(room.localParticipant.publishTrack).toHaveBeenNthCalledWith(2, audio, {
source: Track.Source.ScreenShareAudio,
audioPreset: { maxBitrate: 192_000 },
forceStereo: true,
dtx: false,
red: false,
});
});
it("reapplies and retains the current sender settings after replacing video", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(stream(track("video", "video-1")), qualityProfile);
const localTrack = livekit.state.rooms[0].localParticipant.publications[0].track;
await expect(
publisher.replaceStream(stream(track("video", "video-2"))),
).resolves.toBe(true);
expect(localTrack.sender.setParameters).toHaveBeenCalledTimes(2);
expect(publisher.getSenderParameters()?.requested).toMatchObject({
maxBitrate: 8_000_000,
maxFramerate: 60,
degradationPreference: "maintain-resolution",
});
});
it("restores sender settings when replacement configuration is rejected", async () => {
const previousVideo = track("video", "video-1");
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(stream(previousVideo), qualityProfile);
const localTrack = livekit.state.rooms[0].localParticipant.publications[0].track;
const previousPublishedVideo = localTrack.currentTrack;
localTrack.sender.setParameters.mockRejectedValueOnce(
new Error("replacement parameters rejected"),
);
await expect(
publisher.replaceStream(stream(track("video", "video-2"))),
).resolves.toBe(false);
expect(localTrack.replaceTrack).toHaveBeenCalledTimes(2);
expect(localTrack.currentTrack).toBe(previousPublishedVideo);
expect(localTrack.sender.setParameters).toHaveBeenCalledTimes(3);
expect(publisher.getSenderParameters()?.applied.maxBitrate).toBe(8_000_000);
expect(publisher.getQualityWarning()).toBe("切换 SFU 分享来源失败");
expect(publisher.getQualityWarning()).not.toContain(
"replacement parameters rejected",
);
});
it("disconnects fail-closed when replacement rollback also fails", async () => {
const disconnected = vi.fn();
const previousVideo = track("video", "video-1");
const publisher = new SfuPublisher({ onDisconnected: disconnected });
await publisher.connect(connection);
await publisher.activate(stream(previousVideo), qualityProfile);
const room = livekit.state.rooms[0];
const localTrack = room.localParticipant.publications[0].track;
localTrack.replaceTrack
.mockRejectedValueOnce(new Error("replace failed"))
.mockRejectedValueOnce(new Error("rollback failed"));
await expect(
publisher.replaceStream(stream(track("video", "video-2"))),
).rejects.toThrow("rollback failed");
expect(room.disconnect).toHaveBeenCalledWith(false);
expect(disconnected).toHaveBeenCalledOnce();
});
it("does not resurrect a stale connection after disconnect", async () => {
const gate = deferred();
livekit.state.connectGate = gate.promise;
const publisher = new SfuPublisher();
const connecting = publisher.connect(connection);
await vi.waitFor(() => expect(livekit.state.rooms).toHaveLength(1));
const room = livekit.state.rooms[0];
await publisher.disconnect();
gate.resolve();
await expect(connecting).resolves.toBe(false);
expect(room.localParticipant.publishTrack).not.toHaveBeenCalled();
expect(room.disconnect).toHaveBeenCalledOnce();
});
});
describe("SfuSubscriber", () => {
it("uses cumulative frames for a fresh exact subscriber and growth after rearm", async () => {
vi.useFakeTimers();
let framesDecoded = 1;
const proofs: number[] = [];
const subscriber = new SfuSubscriber({
onStream: vi.fn(),
onFirstDecodedFrame: () => {
proofs.push(framesDecoded);
return true;
},
});
await subscriber.connect(connection);
const room = livekit.state.rooms[0];
expect(room.options).toEqual({ disconnectOnPageLeave: false });
const host = new livekit.FakeRemoteParticipant("host");
const publication = new livekit.FakeRemotePublication(
"host-video",
Track.Source.ScreenShare,
);
host.add(publication);
room.remoteParticipants.set("host", host);
expect(subscriber.activate()).toBe(true);
subscriber.armDecodedFrameProof();
const video = track("video", "video-1");
room.emit(
RoomEvent.TrackSubscribed,
remoteTrack(video, () =>
statsReport([
{
id: "video-in",
type: "inbound-rtp",
timestamp: 1_000,
kind: "video",
framesDecoded,
},
]),
),
publication,
host,
);
await vi.waitFor(() => expect(proofs).toEqual([1]));
proofs.length = 0;
framesDecoded = 7;
subscriber.armDecodedFrameProof(true);
await Promise.resolve();
await vi.advanceTimersByTimeAsync(100);
expect(proofs).toEqual([]);
framesDecoded = 8;
await vi.advanceTimersByTimeAsync(300);
expect(proofs).toEqual([8]);
subscriber.deactivate();
framesDecoded = 9;
await vi.advanceTimersByTimeAsync(500);
expect(proofs).toEqual([8]);
});
it("retains pending frame proof through LiveKit full reconnect order", async () => {
vi.useFakeTimers();
const proofs = vi.fn(() => true);
const subscriber = new SfuSubscriber({
onStream: vi.fn(),
onFirstDecodedFrame: proofs,
});
await subscriber.connect(connection);
const room = livekit.state.rooms[0];
const oldHost = new livekit.FakeRemoteParticipant("host");
const oldPublication = new livekit.FakeRemotePublication(
"host-video-before-reconnect",
Track.Source.ScreenShare,
);
oldHost.add(oldPublication);
room.remoteParticipants.set("host", oldHost);
expect(subscriber.activate()).toBe(true);
subscriber.armDecodedFrameProof();
let releaseOldRead!: () => void;
const oldRead = new Promise<void>((resolve) => {
releaseOldRead = resolve;
});
room.emit(
RoomEvent.TrackSubscribed,
remoteTrack(track("video", "video-before-reconnect"), async () => {
await oldRead;
return statsReport([]);
}),
oldPublication,
oldHost,
);
room.remoteParticipants.delete("host");
room.emit(RoomEvent.ParticipantDisconnected, oldHost);
room.emit(RoomEvent.Reconnecting);
const newHost = new livekit.FakeRemoteParticipant("host");
const newPublication = new livekit.FakeRemotePublication(
"host-video-after-reconnect",
Track.Source.ScreenShare,
);
newHost.add(newPublication);
room.remoteParticipants.set("host", newHost);
room.emit(RoomEvent.Reconnected);
room.emit(
RoomEvent.TrackSubscribed,
remoteTrack(track("video", "video-after-reconnect"), async () =>
statsReport([
{
id: "video-after-reconnect",
type: "inbound-rtp",
timestamp: 1,
kind: "video",
framesDecoded: 1,
},
]),
),
newPublication,
newHost,
);
await vi.waitFor(() => expect(proofs).toHaveBeenCalledOnce());
releaseOldRead();
expect(subscriber.deactivate()).toBe(true);
});
it("reconciles a Host publication announced while connect is pending", async () => {
const gate = deferred();
livekit.state.connectGate = gate.promise;
const subscriber = new SfuSubscriber({ onStream: vi.fn() });
const connecting = subscriber.connect(connection);
await vi.waitFor(() => expect(livekit.state.rooms).toHaveLength(1));
const room = livekit.state.rooms[0];
const host = new livekit.FakeRemoteParticipant("host");
const hostVideo = new livekit.FakeRemotePublication(
"host-video-pending",
Track.Source.ScreenShare,
);
host.add(hostVideo);
room.emit(RoomEvent.TrackPublished, hostVideo, host);
expect(hostVideo.setSubscribed).not.toHaveBeenCalled();
gate.resolve();
await expect(connecting).resolves.toBe(true);
expect(subscriber.activate()).toBe(true);
expect(hostVideo.setSubscribed).toHaveBeenCalledWith(true);
});
it("reconciles a Host participant that appears after activation", async () => {
const subscriber = new SfuSubscriber({ onStream: vi.fn() });
await subscriber.connect(connection);
const room = livekit.state.rooms[0];
expect(subscriber.activate()).toBe(true);
const host = new livekit.FakeRemoteParticipant("host");
const hostVideo = new livekit.FakeRemotePublication(
"host-video-late",
Track.Source.ScreenShare,
);
host.add(hostVideo);
room.emit(RoomEvent.ParticipantConnected, host);
expect(hostVideo.setSubscribed).toHaveBeenCalledWith(true);
});
it("subscribes only to assigned Host screen tracks", async () => {
const gate = deferred();
livekit.state.connectGate = gate.promise;
const streams: Array<MediaStream | null> = [];
const subscriber = new SfuSubscriber({
onStream: (nextStream) => streams.push(nextStream),
});
const connecting = subscriber.connect(connection);
await vi.waitFor(() => expect(livekit.state.rooms).toHaveLength(1));
const room = livekit.state.rooms[0];
const host = new livekit.FakeRemoteParticipant("host");
const hostVideo = new livekit.FakeRemotePublication(
"host-video",
Track.Source.ScreenShare,
);
const hostAudio = new livekit.FakeRemotePublication(
"host-audio",
Track.Source.ScreenShareAudio,
);
const hostCamera = new livekit.FakeRemotePublication(
"host-camera",
Track.Source.Camera,
);
host.add(hostVideo).add(hostAudio).add(hostCamera);
room.remoteParticipants.set("host", host);
const viewer = new livekit.FakeRemoteParticipant("viewer:other");
const viewerScreen = new livekit.FakeRemotePublication(
"viewer-screen",
Track.Source.ScreenShare,
);
viewer.add(viewerScreen);
room.remoteParticipants.set(viewer.identity, viewer);
gate.resolve();
await expect(connecting).resolves.toBe(true);
expect(room.connect).toHaveBeenCalledWith(connection.url, connection.token, {
autoSubscribe: false,
rtcConfig: { iceServers: [] },
});
expect(hostVideo.setSubscribed).not.toHaveBeenCalled();
expect(streams).toEqual([]);
expect(subscriber.activate()).toBe(true);
expect(hostVideo.setSubscribed).toHaveBeenCalledWith(true);
expect(hostAudio.setSubscribed).toHaveBeenCalledWith(true);
expect(hostCamera.setSubscribed).not.toHaveBeenCalled();
expect(viewerScreen.setSubscribed).not.toHaveBeenCalled();
const audio = track("audio", "audio-1");
room.emit(
RoomEvent.TrackSubscribed,
remoteTrack(audio),
hostAudio,
host,
);
expect(streams).toEqual([]);
const video = track("video", "video-1");
room.emit(
RoomEvent.TrackSubscribed,
remoteTrack(video),
hostVideo,
host,
);
expect(streams.at(-1)?.getVideoTracks()).toEqual([video]);
expect(streams.at(-1)?.getAudioTracks()).toEqual([audio]);
expect(subscriber.deactivate()).toBe(true);
expect(hostVideo.setSubscribed).toHaveBeenLastCalledWith(false);
expect(hostAudio.setSubscribed).toHaveBeenLastCalledWith(false);
expect(streams.at(-1)).toBeNull();
expect(room.disconnect).not.toHaveBeenCalled();
});
it("keeps one stream while video-first tracks change", async () => {
const streams: Array<MediaStream | null> = [];
const availability: boolean[] = [];
const subscriber = new SfuSubscriber({
onStream: (nextStream) => streams.push(nextStream),
onVideoAvailability: (available) => availability.push(available),
});
await subscriber.connect(connection);
const room = livekit.state.rooms[0];
const host = new livekit.FakeRemoteParticipant("host");
const hostVideo = new livekit.FakeRemotePublication(
"host-video",
Track.Source.ScreenShare,
);
const hostAudio = new livekit.FakeRemotePublication(
"host-audio",
Track.Source.ScreenShareAudio,
);
host.add(hostVideo).add(hostAudio);
room.remoteParticipants.set("host", host);
expect(subscriber.activate()).toBe(true);
const video = track("video", "video-1");
room.emit(RoomEvent.TrackSubscribed, remoteTrack(video), hostVideo, host);
const stableStream = streams.at(-1);
expect(stableStream?.getTracks()).toEqual([video]);
expect(availability).toEqual([true]);
const audio = track("audio", "audio-1");
room.emit(RoomEvent.TrackSubscribed, remoteTrack(audio), hostAudio, host);
expect(streams.at(-1)).toBe(stableStream);
expect(stableStream?.getAudioTracks()).toEqual([audio]);
const callbacksBeforeReplacement = streams.length;
const replacement = track("video", "video-2");
room.emit(RoomEvent.TrackSubscribed, remoteTrack(replacement), hostVideo, host);
expect(streams).toHaveLength(callbacksBeforeReplacement + 1);
expect(streams.at(-1)).toBe(stableStream);
expect(stableStream?.getVideoTracks()).toEqual([replacement]);
video.dispatchEvent(new Event("ended"));
expect(stableStream?.getVideoTracks()).toEqual([replacement]);
expect(availability).toEqual([true]);
replacement.dispatchEvent(new Event("ended"));
expect(stableStream?.getVideoTracks()).toEqual([]);
expect(streams).toHaveLength(callbacksBeforeReplacement + 1);
expect(availability).toEqual([true, false]);
const recovered = track("video", "video-3");
room.emit(RoomEvent.TrackSubscribed, remoteTrack(recovered), hostVideo, host);
expect(streams.at(-1)).toBe(stableStream);
expect(stableStream?.getVideoTracks()).toEqual([recovered]);
expect(availability).toEqual([true, false, true]);
});
it.each(["unsubscribed", "unpublished", "host-disconnected"] as const)(
"reports the last video unavailable when it is %s",
async (loss) => {
const availability: boolean[] = [];
const subscriber = new SfuSubscriber({
onStream: vi.fn(),
onVideoAvailability: (available) => availability.push(available),
});
await subscriber.connect(connection);
const room = livekit.state.rooms[0];
const host = new livekit.FakeRemoteParticipant("host");
const publication = new livekit.FakeRemotePublication(
"host-video",
Track.Source.ScreenShare,
);
host.add(publication);
room.remoteParticipants.set("host", host);
subscriber.activate();
const video = track("video", "video-1");
const remoteVideo = remoteTrack(video);
room.emit(RoomEvent.TrackSubscribed, remoteVideo, publication, host);
availability.length = 0;
if (loss === "unsubscribed") {
room.emit(RoomEvent.TrackUnsubscribed, remoteVideo, publication, host);
} else if (loss === "unpublished") {
room.emit(RoomEvent.TrackUnpublished, publication, host);
} else {
room.emit(RoomEvent.ParticipantDisconnected, host);
}
expect(availability).toEqual([false]);
},
);
it("keeps active decoded-frame sampling through unavailable receiver stats", async () => {
vi.useFakeTimers();
const samples: Array<number | null> = [];
const updates: ConnectionMetrics[] = [];
const subscriber = new SfuSubscriber({
onStream: vi.fn(),
onStats: (metrics) => updates.push(metrics),
onDecodedFrameSample: (framesDecodedDelta) =>
samples.push(framesDecodedDelta),
});
await subscriber.connect(connection);
const room = livekit.state.rooms[0];
const host = new livekit.FakeRemoteParticipant("host");
const publication = new livekit.FakeRemotePublication(
"host-video",
Track.Source.ScreenShare,
);
host.add(publication);
room.remoteParticipants.set("host", host);
expect(subscriber.activate()).toBe(true);
expect(samples).toEqual([null]);
let receiverStats: RTCStatsReport | Promise<RTCStatsReport> | Error =
statsReport([]);
const video = track("video", "video-1");
const remoteVideo = remoteTrack(video, () => {
if (receiverStats instanceof Error) throw receiverStats;
return receiverStats;
});
room.emit(RoomEvent.TrackSubscribed, remoteVideo, publication, host);
await vi.advanceTimersByTimeAsync(0);
expect(samples).toEqual([null, null]);
expect(updates).toEqual([]);
receiverStats = new Error("stats unavailable");
const samplesBeforeStatsError = samples.length;
await vi.advanceTimersByTimeAsync(2_000);
expect(samples).toHaveLength(samplesBeforeStatsError + 1);
expect(samples.at(-1)).toBeNull();
expect(updates).toEqual([]);
receiverStats = statsReport([
{
id: "video-in",
type: "inbound-rtp",
timestamp: 1_000,
kind: "video",
trackIdentifier: video.id,
framesDecoded: 1,
},
]);
await vi.advanceTimersByTimeAsync(2_000);
expect(samples.at(-1)).toBeNull();
expect(updates).toHaveLength(1);
receiverStats = statsReport([
{
id: "video-in",
type: "inbound-rtp",
timestamp: 3_000,
kind: "video",
trackIdentifier: video.id,
framesDecoded: 5,
},
]);
await vi.advanceTimersByTimeAsync(2_000);
expect(samples.at(-1)).toBe(4);
expect(updates).toHaveLength(2);
let releasePendingStats!: (report: RTCStatsReport) => void;
receiverStats = new Promise<RTCStatsReport>((resolve) => {
releasePendingStats = resolve;
});
const statsCallsBeforePending = remoteVideo.getRTCStatsReport.mock.calls.length;
const samplesBeforePending = samples.length;
await vi.advanceTimersByTimeAsync(2_000);
expect(remoteVideo.getRTCStatsReport).toHaveBeenCalledTimes(
statsCallsBeforePending + 1,
);
expect(samples).toHaveLength(samplesBeforePending);
await vi.advanceTimersByTimeAsync(2_000);
expect(remoteVideo.getRTCStatsReport).toHaveBeenCalledTimes(
statsCallsBeforePending + 1,
);
expect(samples).toHaveLength(samplesBeforePending + 1);
expect(samples.at(-1)).toBeNull();
const replacementVideo = track("video", "video-2");
let replacementStats = statsReport([
{
id: "replacement-video-in",
type: "inbound-rtp",
timestamp: 1_000,
kind: "video",
trackIdentifier: replacementVideo.id,
framesDecoded: 2,
},
]);
const replacementRemoteVideo = remoteTrack(
replacementVideo,
() => replacementStats,
);
room.emit(
RoomEvent.TrackSubscribed,
replacementRemoteVideo,
publication,
host,
);
await vi.advanceTimersByTimeAsync(0);
expect(updates.at(-1)?.trackIdentifier).toBe(replacementVideo.id);
const samplesAfterReplacement = samples.length;
const updatesAfterReplacement = updates.length;
releasePendingStats(
statsReport([
{
id: "video-in",
type: "inbound-rtp",
timestamp: 5_000,
kind: "video",
trackIdentifier: video.id,
framesDecoded: 9,
},
]),
);
await Promise.resolve();
await Promise.resolve();
expect(samples).toHaveLength(samplesAfterReplacement);
expect(updates).toHaveLength(updatesAfterReplacement);
replacementStats = statsReport([
{
id: "replacement-video-in",
type: "inbound-rtp",
timestamp: 3_000,
kind: "video",
trackIdentifier: replacementVideo.id,
framesDecoded: 7,
},
]);
await vi.advanceTimersByTimeAsync(2_000);
expect(samples.at(-1)).toBe(5);
room.emit(
RoomEvent.TrackUnsubscribed,
replacementRemoteVideo,
publication,
host,
);
const samplesBeforeMissingTrack = samples.length;
await vi.advanceTimersByTimeAsync(2_000);
expect(samples).toHaveLength(samplesBeforeMissingTrack + 1);
expect(samples.at(-1)).toBeNull();
expect(subscriber.deactivate()).toBe(true);
const samplesAfterDeactivate = samples.length;
await vi.advanceTimersByTimeAsync(4_000);
expect(samples).toHaveLength(samplesAfterDeactivate);
});
it("keeps video liveness independent from optional audio stats", async () => {
vi.useFakeTimers();
const samples: Array<number | null> = [];
const updates: ConnectionMetrics[] = [];
const subscriber = new SfuSubscriber({
onStream: vi.fn(),
onStats: (metrics) => updates.push(metrics),
onDecodedFrameSample: (framesDecodedDelta) =>
samples.push(framesDecodedDelta),
});
await subscriber.connect(connection);
const room = livekit.state.rooms[0];
const host = new livekit.FakeRemoteParticipant("host");
const hostVideo = new livekit.FakeRemotePublication(
"host-video",
Track.Source.ScreenShare,
);
const hostAudio = new livekit.FakeRemotePublication(
"host-audio",
Track.Source.ScreenShareAudio,
);
host.add(hostVideo).add(hostAudio);
room.remoteParticipants.set("host", host);
expect(subscriber.activate()).toBe(true);
const video = track("video", "video-1");
let videoTimestamp = 1_000;
let videoFrames = 1;
const remoteVideo = remoteTrack(video, () =>
statsReport([
{
id: "video-in",
type: "inbound-rtp",
timestamp: videoTimestamp,
kind: "video",
trackIdentifier: video.id,
framesDecoded: videoFrames,
estimatedPlayoutTimestamp: 10_000,
},
]),
);
const audio = track("audio", "audio-1");
let audioStats: RTCStatsReport | Promise<RTCStatsReport> | Error =
new Error("audio stats unavailable");
const remoteAudio = remoteTrack(audio, () => {
if (audioStats instanceof Error) throw audioStats;
return audioStats;
});
room.emit(RoomEvent.TrackSubscribed, remoteAudio, hostAudio, host);
room.emit(RoomEvent.TrackSubscribed, remoteVideo, hostVideo, host);
await vi.advanceTimersByTimeAsync(0);
expect(samples.at(-1)).toBeNull();
expect(updates).toHaveLength(1);
videoTimestamp = 3_000;
videoFrames = 5;
await vi.advanceTimersByTimeAsync(2_000);
expect(samples.at(-1)).toBe(4);
expect(remoteAudio.getRTCStatsReport).toHaveBeenCalledTimes(2);
audioStats = receiverReport("audio");
videoTimestamp = 5_000;
videoFrames = 9;
await vi.advanceTimersByTimeAsync(2_000);
expect(samples.at(-1)).toBe(4);
expect(updates.at(-1)?.audioVideoPlayoutDeltaMs).toBe(12);
expect(remoteAudio.getRTCStatsReport).toHaveBeenCalledTimes(3);
audioStats = new Promise<RTCStatsReport>(() => undefined);
videoTimestamp = 7_000;
videoFrames = 13;
const samplesBeforePendingAudio = samples.length;
await vi.advanceTimersByTimeAsync(2_000);
expect(samples).toHaveLength(samplesBeforePendingAudio + 1);
expect(samples.at(-1)).toBe(4);
expect(updates.at(-1)?.audioVideoPlayoutDeltaMs).toBeNull();
expect(remoteAudio.getRTCStatsReport).toHaveBeenCalledTimes(4);
videoTimestamp = 9_000;
videoFrames = 17;
const samplesBeforeSecondPendingAudioTick = samples.length;
await vi.advanceTimersByTimeAsync(2_000);
expect(samples).toHaveLength(samplesBeforeSecondPendingAudioTick + 1);
expect(samples.at(-1)).toBe(4);
expect(updates.at(-1)?.audioVideoPlayoutDeltaMs).toBeNull();
expect(remoteAudio.getRTCStatsReport).toHaveBeenCalledTimes(4);
expect(remoteVideo.getRTCStatsReport).toHaveBeenCalledTimes(5);
expect(subscriber.deactivate()).toBe(true);
});
it("reports merged receiver stats and drops a stale sample", async () => {
const updates: ConnectionMetrics[] = [];
const samples: Array<number | null> = [];
const states: string[] = [];
const subscriber = new SfuSubscriber({
onStream: vi.fn(),
onStats: (metrics) => updates.push(metrics),
onDecodedFrameSample: (framesDecodedDelta) =>
samples.push(framesDecodedDelta),
onState: (state) => states.push(state),
});
await subscriber.connect(connection);
const room = livekit.state.rooms[0];
const host = new livekit.FakeRemoteParticipant("host");
const hostVideo = new livekit.FakeRemotePublication(
"host-video",
Track.Source.ScreenShare,
);
const hostAudio = new livekit.FakeRemotePublication(
"host-audio",
Track.Source.ScreenShareAudio,
);
host.add(hostVideo).add(hostAudio);
room.remoteParticipants.set("host", host);
expect(subscriber.activate()).toBe(true);
const reports = {
video: receiverReport("video"),
audio: receiverReport("audio"),
};
expect(
Array.from(mergeStatsReports([reports.video, reports.audio])!.keys()),
).toEqual(["video-in", "audio-in"]);
const video = track("video", "video-1");
const audio = track("audio", "audio-1");
let videoStats: RTCStatsReport | Promise<RTCStatsReport> = reports.video;
const remoteVideo = remoteTrack(video, () => videoStats);
room.emit(
RoomEvent.TrackSubscribed,
remoteTrack(audio, () => reports.audio),
hostAudio,
host,
);
room.emit(
RoomEvent.TrackSubscribed,
remoteVideo,
hostVideo,
host,
);
await vi.waitFor(() => expect(updates).toHaveLength(1));
expect(samples).toEqual([null, null]);
expect(updates.at(-1)).toMatchObject({
rtpStatsId: "video-in",
trackIdentifier: "video-1",
audioVideoPlayoutDeltaMs: 12,
});
let releaseStats = (): void => undefined;
videoStats = new Promise<RTCStatsReport>((resolve) => {
releaseStats = () => resolve(reports.video);
});
room.emit(RoomEvent.Reconnecting);
room.emit(RoomEvent.Reconnected);
expect(states).toEqual(["reconnecting", "connected"]);
await vi.waitFor(() =>
expect(remoteVideo.getRTCStatsReport).toHaveBeenCalledTimes(2),
);
expect(subscriber.deactivate()).toBe(true);
const samplesAfterDeactivate = samples.length;
releaseStats();
await Promise.resolve();
await Promise.resolve();
expect(updates).toHaveLength(1);
expect(samples).toHaveLength(samplesAfterDeactivate);
});
it("notifies the controller after a terminal room disconnect", async () => {
const disconnected = vi.fn();
const subscriber = new SfuSubscriber({
onStream: vi.fn(),
onDisconnected: disconnected,
});
await subscriber.connect(connection);
const room = livekit.state.rooms[0];
room.emit(RoomEvent.Disconnected);
room.emit(RoomEvent.Disconnected);
expect(disconnected).toHaveBeenCalledOnce();
expect(subscriber.deactivate()).toBe(false);
});
it("does not activate a stale connection after disconnect", async () => {
const gate = deferred();
livekit.state.connectGate = gate.promise;
const subscriber = new SfuSubscriber({ onStream: vi.fn() });
const connecting = subscriber.connect(connection);
await vi.waitFor(() => expect(livekit.state.rooms).toHaveLength(1));
const room = livekit.state.rooms[0];
await subscriber.disconnect();
await subscriber.disconnect();
gate.resolve();
await expect(connecting).resolves.toBe(false);
expect(room.disconnect).toHaveBeenCalledOnce();
expect(() => subscriber.activate()).toThrow("expected prepared");
});
});