mobile-automator 0.28.2 → 0.30.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,6 +8,7 @@ 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
+ [![Host](https://img.shields.io/badge/Host-macOS%20%7C%20Linux-lightgrey.svg)](#)
11
12
  [![Node](https://img.shields.io/badge/Node-%E2%89%A522.12-brightgreen.svg)](#)
12
13
 
13
14
  ---
@@ -24,6 +25,8 @@ Claude Code, Cursor, Gemini CLI, GitHub Copilot, and OpenAI Agents all have one-
24
25
  npm i -g mobile-automator # exposes `mauto` globally
25
26
  ```
26
27
 
28
+ > **Supported hosts:** macOS and Linux. Windows is not supported natively (npm refuses the install) — run `mauto` inside WSL2. See [TROUBLESHOOTING.md](TROUBLESHOOTING.md#-mauto-refuses-to-run-on-windows).
29
+
27
30
  <details>
28
31
  <summary>Or run it without installing / build from source</summary>
29
32
 
@@ -140,7 +143,7 @@ Most low-level verbs are called **by the agent**, not by you. The ones you'll ru
140
143
  | `mauto memory show [--kind <k>] [--scenario <id>]` | Print cross-session memory (run history auto-harvested from results) as raw markdown for the agent to read at run start |
141
144
  | `mauto memory add <text> --kind <app-knowledge\|preferences>` | Record a durable agent-learned app fact or user preference (`[asserted]`, de-duped) |
142
145
  | `mauto memory forget --kind <k> --match <substr>` | Remove agent-authored memory entries matching a substring (the correction path) |
143
- | `mauto validate <file>` | Validate a scenario JSON against the schema |
146
+ | `mauto validate <file> [--strict]` | Validate a scenario JSON against the schema; warns on actions that cannot replay (`--strict` fails) |
144
147
  | `mauto config get\|set <key> [value]` | Read / update workspace config |
145
148
  | **Agent-driven verbs** | `elements`, `tap`, `type`, `swipe`, `press`, `screenshot`, `assert`, `result`, `schema` |
146
149
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-automator",
3
- "version": "0.28.2",
3
+ "version": "0.30.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": {
@@ -45,5 +45,9 @@
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=22.12.0"
48
- }
48
+ },
49
+ "os": [
50
+ "darwin",
51
+ "linux"
52
+ ]
49
53
  }
package/src/cli.js CHANGED
@@ -11,6 +11,7 @@ const { DeviceBridge } = require('./device/bridge');
11
11
  const selectionStore = require('./device/selection');
12
12
  const { engineHint } = require('./device/engine-hints');
13
13
  const { ScenarioValidator } = require('./scenario/validator');
14
+ const { findUnsupportedActions } = require('./scenario/replayability');
14
15
  const { evaluate, MECHANICAL_TYPES } = require('./assertion/evaluator');
15
16
  const { ResultStore } = require('./result/store');
16
17
  const { OBSERVATION_TYPES, parseObservation, parseCapture, parseBool } = require('./result/flags');
@@ -25,6 +26,7 @@ const guideEmitter = require('./guide/emitter');
25
26
  const { ADAPTERS } = require('./init/adapters');
26
27
  const { isSemanticAction, ACTION_METHOD, selectResolver } = require('./device/semantic-press');
27
28
  const connection = require('./device/connection');
29
+ const { isSupportedHost, unsupportedHostError } = require('./device/host-support');
28
30
  const { record } = require('./observe/recorder');
29
31
  const { resolveRunId } = require('./observe/settings');
30
32
  const { runTracePath } = require('./observe/paths');
@@ -210,7 +212,7 @@ async function handleScreenshot({ deviceBridge, cwd = process.cwd() }, destPath)
210
212
  }
211
213
  }
212
214
 
213
- function handleValidate({ validator }, file) {
215
+ function handleValidate({ validator }, file, opts = {}) {
214
216
  let raw;
215
217
  try {
216
218
  raw = fs.readFileSync(file, 'utf8');
@@ -241,7 +243,20 @@ function handleValidate({ validator }, file) {
241
243
 
242
244
  const result = validator.validate(scenario);
243
245
  if (result.valid) {
244
- return { envelope: ok({ valid: true }), exitKind: 'ok' };
246
+ // Schema-valid is not the same as replayable: surface actions the catalog
247
+ // marks unsupported. A warning by default so existing scenarios keep
248
+ // validating; `--strict` makes it a failure (#166).
249
+ const warnings = findUnsupportedActions(scenario);
250
+ if (warnings.length === 0) {
251
+ return { envelope: ok({ valid: true }), exitKind: 'ok' };
252
+ }
253
+ const hint = `${warnings.length} action(s) cannot replay unattended — see data.warnings`;
254
+ if (opts.strict) {
255
+ const env = fail('invalid_input', 'scenario uses actions with no execution path', hint);
256
+ env.data = { valid: false, errors: [], warnings };
257
+ return { envelope: env, exitKind: 'invalid_input' };
258
+ }
259
+ return { envelope: ok({ valid: true, warnings }, hint), exitKind: 'ok' };
245
260
  }
246
261
 
247
262
  const env = fail(
@@ -253,6 +268,19 @@ function handleValidate({ validator }, file) {
253
268
  return { envelope: env, exitKind: 'invalid_input' };
254
269
  }
255
270
 
271
+ // The unsupported-host gate (#165), shared by every site that reaches the
272
+ // session layer: connectBridge (all device verbs) and the `session` verbs,
273
+ // which talk to connection.* directly. Null on a supported host; otherwise an
274
+ // `environment` failure (exit 5). Deliberately NOT routed through deviceFail —
275
+ // a `device` kind is exactly the misdiagnosis this exists to prevent: on
276
+ // Windows the session layer's Unix socket, `ps` and process-group kill fail in
277
+ // ways that read as "no device connected".
278
+ function hostGate(platform) {
279
+ if (isSupportedHost(platform)) return null;
280
+ const err = unsupportedHostError(platform);
281
+ return { envelope: fail(err.kind, err.message, err.hint), exitKind: err.kind };
282
+ }
283
+
256
284
  function deviceFail(err) {
257
285
  // Respect a typed error's kind (e.g. the daemon's 'timeout') so the envelope
258
286
  // and exit code reflect the real failure class; anything else defaults to
@@ -1562,6 +1590,8 @@ function buildProgram(deps = {}) {
1562
1590
  emitters = makeEmitters({ projectRoot }),
1563
1591
  // Sink used to emit the rendered envelope + drive the exit code.
1564
1592
  emit = emitters.emit,
1593
+ // Host OS, for the unsupported-host gate (#165). Overridable in tests.
1594
+ platform = process.platform,
1565
1595
  } = deps;
1566
1596
 
1567
1597
  const program = new Command();
@@ -1658,6 +1688,18 @@ function buildProgram(deps = {}) {
1658
1688
  return action(...args);
1659
1689
  };
1660
1690
 
1691
+ // Gate for verbs that reach the device session layer (#165). Runs before
1692
+ // anything is spawned or connected; see hostGate. Emits and returns true when
1693
+ // the host is unsupported, so a caller can bail out.
1694
+ const refuseUnsupportedHost = () => {
1695
+ const refusal = hostGate(platform);
1696
+ if (!refusal) return false;
1697
+ emit(refusal, humanFlag());
1698
+ return true;
1699
+ };
1700
+ const requireSupportedHost = (action) => (...args) =>
1701
+ (refuseUnsupportedHost() ? undefined : action(...args));
1702
+
1661
1703
  // Resolve which device a verb targets: explicit --device wins, else the
1662
1704
  // persisted selection, else null (mobile-mcp auto-selects a single device).
1663
1705
  // Keeping the no-selection fast path returning null preserves zero-config use.
@@ -1673,6 +1715,9 @@ function buildProgram(deps = {}) {
1673
1715
  // (the single connection seam), so this keeps both the envelope boundary and
1674
1716
  // the acquire-seam refactor.
1675
1717
  const connectBridge = async (device, fn) => {
1718
+ // Before the factory: on an unsupported host there is nothing to connect,
1719
+ // and a connect failure would land below as a misleading `device` kind.
1720
+ if (refuseUnsupportedHost()) return;
1676
1721
  let bridge;
1677
1722
  let close;
1678
1723
  try {
@@ -1754,8 +1799,9 @@ function buildProgram(deps = {}) {
1754
1799
  program
1755
1800
  .command('validate <file>')
1756
1801
  .description('Validate a scenario JSON file against the scenario schema')
1757
- .action(withEnvelope((file) => {
1758
- const r = handleValidate({ validator }, file);
1802
+ .option('--strict', 'fail (instead of warn) when the scenario uses actions that cannot replay unattended')
1803
+ .action(withEnvelope((file, opts) => {
1804
+ const r = handleValidate({ validator }, file, { strict: Boolean(opts && opts.strict) });
1759
1805
  emit(r, humanFlag());
1760
1806
  }));
1761
1807
 
@@ -2091,26 +2137,26 @@ function buildProgram(deps = {}) {
2091
2137
  .description('Start the device session daemon (subsequent verbs reuse its connection)')
2092
2138
  .option('--device <id>', 'pin the daemon to a target device id')
2093
2139
  .option('--idle <ms>', 'idle timeout in milliseconds before the daemon self-reaps')
2094
- .action(withEnvelope(requireWorkspace(async (opts) => {
2140
+ .action(withEnvelope(requireSupportedHost(requireWorkspace(async (opts) => {
2095
2141
  const r = await handleSessionStart({ projectRoot }, { device: opts.device, idle: opts.idle });
2096
2142
  emit(r, humanFlag());
2097
- })));
2143
+ }))));
2098
2144
 
2099
2145
  session
2100
2146
  .command('status')
2101
2147
  .description('Report whether a device session daemon is running')
2102
- .action(withEnvelope(async () => {
2148
+ .action(withEnvelope(requireSupportedHost(async () => {
2103
2149
  const r = await handleSessionStatus({ projectRoot });
2104
2150
  emit(r, humanFlag());
2105
- }));
2151
+ })));
2106
2152
 
2107
2153
  session
2108
2154
  .command('end')
2109
2155
  .description('Stop the device session daemon and remove its socket/pidfile')
2110
- .action(withEnvelope(async () => {
2156
+ .action(withEnvelope(requireSupportedHost(async () => {
2111
2157
  const r = await handleSessionEnd({ projectRoot });
2112
2158
  emit(r, humanFlag());
2113
- }));
2159
+ })));
2114
2160
 
2115
2161
  // --- Issue #92: device discovery + persisted selection -------------------
2116
2162
 
@@ -2135,6 +2181,12 @@ function buildProgram(deps = {}) {
2135
2181
  devices
2136
2182
  .command('clear')
2137
2183
  .description('Remove the persisted device selection')
2184
+ // Deliberately OUTSIDE the host gate (#165): its only session-layer probe,
2185
+ // sessionClient.isAlive, is a socket ping — it never runs `ps` or signals a
2186
+ // process, so it answers safely on an unsupported host too. That invariant
2187
+ // is executable, not just prose: the canary in
2188
+ // tests/unit/cli-host-support.test.js ("devices clear stays process-free")
2189
+ // fails if this verb ever starts spawning child processes.
2138
2190
  .action(withEnvelope(async () => {
2139
2191
  const r = await handleDevicesClear({ projectRoot });
2140
2192
  emit(r, humanFlag());
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ // Which hosts can run the device session, and the error for the ones that
4
+ // cannot (#165). The session daemon binds a Unix domain socket, and the
5
+ // session layer identifies and reaps its daemon with `ps`, process-group kill
6
+ // (`kill(-pid)`) and POSIX signals. None of that exists on Windows, where it
7
+ // used to fail as a confusing `device` error — as if no phone were connected —
8
+ // rather than saying the host itself is unsupported.
9
+ //
10
+ // package.json `os` is the single source of truth: npm refuses the install on
11
+ // any other host, and this guard catches what slips past it (`--force`,
12
+ // `npm link`, a vendored copy). Pure — no I/O; the caller decides how to emit.
13
+
14
+ const pkg = require('../../package.json');
15
+
16
+ const SUPPORTED_HOSTS = Object.freeze([...(pkg.os || [])]);
17
+
18
+ // Human names for the error text — the ONE copy; the docs-agreement lint test
19
+ // (tests/lint/host-support-agreement.test.js) consumes it rather than
20
+ // restating it. Deliberately no fallback: a host added to package.json `os`
21
+ // without a name here is a test failure and a crash at render time, not a raw
22
+ // platform id leaking into the user-facing error.
23
+ const HOST_NAMES = Object.freeze({ darwin: 'macOS', linux: 'Linux' });
24
+
25
+ const TROUBLESHOOTING_REF = 'see TROUBLESHOOTING.md ("mauto refuses to run on Windows")';
26
+
27
+ // Per-host guidance. Unknown hosts get the generic pointer instead of a
28
+ // Windows-specific instruction — a freebsd user must not be told about WSL2.
29
+ const HOST_HINTS = Object.freeze({
30
+ win32: `On Windows, install and run mauto inside WSL2 (a Linux distribution); ${TROUBLESHOOTING_REF}.`,
31
+ });
32
+
33
+ // For hosts with no specific hint: point at the troubleshooting entry without
34
+ // naming an OS-specific route.
35
+ const GENERIC_HINT = `See TROUBLESHOOTING.md for where mauto runs and how to get there.`;
36
+
37
+ function isSupportedHost(platform = process.platform) {
38
+ return SUPPORTED_HOSTS.includes(platform);
39
+ }
40
+
41
+ function unsupportedHostError(platform = process.platform) {
42
+ const names = SUPPORTED_HOSTS.map((p) => HOST_NAMES[p]).join(' or ');
43
+ const err = new Error(
44
+ `mauto's device session needs a POSIX host (${names}); "${platform}" is not supported`
45
+ );
46
+ err.kind = 'environment';
47
+ err.hint = HOST_HINTS[platform] || GENERIC_HINT;
48
+ return err;
49
+ }
50
+
51
+ module.exports = { SUPPORTED_HOSTS, HOST_NAMES, isSupportedHost, unsupportedHostError };
@@ -58,18 +58,19 @@ Record each observation with `mauto result add-step --observation <type>:<messag
58
58
  4. **App under test.**
59
59
  - If the user pointed you at a prebuilt app artifact, install it with `mauto install <path>`.
60
60
  - Otherwise verify the app is already installed; if it is not, halt and ask the user to install it. This guide never builds.
61
+ - **Fresh install.** If the scenario's `preconditions.app_state` is `fresh_install` and the user pointed you at a prebuilt app artifact, uninstall the app first with `mauto uninstall <appId>` (the app identifier from `mobile-automator/config.json` — `android_package` / `ios_bundle_id`) and only then install that artifact, so the run starts with no app data. Without an artifact, do not uninstall — you could not reinstall the app. Leave it in place and report that the app state was not reset, so a failure that depends on existing app data is not mistaken for a regression.
61
62
  - If the install fails because a different build is already on the device, uninstall it with `mauto uninstall <appId>` (the app identifier from `mobile-automator/config.json` — `android_package` / `ios_bundle_id`), install again, and report that app data was wiped — a scenario whose preconditions assume existing app state may now fail for that reason.
62
63
  - State which path you took before continuing.
63
64
  5. **Validate schema version:** read the `$schema_version` field; it must be `"2.1"`. If absent or unrecognized, report an error and halt.
64
- 6. Validate the scenario with `mauto validate <path>` against `mauto schema scenario`.
65
+ 6. Validate the scenario with `mauto validate <path>` against `mauto schema scenario`. If the result lists `data.warnings`, each entry (its `path` names the step or precondition) uses an action that is unsupported on this run: report it as unsupported in the result (see "Not mechanically executable" under step execution) and do not substitute a gesture for it.
65
66
 
66
67
  **Precondition `device_actions`** (from the scenario's `preconditions`, run before step replay):
67
68
 
68
- | Device action | `mauto` verb |
69
- |---|---|
70
- | `install_app` | `mauto install <path>` |
71
- | `uninstall_app` | `mauto uninstall <appId>` |
72
- | `set_orientation` | `mauto orientation <portrait\|landscape>` |
69
+ | Device action | `mauto` verb | Notes |
70
+ |---|---|---|
71
+ | `install_app` | `mauto install <path>` | `path` = the device action's `value` if set and the file exists, else the build artifact used in pre-flight. If neither is available, report this precondition as not executed |
72
+ | `uninstall_app` | `mauto uninstall <appId>` | `appId` = the device action's `target_package` if set, else the app identifier from `mobile-automator/config.json` (`android_package` / `ios_bundle_id`) for the connected device's platform. If a later `install_app` in the same block has no resolvable path, skip this uninstall too and report both as not executed — never leave the device without the app under test |
73
+ | `set_orientation` | `mauto orientation <portrait\|landscape>` | |
73
74
 
74
75
  The remaining precondition actions are not mechanically executable (see step execution): report them honestly or perform them out of band.
75
76
 
@@ -56,18 +56,19 @@ Record each observation with `mauto result add-step --observation <type>:<messag
56
56
  - **App under test.** If the user pointed you at a prebuilt artifact, install it with `mauto install <path>` and do NOT build — reusing a ready artifact is the point.
57
57
  - If no artifact was named: check whether the app is already installed. If it is, say so and ask whether to rebuild before running the build command ({{build_command}}) — a previous run may have just built it. If it is not installed, build and install with the build command ({{build_command}}).
58
58
  - If that install fails because a different build of the app is already on the device, uninstall it — run `mauto uninstall` with the app package ({{app_package}}) — then install again, and report that app data was wiped — a scenario whose preconditions assume existing app state may now fail for that reason.
59
+ - **Fresh install.** If the scenario's `preconditions.app_state` is `fresh_install` (read it from the scenario file now), uninstall the app first — `mauto uninstall` with the app package ({{app_package}}) — and only then install, so the run starts with no app data.
59
60
  - State which path you took — installed a supplied artifact, or built — before continuing.
60
61
 
61
62
  ### 2. Load Scenario
62
63
 
63
64
  - Read the JSON scenario file from `mobile-automator/scenarios/`.
64
65
  - **Validate schema version:** Read the `$schema_version` field. It must be `"2.1"`. If absent or unrecognized, report an error and halt.
65
- - Validate the scenario with `mauto validate <path>` against `mauto schema scenario`.
66
+ - Validate the scenario with `mauto validate <path>` against `mauto schema scenario`. If the result lists `data.warnings`, each entry (its `path` names the step or precondition) uses an action that is unsupported on this run: report it as unsupported in the result, as described under "Not mechanically executable" below, and do not substitute a gesture for it.
66
67
  - Read the `metadata` to compare the recording environment against the current execution environment. Note any differences (different device, API level, environment).
67
68
 
68
69
  ### 3. Verify Preconditions
69
70
 
70
- - **Note:** preconditions like `app_uninstalled` and `fresh_install` are handled by the `execute` pre-flight before this workflow runs.
71
+ - **Note:** an `app_state` of `fresh_install` is handled in pre-flight (uninstall before the install, see above), and precondition `device_actions` run per the table in step 4.
71
72
  - Review the scenario's `preconditions` array to understand the expected starting state.
72
73
  - If any preconditions are state-based (e.g., `"user_logged_out"`, `"no_network"`, `"dark_mode_enabled"`), verify or configure them now:
73
74
  - For app-state preconditions: launch the app and check/configure the state.
@@ -121,8 +122,8 @@ For each step in the scenario:
121
122
 
122
123
  | Device action | `mauto` verb | Notes |
123
124
  |---|---|---|
124
- | `install_app` | `mauto install <path>` | `path` = the build artifact to install |
125
- | `uninstall_app` | `mauto uninstall <appId>` | `appId` from config / scenario metadata |
125
+ | `install_app` | `mauto install <path>` | `path` = the device action's `value` if set and the file exists, else the build artifact used in pre-flight. If neither is available, report this precondition as not executed |
126
+ | `uninstall_app` | `mauto uninstall <appId>` | `appId` = the device action's `target_package` if set, else the app package ({{app_package}}). If a later `install_app` in the same block has no resolvable path, skip this uninstall too and report both as not executed — never leave the device without the app under test |
126
127
  | `set_orientation` | `mauto orientation <portrait\|landscape>` | `value` = target orientation |
127
128
 
128
129
  **Not mechanically executable** — these actions have no `mauto` verb and no underlying primitive. Do NOT improvise a gesture for them: report them honestly in the result (as unsupported on this run) and, if the run genuinely depends on them, perform them manually out of band before continuing.
@@ -100,7 +100,7 @@ Translate user language to action types and `mauto` verbs:
100
100
  |---|---|---|---|
101
101
  | "open the app", "launch" | `launch_app` | `mauto press` / launch the app under test | |
102
102
  | "uninstall", "remove the app" | (precondition `device_action`) | uninstall before scenario | |
103
- | "clear app data", "fresh install" | `clear_app_data` | clear app data before scenario | Set `app_state: "fresh_install"` in preconditions |
103
+ | "clear app data", "fresh install" | `uninstall_app` + `install_app` | reset app to a clean state before scenario | Set `app_state: "fresh_install"` in preconditions and add `device_actions`: `[{ "action": "uninstall_app" }, { "action": "install_app" }]`. Leave out `target_package` and `value`: a cross-platform scenario must not bake in one platform's app identifier or build path, and the executor fills both per platform from config and the pre-flight build |
104
104
  | "tap", "click", "press" | `tap` | `mauto tap --at <x,y>` | Resolve coords from `mauto elements` |
105
105
  | "long press", "hold" | `long_press` | `mauto tap --at <x,y>` (long-press variant) | Resolve coords from `mauto elements` |
106
106
  | "double tap", "double click" | `double_tap` | `mauto tap --at <x,y>` (double-tap variant) | Resolve coords from `mauto elements` |
@@ -202,7 +202,7 @@ After all steps are executed and recorded:
202
202
  2. Obtain the scenario schema by running `mauto schema scenario`, and assemble the JSON scenario to match it. Include the `tags` array.
203
203
  3. **Always include `"$schema_version": "2.1"` as the first field** in agnostic-mode scenarios.
204
204
  4. **Use named string IDs** for all steps and assertions (snake_case, e.g., `"id": "tap_login"`).
205
- 5. Validate the assembled scenario with `mauto validate <path>` against `mauto schema scenario`.
205
+ 5. Validate the assembled scenario with `mauto validate <path>` against `mauto schema scenario`. If it returns `data.warnings`, revise the scenario to drop those steps, or tell the user the step needs manual handling.
206
206
  6. Save to `mobile-automator/scenarios/<scenario_id>.json`.
207
207
  7. Present summary:
208
208
  > "Scenario saved: `mobile-automator/scenarios/<scenario_id>.json`
@@ -217,7 +217,7 @@ After all steps are executed and recorded:
217
217
  - `app_version` — from the installed app
218
218
  - `environment` — ask the user or use `default_environment` from `mobile-automator/config.json`
219
219
  - Do NOT include `device_model`, `api_level`, or `timestamp` in metadata — these belong in the result schema, not the scenario.
220
- 7. Validate the assembled scenario with `mauto validate <path>` against `mauto schema scenario`.
220
+ 7. Validate the assembled scenario with `mauto validate <path>` against `mauto schema scenario`. If it returns `data.warnings`, revise the scenario to drop those steps, or tell the user the step needs manual handling.
221
221
  8. Save to `mobile-automator/scenarios/<scenario_id>.json`.
222
222
  9. Present summary:
223
223
  > "Scenario saved: `mobile-automator/scenarios/<scenario_id>.json`
@@ -232,7 +232,7 @@ Translate user language to `mauto` verbs and schema actions:
232
232
  |---|---|---|---|
233
233
  | "open the app", "launch" | `launch_app` | `mauto press` / launch the app under test | |
234
234
  | "uninstall", "remove the app" | (precondition device_action) | uninstall before scenario | Use `device_actions` in `preconditions` block |
235
- | "clear app data", "fresh install" | `clear_app_data` | clear app data before scenario | Also set `app_state: "fresh_install"` in preconditions |
235
+ | "clear app data", "fresh install" | `uninstall_app` + `install_app` | reset app to a clean state before scenario | Set `app_state: "fresh_install"` in preconditions and add `device_actions`: `uninstall_app` then `install_app`. Both fields are optional: `target_package` defaults to the config app package ({{app_package}}) and `install_app` `value` defaults to the build installed in pre-flight; set them only to override those |
236
236
  | "tap", "click", "press" | `tap` | `mauto tap --at <x,y>` | Resolve `<x,y>` from `mauto elements` |
237
237
  | "long press", "hold" | `long_press` | `mauto tap --at <x,y>` (long-press variant) | Resolve coords from `mauto elements` |
238
238
  | "double tap", "double click" | `double_tap` | `mauto tap --at <x,y>` (double-tap variant) | Resolve coords from `mauto elements` |
@@ -268,7 +268,7 @@ Translate user language to `mauto` verbs and schema actions:
268
268
  - Create assertion step using mapped type from Pass 2b
269
269
  - Evaluate via `mauto assert <type> ...` and record with appropriate fields
270
270
  6. Combine steps and assertions into ordered scenario
271
- 7. Generate scenario JSON, then `mauto validate <path>`
271
+ 7. Generate scenario JSON, then `mauto validate <path>` (if it returns `data.warnings`, revise the scenario to drop those steps, or tell the user the step needs manual handling)
272
272
  ```
273
273
 
274
274
  ### Detecting and Encoding Patterns
@@ -1,5 +1,5 @@
1
1
  - **You do NOT plan or suggest steps.** The user tells you exactly what to do; you execute, document, and verify — never add extra steps.
2
2
  - Capture a screenshot after every single step. No exceptions.
3
3
  - Drive the device **only** through `mauto` verbs; never assume resource-ids or OS-specific element IDs.
4
- - Assemble the scenario JSON to match `mauto schema scenario`: use named string IDs (snake_case, never integers), then validate with `mauto validate <path>` before finishing.
4
+ - Assemble the scenario JSON to match `mauto schema scenario`: use named string IDs (snake_case, never integers), then validate with `mauto validate <path>` before finishing, and resolve any `data.warnings` it reports.
5
5
  - If this workspace has memory, read it first with `mauto memory show`; save durable app facts and user corrections with `mauto memory add`.
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ // Replayability check for scenarios (#166). The scenario schema accepts every
4
+ // action in its enum, but the action catalog marks some `unsupported`: mobile-mcp
5
+ // exposes no primitive for them, so no `mauto` verb can run them and the step
6
+ // can never replay unattended. Schema validity alone therefore overstates what
7
+ // a scenario can do; this module reports the gap.
8
+ //
9
+ // The unsupported set is derived from the catalog, never restated here, so it
10
+ // moves when an action gains (or loses) a device primitive.
11
+
12
+ const { actionsByResolution } = require('../device/action-catalog');
13
+
14
+ function finding(action, path, stepId) {
15
+ const f = {
16
+ code: 'unsupported_action',
17
+ action,
18
+ path,
19
+ message: `${action} has no execution path in mauto; this step cannot replay unattended`,
20
+ };
21
+ if (stepId !== undefined) f.step_id = stepId;
22
+ return f;
23
+ }
24
+
25
+ // Walks a step list depth-first in document order, descending into each
26
+ // step's `sub_steps` (the schema types them as steps, so they nest freely).
27
+ function scanSteps(steps, basePath, unsupported, findings) {
28
+ if (!Array.isArray(steps)) return;
29
+ steps.forEach((step, i) => {
30
+ if (!step) return;
31
+ const path = `${basePath}[${i}]`;
32
+ if (unsupported.has(step.action)) {
33
+ findings.push(finding(step.action, path, step.id));
34
+ }
35
+ scanSteps(step.sub_steps, `${path}.sub_steps`, unsupported, findings);
36
+ });
37
+ }
38
+
39
+ // Returns one finding per use of an unsupported action: steps first (including
40
+ // nested sub_steps, depth-first in document order), then precondition device
41
+ // actions in document order.
42
+ function findUnsupportedActions(scenario) {
43
+ const unsupported = new Set(actionsByResolution('unsupported'));
44
+ const findings = [];
45
+
46
+ scanSteps(scenario && scenario.steps, 'steps', unsupported, findings);
47
+
48
+ const pre = scenario && scenario.preconditions;
49
+ const deviceActions = pre && Array.isArray(pre.device_actions) ? pre.device_actions : [];
50
+ deviceActions.forEach((da, i) => {
51
+ if (da && unsupported.has(da.action)) {
52
+ findings.push(finding(da.action, `preconditions.device_actions[${i}]`));
53
+ }
54
+ });
55
+
56
+ return findings;
57
+ }
58
+
59
+ module.exports = { findUnsupportedActions };
@@ -140,15 +140,15 @@
140
140
  "disable_wifi",
141
141
  "set_orientation"
142
142
  ],
143
- "description": "The setup action to perform"
143
+ "description": "The setup action to perform. enable_wifi, disable_wifi and clear_app_data are accepted by the schema but are not mechanically executable (no device primitive), so they cannot replay unattended; mauto validate warns."
144
144
  },
145
145
  "target_package": {
146
146
  "type": "string",
147
- "description": "App package to target (for clear_app_data and uninstall_app)"
147
+ "description": "App package to target, for uninstall_app; defaults to the config app id"
148
148
  },
149
149
  "value": {
150
150
  "type": "string",
151
- "description": "Value parameter for the action (e.g., orientation value)"
151
+ "description": "Value parameter for the action: the orientation for set_orientation, or the build path for install_app (defaults to the pre-flight build)"
152
152
  },
153
153
  "description": {
154
154
  "type": "string",
@@ -247,7 +247,7 @@
247
247
  "grant_permission",
248
248
  "deny_permission"
249
249
  ],
250
- "description": "The automation action to perform. Supported types: wait_for_element, wait_for_element_gone, wait_for_loading_complete, capture_value, clear_app_data, long_press, double_tap, scroll_to_element, press_back, dismiss_keyboard, grant_permission, deny_permission."
250
+ "description": "The automation action to perform. Supported types: wait_for_element, wait_for_element_gone, wait_for_loading_complete, capture_value, long_press, double_tap, scroll_to_element, press_back, dismiss_keyboard, grant_permission, deny_permission. clear_app_data is accepted by the schema but is not mechanically executable: mobile-mcp exposes no primitive for it, so it cannot replay unattended (mauto validate warns)."
251
251
  },
252
252
  "description": {
253
253
  "type": "string",