'use strict'; // What the worker does with the playlist message the player posts it — specifically the prune half. // // THE BUG: `pruneToPlaylist` deletes every content entry not in the keep-set, and the message // handler ran it on an EMPTY keep-set. `assignments: []` is not a rare shape. buildPlaylistPayload() // produces it for a device between playlists, for a playlist that has never been published, and — // this is the one that hurts — inside `catch (e) { assignments = []; }` when a published_snapshot // fails to JSON.parse. Any of those wiped every byte of media the panel had cached, which is only // survivable while the uplink is up, i.e. exactly when the offline cache does not matter. // Reproduced in a browser before the fix: three cached assets, one empty payload, cache emptied. // // Runs the SHIPPED worker (player/sw.js) against a fake Cache API, capturing the message listener // it registers — the listener is the thing under test, so stubbing addEventListener away (as the // prefetch tests do) would leave this path untested, which is how it shipped. const { test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const SW_SRC = fs.readFileSync(path.join(__dirname, '..', 'player', 'sw.js'), 'utf8'); const POLICY_PATH = path.join(__dirname, '..', 'lib', 'player-cache-policy.js'); const A = 'http://s/uploads/content/a.mp4?rev=1'; const B = 'http://s/uploads/content/b.png?rev=1'; const OLD = 'http://s/uploads/content/a.mp4?rev=0'; class FakeCache { constructor() { this.map = new Map(); } #url(req) { return typeof req === 'string' ? req : req.url; } async match(req) { const r = this.map.get(this.#url(req)); return r ? r.clone() : undefined; } async put(req, res) { this.map.set(this.#url(req), res); } async keys() { return [...this.map.keys()].map((u) => ({ url: u })); } async delete(req) { return this.map.delete(this.#url(req)); } } function load() { const content = new FakeCache(); const listeners = {}; const sandbox = { caches: { open: async () => content, keys: async () => ['rd-content-v1'], delete: async () => true, match: async () => undefined, }, // Any network use here would be a bug in the test, not the worker: prune touches no network. fetch: async () => { throw new Error('prune must not fetch'); }, Response, Request, Blob, URL, console, location: { href: 'http://s/player/index.html' }, navigator: {}, importScripts() { delete require.cache[require.resolve(POLICY_PATH)]; sandbox.self.PlayerCachePolicy = require(POLICY_PATH); }, addEventListener(type, fn) { (listeners[type] = listeners[type] || []).push(fn); }, skipWaiting() {}, clients: { claim() {} }, }; sandbox.self = sandbox; vm.createContext(sandbox); vm.runInContext(SW_SRC, sandbox); return { sandbox, content, post: (data) => listeners.message.forEach((fn) => fn({ data })) }; } const seed = async (content, urls) => { for (const u of urls) await content.put(u, new Response('x')); }; const keys = (content) => [...content.map.keys()].sort(); // The worker chains prune onto its serialised prefetch queue, so give the microtasks a turn. const settle = () => new Promise((r) => setTimeout(r, 20)); test('THE BUG: an empty playlist must NOT wipe the offline cache', async () => { const { content, post } = load(); await seed(content, [A, B]); post({ type: 'st-cache-playlist', urls: [], prune: true }); await settle(); assert.deepEqual(keys(content), [A, B].sort(), 'a payload with no assignments is indistinguishable from a payload that failed to build — it is not a delete instruction'); }); test('a real playlist still reclaims what it supersedes', async () => { // The guard must not cost the feature it guards: a replace writes a NEW random filename, so the // superseded copy lives at a different path and only the keep-set can find it. const { content, post } = load(); await seed(content, [A, B, OLD]); post({ type: 'st-cache-playlist', urls: [A], prune: true }); await settle(); assert.deepEqual(keys(content), [A], 'everything the display no longer needs is dropped'); }); test('an in-flight transfer\'s bookkeeping survives a prune of its own asset', async () => { // The chunk keys are not in the keep-set (they carry __st_part), so deleting them on the URL test // alone would restart that download on every 60s sweep — a resume that never completes. const { content, post } = load(); const chunk = A + '&__st_part=0'; const meta = A + '&__st_part=meta'; await seed(content, [B, chunk, meta]); post({ type: 'st-cache-playlist', urls: [A], prune: true }); await settle(); assert.deepEqual(keys(content), [chunk, meta].sort(), 'progress on a wanted asset is kept; B is not wanted'); }); test('prune:false never deletes, whatever the list says', async () => { const { content, post } = load(); await seed(content, [A, B]); post({ type: 'st-cache-playlist', urls: [A], prune: false }); await settle(); assert.deepEqual(keys(content), [A, B].sort()); }); test('a message that is not ours is ignored', async () => { const { content, post } = load(); await seed(content, [A, B]); for (const bad of [null, {}, { type: 'other', urls: [], prune: true }, { type: 'st-cache-playlist', prune: true }, { type: 'st-cache-playlist', urls: 'all', prune: true }]) { post(bad); } await settle(); assert.deepEqual(keys(content), [A, B].sort()); });