screentinker/server/test/sw-prune-guard.test.js
ScreenTinker 3d9ef039e5 Make the volume slider real, and stop an empty playlist wiping the cache
Three faults in the web player, each found by driving the shipped code in a
browser against the real server rather than by reading it.

set_volume did nothing at all. The dashboard sends `{ level: 0..1 }`
(device-detail.js: slider/100) and the Android player reads exactly that; this
player read `payload.value` and divided it by 100. Nothing in the product sends
`value`, so every browser panel acked the command and ignored it — the quietest
possible failure. Correcting only the key would have been worse than leaving it
broken: `level: 0.5` would have become 0.5%, which is inaudible and looks fixed.
The fraction is now canonical, `value` is still read as a percentage for
anything written against the old handler, and the scale is chosen by WHICH KEY
arrived rather than by the size of the number — 1 is legal in both conventions,
so a magnitude guess is guaranteed to be wrong for somebody. Parsing moved into
volumeLevelFromCommand() so it can be asserted without a socket.

setMediaVolume() also wrote `el.muted = (v === 0)`, so any non-zero volume
un-muted whatever was playing. An item an operator had deliberately silenced
started making noise the moment anyone touched the slider — reproduced live:
item flagged muted, one set_volume, muted went false. Mute has four inputs and
a fixed order (lib/media-mute.js), it is resolved when the element is mounted,
and a level is not entitled to overrule it — least of all the autoplay rule,
where unmuting without a gesture costs the video rather than winning the audio.
Volume 0 is silence on its own.

And the service worker pruned its content cache to an EMPTY keep-set.
`assignments: []` is what the server sends for a device between playlists, for a
playlist never published, and inside the `catch` when a published_snapshot fails
to parse — none of which mean "delete the media". Reproduced: three cached
assets, one empty payload, cache emptied. That is only survivable while the
uplink is up, which is precisely when the offline cache is worthless. A cache
kept too long costs disk the quota reclaims anyway.

Verified in Chrome against a live server: volume 0.42/0.8/0/0.25 land on the
element and survive an item change, a muted item stays muted through a volume
command, and three cached assets survive an empty push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 16:18:25 -05:00

124 lines
5.5 KiB
JavaScript

'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());
});