## Problem and outcome / ????? Windows Auto could reject startup because it benchmarked the only remaining VP8 path after hardware encoding failed. Start that ordinary fallback directly; keep comparison only when it can choose between usable encoders. Browser peer construction failures now reach their existing preparation/recovery owners, while constructors release tracks they acquired before throwing. Native event timeout text no longer assumes which encoder event was missing. ## Release notes ### ?? ?????????????????? - ?? Windows ????????????????????????????????????? - ????????????????????????????????????? - ?????????????????????????????? ???????????????????????????????????? ### English This patch improves sharing startup and connection recovery. - Fixes Windows automatic codec selection failing to start when an unnecessary software benchmark fails after hardware encoding is unavailable. - Fixes some browser media-connection initialization failures not reaching the existing failure or recovery flow. - Releases video tracks left behind by failed initialization and clarifies native encoder timeout diagnostics. These fixes passed local validation. Device- or network-specific sharing failures still require evidence from the affected environment. ## Verification / ?? - npm run check: 76 test files / 1,115 tests, TypeScript and production Web build passed. - Windows capture build and deterministic native checks passed, including proved-H264 retention and software-only fallback under a failed/expired optional comparison. The old implementation fails the new fallback check. - Fault-injected hardware-unavailable and exhausted-probe-budget paths both captured a synthetic window and produced decoded 1920?1080 VP8 frames, followed by clean process/port/profile retirement. - Browser regressions cover construction failures, exact connection identity, existing recovery budgets, prepared-candidate replacement and owned-track cleanup. Ordinary Host rebuild keeps the existing Viewer recovery owner. - Documentation and repository hygiene checks passed. npm run check:go-core passed; Darwin cgo remains assigned to the native macOS packaging runner. CI also verifies race checks and all platform packages. ## Impact / ?? Compatible patch release v1.6.5; no wire, storage, room authority, retry-policy or quality-adaptation change. Source diagnostics stay ignored and private. The complete v1.6.3 reporter exports narrow failure stages but do not establish the affected machine's root cause. No interaction or room-voice feature is included. A matching Server restart interrupts process-owned media; routine atomic deployment keeps the pre-cutover binary through bounded postflight.
3120 lines
111 KiB
TypeScript
3120 lines
111 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
QUALITY_PROFILES,
|
|
type QualityProfile,
|
|
} from "../src/client/media/quality.ts";
|
|
import { HostProvisionalChild } from "../src/client/media/host-provisional-child.ts";
|
|
import { EMPTY_METRICS, type PeerSnapshot } from "../src/client/types.ts";
|
|
import { HostPeer, type HostMediaPeer } from "../src/client/webrtc/host-peer.ts";
|
|
import {
|
|
automaticVideoCodecPreference,
|
|
manualVideoCodecPreference,
|
|
type BrowserVideoCodecPreference,
|
|
VP8_ONLY_VIDEO_CODEC,
|
|
} from "../src/client/webrtc/video-codec.ts";
|
|
import {
|
|
ViewerRelay,
|
|
type ViewerRelayPeerEvents,
|
|
type ViewerRelayPeerFactory,
|
|
} from "../src/client/webrtc/viewer-relay.ts";
|
|
import { setCopy } from "../src/client/ui/copy.ts";
|
|
import type {
|
|
IceConfig,
|
|
ParticipantRouteAssignment,
|
|
SignalPayload,
|
|
} from "../src/shared/protocol.ts";
|
|
|
|
const codecPreflight = vi.hoisted(() => ({
|
|
probe: vi.fn(async (): Promise<"h264" | "vp8"> => "vp8"),
|
|
}));
|
|
|
|
vi.mock("../src/client/webrtc/video-codec-preflight.ts", () => ({
|
|
preferredVideoCodecForTrack: codecPreflight.probe,
|
|
}));
|
|
|
|
const statsCallbacks: Array<() => void> = [];
|
|
|
|
class FakeSender {
|
|
failNextReplace = false;
|
|
deferReplaceCall: number | null = null;
|
|
failNextSetParameters = false;
|
|
deferNextSetParameters = false;
|
|
readonly appliedMaxBitrates: Array<number | undefined> = [];
|
|
private parameters = { encodings: [{}] } as RTCRtpSendParameters;
|
|
private replaceCallCount = 0;
|
|
private releaseReplaceTrack: (() => void) | null = null;
|
|
private releaseSetParameters: (() => void) | null = null;
|
|
readonly setParameters = vi.fn(
|
|
async (parameters: RTCRtpSendParameters) => {
|
|
if (this.failNextSetParameters) {
|
|
this.failNextSetParameters = false;
|
|
throw new Error("setParameters failed");
|
|
}
|
|
if (this.deferNextSetParameters) {
|
|
this.deferNextSetParameters = false;
|
|
await new Promise<void>((resolve) => {
|
|
this.releaseSetParameters = resolve;
|
|
});
|
|
}
|
|
this.appliedMaxBitrates.push(parameters.encodings[0]?.maxBitrate);
|
|
this.parameters = parameters;
|
|
},
|
|
);
|
|
readonly replaceTrack = vi.fn(async (track: MediaStreamTrack | null) => {
|
|
this.replaceCallCount += 1;
|
|
if (this.failNextReplace) {
|
|
this.failNextReplace = false;
|
|
throw new Error("replaceTrack failed");
|
|
}
|
|
if (this.deferReplaceCall === this.replaceCallCount) {
|
|
this.deferReplaceCall = null;
|
|
await new Promise<void>((resolve) => {
|
|
this.releaseReplaceTrack = resolve;
|
|
});
|
|
}
|
|
this.track = track;
|
|
});
|
|
|
|
constructor(public track: MediaStreamTrack | null) {}
|
|
|
|
getParameters(): RTCRtpSendParameters {
|
|
return this.parameters;
|
|
}
|
|
|
|
releaseDeferredSetParameters(): void {
|
|
this.releaseSetParameters?.();
|
|
this.releaseSetParameters = null;
|
|
}
|
|
|
|
releaseDeferredReplaceTrack(): void {
|
|
this.releaseReplaceTrack?.();
|
|
this.releaseReplaceTrack = null;
|
|
}
|
|
}
|
|
|
|
class FakePeerConnection {
|
|
static latest: FakePeerConnection | null = null;
|
|
static instances: FakePeerConnection[] = [];
|
|
static activeCount = 0;
|
|
static peakActiveCount = 0;
|
|
static offersFailing = 0;
|
|
static constructorsFailing = 0;
|
|
static omitCodecPreferenceSetter = false;
|
|
static codecPreferenceCallsFailing = 0;
|
|
|
|
readonly configurations: RTCConfiguration[] = [];
|
|
readonly senders: FakeSender[] = [];
|
|
readonly transceivers: RTCRtpTransceiver[] = [];
|
|
readonly transceiverInputs: Array<{
|
|
trackOrKind: MediaStreamTrack | string;
|
|
init?: RTCRtpTransceiverInit;
|
|
}> = [];
|
|
readonly codecPreferenceCalls: RTCRtpCodec[][] = [];
|
|
createOfferCallCount = 0;
|
|
remoteDescriptionCallCount = 0;
|
|
deferRemoteDescriptionCall: number | null = null;
|
|
private releaseRemoteDescription: (() => void) | null = null;
|
|
connectionState: RTCPeerConnectionState = "new";
|
|
iceConnectionState: RTCIceConnectionState = "new";
|
|
iceGatheringState: RTCIceGatheringState = "new";
|
|
signalingState: RTCSignalingState = "stable";
|
|
localDescription: RTCSessionDescription | null = null;
|
|
remoteDescription: RTCSessionDescription | null = null;
|
|
readonly addedIceCandidates: Array<RTCIceCandidateInit | null> = [];
|
|
readonly statsReports: Array<RTCStatsReport | Promise<RTCStatsReport>> = [];
|
|
private readonly eventListeners = new Map<
|
|
string,
|
|
Array<(event: Event) => void>
|
|
>();
|
|
|
|
constructor(configuration?: RTCConfiguration) {
|
|
if (FakePeerConnection.constructorsFailing > 0) {
|
|
FakePeerConnection.constructorsFailing--;
|
|
throw new DOMException("Allocation failed", "UnknownError");
|
|
}
|
|
FakePeerConnection.latest = this;
|
|
FakePeerConnection.instances.push(this);
|
|
FakePeerConnection.activeCount += 1;
|
|
FakePeerConnection.peakActiveCount = Math.max(
|
|
FakePeerConnection.peakActiveCount,
|
|
FakePeerConnection.activeCount,
|
|
);
|
|
if (configuration) {
|
|
this.configurations.push(configuration);
|
|
}
|
|
}
|
|
|
|
readonly setConfiguration = vi.fn((configuration: RTCConfiguration) => {
|
|
this.configurations.push(configuration);
|
|
});
|
|
|
|
readonly restartIce = vi.fn();
|
|
|
|
addTransceiver(
|
|
trackOrKind: MediaStreamTrack | string,
|
|
init?: RTCRtpTransceiverInit,
|
|
): RTCRtpTransceiver {
|
|
const sender = new FakeSender(
|
|
typeof trackOrKind === "string" ? null : trackOrKind,
|
|
);
|
|
this.senders.push(sender);
|
|
this.transceiverInputs.push({ trackOrKind, init });
|
|
const transceiver = {
|
|
sender,
|
|
direction: init?.direction ?? "sendrecv",
|
|
...(FakePeerConnection.omitCodecPreferenceSetter
|
|
? {}
|
|
: {
|
|
setCodecPreferences: (codecs: RTCRtpCodec[]) => {
|
|
if (FakePeerConnection.codecPreferenceCallsFailing > 0) {
|
|
FakePeerConnection.codecPreferenceCallsFailing -= 1;
|
|
throw new Error("setCodecPreferences failed");
|
|
}
|
|
this.codecPreferenceCalls.push([...codecs]);
|
|
},
|
|
}),
|
|
} as unknown as RTCRtpTransceiver;
|
|
this.transceivers.push(transceiver);
|
|
return transceiver;
|
|
}
|
|
|
|
addEventListener(type: string, listener: (event: Event) => void): void {
|
|
const listeners = this.eventListeners.get(type) ?? [];
|
|
listeners.push(listener);
|
|
this.eventListeners.set(type, listeners);
|
|
}
|
|
|
|
dispatchEvent(event: Event): boolean {
|
|
for (const listener of this.eventListeners.get(event.type) ?? []) {
|
|
listener(event);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async createOffer(): Promise<RTCSessionDescriptionInit> {
|
|
this.createOfferCallCount += 1;
|
|
if (FakePeerConnection.offersFailing > 0) {
|
|
FakePeerConnection.offersFailing -= 1;
|
|
throw new Error("createOffer failed");
|
|
}
|
|
return { type: "offer", sdp: "test-offer" };
|
|
}
|
|
|
|
async setLocalDescription(
|
|
description: RTCSessionDescriptionInit,
|
|
): Promise<void> {
|
|
if (description.type === "rollback") {
|
|
this.localDescription = null;
|
|
this.signalingState = "stable";
|
|
return;
|
|
}
|
|
this.localDescription = description as RTCSessionDescription;
|
|
}
|
|
|
|
async setRemoteDescription(
|
|
description: RTCSessionDescriptionInit,
|
|
): Promise<void> {
|
|
this.remoteDescriptionCallCount += 1;
|
|
if (this.deferRemoteDescriptionCall === this.remoteDescriptionCallCount) {
|
|
this.deferRemoteDescriptionCall = null;
|
|
await new Promise<void>((resolve) => {
|
|
this.releaseRemoteDescription = resolve;
|
|
});
|
|
}
|
|
this.remoteDescription = description as RTCSessionDescription;
|
|
}
|
|
|
|
releaseDeferredRemoteDescription(): void {
|
|
this.releaseRemoteDescription?.();
|
|
this.releaseRemoteDescription = null;
|
|
}
|
|
|
|
async addIceCandidate(candidate: RTCIceCandidateInit | null): Promise<void> {
|
|
this.addedIceCandidates.push(candidate);
|
|
}
|
|
|
|
readonly getStats = vi.fn(async (): Promise<RTCStatsReport> =>
|
|
await (this.statsReports.shift() ?? emptyStatsReport())
|
|
);
|
|
|
|
close(): void {
|
|
if (this.connectionState !== "closed") {
|
|
FakePeerConnection.activeCount -= 1;
|
|
}
|
|
this.connectionState = "closed";
|
|
}
|
|
}
|
|
|
|
function emptyStatsReport(): RTCStatsReport {
|
|
return new Map() as unknown as RTCStatsReport;
|
|
}
|
|
|
|
function sendStatsReport({
|
|
bytesSent,
|
|
framesEncoded,
|
|
timestamp,
|
|
totalEncodeTime,
|
|
qualityLimitationReason,
|
|
trackIdentifier = "video",
|
|
}: {
|
|
bytesSent: number;
|
|
framesEncoded: number;
|
|
timestamp: number;
|
|
totalEncodeTime?: number;
|
|
qualityLimitationReason: string;
|
|
trackIdentifier?: string;
|
|
}): RTCStatsReport {
|
|
return new Map<string, Record<string, unknown>>([
|
|
[
|
|
"transport",
|
|
{
|
|
id: "transport",
|
|
type: "transport",
|
|
timestamp,
|
|
selectedCandidatePairId: "pair",
|
|
},
|
|
],
|
|
[
|
|
"pair",
|
|
{
|
|
id: "pair",
|
|
type: "candidate-pair",
|
|
timestamp,
|
|
transportId: "transport",
|
|
state: "succeeded",
|
|
nominated: true,
|
|
localCandidateId: "local",
|
|
remoteCandidateId: "remote",
|
|
currentRoundTripTime: 0.02,
|
|
availableOutgoingBitrate: 6_000_000,
|
|
},
|
|
],
|
|
[
|
|
"local",
|
|
{
|
|
id: "local",
|
|
type: "local-candidate",
|
|
timestamp,
|
|
candidateType: "host",
|
|
protocol: "udp",
|
|
},
|
|
],
|
|
[
|
|
"remote",
|
|
{
|
|
id: "remote",
|
|
type: "remote-candidate",
|
|
timestamp,
|
|
candidateType: "host",
|
|
protocol: "udp",
|
|
},
|
|
],
|
|
[
|
|
"outbound-video",
|
|
{
|
|
id: "outbound-video",
|
|
type: "outbound-rtp",
|
|
timestamp,
|
|
kind: "video",
|
|
transportId: "transport",
|
|
mediaSourceId: "video-source",
|
|
bytesSent,
|
|
framesEncoded,
|
|
framesPerSecond: 30,
|
|
frameWidth: 1280,
|
|
frameHeight: 720,
|
|
totalEncodeTime: totalEncodeTime ?? framesEncoded * 0.005,
|
|
qualityLimitationReason,
|
|
encoderImplementation: "test-encoder",
|
|
codecId: "codec",
|
|
},
|
|
],
|
|
[
|
|
"video-source",
|
|
{
|
|
id: "video-source",
|
|
type: "media-source",
|
|
timestamp,
|
|
kind: "video",
|
|
trackIdentifier,
|
|
},
|
|
],
|
|
[
|
|
"codec",
|
|
{
|
|
id: "codec",
|
|
type: "codec",
|
|
timestamp,
|
|
transportId: "transport",
|
|
mimeType: "video/VP8",
|
|
},
|
|
],
|
|
]) as unknown as RTCStatsReport;
|
|
}
|
|
|
|
function withOutboundAudio(
|
|
report: RTCStatsReport,
|
|
tracks: readonly {
|
|
id: string;
|
|
trackIdentifier: string;
|
|
bytesSent: number;
|
|
}[],
|
|
): RTCStatsReport {
|
|
const merged = new Map(
|
|
report as unknown as Map<string, Record<string, unknown>>,
|
|
);
|
|
for (const [index, track] of tracks.entries()) {
|
|
const sourceId = `${track.id}-source`;
|
|
merged.set(track.id, {
|
|
id: track.id,
|
|
type: "outbound-rtp",
|
|
timestamp: Number(merged.get("outbound-video")?.timestamp),
|
|
kind: "audio",
|
|
ssrc: index + 1,
|
|
mediaSourceId: sourceId,
|
|
bytesSent: track.bytesSent,
|
|
});
|
|
merged.set(sourceId, {
|
|
id: sourceId,
|
|
type: "media-source",
|
|
timestamp: Number(merged.get("outbound-video")?.timestamp),
|
|
kind: "audio",
|
|
trackIdentifier: track.trackIdentifier,
|
|
});
|
|
}
|
|
return merged as unknown as RTCStatsReport;
|
|
}
|
|
|
|
function createTrack(kind: "video" | "audio", id: string): MediaStreamTrack {
|
|
const track = Object.assign(new EventTarget(), {
|
|
id,
|
|
kind,
|
|
contentHint: "",
|
|
enabled: true,
|
|
getSettings: () => ({}),
|
|
applyConstraints: vi.fn(async () => undefined),
|
|
stop: vi.fn(),
|
|
}) as unknown as MediaStreamTrack;
|
|
track.clone = vi.fn(() => {
|
|
const clone = createTrack(kind, id);
|
|
clone.getSettings = track.getSettings.bind(track);
|
|
return clone;
|
|
});
|
|
return track;
|
|
}
|
|
|
|
function createConfiguredVideoTrack(
|
|
id: string,
|
|
width: number,
|
|
height: number,
|
|
frameRate: number,
|
|
): MediaStreamTrack {
|
|
const track = createTrack("video", id);
|
|
track.getSettings = () => ({ width, height, frameRate });
|
|
track.clone = vi.fn(() =>
|
|
createConfiguredVideoTrack(id, width, height, frameRate),
|
|
);
|
|
return track;
|
|
}
|
|
|
|
function createStream(
|
|
videoTrack: MediaStreamTrack,
|
|
audioTrack: MediaStreamTrack | null,
|
|
): MediaStream {
|
|
const tracks = audioTrack ? [videoTrack, audioTrack] : [videoTrack];
|
|
return {
|
|
getTracks: () => tracks,
|
|
getVideoTracks: () => [videoTrack],
|
|
getAudioTracks: () => (audioTrack ? [audioTrack] : []),
|
|
} as unknown as MediaStream;
|
|
}
|
|
|
|
function createPeer(
|
|
stream: MediaStream,
|
|
onUpdate: (snapshot: PeerSnapshot) => void = () => undefined,
|
|
iceConfig: IceConfig = { iceServers: [] },
|
|
profile: QualityProfile = QUALITY_PROFILES["720p30"],
|
|
videoCodec: BrowserVideoCodecPreference = VP8_ONLY_VIDEO_CODEC,
|
|
natPredictionEnabled = false,
|
|
): HostPeer {
|
|
return new HostPeer(
|
|
"viewer-peer",
|
|
iceConfig,
|
|
stream,
|
|
profile,
|
|
{
|
|
sendSignal: () => true,
|
|
onUpdate,
|
|
},
|
|
videoCodec,
|
|
undefined,
|
|
natPredictionEnabled,
|
|
);
|
|
}
|
|
|
|
async function acceptPeerAnswer(peer: HostPeer): Promise<void> {
|
|
const connection = FakePeerConnection.latest!;
|
|
await peer.acceptSignal({
|
|
kind: "description",
|
|
connectionId: peer.connectionId,
|
|
description: { type: "answer", sdp: "test-answer" },
|
|
});
|
|
connection.connectionState = "connected";
|
|
connection.dispatchEvent(new Event("connectionstatechange"));
|
|
await completeVideoStartup(connection);
|
|
}
|
|
|
|
async function completeVideoStartup(
|
|
connection: FakePeerConnection,
|
|
framesEncoded = 5,
|
|
): Promise<void> {
|
|
const statsCalls = connection.getStats.mock.calls.length;
|
|
connection.statsReports.push(
|
|
sendStatsReport({
|
|
bytesSent: 10_000,
|
|
framesEncoded,
|
|
timestamp: 1_000,
|
|
qualityLimitationReason: "none",
|
|
trackIdentifier: connection.senders[0]?.track?.id ?? "video",
|
|
}),
|
|
);
|
|
statsCallbacks.at(-1)!();
|
|
await vi.waitFor(() =>
|
|
expect(connection.getStats).toHaveBeenCalledTimes(statsCalls + 1),
|
|
);
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
setCopy({ lang: "zh", vis: false });
|
|
codecPreflight.probe.mockReset();
|
|
codecPreflight.probe.mockResolvedValue("vp8");
|
|
FakePeerConnection.latest = null;
|
|
FakePeerConnection.instances = [];
|
|
FakePeerConnection.activeCount = 0;
|
|
FakePeerConnection.peakActiveCount = 0;
|
|
FakePeerConnection.offersFailing = 0;
|
|
FakePeerConnection.constructorsFailing = 0;
|
|
FakePeerConnection.omitCodecPreferenceSetter = false;
|
|
FakePeerConnection.codecPreferenceCallsFailing = 0;
|
|
statsCallbacks.length = 0;
|
|
vi.stubGlobal("RTCPeerConnection", FakePeerConnection);
|
|
vi.stubGlobal("RTCRtpSender", {
|
|
getCapabilities: () => ({
|
|
codecs: [
|
|
{ mimeType: "video/VP8", clockRate: 90_000 },
|
|
{ mimeType: "video/H264", clockRate: 90_000 },
|
|
],
|
|
headerExtensions: [],
|
|
}),
|
|
});
|
|
vi.stubGlobal("window", {
|
|
setInterval: vi.fn((callback: () => void) => {
|
|
statsCallbacks.push(callback);
|
|
return statsCallbacks.length;
|
|
}),
|
|
clearInterval: vi.fn(),
|
|
setTimeout: (callback: () => void, delay: number) =>
|
|
globalThis.setTimeout(callback, delay),
|
|
clearTimeout: (timer: ReturnType<typeof setTimeout>) =>
|
|
globalThis.clearTimeout(timer),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("HostPeer source replacement", () => {
|
|
it("does not expose WebRTC exception text in connection errors", async () => {
|
|
const updates: PeerSnapshot[] = [];
|
|
FakePeerConnection.offersFailing = 1;
|
|
const peer = createPeer(
|
|
createStream(createTrack("video", "video"), null),
|
|
(snapshot) => updates.push(snapshot),
|
|
);
|
|
|
|
await expect(peer.start()).resolves.toBe(false);
|
|
|
|
// The snapshot carries a copy key, so raw WebRTC text cannot leak.
|
|
expect(updates.at(-1)?.error).toEqual({ key: "host.err.createConnection" });
|
|
});
|
|
|
|
it("offers only advertised VP8 and repair codecs", async () => {
|
|
vi.stubGlobal("RTCRtpSender", {
|
|
getCapabilities: () => ({
|
|
codecs: [
|
|
{ mimeType: "video/H264", clockRate: 90_000 },
|
|
{ mimeType: "video/rtx", clockRate: 90_000 },
|
|
{ mimeType: "video/VP9", clockRate: 90_000 },
|
|
{ mimeType: "video/RED", clockRate: 90_000 },
|
|
{ mimeType: "video/VP8", clockRate: 90_000 },
|
|
{ mimeType: "video/ulpfec", clockRate: 90_000 },
|
|
{ mimeType: "video/AV1", clockRate: 90_000 },
|
|
{ mimeType: "video/flexfec-03", clockRate: 90_000 },
|
|
],
|
|
headerExtensions: [],
|
|
}),
|
|
});
|
|
const peer = createPeer(createStream(createTrack("video", "video"), null));
|
|
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
|
|
const connection = FakePeerConnection.latest!;
|
|
expect(
|
|
connection.codecPreferenceCalls[0]?.map(({ mimeType }) =>
|
|
mimeType.toLowerCase(),
|
|
),
|
|
).toEqual([
|
|
"video/vp8",
|
|
"video/rtx",
|
|
"video/red",
|
|
"video/ulpfec",
|
|
"video/flexfec-03",
|
|
]);
|
|
expect(connection.createOfferCallCount).toBe(1);
|
|
});
|
|
|
|
it("prefers native H264 mode 1 and retains VP8 fallback", async () => {
|
|
vi.stubGlobal("RTCRtpSender", {
|
|
getCapabilities: () => ({
|
|
codecs: [
|
|
{
|
|
mimeType: "video/H264",
|
|
clockRate: 90_000,
|
|
sdpFmtpLine: "packetization-mode=0;profile-level-id=42001f",
|
|
},
|
|
{
|
|
mimeType: "video/H264",
|
|
clockRate: 90_000,
|
|
sdpFmtpLine: "packetization-mode=1;profile-level-id=42001f",
|
|
},
|
|
{ mimeType: "video/VP8", clockRate: 90_000 },
|
|
{ mimeType: "video/rtx", clockRate: 90_000 },
|
|
{ mimeType: "video/RED", clockRate: 90_000 },
|
|
],
|
|
headerExtensions: [],
|
|
}),
|
|
});
|
|
const peer = createPeer(
|
|
createStream(createTrack("video", "video"), null),
|
|
() => undefined,
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
automaticVideoCodecPreference("h264"),
|
|
);
|
|
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
|
|
expect(
|
|
FakePeerConnection.latest!.codecPreferenceCalls[0]?.map((codec) => [
|
|
codec.mimeType.toLowerCase(),
|
|
codec.sdpFmtpLine ?? null,
|
|
]),
|
|
).toEqual([
|
|
["video/h264", "packetization-mode=1;profile-level-id=42001f"],
|
|
["video/vp8", null],
|
|
["video/rtx", null],
|
|
["video/red", null],
|
|
]);
|
|
});
|
|
|
|
it("keeps a manual H264 selection strict", async () => {
|
|
vi.stubGlobal("RTCRtpSender", {
|
|
getCapabilities: () => ({
|
|
codecs: [
|
|
{
|
|
mimeType: "video/H264",
|
|
clockRate: 90_000,
|
|
sdpFmtpLine: "packetization-mode=1;profile-level-id=42001f",
|
|
},
|
|
{ mimeType: "video/VP8", clockRate: 90_000 },
|
|
{ mimeType: "video/rtx", clockRate: 90_000 },
|
|
],
|
|
headerExtensions: [],
|
|
}),
|
|
});
|
|
const peer = createPeer(
|
|
createStream(createTrack("video", "video"), null),
|
|
() => undefined,
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
manualVideoCodecPreference("h264"),
|
|
);
|
|
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
|
|
expect(
|
|
FakePeerConnection.latest!.codecPreferenceCalls[0]?.map(({ mimeType }) =>
|
|
mimeType.toLowerCase(),
|
|
),
|
|
).toEqual(["video/h264", "video/rtx"]);
|
|
});
|
|
|
|
it("fails before creating an offer when codec preferences are unavailable", async () => {
|
|
FakePeerConnection.omitCodecPreferenceSetter = true;
|
|
const updates: PeerSnapshot[] = [];
|
|
const peer = createPeer(
|
|
createStream(createTrack("video", "video"), null),
|
|
(snapshot) => updates.push(snapshot),
|
|
);
|
|
|
|
await expect(peer.start()).resolves.toBe(false);
|
|
|
|
expect(FakePeerConnection.latest!.createOfferCallCount).toBe(0);
|
|
expect(FakePeerConnection.latest!.localDescription).toBeNull();
|
|
expect(updates.at(-1)?.error).toEqual({ key: "host.err.codecUnsupported" });
|
|
});
|
|
|
|
it("fails before creating an offer when VP8 is unavailable", async () => {
|
|
vi.stubGlobal("RTCRtpSender", {
|
|
getCapabilities: () => ({
|
|
codecs: [
|
|
{ mimeType: "video/H264", clockRate: 90_000 },
|
|
{ mimeType: "video/rtx", clockRate: 90_000 },
|
|
],
|
|
headerExtensions: [],
|
|
}),
|
|
});
|
|
const updates: PeerSnapshot[] = [];
|
|
const peer = createPeer(
|
|
createStream(createTrack("video", "video"), null),
|
|
(snapshot) => updates.push(snapshot),
|
|
);
|
|
|
|
await expect(peer.start()).resolves.toBe(false);
|
|
|
|
expect(FakePeerConnection.latest!.createOfferCallCount).toBe(0);
|
|
expect(FakePeerConnection.latest!.localDescription).toBeNull();
|
|
expect(updates.at(-1)?.error).toEqual({ key: "host.err.codecUnsupported" });
|
|
});
|
|
|
|
it("fails before creating an offer when codec preference setup fails", async () => {
|
|
FakePeerConnection.codecPreferenceCallsFailing = 1;
|
|
const updates: PeerSnapshot[] = [];
|
|
const peer = createPeer(
|
|
createStream(createTrack("video", "video"), null),
|
|
(snapshot) => updates.push(snapshot),
|
|
);
|
|
|
|
await expect(peer.start()).resolves.toBe(false);
|
|
|
|
expect(FakePeerConnection.latest!.createOfferCallCount).toBe(0);
|
|
expect(FakePeerConnection.latest!.localDescription).toBeNull();
|
|
expect(updates.at(-1)?.error).toEqual({ key: "host.err.codecUnsupported" });
|
|
});
|
|
|
|
it("applies STUN-only ICE configuration at creation and update", () => {
|
|
const peer = createPeer(
|
|
createStream(createTrack("video", "video"), null),
|
|
() => undefined,
|
|
{ iceServers: [{ urls: ["stun:stun-a.example.test:3478"] }] },
|
|
);
|
|
const connection = FakePeerConnection.latest!;
|
|
|
|
peer.updateIceConfig({
|
|
iceServers: [{ urls: ["stun:stun-b.example.test:3478"] }],
|
|
});
|
|
|
|
expect(connection.configurations).toEqual([
|
|
{ iceServers: [{ urls: ["stun:stun-a.example.test:3478"] }] },
|
|
{ iceServers: [{ urls: ["stun:stun-b.example.test:3478"] }] },
|
|
]);
|
|
expect(connection.setConfiguration).toHaveBeenCalledOnce();
|
|
for (const configuration of connection.configurations) {
|
|
expect(
|
|
configuration.iceServers?.every(
|
|
(server) =>
|
|
!Reflect.has(server, "username") &&
|
|
!Reflect.has(server, "credential") &&
|
|
(typeof server.urls === "string"
|
|
? server.urls.startsWith("stun:")
|
|
: server.urls.every((url) => url.startsWith("stun:"))),
|
|
),
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("keeps ordinary candidates while opting into bounded NAT predictions", () => {
|
|
const signals: SignalPayload[] = [];
|
|
const peer = new HostPeer(
|
|
"viewer-peer",
|
|
{
|
|
iceServers: [{ urls: "stun:share.example.test:3478" }],
|
|
natPredictionStunUrls: [
|
|
"stun:share.example.test:3479",
|
|
"stun:share.example.test:3480",
|
|
],
|
|
},
|
|
createStream(createTrack("video", "video"), null),
|
|
QUALITY_PROFILES["720p30"],
|
|
{
|
|
sendSignal: (_peerId, payload) => {
|
|
signals.push(payload);
|
|
return true;
|
|
},
|
|
onUpdate: () => undefined,
|
|
},
|
|
VP8_ONLY_VIDEO_CODEC,
|
|
undefined,
|
|
true,
|
|
);
|
|
const connection = FakePeerConnection.latest!;
|
|
expect(connection.configurations[0]?.iceServers).toEqual([
|
|
{ urls: "stun:share.example.test:3478" },
|
|
{ urls: "stun:share.example.test:3479" },
|
|
{ urls: "stun:share.example.test:3480" },
|
|
]);
|
|
|
|
const surveyUrls = [
|
|
"stun:share.example.test:3478",
|
|
"stun:share.example.test:3479",
|
|
"stun:share.example.test:3480",
|
|
];
|
|
const emitCandidate = (port: number | null, url?: string): void => {
|
|
const event = new Event("icecandidate");
|
|
Object.defineProperty(event, "candidate", {
|
|
value:
|
|
port === null
|
|
? null
|
|
: {
|
|
candidate:
|
|
`candidate:base 1 udp 2122260223 203.0.113.7 ${port} ` +
|
|
"typ srflx raddr 192.0.2.7 rport 50000 generation 0 ufrag test",
|
|
sdpMid: "0",
|
|
sdpMLineIndex: 0,
|
|
usernameFragment: "test",
|
|
url,
|
|
},
|
|
});
|
|
connection.dispatchEvent(event);
|
|
};
|
|
emitCandidate(40_000, surveyUrls[0]);
|
|
emitCandidate(40_003, surveyUrls[1]);
|
|
emitCandidate(40_006, surveyUrls[2]);
|
|
connection.iceGatheringState = "complete";
|
|
connection.dispatchEvent(new Event("icegatheringstatechange"));
|
|
emitCandidate(null);
|
|
|
|
const candidateSignals = signals.filter(
|
|
(signal): signal is Extract<SignalPayload, { kind: "candidate" }> =>
|
|
signal.kind === "candidate",
|
|
);
|
|
const predictionIndex = candidateSignals.findIndex((signal) =>
|
|
signal.candidate?.candidate.includes("candidate:s"),
|
|
);
|
|
const ordinaryIndex = candidateSignals.findIndex((signal) =>
|
|
signal.candidate?.candidate.startsWith("candidate:base"),
|
|
);
|
|
expect(ordinaryIndex).toBe(0);
|
|
expect(predictionIndex).toBeGreaterThanOrEqual(0);
|
|
expect(predictionIndex).toBeGreaterThan(ordinaryIndex);
|
|
expect(
|
|
candidateSignals.filter((signal) =>
|
|
signal.candidate?.candidate.startsWith("candidate:base"),
|
|
),
|
|
).toHaveLength(3);
|
|
expect(candidateSignals.at(-1)?.candidate).toBeNull();
|
|
expect(candidateSignals.filter((signal) => signal.candidate === null)).toHaveLength(1);
|
|
peer.dispose();
|
|
});
|
|
|
|
it("reserves send-only video and audio senders and replaces both tracks", async () => {
|
|
const oldVideo = createTrack("video", "old-video");
|
|
const oldAudio = createTrack("audio", "old-audio");
|
|
const peer = createPeer(
|
|
createStream(oldVideo, oldAudio),
|
|
() => undefined,
|
|
{ iceServers: [] },
|
|
{
|
|
...QUALITY_PROFILES["720p30"],
|
|
screenAudioQuality: "very-high",
|
|
},
|
|
);
|
|
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
await acceptPeerAnswer(peer);
|
|
const connection = FakePeerConnection.latest!;
|
|
const oldSenderVideo = connection.senders[0]!.track!;
|
|
expect(oldSenderVideo).not.toBe(oldVideo);
|
|
expect(connection.transceiverInputs).toHaveLength(2);
|
|
expect(connection.transceiverInputs.map(({ init }) => init?.direction)).toEqual([
|
|
"sendonly",
|
|
"sendonly",
|
|
]);
|
|
|
|
const nextVideo = createTrack("video", "next-video");
|
|
const nextAudio = createTrack("audio", "next-audio");
|
|
await expect(
|
|
peer.replaceStream(createStream(nextVideo, nextAudio)),
|
|
).resolves.toBe(true);
|
|
|
|
const nextSenderVideo = connection.senders[0]?.track;
|
|
expect(nextSenderVideo).not.toBe(nextVideo);
|
|
expect(nextSenderVideo?.id).toBe(nextVideo.id);
|
|
expect(connection.senders[1]?.track).toBe(nextAudio);
|
|
expect(oldVideo.stop).not.toHaveBeenCalled();
|
|
expect(oldSenderVideo.stop).toHaveBeenCalledOnce();
|
|
expect(connection.transceiverInputs).toHaveLength(2);
|
|
expect(connection.senders[0]?.setParameters).toHaveBeenCalledTimes(3);
|
|
expect(connection.senders[1]?.appliedMaxBitrates).toEqual([
|
|
192_000,
|
|
192_000,
|
|
]);
|
|
peer.dispose();
|
|
expect(nextVideo.stop).not.toHaveBeenCalled();
|
|
expect(nextSenderVideo?.stop).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it.each(["video", "audio", "rollback"])("retires a replacement blocked in %s without waiting for the browser", async (stage) => {
|
|
const video = createTrack("video", "old-video");
|
|
const audio = createTrack("audio", "old-audio");
|
|
const peer = createPeer(createStream(video, audio));
|
|
await peer.start();
|
|
const connection = FakePeerConnection.latest!;
|
|
const videoSender = connection.senders[0]!;
|
|
const audioSender = connection.senders[1]!;
|
|
const previousClone = videoSender.track!;
|
|
videoSender.deferReplaceCall = stage === "video" ? 1 : stage === "rollback" ? 2 : null;
|
|
audioSender.deferReplaceCall = stage === "audio" ? 1 : null;
|
|
audioSender.failNextReplace = stage === "rollback";
|
|
const nextVideo = createTrack("video", "next-video");
|
|
const nextAudio = createTrack("audio", "next-audio");
|
|
const replacing = peer.replaceStream(createStream(nextVideo, nextAudio));
|
|
await vi.waitFor(() => expect(
|
|
stage === "audio" ? audioSender.replaceTrack : videoSender.replaceTrack,
|
|
).toHaveBeenCalledTimes(stage === "rollback" ? 2 : 1));
|
|
const replacementClone = vi.mocked(nextVideo.clone).mock.results[0]!.value;
|
|
const queuedProfile = peer.updateProfile(QUALITY_PROFILES["1080p30"]);
|
|
peer.dispose();
|
|
await expect(replacing).resolves.toBe(false);
|
|
await expect(queuedProfile).resolves.toBe(false);
|
|
expect(previousClone.stop).toHaveBeenCalledOnce();
|
|
expect(replacementClone.stop).toHaveBeenCalledOnce();
|
|
for (const source of [video, audio, nextVideo, nextAudio]) expect(source.stop).not.toHaveBeenCalled();
|
|
const audioCalls = audioSender.replaceTrack.mock.calls.length;
|
|
const offers = connection.createOfferCallCount;
|
|
videoSender.releaseDeferredReplaceTrack();
|
|
audioSender.releaseDeferredReplaceTrack();
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(audioSender.replaceTrack).toHaveBeenCalledTimes(audioCalls);
|
|
expect(connection.createOfferCallCount).toBe(offers);
|
|
});
|
|
|
|
it.each([false, true])("keeps the video sender and quality when only audio changes (failure=%s)", async (fail) => {
|
|
const video = createTrack("video", "same-video");
|
|
const peer = createPeer(createStream(video, null));
|
|
await peer.start();
|
|
await acceptPeerAnswer(peer);
|
|
const connection = FakePeerConnection.latest!;
|
|
const videoSender = connection.senders[0]!;
|
|
const audioSender = connection.senders[1]!;
|
|
const clone = videoSender.track!;
|
|
const parametersBefore = videoSender.setParameters.mock.calls.length;
|
|
const offersBefore = connection.createOfferCallCount;
|
|
audioSender.failNextReplace = fail;
|
|
const audio = createTrack("audio", "mixed-audio");
|
|
expect(await peer.replaceStream(createStream(video, audio))).toBe(!fail);
|
|
expect(videoSender.replaceTrack).not.toHaveBeenCalled();
|
|
expect(videoSender.setParameters).toHaveBeenCalledTimes(parametersBefore);
|
|
expect(videoSender.track).toBe(clone);
|
|
expect(clone.stop).not.toHaveBeenCalled();
|
|
expect(video.clone).toHaveBeenCalledOnce();
|
|
expect(audioSender.track).toBe(fail ? null : audio);
|
|
expect(connection.createOfferCallCount).toBe(offersBefore + (fail ? 0 : 1));
|
|
peer.dispose();
|
|
});
|
|
|
|
it("keeps capture controls on the sender-owned video clone", async () => {
|
|
const sourceVideo = createConfiguredVideoTrack(
|
|
"capture-video",
|
|
1920,
|
|
1080,
|
|
30,
|
|
);
|
|
sourceVideo.contentHint = "motion";
|
|
const sourceAudio = createTrack("audio", "capture-audio");
|
|
const peer = createPeer(createStream(sourceVideo, sourceAudio));
|
|
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
const senderVideo = FakePeerConnection.latest!.senders[0]!.track!;
|
|
expect(senderVideo).not.toBe(sourceVideo);
|
|
expect(senderVideo.contentHint).toBe("motion");
|
|
|
|
peer.setPaused(true);
|
|
expect(senderVideo.enabled).toBe(false);
|
|
expect(sourceVideo.enabled).toBe(true);
|
|
peer.setPaused(false);
|
|
expect(senderVideo.enabled).toBe(true);
|
|
|
|
await expect(
|
|
peer.updateCaptureProfile(QUALITY_PROFILES["1080p60"]),
|
|
).resolves.toBe(true);
|
|
expect(senderVideo.applyConstraints).toHaveBeenLastCalledWith({
|
|
width: { ideal: 1920, max: 1920 },
|
|
height: { ideal: 1080, max: 1080 },
|
|
frameRate: { ideal: 60, max: 60 },
|
|
});
|
|
expect(sourceVideo.applyConstraints).not.toHaveBeenCalled();
|
|
|
|
peer.dispose();
|
|
expect(senderVideo.stop).toHaveBeenCalledOnce();
|
|
expect(sourceVideo.stop).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("applies pause authority to a source replacement while it is in flight", async () => {
|
|
const peer = createPeer(
|
|
createStream(
|
|
createTrack("video", "old-video"),
|
|
createTrack("audio", "old-audio"),
|
|
),
|
|
);
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
const connection = FakePeerConnection.latest!;
|
|
const videoSender = connection.senders[0]!;
|
|
const nextVideo = createTrack("video", "next-video");
|
|
const nextAudio = createTrack("audio", "next-audio");
|
|
videoSender.deferReplaceCall = 1;
|
|
|
|
const replacing = peer.replaceStream(createStream(nextVideo, nextAudio));
|
|
await vi.waitFor(() =>
|
|
expect(videoSender.replaceTrack).toHaveBeenCalledOnce(),
|
|
);
|
|
const replacementVideo = videoSender.replaceTrack.mock.calls[0]![0]!;
|
|
peer.setPaused(true);
|
|
expect(replacementVideo.enabled).toBe(false);
|
|
expect(nextAudio.enabled).toBe(false);
|
|
|
|
videoSender.releaseDeferredReplaceTrack();
|
|
await expect(replacing).resolves.toBe(true);
|
|
expect(videoSender.track?.enabled).toBe(false);
|
|
expect(connection.senders[1]!.track?.enabled).toBe(false);
|
|
peer.setPaused(false);
|
|
expect(videoSender.track?.enabled).toBe(true);
|
|
expect(connection.senders[1]!.track?.enabled).toBe(true);
|
|
});
|
|
|
|
it("keeps a pre-negotiated audio sender inactive until a real track exists", async () => {
|
|
const peer = createPeer(
|
|
createStream(createTrack("video", "old-video"), null),
|
|
);
|
|
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
const connection = FakePeerConnection.latest!;
|
|
expect(connection.transceiverInputs[1]?.trackOrKind).toBe("audio");
|
|
expect(connection.senders[1]?.track).toBeNull();
|
|
expect(connection.transceivers[1]?.direction).toBe("inactive");
|
|
expect(connection.senders[1]?.appliedMaxBitrates).toEqual([]);
|
|
|
|
const nextAudio = createTrack("audio", "next-audio");
|
|
await expect(
|
|
peer.replaceStream(
|
|
createStream(createTrack("video", "next-video"), nextAudio),
|
|
),
|
|
).resolves.toBe(true);
|
|
expect(connection.senders[1]?.track).toBe(nextAudio);
|
|
expect(connection.transceivers[1]?.direction).toBe("sendonly");
|
|
expect(connection.createOfferCallCount).toBe(2);
|
|
expect(connection.senders[1]?.appliedMaxBitrates).toEqual([128_000]);
|
|
});
|
|
|
|
it("retires an absent audio track from the negotiated direction", async () => {
|
|
const peer = createPeer(
|
|
createStream(
|
|
createTrack("video", "old-video"),
|
|
createTrack("audio", "old-audio"),
|
|
),
|
|
);
|
|
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
const connection = FakePeerConnection.latest!;
|
|
await expect(
|
|
peer.replaceStream(
|
|
createStream(createTrack("video", "next-video"), null),
|
|
),
|
|
).resolves.toBe(true);
|
|
|
|
expect(connection.senders[1]?.track).toBeNull();
|
|
expect(connection.transceivers[1]?.direction).toBe("inactive");
|
|
expect(connection.createOfferCallCount).toBe(2);
|
|
expect(connection.senders[1]?.appliedMaxBitrates).toEqual([128_000]);
|
|
});
|
|
|
|
it("updates quality parameters without replacing media tracks", async () => {
|
|
const video = createTrack("video", "video");
|
|
const audio = createTrack("audio", "audio");
|
|
const peer = createPeer(createStream(video, audio));
|
|
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
await acceptPeerAnswer(peer);
|
|
const connection = FakePeerConnection.latest!;
|
|
await expect(
|
|
peer.updateProfile(QUALITY_PROFILES["1080p60"]),
|
|
).resolves.toBe(true);
|
|
|
|
expect(connection.senders[0]?.replaceTrack).not.toHaveBeenCalled();
|
|
expect(connection.senders[1]?.replaceTrack).not.toHaveBeenCalled();
|
|
expect(connection.senders[0]?.setParameters).toHaveBeenCalledTimes(3);
|
|
expect(
|
|
connection.senders[0]?.setParameters.mock.calls.at(-1)?.[0],
|
|
).toMatchObject({
|
|
degradationPreference: "balanced",
|
|
encodings: [{ maxBitrate: 8_000_000, maxFramerate: 60 }],
|
|
});
|
|
expect(connection.senders[1]?.setParameters).toHaveBeenCalledOnce();
|
|
|
|
await expect(
|
|
peer.updateProfile({
|
|
...QUALITY_PROFILES["1080p60"],
|
|
screenAudioQuality: "very-high",
|
|
}),
|
|
).resolves.toBe(true);
|
|
expect(connection.senders[0]?.setParameters).toHaveBeenCalledTimes(3);
|
|
expect(connection.senders[1]?.setParameters).toHaveBeenCalledTimes(2);
|
|
expect(connection.senders[1]?.appliedMaxBitrates).toEqual([
|
|
128_000,
|
|
192_000,
|
|
]);
|
|
expect(peer.getSnapshot().audioSenderParameters).toEqual({
|
|
requestedMaxBitrate: 192_000,
|
|
appliedMaxBitrate: 192_000,
|
|
mismatch: false,
|
|
});
|
|
});
|
|
|
|
it("keeps media and prior audio readback when a live ceiling fails", async () => {
|
|
const video = createTrack("video", "video");
|
|
const audio = createTrack("audio", "audio");
|
|
const peer = createPeer(createStream(video, audio));
|
|
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
const connection = FakePeerConnection.latest!;
|
|
const audioSender = connection.senders[1]!;
|
|
audioSender.failNextSetParameters = true;
|
|
|
|
await expect(
|
|
peer.updateProfile({
|
|
...QUALITY_PROFILES["720p30"],
|
|
screenAudioQuality: "very-high",
|
|
}),
|
|
).resolves.toBe(false);
|
|
|
|
expect(audioSender.track).toBe(audio);
|
|
expect(audioSender.getParameters().encodings[0]?.maxBitrate).toBe(128_000);
|
|
expect(peer.getSnapshot().audioSenderParameters?.appliedMaxBitrate).toBe(
|
|
128_000,
|
|
);
|
|
|
|
await expect(
|
|
peer.updateProfile({
|
|
...QUALITY_PROFILES["720p30"],
|
|
screenAudioQuality: "very-high",
|
|
}),
|
|
).resolves.toBe(true);
|
|
expect(audioSender.getParameters().encodings[0]?.maxBitrate).toBe(192_000);
|
|
});
|
|
|
|
it("keeps rapid audio ceiling changes last-wins", async () => {
|
|
const peer = createPeer(
|
|
createStream(createTrack("video", "video"), createTrack("audio", "audio")),
|
|
);
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
const audioSender = FakePeerConnection.latest!.senders[1]!;
|
|
audioSender.deferNextSetParameters = true;
|
|
|
|
const saver = peer.updateProfile({
|
|
...QUALITY_PROFILES["720p30"],
|
|
screenAudioQuality: "saver",
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(audioSender.setParameters).toHaveBeenCalledTimes(2),
|
|
);
|
|
const veryHigh = peer.updateProfile({
|
|
...QUALITY_PROFILES["720p30"],
|
|
screenAudioQuality: "very-high",
|
|
});
|
|
audioSender.releaseDeferredSetParameters();
|
|
|
|
await expect(saver).resolves.toBe(false);
|
|
await expect(veryHigh).resolves.toBe(true);
|
|
expect(audioSender.appliedMaxBitrates).toEqual([
|
|
128_000,
|
|
64_000,
|
|
192_000,
|
|
]);
|
|
expect(peer.getSnapshot().audioSenderParameters?.appliedMaxBitrate).toBe(
|
|
192_000,
|
|
);
|
|
});
|
|
|
|
it("retains restart candidates that arrive before the new answer", async () => {
|
|
const peer = createPeer(createStream(createTrack("video", "video"), null));
|
|
await peer.start();
|
|
const connection = FakePeerConnection.latest!;
|
|
await peer.acceptSignal({
|
|
kind: "description", connectionId: peer.connectionId,
|
|
description: { type: "answer", sdp: "previous-answer" },
|
|
});
|
|
await expect(peer.restartIce()).resolves.toBe(true);
|
|
const nextCandidate = {
|
|
candidate: "candidate:next 1 udp 2122260223 192.0.2.8 50001 typ host ufrag next",
|
|
usernameFragment: "next", sdpMid: "0", sdpMLineIndex: 0,
|
|
};
|
|
await peer.acceptSignal({
|
|
kind: "candidate", connectionId: peer.connectionId, candidate: nextCandidate,
|
|
});
|
|
// Native receiver events can precede the receive-offer RPC response.
|
|
expect(connection.addedIceCandidates).toEqual([]);
|
|
connection.deferRemoteDescriptionCall = 2;
|
|
const answer = peer.acceptSignal({
|
|
kind: "description", connectionId: peer.connectionId,
|
|
description: { type: "answer", sdp: "next-answer" },
|
|
});
|
|
await vi.waitFor(() => expect(connection.remoteDescriptionCallCount).toBe(2));
|
|
const end = peer.acceptSignal({
|
|
kind: "candidate", connectionId: peer.connectionId, candidate: null,
|
|
});
|
|
connection.releaseDeferredRemoteDescription();
|
|
await Promise.all([answer, end]);
|
|
expect(connection.remoteDescription?.sdp).toBe("next-answer");
|
|
expect(connection.addedIceCandidates).toEqual([nextCandidate, null]);
|
|
peer.dispose();
|
|
});
|
|
|
|
it("drains valid restart candidates after an old-generation candidate is rejected", async () => {
|
|
const peer = createPeer(createStream(createTrack("video", "video"), null));
|
|
await peer.start();
|
|
const connection = FakePeerConnection.latest!;
|
|
await peer.acceptSignal({ kind: "description", connectionId: peer.connectionId,
|
|
description: { type: "answer", sdp: "previous-answer" } });
|
|
await peer.restartIce();
|
|
const stale = { candidate: "stale-candidate", sdpMid: "0", usernameFragment: "previous" };
|
|
const current = { candidate: "current-candidate", sdpMid: "0", usernameFragment: "current" };
|
|
for (const candidate of [stale, current]) {
|
|
await peer.acceptSignal({ kind: "candidate", connectionId: peer.connectionId, candidate });
|
|
}
|
|
vi.spyOn(connection, "addIceCandidate").mockRejectedValueOnce(
|
|
new DOMException("Unknown ICE username fragment", "OperationError"),
|
|
);
|
|
await peer.acceptSignal({ kind: "description", connectionId: peer.connectionId,
|
|
description: { type: "answer", sdp: "current-answer" } });
|
|
expect(connection.addedIceCandidates).toEqual([current]);
|
|
expect(peer.getSnapshot().error).toBeNull();
|
|
peer.dispose();
|
|
});
|
|
|
|
it("enables the selected balanced profile after startup frames", async () => {
|
|
const video = createTrack("video", "video");
|
|
const peer = createPeer(createStream(video, createTrack("audio", "audio")));
|
|
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
const connection = FakePeerConnection.latest!;
|
|
expect(connection.senders[0]?.setParameters).toHaveBeenCalledOnce();
|
|
expect(
|
|
connection.senders[0]?.setParameters.mock.calls[0]?.[0],
|
|
).toMatchObject({ degradationPreference: "maintain-resolution" });
|
|
const pendingCandidate = { candidate: "candidate-before-answer" };
|
|
await peer.acceptSignal({
|
|
kind: "candidate",
|
|
connectionId: peer.connectionId,
|
|
candidate: pendingCandidate,
|
|
});
|
|
expect(connection.addedIceCandidates).toEqual([]);
|
|
await peer.acceptSignal({
|
|
kind: "description",
|
|
connectionId: peer.connectionId,
|
|
description: { type: "answer", sdp: "test-answer" },
|
|
});
|
|
|
|
expect(connection.remoteDescription?.type).toBe("answer");
|
|
expect(connection.addedIceCandidates).toEqual([pendingCandidate]);
|
|
expect(connection.senders[0]?.track).not.toBe(video);
|
|
expect(connection.senders[0]?.track?.id).toBe(video.id);
|
|
expect(connection.senders[0]?.setParameters).toHaveBeenCalledOnce();
|
|
|
|
connection.connectionState = "connected";
|
|
connection.dispatchEvent(new Event("connectionstatechange"));
|
|
await Promise.resolve();
|
|
expect(connection.senders[0]?.setParameters).toHaveBeenCalledOnce();
|
|
|
|
await completeVideoStartup(connection, 4);
|
|
expect(connection.senders[0]?.setParameters).toHaveBeenCalledOnce();
|
|
|
|
await completeVideoStartup(connection, 5);
|
|
expect(connection.senders[0]?.setParameters).toHaveBeenCalledTimes(2);
|
|
expect(
|
|
connection.senders[0]?.setParameters.mock.calls.at(-1)?.[0],
|
|
).toMatchObject({
|
|
degradationPreference: "balanced",
|
|
encodings: [{ maxBitrate: 3_000_000, maxFramerate: 30 }],
|
|
});
|
|
expect(connection.senders[1]?.setParameters).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it.each([0, 250])("counts new source frames from a valid baseline of %s", async (baseline) => {
|
|
const video = createTrack("video", "video");
|
|
const peer = createPeer(createStream(video, createTrack("audio", "audio")));
|
|
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
const connection = FakePeerConnection.latest!;
|
|
await acceptPeerAnswer(peer);
|
|
expect(
|
|
connection.senders[0]?.setParameters.mock.calls.at(-1)?.[0],
|
|
).toMatchObject({ degradationPreference: "balanced" });
|
|
|
|
const nextVideo = createTrack("video", "next-video");
|
|
await expect(
|
|
peer.replaceStream(createStream(nextVideo, null)),
|
|
).resolves.toBe(true);
|
|
expect(
|
|
connection.senders[0]?.setParameters.mock.calls.at(-1)?.[0],
|
|
).toMatchObject({ degradationPreference: "maintain-resolution" });
|
|
const configureCalls =
|
|
connection.senders[0]!.setParameters.mock.calls.length;
|
|
|
|
const staleReport = sendStatsReport({
|
|
bytesSent: 10_000,
|
|
framesEncoded: 250,
|
|
timestamp: 1_000,
|
|
qualityLimitationReason: "none",
|
|
trackIdentifier: video.id,
|
|
});
|
|
const missingCount = sendStatsReport({
|
|
bytesSent: 10_000,
|
|
framesEncoded: 0,
|
|
timestamp: 1_000,
|
|
qualityLimitationReason: "none",
|
|
trackIdentifier: connection.senders[0]!.track!.id,
|
|
});
|
|
delete missingCount.get("outbound-video").framesEncoded;
|
|
// Missing/currently unmatched stats cannot seed the new baseline as zero.
|
|
for (const report of [emptyStatsReport(), staleReport, missingCount]) {
|
|
connection.statsReports.push(report);
|
|
statsCallbacks.at(-1)!();
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
}
|
|
|
|
// A valid zero is different from missing stats. An existing RTP stream may
|
|
// instead retain a large cumulative count across replaceTrack.
|
|
await completeVideoStartup(connection, baseline);
|
|
expect(connection.senders[0]!.setParameters.mock.calls.length).toBe(
|
|
configureCalls,
|
|
);
|
|
await completeVideoStartup(connection, baseline + 4);
|
|
expect(connection.senders[0]!.setParameters.mock.calls.length).toBe(
|
|
configureCalls,
|
|
);
|
|
await completeVideoStartup(connection, baseline + 5);
|
|
expect(connection.senders[0]!.setParameters.mock.calls.length).toBe(
|
|
configureCalls + 1,
|
|
);
|
|
expect(
|
|
connection.senders[0]?.setParameters.mock.calls.at(-1)?.[0],
|
|
).toMatchObject({ degradationPreference: "balanced" });
|
|
});
|
|
|
|
it("applies the latest pre-answer profile once negotiation completes", async () => {
|
|
const peer = createPeer(
|
|
createStream(
|
|
createTrack("video", "video"),
|
|
createTrack("audio", "audio"),
|
|
),
|
|
);
|
|
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
const videoSender = FakePeerConnection.latest!.senders[0]!;
|
|
expect(videoSender.setParameters).toHaveBeenCalledOnce();
|
|
|
|
await expect(
|
|
peer.updateProfile(QUALITY_PROFILES["1080p60"]),
|
|
).resolves.toBe(true);
|
|
expect(videoSender.setParameters).toHaveBeenCalledOnce();
|
|
|
|
await acceptPeerAnswer(peer);
|
|
await vi.waitFor(() =>
|
|
expect(videoSender.setParameters).toHaveBeenCalledTimes(2),
|
|
);
|
|
expect(videoSender.appliedMaxBitrates).toEqual([3_000_000, 8_000_000]);
|
|
});
|
|
|
|
it.each([false, true])("applies connecting profile changes without canceling an in-flight constraint: %s", async (holdConstraints) => {
|
|
const peer = createPeer(createStream(createTrack("video", "video"), createTrack("audio", "audio")));
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
const connection = FakePeerConnection.latest!;
|
|
const sender = connection.senders[0]!;
|
|
await peer.acceptSignal({
|
|
kind: "description", connectionId: peer.connectionId,
|
|
description: { type: "answer", sdp: "test-answer" },
|
|
});
|
|
await completeVideoStartup(connection, 5);
|
|
const before = sender.setParameters.mock.calls.length;
|
|
let release: (() => void) | undefined;
|
|
if (holdConstraints) {
|
|
vi.mocked(sender.track!.applyConstraints).mockImplementationOnce(() =>
|
|
new Promise<void>((resolve) => { release = resolve; }),
|
|
);
|
|
}
|
|
const changing = holdConstraints
|
|
? peer.updateCaptureProfile(QUALITY_PROFILES["1080p60"])
|
|
: peer.updateProfile(QUALITY_PROFILES["1080p60"]);
|
|
if (holdConstraints) {
|
|
await vi.waitFor(() => expect(release).toBeTypeOf("function"));
|
|
} else {
|
|
await expect(changing).resolves.toBe(true);
|
|
}
|
|
expect(sender.setParameters).toHaveBeenCalledTimes(before);
|
|
connection.connectionState = "connected";
|
|
connection.dispatchEvent(new Event("connectionstatechange"));
|
|
release?.();
|
|
await expect(changing).resolves.toBe(true);
|
|
await vi.waitFor(() => expect(sender.appliedMaxBitrates.at(-1)).toBe(8_000_000));
|
|
expect(sender.setParameters).toHaveBeenCalledTimes(before + 1);
|
|
});
|
|
|
|
it("replays the desired profile only after a connected peer reconnects", async () => {
|
|
const peer = createPeer(
|
|
createStream(
|
|
createTrack("video", "video"),
|
|
createTrack("audio", "audio"),
|
|
),
|
|
);
|
|
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
await acceptPeerAnswer(peer);
|
|
const connection = FakePeerConnection.latest!;
|
|
const videoSender = connection.senders[0]!;
|
|
const audioSender = connection.senders[1]!;
|
|
const videoCalls = videoSender.setParameters.mock.calls.length;
|
|
const audioCalls = audioSender.setParameters.mock.calls.length;
|
|
|
|
connection.dispatchEvent(new Event("connectionstatechange"));
|
|
await Promise.resolve();
|
|
expect(videoSender.setParameters).toHaveBeenCalledTimes(videoCalls);
|
|
expect(audioSender.setParameters).toHaveBeenCalledTimes(audioCalls);
|
|
|
|
connection.connectionState = "disconnected";
|
|
connection.dispatchEvent(new Event("connectionstatechange"));
|
|
connection.connectionState = "connected";
|
|
connection.dispatchEvent(new Event("connectionstatechange"));
|
|
|
|
await vi.waitFor(() =>
|
|
expect(videoSender.setParameters).toHaveBeenCalledTimes(videoCalls + 1),
|
|
);
|
|
expect(audioSender.setParameters).toHaveBeenCalledTimes(audioCalls + 1);
|
|
});
|
|
|
|
it("continues queued profile updates after initial configuration rejects", async () => {
|
|
const peer = createPeer(
|
|
createStream(
|
|
createTrack("video", "video"),
|
|
createTrack("audio", "audio"),
|
|
),
|
|
);
|
|
|
|
const starting = peer.start();
|
|
const videoSender = FakePeerConnection.latest!.senders[0]!;
|
|
videoSender.failNextSetParameters = true;
|
|
await expect(starting).resolves.toBe(true);
|
|
await acceptPeerAnswer(peer);
|
|
|
|
await expect(
|
|
peer.updateProfile(QUALITY_PROFILES["1080p60"]),
|
|
).resolves.toBe(true);
|
|
expect(videoSender.setParameters).toHaveBeenCalledTimes(3);
|
|
expect(videoSender.appliedMaxBitrates).toEqual([3_000_000, 8_000_000]);
|
|
});
|
|
|
|
it("can retry the selected quality after a sender update fails", async () => {
|
|
const peer = createPeer(
|
|
createStream(
|
|
createTrack("video", "video"),
|
|
createTrack("audio", "audio"),
|
|
),
|
|
);
|
|
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
await acceptPeerAnswer(peer);
|
|
const videoSender = FakePeerConnection.latest!.senders[0]!;
|
|
videoSender.failNextSetParameters = true;
|
|
|
|
await expect(
|
|
peer.updateProfile(QUALITY_PROFILES["1080p60"]),
|
|
).resolves.toBe(false);
|
|
await expect(
|
|
peer.updateProfile(QUALITY_PROFILES["1080p60"]),
|
|
).resolves.toBe(true);
|
|
|
|
expect(videoSender.setParameters).toHaveBeenCalledTimes(4);
|
|
expect(videoSender.setParameters.mock.calls.at(-1)?.[0]).toMatchObject({
|
|
degradationPreference: "balanced",
|
|
encodings: [{ maxBitrate: 8_000_000, maxFramerate: 60 }],
|
|
});
|
|
});
|
|
|
|
it("discards an in-flight stats sample after a profile reset", async () => {
|
|
let resolveOldStats!: (report: RTCStatsReport) => void;
|
|
const oldStats = new Promise<RTCStatsReport>((resolve) => {
|
|
resolveOldStats = resolve;
|
|
});
|
|
const updates: PeerSnapshot[] = [];
|
|
const peer = createPeer(
|
|
createStream(createTrack("video", "video"), null),
|
|
(snapshot) => updates.push(snapshot),
|
|
);
|
|
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
await acceptPeerAnswer(peer);
|
|
const connection = FakePeerConnection.latest!;
|
|
connection.statsReports.push(oldStats);
|
|
statsCallbacks[0]!();
|
|
await vi.waitFor(() => expect(connection.statsReports).toHaveLength(0));
|
|
statsCallbacks[0]!();
|
|
expect(connection.getStats).toHaveBeenCalledTimes(2);
|
|
|
|
await expect(
|
|
peer.updateProfile(QUALITY_PROFILES["1080p60"]),
|
|
).resolves.toBe(true);
|
|
resolveOldStats(
|
|
sendStatsReport({
|
|
bytesSent: 1_000_000,
|
|
framesEncoded: 30,
|
|
timestamp: 1_000,
|
|
totalEncodeTime: 0.15,
|
|
qualityLimitationReason: "cpu",
|
|
}),
|
|
);
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(
|
|
updates.some(
|
|
({ metrics }) => metrics.qualityLimitationReason === "cpu",
|
|
),
|
|
).toBe(false);
|
|
|
|
connection.statsReports.push(
|
|
sendStatsReport({
|
|
bytesSent: 2_000_000,
|
|
framesEncoded: 60,
|
|
timestamp: 2_000,
|
|
totalEncodeTime: 0.3,
|
|
qualityLimitationReason: "bandwidth",
|
|
}),
|
|
sendStatsReport({
|
|
bytesSent: 2_500_000,
|
|
framesEncoded: 90,
|
|
timestamp: 3_000,
|
|
totalEncodeTime: 0.9,
|
|
qualityLimitationReason: "bandwidth",
|
|
}),
|
|
);
|
|
statsCallbacks[0]!();
|
|
await vi.waitFor(() =>
|
|
expect(updates.at(-1)?.metrics.qualityLimitationReason).toBe(
|
|
"bandwidth",
|
|
),
|
|
);
|
|
expect(updates.at(-1)?.metrics.intervalEncodeMs).toBeNull();
|
|
|
|
statsCallbacks[0]!();
|
|
await vi.waitFor(() =>
|
|
expect(updates.at(-1)?.metrics.intervalEncodeMs).toBeCloseTo(20),
|
|
);
|
|
expect(
|
|
updates.some(
|
|
({ metrics }) => metrics.qualityLimitationReason === "cpu",
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("publishes capture settings with outbound evidence from the same stats tick", async () => {
|
|
const getSettings = vi.fn(() => ({
|
|
width: 1920,
|
|
height: 1080,
|
|
frameRate: 59.94,
|
|
}));
|
|
const videoTrack = createTrack("video", "capture-video");
|
|
videoTrack.getSettings = getSettings;
|
|
const updates: PeerSnapshot[] = [];
|
|
const peer = createPeer(
|
|
createStream(videoTrack, null),
|
|
(snapshot) => updates.push(snapshot),
|
|
);
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
getSettings.mockClear();
|
|
const connection = FakePeerConnection.latest!;
|
|
connection.statsReports.push(
|
|
sendStatsReport({
|
|
bytesSent: 1_000_000,
|
|
framesEncoded: 30,
|
|
timestamp: 1_234,
|
|
qualityLimitationReason: "none",
|
|
trackIdentifier: "capture-video",
|
|
}),
|
|
);
|
|
|
|
expect(getSettings).not.toHaveBeenCalled();
|
|
statsCallbacks[0]!();
|
|
await vi.waitFor(() =>
|
|
expect(updates.at(-1)?.metrics.sampleTimestampMs).toBe(1_234),
|
|
);
|
|
|
|
expect(connection.getStats).toHaveBeenCalledOnce();
|
|
expect(getSettings).toHaveBeenCalledOnce();
|
|
expect(updates.at(-1)?.metrics).toMatchObject({
|
|
sampleTimestampMs: 1_234,
|
|
rtpStatsId: "outbound-video",
|
|
resolution: "1280x720",
|
|
captureWidth: 1920,
|
|
captureHeight: 1080,
|
|
captureFramesPerSecond: 59.94,
|
|
});
|
|
});
|
|
|
|
it("samples only the current audio sender and excludes old audio after removal", async () => {
|
|
const video = createConfiguredVideoTrack("capture-video", 1920, 1080, 60);
|
|
const audio = createTrack("audio", "current-audio");
|
|
const updates: PeerSnapshot[] = [];
|
|
const peer = createPeer(
|
|
createStream(video, audio),
|
|
(snapshot) => updates.push(snapshot),
|
|
);
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
const connection = FakePeerConnection.latest!;
|
|
const report = (timestamp: number, currentBytes: number, oldBytes: number) =>
|
|
withOutboundAudio(
|
|
sendStatsReport({
|
|
bytesSent: timestamp * 1_000,
|
|
framesEncoded: timestamp / 20,
|
|
timestamp,
|
|
qualityLimitationReason: "none",
|
|
trackIdentifier: video.id,
|
|
}),
|
|
[
|
|
{
|
|
id: "current-audio-out",
|
|
trackIdentifier: audio.id,
|
|
bytesSent: currentBytes,
|
|
},
|
|
{
|
|
id: "old-audio-out",
|
|
trackIdentifier: "old-audio",
|
|
bytesSent: oldBytes,
|
|
},
|
|
],
|
|
);
|
|
connection.statsReports.push(
|
|
report(1_000, 10_000, 100_000),
|
|
report(3_000, 50_000, 300_000),
|
|
);
|
|
|
|
statsCallbacks[0]!();
|
|
await vi.waitFor(() =>
|
|
expect(updates.at(-1)?.metrics.sampleTimestampMs).toBe(1_000),
|
|
);
|
|
statsCallbacks[0]!();
|
|
await vi.waitFor(() =>
|
|
expect(updates.at(-1)?.metrics.audioBitrateKbps).toBe(160),
|
|
);
|
|
|
|
const nextVideo = createConfiguredVideoTrack("next-video", 1280, 720, 30);
|
|
await expect(
|
|
peer.replaceStream(createStream(nextVideo, null)),
|
|
).resolves.toBe(true);
|
|
connection.statsReports.push(
|
|
withOutboundAudio(
|
|
sendStatsReport({
|
|
bytesSent: 4_000_000,
|
|
framesEncoded: 200,
|
|
timestamp: 4_000,
|
|
qualityLimitationReason: "none",
|
|
trackIdentifier: nextVideo.id,
|
|
}),
|
|
[
|
|
{
|
|
id: "old-audio-out",
|
|
trackIdentifier: audio.id,
|
|
bytesSent: 400_000,
|
|
},
|
|
],
|
|
),
|
|
);
|
|
statsCallbacks[0]!();
|
|
await vi.waitFor(() =>
|
|
expect(updates.at(-1)?.metrics.sampleTimestampMs).toBe(4_000),
|
|
);
|
|
expect(updates.at(-1)?.metrics).toMatchObject({
|
|
trackIdentifier: nextVideo.id,
|
|
audioBitrateKbps: null,
|
|
audioCodec: null,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
outcome: "succeeds",
|
|
rollback: false,
|
|
deferredCall: 1,
|
|
result: true,
|
|
committed: "next" as const,
|
|
},
|
|
{
|
|
outcome: "rolls back",
|
|
rollback: true,
|
|
deferredCall: 2,
|
|
result: false,
|
|
committed: "old" as const,
|
|
},
|
|
])("blocks stale stats while replacement $outcome", async ({
|
|
rollback,
|
|
deferredCall,
|
|
result,
|
|
committed,
|
|
}) => {
|
|
const scenario = rollback ? "rollback" : "success";
|
|
let resolveOldStats!: (report: RTCStatsReport) => void;
|
|
const oldStats = new Promise<RTCStatsReport>((resolve) => {
|
|
resolveOldStats = resolve;
|
|
});
|
|
const oldVideo = createConfiguredVideoTrack(
|
|
`${scenario}-old-capture`,
|
|
1280,
|
|
720,
|
|
30,
|
|
);
|
|
const nextVideo = createConfiguredVideoTrack(
|
|
`${scenario}-next-capture`,
|
|
1920,
|
|
1080,
|
|
60,
|
|
);
|
|
const oldAudio = rollback ? createTrack("audio", "old-audio") : null;
|
|
const nextAudio = rollback ? createTrack("audio", "next-audio") : null;
|
|
const updates: PeerSnapshot[] = [];
|
|
const peer = createPeer(
|
|
createStream(oldVideo, oldAudio),
|
|
(snapshot) => updates.push(snapshot),
|
|
);
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
const connection = FakePeerConnection.latest!;
|
|
const videoSender = connection.senders[0]!;
|
|
const audioSender = connection.senders[1]!;
|
|
const initialSenderVideo = videoSender.track;
|
|
connection.statsReports.push(
|
|
sendStatsReport({
|
|
bytesSent: 500_000,
|
|
framesEncoded: 15,
|
|
timestamp: 500,
|
|
qualityLimitationReason: "none",
|
|
trackIdentifier: oldVideo.id,
|
|
}),
|
|
);
|
|
statsCallbacks[0]!();
|
|
await vi.waitFor(() =>
|
|
expect(updates.at(-1)?.metrics.sampleTimestampMs).toBe(500),
|
|
);
|
|
connection.statsReports.push(oldStats);
|
|
statsCallbacks[0]!();
|
|
await vi.waitFor(() => expect(connection.getStats).toHaveBeenCalledTimes(2));
|
|
|
|
videoSender.deferReplaceCall = deferredCall;
|
|
audioSender.failNextReplace = rollback;
|
|
const replacing = peer.replaceStream(
|
|
createStream(nextVideo, nextAudio),
|
|
);
|
|
await vi.waitFor(() =>
|
|
expect(videoSender.replaceTrack).toHaveBeenCalledTimes(deferredCall),
|
|
);
|
|
|
|
resolveOldStats(
|
|
sendStatsReport({
|
|
bytesSent: 1_000_000,
|
|
framesEncoded: 30,
|
|
timestamp: 1_000,
|
|
qualityLimitationReason: "none",
|
|
trackIdentifier: oldVideo.id,
|
|
}),
|
|
);
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(
|
|
updates.some(({ metrics }) => metrics.sampleTimestampMs === 1_000),
|
|
).toBe(false);
|
|
statsCallbacks[0]!();
|
|
expect(connection.getStats).toHaveBeenCalledTimes(2);
|
|
|
|
videoSender.releaseDeferredReplaceTrack();
|
|
await expect(replacing).resolves.toBe(result);
|
|
const committedVideo = committed === "old" ? oldVideo : nextVideo;
|
|
if (committed === "old") {
|
|
expect(videoSender.track).toBe(initialSenderVideo);
|
|
} else {
|
|
expect(videoSender.track).not.toBe(nextVideo);
|
|
expect(videoSender.track?.id).toBe(nextVideo.id);
|
|
}
|
|
expect(updates.at(-1)?.metrics.sampleTimestampMs).toBe(
|
|
committed === "old" ? 500 : null,
|
|
);
|
|
|
|
connection.statsReports.push(
|
|
sendStatsReport({
|
|
bytesSent: 2_000_000,
|
|
framesEncoded: 60,
|
|
timestamp: 2_000,
|
|
qualityLimitationReason: "none",
|
|
trackIdentifier: committedVideo.id,
|
|
}),
|
|
);
|
|
statsCallbacks[0]!();
|
|
await vi.waitFor(() =>
|
|
expect(updates.at(-1)?.metrics.sampleTimestampMs).toBe(2_000),
|
|
);
|
|
expect(updates.at(-1)?.metrics).toMatchObject({
|
|
trackIdentifier: committedVideo.id,
|
|
captureWidth: committed === "old" ? 1280 : 1920,
|
|
captureHeight: committed === "old" ? 720 : 1080,
|
|
captureFramesPerSecond: committed === "old" ? 30 : 60,
|
|
});
|
|
});
|
|
|
|
it("rolls the first sender back when the second replacement fails", async () => {
|
|
const oldVideo = createTrack("video", "old-video");
|
|
const oldAudio = createTrack("audio", "old-audio");
|
|
const peer = createPeer(createStream(oldVideo, oldAudio));
|
|
await expect(peer.start()).resolves.toBe(true);
|
|
const connection = FakePeerConnection.latest!;
|
|
const videoSender = connection.senders[0]!;
|
|
const audioSender = connection.senders[1]!;
|
|
const oldSenderVideo = videoSender.track;
|
|
audioSender.failNextReplace = true;
|
|
|
|
const nextVideo = createTrack("video", "next-video");
|
|
const nextAudio = createTrack("audio", "next-audio");
|
|
await expect(
|
|
peer.replaceStream(createStream(nextVideo, nextAudio)),
|
|
).resolves.toBe(false);
|
|
|
|
const nextSenderVideo = videoSender.replaceTrack.mock.calls[0]?.[0];
|
|
expect(nextSenderVideo).not.toBe(nextVideo);
|
|
expect(nextSenderVideo?.id).toBe(nextVideo.id);
|
|
expect(videoSender.replaceTrack).toHaveBeenNthCalledWith(2, oldSenderVideo);
|
|
expect(audioSender.replaceTrack).toHaveBeenNthCalledWith(1, nextAudio);
|
|
expect(audioSender.replaceTrack).toHaveBeenNthCalledWith(2, oldAudio);
|
|
expect(videoSender.track).toBe(oldSenderVideo);
|
|
expect(nextSenderVideo?.stop).toHaveBeenCalledOnce();
|
|
expect(oldSenderVideo?.stop).not.toHaveBeenCalled();
|
|
expect(oldVideo.stop).not.toHaveBeenCalled();
|
|
expect(audioSender.track).toBe(oldAudio);
|
|
});
|
|
});
|
|
|
|
function hostAssignment(
|
|
childPeerIds: string[],
|
|
publicationGeneration: string | null = null,
|
|
): ParticipantRouteAssignment {
|
|
return {
|
|
upstream: { kind: "none" },
|
|
childPeerIds,
|
|
sfuPublicationGeneration: publicationGeneration,
|
|
};
|
|
}
|
|
|
|
function hostProvisionalInput(
|
|
revision: number,
|
|
childPeerIds: string[],
|
|
stream: MediaStream,
|
|
overrides: {
|
|
activeChildPeerIds?: string[];
|
|
publicationGeneration?: string | null;
|
|
} = {},
|
|
) {
|
|
return {
|
|
revision,
|
|
candidate: {
|
|
childPeerId: childPeerIds.at(-1) ?? "candidate-child",
|
|
connectionId: `candidate-connection-${revision}`,
|
|
transport: "direct" as const,
|
|
qualityProbe: false,
|
|
},
|
|
assignment: hostAssignment(
|
|
childPeerIds,
|
|
overrides.publicationGeneration ?? null,
|
|
),
|
|
activeChildPeerIds: overrides.activeChildPeerIds ?? [],
|
|
maxMediaEdges: 2,
|
|
iceConfig: { iceServers: [] },
|
|
stream,
|
|
profile: QUALITY_PROFILES["720p30"],
|
|
videoCodec: VP8_ONLY_VIDEO_CODEC,
|
|
natPredictionEnabled: false,
|
|
};
|
|
}
|
|
|
|
describe("Host provisional child runtime ownership", () => {
|
|
it("rejects failed construction, releases the clone and permits the next preparation", async () => {
|
|
const source = createTrack("video", "construction-source");
|
|
const sendSignal = vi.fn(() => true);
|
|
const owner = new HostProvisionalChild({ sendSignal });
|
|
const input = hostProvisionalInput(7, ["child"], createStream(source, null));
|
|
FakePeerConnection.constructorsFailing = 2;
|
|
expect(owner.prepare(input)).toBe(false);
|
|
expect(owner.prepare(input)).toBe(false);
|
|
expect(sendSignal).not.toHaveBeenCalled();
|
|
for (const result of vi.mocked(source.clone).mock.results) {
|
|
expect((result.value as MediaStreamTrack).stop).toHaveBeenCalledOnce();
|
|
}
|
|
expect(source.stop).not.toHaveBeenCalled();
|
|
expect(FakePeerConnection.activeCount).toBe(0);
|
|
expect(owner.prepare({ ...input, revision: 8 })).toBe(true);
|
|
await vi.waitFor(() => expect(sendSignal).toHaveBeenCalledOnce());
|
|
owner.discard();
|
|
expect(FakePeerConnection.activeCount).toBe(0);
|
|
});
|
|
|
|
it("reports connected preparation once, retries unsent progress and fences replacement", async () => {
|
|
const progress = vi.fn().mockReturnValueOnce(false).mockReturnValue(true);
|
|
const owner = new HostProvisionalChild({ sendSignal: () => true, onPreparedChildConnected: progress });
|
|
const stream = createStream(createTrack("video", "progress-video"), null);
|
|
owner.prepare(hostProvisionalInput(7, ["child"], stream));
|
|
const first = FakePeerConnection.latest!;
|
|
first.connectionState = "connected";
|
|
first.dispatchEvent(new Event("connectionstatechange"));
|
|
first.dispatchEvent(new Event("connectionstatechange"));
|
|
first.dispatchEvent(new Event("connectionstatechange"));
|
|
expect(progress).toHaveBeenCalledTimes(2);
|
|
expect(progress).toHaveBeenLastCalledWith(7, "candidate-connection-7");
|
|
owner.prepare(hostProvisionalInput(8, ["replacement"], stream));
|
|
first.dispatchEvent(new Event("connectionstatechange"));
|
|
expect(progress).toHaveBeenCalledTimes(2);
|
|
const second = FakePeerConnection.latest!;
|
|
second.connectionState = "connected";
|
|
second.dispatchEvent(new Event("connectionstatechange"));
|
|
expect(progress).toHaveBeenLastCalledWith(8, "candidate-connection-8");
|
|
owner.discard();
|
|
second.dispatchEvent(new Event("connectionstatechange"));
|
|
expect(progress).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("accepts only the exact connection and promotes the same peer", async () => {
|
|
const signals: Array<{ peerId: string; connectionId: string }> = [];
|
|
const promotedUpdates = vi.fn();
|
|
const owner = new HostProvisionalChild({
|
|
sendSignal: (peerId, payload) => {
|
|
signals.push({ peerId, connectionId: payload.connectionId });
|
|
return true;
|
|
},
|
|
onPromotedUpdate: promotedUpdates,
|
|
});
|
|
const stream = createStream(createTrack("video", "host-probe-video"), null);
|
|
const input = hostProvisionalInput(7, ["probe-child"], stream);
|
|
|
|
expect(owner.prepare(input)).toBe(true);
|
|
await vi.waitFor(() => expect(signals).toHaveLength(1));
|
|
const preparedConnection = FakePeerConnection.latest!;
|
|
const preparedConnectionId = signals[0]!.connectionId;
|
|
expect(FakePeerConnection.activeCount).toBe(1);
|
|
|
|
expect(owner.acceptSignal("wrong-child", {
|
|
kind: "description",
|
|
connectionId: preparedConnectionId,
|
|
description: { type: "answer", sdp: "wrong-peer" },
|
|
})).toBe(false);
|
|
expect(owner.acceptSignal("probe-child", {
|
|
kind: "description",
|
|
connectionId: "wrong-connection",
|
|
description: { type: "answer", sdp: "wrong-connection" },
|
|
})).toBe(false);
|
|
expect(owner.acceptSignal("probe-child", {
|
|
kind: "candidate",
|
|
connectionId: preparedConnectionId,
|
|
candidate: { candidate: "host-probe-candidate" },
|
|
})).toBe(true);
|
|
expect(owner.acceptSignal("probe-child", {
|
|
kind: "description",
|
|
connectionId: preparedConnectionId,
|
|
description: { type: "answer", sdp: "right-answer" },
|
|
})).toBe(true);
|
|
await vi.waitFor(() =>
|
|
expect(preparedConnection.remoteDescription?.sdp).toBe("right-answer"),
|
|
);
|
|
expect(preparedConnection.addedIceCandidates).toEqual([
|
|
{ candidate: "host-probe-candidate" },
|
|
]);
|
|
|
|
const activation = owner.activate(input);
|
|
expect(activation.kind).toBe("promote");
|
|
if (activation.kind !== "promote") {
|
|
throw new Error("expected the prepared Host child to promote");
|
|
}
|
|
expect(activation.peer.connectionId).toBe(preparedConnectionId);
|
|
expect(FakePeerConnection.latest).toBe(preparedConnection);
|
|
expect(FakePeerConnection.activeCount).toBe(1);
|
|
await expect(activation.peer.restartIce()).resolves.toBe(true);
|
|
expect(signals).toEqual([
|
|
{ peerId: "probe-child", connectionId: preparedConnectionId },
|
|
{ peerId: "probe-child", connectionId: preparedConnectionId },
|
|
]);
|
|
expect(promotedUpdates).toHaveBeenCalledWith(
|
|
activation.peer,
|
|
expect.objectContaining({ connectionId: preparedConnectionId }),
|
|
);
|
|
activation.peer.dispose();
|
|
});
|
|
|
|
it("disposes stale prepares on replacement, rollback, and auth reset", async () => {
|
|
const onPreparedChildFailed = vi.fn();
|
|
const owner = new HostProvisionalChild({ sendSignal: () => true, onPreparedChildFailed });
|
|
const stream = createStream(createTrack("video", "host-cleanup-video"), null);
|
|
|
|
expect(owner.prepare(hostProvisionalInput(7, ["first-probe"], stream))).toBe(true);
|
|
const first = FakePeerConnection.latest!;
|
|
expect(owner.prepare(hostProvisionalInput(8, ["second-probe"], stream))).toBe(true);
|
|
const second = FakePeerConnection.latest!;
|
|
expect(first.connectionState).toBe("closed");
|
|
|
|
expect(owner.activate({
|
|
revision: 9,
|
|
assignment: hostAssignment([]),
|
|
activeChildPeerIds: [],
|
|
maxMediaEdges: 2,
|
|
})).toEqual({ kind: "ordinary" });
|
|
expect(second.connectionState).toBe("closed");
|
|
|
|
expect(owner.prepare(hostProvisionalInput(10, ["auth-probe"], stream))).toBe(true);
|
|
const authProbe = FakePeerConnection.latest!;
|
|
owner.discard();
|
|
expect(authProbe.connectionState).toBe("closed");
|
|
expect(FakePeerConnection.activeCount).toBe(0);
|
|
expect(onPreparedChildFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports failed Host preparation once and fences retired attempts", async () => {
|
|
const onPreparedChildFailed = vi.fn();
|
|
const owner = new HostProvisionalChild({ sendSignal: () => true, onPreparedChildFailed });
|
|
const stream = createStream(createTrack("video", "host-failure-video"), null);
|
|
FakePeerConnection.offersFailing = 1;
|
|
owner.prepare(hostProvisionalInput(7, ["first"], stream));
|
|
await vi.waitFor(() => expect(onPreparedChildFailed).toHaveBeenCalledExactlyOnceWith(7, "candidate-connection-7"));
|
|
|
|
owner.prepare(hostProvisionalInput(8, ["second"], stream));
|
|
const connection = FakePeerConnection.latest!;
|
|
connection.connectionState = "failed";
|
|
connection.dispatchEvent(new Event("connectionstatechange"));
|
|
connection.dispatchEvent(new Event("connectionstatechange"));
|
|
expect(onPreparedChildFailed).toHaveBeenCalledTimes(2);
|
|
expect(onPreparedChildFailed).toHaveBeenLastCalledWith(8, "candidate-connection-8");
|
|
owner.discard();
|
|
connection.dispatchEvent(new Event("connectionstatechange"));
|
|
expect(onPreparedChildFailed).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("updates the prepared stream before promotion", async () => {
|
|
const owner = new HostProvisionalChild({ sendSignal: () => true });
|
|
const initial = createStream(createTrack("video", "initial-video"), null);
|
|
const replacement = createTrack("video", "replacement-video");
|
|
const input = hostProvisionalInput(12, ["stream-child"], initial);
|
|
|
|
expect(owner.prepare(input)).toBe(true);
|
|
const connection = FakePeerConnection.latest!;
|
|
await expect(
|
|
owner.replaceStream(createStream(replacement, null)),
|
|
).resolves.toBe(true);
|
|
expect(connection.senders[0]!.track).not.toBe(replacement);
|
|
expect(connection.senders[0]!.track?.id).toBe(replacement.id);
|
|
|
|
const activation = owner.activate({
|
|
revision: input.revision,
|
|
assignment: input.assignment,
|
|
activeChildPeerIds: [],
|
|
maxMediaEdges: 2,
|
|
});
|
|
expect(activation.kind).toBe("promote");
|
|
if (activation.kind === "promote") {
|
|
activation.peer.dispose();
|
|
}
|
|
});
|
|
|
|
});
|
|
|
|
describe("ViewerRelay downstream ownership", () => {
|
|
it("applies the room NAT policy to downstream Peer connections", async () => {
|
|
const relay = new ViewerRelay(
|
|
{
|
|
iceServers: [{ urls: "stun:share.example.test:3478" }],
|
|
natPredictionStunUrls: [
|
|
"stun:share.example.test:3479",
|
|
"stun:share.example.test:3480",
|
|
],
|
|
},
|
|
QUALITY_PROFILES["720p30"],
|
|
{ sendSignal: () => true },
|
|
2,
|
|
true,
|
|
);
|
|
|
|
relay.setChildren(["nat-child"]);
|
|
relay.setStream(
|
|
createStream(createTrack("video", "nat-relay-source"), null),
|
|
);
|
|
await vi.waitFor(() => expect(FakePeerConnection.latest).not.toBeNull());
|
|
expect(FakePeerConnection.latest!.configurations[0]?.iceServers).toEqual([
|
|
{ urls: "stun:share.example.test:3478" },
|
|
{ urls: "stun:share.example.test:3479" },
|
|
{ urls: "stun:share.example.test:3480" },
|
|
]);
|
|
relay.dispose();
|
|
});
|
|
|
|
it("gives each downstream sender its own video clone", async () => {
|
|
const sourceVideo = createTrack("video", "relay-source");
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
{ sendSignal: () => true },
|
|
);
|
|
|
|
relay.setChildren(["child-one", "child-two"]);
|
|
relay.setStream(createStream(sourceVideo, null));
|
|
await vi.waitFor(() =>
|
|
expect(FakePeerConnection.instances).toHaveLength(2),
|
|
);
|
|
const [firstTrack, secondTrack] = FakePeerConnection.instances.map(
|
|
(connection) => connection.senders[0]!.track!,
|
|
);
|
|
expect(firstTrack).not.toBe(sourceVideo);
|
|
expect(secondTrack).not.toBe(sourceVideo);
|
|
expect(firstTrack).not.toBe(secondTrack);
|
|
expect(firstTrack.contentHint).toBe("motion");
|
|
expect(secondTrack.contentHint).toBe("motion");
|
|
|
|
relay.stop();
|
|
expect(firstTrack.stop).toHaveBeenCalledOnce();
|
|
expect(secondTrack.stop).toHaveBeenCalledOnce();
|
|
expect(sourceVideo.stop).not.toHaveBeenCalled();
|
|
});
|
|
|
|
const routeCandidate = (
|
|
revision: number,
|
|
childPeerId: string,
|
|
) => ({
|
|
childPeerId,
|
|
connectionId: `relay-candidate-${revision}`,
|
|
transport: "direct" as const,
|
|
qualityProbe: false,
|
|
});
|
|
|
|
it("prepares against the current source and codec after an in-flight probe is replaced", async () => {
|
|
let finishOld!: (codec: "vp8") => void;
|
|
let finishCurrent!: (codec: "h264") => void;
|
|
codecPreflight.probe
|
|
.mockImplementationOnce(() => new Promise((resolve) => { finishOld = resolve; }))
|
|
.mockImplementationOnce(() => new Promise((resolve) => { finishCurrent = resolve; }));
|
|
const signals: SignalPayload[] = [];
|
|
const relay = new ViewerRelay({ iceServers: [] }, QUALITY_PROFILES["720p30"], {
|
|
sendSignal: (_peerId, payload) => { signals.push(payload); return true; },
|
|
});
|
|
const current = createTrack("video", "current-source");
|
|
try {
|
|
relay.setStream(createStream(createTrack("video", "old-source"), null));
|
|
expect(relay.prepareChild(7, routeCandidate(7, "child"), ["child"])).toBe(true);
|
|
relay.setStream(createStream(current, null));
|
|
finishOld("vp8");
|
|
await vi.waitFor(() => expect(codecPreflight.probe).toHaveBeenCalledTimes(2));
|
|
expect(FakePeerConnection.instances).toHaveLength(0);
|
|
finishCurrent("h264");
|
|
await vi.waitFor(() => expect(signals).toHaveLength(1));
|
|
expect(signals[0]?.connectionId).toBe("relay-candidate-7");
|
|
const connection = FakePeerConnection.latest!;
|
|
expect(connection.senders[0]!.track!.id).toContain("current-source");
|
|
expect(connection.codecPreferenceCalls[0]?.[0]?.mimeType.toLowerCase()).toBe("video/h264");
|
|
expect(relay.prepareChild(7, routeCandidate(7, "child"), ["child"])).toBe(true);
|
|
expect(FakePeerConnection.instances).toHaveLength(1);
|
|
} finally {
|
|
relay.dispose();
|
|
}
|
|
});
|
|
|
|
it.each(["discard", "stop", "dispose"] as const)("does not resurrect preparation after %s during a codec probe", async (action) => {
|
|
let finishProbe!: (codec: "vp8") => void;
|
|
codecPreflight.probe.mockImplementationOnce(() => new Promise((resolve) => { finishProbe = resolve; }));
|
|
const sendSignal = vi.fn(() => true);
|
|
const relay = new ViewerRelay({ iceServers: [] }, QUALITY_PROFILES["720p30"], { sendSignal });
|
|
relay.setStream(createStream(createTrack("video", "pending-source"), null));
|
|
expect(relay.prepareChild(7, routeCandidate(7, "child"), ["child"])).toBe(true);
|
|
if (action === "discard") relay.discardPreparedChild();
|
|
else relay[action]();
|
|
finishProbe("vp8");
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(FakePeerConnection.instances).toHaveLength(0);
|
|
expect(sendSignal).not.toHaveBeenCalled();
|
|
relay.dispose();
|
|
});
|
|
|
|
it("rebuilds a failed ICE restart from the source selected while it was pending", async () => {
|
|
let finishRestart!: (result: boolean) => void;
|
|
vi.spyOn(HostPeer.prototype, "restartIce").mockImplementationOnce(() =>
|
|
new Promise((resolve) => { finishRestart = resolve; }));
|
|
const signals: SignalPayload[] = [];
|
|
const relay = new ViewerRelay({ iceServers: [] }, QUALITY_PROFILES["720p30"], {
|
|
sendSignal: (_peerId, payload) => { signals.push(payload); return true; },
|
|
});
|
|
try {
|
|
relay.setChildren(["child"]);
|
|
relay.setStream(createStream(createTrack("video", "old-source"), null));
|
|
await vi.waitFor(() => expect(signals).toHaveLength(1));
|
|
const original = FakePeerConnection.latest!;
|
|
const recovery = relay.recover("child", signals[0]!.connectionId, false);
|
|
relay.setStream(createStream(createTrack("video", "current-source"), null));
|
|
await vi.waitFor(() => expect(original.senders[0]!.track!.id).toContain("current-source"));
|
|
finishRestart(false);
|
|
await recovery;
|
|
await vi.waitFor(() => expect(signals).toHaveLength(2));
|
|
expect(original.connectionState).toBe("closed");
|
|
expect(FakePeerConnection.latest!.senders[0]!.track!.id).toContain("current-source");
|
|
} finally {
|
|
relay.dispose();
|
|
}
|
|
});
|
|
|
|
it("keeps a completed H264 decision across source replacement", async () => {
|
|
codecPreflight.probe.mockResolvedValueOnce("h264");
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
{ sendSignal: () => true },
|
|
);
|
|
relay.setStream(createStream(createTrack("video", "h264-source"), null));
|
|
await vi.waitFor(() => expect(codecPreflight.probe).toHaveBeenCalledOnce());
|
|
await Promise.resolve();
|
|
relay.setStream(
|
|
createStream(createTrack("video", "replacement-source"), null),
|
|
);
|
|
expect(codecPreflight.probe).toHaveBeenCalledOnce();
|
|
relay.setChildren(["h264-child"]);
|
|
await vi.waitFor(() =>
|
|
expect(FakePeerConnection.latest?.codecPreferenceCalls).toHaveLength(1),
|
|
);
|
|
|
|
expect(
|
|
FakePeerConnection.latest!.codecPreferenceCalls[0]?.map(({ mimeType }) =>
|
|
mimeType.toLowerCase(),
|
|
),
|
|
).toEqual(["video/h264", "video/vp8"]);
|
|
relay.dispose();
|
|
});
|
|
|
|
it("promotes the exact prepared child connection within the current Viewer cap", async () => {
|
|
const signals: Array<{ peerId: string; connectionId: string }> = [];
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
{
|
|
sendSignal: (peerId, payload) => {
|
|
signals.push({ peerId, connectionId: payload.connectionId });
|
|
return true;
|
|
},
|
|
},
|
|
);
|
|
relay.setStream(createStream(createTrack("video", "prepared-video"), null));
|
|
expect(relay.prepareChild(7, routeCandidate(7, "prepared-child"), ["prepared-child"])).toBe(true);
|
|
await vi.waitFor(() => expect(signals).toHaveLength(1));
|
|
const preparedConnection = FakePeerConnection.latest!;
|
|
const preparedConnectionId = signals[0]!.connectionId;
|
|
expect(relay.getSnapshot("prepared-child")).toBeNull();
|
|
expect(FakePeerConnection.activeCount).toBe(1);
|
|
|
|
await expect(
|
|
relay.acceptSignal("prepared-child", {
|
|
kind: "description",
|
|
connectionId: "wrong-connection",
|
|
description: { type: "answer", sdp: "wrong-answer" },
|
|
}, 7),
|
|
).resolves.toBe(false);
|
|
await expect(
|
|
relay.acceptSignal("prepared-child", {
|
|
kind: "description",
|
|
connectionId: preparedConnectionId,
|
|
description: { type: "answer", sdp: "right-answer" },
|
|
}, 6),
|
|
).resolves.toBe(false);
|
|
const preparedRevision = relay.getSignalRouteRevision(
|
|
"prepared-child",
|
|
preparedConnectionId,
|
|
6,
|
|
);
|
|
expect(preparedRevision).toBe(7);
|
|
const candidate = { candidate: "prepared-candidate" };
|
|
await expect(
|
|
relay.acceptSignal("prepared-child", {
|
|
kind: "candidate",
|
|
connectionId: preparedConnectionId,
|
|
candidate,
|
|
}, preparedRevision),
|
|
).resolves.toBe(true);
|
|
expect(preparedConnection.addedIceCandidates).toEqual([]);
|
|
await expect(
|
|
relay.acceptSignal("prepared-child", {
|
|
kind: "description",
|
|
connectionId: preparedConnectionId,
|
|
description: { type: "answer", sdp: "right-answer" },
|
|
}, preparedRevision),
|
|
).resolves.toBe(true);
|
|
expect(preparedConnection.remoteDescription?.sdp).toBe("right-answer");
|
|
expect(preparedConnection.addedIceCandidates).toEqual([candidate]);
|
|
|
|
preparedConnection.connectionState = "connected";
|
|
relay.activateChildren(7, ["prepared-child"]);
|
|
expect(FakePeerConnection.latest).toBe(preparedConnection);
|
|
expect(FakePeerConnection.activeCount).toBe(1);
|
|
expect(relay.getSnapshot("prepared-child")?.connectionId).toBe(
|
|
preparedConnectionId,
|
|
);
|
|
relay.dispose();
|
|
});
|
|
|
|
it("binds native relay children so their signalling and snapshots stay owned", async () => {
|
|
const stubs = new Map<
|
|
string,
|
|
{ peer: HostMediaPeer; events: ViewerRelayPeerEvents }
|
|
>();
|
|
const nativeSnapshot = (peer: HostMediaPeer): PeerSnapshot => ({
|
|
peerId: peer.peerId,
|
|
connectionId: peer.connectionId,
|
|
connectionState: "connected",
|
|
iceConnectionState: "connected",
|
|
metrics: { ...EMPTY_METRICS },
|
|
error: null,
|
|
});
|
|
const peerFactory: ViewerRelayPeerFactory = {
|
|
requiresStream: false,
|
|
create: (childPeerId, connectionId, events) => {
|
|
const edgeConnectionId = connectionId ?? `native-${childPeerId}`;
|
|
const peer: HostMediaPeer = {
|
|
peerId: childPeerId,
|
|
connectionId: edgeConnectionId,
|
|
start: async () =>
|
|
events.sendSignal(childPeerId, {
|
|
kind: "description",
|
|
connectionId: edgeConnectionId,
|
|
description: { type: "offer", sdp: "native-offer" },
|
|
}),
|
|
acceptSignal: async () => undefined,
|
|
restartIce: async () => true,
|
|
isConnected: () => true,
|
|
getSnapshot: () => nativeSnapshot(peer),
|
|
updateIceConfig: () => undefined,
|
|
updateProfile: async () => true,
|
|
updateCaptureProfile: async () => true,
|
|
setPaused: () => undefined,
|
|
replaceStream: async () => true,
|
|
dispose: () => undefined,
|
|
};
|
|
stubs.set(childPeerId, { peer, events });
|
|
return peer;
|
|
},
|
|
};
|
|
const onSenderUpdate = vi.fn();
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
{ sendSignal: () => true, onSenderUpdate },
|
|
2,
|
|
false,
|
|
peerFactory,
|
|
);
|
|
|
|
relay.setChildren(["native-a"]);
|
|
await vi.waitFor(() => expect(stubs.has("native-a")).toBe(true));
|
|
expect(
|
|
relay.prepareChild(7, routeCandidate(7, "native-b"), [
|
|
"native-a",
|
|
"native-b",
|
|
]),
|
|
).toBe(true);
|
|
await vi.waitFor(() => expect(stubs.has("native-b")).toBe(true));
|
|
const active = stubs.get("native-a")!;
|
|
const prepared = stubs.get("native-b")!;
|
|
|
|
expect(
|
|
active.events.sendSignal("native-a", {
|
|
kind: "candidate",
|
|
connectionId: active.peer.connectionId,
|
|
candidate: { candidate: "late-candidate" },
|
|
}),
|
|
).toBe(true);
|
|
prepared.events.onUpdate(nativeSnapshot(prepared.peer));
|
|
expect(onSenderUpdate).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ peerId: "native-b" }),
|
|
7,
|
|
);
|
|
|
|
relay.activateChildren(7, ["native-a", "native-b"]);
|
|
prepared.events.onUpdate({
|
|
...nativeSnapshot(prepared.peer),
|
|
error: { key: "host.err.serverError" },
|
|
});
|
|
expect(relay.getSnapshot("native-b")?.error).toEqual({
|
|
key: "host.err.serverError",
|
|
});
|
|
relay.dispose();
|
|
});
|
|
|
|
it("replaces one active child with a same-parent prepared connection", async () => {
|
|
const signals: Array<{ peerId: string; connectionId: string }> = [];
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
{
|
|
sendSignal: (peerId, payload) => {
|
|
signals.push({ peerId, connectionId: payload.connectionId });
|
|
return true;
|
|
},
|
|
},
|
|
);
|
|
relay.setStream(createStream(createTrack("video", "same-parent-video"), null));
|
|
expect(relay.prepareChild(1, routeCandidate(1, "same-child"), ["same-child"])).toBe(true);
|
|
await vi.waitFor(() => expect(signals).toHaveLength(1));
|
|
const firstConnection = FakePeerConnection.latest!;
|
|
await relay.acceptSignal("same-child", {
|
|
kind: "description",
|
|
connectionId: signals[0]!.connectionId,
|
|
description: { type: "answer", sdp: "first-answer" },
|
|
}, 1);
|
|
firstConnection.connectionState = "connected";
|
|
relay.activateChildren(1, ["same-child"]);
|
|
expect(relay.getSnapshot("same-child")?.connectionId).toBe(
|
|
signals[0]!.connectionId,
|
|
);
|
|
|
|
expect(relay.prepareChild(2, routeCandidate(2, "same-child"), ["same-child"])).toBe(true);
|
|
await vi.waitFor(() => expect(signals).toHaveLength(2));
|
|
const secondConnection = FakePeerConnection.latest!;
|
|
expect(firstConnection.connectionState).toBe("connected");
|
|
await relay.acceptSignal("same-child", {
|
|
kind: "description",
|
|
connectionId: signals[1]!.connectionId,
|
|
description: { type: "answer", sdp: "second-answer" },
|
|
}, 2);
|
|
secondConnection.connectionState = "connected";
|
|
relay.activateChildren(2, ["same-child"]);
|
|
|
|
expect(firstConnection.connectionState).toBe("closed");
|
|
expect(secondConnection.connectionState).toBe("connected");
|
|
expect(relay.getSnapshot("same-child")?.connectionId).toBe(
|
|
signals[1]!.connectionId,
|
|
);
|
|
relay.dispose();
|
|
});
|
|
|
|
it("cleans prepared children on failure, replacement, rollback, session reset, and share stop", async () => {
|
|
const onPreparedChildFailed = vi.fn();
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
{ sendSignal: () => true, onPreparedChildFailed },
|
|
);
|
|
relay.setStream(createStream(createTrack("video", "rollback-video"), null));
|
|
expect(relay.prepareChild(7, routeCandidate(7, "first-probe"), ["first-probe"])).toBe(true);
|
|
await vi.waitFor(() => expect(FakePeerConnection.latest).not.toBeNull());
|
|
const firstProbe = FakePeerConnection.latest!;
|
|
expect(relay.prepareChild(8, routeCandidate(8, "second-probe"), ["second-probe"])).toBe(true);
|
|
const secondProbe = FakePeerConnection.latest!;
|
|
expect(firstProbe.connectionState).toBe("closed");
|
|
relay.activateChildren(9, []);
|
|
expect(secondProbe.connectionState).toBe("closed");
|
|
expect(onPreparedChildFailed).not.toHaveBeenCalled();
|
|
|
|
FakePeerConnection.offersFailing = 1;
|
|
const instancesBeforeFailure = FakePeerConnection.instances.length;
|
|
expect(relay.prepareChild(10, routeCandidate(10, "failed-probe"), ["failed-probe"])).toBe(true);
|
|
const failedProbe = FakePeerConnection.latest!;
|
|
await vi.waitFor(() => expect(failedProbe.connectionState).toBe("closed"));
|
|
expect(FakePeerConnection.instances).toHaveLength(instancesBeforeFailure + 1);
|
|
expect(onPreparedChildFailed).toHaveBeenCalledExactlyOnceWith(10, "relay-candidate-10");
|
|
|
|
expect(relay.prepareChild(11, routeCandidate(11, "session-probe"), ["session-probe"])).toBe(true);
|
|
const sessionProbe = FakePeerConnection.latest!;
|
|
relay.discardPreparedChild();
|
|
expect(sessionProbe.connectionState).toBe("closed");
|
|
|
|
expect(relay.prepareChild(12, routeCandidate(12, "stopped-probe"), ["stopped-probe"])).toBe(true);
|
|
const stoppedProbe = FakePeerConnection.latest!;
|
|
relay.stop();
|
|
expect(stoppedProbe.connectionState).toBe("closed");
|
|
relay.dispose();
|
|
expect(onPreparedChildFailed).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("reports relay transport progress once and retires its reporting scope", async () => {
|
|
const progress = vi.fn().mockReturnValueOnce(false).mockReturnValue(true);
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] }, QUALITY_PROFILES["720p30"],
|
|
{ sendSignal: () => true, onPreparedChildConnected: progress },
|
|
);
|
|
relay.setStream(createStream(createTrack("video", "relay-progress"), null));
|
|
relay.prepareChild(7, routeCandidate(7, "child"), ["child"]);
|
|
await vi.waitFor(() => expect(FakePeerConnection.latest).not.toBeNull());
|
|
const first = FakePeerConnection.latest!;
|
|
first.connectionState = "connected";
|
|
first.dispatchEvent(new Event("connectionstatechange"));
|
|
first.dispatchEvent(new Event("connectionstatechange"));
|
|
first.dispatchEvent(new Event("connectionstatechange"));
|
|
expect(progress).toHaveBeenCalledTimes(2);
|
|
expect(progress).toHaveBeenLastCalledWith(7, "relay-candidate-7");
|
|
relay.prepareChild(8, routeCandidate(8, "replacement"), ["replacement"]);
|
|
first.dispatchEvent(new Event("connectionstatechange"));
|
|
expect(progress).toHaveBeenCalledTimes(2);
|
|
const second = FakePeerConnection.latest!;
|
|
second.connectionState = "connected";
|
|
second.dispatchEvent(new Event("connectionstatechange"));
|
|
expect(progress).toHaveBeenLastCalledWith(8, "relay-candidate-8");
|
|
relay.dispose();
|
|
second.dispatchEvent(new Event("connectionstatechange"));
|
|
expect(progress).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it.each([false, true])("bounds ordinary relay construction retries; retired=%s", async (retired) => {
|
|
vi.useFakeTimers();
|
|
const relay = new ViewerRelay({ iceServers: [] }, QUALITY_PROFILES["720p30"], { sendSignal: () => true });
|
|
const source = createTrack("video", "relay-retry");
|
|
try {
|
|
relay.setStream(createStream(source, null));
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
FakePeerConnection.constructorsFailing = 3;
|
|
relay.activateChildren(1, ["child"]);
|
|
expect(source.clone).toHaveBeenCalledTimes(1);
|
|
if (retired) relay.dispose();
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
expect(source.clone).toHaveBeenCalledTimes(retired ? 1 : 2);
|
|
for (const result of vi.mocked(source.clone).mock.results) {
|
|
expect((result.value as MediaStreamTrack).stop).toHaveBeenCalledOnce();
|
|
}
|
|
expect(source.stop).not.toHaveBeenCalled();
|
|
expect(FakePeerConnection.activeCount).toBe(0);
|
|
} finally {
|
|
relay.dispose();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("reports deferred prepared construction failure and accepts the next candidate", async () => {
|
|
const onPreparedChildFailed = vi.fn();
|
|
const sendSignal = vi.fn(() => true);
|
|
const relay = new ViewerRelay({ iceServers: [] }, QUALITY_PROFILES["720p30"],
|
|
{ sendSignal, onPreparedChildFailed });
|
|
const source = createTrack("video", "relay-construction");
|
|
relay.setStream(createStream(source, null));
|
|
FakePeerConnection.constructorsFailing = 1;
|
|
expect(relay.prepareChild(1, routeCandidate(1, "child"), ["child"])).toBe(true);
|
|
await vi.waitFor(() => expect(onPreparedChildFailed).toHaveBeenCalledExactlyOnceWith(1, "relay-candidate-1"));
|
|
expect(sendSignal).not.toHaveBeenCalled();
|
|
expect(vi.mocked(source.clone).mock.results[0]!.value.stop).toHaveBeenCalledOnce();
|
|
expect(source.stop).not.toHaveBeenCalled();
|
|
expect(relay.prepareChild(2, routeCandidate(2, "child"), ["child"])).toBe(true);
|
|
await vi.waitFor(() => expect(sendSignal).toHaveBeenCalledOnce());
|
|
relay.dispose();
|
|
expect(FakePeerConnection.activeCount).toBe(0);
|
|
});
|
|
|
|
it("reports a failed prepared transport once without blaming retained children", async () => {
|
|
const onPreparedChildFailed = vi.fn();
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
{ sendSignal: () => true, onPreparedChildFailed },
|
|
);
|
|
relay.setStream(createStream(createTrack("video", "failure-video"), null));
|
|
relay.prepareChild(1, routeCandidate(1, "retained"), ["retained"]);
|
|
await vi.waitFor(() => expect(FakePeerConnection.latest).not.toBeNull());
|
|
const retained = FakePeerConnection.latest!;
|
|
retained.connectionState = "connected";
|
|
relay.activateChildren(1, ["retained"]);
|
|
relay.prepareChild(2, routeCandidate(2, "candidate"), ["retained", "candidate"]);
|
|
const candidate = FakePeerConnection.latest!;
|
|
candidate.connectionState = "failed";
|
|
candidate.dispatchEvent(new Event("connectionstatechange"));
|
|
candidate.dispatchEvent(new Event("connectionstatechange"));
|
|
expect(onPreparedChildFailed).toHaveBeenCalledExactlyOnceWith(2, "relay-candidate-2");
|
|
expect(retained.connectionState).toBe("connected");
|
|
relay.activateChildren(3, ["retained"]);
|
|
candidate.dispatchEvent(new Event("connectionstatechange"));
|
|
expect(onPreparedChildFailed).toHaveBeenCalledTimes(1);
|
|
relay.dispose();
|
|
});
|
|
|
|
it("keeps the prepared connection when its stream replacement succeeds", async () => {
|
|
const signals: Array<{ peerId: string; connectionId: string }> = [];
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
{
|
|
sendSignal: (peerId, payload) => {
|
|
signals.push({ peerId, connectionId: payload.connectionId });
|
|
return true;
|
|
},
|
|
},
|
|
);
|
|
relay.setStream(createStream(createTrack("video", "initial-video"), null));
|
|
expect(relay.prepareChild(7, routeCandidate(7, "prepared-child"), ["prepared-child"])).toBe(true);
|
|
await vi.waitFor(() => expect(signals).toHaveLength(1));
|
|
const preparedConnection = FakePeerConnection.latest!;
|
|
const preparedConnectionId = signals[0]!.connectionId;
|
|
const instanceCount = FakePeerConnection.instances.length;
|
|
|
|
const replacementVideo = createTrack("video", "replacement-video");
|
|
relay.setStream(createStream(replacementVideo, null));
|
|
await vi.waitFor(() =>
|
|
expect(preparedConnection.senders[0]!.track?.id).toBe(replacementVideo.id),
|
|
);
|
|
expect(FakePeerConnection.instances).toHaveLength(instanceCount);
|
|
preparedConnection.connectionState = "connected";
|
|
relay.activateChildren(7, ["prepared-child"]);
|
|
expect(relay.getSnapshot("prepared-child")?.connectionId).toBe(
|
|
preparedConnectionId,
|
|
);
|
|
expect(FakePeerConnection.instances).toHaveLength(instanceCount);
|
|
relay.dispose();
|
|
});
|
|
|
|
it("syncs same-stream audio changes into the prepared connection", async () => {
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
{ sendSignal: () => true },
|
|
);
|
|
const video = createTrack("video", "persistent-video");
|
|
let audio: MediaStreamTrack | null = null;
|
|
const persistentStream = {
|
|
getTracks: () => (audio ? [video, audio] : [video]),
|
|
getVideoTracks: () => [video],
|
|
getAudioTracks: () => (audio ? [audio] : []),
|
|
} as unknown as MediaStream;
|
|
|
|
relay.setStream(persistentStream);
|
|
expect(
|
|
relay.prepareChild(
|
|
7,
|
|
routeCandidate(7, "prepared-child"),
|
|
["prepared-child"],
|
|
),
|
|
).toBe(true);
|
|
await vi.waitFor(() => expect(FakePeerConnection.latest).not.toBeNull());
|
|
const preparedConnection = FakePeerConnection.latest!;
|
|
expect(preparedConnection.senders[1]?.track).toBeNull();
|
|
|
|
audio = createTrack("audio", "late-audio");
|
|
relay.setStream(persistentStream);
|
|
await vi.waitFor(() =>
|
|
expect(preparedConnection.senders[1]?.track).toBe(audio),
|
|
);
|
|
|
|
audio = null;
|
|
relay.setStream(persistentStream);
|
|
await vi.waitFor(() =>
|
|
expect(preparedConnection.senders[1]?.track).toBeNull(),
|
|
);
|
|
expect(FakePeerConnection.latest).toBe(preparedConnection);
|
|
relay.dispose();
|
|
});
|
|
|
|
it("admits provisional children until endpoint cap three is full", async () => {
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
{ sendSignal: () => true },
|
|
);
|
|
relay.setStream(createStream(createTrack("video", "cap-video"), null));
|
|
expect(relay.prepareChild(1, routeCandidate(1, "child-0"), ["child-0"])).toBe(true);
|
|
await vi.waitFor(() => expect(FakePeerConnection.latest).not.toBeNull());
|
|
const preparedConnection = FakePeerConnection.latest!;
|
|
preparedConnection.connectionState = "connected";
|
|
relay.activateChildren(1, ["child-0"]);
|
|
expect(FakePeerConnection.activeCount).toBe(1);
|
|
expect(relay.prepareChild(2, routeCandidate(2, "child-1"), ["child-0", "child-1"])).toBe(true);
|
|
FakePeerConnection.latest!.connectionState = "connected";
|
|
relay.activateChildren(2, ["child-0", "child-1"]);
|
|
expect(relay.prepareChild(3, routeCandidate(3, "child-2"), ["child-0", "child-1", "child-2"])).toBe(
|
|
true,
|
|
);
|
|
FakePeerConnection.latest!.connectionState = "connected";
|
|
relay.activateChildren(3, ["child-0", "child-1", "child-2"]);
|
|
expect(
|
|
relay.prepareChild(4, routeCandidate(4, "child-3"), ["child-0", "child-1", "child-2", "child-3"]),
|
|
).toBe(false);
|
|
expect(FakePeerConnection.activeCount).toBe(3);
|
|
relay.dispose();
|
|
});
|
|
|
|
it("reconciles a reauthenticated endpoint cap without rebuilding retained children", async () => {
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
{ sendSignal: () => true },
|
|
3,
|
|
);
|
|
relay.setStream(createStream(createTrack("video", "cap-update-video"), null));
|
|
relay.setChildren(["child-0", "child-1", "child-2"]);
|
|
await vi.waitFor(() => expect(FakePeerConnection.instances).toHaveLength(3));
|
|
const retained = FakePeerConnection.instances.slice(0, 2);
|
|
const removed = FakePeerConnection.instances[2]!;
|
|
retained.forEach((connection) => {
|
|
connection.connectionState = "connected";
|
|
});
|
|
|
|
relay.updateCapacity(2);
|
|
|
|
expect(FakePeerConnection.instances.slice(0, 2)).toEqual(retained);
|
|
expect(removed.connectionState).toBe("closed");
|
|
expect(FakePeerConnection.activeCount).toBe(2);
|
|
relay.dispose();
|
|
});
|
|
|
|
it("exposes a defensive snapshot of current downstream send metrics", async () => {
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
{ sendSignal: () => true },
|
|
);
|
|
relay.setChildren(["metrics-child"]);
|
|
relay.setStream(
|
|
createStream(createTrack("video", "metrics-video"), null),
|
|
);
|
|
|
|
await vi.waitFor(() =>
|
|
expect(relay.getSnapshot()?.peerId).toBe("metrics-child"),
|
|
);
|
|
const connection = FakePeerConnection.latest!;
|
|
connection.statsReports.push(
|
|
sendStatsReport({
|
|
bytesSent: 1_000_000,
|
|
framesEncoded: 30,
|
|
timestamp: 1_000,
|
|
totalEncodeTime: 0.15,
|
|
qualityLimitationReason: "cpu",
|
|
trackIdentifier: "metrics-video",
|
|
}),
|
|
sendStatsReport({
|
|
bytesSent: 1_500_000,
|
|
framesEncoded: 60,
|
|
timestamp: 2_000,
|
|
totalEncodeTime: 0.75,
|
|
qualityLimitationReason: "bandwidth",
|
|
trackIdentifier: "metrics-video",
|
|
}),
|
|
);
|
|
|
|
statsCallbacks[0]!();
|
|
await vi.waitFor(() =>
|
|
expect(relay.getSnapshot()?.metrics.qualityLimitationReason).toBe("cpu"),
|
|
);
|
|
expect(relay.getSnapshot()?.metrics.intervalEncodeMs).toBeNull();
|
|
statsCallbacks[0]!();
|
|
await vi.waitFor(() =>
|
|
expect(relay.getSnapshot()?.metrics.intervalEncodeMs).toBe(20),
|
|
);
|
|
|
|
const snapshot = relay.getSnapshot()!;
|
|
expect(snapshot.metrics).toMatchObject({
|
|
intervalEncodeMs: 20,
|
|
bitrateKbps: 4_000,
|
|
encoderImplementation: "test-encoder",
|
|
qualityLimitationReason: "bandwidth",
|
|
});
|
|
snapshot.metrics.qualityLimitationReason = "mutated";
|
|
expect(relay.getSnapshot()?.metrics.qualityLimitationReason).toBe(
|
|
"bandwidth",
|
|
);
|
|
});
|
|
|
|
it("clears snapshots across child replacement, stop, and dispose", async () => {
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
{ sendSignal: () => true },
|
|
);
|
|
const stream = createStream(createTrack("video", "relay-video"), null);
|
|
relay.setChildren(["first-child"]);
|
|
relay.setStream(stream);
|
|
await vi.waitFor(() =>
|
|
expect(relay.getSnapshot()?.peerId).toBe("first-child"),
|
|
);
|
|
|
|
relay.setChildren(["second-child"]);
|
|
expect(relay.getSnapshot()).toBeNull();
|
|
await vi.waitFor(() =>
|
|
expect(relay.getSnapshot()?.peerId).toBe("second-child"),
|
|
);
|
|
|
|
relay.stop();
|
|
expect(relay.getSnapshot()).toBeNull();
|
|
relay.setStream(stream);
|
|
await vi.waitFor(() =>
|
|
expect(relay.getSnapshot()?.peerId).toBe("second-child"),
|
|
);
|
|
|
|
relay.dispose();
|
|
expect(relay.getSnapshot()).toBeNull();
|
|
relay.setStream(stream);
|
|
expect(relay.getSnapshot()).toBeNull();
|
|
});
|
|
|
|
it("does not publish a stale stats result after replacing its child", async () => {
|
|
let resolveOldStats!: (report: RTCStatsReport) => void;
|
|
const pendingOldStats = new Promise<RTCStatsReport>((resolve) => {
|
|
resolveOldStats = resolve;
|
|
});
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
{ sendSignal: () => true },
|
|
);
|
|
relay.setChildren(["old-child"]);
|
|
relay.setStream(
|
|
createStream(createTrack("video", "relay-video"), null),
|
|
);
|
|
await vi.waitFor(() =>
|
|
expect(relay.getSnapshot()?.peerId).toBe("old-child"),
|
|
);
|
|
FakePeerConnection.latest!.statsReports.push(pendingOldStats);
|
|
statsCallbacks[0]!();
|
|
|
|
relay.setChildren(["current-child"]);
|
|
await vi.waitFor(() =>
|
|
expect(relay.getSnapshot()?.peerId).toBe("current-child"),
|
|
);
|
|
resolveOldStats(
|
|
sendStatsReport({
|
|
bytesSent: 999_999,
|
|
framesEncoded: 30,
|
|
timestamp: 1_000,
|
|
qualityLimitationReason: "old-peer",
|
|
}),
|
|
);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(relay.getSnapshot()).toMatchObject({
|
|
peerId: "current-child",
|
|
metrics: { qualityLimitationReason: null },
|
|
});
|
|
});
|
|
|
|
it("creates a downstream peer without secure-context randomUUID", async () => {
|
|
vi.stubGlobal("crypto", {
|
|
getRandomValues: (bytes: Uint8Array) => {
|
|
bytes.fill(7);
|
|
return bytes;
|
|
},
|
|
});
|
|
const targets: string[] = [];
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
{
|
|
sendSignal: (peerId) => {
|
|
targets.push(peerId);
|
|
return true;
|
|
},
|
|
},
|
|
);
|
|
|
|
relay.setChildren(["lan-child-peer"]);
|
|
relay.setStream(
|
|
createStream(createTrack("video", "lan-video"), null),
|
|
);
|
|
|
|
await vi.waitFor(() => expect(targets).toEqual(["lan-child-peer"]));
|
|
expect(FakePeerConnection.latest?.connectionState).toBe("new");
|
|
});
|
|
|
|
it("retries a missing peer when the same assignment is reconciled", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
FakePeerConnection.offersFailing = 2;
|
|
const targets: string[] = [];
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
{
|
|
sendSignal: (peerId) => {
|
|
targets.push(peerId);
|
|
return true;
|
|
},
|
|
},
|
|
);
|
|
const stream = createStream(createTrack("video", "relay-video"), null);
|
|
relay.setChildren(["same-child-peer"]);
|
|
relay.setStream(stream);
|
|
|
|
await vi.runAllTimersAsync();
|
|
const failedPeer = FakePeerConnection.latest;
|
|
expect(targets).toEqual([]);
|
|
|
|
relay.setChildren(["same-child-peer"]);
|
|
await vi.runAllTimersAsync();
|
|
|
|
expect(FakePeerConnection.latest).not.toBe(failedPeer);
|
|
expect(targets).toEqual(["same-child-peer"]);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("preserves a connecting replacement when topology reaffirms the same child", async () => {
|
|
const connectionIds: string[] = [];
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
{
|
|
sendSignal: (_peerId, payload) => {
|
|
connectionIds.push(payload.connectionId);
|
|
return true;
|
|
},
|
|
},
|
|
);
|
|
relay.setChildren(["reconciled-child"]);
|
|
relay.setStream(
|
|
createStream(createTrack("video", "reconciled-video"), null),
|
|
);
|
|
try {
|
|
await vi.waitFor(() => expect(connectionIds).toHaveLength(1));
|
|
const original = FakePeerConnection.latest!;
|
|
original.connectionState = "connected";
|
|
|
|
await relay.recover("reconciled-child", connectionIds[0]!, true);
|
|
await vi.waitFor(() => expect(connectionIds).toHaveLength(2));
|
|
const replacement = FakePeerConnection.latest!;
|
|
expect(replacement).not.toBe(original);
|
|
expect(original.connectionState).toBe("closed");
|
|
replacement.connectionState = "connecting";
|
|
|
|
relay.activateChildren(2, ["reconciled-child"]);
|
|
relay.setChildren(["reconciled-child"]);
|
|
relay.updateCapacity(1);
|
|
|
|
await expect(relay.acceptSignal("reconciled-child", {
|
|
kind: "description",
|
|
connectionId: connectionIds[1]!,
|
|
description: { type: "answer", sdp: "replacement-answer" },
|
|
}, 2)).resolves.toBe(true);
|
|
expect(FakePeerConnection.latest).toBe(replacement);
|
|
expect(replacement.connectionState).toBe("connecting");
|
|
expect(replacement.remoteDescription?.sdp).toBe("replacement-answer");
|
|
expect(connectionIds).toHaveLength(2);
|
|
} finally {
|
|
relay.dispose();
|
|
}
|
|
});
|
|
|
|
it("rebuilds only unfinished negotiations after signaling resynchronizes", async () => {
|
|
const signals: Array<{ peerId: string; connectionId: string }> = [];
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["720p30"],
|
|
{
|
|
sendSignal: (peerId, payload) => {
|
|
signals.push({ peerId, connectionId: payload.connectionId });
|
|
return true;
|
|
},
|
|
},
|
|
);
|
|
const children = ["healthy-child", "unfinished-child"];
|
|
relay.setChildren(children);
|
|
relay.setStream(createStream(createTrack("video", "resync-video"), null));
|
|
try {
|
|
await vi.waitFor(() => expect(signals).toHaveLength(2));
|
|
const [healthy, unfinished] = FakePeerConnection.instances;
|
|
healthy!.connectionState = "connected";
|
|
unfinished!.connectionState = "connecting";
|
|
const oldId = signals.find(({ peerId }) => peerId === "unfinished-child")!.connectionId;
|
|
|
|
relay.resyncSignaling();
|
|
relay.activateChildren(2, children);
|
|
await vi.waitFor(() => expect(signals).toHaveLength(3));
|
|
expect(healthy!.connectionState).toBe("connected");
|
|
expect(unfinished!.connectionState).toBe("closed");
|
|
expect(signals.filter(({ peerId }) => peerId === "healthy-child")).toHaveLength(1);
|
|
const replacement = FakePeerConnection.latest!;
|
|
const newId = signals[2]!.connectionId;
|
|
expect(newId).not.toBe(oldId);
|
|
await expect(relay.acceptSignal("unfinished-child", {
|
|
kind: "description",
|
|
connectionId: oldId,
|
|
description: { type: "answer", sdp: "stale-answer" },
|
|
}, 2)).resolves.toBe(false);
|
|
await expect(relay.acceptSignal("unfinished-child", {
|
|
kind: "description",
|
|
connectionId: newId,
|
|
description: { type: "answer", sdp: "resynced-answer" },
|
|
}, 2)).resolves.toBe(true);
|
|
expect(replacement.remoteDescription?.sdp).toBe("resynced-answer");
|
|
} finally {
|
|
relay.dispose();
|
|
}
|
|
});
|
|
|
|
it("keeps one downstream connection across upstream stream replacement", async () => {
|
|
const targets: string[] = [];
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["1080p60"],
|
|
{
|
|
sendSignal: (peerId) => {
|
|
targets.push(peerId);
|
|
return true;
|
|
},
|
|
},
|
|
);
|
|
const firstStream = createStream(
|
|
createTrack("video", "first-video"),
|
|
createTrack("audio", "first-audio"),
|
|
);
|
|
|
|
relay.setChildren(["child-peer-one"]);
|
|
relay.setStream(firstStream);
|
|
await vi.waitFor(() => expect(FakePeerConnection.latest).not.toBeNull());
|
|
const connection = FakePeerConnection.latest!;
|
|
await vi.waitFor(() => expect(targets).toEqual(["child-peer-one"]));
|
|
|
|
const nextVideo = createTrack("video", "next-video");
|
|
const nextAudio = createTrack("audio", "next-audio");
|
|
relay.setStream(createStream(nextVideo, nextAudio));
|
|
await vi.waitFor(() =>
|
|
expect(connection.senders[0]?.track?.id).toBe(nextVideo.id),
|
|
);
|
|
expect(connection.senders[0]?.track).not.toBe(nextVideo);
|
|
|
|
expect(FakePeerConnection.latest).toBe(connection);
|
|
expect(connection.senders[1]?.track).toBe(nextAudio);
|
|
expect(connection.transceiverInputs).toHaveLength(2);
|
|
|
|
relay.setChildren(["child-peer-two"]);
|
|
await vi.waitFor(() => expect(FakePeerConnection.latest).not.toBe(connection));
|
|
expect(connection.connectionState).toBe("closed");
|
|
await vi.waitFor(() => expect(targets.at(-1)).toBe("child-peer-two"));
|
|
});
|
|
|
|
it("applies the latest profile to the current and future child", async () => {
|
|
const customSettings = {
|
|
resolution: "1440p",
|
|
maxFramerate: 45,
|
|
maxBitrate: 10_500_000,
|
|
degradationPreference: "balanced",
|
|
} as const;
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
QUALITY_PROFILES["1080p60"],
|
|
{ sendSignal: () => true },
|
|
);
|
|
relay.setChildren(["first-profile-child"]);
|
|
relay.setStream(createStream(createTrack("video", "profile-video"), null));
|
|
await vi.waitFor(() =>
|
|
expect(relay.getSnapshot("first-profile-child")).not.toBeNull(),
|
|
);
|
|
const firstConnectionId = relay.getSnapshot("first-profile-child")!.connectionId;
|
|
const firstConnection = FakePeerConnection.latest!;
|
|
await expect(
|
|
relay.acceptSignal(
|
|
"first-profile-child",
|
|
{
|
|
kind: "description",
|
|
connectionId: firstConnectionId,
|
|
description: { type: "answer", sdp: "first-answer" },
|
|
},
|
|
0,
|
|
),
|
|
).resolves.toBe(true);
|
|
firstConnection.connectionState = "connected";
|
|
firstConnection.dispatchEvent(new Event("connectionstatechange"));
|
|
await completeVideoStartup(firstConnection);
|
|
|
|
await expect(
|
|
relay.updateProfile(customSettings),
|
|
).resolves.toBe(true);
|
|
expect(
|
|
firstConnection.senders[0]?.setParameters.mock.calls.at(-1)?.[0],
|
|
).toMatchObject({
|
|
degradationPreference: "balanced",
|
|
encodings: [{ maxBitrate: 10_500_000, maxFramerate: 45 }],
|
|
});
|
|
|
|
relay.setChildren(["second-profile-child"]);
|
|
await vi.waitFor(() =>
|
|
expect(FakePeerConnection.latest).not.toBe(firstConnection),
|
|
);
|
|
const secondConnection = FakePeerConnection.latest!;
|
|
await vi.waitFor(() =>
|
|
expect(secondConnection.localDescription?.type).toBe("offer"),
|
|
);
|
|
await vi.waitFor(() =>
|
|
expect(relay.getSnapshot("second-profile-child")).not.toBeNull(),
|
|
);
|
|
const secondConnectionId = relay.getSnapshot("second-profile-child")!.connectionId;
|
|
await expect(
|
|
relay.acceptSignal(
|
|
"second-profile-child",
|
|
{
|
|
kind: "description",
|
|
connectionId: secondConnectionId,
|
|
description: { type: "answer", sdp: "second-answer" },
|
|
},
|
|
0,
|
|
),
|
|
).resolves.toBe(true);
|
|
secondConnection.connectionState = "connected";
|
|
secondConnection.dispatchEvent(new Event("connectionstatechange"));
|
|
await completeVideoStartup(secondConnection);
|
|
expect(
|
|
secondConnection.senders[0]?.setParameters.mock.calls[0]?.[0],
|
|
).toMatchObject({
|
|
degradationPreference: "balanced",
|
|
encodings: [{ maxBitrate: 10_500_000, maxFramerate: 45 }],
|
|
});
|
|
});
|
|
|
|
it("applies the latest audio ceiling to current and future children", async () => {
|
|
const initialProfile = {
|
|
...QUALITY_PROFILES["1080p60"],
|
|
screenAudioQuality: "saver",
|
|
} as const;
|
|
const relay = new ViewerRelay(
|
|
{ iceServers: [] },
|
|
initialProfile,
|
|
{ sendSignal: () => true },
|
|
);
|
|
relay.setChildren(["first-audio-child"]);
|
|
relay.setStream(
|
|
createStream(
|
|
createTrack("video", "relay-video"),
|
|
createTrack("audio", "relay-audio"),
|
|
),
|
|
);
|
|
await vi.waitFor(() =>
|
|
expect(FakePeerConnection.latest?.senders[1]?.appliedMaxBitrates).toEqual([
|
|
64_000,
|
|
]),
|
|
);
|
|
const firstConnection = FakePeerConnection.latest!;
|
|
|
|
await expect(
|
|
relay.updateProfile({
|
|
...initialProfile,
|
|
screenAudioQuality: "very-high",
|
|
}),
|
|
).resolves.toBe(true);
|
|
expect(firstConnection.senders[1]?.appliedMaxBitrates).toEqual([
|
|
64_000,
|
|
192_000,
|
|
]);
|
|
|
|
relay.setChildren(["second-audio-child"]);
|
|
await vi.waitFor(() =>
|
|
expect(FakePeerConnection.latest).not.toBe(firstConnection),
|
|
);
|
|
await vi.waitFor(() =>
|
|
expect(FakePeerConnection.latest?.senders[1]?.appliedMaxBitrates).toEqual([
|
|
192_000,
|
|
]),
|
|
);
|
|
});
|
|
});
|