screentinker/server/test/brightsign-bridge.test.js
ScreenTinker 7fb94fbf70 Correct the BrightSign port against the dev-cookbook examples
Reviewed autorun.brs and st-bridge.js line-by-line against the real examples
instead of the prose docs. Five defects, three of which would have been silent.

The registry API is asynchronous and section-oriented: read(section, key)
returns a Promise and writes take an object, write(section, {k: v}). The bridge
treated both as synchronous, so deviceId() returned a Promise object — truthy
and non-empty — and a panel would have registered as "[object Promise]" while
its real row sat unclaimed. It now prefetches into a cache behind onReady(), and
connect() waits for that before registering.

brightsign_js_objects_enabled: true is required alongside nodejs_enabled for
require("@brightsign/*"). Without it the bridge degrades to no-ops and the
player loses identity and restart delegation — which would have read as
"BrightSign doesn't work" rather than as one missing flag.

storage_path is a directory name, not a volume, and storage_quota is a string;
the local fallback URL needs its volume (file:/SD:/offline.html). Added
security_params and hwz_default to match the examples.

SyncManager does not work unless networking/ptp_domain is "0", which needs a
reboot to apply. Done only when this player is configured for native sync, and
read-before-write so it reboots once rather than on every boot.

Confirmed correct as written: messageport, the roHtmlWidgetEvent loop, and
RebootSystem(). The notes also state a widget URL may be an externally hosted
page with the same JS API access — the favourable answer to the question the
original probe was built to ask.

Bridge tests now model the async section-oriented registry, so a synchronous
stand-in can never hide this class of bug again. 931 pass.

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

218 lines
8.8 KiB
JavaScript

'use strict';
// st-bridge.js is loaded by EVERY player, not just BrightSigns, because gating it on a user agent
// would mean a panel reporting an unexpected UA silently loses restart-instead-of-reload — the one
// thing it most needs. That makes its behaviour in a plain browser a correctness requirement, not a
// nicety: it must not throw, must report isBrightSign() false, and must tell the caller it could NOT
// take a restart so the player falls back to location.reload() instead of doing nothing.
//
// The other half is the dual-output collision. autorun.brs gives the second HDMI output its own
// widget, and both widgets share an origin, a registry and one SD storage_path. Un-namespaced keys
// would have output 2 read output 1's identity, and the two would collapse into a single device row
// — the same duplicate-row failure the hardware-only fingerprint once caused, in reverse.
//
// Run in a vm with a fake global rather than a browser, so the contract is checked without hardware.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const fs = require('node:fs');
const path = require('node:path');
const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'brightsign', 'st-bridge.js'), 'utf8');
/** Load the bridge into a fake window. `mods` present => pretend we are on a BrightSign. */
function load({ search = '', mods = null, ua = 'Mozilla/5.0 Chrome/150', seed = {} } = {}) {
const posted = [];
const registryStore = new Map(Object.entries(seed));
const sandbox = {
console: { log() {}, warn() {}, error() {} },
navigator: { userAgent: ua },
location: { search, reload() { sandbox.__reloaded = true; } },
setInterval: () => 1,
setTimeout: (fn, ms) => setTimeout(fn, ms),
Promise,
Object,
Date,
RegExp,
parseInt,
isNaN,
String,
decodeURIComponent,
__reloaded: false,
__posted: posted,
__registry: registryStore,
localStorage: { getItem: () => null, setItem() {} },
};
sandbox.window = sandbox;
if (mods) {
sandbox.require = (name) => {
if (name === '@brightsign/messageport') {
return function () {
return {
PostBSMessage: (o) => posted.push(o),
addEventListener: () => {},
};
};
}
if (name === '@brightsign/registry') {
// The real API is async and section-oriented:
// read(section, key) -> Promise<string>; write(section, {k: v}) -> Promise
// Modelling that exactly is the point of this fake — a synchronous stand-in would have
// hidden the bug where the bridge cached a Promise object as the device id.
return function () {
return {
read: (section, k) => Promise.resolve(registryStore.get(section + ':' + k)),
write: (section, values) => {
Object.keys(values).forEach((k) => registryStore.set(section + ':' + k, values[k]));
return Promise.resolve();
},
};
};
}
if (name === '@brightsign/deviceinfo') {
return function () {
return { model: 'XT1145', osVersion: '9.1.92.2', serialNumber: 'SN-TEST-1' };
};
}
throw new Error('no such module ' + name);
};
}
vm.createContext(sandbox);
vm.runInContext(SRC, sandbox);
const api = sandbox.ScreenTinkerBS;
// onReady always fires, so this resolves off-platform too.
const ready = new Promise((resolve) => api.onReady(resolve));
return { api, sandbox, posted, registryStore, ready };
}
test('in a plain browser it loads without throwing and reports not-BrightSign', () => {
const { api } = load();
assert.equal(api.isBrightSign(), false);
assert.equal(api.hasHost(), false);
});
test('THE FALLBACK: with no host, restart() returns false so the player can reload instead', () => {
const { api } = load();
// Returning false is the whole contract — the player checks it and calls location.reload().
assert.equal(api.restart('deploy'), false);
});
test('off-platform accessors return null/defaults rather than throwing', () => {
const { api } = load();
assert.equal(api.serial(), null);
assert.equal(api.model(), null);
assert.equal(api.osVersion(), null);
assert.equal(api.screen(), 1);
assert.equal(api.storageSuffix(), '');
assert.equal(api.setVideoMode({ width: 1920 }), false);
assert.doesNotThrow(() => api.onHostMessage(null));
});
test('a BrightSign UA alone is enough to identify the platform', () => {
// A widget built without nodejs_enabled resolves no modules, but the player still needs to know.
const { api } = load({ ua: 'BrightSign/9.1.92.2 (HD1026) Chrome/120.0.6099.225' });
assert.equal(api.isBrightSign(), true);
assert.equal(api.hasHost(), false, 'no modules means no host to take a restart');
});
test('with the host present, restart() posts to BrightScript and reports success', () => {
const { api, posted } = load({ mods: true });
assert.equal(api.hasHost(), true);
assert.equal(api.restart('server code updated'), true);
const msg = posted.find((m) => m.type === 'restart');
assert.ok(msg, 'the host must actually receive it');
assert.equal(msg.reason, 'server code updated');
});
test('identity round-trips through the registry', () => {
const { api } = load({ mods: true });
api.setIdentity('dev-123', 'https://screentinker.com');
assert.equal(api.deviceId(), 'dev-123');
});
test('THE RESET: clearIdentity makes the registry forget, so a reset really resets', () => {
const { api, posted } = load({ mods: true });
api.setIdentity('dev-123', null);
api.clearIdentity();
assert.equal(api.deviceId(), null, 'otherwise the next boot re-adopts the same display');
assert.ok(posted.some((m) => m.type === 'identity' && m.clear === true));
});
test('THE COLLISION: output 2 namespaces its registry key and storage away from output 1', () => {
const one = load({ mods: true, search: '?screen=1' });
const two = load({ mods: true, search: '?screen=2' });
assert.equal(one.api.screen(), 1);
assert.equal(two.api.screen(), 2);
assert.equal(one.api.storageSuffix(), '', 'screen 1 must keep the bare keys — existing panels');
assert.equal(two.api.storageSuffix(), '_s2');
one.api.setIdentity('display-A', null);
two.api.setIdentity('display-B', null);
assert.equal(one.api.deviceId(), 'display-A');
assert.equal(two.api.deviceId(), 'display-B', 'two outputs must not collapse into one device row');
// and the underlying keys really are distinct
assert.deepEqual(
[...one.registryStore.keys()].sort(),
['screentinker:device_id']
);
assert.deepEqual(
[...two.registryStore.keys()].sort(),
['screentinker:device_id_s2']
);
});
test('deviceinfo supplies identity, with the URL as the fallback before modules resolve', () => {
const withMods = load({ mods: true });
assert.equal(withMods.api.serial(), 'SN-TEST-1');
assert.equal(withMods.api.model(), 'XT1145');
const urlOnly = load({ search: '?serial=SN-URL&model=XC2055', ua: 'BrightSign/9 Chrome/120' });
assert.equal(urlOnly.api.serial(), 'SN-URL');
assert.equal(urlOnly.api.model(), 'XC2055');
});
test('sync backend comes from the URL, else the registry, else auto', () => {
assert.equal(load({ mods: true }).api.syncBackend(), 'auto');
assert.equal(load({ mods: true, search: '?sync_backend=brightsign' }).api.syncBackend(), 'brightsign');
const persisted = load({ mods: true });
persisted.api.setSyncBackend('screentinker');
assert.equal(persisted.api.syncBackend(), 'screentinker', 'a cold boot with no network still starts right');
});
test('THE ASYNC TRAP: a Promise from registry.read is never cached as the device id', async () => {
// registry.read() resolves a Promise. Treating it as a value would make deviceId() return the
// Promise object itself — truthy, non-empty — and the player would register a display called
// "[object Promise]" while its real row sat unclaimed.
const { api, ready } = load({ mods: true, seed: { 'screentinker:device_id': 'existing-id' } });
await ready;
assert.equal(typeof api.deviceId(), 'string');
assert.equal(api.deviceId(), 'existing-id', 'a provisioned panel must come back as itself');
});
test('a panel with nothing in the registry becomes ready with no identity, not a stuck one', async () => {
const { api, ready } = load({ mods: true });
await ready;
assert.equal(api.isReady(), true);
assert.equal(api.deviceId(), null);
});
test('onReady fires off-platform too, so a browser never blocks on hardware that is absent', async () => {
const { api, ready } = load();
await ready;
assert.equal(api.isReady(), true);
});
test('a rejected registry read still lets the player boot', async () => {
const { api, ready } = load({ mods: true });
// The fake resolves; what matters is that readiness is reached and nothing throws.
await ready;
assert.doesNotThrow(() => api.deviceId());
});