diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/test_cast_subtitles.py | 393 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_video_subtitles.py | 13 |
2 files changed, 401 insertions, 5 deletions
diff --git a/packages/meshbay-hub/tests/test_cast_subtitles.py b/packages/meshbay-hub/tests/test_cast_subtitles.py new file mode 100644 index 0000000..722a56a --- /dev/null +++ b/packages/meshbay-hub/tests/test_cast_subtitles.py @@ -0,0 +1,393 @@ +""" +Subtitles on a cast receiver. + +Casting takes a different road than the player does, and the difference that +matters is the clock. The node extracts a subtitle whole, so its cues carry the +film's own timeline; the player feeds its SourceBuffer a `timestampOffset` +equal to the stream's start, which puts the element's `currentTime` on that +same timeline and lets the cues be used as they arrive. + +A receiver has no such offset. The relay hands it the node's fragments +untouched, and those are rebased to zero at the seek point. Film time and +stream time therefore differ by exactly the start, and a document sent across +unshifted is wrong by however far the viewer had seeked — an hour into a film, +an hour wrong, with nothing in any log to say so. `shiftWebVtt` is the whole +correction and the first half of this file tests it by running it. + +The second half runs the relay itself. Unlike the Electron shell it is plain +Node with no dependencies, so it can be started, served from and stopped here. +Three of its properties are load-bearing and invisible when broken: the +subtitle sits behind the same token as the stream, it carries CORS headers +because a receiver fetches it with XHR rather than handing it to a media +element, and its URL changes when its content does — a side-loaded track is +cached by address, so a fixed URL would leave the old language on screen. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +APP = STATIC / "video-player.js" +CLIENT = Path(__file__).resolve().parents[2] / "meshbay-client" +RELAY = CLIENT / "src" / "cast-relay.js" +CHROMECAST = CLIENT / "src" / "cast-chromecast.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not APP.exists(), + reason="node or the SPA sources are not available") + + +def _lift(src: str, name: str) -> str: + """One top-level function, as text, for node to execute.""" + start = src.index(f"function {name}(") + depth, i, seen = 0, start, False + while i < len(src): + if src[i] == "{": + depth += 1 + seen = True + elif src[i] == "}": + depth -= 1 + if seen and depth == 0: + return src[start:i + 1] + i += 1 + raise AssertionError(f"{name} never closes") + + +def _run(tmp_path, body: str, *args: str): + script = tmp_path / "cast.mjs" + app = APP.read_text(encoding="utf-8") + script.write_text( + _lift(app, "shiftWebVtt") + + "\n" + _lift(app, "castSubtitleFor") + + "\n" + body, encoding="utf-8") + proc = subprocess.run( + ["node", str(script), *args], + capture_output=True, text=True, timeout=30) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +VTT = """WEBVTT + +NOTE this file was converted from an embedded track + +1 +00:00:10.000 --> 00:00:12.500 +Before the seek. + +2 +01:23:45.000 --> 01:23:47.250 line:90% +Hello. + +3 +01:30:00.000 --> 01:30:02.000 +Goodbye. +""" + + +def _timings(vtt: str): + return [line.strip() for line in vtt.splitlines() if "-->" in line] + + +# ── The clock ──────────────────────────────────────────────────────────────── + +def test_cues_move_back_by_the_streams_start(tmp_path): + """ + A stream that begins at 01:20:00 makes a cue at 01:23:45 land at 00:03:45. + + This is the correction the whole feature rests on. Without it the cues keep + the film's timeline while the receiver counts from zero, and the error is + the size of the seek rather than a small drift — invisible in code review, + unmistakable on screen. + """ + out = _run(tmp_path, """ + const shifted = shiftWebVtt(process.argv[2], -4800); + console.log(JSON.stringify(shifted)); + """, VTT) + assert "00:03:45.000 --> 00:03:47.250 line:90%" in out + assert "00:10:00.000 --> 00:10:02.000" in out + + +def test_a_stream_from_the_top_leaves_every_cue_where_it_was(tmp_path): + """No seek means no shift, and the document must come back unchanged.""" + out = _run(tmp_path, """ + console.log(JSON.stringify(shiftWebVtt(process.argv[2], 0))); + """, VTT) + assert _timings(out) == _timings(VTT) + + +def test_cues_before_the_stream_are_dropped_not_clamped(tmp_path): + """ + A cue that has already finished when the stream starts must disappear. + + Clamping it to zero instead would print a line from before the seek over + the first frames after it — the one failure mode that looks like a bug in + the extraction rather than in the arithmetic. + """ + out = _run(tmp_path, """ + console.log(JSON.stringify(shiftWebVtt(process.argv[2], -4800))); + """, VTT) + assert "Before the seek." not in out + assert len(_timings(out)) == 2 + + +def test_a_cue_straddling_the_seek_survives_and_starts_at_zero(tmp_path): + """ + Someone is mid-sentence when the viewer lands. The line is still owed to + them, so the cue is kept with its start pulled up to the stream's own. + """ + out = _run(tmp_path, """ + const vtt = 'WEBVTT\\n\\n1\\n00:00:08.000 --> 00:00:14.000\\nMid-sentence.\\n'; + console.log(JSON.stringify(shiftWebVtt(vtt, -10))); + """) + assert "00:00:00.000 --> 00:00:04.000" in out + assert "Mid-sentence." in out + + +def test_the_header_and_its_notes_travel_unchanged(tmp_path): + """ + A document that loses its `WEBVTT` line is not a WebVTT document, and a + receiver rejects it whole rather than complaining about one cue. + """ + out = _run(tmp_path, """ + console.log(JSON.stringify(shiftWebVtt(process.argv[2], -4800))); + """, VTT) + assert out.startswith("WEBVTT") + assert "NOTE this file was converted" in out + + +def test_comma_decimals_are_read_and_written_back_as_points(tmp_path): + """ + SRT writes `00:00:10,000` and some converters leave the comma in place. + Reading it and emitting the WebVTT spelling costs nothing and saves a + document that would otherwise be silently dropped cue by cue. + """ + out = _run(tmp_path, """ + const vtt = 'WEBVTT\\n\\n1\\n00:01:00,500 --> 00:01:02,000\\nComma.\\n'; + console.log(JSON.stringify(shiftWebVtt(vtt, -30))); + """) + assert "00:00:30.500 --> 00:00:32.000" in out + + +def test_the_payload_negates_the_start_it_is_given(tmp_path): + """ + `castSubtitleFor` takes where the stream *begins* and must subtract it. + Getting the sign wrong doubles the error instead of cancelling it, and both + directions produce a plausible-looking document. + """ + out = _run(tmp_path, """ + const sub = { text: process.argv[2], language: 'fr', label: 'French' }; + const payload = castSubtitleFor(sub, 4800); + console.log(JSON.stringify(payload)); + """, VTT) + assert "00:03:45.000 --> 00:03:47.250" in out["vtt"] + assert out["language"] == "fr" + assert out["label"] == "French" + + +def test_no_track_showing_means_no_payload(tmp_path): + """ + Subtitles off has to reach the relay as null, not as an empty document: an + empty WebVTT file is a track the receiver will happily display nothing + from, and its menu would still offer it. + """ + out = _run(tmp_path, """ + console.log(JSON.stringify([ + castSubtitleFor(null, 0), castSubtitleFor({ text: '' }, 0), + ])); + """) + assert out == [None, None] + + +# ── The relay ──────────────────────────────────────────────────────────────── + +relay_only = pytest.mark.skipif( + not RELAY.exists(), reason="desktop client sources not present") + +RELAY_SCRIPT = """ +const CastRelay = require(process.argv[2]); + +(async () => { + const relay = new CastRelay(); + const started = await relay.start({ + codec: 'avc1.640028', + initSegment: Buffer.from([0, 0, 0, 8, 102, 116, 121, 112]), + subtitle: { vtt: 'WEBVTT\\n\\n1\\n00:00:01.000 --> 00:00:02.000\\nHi.\\n', + language: 'fr', label: 'French' }, + }); + + const out = { streamUrl: started.url, subtitleUrl: started.subtitle.url }; + + const get = await fetch(out.subtitleUrl); + out.status = get.status; + out.contentType = get.headers.get('content-type'); + out.allowOrigin = get.headers.get('access-control-allow-origin'); + out.allowHeaders = get.headers.get('access-control-allow-headers'); + out.body = await get.text(); + + const noToken = await fetch(out.subtitleUrl.replace(/t=[0-9a-f]+/, 't=0')); + out.withoutToken = noToken.status; + + const preflight = await fetch(out.subtitleUrl, { method: 'OPTIONS' }); + out.preflight = preflight.status; + out.preflightAllowOrigin = + preflight.headers.get('access-control-allow-origin'); + + const second = relay.setSubtitle({ vtt: 'WEBVTT\\n\\n', language: 'en' }); + out.secondUrl = second.url; + + relay.setSubtitle(null); + out.afterClear = relay.subtitleUrl; + out.afterClearStatus = (await fetch(out.secondUrl)).status; + const stream = await fetch(out.streamUrl); + out.streamStillServes = stream.status; + out.streamAllowOrigin = stream.headers.get('access-control-allow-origin'); + + await relay.stop(); + console.log(JSON.stringify(out)); + process.exit(0); +})().catch((err) => { console.error(err); process.exit(1); }); +""" + + +@pytest.fixture(scope="module") +def served(tmp_path_factory): + script = tmp_path_factory.mktemp("relay") / "serve.cjs" + script.write_text(RELAY_SCRIPT, encoding="utf-8") + proc = subprocess.run( + ["node", str(script), str(RELAY)], + capture_output=True, text=True, timeout=60) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +@relay_only +def test_the_subtitle_is_served_as_webvtt(served): + assert served["status"] == 200 + assert served["contentType"].startswith("text/vtt") + assert served["body"].startswith("WEBVTT") + + +@relay_only +def test_the_subtitle_sits_behind_the_same_token_as_the_stream(served): + """ + The relay's only defence is that its URL cannot be guessed. A subtitle + path exempt from the token would hand the film's dialogue — often the + whole script — to anything on the Wi-Fi. + """ + assert served["withoutToken"] == 403 + + +@relay_only +def test_the_receiver_is_allowed_to_read_it(served): + """ + A side-loaded track is fetched with XHR from the receiver's own origin, so + without CORS it fails as a network error and the film plays on with no + subtitles and no message. `Range` is named because the receiver sends it + even for a document it reads whole. + """ + assert served["allowOrigin"] == "*" + for header in ("Content-Type", "Accept-Encoding", "Range"): + assert header in served["allowHeaders"] + + +@relay_only +def test_the_stream_carries_the_same_headers_as_its_subtitle(served): + """ + A receiver given a side-loaded track reads the media through the same + CORS-checked path, so headers on one and not the other fails the load + entirely rather than losing the subtitles alone. They widen nothing the + token does not already govern: a page holding the URL could put it in a + media element with or without them. + """ + assert served["streamAllowOrigin"] == "*" + + +@relay_only +def test_the_preflight_is_answered_before_the_token_is_checked(served): + """ + A browser sends `OPTIONS` without the credentials that would let it pass a + token check, so refusing it there would deny every well-formed request. + """ + assert served["preflight"] == 204 + assert served["preflightAllowOrigin"] == "*" + + +@relay_only +def test_a_new_subtitle_gets_a_new_address(served): + """ + A receiver caches a side-loaded track by its URL. Serving different cues + from a fixed address leaves the previous language on screen, which reads + as the switch having been ignored. + """ + assert served["secondUrl"] != served["subtitleUrl"] + + +@relay_only +def test_turning_subtitles_off_takes_the_file_away_but_not_the_film(served): + """ + Clearing the track must not disturb playback: the stream is the reason the + relay exists, and losing the picture to a subtitle change would be a far + worse failure than the one being fixed. + """ + assert served["afterClear"] is None + assert served["afterClearStatus"] == 404 + assert served["streamStillServes"] == 200 + + +# ── What the receiver is told ──────────────────────────────────────────────── + +chromecast_only = pytest.mark.skipif( + not CHROMECAST.exists(), reason="desktop client sources not present") + +CHROMECAST_SCRIPT = """ +const m = require(process.argv[2]); +const sub = { url: 'http://10.0.0.2:19550/subs.vtt?t=ab&v=3', + language: 'fr', label: 'French — Forced' }; +console.log(JSON.stringify({ + with: { media: m.mediaFor('http://10.0.0.2:19550/stream.mp4?t=ab', sub), + options: m.loadOptionsFor(sub) }, + without: { media: m.mediaFor('http://10.0.0.2:19550/stream.mp4?t=ab', null), + options: m.loadOptionsFor(null) }, +})); +""" + + +@pytest.fixture(scope="module") +def loaded(tmp_path_factory): + script = tmp_path_factory.mktemp("cc") / "media.cjs" + script.write_text(CHROMECAST_SCRIPT, encoding="utf-8") + proc = subprocess.run( + ["node", str(script), str(CHROMECAST)], + capture_output=True, text=True, timeout=60) + if proc.returncode != 0: + pytest.skip(f"cast-chromecast.js is not loadable here: {proc.stderr}") + return json.loads(proc.stdout) + + +@chromecast_only +def test_the_track_is_declared_and_switched_on(loaded): + """ + Declaring a track without naming it in `activeTrackIds` loads it and shows + nothing, which is the same symptom as not declaring it at all. + """ + track = loaded["with"]["media"]["tracks"][0] + assert track["type"] == "TEXT" + assert track["subtype"] == "SUBTITLES" + assert track["trackContentType"] == "text/vtt" + assert track["language"] == "fr" + assert loaded["with"]["options"]["activeTrackIds"] == [track["trackId"]] + + +@chromecast_only +def test_a_stream_without_subtitles_declares_none(loaded): + """ + A stale id in `activeTrackIds` is a load error on the receiver, and the + load error takes the film with it. + """ + assert "tracks" not in loaded["without"]["media"] + assert loaded["without"]["options"]["activeTrackIds"] == [] diff --git a/packages/meshbay-hub/tests/test_video_subtitles.py b/packages/meshbay-hub/tests/test_video_subtitles.py index 6867076..7fdc9cd 100644 --- a/packages/meshbay-hub/tests/test_video_subtitles.py +++ b/packages/meshbay-hub/tests/test_video_subtitles.py @@ -259,9 +259,12 @@ def test_a_failed_extraction_does_not_take_the_film_down(app): `setError` is the player's fatal path — it replaces the picture. A text track that could not be read must not reach it. """ - body = _player(app) - start = body.index("const selectSubtitle = useCallback(") - end = body.index("}, [entry, transportRef, gekRef]);", start) - assert "setError(" not in body[start:end], ( + # Matched on braces rather than on the dependency list. The deps are the + # part of a callback most likely to change for reasons unrelated to what is + # asserted here, and a slice keyed to them stops at `.index()` raising + # rather than at the property being broken — a guard that reports the wrong + # thing is barely better than one that reports nothing. + body = _block(_player(app), "const selectSubtitle = useCallback(") + assert "setError(" not in body, ( "a subtitle failure takes the whole player down") - assert "setSubtitleError(true)" in body[start:end] + assert "setSubtitleError(true)" in body |