From 9cbff21274604e37c0986d57937deef85819c396 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 16 Sep 2026 12:10:25 +0200 Subject: music: the playlist menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../meshbay-hub/tests/harness/music_queue_probe.py | 58 +++- .../meshbay-hub/tests/harness/playlist_ui_probe.py | 368 +++++++++++++++++++++ 2 files changed, 423 insertions(+), 3 deletions(-) create mode 100644 packages/meshbay-hub/tests/harness/playlist_ui_probe.py (limited to 'packages/meshbay-hub/tests/harness') diff --git a/packages/meshbay-hub/tests/harness/music_queue_probe.py b/packages/meshbay-hub/tests/harness/music_queue_probe.py index f74ceb2..a9146ce 100755 --- a/packages/meshbay-hub/tests/harness/music_queue_probe.py +++ b/packages/meshbay-hub/tests/harness/music_queue_probe.py @@ -119,12 +119,24 @@ function Harness() { const onPlayQueue = useCallback((tracks, startIndex, source, op) => { setQueue({ tracks, startIndex, nonce: Date.now(), op: op || 'replace' }); }, []); + // A queue that crosses groups cannot be built from one group's page, and + // that is the case the skipping rule exists for — so the probe reaches in + // for that one step rather than pretending a gesture builds it. + window.__probePlayQueue = onPlayQueue; + + // Rejects at once for a group nothing serves, and hangs for every other — + // hanging is what the stub node does anyway, and what matters here is which + // track the playhead lands on, not whether anything plays. + const getConnection = (groupId) => (String(groupId).startsWith('dead') + ? Promise.reject(new Error('no node')) + : new Promise(() => {})); + 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 && html`<${MusicPlayerBar} getConnection=${getConnection} queue=${queue} userPrefs=${{}} onClose=${() => setQueue(null)} />`} `; } @@ -171,9 +183,29 @@ const clickMenu = async (i) => { await initLocale(); render(html`<${Harness} />`, document.getElementById('root')); - if (!await waitFor('.music-card')) return fail('no album grid'); + // Wait for the grid to be *complete*, not merely present — and scroll, + // because it will not complete otherwise: the tiles mount on intersection + // (`LazyTile`), so an album below the fold has no card at all until the + // page is scrolled to it. A probe that counts what happens to be on screen + // is measuring the height of its own iframe; adding one button to the + // toolbar was enough to push the fourth album out of view. + 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('expected 4 albums, got ' + + document.querySelectorAll('.music-card').length); + } const cards = [...document.querySelectorAll('.music-card')]; - if (cards.length < 4) return fail('expected 4 albums, got ' + cards.length); // 1. Open album 1 and play its second track — the ordinary path, through a // row that is no longer one big button. @@ -219,6 +251,26 @@ const clickMenu = async (i) => { await sleep(250); await queueNow('replace with album 4'); + // 6. A group that does not answer: every one of its queued tracks is + // skipped in one step, not one failure at a time against a bound that + // was sized for corrupt files. + const mixed = (id, group) => ({ + id, name: id + '.flac', display_title: id, path: 'p', size: 10, + type: 'audio', duration: 100, groupId: group, + }); + window.__probePlayQueue([ + mixed('live-1', 'g1'), + mixed('dead-1', 'dead-a'), mixed('dead-2', 'dead-a'), + mixed('dead-3', 'dead-a'), mixed('dead-4', 'dead-a'), + mixed('dead-5', 'dead-a'), mixed('dead-6', 'dead-a'), + mixed('live-2', 'g1'), + ], 1, null, 'replace'); + await sleep(1200); + steps.push({ + step: 'an unreachable group is skipped whole', + nowPlaying: (document.querySelector('.music-player-title') || {}).textContent || null, + }); + parent.postMessage({ steps, logs: LOGS.slice(0, 8) }, '*'); } catch (err) { fail(String((err && err.stack) || err)); 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""" + + +
+ +""" + +PAGE = r""" +
""" + + +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()) -- cgit v1.2.3