aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-23 15:21:30 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-23 15:21:30 +0200
commit3d8c1acf785ff7389a68cc515a5c9324dee8de41 (patch)
tree1b7b8c4bf94592007454057bef84d9ba70de2881 /packages/meshbay-hub/tests/harness
parent9f3445d03f106ee3ebd8b4b1bd546a08d9169af7 (diff)
downloadmeshbay-3d8c1acf785ff7389a68cc515a5c9324dee8de41.tar.gz
feat(hub): right-click menu in the Files tab
The toolbar's actions on the row under the pointer, sharing one action list with the toolbar — which keeps showing what does not apply, disabled, while the menu leaves it out. A count only where more than one item is concerned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/harness')
-rw-r--r--packages/meshbay-hub/tests/harness/files_menu_probe.py191
1 files changed, 191 insertions, 0 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())