summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/scroll_probe.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/harness/scroll_probe.py')
-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())