aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/harness/copy_link_probe.py270
-rw-r--r--packages/meshbay-hub/tests/harness/group_link_probe.py176
-rw-r--r--packages/meshbay-hub/tests/harness/invite_link_probe.py23
-rw-r--r--packages/meshbay-hub/tests/test_copy_link.py103
-rw-r--r--packages/meshbay-hub/tests/test_group_link.py170
-rw-r--r--packages/meshbay-hub/tests/test_group_link_flow.py67
-rw-r--r--packages/meshbay-hub/tests/test_http_api_doc.py30
-rw-r--r--packages/meshbay-hub/tests/test_invite_link_flow.py17
-rw-r--r--packages/meshbay-hub/tests/test_search_unreachable_note.py33
-rw-r--r--packages/meshbay-hub/tests/test_spa_ordering.py4
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"),