mobile-debug-mcp 0.24.1 → 0.24.3
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 +124 -17
- package/dist/manage/android.js +24 -4
- package/dist/manage/ios.js +27 -3
- package/dist/server/common.js +32 -4
- package/dist/server/tool-definitions.js +8 -8
- package/dist/server/tool-handlers.js +22 -6
- package/dist/system/index.js +53 -2
- package/docs/CHANGELOG.md +8 -0
- package/docs/specs/baseline-spec-v0.md +312 -0
- package/docs/specs/mcp-tooling-spec-v1.md +272 -0
- package/docs/tools/interact.md +76 -10
- package/docs/tools/manage.md +17 -2
- package/docs/tools/system.md +23 -1
- package/package.json +1 -1
- package/src/interact/index.ts +126 -18
- package/src/manage/android.ts +24 -4
- package/src/manage/ios.ts +27 -3
- package/src/server/common.ts +36 -4
- package/src/server/tool-definitions.ts +8 -8
- package/src/server/tool-handlers.ts +23 -6
- package/src/system/index.ts +57 -2
- package/src/types.ts +37 -1
- 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/server/response_shapes.test.ts +48 -4
- 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,32 @@ 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
|
-
const
|
|
85
|
+
const timestampMs = Date.now()
|
|
86
|
+
const timestamp = new Date(timestampMs).toISOString()
|
|
82
87
|
return {
|
|
83
|
-
action_id: nextActionId(actionType,
|
|
88
|
+
action_id: nextActionId(actionType, timestampMs),
|
|
84
89
|
timestamp,
|
|
85
90
|
action_type: actionType,
|
|
91
|
+
...(device ? { device } : {}),
|
|
86
92
|
target: {
|
|
87
93
|
selector,
|
|
88
94
|
resolved: normalizeResolvedTarget(resolved)
|
|
@@ -90,6 +96,32 @@ export function buildActionExecutionResult({
|
|
|
90
96
|
success,
|
|
91
97
|
...(failure ? { failure_code: failure.failureCode, retryable: failure.retryable } : {}),
|
|
92
98
|
ui_fingerprint_before: uiFingerprintBefore,
|
|
93
|
-
ui_fingerprint_after: uiFingerprintAfter
|
|
99
|
+
ui_fingerprint_after: uiFingerprintAfter,
|
|
100
|
+
...(details ? { details } : {})
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function wrapToolError(name: string, error: unknown) {
|
|
105
|
+
const message = error instanceof Error
|
|
106
|
+
? error.message
|
|
107
|
+
: typeof error === 'object' && error !== null
|
|
108
|
+
? (() => {
|
|
109
|
+
try {
|
|
110
|
+
return JSON.stringify(error, null, 2)
|
|
111
|
+
} catch {
|
|
112
|
+
return '[unserializable error object]'
|
|
113
|
+
}
|
|
114
|
+
})()
|
|
115
|
+
: String(error)
|
|
116
|
+
return {
|
|
117
|
+
content: [{
|
|
118
|
+
type: 'text' as const,
|
|
119
|
+
text: JSON.stringify({
|
|
120
|
+
error: {
|
|
121
|
+
tool: name,
|
|
122
|
+
message
|
|
123
|
+
}
|
|
124
|
+
}, null, 2)
|
|
125
|
+
}]
|
|
94
126
|
}
|
|
95
127
|
}
|
|
@@ -10,7 +10,7 @@ Inputs:
|
|
|
10
10
|
- deviceId (optional)
|
|
11
11
|
|
|
12
12
|
Output Structure:
|
|
13
|
-
- action_id, timestamp, action_type
|
|
13
|
+
- action_id, timestamp (ISO 8601), action_type
|
|
14
14
|
- target.selector = { appId }
|
|
15
15
|
- success = true when launch was dispatched successfully
|
|
16
16
|
- failure_code/retryable when launch dispatch fails
|
|
@@ -83,7 +83,7 @@ Inputs:
|
|
|
83
83
|
- deviceId (optional)
|
|
84
84
|
|
|
85
85
|
Output Structure:
|
|
86
|
-
- action_id, timestamp, action_type
|
|
86
|
+
- action_id, timestamp (ISO 8601), action_type
|
|
87
87
|
- target.selector = { appId }
|
|
88
88
|
- success = true when the restart command completed
|
|
89
89
|
- failure_code/retryable when restart dispatch fails
|
|
@@ -532,7 +532,7 @@ Inputs:
|
|
|
532
532
|
- deviceId (optional)
|
|
533
533
|
|
|
534
534
|
Output Structure:
|
|
535
|
-
- action_id, timestamp, action_type
|
|
535
|
+
- action_id, timestamp (ISO 8601), action_type
|
|
536
536
|
- target.selector = { x, y }
|
|
537
537
|
- success = true when the tap was dispatched
|
|
538
538
|
- failure_code/retryable when dispatch fails
|
|
@@ -587,7 +587,7 @@ Inputs:
|
|
|
587
587
|
|
|
588
588
|
Output Structure:
|
|
589
589
|
- action_id: unique timestamp-based action identifier
|
|
590
|
-
- timestamp:
|
|
590
|
+
- timestamp: ISO 8601 timestamp for the action attempt
|
|
591
591
|
- action_type: "tap_element"
|
|
592
592
|
- target.selector: original target handle ({ elementId })
|
|
593
593
|
- target.resolved: minimal resolved element info used for the tap
|
|
@@ -640,7 +640,7 @@ Inputs:
|
|
|
640
640
|
- platform/deviceId (optional)
|
|
641
641
|
|
|
642
642
|
Output Structure:
|
|
643
|
-
- action_id, timestamp, action_type
|
|
643
|
+
- action_id, timestamp (ISO 8601), action_type
|
|
644
644
|
- target.selector = { x1, y1, x2, y2, duration }
|
|
645
645
|
- success = true when the swipe was dispatched
|
|
646
646
|
- failure_code/retryable when dispatch fails
|
|
@@ -692,7 +692,7 @@ Inputs:
|
|
|
692
692
|
- direction, maxScrolls, scrollAmount, deviceId (optional)
|
|
693
693
|
|
|
694
694
|
Output Structure:
|
|
695
|
-
- action_id, timestamp, action_type
|
|
695
|
+
- action_id, timestamp (ISO 8601), action_type
|
|
696
696
|
- target.selector = original selector
|
|
697
697
|
- target.resolved = minimal resolved element info when found
|
|
698
698
|
- success = true when scrolling produced a visible target element
|
|
@@ -746,7 +746,7 @@ Inputs:
|
|
|
746
746
|
- platform/deviceId (optional)
|
|
747
747
|
|
|
748
748
|
Output Structure:
|
|
749
|
-
- action_id, timestamp, action_type
|
|
749
|
+
- action_id, timestamp (ISO 8601), action_type
|
|
750
750
|
- target.selector = { text }
|
|
751
751
|
- success = true when text input was dispatched
|
|
752
752
|
- failure_code/retryable when dispatch fails
|
|
@@ -795,7 +795,7 @@ Inputs:
|
|
|
795
795
|
- platform/deviceId (optional)
|
|
796
796
|
|
|
797
797
|
Output Structure:
|
|
798
|
-
- action_id, timestamp, action_type
|
|
798
|
+
- action_id, timestamp (ISO 8601), action_type
|
|
799
799
|
- target.selector = { key: "back" }
|
|
800
800
|
- success = true when the back action was dispatched
|
|
801
801
|
- failure_code/retryable when dispatch fails
|
|
@@ -16,7 +16,8 @@ import {
|
|
|
16
16
|
inferScrollFailure,
|
|
17
17
|
ToolCallArgs,
|
|
18
18
|
ToolHandler,
|
|
19
|
-
wrapResponse
|
|
19
|
+
wrapResponse,
|
|
20
|
+
wrapToolError
|
|
20
21
|
} from './common.js'
|
|
21
22
|
|
|
22
23
|
async function handleStartApp(args: ToolCallArgs) {
|
|
@@ -27,11 +28,19 @@ async function handleStartApp(args: ToolCallArgs) {
|
|
|
27
28
|
const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
|
|
28
29
|
return wrapResponse(buildActionExecutionResult({
|
|
29
30
|
actionType: 'start_app',
|
|
31
|
+
device: res.device,
|
|
30
32
|
selector: { appId },
|
|
31
33
|
success: !!res.appStarted,
|
|
32
34
|
uiFingerprintBefore,
|
|
33
35
|
uiFingerprintAfter,
|
|
34
|
-
failure: res.appStarted ? undefined : inferGenericFailure(
|
|
36
|
+
failure: res.appStarted ? undefined : inferGenericFailure(res.error),
|
|
37
|
+
details: {
|
|
38
|
+
launch_time_ms: res.launchTimeMs,
|
|
39
|
+
...(typeof res.output === 'string' ? { output: res.output } : {}),
|
|
40
|
+
...(res.device ? { device_id: res.device.id } : {}),
|
|
41
|
+
...(typeof res.error === 'string' ? { error: res.error } : {}),
|
|
42
|
+
...(res.observedApp ? { observed_app: res.observedApp } : {})
|
|
43
|
+
}
|
|
35
44
|
}))
|
|
36
45
|
}
|
|
37
46
|
|
|
@@ -50,11 +59,20 @@ async function handleRestartApp(args: ToolCallArgs) {
|
|
|
50
59
|
const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
|
|
51
60
|
return wrapResponse(buildActionExecutionResult({
|
|
52
61
|
actionType: 'restart_app',
|
|
62
|
+
device: res.device,
|
|
53
63
|
selector: { appId },
|
|
54
64
|
success: !!res.appRestarted,
|
|
55
65
|
uiFingerprintBefore,
|
|
56
66
|
uiFingerprintAfter,
|
|
57
|
-
failure: res.appRestarted ? undefined : inferGenericFailure(
|
|
67
|
+
failure: res.appRestarted ? undefined : inferGenericFailure(res.error),
|
|
68
|
+
details: {
|
|
69
|
+
launch_time_ms: res.launchTimeMs,
|
|
70
|
+
...(typeof res.output === 'string' ? { output: res.output } : {}),
|
|
71
|
+
...(typeof res.terminatedBeforeRestart === 'boolean' ? { terminated_before_restart: res.terminatedBeforeRestart } : {}),
|
|
72
|
+
...(typeof res.terminateError === 'string' ? { terminate_error: res.terminateError } : {}),
|
|
73
|
+
...(typeof res.error === 'string' ? { error: res.error } : {}),
|
|
74
|
+
...(res.observedApp ? { observed_app: res.observedApp } : {})
|
|
75
|
+
}
|
|
58
76
|
}))
|
|
59
77
|
}
|
|
60
78
|
|
|
@@ -358,8 +376,7 @@ export async function handleToolCall(name: string, args: ToolCallArgs = {}) {
|
|
|
358
376
|
try {
|
|
359
377
|
return await handler(args)
|
|
360
378
|
} catch (error) {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}
|
|
379
|
+
console.error(`Error executing tool ${name}:`, error)
|
|
380
|
+
return wrapToolError(name, error)
|
|
364
381
|
}
|
|
365
382
|
}
|
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
|
}
|
|
@@ -153,8 +173,9 @@ export interface ActionTargetResolved {
|
|
|
153
173
|
|
|
154
174
|
export interface ActionExecutionResult {
|
|
155
175
|
action_id: string;
|
|
156
|
-
timestamp:
|
|
176
|
+
timestamp: string;
|
|
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
|
}
|
|
@@ -47,7 +47,7 @@ async function run() {
|
|
|
47
47
|
|
|
48
48
|
;(ToolsInteract as any).tapElementHandler = async () => ({
|
|
49
49
|
action_id: 'tap_element_1',
|
|
50
|
-
timestamp:
|
|
50
|
+
timestamp: '2026-04-23T08:00:00.000Z',
|
|
51
51
|
action_type: 'tap_element',
|
|
52
52
|
target: {
|
|
53
53
|
selector: { elementId: 'el_ready' },
|
|
@@ -62,6 +62,7 @@ async function run() {
|
|
|
62
62
|
const tapElementPayload = JSON.parse((tapElementResponse as any).content[0].text)
|
|
63
63
|
assert.strictEqual(tapElementPayload.success, true)
|
|
64
64
|
assert.strictEqual(tapElementPayload.action_type, 'tap_element')
|
|
65
|
+
assert.match(tapElementPayload.timestamp, /^\d{4}-\d{2}-\d{2}T/)
|
|
65
66
|
assert.strictEqual(tapElementPayload.target.resolved.elementId, 'el_ready')
|
|
66
67
|
assert.strictEqual(tapElementPayload.ui_fingerprint_before, 'fp_before')
|
|
67
68
|
|
|
@@ -71,6 +72,7 @@ async function run() {
|
|
|
71
72
|
const tapPayload = JSON.parse((tapResponse as any).content[0].text)
|
|
72
73
|
assert.strictEqual(tapPayload.success, true)
|
|
73
74
|
assert.strictEqual(tapPayload.action_type, 'tap')
|
|
75
|
+
assert.match(tapPayload.timestamp, /^\d{4}-\d{2}-\d{2}T/)
|
|
74
76
|
assert.deepStrictEqual(tapPayload.target.selector, { x: 1, y: 2 })
|
|
75
77
|
assert.strictEqual(tapPayload.ui_fingerprint_before, 'fp_mock')
|
|
76
78
|
|
|
@@ -78,38 +80,80 @@ async function run() {
|
|
|
78
80
|
return {
|
|
79
81
|
device: { platform: 'android', id: 'emulator-5554', osVersion: '14', model: 'Pixel', simulator: true },
|
|
80
82
|
appStarted: true,
|
|
81
|
-
launchTimeMs: 123
|
|
83
|
+
launchTimeMs: 123,
|
|
84
|
+
output: 'Events injected: 1',
|
|
85
|
+
observedApp: {
|
|
86
|
+
appId: 'com.example.app',
|
|
87
|
+
package: 'com.example.app',
|
|
88
|
+
activity: 'com.example.MainActivity',
|
|
89
|
+
screen: 'MainActivity',
|
|
90
|
+
matchedTarget: true
|
|
91
|
+
}
|
|
82
92
|
} as any
|
|
83
93
|
}
|
|
84
94
|
const startAppResponse = await handleToolCall('start_app', { platform: 'android', appId: 'com.example.app' })
|
|
85
95
|
const startAppPayload = JSON.parse((startAppResponse as any).content[0].text)
|
|
86
96
|
assert.strictEqual(startAppPayload.success, true)
|
|
87
97
|
assert.strictEqual(startAppPayload.action_type, 'start_app')
|
|
98
|
+
assert.match(startAppPayload.timestamp, /^\d{4}-\d{2}-\d{2}T/)
|
|
99
|
+
assert.strictEqual(startAppPayload.device.id, 'emulator-5554')
|
|
88
100
|
assert.deepStrictEqual(startAppPayload.target.selector, { appId: 'com.example.app' })
|
|
101
|
+
assert.strictEqual(startAppPayload.details.launch_time_ms, 123)
|
|
102
|
+
assert.strictEqual(startAppPayload.details.observed_app.matchedTarget, true)
|
|
89
103
|
|
|
90
104
|
;(ToolsInteract as any).expectScreenHandler = async () => ({
|
|
91
105
|
success: true,
|
|
92
106
|
observed_screen: { fingerprint: 'fp_after', screen: 'MainActivity' },
|
|
93
107
|
expected_screen: { fingerprint: 'fp_after', screen: null },
|
|
94
|
-
confidence: 1
|
|
108
|
+
confidence: 1,
|
|
109
|
+
comparison: { basis: 'fingerprint', matched: true, reason: 'observed fingerprint matches expected fingerprint fp_after' }
|
|
95
110
|
})
|
|
96
111
|
|
|
97
112
|
const expectScreenResponse = await handleToolCall('expect_screen', { fingerprint: 'fp_after' })
|
|
98
113
|
const expectScreenPayload = JSON.parse((expectScreenResponse as any).content[0].text)
|
|
99
114
|
assert.strictEqual(expectScreenPayload.success, true)
|
|
100
115
|
assert.strictEqual(expectScreenPayload.confidence, 1)
|
|
116
|
+
assert.strictEqual(expectScreenPayload.comparison.basis, 'fingerprint')
|
|
101
117
|
|
|
102
118
|
;(ToolsInteract as any).expectElementVisibleHandler = async () => ({
|
|
103
119
|
success: true,
|
|
104
120
|
selector: { text: 'Ready' },
|
|
105
121
|
element_id: 'el_ready',
|
|
106
|
-
|
|
122
|
+
expected_condition: 'visible',
|
|
123
|
+
element: { elementId: 'el_ready', text: 'Ready', resource_id: null, accessibility_id: null, class: 'TextView', bounds: [0, 0, 10, 10], index: 0 },
|
|
124
|
+
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 } },
|
|
125
|
+
reason: 'selector is visible'
|
|
107
126
|
})
|
|
108
127
|
|
|
109
128
|
const expectElementResponse = await handleToolCall('expect_element_visible', { selector: { text: 'Ready' } })
|
|
110
129
|
const expectElementPayload = JSON.parse((expectElementResponse as any).content[0].text)
|
|
111
130
|
assert.strictEqual(expectElementPayload.success, true)
|
|
112
131
|
assert.strictEqual(expectElementPayload.element_id, 'el_ready')
|
|
132
|
+
assert.strictEqual(expectElementPayload.expected_condition, 'visible')
|
|
133
|
+
|
|
134
|
+
;(ToolsInteract as any).tapHandler = async () => {
|
|
135
|
+
throw new Error('boom')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const failingTapResponse = await handleToolCall('tap', { platform: 'android', x: 1, y: 2 })
|
|
139
|
+
assert.strictEqual((failingTapResponse as any).content.length, 1)
|
|
140
|
+
const failingTapPayload = JSON.parse((failingTapResponse as any).content[0].text)
|
|
141
|
+
assert.deepStrictEqual(failingTapPayload, {
|
|
142
|
+
error: {
|
|
143
|
+
tool: 'tap',
|
|
144
|
+
message: 'boom'
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
;(ToolsInteract as any).tapHandler = async () => {
|
|
149
|
+
throw { code: 'E_CUSTOM', detail: { field: 'value' } }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const objectTapResponse = await handleToolCall('tap', { platform: 'android', x: 1, y: 2 })
|
|
153
|
+
const objectTapPayload = JSON.parse((objectTapResponse as any).content[0].text)
|
|
154
|
+
assert.strictEqual(objectTapPayload.error.tool, 'tap')
|
|
155
|
+
assert.match(objectTapPayload.error.message, /"code": "E_CUSTOM"/)
|
|
156
|
+
assert.match(objectTapPayload.error.message, /"field": "value"/)
|
|
113
157
|
|
|
114
158
|
;(ToolsObserve as any).captureScreenshotHandler = async () => ({
|
|
115
159
|
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 {
|