diff options
Diffstat (limited to 'packages/meshbay-hub/tests/harness/scroll_probe.py')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/scroll_probe.py | 169 |
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()) |