mobile-debug-mcp 0.35.0 → 0.37.0
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/.cursor-plugin/marketplace.json +1 -1
- package/.github/workflows/ci.yml +1 -1
- package/README.md +96 -24
- package/agent-plugin/.antigravity-plugin/README.md +48 -0
- package/agent-plugin/.antigravity-plugin/plugin.json +18 -0
- package/agent-plugin/.claude-plugin/plugin.json +2 -2
- package/agent-plugin/.codex-plugin/plugin.json +1 -1
- package/agent-plugin/.cursor-plugin/plugin.json +2 -2
- package/agent-plugin/.mcp.json +1 -1
- package/agent-plugin/README.md +37 -8
- package/agent-plugin/cursor/.cursor-plugin/plugin.json +23 -0
- package/agent-plugin/cursor/LICENSE +201 -0
- package/agent-plugin/cursor/README.md +9 -0
- package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +261 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/mcp_config.json +13 -0
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +46 -18
- package/dist/contracts/device-readiness.js +1 -0
- package/dist/debugger/guard.js +4 -4
- package/dist/debugger/ios-worker.js +62 -0
- package/dist/debugger/ios.js +57 -0
- package/dist/debugger/sessions.js +33 -16
- package/dist/debugger/worker.js +6 -4
- package/dist/interact/index.js +12 -2
- package/dist/interact/waits/ui-change-wait.js +9 -2
- package/dist/interact/waits/ui-wait.js +7 -3
- package/dist/manage/index.js +3 -0
- package/dist/manage/ios.js +2 -0
- package/dist/observe/device-readiness.js +116 -0
- package/dist/observe/evidence.js +31 -0
- package/dist/observe/ios-trace/artifact.js +63 -0
- package/dist/observe/ios-trace/capabilities.js +86 -0
- package/dist/observe/ios-trace/native.js +93 -0
- package/dist/observe/ios-trace/notification.js +35 -0
- package/dist/observe/ios-trace/process.js +98 -0
- package/dist/observe/ios-trace/sessions.js +246 -0
- package/dist/observe/readiness-service.js +22 -10
- package/dist/observe/readiness.js +34 -4
- package/dist/observe/screenshot-encoding.js +4 -0
- package/dist/observe/snapshot-collection.js +7 -2
- package/dist/observe/system-trace-error.js +7 -0
- package/dist/observe/system-trace-router.js +81 -0
- package/dist/observe/system-trace.js +4 -8
- package/dist/server/definitions/manage.js +3 -2
- package/dist/server/handlers/action-execution.js +23 -14
- package/dist/server/handlers/compact-interaction.js +106 -0
- package/dist/server/handlers/compact-state.js +47 -0
- package/dist/server/handlers/debugger-handlers.js +27 -8
- package/dist/server/handlers/diagnostic.js +12 -4
- package/dist/server/handlers/input.js +2 -0
- package/dist/server/handlers/interact.js +2 -0
- package/dist/server/handlers/interaction-diagnostics.js +62 -0
- package/dist/server/handlers/journey.js +18 -20
- package/dist/server/handlers/observe.js +2 -1
- package/dist/server/handlers/tool-handlers.js +28 -14
- package/dist/server/schemas/debugger-schema.js +10 -7
- package/dist/server/schemas/interaction-diagnostics.js +9 -0
- package/dist/server/schemas/system-trace-schema.js +14 -9
- package/dist/server/tool-definitions.js +9 -4
- package/dist/server-core.js +5 -2
- package/dist/server.js +5 -5
- package/dist/system/android.js +19 -15
- package/dist/system/index.js +2 -1
- package/dist/utils/android/utils.js +7 -16
- package/dist/utils/app-installation.js +77 -0
- package/dist/utils/benchmark-metrics.js +43 -0
- package/dist/utils/exec.js +4 -0
- package/dist/utils/ios/utils.js +7 -11
- package/dist/utils/resolve-device.js +3 -2
- package/dist/utils/timing.js +3 -3
- package/docs/CHANGELOG.md +48 -1
- package/docs/agent-plugin-installation-verification.md +39 -0
- package/docs/benchmarks/README.md +1 -0
- package/docs/benchmarks/end-to-end-latency/README.md +98 -0
- package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.json +6532 -0
- package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.md +30 -0
- package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.json +15901 -0
- package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.md +14 -0
- package/docs/benchmarks/end-to-end-latency/baseline.json +233843 -0
- package/docs/benchmarks/end-to-end-latency/baseline.md +21 -0
- package/docs/benchmarks/end-to-end-latency/investigation.md +128 -0
- package/docs/benchmarks/end-to-end-latency/ios-smoke.json +84 -0
- package/docs/benchmarks/end-to-end-latency/ios-smoke.md +9 -0
- package/docs/benchmarks/end-to-end-latency/smoke.json +19601 -0
- package/docs/benchmarks/end-to-end-latency/smoke.md +21 -0
- package/docs/code-structure.md +3 -1
- package/docs/specs/031-ios-system-traces/intent.md +38 -0
- package/docs/specs/031-ios-system-traces/investigation.md +92 -0
- package/docs/specs/031-ios-system-traces/late-run-failure-evidence.json +32 -0
- package/docs/specs/031-ios-system-traces/plan.md +166 -0
- package/docs/specs/031-ios-system-traces/pr102-monitor-evidence.json +22 -0
- package/docs/specs/031-ios-system-traces/simulator-evidence.json +47 -0
- package/docs/specs/031-ios-system-traces/spec.md +135 -0
- package/docs/specs/032-ios-runtime-debugger/evidence.json +176 -0
- package/docs/specs/032-ios-runtime-debugger/intent.md +38 -0
- package/docs/specs/032-ios-runtime-debugger/plan.md +95 -0
- package/docs/specs/032-ios-runtime-debugger/report.md +205 -0
- package/docs/specs/032-ios-runtime-debugger/spec.md +153 -0
- package/docs/specs/033-device-readiness-blockers/android-evidence.json +437 -0
- package/docs/specs/033-device-readiness-blockers/android-physical-evidence.json +28 -0
- package/docs/specs/033-device-readiness-blockers/android-physical-power-evidence.json +29 -0
- package/docs/specs/033-device-readiness-blockers/intent.md +41 -0
- package/docs/specs/033-device-readiness-blockers/ios-investigation.md +55 -0
- package/docs/specs/033-device-readiness-blockers/ios-probe-evidence.json +24 -0
- package/docs/specs/033-device-readiness-blockers/ios-tool-evidence.json +73 -0
- package/docs/specs/033-device-readiness-blockers/plan.md +249 -0
- package/docs/specs/033-device-readiness-blockers/spec.md +134 -0
- package/docs/specs/034-app-installation-status/intent.md +41 -0
- package/docs/specs/034-app-installation-status/native-evidence.json +654 -0
- package/docs/specs/034-app-installation-status/plan.md +73 -0
- package/docs/specs/034-app-installation-status/spec.md +114 -0
- package/docs/specs/035-compact-interaction-results/baseline.md +45 -0
- package/docs/specs/035-compact-interaction-results/intent.md +38 -0
- package/docs/specs/035-compact-interaction-results/plan.md +43 -0
- package/docs/specs/035-compact-interaction-results/spec.md +118 -0
- package/docs/specs/035-compact-interaction-results/verification.md +111 -0
- package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-amendment.md +60 -0
- package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-plan.md +32 -0
- package/docs/specs/036-end-to-end-interaction-latency/intent.md +37 -0
- package/docs/specs/036-end-to-end-interaction-latency/plan.md +59 -0
- package/docs/specs/036-end-to-end-interaction-latency/spec.md +126 -0
- package/docs/specs/036-end-to-end-interaction-latency/verification.md +87 -0
- package/docs/tools/TOOLS.md +3 -2
- package/docs/tools/compact-interactions.md +56 -0
- package/docs/tools/correlated-evidence.md +6 -0
- package/docs/tools/debugger.md +45 -5
- package/docs/tools/interact.md +17 -8
- package/docs/tools/manage.md +10 -2
- package/docs/tools/observe.md +42 -2
- package/docs/tools/system-traces.md +81 -14
- package/docs/tools/system.md +6 -0
- package/package.json +2 -1
- package/scripts/agent-plugin.mjs +55 -5
- package/src/contracts/device-readiness.ts +8 -0
- package/src/debugger/android.ts +1 -1
- package/src/debugger/guard.ts +5 -5
- package/src/debugger/ios-worker.ts +49 -0
- package/src/debugger/ios.ts +45 -0
- package/src/debugger/lldb/breakpoints.py +51 -0
- package/src/debugger/lldb/inspection.py +109 -0
- package/src/debugger/lldb/worker.py +203 -0
- package/src/debugger/sessions.ts +24 -12
- package/src/debugger/worker.ts +7 -4
- package/src/interact/index.ts +13 -2
- package/src/interact/waits/ui-change-wait.ts +7 -2
- package/src/interact/waits/ui-wait.ts +6 -3
- package/src/manage/index.ts +2 -0
- package/src/manage/ios.ts +2 -0
- package/src/observe/device-readiness.ts +96 -0
- package/src/observe/evidence.ts +30 -1
- package/src/observe/ios-trace/artifact.ts +55 -0
- package/src/observe/ios-trace/capabilities.ts +74 -0
- package/src/observe/ios-trace/native.ts +72 -0
- package/src/observe/ios-trace/notification.ts +30 -0
- package/src/observe/ios-trace/process.ts +82 -0
- package/src/observe/ios-trace/sessions.ts +206 -0
- package/src/observe/readiness-service.ts +22 -10
- package/src/observe/readiness.ts +37 -4
- package/src/observe/screenshot-encoding.ts +4 -0
- package/src/observe/snapshot-collection.ts +7 -2
- package/src/observe/system-trace-error.ts +3 -0
- package/src/observe/system-trace-router.ts +61 -0
- package/src/observe/system-trace.ts +4 -4
- package/src/server/common.ts +2 -2
- package/src/server/definitions/manage.ts +3 -2
- package/src/server/handlers/action-execution.ts +22 -14
- package/src/server/handlers/compact-interaction.ts +91 -0
- package/src/server/handlers/compact-state.ts +43 -0
- package/src/server/handlers/debugger-handlers.ts +28 -9
- package/src/server/handlers/diagnostic.ts +11 -4
- package/src/server/handlers/input.ts +2 -0
- package/src/server/handlers/interact.ts +2 -0
- package/src/server/handlers/interaction-diagnostics.ts +50 -0
- package/src/server/handlers/journey.ts +15 -17
- package/src/server/handlers/observe.ts +2 -1
- package/src/server/handlers/payload.ts +2 -1
- package/src/server/handlers/tool-handlers.ts +29 -14
- package/src/server/schemas/debugger-schema.ts +10 -7
- package/src/server/schemas/interaction-diagnostics.ts +10 -0
- package/src/server/schemas/system-trace-schema.ts +15 -9
- package/src/server/tool-definitions.ts +8 -4
- package/src/server-core.ts +5 -2
- package/src/server.ts +4 -4
- package/src/system/android.ts +18 -11
- package/src/system/index.ts +2 -1
- package/src/types.ts +2 -0
- package/src/utils/android/utils.ts +8 -10
- package/src/utils/app-installation.ts +73 -0
- package/src/utils/benchmark-metrics.ts +42 -0
- package/src/utils/exec.ts +5 -0
- package/src/utils/ios/utils.ts +7 -10
- package/src/utils/resolve-device.ts +4 -2
- package/src/utils/timing.ts +3 -2
- package/test/device/automated/debugger/runtime.android.test.ts +5 -0
- package/test/device/automated/debugger/runtime.ios.test.ts +227 -0
- package/test/device/automated/interact/compact-results.smoke.ts +58 -0
- package/test/device/automated/latency/end-to-end-environment.ts +61 -0
- package/test/device/automated/latency/end-to-end-report.ts +90 -0
- package/test/device/automated/latency/end-to-end-session.ts +98 -0
- package/test/device/automated/latency/end-to-end.ts +149 -0
- package/test/device/automated/latency/owned-transport.ts +81 -0
- package/test/device/automated/latency/snapshot-pair.ts +143 -0
- package/test/device/automated/latency-ios/benchmark.ts +126 -0
- package/test/device/automated/observe/correlated-evidence.smoke.ts +7 -5
- package/test/device/automated/observe/device-readiness-cleanup.ts +14 -0
- package/test/device/automated/observe/device-readiness.android.smoke.ts +78 -0
- package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
- package/test/device/automated/observe/system-trace.smoke.ts +2 -2
- package/test/fixtures/contracts/tool-definitions.json +243 -47
- package/test/fixtures/ios-debugger-app/Main.swift +95 -0
- package/test/fixtures/ios-debugger-app/README.md +27 -0
- package/test/fixtures/ios-debugger-app/build.ts +21 -0
- package/test/fixtures/latency-journey-app/build.ts +3 -3
- package/test/helpers/ios-trace-shutdown.ts +29 -0
- package/test/unit/debugger/handle-guards.test.ts +89 -0
- package/test/unit/debugger/ios-preparation.test.ts +57 -0
- package/test/unit/debugger/ios.test.ts +104 -0
- package/test/unit/debugger/lldb-worker.py +166 -0
- package/test/unit/debugger/lldb-worker.test.ts +10 -0
- package/test/unit/interact/ui-change-readiness-interruption.test.ts +37 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +66 -12
- package/test/unit/observe/device-readiness-cleanup.test.ts +24 -0
- package/test/unit/observe/device-readiness.test.ts +97 -0
- package/test/unit/observe/ios-trace-native.test.ts +90 -0
- package/test/unit/observe/ios-trace-sessions.test.ts +154 -0
- package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
- package/test/unit/observe/readiness.test.ts +1 -1
- package/test/unit/observe/system-trace-router.test.ts +49 -0
- package/test/unit/quality/agent-plugin-export.test.ts +38 -0
- package/test/unit/server/compact-interaction.test.ts +87 -0
- package/test/unit/server/compact-journey-evidence.test.ts +40 -0
- package/test/unit/server/compact-review-regressions.test.ts +78 -0
- package/test/unit/server/conditional_actions.test.ts +7 -3
- package/test/unit/server/correlated-evidence.test.ts +18 -6
- package/test/unit/server/end-to-end-report.test.ts +46 -0
- package/test/unit/server/input-device-readiness.test.ts +55 -0
- package/test/unit/server/interaction-diagnostics.test.ts +45 -0
- package/test/unit/server/owned-benchmark-transport.test.ts +32 -0
- package/test/unit/server/readiness-retained-deadline.test.ts +32 -0
- package/test/unit/server/readiness.test.ts +24 -1
- package/test/unit/server/run_journey.test.ts +2 -1
- package/test/unit/server/selector_tap_target.test.ts +2 -1
- package/test/unit/server/system-trace.test.ts +18 -7
- package/test/unit/utils/app-installation.test.ts +78 -0
- package/test/unit/utils/benchmark-metrics.test.ts +36 -0
- package/test/unit/utils/resolve_device.test.ts +11 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Private JSON worker for simulator LLDB. Stdout is exclusively bounded IPC."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import queue
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import lldb
|
|
9
|
+
from inspection import Inspection
|
|
10
|
+
from breakpoints import Breakpoints
|
|
11
|
+
|
|
12
|
+
requests = queue.Queue(maxsize=64)
|
|
13
|
+
output_lock = threading.Lock()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def send(value):
|
|
17
|
+
data = json.dumps(value, separators=(",", ":"))
|
|
18
|
+
if len(data.encode()) > 1024 * 1024:
|
|
19
|
+
raise ValueError("DEBUGGER_WORKER_FAILED")
|
|
20
|
+
with output_lock:
|
|
21
|
+
sys.stdout.write(data + "\n")
|
|
22
|
+
sys.stdout.flush()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_input():
|
|
26
|
+
try:
|
|
27
|
+
while True:
|
|
28
|
+
line = sys.stdin.buffer.readline(1024 * 1024 + 1)
|
|
29
|
+
if not line:
|
|
30
|
+
break
|
|
31
|
+
if len(line) > 1024 * 1024:
|
|
32
|
+
break
|
|
33
|
+
requests.put(json.loads(line), timeout=1)
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
finally:
|
|
37
|
+
# EOF must remain observable even when the queue was filled by a dead parent.
|
|
38
|
+
eof.set()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
eof = threading.Event()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Worker:
|
|
45
|
+
def __init__(self):
|
|
46
|
+
self.debugger = lldb.SBDebugger.Create(False)
|
|
47
|
+
self.debugger.SetAsync(True)
|
|
48
|
+
self.debugger.SetUseColor(False)
|
|
49
|
+
lldb.SBDebugger.SetInternalVariable("target.load-script-from-symbol-file", "false", self.debugger.GetInstanceName())
|
|
50
|
+
self.output = open(os.devnull, "w")
|
|
51
|
+
self.debugger.SetOutputFileHandle(self.output, False)
|
|
52
|
+
self.debugger.SetErrorFileHandle(self.output, False)
|
|
53
|
+
self.process = None
|
|
54
|
+
self.attached = False
|
|
55
|
+
self.breakpoints = None
|
|
56
|
+
self.inspection = Inspection()
|
|
57
|
+
self.created = self.activity = time.monotonic()
|
|
58
|
+
self.paused_at = None
|
|
59
|
+
self.last_stop = 0
|
|
60
|
+
self.terminal = False
|
|
61
|
+
|
|
62
|
+
def attach(self, args):
|
|
63
|
+
self.debugger.SetAsync(False)
|
|
64
|
+
target = self.debugger.CreateTarget(args["executablePath"])
|
|
65
|
+
if not target.IsValid():
|
|
66
|
+
raise ValueError("DEBUGGER_WORKER_FAILED")
|
|
67
|
+
self.breakpoints = Breakpoints(target)
|
|
68
|
+
error = lldb.SBError()
|
|
69
|
+
self.process = target.AttachToProcessWithID(self.debugger.GetListener(), args["pid"], error)
|
|
70
|
+
self.debugger.SetAsync(True)
|
|
71
|
+
self.attached = self.process.IsValid()
|
|
72
|
+
if not error.Success() or not self.process.IsValid():
|
|
73
|
+
raise ValueError("DEBUGGER_WORKER_FAILED")
|
|
74
|
+
actual = self.process.GetProcessInfo().GetExecutableFile()
|
|
75
|
+
if not actual.IsValid() or os.path.realpath(str(actual)) != os.path.realpath(args["executablePath"]):
|
|
76
|
+
self.process.Detach()
|
|
77
|
+
raise ValueError("DEBUGGER_WORKER_FAILED")
|
|
78
|
+
self.last_stop = self.process.GetStopID()
|
|
79
|
+
if not self.process.Continue().Success():
|
|
80
|
+
raise ValueError("DEBUGGER_WORKER_FAILED")
|
|
81
|
+
return {"attached": True}
|
|
82
|
+
|
|
83
|
+
def detach(self):
|
|
84
|
+
self.inspection.invalidate()
|
|
85
|
+
if self.breakpoints:
|
|
86
|
+
self.breakpoints.clear()
|
|
87
|
+
if self.attached:
|
|
88
|
+
if not self.process.IsValid():
|
|
89
|
+
raise ValueError("DEBUGGER_WORKER_FAILED")
|
|
90
|
+
if self.process.GetState() not in (lldb.eStateExited, lldb.eStateDetached):
|
|
91
|
+
error = self.process.Detach()
|
|
92
|
+
if not error.Success():
|
|
93
|
+
raise ValueError("DEBUGGER_WORKER_FAILED")
|
|
94
|
+
self.attached = False
|
|
95
|
+
self.terminal = True
|
|
96
|
+
return {"detached": True}
|
|
97
|
+
|
|
98
|
+
def poll(self):
|
|
99
|
+
# Removing process events updates LLDB's public state/thread snapshot.
|
|
100
|
+
# Polling GetState alone can leave the initial attach stop cached.
|
|
101
|
+
for _ in range(32):
|
|
102
|
+
event = lldb.SBEvent()
|
|
103
|
+
if not self.debugger.GetListener().GetNextEvent(event):
|
|
104
|
+
break
|
|
105
|
+
if lldb.SBProcess.EventIsProcessEvent(event):
|
|
106
|
+
lldb.SBProcess.GetStateFromEvent(event)
|
|
107
|
+
if not self.process or self.terminal:
|
|
108
|
+
return
|
|
109
|
+
state = self.process.GetState()
|
|
110
|
+
if state in (lldb.eStateExited, lldb.eStateDetached):
|
|
111
|
+
self.inspection.invalidate()
|
|
112
|
+
self.terminal = True
|
|
113
|
+
send({"event": "terminated", "reason": "process_exit" if state == lldb.eStateExited else "detached"})
|
|
114
|
+
elif state == lldb.eStateStopped and self.process.GetStopID() != self.last_stop:
|
|
115
|
+
self.breakpoints.refresh()
|
|
116
|
+
event = self.inspection.stopped(self.process)
|
|
117
|
+
if event:
|
|
118
|
+
self.last_stop = self.process.GetStopID()
|
|
119
|
+
self.paused_at = time.monotonic()
|
|
120
|
+
send(event)
|
|
121
|
+
|
|
122
|
+
def command(self, args):
|
|
123
|
+
command = args["command"]
|
|
124
|
+
if command == "attach":
|
|
125
|
+
return self.attach(args)
|
|
126
|
+
if command == "detach":
|
|
127
|
+
return self.detach()
|
|
128
|
+
if not self.process or self.terminal:
|
|
129
|
+
raise ValueError("DEBUGGER_DISCONNECTED")
|
|
130
|
+
if command in ("heartbeat", "state"):
|
|
131
|
+
return {"breakpoints": self.breakpoints.refresh()}
|
|
132
|
+
if command == "set":
|
|
133
|
+
return self.breakpoints.set(args)
|
|
134
|
+
if command == "remove":
|
|
135
|
+
return self.breakpoints.remove(args["breakpointId"])
|
|
136
|
+
if command in ("stack", "locals", "step", "resume"):
|
|
137
|
+
if args.get("stopId") != self.inspection.stop_id:
|
|
138
|
+
raise ValueError("DEBUGGER_STALE_REFERENCE")
|
|
139
|
+
if self.process.GetState() != lldb.eStateStopped:
|
|
140
|
+
raise ValueError("DEBUGGER_NOT_PAUSED")
|
|
141
|
+
if command == "stack":
|
|
142
|
+
return self.inspection.stack(args)
|
|
143
|
+
if command == "locals":
|
|
144
|
+
return self.inspection.locals(args)
|
|
145
|
+
if command in ("step", "resume"):
|
|
146
|
+
thread = self.inspection.thread(args) if command == "step" else None
|
|
147
|
+
self.inspection.invalidate()
|
|
148
|
+
self.paused_at = None
|
|
149
|
+
if command == "resume":
|
|
150
|
+
if not self.process.Continue().Success():
|
|
151
|
+
raise ValueError("DEBUGGER_WORKER_FAILED")
|
|
152
|
+
else:
|
|
153
|
+
error = lldb.SBError()
|
|
154
|
+
if args["kind"] == "into":
|
|
155
|
+
thread.StepInto(None, lldb.LLDB_INVALID_LINE_NUMBER, error, lldb.eOnlyDuringStepping)
|
|
156
|
+
elif args["kind"] == "over":
|
|
157
|
+
thread.StepOver(lldb.eOnlyDuringStepping, error)
|
|
158
|
+
else:
|
|
159
|
+
thread.StepOut(error)
|
|
160
|
+
if not error.Success():
|
|
161
|
+
raise ValueError("DEBUGGER_WORKER_FAILED")
|
|
162
|
+
return {"resumed": True}
|
|
163
|
+
raise ValueError("INVALID_ARGUMENT")
|
|
164
|
+
|
|
165
|
+
def expired(self):
|
|
166
|
+
now = time.monotonic()
|
|
167
|
+
return ("pause_limit" if self.paused_at and now - self.paused_at >= 60 else
|
|
168
|
+
"idle_limit" if now - self.activity >= 300 else
|
|
169
|
+
"session_limit" if now - self.created >= 1800 else None)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main():
|
|
173
|
+
worker = Worker()
|
|
174
|
+
threading.Thread(target=read_input, daemon=True).start()
|
|
175
|
+
try:
|
|
176
|
+
while not eof.is_set():
|
|
177
|
+
worker.poll()
|
|
178
|
+
reason = worker.expired()
|
|
179
|
+
if reason:
|
|
180
|
+
worker.detach()
|
|
181
|
+
send({"event": "terminated", "reason": reason})
|
|
182
|
+
break
|
|
183
|
+
try:
|
|
184
|
+
args = requests.get(timeout=0.02)
|
|
185
|
+
except queue.Empty:
|
|
186
|
+
continue
|
|
187
|
+
worker.activity = time.monotonic()
|
|
188
|
+
try:
|
|
189
|
+
result = worker.command(args)
|
|
190
|
+
send({"id": args["id"], "result": result})
|
|
191
|
+
except Exception as error:
|
|
192
|
+
allowed = {"INVALID_ARGUMENT", "DEBUGGER_NOT_PAUSED", "DEBUGGER_STALE_REFERENCE", "DEBUGGER_DISCONNECTED"}
|
|
193
|
+
code = str(error) if isinstance(error, ValueError) and str(error) in allowed else "DEBUGGER_WORKER_FAILED"
|
|
194
|
+
send({"id": args["id"], "error": {"code": code}})
|
|
195
|
+
finally:
|
|
196
|
+
try:
|
|
197
|
+
worker.detach()
|
|
198
|
+
finally:
|
|
199
|
+
lldb.SBDebugger.Destroy(worker.debugger)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
main()
|
package/src/debugger/sessions.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { atStage, classify, diagnostic, type DebuggerDiagnostic, type DiagnosticStage } from './diagnostics.js';
|
|
2
2
|
import type { WorkerReply } from './protocol.js';
|
|
3
3
|
import { registerDebuggerPauseProvider } from './guard.js';
|
|
4
|
+
import { IOSDebugNative } from './ios.js';
|
|
5
|
+
import { createIOSWorker, prepareIOSWorker, iosCapabilities, iosLimitations } from './ios-worker.js';
|
|
4
6
|
import { randomUUID } from 'node:crypto';
|
|
5
7
|
import { AndroidDebugNative, type DebugNative, type DebugTarget } from './android.js';
|
|
6
8
|
import { createWorker, prepareWorker, DebuggerError, withDebuggerBudget, debuggerRemaining, type DebugWorker, type WorkerEvent } from './worker.js';
|
|
@@ -56,14 +58,16 @@ interface ResultPayload {
|
|
|
56
58
|
}
|
|
57
59
|
export const debuggerCapabilities = { attach: true, breakpoints: true, stacks: true, locals: true, step: true, resume: true,
|
|
58
60
|
evaluation: false, mutation: false, coroutines: false, native: false };
|
|
59
|
-
const
|
|
61
|
+
export const androidLimitations = ['JAVA_KOTLIN_BYTECODE_ONLY', 'DEBUG_METADATA_REQUIRED', 'SHALLOW_LOCALS', 'ALL_THREADS_SUSPEND', 'DEVICE_WIDE_UI_GUARD'];
|
|
60
62
|
export function debuggerExpiry(created: number, activity: number, pausedAt: number | undefined, now: number) {
|
|
61
63
|
return pausedAt !== undefined && now - pausedAt >= 60000 ? 'pause_limit' : now - activity >= 300000 ? 'idle_limit' : now - created >= 1800000 ? 'session_limit' : null;
|
|
62
64
|
}
|
|
63
65
|
export class DebuggerSessions {
|
|
64
66
|
private sessions = new Map<string, Session>();
|
|
65
67
|
private closing = false;
|
|
66
|
-
constructor(private native: DebugNative = new AndroidDebugNative(), private workerFactory = createWorker, private prerequisite = prepareWorker) { }
|
|
68
|
+
constructor(private native: DebugNative = new AndroidDebugNative(), private workerFactory: (event: (event: WorkerEvent) => void) => Promise<DebugWorker> = createWorker, private prerequisite: () => Promise<unknown> = prepareWorker, private options: { platform?: 'android' | 'ios'; capabilities?: Record<string, boolean>; limitations?: string[] } = {}) { }
|
|
69
|
+
private get platform() { return this.options.platform ?? 'android'; }
|
|
70
|
+
private get workerResource() { return this.platform === 'ios' ? 'lldb_worker' : 'jdi_worker'; }
|
|
67
71
|
private prune() {
|
|
68
72
|
const terminal = [...this.sessions.values()].filter(s => s.terminalAt !== undefined && s.cleanup.status === 'complete');
|
|
69
73
|
for (const s of terminal)
|
|
@@ -80,9 +84,10 @@ export class DebuggerSessions {
|
|
|
80
84
|
const success = payload.success ?? (!['failed', 'disconnected'].includes(s.state) && s.cleanup.status !== 'failed');
|
|
81
85
|
const code = s.cleanup.status === 'failed' ? 'DEBUGGER_CLEANUP_FAILED' : s.state === 'disconnected' ? 'DEBUGGER_DISCONNECTED' : s.reason?.match(/^[A-Z_]+$/) ? s.reason : 'DEBUGGER_WORKER_FAILED';
|
|
82
86
|
return structuredClone({ success, ...(!success ? { error: { code, message: code, retryable: false, diagnostic: s.cleanup.status === 'failed' ? diagnostic('cleanup', 'operation_failed') : s.diagnostic ?? diagnostic('unknown', 'unknown') } } : {}), debug_session_id: s.id,
|
|
83
|
-
device: { platform:
|
|
84
|
-
capabilities: debuggerCapabilities, limitations, cleanup: s.cleanup, reason: s.reason, stop: s.stop, ...payload });
|
|
87
|
+
device: { platform: this.platform, id: s.device }, target: s.target, state: s.state, captured_at_ms: Date.now(),
|
|
88
|
+
capabilities: this.options.capabilities ?? debuggerCapabilities, limitations: this.options.limitations ?? androidLimitations, cleanup: s.cleanup, reason: s.reason, stop: s.stop, ...payload });
|
|
85
89
|
}
|
|
90
|
+
owns(id: string) { return this.sessions.has(id); }
|
|
86
91
|
summary(id: string) { return this.result(this.get(id)); }
|
|
87
92
|
hasActiveSessions() { return [...this.sessions.values()].some(s => s.cleanup.status !== 'complete'); }
|
|
88
93
|
paused(device: string) { const s = [...this.sessions.values()].find(s => s.device === device && s.state === 'paused'); return s ? this.result(s) : null; }
|
|
@@ -112,13 +117,13 @@ export class DebuggerSessions {
|
|
|
112
117
|
s.events.shift();
|
|
113
118
|
}
|
|
114
119
|
async capabilities(device: string, app: string, pid?: number) {
|
|
115
|
-
const prerequisites: Record<string, boolean> = { jdk: false, device: false, process: false };
|
|
120
|
+
const prerequisites: Record<string, boolean> = { [this.platform === 'ios' ? 'lldb' : 'jdk']: false, device: false, process: false };
|
|
116
121
|
let candidates: DebugTarget[] = [];
|
|
117
122
|
const reasons: string[] = [];
|
|
118
123
|
const diagnostics: DebuggerDiagnostic[] = [];
|
|
119
124
|
try {
|
|
120
125
|
await atStage('prerequisite', 'DEBUGGER_UNSUPPORTED', () => this.prerequisite());
|
|
121
|
-
prerequisites.jdk = true;
|
|
126
|
+
prerequisites[this.platform === 'ios' ? 'lldb' : 'jdk'] = true;
|
|
122
127
|
}
|
|
123
128
|
catch (e) {
|
|
124
129
|
if (e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED'].includes(e.code))
|
|
@@ -139,9 +144,9 @@ export class DebuggerSessions {
|
|
|
139
144
|
reasons.push(e instanceof DebuggerError ? e.code : 'DEVICE_UNAVAILABLE');
|
|
140
145
|
diagnostics.push(classify(e, 'target_resolution', 'DEVICE_UNAVAILABLE').diagnostic ?? diagnostic('target_resolution', 'unknown'));
|
|
141
146
|
}
|
|
142
|
-
return { success: true, debug_session_id: null, device: { platform:
|
|
147
|
+
return { success: true, debug_session_id: null, device: { platform: this.platform, id: device }, target: null, state: null,
|
|
143
148
|
captured_at_ms: Date.now(), supported: Object.values(prerequisites).every(Boolean), prerequisites, candidates,
|
|
144
|
-
...(diagnostics.length ? { diagnostics } : {}), capabilities: debuggerCapabilities, reasons, limitations, cleanup: { status: 'complete', remaining_resources: [] } };
|
|
149
|
+
...(diagnostics.length ? { diagnostics } : {}), capabilities: this.options.capabilities ?? debuggerCapabilities, reasons, limitations: this.options.limitations ?? androidLimitations, cleanup: { status: 'complete', remaining_resources: [] } };
|
|
145
150
|
}
|
|
146
151
|
async attach(device: string, app: string, pid?: number, timeout = 10000) {
|
|
147
152
|
this.prune();
|
|
@@ -167,16 +172,19 @@ export class DebuggerSessions {
|
|
|
167
172
|
if (candidates.length > 1)
|
|
168
173
|
throw new DebuggerError('PROCESS_AMBIGUOUS');
|
|
169
174
|
s.target = candidates[0];
|
|
175
|
+
if (this.platform === 'android') {
|
|
170
176
|
stage = 'forwarding';
|
|
171
177
|
s.port = await atStage('forwarding', 'DEBUGGER_ATTACH_FAILED', () => this.native.forward(device, s.target!.pid));
|
|
172
178
|
remaining();
|
|
173
179
|
s.cleanup.remaining_resources.push(`forward:tcp:${s.port}`);
|
|
180
|
+
}
|
|
174
181
|
stage = 'worker_start';
|
|
175
182
|
s.worker = await atStage('worker_start', 'DEBUGGER_ATTACH_FAILED', () => this.workerFactory(event => this.event(s, event)));
|
|
176
183
|
remaining();
|
|
177
|
-
s.cleanup.remaining_resources.push(
|
|
184
|
+
s.cleanup.remaining_resources.push(this.workerResource);
|
|
185
|
+
if (this.platform === 'ios') await atStage('target_resolution', 'DEBUGGER_ATTACH_FAILED', () => this.native.identity(device, s.target!));
|
|
178
186
|
stage = 'attach';
|
|
179
|
-
await atStage('attach', 'DEBUGGER_ATTACH_FAILED', () => s.worker!.call('attach', { port: s.port, timeoutMs: remaining() }, remaining()));
|
|
187
|
+
await atStage('attach', 'DEBUGGER_ATTACH_FAILED', () => s.worker!.call('attach', { port: s.port, ...(this.platform === 'ios' ? { pid: s.target!.pid, executablePath: s.target!.executable_path } : {}), timeoutMs: remaining() }, remaining()));
|
|
180
188
|
stage = 'target_resolution';
|
|
181
189
|
await atStage('target_resolution', 'DEBUGGER_ATTACH_FAILED', () => this.native.identity(device, s.target!));
|
|
182
190
|
remaining();
|
|
@@ -227,7 +235,7 @@ export class DebuggerSessions {
|
|
|
227
235
|
s.worker = undefined;
|
|
228
236
|
}
|
|
229
237
|
catch {
|
|
230
|
-
remaining.push(
|
|
238
|
+
remaining.push(this.workerResource);
|
|
231
239
|
}
|
|
232
240
|
}
|
|
233
241
|
if (s.port !== undefined && s.target) {
|
|
@@ -275,6 +283,7 @@ export class DebuggerSessions {
|
|
|
275
283
|
private assertCommandTarget(s: Session, command: string, args: CommandArgs) {
|
|
276
284
|
if (!s.worker || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state))
|
|
277
285
|
throw new DebuggerError('DEBUGGER_DISCONNECTED', s.diagnostic ?? diagnostic('command', 'unavailable'));
|
|
286
|
+
if (command === 'set' && (this.platform === 'ios' ? typeof args.sourceFile !== 'string' || args.className !== undefined : typeof args.className !== 'string' || args.sourceFile !== undefined)) throw new DebuggerError('INVALID_ARGUMENT');
|
|
278
287
|
if (['stack', 'locals', 'step', 'resume'].includes(command)) {
|
|
279
288
|
if (args.stopId !== s.stop?.stop_id)
|
|
280
289
|
throw new DebuggerError('DEBUGGER_STALE_REFERENCE');
|
|
@@ -291,6 +300,7 @@ export class DebuggerSessions {
|
|
|
291
300
|
if (command === 'state' && (s.cleanup.status === 'complete' || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state)))
|
|
292
301
|
return this.result(s);
|
|
293
302
|
this.assertCommandTarget(s, command, args);
|
|
303
|
+
const previous = { state: s.state, stop: s.stop, pausedAt: s.pausedAt };
|
|
294
304
|
if (command === 'step' || command === 'resume') {
|
|
295
305
|
s.state = 'running';
|
|
296
306
|
s.stop = null;
|
|
@@ -301,6 +311,7 @@ export class DebuggerSessions {
|
|
|
301
311
|
return this.result(s, command === 'state' ? { breakpoints: data.breakpoints } : { result: data });
|
|
302
312
|
}
|
|
303
313
|
catch (e) {
|
|
314
|
+
if (['step', 'resume'].includes(command) && s.state === 'running' && s.stop === null && e instanceof DebuggerError && ['INVALID_ARGUMENT', 'DEBUGGER_STALE_REFERENCE'].includes(e.code)) Object.assign(s, previous);
|
|
304
315
|
return this.handleCommandError(s, command, classify(e, 'command', 'DEBUGGER_WORKER_FAILED'));
|
|
305
316
|
}
|
|
306
317
|
}
|
|
@@ -324,4 +335,5 @@ export class DebuggerSessions {
|
|
|
324
335
|
async shutdown() { this.closing = true; await Promise.allSettled([...this.sessions.values()].filter(s => s.cleanup.status !== 'complete').map(s => this.serial(s, () => this.cleanup(s, true)))); return [...this.sessions.values()].filter(s => s.cleanup.status !== 'complete').map(s => this.result(s)); }
|
|
325
336
|
}
|
|
326
337
|
export const debuggerSessions = new DebuggerSessions();
|
|
327
|
-
|
|
338
|
+
export const iosDebuggerSessions = new DebuggerSessions(new IOSDebugNative(), createIOSWorker, prepareIOSWorker, { platform: 'ios', capabilities: iosCapabilities, limitations: iosLimitations });
|
|
339
|
+
registerDebuggerPauseProvider((device, platform = 'android') => (platform === 'ios' ? iosDebuggerSessions : debuggerSessions).paused(device));
|
package/src/debugger/worker.ts
CHANGED
|
@@ -58,7 +58,7 @@ export interface DebugWorker {
|
|
|
58
58
|
close(): Promise<void>
|
|
59
59
|
}
|
|
60
60
|
type WorkerFailureReason = 'spawn_failed' | 'process_exited' | 'write_failed' | 'invalid_message' | 'message_limit'
|
|
61
|
-
export class
|
|
61
|
+
export class DebuggerProcess implements DebugWorker {
|
|
62
62
|
private child: ChildProcessWithoutNullStreams
|
|
63
63
|
private pending = new Map<number, { resolve(value: WorkerReply): void; reject(error: Error): void; timer: ReturnType<typeof setTimeout>; command: string }>()
|
|
64
64
|
private sequence = 0
|
|
@@ -66,8 +66,8 @@ export class JdiWorker implements DebugWorker {
|
|
|
66
66
|
private failure?: DebuggerError
|
|
67
67
|
private replied = false
|
|
68
68
|
private exited: Promise<void>
|
|
69
|
-
constructor(java: string, directory: string, private onEvent: (event: WorkerEvent) => void) {
|
|
70
|
-
this.child = spawn(java, ['-Xmx128m', '--add-modules', 'jdk.jdi', '-cp', directory, 'DebuggerWorker'], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
69
|
+
constructor(java: string, directory: string, private onEvent: (event: WorkerEvent) => void, launch?: { args: string[]; env: NodeJS.ProcessEnv }) {
|
|
70
|
+
this.child = spawn(java, launch?.args ?? ['-Xmx128m', '--add-modules', 'jdk.jdi', '-cp', directory, 'DebuggerWorker'], { stdio: ['pipe', 'pipe', 'pipe'], ...(launch ? { env: launch.env } : {}) })
|
|
71
71
|
let buffer = ''
|
|
72
72
|
this.exited = new Promise(resolve => { this.child.once('exit', () => { this.fail('process_exited'); resolve() }); this.child.once('error', () => { this.fail('spawn_failed'); resolve() }) })
|
|
73
73
|
this.child.stdin.on('error', () => this.fail('write_failed'))
|
|
@@ -131,5 +131,8 @@ export class JdiWorker implements DebugWorker {
|
|
|
131
131
|
}
|
|
132
132
|
export async function createWorker(onEvent: (event: WorkerEvent) => void) {
|
|
133
133
|
const { java, directory } = await prepareWorker()
|
|
134
|
-
return new
|
|
134
|
+
return new DebuggerProcess(java, directory, onEvent)
|
|
135
135
|
}
|
|
136
|
+
|
|
137
|
+
// Compatibility export for existing JDI transport callers/tests.
|
|
138
|
+
export { DebuggerProcess as JdiWorker }
|
package/src/interact/index.ts
CHANGED
|
@@ -157,11 +157,22 @@ export class ToolsInteract {
|
|
|
157
157
|
return await interact.tap(x, y, resolved.id, timeoutMs)
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
private static _getResolvedElement(elementId: string, platform?: 'android' | 'ios', deviceId?: string) {
|
|
161
|
+
const resolved = ToolsInteract._resolvedUiElements.get(elementId)
|
|
162
|
+
if (!resolved || (platform && resolved.platform !== platform) || (deviceId && resolved.deviceId !== deviceId)) return null
|
|
163
|
+
return resolved
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
static getResolvedElementTarget(elementId: string, platform?: 'android' | 'ios', deviceId?: string) {
|
|
167
|
+
const resolved = ToolsInteract._getResolvedElement(elementId, platform, deviceId)
|
|
168
|
+
return resolved ? { platform: resolved.platform, deviceId: resolved.deviceId } : null
|
|
169
|
+
}
|
|
170
|
+
|
|
160
171
|
static async tapElementHandler({ elementId, verificationMode = 'full', timeoutMs, platform, deviceId, freshTree }: { elementId: string, verificationMode?: 'none' | 'light' | 'full', timeoutMs?: number, platform?: 'android' | 'ios', deviceId?: string, freshTree?: UiTree }): Promise<TapElementResponse> {
|
|
161
172
|
const actionType = 'tap_element'
|
|
162
173
|
const selector = { elementId }
|
|
163
|
-
const resolved = ToolsInteract.
|
|
164
|
-
if (!resolved
|
|
174
|
+
const resolved = ToolsInteract._getResolvedElement(elementId, platform, deviceId)
|
|
175
|
+
if (!resolved) {
|
|
165
176
|
return ToolsInteract._actionFailure(actionType, selector, null, 'STALE_REFERENCE', true, null)
|
|
166
177
|
}
|
|
167
178
|
|
|
@@ -5,7 +5,7 @@ import type { ScopeTree } from '../targeting/target-scope.js'
|
|
|
5
5
|
import type { UiChangeScopeResult, UiChangeSignatureSet } from '../model.js'
|
|
6
6
|
import { remainingBudget } from '../../utils/operation-budget.js'
|
|
7
7
|
import { checkDebuggerGuard } from '../../debugger/guard.js'
|
|
8
|
-
import { observeReadinessWait, isDeviceUnavailable } from '../../observe/readiness.js'
|
|
8
|
+
import { observeReadinessWait, observeReadinessDeviceWait, isDeviceUnavailable } from '../../observe/readiness.js'
|
|
9
9
|
import { readinessDelay } from '../../observe/readiness-service.js'
|
|
10
10
|
import { _buildUiChangeSignatures, _summarizeUiChangeDelta, _matchesUiChange, _uiChangeSignaturesEqual } from './ui-change-helpers.js'
|
|
11
11
|
import { createChangeWait, changeResult, type ChangeInput, type ChangeWait } from './ui-change-state.js'
|
|
@@ -24,11 +24,16 @@ function changed(s: ChangeWait, signatures: UiChangeSignatureSet) {
|
|
|
24
24
|
s.candidate = signatures; s.change = change; s.since = s.clock.now()
|
|
25
25
|
}
|
|
26
26
|
const stable = s.since === null ? 0 : s.clock.now() - s.since
|
|
27
|
-
return (s.stability === 0 || stable >= s.stability) && (!s.tracker || s.tracker.
|
|
27
|
+
return (s.stability === 0 || stable >= s.stability) && (!s.tracker || s.tracker.ready)
|
|
28
28
|
}
|
|
29
29
|
async function sample(s: ChangeWait, deps: ChangeDependencies) {
|
|
30
30
|
if (s.tracker) remainingBudget()
|
|
31
31
|
const started = s.clock.now(), a = s.input
|
|
32
|
+
await observeReadinessDeviceWait()
|
|
33
|
+
if (s.tracker?.deviceReadiness.input_ready === false) {
|
|
34
|
+
s.candidate = null; s.change = null; s.since = null
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
32
37
|
const tree = await deps.observe({ platform: a.platform, deviceId: a.deviceId })
|
|
33
38
|
observeReadinessWait(tree, started)
|
|
34
39
|
const scoped = deps.scope(tree, a.scope, a.target)
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import { systemClock } from '../../runtime/clock.js'
|
|
2
|
+
import { benchmarkStage } from '../../utils/benchmark-metrics.js'
|
|
2
3
|
import type { UiTree } from '../observation.js'
|
|
3
4
|
import { remainingBudget } from '../../utils/operation-budget.js'
|
|
4
5
|
import { checkDebuggerGuard } from '../../debugger/guard.js'
|
|
5
|
-
import { observeReadinessWait, isDeviceUnavailable } from '../../observe/readiness.js'
|
|
6
|
+
import { observeReadinessWait, observeReadinessDeviceWait, isDeviceUnavailable } from '../../observe/readiness.js'
|
|
6
7
|
import { readinessDelay } from '../../observe/readiness-service.js'
|
|
7
8
|
import { matchingElements, evaluateCondition } from './ui-wait-condition.js'
|
|
8
9
|
import { createUIWait, validateUIWait, waitObserved, waitFailure,
|
|
9
10
|
type UIWaitInput, type UIWaitDependencies, type UIWaitState, type UIWaitResult } from './ui-wait-state.js'
|
|
10
11
|
|
|
11
12
|
async function delay(s: UIWaitState, ms: number) {
|
|
12
|
-
await (s.tracker && s.clock === systemClock ? readinessDelay(ms) : s.clock.sleep(ms))
|
|
13
|
+
await benchmarkStage('poll_sleep_ms', () => s.tracker && s.clock === systemClock ? readinessDelay(ms) : s.clock.sleep(ms))
|
|
13
14
|
}
|
|
14
15
|
function acceptObservation(s: UIWaitState, tree: UiTree, now: number): UIWaitResult | null {
|
|
15
16
|
const age = typeof tree?.captured_at_ms === 'number' ? now - tree.captured_at_ms : null
|
|
16
17
|
if (!(s.met && (age === null || age <= 500))) { s.stable = 0; return null }
|
|
17
18
|
s.stable++
|
|
18
|
-
if (s.stable < (s.args.singleObservation ? 1 : 2) || (s.tracker && s.tracker.
|
|
19
|
+
if (s.stable < (s.args.singleObservation ? 1 : 2) || (s.tracker && !s.tracker.ready)) return null
|
|
19
20
|
return { status: 'success', ...(s.args.singleObservation ? { _tree: tree } : {}), matched: s.count,
|
|
20
21
|
element: s.element, metrics: { latency_ms: now - s.start, poll_count: s.polls, attempts: s.attempts }, requested: s.requested, observed: waitObserved(s, true) }
|
|
21
22
|
}
|
|
@@ -42,6 +43,8 @@ async function poll(s: UIWaitState, deps: UIWaitDependencies): Promise<UIWaitRes
|
|
|
42
43
|
remainingBudget(); s.polls++
|
|
43
44
|
try {
|
|
44
45
|
const started = s.clock.now(), a = s.args
|
|
46
|
+
await observeReadinessDeviceWait()
|
|
47
|
+
if (s.tracker?.deviceReadiness.input_ready === false) return null
|
|
45
48
|
const tree = await deps.observe({ platform: a.platform, deviceId: a.deviceId })
|
|
46
49
|
observeReadinessWait(tree, started)
|
|
47
50
|
if (a.resolveTapTarget && tree?.error && /ACTION_TIMEOUT|timed out/i.test(String(tree.error))) throw new Error('ACTION_TIMEOUT')
|
package/src/manage/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validAppId } from '../utils/app-installation.js'
|
|
1
2
|
import type { IOSBuildOptions } from './ios.js'
|
|
2
3
|
import { promises as fs } from 'fs'
|
|
3
4
|
import path from 'path'
|
|
@@ -303,6 +304,7 @@ export class ToolsManage {
|
|
|
303
304
|
}
|
|
304
305
|
|
|
305
306
|
static async listDevicesHandler({ platform, appId }: { platform?: 'android' | 'ios', appId?: string }) {
|
|
307
|
+
if (appId !== undefined && !validAppId(appId)) throw new Error('Invalid appId')
|
|
306
308
|
const devices = await listDevices(platform, appId)
|
|
307
309
|
return { devices }
|
|
308
310
|
}
|
package/src/manage/ios.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checkDebuggerGuard } from '../debugger/guard.js'
|
|
1
2
|
import { buildIOS } from './ios/build.js'
|
|
2
3
|
import type { IOSBuildOptions } from './ios/build-types.js'
|
|
3
4
|
export type { IOSBuildOptions } from './ios/build-types.js'
|
|
@@ -160,6 +161,7 @@ export class iOSManage {
|
|
|
160
161
|
|
|
161
162
|
async function installWithIdb(toInstall: string, deviceId: string) {
|
|
162
163
|
await new Promise<void>((resolve, reject) => {
|
|
164
|
+
checkDebuggerGuard(true)
|
|
163
165
|
const proc = spawn(getIdbCmd(), ['install', toInstall, '--udid', deviceId]);
|
|
164
166
|
let stderr = '';
|
|
165
167
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import type { DeviceReadiness } from '../contracts/device-readiness.js'
|
|
3
|
+
import type { DeviceInfo } from '../types.js'
|
|
4
|
+
import { pausedReadiness } from '../debugger/guard.js'
|
|
5
|
+
import { execCmd } from '../utils/exec.js'
|
|
6
|
+
import { getAdbCmd } from '../utils/android/commands.js'
|
|
7
|
+
import { getXcrunCmd } from '../utils/ios/utils.js'
|
|
8
|
+
import { remainingBudget } from '../utils/operation-budget.js'
|
|
9
|
+
import { benchmarkStage } from '../utils/benchmark-metrics.js'
|
|
10
|
+
|
|
11
|
+
export function unknownDeviceReadiness(code?: string): DeviceReadiness {
|
|
12
|
+
return { wakefulness: 'unknown', lock_state: 'unknown', input_ready: null, blockers: [], evidence: [], limitations: code ? [code] : [] }
|
|
13
|
+
}
|
|
14
|
+
function values(text: string, pattern: RegExp) { return [...text.matchAll(pattern)].map(match => match[1]) }
|
|
15
|
+
function unique(items: string[]) { return items.length > 0 && new Set(items).size === 1 ? items[0] : null }
|
|
16
|
+
export function parseAndroidPower(text: string): DeviceReadiness['wakefulness'] {
|
|
17
|
+
// Later vendor/debug sections can repeat mWakefulness as a numeric enum.
|
|
18
|
+
// Classify only the named current-state section when its boundary is present.
|
|
19
|
+
const main = text.split('Power Manager State:')[1]
|
|
20
|
+
if (main?.includes('Settings and Configuration:')) text = main.split('Settings and Configuration:')[0]
|
|
21
|
+
const wake = unique(values(text, /^\s*mWakefulness=(\w+)\s*$/gm))
|
|
22
|
+
const changing = unique(values(text, /^\s*mWakefulnessChanging=(true|false)\s*$/gm))
|
|
23
|
+
if (!wake || !changing) return 'unknown'
|
|
24
|
+
if (changing === 'true') return 'transitioning'
|
|
25
|
+
return wake === 'Awake' ? 'awake' : ['Asleep', 'Dozing'].includes(wake) ? 'asleep' : 'unknown'
|
|
26
|
+
}
|
|
27
|
+
export function parseAndroidLock(text: string): DeviceReadiness['lock_state'] {
|
|
28
|
+
const section = text.split(/\bKeyguardServiceDelegate\s*\n/)[1]
|
|
29
|
+
if (!section) return 'unknown'
|
|
30
|
+
const showing = unique(values(section, /^\s*(?:showing|mIsShowing)=(true|false)\s*$/gm))
|
|
31
|
+
return showing === 'true' ? 'locked' : showing === 'false' ? 'unlocked' : 'unknown'
|
|
32
|
+
}
|
|
33
|
+
export function finishDeviceReadiness(state: DeviceReadiness): DeviceReadiness {
|
|
34
|
+
if (state.wakefulness === 'asleep') state.blockers.push('DEVICE_ASLEEP')
|
|
35
|
+
if (state.wakefulness === 'transitioning') state.blockers.push('DEVICE_TRANSITIONING')
|
|
36
|
+
if (state.lock_state === 'locked') state.blockers.push('DEVICE_LOCKED')
|
|
37
|
+
state.blockers = [...new Set(state.blockers)]
|
|
38
|
+
state.input_ready = state.blockers.length ? false : null
|
|
39
|
+
return state
|
|
40
|
+
}
|
|
41
|
+
export function parseIosLock(text: string): DeviceReadiness['lock_state'] {
|
|
42
|
+
try {
|
|
43
|
+
const data = JSON.parse(text)
|
|
44
|
+
if (data.error?.domain === 'com.apple.dt.CoreDeviceError' && data.error.code === 1001 &&
|
|
45
|
+
data.error.userInfo?.CapabilityFeatureIdentifier?.string === 'com.apple.coredevice.feature.getlockstate') return 'unsupported'
|
|
46
|
+
// Successful response semantics require runtime validation before classification.
|
|
47
|
+
} catch { /* Non-JSON output supplies no state evidence. */ }
|
|
48
|
+
return 'unknown'
|
|
49
|
+
}
|
|
50
|
+
type Probe = (command: string, args: string[], timeout: number) => Promise<{ exitCode: number | null; stdout: string; stderr: string }>
|
|
51
|
+
const probes = new AsyncLocalStorage<Probe>()
|
|
52
|
+
export function withDeviceReadinessProbe<T>(probe: Probe, operation: () => Promise<T>) { return probes.run(probe, operation) }
|
|
53
|
+
const nativeProbe: Probe = (command, args, timeout) => execCmd(command, args, { timeout })
|
|
54
|
+
export async function observeDeviceReadiness(device?: Pick<DeviceInfo, 'platform' | 'id'> | null, probe: Probe = probes.getStore() ?? nativeProbe): Promise<DeviceReadiness> {
|
|
55
|
+
return benchmarkStage('device_readiness_ms', () => observe(device, probe))
|
|
56
|
+
}
|
|
57
|
+
async function observe(device: Pick<DeviceInfo, 'platform' | 'id'> | null | undefined, probe: Probe): Promise<DeviceReadiness> {
|
|
58
|
+
const state = unknownDeviceReadiness()
|
|
59
|
+
if (!device?.id || !['android', 'ios'].includes(device.platform) || ['booted', 'default'].includes(device.id)) return unknownDeviceReadiness('DEVICE_TARGET_UNAVAILABLE')
|
|
60
|
+
const paused = pausedReadiness(device.id, device.platform)
|
|
61
|
+
if (paused) {
|
|
62
|
+
state.blockers.push('DEBUGGER_PAUSED')
|
|
63
|
+
state.evidence.push({ code: 'DEBUGGER_PAUSED', source: 'debugger', captured_at_ms: paused.captured_at_ms })
|
|
64
|
+
return finishDeviceReadiness(state)
|
|
65
|
+
}
|
|
66
|
+
if (device.platform === 'android') {
|
|
67
|
+
await androidProbe(state, device.id, 'power', probe)
|
|
68
|
+
await androidProbe(state, device.id, 'window', probe)
|
|
69
|
+
} else {
|
|
70
|
+
state.limitations.push('DEVICE_WAKEFULNESS_UNVERIFIED')
|
|
71
|
+
try {
|
|
72
|
+
const timeout = remainingBudget(1000)
|
|
73
|
+
// CoreDevice requires a CLI timeout of at least five seconds; the parent
|
|
74
|
+
// subprocess deadline still caps this read at one second.
|
|
75
|
+
const result = await probe(getXcrunCmd(), ['devicectl', 'device', 'info', 'lockState', '--device', device.id, '--timeout', '5', '--json-output', '-'], timeout)
|
|
76
|
+
remainingBudget()
|
|
77
|
+
state.lock_state = parseIosLock(result.stdout)
|
|
78
|
+
if (state.lock_state === 'unsupported') state.evidence.push({ code: 'DEVICE_LOCK_SIGNAL_UNSUPPORTED', source: 'coredevice', captured_at_ms: Date.now() })
|
|
79
|
+
else state.limitations.push('DEVICE_LOCK_OBSERVATION_UNAVAILABLE')
|
|
80
|
+
} catch { state.limitations.push('DEVICE_LOCK_OBSERVATION_FAILED') }
|
|
81
|
+
}
|
|
82
|
+
return finishDeviceReadiness(state)
|
|
83
|
+
}
|
|
84
|
+
async function androidProbe(state: DeviceReadiness, id: string, kind: 'power' | 'window', probe: Probe) {
|
|
85
|
+
try {
|
|
86
|
+
const timeout = remainingBudget(1000)
|
|
87
|
+
const result = await probe(getAdbCmd(), ['-s', id, 'shell', 'dumpsys', kind, ...(kind === 'window' ? ['policy'] : [])], timeout)
|
|
88
|
+
remainingBudget()
|
|
89
|
+
if (result.exitCode !== 0) throw new Error('PROBE_FAILED')
|
|
90
|
+
const value = kind === 'power' ? parseAndroidPower(result.stdout) : parseAndroidLock(result.stdout)
|
|
91
|
+
if (kind === 'power') state.wakefulness = value as DeviceReadiness['wakefulness']
|
|
92
|
+
else state.lock_state = value as DeviceReadiness['lock_state']
|
|
93
|
+
if (value === 'unknown') state.limitations.push(kind === 'power' ? 'DEVICE_WAKEFULNESS_UNVERIFIED' : 'DEVICE_LOCK_STATE_UNVERIFIED')
|
|
94
|
+
else state.evidence.push({ code: `DEVICE_${value.toUpperCase()}`, source: `android_${kind}`, captured_at_ms: Date.now() })
|
|
95
|
+
} catch { state.limitations.push(kind === 'power' ? 'DEVICE_POWER_OBSERVATION_FAILED' : 'DEVICE_LOCK_OBSERVATION_FAILED') }
|
|
96
|
+
}
|
package/src/observe/evidence.ts
CHANGED
|
@@ -214,15 +214,25 @@ export function refreshJourneyEvidence(steps: Array<{ result?: { evidence?: unkn
|
|
|
214
214
|
const current = context.getStore()
|
|
215
215
|
if (!current) return
|
|
216
216
|
for (const step of steps) {
|
|
217
|
-
const previous = step.result?.evidence as { action_id?: string } | undefined
|
|
217
|
+
const previous = step.result?.evidence as { action_id?: string; observations?: Array<{ evidence_id: string }> } | undefined
|
|
218
218
|
const child = current.children.find(c => c.action?.id === previous?.action_id)
|
|
219
219
|
if (child && step.result) step.result.evidence = assemble(child)
|
|
220
|
+
else if (previous?.observations && step.result) {
|
|
221
|
+
const ids = new Set(previous.observations.map(o => o.evidence_id))
|
|
222
|
+
step.result.evidence = assemble({ ...current, observations: current.observations.filter(o => ids.has(o.value.evidence_id)) })
|
|
223
|
+
}
|
|
220
224
|
}
|
|
221
225
|
}
|
|
222
226
|
|
|
223
227
|
export function completeJourneyVerification(actionId: string) {
|
|
224
228
|
const child = context.getStore()?.children.find(c => c.action?.id === actionId)
|
|
225
229
|
if (child?.action) {
|
|
230
|
+
const parent = context.getStore()!
|
|
231
|
+
const completed = child.action.dispatchEnd?.mono
|
|
232
|
+
if (completed !== undefined) {
|
|
233
|
+
const after = parent.observations.filter(o => o.start.mono >= completed && sameDevice(o.value.device, child.action!.device))
|
|
234
|
+
child.observations.push(...after)
|
|
235
|
+
}
|
|
226
236
|
child.verification = stamp()
|
|
227
237
|
child.action.verification = child.verification
|
|
228
238
|
}
|
|
@@ -312,3 +322,22 @@ function recordCaptureTime(acquisition: Acquisition, result: EvidencePayload | n
|
|
|
312
322
|
acquisition.reused = source < start.wall
|
|
313
323
|
}
|
|
314
324
|
}
|
|
325
|
+
|
|
326
|
+
export class JourneyStepError extends Error {
|
|
327
|
+
constructor(readonly evidence: ReturnType<typeof assemble>, cause: unknown) {
|
|
328
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause })
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Retain per-step evidence without changing the parent journey's acquisition flow. */
|
|
333
|
+
export async function withJourneyStepEvidence<T>(operation: () => Promise<T>) {
|
|
334
|
+
const current = context.getStore()
|
|
335
|
+
const start = current?.observations.length ?? 0
|
|
336
|
+
try {
|
|
337
|
+
const result = await operation()
|
|
338
|
+
return { ...result, ...(current ? { evidence: assemble({ ...current, observations: current.observations.slice(start) }) } : {}) }
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (current) throw new JourneyStepError(assemble({ ...current, observations: current.observations.slice(start) }), error)
|
|
341
|
+
throw error
|
|
342
|
+
}
|
|
343
|
+
}
|