Add player control-plane and dashboard updates
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user