mirror of
https://github.com/ksyasuda/dotfiles.git
synced 2026-02-27 12:22:43 -08:00
392 lines
13 KiB
Python
Executable File
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("\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()
|