aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/scroll_probe.py
blob: ae407b8a4c37e159aefef22819479e69b9bf43ac (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
#!/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"
# fit() is ChatPanel's own viewport-sizing logic, moved to chat-app.js in the
# group-page refactor.
APP = STATIC / "chat-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()
        # ignore_cleanup_errors: Chrome's children (zygote, renderer, gpu)
        # outlive terminate() on the parent by a moment and go on writing into
        # the profile. rmtree then walks a directory that gains a file between
        # its readdir and its rmdir and raises "Directory not empty" -- which
        # failed the probe, which failed every test in the file, intermittently
        # and for a reason nowhere near the chat code they were testing. A few
        # bytes left in a throwaway profile are harmless; failing the run is not.
        with tempfile.TemporaryDirectory(
                ignore_cleanup_errors=True) 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())