mobile-debug-mcp 0.21.4 → 0.22.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/AGENTS.md +74 -0
- package/README.md +24 -5
- package/dist/interact/index.js +263 -41
- package/dist/observe/ios.js +10 -3
- package/dist/server-core.js +707 -0
- package/dist/server.js +6 -693
- package/dist/utils/resolve-device.js +15 -3
- package/docs/CHANGELOG.md +9 -1
- package/docs/tools/interact.md +69 -30
- package/package.json +3 -3
- package/skills/README.md +35 -0
- package/skills/test-authoring/SKILL.md +57 -0
- package/skills/test-authoring/references/repo-test-layout.md +47 -0
- package/skills/test-authoring/references/test-authoring-workflow.md +73 -0
- package/skills/test-authoring/references/test-quality-checklist.md +39 -0
- package/src/interact/index.ts +286 -38
- package/src/observe/ios.ts +12 -3
- package/src/server-core.ts +762 -0
- package/src/server.ts +8 -754
- package/src/types.ts +10 -1
- package/src/utils/resolve-device.ts +19 -3
- package/test/device/automated/observe/capture_screenshot.android.smoke.ts +30 -0
- package/test/device/automated/observe/capture_screenshot.ios.smoke.ts +30 -0
- package/test/{observe/device → device/automated/observe}/get_logs.android.smoke.ts +1 -1
- package/test/{observe/device → device/automated/observe}/get_logs.ios.smoke.ts +1 -1
- package/test/device/automated/observe/get_ui_tree.android.smoke.ts +31 -0
- package/test/device/automated/observe/get_ui_tree.ios.smoke.ts +31 -0
- package/test/device/index.ts +52 -0
- package/test/{interact/device/smoke-test.ts → device/manual/interact/app_lifecycle.manual.ts} +5 -5
- package/test/{manage/device/run-build-install-ios.ts → device/manual/manage/build_install_ios.manual.ts} +1 -1
- package/test/{manage/device → device/manual/manage}/install.integration.ts +6 -6
- package/test/{manage/device/run-install-android.ts → device/manual/manage/install_android.manual.ts} +1 -1
- package/test/{manage/device/run-install-ios.ts → device/manual/manage/install_ios.manual.ts} +1 -1
- package/test/device/manual/observe/capture_screenshot.manual.ts +29 -0
- package/test/{helpers/run-get-logs.ts → device/manual/observe/get_logs.manual.ts} +1 -1
- package/test/device/manual/observe/get_ui_tree.manual.ts +29 -0
- package/test/{observe/device/logstream-real.ts → device/manual/observe/logstream.manual.ts} +1 -1
- package/test/{observe/device/run-screen-fingerprint.ts → device/manual/observe/screen_fingerprint.manual.ts} +1 -1
- package/test/{observe/device/run-scroll-test-android.ts → device/manual/observe/scroll_to_element_android.manual.ts} +1 -1
- package/test/{observe/device/test-ui-tree.ts → device/manual/observe/ui_tree.manual.ts} +6 -6
- package/test/unit/index.ts +47 -27
- package/test/unit/interact/handler_shapes.test.ts +55 -0
- package/test/unit/interact/tap_element.test.ts +170 -0
- package/test/unit/interact/wait_for_screen_change.test.ts +34 -0
- package/test/{interact/unit → unit/interact}/wait_for_ui_contract.test.ts +11 -10
- package/test/unit/interact/wait_for_ui_selector_matching.test.ts +76 -0
- package/test/unit/manage/handler_shapes.test.ts +43 -0
- package/test/{observe/unit → unit/observe}/capture_debug_snapshot.test.ts +5 -1
- package/test/{observe/unit → unit/observe}/find_element.test.ts +12 -6
- package/test/unit/observe/get_screen_fingerprint.test.ts +71 -0
- package/test/unit/observe/ios-getlogs.test.ts +53 -0
- package/test/unit/observe/scroll_to_element.test.ts +127 -0
- package/test/unit/server/contract.test.ts +45 -0
- package/test/unit/server/response_shapes.test.ts +93 -0
- package/test/unit/system/adb_version.test.ts +35 -0
- package/test/unit/system/get_system_status.test.ts +20 -0
- package/test/unit/system/system_status.test.ts +141 -0
- package/test/{utils → unit/utils}/detect_java.test.ts +1 -1
- package/test/unit/utils/exec.test.ts +51 -0
- package/test/unit/utils/resolve_device.test.ts +63 -0
- package/tsconfig.json +2 -2
- package/test/interact/device/run-real-test.ts +0 -3
- package/test/interact/unit/wait_for_screen_change.test.ts +0 -32
- package/test/interact/unit/wait_for_ui.test.ts +0 -76
- package/test/interact/unit/wait_for_ui_new.test.ts +0 -57
- package/test/observe/device/wait_for_element_real.ts +0 -3
- package/test/observe/unit/get_screen_fingerprint.test.ts +0 -69
- package/test/observe/unit/ios-getlogs.test.ts +0 -67
- package/test/observe/unit/scroll_to_element.test.ts +0 -129
- package/test/observe/unit/wait_for_element_mock.ts +0 -2
- package/test/observe/unit/wait_for_ui_edge_cases.test.ts +0 -41
- package/test/observe/unit/wait_for_ui_stability.test.ts +0 -30
- package/test/system/adb_version.test.ts +0 -25
- package/test/system/get_system_status.test.ts +0 -52
- package/test/system/system_status.test.ts +0 -109
- /package/test/{manage/unit → unit/manage}/build.test.ts +0 -0
- /package/test/{manage/unit → unit/manage}/build_and_install.test.ts +0 -0
- /package/test/{manage/unit → unit/manage}/detection.test.ts +0 -0
- /package/test/{manage/unit → unit/manage}/diagnostics.test.ts +0 -0
- /package/test/{manage/unit → unit/manage}/install.test.ts +0 -0
- /package/test/{manage/unit → unit/manage}/mcp_disable_autodetect.test.ts +0 -0
- /package/test/{observe/unit → unit/observe}/get_logs.test.ts +0 -0
- /package/test/{observe/unit → unit/observe}/logparse.test.ts +0 -0
- /package/test/{observe/unit → unit/observe}/logstream.test.ts +0 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
This file is the **cold-start entrypoint for autonomous agents** that arrive in this public repository without prior context.
|
|
4
|
+
|
|
5
|
+
It is intentionally vendor-neutral and should be useful to Copilot, Codex, Claude, or custom agent systems.
|
|
6
|
+
|
|
7
|
+
## What this repository is
|
|
8
|
+
|
|
9
|
+
`mobile-debug-mcp` is an MCP server for mobile app debugging on Android and iOS. The codebase is TypeScript and the built server entrypoint is `dist/server.js`.
|
|
10
|
+
|
|
11
|
+
Primary areas:
|
|
12
|
+
|
|
13
|
+
- `src/server.ts` — runtime entrypoint
|
|
14
|
+
- `src/server-core.ts` — tool registration and dispatch logic
|
|
15
|
+
- `src/interact/` — tap, swipe, typing, waits, and interaction helpers
|
|
16
|
+
- `src/observe/` — logs, screenshots, UI tree, fingerprints, snapshots
|
|
17
|
+
- `src/manage/` — build, install, start, terminate, restart, reset-app-data
|
|
18
|
+
- `src/system/` — environment and toolchain health checks
|
|
19
|
+
- `src/utils/` — shared helpers and platform-specific utility code
|
|
20
|
+
|
|
21
|
+
## First things an agent should know
|
|
22
|
+
|
|
23
|
+
1. Prefer existing helpers and conventions over introducing new frameworks or abstractions.
|
|
24
|
+
2. The repository already has a test structure; place tests in the correct tree instead of inventing new ones.
|
|
25
|
+
3. Use existing validation commands:
|
|
26
|
+
- `npm run build`
|
|
27
|
+
- `npm run lint`
|
|
28
|
+
- `npm run test:unit`
|
|
29
|
+
- `npm run test:device`
|
|
30
|
+
4. Device tests are split intentionally:
|
|
31
|
+
- `test/device/automated/...` = automated smoke/integration coverage
|
|
32
|
+
- `test/device/manual/...` = helper/debug/manual scripts
|
|
33
|
+
5. Unit tests use the current lightweight self-running `tsx` pattern rather than a dedicated framework.
|
|
34
|
+
|
|
35
|
+
## Where to look for deeper guidance
|
|
36
|
+
|
|
37
|
+
### Skills
|
|
38
|
+
|
|
39
|
+
Portable agent skills live under `skills/`.
|
|
40
|
+
|
|
41
|
+
- `skills/README.md` — repo-wide skill convention
|
|
42
|
+
- `skills/mcp-builder/` — build/install/toolchain guidance
|
|
43
|
+
- `skills/test-authoring/` — test creation and placement guidance
|
|
44
|
+
|
|
45
|
+
If the task is about **creating or updating tests**, load `skills/test-authoring/SKILL.md` first.
|
|
46
|
+
|
|
47
|
+
If the task is about **building, installing, or diagnosing native tooling**, load `skills/mcp-builder/SKILL.md` first.
|
|
48
|
+
|
|
49
|
+
### Repository docs
|
|
50
|
+
|
|
51
|
+
- `README.md` — high-level repo overview and commands
|
|
52
|
+
- `docs/tools/TOOLS.md` — tool documentation and contracts
|
|
53
|
+
- `docs/CHANGELOG.md` — recent behavioral changes
|
|
54
|
+
|
|
55
|
+
## Testing guidance
|
|
56
|
+
|
|
57
|
+
Use these defaults unless the task clearly requires something else:
|
|
58
|
+
|
|
59
|
+
- Add automated unit tests under `test/unit/...`
|
|
60
|
+
- Add automated device smoke tests under `test/device/automated/...`
|
|
61
|
+
- Add ad hoc or environment-specific helper scripts under `test/device/manual/...`
|
|
62
|
+
|
|
63
|
+
For test authoring details, rely on the `test-authoring` skill package rather than duplicating its rules here.
|
|
64
|
+
|
|
65
|
+
## Output expectations for agents
|
|
66
|
+
|
|
67
|
+
- Make focused changes
|
|
68
|
+
- Preserve existing repo behavior unless the task requires a change
|
|
69
|
+
- Keep docs aligned when structure or usage changes
|
|
70
|
+
- Prefer deterministic validation over ad hoc verification
|
|
71
|
+
|
|
72
|
+
## Notes for maintainers
|
|
73
|
+
|
|
74
|
+
This file is intentionally short. Keep task-specific guidance in `skills/...` so multiple agent systems can reuse the same instructions.
|
package/README.md
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
A minimal, secure MCP server for AI-assisted mobile development. Build, install, and inspect Android/iOS apps from an MCP-compatible client.
|
|
4
4
|
|
|
5
|
-
> **
|
|
6
|
-
> *
|
|
5
|
+
> **Support:**
|
|
6
|
+
> * Android support
|
|
7
|
+
> * iOS only tested on simulator
|
|
8
|
+
> * KMP support
|
|
7
9
|
> * Flutter iOS projects not fetching logs
|
|
8
10
|
> * React native not tested
|
|
9
11
|
|
|
@@ -32,15 +34,32 @@ You will need to add ADB_PATH for Android and XCRUN_PATH and IDB_PATH for iOS.
|
|
|
32
34
|
## Usage
|
|
33
35
|
|
|
34
36
|
Examples:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
|
|
38
|
+
Crash fixing:
|
|
39
|
+
> I have a crash on the app, can you diagnose it, fix and validate using the mcp tools available
|
|
40
|
+
|
|
41
|
+
Feature building:
|
|
42
|
+
> Add a button, hook into the repository and confirm API request successful
|
|
37
43
|
|
|
38
44
|
## Docs
|
|
39
45
|
|
|
40
46
|
- Tools: [Tools](docs/tools/TOOLS.md) — full input/response examples
|
|
41
47
|
- Changelog: [Changelog](docs/CHANGELOG.md)
|
|
48
|
+
- Agents: [AGENTS.md](AGENTS.md) — cold-start guidance for autonomous agents entering the public repo
|
|
49
|
+
- Skills: [skills/README.md](skills/README.md) — portable Markdown skill packages for agents such as Copilot, Codex, Claude, or custom systems
|
|
50
|
+
|
|
51
|
+
## Testing
|
|
52
|
+
|
|
53
|
+
- `npm run test:unit` runs every automated unit test under `test/unit/...`
|
|
54
|
+
- `npm run test:device` runs the automated device smoke checks under `test/device/automated/...`
|
|
55
|
+
- Manual and debug-oriented device scripts live under `test/device/manual/...` and are not part of the default test commands
|
|
56
|
+
|
|
57
|
+
## Agent skills
|
|
58
|
+
|
|
59
|
+
- `skills/mcp-builder/` contains reusable build/install guidance for agents
|
|
60
|
+
- `skills/test-authoring/` contains reusable test-creation guidance aligned to this repo's current test structure
|
|
61
|
+
- Skills are written as plain Markdown packages so they can be consumed by different agent systems rather than one vendor-specific runtime
|
|
42
62
|
|
|
43
63
|
## License
|
|
44
64
|
|
|
45
65
|
MIT
|
|
46
|
-
|
package/dist/interact/index.js
CHANGED
|
@@ -1,9 +1,145 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
1
2
|
import { AndroidInteract } from './android.js';
|
|
2
3
|
import { iOSInteract } from './ios.js';
|
|
3
4
|
export { AndroidInteract, iOSInteract };
|
|
4
5
|
import { resolveTargetDevice } from '../utils/resolve-device.js';
|
|
5
6
|
import { ToolsObserve } from '../observe/index.js';
|
|
6
7
|
export class ToolsInteract {
|
|
8
|
+
static _maxResolvedUiElements = 256;
|
|
9
|
+
static _resolvedUiElements = new Map();
|
|
10
|
+
static _normalize(s) {
|
|
11
|
+
if (s === null || s === undefined)
|
|
12
|
+
return '';
|
|
13
|
+
try {
|
|
14
|
+
return String(s).toLowerCase().trim();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
static _normalizeBounds(bounds) {
|
|
21
|
+
if (!Array.isArray(bounds) || bounds.length < 4)
|
|
22
|
+
return null;
|
|
23
|
+
const normalized = bounds.slice(0, 4).map((value) => Number(value));
|
|
24
|
+
if (normalized.some((value) => Number.isNaN(value)))
|
|
25
|
+
return null;
|
|
26
|
+
return normalized;
|
|
27
|
+
}
|
|
28
|
+
static _isVisibleElement(el) {
|
|
29
|
+
const bounds = ToolsInteract._normalizeBounds(el.bounds);
|
|
30
|
+
return !!el.visible && !!bounds && bounds[2] > bounds[0] && bounds[3] > bounds[1];
|
|
31
|
+
}
|
|
32
|
+
static _computeElementId(platform, deviceId, el, index) {
|
|
33
|
+
const identity = {
|
|
34
|
+
platform,
|
|
35
|
+
deviceId: deviceId || '',
|
|
36
|
+
text: ToolsInteract._normalize(el.text ?? el.label ?? el.value ?? ''),
|
|
37
|
+
resourceId: ToolsInteract._normalize(el.resourceId ?? el.resourceID ?? el.id ?? ''),
|
|
38
|
+
accessibilityId: ToolsInteract._normalize(el.contentDescription ?? el.contentDesc ?? el.accessibilityLabel ?? el.label ?? ''),
|
|
39
|
+
class: ToolsInteract._normalize(el.type ?? el.class ?? ''),
|
|
40
|
+
bounds: ToolsInteract._normalizeBounds(el.bounds) ?? [0, 0, 0, 0],
|
|
41
|
+
index
|
|
42
|
+
};
|
|
43
|
+
return `el_${createHash('sha1').update(JSON.stringify(identity)).digest('hex').slice(0, 24)}`;
|
|
44
|
+
}
|
|
45
|
+
static _buildResolvedElement(platform, deviceId, el, index) {
|
|
46
|
+
const bounds = ToolsInteract._normalizeBounds(el.bounds);
|
|
47
|
+
const elementId = ToolsInteract._computeElementId(platform, deviceId, el, index);
|
|
48
|
+
ToolsInteract._rememberResolvedElement(elementId, {
|
|
49
|
+
elementId,
|
|
50
|
+
platform,
|
|
51
|
+
deviceId,
|
|
52
|
+
bounds,
|
|
53
|
+
index
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
text: el.text ?? null,
|
|
57
|
+
resource_id: el.resourceId ?? el.resourceID ?? el.id ?? null,
|
|
58
|
+
accessibility_id: el.contentDescription ?? el.contentDesc ?? el.accessibilityLabel ?? el.label ?? null,
|
|
59
|
+
class: el.type ?? el.class ?? null,
|
|
60
|
+
bounds,
|
|
61
|
+
index,
|
|
62
|
+
elementId
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
static _rememberResolvedElement(elementId, context) {
|
|
66
|
+
if (ToolsInteract._resolvedUiElements.has(elementId)) {
|
|
67
|
+
ToolsInteract._resolvedUiElements.delete(elementId);
|
|
68
|
+
}
|
|
69
|
+
ToolsInteract._resolvedUiElements.set(elementId, context);
|
|
70
|
+
while (ToolsInteract._resolvedUiElements.size > ToolsInteract._maxResolvedUiElements) {
|
|
71
|
+
const oldestElementId = ToolsInteract._resolvedUiElements.keys().next().value;
|
|
72
|
+
if (!oldestElementId)
|
|
73
|
+
break;
|
|
74
|
+
ToolsInteract._resolvedUiElements.delete(oldestElementId);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
static _resetResolvedUiElementsForTests() {
|
|
78
|
+
ToolsInteract._resolvedUiElements.clear();
|
|
79
|
+
}
|
|
80
|
+
static _findCurrentResolvedElement(elements, platform, deviceId, resolved) {
|
|
81
|
+
const indexedCandidate = elements[resolved.index];
|
|
82
|
+
if (indexedCandidate && ToolsInteract._computeElementId(platform, deviceId, indexedCandidate, resolved.index) === resolved.elementId) {
|
|
83
|
+
return { el: indexedCandidate, index: resolved.index };
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
static _resolveActionableAncestor(elements, chosen) {
|
|
88
|
+
if (!chosen)
|
|
89
|
+
return null;
|
|
90
|
+
if (chosen.el.clickable || chosen.el.focusable)
|
|
91
|
+
return chosen;
|
|
92
|
+
let current = chosen;
|
|
93
|
+
let safety = 0;
|
|
94
|
+
while (safety < 20 && current.el && !(current.el.clickable || current.el.focusable) && current.el.parentId !== undefined && current.el.parentId !== null) {
|
|
95
|
+
const parentId = current.el.parentId;
|
|
96
|
+
let parentIndex = null;
|
|
97
|
+
if (typeof parentId === 'number')
|
|
98
|
+
parentIndex = parentId;
|
|
99
|
+
else if (typeof parentId === 'string' && /^\d+$/.test(parentId))
|
|
100
|
+
parentIndex = Number(parentId);
|
|
101
|
+
if (parentIndex !== null && elements[parentIndex]) {
|
|
102
|
+
current = { el: elements[parentIndex], idx: parentIndex };
|
|
103
|
+
if (current.el.clickable || current.el.focusable)
|
|
104
|
+
return current;
|
|
105
|
+
}
|
|
106
|
+
else if (typeof parentId === 'string') {
|
|
107
|
+
const foundIndex = elements.findIndex((el) => el.resourceId === parentId || el.id === parentId);
|
|
108
|
+
if (foundIndex === -1)
|
|
109
|
+
break;
|
|
110
|
+
current = { el: elements[foundIndex], idx: foundIndex };
|
|
111
|
+
if (current.el.clickable || current.el.focusable)
|
|
112
|
+
return current;
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
safety++;
|
|
118
|
+
}
|
|
119
|
+
const childBounds = ToolsInteract._normalizeBounds(chosen.el.bounds);
|
|
120
|
+
if (!childBounds)
|
|
121
|
+
return null;
|
|
122
|
+
const [cl, ct, cr, cb] = childBounds;
|
|
123
|
+
let best = null;
|
|
124
|
+
let bestArea = Infinity;
|
|
125
|
+
for (let i = 0; i < elements.length; i++) {
|
|
126
|
+
const el = elements[i];
|
|
127
|
+
if (!el || !(el.clickable || el.focusable))
|
|
128
|
+
continue;
|
|
129
|
+
const bounds = ToolsInteract._normalizeBounds(el.bounds);
|
|
130
|
+
if (!bounds)
|
|
131
|
+
continue;
|
|
132
|
+
const [pl, pt, pr, pb] = bounds;
|
|
133
|
+
if (pl <= cl && pt <= ct && pr >= cr && pb >= cb) {
|
|
134
|
+
const area = (pr - pl) * (pb - pt);
|
|
135
|
+
if (area < bestArea) {
|
|
136
|
+
bestArea = area;
|
|
137
|
+
best = { el, idx: i };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return best;
|
|
142
|
+
}
|
|
7
143
|
static async getInteractionService(platform, deviceId) {
|
|
8
144
|
const effectivePlatform = platform || 'android';
|
|
9
145
|
const resolved = await resolveTargetDevice({ platform: effectivePlatform, deviceId });
|
|
@@ -14,6 +150,90 @@ export class ToolsInteract {
|
|
|
14
150
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
|
|
15
151
|
return await interact.tap(x, y, resolved.id);
|
|
16
152
|
}
|
|
153
|
+
static async tapElementHandler({ elementId }) {
|
|
154
|
+
const action = 'tap';
|
|
155
|
+
const resolved = ToolsInteract._resolvedUiElements.get(elementId);
|
|
156
|
+
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
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const tree = await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId });
|
|
168
|
+
const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : resolved.platform;
|
|
169
|
+
const treeDeviceId = tree?.device?.id || resolved.deviceId;
|
|
170
|
+
const elements = Array.isArray(tree?.elements) ? tree.elements : [];
|
|
171
|
+
const currentMatch = ToolsInteract._findCurrentResolvedElement(elements, treePlatform, treeDeviceId, resolved);
|
|
172
|
+
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
|
+
};
|
|
182
|
+
}
|
|
183
|
+
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
|
+
};
|
|
193
|
+
}
|
|
194
|
+
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
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const bounds = ToolsInteract._normalizeBounds(currentMatch.el.bounds) ?? resolved.bounds;
|
|
206
|
+
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
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const x = Math.floor((bounds[0] + bounds[2]) / 2);
|
|
218
|
+
const y = Math.floor((bounds[1] + bounds[3]) / 2);
|
|
219
|
+
const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId });
|
|
220
|
+
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
|
+
};
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
success: true,
|
|
233
|
+
elementId,
|
|
234
|
+
action
|
|
235
|
+
};
|
|
236
|
+
}
|
|
17
237
|
static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }) {
|
|
18
238
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
|
|
19
239
|
return await interact.swipe(x1, y1, x2, y2, duration, resolved.id);
|
|
@@ -34,7 +254,7 @@ export class ToolsInteract {
|
|
|
34
254
|
// Try to use observe layer to fetch the current UI tree and perform a fast semantic search
|
|
35
255
|
const start = Date.now();
|
|
36
256
|
const deadline = start + timeoutMs;
|
|
37
|
-
const normalize =
|
|
257
|
+
const normalize = ToolsInteract._normalize;
|
|
38
258
|
const q = normalize(query);
|
|
39
259
|
if (!q)
|
|
40
260
|
return { found: false, error: 'Empty query' };
|
|
@@ -217,15 +437,19 @@ export class ToolsInteract {
|
|
|
217
437
|
}
|
|
218
438
|
static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId }) {
|
|
219
439
|
const overallStart = Date.now();
|
|
220
|
-
// Validate selector: require at least one
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const hasText = selector && typeof selector.text === 'string' && selector.text.trim().length > 0;
|
|
225
|
-
const hasResId = selector && typeof selector.resource_id === 'string' && selector.resource_id.trim().length > 0;
|
|
226
|
-
const hasAccId = selector && typeof selector.accessibility_id === 'string' && selector.accessibility_id.trim().length > 0;
|
|
440
|
+
// Validate selector: require at least one non-empty field (text, resource_id, or accessibility_id)
|
|
441
|
+
const hasText = typeof selector?.text === 'string' && selector.text.trim().length > 0;
|
|
442
|
+
const hasResId = typeof selector?.resource_id === 'string' && selector.resource_id.trim().length > 0;
|
|
443
|
+
const hasAccId = typeof selector?.accessibility_id === 'string' && selector.accessibility_id.trim().length > 0;
|
|
227
444
|
if (!hasText && !hasResId && !hasAccId) {
|
|
228
|
-
return {
|
|
445
|
+
return {
|
|
446
|
+
status: 'timeout',
|
|
447
|
+
error: {
|
|
448
|
+
code: 'INVALID_SELECTOR',
|
|
449
|
+
message: 'Selector must include at least one non-empty field: text, resource_id, or accessibility_id'
|
|
450
|
+
},
|
|
451
|
+
metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 }
|
|
452
|
+
};
|
|
229
453
|
}
|
|
230
454
|
// Validate condition
|
|
231
455
|
if (!['exists', 'not_exists', 'visible', 'clickable'].includes(condition)) {
|
|
@@ -241,11 +465,11 @@ export class ToolsInteract {
|
|
|
241
465
|
let attempts = 0;
|
|
242
466
|
let totalPollCount = 0;
|
|
243
467
|
// Precompute normalized selector values and helpers (constant across polls)
|
|
244
|
-
const normalize =
|
|
245
|
-
const containsFlag = !!selector
|
|
246
|
-
const selText = normalize(selector
|
|
247
|
-
const selRid = normalize(selector
|
|
248
|
-
const selAid = normalize(selector
|
|
468
|
+
const normalize = ToolsInteract._normalize;
|
|
469
|
+
const containsFlag = !!selector?.contains;
|
|
470
|
+
const selText = normalize(selector?.text);
|
|
471
|
+
const selRid = normalize(selector?.resource_id);
|
|
472
|
+
const selAid = normalize(selector?.accessibility_id);
|
|
249
473
|
try {
|
|
250
474
|
while (attempts < maxAttempts) {
|
|
251
475
|
attempts++;
|
|
@@ -301,30 +525,32 @@ export class ToolsInteract {
|
|
|
301
525
|
}
|
|
302
526
|
// Evaluate condition
|
|
303
527
|
const matchedCount = matches.length;
|
|
304
|
-
const
|
|
305
|
-
const pickIndex = pickIndexProvided ? Number(match.index) : 0;
|
|
528
|
+
const pickIndex = (typeof match?.index === 'number') ? match.index : undefined;
|
|
306
529
|
let chosen = null;
|
|
307
|
-
if (matches.length
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
530
|
+
if (matches.length > 0) {
|
|
531
|
+
if (pickIndex !== undefined) {
|
|
532
|
+
// If a specific index is requested but out of bounds, treat as not matched for this poll (deterministic)
|
|
533
|
+
if (pickIndex >= 0 && pickIndex < matches.length)
|
|
534
|
+
chosen = matches[pickIndex];
|
|
535
|
+
else
|
|
536
|
+
chosen = null;
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
chosen = matches[0];
|
|
540
|
+
}
|
|
316
541
|
}
|
|
317
542
|
else {
|
|
318
|
-
chosen =
|
|
543
|
+
chosen = null;
|
|
319
544
|
}
|
|
320
545
|
let conditionMet = false;
|
|
546
|
+
let matchedElement = chosen;
|
|
321
547
|
if (condition === 'exists') {
|
|
322
548
|
// when an index is specified, existence requires that specific index be present
|
|
323
|
-
conditionMet =
|
|
549
|
+
conditionMet = (pickIndex !== undefined) ? (chosen !== null) : (matchedCount >= 1);
|
|
324
550
|
}
|
|
325
551
|
else if (condition === 'not_exists') {
|
|
326
552
|
// when an index is specified, not_exists is true if that index is absent
|
|
327
|
-
conditionMet =
|
|
553
|
+
conditionMet = (pickIndex !== undefined) ? (chosen === null) : (matchedCount === 0);
|
|
328
554
|
}
|
|
329
555
|
else if (condition === 'visible') {
|
|
330
556
|
if (chosen) {
|
|
@@ -336,11 +562,12 @@ export class ToolsInteract {
|
|
|
336
562
|
conditionMet = false;
|
|
337
563
|
}
|
|
338
564
|
else if (condition === 'clickable') {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
const
|
|
343
|
-
const
|
|
565
|
+
matchedElement = chosen ? (ToolsInteract._resolveActionableAncestor(elements, chosen) || chosen) : null;
|
|
566
|
+
if (matchedElement) {
|
|
567
|
+
const b = matchedElement.el.bounds;
|
|
568
|
+
const visibleFlag = !!matchedElement.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1]);
|
|
569
|
+
const enabled = !!matchedElement.el.enabled;
|
|
570
|
+
const clickable = !!matchedElement.el.clickable || !!matchedElement.el._interactable || !!matchedElement.el.focusable;
|
|
344
571
|
conditionMet = visibleFlag && enabled && clickable;
|
|
345
572
|
}
|
|
346
573
|
else
|
|
@@ -350,14 +577,9 @@ export class ToolsInteract {
|
|
|
350
577
|
const now = Date.now();
|
|
351
578
|
const latency_ms = now - overallStart;
|
|
352
579
|
// Build element output per spec
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
accessibility_id: chosen.el.contentDescription ?? chosen.el.contentDesc ?? chosen.el.accessibilityLabel ?? chosen.el.label ?? null,
|
|
357
|
-
class: chosen.el.type ?? chosen.el.class ?? null,
|
|
358
|
-
bounds: Array.isArray(chosen.el.bounds) && chosen.el.bounds.length >= 4 ? chosen.el.bounds : null,
|
|
359
|
-
index: chosen.idx
|
|
360
|
-
} : null;
|
|
580
|
+
const resolvedPlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android');
|
|
581
|
+
const resolvedDeviceId = tree?.device?.id || deviceId;
|
|
582
|
+
const outEl = matchedElement ? ToolsInteract._buildResolvedElement(resolvedPlatform, resolvedDeviceId, matchedElement.el, matchedElement.idx) : null;
|
|
361
583
|
return {
|
|
362
584
|
status: 'success',
|
|
363
585
|
matched: matchedCount,
|
package/dist/observe/ios.js
CHANGED
|
@@ -7,6 +7,13 @@ import { parseLogLine } from '../utils/android/utils.js';
|
|
|
7
7
|
import { computeScreenFingerprint } from '../utils/ui/index.js';
|
|
8
8
|
import { parsePngSize } from '../utils/image.js';
|
|
9
9
|
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
10
|
+
let iosExecCommand = execCommand;
|
|
11
|
+
export function _setIOSExecCommandForTests(fn) {
|
|
12
|
+
iosExecCommand = fn;
|
|
13
|
+
}
|
|
14
|
+
export function _resetIOSExecCommandForTests() {
|
|
15
|
+
iosExecCommand = execCommand;
|
|
16
|
+
}
|
|
10
17
|
function parseIDBFrame(frame) {
|
|
11
18
|
if (!frame)
|
|
12
19
|
return [0, 0, 0, 0];
|
|
@@ -122,7 +129,7 @@ export class iOSObserve {
|
|
|
122
129
|
const parts = appId.split('.');
|
|
123
130
|
const simpleName = parts[parts.length - 1];
|
|
124
131
|
try {
|
|
125
|
-
const pgrepRes = await
|
|
132
|
+
const pgrepRes = await iosExecCommand(['simctl', 'spawn', deviceId, 'pgrep', '-f', simpleName], deviceId);
|
|
126
133
|
const out = pgrepRes && pgrepRes.output ? pgrepRes.output.trim() : '';
|
|
127
134
|
const firstLine = out.split(/\r?\n/).find(Boolean);
|
|
128
135
|
if (firstLine) {
|
|
@@ -136,7 +143,7 @@ export class iOSObserve {
|
|
|
136
143
|
}
|
|
137
144
|
}
|
|
138
145
|
const effectivePid = pid || detectedPid || null;
|
|
139
|
-
const result = await
|
|
146
|
+
const result = await iosExecCommand(args, deviceId);
|
|
140
147
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
141
148
|
const rawLines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : [];
|
|
142
149
|
// Parse lines into structured entries. iOS log format: timestamp [PID:tid] <level> subsystem:category: message
|
|
@@ -243,7 +250,7 @@ export class iOSObserve {
|
|
|
243
250
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
244
251
|
const tmpFile = `/tmp/mcp-ios-screenshot-${Date.now()}.png`;
|
|
245
252
|
try {
|
|
246
|
-
await
|
|
253
|
+
await iosExecCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId);
|
|
247
254
|
const buffer = await fs.readFile(tmpFile);
|
|
248
255
|
const base64 = buffer.toString('base64');
|
|
249
256
|
const dims = parsePngSize(buffer);
|