mobile-debug-mcp 0.30.2 → 0.32.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.
Files changed (87) hide show
  1. package/README.md +50 -5
  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 +20 -11
  7. package/dist/observe/android.js +6 -1
  8. package/dist/observe/index.js +22 -5
  9. package/dist/observe/ios.js +7 -2
  10. package/dist/observe/snapshot-metadata.js +94 -15
  11. package/dist/server/common.js +14 -7
  12. package/dist/server/journey-schema.js +60 -0
  13. package/dist/server/tool-definitions.js +59 -6
  14. package/dist/server/tool-handlers.js +358 -55
  15. package/dist/server-core.js +1 -1
  16. package/dist/system/index.js +485 -48
  17. package/dist/system/ios.js +15 -7
  18. package/dist/utils/android/utils.js +4 -4
  19. package/dist/utils/cli/idb/check-idb.js +10 -3
  20. package/dist/utils/cli/idb/idb-helper.js +47 -13
  21. package/dist/utils/exec.js +4 -2
  22. package/dist/utils/ios/utils.js +10 -11
  23. package/dist/utils/operation-budget.js +25 -0
  24. package/dist/utils/resolve-device.js +12 -1
  25. package/dist/utils/timing.js +19 -0
  26. package/dist/utils/ui/index.js +2 -0
  27. package/docs/CHANGELOG.md +6 -0
  28. package/docs/ROADMAP.md +4 -4
  29. package/docs/benchmarks/interaction-latency/README.md +26 -0
  30. package/docs/benchmarks/interaction-latency/final.json +1810 -0
  31. package/docs/benchmarks/interaction-latency/final.md +17 -0
  32. package/docs/benchmarks/interaction-latency/ios-validation.md +11 -0
  33. package/docs/specs/baseline-spec-v0.md +1 -1
  34. package/docs/specs-deltas/016-environment-tools.md +533 -0
  35. package/docs/specs-deltas/018-interaction-latency-and-batched-journeys.md +301 -0
  36. package/docs/tools/TOOLS.md +2 -0
  37. package/docs/tools/interact.md +30 -0
  38. package/docs/tools/manage.md +4 -2
  39. package/docs/tools/system.md +9 -2
  40. package/package.json +1 -1
  41. package/src/interact/android.ts +13 -8
  42. package/src/interact/index.ts +32 -23
  43. package/src/interact/ios.ts +5 -2
  44. package/src/manage/android.ts +19 -10
  45. package/src/manage/ios.ts +19 -11
  46. package/src/observe/android.ts +5 -1
  47. package/src/observe/index.ts +21 -5
  48. package/src/observe/ios.ts +6 -2
  49. package/src/observe/snapshot-metadata.ts +94 -15
  50. package/src/server/common.ts +13 -6
  51. package/src/server/journey-schema.ts +63 -0
  52. package/src/server/tool-definitions.ts +60 -7
  53. package/src/server/tool-handlers.ts +324 -53
  54. package/src/server-core.ts +1 -1
  55. package/src/system/index.ts +572 -50
  56. package/src/system/ios.ts +14 -7
  57. package/src/types.ts +31 -2
  58. package/src/utils/android/utils.ts +3 -3
  59. package/src/utils/cli/idb/check-idb.ts +11 -2
  60. package/src/utils/cli/idb/idb-helper.ts +49 -12
  61. package/src/utils/exec.ts +4 -2
  62. package/src/utils/ios/utils.ts +9 -10
  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/fixtures/latency-journey-app/AndroidManifest.xml +6 -0
  70. package/test/fixtures/latency-journey-app/README.md +10 -0
  71. package/test/fixtures/latency-journey-app/build.ts +26 -0
  72. package/test/fixtures/latency-journey-app/src/MainActivity.java +48 -0
  73. package/test/unit/interact/tap_element.test.ts +1 -1
  74. package/test/unit/manage/restart_budget.test.ts +37 -0
  75. package/test/unit/observe/capture_debug_snapshot.test.ts +6 -0
  76. package/test/unit/observe/snapshot_metadata.test.ts +20 -1
  77. package/test/unit/observe/state_delta.test.ts +15 -0
  78. package/test/unit/server/benchmark_report.test.ts +4 -0
  79. package/test/unit/server/contract.test.ts +1 -1
  80. package/test/unit/server/interaction_modes.test.ts +60 -0
  81. package/test/unit/server/operation_budget.test.ts +32 -0
  82. package/test/unit/server/response_shapes.test.ts +3 -3
  83. package/test/unit/server/run_journey.test.ts +90 -0
  84. package/test/unit/server/tap_selector.test.ts +68 -0
  85. package/test/unit/system/get_system_status.test.ts +3 -0
  86. package/test/unit/system/system_status.test.ts +40 -0
  87. package/test/unit/utils/idb_health.test.ts +74 -0
@@ -83,6 +83,12 @@ async function run() {
83
83
  assert.ok(pass3, 'captureDebugSnapshot should return an empty logs array when includeLogs is false')
84
84
  console.log('Test 3:', pass3 ? 'PASS' : 'FAIL')
85
85
 
86
+ let screenshotCalls = 0
87
+ ;(ToolsObserve as any).captureScreenshotHandler = async function() { screenshotCalls++; return { screenshot: 'unexpected' } }
88
+ const res4: any = await ToolsObserve.captureDebugSnapshotHandler({ platform: 'android', includeLogs: false, includeScreenshot: false })
89
+ assert.strictEqual(screenshotCalls, 0, 'includeScreenshot=false must not invoke screenshot capture')
90
+ assert.strictEqual(res4.raw.screenshot, null)
91
+
86
92
  } finally {
87
93
  ;(ToolsObserve as any).captureScreenshotHandler = origCaptureHandler
88
94
  ;(ToolsObserve as any).getCurrentScreenHandler = origGetCurrentHandler
@@ -1,5 +1,5 @@
1
1
  import assert from 'assert'
2
- import { deriveSnapshotMetadata, resetSnapshotMetadataForTests } from '../../../src/observe/snapshot-metadata.js'
2
+ import { deriveSnapshotMetadata, getStateDelta, resetSnapshotMetadataForTests } from '../../../src/observe/snapshot-metadata.js'
3
3
 
4
4
  async function run() {
5
5
  console.log('Starting snapshot_metadata unit tests...')
@@ -57,6 +57,25 @@ async function run() {
57
57
  total_elements: 1
58
58
  })
59
59
 
60
+ const delta = getStateDelta(deviceKey, 1, 2)
61
+ assert.ok(delta)
62
+ assert.strictEqual(delta!.added.length, 0)
63
+ assert.strictEqual(delta!.removed.length, 0)
64
+ assert.strictEqual(delta!.changed.length, 1)
65
+ assert.strictEqual(delta!.changed[0].text, 'Beta')
66
+ assert.strictEqual(delta!.truncated, false)
67
+ assert.strictEqual(getStateDelta(deviceKey, 99, 2), null)
68
+
69
+ for (let index = 3; index <= 10; index++) {
70
+ deriveSnapshotMetadata(deviceKey, {
71
+ screen: 'Home',
72
+ resolution: { width: 100, height: 200 },
73
+ elements: [{ text: `Revision ${index}`, stable_id: 'stable-row', visible: true }]
74
+ }, 'ui_tree')
75
+ }
76
+ assert.ok(getStateDelta(deviceKey, 3, 10), 'recent revisions remain available after pruning')
77
+ assert.strictEqual(getStateDelta(deviceKey, 2, 10), null, 'only the oldest revision is pruned')
78
+
60
79
  resetSnapshotMetadataForTests()
61
80
  console.log('snapshot_metadata unit tests passed')
62
81
  }
@@ -0,0 +1,15 @@
1
+ import assert from 'node:assert/strict'
2
+ import { deriveSnapshotMetadata, getStateDelta, getLatestStateDelta, resetSnapshotMetadataForTests } from '../../../src/observe/snapshot-metadata.js'
3
+ resetSnapshotMetadataForTests()
4
+ const tree = (text: string) => ({ elements: [{ stable_id: 'one', text }, { stable_id: 'unchanged', text: 'fixed' }] }) as any
5
+ const first = deriveSnapshotMetadata('android:test', tree('before'), 'test')
6
+ const second = deriveSnapshotMetadata('android:test', tree('after'), 'test')
7
+ const delta = getStateDelta('android:test', first.snapshot_revision, second.snapshot_revision)!
8
+ assert.equal(delta.changed.length, 1)
9
+ assert.equal(delta.changed[0].text, 'after')
10
+ deriveSnapshotMetadata('android:test', tree('after'), 'test')
11
+ assert.equal(getLatestStateDelta('android:test')!.changed.length, 0)
12
+ const large = { elements: Array.from({ length: 501 }, (_, i) => ({ stable_id: String(i), text: String(i) })) } as any
13
+ const third = deriveSnapshotMetadata('android:test', large, 'test')
14
+ assert.equal(getStateDelta('android:test', second.snapshot_revision, third.snapshot_revision)!.truncated, true)
15
+ assert.equal(getStateDelta('android:test', -1, third.snapshot_revision), null)
@@ -0,0 +1,4 @@
1
+ import assert from 'node:assert/strict'
2
+ import { summarize } from '../../device/automated/latency/report.js'
3
+ assert.equal(summarize([]), null)
4
+ assert.deepEqual(summarize([10, 1, 8, 2, 9, 3, 7, 4, 6, 5]), { count: 10, p50: 5, p95: 10, min: 1, max: 10 })
@@ -49,7 +49,7 @@ async function run() {
49
49
 
50
50
  const tapElement = toolDefinitions.find((tool) => tool.name === 'tap_element')
51
51
  assert(tapElement, 'tap_element should be registered')
52
- assert.deepStrictEqual((tapElement as any).inputSchema.required, ['elementId'])
52
+ assert.deepStrictEqual((tapElement as any).inputSchema.oneOf, [{ required: ['elementId'], not: { required: ['selector'] } }, { required: ['selector'], not: { required: ['elementId'] } }])
53
53
  assert.match((tapElement as any).description, /RESOLVE → ACT → WAIT \(if needed\) → EXPECT/)
54
54
  assert.match((tapElement as any).description, /If needed, wait for transition using wait_for_\*/)
55
55
  assert.match((tapElement as any).description, /Verify outcome using expect_\*/)
@@ -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,32 @@
1
+ import assert from 'node:assert/strict'
2
+ import { runWithBudget, remainingBudget, markDispatchStarted } from '../../../src/utils/operation-budget.js'
3
+ import { execCmd } from '../../../src/utils/exec.js'
4
+ import { ToolsInteract } from '../../../src/interact/index.js'
5
+ import { ToolsObserve } from '../../../src/observe/index.js'
6
+
7
+ async function run() {
8
+ await runWithBudget(100, async budget => {
9
+ assert.equal(budget.dispatchStarted, false)
10
+ markDispatchStarted()
11
+ assert.equal(budget.dispatchStarted, true)
12
+ const response = await execCmd(process.execPath, ['-e', 'setTimeout(() => {}, 10000)'])
13
+ assert.equal(response.stderr, 'ACTION_TIMEOUT')
14
+ assert.throws(() => remainingBudget(), /ACTION_TIMEOUT/)
15
+ })
16
+ await runWithBudget(1, async budget => {
17
+ await new Promise(resolve => setTimeout(resolve, 5))
18
+ assert.throws(() => markDispatchStarted(), /ACTION_TIMEOUT/)
19
+ assert.equal(budget.dispatchStarted, false)
20
+ })
21
+ const originalTree = ToolsObserve.getUITreeHandler
22
+ ToolsObserve.getUITreeHandler = async () => { throw new Error('transient observation error') }
23
+ try {
24
+ await assert.rejects(
25
+ () => runWithBudget(30, () => ToolsInteract.waitForUIHandler({ selector: { text: 'missing' }, timeout_ms: 1000, poll_interval_ms: 5, platform: 'android', deviceId: 'fixture' })),
26
+ /ACTION_TIMEOUT/
27
+ )
28
+ } finally {
29
+ ToolsObserve.getUITreeHandler = originalTree
30
+ }
31
+ }
32
+ 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,90 @@
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
+ } finally {
82
+ _resetDeviceListersForTests()
83
+ ;(ToolsInteract as any).waitForUIHandler = originalWait
84
+ ;(ToolsInteract as any).expectElementVisibleHandler = originalExpectVisible
85
+ ;(ToolsInteract as any).tapElementHandler = originalTap
86
+ ;(ToolsObserve as any).getUITreeHandler = originalTree
87
+ }
88
+ }
89
+
90
+ 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 })
@@ -9,6 +9,9 @@ async function run() {
9
9
  assert(typeof (payload as any).status === 'string')
10
10
  assert(Array.isArray(payload.issues))
11
11
  assert((payload as any).summary && typeof (payload as any).summary.overall === 'string')
12
+ assert((payload as any).host && typeof (payload as any).host.os === 'string')
13
+ assert((payload as any).android && typeof (payload as any).android.status === 'string')
14
+ assert((payload as any).ios && typeof (payload as any).ios.status === 'string')
12
15
 
13
16
  const adb = ensureAdbAvailable()
14
17
  assert(adb && typeof adb.ok === 'boolean')
@@ -74,6 +74,7 @@ async function run() {
74
74
  XCRUN_VERSION_OUTPUT: process.env.XCRUN_VERSION_OUTPUT,
75
75
  XCRUN_VERSION_STATUS: process.env.XCRUN_VERSION_STATUS,
76
76
  SIMCTL_LIST_OUTPUT: process.env.SIMCTL_LIST_OUTPUT,
77
+ MOBILE_DEBUG_MCP_HOST_OS: process.env.MOBILE_DEBUG_MCP_HOST_OS,
77
78
  }
78
79
 
79
80
  try {
@@ -82,6 +83,7 @@ async function run() {
82
83
  process.env.XCRUN_PATH = xcrunPath
83
84
 
84
85
  setScenario({
86
+ MOBILE_DEBUG_MCP_HOST_OS: 'darwin',
85
87
  ADB_VERSION_STATUS: '0',
86
88
  ADB_VERSION_OUTPUT: '8.1.0\n',
87
89
  ADB_DEVICES_OUTPUT: 'List of devices attached\nemulator-5554\tdevice product:sdk\n',
@@ -94,12 +96,16 @@ async function run() {
94
96
  const healthy = await systemStatus.getSystemStatus()
95
97
  assert.strictEqual(healthy.success, true)
96
98
  assert.strictEqual((healthy as any).status, 'ready')
99
+ assert.strictEqual((healthy as any).host.supported, true)
97
100
  assert.strictEqual(healthy.adbAvailable, true)
98
101
  assert.strictEqual(typeof healthy.adbVersion, 'string')
102
+ assert.strictEqual((healthy as any).android.status, 'ready')
103
+ assert.strictEqual((healthy as any).ios.status, 'ready')
99
104
  assert.strictEqual((healthy as any).summary.android.ready, true)
100
105
  assert.strictEqual(typeof (healthy as any).summary.ios.summary, 'string')
101
106
 
102
107
  setScenario({
108
+ MOBILE_DEBUG_MCP_HOST_OS: 'darwin',
103
109
  ADB_VERSION_STATUS: '1',
104
110
  ADB_VERSION_OUTPUT: 'not found',
105
111
  ADB_DEVICES_OUTPUT: 'List of devices attached\n',
@@ -112,6 +118,7 @@ async function run() {
112
118
  assert(missingAdb.issues.some((issue: string) => issue.includes('ADB')))
113
119
 
114
120
  setScenario({
121
+ MOBILE_DEBUG_MCP_HOST_OS: 'darwin',
115
122
  ADB_VERSION_STATUS: '0',
116
123
  ADB_VERSION_OUTPUT: '8.1.0\n',
117
124
  ADB_DEVICES_OUTPUT: 'List of devices attached\nserial1\tunauthorized\nserial2\toffline\n',
@@ -125,6 +132,7 @@ async function run() {
125
132
  assert(unauthorized.issues.some((issue: string) => issue.includes('offline')))
126
133
 
127
134
  setScenario({
135
+ MOBILE_DEBUG_MCP_HOST_OS: 'darwin',
128
136
  ADB_VERSION_STATUS: '0',
129
137
  ADB_VERSION_OUTPUT: '8.1.0\n',
130
138
  ADB_DEVICES_OUTPUT: 'List of devices attached\nemulator-5554\tdevice product:sdk\n',
@@ -136,6 +144,38 @@ async function run() {
136
144
  assert.strictEqual(missingXcrun.adbAvailable, true)
137
145
  assert.strictEqual(Array.isArray(missingXcrun.issues), true)
138
146
  assert.strictEqual((missingXcrun as any).summary.ios.ready, false)
147
+ assert.strictEqual((missingXcrun as any).ios.status, 'unavailable')
148
+
149
+ setScenario({
150
+ MOBILE_DEBUG_MCP_HOST_OS: 'win32',
151
+ ADB_VERSION_STATUS: '0',
152
+ ADB_VERSION_OUTPUT: '8.1.0\n',
153
+ ADB_DEVICES_OUTPUT: 'List of devices attached\nemulator-5554\tdevice product:sdk\n',
154
+ XCRUN_VERSION_STATUS: '1',
155
+ XCRUN_VERSION_OUTPUT: 'not found',
156
+ })
157
+ const windowsHost = await systemStatus.getSystemStatus()
158
+ assert.strictEqual((windowsHost as any).host.os, 'windows')
159
+ assert.strictEqual((windowsHost as any).host.supported, true)
160
+ assert.strictEqual((windowsHost as any).ios.status, 'unsupported')
161
+ assert.deepStrictEqual((windowsHost as any).ios.tools, {})
162
+ assert.deepStrictEqual((windowsHost as any).ios.providers, {})
163
+ assert.deepStrictEqual((windowsHost as any).ios.capabilities, {})
164
+ assert.deepStrictEqual((windowsHost as any).ios.devices, [])
165
+ assert.strictEqual((windowsHost as any).ios.failures[0].code, 'PLATFORM_UNAVAILABLE')
166
+ assert.strictEqual((windowsHost as any).summary.ios.ready, false)
167
+
168
+ setScenario({ MOBILE_DEBUG_MCP_HOST_OS: 'freebsd' })
169
+ const unsupported = await systemStatus.getSystemStatus()
170
+ assert.strictEqual((unsupported as any).host.os, 'freebsd')
171
+ assert.strictEqual((unsupported as any).host.supported, false)
172
+ assert.strictEqual((unsupported as any).android.status, 'unsupported')
173
+ assert.strictEqual((unsupported as any).ios.status, 'unsupported')
174
+ assert.strictEqual((unsupported as any).android.failures[0].code, 'HOST_OS_UNSUPPORTED')
175
+ assert.strictEqual((unsupported as any).ios.failures[0].code, 'HOST_OS_UNSUPPORTED')
176
+ assert.strictEqual(unsupported.adbAvailable, false)
177
+ assert.strictEqual(unsupported.iosAvailable, false)
178
+ assert.strictEqual((unsupported as any).summary.overall, 'blocked')
139
179
 
140
180
  console.log('system_status checks passed')
141
181
  } finally {
@@ -0,0 +1,74 @@
1
+ import assert from 'assert'
2
+ import fs from 'fs/promises'
3
+ import os from 'os'
4
+ import path from 'path'
5
+
6
+ import { isIDBInstalled, probeIdb } from '../../../src/utils/cli/idb/idb-helper.js'
7
+
8
+ async function run() {
9
+ const binDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-idb-health-'))
10
+ const idbPath = path.join(binDir, 'idb')
11
+ const python3Path = path.join(binDir, 'python3')
12
+ const pythonPath = path.join(binDir, 'python')
13
+ const pythonLog = path.join(binDir, 'python.log')
14
+ const originalIdbPath = process.env.MCP_IDB_PATH
15
+ const originalPath = process.env.PATH
16
+
17
+ try {
18
+ await fs.writeFile(idbPath, `#!/bin/sh
19
+ if [ "$1" = "list-targets" ] && [ "$2" = "--json" ]; then
20
+ printf '{"targets":[]}\n'
21
+ exit 0
22
+ fi
23
+ if [ "$1" = "--version" ]; then
24
+ printf 'idb: error: unrecognized arguments: --version\n' >&2
25
+ exit 2
26
+ fi
27
+ exit 0
28
+ `, { mode: 0o755 })
29
+
30
+ await fs.writeFile(python3Path, `#!/bin/sh
31
+ printf 'python3 called\\n' >> "${pythonLog}"
32
+ exit 0
33
+ `, { mode: 0o755 })
34
+
35
+ await fs.writeFile(pythonPath, `#!/bin/sh
36
+ printf 'python called\\n' >> "${pythonLog}"
37
+ exit 0
38
+ `, { mode: 0o755 })
39
+
40
+ process.env.MCP_IDB_PATH = idbPath
41
+ process.env.PATH = `${binDir}${path.delimiter}${originalPath || ''}`
42
+
43
+ const probe = probeIdb(idbPath)
44
+ assert.strictEqual(probe.ok, true)
45
+ assert.strictEqual(isIDBInstalled(), true)
46
+ assert(probe.raw === null || typeof probe.raw === 'string')
47
+
48
+ await fs.rm(pythonLog, { force: true }).catch(() => {})
49
+ const failedIdbPath = path.join(binDir, 'idb-failing')
50
+ await fs.writeFile(failedIdbPath, `#!/bin/sh
51
+ if [ "$1" = "list-targets" ] && [ "$2" = "--json" ]; then
52
+ printf 'idb unavailable\\n' >&2
53
+ exit 2
54
+ fi
55
+ exit 2
56
+ `, { mode: 0o755 })
57
+
58
+ const failureProbe = probeIdb(failedIdbPath)
59
+ assert.strictEqual(failureProbe.ok, false)
60
+ assert.strictEqual(await fs.readFile(pythonLog, 'utf8').catch(() => ''), '')
61
+
62
+ console.log('idb health probe test passed')
63
+ } finally {
64
+ if (originalIdbPath === undefined) delete process.env.MCP_IDB_PATH
65
+ else process.env.MCP_IDB_PATH = originalIdbPath
66
+
67
+ if (originalPath === undefined) delete process.env.PATH
68
+ else process.env.PATH = originalPath
69
+
70
+ await fs.rm(binDir, { recursive: true, force: true }).catch(() => {})
71
+ }
72
+ }
73
+
74
+ run().catch((error) => { console.error(error); process.exit(1) })