mobile-debug-mcp 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/README.md +10 -0
  2. package/agent-plugin/.antigravity-plugin/plugin.json +1 -1
  3. package/agent-plugin/.claude-plugin/plugin.json +2 -2
  4. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  5. package/agent-plugin/.cursor-plugin/plugin.json +2 -2
  6. package/agent-plugin/.mcp.json +1 -1
  7. package/agent-plugin/cursor/.cursor-plugin/plugin.json +2 -2
  8. package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +10 -2
  9. package/agent-plugin/mcp.json +1 -1
  10. package/agent-plugin/mcp_config.json +1 -1
  11. package/agent-plugin/plugin.json +1 -1
  12. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +10 -2
  13. package/dist/contracts/device-readiness.js +1 -0
  14. package/dist/interact/waits/ui-change-wait.js +9 -2
  15. package/dist/interact/waits/ui-wait.js +7 -3
  16. package/dist/manage/index.js +3 -0
  17. package/dist/observe/device-readiness.js +116 -0
  18. package/dist/observe/evidence.js +31 -0
  19. package/dist/observe/ios-trace/sessions.js +5 -3
  20. package/dist/observe/readiness-service.js +21 -9
  21. package/dist/observe/readiness.js +30 -0
  22. package/dist/observe/screenshot-encoding.js +4 -0
  23. package/dist/observe/snapshot-collection.js +7 -2
  24. package/dist/server/definitions/manage.js +3 -2
  25. package/dist/server/handlers/action-execution.js +23 -14
  26. package/dist/server/handlers/compact-interaction.js +106 -0
  27. package/dist/server/handlers/compact-state.js +47 -0
  28. package/dist/server/handlers/input.js +2 -0
  29. package/dist/server/handlers/interact.js +2 -0
  30. package/dist/server/handlers/interaction-diagnostics.js +62 -0
  31. package/dist/server/handlers/journey.js +18 -20
  32. package/dist/server/handlers/observe.js +2 -1
  33. package/dist/server/handlers/tool-handlers.js +9 -5
  34. package/dist/server/schemas/interaction-diagnostics.js +9 -0
  35. package/dist/server/tool-definitions.js +9 -4
  36. package/dist/server-core.js +5 -2
  37. package/dist/server.js +1 -1
  38. package/dist/system/android.js +19 -15
  39. package/dist/system/index.js +2 -1
  40. package/dist/utils/android/utils.js +7 -16
  41. package/dist/utils/app-installation.js +77 -0
  42. package/dist/utils/benchmark-metrics.js +43 -0
  43. package/dist/utils/exec.js +4 -0
  44. package/dist/utils/ios/utils.js +4 -11
  45. package/dist/utils/resolve-device.js +3 -2
  46. package/dist/utils/timing.js +3 -3
  47. package/docs/CHANGELOG.md +25 -0
  48. package/docs/benchmarks/README.md +1 -0
  49. package/docs/benchmarks/end-to-end-latency/README.md +98 -0
  50. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.json +6532 -0
  51. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.md +30 -0
  52. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.json +15901 -0
  53. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.md +14 -0
  54. package/docs/benchmarks/end-to-end-latency/baseline.json +233843 -0
  55. package/docs/benchmarks/end-to-end-latency/baseline.md +21 -0
  56. package/docs/benchmarks/end-to-end-latency/investigation.md +128 -0
  57. package/docs/benchmarks/end-to-end-latency/ios-smoke.json +84 -0
  58. package/docs/benchmarks/end-to-end-latency/ios-smoke.md +9 -0
  59. package/docs/benchmarks/end-to-end-latency/smoke.json +19601 -0
  60. package/docs/benchmarks/end-to-end-latency/smoke.md +21 -0
  61. package/docs/specs/031-ios-system-traces/plan.md +28 -0
  62. package/docs/specs/033-device-readiness-blockers/android-evidence.json +437 -0
  63. package/docs/specs/033-device-readiness-blockers/android-physical-evidence.json +28 -0
  64. package/docs/specs/033-device-readiness-blockers/android-physical-power-evidence.json +29 -0
  65. package/docs/specs/033-device-readiness-blockers/intent.md +41 -0
  66. package/docs/specs/033-device-readiness-blockers/ios-investigation.md +55 -0
  67. package/docs/specs/033-device-readiness-blockers/ios-probe-evidence.json +24 -0
  68. package/docs/specs/033-device-readiness-blockers/ios-tool-evidence.json +73 -0
  69. package/docs/specs/033-device-readiness-blockers/plan.md +249 -0
  70. package/docs/specs/033-device-readiness-blockers/spec.md +134 -0
  71. package/docs/specs/034-app-installation-status/intent.md +41 -0
  72. package/docs/specs/034-app-installation-status/native-evidence.json +654 -0
  73. package/docs/specs/034-app-installation-status/plan.md +73 -0
  74. package/docs/specs/034-app-installation-status/spec.md +114 -0
  75. package/docs/specs/035-compact-interaction-results/baseline.md +45 -0
  76. package/docs/specs/035-compact-interaction-results/intent.md +38 -0
  77. package/docs/specs/035-compact-interaction-results/plan.md +43 -0
  78. package/docs/specs/035-compact-interaction-results/spec.md +118 -0
  79. package/docs/specs/035-compact-interaction-results/verification.md +111 -0
  80. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-amendment.md +60 -0
  81. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-plan.md +32 -0
  82. package/docs/specs/036-end-to-end-interaction-latency/intent.md +37 -0
  83. package/docs/specs/036-end-to-end-interaction-latency/plan.md +59 -0
  84. package/docs/specs/036-end-to-end-interaction-latency/spec.md +126 -0
  85. package/docs/specs/036-end-to-end-interaction-latency/verification.md +87 -0
  86. package/docs/tools/TOOLS.md +1 -0
  87. package/docs/tools/compact-interactions.md +56 -0
  88. package/docs/tools/correlated-evidence.md +6 -0
  89. package/docs/tools/interact.md +17 -8
  90. package/docs/tools/manage.md +10 -2
  91. package/docs/tools/observe.md +42 -2
  92. package/docs/tools/system.md +6 -0
  93. package/package.json +1 -1
  94. package/src/contracts/device-readiness.ts +8 -0
  95. package/src/interact/waits/ui-change-wait.ts +7 -2
  96. package/src/interact/waits/ui-wait.ts +6 -3
  97. package/src/manage/index.ts +2 -0
  98. package/src/observe/device-readiness.ts +96 -0
  99. package/src/observe/evidence.ts +30 -1
  100. package/src/observe/ios-trace/sessions.ts +4 -2
  101. package/src/observe/readiness-service.ts +21 -9
  102. package/src/observe/readiness.ts +33 -0
  103. package/src/observe/screenshot-encoding.ts +4 -0
  104. package/src/observe/snapshot-collection.ts +7 -2
  105. package/src/server/common.ts +2 -2
  106. package/src/server/definitions/manage.ts +3 -2
  107. package/src/server/handlers/action-execution.ts +22 -14
  108. package/src/server/handlers/compact-interaction.ts +91 -0
  109. package/src/server/handlers/compact-state.ts +43 -0
  110. package/src/server/handlers/input.ts +2 -0
  111. package/src/server/handlers/interact.ts +2 -0
  112. package/src/server/handlers/interaction-diagnostics.ts +50 -0
  113. package/src/server/handlers/journey.ts +15 -17
  114. package/src/server/handlers/observe.ts +2 -1
  115. package/src/server/handlers/payload.ts +2 -1
  116. package/src/server/handlers/tool-handlers.ts +9 -5
  117. package/src/server/schemas/interaction-diagnostics.ts +10 -0
  118. package/src/server/tool-definitions.ts +8 -4
  119. package/src/server-core.ts +5 -2
  120. package/src/server.ts +1 -1
  121. package/src/system/android.ts +18 -11
  122. package/src/system/index.ts +2 -1
  123. package/src/types.ts +2 -0
  124. package/src/utils/android/utils.ts +8 -10
  125. package/src/utils/app-installation.ts +73 -0
  126. package/src/utils/benchmark-metrics.ts +42 -0
  127. package/src/utils/exec.ts +5 -0
  128. package/src/utils/ios/utils.ts +4 -10
  129. package/src/utils/resolve-device.ts +4 -2
  130. package/src/utils/timing.ts +3 -2
  131. package/test/device/automated/debugger/runtime.android.test.ts +5 -0
  132. package/test/device/automated/debugger/runtime.ios.test.ts +7 -1
  133. package/test/device/automated/interact/compact-results.smoke.ts +58 -0
  134. package/test/device/automated/latency/end-to-end-environment.ts +61 -0
  135. package/test/device/automated/latency/end-to-end-report.ts +90 -0
  136. package/test/device/automated/latency/end-to-end-session.ts +98 -0
  137. package/test/device/automated/latency/end-to-end.ts +149 -0
  138. package/test/device/automated/latency/owned-transport.ts +81 -0
  139. package/test/device/automated/latency/snapshot-pair.ts +143 -0
  140. package/test/device/automated/latency-ios/benchmark.ts +126 -0
  141. package/test/device/automated/observe/correlated-evidence.smoke.ts +7 -5
  142. package/test/device/automated/observe/device-readiness-cleanup.ts +14 -0
  143. package/test/device/automated/observe/device-readiness.android.smoke.ts +78 -0
  144. package/test/device/automated/observe/ios-system-trace.smoke.ts +2 -2
  145. package/test/device/automated/observe/system-trace.smoke.ts +2 -2
  146. package/test/fixtures/contracts/tool-definitions.json +51 -15
  147. package/test/fixtures/latency-journey-app/build.ts +3 -3
  148. package/test/unit/debugger/handle-guards.test.ts +2 -1
  149. package/test/unit/interact/ui-change-readiness-interruption.test.ts +37 -0
  150. package/test/unit/observe/capture_debug_snapshot.test.ts +66 -12
  151. package/test/unit/observe/device-readiness-cleanup.test.ts +24 -0
  152. package/test/unit/observe/device-readiness.test.ts +97 -0
  153. package/test/unit/observe/ios-trace-sessions.test.ts +23 -0
  154. package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
  155. package/test/unit/server/compact-interaction.test.ts +87 -0
  156. package/test/unit/server/compact-journey-evidence.test.ts +40 -0
  157. package/test/unit/server/compact-review-regressions.test.ts +78 -0
  158. package/test/unit/server/conditional_actions.test.ts +7 -3
  159. package/test/unit/server/correlated-evidence.test.ts +18 -6
  160. package/test/unit/server/end-to-end-report.test.ts +46 -0
  161. package/test/unit/server/input-device-readiness.test.ts +55 -0
  162. package/test/unit/server/interaction-diagnostics.test.ts +45 -0
  163. package/test/unit/server/owned-benchmark-transport.test.ts +32 -0
  164. package/test/unit/server/readiness-retained-deadline.test.ts +32 -0
  165. package/test/unit/server/readiness.test.ts +24 -1
  166. package/test/unit/server/run_journey.test.ts +2 -1
  167. package/test/unit/server/selector_tap_target.test.ts +2 -1
  168. package/test/unit/server/system-trace.test.ts +6 -4
  169. package/test/unit/utils/app-installation.test.ts +78 -0
  170. package/test/unit/utils/benchmark-metrics.test.ts +36 -0
  171. package/test/unit/utils/resolve_device.test.ts +11 -0
@@ -0,0 +1,143 @@
1
+ import { writeFileSync } from 'node:fs'
2
+ import { dirname, isAbsolute } from 'node:path'
3
+ import assert from 'node:assert/strict'
4
+ import { computeScreenFingerprint } from '../../../../src/utils/ui/index.js'
5
+ import type { CaptureDebugSnapshotRawResponse, GetCurrentScreenResponse } from '../../../../src/types.js'
6
+ import { buildFixture } from '../../../fixtures/latency-journey-app/build.js'
7
+ import { BenchmarkSession, UnsafeMeasurement } from './end-to-end-session.js'
8
+ import { cleanupFixture, command, environment, fixturePresent, treeHash } from './end-to-end-environment.js'
9
+ import { summarizeAttempts, type Attempt } from './end-to-end-report.js'
10
+
11
+ const device = process.env.LATENCY_DEVICE_ID ?? ''
12
+ const baseline = process.env.SNAPSHOT_BASELINE_SERVER ?? ''
13
+ const baselineSource = process.env.SNAPSHOT_BASELINE_SOURCE_SHA ?? ''
14
+ assert.match(device, /^emulator-\d+$/)
15
+ assert.ok(isAbsolute(baseline) && /^[a-f0-9]{64}$/.test(baselineSource), 'Provide the frozen baseline server path and source hash')
16
+ const adb = (...args: string[]) => command('adb', ['-s', device, ...args])
17
+ const app = 'dev.mobiledebugmcp.latencyfixture'
18
+ const fixtureActivity = `${app}.MainActivity`
19
+ const options = { includeScreenshot: false, includeLogs: false }
20
+ const attempts: Attempt[] = []
21
+ const sessions = {
22
+ baseline: new BenchmarkSession(device, true, baseline),
23
+ candidate: new BenchmarkSession(device, true)
24
+ }
25
+ type Build = keyof typeof sessions
26
+ let installAttempted = false, launched = false, interrupted = false
27
+ let fatal: string | undefined, metadata: ReturnType<typeof environment> | undefined
28
+ let fixtureSha: string | undefined
29
+ let nativeFreshness = false
30
+ const baselineHash = treeHash(dirname(baseline))
31
+ const cleanup = { servers: false, fixture: false }
32
+
33
+ async function checkFixtureActivity(session: BenchmarkSession) {
34
+ const screen = await session.call('get_current_screen', {}, [])
35
+ assert.equal(screen.package, app)
36
+ assert.equal(screen.activity, fixtureActivity)
37
+ }
38
+
39
+ function verifyFingerprint(raw: CaptureDebugSnapshotRawResponse) {
40
+ const tree = raw.ui_tree
41
+ assert.ok(tree && tree.device.id === device && tree.snapshot_revision)
42
+ assert.equal(raw.snapshot_revision, tree.snapshot_revision)
43
+ assert.equal(raw.captured_at_ms, tree.captured_at_ms)
44
+ assert.match(raw.fingerprint ?? '', /^[a-f0-9]{64}$/)
45
+ const current: GetCurrentScreenResponse = { device: tree.device, activity: fixtureActivity, package: app, shortActivity: '' }
46
+ assert.equal(raw.fingerprint, computeScreenFingerprint(tree, current, 'android').fingerprint)
47
+ }
48
+
49
+ async function sample(build: Build, iteration: number) {
50
+ if (interrupted) throw new UnsafeMeasurement('INTERRUPTED')
51
+ const attempt: Attempt = { case: build, iteration, warmup: iteration < 3, success: false, setup_ms: 0, requests: [] }
52
+ attempts.push(attempt)
53
+ try {
54
+ await checkFixtureActivity(sessions[build])
55
+ const data = await sessions[build].call('capture_debug_snapshot', options, attempt.requests)
56
+ const raw = data.raw as CaptureDebugSnapshotRawResponse
57
+ assert.ok(raw.ui_tree?.elements.some(element => element.contentDescription === 'home-screen' || element.text === 'home-screen'))
58
+ await checkFixtureActivity(sessions[build])
59
+ if (build === 'candidate') {
60
+ verifyFingerprint(raw)
61
+ assert.equal(attempt.requests[0].tree.filter(event => event.type === 'acquisition_completed').length, 1)
62
+ }
63
+ attempt.success = true
64
+ } catch (error) {
65
+ attempt.failure = attempt.requests[0]?.failure ?? 'SNAPSHOT_ASSERTION_FAILED'
66
+ if (error instanceof UnsafeMeasurement) throw error
67
+ }
68
+ }
69
+
70
+ async function checkFreshness() {
71
+ const candidate = sessions.candidate
72
+ const before = (await candidate.call('capture_debug_snapshot', options, [])).raw as CaptureDebugSnapshotRawResponse
73
+ await candidate.call('run_journey', { steps: [
74
+ { id: 'open', type: 'tap', selector: { accessibility_id: 'saved-session' } },
75
+ { id: 'check', type: 'assert', assertion: { kind: 'element_visible', selector: { accessibility_id: 'idle-detail' } }, timeoutMs: 15000 }
76
+ ], defaults: { verificationMode: 'light' }, responseMode: 'compact' }, [])
77
+ await checkFixtureActivity(candidate)
78
+ const after = (await candidate.call('capture_debug_snapshot', options, [])).raw as CaptureDebugSnapshotRawResponse
79
+ await checkFixtureActivity(candidate)
80
+ verifyFingerprint(after)
81
+ assert.ok(after.ui_tree?.elements.some(element => element.contentDescription === 'idle-detail'))
82
+ assert.ok(after.snapshot_revision > before.snapshot_revision)
83
+ assert.notEqual(after.fingerprint, before.fingerprint)
84
+ nativeFreshness = true
85
+ }
86
+
87
+ async function main() {
88
+ const stop = () => { interrupted = true; for (const session of Object.values(sessions)) session.abort() }
89
+ process.on('SIGINT', stop); process.on('SIGTERM', stop)
90
+ try {
91
+ assert.equal(adb('get-state'), 'device')
92
+ assert.equal(adb('shell', 'getprop', 'ro.kernel.qemu'), '1')
93
+ const apk = buildFixture()
94
+ if (!fixturePresent(adb, app)) {
95
+ installAttempted = true
96
+ adb('install', apk)
97
+ }
98
+ const packagePath = adb('shell', 'pm', 'path', app).match(/^package:(.+)$/)?.[1]
99
+ assert.ok(packagePath)
100
+ fixtureSha = adb('shell', 'sha256sum', packagePath).split(/\s+/)[0]
101
+ assert.match(fixtureSha, /^[a-f0-9]{64}$/)
102
+ metadata = { ...environment(device, apk), fixture_sha256: fixtureSha }
103
+ adb('shell', 'am', 'force-stop', app)
104
+ launched = true
105
+ adb('shell', 'am', 'start', '-W', '-n', `${app}/.MainActivity`)
106
+ for (const session of Object.values(sessions)) await session.connect()
107
+ await sessions.baseline.call('expect_element_visible', { selector: { accessibility_id: 'home-screen' }, timeout_ms: 30000 }, [])
108
+ for (let iteration = 0; iteration < 13; iteration++) {
109
+ const order: Build[] = iteration % 2 ? ['candidate', 'baseline'] : ['baseline', 'candidate']
110
+ for (const build of order) {
111
+ console.log(`Round ${iteration + 1}/13 ${build}`)
112
+ await sample(build, iteration)
113
+ }
114
+ }
115
+ await checkFreshness()
116
+ } catch (error) { fatal = error instanceof UnsafeMeasurement ? error.message : 'SETUP_OR_FRESHNESS_FAILED' }
117
+ finally {
118
+ cleanup.servers = true
119
+ for (const session of Object.values(sessions)) {
120
+ try { await session.close(); cleanup.servers &&= session.transport.cleanupVerified }
121
+ catch { cleanup.servers = false }
122
+ }
123
+ try {
124
+ if (launched || installAttempted) cleanupFixture(adb, app, installAttempted)
125
+ cleanup.fixture = true
126
+ } catch { cleanup.fixture = false }
127
+ process.off('SIGINT', stop); process.off('SIGTERM', stop)
128
+ }
129
+ const summaries = summarizeAttempts(attempts)
130
+ const prior = summaries.baseline
131
+ const next = summaries.candidate
132
+ const sourceStable = metadata?.source_sha256 === treeHash('src') && metadata?.build_sha256 === treeHash('dist') &&
133
+ metadata?.harness_sha256 === treeHash('test/device/automated/latency') && baselineHash === treeHash(dirname(baseline))
134
+ const passed = !fatal && !interrupted && sourceStable && cleanup.servers && cleanup.fixture && nativeFreshness &&
135
+ attempts.length === 26 && attempts.every(attempt => attempt.success) && prior?.total_ms?.count === 10 && next?.total_ms?.count === 10 &&
136
+ next.physical_reads < prior.physical_reads && next.total_ms.p50 <= prior.total_ms.p50 && next.total_ms.p95 < prior.total_ms.p95
137
+ const report = { passed, fatal, interrupted, unrun_attempts: 26 - attempts.length, sourceStable, cleanup, nativeFreshness,
138
+ metadata, expected_fixture_activity: fixtureActivity, fixture_installed_sha256: fixtureSha, baseline: { source_sha256: baselineSource, build_sha256: baselineHash }, summaries, attempts }
139
+ writeFileSync('docs/benchmarks/end-to-end-latency/android-snapshot-pair.json', JSON.stringify(report, null, 2) + '\n')
140
+ process.exitCode = passed ? 0 : 1
141
+ }
142
+
143
+ main().catch(() => { console.error('Snapshot comparison could not write its report'); process.exitCode = 1 })
@@ -0,0 +1,126 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import os from 'node:os'
3
+ import { getIdbCmd } from '../../../../src/utils/ios/utils.js'
4
+ import { BenchmarkSession, UnsafeMeasurement } from '../latency/end-to-end-session.js'
5
+ import { command, treeHash } from '../latency/end-to-end-environment.js'
6
+ import { summarizeAttempts, observationOverhead, type Attempt } from '../latency/end-to-end-report.js'
7
+
8
+ const deviceId = process.env.IOS_LATENCY_DEVICE_ID
9
+ if (!deviceId) throw new Error('Set IOS_LATENCY_DEVICE_ID to an explicit isolated, booted simulator')
10
+ const appId = 'com.apple.Preferences'
11
+ const smoke = process.argv.includes('--smoke')
12
+ const rounds = smoke ? 1 : 13
13
+ const warmups = smoke ? 0 : 3
14
+ const cases = ['screenshot-on', 'screenshot-off', 'tree-on', 'tree-off', 'snapshot-on', 'snapshot-off']
15
+ const attempts: Attempt[] = []
16
+ const sessions = new Map<boolean, BenchmarkSession>()
17
+ const phases: { phase: string; elapsed_ms: number; success: boolean }[] = []
18
+ const startedAt = new Date().toISOString()
19
+ const cleanup = { servers: false, settings: false }
20
+ const controller = new AbortController()
21
+ let fatal: string | undefined
22
+ let sourceStable: boolean | undefined
23
+ let metadata: Record<string, unknown> | undefined
24
+ let startedSettings = false
25
+ const root = 'docs/benchmarks/end-to-end-latency'
26
+ const file = smoke ? 'ios-smoke' : 'ios-observations'
27
+ const fingerprint = () => ({ source: treeHash('src'), build: treeHash('dist'),
28
+ shared_harness: treeHash('test/device/automated/latency'), ios_harness: treeHash('test/device/automated/latency-ios') })
29
+ const identity = fingerprint()
30
+ function idbVersion() {
31
+ try {
32
+ const interpreter = readFileSync(getIdbCmd(), 'utf8').split('\n')[0].replace(/^#!/, '').trim().split(/\s+/)
33
+ return command(interpreter[0], [...interpreter.slice(1), '-c', 'import importlib.metadata; print(importlib.metadata.version("fb-idb"))'])
34
+ } catch { return 'unavailable' }
35
+ }
36
+ async function phase<T>(name: string, operation: () => T | Promise<T>) {
37
+ const start = performance.now()
38
+ let success = false
39
+ try { const value = await operation(); success = true; return value }
40
+ finally { phases.push({ phase: name, elapsed_ms: performance.now() - start, success }) }
41
+ }
42
+ function save() {
43
+ mkdirSync(root, { recursive: true })
44
+ const summaries = summarizeAttempts(attempts)
45
+ const status = fatal ? 'stopped' : attempts.length === rounds * cases.length ? 'finished' : 'in_progress'
46
+ const outcome = fatal || attempts.some(value => !value.success) || controller.signal.aborted ? 'fail' : cleanup.servers && cleanup.settings ? 'pass' : 'pending'
47
+ writeFileSync(`${root}/${file}.json`, JSON.stringify({ status, outcome, started_at: startedAt,
48
+ performance_evidence: !smoke && sourceStable !== false, smoke, warmups, measured_attempts_per_case: smoke ? 1 : 10,
49
+ source_and_build_unchanged: sourceStable, identity, metadata, phases, fatal, cleanup,
50
+ unrun_attempts: rounds * cases.length - attempts.length,
51
+ invocation: `IOS_LATENCY_DEVICE_ID=[simulator] npx tsx test/device/automated/latency-ios/benchmark.ts${smoke ? ' --smoke' : ''}`,
52
+ summaries, observation_overhead: observationOverhead(attempts), attempts }, null, 2) + '\n')
53
+ const rows = Object.entries(summaries).map(([name, value]) => `| ${name} | ${value.total_ms?.count ?? 0}/${value.attempts} | ${value.total_ms?.p50.toFixed(1) ?? 'n/a'} | ${value.total_ms?.p95.toFixed(1) ?? 'n/a'} | ${value.failures} | ${value.warmup_failures} |`)
54
+ writeFileSync(`${root}/${file}.md`, `# iOS standalone observation ${smoke ? 'smoke (not performance evidence)' : 'baseline'}\n\nStatus: ${status}; outcome: ${outcome}. Intended screen: Settings root on an isolated simulator. No fixture journey or physical iOS claim.\n\n| Case | Successful/measured | p50 ms | p95 ms | Failures | Warm-up failures |\n| --- | ---: | ---: | ---: | ---: | ---: |\n${rows.join('\n')}\n\nSee ${file}.json for environment/build identity, all attempts, phase timings and inclusive timing diagnostics. Percentiles use the successful measured count shown for each case. With 1–19 successful samples, nearest-rank p95 is the sample maximum. Zero successful samples yield no percentiles. Failed attempts stay visible and fail the run. Probe comparisons include device variability; nested intervals cannot be summed. Application work remains in round trips. The shared #89 fixture remains required for equivalent static/network journeys.\n`)
55
+ }
56
+ async function sample(name: string, iteration: number) {
57
+ if (controller.signal.aborted) throw new UnsafeMeasurement('INTERRUPTED')
58
+ const value: Attempt = { case: name, iteration, warmup: iteration < warmups, success: false, setup_ms: 0, requests: [] }
59
+ attempts.push(value)
60
+ const session = sessions.get(!name.endsWith('-off'))!
61
+ const setupStarted = performance.now()
62
+ try {
63
+ await session.call('expect_element_visible', { platform: 'ios', selector: { text: 'Settings' }, timeout_ms: 30000, poll_interval_ms: 100 }, [])
64
+ value.setup_ms = performance.now() - setupStarted
65
+ const tool = name.startsWith('screenshot') ? 'capture_screenshot' : name.startsWith('tree') ? 'get_ui_tree' : 'capture_debug_snapshot'
66
+ await session.call(tool, { platform: 'ios', ...(tool === 'capture_debug_snapshot' ? { includeScreenshot: false, includeLogs: false } : {}) }, value.requests)
67
+ value.success = true
68
+ } catch (error) {
69
+ if (!value.setup_ms) value.setup_ms = performance.now() - setupStarted
70
+ value.failure = error instanceof UnsafeMeasurement ? error.message : value.requests.at(-1)?.failure ?? 'SETUP_FAILED'
71
+ if (error instanceof UnsafeMeasurement) throw error
72
+ } finally { save() }
73
+ }
74
+ async function main() {
75
+ const interrupt = () => { controller.abort(); for (const session of sessions.values()) session.abort() }
76
+ process.on('SIGINT', interrupt); process.on('SIGTERM', interrupt)
77
+ try {
78
+ const discovery = JSON.parse(command('xcrun', ['simctl', 'list', 'devices', 'booted', '--json']))
79
+ const devices = discovery.devices as Record<string, { udid: string; name: string; state: string }[]>
80
+ const runtime = Object.entries(devices).find(([, list]) => list.some(device => device.udid === deviceId && device.state === 'Booted'))
81
+ if (!runtime) throw new UnsafeMeasurement('BOOTED_SIMULATOR_REQUIRED')
82
+ const runtimes = JSON.parse(command('xcrun', ['simctl', 'list', 'runtimes', '--json'])).runtimes as { identifier: string; version: string; buildversion: string }[]
83
+ const runtimeInfo = runtimes.find(value => value.identifier === runtime[0])
84
+ metadata = { platform: 'ios', model: runtime[1].find(device => device.udid === deviceId)?.name,
85
+ runtime: runtime[0], os: runtimeInfo?.version, runtime_build: runtimeInfo?.buildversion,
86
+ screen: 'Settings root; English required; caller-provided isolated simulator', node: process.version,
87
+ host: { platform: os.platform(), release: os.release(), arch: os.arch() },
88
+ package_version: JSON.parse(readFileSync('package.json', 'utf8')).version,
89
+ commit: command('git', ['rev-parse', 'HEAD']), working_tree: command('git', ['status', '--porcelain', '--untracked-files=all']),
90
+ tool_versions: { xcode: command('xcodebuild', ['-version']), idb: idbVersion() },
91
+ timeouts: { setup_ms: 30000, call_ms: 300000, readiness_ms: 30000 },
92
+ journey: 'blocked: shared #89 static/network fixture unavailable', physical_ios: 'excluded' }
93
+ await phase('launch_settings', () => command('xcrun', ['simctl', 'launch', deviceId!, appId])); startedSettings = true
94
+ for (const enabled of [true, false]) {
95
+ const session = new BenchmarkSession(deviceId!, enabled)
96
+ sessions.set(enabled, session)
97
+ await phase(`server_startup_metrics_${enabled}`, () => session.connect())
98
+ await phase(`stable_screen_assertion_metrics_${enabled}`, () => session.call('expect_element_visible',
99
+ { platform: 'ios', selector: { text: 'Settings' }, timeout_ms: 30000, poll_interval_ms: 100 }, []))
100
+ if (enabled) {
101
+ const tree = await phase('screen_metadata', () => session.call('get_ui_tree', { platform: 'ios' }, []))
102
+ metadata.resolution = tree.resolution
103
+ }
104
+ }
105
+ save()
106
+ for (let iteration = 0; iteration < rounds; iteration++) for (const name of iteration % 2 ? [...cases].reverse() : cases) {
107
+ console.log(`${smoke ? 'smoke' : `round ${iteration + 1}/${rounds}`} ${name}`)
108
+ await sample(name, iteration)
109
+ }
110
+ } catch (error) { fatal = error instanceof UnsafeMeasurement ? error.message : 'PREREQUISITE_FAILED'; process.exitCode = 1 }
111
+ finally {
112
+ cleanup.servers = true
113
+ for (const session of sessions.values()) {
114
+ try { await session.close(); cleanup.servers &&= session.transport.cleanupVerified }
115
+ catch { cleanup.servers = false }
116
+ }
117
+ try { if (startedSettings) command('xcrun', ['simctl', 'terminate', deviceId!, appId]); cleanup.settings = true }
118
+ catch { cleanup.settings = false }
119
+ sourceStable = JSON.stringify(identity) === JSON.stringify(fingerprint())
120
+ if (!sourceStable) fatal = 'MEASURED_SOURCE_CHANGED'
121
+ if (!cleanup.servers || !cleanup.settings) fatal = 'CLEANUP_FAILED'
122
+ if (fatal || attempts.some(value => !value.success) || controller.signal.aborted) process.exitCode = 1
123
+ save(); process.off('SIGINT', interrupt); process.off('SIGTERM', interrupt)
124
+ }
125
+ }
126
+ main().catch(() => { console.error('iOS observation benchmark failed; inspect report'); process.exitCode = 1 })
@@ -24,9 +24,11 @@ async function run() {
24
24
  const action = parse(await call('start_app', { ...target, appId, verificationMode: 'none', responseMode: 'compact' }))
25
25
  assert.equal(action.success, true, JSON.stringify(action))
26
26
  assert.equal(action.action_id, action.evidence.action_id)
27
- assert.ok(action.evidence.dispatch_started_at_ms)
28
- assert.ok(action.evidence.dispatch_completed_at_ms >= action.evidence.dispatch_started_at_ms)
29
- assert.equal(action.evidence.verification_completed_at_ms, null)
27
+ const retained = parse(await call('get_interaction_diagnostics', { sessionId: action.diagnostics.session_id, resultId: action.diagnostics.result_id }))
28
+ assert.equal(retained.success, true)
29
+ assert.ok(retained.result.evidence.dispatch_started_at_ms)
30
+ assert.ok(retained.result.evidence.dispatch_completed_at_ms >= retained.result.evidence.dispatch_started_at_ms)
31
+ assert.equal(retained.result.evidence.verification_completed_at_ms, null)
30
32
  const actionRef = { sessionId: action.evidence.session_id, actionId: action.evidence.action_id }
31
33
  const screenResponse: any = await call('capture_screenshot', { ...target, actionRef })
32
34
  const screen = parse(screenResponse)
@@ -37,7 +39,7 @@ async function run() {
37
39
  const screenshot = screen.evidence.observations.find((v: any) => v.kind === 'screenshot')
38
40
  assert.equal(screenshot.status, 'captured')
39
41
  assert.equal(screenshot.freshness, 'fresh')
40
- assert.ok(screenshot.capture_started_at_ms >= action.evidence.dispatch_completed_at_ms)
42
+ assert.ok(screenshot.capture_started_at_ms >= retained.result.evidence.dispatch_completed_at_ms)
41
43
  const logsResponse: any = await call('get_logs', { ...target, appId, limit: 5, actionRef })
42
44
  const logs = parse(logsResponse)
43
45
  assert.equal(logs.evidence.action_id, action.action_id)
@@ -59,7 +61,7 @@ async function run() {
59
61
  assert.equal(unassociated.evidence.action_id, null)
60
62
  console.log(JSON.stringify({ status: 'PASS', server: client.getServerVersion(), platform, device: device.id,
61
63
  osVersion: device.osVersion, startingApp: appId, actionId: action.action_id,
62
- phases: { dispatchStarted: action.evidence.dispatch_started_at_ms, dispatchCompleted: action.evidence.dispatch_completed_at_ms,
64
+ phases: { dispatchStarted: retained.result.evidence.dispatch_started_at_ms, dispatchCompleted: retained.result.evidence.dispatch_completed_at_ms,
63
65
  screenshotStarted: screenshot.capture_started_at_ms, screenshotCompleted: screenshot.capture_completed_at_ms },
64
66
  screenshot: screenshot.status, logs: log.status, uiTree: tree.status,
65
67
  disabledComponents: 'omitted', foreignReference: 'unavailable',
@@ -0,0 +1,14 @@
1
+ /** Attempt every restoration step and always close the MCP transport. */
2
+ export async function cleanupReadinessDevice(restore: boolean, adb: (...args: string[]) => unknown, close: () => Promise<void>) {
3
+ const failures: unknown[] = []
4
+ try {
5
+ if (restore) {
6
+ for (const args of [['shell', 'input', 'keyevent', '224'], ['shell', 'wm', 'dismiss-keyguard'], ['shell', 'input', 'keyevent', '3']]) {
7
+ try { adb(...args) } catch (error) { failures.push(error) }
8
+ }
9
+ }
10
+ } finally {
11
+ try { await close() } catch (error) { failures.push(error) }
12
+ }
13
+ return failures
14
+ }
@@ -0,0 +1,78 @@
1
+ import { cleanupReadinessDevice } from './device-readiness-cleanup.js'
2
+ import assert from 'node:assert/strict'
3
+ import { execFileSync } from 'node:child_process'
4
+ import { writeFileSync } from 'node:fs'
5
+ import { createHash } from 'node:crypto'
6
+ import { readFileSync } from 'node:fs'
7
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
8
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
9
+
10
+ // Opt in only on a disposable emulator; never sleep the user's physical phone.
11
+ const deviceId = process.env.MCP_READINESS_DEVICE
12
+ if (!deviceId) console.log('UNRUN device readiness: set MCP_READINESS_DEVICE to a disposable emulator')
13
+ else {
14
+ assert.match(deviceId, /^emulator-\d+$/)
15
+ const adb = (...args: string[]) => execFileSync(process.env.ADB_PATH ?? 'adb', ['-s', deviceId, ...args], { encoding: 'utf8', timeout: 15000 })
16
+ const client = new Client({ name: 'device-readiness-smoke', version: '1.0.0' })
17
+ const observations: unknown[] = []
18
+ const call = async (name: string, args: Record<string, unknown> = {}) => {
19
+ const reply = await client.callTool({ name, arguments: { platform: 'android', deviceId, ...args } }, undefined, { timeout: 45000 })
20
+ const content = reply.content.find(item => item.type === 'text')
21
+ assert.ok(content?.type === 'text')
22
+ const result = JSON.parse(content.text)
23
+ observations.push({ tool: name, args, result })
24
+ return result
25
+ }
26
+ let restore = false
27
+ let journeyError: unknown
28
+ try {
29
+ await client.connect(new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' }))
30
+ const initial = await call('get_app_readiness')
31
+ assert.equal(initial.device_readiness.wakefulness, 'awake')
32
+ assert.equal(initial.device_readiness.lock_state, 'unlocked', 'start with an unlocked disposable emulator')
33
+ restore = true
34
+ adb('shell', 'input', 'keyevent', '223')
35
+ let sleeping = await call('get_app_readiness')
36
+ for (let poll = 0; sleeping.device_readiness.wakefulness === 'transitioning' && poll < 10; poll++) {
37
+ await new Promise(resolve => setTimeout(resolve, 100))
38
+ sleeping = await call('get_app_readiness')
39
+ }
40
+ assert.equal(sleeping.device_readiness.wakefulness, 'asleep')
41
+ assert.equal(sleeping.device_readiness.input_ready, false)
42
+ const launch = await call('start_app', { appId: 'com.android.settings', verificationMode: 'none', responseMode: 'compact', actionTimeoutMs: 10000 })
43
+ assert.equal(launch.success, true)
44
+ assert.equal(launch.device_readiness.input_ready, false, 'launch dispatch must not erase the blocker')
45
+ for (const action of [
46
+ { name: 'tap', args: { x: 10, y: 10 } },
47
+ { name: 'swipe', args: { x1: 10, y1: 10, x2: 20, y2: 20, duration: 100 } }
48
+ ]) {
49
+ const input = await call(action.name, { ...action.args, verificationMode: 'none', responseMode: 'compact', actionTimeoutMs: 10000 })
50
+ assert.equal(input.success, true)
51
+ assert.ok(input.device_readiness.blockers.includes('DEVICE_ASLEEP'), 'ordinary input retains its resolved device blocker')
52
+ }
53
+ const wait = await call('wait_for_app_ready', { timeoutMs: 1200, pollIntervalMs: 100 })
54
+ assert.equal(wait.success, false)
55
+ assert.equal(wait.failure_code, 'READINESS_TIMEOUT')
56
+ assert.ok(wait.device_readiness.blockers.includes('DEVICE_ASLEEP'), 'timeout retains the last observed blocker')
57
+ adb('shell', 'input', 'keyevent', '224')
58
+ const awake = await call('get_app_readiness')
59
+ assert.equal(awake.device_readiness.wakefulness, 'awake')
60
+ assert.ok(['locked', 'unlocked'].includes(awake.device_readiness.lock_state))
61
+ console.log(awake.device_readiness.lock_state === 'locked' ? 'PASS awake keyguard observed' : 'UNRUN real locked state: this emulator wakes unlocked; security settings were not changed')
62
+ // The default emulator has no passcode. Dismiss only its test keyguard to
63
+ // restore the explicitly required unlocked starting state.
64
+ adb('shell', 'wm', 'dismiss-keyguard')
65
+ const recovered = await call('wait_for_app_ready', { timeoutMs: 15000, stabilityMs: 100, pollIntervalMs: 100 })
66
+ assert.equal(recovered.success, true, JSON.stringify(recovered))
67
+ assert.equal(recovered.device_readiness.lock_state, 'unlocked')
68
+ assert.equal(recovered.device_readiness.input_ready, null)
69
+ } catch (error) {
70
+ journeyError = error
71
+ throw error
72
+ } finally {
73
+ const failures = await cleanupReadinessDevice(restore, adb, () => client.close())
74
+ if (process.env.MCP_READINESS_EVIDENCE) writeFileSync(process.env.MCP_READINESS_EVIDENCE, JSON.stringify({ deviceId, server: 'dist/server.js', server_sha256: createHash('sha256').update(readFileSync('dist/server.js')).digest('hex'), runtime: process.version, observations, cleanup: failures.length ? 'failed; all restoration steps and client close attempted' : 'wake, dismiss disposable keyguard, Home; no security settings changed' }, null, 2) + '\n')
75
+ if (failures.length) throw new AggregateError(journeyError ? [journeyError, ...failures] : failures, 'Device readiness cleanup failed')
76
+ }
77
+ console.log('PASS Android readiness: sleeping launch, blocked wait, awake recovery and cleanup')
78
+ }
@@ -62,8 +62,8 @@ async function run() {
62
62
  ]})
63
63
  if (!journey.success) journeyFailure = new Error(`Traced fixture input journey failed at ${journey.stopped_at_step_id}; ${journey.steps[0].result?.failure_code}`)
64
64
  else assert.equal(journey.steps[1].status,'passed')
65
- assert.equal(journey.evidence.trace_context.trace_id,recording.trace_id)
66
- assert.equal(journey.steps[0].result.evidence.trace_context.trace_id,recording.trace_id)
65
+ assert.equal(journey.evidence.trace_id,recording.trace_id)
66
+ assert.equal(journey.steps[0].result.evidence.trace_id,recording.trace_id)
67
67
  assert.equal(journey.system_trace.coverage,'covered');assert.ok(recording.recording_ready_at_ms<=journeySubmitted)
68
68
  const completed=await stop(recording.trace_id)
69
69
  assert.equal(completed.device.id,deviceId);assert.equal(completed.app_id,readinessAppId)
@@ -39,8 +39,8 @@ async function run() {
39
39
  ] })
40
40
  assert.equal(journey.success, true, JSON.stringify(journey))
41
41
  assert.equal(journey.steps[1].status, 'passed', 'The baseline Settings-owned UI element must remain semantically visible')
42
- assert.equal(journey.evidence.trace_context.trace_id, start.trace_id)
43
- assert.equal(journey.steps[0].result.evidence.trace_context.trace_id, start.trace_id)
42
+ assert.equal(journey.evidence.trace_id, start.trace_id)
43
+ assert.equal(journey.steps[0].result.evidence.trace_id, start.trace_id)
44
44
  assert.equal(journey.system_trace.coverage, 'covered')
45
45
  const stop = await call('stop_system_trace', { traceId: start.trace_id })
46
46
  if (stop.artifact) artifacts.push(stop.artifact.path)