diff options
Diffstat (limited to 'packages/meshbay-hub/tests/harness/cast_session_probe.py')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/cast_session_probe.py | 166 |
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: |