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/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_invite_link_flow.py | 17 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_search_unreachable_note.py | 33 |
5 files changed, 441 insertions, 5 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/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_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 |