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
133 lines
6.3 KiB
JavaScript
133 lines
6.3 KiB
JavaScript
'use strict';
|
|
|
|
// A group is the mixed-platform case by definition: a lobby group holding two Android panels and
|
|
// a couple of browser tabs. "Reboot" is a legitimate thing to ask that group, and the two Android
|
|
// panels should get it — but the browser tabs cannot reboot their host, and the response used to
|
|
// count them as sent. The operator reads "sent to 4/4 devices" and walks away believing the whole
|
|
// group rebooted, which is the exact failure the capability model exists to end, just aggregated.
|
|
//
|
|
// The choice being pinned here: report per-device, do NOT refuse the whole command. Failing all
|
|
// four because one member is a browser tab would be its own bug.
|
|
|
|
const { test, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { spawn } = require('node:child_process');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const fs = require('node:fs');
|
|
const crypto = require('node:crypto');
|
|
const Database = require('better-sqlite3');
|
|
|
|
const { freePort } = require('./helpers/free-port');
|
|
let PORT, BASE, proc, db;
|
|
const DATA_DIR = path.join(os.tmpdir(), 'st-grpcaps-' + crypto.randomBytes(4).toString('hex'));
|
|
const LOG = path.join(os.tmpdir(), 'st-grpcaps-' + crypto.randomBytes(4).toString('hex') + '.log');
|
|
const S = {};
|
|
|
|
const jfetch = async (p, opts = {}) => {
|
|
const res = await fetch(BASE + p, opts);
|
|
let body = null; try { body = await res.json(); } catch { /* */ }
|
|
return { status: res.status, body };
|
|
};
|
|
const auth = () => ({ Authorization: 'Bearer ' + S.token, 'Content-Type': 'application/json' });
|
|
|
|
before(async () => {
|
|
PORT = await freePort();
|
|
BASE = `http://127.0.0.1:${PORT}`;
|
|
const logFd = fs.openSync(LOG, 'w');
|
|
proc = spawn('node', ['server.js'], {
|
|
cwd: path.join(__dirname, '..'),
|
|
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
|
|
stdio: ['ignore', logFd, logFd],
|
|
});
|
|
let up = false;
|
|
for (let i = 0; i < 80; i++) {
|
|
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ }
|
|
await new Promise(r => setTimeout(r, 250));
|
|
}
|
|
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
|
|
db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'));
|
|
|
|
const email = 'u' + crypto.randomBytes(5).toString('hex') + '@x.local';
|
|
const reg = await jfetch('/api/auth/register', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password: 'Passw0rd123' }),
|
|
});
|
|
S.token = reg.body.token;
|
|
const me = await jfetch('/api/auth/me', { headers: auth() });
|
|
S.wsId = me.body.accessible_workspaces[0].id;
|
|
|
|
const g = await jfetch('/api/groups', {
|
|
method: 'POST', headers: auth(), body: JSON.stringify({ name: 'lobby' }),
|
|
});
|
|
S.groupId = g.body.id;
|
|
|
|
// Two Android panels and two browser tabs.
|
|
//
|
|
// The panels DECLARE system.reboot (i.e. they are device owners) rather than relying on the
|
|
// baseline. The parity audit removed system.reboot from the Android baseline — STPolicy.reboot()
|
|
// needs device owner, so an undeclared panel cannot honour it either — and with all four members
|
|
// unable to reboot, this test would still pass while proving nothing. The point here is the
|
|
// MIXED case: some members can, some cannot, and the response must not blur them together.
|
|
const owner = JSON.stringify(['playback.video', 'system.reboot', 'system.restart_player']);
|
|
S.android = [
|
|
mkDevice({ client_type: 'apk', android_version: '12', capabilities: owner }),
|
|
mkDevice({ client_type: 'apk', android_version: '12', capabilities: owner }),
|
|
];
|
|
S.web = [mkDevice({ android_version: 'Web/Chrome' }), mkDevice({ android_version: 'Web/Chrome' })];
|
|
for (const id of [...S.android, ...S.web]) {
|
|
db.prepare('INSERT INTO device_group_members (group_id, device_id) VALUES (?, ?)').run(S.groupId, id);
|
|
}
|
|
});
|
|
after(() => { try { db && db.close(); } catch { /* */ } try { proc.kill('SIGKILL'); } catch { /* */ } });
|
|
|
|
function mkDevice(cols) {
|
|
const id = crypto.randomUUID();
|
|
db.prepare(`INSERT INTO devices (id, name, status, workspace_id, device_token, client_type, android_version, capabilities, created_at)
|
|
VALUES (?, ?, 'offline', ?, ?, ?, ?, ?, strftime('%s','now'))`)
|
|
.run(id, 'panel-' + id.slice(0, 4), S.wsId, crypto.randomBytes(16).toString('hex'),
|
|
cols.client_type || null, cols.android_version || null, cols.capabilities || null);
|
|
return id;
|
|
}
|
|
|
|
const send = (type) => jfetch(`/api/groups/${S.groupId}/command`, {
|
|
method: 'POST', headers: auth(), body: JSON.stringify({ type }),
|
|
});
|
|
|
|
test('reboot on a mixed group does not count the browser tabs as sent', async () => {
|
|
const r = await send('reboot');
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.total, 4);
|
|
assert.equal(r.body.unsupported, 2, 'the two web players cannot reboot their host');
|
|
assert.equal(r.body.offline, 2, 'the two Android panels are reachable in principle, just not connected');
|
|
assert.equal(r.body.sent, 0);
|
|
assert.notEqual(r.body.offline + r.body.sent, 4,
|
|
'before this, "4/4" was reported and the operator believed the whole group rebooted');
|
|
});
|
|
|
|
test('the response names which device was skipped and what it lacked', async () => {
|
|
const r = await send('reboot');
|
|
const skipped = r.body.results.filter(x => x.status === 'unsupported');
|
|
assert.equal(skipped.length, 2);
|
|
for (const x of skipped) {
|
|
assert.ok(S.web.includes(x.device_id), 'only the web players are skipped');
|
|
assert.equal(x.capability, 'system.reboot', 'so the reason is diagnosable without guessing');
|
|
assert.ok(x.name, 'named, because a device_id alone means nothing to an operator');
|
|
}
|
|
});
|
|
|
|
test('a command every member supports skips nobody', async () => {
|
|
// The control case: gating that quietly refuses everything looks identical to gating that
|
|
// works, until someone checks a command that should pass.
|
|
const r = await send('launch');
|
|
assert.equal(r.body.unsupported, 0, 'restarting the player is something all four can do');
|
|
assert.equal(r.body.offline, 4);
|
|
});
|
|
|
|
test('one unsupported member does not fail the command for the rest of the group', async () => {
|
|
const r = await send('reboot');
|
|
assert.equal(r.status, 200, 'the request itself succeeds');
|
|
assert.equal(r.body.success, true);
|
|
assert.equal(r.body.results.length, 4, 'every member is accounted for, none silently dropped');
|
|
});
|