diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/copy_link_probe.py | 270 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/harness/group_link_probe.py | 176 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/harness/invite_link_probe.py | 23 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_copy_link.py | 103 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_group_link.py | 170 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_group_link_flow.py | 67 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_http_api_doc.py | 30 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_invite_link_flow.py | 17 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_search_unreachable_note.py | 33 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_spa_ordering.py | 4 |
10 files changed, 886 insertions, 7 deletions
diff --git a/packages/meshbay-hub/tests/harness/copy_link_probe.py b/packages/meshbay-hub/tests/harness/copy_link_probe.py new file mode 100644 index 0000000..564d277 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/copy_link_probe.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +""" +"Copy link" in Files, Music and Photos, in a real browser. + +Mounts the shipped `FilesPanel`, `MusicApp` and `PhotosApp` on a made-up +index — no node, no transport — with the `linkFor` a group page gives them, +built by the real `copy-link.js` for a group `demo@someowner`. Opens each +menu the way a reader would (right-click; the dots on a phone; the Files +toolbar with one row ticked), picks "Copy link", and reads back what reached +the clipboard and what the page said. + + copy_link_probe.py + +Prints JSON: one entry per case. +""" + +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 = 8775 +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> +<div id="files"></div><div id="music"></div><div id="photos"></div> +<script type="module"> +import { html, render } from '/vendor/htm-preact.js'; +import { initLocale, setLocale } from '/i18n.js'; +import { FilesPanel } from '/files-app.js'; +import { MusicApp } from '/music-app.js'; +import { PhotosApp } from '/photos-app.js'; +import { groupItemLink, entryPath } from '/copy-link.js'; + +const LOGS = []; +addEventListener('error', (e) => LOGS.push('error: ' + (e.message || e))); +addEventListener('unhandledrejection', + (e) => LOGS.push('rejection: ' + (e.reason && e.reason.message || e.reason))); +const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + +// What the clipboard was handed, and nothing else: a headless page has no +// clipboard permission to test against. +const COPIES = []; +Object.defineProperty(navigator, 'clipboard', { configurable: true, + value: { writeText: async (text) => { COPIES.push(text); } } }); + +const GROUP = { name: 'demo', owner_username: 'someowner' }; +const linkFor = (target) => (target + ? groupItemLink(GROUP, typeof target === 'string' ? target : entryPath(target)) : null); +const noop = () => {}; +const ref = { current: null }; + +const labels = () => [...document.querySelectorAll('.ctx-menu .ctx-menu-label')] + .map((el) => el.textContent); +const pick = async (label) => { + const item = [...document.querySelectorAll('.ctx-menu .ctx-menu-item')] + .find((b) => b.querySelector('.ctx-menu-label').textContent === label); + if (!item) return false; + item.click(); + await wait(50); + return true; +}; +const note = () => (document.querySelector('.page-note') || {}).textContent || ''; +const closeMenus = async () => { + document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + await frame(); +}; +const rightClick = async (el) => { + await closeMenus(); + const r = el.getBoundingClientRect(); + el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, + clientX: r.left + 10, clientY: r.top + 5 })); + await frame(); +}; +const rowNamed = (name) => [...document.querySelectorAll('tr.file-row')] + .find((tr) => tr.querySelector('.file-name') + && tr.querySelector('.file-name').textContent.trim().startsWith(name)); + +(async () => { + const cases = []; + try { + setLocale('en'); + await initLocale(); + try { localStorage.setItem('meshbay_music_view_mode', 'flat'); } catch {} + + // -- Files -- + const FILES = [ + { id: 'f1', name: 'IMG 0001.JPG', path: 'Root/trip_2015', size: 10, type: 'image', + added_at: 1 }, + { id: 'f2', name: 'notes.txt', path: 'Root/trip_2015', size: 5, type: 'document', + added_at: 2 }, + ]; + render(html`<${FilesPanel} groupId="g" transportRef=${ref} gekRef=${ref} + status="connected" entries=${FILES} nodeDirs=${['Root', 'Root/trip_2015']} + nodeRoots=${[{ name: 'Root', writable: false }]} + setEntries=${noop} setNodeDirs=${noop} setNodeRoots=${noop} applyIndex=${noop} + isNodeAdmin=${false} operatorPaired=${false} userId="me" setError=${noop} + onPreview=${noop} linkFor=${linkFor} />`, document.getElementById('files')); + await frame(); + + await rightClick(rowNamed('Root')); + const folderLabels = labels(); + await pick('Copy link'); + cases.push({ case: 'files folder', labels: folderLabels, copied: COPIES.at(-1), note: note() }); + + rowNamed('Root').click(); await frame(); + rowNamed('trip_2015').click(); await frame(); + await rightClick(rowNamed('IMG 0001.JPG')); + const fileLabels = labels(); + await pick('Copy link'); + cases.push({ case: 'files file', labels: fileLabels, copied: COPIES.at(-1), note: note() }); + + await closeMenus(); + const linkBtn = () => document.querySelector('.tb-actions button[aria-label="Copy link"]'); + rowNamed('notes.txt').querySelector('input[type=checkbox]').click(); await frame(); + const oneTicked = linkBtn() ? !linkBtn().disabled : null; + if (linkBtn() && !linkBtn().disabled) { linkBtn().click(); await wait(50); } + const copiedFromToolbar = COPIES.at(-1); + for (const n of ['IMG 0001.JPG', 'notes.txt']) { + rowNamed(n).querySelector('input[type=checkbox]').click(); await frame(); + } + const twoTicked = linkBtn() ? !linkBtn().disabled : null; + cases.push({ case: 'files toolbar', one_enabled: oneTicked, two_enabled: twoTicked, + copied: copiedFromToolbar }); + render(null, document.getElementById('files')); + + // -- Music: a loose track, its row's dots (what a phone has) -- + const TRACKS = [ + { id: 'a1', name: 'track 01.flac', path: 'Music/Some Album', size: 3, type: 'audio' }, + { id: 'a2', name: 'track 02.flac', path: 'Music/Some Album', size: 3, type: 'audio' }, + ]; + render(html`<${MusicApp} groupId="g" transportRef=${ref} gekRef=${ref} + status="connected" entries=${TRACKS} musicDirectories=${['Music']} + musicbrainzConfig=${{ enabled: false }} onPlayQueue=${noop} userId="" + linkFor=${linkFor} />`, document.getElementById('music')); + await frame(); + await closeMenus(); + const dots = document.querySelector('.music-flat-track .ctx-dots'); + if (dots) { dots.click(); await frame(); } + const trackLabels = labels(); + await pick('Copy link'); + cases.push({ case: 'music track', dots: Boolean(dots), labels: trackLabels, + copied: COPIES.at(-1) }); + render(null, document.getElementById('music')); + + // -- Photos: right-click a tile; the lightbox's button -- + const PHOTOS = [ + { id: 'p1', name: 'beach.jpg', path: 'Pics/Summer', size: 4, type: 'image', added_at: 1 }, + ]; + render(html`<${PhotosApp} groupId="g" transportRef=${ref} gekRef=${ref} + status="connected" entries=${PHOTOS} photoDirectories=${['Pics']} + setError=${noop} linkFor=${linkFor} />`, document.getElementById('photos')); + await frame(); + const album = document.querySelector('.photo-album-card'); + if (album) { album.click(); await frame(); } + const tile = document.querySelector('.photo-tile'); + let tileLabels = []; + if (tile) { + await rightClick(tile); + tileLabels = labels(); + await pick('Copy link'); + } + const fromTile = COPIES.at(-1); + if (tile) { tile.click(); await frame(); } + const lb = document.querySelector('.photo-lightbox button[aria-label="Copy link"]'); + COPIES.length = 0; + if (lb) { lb.click(); await wait(50); } + cases.push({ case: 'photos', tile: Boolean(tile), labels: tileLabels, copied: fromTile, + lightbox_button: Boolean(lb), lightbox_copied: COPIES.at(-1) || null }); + + // Said, then gone (note.js): still up just after, not 2.5 s later. + const upAfterCopy = Boolean(document.querySelector('.page-note')); + await wait(2700); + cases.push({ case: 'note goes', up_after_copy: upAfterCopy, + up_later: Boolean(document.querySelector('.page-note')) }); + + parent.postMessage({ cases, logs: LOGS, origin: location.origin }, '*'); + } catch (err) { + parent.postMessage({ error: String(err && (err.stack || err)), logs: LOGS }, '*'); + } +})(); +</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") + 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(300): + 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/harness/group_link_probe.py b/packages/meshbay-hub/tests/harness/group_link_probe.py new file mode 100644 index 0000000..2d7c0d7 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/group_link_probe.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +A group link, `#/name@owner[/path]`, opened in the real application. + +`test_group_link.py` runs `group-link.js` on its own; this is where it meets +the router, the account's group list and the sign-in state. Loads the shipped +`app.js` in a real browser with `fetch` stubbed (no node answers, so a group +page goes as far as "offline"), once per case: + + handle — `#/demo@someowner`: the group page, the address left as it is + uuid — `#/group/<id>`: the same page, the address showing the handle + file — `#/demo@someowner/<path>`: the page, the path kept in the address + until the index can say what it is (no node here: never) + unknown — `#/demo@stranger1`: not among the account's groups + signed_out — `#/demo@someowner/<path>` with no session: the sign-in form, and + the address untouched under it + + group_link_probe.py + +Prints JSON: one object per case. +""" + +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 = 8774 +RECORDS = [] +socketserver.TCPServer.allow_reuse_address = True + +GROUP = "0f8fad5b-d9cb-469f-a165-70867728950e" +FILE = "backup/city_2015/backup/IMG_0001.JPG" +LINKS = { + "handle": "#/demo@someowner", + "uuid": f"#/group/{GROUP}", + "file": f"#/demo@someowner/{FILE}", + "unknown": "#/demo@stranger1", + "signed_out": f"#/demo@someowner/{FILE}", +} +CASES = list(LINKS) + +PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head><body> +<div id="app"></div> +<script type="module"> +const CASE = new URLSearchParams(location.search).get('case'); +const LINKS = __LINKS__; +const realFetch = window.fetch.bind(window); +const post = (o) => realFetch('/log', { method: 'POST', body: JSON.stringify(o) }); +const calls = []; +const json = (body, status = 200) => ({ + ok: status < 400, status, statusText: '', headers: new Headers(), + json: async () => body, text: async () => JSON.stringify(body), +}); +window.fetch = async (url, init = {}) => { + const u = String(url); + calls.push(u); + if (u.includes('/v1/users/me/preferences')) return json({}); + if (u.includes('/v1/users/me')) return json({ user_id: 'u-1', role: 'user' }); + if (u.includes('/v1/groups/mine')) return json({ groups: [ + { id: '__GROUP__', name: 'demo', owner_username: 'someowner', visibility: 'private', + created_at: '2026-01-01T00:00:00+00:00', description: '' }, + { id: 'other-group', name: 'demo', owner_username: 'otherowner', visibility: 'private', + created_at: '2026-01-01T00:00:00+00:00', description: '' }, + ] }); + if (u.includes('/nodes')) return json({ nodes: [] }); + return json({}); +}; +if (CASE === 'signed_out') { + localStorage.removeItem('mb_auth'); +} else { + localStorage.setItem('mb_auth', JSON.stringify({ + username: 'member-account', userId: 'u-1', token: 'tok', refreshToken: 'ref', + role: 'user' })); +} +sessionStorage.clear(); +history.replaceState(null, '', '/?case=' + CASE + LINKS[CASE]); + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +(async () => { + const out = { case: CASE }; + try { + await import('/app.js'); + await wait(2000); + out.hash = decodeURI(location.hash); + out.history_length = history.length; + out.group_page = Boolean(document.querySelector('.group-header')); + out.group_title = (document.querySelector('.group-header h2') || {}).innerText || ''; + out.active_sidebar = [...document.querySelectorAll('.sidebar-group.active')] + .map((a) => a.getAttribute('href')); + out.message = (document.querySelector('main .page-message') || {}).innerText || ''; + out.spinner = Boolean(document.querySelector('main .page-message .spinner')); + out.login_form = Boolean(document.querySelector('input[type=password]')); + out.hub_calls = calls.map((c) => c.replace(/^https?:\/\/[^/]+/, '')); + } catch (e) { + out.error = String(e && e.stack || e); + } + post(out); +})(); +</script></body></html> +""".replace("__GROUP__", GROUP).replace("__LINKS__", json.dumps(LINKS)) + + +class H(http.server.SimpleHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) + if self.path == "/log": + RECORDS.append(json.loads(body.decode())) + 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") + return + 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 + ctype = "text/javascript" if asset.suffix in (".js", ".mjs") else ( + "application/wasm" if asset.suffix == ".wasm" else "application/octet-stream") + self._send(asset.read_bytes(), ctype) + + +def _run(case: str) -> dict | None: + before = len(RECORDS) + 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}", f"http://127.0.0.1:{PORT}/?case={case}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(300): + if len(RECORDS) > before: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + return RECORDS[before] if len(RECORDS) > before else None + + +def main() -> int: + with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + results = [_run(case) for case in CASES] + if not all(results): + print(json.dumps({"error": "no measurement", "got": results}), file=sys.stderr) + return 1 + print(json.dumps(results, indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/meshbay-hub/tests/harness/invite_link_probe.py b/packages/meshbay-hub/tests/harness/invite_link_probe.py index 97ee893..b0a833e 100644 --- a/packages/meshbay-hub/tests/harness/invite_link_probe.py +++ b/packages/meshbay-hub/tests/harness/invite_link_probe.py @@ -13,6 +13,9 @@ Loads the shipped `app.js` in a real browser with `fetch` stubbed, twice: signed_out — a link, no session signed_in — the same link, a session; then the Join button is clicked + signed_in_listed — the same, with the hub listing the group once joined, as + it does: the group page opens and the address shows its handle + (`#/name@owner`, group-link.js) invite_link_probe.py @@ -56,7 +59,15 @@ window.fetch = async (url, init = {}) => { calls.push({ url: u, body: init.body ? String(init.body) : '' }); if (u.includes('/v1/users/me/preferences')) return json({}); if (u.includes('/v1/users/me')) return json({ user_id: 'u-1', role: 'user' }); - if (u.includes('/v1/groups/mine')) return json({ groups: [] }); + // Joined, the hub lists the group — in the one case that says so. The others + // keep an empty list, so the address stays the `#/group/<id>` the Join + // button navigated to and is read before the router could rewrite it. + if (u.includes('/v1/groups/mine')) return json({ groups: + CASE === 'signed_in_listed' && calls.some((c) => c.url.includes('/redeem')) + ? [{ id: '__GROUP__', name: 'Some Group', owner_username: 'the-owner', + visibility: 'private', created_at: '2026-01-01T00:00:00+00:00', + description: '' }] + : [] }); if (u.includes('/v1/invite-links/preview')) return json({ group_id: '__GROUP__', group_name: 'Some Group', inviter: 'the-owner', expires_at: '2099-01-01T00:00:00+00:00', already_member: false }); @@ -65,7 +76,7 @@ window.fetch = async (url, init = {}) => { if (u.includes('/nodes')) return json({ nodes: [] }); return json({}); }; -if (CASE === 'signed_in') { +if (CASE.startsWith('signed_in')) { localStorage.setItem('mb_auth', JSON.stringify({ username: 'invitee-account', userId: 'u-1', token: 'tok', refreshToken: 'ref', role: 'user' })); @@ -98,6 +109,12 @@ const text = () => document.getElementById('app').innerText; out.join_button = Boolean(join); if (join) { join.click(); await wait(1500); } out.hash_after_click = location.hash; + if (CASE === 'signed_in_listed') { + await wait(1500); + out.hash_settled = decodeURI(location.hash); + out.group_page = Boolean(document.querySelector('.group-header')); + out.history_length = history.length; + } out.redeem_bodies = calls.filter((c) => c.url.includes('/redeem')).map((c) => c.body); } out.code_in_a_hub_request = calls.some( @@ -169,7 +186,7 @@ def _run(case: str) -> dict | None: def main() -> int: with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: threading.Thread(target=srv.serve_forever, daemon=True).start() - results = [_run("signed_out"), _run("signed_in")] + results = [_run("signed_out"), _run("signed_in"), _run("signed_in_listed")] if not all(results): print(json.dumps({"error": "no measurement", "got": results}), file=sys.stderr) return 1 diff --git a/packages/meshbay-hub/tests/test_copy_link.py b/packages/meshbay-hub/tests/test_copy_link.py new file mode 100644 index 0000000..22e56e7 --- /dev/null +++ b/packages/meshbay-hub/tests/test_copy_link.py @@ -0,0 +1,103 @@ +""" +"Copy link": the `#/name@owner/path` address of a file or folder, from the +views that show one (harness/copy_link_probe.py). + +Files offers it for one row — right-click, or the toolbar with one row ticked, +which is how a phone reaches it; Music on one track's menu, whose dots are a +phone's way in; Photos on a right-clicked tile and in the lightbox. The video +player and the file preview carry a button. Search passes the same `linkFor`, +naming each result's own group and its path there rather than the merged tree's. +""" + +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "copy_link_probe.py" +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +ORIGIN = "http://127.0.0.1:8775" + + +@pytest.fixture(scope="module") +def cases(): + if shutil.which("google-chrome") is None: + pytest.skip("Chrome is not available") + proc = subprocess.run([sys.executable, str(HARNESS)], + capture_output=True, text=True, timeout=180) + assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}" + out = json.loads(proc.stdout) + assert "error" not in out, out["error"] + assert out["logs"] == [] + return {c["case"]: c for c in out["cases"]} + + +def test_files_a_folder(cases): + c = cases["files folder"] + assert "Copy link" in c["labels"] + assert c["copied"] == f"{ORIGIN}/#/demo@someowner/Root" + assert c["note"] == "Link copied" + + +def test_files_a_file(cases): + c = cases["files file"] + assert c["labels"].index("Copy link") > c["labels"].index("Download") + assert c["copied"] == f"{ORIGIN}/#/demo@someowner/Root/trip_2015/IMG%200001.JPG" + + +def test_files_toolbar_one_ticked_row_only(cases): + c = cases["files toolbar"] + assert c["one_enabled"] is True and c["two_enabled"] is False + assert c["copied"] == f"{ORIGIN}/#/demo@someowner/Root/trip_2015/notes.txt" + + +def test_music_a_track_from_its_dots(cases): + c = cases["music track"] + assert c["dots"] and c["labels"][-1] == "Copy link" + assert c["copied"] == f"{ORIGIN}/#/demo@someowner/Music/Some%20Album/track%2001.flac" + + +def test_photos_tile_and_lightbox(cases): + c = cases["photos"] + link = f"{ORIGIN}/#/demo@someowner/Pics/Summer/beach.jpg" + assert c["labels"] == ["Copy link"] and c["copied"] == link + assert c["lightbox_button"] and c["lightbox_copied"] == link + + +@pytest.mark.parametrize("module", ["video-player.js", "files-app.js"]) +def test_the_modals_carry_a_link_button(module): + source = (STATIC / module).read_text(encoding="utf-8") + assert re.search(r"function (VideoPlayer|FilePreview)\(\{[^}]*onCopyLink", source) + assert "${onCopyLink && html`" in source + + +@pytest.mark.parametrize("page, n", [("group-page.js", 2), ("search-page.js", 2)]) +def test_both_pages_hand_the_modals_a_link(page, n): + source = (STATIC / page).read_text(encoding="utf-8") + assert source.count("onCopyLink=${linkFor(") == n + + +def test_search_names_each_results_own_group_and_path(): + source = (STATIC / "search-page.js").read_text(encoding="utf-8") + # Every view prefixes `path`; the link is built from the original. + assert source.count("_origPath: e.path,") == 4 + assert "entryPath(target, target._origPath)" in source + for view in ("FilesPanel", "MusicApp", "PhotosApp"): + at = source.index(f"<${{{view}}}") + assert "linkFor=${linkFor}" in source[at:source.index("/>", at)], view + + +def test_the_link_is_the_hubs_not_the_pages(): + """In the desktop application the page is app://meshbay.""" + source = (STATIC / "copy-link.js").read_text(encoding="utf-8") + assert "platform.hubOrigin() + '/#' + groupLinkRoute(" in source + assert "location.origin" not in source + + +def test_the_note_goes_by_itself(cases): + c = cases["note goes"] + assert c["up_after_copy"] is True and c["up_later"] is False diff --git a/packages/meshbay-hub/tests/test_group_link.py b/packages/meshbay-hub/tests/test_group_link.py new file mode 100644 index 0000000..e71df20 --- /dev/null +++ b/packages/meshbay-hub/tests/test_group_link.py @@ -0,0 +1,170 @@ +""" +A group named in the address: `#/name@owner[/path]`. + +The handle under every group's name is also a link to it, and a path after it +names a folder to open or a file to download. `group-link.js` parses it, +builds it, finds the group among the account's own and the entry in the +group's index; the module is executed whole, as `test_search_source_merge.py` +does with `source-merge.js`, so these rules are the ones the page runs. + +Also held here, at source level: the sign-in form no longer sends everyone +home, which is what made any link opened signed out land on the home page. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +SRC = STATIC / "group-link.js" + +IMPORT = re.compile(r"^\s*import\b", re.M) +EXPORT = re.compile(r"^export \{[^}]*\};?\s*$", re.M) + +needs_node = pytest.mark.skipif( + shutil.which("node") is None or not SRC.exists(), + reason="node or the SPA sources are not available") + + +@pytest.fixture(scope="module") +def module_source(): + text = SRC.read_text(encoding="utf-8") + assert not IMPORT.search(text), ( + "group-link.js has gained an import; this test runs it standalone") + stripped, n = EXPORT.subn("", text) + assert n == 1 + return stripped + + +def _run(tmp_path, module_source, expr): + script = tmp_path / "case.js" + script.write_text(f"{module_source}\nconsole.log(JSON.stringify({expr}));\n", + encoding="utf-8") + out = subprocess.run(["node", str(script)], capture_output=True, text=True, + encoding="utf-8", timeout=30) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +GROUPS = [ + {"id": "g1", "name": "demo", "owner_username": "someowner"}, + {"id": "g2", "name": "demo", "owner_username": "otherowner"}, + {"id": "g3", "name": "trips@home", "owner_username": "someowner"}, + {"id": "g4", "name": "a/b c", "owner_username": "someowner"}, +] + +ENTRIES = [ + {"path": "backup/city_2015/backup", "name": "IMG_0001.JPG"}, + {"path": "backup/city_2015", "name": "notes.txt"}, + {"path": "music", "name": "track 01.flac"}, +] + + +@needs_node +@pytest.mark.parametrize("route, expected", [ + ("/demo@someowner", {"name": "demo", "owner": "someowner", "path": ""}), + ("/demo@someowner/backup/city_2015/backup/IMG_0001.JPG", + {"name": "demo", "owner": "someowner", + "path": "backup/city_2015/backup/IMG_0001.JPG"}), + # The owner is after the last `@`: a group name may hold one, a username not. + ("/trips@home@someowner", {"name": "trips@home", "owner": "someowner", "path": ""}), + # Each segment decoded on its own: an escaped `/` stays inside its segment. + ("/a%2Fb%20c@someowner/music/track%2001.flac", + {"name": "a/b c", "owner": "someowner", "path": "music/track 01.flac"}), + ("/demo@someowner/music/", {"name": "demo", "owner": "someowner", "path": "music"}), + # Every other route, and anything that is not a well-formed handle. + ("/group/0f8fad5b-d9cb-469f-a165-70867728950e", None), + ("/login", None), ("/", None), ("", None), + ("/@someowner", None), ("/demo@", None), + ("/demo@someowner/%E0%A4%A", None), + ("/demo@someowner/music/../../etc", None), +]) +def test_parse(tmp_path, module_source, route, expected): + assert _run(tmp_path, module_source, f"parseGroupLink({json.dumps(route)})") == expected + + +@needs_node +@pytest.mark.parametrize("group, path", [ + (GROUPS[0], ""), + (GROUPS[0], "backup/city_2015/backup/IMG_0001.JPG"), + (GROUPS[2], ""), + (GROUPS[3], "music/track 01.flac"), + ({"name": "Été à la mer", "owner_username": "someowner"}, "photos/plage #1.jpg"), +]) +def test_built_routes_parse_back(tmp_path, module_source, group, path): + out = _run(tmp_path, module_source, + f"(() => {{ const r = groupLinkRoute({json.dumps(group)}, {json.dumps(path)});" + f" return [r, parseGroupLink(r)]; }})()") + route, parsed = out + assert parsed == {"name": group["name"], "owner": group["owner_username"], "path": path} + # Nothing that ends or splits a fragment is left bare. + assert not re.search(r"[#?\s%](?![0-9A-F]{2})", route) + + +@needs_node +def test_a_plain_handle_stays_readable(tmp_path, module_source): + route = _run(tmp_path, module_source, + f"groupLinkRoute({json.dumps(GROUPS[0])}, 'backup/city_2015/IMG_1.JPG')") + assert route == "/demo@someowner/backup/city_2015/IMG_1.JPG" + + +@needs_node +@pytest.mark.parametrize("link, expected", [ + ({"name": "demo", "owner": "someowner"}, "g1"), + ({"name": "demo", "owner": "otherowner"}, "g2"), + # The hub keeps names unique on lower(name). + ({"name": "DEMO", "owner": "someowner"}, "g1"), + ({"name": "demo", "owner": "SomeOwner"}, "g1"), + # Not among the account's groups: nothing, and nothing asked of the hub. + ({"name": "demo", "owner": "stranger1"}, None), + ({"name": "secret", "owner": "someowner"}, None), +]) +def test_find_among_own_groups(tmp_path, module_source, link, expected): + out = _run(tmp_path, module_source, + f"(findLinkedGroup({json.dumps(GROUPS)}, {json.dumps(link)}) || {{}}).id || null") + assert out == expected + + +@needs_node +@pytest.mark.parametrize("path, expected", [ + ("backup/city_2015/backup/IMG_0001.JPG", + {"kind": "file", "entry": ENTRIES[0]}), + ("backup/city_2015/notes.txt", {"kind": "file", "entry": ENTRIES[1]}), + ("backup/city_2015", {"kind": "dir", "dir": "backup/city_2015"}), + ("backup", {"kind": "dir", "dir": "backup"}), + # An empty folder exists only in the node's own listing. + ("backup/empty", {"kind": "dir", "dir": "backup/empty"}), + # A prefix of a folder name is not that folder. + ("backup/city", None), + ("backup/city_2015/backup/img_0001.jpg", None), + ("", None), +]) +def test_resolve_in_index(tmp_path, module_source, path, expected): + out = _run(tmp_path, module_source, + f"resolveLinkedPath({json.dumps(ENTRIES)}, ['backup/empty'], {json.dumps(path)})") + assert out == expected + + +def test_signing_in_keeps_the_page_the_address_names(): + """A group or file link opened signed out shows the sign-in form in its + place; signing in must leave the address alone, not send everyone home.""" + source = (STATIC / "auth-page.js").read_text(encoding="utf-8") + body = source[source.index("export function LoginPage"):] + body = body[:body.index("\n}\n")] + assert "navigate('/')" not in body and 'navigate("/")' not in body + assert "if (loadPending()) navigate('/invite');" in body + + +def test_the_router_resolves_a_handle_and_shows_it(): + source = (STATIC / "app.js").read_text(encoding="utf-8") + body = source[source.index("\nfunction App() {"):] + assert "parseGroupLink(route)" in body + assert "findLinkedGroup(groups, groupLink)" in body + # Rewritten with `replace`: showing the handle is not a history entry. + assert "window.location.replace('#' + shownGroupRoute)" in body + # The sidebar highlights the group whichever form opened it. + assert re.search(r"<\$\{Sidebar\}[\s\S]*?route=\$\{groupRoute\}", body) diff --git a/packages/meshbay-hub/tests/test_group_link_flow.py b/packages/meshbay-hub/tests/test_group_link_flow.py new file mode 100644 index 0000000..eb6a353 --- /dev/null +++ b/packages/meshbay-hub/tests/test_group_link_flow.py @@ -0,0 +1,67 @@ +""" +A group link, opened in the real application (harness/group_link_probe.py). + +`test_group_link.py` holds the parsing and the lookups; this is where they meet +the router. A handle opens the group's page, and an id opens it with the handle +in the address; a path waits in the address for the index to say what it is; a +handle the account does not know is said to be unknown without the hub ever +being asked about it; and signed out, the sign-in form stands in for the page +with the address left alone, which is what lets signing in land there. +""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "group_link_probe.py" +GROUP_HREF = "#/group/0f8fad5b-d9cb-469f-a165-70867728950e" +FILE_LINK = "#/demo@someowner/backup/city_2015/backup/IMG_0001.JPG" + + +@pytest.fixture(scope="module") +def cases(): + if shutil.which("google-chrome") is None: + pytest.skip("Chrome is not available") + proc = subprocess.run([sys.executable, str(HARNESS)], + capture_output=True, text=True, timeout=240) + assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}" + out = {c["case"]: c for c in json.loads(proc.stdout)} + for c in out.values(): + assert "error" not in c, c["error"] + return out + + +@pytest.mark.parametrize("case", ["handle", "uuid", "file"]) +def test_a_handle_or_an_id_opens_the_group(cases, case): + c = cases[case] + assert c["group_page"] and c["group_title"] == "demo@someowner" + # Of two groups called "demo", the one whose owner the handle names. + assert c["active_sidebar"] == [GROUP_HREF] + + +@pytest.mark.parametrize("case", ["handle", "uuid"]) +def test_the_address_shows_the_handle_without_a_history_entry(cases, case): + assert cases[case]["hash"] == "#/demo@someowner" + assert cases[case]["history_length"] == 1 + + +def test_a_path_stays_in_the_address_until_it_has_been_acted_on(cases): + assert cases["file"]["hash"] == FILE_LINK + + +def test_an_unknown_handle_is_said_so_without_asking_the_hub(cases): + c = cases["unknown"] + assert not c["group_page"] and c["message"] and not c["spinner"] + assert c["hash"] == "#/demo@stranger1" + for case in cases.values(): + assert not any("stranger1" in u or "@" in u for u in case["hub_calls"]) + + +def test_signed_out_the_form_stands_in_for_the_page(cases): + c = cases["signed_out"] + assert c["login_form"] and not c["group_page"] + assert c["hash"] == FILE_LINK diff --git a/packages/meshbay-hub/tests/test_http_api_doc.py b/packages/meshbay-hub/tests/test_http_api_doc.py new file mode 100644 index 0000000..5568e63 --- /dev/null +++ b/packages/meshbay-hub/tests/test_http_api_doc.py @@ -0,0 +1,30 @@ +""" +docs/MESHBAY_HTTP_API.md is generated from the routes of the hub and of the +node's control API. A route added, removed or redescribed without running +`python docs/generate_http_api.py` fails here. +""" + +import importlib.util +from pathlib import Path + +GENERATOR = Path(__file__).resolve().parents[3] / "docs" / "generate_http_api.py" + + +def _generator(): + spec = importlib.util.spec_from_file_location("generate_http_api", GENERATOR) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_the_api_listing_matches_the_routes(): + gen = _generator() + assert gen.OUT.read_text(encoding="utf-8") == gen.render(), ( + "docs/MESHBAY_HTTP_API.md is out of date: run python docs/generate_http_api.py") + + +def test_every_route_says_what_it_does(): + gen = _generator() + bare = [f"{gen.method(r)} {r.path}" for r in gen.hub_routes() + gen.node_routes() + if not gen.summary(r)] + assert not bare, f"give these routes a docstring, it is their line in the listing: {bare}" diff --git a/packages/meshbay-hub/tests/test_invite_link_flow.py b/packages/meshbay-hub/tests/test_invite_link_flow.py index 42f461c..cc8b432 100644 --- a/packages/meshbay-hub/tests/test_invite_link_flow.py +++ b/packages/meshbay-hub/tests/test_invite_link_flow.py @@ -33,14 +33,14 @@ def cases(): return out -@pytest.mark.parametrize("case", ["signed_out", "signed_in"]) +@pytest.mark.parametrize("case", ["signed_out", "signed_in", "signed_in_listed"]) def test_the_code_is_out_of_the_address_and_kept_in_the_tab(cases, case): c = cases[case] assert c["hash_after_load"] == "#/invite" assert c["pending"] and c["pending"]["c"] == "K7P2-9WQX" -@pytest.mark.parametrize("case", ["signed_out", "signed_in"]) +@pytest.mark.parametrize("case", ["signed_out", "signed_in", "signed_in_listed"]) def test_the_code_never_reaches_the_hub(cases, case): assert cases[case]["code_in_a_hub_request"] is False @@ -59,3 +59,16 @@ def test_a_signed_in_reader_joins_with_one_click_and_lands_on_the_group(cases): assert c["join_button"] assert c["redeem_bodies"] == ['{"ticket":"AbCdEfGhIjKlMnOpQr-_12"}'] assert c["hash_after_click"] == "#/group/0f8fad5b-d9cb-469f-a165-70867728950e" + + +def test_once_listed_the_joined_group_opens_under_its_handle(cases): + """The group links (group-link.js) rewrite `#/group/<id>` to the handle as + soon as the hub lists the group. Joining from an invitation must land on + the group page all the same, under that handle, with the rewrite adding + no history entry of its own — and still without the code reaching the hub.""" + c = cases["signed_in_listed"] + assert c["join_button"] + assert c["hash_settled"] == "#/Some Group@the-owner" + assert c["group_page"] is True + # The page load, then the Join button's navigation; the rewrite replaces. + assert c["history_length"] == 2 diff --git a/packages/meshbay-hub/tests/test_search_unreachable_note.py b/packages/meshbay-hub/tests/test_search_unreachable_note.py new file mode 100644 index 0000000..41ed25c --- /dev/null +++ b/packages/meshbay-hub/tests/test_search_unreachable_note.py @@ -0,0 +1,33 @@ +""" +"N groups unreachable" on the cross-group Search page is said once a pass is +over, for a few seconds, in the same passing note as "Link copied" (note.js) — +not left above the results for as long as the page is open. + +Source-level, as the other Search wiring checks are: what is held is where the +line is said and that nothing renders it in the page any more. +""" + +import re +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" + + +def test_said_once_the_pass_is_over_and_not_rendered(): + source = (STATIC / "search-page.js").read_text(encoding="utf-8") + assert "import { say } from './note.js';" in source + after = source[source.index("const { unreachable } = await fetchAllIndexes("):] + after = after[:after.index("})();")] + assert re.search(r"if \(cancelled\) return;\s*setFetching\(false\);", after) + assert "say(t('search.unreachable', { n: unreachable.length }), UNREACHABLE_NOTE_MS)" in after + assert "search-unreachable" not in source + ms = int(re.search(r"const UNREACHABLE_NOTE_MS = (\d+);", source).group(1)) + assert 2000 <= ms <= 8000 + + +def test_one_note_for_the_whole_page(): + note = (STATIC / "note.js").read_text(encoding="utf-8") + assert "export function say(text, ms = 2500)" in note + assert "clearTimeout(_timer)" in note + copy = (STATIC / "copy-link.js").read_text(encoding="utf-8") + assert "import { say } from './note.js';" in copy and "_say" not in copy diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py index 6f28aaa..44f98db 100644 --- a/packages/meshbay-hub/tests/test_spa_ordering.py +++ b/packages/meshbay-hub/tests/test_spa_ordering.py @@ -162,7 +162,7 @@ def _component(name: str) -> str: def test_the_group_settings_panel_renders_what_it_owns(): panel = _component("GroupSettingsPanel") - assert "members.map(" in panel, "the member list is not rendered" + assert "joined.map(" in panel, "the member list is not rendered" assert "onSubmit=${doInvite}" in panel, "the invite form is not rendered" assert "onSubmit=${doPair}" in panel, "the pairing form is not rendered" assert "device.mine_title" in panel, "the devices section is not rendered" @@ -175,7 +175,7 @@ def test_the_roster_comes_last(): order is for — asked for in those terms. """ panel = _component("GroupSettingsPanel") - listing = panel.index("members.map(") + listing = panel.index("joined.map(") for name, marker in (("the invite form", "onSubmit=${doInvite}"), ("the pairing form", "onSubmit=${doPair}"), ("the devices section", "device.mine_title"), |