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.
@@ -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 action = 'tap';
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
- return {
222
- success: false,
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
- elementId,
234
- action
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
+ }