oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1// room-share.test.mjs — the pure parts of room.js's chat and screen
2// share, gated without a browser.
3//
4// node --test test/browser/room-share.test.mjs
5//
6// Same trick as negotiation.test.mjs: room.js is an IIFE that wants a
7// document, so it cannot be imported, but a pure function inside it can
8// be lifted out by name and evaluated here. What is under test is the
9// shipped source and not a copy of it, so renaming or reshaping one of
10// these functions fails loudly rather than passing against a stale
11// duplicate.
12//
13// Four judgements are gated here, and they are the four that decide
14// whether §5.4 and §6 work at all:
15//
16// classifyTrack a peer's screen told from its camera
17// shareKey the tile key a screen gets, which must never
18// collide with a peer id
19// shareCaption the words under a screen, which the browser gate
20// rebuilds from the roster and compares
21// frameKey which of a peer's two video tiles a stats report
22// belongs to
23// broadcastBody the wire shape of an untargeted message — the
24// contract is what is *not* in it
25// broadcastVerdict every status the wire contract names,
26// accepted the strict boolean chat.js echoes on, and
27// recoveryDeadline the floor under a 404's diagnosis
28import { test } from 'node:test';
29import assert from 'node:assert/strict';
30import { readFileSync } from 'node:fs';
31import { fileURLToPath } from 'node:url';
32
33const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url));
34const src = readFileSync(roomJS, 'utf8');
35
36// lift returns the source of one `function name(...) {...}` by matching
37// braces from its declaration.
38function lift(source, name) {
39 let start = source.indexOf('function ' + name + '(');
40 assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing');
41 if (source.slice(start - 6, start) === 'async ') start -= 6;
42 let depth = 0;
43 for (let i = source.indexOf('{', start); i < source.length; i++) {
44 if (source[i] === '{') depth += 1;
45 else if (source[i] === '}') {
46 depth -= 1;
47 if (depth === 0) return source.slice(start, i + 1);
48 }
49 }
50 assert.fail('unbalanced braces reading ' + name + ' out of room.js');
51}
52
53// readConst reads a top-level string constant out of the shipped source,
54// so a test cannot go on asserting a value someone has since changed.
55function readConst(source, name) {
56 const m = new RegExp("const " + name + " = '([^']*)'").exec(source);
57 assert.notEqual(m, null, name + ' is not a string constant in room.js any more');
58 return m[1];
59}
60
61// readNumber does the same for a number, so no timing constant is
62// retyped here either.
63function readNumber(source, name) {
64 const m = new RegExp('const ' + name + ' = (\\d+);').exec(source);
65 assert.notEqual(m, null, name + ' is not a number constant in room.js any more');
66 return Number(m[1]);
67}
68
69const SHARE_SUFFIX = readConst(src, 'SHARE_SUFFIX');
70const BROADCAST = readConst(src, 'BROADCAST');
71const MIN_RECOVERY = readNumber(src, 'MIN_RECOVERY');
72
73const build = (name, prelude) =>
74 // eslint-disable-next-line no-new-func
75 new Function((prelude || '') + lift(src, name) + '; return ' + name + ';')();
76
77const suffix = 'const SHARE_SUFFIX = ' + JSON.stringify(SHARE_SUFFIX) + ';';
78const shareKey = build('shareKey', suffix);
79const shareCaption = build('shareCaption');
80const classifyTrack = build('classifyTrack');
81const frameKey = build('frameKey', suffix + lift(src, 'shareKey'));
82const broadcastBody = build('broadcastBody');
83const broadcastVerdict = build('broadcastVerdict');
84const accepted = build('accepted');
85const recoveryDeadline = build('recoveryDeadline',
86 'const MIN_RECOVERY = ' + readNumber(src, 'MIN_RECOVERY') + ';');
87
88// ---------------------------------------------------------------
89// The two sentinels
90// ---------------------------------------------------------------
91
92test('the share suffix and the broadcast key are what the contract names', () => {
93 // Both are pinned rather than retyped: everything below is driven by
94 // the values read out of the file, so changing one is a deliberate act
95 // with a failing test in front of it.
96 assert.equal(SHARE_SUFFIX, '#share');
97 assert.equal(BROADCAST, '*');
98});
99
100test('neither sentinel could ever be mistaken for a peer id', () => {
101 const PEER_ID = /^[0-9a-f]{32}$/;
102 const peer = 'a'.repeat(32);
103 assert.equal(PEER_ID.test(peer), true);
104 assert.equal(PEER_ID.test(shareKey(peer)), false);
105 assert.equal(PEER_ID.test(BROADCAST), false);
106});
107
108// ---------------------------------------------------------------
109// shareKey
110// ---------------------------------------------------------------
111
112test('a peer holds two tiles, and they are not the same key', () => {
113 const peer = '0123456789abcdef0123456789abcdef';
114 assert.equal(shareKey(peer), peer + '#share');
115 assert.notEqual(shareKey(peer), peer);
116});
117
118test('two peers sharing at once keep their screens apart', () => {
119 const a = 'a'.repeat(32);
120 const b = 'b'.repeat(32);
121 assert.notEqual(shareKey(a), shareKey(b));
122});
123
124// ---------------------------------------------------------------
125// shareCaption
126// ---------------------------------------------------------------
127//
128// The browser gate builds this string for itself, out of the roster's
129// name, and compares it with the caption on the tile. That makes the
130// words a claim two files agree on — and this is the file that can fail
131// in fifty milliseconds when someone changes them.
132
133test('a screen says whose it is, with the typographic apostrophe', () => {
134 assert.equal(shareCaption('Bea'), 'Bea’s screen');
135 // Not the ASCII one. TERMINAL.accept uses U+2019 and so does this;
136 // a stray "'" here would be a second convention in one file.
137 assert.equal(shareCaption('Bea').includes("'"), false);
138 // The name goes in whole: nothing trims it, shortens it or changes
139 // its case, because it is the roster's word and not this file's.
140 assert.equal(shareCaption('de Vries'), 'de Vries’s screen');
141});
142
143// ---------------------------------------------------------------
144// classifyTrack (§5.4, receiving)
145// ---------------------------------------------------------------
146
147// A connection as the classifier sees one, an arriving track, and this
148// tab. Written out rather than defaulted, because every field of the
149// three is a signal and a silent default would hide which one decided.
150const peer = (o) => Object.assign(
151 { cameraMid: '', cameraStream: '', peerStream: '', sharing: false }, o || {});
152const track = (o) => Object.assign(
153 { kind: 'video', streamId: '', mid: '', direction: '' }, o || {});
154const me = (hasVideo) => ({ hasVideo: !!hasVideo });
155
156const nothing = peer();
157const known = peer({ cameraMid: '0', cameraStream: 'cam', peerStream: 'cam' });
158
159test('audio is never a screen, whatever else is known', () => {
160 assert.equal(classifyTrack(nothing, track({ kind: 'audio', streamId: 'cam', mid: '1' }), me(true)), 'audio');
161 assert.equal(classifyTrack(known, track({ kind: 'audio', streamId: 'other', mid: '9' }), me(true)), 'audio');
162});
163
164test('the first video track a peer sends is the camera', () => {
165 assert.equal(classifyTrack(nothing, track({ streamId: 'cam', mid: '0' }), me(false)), 'camera');
166 // Even with nothing to compare at all: there is no second of
167 // something that has not arrived yet.
168 assert.equal(classifyTrack(nothing, track(), me(false)), 'camera');
169});
170
171test('a second video track on another transceiver is the screen', () => {
172 assert.equal(classifyTrack(known, track({ streamId: 'screen', mid: '2' }), me(false)), 'share');
173});
174
175test('the camera track again is still the camera, not a screen', () => {
176 // A replaced camera track arrives on the same mid. Reading that as a
177 // screen would give the peer a second tile it never shared.
178 assert.equal(classifyTrack(known, track({ streamId: 'cam', mid: '0' }), me(false)), 'camera');
179 // The mid is what decides when there is one: a camera whose stream id
180 // changed under a renegotiation is still the camera.
181 assert.equal(classifyTrack(known, track({ streamId: 'renamed', mid: '0' }), me(false)), 'camera');
182});
183
184test('with no mid to compare, the stream id decides', () => {
185 const byStream = peer({ cameraStream: 'cam' });
186 assert.equal(classifyTrack(byStream, track({ streamId: 'cam' }), me(false)), 'camera');
187 assert.equal(classifyTrack(byStream, track({ streamId: 'screen' }), me(false)), 'share');
188 // A known mid but none on this track falls through to the stream id.
189 assert.equal(classifyTrack(known, track({ streamId: 'cam' }), me(false)), 'camera');
190 assert.equal(classifyTrack(known, track({ streamId: 'screen' }), me(false)), 'share');
191});
192
193test('a second video track with nothing to compare is the screen', () => {
194 // A sender that named no stream and a browser that reports no mid:
195 // the camera is already known, so this is the screen.
196 assert.equal(classifyTrack(known, track(), me(false)), 'share');
197});
198
199// The blind spot the review found, and the three signals that close it.
200// startLocal has an audio-only rung — a camera in use, or gone — and a
201// peer on it has no camera m-line at all, so its *screen* is the first
202// video track its peers ever see.
203
204test('a recvonly video track is the screen when this tab has a camera of its own', () => {
205 // This tab builds a video m-line for every connection, sendrecv from
206 // its own camera. A video track arriving recvonly is therefore on an
207 // m-line the far side added, and the only one it adds is the screen —
208 // even as the first video track that peer has ever sent.
209 assert.equal(classifyTrack(nothing, track({ streamId: 'screen', mid: '2', direction: 'recvonly' }), me(true)),
210 'share');
211});
212
213test('recvonly says nothing when this tab has no camera either', () => {
214 // With no local video this tab offers recvonly itself, so the peer's
215 // camera arrives recvonly too and the direction cannot tell them
216 // apart. The rule must not fire, or every camera becomes a screen.
217 assert.equal(classifyTrack(nothing, track({ streamId: 'cam', mid: '1', direction: 'recvonly' }), me(false)),
218 'camera');
219});
220
221test('a sendrecv camera is a camera even while this tab has video', () => {
222 assert.equal(classifyTrack(nothing, track({ streamId: 'cam', mid: '1', direction: 'sendrecv' }), me(true)),
223 'camera');
224});
225
226test('a video track in a stream the peer\u2019s audio never used is the screen', () => {
227 // The audio-only sharer, seen by a peer that also has no camera: no
228 // camera has ever arrived, so there is no cameraMid and no
229 // cameraStream — but its microphone came in a stream of its own, and a
230 // sender puts its camera in that same stream and its screen in
231 // another.
232 const audioOnly = peer({ peerStream: 'theirs' });
233 assert.equal(classifyTrack(audioOnly, track({ streamId: 'screen', mid: '2' }), me(false)), 'share');
234 // And the camera, when there is one, arrives in that same stream.
235 assert.equal(classifyTrack(audioOnly, track({ streamId: 'theirs', mid: '1' }), me(false)), 'camera');
236});
237
238test('the share message breaks the tie, and only the tie', () => {
239 // Nothing else can speak: no camera known, no stream on the track, no
240 // direction, nothing from this peer before. Then the `share {on:true}`
241 // that always precedes the media is the only evidence there is.
242 assert.equal(classifyTrack(peer({ sharing: true }), track(), me(false)), 'share');
243 assert.equal(classifyTrack(peer({ sharing: false }), track(), me(false)), 'camera');
244 // But it never overrides a signal that can speak. A peer that clicked
245 // share before its own first negotiation finished sends its camera and
246 // its screen in one description, camera first — and the camera is in
247 // the stream its microphone was in.
248 const sharingPeer = peer({ sharing: true, peerStream: 'theirs' });
249 assert.equal(classifyTrack(sharingPeer, track({ streamId: 'theirs', mid: '1' }), me(false)), 'camera');
250 // Nor does it override the camera's own mid.
251 assert.equal(classifyTrack(peer({ sharing: true, cameraMid: '1' }), track({ mid: '1' }), me(false)),
252 'camera');
253});
254
255// ---------------------------------------------------------------
256// frameKey (the getStats fallback, two video tiles)
257// ---------------------------------------------------------------
258
259const sharingEntry = { cameraMid: '1', shareMid: '2', share: {}, sharing: true };
260
261test('a peer sharing a screen counts its two video streams apart', () => {
262 assert.equal(frameKey('p1', sharingEntry, '1'), 'p1');
263 assert.equal(frameKey('p1', sharingEntry, '2'), 'p1#share');
264});
265
266test('a report with no mid on a peer with two tiles is left out, not guessed', () => {
267 // Crediting it to either tile would be inventing a number; the
268 // requestVideoFrameCallback path, which every current browser takes,
269 // never needs this.
270 assert.equal(frameKey('p1', sharingEntry, ''), '');
271 assert.equal(frameKey('p1', sharingEntry, '9'), '');
272});
273
274test('a peer with one video tile is never ambiguous', () => {
275 const plain = { cameraMid: '1', shareMid: '', share: null, sharing: false };
276 assert.equal(frameKey('p1', plain, ''), 'p1');
277 assert.equal(frameKey('p1', plain, '1'), 'p1');
278 // A mid this connection has never seen, and only one tile to put it
279 // on: it is the camera's.
280 assert.equal(frameKey('p1', plain, '7'), 'p1');
281});
282
283test('a screen announced but not yet arriving does not take the camera\u2019s count', () => {
284 // `share {on:true}` has drawn the tile and the track is still a
285 // renegotiation away. Writing the camera's frames under a mid that has
286 // not been recorded yet would move them onto the empty tile.
287 const announced = { cameraMid: '1', shareMid: '', share: null, sharing: true };
288 assert.equal(frameKey('p1', announced, '1'), 'p1');
289 assert.equal(frameKey('p1', announced, ''), '');
290});
291
292// ---------------------------------------------------------------
293// recoveryDeadline (the 404's floor)
294// ---------------------------------------------------------------
295
296test('a diagnosis with budget left keeps the message\u2019s own deadline', () => {
297 assert.equal(recoveryDeadline(10000, 1000), 10000);
298});
299
300test('a diagnosis with no budget left gets a floor rather than nothing', () => {
301 // The POST that produced the 404 can spend the whole thirty seconds.
302 // A recovery handed a deadline in the past probes nothing, decides
303 // nothing, and leaves the tab looking live with no participation
304 // behind it.
305 assert.equal(recoveryDeadline(1000, 5000), 5000 + MIN_RECOVERY);
306 assert.equal(recoveryDeadline(5000, 5000), 5000 + MIN_RECOVERY);
307});
308
309// ---------------------------------------------------------------
310// broadcastBody (§4.3's fan-out wire)
311// ---------------------------------------------------------------
312
313test('a broadcast body carries the peer, the type and the payload — and nothing else', () => {
314 const body = broadcastBody('a'.repeat(32), 'chat', { text: 'hi' });
315 assert.deepEqual(Object.keys(body).sort(), ['payload', 'peer', 'type']);
316 assert.equal(body.peer, 'a'.repeat(32));
317 assert.equal(body.type, 'chat');
318 assert.deepEqual(body.payload, { text: 'hi' });
319});
320
321test('no to, no seq and no gen reach the wire', () => {
322 // The server answers 400 to any of the three on a chat or a share, so
323 // this is the difference between a working panel and a bad request.
324 const sent = JSON.parse(JSON.stringify(broadcastBody('b'.repeat(32), 'share', { on: true })));
325 assert.equal('to' in sent, false);
326 assert.equal('seq' in sent, false);
327 assert.equal('gen' in sent, false);
328 assert.deepEqual(sent, { peer: 'b'.repeat(32), type: 'share', payload: { on: true } });
329});
330
331// ---------------------------------------------------------------
332// broadcastVerdict and accepted (the lane's status table)
333// ---------------------------------------------------------------
334
335const answer = (status, extra) => Object.assign({ status, data: {}, signedOut: false, retryAfter: null }, extra);
336
337test('a 200 is the only answer that sends', () => {
338 assert.equal(broadcastVerdict(answer(200)), 'sent');
339 assert.equal(accepted('sent'), true);
340});
341
342test('only a 200 answers the sender true', () => {
343 // chat.js echoes a line into the log on exactly `true`, so every other
344 // ending has to be false: a message nobody received must not appear on
345 // the screen of the person who wrote it.
346 for (const status of [0, 400, 404, 409, 413, 429, 500, 503]) {
347 assert.equal(accepted(broadcastVerdict(answer(status))), false, 'status ' + status);
348 }
349 assert.equal(accepted(broadcastVerdict(answer(200, { signedOut: true }))), false);
350});
351
352test('400, 413 and 409 are dropped and never retried', () => {
353 // A bug in this file, a message the server will never take, and a
354 // conflict that belongs to the targeted lane.
355 for (const status of [400, 409, 413]) {
356 assert.equal(broadcastVerdict(answer(status)), 'drop', 'status ' + status);
357 }
358});
359
360test('a 429 waits and a 404 goes to the probe', () => {
361 assert.equal(broadcastVerdict(answer(429, { retryAfter: '1' })), 'wait');
362 assert.equal(broadcastVerdict(answer(404, { data: { error: 'not-found', boot: 'b1' } })), 'recover');
363});
364
365test('a 5xx, a network failure and an unnamed status are retried', () => {
366 for (const status of [0, 418, 500, 502, 503]) {
367 assert.equal(broadcastVerdict(answer(status)), 'retry', 'status ' + status);
368 }
369});
370
371test('signed out is read before the status, because it can be true of a 200', () => {
372 // An expired session answers 303 to /signin, fetch follows it, and the
373 // sign-in page comes back 200 — which read as a status alone is
374 // indistinguishable from a message that was delivered.
375 assert.equal(broadcastVerdict(answer(200, { signedOut: true })), 'signedout');
376 assert.equal(broadcastVerdict(answer(401, { signedOut: true })), 'signedout');
377 assert.equal(accepted('signedout'), false);
378});
379
380// ---------------------------------------------------------------
381// adoptTrack: the receiving half of the rollback damage
382// ---------------------------------------------------------------
383//
384// Chromium rebuilds every stream on a connection when it rolls back an
385// offer that added an m-line, receive streams included, so the far
386// side's camera arrives again as a *new* track — sometimes with no
387// stream of its own, sometimes inside the very stream that still holds
388// the old, ended one. A media element renders the first track of its
389// kind, so an ended track left in place is a face that never moves
390// again on a connection that is otherwise perfect. The gate's
391// `laterolls` is where that was measured.
392
393const adoptTrack = build('adoptTrack');
394const shownTrack = build('shownTrack');
395
396function mediaStream(tracks) {
397 const held = tracks.slice();
398 return {
399 getTracks: () => held.slice(),
400 getVideoTracks: () => held.filter((t) => t.kind === 'video'),
401 addTrack: (t) => held.push(t),
402 removeTrack: (t) => {
403 const at = held.indexOf(t);
404 if (at !== -1) held.splice(at, 1);
405 },
406 };
407}
408
409// A track as room.js handles one: the share path listens for its
410// ending, so the fake has to be able to be listened to.
411const videoTrack = (id, readyState) => ({
412 id,
413 kind: 'video',
414 readyState: readyState || 'live',
415 listeners: {},
416 addEventListener(type, fn) {
417 (this.listeners[type] = this.listeners[type] || []).push(fn);
418 },
419 end() {
420 this.readyState = 'ended';
421 for (const fn of this.listeners.ended || []) fn();
422 },
423});
424
425test('a rebuilt camera replaces the ended one it arrives beside', () => {
426 const dead = videoTrack('v1', 'ended');
427 const live = videoTrack('v2');
428 const stream = mediaStream([dead, { id: 'a1', kind: 'audio', readyState: 'live' }]);
429 adoptTrack(stream, live);
430 assert.deepEqual(stream.getVideoTracks(), [live],
431 'the element would have gone on rendering the ended track');
432 assert.equal(stream.getTracks().length, 2, 'the audio track was not collateral');
433 assert.equal(shownTrack(stream), live);
434});
435
436test('a replacement supersedes a live predecessor, not only an ended one', () => {
437 // The rule this function used to have, and the defect under it
438 // (Codex S4-9). A receiver can be superseded while its track is still
439 // `live`: it simply never carries another frame. Pruning only ended
440 // tracks left the stream holding [old, new], and an element renders
441 // the first track of its kind — the old one, for the rest of the
442 // call. Which track is authoritative is settled before this is
443 // called; there is no ordering question left for it to get wrong.
444 const first = videoTrack('v1');
445 const second = videoTrack('v2');
446 const stream = mediaStream([first]);
447 adoptTrack(stream, second);
448 assert.deepEqual(stream.getVideoTracks(), [second],
449 'the superseded track is still in the stream the tile shows');
450 assert.equal(shownTrack(stream), second);
451});
452
453test('adopting a track twice adds it once', () => {
454 const track = videoTrack('v1');
455 const stream = mediaStream([]);
456 adoptTrack(stream, track);
457 adoptTrack(stream, track);
458 assert.deepEqual(stream.getTracks(), [track]);
459});
460
461// onRemoteTrack, driven whole: the same-mid replacement regression
462// (Codex S4-9). A camera rebuilt by a rollback arrives as a new track
463// on the mid the camera is already known by, often with no stream of
464// its own, and the old track can still be `live`. What the tile ends up
465// bound to is the whole question.
466
467const remoteBox = new Function(
468 'pcs', 'renderTiles', 'dropShare', 'MediaStream', 'SHARE_SUFFIX',
469 [lift(src, 'onRemoteTrack'), lift(src, 'classifyTrack'), lift(src, 'adoptTrack'),
470 lift(src, 'shownTrack'), lift(src, 'dropShare')].join('\n') +
471 '\nreturn { onRemoteTrack, shownTrack };',
472);
473
474// arriving builds the `track` event as onRemoteTrack reads one.
475function arriving(track, opts) {
476 const o = opts || {};
477 return {
478 track,
479 streams: o.stream ? [o.stream] : [],
480 transceiver: { mid: o.mid === undefined ? '1' : o.mid, currentDirection: o.direction || 'sendrecv' },
481 };
482}
483
484function receiving() {
485 const renders = [];
486 const pcs = new Map();
487 const entry = {
488 sentVideo: true,
489 stream: null,
490 share: null,
491 sharing: false,
492 cameraMid: '',
493 cameraStream: '',
494 peerStream: '',
495 shareMid: '',
496 };
497 pcs.set('p1', entry);
498 const box = remoteBox(
499 pcs,
500 () => renders.push('tiles'),
501 () => {},
502 function FakeMediaStream() { return mediaStream([]); },
503 '#share',
504 );
505 return { entry, pcs, renders, onRemoteTrack: box.onRemoteTrack, shownTrack: box.shownTrack };
506}
507
508test('a camera rebuilt on the same mid replaces the one before it', () => {
509 const r = receiving();
510 const first = videoTrack('cam1');
511 r.onRemoteTrack('p1', r.entry, arriving(first, { stream: mediaStream([first]) }));
512 assert.equal(r.entry.cameraMid, '1');
513 assert.equal(r.shownTrack(r.entry.stream), first);
514
515 // The rollback's rebuild: same mid, new track, no stream of its own —
516 // and the old track has not ended, it has simply stopped carrying
517 // frames.
518 const second = videoTrack('cam2');
519 assert.equal(first.readyState, 'live', 'the predecessor is still live, which is the whole point');
520 r.onRemoteTrack('p1', r.entry, arriving(second, { mid: '1' }));
521
522 assert.deepEqual(r.entry.stream.getVideoTracks(), [second],
523 'the tile stream still holds the superseded camera');
524 assert.equal(r.shownTrack(r.entry.stream), second);
525 assert.equal(r.entry.cameraTrack, second, 'the authoritative track was not recorded');
526});
527
528test('a rebuilt camera does not disturb the microphone', () => {
529 const r = receiving();
530 const mic = { id: 'mic1', kind: 'audio', readyState: 'live' };
531 const first = videoTrack('cam1');
532 r.onRemoteTrack('p1', r.entry, arriving(mic, { mid: '0' }));
533 r.onRemoteTrack('p1', r.entry, arriving(first, { mid: '1' }));
534 r.onRemoteTrack('p1', r.entry, arriving(videoTrack('cam2'), { mid: '1' }));
535 assert.deepEqual(r.entry.stream.getTracks().map((t) => t.id), ['mic1', 'cam2']);
536});
537
538test("a peer's screen is bound to its own track, on its own stream", () => {
539 const r = receiving();
540 const camera = videoTrack('cam1');
541 r.onRemoteTrack('p1', r.entry, arriving(camera, { mid: '1' }));
542 const screen = videoTrack('scr1');
543 r.onRemoteTrack('p1', r.entry, arriving(screen, { mid: '2' }));
544 assert.equal(r.entry.sharing, true);
545 assert.equal(r.entry.share.track, screen);
546 assert.deepEqual(r.entry.share.stream.getVideoTracks(), [screen]);
547 assert.notEqual(r.entry.share.stream, r.entry.stream, 'the two tiles share one stream');
548 assert.deepEqual(r.entry.stream.getVideoTracks(), [camera], 'the screen displaced the camera');
549
550 // And a rebuilt screen replaces the screen, not the camera.
551 const screen2 = videoTrack('scr2');
552 r.onRemoteTrack('p1', r.entry, arriving(screen2, { mid: '2' }));
553 assert.deepEqual(r.entry.share.stream.getVideoTracks(), [screen2]);
554 assert.deepEqual(r.entry.stream.getVideoTracks(), [camera]);
555});
556
557test('a screen that was replaced cannot take its replacement away', () => {
558 // Codex S4-15. A screen can be replaced on its own mid — the far side
559 // rebuilding it, or a rollback doing it for them — and the
560 // predecessor ends afterwards, as a superseded track does. Its
561 // `ended` listener is still on the connection, and it checked only
562 // that the connection was the same one: so the predecessor's ending
563 // dropped the live replacement's tile.
564 const r = receiving();
565 r.onRemoteTrack('p1', r.entry, arriving(videoTrack('cam1'), { mid: '1' }));
566 const first = videoTrack('scr1');
567 r.onRemoteTrack('p1', r.entry, arriving(first, { mid: '2' }));
568 const second = videoTrack('scr2');
569 r.onRemoteTrack('p1', r.entry, arriving(second, { mid: '2' }));
570 assert.equal(r.entry.share.track, second);
571
572 first.end();
573 assert.equal(r.entry.sharing, true, 'the predecessor took the live screen with it');
574 assert.equal(r.entry.share && r.entry.share.track, second);
575
576 // And the screen that *is* showing still ends the tile when it ends.
577 second.end();
578 assert.equal(r.entry.sharing, false);
579 assert.equal(r.entry.share, null);
580});
581
582// ---------------------------------------------------------------
583// freshStream and replayTile (Codex S4-13)
584// ---------------------------------------------------------------
585//
586// The render watch's repair hands the element a new MediaStream over
587// the same tracks. The first draft handed it to the element alone and
588// left the role writing to the old object, so the next microphone to
589// arrive or be replaced went somewhere nothing was showing.
590
591const replayBox = new Function(
592 'MediaStream', 'shownTrack', 'shouldPlay', 'startPlaying',
593 [lift(src, 'freshStream'), lift(src, 'replayTile')].join('\n') +
594 '\nreturn { freshStream, replayTile };',
595);
596
597function replaying() {
598 const played = [];
599 const box = replayBox(
600 function FakeMediaStream(tracks) { return mediaStream(tracks || []); },
601 (stream) => (stream && stream.getVideoTracks ? stream.getVideoTracks()[0] || null : null),
602 () => true,
603 (key) => played.push(key),
604 );
605 return { played, freshStream: box.freshStream, replayTile: box.replayTile };
606}
607
608// A tile record as replayTile sees one, over an element that remembers
609// what it was last handed.
610function replayTileRecord(stream) {
611 return { video: { srcObject: stream }, stream, track: null, play: null };
612}
613
614test('a replayed camera keeps hearing the microphone that arrives after it', () => {
615 // The reproduction: replay, then a microphone arrives. The
616 // authoritative stream has [video, mic]; the element must too.
617 const r = receiving();
618 const camera = videoTrack('cam1');
619 r.onRemoteTrack('p1', r.entry, arriving(camera, { mid: '1' }));
620 const t = replayTileRecord(r.entry.stream);
621
622 const box = replaying();
623 box.replayTile('p1', t, r.entry, false);
624 assert.notEqual(t.video.srcObject, null);
625 assert.equal(t.video.srcObject, r.entry.stream, 'the role kept writing to the old stream');
626 assert.equal(t.stream, r.entry.stream);
627 assert.equal(t.track, camera);
628
629 const mic = { id: 'mic1', kind: 'audio', readyState: 'live' };
630 r.onRemoteTrack('p1', r.entry, arriving(mic, { mid: '0' }));
631 assert.deepEqual(t.video.srcObject.getTracks().map((x) => x.id), ['cam1', 'mic1'],
632 'the element cannot hear a microphone that arrived after the repair');
633});
634
635test('a replayed camera follows a microphone that is replaced after it', () => {
636 const r = receiving();
637 r.onRemoteTrack('p1', r.entry, arriving({ id: 'mic1', kind: 'audio', readyState: 'live' }, { mid: '0' }));
638 r.onRemoteTrack('p1', r.entry, arriving(videoTrack('cam1'), { mid: '1' }));
639 const t = replayTileRecord(r.entry.stream);
640
641 replaying().replayTile('p1', t, r.entry, false);
642 r.onRemoteTrack('p1', r.entry, arriving({ id: 'mic2', kind: 'audio', readyState: 'live' }, { mid: '0' }));
643
644 assert.deepEqual(t.video.srcObject.getTracks().map((x) => x.id), ['cam1', 'mic2'],
645 'the element is still holding the microphone that was replaced');
646 assert.deepEqual(r.entry.stream.getTracks().map((x) => x.id), ['cam1', 'mic2']);
647});
648
649test('a replayed screen replaces the screen and leaves the camera alone', () => {
650 const r = receiving();
651 r.onRemoteTrack('p1', r.entry, arriving(videoTrack('cam1'), { mid: '1' }));
652 r.onRemoteTrack('p1', r.entry, arriving(videoTrack('scr1'), { mid: '2' }));
653 const camera = r.entry.stream;
654 const t = replayTileRecord(r.entry.share.stream);
655
656 replaying().replayTile('p1#share', t, r.entry, true);
657 assert.equal(t.video.srcObject, r.entry.share.stream);
658 assert.notEqual(r.entry.share.stream, camera);
659 assert.equal(r.entry.stream, camera, 'the camera tile was handed the screen repair');
660});
661
662test('a replay with nothing to hand over does nothing at all', () => {
663 const box = replaying();
664 const t = replayTileRecord(null);
665 assert.doesNotThrow(() => box.replayTile('p1', t, { stream: null }, false));
666 assert.equal(t.video.srcObject, null);
667 assert.equal(box.freshStream({ stream: null }, false), null);
668 assert.equal(box.freshStream({ share: null }, true), null);
669});
670
671test('adoptTrack survives what a browser can hand it', () => {
672 assert.equal(adoptTrack(null, videoTrack('v1')), null);
673 const stream = mediaStream([]);
674 assert.equal(adoptTrack(stream, null), stream);
675 assert.equal(shownTrack(null), null);
676 assert.equal(shownTrack({}), null);
677 assert.equal(shownTrack(mediaStream([])), null);
678});
679
680// ---------------------------------------------------------------
681// watchFrames: the counter the whole browser gate stands on
682// ---------------------------------------------------------------
683//
684// Every frames assertion in call.mjs reads this counter, so a counter
685// that can die quietly makes the gate able to report dead media on a
686// tile that is playing perfectly — which is what `laterolls` found.
687// Setting `srcObject` resets the media element and discards any pending
688// requestVideoFrameCallback, so a tile handed a new stream (a rebuilt
689// pair, or a camera the far side rebuilt after a rollback) lost its
690// chain, and the WeakSet of elements it used to keep then refused to
691// arm another one.
692
693const FRAME_QUIET = readNumber(src, 'FRAME_QUIET');
694
695const framesBox = new Function(
696 'frames', 'HAS_RVFC', 'Date',
697 'const watched = new WeakMap();\nconst FRAME_QUIET = ' + FRAME_QUIET + ';\n' +
698 [lift(src, 'watchFrames'), lift(src, 'chainDropped')].join('\n') +
699 '\nreturn { watchFrames };',
700);
701
702// A video element as watchFrames sees one: a stream, a connectedness, a
703// clock, and one pending frame callback at a time. `frame()` both
704// delivers a rendered frame and moves the clock, the way a playing
705// video does.
706function fakeVideo(stream) {
707 return {
708 srcObject: stream,
709 isConnected: true,
710 currentTime: 0,
711 pending: null,
712 requestVideoFrameCallback(fn) {
713 this.pending = fn;
714 },
715 frame() {
716 const fn = this.pending;
717 this.pending = null;
718 this.currentTime += 0.033;
719 if (fn) fn();
720 return this;
721 },
722 };
723}
724
725// A stream as watchFrames sees one: its video track is the second half
726// of the identity, because a camera the far side rebuilt comes back on
727// a new track inside the same MediaStream.
728function fakeStream(id, track) {
729 const tracks = track === null ? [] : [track || { id: id + '-v' }];
730 return {
731 id,
732 getVideoTracks: () => tracks,
733 // What a rebuilt camera does to a stream that is kept: the same
734 // MediaStream object, carrying a different track.
735 swap(next) {
736 tracks[0] = next;
737 return this;
738 },
739 };
740}
741
742// counter builds the box over a clock the test moves, so staleness can
743// be reached without waiting two seconds for it.
744function counter() {
745 const frames = new Map();
746 let now = 1000;
747 const clock = { now: () => now };
748 return {
749 frames,
750 watchFrames: framesBox(frames, true, clock).watchFrames,
751 tick: (ms) => { now += ms; },
752 };
753}
754
755test('one chain per tile: arming twice does not count twice', () => {
756 const c = counter();
757 const video = fakeVideo(fakeStream('s1'));
758 c.watchFrames('p1', video);
759 c.watchFrames('p1', video);
760 video.frame().frame().frame();
761 assert.equal(c.frames.get('p1'), 3, 'two chains raced the same tile up');
762});
763
764test('a tile handed a new stream starts counting again', () => {
765 // The element is the same one; the stream is not.
766 const c = counter();
767 const video = fakeVideo(fakeStream('s1'));
768 c.watchFrames('p1', video);
769 video.frame().frame();
770 assert.equal(c.frames.get('p1'), 2);
771
772 // What `srcObject = stream` does: the element resets, and the pending
773 // frame callback goes with it.
774 video.srcObject = fakeStream('s2');
775 video.pending = null;
776 c.watchFrames('p1', video);
777 video.frame().frame().frame();
778 assert.equal(c.frames.get('p1'), 5,
779 'the tile stopped counting when its stream was replaced');
780});
781
782test('a tile whose track was rebuilt inside the same stream counts again', () => {
783 // The defect the browser gate found, and the reason the stream alone
784 // is not the identity: Chromium hands a rebuilt camera back inside
785 // the *same* MediaStream, on a new receiver track. The element resets
786 // — its clock goes back and the pending callback is discarded — and a
787 // chain keyed on the stream would have seen nothing to re-arm for.
788 const c = counter();
789 const stream = fakeStream('s1', { id: 'v1' });
790 const video = fakeVideo(stream);
791 c.watchFrames('p1', video);
792 video.frame().frame();
793 assert.equal(c.frames.get('p1'), 2);
794
795 // The same stream object, a new track inside it, and an element that
796 // reset itself around the swap.
797 stream.swap({ id: 'v2' });
798 video.pending = null;
799 video.currentTime = 0;
800 c.watchFrames('p1', video);
801 video.frame().frame().frame();
802 assert.equal(c.frames.get('p1'), 5,
803 'the tile stopped counting when its track was rebuilt under it');
804});
805
806test('a chain whose tile was detached is armed again on the next render', () => {
807 const c = counter();
808 const video = fakeVideo(fakeStream('s1'));
809 c.watchFrames('p1', video);
810 video.frame();
811 assert.equal(c.frames.get('p1'), 1);
812
813 // The grid was rebuilt under it.
814 video.isConnected = false;
815 video.frame();
816 assert.equal(c.frames.get('p1'), 1, 'a detached tile went on counting');
817
818 video.isConnected = true;
819 c.watchFrames('p1', video);
820 video.frame().frame();
821 assert.equal(c.frames.get('p1'), 3, 'the tile never started counting again');
822});
823
824test('a counter that goes quiet while the video plays on is re-armed', () => {
825 // The backstop, for a cause nobody has thought of. Whatever drops a
826 // frame callback, the signature is the same: the element's clock
827 // moves and the count does not.
828 const c = counter();
829 const video = fakeVideo(fakeStream('s1'));
830 c.watchFrames('p1', video);
831 video.frame();
832 assert.equal(c.frames.get('p1'), 1);
833
834 // The callback is gone, but the video is playing: the clock advances
835 // with nothing to count it.
836 video.pending = null;
837 video.currentTime += 1;
838 c.tick(FRAME_QUIET + 1);
839 c.watchFrames('p1', video);
840 video.frame().frame();
841 assert.equal(c.frames.get('p1'), 3, 'a dropped chain was never noticed');
842});
843
844test('a frozen tile is idle, not dropped, and is left alone', () => {
845 // The other half of the same question, and the reason the clock is
846 // read at all: a tile receiving nothing has a chain that is perfectly
847 // alive and simply has nothing to count. Re-arming it every second
848 // would pile up callbacks on a still picture.
849 const c = counter();
850 const video = fakeVideo(fakeStream('s1'));
851 c.watchFrames('p1', video);
852 const armed = video.pending;
853 c.tick(FRAME_QUIET * 5);
854 c.watchFrames('p1', video);
855 assert.equal(video.pending, armed, 'a frozen tile was re-armed');
856});
857
858test('a tile that changed peer counts under the new key only', () => {
859 const c = counter();
860 const video = fakeVideo(fakeStream('s1'));
861 c.watchFrames('p1', video);
862 video.frame();
863 video.srcObject = fakeStream('s2');
864 video.pending = null;
865 c.watchFrames('p2', video);
866 video.frame().frame();
867 assert.equal(c.frames.get('p1'), 1);
868 assert.equal(c.frames.get('p2'), 2);
869});
870
871test('a browser without requestVideoFrameCallback counts nothing here', () => {
872 // pollFrames is that browser's counter, and arming this one would be a
873 // second one racing it.
874 const frames = new Map();
875 const { watchFrames } = framesBox(frames, false, Date);
876 const video = fakeVideo(fakeStream('s1'));
877 watchFrames('p1', video);
878 assert.equal(video.pending, null);
879 assert.equal(frames.size, 0);
880});
881
882// ---------------------------------------------------------------
883// Sending a screen: sharePlan, cameraCap, tuneSender, shareSendReport
884//
885// The native app's ScreenSendPolicy is sharePlan's twin (its tests are
886// native/apple/Tests/CallsKitTests/ScreenSendTests.swift), and the two
887// are meant to agree number for number.
888// ---------------------------------------------------------------
889
890const sharePlan = build('sharePlan');
891const cameraCap = build('cameraCap');
892const tuneSender = build('tuneSender');
893const shareSendReport = build('shareSendReport');
894
895test('the default is balanced, and every mode is offered by name', () => {
896 const m = /const SHARE_MODES = \[([\s\S]*?)\];/.exec(src);
897 assert.notEqual(m, null, 'SHARE_MODES is not in room.js any more');
898 const modes = [...m[1].matchAll(/\['(\w+)', '([^']+)'\]/g)].map((x) => [x[1], x[2]]);
899 assert.deepEqual(modes, [
900 ['balanced', 'Balanced'], ['detail', 'Text and detail'], ['motion', 'Motion and video'],
901 ]);
902});
903
904test('detail keeps every pixel, at the highest ceiling', () => {
905 const p = sharePlan('detail', 1920, 1080);
906 assert.equal(p.contentHint, 'detail');
907 assert.equal(p.degradationPreference, 'maintain-resolution');
908 assert.equal(p.encoding.maxBitrate, 3500000);
909 assert.equal(p.encoding.scaleResolutionDownBy, 1);
910 assert.equal(p.encoding.maxFramerate, 30);
911 assert.equal(p.encoding.priority, 'high');
912 assert.equal(p.encoding.networkPriority, 'high');
913});
914
915test('motion keeps the frame rate at 1280 on the long side', () => {
916 const p = sharePlan('motion', 1920, 1080);
917 assert.equal(p.contentHint, 'motion');
918 assert.equal(p.degradationPreference, 'maintain-framerate');
919 assert.equal(p.encoding.scaleResolutionDownBy, 1.5);
920 assert.equal(p.encoding.maxFramerate, 30);
921 assert.equal(p.encoding.priority, 'high');
922 assert.equal(sharePlan('motion', 1000, 1600).encoding.scaleResolutionDownBy, 1.25, 'portrait, by its long side');
923 assert.equal(sharePlan('motion', 1280, 720).encoding.scaleResolutionDownBy, 1);
924});
925
926test('balanced holds the frame rate from full size, and anything unknown is balanced', () => {
927 const b = sharePlan('balanced', 1920, 1080);
928 assert.equal(b.contentHint, 'detail', 'still encoded as screen content');
929 assert.equal(b.degradationPreference, 'maintain-framerate');
930 assert.equal(b.encoding.maxBitrate, 3000000);
931 assert.equal(b.encoding.scaleResolutionDownBy, 1, 'full size until the link says otherwise');
932 assert.equal(b.encoding.priority, 'high');
933 assert.deepEqual(sharePlan('nonsense', 1920, 1080), b);
934 assert.deepEqual(sharePlan(undefined, 1920, 1080), b);
935});
936
937test('a screen larger than 1920 is never sent larger, in any mode', () => {
938 for (const mode of ['balanced', 'detail', 'motion']) {
939 assert.ok(sharePlan(mode, 3840, 2160).encoding.scaleResolutionDownBy >= 2, mode);
940 }
941});
942
943test('an unknown size leaves the scale alone', () => {
944 for (const mode of ['balanced', 'detail', 'motion']) {
945 assert.equal(sharePlan(mode).encoding.scaleResolutionDownBy, 1, mode);
946 }
947});
948
949test('the camera is capped while sharing and given back after', () => {
950 assert.deepEqual(cameraCap(true, 720), { maxBitrate: 500000, scaleResolutionDownBy: 2 });
951 assert.deepEqual(cameraCap(true, 360), { maxBitrate: 500000, scaleResolutionDownBy: 1 });
952 assert.deepEqual(cameraCap(false, 720), { maxBitrate: undefined, scaleResolutionDownBy: 1 });
953});
954
955// A sender that keeps what it was given, as Chromium's does.
956function fakeSender(encodings = [{ active: true }]) {
957 const sender = {
958 params: { encodings, degradationPreference: undefined },
959 sets: 0,
960 getParameters() { return JSON.parse(JSON.stringify(this.params)); },
961 async setParameters(p) { this.sets += 1; this.params = p; },
962 };
963 return sender;
964}
965
966test('a plan reaches the sender, and giving the camera back removes the cap', async () => {
967 const camera = fakeSender();
968 tuneSender(camera, cameraCap(true, 720));
969 await new Promise((r) => setTimeout(r, 0));
970 assert.equal(camera.params.encodings[0].maxBitrate, 500000);
971 assert.equal(camera.params.encodings[0].scaleResolutionDownBy, 2);
972 tuneSender(camera, cameraCap(false, 720));
973 await new Promise((r) => setTimeout(r, 0));
974 assert.equal('maxBitrate' in camera.params.encodings[0], false, 'undefined deletes, never zero');
975 assert.equal(camera.params.encodings[0].scaleResolutionDownBy, 1);
976 assert.equal(camera.params.encodings[0].active, true, 'the rest of the encoding is left alone');
977
978 const screen = fakeSender();
979 const plan = sharePlan('motion', 1920, 1080);
980 tuneSender(screen, plan.encoding, plan.degradationPreference);
981 await new Promise((r) => setTimeout(r, 0));
982 assert.equal(screen.params.degradationPreference, 'maintain-framerate');
983 assert.equal(screen.params.encodings[0].maxBitrate, 2500000);
984 assert.equal(screen.params.encodings[0].priority, 'high');
985});
986
987test('a sender with no encodings yet, or one that refuses, costs nothing', async () => {
988 const empty = fakeSender([]);
989 tuneSender(empty, cameraCap(true, 720));
990 await new Promise((r) => setTimeout(r, 0));
991 assert.equal(empty.sets, 0);
992 const refusing = fakeSender();
993 refusing.setParameters = () => Promise.reject(new Error('InvalidModificationError'));
994 tuneSender(refusing, cameraCap(true, 720));
995 await new Promise((r) => setTimeout(r, 0));
996 tuneSender(null, cameraCap(true, 720));
997 tuneSender({}, cameraCap(true, 720));
998});
999
1000test('the screen is read off its own outbound report, by mid', () => {
1001 const reports = [
1002 { mid: '1', frameWidth: 640, frameHeight: 480, framesPerSecond: 30 },
1003 { mid: '2', frameWidth: 1920, frameHeight: 1080, framesPerSecond: 8, qualityLimitationReason: 'bandwidth',
1004 targetBitrate: 1200000, encoderImplementation: 'OpenH264', framesEncoded: 400, framesSent: 398,
1005 keyFramesEncoded: 3 },
1006 ];
1007 // framesSent and keyFramesEncoded (§15.22): what the ladder counts as
1008 // having left for this person, and the diagnostics' keyframes.
1009 assert.deepEqual(shareSendReport(reports, '2'), {
1010 width: 1920, height: 1080, fps: 8, limitation: 'bandwidth', targetBitrate: 1200000,
1011 encoder: 'OpenH264', framesEncoded: 400, framesSent: 398, keyFramesEncoded: 3,
1012 });
1013 assert.equal(shareSendReport(reports, ''), null, 'not sharing on this connection');
1014 assert.equal(shareSendReport(reports, '9'), null, 'not negotiated yet');
1015});
1016