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
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { atStage, classify, diagnostic } from './diagnostics.js';
|
|
2
2
|
import { registerDebuggerPauseProvider } from './guard.js';
|
|
3
|
+
import { IOSDebugNative } from './ios.js';
|
|
4
|
+
import { createIOSWorker, prepareIOSWorker, iosCapabilities, iosLimitations } from './ios-worker.js';
|
|
3
5
|
import { randomUUID } from 'node:crypto';
|
|
4
6
|
import { AndroidDebugNative } from './android.js';
|
|
5
7
|
import { createWorker, prepareWorker, DebuggerError, withDebuggerBudget, debuggerRemaining } from './worker.js';
|
|
6
8
|
export const debuggerCapabilities = { attach: true, breakpoints: true, stacks: true, locals: true, step: true, resume: true,
|
|
7
9
|
evaluation: false, mutation: false, coroutines: false, native: false };
|
|
8
|
-
const
|
|
10
|
+
export const androidLimitations = ['JAVA_KOTLIN_BYTECODE_ONLY', 'DEBUG_METADATA_REQUIRED', 'SHALLOW_LOCALS', 'ALL_THREADS_SUSPEND', 'DEVICE_WIDE_UI_GUARD'];
|
|
9
11
|
export function debuggerExpiry(created, activity, pausedAt, now) {
|
|
10
12
|
return pausedAt !== undefined && now - pausedAt >= 60000 ? 'pause_limit' : now - activity >= 300000 ? 'idle_limit' : now - created >= 1800000 ? 'session_limit' : null;
|
|
11
13
|
}
|
|
@@ -13,13 +15,17 @@ export class DebuggerSessions {
|
|
|
13
15
|
native;
|
|
14
16
|
workerFactory;
|
|
15
17
|
prerequisite;
|
|
18
|
+
options;
|
|
16
19
|
sessions = new Map();
|
|
17
20
|
closing = false;
|
|
18
|
-
constructor(native = new AndroidDebugNative(), workerFactory = createWorker, prerequisite = prepareWorker) {
|
|
21
|
+
constructor(native = new AndroidDebugNative(), workerFactory = createWorker, prerequisite = prepareWorker, options = {}) {
|
|
19
22
|
this.native = native;
|
|
20
23
|
this.workerFactory = workerFactory;
|
|
21
24
|
this.prerequisite = prerequisite;
|
|
25
|
+
this.options = options;
|
|
22
26
|
}
|
|
27
|
+
get platform() { return this.options.platform ?? 'android'; }
|
|
28
|
+
get workerResource() { return this.platform === 'ios' ? 'lldb_worker' : 'jdi_worker'; }
|
|
23
29
|
prune() {
|
|
24
30
|
const terminal = [...this.sessions.values()].filter(s => s.terminalAt !== undefined && s.cleanup.status === 'complete');
|
|
25
31
|
for (const s of terminal)
|
|
@@ -42,9 +48,10 @@ export class DebuggerSessions {
|
|
|
42
48
|
const success = payload.success ?? (!['failed', 'disconnected'].includes(s.state) && s.cleanup.status !== 'failed');
|
|
43
49
|
const code = s.cleanup.status === 'failed' ? 'DEBUGGER_CLEANUP_FAILED' : s.state === 'disconnected' ? 'DEBUGGER_DISCONNECTED' : s.reason?.match(/^[A-Z_]+$/) ? s.reason : 'DEBUGGER_WORKER_FAILED';
|
|
44
50
|
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,
|
|
45
|
-
device: { platform:
|
|
46
|
-
capabilities: debuggerCapabilities, limitations, cleanup: s.cleanup, reason: s.reason, stop: s.stop, ...payload });
|
|
51
|
+
device: { platform: this.platform, id: s.device }, target: s.target, state: s.state, captured_at_ms: Date.now(),
|
|
52
|
+
capabilities: this.options.capabilities ?? debuggerCapabilities, limitations: this.options.limitations ?? androidLimitations, cleanup: s.cleanup, reason: s.reason, stop: s.stop, ...payload });
|
|
47
53
|
}
|
|
54
|
+
owns(id) { return this.sessions.has(id); }
|
|
48
55
|
summary(id) { return this.result(this.get(id)); }
|
|
49
56
|
hasActiveSessions() { return [...this.sessions.values()].some(s => s.cleanup.status !== 'complete'); }
|
|
50
57
|
paused(device) { const s = [...this.sessions.values()].find(s => s.device === device && s.state === 'paused'); return s ? this.result(s) : null; }
|
|
@@ -77,13 +84,13 @@ export class DebuggerSessions {
|
|
|
77
84
|
s.events.shift();
|
|
78
85
|
}
|
|
79
86
|
async capabilities(device, app, pid) {
|
|
80
|
-
const prerequisites = { jdk: false, device: false, process: false };
|
|
87
|
+
const prerequisites = { [this.platform === 'ios' ? 'lldb' : 'jdk']: false, device: false, process: false };
|
|
81
88
|
let candidates = [];
|
|
82
89
|
const reasons = [];
|
|
83
90
|
const diagnostics = [];
|
|
84
91
|
try {
|
|
85
92
|
await atStage('prerequisite', 'DEBUGGER_UNSUPPORTED', () => this.prerequisite());
|
|
86
|
-
prerequisites.jdk = true;
|
|
93
|
+
prerequisites[this.platform === 'ios' ? 'lldb' : 'jdk'] = true;
|
|
87
94
|
}
|
|
88
95
|
catch (e) {
|
|
89
96
|
if (e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED'].includes(e.code))
|
|
@@ -106,9 +113,9 @@ export class DebuggerSessions {
|
|
|
106
113
|
reasons.push(e instanceof DebuggerError ? e.code : 'DEVICE_UNAVAILABLE');
|
|
107
114
|
diagnostics.push(classify(e, 'target_resolution', 'DEVICE_UNAVAILABLE').diagnostic ?? diagnostic('target_resolution', 'unknown'));
|
|
108
115
|
}
|
|
109
|
-
return { success: true, debug_session_id: null, device: { platform:
|
|
116
|
+
return { success: true, debug_session_id: null, device: { platform: this.platform, id: device }, target: null, state: null,
|
|
110
117
|
captured_at_ms: Date.now(), supported: Object.values(prerequisites).every(Boolean), prerequisites, candidates,
|
|
111
|
-
...(diagnostics.length ? { diagnostics } : {}), capabilities: debuggerCapabilities, reasons, limitations, cleanup: { status: 'complete', remaining_resources: [] } };
|
|
118
|
+
...(diagnostics.length ? { diagnostics } : {}), capabilities: this.options.capabilities ?? debuggerCapabilities, reasons, limitations: this.options.limitations ?? androidLimitations, cleanup: { status: 'complete', remaining_resources: [] } };
|
|
112
119
|
}
|
|
113
120
|
async attach(device, app, pid, timeout = 10000) {
|
|
114
121
|
this.prune();
|
|
@@ -144,16 +151,20 @@ export class DebuggerSessions {
|
|
|
144
151
|
if (candidates.length > 1)
|
|
145
152
|
throw new DebuggerError('PROCESS_AMBIGUOUS');
|
|
146
153
|
s.target = candidates[0];
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
154
|
+
if (this.platform === 'android') {
|
|
155
|
+
stage = 'forwarding';
|
|
156
|
+
s.port = await atStage('forwarding', 'DEBUGGER_ATTACH_FAILED', () => this.native.forward(device, s.target.pid));
|
|
157
|
+
remaining();
|
|
158
|
+
s.cleanup.remaining_resources.push(`forward:tcp:${s.port}`);
|
|
159
|
+
}
|
|
151
160
|
stage = 'worker_start';
|
|
152
161
|
s.worker = await atStage('worker_start', 'DEBUGGER_ATTACH_FAILED', () => this.workerFactory(event => this.event(s, event)));
|
|
153
162
|
remaining();
|
|
154
|
-
s.cleanup.remaining_resources.push(
|
|
163
|
+
s.cleanup.remaining_resources.push(this.workerResource);
|
|
164
|
+
if (this.platform === 'ios')
|
|
165
|
+
await atStage('target_resolution', 'DEBUGGER_ATTACH_FAILED', () => this.native.identity(device, s.target));
|
|
155
166
|
stage = 'attach';
|
|
156
|
-
await atStage('attach', 'DEBUGGER_ATTACH_FAILED', () => s.worker.call('attach', { port: s.port, timeoutMs: remaining() }, remaining()));
|
|
167
|
+
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()));
|
|
157
168
|
stage = 'target_resolution';
|
|
158
169
|
await atStage('target_resolution', 'DEBUGGER_ATTACH_FAILED', () => this.native.identity(device, s.target));
|
|
159
170
|
remaining();
|
|
@@ -204,7 +215,7 @@ export class DebuggerSessions {
|
|
|
204
215
|
s.worker = undefined;
|
|
205
216
|
}
|
|
206
217
|
catch {
|
|
207
|
-
remaining.push(
|
|
218
|
+
remaining.push(this.workerResource);
|
|
208
219
|
}
|
|
209
220
|
}
|
|
210
221
|
if (s.port !== undefined && s.target) {
|
|
@@ -252,6 +263,8 @@ export class DebuggerSessions {
|
|
|
252
263
|
assertCommandTarget(s, command, args) {
|
|
253
264
|
if (!s.worker || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state))
|
|
254
265
|
throw new DebuggerError('DEBUGGER_DISCONNECTED', s.diagnostic ?? diagnostic('command', 'unavailable'));
|
|
266
|
+
if (command === 'set' && (this.platform === 'ios' ? typeof args.sourceFile !== 'string' || args.className !== undefined : typeof args.className !== 'string' || args.sourceFile !== undefined))
|
|
267
|
+
throw new DebuggerError('INVALID_ARGUMENT');
|
|
255
268
|
if (['stack', 'locals', 'step', 'resume'].includes(command)) {
|
|
256
269
|
if (args.stopId !== s.stop?.stop_id)
|
|
257
270
|
throw new DebuggerError('DEBUGGER_STALE_REFERENCE');
|
|
@@ -268,6 +281,7 @@ export class DebuggerSessions {
|
|
|
268
281
|
if (command === 'state' && (s.cleanup.status === 'complete' || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state)))
|
|
269
282
|
return this.result(s);
|
|
270
283
|
this.assertCommandTarget(s, command, args);
|
|
284
|
+
const previous = { state: s.state, stop: s.stop, pausedAt: s.pausedAt };
|
|
271
285
|
if (command === 'step' || command === 'resume') {
|
|
272
286
|
s.state = 'running';
|
|
273
287
|
s.stop = null;
|
|
@@ -278,6 +292,8 @@ export class DebuggerSessions {
|
|
|
278
292
|
return this.result(s, command === 'state' ? { breakpoints: data.breakpoints } : { result: data });
|
|
279
293
|
}
|
|
280
294
|
catch (e) {
|
|
295
|
+
if (['step', 'resume'].includes(command) && s.state === 'running' && s.stop === null && e instanceof DebuggerError && ['INVALID_ARGUMENT', 'DEBUGGER_STALE_REFERENCE'].includes(e.code))
|
|
296
|
+
Object.assign(s, previous);
|
|
281
297
|
return this.handleCommandError(s, command, classify(e, 'command', 'DEBUGGER_WORKER_FAILED'));
|
|
282
298
|
}
|
|
283
299
|
}
|
|
@@ -301,4 +317,5 @@ export class DebuggerSessions {
|
|
|
301
317
|
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)); }
|
|
302
318
|
}
|
|
303
319
|
export const debuggerSessions = new DebuggerSessions();
|
|
304
|
-
|
|
320
|
+
export const iosDebuggerSessions = new DebuggerSessions(new IOSDebugNative(), createIOSWorker, prepareIOSWorker, { platform: 'ios', capabilities: iosCapabilities, limitations: iosLimitations });
|
|
321
|
+
registerDebuggerPauseProvider((device, platform = 'android') => (platform === 'ios' ? iosDebuggerSessions : debuggerSessions).paused(device));
|
package/dist/debugger/worker.js
CHANGED
|
@@ -63,7 +63,7 @@ export async function prepareWorker() {
|
|
|
63
63
|
return compiled;
|
|
64
64
|
}
|
|
65
65
|
function commandStage(command) { return command === 'attach' ? 'attach' : command === 'detach' ? 'cleanup' : 'command'; }
|
|
66
|
-
export class
|
|
66
|
+
export class DebuggerProcess {
|
|
67
67
|
onEvent;
|
|
68
68
|
child;
|
|
69
69
|
pending = new Map();
|
|
@@ -72,9 +72,9 @@ export class JdiWorker {
|
|
|
72
72
|
failure;
|
|
73
73
|
replied = false;
|
|
74
74
|
exited;
|
|
75
|
-
constructor(java, directory, onEvent) {
|
|
75
|
+
constructor(java, directory, onEvent, launch) {
|
|
76
76
|
this.onEvent = onEvent;
|
|
77
|
-
this.child = spawn(java, ['-Xmx128m', '--add-modules', 'jdk.jdi', '-cp', directory, 'DebuggerWorker'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
77
|
+
this.child = spawn(java, launch?.args ?? ['-Xmx128m', '--add-modules', 'jdk.jdi', '-cp', directory, 'DebuggerWorker'], { stdio: ['pipe', 'pipe', 'pipe'], ...(launch ? { env: launch.env } : {}) });
|
|
78
78
|
let buffer = '';
|
|
79
79
|
this.exited = new Promise(resolve => { this.child.once('exit', () => { this.fail('process_exited'); resolve(); }); this.child.once('error', () => { this.fail('spawn_failed'); resolve(); }); });
|
|
80
80
|
this.child.stdin.on('error', () => this.fail('write_failed'));
|
|
@@ -172,5 +172,7 @@ export class JdiWorker {
|
|
|
172
172
|
}
|
|
173
173
|
export async function createWorker(onEvent) {
|
|
174
174
|
const { java, directory } = await prepareWorker();
|
|
175
|
-
return new
|
|
175
|
+
return new DebuggerProcess(java, directory, onEvent);
|
|
176
176
|
}
|
|
177
|
+
// Compatibility export for existing JDI transport callers/tests.
|
|
178
|
+
export { DebuggerProcess as JdiWorker };
|
package/dist/interact/index.js
CHANGED
|
@@ -105,11 +105,21 @@ export class ToolsInteract {
|
|
|
105
105
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
|
|
106
106
|
return await interact.tap(x, y, resolved.id, timeoutMs);
|
|
107
107
|
}
|
|
108
|
+
static _getResolvedElement(elementId, platform, deviceId) {
|
|
109
|
+
const resolved = ToolsInteract._resolvedUiElements.get(elementId);
|
|
110
|
+
if (!resolved || (platform && resolved.platform !== platform) || (deviceId && resolved.deviceId !== deviceId))
|
|
111
|
+
return null;
|
|
112
|
+
return resolved;
|
|
113
|
+
}
|
|
114
|
+
static getResolvedElementTarget(elementId, platform, deviceId) {
|
|
115
|
+
const resolved = ToolsInteract._getResolvedElement(elementId, platform, deviceId);
|
|
116
|
+
return resolved ? { platform: resolved.platform, deviceId: resolved.deviceId } : null;
|
|
117
|
+
}
|
|
108
118
|
static async tapElementHandler({ elementId, verificationMode = 'full', timeoutMs, platform, deviceId, freshTree }) {
|
|
109
119
|
const actionType = 'tap_element';
|
|
110
120
|
const selector = { elementId };
|
|
111
|
-
const resolved = ToolsInteract.
|
|
112
|
-
if (!resolved
|
|
121
|
+
const resolved = ToolsInteract._getResolvedElement(elementId, platform, deviceId);
|
|
122
|
+
if (!resolved) {
|
|
113
123
|
return ToolsInteract._actionFailure(actionType, selector, null, 'STALE_REFERENCE', true, null);
|
|
114
124
|
}
|
|
115
125
|
const fingerprintBefore = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { systemClock } from '../../runtime/clock.js';
|
|
2
2
|
import { remainingBudget } from '../../utils/operation-budget.js';
|
|
3
3
|
import { checkDebuggerGuard } from '../../debugger/guard.js';
|
|
4
|
-
import { observeReadinessWait, isDeviceUnavailable } from '../../observe/readiness.js';
|
|
4
|
+
import { observeReadinessWait, observeReadinessDeviceWait, isDeviceUnavailable } from '../../observe/readiness.js';
|
|
5
5
|
import { readinessDelay } from '../../observe/readiness-service.js';
|
|
6
6
|
import { _buildUiChangeSignatures, _summarizeUiChangeDelta, _matchesUiChange, _uiChangeSignaturesEqual } from './ui-change-helpers.js';
|
|
7
7
|
import { createChangeWait, changeResult } from './ui-change-state.js';
|
|
@@ -26,12 +26,19 @@ function changed(s, signatures) {
|
|
|
26
26
|
s.since = s.clock.now();
|
|
27
27
|
}
|
|
28
28
|
const stable = s.since === null ? 0 : s.clock.now() - s.since;
|
|
29
|
-
return (s.stability === 0 || stable >= s.stability) && (!s.tracker || s.tracker.
|
|
29
|
+
return (s.stability === 0 || stable >= s.stability) && (!s.tracker || s.tracker.ready);
|
|
30
30
|
}
|
|
31
31
|
async function sample(s, deps) {
|
|
32
32
|
if (s.tracker)
|
|
33
33
|
remainingBudget();
|
|
34
34
|
const started = s.clock.now(), a = s.input;
|
|
35
|
+
await observeReadinessDeviceWait();
|
|
36
|
+
if (s.tracker?.deviceReadiness.input_ready === false) {
|
|
37
|
+
s.candidate = null;
|
|
38
|
+
s.change = null;
|
|
39
|
+
s.since = null;
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
35
42
|
const tree = await deps.observe({ platform: a.platform, deviceId: a.deviceId });
|
|
36
43
|
observeReadinessWait(tree, started);
|
|
37
44
|
const scoped = deps.scope(tree, a.scope, a.target);
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { systemClock } from '../../runtime/clock.js';
|
|
2
|
+
import { benchmarkStage } from '../../utils/benchmark-metrics.js';
|
|
2
3
|
import { remainingBudget } from '../../utils/operation-budget.js';
|
|
3
4
|
import { checkDebuggerGuard } from '../../debugger/guard.js';
|
|
4
|
-
import { observeReadinessWait, isDeviceUnavailable } from '../../observe/readiness.js';
|
|
5
|
+
import { observeReadinessWait, observeReadinessDeviceWait, isDeviceUnavailable } from '../../observe/readiness.js';
|
|
5
6
|
import { readinessDelay } from '../../observe/readiness-service.js';
|
|
6
7
|
import { matchingElements, evaluateCondition } from './ui-wait-condition.js';
|
|
7
8
|
import { createUIWait, validateUIWait, waitObserved, waitFailure } from './ui-wait-state.js';
|
|
8
9
|
async function delay(s, ms) {
|
|
9
|
-
await (s.tracker && s.clock === systemClock ? readinessDelay(ms) : s.clock.sleep(ms));
|
|
10
|
+
await benchmarkStage('poll_sleep_ms', () => s.tracker && s.clock === systemClock ? readinessDelay(ms) : s.clock.sleep(ms));
|
|
10
11
|
}
|
|
11
12
|
function acceptObservation(s, tree, now) {
|
|
12
13
|
const age = typeof tree?.captured_at_ms === 'number' ? now - tree.captured_at_ms : null;
|
|
@@ -15,7 +16,7 @@ function acceptObservation(s, tree, now) {
|
|
|
15
16
|
return null;
|
|
16
17
|
}
|
|
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))
|
|
19
20
|
return null;
|
|
20
21
|
return { status: 'success', ...(s.args.singleObservation ? { _tree: tree } : {}), matched: s.count,
|
|
21
22
|
element: s.element, metrics: { latency_ms: now - s.start, poll_count: s.polls, attempts: s.attempts }, requested: s.requested, observed: waitObserved(s, true) };
|
|
@@ -50,6 +51,9 @@ async function poll(s, deps) {
|
|
|
50
51
|
s.polls++;
|
|
51
52
|
try {
|
|
52
53
|
const started = s.clock.now(), a = s.args;
|
|
54
|
+
await observeReadinessDeviceWait();
|
|
55
|
+
if (s.tracker?.deviceReadiness.input_ready === false)
|
|
56
|
+
return null;
|
|
53
57
|
const tree = await deps.observe({ platform: a.platform, deviceId: a.deviceId });
|
|
54
58
|
observeReadinessWait(tree, started);
|
|
55
59
|
if (a.resolveTapTarget && tree?.error && /ACTION_TIMEOUT|timed out/i.test(String(tree.error)))
|
package/dist/manage/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validAppId } from '../utils/app-installation.js';
|
|
1
2
|
import { promises as fs } from 'fs';
|
|
2
3
|
import path from 'path';
|
|
3
4
|
import { resolveTargetDevice, listDevices } from '../utils/resolve-device.js';
|
|
@@ -313,6 +314,8 @@ export class ToolsManage {
|
|
|
313
314
|
}
|
|
314
315
|
}
|
|
315
316
|
static async listDevicesHandler({ platform, appId }) {
|
|
317
|
+
if (appId !== undefined && !validAppId(appId))
|
|
318
|
+
throw new Error('Invalid appId');
|
|
316
319
|
const devices = await listDevices(platform, appId);
|
|
317
320
|
return { devices };
|
|
318
321
|
}
|
package/dist/manage/ios.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checkDebuggerGuard } from '../debugger/guard.js';
|
|
1
2
|
import { buildIOS } from './ios/build.js';
|
|
2
3
|
import { evidenceDispatch } from '../observe/evidence.js';
|
|
3
4
|
import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js';
|
|
@@ -156,6 +157,7 @@ export class iOSManage {
|
|
|
156
157
|
}
|
|
157
158
|
async function installWithIdb(toInstall, deviceId) {
|
|
158
159
|
await new Promise((resolve, reject) => {
|
|
160
|
+
checkDebuggerGuard(true);
|
|
159
161
|
const proc = spawn(getIdbCmd(), ['install', toInstall, '--udid', deviceId]);
|
|
160
162
|
let stderr = '';
|
|
161
163
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { pausedReadiness } from '../debugger/guard.js';
|
|
3
|
+
import { execCmd } from '../utils/exec.js';
|
|
4
|
+
import { getAdbCmd } from '../utils/android/commands.js';
|
|
5
|
+
import { getXcrunCmd } from '../utils/ios/utils.js';
|
|
6
|
+
import { remainingBudget } from '../utils/operation-budget.js';
|
|
7
|
+
import { benchmarkStage } from '../utils/benchmark-metrics.js';
|
|
8
|
+
export function unknownDeviceReadiness(code) {
|
|
9
|
+
return { wakefulness: 'unknown', lock_state: 'unknown', input_ready: null, blockers: [], evidence: [], limitations: code ? [code] : [] };
|
|
10
|
+
}
|
|
11
|
+
function values(text, pattern) { return [...text.matchAll(pattern)].map(match => match[1]); }
|
|
12
|
+
function unique(items) { return items.length > 0 && new Set(items).size === 1 ? items[0] : null; }
|
|
13
|
+
export function parseAndroidPower(text) {
|
|
14
|
+
// Later vendor/debug sections can repeat mWakefulness as a numeric enum.
|
|
15
|
+
// Classify only the named current-state section when its boundary is present.
|
|
16
|
+
const main = text.split('Power Manager State:')[1];
|
|
17
|
+
if (main?.includes('Settings and Configuration:'))
|
|
18
|
+
text = main.split('Settings and Configuration:')[0];
|
|
19
|
+
const wake = unique(values(text, /^\s*mWakefulness=(\w+)\s*$/gm));
|
|
20
|
+
const changing = unique(values(text, /^\s*mWakefulnessChanging=(true|false)\s*$/gm));
|
|
21
|
+
if (!wake || !changing)
|
|
22
|
+
return 'unknown';
|
|
23
|
+
if (changing === 'true')
|
|
24
|
+
return 'transitioning';
|
|
25
|
+
return wake === 'Awake' ? 'awake' : ['Asleep', 'Dozing'].includes(wake) ? 'asleep' : 'unknown';
|
|
26
|
+
}
|
|
27
|
+
export function parseAndroidLock(text) {
|
|
28
|
+
const section = text.split(/\bKeyguardServiceDelegate\s*\n/)[1];
|
|
29
|
+
if (!section)
|
|
30
|
+
return 'unknown';
|
|
31
|
+
const showing = unique(values(section, /^\s*(?:showing|mIsShowing)=(true|false)\s*$/gm));
|
|
32
|
+
return showing === 'true' ? 'locked' : showing === 'false' ? 'unlocked' : 'unknown';
|
|
33
|
+
}
|
|
34
|
+
export function finishDeviceReadiness(state) {
|
|
35
|
+
if (state.wakefulness === 'asleep')
|
|
36
|
+
state.blockers.push('DEVICE_ASLEEP');
|
|
37
|
+
if (state.wakefulness === 'transitioning')
|
|
38
|
+
state.blockers.push('DEVICE_TRANSITIONING');
|
|
39
|
+
if (state.lock_state === 'locked')
|
|
40
|
+
state.blockers.push('DEVICE_LOCKED');
|
|
41
|
+
state.blockers = [...new Set(state.blockers)];
|
|
42
|
+
state.input_ready = state.blockers.length ? false : null;
|
|
43
|
+
return state;
|
|
44
|
+
}
|
|
45
|
+
export function parseIosLock(text) {
|
|
46
|
+
try {
|
|
47
|
+
const data = JSON.parse(text);
|
|
48
|
+
if (data.error?.domain === 'com.apple.dt.CoreDeviceError' && data.error.code === 1001 &&
|
|
49
|
+
data.error.userInfo?.CapabilityFeatureIdentifier?.string === 'com.apple.coredevice.feature.getlockstate')
|
|
50
|
+
return 'unsupported';
|
|
51
|
+
// Successful response semantics require runtime validation before classification.
|
|
52
|
+
}
|
|
53
|
+
catch { /* Non-JSON output supplies no state evidence. */ }
|
|
54
|
+
return 'unknown';
|
|
55
|
+
}
|
|
56
|
+
const probes = new AsyncLocalStorage();
|
|
57
|
+
export function withDeviceReadinessProbe(probe, operation) { return probes.run(probe, operation); }
|
|
58
|
+
const nativeProbe = (command, args, timeout) => execCmd(command, args, { timeout });
|
|
59
|
+
export async function observeDeviceReadiness(device, probe = probes.getStore() ?? nativeProbe) {
|
|
60
|
+
return benchmarkStage('device_readiness_ms', () => observe(device, probe));
|
|
61
|
+
}
|
|
62
|
+
async function observe(device, probe) {
|
|
63
|
+
const state = unknownDeviceReadiness();
|
|
64
|
+
if (!device?.id || !['android', 'ios'].includes(device.platform) || ['booted', 'default'].includes(device.id))
|
|
65
|
+
return unknownDeviceReadiness('DEVICE_TARGET_UNAVAILABLE');
|
|
66
|
+
const paused = pausedReadiness(device.id, device.platform);
|
|
67
|
+
if (paused) {
|
|
68
|
+
state.blockers.push('DEBUGGER_PAUSED');
|
|
69
|
+
state.evidence.push({ code: 'DEBUGGER_PAUSED', source: 'debugger', captured_at_ms: paused.captured_at_ms });
|
|
70
|
+
return finishDeviceReadiness(state);
|
|
71
|
+
}
|
|
72
|
+
if (device.platform === 'android') {
|
|
73
|
+
await androidProbe(state, device.id, 'power', probe);
|
|
74
|
+
await androidProbe(state, device.id, 'window', probe);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
state.limitations.push('DEVICE_WAKEFULNESS_UNVERIFIED');
|
|
78
|
+
try {
|
|
79
|
+
const timeout = remainingBudget(1000);
|
|
80
|
+
// CoreDevice requires a CLI timeout of at least five seconds; the parent
|
|
81
|
+
// subprocess deadline still caps this read at one second.
|
|
82
|
+
const result = await probe(getXcrunCmd(), ['devicectl', 'device', 'info', 'lockState', '--device', device.id, '--timeout', '5', '--json-output', '-'], timeout);
|
|
83
|
+
remainingBudget();
|
|
84
|
+
state.lock_state = parseIosLock(result.stdout);
|
|
85
|
+
if (state.lock_state === 'unsupported')
|
|
86
|
+
state.evidence.push({ code: 'DEVICE_LOCK_SIGNAL_UNSUPPORTED', source: 'coredevice', captured_at_ms: Date.now() });
|
|
87
|
+
else
|
|
88
|
+
state.limitations.push('DEVICE_LOCK_OBSERVATION_UNAVAILABLE');
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
state.limitations.push('DEVICE_LOCK_OBSERVATION_FAILED');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return finishDeviceReadiness(state);
|
|
95
|
+
}
|
|
96
|
+
async function androidProbe(state, id, kind, probe) {
|
|
97
|
+
try {
|
|
98
|
+
const timeout = remainingBudget(1000);
|
|
99
|
+
const result = await probe(getAdbCmd(), ['-s', id, 'shell', 'dumpsys', kind, ...(kind === 'window' ? ['policy'] : [])], timeout);
|
|
100
|
+
remainingBudget();
|
|
101
|
+
if (result.exitCode !== 0)
|
|
102
|
+
throw new Error('PROBE_FAILED');
|
|
103
|
+
const value = kind === 'power' ? parseAndroidPower(result.stdout) : parseAndroidLock(result.stdout);
|
|
104
|
+
if (kind === 'power')
|
|
105
|
+
state.wakefulness = value;
|
|
106
|
+
else
|
|
107
|
+
state.lock_state = value;
|
|
108
|
+
if (value === 'unknown')
|
|
109
|
+
state.limitations.push(kind === 'power' ? 'DEVICE_WAKEFULNESS_UNVERIFIED' : 'DEVICE_LOCK_STATE_UNVERIFIED');
|
|
110
|
+
else
|
|
111
|
+
state.evidence.push({ code: `DEVICE_${value.toUpperCase()}`, source: `android_${kind}`, captured_at_ms: Date.now() });
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
state.limitations.push(kind === 'power' ? 'DEVICE_POWER_OBSERVATION_FAILED' : 'DEVICE_LOCK_OBSERVATION_FAILED');
|
|
115
|
+
}
|
|
116
|
+
}
|
package/dist/observe/evidence.js
CHANGED
|
@@ -217,11 +217,21 @@ export function refreshJourneyEvidence(steps) {
|
|
|
217
217
|
const child = current.children.find(c => c.action?.id === previous?.action_id);
|
|
218
218
|
if (child && step.result)
|
|
219
219
|
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
|
export function completeJourneyVerification(actionId) {
|
|
223
227
|
const child = context.getStore()?.children.find(c => c.action?.id === actionId);
|
|
224
228
|
if (child?.action) {
|
|
229
|
+
const parent = context.getStore();
|
|
230
|
+
const completed = child.action.dispatchEnd?.mono;
|
|
231
|
+
if (completed !== undefined) {
|
|
232
|
+
const after = parent.observations.filter(o => o.start.mono >= completed && sameDevice(o.value.device, child.action.device));
|
|
233
|
+
child.observations.push(...after);
|
|
234
|
+
}
|
|
225
235
|
child.verification = stamp();
|
|
226
236
|
child.action.verification = child.verification;
|
|
227
237
|
}
|
|
@@ -317,3 +327,24 @@ function recordCaptureTime(acquisition, result, start) {
|
|
|
317
327
|
acquisition.reused = source < start.wall;
|
|
318
328
|
}
|
|
319
329
|
}
|
|
330
|
+
export class JourneyStepError extends Error {
|
|
331
|
+
evidence;
|
|
332
|
+
constructor(evidence, cause) {
|
|
333
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
|
334
|
+
this.evidence = evidence;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/** Retain per-step evidence without changing the parent journey's acquisition flow. */
|
|
338
|
+
export async function withJourneyStepEvidence(operation) {
|
|
339
|
+
const current = context.getStore();
|
|
340
|
+
const start = current?.observations.length ?? 0;
|
|
341
|
+
try {
|
|
342
|
+
const result = await operation();
|
|
343
|
+
return { ...result, ...(current ? { evidence: assemble({ ...current, observations: current.observations.slice(start) }) } : {}) };
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
if (current)
|
|
347
|
+
throw new JourneyStepError(assemble({ ...current, observations: current.observations.slice(start) }), error);
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
|
4
|
+
import { getXcrunCmd } from '../../utils/ios/utils.js';
|
|
5
|
+
import { traceCommand, spawnTrace } from './process.js';
|
|
6
|
+
export const IOS_TRACE_MAX_BYTES = 256 * 1024 * 1024;
|
|
7
|
+
export async function traceSize(root, secure = false) {
|
|
8
|
+
const stat = await fs.lstat(root);
|
|
9
|
+
if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory()))
|
|
10
|
+
throw new Error('UNSAFE_ARTIFACT');
|
|
11
|
+
if (secure)
|
|
12
|
+
await fs.chmod(root, stat.isDirectory() ? 0o700 : 0o600);
|
|
13
|
+
if (stat.isFile())
|
|
14
|
+
return stat.size;
|
|
15
|
+
let size = 0;
|
|
16
|
+
for (const name of await fs.readdir(root)) {
|
|
17
|
+
size += await traceSize(path.join(root, name), secure);
|
|
18
|
+
if (size > IOS_TRACE_MAX_BYTES)
|
|
19
|
+
throw new Error('STORAGE_LIMIT');
|
|
20
|
+
}
|
|
21
|
+
return size;
|
|
22
|
+
}
|
|
23
|
+
export function validateToc(xml, device, app) {
|
|
24
|
+
if (XMLValidator.validate(xml) !== true)
|
|
25
|
+
throw new Error('INVALID_TOC');
|
|
26
|
+
const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml);
|
|
27
|
+
const raw = parsed['trace-toc']?.run;
|
|
28
|
+
if (!raw || Array.isArray(raw))
|
|
29
|
+
throw new Error('INVALID_TOC');
|
|
30
|
+
const target = raw.info?.target;
|
|
31
|
+
if (target?.device?.['@_uuid'] !== device || Number(target.process?.['@_pid']) !== app.pid || raw.info?.summary?.['template-name'] !== 'Time Profiler')
|
|
32
|
+
throw new Error('ARTIFACT_TARGET_MISMATCH');
|
|
33
|
+
const processes = raw.processes?.process;
|
|
34
|
+
if (!(Array.isArray(processes) ? processes : [processes]).some(p => Number(p?.['@_pid']) === app.pid && p?.['@_path'] === app.executable))
|
|
35
|
+
throw new Error('ARTIFACT_APP_MISMATCH');
|
|
36
|
+
const tables = raw.data?.table;
|
|
37
|
+
if (!(Array.isArray(tables) ? tables : [tables]).some(t => t?.['@_schema'] === 'time-sample'))
|
|
38
|
+
throw new Error('SAMPLE_SCHEMA_MISSING');
|
|
39
|
+
}
|
|
40
|
+
export async function validateArtifact(file, device, app, spawn = spawnTrace) {
|
|
41
|
+
const size = await traceSize(file, true);
|
|
42
|
+
if (size <= 0 || size > IOS_TRACE_MAX_BYTES)
|
|
43
|
+
throw new Error('STORAGE_LIMIT');
|
|
44
|
+
const toc = path.join(path.dirname(file), 'toc.xml'), samples = path.join(path.dirname(file), 'samples.xml');
|
|
45
|
+
try {
|
|
46
|
+
await traceCommand(getXcrunCmd(), ['xctrace', 'export', '--input', file, '--toc', '--output', toc], 15000, spawn);
|
|
47
|
+
if ((await fs.stat(toc)).size > 1024 * 1024)
|
|
48
|
+
throw new Error('INVALID_TOC');
|
|
49
|
+
validateToc(await fs.readFile(toc, 'utf8'), device, app);
|
|
50
|
+
await traceCommand(getXcrunCmd(), ['xctrace', 'export', '--input', file, '--xpath', '/trace-toc/run[@number="1"]/data/table[@schema="time-sample"]', '--output', samples], 15000, spawn);
|
|
51
|
+
if ((await fs.stat(samples)).size > IOS_TRACE_MAX_BYTES)
|
|
52
|
+
throw new Error('STORAGE_LIMIT');
|
|
53
|
+
const xml = await fs.readFile(samples, 'utf8');
|
|
54
|
+
if (XMLValidator.validate(xml) !== true || !xml.includes('<trace-query-result>'))
|
|
55
|
+
throw new Error('INVALID_SAMPLES');
|
|
56
|
+
const sample_rows = (xml.match(/<row(?:\s|>)/g) ?? []).length;
|
|
57
|
+
return { path: file, format: 'instruments', kind: (await fs.stat(file)).isDirectory() ? 'directory' : 'file',
|
|
58
|
+
size_bytes: size, validation: { readable: true, schema: 'time-sample', sample_rows } };
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await Promise.all([toc, samples].map(p => fs.rm(p, { force: true })));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getXcrunCmd } from '../../utils/ios/utils.js';
|
|
5
|
+
import { traceCommand, spawnTrace } from './process.js';
|
|
6
|
+
import { prepareNotification } from './notification.js';
|
|
7
|
+
export const IOS_TEMPLATES = ['System Trace', 'Time Profiler', 'Animation Hitches', 'Hangs'];
|
|
8
|
+
export async function resolveIosApp(device, appId, spawn = spawnTrace) {
|
|
9
|
+
const run = (args) => traceCommand(getXcrunCmd(), args, 10000, spawn);
|
|
10
|
+
const container = await run(['simctl', 'get_app_container', device, appId, 'app']);
|
|
11
|
+
if (!path.isAbsolute(container) || !container.endsWith('.app'))
|
|
12
|
+
throw new Error('APP_IDENTITY_UNAVAILABLE');
|
|
13
|
+
const output = await run(['simctl', 'spawn', device, 'launchctl', 'list']);
|
|
14
|
+
const ids = output.split('\n').filter(line => line.includes(`UIKitApplication:${appId}[`))
|
|
15
|
+
.map(line => Number(line.trim().split(/\s+/)[0])).filter(pid => Number.isSafeInteger(pid) && pid > 1);
|
|
16
|
+
if (ids.length !== 1)
|
|
17
|
+
throw new Error('APP_NOT_RUNNING');
|
|
18
|
+
const executable = await traceCommand('/bin/ps', ['-p', String(ids[0]), '-o', 'comm='], 10000, spawn);
|
|
19
|
+
if (!executable.startsWith(container + '/') || executable.includes('\n'))
|
|
20
|
+
throw new Error('APP_IDENTITY_UNAVAILABLE');
|
|
21
|
+
const started_at = await traceCommand('/bin/ps', ['-p', String(ids[0]), '-o', 'lstart='], 10000, spawn);
|
|
22
|
+
if (!started_at || started_at.includes('\n'))
|
|
23
|
+
throw new Error('APP_IDENTITY_UNAVAILABLE');
|
|
24
|
+
return { app_id: appId, pid: ids[0], executable, started_at };
|
|
25
|
+
}
|
|
26
|
+
export async function iosCapabilities(device, appId, template = 'Time Profiler', spawn = spawnTrace, host = process.platform) {
|
|
27
|
+
const result = {
|
|
28
|
+
supported: false, backend: 'xctrace', device: null, xcode_version: null,
|
|
29
|
+
prerequisites: { host: host === 'darwin' ? 'available' : 'missing', permissions: 'unknown', symbols: 'unknown' },
|
|
30
|
+
reasons: [], recovery: [], discovered_templates: [], templates: [], app: null,
|
|
31
|
+
limitations: ['App attachment may include sensitive system metadata, paths, logs and signposts.', 'Permissions and symbolication are not established by discovery.', 'Physical iOS capture is not supported in the initial matrix.']
|
|
32
|
+
};
|
|
33
|
+
if (host !== 'darwin') {
|
|
34
|
+
result.reasons.push('MACOS_REQUIRED');
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
const probe = async (key, fn, recovery) => {
|
|
38
|
+
try {
|
|
39
|
+
await fn();
|
|
40
|
+
result.prerequisites[key] = 'available';
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
result.prerequisites[key] = 'missing';
|
|
44
|
+
result.reasons.push(error instanceof Error && ['APP_NOT_RUNNING', 'APP_IDENTITY_UNAVAILABLE'].includes(error.message) ? error.message : key.toUpperCase() + '_UNAVAILABLE');
|
|
45
|
+
result.recovery.push(recovery);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
await probe('xcode', async () => { result.xcode_version = await traceCommand(getXcrunCmd(), ['xcodebuild', '-version'], 10000, spawn); }, 'Select a full Xcode installation with xcode-select.');
|
|
49
|
+
await probe('templates', async () => {
|
|
50
|
+
const output = await traceCommand(getXcrunCmd(), ['xctrace', 'list', 'templates'], 10000, spawn);
|
|
51
|
+
result.discovered_templates = output.split('\n').map(s => s.trim()).filter(s => s && !s.startsWith('=='));
|
|
52
|
+
}, 'Install/select Xcode with Instruments and run xcrun xctrace list templates.');
|
|
53
|
+
await probe('instruments_targets', async () => {
|
|
54
|
+
const targets = await traceCommand(getXcrunCmd(), ['xctrace', 'list', 'devices'], 15000, spawn);
|
|
55
|
+
if (!targets.includes(`(${device})`))
|
|
56
|
+
throw new Error('TARGET_UNAVAILABLE');
|
|
57
|
+
}, 'Check xcrun xctrace list devices; use a simulator visible to Instruments.');
|
|
58
|
+
await probe('target', async () => {
|
|
59
|
+
const data = JSON.parse(await traceCommand(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], 10000, spawn));
|
|
60
|
+
const entries = Object.entries(data.devices).flatMap(([runtime, values]) => values.map(value => ({ ...value, runtime })));
|
|
61
|
+
const target = entries.find(d => d.udid === device);
|
|
62
|
+
if (!target || !target.isAvailable || target.state !== 'Booted')
|
|
63
|
+
throw new Error('TARGET_UNAVAILABLE');
|
|
64
|
+
result.device = { platform: 'ios', id: target.udid, simulator: true, model: target.name, osVersion: target.runtime.split('iOS-')[1]?.replaceAll('-', '.') ?? target.runtime };
|
|
65
|
+
}, 'Use an explicit booted simulator UDID. Physical devices and the Mac host are unsupported.');
|
|
66
|
+
await probe('notification_helper', async () => {
|
|
67
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-trace-probe-'));
|
|
68
|
+
try {
|
|
69
|
+
await prepareNotification(directory, spawn);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
}, 'Allow Xcode clang to compile the local Darwin-notification listener.');
|
|
75
|
+
if (appId && result.device)
|
|
76
|
+
await probe('app', async () => { result.app = await resolveIosApp(device, appId, spawn); }, 'Start the requested development app on the selected simulator before tracing.');
|
|
77
|
+
result.templates = IOS_TEMPLATES.map(name => ({ name,
|
|
78
|
+
supported: name === 'Time Profiler' && result.discovered_templates.includes(name) && result.device !== null,
|
|
79
|
+
coverage: name === 'Time Profiler' ? 'CPU samples; bundled Hangs instrument is not independently verified' : name === 'System Trace' ? 'Scheduling coverage unverified' : name === 'Hangs' ? 'Not a standalone template here' : 'Unsupported on tested simulator',
|
|
80
|
+
reason: name === 'Time Profiler' ? null : 'TEMPLATE_TARGET_UNSUPPORTED'
|
|
81
|
+
}));
|
|
82
|
+
if (!result.templates.find(t => t.name === template)?.supported)
|
|
83
|
+
result.reasons.push('TEMPLATE_TARGET_UNSUPPORTED');
|
|
84
|
+
result.supported = result.reasons.length === 0;
|
|
85
|
+
return result;
|
|
86
|
+
}
|