# retoor import html import os import shutil import subprocess import sys import time import tomllib from datetime import datetime, timezone from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent SHOTS_DATA_DIR = REPO_ROOT / "data" / f".tmp-shots-{os.getpid()}" OUTPUT_DIR = Path(os.environ.get("SCREENSHOTS_DIR", str(REPO_ROOT.parent / "static" / "pizdetz"))) ENV = os.environ.get("SCREENSHOTS_ENV", "test") BASE_URL = os.environ.get("SCREENSHOTS_BASE_URL", "").rstrip("/") PORT = os.environ.get("SCREENSHOTS_PORT", "8094") LOCAL_BASE = f"http://127.0.0.1:{PORT}" ADMIN_USERNAME = os.environ.get("SCREENSHOTS_ADMIN_USERNAME", "retoor") ADMIN_PASSWORD = os.environ.get("SCREENSHOTS_ADMIN_PASSWORD", "") BOOT_ADMIN_PASSWORD = "shots-admin-pass-9d3c" FULL_DESKTOP = {"width": 1440, "height": 900} KNOWN_ENVS = ["test", "staging", "production"] PUBLIC_SHOTS = [ ("home", "/", "Home"), ("roll", "/roll", "Roll"), ("standard", "/standard", "Standard"), ("flyers", "/flyers", "Flyers"), ("memes", "/memes", "Memes"), ("people", "/people", "People"), ("person", "/people/retoor", "Person"), ("join", "/join", "Join"), ("terms", "/terms", "Terms"), ("privacy", "/privacy", "Privacy"), ("login", "/auth/login", "Log in"), ] DOCS_SHOTS = [ ("docs", "/docs", "Docs"), ("docs-welcome", "/docs/welcome", "Docs welcome"), ("docs-api", "/docs/api", "Docs API index"), ("docs-api-content", "/docs/api/content", "Docs API content"), ("docs-component-code", "/docs/component-code", "Docs component code"), ] ADMIN_SHOTS = [ ("admin", "/admin", "Admin"), ("admin-posts", "/admin/posts", "Admin posts"), ("admin-post-new", "/admin/posts/new", "Admin editor"), ("admin-joins", "/admin/joins", "Admin joins"), ("admin-users", "/admin/users", "Admin users"), ("admin-settings", "/admin/settings", "Admin settings"), ("admin-services", "/admin/services", "Admin services"), ("admin-services-backup", "/admin/services/backup", "Admin service backup"), ("admin-backups", "/admin/backups", "Admin backups"), ("admin-audit", "/admin/audit", "Admin audit"), ("admin-stats", "/admin/stats", "Admin stats"), ("admin-trash", "/admin/trash", "Admin trash"), ("notifications", "/notifications", "Notifications"), ("profile-api-key", "/profile/api-key", "Profile API key"), ("docs-api-admin", "/docs/api/admin", "Docs API admin"), ] RESPONSIVE_VIEWPORTS = [ ("1440x900", {"width": 1440, "height": 900}), ("768x1024", {"width": 768, "height": 1024}), ("390x844", {"width": 390, "height": 844}), ("360x740", {"width": 360, "height": 740}), ("320x568", {"width": 320, "height": 568}), ] RESPONSIVE_PAGES = [ ("home", "/", "Home"), ("roll", "/roll", "Roll"), ("flyers", "/flyers", "Flyers"), ("join", "/join", "Join"), ("docs", "/docs", "Docs"), ] def server_env(data_dir): return { "MOLODETZ_DATA_DIR": str(data_dir), "MOLODETZ_DATABASE_URL": f"sqlite:///{data_dir / 'molodetz.db'}", "MOLODETZ_ADMIN_USERNAME": ADMIN_USERNAME, "MOLODETZ_ADMIN_EMAIL": "retoor@molodetz.nl", "MOLODETZ_ADMIN_PASSWORD": BOOT_ADMIN_PASSWORD, "MOLODETZ_DISABLE_SERVICES": "1", "MOLODETZ_DISABLE_RATE_LIMIT": "1", "MOLODETZ_PORT": PORT, "MOLODETZ_INTERNAL_BASE_URL": LOCAL_BASE, "MOLODETZ_SITEMAP_TTL": "0", "MOLODETZ_LANDING_TTL": "0", "MOLODETZ_UNREAD_TTL": "0", "MOLODETZ_STATIC_VERSION": "shots", "SECRET_KEY": "shots-secret-key-not-for-production", } def wait_ready(process, base): import httpx deadline = time.monotonic() + 60.0 while time.monotonic() < deadline: if process.poll() is not None: raise RuntimeError("shots server exited during startup") try: if httpx.get(f"{base}/health", timeout=1.0).status_code == 200: return except httpx.HTTPError: pass time.sleep(0.2) raise RuntimeError("shots server did not become ready") def start_server(data_dir): log = open(data_dir / "server.log", "w") process = subprocess.Popen( [sys.executable, "-m", "uvicorn", "molodetz.main:app", "--host", "127.0.0.1", "--port", PORT], cwd=REPO_ROOT, env={**os.environ, **server_env(data_dir)}, stdout=log, stderr=subprocess.STDOUT, ) try: wait_ready(process, LOCAL_BASE) except Exception: process.terminate() log.close() raise return process, log def stop_server(process, log): process.terminate() try: process.wait(timeout=10) except subprocess.TimeoutExpired: process.kill() log.close() def goto_ready(page, url): from playwright.sync_api import TimeoutError as PlaywrightTimeoutError page.goto(url, wait_until="domcontentloaded") try: page.wait_for_selector("html[data-app=ready]") except PlaywrightTimeoutError: page.reload(wait_until="domcontentloaded") page.wait_for_selector("html[data-app=ready]") def login_admin(page, base, password): page.goto(base + "/auth/login", wait_until="domcontentloaded") page.fill("input[name=username]", ADMIN_USERNAME) page.fill("input[name=password]", password) page.click("button[type=submit]") page.wait_for_url("**/admin", wait_until="domcontentloaded") def seed_join(base): import httpx response = httpx.post( base + "/join", data={ "name": "Gallery Writer", "contact": "gallery@example.invalid", "repo_url": "https://example.com/gallery", "message": "I want to write along.", "website": "", }, timeout=15.0, ) if response.status_code not in (302, 303): raise RuntimeError(f"seed join failed: {response.status_code}") def first_post_slug(base): import httpx response = httpx.get(base + "/roll", headers={"Accept": "application/json"}, timeout=15.0) posts = response.json().get("posts", []) if not posts: raise RuntimeError("roll has no posts") return posts[0]["slug"] def capture(page, out_dir, base, counter, name, path, title, section, viewport, full_page): goto_ready(page, base + path) filename = f"{counter:02d}-{name}-{viewport}.png" page.screenshot(path=str(out_dir / filename), full_page=full_page) return {"section": section, "title": title, "path": path, "viewport": viewport, "file": filename} def check_overflow(page, path, viewport): width, inner = page.evaluate("[document.documentElement.scrollWidth, window.innerWidth]") if width > inner: return f"{path} at {viewport}: scroll {width} wider than viewport {inner}" return "" def capture_full(browser, out_dir, base, password, records, counter): anon = browser.new_context(viewport=FULL_DESKTOP) page = anon.new_page() page.set_default_timeout(60000) for name, path, title in PUBLIC_SHOTS: records.append(capture(page, out_dir, base, counter, name, path, title, "Public", "full", True)) counter += 1 slug = first_post_slug(base) records.append(capture(page, out_dir, base, counter, "post", f"/posts/{slug}", "Post", "Public", "full", True)) counter += 1 for name, path, title in DOCS_SHOTS: records.append(capture(page, out_dir, base, counter, name, path, title, "Docs", "full", True)) counter += 1 anon.close() admin = browser.new_context(viewport=FULL_DESKTOP) admin_page = admin.new_page() admin_page.set_default_timeout(60000) login_admin(admin_page, base, password) for name, path, title in ADMIN_SHOTS: records.append(capture(admin_page, out_dir, base, counter, name, path, title, "Administration", "full", True)) counter += 1 admin.close() return counter def capture_responsive(browser, out_dir, base, records, failures, counter): for viewport, size in RESPONSIVE_VIEWPORTS: context = browser.new_context(viewport=size) page = context.new_page() page.set_default_timeout(60000) for name, path, title in RESPONSIVE_PAGES: goto_ready(page, base + path) failure = check_overflow(page, path, viewport) if failure: failures.append(failure) filename = f"{counter:02d}-{name}-{viewport}.png" page.screenshot(path=str(out_dir / filename), full_page=False) records.append({"section": "Responsive", "title": title, "path": path, "viewport": viewport, "file": filename}) counter += 1 context.close() context = browser.new_context(viewport={"width": 390, "height": 844}) page = context.new_page() page.set_default_timeout(60000) goto_ready(page, base + "/") page.click("[data-nav-toggle]") filename = f"{counter:02d}-home-nav-open-390x844.png" page.screenshot(path=str(out_dir / filename), full_page=False) records.append({"section": "Responsive", "title": "Home navigation open", "path": "/", "viewport": "390x844", "file": filename}) context.close() return counter + 1 def app_version(): with (REPO_ROOT / "pyproject.toml").open("rb") as handle: return tomllib.load(handle)["project"]["version"] def render_index(records, failures, checked, env, version, stamped): sections = [] for section in ["Public", "Docs", "Administration", "Responsive"]: figures = [] for record in records: if record["section"] != section: continue figures.append( '
{t}' "
{t} {p} ({v})
".format( f=html.escape(record["file"]), t=html.escape(record["title"]), p=html.escape(record["path"]), v=html.escape(record["viewport"]), ) ) note = "" if section == "Responsive": passed = checked - len(failures) items = "".join(f"
  • {html.escape(failure)}
  • " for failure in failures) failures_html = f"" if items else "" note = f"

    {passed} of {checked} responsive checks passed, no horizontal overflow.

    {failures_html}" sections.append("

    {s}

    {n}
    {f}
    ".format(s=section, n=note, f="".join(figures))) return """ Molodetz in pictures ({e})

    Molodetz in pictures ({e})

    {n} screenshots of version {v}, taken {d}.

    Every thumbnail links to the full image.

    {s} """.format(n=len(records), e=html.escape(env), v=html.escape(version), d=html.escape(stamped), s="".join(sections)) def env_previews(out_dir): preferred = ["01-home-full.png", "18-admin-full.png"] found = [name for name in preferred if (out_dir / name).exists()] for shot in sorted(out_dir.glob("*-home-390x844.png")): if shot.name not in found: found.append(shot.name) if len(found) == 3: break for shot in sorted(out_dir.glob("*.png")): if shot.name not in found: found.append(shot.name) if len(found) == 3: break return found def render_hub(entries): cards = [] for env, count, previews in entries: thumbs = "".join( '{e}'.format(e=env, f=html.escape(name)) for name in previews ) cards.append( '

    {e}

    {n} screenshots.

    {t}
    '.format( e=html.escape(env), n=count, t=thumbs ) ) return """ Molodetz screenshot environments

    Molodetz screenshot environments

    One gallery per environment. Regenerate with make screenshots, make screenshots-staging, make screenshots-production.

    {c} """.format(c="".join(cards)) def rebuild_hub(): entries = [] for env in KNOWN_ENVS: out_dir = OUTPUT_DIR / env if not (out_dir / "index.html").exists(): continue count = len(list(out_dir.glob("*.png"))) entries.append((env, count, env_previews(out_dir))) OUTPUT_DIR.mkdir(parents=True, exist_ok=True) (OUTPUT_DIR / "index.html").write_text(render_hub(entries), encoding="utf-8") return [env for env, _, _ in entries] def clear_output(out_dir): out_dir.mkdir(parents=True, exist_ok=True) for stale in out_dir.glob("*.png"): stale.unlink() index = out_dir / "index.html" if index.exists(): index.unlink() def wait_live(base): import httpx try: response = httpx.get(f"{base}/health", timeout=15.0) except httpx.HTTPError as exc: raise RuntimeError(f"base url {base} is not reachable: {exc}") from exc if response.status_code != 200: raise RuntimeError(f"base url {base} health is {response.status_code}") def main(): from playwright.sync_api import sync_playwright if ENV not in KNOWN_ENVS: raise RuntimeError(f"unknown SCREENSHOTS_ENV {ENV!r}, want one of {', '.join(KNOWN_ENVS)}") boot = not BASE_URL if boot and ENV != "test": raise RuntimeError("booted runs always target the test gallery, set SCREENSHOTS_ENV=test") if not boot and ENV == "test": raise RuntimeError("base-url runs target staging or production, set SCREENSHOTS_ENV accordingly") if not boot and not ADMIN_PASSWORD: raise RuntimeError("base-url runs need SCREENSHOTS_ADMIN_PASSWORD for the admin shots") base = LOCAL_BASE if boot else BASE_URL password = BOOT_ADMIN_PASSWORD if boot else ADMIN_PASSWORD out_dir = OUTPUT_DIR / ENV process = None log = None records = [] failures = [] try: if boot: if REPO_ROOT / "data" not in SHOTS_DATA_DIR.parents: raise RuntimeError(f"refusing test data outside {REPO_ROOT / 'data'}") shutil.rmtree(SHOTS_DATA_DIR, ignore_errors=True) SHOTS_DATA_DIR.mkdir(parents=True, exist_ok=True) process, log = start_server(SHOTS_DATA_DIR) seed_join(base) else: wait_live(base) headless = os.environ.get("PLAYWRIGHT_HEADLESS", "1") == "1" slow_mo = int(os.environ.get("PLAYWRIGHT_SLOW_MO", "0") or 0) clear_output(out_dir) with sync_playwright() as playwright: browser = playwright.chromium.launch(headless=headless, slow_mo=slow_mo) counter = capture_full(browser, out_dir, base, password, records, 1) capture_responsive(browser, out_dir, base, records, failures, counter) browser.close() checked = len(RESPONSIVE_PAGES) * len(RESPONSIVE_VIEWPORTS) stamped = datetime.now(timezone.utc).date().isoformat() (out_dir / "index.html").write_text(render_index(records, failures, checked, ENV, app_version(), stamped), encoding="utf-8") present = rebuild_hub() finally: if process is not None: stop_server(process, log) shutil.rmtree(SHOTS_DATA_DIR, ignore_errors=True) print(f"wrote {len(records)} screenshots for {ENV} to {out_dir}, hub covers {', '.join(present)}") for failure in failures: print(f"OVERFLOW {failure}") return 1 if failures else 0 if __name__ == "__main__": sys.exit(main())