aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-10 10:29:12 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-10 10:29:12 +0200
commitc2bc79f71c553f3c8e76c592715266c8469e583e (patch)
tree27e0a57231ed8b12518894526d0baee7621d261b /packages/meshbay-hub/tests/harness
parent0b9b85bf2d434177968dfc6e27555d54d703ff7e (diff)
downloadmeshbay-c2bc79f71c553f3c8e76c592715266c8469e583e.tar.gz
feat(hub): pin the app controls while a library scrolls
Files, Videos, Music and Photos are read by scrolling, and everything that steers that reading left with the first screenful. Three bands now pin under the navigation bar, in a group and on the Search page alike: the tab bar (the search field, on Search), the application's own toolbar, and the file table's column heads. The group's name and description still scroll — they say nothing a reader needs while walking a directory, and the height they would cost is height the list does not get. A band's offset is the heights of the bands above it, and those are not constants: the toolbar wraps to three rows on a phone, grows a field while a folder is being named, and loses its filter on Search. So each band measures itself and publishes `--chrome-h` / `--toolbar-h` (static/sticky.js) and the stylesheet does the arithmetic in calc(), rather than a number written down twice — the fault CLAUDE.md already records against this layout twice over. A band publishes height *plus its own bottom margin*, and paints that margin as a ring of page colour, so the pinned layout is pixel-identical to the flow layout and nothing shifts at the moment a band pins. Three overflow faults came out of it, all of the same class and all of them what "the header does not stay" actually meant on Android — a document wider than the screen leaves everything pinned attached to a viewport the reader can no longer see, the navigation bar included: - a directory's name cell was a bare <td>, so an unbreakable folder name (`Rage_Against_The_Machine_Discography_1992-2000_FLAC`) set the column's minimum: a 527px table in a 390px window - Search's group column did the same at 442px with an underscored group name. It also goes entirely below 768px, where there is no room for it and the breadcrumb already names the group - the shared-directories table has four columns of controls with a combined minimum near 440px, none of it compressible. On a phone the row stops being a row: the name and its eject/remove pair on one line, the two switches — each carrying the column head's own string as a label — on the next - and, found by measuring at 360px, the tab bar itself was 19px too wide `.file-table` moves to separated borders: a collapsed border belongs to the table rather than to the cell, so the column heads lost their rule the moment they pinned. Measured, not read. tests/harness/sticky_header_probe.py drives the shipped GroupPage and SearchPage against a stub node, walks to each application, scrolls to the end and reports every rectangle — 11 views x 4 widths x 2 engines. Its fixture says what real data says: the first version used `note-007.txt` and `un groupe`, which fit any screen, and found none of the above. A fixture narrower than real data tests the fixture. Also: `test_desktop_shell` no longer looks for the CSP after the first `-->`, which made it fail on correct markup as soon as a comment was added above it, and `search-page.js` joins test_hook_ordering's file list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tx16FhyD2BUdpooGb5jcyN
Diffstat (limited to 'packages/meshbay-hub/tests/harness')
-rwxr-xr-xpackages/meshbay-hub/tests/harness/sticky_header_probe.py563
1 files changed, 563 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/sticky_header_probe.py b/packages/meshbay-hub/tests/harness/sticky_header_probe.py
new file mode 100755
index 0000000..9e9dc3c
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/sticky_header_probe.py
@@ -0,0 +1,563 @@
+#!/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 os
+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 = """
+<nav class="nav">
+ <div class="nav-left"><button class="nav-hamburger">&#9776;</button>
+ <a class="nav-brand" href="#/">MeshBay</a></div>
+ <div class="nav-right"><a class="nav-notif" href="#/">&#128276;</a>
+ <div class="user-menu"><button class="nav-btn">someone</button></div></div>
+</nav>
+<div class="layout">
+ <aside class="sidebar"><div class="sidebar-section">Groups</div></aside>
+ <main class="main"><div id="root"></div></main>
+</div>
+"""
+
+# 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"""<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head><body>
+<!--SHELL-->
+<script>
+const CFG = /*CFG*/;
+
+// An index big enough that every view scrolls: loose files for Files, a films
+// root, an album tree for Music, and forty photo albums.
+//
+// The names are the length real ones are, and that is not decoration. The
+// first version of this fixture used `note-007.txt` and one group called
+// `un groupe`, which fits any screen — so it measured a table that never
+// overflowed, and missed the fault that was actually reported from a phone.
+// A fixture narrower than the real thing tests the fixture.
+// Underscores on purpose. A name with no space in it is one unbreakable word,
+// and a table column is sized from what its content cannot be narrower than —
+// which is the whole point of the case reported from a phone.
+const ARTISTS = ['Augustus Pablo', 'Babylon Circus', 'Ben Harper',
+ 'Beruriers Noirs', 'Billy Ze Kick', 'Black Sabbath',
+ 'Chemical Brothers', 'Dire Straits', 'Dj_Shadow',
+ 'Massive Attack', 'Noir Desir', 'Rage Against The Machine',
+ 'Rage_Against_The_Machine_Discography_1992-2000_FLAC'];
+const ENTRIES = [];
+let n = 0;
+const add = (o) => ENTRIES.push(Object.assign(
+ { id: 'e' + (++n), size: 1024 * 1024 * n, added_at: 1750000000 + n }, o));
+for (let i = 0; i < 60; i++)
+ add({ name: `${ARTISTS[i % ARTISTS.length]} - ${String(i + 1).padStart(2, '0')} `
+ + `Un titre de morceau plutot long.flac`, path: '', type: 'document' });
+for (let i = 0; i < 40; i++)
+ add({ name: `${ARTISTS[i % ARTISTS.length]} au Zenith (2019) 1080p.mkv`,
+ path: 'films', type: 'video',
+ display_title: `${ARTISTS[i % ARTISTS.length]} au Zenith` });
+for (let a = 0; a < 20; a++)
+ for (let t = 0; t < 2; t++)
+ add({ name: `${t + 1} track.flac`,
+ path: `musique/${ARTISTS[a % ARTISTS.length]} ${a}/disque ${t}`,
+ type: 'audio', artist: `Artiste ${a}`, album: `Disque ${a}-${t}`,
+ track_no: t + 1, duration: 200 });
+for (let d = 0; d < 40; d++)
+ // The first album is deep enough to scroll on its own: an open album is a
+ // case of its own, and one that fits the window proves nothing.
+ for (let i = 0; i < (d === 0 ? 80 : 6); i++)
+ add({ name: `img-${String(i).padStart(3, '0')}.jpg`,
+ path: `photos/sortie ${String(d).padStart(2, '0')}`, type: 'image' });
+
+const ACK = {
+ is_node_admin: true, member_upload: true,
+ enabled_apps: ['chat', 'files', 'video', 'music', 'photo'],
+ tmdb_enabled: false, musicbrainz_enabled: false,
+ video_directories: ['films'], music_directories: ['musique'],
+ photo_directories: ['photos'],
+};
+
+// The node, as far as the page is concerned. Anything reached for beyond the
+// handful of calls below answers with a promise that never settles: a thumb
+// or a metadata lookup left pending shows a spinner, and a spinner occupies
+// exactly the tile the grid already reserved — which is all the bands need.
+window.MeshBayTransport = function () {
+ const self = {
+ // The operator of this node: it is their own library, and it is what puts
+ // the eject/remove pair in the settings table and the fifth button in the
+ // Files toolbar.
+ connected: false, memberRole: 'operator', supportsAppOps: true,
+ // Every property the page *reads* rather than calls has to be declared:
+ // the fallback below answers with a function, and a function is truthy, so
+ // an undeclared `newNodeBundle` sends the page off to store a key bundle
+ // that never comes back — it waits there for ever, on "Connecting".
+ sessionKeys: null, gekRaw: null,
+ newNodeBundle: null, newNodeBundleRecovery: null,
+ async connect() { self.connected = true; return ACK; },
+ async fetchIndex() {
+ return { entries: ENTRIES, dirs: ['films', 'musique', 'photos'],
+ roots: [{ name: 'films', available: true, writable: false,
+ removable: true },
+ { name: 'musique', available: true, writable: true,
+ removable: true },
+ { name: 'photos', available: true, writable: false,
+ removable: false }] };
+ },
+ async fetchChatHistory() { return { messages: [], hasMore: false }; },
+ async fetchLinkPreview() { return { ok: false }; },
+ close() {},
+ };
+ return new Proxy(self, {
+ get(target, prop) {
+ if (prop in target) return target[prop];
+ // A handler slot the page reads before assigning must stay empty, or the
+ // page calls this stub as though the node had spoken.
+ if (typeof prop === 'string' && prop.startsWith('on')) return undefined;
+ if (typeof prop === 'symbol') return undefined;
+ return () => new Promise(() => {});
+ },
+ set(target, prop, value) { target[prop] = value; return true; },
+ });
+};
+</script>
+<script type="module">
+import { html, render } from '/vendor/htm-preact.js';
+import { initLocale } from '/i18n.js';
+import { GroupPage } from '/group-page.js';
+import { SearchPage } from '/search-page.js';
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// A page that never reaches "connected" says why in the console and nowhere
+// else; without this a failure reads only as "the toolbar never appeared".
+const LOGS = [];
+addEventListener('error', (e) => LOGS.push('error: ' + (e.message || e)));
+addEventListener('unhandledrejection',
+ (e) => LOGS.push('rejection: ' + (e.reason && (e.reason.stack || e.reason.message)
+ || e.reason)));
+for (const level of ['error', 'warn']) {
+ const orig = console[level].bind(console);
+ console[level] = (...a) => { LOGS.push(level + ': ' + a.map(String).join(' ')); orig(...a); };
+}
+
+async function waitFor(sel, ms = 15000) {
+ const until = Date.now() + ms;
+ for (;;) {
+ const el = document.querySelector(sel);
+ if (el) return el;
+ if (Date.now() > until) return null;
+ await sleep(50);
+ }
+}
+
+const box = (sel) => {
+ const el = document.querySelector(sel);
+ if (!el) return null;
+ const r = el.getBoundingClientRect();
+ return { top: Math.round(r.top), bottom: Math.round(r.bottom),
+ height: Math.round(r.height),
+ // The gap the band keeps below it, in the flow and once pinned.
+ gap: Math.round(parseFloat(getComputedStyle(el).marginBottom) || 0) };
+};
+
+const snapshot = () => {
+ const out = {};
+ for (const sel of ['.nav', ...CFG.bands, CFG.content]) out[sel] = box(sel);
+ return out;
+};
+
+// How far past the window the document reaches sideways. On Android a page
+// wider than the screen takes everything pinned out of the visible area with
+// it — the header is still stuck to the top of a viewport the reader can no
+// longer see — so this is measured on every case, not only the ones with a
+// table.
+const overflowX = () => Math.round(
+ document.documentElement.scrollWidth - document.documentElement.clientWidth);
+
+// Two frames: one for the scroll to be applied, one for the sticky bands to be
+// laid out against it.
+const settle = () => new Promise((r) =>
+ requestAnimationFrame(() => requestAnimationFrame(r)));
+
+(async () => {
+ const fail = (why) => parent.postMessage(
+ { case: CFG.index, error: why, logs: LOGS.slice(0, 12),
+ text: (document.getElementById('root').textContent || '').slice(0, 300) },
+ '*');
+ try {
+ await initLocale();
+ if (CFG.page === 'group') {
+ render(html`<${GroupPage} groupId="g1" token="t" username="me" userId="u1"
+ group=${{ id: 'g1', name: 'un groupe', owner_username: 'me',
+ is_admin: false }}
+ userPrefs=${{ default_tab: 'files' }} />`,
+ document.getElementById('root'));
+ } else {
+ render(html`<${SearchPage} token="t" username="me" userId="u1"
+ ${/* Underscores, like the folder names: the group's name is a whole
+ column of its own in Search, in a cell with no wrapping rule,
+ and a hyphen is a break opportunity while an underscore is
+ not. */''}
+ groups=${[{ id: 'g1', name: 'musique_de_la_maison_2024',
+ owner_username: 'cbesson' }]}
+ userPrefs=${{}} />`, document.getElementById('root'));
+ // Results only exist once an index has arrived; the view toggle is drawn
+ // with them.
+ const field = await waitFor('.search-bar input');
+ if (!field) return fail('no search field');
+ if (!await waitFor('.view-toggle')) return fail('no results to view');
+ }
+
+ // Whether the search field *asks* for focus, not whether it holds it:
+ // every case is an iframe of one page and only one document in a page can
+ // have an active element, so real focus is a property of the harness here
+ // rather than of the page under test.
+ const searchInput = document.querySelector('.search-bar input');
+ const autofocusAsked = !!(searchInput && searchInput.autofocus);
+
+ for (const sel of CFG.click) {
+ const el = await waitFor(sel);
+ if (!el) return fail('nothing at ' + sel);
+ el.click();
+ await sleep(120);
+ }
+ if (!await waitFor(CFG.ready)) return fail('never drew ' + CFG.ready);
+ if (!await waitFor(CFG.content)) return fail('never drew ' + CFG.content);
+ await settle();
+
+ const before = snapshot();
+ const room = document.documentElement.scrollHeight - innerHeight;
+ scrollTo(0, Math.min(500, Math.max(0, room)));
+ await settle();
+ await sleep(150);
+ await settle();
+ const after = snapshot();
+ const scrolledBy = Math.round(scrollY);
+
+ // All the way down. A sticky element only sticks inside its own parent, so
+ // a band whose containing block ends before the page does comes unstuck
+ // partway — which a single 500px scroll never reaches.
+ scrollTo(0, Math.max(0, room));
+ await settle();
+ await sleep(150);
+ await settle();
+ const bottom = snapshot();
+
+ parent.postMessage({
+ case: CFG.index, before, after, bottom,
+ bottomScrollY: Math.round(scrollY),
+ scrollY: scrolledBy, room: Math.round(room),
+ overflowX: overflowX(),
+ // Whether the Search page's group column is on screen. It is dropped at
+ // phone widths, where the file name needs every pixel it can get.
+ groupColumn: (() => {
+ const el = document.querySelector('.file-table th.td-group');
+ if (!el) return 'absent';
+ return getComputedStyle(el).display === 'none' ? 'hidden' : 'shown';
+ })(),
+ // What is actually too wide, when something is.
+ widest: (() => {
+ const limit = document.documentElement.clientWidth;
+ return [...document.querySelectorAll('.main *')]
+ .filter((el) => el.getBoundingClientRect().right > limit + 1)
+ .slice(0, 6)
+ .map((el) => (el.tagName.toLowerCase() + '.' + (el.className || ''))
+ .slice(0, 60) + ' →'
+ + Math.round(el.getBoundingClientRect().right));
+ })(),
+ innerHeight, innerWidth,
+ // What the stylesheet was actually handed, so a wrong offset can be told
+ // from an unmeasured one.
+ // The Search field focuses itself on a pointer device and must go on
+ // doing so — it stopped only for touch, where the keyboard it raises
+ // takes the pinned header out of the visible viewport.
+ autofocusAsked,
+ chromeH: getComputedStyle(document.querySelector('.sticky-chrome'))
+ .getPropertyValue('--chrome-h').trim(),
+ toolbarH: getComputedStyle(document.querySelector('.sticky-chrome'))
+ .getPropertyValue('--toolbar-h').trim(),
+ }, '*');
+ } catch (err) {
+ fail(String(err && err.stack || err));
+ }
+})();
+</script></body></html>"""
+
+# Appended to every frame when the engine needs the load event held back.
+HOLD = ""
+HOLD_TAG = '<img src="/hold" style="position:fixed;left:-4px;top:-4px;width:1px">'
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head>
+<body style="margin:0"><div id="frames"></div><script>
+const CASES = /*CASES*/;
+const seen = [];
+addEventListener('message', (e) => {
+ seen.push(e.data);
+ if (seen.length === CASES.length) {
+ fetch('/log', { method: 'POST', body: JSON.stringify(seen) });
+ }
+});
+for (let i = 0; i < CASES.length; i++) {
+ const f = document.createElement('iframe');
+ f.src = '/case?n=' + i;
+ f.style.cssText =
+ `width:${CASES[i]}px;height:760px;border:0;display:block;margin-bottom:4px`;
+ document.getElementById('frames').appendChild(f);
+}
+</script></body></html>"""
+
+
+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-->", 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")
+ 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.
+ #
+ # And it is isolated with `HOME`, not `--profile`: given `--profile` on a
+ # directory it has not initialised itself, this Firefox starts, prints its
+ # headless banner, and then never requests the URL at all — no error, no
+ # page, nothing to debug. Pointing HOME at a throwaway directory lets it
+ # create its own default profile there, which works and leaves the
+ # developer's real one alone.
+ "firefox": lambda profile, size: [
+ "firefox", "--headless",
+ "--screenshot", str(Path(profile) / "shot.png"),
+ "--window-size", size],
+}
+# The environment each engine is launched with, on top of the current one.
+ENGINE_ENV = {"firefox": lambda profile: {"HOME": profile}}
+# Which engines need the load event held until the measurement is in.
+HOLDS_LOAD = {"firefox"}
+
+
+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.
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile:
+ env = dict(os.environ,
+ **ENGINE_ENV.get(args.engine, lambda _p: {})(profile))
+ proc = subprocess.Popen(
+ launcher(profile, "1200,900") + [f"http://127.0.0.1:{PORT}/"],
+ env=env, 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())