Release 2.6.7
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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, [{}]);
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -108,7 +108,7 @@ function registerThinClientRoutes(fetchImpl) {
|
||||
return 'form';
|
||||
}
|
||||
},
|
||||
thinClientBaseUrl: 'http://bridge.test',
|
||||
bridgeBaseUrl: 'http://bridge.test',
|
||||
playerPublicBaseUrl: 'http://public.test'
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user