mobile-debug-mcp 0.32.0 → 0.32.2

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.
Files changed (61) hide show
  1. package/README.md +1 -0
  2. package/dist/observe/android-tree-bridge.js +202 -0
  3. package/dist/observe/android.js +49 -15
  4. package/dist/observe/ios.js +45 -29
  5. package/dist/observe/performance-metrics.js +124 -0
  6. package/dist/server/tool-handlers.js +31 -9
  7. package/dist/server-core.js +1 -1
  8. package/dist/utils/exec.js +15 -4
  9. package/docs/CHANGELOG.md +6 -0
  10. package/docs/ROADMAP.md +9 -0
  11. package/docs/benchmarks/ui-tree-performance/README.md +58 -0
  12. package/docs/benchmarks/ui-tree-performance/baseline.json +59546 -0
  13. package/docs/benchmarks/ui-tree-performance/bridge-probe.json +29 -0
  14. package/docs/benchmarks/ui-tree-performance/candidate.json +51691 -0
  15. package/docs/benchmarks/ui-tree-performance/comparison-attempt-1.json +110679 -0
  16. package/docs/benchmarks/ui-tree-performance/comparison.json +111230 -0
  17. package/docs/benchmarks/ui-tree-performance/comparison.md +41 -0
  18. package/docs/benchmarks/ui-tree-performance/initial-baseline.json +59560 -0
  19. package/docs/specs/019-ui-tree-observation-performance/spec.md +148 -0
  20. package/docs/tools/observe.md +2 -0
  21. package/package.json +1 -1
  22. package/skills/mobile-debug-tools-usage/SKILL.md +74 -0
  23. package/src/observe/android-tree-bridge.ts +178 -0
  24. package/src/observe/android.ts +44 -17
  25. package/src/observe/ios.ts +45 -36
  26. package/src/observe/performance-metrics.ts +125 -0
  27. package/src/server/tool-handlers.ts +34 -10
  28. package/src/server-core.ts +1 -1
  29. package/src/utils/exec.ts +10 -4
  30. package/test/device/automated/ui-tree-performance/benchmark.ts +401 -0
  31. package/test/device/automated/ui-tree-performance/bridge-probe.ts +197 -0
  32. package/test/fixtures/latency-journey-app/AndroidManifest.xml +2 -1
  33. package/test/fixtures/latency-journey-app/README.md +19 -1
  34. package/test/fixtures/latency-journey-app/src/MainActivity.java +88 -1
  35. package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +6 -0
  36. package/test/fixtures/ui-tree-bridge/build.ts +34 -0
  37. package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +126 -0
  38. package/test/unit/observe/android_tree_acquisition.test.ts +70 -0
  39. package/test/unit/observe/android_tree_bridge_cleanup.test.ts +95 -0
  40. package/test/unit/observe/ios_tree_acquisition.test.ts +77 -0
  41. package/test/unit/observe/performance_metrics.test.ts +40 -0
  42. package/test/unit/server/operation_budget.test.ts +13 -0
  43. package/test/unit/server/run_journey.test.ts +30 -0
  44. /package/docs/{specs-deltas/001-state-verification.md → specs/001-state-verification/spec.md} +0 -0
  45. /package/docs/{specs-deltas/002-richer-element-identity → specs/002-richer-element-identity/spec.md} +0 -0
  46. /package/docs/{specs-deltas/003-wait-and-synchronization-reliability.md → specs/003-wait-and-synchronization-reliability/spec.md} +0 -0
  47. /package/docs/{specs-deltas/004-action-verification-routing.md → specs/004-action-verification-routing/spec.md} +0 -0
  48. /package/docs/{specs-deltas/005-unified-action-execution-and-verification-model.md → specs/005-unified-action-execution-and-verification-model/spec.md} +0 -0
  49. /package/docs/{specs-deltas/006-runtime-action-instrumentation-and-binding-layer.md → specs/006-runtime-action-instrumentation-and-binding-layer/spec.md} +0 -0
  50. /package/docs/{specs-deltas/007-actionability-resolution-and-executable-target-selection.md → specs/007-actionability-resolution-and-executable-target-selection/spec.md} +0 -0
  51. /package/docs/{specs-deltas/008-adjustable-control-support-and-semantic-value-manipulation.md → specs/008-adjustable-control-support-and-semantic-value-manipulation/spec.md} +0 -0
  52. /package/docs/{specs-deltas/009-semantic-control-modeling-for-custom-and-composite-controls.md → specs/009-semantic-control-modeling-for-custom-and-composite-controls/spec.md} +0 -0
  53. /package/docs/{specs-deltas/010-verification-stabilization-and-temporal-convergence.md → specs/010-verification-stabilization-and-temporal-convergence/spec.md} +0 -0
  54. /package/docs/{specs-deltas/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows.md → specs/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows/spec.md} +0 -0
  55. /package/docs/{specs-deltas/011.1-recovery-contract-types-and-runtime-wiring-spec.md → specs/011.1-recovery-contract-types-and-runtime-wiring-spec/spec.md} +0 -0
  56. /package/docs/{specs-deltas/012-action-trace-and-xecution-observability.md → specs/012-action-trace-and-xecution-observability/spec.md} +0 -0
  57. /package/docs/{specs-deltas/013-wait-and-synchronization-reliability.md → specs/013-wait-and-synchronization-reliability/spec.md} +0 -0
  58. /package/docs/{specs-deltas/014-actionability-resolution.md → specs/014-actionability-resolution/spec.md} +0 -0
  59. /package/docs/{specs-deltas/015-adjustable-control-support.md → specs/015-adjustable-control-support/spec.md} +0 -0
  60. /package/docs/{specs-deltas/016-environment-tools.md → specs/016-environment-tools/spec.md} +0 -0
  61. /package/docs/{specs-deltas/018-interaction-latency-and-batched-journeys.md → specs/018-interaction-latency-and-batched-journeys/spec.md} +0 -0
@@ -1,5 +1,7 @@
1
1
  import { spawn } from "child_process"
2
2
  import { remainingBudget } from '../utils/operation-budget.js'
3
+ import { execCmd } from '../utils/exec.js'
4
+ import { createObservationMeasurement } from './performance-metrics.js'
3
5
  import { promises as fs } from "fs"
4
6
  import { GetLogsResponse, CaptureIOSScreenshotResponse, GetUITreeResponse, UIElement, DeviceInfo, UIElementSemanticMetadata, UIElementState, UIResolutionSelector, SelectorConfidence } from "../types.js"
5
7
  import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "../utils/ios/utils.js"
@@ -525,12 +527,22 @@ export class iOSObserve {
525
527
  }
526
528
 
527
529
  async getUITree(deviceId: string = "booted"): Promise<GetUITreeResponse> {
528
- const device = await getIOSDeviceMetadata(deviceId);
530
+ const measurement = createObservationMeasurement('ios')
531
+ let device
532
+ try {
533
+ device = await measurement.timed('metadata_ms', () => getIOSDeviceMetadata(deviceId))
534
+ } catch (error) {
535
+ measurement.setOutcome('unavailable')
536
+ measurement.finish()
537
+ throw error
538
+ }
529
539
  const deviceKey = `ios:${device.id}`
530
540
 
531
- const idbExists = await isIDBInstalled();
541
+ const idbExists = await measurement.timed('capability_check_ms', () => isIDBInstalled());
532
542
  if (!idbExists) {
533
543
  const snapshotMetadata = deriveSnapshotMetadata(deviceKey, null, 'ui_tree')
544
+ measurement.setOutcome('unavailable')
545
+ measurement.finish()
534
546
  return {
535
547
  device,
536
548
  screen: "",
@@ -551,7 +563,7 @@ export class iOSObserve {
551
563
  remainingBudget()
552
564
  attempts++;
553
565
  try {
554
- await delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100)));
566
+ await measurement.timed('observation_wait_ms', () => delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))));
555
567
  remainingBudget()
556
568
 
557
569
  const args = ['ui', 'describe-all', '--json'];
@@ -559,27 +571,16 @@ export class iOSObserve {
559
571
  args.push('--udid', targetUdid);
560
572
  }
561
573
 
562
- const output = await new Promise<string>((resolve, reject) => {
563
- const child = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
564
- let stdout = '';
565
- let stderr = '';
566
-
567
- child.stdout.on('data', (data) => stdout += data.toString());
568
- child.stderr.on('data', (data) => stderr += data.toString());
569
-
570
- child.on('error', (err) => reject(new Error(`Failed to execute idb: ${err.message}`)));
571
-
572
- child.on('close', (code) => {
573
- if (code !== 0) {
574
- reject(new Error(`idb failed (code ${code}): ${stderr.trim()}`));
575
- } else {
576
- resolve(stdout);
577
- }
578
- });
579
- });
574
+ const output = await measurement.physicalRead(async () => {
575
+ const result = await execCmd(getIdbCmd(), args, { timeout: remainingBudget() })
576
+ if (result.stderr === 'ACTION_TIMEOUT') throw new Error('ACTION_TIMEOUT')
577
+ if (result.exitCode !== 0) throw new Error(`idb failed (code ${result.exitCode}): ${result.stderr}`)
578
+ if (!result.stdout.trim()) throw new Error('Empty idb UI tree output')
579
+ return result.stdout
580
+ })
580
581
 
581
582
  if (output && output.trim().length > 0) {
582
- jsonContent = JSON.parse(output);
583
+ jsonContent = await measurement.timed('parse_ms', async () => JSON.parse(output));
583
584
  break; // Success
584
585
  }
585
586
  } catch (e) {
@@ -589,6 +590,8 @@ export class iOSObserve {
589
590
 
590
591
  if (attempts === maxAttempts) {
591
592
  const snapshotMetadata = deriveSnapshotMetadata(deviceKey, null, 'ui_tree')
593
+ measurement.setOutcome('unavailable')
594
+ measurement.finish()
592
595
  return {
593
596
  device,
594
597
  screen: "",
@@ -601,14 +604,15 @@ export class iOSObserve {
601
604
  }
602
605
 
603
606
  try {
604
- const elements: UIElement[] = [];
605
- if (Array.isArray(jsonContent)) {
606
- for (const node of jsonContent) {
607
- traverseIDBNode(node, elements);
607
+ const elements: UIElement[] = await measurement.timed('conversion_ms', async () => {
608
+ const parsed: UIElement[] = []
609
+ if (Array.isArray(jsonContent)) {
610
+ for (const node of jsonContent) traverseIDBNode(node, parsed)
611
+ } else {
612
+ traverseIDBNode(jsonContent, parsed)
608
613
  }
609
- } else {
610
- traverseIDBNode(jsonContent, elements);
611
- }
614
+ return parsed
615
+ })
612
616
 
613
617
  let width = 0;
614
618
  let height = 0;
@@ -624,15 +628,20 @@ export class iOSObserve {
624
628
  elements
625
629
  }, 'ui_tree')
626
630
 
627
- return {
628
- device,
629
- screen: "",
630
- resolution: { width, height },
631
- elements,
632
- ...snapshotMetadata
633
- };
631
+ measurement.setOutcome('observed')
632
+ const response = measurement.bindObservation({
633
+ device,
634
+ screen: "",
635
+ resolution: { width, height },
636
+ elements,
637
+ ...snapshotMetadata
638
+ })
639
+ measurement.finish()
640
+ return response
634
641
  } catch (e) {
635
642
  const snapshotMetadata = deriveSnapshotMetadata(deviceKey, null, 'ui_tree')
643
+ measurement.setOutcome('unavailable')
644
+ measurement.finish()
636
645
  return {
637
646
  device,
638
647
  screen: "",
@@ -0,0 +1,125 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { AsyncLocalStorage } from 'node:async_hooks'
3
+
4
+ export type ObservationPurpose = 'target' | 'verification' | 'assertion' | 'debug' | 'unspecified'
5
+
6
+ interface ObservationContext {
7
+ stepId?: string
8
+ purpose: ObservationPurpose
9
+ }
10
+
11
+ const context = new AsyncLocalStorage<ObservationContext>()
12
+ const observationIds = new WeakMap<object, string>()
13
+
14
+ export function withObservationContext<T>(value: Partial<ObservationContext>, operation: () => Promise<T>): Promise<T> {
15
+ if (process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS !== '1') return operation()
16
+ return context.run({ purpose: value.purpose ?? context.getStore()?.purpose ?? 'unspecified', stepId: value.stepId ?? context.getStore()?.stepId }, operation)
17
+ }
18
+
19
+ export function recordObservationEvent(event: Record<string, unknown>) {
20
+ if (process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS !== '1') return
21
+ const current = context.getStore()
22
+ process.stderr.write(`${JSON.stringify({
23
+ event: 'ui_tree_metric',
24
+ host_monotonic_ns: process.hrtime.bigint().toString(),
25
+ type: 'action_observation',
26
+ step_id: current?.stepId,
27
+ purpose: current?.purpose ?? 'unspecified',
28
+ ...event
29
+ })}\n`)
30
+ }
31
+
32
+ export function recordObservationConsumer(observation: unknown, purpose: ObservationPurpose, stepId?: string) {
33
+ if (process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS !== '1' || !observation || typeof observation !== 'object') return
34
+ const acquisitionId = observationIds.get(observation)
35
+ if (!acquisitionId) return
36
+ const current = context.getStore()
37
+ process.stderr.write(`${JSON.stringify({
38
+ event: 'ui_tree_metric',
39
+ host_monotonic_ns: process.hrtime.bigint().toString(),
40
+ type: 'observation_consumer',
41
+ acquisition_id: acquisitionId,
42
+ consumer_step_id: stepId ?? current?.stepId,
43
+ consumer_purpose: purpose,
44
+ reused_observations: 1
45
+ })}\n`)
46
+ }
47
+
48
+ export function createObservationMeasurement(platform: 'android' | 'ios') {
49
+ const enabled = process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS === '1'
50
+ const startedAt = enabled ? performance.now() : 0
51
+ const id = enabled ? randomUUID() : undefined
52
+ const current = context.getStore()
53
+ const durations: Record<string, number> = {}
54
+ let successfulOutput = false
55
+ let outcome: 'observed' | 'unavailable' = 'unavailable'
56
+ let readAttempt = 0
57
+
58
+ const emit = (event: Record<string, unknown>) => {
59
+ if (!enabled) return
60
+ process.stderr.write(`${JSON.stringify({
61
+ event: 'ui_tree_metric',
62
+ host_monotonic_ns: process.hrtime.bigint().toString(),
63
+ acquisition_id: id,
64
+ platform,
65
+ step_id: current?.stepId,
66
+ purpose: current?.purpose ?? 'unspecified',
67
+ ...event
68
+ })}\n`)
69
+ }
70
+
71
+ return {
72
+ id,
73
+ bindObservation<T extends object>(observation: T): T {
74
+ if (enabled && id) observationIds.set(observation, id)
75
+ return observation
76
+ },
77
+ timed: async <T>(stage: string, operation: () => Promise<T>): Promise<T> => {
78
+ if (!enabled) return operation()
79
+ const start = performance.now()
80
+ let success = true
81
+ try {
82
+ return await operation()
83
+ } catch (error) {
84
+ success = false
85
+ throw error
86
+ } finally {
87
+ const elapsed = performance.now() - start
88
+ durations[stage] = (durations[stage] ?? 0) + elapsed
89
+ emit({ type: 'stage', stage, attempt_index: stage === 'parse_ms' ? readAttempt : undefined, elapsed_ms: elapsed, success })
90
+ }
91
+ },
92
+ physicalRead: async <T>(operation: () => Promise<T>): Promise<T> => {
93
+ if (!enabled) return operation()
94
+ const start = performance.now()
95
+ readAttempt++
96
+ emit({ type: 'physical_read_started', attempt_index: readAttempt })
97
+ try {
98
+ const result = await operation()
99
+ successfulOutput = true
100
+ emit({ type: 'physical_read_completed', attempt_index: readAttempt, elapsed_ms: performance.now() - start, success: true })
101
+ return result
102
+ } catch (error) {
103
+ emit({ type: 'physical_read_completed', attempt_index: readAttempt, elapsed_ms: performance.now() - start, success: false })
104
+ throw error
105
+ }
106
+ },
107
+ setOutcome(value: 'observed' | 'unavailable') { if (enabled) outcome = value },
108
+ finish() {
109
+ if (!enabled) return
110
+ emit({
111
+ type: 'observation_consumer',
112
+ consumer_step_id: current?.stepId,
113
+ consumer_purpose: current?.purpose ?? 'unspecified',
114
+ reused_observations: 0
115
+ })
116
+ emit({
117
+ type: 'acquisition_completed',
118
+ elapsed_ms: performance.now() - startedAt,
119
+ successful_output: successfulOutput,
120
+ outcome,
121
+ durations_ms: durations
122
+ })
123
+ }
124
+ }
125
+ }
@@ -14,6 +14,7 @@ import { ToolsObserve } from '../observe/index.js'
14
14
  import { classifyActionOutcome } from '../interact/classify.js'
15
15
  import { ToolsNetwork } from '../network/index.js'
16
16
  import { getSystemStatus } from '../system/index.js'
17
+ import { recordObservationConsumer, recordObservationEvent, withObservationContext } from '../observe/performance-metrics.js'
17
18
  import {
18
19
  buildActionExecutionResult,
19
20
  captureActionFingerprint,
@@ -102,12 +103,27 @@ function actionHandler(type: string, execute: ToolHandler): ToolHandler {
102
103
  if (!result.success && typeof result.retryable !== 'boolean') result.retryable = !dispatchStarted
103
104
  result.action_id ??= randomUUID()
104
105
  result.action_type ??= type
106
+ recordObservationEvent({
107
+ step_id: args._stepId as string | undefined,
108
+ action_type: type,
109
+ dispatch_ms: timing.dispatch_ms,
110
+ verification_mode: controls.verificationMode,
111
+ dispatch_started: dispatchStarted
112
+ })
105
113
  let delta: unknown
106
114
  let observationAvailable = false
107
115
  if (result.success && controls.verificationMode === 'light') {
108
- const tree = await measure('post_observation_ms', () => runWithBudget(controls.verificationTimeoutMs, () =>
109
- ToolsObserve.getUITreeHandler({ platform: (args.platform as PlatformArg | undefined) ?? result.device?.platform, deviceId: (args.deviceId as string | undefined) ?? result.device?.id }))).catch(() => null) as any
116
+ const verificationStarted = performance.now()
117
+ const tree = await withObservationContext({ stepId: args._stepId as string | undefined, purpose: 'verification' }, () =>
118
+ measure('post_observation_ms', () => runWithBudget(controls.verificationTimeoutMs, () =>
119
+ ToolsObserve.getUITreeHandler({ platform: (args.platform as PlatformArg | undefined) ?? result.device?.platform, deviceId: (args.deviceId as string | undefined) ?? result.device?.id })))).catch(() => null) as any
110
120
  observationAvailable = !!tree && !tree.error
121
+ recordObservationEvent({
122
+ verification_budget_ms: controls.verificationTimeoutMs,
123
+ verification_elapsed_ms: performance.now() - verificationStarted,
124
+ verification_outcome: observationAvailable ? 'observed' : 'unavailable',
125
+ verification_budget_expired: !observationAvailable && performance.now() - verificationStarted >= controls.verificationTimeoutMs
126
+ })
111
127
  if (observationAvailable && tree.device) delta = ToolsObserve.getLatestStateDelta(tree.device.platform, tree.device.id) ?? undefined
112
128
  }
113
129
  const compact = compactAction(result, controls.verificationMode, dispatchStarted, delta, performance.now() - started)
@@ -321,7 +337,9 @@ async function handleGetUITree(args: ToolCallArgs) {
321
337
  const deviceId = getStringArg(args, 'deviceId')
322
338
  const responseMode = (getStringArg(args, 'responseMode') as ResponseMode | undefined) ?? 'debug'
323
339
  const sinceSnapshotRevision = getNumberArg(args, 'sinceSnapshotRevision')
324
- const res = await ToolsObserve.getUITreeHandler({ platform, deviceId, responseMode, sinceSnapshotRevision })
340
+ const metricsStepId = process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS === '1' ? getStringArg(args, '_metricsStepId') : undefined
341
+ const res = await withObservationContext({ stepId: metricsStepId, purpose: metricsStepId?.includes('post-response') ? 'assertion' : 'debug' }, () =>
342
+ ToolsObserve.getUITreeHandler({ platform, deviceId, responseMode, sinceSnapshotRevision }))
325
343
  return wrapResponse(res)
326
344
  }
327
345
 
@@ -441,7 +459,9 @@ async function handleFindElement(args: ToolCallArgs) {
441
459
  const timeoutMs = getNumberArg(args, 'timeoutMs') ?? 3000
442
460
  const platform = getStringArg(args, 'platform') as PlatformArg | undefined
443
461
  const deviceId = getStringArg(args, 'deviceId')
444
- const res = await ToolsInteract.findElementHandler({ query, exact, timeoutMs, platform, deviceId })
462
+ const targetStarted = performance.now()
463
+ const res = await withObservationContext({ purpose: 'target' }, () => ToolsInteract.findElementHandler({ query, exact, timeoutMs, platform, deviceId }))
464
+ recordObservationEvent({ target_resolution_ms: performance.now() - targetStarted, target_resolution_outcome: res.found ? 'found' : 'not_found', operation: 'find_element' })
445
465
  return wrapResponse(res)
446
466
  }
447
467
 
@@ -477,12 +497,16 @@ async function executeTapElement(args: ToolCallArgs) {
477
497
  let freshTree: any
478
498
  if (selector) {
479
499
  const waitFor = getObjectArg<WaitForUiMatchArg>(args, 'waitFor') as any
480
- const resolution = await measure('target_resolution_ms', () => runWithBudget(waitFor?.timeoutMs ?? 1500, () => ToolsInteract.waitForUIHandler({ selector, condition: waitFor?.condition ?? 'clickable', timeout_ms: waitFor?.timeoutMs ?? 1500, poll_interval_ms: waitFor?.pollIntervalMs ?? 100, match: waitFor?.match, singleObservation: true, singleAttempt: !waitFor, rejectAmbiguous: !waitFor, platform: getStringArg(args, 'platform') as PlatformArg | undefined, deviceId: getStringArg(args, 'deviceId') }))) as any
500
+ const targetStarted = performance.now()
501
+ const resolution = await withObservationContext({ stepId: args._stepId as string | undefined, purpose: 'target' }, () =>
502
+ measure('target_resolution_ms', () => runWithBudget(waitFor?.timeoutMs ?? 1500, () => ToolsInteract.waitForUIHandler({ selector, condition: waitFor?.condition ?? 'clickable', timeout_ms: waitFor?.timeoutMs ?? 1500, poll_interval_ms: waitFor?.pollIntervalMs ?? 100, match: waitFor?.match, singleObservation: true, singleAttempt: !waitFor, rejectAmbiguous: !waitFor, platform: getStringArg(args, 'platform') as PlatformArg | undefined, deviceId: getStringArg(args, 'deviceId') })))) as any
503
+ recordObservationEvent({ step_id: args._stepId as string | undefined, purpose: 'target', target_resolution_ms: performance.now() - targetStarted, target_resolution_outcome: resolution.status })
481
504
  if (resolution.status !== 'success' || !resolution.element?.elementId) return wrapResponse({ success: false, action_type: 'tap_element', failure_code: resolution.error?.code ?? 'ELEMENT_NOT_FOUND', retryable: true })
482
505
  resolvedId = resolution.element.elementId
483
506
  freshTree = resolution._tree
484
507
  }
485
508
  ToolsNetwork.notifyActionStart()
509
+ if (freshTree) recordObservationConsumer(freshTree, 'target', args._stepId as string | undefined)
486
510
  const res = await measure('dispatch_ms', () => ToolsInteract.tapElementHandler({ elementId: resolvedId!, freshTree, verificationMode, timeoutMs: actionTimeoutMs, platform: getStringArg(args, 'platform') as PlatformArg | undefined, deviceId: getStringArg(args, 'deviceId') })) as any
487
511
  return wrapResponse(res)
488
512
  }
@@ -549,12 +573,12 @@ async function handleRunJourney(args: ToolCallArgs) {
549
573
  }
550
574
  try {
551
575
  if (step.type === 'tap') {
552
- result = payloadOf(await handleTapElement({ ...defaults, ...step, platform, deviceId, responseMode, _journey: true, verificationMode: lightCoveredByAssertion ? 'none' : requestedVerification }))
576
+ result = payloadOf(await handleTapElement({ ...defaults, ...step, platform, deviceId, responseMode, _journey: true, _stepId: step.id, verificationMode: lightCoveredByAssertion ? 'none' : requestedVerification }))
553
577
  } else if (step.type === 'wait') {
554
- result = await ToolsInteract.waitForUIHandler({ selector: step.selector as ExpectElementSelectorArg, condition: (step.condition as any) ?? 'exists', timeout_ms: (step.timeoutMs as number | undefined) ?? 5000, poll_interval_ms: (step.pollIntervalMs as number | undefined) ?? 100, platform, deviceId })
578
+ result = await withObservationContext({ stepId: step.id, purpose: 'target' }, () => ToolsInteract.waitForUIHandler({ selector: step.selector as ExpectElementSelectorArg, condition: (step.condition as any) ?? 'exists', timeout_ms: (step.timeoutMs as number | undefined) ?? 5000, poll_interval_ms: (step.pollIntervalMs as number | undefined) ?? 100, platform, deviceId }))
555
579
  result = { success: result.status === 'success', ...result }
556
580
  } else if (step.type === 'assert') {
557
- result = await runWithBudget((step.timeoutMs as number | undefined) ?? 5000, async () => {
581
+ result = await withObservationContext({ stepId: step.id, purpose: 'assertion' }, () => runWithBudget((step.timeoutMs as number | undefined) ?? 5000, async () => {
558
582
  const assertion = step.assertion as any
559
583
  if (assertion?.kind === 'element_visible') result = await ToolsInteract.expectElementVisibleHandler({ selector: assertion.selector, timeout_ms: (step.timeoutMs as number | undefined) ?? 5000, poll_interval_ms: (step.pollIntervalMs as number | undefined) ?? 100, platform, deviceId })
560
584
  else if (assertion?.kind === 'element_absent') result = await expectElementAbsent({ selector: assertion.selector, timeoutMs: (step.timeoutMs as number | undefined) ?? 5000, pollIntervalMs: (step.pollIntervalMs as number | undefined) ?? 100, platform, deviceId })
@@ -562,9 +586,9 @@ async function handleRunJourney(args: ToolCallArgs) {
562
586
  else if (assertion?.kind === 'state_equals') result = await ToolsInteract.expectStateHandler({ selector: assertion.selector, element_id: assertion.elementId, property: assertion.property, expected: assertion.expected, platform, deviceId, stabilization_window_ms: step.timeoutMs ?? 5000, poll_interval_ms: step.pollIntervalMs ?? 100, exact_budget: true })
563
587
  else result = { success: false, failure_code: 'INVALID_ASSERTION' }
564
588
  return result
565
- })
589
+ }))
566
590
  } else if (step.type === 'start_app') {
567
- result = payloadOf(await handleStartApp({ ...defaults, platform, deviceId, appId: step.appId, responseMode, _journey: true, verificationMode: step.verificationMode ?? defaults?.verificationMode ?? 'full' }))
591
+ result = payloadOf(await handleStartApp({ ...defaults, platform, deviceId, appId: step.appId, responseMode, _journey: true, _stepId: step.id, verificationMode: step.verificationMode ?? defaults?.verificationMode ?? 'full' }))
568
592
  } else {
569
593
  result = { success: false, failure_code: 'UNSUPPORTED_JOURNEY_STEP' }
570
594
  }
@@ -16,7 +16,7 @@ export { wrapResponse, toolDefinitions, handleToolCall }
16
16
 
17
17
  export const serverInfo = {
18
18
  name: 'mobile-debug-mcp',
19
- version: '0.32.0'
19
+ version: '0.32.2'
20
20
  }
21
21
 
22
22
  export function createServer() {
package/src/utils/exec.ts CHANGED
@@ -14,22 +14,28 @@ export async function execCmd(cmd: string, args: string[], opts: ExecOptions = {
14
14
  if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString() })
15
15
 
16
16
  let timedOut = false
17
+ let forceKillTimer: NodeJS.Timeout | null = null
17
18
  const timer = timeout && timeout > 0 ? setTimeout(() => {
18
19
  timedOut = true
19
20
  try { child.kill() } catch { }
20
- resolve({ exitCode: null, stdout: stdout.trim(), stderr: 'ACTION_TIMEOUT' })
21
+ forceKillTimer = setTimeout(() => {
22
+ try { child.kill('SIGKILL') } catch { }
23
+ }, 1000)
24
+ forceKillTimer.unref()
21
25
  }, timeout) : null
22
26
 
23
27
  child.on('close', (code) => {
24
28
  if (timer) clearTimeout(timer)
25
- if (timedOut) return
26
- resolve({ exitCode: code, stdout: stdout.trim(), stderr: stderr.trim() })
29
+ if (forceKillTimer) clearTimeout(forceKillTimer)
30
+ resolve(timedOut
31
+ ? { exitCode: null, stdout: stdout.trim(), stderr: 'ACTION_TIMEOUT' }
32
+ : { exitCode: code, stdout: stdout.trim(), stderr: stderr.trim() })
27
33
  })
28
34
 
29
35
  child.on('error', (err) => {
30
36
  if (timer) clearTimeout(timer)
37
+ if (forceKillTimer) clearTimeout(forceKillTimer)
31
38
  reject(err)
32
39
  })
33
40
  })
34
41
  }
35
-