mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Every device control was offered to every display. A browser tab was shown "Reboot device", a Tizen TV was shown screen power, a player with no framebuffer read was shown a live view that stayed black. They all looked like working buttons and did nothing — the "reports success and changes nothing" shape that keeps costing people days. Players now declare what they can do at registration, because only the player knows at runtime: an Android panel gains real screenshots when accessibility is switched on and loses Tier-2 when device owner is revoked. The dashboard hides what is not supported rather than disabling it, and the Info tab lists the capability set so a missing control is explainable. The declaration is three-state and the middle state is load bearing: NULL means "has never told us anything" and falls back to a per-platform baseline, because several hundred displays in the field will not update before this deploys and blanking their controls would be a far worse bug. An empty array means "I genuinely can do nothing" and is honoured. Hiding a button is not enforcement, so unsupported commands are also refused server-side — the socket is reachable directly and a stale tab still renders the old controls. Group sends report skipped devices separately from sent ones; counting an unreachable member as "sent" is how an operator walks away believing the whole group rebooted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
123 lines
5.7 KiB
JavaScript
123 lines
5.7 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 (declare nothing -> baseline, i.e. the legacy fleet) and two browser tabs.
|
|
S.android = [mkDevice({ client_type: 'apk', android_version: '12' }), mkDevice({ client_type: 'apk', android_version: '12' })];
|
|
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, 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);
|
|
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');
|
|
});
|