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:
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user