Add player control-plane and dashboard updates

This commit is contained in:
2026-08-07 22:35:27 +01:00
parent a4f8a807ff
commit 2bd47fb96a
36 changed files with 5081 additions and 263 deletions
+470
View File
@@ -0,0 +1,470 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerScreenCommandRoutes = require('../src/web/routes/admin/client-commands');
function createHandlers() {
const handlers = {};
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
return { app, handlers };
}
test('move client rebinding redirects the live player to the target screen', async () => {
const calls = [];
const { app, handlers } = createHandlers();
registerScreenCommandRoutes(app, {
pool: {
async query(sql, params) {
calls.push({ kind: 'query', sql, params });
if (sql.includes('SELECT slug FROM d_screens ORDER BY slug ASC')) {
return [[{ slug: 'source-screen' }, { slug: 'target-screen' }]];
}
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'source-screen') {
return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]];
}
if (sql.includes('SELECT d.client_name, s.slug AS current_screen_slug')) {
return [[{ client_name: 'Lobby Client', current_screen_slug: 'source-screen' }]];
}
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'target-screen') {
return [[{ id: 27, name: 'Target Screen', slug: 'target-screen' }]];
}
if (sql.includes('INSERT INTO d_onboarding_devices')) {
return [{ affectedRows: 1 }];
}
return [[]];
}
},
common: {},
forwardPlayerCommand(slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
return { ok: true };
},
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload, connectionId });
return { ok: true };
},
getScreenConnections: async (slug) => {
if (slug === 'source-screen') {
return {
connections: [
{
id: 'conn-1',
clientId: 'conn-1',
deviceId: 'device-123',
playerPublicBaseUrl: 'http://remote-player.example'
}
]
};
}
return { connections: [] };
},
isClientNameAvailable: async () => true,
withClientNameReservation: async (_pool, _name, callback) => callback(),
broadcastDashboardState: async () => {
calls.push({ kind: 'broadcastDashboardState' });
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const routeHandlers = handlers['/clients/:slug/commands'];
assert.equal(Array.isArray(routeHandlers), true);
const response = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
}
};
await routeHandlers[1]({
params: { slug: 'source-screen' },
body: {
command: 'moveclient',
deviceId: 'device-123',
clientName: 'Lobby Client',
targetScreenSlug: 'target-screen',
connectionId: 'conn-1',
playerBaseUrl: 'http://remote-player.example'
},
query: {}
}, response, () => {});
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.equal(response.body.targetScreenSlug, 'target-screen');
assert.equal(response.body.playerUrl, 'http://remote-player.example/screen/target-screen');
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://remote-player.example' && entry.payload && entry.payload.command === 'redirect'), true);
});
test('screen control commands can target all screens', async () => {
const calls = [];
const { app, handlers } = createHandlers();
registerScreenCommandRoutes(app, {
pool: {
async query(sql) {
calls.push({ kind: 'query', sql });
if (String(sql || '').includes('SELECT id, name, slug FROM d_screens WHERE slug IS NOT NULL ORDER BY slug ASC')) {
return [[
{ id: 1, name: 'Alpha', slug: 'alpha' },
{ id: 2, name: 'Beta', slug: 'beta' }
]];
}
return [[]];
}
},
common: {},
forwardPlayerCommand(slug, payload) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
return { ok: true };
},
getScreenConnections: async () => ({ connections: [] }),
isClientNameAvailable: async () => true,
withClientNameReservation: async (_pool, _name, callback) => callback(),
broadcastDashboardState: async () => {
calls.push({ kind: 'broadcastDashboardState' });
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const routeHandlers = handlers['/clients/:slug/commands'];
assert.equal(Array.isArray(routeHandlers), true);
const response = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
}
};
await routeHandlers[1]({
params: { slug: '__all__' },
body: {
command: 'pause',
paused: 'true'
},
query: {}
}, response, () => {});
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.equal(response.body.allScreens, true);
assert.equal(response.body.targetScreenCount, 2);
assert.deepEqual(calls.filter((entry) => entry.kind === 'forwardPlayerCommand').map((entry) => entry.slug), ['alpha', 'beta']);
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
});
test('screen control commands use the live connection player url when available', async () => {
const calls = [];
const { app, handlers } = createHandlers();
registerScreenCommandRoutes(app, {
pool: {
async query(sql, params) {
calls.push({ kind: 'query', sql, params });
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'source-screen') {
return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]];
}
return [[]];
}
},
common: {},
forwardPlayerCommand(slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
return { ok: true };
},
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload, connectionId });
return { ok: true };
},
getScreenConnections: async (slug) => {
if (slug === 'source-screen') {
return {
connections: [
{
id: 'conn-1',
clientId: 'conn-1',
deviceId: 'device-123',
playerPublicBaseUrl: 'http://local-player.example'
}
]
};
}
return { connections: [] };
},
isClientNameAvailable: async () => true,
withClientNameReservation: async (_pool, _name, callback) => callback(),
broadcastDashboardState: async () => {
calls.push({ kind: 'broadcastDashboardState' });
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const routeHandlers = handlers['/clients/:slug/commands'];
assert.equal(Array.isArray(routeHandlers), true);
const response = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
}
};
await routeHandlers[1]({
params: { slug: 'source-screen' },
body: {
command: 'pause',
connectionId: 'conn-1'
},
query: {}
}, response, () => {});
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://local-player.example'), true);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
});
test('screen control commands fan out to every live player for the selected screen', async () => {
const calls = [];
const { app, handlers } = createHandlers();
registerScreenCommandRoutes(app, {
pool: {
async query(sql, params) {
calls.push({ kind: 'query', sql, params });
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'source-screen') {
return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]];
}
return [[]];
}
},
common: {},
forwardPlayerCommand(slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
return { ok: true };
},
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload, connectionId });
return { ok: true };
},
getScreenConnections: async (slug) => {
if (slug === 'source-screen') {
return {
connections: [
{
id: 'conn-1',
clientId: 'conn-1',
deviceId: 'device-123',
playerPublicBaseUrl: 'http://local-player-a.example'
},
{
id: 'conn-2',
clientId: 'conn-2',
deviceId: 'device-456',
playerPublicBaseUrl: 'http://local-player-b.example'
}
]
};
}
return { connections: [] };
},
isClientNameAvailable: async () => true,
withClientNameReservation: async (_pool, _name, callback) => callback(),
broadcastDashboardState: async () => {
calls.push({ kind: 'broadcastDashboardState' });
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const routeHandlers = handlers['/clients/:slug/commands'];
assert.equal(Array.isArray(routeHandlers), true);
const response = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
}
};
await routeHandlers[1]({
params: { slug: 'source-screen' },
body: {
command: 'pause'
},
query: {}
}, response, () => {});
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.deepEqual(
calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl),
['http://local-player-a.example', 'http://local-player-b.example']
);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
});
test('all screens commands fan out across the live player for each screen', async () => {
const calls = [];
const { app, handlers } = createHandlers();
registerScreenCommandRoutes(app, {
pool: {
async query(sql) {
calls.push({ kind: 'query', sql });
if (String(sql || '').includes('SELECT id, name, slug FROM d_screens WHERE slug IS NOT NULL ORDER BY slug ASC')) {
return [[
{ id: 1, name: 'Alpha', slug: 'alpha' },
{ id: 2, name: 'Beta', slug: 'beta' }
]];
}
return [[]];
}
},
common: {},
forwardPlayerCommand(slug, payload) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
return { ok: true };
},
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload) {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload });
return { ok: true };
},
getScreenConnections: async (slug) => {
if (slug === 'alpha') {
return {
connections: [
{
id: 'alpha-1',
clientId: 'alpha-1',
deviceId: 'alpha-device',
playerPublicBaseUrl: 'http://player-a.example'
}
]
};
}
if (slug === 'beta') {
return {
connections: [
{
id: 'beta-1',
clientId: 'beta-1',
deviceId: 'beta-device',
playerPublicBaseUrl: 'http://player-b.example'
}
]
};
}
return { connections: [] };
},
isClientNameAvailable: async () => true,
withClientNameReservation: async (_pool, _name, callback) => callback(),
broadcastDashboardState: async () => {
calls.push({ kind: 'broadcastDashboardState' });
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const routeHandlers = handlers['/clients/:slug/commands'];
assert.equal(Array.isArray(routeHandlers), true);
const response = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
}
};
await routeHandlers[1]({
params: { slug: '__all__' },
body: {
command: 'pause'
},
query: {}
}, response, () => {});
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.equal(response.body.allScreens, true);
assert.equal(response.body.targetScreenCount, 2);
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
assert.deepEqual(
calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl),
['http://player-a.example', 'http://player-b.example']
);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
});
+111
View File
@@ -0,0 +1,111 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerClientsRoutes = require('../src/web/routes/signage/clients/routes');
const renderConnectedClientsPage = require('../src/web/routes/signage/clients/list');
function createHandlers() {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
return { app, handlers };
}
test('clients list defaults to client then ip ordering', async () => {
const { app, handlers } = createHandlers();
registerClientsRoutes(app, {
pool: {},
common: {
getSearchQuery() {
return '';
},
getSortQuery() {
return '';
},
getSortDirectionQuery() {
return 'asc';
}
},
pages: {
renderConnectedClientsPage(data) {
return JSON.stringify(data.clients.map(function (client) {
return {
client_name: client.client_name,
clientIp: client.clientIp
};
}));
}
},
buildDashboardState: async () => ({
screens: [],
clients: [
{ client_name: 'Beta', clientIp: '10.0.0.9' },
{ client_name: 'Alpha', clientIp: '10.0.0.20' },
{ client_name: 'Alpha', clientIp: '10.0.0.2' }
]
}),
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const routeHandlers = handlers['/clients'];
assert.equal(Array.isArray(routeHandlers), true);
const response = {
statusCode: 200,
body: null,
send(value) {
this.body = value;
return this;
}
};
await routeHandlers[1]({
query: {},
currentUser: { id: 1 }
}, response, () => {});
assert.deepEqual(JSON.parse(response.body), [
{ client_name: 'Alpha', clientIp: '10.0.0.2' },
{ client_name: 'Alpha', clientIp: '10.0.0.20' },
{ client_name: 'Beta', clientIp: '10.0.0.9' }
]);
});
test('clients page renders action cells for clients with permission', () => {
const html = renderConnectedClientsPage(
{
screens: [],
clients: [
{
id: 'row-1',
clientId: 'client-1',
screen_slug: 'demo-lobby',
screen_name: 'Demo Lobby Screen',
client_name: 'Local Test',
clientIp: '192.168.0.1',
player_url: 'http://player.local',
connectedAt: new Date('2026-08-07T20:00:00Z'),
connectedAtLabel: 'Aug 07, 2026, 8:00:00 PM'
}
],
pagination: null
},
'',
{ id: 1, permissions: ['clients.read', 'clients.allow'] }
);
assert.match(html, /data-label="Actions"/);
assert.match(html, /Pause client/);
assert.match(html, /Blackout client/);
});
+126
View File
@@ -0,0 +1,126 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const { createDashboardStateService } = require('../src/web/lib/dashboard-state');
test('dashboard state counts active players from the registry heartbeat', async () => {
const service = createDashboardStateService({
pool: {
async query(sql) {
if (String(sql || '').includes('COUNT(*) AS connected_count')) {
return [[{ connected_count: 6 }]];
}
if (String(sql || '').includes('SELECT s.slug, pod.device_id, pod.client_name')) {
return [[]];
}
return [[]];
}
},
common: {
async fetchAdminData() {
return {
screens: [
{
slug: 'alpha',
name: 'Alpha',
playlist_name: 'Main Loop'
}
],
playlists: [],
slides: []
};
},
async fetchScreenPlayerUrls() {
return {
alpha: 'http://player.local/screen/alpha'
};
},
async fetchPlayerRegistrations() {
return [
{
identifier: 'player-alpha',
public_base_url: 'http://player.local',
last_seen_at: new Date(Date.now() - 10 * 1000)
},
{
identifier: 'player-stale',
public_base_url: 'http://stale.player.local',
last_seen_at: new Date(Date.now() - 10 * 60 * 1000)
}
];
}
},
playerSnapshotCache: new Map([
['alpha', { count: 1, connections: [{ id: 1, playerPublicBaseUrl: 'http://player.local' }] }]
]),
playerSnapshotSockets: new Map(),
ensurePlayerSnapshotSubscription() {},
formatDashboardDate(value) {
return String(value || '');
}
});
const state = await service.buildDashboardState();
assert.equal(state.connectedPlayersCount, 6);
assert.equal(state.connectedClientsCount, 1);
assert.equal(state.clients[0].player_identifier, 'player-alpha');
assert.equal(state.kioskPlayers.length, 1);
assert.deepEqual(state.kioskPlayers[0], {
player_identifier: 'player-alpha',
player_url: 'http://player.local'
});
});
test('dashboard state keeps other screens when one subscription fails', async () => {
const service = createDashboardStateService({
pool: {
async query(sql) {
if (String(sql || '').includes('COUNT(*) AS connected_count')) {
return [[{ connected_count: 0 }]];
}
if (String(sql || '').includes('SELECT s.slug, pod.device_id, pod.client_name')) {
return [[]];
}
return [[]];
}
},
common: {
async fetchAdminData() {
return {
screens: [
{ slug: 'alpha', name: 'Alpha' },
{ slug: 'beta', name: 'Beta' }
],
playlists: [],
slides: []
};
},
async fetchScreenPlayerUrls() {
return {};
},
async fetchPlayerRegistrations() {
return [];
}
},
playerSnapshotCache: new Map([
['beta', { count: 1, connections: [{ id: 'beta-conn', clientId: 'beta-conn' }] }]
]),
playerSnapshotSockets: new Map(),
ensurePlayerSnapshotSubscription(slug) {
if (slug === 'alpha') {
throw new Error('subscription failed');
}
},
formatDashboardDate(value) {
return String(value || '');
}
});
const state = await service.buildDashboardState();
assert.equal(state.screens.length, 2);
assert.equal(state.clients.length, 1);
assert.equal(state.clients[0].screen_slug, 'beta');
});
+134
View File
@@ -0,0 +1,134 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const { createPlayerActionService } = require('../src/web/lib/player-actions');
test('player actions prefer the exact configured player registration over a remote FQDN row', async () => {
const fetchCalls = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
fetchCalls.push({ url, init });
return {
ok: true,
status: 200,
headers: {
get() {
return null;
}
},
async json() {
return { ok: true };
},
async text() {
return JSON.stringify({ ok: true });
}
};
};
const playerActionService = createPlayerActionService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-local',
internal_base_url: 'http://player:8081'
},
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com'
}
]];
}
}
});
const originalIdentifier = process.env.PLAYER_IDENTIFIER;
process.env.PLAYER_IDENTIFIER = 'player-local';
try {
const response = await playerActionService.forwardPlayerCommand('demo', { command: 'refresh' });
assert.deepEqual(response, { ok: true });
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'http://player:8081/api/screens/demo/commands');
} finally {
process.env.PLAYER_IDENTIFIER = originalIdentifier;
global.fetch = originalFetch;
}
});
test('player actions merge screen connections from every recent player registration', async () => {
const fetchCalls = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
fetchCalls.push({ url, init });
if (String(url).includes('player-a.example')) {
return {
ok: true,
status: 200,
headers: { get() { return null; } },
async json() {
return {
screenSlug: 'demo',
connections: [
{ id: 'a-1', playerPublicBaseUrl: 'http://player-a.example' }
]
};
},
async text() {
return JSON.stringify({ ok: true });
}
};
}
return {
ok: true,
status: 200,
headers: { get() { return null; } },
async json() {
return {
screenSlug: 'demo',
connections: [
{ id: 'b-1', playerPublicBaseUrl: 'http://player-b.example' }
]
};
},
async text() {
return JSON.stringify({ ok: true });
}
};
};
const playerActionService = createPlayerActionService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-a',
public_base_url: 'http://player-a.example',
last_seen_at: new Date().toISOString()
},
{
identifier: 'player-b',
public_base_url: 'http://player-b.example',
last_seen_at: new Date().toISOString()
}
]];
}
}
});
try {
const response = await playerActionService.getScreenConnections('demo');
assert.equal(response.count, 2);
assert.deepEqual(response.connections.map(function (connection) { return connection.id; }), ['a-1', 'b-1']);
assert.equal(fetchCalls.length, 2);
} finally {
global.fetch = originalFetch;
}
});
+63
View File
@@ -0,0 +1,63 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const originalWebBaseUrl = process.env.WEB_BASE_URL;
const { resolveWebBaseUrl, resolveScreenCommandTargets } = require('../src/player-bridge/index');
test.after(() => {
if (originalWebBaseUrl === undefined) {
delete process.env.WEB_BASE_URL;
} else {
process.env.WEB_BASE_URL = originalWebBaseUrl;
}
});
test('resolveWebBaseUrl prefers WEB_BASE_URL', () => {
process.env.WEB_BASE_URL = 'https://web.example.test/app/';
const resolved = resolveWebBaseUrl({
headers: {
host: 'bridge.example.test',
'x-forwarded-host': 'bridge.example.test',
'x-forwarded-proto': 'https'
},
socket: { encrypted: true }
});
assert.equal(resolved, 'https://web.example.test/app');
});
test('resolveWebBaseUrl keeps external https hosts on the default port', () => {
delete process.env.WEB_BASE_URL;
const resolved = resolveWebBaseUrl({
headers: {
host: 'bridge.example.test',
'x-forwarded-host': 'bridge.example.test',
'x-forwarded-proto': 'https'
},
socket: { encrypted: true }
});
assert.equal(resolved, 'https://bridge.example.test');
});
test('resolveScreenCommandTargets only returns open sockets for the requested screen', () => {
const playerSockets = new Map([
['player-a', { readyState: 1 }],
['player-b', { readyState: 3 }],
['player-c', { readyState: 1 }]
]);
const screenPlayerDeviceIds = new Map([
['demo-conference', ['player-a', 'player-b', 'player-a', 'player-missing']],
['other-screen', ['player-c']]
]);
const targets = resolveScreenCommandTargets('demo-conference', playerSockets, screenPlayerDeviceIds);
assert.deepEqual(targets.map(function (target) {
return target.deviceId;
}), ['player-a']);
});
+3 -2
View File
@@ -56,7 +56,7 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
id: 101,
title: 'Intro',
template_id: 33,
content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"textRegion":{"type":"text","value":"Hello"},"qrRegion":{"type":"qr-code","value":"https://example.com"}}',
content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"backupVideo":{"type":"video","duration_seconds":20.1111},"textRegion":{"type":"text","value":"Hello"},"qrRegion":{"type":"qr-code","value":"https://example.com"}}',
modified_at: '2026-08-03T00:00:01.000Z',
position: 1,
duration_seconds: 9,
@@ -123,9 +123,10 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
assert.equal(payload.screen.slug, 'test2');
assert.equal(payload.playlist.name, 'Playlist 22');
assert.equal(payload.slides[0].duration_seconds, 12.346);
assert.equal(payload.slides[0].duration_seconds, 20.111);
assert.equal(payload.slides[0].disable_audio, true);
assert.equal(payload.slides[0].content.videoRegion.disable_audio, true);
assert.equal(payload.slides[0].content.backupVideo.disable_audio, true);
assert.equal(payload.slides[0].content.videoRegion.cache_bust, '2026-08-03T00:00:01.000Z');
assert.equal(payload.slides[1].disable_audio, true);
assert.equal(payload.slides[1].content.rtmpRegion.disable_audio, true);
+29
View File
@@ -54,4 +54,33 @@ test('playlist rows show mute controls for RTMP slides', () => {
assert.equal(model.playlistSlides[0].showMuteButton, true);
assert.equal(model.playlistSlides[1].showMuteButton, true);
assert.equal(model.playlistSlides[2].showMuteButton, false);
});
test('playlist rows use the longest video region duration', () => {
const model = buildPlaylistFormViewModel(
{ id: 23, name: 'Playlist 23' },
{
playlistSlides: [
{
id: 4,
playlist_id: 23,
slide_id: 104,
position: 1,
title: 'Multi-video slide',
content_json: '{"firstVideo":{"type":"video","value":"/media/first.mp4","duration_seconds":12.5},"secondVideo":{"type":"video","value":"/media/second.mp4","duration_seconds":27.75},"textRegion":{"type":"text","value":"Hello"}}',
duration_seconds: 10,
use_video_duration: 1,
disable_audio: 1
}
],
slides: []
},
'',
null,
{ isEdit: true }
);
assert.equal(model.playlistSlides[0].showVideoDurationButton, true);
assert.equal(model.playlistSlides[0].videoDurationSeconds, 27.75);
assert.equal(model.playlistSlides[0].durationSeconds, 27.75);
});
+133
View File
@@ -0,0 +1,133 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
test('table pagination cards skip the minimum height for short content', () => {
const cardStyle = {
properties: {},
setProperty(name, value) {
this.properties[name] = String(value);
},
removeProperty(name) {
delete this.properties[name];
}
};
const card = {
style: cardStyle,
scrollHeight: 200,
getBoundingClientRect() {
return { top: 100 };
}
};
const bodyClassList = {
toggled: null,
toggle(name, value) {
this.toggled = { name, value };
}
};
const sandbox = {
document: {
body: {
classList: bodyClassList
},
querySelector(selector) {
return selector === '[data-table-pagination-card]' ? card : null;
},
querySelectorAll(selector) {
return selector === '[data-table-pagination-card]' ? [card] : [];
}
},
window: null,
URL,
console,
setTimeout,
clearTimeout
};
sandbox.window = Object.assign(sandbox, {
innerHeight: 280,
addEventListener() {},
clearTimeout,
setTimeout,
requestAnimationFrame(callback) {
callback();
}
});
const scriptPath = path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'table', 'table-search.js');
const script = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(script, sandbox, { filename: scriptPath });
assert.equal(cardStyle.properties['--table-pagination-card-min-height'], undefined);
assert.equal(cardStyle.properties['--table-pagination-card-max-height'], '320px');
assert.deepEqual(bodyClassList.toggled, { name: 'table-pagination-page', value: true });
});
test('table pagination cards keep the minimum height for tall content', () => {
const cardStyle = {
properties: {},
setProperty(name, value) {
this.properties[name] = String(value);
},
removeProperty(name) {
delete this.properties[name];
}
};
const card = {
style: cardStyle,
scrollHeight: 640,
getBoundingClientRect() {
return { top: 100 };
}
};
const bodyClassList = {
toggled: null,
toggle(name, value) {
this.toggled = { name, value };
}
};
const sandbox = {
document: {
body: {
classList: bodyClassList
},
querySelector(selector) {
return selector === '[data-table-pagination-card]' ? card : null;
},
querySelectorAll(selector) {
return selector === '[data-table-pagination-card]' ? [card] : [];
}
},
window: null,
URL,
console,
setTimeout,
clearTimeout
};
sandbox.window = Object.assign(sandbox, {
innerHeight: 280,
addEventListener() {},
clearTimeout,
setTimeout,
requestAnimationFrame(callback) {
callback();
}
});
const scriptPath = path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'table', 'table-search.js');
const script = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(script, sandbox, { filename: scriptPath });
assert.equal(cardStyle.properties['--table-pagination-card-min-height'], '320px');
assert.equal(cardStyle.properties['--table-pagination-card-max-height'], '320px');
assert.deepEqual(bodyClassList.toggled, { name: 'table-pagination-page', value: true });
});
+229
View File
@@ -45,4 +45,233 @@ test('multipart uploads accept large text fields used by slide and template form
await new Promise((resolve) => server.close(resolve));
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('fqdn player registration wins over a local configured player target for media sync', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
const activeLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const fetchCalls = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
fetchCalls.push({ url, init });
return {
ok: true,
status: 200,
statusText: 'OK',
headers: {
get() {
return null;
}
},
async text() {
return JSON.stringify({ ok: true });
}
};
};
const uploadSyncService = createUploadSyncService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-local',
internal_base_url: 'http://player:8081',
last_seen_at: activeLastSeenAt
},
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com',
last_seen_at: activeLastSeenAt
}
]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
try {
const success = await uploadSyncService.pushUploadFileToPlayer('/media/uploads/sample.bin', uploadDir);
assert.equal(success, true);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin');
} finally {
global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('media sync retry warnings include the resolved player label', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-log-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
const activeLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const originalFetch = global.fetch;
const originalWarn = console.warn;
const warned = [];
console.warn = function () {
warned.push(Array.from(arguments).join(' '));
};
global.fetch = async function () {
const error = new Error('getaddrinfo ENOTFOUND player-remote');
error.code = 'ENOTFOUND';
throw error;
};
const uploadSyncService = createUploadSyncService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com',
last_seen_at: activeLastSeenAt
}
]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
try {
await uploadSyncService.syncUploadRefsToPlayer(['/media/uploads/sample.bin'], uploadDir);
await uploadSyncService.flushPendingPlayerUploadSyncs();
assert.ok(warned.some(function (message) {
return message.includes('[media-sync] Player unavailable, retry queued for 1 upload for player-remote');
}));
} finally {
global.fetch = originalFetch;
console.warn = originalWarn;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('media sync treats 503 responses as unavailable without logging a per-upload warning', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-503-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
const activeLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const originalFetch = global.fetch;
const originalWarn = console.warn;
const warned = [];
console.warn = function () {
warned.push(Array.from(arguments).join(' '));
};
global.fetch = async function () {
return {
ok: false,
status: 503,
statusText: 'Service Unavailable',
headers: {
get() {
return null;
}
},
async text() {
return 'Service Unavailable';
}
};
};
const uploadSyncService = createUploadSyncService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com',
last_seen_at: activeLastSeenAt
}
]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
try {
await uploadSyncService.syncUploadRefsToPlayer(['/media/uploads/sample.bin'], uploadDir);
await uploadSyncService.flushPendingPlayerUploadSyncs();
assert.ok(warned.some(function (message) {
return message.includes('[media-sync] Player unavailable, retry queued for 1 upload for player-remote');
}));
assert.ok(warned.every(function (message) {
return !message.includes('Unable to sync upload to player: sample.bin 503 Service Unavailable');
}));
} finally {
global.fetch = originalFetch;
console.warn = originalWarn;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('stale player registrations stop media sync retries and warnings', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-stale-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
const originalFetch = global.fetch;
const originalWarn = console.warn;
const fetchCalls = [];
const warned = [];
console.warn = function () {
warned.push(Array.from(arguments).join(' '));
};
global.fetch = async function (url, init) {
fetchCalls.push({ url, init });
return {
ok: false,
status: 503,
statusText: 'Service Unavailable',
headers: {
get() {
return null;
}
},
async text() {
return 'Service Unavailable';
}
};
};
const uploadSyncService = createUploadSyncService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com',
last_seen_at: new Date(Date.now() - 5 * 60 * 1000).toISOString()
}
]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
try {
await uploadSyncService.syncUploadRefsToPlayer(['/media/uploads/sample.bin'], uploadDir);
await uploadSyncService.flushPendingPlayerUploadSyncs();
assert.equal(fetchCalls.length, 0);
assert.equal(warned.length, 0);
} finally {
global.fetch = originalFetch;
console.warn = originalWarn;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
+87
View File
@@ -0,0 +1,87 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const { createWebBootstrap } = require('../src/web/bootstrap');
test('dashboard websocket broadcasts keep the last successful snapshot when refreshes fail', async () => {
let adminDataCalls = 0;
const bootstrap = createWebBootstrap({
pool: {
async query(sql) {
if (String(sql || '').includes('COUNT(*) AS connected_count')) {
return [[{ connected_count: 1 }]];
}
if (String(sql || '').includes('SELECT s.slug, pod.device_id, pod.client_name')) {
return [[]];
}
return [[]];
}
},
common: {
async fetchAdminData() {
adminDataCalls += 1;
if (adminDataCalls === 1) {
return {
screens: [
{
slug: 'alpha',
name: 'Alpha',
playlist_name: 'Main Loop'
}
],
playlists: [],
slides: []
};
}
throw new Error('dashboard refresh failed');
},
async fetchScreenPlayerUrls() {
return {
alpha: 'http://player.local/screen/alpha'
};
},
async fetchPlayerRegistrations() {
return [
{
identifier: 'player-alpha',
public_base_url: 'http://player.local',
last_seen_at: new Date()
}
];
}
},
playerSnapshotCache: new Map([
['alpha', {
count: 1,
connections: [
{
id: 'conn-1',
playerPublicBaseUrl: 'http://player.local'
}
]
}]
]),
playerSnapshotSockets: new Map(),
ensurePlayerSnapshotSubscription() {},
formatDashboardDate(value) {
return String(value || '');
},
notifyPlayerScreens() {},
backgroundTaskQueue: null,
uploadDir: 'media/uploads',
playerInternalBaseUrl: 'http://player.local'
});
const firstState = await bootstrap.broadcastDashboardState();
const secondState = await bootstrap.broadcastDashboardState();
assert.equal(firstState.screens[0].slug, 'alpha');
assert.equal(firstState.clients.length, 1);
assert.equal(secondState.screens[0].slug, 'alpha');
assert.equal(secondState.clients.length, 1);
assert.equal(adminDataCalls, 2);
});
+727 -3
View File
@@ -1,5 +1,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
require('../src/common');
@@ -16,12 +18,734 @@ test('dashboard onboarding link uses the player base url', () => {
playlists: [],
clients: [],
slides: [],
connectedClientsCount: 0
connectedClientsCount: 0,
connectedPlayersCount: 0
},
'',
null
{ id: 1, permissions: ['screens.read', 'clients.read'] }
);
assert.match(html, /href="http:\/\/player\.local"/);
assert.doesNotMatch(html, /http:\/\/player\.local\//);
});
});
test('dashboard screen snapshot omits player links and duplicate connection counts', () => {
const html = renderDashboardPage(
{
screens: [
{
id: 7,
name: 'Demo Conference',
slug: 'demo-conference',
playlist_name: 'Main Loop',
player_connection_count: 3,
public_base_url: 'http://player.local/'
}
],
playlists: [],
clients: [],
slides: [],
connectedClientsCount: 3,
connectedPlayersCount: 3
},
'',
{ id: 1, permissions: ['screens.read', 'clients.read'] }
);
assert.match(html, /Screen group snapshot/);
assert.match(html, /players/);
assert.doesNotMatch(html, /dashboard-screen-link/);
assert.doesNotMatch(html, /Connections<\/dt>/);
assert.match(html, /3\s+live/);
assert.doesNotMatch(html, />3 connected/);
});
test('dashboard kiosk launcher includes connected player choices', () => {
const html = renderDashboardPage(
{
screens: [],
playlists: [],
kioskPlayers: [
{
player_identifier: 'player-alpha',
player_url: 'http://player-a.example'
},
{
player_identifier: 'player-beta',
player_url: 'http://player-b.example'
}
],
slides: [],
connectedClientsCount: 1,
connectedPlayersCount: 1
},
'',
{ id: 1, permissions: ['screens.read', 'clients.read', 'dashboard.allow'] }
);
assert.match(html, /dashboard-kiosk-launcher-player-select/);
assert.match(html, /<option value="http:\/\/player-a\.example" data-player-identifier="player-alpha">player-alpha - http:\/\/player-a\.example<\/option>/);
assert.match(html, /<option value="http:\/\/player-b\.example" data-player-identifier="player-beta">player-beta - http:\/\/player-b\.example<\/option>/);
assert.match(html, /data-kiosk-launcher-download-base="\/downloads\/kiosk\/pulse-signage-kiosk\.bat"/);
});
test('dashboard kiosk launcher requires both confirmation and a player selection', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
const downloadLink = {
classList: {
classes: new Set(['disabled']),
add(name) { this.classes.add(name); },
remove(name) { this.classes.delete(name); },
contains(name) { return this.classes.has(name); }
},
attributes: {
href: '',
'data-kiosk-launcher-download-base': '/downloads/kiosk/pulse-signage-kiosk.bat',
'aria-disabled': 'true',
tabindex: '-1'
},
setAttribute(name, value) {
this.attributes[name] = String(value);
},
getAttribute(name) {
return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : '';
},
removeAttribute(name) {
delete this.attributes[name];
}
};
const checkbox = {
checked: false,
listeners: {},
addEventListener(type, handler) {
this.listeners[type] = handler;
}
};
const select = {
value: '',
options: [
{ value: '', textContent: 'Select a player' },
{ value: 'http://player-a.example', textContent: 'player-alpha' }
],
listeners: {},
innerHTML: '',
addEventListener(type, handler) {
this.listeners[type] = handler;
}
};
const modal = {
querySelectorAll(selector) {
return selector === '[data-kiosk-launcher-download]' ? [downloadLink] : [];
},
querySelector(selector) {
if (selector === '[data-kiosk-launcher-confirm]') {
return checkbox;
}
if (selector === '[data-kiosk-launcher-player-select]') {
return select;
}
return null;
},
addEventListener(type, handler) {
this.listeners = this.listeners || {};
this.listeners[type] = handler;
}
};
const context = {
document: {
getElementById(id) {
if (id === 'dashboard-kiosk-launcher-modal') {
return modal;
}
return null;
},
querySelector() {
return null;
}
},
window: {
webUiHelpers: {
escapeHtml(value) { return String(value); },
formatDashboardDate(value) { return String(value); },
getClientRowKey() { return ''; },
getClientDisplayName() { return ''; },
setButtonVariant() {},
normalizeDisplayIp(value) { return String(value); }
},
WebSocket: null,
location: {
protocol: 'http:',
host: 'example.test'
},
setTimeout() { return 1; },
clearTimeout() {},
alert() {},
prompt() {
return null;
},
webHandleDashboardState() {}
},
WebSocket: function MockWebSocket() {},
JSON: JSON,
Number: Number,
String: String,
Boolean: Boolean,
Array: Array,
Object: Object,
Math: Math,
Set: Set,
URLSearchParams: URLSearchParams,
FormData: function FormData() {},
setTimeout() {},
clearTimeout() {},
console: console
};
context.window.document = context.document;
context.window.WebSocket = context.WebSocket;
vm.runInNewContext(script, context);
assert.equal(downloadLink.classList.contains('disabled'), true);
assert.equal(Object.prototype.hasOwnProperty.call(downloadLink.attributes, 'href'), false);
checkbox.checked = true;
checkbox.listeners.change();
assert.equal(downloadLink.classList.contains('disabled'), true);
assert.equal(Object.prototype.hasOwnProperty.call(downloadLink.attributes, 'href'), false);
select.value = 'http://player-a.example';
select.listeners.change();
assert.equal(downloadLink.classList.contains('disabled'), false);
assert.equal(downloadLink.attributes.href, '/downloads/kiosk/pulse-signage-kiosk.bat?playerUrl=http%3A%2F%2Fplayer-a.example');
});
test('screen controls include an all screens option and update the target summary', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
const select = {
value: '__all__',
selectedIndex: 2,
options: [
{ value: 'alpha', textContent: 'Alpha', getAttribute() { return null; } },
{ value: 'beta', textContent: 'Beta', getAttribute() { return null; } },
{ value: '__all__', textContent: 'All screens', getAttribute(name) { return name === 'data-screen-target-all' ? 'true' : null; } }
],
listeners: {},
addEventListener(type, handler) {
this.listeners[type] = handler;
}
};
const commandInput = { value: '' };
const pausedInput = { value: 'true' };
const button = {
innerHTML: '',
disabled: false
};
const form = {
dataset: {},
getAttribute(name) {
if (name === 'data-screen-command-action') {
return 'pause';
}
return this[name] || '';
},
querySelector(selector) {
if (selector === 'input[name="command"]') {
return commandInput;
}
if (selector === 'input[name="paused"]') {
return pausedInput;
}
if (selector === 'button[type="submit"]') {
return button;
}
return null;
},
querySelectorAll() {
return [button, commandInput, pausedInput];
},
setAttribute(name, value) {
this[name] = value;
},
action: ''
};
const pill = { classList: { toggle() {}, add() {}, remove() {} }, textContent: '' };
const nameNode = { textContent: '' };
const metaNode = { textContent: '' };
const context = {
document: {
getElementById(id) {
if (id === 'screen-command-select') {
return select;
}
return null;
},
querySelector(selector) {
if (selector === '[data-screen-command-pill]') {
return pill;
}
if (selector === '[data-screen-command-name]') {
return nameNode;
}
if (selector === '[data-screen-command-meta]') {
return metaNode;
}
return null;
},
querySelectorAll(selector) {
return selector === '[data-screen-command-form]' ? [form] : [];
}
},
window: {
webUiHelpers: {
escapeHtml(value) { return String(value); },
formatDashboardDate(value) { return String(value); },
getClientRowKey() { return ''; },
getClientDisplayName() { return ''; },
setButtonVariant() {},
normalizeDisplayIp(value) { return String(value); }
},
WebSocket: null,
location: {
protocol: 'http:',
host: 'example.test'
},
setTimeout() { return 1; },
clearTimeout() {},
alert() {},
prompt() {
return null;
},
webHandleDashboardState() {}
},
WebSocket: function MockWebSocket() {},
JSON: JSON,
Number: Number,
String: String,
Boolean: Boolean,
Array: Array,
Object: Object,
Math: Math,
Set: Set,
URLSearchParams: URLSearchParams,
FormData: function FormData() {},
setTimeout() {},
clearTimeout() {},
console: console
};
context.window.document = context.document;
context.window.WebSocket = context.WebSocket;
vm.runInNewContext(script, context);
context.window.webHandleDashboardState({
screens: [
{ slug: 'alpha', name: 'Alpha' },
{ slug: 'beta', name: 'Beta' }
],
clients: [
{ screen_slug: 'alpha', paused: true },
{ screen_slug: 'beta', paused: true }
]
});
assert.equal(form.action, '/clients/__all__/commands');
assert.equal(nameNode.textContent, 'All screens');
assert.equal(metaNode.textContent, 'Commands sent here target every client across every screen group.');
assert.match(button.innerHTML, /Resume all screens/);
assert.equal(commandInput.value, 'pause');
assert.equal(pausedInput.value, 'false');
});
test('dashboard client refresh respects the active search filter', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
const tbody = {
innerHTML: '<tr><td>stale</td></tr>',
addEventListener() {}
};
const table = {
getAttribute(name) {
if (name === 'data-has-actions-column') {
return 'false';
}
return '';
}
};
const context = {
document: {
body: {
classList: {
toggle() {},
add() {},
remove() {}
}
},
getElementById(id) {
if (id === 'dashboard-clients-table') {
return table;
}
if (id === 'dashboard-clients-table-body') {
return tbody;
}
return null;
},
querySelector() {
return null;
},
querySelectorAll() {
return [];
}
},
window: {
location: {
search: '?search=beta',
protocol: 'http:',
host: 'example.test'
},
webUiHelpers: {
escapeHtml(value) { return String(value); },
formatDashboardDate(value) { return String(value); },
getClientRowKey(client) { return String(client && client.id || ''); },
getClientDisplayName(client) { return String(client && (client.client_name || client.name || client.clientId) || ''); },
setButtonVariant() {},
normalizeDisplayIp(value) { return String(value); }
},
WebSocket: null,
setTimeout() { return 1; },
clearTimeout() {},
alert() {},
prompt() {
return null;
},
webHandleDashboardState() {}
},
WebSocket: function MockWebSocket() {},
JSON: JSON,
Number: Number,
String: String,
Boolean: Boolean,
Array: Array,
Object: Object,
Math: Math,
Set: Set,
URLSearchParams: URLSearchParams,
FormData: function FormData() {},
setTimeout() {},
clearTimeout() {},
console: console
};
context.window.document = context.document;
context.window.WebSocket = context.WebSocket;
vm.runInNewContext(script, context);
context.window.webHandleDashboardState({
clients: [
{ id: 'alpha', client_name: 'Alpha', clientId: 'alpha', screen_slug: 'alpha', clientIp: '10.0.0.1' },
{ id: 'beta', client_name: 'Beta', clientId: 'beta', screen_slug: 'beta', clientIp: '10.0.0.2' }
]
});
assert.match(tbody.innerHTML, /Beta/);
assert.doesNotMatch(tbody.innerHTML, /Alpha/);
});
test('dashboard client refresh respects the live search input before the URL updates', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
const searchInput = {
value: 'beta'
};
const tbody = {
innerHTML: '<tr><td>stale</td></tr>',
addEventListener() {}
};
const table = {
getAttribute(name) {
if (name === 'data-has-actions-column') {
return 'false';
}
return '';
}
};
const context = {
document: {
body: {
classList: {
toggle() {},
add() {},
remove() {}
}
},
getElementById(id) {
if (id === 'dashboard-clients-table') {
return table;
}
if (id === 'dashboard-clients-table-body') {
return tbody;
}
return null;
},
querySelector(selector) {
if (selector === '[data-table-search]') {
return searchInput;
}
return null;
},
querySelectorAll() {
return [];
}
},
window: {
location: {
search: '',
protocol: 'http:',
host: 'example.test'
},
webUiHelpers: {
escapeHtml(value) { return String(value); },
formatDashboardDate(value) { return String(value); },
getClientRowKey(client) { return String(client && client.id || ''); },
getClientDisplayName(client) { return String(client && (client.client_name || client.name || client.clientId) || ''); },
setButtonVariant() {},
normalizeDisplayIp(value) { return String(value); }
},
WebSocket: null,
setTimeout() { return 1; },
clearTimeout() {},
alert() {},
prompt() {
return null;
},
webHandleDashboardState() {}
},
WebSocket: function MockWebSocket() {},
JSON: JSON,
Number: Number,
String: String,
Boolean: Boolean,
Array: Array,
Object: Object,
Math: Math,
Set: Set,
URLSearchParams: URLSearchParams,
FormData: function FormData() {},
setTimeout() {},
clearTimeout() {},
console: console
};
context.window.document = context.document;
context.window.WebSocket = context.WebSocket;
vm.runInNewContext(script, context);
context.window.webHandleDashboardState({
clients: [
{ id: 'alpha', client_name: 'Alpha', clientId: 'alpha', screen_slug: 'alpha', clientIp: '10.0.0.1' },
{ id: 'beta', client_name: 'Beta', clientId: 'beta', screen_slug: 'beta', clientIp: '10.0.0.2' }
]
});
assert.match(tbody.innerHTML, /Beta/);
assert.doesNotMatch(tbody.innerHTML, /Alpha/);
});
test('dashboard client refresh does not rerender while a table search is loading', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
const tbody = {
innerHTML: '<tr><td>stale</td></tr>',
addEventListener() {}
};
const table = {
getAttribute(name) {
if (name === 'data-has-actions-column') {
return 'false';
}
return '';
}
};
const loadingMarker = {};
const context = {
document: {
body: {
classList: {
toggle() {},
add() {},
remove() {}
}
},
getElementById(id) {
if (id === 'dashboard-clients-table') {
return table;
}
if (id === 'dashboard-clients-table-body') {
return tbody;
}
return null;
},
querySelector(selector) {
if (selector === '[data-table-search-loading="true"]') {
return loadingMarker;
}
if (selector === '[data-table-search]') {
return { value: 'beta' };
}
return null;
},
querySelectorAll() {
return [];
}
},
window: {
location: {
search: '?search=beta',
protocol: 'http:',
host: 'example.test'
},
webUiHelpers: {
escapeHtml(value) { return String(value); },
formatDashboardDate(value) { return String(value); },
getClientRowKey(client) { return String(client && client.id || ''); },
getClientDisplayName(client) { return String(client && (client.client_name || client.name || client.clientId) || ''); },
setButtonVariant() {},
normalizeDisplayIp(value) { return String(value); }
},
WebSocket: null,
setTimeout() { return 1; },
clearTimeout() {},
alert() {},
prompt() {
return null;
},
webHandleDashboardState() {}
},
WebSocket: function MockWebSocket() {},
JSON: JSON,
Number: Number,
String: String,
Boolean: Boolean,
Array: Array,
Object: Object,
Math: Math,
Set: Set,
URLSearchParams: URLSearchParams,
FormData: function FormData() {},
setTimeout() {},
clearTimeout() {},
console: console
};
context.window.document = context.document;
context.window.WebSocket = context.WebSocket;
vm.runInNewContext(script, context);
context.window.webHandleDashboardState({
clients: [
{ id: 'alpha', client_name: 'Alpha', clientId: 'alpha', screen_slug: 'alpha', clientIp: '10.0.0.1' },
{ id: 'beta', client_name: 'Beta', clientId: 'beta', screen_slug: 'beta', clientIp: '10.0.0.2' }
]
});
assert.equal(tbody.innerHTML, '<tr><td>stale</td></tr>');
});
test('dashboard client table can rehydrate actions after a search swap', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
const tbody = {
innerHTML: '<tr><td>stale</td></tr>',
addEventListener() {}
};
const table = {
getAttribute(name) {
if (name === 'data-has-actions-column') {
return 'true';
}
return '';
}
};
const context = {
document: {
body: {
classList: {
toggle() {},
add() {},
remove() {}
}
},
getElementById(id) {
if (id === 'dashboard-clients-table') {
return table;
}
if (id === 'dashboard-clients-table-body') {
return tbody;
}
return null;
},
querySelector(selector) {
if (selector === '[data-table-search]') {
return { value: '48' };
}
return null;
},
querySelectorAll() {
return [];
}
},
window: {
location: {
search: '?search=48',
protocol: 'http:',
host: 'example.test'
},
webUiHelpers: {
escapeHtml(value) { return String(value); },
formatDashboardDate(value) { return String(value); },
getClientRowKey(client) { return String(client && client.id || ''); },
getClientDisplayName(client) { return String(client && (client.client_name || client.name || client.clientId) || ''); },
setButtonVariant() {},
normalizeDisplayIp(value) { return String(value); }
},
WebSocket: null,
setTimeout() { return 1; },
clearTimeout() {},
requestAnimationFrame(callback) {
callback();
},
alert() {},
prompt() {
return null;
},
webHandleDashboardState() {}
},
WebSocket: function MockWebSocket() {},
JSON: JSON,
Number: Number,
String: String,
Boolean: Boolean,
Array: Array,
Object: Object,
Math: Math,
Set: Set,
URLSearchParams: URLSearchParams,
FormData: function FormData() {},
setTimeout() {},
clearTimeout() {},
console: console
};
context.window.document = context.document;
context.window.WebSocket = context.WebSocket;
vm.runInNewContext(script, context);
context.window.webHandleDashboardState({
clients: [
{ id: '48f94e8d-a307-45f8-99a5-dafd3f0e2282', client_name: 'Beta', clientId: '48f94e8d-a307-45f8-99a5-dafd3f0e2282', screen_slug: 'beta', clientIp: '10.0.0.2' },
{ id: 'alpha', client_name: 'Alpha', clientId: 'alpha', screen_slug: 'alpha', clientIp: '10.0.0.1' }
]
});
tbody.innerHTML = '<tr data-client-key="48f94e8d-a307-45f8-99a5-dafd3f0e2282"><td data-label="Client"><div>Beta</div></td></tr>';
context.window.webRefreshClientTableFromLatestState();
assert.match(tbody.innerHTML, /data-label="Actions"/);
assert.match(tbody.innerHTML, /Pause/);
assert.match(tbody.innerHTML, /Blackout/);
});
+280 -5
View File
@@ -4,6 +4,7 @@ const assert = require('node:assert/strict');
require('../src/common');
const registerScreensRoutes = require('../src/web/routes/signage/screens/routes');
const { renderScreenEditPage } = require('../src/web/pages');
test('screen edit page includes shared launcher downloads and base player url', async () => {
const handlers = {};
@@ -34,10 +35,11 @@ test('screen edit page includes shared launcher downloads and base player url',
async fetchScreenEditData() {
return { playlists: [] };
},
async fetchScreenPlayerUrls() {
return {
'demo-conference': 'http://player.example/screen/demo-conference'
};
async fetchPlayerRegistrations() {
return [
{ identifier: 'player-alpha', public_base_url: 'http://alpha.example', last_seen_at: new Date().toISOString() },
{ identifier: 'player-beta', public_base_url: 'http://beta.example/', last_seen_at: new Date().toISOString() }
];
},
async fetchPlayerPublicBaseUrl() {
return 'http://player.example';
@@ -71,13 +73,218 @@ test('screen edit page includes shared launcher downloads and base player url',
await handler[1]({ params: { id: '7' }, query: {}, currentUser: { id: 1 } }, res, () => {});
assert.equal(res.body, 'ok');
assert.deepEqual(renderedArgs.screen.player_url, 'http://player.example/screen/demo-conference');
assert.deepEqual(renderedArgs.screen.player_urls, [
{
identifier: 'player-alpha',
public_base_url: 'http://alpha.example',
player_url: 'http://alpha.example/screen/demo-conference'
},
{
identifier: 'player-beta',
public_base_url: 'http://beta.example',
player_url: 'http://beta.example/screen/demo-conference'
}
]);
assert.deepEqual(renderedArgs.screen.launcher_downloads, {
windows: '/downloads/kiosk/pulse-signage-kiosk.bat',
linux: '/downloads/kiosk/pulse-signage-kiosk.sh'
});
});
test('screen edit query route includes player urls for every registration', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
let renderedArgs = null;
const pages = {
renderScreenEditPage(screen, data, message, currentUser) {
renderedArgs = { screen, data, message, currentUser };
return 'ok';
}
};
registerScreensRoutes(app, {
pool: {
async query() {
return [[]];
}
},
common: {
async fetchScreenById() {
return { id: 7, name: 'Demo Conference', slug: 'demo-conference', playlist_id: null };
},
async fetchScreenEditData() {
return { playlists: [] };
},
async fetchPlayerRegistrations() {
return [
{ identifier: 'player-alpha', public_base_url: 'http://alpha.example', last_seen_at: new Date().toISOString() }
];
},
async fetchPlayerPublicBaseUrl() {
return 'http://player.example';
}
},
pages,
buildDashboardState: async () => ({ screens: [] }),
getScreenDeleteBlockMessage: async () => '',
getScreenConnections: async () => [],
playerPublicBaseUrl: 'http://player.example',
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const handler = handlers['/screens'];
assert.equal(Array.isArray(handler), true);
const res = {
send(value) {
this.body = value;
},
status(code) {
this.statusCode = code;
return this;
}
};
await handler[1]({ query: { edit: '7' }, currentUser: { id: 1 } }, res, () => {});
assert.equal(res.body, 'ok');
assert.deepEqual(renderedArgs.screen.player_urls, [
{
identifier: 'player-alpha',
public_base_url: 'http://alpha.example',
player_url: 'http://alpha.example/screen/demo-conference'
}
]);
assert.deepEqual(renderedArgs.screen.launcher_downloads, {
windows: '/downloads/kiosk/pulse-signage-kiosk.bat',
linux: '/downloads/kiosk/pulse-signage-kiosk.sh'
});
});
test('screen edit page hides stale player registrations', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
let renderedArgs = null;
const now = Date.now();
const recentSeenAt = new Date(now - 10 * 1000).toISOString();
const staleSeenAt = new Date(now - 5 * 60 * 1000).toISOString();
const pages = {
renderScreenEditPage(screen, data, message, currentUser) {
renderedArgs = { screen, data, message, currentUser };
return 'ok';
}
};
registerScreensRoutes(app, {
pool: {
async query() {
return [[]];
}
},
common: {
async fetchScreenById() {
return { id: 7, name: 'Demo Conference', slug: 'demo-conference', playlist_id: null };
},
async fetchScreenEditData() {
return { playlists: [] };
},
async fetchPlayerRegistrations() {
return [
{ identifier: 'player-stale', public_base_url: 'http://stale.example', last_seen_at: staleSeenAt },
{ identifier: 'player-recent', public_base_url: 'http://recent.example', last_seen_at: recentSeenAt }
];
},
async fetchPlayerPublicBaseUrl() {
return 'http://player.example';
}
},
pages,
buildDashboardState: async () => ({ screens: [] }),
getScreenDeleteBlockMessage: async () => '',
getScreenConnections: async () => [],
formatDashboardDate(value) {
return `formatted:${String(value)}`;
},
playerPublicBaseUrl: 'http://player.example',
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const handler = handlers['/screens/:id/edit'];
assert.equal(Array.isArray(handler), true);
const res = {
send(value) {
this.body = value;
},
status(code) {
this.statusCode = code;
return this;
}
};
await handler[1]({ params: { id: '7' }, query: {}, currentUser: { id: 1 } }, res, () => {});
assert.equal(res.body, 'ok');
assert.deepEqual(renderedArgs.screen.player_urls.map(function (player) {
return {
identifier: player.identifier,
public_base_url: player.public_base_url,
player_url: player.player_url
};
}), [
{
identifier: 'player-recent',
public_base_url: 'http://recent.example',
player_url: 'http://recent.example/screen/demo-conference'
}
]);
});
test('screen edit page renders player urls as an adminlte table', async () => {
const html = renderScreenEditPage(
{
id: 7,
name: 'Demo Conference',
slug: 'demo-conference',
playlist_id: null,
player_urls: [
{
identifier: 'player-alpha',
public_base_url: 'http://alpha.example',
player_url: 'http://alpha.example/screen/demo-conference'
}
]
},
{ playlists: [] },
'',
{ id: 1 }
);
assert.match(html, /Player URLs/);
assert.match(html, /<table/i);
assert.match(html, /card-body table-responsive p-0/);
assert.match(html, /table table-striped w-100 mb-0/);
assert.match(html, /player-alpha/);
});
test('screen launcher downloads are shared and attached', async () => {
const handlers = {};
const app = {
@@ -150,4 +357,72 @@ test('screen launcher downloads are shared and attached', async () => {
assert.match(res.headers['content-disposition'], /pulse-signage-kiosk\.bat/);
assert.match(res.body, /TARGET_URL=http:\/\/player\.example/);
assert.match(res.body, /--kiosk/);
});
test('screen launcher downloads can target an explicit player url', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
registerScreensRoutes(app, {
pool: {
async query() {
return [[]];
}
},
common: {
async fetchScreenById() {
return { id: 7, name: 'Demo Conference', slug: 'demo-conference', playlist_id: null };
},
async fetchScreenEditData() {
return { playlists: [] };
},
async fetchPlayerPublicBaseUrl() {
return 'http://player.example';
}
},
pages: { renderScreenEditPage() { return 'ok'; } },
buildDashboardState: async () => ({ screens: [] }),
getScreenDeleteBlockMessage: async () => '',
getScreenConnections: async () => [],
playerPublicBaseUrl: 'http://player.example',
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const windowsHandler = handlers['/downloads/kiosk/pulse-signage-kiosk.bat'];
assert.equal(Array.isArray(windowsHandler), true);
const res = {
headers: {},
statusCode: 200,
set(name, value) {
this.headers[name.toLowerCase()] = value;
},
attachment(filename) {
this.headers['content-disposition'] = `attachment; filename="${filename}"`;
},
type(value) {
this.headers['content-type'] = value;
},
send(value) {
this.body = value;
},
status(code) {
this.statusCode = code;
return this;
}
};
await windowsHandler[1]({ query: { playerUrl: 'http://player-b.example/screen/demo-conference' } }, res, () => {});
assert.equal(res.statusCode, 200);
assert.match(res.body, /TARGET_URL=http:\/\/player-b\.example\/screen\/demo-conference/);
assert.doesNotMatch(res.body, /TARGET_URL=http:\/\/player\.example/);
});