aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/cast_session_probe.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/harness/cast_session_probe.py')
-rw-r--r--packages/meshbay-hub/tests/harness/cast_session_probe.py166
1 files changed, 147 insertions, 19 deletions
diff --git a/packages/meshbay-hub/tests/harness/cast_session_probe.py b/packages/meshbay-hub/tests/harness/cast_session_probe.py
index 2904860..573dd95 100644
--- a/packages/meshbay-hub/tests/harness/cast_session_probe.py
+++ b/packages/meshbay-hub/tests/harness/cast_session_probe.py
@@ -40,9 +40,13 @@ FRAME = r"""<!doctype html><html><head><meta charset=utf-8>
<script>
// A module that fails to load never reaches its own catch: say so.
const report = (error) => parent.postMessage({ error }, '*');
-addEventListener('error', (e) => report(
- `${e.message || 'load error'} ${e.filename || (e.target && e.target.src) || ''}`
- + `:${e.lineno || ''}`), true);
+// Scripts only: the music case's stub track is three bytes no <audio>
+// element can decode, which is expected and is not the page failing.
+addEventListener('error', (e) => {
+ if (!(e instanceof ErrorEvent) && !(e.target && e.target.tagName === 'SCRIPT')) return;
+ report(`${e.message || 'load error'} ${e.filename || (e.target && e.target.src) || ''}`
+ + `:${e.lineno || ''}`);
+}, true);
addEventListener('unhandledrejection',
(e) => report(String((e.reason && e.reason.stack) || e.reason)));
const WITH_BRIDGE = !location.search.includes('nobridge');
@@ -111,6 +115,8 @@ window.MeshBayTransport = function () {
// The native side, as the Electron preload and the Android shim present it,
// recording every cast call.
window.CALLS = [];
+// What the stub receiver reports: the music case moves it along.
+window.TV = { state: 'IDLE', position: 0, idle: null };
if (WITH_BRIDGE) {
let connected = false, v = 0;
const rec = (name, arg) => { CALLS.push({ name, arg }); };
@@ -127,13 +133,19 @@ if (WITH_BRIDGE) {
stop: async () => { rec('stop'); return true; },
subtitle: async () => null,
finish: async () => true,
- image: async (img) => {
- const bmp = await createImageBitmap(new Blob([img.bytes], { type: img.type }));
- rec('image', { type: img.type, width: bmp.width, height: bmp.height,
- jpeg: img.bytes[0] === 0xff && img.bytes[1] === 0xd8 });
- return { url: 'http://tv/image?t=x&v=' + (++v) };
+ file: async (f) => {
+ let info = { type: f.type, size: f.bytes.length, title: f.title || null,
+ artist: f.artist || null, album: f.album || null, cover: !!f.cover };
+ if (f.type.startsWith('image/')) {
+ const bmp = await createImageBitmap(new Blob([f.bytes], { type: f.type }));
+ info = { ...info, width: bmp.width, height: bmp.height,
+ jpeg: f.bytes[0] === 0xff && f.bytes[1] === 0xd8 };
+ }
+ rec('file', info);
+ return { url: 'http://tv/file?t=x&v=' + (++v) };
},
- status: async () => ({ active: true, url: null, chromecast: { connected } }),
+ status: async () => ({ active: true, url: null, chromecast: {
+ connected, playerState: TV.state, position: TV.position, idleReason: TV.idle } }),
scan: async () => { rec('scan'); return true; },
devices: async () => ({ devices: [{ id: 'tv1', name: 'Salon' }], scanning: false }),
chromecastConnect: async (o) => {
@@ -142,8 +154,9 @@ if (WITH_BRIDGE) {
},
chromecastReload: async (o) => { rec('reload', o); return { playerState: 'IDLE' }; },
chromecastDisconnect: async () => { rec('disconnect'); connected = false; return true; },
- chromecastPause: async () => ({}),
- chromecastPlay: async () => ({}),
+ chromecastPause: async () => { rec('pause'); TV.state = 'PAUSED'; return {}; },
+ chromecastPlay: async () => { rec('play'); TV.state = 'PLAYING'; return {}; },
+ chromecastSeek: async (t) => { rec('seek', t); TV.position = t; return {}; },
},
};
}
@@ -161,12 +174,91 @@ async function until(pred, what, tries = 200) {
throw new Error('timed out waiting for ' + what);
}
const calls = (name) => CALLS.filter((c) => c.name === name);
+const files = () => calls('file').map((c) => c.arg);
// Files is always first; then the enabled apps in their registry order.
const videosTab = () => $$('.group-tab')[1];
const count = () => (($('.photo-lightbox-count') || {}).textContent || '').trim();
+// The music bar, with a television already chosen: two tracks of one album.
+async function music() {
+ const { MusicPlayerBar } = await import('/music-player.js');
+ const { castSession } = await import('/cast-session.js');
+ const out = {};
+ castSession.choose({ id: 'tv1', name: 'Salon' });
+ // Which hashes were fetched, so a cover can be traced to where it came from.
+ window.FETCHED = [];
+ const transport = { connected: true, async waitForReconnect() {},
+ async fetchChunk(id) {
+ FETCHED.push(id);
+ return { nonce: 'n', ct: new Uint8Array([1, 2, 3]) };
+ },
+ // The node's MusicBrainz answer: a cover for the other album only.
+ async fetchMusicMeta(id) {
+ CALLS.push({ name: 'musicMeta', arg: id });
+ return id === 't3' ? { confidence: 1, cover_thumb_hash: 'mb-cover' }
+ : { confidence: 0 };
+ } };
+ const getConnection = async () => ({ transport, gek: 'gek' });
+ // One has no cover of its own but Two, of the same album, has; Three is of
+ // another album whose only cover is the one the node found.
+ const track = (n, title, album, hash) => ({ id: 't' + n, name: title + '.mp3', size: 3,
+ groupId: 'g1', display_title: title, artist: 'Some Band', album, duration: 200,
+ thumb_hash: hash });
+ const queue = { tracks: [track(1, 'One', 'An Album', null), track(2, 'Two', 'An Album', 'cover2'),
+ track(3, 'Three', 'Another Album', null)], startIndex: 0, nonce: 1 };
+ render(html`<${MusicPlayerBar} getConnection=${getConnection} queue=${queue}
+ userPrefs=${{}} onClose=${() => {}} />`, document.getElementById('root'));
+
+ await until(() => calls('connect').length === 1, 'the first track on the television');
+ out.first = { file: files()[0], connect: calls('connect')[0].arg,
+ coverFetched: FETCHED.includes('cover2') };
+ TV.state = 'PLAYING'; TV.position = 12;
+ await until(() => ($$('.music-player-time')[0] || {}).textContent === '0:12',
+ "the television's clock in the bar");
+ out.barButton = !!$('.music-player-bar .cast-btn.casting');
+ out.volumeDisabled = $('.music-player-volume input').disabled;
+
+ $('.music-player-play').click();
+ await until(() => calls('pause').length === 1, 'pause on the television');
+ const slider = $('.music-player-seek input');
+ slider.value = '30';
+ slider.dispatchEvent(new Event('input', { bubbles: true }));
+ await until(() => calls('seek').length === 1, 'a seek on the television');
+ out.seek = calls('seek')[0].arg;
+
+ // The television finishes the track: the next one goes, once.
+ TV.state = 'IDLE'; TV.idle = 'FINISHED';
+ await until(() => calls('reload').length === 1, 'the next track on the television');
+ out.second = { file: files()[1], reload: calls('reload')[0].arg,
+ title: $('.music-player-title').textContent.trim() };
+ TV.state = 'PLAYING'; TV.idle = null; TV.position = 1;
+ await sleep(2500);
+ out.filesAfterSettling = files().length;
+
+ // Three: the cover comes from the node's answer for its album.
+ TV.state = 'IDLE'; TV.idle = 'FINISHED';
+ await until(() => calls('reload').length === 2, 'the third track on the television');
+ out.third = { file: files()[2], mbCover: FETCHED.includes('mb-cover'),
+ metaAsked: calls('musicMeta').map((c) => c.arg) };
+ TV.state = 'PLAYING'; TV.idle = null;
+
+ // Stopping the cast lets the television go.
+ await castSession.end();
+ await sleep(200);
+ out.afterStop = { disconnects: calls('disconnect').length,
+ barCasting: !!$('.music-player-bar .cast-btn.casting') };
+ parent.postMessage(out, '*');
+}
+
(async () => {
const out = { bridge: !!window.meshbay };
+ if (location.search.includes('music')) {
+ try { await initLocale(); await music(); }
+ catch (err) {
+ parent.postMessage({ calls: CALLS, error: String((err && err.stack) || err) }, '*');
+ }
+ return;
+ }
try {
await initLocale();
render(html`<${GroupPage} groupId="g1" token="t" username="me" userId="u1"
@@ -195,13 +287,13 @@ const count = () => (($('.photo-lightbox-count') || {}).textContent || '').trim(
$$('.cast-menu .ctx-menu-item').find((b) => b.textContent.includes('Salon')).click();
await until(() => $('.photo-lightbox'), 'the lightbox to open on choosing');
await until(() => calls('connect').length === 1, 'the first photo to reach the television');
- out.first = { count: count(), image: calls('image')[0], connect: calls('connect')[0].arg,
+ out.first = { count: count(), image: files()[0], connect: calls('connect')[0].arg,
lightboxCasting: !!$('.photo-lightbox .cast-btn.casting') };
// Next photo: the receiver is connected, so it is reloaded.
$('.photo-nav-next').click();
await until(() => calls('reload').length === 1, 'the second photo');
- out.second = { count: count(), reload: calls('reload')[0].arg, images: calls('image').length };
+ out.second = { count: count(), reload: calls('reload')[0].arg, images: files().length };
// Slideshow: on by itself to the last photo, then off.
const play = $$('.photo-lightbox .video-top-bar .video-close')
@@ -209,7 +301,7 @@ const count = () => (($('.photo-lightbox-count') || {}).textContent || '').trim(
play.click();
await until(() => count() === '3 / 3', 'the slideshow to reach the last photo', 160);
await until(() => play.getAttribute('aria-pressed') === 'false', 'the slideshow to end', 160);
- out.slideshow = { count: count(), images: calls('image').length,
+ out.slideshow = { count: count(), images: files().length,
reloads: calls('reload').length };
// Close the lightbox; Videos shows the same television; a film opens as its remote.
@@ -341,16 +433,17 @@ def check(engine: str, cast: dict, plain: dict) -> list[str]:
first = cast["first"]
if first["count"] != "1 / 3":
bad.append(f"choosing a television did not open the first photo: {first['count']}")
- img = first["image"]["arg"]
+ img = first["image"]
if not (img["type"] == "image/jpeg" and img["jpeg"] and img["width"] == 1440
and img["height"] == 1080):
bad.append(f"the photo did not leave as a 1440x1080 JPEG: {img}")
- if first["connect"] != {"deviceId": "tv1", "mediaUrl": "http://tv/image?t=x&v=1"}:
+ if first["connect"] != {"deviceId": "tv1", "mediaUrl": "http://tv/file?t=x&v=1",
+ "startAt": 0}:
bad.append(f"connect: {first['connect']}")
if not first["lightboxCasting"]:
bad.append("the lightbox's cast button does not say it is casting")
if cast["second"]["count"] != "2 / 3" or cast["second"]["reload"] != {
- "mediaUrl": "http://tv/image?t=x&v=2"}:
+ "mediaUrl": "http://tv/file?t=x&v=2", "startAt": 0}:
bad.append(f"next photo: {cast['second']}")
if cast["slideshow"]["count"] != "3 / 3" or cast["slideshow"]["images"] != 3:
bad.append(f"slideshow: {cast['slideshow']}")
@@ -367,6 +460,39 @@ def check(engine: str, cast: dict, plain: dict) -> list[str]:
return [f"{engine}: {b}" for b in bad]
+def check_music(engine: str, m: dict) -> list[str]:
+ if "error" in m:
+ return [f"{engine}: music: {m['error']}"]
+ bad = []
+ f = m["first"]["file"]
+ if not (f["type"] == "audio/mpeg" and f["title"] == "One" and f["artist"] == "Some Band"
+ and f["album"] == "An Album" and f["cover"] and m["first"]["coverFetched"]):
+ bad.append(f"the first track left without its album's cover: {m['first']}")
+ third = m["third"]
+ if not (third["file"]["title"] == "Three" and third["file"]["cover"] and third["mbCover"]):
+ bad.append(f"the node's cover did not reach the television: {third}")
+ if "t1" in third["metaAsked"] or "t2" in third["metaAsked"]:
+ bad.append(f"the node was asked for a cover a track already had: {third['metaAsked']}")
+ if m["first"]["connect"] != {"deviceId": "tv1", "mediaUrl": "http://tv/file?t=x&v=1",
+ "startAt": 0}:
+ bad.append(f"connect: {m['first']['connect']}")
+ if not m["barButton"]:
+ bad.append("the music bar's cast button does not say it is casting")
+ if not m["volumeDisabled"]:
+ bad.append("the volume slider still acts while casting")
+ if m["seek"] != 30:
+ bad.append(f"seek: {m['seek']}")
+ s = m["second"]
+ if s["file"]["title"] != "Two" or s["title"] != "Two" or s["reload"] != {
+ "mediaUrl": "http://tv/file?t=x&v=2", "startAt": 0}:
+ bad.append(f"the end of a track did not move the queue on: {s}")
+ if m["filesAfterSettling"] != 2:
+ bad.append(f"a finished track moved the queue on more than once: {m['filesAfterSettling']}")
+ if m["afterStop"]["disconnects"] < 1 or m["afterStop"]["barCasting"]:
+ bad.append(f"stopping: {m['afterStop']}")
+ return [f"{engine}: music: {b}" for b in bad]
+
+
def main() -> int:
failures = []
with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), H) as srv:
@@ -377,11 +503,13 @@ def main() -> int:
continue
cast = browse(engine, "")
plain = browse(engine, "?nobridge")
- if cast is None or plain is None:
+ music = browse(engine, "?music")
+ if cast is None or plain is None or music is None:
failures.append(f"{engine}: the page never reported")
continue
- print(f"{engine}:", json.dumps(cast, indent=1))
+ print(f"{engine}:", json.dumps({"photos-videos": cast, "music": music}, indent=1))
failures += check(engine, cast, plain)
+ failures += check_music(engine, music)
for f in failures:
print("FAIL", f, file=sys.stderr)
if not failures: