oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
18import { test } from 'node:test';
19import assert from 'node:assert/strict';
20import { readFileSync } from 'node:fs';
21import { fileURLToPath } from 'node:url';
22
23const devicesJS = fileURLToPath(new URL('../../internal/calls/static/devices.js', import.meta.url));
24const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url));
25
26function 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
41const devicesSrc = readFileSync(devicesJS, 'utf8');
42const roomSrc = readFileSync(roomJS, 'utf8');
43
44test('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
49const labelFor = new Function(lift(roomSrc, 'labelFor', 'room.js') + '; return labelFor;')();
50
51test('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
59test('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
70test('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
77test('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
89test('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
101test('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.
121function 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
127const audioTestConsts = ['MIC_WORKING_DB', 'MIC_SILENT_AFTER'].map((n) => liftConst(devicesSrc, n)).join('\n');
128const liftAudio = (name) =>
129 // eslint-disable-next-line no-new-func
130 new Function(audioTestConsts + '\n' + lift(devicesSrc, name, 'devices.js') + '; return ' + name + ';')();
131
132const rmsDb = liftAudio('rmsDb');
133const meterFill = liftAudio('meterFill');
134const micVerdict = liftAudio('micVerdict');
135const micSentence = liftAudio('micSentence');
136
137test('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
146test('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
155test('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
164test('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
170test('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