mobile-debug-mcp 0.31.0 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/interact/android.js +13 -8
- package/dist/interact/index.js +33 -22
- package/dist/interact/ios.js +5 -2
- package/dist/manage/android.js +20 -10
- package/dist/manage/ios.js +19 -10
- package/dist/observe/android-tree-bridge.js +192 -0
- package/dist/observe/android.js +53 -14
- package/dist/observe/index.js +22 -5
- package/dist/observe/ios.js +50 -29
- package/dist/observe/performance-metrics.js +124 -0
- package/dist/observe/snapshot-metadata.js +94 -15
- package/dist/server/common.js +14 -7
- package/dist/server/journey-schema.js +60 -0
- package/dist/server/tool-definitions.js +59 -6
- package/dist/server/tool-handlers.js +381 -56
- package/dist/server-core.js +1 -1
- package/dist/utils/exec.js +18 -5
- package/dist/utils/ios/utils.js +5 -2
- package/dist/utils/operation-budget.js +25 -0
- package/dist/utils/resolve-device.js +12 -1
- package/dist/utils/timing.js +19 -0
- package/dist/utils/ui/index.js +2 -0
- package/docs/CHANGELOG.md +6 -0
- package/docs/ROADMAP.md +9 -0
- package/docs/benchmarks/interaction-latency/README.md +26 -0
- package/docs/benchmarks/interaction-latency/final.json +1810 -0
- package/docs/benchmarks/interaction-latency/final.md +17 -0
- package/docs/benchmarks/interaction-latency/ios-validation.md +11 -0
- package/docs/benchmarks/ui-tree-performance/README.md +51 -0
- package/docs/benchmarks/ui-tree-performance/baseline.json +59546 -0
- package/docs/benchmarks/ui-tree-performance/bridge-probe.json +28 -0
- package/docs/benchmarks/ui-tree-performance/candidate.json +51691 -0
- package/docs/benchmarks/ui-tree-performance/comparison-attempt-1.json +110679 -0
- package/docs/benchmarks/ui-tree-performance/comparison.json +111230 -0
- package/docs/benchmarks/ui-tree-performance/comparison.md +41 -0
- package/docs/benchmarks/ui-tree-performance/initial-baseline.json +59560 -0
- package/docs/specs/018-interaction-latency-and-batched-journeys/spec.md +301 -0
- package/docs/specs/019-ui-tree-observation-performance/spec.md +148 -0
- package/docs/tools/interact.md +30 -0
- package/docs/tools/observe.md +1 -0
- package/package.json +1 -1
- package/skills/mobile-debug-tools-usage/SKILL.md +74 -0
- package/src/interact/android.ts +13 -8
- package/src/interact/index.ts +32 -23
- package/src/interact/ios.ts +5 -2
- package/src/manage/android.ts +19 -10
- package/src/manage/ios.ts +18 -10
- package/src/observe/android-tree-bridge.ts +170 -0
- package/src/observe/android.ts +48 -17
- package/src/observe/index.ts +21 -5
- package/src/observe/ios.ts +49 -36
- package/src/observe/performance-metrics.ts +125 -0
- package/src/observe/snapshot-metadata.ts +94 -15
- package/src/server/common.ts +13 -6
- package/src/server/journey-schema.ts +63 -0
- package/src/server/tool-definitions.ts +60 -7
- package/src/server/tool-handlers.ts +349 -54
- package/src/server-core.ts +1 -1
- package/src/types.ts +31 -2
- package/src/utils/exec.ts +13 -5
- package/src/utils/ios/utils.ts +5 -2
- package/src/utils/operation-budget.ts +22 -0
- package/src/utils/resolve-device.ts +13 -1
- package/src/utils/timing.ts +15 -0
- package/src/utils/ui/index.ts +2 -0
- package/test/device/automated/latency/benchmark.ts +136 -0
- package/test/device/automated/latency/report.ts +6 -0
- package/test/device/automated/ui-tree-performance/benchmark.ts +401 -0
- package/test/device/automated/ui-tree-performance/bridge-probe.ts +183 -0
- package/test/fixtures/latency-journey-app/AndroidManifest.xml +7 -0
- package/test/fixtures/latency-journey-app/README.md +28 -0
- package/test/fixtures/latency-journey-app/build.ts +26 -0
- package/test/fixtures/latency-journey-app/src/MainActivity.java +104 -0
- package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +6 -0
- package/test/fixtures/ui-tree-bridge/build.ts +34 -0
- package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +119 -0
- package/test/unit/manage/restart_budget.test.ts +37 -0
- package/test/unit/observe/android_tree_acquisition.test.ts +70 -0
- package/test/unit/observe/android_tree_bridge_cleanup.test.ts +76 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +6 -0
- package/test/unit/observe/ios_tree_acquisition.test.ts +77 -0
- package/test/unit/observe/performance_metrics.test.ts +40 -0
- package/test/unit/observe/snapshot_metadata.test.ts +20 -1
- package/test/unit/observe/state_delta.test.ts +15 -0
- package/test/unit/server/benchmark_report.test.ts +4 -0
- package/test/unit/server/contract.test.ts +1 -1
- package/test/unit/server/interaction_modes.test.ts +60 -0
- package/test/unit/server/operation_budget.test.ts +45 -0
- package/test/unit/server/response_shapes.test.ts +3 -3
- package/test/unit/server/run_journey.test.ts +120 -0
- package/test/unit/server/tap_selector.test.ts +68 -0
- /package/docs/{specs-deltas/001-state-verification.md → specs/001-state-verification/spec.md} +0 -0
- /package/docs/{specs-deltas/002-richer-element-identity → specs/002-richer-element-identity/spec.md} +0 -0
- /package/docs/{specs-deltas/003-wait-and-synchronization-reliability.md → specs/003-wait-and-synchronization-reliability/spec.md} +0 -0
- /package/docs/{specs-deltas/004-action-verification-routing.md → specs/004-action-verification-routing/spec.md} +0 -0
- /package/docs/{specs-deltas/005-unified-action-execution-and-verification-model.md → specs/005-unified-action-execution-and-verification-model/spec.md} +0 -0
- /package/docs/{specs-deltas/006-runtime-action-instrumentation-and-binding-layer.md → specs/006-runtime-action-instrumentation-and-binding-layer/spec.md} +0 -0
- /package/docs/{specs-deltas/007-actionability-resolution-and-executable-target-selection.md → specs/007-actionability-resolution-and-executable-target-selection/spec.md} +0 -0
- /package/docs/{specs-deltas/008-adjustable-control-support-and-semantic-value-manipulation.md → specs/008-adjustable-control-support-and-semantic-value-manipulation/spec.md} +0 -0
- /package/docs/{specs-deltas/009-semantic-control-modeling-for-custom-and-composite-controls.md → specs/009-semantic-control-modeling-for-custom-and-composite-controls/spec.md} +0 -0
- /package/docs/{specs-deltas/010-verification-stabilization-and-temporal-convergence.md → specs/010-verification-stabilization-and-temporal-convergence/spec.md} +0 -0
- /package/docs/{specs-deltas/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows.md → specs/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows/spec.md} +0 -0
- /package/docs/{specs-deltas/011.1-recovery-contract-types-and-runtime-wiring-spec.md → specs/011.1-recovery-contract-types-and-runtime-wiring-spec/spec.md} +0 -0
- /package/docs/{specs-deltas/012-action-trace-and-xecution-observability.md → specs/012-action-trace-and-xecution-observability/spec.md} +0 -0
- /package/docs/{specs-deltas/013-wait-and-synchronization-reliability.md → specs/013-wait-and-synchronization-reliability/spec.md} +0 -0
- /package/docs/{specs-deltas/014-actionability-resolution.md → specs/014-actionability-resolution/spec.md} +0 -0
- /package/docs/{specs-deltas/015-adjustable-control-support.md → specs/015-adjustable-control-support/spec.md} +0 -0
- /package/docs/{specs-deltas/016-environment-tools.md → specs/016-environment-tools/spec.md} +0 -0
package/src/types.ts
CHANGED
|
@@ -162,6 +162,16 @@ export interface SnapshotDelta {
|
|
|
162
162
|
total_elements: number;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
export interface StateDelta {
|
|
166
|
+
base_snapshot_revision: number | null;
|
|
167
|
+
snapshot_revision: number;
|
|
168
|
+
added: UIElement[];
|
|
169
|
+
removed: Array<{ stable_id?: string; resourceId?: string; contentDescription?: string; text?: string; index: number }>;
|
|
170
|
+
changed: UIElement[];
|
|
171
|
+
truncated: boolean;
|
|
172
|
+
omitted_changes?: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
165
175
|
export interface CaptureAndroidScreenResponse {
|
|
166
176
|
device: DeviceInfo;
|
|
167
177
|
screenshot: string; // base64 encoded string
|
|
@@ -282,6 +292,8 @@ export type ActionFailureCode =
|
|
|
282
292
|
| 'ELEMENT_NOT_FOUND'
|
|
283
293
|
| 'ELEMENT_NOT_INTERACTABLE'
|
|
284
294
|
| 'TIMEOUT'
|
|
295
|
+
| 'ACTION_TIMEOUT'
|
|
296
|
+
| 'ASSERTION_TIMEOUT'
|
|
285
297
|
| 'NAVIGATION_NO_CHANGE'
|
|
286
298
|
| 'AMBIGUOUS_TARGET'
|
|
287
299
|
| 'STALE_REFERENCE'
|
|
@@ -393,11 +405,23 @@ export interface FindElementResponse {
|
|
|
393
405
|
error?: string;
|
|
394
406
|
}
|
|
395
407
|
|
|
408
|
+
export interface TimingBreakdown {
|
|
409
|
+
total_ms: number;
|
|
410
|
+
device_resolution_ms?: number;
|
|
411
|
+
pre_observation_ms?: number;
|
|
412
|
+
target_resolution_ms?: number;
|
|
413
|
+
dispatch_ms?: number;
|
|
414
|
+
post_observation_ms?: number;
|
|
415
|
+
verification_ms?: number;
|
|
416
|
+
screenshot_ms?: number;
|
|
417
|
+
serialization_ms?: number;
|
|
418
|
+
}
|
|
419
|
+
|
|
396
420
|
export interface ActionExecutionResult {
|
|
397
421
|
action_id: string;
|
|
398
422
|
timestamp: string;
|
|
399
423
|
action_type: string;
|
|
400
|
-
lifecycle_state?: 'pending_verification' | 'failed';
|
|
424
|
+
lifecycle_state?: 'pending_verification' | 'verified' | 'failed';
|
|
401
425
|
source_module?: 'server' | 'interact';
|
|
402
426
|
device?: DeviceInfo;
|
|
403
427
|
target: {
|
|
@@ -407,6 +431,11 @@ export interface ActionExecutionResult {
|
|
|
407
431
|
success: boolean;
|
|
408
432
|
failure_code?: ActionFailureCode;
|
|
409
433
|
retryable?: boolean;
|
|
434
|
+
delivery_status?: 'delivered' | 'not_delivered' | 'unknown';
|
|
435
|
+
dispatch_started?: boolean;
|
|
436
|
+
verification?: { mode: 'none' | 'light' | 'full'; status: 'not_requested' | 'observed' | 'verified' | 'unavailable' | 'failed' };
|
|
437
|
+
state_delta?: StateDelta;
|
|
438
|
+
timing?: TimingBreakdown;
|
|
410
439
|
recovery?: RecoveryState;
|
|
411
440
|
trace: ActionTrace;
|
|
412
441
|
ui_fingerprint_before?: string | null;
|
|
@@ -414,7 +443,7 @@ export interface ActionExecutionResult {
|
|
|
414
443
|
details?: Record<string, unknown>;
|
|
415
444
|
}
|
|
416
445
|
|
|
417
|
-
export interface TapElementResponse extends ActionExecutionResult {}
|
|
446
|
+
export interface TapElementResponse extends ActionExecutionResult { error?: string }
|
|
418
447
|
|
|
419
448
|
export interface ExpectScreenResponse {
|
|
420
449
|
success: boolean;
|
package/src/utils/exec.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { remainingBudget } from './operation-budget.js'
|
|
1
2
|
import { spawn } from 'child_process'
|
|
2
3
|
|
|
3
4
|
export type ExecOptions = { timeout?: number; env?: NodeJS.ProcessEnv; cwd?: string; shell?: boolean }
|
|
4
5
|
|
|
5
6
|
export async function execCmd(cmd: string, args: string[], opts: ExecOptions = {}): Promise<{ exitCode: number | null, stdout: string, stderr: string }> {
|
|
6
|
-
const {
|
|
7
|
+
const { env, cwd, shell } = opts
|
|
8
|
+
const timeout = remainingBudget(opts.timeout ?? 0)
|
|
7
9
|
return new Promise((resolve, reject) => {
|
|
8
10
|
const child = spawn(cmd, args, { env: { ...process.env, ...(env || {}) }, cwd, shell })
|
|
9
11
|
let stdout = ''
|
|
@@ -12,22 +14,28 @@ export async function execCmd(cmd: string, args: string[], opts: ExecOptions = {
|
|
|
12
14
|
if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString() })
|
|
13
15
|
|
|
14
16
|
let timedOut = false
|
|
17
|
+
let forceKillTimer: NodeJS.Timeout | null = null
|
|
15
18
|
const timer = timeout && timeout > 0 ? setTimeout(() => {
|
|
16
19
|
timedOut = true
|
|
17
20
|
try { child.kill() } catch { }
|
|
18
|
-
|
|
21
|
+
forceKillTimer = setTimeout(() => {
|
|
22
|
+
try { child.kill('SIGKILL') } catch { }
|
|
23
|
+
}, 1000)
|
|
24
|
+
forceKillTimer.unref()
|
|
19
25
|
}, timeout) : null
|
|
20
26
|
|
|
21
27
|
child.on('close', (code) => {
|
|
22
28
|
if (timer) clearTimeout(timer)
|
|
23
|
-
if (
|
|
24
|
-
resolve(
|
|
29
|
+
if (forceKillTimer) clearTimeout(forceKillTimer)
|
|
30
|
+
resolve(timedOut
|
|
31
|
+
? { exitCode: null, stdout: stdout.trim(), stderr: 'ACTION_TIMEOUT' }
|
|
32
|
+
: { exitCode: code, stdout: stdout.trim(), stderr: stderr.trim() })
|
|
25
33
|
})
|
|
26
34
|
|
|
27
35
|
child.on('error', (err) => {
|
|
28
36
|
if (timer) clearTimeout(timer)
|
|
37
|
+
if (forceKillTimer) clearTimeout(forceKillTimer)
|
|
29
38
|
reject(err)
|
|
30
39
|
})
|
|
31
40
|
})
|
|
32
41
|
}
|
|
33
|
-
|
package/src/utils/ios/utils.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { remainingBudget } from '../operation-budget.js'
|
|
1
2
|
import { execFile, spawn, execSync, spawnSync } from "child_process"
|
|
2
3
|
import { DeviceInfo } from "../../types.js"
|
|
3
4
|
import { promises as fsPromises } from 'fs'
|
|
@@ -86,6 +87,7 @@ export function validateBundleId(bundleId: string) {
|
|
|
86
87
|
export function execCommand(args: string[], deviceId: string = "booted"): Promise<IOSResult> {
|
|
87
88
|
return new Promise((resolve, reject) => {
|
|
88
89
|
// Use spawn for better stream control and consistency with Android implementation
|
|
90
|
+
const budgetMs = remainingBudget()
|
|
89
91
|
const child = spawn(getXcrunCmd(), args)
|
|
90
92
|
|
|
91
93
|
let stdout = ''
|
|
@@ -105,7 +107,7 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
|
|
|
105
107
|
|
|
106
108
|
const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000 // env (ms) or default 30s
|
|
107
109
|
const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000 // env (ms) or default 60s
|
|
108
|
-
const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT // choose appropriate timeout
|
|
110
|
+
const timeoutMs = budgetMs || (args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT) // choose appropriate timeout
|
|
109
111
|
const timeout = setTimeout(() => {
|
|
110
112
|
child.kill()
|
|
111
113
|
reject(new Error(`Command timed out after ${timeoutMs}ms: ${getXcrunCmd()} ${args.join(' ')}`))
|
|
@@ -128,10 +130,11 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
|
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
export function execCommandWithDiagnostics(args: string[], deviceId: string = "booted") {
|
|
133
|
+
const budgetMs = remainingBudget()
|
|
131
134
|
// Run synchronously to capture stdout/stderr and exitCode reliably for diagnostics
|
|
132
135
|
const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000
|
|
133
136
|
const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000
|
|
134
|
-
const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT
|
|
137
|
+
const timeoutMs = budgetMs || (args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT)
|
|
135
138
|
const res = spawnSync(getXcrunCmd(), args, { encoding: 'utf8', timeout: timeoutMs }) as any
|
|
136
139
|
const runResult = {
|
|
137
140
|
exitCode: typeof res.status === 'number' ? res.status : null,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
|
|
3
|
+
export interface OperationBudget { deadline: number; dispatchStarted: boolean; parent?: OperationBudget }
|
|
4
|
+
const budgets = new AsyncLocalStorage<OperationBudget>()
|
|
5
|
+
export function hasFiniteBudget() { return Number.isFinite(budgets.getStore()?.deadline) }
|
|
6
|
+
export function runWithBudget<T>(timeoutMs: number, operation: (budget: OperationBudget) => Promise<T>) {
|
|
7
|
+
const parent = budgets.getStore()
|
|
8
|
+
const budget = { deadline: Math.min(parent?.deadline ?? Infinity, performance.now() + timeoutMs), dispatchStarted: false, parent }
|
|
9
|
+
return budgets.run(budget, () => operation(budget))
|
|
10
|
+
}
|
|
11
|
+
export function remainingBudget(fallback = 0): number {
|
|
12
|
+
const budget = budgets.getStore()
|
|
13
|
+
if (!budget || !Number.isFinite(budget.deadline)) return fallback
|
|
14
|
+
const remaining = Math.floor(budget.deadline - performance.now())
|
|
15
|
+
if (remaining <= 0) throw new Error('ACTION_TIMEOUT')
|
|
16
|
+
return fallback > 0 ? Math.min(fallback, remaining) : remaining
|
|
17
|
+
}
|
|
18
|
+
export function markDispatchStarted() {
|
|
19
|
+
remainingBudget()
|
|
20
|
+
let budget = budgets.getStore()
|
|
21
|
+
while (budget) { budget.dispatchStarted = true; budget = budget.parent }
|
|
22
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { retainConnectedDeviceSnapshots } from '../observe/snapshot-metadata.js'
|
|
2
|
+
import { measure } from './timing.js'
|
|
3
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
1
4
|
import { DeviceInfo } from "../types.js"
|
|
2
5
|
import { listAndroidDevices } from "./android/utils.js"
|
|
3
6
|
import { listIOSDevices } from "./ios/utils.js"
|
|
@@ -44,9 +47,18 @@ export async function listDevices(platform?: "android" | "ios", appId?: string):
|
|
|
44
47
|
return iosDeviceLister(appId)
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
const deviceContext = new AsyncLocalStorage<DeviceInfo>()
|
|
51
|
+
|
|
52
|
+
export function withResolvedDevice<T>(device: DeviceInfo, operation: () => Promise<T>): Promise<T> {
|
|
53
|
+
return deviceContext.run(device, operation)
|
|
54
|
+
}
|
|
55
|
+
|
|
47
56
|
export async function resolveTargetDevice(opts: ResolveOptions): Promise<DeviceInfo> {
|
|
48
57
|
const { platform, appId, prefer, deviceId } = opts
|
|
49
|
-
const
|
|
58
|
+
const current = deviceContext.getStore()
|
|
59
|
+
if (current && current.platform === platform && (!deviceId || current.id === deviceId)) return current
|
|
60
|
+
const devices = await measure('device_resolution_ms', () => listDevices(platform, appId))
|
|
61
|
+
retainConnectedDeviceSnapshots(platform, devices.map(device => device.id))
|
|
50
62
|
|
|
51
63
|
// During unit tests (no adb/xcrun available), provide a lightweight mock device so
|
|
52
64
|
// the observe/interact unit tests can run without real devices.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
const timings = new AsyncLocalStorage<Record<string, number>>()
|
|
3
|
+
export function withTiming<T>(values: Record<string, number>, operation: () => Promise<T>) { return timings.run(values, operation) }
|
|
4
|
+
export function recordTiming(component: string, milliseconds: number) {
|
|
5
|
+
const values = timings.getStore()
|
|
6
|
+
if (values) values[component] = (values[component] ?? 0) + milliseconds
|
|
7
|
+
}
|
|
8
|
+
export async function measure<T>(component: string, operation: () => Promise<T>): Promise<T> {
|
|
9
|
+
const start = performance.now()
|
|
10
|
+
try { return await operation() }
|
|
11
|
+
finally {
|
|
12
|
+
const values = timings.getStore()
|
|
13
|
+
if (values) values[component] = (values[component] ?? 0) + performance.now() - start
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/utils/ui/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { remainingBudget } from '../operation-budget.js'
|
|
1
2
|
import crypto from 'crypto'
|
|
2
3
|
import { GetUITreeResponse, GetCurrentScreenResponse, UIElement, SwipeResponse } from '../../types.js'
|
|
3
4
|
|
|
@@ -152,6 +153,7 @@ export async function scrollToElementShared(opts: {
|
|
|
152
153
|
let scrollsPerformed = 0
|
|
153
154
|
|
|
154
155
|
for (let i = 0; i < maxScrolls; i++) {
|
|
156
|
+
remainingBudget()
|
|
155
157
|
const { x1, y1, x2, y2 } = computeCoords()
|
|
156
158
|
try {
|
|
157
159
|
await swipe(x1, y1, x2, y2, duration, deviceId)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
4
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
5
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
6
|
+
import { buildFixture } from '../../../fixtures/latency-journey-app/build.js'
|
|
7
|
+
import { summarize } from './report.js'
|
|
8
|
+
|
|
9
|
+
const appId = 'dev.mobiledebugmcp.latencyfixture'
|
|
10
|
+
const stepTimeoutMs = 30000
|
|
11
|
+
const targetWaitTimeoutMs = 10000
|
|
12
|
+
const verificationTimeoutMs = 60000
|
|
13
|
+
const deviceId = process.env.LATENCY_DEVICE_ID
|
|
14
|
+
if (!deviceId) throw new Error('Set LATENCY_DEVICE_ID to the named Android emulator serial')
|
|
15
|
+
const adb = (...args: string[]) => execFileSync('adb', ['-s', deviceId, ...args], { encoding: 'utf8', timeout: 30000 }).trim()
|
|
16
|
+
const apk = buildFixture()
|
|
17
|
+
const selectorMap = {
|
|
18
|
+
savedSession: 'saved-session', idleDetail: 'idle-detail', activeDetail: 'active-detail',
|
|
19
|
+
activate: 'activate', deactivate: 'deactivate', home: 'home', homeScreen: 'home-screen',
|
|
20
|
+
stop: 'stop', stopped: 'stopped'
|
|
21
|
+
}
|
|
22
|
+
const selector = (id: string) => ({ accessibility_id: id })
|
|
23
|
+
const flow = [
|
|
24
|
+
[selectorMap.savedSession, selectorMap.idleDetail], [selectorMap.activate, selectorMap.activeDetail],
|
|
25
|
+
[selectorMap.home, selectorMap.homeScreen], [selectorMap.savedSession, selectorMap.activeDetail],
|
|
26
|
+
[selectorMap.deactivate, selectorMap.idleDetail], [selectorMap.stop, selectorMap.stopped]
|
|
27
|
+
]
|
|
28
|
+
const transport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
|
|
29
|
+
transport.stderr?.on('data', chunk => process.stderr.write(`[server] ${chunk.toString()}`))
|
|
30
|
+
const client = new Client({ name: 'latency-benchmark', version: '1.0' })
|
|
31
|
+
const raw: any[] = []
|
|
32
|
+
const requestSequence: Record<string, any[]> = {}
|
|
33
|
+
let sequence: any[] = []
|
|
34
|
+
let failedSelector: string | null = null
|
|
35
|
+
let failedTool: string | null = null
|
|
36
|
+
async function call(name: string, args: any) {
|
|
37
|
+
failedTool = name
|
|
38
|
+
sequence.push({ tool: name, ...args, appId: args.appId ? '[fixture]' : undefined, deviceId: undefined, steps: args.steps?.map((step: any) => ({ ...step, appId: step.appId ? '[fixture]' : undefined })) })
|
|
39
|
+
const start = performance.now()
|
|
40
|
+
let response
|
|
41
|
+
try {
|
|
42
|
+
response = await client.callTool({ name, arguments: { platform: 'android', deviceId, ...args } }, undefined, { timeout: 300_000 })
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error(`MCP ${name} rejected: ${error instanceof Error ? error.stack : String(error)}`)
|
|
45
|
+
throw error
|
|
46
|
+
}
|
|
47
|
+
const elapsed = performance.now() - start
|
|
48
|
+
const text = (response.content as any[]).find(item => item.type === 'text')?.text
|
|
49
|
+
let data: any
|
|
50
|
+
try { data = JSON.parse(text) } catch { data = {} }
|
|
51
|
+
if (response.isError || data.success === false || data.status === 'timeout' || data.error) {
|
|
52
|
+
const failed = args.steps?.find((step: any) => step.id === data.stopped_at_step_id)
|
|
53
|
+
if (failed) { failedSelector = failed.selector?.accessibility_id ?? failed.assertion?.selector?.accessibility_id ?? null; failedTool = `run_journey:${failed.id}` }
|
|
54
|
+
throw new Error('Fixture step failed')
|
|
55
|
+
}
|
|
56
|
+
return { data, elapsed }
|
|
57
|
+
}
|
|
58
|
+
async function run(mode: string) {
|
|
59
|
+
sequence = []
|
|
60
|
+
failedSelector = null
|
|
61
|
+
failedTool = null
|
|
62
|
+
adb('shell', 'am', 'force-stop', appId)
|
|
63
|
+
try { adb('shell', 'pm', 'clear', appId) } catch { /* First install has no app data. */ }
|
|
64
|
+
adb('install', '-r', apk)
|
|
65
|
+
// Check the reset state outside the measured MCP flow, then force-stop once more.
|
|
66
|
+
failedTool = 'reset-state'
|
|
67
|
+
failedSelector = selectorMap.savedSession
|
|
68
|
+
adb('shell', 'am', 'start', '-n', `${appId}/.MainActivity`)
|
|
69
|
+
adb('shell', 'uiautomator', 'dump', '/sdcard/ui.xml')
|
|
70
|
+
if (!adb('shell', 'cat', '/sdcard/ui.xml').includes(`content-desc="${selectorMap.savedSession}"`)) throw new Error('Reset selector missing')
|
|
71
|
+
adb('shell', 'am', 'force-stop', appId)
|
|
72
|
+
sequence = []
|
|
73
|
+
const requestTimes: number[] = []
|
|
74
|
+
const componentTimes: Record<string, number[]> = {}
|
|
75
|
+
const record = (response: any) => {
|
|
76
|
+
requestTimes.push(response.elapsed)
|
|
77
|
+
const timing = response.data.timing ?? {}
|
|
78
|
+
const components = Object.entries(timing).filter(([key, value]) => key !== 'total_ms' && typeof value === 'number')
|
|
79
|
+
const stepTimings = (response.data.steps ?? []).flatMap((step: any) => Object.entries(step.result?.timing ?? {}))
|
|
80
|
+
const selected = components.length ? components : stepTimings
|
|
81
|
+
for (const [key, value] of selected) if (typeof value === 'number') (componentTimes[key] ??= []).push(value)
|
|
82
|
+
}
|
|
83
|
+
if (mode === 'baseline') {
|
|
84
|
+
record(await call('start_app', { appId, actionTimeoutMs: stepTimeoutMs, verificationTimeoutMs }))
|
|
85
|
+
for (const [target, expected] of flow) {
|
|
86
|
+
failedSelector = target
|
|
87
|
+
const found = await call('wait_for_ui', { selector: selector(target), condition: 'clickable', timeout_ms: targetWaitTimeoutMs, poll_interval_ms: 100 })
|
|
88
|
+
record(found)
|
|
89
|
+
record(await call('tap_element', { elementId: found.data.element.elementId, actionTimeoutMs: stepTimeoutMs, verificationTimeoutMs }))
|
|
90
|
+
failedSelector = expected
|
|
91
|
+
record(await call('expect_element_visible', { selector: selector(expected), timeout_ms: stepTimeoutMs, poll_interval_ms: 100 }))
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
const steps: any[] = [{ id: 'launch', type: 'start_app', appId }]
|
|
95
|
+
flow.forEach(([target, expected], index) => {
|
|
96
|
+
steps.push({ id: `tap-${index}`, type: 'tap', selector: selector(target), waitFor: { condition: 'clickable', timeoutMs: targetWaitTimeoutMs, pollIntervalMs: 100 } })
|
|
97
|
+
steps.push({ id: `assert-${index}`, type: 'assert', assertion: { kind: 'element_visible', selector: selector(expected) }, timeoutMs: stepTimeoutMs, pollIntervalMs: 100 })
|
|
98
|
+
})
|
|
99
|
+
record(await call('run_journey', { defaults: { verificationMode: mode === 'full' ? 'full' : 'light', actionTimeoutMs: stepTimeoutMs, verificationTimeoutMs }, responseMode: mode === 'full' ? 'debug' : 'compact', steps }))
|
|
100
|
+
}
|
|
101
|
+
requestSequence[mode] ??= sequence
|
|
102
|
+
const screenshot = await call('capture_screenshot', {})
|
|
103
|
+
const snapshot = await call('capture_debug_snapshot', { includeScreenshot: false, includeLogs: false })
|
|
104
|
+
return { total_ms: requestTimes.reduce((a, b) => a + b, 0), componentTimes, screenshot_ms: screenshot.elapsed, snapshot_ms: snapshot.elapsed }
|
|
105
|
+
}
|
|
106
|
+
async function main() {
|
|
107
|
+
await client.connect(transport)
|
|
108
|
+
try {
|
|
109
|
+
for (const mode of ['baseline', 'full', 'light']) {
|
|
110
|
+
for (let iteration = 0; iteration < 13; iteration++) {
|
|
111
|
+
console.log(`${mode} ${iteration + 1}/13`)
|
|
112
|
+
try { raw.push({ mode, warmup: iteration < 3, success: true, ...await run(mode) }) }
|
|
113
|
+
catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`${mode} run failed at ${failedTool}/${failedSelector}: ${message}`); raw.push({ mode, warmup: iteration < 3, success: false, failed_selector: failedSelector, failed_tool: failedTool, error: message }) }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} finally { await client.close() }
|
|
117
|
+
const modes = Object.fromEntries(['baseline', 'full', 'light'].map(mode => {
|
|
118
|
+
const samples = raw.filter(item => item.mode === mode && !item.warmup && item.success)
|
|
119
|
+
const failures = raw.filter(item => item.mode === mode && !item.success)
|
|
120
|
+
return [mode, { failures: failures.length, first_failure: failures[0] ? { selector: failures[0].failed_selector, tool_or_step: failures[0].failed_tool } : null, total_ms: summarize(samples.map(item => item.total_ms)), screenshot_ms: summarize(samples.map(item => item.screenshot_ms)), snapshot_ms: summarize(samples.map(item => item.snapshot_ms)), components: Object.fromEntries([...new Set(samples.flatMap(item => Object.keys(item.componentTimes)))].map(key => [key, summarize(samples.flatMap(item => item.componentTimes[key] ?? []))])) }]
|
|
121
|
+
})) as any
|
|
122
|
+
const report = {
|
|
123
|
+
commit: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), working_tree: 'candidate',
|
|
124
|
+
server_version: JSON.parse(readFileSync('package.json', 'utf8')).version,
|
|
125
|
+
platform: 'android', model: adb('shell', 'getprop', 'ro.product.model'), os: adb('shell', 'getprop', 'ro.build.version.release'), api: adb('shell', 'getprop', 'ro.build.version.sdk'), emulator_image: adb('emu', 'avd', 'name').split('\n')[0].trim(), display_size: adb('shell', 'wm', 'size'), display_density: adb('shell', 'wm', 'density'), adb_timeout_ms: Number(process.env.MCP_ADB_TIMEOUT ?? 20000),
|
|
126
|
+
fixture_version: '1.0', fixture_sha256: createHash('sha256').update(readFileSync(apk)).digest('hex'), warmup_runs: 3, measured_runs: 10,
|
|
127
|
+
modes, requests: requestSequence, raw,
|
|
128
|
+
gates: { full_p95: !!modes.baseline.total_ms && !!modes.full.total_ms && modes.full.total_ms.p95 <= modes.baseline.total_ms.p95 * 1.1, light_p50: !!modes.full.total_ms && !!modes.light.total_ms && modes.light.total_ms.p50 <= modes.full.total_ms.p50 * .7 }
|
|
129
|
+
}
|
|
130
|
+
const root = 'docs/benchmarks/interaction-latency'
|
|
131
|
+
mkdirSync(root, { recursive: true })
|
|
132
|
+
writeFileSync(`${root}/final.json`, JSON.stringify(report, null, 2))
|
|
133
|
+
writeFileSync(`${root}/final.md`, `# Interaction latency benchmark\n\nCommit: ${report.commit} (working tree candidate). Fixture: ${report.fixture_version}, SHA-256 ${report.fixture_sha256}.\n\nEnvironment: ${report.emulator_image}, Android ${report.os}, API ${report.api}. Three warm-ups and ten measured runs per mode. Failed runs excluded.\n\n| Mode | p50 ms | p95 ms | Failures |\n|---|---:|---:|---:|\n${Object.entries(modes).map(([mode, value]: [string, any]) => `| ${mode} | ${value.total_ms?.p50 ?? 'n/a'} | ${value.total_ms?.p95 ?? 'n/a'} | ${value.failures} |`).join('\n')}\n\nFirst failure by mode: ${Object.entries(modes).map(([mode, value]: [string, any]) => `${mode}: ${value.first_failure ? `${value.first_failure.tool_or_step} / ${value.first_failure.selector ?? 'no selector'}` : 'none'}`).join('; ')}.\n\nFull p95 gate: ${report.gates.full_p95}. Light p50 gate: ${report.gates.light_p50}.\n\nSee final.json for requests, timing components, and failure selectors.\n`)
|
|
134
|
+
if (!report.gates.full_p95 || !report.gates.light_p50) process.exitCode = 1
|
|
135
|
+
}
|
|
136
|
+
main().catch(error => { console.error(error); process.exitCode = 1 })
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export function summarize(samples: number[]) {
|
|
2
|
+
if (!samples.length) return null
|
|
3
|
+
const sorted = [...samples].sort((a, b) => a - b)
|
|
4
|
+
const percentile = (p: number) => sorted[Math.max(0, Math.ceil(p * sorted.length) - 1)]
|
|
5
|
+
return { count: samples.length, p50: percentile(.5), p95: percentile(.95), min: sorted[0], max: sorted[sorted.length - 1] }
|
|
6
|
+
}
|