feat: add private office media signaling
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
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;
|
||||
stop(): void { this.stopped += 1; this.readyState = "ended"; }
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
closed = 0;
|
||||
|
||||
addTrack(track: MediaStreamTrack): RTCRtpSender { this.added.push(track); return {} 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 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("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 });
|
||||
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 });
|
||||
await settle();
|
||||
assert.equal(peers.length, 1);
|
||||
assert.equal(peers[0]?.added[0], presenterTrack as unknown as MediaStreamTrack);
|
||||
assert.equal(video.autoplay, false);
|
||||
assert.equal(video.muted, true);
|
||||
const signal = calls.find((call) => call.url.endsWith("/signal"));
|
||||
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(peers[0]?.closed, 1);
|
||||
});
|
||||
|
||||
it("tears down failed peers and rebuilds them from a resumed participant snapshot", async () => {
|
||||
const timers = new FakeTimers();
|
||||
const peers: FakePeer[] = [];
|
||||
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>;
|
||||
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")) return sse([grant("screen-share-resume-grant", String(body.requestId), "presenter", [participant])]);
|
||||
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 });
|
||||
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);
|
||||
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>;
|
||||
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[] = [];
|
||||
let eventCalls = 0;
|
||||
const fetcher: typeof fetch = async (input, init = {}) => {
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
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.equal(timers.pending.size, 1, "one pre-expiry renewal is scheduled");
|
||||
timers.run();
|
||||
await settle();
|
||||
assert.deepEqual(eventTokens, ["private-presenter-grant", "rotated-1"]);
|
||||
assert.equal(JSON.stringify(media.state()).includes("rotated-2"), false);
|
||||
await media.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
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({ decision: decision(), 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
advanceScreenShareStreamCursor,
|
||||
isScreenShareGrantActive,
|
||||
parseScreenShareClientMessage,
|
||||
parseScreenShareServerMessage,
|
||||
} from "../media/signalingValidation.ts";
|
||||
import type {
|
||||
ScreenShareAccessGrant,
|
||||
ScreenShareBinding,
|
||||
ScreenShareSignalRelay,
|
||||
} from "../media/signalingTypes.ts";
|
||||
|
||||
const BINDING: ScreenShareBinding = {
|
||||
officeId: "lumbridge-hq",
|
||||
levelId: "level-1",
|
||||
roomId: "lobby",
|
||||
screenId: "lobby-monitor",
|
||||
};
|
||||
const CREDENTIAL = {
|
||||
sessionId: "opaque-session",
|
||||
participantId: "opaque-participant",
|
||||
role: "viewer" as const,
|
||||
grantToken: "opaque-secret-token-at-least-sixteen",
|
||||
};
|
||||
|
||||
describe("office screen signaling protocol", () => {
|
||||
it("requires literal viewer opt-in and exact JSON keys", () => {
|
||||
const join = {
|
||||
type: "screen-share-join-request",
|
||||
protocolVersion: 1,
|
||||
sequence: 0,
|
||||
timestampMs: 1_000,
|
||||
requestId: "join-1",
|
||||
binding: BINDING,
|
||||
role: "viewer",
|
||||
viewerOptIn: true,
|
||||
};
|
||||
assert.equal(parseScreenShareClientMessage(join).ok, true);
|
||||
assert.equal(parseScreenShareClientMessage({ ...join, viewerOptIn: false }).ok, false);
|
||||
assert.equal(parseScreenShareClientMessage({ ...join, subject: "identity-leak" }).ok, false);
|
||||
assert.equal(parseScreenShareClientMessage({ ...join, locator: "https://media.invalid" }).ok, false);
|
||||
});
|
||||
|
||||
it("bounds SDP, ICE and disallows self-signaling", () => {
|
||||
const request = {
|
||||
type: "screen-share-signal-request",
|
||||
protocolVersion: 1,
|
||||
sequence: 1,
|
||||
timestampMs: 1_001,
|
||||
binding: BINDING,
|
||||
credential: CREDENTIAL,
|
||||
targetParticipantId: "opaque-presenter",
|
||||
signal: { kind: "sdp", descriptionType: "answer", sdp: "v=0" },
|
||||
};
|
||||
assert.equal(parseScreenShareClientMessage(request).ok, true);
|
||||
assert.equal(parseScreenShareClientMessage({ ...request, targetParticipantId: CREDENTIAL.participantId }).ok, false);
|
||||
assert.equal(parseScreenShareClientMessage({
|
||||
...request,
|
||||
signal: { ...request.signal, sdp: "x".repeat(24 * 1024 + 1) },
|
||||
}).ok, false);
|
||||
});
|
||||
|
||||
it("accepts server-issued opaque peer snapshots but no credentials in fan-out", () => {
|
||||
const message = {
|
||||
type: "screen-share-participants",
|
||||
protocolVersion: 1,
|
||||
sequence: 3,
|
||||
timestampMs: 1_003,
|
||||
sessionId: CREDENTIAL.sessionId,
|
||||
binding: BINDING,
|
||||
participants: [{ participantId: "opaque-presenter", role: "presenter" }],
|
||||
leaseExpiresAtMs: 2_000,
|
||||
};
|
||||
assert.equal(parseScreenShareServerMessage(message).ok, true);
|
||||
assert.equal(parseScreenShareServerMessage({
|
||||
...message,
|
||||
participants: [{ ...message.participants[0], grantToken: "leak" }],
|
||||
}).ok, false);
|
||||
assert.equal(parseScreenShareServerMessage({
|
||||
...message,
|
||||
participants: Array.from({ length: 8 }, (_, index) => ({ participantId: `viewer-${index}`, role: "viewer" })),
|
||||
}).ok, false);
|
||||
});
|
||||
|
||||
it("checks grant time windows without exposing bearer material in state", () => {
|
||||
const grant: ScreenShareAccessGrant = { credential: CREDENTIAL, issuedAtMs: 1_000, expiresAtMs: 2_000 };
|
||||
assert.equal(isScreenShareGrantActive(grant, 1_000), true);
|
||||
assert.equal(isScreenShareGrantActive(grant, 1_999), true);
|
||||
assert.equal(isScreenShareGrantActive(grant, 2_000), false);
|
||||
assert.equal(isScreenShareGrantActive(grant, 999), false);
|
||||
});
|
||||
|
||||
it("advances only same-session monotonic peer messages", () => {
|
||||
const relay: ScreenShareSignalRelay = {
|
||||
type: "screen-share-signal-relay",
|
||||
protocolVersion: 1,
|
||||
sequence: 5,
|
||||
timestampMs: 1_005,
|
||||
sessionId: CREDENTIAL.sessionId,
|
||||
binding: BINDING,
|
||||
fromParticipantId: "opaque-presenter",
|
||||
targetParticipantId: CREDENTIAL.participantId,
|
||||
signal: { kind: "ice-complete" },
|
||||
};
|
||||
const first = advanceScreenShareStreamCursor(null, relay);
|
||||
assert.equal(first.ok, true);
|
||||
if (!first.ok) return;
|
||||
assert.equal(advanceScreenShareStreamCursor(first.value, relay).ok, false);
|
||||
assert.equal(advanceScreenShareStreamCursor(first.value, { ...relay, sequence: 6, sessionId: "foreign" }).ok, false);
|
||||
assert.equal(advanceScreenShareStreamCursor(first.value, { ...relay, sequence: 6, timestampMs: 1_004 }).ok, false);
|
||||
assert.equal(advanceScreenShareStreamCursor(first.value, { ...relay, sequence: 6, timestampMs: 1_006 }).ok, true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user