screentinker/server/test/brightsign-host-diagnostics.test.js
ScreenTinker 4e1de8ec0e Make the Tizen and BrightSign players do what they say they do
Both players carried calls that compile, read correctly, and are documented to
do something else. Verified line by line against docs.brightsign.biz and
Samsung's Smart TV Filesystem reference; every fix below cites the doc that
proves it, and the linter has been extended so each one fails here next time.

TIZEN

The offline media cache could never have worked on a panel. Its adapter used
the deprecated Filesystem API in three ways the IDL rules out:
`tizen.filesystem.resolve()` is declared `void`, so `var dir = resolve(...)`
was always undefined and MediaCache.create() returned null on every panel in
the fleet; `openStream()` is asynchronous, so appendPart read `written` before
any callback could run and returned 0 forever; and `moveTo()` is asynchronous,
belongs on the parent directory, and takes (origin, destination) — it was
called on a file handle with the arguments transposed. Rewritten against the
5.0 synchronous FileSystemManager, which is genuinely synchronous and is what
the decision layer needs. A Tizen 4.0 panel now reports available() false
instead of being handed a cache that silently writes nothing.

Writes are now POSITIONED rather than appended at EOF. Power cut between a
write and the index save — the exact event this feature exists for — replayed
the last chunk, and an append landed it twice: a silently corrupt video that
promoted as complete. A positioned write makes the replay idempotent.

Three decision-layer bugs alongside it: a 206 with no readable Content-Range
fell back to Content-Length, which is the CHUNK length, so the first megabyte
of a 50MB video promoted as a complete 1MB asset; a 200 whose body was short of
its own Content-Length returned 'done'; and a server with no ETag or
Last-Modified was re-fetched from zero on every sweep, forever, on precisely
the marginal link this feature exists to be gentle on.

The volume slider was dead. The dashboard sends `{level: 0..1}`; this handler
read `value`/`volume` as a 0..100 percentage, so it matched nothing and logged
"no usable value in payload" on every slider move while the panel declared
audio.volume as working. Both halves had to move together — taking `level` as a
percentage turns 50% into 0.5%, which is inaudible and looks like a fix.
Verified by driving the real handler in headless Chrome, before and after.

BRIGHTSIGN

FindMemberFunction is documented as available only when
roDeviceInfo.HasFeature("FindMemberFunction") is true. It was called
unguarded from the capability probe and from host telemetry — both on the event
loop — so a player without the feature would have died within a minute of boot
and taken the display with it. The guard needed guarding.

The boot report never arrived. The host flushed its buffer straight after
Show(), before the page had been fetched, while the player correctly waits for
its socket before subscribing. Between two correct decisions every boot line
fell on the floor. The host now waits for the page's `probe`, and the bridge
buffers until a consumer registers.

offline.cache was claimed on `navigator.serviceWorker` being present. It is
present on a BrightSign widget and will not run a worker — our XT245 passes the
check and never fetches sw.js. Now requires a controller, matching the web
player. Removed from the brightsign baseline for the same reason.

display.resolution was claimed on @brightsign/videooutput, which has no
setMode at all; mode setting lives on @brightsign/videomodeconfiguration.

roStorageHotplug.GetStorages() answers "USB1:/" while GetStorageStatus() is
documented as unreliable for "USBn:" — feeding one to the other re-created the
bug the static fallback list exists to avoid, and only on the OS versions that
have the enumerator.

dual/clone output mode put two full-screen widgets on output ONE, on top of
each other, while output two stayed dark: roHtmlWidget has no output selector,
and a second output is addressed by its display_x/display_y within the
SetScreenModes canvas. Now positioned properly, or refused with a reason.

Also: a manifest missing sha256/size passed `invalid` into typed parameters, a
runtime error at the call the comment already described and did not prevent;
storage_quota was a string where the docs say use a double; and the comment
crediting brightsign_js_objects_enabled with gating require("@brightsign/*")
named the wrong flag — it is nodejs_enabled.

TESTS

The two suites that mattered most were the ones that passed while the code was
broken, because they asserted on source text or against a fake more correct
than the platform. The host-diagnostics regexes now execute the bridge; the
media-cache suite now drives the shipped adapter against a fake tizen.filesystem
written from Samsung's IDL. Ten new rules in the BrightScript linter, each
verified to fail against the source it was written to reject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 10:51:15 -05:00

237 lines
13 KiB
JavaScript

'use strict';
// A BrightSign knows things the page cannot ask for — the uptime, the wired IP, the video mode
// actually in force, which volume it booted from, whether a staged package applied — and all of it
// used to go to a serial console. On a panel on a wall that is the same as reporting nothing.
//
// The cost was concrete: a single bad string literal stopped the host script compiling, and the only
// evidence anywhere in the world was one line on a cable. From the server the display looked
// identical to one that had simply never started. Every other player reports its own failures.
//
// This pins the three-hop contract — host posts, bridge forwards, player emits — because no part of
// it can be executed here. The host half is BrightScript (no interpreter), the bridge half needs a
// widget, and a broken link in the chain is silent by construction: diagnostics that do not arrive
// look exactly like diagnostics that were never generated.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const host = fs.readFileSync(path.join(ROOT, 'brightsign', 'autorun.brs'), 'utf8');
const bridge = fs.readFileSync(path.join(ROOT, 'brightsign', 'st-bridge.js'), 'utf8');
const player = fs.readFileSync(path.join(ROOT, 'server', 'player', 'index.html'), 'utf8');
const code = host.split('\n').filter((l) => !/^\s*'/.test(l)).join('\n');
// Objects built inside the vm carry the vm realm's prototypes, so deepStrictEqual would compare
// realms rather than values. Round-trip through JSON to compare what actually crossed the bridge.
const norm = (x) => JSON.parse(JSON.stringify(x));
/*
* The bridge half, actually EXECUTED against a fake widget rather than pattern-matched.
*
* The two source-regex assertions this replaces both passed while the chain was broken end to end,
* which is the whole argument for running it: "the file contains onHostLog" is not evidence that a
* log line reaches anybody. `deliver` plays the part of roHtmlWidget.PostJSMessage.
*/
function loadBridge() {
const vm = require('node:vm');
const inbound = [];
const sandbox = {
console: { log() {}, warn() {}, error() {} },
navigator: { userAgent: 'BrightSign/9.0.189 (XT245) Chrome/120' },
location: { search: '' },
setTimeout: () => 1, setInterval: () => 1, clearTimeout: () => {},
Promise, Object, Array, Uint8Array, Math, Date, RegExp, String, Number,
parseInt, isNaN, isFinite, decodeURIComponent, Error,
localStorage: { getItem: () => null, setItem() {} },
};
sandbox.window = sandbox;
sandbox.require = (name) => {
if (name === '@brightsign/messageport') {
return function () {
return {
PostBSMessage: () => {},
addEventListener: (evt, fn) => { if (evt === 'bsmessage') inbound.push(fn); },
};
};
}
throw new Error('no module ' + name);
};
vm.createContext(sandbox);
vm.runInContext(bridge, sandbox);
return { api: sandbox.ScreenTinkerBS, deliver: (msg) => inbound.forEach((fn) => fn(msg)) };
}
test('the host reports its boot story, which happens before there is a page to hear it', () => {
// The interesting failures all live in this window: the storage probe, a pending package being
// applied, the video mode being set. A design that could only report after the widget existed
// would miss every one of them.
assert.match(code, /Sub LogTo\(buf As Object/, 'a buffer the pre-widget phase can log into');
assert.match(code, /Sub FlushLog\(widget As Object, buf As Object\)/, 'and a flush once there is a page');
assert.match(code, /boot = CreateObject\("roArray"/, 'Main must create the buffer');
assert.match(code, /FlushLog\(widget, boot\)/, 'and flush it once a page is listening');
// The update path is the one that replaces the boot script — its diagnostics are the ones you
// most want when a player does not come back.
assert.match(code, /Sub ApplyPendingPackage\(root As String, buf As Object\)/);
assert.match(code, /LogTo\(buf, "update"/);
});
test('the host reports facts the page has no API for', () => {
const fn = code.slice(code.indexOf('Sub SendHostTelemetry'));
for (const [needle, why] of [
['UpTime(', 'a display that always reports a small uptime is reboot-looping'],
['roNetworkConfiguration', 'the wired IP — there is no JavaScript route to it'],
['GetVersion', 'the OS build, which decides which APIs exist at all'],
['StorageProbe()', 'the real volume, not the widget cache quota'],
['StorageRoot()', 'which volume it booted from'],
['PackageVersion()', 'what it is actually running'],
]) {
assert.ok(fn.slice(0, 2000).includes(needle), `host telemetry must include ${needle}: ${why}`);
}
});
test('a widget rebuild is reported as an incident, not just a console line', () => {
// The watchdog healing a wedged page is the single most important thing a BrightSign does
// unattended. Doing it silently made a panel rebuilding itself every two minutes look identical
// to one that was healthy.
assert.match(code, /HostEvent\(widget, "crash", "watchdog"/);
assert.match(code, /HostEvent\(widget, "app_error", "load-error"/);
});
test('the event types the host emits are ones the server actually accepts', () => {
// The server drops unknown event types silently, so an invented one would be exactly as
// invisible as the console.warn this replaces.
const allowed = fs.readFileSync(path.join(ROOT, 'server', 'lib', 'incident-classify.js'), 'utf8');
for (const m of code.matchAll(/HostEvent\([^,]+,\s*"([a-z_]+)"/g)) {
assert.ok(allowed.includes(`'${m[1]}'`), `the server does not accept event type "${m[1]}"`);
}
});
test('the bridge carries logs and events without interpreting them', () => {
const { api, deliver } = loadBridge();
const logs = [];
const events = [];
api.onHostLog((l) => logs.push(l));
api.onHostEvent((e) => events.push(e));
deliver({ type: 'host-log', tag: 'update', level: 'i', message: 'package applied' });
deliver({ type: 'host-event', event: 'crash', reason: 'watchdog', detail: 'no heartbeat for 120s' });
assert.deepEqual(norm(logs), [{ tag: 'update', level: 'i', message: 'package applied' }]);
assert.deepEqual(norm(events), [{ event: 'crash', reason: 'watchdog', detail: 'no heartbeat for 120s' }]);
// Bounded before they reach the wire: the server truncates too, but a host bug should not be
// able to push a megabyte through the socket every second.
deliver({ type: 'host-log', tag: 'x'.repeat(200), message: 'y'.repeat(5000) });
deliver({ type: 'host-event', event: 'app_error', reason: 'r'.repeat(200), detail: 'd'.repeat(5000) });
assert.equal(logs[1].message.length, 2000);
assert.equal(logs[1].tag.length, 64);
assert.equal(events[1].detail.length, 500);
assert.equal(events[1].reason.length, 64);
});
test('THE DROPPED BOOT REPORT: a diagnostic sent before anyone subscribed is still delivered', () => {
// The regression this whole file exists to prevent, and it was live. The ordering is not an edge
// case, it is the ONLY ordering: the host buffers its pre-widget lines and posts them the instant
// the page says hello, while the player deliberately does not subscribe until its socket is up
// (forwarding earlier would have nowhere to send them). Between those two correct decisions every
// boot line fell on the floor — the host spoke to a page with no listener, and the listener
// arrived after the words had gone.
//
// So the bridge holds them. Nothing else in the chain can: the host has already moved on and the
// player cannot subscribe any earlier.
const { api, deliver } = loadBridge();
deliver({ type: 'host-log', tag: 'boot', level: 'i', message: 'host 1.2.3 from SSD: -> https://s' });
deliver({ type: 'host-log', tag: 'update', level: 'i', message: 'package applied — rebooting into it' });
deliver({ type: 'host-event', event: 'app_error', reason: 'load-error', detail: 'attempt 1: https://s/player' });
const logs = [];
const events = [];
api.onHostLog((l) => logs.push(l));
api.onHostEvent((e) => events.push(e));
assert.deepEqual(logs.map((l) => l.tag), ['boot', 'update'], 'the boot story must survive the gap');
assert.deepEqual(events.map((e) => e.event), ['app_error']);
// ...and delivery keeps working normally afterwards, oldest-first with no duplication.
deliver({ type: 'host-log', tag: 'tel', level: 'i', message: 'later' });
assert.deepEqual(logs.map((l) => l.tag), ['boot', 'update', 'tel']);
});
test('a second subscriber gets the same history, and one that throws cannot eat it', () => {
const { api, deliver } = loadBridge();
deliver({ type: 'host-log', tag: 'boot', level: 'i', message: 'early' });
api.onHostLog(() => { throw new Error('a consumer blew up'); });
const logs = [];
api.onHostLog((l) => logs.push(l));
assert.deepEqual(logs.map((l) => l.message), ['early'], 'a broken consumer must not swallow the replay');
});
test('the pending queue is bounded — a reboot loop must not grow it without limit', () => {
// This player runs for months. An unbounded buffer fed by a host stuck in a loop is a slow leak
// on the one device nobody is watching.
const { api, deliver } = loadBridge();
for (let i = 0; i < 5000; i++) deliver({ type: 'host-log', tag: 'boot', message: 'line ' + i });
const logs = [];
api.onHostLog((l) => logs.push(l));
assert.ok(logs.length > 0 && logs.length <= 200, `queue must be capped, got ${logs.length}`);
});
test('host telemetry merges into the snapshot the heartbeat already sends', () => {
// Not a new channel — the heartbeat has carried BS.telemetrySnapshot() for releases. The host
// simply fills in the fields only it can see.
assert.match(bridge, /msg\.type === 'host-telemetry'/);
assert.match(bridge, /telemetry\[keys\[i\]\] = v/);
assert.match(player, /BS\.telemetrySnapshot\(\) : \{\}/);
});
test('the host telemetry listener is registered at load, not behind the readiness gate', () => {
// The host sends its boot report the moment the page says hello. A listener attached after the
// bridge finished its own probe would miss precisely the message that says which volume the
// player came up from and whether a package applied.
//
// Executed, not pattern-matched: the previous form asserted that a `listeners.push` appeared
// within 1400 characters of a variable declaration, which is a statement about formatting.
const { api, deliver } = loadBridge();
deliver({ type: 'host-telemetry', boot_volume: 'SSD:', storage_free_mb: 90000, package_version: '1.2.3' });
assert.deepEqual(norm(api.telemetrySnapshot()), {
boot_volume: 'SSD:', storage_free_mb: 90000, package_version: '1.2.3',
});
});
test('the host holds its boot log until a PAGE answers, not until a widget exists', () => {
// Show() only creates the widget: the page has not been fetched, let alone run st-bridge.js, so a
// flush there posts into a void. The `probe` message is the first proof that JavaScript is running
// on the other end, and is therefore the earliest moment the buffer can actually be delivered.
const main = code.slice(code.indexOf('Sub Main()'));
const afterShow = main.slice(main.indexOf('widget.Show()'), main.indexOf('widget.Show()') + 400);
assert.ok(!/FlushLog\(/.test(afterShow),
'flushing straight after Show() posts the boot story to a page that has not loaded yet');
const probeBranch = main.slice(main.indexOf('m.type = "probe"'), main.indexOf('m.type = "probe"') + 400);
assert.match(probeBranch, /FlushLog\(widget, boot\)/, 'flush when the page proves it is listening');
});
test('the player forwards them, and only where the hooks exist', () => {
assert.match(player, /function wireHostDiagnostics\(\)/);
assert.match(player, /typeof BS\.onHostLog !== 'function'\) return;/, 'a browser must skip this entirely');
assert.match(player, /socket\.emit\('device:log'/);
assert.match(player, /BS\.onHostEvent\(\(ev\) => emitDeviceEvent\(ev\.event, ev\.reason, ev\.detail\)\)/);
});
test('forwarding is wired AFTER the socket, or the boot report is dropped rather than delayed', () => {
const connect = player.slice(player.indexOf('startVersionCheck();'), player.indexOf('startVersionCheck();') + 400);
assert.match(connect, /wireHostDiagnostics\(\)/);
});
test('diagnostics can never take the player down', () => {
// The whole point is a display that keeps playing while telling you it is unhappy. A reporting
// path that throws would invert that.
const fn = player.slice(player.indexOf('function wireHostDiagnostics'), player.indexOf('function emitDeviceEvent'));
assert.equal((fn.match(/try \{/g) || []).length >= 2, true, 'both the wiring and each callback must be guarded');
assert.match(fn, /catch \(e\) \{ \/\* diagnostics must never break playback/);
});