mobile-debug-mcp 0.23.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/index.js +133 -57
- 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 -801
- package/docs/CHANGELOG.md +3 -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/index.ts +186 -58
- 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 -844
- package/src/types.ts +59 -6
- package/test/unit/interact/expect_tools.test.ts +77 -0
- package/test/unit/interact/tap_element.test.ts +23 -6
- package/test/unit/server/contract.test.ts +26 -0
- package/test/unit/server/response_shapes.test.ts +69 -4
package/dist/interact/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { iOSInteract } from './ios.js';
|
|
|
4
4
|
export { AndroidInteract, iOSInteract };
|
|
5
5
|
import { resolveTargetDevice } from '../utils/resolve-device.js';
|
|
6
6
|
import { ToolsObserve } from '../observe/index.js';
|
|
7
|
+
import { nextActionId } from '../server/common.js';
|
|
7
8
|
export class ToolsInteract {
|
|
8
9
|
static _maxResolvedUiElements = 256;
|
|
9
10
|
static _resolvedUiElements = new Map();
|
|
@@ -74,6 +75,39 @@ export class ToolsInteract {
|
|
|
74
75
|
ToolsInteract._resolvedUiElements.delete(oldestElementId);
|
|
75
76
|
}
|
|
76
77
|
}
|
|
78
|
+
static async _captureFingerprint(platform, deviceId) {
|
|
79
|
+
try {
|
|
80
|
+
const fingerprint = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId });
|
|
81
|
+
return fingerprint?.fingerprint ?? null;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
static _resolvedTargetFromElement(elementId, element, index) {
|
|
88
|
+
return {
|
|
89
|
+
elementId,
|
|
90
|
+
text: element.text ?? null,
|
|
91
|
+
resource_id: element.resourceId ?? element.resourceID ?? element.id ?? null,
|
|
92
|
+
accessibility_id: element.contentDescription ?? element.contentDesc ?? element.accessibilityLabel ?? element.label ?? null,
|
|
93
|
+
class: element.type ?? element.class ?? null,
|
|
94
|
+
bounds: ToolsInteract._normalizeBounds(element.bounds),
|
|
95
|
+
index
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
static _actionFailure(actionId, timestamp, actionType, selector, resolved, failureCode, retryable, uiFingerprintBefore, uiFingerprintAfter) {
|
|
99
|
+
return {
|
|
100
|
+
action_id: actionId,
|
|
101
|
+
timestamp,
|
|
102
|
+
action_type: actionType,
|
|
103
|
+
target: { selector, resolved },
|
|
104
|
+
success: false,
|
|
105
|
+
failure_code: failureCode,
|
|
106
|
+
retryable,
|
|
107
|
+
ui_fingerprint_before: uiFingerprintBefore,
|
|
108
|
+
ui_fingerprint_after: uiFingerprintAfter
|
|
109
|
+
};
|
|
110
|
+
}
|
|
77
111
|
static _resetResolvedUiElementsForTests() {
|
|
78
112
|
ToolsInteract._resolvedUiElements.clear();
|
|
79
113
|
}
|
|
@@ -151,87 +185,53 @@ export class ToolsInteract {
|
|
|
151
185
|
return await interact.tap(x, y, resolved.id);
|
|
152
186
|
}
|
|
153
187
|
static async tapElementHandler({ elementId }) {
|
|
154
|
-
const
|
|
188
|
+
const timestamp = Date.now();
|
|
189
|
+
const actionType = 'tap_element';
|
|
190
|
+
const actionId = nextActionId(actionType, timestamp);
|
|
191
|
+
const selector = { elementId };
|
|
155
192
|
const resolved = ToolsInteract._resolvedUiElements.get(elementId);
|
|
156
193
|
if (!resolved) {
|
|
157
|
-
return
|
|
158
|
-
success: false,
|
|
159
|
-
elementId,
|
|
160
|
-
action,
|
|
161
|
-
error: {
|
|
162
|
-
code: 'element_not_found',
|
|
163
|
-
message: 'Element ID was not found in the current UI context'
|
|
164
|
-
}
|
|
165
|
-
};
|
|
194
|
+
return ToolsInteract._actionFailure(actionId, timestamp, actionType, selector, null, 'STALE_REFERENCE', true, null);
|
|
166
195
|
}
|
|
196
|
+
const fingerprintBefore = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId);
|
|
167
197
|
const tree = await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId });
|
|
168
198
|
const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : resolved.platform;
|
|
169
199
|
const treeDeviceId = tree?.device?.id || resolved.deviceId;
|
|
170
200
|
const elements = Array.isArray(tree?.elements) ? tree.elements : [];
|
|
171
201
|
const currentMatch = ToolsInteract._findCurrentResolvedElement(elements, treePlatform, treeDeviceId, resolved);
|
|
172
202
|
if (!currentMatch) {
|
|
173
|
-
return
|
|
174
|
-
success: false,
|
|
175
|
-
elementId,
|
|
176
|
-
action,
|
|
177
|
-
error: {
|
|
178
|
-
code: 'element_not_found',
|
|
179
|
-
message: 'Element ID is not present in the current UI context'
|
|
180
|
-
}
|
|
181
|
-
};
|
|
203
|
+
return ToolsInteract._actionFailure(actionId, timestamp, actionType, selector, null, 'STALE_REFERENCE', true, fingerprintBefore);
|
|
182
204
|
}
|
|
205
|
+
const resolvedTarget = ToolsInteract._resolvedTargetFromElement(resolved.elementId, currentMatch.el, currentMatch.index);
|
|
183
206
|
if (!ToolsInteract._isVisibleElement(currentMatch.el)) {
|
|
184
|
-
return
|
|
185
|
-
success: false,
|
|
186
|
-
elementId,
|
|
187
|
-
action,
|
|
188
|
-
error: {
|
|
189
|
-
code: 'element_not_visible',
|
|
190
|
-
message: 'Element is not visible'
|
|
191
|
-
}
|
|
192
|
-
};
|
|
207
|
+
return ToolsInteract._actionFailure(actionId, timestamp, actionType, selector, resolvedTarget, 'ELEMENT_NOT_INTERACTABLE', true, fingerprintBefore);
|
|
193
208
|
}
|
|
194
209
|
if (currentMatch.el.enabled === false) {
|
|
195
|
-
return
|
|
196
|
-
success: false,
|
|
197
|
-
elementId,
|
|
198
|
-
action,
|
|
199
|
-
error: {
|
|
200
|
-
code: 'element_not_enabled',
|
|
201
|
-
message: 'Element is not enabled'
|
|
202
|
-
}
|
|
203
|
-
};
|
|
210
|
+
return ToolsInteract._actionFailure(actionId, timestamp, actionType, selector, resolvedTarget, 'ELEMENT_NOT_INTERACTABLE', true, fingerprintBefore);
|
|
204
211
|
}
|
|
205
212
|
const bounds = ToolsInteract._normalizeBounds(currentMatch.el.bounds) ?? resolved.bounds;
|
|
206
213
|
if (!bounds || bounds[2] <= bounds[0] || bounds[3] <= bounds[1]) {
|
|
207
|
-
return
|
|
208
|
-
success: false,
|
|
209
|
-
elementId,
|
|
210
|
-
action,
|
|
211
|
-
error: {
|
|
212
|
-
code: 'element_not_visible',
|
|
213
|
-
message: 'Element does not have valid visible bounds'
|
|
214
|
-
}
|
|
215
|
-
};
|
|
214
|
+
return ToolsInteract._actionFailure(actionId, timestamp, actionType, selector, resolvedTarget, 'ELEMENT_NOT_INTERACTABLE', true, fingerprintBefore);
|
|
216
215
|
}
|
|
217
216
|
const x = Math.floor((bounds[0] + bounds[2]) / 2);
|
|
218
217
|
const y = Math.floor((bounds[1] + bounds[3]) / 2);
|
|
219
218
|
const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId });
|
|
220
219
|
if (!tapResult.success) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
elementId,
|
|
224
|
-
action,
|
|
225
|
-
error: {
|
|
226
|
-
code: 'tap_failed',
|
|
227
|
-
message: tapResult.error || 'Tap failed'
|
|
228
|
-
}
|
|
229
|
-
};
|
|
220
|
+
const fingerprintAfterFailure = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId);
|
|
221
|
+
return ToolsInteract._actionFailure(actionId, timestamp, actionType, selector, resolvedTarget, 'UNKNOWN', false, fingerprintBefore, fingerprintAfterFailure);
|
|
230
222
|
}
|
|
223
|
+
const fingerprintAfter = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId);
|
|
231
224
|
return {
|
|
225
|
+
action_id: actionId,
|
|
226
|
+
timestamp,
|
|
227
|
+
action_type: actionType,
|
|
228
|
+
target: {
|
|
229
|
+
selector,
|
|
230
|
+
resolved: resolvedTarget
|
|
231
|
+
},
|
|
232
232
|
success: true,
|
|
233
|
-
|
|
234
|
-
|
|
233
|
+
ui_fingerprint_before: fingerprintBefore,
|
|
234
|
+
ui_fingerprint_after: fingerprintAfter
|
|
235
235
|
};
|
|
236
236
|
}
|
|
237
237
|
static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }) {
|
|
@@ -670,6 +670,82 @@ export class ToolsInteract {
|
|
|
670
670
|
}
|
|
671
671
|
return { success: false, reason: 'timeout', lastFingerprint, elapsedMs: Date.now() - start };
|
|
672
672
|
}
|
|
673
|
+
static async expectScreenHandler({ platform, fingerprint, screen, deviceId }) {
|
|
674
|
+
const observedFingerprint = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId });
|
|
675
|
+
const observedScreen = {
|
|
676
|
+
fingerprint: observedFingerprint?.fingerprint ?? null,
|
|
677
|
+
screen: observedFingerprint?.activity ?? null
|
|
678
|
+
};
|
|
679
|
+
let observedScreenLabel = observedScreen.screen;
|
|
680
|
+
if (!fingerprint && screen && platform !== 'ios') {
|
|
681
|
+
try {
|
|
682
|
+
const current = await ToolsObserve.getCurrentScreenHandler({ deviceId });
|
|
683
|
+
observedScreenLabel = current?.shortActivity || current?.activity || observedScreenLabel;
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
// Keep fingerprint-derived activity when current-screen lookup is unavailable.
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
const expectedScreen = {
|
|
690
|
+
fingerprint: fingerprint ?? null,
|
|
691
|
+
screen: screen ?? null
|
|
692
|
+
};
|
|
693
|
+
let success = false;
|
|
694
|
+
if (fingerprint) {
|
|
695
|
+
success = observedScreen.fingerprint === fingerprint;
|
|
696
|
+
}
|
|
697
|
+
else if (screen) {
|
|
698
|
+
const candidates = new Set();
|
|
699
|
+
if (observedScreen.screen)
|
|
700
|
+
candidates.add(observedScreen.screen);
|
|
701
|
+
if (observedScreenLabel)
|
|
702
|
+
candidates.add(observedScreenLabel);
|
|
703
|
+
success = candidates.has(screen);
|
|
704
|
+
}
|
|
705
|
+
return {
|
|
706
|
+
success,
|
|
707
|
+
observed_screen: {
|
|
708
|
+
fingerprint: observedScreen.fingerprint,
|
|
709
|
+
screen: observedScreenLabel
|
|
710
|
+
},
|
|
711
|
+
expected_screen: expectedScreen,
|
|
712
|
+
confidence: success ? 1 : 0
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
static async expectElementVisibleHandler({ selector, element_id, timeout_ms = 5000, poll_interval_ms = 300, platform, deviceId }) {
|
|
716
|
+
const result = await ToolsInteract.waitForUIHandler({
|
|
717
|
+
selector,
|
|
718
|
+
condition: 'visible',
|
|
719
|
+
timeout_ms,
|
|
720
|
+
poll_interval_ms,
|
|
721
|
+
platform,
|
|
722
|
+
deviceId
|
|
723
|
+
});
|
|
724
|
+
if (result?.status === 'success' && result?.element) {
|
|
725
|
+
return {
|
|
726
|
+
success: true,
|
|
727
|
+
selector,
|
|
728
|
+
element_id: result.element.elementId ?? element_id ?? null,
|
|
729
|
+
element: {
|
|
730
|
+
elementId: result.element.elementId ?? null,
|
|
731
|
+
text: result.element.text ?? null,
|
|
732
|
+
resource_id: result.element.resource_id ?? null,
|
|
733
|
+
accessibility_id: result.element.accessibility_id ?? null,
|
|
734
|
+
class: result.element.class ?? null,
|
|
735
|
+
bounds: result.element.bounds ?? null,
|
|
736
|
+
index: typeof result.element.index === 'number' ? result.element.index : null
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
const errorCode = result?.error?.code === 'INTERNAL_ERROR' ? 'UNKNOWN' : 'TIMEOUT';
|
|
741
|
+
return {
|
|
742
|
+
success: false,
|
|
743
|
+
selector,
|
|
744
|
+
element_id: element_id ?? null,
|
|
745
|
+
failure_code: errorCode,
|
|
746
|
+
retryable: errorCode === 'TIMEOUT'
|
|
747
|
+
};
|
|
748
|
+
}
|
|
673
749
|
static async waitForUICore({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }) {
|
|
674
750
|
const start = Date.now();
|
|
675
751
|
const deadline = start + (timeoutMs || 0);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ToolsObserve } from '../observe/index.js';
|
|
2
|
+
export function wrapResponse(data) {
|
|
3
|
+
return {
|
|
4
|
+
content: [{
|
|
5
|
+
type: 'text',
|
|
6
|
+
text: JSON.stringify(data, null, 2)
|
|
7
|
+
}]
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
let actionSequence = 0;
|
|
11
|
+
export function nextActionId(actionType, timestamp) {
|
|
12
|
+
actionSequence += 1;
|
|
13
|
+
return `${actionType}_${timestamp}_${actionSequence}`;
|
|
14
|
+
}
|
|
15
|
+
export async function captureActionFingerprint(platform, deviceId) {
|
|
16
|
+
if (!platform)
|
|
17
|
+
return null;
|
|
18
|
+
try {
|
|
19
|
+
const result = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId });
|
|
20
|
+
return result?.fingerprint ?? null;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function normalizeResolvedTarget(value = null) {
|
|
27
|
+
if (!value)
|
|
28
|
+
return null;
|
|
29
|
+
return {
|
|
30
|
+
elementId: value.elementId ?? null,
|
|
31
|
+
text: value.text ?? null,
|
|
32
|
+
resource_id: value.resource_id ?? null,
|
|
33
|
+
accessibility_id: value.accessibility_id ?? null,
|
|
34
|
+
class: value.class ?? null,
|
|
35
|
+
bounds: value.bounds ?? null,
|
|
36
|
+
index: value.index ?? null
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function inferGenericFailure(message) {
|
|
40
|
+
if (message && /timeout/i.test(message))
|
|
41
|
+
return { failureCode: 'TIMEOUT', retryable: true };
|
|
42
|
+
return { failureCode: 'UNKNOWN', retryable: false };
|
|
43
|
+
}
|
|
44
|
+
export function inferScrollFailure(message) {
|
|
45
|
+
if (message && /unchanged|no change|end of list/i.test(message))
|
|
46
|
+
return { failureCode: 'NAVIGATION_NO_CHANGE', retryable: true };
|
|
47
|
+
if (message && /timeout/i.test(message))
|
|
48
|
+
return { failureCode: 'TIMEOUT', retryable: true };
|
|
49
|
+
return { failureCode: 'UNKNOWN', retryable: false };
|
|
50
|
+
}
|
|
51
|
+
export function buildActionExecutionResult({ actionType, selector, resolved, success, uiFingerprintBefore, uiFingerprintAfter, failure }) {
|
|
52
|
+
const timestamp = Date.now();
|
|
53
|
+
return {
|
|
54
|
+
action_id: nextActionId(actionType, timestamp),
|
|
55
|
+
timestamp,
|
|
56
|
+
action_type: actionType,
|
|
57
|
+
target: {
|
|
58
|
+
selector,
|
|
59
|
+
resolved: normalizeResolvedTarget(resolved)
|
|
60
|
+
},
|
|
61
|
+
success,
|
|
62
|
+
...(failure ? { failure_code: failure.failureCode, retryable: failure.retryable } : {}),
|
|
63
|
+
ui_fingerprint_before: uiFingerprintBefore,
|
|
64
|
+
ui_fingerprint_after: uiFingerprintAfter
|
|
65
|
+
};
|
|
66
|
+
}
|