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 +2 -2
- package/package.json +3 -3
- package/src/cli.js +75 -14
- package/src/device/action-catalog.js +2 -2
- package/src/device/bridge.js +36 -9
- package/src/device/connection.js +72 -3
- 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 +107 -12
- package/src/device/process-identity.js +124 -0
- package/src/device/process-reaper.js +123 -0
- package/src/device/session-client.js +11 -5
- package/src/device/session-daemon.js +75 -76
- package/src/device/session-paths.js +23 -0
- package/src/device/session-spawn.js +22 -2
- package/src/device/swipe-geometry.js +39 -0
- package/src/device/tool-args.js +3 -1
- package/src/observe/event.js +2 -2
- package/src/observe/vocab.js +15 -5
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.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": "
|
|
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) {
|
|
@@ -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
|
-
|
|
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
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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, ...).
|
package/src/device/connection.js
CHANGED
|
@@ -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
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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 };
|