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/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,
|
package/src/observe/android.ts
CHANGED
|
@@ -8,6 +8,7 @@ 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'
|
|
11
12
|
|
|
12
13
|
const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
|
|
13
14
|
|
|
@@ -33,10 +34,12 @@ export class AndroidObserve {
|
|
|
33
34
|
const maxAttempts = 3;
|
|
34
35
|
|
|
35
36
|
while (attempts < maxAttempts) {
|
|
37
|
+
remainingBudget()
|
|
36
38
|
attempts++;
|
|
37
39
|
try {
|
|
38
40
|
// Stabilization delay
|
|
39
|
-
await delay(300 + (attempts * 100)); // 300ms, 400ms, 500ms...
|
|
41
|
+
await delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))); // 300ms, 400ms, 500ms...
|
|
42
|
+
remainingBudget()
|
|
40
43
|
|
|
41
44
|
// Dump UI hierarchy
|
|
42
45
|
await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId);
|
|
@@ -49,6 +52,7 @@ export class AndroidObserve {
|
|
|
49
52
|
break; // Success
|
|
50
53
|
}
|
|
51
54
|
} catch (e) {
|
|
55
|
+
if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
|
|
52
56
|
console.error(`Attempt ${attempts} failed: ${e}`);
|
|
53
57
|
}
|
|
54
58
|
|
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,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 { GetLogsResponse, CaptureIOSScreenshotResponse, GetUITreeResponse, UIElement, DeviceInfo, UIElementSemanticMetadata, UIElementState, UIResolutionSelector, SelectorConfidence } from "../types.js"
|
|
4
5
|
import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "../utils/ios/utils.js"
|
|
@@ -547,9 +548,11 @@ export class iOSObserve {
|
|
|
547
548
|
const maxAttempts = 3;
|
|
548
549
|
|
|
549
550
|
while (attempts < maxAttempts) {
|
|
551
|
+
remainingBudget()
|
|
550
552
|
attempts++;
|
|
551
553
|
try {
|
|
552
|
-
await delay(300 + (attempts * 100));
|
|
554
|
+
await delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100)));
|
|
555
|
+
remainingBudget()
|
|
553
556
|
|
|
554
557
|
const args = ['ui', 'describe-all', '--json'];
|
|
555
558
|
if (targetUdid) {
|
|
@@ -557,7 +560,7 @@ export class iOSObserve {
|
|
|
557
560
|
}
|
|
558
561
|
|
|
559
562
|
const output = await new Promise<string>((resolve, reject) => {
|
|
560
|
-
const child = spawn(getIdbCmd(), args);
|
|
563
|
+
const child = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
|
|
561
564
|
let stdout = '';
|
|
562
565
|
let stderr = '';
|
|
563
566
|
|
|
@@ -580,6 +583,7 @@ export class iOSObserve {
|
|
|
580
583
|
break; // Success
|
|
581
584
|
}
|
|
582
585
|
} catch (e) {
|
|
586
|
+
if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
|
|
583
587
|
console.error(`Attempt ${attempts} failed: ${e}`);
|
|
584
588
|
}
|
|
585
589
|
|
|
@@ -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'
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
const selector = z.object({
|
|
4
|
+
text: z.string().min(1).optional(), resource_id: z.string().min(1).optional(),
|
|
5
|
+
accessibility_id: z.string().min(1).optional(), contains: z.boolean().optional()
|
|
6
|
+
}).strict().refine(value => !!(value.text || value.resource_id || value.accessibility_id), 'Selector requires a target')
|
|
7
|
+
const verificationMode = z.enum(['none', 'light', 'full'])
|
|
8
|
+
const timeoutMs = z.number().int().min(100).max(60000).optional()
|
|
9
|
+
const pollIntervalMs = z.number().int().min(50).max(1000).optional()
|
|
10
|
+
const waitFor = z.object({
|
|
11
|
+
condition: z.enum(['exists', 'visible', 'clickable']).optional(),
|
|
12
|
+
timeoutMs: z.number().int().min(100).max(10000).optional(), pollIntervalMs,
|
|
13
|
+
match: z.object({ index: z.number().int().nonnegative().optional() }).strict().optional()
|
|
14
|
+
}).strict()
|
|
15
|
+
const target = { selector: selector.optional(), elementId: z.string().min(1).optional() }
|
|
16
|
+
const exactlyOneTarget = (value: { selector?: unknown, elementId?: string }) => Number(!!value.selector) + Number(!!value.elementId) === 1
|
|
17
|
+
const assertion = z.union([
|
|
18
|
+
z.object({ kind: z.literal('element_visible'), selector }).strict(),
|
|
19
|
+
z.object({ kind: z.literal('element_absent'), selector }).strict(),
|
|
20
|
+
z.object({ kind: z.literal('screen_fingerprint'), fingerprint: z.string().min(1) }).strict(),
|
|
21
|
+
z.object({ kind: z.literal('state_equals'), property: z.string().min(1), expected: z.union([z.boolean(), z.number().finite(), z.string()]), ...target }).strict().refine(exactlyOneTarget)
|
|
22
|
+
])
|
|
23
|
+
const id = z.string().min(1)
|
|
24
|
+
const step = z.union([
|
|
25
|
+
z.object({ id, type: z.literal('start_app'), appId: z.string().min(1), verificationMode: verificationMode.optional() }).strict(),
|
|
26
|
+
z.object({ id, type: z.literal('tap'), ...target, waitFor: waitFor.optional(), verificationMode: verificationMode.optional() }).strict().refine(exactlyOneTarget),
|
|
27
|
+
z.object({ id, type: z.literal('wait'), selector, condition: z.enum(['exists', 'not_exists', 'visible', 'clickable']).optional(), timeoutMs, pollIntervalMs }).strict(),
|
|
28
|
+
z.object({ id, type: z.literal('assert'), assertion, timeoutMs, pollIntervalMs }).strict()
|
|
29
|
+
])
|
|
30
|
+
export const tapElementControlsSchema = z.object({
|
|
31
|
+
elementId: z.string().min(1).optional(), selector: selector.optional(), waitFor: waitFor.optional()
|
|
32
|
+
}).strict().refine(value => Number(!!value.elementId) + Number(!!value.selector) === 1 && (!value.elementId || !value.waitFor))
|
|
33
|
+
|
|
34
|
+
export const journeySchema = z.object({
|
|
35
|
+
platform: z.enum(['android', 'ios']), deviceId: z.string().min(1).optional(),
|
|
36
|
+
responseMode: z.enum(['compact', 'debug']).default('compact'), captureOnFailure: z.boolean().default(false),
|
|
37
|
+
defaults: z.object({ verificationMode: verificationMode.optional(), actionTimeoutMs: z.number().int().finite().optional(), verificationTimeoutMs: z.number().int().finite().optional() }).strict().optional(),
|
|
38
|
+
steps: z.array(step).min(1).max(50)
|
|
39
|
+
}).strict().refine(value => new Set(value.steps.map(step => step.id)).size === value.steps.length, 'Step IDs must be unique')
|
|
40
|
+
|
|
41
|
+
const selectorJson = { type: 'object', additionalProperties: false, properties: {
|
|
42
|
+
text: { type: 'string', minLength: 1 }, resource_id: { type: 'string', minLength: 1 }, accessibility_id: { type: 'string', minLength: 1 }, contains: { type: 'boolean' }
|
|
43
|
+
}, anyOf: ['text', 'resource_id', 'accessibility_id'].map(key => ({ required: [key] })) }
|
|
44
|
+
const targetJson = { selector: selectorJson, elementId: { type: 'string', minLength: 1 } }
|
|
45
|
+
const oneTargetJson = [{ required: ['selector'], not: { required: ['elementId'] } }, { required: ['elementId'], not: { required: ['selector'] } }]
|
|
46
|
+
const intervalJson = { timeoutMs: { type: 'integer', minimum: 100, maximum: 60000 }, pollIntervalMs: { type: 'integer', minimum: 50, maximum: 1000 } }
|
|
47
|
+
const modeJson = { type: 'string', enum: ['none', 'light', 'full'] }
|
|
48
|
+
const objectJson = (properties: Record<string, unknown>, required: string[], extra = {}) => ({ type: 'object', additionalProperties: false, properties, required, ...extra })
|
|
49
|
+
export const journeyStepsJson = {
|
|
50
|
+
type: 'array', minItems: 1, maxItems: 50, items: { oneOf: [
|
|
51
|
+
objectJson({ id: { type: 'string', minLength: 1 }, type: { const: 'start_app' }, appId: { type: 'string', minLength: 1 }, verificationMode: modeJson }, ['id', 'type', 'appId']),
|
|
52
|
+
objectJson({ id: { type: 'string', minLength: 1 }, type: { const: 'tap' }, ...targetJson, verificationMode: modeJson,
|
|
53
|
+
waitFor: objectJson({ ...intervalJson, timeoutMs: { type: 'integer', minimum: 100, maximum: 10000 }, condition: { enum: ['exists', 'visible', 'clickable'] }, match: objectJson({ index: { type: 'integer', minimum: 0 } }, []) }, [])
|
|
54
|
+
}, ['id', 'type'], { oneOf: oneTargetJson }),
|
|
55
|
+
objectJson({ id: { type: 'string', minLength: 1 }, type: { const: 'wait' }, selector: selectorJson, condition: { enum: ['exists', 'not_exists', 'visible', 'clickable'] }, ...intervalJson }, ['id', 'type', 'selector']),
|
|
56
|
+
objectJson({ id: { type: 'string', minLength: 1 }, type: { const: 'assert' }, ...intervalJson, assertion: { oneOf: [
|
|
57
|
+
objectJson({ kind: { const: 'element_visible' }, selector: selectorJson }, ['kind', 'selector']),
|
|
58
|
+
objectJson({ kind: { const: 'element_absent' }, selector: selectorJson }, ['kind', 'selector']),
|
|
59
|
+
objectJson({ kind: { const: 'screen_fingerprint' }, fingerprint: { type: 'string', minLength: 1 } }, ['kind', 'fingerprint']),
|
|
60
|
+
objectJson({ kind: { const: 'state_equals' }, ...targetJson, property: { type: 'string', minLength: 1 }, expected: { type: ['boolean', 'number', 'string'] } }, ['kind', 'property', 'expected'], { oneOf: oneTargetJson })
|
|
61
|
+
] } }, ['id', 'type', 'assertion'])
|
|
62
|
+
] }
|
|
63
|
+
}
|