| 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. |
| 19 | import { test } from 'node:test'; |
| 20 | import assert from 'node:assert/strict'; |
| 21 | import { readFileSync } from 'node:fs'; |
| 22 | import { fileURLToPath } from 'node:url'; |
| 23 | |
| 24 | const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)); |
| 25 | const src = readFileSync(roomJS, 'utf8'); |
| 26 | |
| 27 | function 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 | |
| 42 | const 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. |
| 51 | const laneSource = [ |
| 52 | 'broadcast', 'runBroadcast', 'pumpBroadcast', 'settleBroadcast', 'drainBroadcast', |
| 53 | 'broadcastBody', 'broadcastVerdict', 'accepted', 'recoveryDeadline', |
| 54 | ].map((name) => lift(src, name)).join('\n'); |
| 55 | |
| 56 | const 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. |
| 65 | function 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. |
| 108 | async 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 | |
| 117 | test('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 | |
| 134 | test('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 | |
| 145 | test('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 | |
| 152 | test('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 | |
| 160 | test('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 | |
| 179 | test('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 | |
| 194 | test('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 | |
| 202 | test('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 | |
| 213 | const 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 | |
| 221 | function fakeTrack(id) { |
| 222 | return { |
| 223 | id, |
| 224 | kind: 'video', |
| 225 | readyState: 'live', |
| 226 | addEventListener() {}, |
| 227 | stop() { this.readyState = 'ended'; }, |
| 228 | }; |
| 229 | } |
| 230 | |
| 231 | function 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. |
| 244 | function 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 | |
| 279 | test('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 | |
| 297 | test('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 | |
| 317 | test('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 | |
| 333 | test('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 | |
| 354 | const 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. |
| 364 | function 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 | |
| 374 | function 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 | |
| 401 | test('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 | |
| 412 | test('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 | |
| 426 | test('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 | |
| 434 | test('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 | |
| 443 | test('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 | |
| 455 | test('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). |
| 478 | function 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 | |
| 484 | const POLITE_YIELD = pin('POLITE_YIELD'); |
| 485 | const POLITE_YIELDS = pin('POLITE_YIELDS'); |
| 486 | |
| 487 | const NEGOTIATE_ROUNDS = pin('NEGOTIATE_ROUNDS'); |
| 488 | |
| 489 | const NEGOTIATE_DEFERS = pin('NEGOTIATE_DEFERS'); |
| 490 | |
| 491 | const 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. |
| 507 | function 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 | |
| 578 | test('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 | |
| 585 | test('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 | |
| 594 | test('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 | |
| 607 | test('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 | |
| 629 | test('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 | |
| 646 | test('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 | |
| 661 | test('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 | |
| 691 | test('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 | |
| 705 | test('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 | |
| 729 | test('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 | |
| 756 | test('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 | |
| 772 | test('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 | |
| 787 | test('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 | |
| 800 | test('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 | |
| 813 | test('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 | |
| 829 | test('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 | |
| 843 | test('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 | |
| 869 | test('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 | |
| 878 | test('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 | |
| 889 | test('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 | |
| 900 | const 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 | |
| 906 | function 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 | |
| 927 | function 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 | |
| 943 | test('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 | |
| 953 | test('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 | |
| 966 | test('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 | |
| 977 | test('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 | |
| 997 | test('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 | |
| 1015 | test('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 | |
| 1026 | test('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 | |
| 1033 | test('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 | |
| 1049 | test('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 | |
| 1058 | test('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 | |
| 1082 | const RENDER_STALL_POLLS = pin('RENDER_STALL_POLLS'); |
| 1083 | const RENDER_REPLAYS = pin('RENDER_REPLAYS'); |
| 1084 | const RENDER_MOVED = Number(/const RENDER_MOVED = ([\d.]+);/.exec(src)[1]); |
| 1085 | |
| 1086 | const renderBox = new Function( |
| 1087 | 'RENDER_STALL_POLLS', 'RENDER_REPLAYS', 'RENDER_MOVED', |
| 1088 | lift(src, 'judgeRender') + '\nreturn { judgeRender };', |
| 1089 | ); |
| 1090 | const 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. |
| 1094 | function 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. |
| 1110 | function paint(t, polls) { |
| 1111 | return polls.map(([decoded, time]) => { |
| 1112 | t.video.currentTime = time; |
| 1113 | return render.judgeRender(t, decoded); |
| 1114 | }); |
| 1115 | } |
| 1116 | |
| 1117 | const play = new Function(lift(src, 'shouldPlay') + '\nreturn shouldPlay;')(); |
| 1118 | |
| 1119 | test('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 | |
| 1138 | test('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 | |
| 1144 | test('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 | |
| 1153 | test('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 | |
| 1166 | test('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 | |
| 1181 | test('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 | |
| 1192 | test('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 | |
| 1200 | test('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 | |
| 1213 | test('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 | |
| 1236 | const CAMERA_STALL_POLLS = pin('CAMERA_STALL_POLLS'); |
| 1237 | const 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. |
| 1244 | const switching = { audio: 0, video: 0 }; |
| 1245 | |
| 1246 | const 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 | ); |
| 1251 | const 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. |
| 1255 | function 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 | |
| 1272 | function 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. |
| 1278 | function verdicts(entry, tx, counts) { |
| 1279 | return counts.map((n) => |
| 1280 | cam.judgeCamera(entry, tx, n === null ? null : { framesEncoded: n, mid: tx.mid })); |
| 1281 | } |
| 1282 | |
| 1283 | test('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 | |
| 1290 | test('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 | |
| 1305 | test('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 | |
| 1320 | test('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 | |
| 1336 | test('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 | |
| 1350 | test('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 | |
| 1360 | test('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 | |
| 1380 | test('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 | |
| 1393 | test('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 | |
| 1410 | test('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 | |
| 1417 | test('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 | |
| 1436 | test('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 | |
| 1448 | test('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 | |
| 1455 | test('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 | |