"""Render LLeMbas pages in a real browser, at a real size. Run it: python scripts/shoot.py OUTDIR [/chat,/settings] # measure + capture python scripts/shoot.py OUTDIR --manifest-screenshots # the two the # manifest wants Needs a `chromium` on PATH and the development dependencies installed. It is a development instrument, like the Node DOM stub the JavaScript is driven under and like `fetch_vendor.py` -- it is not imported by the application and nothing in `src/` knows it exists. Not a test runner: an instrument. It renders a page through TestClient, rewrites every asset URL to a file:// path, and refuses to continue if even one is left pointing at `testserver` -- because the last harness that did this silently measured an unstyled document and reported all five tab panels visible at once. A dramatic finding that was entirely an artefact of a rewrite matching nothing. """ from __future__ import annotations import json import re import shutil import subprocess import sys import tempfile from pathlib import Path REPO = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO / "src")) # Resolved from the package that actually got imported, not from where this # file happens to sit. A copy of this script run from somewhere else silently # pointed STATIC at a directory that did not exist, every asset URL was # rewritten to a file:// path with nothing behind it, and the run measured an # unstyled document -- reporting that every page in the application overflowed # by thirty thousand pixels. The guard below only asked whether the URLs had # been rewritten, which they had. import lembas # noqa: E402 SRC = Path(lembas.__file__).resolve().parent.parent STATIC = Path(lembas.__file__).resolve().parent / "web/static" CHROMIUM = shutil.which("chromium") or shutil.which("chromium-browser") # Routes that are served by the app rather than mounted, so the rewrite has to # fetch them rather than point at a file that does not exist. ROUTE_ASSETS = {"/branding.css": "branding.css", "/sw.js": "sw.js"} MEASURE = """ """ def build_client(): import lembas.config as config_mod tmp = Path(tempfile.mkdtemp(prefix="lembas-shoot-")) config_mod.settings.data_dir = tmp config_mod.settings.secret_key = "x" * 43 from fastapi.testclient import TestClient from lembas.db.session import init_db, session_scope from lembas.main import create_app init_db() app = create_app() client = TestClient(app) client.post( "/auth/register", data={"name": "Frodo", "email": "f@example.com", "password": "mellonmellon"}, follow_redirects=False, ) from lembas.db.models import Connection, Model with session_scope() as db: connection = Connection( name="local", base_url="http://127.0.0.1:1", api_key_encrypted="" ) db.add(connection) db.flush() for name in ("gemma4-moe", "qwen3-coder"): db.add(Model(connection_id=connection.id, model_id=name, display_name=name)) return client def rewrite(html: str, client, assets: Path) -> str: """Point every asset at a file on disk, and prove none was missed.""" for route, name in ROUTE_ASSETS.items(): response = client.get(route) if response.status_code == 200: (assets / name).write_text(response.text) html = re.sub( r'(?:http://testserver)?/static/([^"\'?\s>]+)(\?[^"\'\s>]*)?', lambda m: f"file://{STATIC}/{m.group(1)}", html, ) html = re.sub( r'(?:http://testserver)?/branding\.css(\?[^"\'\s>]*)?', f"file://{assets}/branding.css", html, ) # Fail loudly, and only about things that decide how the page LOOKS: every # `src`, and `href` on a . An `href` on an anchor is a destination, # not an asset -- flagging those makes the guard cry wolf on every page and # a guard nobody believes is worse than none. leftovers = re.findall(r']*\bhref="([^"]+)"', html) leftovers += re.findall(r'\bsrc="([^"]+)"', html) blocking = [ url for url in leftovers if url.startswith(("/", "http://testserver")) and not url.startswith(("/branding/", "/manifest", "/sw.js")) ] if blocking: raise SystemExit( "UNREWRITTEN ASSET URLS -- this would measure an unstyled document: " f"{sorted(set(blocking))[:8]}" ) # And that what they were rewritten *to* is really there. A rewrite that # matches and produces a dead path is indistinguishable, from inside the # browser, from no stylesheet at all -- and it is the failure that actually # happened, twice. missing = [ url for url in re.findall(r'(?:href|src)="file://([^"?]+)"', html) if not Path(url).exists() ] if missing: raise SystemExit(f"REWRITTEN TO NOTHING -- still an unstyled document: {missing[:5]}") # The one-time notifications offer is a modal over the very page we came # to measure, and it is gated on a localStorage key. Set it in the head, so # it runs before the deferred script that reads it. quiet = ( "" ) return html.replace("", quiet + MEASURE + "", 1) def shoot(client, path: str, width: int, height: int, theme: str, outdir: Path) -> dict: """One page, at one size, in one theme. The page is rendered inside an ' "
" "" ) shot = outdir / f"{slug}.png" common = [ CHROMIUM, "--headless", "--no-sandbox", "--disable-gpu", "--allow-file-access-from-files", "--hide-scrollbars", "--force-device-scale-factor=1", f"--window-size={max(width, 520)},{height + 40}", "--virtual-time-budget=4000", ] subprocess.run(common + [f"--screenshot={shot}", f"file://{frame}"], capture_output=True, timeout=120) dom = subprocess.run(common + ["--dump-dom", f"file://{frame}"], capture_output=True, text=True, timeout=120).stdout match = re.search(r'id="__measurements">(.*?)', dom, re.S) if not match or not match.group(1).strip(): raise SystemExit(f"no measurements for {slug} -- the frame did not report") data = json.loads(match.group(1)) if "error" in data: raise SystemExit(f"{slug}: {data['error']}") data["page"] = slug if data["innerW"] != width: raise SystemExit( f"{slug}: measured a {data['innerW']}px viewport, asked for {width}px" ) return data # The two the manifest asks for. Without them Chrome on Android falls back to # the one-line mini-infobar instead of the install dialog with a name, an icon # and a picture in it -- which is the difference between an install somebody # chooses and one they dismiss without reading. MANIFEST_SHOTS = ( ("screenshot-narrow.png", 390, 844, "narrow"), ("screenshot-wide.png", 1280, 800, "wide"), ) def manifest_screenshots(client, outdir: Path) -> None: """Capture the two, straight into static/img/ where the manifest names them. A browser capture rather than something `build_artwork.py` draws: the point of a screenshot is that it is what the application actually looks like, and an illustration of what it looks like is the one thing it must not be. """ try: from PIL import Image except ImportError: # pragma: no cover - design-time tool raise SystemExit("pillow is needed to crop the frame off a screenshot") from None for name, width, height, _form in MANIFEST_SHOTS: shoot(client, "/chat", width, height, "moria", outdir) slug = f"chat-moria-{width}x{height}.png" target = STATIC / "img" / name # Cropped to the iframe, which sits at the origin of a zero-margin # wrapper. The capture is of the *outer* document, so without this the # screenshot carries the harness's own readout along its bottom edge # and a strip of grey beside it -- and a manifest screenshot is the one # picture of this application most people will ever see. with Image.open(outdir / slug) as shot: shot.crop((0, 0, width, height)).save(target) print(f"wrote {target.relative_to(REPO)}") def main() -> None: if not CHROMIUM: raise SystemExit("no chromium") if "--manifest-screenshots" in sys.argv: outdir = Path(sys.argv[1]) if len(sys.argv) > 2 else Path(tempfile.mkdtemp()) outdir.mkdir(parents=True, exist_ok=True) manifest_screenshots(build_client(), outdir) return outdir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/tmp/lembas-shoot/out") outdir.mkdir(parents=True, exist_ok=True) paths = sys.argv[2].split(",") if len(sys.argv) > 2 else ["/chat", "/settings"] sizes = [(390, 844), (360, 640), (1280, 800)] themes = ["moria", "shire"] client = build_client() results = [] for path in paths: for width, height in sizes: for theme in themes: results.append(shoot(client, path, width, height, theme, outdir)) (outdir / "results.json").write_text(json.dumps(results, indent=2)) for r in results: flags = [] if r["documentScrolls"]: flags.append(f"DOC-SCROLLS({r['docScrollH']}>{r['innerH']})") if r["scrollsSideways"]: flags.append(f"SIDEWAYS({r['docScrollW']}>{r['innerW']})") if r["overflowCount"]: flags.append(f"overflow:{r['overflowCount']}") if r["smallCount"]: flags.append(f"small-targets:{r['smallCount']}") print(f"{r['page']:44} {' '.join(flags) or 'clean'}") if __name__ == "__main__": main()