fix(dashboard): keep shell during menu transitions

Replace document navigation with native Streamlit state transitions, preserve dashboard contracts, and add regression plus authenticated browser evidence for Issue #9.
This commit is contained in:
2026-10-05 00:49:28 +00:00
parent ffd943fe65
commit 96e11812ce
13 changed files with 2166 additions and 51 deletions
+285 -34
View File
@@ -14,10 +14,15 @@ import asyncio
import base64
import json
import os
import platform
import shutil
import signal
import subprocess
import sys
import time
import hashlib
import secrets
import ssl
import urllib.parse
import urllib.request
from datetime import datetime
@@ -44,6 +49,13 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--output-dir", default="", help="Evidence output directory. Defaults under docs/batches/BAT-004/evidence/run-<timestamp>.")
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()
@@ -145,12 +157,13 @@ def seed_fixture_db(db_path: Path) -> dict[str, Any]:
return {"account_id": account_id, "transaction_ids": transactions, "db_path": str(db_path)}
def wait_for_http(url: str, timeout_seconds: int = 45) -> None:
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) as response:
with urllib.request.urlopen(url, timeout=2, context=ssl_context) as response:
if response.status == 200:
return
last_error = f"HTTP {response.status}"
@@ -195,6 +208,7 @@ 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
@@ -207,6 +221,8 @@ class CdpClient:
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]:
@@ -295,10 +311,196 @@ def capture_page(chrome: str, base_url: str, page: str, output_dir: Path, devtoo
}
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_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")
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 {}
if not all(isinstance(shell.get(name), dict) for name in ("document_marker", "sidebar_marker", "background")):
raise ValueError("transition observations require pre/post stable shell markers")
if any(values.get("pre") != values.get("post") for values in (shell["document_marker"], shell["sidebar_marker"], shell["background"])):
raise ValueError("transition observations require stable shell identity and background")
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();
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, 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, 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")}}, "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 = require_executable(args.streamlit, "Streamlit")
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)
@@ -308,56 +510,105 @@ def main() -> int:
env = os.environ.copy()
env["JARCA_LEDGER_DB_PATH"] = str(db_path.resolve())
env["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
base_url = f"http://{args.host}:{args.port}"
server_cmd = [
streamlit,
"run",
"dashboard/app.py",
"--server.address",
args.host,
"--server.port",
str(args.port),
"--server.headless",
"true",
"--browser.gatherUsageStats",
"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": fixture,
"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)
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))
manifest["success"] = True
print(json.dumps(manifest, ensure_ascii=False, indent=2))
return 0
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:
log_tail = ""
if server.stdout is not None:
try:
time.sleep(0.5)
if server.poll() is not None:
log_tail = server.stdout.read()[-4000:]
except Exception:
log_tail = ""
manifest["server_log_tail"] = log_tail
(output_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
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__":