const test = require('node:test'); const assert = require('node:assert/strict'); const http = require('http'); const WebSocket = require('ws'); require('../src/common'); const { createPageAuthToken } = require('../src/request-auth'); const { createPlayerRuntime } = require('../src/player/runtime'); const originalSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET; test.after(() => { if (originalSecret === undefined) { delete process.env.PULSE_SIGNAGE_SHARED_SECRET; } else { process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSecret; } }); function waitFor(predicate, timeoutMs = 1000) { const start = Date.now(); return new Promise((resolve, reject) => { const tick = () => { const value = predicate(); if (value) { return resolve(value); } if (Date.now() - start > timeoutMs) { return reject(new Error('Timed out waiting for runtime state.')); } setTimeout(tick, 25); }; tick(); }); } test('player runtime snapshots websocket state and checks live names', async () => { process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret'; const runtime = createPlayerRuntime({ pool: null }); 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: 'test2' }); const client = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/test2?auth=${encodeURIComponent(token)}`); try { await new Promise((resolve, reject) => { client.once('open', resolve); client.once('error', reject); }); client.send(JSON.stringify({ type: 'state', clientId: 'client-1', clientName: ' Lobby Player ', deviceId: 'device 123!?', userAgent: 'Mozilla/5.0 (unit test)', viewport: { width: 1920, height: 1080 }, page: 'http://localhost:8081/screen/test2', paused: true, blackout: false, currentSlide: { id: 9, title: 'Intro', kind: 'slide', playlistSignature: 'sig' } })); await waitFor(() => { const snapshot = runtime.snapshotConnections('test2')[0]; return snapshot && snapshot.clientName === 'Lobby Player' ? snapshot : null; }); const snapshot = runtime.snapshotConnections('test2')[0]; assert.equal(snapshot.clientName, 'Lobby Player'); assert.equal(snapshot.deviceId, 'device123'); assert.match(snapshot.label, /Lobby Player/); assert.equal(snapshot.paused, true); assert.equal(snapshot.currentSlideId, 9); assert.equal(snapshot.currentSlideTitle, 'Intro'); assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Lobby Player', 'other-device'), false); assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Lobby Player', 'device123'), true); assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Other Name', 'device123'), true); } finally { client.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'; const runtime = createPlayerRuntime({ pool: null }); 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: 'cookie-test' }); const client = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/cookie-test`, { headers: { Cookie: `pulse_page_auth=${encodeURIComponent(token)}` } }); try { await new Promise((resolve, reject) => { client.once('open', resolve); client.once('error', reject); }); client.send(JSON.stringify({ type: 'state', clientId: 'cookie-client', clientName: 'Cookie Player', deviceId: 'device-cookie' })); await waitFor(() => { const snapshot = runtime.snapshotConnections('cookie-test')[0]; return snapshot && snapshot.clientName === 'Cookie Player' ? snapshot : null; }); assert.equal(runtime.snapshotConnections('cookie-test')[0].clientName, 'Cookie Player'); } finally { client.close(); await new Promise((resolve) => server.close(resolve)); } }); test('player runtime sends targeted and broadcast commands to live sockets', async () => { process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret'; const runtime = createPlayerRuntime({ pool: null }); 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: 'test2' }); const clientA = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/test2?auth=${encodeURIComponent(token)}`); const clientB = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/test2?auth=${encodeURIComponent(token)}`); function openClient(client) { return new Promise((resolve, reject) => { client.once('open', resolve); client.once('error', reject); }); } function waitForMessage(client) { return new Promise((resolve) => { client.once('message', (raw) => { resolve(JSON.parse(String(raw))); }); }); } try { await Promise.all([openClient(clientA), openClient(clientB)]); clientA.send(JSON.stringify({ type: 'state', clientId: 'client-a', clientName: 'Alpha', deviceId: 'device-a', page: 'http://localhost:8081/screen/test2' })); clientB.send(JSON.stringify({ type: 'state', clientId: 'client-b', clientName: 'Beta', deviceId: 'device-b', page: 'http://localhost:8081/screen/test2' })); await waitFor(() => { const connections = runtime.snapshotConnections('test2'); return connections.length === 2 && connections.every((connection) => connection.clientName); }); const [firstConnection, secondConnection] = runtime.snapshotConnections('test2'); const targetedMessagePromise = waitForMessage(clientA); const targetedCount = await runtime.sendCommandToConnection('test2', firstConnection.id, { action: 'pause' }); assert.equal(targetedCount, 1); const targetedMessage = await targetedMessagePromise; assert.equal(targetedMessage.type, 'command'); assert.equal(targetedMessage.action, 'pause'); assert.equal(targetedMessage.targetConnectionId, firstConnection.id); const broadcastPromises = [waitForMessage(clientA), waitForMessage(clientB)]; const broadcastCount = await runtime.broadcastCommand('test2', 'resume'); assert.equal(broadcastCount, 2); const [broadcastA, broadcastB] = await Promise.all(broadcastPromises); assert.equal(broadcastA.type, 'command'); assert.equal(broadcastA.command, 'resume'); assert.equal(broadcastB.type, 'command'); assert.equal(broadcastB.command, 'resume'); assert.equal(broadcastA.targetConnectionId, undefined); assert.equal(broadcastB.targetConnectionId, undefined); assert.equal(await runtime.sendCommandToConnection('test2', 'missing', 'pause'), 0); assert.equal(secondConnection.clientName, 'Beta'); } finally { clientA.close(); clientB.close(); await new Promise((resolve) => server.close(resolve)); } });