mobile-debug-mcp 0.13.0 → 0.15.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/README.md +2 -2
- package/dist/android/interact.js +13 -1
- package/dist/android/observe.js +13 -0
- package/dist/cli/ios/run-ios-smoke.js +2 -2
- package/dist/cli/ios/run-ios-ui-tree-tap.js +2 -2
- package/dist/interact/android.js +91 -0
- package/dist/interact/index.js +37 -0
- package/dist/interact/ios.js +120 -0
- package/dist/interact/shared/fingerprint.js +72 -0
- package/dist/interact/shared/scroll_to_element.js +98 -0
- package/dist/ios/interact.js +52 -1
- package/dist/ios/observe.js +12 -0
- package/dist/manage/android.js +162 -0
- package/dist/manage/index.js +364 -0
- package/dist/manage/ios.js +353 -0
- package/dist/observe/android.js +351 -0
- package/dist/observe/fingerprint.js +1 -0
- package/dist/observe/index.js +85 -0
- package/dist/observe/ios.js +320 -0
- package/dist/observe/test/device/logstream-real.js +34 -0
- package/dist/observe/test/device/run-screen-fingerprint.js +29 -0
- package/dist/observe/test/device/run-scroll-test-android.js +22 -0
- package/dist/observe/test/device/test-ui-tree.js +67 -0
- package/dist/observe/test/device/wait_for_element_real.js +69 -0
- package/dist/observe/test/unit/get_screen_fingerprint.test.js +54 -0
- package/dist/observe/test/unit/logparse.test.js +39 -0
- package/dist/observe/test/unit/logstream.test.js +41 -0
- package/dist/observe/test/unit/scroll_to_element.test.js +113 -0
- package/dist/observe/test/unit/wait_for_element_mock.js +92 -0
- package/dist/server.js +54 -9
- package/dist/shared/fingerprint.js +72 -0
- package/dist/shared/scroll_to_element.js +98 -0
- package/dist/tools/interact.js +19 -22
- package/dist/tools/manage.js +2 -2
- package/dist/tools/observe.js +45 -43
- package/dist/tools/scroll_to_element.js +98 -0
- package/dist/utils/android/utils.js +429 -0
- package/dist/utils/cli/idb/check-idb.js +84 -0
- package/dist/utils/cli/idb/idb-helper.js +91 -0
- package/dist/utils/cli/idb/install-idb.js +82 -0
- package/dist/utils/cli/ios/preflight-ios.js +155 -0
- package/dist/utils/cli/ios/run-ios-smoke.js +28 -0
- package/dist/utils/cli/ios/run-ios-ui-tree-tap.js +29 -0
- package/dist/utils/diagnostics.js +1 -1
- package/dist/utils/ios/utils.js +301 -0
- package/dist/utils/resolve-device.js +2 -2
- package/docs/CHANGELOG.md +11 -0
- package/docs/tools/TOOLS.md +3 -3
- package/docs/tools/interact.md +31 -0
- package/docs/tools/observe.md +24 -0
- package/package.json +1 -1
- package/src/{android/interact.ts → interact/android.ts} +15 -2
- package/src/interact/index.ts +47 -0
- package/src/{ios/interact.ts → interact/ios.ts} +58 -3
- package/src/interact/shared/fingerprint.ts +73 -0
- package/src/interact/shared/scroll_to_element.ts +110 -0
- package/src/{android/manage.ts → manage/android.ts} +2 -2
- package/src/{tools/manage.ts → manage/index.ts} +7 -4
- package/src/{ios/manage.ts → manage/ios.ts} +1 -1
- package/src/{android/observe.ts → observe/android.ts} +14 -26
- package/src/observe/index.ts +92 -0
- package/src/{ios/observe.ts → observe/ios.ts} +17 -35
- package/src/server.ts +57 -10
- package/src/{android → utils/android}/utils.ts +2 -2
- package/src/{cli → utils/cli}/ios/run-ios-smoke.ts +2 -2
- package/src/{cli → utils/cli}/ios/run-ios-ui-tree-tap.ts +3 -3
- package/src/utils/diagnostics.ts +1 -1
- package/src/{ios → utils/ios}/utils.ts +2 -2
- package/src/utils/resolve-device.ts +2 -2
- package/test/{device/interact → interact/device}/smoke-test.ts +3 -4
- package/test/{device/manage → manage/device}/run-install-android.ts +1 -1
- package/test/{device/manage → manage/device}/run-install-ios.ts +1 -1
- package/test/{device/manage → manage/device}/run-install-kmp.ts +1 -1
- package/test/{unit/manage → manage/unit}/build.test.ts +1 -1
- package/test/{unit/manage → manage/unit}/build_and_install.test.ts +1 -1
- package/test/{unit/manage → manage/unit}/detection.test.ts +1 -1
- package/test/{unit/manage → manage/unit}/diagnostics.test.ts +2 -2
- package/test/{unit/manage → manage/unit}/install.test.ts +1 -1
- package/test/{unit/manage → manage/unit}/mcp_disable_autodetect.test.ts +1 -1
- package/test/{device/observe → observe/device}/logstream-real.ts +1 -1
- package/test/observe/device/run-screen-fingerprint.ts +36 -0
- package/test/observe/device/run-scroll-test-android.ts +24 -0
- package/test/{device/observe → observe/device}/test-ui-tree.ts +2 -2
- package/test/{device/observe → observe/device}/wait_for_element_real.ts +2 -2
- package/test/observe/unit/get_screen_fingerprint.test.ts +69 -0
- package/test/{unit/observe → observe/unit}/logparse.test.ts +1 -1
- package/test/{unit/observe → observe/unit}/logstream.test.ts +1 -1
- package/test/observe/unit/scroll_to_element.test.ts +129 -0
- package/test/{unit/observe → observe/unit}/wait_for_element_mock.ts +3 -3
- package/test/unit/index.ts +12 -11
- package/src/tools/interact.ts +0 -45
- package/src/tools/observe.ts +0 -82
- package/test/device/README.md +0 -49
- package/test/device/index.ts +0 -27
- package/test/device/utils/test-dist.ts +0 -41
- package/test/unit/utils/detect-java.test.ts +0 -22
- /package/src/{cli → utils/cli}/idb/check-idb.ts +0 -0
- /package/src/{cli → utils/cli}/idb/idb-helper.ts +0 -0
- /package/src/{cli → utils/cli}/idb/install-idb.ts +0 -0
- /package/src/{cli → utils/cli}/ios/preflight-ios.ts +0 -0
- /package/test/{device/interact → interact/device}/run-real-test.ts +0 -0
- /package/test/{device/manage → manage/device}/install.integration.ts +0 -0
- /package/test/{device/manage → manage/device}/run-build-install-ios.ts +0 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { execFile, spawn, execSync, spawnSync } from "child_process";
|
|
2
|
+
import { promises as fsPromises } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { makeEnvSnapshot } from '../diagnostics.js';
|
|
5
|
+
export function getXcrunCmd() { return process.env.XCRUN_PATH || 'xcrun'; }
|
|
6
|
+
export function getConfiguredIdbPath() {
|
|
7
|
+
if (process.env.MCP_IDB_PATH)
|
|
8
|
+
return process.env.MCP_IDB_PATH;
|
|
9
|
+
if (process.env.IDB_PATH)
|
|
10
|
+
return process.env.IDB_PATH;
|
|
11
|
+
const cfgPaths = [
|
|
12
|
+
process.env.MCP_CONFIG_PATH || (process.env.HOME ? `${process.env.HOME}/.mcp/config.json` : ''),
|
|
13
|
+
`${process.cwd()}/mcp.config.json`
|
|
14
|
+
];
|
|
15
|
+
try {
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
for (const p of cfgPaths) {
|
|
18
|
+
if (!p)
|
|
19
|
+
continue;
|
|
20
|
+
try {
|
|
21
|
+
if (fs.existsSync(p)) {
|
|
22
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
23
|
+
const json = JSON.parse(raw);
|
|
24
|
+
if (json) {
|
|
25
|
+
if (json.idbPath)
|
|
26
|
+
return json.idbPath;
|
|
27
|
+
if (json.IDB_PATH)
|
|
28
|
+
return json.IDB_PATH;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch { }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch { }
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
export function getIdbCmd() {
|
|
39
|
+
const cfg = getConfiguredIdbPath();
|
|
40
|
+
if (cfg)
|
|
41
|
+
return cfg;
|
|
42
|
+
if (process.env.IDB_PATH)
|
|
43
|
+
return process.env.IDB_PATH;
|
|
44
|
+
try {
|
|
45
|
+
const p = execSync('which idb', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
46
|
+
if (p)
|
|
47
|
+
return p;
|
|
48
|
+
}
|
|
49
|
+
catch { }
|
|
50
|
+
try {
|
|
51
|
+
const p2 = execSync('command -v idb', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
52
|
+
if (p2)
|
|
53
|
+
return p2;
|
|
54
|
+
}
|
|
55
|
+
catch { }
|
|
56
|
+
// check common user locations
|
|
57
|
+
const common = [
|
|
58
|
+
`${process.env.HOME}/Library/Python/3.9/bin/idb`,
|
|
59
|
+
`${process.env.HOME}/Library/Python/3.10/bin/idb`,
|
|
60
|
+
'/opt/homebrew/bin/idb',
|
|
61
|
+
'/usr/local/bin/idb',
|
|
62
|
+
];
|
|
63
|
+
for (const c of common) {
|
|
64
|
+
try {
|
|
65
|
+
execSync(`test -x ${c}`, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
66
|
+
return c;
|
|
67
|
+
}
|
|
68
|
+
catch { }
|
|
69
|
+
}
|
|
70
|
+
return 'idb';
|
|
71
|
+
}
|
|
72
|
+
export async function isIDBInstalled() {
|
|
73
|
+
const cmd = getIdbCmd();
|
|
74
|
+
try {
|
|
75
|
+
execSync(`command -v ${cmd}`, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
try {
|
|
80
|
+
execSync(`${cmd} list-targets --json`, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 });
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Validate bundle ID to prevent any potential injection or invalid characters
|
|
89
|
+
export function validateBundleId(bundleId) {
|
|
90
|
+
if (!bundleId)
|
|
91
|
+
return;
|
|
92
|
+
// Allow alphanumeric, dots, hyphens, and underscores.
|
|
93
|
+
if (!/^[a-zA-Z0-9.\-_]+$/.test(bundleId)) {
|
|
94
|
+
throw new Error(`Invalid Bundle ID: ${bundleId}. Must contain only alphanumeric characters, dots, hyphens, or underscores.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function execCommand(args, deviceId = "booted") {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
// Use spawn for better stream control and consistency with Android implementation
|
|
100
|
+
const child = spawn(getXcrunCmd(), args);
|
|
101
|
+
let stdout = '';
|
|
102
|
+
let stderr = '';
|
|
103
|
+
if (child.stdout) {
|
|
104
|
+
child.stdout.on('data', (data) => {
|
|
105
|
+
stdout += data.toString();
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (child.stderr) {
|
|
109
|
+
child.stderr.on('data', (data) => {
|
|
110
|
+
stderr += data.toString();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000; // env (ms) or default 30s
|
|
114
|
+
const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000; // env (ms) or default 60s
|
|
115
|
+
const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT; // choose appropriate timeout
|
|
116
|
+
const timeout = setTimeout(() => {
|
|
117
|
+
child.kill();
|
|
118
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms: ${getXcrunCmd()} ${args.join(' ')}`));
|
|
119
|
+
}, timeoutMs);
|
|
120
|
+
child.on('close', (code) => {
|
|
121
|
+
clearTimeout(timeout);
|
|
122
|
+
if (code !== 0) {
|
|
123
|
+
reject(new Error(stderr.trim() || `Command failed with code ${code}`));
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
resolve({ output: stdout.trim(), device: { platform: "ios", id: deviceId } });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
child.on('error', (err) => {
|
|
130
|
+
clearTimeout(timeout);
|
|
131
|
+
reject(err);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
export function execCommandWithDiagnostics(args, deviceId = "booted") {
|
|
136
|
+
// Run synchronously to capture stdout/stderr and exitCode reliably for diagnostics
|
|
137
|
+
const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000;
|
|
138
|
+
const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000;
|
|
139
|
+
const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT;
|
|
140
|
+
const res = spawnSync(getXcrunCmd(), args, { encoding: 'utf8', timeout: timeoutMs });
|
|
141
|
+
const runResult = {
|
|
142
|
+
exitCode: typeof res.status === 'number' ? res.status : null,
|
|
143
|
+
stdout: res.stdout || '',
|
|
144
|
+
stderr: res.stderr || '',
|
|
145
|
+
envSnapshot: makeEnvSnapshot(['PATH', 'IDB_PATH', 'JAVA_HOME', 'HOME']),
|
|
146
|
+
command: getXcrunCmd(),
|
|
147
|
+
args,
|
|
148
|
+
deviceId
|
|
149
|
+
};
|
|
150
|
+
if (res.status !== 0) {
|
|
151
|
+
// include suggested fixes for common errors
|
|
152
|
+
const suggested = [];
|
|
153
|
+
if ((runResult.stderr || '').includes('xcodebuild: error')) {
|
|
154
|
+
suggested.push('Ensure the project/workspace path is correct and xcodebuild is installed and accessible.');
|
|
155
|
+
}
|
|
156
|
+
if ((runResult.stderr || '').includes('No such file or directory') || (runResult.stderr || '').includes('not found')) {
|
|
157
|
+
suggested.push('Check that Xcode Command Line Tools are installed and XCRUN_PATH is set if using non-standard location.');
|
|
158
|
+
}
|
|
159
|
+
// Return diagnostics object
|
|
160
|
+
return { runResult: { ...runResult, suggestedFixes: suggested } };
|
|
161
|
+
}
|
|
162
|
+
return { runResult: { ...runResult, suggestedFixes: [] } };
|
|
163
|
+
}
|
|
164
|
+
function parseRuntimeName(runtime) {
|
|
165
|
+
// Example: com.apple.CoreSimulator.SimRuntime.iOS-17-0 -> iOS 17.0
|
|
166
|
+
try {
|
|
167
|
+
const parts = runtime.split('.');
|
|
168
|
+
const lastPart = parts[parts.length - 1]; // e.g. "iOS-17-0"
|
|
169
|
+
// Split by hyphen to separate OS from version numbers
|
|
170
|
+
// e.g. "iOS-17-0" -> ["iOS", "17", "0"]
|
|
171
|
+
const segments = lastPart.split('-');
|
|
172
|
+
if (segments.length > 1) {
|
|
173
|
+
const os = segments[0]; // "iOS"
|
|
174
|
+
const version = segments.slice(1).join('.'); // "17.0"
|
|
175
|
+
return `${os} ${version}`;
|
|
176
|
+
}
|
|
177
|
+
return lastPart;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return runtime;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export async function findAppBundle(dir) {
|
|
184
|
+
const entries = await fsPromises.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
185
|
+
for (const e of entries) {
|
|
186
|
+
const full = path.join(dir, e.name);
|
|
187
|
+
if (e.isDirectory()) {
|
|
188
|
+
if (full.endsWith('.app'))
|
|
189
|
+
return full;
|
|
190
|
+
const found = await findAppBundle(full);
|
|
191
|
+
if (found)
|
|
192
|
+
return found;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
export async function getIOSDeviceMetadata(deviceId = "booted") {
|
|
198
|
+
return new Promise((resolve) => {
|
|
199
|
+
// If deviceId is provided (and not "booted"), attempt to find that device among booted simulators.
|
|
200
|
+
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
|
|
201
|
+
const fallback = {
|
|
202
|
+
platform: "ios",
|
|
203
|
+
id: deviceId,
|
|
204
|
+
osVersion: "Unknown",
|
|
205
|
+
model: "Simulator",
|
|
206
|
+
simulator: true,
|
|
207
|
+
};
|
|
208
|
+
if (err || !stdout) {
|
|
209
|
+
resolve(fallback);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const data = JSON.parse(stdout);
|
|
214
|
+
const devicesMap = data.devices || {};
|
|
215
|
+
for (const runtime in devicesMap) {
|
|
216
|
+
const devices = devicesMap[runtime];
|
|
217
|
+
if (Array.isArray(devices)) {
|
|
218
|
+
for (const device of devices) {
|
|
219
|
+
if (deviceId === "booted" || device.udid === deviceId) {
|
|
220
|
+
resolve({
|
|
221
|
+
platform: "ios",
|
|
222
|
+
id: device.udid,
|
|
223
|
+
osVersion: parseRuntimeName(runtime),
|
|
224
|
+
model: device.name,
|
|
225
|
+
simulator: true,
|
|
226
|
+
});
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
resolve(fallback);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
resolve(fallback);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
export async function listIOSDevices(appId) {
|
|
241
|
+
return new Promise((resolve) => {
|
|
242
|
+
// Query all devices and separately query booted devices to mark them
|
|
243
|
+
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
|
|
244
|
+
if (err || !stdout)
|
|
245
|
+
return resolve([]);
|
|
246
|
+
try {
|
|
247
|
+
const data = JSON.parse(stdout);
|
|
248
|
+
const devicesMap = data.devices || {};
|
|
249
|
+
const out = [];
|
|
250
|
+
const checks = [];
|
|
251
|
+
// Get booted devices set
|
|
252
|
+
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err2, stdout2) => {
|
|
253
|
+
const bootedSet = new Set();
|
|
254
|
+
if (!err2 && stdout2) {
|
|
255
|
+
try {
|
|
256
|
+
const bdata = JSON.parse(stdout2);
|
|
257
|
+
const bmap = bdata.devices || {};
|
|
258
|
+
for (const rt in bmap) {
|
|
259
|
+
const devs = bmap[rt];
|
|
260
|
+
if (Array.isArray(devs))
|
|
261
|
+
for (const d of devs)
|
|
262
|
+
bootedSet.add(d.udid);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch { }
|
|
266
|
+
}
|
|
267
|
+
for (const runtime in devicesMap) {
|
|
268
|
+
const devices = devicesMap[runtime];
|
|
269
|
+
if (Array.isArray(devices)) {
|
|
270
|
+
for (const device of devices) {
|
|
271
|
+
const info = {
|
|
272
|
+
platform: 'ios',
|
|
273
|
+
id: device.udid,
|
|
274
|
+
osVersion: parseRuntimeName(runtime),
|
|
275
|
+
model: device.name,
|
|
276
|
+
simulator: true,
|
|
277
|
+
booted: bootedSet.has(device.udid)
|
|
278
|
+
};
|
|
279
|
+
if (appId) {
|
|
280
|
+
// check if installed
|
|
281
|
+
const p = execCommand(['simctl', 'get_app_container', device.udid, appId, 'data'], device.udid)
|
|
282
|
+
.then(() => { info.appInstalled = true; })
|
|
283
|
+
.catch(() => { info.appInstalled = false; })
|
|
284
|
+
.then(() => { out.push(info); });
|
|
285
|
+
checks.push(p);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
out.push(info);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
Promise.all(checks).then(() => resolve(out)).catch(() => resolve(out));
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
resolve([]);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { listAndroidDevices } from "
|
|
2
|
-
import { listIOSDevices } from "
|
|
1
|
+
import { listAndroidDevices } from "./android/utils.js";
|
|
2
|
+
import { listIOSDevices } from "./ios/utils.js";
|
|
3
3
|
function parseNumericVersion(v) {
|
|
4
4
|
if (!v)
|
|
5
5
|
return 0;
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the **Mobile Debug MCP** project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.15.0]
|
|
6
|
+
- Reorganised repository for cohesion: merged tool handlers into feature entrypoints (src/observe, src/interact, src/manage) and moved platform helpers and CLI tooling into src/utils/{android,ios,cli}.
|
|
7
|
+
- Added computeScreenFingerprint utility used by observe/interact to normalise UI element significance across platforms (fingerprint shared between Android and iOS implementations).
|
|
8
|
+
|
|
9
|
+
## [0.14.0]
|
|
10
|
+
- Added `scroll_to_element` tool: platform-aware helper that scrolls until a UI element matching a selector is visible. Supports Android and iOS with configurable options: direction, maxScrolls, and scrollAmount. Includes unit tests and device runners under `test/device/` for manual E2E validation.
|
|
11
|
+
- Moved scroll logic into platform-specific implementations (`src/android/interact.ts`, `src/ios/interact.ts`) and delegated from `src/tools/interact.ts` to centralise platform behaviour.
|
|
12
|
+
- Fixed iOS `idb` swipe arguments and improved visibility detection by using element bounds and device resolution to avoid treating off-screen elements as visible.
|
|
13
|
+
- Consolidated unit tests for `scroll_to_element` into `test/unit/observe/scroll_to_element.test.ts`, and removed older duplicate test files.
|
|
14
|
+
|
|
15
|
+
|
|
5
16
|
## [0.13.0]
|
|
6
17
|
- Fixed a crash in the `start_app` tool by adding validation to ensure `appId` and `platform` are provided.
|
|
7
18
|
|
package/docs/tools/TOOLS.md
CHANGED
|
@@ -4,8 +4,8 @@ This repository groups tool docs into three areas aligned with the codebase: man
|
|
|
4
4
|
|
|
5
5
|
See:
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
7
|
+
- [mange](manage.md) — build, install and device management tools
|
|
8
|
+
- [observe](observe.md) — logs, screenshots and UI inspection tools
|
|
9
|
+
- [interact](interact.md) — UI interaction tools (tap, swipe, type, wait)
|
|
10
10
|
|
|
11
11
|
For per-tool deep dives, open the linked files above.
|
package/docs/tools/interact.md
CHANGED
|
@@ -41,3 +41,34 @@ Notes:
|
|
|
41
41
|
- swipe: `adb shell input swipe x1 y1 x2 y2 duration`.
|
|
42
42
|
- type_text: `adb shell input text` (spaces encoded as %s) — may fail for special characters.
|
|
43
43
|
- press_back: `adb shell input keyevent 4`.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## scroll_to_element
|
|
48
|
+
|
|
49
|
+
Description:
|
|
50
|
+
- Scrolls the UI until an element matching the provided selector becomes visible, or until a maximum number of scroll attempts is reached.
|
|
51
|
+
- Delegates platform behaviour to Android and iOS implementations for reliable swipes and UI-tree checks.
|
|
52
|
+
|
|
53
|
+
Input example:
|
|
54
|
+
```
|
|
55
|
+
{ "platform": "android", "selector": { "text": "Offscreen Test Element" }, "direction": "down", "maxScrolls": 10, "scrollAmount": 0.7, "deviceId": "emulator-5554" }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Response example (found):
|
|
59
|
+
```
|
|
60
|
+
{ "success": true, "reason": "element_found", "element": { /* element metadata */ }, "scrollsPerformed": 2 }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Response example (failure - unchanged UI):
|
|
64
|
+
```
|
|
65
|
+
{ "success": false, "reason": "ui_unchanged_after_scroll", "scrollsPerformed": 3 }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Notes:
|
|
69
|
+
- Matching is exact on provided selector fields (text, resourceId, contentDesc, className).
|
|
70
|
+
- Visibility check uses element.bounds intersecting the device resolution when available; falls back to the element.visible flag if bounds/resolution are missing.
|
|
71
|
+
- The tool fingerprints the visible UI between scrolls; if the fingerprint doesn't change after a swipe the tool stops early assuming end-of-list.
|
|
72
|
+
- Android swipe uses `adb shell input swipe` with screen percentage coordinates. iOS swipe uses `idb ui swipe` command; note `idb` swipe does not accept a duration argument.
|
|
73
|
+
- Unit tests are located at `test/unit/observe/scroll_to_element.test.ts` and device runners at `test/device/observe/`.
|
|
74
|
+
|
package/docs/tools/observe.md
CHANGED
|
@@ -76,6 +76,30 @@ Response:
|
|
|
76
76
|
|
|
77
77
|
---
|
|
78
78
|
|
|
79
|
+
## get_screen_fingerprint
|
|
80
|
+
Generate a stable fingerprint representing the visible screen. Useful for detecting navigation changes, preventing loops, and synchronisation.
|
|
81
|
+
|
|
82
|
+
Input (optional):
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
{ "platform": "android", "deviceId": "emulator-5554" }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Response:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{ "fingerprint": "<sha256_hex>", "activity": "com.example.app.MainActivity" }
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Notes:
|
|
95
|
+
- Uses get_ui_tree and (on Android) get_current_screen as inputs.
|
|
96
|
+
- Normalises visible, interactable or structurally significant elements (class/type, resourceId, text, contentDesc).
|
|
97
|
+
- Trims and lowercases text, filters out likely dynamic values (timestamps, counters).
|
|
98
|
+
- Sorts deterministically (top-to-bottom, left-to-right) and limits elements to 50.
|
|
99
|
+
- Returns fingerprint: null and an error message if the UI tree or activity cannot be retrieved.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
79
103
|
## start_log_stream / read_log_stream / stop_log_stream
|
|
80
104
|
Start a background adb logcat stream and retrieve parsed NDJSON entries.
|
|
81
105
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { WaitForElementResponse, TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
|
|
2
|
-
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "
|
|
3
|
-
import { AndroidObserve } from "
|
|
2
|
+
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js"
|
|
3
|
+
import { AndroidObserve } from "../observe/index.js"
|
|
4
|
+
import { scrollToElementShared } from "../interact/shared/scroll_to_element.js"
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
export class AndroidInteract {
|
|
@@ -88,4 +89,16 @@ export class AndroidInteract {
|
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
async scrollToElement(selector: { text?: string, resourceId?: string, contentDesc?: string, className?: string }, direction: 'down' | 'up' = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId?: string) {
|
|
93
|
+
return await scrollToElementShared({
|
|
94
|
+
selector,
|
|
95
|
+
direction,
|
|
96
|
+
maxScrolls,
|
|
97
|
+
scrollAmount,
|
|
98
|
+
deviceId,
|
|
99
|
+
fetchTree: async () => await this.observe.getUITree(deviceId),
|
|
100
|
+
swipe: async (x1: number, y1: number, x2: number, y2: number, duration: number, devId?: string) => await this.swipe(x1, y1, x2, y2, duration, devId)
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
91
104
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { AndroidInteract } from './android.js';
|
|
2
|
+
import { iOSInteract } from './ios.js';
|
|
3
|
+
export { AndroidInteract, iOSInteract };
|
|
4
|
+
|
|
5
|
+
import { resolveTargetDevice } from '../utils/resolve-device.js'
|
|
6
|
+
|
|
7
|
+
export class ToolsInteract {
|
|
8
|
+
|
|
9
|
+
private static async getInteractionService(platform?: 'android' | 'ios', deviceId?: string) {
|
|
10
|
+
const effectivePlatform = platform || 'android'
|
|
11
|
+
const resolved = await resolveTargetDevice({ platform: effectivePlatform as 'android' | 'ios', deviceId })
|
|
12
|
+
const interact = effectivePlatform === 'android' ? new AndroidInteract() : new iOSInteract()
|
|
13
|
+
return { interact: interact as any, resolved, platform: effectivePlatform }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static async waitForElementHandler({ platform, text, timeout, deviceId }: { platform: 'android' | 'ios', text: string, timeout?: number, deviceId?: string }) {
|
|
17
|
+
const effectiveTimeout = timeout ?? 10000
|
|
18
|
+
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
|
|
19
|
+
return await interact.waitForElement(text, effectiveTimeout, resolved.id)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static async tapHandler({ platform, x, y, deviceId }: { platform?: 'android' | 'ios', x: number, y: number, deviceId?: string }) {
|
|
23
|
+
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
|
|
24
|
+
return await interact.tap(x, y, resolved.id)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }: { platform?: 'android' | 'ios', x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string }) {
|
|
28
|
+
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
|
|
29
|
+
return await interact.swipe(x1, y1, x2, y2, duration, resolved.id)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static async typeTextHandler({ text, deviceId }: { text: string, deviceId?: string }) {
|
|
33
|
+
const resolved = await resolveTargetDevice({ platform: 'android', deviceId })
|
|
34
|
+
return await new AndroidInteract().typeText(text, resolved.id)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static async pressBackHandler({ deviceId }: { deviceId?: string }) {
|
|
38
|
+
const resolved = await resolveTargetDevice({ platform: 'android', deviceId })
|
|
39
|
+
return await new AndroidInteract().pressBack(resolved.id)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static async scrollToElementHandler({ platform, selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId }: { platform: 'android' | 'ios', selector: { text?: string, resourceId?: string, contentDesc?: string, className?: string }, direction?: 'down' | 'up', maxScrolls?: number, scrollAmount?: number, deviceId?: string }) {
|
|
43
|
+
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
|
|
44
|
+
return await interact.scrollToElement(selector, direction, maxScrolls, scrollAmount, resolved.id)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { spawn } from "child_process"
|
|
2
|
-
import { WaitForElementResponse, TapResponse } from "../types.js"
|
|
3
|
-
import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "
|
|
4
|
-
import { iOSObserve } from "
|
|
2
|
+
import { WaitForElementResponse, TapResponse, SwipeResponse } from "../types.js"
|
|
3
|
+
import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js"
|
|
4
|
+
import { iOSObserve } from "../observe/index.js"
|
|
5
|
+
import { scrollToElementShared } from "../interact/shared/scroll_to_element.js"
|
|
5
6
|
|
|
6
7
|
export class iOSInteract {
|
|
7
8
|
private observe = new iOSObserve();
|
|
@@ -75,4 +76,58 @@ export class iOSInteract {
|
|
|
75
76
|
return { device, success: false, x, y, error: e instanceof Error ? e.message : String(e) };
|
|
76
77
|
}
|
|
77
78
|
}
|
|
79
|
+
|
|
80
|
+
async swipe(x1: number, y1: number, x2: number, y2: number, duration: number, deviceId: string = "booted"): Promise<SwipeResponse> {
|
|
81
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
82
|
+
// Use shared helper to detect idb
|
|
83
|
+
const idbExists = await isIDBInstalled();
|
|
84
|
+
|
|
85
|
+
if (!idbExists) {
|
|
86
|
+
return {
|
|
87
|
+
device,
|
|
88
|
+
success: false,
|
|
89
|
+
start: [x1, y1],
|
|
90
|
+
end: [x2, y2],
|
|
91
|
+
duration,
|
|
92
|
+
error: "iOS swipe requires 'idb' (iOS Device Bridge)."
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const targetUdid = (device.id && device.id !== 'booted') ? device.id : undefined;
|
|
98
|
+
// idb 'ui swipe' does not accept a duration parameter; use coordinates only
|
|
99
|
+
const args: string[] = ['ui', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString()];
|
|
100
|
+
if (targetUdid) {
|
|
101
|
+
args.push('--udid', targetUdid);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await new Promise<void>((resolve, reject) => {
|
|
105
|
+
const proc = spawn(getIdbCmd(), args);
|
|
106
|
+
let stderr = '';
|
|
107
|
+
proc.stderr.on('data', d => stderr += d.toString());
|
|
108
|
+
proc.on('close', code => {
|
|
109
|
+
if (code === 0) resolve();
|
|
110
|
+
else reject(new Error(`idb ui swipe failed: ${stderr}`));
|
|
111
|
+
});
|
|
112
|
+
proc.on('error', err => reject(err));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return { device, success: true, start: [x1, y1], end: [x2, y2], duration };
|
|
116
|
+
} catch (e) {
|
|
117
|
+
return { device, success: false, start: [x1, y1], end: [x2, y2], duration, error: e instanceof Error ? e.message : String(e) };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async scrollToElement(selector: { text?: string, resourceId?: string, contentDesc?: string, className?: string }, direction: 'down' | 'up' = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId: string = 'booted') {
|
|
122
|
+
return await scrollToElementShared({
|
|
123
|
+
selector,
|
|
124
|
+
direction,
|
|
125
|
+
maxScrolls,
|
|
126
|
+
scrollAmount,
|
|
127
|
+
deviceId,
|
|
128
|
+
fetchTree: async () => await this.observe.getUITree(deviceId),
|
|
129
|
+
swipe: async (x1: number, y1: number, x2: number, y2: number, duration: number, devId?: string) => await this.swipe(x1, y1, x2, y2, duration, devId)
|
|
130
|
+
})
|
|
131
|
+
}
|
|
78
132
|
}
|
|
133
|
+
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import crypto from 'crypto'
|
|
2
|
+
import { GetUITreeResponse, GetCurrentScreenResponse, UIElement } from '../../types.js'
|
|
3
|
+
|
|
4
|
+
const ANDROID_STRUCTURAL_TYPES = ['Window','Application','View','ViewGroup','LinearLayout','FrameLayout','RelativeLayout','ScrollView','RecyclerView','TextView','ImageView']
|
|
5
|
+
const IOS_STRUCTURAL_TYPES = ['Window','Application','View','ViewController','UITableView','UICollectionView','UILabel','UIImageView','UIView','UIWindow','UIStackView','UITextView','UITableViewCell']
|
|
6
|
+
|
|
7
|
+
function isDynamicText(t?: string): boolean {
|
|
8
|
+
if (!t) return false
|
|
9
|
+
const txt = t.trim()
|
|
10
|
+
if (!txt) return false
|
|
11
|
+
if (/\b\d{1,2}:\d{2}\b/.test(txt)) return true
|
|
12
|
+
if (/\b\d{4}-\d{2}-\d{2}\b/.test(txt)) return true
|
|
13
|
+
if (/^\d+(?:\.\d+)?%$/.test(txt)) return true
|
|
14
|
+
if (/^\d+$/.test(txt)) return true
|
|
15
|
+
if (/^[\d,]{1,10}$/.test(txt)) return true
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeElement(e: UIElement) {
|
|
20
|
+
return {
|
|
21
|
+
type: (e.type || '').toString(),
|
|
22
|
+
resourceId: (e.resourceId || '').toString(),
|
|
23
|
+
text: typeof e.text === 'string' ? (isDynamicText(e.text) ? '' : e.text.trim().toLowerCase()) : '',
|
|
24
|
+
contentDesc: (e.contentDescription || '').toString(),
|
|
25
|
+
bounds: Array.isArray(e.bounds) ? e.bounds.slice(0,4).map((n:any)=>Number(n)||0) : [0,0,0,0]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function computeScreenFingerprint(tree: GetUITreeResponse, current: GetCurrentScreenResponse | null, platform: 'android' | 'ios', limit: number = 50): { fingerprint: string | null; activity?: string; error?: string } {
|
|
30
|
+
try {
|
|
31
|
+
if (!tree || (tree as any).error) return { fingerprint: null, error: (tree as any).error }
|
|
32
|
+
|
|
33
|
+
const activity = current && (current.activity || (current as any).shortActivity) ? (current.activity || (current as any).shortActivity) : ''
|
|
34
|
+
|
|
35
|
+
const candidates: UIElement[] = (tree.elements || []).filter(e => {
|
|
36
|
+
if (!e) return false
|
|
37
|
+
if (!e.visible) return false
|
|
38
|
+
const hasStableText = typeof e.text === 'string' && e.text.trim().length > 0
|
|
39
|
+
const hasResource = !!e.resourceId
|
|
40
|
+
const interactable = !!e.clickable || !!e.enabled
|
|
41
|
+
const structuralList = platform === 'android' ? ANDROID_STRUCTURAL_TYPES : IOS_STRUCTURAL_TYPES
|
|
42
|
+
const structurallySignificant = hasStableText || hasResource || structuralList.includes(e.type || '')
|
|
43
|
+
return interactable || structurallySignificant
|
|
44
|
+
}) as UIElement[]
|
|
45
|
+
|
|
46
|
+
const normalized = candidates.map(normalizeElement)
|
|
47
|
+
|
|
48
|
+
const filteredNormalized = normalized.filter(e => (e.text && e.text.length > 0) || (e.resourceId && e.resourceId.length > 0) || (e.contentDesc && e.contentDesc.length > 0))
|
|
49
|
+
|
|
50
|
+
filteredNormalized.sort((a,b) => {
|
|
51
|
+
const ay = (a.bounds && a.bounds[1]) || 0
|
|
52
|
+
const by = (b.bounds && b.bounds[1]) || 0
|
|
53
|
+
if (ay !== by) return ay - by
|
|
54
|
+
const ax = (a.bounds && a.bounds[0]) || 0
|
|
55
|
+
const bx = (b.bounds && b.bounds[0]) || 0
|
|
56
|
+
return ax - bx
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const limited = filteredNormalized.slice(0, Math.max(0, limit))
|
|
60
|
+
|
|
61
|
+
const payload = {
|
|
62
|
+
activity: platform === 'android' ? (activity || '') : '',
|
|
63
|
+
resolution: (tree as any).resolution || { width: 0, height: 0 },
|
|
64
|
+
elements: limited.map(e => ({ type: e.type, resourceId: e.resourceId, text: e.text, contentDesc: e.contentDesc }))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const combined = JSON.stringify(payload)
|
|
68
|
+
const hash = crypto.createHash('sha256').update(combined).digest('hex')
|
|
69
|
+
return { fingerprint: hash, activity: activity }
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return { fingerprint: null, error: e instanceof Error ? e.message : String(e) }
|
|
72
|
+
}
|
|
73
|
+
}
|