+
{{#if screen.player_urls.length}}
-
-
-
+
+
+
+ | Player |
+ URL |
+
+
+
+ {{#each screen.player_urls}}
- | Player |
- URL |
+ {{identifier}} |
+
+ {{#if player_url}}
+ {{player_url}}
+ {{else}}
+ Not available
+ {{/if}}
+ |
-
-
- {{#each screen.player_urls}}
-
- |
- {{identifier}}
- |
-
- {{#if player_url}}
- {{player_url}}
- {{else}}
- Not available
- {{/if}}
- |
-
- {{/each}}
-
-
-
+ {{/each}}
+
+
{{else}}
-
No player URLs are available yet.
+
No player URLs are available yet.
{{/if}}
diff --git a/test/admin-client-commands.test.js b/test/admin-client-commands.test.js
index 532a460..4ca7d5e 100644
--- a/test/admin-client-commands.test.js
+++ b/test/admin-client-commands.test.js
@@ -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);
+});
diff --git a/test/clients-routes.test.js b/test/clients-routes.test.js
new file mode 100644
index 0000000..fe4fea8
--- /dev/null
+++ b/test/clients-routes.test.js
@@ -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/);
+});
\ No newline at end of file
diff --git a/test/dashboard-state.test.js b/test/dashboard-state.test.js
index 0af54b1..fd6776f 100644
--- a/test/dashboard-state.test.js
+++ b/test/dashboard-state.test.js
@@ -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');
});
\ No newline at end of file
diff --git a/test/player-actions.test.js b/test/player-actions.test.js
index 65bcf4f..6363294 100644
--- a/test/player-actions.test.js
+++ b/test/player-actions.test.js
@@ -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;
}
diff --git a/test/player-bridge-web-base-url.test.js b/test/player-bridge-web-base-url.test.js
index 6e47030..14bbec3 100644
--- a/test/player-bridge-web-base-url.test.js
+++ b/test/player-bridge-web-base-url.test.js
@@ -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']);
});
\ No newline at end of file
diff --git a/test/player-playlist.test.js b/test/player-playlist.test.js
index 2c84599..8a5267a 100644
--- a/test/player-playlist.test.js
+++ b/test/player-playlist.test.js
@@ -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);
diff --git a/test/playlist-form-view-model.test.js b/test/playlist-form-view-model.test.js
index ab8f1aa..6c919d2 100644
--- a/test/playlist-form-view-model.test.js
+++ b/test/playlist-form-view-model.test.js
@@ -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);
});
\ No newline at end of file
diff --git a/test/table-pagination-height.test.js b/test/table-pagination-height.test.js
new file mode 100644
index 0000000..8b89a0c
--- /dev/null
+++ b/test/table-pagination-height.test.js
@@ -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 });
+});
diff --git a/test/upload-sync.test.js b/test/upload-sync.test.js
index 31bf826..a4dfbc8 100644
--- a/test/upload-sync.test.js
+++ b/test/upload-sync.test.js
@@ -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 });
+ }
});
\ No newline at end of file
diff --git a/test/web-bootstrap.test.js b/test/web-bootstrap.test.js
new file mode 100644
index 0000000..2ae5e39
--- /dev/null
+++ b/test/web-bootstrap.test.js
@@ -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);
+});
\ No newline at end of file
diff --git a/test/web-dashboard-page.test.js b/test/web-dashboard-page.test.js
index a492906..ebb7f8e 100644
--- a/test/web-dashboard-page.test.js
+++ b/test/web-dashboard-page.test.js
@@ -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: '
| stale |
',
+ 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: '
| stale |
',
+ 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: '
| stale |
',
+ 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, '
| stale |
');
+});
+
+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: '
| stale |
',
+ 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 = '
Beta |
';
+ context.window.webRefreshClientTableFromLatestState();
+
+ assert.match(tbody.innerHTML, /data-label="Actions"/);
+ assert.match(tbody.innerHTML, /Pause/);
+ assert.match(tbody.innerHTML, /Blackout/);
+});
diff --git a/test/web-screens-routes.test.js b/test/web-screens-routes.test.js
index 49f2a3c..f4c2989 100644
--- a/test/web-screens-routes.test.js
+++ b/test/web-screens-routes.test.js
@@ -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, /
{
const handlers = {};
const app = {