mobile-debug-mcp 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/interact/classify.js +35 -0
- package/dist/interact/index.js +133 -57
- package/dist/network/index.js +232 -0
- package/dist/server/common.js +66 -0
- package/dist/server/tool-definitions.js +921 -0
- package/dist/server/tool-handlers.js +320 -0
- package/dist/server-core.js +4 -686
- package/docs/CHANGELOG.md +7 -0
- package/docs/tools/TOOLS.md +15 -7
- package/docs/tools/interact.md +270 -107
- package/docs/tools/manage.md +39 -38
- package/docs/tools/observe.md +30 -8
- package/docs/tools/system.md +1 -1
- package/package.json +1 -1
- package/src/interact/classify.ts +64 -0
- package/src/interact/index.ts +186 -58
- package/src/network/index.ts +268 -0
- package/src/server/common.ts +95 -0
- package/src/server/tool-definitions.ts +921 -0
- package/src/server/tool-handlers.ts +365 -0
- package/src/server-core.ts +4 -727
- package/src/types.ts +59 -6
- package/test/unit/interact/classify_action_outcome.test.ts +110 -0
- package/test/unit/interact/expect_tools.test.ts +77 -0
- package/test/unit/interact/tap_element.test.ts +23 -6
- package/test/unit/network/get_network_activity.test.ts +181 -0
- package/test/unit/server/contract.test.ts +26 -0
- package/test/unit/server/response_shapes.test.ts +69 -4
package/src/types.ts
CHANGED
|
@@ -132,14 +132,67 @@ export interface TapResponse {
|
|
|
132
132
|
error?: string;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
export
|
|
135
|
+
export type ActionFailureCode =
|
|
136
|
+
| 'ELEMENT_NOT_FOUND'
|
|
137
|
+
| 'ELEMENT_NOT_INTERACTABLE'
|
|
138
|
+
| 'TIMEOUT'
|
|
139
|
+
| 'NAVIGATION_NO_CHANGE'
|
|
140
|
+
| 'AMBIGUOUS_TARGET'
|
|
141
|
+
| 'STALE_REFERENCE'
|
|
142
|
+
| 'UNKNOWN'
|
|
143
|
+
|
|
144
|
+
export interface ActionTargetResolved {
|
|
145
|
+
elementId: string | null;
|
|
146
|
+
text: string | null;
|
|
147
|
+
resource_id: string | null;
|
|
148
|
+
accessibility_id: string | null;
|
|
149
|
+
class: string | null;
|
|
150
|
+
bounds: [number, number, number, number] | null;
|
|
151
|
+
index: number | null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface ActionExecutionResult {
|
|
155
|
+
action_id: string;
|
|
156
|
+
timestamp: number;
|
|
157
|
+
action_type: string;
|
|
158
|
+
target: {
|
|
159
|
+
selector: Record<string, unknown> | null;
|
|
160
|
+
resolved: ActionTargetResolved | null;
|
|
161
|
+
};
|
|
162
|
+
success: boolean;
|
|
163
|
+
failure_code?: ActionFailureCode;
|
|
164
|
+
retryable?: boolean;
|
|
165
|
+
ui_fingerprint_before?: string | null;
|
|
166
|
+
ui_fingerprint_after?: string | null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface TapElementResponse extends ActionExecutionResult {}
|
|
170
|
+
|
|
171
|
+
export interface ExpectScreenResponse {
|
|
172
|
+
success: boolean;
|
|
173
|
+
observed_screen: {
|
|
174
|
+
fingerprint: string | null;
|
|
175
|
+
screen: string | null;
|
|
176
|
+
};
|
|
177
|
+
expected_screen: {
|
|
178
|
+
fingerprint: string | null;
|
|
179
|
+
screen: string | null;
|
|
180
|
+
};
|
|
181
|
+
confidence: number;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface ExpectElementVisibleResponse {
|
|
136
185
|
success: boolean;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
186
|
+
selector: {
|
|
187
|
+
text?: string;
|
|
188
|
+
resource_id?: string;
|
|
189
|
+
accessibility_id?: string;
|
|
190
|
+
contains?: boolean;
|
|
142
191
|
};
|
|
192
|
+
element_id: string | null;
|
|
193
|
+
element?: ActionTargetResolved | null;
|
|
194
|
+
failure_code?: 'TIMEOUT' | 'ELEMENT_NOT_FOUND' | 'UNKNOWN';
|
|
195
|
+
retryable?: boolean;
|
|
143
196
|
}
|
|
144
197
|
|
|
145
198
|
export interface SwipeResponse {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import { classifyActionOutcome } from '../../../src/interact/classify.js'
|
|
3
|
+
|
|
4
|
+
function run() {
|
|
5
|
+
// Step 1 — uiChanged → success
|
|
6
|
+
{
|
|
7
|
+
const result = classifyActionOutcome({ uiChanged: true })
|
|
8
|
+
assert.strictEqual(result.outcome, 'success')
|
|
9
|
+
assert.ok(result.reasoning.length > 0)
|
|
10
|
+
assert.strictEqual(result.nextAction, undefined)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Step 1 — expectedElementVisible → success
|
|
14
|
+
{
|
|
15
|
+
const result = classifyActionOutcome({ uiChanged: false, expectedElementVisible: true })
|
|
16
|
+
assert.strictEqual(result.outcome, 'success')
|
|
17
|
+
assert.strictEqual(result.reasoning, 'expected element is visible')
|
|
18
|
+
assert.strictEqual(result.nextAction, undefined)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Step 1 — both uiChanged and expectedElementVisible → success
|
|
22
|
+
{
|
|
23
|
+
const result = classifyActionOutcome({ uiChanged: true, expectedElementVisible: true })
|
|
24
|
+
assert.strictEqual(result.outcome, 'success')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Step 2 — UI did not change, networkRequests not yet provided → nextAction required
|
|
28
|
+
{
|
|
29
|
+
const result = classifyActionOutcome({ uiChanged: false })
|
|
30
|
+
assert.strictEqual(result.outcome, 'unknown')
|
|
31
|
+
assert.strictEqual(result.nextAction, 'call_get_network_activity')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Step 2 — explicit null networkRequests → nextAction required
|
|
35
|
+
{
|
|
36
|
+
const result = classifyActionOutcome({ uiChanged: false, expectedElementVisible: null, networkRequests: null })
|
|
37
|
+
assert.strictEqual(result.outcome, 'unknown')
|
|
38
|
+
assert.strictEqual(result.nextAction, 'call_get_network_activity')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Step 3 — failure status → backend_failure
|
|
42
|
+
{
|
|
43
|
+
const result = classifyActionOutcome({
|
|
44
|
+
uiChanged: false,
|
|
45
|
+
networkRequests: [{ endpoint: '/login', status: 'failure' }]
|
|
46
|
+
})
|
|
47
|
+
assert.strictEqual(result.outcome, 'backend_failure')
|
|
48
|
+
assert.ok(result.reasoning.includes('/login'))
|
|
49
|
+
assert.ok(result.reasoning.includes('failure'))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Step 3 — retryable status → backend_failure
|
|
53
|
+
{
|
|
54
|
+
const result = classifyActionOutcome({
|
|
55
|
+
uiChanged: false,
|
|
56
|
+
networkRequests: [
|
|
57
|
+
{ endpoint: '/api/submit', status: 'retryable' },
|
|
58
|
+
{ endpoint: '/api/other', status: 'success' }
|
|
59
|
+
]
|
|
60
|
+
})
|
|
61
|
+
assert.strictEqual(result.outcome, 'backend_failure')
|
|
62
|
+
assert.ok(result.reasoning.includes('/api/submit'))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Step 4 — empty network requests → no_op
|
|
66
|
+
{
|
|
67
|
+
const result = classifyActionOutcome({ uiChanged: false, networkRequests: [] })
|
|
68
|
+
assert.strictEqual(result.outcome, 'no_op')
|
|
69
|
+
assert.ok(result.reasoning.includes('no UI change'))
|
|
70
|
+
assert.ok(result.reasoning.includes('no network activity'))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Step 4 — empty network requests with log errors → no_op with note
|
|
74
|
+
{
|
|
75
|
+
const result = classifyActionOutcome({ uiChanged: false, networkRequests: [], hasLogErrors: true })
|
|
76
|
+
assert.strictEqual(result.outcome, 'no_op')
|
|
77
|
+
assert.ok(result.reasoning.includes('log errors'))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Step 5 — all requests succeeded but UI unchanged → ui_failure
|
|
81
|
+
{
|
|
82
|
+
const result = classifyActionOutcome({
|
|
83
|
+
uiChanged: false,
|
|
84
|
+
networkRequests: [
|
|
85
|
+
{ endpoint: '/api/save', status: 'success' },
|
|
86
|
+
{ endpoint: '/api/refresh', status: 'success' }
|
|
87
|
+
]
|
|
88
|
+
})
|
|
89
|
+
assert.strictEqual(result.outcome, 'ui_failure')
|
|
90
|
+
assert.ok(result.reasoning.includes('network requests succeeded'))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Step 1 takes priority over network signals — success even when failures present
|
|
94
|
+
{
|
|
95
|
+
const result = classifyActionOutcome({
|
|
96
|
+
uiChanged: true,
|
|
97
|
+
networkRequests: [{ endpoint: '/api/log', status: 'failure' }]
|
|
98
|
+
})
|
|
99
|
+
assert.strictEqual(result.outcome, 'success')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log('classify_action_outcome tests passed')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
run()
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error(error)
|
|
109
|
+
process.exit(1)
|
|
110
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import { ToolsInteract } from '../../../src/interact/index.js'
|
|
3
|
+
import * as Observe from '../../../src/observe/index.js'
|
|
4
|
+
|
|
5
|
+
async function run() {
|
|
6
|
+
console.log('Starting expect_* unit tests...')
|
|
7
|
+
const originalGetScreenFingerprintHandler = (Observe as any).ToolsObserve.getScreenFingerprintHandler
|
|
8
|
+
const originalGetCurrentScreenHandler = (Observe as any).ToolsObserve.getCurrentScreenHandler
|
|
9
|
+
const originalWaitForUIHandler = (ToolsInteract as any).waitForUIHandler
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: 'fp_home', activity: 'com.example.HomeActivity' })
|
|
13
|
+
let expectScreen = await ToolsInteract.expectScreenHandler({ platform: 'android', fingerprint: 'fp_home' })
|
|
14
|
+
assert.deepStrictEqual(expectScreen, {
|
|
15
|
+
success: true,
|
|
16
|
+
observed_screen: { fingerprint: 'fp_home', screen: 'com.example.HomeActivity' },
|
|
17
|
+
expected_screen: { fingerprint: 'fp_home', screen: null },
|
|
18
|
+
confidence: 1
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
;(Observe as any).ToolsObserve.getCurrentScreenHandler = async () => ({
|
|
22
|
+
activity: 'com.example.HomeActivity',
|
|
23
|
+
shortActivity: 'HomeActivity'
|
|
24
|
+
})
|
|
25
|
+
expectScreen = await ToolsInteract.expectScreenHandler({ platform: 'android', screen: 'HomeActivity' })
|
|
26
|
+
assert.strictEqual(expectScreen.success, true)
|
|
27
|
+
assert.strictEqual(expectScreen.observed_screen.screen, 'HomeActivity')
|
|
28
|
+
assert.strictEqual(expectScreen.confidence, 1)
|
|
29
|
+
|
|
30
|
+
;(ToolsInteract as any).waitForUIHandler = async () => ({
|
|
31
|
+
status: 'success',
|
|
32
|
+
element: {
|
|
33
|
+
text: 'Ready',
|
|
34
|
+
resource_id: 'rid_ready',
|
|
35
|
+
accessibility_id: null,
|
|
36
|
+
class: 'TextView',
|
|
37
|
+
bounds: [0, 0, 10, 10],
|
|
38
|
+
index: 0,
|
|
39
|
+
elementId: 'el_ready'
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
const expectElementVisible = await ToolsInteract.expectElementVisibleHandler({
|
|
43
|
+
selector: { text: 'Ready' },
|
|
44
|
+
platform: 'android'
|
|
45
|
+
})
|
|
46
|
+
assert.strictEqual(expectElementVisible.success, true)
|
|
47
|
+
assert.strictEqual(expectElementVisible.element_id, 'el_ready')
|
|
48
|
+
assert.strictEqual(expectElementVisible.element?.resource_id, 'rid_ready')
|
|
49
|
+
|
|
50
|
+
;(ToolsInteract as any).waitForUIHandler = async () => ({
|
|
51
|
+
status: 'timeout',
|
|
52
|
+
error: { code: 'ELEMENT_NOT_FOUND', message: 'Condition visible not satisfied within timeout' }
|
|
53
|
+
})
|
|
54
|
+
const timeoutResult = await ToolsInteract.expectElementVisibleHandler({
|
|
55
|
+
selector: { text: 'Missing' },
|
|
56
|
+
platform: 'android'
|
|
57
|
+
})
|
|
58
|
+
assert.deepStrictEqual(timeoutResult, {
|
|
59
|
+
success: false,
|
|
60
|
+
selector: { text: 'Missing' },
|
|
61
|
+
element_id: null,
|
|
62
|
+
failure_code: 'TIMEOUT',
|
|
63
|
+
retryable: true
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
console.log('expect_* unit tests passed')
|
|
67
|
+
} finally {
|
|
68
|
+
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = originalGetScreenFingerprintHandler
|
|
69
|
+
;(Observe as any).ToolsObserve.getCurrentScreenHandler = originalGetCurrentScreenHandler
|
|
70
|
+
;(ToolsInteract as any).waitForUIHandler = originalWaitForUIHandler
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
run().catch((error) => {
|
|
75
|
+
console.error(error)
|
|
76
|
+
process.exit(1)
|
|
77
|
+
})
|
|
@@ -5,11 +5,18 @@ import * as Observe from '../../../src/observe/index.js'
|
|
|
5
5
|
async function run() {
|
|
6
6
|
console.log('Starting tap_element unit tests...')
|
|
7
7
|
const originalGetUITreeHandler = (Observe as any).ToolsObserve.getUITreeHandler
|
|
8
|
+
const originalGetScreenFingerprintHandler = (Observe as any).ToolsObserve.getScreenFingerprintHandler
|
|
8
9
|
const originalTapHandler = (ToolsInteract as any).tapHandler
|
|
9
10
|
const originalComputeElementId = (ToolsInteract as any)._computeElementId
|
|
10
11
|
;(ToolsInteract as any)._resetResolvedUiElementsForTests()
|
|
11
12
|
|
|
12
13
|
try {
|
|
14
|
+
let fingerprintCalls = 0
|
|
15
|
+
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => {
|
|
16
|
+
fingerprintCalls++
|
|
17
|
+
return { fingerprint: 'fp_mock' }
|
|
18
|
+
}
|
|
19
|
+
|
|
13
20
|
;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
|
|
14
21
|
device: { platform: 'android', id: 'mock-device' },
|
|
15
22
|
elements: [
|
|
@@ -34,7 +41,12 @@ async function run() {
|
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
const tapSuccess = await ToolsInteract.tapElementHandler({ elementId: successElementId })
|
|
37
|
-
assert.
|
|
44
|
+
assert.strictEqual(tapSuccess.success, true)
|
|
45
|
+
assert.strictEqual(tapSuccess.action_type, 'tap_element')
|
|
46
|
+
assert.strictEqual(tapSuccess.target.selector?.elementId, successElementId)
|
|
47
|
+
assert.strictEqual(tapSuccess.target.resolved?.elementId, successElementId)
|
|
48
|
+
assert.strictEqual(tapSuccess.ui_fingerprint_before, 'fp_mock')
|
|
49
|
+
assert.strictEqual(tapSuccess.ui_fingerprint_after, 'fp_mock')
|
|
38
50
|
assert.deepStrictEqual(tapped, { platform: 'android', x: 10, y: 10, deviceId: 'mock-device' })
|
|
39
51
|
|
|
40
52
|
;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
|
|
@@ -52,7 +64,8 @@ async function run() {
|
|
|
52
64
|
})
|
|
53
65
|
const hiddenResult = await ToolsInteract.tapElementHandler({ elementId: waitHidden.element.elementId })
|
|
54
66
|
assert.strictEqual(hiddenResult.success, false)
|
|
55
|
-
assert.strictEqual(hiddenResult.
|
|
67
|
+
assert.strictEqual(hiddenResult.failure_code, 'ELEMENT_NOT_INTERACTABLE')
|
|
68
|
+
assert.strictEqual(hiddenResult.retryable, true)
|
|
56
69
|
|
|
57
70
|
;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
|
|
58
71
|
device: { platform: 'android', id: 'mock-device' },
|
|
@@ -69,7 +82,7 @@ async function run() {
|
|
|
69
82
|
})
|
|
70
83
|
const disabledResult = await ToolsInteract.tapElementHandler({ elementId: waitDisabled.element.elementId })
|
|
71
84
|
assert.strictEqual(disabledResult.success, false)
|
|
72
|
-
assert.strictEqual(disabledResult.
|
|
85
|
+
assert.strictEqual(disabledResult.failure_code, 'ELEMENT_NOT_INTERACTABLE')
|
|
73
86
|
|
|
74
87
|
;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
|
|
75
88
|
device: { platform: 'android', id: 'mock-device' },
|
|
@@ -77,7 +90,7 @@ async function run() {
|
|
|
77
90
|
})
|
|
78
91
|
const notFoundResult = await ToolsInteract.tapElementHandler({ elementId: successElementId })
|
|
79
92
|
assert.strictEqual(notFoundResult.success, false)
|
|
80
|
-
assert.strictEqual(notFoundResult.
|
|
93
|
+
assert.strictEqual(notFoundResult.failure_code, 'STALE_REFERENCE')
|
|
81
94
|
|
|
82
95
|
;(ToolsInteract as any)._resetResolvedUiElementsForTests()
|
|
83
96
|
const targetIndex = 25
|
|
@@ -124,7 +137,7 @@ async function run() {
|
|
|
124
137
|
})
|
|
125
138
|
const shiftedIndexResult = await ToolsInteract.tapElementHandler({ elementId: indexedWait.element.elementId })
|
|
126
139
|
assert.strictEqual(shiftedIndexResult.success, false)
|
|
127
|
-
assert.strictEqual(shiftedIndexResult.
|
|
140
|
+
assert.strictEqual(shiftedIndexResult.failure_code, 'STALE_REFERENCE')
|
|
128
141
|
|
|
129
142
|
;(ToolsInteract as any)._resetResolvedUiElementsForTests()
|
|
130
143
|
const cacheLimit = (ToolsInteract as any)._maxResolvedUiElements as number
|
|
@@ -151,15 +164,19 @@ async function run() {
|
|
|
151
164
|
}
|
|
152
165
|
|
|
153
166
|
assert.ok(oldestElementId, 'Oldest element ID should be captured')
|
|
167
|
+
const fingerprintCallsBeforeEvictedTap = fingerprintCalls
|
|
154
168
|
const evictedResult = await ToolsInteract.tapElementHandler({ elementId: oldestElementId as string })
|
|
155
169
|
assert.strictEqual(evictedResult.success, false)
|
|
156
|
-
assert.strictEqual(evictedResult.
|
|
170
|
+
assert.strictEqual(evictedResult.failure_code, 'STALE_REFERENCE')
|
|
171
|
+
assert.strictEqual(evictedResult.ui_fingerprint_before, null)
|
|
172
|
+
assert.strictEqual(fingerprintCalls, fingerprintCallsBeforeEvictedTap)
|
|
157
173
|
|
|
158
174
|
console.log('tap_element unit tests passed')
|
|
159
175
|
} finally {
|
|
160
176
|
;(ToolsInteract as any)._resetResolvedUiElementsForTests()
|
|
161
177
|
;(ToolsInteract as any)._computeElementId = originalComputeElementId
|
|
162
178
|
;(Observe as any).ToolsObserve.getUITreeHandler = originalGetUITreeHandler
|
|
179
|
+
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = originalGetScreenFingerprintHandler
|
|
163
180
|
;(ToolsInteract as any).tapHandler = originalTapHandler
|
|
164
181
|
}
|
|
165
182
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import {
|
|
3
|
+
parseMessageToEvent,
|
|
4
|
+
normalizeEndpoint,
|
|
5
|
+
classifyStatus,
|
|
6
|
+
_setTimestampsForTests,
|
|
7
|
+
ToolsNetwork
|
|
8
|
+
} from '../../../src/network/index.js'
|
|
9
|
+
|
|
10
|
+
function run() {
|
|
11
|
+
|
|
12
|
+
// ─── normalizeEndpoint ──────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
const r = normalizeEndpoint('https://api.example.com/v1/users?page=2')
|
|
16
|
+
assert.strictEqual(r, '/v1/users', `normalizeEndpoint full URL: expected /v1/users, got ${r}`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
const r = normalizeEndpoint('/api/login/')
|
|
21
|
+
assert.strictEqual(r, '/api/login', `normalizeEndpoint path trailing slash: expected /api/login, got ${r}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
const r = normalizeEndpoint('/Api/Login')
|
|
26
|
+
assert.strictEqual(r, '/api/login', `normalizeEndpoint uppercase: expected /api/login, got ${r}`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── classifyStatus ─────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
const s = classifyStatus(200, null)
|
|
33
|
+
assert.strictEqual(s, 'success', `200 should be success`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
const s = classifyStatus(404, null)
|
|
38
|
+
assert.strictEqual(s, 'failure', `404 should be failure`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
const s = classifyStatus(500, null)
|
|
43
|
+
assert.strictEqual(s, 'retryable', `500 should be retryable`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
const s = classifyStatus(null, 'timeout')
|
|
48
|
+
assert.strictEqual(s, 'retryable', `networkError should override to retryable`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
const s = classifyStatus(200, 'connection_reset')
|
|
53
|
+
assert.strictEqual(s, 'retryable', `networkError beats statusCode`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
const s = classifyStatus(null, null)
|
|
58
|
+
assert.strictEqual(s, 'success', `null/null (request detected, no error) = success`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── parseMessageToEvent — emission criteria ─────────────────────────────────
|
|
62
|
+
|
|
63
|
+
// Condition 1: full URL
|
|
64
|
+
{
|
|
65
|
+
const e = parseMessageToEvent('OkHttp: GET https://api.example.com/v1/login 200')
|
|
66
|
+
assert.ok(e !== null, 'full URL line should emit')
|
|
67
|
+
assert.strictEqual(e!.endpoint, '/v1/login')
|
|
68
|
+
assert.strictEqual(e!.method, 'GET')
|
|
69
|
+
assert.strictEqual(e!.statusCode, 200)
|
|
70
|
+
assert.strictEqual(e!.status, 'success')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Condition 2: explicit HTTP status line
|
|
74
|
+
{
|
|
75
|
+
const e = parseMessageToEvent('HTTP/1.1 404 Not Found')
|
|
76
|
+
assert.ok(e !== null, 'HTTP status line should emit')
|
|
77
|
+
assert.strictEqual(e!.statusCode, 404)
|
|
78
|
+
assert.strictEqual(e!.status, 'failure')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Condition 3: method + path
|
|
82
|
+
{
|
|
83
|
+
const e = parseMessageToEvent('Sending POST /api/register HTTP/1.1')
|
|
84
|
+
assert.ok(e !== null, 'method+path line should emit')
|
|
85
|
+
assert.strictEqual(e!.method, 'POST')
|
|
86
|
+
assert.strictEqual(e!.endpoint, '/api/register')
|
|
87
|
+
assert.strictEqual(e!.statusCode, null)
|
|
88
|
+
assert.strictEqual(e!.status, 'success') // no error signal
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// No criteria met — keyword-only noise
|
|
92
|
+
{
|
|
93
|
+
const e = parseMessageToEvent('HTTP connection pool initialised')
|
|
94
|
+
assert.strictEqual(e, null, 'keyword-only line should not emit')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
{
|
|
98
|
+
const e = parseMessageToEvent('Request interceptor registered')
|
|
99
|
+
assert.strictEqual(e, null, 'generic Request line should not emit')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
{
|
|
103
|
+
const e = parseMessageToEvent('Task 200 completed')
|
|
104
|
+
assert.strictEqual(e, null, 'bare status-like numbers should not emit')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
{
|
|
108
|
+
const e = parseMessageToEvent('Response code: 404')
|
|
109
|
+
assert.strictEqual(e, null, 'labeled status without endpoint or HTTP context should not emit')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
const e = parseMessageToEvent('GetBestInfo: /data/app/~~pkg/base.apk status=447')
|
|
114
|
+
assert.strictEqual(e, null, 'filesystem paths should not emit as network endpoints')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
const e = parseMessageToEvent('system/gd/hci/le_address_manager.cc:576 GetNextPrivateAddressIntervalRange')
|
|
119
|
+
assert.strictEqual(e, null, 'source file paths should not emit as network endpoints')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
{
|
|
123
|
+
const e = parseMessageToEvent('status=503 for /api/session/generate')
|
|
124
|
+
assert.ok(e !== null, 'status with plausible endpoint should emit')
|
|
125
|
+
assert.strictEqual(e!.endpoint, '/api/session/generate')
|
|
126
|
+
assert.strictEqual(e!.statusCode, 503)
|
|
127
|
+
assert.strictEqual(e!.status, 'retryable')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Network error detection
|
|
131
|
+
{
|
|
132
|
+
const e = parseMessageToEvent('java.net.SocketTimeoutException: POST /api/data timed out after 30s')
|
|
133
|
+
assert.ok(e !== null, 'timeout error should emit')
|
|
134
|
+
assert.strictEqual(e!.networkError, 'timeout')
|
|
135
|
+
assert.strictEqual(e!.status, 'retryable')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
{
|
|
139
|
+
const e = parseMessageToEvent('SSL handshake failed for https://api.example.com/v1/auth')
|
|
140
|
+
assert.ok(e !== null, 'TLS error should emit')
|
|
141
|
+
assert.strictEqual(e!.networkError, 'tls_error')
|
|
142
|
+
assert.strictEqual(e!.status, 'retryable')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
{
|
|
146
|
+
const e = parseMessageToEvent('DNS resolution failed: GET /api/users')
|
|
147
|
+
assert.ok(e !== null, 'DNS error should emit')
|
|
148
|
+
assert.strictEqual(e!.networkError, 'dns_error')
|
|
149
|
+
assert.strictEqual(e!.status, 'retryable')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 5xx → retryable even without networkError
|
|
153
|
+
{
|
|
154
|
+
const e = parseMessageToEvent('Response 503 for https://api.example.com/v1/data')
|
|
155
|
+
assert.ok(e !== null, '5xx should emit')
|
|
156
|
+
assert.strictEqual(e!.statusCode, 503)
|
|
157
|
+
assert.strictEqual(e!.status, 'retryable')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── lastConsumedTimestamp dedupe ────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
{
|
|
163
|
+
// Simulate: action happened 1000ms ago, last consumed 500ms ago → use consumed
|
|
164
|
+
_setTimestampsForTests(Date.now() - 1000, Date.now() - 500)
|
|
165
|
+
// We can't easily verify the sinceMs value from outside without deep mocking,
|
|
166
|
+
// but we can confirm getNetworkActivity resolves without throwing.
|
|
167
|
+
const promise = ToolsNetwork.getNetworkActivity({ platform: 'android' })
|
|
168
|
+
assert.ok(promise instanceof Promise, 'getNetworkActivity should return a Promise')
|
|
169
|
+
// Allow the promise to settle (logcat may fail in test env — that's fine)
|
|
170
|
+
promise.catch(() => {})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
console.log('get_network_activity tests passed')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
run()
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.error(err)
|
|
180
|
+
process.exit(1)
|
|
181
|
+
}
|
|
@@ -8,6 +8,8 @@ async function run() {
|
|
|
8
8
|
assert.strictEqual(serverInfo.name, 'mobile-debug-mcp')
|
|
9
9
|
assert.strictEqual(names.length, uniqueNames.size, 'tool names should be unique')
|
|
10
10
|
assert(names.includes('wait_for_ui'))
|
|
11
|
+
assert(names.includes('expect_screen'))
|
|
12
|
+
assert(names.includes('expect_element_visible'))
|
|
11
13
|
assert(names.includes('capture_screenshot'))
|
|
12
14
|
assert(names.includes('get_ui_tree'))
|
|
13
15
|
assert(names.includes('tap_element'))
|
|
@@ -16,6 +18,14 @@ async function run() {
|
|
|
16
18
|
assert(waitForUI, 'wait_for_ui should be registered')
|
|
17
19
|
assert.strictEqual((waitForUI as any).inputSchema.properties.timeout_ms.default, 60000)
|
|
18
20
|
assert.strictEqual((waitForUI as any).inputSchema.properties.condition.default, 'exists')
|
|
21
|
+
assert.match((waitForUI as any).description, /resolve elements/i)
|
|
22
|
+
assert.match((waitForUI as any).description, /must not be used alone to confirm action success/i)
|
|
23
|
+
assert.match((waitForUI as any).description, /follow with expect_\*/i)
|
|
24
|
+
|
|
25
|
+
const waitForScreenChange = toolDefinitions.find((tool) => tool.name === 'wait_for_screen_change')
|
|
26
|
+
assert(waitForScreenChange, 'wait_for_screen_change should be registered')
|
|
27
|
+
assert.match((waitForScreenChange as any).description, /does not verify correctness of the resulting state/i)
|
|
28
|
+
assert.match((waitForScreenChange as any).description, /follow with expect_screen/i)
|
|
19
29
|
|
|
20
30
|
const captureDebugSnapshot = toolDefinitions.find((tool) => tool.name === 'capture_debug_snapshot')
|
|
21
31
|
assert(captureDebugSnapshot, 'capture_debug_snapshot should be registered')
|
|
@@ -33,6 +43,22 @@ async function run() {
|
|
|
33
43
|
const tapElement = toolDefinitions.find((tool) => tool.name === 'tap_element')
|
|
34
44
|
assert(tapElement, 'tap_element should be registered')
|
|
35
45
|
assert.deepStrictEqual((tapElement as any).inputSchema.required, ['elementId'])
|
|
46
|
+
assert.match((tapElement as any).description, /RESOLVE → ACT → WAIT \(if needed\) → EXPECT/)
|
|
47
|
+
assert.match((tapElement as any).description, /If needed, wait for transition using wait_for_\*/)
|
|
48
|
+
assert.match((tapElement as any).description, /Verify outcome using expect_\*/)
|
|
49
|
+
|
|
50
|
+
const expectScreen = toolDefinitions.find((tool) => tool.name === 'expect_screen')
|
|
51
|
+
assert(expectScreen, 'expect_screen should be registered')
|
|
52
|
+
assert.match((expectScreen as any).description, /Primary and authoritative verification tool/i)
|
|
53
|
+
assert.match((expectScreen as any).description, /final verification step/i)
|
|
54
|
+
assert.match((expectScreen as any).description, /Returns structured binary success\/failure only/i)
|
|
55
|
+
|
|
56
|
+
const expectElementVisible = toolDefinitions.find((tool) => tool.name === 'expect_element_visible')
|
|
57
|
+
assert(expectElementVisible, 'expect_element_visible should be registered')
|
|
58
|
+
assert.deepStrictEqual((expectElementVisible as any).inputSchema.required, ['selector'])
|
|
59
|
+
assert.match((expectElementVisible as any).description, /Primary and authoritative verification tool/i)
|
|
60
|
+
assert.match((expectElementVisible as any).description, /selector is the primary input/i)
|
|
61
|
+
assert.match((expectElementVisible as any).description, /Returns structured binary success\/failure only/i)
|
|
36
62
|
|
|
37
63
|
await assert.rejects(() => handleToolCall('unknown_tool'), /Unknown tool: unknown_tool/)
|
|
38
64
|
|