
Ios Simulator Skill
- 2k installs
- 1.2k repo stars
- Updated June 18, 2026
- conorluddy/ios-simulator-skill
ios-simulator-skill is an agent skill that 29 production-ready scripts for iOS app testing, building, and automation. Provides semantic UI navigation, build automa.
About
Build test and automate iOS applications using accessibility driven navigation and structured data instead of pixel coordinates bash 1 Check environment bash scripts sim_health_check sh 2 Launch app python scripts app_launcher py launch com example app 3 Map screen to see elements python scripts screen_mapper py 4 Tap button python scripts navigator py find text Login tap The ios simulator skill agent skill provides documented workflows prerequisites triggers and safety guidance from its SKILL md source Agents load it when user requests match the description and follow step by step instructions without inventing capabilities It integrates with standard agent tooling for the tasks inputs outputs and failure modes described in the repository documentation
- description: 29 production-ready scripts for iOS app testing, building, and automation. Provides semantic UI navigation,
- Build, test, and automate iOS applications using accessibility-driven navigation and structured data instead of pixel co
- python scripts/app_launcher.py --launch com.example.app
- Follow ios-simulator-skill SKILL.md steps and documented constraints.
- Follow ios-simulator-skill SKILL.md steps and documented constraints.
Ios Simulator Skill by the numbers
- 1,997 all-time installs (skills.sh)
- +33 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #610 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
ios-simulator-skill capabilities & compatibility
- Capabilities
- description: 29 production ready scripts for ios · build, test, and automate ios applications using · python scripts/app_launcher.py launch com.exam · follow ios simulator skill skill.md steps and do
- Use cases
- orchestration
What ios-simulator-skill says it does
description: 29 production-ready scripts for iOS app testing, building, and automation. Provides semantic UI navigation, build automation, accessibility testing, and simulator lifecycle management. Op
Build, test, and automate iOS applications using accessibility-driven navigation and structured data instead of pixel coordinates.
python scripts/app_launcher.py --launch com.example.app
/plugin marketplace add conorluddy/ios-simulator-skill/plugin install ios-simulator-skill@conorluddyAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2k |
|---|---|
| repo stars | ★ 1.2k |
| Security audit | 2 / 3 scanners passed |
| Last updated | June 18, 2026 |
| Repository | conorluddy/ios-simulator-skill ↗ |
When should an agent use ios-simulator-skill and what problem does it solve?
29 production-ready scripts for iOS app testing, building, and automation. Provides semantic UI navigation, build automation, accessibility testing, and simulator lifecycle management. Optimized for A
Who is it for?
Developers invoking ios-simulator-skill as documented in the skill source.
Skip if: Skip when requirements fall outside ios-simulator-skill documented scope.
When should I use this skill?
29 production-ready scripts for iOS app testing, building, and automation. Provides semantic UI navigation, build automation, accessibility testing, and simulator lifecycle management. Optimized for A
What you get
Outputs aligned with the ios-simulator-skill SKILL.md workflow and stated deliverables.
- Simulator build outputs
- Automated test run results
By the numbers
- Bundles 29 production-ready scripts for iOS testing, building, and automation
Files
iOS Simulator Skill
Build, test, and automate iOS applications using accessibility-driven navigation and structured data instead of pixel coordinates.
Quick Start
# 1. Check environment
bash scripts/sim_health_check.sh
# 2. Launch app
python scripts/app_launcher.py --launch com.example.app
# 3. Map screen to see elements
python scripts/screen_mapper.py
# 4. Tap button
python scripts/navigator.py --find-text "Login" --tap
# 5. Enter text
python scripts/navigator.py --find-type TextField --enter-text "user@example.com"All scripts support --help for detailed options and --json for machine-readable output.
Navigation Strategy
Always prefer the accessibility tree over screenshots for navigation. The accessibility tree gives you element types, labels, frames, and tap targets — structured data that's cheaper and more reliable than image analysis.
Use this priority: 1. screen_mapper.py → structured element list (5-7 lines, ~10 tokens) 2. navigator.py --find-text/--find-type/--find-id → semantic interaction 3. Screenshots → only for visual verification, bug reports, or visual diff
Screenshots cost 1,600–6,300 tokens depending on size. The accessibility tree costs 10–50 tokens in default mode.
29 Production Scripts
Build & Development (2 scripts)
1. build_and_test.py - Build Xcode projects, run tests, parse results with progressive disclosure
- Build with live result streaming
- Parse errors and warnings from xcresult bundles
- Retrieve detailed build logs on demand
- Options:
--project,--scheme,--clean,--test,--verbose,--json
2. log_monitor.py - Real-time log monitoring with intelligent filtering
- Stream logs or capture by duration
- Filter by severity (error/warning/info/debug)
- Deduplicate repeated messages
- Options:
--app,--severity,--follow,--duration,--output,--json
Device State (2 scripts)
3. appearance.py - Control simulator appearance: dark mode, Dynamic Type size, and locale/region
- Toggle light/dark theme via
xcrun simctl ui - Set Dynamic Type size with friendly aliases (XS through AX5)
- Write locale and region defaults; optional app restart via
--bundle-id - RTL flagged automatically for ar/he/fa/ur/yi locales
- Options:
--theme,--text-size,--locale,--region,--reset,--bundle-id,--udid,--json,--verbose
4. location.py - Simulate GPS coordinates, named city presets, and GPX scenario playback
- Fix a coordinate with
--lat/--lngor pick a city with--city - Play a built-in scenario (City Run, Freeway Drive, etc.) via
--gpx <scenario> - Animate multi-waypoint paths with configurable speed via
--waypointsand--speed - Clear simulated location with
--clear; list available scenarios with--list-scenarios - Options:
--lat,--lng,--city,--gpx,--waypoints,--speed,--clear,--list-scenarios,--udid,--json,--verbose
Navigation & Interaction (5 scripts)
5. screen_mapper.py - Analyze current screen and list interactive elements
- Element type breakdown
- Interactive button list
- Text field status
- Options:
--verbose,--hints,--json
6. navigator.py - Find and interact with elements semantically
- Find by text (fuzzy matching)
- Find by element type
- Find by accessibility ID
- Enter text or tap elements
- Options:
--find-text,--find-type,--find-id,--tap,--enter-text,--json
7. gesture.py - Perform swipes, scrolls, pinches, and complex gestures
- Directional swipes (up/down/left/right)
- Multi-swipe scrolling
- Pinch zoom
- Long press
- Pull to refresh
- Options:
--swipe,--scroll,--pinch,--long-press,--refresh,--json
8. keyboard.py - Text input and hardware button control
- Type text (fast or slow)
- Special keys (return, delete, tab, space, arrows)
- Hardware buttons (home, lock, volume, screenshot)
- Key combinations
- Options:
--type,--key,--button,--slow,--clear,--dismiss,--json
9. app_launcher.py - App lifecycle management
- Launch apps by bundle ID
- Terminate apps
- Install/uninstall from .app bundles
- Deep link navigation
- List installed apps
- Check app state
- Pass launch arguments (
--args) and environment variables (--env KEY=VALUE, injected asSIMCTL_CHILD_*) to the app on launch/restart - Options:
--launch,--terminate,--restart,--install,--uninstall,--open-url,--list,--state,--args,--env,--wait-for-debugger
Testing & Analysis (9 scripts)
10. accessibility_audit.py - Check WCAG compliance on current screen
- Critical issues (missing labels, empty buttons, no alt text)
- Warnings (missing hints, small touch targets)
- Info (missing IDs, deep nesting)
- Options:
--verbose,--output,--json
11. visual_diff.py - Compare two screenshots for visual changes
- Pixel-by-pixel comparison
- Threshold-based pass/fail
- Generate diff images
- Options:
--threshold,--output,--details,--json
12. test_recorder.py - Automatically document test execution
- Capture screenshots and accessibility trees per step
- Generate markdown reports with timing data
- Options:
--test-name,--output,--verbose,--json
13. app_state_capture.py - Create comprehensive debugging snapshots
- Screenshot, UI hierarchy, app logs, device info
- Markdown summary for bug reports
- Options:
--app-bundle-id,--output,--log-lines,--json
14. sim_health_check.sh - Verify environment is properly configured
- Check macOS, Xcode, simctl, IDB, Python
- List available and booted simulators
- Verify Python packages (Pillow)
15. model_inspector.py - Inspect Core Data and SwiftData models from project files
- Parse .xcdatamodeld packages (entities, attributes, relationships)
- Detect model versions and current active version
- Best-effort SwiftData @Model class extraction
- Raw source dump for any model on demand (
--raw ModelName) - Options:
--project-path,--core-data-only,--swiftdata-only,--show-versions,--raw,--verbose,--json
16. container.py - Inspect app sandbox: files, UserDefaults, and Core Data store paths
- List data container files at configurable depth via
--ls - Read files with auto-detected plist decoding via
--cat(large files cached) - Dump UserDefaults as key=value or JSON via
--userdefaults - Locate
.sqlite/.sqlite-wal/.sqlite-shmstores via--core-data-path - Export full container snapshot via
--export - Options:
--ls,--cat,--userdefaults,--core-data-path,--export,--udid,--json,--verbose
17. hang_watcher.py (HangBuster) - Record + summarise os_log hang events with progressive disclosure
- Session mode (HangBuster, agent-native): start a detached recorder, interact with the simulator, stop for a token-tight summary
--start→ returns a session ID; detached worker normalises + thresholds events on the fly--stop SESSION_ID→ emits ~80–120 token L1 summary (header + top-N clusters + drill hint)--get-details SESSION_ID [--cluster N | --raw]→ L2 full clusters or L3 per-event detail--list-sessions/--clear-sessions [--older-than 24h]/--diff A B(cross-session regression report)- Filter pipeline: parse → normalise → threshold → bucket → cluster → aggregate → rank → format (in
common/hang_pipeline.py) --budget-tokens Npicks the densest level (L0/L1/L2) that fits;--terseforces L0--auto-samplecaptures a main-thread stack on first event per cluster (soft dependency:main_thread_sampler.py#62; graceful no-op if absent)- Raw capture mode (full fidelity for `jq` exploration): skip the clustering pipeline, dump every matching log line verbatim to
raw.ndjson --start --raw-capture [--max-size-mb 10] [--no-gzip]— spawnslog stream --style ndjson- Per-session size cap (
--max-size-mb, default 10) — worker stops cleanly on cap;extras.truncated=true --stopgzipsraw.ndjson→raw.ndjson.gz(~15–19× compression;--no-gzipopts out)--get-details SESSION_IDon a raw session prints the path with azcat | jq ...hint- Resilience (auto-restart on stream death): EOF or subprocess death triggers a
stream_diedevent then a bounded restart with 2s backoff. AfterIOS_SIM_HANG_MAX_RESTARTS(default 3) the session is markedcrashed, never left in stalerunningstate.--list-sessionsshowscapture=Xsandrestarts=N. - Cleanup is automatic: TTL prune (
IOS_SIM_HANG_SESSION_TTL_HOURS, default 24h) + aggregate cap (IOS_SIM_HANG_TOTAL_CAP_MB, default 100 MB, oldest-first eviction) both run on every--start. - Legacy modes (unchanged for backward compat):
--watch [--duration N](live stream) and--since 5m(historical) - Filters:
--bundle-id(post-parse — hang capture stays simulator-global so RunningBoard/SpringBoard events are kept),--predicate(also viaIOS_SIM_HANG_PREDICATE) - All output supports
--json; session storage at~/.ios-simulator-skill/sessions/<id>/{meta.json,events.jsonl,summary.json,raw.ndjson.gz}
Quick start (summarised mode):
SID=$(python scripts/hang_watcher.py --start --min-hang-ms 200)
# ... interact with the simulator (open sheets, scroll, navigate) ...
python scripts/hang_watcher.py --stop $SID # token-tight L1 summary
python scripts/hang_watcher.py --get-details $SID --cluster 1 # drill into cluster 1
python scripts/hang_watcher.py --diff $SID_BASELINE $SID # cross-session regressionQuick start (raw capture + `jq` exploration):
SID=$(python scripts/hang_watcher.py --start --raw-capture --max-size-mb 5)
# ... interact with the simulator ...
python scripts/hang_watcher.py --stop $SID
# → "Session ...: raw mode, 737 lines, 0.96 MB → 0.05 MB gzipped"
# Top processes by event count:
zcat ~/.ios-simulator-skill/sessions/$SID/raw.ndjson.gz \
| jq -s 'group_by(.processImagePath) | map({proc: (.[0].processImagePath | split("/") | last), n: length}) | sort_by(-.n) | .[:5]'
# All RunningBoard assertion invalidations:
zcat .../raw.ndjson.gz | jq -c 'select(.subsystem == "com.apple.runningboard" and (.eventMessage | startswith("Invalidating")))'
# Hangs per minute:
zcat .../raw.ndjson.gz | jq -r '.timestamp[:16]' | sort | uniq -c18. localization_audit.py - Detect string catalog gaps, missing keys, and placeholder mismatches
- Report missing and
needs_review/newkeys per locale in.xcstringscatalogs - Cross-reference catalog keys against Swift source (
String(localized:)/NSLocalizedString) via--source - Flag placeholder count mismatches (
%d,%@,%s,%lld) across locales - Legacy
.stringsand.stringsdictsupport viaplistlib - CI-friendly
--strictexits 2 on any finding - Options:
--catalog,--source,--locale,--strict,--json,--verbose
Advanced Testing & Permissions (4 scripts)
19. clipboard.py - Manage simulator clipboard for paste testing
- Copy text to clipboard
- Test paste flows without manual entry
- Options:
--copy,--test-name,--expected,--json
20. status_bar.py - Override simulator status bar appearance
- Presets: clean (9:41, 100% battery), testing (11:11, 50%), low-battery (20%), airplane (offline)
- Custom time, network, battery, WiFi settings
- Options:
--preset,--time,--data-network,--battery-level,--clear,--json
21. push_notification.py - Send simulated push notifications
- Simple mode (title + body + badge)
- Custom JSON payloads
- Test notification handling and deep links
- Options:
--bundle-id,--title,--body,--badge,--payload,--json
22. privacy_manager.py - Grant, revoke, and reset app permissions
- 13 supported services (camera, microphone, location, contacts, photos, calendar, health, etc.)
- Batch operations (comma-separated services)
- Audit trail with test scenario tracking
- Options:
--bundle-id,--grant,--revoke,--reset,--list,--json
Simulator Discovery (2 scripts)
23. sim_list.py - List simulators with progressive disclosure
- Concise summary by default (total / available / booted)
- Full details on demand via cache IDs
- Filter by device type
- Suggest recommended simulators with
--suggest - 96% token reduction vs raw
simctl list(57k → 2k tokens) - Options:
--get-details,--suggest,--device-type,--json
24. simulator_selector.py - Suggest the best simulator for the job
- Ranks candidates by recent use (from
config.json), latest iOS, common test models, and boot status - List all available simulators with
--list - Boot a selected simulator directly with
--boot - JSON output for programmatic use
- Options:
--suggest,--list,--boot,--json
Device Lifecycle Management (5 scripts)
25. simctl_boot.py - Boot simulators with optional readiness verification
- Boot by UDID or device name
- Wait for device ready with timeout
- Batch boot operations (--all, --type)
- Performance timing
- Options:
--udid,--name,--wait-ready,--timeout,--all,--type,--json
26. simctl_shutdown.py - Gracefully shutdown simulators
- Shutdown by UDID or device name
- Optional verification of shutdown completion
- Batch shutdown operations
- Options:
--udid,--name,--verify,--timeout,--all,--type,--json
27. simctl_create.py - Create simulators dynamically
- Create by device type and iOS version
- List available device types and runtimes
- Custom device naming
- Returns UDID for CI/CD integration
- Options:
--device,--runtime,--name,--list-devices,--list-runtimes,--json
28. simctl_delete.py - Permanently delete simulators
- Delete by UDID or device name
- Safety confirmation by default (skip with --yes)
- Batch delete operations
- Smart deletion (--old N to keep N per device type)
- Options:
--udid,--name,--yes,--all,--type,--old,--json
29. simctl_erase.py - Factory reset simulators without deletion
- Preserve device UUID (faster than delete+create)
- Erase all, by type, or booted simulators
- Optional verification
- Options:
--udid,--name,--verify,--timeout,--all,--type,--booted,--json
Common Patterns
Auto-UDID Detection: Most scripts auto-detect the booted simulator if --udid is not provided.
Device Name Resolution: Use device names (e.g., "iPhone 16 Pro") instead of UDIDs - scripts resolve automatically.
Batch Operations: Many scripts support --all for all simulators or --type iPhone for device type filtering.
Output Formats: Default is concise human-readable output. Use --json for machine-readable output in CI/CD.
Help: All scripts support --help for detailed options and examples.
Screenshot Sizing: Screenshots are resized to save tokens. Presets: full (3-4 tiles, ~5K tokens), half (1 tile, ~1.6K tokens, default), quarter (1 tile, ~800 tokens, less detail). Use quarter for quick visual checks, half for readable UI, full only when pixel-level detail matters. Scripts that capture screenshots (app_state_capture.py, test_recorder.py) default to half.
Typical Workflow
1. Verify environment: bash scripts/sim_health_check.sh 2. Launch app: python scripts/app_launcher.py --launch com.example.app 3. Analyze screen: python scripts/screen_mapper.py 4. Interact: python scripts/navigator.py --find-text "Button" --tap 5. Verify: python scripts/accessibility_audit.py 6. Debug if needed: python scripts/app_state_capture.py --app-bundle-id com.example.app
Configuration
Most operational limits can be tuned via environment variables. Defaults work for typical local development; raise them for slow CI runners, large monorepo builds, or accessibility audits on complex screens.
| Variable | Default | Controls |
|---|---|---|
IOS_SIM_A11Y_LABEL_MAX | 80 | Max chars of AXLabel retained in accessibility audit output |
IOS_SIM_A11Y_TOP_ISSUES | 10 | Top accessibility issues surfaced per audit |
IOS_SIM_APPS_PREVIEW | 30 | App entries listed by app_launcher.py before truncation |
IOS_SIM_BOOT_SUBPROCESS_TIMEOUT | 60 | Timeout for the simctl boot subprocess itself (seconds) |
IOS_SIM_BOOT_TIMEOUT | 300 | Wait-for-ready timeout after boot (seconds) |
IOS_SIM_BUILD_JSON_CAP | 50 | Max build errors / failed tests in JSON output |
IOS_SIM_BUILD_LOG_PREVIEW | 4000 | Chars of build log preview in default output |
IOS_SIM_BUILD_TIMEOUT | 1800 | Max seconds for an xcodebuild build invocation before kill |
IOS_SIM_INTROSPECT_TIMEOUT | 60 | Timeout for xcodebuild -list and simctl list lookups (seconds) |
IOS_SIM_TEST_TIMEOUT | 2700 | Max seconds for an xcodebuild test invocation before kill |
IOS_SIM_BUILD_SUMMARY_CAP | 15 | Errors/failures in default build summary |
IOS_SIM_BUILD_VERBOSE_CAP | 100 | Errors/warnings in verbose build output |
IOS_SIM_CACHE_MAX_ENTRIES | 500 | Max entries in progressive disclosure cache (LRU eviction) |
IOS_SIM_CACHE_TTL_HOURS | 1 | Cache entry expiration |
IOS_SIM_ERASE_TIMEOUT | 90 | Wait-for-erase timeout (seconds) |
IOS_SIM_HANG_PREDICATE | _(default)_ | Override the os_log predicate used by hang_watcher.py (default catches RunningBoard kills + "Hang detected" + main-thread hangs). Hang events originate from system daemons (RunningBoard, SpringBoard) so the predicate stays simulator-global — --bundle-id is applied post-parse, not ANDed in. |
IOS_SIM_HANG_MIN_MS | 250 | HangBuster threshold — events below this duration never reach disk (smaller = more sensitive, larger summaries) |
IOS_SIM_HANG_SESSION_TTL_HOURS | 24 | HangBuster session prune age; pruning runs on every --start |
IOS_SIM_HANG_DEFAULT_TOP_N | 3 | Default top-N clusters in --stop L1 output |
IOS_SIM_HANG_BUDGET_TOKENS | _(unset)_ | Default token budget for --stop (picks L0/L1/L2 to fit) |
IOS_SIM_HANG_MAX_RESTARTS | 3 | HangBuster worker: max log stream respawn attempts on EOF/subprocess death before the session is marked crashed |
IOS_SIM_HANG_TOTAL_CAP_MB | 100 | HangBuster aggregate disk cap. When total session-state exceeds this on --start, oldest sessions are dropped first. Set to 0 to disable. |
IOS_SIM_LOG_JSON_CAP | 100 | Max errors/warnings in log_monitor.py JSON output |
IOS_SIM_LOG_LINE_MAX | 300 | Per-line truncation in log summaries |
IOS_SIM_LOG_TAIL | 200 | Lines of log tail in verbose / sample output |
IOS_SIM_LOG_TEXT_SUMMARY | 15 | Errors/warnings shown in text-mode log summary |
IOS_SIM_MAX_ELEMENTS | 25 | Tappable elements listed by navigator.py |
IOS_SIM_POLL_INTERVAL | 0.5 | Boot/erase state polling interval (seconds) |
IOS_SIM_RELAUNCH_DELAY_MS | 1000 | Delay between terminate and re-launch in app_launcher.py |
IOS_SIM_SCREEN_BUTTONS_PREVIEW | 15 | Button names listed by screen_mapper.py |
IOS_SIM_SCREEN_SECTION_ITEMS | 10 | Items per section shown by screen_mapper.py |
IOS_SIM_STATE_SUBPROCESS_TIMEOUT | 15 | Subprocess timeout in app_state_capture.py (seconds) |
IOS_SIM_TAP_SETTLE_MS | 500 | Post-tap settle delay in navigator.py |
Example:
# Slow GitHub Actions runner: give boot 10 minutes
IOS_SIM_BOOT_TIMEOUT=600 python scripts/simctl_boot.py --wait-readyRequirements
- macOS 12+
- Xcode Command Line Tools
- Python 3
- IDB (optional, for interactive features)
Documentation
- SKILL.md (this file) - Script reference and quick start
- README.md - Installation and examples
- CLAUDE.md - Architecture and implementation details
- references/ - Deep documentation on specific topics
- examples/ - Complete automation workflows
Key Design Principles
Semantic Navigation: Find elements by meaning (text, type, ID) not pixel coordinates. Survives UI changes.
Token Efficiency: Concise default output (3-5 lines) with optional verbose and JSON modes for detailed results.
Accessibility-First: Built on standard accessibility APIs for reliability and compatibility.
Zero Configuration: Works immediately on any macOS with Xcode. No setup required.
Structured Data: Scripts output JSON or formatted text, not raw logs. Easy to parse and integrate.
Auto-Learning: Build system remembers your device preference. Configuration stored per-project.
---
Use these scripts directly or let Claude Code invoke them automatically when your request matches the skill description.
#!/usr/bin/env python3
"""
iOS Simulator Accessibility Audit
Scans the current simulator screen for accessibility compliance issues.
Optimized for minimal token output while maintaining functionality.
Usage: python scripts/accessibility_audit.py [options]
"""
import argparse
import json
import subprocess
import sys
from dataclasses import asdict, dataclass
from typing import Any
from common import flatten_tree, get_accessibility_tree, resolve_udid
from common.env_config import env_int
A11Y_LABEL_MAX = env_int("IOS_SIM_A11Y_LABEL_MAX", 80)
A11Y_TOP_ISSUES = env_int("IOS_SIM_A11Y_TOP_ISSUES", 10)
@dataclass
class Issue:
"""Represents an accessibility issue."""
severity: str # critical, warning, info
rule: str
element_type: str
issue: str
fix: str
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return asdict(self)
class AccessibilityAuditor:
"""Performs accessibility audits on iOS simulator screens."""
# Critical rules that block users
CRITICAL_RULES = {
"missing_label": lambda e: e.get("type") in ["Button", "Link"] and not e.get("AXLabel"),
"empty_button": lambda e: e.get("type") == "Button"
and not (e.get("AXLabel") or e.get("AXValue")),
"image_no_alt": lambda e: e.get("type") == "Image" and not e.get("AXLabel"),
}
# Warnings that degrade UX
WARNING_RULES = {
"missing_hint": lambda e: e.get("type") in ["Slider", "TextField"] and not e.get("help"),
"missing_traits": lambda e: e.get("type") and not e.get("traits"),
}
# Info level suggestions
INFO_RULES = {
"no_identifier": lambda e: not e.get("AXUniqueId"),
"deep_nesting": lambda e: e.get("depth", 0) > 5,
}
def __init__(self, udid: str | None = None):
"""Initialize auditor with optional device UDID."""
self.udid = udid
def get_accessibility_tree(self) -> dict:
"""Fetch accessibility tree from simulator using shared utility."""
return get_accessibility_tree(self.udid, nested=True)
@staticmethod
def _is_small_target(element: dict) -> bool:
"""Check if touch target is too small (< 44x44 points)."""
frame = element.get("frame", {})
width = frame.get("width", 0)
height = frame.get("height", 0)
return width < 44 or height < 44
def _flatten_tree(self, node: dict, depth: int = 0) -> list[dict]:
"""Flatten nested accessibility tree for easier processing using shared utility."""
return flatten_tree(node, depth)
def audit_element(self, element: dict) -> list[Issue]:
"""Audit a single element for accessibility issues."""
issues = []
# Check critical rules
for rule_name, rule_func in self.CRITICAL_RULES.items():
if rule_func(element):
issues.append(
Issue(
severity="critical",
rule=rule_name,
element_type=element.get("type", "Unknown"),
issue=self._get_issue_description(rule_name),
fix=self._get_fix_suggestion(rule_name),
)
)
# Check warnings (skip if critical issues found)
if not issues:
for rule_name, rule_func in self.WARNING_RULES.items():
if rule_func(element):
issues.append(
Issue(
severity="warning",
rule=rule_name,
element_type=element.get("type", "Unknown"),
issue=self._get_issue_description(rule_name),
fix=self._get_fix_suggestion(rule_name),
)
)
# Check info level (only if verbose or no other issues)
if not issues:
for rule_name, rule_func in self.INFO_RULES.items():
if rule_func(element):
issues.append(
Issue(
severity="info",
rule=rule_name,
element_type=element.get("type", "Unknown"),
issue=self._get_issue_description(rule_name),
fix=self._get_fix_suggestion(rule_name),
)
)
return issues
def _get_issue_description(self, rule: str) -> str:
"""Get human-readable issue description."""
descriptions = {
"missing_label": "Interactive element missing accessibility label",
"empty_button": "Button has no text or label",
"image_no_alt": "Image missing alternative text",
"missing_hint": "Complex control missing hint",
"small_touch_target": "Touch target smaller than 44x44pt",
"missing_traits": "Element missing accessibility traits",
"no_identifier": "Missing accessibility identifier",
"deep_nesting": "Deeply nested (>5 levels)",
}
return descriptions.get(rule, "Accessibility issue")
def _get_fix_suggestion(self, rule: str) -> str:
"""Get fix suggestion for issue."""
fixes = {
"missing_label": "Add accessibilityLabel",
"empty_button": "Set button title or accessibilityLabel",
"image_no_alt": "Add accessibilityLabel with description",
"missing_hint": "Add accessibilityHint",
"small_touch_target": "Increase to minimum 44x44pt",
"missing_traits": "Set appropriate accessibilityTraits",
"no_identifier": "Add accessibilityIdentifier for testing",
"deep_nesting": "Simplify view hierarchy",
}
return fixes.get(rule, "Review accessibility")
def audit(self, verbose: bool = False) -> dict[str, Any]:
"""Perform full accessibility audit."""
# Get accessibility tree
tree = self.get_accessibility_tree()
# Flatten for processing
elements = self._flatten_tree(tree)
# Audit each element
all_issues = []
for element in elements:
issues = self.audit_element(element)
for issue in issues:
issue_dict = issue.to_dict()
# Add minimal element info for context
issue_dict["element"] = {
"type": element.get("type", "Unknown"),
"label": (
element.get("AXLabel", "")[:A11Y_LABEL_MAX]
if element.get("AXLabel")
else None
),
}
all_issues.append(issue_dict)
# Count by severity
critical = len([i for i in all_issues if i["severity"] == "critical"])
warning = len([i for i in all_issues if i["severity"] == "warning"])
info = len([i for i in all_issues if i["severity"] == "info"])
# Build result (token-optimized)
result = {
"summary": {
"total": len(elements),
"issues": len(all_issues),
"critical": critical,
"warning": warning,
"info": info,
}
}
if verbose:
# Full details only if requested
result["issues"] = all_issues
else:
# Default: top issues only (token-efficient)
result["top_issues"] = self._get_top_issues(all_issues)
return result
def _get_top_issues(self, issues: list[dict]) -> list[dict]:
"""Get top 3 issues grouped by type (token-efficient)."""
if not issues:
return []
# Group by rule
grouped = {}
for issue in issues:
rule = issue["rule"]
if rule not in grouped:
grouped[rule] = {
"severity": issue["severity"],
"rule": rule,
"count": 0,
"fix": issue["fix"],
}
grouped[rule]["count"] += 1
# Sort by severity and count
severity_order = {"critical": 0, "warning": 1, "info": 2}
sorted_issues = sorted(
grouped.values(), key=lambda x: (severity_order[x["severity"]], -x["count"])
)
return sorted_issues[:A11Y_TOP_ISSUES]
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Audit iOS simulator screen for accessibility issues"
)
parser.add_argument(
"--udid",
help="Device UDID (auto-detects booted simulator if not provided)",
)
parser.add_argument("--output", help="Save JSON report to file")
parser.add_argument(
"--verbose", action="store_true", help="Include all issue details (increases output)"
)
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)
# Perform audit
auditor = AccessibilityAuditor(udid=udid)
try:
result = auditor.audit(verbose=args.verbose)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
# Output results
if args.output:
# Save to file
with open(args.output, "w") as f:
json.dump(result, f, indent=2)
# Print minimal summary
summary = result["summary"]
print(f"Audit complete: {summary['issues']} issues ({summary['critical']} critical)")
print(f"Report saved to: {args.output}")
# Print to stdout (token-optimized by default)
elif args.verbose:
print(json.dumps(result, indent=2))
else:
# Ultra-compact output
summary = result["summary"]
print(f"Elements: {summary['total']}, Issues: {summary['issues']}")
print(
f"Critical: {summary['critical']}, Warning: {summary['warning']}, Info: {summary['info']}"
)
if result.get("top_issues"):
print("\nTop issues:")
for issue in result["top_issues"]:
print(
f" [{issue['severity']}] {issue['rule']} ({issue['count']}x) - {issue['fix']}"
)
# Exit with error if critical issues found
if result["summary"]["critical"] > 0:
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
iOS App Launcher - App Lifecycle Control
Launches, terminates, and manages iOS apps in the simulator.
Handles deep links and app switching.
Usage: python scripts/app_launcher.py --launch com.example.app
Launch arguments and environment variables can be passed to the app:
python scripts/app_launcher.py --launch com.example.app \
--env DEBUG=1 --args -uiTestingMode 1
--args consumes everything after it (argparse REMAINDER), so it must be the
last flag on the command line. --env is repeatable and each KEY=VALUE pair is
injected into the app process as SIMCTL_CHILD_KEY=VALUE.
"""
import argparse
import contextlib
import os
import subprocess
import sys
import time
from common import build_simctl_command, resolve_udid
from common.env_config import env_float, env_int
RELAUNCH_DELAY_SECONDS = env_float("IOS_SIM_RELAUNCH_DELAY_MS", 1000.0) / 1000.0
APPS_PREVIEW = env_int("IOS_SIM_APPS_PREVIEW", 30)
class AppLauncher:
"""Controls app lifecycle on iOS simulator."""
def __init__(self, udid: str | None = None):
"""Initialize app launcher."""
self.udid = udid
def launch(
self,
bundle_id: str,
wait_for_debugger: bool = False,
launch_args: list[str] | None = None,
env_vars: dict[str, str] | None = None,
) -> tuple[bool, int | None]:
"""
Launch an app.
Args:
bundle_id: App bundle identifier
wait_for_debugger: Wait for debugger attachment
launch_args: Arguments passed to the app as trailing simctl args
env_vars: Variables injected into the app process as SIMCTL_CHILD_*
Returns:
(success, pid) tuple
"""
cmd = build_simctl_command("launch", self.udid, bundle_id, *(launch_args or []))
if wait_for_debugger:
cmd.insert(3, "--wait-for-debugger") # Insert after "launch" operation
# Inherit the parent env unless caller supplied app env vars (SIMCTL_CHILD_*).
run_env = None
if env_vars:
run_env = {**os.environ, **{f"SIMCTL_CHILD_{k}": v for k, v in env_vars.items()}}
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True, env=run_env)
# Parse PID from output if available
pid = None
if result.stdout:
# Output format: "com.example.app: <PID>"
parts = result.stdout.strip().split(":")
if len(parts) > 1:
with contextlib.suppress(ValueError):
pid = int(parts[1].strip())
return (True, pid)
except subprocess.CalledProcessError:
return (False, None)
def terminate(self, bundle_id: str) -> bool:
"""
Terminate an app.
Args:
bundle_id: App bundle identifier
Returns:
Success status
"""
cmd = build_simctl_command("terminate", self.udid, bundle_id)
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def install(self, app_path: str) -> bool:
"""
Install an app.
Args:
app_path: Path to .app bundle
Returns:
Success status
"""
cmd = build_simctl_command("install", self.udid, app_path)
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def uninstall(self, bundle_id: str) -> bool:
"""
Uninstall an app.
Args:
bundle_id: App bundle identifier
Returns:
Success status
"""
cmd = build_simctl_command("uninstall", self.udid, bundle_id)
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def open_url(self, url: str) -> bool:
"""
Open URL (for deep linking).
Args:
url: URL to open (http://, myapp://, etc.)
Returns:
Success status
"""
cmd = build_simctl_command("openurl", self.udid, url)
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def list_apps(self) -> list[dict[str, str]]:
"""
List installed apps.
Returns:
List of app info dictionaries
"""
cmd = build_simctl_command("listapps", self.udid)
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
# Parse plist output using plutil to convert to JSON
plist_data = result.stdout
# Use plutil to convert plist to JSON
convert_cmd = ["plutil", "-convert", "json", "-o", "-", "-"]
convert_result = subprocess.run(
convert_cmd, check=False, input=plist_data, capture_output=True, text=True
)
apps = []
if convert_result.returncode == 0:
import json
try:
data = json.loads(convert_result.stdout)
for bundle_id, app_info in data.items():
# Skip system internal apps that are hidden
if app_info.get("ApplicationType") == "Hidden":
continue
apps.append(
{
"bundle_id": bundle_id,
"name": app_info.get(
"CFBundleDisplayName", app_info.get("CFBundleName", bundle_id)
),
"path": app_info.get("Path", ""),
"version": app_info.get("CFBundleVersion", "Unknown"),
"type": app_info.get("ApplicationType", "User"),
}
)
except json.JSONDecodeError:
pass
return apps
except subprocess.CalledProcessError:
return []
def get_app_state(self, bundle_id: str) -> str:
"""
Get app state (running, suspended, etc.).
Args:
bundle_id: App bundle identifier
Returns:
State string or 'unknown'
"""
# Check if app is running by trying to get its PID
cmd = build_simctl_command("spawn", self.udid, "launchctl", "list")
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
if bundle_id in result.stdout:
return "running"
return "not running"
except subprocess.CalledProcessError:
return "unknown"
def restart_app(
self,
bundle_id: str,
delay: float = RELAUNCH_DELAY_SECONDS,
launch_args: list[str] | None = None,
env_vars: dict[str, str] | None = None,
) -> bool:
"""
Restart an app (terminate then launch).
Args:
bundle_id: App bundle identifier
delay: Delay between terminate and launch
launch_args: Arguments forwarded to the relaunch
env_vars: Variables forwarded to the relaunch as SIMCTL_CHILD_*
Returns:
Success status
"""
# Terminate
self.terminate(bundle_id)
time.sleep(delay)
# Launch
success, _ = self.launch(bundle_id, launch_args=launch_args, env_vars=env_vars)
return success
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Control iOS app lifecycle")
# Actions
parser.add_argument("--launch", help="Launch app by bundle ID")
parser.add_argument("--terminate", help="Terminate app by bundle ID")
parser.add_argument("--restart", help="Restart app by bundle ID")
parser.add_argument("--install", help="Install app from .app path")
parser.add_argument("--uninstall", help="Uninstall app by bundle ID")
parser.add_argument("--open-url", help="Open URL (deep link)")
parser.add_argument("--list", action="store_true", help="List installed apps")
parser.add_argument("--state", help="Get app state by bundle ID")
# Options
parser.add_argument(
"--wait-for-debugger", action="store_true", help="Wait for debugger when launching"
)
parser.add_argument(
"--udid",
help="Device UDID (auto-detects booted simulator if not provided)",
)
parser.add_argument(
"--env",
action="append",
metavar="KEY=VALUE",
help="App environment variable (repeatable); injected as SIMCTL_CHILD_KEY",
)
# REMAINDER must be the last flag on the command line.
parser.add_argument(
"--args",
nargs=argparse.REMAINDER,
help="Launch arguments for the app (everything after this flag)",
)
args = parser.parse_args()
# Parse --env KEY=VALUE pairs, failing fast on malformed input.
env_vars: dict[str, str] = {}
for pair in args.env or []:
key, separator, value = pair.partition("=")
if not separator or not key:
print(f"Error: invalid --env '{pair}', expected KEY=VALUE", file=sys.stderr)
sys.exit(1)
env_vars[key] = value
launch_args = args.args or None
# Resolve UDID with auto-detection
try:
udid = resolve_udid(args.udid)
except RuntimeError as e:
print(f"Error: {e}")
sys.exit(1)
launcher = AppLauncher(udid=udid)
# Execute requested action
if args.launch:
success, pid = launcher.launch(
args.launch,
args.wait_for_debugger,
launch_args=launch_args,
env_vars=env_vars or None,
)
if success:
if pid:
print(f"Launched {args.launch} (PID: {pid})")
else:
print(f"Launched {args.launch}")
else:
print(f"Failed to launch {args.launch}")
sys.exit(1)
elif args.terminate:
if launcher.terminate(args.terminate):
print(f"Terminated {args.terminate}")
else:
print(f"Failed to terminate {args.terminate}")
sys.exit(1)
elif args.restart:
if launcher.restart_app(args.restart, launch_args=launch_args, env_vars=env_vars or None):
print(f"Restarted {args.restart}")
else:
print(f"Failed to restart {args.restart}")
sys.exit(1)
elif args.install:
if launcher.install(args.install):
print(f"Installed {args.install}")
else:
print(f"Failed to install {args.install}")
sys.exit(1)
elif args.uninstall:
if launcher.uninstall(args.uninstall):
print(f"Uninstalled {args.uninstall}")
else:
print(f"Failed to uninstall {args.uninstall}")
sys.exit(1)
elif args.open_url:
if launcher.open_url(args.open_url):
print(f"Opened URL: {args.open_url}")
else:
print(f"Failed to open URL: {args.open_url}")
sys.exit(1)
elif args.list:
apps = launcher.list_apps()
if apps:
print(f"Installed apps ({len(apps)}):")
for app in apps[:APPS_PREVIEW]:
print(f" {app['bundle_id']}: {app['name']} (v{app['version']})")
if len(apps) > APPS_PREVIEW:
print(f" ... and {len(apps) - APPS_PREVIEW} more")
else:
print("No apps found or failed to list")
elif args.state:
state = launcher.get_app_state(args.state)
print(f"{args.state}: {state}")
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
#!/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,
)
from common.env_config import env_int
STATE_SUBPROCESS_TIMEOUT = env_int("IOS_SIM_STATE_SUBPROCESS_TIMEOUT", 15)
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=STATE_SUBPROCESS_TIMEOUT,
)
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()
#!/usr/bin/env python3
"""
iOS Simulator Appearance Manager
Control dark mode, dynamic type size, locale, and region.
Wraps xcrun simctl ui and defaults write for appearance testing.
Usage:
python scripts/appearance.py --theme dark
python scripts/appearance.py --text-size AX3
python scripts/appearance.py --locale ar --region SA --bundle-id com.myapp
python scripts/appearance.py --reset
RTL locales: ar, he, fa, ur, yi (app must restart to reflow layout).
"""
import argparse
import json
import subprocess
import sys
from common import resolve_udid
# === CONSTANTS ===
# Map friendly size aliases to xcrun simctl content_size tokens
TEXT_SIZE_MAP: dict[str, str] = {
"XS": "extra-small",
"S": "small",
"M": "medium",
"L": "large",
"XL": "extra-large",
"XXL": "extra-extra-large",
"XXXL": "extra-extra-extra-large",
"AX1": "accessibility-medium",
"AX2": "accessibility-large",
"AX3": "accessibility-extra-large",
"AX4": "accessibility-extra-extra-large",
"AX5": "accessibility-extra-extra-extra-large",
}
# Locales that require RTL layout direction
RTL_LOCALES: frozenset[str] = frozenset({"ar", "he", "fa", "ur", "yi"})
# Default appearance values used by --reset
DEFAULT_THEME = "light"
DEFAULT_TEXT_SIZE = "M"
DEFAULT_LOCALE = "en"
DEFAULT_REGION = "US"
# === APPEARANCE MANAGER ===
class AppearanceManager:
"""Manages iOS simulator appearance: theme, dynamic type, and locale."""
def __init__(self, udid: str | None = None):
"""Initialize appearance manager.
Args:
udid: Optional device UDID (auto-detects booted simulator if None)
"""
self.udid = udid
# === PUBLIC API ===
def set_theme(self, theme: str) -> tuple[bool, str]:
"""Switch simulator between light and dark appearance.
Args:
theme: 'light' or 'dark'
Returns:
(success, message)
"""
cmd = ["xcrun", "simctl", "ui", self.udid, "appearance", theme]
return self._run(cmd, f"Theme set: {theme}")
def set_text_size(self, alias: str) -> tuple[bool, str]:
"""Set dynamic type content size.
Args:
alias: Friendly alias (XS, S, M, L, XL, XXL, XXXL, AX1-AX5)
Returns:
(success, message)
"""
token = TEXT_SIZE_MAP.get(alias.upper())
if not token:
valid = ", ".join(TEXT_SIZE_MAP.keys())
return False, f"Unknown text size '{alias}'. Valid: {valid}"
cmd = ["xcrun", "simctl", "ui", self.udid, "content_size", token]
return self._run(cmd, f"Text size set: {alias} ({token})")
def set_locale(
self,
locale: str,
region: str | None = None,
bundle_id: str | None = None,
) -> tuple[bool, str]:
"""Write AppleLanguages and AppleLocale to simulator global defaults.
Optionally restarts a specific app via bundle ID so the locale takes
effect immediately. Without --bundle-id the change applies on next
cold app launch.
Args:
locale: BCP-47 language code (e.g. 'en', 'ar', 'de')
region: ISO 3166-1 alpha-2 region code (e.g. 'US', 'SA', 'IE')
bundle_id: Optional app bundle ID — triggers terminate + launch
Returns:
(success, message)
"""
apple_locale = f"{locale}_{region}" if region else locale
# Write AppleLanguages array
lang_cmd = [
"xcrun",
"simctl",
"spawn",
self.udid,
"defaults",
"write",
"-g",
"AppleLanguages",
"-array",
locale,
]
ok, msg = self._run(lang_cmd, "")
if not ok:
return False, f"Failed to write AppleLanguages: {msg}"
# Write AppleLocale string
locale_cmd = [
"xcrun",
"simctl",
"spawn",
self.udid,
"defaults",
"write",
"-g",
"AppleLocale",
"-string",
apple_locale,
]
ok, msg = self._run(locale_cmd, "")
if not ok:
return False, f"Failed to write AppleLocale: {msg}"
is_rtl = locale in RTL_LOCALES
rtl_note = " [RTL layout]" if is_rtl else ""
summary = f"Locale set: {apple_locale}{rtl_note}"
if bundle_id:
restart_ok, restart_msg = self._restart_app(bundle_id)
if restart_ok:
summary += f" — app restarted: {bundle_id}"
else:
summary += f" — locale written but app restart failed: {restart_msg}"
else:
summary += " — restart app to apply"
return True, summary
def reset(self) -> tuple[bool, str]:
"""Reset theme, text size, and locale to system defaults.
Returns:
(success, message)
"""
results: list[str] = []
errors: list[str] = []
ok, msg = self.set_theme(DEFAULT_THEME)
(results if ok else errors).append(msg)
ok, msg = self.set_text_size(DEFAULT_TEXT_SIZE)
(results if ok else errors).append(msg)
ok, msg = self.set_locale(DEFAULT_LOCALE, DEFAULT_REGION)
(results if ok else errors).append(msg)
if errors:
return False, f"Reset partial — errors: {'; '.join(errors)}"
return True, "Appearance reset to defaults (light / M / en_US)"
# === PRIVATE HELPERS ===
def _run(self, cmd: list[str], success_message: str) -> tuple[bool, str]:
"""Run subprocess command and return (success, message).
Args:
cmd: Command list (never shell=True)
success_message: Human-readable success summary
Returns:
(success, message)
"""
try:
subprocess.run(
cmd,
capture_output=True,
text=True,
check=True,
)
return True, success_message
except subprocess.CalledProcessError as error:
stderr = error.stderr.strip() if error.stderr else "unknown error"
return False, stderr
def _restart_app(self, bundle_id: str) -> tuple[bool, str]:
"""Terminate then launch an app by bundle ID.
Args:
bundle_id: App bundle ID
Returns:
(success, message)
"""
terminate_cmd = ["xcrun", "simctl", "terminate", self.udid, bundle_id]
# Terminate may fail if app is not running — that is acceptable
subprocess.run(terminate_cmd, capture_output=True, check=False)
launch_cmd = ["xcrun", "simctl", "launch", self.udid, bundle_id]
try:
subprocess.run(launch_cmd, capture_output=True, text=True, check=True)
return True, f"Launched {bundle_id}"
except subprocess.CalledProcessError as error:
stderr = error.stderr.strip() if error.stderr else "launch failed"
return False, stderr
# === CLI ===
def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser(
description=(
"iOS Simulator Appearance Manager — control dark mode, "
"dynamic type, locale, and region."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python scripts/appearance.py --theme dark
python scripts/appearance.py --text-size AX3
python scripts/appearance.py --locale ar --region SA
python scripts/appearance.py --locale de --region DE --bundle-id com.myapp
python scripts/appearance.py --reset
RTL locales (ar, he, fa, ur, yi): app must restart to reflow layout.
Text sizes: XS S M L XL XXL XXXL AX1 AX2 AX3 AX4 AX5
""",
)
parser.add_argument(
"--theme",
choices=["light", "dark"],
help="Set light or dark appearance",
)
parser.add_argument(
"--text-size",
choices=list(TEXT_SIZE_MAP.keys()),
metavar="{" + ",".join(TEXT_SIZE_MAP.keys()) + "}",
help="Set dynamic type size (XS smallest, AX5 largest)",
)
parser.add_argument(
"--locale",
metavar="CODE",
help="Set locale language code (e.g. en, de, ar, ja)",
)
parser.add_argument(
"--region",
metavar="CODE",
help="Set region code (e.g. US, IE, SA). Used with --locale.",
)
parser.add_argument(
"--bundle-id",
metavar="BUNDLE_ID",
help="App bundle ID to terminate+relaunch after locale change",
)
parser.add_argument(
"--reset",
action="store_true",
help="Reset theme, text size, and locale to system defaults",
)
parser.add_argument(
"--udid",
help="Device UDID (auto-detects booted simulator if not provided)",
)
parser.add_argument(
"--json",
action="store_true",
help="Output results as JSON",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Show detailed output",
)
args = parser.parse_args()
# Guard: require at least one action
if not any([args.theme, args.text_size, args.locale, args.reset]):
parser.print_help()
sys.exit(1)
# Guard: --reset is incompatible with explicit appearance flags
if args.reset and any([args.theme, args.text_size, args.locale]):
print(
"Error: --reset cannot be combined with --theme, --text-size, or --locale",
file=sys.stderr,
)
sys.exit(1)
# Guard: --region without --locale is a no-op
if args.region and not args.locale:
print("Error: --region requires --locale", file=sys.stderr)
sys.exit(1)
# Guard: --bundle-id without --locale is ambiguous
if args.bundle_id and not args.locale:
print("Error: --bundle-id requires --locale", file=sys.stderr)
sys.exit(1)
try:
udid = resolve_udid(args.udid)
except RuntimeError as error:
print(f"Error: {error}", file=sys.stderr)
sys.exit(1)
manager = AppearanceManager(udid=udid)
device_label = udid if udid else "booted"
# Collect all requested operations
operations: list[tuple[str, tuple[bool, str]]] = []
if args.reset:
operations.append(("reset", manager.reset()))
else:
if args.theme:
operations.append(("theme", manager.set_theme(args.theme)))
if args.text_size:
operations.append(("text_size", manager.set_text_size(args.text_size)))
if args.locale:
operations.append(
(
"locale",
manager.set_locale(args.locale, args.region, args.bundle_id),
)
)
overall_success = all(ok for _, (ok, _) in operations)
if args.json:
results = {action: {"success": ok, "message": msg} for action, (ok, msg) in operations}
payload = {
"success": overall_success,
"udid": device_label,
"results": results,
}
print(json.dumps(payload))
elif args.verbose:
print(f"Device: {device_label}")
for action, (ok, msg) in operations:
status = "OK" if ok else "FAIL"
print(f" [{status}] {action}: {msg}")
else:
for _, (_ok, msg) in operations:
if msg:
print(msg)
sys.exit(0 if overall_success else 1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Build and Test Automation for Xcode Projects
Ultra token-efficient build automation with progressive disclosure via xcresult bundles.
Features:
- Minimal default output (5-10 tokens)
- Progressive disclosure for error/warning/log details
- Native xcresult bundle support
- Clean modular architecture
Usage Examples:
# Build (minimal output)
python scripts/build_and_test.py --project MyApp.xcodeproj
# Output: Build: SUCCESS (0 errors, 3 warnings) [xcresult-20251018-143052]
# Get error details
python scripts/build_and_test.py --get-errors xcresult-20251018-143052
# Get warnings
python scripts/build_and_test.py --get-warnings xcresult-20251018-143052
# Get build log
python scripts/build_and_test.py --get-log xcresult-20251018-143052
# Get everything as JSON
python scripts/build_and_test.py --get-all xcresult-20251018-143052 --json
# List recent builds
python scripts/build_and_test.py --list-xcresults
# Verbose mode (for debugging)
python scripts/build_and_test.py --project MyApp.xcodeproj --verbose
"""
import argparse
import sys
from pathlib import Path
# Import our modular components
from common.env_config import env_int
from xcode import BuildRunner, OutputFormatter, XCResultCache, XCResultParser
BUILD_LOG_PREVIEW_CHARS = env_int("IOS_SIM_BUILD_LOG_PREVIEW", 4000)
BUILD_JSON_CAP = env_int("IOS_SIM_BUILD_JSON_CAP", 50)
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Build and test Xcode projects with progressive disclosure",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Build project (minimal output)
python scripts/build_and_test.py --project MyApp.xcodeproj
# Run tests
python scripts/build_and_test.py --project MyApp.xcodeproj --test
# Get error details from previous build
python scripts/build_and_test.py --get-errors xcresult-20251018-143052
# Get all details as JSON
python scripts/build_and_test.py --get-all xcresult-20251018-143052 --json
# List recent builds
python scripts/build_and_test.py --list-xcresults
""",
)
# Build/test mode arguments
build_group = parser.add_argument_group("Build/Test Options")
project_group = build_group.add_mutually_exclusive_group()
project_group.add_argument("--project", help="Path to .xcodeproj file")
project_group.add_argument("--workspace", help="Path to .xcworkspace file")
build_group.add_argument("--scheme", help="Build scheme (auto-detected if not specified)")
build_group.add_argument(
"--configuration",
default="Debug",
help="Build configuration (default: Debug). Accepts any valid Xcode configuration.",
)
build_group.add_argument("--simulator", help="Simulator name (default: iPhone 15)")
build_group.add_argument("--clean", action="store_true", help="Clean before building")
build_group.add_argument("--test", action="store_true", help="Run tests")
build_group.add_argument("--suite", help="Specific test suite to run")
# Progressive disclosure arguments
disclosure_group = parser.add_argument_group("Progressive Disclosure Options")
disclosure_group.add_argument(
"--get-errors", metavar="XCRESULT_ID", help="Get error details from xcresult"
)
disclosure_group.add_argument(
"--get-warnings", metavar="XCRESULT_ID", help="Get warning details from xcresult"
)
disclosure_group.add_argument(
"--get-log", metavar="XCRESULT_ID", help="Get build log from xcresult"
)
disclosure_group.add_argument(
"--get-all", metavar="XCRESULT_ID", help="Get all details from xcresult"
)
disclosure_group.add_argument(
"--list-xcresults", action="store_true", help="List recent xcresult bundles"
)
# Output options
output_group = parser.add_argument_group("Output Options")
output_group.add_argument("--verbose", action="store_true", help="Show detailed output")
output_group.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
# Initialize cache
cache = XCResultCache()
# Handle list mode
if args.list_xcresults:
xcresults = cache.list()
if args.json:
import json
print(json.dumps(xcresults, indent=2))
elif not xcresults:
print("No xcresult bundles found")
else:
print(f"Recent XCResult bundles ({len(xcresults)}):")
print()
for xc in xcresults:
print(f" {xc['id']}")
print(f" Created: {xc['created']}")
print(f" Size: {xc['size_mb']} MB")
print()
return 0
# Handle retrieval modes
xcresult_id = args.get_errors or args.get_warnings or args.get_log or args.get_all
if xcresult_id:
xcresult_path = cache.get_path(xcresult_id)
if not xcresult_path or not xcresult_path.exists():
print(f"Error: XCResult bundle not found: {xcresult_id}", file=sys.stderr)
print("Use --list-xcresults to see available bundles", file=sys.stderr)
return 1
# Load cached stderr for progressive disclosure
cached_stderr = cache.get_stderr(xcresult_id)
parser = XCResultParser(xcresult_path, stderr=cached_stderr)
# Get errors
if args.get_errors:
errors = parser.get_errors()
if args.json:
import json
print(json.dumps(errors, indent=2))
else:
print(OutputFormatter.format_errors(errors))
return 0
# Get warnings
if args.get_warnings:
warnings = parser.get_warnings()
if args.json:
import json
print(json.dumps(warnings, indent=2))
else:
print(OutputFormatter.format_warnings(warnings))
return 0
# Get log
if args.get_log:
log = parser.get_build_log()
if log:
print(OutputFormatter.format_log(log))
else:
print("No build log available", file=sys.stderr)
return 1
return 0
# Get all
if args.get_all:
error_count, warning_count = parser.count_issues()
errors = parser.get_errors()
warnings = parser.get_warnings()
build_log = parser.get_build_log()
if args.json:
import json
data = {
"xcresult_id": xcresult_id,
"error_count": error_count,
"warning_count": warning_count,
"errors": errors,
"warnings": warnings,
"log_preview": build_log[:BUILD_LOG_PREVIEW_CHARS] if build_log else None,
}
print(json.dumps(data, indent=2))
else:
print(f"XCResult: {xcresult_id}")
print(f"Errors: {error_count}, Warnings: {warning_count}")
print()
if errors:
print(OutputFormatter.format_errors(errors, limit=10))
print()
if warnings:
print(OutputFormatter.format_warnings(warnings, limit=10))
print()
if build_log:
print("Build Log (last 30 lines):")
print(OutputFormatter.format_log(build_log, lines=30))
return 0
# Build/test mode
if not args.project and not args.workspace:
# Try to auto-detect in current directory
cwd = Path.cwd()
projects = list(cwd.glob("*.xcodeproj"))
workspaces = list(cwd.glob("*.xcworkspace"))
if workspaces:
args.workspace = str(workspaces[0])
elif projects:
args.project = str(projects[0])
else:
parser.error("No project or workspace specified and none found in current directory")
# Initialize builder
builder = BuildRunner(
project_path=args.project,
workspace_path=args.workspace,
scheme=args.scheme,
configuration=args.configuration,
simulator=args.simulator,
cache=cache,
)
# Execute build or test
if args.test:
success, xcresult_id, stderr = builder.test(test_suite=args.suite)
else:
success, xcresult_id, stderr = builder.build(clean=args.clean)
if not xcresult_id and not stderr:
print("Error: Build/test failed without creating xcresult or error output", file=sys.stderr)
return 1
# Save stderr to cache for progressive disclosure
if xcresult_id and stderr:
cache.save_stderr(xcresult_id, stderr)
# Parse results
xcresult_path = cache.get_path(xcresult_id) if xcresult_id else None
parser = XCResultParser(xcresult_path, stderr=stderr)
error_count, warning_count = parser.count_issues()
# Format output
status = "SUCCESS" if success else "FAILED"
# Collect errors on failure (used by all output modes)
errors = parser.get_errors() if not success else None
hints = OutputFormatter.generate_hints(errors) if errors else None
# Collect test info and failed tests when testing
test_info = None
failed_tests = None
if args.test and xcresult_path:
test_results = parser.get_test_results()
if test_results:
# Xcode 16 `xcresulttool get test-results summary` keys are
# totalTestCount / passedTests / failedTests (fall back to the
# legacy total/passed/failed names for older Xcode).
start = test_results.get("startTime")
finish = test_results.get("finishTime")
duration = (
round(finish - start, 1)
if isinstance(start, (int, float)) and isinstance(finish, (int, float))
else test_results.get("duration", 0.0)
)
test_info = {
"total": test_results.get("totalTestCount", test_results.get("total", 0)),
"passed": test_results.get("passedTests", test_results.get("passed", 0)),
"failed": test_results.get("failedTests", test_results.get("failed", 0)),
"duration": duration,
}
if not success:
failed_tests = parser.get_failed_tests()
if args.verbose:
# Verbose mode with error/warning details
verbose_errors = errors if error_count > 0 else None
warnings = parser.get_warnings() if warning_count > 0 else None
output = OutputFormatter.format_verbose(
status=status,
error_count=error_count,
warning_count=warning_count,
xcresult_id=xcresult_id or "N/A",
errors=verbose_errors,
warnings=warnings,
test_info=test_info,
)
print(output)
elif args.json:
# JSON mode
data = {
"success": success,
"xcresult_id": xcresult_id or None,
"error_count": error_count,
"warning_count": warning_count,
}
if test_info:
data["test_info"] = test_info
if not success:
if errors:
data["errors"] = errors[:BUILD_JSON_CAP]
if failed_tests:
data["failed_tests"] = failed_tests[:BUILD_JSON_CAP]
if hints:
data["hints"] = hints
import json
print(json.dumps(data, indent=2))
else:
# Minimal mode (default)
output = OutputFormatter.format_minimal(
status=status,
error_count=error_count,
warning_count=warning_count,
xcresult_id=xcresult_id or "N/A",
test_info=test_info,
hints=hints,
errors=errors,
failed_tests=failed_tests,
)
print(output)
# Exit with appropriate code
return 0 if success else 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
iOS Simulator Clipboard Manager
Copy text to simulator clipboard for testing paste flows.
Optimized for minimal token output.
Usage: python scripts/clipboard.py --copy "text to copy"
"""
import argparse
import subprocess
import sys
from common import resolve_udid
class ClipboardManager:
"""Manages clipboard operations on iOS simulator."""
def __init__(self, udid: str | None = None):
"""Initialize clipboard manager.
Args:
udid: Optional device UDID (auto-detects booted simulator if None)
"""
self.udid = udid
def copy(self, text: str) -> bool:
"""
Copy text to simulator clipboard.
Args:
text: Text to copy to clipboard
Returns:
Success status
"""
cmd = ["xcrun", "simctl", "pbcopy"]
if self.udid:
cmd.append(self.udid)
else:
cmd.append("booted")
cmd.append(text)
try:
subprocess.run(cmd, capture_output=True, check=True)
return True
except subprocess.CalledProcessError:
return False
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Copy text to iOS simulator clipboard")
parser.add_argument("--copy", required=True, help="Text to copy to clipboard")
parser.add_argument(
"--udid",
help="Device UDID (auto-detects booted simulator if not provided)",
)
parser.add_argument("--test-name", help="Test scenario name for tracking")
parser.add_argument("--expected", help="Expected behavior after paste")
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 manager and copy text
manager = ClipboardManager(udid=udid)
if manager.copy(args.copy):
# Token-efficient output
output = f'Copied: "{args.copy}"'
if args.test_name:
output += f" (test: {args.test_name})"
print(output)
# Provide usage guidance
if args.expected:
print(f"Expected: {args.expected}")
print()
print("Next steps:")
print("1. Tap text field with: python scripts/navigator.py --find-type TextField --tap")
print("2. Paste with: python scripts/keyboard.py --key return")
print(" Or use Cmd+V gesture with: python scripts/keyboard.py --key cmd+v")
else:
print("Failed to copy text to clipboard")
sys.exit(1)
if __name__ == "__main__":
main()
"""
Common utilities shared across iOS simulator scripts.
This module centralizes genuinely reused code patterns to eliminate duplication
while respecting Jackson's Law - no over-abstraction, only truly shared logic.
Organization:
- device_utils: Device detection, command building, coordinate transformation
- idb_utils: IDB-specific operations (accessibility tree, element manipulation)
- cache_utils: Progressive disclosure caching for large outputs
- screenshot_utils: Screenshot capture with file and inline modes
"""
from .cache_utils import ProgressiveCache, get_cache
from .device_utils import (
build_idb_command,
build_simctl_command,
get_booted_device_udid,
get_booted_device_udids,
get_device_screen_size,
resolve_udid,
transform_screenshot_coords,
)
from .idb_utils import (
count_elements,
flatten_tree,
get_accessibility_tree,
get_screen_size,
)
from .screenshot_utils import (
capture_screenshot,
format_screenshot_result,
generate_screenshot_name,
get_size_preset,
resize_screenshot,
)
__all__ = [
# cache_utils
"ProgressiveCache",
# device_utils
"build_idb_command",
"build_simctl_command",
# screenshot_utils
"capture_screenshot",
# idb_utils
"count_elements",
"flatten_tree",
"format_screenshot_result",
"generate_screenshot_name",
"get_accessibility_tree",
"get_booted_device_udid",
"get_booted_device_udids",
"get_cache",
"get_device_screen_size",
"get_screen_size",
"get_size_preset",
"resize_screenshot",
"resolve_udid",
"transform_screenshot_coords",
]
#!/usr/bin/env python3
"""
Progressive disclosure cache for large outputs.
Implements cache system to support progressive disclosure pattern:
- Return concise summary with cache_id for large outputs
- User retrieves full details on demand via cache_id
- Reduces token usage by 96% for common queries
Cache directory: ~/.ios-simulator-skill/cache/
Cache expiration: Configurable per cache type (default 1 hour)
Used by:
- sim_list.py - Simulator listing progressive disclosure
- Future: build logs, UI trees, etc.
"""
import contextlib
import json
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from common.env_config import env_int
DEFAULT_MAX_AGE_HOURS = env_int("IOS_SIM_CACHE_TTL_HOURS", 1)
DEFAULT_MAX_ENTRIES = env_int("IOS_SIM_CACHE_MAX_ENTRIES", 500)
class ProgressiveCache:
"""Cache for progressive disclosure pattern.
Stores large outputs with timestamped IDs for on-demand retrieval.
Automatically cleans up expired entries.
"""
def __init__(
self,
cache_dir: str | None = None,
max_age_hours: int | None = None,
max_entries: int | None = None,
):
"""Initialize cache system.
Args:
cache_dir: Cache directory path (default: ~/.ios-simulator-skill/cache/)
max_age_hours: Max age for cache entries before expiration. Defaults to
``IOS_SIM_CACHE_TTL_HOURS`` env var, or 1 hour.
max_entries: Maximum entries retained; oldest are evicted (LRU by mtime).
Defaults to ``IOS_SIM_CACHE_MAX_ENTRIES`` env var, or 500.
"""
if cache_dir is None:
cache_dir = str(Path("~/.ios-simulator-skill/cache").expanduser())
self.cache_dir = Path(cache_dir)
self.max_age_hours = max_age_hours if max_age_hours is not None else DEFAULT_MAX_AGE_HOURS
self.max_entries = max_entries if max_entries is not None else DEFAULT_MAX_ENTRIES
# Create cache directory if needed
self.cache_dir.mkdir(parents=True, exist_ok=True)
def _evict_overflow(self) -> int:
"""Evict oldest entries when count exceeds ``max_entries``. Returns count evicted."""
files = list(self.cache_dir.glob("*.json"))
if len(files) <= self.max_entries:
return 0
# Sort oldest-first by mtime; delete the head of the overflow
files.sort(key=lambda p: p.stat().st_mtime)
to_remove = len(files) - self.max_entries
for path in files[:to_remove]:
with contextlib.suppress(OSError):
path.unlink()
return to_remove
def save(self, data: dict[str, Any], cache_type: str) -> str:
"""Save data to cache and return cache_id.
Args:
data: Dictionary data to cache
cache_type: Type of cache ('simulator-list', 'build-log', 'ui-tree', etc.)
Returns:
Cache ID like 'sim-20251028-143052' for use in progressive disclosure
Example:
cache_id = cache.save({'devices': [...]}, 'simulator-list')
# Returns: 'sim-20251028-143052'
"""
# Generate cache_id with timestamp
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
cache_prefix = cache_type.split("-", maxsplit=1)[0] # e.g., 'sim' from 'simulator-list'
cache_id = f"{cache_prefix}-{timestamp}"
# Save to file
cache_file = self.cache_dir / f"{cache_id}.json"
with open(cache_file, "w") as f:
json.dump(
{
"cache_id": cache_id,
"cache_type": cache_type,
"created_at": datetime.now().isoformat(),
"data": data,
},
f,
indent=2,
)
self._evict_overflow()
return cache_id
def get(self, cache_id: str) -> dict[str, Any] | None:
"""Retrieve data from cache by cache_id.
Args:
cache_id: Cache ID from save() or list_entries()
Returns:
Cached data dictionary, or None if not found/expired
Example:
data = cache.get('sim-20251028-143052')
if data:
print(f"Found {len(data)} devices")
"""
cache_file = self.cache_dir / f"{cache_id}.json"
if not cache_file.exists():
return None
# Check if expired
if self._is_expired(cache_file):
cache_file.unlink() # Delete expired file
return None
try:
with open(cache_file) as f:
entry = json.load(f)
return entry.get("data")
except (OSError, json.JSONDecodeError):
return None
def list_entries(self, cache_type: str | None = None) -> list[dict[str, Any]]:
"""List available cache entries with metadata.
Args:
cache_type: Filter by type (e.g., 'simulator-list'), or None for all
Returns:
List of cache entries with id, type, created_at, age_seconds
Example:
entries = cache.list_entries('simulator-list')
for entry in entries:
print(f"{entry['id']} - {entry['age_seconds']}s old")
"""
entries = []
for cache_file in sorted(self.cache_dir.glob("*.json"), reverse=True):
# Check if expired
if self._is_expired(cache_file):
cache_file.unlink()
continue
try:
with open(cache_file) as f:
entry = json.load(f)
# Filter by type if specified
if cache_type and entry.get("cache_type") != cache_type:
continue
created_at = datetime.fromisoformat(entry.get("created_at", ""))
age_seconds = (datetime.now() - created_at).total_seconds()
entries.append(
{
"id": entry.get("cache_id"),
"type": entry.get("cache_type"),
"created_at": entry.get("created_at"),
"age_seconds": int(age_seconds),
}
)
except (OSError, json.JSONDecodeError, ValueError):
continue
return entries
def cleanup(self, max_age_hours: int | None = None) -> int:
"""Remove expired cache entries.
Args:
max_age_hours: Age threshold (default: uses instance max_age_hours)
Returns:
Number of entries deleted
Example:
deleted = cache.cleanup()
print(f"Deleted {deleted} expired cache entries")
"""
if max_age_hours is None:
max_age_hours = self.max_age_hours
deleted = 0
for cache_file in self.cache_dir.glob("*.json"):
if self._is_expired(cache_file, max_age_hours):
cache_file.unlink()
deleted += 1
return deleted
def clear(self, cache_type: str | None = None) -> int:
"""Clear all cache entries of a type.
Args:
cache_type: Type to clear (e.g., 'simulator-list'), or None to clear all
Returns:
Number of entries deleted
Example:
cleared = cache.clear('simulator-list')
print(f"Cleared {cleared} simulator list entries")
"""
deleted = 0
for cache_file in self.cache_dir.glob("*.json"):
if cache_type is None:
# Clear all
cache_file.unlink()
deleted += 1
else:
# Clear by type
try:
with open(cache_file) as f:
entry = json.load(f)
if entry.get("cache_type") == cache_type:
cache_file.unlink()
deleted += 1
except (OSError, json.JSONDecodeError):
pass
return deleted
def _is_expired(self, cache_file: Path, max_age_hours: int | None = None) -> bool:
"""Check if cache file is expired.
Args:
cache_file: Path to cache file
max_age_hours: Age threshold (default: uses instance max_age_hours)
Returns:
True if file is older than max_age_hours
"""
if max_age_hours is None:
max_age_hours = self.max_age_hours
try:
with open(cache_file) as f:
entry = json.load(f)
created_at = datetime.fromisoformat(entry.get("created_at", ""))
age = datetime.now() - created_at
return age > timedelta(hours=max_age_hours)
except (OSError, json.JSONDecodeError, ValueError):
return True
# Module-level cache instances (lazy-loaded)
_cache_instances: dict[str, ProgressiveCache] = {}
def get_cache(cache_dir: str | None = None) -> ProgressiveCache:
"""Get or create global cache instance.
Args:
cache_dir: Custom cache directory (uses default if None)
Returns:
ProgressiveCache instance
"""
# Use cache_dir as key, or 'default' if None
key = cache_dir or "default"
if key not in _cache_instances:
_cache_instances[key] = ProgressiveCache(cache_dir)
return _cache_instances[key]
#!/usr/bin/env python3
"""
Shared device and simulator utilities.
Common patterns for interacting with simulators via xcrun simctl and IDB.
Standardizes command building and device targeting to prevent errors.
Follows Jackson's Law - only extracts genuinely reused patterns.
Used by:
- app_launcher.py (8 call sites) - App lifecycle commands
- Multiple scripts (15+ locations) - IDB command building
- navigator.py, gesture.py - Coordinate transformation
- test_recorder.py, app_state_capture.py - Auto-UDID detection
"""
import json
import re
import subprocess
import sys
def build_simctl_command(
operation: str,
udid: str | None = None,
*args,
) -> list[str]:
"""
Build xcrun simctl command with proper device handling.
Standardizes command building to prevent device targeting bugs.
Automatically uses "booted" if no UDID provided.
Used by:
- app_launcher.py: launch, terminate, install, uninstall, openurl, listapps, spawn
- Multiple scripts: generic simctl operations
Args:
operation: simctl operation (launch, terminate, install, etc.)
udid: Device UDID (uses 'booted' if None)
*args: Additional command arguments
Returns:
Complete command list ready for subprocess.run()
Examples:
# Launch app on booted simulator
cmd = build_simctl_command("launch", None, "com.app.bundle")
# Returns: ["xcrun", "simctl", "launch", "booted", "com.app.bundle"]
# Launch on specific device
cmd = build_simctl_command("launch", "ABC123", "com.app.bundle")
# Returns: ["xcrun", "simctl", "launch", "ABC123", "com.app.bundle"]
# Install app on specific device
cmd = build_simctl_command("install", "ABC123", "/path/to/app.app")
# Returns: ["xcrun", "simctl", "install", "ABC123", "/path/to/app.app"]
"""
cmd = ["xcrun", "simctl", operation]
# Add device (booted or specific UDID)
cmd.append(udid if udid else "booted")
# Add remaining arguments
cmd.extend(str(arg) for arg in args)
return cmd
def build_idb_command(
operation: str,
udid: str | None = None,
*args,
) -> list[str]:
"""
Build IDB command with proper device targeting.
Standardizes IDB command building across all scripts using IDB.
Handles device UDID consistently.
Used by:
- navigator.py: ui tap, ui text, ui describe-all
- gesture.py: ui swipe, ui tap
- keyboard.py: ui key, ui text, ui tap
- And more: 15+ locations
Args:
operation: IDB operation path (e.g., "ui tap", "ui text", "ui describe-all")
udid: Device UDID (omits --udid flag if None, IDB uses booted by default)
*args: Additional command arguments
Returns:
Complete command list ready for subprocess.run()
Examples:
# Tap on booted simulator
cmd = build_idb_command("ui tap", None, "200", "400")
# Returns: ["idb", "ui", "tap", "200", "400"]
# Tap on specific device
cmd = build_idb_command("ui tap", "ABC123", "200", "400")
# Returns: ["idb", "ui", "tap", "200", "400", "--udid", "ABC123"]
# Get accessibility tree
cmd = build_idb_command("ui describe-all", "ABC123", "--json", "--nested")
# Returns: ["idb", "ui", "describe-all", "--json", "--nested", "--udid", "ABC123"]
# Enter text
cmd = build_idb_command("ui text", None, "hello world")
# Returns: ["idb", "ui", "text", "hello world"]
"""
# Split operation into parts (e.g., "ui tap" -> ["ui", "tap"])
cmd = ["idb"] + operation.split()
# Add arguments
cmd.extend(str(arg) for arg in args)
# Add device targeting if specified (optional for IDB, uses booted by default)
if udid:
cmd.extend(["--udid", udid])
return cmd
def get_booted_device_udids() -> list[str]:
"""
List the UDIDs of every currently booted simulator.
Queries `xcrun simctl list devices booted` and extracts each UDID in the
order reported.
Returns:
UDIDs of all booted simulators, or an empty list if none are booted
(or the query fails).
Example:
udids = get_booted_device_udids()
# ["ABC123-...", "DEF456-..."] when two simulators are running
"""
try:
result = subprocess.run(
["xcrun", "simctl", "list", "devices", "booted"],
capture_output=True,
text=True,
check=True,
)
except subprocess.CalledProcessError:
return []
# Format: " iPhone 16 Pro (ABC123-DEF456) (Booted)"
udids: list[str] = []
for line in result.stdout.split("\n"):
match = re.search(r"\(([A-F0-9\-]{36})\)", line)
if match:
udids.append(match.group(1))
return udids
def get_booted_device_udid() -> str | None:
"""
Auto-detect a booted simulator UDID.
Returns the first booted simulator. When more than one simulator is booted
the choice is ambiguous — gesture/tap commands can silently target a device
other than the one you are watching (idb reports success on the wrong
device, so nothing appears to happen). In that case a warning is printed to
stderr naming the selected device and advising an explicit ``--udid``.
Returns:
UDID of a booted simulator, or None if no simulator is booted.
Example:
udid = get_booted_device_udid()
if udid:
print(f"Booted simulator: {udid}")
else:
print("No simulator is currently booted")
"""
udids = get_booted_device_udids()
if not udids:
return None
if len(udids) > 1:
print(
f"Warning: {len(udids)} booted simulators detected ({', '.join(udids)}). "
f"Auto-selecting {udids[0]} — pass --udid to target a specific device.",
file=sys.stderr,
)
return udids[0]
def resolve_udid(udid_arg: str | None) -> str:
"""
Resolve device UDID with auto-detection fallback.
If udid_arg is provided, returns it immediately.
If None, attempts to auto-detect booted simulator.
Raises error if neither is available.
Args:
udid_arg: Explicit UDID from command line, or None
Returns:
Valid UDID string
Raises:
RuntimeError: If no UDID provided and no booted simulator found
Example:
try:
udid = resolve_udid(args.udid) # args.udid might be None
print(f"Using device: {udid}")
except RuntimeError as e:
print(f"Error: {e}")
sys.exit(1)
"""
if udid_arg:
return udid_arg
booted_udid = get_booted_device_udid()
if booted_udid:
return booted_udid
raise RuntimeError(
"No device UDID provided and no simulator is currently booted.\n"
"Boot a simulator or provide --udid explicitly:\n"
" xcrun simctl boot <device-name>\n"
" python scripts/script_name.py --udid <device-udid>"
)
def get_device_screen_size(udid: str) -> tuple[int, int]:
"""
Get actual screen dimensions for device via accessibility tree.
Queries IDB accessibility tree to determine actual device resolution.
Falls back to iPhone 14 defaults (390x844) if detection fails.
Args:
udid: Device UDID
Returns:
Tuple of (width, height) in pixels
Example:
width, height = get_device_screen_size("ABC123")
print(f"Device screen: {width}x{height}")
"""
try:
cmd = build_idb_command("ui describe-all", udid, "--json")
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
# Parse JSON response
data = json.loads(result.stdout)
tree = data[0] if isinstance(data, list) and len(data) > 0 else data
# Get frame size from root element
if tree and "frame" in tree:
frame = tree["frame"]
width = int(frame.get("width", 390))
height = int(frame.get("height", 844))
return (width, height)
# Fallback
return (390, 844)
except Exception:
# Graceful fallback to iPhone 14 Pro defaults
return (390, 844)
def resolve_device_identifier(identifier: str) -> str:
"""
Resolve device name or partial UDID to full UDID.
Supports multiple identifier formats:
- Full UDID: "ABC-123-DEF456..." (36 character UUID)
- Device name: "iPhone 16 Pro" (matches full name)
- Partial match: "iPhone 16" (matches first device containing this string)
- Special: "booted" (resolves to currently booted device)
Args:
identifier: Device UDID, name, or special value "booted"
Returns:
Full device UDID
Raises:
RuntimeError: If identifier cannot be resolved
Example:
udid = resolve_device_identifier("iPhone 16 Pro")
# Returns: "ABC123DEF456..."
udid = resolve_device_identifier("booted")
# Returns UDID of booted simulator
"""
# Handle "booted" special case
if identifier.lower() == "booted":
booted = get_booted_device_udid()
if booted:
return booted
raise RuntimeError(
"No simulator is currently booted. "
"Boot a simulator first: xcrun simctl boot <device-udid>"
)
# Check if already a full UDID (36 character UUID format)
if re.match(r"^[A-F0-9\-]{36}$", identifier, re.IGNORECASE):
return identifier.upper()
# Try to match by device name
simulators = list_simulators(state=None)
exact_matches = [s for s in simulators if s["name"].lower() == identifier.lower()]
if exact_matches:
return exact_matches[0]["udid"]
# Try partial match
partial_matches = [s for s in simulators if identifier.lower() in s["name"].lower()]
if partial_matches:
return partial_matches[0]["udid"]
# No match found
raise RuntimeError(
f"Device '{identifier}' not found. "
f"Use 'xcrun simctl list devices' to see available simulators."
)
def list_simulators(state: str | None = None) -> list[dict]:
"""
List iOS simulators with optional state filtering.
Queries xcrun simctl and returns structured list of simulators.
Optionally filters by state (available, booted, all).
Args:
state: Optional filter - "available", "booted", or None for all
Returns:
List of simulator dicts with keys:
- "name": Device name (e.g., "iPhone 16 Pro")
- "udid": Device UDID (36 char UUID)
- "state": Device state ("Booted", "Shutdown", "Unavailable")
- "runtime": iOS version (e.g., "iOS 18.0", "unavailable")
- "type": Device type ("iPhone", "iPad", "Apple Watch", etc.)
Example:
# List all simulators
all_sims = list_simulators()
print(f"Total simulators: {len(all_sims)}")
# List only available simulators
available = list_simulators(state="available")
for sim in available:
print(f"{sim['name']} ({sim['state']}) - {sim['udid']}")
# List only booted simulators
booted = list_simulators(state="booted")
for sim in booted:
print(f"Booted: {sim['name']}")
"""
try:
# Query simctl for device list
cmd = ["xcrun", "simctl", "list", "devices", "-j"]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
data = json.loads(result.stdout)
simulators = []
# Parse JSON response
# Format: {"devices": {"iOS 18.0": [{...}, {...}], "iOS 17.0": [...], ...}}
for ios_version, devices in data.get("devices", {}).items():
for device in devices:
sim = {
"name": device.get("name", "Unknown"),
"udid": device.get("udid", ""),
"state": device.get("state", "Unknown"),
"runtime": ios_version,
"type": _extract_device_type(device.get("name", "")),
}
simulators.append(sim)
# Apply state filtering
if state == "booted":
return [s for s in simulators if s["state"] == "Booted"]
if state == "available":
return [s for s in simulators if s["state"] == "Shutdown"] # Available to boot
if state is None:
return simulators
return [s for s in simulators if s["state"].lower() == state.lower()]
except (subprocess.CalledProcessError, json.JSONDecodeError, KeyError) as e:
raise RuntimeError(f"Failed to list simulators: {e}") from e
def _extract_device_type(device_name: str) -> str:
"""
Extract device type from device name.
Parses device name to determine type (iPhone, iPad, Watch, etc.).
Args:
device_name: Full device name (e.g., "iPhone 16 Pro")
Returns:
Device type string
Example:
_extract_device_type("iPhone 16 Pro") # Returns "iPhone"
_extract_device_type("iPad Air") # Returns "iPad"
_extract_device_type("Apple Watch Series 9") # Returns "Watch"
"""
if "iPhone" in device_name:
return "iPhone"
if "iPad" in device_name:
return "iPad"
if "Watch" in device_name or "Apple Watch" in device_name:
return "Watch"
if "TV" in device_name or "Apple TV" in device_name:
return "TV"
return "Unknown"
def transform_screenshot_coords(
x: float,
y: float,
screenshot_width: int,
screenshot_height: int,
device_width: int,
device_height: int,
) -> tuple[int, int]:
"""
Transform screenshot coordinates to device coordinates.
Handles the case where a screenshot was downscaled (e.g., to 'half' size)
and needs to be transformed back to actual device pixel coordinates
for accurate tapping.
The transformation is linear:
device_x = (screenshot_x / screenshot_width) * device_width
device_y = (screenshot_y / screenshot_height) * device_height
Args:
x, y: Coordinates in the screenshot
screenshot_width, screenshot_height: Screenshot dimensions (e.g., 195, 422)
device_width, device_height: Actual device dimensions (e.g., 390, 844)
Returns:
Tuple of (device_x, device_y) in device pixels
Example:
# Screenshot taken at 'half' size: 195x422 (from 390x844 device)
device_x, device_y = transform_screenshot_coords(
100, 200, # Tap point in screenshot
195, 422, # Screenshot dimensions
390, 844 # Device dimensions
)
print(f"Tap at device coords: ({device_x}, {device_y})")
# Output: Tap at device coords: (200, 400)
"""
device_x = int((x / screenshot_width) * device_width)
device_y = int((y / screenshot_height) * device_height)
return (device_x, device_y)
#!/usr/bin/env python3
"""Env-var overrides for tunable defaults.
All overrides use the ``IOS_SIM_`` prefix. See SKILL.md → Configuration
for the canonical list of supported variables.
"""
import os
import sys
def env_int(name: str, default: int, min_value: int = 1) -> int:
"""Read ``name`` as an int, falling back to ``default`` on miss or parse error."""
raw = os.environ.get(name)
if raw is None or raw == "":
return default
try:
value = int(raw)
except ValueError:
print(f"warning: {name}={raw!r} is not an int; using default {default}", file=sys.stderr)
return default
return max(value, min_value)
def env_float(name: str, default: float, min_value: float = 0.0) -> float:
"""Read ``name`` as a float, falling back to ``default`` on miss or parse error."""
raw = os.environ.get(name)
if raw is None or raw == "":
return default
try:
value = float(raw)
except ValueError:
print(f"warning: {name}={raw!r} is not a float; using default {default}", file=sys.stderr)
return default
return max(value, min_value)
#!/usr/bin/env python3
"""HangBuster session storage — own dir layout, no ProgressiveCache reuse.
Each session is a directory under ``~/.ios-simulator-skill/sessions/<id>/``
containing ``meta.json`` (config + pid + status), ``events.jsonl``
(append-only normalised events), and ``summary.json`` (post-stop).
The parent creates the directory and writes initial meta. The detached
worker updates meta with its own pid (avoids pidfile race) and appends to
events.jsonl. ``--stop`` SIGTERMs the worker, drains events, builds a
``SessionSummary``, and writes ``summary.json``.
"""
from __future__ import annotations
import contextlib
import json
import os
import re
import secrets
import signal
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from common.env_config import env_int
from common.hang_pipeline import (
SessionSummary,
SummaryBuilder,
event_from_jsonl,
summary_from_json,
summary_to_json,
)
# === CONSTANTS ===
DEFAULT_SESSIONS_DIR = Path("~/.ios-simulator-skill/sessions").expanduser()
DEFAULT_TTL_HOURS = env_int("IOS_SIM_HANG_SESSION_TTL_HOURS", 24)
_STATUS_PENDING = "pending"
_STATUS_RUNNING = "running"
_STATUS_STOPPED = "stopped"
_STATUS_CRASHED = "crashed"
_DURATION_RE = re.compile(r"(\d+)([smhd])$")
# === TYPES ===
@dataclass
class SessionMeta:
"""Parent + worker writes to meta.json."""
session_id: str
started_at: str
started_at_ms: int
args: dict
pid: int | None = None
status: str = _STATUS_PENDING
stopped_at: str | None = None
stopped_at_ms: int | None = None
extras: dict = field(default_factory=dict)
def to_json(self) -> dict:
return {
"session_id": self.session_id,
"started_at": self.started_at,
"started_at_ms": self.started_at_ms,
"args": self.args,
"pid": self.pid,
"status": self.status,
"stopped_at": self.stopped_at,
"stopped_at_ms": self.stopped_at_ms,
"extras": self.extras,
}
@classmethod
def from_json(cls, payload: dict) -> SessionMeta:
return cls(
session_id=payload["session_id"],
started_at=payload["started_at"],
started_at_ms=payload["started_at_ms"],
args=payload.get("args", {}),
pid=payload.get("pid"),
status=payload.get("status", _STATUS_PENDING),
stopped_at=payload.get("stopped_at"),
stopped_at_ms=payload.get("stopped_at_ms"),
extras=payload.get("extras", {}),
)
# === SESSION STORE ===
class SessionStore:
"""Filesystem-backed session repository."""
def __init__(self, base_dir: Path | None = None):
self.base_dir = Path(base_dir).expanduser() if base_dir else DEFAULT_SESSIONS_DIR
self.base_dir.mkdir(parents=True, exist_ok=True)
# === PUBLIC API ===
def create(self, args: dict) -> SessionMeta:
"""Generate id + dir + initial meta.json. Caller detaches the worker next."""
session_id = _generate_session_id()
session_dir = self.base_dir / session_id
session_dir.mkdir(parents=True, exist_ok=False)
# Empty events file so the worker can `open(..., 'a')` cleanly.
(session_dir / "events.jsonl").touch()
now = datetime.now()
meta = SessionMeta(
session_id=session_id,
started_at=now.isoformat(),
started_at_ms=int(now.timestamp() * 1000),
args=args,
status=_STATUS_PENDING,
)
self._write_meta(meta)
return meta
def wait_for_worker(self, session_id: str, timeout_seconds: float = 2.0) -> SessionMeta:
"""Poll meta.json until status=running or timeout. Raises on timeout."""
deadline = time.time() + timeout_seconds
while time.time() < deadline:
meta = self.load_meta(session_id)
if meta.status == _STATUS_RUNNING and meta.pid:
return meta
time.sleep(0.05)
raise TimeoutError(f"Worker for {session_id} did not register within {timeout_seconds}s")
def claim_worker(self, session_id: str, pid: int) -> SessionMeta:
"""Called by worker on startup. Writes pid + status=running into meta."""
meta = self.load_meta(session_id)
meta.pid = pid
meta.status = _STATUS_RUNNING
self._write_meta(meta)
return meta
def persist_worker_counters(self, session_id: str, counters: dict) -> None:
"""Worker calls this at shutdown to flush its line counters into meta.
Re-reads meta from disk so a concurrent terminal status — ``stopped``
from the parent's ``stop()`` or ``crashed`` from this worker's own
``mark_crashed`` — is not clobbered back to ``running``.
"""
meta = self.load_meta(session_id)
meta.extras["line_counters"] = counters
if meta.status not in (_STATUS_STOPPED, _STATUS_CRASHED):
meta.status = _STATUS_RUNNING
self._write_meta(meta)
def stop(self, session_id: str, summary: SessionSummary) -> SessionMeta:
"""Mark session stopped and persist the computed summary."""
meta = self.load_meta(session_id)
meta.status = _STATUS_STOPPED
now = datetime.now()
meta.stopped_at = now.isoformat()
meta.stopped_at_ms = int(now.timestamp() * 1000)
self._write_meta(meta)
self._write_summary(session_id, summary)
return meta
def mark_crashed(self, session_id: str) -> None:
"""Best-effort: tag a session whose worker exited without a summary.
Records ``stopped_at`` / ``stopped_at_ms`` so capture-duration math in
``build_summary`` and ``--list-sessions`` reflects when the worker
actually died, not when the session was finally inspected.
"""
try:
meta = self.load_meta(session_id)
except FileNotFoundError:
return
meta.status = _STATUS_CRASHED
now = datetime.now()
meta.stopped_at = now.isoformat()
meta.stopped_at_ms = int(now.timestamp() * 1000)
self._write_meta(meta)
def signal_worker(self, session_id: str, sig: int = signal.SIGTERM) -> bool:
"""Send ``sig`` to the worker pid recorded in meta.json. Returns True if delivered."""
meta = self.load_meta(session_id)
if not meta.pid:
return False
try:
os.kill(meta.pid, sig)
return True
except ProcessLookupError:
return False
except PermissionError:
return False
def load_meta(self, session_id: str) -> SessionMeta:
path = self._meta_path(session_id)
if not path.exists():
raise FileNotFoundError(f"No meta.json for session {session_id}")
with open(path) as handle:
return SessionMeta.from_json(json.load(handle))
def load_summary(self, session_id: str) -> SessionSummary | None:
path = self._summary_path(session_id)
if not path.exists():
return None
with open(path) as handle:
return summary_from_json(json.load(handle))
def stash_auto_sample(self, session_id: str, fingerprint: str, sample: dict) -> None:
"""Append an auto-sample record to ``<session>/auto_samples.jsonl``.
Append-only JSONL avoids the read-modify-write race that an aggregate
JSON dict would have under concurrent worker stashes. Readers reduce
last-write-wins per fingerprint.
"""
path = self._auto_samples_path(session_id)
line = json.dumps({"fingerprint": fingerprint, "sample": sample}, separators=(",", ":"))
with open(path, "a") as handle:
handle.write(line + "\n")
handle.flush()
os.fsync(handle.fileno())
def read_auto_samples(self, session_id: str) -> dict[str, list[dict]]:
"""Return ``{fingerprint: [sample, ...]}`` preserving write order.
Multiple capture mechanisms (e.g. ``--auto-sample`` + ``--auto-spindump``)
can stash distinct records under one fingerprint; callers disambiguate
via the ``kind`` field on each sample payload.
"""
path = self._auto_samples_path(session_id)
if not path.exists():
return {}
samples: dict[str, list[dict]] = {}
with open(path) as handle:
for raw in handle:
line = raw.strip()
if not line:
continue
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
fingerprint = payload.get("fingerprint")
if fingerprint is None:
continue
samples.setdefault(fingerprint, []).append(payload.get("sample"))
return samples
def read_events(self, session_id: str) -> list:
"""Read all events.jsonl lines, returning NormalisedEvent instances.
Skips non-event sentinel lines (e.g. ``{"event": "stream_ended"}``).
"""
path = self._events_path(session_id)
if not path.exists():
return []
events = []
with open(path) as handle:
for raw in handle:
line = raw.strip()
if not line:
continue
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
# Skip non-event sentinel lines (e.g. {"event": "stream_ended"}).
if payload.get("event") == "stream_ended":
continue
try:
events.append(event_from_jsonl(line))
except (json.JSONDecodeError, KeyError):
continue
return events
def events_path(self, session_id: str) -> Path:
"""Worker writes here. Public so the worker can open it line-buffered."""
return self._events_path(session_id)
def raw_path(self, session_id: str, gzipped: bool = False) -> Path:
"""Raw-capture NDJSON path. ``gzipped=True`` returns the post-stop path."""
name = "raw.ndjson.gz" if gzipped else "raw.ndjson"
return self.base_dir / session_id / name
def session_dir(self, session_id: str) -> Path:
return self.base_dir / session_id
def session_total_bytes(self, session_id: str) -> int:
"""Sum of all files under a session dir. Used by aggregate-cap pruning."""
total = 0
session_path = self.session_dir(session_id)
if not session_path.exists():
return 0
for path in session_path.rglob("*"):
if path.is_file():
with contextlib.suppress(OSError):
total += path.stat().st_size
return total
def prune_to_aggregate_cap(self, max_bytes: int) -> int:
"""Drop oldest sessions until total bytes ≤ max_bytes. Returns deletions.
Pairs with ``prune_expired``: TTL handles age, this handles disk usage
when activity outpaces TTL. Both are called automatically on every
``create`` so the user never has to clean up manually.
"""
if max_bytes <= 0:
return 0
# Oldest first — deletion order.
entries: list[tuple[int, str, int]] = [] # (started_at_ms, session_id, bytes)
total = 0
for entry in self.base_dir.iterdir():
if not entry.is_dir():
continue
try:
meta = self.load_meta(entry.name)
except (FileNotFoundError, json.JSONDecodeError):
continue
size = self.session_total_bytes(entry.name)
total += size
entries.append((meta.started_at_ms, entry.name, size))
if total <= max_bytes:
return 0
entries.sort(key=lambda e: e[0]) # oldest first
deleted = 0
for _, session_id, size in entries:
if total <= max_bytes:
break
_remove_tree(self.session_dir(session_id))
total -= size
deleted += 1
return deleted
def list_sessions(self) -> list[SessionMeta]:
"""All non-expired session metas, newest first."""
metas: list[SessionMeta] = []
for entry in self.base_dir.iterdir():
if not entry.is_dir():
continue
try:
metas.append(self.load_meta(entry.name))
except (FileNotFoundError, json.JSONDecodeError):
continue
metas.sort(key=lambda m: m.started_at_ms, reverse=True)
return metas
def clear(self, older_than: str | None = None) -> int:
"""Delete session dirs. ``older_than`` is a duration string like ``24h``."""
cutoff_ms = _resolve_cutoff_ms(older_than) if older_than else None
deleted = 0
for entry in self.base_dir.iterdir():
if not entry.is_dir():
continue
try:
meta = self.load_meta(entry.name)
except (FileNotFoundError, json.JSONDecodeError):
_remove_tree(entry)
deleted += 1
continue
if cutoff_ms is None or meta.started_at_ms <= cutoff_ms:
_remove_tree(entry)
deleted += 1
return deleted
def prune_expired(self, ttl_hours: int | None = None) -> int:
"""Remove sessions older than ttl. Called on every ``create``."""
ttl = ttl_hours if ttl_hours is not None else DEFAULT_TTL_HOURS
cutoff = int((datetime.now() - timedelta(hours=ttl)).timestamp() * 1000)
return self._clear_older_than_ms(cutoff)
# === SUMMARY HELPERS ===
def build_summary(
self,
session_id: str,
matched_lines: int = 0,
total_lines: int = 0,
dropped_below_threshold: int = 0,
extras: dict | None = None,
top_n: int | None = None,
) -> SessionSummary:
"""Convenience: read events.jsonl and run the pipeline through SummaryBuilder.
Duration prefers ``meta.stopped_at_ms`` (set on both ``stop()`` and
``mark_crashed()``) so summaries for crashed/stopped sessions reflect
the actual capture window, not the time of inspection. Live sessions
without ``stopped_at_ms`` fall back to ``now`` as before.
"""
meta = self.load_meta(session_id)
events = self.read_events(session_id)
end_ms = meta.stopped_at_ms or int(datetime.now().timestamp() * 1000)
duration_ms = end_ms - meta.started_at_ms
builder = SummaryBuilder(
session_id=session_id,
started_at=meta.started_at,
duration_ms=max(0, duration_ms),
matched_lines=matched_lines,
total_lines=total_lines,
dropped_below_threshold=dropped_below_threshold,
extras=extras or {},
)
return builder.build(
events,
top_n=top_n,
auto_samples_by_fp=self.read_auto_samples(session_id),
)
# === PRIVATE ===
def _meta_path(self, session_id: str) -> Path:
return self.base_dir / session_id / "meta.json"
def _events_path(self, session_id: str) -> Path:
return self.base_dir / session_id / "events.jsonl"
def _summary_path(self, session_id: str) -> Path:
return self.base_dir / session_id / "summary.json"
def _auto_samples_path(self, session_id: str) -> Path:
return self.base_dir / session_id / "auto_samples.jsonl"
def _write_meta(self, meta: SessionMeta) -> None:
path = self._meta_path(meta.session_id)
tmp = path.with_suffix(".json.tmp")
# Atomic write — concurrent reads (e.g. the parent polling) never see a half-file.
# fsync before replace makes the new contents durable, not just atomically renamed.
with open(tmp, "w") as handle:
json.dump(meta.to_json(), handle, indent=2)
handle.flush()
os.fsync(handle.fileno())
tmp.replace(path)
def _write_summary(self, session_id: str, summary: SessionSummary) -> None:
path = self._summary_path(session_id)
tmp = path.with_suffix(".json.tmp")
with open(tmp, "w") as handle:
json.dump(summary_to_json(summary), handle, indent=2)
handle.flush()
os.fsync(handle.fileno())
tmp.replace(path)
def _clear_older_than_ms(self, cutoff_ms: int) -> int:
deleted = 0
for entry in self.base_dir.iterdir():
if not entry.is_dir():
continue
try:
meta = self.load_meta(entry.name)
except (FileNotFoundError, json.JSONDecodeError):
_remove_tree(entry)
deleted += 1
continue
if meta.started_at_ms <= cutoff_ms:
_remove_tree(entry)
deleted += 1
return deleted
# === MODULE-LEVEL HELPERS ===
def _generate_session_id() -> str:
"""``hang-YYYYMMDD-HHmmss-XXXX`` — random hex suffix avoids same-second collisions."""
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
suffix = secrets.token_hex(2)
return f"hang-{timestamp}-{suffix}"
def _resolve_cutoff_ms(duration_str: str) -> int:
"""Parse e.g. ``24h``, ``30m`` and return the epoch-ms threshold."""
match = _DURATION_RE.match(duration_str.strip().lower())
if not match:
raise ValueError(f"Invalid duration: {duration_str!r}. Use 30s/5m/24h/7d.")
value, unit = int(match.group(1)), match.group(2)
seconds = value * {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
cutoff = datetime.now() - timedelta(seconds=seconds)
return int(cutoff.timestamp() * 1000)
def _remove_tree(path: Path) -> None:
"""rm -rf path. Used for session-dir cleanup."""
for child in path.iterdir():
if child.is_dir():
_remove_tree(child)
else:
with contextlib.suppress(FileNotFoundError):
child.unlink()
with contextlib.suppress(OSError):
path.rmdir()
"""
Xcode build automation module.
Provides structured, modular access to xcodebuild and xcresult functionality.
"""
from .builder import BuildRunner
from .cache import XCResultCache
from .config import Config
from .reporter import OutputFormatter
from .xcresult import XCResultParser
__all__ = ["BuildRunner", "Config", "OutputFormatter", "XCResultCache", "XCResultParser"]
Related skills
How it compares
Pick ios-simulator-skill when you need a ready-made iOS simulator script library for agents rather than writing custom xcodebuild wrappers from scratch.
FAQ
What is ios-simulator-skill?
29 production-ready scripts for iOS app testing, building, and automation. Provides semantic UI navigation, build automation, accessibility testing, and simulator lifecycle managem
When should I use ios-simulator-skill?
29 production-ready scripts for iOS app testing, building, and automation. Provides semantic UI navigation, build automation, accessibility testing, and simulator lifecycle managem
Is ios-simulator-skill safe to install?
Review the Security Audits panel on this page before production use.