| 1 | // room-mute.test.mjs — room.js's media state (spec §15.19), gated |
| 2 | // without a browser. |
| 3 | // |
| 4 | // node --test test/browser/room-mute.test.mjs |
| 5 | // |
| 6 | // Same trick as room-share.test.mjs: the functions are lifted out of the |
| 7 | // shipped room.js by name, so a rename fails here loudly instead of |
| 8 | // passing against a copy. |
| 9 | // |
| 10 | // mediaFrom what counts as a report: exactly two booleans, from a |
| 11 | // roster entry or a `media` payload, and nothing looser — |
| 12 | // an absent or malformed one is unknown, never "on" — |
| 13 | // plus §15.20's two optional keys, read forgivingly |
| 14 | // sameMedia whether the hub's word on us already matches ours |
| 15 | // mediaKey the dedupe key a toggle is compared on |
| 16 | // mediaPayload the report: two keys, and §15.20's only with bad news |
| 17 | // mediaChange the sentence the live region says, and when it says |
| 18 | // nothing at all |
| 19 | // rosterMedia with reportMedia: when our own report goes — on the |
| 20 | // roster that admits us, on one that disagrees, on a |
| 21 | // toggle, never before admission, and again after a 400 |
| 22 | import { test } from 'node:test'; |
| 23 | import assert from 'node:assert/strict'; |
| 24 | import { readFileSync } from 'node:fs'; |
| 25 | import { fileURLToPath } from 'node:url'; |
| 26 | |
| 27 | const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)); |
| 28 | const src = readFileSync(roomJS, 'utf8'); |
| 29 | |
| 30 | function lift(source, name) { |
| 31 | const start = source.indexOf('function ' + name + '('); |
| 32 | assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing'); |
| 33 | let depth = 0; |
| 34 | for (let i = source.indexOf('{', start); i < source.length; i++) { |
| 35 | if (source[i] === '{') depth += 1; |
| 36 | else if (source[i] === '}') { |
| 37 | depth -= 1; |
| 38 | if (depth === 0) return source.slice(start, i + 1); |
| 39 | } |
| 40 | } |
| 41 | assert.fail('unbalanced braces reading ' + name + ' out of room.js'); |
| 42 | } |
| 43 | |
| 44 | // build lifts one function, and any it calls that are named after it. |
| 45 | const build = (name, ...with_) => |
| 46 | // eslint-disable-next-line no-new-func |
| 47 | new Function([name, ...with_].map((n) => lift(src, n)).join('\n') + '; return ' + name + ';')(); |
| 48 | |
| 49 | const mediaFrom = build('mediaFrom'); |
| 50 | const sameMedia = build('sameMedia', 'mediaKey'); |
| 51 | const mediaKey = build('mediaKey'); |
| 52 | const mediaChange = build('mediaChange'); |
| 53 | const mediaPayload = build('mediaPayload'); |
| 54 | |
| 55 | const P1 = 'a'.repeat(32); |
| 56 | const P2 = 'b'.repeat(32); |
| 57 | |
| 58 | test('mediaFrom takes exactly two booleans and nothing looser', () => { |
| 59 | assert.deepEqual(mediaFrom({ muted: true, cameraOff: false }), { muted: true, cameraOff: false }); |
| 60 | // Extra keys are dropped rather than carried: what is drawn is what |
| 61 | // was checked. |
| 62 | assert.deepEqual(mediaFrom({ muted: false, cameraOff: true, extra: 1 }), { muted: false, cameraOff: true }); |
| 63 | for (const bad of [ |
| 64 | undefined, null, 'muted', 1, [], |
| 65 | {}, { muted: true }, { cameraOff: true }, |
| 66 | { muted: 'true', cameraOff: false }, { muted: 1, cameraOff: 0 }, { muted: null, cameraOff: false }, |
| 67 | ]) { |
| 68 | assert.equal(mediaFrom(bad), null, JSON.stringify(bad) + ' is unknown'); |
| 69 | } |
| 70 | }); |
| 71 | |
| 72 | test('mediaFrom reads §15.20’s optional keys, and drops a bad one on its own', () => { |
| 73 | assert.deepEqual( |
| 74 | mediaFrom({ muted: false, cameraOff: false, audioBlocked: true, noSoundFrom: [P1, P2] }), |
| 75 | { muted: false, cameraOff: false, audioBlocked: true, noSoundFrom: [P1, P2] }); |
| 76 | // A malformed optional key costs only itself: the booleans still stand. |
| 77 | for (const [bad, want] of [ |
| 78 | [{ audioBlocked: 'yes' }, {}], |
| 79 | [{ audioBlocked: false }, {}], |
| 80 | [{ noSoundFrom: 'x' }, {}], |
| 81 | [{ noSoundFrom: [] }, {}], |
| 82 | [{ noSoundFrom: [P1, 'nope', 7, P1.toUpperCase(), P1] }, { noSoundFrom: [P1] }], |
| 83 | ]) { |
| 84 | assert.deepEqual(mediaFrom({ muted: true, cameraOff: false, ...bad }), |
| 85 | { muted: true, cameraOff: false, ...want }, JSON.stringify(bad)); |
| 86 | } |
| 87 | // Never more than the hub's fifteen. |
| 88 | const many = Array.from({ length: 20 }, (_, i) => i.toString(16).padStart(32, '0')); |
| 89 | assert.equal(mediaFrom({ muted: false, cameraOff: false, noSoundFrom: many }).noSoundFrom.length, 15); |
| 90 | // And the required pair is still required. |
| 91 | assert.equal(mediaFrom({ muted: 'no', cameraOff: false, audioBlocked: true }), null); |
| 92 | }); |
| 93 | |
| 94 | test('sameMedia and mediaKey see a change in hearing as a change', () => { |
| 95 | const on = { muted: false, cameraOff: false }; |
| 96 | assert.equal(sameMedia(on, { ...on, audioBlocked: true }), false); |
| 97 | assert.equal(sameMedia(on, { ...on, noSoundFrom: [P1] }), false); |
| 98 | assert.equal(sameMedia({ ...on, noSoundFrom: [P1, P2] }, { ...on, noSoundFrom: [P2, P1] }), true, |
| 99 | 'the same peers in another order are the same report'); |
| 100 | assert.notEqual(mediaKey({ ...on, noSoundFrom: [P1] }), mediaKey({ ...on, noSoundFrom: [P2] })); |
| 101 | }); |
| 102 | |
| 103 | test('mediaPayload is the two-key object unless there is bad news', () => { |
| 104 | const mine = { muted: false, cameraOff: true }; |
| 105 | assert.deepEqual(mediaPayload(mine, false, [], false), { muted: false, cameraOff: true }); |
| 106 | assert.deepEqual(Object.keys(mediaPayload(mine, false, null, false)), ['muted', 'cameraOff']); |
| 107 | assert.deepEqual(mediaPayload(mine, true, [P1], false), |
| 108 | { muted: false, cameraOff: true, audioBlocked: true, noSoundFrom: [P1] }); |
| 109 | // A server that refused the extras once gets the two keys for good. |
| 110 | assert.deepEqual(mediaPayload(mine, true, [P1], true), { muted: false, cameraOff: true }); |
| 111 | }); |
| 112 | |
| 113 | test('sameMedia never matches unknown', () => { |
| 114 | const on = { muted: false, cameraOff: false }; |
| 115 | assert.equal(sameMedia(on, { muted: false, cameraOff: false }), true); |
| 116 | assert.equal(sameMedia(on, { muted: true, cameraOff: false }), false); |
| 117 | assert.equal(sameMedia(on, { muted: false, cameraOff: true }), false); |
| 118 | // The roster's silence about us is not agreement with "on": a fresh |
| 119 | // participation must be told, or a peer who joined unmuted stays |
| 120 | // unknown to everyone for the whole call. |
| 121 | assert.equal(sameMedia(null, on), false); |
| 122 | assert.equal(sameMedia(on, null), false); |
| 123 | }); |
| 124 | |
| 125 | test('mediaKey tells every state apart', () => { |
| 126 | const keys = new Set(); |
| 127 | for (const muted of [false, true]) { |
| 128 | for (const cameraOff of [false, true]) keys.add(mediaKey({ muted, cameraOff })); |
| 129 | } |
| 130 | assert.equal(keys.size, 4); |
| 131 | // And never collides with the empty string mediaSent starts at. |
| 132 | assert.equal(keys.has(''), false); |
| 133 | }); |
| 134 | |
| 135 | test('mediaChange says what moved, and nothing for a first report', () => { |
| 136 | const on = { muted: false, cameraOff: false }; |
| 137 | assert.equal(mediaChange('Bea', null, { muted: true, cameraOff: false }), '', |
| 138 | 'a first report is somebody arriving, not news'); |
| 139 | assert.equal(mediaChange('Bea', on, on), ''); |
| 140 | assert.equal(mediaChange('Bea', on, { muted: true, cameraOff: false }), 'Bea muted their microphone.'); |
| 141 | assert.equal(mediaChange('Bea', { muted: true, cameraOff: false }, on), 'Bea unmuted their microphone.'); |
| 142 | assert.equal(mediaChange('Bea', on, { muted: false, cameraOff: true }), 'Bea turned their camera off.'); |
| 143 | assert.equal(mediaChange('Bea', { muted: false, cameraOff: true }, on), 'Bea turned their camera on.'); |
| 144 | assert.equal(mediaChange('Bea', on, { muted: true, cameraOff: true }), |
| 145 | 'Bea muted their microphone. Bea turned their camera off.'); |
| 146 | }); |
| 147 | |
| 148 | test('the dispatch routes media, and the report rides the broadcast lane', () => { |
| 149 | // Wire-level facts a lift cannot reach: the event name the hub sends, |
| 150 | // and that the report is an untargeted broadcast rather than a send() |
| 151 | // with a seq and a generation the hub would refuse. |
| 152 | assert.match(src, /case 'media': return onMedia\(msg\);/); |
| 153 | // An EventSource hears only the names it listens for: the dispatch |
| 154 | // above is dead without this, and the roster path hides it (the |
| 155 | // browser gate caught exactly that). |
| 156 | assert.match(src, /const DOWNSTREAM = \[[^\]]*'media'\]/); |
| 157 | assert.match(src, /broadcast\('media', now,/); |
| 158 | }); |
| 159 | |
| 160 | // rosterMedia and reportMedia, lifted together over real Maps with only |
| 161 | // the wire and the local stream stubbed: the decision under test is when |
| 162 | // a report goes, and that is the pair of them. |
| 163 | function reporter({ admitted = true, local = { muted: false, cameraOff: false }, answer = true, status = 200 } = {}) { |
| 164 | const sent = []; |
| 165 | const env = { local, answer, status, blocked: false, noSound: [], heard: null }; |
| 166 | const made = new Function( |
| 167 | 'state', 'remoteMedia', 'localMedia', 'broadcast', 'env', |
| 168 | ['mediaFrom', 'sameMedia', 'mediaKey', 'mediaPayload', 'learnMedia', 'reportMedia', 'rosterMedia'] |
| 169 | .map((n) => lift(src, n)).join('\n') + |
| 170 | '\nlet mediaSent = "";' + |
| 171 | '\nlet mediaExtras = true;' + |
| 172 | '\nlet audioBlocked = false;' + |
| 173 | '\nlet screenStalled = [];' + |
| 174 | '\nconst noSoundList = () => env.noSound;' + |
| 175 | '\nconst hearingList = () => env.heard;' + |
| 176 | '\nreturn { rosterMedia, reportMedia, memo: () => mediaSent, extras: () => mediaExtras,' + |
| 177 | '\n block: (on) => { audioBlocked = on; } };', |
| 178 | )( |
| 179 | { admitted, stopped: false, peer: 'me' }, |
| 180 | new Map(), |
| 181 | () => env.local, |
| 182 | (type, payload, onStatus) => { |
| 183 | sent.push([type, payload]); |
| 184 | // The status of the answer goes to onStatus first, as pumpBroadcast |
| 185 | // does it, and then the boolean settles the promise. |
| 186 | const extra = Object.keys(payload).length > 2; |
| 187 | const status = extra ? env.status : (env.answer ? 200 : 400); |
| 188 | if (onStatus) onStatus(status); |
| 189 | return Promise.resolve(status === 200); |
| 190 | }, |
| 191 | env, |
| 192 | ); |
| 193 | return { ...made, sent, env }; |
| 194 | } |
| 195 | |
| 196 | test('a roster that has never heard from us is answered with a report', () => { |
| 197 | const r = reporter({ local: { muted: true, cameraOff: false } }); |
| 198 | r.rosterMedia({ peers: [{ peer: 'me' }, { peer: 'bea' }] }); |
| 199 | assert.deepEqual(r.sent, [['media', { muted: true, cameraOff: false }]]); |
| 200 | }); |
| 201 | |
| 202 | test('a roster that already agrees costs nothing, and a toggle then goes once', () => { |
| 203 | const r = reporter(); |
| 204 | r.rosterMedia({ peers: [{ peer: 'me', media: { muted: false, cameraOff: false } }] }); |
| 205 | assert.equal(r.sent.length, 0); |
| 206 | r.env.local = { muted: true, cameraOff: false }; |
| 207 | r.reportMedia(); |
| 208 | r.reportMedia(); |
| 209 | assert.equal(r.sent.length, 1, 'a second call with nothing changed must not send again'); |
| 210 | }); |
| 211 | |
| 212 | test('a roster that disagrees — a report lost while the stream was down — is corrected', () => { |
| 213 | const r = reporter({ local: { muted: true, cameraOff: true } }); |
| 214 | r.reportMedia(); |
| 215 | assert.equal(r.sent.length, 1); |
| 216 | r.rosterMedia({ peers: [{ peer: 'me', media: { muted: true, cameraOff: false } }] }); |
| 217 | assert.equal(r.sent.length, 2); |
| 218 | assert.deepEqual(r.sent[1][1], { muted: true, cameraOff: true }); |
| 219 | }); |
| 220 | |
| 221 | test('nothing is reported before admission', () => { |
| 222 | const r = reporter({ admitted: false, local: { muted: true, cameraOff: false } }); |
| 223 | r.reportMedia(); |
| 224 | assert.equal(r.sent.length, 0); |
| 225 | }); |
| 226 | |
| 227 | test('a refused report is forgotten, so the next chance tries again', async () => { |
| 228 | const r = reporter({ answer: false, local: { muted: true, cameraOff: false } }); |
| 229 | r.reportMedia(); |
| 230 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 231 | assert.equal(r.memo(), '', 'an old server’s 400 must not be remembered as sent'); |
| 232 | r.reportMedia(); |
| 233 | assert.equal(r.sent.length, 2); |
| 234 | }); |
| 235 | |
| 236 | test('bad news about hearing is reported, and its end too', () => { |
| 237 | const r = reporter(); |
| 238 | r.rosterMedia({ peers: [{ peer: 'me', media: { muted: false, cameraOff: false } }] }); |
| 239 | assert.equal(r.sent.length, 0); |
| 240 | r.env.noSound = [P1]; |
| 241 | r.reportMedia(); |
| 242 | assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false, noSoundFrom: [P1] }); |
| 243 | r.block(true); |
| 244 | r.reportMedia(); |
| 245 | assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false, audioBlocked: true, noSoundFrom: [P1] }); |
| 246 | r.reportMedia(); |
| 247 | assert.equal(r.sent.length, 2, 'nothing changed, nothing sent'); |
| 248 | r.block(false); |
| 249 | r.env.noSound = []; |
| 250 | r.reportMedia(); |
| 251 | assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false }); |
| 252 | assert.deepEqual(Object.keys(r.sent.at(-1)[1]), ['muted', 'cameraOff'], |
| 253 | 'the ordinary report is exactly the two keys an old server takes'); |
| 254 | }); |
| 255 | |
| 256 | test('a roster carrying our hearing as we would report it costs nothing', () => { |
| 257 | const r = reporter(); |
| 258 | r.env.noSound = [P1]; |
| 259 | r.rosterMedia({ peers: [{ peer: 'me', media: { muted: false, cameraOff: false, noSoundFrom: [P1] } }] }); |
| 260 | assert.equal(r.sent.length, 0); |
| 261 | r.rosterMedia({ peers: [{ peer: 'me', media: { muted: false, cameraOff: false } }] }); |
| 262 | assert.equal(r.sent.length, 1, 'a roster that lost the list is told again'); |
| 263 | }); |
| 264 | |
| 265 | test('a 400 for the extras falls back to the two keys, at once and for good', async () => { |
| 266 | const r = reporter({ status: 400 }); |
| 267 | r.env.noSound = [P1]; |
| 268 | r.reportMedia(); |
| 269 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 270 | assert.equal(r.extras(), false); |
| 271 | assert.equal(r.sent.length, 2); |
| 272 | assert.deepEqual(r.sent[1][1], { muted: false, cameraOff: false }, |
| 273 | 'the fallback goes straight away, without waiting for the next change'); |
| 274 | r.block(true); |
| 275 | r.env.noSound = [P1, P2]; |
| 276 | r.reportMedia(); |
| 277 | assert.equal(r.sent.length, 2, 'with the extras off, a change in hearing alone is no change'); |
| 278 | r.env.local = { muted: true, cameraOff: false }; |
| 279 | r.reportMedia(); |
| 280 | assert.deepEqual(r.sent.at(-1)[1], { muted: true, cameraOff: false }); |
| 281 | }); |
| 282 | |
| 283 | // §15.24: `hearing` is good news and travels whenever it is given, [] |
| 284 | // included — its presence is what tells the others this tab says who it |
| 285 | // hears — and it is read back the same way: an empty list is kept, and |
| 286 | // is a different report from one without the key. |
| 287 | test('hearing is carried even when empty, and read back as given', () => { |
| 288 | assert.deepEqual(mediaPayload({ muted: false, cameraOff: false }, false, [], false, [], []), |
| 289 | { muted: false, cameraOff: false, hearing: [] }); |
| 290 | assert.deepEqual(mediaPayload({ muted: false, cameraOff: false }, false, [], false, [], [P1, P2]), |
| 291 | { muted: false, cameraOff: false, hearing: [P1, P2] }); |
| 292 | assert.deepEqual(mediaPayload({ muted: false, cameraOff: false }, false, [], true, [], [P1]), |
| 293 | { muted: false, cameraOff: false }, 'a server that refused the extras gets none'); |
| 294 | assert.deepEqual(mediaFrom({ muted: false, cameraOff: false, hearing: [] }), { muted: false, cameraOff: false, hearing: [] }); |
| 295 | assert.deepEqual(mediaFrom({ muted: false, cameraOff: false, hearing: [P1, 'nope', P1] }), |
| 296 | { muted: false, cameraOff: false, hearing: [P1] }); |
| 297 | assert.deepEqual(mediaFrom({ muted: false, cameraOff: false, hearing: 'all' }), { muted: false, cameraOff: false }); |
| 298 | assert.notEqual(mediaKey({ muted: false, cameraOff: false, hearing: [] }), mediaKey({ muted: false, cameraOff: false })); |
| 299 | assert.equal(mediaKey({ muted: false, cameraOff: false, hearing: [P2, P1] }), mediaKey({ muted: false, cameraOff: false, hearing: [P1, P2] })); |
| 300 | }); |
| 301 | |
| 302 | test('who this tab hears is reported on a change, and a roster that agrees costs nothing', () => { |
| 303 | const r = reporter(); |
| 304 | r.env.heard = []; |
| 305 | r.rosterMedia({ peers: [{ peer: 'me' }] }); |
| 306 | assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false, hearing: [] }); |
| 307 | r.rosterMedia({ peers: [{ peer: 'me', media: { muted: false, cameraOff: false, hearing: [] } }] }); |
| 308 | assert.equal(r.sent.length, 1); |
| 309 | r.env.heard = [P1]; |
| 310 | r.reportMedia(); |
| 311 | assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false, hearing: [P1] }); |
| 312 | r.reportMedia(); |
| 313 | assert.equal(r.sent.length, 2); |
| 314 | }); |
| 315 | |
| 316 | test('a 400 for a report carrying hearing falls back to the two keys', async () => { |
| 317 | const r = reporter({ status: 400 }); |
| 318 | r.env.heard = []; |
| 319 | r.reportMedia(); |
| 320 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 321 | assert.equal(r.extras(), false); |
| 322 | assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false }); |
| 323 | }); |
| 324 | |