diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-16 12:10:25 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-16 12:10:25 +0200 |
| commit | 9cbff21274604e37c0986d57937deef85819c396 (patch) | |
| tree | bdfb55978bb105fd78d1d35db33ff8bb35457f67 /packages/meshbay-hub/tests/harness/playlist_ui_probe.py | |
| parent | 2e973795383b71f63ae9e3bef0e5dfc7930b4c90 (diff) | |
| download | meshbay-9cbff21274604e37c0986d57937deef85819c396.tar.gz | |
music: the playlist menus
One button in Music's sticky toolbar — load, create, delete, remove a
track, sync now — and "add to playlist" on every cover and row. Both
surfaces share one list, read from the manifest, so they open instantly
with every node offline and no body is fetched until one is wanted.
Submenus expand in place rather than flying out: the account menu's
language list already does this, and a flyout has nowhere to go at 400px.
The tracklist under "remove a track" loads when it is expanded.
A name is typed into a field. Electron has no prompt — it throws.
Also splits the two playback failures: a decode failure belongs to that
file and keeps the bounded counter, a connection failure belongs to the
group and skips all of its queued tracks at once. Six dead tracks are
one more than the bound, which is where a playlist would otherwise stop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/harness/playlist_ui_probe.py')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/playlist_ui_probe.py | 368 |
1 files changed, 368 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py new file mode 100644 index 0000000..f0fc9c3 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +""" +The playlist menus, pressed in a real browser. + +`playlists.js` is covered against a stubbed node by `playlist_store_probe.py`, +and the merge and the sealing by their own tests. None of that reaches the part +a person actually touches: whether the toolbar button opens a menu, whether +naming a playlist in a field works (Electron has no `prompt` — it throws), and +whether "add to playlist" on an album cover puts the right tracks in the right +playlist. + +So this renders the shipped `GroupPage`, `MusicPlayerBar` and playlist menus, +presses the real controls, and reads the result back out of the store. + + playlist_ui_probe.py + +Prints JSON: one entry per step. +""" + +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8755 +RECORDS = [] +socketserver.TCPServer.allow_reuse_address = True + +FRAME = r"""<!doctype html><html><head><meta charset=utf-8> +<link rel="stylesheet" href="/style.css"></head><body> +<nav class="nav"><div class="nav-left"><a class="nav-brand" href="#/">MeshBay</a></div></nav> +<div class="layout"><main class="main"><div id="root"></div></main></div> +<script> +// Four albums of three tracks, each track titled so the queue can be read back +// unambiguously: "A2-t3" is the third track of the second album and nothing +// else. A fixture whose rows cannot be told apart measures nothing. +const ENTRIES = []; +let n = 0; +for (let a = 1; a <= 4; a++) { + for (let i = 1; i <= 3; i++) { + ENTRIES.push({ + id: 'e' + (++n), name: `A${a}-t${i}.flac`, display_title: `A${a}-t${i}`, + path: `musique/Artiste ${a}/Album ${a}`, type: 'audio', + artist: `Artiste ${a}`, album: `Album ${a}`, + track_no: i, duration: 200, size: 1024 * 1024, added_at: 1750000000 + n, + }); + } +} + +const ACK = { + is_node_admin: false, + enabled_apps: ['files', 'music'], + tmdb_enabled: false, musicbrainz_enabled: false, + video_directories: [], music_directories: ['musique'], photo_directories: [], +}; + +window.MeshBayTransport = function () { + const self = { + connected: false, memberRole: 'member', supportsAppOps: true, + sessionKeys: null, gekRaw: null, + newNodeBundle: null, newNodeBundleRecovery: null, + async connect() { self.connected = true; return ACK; }, + async fetchIndex() { + return { entries: ENTRIES, dirs: ['musique'], + roots: [{ name: 'musique', available: true, writable: false, + removable: false }] }; + }, + async fetchChatHistory() { return { messages: [], hasMore: false }; }, + async fetchLinkPreview() { return { ok: false }; }, + close() {}, + }; + return new Proxy(self, { + get(target, prop) { + if (prop in target) return target[prop]; + if (typeof prop === 'string' && prop.startsWith('on')) return undefined; + if (typeof prop === 'symbol') return undefined; + return () => new Promise(() => {}); + }, + set(target, prop, value) { target[prop] = value; return true; }, + }); +}; +</script> +<script type="module"> +import { html, render, useState, useCallback } from '/vendor/htm-preact.js'; +import { initLocale } from '/i18n.js'; +import { GroupPage } from '/group-page.js'; +import { MusicPlayerBar } from '/music-player.js'; +import { session } from '/hub-client.js'; +import { t } from '/i18n.js'; +import * as P from '/playlists.js'; + +const LOGS = []; +addEventListener('error', (e) => LOGS.push('error: ' + (e.message || e))); +addEventListener('unhandledrejection', + (e) => LOGS.push('rejection: ' + ((e.reason && (e.reason.stack || e.reason.message)) || e.reason))); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const waitFor = async (sel, tries = 60) => { + for (let i = 0; i < tries; i++) { + const el = document.querySelector(sel); + if (el) return el; + await sleep(50); + } + return null; +}; + +// The shell's two jobs, and only those: hold the queue, and pass `op` through. +// app.js does more (a transport fast path, the stop button); the contract this +// exercises is the shape of what it hands the player. +function Harness() { + const [queue, setQueue] = useState(null); + const onPlayQueue = useCallback((tracks, startIndex, source, op) => { + setQueue({ tracks, startIndex, nonce: Date.now(), op: op || 'replace' }); + }, []); + return html` + <${GroupPage} groupId="g1" token="t" username="me" userId="u1" + group=${{ id: 'g1', name: 'un groupe', owner_username: 'me', is_admin: false }} + userPrefs=${{ default_tab: 'music', media_page_size: '50' }} + onPlayQueue=${onPlayQueue} /> + ${queue && html`<${MusicPlayerBar} getConnection=${() => new Promise(() => {})} + queue=${queue} userPrefs=${{}} onClose=${() => setQueue(null)} />`} + `; +} + +const steps = []; + +// The queue as a person sees it: the player's own panel, opened and closed. +async function queueNow(label, extra) { + const open = document.querySelector('.music-player-extra .music-player-btn'); + if (!open) { steps.push({ step: label, bar: false, ...extra }); return; } + open.click(); + const panel = await waitFor('.music-detail .music-tracklist'); + const rows = [...document.querySelectorAll('.music-detail .music-tracklist .music-track-row')]; + steps.push({ + step: label, + bar: true, + play: rows.map((r) => r.querySelector('.music-track-title').textContent), + playing: (rows.findIndex((r) => r.classList.contains('active'))), + nowPlaying: (document.querySelector('.music-player-title') || {}).textContent || null, + ...extra, + }); + const close = document.querySelector('.music-detail .video-close'); + if (close) close.click(); + await sleep(120); +} + +const rightClick = (el) => el.dispatchEvent(new MouseEvent('contextmenu', { + bubbles: true, cancelable: true, clientX: 200, clientY: 200 })); + +const menuLabels = () => [...document.querySelectorAll('.ctx-menu .ctx-menu-item')] + .map((b) => b.querySelector('.ctx-menu-label').textContent); + +const clickMenu = async (i) => { + const items = [...document.querySelectorAll('.ctx-menu .ctx-menu-item')]; + items[i].click(); + await sleep(200); +}; + +(async () => { + const fail = (why) => parent.postMessage( + { error: why, logs: LOGS.slice(0, 12), + text: (document.getElementById('root').textContent || '').slice(0, 400) }, '*'); + try { + await initLocale(); + + // A real HKDF handle, so the store derives its key the way it really does. + const raw = new Uint8Array(32).fill(3); + session.bundleKey = { + v2: await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, + ['encrypt', 'decrypt']), + v2hkdf: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']), + }; + // Deleting a playlist asks. A modal dialog blocks the page and would wedge + // the probe, so the answer is stubbed rather than the question avoided — + // `confirm` is what the application really calls. + let asked = null; + window.confirm = (q) => { asked = q; return true; }; + + render(html`<${Harness} />`, document.getElementById('root')); + + const waitForCount = async (sel, n, tries = 80) => { + for (let i = 0; i < tries; i++) { + if (document.querySelectorAll(sel).length >= n) { + scrollTo(0, 0); await sleep(100); return true; + } + scrollTo(0, document.documentElement.scrollHeight); + await sleep(50); + } + return false; + }; + if (!await waitForCount('.music-card', 4)) return fail('grid never completed'); + + const toolbarButton = [...document.querySelectorAll('.video-toolbar .tb-btn')] + .find((b) => (b.getAttribute('title') || '').length + && b.querySelector('svg') + && !b.classList.contains('active')); + if (!toolbarButton) return fail('no playlist button in the toolbar'); + + const menuLabels = () => [...document.querySelectorAll('.ctx-menu .ctx-menu-item')] + .map((b) => b.querySelector('.ctx-menu-label').textContent); + const clickLabel = async (text) => { + const item = [...document.querySelectorAll('.ctx-menu .ctx-menu-item')] + .find((b) => b.querySelector('.ctx-menu-label').textContent === text); + if (!item) throw new Error('no menu item "' + text + '" among ' + menuLabels()); + item.click(); + await sleep(250); + }; + const openToolbar = async () => { toolbarButton.click(); await sleep(200); }; + + // 1. The toolbar menu, in the order it promises. + await openToolbar(); + steps.push({ step: 'toolbar menu', items: menuLabels() }); + + // 2. Create a playlist by typing a name into a field. + await clickLabel(t('playlists.create')); + const field = await waitFor('.playlist-modal input'); + if (!field) return fail('no name field'); + field.value = 'Soirée'; + field.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(80); + document.querySelector('.playlist-modal .admin-btn').click(); + await sleep(350); + steps.push({ step: 'created', modalGone: !document.querySelector('.playlist-modal'), + lists: await P.listPlaylists('u1') }); + + // 3. Add an album to it from the cover's own menu. + const dots = document.querySelectorAll('.music-card .ctx-dots'); + dots[1].click(); + await sleep(200); + steps.push({ step: 'cover menu', items: menuLabels() }); + await clickLabel(t('playlists.add_to')); + steps.push({ step: 'add-to submenu', items: menuLabels() }); + await clickLabel('Soirée'); + await sleep(300); + const lists = await P.listPlaylists('u1'); + steps.push({ step: 'added to the playlist', + lists: lists.map((p) => ({ name: p.name, count: p.count })), + note: (document.querySelector('.playlist-note') || {}).textContent || null }); + + // 4. Load it into the queue. + await openToolbar(); + await clickLabel(t('playlists.load')); + await clickLabel('Soirée'); + await sleep(400); + const open = document.querySelector('.music-player-extra .music-player-btn'); + if (!open) return fail('nothing is playing after loading a playlist'); + open.click(); + await waitFor('.music-detail .music-tracklist'); + steps.push({ step: 'loaded into the queue', + play: [...document.querySelectorAll('.music-detail .music-tracklist .music-track-title')] + .map((e) => e.textContent) }); + document.querySelector('.music-detail .video-close').click(); + await sleep(150); + + // 5. Remove one track, through the two-level submenu. + await openToolbar(); + await clickLabel(t('playlists.remove_track')); + await clickLabel('Soirée'); + await sleep(350); + steps.push({ step: 'the tracklist submenu', items: menuLabels() }); + await clickLabel('A2-t2'); + await sleep(300); + steps.push({ step: 'track removed', + lists: (await P.listPlaylists('u1')).map((p) => ({ name: p.name, count: p.count })), + tracks: (await P.getPlaylistTracks('u1', lists.find((p) => p.name === 'Soirée').id)) + .map((tr) => tr.display_title) }); + + // 6. Delete it. + await openToolbar(); + await clickLabel(t('playlists.delete')); + await clickLabel('Soirée'); + await sleep(300); + steps.push({ step: 'deleted', asked: !!asked, + lists: (await P.listPlaylists('u1')).map((p) => p.name) }); + + parent.postMessage({ steps, logs: LOGS.slice(0, 8) }, '*'); + } catch (err) { + fail(String((err && err.stack) || err)); + } +})(); +</script></body></html>""" + +PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head> +<body style="margin:0"><div id="frames"></div><script> +addEventListener('message', (e) => { + fetch('/log', { method: 'POST', body: JSON.stringify(e.data) }); +}); +const f = document.createElement('iframe'); +f.src = '/case'; +f.style.cssText = 'width:1100px;height:800px;border:0;display:block'; +document.getElementById('frames').appendChild(f); +</script></body></html>""" + + +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + if self.path == "/log": + RECORDS.append(json.loads(self.rfile.read(length).decode())) + else: + self.rfile.read(length) + self.send_response(204) + self.end_headers() + + def _send(self, body: bytes, ctype: str) -> None: + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/": + self._send(PAGE.encode(), "text/html; charset=utf-8") + elif path == "/case": + self._send(FRAME.encode(), "text/html; charset=utf-8") + elif path == "/v1/groups/g1/nodes": + self._send(b'{"nodes": [{"node_id": "n1"}]}', "application/json") + else: + asset = (STATIC / path.lstrip("/")).resolve() + if not str(asset).startswith(str(STATIC)) or not asset.is_file(): + self.send_response(404) + self.end_headers() + return + self._send(asset.read_bytes(), + "text/css" if asset.suffix == ".css" + else "text/javascript" if asset.suffix == ".js" + else "application/octet-stream") + + +def main() -> int: + with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile: + proc = subprocess.Popen( + ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}", "--window-size=1100,900", + f"http://127.0.0.1:{PORT}/"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(400): + if RECORDS: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + if not RECORDS: + print(json.dumps({"error": "no measurement"}), file=sys.stderr) + return 1 + print(json.dumps(RECORDS[0], indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) |