feat(3 U1-scaffold): app screenshot capture module (offline; not yet wired)
harness/screenshot.py: best-effort Playwright capture of the live app (reuses harness browser). Default = landing page (credential-free, secret-safe R7); recipes needing post-login opt into a recipe-meta SCREENSHOT hook responsible for avoiding secret pages. Every failure swallowed -> None (cosmetics never block, R7). Pure helpers unit-tested. Orchestrator wiring + live demo come after U0 PASSes (avoid deploy contention with the Adversary's cold U0 re-runs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
94
runner/harness/screenshot.py
Normal file
94
runner/harness/screenshot.py
Normal file
@ -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://<domain>/ 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
|
||||
31
tests/unit/test_screenshot.py
Normal file
31
tests/unit/test_screenshot.py
Normal file
@ -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
|
||||
Reference in New Issue
Block a user