mobile-automator 0.30.0 → 0.30.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/bin/mauto.js CHANGED
@@ -10,4 +10,5 @@ const { run, emitFatal } = require('../src/cli');
10
10
  // exit-on-reject listener.
11
11
  process.on('unhandledRejection', emitFatal);
12
12
 
13
- run(process.argv);
13
+ // run() catches everything into emitFatal itself, so the promise never rejects.
14
+ void run(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-automator",
3
- "version": "0.30.0",
3
+ "version": "0.30.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": {
@@ -26,6 +26,7 @@
26
26
  "prepublishOnly": "npm test",
27
27
  "test:watch": "jest --watch",
28
28
  "test:coverage": "jest --coverage",
29
+ "lint": "eslint .",
29
30
  "lint:guides": "jest tests/lint",
30
31
  "lint:schema-additive": "jest tests/lint/schema-additive.test.js",
31
32
  "test:unit": "jest tests/unit",
@@ -39,9 +40,15 @@
39
40
  "commander": "^12.1.0"
40
41
  },
41
42
  "devDependencies": {
43
+ "@eslint/js": "^9.39.5",
44
+ "@types/node": "^22.20.5",
45
+ "eslint": "^9.39.5",
46
+ "globals": "^17.13.0",
42
47
  "jest": "^29.7.0",
43
48
  "jest-environment-jsdom": "^29.7.0",
44
- "js-yaml": "^4.3.2"
49
+ "js-yaml": "^4.3.2",
50
+ "typescript": "~6.0.3",
51
+ "typescript-eslint": "^8.72.0"
45
52
  },
46
53
  "engines": {
47
54
  "node": ">=22.12.0"
package/src/cli.js CHANGED
@@ -7,7 +7,6 @@ const { Command } = require('commander');
7
7
  const { version: PKG_VERSION } = require('../package.json');
8
8
 
9
9
  const { ok, fail, render, exitCodeFor } = require('./output/envelope');
10
- const { DeviceBridge } = require('./device/bridge');
11
10
  const selectionStore = require('./device/selection');
12
11
  const { engineHint } = require('./device/engine-hints');
13
12
  const { ScenarioValidator } = require('./scenario/validator');
@@ -1210,7 +1209,7 @@ async function handleSessionStart(
1210
1209
  projectRoot,
1211
1210
  spawn = require('./device/session-spawn'),
1212
1211
  client = require('./device/session-client'),
1213
- readHandle = require('./device/resolve-connection').readHandleDevice,
1212
+ readHandle: readHandleDevice = require('./device/resolve-connection').readHandleDevice,
1214
1213
  },
1215
1214
  opts = {}
1216
1215
  ) {
@@ -1233,7 +1232,7 @@ async function handleSessionStart(
1233
1232
  // pinned to A reported already_running:true, device:B while every verb
1234
1233
  // kept driving A. Fail with a hint instead — the agent stays in control
1235
1234
  // and decides whether to `session end` and repin.
1236
- const pinned = readHandle(projectRoot);
1235
+ const pinned = readHandleDevice(projectRoot);
1237
1236
  if (device !== null && device !== pinned) {
1238
1237
  return {
1239
1238
  envelope: fail(
@@ -1391,7 +1390,7 @@ async function handleDevicesClear({
1391
1390
  store = selectionStore,
1392
1391
  projectRoot,
1393
1392
  isAlive = require('./device/connection').isSessionAlive,
1394
- readHandle = require('./device/resolve-connection').readHandleDevice,
1393
+ readHandle: readHandleDevice = require('./device/resolve-connection').readHandleDevice,
1395
1394
  } = {}) {
1396
1395
  const previous = store.read(projectRoot);
1397
1396
  store.clear(projectRoot);
@@ -1403,7 +1402,7 @@ async function handleDevicesClear({
1403
1402
  if (!alive) {
1404
1403
  return { envelope: ok({ cleared: previous || null }), exitKind: 'ok' };
1405
1404
  }
1406
- const pinned = readHandle(projectRoot);
1405
+ const pinned = readHandleDevice(projectRoot);
1407
1406
  return {
1408
1407
  envelope: ok(
1409
1408
  { cleared: previous || null, daemon_still_pinned: pinned },
@@ -2325,7 +2324,7 @@ function makeEmitters({ projectRoot = process.cwd() } = {}) {
2325
2324
  // needed). A Date.now() stamped at require time would measure from whenever
2326
2325
  // cli.js was loaded, which is not process start for anything that requires it
2327
2326
  // first (a test harness, bin/mauto.js's own guards).
2328
- function finish({ text = '', exitKind, ok, errorKind, exitCode = exitCodeFor(exitKind) }) {
2327
+ function finish({ text = '', exitKind, ok: succeeded, errorKind, exitCode = exitCodeFor(exitKind) }) {
2329
2328
  // The flag-resolved id when a command parsed, the environment otherwise: a
2330
2329
  // parse failure reaches no preAction hook and so has no flag, but it is
2331
2330
  // still part of the run and is exactly the class of failure (#146) this
@@ -2355,7 +2354,7 @@ function makeEmitters({ projectRoot = process.cwd() } = {}) {
2355
2354
  // tying this line to a run. sends:false in the event catalog.
2356
2355
  run_id: runId || undefined,
2357
2356
  session_id: resolvedSessionId || undefined,
2358
- ok,
2357
+ ok: succeeded,
2359
2358
  error_kind: errorKind,
2360
2359
  exit_code: exitCode,
2361
2360
  dur_ms: Math.round(process.uptime() * 1000),
@@ -44,7 +44,7 @@ class DeviceBridge {
44
44
  // Tap at absolute screen coordinates. The agent derives x,y from listElements
45
45
  // centers, so the bridge stays free of any element targeting logic.
46
46
  async tap({ x, y } = {}) {
47
- return this._call('mobile_click_on_screen_at_coordinates', { x, y });
47
+ return await this._call('mobile_click_on_screen_at_coordinates', { x, y });
48
48
  }
49
49
 
50
50
  // Long-press at absolute screen coordinates. Optional duration (ms) overrides
@@ -52,18 +52,18 @@ class DeviceBridge {
52
52
  async longPress({ x, y, duration } = {}) {
53
53
  const args = { x, y };
54
54
  if (duration !== undefined) args.duration = duration;
55
- return this._call('mobile_long_press_on_screen_at_coordinates', args);
55
+ return await this._call('mobile_long_press_on_screen_at_coordinates', args);
56
56
  }
57
57
 
58
58
  // Double-tap at absolute screen coordinates.
59
59
  async doubleTap({ x, y } = {}) {
60
- return this._call('mobile_double_tap_on_screen', { x, y });
60
+ return await this._call('mobile_double_tap_on_screen', { x, y });
61
61
  }
62
62
 
63
63
  // Type into the focused element. We never auto-submit; pressing ENTER is a
64
64
  // distinct, explicit action via pressButton.
65
65
  async type(text) {
66
- return this._call('mobile_type_keys', { text, submit: false });
66
+ return await this._call('mobile_type_keys', { text, submit: false });
67
67
  }
68
68
 
69
69
  // Swipe in a cardinal direction.
@@ -135,32 +135,32 @@ class DeviceBridge {
135
135
 
136
136
  // Press a hardware/system button (BACK, HOME, ENTER, ...).
137
137
  async pressButton(button) {
138
- return this._call('mobile_press_button', { button });
138
+ return await this._call('mobile_press_button', { button });
139
139
  }
140
140
 
141
141
  // Launch an installed app by its package/bundle identifier.
142
142
  async launchApp(packageName) {
143
- return this._call('mobile_launch_app', { packageName });
143
+ return await this._call('mobile_launch_app', { packageName });
144
144
  }
145
145
 
146
146
  // Install an app from a local artifact (apk/ipa) path.
147
147
  async installApp(appPath) {
148
- return this._call('mobile_install_app', { path: appPath });
148
+ return await this._call('mobile_install_app', { path: appPath });
149
149
  }
150
150
 
151
151
  // Uninstall an app by its bundle/package identifier.
152
152
  async uninstallApp(appId) {
153
- return this._call('mobile_uninstall_app', { bundle_id: appId });
153
+ return await this._call('mobile_uninstall_app', { bundle_id: appId });
154
154
  }
155
155
 
156
156
  // Open a URL (deep link or web) on the device.
157
157
  async openUrl(url) {
158
- return this._call('mobile_open_url', { url });
158
+ return await this._call('mobile_open_url', { url });
159
159
  }
160
160
 
161
161
  // Set the device orientation ('portrait'/'landscape').
162
162
  async setOrientation(orientation) {
163
- return this._call('mobile_set_orientation', { orientation });
163
+ return await this._call('mobile_set_orientation', { orientation });
164
164
  }
165
165
 
166
166
  // Crash reports currently readable on the device, as the agnostic crash
@@ -133,8 +133,8 @@ async function createCall({
133
133
  throw connectError(err, connectTimeoutMs);
134
134
  }
135
135
 
136
- async function rawCall(toolName, args = {}) {
137
- const res = await client.callTool({ name: toolName, arguments: args });
136
+ async function rawCall(toolName, toolArgs = {}) {
137
+ const res = await client.callTool({ name: toolName, arguments: toolArgs });
138
138
  return parseToolResult(res);
139
139
  }
140
140
  const { call } = makeCall({ rawCall, device });
@@ -31,8 +31,6 @@ function defaultCreateCall(opts) {
31
31
  return require('./mobile-mcp-client').createCall(opts);
32
32
  }
33
33
 
34
- const NOOP_CLOSE = () => Promise.resolve();
35
-
36
34
  // Read the live daemon's pinned device from its handle, or null. A null pin
37
35
  // means the daemon serves whatever device mobile-mcp selected (matches any
38
36
  // request that doesn't pin a specific device).
@@ -386,7 +386,13 @@ async function startDaemon({
386
386
  clearFlush();
387
387
  if (!flush || stopping) return;
388
388
  if (!(delayMs > 0) || !Number.isFinite(delayMs)) return;
389
- flushTimer = setTimeout(async () => {
389
+ // tick() never rejects (its flush() is wrapped), so `void` is honest here.
390
+ flushTimer = setTimeout(() => {
391
+ void tick();
392
+ }, delayMs);
393
+ if (typeof flushTimer.unref === 'function') flushTimer.unref();
394
+
395
+ async function tick() {
390
396
  if (stopping) return;
391
397
  if (inFlight > 0) {
392
398
  armFlush(delayMs);
@@ -402,8 +408,7 @@ async function startDaemon({
402
408
  // current caller.
403
409
  }
404
410
  armFlush(next);
405
- }, delayMs);
406
- if (typeof flushTimer.unref === 'function') flushTimer.unref();
411
+ }
407
412
  }
408
413
 
409
414
  async function stop(reason = 'explicit') {
@@ -541,7 +546,27 @@ async function startDaemon({
541
546
  return true;
542
547
  };
543
548
 
544
- socket.on('data', async (chunk) => {
549
+ // A plain listener around an async body, never `socket.on('data', async …)`:
550
+ // the emitter ignores the returned promise, so a throw in the body would be
551
+ // an unhandled rejection, and bin/mauto-session-daemon.js exits on those.
552
+ // Found by @typescript-eslint/no-misused-promises (#164).
553
+ socket.on('data', (chunk) => {
554
+ handleData(chunk).catch(onHandlerError);
555
+ });
556
+
557
+ // Recorded, then the idle clock re-armed: handleData paused it for the
558
+ // frame that threw, and nothing else would restart it. The remaining frames
559
+ // of that chunk are lost; the socket and the daemon both keep serving.
560
+ function onHandlerError(err) {
561
+ safeObserve({
562
+ level: 'error',
563
+ event: 'daemon.handler_error',
564
+ message: err && (err.message || String(err)),
565
+ });
566
+ armIdle();
567
+ }
568
+
569
+ async function handleData(chunk) {
545
570
  const frames = parser.push(chunk);
546
571
  for (const f of frames) {
547
572
  // Pause the idle clock while we handle a frame so it can never fire
@@ -554,11 +579,20 @@ async function startDaemon({
554
579
  continue;
555
580
  }
556
581
  const req = f.value;
582
+ // Valid JSON is not yet a request: `null` would throw on `req.type`
583
+ // below, and a primitive or array is no frame the protocol defines.
584
+ if (req === null || typeof req !== 'object' || Array.isArray(req)) {
585
+ await reply({ id: null, ok: false, error: { message: 'malformed frame: not a JSON object' } });
586
+ armIdle();
587
+ continue;
588
+ }
557
589
 
558
590
  if (req.type === 'shutdown') {
559
591
  await reply({ id: req.id, ok: true, result: { stopping: true } });
560
592
  // Reply already flushed (awaited) — now tear down.
561
- setImmediate(() => stop('shutdown').catch(() => {}));
593
+ setImmediate(() => {
594
+ stop('shutdown').catch(() => {});
595
+ });
562
596
  continue;
563
597
  }
564
598
  if (req.type === 'ping') {
@@ -616,7 +650,7 @@ async function startDaemon({
616
650
  await reply({ id: req.id, ok: false, error: { message: `unknown request type: ${req.type}` } });
617
651
  armIdle();
618
652
  }
619
- });
653
+ }
620
654
 
621
655
  socket.on('error', () => {
622
656
  sockets.delete(socket);
package/src/mcp/server.js CHANGED
@@ -31,11 +31,14 @@ function buildServer({ projectRoot } = {}) {
31
31
  { capabilities: { prompts: {} } }
32
32
  );
33
33
 
34
- server.setRequestHandler(ListPromptsRequestSchema, async () => ({
34
+ // Plain functions, not async: both handlers are synchronous, and the SDK
35
+ // accepts a result or a promise of one. It calls them inside a .then(), so a
36
+ // throw (an unknown prompt name) still becomes an MCP error reply.
37
+ server.setRequestHandler(ListPromptsRequestSchema, () => ({
35
38
  prompts: handlers.listPrompts(),
36
39
  }));
37
40
 
38
- server.setRequestHandler(GetPromptRequestSchema, async (request) => {
41
+ server.setRequestHandler(GetPromptRequestSchema, (request) => {
39
42
  return handlers.getPrompt(request.params.name);
40
43
  });
41
44
 
@@ -12,6 +12,7 @@ const ENTRY_RE = /^-\s+\[(\d{4}-\d{2}-\d{2})\]\[asserted\]\s+(.*)$/;
12
12
 
13
13
  function sanitizeEntryText(raw) {
14
14
  return String(raw == null ? '' : raw)
15
+ // eslint-disable-next-line no-control-regex -- matching control chars is the point
15
16
  .replace(/[\u0000-\u001F\u007F]+/g, ' ') // control chars / newlines / tabs -> space
16
17
  .replace(/\s+/g, ' ')
17
18
  .trim();
@@ -46,6 +46,7 @@ const EVENT_NAMES = Object.freeze([
46
46
  'daemon.engine_exit',
47
47
  'daemon.lock_conflict',
48
48
  'daemon.undeliverable',
49
+ 'daemon.handler_error',
49
50
  // Emitted by the flush path, which lands in a later slice-5 task; declared
50
51
  // now because its event shape is already exercised by unit tests.
51
52
  'telemetry.flush',