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 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.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": "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) {
@@ -1241,7 +1265,7 @@ async function handleDevices({ deviceBridge }) {
1241
1265
  envelope: fail(
1242
1266
  'device',
1243
1267
  err.message || String(err),
1244
- err.hint || 'Ensure a device or simulator is connected and reachable.'
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.hint || 'Ensure a device or simulator is connected and reachable.'),
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.hint || CRASH_LIST_HINT),
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.hint || CRASH_LIST_HINT),
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 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, ...).
@@ -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 };
@@ -160,4 +160,6 @@ function parseToolResult(res) {
160
160
  return res;
161
161
  }
162
162
 
163
- module.exports = { createCall, makeCall, parseToolResult };
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 };
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
- // mobile-mcp 0.0.55 requires `device` on every tool EXCEPT the discovery verb.
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']);