summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/tests/harness/mse_harness.mjs37
-rw-r--r--packages/meshbay-hub/tests/harness/window_leak.mjs73
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py112
-rw-r--r--packages/meshbay-hub/tests/test_video_seek.py323
4 files changed, 538 insertions, 7 deletions
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}"