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'); async function activateWorker(version, cacheKeys) { const listeners = new Map(); const deleted = []; let claimed = false; const sandbox = { URL, self: { location: { href: 'http://localhost/sw.js?v=' + encodeURIComponent(version) }, addEventListener(name, callback) { listeners.set(name, callback); }, clients: { async claim() { claimed = true; } } }, caches: { async keys() { return cacheKeys; }, async delete(key) { deleted.push(key); return true; } } }; const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'sw.js'); vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath }); let activation; listeners.get('activate')({ waitUntil(promise) { activation = promise; } }); await activation; assert.equal(claimed, true); return deleted; } test('player service worker activation preserves caches owned by other applications', async () => { const deleted = await activateWorker('2.14.0', ['other-app-assets', 'other-app-pages']); assert.deepEqual(deleted, []); }); test('player service worker retains its active version and the most recently created other generation', async () => { const cacheKeys = [ 'other-app-cache', 'pulse-signage-player-pages-2.10.0', 'pulse-signage-player-assets-2.10.0', 'pulse-signage-player-media-2.10.0', 'pulse-signage-player-playlists-2.10.0', 'pulse-signage-player-pages-2.9.0', 'pulse-signage-player-assets-2.11.0', 'pulse-signage-player-pages-2.11.0' ]; const deleted = await activateWorker('2.10.0', cacheKeys); assert.deepEqual(deleted, ['pulse-signage-player-pages-2.9.0']); }); test('player service worker retention supports opaque versions before active caches exist', async () => { const deleted = await activateWorker('current-build', [ 'pulse-signage-player-pages-z-old-build', 'pulse-signage-player-assets-z-old-build', 'pulse-signage-player-pages-a-newer-build', 'pulse-signage-player-playlists-a-newer-build' ]); assert.deepEqual(deleted, [ 'pulse-signage-player-pages-z-old-build', 'pulse-signage-player-assets-z-old-build' ]); }); function createFetchWorker({ failure, cachedResponse = null, cacheMatchResponse = null, fetchError = false, networkStatus = 200 } = {}) { const networkResponse = new Response('fresh network body', { status: networkStatus, headers: { ETag: '"fresh"', 'Content-Type': 'text/plain' } }); const namedCacheResponse = cacheMatchResponse === null ? cachedResponse : cacheMatchResponse; const calls = { fetch: 0, writes: [] }; const listeners = new Map(); const sandbox = { URL, Response, Request, Headers, self: { location: { href: 'http://localhost/sw.js', origin: 'http://localhost' }, addEventListener(name, callback) { listeners.set(name, callback); } }, async fetch() { calls.fetch += 1; if (fetchError) throw new Error('Network unavailable'); return networkResponse; }, caches: { async match() { return cachedResponse; }, async open() { if (failure === 'open') throw new Error('Cache storage unavailable'); return { async match() { return namedCacheResponse; }, async put(key, response) { if (failure === 'put') throw new Error('QuotaExceededError'); calls.writes.push({ key, response }); } }; } } }; const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'sw.js'); vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath }); return { sandbox, calls, networkResponse, listeners }; } test('successful network responses survive cache open and write failures', async () => { for (const failure of ['open', 'put']) { for (const strategy of ['networkFirst', 'cacheFirst', 'staleWhileRevalidate']) { for (const cached of strategy === 'networkFirst' ? [false, true] : [false]) { const cachedResponse = cached ? new Response('stale body') : null; const { sandbox, networkResponse } = createFetchWorker({ failure, cachedResponse }); const response = await sandbox[strategy]({ url: 'http://localhost/screen/test' }, 'cache'); assert.equal(response, networkResponse, strategy + ', failure=' + failure + ', cached=' + cached); assert.equal(response.status, 200); assert.equal(response.headers.get('ETag'), '"fresh"'); assert.equal(await response.text(), 'fresh network body'); } } } }); test('successful cache writes retain the normalized key and do not consume the network body', async () => { const { sandbox, calls, networkResponse } = createFetchWorker(); const request = { url: 'http://localhost/api/screens/test/playlist?ts=1' }; const normalizedKey = { url: 'http://localhost/api/screens/test/playlist' }; const response = await sandbox.networkFirst(request, 'cache', normalizedKey); assert.equal(response, networkResponse); assert.equal(calls.writes.length, 1); assert.equal(calls.writes[0].key, normalizedKey); assert.notEqual(calls.writes[0].response, networkResponse); assert.equal(await calls.writes[0].response.text(), 'fresh network body'); assert.equal(await response.text(), 'fresh network body'); }); test('asset cache hits do not fetch and offline pages still fall back to cached responses', async () => { const cachedResponse = new Response('cached body'); const cacheHit = createFetchWorker({ cachedResponse }); assert.equal(await cacheHit.sandbox.cacheFirst({}, 'cache'), cachedResponse); assert.equal(cacheHit.calls.fetch, 0); const offlineHit = createFetchWorker({ cachedResponse, fetchError: true }); assert.equal(await offlineHit.sandbox.networkFirst({}, 'cache'), cachedResponse); const offlineMiss = createFetchWorker({ fetchError: true }); assert.equal((await offlineMiss.sandbox.networkFirst({}, 'cache')).status, 504); }); test('player script assets refresh from network and retain offline cache fallback', async () => { const cachedResponse = new Response('old script body'); const online = createFetchWorker({ cachedResponse }); let onlineResponsePromise; online.listeners.get('fetch')({ request: new Request('http://localhost/assets/player-script/rendering.js?v=2.14.0'), respondWith(promise) { onlineResponsePromise = promise; } }); assert.equal(await onlineResponsePromise, online.networkResponse); assert.equal(online.calls.fetch, 1); const offline = createFetchWorker({ cachedResponse: cachedResponse, fetchError: true }); let offlineResponsePromise; offline.listeners.get('fetch')({ request: new Request('http://localhost/assets/player-script/rendering.js?v=2.14.0'), respondWith(promise) { offlineResponsePromise = promise; } }); assert.equal(await offlineResponsePromise, cachedResponse); }); test('playlist HTTP errors are not hidden by a stale cached playlist', async () => { const cachedResponse = new Response('stale playlist body', { status: 200 }); const worker = createFetchWorker({ cachedResponse, networkStatus: 403 }); const request = new Request('http://localhost/api/screens/test/playlist?ts=2'); const response = await worker.sandbox.networkFirst( request, 'playlists', { url: 'http://localhost/api/screens/test/playlist' }, false ); assert.equal(response.status, 403); assert.equal(await response.text(), 'fresh network body'); }); test('playlist offline fallback does not read an older cache generation', async () => { const staleResponse = new Response('stale QR playlist', { status: 200 }); const currentResponse = new Response('current Video playlist', { status: 200 }); const worker = createFetchWorker({ cachedResponse: staleResponse, cacheMatchResponse: currentResponse, fetchError: true }); const request = new Request('http://localhost/api/screens/test/playlist?ts=3'); const response = await worker.sandbox.networkFirst( request, 'playlists-current', { url: 'http://localhost/api/screens/test/playlist' }, false ); assert.equal(response, currentResponse); assert.equal(await response.text(), 'current Video playlist'); });