mobile-automator 0.23.8

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.
Files changed (61) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +167 -0
  3. package/bin/mauto-session-daemon.js +101 -0
  4. package/bin/mauto.js +13 -0
  5. package/package.json +49 -0
  6. package/src/assertion/evaluator.js +285 -0
  7. package/src/cli.js +1722 -0
  8. package/src/config/coerce.js +97 -0
  9. package/src/config/manager.js +75 -0
  10. package/src/config/schema.js +124 -0
  11. package/src/device/action-catalog.js +99 -0
  12. package/src/device/bridge.js +139 -0
  13. package/src/device/connection.js +65 -0
  14. package/src/device/device-model.js +59 -0
  15. package/src/device/device-resolver.js +34 -0
  16. package/src/device/element-model.js +82 -0
  17. package/src/device/mobile-mcp-client.js +163 -0
  18. package/src/device/resolve-connection.js +156 -0
  19. package/src/device/selection.js +80 -0
  20. package/src/device/semantic-press/index.js +38 -0
  21. package/src/device/semantic-press/labels.js +39 -0
  22. package/src/device/semantic-press/resolver.js +100 -0
  23. package/src/device/session-client.js +173 -0
  24. package/src/device/session-daemon.js +526 -0
  25. package/src/device/session-paths.js +80 -0
  26. package/src/device/session-protocol.js +60 -0
  27. package/src/device/session-spawn.js +67 -0
  28. package/src/device/tool-args.js +14 -0
  29. package/src/guide/content/execute.agnostic.md +207 -0
  30. package/src/guide/content/execute.aware.md +281 -0
  31. package/src/guide/content/execute.invariants.md +4 -0
  32. package/src/guide/content/generate.agnostic.md +236 -0
  33. package/src/guide/content/generate.aware.md +327 -0
  34. package/src/guide/content/generate.invariants.md +5 -0
  35. package/src/guide/content/setup.agnostic.md +63 -0
  36. package/src/guide/content/setup.aware.md +91 -0
  37. package/src/guide/content/setup.invariants.md +3 -0
  38. package/src/guide/emitter.js +137 -0
  39. package/src/guide/placeholders.js +132 -0
  40. package/src/init/adapters.js +162 -0
  41. package/src/init/skill-meta.js +31 -0
  42. package/src/init/skill-renderer.js +60 -0
  43. package/src/mcp/prompts.js +55 -0
  44. package/src/mcp/server.js +55 -0
  45. package/src/memory/entries.js +61 -0
  46. package/src/memory/history.js +86 -0
  47. package/src/memory/lock.js +5 -0
  48. package/src/memory/paths.js +47 -0
  49. package/src/memory/store.js +230 -0
  50. package/src/output/envelope.js +106 -0
  51. package/src/result/capability-catalog.js +144 -0
  52. package/src/result/flags.js +52 -0
  53. package/src/result/store.js +273 -0
  54. package/src/scenario/validator.js +30 -0
  55. package/src/schemas/config_schema.json +68 -0
  56. package/src/schemas/format-error.js +19 -0
  57. package/src/schemas/result_schema.json +211 -0
  58. package/src/schemas/scenario_schema.json +587 -0
  59. package/src/setup/scaffold.js +53 -0
  60. package/src/util/atomic.js +41 -0
  61. package/src/util/lock.js +85 -0
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+
3
+ // Single source of truth binding every FACT a result file can carry to the
4
+ // three things that must agree and never drift: the result schema (does the
5
+ // file have a home for it), the ResultStore (does a method write it), and the
6
+ // `mauto result` verb surface (can an agent actually supply it).
7
+ //
8
+ // tests/lint/result-coverage.test.js derives its assertions from this map, so
9
+ // a capability that loses its CLI reach fails the build instead of silently
10
+ // producing an empty field — which is exactly how #140 went unnoticed. This
11
+ // mirrors src/device/action-catalog.js, whose guard (#117) covers device
12
+ // ACTIONS and has no jurisdiction over the result verbs.
13
+ //
14
+ // `writeCheck` declares how the guard proves the ResultStore method actually
15
+ // writes the field, because a plain `store.js.includes(writes)` check is only
16
+ // trustworthy when `writes` names a token unique to that write site:
17
+ // 'substring' — `writes` is a call/assignment-shaped token (e.g.
18
+ // `this._assertions.push(entry)`) that occurs nowhere else in
19
+ // store.js, so deleting the real write makes it disappear.
20
+ // 'behavioral' — the field is a bare scalar (e.g. `screenshot`) that also
21
+ // appears as a destructured parameter name or elsewhere in
22
+ // store.js, so no substring can distinguish "written" from
23
+ // "merely accepted". These are instead proven by a live
24
+ // ResultStore run in tests/lint/result-coverage.test.js.
25
+ const RESULT_CAPABILITIES = {
26
+ screenshot: {
27
+ verb: 'add-step',
28
+ flags: ['--screenshot'],
29
+ store: 'addStep',
30
+ writes: 'screenshot',
31
+ writeCheck: 'behavioral',
32
+ schemaPointer: '/properties/steps_executed/items/properties/screenshot',
33
+ },
34
+ error_message: {
35
+ verb: 'add-step',
36
+ flags: ['--error-message'],
37
+ store: 'addStep',
38
+ writes: 'error_message',
39
+ writeCheck: 'behavioral',
40
+ schemaPointer: '/properties/steps_executed/items/properties/error_message',
41
+ },
42
+ step_status: {
43
+ verb: 'add-step',
44
+ flags: ['--status'],
45
+ store: 'addStep',
46
+ writes: 'status: normalized',
47
+ writeCheck: 'substring',
48
+ schemaPointer: '/properties/steps_executed/items/properties/status',
49
+ },
50
+ flakiness_observation: {
51
+ verb: 'add-step',
52
+ flags: ['--attempts'],
53
+ store: 'addStep',
54
+ // `--attempts` carries no field of its own; it drives the retry-derived
55
+ // flakiness observation store.js pushes onto the run-level `observations`
56
+ // array (see ResultStore#addStep), so it is bound to that schema node.
57
+ writes: "type: 'flakiness'",
58
+ writeCheck: 'substring',
59
+ schemaPointer: '/properties/observations',
60
+ },
61
+ observation: {
62
+ verb: 'add-step',
63
+ flags: ['--observation'],
64
+ store: 'addObservation',
65
+ writes: 'this._observations.push(entry)',
66
+ writeCheck: 'substring',
67
+ schemaPointer: '/properties/observations',
68
+ },
69
+ captured_variable: {
70
+ verb: 'add-step',
71
+ flags: ['--capture'],
72
+ store: 'captureVariable',
73
+ writes: 'this._capturedVariables[name] = value',
74
+ writeCheck: 'substring',
75
+ schemaPointer: '/properties/captured_variables',
76
+ },
77
+ assertion_verdict: {
78
+ verb: 'add-assertion',
79
+ flags: ['--type', '--pass', '--assertion-id', '--message', '--expected', '--actual'],
80
+ store: 'addAssertion',
81
+ writes: 'this._assertions.push(entry)',
82
+ writeCheck: 'substring',
83
+ schemaPointer: '/properties/assertion_results',
84
+ },
85
+ run_status: {
86
+ verb: 'finalize',
87
+ flags: ['--status'],
88
+ store: 'finalize',
89
+ writes: 'status: resolvedStatus',
90
+ writeCheck: 'substring',
91
+ schemaPointer: '/properties/status',
92
+ },
93
+ duration: {
94
+ verb: 'finalize',
95
+ flags: ['--duration'],
96
+ store: 'finalize',
97
+ writes: 'duration_seconds: Number(durationSeconds) || 0',
98
+ writeCheck: 'substring',
99
+ schemaPointer: '/properties/duration_seconds',
100
+ },
101
+ run_metadata: {
102
+ verb: 'finalize',
103
+ flags: ['--app-version', '--device-model', '--api-level', '--environment'],
104
+ store: 'finalize',
105
+ writes: 'metadata',
106
+ writeCheck: 'behavioral',
107
+ schemaPointer: '/properties/metadata',
108
+ },
109
+ summary: {
110
+ verb: 'finalize',
111
+ flags: ['--summary'],
112
+ store: 'finalize',
113
+ writes: 'summary',
114
+ writeCheck: 'behavioral',
115
+ schemaPointer: '/properties/summary',
116
+ },
117
+ };
118
+
119
+ // Flags that identify WHICH run/step is being recorded, rather than carrying
120
+ // a result fact of their own. Excluded from the no-orphan check. `--status`,
121
+ // `--duration` and `--attempts` are deliberately NOT here — each carries a
122
+ // fact of its own (step/run verdict, run duration, retry-derived flakiness)
123
+ // and has its own RESULT_CAPABILITIES entry above.
124
+ const IDENTITY_FLAGS = new Set([
125
+ '--run-id',
126
+ '--scenario-id',
127
+ '--step-id',
128
+ ]);
129
+
130
+ // Top-level result-schema properties that legitimately have no capability
131
+ // entry: no `mauto result` flag should ever set them directly. Each entry
132
+ // names why, so growing this list always carries its own justification
133
+ // instead of becoming a silent escape hatch from the completeness guard in
134
+ // tests/lint/result-coverage.test.js.
135
+ const NO_FLAG_ALLOWLIST = {
136
+ run_id: 'supplied via the --run-id identity flag on every result verb, not a capability-carried fact',
137
+ scenario_id: 'supplied via the --scenario-id identity flag, not a capability-carried fact',
138
+ schema_version: 'constant emitted by ResultStore (SCHEMA_VERSION); never agent-supplied',
139
+ total_assertions: 'derived by ResultStore.finalize by counting assertion_results; no independent flag by design',
140
+ passed_assertions: 'derived by ResultStore.finalize by counting assertion_results; no independent flag by design',
141
+ failed_assertions: 'derived by ResultStore.finalize by counting assertion_results; no independent flag by design',
142
+ };
143
+
144
+ module.exports = { RESULT_CAPABILITIES, IDENTITY_FLAGS, NO_FLAG_ALLOWLIST };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ // Pure parsers for the composite `result` flags (#140). Kept free of
4
+ // commander and the filesystem so the fiddly cases — a message that itself
5
+ // contains a colon, a captured value that contains '=' — are unit-testable
6
+ // in isolation. Each parser returns { value } or { error }; callers turn
7
+ // `error` into a fail('invalid_input', …) envelope so an unknown observation
8
+ // type is rejected loudly instead of being silently dropped.
9
+
10
+ // Mirrors the enum at /properties/observations/items/properties/type in
11
+ // src/schemas/result_schema.json. tests/lint/result-coverage.test.js asserts
12
+ // the two never drift.
13
+ const OBSERVATION_TYPES = ['regression', 'flakiness', 'state_context'];
14
+
15
+ function parseObservation(spec) {
16
+ const raw = String(spec);
17
+ const idx = raw.indexOf(':');
18
+ if (idx === -1) {
19
+ return { error: `--observation "${raw}" must be <type>:<message>` };
20
+ }
21
+ const type = raw.slice(0, idx).trim();
22
+ // Split on the FIRST colon only: observation messages routinely contain
23
+ // colons ("dark mode: reference was light").
24
+ const message = raw.slice(idx + 1).trim();
25
+ if (!OBSERVATION_TYPES.includes(type)) {
26
+ return {
27
+ error: `unknown observation type "${type}" (expected ${OBSERVATION_TYPES.join(' | ')})`,
28
+ };
29
+ }
30
+ if (!message) return { error: `--observation "${raw}" has an empty message` };
31
+ return { value: { type, message } };
32
+ }
33
+
34
+ function parseCapture(spec) {
35
+ const raw = String(spec);
36
+ const idx = raw.indexOf('=');
37
+ if (idx === -1) return { error: `--capture "${raw}" must be <name>=<value>` };
38
+ const name = raw.slice(0, idx).trim();
39
+ // NOT trimmed and NOT rejected when empty: a captured field can legitimately
40
+ // be the empty string, and trimming would corrupt a captured value.
41
+ const value = raw.slice(idx + 1);
42
+ if (!name) return { error: `--capture "${raw}" has an empty variable name` };
43
+ return { value: { name, value } };
44
+ }
45
+
46
+ function parseBool(raw, flagName) {
47
+ if (raw === 'true') return { value: true };
48
+ if (raw === 'false') return { value: false };
49
+ return { error: `${flagName} must be "true" or "false" (got "${raw}")` };
50
+ }
51
+
52
+ module.exports = { OBSERVATION_TYPES, parseObservation, parseCapture, parseBool };
@@ -0,0 +1,273 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const { atomicWrite } = require('../util/atomic');
7
+ const { withLock } = require('../util/lock');
8
+ const { OBSERVATION_TYPES } = require('./flags');
9
+
10
+ // Incremental result accumulator that persists to
11
+ // <projectRoot>/mobile-automator/results/<runId>.json
12
+ //
13
+ // The CLI runs one-shot: each `result add-step` / `result finalize` invocation
14
+ // is a fresh process. To support that, the store loads any existing in-progress
15
+ // file for the run on construction and appends to it, so state survives across
16
+ // invocations until finalize writes the schema-conformant result.
17
+
18
+ const SCHEMA_VERSION = '2.0';
19
+
20
+ function defaultMetadata(overrides = {}) {
21
+ return {
22
+ app_version: overrides.app_version || 'unknown',
23
+ device_model: overrides.device_model || 'unknown',
24
+ api_level: overrides.api_level || 'unknown',
25
+ environment: overrides.environment || 'unknown',
26
+ timestamp: overrides.timestamp || new Date().toISOString(),
27
+ };
28
+ }
29
+
30
+ // Normalize the caller-friendly status ('pass'/'fail') to the schema enums.
31
+ function stepStatus(status) {
32
+ if (status === 'pass' || status === 'passed') return 'passed';
33
+ if (status === 'fail' || status === 'failed') return 'failed';
34
+ if (status === 'skipped' || status === 'skip') return 'skipped';
35
+ return 'error';
36
+ }
37
+
38
+ function assertionStatus(pass) {
39
+ return pass ? 'passed' : 'failed';
40
+ }
41
+
42
+ class ResultStore {
43
+ constructor({ runId, scenarioId, projectRoot, metadata } = {}) {
44
+ if (!runId) throw new Error('ResultStore requires a runId');
45
+ if (!projectRoot) throw new Error('ResultStore requires a projectRoot');
46
+
47
+ this.runId = runId;
48
+ this.scenarioId = scenarioId || null;
49
+ this.projectRoot = projectRoot;
50
+ this._metadataOverrides = metadata || {};
51
+
52
+ this._dir = path.join(projectRoot, 'mobile-automator', 'results');
53
+ this._file = path.join(this._dir, `${runId}.json`);
54
+ // Per-runId advisory lock guarding every read-modify-write mutator. Lives
55
+ // in the same directory as the result file, so the lock is scoped to this
56
+ // run only — different runs never contend on it.
57
+ this._lock = path.join(this._dir, `${runId}.lock`);
58
+
59
+ // Honest-corruption channel: any recovery the load performs is recorded
60
+ // here so the CLI can thread it into the envelope `hint`. Must exist
61
+ // before `_load()` runs.
62
+ this.warnings = [];
63
+
64
+ this._refreshFromDisk();
65
+ }
66
+
67
+ // Re-load the on-disk accumulator and refresh the in-memory fields. The
68
+ // constructor eager-loads once (read-only paths and back-compat), but every
69
+ // persisting mutator re-runs this UNDER the per-runId lock so it operates on
70
+ // fresh-from-disk state — never the possibly-stale constructor snapshot.
71
+ // Without this, two concurrent processes would both cache the same stale
72
+ // snapshot and serialize only their writes, so the last rename would win and
73
+ // the other's mutation would be silently lost.
74
+ _refreshFromDisk() {
75
+ const loaded = this._load();
76
+ this._steps = loaded.steps_executed || [];
77
+ this._assertions = loaded.assertion_results || [];
78
+ this._observations = loaded.observations || [];
79
+ this._capturedVariables = loaded.captured_variables || {};
80
+ if (!this.scenarioId && loaded.scenario_id) this.scenarioId = loaded.scenario_id;
81
+ }
82
+
83
+ // Load an existing in-progress (or finalized) file if present; else empty.
84
+ //
85
+ // A missing file (ENOENT) is the legitimate first step → empty accumulator.
86
+ // A file that exists but does not parse is a crash artifact: we MUST NOT
87
+ // silently treat it as empty (that would let the next write O_TRUNC-clobber
88
+ // every previously recorded step). Instead we preserve the bytes as a
89
+ // `.corrupt.<ts>` sidecar, record a structured warning (surfaced via the
90
+ // envelope `hint`), and start a fresh accumulator.
91
+ _load() {
92
+ let raw;
93
+ try {
94
+ raw = fs.readFileSync(this._file, 'utf8');
95
+ } catch (e) {
96
+ if (e && e.code === 'ENOENT') return {};
97
+ // Unexpected read failure (perms, I/O) — surface it honestly rather
98
+ // than masquerading as an empty run.
99
+ throw e;
100
+ }
101
+ try {
102
+ return JSON.parse(raw);
103
+ } catch (e) {
104
+ return this._preserveCorrupt(raw, e);
105
+ }
106
+ }
107
+
108
+ // Move a corrupt result file aside so its bytes are never lost, record a
109
+ // structured warning, and return an empty accumulator. The warning is the
110
+ // single source of truth for the recovery — the CLI threads `store.warnings`
111
+ // into the envelope `hint` (machine) which `render({human:true})` also shows
112
+ // (human), so the model stays print-free and trivially unit-testable. Sidecar
113
+ // write failures are themselves surfaced as warnings rather than aborting.
114
+ _preserveCorrupt(raw, err) {
115
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
116
+ const sidecar = `${this._file}.corrupt.${ts}`;
117
+ let preserved = false;
118
+ try {
119
+ fs.renameSync(this._file, sidecar);
120
+ preserved = true;
121
+ } catch (_renameErr) {
122
+ // Cross-device or other rename failure — fall back to a copy.
123
+ try {
124
+ fs.writeFileSync(sidecar, raw);
125
+ preserved = true;
126
+ } catch (_writeErr) {
127
+ // ignore — reported below
128
+ }
129
+ }
130
+ const where = preserved ? `preserved as ${path.basename(sidecar)}` : 'COULD NOT be preserved';
131
+ const message =
132
+ `result file ${this._file} was corrupt (${err.message}); ` +
133
+ `${where} and a fresh accumulator was started so prior steps are not silently clobbered`;
134
+ this.warnings.push(message);
135
+ return {};
136
+ }
137
+
138
+ // Atomic write: delegates to the shared util (see src/util/atomic.js).
139
+ // `this._dir === path.dirname(this._file)` (results dir), so this is
140
+ // behavior-identical to the previous inline implementation.
141
+ _atomicWrite(contents) {
142
+ atomicWrite(this._file, contents);
143
+ }
144
+
145
+ _persistInProgress() {
146
+ const snapshot = {
147
+ run_id: this.runId,
148
+ scenario_id: this.scenarioId,
149
+ schema_version: SCHEMA_VERSION,
150
+ steps_executed: this._steps,
151
+ assertion_results: this._assertions,
152
+ observations: this._observations,
153
+ captured_variables: this._capturedVariables,
154
+ _in_progress: true,
155
+ };
156
+ this._atomicWrite(JSON.stringify(snapshot, null, 2));
157
+ }
158
+
159
+ addStep({ step_id, status, attempts = 1, screenshot = null, error_message = null, observations = null } = {}) {
160
+ return withLock(this._lock, () => {
161
+ this._refreshFromDisk();
162
+ const normalized = stepStatus(status);
163
+ const retryCount = Math.max(0, Number(attempts) - 1);
164
+ const step = {
165
+ step_id,
166
+ status: normalized,
167
+ screenshot,
168
+ error_message,
169
+ retried: retryCount > 0,
170
+ retry_count: retryCount,
171
+ observations,
172
+ };
173
+ this._steps.push(step);
174
+
175
+ // Flakiness bookkeeping: a step that ultimately PASSED but needed more than
176
+ // one attempt is flaky.
177
+ if (retryCount > 0 && normalized === 'passed') {
178
+ this._observations.push({
179
+ type: 'flakiness',
180
+ step_id,
181
+ message: `Step '${step_id}' passed only after ${attempts} attempts; possible flakiness.`,
182
+ });
183
+ }
184
+
185
+ this._persistInProgress();
186
+ return step;
187
+ });
188
+ }
189
+
190
+ // Append a TYPED observation to the run-level `observations` array — the one
191
+ // `finalize()` emits and `src/memory/store.js` harvests. Deliberately NOT
192
+ // routed through `addStep`: the step-level `observations` field is a legacy
193
+ // freeform string, so writing there would produce untyped text in the wrong
194
+ // shape (see #140 D2).
195
+ addObservation({ type, step_id = null, message } = {}) {
196
+ return withLock(this._lock, () => {
197
+ this._refreshFromDisk();
198
+ if (!OBSERVATION_TYPES.includes(type)) {
199
+ throw new Error(
200
+ `unknown observation type "${type}" (expected ${OBSERVATION_TYPES.join(' | ')})`
201
+ );
202
+ }
203
+ if (message == null || String(message).trim() === '') {
204
+ throw new Error(
205
+ 'observation message is required and cannot be empty or whitespace-only'
206
+ );
207
+ }
208
+ const entry = { type, step_id: step_id == null ? null : String(step_id), message: String(message) };
209
+ this._observations.push(entry);
210
+ this._persistInProgress();
211
+ return entry;
212
+ });
213
+ }
214
+
215
+ addAssertion({ step_id, assertion_id, type, pass, message, expected = null, actual = null } = {}) {
216
+ return withLock(this._lock, () => {
217
+ this._refreshFromDisk();
218
+ const entry = {
219
+ assertion_id: assertion_id || `${step_id || 'assert'}_${type || 'unknown'}_${this._assertions.length + 1}`,
220
+ status: assertionStatus(pass),
221
+ expected: expected == null ? null : String(expected),
222
+ actual: actual == null ? null : String(actual),
223
+ message: message || '',
224
+ };
225
+ this._assertions.push(entry);
226
+ this._persistInProgress();
227
+ return entry;
228
+ });
229
+ }
230
+
231
+ captureVariable(name, value) {
232
+ return withLock(this._lock, () => {
233
+ this._refreshFromDisk();
234
+ this._capturedVariables[name] = value;
235
+ this._persistInProgress();
236
+ });
237
+ }
238
+
239
+ finalize({ status, durationSeconds = 0, summary, metadata } = {}) {
240
+ return withLock(this._lock, () => {
241
+ this._refreshFromDisk();
242
+ const passed = this._assertions.filter((a) => a.status === 'passed').length;
243
+ const failed = this._assertions.filter((a) => a.status === 'failed').length;
244
+ const total = this._assertions.length;
245
+
246
+ const resolvedStatus = status || (failed > 0 ? 'failed' : 'passed');
247
+
248
+ const result = {
249
+ run_id: this.runId,
250
+ scenario_id: this.scenarioId,
251
+ schema_version: SCHEMA_VERSION,
252
+ status: resolvedStatus,
253
+ metadata: defaultMetadata({ ...this._metadataOverrides, ...(metadata || {}) }),
254
+ total_assertions: total,
255
+ passed_assertions: passed,
256
+ failed_assertions: failed,
257
+ duration_seconds: Number(durationSeconds) || 0,
258
+ steps_executed: this._steps,
259
+ assertion_results: this._assertions,
260
+ observations: this._observations,
261
+ captured_variables: this._capturedVariables,
262
+ summary:
263
+ summary ||
264
+ `${resolvedStatus}: ${passed}/${total} assertion(s) passed across ${this._steps.length} step(s).`,
265
+ };
266
+
267
+ this._atomicWrite(JSON.stringify(result, null, 2));
268
+ return result;
269
+ });
270
+ }
271
+ }
272
+
273
+ module.exports = { ResultStore };
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const Ajv = require('ajv');
6
+
7
+ const { formatError } = require('../schemas/format-error');
8
+
9
+ // Path to the canonical scenario schema, bundled inside the package.
10
+ // src/scenario/validator.js -> ../schemas/scenario_schema.json
11
+ const DEFAULT_SCHEMA_PATH = path.resolve(__dirname, '../schemas/scenario_schema.json');
12
+
13
+ class ScenarioValidator {
14
+ constructor({ schemaPath = DEFAULT_SCHEMA_PATH } = {}) {
15
+ const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
16
+ const ajv = new Ajv({ allErrors: true, strict: false });
17
+ this._validateFn = ajv.compile(schema);
18
+ }
19
+
20
+ validate(scenarioObject) {
21
+ const valid = this._validateFn(scenarioObject);
22
+ if (valid) {
23
+ return { valid: true, errors: [] };
24
+ }
25
+ const errors = (this._validateFn.errors || []).map(formatError);
26
+ return { valid: false, errors };
27
+ }
28
+ }
29
+
30
+ module.exports = { ScenarioValidator, DEFAULT_SCHEMA_PATH };
@@ -0,0 +1,68 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Mobile Automator Workspace Config",
4
+ "description": "Schema for mobile-automator/config.json. Declares the type of every key `mauto config set` knows about. Unknown keys are permitted (additionalProperties: true) so agents can persist project knowledge this schema does not anticipate — the schema types keys, it does not gate them.",
5
+ "type": "object",
6
+ "additionalProperties": true,
7
+ "definitions": {
8
+ "stringList": {
9
+ "type": "array",
10
+ "items": { "type": "string" },
11
+ "description": "A list of strings. `mauto config set` accepts either a JSON array or a comma-separated value and stores a list either way."
12
+ },
13
+ "nullableString": {
14
+ "type": ["string", "null"],
15
+ "description": "A string, or null when the value is not yet configured."
16
+ }
17
+ },
18
+ "properties": {
19
+ "mode": {
20
+ "type": "string",
21
+ "enum": ["platform-aware", "platform-agnostic"],
22
+ "description": "Selected at setup. Configs predating this field are treated as platform-aware."
23
+ },
24
+ "project_name": { "$ref": "#/definitions/nullableString" },
25
+ "platform": { "type": "string", "description": "Detected platform (platform-aware mode)." },
26
+ "platform_details": { "type": "string" },
27
+ "build_system": { "type": "string" },
28
+ "build_command": { "type": "string" },
29
+ "architecture": { "type": "string" },
30
+ "business_domain": { "type": "string" },
31
+ "loading_indicators": { "type": "string" },
32
+ "automation_extras": { "type": "string" },
33
+ "additional_resources": { "type": "string" },
34
+ "android_package": { "type": "string" },
35
+ "ios_bundle_id": { "type": "string" },
36
+ "default_environment": { "$ref": "#/definitions/nullableString" },
37
+ "environments": { "$ref": "#/definitions/stringList" },
38
+ "protected_directories": { "$ref": "#/definitions/stringList" },
39
+ "business_critical_paths": { "$ref": "#/definitions/stringList" },
40
+ "app": {
41
+ "type": "object",
42
+ "additionalProperties": true,
43
+ "description": "Legacy nested shape written by pre-CLI setup. Read by the placeholder resolver.",
44
+ "properties": {
45
+ "android_package": { "type": "string" },
46
+ "ios_bundle_id": { "$ref": "#/definitions/nullableString" }
47
+ }
48
+ },
49
+ "knowledge": {
50
+ "type": "object",
51
+ "additionalProperties": true,
52
+ "description": "Legacy nested shape written by pre-CLI setup. Read by the placeholder resolver.",
53
+ "properties": {
54
+ "project_name": { "$ref": "#/definitions/nullableString" },
55
+ "architecture": { "type": "string" },
56
+ "platform_details": { "type": "string" },
57
+ "build_system": { "type": "string" },
58
+ "build_command": { "type": "string" },
59
+ "business_domain": { "type": "string" },
60
+ "loading_indicators": { "type": "string" },
61
+ "automation_extras": { "type": "string" },
62
+ "additional_resources": { "type": "string" },
63
+ "protected_directories": { "$ref": "#/definitions/stringList" },
64
+ "business_critical_paths": { "$ref": "#/definitions/stringList" }
65
+ }
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ // Ajv error -> one human-readable sentence. Shared by the scenario validator
4
+ // (`mauto validate`) and the config validator (`mauto config set`) so a schema
5
+ // violation reads the same wherever it surfaces.
6
+ function formatError(err) {
7
+ const where = err.instancePath || '(root)';
8
+ let msg = `${where} ${err.message}`;
9
+ if (err.keyword === 'required' && err.params && err.params.missingProperty) {
10
+ msg = `${where} is missing required property '${err.params.missingProperty}'`;
11
+ } else if (err.keyword === 'enum' && err.params && Array.isArray(err.params.allowedValues)) {
12
+ msg = `${where} ${err.message}: ${err.params.allowedValues.join(', ')}`;
13
+ } else if (err.keyword === 'additionalProperties' && err.params) {
14
+ msg = `${where} has unexpected property '${err.params.additionalProperty}'`;
15
+ }
16
+ return msg;
17
+ }
18
+
19
+ module.exports = { formatError };