diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-02 08:39:56 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-02 08:39:56 +0200 |
| commit | e941cc4c39c38a12220153ea572bd4c7bb92fde0 (patch) | |
| tree | b7a0c1f04eb642f4aa050508b3e24bb4cf66a67a /packages/meshbay-hub/tests/test_cast_discovery.py | |
| parent | fee265e0046e85c3db0e5bb9608b666d2e9547ab (diff) | |
| download | meshbay-e941cc4c39c38a12220153ea572bd4c7bb92fde0.tar.gz | |
feat(client): list cast receivers as they answer
The scan still runs six seconds, but the picker polls what it has found
and shows each receiver immediately. A rescan no longer has its timer
cut short by the scan it replaced.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_cast_discovery.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_cast_discovery.py | 108 |
1 files changed, 108 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_cast_discovery.py b/packages/meshbay-hub/tests/test_cast_discovery.py new file mode 100644 index 0000000..9e9ce85 --- /dev/null +++ b/packages/meshbay-hub/tests/test_cast_discovery.py @@ -0,0 +1,108 @@ +""" +Cast receivers are listed as they answer, not at the end of the scan. + +The scan runs its full length because a receiver coming back from a reset can +take several seconds to answer, but most answer within two; a picker that showed +nothing until the end was slow every time it was opened. These tests run the real +`CastChromecast` with mDNS replaced by a stub that answers on cue, and the clock +shortened, so what they measure is what the page's poll would see. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +CLIENT = Path(__file__).resolve().parents[2] / "meshbay-client" +CHROMECAST = CLIENT / "src" / "cast-chromecast.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not CHROMECAST.exists(), + reason="node or the desktop client sources are not available") + +# Stubs the two dependencies at `require` time, and makes six seconds of scan +# last sixty milliseconds by scaling every timer the module arms. +SCRIPT = r""" +const Module = require('node:module'); +const browsers = []; +const realLoad = Module._load; +Module._load = function (request, ...rest) { + if (request === 'bonjour-service') { + return { Bonjour: class { + find(_q, onUp) { + const b = { onUp, stopped: false, stop() { this.stopped = true; } }; + browsers.push(b); + return b; + } + } }; + } + if (request === 'castv2-client') return { Client: class {}, DefaultMediaReceiver: {} }; + return realLoad.call(this, request, ...rest); +}; +const realSetTimeout = global.setTimeout; +global.setTimeout = (fn, ms, ...a) => realSetTimeout(fn, ms / 100, ...a); +const wait = (ms) => new Promise((r) => realSetTimeout(r, ms)); + +const CastChromecast = require(process.argv[2]); +const tv = (id) => ({ name: id, txt: { id, fn: `TV ${id}` }, + addresses: ['10.0.0.9'], port: 8009 }); + +(async () => { + const cc = new CastChromecast(); + const out = {}; + + cc.startScan(); + out.atStart = cc.devices(); + browsers[0].onUp(tv('a')); + out.afterFirstAnswer = cc.devices(); + await wait(100); + out.afterScan = cc.devices(); + out.firstBrowserStopped = browsers[0].stopped; + + // A second scan started over a first: the first's timer must not end it. + cc.startScan(); + await wait(40); + cc.startScan(); + await wait(40); + out.restartedStillScanning = cc.devices().scanning; + out.restartClearedOldDevices = cc.devices().devices.length === 0; + await wait(60); + out.restartedEnds = !cc.devices().scanning; + console.log(JSON.stringify(out)); +})(); +""" + + +@pytest.fixture(scope="module") +def run(tmp_path_factory): + script = tmp_path_factory.mktemp("cd") / "discover.cjs" + script.write_text(SCRIPT, encoding="utf-8") + proc = subprocess.run( + ["node", str(script), str(CHROMECAST)], + capture_output=True, text=True, encoding="utf-8", timeout=60) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_a_receiver_is_listed_before_the_scan_ends(run): + assert run["atStart"] == {"devices": [], "scanning": True} + assert run["afterFirstAnswer"]["scanning"] is True + assert [d["name"] for d in run["afterFirstAnswer"]["devices"]] == ["TV a"] + + +def test_the_scan_ends_on_its_own_and_keeps_what_it_found(run): + assert run["afterScan"]["scanning"] is False + assert [d["id"] for d in run["afterScan"]["devices"]] == ["a"] + assert run["firstBrowserStopped"] is True + + +def test_a_new_scan_is_not_cut_short_by_the_one_it_replaced(run): + """ + The old code left the first scan's timer armed, and it stopped whichever + browser was current when it fired — the new one, two thirds early. + """ + assert run["restartedStillScanning"] is True + assert run["restartClearedOldDevices"] is True + assert run["restartedEnds"] is True |