mobile-debug-mcp 0.24.0 → 0.24.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/dist/interact/index.js +120 -15
- package/dist/manage/android.js +51 -8
- package/dist/manage/ios.js +27 -3
- package/dist/server/common.js +4 -2
- package/dist/server/tool-handlers.js +19 -2
- package/dist/system/index.js +53 -2
- package/docs/CHANGELOG.md +8 -0
- package/docs/tools/interact.md +73 -7
- package/docs/tools/manage.md +16 -1
- package/docs/tools/system.md +23 -1
- package/package.json +1 -1
- package/src/interact/index.ts +121 -15
- package/src/manage/android.ts +51 -7
- package/src/manage/ios.ts +27 -3
- package/src/server/common.ts +8 -2
- package/src/server/tool-handlers.ts +19 -2
- package/src/system/index.ts +57 -2
- package/src/types.ts +36 -0
- package/test/unit/interact/expect_tools.test.ts +29 -2
- package/test/unit/interact/wait_for_screen_change.test.ts +7 -3
- package/test/unit/interact/wait_for_ui_selector_matching.test.ts +19 -0
- package/test/unit/manage/install.test.ts +94 -1
- package/test/unit/server/response_shapes.test.ts +20 -3
- package/test/unit/system/get_system_status.test.ts +2 -0
- package/test/unit/system/system_status.test.ts +6 -0
package/docs/tools/interact.md
CHANGED
|
@@ -111,13 +111,26 @@ Input example:
|
|
|
111
111
|
Success response example:
|
|
112
112
|
|
|
113
113
|
```json
|
|
114
|
-
{
|
|
114
|
+
{
|
|
115
|
+
"success": true,
|
|
116
|
+
"previousFingerprint": "<old-hex-fingerprint>",
|
|
117
|
+
"newFingerprint": "<hex-fingerprint>",
|
|
118
|
+
"elapsedMs": 420,
|
|
119
|
+
"observed_screen": { "fingerprint": "<hex-fingerprint>", "activity": "MainActivity" }
|
|
120
|
+
}
|
|
115
121
|
```
|
|
116
122
|
|
|
117
123
|
Failure (timeout) example:
|
|
118
124
|
|
|
119
125
|
```json
|
|
120
|
-
{
|
|
126
|
+
{
|
|
127
|
+
"success": false,
|
|
128
|
+
"reason": "timeout",
|
|
129
|
+
"previousFingerprint": "<old-hex-fingerprint>",
|
|
130
|
+
"lastFingerprint": "<hex-fingerprint>",
|
|
131
|
+
"elapsedMs": 5000,
|
|
132
|
+
"observed_screen": { "fingerprint": "<hex-fingerprint>", "activity": "HomeActivity" }
|
|
133
|
+
}
|
|
121
134
|
```
|
|
122
135
|
|
|
123
136
|
Notes:
|
|
@@ -214,7 +227,26 @@ Success response:
|
|
|
214
227
|
"index": 8,
|
|
215
228
|
"elementId": "el_..."
|
|
216
229
|
},
|
|
217
|
-
"metrics": { "latency_ms": 120, "poll_count": 1, "attempts": 1 }
|
|
230
|
+
"metrics": { "latency_ms": 120, "poll_count": 1, "attempts": 1 },
|
|
231
|
+
"requested": {
|
|
232
|
+
"selector": { "text": "Generate Session", "contains": false },
|
|
233
|
+
"condition": "clickable",
|
|
234
|
+
"match": { "index": 0 }
|
|
235
|
+
},
|
|
236
|
+
"observed": {
|
|
237
|
+
"matched_count": 1,
|
|
238
|
+
"condition_satisfied": true,
|
|
239
|
+
"selected_index": 8,
|
|
240
|
+
"last_matched_element": {
|
|
241
|
+
"text": "Generate Session",
|
|
242
|
+
"resource_id": null,
|
|
243
|
+
"accessibility_id": null,
|
|
244
|
+
"class": "android.widget.TextView",
|
|
245
|
+
"bounds": [471, 1098, 809, 1158],
|
|
246
|
+
"index": 8,
|
|
247
|
+
"elementId": "el_..."
|
|
248
|
+
}
|
|
249
|
+
}
|
|
218
250
|
}
|
|
219
251
|
```
|
|
220
252
|
|
|
@@ -223,8 +255,27 @@ Timeout response:
|
|
|
223
255
|
```json
|
|
224
256
|
{
|
|
225
257
|
"status": "timeout",
|
|
226
|
-
"error": { "code": "ELEMENT_NOT_FOUND", "message": "Condition visible not satisfied within timeout" },
|
|
227
|
-
"metrics": { "latency_ms": 5000, "poll_count": 17, "attempts": 1 }
|
|
258
|
+
"error": { "code": "ELEMENT_NOT_FOUND", "message": "Condition visible not satisfied within timeout; observed 1 match(es)" },
|
|
259
|
+
"metrics": { "latency_ms": 5000, "poll_count": 17, "attempts": 1 },
|
|
260
|
+
"requested": {
|
|
261
|
+
"selector": { "text": "Generate Session", "contains": false },
|
|
262
|
+
"condition": "visible",
|
|
263
|
+
"match": { "index": 0 }
|
|
264
|
+
},
|
|
265
|
+
"observed": {
|
|
266
|
+
"matched_count": 1,
|
|
267
|
+
"condition_satisfied": false,
|
|
268
|
+
"selected_index": 8,
|
|
269
|
+
"last_matched_element": {
|
|
270
|
+
"text": "Generate Session",
|
|
271
|
+
"resource_id": null,
|
|
272
|
+
"accessibility_id": null,
|
|
273
|
+
"class": "android.widget.TextView",
|
|
274
|
+
"bounds": [471, 1098, 809, 1158],
|
|
275
|
+
"index": 8,
|
|
276
|
+
"elementId": "el_..."
|
|
277
|
+
}
|
|
278
|
+
}
|
|
228
279
|
}
|
|
229
280
|
```
|
|
230
281
|
|
|
@@ -232,6 +283,7 @@ Notes:
|
|
|
232
283
|
|
|
233
284
|
- Use `wait_for_ui` to get a stable `elementId` for `tap_element`.
|
|
234
285
|
- Use it before an action when the target element or timing is uncertain.
|
|
286
|
+
- Use `requested` and `observed` to see exactly what condition was checked and what the last poll actually found.
|
|
235
287
|
- If the expected outcome is known after the action, follow with `expect_*`.
|
|
236
288
|
|
|
237
289
|
---
|
|
@@ -330,7 +382,12 @@ Response:
|
|
|
330
382
|
"success": true,
|
|
331
383
|
"observed_screen": { "fingerprint": "<actual-fingerprint>", "screen": "com.example.app.MainActivity" },
|
|
332
384
|
"expected_screen": { "fingerprint": "<expected-fingerprint>", "screen": null },
|
|
333
|
-
"confidence": 1
|
|
385
|
+
"confidence": 1,
|
|
386
|
+
"comparison": {
|
|
387
|
+
"basis": "fingerprint",
|
|
388
|
+
"matched": true,
|
|
389
|
+
"reason": "observed fingerprint matches expected fingerprint <expected-fingerprint>"
|
|
390
|
+
}
|
|
334
391
|
}
|
|
335
392
|
```
|
|
336
393
|
|
|
@@ -367,6 +424,7 @@ Response:
|
|
|
367
424
|
"success": true,
|
|
368
425
|
"selector": { "text": "Play session" },
|
|
369
426
|
"element_id": "el_123",
|
|
427
|
+
"expected_condition": "visible",
|
|
370
428
|
"element": {
|
|
371
429
|
"elementId": "el_123",
|
|
372
430
|
"text": "Play session",
|
|
@@ -375,7 +433,14 @@ Response:
|
|
|
375
433
|
"class": "android.widget.TextView",
|
|
376
434
|
"bounds": [519, 1770, 762, 1830],
|
|
377
435
|
"index": 11
|
|
378
|
-
}
|
|
436
|
+
},
|
|
437
|
+
"observed": {
|
|
438
|
+
"status": "success",
|
|
439
|
+
"matched_count": 1,
|
|
440
|
+
"condition_satisfied": true,
|
|
441
|
+
"selected_index": 11
|
|
442
|
+
},
|
|
443
|
+
"reason": "selector is visible"
|
|
379
444
|
}
|
|
380
445
|
```
|
|
381
446
|
|
|
@@ -384,4 +449,5 @@ Notes:
|
|
|
384
449
|
- Primary and authoritative verification tool for expected element visibility.
|
|
385
450
|
- `selector` is the primary input; `element_id` is optional context only.
|
|
386
451
|
- The tool resolves the selector internally when needed.
|
|
452
|
+
- On failure, `reason` and `observed` tell you whether the selector was missing entirely or present but not yet visible.
|
|
387
453
|
- Use when the screen should remain on the same destination but a specific element should appear or become visible.
|
package/docs/tools/manage.md
CHANGED
|
@@ -123,10 +123,23 @@ start_app response example:
|
|
|
123
123
|
"action_id": "start_app_1710000000000_1",
|
|
124
124
|
"timestamp": 1710000000000,
|
|
125
125
|
"action_type": "start_app",
|
|
126
|
+
"device": { "platform": "android", "id": "emulator-5554", "osVersion": "14", "model": "Pixel", "simulator": true },
|
|
126
127
|
"target": { "selector": { "appId": "com.example.app" }, "resolved": null },
|
|
127
128
|
"success": true,
|
|
128
129
|
"ui_fingerprint_before": "fp_before",
|
|
129
|
-
"ui_fingerprint_after": "fp_after"
|
|
130
|
+
"ui_fingerprint_after": "fp_after",
|
|
131
|
+
"details": {
|
|
132
|
+
"launch_time_ms": 1000,
|
|
133
|
+
"output": "Events injected: 1",
|
|
134
|
+
"device_id": "emulator-5554",
|
|
135
|
+
"observed_app": {
|
|
136
|
+
"appId": "com.example.app",
|
|
137
|
+
"package": "com.example.app",
|
|
138
|
+
"activity": "com.example.app.MainActivity",
|
|
139
|
+
"screen": "MainActivity",
|
|
140
|
+
"matchedTarget": true
|
|
141
|
+
}
|
|
142
|
+
}
|
|
130
143
|
}
|
|
131
144
|
```
|
|
132
145
|
|
|
@@ -137,5 +150,7 @@ terminate_app and reset_app_data return operation-specific lifecycle results ins
|
|
|
137
150
|
Notes:
|
|
138
151
|
|
|
139
152
|
- `start_app` and `restart_app` report execution success, not outcome correctness.
|
|
153
|
+
- Use `details.observed_app` as the quick decision signal for what the tool actually saw after launch.
|
|
154
|
+
- Android launch feedback usually includes package/activity matching; iOS launch feedback includes launch output and PID when available.
|
|
140
155
|
- When the landing screen is known, use `expect_screen` as the final verification step.
|
|
141
156
|
- If launch timing is uncertain, insert `wait_for_screen_change` before `expect_screen`.
|
package/docs/tools/system.md
CHANGED
|
@@ -16,6 +16,7 @@ Response (example):
|
|
|
16
16
|
```json
|
|
17
17
|
{
|
|
18
18
|
"success": true,
|
|
19
|
+
"status": "ready",
|
|
19
20
|
"adbAvailable": true,
|
|
20
21
|
"adbVersion": "8.1.0",
|
|
21
22
|
"devices": 1,
|
|
@@ -25,7 +26,26 @@ Response (example):
|
|
|
25
26
|
"issues": [],
|
|
26
27
|
"appInstalled": true,
|
|
27
28
|
"iosAvailable": true,
|
|
28
|
-
"iosDevices": 1
|
|
29
|
+
"iosDevices": 1,
|
|
30
|
+
"summary": {
|
|
31
|
+
"overall": "ready",
|
|
32
|
+
"android": {
|
|
33
|
+
"ready": true,
|
|
34
|
+
"summary": "1 Android device(s) connected; log access available",
|
|
35
|
+
"blockers": []
|
|
36
|
+
},
|
|
37
|
+
"ios": {
|
|
38
|
+
"ready": true,
|
|
39
|
+
"summary": "1 iOS simulator(s) booted",
|
|
40
|
+
"blockers": []
|
|
41
|
+
},
|
|
42
|
+
"gradle": {
|
|
43
|
+
"ready": true,
|
|
44
|
+
"summary": "No explicit Gradle JDK override detected",
|
|
45
|
+
"blockers": [],
|
|
46
|
+
"suggestedFixes": []
|
|
47
|
+
}
|
|
48
|
+
}
|
|
29
49
|
}
|
|
30
50
|
```
|
|
31
51
|
|
|
@@ -39,6 +59,8 @@ Checks performed (fast, best-effort):
|
|
|
39
59
|
|
|
40
60
|
Behavior notes:
|
|
41
61
|
- Always returns structured JSON and never throws; any failures are surfaced in the `issues` array.
|
|
62
|
+
- `status` gives a quick overall gate: `ready`, `degraded`, or `blocked`.
|
|
63
|
+
- `summary.android`, `summary.ios`, and `summary.gradle` provide the fastest path to the actual blocker category.
|
|
42
64
|
- Designed to be fast (<~1s probes where possible); startup callers may prefer a `fastMode` variant that only checks existence.
|
|
43
65
|
- Useful to call at the start of an agent session to gate subsequent actions.
|
|
44
66
|
|
package/package.json
CHANGED
package/src/interact/index.ts
CHANGED
|
@@ -490,6 +490,12 @@ export class ToolsInteract {
|
|
|
490
490
|
|
|
491
491
|
static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId }: { selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }, condition?: 'exists'|'not_exists'|'visible'|'clickable', timeout_ms?: number, poll_interval_ms?: number, match?: { index?: number }, retry?: { max_attempts?: number, backoff_ms?: number }, platform?: 'android'|'ios', deviceId?: string }) {
|
|
492
492
|
const overallStart = Date.now()
|
|
493
|
+
const requestedIndex = typeof match?.index === 'number' ? match.index : null
|
|
494
|
+
const requested = {
|
|
495
|
+
selector: selector ?? {},
|
|
496
|
+
condition,
|
|
497
|
+
match: requestedIndex === null ? null : { index: requestedIndex }
|
|
498
|
+
}
|
|
493
499
|
|
|
494
500
|
// Validate selector: require at least one non-empty field (text, resource_id, or accessibility_id)
|
|
495
501
|
const hasText = typeof selector?.text === 'string' && selector.text.trim().length > 0;
|
|
@@ -503,18 +509,20 @@ export class ToolsInteract {
|
|
|
503
509
|
code: 'INVALID_SELECTOR',
|
|
504
510
|
message: 'Selector must include at least one non-empty field: text, resource_id, or accessibility_id'
|
|
505
511
|
},
|
|
506
|
-
metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 }
|
|
512
|
+
metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 },
|
|
513
|
+
requested,
|
|
514
|
+
observed: { matched_count: 0, condition_satisfied: false, selected_index: null, last_matched_element: null }
|
|
507
515
|
};
|
|
508
516
|
}
|
|
509
517
|
|
|
510
518
|
// Validate condition
|
|
511
519
|
if (!['exists','not_exists','visible','clickable'].includes(condition)) {
|
|
512
|
-
return { status: 'timeout', error: { code: 'INVALID_CONDITION', message: `Unsupported condition: ${condition}` }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } }
|
|
520
|
+
return { status: 'timeout', error: { code: 'INVALID_CONDITION', message: `Unsupported condition: ${condition}` }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 }, requested, observed: { matched_count: 0, condition_satisfied: false, selected_index: null, last_matched_element: null } }
|
|
513
521
|
}
|
|
514
522
|
|
|
515
523
|
// Platform check
|
|
516
524
|
if (platform && !['android','ios'].includes(platform)) {
|
|
517
|
-
return { status: 'timeout', error: { code: 'PLATFORM_NOT_SUPPORTED', message: `Unsupported platform: ${platform}` }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } }
|
|
525
|
+
return { status: 'timeout', error: { code: 'PLATFORM_NOT_SUPPORTED', message: `Unsupported platform: ${platform}` }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 }, requested, observed: { matched_count: 0, condition_satisfied: false, selected_index: null, last_matched_element: null } }
|
|
518
526
|
}
|
|
519
527
|
|
|
520
528
|
const effectivePoll = Math.max(50, Math.min(poll_interval_ms || 300, 2000))
|
|
@@ -523,6 +531,9 @@ export class ToolsInteract {
|
|
|
523
531
|
|
|
524
532
|
let attempts = 0
|
|
525
533
|
let totalPollCount = 0
|
|
534
|
+
let lastMatchedCount = 0
|
|
535
|
+
let lastMatchedElement: ActionTargetResolved | null = null
|
|
536
|
+
let lastConditionSatisfied = false
|
|
526
537
|
|
|
527
538
|
// Precompute normalized selector values and helpers (constant across polls)
|
|
528
539
|
const normalize = ToolsInteract._normalize
|
|
@@ -623,19 +634,29 @@ export class ToolsInteract {
|
|
|
623
634
|
} else conditionMet = false
|
|
624
635
|
}
|
|
625
636
|
|
|
637
|
+
const resolvedPlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android')
|
|
638
|
+
const resolvedDeviceId = tree?.device?.id || deviceId
|
|
639
|
+
lastMatchedCount = matchedCount
|
|
640
|
+
lastConditionSatisfied = conditionMet
|
|
641
|
+
lastMatchedElement = matchedElement ? ToolsInteract._buildResolvedElement(resolvedPlatform, resolvedDeviceId, matchedElement.el, matchedElement.idx) : null
|
|
642
|
+
|
|
626
643
|
if (conditionMet) {
|
|
627
644
|
const now = Date.now()
|
|
628
645
|
const latency_ms = now - overallStart
|
|
629
|
-
|
|
630
|
-
const resolvedPlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android')
|
|
631
|
-
const resolvedDeviceId = tree?.device?.id || deviceId
|
|
632
|
-
const outEl = matchedElement ? ToolsInteract._buildResolvedElement(resolvedPlatform, resolvedDeviceId, matchedElement.el, matchedElement.idx) : null
|
|
646
|
+
const outEl = lastMatchedElement
|
|
633
647
|
|
|
634
648
|
return {
|
|
635
649
|
status: 'success',
|
|
636
650
|
matched: matchedCount,
|
|
637
651
|
element: outEl,
|
|
638
|
-
metrics: { latency_ms, poll_count: totalPollCount, attempts }
|
|
652
|
+
metrics: { latency_ms, poll_count: totalPollCount, attempts },
|
|
653
|
+
requested,
|
|
654
|
+
observed: {
|
|
655
|
+
matched_count: matchedCount,
|
|
656
|
+
condition_satisfied: true,
|
|
657
|
+
selected_index: outEl?.index ?? null,
|
|
658
|
+
last_matched_element: outEl
|
|
659
|
+
}
|
|
639
660
|
}
|
|
640
661
|
}
|
|
641
662
|
|
|
@@ -656,16 +677,38 @@ export class ToolsInteract {
|
|
|
656
677
|
|
|
657
678
|
// Final failure for this call
|
|
658
679
|
const elapsed = Date.now() - overallStart
|
|
680
|
+
const observed = {
|
|
681
|
+
matched_count: lastMatchedCount,
|
|
682
|
+
condition_satisfied: lastConditionSatisfied,
|
|
683
|
+
selected_index: lastMatchedElement?.index ?? null,
|
|
684
|
+
last_matched_element: lastMatchedElement
|
|
685
|
+
}
|
|
686
|
+
const matchNote = requestedIndex !== null && lastMatchedCount <= requestedIndex
|
|
687
|
+
? ` requested match.index=${requestedIndex} but observed ${lastMatchedCount} match(es)`
|
|
688
|
+
: ` observed ${lastMatchedCount} match(es)`
|
|
659
689
|
return {
|
|
660
690
|
status: 'timeout',
|
|
661
|
-
error: { code: 'ELEMENT_NOT_FOUND', message: `Condition ${condition} not satisfied within timeout` },
|
|
662
|
-
metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts }
|
|
691
|
+
error: { code: 'ELEMENT_NOT_FOUND', message: `Condition ${condition} not satisfied within timeout;${matchNote}` },
|
|
692
|
+
metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts },
|
|
693
|
+
requested,
|
|
694
|
+
observed
|
|
663
695
|
}
|
|
664
696
|
}
|
|
665
697
|
|
|
666
698
|
} catch (err) {
|
|
667
699
|
const elapsed = Date.now() - overallStart
|
|
668
|
-
return {
|
|
700
|
+
return {
|
|
701
|
+
status: 'timeout',
|
|
702
|
+
error: { code: 'INTERNAL_ERROR', message: err instanceof Error ? err.message : String(err) },
|
|
703
|
+
metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts },
|
|
704
|
+
requested,
|
|
705
|
+
observed: {
|
|
706
|
+
matched_count: lastMatchedCount,
|
|
707
|
+
condition_satisfied: false,
|
|
708
|
+
selected_index: lastMatchedElement?.index ?? null,
|
|
709
|
+
last_matched_element: lastMatchedElement
|
|
710
|
+
}
|
|
711
|
+
}
|
|
669
712
|
}
|
|
670
713
|
}
|
|
671
714
|
|
|
@@ -682,11 +725,13 @@ export class ToolsInteract {
|
|
|
682
725
|
static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }: { platform?: 'android' | 'ios', previousFingerprint: string, timeoutMs?: number, pollIntervalMs?: number, deviceId?: string }) {
|
|
683
726
|
const start = Date.now()
|
|
684
727
|
let lastFingerprint: string | null = null
|
|
728
|
+
let lastActivity: string | null = null
|
|
685
729
|
|
|
686
730
|
while (Date.now() - start < timeoutMs) {
|
|
687
731
|
try {
|
|
688
732
|
const res = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
|
|
689
733
|
const fp = res?.fingerprint ?? null
|
|
734
|
+
lastActivity = (res as any)?.activity ?? lastActivity
|
|
690
735
|
if (fp === null || fp === undefined) {
|
|
691
736
|
lastFingerprint = null
|
|
692
737
|
await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
|
|
@@ -701,8 +746,18 @@ export class ToolsInteract {
|
|
|
701
746
|
try {
|
|
702
747
|
const confirmRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
|
|
703
748
|
const confirmFp = confirmRes?.fingerprint ?? null
|
|
749
|
+
lastActivity = (confirmRes as any)?.activity ?? lastActivity
|
|
704
750
|
if (confirmFp === fp) {
|
|
705
|
-
return {
|
|
751
|
+
return {
|
|
752
|
+
success: true,
|
|
753
|
+
previousFingerprint,
|
|
754
|
+
newFingerprint: fp,
|
|
755
|
+
elapsedMs: Date.now() - start,
|
|
756
|
+
observed_screen: {
|
|
757
|
+
fingerprint: fp,
|
|
758
|
+
activity: lastActivity
|
|
759
|
+
}
|
|
760
|
+
}
|
|
706
761
|
}
|
|
707
762
|
lastFingerprint = confirmFp
|
|
708
763
|
continue
|
|
@@ -713,7 +768,17 @@ export class ToolsInteract {
|
|
|
713
768
|
await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
|
|
714
769
|
}
|
|
715
770
|
|
|
716
|
-
return {
|
|
771
|
+
return {
|
|
772
|
+
success: false,
|
|
773
|
+
reason: 'timeout',
|
|
774
|
+
previousFingerprint,
|
|
775
|
+
lastFingerprint,
|
|
776
|
+
elapsedMs: Date.now() - start,
|
|
777
|
+
observed_screen: {
|
|
778
|
+
fingerprint: lastFingerprint,
|
|
779
|
+
activity: lastActivity
|
|
780
|
+
}
|
|
781
|
+
}
|
|
717
782
|
}
|
|
718
783
|
|
|
719
784
|
static async expectScreenHandler({
|
|
@@ -749,13 +814,23 @@ export class ToolsInteract {
|
|
|
749
814
|
}
|
|
750
815
|
|
|
751
816
|
let success = false
|
|
817
|
+
let basis: 'fingerprint' | 'screen' | 'none' = 'none'
|
|
818
|
+
let reason = 'No fingerprint or screen expectation provided'
|
|
752
819
|
if (fingerprint) {
|
|
820
|
+
basis = 'fingerprint'
|
|
753
821
|
success = observedScreen.fingerprint === fingerprint
|
|
822
|
+
reason = success
|
|
823
|
+
? `observed fingerprint matches expected fingerprint ${fingerprint}`
|
|
824
|
+
: `expected fingerprint ${fingerprint} but observed ${observedScreen.fingerprint ?? 'null'}`
|
|
754
825
|
} else if (screen) {
|
|
826
|
+
basis = 'screen'
|
|
755
827
|
const candidates = new Set<string>()
|
|
756
828
|
if (observedScreen.screen) candidates.add(observedScreen.screen)
|
|
757
829
|
if (observedScreenLabel) candidates.add(observedScreenLabel)
|
|
758
830
|
success = candidates.has(screen)
|
|
831
|
+
reason = success
|
|
832
|
+
? `observed screen matches expected screen ${screen}`
|
|
833
|
+
: `expected screen ${screen} but observed ${observedScreenLabel ?? observedScreen.screen ?? 'null'}`
|
|
759
834
|
}
|
|
760
835
|
|
|
761
836
|
return {
|
|
@@ -765,7 +840,12 @@ export class ToolsInteract {
|
|
|
765
840
|
screen: observedScreenLabel
|
|
766
841
|
},
|
|
767
842
|
expected_screen: expectedScreen,
|
|
768
|
-
confidence: success ? 1 : 0
|
|
843
|
+
confidence: success ? 1 : 0,
|
|
844
|
+
comparison: {
|
|
845
|
+
basis,
|
|
846
|
+
matched: success,
|
|
847
|
+
reason
|
|
848
|
+
}
|
|
769
849
|
}
|
|
770
850
|
}
|
|
771
851
|
|
|
@@ -798,6 +878,7 @@ export class ToolsInteract {
|
|
|
798
878
|
success: true,
|
|
799
879
|
selector,
|
|
800
880
|
element_id: result.element.elementId ?? element_id ?? null,
|
|
881
|
+
expected_condition: 'visible',
|
|
801
882
|
element: {
|
|
802
883
|
elementId: result.element.elementId ?? null,
|
|
803
884
|
text: result.element.text ?? null,
|
|
@@ -806,7 +887,23 @@ export class ToolsInteract {
|
|
|
806
887
|
class: result.element.class ?? null,
|
|
807
888
|
bounds: result.element.bounds ?? null,
|
|
808
889
|
index: typeof result.element.index === 'number' ? result.element.index : null
|
|
809
|
-
}
|
|
890
|
+
},
|
|
891
|
+
observed: {
|
|
892
|
+
status: result.status,
|
|
893
|
+
matched_count: typeof result.matched === 'number' ? result.matched : result?.observed?.matched_count ?? null,
|
|
894
|
+
condition_satisfied: true,
|
|
895
|
+
selected_index: typeof result.element.index === 'number' ? result.element.index : null,
|
|
896
|
+
last_matched_element: {
|
|
897
|
+
elementId: result.element.elementId ?? null,
|
|
898
|
+
text: result.element.text ?? null,
|
|
899
|
+
resource_id: result.element.resource_id ?? null,
|
|
900
|
+
accessibility_id: result.element.accessibility_id ?? null,
|
|
901
|
+
class: result.element.class ?? null,
|
|
902
|
+
bounds: result.element.bounds ?? null,
|
|
903
|
+
index: typeof result.element.index === 'number' ? result.element.index : null
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
reason: 'selector is visible'
|
|
810
907
|
}
|
|
811
908
|
}
|
|
812
909
|
|
|
@@ -815,6 +912,15 @@ export class ToolsInteract {
|
|
|
815
912
|
success: false,
|
|
816
913
|
selector,
|
|
817
914
|
element_id: element_id ?? null,
|
|
915
|
+
expected_condition: 'visible',
|
|
916
|
+
observed: {
|
|
917
|
+
status: result?.status,
|
|
918
|
+
matched_count: result?.observed?.matched_count,
|
|
919
|
+
condition_satisfied: result?.observed?.condition_satisfied ?? false,
|
|
920
|
+
selected_index: result?.observed?.selected_index ?? null,
|
|
921
|
+
last_matched_element: result?.observed?.last_matched_element ?? null
|
|
922
|
+
},
|
|
923
|
+
reason: result?.error?.message ?? 'selector is not visible',
|
|
818
924
|
failure_code: errorCode,
|
|
819
925
|
retryable: errorCode === 'TIMEOUT'
|
|
820
926
|
}
|
package/src/manage/android.ts
CHANGED
|
@@ -5,9 +5,14 @@ import { existsSync } from 'fs'
|
|
|
5
5
|
import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk } from '../utils/android/utils.js'
|
|
6
6
|
import { execAdbWithDiagnostics } from '../utils/diagnostics.js'
|
|
7
7
|
import { detectJavaHome } from '../utils/java.js'
|
|
8
|
+
import { AndroidObserve } from '../observe/android.js'
|
|
8
9
|
import { InstallAppResponse, StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse } from '../types.js'
|
|
9
10
|
|
|
10
11
|
export class AndroidManage {
|
|
12
|
+
private isTestOnlyInstallFailure(output: string | undefined): boolean {
|
|
13
|
+
return typeof output === 'string' && output.includes('INSTALL_FAILED_TEST_ONLY')
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
async build(projectPath: string, _variant?: string): Promise<{ artifactPath: string, output?: string } | { error: string }> {
|
|
12
17
|
void _variant
|
|
13
18
|
try {
|
|
@@ -92,6 +97,14 @@ export class AndroidManage {
|
|
|
92
97
|
if (res.code === 0) {
|
|
93
98
|
return { device: deviceInfo, installed: true, output: res.stdout }
|
|
94
99
|
}
|
|
100
|
+
|
|
101
|
+
const installOutput = `${res.stdout}\n${res.stderr}`.trim()
|
|
102
|
+
if (this.isTestOnlyInstallFailure(installOutput)) {
|
|
103
|
+
const retryRes = await spawnAdb(['install', '-r', '-t', apkToInstall], deviceId)
|
|
104
|
+
if (retryRes.code === 0) {
|
|
105
|
+
return { device: deviceInfo, installed: true, output: retryRes.stdout }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
95
108
|
} catch (e) {
|
|
96
109
|
console.debug('[android-run] adb install failed, attempting push+pm fallback:', e instanceof Error ? e.message : String(e))
|
|
97
110
|
}
|
|
@@ -99,9 +112,21 @@ export class AndroidManage {
|
|
|
99
112
|
const basename = path.basename(apkToInstall)
|
|
100
113
|
const remotePath = `/data/local/tmp/${basename}`
|
|
101
114
|
await execAdb(['push', apkToInstall, remotePath], deviceId)
|
|
102
|
-
|
|
103
|
-
try {
|
|
104
|
-
|
|
115
|
+
let finalPmRes = await spawnAdb(['shell', 'pm', 'install', '-r', remotePath], deviceId)
|
|
116
|
+
try {
|
|
117
|
+
if (finalPmRes.code === 0) {
|
|
118
|
+
return { device: deviceInfo, installed: true, output: finalPmRes.stdout }
|
|
119
|
+
}
|
|
120
|
+
if (this.isTestOnlyInstallFailure(`${finalPmRes.stdout}\n${finalPmRes.stderr}`)) {
|
|
121
|
+
finalPmRes = await spawnAdb(['shell', 'pm', 'install', '-r', '-t', remotePath], deviceId)
|
|
122
|
+
if (finalPmRes.code === 0) {
|
|
123
|
+
return { device: deviceInfo, installed: true, output: finalPmRes.stdout }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
throw new Error(finalPmRes.stderr || finalPmRes.stdout || 'pm install failed')
|
|
127
|
+
} finally {
|
|
128
|
+
try { await execAdb(['shell', 'rm', remotePath], deviceId) } catch {}
|
|
129
|
+
}
|
|
105
130
|
} catch (e) {
|
|
106
131
|
// gather diagnostics for attempted adb operations
|
|
107
132
|
const basename = path.basename(apkToInstall)
|
|
@@ -117,8 +142,21 @@ export class AndroidManage {
|
|
|
117
142
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
118
143
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
119
144
|
try {
|
|
120
|
-
await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
|
|
121
|
-
|
|
145
|
+
const output = await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
|
|
146
|
+
const current = await new AndroidObserve().getCurrentScreen(deviceId).catch(() => null)
|
|
147
|
+
return {
|
|
148
|
+
device: deviceInfo,
|
|
149
|
+
appStarted: true,
|
|
150
|
+
launchTimeMs: 1000,
|
|
151
|
+
output,
|
|
152
|
+
observedApp: {
|
|
153
|
+
appId,
|
|
154
|
+
package: current?.package ?? null,
|
|
155
|
+
activity: current?.activity ?? null,
|
|
156
|
+
screen: current?.shortActivity ?? current?.activity ?? null,
|
|
157
|
+
matchedTarget: current ? current.package === appId : null
|
|
158
|
+
}
|
|
159
|
+
}
|
|
122
160
|
} catch (e: unknown) {
|
|
123
161
|
const diag = execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
|
|
124
162
|
return { device: deviceInfo, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
|
|
@@ -138,12 +176,18 @@ export class AndroidManage {
|
|
|
138
176
|
}
|
|
139
177
|
|
|
140
178
|
async restartApp(appId: string, deviceId?: string): Promise<RestartAppResponse> {
|
|
141
|
-
await this.terminateApp(appId, deviceId)
|
|
179
|
+
const terminateResult = await this.terminateApp(appId, deviceId)
|
|
142
180
|
const startResult = await this.startApp(appId, deviceId)
|
|
143
181
|
return {
|
|
144
182
|
device: startResult.device,
|
|
145
183
|
appRestarted: startResult.appStarted,
|
|
146
|
-
launchTimeMs: startResult.launchTimeMs
|
|
184
|
+
launchTimeMs: startResult.launchTimeMs,
|
|
185
|
+
output: startResult.output,
|
|
186
|
+
observedApp: startResult.observedApp,
|
|
187
|
+
terminatedBeforeRestart: terminateResult.appTerminated,
|
|
188
|
+
...(terminateResult.error ? { terminateError: terminateResult.error } : {}),
|
|
189
|
+
...(startResult.error ? { error: startResult.error } : {}),
|
|
190
|
+
...(startResult.diagnostics ? { diagnostics: startResult.diagnostics } : {})
|
|
147
191
|
}
|
|
148
192
|
}
|
|
149
193
|
|
package/src/manage/ios.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { promises as fs } from "fs"
|
|
|
2
2
|
import { spawn, spawnSync } from "child_process"
|
|
3
3
|
import { StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, InstallAppResponse } from "../types.js"
|
|
4
4
|
import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "../utils/ios/utils.js"
|
|
5
|
+
import { iOSObserve } from "../observe/ios.js"
|
|
5
6
|
import path from "path"
|
|
6
7
|
|
|
7
8
|
export class iOSManage {
|
|
@@ -301,7 +302,20 @@ export class iOSManage {
|
|
|
301
302
|
try {
|
|
302
303
|
const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
|
|
303
304
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
304
|
-
|
|
305
|
+
const fingerprint = await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null)
|
|
306
|
+
const pidMatch = result.output.match(/:\s*(\d+)\s*$/)
|
|
307
|
+
return {
|
|
308
|
+
device,
|
|
309
|
+
appStarted: !!result.output,
|
|
310
|
+
launchTimeMs: 1000,
|
|
311
|
+
output: result.output,
|
|
312
|
+
observedApp: {
|
|
313
|
+
appId: bundleId,
|
|
314
|
+
pid: pidMatch ? Number(pidMatch[1]) : null,
|
|
315
|
+
screen: fingerprint?.activity ?? null,
|
|
316
|
+
matchedTarget: null
|
|
317
|
+
}
|
|
318
|
+
}
|
|
305
319
|
} catch (e: unknown) {
|
|
306
320
|
const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId)
|
|
307
321
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
@@ -323,9 +337,19 @@ export class iOSManage {
|
|
|
323
337
|
}
|
|
324
338
|
|
|
325
339
|
async restartApp(bundleId: string, deviceId: string = "booted"): Promise<RestartAppResponse> {
|
|
326
|
-
await this.terminateApp(bundleId, deviceId)
|
|
340
|
+
const terminateResult = await this.terminateApp(bundleId, deviceId)
|
|
327
341
|
const startResult = await this.startApp(bundleId, deviceId)
|
|
328
|
-
return {
|
|
342
|
+
return {
|
|
343
|
+
device: startResult.device,
|
|
344
|
+
appRestarted: startResult.appStarted,
|
|
345
|
+
launchTimeMs: startResult.launchTimeMs,
|
|
346
|
+
output: startResult.output,
|
|
347
|
+
observedApp: startResult.observedApp,
|
|
348
|
+
terminatedBeforeRestart: terminateResult.appTerminated,
|
|
349
|
+
...(terminateResult.error ? { terminateError: terminateResult.error } : {}),
|
|
350
|
+
...(startResult.error ? { error: startResult.error } : {}),
|
|
351
|
+
...(startResult.diagnostics ? { diagnostics: startResult.diagnostics } : {})
|
|
352
|
+
}
|
|
329
353
|
}
|
|
330
354
|
|
|
331
355
|
async resetAppData(bundleId: string, deviceId: string = "booted"): Promise<ResetAppDataResponse> {
|