All checks were successful
continuous-integration/drone/push Build is passing
harness.meta.HookCtx (frozen): .domain, .base_url, .meta (RecipeMeta), .deps (provisioned dep creds from $CCCI_DEPS_FILE or None), .op (current lifecycle op or None); built via meta.hook_ctx() at each hook call site. All recipe callables now take ctx: EXTRA_ENV(ctx), UPGRADE_EXTRA_ENV(ctx), READY_PROBE(ctx), BACKUP_VERIFY(ctx), SCREENSHOT(page, ctx), ops.py pre_<op>(ctx). Dict-valued EXTRA_ENV/UPGRADE_EXTRA_ENV unchanged (only the callable signature moved). Call sites converted: deploy_app env shaping, perform_upgrade, wait_ready_probes (gains op=), _perform_op BACKUP_VERIFY, screenshot.capture, _run_pre_hook. Legacy signatures fail FAST with a clear migration message: the registry carries hook_params per hook key, enforced at meta.load() (MetaError names the old vs new signature); ops.py pre-op hooks get the same check at the orchestrator call site (meta.check_hook_signature) — no silent TypeError mid-run. Migrated every in-repo user mechanically (17 ops.py files; cryptpad/lasuite-*/ mailu EXTRA_ENV; mumble+lasuite-drive READY_PROBE; ghost/discourse BACKUP_VERIFY) — seeded values, probes and assertions byte-identical (domain -> ctx.domain; keycloak pre_restore's meta arg -> ctx.meta). Unit tests: hook_ctx field contract, ctx.deps from the run deps file, legacy- signature MetaError (READY_PROBE/EXTRA_ENV/SCREENSHOT + pre-op checker), ctx signatures accepted. Docs table regenerated (signature docs in key docs). Verified on cc-ci: cc-ci-run -m pytest tests/unit -q -> 180 passed; scripts/lint.sh -> PASS.
104 lines
5.3 KiB
Python
104 lines
5.3 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, ctx) -> 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
|
|
from . import meta as meta_mod
|
|
|
|
# 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. SCREENSHOT(page, ctx) —
|
|
# the uniform ctx convention (rcust P3).
|
|
hook(page, meta_mod.hook_ctx(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
|