Release 2.6.7

This commit is contained in:
2026-08-08 13:02:31 +01:00
parent b20beb250b
commit 7e46fffc34
41 changed files with 2489 additions and 1290 deletions
+150 -30
View File
@@ -44,7 +44,18 @@ test('move client rebinding redirects the live player to the target screen', asy
return [[]];
}
},
common: {},
common: {
fetchPlayerRegistrations: async () => ([
{
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
},
{
public_base_url: 'http://player-b.example',
internal_base_url: 'http://player-b.internal'
}
])
},
forwardPlayerCommand(slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
return { ok: true };
@@ -67,6 +78,19 @@ test('move client rebinding redirects the live player to the target screen', asy
};
}
if (slug === 'target-screen') {
return {
connections: [
{
id: 'target-conn-1',
clientId: 'target-conn-1',
deviceId: 'target-device-1',
playerPublicBaseUrl: 'https://remote-target.example'
}
]
};
}
return { connections: [] };
},
isClientNameAvailable: async () => true,
@@ -115,34 +139,78 @@ 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 === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://remote-player.example' && entry.payload && entry.payload.command === 'redirect'), true);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand' && entry.slug === 'source-screen' && entry.payload && entry.payload.command === 'redirect'), false);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://remote-player.example'), true);
});
test('screen control commands can target all screens', async () => {
const calls = [];
const { app, handlers } = createHandlers();
registerScreenCommandRoutes(app, {
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')) {
if (String(sql || '').includes('FROM d_screens') && String(sql || '').includes('ORDER BY slug ASC')) {
return [[
{ id: 1, name: 'Alpha', slug: 'alpha' },
{ id: 2, name: 'Beta', slug: 'beta' }
{ slug: 'alpha' },
{ slug: 'beta' }
]];
}
return [[]];
}
},
common: {},
common: {
fetchPlayerRegistrations: async () => ([
{
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
},
{
public_base_url: 'http://player-b.example',
internal_base_url: 'http://player-b.internal'
}
])
},
forwardPlayerCommand(slug, payload) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
return { ok: true };
},
getScreenConnections: async () => ({ connections: [] }),
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload, connectionId });
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 () => {
@@ -184,11 +252,13 @@ test('screen control commands can target all screens', async () => {
assert.equal(response.body.ok, true);
assert.equal(response.body.allScreens, true);
assert.equal(response.body.targetScreenCount, 2);
assert.deepEqual(calls.filter((entry) => entry.kind === 'forwardPlayerCommand').map((entry) => entry.slug), ['alpha', 'beta']);
assert.equal(response.body.targetPlayerCount, 2);
assert.deepEqual(calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl), ['http://player-a.internal', 'http://player-b.internal']);
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
});
test('screen control commands use the live connection player url when available', async () => {
test('screen control commands use the bridge for live connections', async () => {
const calls = [];
const { app, handlers } = createHandlers();
@@ -197,6 +267,17 @@ test('screen control commands use the live connection player url when available'
async query(sql, params) {
calls.push({ kind: 'query', sql, params });
if (String(sql || '').includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url')) {
return [[
{
slug: 'source-screen',
player_id: 'player-a',
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
}
]];
}
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' }]];
}
@@ -204,15 +285,22 @@ test('screen control commands use the live connection player url when available'
return [[]];
}
},
common: {},
common: {
fetchPlayerRegistrations: async () => ([
{
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
},
{
public_base_url: 'http://player-b.example',
internal_base_url: 'http://player-b.internal'
}
])
},
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 {
@@ -268,11 +356,11 @@ test('screen control commands use the live connection player url when available'
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);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand' && entry.slug === 'source-screen'), true);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://local-player.example'), false);
});
test('screen control commands fan out to every live player for the selected screen', async () => {
test('screen control commands fan out through the bridge for the selected screen', async () => {
const calls = [];
const { app, handlers } = createHandlers();
@@ -281,6 +369,15 @@ test('screen control commands fan out to every live player for the selected scre
async query(sql, params) {
calls.push({ kind: 'query', sql, params });
if (String(sql || '').includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url')) {
return [[{
slug: 'source-screen',
player_id: 'player-a',
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
}]];
}
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' }]];
}
@@ -288,7 +385,18 @@ test('screen control commands fan out to every live player for the selected scre
return [[]];
}
},
common: {},
common: {
fetchPlayerRegistrations: async () => ([
{
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
},
{
public_base_url: 'http://player-b.example',
internal_base_url: 'http://player-b.internal'
}
])
},
forwardPlayerCommand(slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
return { ok: true };
@@ -305,7 +413,7 @@ test('screen control commands fan out to every live player for the selected scre
id: 'conn-1',
clientId: 'conn-1',
deviceId: 'device-123',
playerPublicBaseUrl: 'http://local-player-a.example'
playerPublicBaseUrl: 'http://local-player.example'
},
{
id: 'conn-2',
@@ -357,14 +465,14 @@ test('screen control commands fan out to every live player for the selected scre
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand' && entry.slug === 'source-screen'), false);
assert.deepEqual(
calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl),
['http://local-player-a.example', 'http://local-player-b.example']
['http://local-player.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 () => {
test('all screens commands fan out through the bridge for each screen', async () => {
const calls = [];
const { app, handlers } = createHandlers();
@@ -373,23 +481,34 @@ test('all screens commands fan out across the live player for each screen', asyn
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')) {
if (String(sql || '').includes('FROM d_screens') && String(sql || '').includes('ORDER BY slug ASC')) {
return [[
{ id: 1, name: 'Alpha', slug: 'alpha' },
{ id: 2, name: 'Beta', slug: 'beta' }
{ slug: 'alpha' },
{ slug: 'beta' }
]];
}
return [[]];
}
},
common: {},
common: {
fetchPlayerRegistrations: async () => ([
{
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
},
{
public_base_url: 'http://player-b.example',
internal_base_url: 'http://player-b.internal'
}
])
},
forwardPlayerCommand(slug, payload) {
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
return { ok: true };
},
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload) {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload });
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload, connectionId });
return { ok: true };
},
getScreenConnections: async (slug) => {
@@ -461,10 +580,11 @@ test('all screens commands fan out across the live player for each screen', asyn
assert.equal(response.body.ok, true);
assert.equal(response.body.allScreens, true);
assert.equal(response.body.targetScreenCount, 2);
assert.equal(response.body.targetPlayerCount, 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']
['http://player-a.internal', 'http://player-b.internal']
);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
});
+40
View File
@@ -0,0 +1,40 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { registerFontSweepTask } = require('../src/web/lib/background-tasks/tasks-scheduled/font-sweep');
test('font sweep registers a generic cleanup recurring task without player metadata', async () => {
let registeredTask = null;
const calls = [];
registerFontSweepTask({
backgroundTaskQueue: {
registerRecurringTask(task) {
registeredTask = task;
}
},
mediaDir: '/tmp/media',
uploadSyncService: {
async pushUploadFileToPlayer(uploadPath, mediaDir) {
calls.push(['put', uploadPath, mediaDir]);
},
async removeUploadFileFromPlayer(uploadPath, mediaDir) {
calls.push(['delete', uploadPath, mediaDir]);
}
}
});
assert.ok(registeredTask);
assert.equal(registeredTask.key, 'font-sweep');
assert.equal(registeredTask.title, 'Font sweep');
assert.equal(registeredTask.category, 'cleanup');
assert.equal(registeredTask.intervalMs, 24 * 60 * 60 * 1000);
assert.deepEqual(registeredTask.metadata, { mediaDir: '/tmp/media' });
await registeredTask.run();
assert.deepEqual(calls, [
['put', '/media/fonts/fonts.json', '/tmp/media'],
['put', '/media/fonts/fonts.css', '/tmp/media']
]);
});
@@ -0,0 +1,33 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { registerOnboardingDevicePruneTask } = require('../src/web/lib/background-tasks/tasks-scheduled/onboarding-device-prune');
test('onboarding device prune registers an hourly recurring cleanup job', async () => {
let registeredTask = null;
const calls = [];
registerOnboardingDevicePruneTask({
backgroundTaskQueue: {
registerRecurringTask(task) {
registeredTask = task;
}
},
pool: {},
common: {
async pruneStaleOnboardingDevices(pool) {
calls.push(pool);
}
}
});
assert.ok(registeredTask);
assert.equal(registeredTask.key, 'onboarding-device-prune');
assert.equal(registeredTask.title, 'Onboarding device prune');
assert.equal(registeredTask.category, 'cleanup');
assert.equal(registeredTask.intervalMs, 60 * 60 * 1000);
await registeredTask.run();
assert.deepEqual(calls, [{}]);
});
+197
View File
@@ -0,0 +1,197 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerScreenCommandRoutes = require('../src/web/routes/admin/client-commands');
function createAppHarness() {
const handlers = {};
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
return { app, handlers };
}
test('client command route forwards all screens commands', async () => {
const { app, handlers } = createAppHarness();
const calls = [];
registerScreenCommandRoutes(app, {
pool: {
async query(sql) {
calls.push({ kind: 'query', sql });
if (String(sql).includes('FROM d_screens') && String(sql).includes('ORDER BY slug ASC')) {
return [[{ slug: 'alpha' }, { slug: 'beta' }]];
}
return [[[]]];
}
},
common: {
async fetchPlayerRegistrations() {
return [
{
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
},
{
public_base_url: 'http://player-b.example',
internal_base_url: 'http://player-b.internal'
}
];
}
},
forwardPlayerCommand(screenSlug, commandPayload) {
calls.push({ kind: 'forwardPlayerCommand', screenSlug, commandPayload });
return { ok: true };
},
forwardPlayerCommandToBaseUrl(baseUrl, screenSlug, commandPayload) {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, screenSlug, commandPayload });
return { ok: true };
},
getScreenConnections: async (slug) => {
if (slug === 'alpha') {
return { connections: [{ playerPublicBaseUrl: 'http://player-a.example' }] };
}
if (slug === 'beta') {
return { connections: [{ playerPublicBaseUrl: 'http://player-b.example' }] };
}
return { connections: [] };
},
isClientNameAvailable() {
return true;
},
withClientNameReservation() {
throw new Error('withClientNameReservation should not be called for __all__');
},
broadcastDashboardState() {
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(payload) {
this.body = payload;
return this;
}
};
await routeHandlers[1]({
params: { slug: '__all__' },
body: { command: 'reload' },
query: {},
currentUser: { id: 1 }
}, 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(response.body.sent, 2);
assert.deepEqual(calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl), ['http://player-a.internal', 'http://player-b.internal']);
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
});
test('client command route forwards a single screen command', async () => {
const { app, handlers } = createAppHarness();
const calls = [];
registerScreenCommandRoutes(app, {
pool: {
async query(sql) {
if (String(sql).includes('SELECT id, name, slug FROM d_screens WHERE slug = ?')) {
return [[{ id: 7, name: 'Demo Lobby', slug: 'demo-lobby' }]];
}
if (String(sql).includes('SELECT client_name')) {
return [[null]];
}
if (String(sql).includes('SELECT slug FROM d_screens ORDER BY slug ASC')) {
return [[[]]];
}
if (String(sql).includes('UPDATE d_onboarding_devices')) {
return [{ affectedRows: 0 }];
}
if (String(sql).includes('FROM d_onboarding_devices d')) {
return [[[]]];
}
return [[[]]];
}
},
common: {
async fetchPlayerRegistrations() {
return [];
}
},
forwardPlayerCommand(screenSlug, commandPayload, connectionId) {
calls.push({ kind: 'forward', screenSlug, commandPayload, connectionId });
return Promise.resolve({ ok: true, sent: 1 });
},
forwardPlayerCommandToBaseUrl() {
throw new Error('forwardPlayerCommandToBaseUrl should not be called for this path');
},
getScreenConnections() {
return Promise.resolve({ connections: [] });
},
isClientNameAvailable() {
return true;
},
withClientNameReservation() {
throw new Error('withClientNameReservation should not be called for reload');
},
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(payload) {
this.body = payload;
return this;
}
};
await routeHandlers[1]({
params: { slug: 'demo-lobby' },
body: { command: 'reload' },
query: {},
currentUser: { id: 1 }
}, response, () => {});
assert.equal(response.statusCode, 200);
assert.equal(calls.length, 1);
assert.equal(calls[0].kind, 'forward');
assert.equal(calls[0].screenSlug, 'demo-lobby');
assert.deepEqual(calls[0].commandPayload, { command: 'reload' });
assert.equal(calls[0].connectionId, undefined);
assert.equal(response.body.ok, true);
});
+93 -37
View File
@@ -5,7 +5,7 @@ require('../src/common');
const { createPlayerActionService } = require('../src/web/lib/player-actions');
test('player actions prefer the exact configured player registration over a remote FQDN row', async () => {
test('player actions use the configured bridge url for commands', async () => {
const fetchCalls = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
@@ -29,20 +29,7 @@ test('player actions prefer the exact configured player registration over a remo
const playerActionService = createPlayerActionService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-local',
internal_base_url: 'http://player:8081'
},
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com'
}
]];
}
}
playerInternalBaseUrl: 'http://player:8081'
});
const originalIdentifier = process.env.PLAYER_IDENTIFIER;
@@ -60,12 +47,57 @@ test('player actions prefer the exact configured player registration over a remo
}
});
test('player actions merge screen connections from every recent player registration', async () => {
test('player actions read screen connections from the bridge base url', 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: 'bridge-1', playerPublicBaseUrl: 'http://bridge.example' }
]
};
},
async text() {
return JSON.stringify({ ok: true });
}
};
};
const playerActionService = createPlayerActionService({
common: {},
pool: {
async query() {
return [[[]]];
}
},
playerInternalBaseUrl: 'http://bridge.example'
});
try {
const response = await playerActionService.getScreenConnections('demo');
assert.equal(response.count, 1);
assert.deepEqual(response.connections.map(function (connection) { return connection.id; }), ['bridge-1']);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'http://bridge.example/api/screens/demo/connections');
} finally {
global.fetch = originalFetch;
}
});
test('player actions merge bridge and player screen connections', async () => {
const fetchCalls = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
fetchCalls.push({ url, init });
if (String(url).indexOf('player-bridge:8090') !== -1) {
return {
ok: true,
status: 200,
@@ -74,7 +106,7 @@ test('player actions merge screen connections from every recent player registrat
return {
screenSlug: 'demo',
connections: [
{ id: 'a-1', playerPublicBaseUrl: 'http://player-a.example' }
{ id: 'bridge-1', playerPublicBaseUrl: 'http://bridge.example' }
]
};
},
@@ -92,7 +124,7 @@ test('player actions merge screen connections from every recent player registrat
return {
screenSlug: 'demo',
connections: [
{ id: 'b-1', playerPublicBaseUrl: 'http://player-b.example' }
{ id: 'player-1', playerPublicBaseUrl: 'http://player.example' }
]
};
},
@@ -104,30 +136,54 @@ test('player actions merge screen connections from every recent player registrat
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()
}
]];
}
}
playerInternalBaseUrl: 'http://player:8081',
bridgeInternalBaseUrl: 'http://player-bridge:8090'
});
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);
assert.deepEqual(fetchCalls.map(function (call) { return call.url; }).sort(), [
'http://player-bridge:8090/api/screens/demo/connections',
'http://player:8081/api/screens/demo/connections'
]);
assert.equal(response.count, 2);
assert.deepEqual(response.connections.map(function (connection) { return connection.id; }).sort(), ['bridge-1', 'player-1']);
} finally {
global.fetch = originalFetch;
}
});
test('player actions send device commands through the bridge base url', async () => {
const fetchCalls = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
fetchCalls.push({ url, init });
return {
ok: true,
status: 200,
headers: { get() { return null; } },
async json() {
return { ok: true, sent: true };
},
async text() {
return JSON.stringify({ ok: true, sent: true });
}
};
};
const playerActionService = createPlayerActionService({
common: {},
bridgeInternalBaseUrl: 'http://player-bridge:8090'
});
try {
const response = await playerActionService.forwardPlayerCommandToDevice('device-123', { command: 'pause' });
assert.deepEqual(response, { ok: true, sent: true });
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'http://player-bridge:8090/api/players/device-123/commands');
} finally {
global.fetch = originalFetch;
}
+34 -7
View File
@@ -3,19 +3,19 @@ const assert = require('node:assert/strict');
require('../src/common');
const originalWebBaseUrl = process.env.WEB_BASE_URL;
const { resolveWebBaseUrl, resolveScreenCommandTargets } = require('../src/player-bridge/index');
const originalWebBaseUrl = process.env.WEB_INTERNAL_URL;
const { resolveWebBaseUrl, resolveScreenCommandTargets, resolveSnapshotUpstreamBaseUrl } = require('../src/player-bridge/index');
test.after(() => {
if (originalWebBaseUrl === undefined) {
delete process.env.WEB_BASE_URL;
delete process.env.WEB_INTERNAL_URL;
} else {
process.env.WEB_BASE_URL = originalWebBaseUrl;
process.env.WEB_INTERNAL_URL = originalWebBaseUrl;
}
});
test('resolveWebBaseUrl prefers WEB_BASE_URL', () => {
process.env.WEB_BASE_URL = 'https://web.example.test/app/';
test('resolveWebBaseUrl prefers WEB_INTERNAL_URL', () => {
process.env.WEB_INTERNAL_URL = 'https://web.example.test/app/';
const resolved = resolveWebBaseUrl({
headers: {
@@ -30,7 +30,7 @@ test('resolveWebBaseUrl prefers WEB_BASE_URL', () => {
});
test('resolveWebBaseUrl keeps external https hosts on the default port', () => {
delete process.env.WEB_BASE_URL;
delete process.env.WEB_INTERNAL_URL;
const resolved = resolveWebBaseUrl({
headers: {
@@ -60,4 +60,31 @@ test('resolveScreenCommandTargets only returns open sockets for the requested sc
assert.deepEqual(targets.map(function (target) {
return target.deviceId;
}), ['player-a']);
});
test('resolveScreenCommandTargets falls back to a single open socket when the screen map is empty', () => {
const playerSockets = new Map([
['player-a', { readyState: 1, playerDeviceId: 'player-a' }]
]);
const screenPlayerDeviceIds = new Map();
const targets = resolveScreenCommandTargets('demo-conference', playerSockets, screenPlayerDeviceIds);
assert.deepEqual(targets.map(function (target) {
return target.deviceId;
}), ['player-a']);
});
test('resolveSnapshotUpstreamBaseUrl prefers a local internal player url', () => {
assert.equal(resolveSnapshotUpstreamBaseUrl({
public_base_url: 'https://pulse-dev-player.lzstealth.com',
internal_base_url: 'http://player-dev:8081'
}), 'http://player-dev:8081');
});
test('resolveSnapshotUpstreamBaseUrl falls back to the public player url for remote players', () => {
assert.equal(resolveSnapshotUpstreamBaseUrl({
public_base_url: 'https://remote-player.example',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com'
}), 'https://remote-player.example');
});
+1 -1
View File
@@ -108,7 +108,7 @@ function registerThinClientRoutes(fetchImpl) {
return 'form';
}
},
thinClientBaseUrl: 'http://bridge.test',
bridgeBaseUrl: 'http://bridge.test',
playerPublicBaseUrl: 'http://public.test'
});
+200
View File
@@ -9,6 +9,15 @@ function loadScript(scriptPath, sandbox) {
vm.runInNewContext(source, sandbox, { filename: scriptPath });
}
function loadHtmlScript(scriptPath, sandbox) {
const source = fs.readFileSync(scriptPath, 'utf8').match(/<script>([\s\S]*)<\/script>/);
if (!source) {
throw new Error(`Unable to extract script body from ${scriptPath}`);
}
const script = source[1].trim();
vm.runInNewContext(script, sandbox, { filename: scriptPath });
}
test('webpage preloading only targets the next slide', () => {
const sandbox = {
window: null,
@@ -95,4 +104,195 @@ test('rtmp warmups only target the next slide', () => {
assert.equal(sandbox.getRtmpWarmupSlides([current, next, later], 0).map((slide) => slide.id).join(','), '2');
assert.equal(sandbox.getRtmpWarmupSlides([current, next, later], 1).map((slide) => slide.id).join(','), '3');
assert.equal(sandbox.getRtmpWarmupSlides([current, next, later], 2).map((slide) => slide.id).join(','), '');
});
test('command client ids stay scoped to the screen session', () => {
const sessionStorage = (() => {
const values = new Map();
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const localStorage = (() => {
const values = new Map();
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console,
crypto: {
randomUUID() {
return 'tab-session-client-id';
}
},
localStorage,
sessionStorage,
commandClientId: null,
commandClientStorageKey: 'pulse-command-client-id',
currentPlaylistSignature: 'signature',
slides: [],
index: 0,
activeSlidesCacheKey: '',
activeSlidesCacheValue: [],
renderCacheViewportKey: '',
preloadSignature: '',
preloadContainer: null
};
sandbox.window = sandbox;
loadScript(path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js'), sandbox);
assert.equal(sandbox.getCommandClientId(), 'tab-session-client-id');
assert.equal(sessionStorage.getItem('pulse-command-client-id'), 'tab-session-client-id');
assert.equal(localStorage.getItem('pulse-command-client-id'), null);
});
test('player client names stay scoped to the tab session', () => {
const sessionStorage = (() => {
const values = new Map([['pulse-signage-player-client-name', 'tab-only-name']]);
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const localStorage = (() => {
const values = new Map([['pulse-signage-player-client-name', 'shared-name']]);
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console,
sessionStorage,
localStorage,
WebSocket: { OPEN: 1 },
sendCommandState() {}
};
sandbox.window = sandbox;
loadHtmlScript(path.join(__dirname, '..', 'src', 'player', 'player-client-name.script.html'), sandbox);
assert.equal(sandbox.getOnboardingClientName(), 'tab-only-name');
assert.equal(localStorage.getItem('pulse-signage-player-client-name'), 'shared-name');
sandbox.applyOnboardingClientName('Renamed Tab Client', null);
assert.equal(sessionStorage.getItem('pulse-signage-player-client-name'), 'Renamed Tab Client');
assert.equal(localStorage.getItem('pulse-signage-player-client-name'), 'shared-name');
});
test('onboarding device ids stay scoped to the tab session', () => {
const sessionStorage = (() => {
const values = new Map();
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const localStorage = (() => {
const values = new Map([['pulse-signage-player-device-id', 'shared-device-id']]);
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console,
sessionStorage,
localStorage,
crypto: {
randomUUID() {
return 'tab-device-id';
}
},
fetch() {
return Promise.resolve({
ok: true,
json() {
return Promise.resolve({ onboarded: false, screens: [] });
},
text() {
return Promise.resolve('');
}
});
},
document: {
getElementById() {
return null;
},
createElement() {
return { appendChild() {}, removeChild() {} };
}
},
location: {
replace() {}
},
setInterval() {
return 1;
},
clearInterval() {}
};
sandbox.window = sandbox;
loadHtmlScript(path.join(__dirname, '..', 'src', 'player', 'onboarding', 'player-onboarding-landing.script.html'), sandbox);
assert.equal(sessionStorage.getItem('pulse-signage-player-device-id'), 'tab-device-id');
assert.equal(localStorage.getItem('pulse-signage-player-device-id'), 'shared-device-id');
});
+10 -1
View File
@@ -38,7 +38,13 @@ function waitFor(predicate, timeoutMs = 1000) {
test('player runtime snapshots websocket state and checks live names', async () => {
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
const runtime = createPlayerRuntime({ pool: null });
const snapshotNotifications = [];
const runtime = createPlayerRuntime({
pool: null,
notifySnapshot(snapshot) {
snapshotNotifications.push(snapshot);
}
});
const server = http.createServer();
runtime.installWebsocket(server);
@@ -70,6 +76,7 @@ test('player runtime snapshots websocket state and checks live names', async ()
const snapshot = runtime.snapshotConnections('test2')[0];
return snapshot && snapshot.clientName === 'Lobby Player' ? snapshot : null;
});
await waitFor(() => snapshotNotifications.length > 0);
const snapshot = runtime.snapshotConnections('test2')[0];
assert.equal(snapshot.clientName, 'Lobby Player');
@@ -78,6 +85,8 @@ test('player runtime snapshots websocket state and checks live names', async ()
assert.equal(snapshot.paused, true);
assert.equal(snapshot.currentSlideId, 9);
assert.equal(snapshot.currentSlideTitle, 'Intro');
assert.equal(snapshotNotifications[0].slug, 'test2');
assert.equal(snapshotNotifications[0].connections.length, 1);
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Lobby Player', 'other-device'), false);
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Lobby Player', 'device123'), true);
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Other Name', 'device123'), true);
+97 -504
View File
@@ -89,273 +89,6 @@ test('dashboard kiosk launcher includes connected player choices', () => {
assert.match(html, /data-kiosk-launcher-download-base="\/downloads\/kiosk\/pulse-signage-kiosk\.bat"/);
});
test('dashboard kiosk launcher requires both confirmation and a player selection', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
const downloadLink = {
classList: {
classes: new Set(['disabled']),
add(name) { this.classes.add(name); },
remove(name) { this.classes.delete(name); },
contains(name) { return this.classes.has(name); }
},
attributes: {
href: '',
'data-kiosk-launcher-download-base': '/downloads/kiosk/pulse-signage-kiosk.bat',
'aria-disabled': 'true',
tabindex: '-1'
},
setAttribute(name, value) {
this.attributes[name] = String(value);
},
getAttribute(name) {
return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : '';
},
removeAttribute(name) {
delete this.attributes[name];
}
};
const checkbox = {
checked: false,
listeners: {},
addEventListener(type, handler) {
this.listeners[type] = handler;
}
};
const select = {
value: '',
options: [
{ value: '', textContent: 'Select a player' },
{ value: 'http://player-a.example', textContent: 'player-alpha' }
],
listeners: {},
innerHTML: '',
addEventListener(type, handler) {
this.listeners[type] = handler;
}
};
const modal = {
querySelectorAll(selector) {
return selector === '[data-kiosk-launcher-download]' ? [downloadLink] : [];
},
querySelector(selector) {
if (selector === '[data-kiosk-launcher-confirm]') {
return checkbox;
}
if (selector === '[data-kiosk-launcher-player-select]') {
return select;
}
return null;
},
addEventListener(type, handler) {
this.listeners = this.listeners || {};
this.listeners[type] = handler;
}
};
const context = {
document: {
getElementById(id) {
if (id === 'dashboard-kiosk-launcher-modal') {
return modal;
}
return null;
},
querySelector() {
return null;
}
},
window: {
webUiHelpers: {
escapeHtml(value) { return String(value); },
formatDashboardDate(value) { return String(value); },
getClientRowKey() { return ''; },
getClientDisplayName() { return ''; },
setButtonVariant() {},
normalizeDisplayIp(value) { return String(value); }
},
WebSocket: null,
location: {
protocol: 'http:',
host: 'example.test'
},
setTimeout() { return 1; },
clearTimeout() {},
alert() {},
prompt() {
return null;
},
webHandleDashboardState() {}
},
WebSocket: function MockWebSocket() {},
JSON: JSON,
Number: Number,
String: String,
Boolean: Boolean,
Array: Array,
Object: Object,
Math: Math,
Set: Set,
URLSearchParams: URLSearchParams,
FormData: function FormData() {},
setTimeout() {},
clearTimeout() {},
console: console
};
context.window.document = context.document;
context.window.WebSocket = context.WebSocket;
vm.runInNewContext(script, context);
assert.equal(downloadLink.classList.contains('disabled'), true);
assert.equal(Object.prototype.hasOwnProperty.call(downloadLink.attributes, 'href'), false);
checkbox.checked = true;
checkbox.listeners.change();
assert.equal(downloadLink.classList.contains('disabled'), true);
assert.equal(Object.prototype.hasOwnProperty.call(downloadLink.attributes, 'href'), false);
select.value = 'http://player-a.example';
select.listeners.change();
assert.equal(downloadLink.classList.contains('disabled'), false);
assert.equal(downloadLink.attributes.href, '/downloads/kiosk/pulse-signage-kiosk.bat?playerUrl=http%3A%2F%2Fplayer-a.example');
});
test('screen controls include an all screens option and update the target summary', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
const select = {
value: '__all__',
selectedIndex: 2,
options: [
{ value: 'alpha', textContent: 'Alpha', getAttribute() { return null; } },
{ value: 'beta', textContent: 'Beta', getAttribute() { return null; } },
{ value: '__all__', textContent: 'All screens', getAttribute(name) { return name === 'data-screen-target-all' ? 'true' : null; } }
],
listeners: {},
addEventListener(type, handler) {
this.listeners[type] = handler;
}
};
const commandInput = { value: '' };
const pausedInput = { value: 'true' };
const button = {
innerHTML: '',
disabled: false
};
const form = {
dataset: {},
getAttribute(name) {
if (name === 'data-screen-command-action') {
return 'pause';
}
return this[name] || '';
},
querySelector(selector) {
if (selector === 'input[name="command"]') {
return commandInput;
}
if (selector === 'input[name="paused"]') {
return pausedInput;
}
if (selector === 'button[type="submit"]') {
return button;
}
return null;
},
querySelectorAll() {
return [button, commandInput, pausedInput];
},
setAttribute(name, value) {
this[name] = value;
},
action: ''
};
const pill = { classList: { toggle() {}, add() {}, remove() {} }, textContent: '' };
const nameNode = { textContent: '' };
const metaNode = { textContent: '' };
const context = {
document: {
getElementById(id) {
if (id === 'screen-command-select') {
return select;
}
return null;
},
querySelector(selector) {
if (selector === '[data-screen-command-pill]') {
return pill;
}
if (selector === '[data-screen-command-name]') {
return nameNode;
}
if (selector === '[data-screen-command-meta]') {
return metaNode;
}
return null;
},
querySelectorAll(selector) {
return selector === '[data-screen-command-form]' ? [form] : [];
}
},
window: {
webUiHelpers: {
escapeHtml(value) { return String(value); },
formatDashboardDate(value) { return String(value); },
getClientRowKey() { return ''; },
getClientDisplayName() { return ''; },
setButtonVariant() {},
normalizeDisplayIp(value) { return String(value); }
},
WebSocket: null,
location: {
protocol: 'http:',
host: 'example.test'
},
setTimeout() { return 1; },
clearTimeout() {},
alert() {},
prompt() {
return null;
},
webHandleDashboardState() {}
},
WebSocket: function MockWebSocket() {},
JSON: JSON,
Number: Number,
String: String,
Boolean: Boolean,
Array: Array,
Object: Object,
Math: Math,
Set: Set,
URLSearchParams: URLSearchParams,
FormData: function FormData() {},
setTimeout() {},
clearTimeout() {},
console: console
};
context.window.document = context.document;
context.window.WebSocket = context.WebSocket;
vm.runInNewContext(script, context);
context.window.webHandleDashboardState({
screens: [
{ slug: 'alpha', name: 'Alpha' },
{ slug: 'beta', name: 'Beta' }
],
clients: [
{ screen_slug: 'alpha', paused: true },
{ screen_slug: 'beta', paused: true }
]
});
assert.equal(form.action, '/clients/__all__/commands');
assert.equal(nameNode.textContent, 'All screens');
assert.equal(metaNode.textContent, 'Commands sent here target every client across every screen group.');
assert.match(button.innerHTML, /Resume all screens/);
assert.equal(commandInput.value, 'pause');
assert.equal(pausedInput.value, 'false');
});
test('dashboard client refresh respects the active search filter', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
const tbody = {
@@ -428,10 +161,7 @@ test('dashboard client refresh respects the active search filter', () => {
Math: Math,
Set: Set,
URLSearchParams: URLSearchParams,
FormData: function FormData() {},
setTimeout() {},
clearTimeout() {},
console: console
FormData: function FormData() {}
};
context.window.document = context.document;
context.window.WebSocket = context.WebSocket;
@@ -449,54 +179,112 @@ test('dashboard client refresh respects the active search filter', () => {
assert.doesNotMatch(tbody.innerHTML, /Alpha/);
});
test('dashboard client refresh respects the live search input before the URL updates', () => {
test('dashboard move client button opens the move modal for the selected row', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/dashboard/dashboard-page.js'), 'utf8');
const searchInput = {
value: 'beta'
const modal = {
listeners: {},
addEventListener(type, handler) {
this.listeners[type] = handler;
}
};
const tbody = {
innerHTML: '<tr><td>stale</td></tr>',
const form = {
dataset: {},
method: 'post',
action: '',
querySelector(selector) {
if (selector === 'button[type="submit"]') {
return { disabled: true };
}
return null;
},
addEventListener() {}
};
const table = {
const connectionInput = { value: '' };
const deviceInput = { value: '' };
const clientNameInput = { value: '' };
const playerBaseUrlInput = { value: '' };
const targetSelect = { value: 'alpha' };
targetSelect.options = [
{ value: 'alpha', disabled: false },
{ value: 'beta', disabled: false }
];
const row = {
getAttribute(name) {
if (name === 'data-has-actions-column') {
return 'false';
if (name === 'data-client-screen-slug') {
return 'alpha';
}
if (name === 'data-client-id') {
return 'conn-123';
}
if (name === 'data-client-device-id') {
return 'device-123';
}
if (name === 'data-client-player-base-url') {
return 'http://player.local';
}
return '';
},
querySelector(selector) {
if (selector === 'td[data-label="Client"] > div') {
return { textContent: 'Lobby Client' };
}
return null;
}
};
const moveButton = {
closest(selector) {
if (selector === 'button[data-action="move-screen"]') {
return this;
}
if (selector === 'tr[data-client-key]') {
return row;
}
return null;
}
};
const context = {
document: {
body: {
classList: {
toggle() {},
add() {},
remove() {}
}
},
getElementById(id) {
if (id === 'dashboard-clients-table') {
return table;
if (id === 'client-move-screen-modal') {
return modal;
}
if (id === 'dashboard-clients-table-body') {
return tbody;
if (id === 'client-move-screen-form') {
return form;
}
if (id === 'client-move-screen-target') {
return targetSelect;
}
if (id === 'dashboard-clients-table') {
return { getAttribute() { return 'true'; } };
}
return null;
},
querySelector(selector) {
if (selector === '[data-table-search]') {
return searchInput;
if (selector === '[data-client-move-connection-id]') {
return connectionInput;
}
if (selector === '[data-client-move-device-id]') {
return deviceInput;
}
if (selector === '[data-client-move-client-name]') {
return clientNameInput;
}
if (selector === '[data-client-move-player-base-url]') {
return playerBaseUrlInput;
}
return null;
},
querySelectorAll() {
return [];
},
addEventListener(type, handler) {
if (type === 'click') {
this.clickHandler = handler;
}
}
},
window: {
location: {
search: '',
protocol: 'http:',
host: 'example.test'
},
@@ -515,106 +303,11 @@ test('dashboard client refresh respects the live search input before the URL upd
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() {}
pulseModal: {
show(element) {
element.__shown = true;
}
},
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() {},
@@ -627,125 +320,25 @@ test('dashboard client refresh does not rerender while a table search is loading
Math: Math,
Set: Set,
URLSearchParams: URLSearchParams,
FormData: function FormData() {},
setTimeout() {},
clearTimeout() {},
console: console
FormData: function FormData() {}
};
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(typeof context.document.clickHandler, 'function');
context.document.clickHandler({
target: moveButton,
preventDefault() {}
});
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/);
assert.equal(modal.__shown, true);
assert.equal(form.action, '/clients/alpha/commands');
assert.equal(targetSelect.options[0].disabled, true);
assert.equal(targetSelect.options[1].disabled, false);
assert.equal(connectionInput.value, 'conn-123');
assert.equal(deviceInput.value, 'device-123');
assert.equal(clientNameInput.value, 'Lobby Client');
assert.equal(playerBaseUrlInput.value, 'http://player.local');
});
+277 -5
View File
@@ -14,12 +14,33 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
const pool = {
async query(sql) {
if (sql.includes('SELECT slug FROM d_screens ORDER BY slug ASC')) {
return [[{ slug: 'alpha' }, { slug: 'beta' }]];
if (sql.includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url') && sql.includes('WHERE s.slug = ?')) {
return [[{
slug: 'alpha',
player_id: 'player-a',
public_base_url: 'http://player.local',
internal_base_url: 'http://player.internal'
}]];
}
if (sql.includes('SELECT id, name, slug, playlist_id')) {
return [[{ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }]];
}
if (sql.includes('SELECT s.slug, s.player_id, p.public_base_url, p.internal_base_url')) {
return [[
{
slug: 'alpha',
player_id: 'player-a',
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
},
{
slug: 'beta',
player_id: 'player-b',
public_base_url: 'http://player-b.example',
internal_base_url: 'http://player-b.internal'
}
]];
}
if (sql.includes('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, player_id = ?, modified_by = ? WHERE id = ?')) {
return [{ affectedRows: 1 }];
}
@@ -37,7 +58,13 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
fetchDuplicateName: async () => null,
fetchScreenById: async () => ({ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }),
fetchScreenPlayerRecord: async () => ({ public_base_url: 'http://player.local' }),
fetchPlayerPublicBaseUrl: async () => 'http://player.local'
fetchPlayerPublicBaseUrl: async () => 'http://player.local',
fetchPlayerRegistrations: async () => ([
{
public_base_url: 'http://player.local',
internal_base_url: 'http://player.internal'
}
])
},
pages,
getAuditUserId() { return 7; },
@@ -53,11 +80,30 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
calls.push({ kind: 'broadcastDashboardState' });
},
getScreenDeleteBlockMessage: async () => '',
getScreenConnections: async () => [],
getScreenConnections: async (slug) => {
if (slug === 'alpha') {
return {
connections: [
{
id: 'alpha-1',
clientId: 'alpha-1',
deviceId: 'alpha-device',
playerPublicBaseUrl: 'http://player.local/'
}
]
};
}
return { connections: [] };
},
forwardPlayerCommand: async (slug, payload) => {
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
return { ok: true };
},
forwardPlayerCommandToBaseUrl: async (baseUrl, slug, payload) => {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload });
return { ok: true };
},
playerPublicBaseUrl: 'http://player.example',
requirePermission() {
return function (_req, _res, next) {
@@ -91,7 +137,233 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
assert.equal(res.redirectedTo, '/screens?edit=42');
assert.deepEqual(calls, [
{ kind: 'forwardPlayerCommand', slug: 'alpha', payload: { command: 'redirect', url: 'http://player.local/screen/beta' } },
{ kind: 'forwardPlayerCommandToBaseUrl', baseUrl: 'http://player.internal', slug: 'alpha', payload: { command: 'redirect', url: 'http://player.local/screen/beta' } },
{ kind: 'redirectAfterSave', url: '/screens?edit=42' }
]);
});
test('dashboard commands fan out to each connected bridge player', async () => {
const handlers = {};
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
get() {}
};
const calls = [];
registerManageRoutes(app, {
pool: {
async query(sql) {
if (sql.includes('SELECT s.slug') && sql.includes('FROM d_screens s') && sql.includes('ORDER BY s.slug ASC')) {
return [[
{
slug: 'alpha',
},
{
slug: 'beta'
}
]];
}
return [[]];
}
},
common: {
fetchPlayerRegistrations: async () => ([
{
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
},
{
public_base_url: 'http://player-b.example',
internal_base_url: 'http://player-b.internal'
}
])
},
pages: { renderScreenFormPage() {}, renderScreenEditPage() {} },
getAuditUserId() { return 7; },
redirectAfterSave() {},
notifyPlayerScreens: async () => 0,
broadcastDashboardState: async () => {
calls.push({ kind: 'broadcastDashboardState' });
},
getScreenDeleteBlockMessage: async () => '',
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: [] };
},
forwardPlayerCommand: async (slug, payload) => {
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
return { ok: true };
},
forwardPlayerCommandToBaseUrl: async (baseUrl, slug, payload) => {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload });
return { ok: true };
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const routeHandlers = handlers['/commands'];
assert.equal(Array.isArray(routeHandlers), true);
const req = {
body: { command: 'pause' },
query: {}
};
const res = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
}
};
await routeHandlers[1](req, res, () => {});
assert.equal(res.statusCode, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.sent, 2);
assert.deepEqual(
calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl),
['http://player-a.internal', 'http://player-b.internal']
);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
});
test('dashboard commands use live bridge connections when available', async () => {
const handlers = {};
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
get() {}
};
const calls = [];
registerManageRoutes(app, {
pool: {
async query(sql) {
if (sql.includes('SELECT s.slug') && sql.includes('FROM d_screens s') && sql.includes('ORDER BY s.slug ASC')) {
return [[
{
slug: 'alpha',
}
]];
}
return [[]];
}
},
common: {
fetchPlayerRegistrations: async () => ([
{
public_base_url: 'http://player-a.example',
internal_base_url: 'http://player-a.internal'
}
])
},
pages: { renderScreenFormPage() {}, renderScreenEditPage() {} },
getAuditUserId() { return 7; },
redirectAfterSave() {},
notifyPlayerScreens: async () => 0,
broadcastDashboardState: async () => {
calls.push({ kind: 'broadcastDashboardState' });
},
getScreenDeleteBlockMessage: async () => '',
getScreenConnections: async (slug) => {
if (slug !== 'alpha') {
return { connections: [] };
}
return {
connections: [
{
id: 'alpha-1',
clientId: 'alpha-1',
deviceId: 'alpha-device',
playerPublicBaseUrl: 'http://player-a.example/'
}
]
};
},
forwardPlayerCommand: async (slug, payload) => {
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
return { ok: true };
},
forwardPlayerCommandToBaseUrl: async (baseUrl, slug, payload) => {
calls.push({ kind: 'forwardPlayerCommandToBaseUrl', baseUrl, slug, payload });
return { ok: true };
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const routeHandlers = handlers['/commands'];
assert.equal(Array.isArray(routeHandlers), true);
const req = {
body: { command: 'reload' },
query: {}
};
const res = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
}
};
await routeHandlers[1](req, res, () => {});
assert.equal(res.statusCode, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.sent, 1);
assert.deepEqual(
calls.filter((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl').map((entry) => entry.baseUrl),
['http://player-a.internal']
);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand'), false);
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
});
+33
View File
@@ -0,0 +1,33 @@
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');
function loadScript(scriptPath, sandbox) {
const source = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(source, sandbox, { filename: scriptPath });
}
test('client row keys prefer the client name', () => {
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console
};
sandbox.window = sandbox;
loadScript(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'web-ui-helpers.js'), sandbox);
const helpers = sandbox.window.webUiHelpers;
assert.equal(helpers.getClientRowKey({ client_name: 'Conference Left', screen_slug: 'alpha', deviceId: 'device-123', id: 'conn-1', clientId: 'client-1' }), 'Conference Left');
assert.equal(helpers.getClientRowKey({ client_name: 'Conference Right', screen_slug: 'beta', deviceId: 'device-123', id: 'conn-2', clientId: 'client-2' }), 'Conference Right');
assert.equal(helpers.getClientRowKey({ screen_slug: 'alpha', id: 'conn-1', clientId: 'client-1' }), 'alpha');
assert.equal(helpers.getClientRowKey({ clientId: 'client-1' }), 'client-1');
});