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
|
@@ -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
|
+
}
|
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,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
|
+
}
|