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.
- package/LICENSE +17 -0
- package/README.md +167 -0
- package/bin/mauto-session-daemon.js +101 -0
- package/bin/mauto.js +13 -0
- package/package.json +49 -0
- package/src/assertion/evaluator.js +285 -0
- package/src/cli.js +1722 -0
- package/src/config/coerce.js +97 -0
- package/src/config/manager.js +75 -0
- package/src/config/schema.js +124 -0
- package/src/device/action-catalog.js +99 -0
- package/src/device/bridge.js +139 -0
- package/src/device/connection.js +65 -0
- package/src/device/device-model.js +59 -0
- package/src/device/device-resolver.js +34 -0
- package/src/device/element-model.js +82 -0
- package/src/device/mobile-mcp-client.js +163 -0
- package/src/device/resolve-connection.js +156 -0
- package/src/device/selection.js +80 -0
- package/src/device/semantic-press/index.js +38 -0
- package/src/device/semantic-press/labels.js +39 -0
- package/src/device/semantic-press/resolver.js +100 -0
- package/src/device/session-client.js +173 -0
- package/src/device/session-daemon.js +526 -0
- package/src/device/session-paths.js +80 -0
- package/src/device/session-protocol.js +60 -0
- package/src/device/session-spawn.js +67 -0
- package/src/device/tool-args.js +14 -0
- package/src/guide/content/execute.agnostic.md +207 -0
- package/src/guide/content/execute.aware.md +281 -0
- package/src/guide/content/execute.invariants.md +4 -0
- package/src/guide/content/generate.agnostic.md +236 -0
- package/src/guide/content/generate.aware.md +327 -0
- package/src/guide/content/generate.invariants.md +5 -0
- package/src/guide/content/setup.agnostic.md +63 -0
- package/src/guide/content/setup.aware.md +91 -0
- package/src/guide/content/setup.invariants.md +3 -0
- package/src/guide/emitter.js +137 -0
- package/src/guide/placeholders.js +132 -0
- package/src/init/adapters.js +162 -0
- package/src/init/skill-meta.js +31 -0
- package/src/init/skill-renderer.js +60 -0
- package/src/mcp/prompts.js +55 -0
- package/src/mcp/server.js +55 -0
- package/src/memory/entries.js +61 -0
- package/src/memory/history.js +86 -0
- package/src/memory/lock.js +5 -0
- package/src/memory/paths.js +47 -0
- package/src/memory/store.js +230 -0
- package/src/output/envelope.js +106 -0
- package/src/result/capability-catalog.js +144 -0
- package/src/result/flags.js +52 -0
- package/src/result/store.js +273 -0
- package/src/scenario/validator.js +30 -0
- package/src/schemas/config_schema.json +68 -0
- package/src/schemas/format-error.js +19 -0
- package/src/schemas/result_schema.json +211 -0
- package/src/schemas/scenario_schema.json +587 -0
- package/src/setup/scaffold.js +53 -0
- package/src/util/atomic.js +41 -0
- 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 };
|