mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-15 06:43:27 -06:00
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
124 lines
5.5 KiB
JavaScript
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());
|
|
});
|