mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Make the live debug log work on the web player, and so on BrightSign
The dashboard's per-device "Debug logging" 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, so the panel opened, revealed itself, and streamed nothing but the three unconditional reporters (sync, pip, zone). A display could be failing loudly in its own console and look mute from the dashboard. In a browser that is a nuisance — press F12. On BrightSign it is the whole diagnostic surface: no console, no adb, no logcat, a panel on a wall. Rather than hand-instrument eighty-seven call sites to match Android's tag by tag, this streams the ring buffer the error trap at the top of <head> has always filled: every console.log/warn/error, every uncaught error with file:line and stack, every unhandled rejection, every failed resource load. Turning the stream on also REPLAYS that backlog, so the operator sees the failure that happened before they opened the screen — the case they actually came to investigate, and one no log tail gives them. Replayed lines carry their real age, because the dashboard stamps on arrival and 200 lines would otherwise all claim to have happened this second. The bracket prefixes the player already uses ([wall], [bs], [group-sync]) become the tag column, so the panel reads the same shape as Android's, and the panel now colours by level — all four rendered identically before, so the one line explaining the fault sat in a wall of grey. Bounded three ways, because this sink is fed by console.*: - 40 lines/sec, over which lines are COUNTED and reported, not queued - auto-off after 30 min, for the checkbox nobody unticks - the dashboard also switches it off when the operator leaves the screen The reentrancy guard in pushLog is not theoretical: the sink runs inside the console wrapper, so a subscriber that logs anything would recurse until the stack gave out and the player would die of its own diagnostics. BrightSign host lines stand their direct emit down while the stream is on (the console path already carries them) but still go out unconditionally when it is off — the boot report is the one diagnostic nobody can ask for in advance, because it is over before the operator has a device to open. Verified on the XT245 on alpha: 34 lines across 7 tags, backlog replayed with real ages, levels intact, platform line reporting BOS 9.1.93.2 / XT245 / 1920x1200. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
This commit is contained in:
parent
a44d3f8a77
commit
c594a1a67a
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 <head> 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;
|
||||
|
|
|
|||
232
server/test/player-live-debug-log.test.js
Normal file
232
server/test/player-live-debug-log.test.js
Normal file
|
|
@ -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 <head> 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 <head> error trap for real — it is ES5 and self-contained, so it executes standalone. */
|
||||
function loadTrap() {
|
||||
const start = PLAYER.indexOf('<script>');
|
||||
const src = PLAYER.slice(PLAYER.indexOf('(function () {', start), PLAYER.indexOf('</script>', 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\)/);
|
||||
});
|
||||
Loading…
Reference in a new issue