Add multi-player and remote bridge support

This commit is contained in:
2026-08-07 02:58:18 +01:00
parent a4f8a807ff
commit 74318eb34e
58 changed files with 3785 additions and 432 deletions
+74
View File
@@ -0,0 +1,74 @@
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'
});
});
+221
View File
@@ -0,0 +1,221 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const { createPageAuthToken } = require('../src/request-auth');
const { registerPlayerOnboardingRoutes } = require('../src/player/onboarding');
const originalSharedSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET;
const originalFetch = global.fetch;
test.after(() => {
if (originalSharedSecret === undefined) {
delete process.env.PULSE_SIGNAGE_SHARED_SECRET;
} else {
process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSharedSecret;
}
global.fetch = originalFetch;
});
function createAppAndHandlers() {
const handlers = {};
const app = {
use() {},
get(path, ...routeHandlers) {
handlers[path] = function (req, res) {
return new Promise((resolve, reject) => {
const runHandler = (index) => {
const handler = routeHandlers[index];
if (!handler) {
resolve();
return;
}
let nextCalled = false;
const next = function () {
nextCalled = true;
return runHandler(index + 1);
};
let result;
try {
result = handler(req, res, next);
} catch (error) {
reject(error);
return;
}
Promise.resolve(result).then(function (value) {
if (!nextCalled) {
resolve(value);
}
}, reject);
};
runHandler(0);
});
};
},
post() {}
};
return { app, handlers };
}
function createResponse() {
const headers = {};
return {
headers,
statusCode: 200,
body: null,
set(name, value) {
headers[name] = value;
return this;
},
status(code) {
this.statusCode = code;
return this;
},
type(value) {
headers['Content-Type'] = value;
return this;
},
json(value) {
this.body = value;
return this;
},
send(value) {
this.body = value;
return this;
}
};
}
function registerThinClientRoutes(fetchImpl) {
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'bridge-secret';
global.fetch = fetchImpl;
const { app, handlers } = createAppAndHandlers();
registerPlayerOnboardingRoutes(app, {
app,
common: {
renderPlayerOnboardingLandingPage() {
return 'landing';
},
renderPlayerOnboardingFormPage() {
return 'form';
}
},
thinClientBaseUrl: 'http://bridge.test',
playerPublicBaseUrl: 'http://public.test'
});
return handlers;
}
test('remote onboarding status proxies to the bridge and rewrites playerUrl', async () => {
const fetchCalls = [];
const handlers = registerThinClientRoutes(async (url, init) => {
fetchCalls.push({ url, init });
return {
status: 200,
headers: {
get(name) {
return String(name || '').toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null;
}
},
async json() {
return {
deviceId: 'device123',
onboarded: true,
clientName: 'Lobby Player',
screenId: 7,
screenSlug: 'main-screen',
screenName: 'Main Screen',
playerUrl: null
};
}
};
});
const token = createPageAuthToken({ scope: 'onboarding', deviceId: 'device123' });
const res = createResponse();
await handlers['/api/onboarding/status']({
headers: {
'x-pulse-page-auth': token
},
query: {
deviceId: 'device 123!?'
},
socket: {}
}, res);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'http://bridge.test/api/onboarding/status?deviceId=device123');
assert.equal(fetchCalls[0].init.method, 'GET');
assert.equal(fetchCalls[0].init.headers['x-pulse-page-auth'], token);
assert.equal(res.statusCode, 200);
assert.equal(res.body.playerUrl, 'http://public.test/screen/main-screen');
assert.equal(res.body.screenSlug, 'main-screen');
});
test('remote onboarding status returns 502 when the bridge is unavailable', async () => {
const handlers = registerThinClientRoutes(async () => {
return null;
});
const token = createPageAuthToken({ scope: 'player', deviceId: 'device123' });
const res = createResponse();
await handlers['/api/onboarding/status']({
headers: {
'x-pulse-page-auth': token
},
query: {
deviceId: 'device123'
},
socket: {}
}, res);
assert.equal(res.statusCode, 502);
assert.deepEqual(res.body, { error: 'Player bridge unavailable.' });
});
test('remote onboarding screens proxy preserves the bridge response body', async () => {
const fetchCalls = [];
const handlers = registerThinClientRoutes(async (url, init) => {
fetchCalls.push({ url, init });
return {
status: 200,
headers: {
get(name) {
return String(name || '').toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null;
}
},
async text() {
return JSON.stringify({ screens: [{ id: 1, name: 'Lobby', slug: 'lobby' }] });
}
};
});
const token = createPageAuthToken({ scope: 'onboarding', deviceId: 'device123' });
const res = createResponse();
await handlers['/api/onboarding/screens']({
headers: {
'x-pulse-page-auth': token
},
query: {},
socket: {}
}, res);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'http://bridge.test/api/onboarding/screens');
assert.equal(fetchCalls[0].init.method, 'GET');
assert.equal(res.statusCode, 200);
assert.equal(res.headers['Content-Type'], 'application/json; charset=utf-8');
assert.equal(res.body, JSON.stringify({ screens: [{ id: 1, name: 'Lobby', slug: 'lobby' }] }));
});
+67
View File
@@ -0,0 +1,67 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { normalizeIdentifier, recordPlayerHeartbeat, upsertPlayerRegistration } = require('../src/data/player-registry');
test('normalizeIdentifier trims and limits friendly player labels', () => {
assert.equal(normalizeIdentifier(' Shop2 '), 'Shop2');
assert.equal(normalizeIdentifier(''), '');
assert.equal(normalizeIdentifier('x'.repeat(300)).length, 255);
});
test('player registry upserts include the identifier field', async () => {
const calls = [];
const pool = {
async query(sql, params) {
calls.push({ sql, params });
if (String(sql || '').includes('information_schema.COLUMNS')) {
return [[{ column_count: 1 }]];
}
if (String(sql || '').includes('INSERT INTO d_players')) {
return [{ affectedRows: 1 }];
}
if (String(sql || '').includes('SELECT id, identifier, public_base_url, internal_base_url, last_seen_at')) {
return [[{
id: 1,
identifier: 'Shop2',
public_base_url: 'http://player.local',
internal_base_url: 'http://player:8081',
last_seen_at: '2026-08-07 00:00:00'
}]];
}
return [[]];
}
};
const registration = await upsertPlayerRegistration(pool, {
deviceId: 'device-01',
identifier: 'Shop2',
publicBaseUrl: 'http://player.local/',
internalBaseUrl: 'http://player:8081/'
});
const heartbeat = await recordPlayerHeartbeat(pool, {
deviceId: 'device-01',
identifier: 'Shop2',
publicBaseUrl: 'http://player.local/',
internalBaseUrl: 'http://player:8081/'
});
assert.deepEqual(registration, {
id: 1,
identifier: 'Shop2',
public_base_url: 'http://player.local',
internal_base_url: 'http://player:8081',
last_seen_at: '2026-08-07 00:00:00'
});
assert.deepEqual(heartbeat, registration);
assert.ok(calls.some(function (call) {
return /INSERT INTO d_players/.test(String(call.sql || '')) && /identifier/.test(String(call.sql || ''));
}));
assert.ok(calls.some(function (call) {
return /SELECT .*identifier/.test(String(call.sql || ''));
}));
assert.deepEqual((calls.find(function (call) {
return /INSERT INTO d_players/.test(String(call.sql || ''));
}) || {}).params, ['Shop2', 'http://player.local', 'http://player:8081']);
});
+144
View File
@@ -0,0 +1,144 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
test('system status shows connected player count in the live feed label', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/system-status.js'), 'utf8');
const elements = {
'sidebar-status-dot': {
classList: {
remove() {},
add() {}
},
setAttribute() {}
},
'sidebar-status-text': {
textContent: ''
}
};
const sockets = [];
const context = {
document: {
getElementById(id) {
return elements[id] || null;
}
},
window: {
WebSocket: true,
location: {
protocol: 'http:',
host: 'example.test'
},
setTimeout() {
return 1;
},
clearTimeout() {},
webHandleDashboardState() {}
},
WebSocket: function MockWebSocket(url) {
this.url = url;
sockets.push(this);
this.close = function () {};
},
JSON: JSON,
Number: Number,
String: String,
Boolean: Boolean,
Array: Array,
Object: Object,
Math: Math,
setTimeout() {},
clearTimeout() {},
console: console
};
context.window.document = context.document;
context.window.WebSocket = context.WebSocket;
vm.runInNewContext(script, context);
assert.equal(sockets.length, 1);
sockets[0].onmessage({
data: JSON.stringify({
type: 'dashboard-state',
state: {
screens: [{ slug: 'alpha' }],
playerServiceConnected: true,
connectedPlayersCount: 4
}
})
});
assert.equal(elements['sidebar-status-text'].textContent, 'Live player feed - 4 players connected');
});
test('system status shows an explicit idle label when no players are connected', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/system-status.js'), 'utf8');
const elements = {
'sidebar-status-dot': {
classList: {
remove() {},
add() {}
},
setAttribute() {}
},
'sidebar-status-text': {
textContent: ''
}
};
const sockets = [];
const context = {
document: {
getElementById(id) {
return elements[id] || null;
}
},
window: {
WebSocket: true,
location: {
protocol: 'http:',
host: 'example.test'
},
setTimeout() {
return 1;
},
clearTimeout() {},
webHandleDashboardState() {}
},
WebSocket: function MockWebSocket(url) {
this.url = url;
sockets.push(this);
this.close = function () {};
},
JSON: JSON,
Number: Number,
String: String,
Boolean: Boolean,
Array: Array,
Object: Object,
Math: Math,
setTimeout() {},
clearTimeout() {},
console: console
};
context.window.document = context.document;
context.window.WebSocket = context.WebSocket;
vm.runInNewContext(script, context);
assert.equal(sockets.length, 1);
sockets[0].onmessage({
data: JSON.stringify({
type: 'dashboard-state',
state: {
screens: [{ slug: 'alpha' }],
playerServiceConnected: true,
connectedPlayersCount: 0
}
})
});
assert.equal(elements['sidebar-status-text'].textContent, 'Player feed online - no players connected');
});
+196 -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,203 @@ 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');
});
+2 -1
View File
@@ -36,7 +36,8 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
uniqueScreenSlug: async () => 'beta',
fetchDuplicateName: async () => null,
fetchScreenById: async () => ({ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }),
fetchScreenPlayerRecord: async () => ({ public_base_url: 'http://player.local' })
fetchScreenPlayerRecord: async () => ({ public_base_url: 'http://player.local' }),
fetchPlayerPublicBaseUrl: async () => 'http://player.local'
},
pages,
getAuditUserId() { return 7; },
+164 -5
View File
@@ -34,10 +34,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' },
{ identifier: 'player-beta', public_base_url: 'http://beta.example/' }
];
},
async fetchPlayerPublicBaseUrl() {
return 'http://player.example';
@@ -71,7 +72,97 @@ 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' }
];
},
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'
@@ -150,4 +241,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/);
});