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/observe/android.ts
CHANGED
|
@@ -8,6 +8,9 @@ import path from "path"
|
|
|
8
8
|
import { computeScreenFingerprint } from "../utils/ui/index.js"
|
|
9
9
|
import { parsePngSize } from "../utils/image.js"
|
|
10
10
|
import { deriveSnapshotMetadata } from "./snapshot-metadata.js"
|
|
11
|
+
import { remainingBudget } from '../utils/operation-budget.js'
|
|
12
|
+
import { createObservationMeasurement } from './performance-metrics.js'
|
|
13
|
+
import { androidTreeBridgeEnabled, readAndroidTreeFromBridge } from './android-tree-bridge.js'
|
|
11
14
|
|
|
12
15
|
const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
|
|
13
16
|
|
|
@@ -17,12 +20,20 @@ export class AndroidObserve {
|
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
async getUITree(deviceId?: string): Promise<GetUITreeResponse> {
|
|
20
|
-
const
|
|
23
|
+
const measurement = createObservationMeasurement('android')
|
|
24
|
+
let metadata
|
|
25
|
+
try {
|
|
26
|
+
metadata = await measurement.timed('metadata_ms', () => getAndroidDeviceMetadata("", deviceId))
|
|
27
|
+
} catch (error) {
|
|
28
|
+
measurement.setOutcome('unavailable')
|
|
29
|
+
measurement.finish()
|
|
30
|
+
throw error
|
|
31
|
+
}
|
|
21
32
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
22
33
|
|
|
23
34
|
try {
|
|
24
35
|
// Get screen resolution first
|
|
25
|
-
const resolution = await getScreenResolution(deviceId);
|
|
36
|
+
const resolution = await measurement.timed('resolution_ms', () => getScreenResolution(deviceId));
|
|
26
37
|
if (resolution.width === 0 && resolution.height === 0) {
|
|
27
38
|
throw new Error("Failed to get screen resolution. Is the device connected and authorized?");
|
|
28
39
|
}
|
|
@@ -33,22 +44,37 @@ export class AndroidObserve {
|
|
|
33
44
|
const maxAttempts = 3;
|
|
34
45
|
|
|
35
46
|
while (attempts < maxAttempts) {
|
|
47
|
+
remainingBudget()
|
|
36
48
|
attempts++;
|
|
37
49
|
try {
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
// The first read starts immediately so it remains fresh for in-place UI changes.
|
|
51
|
+
// Delay only a retry after a failed or invalid acquisition.
|
|
52
|
+
if (attempts > 1) {
|
|
53
|
+
await measurement.timed('observation_wait_ms', () => delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))));
|
|
54
|
+
}
|
|
55
|
+
remainingBudget()
|
|
56
|
+
|
|
57
|
+
let bridgeContent: string | null = null
|
|
58
|
+
if (androidTreeBridgeEnabled()) {
|
|
59
|
+
try {
|
|
60
|
+
bridgeContent = await measurement.physicalRead(async () => {
|
|
61
|
+
const content = await readAndroidTreeFromBridge(deviceInfo.id)
|
|
62
|
+
if (!content) throw new Error('Android UI tree bridge unavailable')
|
|
63
|
+
return content
|
|
64
|
+
})
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error instanceof Error && (error.message === 'ACTION_TIMEOUT' || error.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED'))) throw error
|
|
67
|
+
}
|
|
50
68
|
}
|
|
69
|
+
xmlContent = bridgeContent ?? await measurement.physicalRead(async () => {
|
|
70
|
+
await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId)
|
|
71
|
+
const content = await execAdb(['shell', 'cat', '/sdcard/ui.xml'], deviceId)
|
|
72
|
+
if (!content || !content.trim() || content.includes('ERROR:')) throw new Error('Empty or invalid UI dump')
|
|
73
|
+
return content
|
|
74
|
+
})
|
|
75
|
+
break
|
|
51
76
|
} catch (e) {
|
|
77
|
+
if (e instanceof Error && (e.message === 'ACTION_TIMEOUT' || e.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED'))) throw e
|
|
52
78
|
console.error(`Attempt ${attempts} failed: ${e}`);
|
|
53
79
|
}
|
|
54
80
|
|
|
@@ -61,7 +87,7 @@ export class AndroidObserve {
|
|
|
61
87
|
ignoreAttributes: false,
|
|
62
88
|
attributeNamePrefix: "@_"
|
|
63
89
|
});
|
|
64
|
-
const result = parser.parse(xmlContent);
|
|
90
|
+
const result = await measurement.timed('parse_ms', async () => parser.parse(xmlContent));
|
|
65
91
|
|
|
66
92
|
const elements: UIElement[] = [];
|
|
67
93
|
|
|
@@ -81,17 +107,22 @@ export class AndroidObserve {
|
|
|
81
107
|
elements
|
|
82
108
|
}, 'ui_tree')
|
|
83
109
|
|
|
84
|
-
|
|
110
|
+
measurement.setOutcome('observed')
|
|
111
|
+
const response = measurement.bindObservation({
|
|
85
112
|
device: deviceInfo,
|
|
86
113
|
screen: "",
|
|
87
114
|
resolution,
|
|
88
115
|
elements,
|
|
89
116
|
...snapshotMetadata
|
|
90
|
-
}
|
|
117
|
+
})
|
|
118
|
+
measurement.finish()
|
|
119
|
+
return response
|
|
91
120
|
} catch (e) {
|
|
92
121
|
const errorMessage = `Failed to get UI tree. ADB Path: '${getAdbCmd()}'. Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
93
122
|
console.error(errorMessage);
|
|
94
123
|
const snapshotMetadata = deriveSnapshotMetadata(`android:${deviceInfo.id}`, null, 'ui_tree')
|
|
124
|
+
measurement.setOutcome('unavailable')
|
|
125
|
+
measurement.finish()
|
|
95
126
|
return {
|
|
96
127
|
device: deviceInfo,
|
|
97
128
|
screen: "",
|
package/src/observe/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { measure } from '../utils/timing.js'
|
|
1
2
|
import { resolveTargetDevice } from '../utils/resolve-device.js'
|
|
2
3
|
import { AndroidObserve } from './android.js'
|
|
3
4
|
import { iOSObserve } from './ios.js'
|
|
@@ -5,7 +6,7 @@ import type {
|
|
|
5
6
|
CaptureDebugSnapshotRawResponse,
|
|
6
7
|
SnapshotSemanticResponse
|
|
7
8
|
} from '../types.js'
|
|
8
|
-
import { deriveSnapshotMetadata } from './snapshot-metadata.js'
|
|
9
|
+
import { deriveSnapshotMetadata, getLatestStateDelta, getStateDelta } from './snapshot-metadata.js'
|
|
9
10
|
|
|
10
11
|
export { AndroidObserve } from './android.js'
|
|
11
12
|
export { iOSObserve } from './ios.js'
|
|
@@ -149,6 +150,9 @@ function deriveSnapshotSemantic(raw: CaptureDebugSnapshotRawResponse): SnapshotS
|
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
export class ToolsObserve {
|
|
153
|
+
static getLatestStateDelta(platform: 'android' | 'ios', deviceId: string) {
|
|
154
|
+
return getLatestStateDelta(`${platform}:${deviceId}`)
|
|
155
|
+
}
|
|
152
156
|
// Resolve a target device and return the appropriate observe instance and resolved info.
|
|
153
157
|
private static async resolveObserve(platform?: 'android' | 'ios', deviceId?: string, appId?: string) {
|
|
154
158
|
if (platform === 'android') {
|
|
@@ -170,9 +174,21 @@ export class ToolsObserve {
|
|
|
170
174
|
}
|
|
171
175
|
}
|
|
172
176
|
|
|
173
|
-
static async getUITreeHandler({ platform, deviceId }: { platform?: 'android' | 'ios', deviceId?: string }) {
|
|
177
|
+
static async getUITreeHandler({ platform, deviceId, responseMode = 'debug', sinceSnapshotRevision }: { platform?: 'android' | 'ios', deviceId?: string, responseMode?: 'compact' | 'debug', sinceSnapshotRevision?: number }) {
|
|
174
178
|
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId)
|
|
175
|
-
|
|
179
|
+
const tree = await observe.getUITree(resolved.id) as any
|
|
180
|
+
if (responseMode !== 'compact') return tree
|
|
181
|
+
const revision = tree?.snapshot_revision ?? null
|
|
182
|
+
const baseRevision = sinceSnapshotRevision
|
|
183
|
+
if (!Number.isInteger(baseRevision) || revision === null) {
|
|
184
|
+
return { delta_available: false, snapshot_revision: revision }
|
|
185
|
+
}
|
|
186
|
+
const delta = getStateDelta(`${resolved.platform}:${resolved.id}`, baseRevision as number, revision)
|
|
187
|
+
return delta ? { delta_available: true, state_delta: delta, snapshot_revision: revision } : {
|
|
188
|
+
delta_available: false,
|
|
189
|
+
snapshot_revision: revision,
|
|
190
|
+
reason: 'BASE_REVISION_UNAVAILABLE'
|
|
191
|
+
}
|
|
176
192
|
}
|
|
177
193
|
|
|
178
194
|
static async getCurrentScreenHandler({ deviceId }: { deviceId?: string }) {
|
|
@@ -244,7 +260,7 @@ export class ToolsObserve {
|
|
|
244
260
|
return await (observe as any).getScreenFingerprint(resolved.id)
|
|
245
261
|
}
|
|
246
262
|
|
|
247
|
-
static async captureDebugSnapshotHandler({ reason, includeLogs = true, logLines = 200, platform, appId, deviceId, sessionId }: { reason?: string; includeLogs?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string } = {}) {
|
|
263
|
+
static async captureDebugSnapshotHandler({ reason, includeLogs = true, includeScreenshot = true, logLines = 200, platform, appId, deviceId, sessionId }: { reason?: string; includeLogs?: boolean; includeScreenshot?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string } = {}) {
|
|
248
264
|
const timestamp = Date.now()
|
|
249
265
|
const raw: CaptureDebugSnapshotRawResponse = {
|
|
250
266
|
timestamp,
|
|
@@ -261,7 +277,7 @@ export class ToolsObserve {
|
|
|
261
277
|
// Parallel fetches for performance: screenshot, current screen, fingerprint, ui tree, and log stream/get logs
|
|
262
278
|
const sid = sessionId || 'default'
|
|
263
279
|
const tasks: Record<string, Promise<any>> = {
|
|
264
|
-
screenshot: ToolsObserve.captureScreenshotHandler({ platform, deviceId }),
|
|
280
|
+
screenshot: includeScreenshot ? measure('screenshot_ms', () => ToolsObserve.captureScreenshotHandler({ platform, deviceId })) : Promise.resolve(null),
|
|
265
281
|
currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
|
|
266
282
|
fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
|
|
267
283
|
uiTree: ToolsObserve.getUITreeHandler({ platform, deviceId }),
|
package/src/observe/ios.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { spawn } from "child_process"
|
|
2
|
+
import { remainingBudget } from '../utils/operation-budget.js'
|
|
3
|
+
import { execCmd } from '../utils/exec.js'
|
|
4
|
+
import { createObservationMeasurement } from './performance-metrics.js'
|
|
2
5
|
import { promises as fs } from "fs"
|
|
3
6
|
import { GetLogsResponse, CaptureIOSScreenshotResponse, GetUITreeResponse, UIElement, DeviceInfo, UIElementSemanticMetadata, UIElementState, UIResolutionSelector, SelectorConfidence } from "../types.js"
|
|
4
7
|
import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "../utils/ios/utils.js"
|
|
@@ -524,12 +527,22 @@ export class iOSObserve {
|
|
|
524
527
|
}
|
|
525
528
|
|
|
526
529
|
async getUITree(deviceId: string = "booted"): Promise<GetUITreeResponse> {
|
|
527
|
-
const
|
|
530
|
+
const measurement = createObservationMeasurement('ios')
|
|
531
|
+
let device
|
|
532
|
+
try {
|
|
533
|
+
device = await measurement.timed('metadata_ms', () => getIOSDeviceMetadata(deviceId))
|
|
534
|
+
} catch (error) {
|
|
535
|
+
measurement.setOutcome('unavailable')
|
|
536
|
+
measurement.finish()
|
|
537
|
+
throw error
|
|
538
|
+
}
|
|
528
539
|
const deviceKey = `ios:${device.id}`
|
|
529
540
|
|
|
530
|
-
const idbExists = await isIDBInstalled();
|
|
541
|
+
const idbExists = await measurement.timed('capability_check_ms', () => isIDBInstalled());
|
|
531
542
|
if (!idbExists) {
|
|
532
543
|
const snapshotMetadata = deriveSnapshotMetadata(deviceKey, null, 'ui_tree')
|
|
544
|
+
measurement.setOutcome('unavailable')
|
|
545
|
+
measurement.finish()
|
|
533
546
|
return {
|
|
534
547
|
device,
|
|
535
548
|
screen: "",
|
|
@@ -547,44 +560,38 @@ export class iOSObserve {
|
|
|
547
560
|
const maxAttempts = 3;
|
|
548
561
|
|
|
549
562
|
while (attempts < maxAttempts) {
|
|
563
|
+
remainingBudget()
|
|
550
564
|
attempts++;
|
|
551
565
|
try {
|
|
552
|
-
await delay(300 + (attempts * 100));
|
|
566
|
+
await measurement.timed('observation_wait_ms', () => delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))));
|
|
567
|
+
remainingBudget()
|
|
553
568
|
|
|
554
569
|
const args = ['ui', 'describe-all', '--json'];
|
|
555
570
|
if (targetUdid) {
|
|
556
571
|
args.push('--udid', targetUdid);
|
|
557
572
|
}
|
|
558
573
|
|
|
559
|
-
const output = await
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
child.on('error', (err) => reject(new Error(`Failed to execute idb: ${err.message}`)));
|
|
568
|
-
|
|
569
|
-
child.on('close', (code) => {
|
|
570
|
-
if (code !== 0) {
|
|
571
|
-
reject(new Error(`idb failed (code ${code}): ${stderr.trim()}`));
|
|
572
|
-
} else {
|
|
573
|
-
resolve(stdout);
|
|
574
|
-
}
|
|
575
|
-
});
|
|
576
|
-
});
|
|
574
|
+
const output = await measurement.physicalRead(async () => {
|
|
575
|
+
const result = await execCmd(getIdbCmd(), args, { timeout: remainingBudget() })
|
|
576
|
+
if (result.stderr === 'ACTION_TIMEOUT') throw new Error('ACTION_TIMEOUT')
|
|
577
|
+
if (result.exitCode !== 0) throw new Error(`idb failed (code ${result.exitCode}): ${result.stderr}`)
|
|
578
|
+
if (!result.stdout.trim()) throw new Error('Empty idb UI tree output')
|
|
579
|
+
return result.stdout
|
|
580
|
+
})
|
|
577
581
|
|
|
578
582
|
if (output && output.trim().length > 0) {
|
|
579
|
-
jsonContent = JSON.parse(output);
|
|
583
|
+
jsonContent = await measurement.timed('parse_ms', async () => JSON.parse(output));
|
|
580
584
|
break; // Success
|
|
581
585
|
}
|
|
582
586
|
} catch (e) {
|
|
587
|
+
if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
|
|
583
588
|
console.error(`Attempt ${attempts} failed: ${e}`);
|
|
584
589
|
}
|
|
585
590
|
|
|
586
591
|
if (attempts === maxAttempts) {
|
|
587
592
|
const snapshotMetadata = deriveSnapshotMetadata(deviceKey, null, 'ui_tree')
|
|
593
|
+
measurement.setOutcome('unavailable')
|
|
594
|
+
measurement.finish()
|
|
588
595
|
return {
|
|
589
596
|
device,
|
|
590
597
|
screen: "",
|
|
@@ -597,14 +604,15 @@ export class iOSObserve {
|
|
|
597
604
|
}
|
|
598
605
|
|
|
599
606
|
try {
|
|
600
|
-
const elements: UIElement[] =
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
traverseIDBNode(node,
|
|
607
|
+
const elements: UIElement[] = await measurement.timed('conversion_ms', async () => {
|
|
608
|
+
const parsed: UIElement[] = []
|
|
609
|
+
if (Array.isArray(jsonContent)) {
|
|
610
|
+
for (const node of jsonContent) traverseIDBNode(node, parsed)
|
|
611
|
+
} else {
|
|
612
|
+
traverseIDBNode(jsonContent, parsed)
|
|
604
613
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
}
|
|
614
|
+
return parsed
|
|
615
|
+
})
|
|
608
616
|
|
|
609
617
|
let width = 0;
|
|
610
618
|
let height = 0;
|
|
@@ -620,15 +628,20 @@ export class iOSObserve {
|
|
|
620
628
|
elements
|
|
621
629
|
}, 'ui_tree')
|
|
622
630
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
631
|
+
measurement.setOutcome('observed')
|
|
632
|
+
const response = measurement.bindObservation({
|
|
633
|
+
device,
|
|
634
|
+
screen: "",
|
|
635
|
+
resolution: { width, height },
|
|
636
|
+
elements,
|
|
637
|
+
...snapshotMetadata
|
|
638
|
+
})
|
|
639
|
+
measurement.finish()
|
|
640
|
+
return response
|
|
630
641
|
} catch (e) {
|
|
631
642
|
const snapshotMetadata = deriveSnapshotMetadata(deviceKey, null, 'ui_tree')
|
|
643
|
+
measurement.setOutcome('unavailable')
|
|
644
|
+
measurement.finish()
|
|
632
645
|
return {
|
|
633
646
|
device,
|
|
634
647
|
screen: "",
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
3
|
+
|
|
4
|
+
export type ObservationPurpose = 'target' | 'verification' | 'assertion' | 'debug' | 'unspecified'
|
|
5
|
+
|
|
6
|
+
interface ObservationContext {
|
|
7
|
+
stepId?: string
|
|
8
|
+
purpose: ObservationPurpose
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const context = new AsyncLocalStorage<ObservationContext>()
|
|
12
|
+
const observationIds = new WeakMap<object, string>()
|
|
13
|
+
|
|
14
|
+
export function withObservationContext<T>(value: Partial<ObservationContext>, operation: () => Promise<T>): Promise<T> {
|
|
15
|
+
if (process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS !== '1') return operation()
|
|
16
|
+
return context.run({ purpose: value.purpose ?? context.getStore()?.purpose ?? 'unspecified', stepId: value.stepId ?? context.getStore()?.stepId }, operation)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function recordObservationEvent(event: Record<string, unknown>) {
|
|
20
|
+
if (process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS !== '1') return
|
|
21
|
+
const current = context.getStore()
|
|
22
|
+
process.stderr.write(`${JSON.stringify({
|
|
23
|
+
event: 'ui_tree_metric',
|
|
24
|
+
host_monotonic_ns: process.hrtime.bigint().toString(),
|
|
25
|
+
type: 'action_observation',
|
|
26
|
+
step_id: current?.stepId,
|
|
27
|
+
purpose: current?.purpose ?? 'unspecified',
|
|
28
|
+
...event
|
|
29
|
+
})}\n`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function recordObservationConsumer(observation: unknown, purpose: ObservationPurpose, stepId?: string) {
|
|
33
|
+
if (process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS !== '1' || !observation || typeof observation !== 'object') return
|
|
34
|
+
const acquisitionId = observationIds.get(observation)
|
|
35
|
+
if (!acquisitionId) return
|
|
36
|
+
const current = context.getStore()
|
|
37
|
+
process.stderr.write(`${JSON.stringify({
|
|
38
|
+
event: 'ui_tree_metric',
|
|
39
|
+
host_monotonic_ns: process.hrtime.bigint().toString(),
|
|
40
|
+
type: 'observation_consumer',
|
|
41
|
+
acquisition_id: acquisitionId,
|
|
42
|
+
consumer_step_id: stepId ?? current?.stepId,
|
|
43
|
+
consumer_purpose: purpose,
|
|
44
|
+
reused_observations: 1
|
|
45
|
+
})}\n`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createObservationMeasurement(platform: 'android' | 'ios') {
|
|
49
|
+
const enabled = process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS === '1'
|
|
50
|
+
const startedAt = enabled ? performance.now() : 0
|
|
51
|
+
const id = enabled ? randomUUID() : undefined
|
|
52
|
+
const current = context.getStore()
|
|
53
|
+
const durations: Record<string, number> = {}
|
|
54
|
+
let successfulOutput = false
|
|
55
|
+
let outcome: 'observed' | 'unavailable' = 'unavailable'
|
|
56
|
+
let readAttempt = 0
|
|
57
|
+
|
|
58
|
+
const emit = (event: Record<string, unknown>) => {
|
|
59
|
+
if (!enabled) return
|
|
60
|
+
process.stderr.write(`${JSON.stringify({
|
|
61
|
+
event: 'ui_tree_metric',
|
|
62
|
+
host_monotonic_ns: process.hrtime.bigint().toString(),
|
|
63
|
+
acquisition_id: id,
|
|
64
|
+
platform,
|
|
65
|
+
step_id: current?.stepId,
|
|
66
|
+
purpose: current?.purpose ?? 'unspecified',
|
|
67
|
+
...event
|
|
68
|
+
})}\n`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
id,
|
|
73
|
+
bindObservation<T extends object>(observation: T): T {
|
|
74
|
+
if (enabled && id) observationIds.set(observation, id)
|
|
75
|
+
return observation
|
|
76
|
+
},
|
|
77
|
+
timed: async <T>(stage: string, operation: () => Promise<T>): Promise<T> => {
|
|
78
|
+
if (!enabled) return operation()
|
|
79
|
+
const start = performance.now()
|
|
80
|
+
let success = true
|
|
81
|
+
try {
|
|
82
|
+
return await operation()
|
|
83
|
+
} catch (error) {
|
|
84
|
+
success = false
|
|
85
|
+
throw error
|
|
86
|
+
} finally {
|
|
87
|
+
const elapsed = performance.now() - start
|
|
88
|
+
durations[stage] = (durations[stage] ?? 0) + elapsed
|
|
89
|
+
emit({ type: 'stage', stage, attempt_index: stage === 'parse_ms' ? readAttempt : undefined, elapsed_ms: elapsed, success })
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
physicalRead: async <T>(operation: () => Promise<T>): Promise<T> => {
|
|
93
|
+
if (!enabled) return operation()
|
|
94
|
+
const start = performance.now()
|
|
95
|
+
readAttempt++
|
|
96
|
+
emit({ type: 'physical_read_started', attempt_index: readAttempt })
|
|
97
|
+
try {
|
|
98
|
+
const result = await operation()
|
|
99
|
+
successfulOutput = true
|
|
100
|
+
emit({ type: 'physical_read_completed', attempt_index: readAttempt, elapsed_ms: performance.now() - start, success: true })
|
|
101
|
+
return result
|
|
102
|
+
} catch (error) {
|
|
103
|
+
emit({ type: 'physical_read_completed', attempt_index: readAttempt, elapsed_ms: performance.now() - start, success: false })
|
|
104
|
+
throw error
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
setOutcome(value: 'observed' | 'unavailable') { if (enabled) outcome = value },
|
|
108
|
+
finish() {
|
|
109
|
+
if (!enabled) return
|
|
110
|
+
emit({
|
|
111
|
+
type: 'observation_consumer',
|
|
112
|
+
consumer_step_id: current?.stepId,
|
|
113
|
+
consumer_purpose: current?.purpose ?? 'unspecified',
|
|
114
|
+
reused_observations: 0
|
|
115
|
+
})
|
|
116
|
+
emit({
|
|
117
|
+
type: 'acquisition_completed',
|
|
118
|
+
elapsed_ms: performance.now() - startedAt,
|
|
119
|
+
successful_output: successfulOutput,
|
|
120
|
+
outcome,
|
|
121
|
+
durations_ms: durations
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -5,8 +5,15 @@ interface SnapshotState {
|
|
|
5
5
|
revision: number
|
|
6
6
|
signature: string | null
|
|
7
7
|
elementSignatures: Map<string, string>
|
|
8
|
+
elements: Map<string, UIElement>
|
|
9
|
+
history: Map<number, Map<string, UIElement>>
|
|
10
|
+
historyTimes: Map<number, number>
|
|
11
|
+
oversized: Map<number, number>
|
|
12
|
+
previousRevision: number | null
|
|
13
|
+
updatedAt: number
|
|
8
14
|
}
|
|
9
15
|
|
|
16
|
+
let nextRevision = 1
|
|
10
17
|
const snapshotStateByDevice = new Map<string, SnapshotState>()
|
|
11
18
|
|
|
12
19
|
function normalize(value: unknown): string {
|
|
@@ -53,18 +60,20 @@ function stableElementIdentity(element: UIElement, index: number) {
|
|
|
53
60
|
})).digest('hex')}`
|
|
54
61
|
}
|
|
55
62
|
|
|
56
|
-
function
|
|
63
|
+
function buildElementRecords(tree: Pick<GetUITreeResponse, 'elements'> | null | undefined) {
|
|
57
64
|
const signatures = new Map<string, string>()
|
|
65
|
+
const elementsByIdentity = new Map<string, UIElement>()
|
|
58
66
|
const elements = Array.isArray(tree?.elements) ? tree!.elements! : []
|
|
59
67
|
|
|
60
|
-
for (let index = 0; index < elements.length; index++) {
|
|
68
|
+
for (let index = 0; index < Math.min(elements.length, 500); index++) {
|
|
61
69
|
const element = elements[index]
|
|
62
70
|
if (!element) continue
|
|
63
71
|
const identity = stableElementIdentity(element, index)
|
|
64
72
|
signatures.set(identity, crypto.createHash('sha1').update(JSON.stringify(stableElementSignature(element))).digest('hex'))
|
|
73
|
+
elementsByIdentity.set(identity, structuredClone(element))
|
|
65
74
|
}
|
|
66
75
|
|
|
67
|
-
return signatures
|
|
76
|
+
return { signatures, elementsByIdentity }
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
function summarizeSnapshotDelta(previous: SnapshotState | undefined, currentElements: Map<string, string>): SnapshotDelta | null {
|
|
@@ -140,25 +149,45 @@ export function deriveSnapshotMetadata(
|
|
|
140
149
|
signatureOverride?: string | null
|
|
141
150
|
) {
|
|
142
151
|
const signature = signatureOverride ?? computeSnapshotSignature(tree)
|
|
143
|
-
const
|
|
152
|
+
const existing = snapshotStateByDevice.get(deviceKey)
|
|
153
|
+
const previous = existing && Date.now() - existing.updatedAt <= 10 * 60 * 1000 ? existing : undefined
|
|
154
|
+
if (!previous && existing) snapshotStateByDevice.delete(deviceKey)
|
|
144
155
|
const hasValidTree = !!tree && !tree.error
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
156
|
+
const records = hasValidTree ? buildElementRecords(tree) : null
|
|
157
|
+
const currentElementSignatures = records?.signatures ?? previous?.elementSignatures ?? new Map<string, string>()
|
|
158
|
+
const currentElements = records?.elementsByIdentity ?? previous?.elements ?? new Map<string, UIElement>()
|
|
159
|
+
|
|
160
|
+
const revision = previous && (signature === null || previous.signature === signature) ? previous.revision : nextRevision++
|
|
161
|
+
const history = previous?.history ?? new Map<number, Map<string, UIElement>>()
|
|
162
|
+
const historyTimes = previous?.historyTimes ?? new Map<number, number>()
|
|
163
|
+
const oversized = previous?.oversized ?? new Map<number, number>()
|
|
164
|
+
if (hasValidTree) {
|
|
165
|
+
history.set(revision, currentElements)
|
|
166
|
+
if (!historyTimes.has(revision)) historyTimes.set(revision, Date.now())
|
|
167
|
+
if ((tree.elements?.length ?? 0) > 500) oversized.set(revision, (tree.elements?.length ?? 0) - 500)
|
|
168
|
+
}
|
|
169
|
+
for (const [id, timestamp] of historyTimes) {
|
|
170
|
+
if (Date.now() - timestamp > 10 * 60 * 1000) {
|
|
171
|
+
history.delete(id)
|
|
172
|
+
historyTimes.delete(id)
|
|
173
|
+
oversized.delete(id)
|
|
155
174
|
}
|
|
156
175
|
}
|
|
176
|
+
while (history.size > 8) {
|
|
177
|
+
const oldest = history.keys().next().value
|
|
178
|
+
if (oldest === undefined) break
|
|
179
|
+
history.delete(oldest)
|
|
180
|
+
historyTimes.delete(oldest)
|
|
181
|
+
oversized.delete(oldest)
|
|
182
|
+
}
|
|
157
183
|
|
|
158
184
|
snapshotStateByDevice.set(deviceKey, {
|
|
159
185
|
revision,
|
|
160
186
|
signature,
|
|
161
|
-
elementSignatures: currentElementSignatures
|
|
187
|
+
elementSignatures: currentElementSignatures,
|
|
188
|
+
elements: currentElements,
|
|
189
|
+
history, historyTimes, oversized, previousRevision: previous?.revision ?? null,
|
|
190
|
+
updatedAt: Date.now()
|
|
162
191
|
})
|
|
163
192
|
|
|
164
193
|
return {
|
|
@@ -169,6 +198,56 @@ export function deriveSnapshotMetadata(
|
|
|
169
198
|
}
|
|
170
199
|
}
|
|
171
200
|
|
|
201
|
+
export function getStateDelta(deviceKey: string, baseRevision: number, currentRevision: number) {
|
|
202
|
+
const state = snapshotStateByDevice.get(deviceKey)
|
|
203
|
+
if (!state || Date.now() - (state.historyTimes.get(baseRevision) ?? 0) > 10 * 60 * 1000 || Date.now() - state.updatedAt > 10 * 60 * 1000) return null
|
|
204
|
+
const base = state.history.get(baseRevision)
|
|
205
|
+
const current = state?.history.get(currentRevision)
|
|
206
|
+
if (!base || !current) return null
|
|
207
|
+
|
|
208
|
+
const added: UIElement[] = []
|
|
209
|
+
const changed: UIElement[] = []
|
|
210
|
+
const removed: Array<{ stable_id?: string, resourceId?: string, contentDescription?: string, text?: string, index: number }> = []
|
|
211
|
+
for (const [identity, element] of current) {
|
|
212
|
+
const before = base.get(identity)
|
|
213
|
+
if (!before) added.push(element)
|
|
214
|
+
else if (JSON.stringify(stableElementSignature(before)) !== JSON.stringify(stableElementSignature(element))) changed.push(element)
|
|
215
|
+
}
|
|
216
|
+
let index = 0
|
|
217
|
+
for (const [identity, element] of base) {
|
|
218
|
+
if (!current.has(identity)) removed.push({ stable_id: element.stable_id ?? undefined, resourceId: element.resourceId ?? undefined, contentDescription: element.contentDescription ?? undefined, text: element.text ?? undefined, index })
|
|
219
|
+
index++
|
|
220
|
+
}
|
|
221
|
+
const all = added.length + changed.length + removed.length
|
|
222
|
+
const limit = 200
|
|
223
|
+
return {
|
|
224
|
+
base_snapshot_revision: baseRevision,
|
|
225
|
+
snapshot_revision: currentRevision,
|
|
226
|
+
added: added.slice(0, limit),
|
|
227
|
+
changed: changed.slice(0, Math.max(0, limit - added.length)),
|
|
228
|
+
removed: removed.slice(0, Math.max(0, limit - added.length - changed.length)),
|
|
229
|
+
truncated: all > limit || state.oversized.has(baseRevision) || state.oversized.has(currentRevision),
|
|
230
|
+
...((all > limit || state.oversized.has(baseRevision) || state.oversized.has(currentRevision))
|
|
231
|
+
? { omitted_changes: Math.max(all - limit, state.oversized.get(baseRevision) ?? 0, state.oversized.get(currentRevision) ?? 0) } : {})
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function getLatestStateDelta(deviceKey: string) {
|
|
236
|
+
const state = snapshotStateByDevice.get(deviceKey)
|
|
237
|
+
if (!state || state.previousRevision === null) return null
|
|
238
|
+
return getStateDelta(deviceKey, state.previousRevision, state.revision)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function retainConnectedDeviceSnapshots(platform: string, deviceIds: string[]) {
|
|
242
|
+
const keys = new Set(deviceIds.map(id => `${platform}:${id}`))
|
|
243
|
+
for (const key of snapshotStateByDevice.keys()) if (key.startsWith(`${platform}:`) && !keys.has(key)) snapshotStateByDevice.delete(key)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function clearDeviceSnapshots(deviceKey: string) {
|
|
247
|
+
snapshotStateByDevice.delete(deviceKey)
|
|
248
|
+
}
|
|
249
|
+
|
|
172
250
|
export function resetSnapshotMetadataForTests() {
|
|
173
251
|
snapshotStateByDevice.clear()
|
|
252
|
+
nextRevision = 1
|
|
174
253
|
}
|
package/src/server/common.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { recordTiming } from '../utils/timing.js'
|
|
1
2
|
import type {
|
|
2
3
|
ActionTrace,
|
|
3
4
|
ActionExecutionResult,
|
|
@@ -14,13 +15,17 @@ import { ToolsObserve } from '../observe/index.js'
|
|
|
14
15
|
export const DEFAULT_MAX_RECOVERY_ATTEMPTS = 3
|
|
15
16
|
export const DEFAULT_MAX_RETRY_DEPTH = 3
|
|
16
17
|
|
|
17
|
-
export function wrapResponse<T>(data: T) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
export function wrapResponse<T>(data: T, includeSerializationTiming = true) {
|
|
19
|
+
const start = performance.now()
|
|
20
|
+
let text = JSON.stringify(data, null, 2)
|
|
21
|
+
const elapsed = performance.now() - start
|
|
22
|
+
const value = data as any
|
|
23
|
+
if (includeSerializationTiming && value && typeof value === 'object' && value.timing && typeof value.timing === 'object') {
|
|
24
|
+
value.timing.serialization_ms = (value.timing.serialization_ms ?? 0) + elapsed
|
|
25
|
+
recordTiming('serialization_ms', elapsed)
|
|
26
|
+
text = JSON.stringify(value, null, 2)
|
|
23
27
|
}
|
|
28
|
+
return { content: [{ type: 'text' as const, text }] }
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
export type ToolCallArgs = Record<string, unknown>
|
|
@@ -312,6 +317,7 @@ function mapFailureCodeToFailureClass(code: ActionFailureCode): FailureClass {
|
|
|
312
317
|
case 'STALE_REFERENCE':
|
|
313
318
|
return 'TargetResolutionFailure'
|
|
314
319
|
case 'ELEMENT_NOT_INTERACTABLE':
|
|
320
|
+
case 'ACTION_TIMEOUT':
|
|
315
321
|
return 'ExecutionFailure'
|
|
316
322
|
case 'TIMEOUT':
|
|
317
323
|
case 'ACTION_REJECTED':
|
|
@@ -320,6 +326,7 @@ function mapFailureCodeToFailureClass(code: ActionFailureCode): FailureClass {
|
|
|
320
326
|
return 'ExecutionFailure'
|
|
321
327
|
case 'VERIFICATION_FAILED':
|
|
322
328
|
case 'EXPECT_STATE_MISMATCH':
|
|
329
|
+
case 'ASSERTION_TIMEOUT':
|
|
323
330
|
return 'VerificationFailure'
|
|
324
331
|
case 'CONTROL_CONVERGENCE_FAILED':
|
|
325
332
|
return 'ControlConvergenceFailure'
|