Files
cc-ci/runner/harness/screenshot.py
autonomic-bot 472a68b32c
All checks were successful
continuous-integration/drone/push Build is passing
feat(harness): P1 — single registry-backed meta loader (rcust)
One loader: runner/harness/meta.py::load(recipe) -> RecipeMeta (frozen dataclass,
attribute access), backed by the declarative KEYS registry (14 final keys + 3
P2-deprecated). The ONLY exec() of tests/<recipe>/recipe_meta.py. Validation per
the locked decision: unknown ALL-CAPS top-level name or type mismatch = MetaError
(hard error at load); underscore-prefixed names recipe-private; callables only on
hook-typed keys.

Migrated all six legacy loaders (spec §4 L1–L6):
- run_recipe_ci.py::_load_meta deleted; orchestrator loads once, passes meta down
- tests/conftest.py::_recipe_meta deleted; meta fixture returns full RecipeMeta (R3)
- lifecycle.py::_recipe_extra_env/_recipe_meta_flag deleted; deploy_app takes meta
- deps.py::declared_deps deleted; callers read meta.DEPS
- canonical.py::is_enrolled reads through meta.load()
- screenshot.py now actually receives SCREENSHOT through the orchestrator path (R2
  fix; proven by unit test through the real load path)

Mumble private constants underscore-prefixed (_WELCOME_TEXT_MARKER/_MAX_USERS) +
importers fixed. New tests/unit/test_meta.py (all-recipes-load-clean typo gate,
MetaError cases, spec §2 baseline defaults, underscore exemption, doc sync). Docs
§4 key table now GENERATED from the registry (scripts/gen-meta-docs.py); drift
fails CI.

Verified on cc-ci: cc-ci-run -m pytest tests/unit -q -> 175 passed; scripts/lint.sh -> PASS.
2026-06-10 16:46:58 +00:00

102 lines
5.1 KiB
Python

"""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):
"""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.
`recipe_meta` is the loaded RecipeMeta (rcust P1 — the single loader actually delivers
SCREENSHOT now; under the old L1 allowlist the key never arrived, spec §8 R2). A plain dict
is still accepted for direct/manual callers."""
if recipe_meta is None:
return None
if isinstance(recipe_meta, dict):
hook = recipe_meta.get("SCREENSHOT")
else:
hook = getattr(recipe_meta, "SCREENSHOT", None)
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