mobile-debug-mcp 0.31.0 → 0.32.1

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 (109) hide show
  1. package/README.md +1 -0
  2. package/dist/interact/android.js +13 -8
  3. package/dist/interact/index.js +33 -22
  4. package/dist/interact/ios.js +5 -2
  5. package/dist/manage/android.js +20 -10
  6. package/dist/manage/ios.js +19 -10
  7. package/dist/observe/android-tree-bridge.js +192 -0
  8. package/dist/observe/android.js +53 -14
  9. package/dist/observe/index.js +22 -5
  10. package/dist/observe/ios.js +50 -29
  11. package/dist/observe/performance-metrics.js +124 -0
  12. package/dist/observe/snapshot-metadata.js +94 -15
  13. package/dist/server/common.js +14 -7
  14. package/dist/server/journey-schema.js +60 -0
  15. package/dist/server/tool-definitions.js +59 -6
  16. package/dist/server/tool-handlers.js +381 -56
  17. package/dist/server-core.js +1 -1
  18. package/dist/utils/exec.js +18 -5
  19. package/dist/utils/ios/utils.js +5 -2
  20. package/dist/utils/operation-budget.js +25 -0
  21. package/dist/utils/resolve-device.js +12 -1
  22. package/dist/utils/timing.js +19 -0
  23. package/dist/utils/ui/index.js +2 -0
  24. package/docs/CHANGELOG.md +6 -0
  25. package/docs/ROADMAP.md +9 -0
  26. package/docs/benchmarks/interaction-latency/README.md +26 -0
  27. package/docs/benchmarks/interaction-latency/final.json +1810 -0
  28. package/docs/benchmarks/interaction-latency/final.md +17 -0
  29. package/docs/benchmarks/interaction-latency/ios-validation.md +11 -0
  30. package/docs/benchmarks/ui-tree-performance/README.md +51 -0
  31. package/docs/benchmarks/ui-tree-performance/baseline.json +59546 -0
  32. package/docs/benchmarks/ui-tree-performance/bridge-probe.json +28 -0
  33. package/docs/benchmarks/ui-tree-performance/candidate.json +51691 -0
  34. package/docs/benchmarks/ui-tree-performance/comparison-attempt-1.json +110679 -0
  35. package/docs/benchmarks/ui-tree-performance/comparison.json +111230 -0
  36. package/docs/benchmarks/ui-tree-performance/comparison.md +41 -0
  37. package/docs/benchmarks/ui-tree-performance/initial-baseline.json +59560 -0
  38. package/docs/specs/018-interaction-latency-and-batched-journeys/spec.md +301 -0
  39. package/docs/specs/019-ui-tree-observation-performance/spec.md +148 -0
  40. package/docs/tools/interact.md +30 -0
  41. package/docs/tools/observe.md +1 -0
  42. package/package.json +1 -1
  43. package/skills/mobile-debug-tools-usage/SKILL.md +74 -0
  44. package/src/interact/android.ts +13 -8
  45. package/src/interact/index.ts +32 -23
  46. package/src/interact/ios.ts +5 -2
  47. package/src/manage/android.ts +19 -10
  48. package/src/manage/ios.ts +18 -10
  49. package/src/observe/android-tree-bridge.ts +170 -0
  50. package/src/observe/android.ts +48 -17
  51. package/src/observe/index.ts +21 -5
  52. package/src/observe/ios.ts +49 -36
  53. package/src/observe/performance-metrics.ts +125 -0
  54. package/src/observe/snapshot-metadata.ts +94 -15
  55. package/src/server/common.ts +13 -6
  56. package/src/server/journey-schema.ts +63 -0
  57. package/src/server/tool-definitions.ts +60 -7
  58. package/src/server/tool-handlers.ts +349 -54
  59. package/src/server-core.ts +1 -1
  60. package/src/types.ts +31 -2
  61. package/src/utils/exec.ts +13 -5
  62. package/src/utils/ios/utils.ts +5 -2
  63. package/src/utils/operation-budget.ts +22 -0
  64. package/src/utils/resolve-device.ts +13 -1
  65. package/src/utils/timing.ts +15 -0
  66. package/src/utils/ui/index.ts +2 -0
  67. package/test/device/automated/latency/benchmark.ts +136 -0
  68. package/test/device/automated/latency/report.ts +6 -0
  69. package/test/device/automated/ui-tree-performance/benchmark.ts +401 -0
  70. package/test/device/automated/ui-tree-performance/bridge-probe.ts +183 -0
  71. package/test/fixtures/latency-journey-app/AndroidManifest.xml +7 -0
  72. package/test/fixtures/latency-journey-app/README.md +28 -0
  73. package/test/fixtures/latency-journey-app/build.ts +26 -0
  74. package/test/fixtures/latency-journey-app/src/MainActivity.java +104 -0
  75. package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +6 -0
  76. package/test/fixtures/ui-tree-bridge/build.ts +34 -0
  77. package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +119 -0
  78. package/test/unit/manage/restart_budget.test.ts +37 -0
  79. package/test/unit/observe/android_tree_acquisition.test.ts +70 -0
  80. package/test/unit/observe/android_tree_bridge_cleanup.test.ts +76 -0
  81. package/test/unit/observe/capture_debug_snapshot.test.ts +6 -0
  82. package/test/unit/observe/ios_tree_acquisition.test.ts +77 -0
  83. package/test/unit/observe/performance_metrics.test.ts +40 -0
  84. package/test/unit/observe/snapshot_metadata.test.ts +20 -1
  85. package/test/unit/observe/state_delta.test.ts +15 -0
  86. package/test/unit/server/benchmark_report.test.ts +4 -0
  87. package/test/unit/server/contract.test.ts +1 -1
  88. package/test/unit/server/interaction_modes.test.ts +60 -0
  89. package/test/unit/server/operation_budget.test.ts +45 -0
  90. package/test/unit/server/response_shapes.test.ts +3 -3
  91. package/test/unit/server/run_journey.test.ts +120 -0
  92. package/test/unit/server/tap_selector.test.ts +68 -0
  93. /package/docs/{specs-deltas/001-state-verification.md → specs/001-state-verification/spec.md} +0 -0
  94. /package/docs/{specs-deltas/002-richer-element-identity → specs/002-richer-element-identity/spec.md} +0 -0
  95. /package/docs/{specs-deltas/003-wait-and-synchronization-reliability.md → specs/003-wait-and-synchronization-reliability/spec.md} +0 -0
  96. /package/docs/{specs-deltas/004-action-verification-routing.md → specs/004-action-verification-routing/spec.md} +0 -0
  97. /package/docs/{specs-deltas/005-unified-action-execution-and-verification-model.md → specs/005-unified-action-execution-and-verification-model/spec.md} +0 -0
  98. /package/docs/{specs-deltas/006-runtime-action-instrumentation-and-binding-layer.md → specs/006-runtime-action-instrumentation-and-binding-layer/spec.md} +0 -0
  99. /package/docs/{specs-deltas/007-actionability-resolution-and-executable-target-selection.md → specs/007-actionability-resolution-and-executable-target-selection/spec.md} +0 -0
  100. /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
  101. /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
  102. /package/docs/{specs-deltas/010-verification-stabilization-and-temporal-convergence.md → specs/010-verification-stabilization-and-temporal-convergence/spec.md} +0 -0
  103. /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
  104. /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
  105. /package/docs/{specs-deltas/012-action-trace-and-xecution-observability.md → specs/012-action-trace-and-xecution-observability/spec.md} +0 -0
  106. /package/docs/{specs-deltas/013-wait-and-synchronization-reliability.md → specs/013-wait-and-synchronization-reliability/spec.md} +0 -0
  107. /package/docs/{specs-deltas/014-actionability-resolution.md → specs/014-actionability-resolution/spec.md} +0 -0
  108. /package/docs/{specs-deltas/015-adjustable-control-support.md → specs/015-adjustable-control-support/spec.md} +0 -0
  109. /package/docs/{specs-deltas/016-environment-tools.md → specs/016-environment-tools/spec.md} +0 -0
@@ -0,0 +1,60 @@
1
+ import assert from 'node:assert/strict'
2
+ import { handleToolCall } from '../../../src/server-core.js'
3
+ import { ToolsInteract } from '../../../src/interact/index.js'
4
+ import { ToolsObserve } from '../../../src/observe/index.js'
5
+ import { AndroidManage } from '../../../src/manage/android.js'
6
+ import { markDispatchStarted } from '../../../src/utils/operation-budget.js'
7
+
8
+ async function run() {
9
+ const treeOriginal = ToolsObserve.getUITreeHandler
10
+ const fingerprintOriginal = ToolsObserve.getScreenFingerprintHandler
11
+ const tapOriginal = ToolsInteract.tapHandler
12
+ const launchOriginal = AndroidManage.prototype.startApp
13
+ const device: any = { platform: 'android', id: 'fixture' }
14
+ let dispatched = false
15
+ let postReads = 0
16
+ let fingerprints = 0
17
+ ToolsObserve.getUITreeHandler = async () => {
18
+ if (dispatched) postReads++
19
+ return { device, elements: [{ text: 'Go', stable_id: 'go', bounds: [0, 0, 40, 40], visible: true, enabled: true, clickable: true }] } as any
20
+ }
21
+ ToolsObserve.getScreenFingerprintHandler = async () => { fingerprints++; return { fingerprint: 'fixture' } as any }
22
+ ToolsInteract.tapHandler = async () => { dispatched = true; return { success: true, device } as any }
23
+ AndroidManage.prototype.startApp = async () => { dispatched = true; return { appStarted: true, device } as any }
24
+ try {
25
+ for (const tool of ['tap_element', 'start_app']) {
26
+ for (const mode of ['none', 'light', 'full']) {
27
+ dispatched = false; postReads = 0; fingerprints = 0
28
+ const response = await handleToolCall(tool, { platform: 'android', deviceId: 'fixture', ...(tool === 'tap_element' ? { selector: { text: 'Go' } } : { appId: 'fixture' }), verificationMode: mode, responseMode: 'compact' })
29
+ const result = JSON.parse(response.content[0].text!)
30
+ assert.equal(result.success, true, JSON.stringify(result))
31
+ assert.equal(postReads, mode === 'light' ? 1 : 0)
32
+ assert.equal(fingerprints, mode === 'full' ? 2 : 0)
33
+ assert.equal(result.device, undefined)
34
+ assert.equal(result.trace, undefined)
35
+ assert.equal(result.dispatch_started, true)
36
+ assert.deepEqual(Object.keys(result.timing), ['total_ms'])
37
+ if (mode !== 'full') assert.equal(result.lifecycle_state, 'pending_verification')
38
+ }
39
+ }
40
+ ToolsObserve.getUITreeHandler = async () => { throw new Error('offline') }
41
+ const result = JSON.parse((await handleToolCall('start_app', { platform: 'android', appId: 'fixture', verificationMode: 'light', responseMode: 'compact' })).content[0].text!)
42
+ assert.equal(result.success, true)
43
+ assert.equal(result.verification.status, 'unavailable')
44
+ assert.equal(result.verification_diagnostic.retryable, true)
45
+ ToolsInteract.tapHandler = async () => { markDispatchStarted(); dispatched = true; return { success: false, error: 'ACTION_TIMEOUT', device } as any }
46
+ const timedOut = JSON.parse((await handleToolCall('tap', { platform: 'android', deviceId: 'fixture', x: 1, y: 1, verificationMode: 'none', responseMode: 'compact' })).content[0].text!)
47
+ assert.equal(timedOut.failure_code, 'ACTION_TIMEOUT')
48
+ assert.equal(timedOut.delivery_status, 'unknown')
49
+ assert.equal(timedOut.retryable, false)
50
+ AndroidManage.prototype.startApp = async () => ({ appStarted: false, device, error: 'launch failed', diagnostics: { runResult: { exitCode: 1, stdout: '', stderr: 'launch failed' } } } as any)
51
+ const failedLaunch = JSON.parse((await handleToolCall('start_app', { platform: 'android', appId: 'fixture' })).content[0].text!)
52
+ assert.equal(failedLaunch.details.diagnostics.runResult.exitCode, 1)
53
+ } finally {
54
+ ToolsObserve.getUITreeHandler = treeOriginal
55
+ ToolsObserve.getScreenFingerprintHandler = fingerprintOriginal
56
+ ToolsInteract.tapHandler = tapOriginal
57
+ AndroidManage.prototype.startApp = launchOriginal
58
+ }
59
+ }
60
+ run().catch(error => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,45 @@
1
+ import assert from 'node:assert/strict'
2
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import path from 'node:path'
5
+ import { runWithBudget, remainingBudget, markDispatchStarted } from '../../../src/utils/operation-budget.js'
6
+ import { execCmd } from '../../../src/utils/exec.js'
7
+ import { ToolsInteract } from '../../../src/interact/index.js'
8
+ import { ToolsObserve } from '../../../src/observe/index.js'
9
+
10
+ async function run() {
11
+ const markerDir = mkdtempSync(path.join(tmpdir(), 'mcp-exec-close-'))
12
+ try {
13
+ const marker = path.join(markerDir, 'closed')
14
+ const command = `process.on('SIGTERM', () => setTimeout(() => { require('fs').writeFileSync(${JSON.stringify(marker)}, 'closed'); process.exit(0) }, 50)); setInterval(() => {}, 1000)`
15
+ const response = await execCmd(process.execPath, ['-e', command], { timeout: 250 })
16
+ assert.equal(response.stderr, 'ACTION_TIMEOUT')
17
+ assert.equal(existsSync(marker), true, 'timeout must await child close before returning')
18
+ } finally {
19
+ rmSync(markerDir, { recursive: true, force: true })
20
+ }
21
+ await runWithBudget(100, async budget => {
22
+ assert.equal(budget.dispatchStarted, false)
23
+ markDispatchStarted()
24
+ assert.equal(budget.dispatchStarted, true)
25
+ const response = await execCmd(process.execPath, ['-e', 'setTimeout(() => {}, 10000)'])
26
+ assert.equal(response.stderr, 'ACTION_TIMEOUT')
27
+ assert.throws(() => remainingBudget(), /ACTION_TIMEOUT/)
28
+ })
29
+ await runWithBudget(1, async budget => {
30
+ await new Promise(resolve => setTimeout(resolve, 5))
31
+ assert.throws(() => markDispatchStarted(), /ACTION_TIMEOUT/)
32
+ assert.equal(budget.dispatchStarted, false)
33
+ })
34
+ const originalTree = ToolsObserve.getUITreeHandler
35
+ ToolsObserve.getUITreeHandler = async () => { throw new Error('transient observation error') }
36
+ try {
37
+ await assert.rejects(
38
+ () => runWithBudget(30, () => ToolsInteract.waitForUIHandler({ selector: { text: 'missing' }, timeout_ms: 1000, poll_interval_ms: 5, platform: 'android', deviceId: 'fixture' })),
39
+ /ACTION_TIMEOUT/
40
+ )
41
+ } finally {
42
+ ToolsObserve.getUITreeHandler = originalTree
43
+ }
44
+ }
45
+ run().catch(error => { console.error(error); process.exitCode = 1 })
@@ -37,7 +37,7 @@ async function run() {
37
37
 
38
38
  const missingBuildResponse = await handleToolCall('build_app', { projectPath: '/tmp/project' })
39
39
  const missingBuildPayload = JSON.parse((missingBuildResponse as any).content[0].text)
40
- assert.deepStrictEqual(missingBuildPayload, {
40
+ assert.deepStrictEqual({ error: missingBuildPayload.error }, {
41
41
  error: {
42
42
  tool: 'build_app',
43
43
  message: 'Missing or invalid string argument: platform'
@@ -236,7 +236,7 @@ async function run() {
236
236
  const failingTapResponse = await handleToolCall('tap', { platform: 'android', x: 1, y: 2 })
237
237
  assert.strictEqual((failingTapResponse as any).content.length, 1)
238
238
  const failingTapPayload = JSON.parse((failingTapResponse as any).content[0].text)
239
- assert.deepStrictEqual(failingTapPayload, {
239
+ assert.deepStrictEqual({ error: failingTapPayload.error }, {
240
240
  error: {
241
241
  tool: 'tap',
242
242
  message: 'boom'
@@ -255,7 +255,7 @@ async function run() {
255
255
 
256
256
  const missingArgResponse = await handleToolCall('tap', { platform: 'android', x: 1 })
257
257
  const missingArgPayload = JSON.parse((missingArgResponse as any).content[0].text)
258
- assert.deepStrictEqual(missingArgPayload, {
258
+ assert.deepStrictEqual({ error: missingArgPayload.error }, {
259
259
  error: {
260
260
  tool: 'tap',
261
261
  message: 'Missing or invalid number argument: y'
@@ -0,0 +1,120 @@
1
+ import { _setDeviceListersForTests, _resetDeviceListersForTests } from '../../../src/utils/resolve-device.js'
2
+ import assert from 'assert'
3
+ import { handleToolCall } from '../../../src/server-core.js'
4
+ import { ToolsInteract } from '../../../src/interact/index.js'
5
+ import { ToolsObserve } from '../../../src/observe/index.js'
6
+
7
+ async function run() {
8
+ const originalWait = (ToolsInteract as any).waitForUIHandler
9
+ const originalExpectVisible = (ToolsInteract as any).expectElementVisibleHandler
10
+ const originalTap = (ToolsInteract as any).tapElementHandler
11
+ const originalTree = (ToolsObserve as any).getUITreeHandler
12
+ let resolutions = 0
13
+ _setDeviceListersForTests({ listAndroidDevices: async () => { resolutions++; return [{ id: 'fixture', platform: 'android' } as any] } })
14
+ try {
15
+ const calls: string[] = []
16
+ ;(ToolsInteract as any).waitForUIHandler = async () => {
17
+ calls.push('wait')
18
+ return { status: 'success', element: { elementId: 'ready' } }
19
+ }
20
+ ;(ToolsInteract as any).expectElementVisibleHandler = async () => {
21
+ calls.push('assert')
22
+ return { success: false, failure_code: 'TIMEOUT' }
23
+ }
24
+
25
+ const response: any = await handleToolCall('run_journey', {
26
+ platform: 'android',
27
+ steps: [
28
+ { id: 'wait-ready', type: 'wait', selector: { text: 'Ready' } },
29
+ { id: 'assert-ready', type: 'assert', assertion: { kind: 'element_visible', selector: { text: 'Ready' } } },
30
+ { id: 'not-run', type: 'wait', selector: { text: 'Later' } }
31
+ ]
32
+ })
33
+ const result = JSON.parse(response.content[0].text)
34
+ assert.strictEqual(resolutions, 1)
35
+ assert.deepStrictEqual(calls, ['wait', 'assert'])
36
+ await handleToolCall('run_journey', { platform: 'android', steps: [{ id: 'valid', type: 'wait', selector: { text: 'Ready' } }, { id: 'bad', type: 'tap' }] })
37
+ assert.strictEqual(resolutions, 1, 'Invalid later steps must fail before device work')
38
+ assert.deepStrictEqual(calls, ['wait', 'assert'])
39
+ assert.strictEqual(result.success, false)
40
+ assert.strictEqual(result.stopped_at_step_id, 'assert-ready')
41
+ assert.strictEqual(result.steps[0].status, 'passed')
42
+ assert.strictEqual(result.steps[1].status, 'failed')
43
+ assert.deepStrictEqual(result.steps[2], {
44
+ id: 'not-run', type: 'wait', status: 'not_run', success: false,
45
+ reason: 'PREVIOUS_STEP_FAILED', result: null, timing: { total_ms: 0 }
46
+ })
47
+
48
+ let postActionReads = 0
49
+ ;(ToolsObserve as any).getUITreeHandler = async () => { postActionReads++; throw new Error('Unexpected duplicate observation') }
50
+ ;(ToolsInteract as any).waitForUIHandler = async () => ({ status: 'success', element: { elementId: 'target' }, _tree: {} })
51
+ ;(ToolsInteract as any).tapElementHandler = async (args: any) => {
52
+ assert.strictEqual(args.verificationMode, 'none')
53
+ return { success: true, action_id: 'tap', action_type: 'tap_element' }
54
+ }
55
+ ;(ToolsInteract as any).expectElementVisibleHandler = async () => ({ success: true, observed: { matched_count: 1 } })
56
+ const lightResponse: any = await handleToolCall('run_journey', {
57
+ platform: 'android', responseMode: 'compact', defaults: { verificationMode: 'light' },
58
+ steps: [
59
+ { id: 'tap-target', type: 'tap', selector: { accessibility_id: 'target' } },
60
+ { id: 'assert-result', type: 'assert', assertion: { kind: 'element_visible', selector: { accessibility_id: 'result' } } }
61
+ ]
62
+ })
63
+ const light = JSON.parse(lightResponse.content[0].text)
64
+ assert.strictEqual(light.success, true)
65
+ assert.deepStrictEqual(Object.keys(light.timing), ['total_ms'])
66
+ assert.deepStrictEqual(light.steps[0].result.verification, { mode: 'light', status: 'observed' })
67
+ assert.strictEqual(light.steps[0].result.verification_diagnostic, undefined)
68
+ assert.strictEqual(postActionReads, 0, 'The following assertion supplies the fresh observation')
69
+ ;(ToolsInteract as any).expectElementVisibleHandler = async () => ({ success: false, failure_code: 'TIMEOUT', observed: { matched_count: 0 } })
70
+ const failedAssertionResponse: any = await handleToolCall('run_journey', {
71
+ platform: 'android', responseMode: 'compact', defaults: { verificationMode: 'light' },
72
+ steps: [
73
+ { id: 'tap-target', type: 'tap', selector: { accessibility_id: 'target' } },
74
+ { id: 'assert-result', type: 'assert', assertion: { kind: 'element_visible', selector: { accessibility_id: 'result' } } }
75
+ ]
76
+ })
77
+ const failedAssertion = JSON.parse(failedAssertionResponse.content[0].text)
78
+ assert.strictEqual(failedAssertion.success, false)
79
+ assert.deepStrictEqual(failedAssertion.steps[0].result.verification, { mode: 'light', status: 'observed' })
80
+ assert.strictEqual(postActionReads, 0)
81
+
82
+ ;(ToolsInteract as any).waitForUIHandler = originalWait
83
+ ;(ToolsInteract as any).expectElementVisibleHandler = originalExpectVisible
84
+ let dispatchedAt = 0
85
+ const readTimes: number[] = []
86
+ ;(ToolsObserve as any).getUITreeHandler = async () => {
87
+ readTimes.push(performance.now())
88
+ const target = readTimes.length === 1
89
+ return {
90
+ device: { platform: 'android', id: 'fixture' },
91
+ captured_at_ms: Date.now(),
92
+ elements: [{ contentDescription: target ? 'target' : 'result', bounds: [0, 0, 20, 20], visible: true, enabled: true, clickable: true }]
93
+ }
94
+ }
95
+ ;(ToolsInteract as any).tapElementHandler = async () => {
96
+ dispatchedAt = performance.now()
97
+ return { success: true, action_id: 'tap', action_type: 'tap_element' }
98
+ }
99
+ const freshResponse: any = await handleToolCall('run_journey', {
100
+ platform: 'android', responseMode: 'compact', defaults: { verificationMode: 'light' },
101
+ steps: [
102
+ { id: 'tap-target', type: 'tap', selector: { accessibility_id: 'target' } },
103
+ { id: 'assert-result', type: 'assert', assertion: { kind: 'element_visible', selector: { accessibility_id: 'result' } } }
104
+ ]
105
+ })
106
+ const fresh = JSON.parse(freshResponse.content[0].text)
107
+ assert.strictEqual(fresh.success, true)
108
+ assert.strictEqual(readTimes.length, 3, 'target selection and two stability samples require separate reads')
109
+ assert.ok(readTimes[0] < dispatchedAt)
110
+ assert.ok(readTimes[1] >= dispatchedAt && readTimes[2] >= dispatchedAt, 'assertion reads must follow dispatch')
111
+ } finally {
112
+ _resetDeviceListersForTests()
113
+ ;(ToolsInteract as any).waitForUIHandler = originalWait
114
+ ;(ToolsInteract as any).expectElementVisibleHandler = originalExpectVisible
115
+ ;(ToolsInteract as any).tapElementHandler = originalTap
116
+ ;(ToolsObserve as any).getUITreeHandler = originalTree
117
+ }
118
+ }
119
+
120
+ run().catch((error) => { console.error(error); process.exit(1) })
@@ -0,0 +1,68 @@
1
+ import assert from 'node:assert/strict'
2
+ import { handleToolCall } from '../../../src/server-core.js'
3
+ import { ToolsInteract } from '../../../src/interact/index.js'
4
+
5
+ async function run() {
6
+ const originalWait = ToolsInteract.waitForUIHandler
7
+ const originalTap = ToolsInteract.tapElementHandler
8
+ let tapCount = 0
9
+ let lastWait: any
10
+ let resolution: any
11
+ ToolsInteract.waitForUIHandler = async (args: any) => { lastWait = args; return resolution }
12
+ ToolsInteract.tapElementHandler = async (args: any) => {
13
+ tapCount++
14
+ assert.equal(args.elementId, 'selected')
15
+ assert.deepEqual(args.freshTree, { elements: [] })
16
+ return { action_id: 'tap', action_type: 'tap_element', success: true, lifecycle_state: 'pending_verification' } as any
17
+ }
18
+ try {
19
+ const selector = { accessibility_id: 'save' }
20
+ resolution = { status: 'success', element: { elementId: 'selected' }, _tree: { elements: [] } }
21
+ const success = JSON.parse((await handleToolCall('tap_element', {
22
+ selector, platform: 'android', verificationMode: 'none', responseMode: 'compact'
23
+ })).content[0].text!)
24
+ assert.equal(success.success, true)
25
+ assert.equal(tapCount, 1)
26
+ assert.equal(lastWait.condition, 'clickable')
27
+ assert.equal(lastWait.singleObservation, true)
28
+ assert.equal(lastWait.singleAttempt, true)
29
+ assert.equal(lastWait.rejectAmbiguous, true)
30
+
31
+ await handleToolCall('tap_element', {
32
+ selector, waitFor: { condition: 'visible', timeoutMs: 700, pollIntervalMs: 80 },
33
+ platform: 'android', verificationMode: 'none', responseMode: 'compact'
34
+ })
35
+ assert.equal(lastWait.condition, 'visible')
36
+ assert.equal(lastWait.timeout_ms, 700)
37
+ assert.equal(lastWait.poll_interval_ms, 80)
38
+ assert.equal(lastWait.singleAttempt, false)
39
+ assert.equal(lastWait.rejectAmbiguous, false)
40
+ assert.equal(tapCount, 2)
41
+
42
+ resolution = { status: 'timeout', error: { code: 'AMBIGUOUS_TARGET' } }
43
+ const ambiguous = JSON.parse((await handleToolCall('tap_element', {
44
+ selector, platform: 'android', verificationMode: 'none', responseMode: 'compact'
45
+ })).content[0].text!)
46
+ assert.equal(ambiguous.failure_code, 'AMBIGUOUS_TARGET')
47
+ assert.equal(tapCount, 2)
48
+
49
+ resolution = { status: 'timeout', error: { code: 'ELEMENT_NOT_FOUND' } }
50
+ const missing = JSON.parse((await handleToolCall('tap_element', {
51
+ selector, platform: 'android', verificationMode: 'none', responseMode: 'compact'
52
+ })).content[0].text!)
53
+ assert.equal(missing.failure_code, 'ELEMENT_NOT_FOUND')
54
+ assert.equal(missing.dispatch_started, false)
55
+ assert.equal(missing.retryable, true)
56
+ assert.equal(tapCount, 2)
57
+
58
+ const invalid = await handleToolCall('tap_element', {
59
+ elementId: 'old', selector, waitFor: {}, platform: 'android'
60
+ })
61
+ assert.match(JSON.parse(invalid.content[0].text!).error.message, /Invalid input/)
62
+ assert.equal(tapCount, 2)
63
+ } finally {
64
+ ToolsInteract.waitForUIHandler = originalWait
65
+ ToolsInteract.tapElementHandler = originalTap
66
+ }
67
+ }
68
+ run().catch(error => { console.error(error); process.exitCode = 1 })