// room-lane.test.mjs — the two pieces of room.js that are about // *sequencing* rather than about a value, driven without a browser. // // node --test test/browser/room-lane.test.mjs // // room-share.test.mjs lifts pure functions and checks their answers. // These three cannot be gated that way: the broadcast lane's contract is // that every sender is answered exactly once however the lane ends, // screen share's is that one gesture takes one capture however many // times the button is pressed, and the chat panel's door is about what // is on the screen afterwards. All three are properties of what happens // rather than of a value, so all three are lifted whole — with their // free variables handed in as arguments, the way negotiation.test.mjs // drives `reconcile` — and run against stubs that record what happened. // // Nothing here sleeps. The lane's own deadline arithmetic is driven by // handing it a SEND_DEADLINE of zero, and its backoff by a `waitWithin` // that answers the same question the real one does without waiting. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)); const src = readFileSync(roomJS, 'utf8'); function lift(source, name) { let start = source.indexOf('function ' + name + '('); assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing'); if (source.slice(start - 6, start) === 'async ') start -= 6; let depth = 0; for (let i = source.indexOf('{', start); i < source.length; i++) { if (source[i] === '{') depth += 1; else if (source[i] === '}') { depth -= 1; if (depth === 0) return source.slice(start, i + 1); } } assert.fail('unbalanced braces reading ' + name + ' out of room.js'); } const quiet = { error() {} }; // --------------------------------------------------------------- // The broadcast lane // --------------------------------------------------------------- // Everything the lane is made of, lifted together: the queue, the pump, // the two ways a message is answered, and the three pure judgements the // pump reads. Only what the lane calls *out* to is stubbed. const laneSource = [ 'broadcast', 'runBroadcast', 'pumpBroadcast', 'settleBroadcast', 'drainBroadcast', 'broadcastBody', 'broadcastVerdict', 'accepted', 'recoveryDeadline', ].map((name) => lift(src, name)).join('\n'); const laneBox = new Function( 'state', 'queue', 'inflight', 'BROADCAST', 'BACKOFF_MIN', 'BACKOFF_MAX', 'SEND_DEADLINE', 'MIN_RECOVERY', 'postSignal', 'retryAfter', 'waitWithin', 'terminal', 'recoverFromNotFound', 'console', laneSource + '\nreturn { broadcast };', ); // lane builds one lane over stubs. `answers` is a list of responses, one // per POST, in order; the last one is repeated if the lane asks again. function lane(answers, opts) { const o = opts || {}; const posted = []; let live = 0; let most = 0; const state = { peer: 'a'.repeat(32), stopped: false }; const events = []; const box = laneBox( state, new Map(), new Set(), '*', 1, 2, o.deadline === undefined ? 30000 : o.deadline, 5000, async (body) => { posted.push(body); live += 1; most = Math.max(most, live); await Promise.resolve(); live -= 1; const at = Math.min(posted.length - 1, answers.length - 1); return Object.assign( { status: 200, data: {}, signedOut: false, retryAfter: null }, answers[at] || {}, ); }, () => 0, // waitWithin, without the waiting: the real one sleeps and then // answers whether there is budget left, and only the answer is what // the pump branches on. async (ms, deadline) => Date.now() < deadline, (reason) => { events.push('terminal:' + reason); state.stopped = true; }, o.recover || (async () => { events.push('recovered'); }), quiet, ); return { broadcast: box.broadcast, posted, events, state, inFlight: () => most }; } // settled turns a promise into its value or the word 'pending', so a // test can assert that a sender was answered at all — which is the whole // contract — without hanging when it was not. async function settled(p) { const pending = Symbol('pending'); const answer = await Promise.race([ p, new Promise((resolve) => setTimeout(() => resolve(pending), 50)), ]); return answer === pending ? 'pending' : answer; } test('the lane holds one POST in flight and sends in order', async () => { const l = lane([{ status: 200 }]); const answers = await Promise.all([ l.broadcast('chat', { text: 'one' }), l.broadcast('chat', { text: 'two' }), l.broadcast('chat', { text: 'three' }), ]); assert.deepEqual(answers, [true, true, true]); assert.equal(l.inFlight(), 1, 'two POSTs were in flight at once'); assert.deepEqual(l.posted.map((b) => b.payload.text), ['one', 'two', 'three']); // §4.3's wire shape, asserted on what actually went out rather than on // the builder alone. for (const body of l.posted) { assert.deepEqual(Object.keys(body).sort(), ['payload', 'peer', 'type']); } }); test('a message with no budget left is answered false, and the lane carries on', async () => { // SEND_DEADLINE of zero: the head's deadline is stamped and already // spent, which is the arm a slow POST leaves the *next* message in. const l = lane([{ status: 200 }], { deadline: 0 }); const first = await settled(l.broadcast('chat', { text: 'one' })); const second = await settled(l.broadcast('chat', { text: 'two' })); assert.equal(first, false); assert.equal(second, false); assert.equal(l.posted.length, 0, 'nothing is posted with no budget to post it in'); }); test('every ending answers the sender, and only a 200 answers true', async () => { for (const [status, want] of [[200, true], [400, false], [413, false], [409, false]]) { const l = lane([{ status }]); assert.equal(await settled(l.broadcast('chat', { text: 'x' })), want, 'status ' + status); } }); test('a signed-out answer ends the call and answers everyone waiting', async () => { const l = lane([{ status: 401, signedOut: true }]); const a = l.broadcast('chat', { text: 'one' }); const b = l.broadcast('chat', { text: 'two' }); assert.deepEqual([await settled(a), await settled(b)], [false, false]); assert.deepEqual(l.events, ['terminal:signedout']); }); test('a sender queued during a 404 recovery that ends the session is still answered', async () => { // The defect this test exists for: the 404 arm empties the lane, // answers everyone in it and then awaits the diagnosis. Anything sent // *during* that diagnosis lands in a fresh queue with the pump still // marked in flight — so it waited for a pump that had already // returned. If the diagnosis ended the session, nobody was ever going // to send it, and the composer it came from never came back. let during = null; const l = lane([{ status: 404, data: { boot: 'b1' } }], { recover: async () => { during = l.broadcast('chat', { text: 'typed while the tab was being diagnosed' }); l.state.stopped = true; }, }); const first = l.broadcast('chat', { text: 'the one that got the 404' }); assert.equal(await settled(first), false); assert.equal(await settled(during), false, 'the sender queued during the recovery was never answered'); }); test('a sender queued during a recovery that leaves the tab alive is sent', async () => { // The other half: the diagnosis found nothing wrong, so the message // typed during it goes out on the pump the `finally` restarts. let during = null; const l = lane([{ status: 404, data: { boot: 'b1' } }, { status: 200 }], { recover: async () => { during = l.broadcast('chat', { text: 'still here' }); }, }); await settled(l.broadcast('chat', { text: 'the one that got the 404' })); assert.equal(await settled(during), true); assert.deepEqual(l.posted.map((b) => b.payload.text), ['the one that got the 404', 'still here']); }); test('a lane that has stopped answers everything left in it', async () => { const l = lane([{ status: 401, signedOut: true }]); const many = []; for (let i = 0; i < 5; i++) many.push(l.broadcast('chat', { text: 'm' + i })); const answers = await Promise.all(many.map(settled)); assert.deepEqual(answers, [false, false, false, false, false]); }); test('nothing is queued once the call is over', async () => { const l = lane([{ status: 200 }]); l.state.stopped = true; assert.equal(await settled(l.broadcast('chat', { text: 'x' })), false); assert.equal(l.posted.length, 0); }); // --------------------------------------------------------------- // Screen share: one gesture, one capture // --------------------------------------------------------------- const shareBox = new Function( 'state', 'HAS_DISPLAY_MEDIA', 'navigator', 'pcs', 'addShareTo', 'removeShareFrom', 'renderShare', 'broadcast', 'say', 'SHARE_REFUSED', 'let share = null; let sharePending = false;\n' + [lift(src, 'startSharing'), lift(src, 'stopSharing'), lift(src, 'stopStream')].join('\n') + '\nreturn { startSharing, stopSharing, capture: () => share };', ); function fakeTrack(id) { return { id, kind: 'video', readyState: 'live', addEventListener() {}, stop() { this.readyState = 'ended'; }, }; } function fakeStream(id) { const tracks = [fakeTrack(id + '-video')]; return { id, tracks, getTracks: () => tracks, getVideoTracks: () => tracks.filter((t) => t.kind === 'video'), }; } // sharer builds the share half over stubs, with a picker whose answer is // released by hand — which is the whole point: the button is live while // the real one is open. function sharer(opts) { const o = opts || {}; const captures = []; const added = []; const removed = []; const sent = []; const said = []; const state = { stopped: false }; let release = null; const opened = new Promise((resolve) => { release = resolve; }); const box = shareBox( state, true, { mediaDevices: { async getDisplayMedia() { const stream = fakeStream('capture-' + (captures.length + 1)); captures.push(stream); await opened; if (o.refuse) throw new Error('NotAllowedError'); return stream; }, }, }, new Map([['peer1', { pc: {} }]]), (entry) => added.push(entry), (entry) => removed.push(entry), () => {}, (type, payload) => { sent.push([type, payload.on]); return Promise.resolve(true); }, (text) => said.push(text), 'Screen sharing was not allowed.', ); return Object.assign(box, { captures, added, removed, sent, said, state, release }); } test('a second click while the picker is open takes no second capture', async () => { // The defect: the picker is open for as long as a person takes, and a // second click during it used to start a second capture. The peers // would be watching the first, `share` would point at the second, and // Off would stop only the one nobody could see. const s = sharer(); const first = s.startSharing(); const second = s.startSharing(); s.release(); await first; await second; assert.equal(s.captures.length, 1, 'two clicks, two captures'); assert.equal(s.added.length, 1, 'the screen was added to the connection twice'); assert.deepEqual(s.sent, [['share', true]], 'the room was told twice that a screen went on'); assert.equal(s.capture().stream, s.captures[0]); }); test('and Off then stops the capture that the peers were actually watching', async () => { const s = sharer(); const first = s.startSharing(); const second = s.startSharing(); s.release(); await first; await second; s.stopSharing(); assert.deepEqual(s.sent, [['share', true], ['share', false]]); assert.equal(s.removed.length, 1); for (const capture of s.captures) { for (const t of capture.getTracks()) { assert.equal(t.readyState, 'ended', 'a capture was left running with nothing to stop it'); } } assert.equal(s.capture(), null); }); test('a capture that arrives after the call ended is stopped, not sent', async () => { // The picker was open when the meeting ended. Nothing can be // negotiated and nothing can be announced, and a capture nobody stops // is a screen still being read from by a tab that has given up. const s = sharer(); const start = s.startSharing(); s.state.stopped = true; s.release(); await start; assert.equal(s.capture(), null); assert.deepEqual(s.sent, [], 'a share was announced for a call that had ended'); assert.deepEqual(s.added, []); assert.equal(s.captures[0].getTracks()[0].readyState, 'ended'); }); test('a refused picker says so and leaves the button ready to try again', async () => { const s = sharer({ refuse: true }); const first = s.startSharing(); s.release(); await first; assert.deepEqual(s.said, ['Screen sharing was not allowed.']); assert.equal(s.capture(), null); assert.deepEqual(s.sent, []); // And the pending flag was cleared, or the button would be dead for // the rest of the call. const again = s.startSharing(); await again; assert.equal(s.captures.length, 2, 'a refusal left the Share button permanently pending'); }); // --------------------------------------------------------------- // The chat panel's door // --------------------------------------------------------------- const doorBox = new Function( 'chatEl', 'window', 'selfName', 'sendChat', 'state', 'let chat = null;\n' + [lift(src, 'mountChat'), lift(src, 'chatEnabled')].join('\n') + '\nreturn { mountChat, chatEnabled, panel: () => chat };', ); // A root with exactly the hooks chat.js looks for, minus whichever ones // a test takes away. No innerHTML anywhere, deliberately: the fake has // none, so anything built by assigning markup would read back empty. function fakeRoot(missing) { const gone = new Set(missing || []); return { hidden: true, querySelector(sel) { return gone.has(sel) ? null : { sel }; }, }; } function door(root, opts) { const o = opts || {}; const mounted = []; const enabled = []; const panel = { append() {}, setEnabled(on) { enabled.push(on); }, clear() {}, }; const win = o.noChat ? {} : { Chat: { mount(r, options) { mounted.push({ root: r, name: options.name }); return o.noop ? null : panel; }, }, }; const box = doorBox( root, win, 'Ann', async () => true, { admitted: o.admitted !== false, stopped: !!o.stopped }, ); return Object.assign(box, { mounted, enabled }); } test('a whole panel is mounted, named and un-hidden', () => { const root = fakeRoot(); const d = door(root); assert.notEqual(d.mountChat(), null); assert.equal(d.mounted.length, 1); assert.equal(d.mounted[0].root, root); assert.equal(d.mounted[0].name, 'Ann', 'the local echo would have no name on it'); assert.equal(root.hidden, false); assert.deepEqual(d.enabled, [true]); }); test('a panel chat.js could only no-op on is never un-hidden', () => { // chat.js answers a root it cannot use with a working no-op rather // than a throw — right for it, and not something to trust here: an // un-hidden composer backed by a no-op swallows everything typed into // it in silence, and every test of the panel still passes. for (const hook of ['[data-chat-log]', '[data-chat-form]', '[data-chat-text]']) { const root = fakeRoot([hook]); const d = door(root); assert.equal(d.mountChat(), null, 'mounted over a missing ' + hook); assert.deepEqual(d.mounted, [], 'chat.js was called for a root it cannot use: ' + hook); assert.equal(root.hidden, true, 'a panel with no ' + hook + ' was put on the screen'); } }); test('no chat.js at all leaves the panel hidden', () => { // The form would otherwise submit and navigate away from the call. const root = fakeRoot(); const d = door(root, { noChat: true }); assert.equal(d.mountChat(), null); assert.equal(root.hidden, true); }); test('a panel is mounted once, however often admission says so', () => { const root = fakeRoot(); const d = door(root); const first = d.mountChat(); assert.equal(d.mountChat(), first); assert.equal(d.mountChat(), first); assert.equal(d.mounted.length, 1); }); test('a panel mounted before admission starts disabled', () => { // The window this exists for: a `chat` fanned out to this tab while // §5.2's fetch still has its own roster waiting behind it. The message // is shown, and the composer opens when the roster says it may. const root = fakeRoot(); const d = door(root, { admitted: false }); d.mountChat(); assert.deepEqual(d.enabled, [false]); d.chatEnabled(true); assert.deepEqual(d.enabled, [false, true]); }); test('chatEnabled is silent with no panel to enable', () => { const d = door(fakeRoot(), { noChat: true }); d.chatEnabled(true); d.chatEnabled(false); assert.deepEqual(d.enabled, []); }); // --------------------------------------------------------------- // §5.1's polite yield, and §5.4's share transceiver // --------------------------------------------------------------- // // Fix round 2's finding, gated where it can be: two peers sharing a // screen in the same moment made the polite one roll back an offer that // had added an m-line, and Chromium answers that by rebuilding every // send stream on the connection — the camera's comes back having // encoded nothing and never encodes again, on an m-line that goes on // reading sendrecv with a live track. The browser half is the gate's // `mutualshare`; what is here is the decision that keeps it from // happening: the side that would roll back gives way before it offers. // Every number these tests assert against is read out of room.js // rather than retyped: a test carrying a constant of its own proves its // own harness (the Task 4 re-review's item 8). function pin(name) { const m = new RegExp('const ' + name + ' = (\\d+);').exec(src); assert.ok(m, name + ' is not a number in room.js any more — this test is stale, not passing'); return Number(m[1]); } const POLITE_YIELD = pin('POLITE_YIELD'); const POLITE_YIELDS = pin('POLITE_YIELDS'); const NEGOTIATE_ROUNDS = pin('NEGOTIATE_ROUNDS'); const NEGOTIATE_DEFERS = pin('NEGOTIATE_DEFERS'); const negotiator = new Function( 'pcs', 'send', 'describe', 'sleep', 'state', 'POLITE_YIELD', 'POLITE_YIELDS', 'NEGOTIATE_ROUNDS', 'NEGOTIATE_DEFERS', 'setTimeout', 'console', [lift(src, 'negotiate'), lift(src, 'politeYield'), lift(src, 'answeredExchange'), lift(src, 'keepNegotiation')].join('\n') + '\nreturn { negotiate, answeredExchange, keepNegotiation };', ); // A connection as `negotiate` sees one, over a fake pc whose // setLocalDescription can be watched and whose signalingState a test // moves under it — which is what a remote offer arriving during the // yield actually looks like. `duringYield` is handed the whole box, so // a test can also complete an exchange while the yield sleeps, which is // the thing a signalingState alone cannot express: the connection is // back at `stable` afterwards and looks untouched. function negotiating(opts) { const o = opts || {}; const sent = []; const waits = []; const scheduled = []; const timers = []; const pcs = new Map(); const entry = { polite: !!o.polite, yields: o.yields || 0, negotiation: 0, negotiating: false, renegotiate: false, negotiateTimer: 0, negotiateDefers: 0, makingOffer: false, pc: { signalingState: o.signalingState || 'stable', localDescription: { type: 'offer', sdp: 'local' }, async setLocalDescription() { this.signalingState = 'have-local-offer'; }, }, }; pcs.set('peer1', entry); let box; const built = negotiator( pcs, (to, type, payload) => sent.push([to, type, !!(payload && payload.description)]), (d) => d, async (ms) => { waits.push(ms); if (o.duringYield) o.duringYield(entry, pcs, box); }, { stopped: !!o.stopped }, POLITE_YIELD, POLITE_YIELDS, NEGOTIATE_ROUNDS, NEGOTIATE_DEFERS, // The retry the rounds hand their outstanding work to. The callback // is kept as well as counted, because what it does when it runs is // the whole of Codex S4-12 and a test that only counts schedulings // never finds out. (fn, ms) => { scheduled.push(ms); timers.push(fn); return timers.length; }, quiet, ); box = { negotiate: built.negotiate, answeredExchange: built.answeredExchange, keepNegotiation: built.keepNegotiation, entry, pcs, sent, waits, scheduled, timers, // fire runs the retry the way a timer would: the entry's own handle // is cleared first, exactly as the shipped callback does. fire: () => { const fn = timers.shift(); entry.negotiateTimer = 0; return fn ? fn() : undefined; }, }; return box; } test('the impolite side offers at once, and waits for nothing', async () => { const n = negotiating({ polite: false }); await n.negotiate('peer1', n.entry); assert.deepEqual(n.waits, [], 'the impolite side gave way'); assert.deepEqual(n.sent, [['peer1', 'offer', true]]); }); test('the polite side gives way first, then offers', async () => { const n = negotiating({ polite: true }); await n.negotiate('peer1', n.entry); assert.deepEqual(n.waits, [POLITE_YIELD], 'the polite side did not wait, or waited the wrong amount'); assert.deepEqual(n.sent, [['peer1', 'offer', true]]); assert.equal(n.entry.yields, 0, 'the offer that went out must give the allowance back'); }); test('a remote offer arriving during the yield takes the negotiation', async () => { // This is the whole fix. The offer that was already on its way is // answered from a stable state; nothing collides, so nothing rolls // back, so the camera keeps its encoder. const n = negotiating({ polite: true, duringYield: (entry) => { entry.pc.signalingState = 'have-remote-offer'; }, }); await n.negotiate('peer1', n.entry); assert.deepEqual(n.sent, [], 'the polite side offered into a remote offer it could see'); assert.equal(n.entry.yields, 1, 'the yield it spent must be remembered'); }); test('giving way is bounded, however many negotiations are lost', async () => { // A side that gave way and came back to find another offer waiting // would give way again, and two peers renegotiating in turn would keep // one of them permanently a beat behind. Nothing here completes an // exchange, so nothing hands the allowance back: after POLITE_YIELDS // in a row it offers and takes the ordinary collision path. const n = negotiating({ polite: true, duringYield: (entry) => { entry.pc.signalingState = 'have-remote-offer'; }, }); for (let i = 0; i < POLITE_YIELDS; i++) await n.negotiate('peer1', n.entry); assert.equal(n.waits.length, POLITE_YIELDS, 'it gave way more times than the bound allows'); assert.deepEqual(n.sent, []); n.entry.pc.signalingState = 'stable'; await n.negotiate('peer1', n.entry); assert.equal(n.waits.length, POLITE_YIELDS, 'it gave way past the bound'); assert.deepEqual(n.sent, [['peer1', 'offer', true]]); }); // --- the generation, the serializer, and the allowance (Codex S4-4) --- test('an exchange that completed during the yield is not offered over', async () => { // The case a signalingState check cannot see. A whole remote // exchange — offer in, answer out — arrives, is answered and // completes while the yield sleeps, leaving the connection back at // `stable` looking exactly as it did when the yield began. Offering // now is a second offer for a negotiation somebody else already made, // and an in-memory drive of the shipped functions produced exactly // that. const n = negotiating({ polite: true, duringYield: (entry, pcs, box) => box.answeredExchange(entry), }); await n.negotiate('peer1', n.entry); assert.deepEqual(n.sent, [], 'an offer went out for a negotiation that was already answered'); assert.equal(n.entry.yields, 0, 'the exchange that took the turn must hand the yield back'); }); test('two negotiations while one sleeps produce one offer', async () => { // `negotiationneeded` fires again while the first attempt is asleep — // a remote exchange completing is exactly what fires it — and the // second attempt used to skip the yield, because the first had // already spent it, and then both of them offered. const n = negotiating({ polite: true }); await Promise.all([ n.negotiate('peer1', n.entry), n.negotiate('peer1', n.entry), n.negotiate('peer1', n.entry), ]); assert.equal(n.waits.length, 1, 'more than one negotiation was in flight'); assert.deepEqual(n.sent, [['peer1', 'offer', true]]); }); test('a negotiation asked for during an abandoned yield still happens', async () => { // The browser gate found this one, and it is the serializer's own // trap. In `mutualshare`, the polite side adds its screen, sleeps in // its yield, and answers the other side's offer while asleep. That // exchange fires `negotiationneeded` for the polite side's *own* // screen — which the serializer dropped, because an attempt was in // flight — and then the attempt woke, saw the generation had moved, // and abandoned its offer. Nobody was left to offer the screen: the // transceiver sat unassociated for the rest of the call, and the far // side showed a caption with nothing under it. let once = true; const n = negotiating({ polite: true, duringYield: (entry, pcs, box) => { if (!once) return; once = false; box.answeredExchange(entry); // Exactly what the browser does next, into a negotiate that is // still in flight. box.negotiate('peer1', entry); }, }); await n.negotiate('peer1', n.entry); assert.deepEqual(n.sent, [['peer1', 'offer', true]], 'the change asked for during the yield was never offered'); assert.equal(n.waits.length, 2, 'the second round gives way of its own accord'); assert.equal(n.entry.negotiating, false, 'the serializer must not stay latched'); assert.equal(n.entry.renegotiate, false); }); test('an abandoned yield that nobody asked to repeat offers nothing', async () => { // The other half: without a fresh ask, an attempt taken over by an // exchange stays taken over. Otherwise every superseded yield would // come back round and offer anyway, which is the extra offer the // generation exists to prevent. const n = negotiating({ polite: true, duringYield: (entry, pcs, box) => box.answeredExchange(entry), }); await n.negotiate('peer1', n.entry); assert.deepEqual(n.sent, []); assert.equal(n.waits.length, 1, 'it went round again with nothing to go round for'); }); test('an ask the rounds run out on is kept, not dropped', async () => { // Codex S4-8, and the far end of f982bdf's fix. Let a remote exchange // complete during *every* yield, each one firing a fresh // `negotiationneeded`: `answeredExchange` hands the spent yield back, // so every round gives way again, and the rounds run out. The shipped // code then cleared the flag in its `finally` — waits 4, offers 0, // renegotiate false — and the last ask, for local work that still // needed an offer, was gone with nothing left to make it. const n = negotiating({ polite: true, duringYield: (entry, pcs, box) => { box.answeredExchange(entry); box.negotiate('peer1', entry); }, }); await n.negotiate('peer1', n.entry); assert.equal(n.waits.length, NEGOTIATE_ROUNDS, 'the bound on synchronous rounds is gone'); assert.deepEqual(n.sent, [], 'an offer went out into an exchange that was still moving'); assert.equal(n.entry.renegotiate, true, 'the outstanding ask was thrown away with the rounds'); assert.deepEqual(n.scheduled, [POLITE_YIELD], 'nothing was scheduled to answer it'); assert.equal(n.entry.negotiating, false, 'the serializer must not stay latched'); }); test('the retry does not spend the ask on a connection mid-exchange', async () => { // Codex S4-12. The ask survived round exhaustion; then the timer ran // while a remote exchange was in flight, `negotiate` cleared the flag // at the top of its round, slept, woke to a connection that was still // not stable, and walked away — pending=false, timers=0, offers=0. // A return to `stable` has nothing left to collect. const n = negotiating({ polite: true, duringYield: (entry, pcs, box) => { box.answeredExchange(entry); box.negotiate('peer1', entry); }, }); await n.negotiate('peer1', n.entry); assert.equal(n.entry.renegotiate, true, 'the rounds did not keep the ask'); assert.equal(n.timers.length, 1); // The exchange the retry lands in the middle of. n.entry.pc.signalingState = 'have-remote-offer'; await n.fire(); assert.equal(n.entry.renegotiate, true, 'the retry spent the ask on an attempt that could not offer'); assert.deepEqual(n.sent, [], 'an offer went out into an exchange'); assert.equal(n.timers.length, 1, 'nothing is coming back for the ask'); assert.equal(n.entry.negotiateDefers, 1); }); test('a deferred retry offers once the connection is quiet again', async () => { const n = negotiating({ polite: true, yields: POLITE_YIELDS }); n.entry.renegotiate = true; n.keepNegotiation('peer1', n.entry); n.entry.pc.signalingState = 'have-remote-offer'; await n.fire(); assert.deepEqual(n.sent, []); // The exchange completes and the connection settles. n.entry.pc.signalingState = 'stable'; await n.fire(); assert.deepEqual(n.sent, [['peer1', 'offer', true]], 'the kept ask was never answered'); assert.equal(n.entry.renegotiate, false); assert.equal(n.entry.negotiateDefers, 0, 'the deferral count must not carry into the next ask'); }); test('an exchange that outlasts the yield leaves the ask behind it', async () => { // The other door onto the same defect, and the one that needs no // timer: an attempt whose yield wakes into an exchange that is still // in flight. Nothing about that settled this side's own reason for // offering. const n = negotiating({ polite: true, duringYield: (entry) => { entry.pc.signalingState = 'have-remote-offer'; }, }); await n.negotiate('peer1', n.entry); assert.deepEqual(n.sent, []); assert.equal(n.entry.renegotiate, true, 'the ask went with the attempt'); assert.equal(n.timers.length, 1, 'and nothing was scheduled to bring it back'); }); test('an impolite side that cannot offer keeps its ask too', async () => { // No yield at all on this side: `setLocalDescription` throws on a // connection mid-exchange, which used to end the attempt silently. const n = negotiating({ polite: false }); n.entry.pc.setLocalDescription = async () => { throw new Error('InvalidStateError'); }; await n.negotiate('peer1', n.entry); assert.deepEqual(n.sent, []); assert.equal(n.entry.renegotiate, true); assert.equal(n.timers.length, 1); }); test('a retry gives up rescheduling but never gives up the ask', async () => { const n = negotiating({ polite: true, yields: POLITE_YIELDS }); n.entry.renegotiate = true; n.keepNegotiation('peer1', n.entry); n.entry.pc.signalingState = 'have-remote-offer'; for (let i = 0; i < NEGOTIATE_DEFERS + 2; i++) { if (n.timers.length) await n.fire(); } assert.equal(n.entry.renegotiate, true, 'the ask must outlive the timer'); assert.equal(n.timers.length, 0, 'the timer went on waking for ever'); assert.equal(n.entry.negotiateDefers, NEGOTIATE_DEFERS); }); test('a kept ask schedules one retry, not one per round', async () => { // The retry is a place to pick the ask up again, not a queue: the // flag is cleared at the top of every round, so a scheduled attempt // that finds nothing outstanding does nothing at all. const n = negotiating({ polite: true, duringYield: (entry, pcs, box) => { box.answeredExchange(entry); box.negotiate('peer1', entry); }, }); await n.negotiate('peer1', n.entry); await n.negotiate('peer1', n.entry); assert.equal(n.scheduled.length, 1, 'retries stacked up: ' + JSON.stringify(n.scheduled)); }); test('a connection that went away keeps no ask and schedules nothing', async () => { const n = negotiating({ polite: true, duringYield: (entry, pcs, box) => { box.answeredExchange(entry); box.negotiate('peer1', entry); pcs.delete('peer1'); }, }); await n.negotiate('peer1', n.entry); assert.deepEqual(n.scheduled, []); assert.equal(n.entry.renegotiate, false); }); test('a negotiation after a lost yield is protected again', async () => { // The reset defect the Task 4 re-review carried (item 1): a yield lost // to an exchange that happened to satisfy this side's own change left // the allowance spent, and the next independent negotiation went // straight out with no yield in front of it. // // Enough of them to exhaust the bound, so the bound cannot be what // makes this pass: without the hand-back, `yields` reaches // POLITE_YIELDS and the next negotiation goes straight out. let exchange = true; const n = negotiating({ polite: true, duringYield: (entry, pcs, box) => { if (exchange) box.answeredExchange(entry); }, }); for (let i = 0; i < POLITE_YIELDS; i++) await n.negotiate('peer1', n.entry); assert.deepEqual(n.sent, [], 'an offer went out for a negotiation somebody else made'); assert.equal(n.entry.yields, 0, 'every yield an exchange took must be handed back'); exchange = false; await n.negotiate('peer1', n.entry); assert.equal(n.waits.length, POLITE_YIELDS + 1, 'the later negotiation went out unprotected'); assert.deepEqual(n.sent, [['peer1', 'offer', true]]); }); test('a connection that went away during the yield is not offered to', async () => { const n = negotiating({ polite: true, duringYield: (entry, pcs) => pcs.delete('peer1'), }); await n.negotiate('peer1', n.entry); assert.deepEqual(n.sent, []); }); test('a call that ended during the yield is not offered to', async () => { const n = negotiating({ polite: true }); const stopped = negotiating({ polite: true, stopped: true }); await stopped.negotiate('peer1', stopped.entry); assert.deepEqual(stopped.sent, [], 'an offer went out after the call ended'); // And the same connection, with the call still up, does offer — so the // assertion above is about `stopped` and not about the harness. await n.negotiate('peer1', n.entry); assert.deepEqual(n.sent, [['peer1', 'offer', true]]); }); test('a connection replaced under the negotiation is not offered to', async () => { const n = negotiating({ polite: false }); n.pcs.set('peer1', { pc: {} }); await n.negotiate('peer1', n.entry); assert.deepEqual(n.sent, []); }); // --------------------------------------------------------------- // addShareTo: only on a stable connection // --------------------------------------------------------------- const sharerBox = new Function( 'share', 'pcs', 'renderTiles', 'renderShare', 'broadcast', 'stopStream', 'tuneShare', [lift(src, 'addShareTo'), lift(src, 'removeShareFrom'), lift(src, 'dropShare')].join('\n') + '\nreturn { addShareTo, removeShareFrom, dropShare };', ); function connection(signalingState) { const removed = []; const camera = { track: { kind: 'video', id: 'camera' }, replaceTrack() { throw new Error('the camera was touched'); } }; const entry = { shareTx: null, pc: { signalingState, removed, added: [], camera, addTransceiver(track, init) { const tx = { track, init, sender: { track }, stopped: false, mid: null, currentDirection: null, stop() { this.stopped = true; } }; this.added.push(tx); return tx; }, removeTrack(sender) { removed.push(sender); }, }, }; return entry; } function shareOver(signalingState) { const display = { kind: 'video', id: 'display' }; const stream = { id: 'display-stream' }; const entry = connection(signalingState); const pcs = new Map([['peer1', entry]]); const rendered = []; // Sender tuning (the screen's parameters, the camera capped and given // back) runs after each add and each removal; it is recorded with the // transceiver the entry holds at that moment. const tuned = []; const box = sharerBox({ stream, track: display }, pcs, () => rendered.push('tiles'), () => rendered.push('share'), () => {}, () => {}, (e) => tuned.push(e.shareTx)); return Object.assign(box, { entry, display, stream, rendered, tuned }); } test('a screen is added to a stable connection', () => { const s = shareOver('stable'); s.addShareTo(s.entry); assert.equal(s.entry.pc.added.length, 1); assert.equal(s.entry.pc.added[0].track, s.display); assert.deepEqual(s.entry.pc.added[0].init, { direction: 'sendonly', streams: [s.stream] }); assert.equal(s.entry.shareTx, s.entry.pc.added[0]); assert.deepEqual(s.tuned, [s.entry.shareTx], 'the new screen is tuned once it exists'); }); test('a screen is not added to a connection that is mid-negotiation', () => { // An unassociated sendonly transceiver is exactly what a remote // offer's m-line matching claims on a rollback, and a rollback is what // costs this connection its camera. The connection's own // signalingstatechange adds it at the next stable moment instead. for (const busy of ['have-local-offer', 'have-remote-offer', 'closed']) { const s = shareOver(busy); s.addShareTo(s.entry); assert.deepEqual(s.entry.pc.added, [], 'a screen was added during ' + busy); assert.equal(s.entry.shareTx, null); } }); test('a connection that already carries the screen is left alone', () => { const s = shareOver('stable'); s.addShareTo(s.entry); s.addShareTo(s.entry); assert.equal(s.entry.pc.added.length, 1); }); // --------------------------------------------------------------- // Stopping a screen never reaches the camera // --------------------------------------------------------------- test('stopping a screen stops its own transceiver and nothing else', () => { const s = shareOver('stable'); s.addShareTo(s.entry); const tx = s.entry.shareTx; s.removeShareFrom(s.entry); assert.equal(tx.stopped, true, 'the screen m-line was not stopped'); assert.equal(s.entry.shareTx, null); // The camera is the thing this must never touch: fix round 2 chased a // camera that stopped encoding, and `removeTrack` on the wrong sender // is one of the two ways this file could have done that itself. The // fake camera sender throws from replaceTrack, so reaching it at all // fails loudly rather than quietly. assert.deepEqual(s.entry.pc.removed, [], 'a sender was removed from the connection'); assert.equal(s.entry.pc.camera.track.id, 'camera'); // Retuned with no screen on it, which is what gives the camera its // own defaults back (cameraCap(false) in room-share.test.mjs). assert.deepEqual(s.tuned, [tx, null], 'the camera was not given back its defaults'); }); test('the fallback is only for a transceiver that cannot be stopped', () => { // A browser whose transceivers have no stop(): the sender is nulled // and removed instead. Still the share's own sender, never the // camera's. const s = shareOver('stable'); s.addShareTo(s.entry); const tx = s.entry.shareTx; delete tx.stop; let replaced = 'not called'; tx.sender.replaceTrack = (t) => { replaced = t; return Promise.resolve(); }; s.removeShareFrom(s.entry); assert.equal(replaced, null, 'the share sender was not nulled'); assert.deepEqual(s.entry.pc.removed, [tx.sender]); assert.notEqual(s.entry.pc.removed[0], s.entry.pc.camera); }); test('a connection with no screen on it is not renegotiated at all', () => { const s = shareOver('stable'); s.removeShareFrom(s.entry); assert.deepEqual(s.entry.pc.removed, []); assert.deepEqual(s.entry.pc.added, []); }); // --------------------------------------------------------------- // dropShare // --------------------------------------------------------------- test('dropping a screen clears the label, the media and the mid', () => { const s = shareOver('stable'); const entry = { sharing: true, share: { stream: {}, track: {} }, shareMid: '2' }; s.dropShare(entry); assert.deepEqual(entry, { sharing: false, share: null, shareMid: '' }); }); test('dropping a screen twice is the same as dropping it once', () => { // §5.4: the track's `ended` and `share {on:false}` both arrive, in // either order, and whichever is second must be a no-op — not a second // render, and not an exception. const s = shareOver('stable'); const entry = { sharing: true, share: { stream: {}, track: {} }, shareMid: '2' }; s.dropShare(entry); s.dropShare(entry); s.dropShare({ sharing: false, share: null, shareMid: '' }); assert.deepEqual(entry, { sharing: false, share: null, shareMid: '' }); // Once, not three times: the grid is redrawn by the ending that // actually ends something, and a screen removed twice must not make // the tiles jump twice. assert.deepEqual(s.rendered, ['tiles']); }); test('a screen announced but never arrived is still dropped', () => { // `share {on:true}` drew the tile and the track never came: the // `share {on:false}` has to take the tile away all the same. const s = shareOver('stable'); const entry = { sharing: true, share: null, shareMid: '' }; s.dropShare(entry); assert.equal(entry.sharing, false); }); test('the numbers these tests assert against are the ones room.js ships', () => { // Everything above reads its constants out of the source, which is // what keeps a test from proving its own harness — but a value read // from the source and then handed straight back in is still no gate on // the value itself: POLITE_YIELD set to zero would leave every test // above green (the whole-branch review's W4-3). Spec §15.16 says 300 // ms, so that is what this pins. assert.equal(POLITE_YIELD, 300, 'spec §15.16 names 300 ms'); assert.ok(POLITE_YIELDS >= 1, 'the polite side must be allowed to give way at least once'); assert.ok(CAMERA_STALL_POLLS >= 2, 'one poll is a sample, not a stall'); assert.ok(CAMERA_REPAIRS >= 1, 'a camera must be given at least one repair before a reset'); }); // --------------------------------------------------------------- // The render watch (Codex round 2, the branch after S4-9) // --------------------------------------------------------------- // // The last link in the chain, and the one nothing else in room.js can // see: an element holding exactly the right track — live, unmuted, the // one the connection receives the camera on — while the decoder // delivers frames it never paints. `meet.media()` settles that this is // not a binding confusion: `agrees: true`, and the picture still does // not move. const RENDER_STALL_POLLS = pin('RENDER_STALL_POLLS'); const RENDER_REPLAYS = pin('RENDER_REPLAYS'); const RENDER_MOVED = Number(/const RENDER_MOVED = ([\d.]+);/.exec(src)[1]); const renderBox = new Function( 'RENDER_STALL_POLLS', 'RENDER_REPLAYS', 'RENDER_MOVED', lift(src, 'judgeRender') + '\nreturn { judgeRender };', ); const render = renderBox(RENDER_STALL_POLLS, RENDER_REPLAYS, RENDER_MOVED); // A tile as judgeRender sees one: an element with a clock, and the // track it is bound to. function fakeTile(opts) { const o = opts || {}; return { video: { currentTime: 0, paused: !!o.paused }, stream: {}, track: { readyState: o.readyState || 'live', muted: !!o.muted }, renderTime: -1, renderDecoded: -1, renderStalls: 0, renderReplays: 0, }; } // paint drives a series of polls: each entry is [decodedTotal, // currentTime], which is exactly the pair of numbers the watch // compares. function paint(t, polls) { return polls.map(([decoded, time]) => { t.video.currentTime = time; return render.judgeRender(t, decoded); }); } const play = new Function(lift(src, 'shouldPlay') + '\nreturn shouldPlay;')(); test('a tile is not played behind the Play control the person was offered', () => { // §5 offers a Play control when the browser's autoplay policy refuses // a remote video, and the gesture is then the person's to give. A // tile can be bound more than once — audio arriving before the camera // does exactly that — and calling play() again behind the control // hides a question nobody has answered. The browser gate's // `playfallback` failed twice before this rule existed. const offered = { stream: {}, play: { hidden: false } }; assert.equal(play(offered, false), false); // Every other case is ordinary: a tile with media and no control // showing is played, a control that has been answered is hidden // again, and the local preview is never this file's to start. assert.equal(play({ stream: {}, play: { hidden: true } }, false), true); assert.equal(play({ stream: {}, play: null }, false), true); assert.equal(play({ stream: null, play: null }, false), false); assert.equal(play({ stream: {}, play: { hidden: true } }, true), false); }); test('a tile that paints what it decodes is never touched', () => { const t = fakeTile(); const said = paint(t, [[10, 0.1], [20, 0.4], [30, 0.7], [40, 1.0], [50, 1.3]]); assert.deepEqual(said, ['', '', '', '', '']); }); test('a tile receiving nothing is idle, not stalled', () => { // A frozen picture on a stream that has stopped arriving is the // sender's business, and the camera watch's — not this one's. const t = fakeTile(); const said = paint(t, [[10, 0.5], [10, 0.5], [10, 0.5], [10, 0.5], [10, 0.5], [10, 0.5]]); assert.deepEqual(said, ['', '', '', '', '', '']); assert.equal(t.renderReplays, 0); }); test('a tile that decodes and does not paint is replayed, then reset', () => { // The failure `laterolls` reproduces: frames arriving, clock stopped. const t = fakeTile(); const said = paint(t, [[10, 1.5], [20, 1.5], [30, 1.5], [40, 1.5], [50, 1.5], [60, 1.5], [70, 1.5], [80, 1.5], [90, 1.5], [100, 1.5], [110, 1.5], [120, 1.5]]); assert.equal(said.filter((v) => v === 'replay').length, RENDER_REPLAYS, JSON.stringify(said)); assert.equal(said.filter((v) => v === 'reset').length, 1, JSON.stringify(said)); assert.ok(said.lastIndexOf('replay') < said.indexOf('reset'), JSON.stringify(said)); // And then it stops asking: a reset that did not take is not a reason // to ask for another one every three seconds. assert.deepEqual(paint(t, [[130, 1.5], [140, 1.5], [150, 1.5], [160, 1.5]]), ['', '', '', '']); }); test('a tile painting one frame every few seconds is stalling, not playing', () => { // The browser gate's own numbers: rendered totals of [22,23,23,23,23,25] // across three seconds. Reading any advance at all as health let a // tile like this reset the budget for ever and never earn the reset // that would rebuild its connection. A second of video is a second of // clock; a frame is thirty milliseconds. const t = fakeTile(); const said = paint(t, [[10, 1.50], [20, 1.53], [30, 1.56], [40, 1.59], [50, 1.62], [60, 1.65], [70, 1.68], [80, 1.71], [90, 1.74], [100, 1.77], [110, 1.80], [120, 1.83]]); assert.equal(said.filter((v) => v === 'repair' || v === 'replay').length, RENDER_REPLAYS, JSON.stringify(said)); assert.equal(said.filter((v) => v === 'reset').length, 1, JSON.stringify(said)); }); test('a clock that goes backwards is a re-attach, not a stall', () => { // What this watch's own repair does to the element it repairs. const t = fakeTile(); paint(t, [[10, 1.5], [20, 1.5]]); assert.equal(t.renderStalls, 1); assert.equal(render.judgeRender(t, 30), '', 'the clock is where it was'); t.video.currentTime = 0; assert.equal(render.judgeRender(t, 40), ''); assert.equal(t.renderStalls, 0, 'a re-attached element was judged on its old clock'); }); test('a tile that starts painting again forgets the replay it needed', () => { const t = fakeTile(); paint(t, [[10, 1.5], [20, 1.5], [30, 1.5], [40, 1.5]]); assert.equal(t.renderReplays, 1, 'the stall was not noticed at all'); paint(t, [[50, 1.8], [60, 2.1]]); assert.equal(t.renderReplays, 0, 'a picture that moves again must clear the budget'); }); test('an element the autoplay policy paused is left to the Play control', () => { // The browser gate taught this one. A refused autoplay leaves the // element paused with its clock stopped while frames arrive, which is // this watch's exact signature and none of its business: §5's answer // is the Play control in the tile's caption, and a watch that called // play() again would take the decision away from the person and hide // the button that says so. const t = fakeTile({ paused: true }); const said = paint(t, [[10, 0], [20, 0], [30, 0], [40, 0], [50, 0], [60, 0]]); assert.deepEqual(said, ['', '', '', '', '', '']); assert.equal(t.renderReplays, 0); }); test('a muted or ended track is nobody here to repair', () => { for (const t of [fakeTile({ muted: true }), fakeTile({ readyState: 'ended' })]) { const said = paint(t, [[10, 1.5], [20, 1.5], [30, 1.5], [40, 1.5], [50, 1.5]]); assert.deepEqual(said, ['', '', '', '', '']); } // And a tile with no stats to read is left where it is. const t = fakeTile(); assert.equal(render.judgeRender(t, undefined), ''); assert.equal(t.renderStalls, 0); }); // --------------------------------------------------------------- // The camera watch (Codex S4-5) // --------------------------------------------------------------- // // POLITE_YIELD prevents the rollback that costs a camera its encoder, // but it is a duration and not a guarantee: a signalling round trip // longer than 300 ms, a 429 retry, or a slow offer can still land the // remote offer late. What is left afterwards looks entirely healthy — // `connected`, the m-line `sendrecv`, the track `live` — and encodes // nothing at all, so no connection-state handler ever fires. The one // thing that says so is framesEncoded standing still. const CAMERA_STALL_POLLS = pin('CAMERA_STALL_POLLS'); const CAMERA_REPAIRS = pin('CAMERA_REPAIRS'); // `switching` is the room's per-kind device-switch counter, injected // rather than stubbed inside the box: repairCamera reads it to find out // whether a device switch took ownership of the sender while it was // awaiting, and a test that could not move it could not exercise the // one case where the repair must stand down. const switching = { audio: 0, video: 0 }; const cameraBox = new Function( 'CAMERA_STALL_POLLS', 'CAMERA_REPAIRS', 'switching', [lift(src, 'judgeCamera'), lift(src, 'cameraReport'), lift(src, 'repairCamera')].join('\n') + '\nreturn { judgeCamera, cameraReport, repairCamera };', ); const cam = cameraBox(CAMERA_STALL_POLLS, CAMERA_REPAIRS, switching); // A camera transceiver as the watch sees one, and the record of what // was done to its sender. function cameraTx(opts) { const o = opts || {}; const calls = []; const track = { kind: 'video', readyState: o.readyState || 'live', muted: !!o.muted, enabled: o.enabled !== false }; const tx = { mid: o.mid === undefined ? '1' : o.mid, currentDirection: o.direction === undefined ? 'sendrecv' : o.direction, sender: { track: o.noReplace ? track : track, replaceTrack: o.noReplace ? undefined : async (t) => { calls.push(t === null ? 'null' : 'track'); }, }, }; return { tx, calls, track }; } function watching() { return { cameraFrames: -1, cameraStalls: 0, cameraRepairs: 0 }; } // verdicts drives one entry through a sequence of framesEncoded counts // and hands back what the watch said each time. function verdicts(entry, tx, counts) { return counts.map((n) => cam.judgeCamera(entry, tx, n === null ? null : { framesEncoded: n, mid: tx.mid })); } test('a camera that keeps encoding is never touched', () => { const c = cameraTx(); const entry = watching(); assert.deepEqual(verdicts(entry, c.tx, [10, 20, 30, 40, 50, 60]), ['', '', '', '', '', '']); assert.equal(entry.cameraRepairs, 0); }); test('a camera that stops encoding is repaired, and then reset', () => { // The signature: the same framesEncoded poll after poll on a sending // m-line with a live track. Two polls of it is a repair; two more is // the pair being rebuilt outright. const c = cameraTx(); const entry = watching(); const said = verdicts(entry, c.tx, [100, 100, 100, 100, 100, 100, 100]); assert.equal(said.filter((v) => v === 'repair').length, 1, JSON.stringify(said)); assert.equal(said.filter((v) => v === 'reset').length, 1, JSON.stringify(said)); assert.ok(said.indexOf('repair') < said.indexOf('reset'), 'the reset came before the repair'); // And then it stops asking: a reset that did not take is not a reason // to ask for another one every two seconds. assert.deepEqual(verdicts(entry, c.tx, [100, 100, 100, 100]), ['', '', '', '']); }); test('a camera that comes back forgets the repair it needed', () => { // A repair that worked must not be counted against the next stall, // hours later: the next one gets a repair of its own before anything // is reset. const c = cameraTx(); const entry = watching(); let said = verdicts(entry, c.tx, [100, 100, 100]); assert.equal(said[said.length - 1], 'repair'); said = verdicts(entry, c.tx, [110, 120, 130]); assert.deepEqual(said, ['', '', ''], 'a camera that is encoding again was still being judged'); said = verdicts(entry, c.tx, [130, 130, 130]); assert.ok(said.includes('repair') && !said.includes('reset'), 'the second stall was reset rather than repaired: ' + JSON.stringify(said)); }); test('a counter that goes backwards is re-baselined, not forgiven', () => { // Codex S4-10. `framesEncoded !== cameraFrames` read every change as // "encoding recovered", including the drop to zero that a stats or // encoder restart produces — so [100,100,100,0,0,0,0,0] gave repair, // repair, reset instead of one repair and then the reset, and every // further reset could hand the allowance back again without a single // new frame being encoded. const c = cameraTx(); const entry = watching(); const said = verdicts(entry, c.tx, [100, 100, 100, 0, 0, 0, 0, 0]); assert.equal(said.filter((v) => v === 'repair').length, 1, 'the allowance was replenished by a counter going backwards: ' + JSON.stringify(said)); assert.equal(said.filter((v) => v === 'reset').length, 1, JSON.stringify(said)); assert.ok(said.indexOf('repair') < said.indexOf('reset')); }); test('growth after a counter reset does forgive the repair', () => { // The other half: a restart that really did come back is a camera // that is encoding again, and the next stall — an hour later — gets a // repair of its own rather than the reset this one earned. const c = cameraTx(); const entry = watching(); verdicts(entry, c.tx, [100, 100, 100]); assert.equal(entry.cameraRepairs, 1); verdicts(entry, c.tx, [0, 5, 10]); assert.equal(entry.cameraRepairs, 0, 'real growth after the reset must clear the budget'); const said = verdicts(entry, c.tx, [10, 10, 10]); assert.ok(said.includes('repair') && !said.includes('reset'), JSON.stringify(said)); }); test('nothing is repaired on a connection that is not sending a camera', () => { for (const opts of [{ direction: 'recvonly' }, { direction: 'inactive' }, { readyState: 'ended' }]) { const c = cameraTx(opts); const entry = watching(); assert.deepEqual(verdicts(entry, c.tx, [100, 100, 100, 100, 100]), ['', '', '', '', ''], JSON.stringify(opts) + ' was treated as a stalled camera'); assert.equal(entry.cameraStalls, 0); } }); test('a camera the system is holding is never repaired or reset', () => { // iOS mutes the camera track, live, while Safari is in the background // or the screen is locked (2026-10-03, "kept cutting in and out"): no // frames, and nothing wrong with the encoder. Nor is a camera the // person turned off. Either way, never a reset of the connection. for (const opts of [{ muted: true }, { enabled: false }]) { const c = cameraTx(opts); const entry = watching(); const said = verdicts(entry, c.tx, [100, 100, 100, 100, 100, 100, 100, 100]); assert.deepEqual(said, ['', '', '', '', '', '', '', ''], JSON.stringify(opts)); assert.equal(entry.cameraRepairs, 0); } // Given back, it is watched again from scratch. const c = cameraTx({ muted: true }); const entry = watching(); verdicts(entry, c.tx, [100, 100, 100]); c.track.muted = false; assert.deepEqual(verdicts(entry, c.tx, [100, 110, 120]), ['', '', '']); }); test('a stall is forgotten when the camera stops being watchable', () => { // Half a stall, then no report at all — a connection that stopped // sending video. The count starts again rather than tipping over into // a repair on the strength of a sample from before. const c = cameraTx(); const entry = watching(); verdicts(entry, c.tx, [100, 100]); assert.equal(entry.cameraStalls, 1); assert.equal(cam.judgeCamera(entry, c.tx, null), ''); assert.equal(entry.cameraStalls, 0); assert.equal(entry.cameraFrames, -1); }); test('the outbound report is chosen by mid, and never guessed at', () => { const camera = { mid: '1', framesEncoded: 10 }; const screen = { mid: '2', framesEncoded: 20 }; assert.equal(cam.cameraReport([camera, screen], '1'), camera); assert.equal(cam.cameraReport([screen, camera], '1'), camera); // A browser that reports no mid on outbound-rtp: one report is // unambiguous, two are the camera and the screen and neither can be // told from the other. const bare = { framesEncoded: 10 }; assert.equal(cam.cameraReport([bare], '1'), bare); assert.equal(cam.cameraReport([bare, { framesEncoded: 20 }], '1'), null); assert.equal(cam.cameraReport([], '1'), null); // No mid known at all: the same rule from the other side. assert.equal(cam.cameraReport([bare], ''), bare); assert.equal(cam.cameraReport([bare, { framesEncoded: 20 }], ''), null); }); test('the repair takes the track off its sender and puts it back', async () => { const c = cameraTx(); await cam.repairCamera(c.tx); assert.deepEqual(c.calls, ['null', 'track'], 'the encoder is rebuilt by removing the track and restoring it, in that order'); }); test('a repair overtaken by a device switch leaves the new track alone', async () => { // The gap between the two replaceTrack calls is the only place a // device switch can do damage here: the track captured on the way in // is the one that switch is retiring, and putting it back would undo // the switch and leave every peer watching a track that has ended. // The stall that prompted this repair is still being watched, so // standing down costs a poll and not a camera. const c = cameraTx(); const replace = c.tx.sender.replaceTrack; c.tx.sender.replaceTrack = async (t) => { const out = await replace(t); if (t === null) switching.video += 1; return out; }; await cam.repairCamera(c.tx); assert.deepEqual(c.calls, ['null'], 'the repair stops once a switch owns the sender'); switching.video = 0; }); test('a repair does not restore a track that has ended under it', async () => { const c = cameraTx(); const replace = c.tx.sender.replaceTrack; c.tx.sender.replaceTrack = async (t) => { const out = await replace(t); if (t === null) c.track.readyState = 'ended'; return out; }; await cam.repairCamera(c.tx); assert.deepEqual(c.calls, ['null'], 'an ended track is not worth putting back'); }); test('a sender that cannot replace its track is left alone', async () => { const c = cameraTx({ noReplace: true }); await assert.doesNotReject(() => cam.repairCamera(c.tx)); await assert.doesNotReject(() => cam.repairCamera(null)); await assert.doesNotReject(() => cam.repairCamera({ sender: null })); }); test('lostKinds: only a kind we had, every track of it ended', () => { // The tab in front again (comeBack): iOS can end the camera and the // microphone while Safari is in the background, and those are opened // again; a kind that is merely muted, or never was, is left alone. const lostKinds = new Function(lift(src, 'lostKinds') + '; return lostKinds;')(); const live = { readyState: 'live' }; const ended = { readyState: 'ended' }; assert.deepEqual(lostKinds([ended], [ended]), ['audio', 'video']); assert.deepEqual(lostKinds([live], [ended]), ['video']); assert.deepEqual(lostKinds([ended, live], []), [], 'one live track is enough, and no video is not lost video'); assert.deepEqual(lostKinds([], []), []); assert.deepEqual(lostKinds(undefined, undefined), []); });