From 42047dac4041e72e09499e3adf145f1c0f83b284 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 17 Aug 2026 02:16:28 +0200 Subject: test(hub): a hook that depends on one declared below it never runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `const a = useCallback(fn, [b])` evaluates `[b]` where it is written, so a `b` further down the component is still in its temporal dead zone. ReferenceError on every render, before anything the component does can run — and the symptom is the component simply not appearing. Clicking a video did nothing at all: no picture, no error on screen, nothing in the node's log because nothing was ever requested. It reached production. Nothing caught it. `node --check` passes, the code is well-formed. Worse, the MSE harness extracts the player functions into an order of its own and therefore *reordered* them before running — quietly repairing the one class of defect it was best placed to catch. It sorts by position in the file now, and test_hook_ordering.py checks the property directly across the whole SPA. Both the rule and the harness are checked against the layout that actually shipped. test_video_seek.py covers the rest of seeking, and window_leak.mjs forces the race that made the third seek hang: the whole in-flight window arriving while `reinitAt` is still awaiting. Before, the player is left believing eight segments are in flight and grants nothing; after, the window comes back. A run that happens to work proves nothing about a race, which is the point of forcing the worst case rather than trusting a longer session. --- packages/meshbay-hub/tests/harness/mse_harness.mjs | 37 ++- packages/meshbay-hub/tests/harness/window_leak.mjs | 73 +++++ packages/meshbay-hub/tests/test_hook_ordering.py | 112 +++++++ packages/meshbay-hub/tests/test_video_seek.py | 323 +++++++++++++++++++++ 4 files changed, 538 insertions(+), 7 deletions(-) create mode 100644 packages/meshbay-hub/tests/harness/window_leak.mjs create mode 100644 packages/meshbay-hub/tests/test_hook_ordering.py create mode 100644 packages/meshbay-hub/tests/test_video_seek.py (limited to 'packages/meshbay-hub') diff --git a/packages/meshbay-hub/tests/harness/mse_harness.mjs b/packages/meshbay-hub/tests/harness/mse_harness.mjs index 0daacfb..437a542 100644 --- a/packages/meshbay-hub/tests/harness/mse_harness.mjs +++ b/packages/meshbay-hub/tests/harness/mse_harness.mjs @@ -31,7 +31,13 @@ const grab = (name) => { }; const useCallback = (fn) => fn; -const src = ['bufferedAhead', 'evictBehind', 'flushQueue', 'pump'] +// Sorted by where they appear in app.js, not by the order this list happens to +// be written in. Extracting into an order of our own would quietly repair a +// hook declared before its own dependency — a real fault, which reached +// production once, and which the harness is otherwise well placed to catch. +const src = ['currentRange', 'bufferedAhead', 'evictBehind', 'flushQueue', 'pump'] + .sort((a, b) => app.indexOf(`const ${a} = useCallback(`) + - app.indexOf(`const ${b} = useCallback(`)) .map(grab).join('\n'); const SEG = 256 * 1024; @@ -68,17 +74,34 @@ const sb = { const e = new Error('quota'); e.name = 'QuotaExceededError'; throw e; } const at = ranges.length ? ranges[ranges.length - 1][1] : 0; - ranges.push([at, at + chunk.byteLength / BITRATE]); + const end = at + chunk.byteLength / BITRATE; + // A real SourceBuffer coalesces contiguous ranges: `buffered` reports the + // spans of media it holds, not the appends that built them. Pushing one + // range per segment made every range a couple of seconds long, which is + // invisible to code that reads `end(length - 1)` and fatal to code that + // looks for the range around the playhead. + if (ranges.length && Math.abs(ranges[ranges.length - 1][1] - at) < 0.001) { + ranges[ranges.length - 1][1] = end; + } else { + ranges.push([at, end]); + } bytes += chunk.byteLength; appended++; }, remove(a, b) { removes++; + // remove(a, b) takes a span out of whatever it overlaps, trimming a range + // rather than only dropping whole ones — otherwise a coalesced range is + // never evicted at all and the buffer grows without limit. let dropped = 0; - ranges = ranges.filter(([s, e]) => { - if (e <= b && s >= a) { dropped += (e - s) * BITRATE; return false; } - return true; - }); + const kept = []; + for (const [s, e] of ranges) { + if (e <= a || s >= b) { kept.push([s, e]); continue; } + if (s < a) kept.push([s, a]); + if (e > b) kept.push([b, e]); + dropped += (Math.min(e, b) - Math.max(s, a)) * BITRATE; + } + ranges = kept; bytes -= dropped; // A real remove() is asynchronous and fires updateend when it lands. That // event is indistinguishable from an append's unless the player kept track. @@ -108,7 +131,7 @@ const fns = new Function( 'sbRef,videoRef,msRef,queueRef,appendingRef,endedRef,outstandingRef,' + 'lastPokeRef,transportRef,BUFFER_BEHIND_S,BUFFER_AHEAD_S,QUEUE_HIGH_WATER,' + 'CREDIT_KEEPALIVE_MS,STREAM_WINDOW,quotaRef,stalledRef,console,useCallback', - src + '\n return {bufferedAhead, evictBehind, flushQueue, pump};' + src + '\n return {currentRange, bufferedAhead, evictBehind, flushQueue, pump};' )(sbRef, videoRef, msRef, queueRef, appendingRef, endedRef, outstandingRef, lastPokeRef, transportRef, BUFFER_BEHIND_S, BUFFER_AHEAD_S, QUEUE_HIGH_WATER, CREDIT_KEEPALIVE_MS, STREAM_WINDOW, quotaRef, stalledRef, console, useCallback); diff --git a/packages/meshbay-hub/tests/harness/window_leak.mjs b/packages/meshbay-hub/tests/harness/window_leak.mjs new file mode 100644 index 0000000..ae6d46e --- /dev/null +++ b/packages/meshbay-hub/tests/harness/window_leak.mjs @@ -0,0 +1,73 @@ +// Run the shipped onStreamData against a seek that lands badly. +// +// The defect was a race: `reinitAt` waits for two `updateend` events, and the +// segments the node sends in that gap are discarded. Whether the player +// survived depended on how many arrived before the gap closed — which is why +// the same seek worked twice and hung on the third, and why "it works now" is +// not on its own evidence that it is fixed. +// +// So force the worst case. Every in-flight segment arrives during the gap. The +// question is only whether the window comes back. +// +// The handler is lifted out of app.js as text, like the rest of the harness. +// What is modelled is the transport and the clock. +import { readFileSync } from 'fs'; + +const app = readFileSync(process.argv[2], 'utf8'); +const cfg = JSON.parse(process.argv[3] || '{}'); +const { decrementFirst = true } = cfg; // false reproduces the shipped defect + +const STREAM_WINDOW = Number(app.match(/const STREAM_WINDOW = (\d+)/)[1]); + +// The body of `transport.onStreamData = async (msg) => { ... }`. +const start = app.indexOf('transport.onStreamData = async (msg) => {'); +const body = app.slice(app.indexOf('{', start) + 1, + app.indexOf('\n };', start)); + +// The A/B: put the decrement back after the guards, which is where it was. +const DECREMENT = 'outstandingRef.current = Math.max(0, outstandingRef.current - 1);'; +let source = body; +if (!decrementFirst) { + source = source.replace(DECREMENT, ''); + source = source.replace('if (msg.file_id && msg.file_id !== entry.id) return;', + 'if (msg.file_id && msg.file_id !== entry.id) return;\n' + DECREMENT); +} + +const outstandingRef = { current: STREAM_WINDOW }; +const awaitingInitRef = { current: true }; // mid-reinit, as after a seek +const queueRef = { current: [] }; +const entry = { id: 'abc' }; +let cancelled = false; +const pump = () => {}; +const flushQueue = () => {}; +const gekRef = { current: null }; +const window_ = { MeshBayCrypto: { decryptChunkBin: async () => new Uint8Array(4) } }; + +const handler = new Function( + 'msg', 'cancelled', 'awaitingInitRef', 'outstandingRef', 'queueRef', + 'entry', 'gekRef', 'pump', 'flushQueue', 'console', 'window', + `return (async () => {${source}})();`); + +const deliver = (n) => Promise.all( + Array.from({ length: n }, (_, i) => handler( + { file_id: entry.id, segment_index: i, nonce: 'n', ct: 'c' }, + cancelled, awaitingInitRef, outstandingRef, queueRef, entry, gekRef, + pump, flushQueue, console, window_))); + +const run = async () => { + // The whole window arrives while reinitAt is still awaiting its updateends. + await deliver(STREAM_WINDOW); + const duringGap = outstandingRef.current; + // reinitAt finishes and lowers the flag. + awaitingInitRef.current = false; + // pump() would now top the window up to STREAM_WINDOW - outstanding. + const roomAfterwards = STREAM_WINDOW - outstandingRef.current; + console.log(JSON.stringify({ + decrementFirst, + windowAfterDiscards: duringGap, + creditPumpWouldGrant: roomAfterwards, + deadlocked: roomAfterwards <= 0, + })); +}; + +run(); diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py new file mode 100644 index 0000000..cd9a11e --- /dev/null +++ b/packages/meshbay-hub/tests/test_hook_ordering.py @@ -0,0 +1,112 @@ +""" +A hook cannot depend on one declared below it. + +`const a = useCallback(fn, [b])` evaluates `[b]` where it is written. If `b` is +another `const` further down the component, it is still in its temporal dead +zone and the array throws `ReferenceError: Cannot access 'b' before +initialization` — during render, every render, before anything the component +does can run. + +The symptom is the component simply not appearing. Clicking a video did +nothing at all: no picture, no error on screen, nothing in the node's log +because nothing was ever requested. It reached production. + +Nothing else catches it. `node --check` validates syntax and this is +well-formed. The MSE harness runs the same functions but extracts them into a +list of its own choosing, so it *reorders* them and cannot see an ordering +fault — it is now ordered by position in the file for that reason, and this +test covers the case directly. +""" + +import re +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +APP = STATIC / "app.js" + +pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable") + +# `const NAME = useCallback(` / `useMemo(` — the declarations that both define a +# binding and take a dependency array. +DECL = re.compile(r"^ const (\w+) = (?:useCallback|useMemo)\(", re.M) +# The closing `}, [a, b]);` of such a declaration. +DEPS = re.compile(r"^ \}, \[([^\]]*)\]\);", re.M) + + +@pytest.fixture(scope="module") +def app(): + return APP.read_text() + + +def _components(app: str): + """Each top-level component, with the offset it starts at.""" + for m in re.finditer(r"^function ([A-Z]\w*)\(", app, re.M): + start = m.start() + nxt = app.find("\nfunction ", start + 1) + yield m.group(1), app[start:nxt if nxt > 0 else len(app)] + + +def test_no_hook_depends_on_something_declared_below_it(app): + """The whole file, not just the player that was broken by it.""" + problems = [] + for name, body in _components(app): + # Where each hook binding becomes usable. + declared_at = {m.group(1): m.start() for m in DECL.finditer(body)} + for deps in DEPS.finditer(body): + for dep in (d.strip() for d in deps.group(1).split(",")): + if not dep or dep not in declared_at: + continue + if declared_at[dep] > deps.start(): + problems.append( + f"{name}: a hook at offset {deps.start()} lists `{dep}` " + f"as a dependency, but `{dep}` is declared below it") + assert not problems, ( + "a dependency array is evaluated where it is written, so this throws " + "on every render and the component never appears:\n " + + "\n ".join(problems)) + + +def test_the_check_would_notice(app): + """A test that cannot fail proves nothing — so make it fail on purpose. + + Swaps two declarations in the real file and confirms the rule fires. If + this stops working the rule above has quietly become decoration. + """ + body = next(b for n, b in _components(app) if n == "VideoPlayer") + decls = list(DECL.finditer(body)) + assert len(decls) >= 2, "VideoPlayer has too few hooks to test the check" + + # Build a body where the first hook depends on the last one, declared after. + first, last = decls[0].group(1), decls[-1].group(1) + broken = body.replace(decls[0].group(0), + decls[0].group(0), 1) + # Inject a dependency on `last` into the first declaration's dep array. + end = broken.index("\n }, [", decls[0].start()) + close = broken.index("]", end) + broken = broken[:close] + (", " if broken[end + 7:close].strip() else "") + last + broken[close:] + + declared_at = {m.group(1): m.start() for m in DECL.finditer(broken)} + caught = False + for deps in DEPS.finditer(broken): + for dep in (d.strip() for d in deps.group(1).split(",")): + if dep in declared_at and declared_at[dep] > deps.start(): + caught = True + assert caught, ( + f"made `{first}` depend on `{last}` which is declared after it, and the " + "rule did not fire — it is not checking what it claims to") + + +def test_the_mse_harness_reads_functions_in_source_order(): + """Otherwise it hides exactly this fault. + + The harness exists to run the shipped code rather than a paraphrase of it. + Extracting into an order of its own quietly repairs an ordering bug before + running it, which is the one class of defect it would otherwise be well + placed to catch. + """ + harness = (Path(__file__).parent / "harness" / "mse_harness.mjs").read_text() + assert "sort" in harness and "indexOf" in harness, ( + "the harness still extracts the player functions in a hardcoded order, " + "so it cannot see one declared before its own dependency") diff --git a/packages/meshbay-hub/tests/test_video_seek.py b/packages/meshbay-hub/tests/test_video_seek.py new file mode 100644 index 0000000..51f65ce --- /dev/null +++ b/packages/meshbay-hub/tests/test_video_seek.py @@ -0,0 +1,323 @@ +""" +Seeking in a stream, and picking a film up where it was left. + +Until now the scrubber was a lie: `ms.duration` was set to the whole film, so +the bar was drawn full length, and `onSeeking` quietly clamped any target back +into what happened to be buffered. The stream itself only ever ran forwards +from byte zero. + +Seeking is a stream restarted somewhere else. The node already knew how — the +legacy HLS path passes `-ss` — so `stream_req` gained a `start`, ffmpeg is +spawned with `-ss` **before** `-i` (seeking by the container index, milliseconds +on a 500 MB film rather than tens of seconds of decoding), and the client puts +the fragments back on the film's timeline with `SourceBuffer.timestampOffset`. + +Three things about that are easy to get wrong and are what these tests hold: + +**ffmpeg restarts its timestamps at zero** however far in it seeks — measured, +`-copyts` does not change it for this input. So the offset has to come from the +client, and the node has to say which position it actually used. + +**The channel is ordered, and a seek does not change the file.** Everything +between asking for a seek and its `stream_init` belongs to the stream being +abandoned, and `file_id` cannot tell them apart. Appending it would put the old +material on top of the new. + +**A seek makes the buffer discontinuous.** Every piece of code that reads +`buffered` then has to mean a particular range: "the last one" stops being "the +one being watched", and measuring the read-ahead across a gap reports a full +buffer while the player starves. +""" + +import re +import shutil +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +APP = STATIC / "app.js" +TRANSPORT = STATIC / "transport.js" +NODE_SERVER = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src" + / "meshbay_node" / "transport" / "webrtc_server.py") + +pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable") + + +@pytest.fixture(scope="module") +def app(): + return APP.read_text() + + +def _player(app: str) -> str: + i = app.index("function VideoPlayer(") + return app[i:app.index("\nfunction ", i + 1)] + + +# ── The node ────────────────────────────────────────────────────────────────── + +@pytest.fixture(scope="module") +def stream_fn(): + if not NODE_SERVER.exists(): + pytest.skip("node sources unavailable") + text = NODE_SERVER.read_text() + i = text.index("async def _stream_video_inner") + return text[i:text.index("\n async def ", i + 1)] + + +def test_the_seek_is_an_index_lookup_not_a_decode(stream_fn): + """`-ss` before `-i`, which is the difference between instant and unusable. + + After `-i` it means "decode and discard until you get there" — tens of + seconds on a long film, per seek. Before it, ffmpeg uses the container's + index and starts almost at once. + """ + args = stream_fn[stream_fn.index("create_subprocess_exec"):] + args = args[:args.index("stdout=")] + assert "seek_args" in args, "the stream is still spawned without a seek" + assert args.index("*seek_args") < args.index('"-i"'), ( + "-ss lands after -i, which decodes the whole film up to the seek point") + + +def test_the_node_says_where_it_actually_started(stream_fn): + """The client cannot infer it. + + ffmpeg restarts its output timestamps at zero however far in it seeks, so + the offset that puts the fragments back on the timeline has to be told. + """ + init = stream_fn[stream_fn.index("MNP.STREAM_INIT"):] + init = init[:init.index("})")] + assert re.search(r'"start":\s*start', init), ( + "stream_init carries no start, so the client has nothing to offset by") + + +def test_seeking_past_the_end_is_pulled_back(stream_fn): + """Otherwise ffmpeg produces nothing and the player waits for ever.""" + assert "duration - 1" in stream_fn and "duration - 5" in stream_fn, ( + "a seek to or past the end is not clamped — the stream would be empty " + "and the player would sit on 'buffering' with nothing coming") + assert "start = max(0.0, start)" in stream_fn, "a negative start is not refused" + + +def test_the_request_carries_it(): + text = TRANSPORT.read_text() + fn = text[text.index("requestStream(fileId"):] + fn = fn[:fn.index("\n }")] + assert "start" in fn, "requestStream cannot express a seek" + + +# ── Old material must not land on the new stream ────────────────────────────── + +def test_everything_before_the_new_init_is_dropped(app): + """A seek does not change the file, so `file_id` cannot separate them. + + Ordering can: the node retires the previous stream before sending the new + `stream_init`, so anything arriving in between is the film we left. + """ + player = _player(app) + for handler in ("onStreamData", "onStreamEnd"): + body = player[player.index(f"transport.{handler} = "):] + body = body[:body.index("\n };")] + assert "awaitingInitRef.current" in body, ( + f"{handler} accepts data from the stream being abandoned; on " + "onStreamData that appends the old film over the new one, on " + "onStreamEnd it truncates the film at the seek point") + + +def test_a_discarded_segment_still_frees_its_place_in_the_window(app): + """The leak that made the third seek hang. + + `reinitAt` waits for two `updateend` events, and the seek's first segments + arrive during that gap and are discarded by the flag above. If the window + is only decremented after those early returns, each discarded segment takes + a slot with it. Lose the whole window and the player believes eight + segments are in flight, grants nothing ever again, and the node waits for + credit that cannot come — while its log shows a stream it fed perfectly + well. A race, which is why it worked twice and hung on the third try. + """ + player = _player(app) + body = player[player.index("transport.onStreamData = "):] + body = body[:body.index("\n };")] + + decrement = body.index("outstandingRef.current = Math.max(0") + for guard in ("awaitingInitRef.current", "msg.file_id !== entry.id"): + assert body.index(guard) > decrement, ( + f"the window is decremented after the `{guard}` check, so every " + "segment discarded there is a slot lost from the window for good") + + +def test_the_leak_deadlocks_the_window_and_the_fix_clears_it(): + """The same defect, run rather than read. + + It was a race — whether the player survived a seek depended on how many + segments arrived before `reinitAt` finished — so a run that works proves + little on its own. This forces the worst case: the whole window arrives + while the flag is up. Then the only question left is arithmetic. + """ + import json + import subprocess + + harness = Path(__file__).parent / "harness" / "window_leak.mjs" + if shutil.which("node") is None: + pytest.skip("node is not available") + + def run(decrement_first: bool) -> dict: + proc = subprocess.run( + ["node", str(harness), str(APP), + json.dumps({"decrementFirst": decrement_first})], + capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + was = run(False) + assert was["deadlocked"], ( + "putting the decrement back after the guards no longer deadlocks the " + "window, so this test no longer describes the defect it guards") + now = run(True) + assert not now["deadlocked"], ( + f"the window is still {now['windowAfterDiscards']} after discarding a " + "full window's worth of segments — the player would grant no more " + "credit and the node would wait for ever") + + +def test_the_window_is_topped_up_by_something_other_than_arrivals(app): + """Because the arrival that would have done it is the one being discarded.""" + player = _player(app) + assert "setInterval(pump" in player, ( + "only an accepted segment tops the window up, so a window emptied by " + "discards has nothing to refill it") + + +def test_a_new_stream_clears_the_seek_state(app): + """The same shape as `appendingRef` before it, and worse. + + Switching film while a seek is in flight leaves `awaitingInit` true, and + only `reinitAt` lowers it — which the next film never reaches, because it + builds a new SourceBuffer and takes the first-init path. Every segment of + the new film is then discarded as though it belonged to the old one. + """ + player = _player(app) + effect = player[player.index("useEffect(() => {\n let cancelled = false;"):] + effect = effect[:effect.index("transport.requestStream")] + assert "awaitingInitRef.current = false" in effect, ( + "a seek in flight when the film changes silences the next one entirely") + assert "seekTargetRef.current = null" in effect, ( + "the next film jumps to a position from the previous one") + + +def test_the_flag_is_raised_when_the_seek_is_requested_and_cleared_on_init(app): + player = _player(app) + seek = player[player.index("const requestSeek = "):] + seek = seek[:seek.index("\n };")] + assert "awaitingInitRef.current = true" in seek, ( + "nothing marks the gap between asking and being answered") + reinit = player[player.index("const reinitAt = "):] + reinit = reinit[:reinit.index("\n };")] + assert "awaitingInitRef.current = false" in reinit, ( + "the flag is never lowered, so the new stream is dropped too") + + +# ── The buffer after a seek ─────────────────────────────────────────────────── + +def test_the_parser_is_reset_before_the_next_stream_is_appended(app): + """ffmpeg was killed mid-fragment, so the parser holds half of one.""" + player = _player(app) + reinit = player[player.index("const reinitAt = "):] + reinit = reinit[:reinit.index("\n };")] + assert "sb.abort()" in reinit, ( + "the SourceBuffer keeps the half fragment it was parsing, and the next " + "stream's header lands on top of it") + assert "sb.remove(0, Infinity)" in reinit, ( + "the old material is kept, so the buffer is discontinuous for the rest " + "of the film") + assert "sb.timestampOffset = start" in reinit, ( + "the new fragments are not placed on the film's timeline") + + +def test_the_read_ahead_is_measured_on_the_range_being_watched(app): + """"The last range" stops meaning "the one playing" once there is a gap.""" + player = _player(app) + for fn in ("bufferedAhead", "evictBehind"): + body = player[player.index(f"const {fn} = useCallback("):] + body = body[:body.index("\n }, [")] + assert "currentRange()" in body, ( + f"{fn} still assumes one contiguous range: after a seek it reads " + "a range on the far side of a gap") + + +def test_the_mode_places_fragments_rather_than_stacking_them(app): + """'sequence' concatenates; a stream that starts at 40 minutes must not.""" + player = _player(app) + assert "sb.mode = 'segments'" in player, ( + "in 'sequence' mode the fragments are laid end to end, so a stream " + "starting mid-film is buffered at zero and the scrubber lies") + + +def test_the_playhead_waits_for_the_data(app): + """Seeking into an unbuffered region leaves the element with nothing.""" + player = _player(app) + assert "const landPlayhead" in player, ( + "currentTime is set without checking the data for it has arrived") + land = player[player.index("const landPlayhead = "):] + land = land[:land.index("\n };")] + assert "sb.buffered" in land and "seekTargetRef.current = null" in land, ( + "the target is not checked against what is buffered, or never cleared") + + +# ── Scrubbing must not be a storm of ffmpeg ─────────────────────────────────── + +def test_seeks_are_debounced(app): + """Dragging fires `seeking` continuously; each one we act on costs a spawn.""" + player = _player(app) + assert "SEEK_DEBOUNCE_MS" in player, "every intermediate drag position seeks" + ms = int(re.search(r"const SEEK_DEBOUNCE_MS = (\d+)", APP.read_text()).group(1)) + assert 150 <= ms <= 1000, ( + f"{ms} ms is either short enough to still storm the node or long " + "enough to feel broken") + + +def test_a_seek_inside_the_buffer_does_not_reach_the_node(app): + """The browser already has it; restarting ffmpeg for it would be absurd.""" + player = _player(app) + body = player[player.index("const onSeeking = "):] + body = body[:body.index("\n };")] + assert "sb.buffered" in body and "return" in body, ( + "onSeeking asks the node even for a position already buffered") + + +# ── Resume ──────────────────────────────────────────────────────────────────── + +def test_the_position_is_kept_in_this_browser(app): + """localStorage: no protocol, no storage for anyone else to keep, and + nothing new learns what you watch.""" + src = APP.read_text() + assert "mb:pos:" in src, "no position is stored" + read = src[src.index("function readResumePosition"):] + read = read[:read.index("\n}")] + assert "catch" in read, ( + "localStorage throws in private browsing and with storage disabled, " + "and a player that cannot start there is worse than one that forgets") + + +def test_a_finished_film_does_not_offer_to_resume(app): + src = APP.read_text() + write = src[src.index("function writeResumePosition"):] + write = write[:write.index("\n}")] + assert "RESUME_MAX_FRACTION" in write and "removeItem" in write, ( + "a position at the credits is kept, so reopening the film resumes " + "thirty seconds before the end for ever") + assert "RESUME_MIN_S" in write, "the first seconds are remembered as a position" + + +def test_the_viewer_can_refuse_the_resume(app): + player = _player(app) + assert "video.from_start" in player, ( + "resuming is imposed with no way back to the beginning") + + +@pytest.mark.parametrize("locale", ["en", "fr", "es", "pt-BR", "zh-CN", "ja", + "de", "it", "nl", "pl"]) +def test_the_resume_strings_exist_everywhere(locale): + text = (STATIC / "locales" / f"{locale}.js").read_text() + for key in ("video.resumed_at", "video.from_start"): + assert key in text, f"{locale} is missing {key}" -- cgit v1.2.3