// room-share.test.mjs — the pure parts of room.js's chat and screen // share, gated without a browser. // // node --test test/browser/room-share.test.mjs // // Same trick as negotiation.test.mjs: room.js is an IIFE that wants a // document, so it cannot be imported, but a pure function inside it can // be lifted out by name and evaluated here. What is under test is the // shipped source and not a copy of it, so renaming or reshaping one of // these functions fails loudly rather than passing against a stale // duplicate. // // Four judgements are gated here, and they are the four that decide // whether §5.4 and §6 work at all: // // classifyTrack a peer's screen told from its camera // shareKey the tile key a screen gets, which must never // collide with a peer id // shareCaption the words under a screen, which the browser gate // rebuilds from the roster and compares // frameKey which of a peer's two video tiles a stats report // belongs to // broadcastBody the wire shape of an untargeted message — the // contract is what is *not* in it // broadcastVerdict every status the wire contract names, // accepted the strict boolean chat.js echoes on, and // recoveryDeadline the floor under a 404's diagnosis import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)); const src = readFileSync(roomJS, 'utf8'); // lift returns the source of one `function name(...) {...}` by matching // braces from its declaration. function lift(source, name) { let start = source.indexOf('function ' + name + '('); assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing'); if (source.slice(start - 6, start) === 'async ') start -= 6; let depth = 0; for (let i = source.indexOf('{', start); i < source.length; i++) { if (source[i] === '{') depth += 1; else if (source[i] === '}') { depth -= 1; if (depth === 0) return source.slice(start, i + 1); } } assert.fail('unbalanced braces reading ' + name + ' out of room.js'); } // readConst reads a top-level string constant out of the shipped source, // so a test cannot go on asserting a value someone has since changed. function readConst(source, name) { const m = new RegExp("const " + name + " = '([^']*)'").exec(source); assert.notEqual(m, null, name + ' is not a string constant in room.js any more'); return m[1]; } // readNumber does the same for a number, so no timing constant is // retyped here either. function readNumber(source, name) { const m = new RegExp('const ' + name + ' = (\\d+);').exec(source); assert.notEqual(m, null, name + ' is not a number constant in room.js any more'); return Number(m[1]); } const SHARE_SUFFIX = readConst(src, 'SHARE_SUFFIX'); const BROADCAST = readConst(src, 'BROADCAST'); const MIN_RECOVERY = readNumber(src, 'MIN_RECOVERY'); const build = (name, prelude) => // eslint-disable-next-line no-new-func new Function((prelude || '') + lift(src, name) + '; return ' + name + ';')(); const suffix = 'const SHARE_SUFFIX = ' + JSON.stringify(SHARE_SUFFIX) + ';'; const shareKey = build('shareKey', suffix); const shareCaption = build('shareCaption'); const classifyTrack = build('classifyTrack'); const frameKey = build('frameKey', suffix + lift(src, 'shareKey')); const broadcastBody = build('broadcastBody'); const broadcastVerdict = build('broadcastVerdict'); const accepted = build('accepted'); const recoveryDeadline = build('recoveryDeadline', 'const MIN_RECOVERY = ' + readNumber(src, 'MIN_RECOVERY') + ';'); // --------------------------------------------------------------- // The two sentinels // --------------------------------------------------------------- test('the share suffix and the broadcast key are what the contract names', () => { // Both are pinned rather than retyped: everything below is driven by // the values read out of the file, so changing one is a deliberate act // with a failing test in front of it. assert.equal(SHARE_SUFFIX, '#share'); assert.equal(BROADCAST, '*'); }); test('neither sentinel could ever be mistaken for a peer id', () => { const PEER_ID = /^[0-9a-f]{32}$/; const peer = 'a'.repeat(32); assert.equal(PEER_ID.test(peer), true); assert.equal(PEER_ID.test(shareKey(peer)), false); assert.equal(PEER_ID.test(BROADCAST), false); }); // --------------------------------------------------------------- // shareKey // --------------------------------------------------------------- test('a peer holds two tiles, and they are not the same key', () => { const peer = '0123456789abcdef0123456789abcdef'; assert.equal(shareKey(peer), peer + '#share'); assert.notEqual(shareKey(peer), peer); }); test('two peers sharing at once keep their screens apart', () => { const a = 'a'.repeat(32); const b = 'b'.repeat(32); assert.notEqual(shareKey(a), shareKey(b)); }); // --------------------------------------------------------------- // shareCaption // --------------------------------------------------------------- // // The browser gate builds this string for itself, out of the roster's // name, and compares it with the caption on the tile. That makes the // words a claim two files agree on — and this is the file that can fail // in fifty milliseconds when someone changes them. test('a screen says whose it is, with the typographic apostrophe', () => { assert.equal(shareCaption('Bea'), 'Bea’s screen'); // Not the ASCII one. TERMINAL.accept uses U+2019 and so does this; // a stray "'" here would be a second convention in one file. assert.equal(shareCaption('Bea').includes("'"), false); // The name goes in whole: nothing trims it, shortens it or changes // its case, because it is the roster's word and not this file's. assert.equal(shareCaption('de Vries'), 'de Vries’s screen'); }); // --------------------------------------------------------------- // classifyTrack (§5.4, receiving) // --------------------------------------------------------------- // A connection as the classifier sees one, an arriving track, and this // tab. Written out rather than defaulted, because every field of the // three is a signal and a silent default would hide which one decided. const peer = (o) => Object.assign( { cameraMid: '', cameraStream: '', peerStream: '', sharing: false }, o || {}); const track = (o) => Object.assign( { kind: 'video', streamId: '', mid: '', direction: '' }, o || {}); const me = (hasVideo) => ({ hasVideo: !!hasVideo }); const nothing = peer(); const known = peer({ cameraMid: '0', cameraStream: 'cam', peerStream: 'cam' }); test('audio is never a screen, whatever else is known', () => { assert.equal(classifyTrack(nothing, track({ kind: 'audio', streamId: 'cam', mid: '1' }), me(true)), 'audio'); assert.equal(classifyTrack(known, track({ kind: 'audio', streamId: 'other', mid: '9' }), me(true)), 'audio'); }); test('the first video track a peer sends is the camera', () => { assert.equal(classifyTrack(nothing, track({ streamId: 'cam', mid: '0' }), me(false)), 'camera'); // Even with nothing to compare at all: there is no second of // something that has not arrived yet. assert.equal(classifyTrack(nothing, track(), me(false)), 'camera'); }); test('a second video track on another transceiver is the screen', () => { assert.equal(classifyTrack(known, track({ streamId: 'screen', mid: '2' }), me(false)), 'share'); }); test('the camera track again is still the camera, not a screen', () => { // A replaced camera track arrives on the same mid. Reading that as a // screen would give the peer a second tile it never shared. assert.equal(classifyTrack(known, track({ streamId: 'cam', mid: '0' }), me(false)), 'camera'); // The mid is what decides when there is one: a camera whose stream id // changed under a renegotiation is still the camera. assert.equal(classifyTrack(known, track({ streamId: 'renamed', mid: '0' }), me(false)), 'camera'); }); test('with no mid to compare, the stream id decides', () => { const byStream = peer({ cameraStream: 'cam' }); assert.equal(classifyTrack(byStream, track({ streamId: 'cam' }), me(false)), 'camera'); assert.equal(classifyTrack(byStream, track({ streamId: 'screen' }), me(false)), 'share'); // A known mid but none on this track falls through to the stream id. assert.equal(classifyTrack(known, track({ streamId: 'cam' }), me(false)), 'camera'); assert.equal(classifyTrack(known, track({ streamId: 'screen' }), me(false)), 'share'); }); test('a second video track with nothing to compare is the screen', () => { // A sender that named no stream and a browser that reports no mid: // the camera is already known, so this is the screen. assert.equal(classifyTrack(known, track(), me(false)), 'share'); }); // The blind spot the review found, and the three signals that close it. // startLocal has an audio-only rung — a camera in use, or gone — and a // peer on it has no camera m-line at all, so its *screen* is the first // video track its peers ever see. test('a recvonly video track is the screen when this tab has a camera of its own', () => { // This tab builds a video m-line for every connection, sendrecv from // its own camera. A video track arriving recvonly is therefore on an // m-line the far side added, and the only one it adds is the screen — // even as the first video track that peer has ever sent. assert.equal(classifyTrack(nothing, track({ streamId: 'screen', mid: '2', direction: 'recvonly' }), me(true)), 'share'); }); test('recvonly says nothing when this tab has no camera either', () => { // With no local video this tab offers recvonly itself, so the peer's // camera arrives recvonly too and the direction cannot tell them // apart. The rule must not fire, or every camera becomes a screen. assert.equal(classifyTrack(nothing, track({ streamId: 'cam', mid: '1', direction: 'recvonly' }), me(false)), 'camera'); }); test('a sendrecv camera is a camera even while this tab has video', () => { assert.equal(classifyTrack(nothing, track({ streamId: 'cam', mid: '1', direction: 'sendrecv' }), me(true)), 'camera'); }); test('a video track in a stream the peer\u2019s audio never used is the screen', () => { // The audio-only sharer, seen by a peer that also has no camera: no // camera has ever arrived, so there is no cameraMid and no // cameraStream — but its microphone came in a stream of its own, and a // sender puts its camera in that same stream and its screen in // another. const audioOnly = peer({ peerStream: 'theirs' }); assert.equal(classifyTrack(audioOnly, track({ streamId: 'screen', mid: '2' }), me(false)), 'share'); // And the camera, when there is one, arrives in that same stream. assert.equal(classifyTrack(audioOnly, track({ streamId: 'theirs', mid: '1' }), me(false)), 'camera'); }); test('the share message breaks the tie, and only the tie', () => { // Nothing else can speak: no camera known, no stream on the track, no // direction, nothing from this peer before. Then the `share {on:true}` // that always precedes the media is the only evidence there is. assert.equal(classifyTrack(peer({ sharing: true }), track(), me(false)), 'share'); assert.equal(classifyTrack(peer({ sharing: false }), track(), me(false)), 'camera'); // But it never overrides a signal that can speak. A peer that clicked // share before its own first negotiation finished sends its camera and // its screen in one description, camera first — and the camera is in // the stream its microphone was in. const sharingPeer = peer({ sharing: true, peerStream: 'theirs' }); assert.equal(classifyTrack(sharingPeer, track({ streamId: 'theirs', mid: '1' }), me(false)), 'camera'); // Nor does it override the camera's own mid. assert.equal(classifyTrack(peer({ sharing: true, cameraMid: '1' }), track({ mid: '1' }), me(false)), 'camera'); }); // --------------------------------------------------------------- // frameKey (the getStats fallback, two video tiles) // --------------------------------------------------------------- const sharingEntry = { cameraMid: '1', shareMid: '2', share: {}, sharing: true }; test('a peer sharing a screen counts its two video streams apart', () => { assert.equal(frameKey('p1', sharingEntry, '1'), 'p1'); assert.equal(frameKey('p1', sharingEntry, '2'), 'p1#share'); }); test('a report with no mid on a peer with two tiles is left out, not guessed', () => { // Crediting it to either tile would be inventing a number; the // requestVideoFrameCallback path, which every current browser takes, // never needs this. assert.equal(frameKey('p1', sharingEntry, ''), ''); assert.equal(frameKey('p1', sharingEntry, '9'), ''); }); test('a peer with one video tile is never ambiguous', () => { const plain = { cameraMid: '1', shareMid: '', share: null, sharing: false }; assert.equal(frameKey('p1', plain, ''), 'p1'); assert.equal(frameKey('p1', plain, '1'), 'p1'); // A mid this connection has never seen, and only one tile to put it // on: it is the camera's. assert.equal(frameKey('p1', plain, '7'), 'p1'); }); test('a screen announced but not yet arriving does not take the camera\u2019s count', () => { // `share {on:true}` has drawn the tile and the track is still a // renegotiation away. Writing the camera's frames under a mid that has // not been recorded yet would move them onto the empty tile. const announced = { cameraMid: '1', shareMid: '', share: null, sharing: true }; assert.equal(frameKey('p1', announced, '1'), 'p1'); assert.equal(frameKey('p1', announced, ''), ''); }); // --------------------------------------------------------------- // recoveryDeadline (the 404's floor) // --------------------------------------------------------------- test('a diagnosis with budget left keeps the message\u2019s own deadline', () => { assert.equal(recoveryDeadline(10000, 1000), 10000); }); test('a diagnosis with no budget left gets a floor rather than nothing', () => { // The POST that produced the 404 can spend the whole thirty seconds. // A recovery handed a deadline in the past probes nothing, decides // nothing, and leaves the tab looking live with no participation // behind it. assert.equal(recoveryDeadline(1000, 5000), 5000 + MIN_RECOVERY); assert.equal(recoveryDeadline(5000, 5000), 5000 + MIN_RECOVERY); }); // --------------------------------------------------------------- // broadcastBody (§4.3's fan-out wire) // --------------------------------------------------------------- test('a broadcast body carries the peer, the type and the payload — and nothing else', () => { const body = broadcastBody('a'.repeat(32), 'chat', { text: 'hi' }); assert.deepEqual(Object.keys(body).sort(), ['payload', 'peer', 'type']); assert.equal(body.peer, 'a'.repeat(32)); assert.equal(body.type, 'chat'); assert.deepEqual(body.payload, { text: 'hi' }); }); test('no to, no seq and no gen reach the wire', () => { // The server answers 400 to any of the three on a chat or a share, so // this is the difference between a working panel and a bad request. const sent = JSON.parse(JSON.stringify(broadcastBody('b'.repeat(32), 'share', { on: true }))); assert.equal('to' in sent, false); assert.equal('seq' in sent, false); assert.equal('gen' in sent, false); assert.deepEqual(sent, { peer: 'b'.repeat(32), type: 'share', payload: { on: true } }); }); // --------------------------------------------------------------- // broadcastVerdict and accepted (the lane's status table) // --------------------------------------------------------------- const answer = (status, extra) => Object.assign({ status, data: {}, signedOut: false, retryAfter: null }, extra); test('a 200 is the only answer that sends', () => { assert.equal(broadcastVerdict(answer(200)), 'sent'); assert.equal(accepted('sent'), true); }); test('only a 200 answers the sender true', () => { // chat.js echoes a line into the log on exactly `true`, so every other // ending has to be false: a message nobody received must not appear on // the screen of the person who wrote it. for (const status of [0, 400, 404, 409, 413, 429, 500, 503]) { assert.equal(accepted(broadcastVerdict(answer(status))), false, 'status ' + status); } assert.equal(accepted(broadcastVerdict(answer(200, { signedOut: true }))), false); }); test('400, 413 and 409 are dropped and never retried', () => { // A bug in this file, a message the server will never take, and a // conflict that belongs to the targeted lane. for (const status of [400, 409, 413]) { assert.equal(broadcastVerdict(answer(status)), 'drop', 'status ' + status); } }); test('a 429 waits and a 404 goes to the probe', () => { assert.equal(broadcastVerdict(answer(429, { retryAfter: '1' })), 'wait'); assert.equal(broadcastVerdict(answer(404, { data: { error: 'not-found', boot: 'b1' } })), 'recover'); }); test('a 5xx, a network failure and an unnamed status are retried', () => { for (const status of [0, 418, 500, 502, 503]) { assert.equal(broadcastVerdict(answer(status)), 'retry', 'status ' + status); } }); test('signed out is read before the status, because it can be true of a 200', () => { // An expired session answers 303 to /signin, fetch follows it, and the // sign-in page comes back 200 — which read as a status alone is // indistinguishable from a message that was delivered. assert.equal(broadcastVerdict(answer(200, { signedOut: true })), 'signedout'); assert.equal(broadcastVerdict(answer(401, { signedOut: true })), 'signedout'); assert.equal(accepted('signedout'), false); }); // --------------------------------------------------------------- // adoptTrack: the receiving half of the rollback damage // --------------------------------------------------------------- // // Chromium rebuilds every stream on a connection when it rolls back an // offer that added an m-line, receive streams included, so the far // side's camera arrives again as a *new* track — sometimes with no // stream of its own, sometimes inside the very stream that still holds // the old, ended one. A media element renders the first track of its // kind, so an ended track left in place is a face that never moves // again on a connection that is otherwise perfect. The gate's // `laterolls` is where that was measured. const adoptTrack = build('adoptTrack'); const shownTrack = build('shownTrack'); function mediaStream(tracks) { const held = tracks.slice(); return { getTracks: () => held.slice(), getVideoTracks: () => held.filter((t) => t.kind === 'video'), addTrack: (t) => held.push(t), removeTrack: (t) => { const at = held.indexOf(t); if (at !== -1) held.splice(at, 1); }, }; } // A track as room.js handles one: the share path listens for its // ending, so the fake has to be able to be listened to. const videoTrack = (id, readyState) => ({ id, kind: 'video', readyState: readyState || 'live', listeners: {}, addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); }, end() { this.readyState = 'ended'; for (const fn of this.listeners.ended || []) fn(); }, }); test('a rebuilt camera replaces the ended one it arrives beside', () => { const dead = videoTrack('v1', 'ended'); const live = videoTrack('v2'); const stream = mediaStream([dead, { id: 'a1', kind: 'audio', readyState: 'live' }]); adoptTrack(stream, live); assert.deepEqual(stream.getVideoTracks(), [live], 'the element would have gone on rendering the ended track'); assert.equal(stream.getTracks().length, 2, 'the audio track was not collateral'); assert.equal(shownTrack(stream), live); }); test('a replacement supersedes a live predecessor, not only an ended one', () => { // The rule this function used to have, and the defect under it // (Codex S4-9). A receiver can be superseded while its track is still // `live`: it simply never carries another frame. Pruning only ended // tracks left the stream holding [old, new], and an element renders // the first track of its kind — the old one, for the rest of the // call. Which track is authoritative is settled before this is // called; there is no ordering question left for it to get wrong. const first = videoTrack('v1'); const second = videoTrack('v2'); const stream = mediaStream([first]); adoptTrack(stream, second); assert.deepEqual(stream.getVideoTracks(), [second], 'the superseded track is still in the stream the tile shows'); assert.equal(shownTrack(stream), second); }); test('adopting a track twice adds it once', () => { const track = videoTrack('v1'); const stream = mediaStream([]); adoptTrack(stream, track); adoptTrack(stream, track); assert.deepEqual(stream.getTracks(), [track]); }); // onRemoteTrack, driven whole: the same-mid replacement regression // (Codex S4-9). A camera rebuilt by a rollback arrives as a new track // on the mid the camera is already known by, often with no stream of // its own, and the old track can still be `live`. What the tile ends up // bound to is the whole question. const remoteBox = new Function( 'pcs', 'renderTiles', 'dropShare', 'MediaStream', 'SHARE_SUFFIX', [lift(src, 'onRemoteTrack'), lift(src, 'classifyTrack'), lift(src, 'adoptTrack'), lift(src, 'shownTrack'), lift(src, 'dropShare')].join('\n') + '\nreturn { onRemoteTrack, shownTrack };', ); // arriving builds the `track` event as onRemoteTrack reads one. function arriving(track, opts) { const o = opts || {}; return { track, streams: o.stream ? [o.stream] : [], transceiver: { mid: o.mid === undefined ? '1' : o.mid, currentDirection: o.direction || 'sendrecv' }, }; } function receiving() { const renders = []; const pcs = new Map(); const entry = { sentVideo: true, stream: null, share: null, sharing: false, cameraMid: '', cameraStream: '', peerStream: '', shareMid: '', }; pcs.set('p1', entry); const box = remoteBox( pcs, () => renders.push('tiles'), () => {}, function FakeMediaStream() { return mediaStream([]); }, '#share', ); return { entry, pcs, renders, onRemoteTrack: box.onRemoteTrack, shownTrack: box.shownTrack }; } test('a camera rebuilt on the same mid replaces the one before it', () => { const r = receiving(); const first = videoTrack('cam1'); r.onRemoteTrack('p1', r.entry, arriving(first, { stream: mediaStream([first]) })); assert.equal(r.entry.cameraMid, '1'); assert.equal(r.shownTrack(r.entry.stream), first); // The rollback's rebuild: same mid, new track, no stream of its own — // and the old track has not ended, it has simply stopped carrying // frames. const second = videoTrack('cam2'); assert.equal(first.readyState, 'live', 'the predecessor is still live, which is the whole point'); r.onRemoteTrack('p1', r.entry, arriving(second, { mid: '1' })); assert.deepEqual(r.entry.stream.getVideoTracks(), [second], 'the tile stream still holds the superseded camera'); assert.equal(r.shownTrack(r.entry.stream), second); assert.equal(r.entry.cameraTrack, second, 'the authoritative track was not recorded'); }); test('a rebuilt camera does not disturb the microphone', () => { const r = receiving(); const mic = { id: 'mic1', kind: 'audio', readyState: 'live' }; const first = videoTrack('cam1'); r.onRemoteTrack('p1', r.entry, arriving(mic, { mid: '0' })); r.onRemoteTrack('p1', r.entry, arriving(first, { mid: '1' })); r.onRemoteTrack('p1', r.entry, arriving(videoTrack('cam2'), { mid: '1' })); assert.deepEqual(r.entry.stream.getTracks().map((t) => t.id), ['mic1', 'cam2']); }); test("a peer's screen is bound to its own track, on its own stream", () => { const r = receiving(); const camera = videoTrack('cam1'); r.onRemoteTrack('p1', r.entry, arriving(camera, { mid: '1' })); const screen = videoTrack('scr1'); r.onRemoteTrack('p1', r.entry, arriving(screen, { mid: '2' })); assert.equal(r.entry.sharing, true); assert.equal(r.entry.share.track, screen); assert.deepEqual(r.entry.share.stream.getVideoTracks(), [screen]); assert.notEqual(r.entry.share.stream, r.entry.stream, 'the two tiles share one stream'); assert.deepEqual(r.entry.stream.getVideoTracks(), [camera], 'the screen displaced the camera'); // And a rebuilt screen replaces the screen, not the camera. const screen2 = videoTrack('scr2'); r.onRemoteTrack('p1', r.entry, arriving(screen2, { mid: '2' })); assert.deepEqual(r.entry.share.stream.getVideoTracks(), [screen2]); assert.deepEqual(r.entry.stream.getVideoTracks(), [camera]); }); test('a screen that was replaced cannot take its replacement away', () => { // Codex S4-15. A screen can be replaced on its own mid — the far side // rebuilding it, or a rollback doing it for them — and the // predecessor ends afterwards, as a superseded track does. Its // `ended` listener is still on the connection, and it checked only // that the connection was the same one: so the predecessor's ending // dropped the live replacement's tile. const r = receiving(); r.onRemoteTrack('p1', r.entry, arriving(videoTrack('cam1'), { mid: '1' })); const first = videoTrack('scr1'); r.onRemoteTrack('p1', r.entry, arriving(first, { mid: '2' })); const second = videoTrack('scr2'); r.onRemoteTrack('p1', r.entry, arriving(second, { mid: '2' })); assert.equal(r.entry.share.track, second); first.end(); assert.equal(r.entry.sharing, true, 'the predecessor took the live screen with it'); assert.equal(r.entry.share && r.entry.share.track, second); // And the screen that *is* showing still ends the tile when it ends. second.end(); assert.equal(r.entry.sharing, false); assert.equal(r.entry.share, null); }); // --------------------------------------------------------------- // freshStream and replayTile (Codex S4-13) // --------------------------------------------------------------- // // The render watch's repair hands the element a new MediaStream over // the same tracks. The first draft handed it to the element alone and // left the role writing to the old object, so the next microphone to // arrive or be replaced went somewhere nothing was showing. const replayBox = new Function( 'MediaStream', 'shownTrack', 'shouldPlay', 'startPlaying', [lift(src, 'freshStream'), lift(src, 'replayTile')].join('\n') + '\nreturn { freshStream, replayTile };', ); function replaying() { const played = []; const box = replayBox( function FakeMediaStream(tracks) { return mediaStream(tracks || []); }, (stream) => (stream && stream.getVideoTracks ? stream.getVideoTracks()[0] || null : null), () => true, (key) => played.push(key), ); return { played, freshStream: box.freshStream, replayTile: box.replayTile }; } // A tile record as replayTile sees one, over an element that remembers // what it was last handed. function replayTileRecord(stream) { return { video: { srcObject: stream }, stream, track: null, play: null }; } test('a replayed camera keeps hearing the microphone that arrives after it', () => { // The reproduction: replay, then a microphone arrives. The // authoritative stream has [video, mic]; the element must too. const r = receiving(); const camera = videoTrack('cam1'); r.onRemoteTrack('p1', r.entry, arriving(camera, { mid: '1' })); const t = replayTileRecord(r.entry.stream); const box = replaying(); box.replayTile('p1', t, r.entry, false); assert.notEqual(t.video.srcObject, null); assert.equal(t.video.srcObject, r.entry.stream, 'the role kept writing to the old stream'); assert.equal(t.stream, r.entry.stream); assert.equal(t.track, camera); const mic = { id: 'mic1', kind: 'audio', readyState: 'live' }; r.onRemoteTrack('p1', r.entry, arriving(mic, { mid: '0' })); assert.deepEqual(t.video.srcObject.getTracks().map((x) => x.id), ['cam1', 'mic1'], 'the element cannot hear a microphone that arrived after the repair'); }); test('a replayed camera follows a microphone that is replaced after it', () => { const r = receiving(); r.onRemoteTrack('p1', r.entry, arriving({ id: 'mic1', kind: 'audio', readyState: 'live' }, { mid: '0' })); r.onRemoteTrack('p1', r.entry, arriving(videoTrack('cam1'), { mid: '1' })); const t = replayTileRecord(r.entry.stream); replaying().replayTile('p1', t, r.entry, false); r.onRemoteTrack('p1', r.entry, arriving({ id: 'mic2', kind: 'audio', readyState: 'live' }, { mid: '0' })); assert.deepEqual(t.video.srcObject.getTracks().map((x) => x.id), ['cam1', 'mic2'], 'the element is still holding the microphone that was replaced'); assert.deepEqual(r.entry.stream.getTracks().map((x) => x.id), ['cam1', 'mic2']); }); test('a replayed screen replaces the screen and leaves the camera alone', () => { const r = receiving(); r.onRemoteTrack('p1', r.entry, arriving(videoTrack('cam1'), { mid: '1' })); r.onRemoteTrack('p1', r.entry, arriving(videoTrack('scr1'), { mid: '2' })); const camera = r.entry.stream; const t = replayTileRecord(r.entry.share.stream); replaying().replayTile('p1#share', t, r.entry, true); assert.equal(t.video.srcObject, r.entry.share.stream); assert.notEqual(r.entry.share.stream, camera); assert.equal(r.entry.stream, camera, 'the camera tile was handed the screen repair'); }); test('a replay with nothing to hand over does nothing at all', () => { const box = replaying(); const t = replayTileRecord(null); assert.doesNotThrow(() => box.replayTile('p1', t, { stream: null }, false)); assert.equal(t.video.srcObject, null); assert.equal(box.freshStream({ stream: null }, false), null); assert.equal(box.freshStream({ share: null }, true), null); }); test('adoptTrack survives what a browser can hand it', () => { assert.equal(adoptTrack(null, videoTrack('v1')), null); const stream = mediaStream([]); assert.equal(adoptTrack(stream, null), stream); assert.equal(shownTrack(null), null); assert.equal(shownTrack({}), null); assert.equal(shownTrack(mediaStream([])), null); }); // --------------------------------------------------------------- // watchFrames: the counter the whole browser gate stands on // --------------------------------------------------------------- // // Every frames assertion in call.mjs reads this counter, so a counter // that can die quietly makes the gate able to report dead media on a // tile that is playing perfectly — which is what `laterolls` found. // Setting `srcObject` resets the media element and discards any pending // requestVideoFrameCallback, so a tile handed a new stream (a rebuilt // pair, or a camera the far side rebuilt after a rollback) lost its // chain, and the WeakSet of elements it used to keep then refused to // arm another one. const FRAME_QUIET = readNumber(src, 'FRAME_QUIET'); const framesBox = new Function( 'frames', 'HAS_RVFC', 'Date', 'const watched = new WeakMap();\nconst FRAME_QUIET = ' + FRAME_QUIET + ';\n' + [lift(src, 'watchFrames'), lift(src, 'chainDropped')].join('\n') + '\nreturn { watchFrames };', ); // A video element as watchFrames sees one: a stream, a connectedness, a // clock, and one pending frame callback at a time. `frame()` both // delivers a rendered frame and moves the clock, the way a playing // video does. function fakeVideo(stream) { return { srcObject: stream, isConnected: true, currentTime: 0, pending: null, requestVideoFrameCallback(fn) { this.pending = fn; }, frame() { const fn = this.pending; this.pending = null; this.currentTime += 0.033; if (fn) fn(); return this; }, }; } // A stream as watchFrames sees one: its video track is the second half // of the identity, because a camera the far side rebuilt comes back on // a new track inside the same MediaStream. function fakeStream(id, track) { const tracks = track === null ? [] : [track || { id: id + '-v' }]; return { id, getVideoTracks: () => tracks, // What a rebuilt camera does to a stream that is kept: the same // MediaStream object, carrying a different track. swap(next) { tracks[0] = next; return this; }, }; } // counter builds the box over a clock the test moves, so staleness can // be reached without waiting two seconds for it. function counter() { const frames = new Map(); let now = 1000; const clock = { now: () => now }; return { frames, watchFrames: framesBox(frames, true, clock).watchFrames, tick: (ms) => { now += ms; }, }; } test('one chain per tile: arming twice does not count twice', () => { const c = counter(); const video = fakeVideo(fakeStream('s1')); c.watchFrames('p1', video); c.watchFrames('p1', video); video.frame().frame().frame(); assert.equal(c.frames.get('p1'), 3, 'two chains raced the same tile up'); }); test('a tile handed a new stream starts counting again', () => { // The element is the same one; the stream is not. const c = counter(); const video = fakeVideo(fakeStream('s1')); c.watchFrames('p1', video); video.frame().frame(); assert.equal(c.frames.get('p1'), 2); // What `srcObject = stream` does: the element resets, and the pending // frame callback goes with it. video.srcObject = fakeStream('s2'); video.pending = null; c.watchFrames('p1', video); video.frame().frame().frame(); assert.equal(c.frames.get('p1'), 5, 'the tile stopped counting when its stream was replaced'); }); test('a tile whose track was rebuilt inside the same stream counts again', () => { // The defect the browser gate found, and the reason the stream alone // is not the identity: Chromium hands a rebuilt camera back inside // the *same* MediaStream, on a new receiver track. The element resets // — its clock goes back and the pending callback is discarded — and a // chain keyed on the stream would have seen nothing to re-arm for. const c = counter(); const stream = fakeStream('s1', { id: 'v1' }); const video = fakeVideo(stream); c.watchFrames('p1', video); video.frame().frame(); assert.equal(c.frames.get('p1'), 2); // The same stream object, a new track inside it, and an element that // reset itself around the swap. stream.swap({ id: 'v2' }); video.pending = null; video.currentTime = 0; c.watchFrames('p1', video); video.frame().frame().frame(); assert.equal(c.frames.get('p1'), 5, 'the tile stopped counting when its track was rebuilt under it'); }); test('a chain whose tile was detached is armed again on the next render', () => { const c = counter(); const video = fakeVideo(fakeStream('s1')); c.watchFrames('p1', video); video.frame(); assert.equal(c.frames.get('p1'), 1); // The grid was rebuilt under it. video.isConnected = false; video.frame(); assert.equal(c.frames.get('p1'), 1, 'a detached tile went on counting'); video.isConnected = true; c.watchFrames('p1', video); video.frame().frame(); assert.equal(c.frames.get('p1'), 3, 'the tile never started counting again'); }); test('a counter that goes quiet while the video plays on is re-armed', () => { // The backstop, for a cause nobody has thought of. Whatever drops a // frame callback, the signature is the same: the element's clock // moves and the count does not. const c = counter(); const video = fakeVideo(fakeStream('s1')); c.watchFrames('p1', video); video.frame(); assert.equal(c.frames.get('p1'), 1); // The callback is gone, but the video is playing: the clock advances // with nothing to count it. video.pending = null; video.currentTime += 1; c.tick(FRAME_QUIET + 1); c.watchFrames('p1', video); video.frame().frame(); assert.equal(c.frames.get('p1'), 3, 'a dropped chain was never noticed'); }); test('a frozen tile is idle, not dropped, and is left alone', () => { // The other half of the same question, and the reason the clock is // read at all: a tile receiving nothing has a chain that is perfectly // alive and simply has nothing to count. Re-arming it every second // would pile up callbacks on a still picture. const c = counter(); const video = fakeVideo(fakeStream('s1')); c.watchFrames('p1', video); const armed = video.pending; c.tick(FRAME_QUIET * 5); c.watchFrames('p1', video); assert.equal(video.pending, armed, 'a frozen tile was re-armed'); }); test('a tile that changed peer counts under the new key only', () => { const c = counter(); const video = fakeVideo(fakeStream('s1')); c.watchFrames('p1', video); video.frame(); video.srcObject = fakeStream('s2'); video.pending = null; c.watchFrames('p2', video); video.frame().frame(); assert.equal(c.frames.get('p1'), 1); assert.equal(c.frames.get('p2'), 2); }); test('a browser without requestVideoFrameCallback counts nothing here', () => { // pollFrames is that browser's counter, and arming this one would be a // second one racing it. const frames = new Map(); const { watchFrames } = framesBox(frames, false, Date); const video = fakeVideo(fakeStream('s1')); watchFrames('p1', video); assert.equal(video.pending, null); assert.equal(frames.size, 0); }); // --------------------------------------------------------------- // Sending a screen: sharePlan, cameraCap, tuneSender, shareSendReport // // The native app's ScreenSendPolicy is sharePlan's twin (its tests are // native/apple/Tests/CallsKitTests/ScreenSendTests.swift), and the two // are meant to agree number for number. // --------------------------------------------------------------- const sharePlan = build('sharePlan'); const cameraCap = build('cameraCap'); const tuneSender = build('tuneSender'); const shareSendReport = build('shareSendReport'); test('the default is balanced, and every mode is offered by name', () => { const m = /const SHARE_MODES = \[([\s\S]*?)\];/.exec(src); assert.notEqual(m, null, 'SHARE_MODES is not in room.js any more'); const modes = [...m[1].matchAll(/\['(\w+)', '([^']+)'\]/g)].map((x) => [x[1], x[2]]); assert.deepEqual(modes, [ ['balanced', 'Balanced'], ['detail', 'Text and detail'], ['motion', 'Motion and video'], ]); }); test('detail keeps every pixel, at the highest ceiling', () => { const p = sharePlan('detail', 1920, 1080); assert.equal(p.contentHint, 'detail'); assert.equal(p.degradationPreference, 'maintain-resolution'); assert.equal(p.encoding.maxBitrate, 3500000); assert.equal(p.encoding.scaleResolutionDownBy, 1); assert.equal(p.encoding.maxFramerate, 30); assert.equal(p.encoding.priority, 'high'); assert.equal(p.encoding.networkPriority, 'high'); }); test('motion keeps the frame rate at 1280 on the long side', () => { const p = sharePlan('motion', 1920, 1080); assert.equal(p.contentHint, 'motion'); assert.equal(p.degradationPreference, 'maintain-framerate'); assert.equal(p.encoding.scaleResolutionDownBy, 1.5); assert.equal(p.encoding.maxFramerate, 30); assert.equal(p.encoding.priority, 'high'); assert.equal(sharePlan('motion', 1000, 1600).encoding.scaleResolutionDownBy, 1.25, 'portrait, by its long side'); assert.equal(sharePlan('motion', 1280, 720).encoding.scaleResolutionDownBy, 1); }); test('balanced holds the frame rate from full size, and anything unknown is balanced', () => { const b = sharePlan('balanced', 1920, 1080); assert.equal(b.contentHint, 'detail', 'still encoded as screen content'); assert.equal(b.degradationPreference, 'maintain-framerate'); assert.equal(b.encoding.maxBitrate, 3000000); assert.equal(b.encoding.scaleResolutionDownBy, 1, 'full size until the link says otherwise'); assert.equal(b.encoding.priority, 'high'); assert.deepEqual(sharePlan('nonsense', 1920, 1080), b); assert.deepEqual(sharePlan(undefined, 1920, 1080), b); }); test('a screen larger than 1920 is never sent larger, in any mode', () => { for (const mode of ['balanced', 'detail', 'motion']) { assert.ok(sharePlan(mode, 3840, 2160).encoding.scaleResolutionDownBy >= 2, mode); } }); test('an unknown size leaves the scale alone', () => { for (const mode of ['balanced', 'detail', 'motion']) { assert.equal(sharePlan(mode).encoding.scaleResolutionDownBy, 1, mode); } }); test('the camera is capped while sharing and given back after', () => { assert.deepEqual(cameraCap(true, 720), { maxBitrate: 500000, scaleResolutionDownBy: 2 }); assert.deepEqual(cameraCap(true, 360), { maxBitrate: 500000, scaleResolutionDownBy: 1 }); assert.deepEqual(cameraCap(false, 720), { maxBitrate: undefined, scaleResolutionDownBy: 1 }); }); // A sender that keeps what it was given, as Chromium's does. function fakeSender(encodings = [{ active: true }]) { const sender = { params: { encodings, degradationPreference: undefined }, sets: 0, getParameters() { return JSON.parse(JSON.stringify(this.params)); }, async setParameters(p) { this.sets += 1; this.params = p; }, }; return sender; } test('a plan reaches the sender, and giving the camera back removes the cap', async () => { const camera = fakeSender(); tuneSender(camera, cameraCap(true, 720)); await new Promise((r) => setTimeout(r, 0)); assert.equal(camera.params.encodings[0].maxBitrate, 500000); assert.equal(camera.params.encodings[0].scaleResolutionDownBy, 2); tuneSender(camera, cameraCap(false, 720)); await new Promise((r) => setTimeout(r, 0)); assert.equal('maxBitrate' in camera.params.encodings[0], false, 'undefined deletes, never zero'); assert.equal(camera.params.encodings[0].scaleResolutionDownBy, 1); assert.equal(camera.params.encodings[0].active, true, 'the rest of the encoding is left alone'); const screen = fakeSender(); const plan = sharePlan('motion', 1920, 1080); tuneSender(screen, plan.encoding, plan.degradationPreference); await new Promise((r) => setTimeout(r, 0)); assert.equal(screen.params.degradationPreference, 'maintain-framerate'); assert.equal(screen.params.encodings[0].maxBitrate, 2500000); assert.equal(screen.params.encodings[0].priority, 'high'); }); test('a sender with no encodings yet, or one that refuses, costs nothing', async () => { const empty = fakeSender([]); tuneSender(empty, cameraCap(true, 720)); await new Promise((r) => setTimeout(r, 0)); assert.equal(empty.sets, 0); const refusing = fakeSender(); refusing.setParameters = () => Promise.reject(new Error('InvalidModificationError')); tuneSender(refusing, cameraCap(true, 720)); await new Promise((r) => setTimeout(r, 0)); tuneSender(null, cameraCap(true, 720)); tuneSender({}, cameraCap(true, 720)); }); test('the screen is read off its own outbound report, by mid', () => { const reports = [ { mid: '1', frameWidth: 640, frameHeight: 480, framesPerSecond: 30 }, { mid: '2', frameWidth: 1920, frameHeight: 1080, framesPerSecond: 8, qualityLimitationReason: 'bandwidth', targetBitrate: 1200000, encoderImplementation: 'OpenH264', framesEncoded: 400, framesSent: 398, keyFramesEncoded: 3 }, ]; // framesSent and keyFramesEncoded (§15.22): what the ladder counts as // having left for this person, and the diagnostics' keyframes. assert.deepEqual(shareSendReport(reports, '2'), { width: 1920, height: 1080, fps: 8, limitation: 'bandwidth', targetBitrate: 1200000, encoder: 'OpenH264', framesEncoded: 400, framesSent: 398, keyFramesEncoded: 3, }); assert.equal(shareSendReport(reports, ''), null, 'not sharing on this connection'); assert.equal(shareSendReport(reports, '9'), null, 'not negotiated yet'); });