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/interact/android.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { markDispatchStarted } from '../utils/operation-budget.js'
|
|
1
2
|
import { TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
|
|
2
3
|
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js"
|
|
3
4
|
import { AndroidObserve } from "../observe/index.js"
|
|
@@ -7,31 +8,33 @@ import { scrollToElementShared } from "../utils/ui/index.js"
|
|
|
7
8
|
export class AndroidInteract {
|
|
8
9
|
private observe = new AndroidObserve();
|
|
9
10
|
|
|
10
|
-
async tap(x: number, y: number, deviceId?: string): Promise<TapResponse> {
|
|
11
|
+
async tap(x: number, y: number, deviceId?: string, timeoutMs?: number): Promise<TapResponse> {
|
|
11
12
|
const metadata = await getAndroidDeviceMetadata("", deviceId)
|
|
12
13
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
13
14
|
|
|
14
15
|
try {
|
|
15
|
-
|
|
16
|
+
markDispatchStarted()
|
|
17
|
+
await execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId, { timeout: timeoutMs })
|
|
16
18
|
return { device: deviceInfo, success: true, x, y }
|
|
17
19
|
} catch (e) {
|
|
18
20
|
return { device: deviceInfo, success: false, x, y, error: e instanceof Error ? e.message : String(e) }
|
|
19
21
|
}
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
async swipe(x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string): Promise<SwipeResponse> {
|
|
24
|
+
async swipe(x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string, timeoutMs?: number): Promise<SwipeResponse> {
|
|
23
25
|
const metadata = await getAndroidDeviceMetadata("", deviceId)
|
|
24
26
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
25
27
|
|
|
26
28
|
try {
|
|
27
|
-
|
|
29
|
+
markDispatchStarted()
|
|
30
|
+
await execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId, { timeout: timeoutMs })
|
|
28
31
|
return { device: deviceInfo, success: true, start: [x1, y1], end: [x2, y2], duration }
|
|
29
32
|
} catch (e) {
|
|
30
33
|
return { device: deviceInfo, success: false, start: [x1, y1], end: [x2, y2], duration, error: e instanceof Error ? e.message : String(e) }
|
|
31
34
|
}
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
async typeText(text: string, deviceId?: string): Promise<TypeTextResponse> {
|
|
37
|
+
async typeText(text: string, deviceId?: string, timeoutMs?: number): Promise<TypeTextResponse> {
|
|
35
38
|
const metadata = await getAndroidDeviceMetadata("", deviceId)
|
|
36
39
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
37
40
|
|
|
@@ -39,19 +42,21 @@ export class AndroidInteract {
|
|
|
39
42
|
// Encode spaces as %s to ensure proper input handling by adb shell input text
|
|
40
43
|
const encodedText = text.replace(/\s/g, '%s')
|
|
41
44
|
// Note: 'input text' might fail with some characters or if keyboard isn't ready, but it's the standard ADB way.
|
|
42
|
-
|
|
45
|
+
markDispatchStarted()
|
|
46
|
+
await execAdb(['shell', 'input', 'text', encodedText], deviceId, { timeout: timeoutMs })
|
|
43
47
|
return { device: deviceInfo, success: true, text }
|
|
44
48
|
} catch (e) {
|
|
45
49
|
return { device: deviceInfo, success: false, text, error: e instanceof Error ? e.message : String(e) }
|
|
46
50
|
}
|
|
47
51
|
}
|
|
48
52
|
|
|
49
|
-
async pressBack(deviceId?: string): Promise<PressBackResponse> {
|
|
53
|
+
async pressBack(deviceId?: string, timeoutMs?: number): Promise<PressBackResponse> {
|
|
50
54
|
const metadata = await getAndroidDeviceMetadata("", deviceId)
|
|
51
55
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
52
56
|
|
|
53
57
|
try {
|
|
54
|
-
|
|
58
|
+
markDispatchStarted()
|
|
59
|
+
await execAdb(['shell', 'input', 'keyevent', '4'], deviceId, { timeout: timeoutMs })
|
|
55
60
|
return { device: deviceInfo, success: true }
|
|
56
61
|
} catch (e) {
|
|
57
62
|
return { device: deviceInfo, success: false, error: e instanceof Error ? e.message : String(e) }
|
package/src/interact/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { remainingBudget } from '../utils/operation-budget.js'
|
|
1
2
|
import { createHash } from 'crypto'
|
|
2
3
|
import { AndroidInteract } from './android.js';
|
|
3
4
|
import { iOSInteract } from './ios.js';
|
|
@@ -1226,22 +1227,22 @@ export class ToolsInteract {
|
|
|
1226
1227
|
return { interact: interact as any, resolved, platform: effectivePlatform }
|
|
1227
1228
|
}
|
|
1228
1229
|
|
|
1229
|
-
static async tapHandler({ platform, x, y, deviceId }: { platform?: 'android' | 'ios', x: number, y: number, deviceId?: string }) {
|
|
1230
|
+
static async tapHandler({ platform, x, y, deviceId, timeoutMs }: { platform?: 'android' | 'ios', x: number, y: number, deviceId?: string, timeoutMs?: number }) {
|
|
1230
1231
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
|
|
1231
|
-
return await interact.tap(x, y, resolved.id)
|
|
1232
|
+
return await interact.tap(x, y, resolved.id, timeoutMs)
|
|
1232
1233
|
}
|
|
1233
1234
|
|
|
1234
|
-
static async tapElementHandler({ elementId }: { elementId: string }): Promise<TapElementResponse> {
|
|
1235
|
+
static async tapElementHandler({ elementId, verificationMode = 'full', timeoutMs, platform, deviceId, freshTree }: { elementId: string, verificationMode?: 'none' | 'light' | 'full', timeoutMs?: number, platform?: 'android' | 'ios', deviceId?: string, freshTree?: any }): Promise<TapElementResponse> {
|
|
1235
1236
|
const actionType = 'tap_element'
|
|
1236
1237
|
const selector = { elementId }
|
|
1237
1238
|
const resolved = ToolsInteract._resolvedUiElements.get(elementId)
|
|
1238
|
-
if (!resolved) {
|
|
1239
|
+
if (!resolved || (platform && resolved.platform !== platform) || (deviceId && resolved.deviceId !== deviceId)) {
|
|
1239
1240
|
return ToolsInteract._actionFailure(actionType, selector, null, 'STALE_REFERENCE', true, null)
|
|
1240
1241
|
}
|
|
1241
1242
|
|
|
1242
|
-
const fingerprintBefore = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId)
|
|
1243
|
+
const fingerprintBefore = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null
|
|
1243
1244
|
|
|
1244
|
-
const tree = await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId }) as any
|
|
1245
|
+
const tree = freshTree ?? await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId }) as any
|
|
1245
1246
|
const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : resolved.platform
|
|
1246
1247
|
const treeDeviceId = tree?.device?.id || resolved.deviceId
|
|
1247
1248
|
const elements = Array.isArray(tree?.elements) ? tree.elements as UiElement[] : []
|
|
@@ -1272,14 +1273,14 @@ export class ToolsInteract {
|
|
|
1272
1273
|
|
|
1273
1274
|
const x = Math.floor((bounds[0] + bounds[2]) / 2)
|
|
1274
1275
|
const y = Math.floor((bounds[1] + bounds[3]) / 2)
|
|
1275
|
-
const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId })
|
|
1276
|
+
const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId, timeoutMs })
|
|
1276
1277
|
|
|
1277
1278
|
if (!tapResult.success) {
|
|
1278
|
-
const fingerprintAfterFailure = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId)
|
|
1279
|
-
return ToolsInteract._actionFailure(actionType, selector, resolvedTarget, 'UNKNOWN', false, fingerprintBefore, fingerprintAfterFailure)
|
|
1279
|
+
const fingerprintAfterFailure = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null
|
|
1280
|
+
return { ...ToolsInteract._actionFailure(actionType, selector, resolvedTarget, 'UNKNOWN', false, fingerprintBefore, fingerprintAfterFailure), error: tapResult.error }
|
|
1280
1281
|
}
|
|
1281
1282
|
|
|
1282
|
-
const fingerprintAfter = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId)
|
|
1283
|
+
const fingerprintAfter = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null
|
|
1283
1284
|
return buildActionExecutionResult({
|
|
1284
1285
|
actionType,
|
|
1285
1286
|
device: tree?.device,
|
|
@@ -2204,19 +2205,19 @@ export class ToolsInteract {
|
|
|
2204
2205
|
return buildFailure('CONTROL_CONVERGENCE_FAILED', 'control did not converge within the allotted attempts', resolvedTarget, currentDevice, lastObservedState, attemptCount, lastAdjustmentMode, true, uiFingerprintAfter)
|
|
2205
2206
|
}
|
|
2206
2207
|
|
|
2207
|
-
static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }: { platform?: 'android' | 'ios', x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string }) {
|
|
2208
|
+
static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId, timeoutMs }: { platform?: 'android' | 'ios', x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string, timeoutMs?: number }) {
|
|
2208
2209
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
|
|
2209
|
-
return await interact.swipe(x1, y1, x2, y2, duration, resolved.id)
|
|
2210
|
+
return await interact.swipe(x1, y1, x2, y2, duration, resolved.id, timeoutMs)
|
|
2210
2211
|
}
|
|
2211
2212
|
|
|
2212
|
-
static async typeTextHandler({ text, deviceId }: { text: string, deviceId?: string }) {
|
|
2213
|
+
static async typeTextHandler({ text, deviceId, timeoutMs }: { text: string, deviceId?: string, timeoutMs?: number }) {
|
|
2213
2214
|
const resolved = await resolveTargetDevice({ platform: 'android', deviceId })
|
|
2214
|
-
return await new AndroidInteract().typeText(text, resolved.id)
|
|
2215
|
+
return await new AndroidInteract().typeText(text, resolved.id, timeoutMs)
|
|
2215
2216
|
}
|
|
2216
2217
|
|
|
2217
|
-
static async pressBackHandler({ deviceId }: { deviceId?: string }) {
|
|
2218
|
+
static async pressBackHandler({ deviceId, timeoutMs }: { deviceId?: string, timeoutMs?: number }) {
|
|
2218
2219
|
const resolved = await resolveTargetDevice({ platform: 'android', deviceId })
|
|
2219
|
-
return await new AndroidInteract().pressBack(resolved.id)
|
|
2220
|
+
return await new AndroidInteract().pressBack(resolved.id, timeoutMs)
|
|
2220
2221
|
}
|
|
2221
2222
|
|
|
2222
2223
|
static async scrollToElementHandler({ platform, selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId }: { platform: 'android' | 'ios', selector: { text?: string, resourceId?: string, contentDesc?: string, className?: string }, direction?: 'down' | 'up', maxScrolls?: number, scrollAmount?: number, deviceId?: string }) {
|
|
@@ -2490,7 +2491,7 @@ export class ToolsInteract {
|
|
|
2490
2491
|
return { found: true, element: outEl, score: scoreVal, confidence: scoreVal, resolution }
|
|
2491
2492
|
}
|
|
2492
2493
|
|
|
2493
|
-
static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId }: { selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }, condition?: 'exists'|'not_exists'|'visible'|'clickable', timeout_ms?: number, poll_interval_ms?: number, match?: { index?: number }, retry?: { max_attempts?: number, backoff_ms?: number }, platform?: 'android'|'ios', deviceId?: string }) {
|
|
2494
|
+
static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId, singleObservation = false, singleAttempt = false, rejectAmbiguous = false }: { selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }, condition?: 'exists'|'not_exists'|'visible'|'clickable', timeout_ms?: number, poll_interval_ms?: number, match?: { index?: number }, retry?: { max_attempts?: number, backoff_ms?: number }, platform?: 'android'|'ios', deviceId?: string, singleObservation?: boolean, singleAttempt?: boolean, rejectAmbiguous?: boolean }) {
|
|
2494
2495
|
const overallStart = Date.now()
|
|
2495
2496
|
const requestedIndex = typeof match?.index === 'number' ? match.index : null
|
|
2496
2497
|
const requested = {
|
|
@@ -2538,7 +2539,7 @@ export class ToolsInteract {
|
|
|
2538
2539
|
let lastConditionSatisfied = false
|
|
2539
2540
|
let matchedAt: number | null = null
|
|
2540
2541
|
let stableMatchCount = 0
|
|
2541
|
-
const stableObservationCount = 2
|
|
2542
|
+
const stableObservationCount = singleObservation ? 1 : 2
|
|
2542
2543
|
const snapshotStaleThresholdMs = 500
|
|
2543
2544
|
|
|
2544
2545
|
// Precompute normalized selector values and helpers (constant across polls)
|
|
@@ -2554,7 +2555,8 @@ export class ToolsInteract {
|
|
|
2554
2555
|
const attemptStart = Date.now()
|
|
2555
2556
|
const deadline = attemptStart + (timeout_ms || 0)
|
|
2556
2557
|
|
|
2557
|
-
while (Date.now() <= deadline) {
|
|
2558
|
+
while (Date.now() <= deadline || (singleAttempt && totalPollCount === 0)) {
|
|
2559
|
+
remainingBudget()
|
|
2558
2560
|
totalPollCount++
|
|
2559
2561
|
try {
|
|
2560
2562
|
const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId }) as any
|
|
@@ -2601,6 +2603,7 @@ export class ToolsInteract {
|
|
|
2601
2603
|
|
|
2602
2604
|
// Evaluate condition
|
|
2603
2605
|
const matchedCount = matches.length
|
|
2606
|
+
if (rejectAmbiguous && matchedCount > 1 && match?.index === undefined) return { status: 'timeout', error: { code: 'AMBIGUOUS_TARGET' } }
|
|
2604
2607
|
const pickIndex = (typeof match?.index === 'number') ? match!.index as number : undefined
|
|
2605
2608
|
let chosen: { el: any, idx: number } | null = null
|
|
2606
2609
|
if (matches.length > 0) {
|
|
@@ -2659,6 +2662,7 @@ export class ToolsInteract {
|
|
|
2659
2662
|
|
|
2660
2663
|
return {
|
|
2661
2664
|
status: 'success',
|
|
2665
|
+
...(singleObservation ? { _tree: tree } : {}),
|
|
2662
2666
|
matched: matchedCount,
|
|
2663
2667
|
element: outEl,
|
|
2664
2668
|
metrics: { latency_ms, poll_count: totalPollCount, attempts },
|
|
@@ -2677,10 +2681,12 @@ export class ToolsInteract {
|
|
|
2677
2681
|
}
|
|
2678
2682
|
|
|
2679
2683
|
} catch (e) {
|
|
2684
|
+
if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
|
|
2680
2685
|
// Non-fatal per-poll error; record and continue
|
|
2681
2686
|
console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e))
|
|
2682
2687
|
}
|
|
2683
2688
|
|
|
2689
|
+
if (singleAttempt) break
|
|
2684
2690
|
// Sleep until next poll
|
|
2685
2691
|
await new Promise(r => setTimeout(r, effectivePoll || 50))
|
|
2686
2692
|
}
|
|
@@ -2712,6 +2718,7 @@ export class ToolsInteract {
|
|
|
2712
2718
|
}
|
|
2713
2719
|
|
|
2714
2720
|
} catch (err) {
|
|
2721
|
+
if (err instanceof Error && err.message === 'ACTION_TIMEOUT') throw err
|
|
2715
2722
|
const elapsed = Date.now() - overallStart
|
|
2716
2723
|
return {
|
|
2717
2724
|
status: 'timeout',
|
|
@@ -3139,7 +3146,8 @@ export class ToolsInteract {
|
|
|
3139
3146
|
stabilization_window_ms = 1000,
|
|
3140
3147
|
stable_observation_count = 2,
|
|
3141
3148
|
snapshot_stale_threshold_ms = 500,
|
|
3142
|
-
poll_interval_ms = 150
|
|
3149
|
+
poll_interval_ms = 150,
|
|
3150
|
+
exact_budget = false
|
|
3143
3151
|
}: {
|
|
3144
3152
|
selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean },
|
|
3145
3153
|
element_id?: string,
|
|
@@ -3150,16 +3158,17 @@ export class ToolsInteract {
|
|
|
3150
3158
|
stabilization_window_ms?: number,
|
|
3151
3159
|
stable_observation_count?: number,
|
|
3152
3160
|
snapshot_stale_threshold_ms?: number,
|
|
3153
|
-
poll_interval_ms?: number
|
|
3161
|
+
poll_interval_ms?: number,
|
|
3162
|
+
exact_budget?: boolean
|
|
3154
3163
|
}): Promise<ExpectStateResponse> {
|
|
3155
3164
|
const compareBoolean = (value: unknown) => typeof value === 'boolean' ? value : null
|
|
3156
3165
|
const compareString = (value: unknown) => typeof value === 'string' ? value : null
|
|
3157
3166
|
const compareNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
3158
3167
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
|
3159
3168
|
const start = Date.now()
|
|
3160
|
-
const deadline = start + Math.max(500, stabilization_window_ms)
|
|
3169
|
+
const deadline = start + (exact_budget ? stabilization_window_ms : Math.max(500, stabilization_window_ms))
|
|
3161
3170
|
const stableTarget = Math.max(1, Math.floor(stable_observation_count || 2))
|
|
3162
|
-
const pollDelay = Math.max(100, Math.min(poll_interval_ms || 150, 200))
|
|
3171
|
+
const pollDelay = exact_budget ? Math.max(50, Math.min(poll_interval_ms || 150, 1000)) : Math.max(100, Math.min(poll_interval_ms || 150, 200))
|
|
3163
3172
|
const staleThreshold = Math.max(300, Math.min(snapshot_stale_threshold_ms || 500, 800))
|
|
3164
3173
|
|
|
3165
3174
|
let attempts = 0
|
package/src/interact/ios.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { remainingBudget, markDispatchStarted } from '../utils/operation-budget.js'
|
|
1
2
|
import { spawn } from "child_process"
|
|
2
3
|
import { TapResponse, SwipeResponse } from "../types.js"
|
|
3
4
|
import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js"
|
|
@@ -31,7 +32,8 @@ export class iOSInteract {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
await new Promise<void>((resolve, reject) => {
|
|
34
|
-
|
|
35
|
+
markDispatchStarted()
|
|
36
|
+
const proc = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
|
|
35
37
|
let stderr = '';
|
|
36
38
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
37
39
|
proc.on('close', code => {
|
|
@@ -72,7 +74,8 @@ export class iOSInteract {
|
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
await new Promise<void>((resolve, reject) => {
|
|
75
|
-
|
|
77
|
+
markDispatchStarted()
|
|
78
|
+
const proc = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
|
|
76
79
|
let stderr = '';
|
|
77
80
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
78
81
|
proc.on('close', code => {
|
package/src/manage/android.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js'
|
|
1
2
|
import { promises as fs } from 'fs'
|
|
2
3
|
import { spawn } from 'child_process'
|
|
3
4
|
import path from 'path'
|
|
@@ -149,16 +150,18 @@ export class AndroidManage {
|
|
|
149
150
|
}
|
|
150
151
|
}
|
|
151
152
|
|
|
152
|
-
async startApp(appId: string, deviceId?: string): Promise<StartAppResponse> {
|
|
153
|
+
async startApp(appId: string, deviceId?: string, timeoutMs?: number): Promise<StartAppResponse> {
|
|
154
|
+
const started = performance.now()
|
|
153
155
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
154
156
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
155
157
|
try {
|
|
156
|
-
|
|
158
|
+
markDispatchStarted()
|
|
159
|
+
const output = await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId, { timeout: timeoutMs })
|
|
157
160
|
const current = await new AndroidObserve().getCurrentScreen(deviceId).catch(() => null)
|
|
158
161
|
return {
|
|
159
162
|
device: deviceInfo,
|
|
160
163
|
appStarted: true,
|
|
161
|
-
launchTimeMs:
|
|
164
|
+
launchTimeMs: performance.now() - started,
|
|
162
165
|
output,
|
|
163
166
|
observedApp: {
|
|
164
167
|
appId,
|
|
@@ -169,8 +172,8 @@ export class AndroidManage {
|
|
|
169
172
|
}
|
|
170
173
|
}
|
|
171
174
|
} catch (e: unknown) {
|
|
172
|
-
const
|
|
173
|
-
return { device: deviceInfo, appStarted: false, launchTimeMs:
|
|
175
|
+
const diagnostics = timeoutMs === undefined ? execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId) : undefined
|
|
176
|
+
return { device: deviceInfo, appStarted: false, launchTimeMs: performance.now() - started, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) }
|
|
174
177
|
}
|
|
175
178
|
}
|
|
176
179
|
|
|
@@ -178,17 +181,22 @@ export class AndroidManage {
|
|
|
178
181
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
179
182
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
180
183
|
try {
|
|
184
|
+
markDispatchStarted()
|
|
181
185
|
await execAdb(['shell', 'am', 'force-stop', appId], deviceId)
|
|
182
186
|
return { device: deviceInfo, appTerminated: true }
|
|
183
187
|
} catch (e: unknown) {
|
|
184
|
-
const
|
|
185
|
-
return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics:
|
|
188
|
+
const diagnostics = hasFiniteBudget() ? undefined : execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId)
|
|
189
|
+
return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) }
|
|
186
190
|
}
|
|
187
191
|
}
|
|
188
192
|
|
|
189
|
-
async restartApp(appId: string, deviceId?: string): Promise<RestartAppResponse> {
|
|
190
|
-
const
|
|
191
|
-
const
|
|
193
|
+
async restartApp(appId: string, deviceId?: string, bounded = false, timeoutMs = 15000): Promise<RestartAppResponse> {
|
|
194
|
+
const started = performance.now()
|
|
195
|
+
const terminateResult = bounded ? await runWithBudget(Math.min(5000, timeoutMs), () => this.terminateApp(appId, deviceId)) : await this.terminateApp(appId, deviceId)
|
|
196
|
+
const remaining = timeoutMs - (performance.now() - started)
|
|
197
|
+
if (bounded && remaining <= 0) throw new Error('ACTION_TIMEOUT')
|
|
198
|
+
const launchBudget = remainingBudget(remaining)
|
|
199
|
+
const startResult = bounded ? await runWithBudget(launchBudget, () => this.startApp(appId, deviceId, remainingBudget(launchBudget))) : await this.startApp(appId, deviceId)
|
|
192
200
|
return {
|
|
193
201
|
device: startResult.device,
|
|
194
202
|
appRestarted: startResult.appStarted,
|
|
@@ -206,6 +214,7 @@ export class AndroidManage {
|
|
|
206
214
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
207
215
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
208
216
|
try {
|
|
217
|
+
markDispatchStarted()
|
|
209
218
|
const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId)
|
|
210
219
|
return { device: deviceInfo, dataCleared: output === 'Success' }
|
|
211
220
|
} catch (e: unknown) {
|
package/src/manage/ios.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js'
|
|
1
2
|
import { promises as fs } from "fs"
|
|
2
3
|
import { spawn, spawnSync } from "child_process"
|
|
3
4
|
import { StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, InstallAppResponse } from "../types.js"
|
|
@@ -310,17 +311,19 @@ export class iOSManage {
|
|
|
310
311
|
}
|
|
311
312
|
}
|
|
312
313
|
|
|
313
|
-
async startApp(bundleId: string, deviceId: string = "booted"): Promise<StartAppResponse> {
|
|
314
|
+
async startApp(bundleId: string, deviceId: string = "booted", observe = true): Promise<StartAppResponse> {
|
|
315
|
+
const started = performance.now()
|
|
314
316
|
validateBundleId(bundleId)
|
|
315
317
|
try {
|
|
318
|
+
markDispatchStarted()
|
|
316
319
|
const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
|
|
317
320
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
318
|
-
const fingerprint = await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null)
|
|
321
|
+
const fingerprint = observe ? await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null) : null
|
|
319
322
|
const pidMatch = result.output.match(/:\s*(\d+)\s*$/)
|
|
320
323
|
return {
|
|
321
324
|
device,
|
|
322
325
|
appStarted: !!result.output,
|
|
323
|
-
launchTimeMs:
|
|
326
|
+
launchTimeMs: performance.now() - started,
|
|
324
327
|
output: result.output,
|
|
325
328
|
observedApp: {
|
|
326
329
|
appId: bundleId,
|
|
@@ -330,28 +333,33 @@ export class iOSManage {
|
|
|
330
333
|
}
|
|
331
334
|
}
|
|
332
335
|
} catch (e: unknown) {
|
|
333
|
-
const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId)
|
|
334
336
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
335
|
-
|
|
337
|
+
const diagnostics = hasFiniteBudget() ? undefined : execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId)
|
|
338
|
+
return { device, appStarted: false, launchTimeMs: performance.now() - started, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) } as any
|
|
336
339
|
}
|
|
337
340
|
}
|
|
338
341
|
|
|
339
342
|
async terminateApp(bundleId: string, deviceId: string = "booted"): Promise<TerminateAppResponse> {
|
|
340
343
|
validateBundleId(bundleId)
|
|
341
344
|
try {
|
|
345
|
+
markDispatchStarted()
|
|
342
346
|
await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId)
|
|
343
347
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
344
348
|
return { device, appTerminated: true }
|
|
345
349
|
} catch (e: unknown) {
|
|
346
|
-
const diag = execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId)
|
|
350
|
+
const diag = hasFiniteBudget() ? undefined : execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId)
|
|
347
351
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
348
|
-
return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
|
|
352
|
+
return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), ...(diag ? { diagnostics: diag } : {}) } as any
|
|
349
353
|
}
|
|
350
354
|
}
|
|
351
355
|
|
|
352
|
-
async restartApp(bundleId: string, deviceId: string = "booted"): Promise<RestartAppResponse> {
|
|
353
|
-
const
|
|
354
|
-
const
|
|
356
|
+
async restartApp(bundleId: string, deviceId: string = "booted", observe = true, bounded = false, timeoutMs = 15000): Promise<RestartAppResponse> {
|
|
357
|
+
const started = performance.now()
|
|
358
|
+
const terminateResult = bounded ? await runWithBudget(Math.min(5000, timeoutMs), () => this.terminateApp(bundleId, deviceId)) : await this.terminateApp(bundleId, deviceId)
|
|
359
|
+
const remaining = timeoutMs - (performance.now() - started)
|
|
360
|
+
if (bounded && remaining <= 0) throw new Error('ACTION_TIMEOUT')
|
|
361
|
+
const launchBudget = remainingBudget(remaining)
|
|
362
|
+
const startResult = bounded ? await runWithBudget(launchBudget, () => this.startApp(bundleId, deviceId, observe)) : await this.startApp(bundleId, deviceId, observe)
|
|
355
363
|
return {
|
|
356
364
|
device: startResult.device,
|
|
357
365
|
appRestarted: startResult.appStarted,
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { execFile, spawn, spawnSync, type ChildProcess } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { connect } from 'node:net'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { execAdb, getAdbCmd } from '../utils/android/utils.js'
|
|
6
|
+
import { remainingBudget } from '../utils/operation-budget.js'
|
|
7
|
+
|
|
8
|
+
const packageName = 'dev.mobiledebugmcp.treebridge'
|
|
9
|
+
const component = `${packageName}/.TreeInstrumentation`
|
|
10
|
+
const devicePort = 39019
|
|
11
|
+
|
|
12
|
+
interface Bridge {
|
|
13
|
+
deviceId: string
|
|
14
|
+
hostPort: number
|
|
15
|
+
child: ChildProcess
|
|
16
|
+
closed: boolean
|
|
17
|
+
childClosed: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const active = new Map<string, Bridge>()
|
|
21
|
+
const starting = new Map<string, Promise<Bridge>>()
|
|
22
|
+
const stopping = new Map<string, Promise<void>>()
|
|
23
|
+
|
|
24
|
+
export function androidTreeBridgeEnabled(): boolean {
|
|
25
|
+
return Boolean(process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function configuredApk() {
|
|
29
|
+
const configured = process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK
|
|
30
|
+
if (!configured) return null
|
|
31
|
+
const apk = path.resolve(configured)
|
|
32
|
+
if (!existsSync(apk)) throw new Error(`Android UI tree bridge APK does not exist: ${apk}`)
|
|
33
|
+
return apk
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function unbudgetedAdb(deviceId: string, args: string[]): Promise<void> {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
execFile(getAdbCmd(), ['-s', deviceId, ...args], { timeout: 5000 }, (error) => error ? reject(error) : resolve())
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function request(bridge: Bridge, command: 'PING' | 'TREE' | 'STOP', timeoutMs: number): Promise<string> {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
let output = ''
|
|
45
|
+
const socket = connect(bridge.hostPort, '127.0.0.1', () => socket.write(`${command}\n`))
|
|
46
|
+
socket.setTimeout(timeoutMs, () => socket.destroy(new Error(`Android UI tree bridge ${command} timed out`)))
|
|
47
|
+
socket.on('data', (chunk) => {
|
|
48
|
+
output += chunk.toString()
|
|
49
|
+
if (command === 'STOP' && output.startsWith('OK\n')) {
|
|
50
|
+
resolve(output)
|
|
51
|
+
socket.destroy()
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
socket.on('error', reject)
|
|
55
|
+
socket.on('end', () => resolve(output))
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function waitForChildClose(bridge: Bridge, timeoutMs: number): Promise<void> {
|
|
60
|
+
if (bridge.childClosed) return Promise.resolve()
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const onClose = () => { clearTimeout(timer); resolve() }
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
bridge.child.off('close', onClose)
|
|
65
|
+
reject(new Error('Android UI tree bridge child did not exit'))
|
|
66
|
+
}, timeoutMs)
|
|
67
|
+
bridge.child.once('close', onClose)
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function stop(bridge: Bridge): Promise<void> {
|
|
72
|
+
const pending = stopping.get(bridge.deviceId)
|
|
73
|
+
if (pending) return pending
|
|
74
|
+
bridge.closed = true
|
|
75
|
+
const cleanup = (async () => {
|
|
76
|
+
await request(bridge, 'STOP', 1000).catch(() => '')
|
|
77
|
+
let forceStopError: unknown
|
|
78
|
+
try { await unbudgetedAdb(bridge.deviceId, ['shell', 'am', 'force-stop', packageName]) }
|
|
79
|
+
catch (error) { forceStopError = error }
|
|
80
|
+
try { await waitForChildClose(bridge, 1000) }
|
|
81
|
+
catch {
|
|
82
|
+
bridge.child.kill('SIGTERM')
|
|
83
|
+
try { await waitForChildClose(bridge, 1000) }
|
|
84
|
+
catch {
|
|
85
|
+
bridge.child.kill('SIGKILL')
|
|
86
|
+
await waitForChildClose(bridge, 2000).catch(() => {})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
let forwardError: unknown
|
|
90
|
+
try { await unbudgetedAdb(bridge.deviceId, ['forward', '--remove', `tcp:${bridge.hostPort}`]) }
|
|
91
|
+
catch (error) { forwardError = error }
|
|
92
|
+
if (forceStopError || !bridge.childClosed || forwardError) {
|
|
93
|
+
const reason = forceStopError ?? (!bridge.childClosed ? 'instrumentation child did not close' : forwardError)
|
|
94
|
+
throw new Error(`BRIDGE_CLEANUP_UNCONFIRMED: ${reason instanceof Error ? reason.message : String(reason)}`)
|
|
95
|
+
}
|
|
96
|
+
if (active.get(bridge.deviceId) === bridge) active.delete(bridge.deviceId)
|
|
97
|
+
})()
|
|
98
|
+
stopping.set(bridge.deviceId, cleanup)
|
|
99
|
+
try { await cleanup } finally { stopping.delete(bridge.deviceId) }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function launch(deviceId: string, apk: string): Promise<Bridge> {
|
|
103
|
+
await unbudgetedAdb(deviceId, ['shell', 'am', 'force-stop', packageName])
|
|
104
|
+
try {
|
|
105
|
+
await execAdb(['install', '-r', apk], deviceId)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (!(error instanceof Error) || !error.message.includes('INSTALL_FAILED_UPDATE_INCOMPATIBLE')) throw error
|
|
108
|
+
await execAdb(['uninstall', packageName], deviceId)
|
|
109
|
+
await execAdb(['install', '-r', apk], deviceId)
|
|
110
|
+
}
|
|
111
|
+
const hostPort = Number(await execAdb(['forward', 'tcp:0', `tcp:${devicePort}`], deviceId))
|
|
112
|
+
if (!Number.isInteger(hostPort) || hostPort <= 0) throw new Error('Android UI tree bridge could not obtain a host port')
|
|
113
|
+
const child = spawn(getAdbCmd(), ['-s', deviceId, 'shell', 'am', 'instrument', '-w', component], { stdio: 'ignore' })
|
|
114
|
+
const bridge: Bridge = { deviceId, hostPort, child, closed: false, childClosed: false }
|
|
115
|
+
child.once('close', () => { bridge.childClosed = true })
|
|
116
|
+
child.once('error', () => { /* Startup detects the failed child before a tree read. */ })
|
|
117
|
+
active.set(deviceId, bridge)
|
|
118
|
+
try {
|
|
119
|
+
const deadline = Date.now() + Math.min(remainingBudget(3000), 3000)
|
|
120
|
+
while (Date.now() < deadline) {
|
|
121
|
+
if (bridge.childClosed || child.exitCode !== null || child.signalCode !== null) throw new Error('Android UI tree bridge exited during startup')
|
|
122
|
+
try {
|
|
123
|
+
if ((await request(bridge, 'PING', 250)).startsWith('OK\n')) {
|
|
124
|
+
return bridge
|
|
125
|
+
}
|
|
126
|
+
} catch { /* The instrumentation process may still be starting. */ }
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
128
|
+
}
|
|
129
|
+
throw new Error('Android UI tree bridge did not become ready')
|
|
130
|
+
} catch (error) {
|
|
131
|
+
await stop(bridge)
|
|
132
|
+
throw error
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function ensure(deviceId: string, apk: string): Promise<Bridge> {
|
|
137
|
+
const pending = starting.get(deviceId)
|
|
138
|
+
if (pending) return pending
|
|
139
|
+
const ready = active.get(deviceId)
|
|
140
|
+
if (ready && !ready.closed && !ready.childClosed && ready.child.exitCode === null && ready.child.signalCode === null) return ready
|
|
141
|
+
if (ready) await stop(ready)
|
|
142
|
+
const promise = launch(deviceId, apk)
|
|
143
|
+
starting.set(deviceId, promise)
|
|
144
|
+
try { return await promise } finally { starting.delete(deviceId) }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Returns null only after the bridge has been stopped, so legacy acquisition is safe. */
|
|
148
|
+
export async function readAndroidTreeFromBridge(deviceId: string): Promise<string | null> {
|
|
149
|
+
const apk = configuredApk()
|
|
150
|
+
if (!apk) return null
|
|
151
|
+
let bridge: Bridge | undefined
|
|
152
|
+
try {
|
|
153
|
+
bridge = await ensure(deviceId, apk)
|
|
154
|
+
const output = await request(bridge, 'TREE', remainingBudget(10000))
|
|
155
|
+
if (!output.startsWith('OK\n') || !output.includes('<hierarchy')) throw new Error('Android UI tree bridge returned invalid hierarchy output')
|
|
156
|
+
return output.slice(3)
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error instanceof Error && error.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED')) throw error
|
|
159
|
+
if (bridge) await stop(bridge)
|
|
160
|
+
if (error instanceof Error && error.message === 'ACTION_TIMEOUT') throw error
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
process.once('exit', () => {
|
|
166
|
+
for (const bridge of active.values()) {
|
|
167
|
+
spawnSync(getAdbCmd(), ['-s', bridge.deviceId, 'shell', 'am', 'force-stop', packageName], { timeout: 2000, stdio: 'ignore' })
|
|
168
|
+
spawnSync(getAdbCmd(), ['-s', bridge.deviceId, 'forward', '--remove', `tcp:${bridge.hostPort}`], { timeout: 2000, stdio: 'ignore' })
|
|
169
|
+
}
|
|
170
|
+
})
|