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.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Pure deterministic classifier. Applies rules in fixed order.
3
+ * Same inputs always produce the same output.
4
+ */
5
+ export function classifyActionOutcome(input) {
6
+ const { uiChanged, expectedElementVisible, networkRequests, hasLogErrors } = input;
7
+ // Step 1 — UI signal is positive
8
+ if (uiChanged || expectedElementVisible === true) {
9
+ return { outcome: 'success', reasoning: expectedElementVisible === true ? 'expected element is visible' : 'UI changed after action' };
10
+ }
11
+ // Step 2 — UI did not change; network signal is required
12
+ if (networkRequests === null || networkRequests === undefined) {
13
+ return {
14
+ outcome: 'unknown',
15
+ reasoning: 'UI did not change; get_network_activity must be called before classification can proceed',
16
+ nextAction: 'call_get_network_activity'
17
+ };
18
+ }
19
+ // Step 3 — any network failure
20
+ const failedRequest = networkRequests.find((r) => r.status === 'failure' || r.status === 'retryable');
21
+ if (failedRequest) {
22
+ return { outcome: 'backend_failure', reasoning: `network request ${failedRequest.endpoint} returned ${failedRequest.status}` };
23
+ }
24
+ // Step 4 — no network requests at all
25
+ if (networkRequests.length === 0) {
26
+ const logNote = hasLogErrors ? ' (log errors present)' : '';
27
+ return { outcome: 'no_op', reasoning: `no UI change and no network activity${logNote}` };
28
+ }
29
+ // Step 5 — network requests exist and all succeeded
30
+ if (networkRequests.every((r) => r.status === 'success')) {
31
+ return { outcome: 'ui_failure', reasoning: 'network requests succeeded but UI did not change' };
32
+ }
33
+ // Step 6 — fallback
34
+ return { outcome: 'unknown', reasoning: 'signals are inconclusive' };
35
+ }
@@ -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,232 @@
1
+ import { execAdb, parseLogLine } from '../utils/android/utils.js';
2
+ import { execCommand } from '../utils/ios/utils.js';
3
+ // ─── Module state ─────────────────────────────────────────────────────────────
4
+ // lastActionTimestamp: set when an action tool fires (tap, swipe, etc.)
5
+ // lastConsumedTimestamp: advanced after each get_network_activity call to prevent duplicates
6
+ let lastActionTimestamp = 0;
7
+ let lastConsumedTimestamp = 0;
8
+ export function notifyActionStart() {
9
+ lastActionTimestamp = Date.now();
10
+ lastConsumedTimestamp = 0;
11
+ }
12
+ /** Exposed for unit tests only. */
13
+ export function _setTimestampsForTests(actionTs, consumedTs) {
14
+ lastActionTimestamp = actionTs;
15
+ lastConsumedTimestamp = consumedTs;
16
+ }
17
+ // ─── Parsing constants ────────────────────────────────────────────────────────
18
+ const URL_RE = /https?:\/\/[^\s"'\]\)><]+/;
19
+ const PATH_RE = /\/[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)+/;
20
+ const METHOD_RE = /\b(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b/;
21
+ const NETWORK_ERROR_PATTERNS = [
22
+ { re: /timed?\s*out|timeout/i, code: 'timeout' },
23
+ { re: /dns|name[\s_]resolution|host\s*not\s*found|nodename/i, code: 'dns_error' },
24
+ { re: /\btls\b|\bssl\b|certificate|handshake/i, code: 'tls_error' },
25
+ { re: /connection\s*refused/i, code: 'connection_refused' },
26
+ { re: /connection\s*reset|reset\s*by\s*peer/i, code: 'connection_reset' },
27
+ ];
28
+ const BACKGROUND_TOKENS = ['/analytics', '/metrics', '/tracking', '/log', '/events', '/telemetry', '/ping', '/beacon'];
29
+ const BACKGROUND_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.css', '.js', '.svg', '.ico', '.woff', '.ttf'];
30
+ const FILESYSTEM_PREFIXES = ['/data/', '/system/', '/apex/', '/proc/', '/dev/', '/vendor/', '/product/', '/storage/', '/sdcard/', '/mnt/', '/odm/', '/cache/', '/metadata/', '/acct/', '/sys/'];
31
+ const FILESYSTEM_EXTENSIONS = ['.apk', '.apex', '.odex', '.vdex', '.dex', '.so', '.jar', '.bin', '.img', '.db', '.sqlite', '.c', '.cc', '.cpp', '.cxx', '.h', '.hpp', '.m', '.mm', '.kt', '.java', '.swift'];
32
+ // ─── Parsing helpers ─────────────────────────────────────────────────────────
33
+ function extractUrl(text) {
34
+ const m = text.match(URL_RE);
35
+ return m ? m[0] : null;
36
+ }
37
+ function isPlausibleEndpointPath(path) {
38
+ const lower = path.toLowerCase();
39
+ if (!lower.startsWith('/'))
40
+ return false;
41
+ if (FILESYSTEM_PREFIXES.some((prefix) => lower.startsWith(prefix)))
42
+ return false;
43
+ if (FILESYSTEM_EXTENSIONS.some((ext) => lower.endsWith(ext)))
44
+ return false;
45
+ return true;
46
+ }
47
+ function extractPath(text) {
48
+ const m = text.match(PATH_RE);
49
+ if (!m)
50
+ return null;
51
+ return isPlausibleEndpointPath(m[0]) ? m[0] : null;
52
+ }
53
+ function toStatusCode(value) {
54
+ if (!value)
55
+ return null;
56
+ const code = Number(value);
57
+ return code >= 100 && code <= 599 ? code : null;
58
+ }
59
+ function escapeRegExp(value) {
60
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
61
+ }
62
+ function extractStatusCode(text, url, path, method) {
63
+ const directHttpMatch = text.match(/\bHTTP\/\d(?:\.\d)?\s+([1-5]\d{2})\b/i) || text.match(/\bHTTP\s+([1-5]\d{2})\b/i);
64
+ if (directHttpMatch)
65
+ return toStatusCode(directHttpMatch[1]);
66
+ const endpointToken = url || path;
67
+ const hasEndpointContext = endpointToken !== null;
68
+ if (!hasEndpointContext && method === null)
69
+ return null;
70
+ const labeledMatch = text.match(/\b(?:status(?:\s*code)?|response(?:\s*code)?)\s*[:=]?\s*([1-5]\d{2})\b/i);
71
+ if (labeledMatch && hasEndpointContext)
72
+ return toStatusCode(labeledMatch[1]);
73
+ if (endpointToken) {
74
+ const escapedEndpoint = escapeRegExp(endpointToken);
75
+ const endpointThenCode = new RegExp(`${escapedEndpoint}[^\\n]*?\\b([1-5]\\d{2})\\b`, 'i');
76
+ const codeThenEndpoint = new RegExp(`\\b([1-5]\\d{2})\\b[^\\n]*?${escapedEndpoint}`, 'i');
77
+ const contextualMatch = text.match(endpointThenCode) || text.match(codeThenEndpoint);
78
+ if (contextualMatch)
79
+ return toStatusCode(contextualMatch[1]);
80
+ }
81
+ if (method !== null && path !== null) {
82
+ const methodPathCodeMatch = text.match(/\b(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b[^\n]*?\b([1-5]\d{2})\b/i);
83
+ if (methodPathCodeMatch)
84
+ return toStatusCode(methodPathCodeMatch[1]);
85
+ }
86
+ return null;
87
+ }
88
+ function extractMethod(text) {
89
+ const m = text.match(METHOD_RE);
90
+ return m ? m[1] : null;
91
+ }
92
+ function detectNetworkError(text) {
93
+ for (const { re, code } of NETWORK_ERROR_PATTERNS) {
94
+ if (re.test(text))
95
+ return code;
96
+ }
97
+ return null;
98
+ }
99
+ export function normalizeEndpoint(raw) {
100
+ try {
101
+ const u = new URL(raw.startsWith('/') ? `https://x${raw}` : raw);
102
+ const p = u.pathname.toLowerCase().replace(/\/+$/, '');
103
+ return p || '/';
104
+ }
105
+ catch {
106
+ return raw.toLowerCase().replace(/\?.*$/, '').replace(/\/+$/, '') || '/';
107
+ }
108
+ }
109
+ export function classifyStatus(statusCode, networkError) {
110
+ if (networkError !== null)
111
+ return 'retryable';
112
+ if (statusCode === null)
113
+ return 'success'; // request detected, no failure signal
114
+ if (statusCode >= 200 && statusCode <= 299)
115
+ return 'success';
116
+ if (statusCode >= 400 && statusCode <= 499)
117
+ return 'failure';
118
+ return 'retryable'; // 5xx, 1xx, 3xx
119
+ }
120
+ function meetsEmissionCriteria(url, path, statusCode, method) {
121
+ if (url !== null)
122
+ return true; // condition 1: full http/https URL
123
+ if (statusCode !== null)
124
+ return true; // condition 2: valid HTTP status code
125
+ if (method !== null && path !== null)
126
+ return true; // condition 3: method + path
127
+ return false;
128
+ }
129
+ function classifyEventType(endpoint) {
130
+ const lower = endpoint.toLowerCase();
131
+ if (BACKGROUND_TOKENS.some(t => lower.includes(t)))
132
+ return 'background';
133
+ if (BACKGROUND_EXTENSIONS.some(e => lower.endsWith(e)))
134
+ return 'background';
135
+ return 'primary';
136
+ }
137
+ function filterToSignificantEvents(events) {
138
+ if (events.length === 0)
139
+ return events;
140
+ const hasPrimary = events.some(e => classifyEventType(e.endpoint) === 'primary');
141
+ return hasPrimary ? events.filter(e => classifyEventType(e.endpoint) === 'primary') : events;
142
+ }
143
+ /** Exported for unit testing. */
144
+ export function parseMessageToEvent(message) {
145
+ const url = extractUrl(message);
146
+ const path = url ? null : extractPath(message);
147
+ const method = extractMethod(message);
148
+ const statusCode = extractStatusCode(message, url, path, method);
149
+ const networkError = detectNetworkError(message);
150
+ if (!meetsEmissionCriteria(url, path, statusCode, method))
151
+ return null;
152
+ const rawEndpoint = url || path || 'unknown';
153
+ return {
154
+ endpoint: normalizeEndpoint(rawEndpoint),
155
+ method: method || 'unknown',
156
+ statusCode,
157
+ networkError,
158
+ status: classifyStatus(statusCode, networkError),
159
+ durationMs: 0
160
+ };
161
+ }
162
+ // ─── Android ─────────────────────────────────────────────────────────────────
163
+ async function getAndroidEvents(sinceMs, deviceId) {
164
+ try {
165
+ const stdout = await execAdb(['logcat', '-d', '-v', 'threadtime', '*:V', '-t', '2000'], deviceId);
166
+ const lines = stdout ? stdout.split(/\r?\n/).filter(Boolean) : [];
167
+ const events = [];
168
+ for (const line of lines) {
169
+ const parsed = parseLogLine(line);
170
+ if (parsed._iso) {
171
+ const ts = new Date(parsed._iso).getTime();
172
+ if (ts > 0 && ts <= sinceMs)
173
+ continue;
174
+ }
175
+ const event = parseMessageToEvent(parsed.message || line);
176
+ if (event)
177
+ events.push(event);
178
+ }
179
+ return events;
180
+ }
181
+ catch {
182
+ return [];
183
+ }
184
+ }
185
+ // ─── iOS ─────────────────────────────────────────────────────────────────────
186
+ async function getIOSEvents(sinceMs, deviceId = 'booted') {
187
+ try {
188
+ const lookbackSeconds = Math.max(15, Math.ceil((Date.now() - sinceMs) / 1000) + 5);
189
+ const args = [
190
+ 'simctl', 'spawn', deviceId, 'log', 'show',
191
+ '--last', `${lookbackSeconds}s`,
192
+ '--style', 'syslog',
193
+ '--predicate', 'eventMessage contains "http" OR eventMessage contains "URLSession" OR eventMessage contains "Task <" OR eventMessage contains "HTTP/"'
194
+ ];
195
+ const result = await execCommand(args, deviceId);
196
+ const lines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : [];
197
+ const events = [];
198
+ for (const line of lines) {
199
+ const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/);
200
+ if (tsMatch) {
201
+ const ts = new Date(tsMatch[1]).getTime();
202
+ if (ts > 0 && ts <= sinceMs)
203
+ continue;
204
+ }
205
+ const event = parseMessageToEvent(line);
206
+ if (event)
207
+ events.push(event);
208
+ }
209
+ return events;
210
+ }
211
+ catch {
212
+ return [];
213
+ }
214
+ }
215
+ // ─── Public API ───────────────────────────────────────────────────────────────
216
+ export class ToolsNetwork {
217
+ static notifyActionStart() {
218
+ notifyActionStart();
219
+ }
220
+ static async getNetworkActivity(params) {
221
+ const { platform, deviceId } = params;
222
+ const sinceMs = lastConsumedTimestamp > lastActionTimestamp
223
+ ? lastConsumedTimestamp
224
+ : lastActionTimestamp > 0 ? lastActionTimestamp : Date.now() - 30000;
225
+ const raw = platform === 'android'
226
+ ? await getAndroidEvents(sinceMs, deviceId)
227
+ : await getIOSEvents(sinceMs, deviceId);
228
+ const requests = filterToSignificantEvents(raw);
229
+ lastConsumedTimestamp = Date.now();
230
+ return { requests, count: requests.length };
231
+ }
232
+ }
@@ -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
+ }