mobile-debug-mcp 0.32.2 → 0.33.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 (127) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/{skills → .agents/skills}/mcp-builder/SKILL.md +1 -2
  3. package/{skills → .agents/skills}/spec-review/SKILL.md +1 -1
  4. package/{skills → .agents/skills}/test-authoring/SKILL.md +1 -1
  5. package/.claude-plugin/marketplace.json +14 -0
  6. package/.cursor-plugin/marketplace.json +16 -0
  7. package/.github/copilot-instructions.md +7 -0
  8. package/AGENTS.md +21 -10
  9. package/README.md +67 -5
  10. package/agent-plugin/.claude-plugin/plugin.json +20 -0
  11. package/agent-plugin/.codex-plugin/plugin.json +34 -0
  12. package/agent-plugin/.cursor-plugin/plugin.json +23 -0
  13. package/agent-plugin/.mcp.json +14 -0
  14. package/agent-plugin/LICENSE +201 -0
  15. package/agent-plugin/README.md +29 -0
  16. package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
  17. package/agent-plugin/mcp.json +11 -0
  18. package/agent-plugin/plugin.json +29 -0
  19. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +124 -0
  20. package/dist/interact/android.js +10 -4
  21. package/dist/interact/conditional-action.js +68 -0
  22. package/dist/interact/index.js +54 -14
  23. package/dist/interact/ios.js +14 -8
  24. package/dist/manage/android.js +4 -3
  25. package/dist/manage/ios.js +3 -2
  26. package/dist/observe/android.js +27 -22
  27. package/dist/observe/evidence.js +273 -0
  28. package/dist/observe/index.js +33 -6
  29. package/dist/observe/ios.js +25 -6
  30. package/dist/observe/readiness-service.js +115 -0
  31. package/dist/observe/readiness.js +81 -0
  32. package/dist/observe/recording.js +165 -0
  33. package/dist/observe/snapshot-metadata.js +10 -2
  34. package/dist/server/common.js +9 -0
  35. package/dist/server/condition-schema.js +32 -0
  36. package/dist/server/journey-schema.js +3 -2
  37. package/dist/server/readiness-schema.js +12 -0
  38. package/dist/server/tool-definitions.js +46 -0
  39. package/dist/server/tool-handlers.js +99 -17
  40. package/dist/server-core.js +1 -1
  41. package/dist/utils/ios/utils.js +4 -4
  42. package/dist/utils/operation-budget.js +24 -2
  43. package/dist/utils/resolve-device.js +7 -0
  44. package/docs/CHANGELOG.md +16 -2
  45. package/docs/benchmarks/ui-tree-performance/README.md +4 -3
  46. package/docs/specs/020-conditional-actions/intent.md +62 -0
  47. package/docs/specs/020-conditional-actions/plan.md +214 -0
  48. package/docs/specs/020-conditional-actions/spec.md +301 -0
  49. package/docs/specs/021-app-readiness-signals/intent.md +40 -0
  50. package/docs/specs/021-app-readiness-signals/plan.md +102 -0
  51. package/docs/specs/021-app-readiness-signals/spec.md +144 -0
  52. package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
  53. package/docs/specs/022-correlated-evidence/intent.md +37 -0
  54. package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
  55. package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
  56. package/docs/specs/022-correlated-evidence/plan.md +171 -0
  57. package/docs/specs/022-correlated-evidence/spec.md +123 -0
  58. package/docs/specs/process.md +213 -0
  59. package/docs/tools/TOOLS.md +3 -0
  60. package/docs/tools/evidence.md +79 -0
  61. package/docs/tools/interact.md +125 -2
  62. package/docs/tools/observe.md +64 -2
  63. package/package.json +4 -2
  64. package/scripts/agent-plugin.mjs +98 -0
  65. package/src/interact/android.ts +10 -4
  66. package/src/interact/conditional-action.ts +75 -0
  67. package/src/interact/index.ts +42 -14
  68. package/src/interact/ios.ts +14 -8
  69. package/src/manage/android.ts +4 -3
  70. package/src/manage/ios.ts +3 -2
  71. package/src/observe/android.ts +35 -21
  72. package/src/observe/evidence.ts +255 -0
  73. package/src/observe/index.ts +32 -8
  74. package/src/observe/ios.ts +32 -8
  75. package/src/observe/readiness-service.ts +89 -0
  76. package/src/observe/readiness.ts +78 -0
  77. package/src/observe/snapshot-metadata.ts +12 -1
  78. package/src/server/common.ts +7 -0
  79. package/src/server/condition-schema.ts +32 -0
  80. package/src/server/journey-schema.ts +3 -2
  81. package/src/server/readiness-schema.ts +12 -0
  82. package/src/server/tool-definitions.ts +44 -0
  83. package/src/server/tool-handlers.ts +93 -17
  84. package/src/server-core.ts +1 -1
  85. package/src/types.ts +30 -1
  86. package/src/utils/ios/utils.ts +4 -4
  87. package/src/utils/operation-budget.ts +22 -2
  88. package/src/utils/resolve-device.ts +8 -0
  89. package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
  90. package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
  91. package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
  92. package/test/device/manual/interact/readiness.manual.ts +99 -0
  93. package/test/fixtures/readiness-app/MainActivity.java +23 -0
  94. package/test/fixtures/readiness-app/README.md +12 -0
  95. package/test/fixtures/readiness-app/build.ts +37 -0
  96. package/test/fixtures/readiness-app/main.m +27 -0
  97. package/test/unit/observe/android_tree_acquisition.test.ts +6 -0
  98. package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
  99. package/test/unit/observe/compact_tree_error.test.ts +24 -0
  100. package/test/unit/observe/conditional_snapshots.test.ts +33 -0
  101. package/test/unit/observe/correlated-evidence.test.ts +128 -0
  102. package/test/unit/observe/ios-getlogs.test.ts +3 -3
  103. package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
  104. package/test/unit/observe/readiness.test.ts +39 -0
  105. package/test/unit/observe/readiness_native.test.ts +68 -0
  106. package/test/unit/server/conditional_actions.test.ts +299 -0
  107. package/test/unit/server/conditional_schema.test.ts +59 -0
  108. package/test/unit/server/correlated-evidence.test.ts +194 -0
  109. package/test/unit/server/readiness.test.ts +170 -0
  110. package/test/unit/server/selector_tap_target.test.ts +79 -0
  111. package/test/unit/server/tap_selector.test.ts +2 -0
  112. package/skills/mobile-debug-tools-usage/SKILL.md +0 -74
  113. package/src/observe/android-tree-bridge.ts +0 -178
  114. package/test/device/automated/ui-tree-performance/bridge-probe.ts +0 -197
  115. package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +0 -6
  116. package/test/fixtures/ui-tree-bridge/build.ts +0 -34
  117. package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +0 -126
  118. package/test/unit/observe/android_tree_bridge_cleanup.test.ts +0 -95
  119. /package/{skills → .agents/skills}/README.md +0 -0
  120. /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
  121. /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
  122. /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
  123. /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
  124. /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
  125. /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
  126. /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
  127. /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
@@ -0,0 +1,59 @@
1
+ import assert from 'node:assert/strict'
2
+ import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'
3
+ import { toolDefinitions } from '../../../src/server/tool-definitions.js'
4
+ import { conditionalTools, validateExpectedState } from '../../../src/server/condition-schema.js'
5
+ import { journeySchema } from '../../../src/server/journey-schema.js'
6
+
7
+ const provider = new AjvJsonSchemaValidator()
8
+ const expectedState = { snapshotSessionId: 'session-fixture', snapshotRevision: 1 }
9
+ for (const name of conditionalTools) {
10
+ const schema = toolDefinitions.find(tool => tool.name === name)!.inputSchema
11
+ const validate = provider.getValidator(schema)
12
+ const args = { platform: 'android', deviceId: 'emulator-5554', expectedState,
13
+ ...({ tap: { x: 1, y: 1 }, tap_element: { selector: { text: 'Go' } }, swipe: { x1: 1, y1: 1, x2: 2, y2: 2, duration: 100 }, type_text: { text: 'hello' }, press_back: {} }[name]) }
14
+ assert.equal(validate(args).valid, true, `${name} schema accepts valid guarded input`)
15
+ assert.deepEqual(validateExpectedState(name, args), expectedState)
16
+ for (const extra of [
17
+ { expectedState: null }, { expectedState: {} }, { expectedState: { ...expectedState, extra: true } },
18
+ { expectedState: { ...expectedState, snapshotRevision: 0 } }, { expectedState: { ...expectedState, snapshotRevision: 1.5 } },
19
+ { expectedState: { ...expectedState, snapshotRevision: Number.MAX_SAFE_INTEGER + 1 } },
20
+ { expectedState: { ...expectedState, snapshotSessionId: '' } }, { expectedState: { ...expectedState, timeoutMs: 99 } },
21
+ { expectedState: { ...expectedState, timeoutMs: 60001 } }, { expectedState: { ...expectedState, timeoutMs: '15000' } },
22
+ { platform: null }, { deviceId: 'booted' }, { deviceId: 'default' }, { deviceId: '' }, { deviceId: ' ' }
23
+ ]) {
24
+ const input = { ...args, ...extra }
25
+ assert.equal(validate(input).valid, false, JSON.stringify(input))
26
+ assert.throws(() => validateExpectedState(name, input), /INVALID_ARGUMENT/)
27
+ }
28
+ const withoutDevice: any = { ...args }; delete withoutDevice.deviceId
29
+ assert.equal(validate(withoutDevice).valid, false)
30
+ assert.throws(() => validateExpectedState(name, withoutDevice), /INVALID_ARGUMENT/)
31
+ const ios = { ...args, platform: 'ios', deviceId: 'concrete-udid' }
32
+ const supported = !['type_text', 'press_back'].includes(name)
33
+ assert.equal(validate(ios).valid, supported)
34
+ if (supported) assert.deepEqual(validateExpectedState(name, ios), expectedState)
35
+ else assert.throws(() => validateExpectedState(name, ios), /INVALID_ARGUMENT/)
36
+ }
37
+ for (const name of ['start_app', 'restart_app', 'terminate_app', 'reset_app_data', 'scroll_to_element', 'adjust_control', 'get_ui_tree']) {
38
+ const schema = toolDefinitions.find(tool => tool.name === name)!.inputSchema
39
+ const args = { platform: 'android', deviceId: 'emulator-5554', appId: 'example.app', selector: { text: 'Go' }, targetValue: 1, expectedState }
40
+ assert.equal(provider.getValidator(schema)(args).valid, false)
41
+ assert.throws(() => validateExpectedState(name, args), /INVALID_ARGUMENT/)
42
+ }
43
+ const validateJourney = provider.getValidator(toolDefinitions.find(tool => tool.name === 'run_journey')!.inputSchema)
44
+ const step = { id: 'tap', type: 'tap', selector: { text: 'Go' }, expectedState }
45
+ const journey = { platform: 'android', steps: [step] }
46
+ assert.equal(validateJourney(journey).valid, true)
47
+ assert.equal(journeySchema.safeParse(journey).success, true)
48
+ for (const input of [
49
+ { ...journey, expectedState },
50
+ { ...journey, defaults: { expectedState } },
51
+ { ...journey, steps: [{ id: 'wait', type: 'wait', selector: { text: 'Go' }, expectedState }] },
52
+ { ...journey, steps: [{ id: 'launch', type: 'start_app', appId: 'example.app', expectedState }] },
53
+ { ...journey, steps: [{ id: 'assert', type: 'assert', assertion: { kind: 'element_visible', selector: { text: 'Go' } }, expectedState }] },
54
+ { ...journey, steps: [{ ...step, expectedState: { ...expectedState, timeoutMs: 0 } }] }
55
+ ]) {
56
+ assert.equal(validateJourney(input).valid, false)
57
+ assert.equal(journeySchema.safeParse(input).success, false)
58
+ }
59
+ console.log('Conditional JSON schema/runtime parity: PASS')
@@ -0,0 +1,194 @@
1
+ import assert from 'node:assert/strict'
2
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import path from 'node:path'
5
+ import { handleToolCall, toolDefinitions } from '../../../src/server-core.js'
6
+ import { AndroidObserve } from '../../../src/observe/android.js'
7
+ import { iOSObserve, _setIOSActiveLogStream, _clearIOSActiveLogStream } from '../../../src/observe/ios.js'
8
+ import { _setDeviceListersForTests, _resetDeviceListersForTests } from '../../../src/utils/resolve-device.js'
9
+ import { deriveSnapshotMetadata } from '../../../src/observe/snapshot-metadata.js'
10
+
11
+ async function run() {
12
+ const directory = mkdtempSync(path.join(tmpdir(), 'mcp-evidence-'))
13
+ const binary = path.join(directory, 'native')
14
+ const events = path.join(directory, 'events')
15
+ const keys = ['ADB_PATH', 'IDB_PATH', 'MCP_IDB_PATH', 'XCRUN_PATH', 'EVIDENCE_FAIL_INPUT', 'EVIDENCE_INPUT_SLOW']
16
+ const env = Object.fromEntries(keys.map(key => [key, process.env[key]]))
17
+ writeFileSync(events, '')
18
+ writeFileSync(binary, `#!/usr/bin/env node
19
+ const fs = require('fs')
20
+ const args = process.argv.slice(2).join(' ')
21
+ fs.appendFileSync(${JSON.stringify(events)}, args + '\\n')
22
+ if (args.includes('ro.build.version.release')) console.log('16')
23
+ else if (args.includes('ro.product.model')) console.log('Fixture')
24
+ else if (args.includes('ro.kernel.qemu')) console.log('1')
25
+ else if (args.includes('simctl list devices')) console.log(JSON.stringify({devices:{'iOS-18':[{udid:'fixture-ios',name:'Fixture',state:'Booted'}]}}))
26
+ else if (args.includes('list-targets')) console.log('[]')
27
+ else if (args.includes('shell input') || args.includes('ui tap') || args.includes('ui swipe')) { if (process.env.EVIDENCE_FAIL_INPUT) process.exit(1); if (process.env.EVIDENCE_INPUT_SLOW) setTimeout(() => process.exit(0), 2000) }
28
+ else if (args.includes('pidof')) console.log('123')
29
+ else if (args.includes('logcat')) { console.log('2026-10-30 10:00:00.000 123 123 I Fixture: stream-owned'); setInterval(() => {}, 1000) }
30
+ else if (args.includes('monkey') || args.includes('simctl launch')) console.log('launched: 123')
31
+ else process.exit(1)
32
+ `)
33
+ chmodSync(binary, 0o755)
34
+ for (const key of keys.slice(0, 4)) process.env[key] = binary
35
+ const device = (platform: 'android' | 'ios', id?: string) => ({ platform, id: id ?? `fixture-${platform}`, osVersion: '16', model: 'Fixture', simulator: true })
36
+ _setDeviceListersForTests({ listAndroidDevices: async () => [device('android'), device('android', 'other')], listIOSDevices: async () => [device('ios')] })
37
+ const originals: Array<() => void> = []
38
+ let captureCount = 0
39
+ let failScreenshot = false
40
+ for (const [platform, klass] of [['android', AndroidObserve], ['ios', iOSObserve]] as const) {
41
+ const prototype = klass.prototype as any
42
+ const replace = (name: string, fn: (...args: any[]) => any) => {
43
+ const original = prototype[name]; originals.push(() => { prototype[name] = original }); prototype[name] = fn
44
+ }
45
+ replace('collectGetUITree', async (id: string) => {
46
+ captureCount++
47
+ const body = { device: device(platform, id), elements: [{ text: 'Ready', resourceId: 'target', contentDescription: 'target', type: 'Button', clickable: true, enabled: true, visible: true, bounds: [0, 0, 50, 50] }], resolution: { width: 100, height: 200 }, screen: 'Ready' }
48
+ return { ...body, ...deriveSnapshotMetadata(`${platform}:${id}`, body as any, 'ui_tree') }
49
+ })
50
+ replace(platform === 'android' ? 'collectCaptureScreen' : 'collectCaptureScreenshot', async (id: string) => {
51
+ captureCount++
52
+ if (failScreenshot) throw new Error('screenshot offline')
53
+ return { device: device(platform, id), screenshot: 'fixture-image', screenshot_mime: 'image/png', resolution: { width: 100, height: 200 } }
54
+ })
55
+ replace('collectGetLogs', async (filters: any) => {
56
+ captureCount++
57
+ return { device: device(platform, filters.deviceId), logs: [{ timestamp: '2020-01-01T00:00:00Z', level: 'INFO', message: 'preserved' }], logCount: 1 }
58
+ })
59
+ if (platform === 'android') replace('collectGetCurrentScreen', async (id: string) => ({ device: device(platform, id), activity: 'FixtureActivity', package: 'fixture.app' }))
60
+ }
61
+ const payload = (r: any) => JSON.parse(r.content[0].text)
62
+ const call = async (name: string, args: Record<string, unknown>) => payload(await handleToolCall(name, args))
63
+ const refFor = (value: any) => ({ sessionId: value.evidence.session_id, actionId: value.evidence.action_id })
64
+ try {
65
+ for (const platform of ['android', 'ios'] as const) {
66
+ const target = { platform, deviceId: `fixture-${platform}` }
67
+ for (const responseMode of ['compact', 'debug']) {
68
+ const a = await call('tap', { ...target, responseMode, verificationMode: 'none', x: 10, y: 10 })
69
+ assert.equal(a.success, true)
70
+ assert.equal(a.action_id, a.evidence.action_id)
71
+ assert.equal(a.evidence.action_id, a.evidence.correlation_id)
72
+ assert.ok(a.evidence.dispatch_started_at_ms)
73
+ assert.ok(a.evidence.dispatch_completed_at_ms >= a.evidence.dispatch_started_at_ms)
74
+ assert.equal(a.evidence.verification_completed_at_ms, null)
75
+ if (responseMode === 'debug') assert.equal(a.trace.action_id, a.action_id)
76
+ const actionRef = refFor(a)
77
+ const image = await handleToolCall('capture_screenshot', { ...target, actionRef })
78
+ const imageMeta = payload(image)
79
+ assert.equal(imageMeta.evidence.action_id, a.action_id)
80
+ assert.equal(imageMeta.evidence.correlation_id, a.evidence.correlation_id)
81
+ assert.equal(image.content[1].data, 'fixture-image')
82
+ assert.equal(imageMeta.evidence.observations[0].freshness, 'fresh')
83
+ const logs = await handleToolCall('get_logs', { ...target, actionRef })
84
+ assert.equal(payload(logs).evidence.action_id, a.action_id)
85
+ assert.equal(JSON.parse(logs.content[1].text!).logs[0].message, 'preserved')
86
+ assert.equal(payload(logs).evidence.observations[0].event_time_comparable, false)
87
+ const tree = await call('get_ui_tree', { ...target, responseMode, actionRef })
88
+ assert.equal(tree.evidence.action_id, a.action_id)
89
+ assert.ok(tree.evidence.observations[0].snapshot_revision)
90
+ const verified = await call('tap', { ...target, responseMode, verificationMode: 'full', x: 10, y: 10 })
91
+ assert.ok(verified.evidence.verification_completed_at_ms >= verified.evidence.dispatch_completed_at_ms)
92
+ assert.ok(verified.evidence.observations.some((v: any) => v.freshness_reasons.includes('BEFORE_ACTION')))
93
+ assert.ok(verified.evidence.observations.some((v: any) => v.freshness === 'fresh'))
94
+ process.env.EVIDENCE_FAIL_INPUT = '1'
95
+ const failed = await call('tap', { ...target, responseMode, verificationMode: 'none', x: 10, y: 10 })
96
+ assert.equal(failed.success, false)
97
+ assert.ok(failed.evidence.dispatch_started_at_ms)
98
+ assert.equal(failed.evidence.verification_completed_at_ms, null)
99
+ delete process.env.EVIDENCE_FAIL_INPUT
100
+ const blocked = await call('tap', { ...target, responseMode, verificationMode: 'none', x: 10, y: 10,
101
+ expectedState: { snapshotSessionId: 'expired-session', snapshotRevision: 1 } })
102
+ assert.equal(blocked.failure_code, 'PRECONDITION_UNAVAILABLE')
103
+ assert.equal(blocked.evidence.dispatch_started_at_ms, null)
104
+ assert.equal(blocked.evidence.verification_completed_at_ms, null)
105
+ }
106
+ failScreenshot = true
107
+ const partial = await call('capture_debug_snapshot', { ...target, includeLogs: false })
108
+ assert.equal(partial.raw.screenshot_error, 'screenshot offline')
109
+ assert.equal(partial.evidence.observations.find((v: any) => v.kind === 'screenshot').status, 'failed')
110
+ assert.equal(partial.evidence.observations.find((v: any) => v.kind === 'logs').status, 'omitted')
111
+ failScreenshot = false
112
+ const disabled = await call('capture_debug_snapshot', { ...target, includeScreenshot: false, includeLogs: false })
113
+ assert.equal(disabled.raw.screenshot, null)
114
+ assert.equal(disabled.evidence.observations.find((v: any) => v.kind === 'screenshot').status, 'omitted')
115
+ const snapshot = await call('capture_debug_snapshot', target)
116
+ assert.equal(snapshot.raw.screenshot, 'fixture-image')
117
+ assert.equal(snapshot.raw.logs[0].message, 'preserved')
118
+ assert.ok(snapshot.evidence.observations.every((v: any) => !v.device || v.device.id === target.deviceId))
119
+ }
120
+ process.env.EVIDENCE_INPUT_SLOW = '1'
121
+ const timeout = await call('tap', { platform: 'android', deviceId: 'fixture-android', x: 1, y: 1,
122
+ verificationMode: 'none', responseMode: 'compact', actionTimeoutMs: 500 })
123
+ delete process.env.EVIDENCE_INPUT_SLOW
124
+ assert.equal(timeout.failure_code, 'ACTION_TIMEOUT')
125
+ assert.ok(timeout.evidence.dispatch_started_at_ms)
126
+ assert.ok(timeout.evidence.dispatch_completed_at_ms)
127
+ assert.equal(timeout.evidence.verification_completed_at_ms, null)
128
+ const before = captureCount
129
+ const malformed = await call('capture_screenshot', { platform: 'android', actionRef: { actionId: '' } })
130
+ assert.match(malformed.error.message, /INVALID_ARGUMENT/)
131
+ assert.equal(captureCount, before)
132
+ const a = await call('tap', { platform: 'android', deviceId: 'fixture-android', x: 1, y: 1, verificationMode: 'none' })
133
+ const mismatch = await call('capture_screenshot', { platform: 'android', deviceId: 'other', actionRef: refFor(a) })
134
+ assert.equal(mismatch.evidence.association_reason, 'DEVICE_MISMATCH')
135
+ assert.notEqual(mismatch.evidence.correlation_id, a.evidence.correlation_id)
136
+ const journey = await call('run_journey', { platform: 'android', deviceId: 'fixture-android', responseMode: 'debug',
137
+ defaults: { verificationMode: 'full' }, steps: [{ id: 'one', type: 'start_app', appId: 'fixture.app' }, { id: 'two', type: 'start_app', appId: 'fixture.app' }] })
138
+ assert.equal(journey.success, true)
139
+ assert.equal(journey.steps[0].result.evidence.correlation_id, journey.evidence.correlation_id)
140
+ assert.equal(journey.steps[1].result.evidence.correlation_id, journey.evidence.correlation_id)
141
+ assert.notEqual(journey.steps[0].result.evidence.action_id, journey.steps[1].result.evidence.action_id)
142
+ assert.ok(journey.steps[0].result.evidence.observations.some((v: any) => v.freshness === 'stale'))
143
+
144
+ assert.ok(journey.steps[0].result.evidence.operation_completed_at_ms <= journey.steps[1].result.evidence.operation_started_at_ms)
145
+ const light = await call('run_journey', { platform: 'android', deviceId: 'fixture-android', responseMode: 'compact',
146
+ defaults: { verificationMode: 'light' }, steps: [
147
+ { id: 'tap', type: 'tap', selector: { accessibility_id: 'target' } },
148
+ { id: 'verify', type: 'assert', assertion: { kind: 'element_visible', selector: { accessibility_id: 'target' } } }
149
+ ] })
150
+ assert.equal(light.success, true)
151
+ const lightStep = light.steps[0].result
152
+ assert.ok(lightStep.evidence.verification_completed_at_ms)
153
+ const afterLight = await call('capture_screenshot', { platform: 'android', deviceId: 'fixture-android', actionRef: refFor(lightStep) })
154
+ assert.equal(afterLight.evidence.verification_completed_at_ms, lightStep.evidence.verification_completed_at_ms)
155
+
156
+ const started = await call('start_log_stream', { platform: 'android', deviceId: 'other', sessionId: 'evidence-owned', packageName: 'fixture.app' })
157
+ assert.equal(started.success, true)
158
+ let androidStream: any
159
+ for (let i = 0; i < 100; i++) {
160
+ androidStream = await call('read_log_stream', { platform: 'android', sessionId: 'evidence-owned', actionRef: refFor(a) })
161
+ if (androidStream.entries.length) break
162
+ await new Promise(resolve => setTimeout(resolve, 10))
163
+ }
164
+ assert.ok(androidStream.entries.length)
165
+ assert.equal(androidStream.evidence.device.id, 'other')
166
+ assert.equal(androidStream.evidence.association_reason, 'DEVICE_MISMATCH')
167
+ assert.match(readFileSync(events, 'utf8'), /-s other logcat/)
168
+ assert.equal((await call('stop_log_stream', { platform: 'android', sessionId: 'evidence-owned' })).success, true)
169
+ const streamFile = path.join(directory, 'stream.ndjson')
170
+ writeFileSync(streamFile, JSON.stringify({ message: 'stream-owned', _iso: '2020-01-01T00:00:00Z' }) + '\n')
171
+ _setIOSActiveLogStream('owned', streamFile, { platform: 'ios', id: 'fixture-ios' })
172
+ // Discovery returning a different device must not retarget this stream read.
173
+ _setDeviceListersForTests({ listIOSDevices: async () => { throw new Error('discovery must not run') } })
174
+ const stream = await call('read_log_stream', { platform: 'ios', sessionId: 'owned' })
175
+ assert.equal(stream.entries[0].message, 'stream-owned')
176
+ assert.equal(stream.evidence.device.id, 'fixture-ios')
177
+ assert.equal(stream.evidence.observations[0].device.id, 'fixture-ios')
178
+ for (const name of ['capture_screenshot', 'get_ui_tree', 'get_screen_fingerprint', 'capture_debug_snapshot', 'get_logs', 'read_log_stream']) {
179
+ assert.ok((toolDefinitions.find(v => v.name === name)!.inputSchema.properties as any).actionRef)
180
+ }
181
+ assert.match(readFileSync(events, 'utf8'), /shell input tap/)
182
+ assert.match(readFileSync(events, 'utf8'), /ui tap/)
183
+ } finally {
184
+ await new AndroidObserve().stopLogStream('evidence-owned')
185
+ rmSync(path.join(tmpdir(), 'mobile-debug-log-evidence-owned.ndjson'), { force: true })
186
+ _clearIOSActiveLogStream('owned')
187
+ for (const restore of originals) restore()
188
+ _resetDeviceListersForTests()
189
+ for (const [key, value] of Object.entries(env)) { if (value === undefined) delete process.env[key]; else process.env[key] = value }
190
+ rmSync(directory, { recursive: true, force: true })
191
+ }
192
+ console.log('Correlated MCP response, native dispatch, snapshot, journey and stream contracts: PASS')
193
+ }
194
+ run().catch(error => { console.error(error); process.exit(1) })
@@ -0,0 +1,170 @@
1
+ import assert from 'node:assert/strict'
2
+ import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'
3
+ import { handleToolCall, toolDefinitions } from '../../../src/server-core.js'
4
+ import { ToolsObserve, AndroidObserve } from '../../../src/observe/index.js'
5
+ import { deriveSnapshotMetadata, resetSnapshotMetadataForTests } from '../../../src/observe/snapshot-metadata.js'
6
+ import { _setDeviceListersForTests, _resetDeviceListersForTests, withResolvedDevice } from '../../../src/utils/resolve-device.js'
7
+ import { runWithBudget } from '../../../src/utils/operation-budget.js'
8
+
9
+ async function run() {
10
+ const originalTree = ToolsObserve.getUITreeHandler
11
+ const originalNative = AndroidObserve.prototype.getUITree
12
+ const device = { platform: 'android', id: 'fixture', model: 'Fixture', osVersion: '16', simulator: true }
13
+ let reads = 0
14
+ let mode = 'stable'
15
+ let delayed = 0
16
+ let expireDuringRead: (() => void) | undefined
17
+ const tree = () => {
18
+ const label = mode === 'changing' ? `Item ${reads}` : mode === 'changeThenStable' && reads === 1 ? 'Before' : 'Ready'
19
+ const elements = [{ type: 'Button', text: label, contentDescription: 'target', visible: true, enabled: true, clickable: true, bounds: [0, 0, 50, 50] }]
20
+ if (mode === 'loading' || mode === 'loadingThenTimeout' || (mode === 'loaderThenReady' && reads <= 2)) elements.push({ type: 'android.widget.ProgressBar', text: '', contentDescription: '', visible: true, enabled: true, clickable: false, bounds: [0, 60, 20, 80] })
21
+ const body = { device, elements, screen: '', resolution: { width: 100, height: 200 } }
22
+ return { ...body, ...deriveSnapshotMetadata('android:fixture', body, 'ui_tree') }
23
+ }
24
+ ToolsObserve.getUITreeHandler = async () => {
25
+ reads++
26
+ if (delayed) await new Promise(resolve => setTimeout(resolve, delayed))
27
+ if (mode === 'loadingThenTimeout' && reads === 2) { expireDuringRead?.(); throw new Error('ACTION_TIMEOUT') }
28
+ if (mode === 'error') throw new Error('observation failed')
29
+ if (mode === 'disconnect') return { ...tree(), error: 'device fixture offline' } as any
30
+ return tree()
31
+ }
32
+ _setDeviceListersForTests({ listAndroidDevices: async () => [device] })
33
+ // Sample counts depend on elapsed time, not the host timer's wakeup precision.
34
+ // Advance both clocks exactly when a fixture/poll timer completes.
35
+ const originalDateNow = Date.now
36
+ const originalPerformanceNow = Object.getOwnPropertyDescriptor(performance, 'now')
37
+ const originalSetTimeout = globalThis.setTimeout
38
+ const epoch = originalDateNow()
39
+ let elapsed = 0
40
+ Date.now = () => epoch + elapsed
41
+ Object.defineProperty(performance, 'now', { configurable: true, value: () => elapsed })
42
+ globalThis.setTimeout = ((callback: (...args: any[]) => void, delay = 0, ...args: any[]) => {
43
+ queueMicrotask(() => { elapsed += Math.max(0, Number(delay) || 0); callback(...args) })
44
+ return {} as ReturnType<typeof setTimeout>
45
+ }) as typeof setTimeout
46
+ const call = async (name: string, args: any = {}) => JSON.parse((await handleToolCall(name, { platform: 'android', deviceId: 'fixture', ...args })).content[0].text!)
47
+ try {
48
+ let result = await call('get_app_readiness', { stabilityMs: 100 })
49
+ assert.equal(result.readiness.state, 'settled')
50
+ assert.equal(result.success, true)
51
+ assert.equal(reads, 2)
52
+ mode = 'loading'; reads = 0
53
+ result = await call('get_app_readiness')
54
+ assert.equal(result.readiness.state, 'loading')
55
+ assert.equal(reads, 1)
56
+ mode = 'changing'; reads = 0
57
+ result = await call('get_app_readiness', { stabilityMs: 100 })
58
+ assert.equal(result.readiness.state, 'transitioning')
59
+ assert.equal(result.success, true)
60
+ mode = 'loaderThenReady'; reads = 0
61
+ result = await call('wait_for_app_ready', { stabilityMs: 100, pollIntervalMs: 100, timeoutMs: 1500 })
62
+ assert.equal(result.readiness.state, 'settled')
63
+ assert.equal(result.success, true)
64
+ assert.equal(reads, 4)
65
+ mode = 'loading'; reads = 0
66
+ result = await call('wait_for_app_ready', { timeoutMs: 200, pollIntervalMs: 100 })
67
+ assert.equal(result.failure_code, 'READINESS_TIMEOUT')
68
+ assert.equal(result.readiness.state, 'loading')
69
+ assert.ok(result.readiness.limitations.includes('DEADLINE_EXCEEDED'))
70
+ assert.equal(result.readiness.capabilities.unresponsive, false)
71
+ mode = 'stable'; reads = 0; delayed = 150
72
+ result = await call('get_app_readiness', { timeoutMs: 100 })
73
+ assert.equal(result.success, false, 'late physical read cannot produce success')
74
+ assert.equal(result.readiness.state, 'unknown')
75
+ delayed = 0
76
+ mode = 'disconnect'; reads = 0
77
+ result = await call('wait_for_app_ready')
78
+ assert.equal(result.failure_code, 'DEVICE_UNAVAILABLE')
79
+ assert.equal(reads, 1)
80
+ mode = 'loading'; reads = 0
81
+ const ordinary = await call('wait_for_ui', { selector: { text: 'Ready' }, timeout_ms: 1500, poll_interval_ms: 100 })
82
+ assert.equal(ordinary.status, 'success', 'default wait keeps predicate-only semantics')
83
+ assert.equal(ordinary.readiness, undefined)
84
+ assert.equal(reads, 2)
85
+ reads = 0
86
+ const blocked = await call('wait_for_ui', { selector: { text: 'Ready' }, requireReady: true, timeout_ms: 650, poll_interval_ms: 100 })
87
+ assert.equal(blocked.status, 'timeout')
88
+ assert.equal(blocked.readiness.state, 'loading')
89
+ assert.equal(blocked.observed.condition_satisfied, true, 'a readiness timeout must not pretend the visible target was absent')
90
+ assert.ok(blocked.readiness.limitations.includes('DEADLINE_EXCEEDED'))
91
+ // Force expiry inside a later acquisition rather than depending on timer rounding.
92
+ for (const tool of ['wait_for_ui', 'wait_for_app_ready']) {
93
+ const originalNow = Object.getOwnPropertyDescriptor(performance, 'now')
94
+ const nativeNow = performance.now.bind(performance)
95
+ let advance = 0
96
+ Object.defineProperty(performance, 'now', { configurable: true, value: () => nativeNow() + advance })
97
+ expireDuringRead = () => { advance += 2000 }
98
+ mode = 'loadingThenTimeout'; reads = 0
99
+ try {
100
+ const timedOut = await call(tool, tool === 'wait_for_ui'
101
+ ? { selector: { text: 'Ready' }, requireReady: true, timeout_ms: 1000, poll_interval_ms: 100 }
102
+ : { timeoutMs: 1000, pollIntervalMs: 100 })
103
+ assert.equal(reads, 2)
104
+ assert.equal(timedOut.readiness.state, 'loading', 'expiry retains the last valid loading evidence')
105
+ assert.ok(timedOut.readiness.limitations.includes('DEADLINE_EXCEEDED'))
106
+ assert.equal(timedOut.readiness.evidence.at(-1).code, 'NATIVE_LOADING_INDICATOR')
107
+ if (tool === 'wait_for_ui') assert.equal(timedOut.observed.condition_satisfied, true)
108
+ else assert.equal(timedOut.failure_code, 'READINESS_TIMEOUT')
109
+ } finally {
110
+ expireDuringRead = undefined
111
+ if (originalNow) Object.defineProperty(performance, 'now', originalNow)
112
+ else delete (performance as any).now
113
+ }
114
+ }
115
+ mode = 'loaderThenReady'; reads = 0
116
+ const ready = await call('wait_for_ui', { selector: { text: 'Ready' }, requireReady: true, timeout_ms: 2000, poll_interval_ms: 100 })
117
+ assert.equal(ready.status, 'success')
118
+ assert.equal(ready.readiness.state, 'settled')
119
+ assert.equal(reads, ready.readiness.sample_count, 'predicate and readiness share samples')
120
+ mode = 'changeThenStable'; reads = 0
121
+ const changed = await call('wait_for_ui_change', { requireReady: true, timeout_ms: 2500, stability_window_ms: 0 })
122
+ assert.equal(changed.success, true, JSON.stringify(changed))
123
+ assert.equal(changed.readiness.state, 'settled')
124
+ assert.equal(reads, changed.readiness.sample_count)
125
+ mode = 'stable'; reads = 0
126
+ result = await runWithBudget(100, () => call('wait_for_app_ready', { timeoutMs: 2000, stabilityMs: 500 }))
127
+ assert.equal(result.success, false, 'outer budget bounds the wait')
128
+ assert.ok(result.readiness.limitations.includes('DEADLINE_EXCEEDED'))
129
+ _setDeviceListersForTests({ listAndroidDevices: async () => { await new Promise(resolve => setTimeout(resolve, 140)); return [] } })
130
+ const discoveryTimeout = await call('wait_for_app_ready', { timeoutMs: 100 })
131
+ assert.equal(discoveryTimeout.failure_code, 'READINESS_TIMEOUT', 'discovery deadline is not proof of a missing device')
132
+ assert.ok(discoveryTimeout.readiness.limitations.includes('DEADLINE_EXCEEDED'))
133
+ _setDeviceListersForTests({ listAndroidDevices: async () => [device] })
134
+ // The ordinary tree path adds single-sample metadata without extra acquisition.
135
+ ToolsObserve.getUITreeHandler = originalTree
136
+ let nativeReads = 0
137
+ AndroidObserve.prototype.getUITree = async () => { nativeReads++; return tree() as any }
138
+ for (const responseMode of ['debug', 'compact'] as const) {
139
+ const observed: any = await withResolvedDevice(device, () => ToolsObserve.getUITreeHandler({ platform: 'android', deviceId: 'fixture', responseMode }))
140
+ assert.equal(observed.readiness.state, 'unknown')
141
+ assert.equal(observed.readiness.sample_count, 1)
142
+ }
143
+ assert.equal(nativeReads, 2, 'one native read per public tree response')
144
+ const validator = new AjvJsonSchemaValidator()
145
+ for (const name of ['get_app_readiness', 'wait_for_app_ready']) {
146
+ const schema = validator.getValidator(toolDefinitions.find(tool => tool.name === name)!.inputSchema)
147
+ assert.equal(schema({ platform: 'android' }).valid, true)
148
+ for (const args of [{ platform: 'other' }, { platform: 'android', timeoutMs: 99 }, { platform: 'android', stabilityMs: 10001 }, { platform: 'android', extra: true }, { platform: 'android', deviceId: '' }]) {
149
+ assert.equal(schema(args).valid, false)
150
+ const result = JSON.parse((await handleToolCall(name, args)).content[0].text!)
151
+ assert.match(result.error.message, /INVALID_ARGUMENT/)
152
+ }
153
+ }
154
+ for (const name of ['wait_for_ui', 'wait_for_ui_change']) {
155
+ const bad = await call(name, { requireReady: 'yes' })
156
+ assert.match(bad.error.message, /INVALID_ARGUMENT/)
157
+ }
158
+ console.log('Readiness tools, schemas and opt-in waits: PASS')
159
+ } finally {
160
+ globalThis.setTimeout = originalSetTimeout
161
+ Date.now = originalDateNow
162
+ if (originalPerformanceNow) Object.defineProperty(performance, 'now', originalPerformanceNow)
163
+ else delete (performance as any).now
164
+ ToolsObserve.getUITreeHandler = originalTree
165
+ AndroidObserve.prototype.getUITree = originalNative
166
+ _resetDeviceListersForTests()
167
+ resetSnapshotMetadataForTests()
168
+ }
169
+ }
170
+ run().catch(error => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,79 @@
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 { remainingBudget } from '../../../src/utils/operation-budget.js'
6
+
7
+ async function run() {
8
+ const originalTree = ToolsObserve.getUITreeHandler
9
+ const originalTap = ToolsInteract.tapHandler
10
+ const tab = () => ({ bounds: [244, 1408, 476, 1528], clickable: true, visible: true, enabled: true, children: [1] })
11
+ const label = () => ({ text: 'GENER8', bounds: [330, 1489, 391, 1513], clickable: false, visible: true, enabled: true, parentId: 0 })
12
+ let elements: any[] = [tab(), label()]
13
+ let error: string | undefined
14
+ let observedBudget = 0
15
+ let reads = 0
16
+ const taps: Array<{ x: number; y: number }> = []
17
+ ToolsObserve.getUITreeHandler = async () => {
18
+ observedBudget = remainingBudget()
19
+ reads++
20
+ return { device: { platform: 'android', id: 'fixture' }, elements, captured_at_ms: Date.now(), error } as any
21
+ }
22
+ ToolsInteract.tapHandler = async args => {
23
+ taps.push({ x: args.x, y: args.y })
24
+ return { device: { platform: 'android', id: 'fixture' }, success: true } as any
25
+ }
26
+ const call = async (args: Record<string, unknown> = {}) => JSON.parse((await handleToolCall('tap_element', {
27
+ platform: 'android', deviceId: 'fixture', selector: { text: 'GENER8' }, responseMode: 'compact', verificationMode: 'none', ...args
28
+ })).content[0].text!)
29
+ try {
30
+ for (const waitFor of [undefined, {}, { condition: 'exists' }, { condition: 'visible' }, { condition: 'clickable' }]) {
31
+ reads = 0
32
+ const result = await call(waitFor ? { waitFor } : {})
33
+ assert.equal(result.success, true)
34
+ assert.deepEqual(taps.at(-1), { x: 360, y: 1468 })
35
+ assert.equal(reads, 1, 'Reuse the selector acquisition for dispatch resolution')
36
+ assert.ok(observedBudget > 1500 && observedBudget <= 5000, `One acquisition should use the action budget: ${observedBudget}`)
37
+ }
38
+ await call({ actionTimeoutMs: 12000 })
39
+ assert.ok(observedBudget > 10000 && observedBudget <= 12000)
40
+ await call({ actionTimeoutMs: 12000, waitFor: { condition: 'exists', timeoutMs: 700 } })
41
+ assert.ok(observedBudget > 0 && observedBudget <= 700)
42
+ const tapCount = taps.length
43
+ error = 'Failed to get UI tree: ACTION_TIMEOUT'
44
+ const timeout = await call()
45
+ assert.equal(timeout.failure_code, 'ACTION_TIMEOUT', 'Observation timeout must not claim that the label is missing')
46
+ assert.equal(timeout.evidence.dispatch_started_at_ms, null)
47
+ assert.equal(taps.length, tapCount)
48
+ error = undefined
49
+
50
+ for (const patch of [{ enabled: false }, { visible: false }]) {
51
+ for (const condition of ['exists', 'visible', 'clickable']) {
52
+ elements = [tab(), { ...label(), ...patch }]
53
+ // A short explicit wait bounds unsatisfied visible/clickable conditions.
54
+ const result = await call({ waitFor: { condition, timeoutMs: 100 } })
55
+ assert.equal(result.success, false)
56
+ assert.equal(taps.length, tapCount, 'Never bypass hidden/disabled labels through an enabled parent')
57
+ }
58
+ }
59
+ elements = [{ ...tab(), enabled: false }, label()]
60
+ assert.equal((await call({ waitFor: { condition: 'exists' } })).failure_code, 'ELEMENT_NOT_INTERACTABLE')
61
+ assert.equal(taps.length, tapCount)
62
+ elements = [label()]
63
+ assert.equal((await call({ waitFor: { condition: 'exists' } })).failure_code, 'ELEMENT_NOT_INTERACTABLE')
64
+ assert.equal(taps.length, tapCount, 'A plain label without an actionable container remains untappable')
65
+ elements = [tab(), label(), { ...label(), parentId: 0 }]
66
+ assert.equal((await call()).failure_code, 'AMBIGUOUS_TARGET')
67
+ assert.equal(taps.length, tapCount)
68
+ // Standalone waits keep returning the matched leaf for exists; promotion is tap-only.
69
+ elements = [tab(), label()]
70
+ const wait = await ToolsInteract.waitForUIHandler({ selector: { text: 'GENER8' }, condition: 'exists', platform: 'android', singleObservation: true })
71
+ assert.equal(wait.element.index, 1)
72
+ } finally {
73
+ ToolsObserve.getUITreeHandler = originalTree
74
+ ToolsInteract.tapHandler = originalTap
75
+ ToolsInteract._resetResolvedUiElementsForTests()
76
+ }
77
+ console.log('Selector tap acquisition budget, executable ancestor, and rejection guards: PASS')
78
+ }
79
+ run().catch(error => { console.error(error); process.exit(1) })
@@ -24,6 +24,8 @@ async function run() {
24
24
  assert.equal(success.success, true)
25
25
  assert.equal(tapCount, 1)
26
26
  assert.equal(lastWait.condition, 'clickable')
27
+ assert.equal(lastWait.timeout_ms, 5000)
28
+ assert.equal(lastWait.resolveTapTarget, true)
27
29
  assert.equal(lastWait.singleObservation, true)
28
30
  assert.equal(lastWait.singleAttempt, true)
29
31
  assert.equal(lastWait.rejectAmbiguous, true)
@@ -1,74 +0,0 @@
1
- ---
2
- name: mobile-debug-mcp
3
- description: Verify Android app behaviour efficiently with mobile-debug MCP, especially session playback, background work, and notifications. Use for emulator/device QA; do not use for implementation-only checks.
4
- metadata:
5
- author: modul8-team
6
- version: "1.0"
7
- ---
8
-
9
- # Mobile-debug MCP verification
10
-
11
- Use mobile-debug MCP to make a product claim observable on a real device or emulator. A successful tap proves only that the input was delivered; verify the resulting UI, notification, or persisted state before reporting success.
12
-
13
- ## Choose the lightest evidence that proves the claim
14
-
15
- - Use an accessibility assertion such as `expect_element_visible` or
16
- `wait_for_ui` for an accessible label, enabled state, or changed control.
17
- - Use a screenshot when visual layout, system UI, or notification content matters.
18
- - Use a debug snapshot only when the screen does not explain a failed action or an accessibility-tree assertion is needed.
19
- - Use Android shell commands only for device navigation or system surfaces that MCP cannot expose. Do not treat shell output as proof of user-visible behaviour.
20
-
21
- Avoid repeated screenshots and snapshots when a targeted accessibility assertion is enough.
22
-
23
- ## Keep interaction fast
24
-
25
- Treat each MCP call as a round trip and use the fastest path that still proves
26
- the behaviour. Prefer the following sequence:
27
-
28
- 1. Wait for one stable, uniquely identifiable control.
29
- 2. Tap its returned element id.
30
- 3. Wait for the specific resulting state.
31
-
32
- Do not wait before every tap when the current screen and element id are already known. Do not poll unchanged screens. Use short timeouts for ordinary controls; reserve longer waits for known asynchronous work such as model initialisation or network calls.
33
-
34
- Use compact responses for routine actions. If action dispatch is reliable but
35
- verification is slow or unavailable, dispatch with verification disabled and
36
- verify at meaningful checkpoints with one focused accessibility assertion or
37
- screenshot. Do not assume `light` verification is faster: use it only when it
38
- has returned useful results reliably on the current MCP version and device.
39
-
40
- Prefer a stable element selector when the accessibility tree exposes one. Use
41
- coordinates only when needed, and take a fresh screenshot immediately before a
42
- coordinate tap on system UI, notifications, or any screen where layout may have
43
- changed. Never reuse coordinates after expanding/collapsing a notification,
44
- changing orientation, or switching screens.
45
-
46
- Element discovery can be the slowest part of a journey. Avoid repeated
47
- `find_element` calls for the same screen: resolve a control once, use the
48
- returned selector or coordinates while that screen is unchanged, and verify
49
- the resulting state before continuing. If a query fails, inspect the current
50
- screen once before retrying; controls represented only by icons may not have a
51
- matching visible text label.
52
-
53
- When the MCP offers a journey or batch action, use it only when its steps can
54
- express the needed observable checks and it returns enough evidence to report
55
- the outcome. Measure the whole journey and compare equivalent steps on the same
56
- device; do not infer a performance improvement from a single fast action.
57
-
58
- ## Session playback checks
59
-
60
- For a claimed playback start, observe a running timer or Pause state after Play. For a claimed pause, observe Resume and unchanged elapsed time. For a claimed stop, observe that playback ends and the notification disappears.
61
-
62
- For background playback, press Home, open the notification shade, and verify
63
- the notification title, progress/status, and available actions. Expand the
64
- session notification if necessary, then capture its current layout before
65
- tapping an action. Exercise each action needed for the change and observe its
66
- result: Pause becomes Resume; Resume returns to active playback; Stop ends
67
- playback and clears the notification. After each action, reacquire the current
68
- screen state before using another coordinate.
69
-
70
- Do not claim spoken guidance was verified unless audio output was actually inspected. A running timer or active notification verifies playback state, not audible wording.
71
-
72
- ## Report evidence precisely
73
-
74
- State the device journey, observed result, and any untested scope. Mention whether evidence came from an accessibility assertion, screenshot, or system notification. Do not say a feature works solely because the build compiled or an MCP action returned success.