diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
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">☰</button> + <a class="nav-brand" href="#/">MeshBay</a></div> + <div class="nav-right"><a class="nav-notif" href="#/">🔔</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")] |