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
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { measure, withTiming } from '../utils/timing.js'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { runWithBudget } from '../utils/operation-budget.js'
|
|
4
|
+
import { journeySchema, tapElementControlsSchema } from './journey-schema.js'
|
|
5
|
+
import { resolveTargetDevice, withResolvedDevice } from '../utils/resolve-device.js'
|
|
1
6
|
import type {
|
|
2
7
|
InstallAppResponse,
|
|
3
8
|
ResetAppDataResponse,
|
|
@@ -24,6 +29,7 @@ import {
|
|
|
24
29
|
requireObjectArg,
|
|
25
30
|
requireStringArg,
|
|
26
31
|
ToolCallArgs,
|
|
32
|
+
ToolCallResult,
|
|
27
33
|
ToolHandler,
|
|
28
34
|
wrapResponse,
|
|
29
35
|
wrapToolError
|
|
@@ -36,16 +42,107 @@ type WaitForUiMatchArg = { index?: number }
|
|
|
36
42
|
type WaitForUiRetryArg = { max_attempts?: number, backoff_ms?: number }
|
|
37
43
|
type ScrollSelectorArg = { text?: string, resourceId?: string, contentDesc?: string, className?: string }
|
|
38
44
|
type ClassifyNetworkRequestArg = { endpoint: string, status: 'success' | 'failure' | 'retryable' }
|
|
45
|
+
type VerificationMode = 'none' | 'light' | 'full'
|
|
46
|
+
type ResponseMode = 'compact' | 'debug'
|
|
39
47
|
|
|
40
|
-
|
|
48
|
+
function interactionControls(args: ToolCallArgs) {
|
|
49
|
+
const verificationMode = (getStringArg(args, 'verificationMode') as VerificationMode | undefined) ?? 'full'
|
|
50
|
+
const responseMode = (getStringArg(args, 'responseMode') as ResponseMode | undefined) ?? 'debug'
|
|
51
|
+
if (!['none', 'light', 'full'].includes(verificationMode) || !['compact', 'debug'].includes(responseMode)) throw new Error('INVALID_ARGUMENT')
|
|
52
|
+
for (const key of ['verificationMode', 'responseMode']) if (args[key] !== undefined && typeof args[key] !== 'string') throw new Error('INVALID_ARGUMENT')
|
|
53
|
+
for (const key of ['actionTimeoutMs', 'verificationTimeoutMs']) if (args[key] !== undefined && (typeof args[key] !== 'number' || !Number.isInteger(args[key]))) throw new Error('INVALID_ARGUMENT')
|
|
54
|
+
const actionTimeoutMs = getNumberArg(args, 'actionTimeoutMs')
|
|
55
|
+
const verificationTimeoutMs = getNumberArg(args, 'verificationTimeoutMs')
|
|
56
|
+
return { verificationMode, responseMode,
|
|
57
|
+
actionTimeoutMs: actionTimeoutMs === undefined ? undefined : Math.max(100, Math.min(30000, actionTimeoutMs)),
|
|
58
|
+
verificationTimeoutMs: verificationTimeoutMs === undefined ? 3000 : Math.max(100, Math.min(60000, verificationTimeoutMs)) }
|
|
59
|
+
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function compactAction(result: any, verificationMode: VerificationMode, started = true, stateDelta?: unknown, totalMs = 0) {
|
|
63
|
+
return {
|
|
64
|
+
action_id: result.action_id,
|
|
65
|
+
action_type: result.action_type,
|
|
66
|
+
success: !!result.success,
|
|
67
|
+
lifecycle_state: result.success ? (verificationMode === 'full' ? result.lifecycle_state : 'pending_verification') : 'failed',
|
|
68
|
+
...(result.failure_code ? { failure_code: result.failure_code } : {}),
|
|
69
|
+
...(typeof result.retryable === 'boolean' ? { retryable: result.retryable } : {}),
|
|
70
|
+
delivery_status: result.success ? 'delivered' : (started ? 'unknown' : 'not_delivered'),
|
|
71
|
+
dispatch_started: started,
|
|
72
|
+
verification: { mode: verificationMode, status: result.success ? (verificationMode === 'none' ? 'not_requested' : verificationMode === 'light' ? 'observed' : (result.lifecycle_state === 'verified' ? 'verified' : 'observed')) : 'failed' },
|
|
73
|
+
...(stateDelta ? { state_delta: stateDelta } : {}),
|
|
74
|
+
timing: { total_ms: totalMs }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function actionHandler(type: string, execute: ToolHandler): ToolHandler {
|
|
79
|
+
return async (args) => {
|
|
80
|
+
const timing: Record<string, number> = {}
|
|
81
|
+
return withTiming(timing, async () => {
|
|
82
|
+
const started = performance.now()
|
|
83
|
+
const controls = interactionControls(args)
|
|
84
|
+
const budgetMs = controls.actionTimeoutMs ?? ({ start_app: 10000, restart_app: 15000, scroll_to_element: 10000 }[type] ?? 5000)
|
|
85
|
+
let dispatchStarted = false
|
|
86
|
+
let result: any
|
|
87
|
+
const dispatchWithBudget = (timeoutMs: number) => runWithBudget(timeoutMs, async (budget) => {
|
|
88
|
+
try { return payloadOf(await execute(args)) }
|
|
89
|
+
finally { dispatchStarted = budget.dispatchStarted }
|
|
90
|
+
})
|
|
91
|
+
try {
|
|
92
|
+
// Omitted controls retain the existing adapter budgets.
|
|
93
|
+
result = args._journey !== true && controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined
|
|
94
|
+
? await dispatchWithBudget(Infinity) : await dispatchWithBudget(budgetMs)
|
|
95
|
+
if (result.success) dispatchStarted = true
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined && !/TIMEOUT|timed out/i.test(String(error))) return wrapToolError(type, error)
|
|
98
|
+
result = { success: false, failure_code: /TIMEOUT|timed out/i.test(String(error)) ? 'ACTION_TIMEOUT' : 'UNKNOWN', error: String(error) }
|
|
99
|
+
}
|
|
100
|
+
if (result.failure_code === 'TIMEOUT' || /ACTION_TIMEOUT|timeout/i.test(result.error ?? result.reason ?? result.details?.error ?? '')) result.failure_code = 'ACTION_TIMEOUT'
|
|
101
|
+
if (result.failure_code === 'ACTION_TIMEOUT') result.retryable = !dispatchStarted
|
|
102
|
+
if (!result.success && typeof result.retryable !== 'boolean') result.retryable = !dispatchStarted
|
|
103
|
+
result.action_id ??= randomUUID()
|
|
104
|
+
result.action_type ??= type
|
|
105
|
+
let delta: unknown
|
|
106
|
+
let observationAvailable = false
|
|
107
|
+
if (result.success && controls.verificationMode === 'light') {
|
|
108
|
+
const tree = await measure('post_observation_ms', () => runWithBudget(controls.verificationTimeoutMs, () =>
|
|
109
|
+
ToolsObserve.getUITreeHandler({ platform: (args.platform as PlatformArg | undefined) ?? result.device?.platform, deviceId: (args.deviceId as string | undefined) ?? result.device?.id }))).catch(() => null) as any
|
|
110
|
+
observationAvailable = !!tree && !tree.error
|
|
111
|
+
if (observationAvailable && tree.device) delta = ToolsObserve.getLatestStateDelta(tree.device.platform, tree.device.id) ?? undefined
|
|
112
|
+
}
|
|
113
|
+
const compact = compactAction(result, controls.verificationMode, dispatchStarted, delta, performance.now() - started)
|
|
114
|
+
if (result.success && controls.verificationMode === 'light' && !observationAvailable) compact.verification.status = 'unavailable'
|
|
115
|
+
const evidence = {
|
|
116
|
+
...compact,
|
|
117
|
+
...(controls.verificationMode === 'light' ? { state_delta_available: !!delta } : {}),
|
|
118
|
+
...(result.success && controls.verificationMode === 'light' && !observationAvailable ? { verification_diagnostic: { code: 'VERIFICATION_UNAVAILABLE', retryable: true } } : {})
|
|
119
|
+
}
|
|
120
|
+
return wrapResponse(controls.responseMode === 'compact' ? evidence : {
|
|
121
|
+
...result, ...evidence, timing: { ...timing, total_ms: performance.now() - started },
|
|
122
|
+
effective_controls: { ...controls, actionTimeoutMs: args._journey !== true && controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined ? null : budgetMs, timeout_bound: !(args._journey !== true && controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined) }
|
|
123
|
+
}, controls.responseMode === 'debug')
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const handleStartApp = actionHandler('start_app', executeStartApp)
|
|
128
|
+
const handleRestartApp = actionHandler('restart_app', executeRestartApp)
|
|
129
|
+
const handleTap = actionHandler('tap', executeTap)
|
|
130
|
+
const handleTapElement = actionHandler('tap_element', executeTapElement)
|
|
131
|
+
const handleSwipe = actionHandler('swipe', executeSwipe)
|
|
132
|
+
const handleScrollToElement = actionHandler('scroll_to_element', executeScrollToElement)
|
|
133
|
+
const handleTypeText = actionHandler('type_text', executeTypeText)
|
|
134
|
+
const handlePressBack = actionHandler('press_back', executePressBack)
|
|
135
|
+
|
|
136
|
+
async function executeStartApp(args: ToolCallArgs) {
|
|
41
137
|
const platform = requireStringArg(args, 'platform') as PlatformArg
|
|
42
138
|
const appId = requireStringArg(args, 'appId')
|
|
43
139
|
const deviceId = getStringArg(args, 'deviceId')
|
|
44
|
-
const
|
|
140
|
+
const { verificationMode, actionTimeoutMs } = interactionControls(args)
|
|
141
|
+
const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
45
142
|
ToolsNetwork.notifyActionStart()
|
|
46
|
-
const res = await (platform === 'android' ? new AndroidManage().startApp(appId, deviceId) : new iOSManage().startApp(appId, deviceId))
|
|
47
|
-
const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
|
|
48
|
-
|
|
143
|
+
const res = await measure('dispatch_ms', () => (platform === 'android' ? new AndroidManage().startApp(appId, deviceId, actionTimeoutMs ?? (verificationMode === 'full' && getStringArg(args, 'responseMode') === undefined && args._journey !== true ? undefined : 10000)) : new iOSManage().startApp(appId, deviceId, verificationMode === 'full')))
|
|
144
|
+
const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
145
|
+
const result = buildActionExecutionResult({
|
|
49
146
|
actionType: 'start_app',
|
|
50
147
|
sourceModule: 'server',
|
|
51
148
|
device: res.device,
|
|
@@ -59,9 +156,11 @@ async function handleStartApp(args: ToolCallArgs) {
|
|
|
59
156
|
...(typeof res.output === 'string' ? { output: res.output } : {}),
|
|
60
157
|
...(res.device ? { device_id: res.device.id } : {}),
|
|
61
158
|
...(typeof res.error === 'string' ? { error: res.error } : {}),
|
|
159
|
+
...(res.diagnostics ? { diagnostics: res.diagnostics } : {}),
|
|
62
160
|
...(res.observedApp ? { observed_app: res.observedApp } : {})
|
|
63
161
|
}
|
|
64
|
-
})
|
|
162
|
+
})
|
|
163
|
+
return wrapResponse(result)
|
|
65
164
|
}
|
|
66
165
|
|
|
67
166
|
async function handleTerminateApp(args: ToolCallArgs) {
|
|
@@ -73,15 +172,17 @@ async function handleTerminateApp(args: ToolCallArgs) {
|
|
|
73
172
|
return wrapResponse(response)
|
|
74
173
|
}
|
|
75
174
|
|
|
76
|
-
async function
|
|
175
|
+
async function executeRestartApp(args: ToolCallArgs) {
|
|
77
176
|
const platform = requireStringArg(args, 'platform') as PlatformArg
|
|
78
177
|
const appId = requireStringArg(args, 'appId')
|
|
79
178
|
const deviceId = getStringArg(args, 'deviceId')
|
|
80
|
-
const
|
|
179
|
+
const { verificationMode, actionTimeoutMs } = interactionControls(args)
|
|
180
|
+
const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
81
181
|
ToolsNetwork.notifyActionStart()
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
182
|
+
const bounded = args._journey === true || verificationMode !== 'full' || args.responseMode !== undefined || actionTimeoutMs !== undefined
|
|
183
|
+
const res = await measure('dispatch_ms', () => (platform === 'android' ? new AndroidManage().restartApp(appId, deviceId, bounded, actionTimeoutMs ?? 15000) : new iOSManage().restartApp(appId, deviceId, verificationMode === 'full', bounded, actionTimeoutMs ?? 15000)))
|
|
184
|
+
const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
185
|
+
const result = buildActionExecutionResult({
|
|
85
186
|
actionType: 'restart_app',
|
|
86
187
|
sourceModule: 'server',
|
|
87
188
|
device: res.device,
|
|
@@ -96,9 +197,11 @@ async function handleRestartApp(args: ToolCallArgs) {
|
|
|
96
197
|
...(typeof res.terminatedBeforeRestart === 'boolean' ? { terminated_before_restart: res.terminatedBeforeRestart } : {}),
|
|
97
198
|
...(typeof res.terminateError === 'string' ? { terminate_error: res.terminateError } : {}),
|
|
98
199
|
...(typeof res.error === 'string' ? { error: res.error } : {}),
|
|
200
|
+
...(res.diagnostics ? { diagnostics: res.diagnostics } : {}),
|
|
99
201
|
...(res.observedApp ? { observed_app: res.observedApp } : {})
|
|
100
202
|
}
|
|
101
|
-
})
|
|
203
|
+
})
|
|
204
|
+
return wrapResponse(result)
|
|
102
205
|
}
|
|
103
206
|
|
|
104
207
|
async function handleResetAppData(args: ToolCallArgs) {
|
|
@@ -185,10 +288,12 @@ async function handleGetSystemStatus() {
|
|
|
185
288
|
async function handleCaptureScreenshot(args: ToolCallArgs) {
|
|
186
289
|
const platform = requireStringArg(args, 'platform') as PlatformArg
|
|
187
290
|
const deviceId = getStringArg(args, 'deviceId')
|
|
291
|
+
const started = performance.now()
|
|
188
292
|
const res = await ToolsObserve.captureScreenshotHandler({ platform, deviceId })
|
|
293
|
+
const screenshotMs = performance.now() - started
|
|
189
294
|
const mime = (res as any).screenshot_mime || 'image/png'
|
|
190
295
|
const content: Array<{ type: 'text' | 'image'; text?: string; data?: string; mimeType?: string }> = [
|
|
191
|
-
{ type: 'text', text: JSON.stringify({ device: res.device, result: { resolution: (res as any).resolution, mimeType: mime } }, null, 2) },
|
|
296
|
+
{ type: 'text', text: JSON.stringify({ device: res.device, result: { resolution: (res as any).resolution, mimeType: mime }, timing: { total_ms: screenshotMs, screenshot_ms: screenshotMs } }, null, 2) },
|
|
192
297
|
{ type: 'image', data: (res as any).screenshot, mimeType: mime }
|
|
193
298
|
]
|
|
194
299
|
if ((res as any).screenshot_fallback) {
|
|
@@ -206,14 +311,17 @@ async function handleCaptureDebugSnapshot(args: ToolCallArgs) {
|
|
|
206
311
|
const appId = getStringArg(args, 'appId')
|
|
207
312
|
const deviceId = getStringArg(args, 'deviceId')
|
|
208
313
|
const sessionId = getStringArg(args, 'sessionId')
|
|
209
|
-
const
|
|
314
|
+
const includeScreenshot = getBooleanArg(args, 'includeScreenshot')
|
|
315
|
+
const res = await ToolsObserve.captureDebugSnapshotHandler({ reason, includeLogs, includeScreenshot, logLines, platform, appId, deviceId, sessionId })
|
|
210
316
|
return wrapResponse(res)
|
|
211
317
|
}
|
|
212
318
|
|
|
213
319
|
async function handleGetUITree(args: ToolCallArgs) {
|
|
214
320
|
const platform = requireStringArg(args, 'platform') as PlatformArg
|
|
215
321
|
const deviceId = getStringArg(args, 'deviceId')
|
|
216
|
-
const
|
|
322
|
+
const responseMode = (getStringArg(args, 'responseMode') as ResponseMode | undefined) ?? 'debug'
|
|
323
|
+
const sinceSnapshotRevision = getNumberArg(args, 'sinceSnapshotRevision')
|
|
324
|
+
const res = await ToolsObserve.getUITreeHandler({ platform, deviceId, responseMode, sinceSnapshotRevision })
|
|
217
325
|
return wrapResponse(res)
|
|
218
326
|
}
|
|
219
327
|
|
|
@@ -337,16 +445,17 @@ async function handleFindElement(args: ToolCallArgs) {
|
|
|
337
445
|
return wrapResponse(res)
|
|
338
446
|
}
|
|
339
447
|
|
|
340
|
-
async function
|
|
448
|
+
async function executeTap(args: ToolCallArgs) {
|
|
341
449
|
const platform = getStringArg(args, 'platform') as PlatformArg | undefined
|
|
342
450
|
const x = requireNumberArg(args, 'x')
|
|
343
451
|
const y = requireNumberArg(args, 'y')
|
|
344
452
|
const deviceId = getStringArg(args, 'deviceId')
|
|
345
|
-
const
|
|
453
|
+
const { verificationMode, actionTimeoutMs } = interactionControls(args)
|
|
454
|
+
const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
346
455
|
ToolsNetwork.notifyActionStart()
|
|
347
|
-
const res = await ToolsInteract.tapHandler({ platform, x, y, deviceId })
|
|
348
|
-
const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
|
|
349
|
-
|
|
456
|
+
const res = await measure('dispatch_ms', () => ToolsInteract.tapHandler({ platform, x, y, deviceId, timeoutMs: actionTimeoutMs }))
|
|
457
|
+
const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
458
|
+
const result = buildActionExecutionResult({
|
|
350
459
|
actionType: 'tap',
|
|
351
460
|
sourceModule: 'server',
|
|
352
461
|
selector: { x, y },
|
|
@@ -354,17 +463,154 @@ async function handleTap(args: ToolCallArgs) {
|
|
|
354
463
|
uiFingerprintBefore,
|
|
355
464
|
uiFingerprintAfter,
|
|
356
465
|
failure: res.success ? undefined : inferGenericFailure((res as any).error)
|
|
357
|
-
})
|
|
466
|
+
})
|
|
467
|
+
return wrapResponse(result)
|
|
358
468
|
}
|
|
359
469
|
|
|
360
|
-
async function
|
|
361
|
-
const
|
|
470
|
+
async function executeTapElement(args: ToolCallArgs) {
|
|
471
|
+
const controls = tapElementControlsSchema.safeParse({ elementId: args.elementId, selector: args.selector, waitFor: args.waitFor })
|
|
472
|
+
if (!controls.success) throw new Error(`INVALID_ARGUMENT: ${controls.error.message}`)
|
|
473
|
+
const elementId = controls.data.elementId
|
|
474
|
+
const selector = controls.data.selector as ExpectElementSelectorArg | undefined
|
|
475
|
+
const { verificationMode, actionTimeoutMs } = interactionControls(args)
|
|
476
|
+
let resolvedId = elementId
|
|
477
|
+
let freshTree: any
|
|
478
|
+
if (selector) {
|
|
479
|
+
const waitFor = getObjectArg<WaitForUiMatchArg>(args, 'waitFor') as any
|
|
480
|
+
const resolution = await measure('target_resolution_ms', () => runWithBudget(waitFor?.timeoutMs ?? 1500, () => ToolsInteract.waitForUIHandler({ selector, condition: waitFor?.condition ?? 'clickable', timeout_ms: waitFor?.timeoutMs ?? 1500, poll_interval_ms: waitFor?.pollIntervalMs ?? 100, match: waitFor?.match, singleObservation: true, singleAttempt: !waitFor, rejectAmbiguous: !waitFor, platform: getStringArg(args, 'platform') as PlatformArg | undefined, deviceId: getStringArg(args, 'deviceId') }))) as any
|
|
481
|
+
if (resolution.status !== 'success' || !resolution.element?.elementId) return wrapResponse({ success: false, action_type: 'tap_element', failure_code: resolution.error?.code ?? 'ELEMENT_NOT_FOUND', retryable: true })
|
|
482
|
+
resolvedId = resolution.element.elementId
|
|
483
|
+
freshTree = resolution._tree
|
|
484
|
+
}
|
|
362
485
|
ToolsNetwork.notifyActionStart()
|
|
363
|
-
const res = await ToolsInteract.tapElementHandler({ elementId })
|
|
486
|
+
const res = await measure('dispatch_ms', () => ToolsInteract.tapElementHandler({ elementId: resolvedId!, freshTree, verificationMode, timeoutMs: actionTimeoutMs, platform: getStringArg(args, 'platform') as PlatformArg | undefined, deviceId: getStringArg(args, 'deviceId') })) as any
|
|
364
487
|
return wrapResponse(res)
|
|
365
488
|
}
|
|
366
489
|
|
|
367
|
-
|
|
490
|
+
function payloadOf(response: ToolCallResult) {
|
|
491
|
+
const text = response.content.find((item: any) => item.type === 'text')?.text
|
|
492
|
+
return text ? JSON.parse(text) : null
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function expectElementAbsent({ selector, timeoutMs, pollIntervalMs, platform, deviceId }: { selector: ExpectElementSelectorArg, timeoutMs: number, pollIntervalMs: number, platform: PlatformArg, deviceId?: string }) {
|
|
496
|
+
const deadline = performance.now() + timeoutMs
|
|
497
|
+
let consecutive = 0
|
|
498
|
+
let observed: unknown
|
|
499
|
+
while (performance.now() < deadline) {
|
|
500
|
+
const result = await ToolsInteract.waitForUIHandler({ selector, condition: 'not_exists', timeout_ms: 0, singleObservation: true, singleAttempt: true, platform, deviceId }) as any
|
|
501
|
+
observed = result.observed
|
|
502
|
+
consecutive = result.status === 'success' ? consecutive + 1 : 0
|
|
503
|
+
if (consecutive === 2 && performance.now() <= deadline) return { success: true, observed }
|
|
504
|
+
const delay = Math.max(100, pollIntervalMs)
|
|
505
|
+
if (performance.now() + delay >= deadline) break
|
|
506
|
+
await new Promise(resolve => setTimeout(resolve, delay))
|
|
507
|
+
}
|
|
508
|
+
return { success: false, failure_code: 'ASSERTION_TIMEOUT', observed }
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function expectFingerprint({ fingerprint, timeoutMs, platform, deviceId }: { fingerprint: string, timeoutMs: number, platform: PlatformArg, deviceId?: string }) {
|
|
512
|
+
const deadline = performance.now() + timeoutMs
|
|
513
|
+
let consecutive = 0
|
|
514
|
+
let observed: string | null = null
|
|
515
|
+
while (performance.now() < deadline) {
|
|
516
|
+
const result = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as any
|
|
517
|
+
observed = result?.fingerprint ?? null
|
|
518
|
+
consecutive = observed === fingerprint ? consecutive + 1 : 0
|
|
519
|
+
if (consecutive === 2 && performance.now() <= deadline) return { success: true, observed_fingerprint: observed }
|
|
520
|
+
if (performance.now() + 100 >= deadline) break
|
|
521
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
522
|
+
}
|
|
523
|
+
return { success: false, failure_code: 'ASSERTION_TIMEOUT', observed_fingerprint: observed }
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function handleRunJourney(args: ToolCallArgs) {
|
|
527
|
+
const parsed = journeySchema.safeParse(args)
|
|
528
|
+
if (!parsed.success) throw new Error(`INVALID_ARGUMENT: ${parsed.error.message}`)
|
|
529
|
+
const { platform, responseMode, defaults, captureOnFailure } = parsed.data
|
|
530
|
+
const steps = parsed.data.steps as Array<Record<string, any>>
|
|
531
|
+
const device = await resolveTargetDevice({ platform, deviceId: parsed.data.deviceId })
|
|
532
|
+
const deviceId = device.id
|
|
533
|
+
return withResolvedDevice(device, async () => {
|
|
534
|
+
const started = performance.now()
|
|
535
|
+
const results: any[] = []
|
|
536
|
+
let stoppedAt: string | null = null
|
|
537
|
+
let pendingLightStep: number | null = null
|
|
538
|
+
for (let index = 0; index < steps.length; index++) {
|
|
539
|
+
const step = steps[index]
|
|
540
|
+
const requestedVerification = step.verificationMode ?? defaults?.verificationMode ?? 'full'
|
|
541
|
+
const next = steps[index + 1]
|
|
542
|
+
const lightCoveredByAssertion = step.type === 'tap' && responseMode === 'compact' && requestedVerification === 'light' && next?.type === 'assert' &&
|
|
543
|
+
next.assertion?.kind === 'element_visible'
|
|
544
|
+
const stepStart = performance.now()
|
|
545
|
+
let result: any
|
|
546
|
+
if (stoppedAt) {
|
|
547
|
+
results.push({ id: step.id, type: step.type, status: 'not_run', success: false, reason: 'PREVIOUS_STEP_FAILED', result: null, timing: { total_ms: 0 } })
|
|
548
|
+
continue
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
if (step.type === 'tap') {
|
|
552
|
+
result = payloadOf(await handleTapElement({ ...defaults, ...step, platform, deviceId, responseMode, _journey: true, verificationMode: lightCoveredByAssertion ? 'none' : requestedVerification }))
|
|
553
|
+
} else if (step.type === 'wait') {
|
|
554
|
+
result = await ToolsInteract.waitForUIHandler({ selector: step.selector as ExpectElementSelectorArg, condition: (step.condition as any) ?? 'exists', timeout_ms: (step.timeoutMs as number | undefined) ?? 5000, poll_interval_ms: (step.pollIntervalMs as number | undefined) ?? 100, platform, deviceId })
|
|
555
|
+
result = { success: result.status === 'success', ...result }
|
|
556
|
+
} else if (step.type === 'assert') {
|
|
557
|
+
result = await runWithBudget((step.timeoutMs as number | undefined) ?? 5000, async () => {
|
|
558
|
+
const assertion = step.assertion as any
|
|
559
|
+
if (assertion?.kind === 'element_visible') result = await ToolsInteract.expectElementVisibleHandler({ selector: assertion.selector, timeout_ms: (step.timeoutMs as number | undefined) ?? 5000, poll_interval_ms: (step.pollIntervalMs as number | undefined) ?? 100, platform, deviceId })
|
|
560
|
+
else if (assertion?.kind === 'element_absent') result = await expectElementAbsent({ selector: assertion.selector, timeoutMs: (step.timeoutMs as number | undefined) ?? 5000, pollIntervalMs: (step.pollIntervalMs as number | undefined) ?? 100, platform, deviceId })
|
|
561
|
+
else if (assertion?.kind === 'screen_fingerprint') result = await expectFingerprint({ fingerprint: assertion.fingerprint, timeoutMs: (step.timeoutMs as number | undefined) ?? 5000, platform, deviceId })
|
|
562
|
+
else if (assertion?.kind === 'state_equals') result = await ToolsInteract.expectStateHandler({ selector: assertion.selector, element_id: assertion.elementId, property: assertion.property, expected: assertion.expected, platform, deviceId, stabilization_window_ms: step.timeoutMs ?? 5000, poll_interval_ms: step.pollIntervalMs ?? 100, exact_budget: true })
|
|
563
|
+
else result = { success: false, failure_code: 'INVALID_ASSERTION' }
|
|
564
|
+
return result
|
|
565
|
+
})
|
|
566
|
+
} else if (step.type === 'start_app') {
|
|
567
|
+
result = payloadOf(await handleStartApp({ ...defaults, platform, deviceId, appId: step.appId, responseMode, _journey: true, verificationMode: step.verificationMode ?? defaults?.verificationMode ?? 'full' }))
|
|
568
|
+
} else {
|
|
569
|
+
result = { success: false, failure_code: 'UNSUPPORTED_JOURNEY_STEP' }
|
|
570
|
+
}
|
|
571
|
+
} catch (error) {
|
|
572
|
+
result = { success: false, failure_code: step.type === 'assert' ? 'ASSERTION_TIMEOUT' : 'STEP_FAILED', error: error instanceof Error ? error.message : String(error) }
|
|
573
|
+
}
|
|
574
|
+
if (step.type === 'assert' && !result?.success && /TIMEOUT/.test(result?.failure_code ?? '')) result.failure_code = 'ASSERTION_TIMEOUT'
|
|
575
|
+
const success = !!result?.success
|
|
576
|
+
if (lightCoveredByAssertion) {
|
|
577
|
+
result.verification = { mode: 'light', status: success ? 'unavailable' : 'failed' }
|
|
578
|
+
if (success) {
|
|
579
|
+
result.verification_diagnostic = { code: 'VERIFICATION_UNAVAILABLE', retryable: true }
|
|
580
|
+
pendingLightStep = results.length
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (step.type === 'assert' && pendingLightStep !== null) {
|
|
584
|
+
const pending = results[pendingLightStep].result
|
|
585
|
+
if (typeof result?.observed?.matched_count === 'number') {
|
|
586
|
+
pending.verification.status = 'observed'
|
|
587
|
+
delete pending.verification_diagnostic
|
|
588
|
+
const delta = ToolsObserve.getLatestStateDelta(platform, deviceId)
|
|
589
|
+
if (delta) { pending.state_delta = delta; pending.state_delta_available = true }
|
|
590
|
+
}
|
|
591
|
+
pendingLightStep = null
|
|
592
|
+
}
|
|
593
|
+
if (responseMode === 'compact' && ['wait', 'assert'].includes(step.type)) result = {
|
|
594
|
+
success, ...(result.failure_code ? { failure_code: result.failure_code } : {}),
|
|
595
|
+
...(result.error?.code ? { failure_code: result.error.code } : {}),
|
|
596
|
+
...(result.observed?.matched_count !== undefined ? { matched_count: result.observed.matched_count } : {}),
|
|
597
|
+
...(result.observed_fingerprint !== undefined ? { matched: success } : {}),
|
|
598
|
+
...(result.actual_state ? { observed: result.actual_state } : {})
|
|
599
|
+
}
|
|
600
|
+
results.push({ id: step.id, type: step.type, status: success ? 'passed' : 'failed', success, result, timing: { total_ms: performance.now() - stepStart } })
|
|
601
|
+
if (!success) stoppedAt = step.id as string
|
|
602
|
+
}
|
|
603
|
+
const failureSnapshot = stoppedAt && captureOnFailure && responseMode === 'debug'
|
|
604
|
+
? await ToolsObserve.captureDebugSnapshotHandler({ platform, deviceId, includeScreenshot: false }).catch(() => null) : undefined
|
|
605
|
+
const componentTiming: Record<string, number> = {}
|
|
606
|
+
for (const step of results) for (const [key, value] of Object.entries(step.result?.timing ?? {})) {
|
|
607
|
+
if (key !== 'total_ms' && typeof value === 'number') componentTiming[key] = (componentTiming[key] ?? 0) + value
|
|
608
|
+
}
|
|
609
|
+
return wrapResponse({ success: stoppedAt === null, stopped_at_step_id: stoppedAt, steps: results, ...(failureSnapshot ? { failure_snapshot: failureSnapshot } : {}), timing: responseMode === 'compact' ? { total_ms: performance.now() - started } : { ...componentTiming, total_ms: performance.now() - started } }, responseMode === 'debug')
|
|
610
|
+
})
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function executeSwipe(args: ToolCallArgs) {
|
|
368
614
|
const platform = (getStringArg(args, 'platform') as PlatformArg | undefined) ?? 'android'
|
|
369
615
|
const x1 = requireNumberArg(args, 'x1')
|
|
370
616
|
const y1 = requireNumberArg(args, 'y1')
|
|
@@ -372,11 +618,12 @@ async function handleSwipe(args: ToolCallArgs) {
|
|
|
372
618
|
const y2 = requireNumberArg(args, 'y2')
|
|
373
619
|
const duration = requireNumberArg(args, 'duration')
|
|
374
620
|
const deviceId = getStringArg(args, 'deviceId')
|
|
375
|
-
const
|
|
621
|
+
const { verificationMode, actionTimeoutMs } = interactionControls(args)
|
|
622
|
+
const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
376
623
|
ToolsNetwork.notifyActionStart()
|
|
377
|
-
const res = await ToolsInteract.swipeHandler({ platform, x1, y1, x2, y2, duration, deviceId })
|
|
378
|
-
const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
|
|
379
|
-
|
|
624
|
+
const res = await measure('dispatch_ms', () => ToolsInteract.swipeHandler({ platform, x1, y1, x2, y2, duration, deviceId, timeoutMs: actionTimeoutMs }))
|
|
625
|
+
const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
626
|
+
const result = buildActionExecutionResult({
|
|
380
627
|
actionType: 'swipe',
|
|
381
628
|
sourceModule: 'server',
|
|
382
629
|
selector: { x1, y1, x2, y2, duration },
|
|
@@ -384,21 +631,23 @@ async function handleSwipe(args: ToolCallArgs) {
|
|
|
384
631
|
uiFingerprintBefore,
|
|
385
632
|
uiFingerprintAfter,
|
|
386
633
|
failure: res.success ? undefined : inferGenericFailure((res as any).error)
|
|
387
|
-
})
|
|
634
|
+
})
|
|
635
|
+
return wrapResponse(result)
|
|
388
636
|
}
|
|
389
637
|
|
|
390
|
-
async function
|
|
638
|
+
async function executeScrollToElement(args: ToolCallArgs) {
|
|
391
639
|
const platform = requireStringArg(args, 'platform') as PlatformArg
|
|
392
640
|
const selector = requireObjectArg<ScrollSelectorArg>(args, 'selector')
|
|
393
641
|
const direction = getStringArg(args, 'direction') as 'down' | 'up' | undefined
|
|
394
642
|
const maxScrolls = getNumberArg(args, 'maxScrolls')
|
|
395
643
|
const scrollAmount = getNumberArg(args, 'scrollAmount')
|
|
396
644
|
const deviceId = getStringArg(args, 'deviceId')
|
|
397
|
-
const
|
|
645
|
+
const { verificationMode } = interactionControls(args)
|
|
646
|
+
const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
398
647
|
ToolsNetwork.notifyActionStart()
|
|
399
|
-
const res = await ToolsInteract.scrollToElementHandler({ platform, selector, direction, maxScrolls, scrollAmount, deviceId })
|
|
400
|
-
const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
|
|
401
|
-
|
|
648
|
+
const res = await measure('dispatch_ms', () => ToolsInteract.scrollToElementHandler({ platform, selector, direction, maxScrolls, scrollAmount, deviceId }))
|
|
649
|
+
const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
650
|
+
const result = buildActionExecutionResult({
|
|
402
651
|
actionType: 'scroll_to_element',
|
|
403
652
|
sourceModule: 'server',
|
|
404
653
|
selector: selector ?? null,
|
|
@@ -415,17 +664,19 @@ async function handleScrollToElement(args: ToolCallArgs) {
|
|
|
415
664
|
uiFingerprintBefore,
|
|
416
665
|
uiFingerprintAfter,
|
|
417
666
|
failure: res.success ? undefined : inferScrollFailure((res as any).reason)
|
|
418
|
-
})
|
|
667
|
+
})
|
|
668
|
+
return wrapResponse(result)
|
|
419
669
|
}
|
|
420
670
|
|
|
421
|
-
async function
|
|
671
|
+
async function executeTypeText(args: ToolCallArgs) {
|
|
422
672
|
const text = requireStringArg(args, 'text')
|
|
423
673
|
const deviceId = getStringArg(args, 'deviceId')
|
|
424
|
-
const
|
|
674
|
+
const { verificationMode, actionTimeoutMs } = interactionControls(args)
|
|
675
|
+
const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint('android', deviceId)) : null
|
|
425
676
|
ToolsNetwork.notifyActionStart()
|
|
426
|
-
const res = await ToolsInteract.typeTextHandler({ text, deviceId })
|
|
427
|
-
const uiFingerprintAfter = await captureActionFingerprint('android', deviceId)
|
|
428
|
-
|
|
677
|
+
const res = await measure('dispatch_ms', () => ToolsInteract.typeTextHandler({ text, deviceId, timeoutMs: actionTimeoutMs }))
|
|
678
|
+
const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint('android', deviceId)) : null
|
|
679
|
+
const result = buildActionExecutionResult({
|
|
429
680
|
actionType: 'type_text',
|
|
430
681
|
sourceModule: 'server',
|
|
431
682
|
selector: { text },
|
|
@@ -433,16 +684,18 @@ async function handleTypeText(args: ToolCallArgs) {
|
|
|
433
684
|
uiFingerprintBefore,
|
|
434
685
|
uiFingerprintAfter,
|
|
435
686
|
failure: res.success ? undefined : inferGenericFailure((res as any).error)
|
|
436
|
-
})
|
|
687
|
+
})
|
|
688
|
+
return wrapResponse(result)
|
|
437
689
|
}
|
|
438
690
|
|
|
439
|
-
async function
|
|
691
|
+
async function executePressBack(args: ToolCallArgs) {
|
|
440
692
|
const deviceId = getStringArg(args, 'deviceId')
|
|
441
|
-
const
|
|
693
|
+
const { verificationMode, actionTimeoutMs } = interactionControls(args)
|
|
694
|
+
const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint('android', deviceId)) : null
|
|
442
695
|
ToolsNetwork.notifyActionStart()
|
|
443
|
-
const res = await ToolsInteract.pressBackHandler({ deviceId })
|
|
444
|
-
const uiFingerprintAfter = await captureActionFingerprint('android', deviceId)
|
|
445
|
-
|
|
696
|
+
const res = await measure('dispatch_ms', () => ToolsInteract.pressBackHandler({ deviceId, timeoutMs: actionTimeoutMs }))
|
|
697
|
+
const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint('android', deviceId)) : null
|
|
698
|
+
const result = buildActionExecutionResult({
|
|
446
699
|
actionType: 'press_back',
|
|
447
700
|
sourceModule: 'server',
|
|
448
701
|
selector: { key: 'back' },
|
|
@@ -450,7 +703,8 @@ async function handlePressBack(args: ToolCallArgs) {
|
|
|
450
703
|
uiFingerprintBefore,
|
|
451
704
|
uiFingerprintAfter,
|
|
452
705
|
failure: res.success ? undefined : inferGenericFailure((res as any).error)
|
|
453
|
-
})
|
|
706
|
+
})
|
|
707
|
+
return wrapResponse(result)
|
|
454
708
|
}
|
|
455
709
|
|
|
456
710
|
async function handleStartLogStream(args: ToolCallArgs) {
|
|
@@ -528,6 +782,7 @@ export const toolHandlers: Record<string, ToolHandler> = {
|
|
|
528
782
|
find_element: handleFindElement,
|
|
529
783
|
tap: handleTap,
|
|
530
784
|
tap_element: handleTapElement,
|
|
785
|
+
run_journey: handleRunJourney,
|
|
531
786
|
swipe: handleSwipe,
|
|
532
787
|
scroll_to_element: handleScrollToElement,
|
|
533
788
|
type_text: handleTypeText,
|
|
@@ -543,10 +798,26 @@ export async function handleToolCall(name: string, args: ToolCallArgs = {}) {
|
|
|
543
798
|
const handler = toolHandlers[name]
|
|
544
799
|
if (!handler) throw new Error(`Unknown tool: ${name}`)
|
|
545
800
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
801
|
+
const started = performance.now()
|
|
802
|
+
const measured: Record<string, number> = {}
|
|
803
|
+
const addTiming = (response: ToolCallResult) => {
|
|
804
|
+
if (!['start_app', 'restart_app', 'tap', 'tap_element', 'swipe', 'scroll_to_element', 'type_text', 'press_back', 'wait_for_ui', 'get_ui_tree', 'capture_screenshot', 'capture_debug_snapshot'].includes(name)) return response
|
|
805
|
+
const item = response.content.find(item => item.type === 'text')
|
|
806
|
+
if (item?.text) {
|
|
807
|
+
try {
|
|
808
|
+
const value = JSON.parse(item.text)
|
|
809
|
+
value.timing = { ...measured, ...value.timing, total_ms: performance.now() - started }
|
|
810
|
+
item.text = JSON.stringify(value)
|
|
811
|
+
} catch { /* Non-JSON text responses retain their original content. */ }
|
|
812
|
+
}
|
|
813
|
+
return response
|
|
551
814
|
}
|
|
815
|
+
return withTiming(measured, async () => {
|
|
816
|
+
try {
|
|
817
|
+
return addTiming(await handler(args))
|
|
818
|
+
} catch (error) {
|
|
819
|
+
console.error(`Error executing tool ${name}:`, error)
|
|
820
|
+
return addTiming(wrapToolError(name, error))
|
|
821
|
+
}
|
|
822
|
+
})
|
|
552
823
|
}
|
package/src/server-core.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -162,6 +162,16 @@ export interface SnapshotDelta {
|
|
|
162
162
|
total_elements: number;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
export interface StateDelta {
|
|
166
|
+
base_snapshot_revision: number | null;
|
|
167
|
+
snapshot_revision: number;
|
|
168
|
+
added: UIElement[];
|
|
169
|
+
removed: Array<{ stable_id?: string; resourceId?: string; contentDescription?: string; text?: string; index: number }>;
|
|
170
|
+
changed: UIElement[];
|
|
171
|
+
truncated: boolean;
|
|
172
|
+
omitted_changes?: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
165
175
|
export interface CaptureAndroidScreenResponse {
|
|
166
176
|
device: DeviceInfo;
|
|
167
177
|
screenshot: string; // base64 encoded string
|
|
@@ -282,6 +292,8 @@ export type ActionFailureCode =
|
|
|
282
292
|
| 'ELEMENT_NOT_FOUND'
|
|
283
293
|
| 'ELEMENT_NOT_INTERACTABLE'
|
|
284
294
|
| 'TIMEOUT'
|
|
295
|
+
| 'ACTION_TIMEOUT'
|
|
296
|
+
| 'ASSERTION_TIMEOUT'
|
|
285
297
|
| 'NAVIGATION_NO_CHANGE'
|
|
286
298
|
| 'AMBIGUOUS_TARGET'
|
|
287
299
|
| 'STALE_REFERENCE'
|
|
@@ -393,11 +405,23 @@ export interface FindElementResponse {
|
|
|
393
405
|
error?: string;
|
|
394
406
|
}
|
|
395
407
|
|
|
408
|
+
export interface TimingBreakdown {
|
|
409
|
+
total_ms: number;
|
|
410
|
+
device_resolution_ms?: number;
|
|
411
|
+
pre_observation_ms?: number;
|
|
412
|
+
target_resolution_ms?: number;
|
|
413
|
+
dispatch_ms?: number;
|
|
414
|
+
post_observation_ms?: number;
|
|
415
|
+
verification_ms?: number;
|
|
416
|
+
screenshot_ms?: number;
|
|
417
|
+
serialization_ms?: number;
|
|
418
|
+
}
|
|
419
|
+
|
|
396
420
|
export interface ActionExecutionResult {
|
|
397
421
|
action_id: string;
|
|
398
422
|
timestamp: string;
|
|
399
423
|
action_type: string;
|
|
400
|
-
lifecycle_state?: 'pending_verification' | 'failed';
|
|
424
|
+
lifecycle_state?: 'pending_verification' | 'verified' | 'failed';
|
|
401
425
|
source_module?: 'server' | 'interact';
|
|
402
426
|
device?: DeviceInfo;
|
|
403
427
|
target: {
|
|
@@ -407,6 +431,11 @@ export interface ActionExecutionResult {
|
|
|
407
431
|
success: boolean;
|
|
408
432
|
failure_code?: ActionFailureCode;
|
|
409
433
|
retryable?: boolean;
|
|
434
|
+
delivery_status?: 'delivered' | 'not_delivered' | 'unknown';
|
|
435
|
+
dispatch_started?: boolean;
|
|
436
|
+
verification?: { mode: 'none' | 'light' | 'full'; status: 'not_requested' | 'observed' | 'verified' | 'unavailable' | 'failed' };
|
|
437
|
+
state_delta?: StateDelta;
|
|
438
|
+
timing?: TimingBreakdown;
|
|
410
439
|
recovery?: RecoveryState;
|
|
411
440
|
trace: ActionTrace;
|
|
412
441
|
ui_fingerprint_before?: string | null;
|
|
@@ -414,7 +443,7 @@ export interface ActionExecutionResult {
|
|
|
414
443
|
details?: Record<string, unknown>;
|
|
415
444
|
}
|
|
416
445
|
|
|
417
|
-
export interface TapElementResponse extends ActionExecutionResult {}
|
|
446
|
+
export interface TapElementResponse extends ActionExecutionResult { error?: string }
|
|
418
447
|
|
|
419
448
|
export interface ExpectScreenResponse {
|
|
420
449
|
success: boolean;
|
package/src/utils/exec.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { remainingBudget } from './operation-budget.js'
|
|
1
2
|
import { spawn } from 'child_process'
|
|
2
3
|
|
|
3
4
|
export type ExecOptions = { timeout?: number; env?: NodeJS.ProcessEnv; cwd?: string; shell?: boolean }
|
|
4
5
|
|
|
5
6
|
export async function execCmd(cmd: string, args: string[], opts: ExecOptions = {}): Promise<{ exitCode: number | null, stdout: string, stderr: string }> {
|
|
6
|
-
const {
|
|
7
|
+
const { env, cwd, shell } = opts
|
|
8
|
+
const timeout = remainingBudget(opts.timeout ?? 0)
|
|
7
9
|
return new Promise((resolve, reject) => {
|
|
8
10
|
const child = spawn(cmd, args, { env: { ...process.env, ...(env || {}) }, cwd, shell })
|
|
9
11
|
let stdout = ''
|
|
@@ -15,7 +17,7 @@ export async function execCmd(cmd: string, args: string[], opts: ExecOptions = {
|
|
|
15
17
|
const timer = timeout && timeout > 0 ? setTimeout(() => {
|
|
16
18
|
timedOut = true
|
|
17
19
|
try { child.kill() } catch { }
|
|
18
|
-
resolve({ exitCode: null, stdout: stdout.trim(), stderr:
|
|
20
|
+
resolve({ exitCode: null, stdout: stdout.trim(), stderr: 'ACTION_TIMEOUT' })
|
|
19
21
|
}, timeout) : null
|
|
20
22
|
|
|
21
23
|
child.on('close', (code) => {
|