Refine player control-plane flow
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const registerScreenCommandRoutes = require('../src/web/routes/admin/client-commands');
|
||||
|
||||
function createHandlers() {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
post(path, ...routeHandlers) {
|
||||
handlers[path] = routeHandlers;
|
||||
}
|
||||
};
|
||||
|
||||
return { app, handlers };
|
||||
}
|
||||
|
||||
test('move client rebinding redirects the live player to the target screen', async () => {
|
||||
const calls = [];
|
||||
const { app, handlers } = createHandlers();
|
||||
|
||||
registerScreenCommandRoutes(app, {
|
||||
pool: {
|
||||
async query(sql, params) {
|
||||
calls.push({ kind: 'query', sql, params });
|
||||
|
||||
if (sql.includes('SELECT slug FROM d_screens ORDER BY slug ASC')) {
|
||||
return [[{ slug: 'source-screen' }, { slug: 'target-screen' }]];
|
||||
}
|
||||
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' }]];
|
||||
}
|
||||
if (sql.includes('SELECT d.client_name, s.slug AS current_screen_slug')) {
|
||||
return [[{ client_name: 'Lobby Client', current_screen_slug: 'source-screen' }]];
|
||||
}
|
||||
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'target-screen') {
|
||||
return [[{ id: 27, name: 'Target Screen', slug: 'target-screen' }]];
|
||||
}
|
||||
if (sql.includes('INSERT INTO d_onboarding_devices')) {
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
common: {},
|
||||
forwardPlayerCommand(slug, payload, connectionId) {
|
||||
calls.push({ kind: 'forwardPlayerCommand', slug, payload, connectionId });
|
||||
return { ok: true };
|
||||
},
|
||||
getScreenConnections: async (slug) => {
|
||||
if (slug === 'source-screen') {
|
||||
return {
|
||||
connections: [
|
||||
{
|
||||
id: 'conn-1',
|
||||
clientId: 'conn-1',
|
||||
deviceId: 'device-123',
|
||||
playerPublicBaseUrl: 'http://remote-player.example'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return { connections: [] };
|
||||
},
|
||||
isClientNameAvailable: async () => true,
|
||||
withClientNameReservation: async (_pool, _name, callback) => callback(),
|
||||
broadcastDashboardState: async () => {
|
||||
calls.push({ kind: 'broadcastDashboardState' });
|
||||
},
|
||||
requirePermission() {
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const routeHandlers = handlers['/clients/:slug/commands'];
|
||||
assert.equal(Array.isArray(routeHandlers), true);
|
||||
|
||||
const response = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(value) {
|
||||
this.body = value;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
await routeHandlers[1]({
|
||||
params: { slug: 'source-screen' },
|
||||
body: {
|
||||
command: 'moveclient',
|
||||
deviceId: 'device-123',
|
||||
clientName: 'Lobby Client',
|
||||
targetScreenSlug: 'target-screen',
|
||||
connectionId: 'conn-1'
|
||||
},
|
||||
query: {}
|
||||
}, response, () => {});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.ok, true);
|
||||
assert.equal(response.body.targetScreenSlug, 'target-screen');
|
||||
assert.equal(response.body.playerUrl, 'http://remote-player.example/screen/target-screen');
|
||||
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
|
||||
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand' && entry.payload && entry.payload.command === 'redirect'), true);
|
||||
});
|
||||
|
||||
test('screen control commands can target all screens', async () => {
|
||||
const calls = [];
|
||||
const { app, handlers } = createHandlers();
|
||||
|
||||
registerScreenCommandRoutes(app, {
|
||||
pool: {
|
||||
async query(sql) {
|
||||
calls.push({ kind: 'query', sql });
|
||||
|
||||
if (String(sql || '').includes('SELECT id, name, slug FROM d_screens WHERE slug IS NOT NULL ORDER BY slug ASC')) {
|
||||
return [[
|
||||
{ id: 1, name: 'Alpha', slug: 'alpha' },
|
||||
{ id: 2, name: 'Beta', slug: 'beta' }
|
||||
]];
|
||||
}
|
||||
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
common: {},
|
||||
forwardPlayerCommand(slug, payload) {
|
||||
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
|
||||
return { ok: true };
|
||||
},
|
||||
getScreenConnections: async () => ({ connections: [] }),
|
||||
isClientNameAvailable: async () => true,
|
||||
withClientNameReservation: async (_pool, _name, callback) => callback(),
|
||||
broadcastDashboardState: async () => {
|
||||
calls.push({ kind: 'broadcastDashboardState' });
|
||||
},
|
||||
requirePermission() {
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const routeHandlers = handlers['/clients/:slug/commands'];
|
||||
assert.equal(Array.isArray(routeHandlers), true);
|
||||
|
||||
const response = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(value) {
|
||||
this.body = value;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
await routeHandlers[1]({
|
||||
params: { slug: '__all__' },
|
||||
body: {
|
||||
command: 'pause',
|
||||
paused: 'true'
|
||||
},
|
||||
query: {}
|
||||
}, response, () => {});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.ok, true);
|
||||
assert.equal(response.body.allScreens, true);
|
||||
assert.equal(response.body.targetScreenCount, 2);
|
||||
assert.deepEqual(calls.filter((entry) => entry.kind === 'forwardPlayerCommand').map((entry) => entry.slug), ['alpha', 'beta']);
|
||||
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
|
||||
});
|
||||
@@ -18,7 +18,10 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
|
||||
status: 'completed',
|
||||
createdAt: '2026-08-04T10:15:00.000Z',
|
||||
startedAt: '2026-08-04T10:16:00.000Z',
|
||||
finishedAt: '2026-08-04T10:17:00.000Z'
|
||||
finishedAt: '2026-08-04T10:17:00.000Z',
|
||||
metadata: {
|
||||
playerLabel: 'Player Alpha'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Older task',
|
||||
@@ -53,7 +56,10 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
|
||||
key: 'refresh',
|
||||
intervalMs: 60000,
|
||||
nextRunAt: '2026-08-04T10:15:00.000Z',
|
||||
lastRunAt: '2026-08-04T09:15:00.000Z'
|
||||
lastRunAt: '2026-08-04T09:15:00.000Z',
|
||||
metadata: {
|
||||
playerIdentifier: 'Player Beta'
|
||||
}
|
||||
}
|
||||
],
|
||||
summary: { counts: {}, total: 1, activeCount: 0, scheduledCount: 1 }
|
||||
@@ -66,7 +72,9 @@ test('background task pages opt into 24-hour timestamps, confirm clearing tasks,
|
||||
|
||||
assert.match(queueHtml, /data-confirm-message="Clear finished background tasks\?"/);
|
||||
assert.match(queueHtml, /data-local-datetime-format="24h"/);
|
||||
assert.match(queueHtml, /Player Alpha:secret-key/);
|
||||
assert.doesNotMatch(queueKeySearchHtml, /Example task/);
|
||||
assert.match(queueDateSearchHtml, /Example task/);
|
||||
assert.match(scheduledHtml, /data-local-datetime-format="24h"/);
|
||||
assert.match(scheduledHtml, /Player:\s+Player Beta/);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const { createPlayerActionService } = require('../src/web/lib/player-actions');
|
||||
|
||||
test('player actions prefer a remote FQDN registration over a local player target', async () => {
|
||||
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 };
|
||||
},
|
||||
async text() {
|
||||
return JSON.stringify({ ok: true });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
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'
|
||||
}
|
||||
]];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await playerActionService.forwardPlayerCommand('demo', { command: 'refresh' });
|
||||
|
||||
assert.deepEqual(response, { ok: true });
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/screens/demo/commands');
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const originalWebBaseUrl = process.env.WEB_BASE_URL;
|
||||
const { resolveWebBaseUrl } = require('../src/player-bridge/index');
|
||||
|
||||
test.after(() => {
|
||||
if (originalWebBaseUrl === undefined) {
|
||||
delete process.env.WEB_BASE_URL;
|
||||
} else {
|
||||
process.env.WEB_BASE_URL = originalWebBaseUrl;
|
||||
}
|
||||
});
|
||||
|
||||
test('resolveWebBaseUrl prefers WEB_BASE_URL', () => {
|
||||
process.env.WEB_BASE_URL = 'https://web.example.test/app/';
|
||||
|
||||
const resolved = resolveWebBaseUrl({
|
||||
headers: {
|
||||
host: 'bridge.example.test',
|
||||
'x-forwarded-host': 'bridge.example.test',
|
||||
'x-forwarded-proto': 'https'
|
||||
},
|
||||
socket: { encrypted: true }
|
||||
});
|
||||
|
||||
assert.equal(resolved, 'https://web.example.test/app');
|
||||
});
|
||||
|
||||
test('resolveWebBaseUrl keeps external https hosts on the default port', () => {
|
||||
delete process.env.WEB_BASE_URL;
|
||||
|
||||
const resolved = resolveWebBaseUrl({
|
||||
headers: {
|
||||
host: 'bridge.example.test',
|
||||
'x-forwarded-host': 'bridge.example.test',
|
||||
'x-forwarded-proto': 'https'
|
||||
},
|
||||
socket: { encrypted: true }
|
||||
});
|
||||
|
||||
assert.equal(resolved, 'https://bridge.example.test');
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
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 });
|
||||
}
|
||||
|
||||
function createStyleStore() {
|
||||
const values = Object.create(null);
|
||||
return {
|
||||
values,
|
||||
getPropertyValue(name) {
|
||||
return values[name] || '';
|
||||
},
|
||||
setProperty(name, value) {
|
||||
values[name] = String(value || '');
|
||||
},
|
||||
removeProperty(name) {
|
||||
delete values[name];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('primeSlideMarkup restores the current canvas dimensions', () => {
|
||||
const style = createStyleStore();
|
||||
style.setProperty('--player-canvas-width', '1920px');
|
||||
style.setProperty('--player-canvas-height', '1080px');
|
||||
|
||||
const sandbox = {
|
||||
window: null,
|
||||
document: {
|
||||
documentElement: { style }
|
||||
},
|
||||
Date,
|
||||
JSON,
|
||||
Math,
|
||||
Number,
|
||||
String,
|
||||
Boolean,
|
||||
Array,
|
||||
Object,
|
||||
Promise,
|
||||
currentPlaylistSignature: 'signature',
|
||||
currentPlaylistEtag: '',
|
||||
currentPlaylistFadeBetweenSlides: false,
|
||||
currentPlaylistSkipUnavailableRtmp: false,
|
||||
videoRegionRenderVersion: 0,
|
||||
lastRenderedSlide: { id: 1 },
|
||||
slideMarkupCache: Object.create(null),
|
||||
templateLayoutCache: Object.create(null),
|
||||
templateRenderPlanCache: Object.create(null),
|
||||
renderCacheViewportKey: '',
|
||||
window: null,
|
||||
innerWidth: 1280,
|
||||
innerHeight: 720,
|
||||
pulsePlayerRegionTypes: {
|
||||
get() {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
syncRenderCacheViewport() {},
|
||||
syncBlackoutState() {},
|
||||
setPlayerCanvasDimensions() {},
|
||||
escapeHtml(value) {
|
||||
return String(value || '');
|
||||
}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
|
||||
const renderingPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-rendering.js');
|
||||
loadScript(renderingPath, sandbox);
|
||||
|
||||
const slide = {
|
||||
id: 2,
|
||||
kind: 'image',
|
||||
media_url: 'https://example.com/next.jpg',
|
||||
modified_at: '2026-08-07T00:00:00.000Z'
|
||||
};
|
||||
|
||||
const markup = sandbox.primeSlideMarkup(slide);
|
||||
|
||||
assert.match(markup, /<img src="https:\/\/example\.com\/next\.jpg"/);
|
||||
assert.equal(style.getPropertyValue('--player-canvas-width'), '1920px');
|
||||
assert.equal(style.getPropertyValue('--player-canvas-height'), '1080px');
|
||||
assert.deepEqual(sandbox.lastRenderedSlide, { id: 1 });
|
||||
});
|
||||
|
||||
test('scheduleSlideMarkupPreload warms the next slide only', () => {
|
||||
const timers = [];
|
||||
const calls = [];
|
||||
const sandbox = {
|
||||
window: null,
|
||||
document: {
|
||||
documentElement: { style: createStyleStore() }
|
||||
},
|
||||
Date,
|
||||
JSON,
|
||||
Math,
|
||||
Number,
|
||||
String,
|
||||
Boolean,
|
||||
Array,
|
||||
Object,
|
||||
Promise,
|
||||
currentPlaylistSignature: 'signature',
|
||||
currentPlaylistEtag: '',
|
||||
currentPlaylistFadeBetweenSlides: false,
|
||||
currentPlaylistSkipUnavailableRtmp: false,
|
||||
videoRegionRenderVersion: 0,
|
||||
innerWidth: 1280,
|
||||
innerHeight: 720,
|
||||
slideMarkupCache: Object.create(null),
|
||||
templateLayoutCache: Object.create(null),
|
||||
templateRenderPlanCache: Object.create(null),
|
||||
renderCacheViewportKey: '',
|
||||
escapeHtml(value) {
|
||||
return String(value || '');
|
||||
},
|
||||
syncRenderCacheViewport() {},
|
||||
syncBlackoutState() {},
|
||||
setPlayerCanvasDimensions() {},
|
||||
primeSlideMarkup(slide) {
|
||||
calls.push(slide.id);
|
||||
return 'markup:' + slide.id;
|
||||
},
|
||||
setTimeout(fn) {
|
||||
timers.push(fn);
|
||||
return timers.length;
|
||||
}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
|
||||
const playlistPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js');
|
||||
loadScript(playlistPath, sandbox);
|
||||
|
||||
const current = { id: 1 };
|
||||
const next = { id: 2 };
|
||||
const later = { id: 3 };
|
||||
|
||||
sandbox.scheduleSlideMarkupPreload([current, next, later], 0);
|
||||
assert.equal(timers.length, 1);
|
||||
timers.shift()();
|
||||
assert.deepEqual(calls, [2]);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
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 loadCommandsScript(sandbox) {
|
||||
const commandsPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
|
||||
const commandsScript = fs.readFileSync(commandsPath, 'utf8');
|
||||
vm.runInNewContext(commandsScript, sandbox, { filename: commandsPath });
|
||||
}
|
||||
|
||||
function createSandbox() {
|
||||
const rafCallbacks = [];
|
||||
const calls = {
|
||||
syncRtmpRegions: 0,
|
||||
initRegion: 0,
|
||||
playRegionAnimations: 0
|
||||
};
|
||||
|
||||
const app = {
|
||||
children: [],
|
||||
firstElementChild: null,
|
||||
innerHTMLValue: '',
|
||||
classList: {
|
||||
contains() {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
set innerHTML(value) {
|
||||
this.innerHTMLValue = String(value || '');
|
||||
this.firstElementChild = this.innerHTMLValue ? { isConnected: true } : null;
|
||||
},
|
||||
get innerHTML() {
|
||||
return this.innerHTMLValue;
|
||||
},
|
||||
appendChild(node) {
|
||||
if (node) {
|
||||
node.isConnected = true;
|
||||
this.children.push(node);
|
||||
this.firstElementChild = this.firstElementChild || node;
|
||||
}
|
||||
return node;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
window: null,
|
||||
Date,
|
||||
JSON,
|
||||
Math,
|
||||
Number,
|
||||
String,
|
||||
Boolean,
|
||||
Array,
|
||||
Object,
|
||||
Promise,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
console,
|
||||
requestAnimationFrame(callback) {
|
||||
rafCallbacks.push(callback);
|
||||
return rafCallbacks.length;
|
||||
},
|
||||
app,
|
||||
slides: [],
|
||||
index: 0,
|
||||
currentPlaylistSignature: 'signature',
|
||||
currentPlaylistFadeBetweenSlides: false,
|
||||
currentPlaylistSkipUnavailableRtmp: false,
|
||||
pendingPlaylistUpdate: null,
|
||||
slideMarkupCache: Object.create(null),
|
||||
templateLayoutCache: Object.create(null),
|
||||
templateRenderPlanCache: Object.create(null),
|
||||
renderCacheViewportKey: '',
|
||||
slideTransitionTimer: null,
|
||||
slideFadeDurationMs: 560,
|
||||
slideExpiresAt: null,
|
||||
pausedRemainingMs: null,
|
||||
timer: null,
|
||||
lastRenderedSlide: null,
|
||||
destroyRtmpRegions() {},
|
||||
syncRtmpRegions() {
|
||||
calls.syncRtmpRegions += 1;
|
||||
},
|
||||
initializeRegionInstances() {
|
||||
calls.initializeRegionInstances += 1;
|
||||
},
|
||||
playRegionAnimations() {
|
||||
calls.playRegionAnimations += 1;
|
||||
},
|
||||
pulsePlayerRegionTypes: {
|
||||
list() {
|
||||
return [
|
||||
{
|
||||
definition: {
|
||||
initRegion() {
|
||||
calls.initRegion += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
},
|
||||
isThumbnailPreview() {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
sandbox.window = sandbox;
|
||||
sandbox.window.requestAnimationFrame = sandbox.requestAnimationFrame;
|
||||
return { sandbox, calls, rafCallbacks, app };
|
||||
}
|
||||
|
||||
test('renderSlideMarkup runs post-render setup immediately', () => {
|
||||
const { sandbox, calls, rafCallbacks, app } = createSandbox();
|
||||
loadCommandsScript(sandbox);
|
||||
|
||||
const returned = sandbox.renderSlideMarkup('<div class="slide">visible</div>', false);
|
||||
|
||||
assert.equal(app.innerHTMLValue, '<div class="slide">visible</div>');
|
||||
assert.equal(calls.syncRtmpRegions, 1);
|
||||
assert.equal(calls.initRegion, 1);
|
||||
assert.equal(calls.playRegionAnimations, 1);
|
||||
assert.equal(rafCallbacks.length, 0);
|
||||
assert.equal(returned, app.firstElementChild);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
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('webpage preloading only targets the next slide', () => {
|
||||
const sandbox = {
|
||||
window: null,
|
||||
Date,
|
||||
Array,
|
||||
Number,
|
||||
String,
|
||||
Boolean,
|
||||
Object,
|
||||
Math,
|
||||
console,
|
||||
escapeHtml(value) {
|
||||
return String(value || '');
|
||||
},
|
||||
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);
|
||||
|
||||
const current = { id: 1 };
|
||||
const next = { id: 2 };
|
||||
const later = { id: 3 };
|
||||
|
||||
assert.equal(sandbox.getWebpagePreloadSlides([current, next, later], 0).map((slide) => slide.id).join(','), '2');
|
||||
assert.equal(sandbox.getWebpagePreloadSlides([current, next, later], 1).map((slide) => slide.id).join(','), '3');
|
||||
assert.equal(sandbox.getWebpagePreloadSlides([current, next, later], 2).map((slide) => slide.id).join(','), '');
|
||||
});
|
||||
|
||||
test('rtmp warmups only target the next slide', () => {
|
||||
const sandbox = {
|
||||
window: null,
|
||||
Date,
|
||||
Array,
|
||||
Number,
|
||||
String,
|
||||
Boolean,
|
||||
Object,
|
||||
Math,
|
||||
console,
|
||||
fetch() {
|
||||
throw new Error('not expected');
|
||||
},
|
||||
currentPlaylistSkipUnavailableRtmp: false,
|
||||
videoRegionRenderVersion: 0,
|
||||
slideMarkupCache: Object.create(null),
|
||||
slides: [],
|
||||
index: 0,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
document: {
|
||||
createElement() {
|
||||
return {
|
||||
className: '',
|
||||
setAttribute() {},
|
||||
appendChild() {},
|
||||
parentNode: null,
|
||||
addEventListener() {}
|
||||
};
|
||||
},
|
||||
body: {
|
||||
appendChild() {}
|
||||
}
|
||||
},
|
||||
pulsePlayerRegionTypes: {
|
||||
register() {}
|
||||
}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
|
||||
loadScript(path.join(__dirname, '..', 'src', 'player', 'regions', 'rtmp.js'), sandbox);
|
||||
|
||||
const current = { id: 1 };
|
||||
const next = { id: 2 };
|
||||
const later = { id: 3 };
|
||||
|
||||
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(','), '');
|
||||
});
|
||||
@@ -45,4 +45,107 @@ test('multipart uploads accept large text fields used by slide and template form
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(uploadDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('fqdn player registration wins over a local configured player target for media sync', async () => {
|
||||
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-'));
|
||||
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
|
||||
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
|
||||
|
||||
const fetchCalls = [];
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async function (url, init) {
|
||||
fetchCalls.push({ url, init });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
get() {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async text() {
|
||||
return JSON.stringify({ ok: true });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const uploadSyncService = createUploadSyncService({
|
||||
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'
|
||||
}
|
||||
]];
|
||||
}
|
||||
},
|
||||
playerSnapshotCache: new Map(),
|
||||
notifyPlayerScreens: async () => {}
|
||||
});
|
||||
|
||||
try {
|
||||
const success = await uploadSyncService.pushUploadFileToPlayer('/media/uploads/sample.bin', uploadDir);
|
||||
|
||||
assert.equal(success, true);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin');
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
fs.rmSync(uploadDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('media sync retry warnings include the resolved player label', async () => {
|
||||
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-log-'));
|
||||
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
|
||||
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const originalWarn = console.warn;
|
||||
const warned = [];
|
||||
console.warn = function () {
|
||||
warned.push(Array.from(arguments).join(' '));
|
||||
};
|
||||
global.fetch = async function () {
|
||||
const error = new Error('getaddrinfo ENOTFOUND player-remote');
|
||||
error.code = 'ENOTFOUND';
|
||||
throw error;
|
||||
};
|
||||
|
||||
const uploadSyncService = createUploadSyncService({
|
||||
common: {},
|
||||
pool: {
|
||||
async query() {
|
||||
return [[
|
||||
{
|
||||
identifier: 'player-remote',
|
||||
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com'
|
||||
}
|
||||
]];
|
||||
}
|
||||
},
|
||||
playerSnapshotCache: new Map(),
|
||||
notifyPlayerScreens: async () => {}
|
||||
});
|
||||
|
||||
try {
|
||||
await uploadSyncService.syncUploadRefsToPlayer(['/media/uploads/sample.bin'], uploadDir);
|
||||
await uploadSyncService.flushPendingPlayerUploadSyncs();
|
||||
|
||||
assert.ok(warned.some(function (message) {
|
||||
return message.includes('[media-sync] Player unavailable, retry queued for 1 upload for player-remote');
|
||||
}));
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
console.warn = originalWarn;
|
||||
fs.rmSync(uploadDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -218,3 +218,140 @@ test('dashboard kiosk launcher requires both confirmation and a player selection
|
||||
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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user