"""
Phase 3.5: Automated Runtime UI Capture via ADB + UIAutomator
Automatically builds, installs, and captures runtime UI data from each screen.
Supports Compose apps by navigating via bottom tabs and capturing UIAutomator dumps
plus screenshots.
Zero code invasion — pure ADB-based approach.
Usage:
python3 runtime_dump.py <project_root> <output_dir>
Prerequisites:
- ADB accessible in PATH
- Connected device/emulator (adb devices shows at least one)
- Gradle wrapper in project root (for building debug APK)
Output:
<output_dir>/runtime_dumps/<ScreenName>.xml — UIAutomator dump
<output_dir>/runtime_dumps/<ScreenName>.png — Screenshot
<output_dir>/runtime_dumps/<ScreenName>_scrolled.xml — After-scroll dump (if scrollable)
<output_dir>/runtime_dumps/runtime_summary.json — Summary with status per screen
"""
import json
import os
import re
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
def run_adb(args, timeout=15):
"""Run an adb command and return (returncode, stdout, stderr)."""
cmd = ["adb"] + args
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return result.returncode, result.stdout.strip(), result.stderr.strip()
except subprocess.TimeoutExpired:
return -1, "", "timeout"
except FileNotFoundError:
return -1, "", "adb not found"
def check_device() -> tuple[bool, str]:
"""Check that adb is available and a device/emulator is connected."""
rc, out, err = run_adb(["version"])
if rc != 0:
return False, "adb not found or not working"
rc, out, err = run_adb(["devices"])
lines = [l for l in out.split("\n") if l.strip() and not l.startswith("List")]
devices = [l for l in lines if "device" in l and "offline" not in l and "unauthorized" not in l]
if not devices:
return False, "No connected device/emulator found"
return True, f"Device ready ({devices[0].split()[0]})"
def check_app_installed(package: str) -> bool:
"""Check if an app is installed on the device."""
rc, out, err = run_adb(["shell", "pm", "list", "packages", package])
return package in out
def is_app_debuggable(package: str) -> bool:
"""Check if the installed app is a debug build (debuggable=true).
Non-exported Activities can only be launched via adb on debug builds."""
rc, out, err = run_adb(["shell", "run-as", package, "true"])
return rc == 0
def find_sample_media_on_device() -> dict:
"""Find sample image and video files on the device for launching intent-data Activities.
Returns dict with 'image' and 'video' keys pointing to file URIs."""
media = {"image": None, "video": None}
rc, out, _ = run_adb(["shell", "find", "/sdcard/DCIM", "/sdcard/Pictures", "/sdcard/Download",
"-maxdepth", "3", "-type", "f",
"(", "-name", "*.jpg", "-o", "-name", "*.png", ")"],
timeout=10)
if rc == 0 and out.strip():
first_file = out.strip().split("\n")[0]
media["image"] = f"file://{first_file}"
rc, out, _ = run_adb(["shell", "find", "/sdcard/DCIM", "/sdcard/Movies", "/sdcard/Download",
"-maxdepth", "3", "-type", "f",
"(", "-name", "*.mp4", "-o", "-name", "*.3gp", ")"],
timeout=10)
if rc == 0 and out.strip():
first_file = out.strip().split("\n")[0]
media["video"] = f"file://{first_file}"
return media
def launch_activity_with_data(package: str, activity_class: str,
data_uri: str, mime_type: str) -> tuple[bool, str]:
"""Launch an activity with intent data URI and MIME type."""
if activity_class.startswith(package + "."):
suffix = activity_class[len(package):]
component = f"{package}/{suffix}"
elif "." in activity_class and not activity_class.startswith("."):
component = f"{package}/{activity_class}"
elif activity_class.startswith("."):
component = f"{package}/{activity_class}"
else:
component = f"{package}/.{activity_class}"
rc, out, err = run_adb([
"shell", "am", "start", "--user", "0",
"-n", component,
"-d", data_uri,
"-t", mime_type,
], timeout=10)
if rc != 0 or "Error" in err or "Error" in out:
return False, f"Failed to launch with data: {(err or out).split(chr(10))[0]}"
time.sleep(3)
return True, "Launched with data"
def install_apk(apk_path: str) -> tuple[bool, str]:
"""Install an APK on the connected device."""
rc, out, err = run_adb(["install", "-r", "-t", apk_path], timeout=120)
if rc == 0 and "Success" in out:
return True, "Installed"
return False, f"Install failed: {err or out}"
def launch_activity(package: str, activity_class: str) -> tuple[bool, str]:
"""Launch an activity via ADB."""
if activity_class.startswith(package + "."):
suffix = activity_class[len(package):]
component = f"{package}/{suffix}"
elif "." in activity_class and not activity_class.startswith("."):
component = f"{package}/{activity_class}"
elif activity_class.startswith("."):
component = f"{package}/{activity_class}"
else:
component = f"{package}/.{activity_class}"
rc, out, err = run_adb(["shell", "am", "start", "--user", "0", "-n", component], timeout=10)
if rc != 0 or "Error" in err or "Error" in out:
return False, f"Failed to launch: {(err or out).split(chr(10))[0]}"
time.sleep(3)
return True, "Launched"
def dump_ui(output_path: str) -> tuple[bool, str]:
"""Capture uiautomator dump and pull to local path."""
remote_path = "/sdcard/ui_dump.xml"
run_adb(["shell", "rm", "-f", remote_path])
rc, out, err = run_adb(["shell", "uiautomator", "dump", remote_path], timeout=15)
if rc != 0 or "error" in (out + err).lower():
return False, f"uiautomator dump failed: {err or out}"
rc, out, err = run_adb(["pull", remote_path, output_path], timeout=10)
if rc != 0:
return False, f"adb pull failed: {err}"
if not os.path.isfile(output_path) or os.path.getsize(output_path) < 100:
return False, "Dump file empty or missing"
return True, "OK"
def take_screenshot(output_path: str) -> tuple[bool, str]:
"""Capture a screenshot and pull to local path."""
remote_path = "/sdcard/screenshot.png"
rc, out, err = run_adb(["shell", "screencap", "-p", remote_path], timeout=10)
if rc != 0:
return False, f"screencap failed: {err}"
rc, out, err = run_adb(["pull", remote_path, output_path], timeout=10)
if rc != 0:
return False, f"pull failed: {err}"
run_adb(["shell", "rm", "-f", remote_path])
return True, "OK"
def tap(x: int, y: int):
"""Tap a screen coordinate."""
run_adb(["shell", "input", "tap", str(x), str(y)])
time.sleep(0.3)
def swipe_up(cx: int, start_y: int, end_y: int, duration_ms: int = 500):
"""Swipe up (scroll down) on the screen."""
run_adb(["shell", "input", "swipe", str(cx), str(start_y), str(cx), str(end_y), str(duration_ms)])
time.sleep(1)
def press_back():
"""Press the back button."""
run_adb(["shell", "input", "keyevent", "KEYCODE_BACK"])
time.sleep(0.5)
def press_home():
"""Press the home button."""
run_adb(["shell", "input", "keyevent", "KEYCODE_HOME"])
time.sleep(1)
def get_screen_size() -> tuple[int, int]:
"""Get device screen width and height."""
rc, out, err = run_adb(["shell", "wm", "size"])
m = re.search(r'(\d+)x(\d+)', out)
if m:
return int(m.group(1)), int(m.group(2))
return 1080, 2400
def find_nav_items(dump_path: str) -> list[dict]:
"""Parse UIAutomator dump XML to find bottom navigation tab items.
Handles two patterns:
A) Clickable nodes with text/content-desc in bottom area
B) Clickable container Views in bottom area with child TextViews (Compose pattern)
Returns list of {text, label, center_x, center_y, bounds, content_desc}.
"""
try:
tree = ET.parse(dump_path)
except ET.ParseError:
return []
items = []
root = tree.getroot()
all_nodes = list(root.iter("node"))
screen_h = 0
for node in all_nodes:
bounds = node.get("bounds", "")
m = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds)
if m:
screen_h = max(screen_h, int(m.group(4)))
if screen_h == 0:
return []
bottom_threshold = screen_h * 0.85
for node in all_nodes:
text = node.get("text", "")
desc = node.get("content-desc", "")
clickable = node.get("clickable", "false") == "true"
bounds = node.get("bounds", "")
m = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds)
if not m:
continue
left, top, right, bottom = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
if top < bottom_threshold:
continue
label = text or desc
if not label or not clickable:
continue
width, height = right - left, bottom - top
if width > screen_h * 0.5 or height > screen_h * 0.15:
continue
items.append({
"text": text, "content_desc": desc, "label": label,
"center_x": (left + right) // 2, "center_y": (top + bottom) // 2,
"bounds": {"left": left, "top": top, "right": right, "bottom": bottom},
})
if not items:
for node in all_nodes:
clickable = node.get("clickable", "false") == "true"
bounds = node.get("bounds", "")
m = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds)
if not m:
continue
left, top, right, bottom = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
if top < bottom_threshold or not clickable:
continue
width, height = right - left, bottom - top
if width > screen_h * 0.4 or width < 50 or height > screen_h * 0.15 or height < 50:
continue
label = ""
for child in all_nodes:
child_text = child.get("text", "") or child.get("content-desc", "")
if not child_text:
continue
cb = child.get("bounds", "")
cm = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', cb)
if not cm:
continue
cl, ct, cr, cbot = int(cm.group(1)), int(cm.group(2)), int(cm.group(3)), int(cm.group(4))
if cl >= left and ct >= top and cr <= right and cbot <= bottom:
label = child_text
break
if label:
items.append({
"text": label, "content_desc": "", "label": label,
"center_x": (left + right) // 2, "center_y": (top + bottom) // 2,
"bounds": {"left": left, "top": top, "right": right, "bottom": bottom},
})
unique = []
for item in items:
is_dup = False
for existing in unique:
if abs(item["center_x"] - existing["center_x"]) < 30 and abs(item["center_y"] - existing["center_y"]) < 30:
is_dup = True
break
if not is_dup:
unique.append(item)
unique.sort(key=lambda x: x["center_x"])
return unique
def count_nodes(dump_path: str) -> int:
"""Count nodes in a UIAutomator dump XML."""
try:
tree = ET.parse(dump_path)
return sum(1 for _ in tree.getroot().iter())
except Exception:
return 0
def detect_package(project_root: str, discovery_path: str | None = None) -> str | None:
"""Detect the app package name from discovery.json, build.gradle, or AndroidManifest.xml."""
if discovery_path and os.path.isfile(discovery_path):
with open(discovery_path, "r") as f:
discovery = json.load(f)
pkg = discovery.get("package")
if pkg:
return pkg
for root, _, files in os.walk(project_root):
if "/build/" in root or "\\build\\" in root:
continue
for f in files:
if f in ("build.gradle.kts", "build.gradle") and "app" in root:
fpath = os.path.join(root, f)
try:
with open(fpath, "r") as fh:
content = fh.read()
m = re.search(r'applicationId\s*=?\s*"([^"]+)"', content)
if m:
return m.group(1)
m = re.search(r'namespace\s*=?\s*"([^"]+)"', content)
if m:
return m.group(1)
except Exception:
pass
if discovery_path and os.path.isfile(discovery_path):
with open(discovery_path, "r") as f:
discovery = json.load(f)
activities = discovery.get("activities", [])
for act in activities:
cls = act.get("class", "")
if "." in cls and not cls.startswith("."):
parts = cls.rsplit(".", 1)
if len(parts) == 2 and parts[0].count(".") >= 1:
return parts[0]
manifest_path = None
for root, _, files in os.walk(project_root):
if "AndroidManifest.xml" in files:
candidate = os.path.join(root, "AndroidManifest.xml")
if "app/src/main" in candidate:
manifest_path = candidate
break
if manifest_path is None:
manifest_path = candidate
if manifest_path:
tree = ET.parse(manifest_path)
root = tree.getroot()
package = root.get("package")
if package:
return package
return None
def find_debug_apk(project_root: str) -> str | None:
"""Find a debug APK in the project's build output."""
apk_dir = os.path.join(project_root, "app", "build", "outputs", "apk")
if not os.path.isdir(apk_dir):
return None
for root, _, files in os.walk(apk_dir):
for f in files:
if f.endswith(".apk") and "debug" in f.lower():
return os.path.join(root, f)
return None
def build_debug_apk(project_root: str) -> tuple[bool, str]:
"""Build a debug APK using Gradle.
Auto-detects product flavors and JAVA_HOME for JDK 17.
"""
gradlew = os.path.join(project_root, "gradlew")
if not os.path.isfile(gradlew):
return False, "gradlew not found"
os.chmod(gradlew, 0o755)
env = os.environ.copy()
if sys.platform == "darwin":
try:
result = subprocess.run(
["/usr/libexec/java_home", "-v", "17"],
capture_output=True, text=True, timeout=5)
if result.returncode == 0 and result.stdout.strip():
env["JAVA_HOME"] = result.stdout.strip()
print(f" Using JAVA_HOME: {env['JAVA_HOME']}")
except Exception:
pass
build_task = "assembleDebug"
for build_file in ["app/build.gradle.kts", "app/build.gradle"]:
build_path = os.path.join(project_root, build_file)
if os.path.isfile(build_path):
with open(build_path, "r") as f:
content = f.read()
if "productFlavors" in content:
for flavor in ["foss", "free", "fdroid", "oss"]:
if f'"{flavor}"' in content or f"'{flavor}'" in content:
build_task = f"assemble{flavor.capitalize()}Debug"
print(f" Detected product flavor: {flavor} -> {build_task}")
break
else:
m = re.search(r'register\("(\w+)"\)', content)
if m:
flavor = m.group(1)
build_task = f"assemble{flavor.capitalize()}Debug"
print(f" Using first flavor: {flavor} -> {build_task}")
break
print(f" Building debug APK ({build_task}, this may take a few minutes)...")
try:
result = subprocess.run(
[gradlew, build_task, "-x", "lint", "-x", "test"],
cwd=project_root,
capture_output=True,
text=True,
timeout=600,
env=env,
)
if result.returncode != 0:
error_lines = [l for l in result.stderr.split("\n") if "ERROR" in l or "FAILURE" in l]
return False, f"Build failed: {' '.join(error_lines[:3])}" if error_lines else "Build failed"
return True, "Build successful"
except subprocess.TimeoutExpired:
return False, "Build timed out (10 minutes)"
def capture_activity(package: str, activity: dict, dumps_dir: str,
sample_media: dict | None = None) -> dict:
"""Capture runtime UI for a single Activity.
Args:
sample_media: dict with 'image' and 'video' file URIs for intent-data Activities.
"""
simple_name = activity["simple_name"]
activity_class = activity["class"]
if "splash" in simple_name.lower():
return {"screen": simple_name, "status": "skipped", "reason": "splash screen"}
skip_keywords = ["WXEntryActivity", "WXPayEntryActivity", "GenLoginAuthActivity"]
if simple_name in skip_keywords:
return {"screen": simple_name, "status": "skipped", "reason": "SDK callback activity"}
intent_data_hints = {
"image": ["Photo", "Edit", "Wallpaper", "Panorama"],
"video": ["Video"],
}
needs_data_type = None
for media_type, keywords in intent_data_hints.items():
if any(kw.lower() in simple_name.lower() for kw in keywords):
needs_data_type = media_type
break
press_home()
ok, msg = launch_activity(package, activity_class)
if not ok and needs_data_type and sample_media:
data_uri = sample_media.get(needs_data_type)
mime_type = f"{needs_data_type}/*"
if data_uri:
ok, msg = launch_activity_with_data(package, activity_class, data_uri, mime_type)
if not ok and "SecurityException" not in msg:
time.sleep(2)
press_home()
ok, msg = launch_activity(package, activity_class)
if not ok:
return {"screen": simple_name, "status": "failed", "reason": msg}
dump_path = os.path.join(dumps_dir, f"{simple_name}.xml")
ok, msg = dump_ui(dump_path)
if not ok:
return {"screen": simple_name, "status": "failed", "reason": msg}
screenshot_path = os.path.join(dumps_dir, f"{simple_name}.png")
take_screenshot(screenshot_path)
nodes = count_nodes(dump_path)
return {
"screen": simple_name,
"status": "success",
"nodes": nodes,
"dump_file": os.path.basename(dump_path),
"screenshot_file": os.path.basename(screenshot_path),
}
def dismiss_dialogs(max_attempts: int = 3):
"""Auto-dismiss common startup dialogs (privacy policy, permissions, updates, etc.).
Looks for common button texts and taps them.
"""
dismiss_texts = {
"同意并继续", "同意", "确定", "我知道了", "允许", "始终允许", "下一步", "跳过",
"AGREE", "ACCEPT", "OK", "ALLOW", "GOT IT", "CONTINUE",
"I AGREE", "NEXT", "SKIP", "同意并进入",
}
for attempt in range(max_attempts):
time.sleep(2)
tmp_dump = f"/tmp/_dialog_check_{attempt}.xml"
ok, _ = dump_ui(tmp_dump)
if not ok:
break
try:
tree = ET.parse(tmp_dump)
except ET.ParseError:
break
found_dialog_button = False
for node in tree.getroot().iter("node"):
text = (node.get("text", "") or "").strip()
bounds = node.get("bounds", "")
if not text:
continue
if text in dismiss_texts:
m = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds)
if m:
cx = (int(m.group(1)) + int(m.group(3))) // 2
cy = (int(m.group(2)) + int(m.group(4))) // 2
print(f" Dismissing dialog: '{text}' at ({cx}, {cy})")
tap(cx, cy)
time.sleep(2)
found_dialog_button = True
break
try:
os.remove(tmp_dump)
except OSError:
pass
if not found_dialog_button:
break
def capture_tab_screens(package: str, main_activity: dict, tab_names: list[str],
dumps_dir: str) -> list[dict]:
"""Navigate through bottom nav tabs and capture each screen.
Returns list of capture results.
"""
results = []
activity_class = main_activity["class"]
press_home()
ok, msg = launch_activity(package, activity_class)
if not ok:
return [{"screen": "MainActivity_tabs", "status": "failed", "reason": msg}]
dismiss_dialogs()
initial_dump = os.path.join(dumps_dir, "_initial_nav_detect.xml")
ok, msg = dump_ui(initial_dump)
if not ok:
return [{"screen": "MainActivity_tabs", "status": "failed", "reason": f"Initial dump: {msg}"}]
nav_items = find_nav_items(initial_dump)
if not nav_items:
print(" No bottom nav items detected — capturing current screen only")
dump_path = os.path.join(dumps_dir, "Tab_Home.xml")
os.rename(initial_dump, dump_path)
screenshot_path = os.path.join(dumps_dir, "Tab_Home.png")
take_screenshot(screenshot_path)
return [{
"screen": "Tab_Home",
"status": "success",
"nodes": count_nodes(dump_path),
"dump_file": "Tab_Home.xml",
"screenshot_file": "Tab_Home.png",
}]
os.remove(initial_dump)
print(f" Found {len(nav_items)} bottom nav items: {[i['label'] for i in nav_items]}")
screen_w, screen_h = get_screen_size()
for idx, item in enumerate(nav_items):
label = item["label"]
safe_name = re.sub(r'[^\w]', '_', label)
screen_name = f"Tab_{safe_name}"
print(f" Tab {idx + 1}/{len(nav_items)}: {label}...", end=" ", flush=True)
tap(item["center_x"], item["center_y"])
time.sleep(2)
dump_path = os.path.join(dumps_dir, f"{screen_name}.xml")
ok, msg = dump_ui(dump_path)
if not ok:
print(f"FAIL ({msg})")
results.append({"screen": screen_name, "status": "failed", "reason": msg})
continue
screenshot_path = os.path.join(dumps_dir, f"{screen_name}.png")
take_screenshot(screenshot_path)
nodes = count_nodes(dump_path)
print(f"OK ({nodes} nodes)")
result = {
"screen": screen_name,
"tab_label": label,
"tab_index": idx,
"status": "success",
"nodes": nodes,
"dump_file": f"{screen_name}.xml",
"screenshot_file": f"{screen_name}.png",
}
cx = screen_w // 2
swipe_up(cx, int(screen_h * 0.7), int(screen_h * 0.3))
scrolled_dump = os.path.join(dumps_dir, f"{screen_name}_scrolled.xml")
ok_s, _ = dump_ui(scrolled_dump)
if ok_s:
scrolled_nodes = count_nodes(scrolled_dump)
if scrolled_nodes != nodes:
result["scrolled_dump_file"] = f"{screen_name}_scrolled.xml"
result["scrolled_nodes"] = scrolled_nodes
else:
os.remove(scrolled_dump)
swipe_up(cx, int(screen_h * 0.3), int(screen_h * 0.7))
time.sleep(0.5)
results.append(result)
return results
def main():
if len(sys.argv) < 3:
print("Usage: python3 runtime_dump.py <project_root> <output_dir>")
sys.exit(1)
project_root = os.path.abspath(sys.argv[1])
output_dir = os.path.abspath(sys.argv[2])
print("[Phase 3.5] Automated Runtime UI Capture")
ok, msg = check_device()
print(f" Device check: {msg}")
if not ok:
print(" No device available — skipping runtime capture (static analysis only)")
sys.exit(0)
rc, out, _ = run_adb(["root"], timeout=10)
if rc == 0 and "root" in out.lower():
print(f" ADB root: enabled (can launch non-exported Activities on Android 12+)")
time.sleep(2)
else:
print(f" ADB root: not available (physical device or production emulator image)")
discovery_path = os.path.join(output_dir, "discovery.json")
package = detect_package(project_root, discovery_path)
if not package:
print(" ERROR: Could not detect app package name")
sys.exit(1)
print(f" Package: {package}")
if not check_app_installed(package):
print(" App not installed — building and installing debug APK...")
apk_path = find_debug_apk(project_root)
if not apk_path:
ok, msg = build_debug_apk(project_root)
if not ok:
print(f" Build failed: {msg}")
print(" Skipping runtime capture")
sys.exit(0)
apk_path = find_debug_apk(project_root)
if apk_path:
print(f" Installing: {os.path.basename(apk_path)}")
ok, msg = install_apk(apk_path)
if not ok:
print(f" Install failed: {msg}")
sys.exit(0)
print(f" {msg}")
else:
print(" No debug APK found after build — skipping")
sys.exit(0)
else:
print(f" App already installed")
debuggable = is_app_debuggable(package)
if debuggable:
print(f" Debug build: YES — can launch non-exported Activities")
else:
print(f" WARNING: Release build detected — non-exported Activities will fail!")
print(f" To fix: build and install a debug APK, then re-run this script.")
print(f" cd {project_root} && ./gradlew assembleDebug && adb install -r <debug-apk>")
sample_media = find_sample_media_on_device()
if sample_media.get("image"):
print(f" Sample image found: {sample_media['image']}")
else:
print(f" No sample image found — intent-data Activities needing images will be skipped")
if sample_media.get("video"):
print(f" Sample video found: {sample_media['video']}")
else:
print(f" No sample video found — intent-data Activities needing videos will be skipped")
discovery = None
if os.path.isfile(discovery_path):
with open(discovery_path, "r") as f:
discovery = json.load(f)
dumps_dir = os.path.join(output_dir, "runtime_dumps")
os.makedirs(dumps_dir, exist_ok=True)
all_results = []
activities = discovery.get("activities", []) if discovery else []
main_activity = None
if activities:
print(f"\n Capturing {len(activities)} Activities...")
for activity in activities:
if activity.get("is_launcher"):
main_activity = activity
result = capture_activity(package, activity, dumps_dir, sample_media)
status = result["status"]
icon = "+" if status == "success" else ("~" if status == "skipped" else "!")
nodes_str = f" ({result.get('nodes', 0)} nodes)" if status == "success" else f" ({result.get('reason', '')})"
print(f" [{icon}] {result['screen']}{nodes_str}")
all_results.append(result)
if main_activity:
print(f"\n Navigating bottom tabs...")
tab_results = capture_tab_screens(package, main_activity, [], dumps_dir)
all_results.extend(tab_results)
press_home()
success = sum(1 for r in all_results if r["status"] == "success")
skipped = sum(1 for r in all_results if r["status"] == "skipped")
failed = sum(1 for r in all_results if r["status"] == "failed")
summary = {
"package": package,
"total_screens": len(all_results),
"success": success,
"skipped": skipped,
"failed": failed,
"results": all_results,
}
summary_path = os.path.join(dumps_dir, "runtime_summary.json")
with open(summary_path, "w", encoding="utf-8") as f:
json.dump(summary, f, indent=2, ensure_ascii=False)
print(f"\n[Phase 3.5 Complete]")
print(f" Success: {success}")
print(f" Skipped: {skipped}")
print(f" Failed: {failed}")
print(f" Output: {dumps_dir}/")
if __name__ == "__main__":
main()