// devices.test.mjs — the two device pickers, held to each other. // // node --test test/browser/devices.test.mjs // // The holding screen (devices.js) and the room (room.js) both enumerate // devices and fill the same three selects, and they are separate // routines because they want different things from the answer: the // holding screen is choosing what to open, the room is showing what is // already open. What they must not differ on is the rules — which row // is hidden when, and what an unlabelled device is called — because a // person who saw "Microphone 2" on one page and something else on the // next would reasonably think they had changed device when they had // not. // // This is the cheap half of that, checked without a browser. The // expensive half is the `switching` scenario in call.mjs, which does a // real swap on a real call. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; const devicesJS = fileURLToPath(new URL('../../internal/calls/static/devices.js', import.meta.url)); const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)); function lift(source, name, where) { let start = source.indexOf('function ' + name + '('); assert.notEqual(start, -1, name + ' is not in ' + where + ' 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 ' + where); } const devicesSrc = readFileSync(devicesJS, 'utf8'); const roomSrc = readFileSync(roomJS, 'utf8'); test('both pages name an unlabelled device the same way', () => { assert.equal(lift(devicesSrc, 'labelFor', 'devices.js'), lift(roomSrc, 'labelFor', 'room.js')); }); // eslint-disable-next-line no-new-func const labelFor = new Function(lift(roomSrc, 'labelFor', 'room.js') + '; return labelFor;')(); test('an unlabelled device is numbered, not blank', () => { // enumerateDevices returns empty labels until permission has been // granted, and on some browsers for some devices even after. A select // full of blank options is a control nobody can use. assert.equal(labelFor('videoinput', 1), 'Camera 1'); assert.equal(labelFor('audioinput', 2), 'Microphone 2'); }); test('both pages hide a row with nothing in it', () => { // A field with no options is clutter, not help — the rule the holding // screen has always followed, and the room now has to as well. for (const [src, where] of [[devicesSrc, 'devices.js'], [roomSrc, 'room.js']]) { for (const row of ['cameraRow', 'micRow']) { assert.match(src, new RegExp(row + '\\.hidden = '), where + ' never hides ' + row); } } }); test('both pages gate the speaker row on setSinkId', () => { // A speaker picker in a browser that cannot route output is a control // that does nothing, which is the one thing worse than no control. assert.match(devicesSrc, /speakerRow\.hidden = !hasSinkId \|\| speakerCount === 0;/); assert.match(roomSrc, /speakerRow\.hidden = !HAS_SINK_ID \|\| speakerCount === 0;/); }); test('the room only ever swaps a track, never adds an m-line', () => { // The whole reason a mid-call switch is safe is that replaceTrack does // not renegotiate. A regression that reached for addTrack here would // put an offer in flight on every device change, straight into the // glare §5.1 spends 300 ms avoiding — and it would pass every test // that only looked at the local stream. const sendTrack = lift(roomSrc, 'sendTrack', 'room.js'); assert.match(sendTrack, /replaceTrack\(track\)/); assert.equal(/addTrack|addTransceiver/.test(sendTrack), false, 'sendTrack must not add tracks or transceivers — replaceTrack is what makes this free'); }); test('switches are serialised, so two clicks cannot interleave', () => { // Two switches running at once are two replaceTrack loops over the // same senders, and what every peer ends up receiving is then decided // by whichever getUserMedia happened to settle last. The queue is what // makes the last click win instead of the fastest device. const src = lift(roomSrc, 'switchDevice', 'room.js'); assert.match(src, /switchQueue\s*=/, 'switchDevice must chain onto the queue, not run immediately'); assert.match(src, /switchQueue\.then\(/, 'switchDevice must wait for the switch before it'); // A rejected switch must not take every later switch with it. assert.match(src, /\.catch\(/); }); test('a switch superseded before it opens anything puts nothing back', () => { // The three generation checks, and the one place there deliberately // is not one. Before getUserMedia there is nothing to tidy; between // getUserMedia and the sender loop the track is this run's alone and // is stopped; *after* the sender loop it belongs to every sender in // the mesh, and stopping it there would stop the call's own media // until a newer switch arrived — which it might never do. const src = lift(roomSrc, 'applySwitch', 'room.js'); const guards = src.match(/myGen !== switching/g) || []; assert.equal(guards.length, 3, 'applySwitch checks its generation ' + guards.length + ' times, expected exactly 3'); assert.match(src, /for \(const t of fresh\.getTracks\(\)\) t\.stop\(\);/, 'the superseded path must stop the tracks getUserMedia handed it'); const afterLoop = src.slice(src.indexOf('await sendTrack(')); assert.equal(/myGen !== switching/.test(afterLoop), false, 'there must be no generation check after the sender loop — see the comment there'); }); // §15.20: the holding screen's audio test. The decisions are pure and // lifted by name, with the thresholds lifted alongside them, so what is // tested is what ships. function liftConst(source, name) { const m = source.match(new RegExp('const ' + name + ' = (-?[0-9.]+);')); assert.ok(m, name + ' is not a numeric const in devices.js any more'); return 'const ' + name + ' = ' + m[1] + ';'; } const audioTestConsts = ['MIC_WORKING_DB', 'MIC_SILENT_AFTER'].map((n) => liftConst(devicesSrc, n)).join('\n'); const liftAudio = (name) => // eslint-disable-next-line no-new-func new Function(audioTestConsts + '\n' + lift(devicesSrc, name, 'devices.js') + '; return ' + name + ';')(); const rmsDb = liftAudio('rmsDb'); const meterFill = liftAudio('meterFill'); const micVerdict = liftAudio('micVerdict'); const micSentence = liftAudio('micSentence'); test('rmsDb measures a window in dBFS', () => { assert.equal(rmsDb(new Float32Array(128)), -Infinity, 'digital silence'); assert.equal(rmsDb([]), -Infinity); const full = new Float32Array(128).fill(1); assert.ok(Math.abs(rmsDb(full)) < 1e-9, 'a full-scale square wave is 0 dBFS'); const sine = Float32Array.from({ length: 4800 }, (_, i) => 0.1 * Math.sin((2 * Math.PI * 440 * i) / 48000)); assert.ok(Math.abs(rmsDb(sine) - (20 * Math.log10(0.1 / Math.SQRT2))) < 0.05, String(rmsDb(sine))); }); test('meterFill runs from -60 dBFS empty to 0 dBFS full', () => { assert.equal(meterFill(-Infinity), 0); assert.equal(meterFill(NaN), 0); assert.equal(meterFill(-60), 0); assert.equal(meterFill(-30), 0.5); assert.equal(meterFill(0), 1); assert.equal(meterFill(6), 1); }); test('micVerdict: working at speech level whenever it comes, silent after five quiet seconds', () => { assert.equal(micVerdict(0, -Infinity), '', 'still listening'); assert.equal(micVerdict(4999, -60), '', 'room noise is not a verdict yet'); assert.equal(micVerdict(5000, -60), 'silent'); assert.equal(micVerdict(5000, -45.01), 'silent', 'just under speech level is still nothing heard'); assert.equal(micVerdict(100, -45), 'working'); assert.equal(micVerdict(9000, -20), 'working', 'someone who started talking late has a microphone that works'); }); test('the microphone test says what it found, naming the microphone', () => { assert.equal(micSentence('working', 'Studio Mic'), 'Your microphone is working.'); assert.match(micSentence('silent', 'Studio Mic'), /^We can’t hear anything from Studio Mic\./); assert.match(micSentence('', 'Studio Mic'), /^Listening to Studio Mic\./); }); test('the audio test never gives its recording a URL or sends it anywhere', () => { // The page's CSP refuses blob: media, and the promise at the top of // devices.js is that nothing heard here leaves the browser. Both are // properties of what the file does not contain. for (const banned of ['createObjectURL', 'fetch(', 'XMLHttpRequest', 'sendBeacon', 'WebSocket']) { assert.equal(devicesSrc.includes(banned), false, 'devices.js must not use ' + banned); } assert.match(devicesSrc, /decodeAudioData/); assert.match(devicesSrc, /testAudio\.srcObject = out/); });