#!/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())