oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1// room-lane.test.mjs — the two pieces of room.js that are about
2// *sequencing* rather than about a value, driven without a browser.
3//
4// node --test test/browser/room-lane.test.mjs
5//
6// room-share.test.mjs lifts pure functions and checks their answers.
7// These three cannot be gated that way: the broadcast lane's contract is
8// that every sender is answered exactly once however the lane ends,
9// screen share's is that one gesture takes one capture however many
10// times the button is pressed, and the chat panel's door is about what
11// is on the screen afterwards. All three are properties of what happens
12// rather than of a value, so all three are lifted whole — with their
13// free variables handed in as arguments, the way negotiation.test.mjs
14// drives `reconcile` — and run against stubs that record what happened.
15//
16// Nothing here sleeps. The lane's own deadline arithmetic is driven by
17// handing it a SEND_DEADLINE of zero, and its backoff by a `waitWithin`
18// that answers the same question the real one does without waiting.
19import { test } from 'node:test';
20import assert from 'node:assert/strict';
21import { readFileSync } from 'node:fs';
22import { fileURLToPath } from 'node:url';
23
24const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url));
25const src = readFileSync(roomJS, 'utf8');
26
27function lift(source, name) {
28 let start = source.indexOf('function ' + name + '(');
29 assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing');
30 if (source.slice(start - 6, start) === 'async ') start -= 6;
31 let depth = 0;
32 for (let i = source.indexOf('{', start); i < source.length; i++) {
33 if (source[i] === '{') depth += 1;
34 else if (source[i] === '}') {
35 depth -= 1;
36 if (depth === 0) return source.slice(start, i + 1);
37 }
38 }
39 assert.fail('unbalanced braces reading ' + name + ' out of room.js');
40}
41
42const quiet = { error() {} };
43
44// ---------------------------------------------------------------
45// The broadcast lane
46// ---------------------------------------------------------------
47
48// Everything the lane is made of, lifted together: the queue, the pump,
49// the two ways a message is answered, and the three pure judgements the
50// pump reads. Only what the lane calls *out* to is stubbed.
51const laneSource = [
52 'broadcast', 'runBroadcast', 'pumpBroadcast', 'settleBroadcast', 'drainBroadcast',
53 'broadcastBody', 'broadcastVerdict', 'accepted', 'recoveryDeadline',
54].map((name) => lift(src, name)).join('\n');
55
56const laneBox = new Function(
57 'state', 'queue', 'inflight', 'BROADCAST', 'BACKOFF_MIN', 'BACKOFF_MAX',
58 'SEND_DEADLINE', 'MIN_RECOVERY', 'postSignal', 'retryAfter', 'waitWithin',
59 'terminal', 'recoverFromNotFound', 'console',
60 laneSource + '\nreturn { broadcast };',
61);
62
63// lane builds one lane over stubs. `answers` is a list of responses, one
64// per POST, in order; the last one is repeated if the lane asks again.
65function lane(answers, opts) {
66 const o = opts || {};
67 const posted = [];
68 let live = 0;
69 let most = 0;
70 const state = { peer: 'a'.repeat(32), stopped: false };
71 const events = [];
72 const box = laneBox(
73 state,
74 new Map(),
75 new Set(),
76 '*',
77 1,
78 2,
79 o.deadline === undefined ? 30000 : o.deadline,
80 5000,
81 async (body) => {
82 posted.push(body);
83 live += 1;
84 most = Math.max(most, live);
85 await Promise.resolve();
86 live -= 1;
87 const at = Math.min(posted.length - 1, answers.length - 1);
88 return Object.assign(
89 { status: 200, data: {}, signedOut: false, retryAfter: null },
90 answers[at] || {},
91 );
92 },
93 () => 0,
94 // waitWithin, without the waiting: the real one sleeps and then
95 // answers whether there is budget left, and only the answer is what
96 // the pump branches on.
97 async (ms, deadline) => Date.now() < deadline,
98 (reason) => { events.push('terminal:' + reason); state.stopped = true; },
99 o.recover || (async () => { events.push('recovered'); }),
100 quiet,
101 );
102 return { broadcast: box.broadcast, posted, events, state, inFlight: () => most };
103}
104
105// settled turns a promise into its value or the word 'pending', so a
106// test can assert that a sender was answered at all — which is the whole
107// contract — without hanging when it was not.
108async function settled(p) {
109 const pending = Symbol('pending');
110 const answer = await Promise.race([
111 p,
112 new Promise((resolve) => setTimeout(() => resolve(pending), 50)),
113 ]);
114 return answer === pending ? 'pending' : answer;
115}
116
117test('the lane holds one POST in flight and sends in order', async () => {
118 const l = lane([{ status: 200 }]);
119 const answers = await Promise.all([
120 l.broadcast('chat', { text: 'one' }),
121 l.broadcast('chat', { text: 'two' }),
122 l.broadcast('chat', { text: 'three' }),
123 ]);
124 assert.deepEqual(answers, [true, true, true]);
125 assert.equal(l.inFlight(), 1, 'two POSTs were in flight at once');
126 assert.deepEqual(l.posted.map((b) => b.payload.text), ['one', 'two', 'three']);
127 // §4.3's wire shape, asserted on what actually went out rather than on
128 // the builder alone.
129 for (const body of l.posted) {
130 assert.deepEqual(Object.keys(body).sort(), ['payload', 'peer', 'type']);
131 }
132});
133
134test('a message with no budget left is answered false, and the lane carries on', async () => {
135 // SEND_DEADLINE of zero: the head's deadline is stamped and already
136 // spent, which is the arm a slow POST leaves the *next* message in.
137 const l = lane([{ status: 200 }], { deadline: 0 });
138 const first = await settled(l.broadcast('chat', { text: 'one' }));
139 const second = await settled(l.broadcast('chat', { text: 'two' }));
140 assert.equal(first, false);
141 assert.equal(second, false);
142 assert.equal(l.posted.length, 0, 'nothing is posted with no budget to post it in');
143});
144
145test('every ending answers the sender, and only a 200 answers true', async () => {
146 for (const [status, want] of [[200, true], [400, false], [413, false], [409, false]]) {
147 const l = lane([{ status }]);
148 assert.equal(await settled(l.broadcast('chat', { text: 'x' })), want, 'status ' + status);
149 }
150});
151
152test('a signed-out answer ends the call and answers everyone waiting', async () => {
153 const l = lane([{ status: 401, signedOut: true }]);
154 const a = l.broadcast('chat', { text: 'one' });
155 const b = l.broadcast('chat', { text: 'two' });
156 assert.deepEqual([await settled(a), await settled(b)], [false, false]);
157 assert.deepEqual(l.events, ['terminal:signedout']);
158});
159
160test('a sender queued during a 404 recovery that ends the session is still answered', async () => {
161 // The defect this test exists for: the 404 arm empties the lane,
162 // answers everyone in it and then awaits the diagnosis. Anything sent
163 // *during* that diagnosis lands in a fresh queue with the pump still
164 // marked in flight — so it waited for a pump that had already
165 // returned. If the diagnosis ended the session, nobody was ever going
166 // to send it, and the composer it came from never came back.
167 let during = null;
168 const l = lane([{ status: 404, data: { boot: 'b1' } }], {
169 recover: async () => {
170 during = l.broadcast('chat', { text: 'typed while the tab was being diagnosed' });
171 l.state.stopped = true;
172 },
173 });
174 const first = l.broadcast('chat', { text: 'the one that got the 404' });
175 assert.equal(await settled(first), false);
176 assert.equal(await settled(during), false, 'the sender queued during the recovery was never answered');
177});
178
179test('a sender queued during a recovery that leaves the tab alive is sent', async () => {
180 // The other half: the diagnosis found nothing wrong, so the message
181 // typed during it goes out on the pump the `finally` restarts.
182 let during = null;
183 const l = lane([{ status: 404, data: { boot: 'b1' } }, { status: 200 }], {
184 recover: async () => {
185 during = l.broadcast('chat', { text: 'still here' });
186 },
187 });
188 await settled(l.broadcast('chat', { text: 'the one that got the 404' }));
189 assert.equal(await settled(during), true);
190 assert.deepEqual(l.posted.map((b) => b.payload.text),
191 ['the one that got the 404', 'still here']);
192});
193
194test('a lane that has stopped answers everything left in it', async () => {
195 const l = lane([{ status: 401, signedOut: true }]);
196 const many = [];
197 for (let i = 0; i < 5; i++) many.push(l.broadcast('chat', { text: 'm' + i }));
198 const answers = await Promise.all(many.map(settled));
199 assert.deepEqual(answers, [false, false, false, false, false]);
200});
201
202test('nothing is queued once the call is over', async () => {
203 const l = lane([{ status: 200 }]);
204 l.state.stopped = true;
205 assert.equal(await settled(l.broadcast('chat', { text: 'x' })), false);
206 assert.equal(l.posted.length, 0);
207});
208
209// ---------------------------------------------------------------
210// Screen share: one gesture, one capture
211// ---------------------------------------------------------------
212
213const shareBox = new Function(
214 'state', 'HAS_DISPLAY_MEDIA', 'navigator', 'pcs', 'addShareTo', 'removeShareFrom',
215 'renderShare', 'broadcast', 'say', 'SHARE_REFUSED',
216 'let share = null; let sharePending = false;\n' +
217 [lift(src, 'startSharing'), lift(src, 'stopSharing'), lift(src, 'stopStream')].join('\n') +
218 '\nreturn { startSharing, stopSharing, capture: () => share };',
219);
220
221function fakeTrack(id) {
222 return {
223 id,
224 kind: 'video',
225 readyState: 'live',
226 addEventListener() {},
227 stop() { this.readyState = 'ended'; },
228 };
229}
230
231function fakeStream(id) {
232 const tracks = [fakeTrack(id + '-video')];
233 return {
234 id,
235 tracks,
236 getTracks: () => tracks,
237 getVideoTracks: () => tracks.filter((t) => t.kind === 'video'),
238 };
239}
240
241// sharer builds the share half over stubs, with a picker whose answer is
242// released by hand — which is the whole point: the button is live while
243// the real one is open.
244function sharer(opts) {
245 const o = opts || {};
246 const captures = [];
247 const added = [];
248 const removed = [];
249 const sent = [];
250 const said = [];
251 const state = { stopped: false };
252 let release = null;
253 const opened = new Promise((resolve) => { release = resolve; });
254 const box = shareBox(
255 state,
256 true,
257 {
258 mediaDevices: {
259 async getDisplayMedia() {
260 const stream = fakeStream('capture-' + (captures.length + 1));
261 captures.push(stream);
262 await opened;
263 if (o.refuse) throw new Error('NotAllowedError');
264 return stream;
265 },
266 },
267 },
268 new Map([['peer1', { pc: {} }]]),
269 (entry) => added.push(entry),
270 (entry) => removed.push(entry),
271 () => {},
272 (type, payload) => { sent.push([type, payload.on]); return Promise.resolve(true); },
273 (text) => said.push(text),
274 'Screen sharing was not allowed.',
275 );
276 return Object.assign(box, { captures, added, removed, sent, said, state, release });
277}
278
279test('a second click while the picker is open takes no second capture', async () => {
280 // The defect: the picker is open for as long as a person takes, and a
281 // second click during it used to start a second capture. The peers
282 // would be watching the first, `share` would point at the second, and
283 // Off would stop only the one nobody could see.
284 const s = sharer();
285 const first = s.startSharing();
286 const second = s.startSharing();
287 s.release();
288 await first;
289 await second;
290
291 assert.equal(s.captures.length, 1, 'two clicks, two captures');
292 assert.equal(s.added.length, 1, 'the screen was added to the connection twice');
293 assert.deepEqual(s.sent, [['share', true]], 'the room was told twice that a screen went on');
294 assert.equal(s.capture().stream, s.captures[0]);
295});
296
297test('and Off then stops the capture that the peers were actually watching', async () => {
298 const s = sharer();
299 const first = s.startSharing();
300 const second = s.startSharing();
301 s.release();
302 await first;
303 await second;
304
305 s.stopSharing();
306
307 assert.deepEqual(s.sent, [['share', true], ['share', false]]);
308 assert.equal(s.removed.length, 1);
309 for (const capture of s.captures) {
310 for (const t of capture.getTracks()) {
311 assert.equal(t.readyState, 'ended', 'a capture was left running with nothing to stop it');
312 }
313 }
314 assert.equal(s.capture(), null);
315});
316
317test('a capture that arrives after the call ended is stopped, not sent', async () => {
318 // The picker was open when the meeting ended. Nothing can be
319 // negotiated and nothing can be announced, and a capture nobody stops
320 // is a screen still being read from by a tab that has given up.
321 const s = sharer();
322 const start = s.startSharing();
323 s.state.stopped = true;
324 s.release();
325 await start;
326
327 assert.equal(s.capture(), null);
328 assert.deepEqual(s.sent, [], 'a share was announced for a call that had ended');
329 assert.deepEqual(s.added, []);
330 assert.equal(s.captures[0].getTracks()[0].readyState, 'ended');
331});
332
333test('a refused picker says so and leaves the button ready to try again', async () => {
334 const s = sharer({ refuse: true });
335 const first = s.startSharing();
336 s.release();
337 await first;
338
339 assert.deepEqual(s.said, ['Screen sharing was not allowed.']);
340 assert.equal(s.capture(), null);
341 assert.deepEqual(s.sent, []);
342
343 // And the pending flag was cleared, or the button would be dead for
344 // the rest of the call.
345 const again = s.startSharing();
346 await again;
347 assert.equal(s.captures.length, 2, 'a refusal left the Share button permanently pending');
348});
349
350// ---------------------------------------------------------------
351// The chat panel's door
352// ---------------------------------------------------------------
353
354const doorBox = new Function(
355 'chatEl', 'window', 'selfName', 'sendChat', 'state',
356 'let chat = null;\n' +
357 [lift(src, 'mountChat'), lift(src, 'chatEnabled')].join('\n') +
358 '\nreturn { mountChat, chatEnabled, panel: () => chat };',
359);
360
361// A root with exactly the hooks chat.js looks for, minus whichever ones
362// a test takes away. No innerHTML anywhere, deliberately: the fake has
363// none, so anything built by assigning markup would read back empty.
364function fakeRoot(missing) {
365 const gone = new Set(missing || []);
366 return {
367 hidden: true,
368 querySelector(sel) {
369 return gone.has(sel) ? null : { sel };
370 },
371 };
372}
373
374function door(root, opts) {
375 const o = opts || {};
376 const mounted = [];
377 const enabled = [];
378 const panel = {
379 append() {},
380 setEnabled(on) { enabled.push(on); },
381 clear() {},
382 };
383 const win = o.noChat ? {} : {
384 Chat: {
385 mount(r, options) {
386 mounted.push({ root: r, name: options.name });
387 return o.noop ? null : panel;
388 },
389 },
390 };
391 const box = doorBox(
392 root,
393 win,
394 'Ann',
395 async () => true,
396 { admitted: o.admitted !== false, stopped: !!o.stopped },
397 );
398 return Object.assign(box, { mounted, enabled });
399}
400
401test('a whole panel is mounted, named and un-hidden', () => {
402 const root = fakeRoot();
403 const d = door(root);
404 assert.notEqual(d.mountChat(), null);
405 assert.equal(d.mounted.length, 1);
406 assert.equal(d.mounted[0].root, root);
407 assert.equal(d.mounted[0].name, 'Ann', 'the local echo would have no name on it');
408 assert.equal(root.hidden, false);
409 assert.deepEqual(d.enabled, [true]);
410});
411
412test('a panel chat.js could only no-op on is never un-hidden', () => {
413 // chat.js answers a root it cannot use with a working no-op rather
414 // than a throw — right for it, and not something to trust here: an
415 // un-hidden composer backed by a no-op swallows everything typed into
416 // it in silence, and every test of the panel still passes.
417 for (const hook of ['[data-chat-log]', '[data-chat-form]', '[data-chat-text]']) {
418 const root = fakeRoot([hook]);
419 const d = door(root);
420 assert.equal(d.mountChat(), null, 'mounted over a missing ' + hook);
421 assert.deepEqual(d.mounted, [], 'chat.js was called for a root it cannot use: ' + hook);
422 assert.equal(root.hidden, true, 'a panel with no ' + hook + ' was put on the screen');
423 }
424});
425
426test('no chat.js at all leaves the panel hidden', () => {
427 // The form would otherwise submit and navigate away from the call.
428 const root = fakeRoot();
429 const d = door(root, { noChat: true });
430 assert.equal(d.mountChat(), null);
431 assert.equal(root.hidden, true);
432});
433
434test('a panel is mounted once, however often admission says so', () => {
435 const root = fakeRoot();
436 const d = door(root);
437 const first = d.mountChat();
438 assert.equal(d.mountChat(), first);
439 assert.equal(d.mountChat(), first);
440 assert.equal(d.mounted.length, 1);
441});
442
443test('a panel mounted before admission starts disabled', () => {
444 // The window this exists for: a `chat` fanned out to this tab while
445 // §5.2's fetch still has its own roster waiting behind it. The message
446 // is shown, and the composer opens when the roster says it may.
447 const root = fakeRoot();
448 const d = door(root, { admitted: false });
449 d.mountChat();
450 assert.deepEqual(d.enabled, [false]);
451 d.chatEnabled(true);
452 assert.deepEqual(d.enabled, [false, true]);
453});
454
455test('chatEnabled is silent with no panel to enable', () => {
456 const d = door(fakeRoot(), { noChat: true });
457 d.chatEnabled(true);
458 d.chatEnabled(false);
459 assert.deepEqual(d.enabled, []);
460});
461
462// ---------------------------------------------------------------
463// §5.1's polite yield, and §5.4's share transceiver
464// ---------------------------------------------------------------
465//
466// Fix round 2's finding, gated where it can be: two peers sharing a
467// screen in the same moment made the polite one roll back an offer that
468// had added an m-line, and Chromium answers that by rebuilding every
469// send stream on the connection — the camera's comes back having
470// encoded nothing and never encodes again, on an m-line that goes on
471// reading sendrecv with a live track. The browser half is the gate's
472// `mutualshare`; what is here is the decision that keeps it from
473// happening: the side that would roll back gives way before it offers.
474
475// Every number these tests assert against is read out of room.js
476// rather than retyped: a test carrying a constant of its own proves its
477// own harness (the Task 4 re-review's item 8).
478function pin(name) {
479 const m = new RegExp('const ' + name + ' = (\\d+);').exec(src);
480 assert.ok(m, name + ' is not a number in room.js any more — this test is stale, not passing');
481 return Number(m[1]);
482}
483
484const POLITE_YIELD = pin('POLITE_YIELD');
485const POLITE_YIELDS = pin('POLITE_YIELDS');
486
487const NEGOTIATE_ROUNDS = pin('NEGOTIATE_ROUNDS');
488
489const NEGOTIATE_DEFERS = pin('NEGOTIATE_DEFERS');
490
491const negotiator = new Function(
492 'pcs', 'send', 'describe', 'sleep', 'state',
493 'POLITE_YIELD', 'POLITE_YIELDS', 'NEGOTIATE_ROUNDS', 'NEGOTIATE_DEFERS',
494 'setTimeout', 'console',
495 [lift(src, 'negotiate'), lift(src, 'politeYield'), lift(src, 'answeredExchange'),
496 lift(src, 'keepNegotiation')].join('\n') +
497 '\nreturn { negotiate, answeredExchange, keepNegotiation };',
498);
499
500// A connection as `negotiate` sees one, over a fake pc whose
501// setLocalDescription can be watched and whose signalingState a test
502// moves under it — which is what a remote offer arriving during the
503// yield actually looks like. `duringYield` is handed the whole box, so
504// a test can also complete an exchange while the yield sleeps, which is
505// the thing a signalingState alone cannot express: the connection is
506// back at `stable` afterwards and looks untouched.
507function negotiating(opts) {
508 const o = opts || {};
509 const sent = [];
510 const waits = [];
511 const scheduled = [];
512 const timers = [];
513 const pcs = new Map();
514 const entry = {
515 polite: !!o.polite,
516 yields: o.yields || 0,
517 negotiation: 0,
518 negotiating: false,
519 renegotiate: false,
520 negotiateTimer: 0,
521 negotiateDefers: 0,
522 makingOffer: false,
523 pc: {
524 signalingState: o.signalingState || 'stable',
525 localDescription: { type: 'offer', sdp: 'local' },
526 async setLocalDescription() {
527 this.signalingState = 'have-local-offer';
528 },
529 },
530 };
531 pcs.set('peer1', entry);
532 let box;
533 const built = negotiator(
534 pcs,
535 (to, type, payload) => sent.push([to, type, !!(payload && payload.description)]),
536 (d) => d,
537 async (ms) => {
538 waits.push(ms);
539 if (o.duringYield) o.duringYield(entry, pcs, box);
540 },
541 { stopped: !!o.stopped },
542 POLITE_YIELD,
543 POLITE_YIELDS,
544 NEGOTIATE_ROUNDS,
545 NEGOTIATE_DEFERS,
546 // The retry the rounds hand their outstanding work to. The callback
547 // is kept as well as counted, because what it does when it runs is
548 // the whole of Codex S4-12 and a test that only counts schedulings
549 // never finds out.
550 (fn, ms) => {
551 scheduled.push(ms);
552 timers.push(fn);
553 return timers.length;
554 },
555 quiet,
556 );
557 box = {
558 negotiate: built.negotiate,
559 answeredExchange: built.answeredExchange,
560 keepNegotiation: built.keepNegotiation,
561 entry,
562 pcs,
563 sent,
564 waits,
565 scheduled,
566 timers,
567 // fire runs the retry the way a timer would: the entry's own handle
568 // is cleared first, exactly as the shipped callback does.
569 fire: () => {
570 const fn = timers.shift();
571 entry.negotiateTimer = 0;
572 return fn ? fn() : undefined;
573 },
574 };
575 return box;
576}
577
578test('the impolite side offers at once, and waits for nothing', async () => {
579 const n = negotiating({ polite: false });
580 await n.negotiate('peer1', n.entry);
581 assert.deepEqual(n.waits, [], 'the impolite side gave way');
582 assert.deepEqual(n.sent, [['peer1', 'offer', true]]);
583});
584
585test('the polite side gives way first, then offers', async () => {
586 const n = negotiating({ polite: true });
587 await n.negotiate('peer1', n.entry);
588 assert.deepEqual(n.waits, [POLITE_YIELD],
589 'the polite side did not wait, or waited the wrong amount');
590 assert.deepEqual(n.sent, [['peer1', 'offer', true]]);
591 assert.equal(n.entry.yields, 0, 'the offer that went out must give the allowance back');
592});
593
594test('a remote offer arriving during the yield takes the negotiation', async () => {
595 // This is the whole fix. The offer that was already on its way is
596 // answered from a stable state; nothing collides, so nothing rolls
597 // back, so the camera keeps its encoder.
598 const n = negotiating({
599 polite: true,
600 duringYield: (entry) => { entry.pc.signalingState = 'have-remote-offer'; },
601 });
602 await n.negotiate('peer1', n.entry);
603 assert.deepEqual(n.sent, [], 'the polite side offered into a remote offer it could see');
604 assert.equal(n.entry.yields, 1, 'the yield it spent must be remembered');
605});
606
607test('giving way is bounded, however many negotiations are lost', async () => {
608 // A side that gave way and came back to find another offer waiting
609 // would give way again, and two peers renegotiating in turn would keep
610 // one of them permanently a beat behind. Nothing here completes an
611 // exchange, so nothing hands the allowance back: after POLITE_YIELDS
612 // in a row it offers and takes the ordinary collision path.
613 const n = negotiating({
614 polite: true,
615 duringYield: (entry) => { entry.pc.signalingState = 'have-remote-offer'; },
616 });
617 for (let i = 0; i < POLITE_YIELDS; i++) await n.negotiate('peer1', n.entry);
618 assert.equal(n.waits.length, POLITE_YIELDS, 'it gave way more times than the bound allows');
619 assert.deepEqual(n.sent, []);
620
621 n.entry.pc.signalingState = 'stable';
622 await n.negotiate('peer1', n.entry);
623 assert.equal(n.waits.length, POLITE_YIELDS, 'it gave way past the bound');
624 assert.deepEqual(n.sent, [['peer1', 'offer', true]]);
625});
626
627// --- the generation, the serializer, and the allowance (Codex S4-4) ---
628
629test('an exchange that completed during the yield is not offered over', async () => {
630 // The case a signalingState check cannot see. A whole remote
631 // exchange — offer in, answer out — arrives, is answered and
632 // completes while the yield sleeps, leaving the connection back at
633 // `stable` looking exactly as it did when the yield began. Offering
634 // now is a second offer for a negotiation somebody else already made,
635 // and an in-memory drive of the shipped functions produced exactly
636 // that.
637 const n = negotiating({
638 polite: true,
639 duringYield: (entry, pcs, box) => box.answeredExchange(entry),
640 });
641 await n.negotiate('peer1', n.entry);
642 assert.deepEqual(n.sent, [], 'an offer went out for a negotiation that was already answered');
643 assert.equal(n.entry.yields, 0, 'the exchange that took the turn must hand the yield back');
644});
645
646test('two negotiations while one sleeps produce one offer', async () => {
647 // `negotiationneeded` fires again while the first attempt is asleep —
648 // a remote exchange completing is exactly what fires it — and the
649 // second attempt used to skip the yield, because the first had
650 // already spent it, and then both of them offered.
651 const n = negotiating({ polite: true });
652 await Promise.all([
653 n.negotiate('peer1', n.entry),
654 n.negotiate('peer1', n.entry),
655 n.negotiate('peer1', n.entry),
656 ]);
657 assert.equal(n.waits.length, 1, 'more than one negotiation was in flight');
658 assert.deepEqual(n.sent, [['peer1', 'offer', true]]);
659});
660
661test('a negotiation asked for during an abandoned yield still happens', async () => {
662 // The browser gate found this one, and it is the serializer's own
663 // trap. In `mutualshare`, the polite side adds its screen, sleeps in
664 // its yield, and answers the other side's offer while asleep. That
665 // exchange fires `negotiationneeded` for the polite side's *own*
666 // screen — which the serializer dropped, because an attempt was in
667 // flight — and then the attempt woke, saw the generation had moved,
668 // and abandoned its offer. Nobody was left to offer the screen: the
669 // transceiver sat unassociated for the rest of the call, and the far
670 // side showed a caption with nothing under it.
671 let once = true;
672 const n = negotiating({
673 polite: true,
674 duringYield: (entry, pcs, box) => {
675 if (!once) return;
676 once = false;
677 box.answeredExchange(entry);
678 // Exactly what the browser does next, into a negotiate that is
679 // still in flight.
680 box.negotiate('peer1', entry);
681 },
682 });
683 await n.negotiate('peer1', n.entry);
684 assert.deepEqual(n.sent, [['peer1', 'offer', true]],
685 'the change asked for during the yield was never offered');
686 assert.equal(n.waits.length, 2, 'the second round gives way of its own accord');
687 assert.equal(n.entry.negotiating, false, 'the serializer must not stay latched');
688 assert.equal(n.entry.renegotiate, false);
689});
690
691test('an abandoned yield that nobody asked to repeat offers nothing', async () => {
692 // The other half: without a fresh ask, an attempt taken over by an
693 // exchange stays taken over. Otherwise every superseded yield would
694 // come back round and offer anyway, which is the extra offer the
695 // generation exists to prevent.
696 const n = negotiating({
697 polite: true,
698 duringYield: (entry, pcs, box) => box.answeredExchange(entry),
699 });
700 await n.negotiate('peer1', n.entry);
701 assert.deepEqual(n.sent, []);
702 assert.equal(n.waits.length, 1, 'it went round again with nothing to go round for');
703});
704
705test('an ask the rounds run out on is kept, not dropped', async () => {
706 // Codex S4-8, and the far end of f982bdf's fix. Let a remote exchange
707 // complete during *every* yield, each one firing a fresh
708 // `negotiationneeded`: `answeredExchange` hands the spent yield back,
709 // so every round gives way again, and the rounds run out. The shipped
710 // code then cleared the flag in its `finally` — waits 4, offers 0,
711 // renegotiate false — and the last ask, for local work that still
712 // needed an offer, was gone with nothing left to make it.
713 const n = negotiating({
714 polite: true,
715 duringYield: (entry, pcs, box) => {
716 box.answeredExchange(entry);
717 box.negotiate('peer1', entry);
718 },
719 });
720 await n.negotiate('peer1', n.entry);
721
722 assert.equal(n.waits.length, NEGOTIATE_ROUNDS, 'the bound on synchronous rounds is gone');
723 assert.deepEqual(n.sent, [], 'an offer went out into an exchange that was still moving');
724 assert.equal(n.entry.renegotiate, true, 'the outstanding ask was thrown away with the rounds');
725 assert.deepEqual(n.scheduled, [POLITE_YIELD], 'nothing was scheduled to answer it');
726 assert.equal(n.entry.negotiating, false, 'the serializer must not stay latched');
727});
728
729test('the retry does not spend the ask on a connection mid-exchange', async () => {
730 // Codex S4-12. The ask survived round exhaustion; then the timer ran
731 // while a remote exchange was in flight, `negotiate` cleared the flag
732 // at the top of its round, slept, woke to a connection that was still
733 // not stable, and walked away — pending=false, timers=0, offers=0.
734 // A return to `stable` has nothing left to collect.
735 const n = negotiating({
736 polite: true,
737 duringYield: (entry, pcs, box) => {
738 box.answeredExchange(entry);
739 box.negotiate('peer1', entry);
740 },
741 });
742 await n.negotiate('peer1', n.entry);
743 assert.equal(n.entry.renegotiate, true, 'the rounds did not keep the ask');
744 assert.equal(n.timers.length, 1);
745
746 // The exchange the retry lands in the middle of.
747 n.entry.pc.signalingState = 'have-remote-offer';
748 await n.fire();
749
750 assert.equal(n.entry.renegotiate, true, 'the retry spent the ask on an attempt that could not offer');
751 assert.deepEqual(n.sent, [], 'an offer went out into an exchange');
752 assert.equal(n.timers.length, 1, 'nothing is coming back for the ask');
753 assert.equal(n.entry.negotiateDefers, 1);
754});
755
756test('a deferred retry offers once the connection is quiet again', async () => {
757 const n = negotiating({ polite: true, yields: POLITE_YIELDS });
758 n.entry.renegotiate = true;
759 n.keepNegotiation('peer1', n.entry);
760 n.entry.pc.signalingState = 'have-remote-offer';
761 await n.fire();
762 assert.deepEqual(n.sent, []);
763
764 // The exchange completes and the connection settles.
765 n.entry.pc.signalingState = 'stable';
766 await n.fire();
767 assert.deepEqual(n.sent, [['peer1', 'offer', true]], 'the kept ask was never answered');
768 assert.equal(n.entry.renegotiate, false);
769 assert.equal(n.entry.negotiateDefers, 0, 'the deferral count must not carry into the next ask');
770});
771
772test('an exchange that outlasts the yield leaves the ask behind it', async () => {
773 // The other door onto the same defect, and the one that needs no
774 // timer: an attempt whose yield wakes into an exchange that is still
775 // in flight. Nothing about that settled this side's own reason for
776 // offering.
777 const n = negotiating({
778 polite: true,
779 duringYield: (entry) => { entry.pc.signalingState = 'have-remote-offer'; },
780 });
781 await n.negotiate('peer1', n.entry);
782 assert.deepEqual(n.sent, []);
783 assert.equal(n.entry.renegotiate, true, 'the ask went with the attempt');
784 assert.equal(n.timers.length, 1, 'and nothing was scheduled to bring it back');
785});
786
787test('an impolite side that cannot offer keeps its ask too', async () => {
788 // No yield at all on this side: `setLocalDescription` throws on a
789 // connection mid-exchange, which used to end the attempt silently.
790 const n = negotiating({ polite: false });
791 n.entry.pc.setLocalDescription = async () => {
792 throw new Error('InvalidStateError');
793 };
794 await n.negotiate('peer1', n.entry);
795 assert.deepEqual(n.sent, []);
796 assert.equal(n.entry.renegotiate, true);
797 assert.equal(n.timers.length, 1);
798});
799
800test('a retry gives up rescheduling but never gives up the ask', async () => {
801 const n = negotiating({ polite: true, yields: POLITE_YIELDS });
802 n.entry.renegotiate = true;
803 n.keepNegotiation('peer1', n.entry);
804 n.entry.pc.signalingState = 'have-remote-offer';
805 for (let i = 0; i < NEGOTIATE_DEFERS + 2; i++) {
806 if (n.timers.length) await n.fire();
807 }
808 assert.equal(n.entry.renegotiate, true, 'the ask must outlive the timer');
809 assert.equal(n.timers.length, 0, 'the timer went on waking for ever');
810 assert.equal(n.entry.negotiateDefers, NEGOTIATE_DEFERS);
811});
812
813test('a kept ask schedules one retry, not one per round', async () => {
814 // The retry is a place to pick the ask up again, not a queue: the
815 // flag is cleared at the top of every round, so a scheduled attempt
816 // that finds nothing outstanding does nothing at all.
817 const n = negotiating({
818 polite: true,
819 duringYield: (entry, pcs, box) => {
820 box.answeredExchange(entry);
821 box.negotiate('peer1', entry);
822 },
823 });
824 await n.negotiate('peer1', n.entry);
825 await n.negotiate('peer1', n.entry);
826 assert.equal(n.scheduled.length, 1, 'retries stacked up: ' + JSON.stringify(n.scheduled));
827});
828
829test('a connection that went away keeps no ask and schedules nothing', async () => {
830 const n = negotiating({
831 polite: true,
832 duringYield: (entry, pcs, box) => {
833 box.answeredExchange(entry);
834 box.negotiate('peer1', entry);
835 pcs.delete('peer1');
836 },
837 });
838 await n.negotiate('peer1', n.entry);
839 assert.deepEqual(n.scheduled, []);
840 assert.equal(n.entry.renegotiate, false);
841});
842
843test('a negotiation after a lost yield is protected again', async () => {
844 // The reset defect the Task 4 re-review carried (item 1): a yield lost
845 // to an exchange that happened to satisfy this side's own change left
846 // the allowance spent, and the next independent negotiation went
847 // straight out with no yield in front of it.
848 //
849 // Enough of them to exhaust the bound, so the bound cannot be what
850 // makes this pass: without the hand-back, `yields` reaches
851 // POLITE_YIELDS and the next negotiation goes straight out.
852 let exchange = true;
853 const n = negotiating({
854 polite: true,
855 duringYield: (entry, pcs, box) => {
856 if (exchange) box.answeredExchange(entry);
857 },
858 });
859 for (let i = 0; i < POLITE_YIELDS; i++) await n.negotiate('peer1', n.entry);
860 assert.deepEqual(n.sent, [], 'an offer went out for a negotiation somebody else made');
861 assert.equal(n.entry.yields, 0, 'every yield an exchange took must be handed back');
862
863 exchange = false;
864 await n.negotiate('peer1', n.entry);
865 assert.equal(n.waits.length, POLITE_YIELDS + 1, 'the later negotiation went out unprotected');
866 assert.deepEqual(n.sent, [['peer1', 'offer', true]]);
867});
868
869test('a connection that went away during the yield is not offered to', async () => {
870 const n = negotiating({
871 polite: true,
872 duringYield: (entry, pcs) => pcs.delete('peer1'),
873 });
874 await n.negotiate('peer1', n.entry);
875 assert.deepEqual(n.sent, []);
876});
877
878test('a call that ended during the yield is not offered to', async () => {
879 const n = negotiating({ polite: true });
880 const stopped = negotiating({ polite: true, stopped: true });
881 await stopped.negotiate('peer1', stopped.entry);
882 assert.deepEqual(stopped.sent, [], 'an offer went out after the call ended');
883 // And the same connection, with the call still up, does offer — so the
884 // assertion above is about `stopped` and not about the harness.
885 await n.negotiate('peer1', n.entry);
886 assert.deepEqual(n.sent, [['peer1', 'offer', true]]);
887});
888
889test('a connection replaced under the negotiation is not offered to', async () => {
890 const n = negotiating({ polite: false });
891 n.pcs.set('peer1', { pc: {} });
892 await n.negotiate('peer1', n.entry);
893 assert.deepEqual(n.sent, []);
894});
895
896// ---------------------------------------------------------------
897// addShareTo: only on a stable connection
898// ---------------------------------------------------------------
899
900const sharerBox = new Function(
901 'share', 'pcs', 'renderTiles', 'renderShare', 'broadcast', 'stopStream', 'tuneShare',
902 [lift(src, 'addShareTo'), lift(src, 'removeShareFrom'), lift(src, 'dropShare')].join('\n') +
903 '\nreturn { addShareTo, removeShareFrom, dropShare };',
904);
905
906function connection(signalingState) {
907 const removed = [];
908 const camera = { track: { kind: 'video', id: 'camera' }, replaceTrack() { throw new Error('the camera was touched'); } };
909 const entry = {
910 shareTx: null,
911 pc: {
912 signalingState,
913 removed,
914 added: [],
915 camera,
916 addTransceiver(track, init) {
917 const tx = { track, init, sender: { track }, stopped: false, mid: null, currentDirection: null, stop() { this.stopped = true; } };
918 this.added.push(tx);
919 return tx;
920 },
921 removeTrack(sender) { removed.push(sender); },
922 },
923 };
924 return entry;
925}
926
927function shareOver(signalingState) {
928 const display = { kind: 'video', id: 'display' };
929 const stream = { id: 'display-stream' };
930 const entry = connection(signalingState);
931 const pcs = new Map([['peer1', entry]]);
932 const rendered = [];
933 // Sender tuning (the screen's parameters, the camera capped and given
934 // back) runs after each add and each removal; it is recorded with the
935 // transceiver the entry holds at that moment.
936 const tuned = [];
937 const box = sharerBox({ stream, track: display }, pcs,
938 () => rendered.push('tiles'), () => rendered.push('share'), () => {}, () => {},
939 (e) => tuned.push(e.shareTx));
940 return Object.assign(box, { entry, display, stream, rendered, tuned });
941}
942
943test('a screen is added to a stable connection', () => {
944 const s = shareOver('stable');
945 s.addShareTo(s.entry);
946 assert.equal(s.entry.pc.added.length, 1);
947 assert.equal(s.entry.pc.added[0].track, s.display);
948 assert.deepEqual(s.entry.pc.added[0].init, { direction: 'sendonly', streams: [s.stream] });
949 assert.equal(s.entry.shareTx, s.entry.pc.added[0]);
950 assert.deepEqual(s.tuned, [s.entry.shareTx], 'the new screen is tuned once it exists');
951});
952
953test('a screen is not added to a connection that is mid-negotiation', () => {
954 // An unassociated sendonly transceiver is exactly what a remote
955 // offer's m-line matching claims on a rollback, and a rollback is what
956 // costs this connection its camera. The connection's own
957 // signalingstatechange adds it at the next stable moment instead.
958 for (const busy of ['have-local-offer', 'have-remote-offer', 'closed']) {
959 const s = shareOver(busy);
960 s.addShareTo(s.entry);
961 assert.deepEqual(s.entry.pc.added, [], 'a screen was added during ' + busy);
962 assert.equal(s.entry.shareTx, null);
963 }
964});
965
966test('a connection that already carries the screen is left alone', () => {
967 const s = shareOver('stable');
968 s.addShareTo(s.entry);
969 s.addShareTo(s.entry);
970 assert.equal(s.entry.pc.added.length, 1);
971});
972
973// ---------------------------------------------------------------
974// Stopping a screen never reaches the camera
975// ---------------------------------------------------------------
976
977test('stopping a screen stops its own transceiver and nothing else', () => {
978 const s = shareOver('stable');
979 s.addShareTo(s.entry);
980 const tx = s.entry.shareTx;
981
982 s.removeShareFrom(s.entry);
983
984 assert.equal(tx.stopped, true, 'the screen m-line was not stopped');
985 assert.equal(s.entry.shareTx, null);
986 // The camera is the thing this must never touch: fix round 2 chased a
987 // camera that stopped encoding, and `removeTrack` on the wrong sender
988 // is one of the two ways this file could have done that itself. The
989 // fake camera sender throws from replaceTrack, so reaching it at all
990 // fails loudly rather than quietly.
991 assert.deepEqual(s.entry.pc.removed, [], 'a sender was removed from the connection');
992 assert.equal(s.entry.pc.camera.track.id, 'camera'); // Retuned with no screen on it, which is what gives the camera its
993 // own defaults back (cameraCap(false) in room-share.test.mjs).
994 assert.deepEqual(s.tuned, [tx, null], 'the camera was not given back its defaults');
995});
996
997test('the fallback is only for a transceiver that cannot be stopped', () => {
998 // A browser whose transceivers have no stop(): the sender is nulled
999 // and removed instead. Still the share's own sender, never the
1000 // camera's.
1001 const s = shareOver('stable');
1002 s.addShareTo(s.entry);
1003 const tx = s.entry.shareTx;
1004 delete tx.stop;
1005 let replaced = 'not called';
1006 tx.sender.replaceTrack = (t) => { replaced = t; return Promise.resolve(); };
1007
1008 s.removeShareFrom(s.entry);
1009
1010 assert.equal(replaced, null, 'the share sender was not nulled');
1011 assert.deepEqual(s.entry.pc.removed, [tx.sender]);
1012 assert.notEqual(s.entry.pc.removed[0], s.entry.pc.camera);
1013});
1014
1015test('a connection with no screen on it is not renegotiated at all', () => {
1016 const s = shareOver('stable');
1017 s.removeShareFrom(s.entry);
1018 assert.deepEqual(s.entry.pc.removed, []);
1019 assert.deepEqual(s.entry.pc.added, []);
1020});
1021
1022// ---------------------------------------------------------------
1023// dropShare
1024// ---------------------------------------------------------------
1025
1026test('dropping a screen clears the label, the media and the mid', () => {
1027 const s = shareOver('stable');
1028 const entry = { sharing: true, share: { stream: {}, track: {} }, shareMid: '2' };
1029 s.dropShare(entry);
1030 assert.deepEqual(entry, { sharing: false, share: null, shareMid: '' });
1031});
1032
1033test('dropping a screen twice is the same as dropping it once', () => {
1034 // §5.4: the track's `ended` and `share {on:false}` both arrive, in
1035 // either order, and whichever is second must be a no-op — not a second
1036 // render, and not an exception.
1037 const s = shareOver('stable');
1038 const entry = { sharing: true, share: { stream: {}, track: {} }, shareMid: '2' };
1039 s.dropShare(entry);
1040 s.dropShare(entry);
1041 s.dropShare({ sharing: false, share: null, shareMid: '' });
1042 assert.deepEqual(entry, { sharing: false, share: null, shareMid: '' });
1043 // Once, not three times: the grid is redrawn by the ending that
1044 // actually ends something, and a screen removed twice must not make
1045 // the tiles jump twice.
1046 assert.deepEqual(s.rendered, ['tiles']);
1047});
1048
1049test('a screen announced but never arrived is still dropped', () => {
1050 // `share {on:true}` drew the tile and the track never came: the
1051 // `share {on:false}` has to take the tile away all the same.
1052 const s = shareOver('stable');
1053 const entry = { sharing: true, share: null, shareMid: '' };
1054 s.dropShare(entry);
1055 assert.equal(entry.sharing, false);
1056});
1057
1058test('the numbers these tests assert against are the ones room.js ships', () => {
1059 // Everything above reads its constants out of the source, which is
1060 // what keeps a test from proving its own harness — but a value read
1061 // from the source and then handed straight back in is still no gate on
1062 // the value itself: POLITE_YIELD set to zero would leave every test
1063 // above green (the whole-branch review's W4-3). Spec §15.16 says 300
1064 // ms, so that is what this pins.
1065 assert.equal(POLITE_YIELD, 300, 'spec §15.16 names 300 ms');
1066 assert.ok(POLITE_YIELDS >= 1, 'the polite side must be allowed to give way at least once');
1067 assert.ok(CAMERA_STALL_POLLS >= 2, 'one poll is a sample, not a stall');
1068 assert.ok(CAMERA_REPAIRS >= 1, 'a camera must be given at least one repair before a reset');
1069});
1070
1071// ---------------------------------------------------------------
1072// The render watch (Codex round 2, the branch after S4-9)
1073// ---------------------------------------------------------------
1074//
1075// The last link in the chain, and the one nothing else in room.js can
1076// see: an element holding exactly the right track — live, unmuted, the
1077// one the connection receives the camera on — while the decoder
1078// delivers frames it never paints. `meet.media()` settles that this is
1079// not a binding confusion: `agrees: true`, and the picture still does
1080// not move.
1081
1082const RENDER_STALL_POLLS = pin('RENDER_STALL_POLLS');
1083const RENDER_REPLAYS = pin('RENDER_REPLAYS');
1084const RENDER_MOVED = Number(/const RENDER_MOVED = ([\d.]+);/.exec(src)[1]);
1085
1086const renderBox = new Function(
1087 'RENDER_STALL_POLLS', 'RENDER_REPLAYS', 'RENDER_MOVED',
1088 lift(src, 'judgeRender') + '\nreturn { judgeRender };',
1089);
1090const render = renderBox(RENDER_STALL_POLLS, RENDER_REPLAYS, RENDER_MOVED);
1091
1092// A tile as judgeRender sees one: an element with a clock, and the
1093// track it is bound to.
1094function fakeTile(opts) {
1095 const o = opts || {};
1096 return {
1097 video: { currentTime: 0, paused: !!o.paused },
1098 stream: {},
1099 track: { readyState: o.readyState || 'live', muted: !!o.muted },
1100 renderTime: -1,
1101 renderDecoded: -1,
1102 renderStalls: 0,
1103 renderReplays: 0,
1104 };
1105}
1106
1107// paint drives a series of polls: each entry is [decodedTotal,
1108// currentTime], which is exactly the pair of numbers the watch
1109// compares.
1110function paint(t, polls) {
1111 return polls.map(([decoded, time]) => {
1112 t.video.currentTime = time;
1113 return render.judgeRender(t, decoded);
1114 });
1115}
1116
1117const play = new Function(lift(src, 'shouldPlay') + '\nreturn shouldPlay;')();
1118
1119test('a tile is not played behind the Play control the person was offered', () => {
1120 // §5 offers a Play control when the browser's autoplay policy refuses
1121 // a remote video, and the gesture is then the person's to give. A
1122 // tile can be bound more than once — audio arriving before the camera
1123 // does exactly that — and calling play() again behind the control
1124 // hides a question nobody has answered. The browser gate's
1125 // `playfallback` failed twice before this rule existed.
1126 const offered = { stream: {}, play: { hidden: false } };
1127 assert.equal(play(offered, false), false);
1128
1129 // Every other case is ordinary: a tile with media and no control
1130 // showing is played, a control that has been answered is hidden
1131 // again, and the local preview is never this file's to start.
1132 assert.equal(play({ stream: {}, play: { hidden: true } }, false), true);
1133 assert.equal(play({ stream: {}, play: null }, false), true);
1134 assert.equal(play({ stream: null, play: null }, false), false);
1135 assert.equal(play({ stream: {}, play: { hidden: true } }, true), false);
1136});
1137
1138test('a tile that paints what it decodes is never touched', () => {
1139 const t = fakeTile();
1140 const said = paint(t, [[10, 0.1], [20, 0.4], [30, 0.7], [40, 1.0], [50, 1.3]]);
1141 assert.deepEqual(said, ['', '', '', '', '']);
1142});
1143
1144test('a tile receiving nothing is idle, not stalled', () => {
1145 // A frozen picture on a stream that has stopped arriving is the
1146 // sender's business, and the camera watch's — not this one's.
1147 const t = fakeTile();
1148 const said = paint(t, [[10, 0.5], [10, 0.5], [10, 0.5], [10, 0.5], [10, 0.5], [10, 0.5]]);
1149 assert.deepEqual(said, ['', '', '', '', '', '']);
1150 assert.equal(t.renderReplays, 0);
1151});
1152
1153test('a tile that decodes and does not paint is replayed, then reset', () => {
1154 // The failure `laterolls` reproduces: frames arriving, clock stopped.
1155 const t = fakeTile();
1156 const said = paint(t, [[10, 1.5], [20, 1.5], [30, 1.5], [40, 1.5], [50, 1.5],
1157 [60, 1.5], [70, 1.5], [80, 1.5], [90, 1.5], [100, 1.5], [110, 1.5], [120, 1.5]]);
1158 assert.equal(said.filter((v) => v === 'replay').length, RENDER_REPLAYS, JSON.stringify(said));
1159 assert.equal(said.filter((v) => v === 'reset').length, 1, JSON.stringify(said));
1160 assert.ok(said.lastIndexOf('replay') < said.indexOf('reset'), JSON.stringify(said));
1161 // And then it stops asking: a reset that did not take is not a reason
1162 // to ask for another one every three seconds.
1163 assert.deepEqual(paint(t, [[130, 1.5], [140, 1.5], [150, 1.5], [160, 1.5]]), ['', '', '', '']);
1164});
1165
1166test('a tile painting one frame every few seconds is stalling, not playing', () => {
1167 // The browser gate's own numbers: rendered totals of [22,23,23,23,23,25]
1168 // across three seconds. Reading any advance at all as health let a
1169 // tile like this reset the budget for ever and never earn the reset
1170 // that would rebuild its connection. A second of video is a second of
1171 // clock; a frame is thirty milliseconds.
1172 const t = fakeTile();
1173 const said = paint(t, [[10, 1.50], [20, 1.53], [30, 1.56], [40, 1.59],
1174 [50, 1.62], [60, 1.65], [70, 1.68], [80, 1.71], [90, 1.74], [100, 1.77],
1175 [110, 1.80], [120, 1.83]]);
1176 assert.equal(said.filter((v) => v === 'repair' || v === 'replay').length, RENDER_REPLAYS,
1177 JSON.stringify(said));
1178 assert.equal(said.filter((v) => v === 'reset').length, 1, JSON.stringify(said));
1179});
1180
1181test('a clock that goes backwards is a re-attach, not a stall', () => {
1182 // What this watch's own repair does to the element it repairs.
1183 const t = fakeTile();
1184 paint(t, [[10, 1.5], [20, 1.5]]);
1185 assert.equal(t.renderStalls, 1);
1186 assert.equal(render.judgeRender(t, 30), '', 'the clock is where it was');
1187 t.video.currentTime = 0;
1188 assert.equal(render.judgeRender(t, 40), '');
1189 assert.equal(t.renderStalls, 0, 'a re-attached element was judged on its old clock');
1190});
1191
1192test('a tile that starts painting again forgets the replay it needed', () => {
1193 const t = fakeTile();
1194 paint(t, [[10, 1.5], [20, 1.5], [30, 1.5], [40, 1.5]]);
1195 assert.equal(t.renderReplays, 1, 'the stall was not noticed at all');
1196 paint(t, [[50, 1.8], [60, 2.1]]);
1197 assert.equal(t.renderReplays, 0, 'a picture that moves again must clear the budget');
1198});
1199
1200test('an element the autoplay policy paused is left to the Play control', () => {
1201 // The browser gate taught this one. A refused autoplay leaves the
1202 // element paused with its clock stopped while frames arrive, which is
1203 // this watch's exact signature and none of its business: §5's answer
1204 // is the Play control in the tile's caption, and a watch that called
1205 // play() again would take the decision away from the person and hide
1206 // the button that says so.
1207 const t = fakeTile({ paused: true });
1208 const said = paint(t, [[10, 0], [20, 0], [30, 0], [40, 0], [50, 0], [60, 0]]);
1209 assert.deepEqual(said, ['', '', '', '', '', '']);
1210 assert.equal(t.renderReplays, 0);
1211});
1212
1213test('a muted or ended track is nobody here to repair', () => {
1214 for (const t of [fakeTile({ muted: true }), fakeTile({ readyState: 'ended' })]) {
1215 const said = paint(t, [[10, 1.5], [20, 1.5], [30, 1.5], [40, 1.5], [50, 1.5]]);
1216 assert.deepEqual(said, ['', '', '', '', '']);
1217 }
1218 // And a tile with no stats to read is left where it is.
1219 const t = fakeTile();
1220 assert.equal(render.judgeRender(t, undefined), '');
1221 assert.equal(t.renderStalls, 0);
1222});
1223
1224// ---------------------------------------------------------------
1225// The camera watch (Codex S4-5)
1226// ---------------------------------------------------------------
1227//
1228// POLITE_YIELD prevents the rollback that costs a camera its encoder,
1229// but it is a duration and not a guarantee: a signalling round trip
1230// longer than 300 ms, a 429 retry, or a slow offer can still land the
1231// remote offer late. What is left afterwards looks entirely healthy —
1232// `connected`, the m-line `sendrecv`, the track `live` — and encodes
1233// nothing at all, so no connection-state handler ever fires. The one
1234// thing that says so is framesEncoded standing still.
1235
1236const CAMERA_STALL_POLLS = pin('CAMERA_STALL_POLLS');
1237const CAMERA_REPAIRS = pin('CAMERA_REPAIRS');
1238
1239// `switching` is the room's per-kind device-switch counter, injected
1240// rather than stubbed inside the box: repairCamera reads it to find out
1241// whether a device switch took ownership of the sender while it was
1242// awaiting, and a test that could not move it could not exercise the
1243// one case where the repair must stand down.
1244const switching = { audio: 0, video: 0 };
1245
1246const cameraBox = new Function(
1247 'CAMERA_STALL_POLLS', 'CAMERA_REPAIRS', 'switching',
1248 [lift(src, 'judgeCamera'), lift(src, 'cameraReport'), lift(src, 'repairCamera')].join('\n') +
1249 '\nreturn { judgeCamera, cameraReport, repairCamera };',
1250);
1251const cam = cameraBox(CAMERA_STALL_POLLS, CAMERA_REPAIRS, switching);
1252
1253// A camera transceiver as the watch sees one, and the record of what
1254// was done to its sender.
1255function cameraTx(opts) {
1256 const o = opts || {};
1257 const calls = [];
1258 const track = { kind: 'video', readyState: o.readyState || 'live', muted: !!o.muted, enabled: o.enabled !== false };
1259 const tx = {
1260 mid: o.mid === undefined ? '1' : o.mid,
1261 currentDirection: o.direction === undefined ? 'sendrecv' : o.direction,
1262 sender: {
1263 track: o.noReplace ? track : track,
1264 replaceTrack: o.noReplace ? undefined : async (t) => {
1265 calls.push(t === null ? 'null' : 'track');
1266 },
1267 },
1268 };
1269 return { tx, calls, track };
1270}
1271
1272function watching() {
1273 return { cameraFrames: -1, cameraStalls: 0, cameraRepairs: 0 };
1274}
1275
1276// verdicts drives one entry through a sequence of framesEncoded counts
1277// and hands back what the watch said each time.
1278function verdicts(entry, tx, counts) {
1279 return counts.map((n) =>
1280 cam.judgeCamera(entry, tx, n === null ? null : { framesEncoded: n, mid: tx.mid }));
1281}
1282
1283test('a camera that keeps encoding is never touched', () => {
1284 const c = cameraTx();
1285 const entry = watching();
1286 assert.deepEqual(verdicts(entry, c.tx, [10, 20, 30, 40, 50, 60]), ['', '', '', '', '', '']);
1287 assert.equal(entry.cameraRepairs, 0);
1288});
1289
1290test('a camera that stops encoding is repaired, and then reset', () => {
1291 // The signature: the same framesEncoded poll after poll on a sending
1292 // m-line with a live track. Two polls of it is a repair; two more is
1293 // the pair being rebuilt outright.
1294 const c = cameraTx();
1295 const entry = watching();
1296 const said = verdicts(entry, c.tx, [100, 100, 100, 100, 100, 100, 100]);
1297 assert.equal(said.filter((v) => v === 'repair').length, 1, JSON.stringify(said));
1298 assert.equal(said.filter((v) => v === 'reset').length, 1, JSON.stringify(said));
1299 assert.ok(said.indexOf('repair') < said.indexOf('reset'), 'the reset came before the repair');
1300 // And then it stops asking: a reset that did not take is not a reason
1301 // to ask for another one every two seconds.
1302 assert.deepEqual(verdicts(entry, c.tx, [100, 100, 100, 100]), ['', '', '', '']);
1303});
1304
1305test('a camera that comes back forgets the repair it needed', () => {
1306 // A repair that worked must not be counted against the next stall,
1307 // hours later: the next one gets a repair of its own before anything
1308 // is reset.
1309 const c = cameraTx();
1310 const entry = watching();
1311 let said = verdicts(entry, c.tx, [100, 100, 100]);
1312 assert.equal(said[said.length - 1], 'repair');
1313 said = verdicts(entry, c.tx, [110, 120, 130]);
1314 assert.deepEqual(said, ['', '', ''], 'a camera that is encoding again was still being judged');
1315 said = verdicts(entry, c.tx, [130, 130, 130]);
1316 assert.ok(said.includes('repair') && !said.includes('reset'),
1317 'the second stall was reset rather than repaired: ' + JSON.stringify(said));
1318});
1319
1320test('a counter that goes backwards is re-baselined, not forgiven', () => {
1321 // Codex S4-10. `framesEncoded !== cameraFrames` read every change as
1322 // "encoding recovered", including the drop to zero that a stats or
1323 // encoder restart produces — so [100,100,100,0,0,0,0,0] gave repair,
1324 // repair, reset instead of one repair and then the reset, and every
1325 // further reset could hand the allowance back again without a single
1326 // new frame being encoded.
1327 const c = cameraTx();
1328 const entry = watching();
1329 const said = verdicts(entry, c.tx, [100, 100, 100, 0, 0, 0, 0, 0]);
1330 assert.equal(said.filter((v) => v === 'repair').length, 1,
1331 'the allowance was replenished by a counter going backwards: ' + JSON.stringify(said));
1332 assert.equal(said.filter((v) => v === 'reset').length, 1, JSON.stringify(said));
1333 assert.ok(said.indexOf('repair') < said.indexOf('reset'));
1334});
1335
1336test('growth after a counter reset does forgive the repair', () => {
1337 // The other half: a restart that really did come back is a camera
1338 // that is encoding again, and the next stall — an hour later — gets a
1339 // repair of its own rather than the reset this one earned.
1340 const c = cameraTx();
1341 const entry = watching();
1342 verdicts(entry, c.tx, [100, 100, 100]);
1343 assert.equal(entry.cameraRepairs, 1);
1344 verdicts(entry, c.tx, [0, 5, 10]);
1345 assert.equal(entry.cameraRepairs, 0, 'real growth after the reset must clear the budget');
1346 const said = verdicts(entry, c.tx, [10, 10, 10]);
1347 assert.ok(said.includes('repair') && !said.includes('reset'), JSON.stringify(said));
1348});
1349
1350test('nothing is repaired on a connection that is not sending a camera', () => {
1351 for (const opts of [{ direction: 'recvonly' }, { direction: 'inactive' }, { readyState: 'ended' }]) {
1352 const c = cameraTx(opts);
1353 const entry = watching();
1354 assert.deepEqual(verdicts(entry, c.tx, [100, 100, 100, 100, 100]), ['', '', '', '', ''],
1355 JSON.stringify(opts) + ' was treated as a stalled camera');
1356 assert.equal(entry.cameraStalls, 0);
1357 }
1358});
1359
1360test('a camera the system is holding is never repaired or reset', () => {
1361 // iOS mutes the camera track, live, while Safari is in the background
1362 // or the screen is locked (2026-10-03, "kept cutting in and out"): no
1363 // frames, and nothing wrong with the encoder. Nor is a camera the
1364 // person turned off. Either way, never a reset of the connection.
1365 for (const opts of [{ muted: true }, { enabled: false }]) {
1366 const c = cameraTx(opts);
1367 const entry = watching();
1368 const said = verdicts(entry, c.tx, [100, 100, 100, 100, 100, 100, 100, 100]);
1369 assert.deepEqual(said, ['', '', '', '', '', '', '', ''], JSON.stringify(opts));
1370 assert.equal(entry.cameraRepairs, 0);
1371 }
1372 // Given back, it is watched again from scratch.
1373 const c = cameraTx({ muted: true });
1374 const entry = watching();
1375 verdicts(entry, c.tx, [100, 100, 100]);
1376 c.track.muted = false;
1377 assert.deepEqual(verdicts(entry, c.tx, [100, 110, 120]), ['', '', '']);
1378});
1379
1380test('a stall is forgotten when the camera stops being watchable', () => {
1381 // Half a stall, then no report at all — a connection that stopped
1382 // sending video. The count starts again rather than tipping over into
1383 // a repair on the strength of a sample from before.
1384 const c = cameraTx();
1385 const entry = watching();
1386 verdicts(entry, c.tx, [100, 100]);
1387 assert.equal(entry.cameraStalls, 1);
1388 assert.equal(cam.judgeCamera(entry, c.tx, null), '');
1389 assert.equal(entry.cameraStalls, 0);
1390 assert.equal(entry.cameraFrames, -1);
1391});
1392
1393test('the outbound report is chosen by mid, and never guessed at', () => {
1394 const camera = { mid: '1', framesEncoded: 10 };
1395 const screen = { mid: '2', framesEncoded: 20 };
1396 assert.equal(cam.cameraReport([camera, screen], '1'), camera);
1397 assert.equal(cam.cameraReport([screen, camera], '1'), camera);
1398 // A browser that reports no mid on outbound-rtp: one report is
1399 // unambiguous, two are the camera and the screen and neither can be
1400 // told from the other.
1401 const bare = { framesEncoded: 10 };
1402 assert.equal(cam.cameraReport([bare], '1'), bare);
1403 assert.equal(cam.cameraReport([bare, { framesEncoded: 20 }], '1'), null);
1404 assert.equal(cam.cameraReport([], '1'), null);
1405 // No mid known at all: the same rule from the other side.
1406 assert.equal(cam.cameraReport([bare], ''), bare);
1407 assert.equal(cam.cameraReport([bare, { framesEncoded: 20 }], ''), null);
1408});
1409
1410test('the repair takes the track off its sender and puts it back', async () => {
1411 const c = cameraTx();
1412 await cam.repairCamera(c.tx);
1413 assert.deepEqual(c.calls, ['null', 'track'],
1414 'the encoder is rebuilt by removing the track and restoring it, in that order');
1415});
1416
1417test('a repair overtaken by a device switch leaves the new track alone', async () => {
1418 // The gap between the two replaceTrack calls is the only place a
1419 // device switch can do damage here: the track captured on the way in
1420 // is the one that switch is retiring, and putting it back would undo
1421 // the switch and leave every peer watching a track that has ended.
1422 // The stall that prompted this repair is still being watched, so
1423 // standing down costs a poll and not a camera.
1424 const c = cameraTx();
1425 const replace = c.tx.sender.replaceTrack;
1426 c.tx.sender.replaceTrack = async (t) => {
1427 const out = await replace(t);
1428 if (t === null) switching.video += 1;
1429 return out;
1430 };
1431 await cam.repairCamera(c.tx);
1432 assert.deepEqual(c.calls, ['null'], 'the repair stops once a switch owns the sender');
1433 switching.video = 0;
1434});
1435
1436test('a repair does not restore a track that has ended under it', async () => {
1437 const c = cameraTx();
1438 const replace = c.tx.sender.replaceTrack;
1439 c.tx.sender.replaceTrack = async (t) => {
1440 const out = await replace(t);
1441 if (t === null) c.track.readyState = 'ended';
1442 return out;
1443 };
1444 await cam.repairCamera(c.tx);
1445 assert.deepEqual(c.calls, ['null'], 'an ended track is not worth putting back');
1446});
1447
1448test('a sender that cannot replace its track is left alone', async () => {
1449 const c = cameraTx({ noReplace: true });
1450 await assert.doesNotReject(() => cam.repairCamera(c.tx));
1451 await assert.doesNotReject(() => cam.repairCamera(null));
1452 await assert.doesNotReject(() => cam.repairCamera({ sender: null }));
1453});
1454
1455test('lostKinds: only a kind we had, every track of it ended', () => {
1456 // The tab in front again (comeBack): iOS can end the camera and the
1457 // microphone while Safari is in the background, and those are opened
1458 // again; a kind that is merely muted, or never was, is left alone.
1459 const lostKinds = new Function(lift(src, 'lostKinds') + '; return lostKinds;')();
1460 const live = { readyState: 'live' };
1461 const ended = { readyState: 'ended' };
1462 assert.deepEqual(lostKinds([ended], [ended]), ['audio', 'video']);
1463 assert.deepEqual(lostKinds([live], [ended]), ['video']);
1464 assert.deepEqual(lostKinds([ended, live], []), [], 'one live track is enough, and no video is not lost video');
1465 assert.deepEqual(lostKinds([], []), []);
1466 assert.deepEqual(lostKinds(undefined, undefined), []);
1467});
1468