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/files_menu_probe.py191
-rw-r--r--packages/meshbay-hub/tests/test_files_context_menu.py60
-rw-r--r--packages/meshbay-hub/tests/test_spa_ordering.py4
3 files changed, 254 insertions, 1 deletions
diff --git a/packages/meshbay-hub/tests/harness/files_menu_probe.py b/packages/meshbay-hub/tests/harness/files_menu_probe.py
new file mode 100644
index 0000000..74c33de
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/files_menu_probe.py
@@ -0,0 +1,191 @@
+#!/usr/bin/env python3
+"""
+The Files tab's right-click menu, in a real browser.
+
+Mounts the shipped `FilesPanel` on a made-up index — no node, no transport —
+right-clicks rows the way a reader would, and reads back which actions the
+shared `Menu` offered. The toolbar is read too, because it is built from the
+same list and must keep showing what does not apply, disabled.
+
+ files_menu_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 = 8758
+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="root"></div>
+<script type="module">
+import { html, render } from '/vendor/htm-preact.js';
+import { initLocale, setLocale } from '/i18n.js';
+import { FilesPanel } from '/files-app.js';
+
+const LOGS = [];
+addEventListener('error', (e) => LOGS.push('error: ' + (e.message || e)));
+const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
+
+// Two loose files in `Root/`: a video this reader uploaded, and a text file
+// somebody else did — so Delete applies to one and not the other.
+const ENTRIES = [
+ { id: 'f-video', name: 'clip.mp4', path: 'Root', size: 10, type: 'video',
+ added_at: 1, uploader_id: 'me' },
+ { id: 'f-text', name: 'notes.txt', path: 'Root', size: 5, type: 'document',
+ added_at: 2, uploader_id: 'someone-else' },
+];
+const noop = () => {};
+
+const labels = () => [...document.querySelectorAll('.ctx-menu .ctx-menu-label')]
+ .map((el) => el.textContent);
+const rowNamed = (name) => [...document.querySelectorAll('tr.file-row')]
+ .find((tr) => tr.querySelector('.file-name')
+ && tr.querySelector('.file-name').textContent.trim().startsWith(name));
+const rightClick = async (el) => {
+ // Close whatever the previous case left open, as a click elsewhere would.
+ document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
+ await frame();
+ const r = el.getBoundingClientRect();
+ const ev = new MouseEvent('contextmenu', { bubbles: true, cancelable: true,
+ clientX: r.left + 20, clientY: r.top + 5 });
+ el.dispatchEvent(ev);
+ await frame();
+ return ev.defaultPrevented;
+};
+
+(async () => {
+ const cases = [];
+ try {
+ // English, whatever the machine running this is set to.
+ setLocale('en');
+ await initLocale();
+ render(html`<${FilesPanel} groupId="g" transportRef=${{ current: null }}
+ gekRef=${{ current: null }} status="connected" entries=${ENTRIES}
+ nodeDirs=${['Root']} nodeRoots=${[{ name: 'Root', writable: false }]}
+ setEntries=${noop} setNodeDirs=${noop} setNodeRoots=${noop} applyIndex=${noop}
+ isNodeAdmin=${false} operatorPaired=${false} userId="me" setError=${noop}
+ onPreview=${noop} />`, document.getElementById('root'));
+ await frame();
+
+ let prevented = await rightClick(rowNamed('Root'));
+ cases.push({ case: 'folder', labels: labels(), prevented });
+
+ rowNamed('Root').click();
+ await frame();
+
+ prevented = await rightClick(rowNamed('clip.mp4'));
+ cases.push({ case: 'own video', labels: labels(), prevented });
+
+ prevented = await rightClick(rowNamed('notes.txt'));
+ cases.push({ case: 'someone else\'s text', labels: labels(), prevented });
+
+ for (const name of ['clip.mp4', 'notes.txt']) {
+ rowNamed(name).querySelector('input[type=checkbox]').click();
+ await frame();
+ }
+ prevented = await rightClick(rowNamed('notes.txt'));
+ cases.push({ case: 'ticked row stands for the selection', labels: labels(), prevented,
+ ticked: document.querySelectorAll('tbody input[type=checkbox]:checked').length });
+
+ cases.push({ case: 'toolbar keeps disabled buttons',
+ buttons: document.querySelectorAll('.tb-actions button').length,
+ disabled: document.querySelectorAll('.tb-actions button:disabled').length });
+
+ parent.postMessage({ cases, logs: LOGS }, '*');
+ } 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/test_files_context_menu.py b/packages/meshbay-hub/tests/test_files_context_menu.py
new file mode 100644
index 0000000..cfcbd90
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_files_context_menu.py
@@ -0,0 +1,60 @@
+"""
+Right-click in the Files tab opens the shared `Menu` (`menu.js`) with what can
+be done to that row — the toolbar's actions, from the same list.
+
+The one difference from the toolbar is deliberate: the toolbar keeps an action
+that does not apply, disabled, so it does not jump about as the selection
+changes; a menu has no layout to keep still, so it leaves the action out.
+
+Measured in a browser by `harness/files_menu_probe.py`.
+"""
+
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "files_menu_probe.py"
+
+
+@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=90)
+ data = json.loads(proc.stdout)
+ assert "error" not in data, f"probe failed: {proc.stdout}{proc.stderr}"
+ assert not data["logs"], data["logs"]
+ return {c["case"]: c for c in data["cases"]}
+
+
+def test_a_folder_offers_the_zip_only(cases):
+ c = cases["folder"]
+ assert c["prevented"]
+ assert c["labels"] == ["Download folder as zip"]
+
+
+def test_a_video_of_ones_own_can_be_played_downloaded_and_deleted(cases):
+ assert cases["own video"]["labels"] == ["Play", "Download", "Delete"]
+
+
+def test_what_does_not_apply_is_left_out_not_greyed(cases):
+ # Someone else's text file: no Play, no zip, and no Delete — the reader
+ # has no right to it, so the entry is absent rather than disabled.
+ assert cases["someone else's text"]["labels"] == ["View", "Download"]
+
+
+def test_a_ticked_row_stands_for_the_whole_selection(cases):
+ c = cases["ticked row stands for the selection"]
+ assert c["ticked"] == 2
+ assert c["labels"] == ["Download (2)", "Delete"]
+
+
+def test_the_toolbar_still_greys_what_does_not_apply(cases):
+ c = cases["toolbar keeps disabled buttons"]
+ assert c["buttons"] == 5
+ assert c["disabled"] == 3
diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py
index ba042d1..1c36d09 100644
--- a/packages/meshbay-hub/tests/test_spa_ordering.py
+++ b/packages/meshbay-hub/tests/test_spa_ordering.py
@@ -273,7 +273,9 @@ def test_a_multi_file_download_waits_for_each_picker():
# Anchored on the loop rather than on the markup around it: the toolbar
# moved from a dropdown to icon buttons and took the old wrapper with it,
# while the property under test — one picker at a time — did not change.
- block = app[app.index("for (const e of selectedFiles)"):]
+ # The loop is over `files` since the toolbar and the right-click menu
+ # share one action list.
+ block = app[app.index("for (const e of files)"):]
block = block[:block.index("\n")]
assert "await downloadFile(e)" in block, (
"downloads are fired without awaiting again; only the first will ask "