mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-19 16:43:51 -06:00
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Licence gate + SBOM (production deps) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
A fleet gets one package, and exactly one box per site should host the server.
Defaulting to on would mean every player that ever received this package
started listening on 8181, and the mistake would stay invisible until two of
them fought over the same displays.
st-config.json on the storage root, {"server": 1}, switches it on. Absent,
unreadable, unparseable, or anything other than an affirmative value leaves it
off - there is no reading of a broken config file that should end with a
device deciding to host a server. It sits at the root rather than in data/
because that is where an operator drops it over the DWS, and autozip never
writes it, so a re-provision cannot silently flip a site either way. The
package ships st-config.example.json, never st-config.json, for the same
reason.
With the server off, NOTHING listens: roNodeJs is never created, so there is
no 8181 and no 8182. The page is told through its URL rather than discovering
it, because "nothing is answering" would otherwise render as a fault and send
someone looking for a server that was never meant to exist. It now has a
fourth state that says so and offers the one line of JSON that changes it.
Separately: the status listener was bound to every interface, so anything on
the customer's LAN could read the install log, disk usage, the device's own
address and a tail of the server's console - that last one carries whatever
the server printed most recently. Its only consumer is a page on the same
device. Now 127.0.0.1 only, confirmed against /proc/net/tcp rather than by
probing, after a first attempt at verifying it fell back to loopback and
reported that as the LAN result.
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
Co-authored-by: Dan Walters <dan.walters@bytetinker.net>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
112 lines
5.4 KiB
JavaScript
112 lines
5.4 KiB
JavaScript
'use strict';
|
|
|
|
// What a BrightSign shows: the server's own diagnostics, or the player.
|
|
//
|
|
// The box is both server and player, so the screen has to be one or the other at any moment, and
|
|
// the interesting part is the transitions:
|
|
//
|
|
// - a fresh install has nothing to play and nobody to play it for, so it must show the address
|
|
// where the first account gets created. There is no keyboard on a player; hiding that address
|
|
// leaves the device unsetuppable.
|
|
// - once an account exists it should get out of the way and be a screen.
|
|
// - if the server later fails, the diagnostics must come BACK, or a black display is the only
|
|
// symptom of a server that died overnight.
|
|
//
|
|
// That last requirement is why the player is an iframe layer rather than a navigation: navigating
|
|
// would replace the document and kill the poller that notices the failure.
|
|
//
|
|
// The decision lives in node-server.html, which ships in autorun.zip and cannot be imported. It is
|
|
// written as one pure function so the table below can pin it; the test lifts that function out of
|
|
// the page rather than restating it, so a change to the page is a change to what is tested.
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const PAGE = fs.readFileSync(
|
|
path.join(__dirname, '..', '..', 'brightsign', 'server', 'node-server.html'), 'utf8');
|
|
|
|
function loadScreenState() {
|
|
const at = PAGE.indexOf('function screenState(');
|
|
assert.notEqual(at, -1, 'screenState() not found in node-server.html');
|
|
const open = PAGE.indexOf('{', at);
|
|
let depth = 0, end = -1;
|
|
for (let i = open; i < PAGE.length; i++) {
|
|
if (PAGE[i] === '{') depth++;
|
|
else if (PAGE[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
|
|
}
|
|
assert.notEqual(end, -1, 'unbalanced screenState()');
|
|
// eslint-disable-next-line no-new-func
|
|
return new Function(`${PAGE.slice(at, end + 1)}; return screenState;`)();
|
|
}
|
|
|
|
const screenState = loadScreenState();
|
|
|
|
const frame = (over) => Object.assign(
|
|
{ serving: true, fatal: null, needsSetup: false, port: '8181' }, over);
|
|
|
|
test('a healthy server with an account shows the player', () => {
|
|
assert.equal(screenState(frame(), true), 'player');
|
|
});
|
|
|
|
test('a player that was never asked to be a server says so, rather than reporting a fault', () => {
|
|
// st-config.json is absent or {"server": 0} - the default. There is no status listener to poll,
|
|
// so without this the page would show "no answer from the server process" and send someone
|
|
// looking for a broken server that was never meant to exist.
|
|
assert.equal(screenState(null, false), 'disabled');
|
|
assert.equal(screenState(frame(), false), 'disabled', 'the setting wins over any stale status');
|
|
});
|
|
|
|
test('a healthy server with NO account shows setup, not a blank player', () => {
|
|
// The whole point of requirement 1: stay on the config screen until someone has signed up.
|
|
assert.equal(screenState(frame({ needsSetup: true }), true), 'setup');
|
|
});
|
|
|
|
test('an unanswered setup probe is not treated as "no setup needed"', () => {
|
|
// null means we have not been told yet. Guessing "false" here would flip a fresh box to a player
|
|
// that has nothing to show, and take the sign-up address off the screen while doing it.
|
|
assert.equal(screenState(frame({ needsSetup: null }), true), 'diagnostics');
|
|
assert.equal(screenState(frame({ needsSetup: undefined }), true), 'diagnostics');
|
|
});
|
|
|
|
test('nothing listening means diagnostics, whatever else is true', () => {
|
|
assert.equal(screenState(frame({ serving: false }), true), 'diagnostics');
|
|
assert.equal(screenState(frame({ serving: false, needsSetup: false }), true), 'diagnostics');
|
|
});
|
|
|
|
test('THE RECOVERY CASE: a server that fails takes the player off the screen', () => {
|
|
// Requirement 2. A box that has been playing for weeks and then throws must show the operator
|
|
// something other than black.
|
|
const playing = frame();
|
|
assert.equal(screenState(playing, true), 'player');
|
|
|
|
const broken = frame({ fatal: 'server failed to start TypeError: ...' });
|
|
assert.equal(screenState(broken, true), 'diagnostics', 'a fatal must reveal the diagnostics again');
|
|
|
|
const gone = frame({ serving: false });
|
|
assert.equal(screenState(gone, true), 'diagnostics', 'so must the port going away');
|
|
});
|
|
|
|
test('no status at all is diagnostics rather than a crash', () => {
|
|
// The first paint happens before the first poll answers.
|
|
assert.equal(screenState(null, true), 'diagnostics');
|
|
assert.equal(screenState(undefined, true), 'diagnostics');
|
|
});
|
|
|
|
test('the page reloads the player when it comes back, rather than leaving an error page', () => {
|
|
// Not expressible in the pure function - assert the wiring instead. A player that rendered a
|
|
// connection error while the server was down will sit on it forever unless the src is re-set.
|
|
assert.match(PAGE, /if \(!playerShown\)[\s\S]{0,220}frame\.src =/,
|
|
'entering the player state must (re)assign the iframe src');
|
|
assert.match(PAGE, /frame\.removeAttribute\('src'\)/,
|
|
'leaving it must blank the frame so a dead server is not hammered behind an invisible layer');
|
|
});
|
|
|
|
test('the player is a layer, never a navigation', () => {
|
|
// location.href = player would replace this document and kill the poller that implements
|
|
// requirement 2. This is the assertion that stops someone "simplifying" it back.
|
|
assert.doesNotMatch(PAGE, /location\.href\s*=/,
|
|
'navigating away would destroy the only thing able to notice the server failing');
|
|
});
|