| 1 | // devices.test.mjs — the two device pickers, held to each other. |
| 2 | // |
| 3 | // node --test test/browser/devices.test.mjs |
| 4 | // |
| 5 | // The holding screen (devices.js) and the room (room.js) both enumerate |
| 6 | // devices and fill the same three selects, and they are separate |
| 7 | // routines because they want different things from the answer: the |
| 8 | // holding screen is choosing what to open, the room is showing what is |
| 9 | // already open. What they must not differ on is the rules — which row |
| 10 | // is hidden when, and what an unlabelled device is called — because a |
| 11 | // person who saw "Microphone 2" on one page and something else on the |
| 12 | // next would reasonably think they had changed device when they had |
| 13 | // not. |
| 14 | // |
| 15 | // This is the cheap half of that, checked without a browser. The |
| 16 | // expensive half is the `switching` scenario in call.mjs, which does a |
| 17 | // real swap on a real call. |
| 18 | import { test } from 'node:test'; |
| 19 | import assert from 'node:assert/strict'; |
| 20 | import { readFileSync } from 'node:fs'; |
| 21 | import { fileURLToPath } from 'node:url'; |
| 22 | |
| 23 | const devicesJS = fileURLToPath(new URL('../../internal/calls/static/devices.js', import.meta.url)); |
| 24 | const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)); |
| 25 | |
| 26 | function lift(source, name, where) { |
| 27 | let start = source.indexOf('function ' + name + '('); |
| 28 | assert.notEqual(start, -1, name + ' is not in ' + where + ' any more — this test is stale, not passing'); |
| 29 | if (source.slice(start - 6, start) === 'async ') start -= 6; |
| 30 | let depth = 0; |
| 31 | for (let i = source.indexOf('{', start); i < source.length; i++) { |
| 32 | if (source[i] === '{') depth += 1; |
| 33 | else if (source[i] === '}') { |
| 34 | depth -= 1; |
| 35 | if (depth === 0) return source.slice(start, i + 1); |
| 36 | } |
| 37 | } |
| 38 | assert.fail('unbalanced braces reading ' + name + ' out of ' + where); |
| 39 | } |
| 40 | |
| 41 | const devicesSrc = readFileSync(devicesJS, 'utf8'); |
| 42 | const roomSrc = readFileSync(roomJS, 'utf8'); |
| 43 | |
| 44 | test('both pages name an unlabelled device the same way', () => { |
| 45 | assert.equal(lift(devicesSrc, 'labelFor', 'devices.js'), lift(roomSrc, 'labelFor', 'room.js')); |
| 46 | }); |
| 47 | |
| 48 | // eslint-disable-next-line no-new-func |
| 49 | const labelFor = new Function(lift(roomSrc, 'labelFor', 'room.js') + '; return labelFor;')(); |
| 50 | |
| 51 | test('an unlabelled device is numbered, not blank', () => { |
| 52 | // enumerateDevices returns empty labels until permission has been |
| 53 | // granted, and on some browsers for some devices even after. A select |
| 54 | // full of blank options is a control nobody can use. |
| 55 | assert.equal(labelFor('videoinput', 1), 'Camera 1'); |
| 56 | assert.equal(labelFor('audioinput', 2), 'Microphone 2'); |
| 57 | }); |
| 58 | |
| 59 | test('both pages hide a row with nothing in it', () => { |
| 60 | // A field with no options is clutter, not help — the rule the holding |
| 61 | // screen has always followed, and the room now has to as well. |
| 62 | for (const [src, where] of [[devicesSrc, 'devices.js'], [roomSrc, 'room.js']]) { |
| 63 | for (const row of ['cameraRow', 'micRow']) { |
| 64 | assert.match(src, new RegExp(row + '\\.hidden = '), |
| 65 | where + ' never hides ' + row); |
| 66 | } |
| 67 | } |
| 68 | }); |
| 69 | |
| 70 | test('both pages gate the speaker row on setSinkId', () => { |
| 71 | // A speaker picker in a browser that cannot route output is a control |
| 72 | // that does nothing, which is the one thing worse than no control. |
| 73 | assert.match(devicesSrc, /speakerRow\.hidden = !hasSinkId \|\| speakerCount === 0;/); |
| 74 | assert.match(roomSrc, /speakerRow\.hidden = !HAS_SINK_ID \|\| speakerCount === 0;/); |
| 75 | }); |
| 76 | |
| 77 | test('the room only ever swaps a track, never adds an m-line', () => { |
| 78 | // The whole reason a mid-call switch is safe is that replaceTrack does |
| 79 | // not renegotiate. A regression that reached for addTrack here would |
| 80 | // put an offer in flight on every device change, straight into the |
| 81 | // glare §5.1 spends 300 ms avoiding — and it would pass every test |
| 82 | // that only looked at the local stream. |
| 83 | const sendTrack = lift(roomSrc, 'sendTrack', 'room.js'); |
| 84 | assert.match(sendTrack, /replaceTrack\(track\)/); |
| 85 | assert.equal(/addTrack|addTransceiver/.test(sendTrack), false, |
| 86 | 'sendTrack must not add tracks or transceivers — replaceTrack is what makes this free'); |
| 87 | }); |
| 88 | |
| 89 | test('switches are serialised, so two clicks cannot interleave', () => { |
| 90 | // Two switches running at once are two replaceTrack loops over the |
| 91 | // same senders, and what every peer ends up receiving is then decided |
| 92 | // by whichever getUserMedia happened to settle last. The queue is what |
| 93 | // makes the last click win instead of the fastest device. |
| 94 | const src = lift(roomSrc, 'switchDevice', 'room.js'); |
| 95 | assert.match(src, /switchQueue\s*=/, 'switchDevice must chain onto the queue, not run immediately'); |
| 96 | assert.match(src, /switchQueue\.then\(/, 'switchDevice must wait for the switch before it'); |
| 97 | // A rejected switch must not take every later switch with it. |
| 98 | assert.match(src, /\.catch\(/); |
| 99 | }); |
| 100 | |
| 101 | test('a switch superseded before it opens anything puts nothing back', () => { |
| 102 | // The three generation checks, and the one place there deliberately |
| 103 | // is not one. Before getUserMedia there is nothing to tidy; between |
| 104 | // getUserMedia and the sender loop the track is this run's alone and |
| 105 | // is stopped; *after* the sender loop it belongs to every sender in |
| 106 | // the mesh, and stopping it there would stop the call's own media |
| 107 | // until a newer switch arrived — which it might never do. |
| 108 | const src = lift(roomSrc, 'applySwitch', 'room.js'); |
| 109 | const guards = src.match(/myGen !== switching/g) || []; |
| 110 | assert.equal(guards.length, 3, 'applySwitch checks its generation ' + guards.length + ' times, expected exactly 3'); |
| 111 | assert.match(src, /for \(const t of fresh\.getTracks\(\)\) t\.stop\(\);/, |
| 112 | 'the superseded path must stop the tracks getUserMedia handed it'); |
| 113 | const afterLoop = src.slice(src.indexOf('await sendTrack(')); |
| 114 | assert.equal(/myGen !== switching/.test(afterLoop), false, |
| 115 | 'there must be no generation check after the sender loop — see the comment there'); |
| 116 | }); |
| 117 | |
| 118 | // §15.20: the holding screen's audio test. The decisions are pure and |
| 119 | // lifted by name, with the thresholds lifted alongside them, so what is |
| 120 | // tested is what ships. |
| 121 | function liftConst(source, name) { |
| 122 | const m = source.match(new RegExp('const ' + name + ' = (-?[0-9.]+);')); |
| 123 | assert.ok(m, name + ' is not a numeric const in devices.js any more'); |
| 124 | return 'const ' + name + ' = ' + m[1] + ';'; |
| 125 | } |
| 126 | |
| 127 | const audioTestConsts = ['MIC_WORKING_DB', 'MIC_SILENT_AFTER'].map((n) => liftConst(devicesSrc, n)).join('\n'); |
| 128 | const liftAudio = (name) => |
| 129 | // eslint-disable-next-line no-new-func |
| 130 | new Function(audioTestConsts + '\n' + lift(devicesSrc, name, 'devices.js') + '; return ' + name + ';')(); |
| 131 | |
| 132 | const rmsDb = liftAudio('rmsDb'); |
| 133 | const meterFill = liftAudio('meterFill'); |
| 134 | const micVerdict = liftAudio('micVerdict'); |
| 135 | const micSentence = liftAudio('micSentence'); |
| 136 | |
| 137 | test('rmsDb measures a window in dBFS', () => { |
| 138 | assert.equal(rmsDb(new Float32Array(128)), -Infinity, 'digital silence'); |
| 139 | assert.equal(rmsDb([]), -Infinity); |
| 140 | const full = new Float32Array(128).fill(1); |
| 141 | assert.ok(Math.abs(rmsDb(full)) < 1e-9, 'a full-scale square wave is 0 dBFS'); |
| 142 | const sine = Float32Array.from({ length: 4800 }, (_, i) => 0.1 * Math.sin((2 * Math.PI * 440 * i) / 48000)); |
| 143 | assert.ok(Math.abs(rmsDb(sine) - (20 * Math.log10(0.1 / Math.SQRT2))) < 0.05, String(rmsDb(sine))); |
| 144 | }); |
| 145 | |
| 146 | test('meterFill runs from -60 dBFS empty to 0 dBFS full', () => { |
| 147 | assert.equal(meterFill(-Infinity), 0); |
| 148 | assert.equal(meterFill(NaN), 0); |
| 149 | assert.equal(meterFill(-60), 0); |
| 150 | assert.equal(meterFill(-30), 0.5); |
| 151 | assert.equal(meterFill(0), 1); |
| 152 | assert.equal(meterFill(6), 1); |
| 153 | }); |
| 154 | |
| 155 | test('micVerdict: working at speech level whenever it comes, silent after five quiet seconds', () => { |
| 156 | assert.equal(micVerdict(0, -Infinity), '', 'still listening'); |
| 157 | assert.equal(micVerdict(4999, -60), '', 'room noise is not a verdict yet'); |
| 158 | assert.equal(micVerdict(5000, -60), 'silent'); |
| 159 | assert.equal(micVerdict(5000, -45.01), 'silent', 'just under speech level is still nothing heard'); |
| 160 | assert.equal(micVerdict(100, -45), 'working'); |
| 161 | assert.equal(micVerdict(9000, -20), 'working', 'someone who started talking late has a microphone that works'); |
| 162 | }); |
| 163 | |
| 164 | test('the microphone test says what it found, naming the microphone', () => { |
| 165 | assert.equal(micSentence('working', 'Studio Mic'), 'Your microphone is working.'); |
| 166 | assert.match(micSentence('silent', 'Studio Mic'), /^We can’t hear anything from Studio Mic\./); |
| 167 | assert.match(micSentence('', 'Studio Mic'), /^Listening to Studio Mic\./); |
| 168 | }); |
| 169 | |
| 170 | test('the audio test never gives its recording a URL or sends it anywhere', () => { |
| 171 | // The page's CSP refuses blob: media, and the promise at the top of |
| 172 | // devices.js is that nothing heard here leaves the browser. Both are |
| 173 | // properties of what the file does not contain. |
| 174 | for (const banned of ['createObjectURL', 'fetch(', 'XMLHttpRequest', 'sendBeacon', 'WebSocket']) { |
| 175 | assert.equal(devicesSrc.includes(banned), false, 'devices.js must not use ' + banned); |
| 176 | } |
| 177 | assert.match(devicesSrc, /decodeAudioData/); |
| 178 | assert.match(devicesSrc, /testAudio\.srcObject = out/); |
| 179 | }); |
| 180 | |