#!/usr/bin/env python3 """Capture local Streamlit browser evidence with Chrome and a synthetic DB. This script is intentionally local-only. It creates a synthetic fixture database, starts Streamlit against that database, and uses an installed Chrome binary to save page screenshots and DOM snapshots. It must not target the operating ledger DB or real financial statement files. """ from __future__ import annotations import argparse import asyncio import base64 import json import os import platform import shutil import signal import subprocess import sys import time import hashlib import math import secrets import ssl import urllib.parse import urllib.request from datetime import datetime from pathlib import Path from typing import Any import websockets from jarca_ledger_mcp import service from jarca_ledger_mcp.db import init_db DEFAULT_PAGES = ["요약", "분류 규칙", "확인필요 큐", "Import"] DEFAULT_CHROME = "/usr/bin/google-chrome-stable" DEFAULT_STREAMLIT = Path(".venv/bin/streamlit") DEFAULT_OUTPUT_ROOT = Path("docs/batches/bat-014_20260703_adapter-surface-narrowing-baseline-refresh/evidence") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Capture Streamlit browser screenshots and DOM snapshots with a synthetic DB.") parser.add_argument("--chrome", default=os.getenv("CHROME_BIN", DEFAULT_CHROME), help="Chrome executable path") parser.add_argument("--streamlit", default=str(DEFAULT_STREAMLIT), help="Streamlit executable path") parser.add_argument("--port", type=int, default=18501, help="Local Streamlit port") parser.add_argument("--host", default="127.0.0.1", help="Local Streamlit host") parser.add_argument("--output-dir", default="", help="Evidence output directory. Defaults under docs/batches/BAT-004/evidence/run-.") parser.add_argument("--page", action="append", dest="pages", help="Page to capture. Repeatable. Defaults to core E2E evidence pages.") parser.add_argument("--keep-server", action="store_true", help="Leave Streamlit running after capture for manual inspection.") parser.add_argument("--mode", choices=("capture", "transition"), default="capture", help="Capture mode") parser.add_argument("--cert-file", default="", help="TLS certificate for transition mode") parser.add_argument("--key-file", default="", help="TLS private key for transition mode") parser.add_argument("--from-page", default="요약") parser.add_argument("--to-page", default="분류 규칙") parser.add_argument("--interval-ms", type=int, default=100) parser.add_argument("--frames", type=int, default=8) return parser.parse_args() def slugify_page(name: str) -> str: mapping = { "분류 규칙": "rules", "확인필요 큐": "transfer-review", "Import": "import", "계좌": "accounts", "요약": "overview", "거래내역": "transactions", "미분류": "uncategorized", "카테고리": "categories", } return mapping.get(name, urllib.parse.quote(name, safe="").replace("%", "").lower() or "page") def require_executable(path: str, label: str) -> str: resolved = shutil.which(path) if os.path.sep not in path else path if not resolved or not Path(resolved).exists(): raise SystemExit(f"{label} executable not found: {path}") return resolved def assert_synthetic_db_path(path: Path) -> None: resolved = path.expanduser().resolve() operating = Path.home() / ".local" / "share" / "jarca" / "ledger.sqlite3" if resolved == operating.resolve(): raise SystemExit("Refusing to use the operating ledger DB for browser evidence") if not str(resolved).endswith("fixture.sqlite3"): raise SystemExit(f"Synthetic evidence DB must end with fixture.sqlite3: {resolved}") def seed_fixture_db(db_path: Path) -> dict[str, Any]: assert_synthetic_db_path(db_path) if db_path.exists(): db_path.unlink() init_db(str(db_path)) service.seed_default_ledger_categories(db_path=str(db_path)) service.seed_default_category_rules(db_path=str(db_path)) account = service.create_ledger_account( "Synthetic Evidence Checking", institution="Synthetic Bank", account_number_masked="SYNTH-0000", holder_name="Synthetic Holder", last_balance=1_250_000, last_synced_at="2026-06-30T23:59:00+09:00", statement_status="partial_import", liquidity_bucket="cash", memo="browser evidence synthetic fixture", db_path=str(db_path), )["account"] account_id = account["id"] service.create_ledger_account( "Synthetic Investment Account", type="investment", institution="Synthetic Securities", last_balance=5_000_000, last_synced_at="2026-06-30T23:59:00+09:00", liquidity_bucket="investment", memo="browser evidence investment asset", db_path=str(db_path), ) service.create_ledger_account( "Synthetic Mortgage Balance", type="loan", institution="Synthetic Bank", last_balance=2_000_000, last_synced_at="2026-06-30T23:59:00+09:00", liquidity_bucket="loan", memo="browser evidence liability", db_path=str(db_path), ) service.create_ledger_account( "Synthetic Appraisal Pending", type="other", institution="Manual", liquidity_bucket="real_estate", memo="browser evidence missing last_balance warning", db_path=str(db_path), ) service.create_ledger_budget("2026-06", "식비", 20_000, memo="browser evidence budget", db_path=str(db_path)) tx_specs = [ dict(transaction_date="2026-06-25", type="income", amount=2_000_000, merchant="SYNTHETIC_SALARY", category="수입", subcategory="급여", source="manual", memo="browser evidence income"), dict(transaction_date="2026-06-26", type="expense", amount=35_000, merchant="SYNTHETIC_DINING", category="식비", subcategory="외식", source="manual", memo="browser evidence expense"), dict(transaction_date="2026-06-27", type="expense", amount=18_500, merchant="SYNTHETIC_MART", category="", subcategory="", source="manual", memo="browser evidence uncategorized", raw_summary="synthetic grocery"), dict(transaction_date="2026-06-28", type="expense", amount=4_800, merchant="SYNTHETIC_CAFE", category="", subcategory="", source="manual", memo="browser evidence uncategorized", raw_summary="synthetic cafe"), dict(transaction_date="2026-06-29", type="transfer", amount=120_000, merchant="SYNTHETIC_TRANSFER", category="이체", subcategory="확인필요", source="kb_bank_pdf", counterparty="SYNTHETIC_TOSS", memo="cross_transfer_review: synthetic browser evidence", raw_summary="synthetic transfer", balance_after=1_250_000), ] transactions = [] for index, spec in enumerate(tx_specs, start=1): result = service.create_ledger_transaction(account_id=account_id, external_id=f"browser-evidence-{index}", db_path=str(db_path), **spec) if not result.get("success"): raise SystemExit(f"Fixture transaction seed failed: {result}") transactions.append(result["transaction"]["id"]) return {"account_id": account_id, "transaction_ids": transactions, "db_path": str(db_path)} def wait_for_http(url: str, timeout_seconds: int = 45, ssl_context: ssl.SSLContext | None = None) -> None: """Wait for HTTP readiness, using verified TLS unless explicitly overridden.""" deadline = time.time() + timeout_seconds last_error: str | None = None while time.time() < deadline: try: with urllib.request.urlopen(url, timeout=2, context=ssl_context) as response: if response.status == 200: return last_error = f"HTTP {response.status}" except Exception as exc: # noqa: BLE001 - diagnostics returned in manifest last_error = str(exc) time.sleep(1) raise SystemExit(f"Streamlit did not become ready at {url}: {last_error}") def read_json_url(url: str, timeout: int = 2) -> Any: with urllib.request.urlopen(url, timeout=timeout) as response: return json.loads(response.read().decode("utf-8")) def wait_for_devtools(port: int, timeout_seconds: int = 15) -> str: deadline = time.time() + timeout_seconds last_error: str | None = None while time.time() < deadline: try: targets = read_json_url(f"http://127.0.0.1:{port}/json/list") for target in targets: if target.get("type") == "page" and target.get("webSocketDebuggerUrl"): return target["webSocketDebuggerUrl"] last_error = "no page target" except Exception as exc: # noqa: BLE001 - Chrome startup diagnostics last_error = str(exc) time.sleep(0.25) raise SystemExit(f"Chrome DevTools endpoint did not become ready on port {port}: {last_error}") def remove_tree_with_retries(path: Path, attempts: int = 5, delay_seconds: float = 0.5) -> None: for attempt in range(1, attempts + 1): shutil.rmtree(path, ignore_errors=True) if not path.exists(): return time.sleep(delay_seconds * attempt) if path.exists(): raise SystemExit(f"temporary Chrome profile cleanup failed: {path}") class CdpClient: def __init__(self, ws: websockets.ClientConnection) -> None: self.ws = ws self.next_id = 1 self.events: list[dict[str, Any]] = [] async def call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: command_id = self.next_id self.next_id += 1 await self.ws.send(json.dumps({"id": command_id, "method": method, "params": params or {}})) while True: raw = await self.ws.recv() payload = json.loads(raw) if payload.get("id") == command_id: if "error" in payload: raise RuntimeError(f"CDP {method} failed: {payload['error']}") return payload.get("result", {}) if payload.get("method"): self.events.append(payload) async def cdp_capture(ws_url: str, url: str, expected_text: str, screenshot: Path, snapshot: Path) -> dict[str, Any]: async with websockets.connect(ws_url, max_size=16 * 1024 * 1024) as ws: cdp = CdpClient(ws) await cdp.call("Page.enable") await cdp.call("Runtime.enable") await cdp.call( "Emulation.setDeviceMetricsOverride", {"width": 1440, "height": 1400, "deviceScaleFactor": 1, "mobile": False}, ) await cdp.call("Page.navigate", {"url": url}) inner_text = "" deadline = time.time() + 45 while time.time() < deadline: result = await cdp.call( "Runtime.evaluate", {"expression": "document.body ? document.body.innerText : ''", "returnByValue": True}, ) inner_text = result.get("result", {}).get("value", "") or "" if expected_text in inner_text: break await asyncio.sleep(1) if expected_text not in inner_text: raise SystemExit(f"Browser page did not render expected text {expected_text!r}; last text starts with {inner_text[:200]!r}") html_result = await cdp.call( "Runtime.evaluate", {"expression": "document.documentElement.outerHTML", "returnByValue": True}, ) snapshot.write_text(html_result.get("result", {}).get("value", ""), encoding="utf-8") image_result = await cdp.call("Page.captureScreenshot", {"format": "png", "fromSurface": True, "captureBeyondViewport": True}) screenshot.write_bytes(base64.b64decode(image_result["data"])) return {"inner_text_head": inner_text[:500]} def capture_page(chrome: str, base_url: str, page: str, output_dir: Path, devtools_port: int) -> dict[str, Any]: slug = slugify_page(page) url = f"{base_url}/?page={urllib.parse.quote(page)}" screenshot = output_dir / f"{slug}.png" snapshot = output_dir / f"{slug}.dom.html" profile_dir = output_dir / f"chrome-profile-{slug}" chrome_cmd = [ chrome, "--headless=new", "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--disable-software-rasterizer", "--hide-scrollbars", f"--remote-debugging-port={devtools_port}", f"--user-data-dir={profile_dir}", "--window-size=1440,1400", "about:blank", ] chrome_proc = subprocess.Popen(chrome_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) try: ws_url = wait_for_devtools(devtools_port) cdp_info = asyncio.run(cdp_capture(ws_url, url, page, screenshot, snapshot)) finally: if chrome_proc.poll() is None: chrome_proc.terminate() try: chrome_proc.wait(timeout=8) except subprocess.TimeoutExpired: chrome_proc.kill() chrome_proc.wait(timeout=5) remove_tree_with_retries(profile_dir) if not screenshot.exists() or screenshot.stat().st_size <= 0: raise SystemExit(f"Screenshot was not created for {page}: {screenshot}") if page not in snapshot.read_text(encoding="utf-8", errors="ignore"): raise SystemExit(f"DOM snapshot does not contain expected page title {page!r}: {snapshot}") return { "page": page, "url": url, "screenshot": str(screenshot), "screenshot_bytes": screenshot.stat().st_size, "snapshot": str(snapshot), "snapshot_bytes": snapshot.stat().st_size, "devtools_port": devtools_port, "inner_text_head": cdp_info.get("inner_text_head", ""), } def redact_secrets(value: Any, secrets_to_redact: tuple[str, ...] = ()) -> Any: """Return JSON-safe evidence with credential-like values removed.""" if isinstance(value, dict): return {key: redact_secrets(item, secrets_to_redact) for key, item in value.items()} if isinstance(value, list): return [redact_secrets(item, secrets_to_redact) for item in value] if isinstance(value, str): result = value for secret in secrets_to_redact: if secret: result = result.replace(secret, "[REDACTED]") return result return value def _validate_surface_records(records: Any, label: str) -> None: required = ("stAppViewContainer", "stSidebar") if not isinstance(records, dict) or any(not isinstance(records.get(name), dict) for name in required): raise ValueError(f"transition observations require surface records for {label}") for name in required: surface = records[name] if any(key not in surface or surface[key] is None for key in ("backgroundImage", "backgroundColor", "visibility", "display", "opacity", "rendered")): raise ValueError(f"transition observations require complete surface records for {label}") if not str(surface["backgroundImage"]).strip() or str(surface["backgroundImage"]).strip().lower() == "none": raise ValueError(f"transition observations reject empty surface background for {label}") if not str(surface["backgroundColor"]).strip(): raise ValueError(f"transition observations reject empty surface background color for {label}") try: opacity = float(surface["opacity"]) except (TypeError, ValueError) as exc: raise ValueError(f"transition observations require valid surface opacity for {label}") from exc if not math.isfinite(opacity) or surface["visibility"] != "visible" or surface["display"] == "none" or opacity <= 0 or surface["rendered"] is not True: raise ValueError(f"transition observations require visible rendered surfaces for {label}") def validate_transition_manifest(manifest: dict[str, Any], secrets_to_redact: tuple[str, ...] = ()) -> None: """Fail closed unless transition evidence contains every required observation.""" serialized = json.dumps(manifest, ensure_ascii=False) if any(secret and secret in serialized for secret in secrets_to_redact): raise ValueError("secret value present in transition manifest") if manifest.get("success") is not True: raise ValueError("transition manifest is not successful") observations = manifest.get("observations") or {} frames = observations.get("frames") if not isinstance(frames, list) or len(frames) < 2: raise ValueError("transition observations require at least two frames") for index, frame in enumerate(frames): _validate_surface_records(frame.get("surfaces"), f"frame {index}") document_events = observations.get("document_navigation_events") if not isinstance(document_events, list) or document_events: raise ValueError("transition observations require zero document navigation events") shell = observations.get("stable_shell") or {} stable_fields = ("document_marker", "sidebar_marker", "background") if any(not isinstance(shell.get(key), dict) for key in stable_fields): raise ValueError("transition observations require pre/post stable shell markers") if any( not isinstance(values.get(position), str) or not values[position].strip() for values in (shell[key] for key in stable_fields) for position in ("pre", "post") ): raise ValueError("transition observations require non-empty stable shell markers") if any(shell[key]["pre"] != shell[key]["post"] for key in stable_fields): raise ValueError("transition observations require stable shell identity and background") stable_surfaces = shell.get("surfaces") if not isinstance(stable_surfaces, dict) or stable_surfaces.get("pre") != frames[0].get("surfaces"): raise ValueError("transition observations require stable shell surface records") _validate_surface_records(stable_surfaces.get("pre"), "stable shell pre") _validate_surface_records(stable_surfaces.get("post"), "stable shell post") if stable_surfaces["pre"] != stable_surfaces["post"] or stable_surfaces["post"] != frames[-1].get("surfaces"): raise ValueError("transition observations require stable shell surface consistency") if observations.get("prior_body_removed") is not True: raise ValueError("transition observations require prior body removal") loading_frames = [frame for frame in frames if frame.get("loading") is True] if observations.get("loading_observed") is not True or not loading_frames: raise ValueError("transition observations require observed loading state") if any(frame.get("prior_body_visible") is not False for frame in loading_frames): raise ValueError("transition observations require prior body absence while loading") if not observations.get("target_page"): raise ValueError("transition observations require target page") if observations.get("final_agreement") is not True: raise ValueError("transition observations require target body agreement") runtime = manifest.get("runtime") or {} if not runtime.get("viewport") or not isinstance(runtime.get("interval_ms"), int): raise ValueError("transition manifest requires viewport and interval") command = manifest.get("command") or {} if command.get("exit_code") != 0 or command.get("teardown") != "complete": raise ValueError("transition manifest requires successful command teardown") source_revision = manifest.get("source_revision") if not isinstance(source_revision, str) or len(source_revision) != 40: raise ValueError("transition manifest requires full source revision") source_files = manifest.get("source_files") if not isinstance(source_files, dict) or not source_files or any( not isinstance(path, str) or not isinstance(digest, str) or len(digest) != 64 for path, digest in source_files.items() ): raise ValueError("transition manifest requires source files and SHA-256 digests") source_paths = tuple(source_files) if manifest.get("source_revision_kind") != "git-head-base": raise ValueError("transition manifest requires git-head-base revision kind") if source_revision != current_git_revision(): raise ValueError("transition manifest source revision does not match current Git HEAD") if source_files != source_file_digests(source_paths): raise ValueError("transition manifest source file digests do not match current files") if manifest.get("source_worktree_dirty") != git_worktree_dirty(source_paths): raise ValueError("transition manifest working-tree state does not match current files") if not isinstance(manifest.get("evidence_command"), list) or not manifest["evidence_command"]: raise ValueError("transition manifest requires evidence command") if not runtime.get("host_architecture") or not runtime.get("browser_user_agent"): raise ValueError("transition manifest requires runtime identity") def current_git_revision() -> str: return subprocess.run( ["git", "rev-parse", "HEAD"], check=True, capture_output=True, text=True, ).stdout.strip() def git_worktree_dirty(paths: tuple[str, ...]) -> bool: result = subprocess.run( ["git", "status", "--porcelain", "--", *paths], check=True, capture_output=True, text=True, ) return bool(result.stdout.strip()) def source_file_digests(paths: tuple[str, ...]) -> dict[str, str]: for path in paths: candidate = Path(path) if candidate.is_absolute() or ".." in candidate.parts: raise ValueError("source file paths must be repository-relative") return {path: hashlib.sha256(Path(path).read_bytes()).hexdigest() for path in paths} async def cdp_transition(ws_url: str, base_url: str, from_page: str, to_page: str, screenshot: Path, snapshot: Path, interval_ms: int, frame_count: int, admin_id: str, admin_password: str) -> dict[str, Any]: async with websockets.connect(ws_url, max_size=16 * 1024 * 1024) as ws: cdp = CdpClient(ws) await cdp.call("Page.enable") await cdp.call("Runtime.enable") await cdp.call("Emulation.setDeviceMetricsOverride", {"width": 1440, "height": 1400, "deviceScaleFactor": 1, "mobile": False}) await cdp.call("Page.navigate", {"url": base_url}) await asyncio.sleep(1) login_expression = """(async () => { const body = new URLSearchParams({admin_id: %s, admin_password: %s}); const response = await fetch('/api/auth/login', {method:'POST', credentials:'include', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body}); return {status: response.status, location: response.url}; })()""" % (json.dumps(admin_id), json.dumps(admin_password)) await cdp.call("Runtime.evaluate", {"expression": login_expression, "awaitPromise": True, "returnByValue": True}) await cdp.call("Page.navigate", {"url": f"{base_url}/?page={urllib.parse.quote(from_page)}"}) deadline = time.time() + 45 while time.time() < deadline: check = await cdp.call("Runtime.evaluate", {"expression": "document.body ? document.body.innerText : ''", "returnByValue": True}) if from_page in (check.get("result", {}).get("value", "") or ""): break await asyncio.sleep(.5) install = """(() => { window.__evidenceEvents=[]; window.__evidenceDocumentMarker ||= crypto.randomUUID(); window.__evidencePriorBodyText = (document.querySelector('[data-testid=stMain]') || document.body)?.innerText || ''; const sidebar=document.querySelector('[data-testid=stSidebar]'); if (sidebar) sidebar.dataset.evidenceMarker ||= crypto.randomUUID(); window.__evidenceSurface = (selector) => { const element=document.querySelector(selector); if (!element) return null; const style=getComputedStyle(element); const rect=element.getBoundingClientRect(); return {backgroundImage:style.backgroundImage, backgroundColor:style.backgroundColor, visibility:style.visibility, display:style.display, opacity:style.opacity, rendered:rect.width>0 && rect.height>0 && style.visibility!=='hidden' && style.display!=='none' && Number(style.opacity)>0}; }; for (const name of ['pushState','replaceState']) { const old=history[name]; history[name]=function(){window.__evidenceEvents.push({type:name,url:String(arguments[2]||location.href)}); return old.apply(this,arguments)}; } })()""" await cdp.call("Runtime.evaluate", {"expression": install}) cdp.events.clear() observations: list[dict[str, Any]] = [] click = """(() => { const labels=[...document.querySelectorAll('label')]; const target=labels.find(x=>x.innerText.trim()===%s); if(target){target.click(); return true;} const input=[...document.querySelectorAll('input')].find(x=>x.value===%s); if(input){input.click(); return true;} return false; })()""" % (json.dumps(to_page), json.dumps(to_page)) deadline = time.time() + 45 index = 0 while time.time() < deadline and (index < frame_count or not observations or observations[-1].get("selected_menu") != to_page): expression = """(() => { const body=document.body; const text=body?.innerText||''; const main=document.querySelector('[data-testid=stMain]')||body; const mainText=main.innerText||''; const sidebar=document.querySelector('[data-testid=stSidebar]'); const selectedInput=document.querySelector('[data-testid=stRadio] input:checked'); const selected=selectedInput?.closest('label')?.innerText?.trim() || document.querySelector('[aria-current=page]')?.textContent?.trim() || ''; const prior=window.__evidencePriorBodyText||''; return {at_ms:%d, selected_menu:selected, body_text:mainText, prior_body_visible:Boolean(prior)&&mainText.includes(prior), document_marker:window.__evidenceDocumentMarker||'', sidebar_marker:sidebar?.dataset.evidenceMarker||'', background:getComputedStyle(document.body).backgroundColor, surfaces:{stAppViewContainer:window.__evidenceSurface('[data-testid=stAppViewContainer]'), stSidebar:window.__evidenceSurface('[data-testid=stSidebar]')}, loading:/불러오는 중/.test(text)}; })()""" % (index * interval_ms) result = await cdp.call("Runtime.evaluate", {"expression": expression, "returnByValue": True}) frame = result.get("result", {}).get("value", {}) frame["body_text_hash"] = hashlib.sha256(frame.get("body_text", "").encode()).hexdigest() frame.pop("body_text", None) observations.append(frame) if index == 0: await cdp.call("Runtime.evaluate", {"expression": click, "returnByValue": True}) index += 1 await asyncio.sleep(interval_ms / 1000) final = await cdp.call("Runtime.evaluate", {"expression": "(() => { const main=document.querySelector('[data-testid=stMain]')||document.body; const sidebar=document.querySelector('[data-testid=stSidebar]'); const selectedInput=document.querySelector('[data-testid=stRadio] input:checked'); return {url:location.href, body:main.innerText||'', selected:selectedInput?.closest('label')?.innerText?.trim() || document.querySelector('[aria-current=page]')?.textContent?.trim() || '', document_marker:window.__evidenceDocumentMarker||'', sidebar_marker:sidebar?.dataset.evidenceMarker||'', background:getComputedStyle(document.body).backgroundColor, surfaces:{stAppViewContainer:window.__evidenceSurface('[data-testid=stAppViewContainer]'), stSidebar:window.__evidenceSurface('[data-testid=stSidebar]')}, history_events:window.__evidenceEvents||[], browser_user_agent:navigator.userAgent}; })()", "returnByValue": True}) data = final.get("result", {}).get("value", {}) html = await cdp.call("Runtime.evaluate", {"expression": "document.documentElement.outerHTML", "returnByValue": True}) html_text = html.get("result", {}).get("value", "") snapshot.write_text(redact_secrets(html_text, (admin_id, admin_password)), encoding="utf-8") image = await cdp.call("Page.captureScreenshot", {"format":"png", "fromSurface":True, "captureBeyondViewport":True}) screenshot.write_bytes(base64.b64decode(image["data"])) document_events = [{"type": event.get("method"), "params": event.get("params", {})} for event in cdp.events if event.get("method") in ("Page.frameNavigated", "Page.loadEventFired")] pre = observations[0] if observations else {} loading_frames = [frame for frame in observations if frame.get("loading")] return {"frames": observations, "document_navigation_events": document_events, "history_events": data.get("history_events", []), "stable_shell": {"document_marker": {"pre": pre.get("document_marker"), "post": data.get("document_marker")}, "sidebar_marker": {"pre": pre.get("sidebar_marker"), "post": data.get("sidebar_marker")}, "background": {"pre": pre.get("background"), "post": data.get("background")}, "surfaces": {"pre": pre.get("surfaces"), "post": data.get("surfaces")}}, "loading_observed": bool(loading_frames), "prior_body_removed": bool(loading_frames) and all(frame.get("prior_body_visible") is False for frame in loading_frames), "target_page": to_page, "final_agreement": data.get("selected") == to_page and to_page in data.get("body", ""), "final_url": data.get("url", ""), "browser_user_agent": data.get("browser_user_agent", "")} def transition_capture(chrome: str, base_url: str, from_page: str, to_page: str, output_dir: Path, devtools_port: int, interval_ms: int, frame_count: int, admin_id: str, admin_password: str) -> dict[str, Any]: profile_dir = output_dir / "chrome-profile-transition" screenshot, snapshot = output_dir / "transition.png", output_dir / "transition.dom.html" chrome_cmd = [chrome, "--headless=new", "--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors", f"--remote-debugging-port={devtools_port}", f"--user-data-dir={profile_dir}", "--window-size=1440,1400", "about:blank"] proc = subprocess.Popen(chrome_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) try: ws_url = wait_for_devtools(devtools_port) return asyncio.run(cdp_transition(ws_url, base_url, from_page, to_page, screenshot, snapshot, interval_ms, frame_count, admin_id, admin_password)) finally: if proc.poll() is None: proc.terminate() try: proc.wait(timeout=8) except subprocess.TimeoutExpired: proc.kill(); proc.wait(timeout=5) remove_tree_with_retries(profile_dir) def main() -> int: args = parse_args() chrome = require_executable(args.chrome, "Chrome") streamlit = None if args.mode == "transition" else require_executable(args.streamlit, "Streamlit") run_id = datetime.now().strftime("run-%Y%m%d-%H%M%S") output_dir = Path(args.output_dir) if args.output_dir else DEFAULT_OUTPUT_ROOT / run_id output_dir.mkdir(parents=True, exist_ok=True) db_path = output_dir / "fixture.sqlite3" fixture = seed_fixture_db(db_path) env = os.environ.copy() env["JARCA_LEDGER_DB_PATH"] = str(db_path.resolve()) env["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false" transition_secrets: tuple[str, ...] = () if args.mode == "transition": if not args.cert_file or not args.key_file: raise SystemExit("transition mode requires --cert-file and --key-file") env["JARCA_DASHBOARD_ADMIN_ID"] = secrets.token_urlsafe(18) env["JARCA_DASHBOARD_ADMIN_PASSWORD"] = secrets.token_urlsafe(32) env["JARCA_DASHBOARD_SIGNING_SECRET"] = secrets.token_hex(32) transition_secrets = (env["JARCA_DASHBOARD_ADMIN_ID"], env["JARCA_DASHBOARD_ADMIN_PASSWORD"], env["JARCA_DASHBOARD_SIGNING_SECRET"]) scheme = "https" if args.mode == "transition" else "http" base_url = f"{scheme}://{args.host}:{args.port}" if args.mode == "transition": server_cmd = [sys.executable, "-m", "uvicorn", "dashboard.server:app", "--host", args.host, "--port", str(args.port), "--ssl-certfile", args.cert_file, "--ssl-keyfile", args.key_file] else: server_cmd = [ streamlit, "run", "dashboard/app.py", "--server.address", args.host, "--server.port", str(args.port), "--server.headless", "true", "--browser.gatherUsageStats", "false", ] server = subprocess.Popen(server_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, start_new_session=True) manifest: dict[str, Any] = { "success": False, "mode": args.mode, "base_url": base_url, "output_dir": str(output_dir.resolve()), "fixture": {"db_path": fixture["db_path"]}, "chrome": chrome, "streamlit": streamlit, "pages": [], "server_command": server_cmd, } error: str | None = None try: wait_for_http(base_url, ssl_context=ssl._create_unverified_context() if args.mode == "transition" else None) if args.mode == "transition": observations = transition_capture(chrome, base_url, args.from_page, args.to_page, output_dir, 19222, args.interval_ms, args.frames, env["JARCA_DASHBOARD_ADMIN_ID"], env["JARCA_DASHBOARD_ADMIN_PASSWORD"]) source_paths = ( "dashboard/app.py", "dashboard/common.py", "scripts/streamlit_browser_evidence.py", ) manifest.update( { "observations": observations, "runtime": { "viewport": {"width": 1440, "height": 1400}, "interval_ms": args.interval_ms, "frame_count": args.frames, "host_architecture": platform.machine(), "python_version": platform.python_version(), "browser_user_agent": observations.get("browser_user_agent", ""), }, "source_revision": current_git_revision(), "source_revision_kind": "git-head-base", "source_worktree_dirty": git_worktree_dirty(source_paths), "source_files": source_file_digests(source_paths), "evidence_command": ["scripts/streamlit_browser_evidence.py", *sys.argv[1:]], "limitations": ["TLS uses caller-provided local certificate"], } ) else: for index, page in enumerate(args.pages or DEFAULT_PAGES): manifest["pages"].append(capture_page(chrome, base_url, page, output_dir, devtools_port=19222 + index)) except Exception as exc: # noqa: BLE001 - failure is recorded in redacted evidence error = str(exc) finally: if not args.keep_server and server.poll() is None: os.killpg(server.pid, signal.SIGTERM) try: server.wait(timeout=10) except subprocess.TimeoutExpired: os.killpg(server.pid, signal.SIGKILL) server.wait(timeout=5) returncode = server.poll() teardown = "complete" if not args.keep_server and returncode is not None else "incomplete" effective_exit = 0 if returncode in (0, -signal.SIGTERM) else returncode manifest["server_log_tail"] = "" manifest["command"] = {"exit_code": effective_exit, "teardown": teardown} if error: manifest["error"] = error if args.mode == "transition" and error is None and teardown == "complete" and effective_exit == 0: try: manifest["success"] = True validate_transition_manifest(manifest, transition_secrets) except Exception as exc: # noqa: BLE001 - preserve failed manifest manifest["success"] = False manifest["error"] = str(exc) elif args.mode == "capture" and error is None and ( (args.keep_server and returncode is None) or (teardown == "complete" and effective_exit == 0) ): manifest["success"] = True safe_manifest = redact_secrets(manifest, transition_secrets) manifest_path = output_dir / "manifest.json" temporary_path = output_dir / ".manifest.json.tmp" temporary_path.write_text(json.dumps(safe_manifest, ensure_ascii=False, indent=2), encoding="utf-8") temporary_path.replace(manifest_path) print(json.dumps(safe_manifest, ensure_ascii=False, indent=2)) return 0 if manifest.get("success") is True else 1 if __name__ == "__main__": raise SystemExit(main())