mobile-debug-mcp 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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.js +6 -1
- package/dist/observe/index.js +22 -5
- package/dist/observe/ios.js +7 -2
- 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 +358 -55
- package/dist/server-core.js +1 -1
- package/dist/utils/exec.js +4 -2
- 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 +3 -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/specs-deltas/018-interaction-latency-and-batched-journeys.md +301 -0
- package/docs/tools/interact.md +30 -0
- package/package.json +1 -1
- 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.ts +5 -1
- package/src/observe/index.ts +21 -5
- package/src/observe/ios.ts +6 -2
- 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 +324 -53
- package/src/server-core.ts +1 -1
- package/src/types.ts +31 -2
- package/src/utils/exec.ts +4 -2
- 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/fixtures/latency-journey-app/AndroidManifest.xml +6 -0
- package/test/fixtures/latency-journey-app/README.md +10 -0
- package/test/fixtures/latency-journey-app/build.ts +26 -0
- package/test/fixtures/latency-journey-app/src/MainActivity.java +48 -0
- package/test/unit/manage/restart_budget.test.ts +37 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +6 -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 +32 -0
- package/test/unit/server/response_shapes.test.ts +3 -3
- package/test/unit/server/run_journey.test.ts +90 -0
- package/test/unit/server/tap_selector.test.ts +68 -0
package/dist/interact/android.js
CHANGED
|
@@ -1,49 +1,54 @@
|
|
|
1
|
+
import { markDispatchStarted } from '../utils/operation-budget.js';
|
|
1
2
|
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js";
|
|
2
3
|
import { AndroidObserve } from "../observe/index.js";
|
|
3
4
|
import { scrollToElementShared } from "../utils/ui/index.js";
|
|
4
5
|
export class AndroidInteract {
|
|
5
6
|
observe = new AndroidObserve();
|
|
6
|
-
async tap(x, y, deviceId) {
|
|
7
|
+
async tap(x, y, deviceId, timeoutMs) {
|
|
7
8
|
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
8
9
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
9
10
|
try {
|
|
10
|
-
|
|
11
|
+
markDispatchStarted();
|
|
12
|
+
await execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId, { timeout: timeoutMs });
|
|
11
13
|
return { device: deviceInfo, success: true, x, y };
|
|
12
14
|
}
|
|
13
15
|
catch (e) {
|
|
14
16
|
return { device: deviceInfo, success: false, x, y, error: e instanceof Error ? e.message : String(e) };
|
|
15
17
|
}
|
|
16
18
|
}
|
|
17
|
-
async swipe(x1, y1, x2, y2, duration, deviceId) {
|
|
19
|
+
async swipe(x1, y1, x2, y2, duration, deviceId, timeoutMs) {
|
|
18
20
|
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
19
21
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
20
22
|
try {
|
|
21
|
-
|
|
23
|
+
markDispatchStarted();
|
|
24
|
+
await execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId, { timeout: timeoutMs });
|
|
22
25
|
return { device: deviceInfo, success: true, start: [x1, y1], end: [x2, y2], duration };
|
|
23
26
|
}
|
|
24
27
|
catch (e) {
|
|
25
28
|
return { device: deviceInfo, success: false, start: [x1, y1], end: [x2, y2], duration, error: e instanceof Error ? e.message : String(e) };
|
|
26
29
|
}
|
|
27
30
|
}
|
|
28
|
-
async typeText(text, deviceId) {
|
|
31
|
+
async typeText(text, deviceId, timeoutMs) {
|
|
29
32
|
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
30
33
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
31
34
|
try {
|
|
32
35
|
// Encode spaces as %s to ensure proper input handling by adb shell input text
|
|
33
36
|
const encodedText = text.replace(/\s/g, '%s');
|
|
34
37
|
// Note: 'input text' might fail with some characters or if keyboard isn't ready, but it's the standard ADB way.
|
|
35
|
-
|
|
38
|
+
markDispatchStarted();
|
|
39
|
+
await execAdb(['shell', 'input', 'text', encodedText], deviceId, { timeout: timeoutMs });
|
|
36
40
|
return { device: deviceInfo, success: true, text };
|
|
37
41
|
}
|
|
38
42
|
catch (e) {
|
|
39
43
|
return { device: deviceInfo, success: false, text, error: e instanceof Error ? e.message : String(e) };
|
|
40
44
|
}
|
|
41
45
|
}
|
|
42
|
-
async pressBack(deviceId) {
|
|
46
|
+
async pressBack(deviceId, timeoutMs) {
|
|
43
47
|
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
44
48
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
45
49
|
try {
|
|
46
|
-
|
|
50
|
+
markDispatchStarted();
|
|
51
|
+
await execAdb(['shell', 'input', 'keyevent', '4'], deviceId, { timeout: timeoutMs });
|
|
47
52
|
return { device: deviceInfo, success: true };
|
|
48
53
|
}
|
|
49
54
|
catch (e) {
|
package/dist/interact/index.js
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';
|
|
@@ -950,19 +951,19 @@ export class ToolsInteract {
|
|
|
950
951
|
const interact = effectivePlatform === 'android' ? new AndroidInteract() : new iOSInteract();
|
|
951
952
|
return { interact: interact, resolved, platform: effectivePlatform };
|
|
952
953
|
}
|
|
953
|
-
static async tapHandler({ platform, x, y, deviceId }) {
|
|
954
|
+
static async tapHandler({ platform, x, y, deviceId, timeoutMs }) {
|
|
954
955
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
|
|
955
|
-
return await interact.tap(x, y, resolved.id);
|
|
956
|
+
return await interact.tap(x, y, resolved.id, timeoutMs);
|
|
956
957
|
}
|
|
957
|
-
static async tapElementHandler({ elementId }) {
|
|
958
|
+
static async tapElementHandler({ elementId, verificationMode = 'full', timeoutMs, platform, deviceId, freshTree }) {
|
|
958
959
|
const actionType = 'tap_element';
|
|
959
960
|
const selector = { elementId };
|
|
960
961
|
const resolved = ToolsInteract._resolvedUiElements.get(elementId);
|
|
961
|
-
if (!resolved) {
|
|
962
|
+
if (!resolved || (platform && resolved.platform !== platform) || (deviceId && resolved.deviceId !== deviceId)) {
|
|
962
963
|
return ToolsInteract._actionFailure(actionType, selector, null, 'STALE_REFERENCE', true, null);
|
|
963
964
|
}
|
|
964
|
-
const fingerprintBefore = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId);
|
|
965
|
-
const tree = await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId });
|
|
965
|
+
const fingerprintBefore = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null;
|
|
966
|
+
const tree = freshTree ?? await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId });
|
|
966
967
|
const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : resolved.platform;
|
|
967
968
|
const treeDeviceId = tree?.device?.id || resolved.deviceId;
|
|
968
969
|
const elements = Array.isArray(tree?.elements) ? tree.elements : [];
|
|
@@ -981,12 +982,12 @@ export class ToolsInteract {
|
|
|
981
982
|
}
|
|
982
983
|
const x = Math.floor((bounds[0] + bounds[2]) / 2);
|
|
983
984
|
const y = Math.floor((bounds[1] + bounds[3]) / 2);
|
|
984
|
-
const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId });
|
|
985
|
+
const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId, timeoutMs });
|
|
985
986
|
if (!tapResult.success) {
|
|
986
|
-
const fingerprintAfterFailure = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId);
|
|
987
|
-
return ToolsInteract._actionFailure(actionType, selector, resolvedTarget, 'UNKNOWN', false, fingerprintBefore, fingerprintAfterFailure);
|
|
987
|
+
const fingerprintAfterFailure = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null;
|
|
988
|
+
return { ...ToolsInteract._actionFailure(actionType, selector, resolvedTarget, 'UNKNOWN', false, fingerprintBefore, fingerprintAfterFailure), error: tapResult.error };
|
|
988
989
|
}
|
|
989
|
-
const fingerprintAfter = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId);
|
|
990
|
+
const fingerprintAfter = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null;
|
|
990
991
|
return buildActionExecutionResult({
|
|
991
992
|
actionType,
|
|
992
993
|
device: tree?.device,
|
|
@@ -1756,17 +1757,17 @@ export class ToolsInteract {
|
|
|
1756
1757
|
const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
|
|
1757
1758
|
return buildFailure('CONTROL_CONVERGENCE_FAILED', 'control did not converge within the allotted attempts', resolvedTarget, currentDevice, lastObservedState, attemptCount, lastAdjustmentMode, true, uiFingerprintAfter);
|
|
1758
1759
|
}
|
|
1759
|
-
static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }) {
|
|
1760
|
+
static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId, timeoutMs }) {
|
|
1760
1761
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
|
|
1761
|
-
return await interact.swipe(x1, y1, x2, y2, duration, resolved.id);
|
|
1762
|
+
return await interact.swipe(x1, y1, x2, y2, duration, resolved.id, timeoutMs);
|
|
1762
1763
|
}
|
|
1763
|
-
static async typeTextHandler({ text, deviceId }) {
|
|
1764
|
+
static async typeTextHandler({ text, deviceId, timeoutMs }) {
|
|
1764
1765
|
const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
|
|
1765
|
-
return await new AndroidInteract().typeText(text, resolved.id);
|
|
1766
|
+
return await new AndroidInteract().typeText(text, resolved.id, timeoutMs);
|
|
1766
1767
|
}
|
|
1767
|
-
static async pressBackHandler({ deviceId }) {
|
|
1768
|
+
static async pressBackHandler({ deviceId, timeoutMs }) {
|
|
1768
1769
|
const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
|
|
1769
|
-
return await new AndroidInteract().pressBack(resolved.id);
|
|
1770
|
+
return await new AndroidInteract().pressBack(resolved.id, timeoutMs);
|
|
1770
1771
|
}
|
|
1771
1772
|
static async scrollToElementHandler({ platform, selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId }) {
|
|
1772
1773
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
|
|
@@ -2067,7 +2068,7 @@ export class ToolsInteract {
|
|
|
2067
2068
|
};
|
|
2068
2069
|
return { found: true, element: outEl, score: scoreVal, confidence: scoreVal, resolution };
|
|
2069
2070
|
}
|
|
2070
|
-
static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId }) {
|
|
2071
|
+
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 }) {
|
|
2071
2072
|
const overallStart = Date.now();
|
|
2072
2073
|
const requestedIndex = typeof match?.index === 'number' ? match.index : null;
|
|
2073
2074
|
const requested = {
|
|
@@ -2109,7 +2110,7 @@ export class ToolsInteract {
|
|
|
2109
2110
|
let lastConditionSatisfied = false;
|
|
2110
2111
|
let matchedAt = null;
|
|
2111
2112
|
let stableMatchCount = 0;
|
|
2112
|
-
const stableObservationCount = 2;
|
|
2113
|
+
const stableObservationCount = singleObservation ? 1 : 2;
|
|
2113
2114
|
const snapshotStaleThresholdMs = 500;
|
|
2114
2115
|
// Precompute normalized selector values and helpers (constant across polls)
|
|
2115
2116
|
const normalize = ToolsInteract._normalize;
|
|
@@ -2122,7 +2123,8 @@ export class ToolsInteract {
|
|
|
2122
2123
|
attempts++;
|
|
2123
2124
|
const attemptStart = Date.now();
|
|
2124
2125
|
const deadline = attemptStart + (timeout_ms || 0);
|
|
2125
|
-
while (Date.now() <= deadline) {
|
|
2126
|
+
while (Date.now() <= deadline || (singleAttempt && totalPollCount === 0)) {
|
|
2127
|
+
remainingBudget();
|
|
2126
2128
|
totalPollCount++;
|
|
2127
2129
|
try {
|
|
2128
2130
|
const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
|
|
@@ -2172,6 +2174,8 @@ export class ToolsInteract {
|
|
|
2172
2174
|
}
|
|
2173
2175
|
// Evaluate condition
|
|
2174
2176
|
const matchedCount = matches.length;
|
|
2177
|
+
if (rejectAmbiguous && matchedCount > 1 && match?.index === undefined)
|
|
2178
|
+
return { status: 'timeout', error: { code: 'AMBIGUOUS_TARGET' } };
|
|
2175
2179
|
const pickIndex = (typeof match?.index === 'number') ? match.index : undefined;
|
|
2176
2180
|
let chosen = null;
|
|
2177
2181
|
if (matches.length > 0) {
|
|
@@ -2237,6 +2241,7 @@ export class ToolsInteract {
|
|
|
2237
2241
|
const outEl = lastMatchedElement;
|
|
2238
2242
|
return {
|
|
2239
2243
|
status: 'success',
|
|
2244
|
+
...(singleObservation ? { _tree: tree } : {}),
|
|
2240
2245
|
matched: matchedCount,
|
|
2241
2246
|
element: outEl,
|
|
2242
2247
|
metrics: { latency_ms, poll_count: totalPollCount, attempts },
|
|
@@ -2256,9 +2261,13 @@ export class ToolsInteract {
|
|
|
2256
2261
|
}
|
|
2257
2262
|
}
|
|
2258
2263
|
catch (e) {
|
|
2264
|
+
if (e instanceof Error && e.message === 'ACTION_TIMEOUT')
|
|
2265
|
+
throw e;
|
|
2259
2266
|
// Non-fatal per-poll error; record and continue
|
|
2260
2267
|
console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e));
|
|
2261
2268
|
}
|
|
2269
|
+
if (singleAttempt)
|
|
2270
|
+
break;
|
|
2262
2271
|
// Sleep until next poll
|
|
2263
2272
|
await new Promise(r => setTimeout(r, effectivePoll || 50));
|
|
2264
2273
|
}
|
|
@@ -2289,6 +2298,8 @@ export class ToolsInteract {
|
|
|
2289
2298
|
}
|
|
2290
2299
|
}
|
|
2291
2300
|
catch (err) {
|
|
2301
|
+
if (err instanceof Error && err.message === 'ACTION_TIMEOUT')
|
|
2302
|
+
throw err;
|
|
2292
2303
|
const elapsed = Date.now() - overallStart;
|
|
2293
2304
|
return {
|
|
2294
2305
|
status: 'timeout',
|
|
@@ -2665,15 +2676,15 @@ export class ToolsInteract {
|
|
|
2665
2676
|
})
|
|
2666
2677
|
};
|
|
2667
2678
|
}
|
|
2668
|
-
static async expectStateHandler({ selector, element_id, property, expected, platform, deviceId, stabilization_window_ms = 1000, stable_observation_count = 2, snapshot_stale_threshold_ms = 500, poll_interval_ms = 150 }) {
|
|
2679
|
+
static async expectStateHandler({ selector, element_id, property, expected, platform, deviceId, stabilization_window_ms = 1000, stable_observation_count = 2, snapshot_stale_threshold_ms = 500, poll_interval_ms = 150, exact_budget = false }) {
|
|
2669
2680
|
const compareBoolean = (value) => typeof value === 'boolean' ? value : null;
|
|
2670
2681
|
const compareString = (value) => typeof value === 'string' ? value : null;
|
|
2671
2682
|
const compareNumber = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
2672
2683
|
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
2673
2684
|
const start = Date.now();
|
|
2674
|
-
const deadline = start + Math.max(500, stabilization_window_ms);
|
|
2685
|
+
const deadline = start + (exact_budget ? stabilization_window_ms : Math.max(500, stabilization_window_ms));
|
|
2675
2686
|
const stableTarget = Math.max(1, Math.floor(stable_observation_count || 2));
|
|
2676
|
-
const pollDelay = Math.max(100, Math.min(poll_interval_ms || 150, 200));
|
|
2687
|
+
const pollDelay = exact_budget ? Math.max(50, Math.min(poll_interval_ms || 150, 1000)) : Math.max(100, Math.min(poll_interval_ms || 150, 200));
|
|
2677
2688
|
const staleThreshold = Math.max(300, Math.min(snapshot_stale_threshold_ms || 500, 800));
|
|
2678
2689
|
let attempts = 0;
|
|
2679
2690
|
let stableCount = 0;
|
package/dist/interact/ios.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { remainingBudget, markDispatchStarted } from '../utils/operation-budget.js';
|
|
1
2
|
import { spawn } from "child_process";
|
|
2
3
|
import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js";
|
|
3
4
|
import { iOSObserve } from "../observe/index.js";
|
|
@@ -24,7 +25,8 @@ export class iOSInteract {
|
|
|
24
25
|
args.push('--udid', targetUdid);
|
|
25
26
|
}
|
|
26
27
|
await new Promise((resolve, reject) => {
|
|
27
|
-
|
|
28
|
+
markDispatchStarted();
|
|
29
|
+
const proc = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
|
|
28
30
|
let stderr = '';
|
|
29
31
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
30
32
|
proc.on('close', code => {
|
|
@@ -63,7 +65,8 @@ export class iOSInteract {
|
|
|
63
65
|
args.push('--udid', targetUdid);
|
|
64
66
|
}
|
|
65
67
|
await new Promise((resolve, reject) => {
|
|
66
|
-
|
|
68
|
+
markDispatchStarted();
|
|
69
|
+
const proc = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
|
|
67
70
|
let stderr = '';
|
|
68
71
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
69
72
|
proc.on('close', code => {
|
package/dist/manage/android.js
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';
|
|
@@ -145,16 +146,18 @@ export class AndroidManage {
|
|
|
145
146
|
return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: { installDiag, pushDiag, pmDiag } };
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
|
-
async startApp(appId, deviceId) {
|
|
149
|
+
async startApp(appId, deviceId, timeoutMs) {
|
|
150
|
+
const started = performance.now();
|
|
149
151
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
150
152
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
151
153
|
try {
|
|
152
|
-
|
|
154
|
+
markDispatchStarted();
|
|
155
|
+
const output = await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId, { timeout: timeoutMs });
|
|
153
156
|
const current = await new AndroidObserve().getCurrentScreen(deviceId).catch(() => null);
|
|
154
157
|
return {
|
|
155
158
|
device: deviceInfo,
|
|
156
159
|
appStarted: true,
|
|
157
|
-
launchTimeMs:
|
|
160
|
+
launchTimeMs: performance.now() - started,
|
|
158
161
|
output,
|
|
159
162
|
observedApp: {
|
|
160
163
|
appId,
|
|
@@ -166,25 +169,31 @@ export class AndroidManage {
|
|
|
166
169
|
};
|
|
167
170
|
}
|
|
168
171
|
catch (e) {
|
|
169
|
-
const
|
|
170
|
-
return { device: deviceInfo, appStarted: false, launchTimeMs:
|
|
172
|
+
const diagnostics = timeoutMs === undefined ? execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId) : undefined;
|
|
173
|
+
return { device: deviceInfo, appStarted: false, launchTimeMs: performance.now() - started, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) };
|
|
171
174
|
}
|
|
172
175
|
}
|
|
173
176
|
async terminateApp(appId, deviceId) {
|
|
174
177
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
175
178
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
176
179
|
try {
|
|
180
|
+
markDispatchStarted();
|
|
177
181
|
await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
|
|
178
182
|
return { device: deviceInfo, appTerminated: true };
|
|
179
183
|
}
|
|
180
184
|
catch (e) {
|
|
181
|
-
const
|
|
182
|
-
return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics:
|
|
185
|
+
const diagnostics = hasFiniteBudget() ? undefined : execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId);
|
|
186
|
+
return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) };
|
|
183
187
|
}
|
|
184
188
|
}
|
|
185
|
-
async restartApp(appId, deviceId) {
|
|
186
|
-
const
|
|
187
|
-
const
|
|
189
|
+
async restartApp(appId, deviceId, bounded = false, timeoutMs = 15000) {
|
|
190
|
+
const started = performance.now();
|
|
191
|
+
const terminateResult = bounded ? await runWithBudget(Math.min(5000, timeoutMs), () => this.terminateApp(appId, deviceId)) : await this.terminateApp(appId, deviceId);
|
|
192
|
+
const remaining = timeoutMs - (performance.now() - started);
|
|
193
|
+
if (bounded && remaining <= 0)
|
|
194
|
+
throw new Error('ACTION_TIMEOUT');
|
|
195
|
+
const launchBudget = remainingBudget(remaining);
|
|
196
|
+
const startResult = bounded ? await runWithBudget(launchBudget, () => this.startApp(appId, deviceId, remainingBudget(launchBudget))) : await this.startApp(appId, deviceId);
|
|
188
197
|
return {
|
|
189
198
|
device: startResult.device,
|
|
190
199
|
appRestarted: startResult.appStarted,
|
|
@@ -201,6 +210,7 @@ export class AndroidManage {
|
|
|
201
210
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
202
211
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
203
212
|
try {
|
|
213
|
+
markDispatchStarted();
|
|
204
214
|
const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
|
|
205
215
|
return { device: deviceInfo, dataCleared: output === 'Success' };
|
|
206
216
|
}
|
package/dist/manage/ios.js
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 { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "../utils/ios/utils.js";
|
|
@@ -287,17 +288,19 @@ export class iOSManage {
|
|
|
287
288
|
return { device, installed: false, error: e instanceof Error ? e.message : String(e) };
|
|
288
289
|
}
|
|
289
290
|
}
|
|
290
|
-
async startApp(bundleId, deviceId = "booted") {
|
|
291
|
+
async startApp(bundleId, deviceId = "booted", observe = true) {
|
|
292
|
+
const started = performance.now();
|
|
291
293
|
validateBundleId(bundleId);
|
|
292
294
|
try {
|
|
295
|
+
markDispatchStarted();
|
|
293
296
|
const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
|
|
294
297
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
295
|
-
const fingerprint = await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null);
|
|
298
|
+
const fingerprint = observe ? await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null) : null;
|
|
296
299
|
const pidMatch = result.output.match(/:\s*(\d+)\s*$/);
|
|
297
300
|
return {
|
|
298
301
|
device,
|
|
299
302
|
appStarted: !!result.output,
|
|
300
|
-
launchTimeMs:
|
|
303
|
+
launchTimeMs: performance.now() - started,
|
|
301
304
|
output: result.output,
|
|
302
305
|
observedApp: {
|
|
303
306
|
appId: bundleId,
|
|
@@ -308,27 +311,33 @@ export class iOSManage {
|
|
|
308
311
|
};
|
|
309
312
|
}
|
|
310
313
|
catch (e) {
|
|
311
|
-
const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId);
|
|
312
314
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
313
|
-
|
|
315
|
+
const diagnostics = hasFiniteBudget() ? undefined : execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId);
|
|
316
|
+
return { device, appStarted: false, launchTimeMs: performance.now() - started, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) };
|
|
314
317
|
}
|
|
315
318
|
}
|
|
316
319
|
async terminateApp(bundleId, deviceId = "booted") {
|
|
317
320
|
validateBundleId(bundleId);
|
|
318
321
|
try {
|
|
322
|
+
markDispatchStarted();
|
|
319
323
|
await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId);
|
|
320
324
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
321
325
|
return { device, appTerminated: true };
|
|
322
326
|
}
|
|
323
327
|
catch (e) {
|
|
324
|
-
const diag = execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId);
|
|
328
|
+
const diag = hasFiniteBudget() ? undefined : execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId);
|
|
325
329
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
326
|
-
return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
330
|
+
return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), ...(diag ? { diagnostics: diag } : {}) };
|
|
327
331
|
}
|
|
328
332
|
}
|
|
329
|
-
async restartApp(bundleId, deviceId = "booted") {
|
|
330
|
-
const
|
|
331
|
-
const
|
|
333
|
+
async restartApp(bundleId, deviceId = "booted", observe = true, bounded = false, timeoutMs = 15000) {
|
|
334
|
+
const started = performance.now();
|
|
335
|
+
const terminateResult = bounded ? await runWithBudget(Math.min(5000, timeoutMs), () => this.terminateApp(bundleId, deviceId)) : await this.terminateApp(bundleId, deviceId);
|
|
336
|
+
const remaining = timeoutMs - (performance.now() - started);
|
|
337
|
+
if (bounded && remaining <= 0)
|
|
338
|
+
throw new Error('ACTION_TIMEOUT');
|
|
339
|
+
const launchBudget = remainingBudget(remaining);
|
|
340
|
+
const startResult = bounded ? await runWithBudget(launchBudget, () => this.startApp(bundleId, deviceId, observe)) : await this.startApp(bundleId, deviceId, observe);
|
|
332
341
|
return {
|
|
333
342
|
device: startResult.device,
|
|
334
343
|
appRestarted: startResult.appStarted,
|
package/dist/observe/android.js
CHANGED
|
@@ -7,6 +7,7 @@ import path from "path";
|
|
|
7
7
|
import { computeScreenFingerprint } from "../utils/ui/index.js";
|
|
8
8
|
import { parsePngSize } from "../utils/image.js";
|
|
9
9
|
import { deriveSnapshotMetadata } from "./snapshot-metadata.js";
|
|
10
|
+
import { remainingBudget } from '../utils/operation-budget.js';
|
|
10
11
|
const activeLogStreams = new Map();
|
|
11
12
|
export class AndroidObserve {
|
|
12
13
|
async getDeviceMetadata(appId, deviceId) {
|
|
@@ -26,10 +27,12 @@ export class AndroidObserve {
|
|
|
26
27
|
let attempts = 0;
|
|
27
28
|
const maxAttempts = 3;
|
|
28
29
|
while (attempts < maxAttempts) {
|
|
30
|
+
remainingBudget();
|
|
29
31
|
attempts++;
|
|
30
32
|
try {
|
|
31
33
|
// Stabilization delay
|
|
32
|
-
await delay(300 + (attempts * 100)); // 300ms, 400ms, 500ms...
|
|
34
|
+
await delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))); // 300ms, 400ms, 500ms...
|
|
35
|
+
remainingBudget();
|
|
33
36
|
// Dump UI hierarchy
|
|
34
37
|
await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId);
|
|
35
38
|
// Read the file
|
|
@@ -40,6 +43,8 @@ export class AndroidObserve {
|
|
|
40
43
|
}
|
|
41
44
|
}
|
|
42
45
|
catch (e) {
|
|
46
|
+
if (e instanceof Error && e.message === 'ACTION_TIMEOUT')
|
|
47
|
+
throw e;
|
|
43
48
|
console.error(`Attempt ${attempts} failed: ${e}`);
|
|
44
49
|
}
|
|
45
50
|
if (attempts === maxAttempts) {
|
package/dist/observe/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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';
|
|
4
|
-
import { deriveSnapshotMetadata } from './snapshot-metadata.js';
|
|
5
|
+
import { deriveSnapshotMetadata, getLatestStateDelta, getStateDelta } from './snapshot-metadata.js';
|
|
5
6
|
export { AndroidObserve } from './android.js';
|
|
6
7
|
export { iOSObserve } from './ios.js';
|
|
7
8
|
function normalizeHint(value) {
|
|
@@ -110,6 +111,9 @@ function deriveSnapshotSemantic(raw) {
|
|
|
110
111
|
};
|
|
111
112
|
}
|
|
112
113
|
export class ToolsObserve {
|
|
114
|
+
static getLatestStateDelta(platform, deviceId) {
|
|
115
|
+
return getLatestStateDelta(`${platform}:${deviceId}`);
|
|
116
|
+
}
|
|
113
117
|
// Resolve a target device and return the appropriate observe instance and resolved info.
|
|
114
118
|
static async resolveObserve(platform, deviceId, appId) {
|
|
115
119
|
if (platform === 'android') {
|
|
@@ -130,9 +134,22 @@ export class ToolsObserve {
|
|
|
130
134
|
return { observe: new iOSObserve(), resolved };
|
|
131
135
|
}
|
|
132
136
|
}
|
|
133
|
-
static async getUITreeHandler({ platform, deviceId }) {
|
|
137
|
+
static async getUITreeHandler({ platform, deviceId, responseMode = 'debug', sinceSnapshotRevision }) {
|
|
134
138
|
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId);
|
|
135
|
-
|
|
139
|
+
const tree = await observe.getUITree(resolved.id);
|
|
140
|
+
if (responseMode !== 'compact')
|
|
141
|
+
return tree;
|
|
142
|
+
const revision = tree?.snapshot_revision ?? null;
|
|
143
|
+
const baseRevision = sinceSnapshotRevision;
|
|
144
|
+
if (!Number.isInteger(baseRevision) || revision === null) {
|
|
145
|
+
return { delta_available: false, snapshot_revision: revision };
|
|
146
|
+
}
|
|
147
|
+
const delta = getStateDelta(`${resolved.platform}:${resolved.id}`, baseRevision, revision);
|
|
148
|
+
return delta ? { delta_available: true, state_delta: delta, snapshot_revision: revision } : {
|
|
149
|
+
delta_available: false,
|
|
150
|
+
snapshot_revision: revision,
|
|
151
|
+
reason: 'BASE_REVISION_UNAVAILABLE'
|
|
152
|
+
};
|
|
136
153
|
}
|
|
137
154
|
static async getCurrentScreenHandler({ deviceId }) {
|
|
138
155
|
const { observe, resolved } = await ToolsObserve.resolveObserve('android', deviceId);
|
|
@@ -199,7 +216,7 @@ export class ToolsObserve {
|
|
|
199
216
|
// Both observes implement getScreenFingerprint
|
|
200
217
|
return await observe.getScreenFingerprint(resolved.id);
|
|
201
218
|
}
|
|
202
|
-
static async captureDebugSnapshotHandler({ reason, includeLogs = true, logLines = 200, platform, appId, deviceId, sessionId } = {}) {
|
|
219
|
+
static async captureDebugSnapshotHandler({ reason, includeLogs = true, includeScreenshot = true, logLines = 200, platform, appId, deviceId, sessionId } = {}) {
|
|
203
220
|
const timestamp = Date.now();
|
|
204
221
|
const raw = {
|
|
205
222
|
timestamp,
|
|
@@ -215,7 +232,7 @@ export class ToolsObserve {
|
|
|
215
232
|
// Parallel fetches for performance: screenshot, current screen, fingerprint, ui tree, and log stream/get logs
|
|
216
233
|
const sid = sessionId || 'default';
|
|
217
234
|
const tasks = {
|
|
218
|
-
screenshot: ToolsObserve.captureScreenshotHandler({ platform, deviceId }),
|
|
235
|
+
screenshot: includeScreenshot ? measure('screenshot_ms', () => ToolsObserve.captureScreenshotHandler({ platform, deviceId })) : Promise.resolve(null),
|
|
219
236
|
currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
|
|
220
237
|
fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
|
|
221
238
|
uiTree: ToolsObserve.getUITreeHandler({ platform, deviceId }),
|
package/dist/observe/ios.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
|
+
import { remainingBudget } from '../utils/operation-budget.js';
|
|
2
3
|
import { promises as fs } from "fs";
|
|
3
4
|
import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "../utils/ios/utils.js";
|
|
4
5
|
import { createWriteStream, promises as fsPromises } from 'fs';
|
|
@@ -501,15 +502,17 @@ export class iOSObserve {
|
|
|
501
502
|
let attempts = 0;
|
|
502
503
|
const maxAttempts = 3;
|
|
503
504
|
while (attempts < maxAttempts) {
|
|
505
|
+
remainingBudget();
|
|
504
506
|
attempts++;
|
|
505
507
|
try {
|
|
506
|
-
await delay(300 + (attempts * 100));
|
|
508
|
+
await delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100)));
|
|
509
|
+
remainingBudget();
|
|
507
510
|
const args = ['ui', 'describe-all', '--json'];
|
|
508
511
|
if (targetUdid) {
|
|
509
512
|
args.push('--udid', targetUdid);
|
|
510
513
|
}
|
|
511
514
|
const output = await new Promise((resolve, reject) => {
|
|
512
|
-
const child = spawn(getIdbCmd(), args);
|
|
515
|
+
const child = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
|
|
513
516
|
let stdout = '';
|
|
514
517
|
let stderr = '';
|
|
515
518
|
child.stdout.on('data', (data) => stdout += data.toString());
|
|
@@ -530,6 +533,8 @@ export class iOSObserve {
|
|
|
530
533
|
}
|
|
531
534
|
}
|
|
532
535
|
catch (e) {
|
|
536
|
+
if (e instanceof Error && e.message === 'ACTION_TIMEOUT')
|
|
537
|
+
throw e;
|
|
533
538
|
console.error(`Attempt ${attempts} failed: ${e}`);
|
|
534
539
|
}
|
|
535
540
|
if (attempts === maxAttempts) {
|