// room-mute.test.mjs — room.js's media state (spec §15.19), gated // without a browser. // // node --test test/browser/room-mute.test.mjs // // Same trick as room-share.test.mjs: the functions are lifted out of the // shipped room.js by name, so a rename fails here loudly instead of // passing against a copy. // // mediaFrom what counts as a report: exactly two booleans, from a // roster entry or a `media` payload, and nothing looser — // an absent or malformed one is unknown, never "on" — // plus §15.20's two optional keys, read forgivingly // sameMedia whether the hub's word on us already matches ours // mediaKey the dedupe key a toggle is compared on // mediaPayload the report: two keys, and §15.20's only with bad news // mediaChange the sentence the live region says, and when it says // nothing at all // rosterMedia with reportMedia: when our own report goes — on the // roster that admits us, on one that disagrees, on a // toggle, never before admission, and again after a 400 import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)); const src = readFileSync(roomJS, 'utf8'); function lift(source, name) { const start = source.indexOf('function ' + name + '('); assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing'); 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 room.js'); } // build lifts one function, and any it calls that are named after it. const build = (name, ...with_) => // eslint-disable-next-line no-new-func new Function([name, ...with_].map((n) => lift(src, n)).join('\n') + '; return ' + name + ';')(); const mediaFrom = build('mediaFrom'); const sameMedia = build('sameMedia', 'mediaKey'); const mediaKey = build('mediaKey'); const mediaChange = build('mediaChange'); const mediaPayload = build('mediaPayload'); const P1 = 'a'.repeat(32); const P2 = 'b'.repeat(32); test('mediaFrom takes exactly two booleans and nothing looser', () => { assert.deepEqual(mediaFrom({ muted: true, cameraOff: false }), { muted: true, cameraOff: false }); // Extra keys are dropped rather than carried: what is drawn is what // was checked. assert.deepEqual(mediaFrom({ muted: false, cameraOff: true, extra: 1 }), { muted: false, cameraOff: true }); for (const bad of [ undefined, null, 'muted', 1, [], {}, { muted: true }, { cameraOff: true }, { muted: 'true', cameraOff: false }, { muted: 1, cameraOff: 0 }, { muted: null, cameraOff: false }, ]) { assert.equal(mediaFrom(bad), null, JSON.stringify(bad) + ' is unknown'); } }); test('mediaFrom reads §15.20’s optional keys, and drops a bad one on its own', () => { assert.deepEqual( mediaFrom({ muted: false, cameraOff: false, audioBlocked: true, noSoundFrom: [P1, P2] }), { muted: false, cameraOff: false, audioBlocked: true, noSoundFrom: [P1, P2] }); // A malformed optional key costs only itself: the booleans still stand. for (const [bad, want] of [ [{ audioBlocked: 'yes' }, {}], [{ audioBlocked: false }, {}], [{ noSoundFrom: 'x' }, {}], [{ noSoundFrom: [] }, {}], [{ noSoundFrom: [P1, 'nope', 7, P1.toUpperCase(), P1] }, { noSoundFrom: [P1] }], ]) { assert.deepEqual(mediaFrom({ muted: true, cameraOff: false, ...bad }), { muted: true, cameraOff: false, ...want }, JSON.stringify(bad)); } // Never more than the hub's fifteen. const many = Array.from({ length: 20 }, (_, i) => i.toString(16).padStart(32, '0')); assert.equal(mediaFrom({ muted: false, cameraOff: false, noSoundFrom: many }).noSoundFrom.length, 15); // And the required pair is still required. assert.equal(mediaFrom({ muted: 'no', cameraOff: false, audioBlocked: true }), null); }); test('sameMedia and mediaKey see a change in hearing as a change', () => { const on = { muted: false, cameraOff: false }; assert.equal(sameMedia(on, { ...on, audioBlocked: true }), false); assert.equal(sameMedia(on, { ...on, noSoundFrom: [P1] }), false); assert.equal(sameMedia({ ...on, noSoundFrom: [P1, P2] }, { ...on, noSoundFrom: [P2, P1] }), true, 'the same peers in another order are the same report'); assert.notEqual(mediaKey({ ...on, noSoundFrom: [P1] }), mediaKey({ ...on, noSoundFrom: [P2] })); }); test('mediaPayload is the two-key object unless there is bad news', () => { const mine = { muted: false, cameraOff: true }; assert.deepEqual(mediaPayload(mine, false, [], false), { muted: false, cameraOff: true }); assert.deepEqual(Object.keys(mediaPayload(mine, false, null, false)), ['muted', 'cameraOff']); assert.deepEqual(mediaPayload(mine, true, [P1], false), { muted: false, cameraOff: true, audioBlocked: true, noSoundFrom: [P1] }); // A server that refused the extras once gets the two keys for good. assert.deepEqual(mediaPayload(mine, true, [P1], true), { muted: false, cameraOff: true }); }); test('sameMedia never matches unknown', () => { const on = { muted: false, cameraOff: false }; assert.equal(sameMedia(on, { muted: false, cameraOff: false }), true); assert.equal(sameMedia(on, { muted: true, cameraOff: false }), false); assert.equal(sameMedia(on, { muted: false, cameraOff: true }), false); // The roster's silence about us is not agreement with "on": a fresh // participation must be told, or a peer who joined unmuted stays // unknown to everyone for the whole call. assert.equal(sameMedia(null, on), false); assert.equal(sameMedia(on, null), false); }); test('mediaKey tells every state apart', () => { const keys = new Set(); for (const muted of [false, true]) { for (const cameraOff of [false, true]) keys.add(mediaKey({ muted, cameraOff })); } assert.equal(keys.size, 4); // And never collides with the empty string mediaSent starts at. assert.equal(keys.has(''), false); }); test('mediaChange says what moved, and nothing for a first report', () => { const on = { muted: false, cameraOff: false }; assert.equal(mediaChange('Bea', null, { muted: true, cameraOff: false }), '', 'a first report is somebody arriving, not news'); assert.equal(mediaChange('Bea', on, on), ''); assert.equal(mediaChange('Bea', on, { muted: true, cameraOff: false }), 'Bea muted their microphone.'); assert.equal(mediaChange('Bea', { muted: true, cameraOff: false }, on), 'Bea unmuted their microphone.'); assert.equal(mediaChange('Bea', on, { muted: false, cameraOff: true }), 'Bea turned their camera off.'); assert.equal(mediaChange('Bea', { muted: false, cameraOff: true }, on), 'Bea turned their camera on.'); assert.equal(mediaChange('Bea', on, { muted: true, cameraOff: true }), 'Bea muted their microphone. Bea turned their camera off.'); }); test('the dispatch routes media, and the report rides the broadcast lane', () => { // Wire-level facts a lift cannot reach: the event name the hub sends, // and that the report is an untargeted broadcast rather than a send() // with a seq and a generation the hub would refuse. assert.match(src, /case 'media': return onMedia\(msg\);/); // An EventSource hears only the names it listens for: the dispatch // above is dead without this, and the roster path hides it (the // browser gate caught exactly that). assert.match(src, /const DOWNSTREAM = \[[^\]]*'media'\]/); assert.match(src, /broadcast\('media', now,/); }); // rosterMedia and reportMedia, lifted together over real Maps with only // the wire and the local stream stubbed: the decision under test is when // a report goes, and that is the pair of them. function reporter({ admitted = true, local = { muted: false, cameraOff: false }, answer = true, status = 200 } = {}) { const sent = []; const env = { local, answer, status, blocked: false, noSound: [], heard: null }; const made = new Function( 'state', 'remoteMedia', 'localMedia', 'broadcast', 'env', ['mediaFrom', 'sameMedia', 'mediaKey', 'mediaPayload', 'learnMedia', 'reportMedia', 'rosterMedia'] .map((n) => lift(src, n)).join('\n') + '\nlet mediaSent = "";' + '\nlet mediaExtras = true;' + '\nlet audioBlocked = false;' + '\nlet screenStalled = [];' + '\nconst noSoundList = () => env.noSound;' + '\nconst hearingList = () => env.heard;' + '\nreturn { rosterMedia, reportMedia, memo: () => mediaSent, extras: () => mediaExtras,' + '\n block: (on) => { audioBlocked = on; } };', )( { admitted, stopped: false, peer: 'me' }, new Map(), () => env.local, (type, payload, onStatus) => { sent.push([type, payload]); // The status of the answer goes to onStatus first, as pumpBroadcast // does it, and then the boolean settles the promise. const extra = Object.keys(payload).length > 2; const status = extra ? env.status : (env.answer ? 200 : 400); if (onStatus) onStatus(status); return Promise.resolve(status === 200); }, env, ); return { ...made, sent, env }; } test('a roster that has never heard from us is answered with a report', () => { const r = reporter({ local: { muted: true, cameraOff: false } }); r.rosterMedia({ peers: [{ peer: 'me' }, { peer: 'bea' }] }); assert.deepEqual(r.sent, [['media', { muted: true, cameraOff: false }]]); }); test('a roster that already agrees costs nothing, and a toggle then goes once', () => { const r = reporter(); r.rosterMedia({ peers: [{ peer: 'me', media: { muted: false, cameraOff: false } }] }); assert.equal(r.sent.length, 0); r.env.local = { muted: true, cameraOff: false }; r.reportMedia(); r.reportMedia(); assert.equal(r.sent.length, 1, 'a second call with nothing changed must not send again'); }); test('a roster that disagrees — a report lost while the stream was down — is corrected', () => { const r = reporter({ local: { muted: true, cameraOff: true } }); r.reportMedia(); assert.equal(r.sent.length, 1); r.rosterMedia({ peers: [{ peer: 'me', media: { muted: true, cameraOff: false } }] }); assert.equal(r.sent.length, 2); assert.deepEqual(r.sent[1][1], { muted: true, cameraOff: true }); }); test('nothing is reported before admission', () => { const r = reporter({ admitted: false, local: { muted: true, cameraOff: false } }); r.reportMedia(); assert.equal(r.sent.length, 0); }); test('a refused report is forgotten, so the next chance tries again', async () => { const r = reporter({ answer: false, local: { muted: true, cameraOff: false } }); r.reportMedia(); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(r.memo(), '', 'an old server’s 400 must not be remembered as sent'); r.reportMedia(); assert.equal(r.sent.length, 2); }); test('bad news about hearing is reported, and its end too', () => { const r = reporter(); r.rosterMedia({ peers: [{ peer: 'me', media: { muted: false, cameraOff: false } }] }); assert.equal(r.sent.length, 0); r.env.noSound = [P1]; r.reportMedia(); assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false, noSoundFrom: [P1] }); r.block(true); r.reportMedia(); assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false, audioBlocked: true, noSoundFrom: [P1] }); r.reportMedia(); assert.equal(r.sent.length, 2, 'nothing changed, nothing sent'); r.block(false); r.env.noSound = []; r.reportMedia(); assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false }); assert.deepEqual(Object.keys(r.sent.at(-1)[1]), ['muted', 'cameraOff'], 'the ordinary report is exactly the two keys an old server takes'); }); test('a roster carrying our hearing as we would report it costs nothing', () => { const r = reporter(); r.env.noSound = [P1]; r.rosterMedia({ peers: [{ peer: 'me', media: { muted: false, cameraOff: false, noSoundFrom: [P1] } }] }); assert.equal(r.sent.length, 0); r.rosterMedia({ peers: [{ peer: 'me', media: { muted: false, cameraOff: false } }] }); assert.equal(r.sent.length, 1, 'a roster that lost the list is told again'); }); test('a 400 for the extras falls back to the two keys, at once and for good', async () => { const r = reporter({ status: 400 }); r.env.noSound = [P1]; r.reportMedia(); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(r.extras(), false); assert.equal(r.sent.length, 2); assert.deepEqual(r.sent[1][1], { muted: false, cameraOff: false }, 'the fallback goes straight away, without waiting for the next change'); r.block(true); r.env.noSound = [P1, P2]; r.reportMedia(); assert.equal(r.sent.length, 2, 'with the extras off, a change in hearing alone is no change'); r.env.local = { muted: true, cameraOff: false }; r.reportMedia(); assert.deepEqual(r.sent.at(-1)[1], { muted: true, cameraOff: false }); }); // §15.24: `hearing` is good news and travels whenever it is given, [] // included — its presence is what tells the others this tab says who it // hears — and it is read back the same way: an empty list is kept, and // is a different report from one without the key. test('hearing is carried even when empty, and read back as given', () => { assert.deepEqual(mediaPayload({ muted: false, cameraOff: false }, false, [], false, [], []), { muted: false, cameraOff: false, hearing: [] }); assert.deepEqual(mediaPayload({ muted: false, cameraOff: false }, false, [], false, [], [P1, P2]), { muted: false, cameraOff: false, hearing: [P1, P2] }); assert.deepEqual(mediaPayload({ muted: false, cameraOff: false }, false, [], true, [], [P1]), { muted: false, cameraOff: false }, 'a server that refused the extras gets none'); assert.deepEqual(mediaFrom({ muted: false, cameraOff: false, hearing: [] }), { muted: false, cameraOff: false, hearing: [] }); assert.deepEqual(mediaFrom({ muted: false, cameraOff: false, hearing: [P1, 'nope', P1] }), { muted: false, cameraOff: false, hearing: [P1] }); assert.deepEqual(mediaFrom({ muted: false, cameraOff: false, hearing: 'all' }), { muted: false, cameraOff: false }); assert.notEqual(mediaKey({ muted: false, cameraOff: false, hearing: [] }), mediaKey({ muted: false, cameraOff: false })); assert.equal(mediaKey({ muted: false, cameraOff: false, hearing: [P2, P1] }), mediaKey({ muted: false, cameraOff: false, hearing: [P1, P2] })); }); test('who this tab hears is reported on a change, and a roster that agrees costs nothing', () => { const r = reporter(); r.env.heard = []; r.rosterMedia({ peers: [{ peer: 'me' }] }); assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false, hearing: [] }); r.rosterMedia({ peers: [{ peer: 'me', media: { muted: false, cameraOff: false, hearing: [] } }] }); assert.equal(r.sent.length, 1); r.env.heard = [P1]; r.reportMedia(); assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false, hearing: [P1] }); r.reportMedia(); assert.equal(r.sent.length, 2); }); test('a 400 for a report carrying hearing falls back to the two keys', async () => { const r = reporter({ status: 400 }); r.env.heard = []; r.reportMedia(); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(r.extras(), false); assert.deepEqual(r.sent.at(-1)[1], { muted: false, cameraOff: false }); });