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/src/server/common.ts
CHANGED
|
@@ -63,26 +63,31 @@ export function inferScrollFailure(message: string | undefined): { failureCode:
|
|
|
63
63
|
|
|
64
64
|
export function buildActionExecutionResult({
|
|
65
65
|
actionType,
|
|
66
|
+
device,
|
|
66
67
|
selector,
|
|
67
68
|
resolved,
|
|
68
69
|
success,
|
|
69
70
|
uiFingerprintBefore,
|
|
70
71
|
uiFingerprintAfter,
|
|
71
|
-
failure
|
|
72
|
+
failure,
|
|
73
|
+
details
|
|
72
74
|
}: {
|
|
73
75
|
actionType: string
|
|
76
|
+
device?: ActionExecutionResult['device']
|
|
74
77
|
selector: Record<string, unknown> | null
|
|
75
78
|
resolved?: Partial<ActionTargetResolved> | null
|
|
76
79
|
success: boolean
|
|
77
80
|
uiFingerprintBefore: string | null
|
|
78
81
|
uiFingerprintAfter: string | null
|
|
79
82
|
failure?: { failureCode: ActionFailureCode; retryable: boolean }
|
|
83
|
+
details?: Record<string, unknown>
|
|
80
84
|
}): ActionExecutionResult {
|
|
81
85
|
const timestamp = Date.now()
|
|
82
86
|
return {
|
|
83
87
|
action_id: nextActionId(actionType, timestamp),
|
|
84
88
|
timestamp,
|
|
85
89
|
action_type: actionType,
|
|
90
|
+
...(device ? { device } : {}),
|
|
86
91
|
target: {
|
|
87
92
|
selector,
|
|
88
93
|
resolved: normalizeResolvedTarget(resolved)
|
|
@@ -90,6 +95,7 @@ export function buildActionExecutionResult({
|
|
|
90
95
|
success,
|
|
91
96
|
...(failure ? { failure_code: failure.failureCode, retryable: failure.retryable } : {}),
|
|
92
97
|
ui_fingerprint_before: uiFingerprintBefore,
|
|
93
|
-
ui_fingerprint_after: uiFingerprintAfter
|
|
98
|
+
ui_fingerprint_after: uiFingerprintAfter,
|
|
99
|
+
...(details ? { details } : {})
|
|
94
100
|
}
|
|
95
101
|
}
|
|
@@ -27,11 +27,19 @@ async function handleStartApp(args: ToolCallArgs) {
|
|
|
27
27
|
const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
|
|
28
28
|
return wrapResponse(buildActionExecutionResult({
|
|
29
29
|
actionType: 'start_app',
|
|
30
|
+
device: res.device,
|
|
30
31
|
selector: { appId },
|
|
31
32
|
success: !!res.appStarted,
|
|
32
33
|
uiFingerprintBefore,
|
|
33
34
|
uiFingerprintAfter,
|
|
34
|
-
failure: res.appStarted ? undefined : inferGenericFailure(
|
|
35
|
+
failure: res.appStarted ? undefined : inferGenericFailure(res.error),
|
|
36
|
+
details: {
|
|
37
|
+
launch_time_ms: res.launchTimeMs,
|
|
38
|
+
...(typeof res.output === 'string' ? { output: res.output } : {}),
|
|
39
|
+
...(res.device ? { device_id: res.device.id } : {}),
|
|
40
|
+
...(typeof res.error === 'string' ? { error: res.error } : {}),
|
|
41
|
+
...(res.observedApp ? { observed_app: res.observedApp } : {})
|
|
42
|
+
}
|
|
35
43
|
}))
|
|
36
44
|
}
|
|
37
45
|
|
|
@@ -50,11 +58,20 @@ async function handleRestartApp(args: ToolCallArgs) {
|
|
|
50
58
|
const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
|
|
51
59
|
return wrapResponse(buildActionExecutionResult({
|
|
52
60
|
actionType: 'restart_app',
|
|
61
|
+
device: res.device,
|
|
53
62
|
selector: { appId },
|
|
54
63
|
success: !!res.appRestarted,
|
|
55
64
|
uiFingerprintBefore,
|
|
56
65
|
uiFingerprintAfter,
|
|
57
|
-
failure: res.appRestarted ? undefined : inferGenericFailure(
|
|
66
|
+
failure: res.appRestarted ? undefined : inferGenericFailure(res.error),
|
|
67
|
+
details: {
|
|
68
|
+
launch_time_ms: res.launchTimeMs,
|
|
69
|
+
...(typeof res.output === 'string' ? { output: res.output } : {}),
|
|
70
|
+
...(typeof res.terminatedBeforeRestart === 'boolean' ? { terminated_before_restart: res.terminatedBeforeRestart } : {}),
|
|
71
|
+
...(typeof res.terminateError === 'string' ? { terminate_error: res.terminateError } : {}),
|
|
72
|
+
...(typeof res.error === 'string' ? { error: res.error } : {}),
|
|
73
|
+
...(res.observedApp ? { observed_app: res.observedApp } : {})
|
|
74
|
+
}
|
|
58
75
|
}))
|
|
59
76
|
}
|
|
60
77
|
|
package/src/system/index.ts
CHANGED
|
@@ -10,8 +10,34 @@ export async function getSystemStatus() {
|
|
|
10
10
|
const issues = [...android.issues, ...ios.issues, ...(gradle.issues || [])]
|
|
11
11
|
|
|
12
12
|
const success = issues.length === 0
|
|
13
|
+
const androidReady = android.adbAvailable && android.devices > 0 && !android.issues.some((issue) => /unauthorized|offline/i.test(issue))
|
|
14
|
+
const iosReady = ios.iosAvailable && ios.iosDevices > 0
|
|
15
|
+
const gradleReady = (gradle.issues || []).length === 0
|
|
16
|
+
const overallStatus = success ? 'ready' : (androidReady || iosReady ? 'degraded' : 'blocked')
|
|
17
|
+
|
|
18
|
+
const androidSummary = !android.adbAvailable
|
|
19
|
+
? 'ADB unavailable'
|
|
20
|
+
: android.devices === 0
|
|
21
|
+
? 'ADB available but no Android devices connected'
|
|
22
|
+
: android.logsAvailable
|
|
23
|
+
? `${android.devices} Android device(s) connected; log access available`
|
|
24
|
+
: `${android.devices} Android device(s) connected; log access unavailable`
|
|
25
|
+
|
|
26
|
+
const iosSummary = !ios.iosAvailable
|
|
27
|
+
? 'xcrun unavailable'
|
|
28
|
+
: ios.iosDevices === 0
|
|
29
|
+
? 'xcrun available but no iOS simulators booted'
|
|
30
|
+
: `${ios.iosDevices} iOS simulator(s) booted`
|
|
31
|
+
|
|
32
|
+
const gradleSummary = !gradle.gradleJavaHome
|
|
33
|
+
? 'No explicit Gradle JDK override detected'
|
|
34
|
+
: gradleReady
|
|
35
|
+
? `Gradle JDK configured at ${gradle.gradleJavaHome}`
|
|
36
|
+
: `Gradle JDK override invalid: ${gradle.gradleJavaHome}`
|
|
37
|
+
|
|
13
38
|
return {
|
|
14
39
|
success,
|
|
40
|
+
status: overallStatus,
|
|
15
41
|
adbAvailable: android.adbAvailable,
|
|
16
42
|
adbVersion: android.adbVersion,
|
|
17
43
|
devices: android.devices,
|
|
@@ -25,9 +51,38 @@ export async function getSystemStatus() {
|
|
|
25
51
|
gradleJavaHome: gradle.gradleJavaHome,
|
|
26
52
|
gradleValid: gradle.gradleValid,
|
|
27
53
|
gradleFilesChecked: gradle.filesChecked,
|
|
28
|
-
gradleSuggestedFixes: gradle.suggestedFixes
|
|
54
|
+
gradleSuggestedFixes: gradle.suggestedFixes,
|
|
55
|
+
summary: {
|
|
56
|
+
overall: overallStatus,
|
|
57
|
+
android: {
|
|
58
|
+
ready: androidReady,
|
|
59
|
+
summary: androidSummary,
|
|
60
|
+
blockers: android.issues
|
|
61
|
+
},
|
|
62
|
+
ios: {
|
|
63
|
+
ready: iosReady,
|
|
64
|
+
summary: iosSummary,
|
|
65
|
+
blockers: ios.issues
|
|
66
|
+
},
|
|
67
|
+
gradle: {
|
|
68
|
+
ready: gradleReady,
|
|
69
|
+
summary: gradleSummary,
|
|
70
|
+
blockers: gradle.issues || [],
|
|
71
|
+
suggestedFixes: gradle.suggestedFixes || []
|
|
72
|
+
}
|
|
73
|
+
}
|
|
29
74
|
}
|
|
30
75
|
} catch (e: unknown) {
|
|
31
|
-
return {
|
|
76
|
+
return {
|
|
77
|
+
success: false,
|
|
78
|
+
status: 'blocked',
|
|
79
|
+
issues: ['Internal error: ' + (e instanceof Error ? e.message : String(e))],
|
|
80
|
+
summary: {
|
|
81
|
+
overall: 'blocked',
|
|
82
|
+
android: { ready: false, summary: 'Android status unavailable', blockers: [] },
|
|
83
|
+
ios: { ready: false, summary: 'iOS status unavailable', blockers: [] },
|
|
84
|
+
gradle: { ready: false, summary: 'Gradle status unavailable', blockers: [], suggestedFixes: [] }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
32
87
|
}
|
|
33
88
|
}
|
package/src/types.ts
CHANGED
|
@@ -10,6 +10,15 @@ export interface StartAppResponse {
|
|
|
10
10
|
device: DeviceInfo;
|
|
11
11
|
appStarted: boolean;
|
|
12
12
|
launchTimeMs: number;
|
|
13
|
+
output?: string;
|
|
14
|
+
observedApp?: {
|
|
15
|
+
appId: string;
|
|
16
|
+
package?: string | null;
|
|
17
|
+
activity?: string | null;
|
|
18
|
+
screen?: string | null;
|
|
19
|
+
pid?: number | null;
|
|
20
|
+
matchedTarget?: boolean | null;
|
|
21
|
+
};
|
|
13
22
|
error?: string;
|
|
14
23
|
diagnostics?: any;
|
|
15
24
|
}
|
|
@@ -25,6 +34,17 @@ export interface RestartAppResponse {
|
|
|
25
34
|
device: DeviceInfo;
|
|
26
35
|
appRestarted: boolean;
|
|
27
36
|
launchTimeMs: number;
|
|
37
|
+
output?: string;
|
|
38
|
+
observedApp?: {
|
|
39
|
+
appId: string;
|
|
40
|
+
package?: string | null;
|
|
41
|
+
activity?: string | null;
|
|
42
|
+
screen?: string | null;
|
|
43
|
+
pid?: number | null;
|
|
44
|
+
matchedTarget?: boolean | null;
|
|
45
|
+
};
|
|
46
|
+
terminatedBeforeRestart?: boolean;
|
|
47
|
+
terminateError?: string;
|
|
28
48
|
error?: string;
|
|
29
49
|
diagnostics?: any;
|
|
30
50
|
}
|
|
@@ -155,6 +175,7 @@ export interface ActionExecutionResult {
|
|
|
155
175
|
action_id: string;
|
|
156
176
|
timestamp: number;
|
|
157
177
|
action_type: string;
|
|
178
|
+
device?: DeviceInfo;
|
|
158
179
|
target: {
|
|
159
180
|
selector: Record<string, unknown> | null;
|
|
160
181
|
resolved: ActionTargetResolved | null;
|
|
@@ -164,6 +185,7 @@ export interface ActionExecutionResult {
|
|
|
164
185
|
retryable?: boolean;
|
|
165
186
|
ui_fingerprint_before?: string | null;
|
|
166
187
|
ui_fingerprint_after?: string | null;
|
|
188
|
+
details?: Record<string, unknown>;
|
|
167
189
|
}
|
|
168
190
|
|
|
169
191
|
export interface TapElementResponse extends ActionExecutionResult {}
|
|
@@ -179,6 +201,11 @@ export interface ExpectScreenResponse {
|
|
|
179
201
|
screen: string | null;
|
|
180
202
|
};
|
|
181
203
|
confidence: number;
|
|
204
|
+
comparison: {
|
|
205
|
+
basis: 'fingerprint' | 'screen' | 'none';
|
|
206
|
+
matched: boolean;
|
|
207
|
+
reason: string;
|
|
208
|
+
};
|
|
182
209
|
}
|
|
183
210
|
|
|
184
211
|
export interface ExpectElementVisibleResponse {
|
|
@@ -190,7 +217,16 @@ export interface ExpectElementVisibleResponse {
|
|
|
190
217
|
contains?: boolean;
|
|
191
218
|
};
|
|
192
219
|
element_id: string | null;
|
|
220
|
+
expected_condition?: 'visible';
|
|
193
221
|
element?: ActionTargetResolved | null;
|
|
222
|
+
observed?: {
|
|
223
|
+
status?: string;
|
|
224
|
+
matched_count?: number;
|
|
225
|
+
condition_satisfied?: boolean;
|
|
226
|
+
selected_index?: number | null;
|
|
227
|
+
last_matched_element?: ActionTargetResolved | null;
|
|
228
|
+
};
|
|
229
|
+
reason?: string;
|
|
194
230
|
failure_code?: 'TIMEOUT' | 'ELEMENT_NOT_FOUND' | 'UNKNOWN';
|
|
195
231
|
retryable?: boolean;
|
|
196
232
|
}
|
|
@@ -15,7 +15,12 @@ async function run() {
|
|
|
15
15
|
success: true,
|
|
16
16
|
observed_screen: { fingerprint: 'fp_home', screen: 'com.example.HomeActivity' },
|
|
17
17
|
expected_screen: { fingerprint: 'fp_home', screen: null },
|
|
18
|
-
confidence: 1
|
|
18
|
+
confidence: 1,
|
|
19
|
+
comparison: {
|
|
20
|
+
basis: 'fingerprint',
|
|
21
|
+
matched: true,
|
|
22
|
+
reason: 'observed fingerprint matches expected fingerprint fp_home'
|
|
23
|
+
}
|
|
19
24
|
})
|
|
20
25
|
|
|
21
26
|
;(Observe as any).ToolsObserve.getCurrentScreenHandler = async () => ({
|
|
@@ -26,6 +31,11 @@ async function run() {
|
|
|
26
31
|
assert.strictEqual(expectScreen.success, true)
|
|
27
32
|
assert.strictEqual(expectScreen.observed_screen.screen, 'HomeActivity')
|
|
28
33
|
assert.strictEqual(expectScreen.confidence, 1)
|
|
34
|
+
assert.deepStrictEqual(expectScreen.comparison, {
|
|
35
|
+
basis: 'screen',
|
|
36
|
+
matched: true,
|
|
37
|
+
reason: 'observed screen matches expected screen HomeActivity'
|
|
38
|
+
})
|
|
29
39
|
|
|
30
40
|
;(ToolsInteract as any).waitForUIHandler = async () => ({
|
|
31
41
|
status: 'success',
|
|
@@ -46,10 +56,18 @@ async function run() {
|
|
|
46
56
|
assert.strictEqual(expectElementVisible.success, true)
|
|
47
57
|
assert.strictEqual(expectElementVisible.element_id, 'el_ready')
|
|
48
58
|
assert.strictEqual(expectElementVisible.element?.resource_id, 'rid_ready')
|
|
59
|
+
assert.strictEqual(expectElementVisible.expected_condition, 'visible')
|
|
60
|
+
assert.strictEqual(expectElementVisible.reason, 'selector is visible')
|
|
49
61
|
|
|
50
62
|
;(ToolsInteract as any).waitForUIHandler = async () => ({
|
|
51
63
|
status: 'timeout',
|
|
52
|
-
error: { code: 'ELEMENT_NOT_FOUND', message: 'Condition visible not satisfied within timeout' }
|
|
64
|
+
error: { code: 'ELEMENT_NOT_FOUND', message: 'Condition visible not satisfied within timeout; observed 0 match(es)' },
|
|
65
|
+
observed: {
|
|
66
|
+
matched_count: 0,
|
|
67
|
+
condition_satisfied: false,
|
|
68
|
+
selected_index: null,
|
|
69
|
+
last_matched_element: null
|
|
70
|
+
}
|
|
53
71
|
})
|
|
54
72
|
const timeoutResult = await ToolsInteract.expectElementVisibleHandler({
|
|
55
73
|
selector: { text: 'Missing' },
|
|
@@ -59,6 +77,15 @@ async function run() {
|
|
|
59
77
|
success: false,
|
|
60
78
|
selector: { text: 'Missing' },
|
|
61
79
|
element_id: null,
|
|
80
|
+
expected_condition: 'visible',
|
|
81
|
+
observed: {
|
|
82
|
+
status: 'timeout',
|
|
83
|
+
matched_count: 0,
|
|
84
|
+
condition_satisfied: false,
|
|
85
|
+
selected_index: null,
|
|
86
|
+
last_matched_element: null
|
|
87
|
+
},
|
|
88
|
+
reason: 'Condition visible not satisfied within timeout; observed 0 match(es)',
|
|
62
89
|
failure_code: 'TIMEOUT',
|
|
63
90
|
retryable: true
|
|
64
91
|
})
|
|
@@ -9,22 +9,26 @@ async function runTests() {
|
|
|
9
9
|
|
|
10
10
|
try {
|
|
11
11
|
let seq1: Array<string | null> = ['B', 'B']
|
|
12
|
-
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: seq1.length ? seq1.shift() : null })
|
|
12
|
+
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: seq1.length ? seq1.shift() : null, activity: 'MainActivity' })
|
|
13
13
|
const start1 = Date.now()
|
|
14
14
|
const res1 = await ToolsInteract.waitForScreenChangeHandler({ platform: 'android', previousFingerprint: 'A', timeoutMs: 2000, pollIntervalMs: 50 })
|
|
15
15
|
const elapsed1 = Date.now() - start1
|
|
16
16
|
assert.ok(res1 && (res1 as any).success === true && (res1 as any).newFingerprint === 'B', 'Immediate fingerprint change should succeed')
|
|
17
|
+
assert.strictEqual((res1 as any).previousFingerprint, 'A')
|
|
18
|
+
assert.strictEqual((res1 as any).observed_screen.activity, 'MainActivity')
|
|
17
19
|
console.log('Test 1: Immediate change -> PASS', 'Elapsed:', elapsed1, 'ms')
|
|
18
20
|
|
|
19
21
|
let seq2: Array<string | null> = [null, null, 'B', 'B']
|
|
20
|
-
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: seq2.length ? seq2.shift() : 'B' })
|
|
22
|
+
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: seq2.length ? seq2.shift() : 'B', activity: 'NextActivity' })
|
|
21
23
|
const res2 = await ToolsInteract.waitForScreenChangeHandler({ platform: 'android', previousFingerprint: 'A', timeoutMs: 3000, pollIntervalMs: 50 })
|
|
22
24
|
assert.ok(res2 && (res2 as any).success === true && (res2 as any).newFingerprint === 'B', 'Transient nulls should not prevent success')
|
|
23
25
|
console.log('Test 2: Transient nulls -> PASS')
|
|
24
26
|
|
|
25
|
-
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: 'A' })
|
|
27
|
+
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: 'A', activity: 'HomeActivity' })
|
|
26
28
|
const res3 = await ToolsInteract.waitForScreenChangeHandler({ platform: 'android', previousFingerprint: 'A', timeoutMs: 300, pollIntervalMs: 50 })
|
|
27
29
|
assert.ok(res3 && (res3 as any).success === false && (res3 as any).reason === 'timeout', 'Unchanged fingerprint should time out')
|
|
30
|
+
assert.strictEqual((res3 as any).previousFingerprint, 'A')
|
|
31
|
+
assert.strictEqual((res3 as any).observed_screen.activity, 'HomeActivity')
|
|
28
32
|
console.log('Test 3: Timeout -> PASS')
|
|
29
33
|
} finally {
|
|
30
34
|
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = original
|
|
@@ -12,6 +12,12 @@ async function run() {
|
|
|
12
12
|
const r1 = await ToolsInteract.waitForUIHandler({ selector: { text: 'Hello' }, condition: 'exists', timeout_ms: 1000, poll_interval_ms: 50, platform: 'android' })
|
|
13
13
|
const ok1 = r1 && r1.status === 'success' && r1.matched === 1 && r1.element && r1.element.text === 'Hello' && typeof r1.element.elementId === 'string'
|
|
14
14
|
assert.ok(ok1, 'Exact match should satisfy exists condition')
|
|
15
|
+
assert.deepStrictEqual((r1 as any).requested, {
|
|
16
|
+
selector: { text: 'Hello' },
|
|
17
|
+
condition: 'exists',
|
|
18
|
+
match: null
|
|
19
|
+
})
|
|
20
|
+
assert.strictEqual((r1 as any).observed.matched_count, 1)
|
|
15
21
|
console.log('Exact match exists:', ok1 ? 'PASS' : 'FAIL', JSON.stringify(r1, null, 2))
|
|
16
22
|
|
|
17
23
|
// Test 2: contains matching
|
|
@@ -26,6 +32,9 @@ async function run() {
|
|
|
26
32
|
const r3 = await ToolsInteract.waitForUIHandler({ selector: { text: 'Hidden' }, condition: 'visible', timeout_ms: 300, poll_interval_ms: 50, platform: 'android' })
|
|
27
33
|
const ok3 = r3 && r3.status === 'timeout' && r3.error && r3.error.code === 'ELEMENT_NOT_FOUND'
|
|
28
34
|
assert.ok(ok3, 'Hidden element should fail visible condition')
|
|
35
|
+
assert.strictEqual((r3 as any).observed.matched_count, 1)
|
|
36
|
+
assert.strictEqual((r3 as any).observed.condition_satisfied, false)
|
|
37
|
+
assert.match((r3 as any).error.message, /observed 1 match/)
|
|
29
38
|
console.log('Visible negative (hidden element):', ok3 ? 'PASS' : 'FAIL', JSON.stringify(r3, null, 2))
|
|
30
39
|
|
|
31
40
|
// Test 4: clickable condition
|
|
@@ -66,8 +75,18 @@ async function run() {
|
|
|
66
75
|
const r7 = await ToolsInteract.waitForUIHandler({ selector: { text: 'Nope' }, condition: 'exists', timeout_ms: 300, poll_interval_ms: 50, retry: { max_attempts: 1 }, platform: 'android' })
|
|
67
76
|
const ok7 = r7 && r7.status === 'timeout' && r7.error && r7.error.code === 'ELEMENT_NOT_FOUND'
|
|
68
77
|
assert.ok(ok7, 'Missing selector should time out with ELEMENT_NOT_FOUND')
|
|
78
|
+
assert.strictEqual((r7 as any).observed.selected_index, null)
|
|
69
79
|
console.log('Timeout no match:', ok7 ? 'PASS' : 'FAIL', JSON.stringify(r7, null, 2))
|
|
70
80
|
|
|
81
|
+
// Test 8: requested match index out of range should not report a selected index
|
|
82
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({ elements: [ { text: 'OnlyOne', resourceId: 'rid8', bounds: [0,0,10,10], visible: true } ] })
|
|
83
|
+
const r8 = await ToolsInteract.waitForUIHandler({ selector: { text: 'OnlyOne' }, condition: 'exists', timeout_ms: 300, poll_interval_ms: 50, match: { index: 1 }, platform: 'android' })
|
|
84
|
+
const ok8 = r8 && r8.status === 'timeout' && r8.error && r8.error.code === 'ELEMENT_NOT_FOUND'
|
|
85
|
+
assert.ok(ok8, 'Out-of-range match index should time out deterministically')
|
|
86
|
+
assert.strictEqual((r8 as any).observed.matched_count, 1)
|
|
87
|
+
assert.strictEqual((r8 as any).observed.selected_index, null)
|
|
88
|
+
console.log('Out-of-range match index:', ok8 ? 'PASS' : 'FAIL', JSON.stringify(r8, null, 2))
|
|
89
|
+
|
|
71
90
|
} finally {
|
|
72
91
|
;(Observe as any).ToolsObserve.getUITreeHandler = origGetUITree
|
|
73
92
|
}
|
|
@@ -69,8 +69,101 @@ exit 0
|
|
|
69
69
|
assert.ok(res2.output && typeof res2.output === 'string', 'Project dir install succeeded with output')
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
const testOnlyAdbPath = path.join(binDir, 'adb-test-only')
|
|
73
|
+
const testOnlyAdbScript = `#!/bin/sh
|
|
74
|
+
if [ "$1" = "-s" ]; then
|
|
75
|
+
shift 2
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
if [ "$1" = "shell" ] && [ "$2" = "getprop" ]; then
|
|
79
|
+
case "$3" in
|
|
80
|
+
ro.build.version.release) echo '16' ;;
|
|
81
|
+
ro.product.model) echo 'sdk_gphone64_arm64' ;;
|
|
82
|
+
ro.kernel.qemu) echo '1' ;;
|
|
83
|
+
esac
|
|
84
|
+
exit 0
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
if [ "$1" = "install" ]; then
|
|
88
|
+
if [ "$2" = "-r" ] && [ "$3" = "-t" ]; then
|
|
89
|
+
echo 'Performing Streamed Install'
|
|
90
|
+
echo 'Success'
|
|
91
|
+
exit 0
|
|
92
|
+
fi
|
|
93
|
+
echo 'Performing Streamed Install'
|
|
94
|
+
echo 'adb: failed to install test.apk: Failure [INSTALL_FAILED_TEST_ONLY: Failed to install test-only apk. Did you forget to add -t?]' 1>&2
|
|
95
|
+
exit 1
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
echo 'Success'
|
|
99
|
+
exit 0
|
|
100
|
+
`
|
|
101
|
+
await fs.writeFile(testOnlyAdbPath, testOnlyAdbScript, { mode: 0o755 })
|
|
102
|
+
process.env.ADB_PATH = testOnlyAdbPath
|
|
103
|
+
|
|
104
|
+
const { dir: d2, file: testOnlyApk } = await makeTempFile('.apk')
|
|
105
|
+
const testOnlyRes = await ai.installApp(testOnlyApk, 'emulator-5554')
|
|
106
|
+
console.log('testOnlyRes', testOnlyRes)
|
|
107
|
+
assert.strictEqual(testOnlyRes.installed, true, 'Test-only APK should retry with -t and install successfully')
|
|
108
|
+
|
|
109
|
+
const cleanupLog = path.join(binDir, 'pm-cleanup.log')
|
|
110
|
+
const pmFallbackAdbPath = path.join(binDir, 'adb-pm-fallback')
|
|
111
|
+
const pmFallbackAdbScript = `#!/bin/sh
|
|
112
|
+
if [ "$1" = "-s" ]; then
|
|
113
|
+
shift 2
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
if [ "$1" = "shell" ] && [ "$2" = "getprop" ]; then
|
|
117
|
+
case "$3" in
|
|
118
|
+
ro.build.version.release) echo '16' ;;
|
|
119
|
+
ro.product.model) echo 'sdk_gphone64_arm64' ;;
|
|
120
|
+
ro.kernel.qemu) echo '1' ;;
|
|
121
|
+
esac
|
|
122
|
+
exit 0
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
if [ "$1" = "install" ]; then
|
|
126
|
+
echo 'adb install failed' 1>&2
|
|
127
|
+
exit 1
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
if [ "$1" = "push" ]; then
|
|
131
|
+
echo 'pushed'
|
|
132
|
+
exit 0
|
|
133
|
+
fi
|
|
134
|
+
|
|
135
|
+
if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "install" ]; then
|
|
136
|
+
if [ "$4" = "-r" ] && [ "$5" = "-t" ]; then
|
|
137
|
+
echo 'Failure [INSTALL_FAILED_VERSION_DOWNGRADE]'
|
|
138
|
+
exit 1
|
|
139
|
+
fi
|
|
140
|
+
echo 'Failure [INSTALL_FAILED_TEST_ONLY: Failed to install test-only apk. Did you forget to add -t?]'
|
|
141
|
+
exit 1
|
|
142
|
+
fi
|
|
143
|
+
|
|
144
|
+
if [ "$1" = "shell" ] && [ "$2" = "rm" ]; then
|
|
145
|
+
echo cleanup >> "${cleanupLog}"
|
|
146
|
+
exit 0
|
|
147
|
+
fi
|
|
148
|
+
|
|
149
|
+
echo 'unexpected args:' "$@" 1>&2
|
|
150
|
+
exit 1
|
|
151
|
+
`
|
|
152
|
+
await fs.writeFile(pmFallbackAdbPath, pmFallbackAdbScript, { mode: 0o755 })
|
|
153
|
+
process.env.ADB_PATH = pmFallbackAdbPath
|
|
154
|
+
|
|
155
|
+
const { dir: d3, file: pmFallbackApk } = await makeTempFile('.apk')
|
|
156
|
+
const pmFallbackRes = await ai.installApp(pmFallbackApk, 'emulator-5554')
|
|
157
|
+
console.log('pmFallbackRes', pmFallbackRes)
|
|
158
|
+
assert.strictEqual(pmFallbackRes.installed, false, 'Failed pm fallback should surface as install failure')
|
|
159
|
+
assert.match(pmFallbackRes.error || '', /INSTALL_FAILED_VERSION_DOWNGRADE/, 'Final pm retry failure should be reported')
|
|
160
|
+
const cleanupCount = (await fs.readFile(cleanupLog, 'utf8')).trim().split('\n').filter(Boolean).length
|
|
161
|
+
assert.strictEqual(cleanupCount, 1, 'pm fallback cleanup should run once')
|
|
162
|
+
|
|
72
163
|
// cleanup
|
|
73
164
|
await fs.rm(d1, { recursive: true, force: true }).catch(() => {})
|
|
165
|
+
await fs.rm(d2, { recursive: true, force: true }).catch(() => {})
|
|
166
|
+
await fs.rm(d3, { recursive: true, force: true }).catch(() => {})
|
|
74
167
|
await fs.rm(dirGradle, { recursive: true, force: true }).catch(() => {})
|
|
75
168
|
|
|
76
169
|
// restore PATH and ADB_PATH
|
|
@@ -87,4 +180,4 @@ exit 0
|
|
|
87
180
|
}
|
|
88
181
|
}
|
|
89
182
|
|
|
90
|
-
run().catch((e) => { console.error(e); process.exit(1) })
|
|
183
|
+
run().catch((e) => { console.error(e); process.exit(1) })
|
|
@@ -78,38 +78,55 @@ async function run() {
|
|
|
78
78
|
return {
|
|
79
79
|
device: { platform: 'android', id: 'emulator-5554', osVersion: '14', model: 'Pixel', simulator: true },
|
|
80
80
|
appStarted: true,
|
|
81
|
-
launchTimeMs: 123
|
|
81
|
+
launchTimeMs: 123,
|
|
82
|
+
output: 'Events injected: 1',
|
|
83
|
+
observedApp: {
|
|
84
|
+
appId: 'com.example.app',
|
|
85
|
+
package: 'com.example.app',
|
|
86
|
+
activity: 'com.example.MainActivity',
|
|
87
|
+
screen: 'MainActivity',
|
|
88
|
+
matchedTarget: true
|
|
89
|
+
}
|
|
82
90
|
} as any
|
|
83
91
|
}
|
|
84
92
|
const startAppResponse = await handleToolCall('start_app', { platform: 'android', appId: 'com.example.app' })
|
|
85
93
|
const startAppPayload = JSON.parse((startAppResponse as any).content[0].text)
|
|
86
94
|
assert.strictEqual(startAppPayload.success, true)
|
|
87
95
|
assert.strictEqual(startAppPayload.action_type, 'start_app')
|
|
96
|
+
assert.strictEqual(startAppPayload.device.id, 'emulator-5554')
|
|
88
97
|
assert.deepStrictEqual(startAppPayload.target.selector, { appId: 'com.example.app' })
|
|
98
|
+
assert.strictEqual(startAppPayload.details.launch_time_ms, 123)
|
|
99
|
+
assert.strictEqual(startAppPayload.details.observed_app.matchedTarget, true)
|
|
89
100
|
|
|
90
101
|
;(ToolsInteract as any).expectScreenHandler = async () => ({
|
|
91
102
|
success: true,
|
|
92
103
|
observed_screen: { fingerprint: 'fp_after', screen: 'MainActivity' },
|
|
93
104
|
expected_screen: { fingerprint: 'fp_after', screen: null },
|
|
94
|
-
confidence: 1
|
|
105
|
+
confidence: 1,
|
|
106
|
+
comparison: { basis: 'fingerprint', matched: true, reason: 'observed fingerprint matches expected fingerprint fp_after' }
|
|
95
107
|
})
|
|
96
108
|
|
|
97
109
|
const expectScreenResponse = await handleToolCall('expect_screen', { fingerprint: 'fp_after' })
|
|
98
110
|
const expectScreenPayload = JSON.parse((expectScreenResponse as any).content[0].text)
|
|
99
111
|
assert.strictEqual(expectScreenPayload.success, true)
|
|
100
112
|
assert.strictEqual(expectScreenPayload.confidence, 1)
|
|
113
|
+
assert.strictEqual(expectScreenPayload.comparison.basis, 'fingerprint')
|
|
101
114
|
|
|
102
115
|
;(ToolsInteract as any).expectElementVisibleHandler = async () => ({
|
|
103
116
|
success: true,
|
|
104
117
|
selector: { text: 'Ready' },
|
|
105
118
|
element_id: 'el_ready',
|
|
106
|
-
|
|
119
|
+
expected_condition: 'visible',
|
|
120
|
+
element: { elementId: 'el_ready', text: 'Ready', resource_id: null, accessibility_id: null, class: 'TextView', bounds: [0, 0, 10, 10], index: 0 },
|
|
121
|
+
observed: { status: 'success', matched_count: 1, condition_satisfied: true, selected_index: 0, last_matched_element: { elementId: 'el_ready', text: 'Ready', resource_id: null, accessibility_id: null, class: 'TextView', bounds: [0, 0, 10, 10], index: 0 } },
|
|
122
|
+
reason: 'selector is visible'
|
|
107
123
|
})
|
|
108
124
|
|
|
109
125
|
const expectElementResponse = await handleToolCall('expect_element_visible', { selector: { text: 'Ready' } })
|
|
110
126
|
const expectElementPayload = JSON.parse((expectElementResponse as any).content[0].text)
|
|
111
127
|
assert.strictEqual(expectElementPayload.success, true)
|
|
112
128
|
assert.strictEqual(expectElementPayload.element_id, 'el_ready')
|
|
129
|
+
assert.strictEqual(expectElementPayload.expected_condition, 'visible')
|
|
113
130
|
|
|
114
131
|
;(ToolsObserve as any).captureScreenshotHandler = async () => ({
|
|
115
132
|
device: { platform: 'ios', id: 'booted', osVersion: '18.0', model: 'Simulator', simulator: true },
|
|
@@ -6,7 +6,9 @@ import { getSystemStatus } from '../../../src/system/index.js'
|
|
|
6
6
|
async function run() {
|
|
7
7
|
const payload = await getSystemStatus()
|
|
8
8
|
assert(typeof payload.success === 'boolean')
|
|
9
|
+
assert(typeof (payload as any).status === 'string')
|
|
9
10
|
assert(Array.isArray(payload.issues))
|
|
11
|
+
assert((payload as any).summary && typeof (payload as any).summary.overall === 'string')
|
|
10
12
|
|
|
11
13
|
const adb = ensureAdbAvailable()
|
|
12
14
|
assert(adb && typeof adb.ok === 'boolean')
|
|
@@ -93,8 +93,11 @@ async function run() {
|
|
|
93
93
|
|
|
94
94
|
const healthy = await systemStatus.getSystemStatus()
|
|
95
95
|
assert.strictEqual(healthy.success, true)
|
|
96
|
+
assert.strictEqual((healthy as any).status, 'ready')
|
|
96
97
|
assert.strictEqual(healthy.adbAvailable, true)
|
|
97
98
|
assert.strictEqual(typeof healthy.adbVersion, 'string')
|
|
99
|
+
assert.strictEqual((healthy as any).summary.android.ready, true)
|
|
100
|
+
assert.strictEqual(typeof (healthy as any).summary.ios.summary, 'string')
|
|
98
101
|
|
|
99
102
|
setScenario({
|
|
100
103
|
ADB_VERSION_STATUS: '1',
|
|
@@ -105,6 +108,7 @@ async function run() {
|
|
|
105
108
|
})
|
|
106
109
|
const missingAdb = await systemStatus.getSystemStatus()
|
|
107
110
|
assert.strictEqual(missingAdb.success, false)
|
|
111
|
+
assert.strictEqual((missingAdb as any).summary.android.ready, false)
|
|
108
112
|
assert(missingAdb.issues.some((issue: string) => issue.includes('ADB')))
|
|
109
113
|
|
|
110
114
|
setScenario({
|
|
@@ -116,6 +120,7 @@ async function run() {
|
|
|
116
120
|
})
|
|
117
121
|
const unauthorized = await systemStatus.getSystemStatus()
|
|
118
122
|
assert.strictEqual(unauthorized.success, false)
|
|
123
|
+
assert.strictEqual((unauthorized as any).summary.android.ready, false)
|
|
119
124
|
assert(unauthorized.issues.some((issue: string) => issue.includes('unauthorized')))
|
|
120
125
|
assert(unauthorized.issues.some((issue: string) => issue.includes('offline')))
|
|
121
126
|
|
|
@@ -130,6 +135,7 @@ async function run() {
|
|
|
130
135
|
assert.strictEqual(missingXcrun.iosAvailable, false)
|
|
131
136
|
assert.strictEqual(missingXcrun.adbAvailable, true)
|
|
132
137
|
assert.strictEqual(Array.isArray(missingXcrun.issues), true)
|
|
138
|
+
assert.strictEqual((missingXcrun as any).summary.ios.ready, false)
|
|
133
139
|
|
|
134
140
|
console.log('system_status checks passed')
|
|
135
141
|
} finally {
|