mobile-debug-mcp 0.32.0 → 0.32.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/observe/android-tree-bridge.js +202 -0
- package/dist/observe/android.js +49 -15
- package/dist/observe/ios.js +45 -29
- package/dist/observe/performance-metrics.js +124 -0
- package/dist/server/tool-handlers.js +31 -9
- package/dist/server-core.js +1 -1
- package/dist/utils/exec.js +15 -4
- package/docs/CHANGELOG.md +6 -0
- package/docs/ROADMAP.md +9 -0
- package/docs/benchmarks/ui-tree-performance/README.md +58 -0
- package/docs/benchmarks/ui-tree-performance/baseline.json +59546 -0
- package/docs/benchmarks/ui-tree-performance/bridge-probe.json +29 -0
- package/docs/benchmarks/ui-tree-performance/candidate.json +51691 -0
- package/docs/benchmarks/ui-tree-performance/comparison-attempt-1.json +110679 -0
- package/docs/benchmarks/ui-tree-performance/comparison.json +111230 -0
- package/docs/benchmarks/ui-tree-performance/comparison.md +41 -0
- package/docs/benchmarks/ui-tree-performance/initial-baseline.json +59560 -0
- package/docs/specs/019-ui-tree-observation-performance/spec.md +148 -0
- package/docs/tools/observe.md +2 -0
- package/package.json +1 -1
- package/skills/mobile-debug-tools-usage/SKILL.md +74 -0
- package/src/observe/android-tree-bridge.ts +178 -0
- package/src/observe/android.ts +44 -17
- package/src/observe/ios.ts +45 -36
- package/src/observe/performance-metrics.ts +125 -0
- package/src/server/tool-handlers.ts +34 -10
- package/src/server-core.ts +1 -1
- package/src/utils/exec.ts +10 -4
- package/test/device/automated/ui-tree-performance/benchmark.ts +401 -0
- package/test/device/automated/ui-tree-performance/bridge-probe.ts +197 -0
- package/test/fixtures/latency-journey-app/AndroidManifest.xml +2 -1
- package/test/fixtures/latency-journey-app/README.md +19 -1
- package/test/fixtures/latency-journey-app/src/MainActivity.java +88 -1
- package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +6 -0
- package/test/fixtures/ui-tree-bridge/build.ts +34 -0
- package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +126 -0
- package/test/unit/observe/android_tree_acquisition.test.ts +70 -0
- package/test/unit/observe/android_tree_bridge_cleanup.test.ts +95 -0
- package/test/unit/observe/ios_tree_acquisition.test.ts +77 -0
- package/test/unit/observe/performance_metrics.test.ts +40 -0
- package/test/unit/server/operation_budget.test.ts +13 -0
- package/test/unit/server/run_journey.test.ts +30 -0
- /package/docs/{specs-deltas/001-state-verification.md → specs/001-state-verification/spec.md} +0 -0
- /package/docs/{specs-deltas/002-richer-element-identity → specs/002-richer-element-identity/spec.md} +0 -0
- /package/docs/{specs-deltas/003-wait-and-synchronization-reliability.md → specs/003-wait-and-synchronization-reliability/spec.md} +0 -0
- /package/docs/{specs-deltas/004-action-verification-routing.md → specs/004-action-verification-routing/spec.md} +0 -0
- /package/docs/{specs-deltas/005-unified-action-execution-and-verification-model.md → specs/005-unified-action-execution-and-verification-model/spec.md} +0 -0
- /package/docs/{specs-deltas/006-runtime-action-instrumentation-and-binding-layer.md → specs/006-runtime-action-instrumentation-and-binding-layer/spec.md} +0 -0
- /package/docs/{specs-deltas/007-actionability-resolution-and-executable-target-selection.md → specs/007-actionability-resolution-and-executable-target-selection/spec.md} +0 -0
- /package/docs/{specs-deltas/008-adjustable-control-support-and-semantic-value-manipulation.md → specs/008-adjustable-control-support-and-semantic-value-manipulation/spec.md} +0 -0
- /package/docs/{specs-deltas/009-semantic-control-modeling-for-custom-and-composite-controls.md → specs/009-semantic-control-modeling-for-custom-and-composite-controls/spec.md} +0 -0
- /package/docs/{specs-deltas/010-verification-stabilization-and-temporal-convergence.md → specs/010-verification-stabilization-and-temporal-convergence/spec.md} +0 -0
- /package/docs/{specs-deltas/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows.md → specs/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows/spec.md} +0 -0
- /package/docs/{specs-deltas/011.1-recovery-contract-types-and-runtime-wiring-spec.md → specs/011.1-recovery-contract-types-and-runtime-wiring-spec/spec.md} +0 -0
- /package/docs/{specs-deltas/012-action-trace-and-xecution-observability.md → specs/012-action-trace-and-xecution-observability/spec.md} +0 -0
- /package/docs/{specs-deltas/013-wait-and-synchronization-reliability.md → specs/013-wait-and-synchronization-reliability/spec.md} +0 -0
- /package/docs/{specs-deltas/014-actionability-resolution.md → specs/014-actionability-resolution/spec.md} +0 -0
- /package/docs/{specs-deltas/015-adjustable-control-support.md → specs/015-adjustable-control-support/spec.md} +0 -0
- /package/docs/{specs-deltas/016-environment-tools.md → specs/016-environment-tools/spec.md} +0 -0
- /package/docs/{specs-deltas/018-interaction-latency-and-batched-journeys.md → specs/018-interaction-latency-and-batched-journeys/spec.md} +0 -0
package/src/observe/ios.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn } from "child_process"
|
|
2
2
|
import { remainingBudget } from '../utils/operation-budget.js'
|
|
3
|
+
import { execCmd } from '../utils/exec.js'
|
|
4
|
+
import { createObservationMeasurement } from './performance-metrics.js'
|
|
3
5
|
import { promises as fs } from "fs"
|
|
4
6
|
import { GetLogsResponse, CaptureIOSScreenshotResponse, GetUITreeResponse, UIElement, DeviceInfo, UIElementSemanticMetadata, UIElementState, UIResolutionSelector, SelectorConfidence } from "../types.js"
|
|
5
7
|
import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "../utils/ios/utils.js"
|
|
@@ -525,12 +527,22 @@ export class iOSObserve {
|
|
|
525
527
|
}
|
|
526
528
|
|
|
527
529
|
async getUITree(deviceId: string = "booted"): Promise<GetUITreeResponse> {
|
|
528
|
-
const
|
|
530
|
+
const measurement = createObservationMeasurement('ios')
|
|
531
|
+
let device
|
|
532
|
+
try {
|
|
533
|
+
device = await measurement.timed('metadata_ms', () => getIOSDeviceMetadata(deviceId))
|
|
534
|
+
} catch (error) {
|
|
535
|
+
measurement.setOutcome('unavailable')
|
|
536
|
+
measurement.finish()
|
|
537
|
+
throw error
|
|
538
|
+
}
|
|
529
539
|
const deviceKey = `ios:${device.id}`
|
|
530
540
|
|
|
531
|
-
const idbExists = await isIDBInstalled();
|
|
541
|
+
const idbExists = await measurement.timed('capability_check_ms', () => isIDBInstalled());
|
|
532
542
|
if (!idbExists) {
|
|
533
543
|
const snapshotMetadata = deriveSnapshotMetadata(deviceKey, null, 'ui_tree')
|
|
544
|
+
measurement.setOutcome('unavailable')
|
|
545
|
+
measurement.finish()
|
|
534
546
|
return {
|
|
535
547
|
device,
|
|
536
548
|
screen: "",
|
|
@@ -551,7 +563,7 @@ export class iOSObserve {
|
|
|
551
563
|
remainingBudget()
|
|
552
564
|
attempts++;
|
|
553
565
|
try {
|
|
554
|
-
await delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100)));
|
|
566
|
+
await measurement.timed('observation_wait_ms', () => delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))));
|
|
555
567
|
remainingBudget()
|
|
556
568
|
|
|
557
569
|
const args = ['ui', 'describe-all', '--json'];
|
|
@@ -559,27 +571,16 @@ export class iOSObserve {
|
|
|
559
571
|
args.push('--udid', targetUdid);
|
|
560
572
|
}
|
|
561
573
|
|
|
562
|
-
const output = await
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
child.on('error', (err) => reject(new Error(`Failed to execute idb: ${err.message}`)));
|
|
571
|
-
|
|
572
|
-
child.on('close', (code) => {
|
|
573
|
-
if (code !== 0) {
|
|
574
|
-
reject(new Error(`idb failed (code ${code}): ${stderr.trim()}`));
|
|
575
|
-
} else {
|
|
576
|
-
resolve(stdout);
|
|
577
|
-
}
|
|
578
|
-
});
|
|
579
|
-
});
|
|
574
|
+
const output = await measurement.physicalRead(async () => {
|
|
575
|
+
const result = await execCmd(getIdbCmd(), args, { timeout: remainingBudget() })
|
|
576
|
+
if (result.stderr === 'ACTION_TIMEOUT') throw new Error('ACTION_TIMEOUT')
|
|
577
|
+
if (result.exitCode !== 0) throw new Error(`idb failed (code ${result.exitCode}): ${result.stderr}`)
|
|
578
|
+
if (!result.stdout.trim()) throw new Error('Empty idb UI tree output')
|
|
579
|
+
return result.stdout
|
|
580
|
+
})
|
|
580
581
|
|
|
581
582
|
if (output && output.trim().length > 0) {
|
|
582
|
-
jsonContent = JSON.parse(output);
|
|
583
|
+
jsonContent = await measurement.timed('parse_ms', async () => JSON.parse(output));
|
|
583
584
|
break; // Success
|
|
584
585
|
}
|
|
585
586
|
} catch (e) {
|
|
@@ -589,6 +590,8 @@ export class iOSObserve {
|
|
|
589
590
|
|
|
590
591
|
if (attempts === maxAttempts) {
|
|
591
592
|
const snapshotMetadata = deriveSnapshotMetadata(deviceKey, null, 'ui_tree')
|
|
593
|
+
measurement.setOutcome('unavailable')
|
|
594
|
+
measurement.finish()
|
|
592
595
|
return {
|
|
593
596
|
device,
|
|
594
597
|
screen: "",
|
|
@@ -601,14 +604,15 @@ export class iOSObserve {
|
|
|
601
604
|
}
|
|
602
605
|
|
|
603
606
|
try {
|
|
604
|
-
const elements: UIElement[] =
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
traverseIDBNode(node,
|
|
607
|
+
const elements: UIElement[] = await measurement.timed('conversion_ms', async () => {
|
|
608
|
+
const parsed: UIElement[] = []
|
|
609
|
+
if (Array.isArray(jsonContent)) {
|
|
610
|
+
for (const node of jsonContent) traverseIDBNode(node, parsed)
|
|
611
|
+
} else {
|
|
612
|
+
traverseIDBNode(jsonContent, parsed)
|
|
608
613
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
}
|
|
614
|
+
return parsed
|
|
615
|
+
})
|
|
612
616
|
|
|
613
617
|
let width = 0;
|
|
614
618
|
let height = 0;
|
|
@@ -624,15 +628,20 @@ export class iOSObserve {
|
|
|
624
628
|
elements
|
|
625
629
|
}, 'ui_tree')
|
|
626
630
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
631
|
+
measurement.setOutcome('observed')
|
|
632
|
+
const response = measurement.bindObservation({
|
|
633
|
+
device,
|
|
634
|
+
screen: "",
|
|
635
|
+
resolution: { width, height },
|
|
636
|
+
elements,
|
|
637
|
+
...snapshotMetadata
|
|
638
|
+
})
|
|
639
|
+
measurement.finish()
|
|
640
|
+
return response
|
|
634
641
|
} catch (e) {
|
|
635
642
|
const snapshotMetadata = deriveSnapshotMetadata(deviceKey, null, 'ui_tree')
|
|
643
|
+
measurement.setOutcome('unavailable')
|
|
644
|
+
measurement.finish()
|
|
636
645
|
return {
|
|
637
646
|
device,
|
|
638
647
|
screen: "",
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
3
|
+
|
|
4
|
+
export type ObservationPurpose = 'target' | 'verification' | 'assertion' | 'debug' | 'unspecified'
|
|
5
|
+
|
|
6
|
+
interface ObservationContext {
|
|
7
|
+
stepId?: string
|
|
8
|
+
purpose: ObservationPurpose
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const context = new AsyncLocalStorage<ObservationContext>()
|
|
12
|
+
const observationIds = new WeakMap<object, string>()
|
|
13
|
+
|
|
14
|
+
export function withObservationContext<T>(value: Partial<ObservationContext>, operation: () => Promise<T>): Promise<T> {
|
|
15
|
+
if (process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS !== '1') return operation()
|
|
16
|
+
return context.run({ purpose: value.purpose ?? context.getStore()?.purpose ?? 'unspecified', stepId: value.stepId ?? context.getStore()?.stepId }, operation)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function recordObservationEvent(event: Record<string, unknown>) {
|
|
20
|
+
if (process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS !== '1') return
|
|
21
|
+
const current = context.getStore()
|
|
22
|
+
process.stderr.write(`${JSON.stringify({
|
|
23
|
+
event: 'ui_tree_metric',
|
|
24
|
+
host_monotonic_ns: process.hrtime.bigint().toString(),
|
|
25
|
+
type: 'action_observation',
|
|
26
|
+
step_id: current?.stepId,
|
|
27
|
+
purpose: current?.purpose ?? 'unspecified',
|
|
28
|
+
...event
|
|
29
|
+
})}\n`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function recordObservationConsumer(observation: unknown, purpose: ObservationPurpose, stepId?: string) {
|
|
33
|
+
if (process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS !== '1' || !observation || typeof observation !== 'object') return
|
|
34
|
+
const acquisitionId = observationIds.get(observation)
|
|
35
|
+
if (!acquisitionId) return
|
|
36
|
+
const current = context.getStore()
|
|
37
|
+
process.stderr.write(`${JSON.stringify({
|
|
38
|
+
event: 'ui_tree_metric',
|
|
39
|
+
host_monotonic_ns: process.hrtime.bigint().toString(),
|
|
40
|
+
type: 'observation_consumer',
|
|
41
|
+
acquisition_id: acquisitionId,
|
|
42
|
+
consumer_step_id: stepId ?? current?.stepId,
|
|
43
|
+
consumer_purpose: purpose,
|
|
44
|
+
reused_observations: 1
|
|
45
|
+
})}\n`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createObservationMeasurement(platform: 'android' | 'ios') {
|
|
49
|
+
const enabled = process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS === '1'
|
|
50
|
+
const startedAt = enabled ? performance.now() : 0
|
|
51
|
+
const id = enabled ? randomUUID() : undefined
|
|
52
|
+
const current = context.getStore()
|
|
53
|
+
const durations: Record<string, number> = {}
|
|
54
|
+
let successfulOutput = false
|
|
55
|
+
let outcome: 'observed' | 'unavailable' = 'unavailable'
|
|
56
|
+
let readAttempt = 0
|
|
57
|
+
|
|
58
|
+
const emit = (event: Record<string, unknown>) => {
|
|
59
|
+
if (!enabled) return
|
|
60
|
+
process.stderr.write(`${JSON.stringify({
|
|
61
|
+
event: 'ui_tree_metric',
|
|
62
|
+
host_monotonic_ns: process.hrtime.bigint().toString(),
|
|
63
|
+
acquisition_id: id,
|
|
64
|
+
platform,
|
|
65
|
+
step_id: current?.stepId,
|
|
66
|
+
purpose: current?.purpose ?? 'unspecified',
|
|
67
|
+
...event
|
|
68
|
+
})}\n`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
id,
|
|
73
|
+
bindObservation<T extends object>(observation: T): T {
|
|
74
|
+
if (enabled && id) observationIds.set(observation, id)
|
|
75
|
+
return observation
|
|
76
|
+
},
|
|
77
|
+
timed: async <T>(stage: string, operation: () => Promise<T>): Promise<T> => {
|
|
78
|
+
if (!enabled) return operation()
|
|
79
|
+
const start = performance.now()
|
|
80
|
+
let success = true
|
|
81
|
+
try {
|
|
82
|
+
return await operation()
|
|
83
|
+
} catch (error) {
|
|
84
|
+
success = false
|
|
85
|
+
throw error
|
|
86
|
+
} finally {
|
|
87
|
+
const elapsed = performance.now() - start
|
|
88
|
+
durations[stage] = (durations[stage] ?? 0) + elapsed
|
|
89
|
+
emit({ type: 'stage', stage, attempt_index: stage === 'parse_ms' ? readAttempt : undefined, elapsed_ms: elapsed, success })
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
physicalRead: async <T>(operation: () => Promise<T>): Promise<T> => {
|
|
93
|
+
if (!enabled) return operation()
|
|
94
|
+
const start = performance.now()
|
|
95
|
+
readAttempt++
|
|
96
|
+
emit({ type: 'physical_read_started', attempt_index: readAttempt })
|
|
97
|
+
try {
|
|
98
|
+
const result = await operation()
|
|
99
|
+
successfulOutput = true
|
|
100
|
+
emit({ type: 'physical_read_completed', attempt_index: readAttempt, elapsed_ms: performance.now() - start, success: true })
|
|
101
|
+
return result
|
|
102
|
+
} catch (error) {
|
|
103
|
+
emit({ type: 'physical_read_completed', attempt_index: readAttempt, elapsed_ms: performance.now() - start, success: false })
|
|
104
|
+
throw error
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
setOutcome(value: 'observed' | 'unavailable') { if (enabled) outcome = value },
|
|
108
|
+
finish() {
|
|
109
|
+
if (!enabled) return
|
|
110
|
+
emit({
|
|
111
|
+
type: 'observation_consumer',
|
|
112
|
+
consumer_step_id: current?.stepId,
|
|
113
|
+
consumer_purpose: current?.purpose ?? 'unspecified',
|
|
114
|
+
reused_observations: 0
|
|
115
|
+
})
|
|
116
|
+
emit({
|
|
117
|
+
type: 'acquisition_completed',
|
|
118
|
+
elapsed_ms: performance.now() - startedAt,
|
|
119
|
+
successful_output: successfulOutput,
|
|
120
|
+
outcome,
|
|
121
|
+
durations_ms: durations
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -14,6 +14,7 @@ import { ToolsObserve } from '../observe/index.js'
|
|
|
14
14
|
import { classifyActionOutcome } from '../interact/classify.js'
|
|
15
15
|
import { ToolsNetwork } from '../network/index.js'
|
|
16
16
|
import { getSystemStatus } from '../system/index.js'
|
|
17
|
+
import { recordObservationConsumer, recordObservationEvent, withObservationContext } from '../observe/performance-metrics.js'
|
|
17
18
|
import {
|
|
18
19
|
buildActionExecutionResult,
|
|
19
20
|
captureActionFingerprint,
|
|
@@ -102,12 +103,27 @@ function actionHandler(type: string, execute: ToolHandler): ToolHandler {
|
|
|
102
103
|
if (!result.success && typeof result.retryable !== 'boolean') result.retryable = !dispatchStarted
|
|
103
104
|
result.action_id ??= randomUUID()
|
|
104
105
|
result.action_type ??= type
|
|
106
|
+
recordObservationEvent({
|
|
107
|
+
step_id: args._stepId as string | undefined,
|
|
108
|
+
action_type: type,
|
|
109
|
+
dispatch_ms: timing.dispatch_ms,
|
|
110
|
+
verification_mode: controls.verificationMode,
|
|
111
|
+
dispatch_started: dispatchStarted
|
|
112
|
+
})
|
|
105
113
|
let delta: unknown
|
|
106
114
|
let observationAvailable = false
|
|
107
115
|
if (result.success && controls.verificationMode === 'light') {
|
|
108
|
-
const
|
|
109
|
-
|
|
116
|
+
const verificationStarted = performance.now()
|
|
117
|
+
const tree = await withObservationContext({ stepId: args._stepId as string | undefined, purpose: 'verification' }, () =>
|
|
118
|
+
measure('post_observation_ms', () => runWithBudget(controls.verificationTimeoutMs, () =>
|
|
119
|
+
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
120
|
observationAvailable = !!tree && !tree.error
|
|
121
|
+
recordObservationEvent({
|
|
122
|
+
verification_budget_ms: controls.verificationTimeoutMs,
|
|
123
|
+
verification_elapsed_ms: performance.now() - verificationStarted,
|
|
124
|
+
verification_outcome: observationAvailable ? 'observed' : 'unavailable',
|
|
125
|
+
verification_budget_expired: !observationAvailable && performance.now() - verificationStarted >= controls.verificationTimeoutMs
|
|
126
|
+
})
|
|
111
127
|
if (observationAvailable && tree.device) delta = ToolsObserve.getLatestStateDelta(tree.device.platform, tree.device.id) ?? undefined
|
|
112
128
|
}
|
|
113
129
|
const compact = compactAction(result, controls.verificationMode, dispatchStarted, delta, performance.now() - started)
|
|
@@ -321,7 +337,9 @@ async function handleGetUITree(args: ToolCallArgs) {
|
|
|
321
337
|
const deviceId = getStringArg(args, 'deviceId')
|
|
322
338
|
const responseMode = (getStringArg(args, 'responseMode') as ResponseMode | undefined) ?? 'debug'
|
|
323
339
|
const sinceSnapshotRevision = getNumberArg(args, 'sinceSnapshotRevision')
|
|
324
|
-
const
|
|
340
|
+
const metricsStepId = process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS === '1' ? getStringArg(args, '_metricsStepId') : undefined
|
|
341
|
+
const res = await withObservationContext({ stepId: metricsStepId, purpose: metricsStepId?.includes('post-response') ? 'assertion' : 'debug' }, () =>
|
|
342
|
+
ToolsObserve.getUITreeHandler({ platform, deviceId, responseMode, sinceSnapshotRevision }))
|
|
325
343
|
return wrapResponse(res)
|
|
326
344
|
}
|
|
327
345
|
|
|
@@ -441,7 +459,9 @@ async function handleFindElement(args: ToolCallArgs) {
|
|
|
441
459
|
const timeoutMs = getNumberArg(args, 'timeoutMs') ?? 3000
|
|
442
460
|
const platform = getStringArg(args, 'platform') as PlatformArg | undefined
|
|
443
461
|
const deviceId = getStringArg(args, 'deviceId')
|
|
444
|
-
const
|
|
462
|
+
const targetStarted = performance.now()
|
|
463
|
+
const res = await withObservationContext({ purpose: 'target' }, () => ToolsInteract.findElementHandler({ query, exact, timeoutMs, platform, deviceId }))
|
|
464
|
+
recordObservationEvent({ target_resolution_ms: performance.now() - targetStarted, target_resolution_outcome: res.found ? 'found' : 'not_found', operation: 'find_element' })
|
|
445
465
|
return wrapResponse(res)
|
|
446
466
|
}
|
|
447
467
|
|
|
@@ -477,12 +497,16 @@ async function executeTapElement(args: ToolCallArgs) {
|
|
|
477
497
|
let freshTree: any
|
|
478
498
|
if (selector) {
|
|
479
499
|
const waitFor = getObjectArg<WaitForUiMatchArg>(args, 'waitFor') as any
|
|
480
|
-
const
|
|
500
|
+
const targetStarted = performance.now()
|
|
501
|
+
const resolution = await withObservationContext({ stepId: args._stepId as string | undefined, purpose: 'target' }, () =>
|
|
502
|
+
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
|
|
503
|
+
recordObservationEvent({ step_id: args._stepId as string | undefined, purpose: 'target', target_resolution_ms: performance.now() - targetStarted, target_resolution_outcome: resolution.status })
|
|
481
504
|
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
505
|
resolvedId = resolution.element.elementId
|
|
483
506
|
freshTree = resolution._tree
|
|
484
507
|
}
|
|
485
508
|
ToolsNetwork.notifyActionStart()
|
|
509
|
+
if (freshTree) recordObservationConsumer(freshTree, 'target', args._stepId as string | undefined)
|
|
486
510
|
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
|
|
487
511
|
return wrapResponse(res)
|
|
488
512
|
}
|
|
@@ -549,12 +573,12 @@ async function handleRunJourney(args: ToolCallArgs) {
|
|
|
549
573
|
}
|
|
550
574
|
try {
|
|
551
575
|
if (step.type === 'tap') {
|
|
552
|
-
result = payloadOf(await handleTapElement({ ...defaults, ...step, platform, deviceId, responseMode, _journey: true, verificationMode: lightCoveredByAssertion ? 'none' : requestedVerification }))
|
|
576
|
+
result = payloadOf(await handleTapElement({ ...defaults, ...step, platform, deviceId, responseMode, _journey: true, _stepId: step.id, verificationMode: lightCoveredByAssertion ? 'none' : requestedVerification }))
|
|
553
577
|
} 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 })
|
|
578
|
+
result = await withObservationContext({ stepId: step.id, purpose: 'target' }, () => 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
579
|
result = { success: result.status === 'success', ...result }
|
|
556
580
|
} else if (step.type === 'assert') {
|
|
557
|
-
result = await runWithBudget((step.timeoutMs as number | undefined) ?? 5000, async () => {
|
|
581
|
+
result = await withObservationContext({ stepId: step.id, purpose: 'assertion' }, () => runWithBudget((step.timeoutMs as number | undefined) ?? 5000, async () => {
|
|
558
582
|
const assertion = step.assertion as any
|
|
559
583
|
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
584
|
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 })
|
|
@@ -562,9 +586,9 @@ async function handleRunJourney(args: ToolCallArgs) {
|
|
|
562
586
|
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
587
|
else result = { success: false, failure_code: 'INVALID_ASSERTION' }
|
|
564
588
|
return result
|
|
565
|
-
})
|
|
589
|
+
}))
|
|
566
590
|
} 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' }))
|
|
591
|
+
result = payloadOf(await handleStartApp({ ...defaults, platform, deviceId, appId: step.appId, responseMode, _journey: true, _stepId: step.id, verificationMode: step.verificationMode ?? defaults?.verificationMode ?? 'full' }))
|
|
568
592
|
} else {
|
|
569
593
|
result = { success: false, failure_code: 'UNSUPPORTED_JOURNEY_STEP' }
|
|
570
594
|
}
|
package/src/server-core.ts
CHANGED
package/src/utils/exec.ts
CHANGED
|
@@ -14,22 +14,28 @@ export async function execCmd(cmd: string, args: string[], opts: ExecOptions = {
|
|
|
14
14
|
if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString() })
|
|
15
15
|
|
|
16
16
|
let timedOut = false
|
|
17
|
+
let forceKillTimer: NodeJS.Timeout | null = null
|
|
17
18
|
const timer = timeout && timeout > 0 ? setTimeout(() => {
|
|
18
19
|
timedOut = true
|
|
19
20
|
try { child.kill() } catch { }
|
|
20
|
-
|
|
21
|
+
forceKillTimer = setTimeout(() => {
|
|
22
|
+
try { child.kill('SIGKILL') } catch { }
|
|
23
|
+
}, 1000)
|
|
24
|
+
forceKillTimer.unref()
|
|
21
25
|
}, timeout) : null
|
|
22
26
|
|
|
23
27
|
child.on('close', (code) => {
|
|
24
28
|
if (timer) clearTimeout(timer)
|
|
25
|
-
if (
|
|
26
|
-
resolve(
|
|
29
|
+
if (forceKillTimer) clearTimeout(forceKillTimer)
|
|
30
|
+
resolve(timedOut
|
|
31
|
+
? { exitCode: null, stdout: stdout.trim(), stderr: 'ACTION_TIMEOUT' }
|
|
32
|
+
: { exitCode: code, stdout: stdout.trim(), stderr: stderr.trim() })
|
|
27
33
|
})
|
|
28
34
|
|
|
29
35
|
child.on('error', (err) => {
|
|
30
36
|
if (timer) clearTimeout(timer)
|
|
37
|
+
if (forceKillTimer) clearTimeout(forceKillTimer)
|
|
31
38
|
reject(err)
|
|
32
39
|
})
|
|
33
40
|
})
|
|
34
41
|
}
|
|
35
|
-
|