mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Two gaps that both end the same way — a panel nobody can fix without a van. OFFLINE. Content bytes were never persistently cached. The service worker skipped /uploads/content/ and leaned on the browser's HTTP cache, which is reasonable on a desktop and is not a documented-persistent store here: BrightSign guarantees survival across reboots for IndexedDB, localStorage and SQLite, and their own answer for offline video is to cache the bytes explicitly. A panel could come back from a power cut with its playlist intact — that lives in localStorage — and no media to play it with. The reason content was skipped is real, and player-cache-policy.js is what makes intercepting it safe. Seeking video issues range requests, and naive caching is worse than none: storing a 206 as the whole file means every later full request gets a fragment, and answering a range request with a 200 makes some media stacks fail outright. So only complete 200s are stored, and ranges are served by slicing the stored body into a correct 206. The content cache survives shell re-versioning, or every deploy would re-download the playlist over a link that may be exactly what is broken. SELF-UPDATE. The package can replace autorun.brs, so a truncated file is a dark panel with no app underneath. The safety is the ordering: download to .part, verify sha256 AND size, then delete the .done marker, rename, reboot. Marker first is not stylistic — leaving it makes the next boot skip the archive and the update silently never happens. A failed extract parks the zip as .bad instead of retrying every boot, which would be a loop indistinguishable from a hardware fault. sha256 because that is what roMessageDigest can compute; a checksum the player cannot verify is an unverifiable package. The decision lives on the server and is unit-tested, and the host only executes it — re-implementing the version comparison in BrightScript would put the prerelease trap somewhere untestable. That trap is honoured directly: a player on 1.9.29-rc1 is running something semver-OLDER than 1.9.29, so an opted-in player HOLDS a prerelease of its own core rather than being pulled off the build it was given to test. Narrowly — a newer core still lands, so opting in never means never updating again. Both loop conditions are closed by construction. The manifest and the download come from one buffer hashed once, so a checksum cannot describe bytes we are not serving. And the version is stamped into autorun.brs at build time by both builders, so the script reports the version it actually is — otherwise the player applies the update, still reports the old version, and is offered the same package forever. Failure always degrades to "keep running the old version": an unreachable manifest, a missing checksum, a failed verification, a full attempt counter and an unbuildable package all resolve to skip. 998 tests pass (was 954).
149 lines
6.8 KiB
JavaScript
149 lines
6.8 KiB
JavaScript
'use strict';
|
|
|
|
// This is the riskiest self-update in the product. An Android OTA that goes wrong leaves a player on
|
|
// the old APK. A BrightSign package update that goes wrong replaces THE SCRIPT THAT BOOTS THE
|
|
// PLAYER — there is no app underneath to fall back to, so a truncated or half-applied autorun.brs is
|
|
// a dark panel and a site visit.
|
|
//
|
|
// Every test here names a way that happens.
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const U = require('../lib/brightsign-update');
|
|
|
|
const base = {
|
|
currentVersion: '1.9.28',
|
|
manifestVersion: '1.9.29',
|
|
manifestSha256: 'abc123',
|
|
attempts: 0
|
|
};
|
|
|
|
// ---------------------------------------------------------------- the prerelease scar
|
|
|
|
test('THE REVERT: a release must not overwrite the prerelease someone is testing', () => {
|
|
// 1.9.29-rc1 is semver-OLDER than 1.9.29. Without this the player asks "anything newer?", is
|
|
// correctly told yes, and wipes the build it was handed to test. That cost a reporter an evening
|
|
// on the Android side; here it would overwrite the boot script.
|
|
const r = U.decidePackageUpdate({
|
|
...base, currentVersion: '1.9.29-rc1', manifestVersion: '1.9.29', allowPrerelease: true
|
|
});
|
|
assert.equal(r.action, 'skip');
|
|
});
|
|
|
|
test('prerelease ordering is right: rc1 < rc2 < release', () => {
|
|
assert.equal(U.compareVersions('1.9.29-rc1', '1.9.29-rc2'), -1);
|
|
assert.equal(U.compareVersions('1.9.29-rc2', '1.9.29'), -1);
|
|
assert.equal(U.compareVersions('1.9.29', '1.9.29-rc1'), 1);
|
|
assert.equal(U.compareVersions('1.9.29', '1.9.29'), 0);
|
|
assert.equal(U.compareVersions('1.10.0', '1.9.99'), 1, 'numeric, not lexicographic');
|
|
});
|
|
|
|
test('a prerelease is never applied without opt-in, mirroring the Android beta channel', () => {
|
|
const r = U.decidePackageUpdate({ ...base, manifestVersion: '1.9.30-rc1' });
|
|
assert.equal(r.action, 'skip');
|
|
assert.match(r.reason, /opt-in/);
|
|
|
|
const opted = U.decidePackageUpdate({ ...base, manifestVersion: '1.9.30-rc1', allowPrerelease: true });
|
|
assert.equal(opted.action, 'download');
|
|
});
|
|
|
|
// ---------------------------------------------------------------- refusing to brick
|
|
|
|
test('THE OUTAGE: no reachable manifest means keep running, never wipe', () => {
|
|
// The common case during exactly the failure this whole feature is meant to survive.
|
|
const r = U.decidePackageUpdate({ ...base, manifestVersion: null });
|
|
assert.equal(r.action, 'skip');
|
|
assert.equal(r.reason, 'no manifest');
|
|
});
|
|
|
|
test('a manifest with no checksum is refused — unverifiable is the truncation risk', () => {
|
|
const r = U.decidePackageUpdate({ ...base, manifestSha256: null });
|
|
assert.equal(r.action, 'skip');
|
|
assert.match(r.reason, /checksum/);
|
|
});
|
|
|
|
test('THE TRUNCATED DOWNLOAD: a staged package that fails its checksum is re-downloaded, not applied', () => {
|
|
// Applying this overwrites autorun.brs with a partial file. The player boots into nothing.
|
|
const r = U.decidePackageUpdate({ ...base, stagedSha256: 'WRONG' });
|
|
assert.equal(r.action, 'download');
|
|
assert.match(r.reason, /failed verification/);
|
|
});
|
|
|
|
test('a staged package that verifies is applied — download and apply are separate gates', () => {
|
|
const r = U.decidePackageUpdate({ ...base, stagedSha256: 'abc123' });
|
|
assert.equal(r.action, 'apply');
|
|
});
|
|
|
|
test('THE RETRY LOOP: repeated failure on one version stops rather than burning the link forever', () => {
|
|
const r = U.decidePackageUpdate({ ...base, attempts: U.MAX_ATTEMPTS_PER_VERSION });
|
|
assert.equal(r.action, 'skip');
|
|
assert.match(r.reason, /too many failed attempts/);
|
|
});
|
|
|
|
test('attempts below the cap still try — one bad download must not park a player permanently', () => {
|
|
assert.equal(U.decidePackageUpdate({ ...base, attempts: 1 }).action, 'download');
|
|
assert.equal(U.decidePackageUpdate({ ...base, attempts: U.MAX_ATTEMPTS_PER_VERSION - 1 }).action, 'download');
|
|
});
|
|
|
|
// ---------------------------------------------------------------- idempotence / no loop
|
|
|
|
test('THE OTA LOOP: once current, the same version is never offered again', () => {
|
|
// Install, report the old version, get offered it again, forever — the classic loop. Equality is
|
|
// what breaks it, and it must hold for prereleases too.
|
|
assert.equal(U.decidePackageUpdate({ ...base, currentVersion: '1.9.29' }).action, 'skip');
|
|
assert.equal(U.decidePackageUpdate({
|
|
...base, currentVersion: '1.9.29-rc2', manifestVersion: '1.9.29-rc2', allowPrerelease: true
|
|
}).action, 'skip');
|
|
});
|
|
|
|
test('an older advertised version is ignored, so a rolled-back server cannot downgrade a fleet', () => {
|
|
const r = U.decidePackageUpdate({ ...base, currentVersion: '1.9.30', manifestVersion: '1.9.28' });
|
|
assert.equal(r.action, 'skip');
|
|
assert.match(r.reason, /older/);
|
|
});
|
|
|
|
test('a genuinely newer package is downloaded', () => {
|
|
assert.equal(U.decidePackageUpdate(base).action, 'download');
|
|
});
|
|
|
|
test('missing state is treated as "do nothing" rather than throwing in a boot path', () => {
|
|
// A throw here happens before the player starts; it must degrade, not explode.
|
|
assert.doesNotThrow(() => U.decidePackageUpdate(undefined));
|
|
assert.equal(U.decidePackageUpdate(undefined).action, 'skip');
|
|
assert.equal(U.decidePackageUpdate({}).action, 'skip');
|
|
});
|
|
|
|
test('holding a prerelease is NARROW: a newer core still lands on an opted-in player', () => {
|
|
// Otherwise "opt into testing" would quietly mean "never update again", which is how testers get
|
|
// stranded on a branch nobody maintains.
|
|
const r = U.decidePackageUpdate({
|
|
...base, currentVersion: '1.9.29-rc1', manifestVersion: '1.9.30', allowPrerelease: true
|
|
});
|
|
assert.equal(r.action, 'download');
|
|
});
|
|
|
|
test('a player that never opted in rejoins the release line from a stale test build', () => {
|
|
// It is not testing anything, so leaving it on an orphaned build is worse than updating it.
|
|
const r = U.decidePackageUpdate({
|
|
...base, currentVersion: '1.9.29-rc1', manifestVersion: '1.9.29', allowPrerelease: false
|
|
});
|
|
assert.equal(r.action, 'download');
|
|
});
|
|
|
|
// ---------------------------------------------------------------- the last gate
|
|
|
|
test('the apply gate demands a matching checksum', () => {
|
|
assert.equal(U.isPackageSafeToApply('aaa', 'aaa', 50000), true);
|
|
assert.equal(U.isPackageSafeToApply('aaa', 'bbb', 50000), false);
|
|
assert.equal(U.isPackageSafeToApply(null, 'aaa', 50000), false);
|
|
assert.equal(U.isPackageSafeToApply('aaa', null, 50000), false);
|
|
});
|
|
|
|
test('THE CAPTIVE PORTAL: a tiny file is refused even if the hash is somehow satisfied', () => {
|
|
// A network that serves a login page in place of the download produces a small HTML body. It has
|
|
// a hash like anything else; what it does not have is a plausible size for a player package.
|
|
assert.equal(U.isPackageSafeToApply('aaa', 'aaa', 800), false);
|
|
assert.equal(U.isPackageSafeToApply('aaa', 'aaa', 800, 1024), false);
|
|
assert.equal(U.isPackageSafeToApply('aaa', 'aaa', 2048, 1024), true);
|
|
});
|