mirror of
https://github.com/ksyasuda/dotfiles.git
synced 2026-02-27 12:22:43 -08:00
update
This commit is contained in:
391
.agents/skills/ios-simulator-skill/scripts/app_state_capture.py
Executable file
391
.agents/skills/ios-simulator-skill/scripts/app_state_capture.py
Executable file
@@ -0,0 +1,391 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user