'use strict';
// The dashboard offered every control to every display. "Reboot device" on a browser tab, screen
// power on a Tizen TV, a Remote tab whose live view is a permanently black canvas on a player with
// no framebuffer read. Every one of them looked like a working button and did nothing — the
// "reports success and changes nothing" shape that keeps costing people days.
//
// Controls are now HIDDEN, not disabled: a greyed-out button on a panel that will never gain the
// capability is a permanent unanswerable question. Which makes the opposite failure the dangerous
// one — a gate that is slightly too strict strips controls from the several hundred displays
// already in the field, none of which declare anything. That case gets its own test below, and it
// is the one to read first if this file ever goes red.
//
// This renders the real device-detail template out of the source file rather than asserting on a
// copy of it, so a control added later without a gate shows up here instead of in production.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const SRC = fs.readFileSync(
path.join(__dirname, '..', '..', 'frontend', 'js', 'views', 'device-detail.js'), 'utf8');
// The template is one tagged region inside loadDevice(). Pull it out and evaluate it against
// stubbed helpers — the point is which controls appear, not how they are styled.
const START = 'contentEl.innerHTML = `';
const template = (() => {
const i = SRC.indexOf(START);
assert.ok(i > 0, 'device-detail.js no longer has the innerHTML template this test renders');
const j = SRC.indexOf('\n `;', i);
assert.ok(j > i, 'could not find the end of the template');
return SRC.slice(i + START.length, j);
})();
function render(device, telemetry) {
const caps = Array.isArray(device.capabilities) ? device.capabilities : null;
const sandbox = {
device,
caps,
can: (cap) => (caps ? caps.includes(cap) : true),
latestTelemetry: telemetry || {},
diagWidget: null,
// Stubs. Each returns something recognisable so a control cannot be "found" by accident.
t: (key) => key,
esc: (s) => String(s == null ? '' : s),
formatBytes: () => '0 MB',
formatUptime: () => '0m',
ssidLabel: () => 'ssid',
livenessBadge: () => ({ state: 'online', label: 'online', title: '' }),
renderDiagPanel: () => '',
renderDeviceClock: () => '',
renderPlaylist: () => '',
isBrightSignDevice: (d) => String(d.platform || '').toLowerCase().includes('brightsign'),
// Same four signals, same order, as the real helper in device-detail.js and platformFamily()
// in server/lib/player-capabilities.js. Kept as a stub rather than imported because this file
// renders the template in a bare VM context — but if the real rule changes, change it here too.
// The brightsign/tizen/wgt short-circuits come FIRST and are load-bearing: a Tizen TV registers
// android_version 'Tizen 6.5', which satisfies the Android test below.
isAndroidDevice: (d) => {
if (!d) return false;
const p = String(d.platform || '').toLowerCase();
if (p.includes('brightsign') || p.includes('tizen')) return false;
if (d.client_type === 'wgt') return false;
if (d.client_type === 'apk') return true;
const av = String(d.android_version || '');
return av !== '' && !av.startsWith('Web/');
},
TERMINAL_PRESETS: [],
localStorage: { getItem: () => null, setItem: () => {} },
Math, Date, JSON, String, Array, Object,
};
return vm.runInNewContext('`' + template + '`', sandbox);
}
const ANDROID_FULL = {
client_type: 'apk', android_version: '13',
capabilities: ['playback.video', 'audio.volume', 'display.power', 'display.brightness',
'remote.screenshot', 'remote.stream', 'remote.input',
'system.reboot', 'system.restart_player', 'system.self_update'],
};
const WEB = {
android_version: 'Web/Chrome',
capabilities: ['playback.video', 'audio.volume', 'remote.screenshot', 'remote.stream',
'remote.input', 'system.restart_player'],
};
// Exactly what tizen/js/app.js registers, including the android_version field — which reads
// 'Tizen 6.5' and NOT anything Android-shaped. An earlier version of this fixture omitted it, so
// every "not offered to Tizen" assertion below passed without ever exercising the case that
// actually matters.
const TIZEN = {
platform: 'Tizen 6.5', client_type: 'wgt', android_version: 'Tizen 6.5',
capabilities: ['playback.video', 'audio.volume', 'display.rotation', 'remote.input',
'system.restart_player'],
};
const BRIGHTSIGN = {
platform: 'brightsign', hardware_model: 'XT245',
capabilities: ['playback.video', 'audio.volume', 'display.power', 'display.rotation',
'remote.input', 'system.reboot', 'system.restart_player'],
};
const has = (html, id) => html.includes(`id="${id}"`);
// Same harness, but with a telemetry payload — the cards above are driven by it.
function renderWith(device, telemetry) {
const saved = renderWith._tel;
renderWith._tel = telemetry;
try { return render(device, telemetry); } finally { renderWith._tel = saved; }
}
test('a browser tab is no longer offered controls over a machine it cannot touch', () => {
const html = render(WEB);
assert.equal(has(html, 'rebootBtn'), false, 'a tab cannot reboot the PC it is running on');
assert.equal(has(html, 'shutdownBtn'), false);
assert.equal(has(html, 'screenOffBtn'), false, 'nor switch off the monitor');
assert.equal(has(html, 'screenOnBtn'), false);
assert.equal(has(html, 'forceUpdateBtn'), false, 'nor update itself — the page reloads instead');
assert.ok(has(html, 'launchAppBtn'), 'but reloading the player IS something it can do');
});
test('a Tizen TV is not offered screen power or the reboot it has no API for', () => {
const html = render(TIZEN);
assert.equal(has(html, 'screenOffBtn'), false);
assert.equal(has(html, 'screenOnBtn'), false);
assert.equal(has(html, 'rebootBtn'), false);
assert.equal(has(html, 'forceUpdateBtn'), false);
});
test('a BrightSign IS offered the screen power and reboot it genuinely has', () => {
// The check that catches gating written as "hide everything that is not Android", which would
// read as correct on every other test in this file.
const html = render(BRIGHTSIGN);
assert.ok(has(html, 'screenOffBtn'));
assert.ok(has(html, 'screenOnBtn'));
assert.ok(has(html, 'rebootBtn'));
});
test('an Android panel keeps the full control set', () => {
const html = render(ANDROID_FULL);
for (const id of ['rebootBtn', 'screenOffBtn', 'screenOnBtn', 'launchAppBtn', 'forceUpdateBtn',
'screenshotBtn', 'startRemoteBtn', 'sysVolume', 'sysWinBrightness']) {
assert.ok(has(html, id), `${id} must survive`);
}
});
test('THE REGRESSION THAT MATTERS: an undeclared legacy display loses nothing', () => {
// ~440 real displays declare nothing. If the gate reads "no declaration => supports nothing",
// every one of them loses its entire control panel the moment this deploys — a far worse bug
// than the one being fixed. The server resolves a per-platform baseline for them, and this
// asserts the client renders whatever it is handed rather than second-guessing it.
const legacyAndroid = { client_type: 'apk', android_version: '9' }; // no capabilities field
const html = render(legacyAndroid);
for (const id of ['rebootBtn', 'screenOffBtn', 'screenOnBtn', 'launchAppBtn', 'forceUpdateBtn',
'screenshotBtn', 'startRemoteBtn']) {
assert.ok(has(html, id), `${id} disappeared for a display that never declared anything`);
}
});
test('the live view is hidden on a player that cannot capture, and the key pad is not', () => {
// Start used to produce a canvas that stayed black forever, which reads as a dead panel rather
// than as an unsupported feature. The D-pad still works there — it is a different mechanism.
const html = render(TIZEN);
assert.equal(has(html, 'startRemoteBtn'), false, 'no screenshot stream to start');
assert.equal(has(html, 'remoteCanvas'), false, 'and no permanently black canvas');
assert.ok(html.includes('KEYCODE_DPAD_CENTER'), 'key input is unaffected');
});
test('a player with no remote surface at all loses the whole Remote tab', () => {
const blind = { platform: 'brightsign', capabilities: ['playback.video', 'audio.volume'] };
const html = render(blind);
assert.equal(html.includes('data-tab="remote"'), false, 'no tab');
assert.equal(has(html, 'tab-remote'), false, 'and no orphaned tab body behind it');
});
test('a tab trigger is never rendered without its content, or the click blanks the page', () => {
// setupTabs() does getElementById(`tab-${dataset.tab}`).classList.add(...) with no null check,
// so a trigger whose body was gated away throws on click and leaves every tab deselected.
for (const device of [WEB, TIZEN, BRIGHTSIGN, ANDROID_FULL, { client_type: 'apk' }]) {
const html = render(device);
for (const m of html.matchAll(/data-tab="([\w-]+)"/g)) {
assert.ok(has(html, `tab-${m[1]}`),
`tab "${m[1]}" has a trigger but no content for ${device.platform || device.android_version || 'apk'}`);
}
}
});
test('the capability list is shown, so a missing control is explainable', () => {
// Hiding controls with no explanation just moves the confusion: "the reboot button vanished"
// is a support ticket unless the page says what the panel reported.
const html = render(TIZEN);
assert.ok(html.includes('device.caps.title'));
assert.ok(html.includes('remote.input'), 'the actual declared names are listed');
assert.ok(html.includes('device.caps.declared'));
const legacy = render({ client_type: 'apk' });
assert.ok(legacy.includes('device.caps.assumed'),
'and an undeclared display says so rather than presenting a guess as fact');
});
test('every gated control still renders balanced markup', () => {
// A gate placed around an opening tag but not its close leaves the rest of the page inside a
// stray element, which does not throw and does not show up in any assertion above.
for (const device of [WEB, TIZEN, BRIGHTSIGN, ANDROID_FULL, { client_type: 'apk' },
{ platform: 'brightsign', capabilities: [] }]) {
const html = render(device);
const open = (html.match(/
', v));
};
test('a BrightSign shows its player version on the Info tab', () => {
const html = render({ ...BRIGHTSIGN, app_version: '1.9.36', client_version: '1.1.0-web' });
const card = infoCard(html, 'device.info.app_version');
assert.ok(card, 'the version card must render off Android');
assert.ok(card.includes('1.9.36'), `expected the host package version, got: ${card}`);
});
test('and the page version alongside it, because the two can disagree', () => {
// On a BrightSign app_version is the on-device host package and client_version is the page we
// serve. A stale host against a fresh page is exactly the skew worth seeing at a glance.
const html = render({ ...BRIGHTSIGN, app_version: '1.9.36', client_version: '1.1.0-web' });
const i = html.indexOf('device.info.app_version');
assert.ok(html.slice(i, i + 400).includes('1.1.0-web'), 'the page version should appear too');
// When they match there is nothing to disambiguate, so it must not be repeated.
const same = render({ ...BRIGHTSIGN, app_version: '1.9.36', client_version: '1.9.36' });
const j = same.indexOf('device.info.app_version');
const seg = same.slice(j, j + 400);
assert.equal((seg.match(/1\.9\.36/g) || []).length, 1, 'identical versions must not be shown twice');
});
test('Tizen and web players get it too, and Android is unchanged', () => {
for (const [name, dev] of [['tizen', TIZEN], ['web', WEB], ['android', ANDROID_FULL]]) {
const html = render({ ...dev, app_version: '9.9.9' });
const card = infoCard(html, 'device.info.app_version');
assert.ok(card && card.includes('9.9.9'), `${name} must show a player version`);
}
});
test('the Android-only cards stay Android-only', () => {
// Moving the version card out must not drag the APK-specific ones with it: a settings PIN and an
// Android OS version mean nothing on a BrightSign.
const bs = render({ ...BRIGHTSIGN, app_version: '1.9.36' });
assert.ok(!bs.includes('device.info.settings_pin'), 'settings PIN is an APK concept');
assert.ok(!bs.includes('device.info.android_version'), 'android_version is an APK concept');
const android = render({ ...ANDROID_FULL, app_version: '1.9.36' });
assert.ok(android.includes('device.info.settings_pin'), 'Android keeps its PIN card');
});