612 lines
27 KiB
TypeScript
612 lines
27 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
import {
|
|
DEFAULT_SCREEN_SHARE_ENCODING_POLICY,
|
|
applyScreenShareEncodingPolicy,
|
|
createRemoteOfficeMedia,
|
|
type MediaAuthorizationDecision,
|
|
type RemoteMediaTimers,
|
|
type ScreenShareAccessGrant,
|
|
type ScreenShareBinding,
|
|
type ScreenShareParticipant,
|
|
} from "../media/index.ts";
|
|
|
|
const BINDING: ScreenShareBinding = {
|
|
officeId: "lumbridge-hq",
|
|
levelId: "level-1",
|
|
roomId: "commons",
|
|
screenId: "wall-display",
|
|
};
|
|
const NOW = 1_765_000_000_000;
|
|
|
|
class FakeTrack {
|
|
readonly kind = "video";
|
|
readyState: MediaStreamTrackState = "live";
|
|
stopped = 0;
|
|
contentHint = "";
|
|
private readonly settings: MediaTrackSettings;
|
|
constructor(settings: MediaTrackSettings = { width: 3_840, height: 2_160, frameRate: 60 }) {
|
|
this.settings = settings;
|
|
}
|
|
getSettings(): MediaTrackSettings { return { ...this.settings }; }
|
|
stop(): void { this.stopped += 1; this.readyState = "ended"; }
|
|
}
|
|
|
|
class FakeSender {
|
|
readonly writes: RTCRtpSendParameters[] = [];
|
|
private readonly rejectWrites: boolean;
|
|
private readonly supported: boolean;
|
|
constructor(rejectWrites = false, supported = true) {
|
|
this.rejectWrites = rejectWrites;
|
|
this.supported = supported;
|
|
}
|
|
getParameters(): RTCRtpSendParameters {
|
|
if (!this.supported) throw new Error("getParameters unsupported");
|
|
return {
|
|
encodings: [{}],
|
|
degradationPreference: "balanced",
|
|
headerExtensions: [],
|
|
codecs: [],
|
|
rtcp: { cname: "fake", reducedSize: false },
|
|
transactionId: "fake",
|
|
};
|
|
}
|
|
async setParameters(value: RTCRtpSendParameters): Promise<void> {
|
|
if (this.rejectWrites) throw new DOMException("unsupported", "NotSupportedError");
|
|
this.writes.push(structuredClone(value));
|
|
}
|
|
}
|
|
|
|
class FakeStream {
|
|
readonly tracks: FakeTrack[];
|
|
constructor(tracks: FakeTrack[]) { this.tracks = tracks; }
|
|
getTracks(): MediaStreamTrack[] { return this.tracks as unknown as MediaStreamTrack[]; }
|
|
getVideoTracks(): MediaStreamTrack[] { return this.getTracks(); }
|
|
}
|
|
|
|
class FakeVideo {
|
|
autoplay = true;
|
|
muted = false;
|
|
srcObject: MediaProvider | null = null;
|
|
}
|
|
|
|
class FakePeer {
|
|
localDescription: RTCSessionDescription | null = null;
|
|
remoteDescription: RTCSessionDescription | null = null;
|
|
connectionState: RTCPeerConnectionState = "new";
|
|
onicecandidate: ((this: RTCPeerConnection, ev: RTCPeerConnectionIceEvent) => unknown) | null = null;
|
|
ontrack: ((this: RTCPeerConnection, ev: RTCTrackEvent) => unknown) | null = null;
|
|
onconnectionstatechange: ((this: RTCPeerConnection, ev: Event) => unknown) | null = null;
|
|
readonly added: MediaStreamTrack[] = [];
|
|
readonly remote: RTCSessionDescriptionInit[] = [];
|
|
readonly ice: (RTCIceCandidateInit | null)[] = [];
|
|
readonly transceivers: string[] = [];
|
|
readonly senders: FakeSender[] = [];
|
|
closed = 0;
|
|
|
|
addTrack(track: MediaStreamTrack): RTCRtpSender {
|
|
this.added.push(track);
|
|
const sender = new FakeSender();
|
|
this.senders.push(sender);
|
|
return sender as unknown as RTCRtpSender;
|
|
}
|
|
addTransceiver(kind: string): RTCRtpTransceiver { this.transceivers.push(kind); return {} as RTCRtpTransceiver; }
|
|
async createOffer(): Promise<RTCSessionDescriptionInit> { return { type: "offer", sdp: "presenter-offer" }; }
|
|
async createAnswer(): Promise<RTCSessionDescriptionInit> { return { type: "answer", sdp: "viewer-answer" }; }
|
|
async setLocalDescription(value: RTCLocalSessionDescriptionInit): Promise<void> {
|
|
this.localDescription = value as RTCSessionDescription;
|
|
}
|
|
async setRemoteDescription(value: RTCSessionDescriptionInit): Promise<void> {
|
|
this.remote.push(value);
|
|
this.remoteDescription = value as RTCSessionDescription;
|
|
}
|
|
async addIceCandidate(value?: RTCIceCandidateInit | null): Promise<void> { this.ice.push(value ?? null); }
|
|
close(): void { this.closed += 1; this.connectionState = "closed"; }
|
|
connect(): void {
|
|
this.connectionState = "connected";
|
|
this.onconnectionstatechange?.call(this as unknown as RTCPeerConnection, new Event("connectionstatechange"));
|
|
}
|
|
fail(): void {
|
|
this.connectionState = "failed";
|
|
this.onconnectionstatechange?.call(this as unknown as RTCPeerConnection, new Event("connectionstatechange"));
|
|
}
|
|
receive(track: FakeTrack, stream: FakeStream): void {
|
|
this.ontrack?.call(this as unknown as RTCPeerConnection, {
|
|
track: track as unknown as MediaStreamTrack,
|
|
streams: [stream as unknown as MediaStream],
|
|
} as unknown as RTCTrackEvent);
|
|
}
|
|
}
|
|
|
|
class FakeTimers implements RemoteMediaTimers {
|
|
pending = new Map<number, () => void>();
|
|
next = 1;
|
|
setTimeout(callback: () => void): ReturnType<typeof setTimeout> {
|
|
const id = this.next++;
|
|
this.pending.set(id, callback);
|
|
return id as unknown as ReturnType<typeof setTimeout>;
|
|
}
|
|
clearTimeout(handle: ReturnType<typeof setTimeout>): void { this.pending.delete(handle as unknown as number); }
|
|
run(): void {
|
|
const first = this.pending.entries().next().value as [number, () => void] | undefined;
|
|
if (!first) throw new Error("no reconnect timer");
|
|
this.pending.delete(first[0]);
|
|
first[1]();
|
|
}
|
|
}
|
|
|
|
function access(role: "presenter" | "viewer", participantId: string, sessionId = "share-session"): ScreenShareAccessGrant {
|
|
return {
|
|
credential: { sessionId, participantId, role, grantToken: `private-${role}-grant` },
|
|
issuedAtMs: NOW - 1_000,
|
|
expiresAtMs: NOW + 60_000,
|
|
};
|
|
}
|
|
|
|
function grant(
|
|
type: "screen-share-create-grant" | "screen-share-join-grant" | "screen-share-resume-grant",
|
|
requestId: string,
|
|
role: "presenter" | "viewer",
|
|
participants: readonly ScreenShareParticipant[],
|
|
) {
|
|
return {
|
|
type,
|
|
protocolVersion: 1,
|
|
sequence: 1,
|
|
timestampMs: NOW,
|
|
requestId,
|
|
binding: BINDING,
|
|
grant: access(role, `${role}-opaque`),
|
|
nextClientSequence: 2,
|
|
participants,
|
|
...(type === "screen-share-resume-grant" ? { continuous: true } : {}),
|
|
};
|
|
}
|
|
|
|
function iceGrant(requestId: string) {
|
|
return {
|
|
type: "ice-config-grant",
|
|
protocolVersion: 1,
|
|
requestId,
|
|
issuedAtMs: NOW - 1_000,
|
|
expiresAtMs: NOW + 50_000,
|
|
iceServers: [{
|
|
urls: ["turns:relay.example.test:5349"],
|
|
username: "temporary-user-01",
|
|
credential: "temporary-password-01",
|
|
credentialType: "password",
|
|
}],
|
|
};
|
|
}
|
|
|
|
function iceResponse(input: RequestInfo | URL, body: Record<string, unknown>): Response | null {
|
|
return String(input).endsWith("/ice")
|
|
? Response.json(iceGrant(String(body.requestId)))
|
|
: null;
|
|
}
|
|
|
|
function sse(messages: readonly unknown[]): Response {
|
|
const bytes = new TextEncoder().encode(messages.map((message) => `data: ${JSON.stringify(message)}\n\n`).join(""));
|
|
return new Response(new ReadableStream<Uint8Array>({
|
|
start(controller) { controller.enqueue(bytes); /* held open like the real fetch stream */ },
|
|
}), { status: 200, headers: { "Content-Type": "text/event-stream" } });
|
|
}
|
|
|
|
function decision(optedIn = true): MediaAuthorizationDecision {
|
|
return {
|
|
authorized: true,
|
|
optedIn,
|
|
canView: optedIn,
|
|
reason: optedIn ? "ready" : "opt_in_required",
|
|
surface: {
|
|
...BINDING,
|
|
status: "presenting",
|
|
source: optedIn ? {
|
|
kind: "live-stream", locator: "opaque", privacy: "private", hasAudio: false, autoplay: false, muted: true,
|
|
} : null,
|
|
playback: { autoplay: false, muted: true },
|
|
},
|
|
};
|
|
}
|
|
|
|
async function settle(): Promise<void> {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
describe("screen share sender policy", () => {
|
|
it("caps a 4K sender at the explicit 720p/15 bandwidth contract", async () => {
|
|
const track = new FakeTrack();
|
|
const sender = new FakeSender();
|
|
const result = await applyScreenShareEncodingPolicy(
|
|
track as unknown as MediaStreamTrack,
|
|
sender as unknown as RTCRtpSender,
|
|
);
|
|
assert.deepEqual(result, { contentHintApplied: true, senderParametersApplied: true });
|
|
assert.equal(track.contentHint, "detail");
|
|
assert.equal(DEFAULT_SCREEN_SHARE_ENCODING_POLICY.maximumWidth, 1_280);
|
|
assert.equal(DEFAULT_SCREEN_SHARE_ENCODING_POLICY.maximumHeight, 720);
|
|
assert.deepEqual(sender.writes[0]?.encodings, [{
|
|
maxBitrate: 1_500_000,
|
|
maxFramerate: 15,
|
|
scaleResolutionDownBy: 3,
|
|
}]);
|
|
assert.equal(sender.writes[0]?.degradationPreference, "maintain-resolution");
|
|
});
|
|
|
|
it("falls back without throwing when a browser rejects sender parameters", async () => {
|
|
const track = new FakeTrack();
|
|
const rejected = await applyScreenShareEncodingPolicy(
|
|
track as unknown as MediaStreamTrack,
|
|
new FakeSender(true) as unknown as RTCRtpSender,
|
|
);
|
|
assert.deepEqual(rejected, { contentHintApplied: true, senderParametersApplied: false });
|
|
const unsupported = await applyScreenShareEncodingPolicy(
|
|
track as unknown as MediaStreamTrack,
|
|
{} as RTCRtpSender,
|
|
);
|
|
assert.deepEqual(unsupported, { contentHintApplied: true, senderParametersApplied: false });
|
|
});
|
|
});
|
|
|
|
describe("remote office media presenter", () => {
|
|
it("requires opt-in, publishes one caller track, and keeps credentials in authenticated POST bodies", async () => {
|
|
const presenterTrack = new FakeTrack();
|
|
const stream = new FakeStream([presenterTrack]);
|
|
const video = new FakeVideo();
|
|
const peers: FakePeer[] = [];
|
|
const calls: Array<{ url: string; init: RequestInit; body: Record<string, unknown> }> = [];
|
|
const viewerPeer = { participantId: "viewer-opaque", role: "viewer" as const };
|
|
const fetcher: typeof fetch = async (input, init = {}) => {
|
|
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
|
calls.push({ url: String(input), init, body });
|
|
const ice = iceResponse(input, body);
|
|
if (ice) return ice;
|
|
if (String(input).endsWith("/join")) {
|
|
return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", []), { status: 201 });
|
|
}
|
|
if (String(input).endsWith("/events")) {
|
|
return sse([grant("screen-share-resume-grant", String(body.requestId), "presenter", [viewerPeer])]);
|
|
}
|
|
return new Response(null, { status: 204 });
|
|
};
|
|
const media = createRemoteOfficeMedia({
|
|
role: "presenter", binding: BINDING, authenticatedFetch: fetcher, now: () => NOW,
|
|
peerConnectionFactory: () => { const peer = new FakePeer(); peers.push(peer); return peer as unknown as RTCPeerConnection; },
|
|
});
|
|
await assert.rejects(
|
|
media.startPresenter({ consent: { authorized: true, optedIn: false }, stream: stream as unknown as MediaStream, video: video as unknown as HTMLVideoElement }),
|
|
/explicit opt-in/,
|
|
);
|
|
await media.startPresenter({ consent: { authorized: true, optedIn: true }, stream: stream as unknown as MediaStream, video: video as unknown as HTMLVideoElement });
|
|
assert.equal(media.state().status, "live", "an empty hosted room is ready for its first viewer");
|
|
await settle();
|
|
assert.equal(peers.length, 1);
|
|
assert.equal(peers[0]?.added[0], presenterTrack as unknown as MediaStreamTrack);
|
|
assert.equal(peers[0]?.senders[0]?.writes[0]?.encodings[0]?.maxFramerate, 15);
|
|
assert.equal(video.autoplay, false);
|
|
assert.equal(video.muted, true);
|
|
const signal = calls.find((call) => call.url.endsWith("/signal"));
|
|
const joinIndex = calls.findIndex((call) => call.url.endsWith("/join"));
|
|
const iceIndex = calls.findIndex((call) => call.url.endsWith("/ice"));
|
|
assert.ok(joinIndex >= 0 && iceIndex > joinIndex, "signaling authorization precedes TURN exchange");
|
|
assert.deepEqual(calls[iceIndex]?.body, {
|
|
type: "ice-config-request",
|
|
protocolVersion: 1,
|
|
requestId: calls[iceIndex]?.body.requestId,
|
|
binding: BINDING,
|
|
credential: access("presenter", "presenter-opaque").credential,
|
|
});
|
|
assert.equal((signal?.body.signal as { kind?: string }).kind, "sdp");
|
|
assert.equal(signal?.init.credentials, "same-origin");
|
|
assert.equal(calls.some((call) => call.url.includes("private-presenter-grant")), false);
|
|
assert.equal(JSON.stringify(media.state()).includes("private-presenter-grant"), false);
|
|
await media.stop();
|
|
assert.equal(presenterTrack.stopped, 0, "caller-owned capture track survives stop");
|
|
assert.equal(presenterTrack.contentHint, "", "caller-owned track metadata is restored on stop");
|
|
assert.equal(peers[0]?.closed, 1);
|
|
});
|
|
|
|
it("applies the bounded sender policy independently to late-joining viewers", async () => {
|
|
const first = { participantId: "viewer-first", role: "viewer" as const };
|
|
const late = { participantId: "viewer-late", role: "viewer" as const };
|
|
const peers: FakePeer[] = [];
|
|
const fetcher: typeof fetch = async (input, init = {}) => {
|
|
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
|
const ice = iceResponse(input, body);
|
|
if (ice) return ice;
|
|
if (String(input).endsWith("/join")) {
|
|
return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", [first]), { status: 201 });
|
|
}
|
|
if (String(input).endsWith("/events")) {
|
|
return sse([
|
|
grant("screen-share-resume-grant", String(body.requestId), "presenter", [first]),
|
|
{
|
|
type: "screen-share-participants",
|
|
protocolVersion: 1,
|
|
sequence: 2,
|
|
timestampMs: NOW + 1,
|
|
sessionId: "share-session",
|
|
binding: BINDING,
|
|
participants: [first, late],
|
|
leaseExpiresAtMs: NOW + 60_000,
|
|
},
|
|
]);
|
|
}
|
|
return new Response(null, { status: 204 });
|
|
};
|
|
const track = new FakeTrack();
|
|
const media = createRemoteOfficeMedia({
|
|
role: "presenter",
|
|
binding: BINDING,
|
|
authenticatedFetch: fetcher,
|
|
now: () => NOW,
|
|
peerConnectionFactory: () => {
|
|
const peer = new FakePeer();
|
|
peers.push(peer);
|
|
return peer as unknown as RTCPeerConnection;
|
|
},
|
|
});
|
|
await media.startPresenter({
|
|
consent: { authorized: true, optedIn: true },
|
|
stream: new FakeStream([track]) as unknown as MediaStream,
|
|
video: new FakeVideo() as unknown as HTMLVideoElement,
|
|
});
|
|
await settle();
|
|
assert.equal(peers.length, 2);
|
|
for (const peer of peers) {
|
|
assert.equal(peer.added[0], track as unknown as MediaStreamTrack);
|
|
assert.equal(peer.senders[0]?.writes.length, 1);
|
|
assert.equal(peer.senders[0]?.writes[0]?.encodings[0]?.maxBitrate, 1_500_000);
|
|
assert.equal(peer.senders[0]?.writes[0]?.encodings[0]?.scaleResolutionDownBy, 3);
|
|
}
|
|
await media.stop();
|
|
assert.equal(track.stopped, 0);
|
|
assert.equal(track.contentHint, "");
|
|
});
|
|
|
|
it("tears down failed peers and rebuilds them from a resumed participant snapshot", async () => {
|
|
const timers = new FakeTimers();
|
|
const peers: FakePeer[] = [];
|
|
const iceTokens: string[] = [];
|
|
let eventCalls = 0;
|
|
const participant = { participantId: "viewer-opaque", role: "viewer" as const };
|
|
const fetcher: typeof fetch = async (input, init = {}) => {
|
|
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
|
const ice = iceResponse(input, body);
|
|
if (ice) {
|
|
iceTokens.push((body.credential as { grantToken: string }).grantToken);
|
|
return ice;
|
|
}
|
|
if (String(input).endsWith("/join")) return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", [participant]), { status: 201 });
|
|
if (String(input).endsWith("/events")) {
|
|
eventCalls += 1;
|
|
const resumed = grant("screen-share-resume-grant", String(body.requestId), "presenter", [participant]);
|
|
resumed.grant.credential.grantToken = `rotated-reconnect-${eventCalls}`;
|
|
return sse([resumed]);
|
|
}
|
|
return new Response(null, { status: 204 });
|
|
};
|
|
const media = createRemoteOfficeMedia({
|
|
role: "presenter", binding: BINDING, fetch: fetcher, timers, now: () => NOW,
|
|
peerConnectionFactory: () => { const peer = new FakePeer(); peers.push(peer); return peer as unknown as RTCPeerConnection; },
|
|
});
|
|
const track = new FakeTrack();
|
|
await media.startPresenter({ consent: { authorized: true, optedIn: true }, stream: new FakeStream([track]) as unknown as MediaStream, video: new FakeVideo() as unknown as HTMLVideoElement });
|
|
await settle();
|
|
assert.deepEqual(iceTokens, ["private-presenter-grant"], "connected peer keeps its active TURN allocation across token rotation");
|
|
peers[0]?.fail();
|
|
assert.equal(media.state().status, "reconnecting");
|
|
assert.equal(peers[0]?.closed, 1);
|
|
timers.run();
|
|
await settle();
|
|
assert.equal(peers.length, 2);
|
|
assert.equal(peers[1]?.added[0], track as unknown as MediaStreamTrack);
|
|
assert.deepEqual(iceTokens, ["private-presenter-grant", "rotated-reconnect-2"],
|
|
"rebuilt peer refreshes TURN under the current rotated grant");
|
|
await media.dispose();
|
|
assert.equal(track.stopped, 0);
|
|
});
|
|
|
|
it("does not resurrect a session when stop overtakes a slow join", async () => {
|
|
let resolveJoin: ((response: Response) => void) | null = null;
|
|
let joinRequestId = "";
|
|
const fetcher: typeof fetch = async (input, init = {}) => {
|
|
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
|
const ice = iceResponse(input, body);
|
|
if (ice) return ice;
|
|
if (String(input).endsWith("/join")) {
|
|
joinRequestId = String(body.requestId);
|
|
return new Promise<Response>((resolve) => { resolveJoin = resolve; });
|
|
}
|
|
return new Response(null, { status: 204 });
|
|
};
|
|
const track = new FakeTrack();
|
|
const media = createRemoteOfficeMedia({ role: "presenter", binding: BINDING, fetch: fetcher, now: () => NOW });
|
|
const starting = media.startPresenter({
|
|
consent: { authorized: true, optedIn: true },
|
|
stream: new FakeStream([track]) as unknown as MediaStream,
|
|
video: new FakeVideo() as unknown as HTMLVideoElement,
|
|
});
|
|
await media.stop();
|
|
assert.ok(resolveJoin);
|
|
(resolveJoin as (response: Response) => void)(Response.json(
|
|
grant("screen-share-create-grant", joinRequestId, "presenter", []), { status: 201 },
|
|
));
|
|
await assert.rejects(starting, /superseded/);
|
|
assert.equal(media.state().status, "stopped");
|
|
assert.equal(media.state().sessionId, null);
|
|
assert.equal(track.stopped, 0);
|
|
});
|
|
|
|
it("renews its lease before expiry and uses only the rotated in-memory grant", async () => {
|
|
const timers = new FakeTimers();
|
|
const eventTokens: string[] = [];
|
|
const iceTokens: string[] = [];
|
|
let eventCalls = 0;
|
|
const fetcher: typeof fetch = async (input, init = {}) => {
|
|
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
|
const ice = iceResponse(input, body);
|
|
if (ice) {
|
|
iceTokens.push((body.credential as { grantToken: string }).grantToken);
|
|
return ice;
|
|
}
|
|
if (String(input).endsWith("/join")) {
|
|
return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", []), { status: 201 });
|
|
}
|
|
if (String(input).endsWith("/events")) {
|
|
eventCalls += 1;
|
|
const credential = body.credential as { grantToken: string };
|
|
eventTokens.push(credential.grantToken);
|
|
const resumed = grant("screen-share-resume-grant", String(body.requestId), "presenter", []);
|
|
resumed.grant.credential.grantToken = `rotated-${eventCalls}`;
|
|
return sse([resumed]);
|
|
}
|
|
return new Response(null, { status: 204 });
|
|
};
|
|
const media = createRemoteOfficeMedia({
|
|
role: "presenter", binding: BINDING, fetch: fetcher, timers, now: () => NOW,
|
|
peerConnectionFactory: () => new FakePeer() as unknown as RTCPeerConnection,
|
|
});
|
|
await media.startPresenter({
|
|
consent: { authorized: true, optedIn: true },
|
|
stream: new FakeStream([new FakeTrack()]) as unknown as MediaStream,
|
|
video: new FakeVideo() as unknown as HTMLVideoElement,
|
|
});
|
|
await settle();
|
|
assert.deepEqual(eventTokens, ["private-presenter-grant"]);
|
|
assert.deepEqual(iceTokens, ["private-presenter-grant"], "initial resume reuses active TURN configuration");
|
|
assert.equal(timers.pending.size, 1, "one pre-expiry renewal is scheduled");
|
|
timers.run();
|
|
await settle();
|
|
assert.deepEqual(eventTokens, ["private-presenter-grant", "rotated-1"]);
|
|
assert.deepEqual(iceTokens, ["private-presenter-grant"], "lease renewal does not issue unused TURN credentials");
|
|
assert.equal(JSON.stringify(media.state()).includes("rotated-2"), false);
|
|
await media.dispose();
|
|
});
|
|
|
|
it("creates no peer before grant-bound ICE succeeds and cleans up a failed hosted join", async () => {
|
|
let peerCount = 0;
|
|
const urls: string[] = [];
|
|
const fetcher: typeof fetch = async (input, init = {}) => {
|
|
const url = String(input);
|
|
urls.push(url);
|
|
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
|
if (url.endsWith("/join")) {
|
|
return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", [
|
|
{ participantId: "viewer-opaque", role: "viewer" },
|
|
]), { status: 201 });
|
|
}
|
|
if (url.endsWith("/ice")) return Response.json({
|
|
type: "ice-config-unavailable", protocolVersion: 1,
|
|
requestId: body.requestId, retryAfterMs: 10_000,
|
|
}, { status: 503 });
|
|
return new Response(null, { status: 204 });
|
|
};
|
|
const track = new FakeTrack();
|
|
const media = createRemoteOfficeMedia({
|
|
role: "presenter", binding: BINDING, fetch: fetcher, now: () => NOW,
|
|
peerConnectionFactory: () => { peerCount += 1; return new FakePeer() as unknown as RTCPeerConnection; },
|
|
});
|
|
await assert.rejects(media.startPresenter({
|
|
consent: { authorized: true, optedIn: true },
|
|
stream: new FakeStream([track]) as unknown as MediaStream,
|
|
video: new FakeVideo() as unknown as HTMLVideoElement,
|
|
}), /temporarily unavailable/);
|
|
assert.equal(peerCount, 0);
|
|
assert.deepEqual(urls.map((url) => url.split("/").at(-1)), ["join", "ice", "leave"]);
|
|
assert.equal(track.stopped, 0);
|
|
assert.equal(media.state().status, "idle");
|
|
});
|
|
});
|
|
|
|
describe("remote office media viewer", () => {
|
|
it("accepts a ready decision only, answers offers, and stops receiver tracks on revoke", async () => {
|
|
const presenterPeer = { participantId: "presenter-opaque", role: "presenter" as const };
|
|
const peers: FakePeer[] = [];
|
|
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
|
|
const fetcher: typeof fetch = async (input, init = {}) => {
|
|
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
|
calls.push({ url: String(input), body });
|
|
const iceConfig = iceResponse(input, body);
|
|
if (iceConfig) return iceConfig;
|
|
if (String(input).endsWith("/join")) return Response.json(grant("screen-share-join-grant", String(body.requestId), "viewer", [presenterPeer]), { status: 201 });
|
|
if (String(input).endsWith("/events")) {
|
|
const resume = grant("screen-share-resume-grant", String(body.requestId), "viewer", [presenterPeer]);
|
|
const ice = {
|
|
type: "screen-share-signal-relay", protocolVersion: 1, sequence: 2, timestampMs: NOW + 1,
|
|
sessionId: "share-session", binding: BINDING, fromParticipantId: "presenter-opaque",
|
|
targetParticipantId: "viewer-opaque",
|
|
signal: { kind: "ice-candidate", candidate: "candidate:remote", sdpMid: "0", sdpMLineIndex: 0, usernameFragment: null },
|
|
};
|
|
const relay = {
|
|
type: "screen-share-signal-relay", protocolVersion: 1, sequence: 3, timestampMs: NOW + 2,
|
|
sessionId: "share-session", binding: BINDING, fromParticipantId: "presenter-opaque",
|
|
targetParticipantId: "viewer-opaque", signal: { kind: "sdp", descriptionType: "offer", sdp: "presenter-offer" },
|
|
};
|
|
return sse([resume, ice, relay]);
|
|
}
|
|
return new Response(null, { status: 204 });
|
|
};
|
|
const media = createRemoteOfficeMedia({
|
|
role: "viewer", binding: BINDING, authenticatedFetch: fetcher, now: () => NOW,
|
|
peerConnectionFactory: () => { const peer = new FakePeer(); peers.push(peer); return peer as unknown as RTCPeerConnection; },
|
|
});
|
|
const video = new FakeVideo();
|
|
await assert.rejects(media.startViewer({ decision: decision(false), video: video as unknown as HTMLVideoElement }), /ready live-stream/);
|
|
await media.startViewer({ viewerOptIn: true, video: video as unknown as HTMLVideoElement });
|
|
await settle();
|
|
assert.equal(peers[0]?.transceivers[0], "video");
|
|
assert.deepEqual(peers[0]?.remote[0], { type: "offer", sdp: "presenter-offer" });
|
|
assert.equal(peers[0]?.ice[0]?.candidate, "candidate:remote", "early ICE waits for remote SDP");
|
|
assert.ok(calls.some((call) => call.url.endsWith("/signal") &&
|
|
(call.body.signal as { descriptionType?: string }).descriptionType === "answer"));
|
|
const remoteTrack = new FakeTrack();
|
|
const remoteStream = new FakeStream([remoteTrack]);
|
|
peers[0]?.receive(remoteTrack, remoteStream);
|
|
assert.equal(video.srcObject, remoteStream as unknown as MediaStream);
|
|
await media.revoke();
|
|
assert.equal(remoteTrack.stopped, 1);
|
|
assert.equal(video.srcObject, null);
|
|
assert.equal(media.state().status, "revoked");
|
|
});
|
|
|
|
it("forgets its grant when the server has already stopped the session", async () => {
|
|
const presenterPeer = { participantId: "presenter-opaque", role: "presenter" as const };
|
|
let emitEvent: (value: Uint8Array) => void = () => { throw new Error("event stream is not open"); };
|
|
let leaveCalls = 0;
|
|
const fetcher: typeof fetch = async (input, init = {}) => {
|
|
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
|
const ice = iceResponse(input, body);
|
|
if (ice) return ice;
|
|
if (String(input).endsWith("/join")) {
|
|
return Response.json(grant("screen-share-join-grant", String(body.requestId), "viewer", [presenterPeer]), { status: 201 });
|
|
}
|
|
if (String(input).endsWith("/events")) {
|
|
const stream = new ReadableStream<Uint8Array>({ start(controller) { emitEvent = (value) => controller.enqueue(value); } });
|
|
const resume = grant("screen-share-resume-grant", String(body.requestId), "viewer", [presenterPeer]);
|
|
emitEvent(new TextEncoder().encode(`data: ${JSON.stringify(resume)}\n\n`));
|
|
return new Response(stream, { status: 200, headers: { "Content-Type": "text/event-stream" } });
|
|
}
|
|
if (String(input).endsWith("/leave")) leaveCalls += 1;
|
|
return new Response(null, { status: 204 });
|
|
};
|
|
const media = createRemoteOfficeMedia({
|
|
role: "viewer", binding: BINDING, authenticatedFetch: fetcher, now: () => NOW,
|
|
peerConnectionFactory: () => new FakePeer() as unknown as RTCPeerConnection,
|
|
});
|
|
await media.startViewer({ viewerOptIn: true, video: new FakeVideo() as unknown as HTMLVideoElement });
|
|
await settle();
|
|
emitEvent(new TextEncoder().encode(`data: ${JSON.stringify({
|
|
type: "screen-share-stopped", protocolVersion: 1, sequence: 2, timestampMs: NOW + 1,
|
|
sessionId: "share-session", binding: BINDING, reason: "presenter-stopped",
|
|
})}\n\n`));
|
|
await settle();
|
|
assert.equal(media.state().status, "stopped");
|
|
assert.equal(media.state().sessionId, null);
|
|
await media.dispose();
|
|
assert.equal(leaveCalls, 0, "cleanup does not reuse a server-invalidated capability");
|
|
});
|
|
});
|