diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index b631355..7fd37ac 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -33,6 +33,11 @@ let shellHandler = null; let diagPollTimer = null; // polls a diag-smoothness widget's reported frame stats while the page is open let screenshotInterval = null; let remoteActive = false; +// Mirrors the Debug-logging checkbox so cleanup() can switch the device's stream back off. +// Without this, leaving the screen left the panel streaming into nothing: the device kept +// emitting, the dashboard kept relaying, and nobody was listening. The player carries its own +// auto-off as the backstop for the case this can't cover -- a tab that is killed, not closed. +let debugStreamOn = false; // Belt for the orphaned-stream fix: if the tab is hidden/closed/backgrounded while a Remote session // is live, stop it (the server also auto-stops on socket drop, but bfcache keeps the socket alive). @@ -189,6 +194,12 @@ export function render(container, deviceId) { const line = document.createElement('div'); const time = new Date(data.ts || Date.now()).toLocaleTimeString(); line.textContent = `${time} [${data.tag || ''}] ${data.message || ''}`; + // Every player sends a level and the panel used to render all four identically, so the one + // line that explains the fault sat in a wall of grey. Errors and warnings are why the + // operator opened this. + const LEVEL_COLOR = { e: '#f87171', w: '#fbbf24', d: '#64748b' }; + const tone = LEVEL_COLOR[(data.level || '').toLowerCase()]; + if (tone) line.style.color = tone; panel.appendChild(line); while (panel.childElementCount > 500) panel.removeChild(panel.firstChild); panel.scrollTop = panel.scrollHeight; @@ -1150,6 +1161,7 @@ function setupActions(device) { const enabled = e.target.checked; const panel = document.getElementById('debugLogPanel'); if (panel) panel.style.display = enabled ? 'block' : 'none'; + debugStreamOn = enabled; sendCommand(device.id, 'set_debug', { enabled }); }); @@ -2171,6 +2183,10 @@ export function cleanup() { if (shellHandler) off('shell-result', shellHandler); // #161 owner-tools listener if (screenshotInterval) clearInterval(screenshotInterval); if (remoteActive && currentDevice) stopRemote(currentDevice.id); + // Same reasoning as stopRemote above: an operator who navigates away has stopped watching, so + // the display should stop talking. Must run BEFORE currentDevice is cleared. + if (debugStreamOn && currentDevice) sendCommand(currentDevice.id, 'set_debug', { enabled: false }); + debugStreamOn = false; remoteActive = false; currentDevice = null; window._sendKey = null; diff --git a/server/player/index.html b/server/player/index.html index edd56cc..fd839a0 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -24,6 +24,12 @@ try { return Date.now(); } catch (e) { return new Date().getTime(); } } + // Live subscribers (the player's set_debug sink). Kept HERE rather than in the player + // script so entries recorded during boot -- before the player has even parsed -- reach a + // subscriber that registers later, via the backlog in window.__debugLog. + var subs = []; + var notifying = false; + function pushLog(entry) { try { entry.t = nowMs(); @@ -32,8 +38,22 @@ window.__debugLog.splice(0, window.__debugLog.length - MAX_LOG); } } catch (e) { /* we are the safety net; do not crash */ } + // Reentrancy guard, and it is not theoretical: this runs inside the console wrapper + // below, so a subscriber that logs anything at all -- directly, or through a library -- + // would call back in here and recurse until the stack gave out. The player would die of + // its own diagnostics. + if (notifying) return; + notifying = true; + try { + for (var s = 0; s < subs.length; s++) { + try { subs[s](entry); } catch (e) { /* one bad subscriber must not eat the rest */ } + } + } finally { notifying = false; } } window.__debugLog_push = pushLog; // shared pusher for debug-overlay.js + window.__debugLog_subscribe = function (fn) { + try { if (typeof fn === 'function') subs.push(fn); } catch (e) {} + }; pushLog({ type: 'init', @@ -542,7 +562,12 @@ if (!BS || typeof BS.onHostLog !== 'function') return; BS.onHostLog((line) => { try { - if (socket?.connected && config.deviceId) { + // While the live stream is ON the console.log below already reaches the dashboard + // through the debug sink, so emitting here too would show every host line twice. + // Host lines still go out unconditionally when it is OFF: the boot story is the one + // diagnostic nobody can ask for in advance, because it is over before the operator + // has a device to open. + if (!remoteDebug && socket?.connected && config.deviceId) { socket.emit('device:log', { device_id: config.deviceId, tag: line.tag, level: line.level, message: line.message @@ -555,6 +580,171 @@ } catch (e) { /* a bridge that throws here must not stop the player starting */ } } + /* ==================== Live remote debug (`set_debug`) ==================== + * + * The dashboard's per-device "Debug logging" checkbox sends a `set_debug` command and opens a + * live panel. The Android player has honoured it for a long time -- DebugLog.* mirrors its + * tagged lines to the dashboard while the box is ticked. The WEB player never implemented the + * command at all, so the panel opened, streamed nothing, and read as a display with nothing to + * say. In a browser that barely mattered: press F12. On a BrightSign it is the whole story -- + * there is no console, no adb, no logcat, and this panel is the only way to watch what the + * player thinks it is doing. + * + * Rather than hand-instrument eighty-odd call sites to match Android's tag-by-tag, this streams + * the ring buffer the error trap at the top of
has always filled: every console.log/ + * warn/error, every uncaught error with file:line and stack, every unhandled rejection, every + * failed resource load, plus the host's own boot report on BrightSign. Turning the stream on + * also FLUSHES that backlog, so the operator sees the failure that happened BEFORE they opened + * the screen -- which is the case they actually came to investigate, and which tailing a log + * cannot give them. + */ + let remoteDebug = false; // is the stream on right now + let debugSubscribed = false; // ring-buffer subscription is permanent once made + let debugAutoOffTimer = null; + let debugWindowAt = 0, debugWindowCount = 0, debugSuppressed = 0; + + // Fed by console.*, so the ceiling has to assume the worst: a video that fails to decode and is + // retried every frame turns a diagnostic aid into a flood down the same socket as playback. + const DEBUG_MAX_LINES_PER_SEC = 40; + // Ticking the box and walking away must not leave a panel streaming for the rest of the week. + // The dashboard turns it off on teardown, but a closed laptop or a killed tab never sends that, + // and the device is the only party in a position to be sure. + const DEBUG_AUTO_OFF_MS = 30 * 60 * 1000; + + function debugSend(tag, level, message) { + if (!socket?.connected || !config.deviceId) return; + try { + socket.emit('device:log', { + device_id: config.deviceId, + tag: String(tag || 'player').slice(0, 64), + level: String(level || 'i').slice(0, 8), + message: String(message == null ? '' : message).slice(0, 2000), + }); + } catch (e) { /* a diagnostic that throws is worse than one that is missing */ } + } + + /* + * One live line, rate-limited. Over the cap the lines are COUNTED, not queued -- an operator + * needs to know output was dropped far more than they need the four hundredth copy of one + * message, and a queue would go on replaying the flood after it stopped. + */ + function debugEmitLine(tag, level, message) { + if (!remoteDebug) return; + const now = Date.now(); + if (now - debugWindowAt >= 1000) { + debugWindowAt = now; + const dropped = debugSuppressed; + debugWindowCount = 0; + debugSuppressed = 0; + if (dropped > 0) { + debugWindowCount++; + debugSend('debug', 'w', `${dropped} line(s) suppressed — rate limit`); + } + } + if (debugWindowCount >= DEBUG_MAX_LINES_PER_SEC) { debugSuppressed++; return; } + debugWindowCount++; + debugSend(tag, level, message); + } + + // The player already prefixes most of its console lines with [wall], [bs], [group-sync] and so + // on -- the same shape Android's tags have -- so lift that into the tag column and the panel + // reads the same on both platforms instead of being one long undifferentiated column. + const DEBUG_TAG_RE = /^\s*\[([a-zA-Z0-9/_.-]{1,24})\]\s*/; + function debugTagFor(entry) { + if (entry.tag) return String(entry.tag); + const m = DEBUG_TAG_RE.exec(entry.message || ''); + if (m) return m[1]; + if (entry.type === 'error' || entry.type === 'rejection') return 'error'; + if (entry.type === 'timing' || entry.type === 'init') return entry.type; + return 'player'; + } + + function debugLevelFor(entry) { + if (entry.level) return entry.level; + if (entry.type === 'console.error' || entry.type === 'error' || entry.type === 'rejection') return 'e'; + if (entry.type === 'console.warn') return 'w'; + return 'i'; + } + + // The non-console entry types carry structured fields rather than a message, and they are the + // valuable ones -- an uncaught error is worth nothing without its file, line and stack. + function debugMessageFor(entry) { + let msg = entry.message || ''; + if (entry.type === 'init') { + msg = `page ${entry.url || '?'} — screen ${entry.sw || '?'}x${entry.sh || '?'} — ${entry.ua || ''}`; + } else if (entry.type === 'timing') { + msg = `${entry.event} +${entry.sinceInit}ms`; + } else if (entry.type === 'error' || entry.type === 'rejection') { + if (entry.source) msg += ` @${entry.source}:${entry.line || 0}:${entry.col || 0}`; + if (entry.stack) msg += ` | ${String(entry.stack).replace(/\s+/g, ' ').slice(0, 400)}`; + } else if (!msg) { + msg = entry.type || ''; + } + return msg.replace(DEBUG_TAG_RE, ''); // already lifted into the tag column + } + + function debugSink(entry) { + if (!remoteDebug || !entry || entry.__stSent) return; + entry.__stSent = true; + debugEmitLine(debugTagFor(entry), debugLevelFor(entry), debugMessageFor(entry)); + } + + // What the operator needs before the first line means anything: which player this is, how big + // the screen really is, and -- on BrightSign -- that they are talking to the host at all. + function debugPlatformLine() { + const bits = []; + try { bits.push(`screen ${screen.width}x${screen.height}@${window.devicePixelRatio || 1}x`); } catch (e) {} + try { if (BS && BS.isBrightSign && BS.isBrightSign()) bits.push('BrightSign'); } catch (e) {} + try { bits.push(navigator.userAgent); } catch (e) {} + return bits.join(' — '); + } + + /* + * Replay what is already in the buffer. Deliberately bypasses the per-second cap: it is a + * one-shot burst bounded by the ring buffer itself (200 entries), and throttling it would drop + * exactly the history the operator turned the stream on to read. + */ + function debugFlushBacklog() { + let buf = []; + try { buf = (window.__debugLog || []).filter((e) => e && !e.__stSent); } catch (e) { return; } + debugSend('debug', 'i', `--- replaying ${buf.length} buffered line(s) from before the stream opened ---`); + const now = Date.now(); + for (const entry of buf) { + entry.__stSent = true; + // The dashboard timestamps each line on arrival, so a replayed line would claim to have + // happened just now. Carry the real age in the text instead of quietly lying about when + // the crash was. + const age = entry.t ? `(-${((now - entry.t) / 1000).toFixed(1)}s) ` : ''; + debugSend(debugTagFor(entry), debugLevelFor(entry), age + debugMessageFor(entry)); + } + debugSend('debug', 'i', '--- end of backlog, now live ---'); + } + + function setRemoteDebug(on) { + if (debugAutoOffTimer) { clearTimeout(debugAutoOffTimer); debugAutoOffTimer = null; } + if (!on) { + if (remoteDebug) debugEmitLine('debug', 'i', 'Remote debug logging OFF'); + remoteDebug = false; + return; + } + const wasOn = remoteDebug; + remoteDebug = true; + debugWindowAt = 0; debugWindowCount = 0; debugSuppressed = 0; + if (!debugSubscribed) { + debugSubscribed = true; + try { window.__debugLog_subscribe?.(debugSink); } catch (e) {} + } + if (!wasOn) { + debugSend('debug', 'i', `Remote debug logging ON — ${debugPlatformLine()}`); + debugFlushBacklog(); + } + debugAutoOffTimer = setTimeout(() => { + debugAutoOffTimer = null; + debugEmitLine('debug', 'w', `Remote debug logging auto-disabled after ${Math.round(DEBUG_AUTO_OFF_MS / 60000)} min`); + remoteDebug = false; + }, DEBUG_AUTO_OFF_MS); + } + function emitDeviceEvent(type, reason, detail) { try { if (!socket?.connected || !config.deviceId) return; @@ -1705,6 +1895,10 @@ if (v === null) console.warn('[volume] set_volume with no usable level/value:', JSON.stringify(data)); else setMediaVolume(v); } + // The dashboard's "Debug logging" checkbox. Accepts the flag at either depth: the command + // relay wraps it in `payload`, but the queued-command replay path and the public API have + // both been seen to deliver it flat. + if (data.type === 'set_debug') setRemoteDebug(!!(data.payload?.enabled ?? data.enabled)); }); // #129: real-time mute. Apply immediately if the toggled item is the one playing now; diff --git a/server/test/player-live-debug-log.test.js b/server/test/player-live-debug-log.test.js new file mode 100644 index 0000000..c1533a1 --- /dev/null +++ b/server/test/player-live-debug-log.test.js @@ -0,0 +1,232 @@ +'use strict'; + +/* + * The dashboard's per-device "Debug logging" checkbox, and the web player's half of it. + * + * The checkbox has always sent a `set_debug` command. The ANDROID player honours it — DebugLog.* + * mirrors its tagged lines over the device socket while the box is ticked. The web player never + * implemented the command at all: the panel opened, revealed itself, streamed nothing, and read as + * a display with nothing to say. Only the three unconditional reporters (sync, pip, zone) ever + * reached it, so a display could be failing loudly in its own console and look silent from here. + * + * In a browser that is a nuisance — press F12. On BrightSign it is the entire diagnostic surface: + * no console, no adb, no logcat, a panel on a wall. Which is why the fix streams the ring buffer + * the error trap at the top of already fills (console.*, uncaught errors with stacks, + * rejections, failed resource loads) rather than hand-instrumenting call sites to match Android's + * tag by tag — and why turning it on REPLAYS the backlog, since the fault the operator came to + * investigate happened before they opened the screen. + */ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8'); +const PLAYER = read('server/player/index.html'); +const DETAIL = read('frontend/js/views/device-detail.js'); + +// ------------------------------------------------------------------ the command is honoured + +test('the web player implements set_debug — the whole point', () => { + const handler = PLAYER.slice(PLAYER.indexOf("socket.on('device:command'"), PLAYER.indexOf("socket.on('device:mute-changed'")); + assert.match(handler, /data\.type === 'set_debug'/, 'the command the dashboard has always sent must be handled'); + assert.match(handler, /setRemoteDebug/); + // The relay wraps the flag in `payload`; other delivery paths have been seen to send it flat. + assert.match(handler, /payload\?\.enabled\s*\?\?\s*data\.enabled/, + 'both payload shapes must be accepted, or the checkbox silently does nothing'); +}); + +test('set_debug is not capability-gated, so a BrightSign can receive it', () => { + const caps = require('../lib/player-capabilities'); + assert.equal(caps.capabilityForCommand('set_debug'), null); + assert.equal(caps.commandAllowed({ android_version: 'BrightSign' }, 'set_debug').ok, true); +}); + +// ------------------------------------------------------------------ the ring buffer, live + +/* Run the error trap for real — it is ES5 and self-contained, so it executes standalone. */ +function loadTrap() { + const start = PLAYER.indexOf('', start)); + const win = {}; + const console_ = { log() {}, warn() {}, error() {} }; + const doc = { addEventListener() {} }; + win.addEventListener = () => {}; + new Function('window', 'console', 'document', 'navigator', 'screen', 'location', src)( + win, console_, doc, { userAgent: 'test' }, { width: 1920, height: 1080 }, { href: 'http://x/player' }, + ); + return { win, console: console_ }; +} + +test('a subscriber receives entries as they are recorded', () => { + const { win, console: c } = loadTrap(); + const seen = []; + win.__debugLog_subscribe((e) => seen.push(e)); + c.log('[wall] hello'); + assert.equal(seen.length, 1); + assert.equal(seen[0].type, 'console.log'); + assert.match(seen[0].message, /\[wall\] hello/); +}); + +test('THE RECURSION TRAP: a subscriber that logs cannot take the player down', () => { + // This sink is fed from inside the console wrapper. A subscriber that logs anything at all — + // directly, or through any library it touches — re-enters pushLog and recurses until the stack + // gives out, and the player dies of its own diagnostics. The guard is not theoretical. + const { win, console: c } = loadTrap(); + let calls = 0; + win.__debugLog_subscribe(() => { calls++; c.log('the subscriber logs too'); }); + assert.doesNotThrow(() => c.log('first')); + assert.equal(calls, 1, 'the reentrant log must not call subscribers again'); +}); + +test('one throwing subscriber does not eat the others', () => { + const { win, console: c } = loadTrap(); + const seen = []; + win.__debugLog_subscribe(() => { throw new Error('bad subscriber'); }); + win.__debugLog_subscribe((e) => seen.push(e)); + assert.doesNotThrow(() => c.log('x')); + assert.equal(seen.length, 1); +}); + +test('subscribing does not disturb the buffer the debug overlay reads', () => { + const { win, console: c } = loadTrap(); + win.__debugLog_subscribe(() => {}); + c.log('a'); + assert.ok(win.__debugLog.length >= 2, 'init + the line'); + assert.equal(typeof win.__debugLog_push, 'function', 'debug-overlay.js pusher must survive'); +}); + +// ------------------------------------------------------------------ what actually goes out + +// Pull the sink's helpers out of the player and run them against real ring-buffer entries. +function loadSink() { + const from = PLAYER.indexOf('const DEBUG_TAG_RE'); + const to = PLAYER.indexOf('function debugSink('); + const src = PLAYER.slice(from, to); + return new Function(`${src}; return { debugTagFor, debugLevelFor, debugMessageFor };`)(); +} + +test('a bracketed prefix becomes the tag, so the panel reads like Android\'s', () => { + const { debugTagFor, debugMessageFor } = loadSink(); + const e = { type: 'console.log', message: '[group-sync] drift 42ms' }; + assert.equal(debugTagFor(e), 'group-sync'); + assert.equal(debugMessageFor(e), 'drift 42ms', 'the prefix must not be repeated in the message'); +}); + +test('an untagged line still lands somewhere sensible', () => { + const { debugTagFor } = loadSink(); + assert.equal(debugTagFor({ type: 'console.log', message: 'Playing: clip.mp4' }), 'player'); + assert.equal(debugTagFor({ type: 'rejection', message: 'boom' }), 'error'); +}); + +test('levels survive, because an error that looks like a log is not a diagnostic', () => { + const { debugLevelFor } = loadSink(); + assert.equal(debugLevelFor({ type: 'console.error' }), 'e'); + assert.equal(debugLevelFor({ type: 'console.warn' }), 'w'); + assert.equal(debugLevelFor({ type: 'error' }), 'e'); + assert.equal(debugLevelFor({ type: 'rejection' }), 'e'); + assert.equal(debugLevelFor({ type: 'console.log' }), 'i'); + assert.equal(debugLevelFor({ type: 'console.log', level: 'w' }), 'w', 'an explicit level wins'); +}); + +test('an uncaught error carries its location and stack, or it is not worth sending', () => { + const { debugMessageFor } = loadSink(); + const msg = debugMessageFor({ + type: 'error', message: 'x is not a function', + source: 'http://h/player/index.html', line: 42, col: 7, stack: 'at a\n at b', + }); + assert.match(msg, /@http:\/\/h\/player\/index\.html:42:7/); + assert.match(msg, /at a at b/, 'the stack must be flattened onto one line, not dropped'); +}); + +test('the panel is told what kind of player it is looking at', () => { + const fn = PLAYER.slice(PLAYER.indexOf('function debugPlatformLine'), PLAYER.indexOf('function debugFlushBacklog')); + assert.match(fn, /screen\.width/); + assert.match(fn, /BS\.isBrightSign\(\)/, 'a BrightSign must identify itself — it is the platform with no other console'); + assert.match(fn, /userAgent/); +}); + +// ------------------------------------------------------------------ the backlog + +test('turning the stream on replays what already happened', () => { + // The operator is investigating a fault that is already over. A stream that starts empty makes + // them reproduce it, which on a panel on a wall may mean waiting days for it to recur. + const fn = PLAYER.slice(PLAYER.indexOf('function debugFlushBacklog'), PLAYER.indexOf('function setRemoteDebug')); + assert.match(fn, /window\.__debugLog/); + assert.match(fn, /__stSent/, 'a replayed line must be marked so the live sink does not send it twice'); + assert.match(fn, /debugSend\(/, 'the replay must bypass the per-second cap'); + assert.ok(!/debugEmitLine\(/.test(fn), 'rate-limiting the replay would drop the history it exists to deliver'); +}); + +test('a replayed line admits it is old rather than claiming to be now', () => { + // The dashboard stamps each line on arrival, so a 200-line replay would all read as this second + // and an operator would date the crash to when they opened the panel. + const fn = PLAYER.slice(PLAYER.indexOf('function debugFlushBacklog'), PLAYER.indexOf('function setRemoteDebug')); + assert.match(fn, /-\$\{\(\(now - entry\.t\)/, 'the real age must travel in the text'); +}); + +// ------------------------------------------------------------------ bounded + +test('the stream is rate-limited, and says so when it drops lines', () => { + // Fed by console.*: a video that fails to decode and retries every frame would otherwise flood + // the socket that also carries playback. + const fn = PLAYER.slice(PLAYER.indexOf('function debugEmitLine'), PLAYER.indexOf('const DEBUG_TAG_RE')); + assert.match(fn, /DEBUG_MAX_LINES_PER_SEC/); + assert.match(fn, /suppressed/, 'silent truncation would read as a player that went quiet'); + assert.ok(!/push\(|\.shift\(/.test(fn), 'dropped lines must be counted, not queued and replayed later'); + const cap = /const DEBUG_MAX_LINES_PER_SEC = (\d+)/.exec(PLAYER); + assert.ok(cap && Number(cap[1]) > 0 && Number(cap[1]) <= 200, `cap must be real, got ${cap && cap[1]}`); +}); + +test('a forgotten checkbox does not stream forever', () => { + const fn = PLAYER.slice(PLAYER.indexOf('function setRemoteDebug'), PLAYER.indexOf('function emitDeviceEvent')); + assert.match(fn, /DEBUG_AUTO_OFF_MS/); + assert.match(fn, /auto-disabled/, 'the stream stopping on its own must be visible, not mysterious'); + const ms = /const DEBUG_AUTO_OFF_MS = ([^;]+);/.exec(PLAYER); + const value = new Function(`return ${ms[1]}`)(); + assert.ok(value >= 5 * 60 * 1000 && value <= 60 * 60 * 1000, `auto-off must outlast a real session, got ${value}ms`); +}); + +test('leaving the device screen turns the device stream off', () => { + const fn = DETAIL.slice(DETAIL.indexOf('export function cleanup()')); + assert.match(fn, /set_debug'?,\s*\{ enabled: false \}/, 'the dashboard must stop what it started'); + assert.ok( + fn.indexOf('set_debug') < fn.indexOf('currentDevice = null'), + 'sent after currentDevice is cleared, this would address nobody', + ); +}); + +// ------------------------------------------------------------------ BrightSign host lines + +test('host lines are not sent twice while the stream is on', () => { + // wireHostDiagnostics emits directly AND console.logs; with the sink live the console.log is + // already a second path to the dashboard, so the direct emit has to stand down. + const fn = PLAYER.slice(PLAYER.indexOf('function wireHostDiagnostics'), PLAYER.indexOf('/* ==================== Live remote debug')); + assert.match(fn, /!remoteDebug && socket\?\.connected/, 'the direct emit must yield to the sink'); + assert.match(fn, /console\.log\(`\[host\/\$\{line\.tag\}\]/, 'and the console path must remain, since that is what the sink reads'); +}); + +test('the boot report still goes out with the stream OFF', () => { + // It is the one diagnostic nobody can ask for in advance: it is over before the operator has a + // device to open. Gating it behind the checkbox would lose it permanently. + const fn = PLAYER.slice(PLAYER.indexOf('function wireHostDiagnostics'), PLAYER.indexOf('/* ==================== Live remote debug')); + assert.match(fn, /socket\.emit\('device:log'/); + assert.ok(!/if \(!remoteDebug\) return/.test(fn), 'host logs must not be suppressed when debug is off'); +}); + +// ------------------------------------------------------------------ it cannot break playback + +test('nothing in the sink can stop the player', () => { + const from = PLAYER.indexOf('/* ==================== Live remote debug'); + const to = PLAYER.indexOf('function emitDeviceEvent'); + const block = PLAYER.slice(from, to); + assert.match(block, /function debugSend/); + const send = block.slice(block.indexOf('function debugSend'), block.indexOf('function debugEmitLine')); + assert.match(send, /try \{/, 'the socket emit must be guarded'); + assert.match(send, /catch \(e\)/); + // Every field is bounded before the wire; the server truncates too, but a player in a bad state + // should not be pushing megabytes at it. + assert.match(send, /slice\(0, 64\)/); + assert.match(send, /slice\(0, 2000\)/); +});