| 1 | // room-quality.test.mjs — the anonymous quality report and "Report a |
| 2 | // problem" (spec §15.26, 2026-10-03), gated without a browser. |
| 3 | // |
| 4 | // node --test test/browser/room-quality.test.mjs |
| 5 | // |
| 6 | // The report's builders are lifted out of the shipped room.js by name, |
| 7 | // with the constants they read, as room-reconnect.test.mjs does; the |
| 8 | // client's description and the sheet's answers out of report.js. The |
| 9 | // fixtures are getStats walks as Chromium writes them, two windows |
| 10 | // apart, with the fields a report must never carry — candidate |
| 11 | // addresses and ports — present in them, so their absence from the |
| 12 | // report is a finding and not an accident of the fixture. |
| 13 | import { test } from 'node:test'; |
| 14 | import assert from 'node:assert/strict'; |
| 15 | import { readFileSync } from 'node:fs'; |
| 16 | import { fileURLToPath } from 'node:url'; |
| 17 | |
| 18 | const roomSrc = readFileSync(fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)), 'utf8'); |
| 19 | const reportSrc = readFileSync(fileURLToPath(new URL('../../internal/calls/static/report.js', import.meta.url)), 'utf8'); |
| 20 | |
| 21 | function lift(src, file, name) { |
| 22 | const start = src.indexOf('function ' + name + '('); |
| 23 | assert.notEqual(start, -1, name + ' is not in ' + file + ' any more — this test is stale, not passing'); |
| 24 | let depth = 0; |
| 25 | for (let i = src.indexOf('{', start); i < src.length; i++) { |
| 26 | if (src[i] === '{') depth += 1; |
| 27 | else if (src[i] === '}') { |
| 28 | depth -= 1; |
| 29 | if (depth === 0) return src.slice(start, i + 1); |
| 30 | } |
| 31 | } |
| 32 | assert.fail('unbalanced braces reading ' + name); |
| 33 | } |
| 34 | function liftConst(name) { |
| 35 | const m = roomSrc.match(new RegExp('const ' + name + ' = ([0-9.e]+);')); |
| 36 | assert.ok(m, name + ' is not a numeric const in room.js any more'); |
| 37 | return 'const ' + name + ' = ' + m[1] + ';'; |
| 38 | } |
| 39 | |
| 40 | const CONSTS = ['QUALITY_MAX_PEERS', 'QUALITY_MAX_SECONDS', 'QUALITY_MAX_MILLIS', 'QUALITY_MAX_COUNT', |
| 41 | 'QUALITY_MAX_KBPS', 'QUALITY_MAX_PIXELS', 'QUALITY_MAX_FPS', 'QUALITY_LOOK_MAX'].map(liftConst).join('\n'); |
| 42 | const ROOM = ['qualityCeiling', 'qualitySet', 'qualityReading', 'qualityPeer', 'qualityWatchRecord', |
| 43 | 'qualityWatch', 'qualityTake', 'qualityReport', 'deviceKind']; |
| 44 | // eslint-disable-next-line no-new-func |
| 45 | const q = new Function(CONSTS + '\n' + ROOM.map((n) => lift(roomSrc, 'room.js', n)).join('\n') + |
| 46 | '; return { ' + ROOM.join(', ') + ' };')(); |
| 47 | const REPORT = ['reportClient', 'problemAnswer', 'problemBody']; |
| 48 | // eslint-disable-next-line no-new-func |
| 49 | const r = new Function(REPORT.map((n) => lift(reportSrc, 'report.js', n)).join('\n') + |
| 50 | '; return { ' + REPORT.join(', ') + ' };')(); |
| 51 | |
| 52 | // The bounds must be protocol/quality.go's, or the server refuses what |
| 53 | // this clamps to. |
| 54 | test('the bounds are the protocol package\'s', () => { |
| 55 | const go = readFileSync(fileURLToPath(new URL('../../protocol/quality.go', import.meta.url)), 'utf8'); |
| 56 | const bound = (name) => { |
| 57 | const m = go.match(new RegExp(name + '\\s*=\\s*([0-9_.e]+)(?:\\s*<<\\s*([0-9]+))?')); |
| 58 | assert.ok(m, name + ' is not in protocol/quality.go'); |
| 59 | return Number(m[1].replace(/_/g, '')) * 2 ** Number(m[2] || 0); |
| 60 | }; |
| 61 | const room = (name) => Number(liftConst(name).match(/= ([0-9.e]+);/)[1]); |
| 62 | assert.equal(room('QUALITY_MAX_PEERS'), bound('MaxQualityPeers')); |
| 63 | assert.equal(room('QUALITY_MAX_SECONDS'), bound('MaxQualitySeconds')); |
| 64 | assert.equal(room('QUALITY_MAX_MILLIS'), bound('MaxQualityMillis')); |
| 65 | assert.equal(room('QUALITY_MAX_COUNT'), bound('MaxQualityCount')); |
| 66 | assert.equal(room('QUALITY_MAX_KBPS'), bound('MaxQualityKbps')); |
| 67 | assert.equal(room('QUALITY_MAX_PIXELS'), bound('MaxQualityPixels')); |
| 68 | assert.equal(room('QUALITY_MAX_FPS'), bound('MaxQualityFPS')); |
| 69 | assert.equal(room('QUALITY_MAX_BODY'), bound('MaxQualityBody')); |
| 70 | }); |
| 71 | |
| 72 | // ── fixtures ───────────────────────────────────────────────────────── |
| 73 | |
| 74 | const PEER = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'; |
| 75 | const OTHER = '0f1e2d3c4b5a69788796a5b4c3d2e1f0'; |
| 76 | |
| 77 | // One connection's walk, Chromium's shape. `t` scales the counters, so |
| 78 | // window two is window one plus what happened in thirty seconds. |
| 79 | function walk(t) { |
| 80 | const reports = [ |
| 81 | { id: 'OA', type: 'outbound-rtp', kind: 'audio', mid: '0', ssrc: 11, bytesSent: 10000 + t * 4000, packetsSent: 100 + t * 50 }, |
| 82 | { id: 'OV', type: 'outbound-rtp', kind: 'video', mid: '1', ssrc: 12, bytesSent: 500000 + t * 100000, packetsSent: 1000 + t * 300, |
| 83 | frameWidth: 640, frameHeight: 480, framesPerSecond: 29.97, qualityLimitationReason: t ? 'bandwidth' : 'none' }, |
| 84 | { id: 'RIA', type: 'remote-inbound-rtp', kind: 'audio', localId: 'OA', ssrc: 11, packetsLost: 2 + t * 3, roundTripTime: 0.0423, jitter: 0.0021 }, |
| 85 | { id: 'RIV', type: 'remote-inbound-rtp', kind: 'video', localId: 'OV', ssrc: 12, packetsLost: 5 + t * 7, roundTripTime: 0.05, jitter: 0.004 }, |
| 86 | { id: 'IA', type: 'inbound-rtp', kind: 'audio', mid: '0', ssrc: 21, bytesReceived: 20000 + t * 3750, packetsReceived: 200 + t * 1500, |
| 87 | packetsLost: 1 + t * 15, jitter: 0.0031, totalSamplesReceived: 48000 + t * 1440000, concealedSamples: 100 + t * 4800, |
| 88 | insertedSamplesForDeceleration: 10 + t * 20, removedSamplesForAcceleration: 5 + t * 30, |
| 89 | jitterBufferDelay: 2880 + t * 86400, jitterBufferEmittedCount: 48000 + t * 1440000 }, |
| 90 | { id: 'IV', type: 'inbound-rtp', kind: 'video', mid: '1', ssrc: 22, bytesReceived: 400000 + t * 3000000, packetsReceived: 900 + t * 3000, |
| 91 | packetsLost: 0 + t * 30, jitter: 0.012, freezeCount: 1 + t * 2, totalFreezesDuration: 0.5 + t * 1.25, framesDecoded: 300 + t * 900, |
| 92 | framesDropped: 2 + t * 4, frameWidth: 1280, frameHeight: 720, framesPerSecond: 30 }, |
| 93 | { id: 'T', type: 'transport', selectedCandidatePairId: 'CP' }, |
| 94 | { id: 'CP', type: 'candidate-pair', localCandidateId: 'L', remoteCandidateId: 'R', state: 'succeeded', nominated: true, |
| 95 | currentRoundTripTime: 0.031 }, |
| 96 | { id: 'L', type: 'local-candidate', candidateType: 'relay', relayProtocol: 'tls', protocol: 'udp', address: '192.0.2.44', port: 54321, |
| 97 | ip: '192.0.2.44', url: 'turns:relay.example.net:443' }, |
| 98 | { id: 'R', type: 'remote-candidate', candidateType: 'srflx', protocol: 'udp', address: '198.51.100.7', port: 61000, ip: '198.51.100.7' }, |
| 99 | ]; |
| 100 | if (t) { |
| 101 | // A screen arriving in window two only: new streams count from zero. |
| 102 | reports.push({ id: 'IS', type: 'inbound-rtp', kind: 'video', mid: '3', ssrc: 23, bytesReceived: 750000, packetsReceived: 800, |
| 103 | packetsLost: 4, jitter: 0.02, freezeCount: 3, totalFreezesDuration: 6, framesDecoded: 150, framesDropped: 9, |
| 104 | frameWidth: 1920, frameHeight: 1080, framesPerSecond: 5 }); |
| 105 | } |
| 106 | return new Map(reports.map((x) => [x.id, x])); |
| 107 | } |
| 108 | const classify = (x) => (x.kind === 'audio' ? 'audio' : x.mid === '3' ? 'screen' : 'video'); |
| 109 | const FORBIDDEN = ['192.0.2.44', '198.51.100.7', '54321', '61000', 'relay.example.net', 'address', 'port', '"ip"', 'url']; |
| 110 | |
| 111 | // ── the report ─────────────────────────────────────────────────────── |
| 112 | |
| 113 | test('two windows: deltas, rates over the window, gauges as they stand', () => { |
| 114 | const one = q.qualityReading(walk(0), classify); |
| 115 | const two = q.qualityReading(walk(1), classify); |
| 116 | const p = q.qualityPeer(one, two, 30); |
| 117 | |
| 118 | assert.deepEqual(p.path, { local: 'relay', remote: 'srflx', protocol: 'tls' }); |
| 119 | assert.equal(p.rtt, 31); |
| 120 | |
| 121 | assert.deepEqual(p.out.audio, { kbps: 1.1, packets: 50, lost: 3, rtt: 42.3, jitter: 2.1 }); |
| 122 | assert.deepEqual(p.out.video, { kbps: 26.7, packets: 300, width: 640, height: 480, fps: 30, limit: 'bandwidth', lost: 7, rtt: 50, jitter: 4 }); |
| 123 | |
| 124 | assert.deepEqual(p.in.audio, { |
| 125 | kbps: 1, packets: 1500, lost: 15, jitter: 3.1, samples: 1440000, concealed: 4800, inserted: 20, removed: 30, |
| 126 | // 86,400 s of buffer delay summed over 1,440,000 emitted samples: 60 ms each. |
| 127 | jitterBufferMs: 60, |
| 128 | }); |
| 129 | assert.deepEqual(p.in.video, { |
| 130 | kbps: 800, packets: 3000, lost: 30, jitter: 12, freezes: 2, freezeSeconds: 1.3, decoded: 900, dropped: 4, |
| 131 | width: 1280, height: 720, fps: 30, |
| 132 | }); |
| 133 | // The screen was new in window two: everything it has counted is this |
| 134 | // window's. |
| 135 | assert.deepEqual(p.in.screen, { |
| 136 | kbps: 200, packets: 800, lost: 4, jitter: 20, freezes: 3, freezeSeconds: 6, decoded: 150, dropped: 9, |
| 137 | width: 1920, height: 1080, fps: 5, |
| 138 | }); |
| 139 | }); |
| 140 | |
| 141 | test('the first window counts from zero, and a counter that goes back counts nothing', () => { |
| 142 | const first = q.qualityPeer(null, q.qualityReading(walk(0), classify), 10); |
| 143 | assert.equal(first.out.audio.packets, 100); |
| 144 | assert.equal(first.in.video.decoded, 300); |
| 145 | const back = q.qualityPeer(q.qualityReading(walk(1), classify), q.qualityReading(walk(0), classify), 30); |
| 146 | assert.equal(back.out.audio.packets, 0); |
| 147 | assert.equal(back.in.audio.lost, 0); |
| 148 | }); |
| 149 | |
| 150 | test('never an address, a port or a URL, whatever the walk carries', () => { |
| 151 | const report = q.qualityReport({ |
| 152 | seconds: 30, |
| 153 | client: { app: 'web', browser: 'chrome' }, |
| 154 | devices: { input: 'bluetooth', output: 'built-in' }, |
| 155 | peers: [{ id: PEER, prev: q.qualityReading(walk(0), classify), now: q.qualityReading(walk(1), classify) }], |
| 156 | }); |
| 157 | const json = JSON.stringify(report); |
| 158 | for (const bad of FORBIDDEN) assert.ok(!json.includes(bad), 'the report carries ' + bad + ': ' + json); |
| 159 | assert.equal(report.v, 1); |
| 160 | assert.equal(report.seconds, 30); |
| 161 | assert.equal(report.peers[0].peer, PEER); |
| 162 | assert.equal(report.final, undefined); |
| 163 | }); |
| 164 | |
| 165 | test('clamped to the protocol\'s ceilings; NaN, infinite and negative left out', () => { |
| 166 | const now = { |
| 167 | streams: new Map([ |
| 168 | ['in:x', { dir: 'in', kind: 'video', counters: { bytesReceived: 1e12, packetsReceived: 5e9, packetsLost: -3, framesDecoded: NaN }, |
| 169 | gauges: { jitter: -0.5, frameWidth: 99999, frameHeight: Infinity, framesPerSecond: 1000 } }], |
| 170 | ['remote:y', { dir: 'remote', kind: 'audio', counters: { packetsLost: 2 }, gauges: { roundTripTime: 1e6, jitter: NaN } }], |
| 171 | ]), |
| 172 | path: { local: 'host', remote: 'host', protocol: 'udp', rtt: -1 }, |
| 173 | }; |
| 174 | const p = q.qualityPeer(null, now, 1); |
| 175 | assert.deepEqual(p.in.video, { kbps: 1e6, packets: 1e9, lost: 0, width: 16384, fps: 240 }); |
| 176 | assert.deepEqual(p.out.audio, { lost: 2, rtt: 600000 }); |
| 177 | assert.equal(p.rtt, undefined); |
| 178 | const report = q.qualityReport({ seconds: 5000, client: { app: 'web' }, peers: [] }); |
| 179 | assert.equal(report.seconds, 600); |
| 180 | assert.equal(report.peers, undefined); |
| 181 | }); |
| 182 | |
| 183 | test('an unknown candidate type, an unknown transport, and Firefox\'s selected pair', () => { |
| 184 | const ff = new Map([ |
| 185 | ['P', { id: 'P', type: 'candidate-pair', selected: true, localCandidateId: 'L', remoteCandidateId: 'R' }], |
| 186 | ['L', { id: 'L', type: 'local-candidate', candidateType: 'weird', protocol: 'sctp', address: '10.0.0.1' }], |
| 187 | ['R', { id: 'R', type: 'remote-candidate', candidateType: 'prflx', protocol: 'tcp' }], |
| 188 | ]); |
| 189 | const p = q.qualityPeer(null, q.qualityReading(ff, classify), 30); |
| 190 | assert.deepEqual(p.path, { local: 'unknown', remote: 'prflx', protocol: 'unknown' }); |
| 191 | const tcpRelay = new Map([ |
| 192 | ['T', { id: 'T', type: 'transport', selectedCandidatePairId: 'P' }], |
| 193 | ['P', { id: 'P', type: 'candidate-pair', localCandidateId: 'L', remoteCandidateId: 'R' }], |
| 194 | ['L', { id: 'L', type: 'local-candidate', candidateType: 'host', protocol: 'tcp' }], |
| 195 | ['R', { id: 'R', type: 'remote-candidate', candidateType: 'host' }], |
| 196 | ]); |
| 197 | assert.equal(q.qualityPeer(null, q.qualityReading(tcpRelay, classify), 30).path.protocol, 'tcp'); |
| 198 | }); |
| 199 | |
| 200 | test('at most four peers, each a well-formed peer id, none with nothing to say', () => { |
| 201 | const now = q.qualityReading(walk(0), classify); |
| 202 | const ids = ['1', '2', '3', '4', '5', '6'].map((d) => d.repeat(32)); |
| 203 | const report = q.qualityReport({ |
| 204 | seconds: 30, |
| 205 | client: { app: 'web' }, |
| 206 | peers: [ |
| 207 | { id: 'not a peer id', prev: null, now }, |
| 208 | { id: OTHER, prev: null, now: { streams: new Map(), path: null } }, |
| 209 | ...ids.map((id) => ({ id, prev: null, now })), |
| 210 | ], |
| 211 | }); |
| 212 | assert.deepEqual(report.peers.map((p) => p.peer), ids.slice(0, 4)); |
| 213 | }); |
| 214 | |
| 215 | test('the screen ladder and the mending by person, and only what happened', () => { |
| 216 | const now = q.qualityReading(walk(1), classify); |
| 217 | const report = q.qualityReport({ |
| 218 | seconds: 30, |
| 219 | final: true, |
| 220 | client: { app: 'web' }, |
| 221 | events: { reconnecting: 1, reconnectingSeconds: 4.2 }, |
| 222 | peers: [{ id: PEER, prev: null, now, ladder: { keyframes: 3, rebuilds: 1, reshares: 0, failures: 0 }, reattaches: 1, |
| 223 | events: { cantHearSeconds: 2.5, noSoundFromUs: true } }], |
| 224 | }); |
| 225 | assert.equal(report.final, true); |
| 226 | assert.deepEqual(report.events, { reconnecting: 1, reconnectingSeconds: 4.2 }); |
| 227 | const p = report.peers[0]; |
| 228 | assert.deepEqual(p.out.screen, { keyframes: 3, rebuilds: 1 }); |
| 229 | assert.equal(p.in.screen.reattaches, 1); |
| 230 | assert.deepEqual(p.events, { cantHearSeconds: 2.5, noSoundFromUs: true }); |
| 231 | }); |
| 232 | |
| 233 | // ── the notices ────────────────────────────────────────────────────── |
| 234 | |
| 235 | test('the notices: counted once each time they appear, timed while they show', () => { |
| 236 | const rec = q.qualityWatchRecord(); |
| 237 | const quiet = { reconnecting: false, cantHear: false }; |
| 238 | const look = (t, call, peer) => q.qualityWatch(rec, t, { call, audioBlocked: false, peers: new Map([[PEER, peer]]) }); |
| 239 | // Can't hear us for the first 1.4 s; then a call-wide "Reconnecting…" |
| 240 | // from 5 s to 8 s; then one on this person from 10 s to 11 s. |
| 241 | for (let t = 0; t <= 15000; t += 200) { |
| 242 | look(t, t >= 5000 && t < 8000, { |
| 243 | reconnecting: t >= 10000 && t < 11000, |
| 244 | cantHear: t < 1400, |
| 245 | noSound: t === 12000, |
| 246 | }); |
| 247 | } |
| 248 | const got = q.qualityTake(rec); |
| 249 | assert.deepEqual(got.call, { reconnecting: 1, reconnectingSeconds: 3 }); |
| 250 | assert.deepEqual(got.peers.get(PEER), { reconnecting: 1, reconnectingSeconds: 1, cantHearSeconds: 1.4, noSound: true }); |
| 251 | // A quiet window says nothing. |
| 252 | look(15200, false, quiet); |
| 253 | look(15400, false, quiet); |
| 254 | const next = q.qualityTake(rec); |
| 255 | assert.deepEqual(next.call, {}); |
| 256 | assert.equal(next.peers.size, 0); |
| 257 | }); |
| 258 | |
| 259 | test('a suspended tab\'s gap is not a notice someone saw, and someone gone is forgotten', () => { |
| 260 | const rec = q.qualityWatchRecord(); |
| 261 | q.qualityWatch(rec, 0, { call: true, audioBlocked: true, peers: new Map([[PEER, { reconnecting: false, cantHear: true }]]) }); |
| 262 | q.qualityWatch(rec, 60000, { call: true, audioBlocked: false, peers: new Map() }); |
| 263 | const got = q.qualityTake(rec); |
| 264 | assert.deepEqual(got.call, { reconnecting: 1, reconnectingSeconds: 1, audioBlocked: true }); |
| 265 | assert.deepEqual(got.peers.get(PEER), { cantHearSeconds: 1 }); |
| 266 | assert.equal(rec.peers.size, 0); |
| 267 | }); |
| 268 | |
| 269 | // ── the devices and the client ─────────────────────────────────────── |
| 270 | |
| 271 | test('a device\'s kind by its label, which never leaves', () => { |
| 272 | const cases = [ |
| 273 | ['Paul’s AirPods Pro', 'bluetooth'], |
| 274 | ['Bose QC35 II', 'bluetooth'], |
| 275 | ['Galaxy Buds2 Pro (Bluetooth)', 'bluetooth'], |
| 276 | ['MacBook Pro Microphone (Built-in)', 'built-in'], |
| 277 | ['Default - MacBook Air Speakers (Built-in)', 'built-in'], |
| 278 | ['Microphone Array (Realtek(R) Audio)', 'built-in'], |
| 279 | ['Scarlett 2i2 USB', 'usb'], |
| 280 | ['USB Audio Device (0d8c:0014)', 'usb'], |
| 281 | ['External Headphones', 'wired'], |
| 282 | ['BlackHole 2ch (Virtual)', 'virtual'], |
| 283 | ['Paul’s iPhone Microphone', 'continuity'], |
| 284 | ['Living Room (AirPlay)', 'airplay'], |
| 285 | ['Studio Display Microphone', 'unknown'], |
| 286 | ['', 'unknown'], |
| 287 | [undefined, 'unknown'], |
| 288 | ]; |
| 289 | for (const [label, kind] of cases) assert.equal(q.deviceKind(label), kind, String(label)); |
| 290 | }); |
| 291 | |
| 292 | test('the client: enums and digits from the user agent, never the string', () => { |
| 293 | const MAC_CHROME = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.50 Safari/537.36'; |
| 294 | const cases = [ |
| 295 | [MAC_CHROME, { platform: 'macOS', platformVersion: '15.5.0' }, 'dev', 0, |
| 296 | { app: 'web', version: 'dev', browser: 'chrome', browserVersion: '138.0', os: 'macos', osVersion: '15.5.0' }], |
| 297 | // No hints: macOS's frozen 10_15_7 is not a version. |
| 298 | [MAC_CHROME, null, 'de04e2d', 0, { app: 'web', version: 'de04e2d', browser: 'chrome', browserVersion: '138.0', os: 'macos' }], |
| 299 | ['Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1', |
| 300 | null, '', 5, { app: 'web', browser: 'safari', browserVersion: '17.4', os: 'ios', osVersion: '17.4.1' }], |
| 301 | // An iPad asking for the desktop site says Macintosh; its touch points say otherwise. |
| 302 | ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15', |
| 303 | null, '', 5, { app: 'web', browser: 'safari', browserVersion: '18.2', os: 'ipados' }], |
| 304 | ['Mozilla/5.0 (iPad; CPU OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1', |
| 305 | null, '', 5, { app: 'web', browser: 'chrome', browserVersion: '120.0', os: 'ipados', osVersion: '16.6' }], |
| 306 | ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.3351.55', |
| 307 | { platform: 'Windows', platformVersion: '19.0.0' }, '', 0, |
| 308 | { app: 'web', browser: 'edge', browserVersion: '138.0', os: 'windows', osVersion: '19.0.0' }], |
| 309 | ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0', null, '', 0, |
| 310 | { app: 'web', browser: 'firefox', browserVersion: '139.0', os: 'linux' }], |
| 311 | ['Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36', null, '', 5, |
| 312 | { app: 'web', browser: 'chrome', browserVersion: '137.0', os: 'android', osVersion: '10' }], |
| 313 | ['Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 OPR/120.0.0.0', |
| 314 | null, 'not a version!', 0, { app: 'web', browser: 'other', os: 'chromeos' }], |
| 315 | ['', null, '', 0, { app: 'web', browser: 'other', os: 'other' }], |
| 316 | ]; |
| 317 | for (const [ua, hints, version, touch, want] of cases) { |
| 318 | const got = r.reportClient(ua, hints, version, touch); |
| 319 | assert.deepEqual(got, want, ua); |
| 320 | const json = JSON.stringify(got); |
| 321 | assert.ok(!json.includes('Mozilla') && !json.includes('AppleWebKit'), json); |
| 322 | } |
| 323 | }); |
| 324 | |
| 325 | // ── "Report a problem" ─────────────────────────────────────────────── |
| 326 | |
| 327 | test('the sheet\'s answers', () => { |
| 328 | assert.deepEqual(r.problemAnswer(200, { ref: 'K7Q2M9PD' }), { ref: 'K7Q2M9PD', text: '' }); |
| 329 | assert.equal(r.problemAnswer(200, { ref: '<b>x</b>' }).text, 'The report could not be sent.'); |
| 330 | assert.equal(r.problemAnswer(429, null).text, 'Too many reports from this call — try again in a minute.'); |
| 331 | assert.equal(r.problemAnswer(404, {}).text, 'This call can no longer be reported.'); |
| 332 | assert.equal(r.problemAnswer(403, {}).text, 'The report could not be sent.'); |
| 333 | assert.equal(r.problemAnswer(0, null).text, 'The report could not be sent.'); |
| 334 | }); |
| 335 | |
| 336 | test('the problem body fits the server\'s 64 KB, cutting the diagnostics and never the note', () => { |
| 337 | const client = { app: 'web' }; |
| 338 | const small = JSON.parse(r.problemBody('It froze', 'all fine', client, 65000)); |
| 339 | assert.deepEqual(small, { note: 'It froze', diagnostics: 'all fine', client }); |
| 340 | const big = r.problemBody('x'.repeat(1200), 'line\n'.repeat(40000), client, 65000); |
| 341 | assert.ok(new TextEncoder().encode(big).length <= 65000); |
| 342 | const parsed = JSON.parse(big); |
| 343 | assert.equal(parsed.note.length, 1000); |
| 344 | assert.ok(parsed.diagnostics.endsWith('[cut short to fit]')); |
| 345 | }); |
| 346 | |
| 347 | test('the diagnostics a problem report sends name nobody', () => { |
| 348 | const names = new Map([[PEER, 'Paul Campbell'], [OTHER, 'bea']]); |
| 349 | const pc = { connectionState: 'connected', signalingState: 'stable', iceConnectionState: 'connected' }; |
| 350 | const pcs = new Map([[PEER, { pc, shareSend: { width: 1280, height: 720, fps: 30, framesEncoded: 9, framesSent: 9, keyFramesEncoded: 1 } }, |
| 351 | ], [OTHER, { pc, shareSend: null, shareTx: {} }]]); |
| 352 | const env = { |
| 353 | names, pcs, |
| 354 | state: { admitted: true, stopped: false }, |
| 355 | share: { stream: {}, track: {} }, |
| 356 | shareMode: () => 'balanced', |
| 357 | screenAsks: new Map([[OTHER, { stage: 'keyframes' }]]), |
| 358 | shareCounts: { keyframes: 1, rebuilds: 0, reshares: 0, failures: 0 }, |
| 359 | screenReadings: new Map([[PEER + '#share', { mid: '3', width: 1, height: 1 }]]), |
| 360 | readingSummary: () => 'a reading', |
| 361 | screenStalled: [PEER], |
| 362 | screenReattaches: 0, |
| 363 | screenLines: ['10:00:00.000 keyframe for ' + OTHER.slice(0, 6)], |
| 364 | SCREEN_LOG_LINES: 200, |
| 365 | SHARE_SUFFIX: '#share', |
| 366 | navigator: { userAgent: 'Test/1.0' }, |
| 367 | }; |
| 368 | // eslint-disable-next-line no-new-func |
| 369 | const callDiagnostics = new Function(...Object.keys(env), lift(roomSrc, 'room.js', 'callDiagnostics') + |
| 370 | '; return callDiagnostics;')(...Object.values(env)); |
| 371 | const anonymous = callDiagnostics({ anonymous: true }); |
| 372 | assert.ok(!/Paul|Campbell|bea\b/.test(anonymous), anonymous); |
| 373 | assert.ok(anonymous.includes('peer ' + PEER.slice(0, 6) + ': connection connected'), anonymous); |
| 374 | assert.ok(anonymous.includes('to peer ' + OTHER.slice(0, 6)), anonymous); |
| 375 | assert.ok(anonymous.includes('recovering peer ' + OTHER.slice(0, 6)), anonymous); |
| 376 | assert.ok(anonymous.includes('stalled here: peer ' + PEER.slice(0, 6)), anonymous); |
| 377 | // "Copy call diagnostics" keeps the names. |
| 378 | const named = callDiagnostics(); |
| 379 | assert.ok(named.includes('Paul Campbell (' + PEER.slice(0, 6) + ')'), named); |
| 380 | assert.ok(named.includes('recovering bea'), named); |
| 381 | }); |
| 382 | |