aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 17:46:54 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 17:46:54 +0200
commitcd2e89f5f5cccdb116db4fcb82d00b6325972782 (patch)
tree17be58fb00b49736f818a2f8063464960b5b1101 /packages/meshbay-hub/tests/harness
parent2ef5498ab1509a93691b87b4cc5d9b52bb3f52dc (diff)
downloadmeshbay-cd2e89f5f5cccdb116db4fcb82d00b6325972782.tar.gz
fix: the chat tab no longer scrolls, and a group is listed or invite-only
**The chat tab was 8px too tall, at every window size.** The panel is sized from JS to `viewport - top - 16`, which puts its bottom 16px above the fold — but it sits inside `.main`, which adds 24px of padding below it. Eight pixels of document past the window, whatever the window. Measured at 700, 900 and 1200: `scrollHeight` 708, 908, 1208. This is the second one of these — the sign-in card was `.page-center` and `.layout` each reserving `100vh - 52px` — so it is now measured in the suite rather than reasoned about. `tests/harness/scroll_probe.py` renders the real markup against the real stylesheet and **runs the real `fit()` lifted out of `app.js`**: a copy of the formula in a test would go on passing after the original changed, which is exactly the bug being guarded. The fix does not encode 24 anywhere. The first pass runs as before, then the leftover is measured and taken off, so anything added below the panel later is absorbed the same way. Now `scrollHeight == innerHeight` at all three heights, nothing below the fold, and the panel still fills the room it has — that last one has its own test, because shrinking the chat to 240px would satisfy every other assertion here and be useless. The Settings tab was measured too and is **not** a bug: it fits at 1200px and overflows only when its content is genuinely taller than the window. **Group creation asked one question twice.** Visibility and admission were separate selectors that could only ever be set together — picking Public reached over and set the policy — and two of the four combinations are meaningless. The API already refused public+invite with a 422, so the form could build a request that could not succeed. Private+open was accepted and should not have been: a group anyone may join that nobody can find is a listing with the listing removed, since joining goes through the node and there is no link to pass around. So: one selector, "who can join", and the request derives the rest. The API now refuses the other impossible pair as well, with a message that says which way to resolve it. Six locale strings the visibility box owned are deleted rather than left unread in ten files, and the two surviving descriptions now say what each choice means for who can *find* the group — with the word "public" gone from the page, nothing else would have said it, and someone would publish a group without meaning to. 865 tests pass. 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/scroll_probe.py169
1 files changed, 169 insertions, 0 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())