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/scroll_probe.py169
-rw-r--r--packages/meshbay-hub/tests/test_groups_self_service.py45
-rw-r--r--packages/meshbay-hub/tests/test_page_does_not_scroll.py125
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py58
4 files changed, 381 insertions, 16 deletions
diff --git a/packages/meshbay-hub/tests/harness/scroll_probe.py b/packages/meshbay-hub/tests/harness/scroll_probe.py
new file mode 100644
index 0000000..73ff0f1
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/scroll_probe.py
@@ -0,0 +1,169 @@
+#!/usr/bin/env python3
+"""
+Does the page scroll vertically when it should not?
+
+`layout_probe.py` answers "where is this box". This one answers "is the document
+taller than the window", which is a different question and the one behind two
+separate reports of a scrollbar that would not go away.
+
+The sizing code under test is **read out of `app.js` and run here**, not
+reimplemented: a copy of the formula living in the test would go on passing
+after the real one changed, which is the failure mode worth avoiding in a file
+whose whole purpose is to catch an arithmetic slip.
+
+ scroll_probe.py <html-fragment-file> [<height>,<height>,...]
+
+Reports per viewport height: the window, the document, the difference, every
+element hanging below the fold, and the chat panel's box if there is one.
+"""
+import http.server
+import json
+import re
+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"
+APP = STATIC / "app.js"
+PORT = 8736
+FRAG = Path(sys.argv[1]).read_text()
+HEIGHTS = ([int(h) for h in sys.argv[2].split(",")]
+ if len(sys.argv) > 2 else [700, 900, 1200])
+
+
+def chat_fit_body() -> str:
+ """
+ The body of the chat panel's `fit()`, lifted from `app.js`.
+
+ It closes over `el` and two constants, so those are supplied around it;
+ everything between the braces — the second pass included — is the shipped
+ code. A rename breaks this loudly, which is intended: a skipped test here
+ would be worse than a failing one.
+ """
+ source = APP.read_text(encoding="utf-8")
+ start = source.index(" const fit = () => {")
+ end = source.index("\n };", start)
+ body = source[source.index("{", start) + 1:end]
+ consts = {}
+ for name in ("CHAT_MIN_HEIGHT", "CHAT_BOTTOM_GAP"):
+ line = re.search(rf"^const {name} = (\d+);", source, re.M)
+ assert line, f"{name} is gone or was renamed"
+ consts[name] = line.group(1)
+ return ("(el, window, document) => {"
+ f"const CHAT_MIN_HEIGHT = {consts['CHAT_MIN_HEIGHT']};"
+ f"const CHAT_BOTTOM_GAP = {consts['CHAT_BOTTOM_GAP']};"
+ + body + "}")
+
+
+PAGE = """<!doctype html><html><head><meta charset=utf-8></head><body style="margin:0">
+<!-- One iframe per height: a headless window has a floor of its own, and an
+ iframe establishes the viewport we actually mean. -->
+<div id="frames"></div><script>
+const HEIGHTS = %(heights)s, FRAG = %(frag)s;
+const host = document.getElementById('frames');
+for (const h of HEIGHTS) {
+ const f = document.createElement('iframe');
+ f.id = 'f' + h;
+ f.style.cssText = `width:1100px;height:${h}px;border:0;display:block`;
+ host.appendChild(f);
+ const d = f.contentDocument;
+ d.open();
+ d.write(`<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head><body>${FRAG}</body></html>`);
+ d.close();
+}
+// The real fit() from app.js. A <script> written into the fragment does not
+// fire, so it is applied from out here once the stylesheet has settled.
+const FIT = %(fit)s;
+setTimeout(() => {
+ for (const h of HEIGHTS) {
+ const win = document.getElementById('f' + h).contentWindow;
+ const el = win.document.querySelector('.chat-panel');
+ if (el) FIT(el, win, win.document);
+ }
+}, 200);
+setTimeout(() => {
+ const out = {};
+ for (const h of HEIGHTS) {
+ const win = document.getElementById('f' + h).contentWindow;
+ const doc = win.document.documentElement;
+ const past = [];
+ for (const el of win.document.querySelectorAll('*')) {
+ const b = el.getBoundingClientRect();
+ if (b.bottom > win.innerHeight + 0.5)
+ past.push((el.className || el.tagName) + ' +' +
+ Math.round(b.bottom - win.innerHeight));
+ }
+ const panel = win.document.querySelector('.chat-panel');
+ const pb = panel && panel.getBoundingClientRect();
+ out[h] = {viewport: win.innerHeight, scrollHeight: doc.scrollHeight,
+ overflow: doc.scrollHeight - win.innerHeight,
+ past: past.slice(0, 12),
+ panel: pb ? {top: Math.round(pb.top), bottom: Math.round(pb.bottom),
+ height: Math.round(pb.height)} : null};
+ }
+ fetch('/log', {method: 'POST', body: JSON.stringify(out)});
+}, 700);
+</script></body></html>"""
+
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+
+class H(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ RECORDS.append(json.loads(
+ self.rfile.read(int(self.headers["Content-Length"])).decode()))
+ self.send_response(204)
+ self.end_headers()
+
+ def do_GET(self):
+ if self.path == "/":
+ # A "</script>" inside the fragment would close the inline script
+ # it is embedded in, and the page would measure nothing.
+ body = (PAGE % {"frag": json.dumps(FRAG).replace("</", "<\\/"),
+ "heights": json.dumps(HEIGHTS),
+ "fit": chat_fit_body()}).encode()
+ ctype = "text/html; charset=utf-8"
+ elif self.path == "/style.css":
+ body = (STATIC / "style.css").read_bytes()
+ ctype = "text/css"
+ else:
+ self.send_response(404)
+ self.end_headers()
+ return
+ 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 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() as profile:
+ subprocess.run(
+ ["google-chrome", "--headless", "--disable-gpu", "--no-sandbox",
+ f"--user-data-dir={profile}", "--window-size=1100,1300",
+ "--virtual-time-budget=6000", "--dump-dom",
+ f"http://127.0.0.1:{PORT}/"],
+ capture_output=True, timeout=120)
+ for _ in range(50):
+ if RECORDS:
+ break
+ time.sleep(0.1)
+ print(json.dumps(RECORDS[0] if RECORDS else {"error": "no measurement"},
+ indent=1))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/packages/meshbay-hub/tests/test_groups_self_service.py b/packages/meshbay-hub/tests/test_groups_self_service.py
index 3202658..def663d 100644
--- a/packages/meshbay-hub/tests/test_groups_self_service.py
+++ b/packages/meshbay-hub/tests/test_groups_self_service.py
@@ -175,3 +175,48 @@ async def test_join_triggers_notification(client):
r = await client.get(f"/v1/groups/{gid}/members",
headers={"Authorization": f"Bearer {alice_token}"})
assert len(r.json()["members"]) == 2
+
+
+# ── Listed and open are one question ────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_a_private_group_cannot_be_open_to_everyone(client):
+ """
+ A group anyone may join that nobody can find is a listing with the listing
+ removed: it is absent from the directory, and joining goes through the node
+ rather than a link, so nothing can reach it. It was accepted until now, and
+ the create form offered it.
+ """
+ await _register(client, "pat", email="pat@x.com")
+ token = await _login(client, "pat")
+ resp = await client.post("/v1/groups", json={
+ "name": "nowhere", "visibility": "private", "join_policy": "open",
+ }, headers={"Authorization": f"Bearer {token}"})
+
+ assert resp.status_code == 422
+ assert "invite-only" in resp.json()["detail"]
+
+
+@pytest.mark.asyncio
+async def test_a_public_group_cannot_be_invite_only(client):
+ """The other half, which was already refused — kept so that removing one
+ check does not quietly remove both."""
+ await _register(client, "sam", email="sam@x.com")
+ token = await _login(client, "sam")
+ resp = await client.post("/v1/groups", json={
+ "name": "deadend", "visibility": "public", "join_policy": "invite",
+ }, headers={"Authorization": f"Bearer {token}"})
+
+ assert resp.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_the_two_combinations_that_mean_something_are_accepted(client):
+ await _register(client, "robin", email="robin@x.com")
+ token = await _login(client, "robin")
+ for name, visibility, policy in (("closed", "private", "invite"),
+ ("open-house", "public", "open")):
+ resp = await client.post("/v1/groups", json={
+ "name": name, "visibility": visibility, "join_policy": policy,
+ }, headers={"Authorization": f"Bearer {token}"})
+ assert resp.status_code == 201, f"{visibility}+{policy}: {resp.text}"
diff --git a/packages/meshbay-hub/tests/test_page_does_not_scroll.py b/packages/meshbay-hub/tests/test_page_does_not_scroll.py
new file mode 100644
index 0000000..1187a7a
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_page_does_not_scroll.py
@@ -0,0 +1,125 @@
+"""
+A page whose content fits the window must not offer a scrollbar.
+
+Twice now. First the sign-in card: `.layout` and `.page-center` each reserved
+`100vh - 52px` and the second sat inside the first's 24px padding, so the
+document was 48px too tall at every window size. Then the chat tab: the panel is
+sized from JS to `viewport - top - 16`, which puts its bottom 16px above the
+fold — but `.main` adds 24px of padding below it, so the document came out
+**exactly 8px too tall, at every window size**, which is what "there is always a
+scrollbar" means.
+
+Neither is visible in the stylesheet. Both are one subtraction against another,
+in different files, and the only way to see them is to measure the document
+against the window — which is what this does, running the real `fit()` lifted
+out of `app.js` rather than a copy of it.
+"""
+
+import json
+import shutil
+import subprocess
+import textwrap
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "scroll_probe.py"
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("google-chrome") is None or not (STATIC / "style.css").exists(),
+ reason="Chrome or the SPA stylesheet is not available")
+
+HEIGHTS = [700, 900, 1200]
+
+NAV_AND_SIDEBAR = """
+<nav class="nav">
+ <div class="nav-left"><button class="nav-hamburger">&#9776;</button>
+ <a class="nav-brand" href="#/">MeshBay</a></div>
+ <div class="nav-right"><a class="nav-notif" href="#/">&#128276;</a>
+ <div class="user-menu"><button class="nav-btn">someone</button></div></div>
+</nav>
+"""
+
+CHAT_TAB = NAV_AND_SIDEBAR + """
+<div class="layout">
+ <aside class="sidebar"><div class="sidebar-section">Groups</div></aside>
+ <main class="main">
+ <div class="group-header"><h2>a group</h2></div>
+ <div class="group-tabs">
+ <button class="group-tab active">Chat</button>
+ <button class="group-tab">Files</button>
+ <button class="group-tab">Settings</button>
+ </div>
+ <div class="chat-panel">
+ <div class="chat-messages"><p>hello</p></div>
+ <div class="chat-composer"><input type="text" /><button class="admin-btn">Send</button></div>
+ </div>
+ </main>
+</div>
+"""
+
+SHORT_PAGE = NAV_AND_SIDEBAR + """
+<div class="layout">
+ <aside class="sidebar"><div class="sidebar-section">Groups</div></aside>
+ <main class="main">
+ <h2>a group</h2>
+ <div class="settings-section"><p>not much here</p></div>
+ </main>
+</div>
+"""
+
+
+def _measure(fragment: str, tmp_path: Path) -> dict:
+ path = tmp_path / "fragment.html"
+ path.write_text(fragment, encoding="utf-8")
+ proc = subprocess.run(
+ ["python3", str(HARNESS), str(path), ",".join(str(h) for h in HEIGHTS)],
+ 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, f"no measurement: {out}"
+ return out
+
+
+@pytest.fixture(scope="module")
+def chat(tmp_path_factory):
+ return _measure(CHAT_TAB, tmp_path_factory.mktemp("chat"))
+
+
+@pytest.fixture(scope="module")
+def short(tmp_path_factory):
+ return _measure(SHORT_PAGE, tmp_path_factory.mktemp("short"))
+
+
+@pytest.mark.parametrize("height", HEIGHTS)
+def test_the_chat_tab_fits_its_window(chat, height):
+ r = chat[str(height)]
+ assert r["overflow"] <= 0, (
+ f"the document is {r['overflow']}px taller than the {height}px window — "
+ f"a scrollbar on the chat tab. Past the fold: {r['past']}")
+
+
+@pytest.mark.parametrize("height", HEIGHTS)
+def test_nothing_on_the_chat_tab_hangs_below_the_fold(chat, height):
+ """The composer is the one that matters: a chat you cannot type in."""
+ assert chat[str(height)]["past"] == []
+
+
+@pytest.mark.parametrize("height", HEIGHTS)
+def test_the_chat_panel_uses_the_room_it_has(chat, height):
+ """The correction must not overshoot. The panel should end just above the
+ fold, not halfway up the page — a 240px chat in a 1200px window would pass
+ every assertion above and be useless."""
+ panel = chat[str(height)]["panel"]
+ assert panel, "no chat panel in the measurement"
+ gap = height - panel["bottom"]
+ assert 0 <= gap <= 40, (
+ f"the panel ends {gap}px above the fold at {height}px")
+
+
+@pytest.mark.parametrize("height", HEIGHTS)
+def test_a_short_page_does_not_scroll_either(short, height):
+ """The control: without this, a chat panel shrunk to nothing would pass."""
+ r = short[str(height)]
+ assert r["overflow"] <= 0, f"{r['overflow']}px of overflow with no content"
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index 8d975d7..5da8104 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -129,6 +129,14 @@ def test_presence_has_three_states_and_a_label_for_each(app):
"the dot needs a title and an aria-label, not just a colour")
+def _string(source: str, key: str) -> str:
+ """One locale entry's text, whether it is written on one line or spliced
+ across several with `+`."""
+ start = source.index(f"'{key}':") + len(f"'{key}':")
+ end = source.index("\n '", start)
+ return source[start:end]
+
+
def test_a_refusal_from_the_node_counts_as_present(app):
"""The node answering "no" proves it is up; only silence proves nothing."""
assert "err.reason ? 'online' : 'offline'" in app
@@ -136,30 +144,48 @@ def test_a_refusal_from_the_node_counts_as_present(app):
# ── The create-group form ─────────────────────────────────────────────────────
-def test_choosing_public_settles_the_admission_question(app):
- """Public implies open, so the policy selector has nothing left to ask.
-
- Enforced twice on purpose: the API refuses public+invite with a 422, and the
- form never offers the combination. A form that can build a request the server
- rejects is a form that produces an error message instead of a group.
+def test_the_form_asks_one_question_not_two(app):
"""
- assert "setVisibility('public'); setJoinPolicy('open');" in app, (
- "picking Public must settle the policy, not leave the previous one")
- assert "setVisibility('private'); setJoinPolicy('invite');" in app, (
- "going back to Private must not leave the group open by accident")
+ Visibility and admission were separate selectors that could only ever be set
+ together, and the form knew it — picking Public reached over and set the
+ policy. Two of the four combinations were impossible: the API refused
+ public+invite with a 422, and private+open is a directory listing nobody can
+ find, joining being through the node rather than a link.
+ So there is one selector. "Open" is what makes a group listed, and the
+ request derives the rest.
+ """
form = app[app.index("function CreateGroupPage"):]
form = form[:form.index("\n}\n")]
- selector = form.index("t('create_group.join_policy')")
- guard = form.rindex("visibility === 'public'", 0, selector)
- assert guard != -1, "the policy selector must sit behind a visibility guard"
- assert "create_group.public_is_open" in form[guard:selector], (
- "a public group should say why there is nothing to choose")
+
+ assert "setVisibility(" not in form, "the visibility selector is back"
+ assert "t('create_group.join_policy')" in form
+ assert "joinPolicy === 'open' ? 'public' : 'private'" in form, (
+ "the request must derive visibility rather than leave it unset")
+
+
+def test_the_form_says_what_each_choice_means_for_finding_the_group(app):
+ """Dropping the visibility box removes the words "public" and "private"
+ from the page. If the descriptions do not say it, nothing does — and
+ somebody publishes a group without meaning to."""
+ en = (STATIC / "locales" / "en.js").read_text(encoding="utf-8")
+ invite = _string(en, "create_group.invite_desc")
+ open_ = _string(en, "create_group.open_desc")
+ assert "not listed" in invite.lower()
+ assert "listed" in open_.lower() and "anyone" in open_.lower()
+
+
+def test_the_strings_the_visibility_box_used_are_gone(app):
+ """A key nobody reads is a key that rots, and ten locales carry each one."""
+ for locale in (STATIC / "locales").glob("*.js"):
+ text = locale.read_text(encoding="utf-8")
+ for key in ("create_group.visibility", "create_group.private",
+ "create_group.public_is_open", "create_group.public_desc"):
+ assert f"'{key}'" not in text, f"{locale.name} still carries {key}"
def test_the_form_starts_on_a_combination_the_api_accepts(app):
form = app[app.index("function CreateGroupPage"):]
- assert "useState('private')" in form[:form.index("return html")]
assert "useState('invite')" in form[:form.index("return html")]