Files
dotfiles/.agents/skills/ios-simulator-skill/scripts/app_state_capture.py
2026-02-19 00:33:08 -08:00

392 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
"""
App State Capture for iOS Simulator
Captures complete app state including screenshot, accessibility tree, and logs.
Optimized for minimal token output.
Usage: python scripts/app_state_capture.py [options]
"""
import argparse
import json
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from common import (
capture_screenshot,
count_elements,
get_accessibility_tree,
resolve_udid,
)
class AppStateCapture:
"""Captures comprehensive app state for debugging."""
def __init__(
self,
app_bundle_id: str | None = None,
udid: str | None = None,
inline: bool = False,
screenshot_size: str = "half",
):
"""
Initialize state capture.
Args:
app_bundle_id: Optional app bundle ID for log filtering
udid: Optional device UDID (uses booted if not specified)
inline: If True, return screenshots as base64 (for vision-based automation)
screenshot_size: 'full', 'half', 'quarter', 'thumb' (default: 'half')
"""
self.app_bundle_id = app_bundle_id
self.udid = udid
self.inline = inline
self.screenshot_size = screenshot_size
def capture_screenshot(self, output_path: Path) -> bool:
"""Capture screenshot of current screen."""
cmd = ["xcrun", "simctl", "io"]
if self.udid:
cmd.append(self.udid)
else:
cmd.append("booted")
cmd.extend(["screenshot", str(output_path)])
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def capture_accessibility_tree(self, output_path: Path) -> dict:
"""Capture accessibility tree using shared utility."""
try:
# Use shared utility to fetch tree
tree = get_accessibility_tree(self.udid, nested=True)
# Save tree
with open(output_path, "w") as f:
json.dump(tree, f, indent=2)
# Return summary using shared utility
return {"captured": True, "element_count": count_elements(tree)}
except Exception as e:
return {"captured": False, "error": str(e)}
def capture_logs(self, output_path: Path, line_limit: int = 100) -> dict:
"""Capture recent app logs."""
if not self.app_bundle_id:
# Can't capture logs without app ID
return {"captured": False, "reason": "No app bundle ID specified"}
# Get app name from bundle ID (simplified)
app_name = self.app_bundle_id.split(".")[-1]
cmd = ["xcrun", "simctl", "spawn"]
if self.udid:
cmd.append(self.udid)
else:
cmd.append("booted")
cmd.extend(
[
"log",
"show",
"--predicate",
f'process == "{app_name}"',
"--last",
"1m", # Last 1 minute
"--style",
"compact",
]
)
try:
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=5)
logs = result.stdout
# Limit lines for token efficiency
lines = logs.split("\n")
if len(lines) > line_limit:
lines = lines[-line_limit:]
# Save logs
with open(output_path, "w") as f:
f.write("\n".join(lines))
# Analyze for issues
warning_count = sum(1 for line in lines if "warning" in line.lower())
error_count = sum(1 for line in lines if "error" in line.lower())
return {
"captured": True,
"lines": len(lines),
"warnings": warning_count,
"errors": error_count,
}
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
return {"captured": False, "error": str(e)}
def capture_device_info(self) -> dict:
"""Get device information."""
cmd = ["xcrun", "simctl", "list", "devices", "booted"]
if self.udid:
# Specific device info
cmd = ["xcrun", "simctl", "list", "devices"]
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
# Parse output for device info (simplified)
lines = result.stdout.split("\n")
device_info = {}
for line in lines:
if "iPhone" in line or "iPad" in line:
# Extract device name and state
parts = line.strip().split("(")
if parts:
device_info["name"] = parts[0].strip()
if len(parts) > 2:
device_info["udid"] = parts[1].replace(")", "").strip()
device_info["state"] = parts[2].replace(")", "").strip()
break
return device_info
except subprocess.CalledProcessError:
return {}
def capture_all(
self, output_dir: str, log_lines: int = 100, app_name: str | None = None
) -> dict:
"""
Capture complete app state.
Args:
output_dir: Directory to save artifacts
log_lines: Number of log lines to capture
app_name: App name for semantic naming (for inline mode)
Returns:
Summary of captured state
"""
# Create output directory (only if not in inline mode)
output_path = Path(output_dir)
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
if not self.inline:
capture_dir = output_path / f"app-state-{timestamp}"
capture_dir.mkdir(parents=True, exist_ok=True)
else:
capture_dir = None
summary = {
"timestamp": datetime.now().isoformat(),
"screenshot_mode": "inline" if self.inline else "file",
}
if capture_dir:
summary["output_dir"] = str(capture_dir)
# Capture screenshot using new unified utility
screenshot_result = capture_screenshot(
self.udid,
size=self.screenshot_size,
inline=self.inline,
app_name=app_name,
)
if self.inline:
# Inline mode: store base64
summary["screenshot"] = {
"mode": "inline",
"base64": screenshot_result["base64_data"],
"width": screenshot_result["width"],
"height": screenshot_result["height"],
"size_preset": self.screenshot_size,
}
else:
# File mode: save to disk
screenshot_path = capture_dir / "screenshot.png"
# Move temp file to target location
import shutil
shutil.move(screenshot_result["file_path"], screenshot_path)
summary["screenshot"] = {
"mode": "file",
"file": "screenshot.png",
"size_bytes": screenshot_result["size_bytes"],
}
# Capture accessibility tree
if not self.inline or capture_dir:
accessibility_path = (capture_dir or output_path) / "accessibility-tree.json"
else:
accessibility_path = None
if accessibility_path:
tree_info = self.capture_accessibility_tree(accessibility_path)
summary["accessibility"] = tree_info
# Capture logs (if app ID provided)
if self.app_bundle_id:
if not self.inline or capture_dir:
logs_path = (capture_dir or output_path) / "app-logs.txt"
else:
logs_path = None
if logs_path:
log_info = self.capture_logs(logs_path, log_lines)
summary["logs"] = log_info
# Get device info
device_info = self.capture_device_info()
if device_info:
summary["device"] = device_info
# Save device info (file mode only)
if capture_dir:
with open(capture_dir / "device-info.json", "w") as f:
json.dump(device_info, f, indent=2)
# Save summary (file mode only)
if capture_dir:
with open(capture_dir / "summary.json", "w") as f:
json.dump(summary, f, indent=2)
# Create markdown summary
self._create_summary_md(capture_dir, summary)
return summary
def _create_summary_md(self, capture_dir: Path, summary: dict) -> None:
"""Create markdown summary file."""
md_path = capture_dir / "summary.md"
with open(md_path, "w") as f:
f.write("# App State Capture\n\n")
f.write(f"**Timestamp:** {summary['timestamp']}\n\n")
if "device" in summary:
f.write("## Device\n")
device = summary["device"]
f.write(f"- Name: {device.get('name', 'Unknown')}\n")
f.write(f"- UDID: {device.get('udid', 'N/A')}\n")
f.write(f"- State: {device.get('state', 'Unknown')}\n\n")
f.write("## Screenshot\n")
f.write("![Current Screen](screenshot.png)\n\n")
if "accessibility" in summary:
acc = summary["accessibility"]
f.write("## Accessibility\n")
if acc.get("captured"):
f.write(f"- Elements: {acc.get('element_count', 0)}\n")
else:
f.write(f"- Error: {acc.get('error', 'Unknown')}\n")
f.write("\n")
if "logs" in summary:
logs = summary["logs"]
f.write("## Logs\n")
if logs.get("captured"):
f.write(f"- Lines: {logs.get('lines', 0)}\n")
f.write(f"- Warnings: {logs.get('warnings', 0)}\n")
f.write(f"- Errors: {logs.get('errors', 0)}\n")
else:
f.write(f"- {logs.get('reason', logs.get('error', 'Not captured'))}\n")
f.write("\n")
f.write("## Files\n")
f.write("- `screenshot.png` - Current screen\n")
f.write("- `accessibility-tree.json` - Full UI hierarchy\n")
if self.app_bundle_id:
f.write("- `app-logs.txt` - Recent app logs\n")
f.write("- `device-info.json` - Device details\n")
f.write("- `summary.json` - Complete capture metadata\n")
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Capture complete app state for debugging")
parser.add_argument(
"--app-bundle-id", help="App bundle ID for log filtering (e.g., com.example.app)"
)
parser.add_argument(
"--output", default=".", help="Output directory (default: current directory)"
)
parser.add_argument(
"--log-lines", type=int, default=100, help="Number of log lines to capture (default: 100)"
)
parser.add_argument(
"--udid",
help="Device UDID (auto-detects booted simulator if not provided)",
)
parser.add_argument(
"--inline",
action="store_true",
help="Return screenshots as base64 (inline mode for vision-based automation)",
)
parser.add_argument(
"--size",
choices=["full", "half", "quarter", "thumb"],
default="half",
help="Screenshot size for token optimization (default: half)",
)
parser.add_argument("--app-name", help="App name for semantic screenshot naming")
args = parser.parse_args()
# Resolve UDID with auto-detection
try:
udid = resolve_udid(args.udid)
except RuntimeError as e:
print(f"Error: {e}")
sys.exit(1)
# Create capturer
capturer = AppStateCapture(
app_bundle_id=args.app_bundle_id,
udid=udid,
inline=args.inline,
screenshot_size=args.size,
)
# Capture state
try:
summary = capturer.capture_all(
output_dir=args.output, log_lines=args.log_lines, app_name=args.app_name
)
# Token-efficient output
if "output_dir" in summary:
print(f"State captured: {summary['output_dir']}/")
else:
# Inline mode
print(
f"State captured (inline mode): {summary['screenshot']['width']}x{summary['screenshot']['height']}"
)
# Report any issues found
if "logs" in summary and summary["logs"].get("captured"):
logs = summary["logs"]
if logs["errors"] > 0 or logs["warnings"] > 0:
print(f"Issues found: {logs['errors']} errors, {logs['warnings']} warnings")
if "accessibility" in summary and summary["accessibility"].get("captured"):
print(f"Elements: {summary['accessibility']['element_count']}")
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()