mobile-automator 0.26.0 → 0.28.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 CHANGED
@@ -8,13 +8,13 @@ Claude Code, Cursor, Gemini CLI, GitHub Copilot, and OpenAI Agents all have one-
8
8
 
9
9
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
10
10
  [![Platform](https://img.shields.io/badge/Platform-Android%20%7C%20iOS%20%7C%20Flutter%20%7C%20RN%20%7C%20KMP%20%7C%20CMP-green.svg)](#)
11
- [![Node](https://img.shields.io/badge/Node-%E2%89%A520-brightgreen.svg)](#)
11
+ [![Node](https://img.shields.io/badge/Node-%E2%89%A522.12-brightgreen.svg)](#)
12
12
 
13
13
  ---
14
14
 
15
15
  ## ⏱️ Quick start (5 minutes)
16
16
 
17
- **You need:** Node ≥ 20 · an AI coding agent · a running emulator/simulator or a connected device.
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.26.0",
3
+ "version": "0.28.0",
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": "0.0.55",
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": ">=20.0.0"
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.hint || 'Ensure a device or simulator is connected and the app is running.'
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.hint || 'Ensure a device or simulator is connected.'
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.hint || 'Ensure a device or simulator is connected and the app is running.'
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
- return { x: Math.round(x), y: Math.round(y) };
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) {
@@ -1210,17 +1234,54 @@ async function handleSessionStatus(
1210
1234
  { projectRoot, client = require('./device/session-client') } = {}
1211
1235
  ) {
1212
1236
  const status = await connection.sessionStatus(projectRoot, { client });
1213
- return { envelope: ok(status), exitKind: 'ok' };
1237
+ const hint =
1238
+ status.reason === 'engine_exited'
1239
+ ? `The device engine exited; this session is shutting down and the next verb starts a fresh one. ${connection.daemonLogHint(projectRoot)}`
1240
+ : null;
1241
+ return { envelope: ok(status, hint), exitKind: 'ok' };
1214
1242
  }
1215
1243
 
1244
+ // endSession's failure outcomes (#156), each with the hint that is true for it.
1245
+ // All three may leave a daemon alive and holding the workspace lock; what
1246
+ // differs is WHY, and so what the user should do about it. One vague hint used
1247
+ // to cover all three.
1248
+ const SESSION_END_FAILURE_HINTS = {
1249
+ survived: (pid, pidFile) =>
1250
+ `It is still alive after SIGTERM and SIGKILL and may still hold the workspace lock. Check it with \`ps -p ${pid}\` and stop it with \`kill -9 ${pid}\` (pidfile: ${pidFile}).`,
1251
+ foreign: (pid, pidFile) =>
1252
+ `It answered the shutdown, but its command line does not name this workspace (possibly started by mauto < 0.28), so it was not signalled. Check it with \`ps -p ${pid}\` and stop it manually (pidfile: ${pidFile}).`,
1253
+ unverified: (pid, pidFile) =>
1254
+ `It could not be identified (\`ps\` failed), so it was not signalled. Check it with \`ps -p ${pid}\` and stop it manually if it is this workspace's mauto daemon (pidfile: ${pidFile}).`,
1255
+ };
1256
+
1216
1257
  async function handleSessionEnd(
1217
- { projectRoot, client = require('./device/session-client') } = {}
1258
+ { projectRoot, client = require('./device/session-client'), end = {} } = {}
1218
1259
  ) {
1219
- const stopped = await connection.endSession(projectRoot, { client });
1220
- return {
1221
- envelope: ok({ stopped, already_stopped: !stopped }),
1222
- exitKind: 'ok',
1260
+ const { outcome, pid } = await connection.endSession(projectRoot, { client, ...end });
1261
+ const logHint = connection.daemonLogHint(projectRoot);
1262
+ if (Object.prototype.hasOwnProperty.call(SESSION_END_FAILURE_HINTS, outcome)) {
1263
+ const pidFile = require('./device/session-paths').pidFilePath(projectRoot);
1264
+ return {
1265
+ envelope: fail(
1266
+ 'device',
1267
+ `device session daemon pid ${pid} did not exit`,
1268
+ `${SESSION_END_FAILURE_HINTS[outcome](pid, pidFile)} ${logHint}`,
1269
+ { stopped: false, already_stopped: false, pid }
1270
+ ),
1271
+ exitKind: 'device',
1272
+ };
1273
+ }
1274
+ // 'stopped' | 'forced' | 'already_stopped'. The ok envelope keeps the boolean
1275
+ // shape it had before endSession returned an outcome.
1276
+ const data = {
1277
+ stopped: outcome === 'stopped' || outcome === 'forced',
1278
+ already_stopped: outcome === 'already_stopped',
1279
+ forced: outcome === 'forced',
1223
1280
  };
1281
+ const hint = outcome === 'forced'
1282
+ ? `The daemon did not exit after shutdown; terminated pid ${pid}. ${logHint}`
1283
+ : null;
1284
+ return { envelope: ok(data, hint), exitKind: 'ok' };
1224
1285
  }
1225
1286
 
1226
1287
  // --- Issue #92: device discovery + persisted selection -------------------
@@ -1241,7 +1302,7 @@ async function handleDevices({ deviceBridge }) {
1241
1302
  envelope: fail(
1242
1303
  'device',
1243
1304
  err.message || String(err),
1244
- err.hint || 'Ensure a device or simulator is connected and reachable.'
1305
+ deviceHint(err, 'Ensure a device or simulator is connected and reachable.')
1245
1306
  ),
1246
1307
  exitKind: 'device',
1247
1308
  };
@@ -1265,7 +1326,7 @@ async function handleDevicesUse({ deviceBridge, store = selectionStore, projectR
1265
1326
  devices = await deviceBridge.listDevices();
1266
1327
  } catch (err) {
1267
1328
  return {
1268
- envelope: fail('device', err.message || String(err), err.hint || 'Ensure a device or simulator is connected and reachable.'),
1329
+ envelope: fail('device', err.message || String(err), deviceHint(err, 'Ensure a device or simulator is connected and reachable.')),
1269
1330
  exitKind: 'device',
1270
1331
  };
1271
1332
  }
@@ -1376,7 +1437,7 @@ async function handleCrashList({ deviceBridge, projectRoot }, opts = {}) {
1376
1437
  // "Could not look" is NOT "found none". Reporting ok:true with an empty
1377
1438
  // list here would be this slice's own bug with the sign flipped.
1378
1439
  return {
1379
- envelope: fail(err.kind || 'device', err.message || String(err), err.hint || CRASH_LIST_HINT),
1440
+ envelope: fail(err.kind || 'device', err.message || String(err), deviceHint(err, CRASH_LIST_HINT)),
1380
1441
  exitKind: err.kind || 'device',
1381
1442
  };
1382
1443
  }
@@ -1408,7 +1469,7 @@ async function handleCrashGet({ deviceBridge, fs: fsDep = fs }, id, opts = {}) {
1408
1469
  report = await deviceBridge.getCrash(id);
1409
1470
  } catch (err) {
1410
1471
  return {
1411
- envelope: fail(err.kind || 'device', err.message || String(err), err.hint || CRASH_LIST_HINT),
1472
+ envelope: fail(err.kind || 'device', err.message || String(err), deviceHint(err, CRASH_LIST_HINT)),
1412
1473
  exitKind: err.kind || 'device',
1413
1474
  };
1414
1475
  }
@@ -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 0.0.55 exposes no primitive for it. Not
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 0.0.55 primitive) ----------------------
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' },
@@ -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. Optional x/y set the start point and
66
- // distance the travel — used by the iOS edge-swipe back gesture. Absent keys
67
- // are not sent so the cardinal-from-center default is preserved.
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
- return { width: Number(r.width), height: Number(r.height) };
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 m = /(\d+)\s*x\s*(\d+)/i.exec(String(r));
101
- if (!m) {
102
- const err = new Error(`Could not read the device screen size from "${r}".`);
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 { width: Number(m[1]), height: Number(m[2]) };
133
+ return size;
107
134
  }
108
135
 
109
136
  // Press a hardware/system button (BACK, HOME, ENTER, ...).
@@ -19,6 +19,9 @@ const { daemonLogHint } = require('./session-log');
19
19
  const paths = require('./session-paths');
20
20
  const sessionClient = require('./session-client');
21
21
  const sessionSpawn = require('./session-spawn');
22
+ const sessionDaemon = require('./session-daemon');
23
+ const processIdentity = require('./process-identity');
24
+ const processReaper = require('./process-reaper');
22
25
 
23
26
  // Acquire a device connection, transparently reusing a live daemon when one
24
27
  // fits and falling back to a one-shot mobile-mcp spawn otherwise. Returns
@@ -63,6 +66,8 @@ async function sessionStatus(projectRoot, { client = sessionClient } = {}) {
63
66
  const status = await client.getSessionStatus(projectRoot);
64
67
  return {
65
68
  ...status,
69
+ // Always present (#156): null, or why a reachable daemon is not running.
70
+ reason: (status && status.reason) || null,
66
71
  log_path: paths.logFilePath(projectRoot),
67
72
  // Read from the handle, not from the ping reply. The handle is written once
68
73
  // the daemon is listening and removed by stop(), so it is the same fact
@@ -82,9 +87,72 @@ function startSession({ projectRoot, device = null, idleMs, spawn = sessionSpawn
82
87
  return spawn.spawnDaemon({ projectRoot, device, idleMs });
83
88
  }
84
89
 
85
- // Ask a live daemon to shut down. Resolves true when one acknowledged.
86
- function endSession(projectRoot, { client = sessionClient } = {}) {
87
- return client.requestShutdown(projectRoot);
90
+ // How long `session end` waits for an acknowledged daemon to exit before it
91
+ // treats it as stuck: stop() drains in-flight calls (bounded by the daemon call
92
+ // timeout) and then closes the engine (the SDK's ~4s ladder). Derived, not
93
+ // restated; pinned by tests/unit/device/timeout-invariant.test.js.
94
+ const END_BUDGET_MS = sessionDaemon.DAEMON_CALL_TIMEOUT_MS + 10000;
95
+
96
+ // Stop the daemon and report only what is true (#156). The daemon acks the
97
+ // shutdown frame BEFORE its teardown runs, so an ack is a promise, not a fact:
98
+ // 'stopped' is reported only once the pid is gone.
99
+ //
100
+ // Returns { outcome, pid } — ONE discriminated answer, so a caller dispatches
101
+ // on what happened instead of re-deriving it from a bag of booleans:
102
+ // 'stopped' - acked, and the pid exited (or there was no pid to watch)
103
+ // 'already_stopped' - nothing acked and nothing of ours was running
104
+ // 'forced' - ours, stuck in teardown, terminated by a group kill
105
+ // 'survived' - ours, stuck, and still alive after SIGTERM + SIGKILL
106
+ // 'foreign' - acked, but the live pid's command line does not name
107
+ // this workspace, so it was reported, never signalled
108
+ // 'unverified' - the pid could not be identified (`ps` failed), so it
109
+ // was never signalled
110
+ // The last three are failures: a daemon may still be alive.
111
+ async function endSession(
112
+ projectRoot,
113
+ {
114
+ client = sessionClient,
115
+ reaper = processReaper,
116
+ // Bound to THIS workspace (#156): a group kill must never reach another
117
+ // workspace's daemon through a copied or recycled pidfile.
118
+ identify = (pid) => processIdentity.pidIdentity(pid, { projectRoot }),
119
+ readPid = () => processIdentity.readPidFile(paths.pidFilePath(projectRoot)),
120
+ budgetMs = END_BUDGET_MS,
121
+ } = {}
122
+ ) {
123
+ // Read BEFORE asking: a daemon that exits cleanly removes its pidfile, and we
124
+ // need to know whom to watch.
125
+ const pid = readPid();
126
+ const acked = await client.requestShutdown(projectRoot);
127
+ const report = (outcome) => ({ outcome, pid: pid || null });
128
+ // The daemon is gone: it stopped because we asked, or was never running.
129
+ const gone = () => report(acked ? 'stopped' : 'already_stopped');
130
+
131
+ // No pidfile: nothing to watch, so the ack is all we have (as before). Our
132
+ // own pid: the in-process daemon of a test — never wait on it, never signal it.
133
+ if (!pid || pid === process.pid) return gone();
134
+
135
+ // A non-acking daemon whose pidfile names a live process is checked at once
136
+ // (budget 0): either it is gone, or it is stuck too badly to answer.
137
+ if (await reaper.waitForDeath(pid, { budgetMs: acked ? budgetMs : 0 })) return gone();
138
+
139
+ switch (identify(pid)) {
140
+ case 'dead':
141
+ case 'other': // the pid was recycled: our daemon is gone
142
+ return gone();
143
+ case 'foreign':
144
+ // A mauto daemon whose command line does not name this workspace:
145
+ // another workspace's, reached through a copied or recycled pidfile.
146
+ // Never signalled. Without an ack nothing answered on OUR socket, so none
147
+ // of ours was running. With an ack the daemon that answered is ours, but
148
+ // its command line cannot prove it (one started before the workspace
149
+ // marker), so it is reported rather than killed.
150
+ return report(acked ? 'foreign' : 'already_stopped');
151
+ case 'ours':
152
+ return report((await reaper.terminate(pid, { group: true })) === 'dead' ? 'forced' : 'survived');
153
+ default: // 'unknown': never signal a process we cannot identify
154
+ return report('unverified');
155
+ }
88
156
  }
89
157
 
90
158
  module.exports = {
@@ -97,4 +165,5 @@ module.exports = {
97
165
  sessionStatus,
98
166
  startSession,
99
167
  endSession,
168
+ END_BUDGET_MS,
100
169
  };
@@ -11,7 +11,8 @@ class DeviceResolutionError extends Error {
11
11
  }
12
12
  }
13
13
 
14
- // mobile-mcp 0.0.55 requires a concrete device id on every action/read tool.
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
- // mobile-mcp 0.0.55 returns elements as the string
62
- // `Found these elements on screen: <JSON array>`. Earlier/other shapes may be
63
- // a bare array or `{elements:[...]}`. Parse all three into the raw element
64
- // array. Parse the WHOLE JSON array (never per-element regex) so labels
65
- // containing brackets/quotes/newlines survive.
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 === 'string') {
70
- const start = raw.indexOf('[');
71
- const end = raw.lastIndexOf(']');
72
- if (start !== -1 && end > start) {
73
- try {
74
- const parsed = JSON.parse(raw.slice(start, end + 1));
75
- if (Array.isArray(parsed)) return parsed;
76
- } catch (_e) { /* fall through */ }
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
- return [];
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 };