mobile-automator 0.26.0 → 0.27.1
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/package.json +3 -3
- package/src/cli.js +32 -8
- package/src/device/action-catalog.js +2 -2
- package/src/device/bridge.js +36 -9
- package/src/device/device-resolver.js +2 -1
- package/src/device/element-model.js +37 -15
- package/src/device/engine-hints.js +83 -0
- package/src/device/mobile-mcp-client.js +3 -1
- package/src/device/swipe-geometry.js +39 -0
- package/src/device/tool-args.js +3 -1
package/README.md
CHANGED
|
@@ -8,13 +8,13 @@ Claude Code, Cursor, Gemini CLI, GitHub Copilot, and OpenAI Agents all have one-
|
|
|
8
8
|
|
|
9
9
|
[](LICENSE)
|
|
10
10
|
[](#)
|
|
11
|
-
[](#)
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
## ⏱️ Quick start (5 minutes)
|
|
16
16
|
|
|
17
|
-
**You need:** Node ≥
|
|
17
|
+
**You need:** Node ≥ 22.12 · an AI coding agent · a running emulator/simulator or a connected device.
|
|
18
18
|
|
|
19
19
|
> This quick start uses **Claude Code** as the example agent. For Cursor, swap `--agent claude` → `--agent cursor`. For any other agent, see [Using another agent](#-using-another-agent).
|
|
20
20
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mobile-automator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.1",
|
|
4
4
|
"description": "Host-agnostic mauto CLI for AI-driven mobile QA automation — analyzes a mobile project and drives devices through mobile-mcp.",
|
|
5
5
|
"homepage": "https://github.com/sh3lan93/mobile-automator#readme",
|
|
6
6
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"test:all": "jest"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@mobilenext/mobile-mcp": "
|
|
36
|
+
"@mobilenext/mobile-mcp": "1.0.5",
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38
38
|
"ajv": "^8.20.0",
|
|
39
39
|
"commander": "^12.1.0"
|
|
@@ -45,6 +45,6 @@
|
|
|
45
45
|
"toml": "^3.0.0"
|
|
46
46
|
},
|
|
47
47
|
"engines": {
|
|
48
|
-
"node": ">=
|
|
48
|
+
"node": ">=22.12.0"
|
|
49
49
|
}
|
|
50
50
|
}
|
package/src/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ const { version: PKG_VERSION } = require('../package.json');
|
|
|
8
8
|
const { ok, fail, render, exitCodeFor } = require('./output/envelope');
|
|
9
9
|
const { DeviceBridge } = require('./device/bridge');
|
|
10
10
|
const selectionStore = require('./device/selection');
|
|
11
|
+
const { engineHint } = require('./device/engine-hints');
|
|
11
12
|
const { ScenarioValidator } = require('./scenario/validator');
|
|
12
13
|
const { evaluate, MECHANICAL_TYPES } = require('./assertion/evaluator');
|
|
13
14
|
const { ResultStore } = require('./result/store');
|
|
@@ -158,6 +159,16 @@ function toEnvelope(err) {
|
|
|
158
159
|
};
|
|
159
160
|
}
|
|
160
161
|
|
|
162
|
+
// The hint for any device-kind failure. Precedence: an explicit err.hint wins
|
|
163
|
+
// (our own code attached it knowing the exact cause — a DeviceResolutionError,
|
|
164
|
+
// the daemon-log pointer, a timeout); then engineHint, which recognises engine
|
|
165
|
+
// messages that are useless on their own (the 1.0.5 engine's missing iOS agent
|
|
166
|
+
// and changed Android device ids, #199); then the handler's generic fallback.
|
|
167
|
+
// Every device fail() below goes through here so no handler can miss it.
|
|
168
|
+
function deviceHint(err, fallback) {
|
|
169
|
+
return (err && err.hint) || engineHint(err && err.message) || fallback;
|
|
170
|
+
}
|
|
171
|
+
|
|
161
172
|
// ---------------------------------------------------------------------------
|
|
162
173
|
// Handlers — pure-ish: accept injected deps, return { envelope, exitKind }.
|
|
163
174
|
// No process.exit / printing here so they are trivially unit-testable.
|
|
@@ -172,7 +183,7 @@ async function handleElements({ deviceBridge }) {
|
|
|
172
183
|
envelope: fail(
|
|
173
184
|
'device',
|
|
174
185
|
err.message || String(err),
|
|
175
|
-
err
|
|
186
|
+
deviceHint(err, 'Ensure a device or simulator is connected and the app is running.')
|
|
176
187
|
),
|
|
177
188
|
exitKind: 'device',
|
|
178
189
|
};
|
|
@@ -188,7 +199,7 @@ async function handleScreenshot({ deviceBridge }, destPath) {
|
|
|
188
199
|
envelope: fail(
|
|
189
200
|
'device',
|
|
190
201
|
err.message || String(err),
|
|
191
|
-
err
|
|
202
|
+
deviceHint(err, 'Ensure a device or simulator is connected.')
|
|
192
203
|
),
|
|
193
204
|
exitKind: 'device',
|
|
194
205
|
};
|
|
@@ -247,7 +258,7 @@ function deviceFail(err) {
|
|
|
247
258
|
envelope: fail(
|
|
248
259
|
kind,
|
|
249
260
|
err.message || String(err),
|
|
250
|
-
err
|
|
261
|
+
deviceHint(err, 'Ensure a device or simulator is connected and the app is running.')
|
|
251
262
|
),
|
|
252
263
|
exitKind: kind,
|
|
253
264
|
};
|
|
@@ -279,7 +290,20 @@ function parseCoordinates(raw) {
|
|
|
279
290
|
error: fail('invalid_input', `coordinates must be numbers, got "${raw}"`, 'Pass --at <x,y>, e.g. --at 100,250.'),
|
|
280
291
|
};
|
|
281
292
|
}
|
|
282
|
-
|
|
293
|
+
// Check AFTER rounding: the device receives the rounded ints, so -0.4 (-> 0)
|
|
294
|
+
// is a valid on-screen point while -0.6 (-> -1) is not. `+ 0` normalizes -0
|
|
295
|
+
// to 0 so neither the envelope nor the engine sees a signed zero. Negatives
|
|
296
|
+
// must be caught here because mobile-mcp 1.0.5 declares coordinates .min(0)
|
|
297
|
+
// and would reject them device-side, misclassified as a device failure that
|
|
298
|
+
// triggers the crash probe (#199).
|
|
299
|
+
const rx = Math.round(x) + 0;
|
|
300
|
+
const ry = Math.round(y) + 0;
|
|
301
|
+
if (rx < 0 || ry < 0) {
|
|
302
|
+
return {
|
|
303
|
+
error: fail('invalid_input', `coordinates must be non-negative, got "${raw}"`, 'Coordinates must be >= 0 (screen pixels from the top-left), e.g. --at 100,250.'),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
return { x: rx, y: ry };
|
|
283
307
|
}
|
|
284
308
|
|
|
285
309
|
async function handleTap({ deviceBridge }, raw) {
|
|
@@ -1241,7 +1265,7 @@ async function handleDevices({ deviceBridge }) {
|
|
|
1241
1265
|
envelope: fail(
|
|
1242
1266
|
'device',
|
|
1243
1267
|
err.message || String(err),
|
|
1244
|
-
err
|
|
1268
|
+
deviceHint(err, 'Ensure a device or simulator is connected and reachable.')
|
|
1245
1269
|
),
|
|
1246
1270
|
exitKind: 'device',
|
|
1247
1271
|
};
|
|
@@ -1265,7 +1289,7 @@ async function handleDevicesUse({ deviceBridge, store = selectionStore, projectR
|
|
|
1265
1289
|
devices = await deviceBridge.listDevices();
|
|
1266
1290
|
} catch (err) {
|
|
1267
1291
|
return {
|
|
1268
|
-
envelope: fail('device', err.message || String(err), err
|
|
1292
|
+
envelope: fail('device', err.message || String(err), deviceHint(err, 'Ensure a device or simulator is connected and reachable.')),
|
|
1269
1293
|
exitKind: 'device',
|
|
1270
1294
|
};
|
|
1271
1295
|
}
|
|
@@ -1376,7 +1400,7 @@ async function handleCrashList({ deviceBridge, projectRoot }, opts = {}) {
|
|
|
1376
1400
|
// "Could not look" is NOT "found none". Reporting ok:true with an empty
|
|
1377
1401
|
// list here would be this slice's own bug with the sign flipped.
|
|
1378
1402
|
return {
|
|
1379
|
-
envelope: fail(err.kind || 'device', err.message || String(err), err
|
|
1403
|
+
envelope: fail(err.kind || 'device', err.message || String(err), deviceHint(err, CRASH_LIST_HINT)),
|
|
1380
1404
|
exitKind: err.kind || 'device',
|
|
1381
1405
|
};
|
|
1382
1406
|
}
|
|
@@ -1408,7 +1432,7 @@ async function handleCrashGet({ deviceBridge, fs: fsDep = fs }, id, opts = {}) {
|
|
|
1408
1432
|
report = await deviceBridge.getCrash(id);
|
|
1409
1433
|
} catch (err) {
|
|
1410
1434
|
return {
|
|
1411
|
-
envelope: fail(err.kind || 'device', err.message || String(err), err
|
|
1435
|
+
envelope: fail(err.kind || 'device', err.message || String(err), deviceHint(err, CRASH_LIST_HINT)),
|
|
1412
1436
|
exitKind: err.kind || 'device',
|
|
1413
1437
|
};
|
|
1414
1438
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// 'composed' — no dedicated verb; the agent composes it from existing verbs
|
|
17
17
|
// (e.g. poll `mauto elements`, repeat `mauto swipe`). The
|
|
18
18
|
// execute guide documents the composition.
|
|
19
|
-
// 'unsupported' — mobile-mcp
|
|
19
|
+
// 'unsupported' — mobile-mcp 1.0.5 exposes no primitive for it. Not
|
|
20
20
|
// mechanically executable; the guide must NOT promise a verb,
|
|
21
21
|
// and the agent reports it honestly / handles it manually.
|
|
22
22
|
//
|
|
@@ -84,7 +84,7 @@ const ACTION_CATALOG = {
|
|
|
84
84
|
wait_for_loading_complete: { resolution: 'composed' },
|
|
85
85
|
capture_value: { resolution: 'composed' },
|
|
86
86
|
|
|
87
|
-
// --- unsupported (no mobile-mcp
|
|
87
|
+
// --- unsupported (no mobile-mcp 1.0.5 primitive) -----------------------
|
|
88
88
|
clear_app_data: { resolution: 'unsupported' },
|
|
89
89
|
enable_wifi: { resolution: 'unsupported' },
|
|
90
90
|
disable_wifi: { resolution: 'unsupported' },
|
package/src/device/bridge.js
CHANGED
|
@@ -4,6 +4,7 @@ const { normalize, parseElements } = require('./element-model');
|
|
|
4
4
|
const { normalizeDevices } = require('./device-model');
|
|
5
5
|
const { normalizeCrashes } = require('./crash-model');
|
|
6
6
|
const { resolveSingleDevice } = require('./device-resolver');
|
|
7
|
+
const { centredSwipe } = require('./swipe-geometry');
|
|
7
8
|
|
|
8
9
|
// Thin wrapper over an injected mobile-mcp `call(toolName, args)` function.
|
|
9
10
|
// Returns the agnostic element model and exposes only the primitives the CLI needs.
|
|
@@ -24,8 +25,11 @@ class DeviceBridge {
|
|
|
24
25
|
return normalizeDevices(result);
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
// format:"json" is required from mobile-mcp 1.0.5, which defaults this tool
|
|
29
|
+
// to a line-per-element text format parseElements cannot read (#199). 0.0.55
|
|
30
|
+
// ignores the unknown key (its zod schema is non-strict), so it is safe on both.
|
|
27
31
|
async listElements() {
|
|
28
|
-
const result = await this._call('mobile_list_elements_on_screen', {});
|
|
32
|
+
const result = await this._call('mobile_list_elements_on_screen', { format: 'json' });
|
|
29
33
|
return normalize(parseElements(result));
|
|
30
34
|
}
|
|
31
35
|
|
|
@@ -62,10 +66,26 @@ class DeviceBridge {
|
|
|
62
66
|
return this._call('mobile_type_keys', { text, submit: false });
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
// Swipe in a cardinal direction.
|
|
66
|
-
//
|
|
67
|
-
//
|
|
69
|
+
// Swipe in a cardinal direction.
|
|
70
|
+
//
|
|
71
|
+
// Direction-only (x, y and distance all undefined): mauto sends explicit
|
|
72
|
+
// centre ± 30% geometry from centredSwipe rather than trusting the engine
|
|
73
|
+
// default, which mobile-mcp 1.x changed from that proportional travel to a
|
|
74
|
+
// fixed 400px on Android and real iOS (#199; see swipe-geometry.js). This
|
|
75
|
+
// costs one extra mobile_get_screen_size round trip per direction-only swipe
|
|
76
|
+
// — acceptable because each verb is one-shot and swipes are not hot-path, so
|
|
77
|
+
// no caching. getScreenSize throws on a zero/unreadable size, so a 0x0 screen
|
|
78
|
+
// can never produce a degenerate swipe.
|
|
79
|
+
//
|
|
80
|
+
// Any explicit key (e.g. the iOS edge-swipe back's x/y/distance) means the
|
|
81
|
+
// caller owns the geometry: everything given is passed through as-is and
|
|
82
|
+
// absent keys are not sent — including partial input such as x,y without
|
|
83
|
+
// distance, which leaves the travel to the engine exactly as before.
|
|
68
84
|
async swipe({ direction, x, y, distance } = {}) {
|
|
85
|
+
if (x === undefined && y === undefined && distance === undefined) {
|
|
86
|
+
const geometry = centredSwipe(await this.getScreenSize(), direction);
|
|
87
|
+
return this._call('mobile_swipe_on_screen', { direction, ...geometry });
|
|
88
|
+
}
|
|
69
89
|
const args = { direction };
|
|
70
90
|
if (x !== undefined) args.x = x;
|
|
71
91
|
if (y !== undefined) args.y = y;
|
|
@@ -92,18 +112,25 @@ class DeviceBridge {
|
|
|
92
112
|
// object), so we parse the WxH out of it; a structured {width,height} shape is
|
|
93
113
|
// also accepted in case a future engine returns one. An unreadable size is a
|
|
94
114
|
// hard error — never silently 0/NaN, which would make geometry gestures no-ops.
|
|
115
|
+
// That includes a READABLE zero: mobile-mcp 1.0.5's mobilecli path returns
|
|
116
|
+
// {width:0,height:0,scale:1} for an unknown size instead of throwing (#199).
|
|
95
117
|
async getScreenSize() {
|
|
96
118
|
const r = await this._call('mobile_get_screen_size', {});
|
|
119
|
+
let size = null;
|
|
97
120
|
if (r && typeof r === 'object' && r.width != null && r.height != null) {
|
|
98
|
-
|
|
121
|
+
size = { width: Number(r.width), height: Number(r.height) };
|
|
122
|
+
} else {
|
|
123
|
+
const m = /(\d+)\s*x\s*(\d+)/i.exec(String(r));
|
|
124
|
+
if (m) size = { width: Number(m[1]), height: Number(m[2]) };
|
|
99
125
|
}
|
|
100
|
-
const
|
|
101
|
-
if (!
|
|
102
|
-
const
|
|
126
|
+
const positive = (n) => Number.isFinite(n) && n > 0;
|
|
127
|
+
if (!size || !positive(size.width) || !positive(size.height)) {
|
|
128
|
+
const shown = r && typeof r === 'object' ? JSON.stringify(r) : String(r);
|
|
129
|
+
const err = new Error(`Could not read the device screen size from "${shown}".`);
|
|
103
130
|
err.hint = 'Ensure a device or simulator is connected.';
|
|
104
131
|
throw err;
|
|
105
132
|
}
|
|
106
|
-
return
|
|
133
|
+
return size;
|
|
107
134
|
}
|
|
108
135
|
|
|
109
136
|
// Press a hardware/system button (BACK, HOME, ENTER, ...).
|
|
@@ -11,7 +11,8 @@ class DeviceResolutionError extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
// mobile-mcp
|
|
14
|
+
// mobile-mcp 1.0.5 requires a concrete device id on every action/read tool
|
|
15
|
+
// (tests/integration/mobile-mcp-contract.test.js pins this against the engine).
|
|
15
16
|
// When the caller pinned nothing (no --device, no persisted selection), we
|
|
16
17
|
// auto-discover: exactly one active device is used; zero or many is a clear,
|
|
17
18
|
// actionable failure rather than a silent empty result.
|
|
@@ -58,25 +58,47 @@ function normalize(rawElements) {
|
|
|
58
58
|
return out;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
// Short, single-line excerpt of an unparseable payload for the error message:
|
|
62
|
+
// the FIRST non-empty line only, capped, so a multi-kilobyte screen dump never
|
|
63
|
+
// floods the envelope but the reader still sees what shape came back.
|
|
64
|
+
const EXCERPT_MAX = 120;
|
|
65
|
+
function excerptOf(text) {
|
|
66
|
+
const line = (text.split(/\r?\n/).find((l) => l.trim() !== '') || '').trim();
|
|
67
|
+
return line.length > EXCERPT_MAX ? `${line.slice(0, EXCERPT_MAX)}…` : line;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// mobile-mcp returns elements as the string
|
|
71
|
+
// `Found these elements on screen: <JSON array>` (0.0.55, and 1.0.5 when called
|
|
72
|
+
// with format:"json"). Earlier/other shapes may be a bare array or
|
|
73
|
+
// `{elements:[...]}`. Parse all three into the raw element array. Parse the
|
|
74
|
+
// WHOLE JSON array (never per-element regex) so labels containing
|
|
75
|
+
// brackets/quotes/newlines survive.
|
|
76
|
+
//
|
|
77
|
+
// A non-empty string we cannot parse THROWS rather than returning []: mobile-mcp
|
|
78
|
+
// 1.0.5 defaults this tool to a line-per-element text format whose header
|
|
79
|
+
// contains `[focused] ... [disabled]`, and a silent [] there reported `ok:true`
|
|
80
|
+
// with an empty screen — the failure probe then blamed the app (#199). An empty
|
|
81
|
+
// or whitespace-only string stays [] because it carries no content to misread:
|
|
82
|
+
// it is not evidence of a format change. null / non-string values stay [] for
|
|
83
|
+
// the same reason.
|
|
66
84
|
function parseElements(raw) {
|
|
67
85
|
if (Array.isArray(raw)) return raw;
|
|
68
86
|
if (raw && Array.isArray(raw.elements)) return raw.elements;
|
|
69
|
-
if (typeof raw
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
87
|
+
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
|
88
|
+
|
|
89
|
+
const start = raw.indexOf('[');
|
|
90
|
+
const end = raw.lastIndexOf(']');
|
|
91
|
+
if (start !== -1 && end > start) {
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(raw.slice(start, end + 1));
|
|
94
|
+
if (Array.isArray(parsed)) return parsed;
|
|
95
|
+
} catch (_e) { /* fall through to the loud failure below */ }
|
|
78
96
|
}
|
|
79
|
-
|
|
97
|
+
const err = new Error(`The element list could not be parsed. Engine returned: "${excerptOf(raw)}"`);
|
|
98
|
+
err.hint =
|
|
99
|
+
'The mobile-mcp engine likely changed its element output format (e.g. text instead of JSON). ' +
|
|
100
|
+
'Check the installed @mobilenext/mobile-mcp version matches the one mobile-automator pins.';
|
|
101
|
+
throw err;
|
|
80
102
|
}
|
|
81
103
|
|
|
82
104
|
module.exports = { normalize, parseElements };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
// Actionable hints for device-engine failures whose raw message alone does not
|
|
7
|
+
// tell the user what to do. Pure: message in, hint string (or null) out. It
|
|
8
|
+
// never throws — a hint classifier that crashes would turn a device failure
|
|
9
|
+
// into an internal one. Found by the #199 real-device run against mobile-mcp
|
|
10
|
+
// 1.0.5 / mobilecli 1.0.13.
|
|
11
|
+
//
|
|
12
|
+
// mauto only TELLS the user which command to run; it never runs mobilecli
|
|
13
|
+
// itself (the device is driven only through mauto verbs, which wrap
|
|
14
|
+
// mobile-mcp).
|
|
15
|
+
|
|
16
|
+
const ISSUE_459 = 'https://github.com/mobile-next/mobile-mcp/issues/459';
|
|
17
|
+
|
|
18
|
+
// The mobilecli that the pinned mobile-mcp actually spawns — resolved from
|
|
19
|
+
// mobile-mcp's own directory, since that is the copy whose agent the device
|
|
20
|
+
// must match. Null when it cannot be resolved; the caller omits `@<version>`.
|
|
21
|
+
function resolveMobilecliVersion() {
|
|
22
|
+
const mcpDir = path.dirname(require.resolve('@mobilenext/mobile-mcp/package.json'));
|
|
23
|
+
const pkgPath = require.resolve('mobilecli/package.json', { paths: [mcpDir] });
|
|
24
|
+
// Read, not require(): a non-literal require specifier is unverifiable to
|
|
25
|
+
// tests/lint/telemetry-transport-isolation.test.js.
|
|
26
|
+
return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version || null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function safeVersion(resolver) {
|
|
30
|
+
try {
|
|
31
|
+
const v = resolver();
|
|
32
|
+
return typeof v === 'string' && v ? v : null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// B1 (iOS): mobilecli `agent status` exits 1 when the DeviceKit agent is
|
|
39
|
+
// missing and mobile-mcp runs it via execFileSync, so the throw skips its own
|
|
40
|
+
// auto-install (upstream mobile-mcp#459). Every verb fails until the agent is
|
|
41
|
+
// installed once by hand.
|
|
42
|
+
function missingAgentHint(message, resolver) {
|
|
43
|
+
const version = safeVersion(resolver);
|
|
44
|
+
const pkg = version ? `mobilecli@${version}` : 'mobilecli';
|
|
45
|
+
const cmd = `npx -y ${pkg} agent install --device`;
|
|
46
|
+
const m = /--device\s+(\S+)/.exec(message);
|
|
47
|
+
const install = m
|
|
48
|
+
? `\`${cmd} ${m[1]}\``
|
|
49
|
+
: `\`${cmd}\` followed by the simulator's id from \`mauto devices\``;
|
|
50
|
+
return (
|
|
51
|
+
'The simulator is missing the device agent the engine needs, and the engine cannot install it ' +
|
|
52
|
+
`itself (upstream bug ${ISSUE_459}). Install it once with ${install} ` +
|
|
53
|
+
'(needs network, takes a few seconds), then retry.'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// B2: mobile-mcp 1.0.5's default robot lists Android emulators by AVD name,
|
|
58
|
+
// MOBILEMCP_LEGACY_ROBOT=1 (and 0.0.55) by adb serial, and each rejects the
|
|
59
|
+
// other's id — so a selection persisted before the upgrade fails every verb.
|
|
60
|
+
function deviceNotFoundHint(id) {
|
|
61
|
+
return (
|
|
62
|
+
`Device "${id}" is not known to the current engine. Device ids can differ between engines: ` +
|
|
63
|
+
"mauto 0.27's default engine lists Android emulators by AVD name (e.g. Pixel_9_Pro), while " +
|
|
64
|
+
'MOBILEMCP_LEGACY_ROBOT=1 uses the adb serial (e.g. emulator-5554). Run `mauto devices`, then ' +
|
|
65
|
+
'`mauto devices use` with an id from that list (or pass that id to --device).'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function engineHint(message, { mobilecliVersion = resolveMobilecliVersion } = {}) {
|
|
70
|
+
try {
|
|
71
|
+
if (typeof message !== 'string' || !message) return null;
|
|
72
|
+
if (/Agent is not installed on the device/.test(message)) {
|
|
73
|
+
return missingAgentHint(message, mobilecliVersion);
|
|
74
|
+
}
|
|
75
|
+
const notFound = /Device "([^"]+)" not found/.exec(message);
|
|
76
|
+
if (notFound) return deviceNotFoundHint(notFound[1]);
|
|
77
|
+
return null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { engineHint };
|
|
@@ -160,4 +160,6 @@ function parseToolResult(res) {
|
|
|
160
160
|
return res;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
// resolveServerEntry is exported so tests/integration/mobile-mcp-contract.test.js
|
|
164
|
+
// spawns the pinned engine by the exact path production does, not a copy of it.
|
|
165
|
+
module.exports = { createCall, makeCall, parseToolResult, resolveServerEntry };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Geometry for a direction-only swipe: start at centre + 30% of the axis
|
|
4
|
+
// dimension and travel 60% of it, i.e. centre ± 30% (#199).
|
|
5
|
+
//
|
|
6
|
+
// Why mauto computes this instead of letting the engine choose: mobile-mcp
|
|
7
|
+
// 0.0.55's adb and WDA robots used exactly this proportional travel, but 1.0.5
|
|
8
|
+
// routes every device through mobilecli, whose direction-only swipe is a fixed
|
|
9
|
+
// 400px centred stroke — about a third of the old travel on a phone, so
|
|
10
|
+
// scroll-to-element needs ~3x the swipes. Every robot in both versions reads an
|
|
11
|
+
// explicit {x, y, distance} the same way (start at x,y; move `distance` px in
|
|
12
|
+
// `direction`), so sending it pins identical behaviour on either pin.
|
|
13
|
+
//
|
|
14
|
+
// Kept pure and in its own module (not inside DeviceBridge) so the arithmetic
|
|
15
|
+
// is testable without an engine fake and the bridge stays a thin wrapper.
|
|
16
|
+
// There is deliberately no platform branch: proportional geometry is what makes
|
|
17
|
+
// it platform-agnostic.
|
|
18
|
+
//
|
|
19
|
+
// The integer formulas mirror 0.0.55's AndroidRobot (`Math.floor(n * 0.8)`,
|
|
20
|
+
// `Math.floor(n * 0.2)`, centre `floor(n / 2)`) so the endpoints match its old
|
|
21
|
+
// ones pixel-for-pixel, floating-point rounding included.
|
|
22
|
+
function centredSwipe({ width, height }, direction) {
|
|
23
|
+
const hi = (n) => Math.floor(n * 0.8);
|
|
24
|
+
const lo = (n) => Math.floor(n * 0.2);
|
|
25
|
+
const cx = Math.floor(width / 2);
|
|
26
|
+
const cy = Math.floor(height / 2);
|
|
27
|
+
switch (direction) {
|
|
28
|
+
case 'up': return { x: cx, y: hi(height), distance: hi(height) - lo(height) };
|
|
29
|
+
case 'down': return { x: cx, y: lo(height), distance: hi(height) - lo(height) };
|
|
30
|
+
case 'left': return { x: hi(width), y: cy, distance: hi(width) - lo(width) };
|
|
31
|
+
case 'right': return { x: lo(width), y: cy, distance: hi(width) - lo(width) };
|
|
32
|
+
default:
|
|
33
|
+
// The CLI validates directions first; this guards other callers from a
|
|
34
|
+
// swipe with undefined geometry.
|
|
35
|
+
throw new Error(`Unknown swipe direction "${direction}".`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { centredSwipe };
|
package/src/device/tool-args.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// mobile-mcp
|
|
3
|
+
// mobile-mcp 1.0.5 requires `device` on every tool EXCEPT the discovery verb;
|
|
4
|
+
// tests/integration/mobile-mcp-contract.test.js enforces this against the
|
|
5
|
+
// pinned engine's own tool schemas.
|
|
4
6
|
// Centralizing the injection (and its one exception) here keeps every call
|
|
5
7
|
// site correct. Pure + non-mutating so it is trivially testable.
|
|
6
8
|
const NO_DEVICE_TOOLS = new Set(['mobile_list_available_devices']);
|