screentinker/server/test/player-declared-capabilities.test.js
ScreenTinker 0a888910dc Stop claiming offline cache on a runtime that refuses to run a service worker
Found on alpha after deploying rc4, by comparing what a device advertised
against what it actually requested.

A real BrightSign XT245 has navigator.serviceWorker, passes an
`'serviceWorker' in navigator` check, and then never even fetches sw.js — its
widget runtime refuses the registration. It was declaring offline.cache to the
fleet while unable to cache a single byte, which is precisely the lie the
capability model exists to prevent. The claim is now made on a worker that is
actually IN CONTROL, and a refused registration sets a flag so the negative
sticks on a runtime where it will never succeed.

That failure previously went to console.warn, on a display nobody has a console
for, so a panel that could cache nothing looked identical to one that could. It
now reports app_error/sw_unavailable — as an allow-listed event type, since an
unknown one is dropped by the server and would have been just as invisible.

The cost is that the first load under-reports, before the worker claims the
page. That is the right direction to be wrong in, and it self-corrects: the next
register sends the true set.

Also corrects docs/player-parity.md, which claimed BrightSign simply inherits
the web player's service worker. The failing unit runs BSN's Supervisor rather
than our brightsign/autorun.brs, and Supervisor's widget has no storage_path —
the setting our own host script does configure and the precondition for a widget
having persistent storage. So this is likely a widget config issue rather than a
platform limit, but it is UNVERIFIED on hardware and the doc now says so.

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

133 lines
6.5 KiB
JavaScript

'use strict';
// The same index.html is BOTH the browser player and the BrightSign player. Identical code,
// wildly different powers depending on whether a host bridge answered.
//
// That is why the declaration is computed rather than constant, and why it is worth testing: a
// browser tab claiming `system.reboot` puts a button on the dashboard that cannot ever work, and
// a BrightSign failing to claim it hides one that would. Both are the same bug in opposite
// directions.
//
// The real function is extracted from index.html and run against fake globals, so this asserts
// what the player will actually send rather than a reimplementation of it.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { CAP_SET } = require('../lib/player-capabilities');
const HTML = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), 'utf8');
/** Pull declaredCapabilities() out of the player and run it in a controlled world. */
function declare({ host = false, sync = false, transitions = false, canvas = true, sw = true, swRefused = false } = {}) {
const start = HTML.indexOf(' function declaredCapabilities() {');
assert.notEqual(start, -1, 'declaredCapabilities() must exist in the player');
let depth = 0, end = -1;
for (let i = HTML.indexOf('{', start); i < HTML.length; i++) {
if (HTML[i] === '{') depth++;
else if (HTML[i] === '}' && --depth === 0) { end = i + 1; break; }
}
const src = HTML.slice(start, end);
const sandbox = {
console: { log() {}, warn() {} },
// `sw` now means "a worker is IN CONTROL", not merely "the API exists" — those are different
// things, and a real BrightSign widget is the difference (see the test below).
navigator: sw ? { serviceWorker: { controller: {} } } : (swRefused ? { serviceWorker: {} } : {}),
document: {
createElement: () => (canvas
? { width: 0, height: 0, getContext: () => ({ drawImage() {} }), toDataURL: () => 'data:,' }
: { width: 0, height: 0, getContext: () => null }),
},
BS: host ? { hasHost: () => true } : null,
};
sandbox.swRegistrationFailed = swRefused;
sandbox.window = sandbox;
if (sync) sandbox.ScreenTinkerBSSync = { available: () => true };
// The real runtime globals the player's own transitionRuntimeReady() checks. An earlier draft
// faked a global that does not exist, which passed a test while declaring nothing.
if (transitions) {
sandbox.TransitionRenderer = {};
sandbox.TransitionParams = {};
sandbox.__TRANSITION_SHADERS = {};
}
vm.createContext(sandbox);
// declaredCapabilities() calls transitionRuntimeReady(), which lives elsewhere in the player.
const predicate = 'function transitionRuntimeReady() { return !!(window.TransitionRenderer && window.TransitionParams && window.__TRANSITION_SHADERS); }';
vm.runInContext(`${predicate}\n${src}\nvar __out = declaredCapabilities();`, sandbox);
return sandbox.__out;
}
test('every declared name is a real capability the server understands', () => {
// A typo here is silently dropped by parseDeclared, so the control just never appears.
for (const c of declare({ host: true, sync: true, transitions: true })) {
assert.ok(CAP_SET.has(c), `player declares unknown capability "${c}"`);
}
});
test('THE LIE WE ARE REMOVING: a browser tab does not claim host-only powers', () => {
const web = declare({ host: false });
for (const cap of ['system.reboot', 'display.power', 'display.resolution', 'system.self_update']) {
assert.ok(!web.includes(cap), `a browser tab must not claim ${cap}`);
}
});
test('the same code behind a live host DOES claim them', () => {
const bs = declare({ host: true });
for (const cap of ['system.reboot', 'display.power', 'display.resolution', 'system.self_update']) {
assert.ok(bs.includes(cap), `a hosted player must claim ${cap}`);
}
});
test('what both share is declared by both', () => {
const web = declare({ host: false });
const bs = declare({ host: true });
for (const cap of ['playback.video', 'playback.zones', 'audio.mute', 'audio.volume',
'display.rotation', 'remote.input', 'system.restart_player', 'sync.clock']) {
assert.ok(web.includes(cap), `web should declare ${cap}`);
assert.ok(bs.includes(cap), `hosted should declare ${cap}`);
}
});
test('native sync depends on the module resolving, not on being a BrightSign', () => {
// BOS 8.2.10+ is a firmware question. A hosted player on older firmware has no SyncManager and
// must not offer frame-accurate sync it cannot perform.
assert.ok(!declare({ host: true, sync: false }).includes('sync.native'));
assert.ok(declare({ host: true, sync: true }).includes('sync.native'));
});
test('screenshots are claimed only when there is something to draw on', () => {
assert.ok(declare({ canvas: true }).includes('remote.screenshot'));
assert.ok(!declare({ canvas: false }).includes('remote.screenshot'));
});
test('offline cache follows a worker that is actually IN CONTROL', () => {
assert.ok(declare({ sw: true }).includes('offline.cache'));
assert.ok(!declare({ sw: false }).includes('offline.cache'));
});
test('THE BRIGHTSIGN CASE: the API exists, no worker controls the page, so no claim', () => {
// Found on real hardware. A BrightSign XT245 on alpha has navigator.serviceWorker, passes an
// `'serviceWorker' in navigator` check, and then never even fetches sw.js — its widget runtime
// refuses to register one. It was advertising offline.cache to the fleet while being unable to
// cache a single byte, which is precisely the lie this whole capability model exists to stop.
const caps = declare({ swRefused: true });
assert.ok(!caps.includes('offline.cache'), 'a runtime that will not run a worker must not claim to cache');
assert.ok(caps.includes('playback.video'), 'and it must still declare what it genuinely can do');
});
test('transitions are declared only when the bundle actually loaded', () => {
// It is a progressive enhancement — a failed load means hard cuts, not a broken player.
assert.ok(!declare({ transitions: false }).includes('playback.transitions'));
assert.ok(declare({ transitions: true }).includes('playback.transitions'));
});
test('a hostile environment degrades instead of throwing during registration', () => {
// This runs inside register(). Throwing here would cost the display its registration entirely,
// which is a far worse outcome than an under-declared capability set.
assert.doesNotThrow(() => declare({ canvas: false, sw: false, host: false }));
});