aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests')
-rwxr-xr-xpackages/meshbay-hub/tests/harness/music_queue_probe.py58
-rw-r--r--packages/meshbay-hub/tests/harness/playlist_ui_probe.py368
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py2
-rw-r--r--packages/meshbay-hub/tests/test_music_queue.py27
-rw-r--r--packages/meshbay-hub/tests/test_playlist_ui.py108
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py2
6 files changed, 555 insertions, 10 deletions
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"""<!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())
diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py
index 994ca2e..78561b6 100644
--- a/packages/meshbay-hub/tests/test_hook_ordering.py
+++ b/packages/meshbay-hub/tests/test_hook_ordering.py
@@ -51,7 +51,7 @@ STATIC_FILES = [
"search-page.js",
# The shared pop-up menu (docs/playlists.md §10.1), reached from the media
# views rather than imported by the shell.
- "menu.js",
+ "menu.js", "playlist-menu.js",
]
pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable")
diff --git a/packages/meshbay-hub/tests/test_music_queue.py b/packages/meshbay-hub/tests/test_music_queue.py
index a53047d..5bf9e97 100644
--- a/packages/meshbay-hub/tests/test_music_queue.py
+++ b/packages/meshbay-hub/tests/test_music_queue.py
@@ -52,11 +52,12 @@ def test_playing_a_track_queues_its_album(steps):
assert s["nowPlaying"] == "A1-t2"
-def test_right_click_opens_the_three_queue_verbs(steps):
- """Three items, in the order the menu promises. Their labels are whatever
- the browser's locale renders, so this counts them rather than reading them
- — the point is that the menu opened and is not empty."""
- assert len(steps["append album 2"]["menu"]) == 3
+def test_right_click_opens_the_queue_verbs(steps):
+ """The menu opened and carries the three queue verbs, plus "add to
+ playlist" — which is why this counts four rather than three. Labels are
+ whatever the browser's locale renders, so counting is what this can assert;
+ `test_playlist_ui.py` is what checks the fourth one leads anywhere."""
+ assert len(steps["append album 2"]["menu"]) == 4
def test_add_to_queue_appends_and_leaves_the_playhead_alone(steps):
@@ -96,6 +97,22 @@ def test_play_all_replaces_everything(steps):
assert s["playing"] == 0
+def test_an_unreachable_group_is_skipped_whole_rather_than_one_track_at_a_time(steps):
+ """A regression playlists would otherwise introduce into correct code.
+
+ `MAX_CONSECUTIVE_FAILURES` is 5 and was sized for a corrupt file between
+ two good ones. A playlist whose next six tracks all come from one node that
+ is off hits that bound and stops on the sixth, with an error, and the
+ reader reads it as "the playlist is broken".
+
+ A connection failure is a property of the *group*, not of each of its
+ tracks in turn, so the group is marked down and all of its queued tracks go
+ in one step. Six dead tracks here, one more than the bound: without the
+ split this lands on the last of them instead of past them.
+ """
+ assert steps["an unreachable group is skipped whole"]["nowPlaying"] == "live-2"
+
+
# ── the wrappers, read rather than driven ────────────────────────────────────
#
# The probe proves the chain works for the group page. Search mounts the same
diff --git a/packages/meshbay-hub/tests/test_playlist_ui.py b/packages/meshbay-hub/tests/test_playlist_ui.py
new file mode 100644
index 0000000..c449dc7
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_playlist_ui.py
@@ -0,0 +1,108 @@
+"""
+The playlist menus, pressed in a real browser.
+
+The store is covered against a stubbed node, and the merge and the sealing by
+their own tests. None of that reaches the part a person touches: whether the
+toolbar button opens a menu at all, whether naming a playlist works (Electron
+has no `prompt` — it *throws*, which is how the Files toolbar's New folder
+button came to do nothing), and whether "add to playlist" on a cover puts the
+right tracks in the right playlist.
+
+The probe renders the shipped `GroupPage`, `MusicPlayerBar` and playlist menus
+and presses the real controls. Labels come back in whatever locale the browser
+picked, so what is asserted is shape and behaviour — counts, order, and what
+ended up in the store — rather than English strings.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "playlist_ui_probe.py"
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("google-chrome") is None or not (STATIC / "playlist-menu.js").exists(),
+ reason="Chrome or the SPA sources are not available")
+
+
+@pytest.fixture(scope="module")
+def steps():
+ proc = subprocess.run(["python3", str(HARNESS)],
+ capture_output=True, text=True, timeout=300)
+ assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
+ out = json.loads(proc.stdout)
+ assert "error" not in out, out
+ assert not out.get("logs"), f"the page logged: {out['logs']}"
+ return {s["step"]: s for s in out["steps"]}
+
+
+def test_one_button_opens_the_five_playlist_verbs_in_order(steps):
+ """Load, create, delete, remove a track — the order asked for — and Sync
+ now, which has to live somewhere and this is the only playlist surface."""
+ assert len(steps["toolbar menu"]["items"]) == 5
+
+
+def test_a_playlist_is_named_in_a_field_and_not_a_prompt(steps):
+ """`window.prompt` throws in Electron and does not return null, so it is
+ banned outright (`test_no_prompt_in_the_spa.py`). Anything that needs typed
+ input needs a field, and this is the one that does."""
+ s = steps["created"]
+ assert s["modalGone"] is True, "the modal stayed open, so nothing was saved"
+ assert [p["name"] for p in s["lists"]] == ["Soirée"]
+ assert s["lists"][0]["count"] == 0
+
+
+def test_the_cover_menu_carries_the_queue_verbs_and_add_to_playlist(steps):
+ assert len(steps["cover menu"]["items"]) == 4
+
+
+def test_favourites_is_offered_first_before_it_has_ever_been_used(steps):
+ """The reserved playlist is materialised on first use, so the submenu has
+ it on a fresh account — and has it first, as the design promises."""
+ items = steps["add-to submenu"]["items"]
+ submenu = items[4:]
+ assert len(submenu) == 3, submenu
+ assert submenu[1] == "Soirée"
+
+
+def test_adding_an_album_from_its_cover_puts_its_tracks_in_the_playlist(steps):
+ s = steps["added to the playlist"]
+ assert s["lists"] == [{"name": "Soirée", "count": 3}]
+ assert s["note"], "nothing said it had happened"
+
+
+def test_loading_a_playlist_replaces_the_queue_with_its_tracks(steps):
+ """Below `onPlayQueue` a playlist and an album are indistinguishable, which
+ is why auto-advance, shuffle and prefetch are unchanged by construction."""
+ assert steps["loaded into the queue"]["play"] == ["A2-t1", "A2-t2", "A2-t3"]
+
+
+def test_the_tracklist_submenu_is_two_levels_and_fetched_when_expanded(steps):
+ """As asked: the playlist, then its tracks. The second level is read from
+ IndexedDB when it is opened — building it eagerly would read every
+ playlist's tracks to draw a menu nobody may open."""
+ items = steps["the tracklist submenu"]["items"]
+ assert "A2-t1" in items and "A2-t3" in items
+ assert items.index("A2-t1") > 0
+ # The tracks sit under their playlist, between it and the next top-level
+ # item — expanded in place rather than in a flyout.
+ assert items[-1] == steps["toolbar menu"]["items"][-1]
+
+
+def test_removing_a_track_removes_that_one(steps):
+ s = steps["track removed"]
+ assert s["lists"] == [{"name": "Soirée", "count": 2}]
+ assert s["tracks"] == ["A2-t1", "A2-t3"], "the wrong track was removed"
+
+
+def test_deleting_a_playlist_asks_first(steps):
+ """A deletion is a tombstone: there is nothing in the interface that undoes
+ it. `confirm` and not a component — Electron implements it and a dozen
+ places in this SPA already use it."""
+ s = steps["deleted"]
+ assert s["asked"] is True, "a playlist was deleted without asking"
+ assert s["lists"] == []
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index 5c535a3..9c3a88b 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -43,7 +43,7 @@ SPLIT_FILES = [APP, GROUP_PAGE, CHAT_APP, STATIC / "files-app.js",
STATIC / "photos-app-settings.js",
STATIC / "helloworld-app.js",
STATIC / "helloworld-app-settings.js",
- STATIC / "menu.js",
+ STATIC / "menu.js", STATIC / "playlist-menu.js",
STATIC / "auth-page.js", STATIC / "explore-page.js",
CREATE_GROUP]