// room-screen.test.mjs — room.js's screen watchdogs (spec §15.21, // 2026-09-28), gated without a browser. // // node --test test/browser/room-screen.test.mjs // // Lifted out of the shipped room.js by name, as room-hearing.test.mjs // does, with the constants they read. // // judgeScreen the receiver's verdict on one screen: stalled after // four seconds without a new frame, for three minutes at // most; any change in the count is movement // reattachDue the receiver's own mending, once per stall (§15.22) // escalateShare the sharer's ladder when keyframes do not answer // recoveringWords, noticeWords the sharer's notice // readingMoved what a stalled screen's statistics say // mediaFrom, mediaKey, mediaPayload the report's screenStalled list 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'); } function liftConst(name) { const m = src.match(new RegExp('const ' + name + ' = ([0-9.]+);')); assert.ok(m, name + ' is not a numeric const in room.js any more'); return 'const ' + name + ' = ' + m[1] + ';'; } const CONSTS = [ 'SCREEN_STALL_AFTER', 'SCREEN_STALL_GIVE_UP', 'SCREEN_REATTACH_AFTER', 'SCREEN_REBUILD_AFTER', 'SCREEN_RESHARE_AFTER', 'SCREEN_GIVE_UP_AFTER', 'SCREEN_REBUILD_EVERY', ].map(liftConst).join('\n'); const build = (name) => // eslint-disable-next-line no-new-func new Function(CONSTS + '\n' + lift(src, name) + '; return ' + name + ';')(); const judgeScreen = build('judgeScreen'); const recoveringWords = build('recoveringWords'); const reattachDue = build('reattachDue'); const escalateShare = build('escalateShare'); const readingMoved = new Function(lift(src, 'readingSummary') + '\n' + lift(src, 'readingMoved') + '; return readingMoved;')(); // eslint-disable-next-line no-new-func const noticeWords = new Function(lift(src, 'recoveringWords') + '\n' + lift(src, 'noticeWords') + '; return noticeWords;')(); const mediaFrom = build('mediaFrom'); const mediaPayload = build('mediaPayload'); // eslint-disable-next-line no-new-func const mediaKey = new Function(lift(src, 'mediaKey') + '; return mediaKey;')(); const ID = '0123456789abcdef0123456789abcdef'; test('a screen is stalled four seconds after its last frame, and for three minutes at most', () => { const seen = { decoded: 0, movedAt: 0 }; const stalledAt = []; for (let t = 0; t <= 200; t++) { const decoded = t < 10 ? t * 10 : 90; if (judgeScreen(seen, decoded, t * 1000, false)) stalledAt.push(t); } assert.equal(stalledAt[0], 13); assert.equal(stalledAt[stalledAt.length - 1], 188); }); test('a rebuilt screen whose count starts again is moving, not stalled', () => { const seen = { decoded: 0, movedAt: 0 }; for (let t = 0; t < 10; t++) judgeScreen(seen, 900 + t * 30, t * 1000, false); for (let t = 10; t < 16; t++) judgeScreen(seen, 1170, t * 1000, false); assert.equal(judgeScreen(seen, 1170, 16000, false), true); for (let t = 17; t < 40; t++) { assert.equal(judgeScreen(seen, (t - 17) * 30, t * 1000, false), false, 't=' + t); } }); test('a stalled picture is handed to its tile again once, four seconds into the stall', () => { const seen = { decoded: 0, movedAt: 0 }; const due = []; for (let t = 0; t < 60; t++) { const decoded = t < 10 ? t * 10 : 90; judgeScreen(seen, decoded, t * 1000, false); if (reattachDue(seen, t * 1000)) due.push(t); } assert.deepEqual(due, [17], 'last moved at 9, stalled from 13, handed back at 17, once'); judgeScreen(seen, 91, 60000, false); const again = []; for (let t = 61; t < 80; t++) { judgeScreen(seen, 91, t * 1000, false); if (reattachDue(seen, t * 1000)) again.push(t); } assert.deepEqual(again, [68], 'a new stall is a new try'); }); // The ladder, driven once a second the way answerScreenStalls drives it. function ladder(from, to, reported, opts = {}) { const rec = opts.rec || { stage: '', at: 0, lastRebuild: 0 }; let resharedAt = opts.resharedAt || 0; const out = []; for (let t = from; t < to; t++) { const now = t * 1000; if (!reported(t)) { rec.stage = ''; continue; } const owed = opts.owed ? opts.owed(t) : true; const free = !resharedAt || now - resharedAt >= 300000; const action = escalateShare(rec, now, owed, free); if (action === 'reshare') resharedAt = now; if (action) out.push([t, action]); } return { out, rec, resharedAt }; } test('a report keyframes do not answer is rebuilt, then shared again, then given up', () => { const { out, rec } = ladder(0, 45, (t) => t >= 3); assert.deepEqual(out, [[9, 'rebuild'], [19, 'reshare'], [29, 'failed']]); assert.equal(rec.stage, 'failed'); }); test('a rebuild that works ends the ladder', () => { const { out, rec } = ladder(0, 60, (t) => t >= 3 && t < 12); assert.deepEqual(out, [[9, 'rebuild']]); assert.equal(rec.stage, ''); }); test('rebuilds and shares again are rationed', () => { const first = ladder(0, 20, (t) => t >= 3 && t < 12); const second = ladder(20, 50, (t) => t >= 25 && t < 34, { rec: first.rec, resharedAt: first.resharedAt }); assert.deepEqual(second.out, [[31, 'reshare']], 'no second rebuild inside a minute'); const third = ladder(80, 120, (t) => t >= 85, { rec: second.rec, resharedAt: second.resharedAt }); assert.deepEqual(third.out, [[91, 'rebuild'], [101, 'failed']], 'and no second share again inside five'); }); test('nothing sent is nothing to escalate', () => { const { out, rec } = ladder(0, 60, (t) => t >= 3, { owed: () => false }); assert.deepEqual(out, []); assert.equal(rec.stage, 'keyframes'); }); test('the notice moves on in words, the app\'s own', () => { assert.equal(noticeWords([], [], []), ''); assert.equal(noticeWords(['Samuel'], [], []), 'Samuel’s view of your screen stalled — recovering'); assert.equal(noticeWords([], ['Samuel'], []), 'Restarting your share for Samuel…'); assert.equal(noticeWords([], [], ['Samuel']), 'Couldn’t restore Samuel’s view — try Stop and Share again'); assert.equal(noticeWords(['Tony'], [], ['Samuel']), 'Couldn’t restore Samuel’s view — try Stop and Share again. Tony’s view of your screen stalled — recovering'); assert.equal(noticeWords([], ['A', 'B', 'C'], []), 'Restarting your share for 3 people…'); }); test('a stalled screen\'s statistics name what stopped', () => { const base = { mid: '3', packets: 1000, lost: 0, received: 300, decoded: 300, dropped: 0, keyframes: 2, pli: 0, fir: 0, nack: 0, freezes: 0, width: 1920, height: 1080, decoder: 'ExternalDecoder', muted: false }; const wedged = Object.assign({}, base, { packets: 1400, received: 420, dropped: 110 }); assert.match(readingMoved(wedged, base), /^packets \+400, frames received \+120, decoded \+0, dropped \+110.* — frames arriving, none decoded \(the decoder\)$/); assert.match(readingMoved(base, base), /no packets arriving/); assert.match(readingMoved(Object.assign({}, base, { mid: '5' }), base), /no earlier reading$/); }); test('a moving screen is never stalled, and a frozen test window is', () => { const seen = { decoded: 0, movedAt: 0 }; for (let t = 0; t < 20; t++) assert.equal(judgeScreen(seen, t, t * 1000, false), false); const frozen = []; for (let t = 20; t < 30; t++) if (judgeScreen(seen, t, t * 1000, true)) frozen.push(t); assert.equal(frozen[0], 23, 'the gate\'s frozen window counts as no new frames'); assert.equal(judgeScreen(seen, 40, 30000, false), false, 'and frames moving again clears it'); }); test('the sharer\'s notice names whose view stalled', () => { assert.equal(recoveringWords([]), ''); assert.equal(recoveringWords(['Vicky']), 'Vicky’s view of your screen stalled — recovering'); assert.equal(recoveringWords(['Tony', 'Vicky']), 'Tony’s and Vicky’s views of your screen stalled — recovering'); assert.equal(recoveringWords(['A', 'B', 'C']), '3 people’s views of your screen stalled — recovering'); }); test('screenStalled travels only with bad news, and never to a server that refused it', () => { const mine = { muted: false, cameraOff: false }; assert.deepEqual(mediaPayload(mine, false, null, false, []), { muted: false, cameraOff: false }); assert.deepEqual(mediaPayload(mine, false, null, false, [ID]), { muted: false, cameraOff: false, screenStalled: [ID] }); assert.deepEqual(mediaPayload(mine, false, null, true, [ID]), { muted: false, cameraOff: false }); // The four-argument calls from before still mean what they meant. assert.deepEqual(mediaPayload(mine, true, [ID], false), { muted: false, cameraOff: false, audioBlocked: true, noSoundFrom: [ID] }); }); test('a report\'s screenStalled is read forgivingly and changes the key', () => { const m = mediaFrom({ muted: false, cameraOff: false, screenStalled: [ID, 'nope', ID, 7] }); assert.deepEqual(m.screenStalled, [ID]); assert.equal(mediaFrom({ muted: false, cameraOff: false, screenStalled: [] }).screenStalled, undefined); assert.notEqual(mediaKey(m), mediaKey({ muted: false, cameraOff: false })); assert.notEqual(mediaKey({ muted: false, cameraOff: false, noSoundFrom: [ID] }), mediaKey(m), 'the same peer silent is not the same report as the same peer stalled'); });