Source code for recon_gen.common.browser.screenshot

"""ScreenshotHarness: walk a deployed App's tree and capture
screenshots systematically.

Three capture modes:

- ``capture_all_sheets()`` — one full-page screenshot per sheet.
  Returns ``dict[Sheet, Path]`` keyed by the Sheet object ref so
  handbook templates can look up by Sheet, not by sheet_id string.
  Filenames remain ``{sheet_id}.png`` for stable on-disk names.
- ``capture_per_visual(sheet)`` — one screenshot per visual on the
  sheet, scroll-into-view + element crop. Returns
  ``dict[VisualLike, Path]`` keyed by the Visual object ref;
  filenames derive from each visual's resolved ``visual_id``.
- ``capture_with_state(parameter_values)`` — apply parameter values
  via URL hash, then capture every sheet. Returns
  ``dict[Sheet, Path]`` keyed by Sheet ref.

Sheet/Visual object keys (M.1.10 / F8) means callers can do
``paths[my_sheet]`` from the same App they constructed, instead
of carrying a parallel ``sheet_id`` string around. The on-disk
filenames stay sheet_id-derived so previously-generated images
overwrite cleanly across runs.

Why this matters for **Phase M**: when whitelabel-V2 swaps personas
(Sasquatch → Acme Bank), the docs need Acme-shaped screenshots.
Manual capture doesn't scale; ``capture_all_sheets()`` regenerates
the screenshot set against the new persona's deploy in one call.
The persona dataclass drives BOTH the seed generator AND the
screenshot pipeline in lockstep.

Same Page-based infra as ``TreeValidator``; different consumer —
they could share a ``DeployedTreeContext`` helper if patterns
crystallize. For now each tool has its own class.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import quote

from recon_gen.common.tree import (
    App,
    ParameterDeclLike,
    Sheet,
    VisualLike,
)

from .helpers import (
    click_sheet_tab,
    wait_for_dashboard_loaded,
    wait_for_visuals_present,
)


[docs] @dataclass class ScreenshotHarness: """Walk an App + Page; produce a directory of named screenshots.""" app: App page: Any # Playwright Page; typed Any so module loads in unit tests. output_dir: Path embed_url: str | None = None # Required for capture_with_state(). timeout_ms: int = 30_000 def __post_init__(self) -> None: self.output_dir = Path(self.output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) # ------------------------------------------------------------------ # Capture modes # ------------------------------------------------------------------
[docs] def capture_all_sheets(self) -> dict[Sheet, Path]: """One full-page screenshot per sheet on the App's analysis. Returns ``dict[Sheet, Path]`` keyed by the Sheet object ref. Filenames remain ``{sheet_id}.png`` so re-running overwrites the same on-disk file; only the in-memory key shape changed (M.1.10 / F8). """ if self.app.analysis is None: raise ValueError( f"App {self.app.name!r} has no Analysis — nothing to capture." ) results: dict[Sheet, Path] = {} for sheet in self.app.analysis.sheets: click_sheet_tab(self.page, sheet.name, self.timeout_ms) wait_for_visuals_present( self.page, min_count=1, timeout_ms=self.timeout_ms, ) path = self.output_dir / f"{self._safe_id(sheet.sheet_id)}.png" self.page.screenshot(path=str(path), full_page=True) results[sheet] = path return results
[docs] def capture_per_visual(self, sheet: Sheet) -> dict[VisualLike, Path]: """One screenshot per visual on ``sheet``, element-cropped. Returns ``dict[VisualLike, Path]`` keyed by the Visual object ref. Skips visuals without a resolved ``visual_id`` — the auto-ID walker hasn't run, or the visual is a factory wrapper without an explicit id. Caller should ``app.emit_analysis()`` once before to resolve auto-IDs (the validator + this harness usually share a session-scoped fixture that already does that). """ click_sheet_tab(self.page, sheet.name, self.timeout_ms) wait_for_visuals_present( self.page, min_count=len(sheet.visuals), timeout_ms=self.timeout_ms, ) results: dict[VisualLike, Path] = {} for visual in sheet.visuals: visual_id = getattr(visual, "visual_id", None) if not visual_id: continue # Scroll into view + crop. Per the project memory, # below-the-fold visuals virtualize; a tall viewport is # sometimes needed. Caller manages viewport — the harness # just captures. element = self._find_visual_element(visual_id) path = self.output_dir / f"{self._safe_id(visual_id)}.png" if element is None: # Couldn't isolate the element — fall back to # full-page so the analyst can still see the result. self.page.screenshot(path=str(path), full_page=True) else: element.screenshot(path=str(path)) results[visual] = path return results
[docs] def capture_with_state( self, *, parameter_values: dict[ParameterDeclLike, Any], suffix: str = "state", ) -> dict[Sheet, Path]: """Re-load the dashboard with parameter values applied via URL hash (``#p.<name>=<value>``), then capture every sheet. Returns ``dict[Sheet, Path]`` keyed by Sheet object ref. Filenames suffix-tagged so multiple states don't overwrite each other: ``{sheet_id}-{suffix}.png``. Pass distinct ``suffix`` values per state. Per the project memory ``project_qs_url_parameter_no_control_sync``, the on-screen control widget may not reflect the URL value even when the data is filtered. The screenshot captures what the analyst SEES, which is the rendered visual state — that's the right semantics for handbook screenshots. Requires ``embed_url`` set on construction. """ if self.embed_url is None: raise ValueError( "capture_with_state needs embed_url set on the harness." ) if self.app.analysis is None: raise ValueError( f"App {self.app.name!r} has no Analysis." ) # Build the hash fragment from parameter object refs. fragments = [ f"p.{p.name}={quote(str(v))}" for p, v in parameter_values.items() ] url = f"{self.embed_url}#{'&'.join(fragments)}" self.page.goto(url, timeout=self.timeout_ms) wait_for_dashboard_loaded(self.page, timeout_ms=self.timeout_ms) results: dict[Sheet, Path] = {} for sheet in self.app.analysis.sheets: click_sheet_tab(self.page, sheet.name, self.timeout_ms) wait_for_visuals_present( self.page, min_count=1, timeout_ms=self.timeout_ms, ) sheet_safe = self._safe_id(sheet.sheet_id) path = self.output_dir / f"{sheet_safe}-{suffix}.png" self.page.screenshot(path=str(path), full_page=True) results[sheet] = path return results
# ------------------------------------------------------------------ # Internals # ------------------------------------------------------------------ def _safe_id(self, identifier: str) -> str: """Sanitize an ID into a filename-safe slug. QuickSight IDs are already mostly slug-safe (kebab-case + alphanumeric); this is belt-and-suspenders against future ID conventions.""" return identifier.replace("/", "_").replace(":", "_") def _find_visual_element(self, visual_id: str): # typing-smell: ignore[bare-str-id]: receives the unresolved VisualId|AutoResolved walk — narrowing belongs in a follow-up screenshot harness pass """Locate the DOM element for a specific visual. QuickSight's visual containers are tagged ``data-automation-id= "analysis_visual"`` generically — the visual_id isn't directly a DOM attribute. Until QS exposes a better selector, we walk the visual containers and match by title (which the title label IS in the DOM). Returns the Playwright Locator-or-element, or None when the match isn't found. Callers fall back to full-page capture. """ # The browser_helpers' VISUAL_SELECTOR is the right anchor; # but matching back to a specific visual_id requires the # auto-ID's title or a structural index. For the MVP we # return None and let the caller fall back; a future # enhancement could thread the title through here. return None
# --------------------------------------------------------------------------- # Standalone capture helper for the `recon-gen export screenshots` CLI # ---------------------------------------------------------------------------
[docs] def capture_deployed_app( app: App, *, embed_url: str, output_dir: Path, viewport: tuple[int, int] = (1280, 900), initial_settle_ms: int = 10_000, per_sheet_settle_ms: int = 8_000, page_timeout_ms: int = 120_000, headless: bool = True, url_params: dict[str, str] | None = None, ) -> dict[Sheet, Path]: """Walk a deployed App's tree and capture full-page PNGs per sheet. Differs from ``ScreenshotHarness.capture_all_sheets()`` (which assumes a Page is already open + the test fixture has set the viewport): this helper owns the browser lifecycle and uses settle timeouts instead of ``wait_for_visuals_present``. Matches the proven shape of the pre-Q ad-hoc capture scripts. The forgiving settle timing tolerates QS spinner-forever cases (Operational Footguns) by capturing whatever painted in the window — a spinner-frame in the PNG is acceptable for handbook docs since the next run usually clears it. ``url_params``: optional ``{paramName: value}`` dict appended to the embed URL as ``#p.<name>=<value>&p.<name>=<value>...`` so the captured page renders with overridden parameter values. Used by the screenshots CLI to inject ``--date-from / --date-to`` when the seed anchors at a date the dashboard's default rolling-window controls don't span. Per the QuickSight URL-param control-sync limitation (see project memory ``project_qs_url_parameter_no_control_sync``), the on-screen *control widget* may still show the default value; the *data* is filtered by the URL value, which is what matters for screenshots. Values are URL-encoded. Returns ``dict[Sheet, Path]`` keyed by Sheet object ref. Filenames are ``{sheet_id}.png`` so re-running overwrites the same on-disk file. """ from urllib.parse import quote from .helpers import ( click_sheet_tab, wait_for_dashboard_loaded, webkit_page, ) if app.analysis is None: raise ValueError( f"App {app.name!r} has no Analysis — nothing to capture." ) output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) if url_params: fragments = [ f"p.{name}={quote(str(value))}" for name, value in url_params.items() ] full_url = f"{embed_url}#{'&'.join(fragments)}" else: full_url = embed_url results: dict[Sheet, Path] = {} with webkit_page(headless=headless, viewport=viewport) as page: page.goto(full_url, timeout=page_timeout_ms) wait_for_dashboard_loaded(page, timeout_ms=page_timeout_ms) page.wait_for_timeout(initial_settle_ms) for sheet in app.analysis.sheets: click_sheet_tab(page, sheet.name, page_timeout_ms) page.wait_for_timeout(per_sheet_settle_ms) sheet_id_safe = ( str(sheet.sheet_id).replace("/", "-").replace(":", "-") ) path = output_dir / f"{sheet_id_safe}.png" page.screenshot(path=str(path), full_page=True) results[sheet] = path return results