Release v2.10.3
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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')}`;
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
}]);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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\./);
|
||||
});
|
||||
Reference in New Issue
Block a user