@leejungkiin/awkit 1.7.0 → 1.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/awk.js +576 -84
- package/core/CLAUDE.md +1 -1
- package/core/GEMINI.md +148 -167
- package/core/GEMINI.md.bak +149 -116
- package/core/skill-runtime-manifest.json +3 -0
- package/docs/Claude Fable 5.md +3826 -0
- package/docs/android_kotlin_system_instruction.md +210 -0
- package/docs/brainstorm_ponytail_integration.md +146 -0
- package/docs/brainstorm_smart_setup.md +113 -0
- package/docs/deep-research-report (1).md +293 -0
- package/docs/history/GEMINI.v1.md +135 -0
- package/docs/history/brainstorm_antigravity_unified_architecture.v1.md +105 -0
- package/docs/history/implementation_plan.v1.md +58 -0
- package/package.json +4 -1
- package/scripts/artifact-storage.js +130 -0
- package/scripts/automation-gate.js +40 -7
- package/scripts/claude-plan.js +76 -0
- package/scripts/dependency-manager.js +210 -0
- package/scripts/exec-rtk.js +11 -5
- package/scripts/i18n-helper.js +381 -0
- package/scripts/multi-model-pipeline.js +144 -0
- package/skill-packs/mobile-ios/pack.json +4 -2
- package/skill-packs/reverse-engineering/pack.json +1 -0
- package/skills/CATALOG.md +20 -0
- package/skills/GEMINI.md +9 -1
- package/skills/TRIGGER_INDEX.md +10 -0
- package/skills/ai-music/SKILL.md +275 -0
- package/skills/android-re-analyzer/SKILL.md +238 -0
- package/skills/android-re-analyzer/references/api-extraction-patterns.md +119 -0
- package/skills/android-re-analyzer/references/call-flow-analysis.md +176 -0
- package/skills/android-re-analyzer/references/fernflower-usage.md +115 -0
- package/skills/android-re-analyzer/references/jadx-usage.md +116 -0
- package/skills/android-re-analyzer/references/setup-guide.md +221 -0
- package/skills/android-re-analyzer/scripts/check-deps.sh +129 -0
- package/skills/android-re-analyzer/scripts/decompile.sh +375 -0
- package/skills/android-re-analyzer/scripts/find-api-calls.sh +118 -0
- package/skills/android-re-analyzer/scripts/install-dep.sh +448 -0
- package/skills/animal-island-ui-style/SKILL.md +1450 -0
- package/skills/app-store-review-agent/SKILL.md +164 -0
- package/skills/app-store-review-agent/references/guidelines/README.md +154 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/ai_apps.md +37 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/all_apps.md +50 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/crypto_finance.md +31 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/games.md +31 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/health_fitness.md +31 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/kids.md +27 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/macos.md +38 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/social_ugc.md +32 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/subscription_iap.md +34 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/vpn.md +18 -0
- package/skills/app-store-review-agent/references/rules/design/minimum_functionality.md +96 -0
- package/skills/app-store-review-agent/references/rules/design/sign_in_with_apple.md +54 -0
- package/skills/app-store-review-agent/references/rules/entitlements/unused_entitlements.md +83 -0
- package/skills/app-store-review-agent/references/rules/metadata/accurate_metadata.md +54 -0
- package/skills/app-store-review-agent/references/rules/metadata/apple_trademark.md +99 -0
- package/skills/app-store-review-agent/references/rules/metadata/china_storefront.md +72 -0
- package/skills/app-store-review-agent/references/rules/metadata/competitor_terms.md +56 -0
- package/skills/app-store-review-agent/references/rules/metadata/subscription_metadata.md +81 -0
- package/skills/app-store-review-agent/references/rules/privacy/privacy_manifest.md +84 -0
- package/skills/app-store-review-agent/references/rules/privacy/unnecessary_data.md +60 -0
- package/skills/app-store-review-agent/references/rules/subscription/misleading_pricing.md +63 -0
- package/skills/app-store-review-agent/references/rules/subscription/missing_tos_pp.md +54 -0
- package/skills/awf-ponytail/SKILL.md +91 -0
- package/skills/awf-ponytail-review/SKILL.md +67 -0
- package/skills/awf-session-restore/SKILL.md +3 -3
- package/skills/brainstorm-agent/SKILL.md +11 -2
- package/skills/brainstorm-agent/templates/brief-template.md +8 -0
- package/skills/claude-planner/SKILL.md +47 -0
- package/skills/code-review/SKILL.md +87 -0
- package/skills/expo-game-development/SKILL.md +163 -0
- package/skills/flutter/LICENSE.txt +202 -0
- package/skills/flutter/SKILL.md +127 -0
- package/skills/flutter-project-creater/LICENSE.txt +202 -0
- package/skills/flutter-project-creater/SKILL.md +106 -0
- package/skills/game-developer/SKILL.md +163 -0
- package/skills/game-developer/references/ecs-patterns.md +501 -0
- package/skills/game-developer/references/multiplayer-networking.md +475 -0
- package/skills/game-developer/references/performance-optimization.md +422 -0
- package/skills/game-developer/references/unity-patterns.md +271 -0
- package/skills/game-developer/references/unreal-cpp.md +352 -0
- package/skills/generate-gui-assets/SKILL.md +305 -0
- package/skills/generate-gui-assets/agents/openai.yaml +4 -0
- package/skills/generate-gui-assets/references/catalog-schema.md +58 -0
- package/skills/generate-gui-assets/references/extraction-techniques.md +21 -0
- package/skills/generate-gui-assets/references/prompt-patterns.md +58 -0
- package/skills/generate-gui-assets/scripts/__pycache__/clean_chroma_edges.cpython-311.pyc +0 -0
- package/skills/generate-gui-assets/scripts/build_gui_contact_sheet.py +51 -0
- package/skills/generate-gui-assets/scripts/clean_chroma_edges.py +262 -0
- package/skills/generate-gui-assets/scripts/copy_approved_icons.py +64 -0
- package/skills/generate-gui-assets/scripts/prepare_gui_asset_run.py +91 -0
- package/skills/generate-gui-assets/scripts/suggest_grid_options.py +63 -0
- package/skills/generate-gui-assets/scripts/validate_gui_catalog.py +50 -0
- package/skills/godot-game-development/SKILL.md +142 -0
- package/skills/hatch-pet/LICENSE.txt +201 -0
- package/skills/hatch-pet/SKILL.md +420 -0
- package/skills/hatch-pet/agents/openai.yaml +4 -0
- package/skills/hatch-pet/references/animation-rows.md +29 -0
- package/skills/hatch-pet/references/codex-pet-contract.md +35 -0
- package/skills/hatch-pet/references/qa-rubric.md +60 -0
- package/skills/hatch-pet/scripts/__pycache__/clean_chroma_edges.cpython-311.pyc +0 -0
- package/skills/hatch-pet/scripts/clean_chroma_edges.py +262 -0
- package/skills/hatch-pet/scripts/compose_atlas.py +150 -0
- package/skills/hatch-pet/scripts/derive_running_left_from_running_right.py +143 -0
- package/skills/hatch-pet/scripts/extract_strip_frames.py +323 -0
- package/skills/hatch-pet/scripts/finalize_pet_run.py +382 -0
- package/skills/hatch-pet/scripts/generate_pet_images.py +287 -0
- package/skills/hatch-pet/scripts/inspect_frames.py +246 -0
- package/skills/hatch-pet/scripts/make_contact_sheet.py +96 -0
- package/skills/hatch-pet/scripts/package_custom_pet.py +108 -0
- package/skills/hatch-pet/scripts/pet_job_status.py +117 -0
- package/skills/hatch-pet/scripts/prepare_pet_run.py +673 -0
- package/skills/hatch-pet/scripts/queue_pet_repairs.py +172 -0
- package/skills/hatch-pet/scripts/record_imagegen_result.py +250 -0
- package/skills/hatch-pet/scripts/render_animation_videos.py +134 -0
- package/skills/hatch-pet/scripts/render_animation_videos.sh +5 -0
- package/skills/hatch-pet/scripts/validate_atlas.py +139 -0
- package/skills/i18n-orchestrator/SKILL.md +37 -0
- package/skills/ios-simulator-skill/SKILL.md +390 -0
- package/skills/ios-simulator-skill/scripts/accessibility_audit.py +300 -0
- package/skills/ios-simulator-skill/scripts/app_launcher.py +326 -0
- package/skills/ios-simulator-skill/scripts/app_state_capture.py +400 -0
- package/skills/ios-simulator-skill/scripts/appearance.py +385 -0
- package/skills/ios-simulator-skill/scripts/build_and_test.py +348 -0
- package/skills/ios-simulator-skill/scripts/clipboard.py +103 -0
- package/skills/ios-simulator-skill/scripts/common/__init__.py +61 -0
- package/skills/ios-simulator-skill/scripts/common/cache_utils.py +289 -0
- package/skills/ios-simulator-skill/scripts/common/device_utils.py +462 -0
- package/skills/ios-simulator-skill/scripts/common/env_config.py +35 -0
- package/skills/ios-simulator-skill/scripts/common/hang_pipeline.py +862 -0
- package/skills/ios-simulator-skill/scripts/common/hang_sessions.py +490 -0
- package/skills/ios-simulator-skill/scripts/common/idb_utils.py +180 -0
- package/skills/ios-simulator-skill/scripts/common/screenshot_utils.py +338 -0
- package/skills/ios-simulator-skill/scripts/container.py +668 -0
- package/skills/ios-simulator-skill/scripts/gesture.py +394 -0
- package/skills/ios-simulator-skill/scripts/hang_watcher.py +1533 -0
- package/skills/ios-simulator-skill/scripts/keyboard.py +391 -0
- package/skills/ios-simulator-skill/scripts/localization_audit.py +483 -0
- package/skills/ios-simulator-skill/scripts/location.py +467 -0
- package/skills/ios-simulator-skill/scripts/log_monitor.py +493 -0
- package/skills/ios-simulator-skill/scripts/model_inspector.py +645 -0
- package/skills/ios-simulator-skill/scripts/navigator.py +461 -0
- package/skills/ios-simulator-skill/scripts/privacy_manager.py +310 -0
- package/skills/ios-simulator-skill/scripts/push_notification.py +240 -0
- package/skills/ios-simulator-skill/scripts/screen_mapper.py +296 -0
- package/skills/ios-simulator-skill/scripts/sim_health_check.sh +245 -0
- package/skills/ios-simulator-skill/scripts/sim_list.py +299 -0
- package/skills/ios-simulator-skill/scripts/simctl_boot.py +312 -0
- package/skills/ios-simulator-skill/scripts/simctl_create.py +316 -0
- package/skills/ios-simulator-skill/scripts/simctl_delete.py +357 -0
- package/skills/ios-simulator-skill/scripts/simctl_erase.py +351 -0
- package/skills/ios-simulator-skill/scripts/simctl_shutdown.py +290 -0
- package/skills/ios-simulator-skill/scripts/simulator_selector.py +375 -0
- package/skills/ios-simulator-skill/scripts/status_bar.py +250 -0
- package/skills/ios-simulator-skill/scripts/test_recorder.py +323 -0
- package/skills/ios-simulator-skill/scripts/visual_diff.py +235 -0
- package/skills/ios-simulator-skill/scripts/xcode/__init__.py +13 -0
- package/skills/ios-simulator-skill/scripts/xcode/builder.py +397 -0
- package/skills/ios-simulator-skill/scripts/xcode/cache.py +204 -0
- package/skills/ios-simulator-skill/scripts/xcode/config.py +178 -0
- package/skills/ios-simulator-skill/scripts/xcode/reporter.py +343 -0
- package/skills/ios-simulator-skill/scripts/xcode/xcresult.py +451 -0
- package/skills/ios-visual-qa-strategist/SKILL.md +111 -0
- package/skills/ios-visual-qa-strategist/agents/openai.yaml +4 -0
- package/skills/ios-visual-qa-strategist/references/ios-tool-selection.md +61 -0
- package/skills/ios-visual-qa-strategist/references/minimal-capture-policy.md +56 -0
- package/skills/ios-visual-qa-strategist/references/visual-reasoning-heuristics.md +53 -0
- package/skills/orchestrator/SKILL.md +0 -20
- package/skills/persistent-storage/SKILL.md +55 -0
- package/skills/short-maker/SKILL.md +23 -0
- package/skills/short-maker/scripts/effects.js +56 -0
- package/skills/short-maker/scripts/shortmaker-bridge.js +332 -0
- package/skills/short-maker/scripts/videomix.js +601 -0
- package/skills/short-maker/templates/hyperframes/cinematic-character.template.html +172 -0
- package/skills/short-maker/templates/hyperframes/index.template.html +194 -0
- package/skills/smali-to-kotlin/SKILL.md +128 -0
- package/skills/smali-to-kotlin/examples/getting-started/tech-stack.md +58 -0
- package/skills/smali-to-kotlin/examples/pipeline/data-ui-parity.md +118 -0
- package/skills/smali-to-kotlin/examples/pipeline/scanner-and-bootstrap.md +106 -0
- package/skills/smali-to-kotlin/library-patterns.md +189 -0
- package/skills/smali-to-kotlin/phase-0-discovery.md +128 -0
- package/skills/smali-to-kotlin/phase-1-architecture.md +166 -0
- package/skills/smali-to-kotlin/phase-2-blueprint-ui.md +347 -0
- package/skills/smali-to-kotlin/phase-2-blueprint.md +228 -0
- package/skills/smali-to-kotlin/phase-3-build.md +248 -0
- package/skills/smali-to-kotlin/phase-3-logic-build.md +268 -0
- package/skills/smali-to-kotlin/smali-reading-guide.md +310 -0
- package/skills/smali-to-kotlin/templates/app-map.md +101 -0
- package/skills/smali-to-kotlin/templates/architecture.md +142 -0
- package/skills/smali-to-kotlin/templates/blueprint.md +145 -0
- package/skills/spec-gate/SKILL.md +6 -2
- package/skills/symphony-enforcer/SKILL.md +8 -0
- package/skills/symphony-enforcer/examples/mindful-stop.md +2 -0
- package/skills/symphony-enforcer/examples/three-phase.md +16 -0
- package/skills/symphony-enforcer/examples/trigger-points.md +7 -1
- package/skills/unity-game-development/SKILL.md +231 -0
- package/skills/verification-gate/SKILL.md +4 -2
- package/skills/video-edit/SKILL.md +36 -0
- package/skills/video-edit/scripts/video_edit.py +324 -0
- package/templates/setup-mapping.json +48 -0
- package/templates/specs/design-template.md +161 -71
- package/templates/specs/requirements-template.md +65 -133
- package/templates/specs/task-spec-template.xml +3 -0
- package/workflows/_uncategorized/critic.md +40 -0
- package/workflows/_uncategorized/git-rebase-flow.md +81 -0
- package/workflows/_uncategorized/image-gen.md +118 -0
- package/workflows/_uncategorized/multi-model-pipeline.md +60 -0
- package/workflows/_uncategorized/pixel-gen.md +86 -0
- package/workflows/_uncategorized/pixel-setup.md +90 -0
- package/workflows/_uncategorized/ponytail-review.md +59 -0
- package/workflows/_uncategorized/reverse-android-build.md +222 -0
- package/workflows/_uncategorized/reverse-android-design.md +139 -0
- package/workflows/_uncategorized/reverse-android-discover.md +150 -0
- package/workflows/_uncategorized/reverse-android-scan.md +158 -0
- package/workflows/_uncategorized/reverse-android.md +143 -0
- package/workflows/_uncategorized/reverse-ios-build.md +240 -0
- package/workflows/_uncategorized/reverse-ios-design.md +112 -0
- package/workflows/_uncategorized/reverse-ios-discover.md +120 -0
- package/workflows/_uncategorized/reverse-ios-scan.md +155 -0
- package/workflows/_uncategorized/reverse-ios.md +152 -0
- package/workflows/_uncategorized/safety-router.md +34 -0
- package/workflows/_uncategorized/teach.md +89 -0
- package/workflows/_uncategorized/verify-ui.md +53 -0
- package/workflows/_uncategorized/visualize-screenshots.md +34 -0
- package/workflows/ads/ads-analyst.md +201 -0
- package/workflows/ads/ads-audit.md +106 -0
- package/workflows/ads/ads-optimize.md +97 -0
- package/workflows/ads/ads-targeting.md +241 -0
- package/workflows/ads/adsExpert.md +160 -0
- package/workflows/ads/smali-ads-config.md +400 -0
- package/workflows/ads/smali-ads-flow.md +331 -0
- package/workflows/ads/smali-ads-interstitial.md +377 -0
- package/workflows/ads/smali-ads-native.md +382 -0
- package/workflows/context/teach.md +89 -0
- package/workflows/gitnexus.md +8 -8
- package/workflows/lifecycle/brainstorm.md +43 -0
- package/workflows/lifecycle/code.md +5 -0
- package/workflows/lifecycle/init.md +23 -5
- package/workflows/lifecycle/multi-model-pipeline.md +60 -0
- package/workflows/quality/ponytail-review.md +59 -0
- package/workflows/roles/critic.md +40 -0
- package/workflows/roles/safety-router.md +34 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""HangBuster session storage — own dir layout, no ProgressiveCache reuse.
|
|
3
|
+
|
|
4
|
+
Each session is a directory under ``~/.ios-simulator-skill/sessions/<id>/``
|
|
5
|
+
containing ``meta.json`` (config + pid + status), ``events.jsonl``
|
|
6
|
+
(append-only normalised events), and ``summary.json`` (post-stop).
|
|
7
|
+
|
|
8
|
+
The parent creates the directory and writes initial meta. The detached
|
|
9
|
+
worker updates meta with its own pid (avoids pidfile race) and appends to
|
|
10
|
+
events.jsonl. ``--stop`` SIGTERMs the worker, drains events, builds a
|
|
11
|
+
``SessionSummary``, and writes ``summary.json``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import secrets
|
|
21
|
+
import signal
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import datetime, timedelta
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from common.env_config import env_int
|
|
28
|
+
from common.hang_pipeline import (
|
|
29
|
+
SessionSummary,
|
|
30
|
+
SummaryBuilder,
|
|
31
|
+
event_from_jsonl,
|
|
32
|
+
summary_from_json,
|
|
33
|
+
summary_to_json,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# === CONSTANTS ===
|
|
37
|
+
|
|
38
|
+
DEFAULT_SESSIONS_DIR = Path("~/.ios-simulator-skill/sessions").expanduser()
|
|
39
|
+
DEFAULT_TTL_HOURS = env_int("IOS_SIM_HANG_SESSION_TTL_HOURS", 24)
|
|
40
|
+
|
|
41
|
+
_STATUS_PENDING = "pending"
|
|
42
|
+
_STATUS_RUNNING = "running"
|
|
43
|
+
_STATUS_STOPPED = "stopped"
|
|
44
|
+
_STATUS_CRASHED = "crashed"
|
|
45
|
+
|
|
46
|
+
_DURATION_RE = re.compile(r"(\d+)([smhd])$")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# === TYPES ===
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class SessionMeta:
|
|
54
|
+
"""Parent + worker writes to meta.json."""
|
|
55
|
+
|
|
56
|
+
session_id: str
|
|
57
|
+
started_at: str
|
|
58
|
+
started_at_ms: int
|
|
59
|
+
args: dict
|
|
60
|
+
pid: int | None = None
|
|
61
|
+
status: str = _STATUS_PENDING
|
|
62
|
+
stopped_at: str | None = None
|
|
63
|
+
stopped_at_ms: int | None = None
|
|
64
|
+
extras: dict = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
def to_json(self) -> dict:
|
|
67
|
+
return {
|
|
68
|
+
"session_id": self.session_id,
|
|
69
|
+
"started_at": self.started_at,
|
|
70
|
+
"started_at_ms": self.started_at_ms,
|
|
71
|
+
"args": self.args,
|
|
72
|
+
"pid": self.pid,
|
|
73
|
+
"status": self.status,
|
|
74
|
+
"stopped_at": self.stopped_at,
|
|
75
|
+
"stopped_at_ms": self.stopped_at_ms,
|
|
76
|
+
"extras": self.extras,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_json(cls, payload: dict) -> SessionMeta:
|
|
81
|
+
return cls(
|
|
82
|
+
session_id=payload["session_id"],
|
|
83
|
+
started_at=payload["started_at"],
|
|
84
|
+
started_at_ms=payload["started_at_ms"],
|
|
85
|
+
args=payload.get("args", {}),
|
|
86
|
+
pid=payload.get("pid"),
|
|
87
|
+
status=payload.get("status", _STATUS_PENDING),
|
|
88
|
+
stopped_at=payload.get("stopped_at"),
|
|
89
|
+
stopped_at_ms=payload.get("stopped_at_ms"),
|
|
90
|
+
extras=payload.get("extras", {}),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# === SESSION STORE ===
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class SessionStore:
|
|
98
|
+
"""Filesystem-backed session repository."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, base_dir: Path | None = None):
|
|
101
|
+
self.base_dir = Path(base_dir).expanduser() if base_dir else DEFAULT_SESSIONS_DIR
|
|
102
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
|
|
104
|
+
# === PUBLIC API ===
|
|
105
|
+
|
|
106
|
+
def create(self, args: dict) -> SessionMeta:
|
|
107
|
+
"""Generate id + dir + initial meta.json. Caller detaches the worker next."""
|
|
108
|
+
session_id = _generate_session_id()
|
|
109
|
+
session_dir = self.base_dir / session_id
|
|
110
|
+
session_dir.mkdir(parents=True, exist_ok=False)
|
|
111
|
+
# Empty events file so the worker can `open(..., 'a')` cleanly.
|
|
112
|
+
(session_dir / "events.jsonl").touch()
|
|
113
|
+
now = datetime.now()
|
|
114
|
+
meta = SessionMeta(
|
|
115
|
+
session_id=session_id,
|
|
116
|
+
started_at=now.isoformat(),
|
|
117
|
+
started_at_ms=int(now.timestamp() * 1000),
|
|
118
|
+
args=args,
|
|
119
|
+
status=_STATUS_PENDING,
|
|
120
|
+
)
|
|
121
|
+
self._write_meta(meta)
|
|
122
|
+
return meta
|
|
123
|
+
|
|
124
|
+
def wait_for_worker(self, session_id: str, timeout_seconds: float = 2.0) -> SessionMeta:
|
|
125
|
+
"""Poll meta.json until status=running or timeout. Raises on timeout."""
|
|
126
|
+
deadline = time.time() + timeout_seconds
|
|
127
|
+
while time.time() < deadline:
|
|
128
|
+
meta = self.load_meta(session_id)
|
|
129
|
+
if meta.status == _STATUS_RUNNING and meta.pid:
|
|
130
|
+
return meta
|
|
131
|
+
time.sleep(0.05)
|
|
132
|
+
raise TimeoutError(f"Worker for {session_id} did not register within {timeout_seconds}s")
|
|
133
|
+
|
|
134
|
+
def claim_worker(self, session_id: str, pid: int) -> SessionMeta:
|
|
135
|
+
"""Called by worker on startup. Writes pid + status=running into meta."""
|
|
136
|
+
meta = self.load_meta(session_id)
|
|
137
|
+
meta.pid = pid
|
|
138
|
+
meta.status = _STATUS_RUNNING
|
|
139
|
+
self._write_meta(meta)
|
|
140
|
+
return meta
|
|
141
|
+
|
|
142
|
+
def persist_worker_counters(self, session_id: str, counters: dict) -> None:
|
|
143
|
+
"""Worker calls this at shutdown to flush its line counters into meta.
|
|
144
|
+
|
|
145
|
+
Re-reads meta from disk so a concurrent terminal status — ``stopped``
|
|
146
|
+
from the parent's ``stop()`` or ``crashed`` from this worker's own
|
|
147
|
+
``mark_crashed`` — is not clobbered back to ``running``.
|
|
148
|
+
"""
|
|
149
|
+
meta = self.load_meta(session_id)
|
|
150
|
+
meta.extras["line_counters"] = counters
|
|
151
|
+
if meta.status not in (_STATUS_STOPPED, _STATUS_CRASHED):
|
|
152
|
+
meta.status = _STATUS_RUNNING
|
|
153
|
+
self._write_meta(meta)
|
|
154
|
+
|
|
155
|
+
def stop(self, session_id: str, summary: SessionSummary) -> SessionMeta:
|
|
156
|
+
"""Mark session stopped and persist the computed summary."""
|
|
157
|
+
meta = self.load_meta(session_id)
|
|
158
|
+
meta.status = _STATUS_STOPPED
|
|
159
|
+
now = datetime.now()
|
|
160
|
+
meta.stopped_at = now.isoformat()
|
|
161
|
+
meta.stopped_at_ms = int(now.timestamp() * 1000)
|
|
162
|
+
self._write_meta(meta)
|
|
163
|
+
self._write_summary(session_id, summary)
|
|
164
|
+
return meta
|
|
165
|
+
|
|
166
|
+
def mark_crashed(self, session_id: str) -> None:
|
|
167
|
+
"""Best-effort: tag a session whose worker exited without a summary.
|
|
168
|
+
|
|
169
|
+
Records ``stopped_at`` / ``stopped_at_ms`` so capture-duration math in
|
|
170
|
+
``build_summary`` and ``--list-sessions`` reflects when the worker
|
|
171
|
+
actually died, not when the session was finally inspected.
|
|
172
|
+
"""
|
|
173
|
+
try:
|
|
174
|
+
meta = self.load_meta(session_id)
|
|
175
|
+
except FileNotFoundError:
|
|
176
|
+
return
|
|
177
|
+
meta.status = _STATUS_CRASHED
|
|
178
|
+
now = datetime.now()
|
|
179
|
+
meta.stopped_at = now.isoformat()
|
|
180
|
+
meta.stopped_at_ms = int(now.timestamp() * 1000)
|
|
181
|
+
self._write_meta(meta)
|
|
182
|
+
|
|
183
|
+
def signal_worker(self, session_id: str, sig: int = signal.SIGTERM) -> bool:
|
|
184
|
+
"""Send ``sig`` to the worker pid recorded in meta.json. Returns True if delivered."""
|
|
185
|
+
meta = self.load_meta(session_id)
|
|
186
|
+
if not meta.pid:
|
|
187
|
+
return False
|
|
188
|
+
try:
|
|
189
|
+
os.kill(meta.pid, sig)
|
|
190
|
+
return True
|
|
191
|
+
except ProcessLookupError:
|
|
192
|
+
return False
|
|
193
|
+
except PermissionError:
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
def load_meta(self, session_id: str) -> SessionMeta:
|
|
197
|
+
path = self._meta_path(session_id)
|
|
198
|
+
if not path.exists():
|
|
199
|
+
raise FileNotFoundError(f"No meta.json for session {session_id}")
|
|
200
|
+
with open(path) as handle:
|
|
201
|
+
return SessionMeta.from_json(json.load(handle))
|
|
202
|
+
|
|
203
|
+
def load_summary(self, session_id: str) -> SessionSummary | None:
|
|
204
|
+
path = self._summary_path(session_id)
|
|
205
|
+
if not path.exists():
|
|
206
|
+
return None
|
|
207
|
+
with open(path) as handle:
|
|
208
|
+
return summary_from_json(json.load(handle))
|
|
209
|
+
|
|
210
|
+
def stash_auto_sample(self, session_id: str, fingerprint: str, sample: dict) -> None:
|
|
211
|
+
"""Append an auto-sample record to ``<session>/auto_samples.jsonl``.
|
|
212
|
+
|
|
213
|
+
Append-only JSONL avoids the read-modify-write race that an aggregate
|
|
214
|
+
JSON dict would have under concurrent worker stashes. Readers reduce
|
|
215
|
+
last-write-wins per fingerprint.
|
|
216
|
+
"""
|
|
217
|
+
path = self._auto_samples_path(session_id)
|
|
218
|
+
line = json.dumps({"fingerprint": fingerprint, "sample": sample}, separators=(",", ":"))
|
|
219
|
+
with open(path, "a") as handle:
|
|
220
|
+
handle.write(line + "\n")
|
|
221
|
+
handle.flush()
|
|
222
|
+
os.fsync(handle.fileno())
|
|
223
|
+
|
|
224
|
+
def read_auto_samples(self, session_id: str) -> dict[str, list[dict]]:
|
|
225
|
+
"""Return ``{fingerprint: [sample, ...]}`` preserving write order.
|
|
226
|
+
|
|
227
|
+
Multiple capture mechanisms (e.g. ``--auto-sample`` + ``--auto-spindump``)
|
|
228
|
+
can stash distinct records under one fingerprint; callers disambiguate
|
|
229
|
+
via the ``kind`` field on each sample payload.
|
|
230
|
+
"""
|
|
231
|
+
path = self._auto_samples_path(session_id)
|
|
232
|
+
if not path.exists():
|
|
233
|
+
return {}
|
|
234
|
+
samples: dict[str, list[dict]] = {}
|
|
235
|
+
with open(path) as handle:
|
|
236
|
+
for raw in handle:
|
|
237
|
+
line = raw.strip()
|
|
238
|
+
if not line:
|
|
239
|
+
continue
|
|
240
|
+
try:
|
|
241
|
+
payload = json.loads(line)
|
|
242
|
+
except json.JSONDecodeError:
|
|
243
|
+
continue
|
|
244
|
+
fingerprint = payload.get("fingerprint")
|
|
245
|
+
if fingerprint is None:
|
|
246
|
+
continue
|
|
247
|
+
samples.setdefault(fingerprint, []).append(payload.get("sample"))
|
|
248
|
+
return samples
|
|
249
|
+
|
|
250
|
+
def read_events(self, session_id: str) -> list:
|
|
251
|
+
"""Read all events.jsonl lines, returning NormalisedEvent instances.
|
|
252
|
+
|
|
253
|
+
Skips non-event sentinel lines (e.g. ``{"event": "stream_ended"}``).
|
|
254
|
+
"""
|
|
255
|
+
path = self._events_path(session_id)
|
|
256
|
+
if not path.exists():
|
|
257
|
+
return []
|
|
258
|
+
events = []
|
|
259
|
+
with open(path) as handle:
|
|
260
|
+
for raw in handle:
|
|
261
|
+
line = raw.strip()
|
|
262
|
+
if not line:
|
|
263
|
+
continue
|
|
264
|
+
try:
|
|
265
|
+
payload = json.loads(line)
|
|
266
|
+
except json.JSONDecodeError:
|
|
267
|
+
continue
|
|
268
|
+
# Skip non-event sentinel lines (e.g. {"event": "stream_ended"}).
|
|
269
|
+
if payload.get("event") == "stream_ended":
|
|
270
|
+
continue
|
|
271
|
+
try:
|
|
272
|
+
events.append(event_from_jsonl(line))
|
|
273
|
+
except (json.JSONDecodeError, KeyError):
|
|
274
|
+
continue
|
|
275
|
+
return events
|
|
276
|
+
|
|
277
|
+
def events_path(self, session_id: str) -> Path:
|
|
278
|
+
"""Worker writes here. Public so the worker can open it line-buffered."""
|
|
279
|
+
return self._events_path(session_id)
|
|
280
|
+
|
|
281
|
+
def raw_path(self, session_id: str, gzipped: bool = False) -> Path:
|
|
282
|
+
"""Raw-capture NDJSON path. ``gzipped=True`` returns the post-stop path."""
|
|
283
|
+
name = "raw.ndjson.gz" if gzipped else "raw.ndjson"
|
|
284
|
+
return self.base_dir / session_id / name
|
|
285
|
+
|
|
286
|
+
def session_dir(self, session_id: str) -> Path:
|
|
287
|
+
return self.base_dir / session_id
|
|
288
|
+
|
|
289
|
+
def session_total_bytes(self, session_id: str) -> int:
|
|
290
|
+
"""Sum of all files under a session dir. Used by aggregate-cap pruning."""
|
|
291
|
+
total = 0
|
|
292
|
+
session_path = self.session_dir(session_id)
|
|
293
|
+
if not session_path.exists():
|
|
294
|
+
return 0
|
|
295
|
+
for path in session_path.rglob("*"):
|
|
296
|
+
if path.is_file():
|
|
297
|
+
with contextlib.suppress(OSError):
|
|
298
|
+
total += path.stat().st_size
|
|
299
|
+
return total
|
|
300
|
+
|
|
301
|
+
def prune_to_aggregate_cap(self, max_bytes: int) -> int:
|
|
302
|
+
"""Drop oldest sessions until total bytes ≤ max_bytes. Returns deletions.
|
|
303
|
+
|
|
304
|
+
Pairs with ``prune_expired``: TTL handles age, this handles disk usage
|
|
305
|
+
when activity outpaces TTL. Both are called automatically on every
|
|
306
|
+
``create`` so the user never has to clean up manually.
|
|
307
|
+
"""
|
|
308
|
+
if max_bytes <= 0:
|
|
309
|
+
return 0
|
|
310
|
+
# Oldest first — deletion order.
|
|
311
|
+
entries: list[tuple[int, str, int]] = [] # (started_at_ms, session_id, bytes)
|
|
312
|
+
total = 0
|
|
313
|
+
for entry in self.base_dir.iterdir():
|
|
314
|
+
if not entry.is_dir():
|
|
315
|
+
continue
|
|
316
|
+
try:
|
|
317
|
+
meta = self.load_meta(entry.name)
|
|
318
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
319
|
+
continue
|
|
320
|
+
size = self.session_total_bytes(entry.name)
|
|
321
|
+
total += size
|
|
322
|
+
entries.append((meta.started_at_ms, entry.name, size))
|
|
323
|
+
if total <= max_bytes:
|
|
324
|
+
return 0
|
|
325
|
+
entries.sort(key=lambda e: e[0]) # oldest first
|
|
326
|
+
deleted = 0
|
|
327
|
+
for _, session_id, size in entries:
|
|
328
|
+
if total <= max_bytes:
|
|
329
|
+
break
|
|
330
|
+
_remove_tree(self.session_dir(session_id))
|
|
331
|
+
total -= size
|
|
332
|
+
deleted += 1
|
|
333
|
+
return deleted
|
|
334
|
+
|
|
335
|
+
def list_sessions(self) -> list[SessionMeta]:
|
|
336
|
+
"""All non-expired session metas, newest first."""
|
|
337
|
+
metas: list[SessionMeta] = []
|
|
338
|
+
for entry in self.base_dir.iterdir():
|
|
339
|
+
if not entry.is_dir():
|
|
340
|
+
continue
|
|
341
|
+
try:
|
|
342
|
+
metas.append(self.load_meta(entry.name))
|
|
343
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
344
|
+
continue
|
|
345
|
+
metas.sort(key=lambda m: m.started_at_ms, reverse=True)
|
|
346
|
+
return metas
|
|
347
|
+
|
|
348
|
+
def clear(self, older_than: str | None = None) -> int:
|
|
349
|
+
"""Delete session dirs. ``older_than`` is a duration string like ``24h``."""
|
|
350
|
+
cutoff_ms = _resolve_cutoff_ms(older_than) if older_than else None
|
|
351
|
+
deleted = 0
|
|
352
|
+
for entry in self.base_dir.iterdir():
|
|
353
|
+
if not entry.is_dir():
|
|
354
|
+
continue
|
|
355
|
+
try:
|
|
356
|
+
meta = self.load_meta(entry.name)
|
|
357
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
358
|
+
_remove_tree(entry)
|
|
359
|
+
deleted += 1
|
|
360
|
+
continue
|
|
361
|
+
if cutoff_ms is None or meta.started_at_ms <= cutoff_ms:
|
|
362
|
+
_remove_tree(entry)
|
|
363
|
+
deleted += 1
|
|
364
|
+
return deleted
|
|
365
|
+
|
|
366
|
+
def prune_expired(self, ttl_hours: int | None = None) -> int:
|
|
367
|
+
"""Remove sessions older than ttl. Called on every ``create``."""
|
|
368
|
+
ttl = ttl_hours if ttl_hours is not None else DEFAULT_TTL_HOURS
|
|
369
|
+
cutoff = int((datetime.now() - timedelta(hours=ttl)).timestamp() * 1000)
|
|
370
|
+
return self._clear_older_than_ms(cutoff)
|
|
371
|
+
|
|
372
|
+
# === SUMMARY HELPERS ===
|
|
373
|
+
|
|
374
|
+
def build_summary(
|
|
375
|
+
self,
|
|
376
|
+
session_id: str,
|
|
377
|
+
matched_lines: int = 0,
|
|
378
|
+
total_lines: int = 0,
|
|
379
|
+
dropped_below_threshold: int = 0,
|
|
380
|
+
extras: dict | None = None,
|
|
381
|
+
top_n: int | None = None,
|
|
382
|
+
) -> SessionSummary:
|
|
383
|
+
"""Convenience: read events.jsonl and run the pipeline through SummaryBuilder.
|
|
384
|
+
|
|
385
|
+
Duration prefers ``meta.stopped_at_ms`` (set on both ``stop()`` and
|
|
386
|
+
``mark_crashed()``) so summaries for crashed/stopped sessions reflect
|
|
387
|
+
the actual capture window, not the time of inspection. Live sessions
|
|
388
|
+
without ``stopped_at_ms`` fall back to ``now`` as before.
|
|
389
|
+
"""
|
|
390
|
+
meta = self.load_meta(session_id)
|
|
391
|
+
events = self.read_events(session_id)
|
|
392
|
+
end_ms = meta.stopped_at_ms or int(datetime.now().timestamp() * 1000)
|
|
393
|
+
duration_ms = end_ms - meta.started_at_ms
|
|
394
|
+
builder = SummaryBuilder(
|
|
395
|
+
session_id=session_id,
|
|
396
|
+
started_at=meta.started_at,
|
|
397
|
+
duration_ms=max(0, duration_ms),
|
|
398
|
+
matched_lines=matched_lines,
|
|
399
|
+
total_lines=total_lines,
|
|
400
|
+
dropped_below_threshold=dropped_below_threshold,
|
|
401
|
+
extras=extras or {},
|
|
402
|
+
)
|
|
403
|
+
return builder.build(
|
|
404
|
+
events,
|
|
405
|
+
top_n=top_n,
|
|
406
|
+
auto_samples_by_fp=self.read_auto_samples(session_id),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
# === PRIVATE ===
|
|
410
|
+
|
|
411
|
+
def _meta_path(self, session_id: str) -> Path:
|
|
412
|
+
return self.base_dir / session_id / "meta.json"
|
|
413
|
+
|
|
414
|
+
def _events_path(self, session_id: str) -> Path:
|
|
415
|
+
return self.base_dir / session_id / "events.jsonl"
|
|
416
|
+
|
|
417
|
+
def _summary_path(self, session_id: str) -> Path:
|
|
418
|
+
return self.base_dir / session_id / "summary.json"
|
|
419
|
+
|
|
420
|
+
def _auto_samples_path(self, session_id: str) -> Path:
|
|
421
|
+
return self.base_dir / session_id / "auto_samples.jsonl"
|
|
422
|
+
|
|
423
|
+
def _write_meta(self, meta: SessionMeta) -> None:
|
|
424
|
+
path = self._meta_path(meta.session_id)
|
|
425
|
+
tmp = path.with_suffix(".json.tmp")
|
|
426
|
+
# Atomic write — concurrent reads (e.g. the parent polling) never see a half-file.
|
|
427
|
+
# fsync before replace makes the new contents durable, not just atomically renamed.
|
|
428
|
+
with open(tmp, "w") as handle:
|
|
429
|
+
json.dump(meta.to_json(), handle, indent=2)
|
|
430
|
+
handle.flush()
|
|
431
|
+
os.fsync(handle.fileno())
|
|
432
|
+
tmp.replace(path)
|
|
433
|
+
|
|
434
|
+
def _write_summary(self, session_id: str, summary: SessionSummary) -> None:
|
|
435
|
+
path = self._summary_path(session_id)
|
|
436
|
+
tmp = path.with_suffix(".json.tmp")
|
|
437
|
+
with open(tmp, "w") as handle:
|
|
438
|
+
json.dump(summary_to_json(summary), handle, indent=2)
|
|
439
|
+
handle.flush()
|
|
440
|
+
os.fsync(handle.fileno())
|
|
441
|
+
tmp.replace(path)
|
|
442
|
+
|
|
443
|
+
def _clear_older_than_ms(self, cutoff_ms: int) -> int:
|
|
444
|
+
deleted = 0
|
|
445
|
+
for entry in self.base_dir.iterdir():
|
|
446
|
+
if not entry.is_dir():
|
|
447
|
+
continue
|
|
448
|
+
try:
|
|
449
|
+
meta = self.load_meta(entry.name)
|
|
450
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
451
|
+
_remove_tree(entry)
|
|
452
|
+
deleted += 1
|
|
453
|
+
continue
|
|
454
|
+
if meta.started_at_ms <= cutoff_ms:
|
|
455
|
+
_remove_tree(entry)
|
|
456
|
+
deleted += 1
|
|
457
|
+
return deleted
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# === MODULE-LEVEL HELPERS ===
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _generate_session_id() -> str:
|
|
464
|
+
"""``hang-YYYYMMDD-HHmmss-XXXX`` — random hex suffix avoids same-second collisions."""
|
|
465
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
466
|
+
suffix = secrets.token_hex(2)
|
|
467
|
+
return f"hang-{timestamp}-{suffix}"
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _resolve_cutoff_ms(duration_str: str) -> int:
|
|
471
|
+
"""Parse e.g. ``24h``, ``30m`` and return the epoch-ms threshold."""
|
|
472
|
+
match = _DURATION_RE.match(duration_str.strip().lower())
|
|
473
|
+
if not match:
|
|
474
|
+
raise ValueError(f"Invalid duration: {duration_str!r}. Use 30s/5m/24h/7d.")
|
|
475
|
+
value, unit = int(match.group(1)), match.group(2)
|
|
476
|
+
seconds = value * {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
|
|
477
|
+
cutoff = datetime.now() - timedelta(seconds=seconds)
|
|
478
|
+
return int(cutoff.timestamp() * 1000)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _remove_tree(path: Path) -> None:
|
|
482
|
+
"""rm -rf path. Used for session-dir cleanup."""
|
|
483
|
+
for child in path.iterdir():
|
|
484
|
+
if child.is_dir():
|
|
485
|
+
_remove_tree(child)
|
|
486
|
+
else:
|
|
487
|
+
with contextlib.suppress(FileNotFoundError):
|
|
488
|
+
child.unlink()
|
|
489
|
+
with contextlib.suppress(OSError):
|
|
490
|
+
path.rmdir()
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Shared IDB utility functions.
|
|
4
|
+
|
|
5
|
+
This module provides common IDB operations used across multiple scripts.
|
|
6
|
+
Follows Jackson's Law - only shared code that's truly reused, not speculative.
|
|
7
|
+
|
|
8
|
+
Used by:
|
|
9
|
+
- navigator.py - Accessibility tree navigation
|
|
10
|
+
- screen_mapper.py - UI element analysis
|
|
11
|
+
- accessibility_audit.py - WCAG compliance checking
|
|
12
|
+
- test_recorder.py - Test documentation
|
|
13
|
+
- app_state_capture.py - State snapshots
|
|
14
|
+
- gesture.py - Touch gesture operations
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_accessibility_tree(udid: str | None = None, nested: bool = True) -> dict:
|
|
23
|
+
"""
|
|
24
|
+
Fetch accessibility tree from IDB.
|
|
25
|
+
|
|
26
|
+
The accessibility tree represents the complete UI hierarchy of the current
|
|
27
|
+
screen, with all element properties needed for semantic navigation.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
udid: Device UDID (uses booted simulator if None)
|
|
31
|
+
nested: Include nested structure (default True). If False, returns flat array.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Root element of accessibility tree as dict.
|
|
35
|
+
Structure: {
|
|
36
|
+
"type": "Window",
|
|
37
|
+
"AXLabel": "App Name",
|
|
38
|
+
"frame": {"x": 0, "y": 0, "width": 390, "height": 844},
|
|
39
|
+
"children": [...]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
SystemExit: If IDB command fails or returns invalid JSON
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
tree = get_accessibility_tree("UDID123")
|
|
47
|
+
# Root is Window element with all children nested
|
|
48
|
+
"""
|
|
49
|
+
cmd = ["idb", "ui", "describe-all", "--json"]
|
|
50
|
+
if nested:
|
|
51
|
+
cmd.append("--nested")
|
|
52
|
+
if udid:
|
|
53
|
+
cmd.extend(["--udid", udid])
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
57
|
+
tree_data = json.loads(result.stdout)
|
|
58
|
+
|
|
59
|
+
# IDB returns array format, extract first element (root)
|
|
60
|
+
if isinstance(tree_data, list) and len(tree_data) > 0:
|
|
61
|
+
return tree_data[0]
|
|
62
|
+
return tree_data
|
|
63
|
+
except subprocess.CalledProcessError as e:
|
|
64
|
+
print(f"Error: Failed to get accessibility tree: {e.stderr}", file=sys.stderr)
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
except json.JSONDecodeError:
|
|
67
|
+
print("Error: Invalid JSON from idb", file=sys.stderr)
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def flatten_tree(node: dict, depth: int = 0, elements: list[dict] | None = None) -> list[dict]:
|
|
72
|
+
"""
|
|
73
|
+
Flatten nested accessibility tree into list of elements.
|
|
74
|
+
|
|
75
|
+
Converts the hierarchical accessibility tree into a flat list where each
|
|
76
|
+
element includes its depth for context.
|
|
77
|
+
|
|
78
|
+
Used by:
|
|
79
|
+
- navigator.py - Element finding
|
|
80
|
+
- screen_mapper.py - Element analysis
|
|
81
|
+
- accessibility_audit.py - Audit scanning
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
node: Root node of tree (typically from get_accessibility_tree)
|
|
85
|
+
depth: Current depth (used internally, start at 0)
|
|
86
|
+
elements: Accumulator list (used internally, start as None)
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Flat list of elements, each with "depth" key indicating nesting level.
|
|
90
|
+
Structure of each element: {
|
|
91
|
+
"type": "Button",
|
|
92
|
+
"AXLabel": "Login",
|
|
93
|
+
"frame": {...},
|
|
94
|
+
"depth": 2,
|
|
95
|
+
...
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
tree = get_accessibility_tree()
|
|
100
|
+
flat = flatten_tree(tree)
|
|
101
|
+
for elem in flat:
|
|
102
|
+
print(f"{' ' * elem['depth']}{elem.get('type')}: {elem.get('AXLabel')}")
|
|
103
|
+
"""
|
|
104
|
+
if elements is None:
|
|
105
|
+
elements = []
|
|
106
|
+
|
|
107
|
+
# Add current node with depth tracking
|
|
108
|
+
node_copy = node.copy()
|
|
109
|
+
node_copy["depth"] = depth
|
|
110
|
+
elements.append(node_copy)
|
|
111
|
+
|
|
112
|
+
# Process children recursively
|
|
113
|
+
for child in node.get("children", []):
|
|
114
|
+
flatten_tree(child, depth + 1, elements)
|
|
115
|
+
|
|
116
|
+
return elements
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def count_elements(node: dict) -> int:
|
|
120
|
+
"""
|
|
121
|
+
Count total elements in tree (recursive).
|
|
122
|
+
|
|
123
|
+
Traverses entire tree counting all elements for reporting purposes.
|
|
124
|
+
|
|
125
|
+
Used by:
|
|
126
|
+
- test_recorder.py - Element counting per step
|
|
127
|
+
- screen_mapper.py - Summary statistics
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
node: Root node of tree
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Total element count including root and all descendants
|
|
134
|
+
|
|
135
|
+
Example:
|
|
136
|
+
tree = get_accessibility_tree()
|
|
137
|
+
total = count_elements(tree)
|
|
138
|
+
print(f"Screen has {total} elements")
|
|
139
|
+
"""
|
|
140
|
+
count = 1
|
|
141
|
+
for child in node.get("children", []):
|
|
142
|
+
count += count_elements(child)
|
|
143
|
+
return count
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_screen_size(udid: str | None = None) -> tuple[int, int]:
|
|
147
|
+
"""
|
|
148
|
+
Get screen dimensions from accessibility tree.
|
|
149
|
+
|
|
150
|
+
Extracts the screen size from the root element's frame. Useful for
|
|
151
|
+
gesture calculations and coordinate normalization.
|
|
152
|
+
|
|
153
|
+
Used by:
|
|
154
|
+
- gesture.py - Gesture positioning
|
|
155
|
+
- Potentially: screenshot positioning, screen-aware scaling
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
udid: Device UDID (uses booted if None)
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
(width, height) tuple. Defaults to (390, 844) if detection fails
|
|
162
|
+
or tree cannot be accessed.
|
|
163
|
+
|
|
164
|
+
Example:
|
|
165
|
+
width, height = get_screen_size()
|
|
166
|
+
center_x = width // 2
|
|
167
|
+
center_y = height // 2
|
|
168
|
+
"""
|
|
169
|
+
DEFAULT_WIDTH = 390 # iPhone 14
|
|
170
|
+
DEFAULT_HEIGHT = 844
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
tree = get_accessibility_tree(udid, nested=False)
|
|
174
|
+
frame = tree.get("frame", {})
|
|
175
|
+
width = int(frame.get("width", DEFAULT_WIDTH))
|
|
176
|
+
height = int(frame.get("height", DEFAULT_HEIGHT))
|
|
177
|
+
return (width, height)
|
|
178
|
+
except Exception:
|
|
179
|
+
# Silently fall back to defaults if tree access fails
|
|
180
|
+
return (DEFAULT_WIDTH, DEFAULT_HEIGHT)
|