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.
- package/README.md +10 -0
- package/agent-plugin/.antigravity-plugin/plugin.json +1 -1
- package/agent-plugin/.claude-plugin/plugin.json +2 -2
- package/agent-plugin/.codex-plugin/plugin.json +1 -1
- package/agent-plugin/.cursor-plugin/plugin.json +2 -2
- package/agent-plugin/.mcp.json +1 -1
- package/agent-plugin/cursor/.cursor-plugin/plugin.json +2 -2
- package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +10 -2
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/mcp_config.json +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +10 -2
- package/dist/contracts/device-readiness.js +1 -0
- package/dist/interact/waits/ui-change-wait.js +9 -2
- package/dist/interact/waits/ui-wait.js +7 -3
- package/dist/manage/index.js +3 -0
- package/dist/observe/device-readiness.js +116 -0
- package/dist/observe/evidence.js +31 -0
- package/dist/observe/ios-trace/sessions.js +5 -3
- package/dist/observe/readiness-service.js +21 -9
- package/dist/observe/readiness.js +30 -0
- package/dist/observe/screenshot-encoding.js +4 -0
- package/dist/observe/snapshot-collection.js +7 -2
- package/dist/server/definitions/manage.js +3 -2
- package/dist/server/handlers/action-execution.js +23 -14
- package/dist/server/handlers/compact-interaction.js +106 -0
- package/dist/server/handlers/compact-state.js +47 -0
- package/dist/server/handlers/input.js +2 -0
- package/dist/server/handlers/interact.js +2 -0
- package/dist/server/handlers/interaction-diagnostics.js +62 -0
- package/dist/server/handlers/journey.js +18 -20
- package/dist/server/handlers/observe.js +2 -1
- package/dist/server/handlers/tool-handlers.js +9 -5
- package/dist/server/schemas/interaction-diagnostics.js +9 -0
- package/dist/server/tool-definitions.js +9 -4
- package/dist/server-core.js +5 -2
- package/dist/server.js +1 -1
- package/dist/system/android.js +19 -15
- package/dist/system/index.js +2 -1
- package/dist/utils/android/utils.js +7 -16
- package/dist/utils/app-installation.js +77 -0
- package/dist/utils/benchmark-metrics.js +43 -0
- package/dist/utils/exec.js +4 -0
- package/dist/utils/ios/utils.js +4 -11
- package/dist/utils/resolve-device.js +3 -2
- package/dist/utils/timing.js +3 -3
- package/docs/CHANGELOG.md +25 -0
- package/docs/benchmarks/README.md +1 -0
- package/docs/benchmarks/end-to-end-latency/README.md +98 -0
- package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.json +6532 -0
- package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.md +30 -0
- package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.json +15901 -0
- package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.md +14 -0
- package/docs/benchmarks/end-to-end-latency/baseline.json +233843 -0
- package/docs/benchmarks/end-to-end-latency/baseline.md +21 -0
- package/docs/benchmarks/end-to-end-latency/investigation.md +128 -0
- package/docs/benchmarks/end-to-end-latency/ios-smoke.json +84 -0
- package/docs/benchmarks/end-to-end-latency/ios-smoke.md +9 -0
- package/docs/benchmarks/end-to-end-latency/smoke.json +19601 -0
- package/docs/benchmarks/end-to-end-latency/smoke.md +21 -0
- package/docs/specs/031-ios-system-traces/plan.md +28 -0
- package/docs/specs/033-device-readiness-blockers/android-evidence.json +437 -0
- package/docs/specs/033-device-readiness-blockers/android-physical-evidence.json +28 -0
- package/docs/specs/033-device-readiness-blockers/android-physical-power-evidence.json +29 -0
- package/docs/specs/033-device-readiness-blockers/intent.md +41 -0
- package/docs/specs/033-device-readiness-blockers/ios-investigation.md +55 -0
- package/docs/specs/033-device-readiness-blockers/ios-probe-evidence.json +24 -0
- package/docs/specs/033-device-readiness-blockers/ios-tool-evidence.json +73 -0
- package/docs/specs/033-device-readiness-blockers/plan.md +249 -0
- package/docs/specs/033-device-readiness-blockers/spec.md +134 -0
- package/docs/specs/034-app-installation-status/intent.md +41 -0
- package/docs/specs/034-app-installation-status/native-evidence.json +654 -0
- package/docs/specs/034-app-installation-status/plan.md +73 -0
- package/docs/specs/034-app-installation-status/spec.md +114 -0
- package/docs/specs/035-compact-interaction-results/baseline.md +45 -0
- package/docs/specs/035-compact-interaction-results/intent.md +38 -0
- package/docs/specs/035-compact-interaction-results/plan.md +43 -0
- package/docs/specs/035-compact-interaction-results/spec.md +118 -0
- package/docs/specs/035-compact-interaction-results/verification.md +111 -0
- package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-amendment.md +60 -0
- package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-plan.md +32 -0
- package/docs/specs/036-end-to-end-interaction-latency/intent.md +37 -0
- package/docs/specs/036-end-to-end-interaction-latency/plan.md +59 -0
- package/docs/specs/036-end-to-end-interaction-latency/spec.md +126 -0
- package/docs/specs/036-end-to-end-interaction-latency/verification.md +87 -0
- package/docs/tools/TOOLS.md +1 -0
- package/docs/tools/compact-interactions.md +56 -0
- package/docs/tools/correlated-evidence.md +6 -0
- package/docs/tools/interact.md +17 -8
- package/docs/tools/manage.md +10 -2
- package/docs/tools/observe.md +42 -2
- package/docs/tools/system.md +6 -0
- package/package.json +1 -1
- package/src/contracts/device-readiness.ts +8 -0
- package/src/interact/waits/ui-change-wait.ts +7 -2
- package/src/interact/waits/ui-wait.ts +6 -3
- package/src/manage/index.ts +2 -0
- package/src/observe/device-readiness.ts +96 -0
- package/src/observe/evidence.ts +30 -1
- package/src/observe/ios-trace/sessions.ts +4 -2
- package/src/observe/readiness-service.ts +21 -9
- package/src/observe/readiness.ts +33 -0
- package/src/observe/screenshot-encoding.ts +4 -0
- package/src/observe/snapshot-collection.ts +7 -2
- package/src/server/common.ts +2 -2
- package/src/server/definitions/manage.ts +3 -2
- package/src/server/handlers/action-execution.ts +22 -14
- package/src/server/handlers/compact-interaction.ts +91 -0
- package/src/server/handlers/compact-state.ts +43 -0
- package/src/server/handlers/input.ts +2 -0
- package/src/server/handlers/interact.ts +2 -0
- package/src/server/handlers/interaction-diagnostics.ts +50 -0
- package/src/server/handlers/journey.ts +15 -17
- package/src/server/handlers/observe.ts +2 -1
- package/src/server/handlers/payload.ts +2 -1
- package/src/server/handlers/tool-handlers.ts +9 -5
- package/src/server/schemas/interaction-diagnostics.ts +10 -0
- package/src/server/tool-definitions.ts +8 -4
- package/src/server-core.ts +5 -2
- package/src/server.ts +1 -1
- package/src/system/android.ts +18 -11
- package/src/system/index.ts +2 -1
- package/src/types.ts +2 -0
- package/src/utils/android/utils.ts +8 -10
- package/src/utils/app-installation.ts +73 -0
- package/src/utils/benchmark-metrics.ts +42 -0
- package/src/utils/exec.ts +5 -0
- package/src/utils/ios/utils.ts +4 -10
- package/src/utils/resolve-device.ts +4 -2
- package/src/utils/timing.ts +3 -2
- package/test/device/automated/debugger/runtime.android.test.ts +5 -0
- package/test/device/automated/debugger/runtime.ios.test.ts +7 -1
- package/test/device/automated/interact/compact-results.smoke.ts +58 -0
- package/test/device/automated/latency/end-to-end-environment.ts +61 -0
- package/test/device/automated/latency/end-to-end-report.ts +90 -0
- package/test/device/automated/latency/end-to-end-session.ts +98 -0
- package/test/device/automated/latency/end-to-end.ts +149 -0
- package/test/device/automated/latency/owned-transport.ts +81 -0
- package/test/device/automated/latency/snapshot-pair.ts +143 -0
- package/test/device/automated/latency-ios/benchmark.ts +126 -0
- package/test/device/automated/observe/correlated-evidence.smoke.ts +7 -5
- package/test/device/automated/observe/device-readiness-cleanup.ts +14 -0
- package/test/device/automated/observe/device-readiness.android.smoke.ts +78 -0
- package/test/device/automated/observe/ios-system-trace.smoke.ts +2 -2
- package/test/device/automated/observe/system-trace.smoke.ts +2 -2
- package/test/fixtures/contracts/tool-definitions.json +51 -15
- package/test/fixtures/latency-journey-app/build.ts +3 -3
- package/test/unit/debugger/handle-guards.test.ts +2 -1
- package/test/unit/interact/ui-change-readiness-interruption.test.ts +37 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +66 -12
- package/test/unit/observe/device-readiness-cleanup.test.ts +24 -0
- package/test/unit/observe/device-readiness.test.ts +97 -0
- package/test/unit/observe/ios-trace-sessions.test.ts +23 -0
- package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
- package/test/unit/server/compact-interaction.test.ts +87 -0
- package/test/unit/server/compact-journey-evidence.test.ts +40 -0
- package/test/unit/server/compact-review-regressions.test.ts +78 -0
- package/test/unit/server/conditional_actions.test.ts +7 -3
- package/test/unit/server/correlated-evidence.test.ts +18 -6
- package/test/unit/server/end-to-end-report.test.ts +46 -0
- package/test/unit/server/input-device-readiness.test.ts +55 -0
- package/test/unit/server/interaction-diagnostics.test.ts +45 -0
- package/test/unit/server/owned-benchmark-transport.test.ts +32 -0
- package/test/unit/server/readiness-retained-deadline.test.ts +32 -0
- package/test/unit/server/readiness.test.ts +24 -1
- package/test/unit/server/run_journey.test.ts +2 -1
- package/test/unit/server/selector_tap_target.test.ts +2 -1
- package/test/unit/server/system-trace.test.ts +6 -4
- package/test/unit/utils/app-installation.test.ts +78 -0
- package/test/unit/utils/benchmark-metrics.test.ts +36 -0
- package/test/unit/utils/resolve_device.test.ts +11 -0
|
@@ -51,6 +51,8 @@ export async function listDevices(platform?: "android" | "ios", appId?: string):
|
|
|
51
51
|
|
|
52
52
|
const deviceContext = new AsyncLocalStorage<DeviceInfo>()
|
|
53
53
|
|
|
54
|
+
export function currentResolvedDevice() { return deviceContext.getStore() }
|
|
55
|
+
|
|
54
56
|
export function withResolvedDevice<T>(device: DeviceInfo, operation: () => Promise<T>): Promise<T> {
|
|
55
57
|
bindEvidenceDevice(device)
|
|
56
58
|
return deviceContext.run(device, operation)
|
|
@@ -92,7 +94,7 @@ function selectDevice(devices: DeviceInfo[], platform: string, appId?: string, p
|
|
|
92
94
|
if (prefer === "emulator") candidates = candidates.filter(d => d.simulator)
|
|
93
95
|
|
|
94
96
|
if (appId) {
|
|
95
|
-
const installed = candidates.filter(d => d.appInstalled)
|
|
97
|
+
const installed = candidates.filter(d => d.appInstallation ? d.appInstallation.status === 'installed' && d.appInstallation.appId === appId && d.appInstallation.deviceId === d.id : d.appInstalled === true)
|
|
96
98
|
if (installed.length > 0) candidates = installed
|
|
97
99
|
}
|
|
98
100
|
|
|
@@ -117,7 +119,7 @@ function selectDevice(devices: DeviceInfo[], platform: string, appId?: string, p
|
|
|
117
119
|
return candidates[0]
|
|
118
120
|
}
|
|
119
121
|
|
|
120
|
-
const list = candidates.map(d => ({ id: d.id, platform: d.platform, osVersion: d.osVersion, model: d.model, simulator: d.simulator, appInstalled: d.appInstalled }))
|
|
122
|
+
const list = candidates.map(d => ({ id: d.id, platform: d.platform, osVersion: d.osVersion, model: d.model, simulator: d.simulator, appInstalled: d.appInstalled, appInstallation: d.appInstallation }))
|
|
121
123
|
const err = Object.assign(new Error(`Multiple matching devices found: ${JSON.stringify(list, null, 2)}`), { devices: list })
|
|
122
124
|
throw err
|
|
123
125
|
}
|
package/src/utils/timing.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { recordBenchmarkSpan } from './benchmark-metrics.js'
|
|
2
3
|
const timings = new AsyncLocalStorage<Record<string, number>>()
|
|
3
4
|
export function withTiming<T>(values: Record<string, number>, operation: () => Promise<T>) { return timings.run(values, operation) }
|
|
4
5
|
export function recordTiming(component: string, milliseconds: number) {
|
|
6
|
+
recordBenchmarkSpan(component, milliseconds)
|
|
5
7
|
const values = timings.getStore()
|
|
6
8
|
if (values) values[component] = (values[component] ?? 0) + milliseconds
|
|
7
9
|
}
|
|
@@ -9,7 +11,6 @@ export async function measure<T>(component: string, operation: () => Promise<T>)
|
|
|
9
11
|
const start = performance.now()
|
|
10
12
|
try { return await operation() }
|
|
11
13
|
finally {
|
|
12
|
-
|
|
13
|
-
if (values) values[component] = (values[component] ?? 0) + performance.now() - start
|
|
14
|
+
recordTiming(component, performance.now() - start)
|
|
14
15
|
}
|
|
15
16
|
}
|
|
@@ -104,6 +104,11 @@ if (!process.env.MCP_DEBUGGER_DEVICE) {
|
|
|
104
104
|
assert.equal(object.value.type, 'java.lang.Object'); assert.equal(typeof object.value.object_id, 'string')
|
|
105
105
|
const readiness = await call('get_app_readiness', { platform: 'android', deviceId })
|
|
106
106
|
assert.equal(readiness.readiness.state, 'debugger_paused')
|
|
107
|
+
assert.deepEqual(readiness.device_readiness.blockers, ['DEBUGGER_PAUSED'])
|
|
108
|
+
assert.equal(readiness.device_readiness.input_ready, false)
|
|
109
|
+
const readyWait = await call('wait_for_app_ready', { platform: 'android', deviceId })
|
|
110
|
+
assert.equal(readyWait.failure_code, 'DEBUGGER_PAUSED')
|
|
111
|
+
assert.deepEqual(readyWait.device_readiness.blockers, ['DEBUGGER_PAUSED'])
|
|
107
112
|
for (const name of ['tap', 'wait_for_ui', 'terminate_app', 'run_journey']) {
|
|
108
113
|
const blocked = await call(name, { platform: 'android', deviceId, x: 5, y: 5, appId: debuggerAppId })
|
|
109
114
|
assert.equal(blocked.failure_code, 'DEBUGGER_PAUSED', name)
|
|
@@ -134,7 +134,13 @@ if (!process.env.MCP_IOS_DEBUGGER_DEVICE) {
|
|
|
134
134
|
assert.equal(storedTap.delivery_status, 'not_delivered'); assert.equal(storedTap.dispatch_started, false)
|
|
135
135
|
assert.deepEqual(storedTap.debugger.device, { platform: 'ios', id: deviceId })
|
|
136
136
|
console.log('PASS paused stored iOS handle without platform/device hints rejected before dispatch')
|
|
137
|
-
|
|
137
|
+
const readiness = await call('get_app_readiness', { platform: 'ios', deviceId })
|
|
138
|
+
assert.equal(readiness.readiness.state, 'debugger_paused')
|
|
139
|
+
assert.deepEqual(readiness.device_readiness.blockers, ['DEBUGGER_PAUSED'])
|
|
140
|
+
assert.equal(readiness.device_readiness.input_ready, false)
|
|
141
|
+
const readyWait = await call('wait_for_app_ready', { platform: 'ios', deviceId })
|
|
142
|
+
assert.equal(readyWait.failure_code, 'DEBUGGER_PAUSED')
|
|
143
|
+
assert.deepEqual(readyWait.device_readiness.blockers, ['DEBUGGER_PAUSED'])
|
|
138
144
|
await remove(bp)
|
|
139
145
|
for (const kind of ['into', 'over', 'out']) {
|
|
140
146
|
const response = await call('step_debugger', { ...session(), stopId: event.stop_id, threadId: event.thread_id, kind })
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
3
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
4
|
+
|
|
5
|
+
const client = new Client({ name: 'compact-results-smoke', version: '1.0.0' })
|
|
6
|
+
const transport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
|
|
7
|
+
async function call(name: string, args: Record<string, unknown> = {}) {
|
|
8
|
+
const response = await client.callTool({ name, arguments: args }, undefined, { timeout: 120000 })
|
|
9
|
+
const item = response.content.find(item => item.type === 'text')
|
|
10
|
+
assert(item?.type === 'text')
|
|
11
|
+
return { value: JSON.parse(item.text), bytes: Buffer.byteLength(item.text) }
|
|
12
|
+
}
|
|
13
|
+
async function details(value: Record<string, any>) {
|
|
14
|
+
const result = await call('get_interaction_diagnostics', { sessionId: value.diagnostics.session_id, resultId: value.diagnostics.result_id })
|
|
15
|
+
assert.equal(result.value.success, true)
|
|
16
|
+
return result.value.result
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
await client.connect(transport)
|
|
20
|
+
const discovery = await call('list_devices')
|
|
21
|
+
for (const platform of ['android', 'ios']) {
|
|
22
|
+
const device = discovery.value.devices.find((d: Record<string, any>) => d.platform === platform && d.simulator && (platform === 'android' || d.booted))
|
|
23
|
+
assert(device, `A booted ${platform} simulator/emulator is required`)
|
|
24
|
+
const target = { platform, deviceId: device.id }, appId = platform === 'android' ? 'com.android.settings' : 'com.apple.Preferences'
|
|
25
|
+
const none = await call('start_app', { ...target, appId, verificationMode: 'none', responseMode: 'compact' })
|
|
26
|
+
assert.equal(none.value.success, true)
|
|
27
|
+
assert.equal(none.value.verification.status, 'not_requested')
|
|
28
|
+
assert.equal(none.value.observed_state.status, 'unknown')
|
|
29
|
+
assert.ok(none.bytes <= 1280 + Buffer.byteLength(device.id))
|
|
30
|
+
assert.equal((await details(none.value)).evidence.action_id, none.value.action_id)
|
|
31
|
+
const observed = await call('start_app', { ...target, appId, verificationMode: 'light', verificationTimeoutMs: 30000, responseMode: 'compact' })
|
|
32
|
+
assert.equal(observed.value.success, true)
|
|
33
|
+
assert.equal(observed.value.observed_state.status, 'observed', JSON.stringify(observed.value))
|
|
34
|
+
assert.ok(observed.bytes <= 1280 + Buffer.byteLength(device.id))
|
|
35
|
+
const blocked = await call('tap_element', { ...target, selector: { text: '__mcp_absent_compact_fixture__' }, verificationMode: 'none', responseMode: 'compact', actionTimeoutMs: 1000 })
|
|
36
|
+
assert.equal(blocked.value.success, false)
|
|
37
|
+
assert.equal(blocked.value.dispatch_started, false)
|
|
38
|
+
assert.equal(blocked.value.observed_state.status, 'unknown')
|
|
39
|
+
assert.equal((await details(blocked.value)).success, false)
|
|
40
|
+
const tree = await call('get_ui_tree', target)
|
|
41
|
+
const element = tree.value.elements?.find((e: Record<string, any>) => e.visible && typeof e.text === 'string' && e.text.trim())
|
|
42
|
+
assert(element, 'Settings must expose a visible text target')
|
|
43
|
+
const journey = await call('run_journey', { ...target, defaults: { verificationMode: 'none' }, steps: [
|
|
44
|
+
{ id: 'open', type: 'start_app', appId },
|
|
45
|
+
{ id: 'observe', type: 'assert', assertion: { kind: 'element_visible', selector: { text: element.text } }, timeoutMs: 15000 }
|
|
46
|
+
] })
|
|
47
|
+
assert.equal(journey.value.success, true, JSON.stringify(journey.value))
|
|
48
|
+
assert.equal(journey.value.steps[1].result.observed_state.status, 'observed')
|
|
49
|
+
const retainedJourney = await details(journey.value)
|
|
50
|
+
const retainedStep = await details(journey.value.steps[0].result)
|
|
51
|
+
assert.equal(retainedJourney.steps[0].result.evidence.action_id, retainedStep.evidence.action_id)
|
|
52
|
+
assert.ok(retainedStep.evidence.dispatch_completed_at_ms)
|
|
53
|
+
console.log(JSON.stringify({ status: 'PASS', platform, device: device.id, os: device.osVersion, server: client.getServerVersion(),
|
|
54
|
+
bytes: { none: none.bytes, observed: observed.bytes, blocked: blocked.bytes, journey: journey.bytes },
|
|
55
|
+
observed: observed.value.observed_state, journeyAssertion: journey.value.steps[1].result.observed_state,
|
|
56
|
+
checks: ['dispatch-only unknown', 'fresh post-observation', 'no-dispatch failure', 'journey', 'read-only original/child lookup'], cleanup: 'Settings remains foreground; no app data/settings changed' }))
|
|
57
|
+
}
|
|
58
|
+
} finally { await client.close() }
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
|
|
7
|
+
export function command(command: string, args: string[], timeout = 30000) {
|
|
8
|
+
return execFileSync(command, args, { encoding: 'utf8', timeout, killSignal: 'SIGKILL', stdio: 'pipe' }).trim()
|
|
9
|
+
}
|
|
10
|
+
export function fixturePresent(adb: (...args: string[]) => string, app: string) {
|
|
11
|
+
return adb('shell', 'pm', 'list', 'packages', app).split('\n').includes(`package:${app}`)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function cleanupFixture(adb: (...args: string[]) => string, app: string, remove: boolean) {
|
|
15
|
+
if (!fixturePresent(adb, app)) return false
|
|
16
|
+
adb('shell', 'am', 'force-stop', app)
|
|
17
|
+
if (!remove) return false
|
|
18
|
+
adb('uninstall', app)
|
|
19
|
+
if (fixturePresent(adb, app)) throw new Error('FIXTURE_REMOVAL_FAILED')
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const sha256 = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex')
|
|
24
|
+
export function treeHash(root: string): string {
|
|
25
|
+
const hash = createHash('sha256')
|
|
26
|
+
const visit = (directory: string) => {
|
|
27
|
+
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
28
|
+
const file = path.join(directory, entry.name)
|
|
29
|
+
if (entry.isDirectory()) visit(file)
|
|
30
|
+
else if (entry.isFile()) { hash.update(path.relative(root, file)); hash.update(readFileSync(file)) }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
visit(root)
|
|
34
|
+
return hash.digest('hex')
|
|
35
|
+
}
|
|
36
|
+
function version(tool: string, args: string[]) {
|
|
37
|
+
try { return command(tool, args).split('\n').filter(line => !/Installed as/.test(line)).slice(0, 3).join('\n') }
|
|
38
|
+
catch { return 'unavailable' }
|
|
39
|
+
}
|
|
40
|
+
export function environment(deviceId: string, apk: string) {
|
|
41
|
+
const adb = (...args: string[]) => command('adb', ['-s', deviceId, ...args])
|
|
42
|
+
const connected = command('adb', ['devices']).split('\n').filter(line => /\sdevice$/.test(line.trim()))
|
|
43
|
+
return {
|
|
44
|
+
commit: command('git', ['rev-parse', 'HEAD']),
|
|
45
|
+
working_tree: command('git', ['status', '--porcelain', '--untracked-files=all']),
|
|
46
|
+
source_sha256: treeHash('src'), build_sha256: treeHash('dist'), harness_sha256: treeHash('test/device/automated/latency'), fixture_sha256: sha256(apk),
|
|
47
|
+
package_version: JSON.parse(readFileSync('package.json', 'utf8')).version,
|
|
48
|
+
node: process.version, host: { platform: os.platform(), release: os.release(), arch: os.arch() },
|
|
49
|
+
platform: 'android', model: adb('shell', 'getprop', 'ro.product.model'),
|
|
50
|
+
discovery_topology: { android_emulators: connected.filter(line => line.startsWith('emulator-')).length,
|
|
51
|
+
android_other_devices: connected.filter(line => !line.startsWith('emulator-')).length },
|
|
52
|
+
os: adb('shell', 'getprop', 'ro.build.version.release'), api: adb('shell', 'getprop', 'ro.build.version.sdk'),
|
|
53
|
+
avd: adb('emu', 'avd', 'name').split('\n')[0], display: adb('shell', 'wm', 'size'), density: adb('shell', 'wm', 'density'),
|
|
54
|
+
tool_versions: { adb: version('adb', ['version']), javac: version(process.env.JAVA_HOME ? path.join(process.env.JAVA_HOME, 'bin', 'javac') : 'javac', ['--version']), android_build_tools: '36.0.0',
|
|
55
|
+
emulator: version(path.join(process.env.ANDROID_HOME ?? path.join(os.homedir(), 'Library/Android/sdk'), 'emulator/emulator'), ['-version']),
|
|
56
|
+
android_compile_platform: '36', xcode: version('xcodebuild', ['-version']), idb: version('idb', ['--version']) },
|
|
57
|
+
timeouts: { setup_ms: 30000, call_ms: 300000, action_ms: 30000, wait_ms: 10000, verification_ms: 60000,
|
|
58
|
+
adb_ms: Number(process.env.MCP_ADB_TIMEOUT ?? 20000) },
|
|
59
|
+
ios: { journey: 'blocked: shared #89 fixture unavailable', observations: 'blocked: idb executable unavailable and no booted simulator at investigation', physical_device: 'excluded' }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { summarize } from './report.js'
|
|
2
|
+
|
|
3
|
+
export interface Span { stage: string; start_ms: number; end_ms: number }
|
|
4
|
+
export interface RequestMetric {
|
|
5
|
+
event: 'latency_metric'; request_id: string; tool: string
|
|
6
|
+
handler_completed: boolean; elapsed_ms: number; spans: Span[]
|
|
7
|
+
}
|
|
8
|
+
export interface TreeMetric {
|
|
9
|
+
event: 'ui_tree_metric'; type: string; stage?: string; elapsed_ms?: number
|
|
10
|
+
acquisition_id?: string; success?: boolean; durations_ms?: Record<string, number>
|
|
11
|
+
}
|
|
12
|
+
export interface RequestSample {
|
|
13
|
+
tool: string; elapsed_ms: number; response_bytes?: number; success: boolean
|
|
14
|
+
failure?: string; failed_step?: string; failure_code?: string; metrics: RequestMetric[]; tree: TreeMetric[]
|
|
15
|
+
}
|
|
16
|
+
export interface Attempt {
|
|
17
|
+
case: string; iteration: number; warmup: boolean; success: boolean
|
|
18
|
+
failure?: string; setup_ms: number; requests: RequestSample[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function unionDuration(spans: Span[], limit: number) {
|
|
22
|
+
const sorted = spans.map(span => [Math.max(0, span.start_ms), Math.min(limit, span.end_ms)])
|
|
23
|
+
.filter(([start, end]) => Number.isFinite(start) && Number.isFinite(end) && end >= start)
|
|
24
|
+
.sort((a, b) => a[0] - b[0])
|
|
25
|
+
let end = 0, total = 0
|
|
26
|
+
for (const [start, stop] of sorted) { total += Math.max(0, stop - Math.max(end, start)); end = Math.max(end, stop) }
|
|
27
|
+
return total
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function summarizeAttempts(attempts: Attempt[]) {
|
|
31
|
+
return Object.fromEntries([...new Set(attempts.map(attempt => attempt.case))].map(name => {
|
|
32
|
+
const selected = attempts.filter(attempt => attempt.case === name)
|
|
33
|
+
const measured = selected.filter(attempt => !attempt.warmup)
|
|
34
|
+
const successful = measured.filter(attempt => attempt.success)
|
|
35
|
+
const requests = successful.flatMap(attempt => attempt.requests)
|
|
36
|
+
const metrics = requests.flatMap(request => request.metrics)
|
|
37
|
+
const tree = requests.flatMap(request => request.tree)
|
|
38
|
+
const stages = [...new Set(metrics.flatMap(metric => metric.spans.map(span => span.stage)))]
|
|
39
|
+
const treeStages = [...new Set(tree.filter(event => event.type === 'stage').map(event => event.stage!))]
|
|
40
|
+
return [name, {
|
|
41
|
+
attempts: measured.length, failures: measured.filter(attempt => !attempt.success).length,
|
|
42
|
+
warmup_failures: selected.filter(attempt => attempt.warmup && !attempt.success).length,
|
|
43
|
+
total_ms: summarize(successful.map(attempt => attempt.requests.reduce((sum, request) => sum + request.elapsed_ms, 0))),
|
|
44
|
+
response_bytes: summarize(successful.map(attempt => attempt.requests.reduce((sum, request) => sum + (request.response_bytes ?? 0), 0))),
|
|
45
|
+
handler_ms: summarize(metrics.map(metric => metric.elapsed_ms)),
|
|
46
|
+
handler_unattributed_ms: summarize(metrics.map(metric => Math.max(0, metric.elapsed_ms - unionDuration(metric.spans, metric.elapsed_ms)))),
|
|
47
|
+
client_minus_handler_ms: summarize(requests.filter(request => request.metrics.length === 1)
|
|
48
|
+
.map(request => request.elapsed_ms - request.metrics[0].elapsed_ms)),
|
|
49
|
+
inclusive_components_ms: Object.fromEntries(stages.map(stage => [stage, summarize(metrics.map(metric =>
|
|
50
|
+
metric.spans.filter(span => span.stage === stage).reduce((sum, span) => sum + span.end_ms - span.start_ms, 0)))])),
|
|
51
|
+
physical_reads: tree.filter(event => event.type === 'physical_read_completed').length,
|
|
52
|
+
failed_physical_reads: tree.filter(event => event.type === 'physical_read_completed' && event.success === false).length,
|
|
53
|
+
tree_stages_ms: Object.fromEntries(treeStages.map(stage => [stage, summarize(tree.filter(event => event.type === 'stage' && event.stage === stage).map(event => event.elapsed_ms ?? 0))])),
|
|
54
|
+
tree_acquisition_ms: summarize(tree
|
|
55
|
+
.filter(event => event.type === 'acquisition_completed').map(event => event.elapsed_ms ?? 0)),
|
|
56
|
+
missing_request_metrics: requests.filter(request => request.metrics.length !== 1).length
|
|
57
|
+
}]
|
|
58
|
+
}))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function observationOverhead(attempts: Attempt[]) {
|
|
62
|
+
return Object.fromEntries(['screenshot', 'tree', 'snapshot'].map(name => {
|
|
63
|
+
const pairs = attempts.filter(attempt => attempt.case === `${name}-on` && !attempt.warmup && attempt.success)
|
|
64
|
+
.flatMap(on => {
|
|
65
|
+
const off = attempts.find(attempt => attempt.case === `${name}-off` && attempt.iteration === on.iteration && !attempt.warmup && attempt.success)
|
|
66
|
+
return off ? [on.requests.reduce((sum, request) => sum + request.elapsed_ms, 0) - off.requests.reduce((sum, request) => sum + request.elapsed_ms, 0)] : []
|
|
67
|
+
})
|
|
68
|
+
return [name, { paired_on_minus_off_ms: summarize(pairs), interpretation: 'Includes device/order variability; not an isolated causal overhead estimate' }]
|
|
69
|
+
}))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Whitelist timing metadata rather than persisting unrestricted server stderr.
|
|
73
|
+
export function parseMetric(line: string): RequestMetric | TreeMetric | null {
|
|
74
|
+
try {
|
|
75
|
+
const value = JSON.parse(line)
|
|
76
|
+
const finite = (number: unknown) => typeof number === 'number' && Number.isFinite(number) && number >= 0
|
|
77
|
+
if (value.event === 'latency_metric' && typeof value.tool === 'string' && typeof value.request_id === 'string' &&
|
|
78
|
+
typeof value.handler_completed === 'boolean' && finite(value.elapsed_ms) && Array.isArray(value.spans) &&
|
|
79
|
+
value.spans.every((span: Span) => typeof span.stage === 'string' && finite(span.start_ms) && finite(span.end_ms) && span.end_ms >= span.start_ms)) {
|
|
80
|
+
return { event: value.event, request_id: value.request_id, tool: value.tool,
|
|
81
|
+
handler_completed: value.handler_completed, elapsed_ms: value.elapsed_ms,
|
|
82
|
+
spans: value.spans.map((span: Span) => ({ stage: span.stage, start_ms: span.start_ms, end_ms: span.end_ms })) }
|
|
83
|
+
}
|
|
84
|
+
if (value.event === 'ui_tree_metric' && typeof value.type === 'string' &&
|
|
85
|
+
(value.elapsed_ms === undefined || finite(value.elapsed_ms))) return { event: value.event, type: value.type,
|
|
86
|
+
stage: value.stage, elapsed_ms: value.elapsed_ms, acquisition_id: value.acquisition_id,
|
|
87
|
+
success: value.success, durations_ms: value.durations_ms }
|
|
88
|
+
} catch { /* Non-metric stderr is intentionally discarded. */ }
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
3
|
+
import { OwnedTransport } from './owned-transport.js'
|
|
4
|
+
import { parseMetric, type RequestMetric, type TreeMetric, type RequestSample } from './end-to-end-report.js'
|
|
5
|
+
|
|
6
|
+
function object(value: unknown): Record<string, unknown> {
|
|
7
|
+
return value !== null && typeof value === 'object' ? value as Record<string, unknown> : {}
|
|
8
|
+
}
|
|
9
|
+
export function validObservation(tool: string, data: Record<string, unknown>, content: unknown, deviceId: string) {
|
|
10
|
+
const treeValid = (value: Record<string, unknown>) => !value.error && Array.isArray(value.elements) && value.elements.length > 0 && object(value.device).id === deviceId
|
|
11
|
+
if (tool === 'get_ui_tree') return treeValid(data)
|
|
12
|
+
if (tool === 'capture_screenshot') return object(data.device).id === deviceId && Array.isArray(content) && content.some(item => {
|
|
13
|
+
const block = object(item)
|
|
14
|
+
return block.type === 'image' && typeof block.data === 'string' && block.data.length > 0
|
|
15
|
+
})
|
|
16
|
+
if (tool === 'capture_debug_snapshot') {
|
|
17
|
+
const raw = object(data.raw)
|
|
18
|
+
return treeValid(object(raw.ui_tree)) && !Object.keys(raw).some(key => key.endsWith('_error') && raw[key])
|
|
19
|
+
}
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class UnsafeMeasurement extends Error {}
|
|
24
|
+
export class BenchmarkSession {
|
|
25
|
+
readonly client = new Client({ name: 'end-to-end-latency', version: '1.0' })
|
|
26
|
+
readonly transport: OwnedTransport
|
|
27
|
+
private events: (RequestMetric | TreeMetric)[] = []
|
|
28
|
+
private pending = ''
|
|
29
|
+
private interrupted = new AbortController()
|
|
30
|
+
constructor(readonly deviceId: string, readonly instrumented: boolean, server = 'dist/server.js') {
|
|
31
|
+
this.transport = new OwnedTransport({ ...process.env,
|
|
32
|
+
MOBILE_DEBUG_MCP_LATENCY_METRICS: instrumented ? '1' : '0',
|
|
33
|
+
MOBILE_DEBUG_MCP_UI_TREE_METRICS: instrumented ? '1' : '0'
|
|
34
|
+
}, chunk => {
|
|
35
|
+
this.pending += chunk
|
|
36
|
+
let index: number
|
|
37
|
+
while ((index = this.pending.indexOf('\n')) >= 0) {
|
|
38
|
+
const event = parseMetric(this.pending.slice(0, index))
|
|
39
|
+
if (event) this.events.push(event)
|
|
40
|
+
this.pending = this.pending.slice(index + 1)
|
|
41
|
+
}
|
|
42
|
+
if (this.pending.length > 1024 * 1024) this.pending = ''
|
|
43
|
+
}, [server])
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async connect() { await this.client.connect(this.transport, { timeout: 15000 }) }
|
|
47
|
+
abort() { this.interrupted.abort() }
|
|
48
|
+
async close() { await this.client.close(); await this.transport.close() }
|
|
49
|
+
|
|
50
|
+
async call(tool: string, args: Record<string, unknown>, samples: RequestSample[]) {
|
|
51
|
+
if (this.interrupted.signal.aborted) throw new UnsafeMeasurement('INTERRUPTED')
|
|
52
|
+
this.events = []
|
|
53
|
+
const started = performance.now()
|
|
54
|
+
const sample: RequestSample = { tool, elapsed_ms: 0, success: false, metrics: [], tree: [] }
|
|
55
|
+
samples.push(sample)
|
|
56
|
+
try {
|
|
57
|
+
const response = await this.client.callTool({ name: tool,
|
|
58
|
+
arguments: { platform: 'android', deviceId: this.deviceId, ...args }
|
|
59
|
+
}, undefined, { timeout: 300000, signal: this.interrupted.signal })
|
|
60
|
+
sample.elapsed_ms = performance.now() - started
|
|
61
|
+
console.log(` ${tool}: ${Math.round(sample.elapsed_ms)} ms`)
|
|
62
|
+
sample.response_bytes = Buffer.byteLength(JSON.stringify(response))
|
|
63
|
+
// Stderr and stdout are independent streams. Await the matching metric
|
|
64
|
+
// outside the measured round trip before dispatching the next request.
|
|
65
|
+
const deadline = performance.now() + 1000
|
|
66
|
+
while (this.instrumented && !this.events.some(event => event.event === 'latency_metric') && performance.now() < deadline) await delay(5)
|
|
67
|
+
sample.metrics = this.events.filter((event): event is RequestMetric => event.event === 'latency_metric')
|
|
68
|
+
sample.tree = this.events.filter((event): event is TreeMetric => event.event === 'ui_tree_metric')
|
|
69
|
+
const text = (response.content as { type: string; text?: string }[]).find(item => item.type === 'text')?.text
|
|
70
|
+
const data = JSON.parse(text ?? '{}')
|
|
71
|
+
sample.success = !response.isError && data.success !== false && !data.error && data.status !== 'timeout' && validObservation(tool, data, response.content, this.deviceId)
|
|
72
|
+
if (!sample.success) {
|
|
73
|
+
sample.failure = 'TOOL_OUTCOME_FAILED'
|
|
74
|
+
// Only fixture-generated step IDs and enum-style codes may be saved.
|
|
75
|
+
const step = typeof data.stopped_at_step_id === 'string' ? data.stopped_at_step_id : ''
|
|
76
|
+
if (/^(launch|tap-\d+|assert-\d+)$/.test(step)) sample.failed_step = step
|
|
77
|
+
const failed = Array.isArray(data.steps) ? data.steps.find((value: Record<string, unknown>) => value.id === step) : undefined
|
|
78
|
+
const code = data.failure_code ?? object(object(failed).result).failure_code ?? object(data.error).code
|
|
79
|
+
if (typeof code === 'string' && /^[A-Z][A-Z_]{0,63}$/.test(code)) sample.failure_code = code
|
|
80
|
+
throw new Error(sample.failure)
|
|
81
|
+
}
|
|
82
|
+
if (this.instrumented && (sample.metrics.length !== 1 || sample.metrics[0].tool !== tool)) {
|
|
83
|
+
sample.success = false; sample.failure = 'METRIC_CORRELATION_FAILED'
|
|
84
|
+
throw new UnsafeMeasurement(sample.failure)
|
|
85
|
+
}
|
|
86
|
+
return data as Record<string, unknown>
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (!sample.elapsed_ms) sample.elapsed_ms = performance.now() - started
|
|
89
|
+
sample.metrics = this.events.filter((event): event is RequestMetric => event.event === 'latency_metric')
|
|
90
|
+
sample.tree = this.events.filter((event): event is TreeMetric => event.event === 'ui_tree_metric')
|
|
91
|
+
if (!sample.failure) {
|
|
92
|
+
sample.failure = this.interrupted.signal.aborted ? 'INTERRUPTED' : 'TRANSPORT_OR_RESPONSE_FAILED'
|
|
93
|
+
throw new UnsafeMeasurement(sample.failure)
|
|
94
|
+
}
|
|
95
|
+
throw error
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { buildFixture } from '../../../fixtures/latency-journey-app/build.js'
|
|
3
|
+
import { BenchmarkSession, UnsafeMeasurement } from './end-to-end-session.js'
|
|
4
|
+
import { cleanupFixture, command, environment, fixturePresent, treeHash } from './end-to-end-environment.js'
|
|
5
|
+
import { summarizeAttempts, observationOverhead, type Attempt } from './end-to-end-report.js'
|
|
6
|
+
|
|
7
|
+
const deviceId = process.env.LATENCY_DEVICE_ID
|
|
8
|
+
if (!deviceId || !deviceId.startsWith('emulator-')) throw new Error('Set LATENCY_DEVICE_ID to an explicit Android emulator serial')
|
|
9
|
+
const adb = (...args: string[]) => command('adb', ['-s', deviceId, ...args])
|
|
10
|
+
const appId = 'dev.mobiledebugmcp.latencyfixture'
|
|
11
|
+
const smoke = process.argv.includes('--smoke')
|
|
12
|
+
const rounds = smoke ? 1 : 13
|
|
13
|
+
const warmups = smoke ? 0 : 3
|
|
14
|
+
const cases = ['standalone', 'full-debug', 'full-compact', 'light-debug', 'light-compact',
|
|
15
|
+
'screenshot-on', 'screenshot-off', 'tree-on', 'tree-off', 'snapshot-on', 'snapshot-off']
|
|
16
|
+
const flow = [['saved-session', 'idle-detail'], ['activate', 'active-detail'], ['home', 'home-screen'],
|
|
17
|
+
['saved-session', 'active-detail'], ['deactivate', 'idle-detail'], ['stop', 'stopped']]
|
|
18
|
+
const selector = (id: string) => ({ accessibility_id: id })
|
|
19
|
+
const controls = { actionTimeoutMs: 30000, verificationTimeoutMs: 60000 }
|
|
20
|
+
const attempts: Attempt[] = []
|
|
21
|
+
const phases: { phase: string; elapsed_ms: number; success: boolean }[] = []
|
|
22
|
+
const sessions = new Map<boolean, BenchmarkSession>()
|
|
23
|
+
const cleanup: { server?: boolean; fixture?: boolean; removed_install?: boolean } = {}
|
|
24
|
+
let metadata: ReturnType<typeof environment> | undefined
|
|
25
|
+
let interrupted = false
|
|
26
|
+
let fatal: string | undefined
|
|
27
|
+
let fixtureExisted = false
|
|
28
|
+
let installAttempted = false
|
|
29
|
+
let sourceStable: boolean | undefined
|
|
30
|
+
const startedAt = new Date().toISOString()
|
|
31
|
+
const root = 'docs/benchmarks/end-to-end-latency'
|
|
32
|
+
const reportName = smoke ? 'smoke' : 'baseline'
|
|
33
|
+
async function phase<T>(name: string, operation: () => T | Promise<T>) {
|
|
34
|
+
const start = performance.now()
|
|
35
|
+
let success = false
|
|
36
|
+
try { const result = await operation(); success = true; return result }
|
|
37
|
+
finally { phases.push({ phase: name, elapsed_ms: performance.now() - start, success }) }
|
|
38
|
+
}
|
|
39
|
+
function save() {
|
|
40
|
+
mkdirSync(root, { recursive: true })
|
|
41
|
+
const summaries = summarizeAttempts(attempts)
|
|
42
|
+
const report = { status: fatal ? 'stopped' : interrupted ? 'interrupted' : attempts.length === rounds * cases.length ? 'finished' : 'in_progress',
|
|
43
|
+
performance_evidence: !smoke && sourceStable !== false, smoke, warmups, measured_attempts_per_case: smoke ? 1 : 10,
|
|
44
|
+
started_at: startedAt, source_and_build_unchanged: sourceStable,
|
|
45
|
+
planned_cases: cases, unrun_attempts: rounds * cases.length - attempts.length,
|
|
46
|
+
outcome: fatal || interrupted || attempts.some(value => !value.success) || cleanup.server === false || cleanup.fixture === false ? 'fail' : cleanup.server && cleanup.fixture ? 'pass' : 'pending',
|
|
47
|
+
invocation: `LATENCY_DEVICE_ID=[emulator] MCP_ADB_TIMEOUT=${process.env.MCP_ADB_TIMEOUT ?? '20000'} npx tsx test/device/automated/latency/end-to-end.ts${smoke ? ' --smoke' : ''}`,
|
|
48
|
+
metadata, phases, fatal, cleanup, summaries, observation_overhead: observationOverhead(attempts), attempts }
|
|
49
|
+
writeFileSync(`${root}/${reportName}.json`, JSON.stringify(report, null, 2) + '\n')
|
|
50
|
+
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} |`)
|
|
51
|
+
writeFileSync(`${root}/${reportName}.md`, `# End-to-end latency ${smoke ? 'smoke (not performance evidence)' : 'baseline'}\n\nStatus: ${report.status}. Build: ${metadata?.build_sha256 ?? 'unavailable'}.\n\n| Case | Successful/measured | p50 ms | p95 ms | Failures | Warm-up failures |\n| --- | ---: | ---: | ---: | ---: | ---: |\n${rows.join('\n')}\n\nSee ${reportName}.json for exact source/build hashes, environment, phase durations, attempt order, inclusive components and failure records. 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 are excluded from percentiles, retained in raw data, and fail the run.\n\nIntervals can overlap; component sums are not an additive latency budget. Client-minus-handler includes scheduling, transport and uninstrumented work. Handler residual excludes the union of recorded spans. Tree acquisition/parse/retry events are separate nested diagnostics. Application work inside requests remains included. Startup, fixture builds/resets and external validation are excluded from steady-state round trips. SDK wire serialization is not isolated.\n`)
|
|
52
|
+
}
|
|
53
|
+
async function reset(session: BenchmarkSession, observation: boolean) {
|
|
54
|
+
adb('shell', 'am', 'force-stop', appId)
|
|
55
|
+
if (!adb('shell', 'pm', 'clear', appId).includes('Success')) throw new Error('RESET_FAILED')
|
|
56
|
+
if (observation) {
|
|
57
|
+
adb('shell', 'am', 'start', '-W', '-n', `${appId}/.MainActivity`)
|
|
58
|
+
await session.call('expect_element_visible', { selector: selector('home-screen'), timeout_ms: 30000, poll_interval_ms: 100 }, [])
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function journey(session: BenchmarkSession, mode: string, attempt: Attempt) {
|
|
62
|
+
if (mode === 'standalone') {
|
|
63
|
+
await session.call('start_app', { appId, ...controls }, attempt.requests)
|
|
64
|
+
for (const [target, expected] of flow) {
|
|
65
|
+
const found = await session.call('wait_for_ui', { selector: selector(target), condition: 'clickable', timeout_ms: 10000, poll_interval_ms: 100 }, attempt.requests)
|
|
66
|
+
const element = found.element as { elementId?: string } | undefined
|
|
67
|
+
if (!element?.elementId) throw new Error('TARGET_HANDLE_MISSING')
|
|
68
|
+
await session.call('tap_element', { elementId: element.elementId, ...controls }, attempt.requests)
|
|
69
|
+
await session.call('expect_element_visible', { selector: selector(expected), timeout_ms: 30000, poll_interval_ms: 100 }, attempt.requests)
|
|
70
|
+
}
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
const [verificationMode, responseMode] = mode.split('-')
|
|
74
|
+
const steps: Record<string, unknown>[] = [{ id: 'launch', type: 'start_app', appId }]
|
|
75
|
+
flow.forEach(([target, expected], index) => {
|
|
76
|
+
steps.push({ id: `tap-${index}`, type: 'tap', selector: selector(target), waitFor: { condition: 'clickable', timeoutMs: 10000, pollIntervalMs: 100 } })
|
|
77
|
+
steps.push({ id: `assert-${index}`, type: 'assert', assertion: { kind: 'element_visible', selector: selector(expected) }, timeoutMs: 30000, pollIntervalMs: 100 })
|
|
78
|
+
})
|
|
79
|
+
await session.call('run_journey', { defaults: { verificationMode, ...controls }, responseMode, steps }, attempt.requests)
|
|
80
|
+
}
|
|
81
|
+
async function attempt(name: string, iteration: number) {
|
|
82
|
+
if (interrupted) throw new UnsafeMeasurement('INTERRUPTED')
|
|
83
|
+
const instrumented = !name.endsWith('-off')
|
|
84
|
+
const session = sessions.get(instrumented)!
|
|
85
|
+
const observation = /^(screenshot|tree|snapshot)-/.test(name)
|
|
86
|
+
const value: Attempt = { case: name, iteration, warmup: iteration < warmups, success: false, setup_ms: 0, requests: [] }
|
|
87
|
+
attempts.push(value)
|
|
88
|
+
const start = performance.now()
|
|
89
|
+
try {
|
|
90
|
+
await reset(session, observation)
|
|
91
|
+
value.setup_ms = performance.now() - start
|
|
92
|
+
if (!observation) await journey(session, name, value)
|
|
93
|
+
else {
|
|
94
|
+
const tool = name.startsWith('screenshot') ? 'capture_screenshot' : name.startsWith('tree') ? 'get_ui_tree' : 'capture_debug_snapshot'
|
|
95
|
+
await session.call(tool, tool === 'capture_debug_snapshot' ? { includeScreenshot: false, includeLogs: false } : {}, value.requests)
|
|
96
|
+
}
|
|
97
|
+
value.success = true
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!value.setup_ms) value.setup_ms = performance.now() - start
|
|
100
|
+
value.failure = error instanceof UnsafeMeasurement ? error.message : value.requests.at(-1)?.failure ?? 'SETUP_OR_ASSERTION_FAILED'
|
|
101
|
+
if (error instanceof UnsafeMeasurement || !value.requests.length) throw new UnsafeMeasurement(value.failure)
|
|
102
|
+
} finally { save() }
|
|
103
|
+
}
|
|
104
|
+
async function main() {
|
|
105
|
+
const signal = () => { interrupted = true; for (const session of sessions.values()) session.abort() }
|
|
106
|
+
process.on('SIGINT', signal); process.on('SIGTERM', signal)
|
|
107
|
+
try {
|
|
108
|
+
if (adb('get-state') !== 'device' || adb('shell', 'getprop', 'ro.kernel.qemu') !== '1') throw new Error('EMULATOR_REQUIRED')
|
|
109
|
+
fixtureExisted = fixturePresent(adb, appId)
|
|
110
|
+
const apk = await phase('fixture_build', buildFixture)
|
|
111
|
+
metadata = environment(deviceId!, apk)
|
|
112
|
+
installAttempted = true
|
|
113
|
+
await phase('fixture_install', () => adb('install', '-r', apk))
|
|
114
|
+
for (const enabled of [true, false]) {
|
|
115
|
+
const session = new BenchmarkSession(deviceId!, enabled)
|
|
116
|
+
sessions.set(enabled, session)
|
|
117
|
+
await phase(`server_startup_metrics_${enabled}`, () => session.connect())
|
|
118
|
+
}
|
|
119
|
+
for (let round = 0; round < rounds; round++) {
|
|
120
|
+
const order = round % 2 ? [...cases].reverse() : cases
|
|
121
|
+
for (const name of order) {
|
|
122
|
+
console.log(`${smoke ? 'smoke' : `round ${round + 1}/${rounds}`} ${name}`)
|
|
123
|
+
await attempt(name, round)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch (error) {
|
|
127
|
+
fatal = error instanceof UnsafeMeasurement ? error.message : 'PREREQUISITE_OR_CLEANUP_FAILED'
|
|
128
|
+
process.exitCode = 1
|
|
129
|
+
} finally {
|
|
130
|
+
cleanup.server = true
|
|
131
|
+
for (const session of sessions.values()) {
|
|
132
|
+
try { await session.close(); cleanup.server &&= session.transport.cleanupVerified }
|
|
133
|
+
catch { cleanup.server = false }
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
if (installAttempted && cleanupFixture(adb, appId, !fixtureExisted)) cleanup.removed_install = true
|
|
137
|
+
cleanup.fixture = true
|
|
138
|
+
} catch { cleanup.fixture = false }
|
|
139
|
+
if (!cleanup.server || !cleanup.fixture || interrupted || attempts.some(value => !value.success)) process.exitCode = 1
|
|
140
|
+
if (!cleanup.server || !cleanup.fixture) fatal = 'CLEANUP_FAILED'
|
|
141
|
+
if (metadata) {
|
|
142
|
+
sourceStable = metadata.source_sha256 === treeHash('src') && metadata.build_sha256 === treeHash('dist') && metadata.harness_sha256 === treeHash('test/device/automated/latency')
|
|
143
|
+
if (!sourceStable) { fatal = 'MEASURED_SOURCE_CHANGED'; process.exitCode = 1 }
|
|
144
|
+
}
|
|
145
|
+
save()
|
|
146
|
+
process.off('SIGINT', signal); process.off('SIGTERM', signal)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
main().catch(() => { console.error('Benchmark failed; inspect timing report'); process.exitCode = 1 })
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
3
|
+
import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/sdk/shared/stdio.js'
|
|
4
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
|
5
|
+
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'
|
|
6
|
+
|
|
7
|
+
// A dedicated POSIX process group bounds cleanup to this benchmark's server
|
|
8
|
+
// and its children. The SDK client and stdio wire protocol are unchanged.
|
|
9
|
+
export class OwnedTransport implements Transport {
|
|
10
|
+
onclose?: () => void
|
|
11
|
+
onerror?: (error: Error) => void
|
|
12
|
+
onmessage?: (message: JSONRPCMessage) => void
|
|
13
|
+
private child?: ChildProcessWithoutNullStreams
|
|
14
|
+
private buffer = new ReadBuffer({ maxBufferSize: 64 * 1024 * 1024 })
|
|
15
|
+
private closed?: Promise<void>
|
|
16
|
+
private closing?: Promise<void>
|
|
17
|
+
cleanupVerified = false
|
|
18
|
+
constructor(private env: NodeJS.ProcessEnv, private stderr: (chunk: string) => void,
|
|
19
|
+
private args = ['dist/server.js']) {}
|
|
20
|
+
|
|
21
|
+
async start() {
|
|
22
|
+
if (process.platform === 'win32') throw new Error('POSIX benchmark transport required')
|
|
23
|
+
if (this.child) throw new Error('Already started')
|
|
24
|
+
this.child = spawn(process.execPath, this.args, { env: this.env, detached: true, stdio: 'pipe' })
|
|
25
|
+
this.closed = new Promise(resolve => this.child!.once('close', () => { this.onclose?.(); resolve() }))
|
|
26
|
+
this.child.on('error', error => this.onerror?.(error))
|
|
27
|
+
this.child.stdin.on('error', error => this.onerror?.(error))
|
|
28
|
+
this.child.stderr.on('data', chunk => this.stderr(String(chunk)))
|
|
29
|
+
this.child.stdout.on('data', chunk => {
|
|
30
|
+
try {
|
|
31
|
+
this.buffer.append(chunk)
|
|
32
|
+
let message
|
|
33
|
+
while ((message = this.buffer.readMessage()) !== null) this.onmessage?.(message)
|
|
34
|
+
} catch (error) { this.onerror?.(error as Error) }
|
|
35
|
+
})
|
|
36
|
+
await new Promise<void>((resolve, reject) => { this.child!.once('spawn', resolve); this.child!.once('error', reject) })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async send(message: JSONRPCMessage) {
|
|
40
|
+
if (!this.child) throw new Error('Not started')
|
|
41
|
+
await new Promise<void>((resolve, reject) => this.child!.stdin.write(serializeMessage(message), error => error ? reject(error) : resolve()))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private groupExists() {
|
|
45
|
+
if (!this.child?.pid) return false
|
|
46
|
+
try { process.kill(-this.child.pid, 0); return true }
|
|
47
|
+
catch (error) {
|
|
48
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
49
|
+
if (code === 'ESRCH') return false
|
|
50
|
+
// Darwin can return EPERM while a killed group is being reaped. It is
|
|
51
|
+
// still unconfirmed: retry until ESRCH, never count EPERM as absence.
|
|
52
|
+
if (code === 'EPERM') return true
|
|
53
|
+
throw error
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private signal(signal: NodeJS.Signals) {
|
|
58
|
+
if (!this.child?.pid) return
|
|
59
|
+
try { process.kill(-this.child.pid, signal) }
|
|
60
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
close() {
|
|
64
|
+
this.closing ??= this.cleanup()
|
|
65
|
+
return this.closing
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async cleanup() {
|
|
69
|
+
if (!this.child) { this.cleanupVerified = true; return }
|
|
70
|
+
this.child.stdin.end()
|
|
71
|
+
await Promise.race([this.closed, delay(1000)])
|
|
72
|
+
if (this.groupExists()) this.signal('SIGTERM')
|
|
73
|
+
await Promise.race([this.closed, delay(1000)])
|
|
74
|
+
if (this.groupExists()) this.signal('SIGKILL')
|
|
75
|
+
const deadline = performance.now() + 3000
|
|
76
|
+
while (this.groupExists() && performance.now() < deadline) await delay(50)
|
|
77
|
+
this.cleanupVerified = !this.groupExists()
|
|
78
|
+
this.buffer.clear()
|
|
79
|
+
if (!this.cleanupVerified) throw new Error('Owned process group did not exit')
|
|
80
|
+
}
|
|
81
|
+
}
|