screentinker/server/test/brightsign-bridge.test.js
ScreenTinker ce854ff2d8 Wire the BrightSign bridge into the web player
The bridge and the host existed but nothing loaded them. Now the player does.

restartPlayer() replaces every location.reload() call site. On BrightSign a
page-initiated reload does not reliably bring the roHtmlWidget back, so the page
asks the host to rebuild it and only falls back to reload() when no host is
there to take the request. That covers the deploy path, the operator refresh,
the service-worker activation and the manual reset.

Identity now round-trips through the registry, which outlives localStorage on
this platform: getConfig() adopts a registry identity when local storage comes
back empty, instead of re-pairing and spawning a second row for a panel that is
already provisioned. The operator reset clears the registry too — otherwise it
would clear localStorage, get the same identity straight back on the next boot,
and reset nothing.

Registration reports platform 'brightsign' rather than "Chrome 120", which is
what sync-backend.js resolves native-vs-ours from, plus model, OS, serial and
which output this widget paints.

Dual output needed a collision fix: 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, output 2 would read output 1's config, install salt
and device id and the two would collapse into a single device row. Storage keys
and registry keys are now suffixed per output; screen 1 keeps the bare names so
nothing existing moves.

The bridge is served from its single source so the copy the player loads can
never skew from the one on the SD card next to autorun.brs, and it is served to
every player rather than gated on a user agent — a panel reporting an unexpected
UA would otherwise silently lose restart-instead-of-reload.

Two test harnesses extract player functions and run them in an isolated scope,
so they now supply SCREEN_SUFFIX; one gained a case proving two outputs of one
player get distinct identities. 927 pass.

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

175 lines
6.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' } = {}) {
const posted = [];
const registryStore = new Map();
const sandbox = {
console: { log() {}, warn() {}, error() {} },
navigator: { userAgent: ua },
location: { search, reload() { sandbox.__reloaded = true; } },
setInterval: () => 1,
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') {
return function () {
return {
read: (section, key) => registryStore.get(section + ':' + key),
write: (section, key, value) => registryStore.set(section + ':' + key, value),
};
};
}
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);
return { api: sandbox.ScreenTinkerBS, sandbox, posted, registryStore };
}
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');
});