| 1 | // room-screen.test.mjs — room.js's screen watchdogs (spec §15.21, |
| 2 | // 2026-09-28), gated without a browser. |
| 3 | // |
| 4 | // node --test test/browser/room-screen.test.mjs |
| 5 | // |
| 6 | // Lifted out of the shipped room.js by name, as room-hearing.test.mjs |
| 7 | // does, with the constants they read. |
| 8 | // |
| 9 | // judgeScreen the receiver's verdict on one screen: stalled after |
| 10 | // four seconds without a new frame, for three minutes at |
| 11 | // most; any change in the count is movement |
| 12 | // reattachDue the receiver's own mending, once per stall (§15.22) |
| 13 | // escalateShare the sharer's ladder when keyframes do not answer |
| 14 | // recoveringWords, noticeWords the sharer's notice |
| 15 | // readingMoved what a stalled screen's statistics say |
| 16 | // mediaFrom, mediaKey, mediaPayload the report's screenStalled list |
| 17 | import { test } from 'node:test'; |
| 18 | import assert from 'node:assert/strict'; |
| 19 | import { readFileSync } from 'node:fs'; |
| 20 | import { fileURLToPath } from 'node:url'; |
| 21 | |
| 22 | const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)); |
| 23 | const src = readFileSync(roomJS, 'utf8'); |
| 24 | |
| 25 | function lift(source, name) { |
| 26 | const start = source.indexOf('function ' + name + '('); |
| 27 | assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing'); |
| 28 | let depth = 0; |
| 29 | for (let i = source.indexOf('{', start); i < source.length; i++) { |
| 30 | if (source[i] === '{') depth += 1; |
| 31 | else if (source[i] === '}') { |
| 32 | depth -= 1; |
| 33 | if (depth === 0) return source.slice(start, i + 1); |
| 34 | } |
| 35 | } |
| 36 | assert.fail('unbalanced braces reading ' + name + ' out of room.js'); |
| 37 | } |
| 38 | |
| 39 | function liftConst(name) { |
| 40 | const m = src.match(new RegExp('const ' + name + ' = ([0-9.]+);')); |
| 41 | assert.ok(m, name + ' is not a numeric const in room.js any more'); |
| 42 | return 'const ' + name + ' = ' + m[1] + ';'; |
| 43 | } |
| 44 | const CONSTS = [ |
| 45 | 'SCREEN_STALL_AFTER', 'SCREEN_STALL_GIVE_UP', 'SCREEN_REATTACH_AFTER', 'SCREEN_REBUILD_AFTER', |
| 46 | 'SCREEN_RESHARE_AFTER', 'SCREEN_GIVE_UP_AFTER', 'SCREEN_REBUILD_EVERY', |
| 47 | ].map(liftConst).join('\n'); |
| 48 | const build = (name) => |
| 49 | // eslint-disable-next-line no-new-func |
| 50 | new Function(CONSTS + '\n' + lift(src, name) + '; return ' + name + ';')(); |
| 51 | |
| 52 | const judgeScreen = build('judgeScreen'); |
| 53 | const recoveringWords = build('recoveringWords'); |
| 54 | const reattachDue = build('reattachDue'); |
| 55 | const escalateShare = build('escalateShare'); |
| 56 | const readingMoved = new Function(lift(src, 'readingSummary') + '\n' + lift(src, 'readingMoved') + '; return readingMoved;')(); |
| 57 | // eslint-disable-next-line no-new-func |
| 58 | const noticeWords = new Function(lift(src, 'recoveringWords') + '\n' + lift(src, 'noticeWords') + '; return noticeWords;')(); |
| 59 | const mediaFrom = build('mediaFrom'); |
| 60 | const mediaPayload = build('mediaPayload'); |
| 61 | // eslint-disable-next-line no-new-func |
| 62 | const mediaKey = new Function(lift(src, 'mediaKey') + '; return mediaKey;')(); |
| 63 | |
| 64 | const ID = '0123456789abcdef0123456789abcdef'; |
| 65 | |
| 66 | test('a screen is stalled four seconds after its last frame, and for three minutes at most', () => { |
| 67 | const seen = { decoded: 0, movedAt: 0 }; |
| 68 | const stalledAt = []; |
| 69 | for (let t = 0; t <= 200; t++) { |
| 70 | const decoded = t < 10 ? t * 10 : 90; |
| 71 | if (judgeScreen(seen, decoded, t * 1000, false)) stalledAt.push(t); |
| 72 | } |
| 73 | assert.equal(stalledAt[0], 13); |
| 74 | assert.equal(stalledAt[stalledAt.length - 1], 188); |
| 75 | }); |
| 76 | |
| 77 | test('a rebuilt screen whose count starts again is moving, not stalled', () => { |
| 78 | const seen = { decoded: 0, movedAt: 0 }; |
| 79 | for (let t = 0; t < 10; t++) judgeScreen(seen, 900 + t * 30, t * 1000, false); |
| 80 | for (let t = 10; t < 16; t++) judgeScreen(seen, 1170, t * 1000, false); |
| 81 | assert.equal(judgeScreen(seen, 1170, 16000, false), true); |
| 82 | for (let t = 17; t < 40; t++) { |
| 83 | assert.equal(judgeScreen(seen, (t - 17) * 30, t * 1000, false), false, 't=' + t); |
| 84 | } |
| 85 | }); |
| 86 | |
| 87 | test('a stalled picture is handed to its tile again once, four seconds into the stall', () => { |
| 88 | const seen = { decoded: 0, movedAt: 0 }; |
| 89 | const due = []; |
| 90 | for (let t = 0; t < 60; t++) { |
| 91 | const decoded = t < 10 ? t * 10 : 90; |
| 92 | judgeScreen(seen, decoded, t * 1000, false); |
| 93 | if (reattachDue(seen, t * 1000)) due.push(t); |
| 94 | } |
| 95 | assert.deepEqual(due, [17], 'last moved at 9, stalled from 13, handed back at 17, once'); |
| 96 | judgeScreen(seen, 91, 60000, false); |
| 97 | const again = []; |
| 98 | for (let t = 61; t < 80; t++) { |
| 99 | judgeScreen(seen, 91, t * 1000, false); |
| 100 | if (reattachDue(seen, t * 1000)) again.push(t); |
| 101 | } |
| 102 | assert.deepEqual(again, [68], 'a new stall is a new try'); |
| 103 | }); |
| 104 | |
| 105 | // The ladder, driven once a second the way answerScreenStalls drives it. |
| 106 | function ladder(from, to, reported, opts = {}) { |
| 107 | const rec = opts.rec || { stage: '', at: 0, lastRebuild: 0 }; |
| 108 | let resharedAt = opts.resharedAt || 0; |
| 109 | const out = []; |
| 110 | for (let t = from; t < to; t++) { |
| 111 | const now = t * 1000; |
| 112 | if (!reported(t)) { |
| 113 | rec.stage = ''; |
| 114 | continue; |
| 115 | } |
| 116 | const owed = opts.owed ? opts.owed(t) : true; |
| 117 | const free = !resharedAt || now - resharedAt >= 300000; |
| 118 | const action = escalateShare(rec, now, owed, free); |
| 119 | if (action === 'reshare') resharedAt = now; |
| 120 | if (action) out.push([t, action]); |
| 121 | } |
| 122 | return { out, rec, resharedAt }; |
| 123 | } |
| 124 | |
| 125 | test('a report keyframes do not answer is rebuilt, then shared again, then given up', () => { |
| 126 | const { out, rec } = ladder(0, 45, (t) => t >= 3); |
| 127 | assert.deepEqual(out, [[9, 'rebuild'], [19, 'reshare'], [29, 'failed']]); |
| 128 | assert.equal(rec.stage, 'failed'); |
| 129 | }); |
| 130 | |
| 131 | test('a rebuild that works ends the ladder', () => { |
| 132 | const { out, rec } = ladder(0, 60, (t) => t >= 3 && t < 12); |
| 133 | assert.deepEqual(out, [[9, 'rebuild']]); |
| 134 | assert.equal(rec.stage, ''); |
| 135 | }); |
| 136 | |
| 137 | test('rebuilds and shares again are rationed', () => { |
| 138 | const first = ladder(0, 20, (t) => t >= 3 && t < 12); |
| 139 | const second = ladder(20, 50, (t) => t >= 25 && t < 34, { rec: first.rec, resharedAt: first.resharedAt }); |
| 140 | assert.deepEqual(second.out, [[31, 'reshare']], 'no second rebuild inside a minute'); |
| 141 | const third = ladder(80, 120, (t) => t >= 85, { rec: second.rec, resharedAt: second.resharedAt }); |
| 142 | assert.deepEqual(third.out, [[91, 'rebuild'], [101, 'failed']], 'and no second share again inside five'); |
| 143 | }); |
| 144 | |
| 145 | test('nothing sent is nothing to escalate', () => { |
| 146 | const { out, rec } = ladder(0, 60, (t) => t >= 3, { owed: () => false }); |
| 147 | assert.deepEqual(out, []); |
| 148 | assert.equal(rec.stage, 'keyframes'); |
| 149 | }); |
| 150 | |
| 151 | test('the notice moves on in words, the app\'s own', () => { |
| 152 | assert.equal(noticeWords([], [], []), ''); |
| 153 | assert.equal(noticeWords(['Samuel'], [], []), 'Samuel’s view of your screen stalled — recovering'); |
| 154 | assert.equal(noticeWords([], ['Samuel'], []), 'Restarting your share for Samuel…'); |
| 155 | assert.equal(noticeWords([], [], ['Samuel']), 'Couldn’t restore Samuel’s view — try Stop and Share again'); |
| 156 | assert.equal(noticeWords(['Tony'], [], ['Samuel']), |
| 157 | 'Couldn’t restore Samuel’s view — try Stop and Share again. Tony’s view of your screen stalled — recovering'); |
| 158 | assert.equal(noticeWords([], ['A', 'B', 'C'], []), 'Restarting your share for 3 people…'); |
| 159 | }); |
| 160 | |
| 161 | test('a stalled screen\'s statistics name what stopped', () => { |
| 162 | const base = { mid: '3', packets: 1000, lost: 0, received: 300, decoded: 300, dropped: 0, keyframes: 2, pli: 0, fir: 0, |
| 163 | nack: 0, freezes: 0, width: 1920, height: 1080, decoder: 'ExternalDecoder', muted: false }; |
| 164 | const wedged = Object.assign({}, base, { packets: 1400, received: 420, dropped: 110 }); |
| 165 | assert.match(readingMoved(wedged, base), /^packets \+400, frames received \+120, decoded \+0, dropped \+110.* — frames arriving, none decoded \(the decoder\)$/); |
| 166 | assert.match(readingMoved(base, base), /no packets arriving/); |
| 167 | assert.match(readingMoved(Object.assign({}, base, { mid: '5' }), base), /no earlier reading$/); |
| 168 | }); |
| 169 | |
| 170 | test('a moving screen is never stalled, and a frozen test window is', () => { |
| 171 | const seen = { decoded: 0, movedAt: 0 }; |
| 172 | for (let t = 0; t < 20; t++) assert.equal(judgeScreen(seen, t, t * 1000, false), false); |
| 173 | const frozen = []; |
| 174 | for (let t = 20; t < 30; t++) if (judgeScreen(seen, t, t * 1000, true)) frozen.push(t); |
| 175 | assert.equal(frozen[0], 23, 'the gate\'s frozen window counts as no new frames'); |
| 176 | assert.equal(judgeScreen(seen, 40, 30000, false), false, 'and frames moving again clears it'); |
| 177 | }); |
| 178 | |
| 179 | test('the sharer\'s notice names whose view stalled', () => { |
| 180 | assert.equal(recoveringWords([]), ''); |
| 181 | assert.equal(recoveringWords(['Vicky']), 'Vicky’s view of your screen stalled — recovering'); |
| 182 | assert.equal(recoveringWords(['Tony', 'Vicky']), 'Tony’s and Vicky’s views of your screen stalled — recovering'); |
| 183 | assert.equal(recoveringWords(['A', 'B', 'C']), '3 people’s views of your screen stalled — recovering'); |
| 184 | }); |
| 185 | |
| 186 | test('screenStalled travels only with bad news, and never to a server that refused it', () => { |
| 187 | const mine = { muted: false, cameraOff: false }; |
| 188 | assert.deepEqual(mediaPayload(mine, false, null, false, []), { muted: false, cameraOff: false }); |
| 189 | assert.deepEqual(mediaPayload(mine, false, null, false, [ID]), { muted: false, cameraOff: false, screenStalled: [ID] }); |
| 190 | assert.deepEqual(mediaPayload(mine, false, null, true, [ID]), { muted: false, cameraOff: false }); |
| 191 | // The four-argument calls from before still mean what they meant. |
| 192 | assert.deepEqual(mediaPayload(mine, true, [ID], false), { muted: false, cameraOff: false, audioBlocked: true, noSoundFrom: [ID] }); |
| 193 | }); |
| 194 | |
| 195 | test('a report\'s screenStalled is read forgivingly and changes the key', () => { |
| 196 | const m = mediaFrom({ muted: false, cameraOff: false, screenStalled: [ID, 'nope', ID, 7] }); |
| 197 | assert.deepEqual(m.screenStalled, [ID]); |
| 198 | assert.equal(mediaFrom({ muted: false, cameraOff: false, screenStalled: [] }).screenStalled, undefined); |
| 199 | assert.notEqual(mediaKey(m), mediaKey({ muted: false, cameraOff: false })); |
| 200 | assert.notEqual(mediaKey({ muted: false, cameraOff: false, noSoundFrom: [ID] }), mediaKey(m), |
| 201 | 'the same peer silent is not the same report as the same peer stalled'); |
| 202 | }); |
| 203 | |