aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/harness')
-rwxr-xr-xpackages/meshbay-hub/tests/harness/music_queue_probe.py58
-rw-r--r--packages/meshbay-hub/tests/harness/playlist_ui_probe.py368
2 files changed, 423 insertions, 3 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())