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

This commit is contained in:
2026-08-29 01:56:45 +01:00
parent 30814f3f46
commit 3f4f57020a
60 changed files with 1075 additions and 523 deletions
+58
View File
@@ -37,6 +37,9 @@ test('move client rebinding redirects the live player to the target screen', asy
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('SELECT identifier, public_base_url FROM d_players WHERE public_base_url = ?')) {
return [[{ identifier: 'player-a', public_base_url: 'http://remote-player.example' }]];
}
if (sql.includes('INSERT INTO d_onboarding_devices')) {
return [{ affectedRows: 1 }];
}
@@ -152,6 +155,61 @@ test('move client rebinding redirects the live player to the target screen', asy
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://remote-player.example'), true);
});
test('move client requires a registered player identity', async () => {
const { app, handlers } = createHandlers();
registerScreenCommandRoutes(app, {
pool: {
async query(sql) {
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?')) {
return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]];
}
return [[]];
}
},
common: { fetchPlayerRegistrations: async () => [] },
forwardPlayerCommand: async () => ({ ok: true }),
forwardPlayerCommandToBaseUrl: async () => ({ ok: true }),
getScreenConnections: async () => ({ connections: [] }),
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',
deviceId: 'unregistered-device',
clientName: 'Lobby Client',
targetScreenSlug: 'target-screen',
playerBaseUrl: 'http://unregistered-player.example'
},
query: {}
}, response, () => {});
assert.equal(response.statusCode, 400);
assert.equal(response.body.error, 'Registered player identity is required');
});
test('screen control commands can target all screens', async () => {
const calls = [];
const { app, handlers } = createHandlers();
+19 -2
View File
@@ -4,6 +4,7 @@ const crypto = require('node:crypto');
const {
collectLiveConnections,
findAvailableClientName,
isClientNameAvailable,
normalizeClientName,
normalizeDeviceId,
@@ -22,7 +23,7 @@ test('collectLiveConnections returns an array safely', () => {
assert.deepEqual(collectLiveConnections([{ clientName: 'A' }]), [{ clientName: 'A' }]);
});
test('isClientNameAvailable rejects matching db rows and live connections', async () => {
test('isClientNameAvailable ignores stored names for offline devices', async () => {
const pool = {
async query(sql, params) {
assert.match(sql, /FROM d_onboarding_devices/);
@@ -31,12 +32,28 @@ test('isClientNameAvailable rejects matching db rows and live connections', asyn
}
};
assert.equal(await isClientNameAvailable(pool, ' Screen A ', 'device-01', []), false);
assert.equal(await isClientNameAvailable(pool, ' Screen A ', 'device-01', []), true);
assert.equal(await isClientNameAvailable(pool, 'Screen A', 'device-01', [{ clientName: 'screen a', deviceId: 'device-99' }]), false);
assert.equal(await isClientNameAvailable(null, 'Screen A', 'device-01', [{ clientName: 'screen a', clientId: 'device-99' }]), false);
assert.equal(await isClientNameAvailable(null, 'Screen A', 'device-01', [{ clientName: 'screen a', clientId: 'device-01' }]), true);
assert.equal(await isClientNameAvailable(null, ' ', 'device-01', []), false);
});
test('findAvailableClientName adds the first free numeric suffix', async () => {
const occupiedNames = new Set(['Lobby', 'Lobby (1)']);
const pool = {
async query(_sql, params) {
const name = params[0];
return [[occupiedNames.has(name) ? { device_id: 'active-device' } : undefined].filter(Boolean)];
}
};
assert.equal(
await findAvailableClientName(pool, 'Lobby', 'new-device', [{ deviceId: 'active-device' }]),
'Lobby (2)'
);
});
test('withClientNameReservation acquires and releases locks around the handler', async () => {
const calls = [];
const lockName = `ps_client_name_${crypto.createHash('sha1').update('screen a').digest('hex')}`;
+84 -1
View File
@@ -1,7 +1,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { refreshApiSource, refreshRssFeed } = require('../src/web/lib/data-source-refresh');
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('../src/web/lib/data-source-refresh');
function createConnection(options) {
const state = Object.assign({
@@ -178,3 +178,86 @@ test('refreshRssFeed skips notifications when the RSS items are unchanged', asyn
assert.deepEqual(notifyCalls, []);
});
test('refreshWeatherLocation notifies players when the forecast changes', async () => {
const connection = createConnection({
slideRows: [{ id: 12, content_json: JSON.stringify({ weather_location_id: 4 }) }],
screenRows: [{ slug: 'screen-c' }]
});
const notifyCalls = [];
const pool = { async getConnection() { return connection; } };
const common = {
async fetchWeatherLocationForecast() {
return { responseJson: JSON.stringify({ temperature: 21 }) };
},
parseJsonSafe(value) {
return JSON.parse(value);
}
};
await refreshWeatherLocation(pool, common, {
id: 4,
last_pulled_at: new Date(),
last_response_json: JSON.stringify({ temperature: 20 })
}, 42, async function (slugs, payload) {
notifyCalls.push({ slugs, payload });
});
assert.deepEqual(notifyCalls, [{ slugs: ['screen-c'], payload: 'refresh' }]);
});
test('refreshWeatherLocation skips notifications when the forecast is unchanged within the hour', async () => {
const connection = createConnection({
slideRows: [{ id: 12, content_json: JSON.stringify({ weather_location_id: 4 }) }],
screenRows: [{ slug: 'screen-c' }]
});
const notifyCalls = [];
const pool = { async getConnection() { return connection; } };
const snapshot = JSON.stringify({ temperature: 20 });
const common = {
async fetchWeatherLocationForecast() {
return { responseJson: snapshot };
},
parseJsonSafe(value) {
return JSON.parse(value);
}
};
await refreshWeatherLocation(pool, common, {
id: 4,
last_pulled_at: new Date(),
last_response_json: snapshot
}, 42, async function (slugs, payload) {
notifyCalls.push({ slugs, payload });
});
assert.deepEqual(notifyCalls, []);
});
test('refreshWeatherLocation notifies when an unchanged forecast crosses into a new hour', async () => {
const connection = createConnection({
slideRows: [{ id: 12, content_json: JSON.stringify({ weather_location_id: 4 }) }],
screenRows: [{ slug: 'screen-c' }]
});
const notifyCalls = [];
const pool = { async getConnection() { return connection; } };
const snapshot = JSON.stringify({ temperature: 20 });
const common = {
async fetchWeatherLocationForecast() {
return { responseJson: snapshot };
},
parseJsonSafe(value) {
return JSON.parse(value);
}
};
await refreshWeatherLocation(pool, common, {
id: 4,
last_pulled_at: new Date('2000-01-01T00:00:00.000Z'),
last_response_json: snapshot
}, 42, async function (slugs, payload) {
notifyCalls.push({ slugs, payload });
});
assert.deepEqual(notifyCalls, [{ slugs: ['screen-c'], payload: 'refresh' }]);
});
+35
View File
@@ -0,0 +1,35 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { createNotifyPlayerScreens } = require('../src/web/lib/notify-player-screens');
test('playlist notifications use the player URL reported by the screen connection', async () => {
const calls = [];
const notify = createNotifyPlayerScreens(
async function () {
calls.push({ type: 'fallback' });
return { ok: true };
},
async function () {
return {
connections: [{ playerPublicBaseUrl: 'http://192.168.0.80:8082' }]
};
},
async function (baseUrl, slug, command) {
calls.push({ baseUrl, slug, command });
return { ok: true };
},
async function () {
return 'http://player:8081';
}
);
const count = await notify(['test'], 'refresh');
assert.equal(count, 1);
assert.deepEqual(calls, [{
baseUrl: 'http://player:8081',
slug: 'test',
command: 'refresh'
}]);
});
+22 -2
View File
@@ -14,7 +14,27 @@ test('prunes old unbound onboarding devices without deleting completed bindings'
await pruneStaleOnboardingDevices(pool);
assert.match(queryText, /modified_at < \(CURRENT_TIMESTAMP - INTERVAL 1 MINUTE\)/);
assert.match(queryText, /WHERE screen_id IS NULL/);
assert.match(queryText, /screen_id IS NULL/);
assert.match(queryText, /modified_at < \(CURRENT_TIMESTAMP - INTERVAL 15 MINUTE\)/);
assert.match(queryText, /last_seen_at < \(CURRENT_TIMESTAMP - INTERVAL 24 HOUR\)/);
assert.doesNotMatch(queryText, /d_players\.identifier = d_onboarding_devices\.device_id/);
});
test('updates an onboarding device last-seen timestamp by device id', async () => {
let queryText = '';
let queryParams = null;
const pool = {
async query(sql, params) {
queryText = sql;
queryParams = params;
return [[]];
}
};
const { touchOnboardingDeviceLastSeen } = require('../src/db/common');
await touchOnboardingDeviceLastSeen(pool, ['device-123', 'client-123']);
assert.match(queryText, /SET last_seen_at = CURRENT_TIMESTAMP/);
assert.match(queryText, /WHERE device_id IN \(\?, \?\)/);
assert.deepEqual(queryParams, ['device-123', 'client-123']);
});
+72
View File
@@ -96,6 +96,52 @@ test('player runtime snapshots websocket state and checks live names', async ()
}
});
test('player runtime suffixes a reconnecting client name already in use', async () => {
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
const persistedNames = [];
const runtime = createPlayerRuntime({
pool: {
async query(_sql, params) {
return [[params[0] === 'Lobby' && params[1] !== 'device-a' ? { device_id: 'device-a' } : undefined].filter(Boolean)];
}
},
persistClientName(deviceId, clientName) {
persistedNames.push({ deviceId, clientName });
return Promise.resolve();
}
});
const server = http.createServer();
runtime.installWebsocket(server);
await new Promise((resolve) => server.listen(0, resolve));
const { port } = server.address();
const token = createPageAuthToken({ scope: 'player', slug: 'reconnect-test' });
const clientA = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/reconnect-test?auth=${encodeURIComponent(token)}`);
const clientB = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/reconnect-test?auth=${encodeURIComponent(token)}`);
try {
await Promise.all([
new Promise((resolve, reject) => { clientA.once('open', resolve); clientA.once('error', reject); }),
new Promise((resolve, reject) => { clientB.once('open', resolve); clientB.once('error', reject); })
]);
clientA.send(JSON.stringify({ type: 'state', clientId: 'client-a', clientName: 'Lobby', deviceId: 'device-a' }));
await waitFor(() => runtime.snapshotConnections('reconnect-test').some((connection) => connection.clientName === 'Lobby'));
clientB.send(JSON.stringify({ type: 'state', clientId: 'client-b', clientName: 'Lobby', deviceId: 'device-b' }));
await waitFor(() => {
const connections = runtime.snapshotConnections('reconnect-test');
return connections.length === 2 && connections.some((connection) => connection.clientName === 'Lobby (1)');
});
assert.deepEqual(persistedNames, [{ deviceId: 'device-b', clientName: 'Lobby (1)' }]);
} finally {
clientA.close();
clientB.close();
await new Promise((resolve) => server.close(resolve));
}
});
test('player runtime accepts websocket auth from cookies', async () => {
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
@@ -198,4 +244,30 @@ test('player runtime sends targeted and broadcast commands to live sockets', asy
clientB.close();
await new Promise((resolve) => server.close(resolve));
}
});
test('player runtime removes browser connections that stop sending state', async () => {
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
const runtime = createPlayerRuntime({ pool: null, staleConnectionMs: 50 });
const server = http.createServer();
runtime.installWebsocket(server);
await new Promise((resolve) => server.listen(0, resolve));
const { port } = server.address();
const token = createPageAuthToken({ scope: 'player', slug: 'stale-test' });
const client = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/stale-test?auth=${encodeURIComponent(token)}`);
try {
await new Promise((resolve, reject) => {
client.once('open', resolve);
client.once('error', reject);
});
client.send(JSON.stringify({ type: 'state', clientId: 'stale-client', clientName: 'Stale Player' }));
await waitFor(() => runtime.snapshotConnections('stale-test').length === 1);
await waitFor(() => runtime.snapshotConnections('stale-test').length === 0, 1000);
} finally {
client.close();
await new Promise((resolve) => server.close(resolve));
}
});
+1 -1
View File
@@ -44,7 +44,7 @@ test('pending migrations are empty when the schema already matches the app versi
}
]);
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.10.1' });
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.10.2' });
assert.equal(pendingMigrations.length, 0);
});
+3
View File
@@ -35,5 +35,8 @@ test('weather forecast preview only shows the fetch hint without a snapshot', ()
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'weather', 'forecast-preview.hbs'), 'utf8');
assert.match(template, /\{\{#if weatherPreview\.hasSnapshot\}\}[\s\S]*weather-daily-forecast[\s\S]*weather-hourly-forecast[\s\S]*\{\{\/if\}\}/);
assert.match(template, /id="weather-daily-forecast" class="weather-daily-forecast"/);
assert.match(template, /id="weather-hourly-forecast" class="d-none"/);
assert.doesNotMatch(template, /Hourly forecast · next 24 hours/);
assert.match(template, /Daily and hourly forecasts will appear here after the first successful fetch\./);
});