diff --git a/runner/harness/screenshot.py b/runner/harness/screenshot.py new file mode 100644 index 0000000..de69766 --- /dev/null +++ b/runner/harness/screenshot.py @@ -0,0 +1,94 @@ +"""Phase 3 — app screenshot capture (plan-phase3-results-ux.md §4.2, R4/U1). + +Captures a real screenshot of the deployed app while it is up (before teardown), reusing the Phase-1 +Playwright browser already in the harness — no new heavy dep. The PNG is embedded in the summary +card (R3) and the dashboard (R5). + +Secret-safety (R7, the cardinal screenshot guardrail): the screenshot step must NEVER capture a page +that displays generated credentials (an install wizard showing the initial admin password, a secrets +page, etc.). The DEFAULT capture is the app's **landing page** (a login form shows fields, not the +password) — safe for every recipe. A recipe that needs a post-login view opts in via a recipe-meta +`SCREENSHOT` hook: a callable `screenshot(page, domain, meta) -> None` that drives Playwright to a +safe, credential-free view and is responsible for not landing on a secrets page. The harness never +auto-fills a wizard. + +Robustness (R7, cosmetics never block): every entry point is best-effort — any failure (Playwright +missing, app slow, navigation error) is swallowed and returns None so the run/verdict is unaffected. +""" + +from __future__ import annotations + +import os + +from . import browser as harness_browser + +# Default viewport for the captured screenshot — a desktop-ish frame that crops well into the card. +VIEWPORT = {"width": 1280, "height": 800} +# Hard cap so a wedged app can never hang the run on the screenshot step (R7 / Phase-1 timeouts). +NAV_DEADLINE_S = 45 + + +def screenshot_path(run_artifact_dir: str) -> str: + """Canonical on-disk path for a run's app screenshot (pure).""" + return os.path.join(run_artifact_dir, "screenshot.png") + + +def _load_screenshot_hook(recipe_meta: dict | None): + """Return the recipe's optional SCREENSHOT hook (a callable) if it declared one, else None. + The hook drives Playwright to a safe post-login view; default is the landing page.""" + if not recipe_meta: + return None + hook = recipe_meta.get("SCREENSHOT") + return hook if callable(hook) else None + + +def capture(domain: str, out_path: str, *, recipe_meta: dict | None = None) -> str | None: + """Capture a screenshot of the live app at https:/// into out_path. + + Default: navigate to the landing page and screenshot it (credential-free, safe for any recipe). + If the recipe declared a SCREENSHOT hook in recipe_meta, run it instead (post-login / app-specific + view, recipe-responsible for avoiding secret pages). Returns out_path on success, else None + (best-effort — never raises into the run; cosmetics never block, R7).""" + try: + from playwright.sync_api import sync_playwright + except ImportError: # pragma: no cover — playwright is always present in cc-ci-run + print(" screenshot: playwright unavailable — skipping (verdict unaffected)", flush=True) + return None + + os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) + url = f"https://{domain}/" + hook = _load_screenshot_hook(recipe_meta) + try: + with sync_playwright() as p: + browser = p.chromium.launch(args=["--no-sandbox"]) + try: + context = browser.new_context(ignore_https_errors=True, viewport=VIEWPORT) + page = context.new_page() + if hook is not None: + # Recipe-specific safe view (post-login etc.). The hook owns navigation + + # the no-secret-page guarantee; it should call page.screenshot itself, but if + # it doesn't, we still snap the resulting page below. + hook(page, domain, recipe_meta) + if not os.path.exists(out_path): + page.screenshot(path=out_path, full_page=False) + else: + # Default: landing page. Accept any rendered status (200 or an auth redirect to a + # login form) — both are credential-free and representative of "the app is up". + harness_browser.goto_with_retry( + page, + url, + accept_statuses=(200, 301, 302, 303, 401, 403), + deadline_seconds=NAV_DEADLINE_S, + wait_until="domcontentloaded", + ) + page.screenshot(path=out_path, full_page=False) + finally: + browser.close() + if os.path.exists(out_path) and os.path.getsize(out_path) > 0: + print(f" screenshot: captured {out_path}", flush=True) + return out_path + print(" screenshot: produced no file — skipping (verdict unaffected)", flush=True) + return None + except Exception as e: # noqa: BLE001 — screenshot is cosmetic; never fail/hang a run (R7) + print(f" screenshot: capture failed (non-fatal, verdict unaffected): {e}", flush=True) + return None diff --git a/tests/unit/test_screenshot.py b/tests/unit/test_screenshot.py new file mode 100644 index 0000000..f033946 --- /dev/null +++ b/tests/unit/test_screenshot.py @@ -0,0 +1,31 @@ +"""Unit tests for the pure helpers in harness.screenshot (Phase 3 U1). + +The Playwright capture itself needs a live app (exercised in the U1 live demo); here we cover the +pure bits: the artifact path and the SCREENSHOT-hook resolution. Run cold: + cc-ci-run -m pytest tests/unit/test_screenshot.py -q +""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) +from harness import screenshot as S # noqa: E402 + + +def test_screenshot_path(): + assert S.screenshot_path("/var/lib/cc-ci-runs/42") == "/var/lib/cc-ci-runs/42/screenshot.png" + + +def test_hook_none_when_absent(): + assert S._load_screenshot_hook(None) is None + assert S._load_screenshot_hook({}) is None + assert S._load_screenshot_hook({"SCREENSHOT": "not-callable"}) is None + + +def test_hook_returned_when_callable(): + def hook(page, domain, meta): + pass + + assert S._load_screenshot_hook({"SCREENSHOT": hook}) is hook