'use strict'; // Drives the REAL service worker (server/player/sw.js) against a fake Cache API and a deliberately // bad link. The policy tests cover the arithmetic; this covers the orchestration around it, which is // where a resumed download actually gets corrupted: appending the wrong chunk, publishing a // half-assembled asset, or serving a bookkeeping entry as if it were a video. // // It matters that this runs the shipped file rather than a copy — the worker cannot be exercised on // a device without deploying to one, and "the chunks assemble correctly" is not something you want // to discover from a panel showing a corrupt video. const { test, beforeEach } = 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 ASSET = 'http://s/uploads/content/clip.mp4?rev=100'; /** Cache API over a Map. Keys are URLs, exactly as the real one behaves for our uses. */ 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)); } } /** * A server for one asset. `failEvery` drops the connection on every Nth request (0 = never), which * is what a marginal link looks like from the client's side. */ function makeServer(body, { etag = '"v1"', failEvery = 0, rangeSupport = true } = {}) { const state = { body, etag, failEvery, rangeSupport, requests: 0, ranged: [] }; state.fetch = async (request) => { state.requests++; if (state.failEvery && state.requests % state.failEvery === 0) throw new Error('network dropped'); const range = request.headers.get('Range'); const ifRange = request.headers.get('If-Range'); if (!range || !state.rangeSupport) { return new Response(state.body, { status: 200, headers: { ETag: state.etag, 'Content-Type': 'video/mp4' } }); } // If-Range with a stale validator: the server must send the WHOLE asset, not a tail. This is // the mechanism that stops a resume splicing two different files together. if (ifRange && ifRange !== state.etag) { return new Response(state.body, { status: 200, headers: { ETag: state.etag, 'Content-Type': 'video/mp4' } }); } const m = /bytes=(\d+)-(\d*)/.exec(range); const start = Number(m[1]); if (start >= state.body.length) { return new Response('', { status: 416, headers: { 'Content-Range': `bytes */${state.body.length}` } }); } const end = m[2] === '' ? state.body.length - 1 : Math.min(Number(m[2]), state.body.length - 1); state.ranged.push([start, end]); return new Response(state.body.slice(start, end + 1), { status: 206, headers: { 'Content-Range': `bytes ${start}-${end}/${state.body.length}`, ETag: state.etag, 'Content-Type': 'video/mp4' } }); }; return state; } let sandbox, caches, server; function load(srv, chunkBytes) { server = srv; const contentCache = new FakeCache(); const shellCache = new FakeCache(); caches = { open: async (name) => (name === 'rd-content-v1' ? contentCache : shellCache), keys: async () => ['rd-content-v1'], delete: async () => true, match: async () => undefined, _content: contentCache }; sandbox = { caches, fetch: (req) => server.fetch(req), Response, Request, Blob, URL, console, location: { href: 'http://s/player/index.html' }, navigator: {}, importScripts() { // The worker importScripts()es the same policy module the Node tests require, so both sides // are provably the same rules rather than two implementations that agree today. delete require.cache[require.resolve(POLICY_PATH)]; sandbox.self.PlayerCachePolicy = require(POLICY_PATH); }, addEventListener() {}, skipWaiting() {}, clients: { claim() {} } }; sandbox.self = sandbox; vm.createContext(sandbox); vm.runInContext(SW_SRC, sandbox); // A 4MB production chunk would make these tests move 100MB around; the logic is size-agnostic. if (chunkBytes) sandbox.self.PlayerCachePolicy.CHUNK_BYTES = chunkBytes; return sandbox; } const bytes = (n, fill) => Buffer.alloc(n, fill); async function cachedBody(url = ASSET) { const hit = await caches._content.match(url); return hit ? Buffer.from(await hit.arrayBuffer()) : null; } const internalKeys = () => [...caches._content.map.keys()].filter((k) => k.includes('__st_part')); beforeEach(() => { sandbox = null; }); test('THE BUG: a link that drops every other request still assembles a byte-perfect asset', async () => { // Each call gets a chunk or two and dies. Without accumulation this is an infinite loop that // caches nothing — the panel keeps an empty cache and goes dark the moment the uplink does. const body = bytes(1000, 0x41); const sw = load(makeServer(body, { failEvery: 2 }), 100); for (let pass = 0; pass < 40 && !(await cachedBody()); pass++) { try { await sw.ensureCached(ASSET); } catch (e) { /* the link, not the worker */ } } const got = await cachedBody(); assert.ok(got, 'the asset must eventually be cached'); assert.deepEqual(got, body, 'reassembled bytes must be identical to the original'); }); test('the full entry appears only when whole — a fragment is never published', async () => { // The invariant that protects playback: a cache hit is always a complete asset. Publishing early // would hand a media element a truncated file that it cannot report as "incomplete", only as // broken. const body = bytes(1000, 0x42); const sw = load(makeServer(body, { failEvery: 3 }), 100); for (let pass = 0; pass < 40; pass++) { try { await sw.ensureCached(ASSET); } catch (e) { /* */ } const partial = await cachedBody(); if (partial) { assert.equal(partial.length, body.length, 'a published entry must be the whole asset'); break; } assert.ok(internalKeys().length >= 0); } assert.deepEqual(await cachedBody(), body); }); test('bookkeeping entries are cleaned up once the asset is whole', async () => { const body = bytes(500, 0x43); const sw = load(makeServer(body), 100); await sw.ensureCached(ASSET); assert.deepEqual(await cachedBody(), body); assert.deepEqual(internalKeys(), [], 'chunks and meta must not outlive the assembled asset'); }); test('an already-cached asset costs no requests at all', async () => { // The prefetch runs on every playlist sweep. Re-fetching a cached asset each time would be a // constant drain on the link least able to afford it. const srv = makeServer(bytes(500, 0x44)); const sw = load(srv, 100); await sw.ensureCached(ASSET); const after = srv.requests; await sw.ensureCached(ASSET); assert.equal(srv.requests, after, 'a second pass over a cached asset must not touch the network'); }); test('an asset replaced mid-transfer is NOT spliced — the chunks are discarded', async () => { // The corruption this whole design exists to prevent: appending the tail of the new asset to the // head of the old one yields a file of exactly the right length that is wrong throughout, and it // would pass every completeness check we have. const v1 = bytes(1000, 0x61); const v2 = bytes(1000, 0x62); const srv = makeServer(v1, { failEvery: 3 }); const sw = load(srv, 100); try { await sw.ensureCached(ASSET); } catch (e) { /* */ } assert.ok(internalKeys().length > 0, 'expected partial progress to exist before the swap'); srv.body = v2; srv.etag = '"v2"'; srv.failEvery = 0; // The first pass after the swap gets a 200 from If-Range and must throw the v1 chunks away // rather than continue on top of them; the next rebuilds from scratch. await sw.ensureCached(ASSET); for (let pass = 0; pass < 10 && !(await cachedBody()); pass++) await sw.ensureCached(ASSET); const got = await cachedBody(); assert.ok(got, 'the replaced asset must still end up cached'); assert.deepEqual(got, v2, 'the cached asset must be all-v2, with no v1 bytes spliced in'); assert.ok(!got.includes(0x61), 'not one byte of the superseded asset may survive'); }); test('a superseded revision is swept rather than left to fill the quota', async () => { // Replacing an asset changes the revision in the URL, which is what makes the new bytes a miss. // Without the sweep the old copy sits there until the quota evicts it — on a 1GB panel budget a // handful of replaced videos is the whole cache. const oldUrl = 'http://s/uploads/content/clip.mp4?rev=100'; const newUrl = 'http://s/uploads/content/clip.mp4?rev=200'; const sw = load(makeServer(bytes(300, 0x45)), 100); await sw.ensureCached(oldUrl); assert.ok(await cachedBody(oldUrl)); server.body = bytes(300, 0x46); server.etag = '"v2"'; await sw.ensureCached(newUrl); assert.ok(await cachedBody(newUrl), 'the new revision is cached'); assert.equal(await cachedBody(oldUrl), null, 'the superseded revision is gone'); }); test('a server with no range support still caches the asset whole', async () => { // Not every deployment sits behind something that honours Range. Falling back to a plain store is // the pre-existing behaviour and remains correct — just not resumable. const body = bytes(600, 0x47); const sw = load(makeServer(body, { rangeSupport: false }), 100); await sw.ensureCached(ASSET); assert.deepEqual(await cachedBody(), body); }); test('a chunked transfer asks for each range exactly once, in order', async () => { const sw = load(makeServer(bytes(1000, 0x48)), 250); await sw.ensureCached(ASSET); const starts = server.ranged.map((r) => r[0]); assert.deepEqual(starts, [0, 250, 500, 750], 'no gaps, no repeats, no overlap'); });