mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
The parity doc and the capability model had drifted from the players in both
directions, and nothing failed when they did. Auditing all four players against
their shipped sources turned up three controls a customer can press today that
change nothing, and a set of baselines that were partly too generous and partly
too stingy.
The three dead controls:
- The volume slider works on Android only. The dashboard sends set_volume as
{ level: 0..1 }; the web player reads payload.value and Tizen reads
payload.value ?? payload.volume, so on both the number is undefined and the
handler quietly declines. Three complete, working volume implementations
that cannot be driven. The fix is one line in each player and belongs to
those files; audio.volume is out of the web and brightsign baselines until
it lands, held there by a biconditional test that fails the moment a player
starts reading `level`.
- Every #161 Tier-2 command was refused for the entire fleet. lock_now,
power_menu, status_bar, block_uninstall and unblock_uninstall were gated on
system.device_owner, which no player declares and no baseline grants, so
supports() was false everywhere -- including on the device-owner panels the
feature was built for. The dashboard still drew the buttons because it also
gates on device.tier === 2. Fixed here: those five now accept
system.device_owner OR system.kiosk, which PlayerCapabilities.kt declares
under `if (isOwner)` and nothing else, and which no non-Android player
declares. Android should declare system.device_owner and retire the
stand-in.
- enable_system_capture required the capability it creates. It raises the
MediaProjection consent dialog -- the way a panel GAINS capture -- and was
gated on remote.screenshot, so the only panel that needs it was the one
panel that could not be sent it. Now ungated. The dashboard still hides the
button behind the same check; that half is a frontend change.
The baselines describe what an un-updated fielded display can do, and since
v1.9.29 is the first build in which any player declares anything, that means
v1.9.28. Every entry is now justified against `git show v1.9.28:<source>`:
- android loses display.power (v1.9.28 answers screen_on with a logged no-op,
so the ON half is dead on every fielded panel and one capability renders
both buttons) and system.reboot (owner-only; off-owner it paints an
accessibility power dialog over the signage). Scheduled reboots now skip
undeclared Android panels rather than logging a reboot that never happened,
which is the reason that gate exists.
- tizen gains display.power: v1.9.28 implements both halves with no signing
and no panel API, so withholding it hid a working control.
- brightsign loses audio.volume, display.power, system.reboot,
system.restart_player and offline.cache. All need a host bridge the unit is
not known to have, and restart_player without one is the page reload that
darkened a panel on 2026-07-28.
Also found, not fixed here because the files belong to others:
st-bridge.js computeCapabilities() is dead code -- nothing calls BS.capabilities()
-- and its 199 lines of passing tests constrain nothing a BrightSign actually
declares; the two disagree on six capabilities and the bridge is right about
most of them. BrightSign's "Force update" button is dead. PlayerCapabilities.kt
under-declares display.brightness.
The new test reads the player sources rather than the table: a dead-button rule
(every gated command has a branch somewhere), an unreachable-capability rule
(which would have caught system.device_owner), and biconditionals so a fix in a
player fails the test until the baseline follows. Claims that need hardware --
CEC reaching a display, a widget being allowed a service worker, SyncManager
holding frame lock -- are marked unverifiable in the document instead of
asserted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
135 lines
7.9 KiB
JavaScript
135 lines
7.9 KiB
JavaScript
'use strict';
|
|
|
|
// The dashboard offered every control to every display. A browser tab cannot reboot its host, a
|
|
// Tizen TV has no device-owner concept, a BrightSign has no per-window brightness — so those
|
|
// buttons did nothing, silently. "UI that reports success and changes nothing" is a recurring bug
|
|
// shape here, and hiding an unsupported control is the fix.
|
|
//
|
|
// The risk in doing that is the opposite failure: stripping controls from the several hundred
|
|
// displays already in the field, none of which declare anything. So an ABSENT declaration falls
|
|
// back to a per-platform baseline, while an EMPTY one is honoured as a player genuinely saying it
|
|
// can do nothing. Those two cases are easy to conflate and the difference is a dark dashboard.
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const caps = require('../lib/player-capabilities');
|
|
|
|
test('a legacy display with NO declaration keeps its platform baseline', () => {
|
|
// The several-hundred-device case: they will not update before the next dashboard deploy.
|
|
const android = { client_type: 'apk', android_version: '12' };
|
|
assert.ok(caps.supports(android, 'system.restart_player'));
|
|
assert.ok(caps.supports(android, 'playback.video'));
|
|
assert.ok(caps.supports(android, 'offline.cache'));
|
|
});
|
|
|
|
test('THE DISTINCTION: an EMPTY declaration is honoured, not treated as missing', () => {
|
|
// A player saying "I can do nothing" is a real answer — a widget with no host bridge, say — and
|
|
// must not be quietly upgraded to the baseline.
|
|
const d = { client_type: 'apk', capabilities: '[]' };
|
|
assert.deepEqual(caps.capabilitiesFor(d), []);
|
|
assert.equal(caps.supports(d, 'playback.video'), false);
|
|
});
|
|
|
|
test('a declaration overrides the baseline in BOTH directions', () => {
|
|
// Android gains real screenshots only when accessibility is on, and loses Tier-2 commands when
|
|
// it is not device owner. A static table could never know either.
|
|
const restricted = { client_type: 'apk', capabilities: JSON.stringify(['playback.video']) };
|
|
assert.equal(caps.supports(restricted, 'system.reboot'), false, 'declared set is authoritative');
|
|
|
|
const enhanced = { platform: 'Chrome 120', capabilities: JSON.stringify(['playback.video', 'system.reboot']) };
|
|
assert.ok(caps.supports(enhanced, 'system.reboot'), 'a web player behind a host CAN reboot');
|
|
});
|
|
|
|
test('a browser tab does not claim what it cannot do', () => {
|
|
const web = { platform: 'Chrome 150', android_version: 'Web/Chrome' };
|
|
assert.equal(caps.supports(web, 'system.reboot'), false);
|
|
assert.equal(caps.supports(web, 'system.kiosk'), false);
|
|
assert.equal(caps.supports(web, 'display.power'), false);
|
|
assert.ok(caps.supports(web, 'playback.video'));
|
|
});
|
|
|
|
test('platform families are recognised from the same fields the rest of the product uses', () => {
|
|
assert.equal(caps.platformFamily({ platform: 'brightsign' }), 'brightsign');
|
|
assert.equal(caps.platformFamily({ platform: 'Tizen 6.0' }), 'tizen');
|
|
assert.equal(caps.platformFamily({ client_type: 'apk' }), 'android');
|
|
assert.equal(caps.platformFamily({ android_version: 'Android 12' }), 'android');
|
|
assert.equal(caps.platformFamily({ android_version: 'Web/Safari' }), 'web');
|
|
assert.equal(caps.platformFamily({}), 'web', 'unknown falls back to the most limited set');
|
|
});
|
|
|
|
test('an unknown capability from a NEWER player does not discard the ones we understand', () => {
|
|
const d = { client_type: 'apk', capabilities: JSON.stringify(['playback.video', 'quantum.teleport']) };
|
|
assert.deepEqual(caps.capabilitiesFor(d), ['playback.video']);
|
|
});
|
|
|
|
test('asking about a capability that does not exist is false, never a throw', () => {
|
|
assert.equal(caps.supports({ client_type: 'apk' }, 'nonsense.capability'), false);
|
|
assert.equal(caps.supports(null, 'playback.video'), false);
|
|
});
|
|
|
|
test('malformed declarations fall back rather than blanking the UI', () => {
|
|
for (const bad of ['not json', '{"a":1}', ' ', 42]) {
|
|
const d = { client_type: 'apk', capabilities: bad };
|
|
assert.ok(caps.supports(d, 'playback.video'), `${JSON.stringify(bad)} must fall back to baseline`);
|
|
}
|
|
});
|
|
|
|
test('every baseline entry is a real capability name', () => {
|
|
// A typo here silently disables a control for a whole platform.
|
|
for (const [family, list] of Object.entries(caps.BASELINE)) {
|
|
for (const c of list) assert.ok(caps.CAP_SET.has(c), `${family} baseline has unknown capability ${c}`);
|
|
}
|
|
});
|
|
|
|
test('an UNDECLARED BrightSign is a browser tab, because the host bridge is unreleased', () => {
|
|
/*
|
|
* This test used to assert the opposite — that a BrightSign claims display power and reboot —
|
|
* and it was wrong in the way that matters: the page reaches all of them only behind
|
|
* BS.hasHost(), which needs an roHtmlWidget the on-device BrightScript created with node
|
|
* integration. No released package shipped that, the one real XT245 we have runs BSN
|
|
* Supervisor's widget where hasHost() is false, and any unit that DOES have a bridge declares
|
|
* for itself and never reads this baseline.
|
|
*/
|
|
const bs = { platform: 'brightsign' };
|
|
assert.equal(caps.supports(bs, 'system.reboot'), false, 'RebootSystem() needs a host that is not there');
|
|
assert.equal(caps.supports(bs, 'display.power'), false, 'CEC needs the same host');
|
|
assert.equal(caps.supports(bs, 'system.restart_player'), false,
|
|
'a page-initiated reload does not reliably bring an roHtmlWidget back — this one darkened a panel');
|
|
assert.ok(caps.supports(bs, 'playback.video'), 'it is still a player');
|
|
|
|
// A BrightSign that DOES declare gets everything its bridge really provides.
|
|
const withHost = { platform: 'brightsign', capabilities: JSON.stringify(['system.reboot', 'display.power']) };
|
|
assert.ok(caps.supports(withHost, 'system.reboot'));
|
|
|
|
// Tizen has a real blanking path on every build (showScreenOff/clearScreenOff, no signing
|
|
// needed) but no reboot without a partner-signed B2B surface it cannot assume.
|
|
const tizen = { platform: 'Tizen 6.5' };
|
|
assert.ok(caps.supports(tizen, 'display.power'), 'both halves work on a fielded .wgt');
|
|
assert.equal(caps.supports(tizen, 'system.reboot'), false);
|
|
});
|
|
|
|
test('Tizen does NOT claim offline caching — it caches the playlist, not the media', () => {
|
|
// st_payload_cache holds the playlist JSON in localStorage; there is no service worker and no
|
|
// media cache, so an outage leaves the panel with a playlist it cannot play. The first version
|
|
// of this baseline claimed offline.cache, which is precisely the lie the model exists to stop.
|
|
assert.equal(caps.supports({ platform: 'Tizen 6.5' }, 'offline.cache'), false);
|
|
assert.ok(caps.supports({ client_type: 'apk' }, 'offline.cache'), 'Android really does cache media');
|
|
});
|
|
|
|
test('the baseline describes a FIELDED player, not the one we are about to ship', () => {
|
|
// Tizen's shipped build has no set_volume handler — the command falls through to "unknown
|
|
// command" — so a legacy panel must not claim audio.volume even though updated panels will
|
|
// declare it themselves. It DOES implement capture and streaming, so those must be claimed or
|
|
// working controls disappear from every legacy Tizen display.
|
|
const tizen = { platform: 'Tizen 6.5' };
|
|
assert.equal(caps.supports(tizen, 'audio.volume'), false, 'the slider is dead on a fielded panel');
|
|
// Same answer on web and BrightSign, for a second and separate reason: those players read
|
|
// payload.value while the dashboard sends payload.level, so even HEAD's handler never fires.
|
|
assert.equal(caps.supports({ android_version: 'Web/Chrome' }, 'audio.volume'), false);
|
|
assert.equal(caps.supports({ platform: 'brightsign' }, 'audio.volume'), false);
|
|
assert.ok(caps.supports({ client_type: 'apk' }, 'audio.volume'), 'Android reads the payload it is sent');
|
|
assert.ok(caps.supports(tizen, 'audio.mute'), 'mute does work');
|
|
assert.ok(caps.supports(tizen, 'remote.screenshot'), 'captureAndSend exists in the shipped player');
|
|
assert.ok(caps.supports(tizen, 'remote.stream'), 'startStreaming exists in the shipped player');
|
|
});
|