Release v2.10.4
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m14s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 33s

This commit is contained in:
2026-08-29 12:27:01 +01:00
parent 3f4f57020a
commit ca9f38f3b9
22 changed files with 277 additions and 164 deletions
+88
View File
@@ -210,6 +210,94 @@ test('move client requires a registered player identity', async () => {
assert.equal(response.body.error, 'Registered player identity is required');
});
test('move client routes a remote connection through its bridge player identity', async () => {
const calls = [];
const { app, handlers } = createHandlers();
registerScreenCommandRoutes(app, {
pool: {
async query(sql, params) {
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && 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: 'Remote Client', current_screen_slug: 'source-screen' }]];
}
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params[0] === 'target-screen') {
return [[{ id: 27, name: 'Target Screen', slug: 'target-screen' }]];
}
return [[]];
}
},
common: { fetchPlayerRegistrations: async () => [] },
forwardPlayerCommand() {
throw new Error('direct player fallback should not be used');
},
forwardPlayerCommandToBaseUrl() {
throw new Error('public URL routing should not be used');
},
forwardPlayerCommandToDevice(deviceId, payload) {
calls.push({ deviceId, payload });
return { ok: true };
},
getScreenConnections: async () => ({
connections: [{
id: 'connection-1',
clientId: 'connection-1',
deviceId: 'browser-tab-1',
playerDeviceId: 'remote-player-1',
playerPublicBaseUrl: null
}]
}),
isClientNameAvailable: async () => true,
withClientNameReservation: async (_pool, _name, callback) => callback(),
broadcastDashboardState: async () => {},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const response = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
}
};
await handlers['/clients/:slug/commands'][1]({
params: { slug: 'source-screen' },
body: {
command: 'moveclient',
clientId: 'browser-tab-1',
clientName: 'Remote Client',
targetScreenSlug: 'target-screen',
connectionId: 'connection-1'
},
query: {}
}, response, (error) => { throw error; });
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.deepEqual(calls, [{
deviceId: 'remote-player-1',
payload: {
screenSlug: 'source-screen',
command: 'redirect',
url: '/screen/target-screen',
moveToken: calls[0] && calls[0].payload.moveToken,
connectionId: 'connection-1'
}
}]);
});
test('screen control commands can target all screens', async () => {
const calls = [];
const { app, handlers } = createHandlers();
+53
View File
@@ -195,3 +195,56 @@ test('client command route forwards a single screen command', async () => {
assert.equal(calls[0].connectionId, undefined);
assert.equal(response.body.ok, true);
});
test('client command route uses the bridge device route without a public player URL', 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: 1, name: 'Lobby', slug: 'lobby' }]];
}
return [[]];
}
},
common: { async fetchPlayerRegistrations() { return []; } },
forwardPlayerCommand() {
throw new Error('direct player fallback should not be used');
},
forwardPlayerCommandToBaseUrl() {
throw new Error('public URL fallback should not be used');
},
forwardPlayerCommandToDevice(deviceId, payload) {
calls.push({ deviceId, payload });
return { ok: true };
},
getScreenConnections: async () => ({
connections: [{ id: 'connection-1', deviceId: 'browser-tab-1', playerDeviceId: 'remote-player-1', playerPublicBaseUrl: null }]
}),
isClientNameAvailable() { return true; },
withClientNameReservation() {},
broadcastDashboardState() {},
requirePermission() {
return function (_req, _res, next) { next(); };
}
});
const response = {
statusCode: 200,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; }
};
await handlers['/clients/:slug/commands'][1]({
params: { slug: 'lobby' },
body: { command: 'pause', connectionId: 'connection-1' },
query: {}
}, response, () => {});
assert.equal(response.statusCode, 200);
assert.deepEqual(calls, [{
deviceId: 'remote-player-1',
payload: { screenSlug: 'lobby', command: 'pause', connectionId: 'connection-1' }
}]);
});
+11 -56
View File
@@ -6,7 +6,7 @@ require('../src/common');
const registerScreensRoutes = require('../src/web/routes/signage/screens/routes');
const { renderScreenEditPage } = require('../src/web/pages');
test('screen edit page includes shared launcher downloads and base player url', async () => {
test('screen edit page omits player URL data', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
@@ -88,25 +88,11 @@ 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_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'
});
assert.equal(Object.prototype.hasOwnProperty.call(renderedArgs.screen, 'player_urls'), false);
assert.equal(Object.prototype.hasOwnProperty.call(renderedArgs.screen, 'launcher_downloads'), false);
});
test('screen edit query route includes player urls for every registration', async () => {
test('screen edit query route omits player URLs', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
@@ -172,20 +158,11 @@ test('screen edit query route includes player urls for every registration', asyn
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'
});
assert.equal(Object.prototype.hasOwnProperty.call(renderedArgs.screen, 'player_urls'), false);
assert.equal(Object.prototype.hasOwnProperty.call(renderedArgs.screen, 'launcher_downloads'), false);
});
test('screen edit page hides stale player registrations', async () => {
test('screen edit page does not load player URL registrations', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
@@ -258,22 +235,10 @@ test('screen edit page hides stale player registrations', async () => {
await handler[1]({ params: { id: '7' }, query: {}, currentUser: { id: 1 } }, res, () => {});
assert.equal(res.body, 'ok');
assert.deepEqual(renderedArgs.screen.player_urls.map(function (player) {
return {
identifier: player.identifier,
public_base_url: player.public_base_url,
player_url: player.player_url
};
}), [
{
identifier: 'player-recent',
public_base_url: 'http://recent.example',
player_url: 'http://recent.example/screen/demo-conference'
}
]);
assert.equal(Object.prototype.hasOwnProperty.call(renderedArgs.screen, 'player_urls'), false);
});
test('screen edit page renders player urls as an adminlte table', async () => {
test('screen edit page does not render a player URL table', async () => {
const html = renderScreenEditPage(
{
id: 7,
@@ -282,24 +247,14 @@ test('screen edit page renders player urls as an adminlte table', async () => {
playlist_id: null,
slug_update_confirm_live_connection_count: 2,
slug_update_confirm_message: 'Are you sure you want to update the slug? This will refresh all screens using this slug.',
player_urls: [
{
identifier: 'player-alpha',
public_base_url: 'http://alpha.example',
player_url: 'http://alpha.example/screen/demo-conference'
}
]
},
{ playlists: [] },
'',
{ id: 1 }
);
assert.match(html, /Player URLs/);
assert.match(html, /<table/i);
assert.match(html, /card-body table-responsive p-0/);
assert.match(html, /table table-striped w-100 mb-0/);
assert.match(html, /player-alpha/);
assert.doesNotMatch(html, /Player URLs/);
assert.doesNotMatch(html, /player-alpha/);
assert.match(html, /<input[^>]+id="screen-slug"[^>]+disabled/);
assert.match(html, /Slug cannot be changed after creation/);
});