Add multi-player and remote bridge support
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const { createDashboardStateService } = require('../src/web/lib/dashboard-state');
|
||||
|
||||
test('dashboard state counts active players from the registry heartbeat', async () => {
|
||||
const service = createDashboardStateService({
|
||||
pool: {
|
||||
async query(sql) {
|
||||
if (String(sql || '').includes('COUNT(*) AS connected_count')) {
|
||||
return [[{ connected_count: 6 }]];
|
||||
}
|
||||
if (String(sql || '').includes('SELECT s.slug, pod.device_id, pod.client_name')) {
|
||||
return [[]];
|
||||
}
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
common: {
|
||||
async fetchAdminData() {
|
||||
return {
|
||||
screens: [
|
||||
{
|
||||
slug: 'alpha',
|
||||
name: 'Alpha',
|
||||
playlist_name: 'Main Loop'
|
||||
}
|
||||
],
|
||||
playlists: [],
|
||||
slides: []
|
||||
};
|
||||
},
|
||||
async fetchScreenPlayerUrls() {
|
||||
return {
|
||||
alpha: 'http://player.local/screen/alpha'
|
||||
};
|
||||
},
|
||||
async fetchPlayerRegistrations() {
|
||||
return [
|
||||
{
|
||||
identifier: 'player-alpha',
|
||||
public_base_url: 'http://player.local',
|
||||
last_seen_at: new Date(Date.now() - 10 * 1000)
|
||||
},
|
||||
{
|
||||
identifier: 'player-stale',
|
||||
public_base_url: 'http://stale.player.local',
|
||||
last_seen_at: new Date(Date.now() - 10 * 60 * 1000)
|
||||
}
|
||||
];
|
||||
}
|
||||
},
|
||||
playerSnapshotCache: new Map([
|
||||
['alpha', { count: 1, connections: [{ id: 1, playerPublicBaseUrl: 'http://player.local' }] }]
|
||||
]),
|
||||
playerSnapshotSockets: new Map(),
|
||||
ensurePlayerSnapshotSubscription() {},
|
||||
formatDashboardDate(value) {
|
||||
return String(value || '');
|
||||
}
|
||||
});
|
||||
const state = await service.buildDashboardState();
|
||||
|
||||
assert.equal(state.connectedPlayersCount, 6);
|
||||
assert.equal(state.connectedClientsCount, 1);
|
||||
assert.equal(state.clients[0].player_identifier, 'player-alpha');
|
||||
assert.equal(state.kioskPlayers.length, 1);
|
||||
assert.deepEqual(state.kioskPlayers[0], {
|
||||
player_identifier: 'player-alpha',
|
||||
player_url: 'http://player.local'
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const { createPageAuthToken } = require('../src/request-auth');
|
||||
const { registerPlayerOnboardingRoutes } = require('../src/player/onboarding');
|
||||
|
||||
const originalSharedSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET;
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
test.after(() => {
|
||||
if (originalSharedSecret === undefined) {
|
||||
delete process.env.PULSE_SIGNAGE_SHARED_SECRET;
|
||||
} else {
|
||||
process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSharedSecret;
|
||||
}
|
||||
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function createAppAndHandlers() {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
use() {},
|
||||
get(path, ...routeHandlers) {
|
||||
handlers[path] = function (req, res) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const runHandler = (index) => {
|
||||
const handler = routeHandlers[index];
|
||||
if (!handler) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let nextCalled = false;
|
||||
const next = function () {
|
||||
nextCalled = true;
|
||||
return runHandler(index + 1);
|
||||
};
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = handler(req, res, next);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve(result).then(function (value) {
|
||||
if (!nextCalled) {
|
||||
resolve(value);
|
||||
}
|
||||
}, reject);
|
||||
};
|
||||
|
||||
runHandler(0);
|
||||
});
|
||||
};
|
||||
},
|
||||
post() {}
|
||||
};
|
||||
|
||||
return { app, handlers };
|
||||
}
|
||||
|
||||
function createResponse() {
|
||||
const headers = {};
|
||||
return {
|
||||
headers,
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
set(name, value) {
|
||||
headers[name] = value;
|
||||
return this;
|
||||
},
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
type(value) {
|
||||
headers['Content-Type'] = value;
|
||||
return this;
|
||||
},
|
||||
json(value) {
|
||||
this.body = value;
|
||||
return this;
|
||||
},
|
||||
send(value) {
|
||||
this.body = value;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function registerThinClientRoutes(fetchImpl) {
|
||||
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'bridge-secret';
|
||||
global.fetch = fetchImpl;
|
||||
|
||||
const { app, handlers } = createAppAndHandlers();
|
||||
registerPlayerOnboardingRoutes(app, {
|
||||
app,
|
||||
common: {
|
||||
renderPlayerOnboardingLandingPage() {
|
||||
return 'landing';
|
||||
},
|
||||
renderPlayerOnboardingFormPage() {
|
||||
return 'form';
|
||||
}
|
||||
},
|
||||
thinClientBaseUrl: 'http://bridge.test',
|
||||
playerPublicBaseUrl: 'http://public.test'
|
||||
});
|
||||
|
||||
return handlers;
|
||||
}
|
||||
|
||||
test('remote onboarding status proxies to the bridge and rewrites playerUrl', async () => {
|
||||
const fetchCalls = [];
|
||||
const handlers = registerThinClientRoutes(async (url, init) => {
|
||||
fetchCalls.push({ url, init });
|
||||
return {
|
||||
status: 200,
|
||||
headers: {
|
||||
get(name) {
|
||||
return String(name || '').toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null;
|
||||
}
|
||||
},
|
||||
async json() {
|
||||
return {
|
||||
deviceId: 'device123',
|
||||
onboarded: true,
|
||||
clientName: 'Lobby Player',
|
||||
screenId: 7,
|
||||
screenSlug: 'main-screen',
|
||||
screenName: 'Main Screen',
|
||||
playerUrl: null
|
||||
};
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const token = createPageAuthToken({ scope: 'onboarding', deviceId: 'device123' });
|
||||
const res = createResponse();
|
||||
|
||||
await handlers['/api/onboarding/status']({
|
||||
headers: {
|
||||
'x-pulse-page-auth': token
|
||||
},
|
||||
query: {
|
||||
deviceId: 'device 123!?'
|
||||
},
|
||||
socket: {}
|
||||
}, res);
|
||||
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(fetchCalls[0].url, 'http://bridge.test/api/onboarding/status?deviceId=device123');
|
||||
assert.equal(fetchCalls[0].init.method, 'GET');
|
||||
assert.equal(fetchCalls[0].init.headers['x-pulse-page-auth'], token);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.body.playerUrl, 'http://public.test/screen/main-screen');
|
||||
assert.equal(res.body.screenSlug, 'main-screen');
|
||||
});
|
||||
|
||||
test('remote onboarding status returns 502 when the bridge is unavailable', async () => {
|
||||
const handlers = registerThinClientRoutes(async () => {
|
||||
return null;
|
||||
});
|
||||
|
||||
const token = createPageAuthToken({ scope: 'player', deviceId: 'device123' });
|
||||
const res = createResponse();
|
||||
|
||||
await handlers['/api/onboarding/status']({
|
||||
headers: {
|
||||
'x-pulse-page-auth': token
|
||||
},
|
||||
query: {
|
||||
deviceId: 'device123'
|
||||
},
|
||||
socket: {}
|
||||
}, res);
|
||||
|
||||
assert.equal(res.statusCode, 502);
|
||||
assert.deepEqual(res.body, { error: 'Player bridge unavailable.' });
|
||||
});
|
||||
|
||||
test('remote onboarding screens proxy preserves the bridge response body', async () => {
|
||||
const fetchCalls = [];
|
||||
const handlers = registerThinClientRoutes(async (url, init) => {
|
||||
fetchCalls.push({ url, init });
|
||||
return {
|
||||
status: 200,
|
||||
headers: {
|
||||
get(name) {
|
||||
return String(name || '').toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null;
|
||||
}
|
||||
},
|
||||
async text() {
|
||||
return JSON.stringify({ screens: [{ id: 1, name: 'Lobby', slug: 'lobby' }] });
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const token = createPageAuthToken({ scope: 'onboarding', deviceId: 'device123' });
|
||||
const res = createResponse();
|
||||
|
||||
await handlers['/api/onboarding/screens']({
|
||||
headers: {
|
||||
'x-pulse-page-auth': token
|
||||
},
|
||||
query: {},
|
||||
socket: {}
|
||||
}, res);
|
||||
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(fetchCalls[0].url, 'http://bridge.test/api/onboarding/screens');
|
||||
assert.equal(fetchCalls[0].init.method, 'GET');
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['Content-Type'], 'application/json; charset=utf-8');
|
||||
assert.equal(res.body, JSON.stringify({ screens: [{ id: 1, name: 'Lobby', slug: 'lobby' }] }));
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { normalizeIdentifier, recordPlayerHeartbeat, upsertPlayerRegistration } = require('../src/data/player-registry');
|
||||
|
||||
test('normalizeIdentifier trims and limits friendly player labels', () => {
|
||||
assert.equal(normalizeIdentifier(' Shop2 '), 'Shop2');
|
||||
assert.equal(normalizeIdentifier(''), '');
|
||||
assert.equal(normalizeIdentifier('x'.repeat(300)).length, 255);
|
||||
});
|
||||
|
||||
test('player registry upserts include the identifier field', async () => {
|
||||
const calls = [];
|
||||
const pool = {
|
||||
async query(sql, params) {
|
||||
calls.push({ sql, params });
|
||||
if (String(sql || '').includes('information_schema.COLUMNS')) {
|
||||
return [[{ column_count: 1 }]];
|
||||
}
|
||||
if (String(sql || '').includes('INSERT INTO d_players')) {
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
if (String(sql || '').includes('SELECT id, identifier, public_base_url, internal_base_url, last_seen_at')) {
|
||||
return [[{
|
||||
id: 1,
|
||||
identifier: 'Shop2',
|
||||
public_base_url: 'http://player.local',
|
||||
internal_base_url: 'http://player:8081',
|
||||
last_seen_at: '2026-08-07 00:00:00'
|
||||
}]];
|
||||
}
|
||||
return [[]];
|
||||
}
|
||||
};
|
||||
|
||||
const registration = await upsertPlayerRegistration(pool, {
|
||||
deviceId: 'device-01',
|
||||
identifier: 'Shop2',
|
||||
publicBaseUrl: 'http://player.local/',
|
||||
internalBaseUrl: 'http://player:8081/'
|
||||
});
|
||||
|
||||
const heartbeat = await recordPlayerHeartbeat(pool, {
|
||||
deviceId: 'device-01',
|
||||
identifier: 'Shop2',
|
||||
publicBaseUrl: 'http://player.local/',
|
||||
internalBaseUrl: 'http://player:8081/'
|
||||
});
|
||||
|
||||
assert.deepEqual(registration, {
|
||||
id: 1,
|
||||
identifier: 'Shop2',
|
||||
public_base_url: 'http://player.local',
|
||||
internal_base_url: 'http://player:8081',
|
||||
last_seen_at: '2026-08-07 00:00:00'
|
||||
});
|
||||
assert.deepEqual(heartbeat, registration);
|
||||
assert.ok(calls.some(function (call) {
|
||||
return /INSERT INTO d_players/.test(String(call.sql || '')) && /identifier/.test(String(call.sql || ''));
|
||||
}));
|
||||
assert.ok(calls.some(function (call) {
|
||||
return /SELECT .*identifier/.test(String(call.sql || ''));
|
||||
}));
|
||||
assert.deepEqual((calls.find(function (call) {
|
||||
return /INSERT INTO d_players/.test(String(call.sql || ''));
|
||||
}) || {}).params, ['Shop2', 'http://player.local', 'http://player:8081']);
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
|
||||
test('system status shows connected player count in the live feed label', () => {
|
||||
const script = fs.readFileSync(require.resolve('../src/web/public/js/system-status.js'), 'utf8');
|
||||
const elements = {
|
||||
'sidebar-status-dot': {
|
||||
classList: {
|
||||
remove() {},
|
||||
add() {}
|
||||
},
|
||||
setAttribute() {}
|
||||
},
|
||||
'sidebar-status-text': {
|
||||
textContent: ''
|
||||
}
|
||||
};
|
||||
const sockets = [];
|
||||
|
||||
const context = {
|
||||
document: {
|
||||
getElementById(id) {
|
||||
return elements[id] || null;
|
||||
}
|
||||
},
|
||||
window: {
|
||||
WebSocket: true,
|
||||
location: {
|
||||
protocol: 'http:',
|
||||
host: 'example.test'
|
||||
},
|
||||
setTimeout() {
|
||||
return 1;
|
||||
},
|
||||
clearTimeout() {},
|
||||
webHandleDashboardState() {}
|
||||
},
|
||||
WebSocket: function MockWebSocket(url) {
|
||||
this.url = url;
|
||||
sockets.push(this);
|
||||
this.close = function () {};
|
||||
},
|
||||
JSON: JSON,
|
||||
Number: Number,
|
||||
String: String,
|
||||
Boolean: Boolean,
|
||||
Array: Array,
|
||||
Object: Object,
|
||||
Math: Math,
|
||||
setTimeout() {},
|
||||
clearTimeout() {},
|
||||
console: console
|
||||
};
|
||||
context.window.document = context.document;
|
||||
context.window.WebSocket = context.WebSocket;
|
||||
|
||||
vm.runInNewContext(script, context);
|
||||
|
||||
assert.equal(sockets.length, 1);
|
||||
sockets[0].onmessage({
|
||||
data: JSON.stringify({
|
||||
type: 'dashboard-state',
|
||||
state: {
|
||||
screens: [{ slug: 'alpha' }],
|
||||
playerServiceConnected: true,
|
||||
connectedPlayersCount: 4
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
assert.equal(elements['sidebar-status-text'].textContent, 'Live player feed - 4 players connected');
|
||||
});
|
||||
|
||||
test('system status shows an explicit idle label when no players are connected', () => {
|
||||
const script = fs.readFileSync(require.resolve('../src/web/public/js/system-status.js'), 'utf8');
|
||||
const elements = {
|
||||
'sidebar-status-dot': {
|
||||
classList: {
|
||||
remove() {},
|
||||
add() {}
|
||||
},
|
||||
setAttribute() {}
|
||||
},
|
||||
'sidebar-status-text': {
|
||||
textContent: ''
|
||||
}
|
||||
};
|
||||
const sockets = [];
|
||||
|
||||
const context = {
|
||||
document: {
|
||||
getElementById(id) {
|
||||
return elements[id] || null;
|
||||
}
|
||||
},
|
||||
window: {
|
||||
WebSocket: true,
|
||||
location: {
|
||||
protocol: 'http:',
|
||||
host: 'example.test'
|
||||
},
|
||||
setTimeout() {
|
||||
return 1;
|
||||
},
|
||||
clearTimeout() {},
|
||||
webHandleDashboardState() {}
|
||||
},
|
||||
WebSocket: function MockWebSocket(url) {
|
||||
this.url = url;
|
||||
sockets.push(this);
|
||||
this.close = function () {};
|
||||
},
|
||||
JSON: JSON,
|
||||
Number: Number,
|
||||
String: String,
|
||||
Boolean: Boolean,
|
||||
Array: Array,
|
||||
Object: Object,
|
||||
Math: Math,
|
||||
setTimeout() {},
|
||||
clearTimeout() {},
|
||||
console: console
|
||||
};
|
||||
context.window.document = context.document;
|
||||
context.window.WebSocket = context.WebSocket;
|
||||
|
||||
vm.runInNewContext(script, context);
|
||||
|
||||
assert.equal(sockets.length, 1);
|
||||
sockets[0].onmessage({
|
||||
data: JSON.stringify({
|
||||
type: 'dashboard-state',
|
||||
state: {
|
||||
screens: [{ slug: 'alpha' }],
|
||||
playerServiceConnected: true,
|
||||
connectedPlayersCount: 0
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
assert.equal(elements['sidebar-status-text'].textContent, 'Player feed online - no players connected');
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
@@ -16,12 +18,203 @@ test('dashboard onboarding link uses the player base url', () => {
|
||||
playlists: [],
|
||||
clients: [],
|
||||
slides: [],
|
||||
connectedClientsCount: 0
|
||||
connectedClientsCount: 0,
|
||||
connectedPlayersCount: 0
|
||||
},
|
||||
'',
|
||||
null
|
||||
{ id: 1, permissions: ['screens.read', 'clients.read'] }
|
||||
);
|
||||
|
||||
assert.match(html, /href="http:\/\/player\.local"/);
|
||||
assert.doesNotMatch(html, /http:\/\/player\.local\//);
|
||||
});
|
||||
});
|
||||
|
||||
test('dashboard screen snapshot omits player links and duplicate connection counts', () => {
|
||||
const html = renderDashboardPage(
|
||||
{
|
||||
screens: [
|
||||
{
|
||||
id: 7,
|
||||
name: 'Demo Conference',
|
||||
slug: 'demo-conference',
|
||||
playlist_name: 'Main Loop',
|
||||
player_connection_count: 3,
|
||||
public_base_url: 'http://player.local/'
|
||||
}
|
||||
],
|
||||
playlists: [],
|
||||
clients: [],
|
||||
slides: [],
|
||||
connectedClientsCount: 3,
|
||||
connectedPlayersCount: 3
|
||||
},
|
||||
'',
|
||||
{ id: 1, permissions: ['screens.read', 'clients.read'] }
|
||||
);
|
||||
|
||||
assert.match(html, /Screen group snapshot/);
|
||||
assert.match(html, /players/);
|
||||
assert.doesNotMatch(html, /dashboard-screen-link/);
|
||||
assert.doesNotMatch(html, /Connections<\/dt>/);
|
||||
assert.match(html, /3\s+live/);
|
||||
assert.doesNotMatch(html, />3 connected/);
|
||||
});
|
||||
|
||||
test('dashboard kiosk launcher includes connected player choices', () => {
|
||||
const html = renderDashboardPage(
|
||||
{
|
||||
screens: [],
|
||||
playlists: [],
|
||||
kioskPlayers: [
|
||||
{
|
||||
player_identifier: 'player-alpha',
|
||||
player_url: 'http://player-a.example'
|
||||
},
|
||||
{
|
||||
player_identifier: 'player-beta',
|
||||
player_url: 'http://player-b.example'
|
||||
}
|
||||
],
|
||||
slides: [],
|
||||
connectedClientsCount: 1,
|
||||
connectedPlayersCount: 1
|
||||
},
|
||||
'',
|
||||
{ id: 1, permissions: ['screens.read', 'clients.read', 'dashboard.allow'] }
|
||||
);
|
||||
|
||||
assert.match(html, /dashboard-kiosk-launcher-player-select/);
|
||||
assert.match(html, /<option value="http:\/\/player-a\.example" data-player-identifier="player-alpha">player-alpha - http:\/\/player-a\.example<\/option>/);
|
||||
assert.match(html, /<option value="http:\/\/player-b\.example" data-player-identifier="player-beta">player-beta - http:\/\/player-b\.example<\/option>/);
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -36,7 +36,8 @@ test('screen update redirects and forwards redirect when the slug changes', asyn
|
||||
uniqueScreenSlug: async () => 'beta',
|
||||
fetchDuplicateName: async () => null,
|
||||
fetchScreenById: async () => ({ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }),
|
||||
fetchScreenPlayerRecord: async () => ({ public_base_url: 'http://player.local' })
|
||||
fetchScreenPlayerRecord: async () => ({ public_base_url: 'http://player.local' }),
|
||||
fetchPlayerPublicBaseUrl: async () => 'http://player.local'
|
||||
},
|
||||
pages,
|
||||
getAuditUserId() { return 7; },
|
||||
|
||||
@@ -34,10 +34,11 @@ test('screen edit page includes shared launcher downloads and base player url',
|
||||
async fetchScreenEditData() {
|
||||
return { playlists: [] };
|
||||
},
|
||||
async fetchScreenPlayerUrls() {
|
||||
return {
|
||||
'demo-conference': 'http://player.example/screen/demo-conference'
|
||||
};
|
||||
async fetchPlayerRegistrations() {
|
||||
return [
|
||||
{ identifier: 'player-alpha', public_base_url: 'http://alpha.example' },
|
||||
{ identifier: 'player-beta', public_base_url: 'http://beta.example/' }
|
||||
];
|
||||
},
|
||||
async fetchPlayerPublicBaseUrl() {
|
||||
return 'http://player.example';
|
||||
@@ -71,7 +72,97 @@ test('screen edit page includes shared launcher downloads and base player url',
|
||||
await handler[1]({ params: { id: '7' }, query: {}, currentUser: { id: 1 } }, res, () => {});
|
||||
|
||||
assert.equal(res.body, 'ok');
|
||||
assert.deepEqual(renderedArgs.screen.player_url, 'http://player.example/screen/demo-conference');
|
||||
assert.deepEqual(renderedArgs.screen.player_urls, [
|
||||
{
|
||||
identifier: 'player-alpha',
|
||||
public_base_url: 'http://alpha.example',
|
||||
player_url: 'http://alpha.example/screen/demo-conference'
|
||||
},
|
||||
{
|
||||
identifier: 'player-beta',
|
||||
public_base_url: 'http://beta.example',
|
||||
player_url: 'http://beta.example/screen/demo-conference'
|
||||
}
|
||||
]);
|
||||
assert.deepEqual(renderedArgs.screen.launcher_downloads, {
|
||||
windows: '/downloads/kiosk/pulse-signage-kiosk.bat',
|
||||
linux: '/downloads/kiosk/pulse-signage-kiosk.sh'
|
||||
});
|
||||
});
|
||||
|
||||
test('screen edit query route includes player urls for every registration', async () => {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
get(path, ...routeHandlers) {
|
||||
handlers[path] = routeHandlers;
|
||||
}
|
||||
};
|
||||
|
||||
let renderedArgs = null;
|
||||
const pages = {
|
||||
renderScreenEditPage(screen, data, message, currentUser) {
|
||||
renderedArgs = { screen, data, message, currentUser };
|
||||
return 'ok';
|
||||
}
|
||||
};
|
||||
|
||||
registerScreensRoutes(app, {
|
||||
pool: {
|
||||
async query() {
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
common: {
|
||||
async fetchScreenById() {
|
||||
return { id: 7, name: 'Demo Conference', slug: 'demo-conference', playlist_id: null };
|
||||
},
|
||||
async fetchScreenEditData() {
|
||||
return { playlists: [] };
|
||||
},
|
||||
async fetchPlayerRegistrations() {
|
||||
return [
|
||||
{ identifier: 'player-alpha', public_base_url: 'http://alpha.example' }
|
||||
];
|
||||
},
|
||||
async fetchPlayerPublicBaseUrl() {
|
||||
return 'http://player.example';
|
||||
}
|
||||
},
|
||||
pages,
|
||||
buildDashboardState: async () => ({ screens: [] }),
|
||||
getScreenDeleteBlockMessage: async () => '',
|
||||
getScreenConnections: async () => [],
|
||||
playerPublicBaseUrl: 'http://player.example',
|
||||
requirePermission() {
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const handler = handlers['/screens'];
|
||||
assert.equal(Array.isArray(handler), true);
|
||||
|
||||
const res = {
|
||||
send(value) {
|
||||
this.body = value;
|
||||
},
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
await handler[1]({ query: { edit: '7' }, currentUser: { id: 1 } }, res, () => {});
|
||||
|
||||
assert.equal(res.body, 'ok');
|
||||
assert.deepEqual(renderedArgs.screen.player_urls, [
|
||||
{
|
||||
identifier: 'player-alpha',
|
||||
public_base_url: 'http://alpha.example',
|
||||
player_url: 'http://alpha.example/screen/demo-conference'
|
||||
}
|
||||
]);
|
||||
assert.deepEqual(renderedArgs.screen.launcher_downloads, {
|
||||
windows: '/downloads/kiosk/pulse-signage-kiosk.bat',
|
||||
linux: '/downloads/kiosk/pulse-signage-kiosk.sh'
|
||||
@@ -150,4 +241,72 @@ test('screen launcher downloads are shared and attached', async () => {
|
||||
assert.match(res.headers['content-disposition'], /pulse-signage-kiosk\.bat/);
|
||||
assert.match(res.body, /TARGET_URL=http:\/\/player\.example/);
|
||||
assert.match(res.body, /--kiosk/);
|
||||
});
|
||||
|
||||
test('screen launcher downloads can target an explicit player url', async () => {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
get(path, ...routeHandlers) {
|
||||
handlers[path] = routeHandlers;
|
||||
}
|
||||
};
|
||||
|
||||
registerScreensRoutes(app, {
|
||||
pool: {
|
||||
async query() {
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
common: {
|
||||
async fetchScreenById() {
|
||||
return { id: 7, name: 'Demo Conference', slug: 'demo-conference', playlist_id: null };
|
||||
},
|
||||
async fetchScreenEditData() {
|
||||
return { playlists: [] };
|
||||
},
|
||||
async fetchPlayerPublicBaseUrl() {
|
||||
return 'http://player.example';
|
||||
}
|
||||
},
|
||||
pages: { renderScreenEditPage() { return 'ok'; } },
|
||||
buildDashboardState: async () => ({ screens: [] }),
|
||||
getScreenDeleteBlockMessage: async () => '',
|
||||
getScreenConnections: async () => [],
|
||||
playerPublicBaseUrl: 'http://player.example',
|
||||
requirePermission() {
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const windowsHandler = handlers['/downloads/kiosk/pulse-signage-kiosk.bat'];
|
||||
assert.equal(Array.isArray(windowsHandler), true);
|
||||
|
||||
const res = {
|
||||
headers: {},
|
||||
statusCode: 200,
|
||||
set(name, value) {
|
||||
this.headers[name.toLowerCase()] = value;
|
||||
},
|
||||
attachment(filename) {
|
||||
this.headers['content-disposition'] = `attachment; filename="${filename}"`;
|
||||
},
|
||||
type(value) {
|
||||
this.headers['content-type'] = value;
|
||||
},
|
||||
send(value) {
|
||||
this.body = value;
|
||||
},
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
await windowsHandler[1]({ query: { playerUrl: 'http://player-b.example/screen/demo-conference' } }, res, () => {});
|
||||
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.match(res.body, /TARGET_URL=http:\/\/player-b\.example\/screen\/demo-conference/);
|
||||
assert.doesNotMatch(res.body, /TARGET_URL=http:\/\/player\.example/);
|
||||
});
|
||||
Reference in New Issue
Block a user