#!/usr/bin/env python3 """ Does the header stay put? Files, Videos, Music and Photos pin three bands under the navigation bar — the tab bar (the search field, on the Search page), the application's toolbar, and the file table's column heads — so that scrolling a library never costs the controls that steer it. Two of those offsets are `calc()` over a height measured at runtime (sticky.js), which means the arrangement can be wrong in ways no stylesheet reading finds: a band an inch too low leaves a stripe of list showing through above it, a band too high hides the one over it, and a toolbar that wraps to three rows on a phone moves the column heads by 96px that nothing in the CSS knows about. So this scrolls. It renders the shipped `GroupPage` and `SearchPage` against a stub node, walks to each application, scrolls the window down, and reports the rectangle of every band before and after — at a desktop width and at a phone width, where the toolbars wrap and the measurement earns its keep. sticky_header_probe.py Prints JSON: one object per case, each with `before` and `after` maps of selector -> {top, bottom, height} in viewport coordinates, plus what the window did. Chrome by default; `--engine firefox` runs the same cases in Firefox, which is half of MeshBay's readers and has its own history with `position: sticky`. """ import argparse import http.server import json 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" PORT = 8751 RECORDS = [] socketserver.TCPServer.allow_reuse_address = True # Set when every case has reported. Firefox needs it: it has no # "navigate and stay open" headless mode that runs a page to completion, only # `--screenshot`, which fires at the load event and exits. So each frame holds # one image open, the load event waits for that image, and the image is # answered here once the run is done. Chrome does not need it and does not get # it. FINISHED = threading.Event() # 1100 desktop; 420 is where the phone media query has been tuned; 390 is an # actual handset, and the width the first report of a band not pinning at all # came from; 360 is the small end of what is still sold, and it is where the # settings table's two switches and its two buttons have the least room. WIDTHS = [1100, 420, 390, 360] # The tab bar's buttons are the enabled apps in registry order, so Files is 1, # Videos 2, Music 3, Photos 4 (Chat is 0, Settings last). The Search page's # view toggle is files/videos/music/photos in that order. GROUP_CASES = [ ("group files", {"page": "group", "click": [".group-tabs .group-tab:nth-of-type(2)"], "ready": ".file-toolbar", "bands": [".group-tabs", ".file-toolbar", ".file-table th"], "content": ".file-row"}), # Inside a root, where the rows are the library's own folders. The report # that started this came from there, not from the top level. ("group files in a folder", {"page": "group", # The second row, which is the music root: # the first is `films`, whose folders are # short and prove nothing. "click": [".group-tabs .group-tab:nth-of-type(2)", ".file-table tbody tr:nth-of-type(2)"], "ready": ".file-toolbar", "bands": [".group-tabs", ".file-toolbar", ".file-table th"], "content": ".file-row"}), ("group videos", {"page": "group", "click": [".group-tabs .group-tab:nth-of-type(3)"], "ready": ".video-toolbar", "bands": [".group-tabs", ".video-toolbar"], "content": ".video-tile-slot"}), ("group music", {"page": "group", "click": [".group-tabs .group-tab:nth-of-type(4)"], "ready": ".video-toolbar", "bands": [".group-tabs", ".video-toolbar"], "content": ".music-tile-slot"}), ("group photos", {"page": "group", "click": [".group-tabs .group-tab:nth-of-type(5)"], "ready": ".photo-toolbar", "bands": [".group-tabs", ".photo-toolbar"], "content": ".photo-album-tile-slot"}), # An open album swaps the toolbar for its own title bar, which pins in the # same place — the one band that is not a toolbar. # The group's own Settings, as its operator sees it: the shared-directories # table is four columns of controls, none of which can be squeezed, and it # was the next thing to hang off the right of a phone. ("group settings", {"page": "group", "click": [".group-tabs .group-tab:nth-of-type(6)"], "ready": ".shared-dirs-tbl", "bands": [".group-tabs"], "content": ".shared-dirs-tbl tbody tr"}), ("group photo album", {"page": "group", "click": [".group-tabs .group-tab:nth-of-type(5)", ".photo-album-card"], "ready": ".photo-album-bar", "bands": [".group-tabs", ".photo-album-bar"], "content": ".photo-tile-slot"}), # Search's Files view opens on the list of groups, one folder per group — # the rows are inside it. ("search files", {"page": "search", "click": [".view-toggle button:nth-of-type(1)", ".file-row.dir-row"], "ready": ".file-toolbar", "bands": [".search-bar", ".file-toolbar", ".file-table th"], "content": ".file-row"}), ("search videos", {"page": "search", "click": [".view-toggle button:nth-of-type(2)"], "ready": ".video-toolbar", "bands": [".search-bar", ".video-toolbar"], "content": ".video-tile-slot"}), ("search music", {"page": "search", "click": [".view-toggle button:nth-of-type(3)"], "ready": ".video-toolbar", "bands": [".search-bar", ".video-toolbar"], "content": ".music-tile-slot"}), # Photos on the Search page has no toolbar of its own: its only control is # the filter, and the search field above it already is one. ("search photos", {"page": "search", "click": [".view-toggle button:nth-of-type(4)"], "ready": ".photo-album-grid", "bands": [".search-bar"], "content": ".photo-album-tile-slot"}), ] CASES = [(f"{name} @{w}", dict(spec, width=w, label=name)) for w in WIDTHS for name, spec in GROUP_CASES] # The shell around the page under test: the real navigation bar (which is what # every band pins beneath), the real sidebar, the real main column. Measuring a # page mounted on a bare body would put every band at the top of the window and # prove nothing about the offset. SHELL = """
""" # Substituted by name, not by `%`-formatting: this template is JavaScript, # and JavaScript has a modulo operator. `i % ARTISTS.length` in the fixture # below made the whole page fail to render with "not enough arguments for # format string", from inside a request handler, which reads as the probe # measuring nothing rather than as a typo. FRAME = r""" """ # Appended to every frame when the engine needs the load event held back. HOLD = "" HOLD_TAG = '' PAGE = r"""
""" def render_frame(index: int) -> str: """One case's page: the shell around it and its own configuration.""" cfg = dict(CASES[index][1], index=index) return (FRAME.replace("", SHELL) .replace("/*CFG*/", json.dumps(cfg)) + HOLD) class H(http.server.BaseHTTPRequestHandler): def log_message(self, *a): pass def do_POST(self): length = int(self.headers.get("Content-Length") or 0) if self.path == "/log": RECORDS.append(json.loads(self.rfile.read(length).decode())) FINISHED.set() else: self.rfile.read(length) self.send_response(204) self.end_headers() def _send(self, body: bytes, ctype: str) -> None: 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 do_GET(self): path = self.path.split("?")[0] if path == "/hold": FINISHED.wait(120) self._send(b"", "image/gif") elif path == "/": widths = [spec["width"] for _, spec in CASES] self._send(PAGE.replace("/*CASES*/", json.dumps(widths)).encode(), "text/html; charset=utf-8") elif path == "/case": index = int(self.path.split("n=")[1]) self._send(render_frame(index).encode(), "text/html; charset=utf-8") elif path == "/v1/groups/g1/nodes": self._send(b'{"nodes": [{"node_id": "n1"}]}', "application/json") elif path == "/v1/groups/g1/members": # Served at all because an empty table measures only its headers, # and a member's name is a string this project did not choose. One # of them has no spaces to break at, which is what makes a cell set # its column's minimum width — the fault already recorded against # the Files table, one table along. self._send( b'{"members": [' b'{"user_id": "u1", "username": "moi"},' b'{"user_id": "u2", "username":' b' "un_utilisateur_au_nom_deraisonnablement_long_2026"}]}', "application/json") else: asset = (STATIC / path.lstrip("/")).resolve() if not str(asset).startswith(str(STATIC)) or not asset.is_file(): self.send_response(404) self.end_headers() return self._send(asset.read_bytes(), "text/css" if asset.suffix == ".css" else "text/javascript" if asset.suffix == ".js" else "application/octet-stream") # Each engine gets the same page and reports through the same `/log` POST, so # nothing here depends on a debugging protocol only one of them speaks. ENGINES = { "chrome": lambda profile, size: [ "google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", f"--user-data-dir={profile}", f"--window-size={size}"], # Firefox has no headless mode that simply opens a page and waits, so it # is driven through `--screenshot`: the picture is thrown away, the point # is that the page runs and the load event is what ends the process. # # `--profile`, and the directory has to be one the browser can actually # read — see PROFILE_PARENT. This used to point `HOME` at a throwaway # directory instead, which isolated nothing at all: snapd sets its own HOME # inside the sandbox, the throwaway one came back empty every time, and # Firefox opened the developer's real profile. With their browser open that # profile is locked, so every run printed "Firefox is already running" and # measured nothing — twelve errors at setup that looked like a regression # and were not. "firefox": lambda profile, size: [ "firefox", "--headless", "--profile", profile, "--screenshot", str(Path(profile) / "shot.png"), "--window-size", size], } # Which engines need the load event held until the measurement is in. HOLDS_LOAD = {"firefox"} # Where an engine's throwaway profile has to live. Chrome takes /tmp and is # absent from this map. # # Firefox on this distribution is a snap, and two rules of that sandbox decide # this between them: it has a private /tmp, so a directory made there is simply # not the one it sees, and the `home` interface grants no *hidden* directory, so # `~/.cache` is refused as well. Both failures are silent in their own way — # "Could not find profile folder" for the first, and for an unreadable profile # the same misleading "Firefox is already running" it prints when nothing is # running at all. What is left is a plain directory under $HOME; the snap's own # data directory is preferred where it exists, so a run leaves nothing in the # developer's home even for the moment it takes. def _profile_parent() -> Path | None: snap_data = Path.home() / "snap" / "firefox" / "common" return snap_data if snap_data.is_dir() else Path.home() PROFILE_PARENT = {"firefox": _profile_parent} def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--engine", choices=sorted(ENGINES), default="chrome") args = ap.parse_args() launcher = ENGINES[args.engine] global HOLD HOLD = HOLD_TAG if args.engine in HOLDS_LOAD else "" with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), H) as srv: threading.Thread(target=srv.serve_forever, daemon=True).start() # ignore_cleanup_errors for the same reason group_tab_probe.py gives: # Chrome's children outlive terminate() by a moment and go on writing # into the profile, and a throwaway profile is not worth a failed run. parent = PROFILE_PARENT.get(args.engine) with tempfile.TemporaryDirectory( ignore_cleanup_errors=True, prefix="meshbay-probe-", dir=str(parent()) if parent else None) as profile: proc = subprocess.Popen( launcher(profile, "1200,900") + [f"http://127.0.0.1:{PORT}/"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) for _ in range(900): if RECORDS: break time.sleep(0.1) proc.terminate() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() proc.wait() if not RECORDS: print(json.dumps({"error": "no measurement"}), file=sys.stderr) return 1 by_case = {r["case"]: r for r in RECORDS[0]} print(json.dumps( [dict(name=CASES[i][0], engine=args.engine, width=CASES[i][1]["width"], view=CASES[i][1]["label"], page=CASES[i][1]["page"], bands=CASES[i][1]["bands"], content=CASES[i][1]["content"], **by_case[i]) for i in sorted(by_case)], indent=1)) return 0 if __name__ == "__main__": raise SystemExit(main())