Add player control-plane and dashboard updates

This commit is contained in:
2026-08-07 22:23:46 +01:00
parent 433be06bc7
commit 0801c119ae
36 changed files with 2301 additions and 228 deletions
+288 -2
View File
@@ -49,6 +49,10 @@ test('move client rebinding redirects the live player to the target screen', asy
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 {
@@ -100,7 +104,8 @@ test('move client rebinding redirects the live player to the target screen', asy
deviceId: 'device-123',
clientName: 'Lobby Client',
targetScreenSlug: 'target-screen',
connectionId: 'conn-1'
connectionId: 'conn-1',
playerBaseUrl: 'http://remote-player.example'
},
query: {}
}, response, () => {});
@@ -110,7 +115,7 @@ test('move client rebinding redirects the live player to the target screen', asy
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 === 'forwardPlayerCommand' && entry.payload && entry.payload.command === 'redirect'), 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 () => {
@@ -182,3 +187,284 @@ test('screen control commands can target all screens', async () => {
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/);
});
+52
View File
@@ -71,4 +71,56 @@ test('dashboard state counts active players from the registry heartbeat', async
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');
});
+79 -2
View File
@@ -5,7 +5,7 @@ require('../src/common');
const { createPlayerActionService } = require('../src/web/lib/player-actions');
test('player actions prefer a remote FQDN registration over a local player target', async () => {
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) {
@@ -45,12 +45,89 @@ test('player actions prefer a remote FQDN registration over a local player targe
}
});
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, 'https://pulse-dev-bridge.lzstealth.com/api/screens/demo/commands');
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;
}
+19 -1
View File
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
require('../src/common');
const originalWebBaseUrl = process.env.WEB_BASE_URL;
const { resolveWebBaseUrl } = require('../src/player-bridge/index');
const { resolveWebBaseUrl, resolveScreenCommandTargets } = require('../src/player-bridge/index');
test.after(() => {
if (originalWebBaseUrl === undefined) {
@@ -42,4 +42,22 @@ test('resolveWebBaseUrl keeps external https hosts on the default port', () => {
});
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 });
});
+129 -3
View File
@@ -51,6 +51,7 @@ test('fqdn player registration wins over a local configured player target for me
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;
@@ -78,11 +79,13 @@ test('fqdn player registration wins over a local configured player target for me
return [[
{
identifier: 'player-local',
internal_base_url: 'http://player:8081'
internal_base_url: 'http://player:8081',
last_seen_at: activeLastSeenAt
},
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com'
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com',
last_seen_at: activeLastSeenAt
}
]];
}
@@ -107,6 +110,7 @@ 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;
@@ -127,7 +131,8 @@ test('media sync retry warnings include the resolved player label', async () =>
return [[
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com'
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com',
last_seen_at: activeLastSeenAt
}
]];
}
@@ -148,4 +153,125 @@ test('media sync retry warnings include the resolved player label', async () =>
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);
});
+394
View File
@@ -355,3 +355,397 @@ test('screen controls include an all screens option and update the target summar
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/);
});
+119 -3
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 = {};
@@ -36,8 +37,8 @@ test('screen edit page includes shared launcher downloads and base player url',
},
async fetchPlayerRegistrations() {
return [
{ identifier: 'player-alpha', public_base_url: 'http://alpha.example' },
{ identifier: 'player-beta', public_base_url: 'http://beta.example/' }
{ 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() {
@@ -121,7 +122,7 @@ test('screen edit query route includes player urls for every registration', asyn
},
async fetchPlayerRegistrations() {
return [
{ identifier: 'player-alpha', public_base_url: 'http://alpha.example' }
{ identifier: 'player-alpha', public_base_url: 'http://alpha.example', last_seen_at: new Date().toISOString() }
];
},
async fetchPlayerPublicBaseUrl() {
@@ -169,6 +170,121 @@ test('screen edit query route includes player urls for every registration', asyn
});
});
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 = {