mobile-automator 0.25.0 → 0.26.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.
Files changed (39) hide show
  1. package/README.md +11 -0
  2. package/bin/mauto-session-daemon.js +85 -16
  3. package/package.json +1 -1
  4. package/src/cli.js +724 -59
  5. package/src/device/bridge.js +30 -0
  6. package/src/device/connection.js +23 -4
  7. package/src/device/crash-model.js +110 -0
  8. package/src/device/device-call.js +145 -0
  9. package/src/device/failure-probe.js +276 -0
  10. package/src/device/mobile-mcp-tools.js +64 -0
  11. package/src/device/resolve-connection.js +8 -10
  12. package/src/device/session-daemon.js +265 -51
  13. package/src/device/session-handle.js +53 -0
  14. package/src/device/session-log.js +5 -16
  15. package/src/guide/content/execute.agnostic.md +48 -5
  16. package/src/guide/content/execute.aware.md +42 -1
  17. package/src/guide/content/execute.invariants.md +1 -0
  18. package/src/observe/accepts.js +108 -0
  19. package/src/observe/event.js +241 -0
  20. package/src/observe/failure-capture.js +166 -0
  21. package/src/observe/flush.js +177 -0
  22. package/src/observe/paths.js +125 -0
  23. package/src/observe/recorder.js +178 -0
  24. package/src/observe/settings.js +51 -0
  25. package/src/observe/sinks/file.js +101 -0
  26. package/src/observe/sinks/stderr.js +20 -0
  27. package/src/observe/spool.js +199 -0
  28. package/src/observe/telemetry.js +143 -0
  29. package/src/observe/trace.js +174 -0
  30. package/src/observe/transport.js +169 -0
  31. package/src/observe/vocab.js +96 -0
  32. package/src/output/envelope.js +6 -0
  33. package/src/result/capability-catalog.js +14 -0
  34. package/src/result/store.js +98 -1
  35. package/src/schemas/config_schema.json +11 -0
  36. package/src/schemas/result_schema.json +55 -0
  37. package/src/setup/scaffold.js +27 -0
  38. package/src/util/log-rotate.js +34 -0
  39. package/src/workspace/discover.js +69 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { normalize, parseElements } = require('./element-model');
4
4
  const { normalizeDevices } = require('./device-model');
5
+ const { normalizeCrashes } = require('./crash-model');
5
6
  const { resolveSingleDevice } = require('./device-resolver');
6
7
 
7
8
  // Thin wrapper over an injected mobile-mcp `call(toolName, args)` function.
@@ -134,6 +135,35 @@ class DeviceBridge {
134
135
  async setOrientation(orientation) {
135
136
  return this._call('mobile_set_orientation', { orientation });
136
137
  }
138
+
139
+ // Crash reports currently readable on the device, as the agnostic crash
140
+ // model. DIAGNOSTIC only — never invoked by a scenario action, which is why
141
+ // `crash` has no src/device/action-catalog.js entry (that catalog holds
142
+ // exactly the scenario-schema actions and its lint guard asserts parity).
143
+ //
144
+ // No `device` argument: src/device/tool-args.js injects the resolved id into
145
+ // every tool except discovery.
146
+ //
147
+ // Retention differs by platform and the caller must know it: Android reports
148
+ // come from the device log and AGE OUT; iOS device/simulator reports are files
149
+ // that PERSIST and accumulate across runs. Same call, under-inclusive on one
150
+ // platform and over-inclusive on the other — hence the `since` scoping in
151
+ // src/device/failure-probe.js. There is deliberately no platform branch here.
152
+ async listCrashes() {
153
+ return normalizeCrashes(await this._call('mobile_list_crashes', {}));
154
+ }
155
+
156
+ // The full text of one crash report. The engine returns a bare string (its
157
+ // `response.data.content`), so coerce rather than assume: an engine that
158
+ // starts wrapping it must not turn this into "[object Object]" at the CLI.
159
+ async getCrash(id) {
160
+ const result = await this._call('mobile_get_crash', { id });
161
+ if (typeof result === 'string') return result;
162
+ if (result && typeof result === 'object' && typeof result.content === 'string') {
163
+ return result.content;
164
+ }
165
+ return String(result == null ? '' : result);
166
+ }
137
167
  }
138
168
 
139
169
  module.exports = { DeviceBridge };
@@ -14,6 +14,7 @@
14
14
  // handlers.
15
15
 
16
16
  const { resolveDeviceConnection } = require('./resolve-connection');
17
+ const { readSessionId } = require('./session-handle');
17
18
  const { daemonLogHint } = require('./session-log');
18
19
  const paths = require('./session-paths');
19
20
  const sessionClient = require('./session-client');
@@ -24,14 +25,19 @@ const sessionSpawn = require('./session-spawn');
24
25
  // { bridge, close }; close() releases this verb's hold — a no-op for the shared
25
26
  // daemon, a real transport teardown for a one-shot.
26
27
  //
27
- // `resolve` is an internal test seam only; verbs call acquireConnection with
28
- // just { device, projectRoot }.
28
+ // `autostart` (default true: the daemon is the normal connection path) is the
29
+ // daemon-vs-oneshot policy. cli.js's connectBridge passes false when no
30
+ // workspace was discovered (#188), so a workspace-less cwd gets a one-shot
31
+ // bridge and no mobile-automator/.session/ tree. `resolve` is an internal test
32
+ // seam only; verbs call acquireConnection with just
33
+ // { device, projectRoot, autostart }.
29
34
  async function acquireConnection({
30
35
  device = null,
31
36
  projectRoot = process.cwd(),
37
+ autostart = true,
32
38
  resolve = resolveDeviceConnection,
33
39
  } = {}) {
34
- const { bridge, close } = await resolve({ device, projectRoot });
40
+ const { bridge, close } = await resolve({ device, projectRoot, autostart });
35
41
  return { bridge, close };
36
42
  }
37
43
 
@@ -55,7 +61,20 @@ function isSessionAlive(projectRoot, { client = sessionClient } = {}) {
55
61
  // answer it. Do not "simplify" this into the ping payload.
56
62
  async function sessionStatus(projectRoot, { client = sessionClient } = {}) {
57
63
  const status = await client.getSessionStatus(projectRoot);
58
- return { ...status, log_path: paths.logFilePath(projectRoot) };
64
+ return {
65
+ ...status,
66
+ log_path: paths.logFilePath(projectRoot),
67
+ // Read from the handle, not from the ping reply. The handle is written once
68
+ // the daemon is listening and removed by stop(), so it is the same fact
69
+ // without a second protocol field that could disagree with it — and a verb
70
+ // that wants the id does not have to pay a socket round trip.
71
+ //
72
+ // Deliberately NOT symmetric with log_path, which survives running:false on
73
+ // purpose (a dead daemon's log is exactly what you want then). A SIGKILLed
74
+ // daemon leaves its handle behind, so reporting that id alongside
75
+ // running:false would name a session that no longer exists.
76
+ session_id: status && status.running ? readSessionId(projectRoot) : null,
77
+ };
59
78
  }
60
79
 
61
80
  // Spawn a daemon and wait until it answers. Resolves true on success.
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ // Agnostic crash-report model, in the shape of device-model.js / element-model.js.
4
+ //
5
+ // It exists because the engine's payload is not mauto's contract. mobile-mcp's
6
+ // mobile_list_crashes delegates to `mobilecli`, a separately-versioned Go binary
7
+ // that reaches us TRANSITIVELY (through mobilewright) rather than as a pin in our
8
+ // package.json, so its CrashReport struct is not something this repo can hold
9
+ // still. Normalizing here means a key rename upstream costs one line in one
10
+ // file instead of a silently-empty crash list at the call sites.
11
+ //
12
+ // The one thing this module never does is invent a field. An unreadable id or
13
+ // time is `null`, and callers treat null-timed reports as UNATTRIBUTED rather
14
+ // than assuming them recent or assuming them stale — see the "absent-versus-
15
+ // empty problem" section of the slice-4 plan. An at-or-before-epoch time is
16
+ // treated as unreadable too, not as a real, ancient instant: no device capable
17
+ // of running mauto reports a crash at or before 1970-01-01T00:00:00Z, so a
18
+ // decoded value that low is a zero-value sentinel (Go's unset int64, or its
19
+ // zero-value time.Time) rather than a fact about the crash.
20
+
21
+ // Plausible spellings for each field, most-likely first. A list rather than a
22
+ // single key because "which spelling does mobilecli use this month" is exactly
23
+ // the fact we refuse to depend on.
24
+ const ID_KEYS = ['id', 'crashId', 'crash_id', 'reportId', 'name'];
25
+ const PROCESS_KEYS = ['processName', 'process', 'packageName', 'bundleId', 'bundle_id', 'appId'];
26
+ const TIME_KEYS = ['timestamp', 'time', 'date', 'createdAt', 'created_at'];
27
+
28
+ function firstString(obj, keys) {
29
+ for (const k of keys) {
30
+ const v = obj[k];
31
+ if (typeof v === 'string' && v.trim() !== '') return v;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ function firstValue(obj, keys) {
37
+ for (const k of keys) {
38
+ const v = obj[k];
39
+ if (v !== undefined && v !== null && v !== '') return v;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ // Accepts a bare array, a { crashes: [...] } envelope, or the JSON text of
45
+ // either — mobile-mcp stringifies its payload and parseToolResult's text
46
+ // fallback can hand it to us unparsed.
47
+ function toArray(raw) {
48
+ if (Array.isArray(raw)) return raw;
49
+ if (typeof raw === 'string') {
50
+ try {
51
+ return toArray(JSON.parse(raw));
52
+ } catch (_) {
53
+ return [];
54
+ }
55
+ }
56
+ if (raw && typeof raw === 'object') {
57
+ for (const k of ['crashes', 'reports', 'data', 'items']) {
58
+ if (Array.isArray(raw[k])) return raw[k];
59
+ }
60
+ }
61
+ return [];
62
+ }
63
+
64
+ function normalizeCrashes(raw) {
65
+ return toArray(raw)
66
+ .filter((c) => c && typeof c === 'object' && !Array.isArray(c))
67
+ .map((c) => ({
68
+ id: firstString(c, ID_KEYS),
69
+ process: firstString(c, PROCESS_KEYS),
70
+ timestamp: (() => {
71
+ const t = firstValue(c, TIME_KEYS);
72
+ return t == null ? null : String(t);
73
+ })(),
74
+ }));
75
+ }
76
+
77
+ // Epoch milliseconds for a normalized crash, or null when the time cannot be
78
+ // read. Never <= 0 and never NaN. NaN compares false against every watermark,
79
+ // so it would silently drop the report. An at-or-before-epoch value is not a
80
+ // real crash instant either: no device capable of running mauto reports a
81
+ // crash at or before 1970-01-01T00:00:00Z, so a decoded instant that low is a
82
+ // zero-value sentinel wearing a timestamp's clothes, not a fact about the
83
+ // crash — Go emits exactly this for an unset field (a zero int64, or
84
+ // json.Marshal's `"0001-01-01T00:00:00Z"` for a zero-value time.Time). A floor
85
+ // is used rather than a list of known sentinel spellings because the whole
86
+ // reason this module normalizes instead of trusting keys is that we cannot
87
+ // enumerate mobilecli's spellings; a new sentinel spelling defeats a list but
88
+ // not a floor. Either failure mode is the confident wrong answer this slice
89
+ // exists to prevent, so unreadable is reported as unreadable.
90
+ function crashTimestampMs(crash) {
91
+ const t = crash && crash.timestamp;
92
+ if (t == null || t === '') return null;
93
+ if (typeof t === 'number' && Number.isFinite(t)) {
94
+ // Heuristic shared with every log tool: a 10-digit value is seconds.
95
+ const ms = t < 1e12 ? Math.round(t * 1000) : Math.round(t);
96
+ return ms > 0 ? ms : null;
97
+ }
98
+ const s = String(t).trim();
99
+ if (/^\d+$/.test(s)) {
100
+ const n = Number(s);
101
+ if (!Number.isFinite(n)) return null;
102
+ const ms = n < 1e12 ? n * 1000 : n;
103
+ return ms > 0 ? ms : null;
104
+ }
105
+ const parsed = Date.parse(s);
106
+ if (!Number.isFinite(parsed)) return null;
107
+ return parsed > 0 ? parsed : null;
108
+ }
109
+
110
+ module.exports = { normalizeCrashes, crashTimestampMs };
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ // One bounded, measured device call.
4
+ //
5
+ // Wraps the daemon's single injected `call` so that invoking a mobile-mcp
6
+ // primitive is one thing with one contract — "it answers within timeoutMs, and
7
+ // it leaves a trace either way" — instead of ninety lines inlined into a socket
8
+ // data handler, where the device logic and the telemetry were interleaved at
9
+ // nesting depth 6 and neither could be read on its own.
10
+ //
11
+ // Why this is ONE concept and not two. The obvious split (a timeout wrapper,
12
+ // then an instrumentation wrapper around it) does not survive contact with the
13
+ // events: `call.end` reports error_kind, and the only way to know a failure was
14
+ // a timeout rather than a device error is `err.kind === 'timeout'` — a marker
15
+ // this module itself invents on the timeout path. Split them and the outer
16
+ // layer has to re-derive the inner layer's classification by sniffing an error
17
+ // property, which is the same coupling with an extra seam in front of it. The
18
+ // bound and the measurement are the same fact about a call.
19
+ //
20
+ // Built ONCE per daemon, right after createCall resolves, and then shared by
21
+ // every connection. That is deliberate: the timeout, the sink and the
22
+ // connection are all per-daemon, so nothing here needs a per-connection
23
+ // identity. WARNING for anyone extending it: this means a `let` at factory
24
+ // scope would be cross-connection shared state, mutated by concurrent calls
25
+ // from unrelated sockets (the daemon multiplexes, and frames from two sockets
26
+ // interleave freely). Everything mutable must stay inside `invoke`.
27
+ //
28
+ // Everything it needs is injected — no defaults — because every one of these
29
+ // has a failure mode that is silent if it is wrong. See the construction-time
30
+ // validation below.
31
+
32
+ const { isKnownTool } = require('./mobile-mcp-tools');
33
+
34
+ function makeDeviceCall(call, { scheduleTimeout, observe, timeoutMs, nextCallId } = {}) {
35
+ // Validate at CONSTRUCTION, loudly, rather than defaulting. A default here
36
+ // would be worse than a crash: with `timeoutMs` undefined,
37
+ // scheduleTimeout(undefined) is setTimeout(fn, undefined) is setTimeout(fn, 0),
38
+ // so EVERY device call would lose its race and fail with "... did not respond
39
+ // within undefinedms" — an entire session broken, in a way no test asserts
40
+ // against because every test injects its own scheduleTimeout. Failing at
41
+ // build time means the daemon never starts instead of starting useless.
42
+ if (typeof call !== 'function') throw new TypeError('makeDeviceCall requires a call function');
43
+ if (typeof observe !== 'function') throw new TypeError('makeDeviceCall requires an observe function');
44
+ if (typeof scheduleTimeout !== 'function') {
45
+ throw new TypeError('makeDeviceCall requires a scheduleTimeout function');
46
+ }
47
+ if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
48
+ throw new TypeError('makeDeviceCall requires a finite positive timeoutMs');
49
+ }
50
+ // Injected rather than defaulted for the reason in the WARNING above: a
51
+ // counter kept at factory scope here would be cross-connection shared state
52
+ // living in the wrong module. The sequence belongs to the DAEMON LIFETIME,
53
+ // which is what the id is scoped to, so startDaemon owns it — and a default
54
+ // built here would silently work (the decorator is built once per daemon)
55
+ // right up until something builds two, at which point two live calls could
56
+ // share an id and the pairing would be wrong rather than merely absent.
57
+ if (typeof nextCallId !== 'function') {
58
+ throw new TypeError('makeDeviceCall requires a nextCallId function');
59
+ }
60
+
61
+ return async function invoke(tool, args) {
62
+ // NEVER record `tool` unchecked. The value arrives inside a socket frame
63
+ // and the daemon's socket is reachable by any process on the machine, while
64
+ // `tool` is sends:true in the event catalog on the grounds that it is an
65
+ // enumerated primitive name. Unknown values are omitted (makeEvent drops
66
+ // undefined), so the latency is still recorded — just anonymously.
67
+ const name = isKnownTool(tool) ? tool : undefined;
68
+ // Drawn ONCE, here, and stamped on both events. Drawing it twice would look
69
+ // like a start and an end belonging to two different calls, which is worse
70
+ // than carrying no id: the pairing would be wrong rather than absent.
71
+ const callId = nextCallId();
72
+ // Started BEFORE the call.start observe, so the sink's own cost (an
73
+ // appendFileSync) is inside the measurement rather than excluded from it.
74
+ // The number is meant to bound what the device took, from the daemon's
75
+ // point of view, not to flatter it.
76
+ const started = Date.now();
77
+ // debug: off at the default level, so a scenario pays ONE append per device
78
+ // call. Its value is bracketing — a daemon SIGKILLed mid-call leaves a
79
+ // call.start with no call.end, which is the only trace a call that never
80
+ // returned can possibly leave. That reading needs call_id: the daemon
81
+ // multiplexes and every call in a lifetime shares session_id, so without a
82
+ // per-call id "no matching call.end" is not a decidable question.
83
+ observe({ level: 'debug', event: 'call.start', tool: name, call_id: callId });
84
+ try {
85
+ // `call(...)` stays INSIDE the try. A `call` that throws synchronously —
86
+ // or returns a non-thenable, which makes `p.catch` a TypeError — would
87
+ // otherwise escape with no call.end recorded and no 'device'
88
+ // classification, i.e. the one failure shape whose trace matters most
89
+ // would be the one that leaves none.
90
+ const p = call(tool, args);
91
+ // Swallow the race loser's eventual settlement: once the timeout has won,
92
+ // the underlying call is still running (it is NOT cancellable) and its
93
+ // later rejection would surface as an unhandled rejection — which, in the
94
+ // daemon, is a process exit.
95
+ p.catch(() => {});
96
+ const result = await Promise.race([
97
+ p,
98
+ scheduleTimeout(timeoutMs).then(() => {
99
+ // The RAW `tool`, never the checked `name`: with an unknown tool
100
+ // `name` is undefined and the message would read "undefined: the
101
+ // mobile-mcp call did not respond ...", hiding which call hung at
102
+ // exactly the moment a human needs to know. This is safe precisely
103
+ // because it lands in `message`, which is sends:false in the event
104
+ // catalog and never leaves the machine.
105
+ const e = new Error(`${tool}: the mobile-mcp call did not respond within ${timeoutMs}ms`);
106
+ e.kind = 'timeout';
107
+ e.hint = 'the action may have partially executed on the device — verify state before retrying';
108
+ throw e;
109
+ }),
110
+ ]);
111
+ observe({
112
+ level: 'info',
113
+ event: 'call.end',
114
+ tool: name,
115
+ call_id: callId,
116
+ ok: true,
117
+ dur_ms: Date.now() - started,
118
+ });
119
+ return result;
120
+ } catch (err) {
121
+ observe({
122
+ // warn, not info. The daemon's stderr is a log file, not a terminal, so
123
+ // this costs a human no noise and lands next to the adb/simctl output
124
+ // that explains it.
125
+ level: 'warn',
126
+ event: 'call.end',
127
+ tool: name,
128
+ call_id: callId,
129
+ ok: false,
130
+ // The envelope's own taxonomy, reused verbatim: a non-timeout failure
131
+ // is exactly what client/deviceFail turns into kind 'device'.
132
+ error_kind: err && err.kind === 'timeout' ? 'timeout' : 'device',
133
+ dur_ms: Date.now() - started,
134
+ // sends:false — an engine message can embed element labels, typed text
135
+ // and filesystem paths. `err &&` because a caller can reject with a
136
+ // falsy value, and reading .message off null here would replace an
137
+ // ordinary failure with a TypeError.
138
+ message: err && (err.message || String(err)),
139
+ });
140
+ throw err;
141
+ }
142
+ };
143
+ }
144
+
145
+ module.exports = { makeDeviceCall };
@@ -0,0 +1,276 @@
1
+ 'use strict';
2
+
3
+ // The failure-path crash check.
4
+ //
5
+ // Runs ONLY after an action has already failed, or after `mauto elements`
6
+ // returned an empty list. Never after a successful action: a round trip on
7
+ // every `mauto tap` would put a device call on the hot path, whereas checking
8
+ // after a failure costs nothing in the common case and answers the exact
9
+ // question that is otherwise unanswerable — "did the app die, or did the button
10
+ // move?".
11
+ //
12
+ // THE CONTRACT (see the slice-4 plan's "absent-versus-empty problem"): three
13
+ // states, not two.
14
+ //
15
+ // crashes: [ … ] we asked and the device reported crashes
16
+ // crashes: [] we asked and the device reported none — a POSITIVE claim
17
+ // (no key) we could not ask, or could not attribute — UNKNOWN
18
+ //
19
+ // Emitting `[]` when the probe failed, timed out or had no watermark would be
20
+ // this slice's own bug with the sign flipped: the tool would confidently tell
21
+ // the agent the app is fine when it has no idea. So the probe attaches a key it
22
+ // earned, or attaches nothing at all.
23
+ //
24
+ // It is TOTAL. It never throws, never rejects, and never alters ok, error or
25
+ // schema_version. An observability failure must not rewrite the caller's error.
26
+ //
27
+ // SIGNATURE, and why it differs from the plan's own "Interfaces" prose: that
28
+ // prose sketched probeCrashes deriving the watermark internally (via
29
+ // readHandle(projectRoot)), but its own test file passes `watermark` and
30
+ // `sinks` explicitly and sets `projectRoot` to the nonexistent path '/nope'.
31
+ // The tests are the contract: this module takes `watermark` as a plain value
32
+ // and never touches the filesystem itself, which is what makes it a pure,
33
+ // synchronously-testable function. `src/cli.js` already owns a
34
+ // `sessionWatermark(projectRoot)` helper (added with the `crash` verb, reusing
35
+ // the same `readHandle`-backed logic `handleCrashList` needs) — connectBridge
36
+ // calls that and passes the result in, rather than this module growing a second
37
+ // copy of "how do we find the session start time".
38
+ //
39
+ // ENVELOPE SHAPE ON FAILURE: attaching `data.crashes` to a `fail()` envelope is
40
+ // not a new envelope shape. `src/output/envelope.js`'s `fail(kind, message,
41
+ // hint, data)` has taken an optional trailing `data` since before this slice
42
+ // (used by `init --agent all`'s per-host ok/failed map) — `data` is included
43
+ // only when given, so the common fail shape is unchanged for every verb that
44
+ // doesn't pass one. No lint guard or schema asserts a fixed key set for a
45
+ // failure envelope (checked: no envelope-shape guard exists in tests/lint or
46
+ // tests/unit). This module uses that existing extension point rather than
47
+ // inventing one.
48
+ //
49
+ // THE ok:true / EMPTY-`elements` ASYMMETRY IS INTENTIONAL, AND DELIBERATELY NOT
50
+ // SILENT. On failure, `data` is an object (or absent) and gaining a `crashes`
51
+ // key is additive. On the `elements` ok:true path `data` IS the elements array
52
+ // — the verb's whole contract — so grafting a `crashes` key onto it would
53
+ // corrupt that contract for every consumer expecting `data` to stay an array.
54
+ // So on that path the result reaches the caller ONLY as a hint, on BOTH
55
+ // determined outcomes (crash found, or confirmed clear) — not only when a
56
+ // crash is found. Signalling only the "found" case would make an earned-clear
57
+ // probe look identical to a probe that never ran, which is exactly the
58
+ // UNKNOWN state this module exists to keep distinct from EARNED-EMPTY. Only
59
+ // the genuinely undetermined case (skipped, failed, timed out, unscoped)
60
+ // leaves the envelope untouched.
61
+
62
+ const { crashTimestampMs } = require('./crash-model');
63
+ const { record } = require('../observe/recorder');
64
+
65
+ // The probe's own budget, deliberately far below the daemon's per-call timeout
66
+ // (session-daemon.js's DAEMON_CALL_TIMEOUT_MS, 25s). Inheriting that would add
67
+ // up to 25 seconds to a verb that has ALREADY failed, for a courtesy lookup the
68
+ // caller did not ask for.
69
+ const CRASH_PROBE_TIMEOUT_MS = 3000;
70
+
71
+ // `mauto devices` returns a bare array through the same connectBridge seam, so
72
+ // the empty-list trigger would fire on "nothing connected" and then try to
73
+ // probe a device that does not exist.
74
+ //
75
+ // `crash` is exempt for the same reason, sharper: it is ITSELF the
76
+ // crash-diagnostic tool. A failing `crash get <id>` (bad id, unreadable
77
+ // report — kind `device` either way) would otherwise re-run `listCrashes()`
78
+ // behind its back and staple a second, unrelated crash-probe hint onto the
79
+ // error, e.g. "The app under test crashed... run `mauto crash get c1`"
80
+ // alongside "the device engine could not read crash reports" for the SAME
81
+ // envelope — self-contradictory, and pointing at a different id than the one
82
+ // the caller asked about. The probe exists to answer a question other verbs
83
+ // can't; `crash` can already answer it directly.
84
+ const PROBE_EXEMPT_VERBS = new Set(['devices', 'crash']);
85
+
86
+ // Failure kinds that can plausibly mean "the app died". invalid_input is a bad
87
+ // flag, target_not_found/environment/internal never reached (or never
88
+ // involved) the device — none of them are worth a device round trip.
89
+ const PROBE_KINDS = new Set(['device', 'timeout']);
90
+
91
+ function shouldProbe({ envelope, verb } = {}) {
92
+ if (!envelope || typeof envelope !== 'object') return false;
93
+ if (PROBE_EXEMPT_VERBS.has(verb)) return false;
94
+ if (envelope.ok === false) {
95
+ return PROBE_KINDS.has(envelope.error && envelope.error.kind);
96
+ }
97
+ // The silent symptom: the app is gone, so the view hierarchy is empty and the
98
+ // verb still succeeds. Named by verb rather than by shape — relying on "only
99
+ // `elements` returns a bare array" would be relying on an accident.
100
+ return verb === 'elements' && Array.isArray(envelope.data) && envelope.data.length === 0;
101
+ }
102
+
103
+ function crashHint(crashes) {
104
+ const process = crashes[0] && crashes[0].process;
105
+ const who = process ? ` (${process})` : '';
106
+ const id = crashes[0] && crashes[0].id;
107
+ const how = id ? ` Run \`mauto crash get ${id}\` for the report.` : ' Run `mauto crash list`.';
108
+ return `The app under test crashed during this session${who}: ${crashes.length} crash report(s).${how}`;
109
+ }
110
+
111
+ // The confirmatory half of the three-state contract on the ok:true path, where
112
+ // there is no `data` slot to carry an earned-empty result.
113
+ const NO_CRASH_HINT =
114
+ 'Checked for an app crash since the session started: none found — the empty result looks genuine.';
115
+
116
+ // A report whose own timestamp is missing or unparseable (crashTimestampMs
117
+ // returns null — see crash-model.js's epoch floor for why a zero-value
118
+ // sentinel counts as unreadable too) is neither included nor silently
119
+ // dropped: it is `unattributed`, matching handleCrashList's vocabulary in
120
+ // src/cli.js. Attaching an earned-empty `crashes: []` here instead would be
121
+ // this slice's own confident-wrong-answer bug, just moved from "found none"
122
+ // to "found something I couldn't place in time".
123
+ function unattributedHint(count) {
124
+ return `Checked for an app crash since the session started: the device returned ${count} report(s) with an unreadable timestamp — not counted as clear. Run \`mauto crash list\` to inspect them.`;
125
+ }
126
+
127
+ function amendHint(envelope, note) {
128
+ envelope.hint = envelope.hint ? `${note} ${envelope.hint}` : note;
129
+ }
130
+
131
+ // Shared sentinel so a caller racing withDeadline() can tell "the deadline won"
132
+ // apart from "the bridge resolved with this exact value" — a module-level
133
+ // constant rather than a fresh Symbol() per call, so the identity check below
134
+ // actually matches.
135
+ const TIMED_OUT = Symbol('mauto.probe.timeout');
136
+
137
+ // Race a promise against a deadline. The timer is deliberately left REF'D
138
+ // (not unref()'d): the promise on the other side of this race is, by
139
+ // definition, a HUNG bridge call that will never settle on its own — that is
140
+ // the exact case this deadline exists to catch. If the timer were unref'd and
141
+ // nothing else happened to be keeping the event loop alive (a one-shot verb
142
+ // whose connectBridge socket handling has already torn down, say), Node would
143
+ // consider the loop empty and exit *before the timer ever fires*: the race
144
+ // never settles, probeCrashes never resolves, connectBridge's `await
145
+ // probeCrashes(...)` never returns, and the CLI exits 0 with no output —
146
+ // silently swallowing the very device failure this module exists to surface.
147
+ // That is strictly worse than never having built this deadline. Keeping the
148
+ // timer ref'd costs nothing it was trying to save: the `.finally(() =>
149
+ // clearTimeout(timer))` below already guarantees the timer cannot outlive the
150
+ // race on either branch, which is the actual guarantee "don't leave a live
151
+ // timer behind" was after.
152
+ function withDeadline(promise, ms) {
153
+ let timer;
154
+ const deadline = new Promise((resolve) => {
155
+ timer = setTimeout(() => resolve(TIMED_OUT), ms);
156
+ });
157
+ return Promise.race([promise, deadline]).finally(() => clearTimeout(timer));
158
+ }
159
+
160
+ async function probeCrashes({
161
+ bridge,
162
+ envelope,
163
+ verb,
164
+ projectRoot,
165
+ env = process.env,
166
+ watermark,
167
+ sinks,
168
+ timeoutMs = CRASH_PROBE_TIMEOUT_MS,
169
+ } = {}) {
170
+ const recordOpts = { projectRoot, env, sinks };
171
+ try {
172
+ if (!bridge || typeof bridge.listCrashes !== 'function') return;
173
+ if (!shouldProbe({ envelope, verb })) return;
174
+
175
+ if (watermark == null) {
176
+ // No session handle (or a caller that passed none) means nothing to
177
+ // attribute against. An iOS DiagnosticReports file from last Tuesday is
178
+ // not evidence about this step, so say nothing rather than say something
179
+ // wrong.
180
+ record({ level: 'debug', src: 'cli', event: 'crash.probe_unscoped', verb }, recordOpts);
181
+ return;
182
+ }
183
+ const sinceMs = Date.parse(watermark);
184
+
185
+ let raw;
186
+ try {
187
+ raw = await withDeadline(
188
+ Promise.resolve().then(() => bridge.listCrashes()),
189
+ timeoutMs
190
+ );
191
+ } catch (err) {
192
+ record(
193
+ {
194
+ level: 'warn',
195
+ src: 'cli',
196
+ event: 'crash.probe_failed',
197
+ verb,
198
+ message: (err && err.message) || String(err),
199
+ },
200
+ recordOpts
201
+ );
202
+ return;
203
+ }
204
+ if (raw === TIMED_OUT) {
205
+ record({ level: 'warn', src: 'cli', event: 'crash.probe_timeout', verb, dur_ms: timeoutMs }, recordOpts);
206
+ return;
207
+ }
208
+
209
+ const crashes = [];
210
+ let unattributed = 0;
211
+ for (const c of Array.isArray(raw) ? raw : []) {
212
+ const ms = crashTimestampMs(c);
213
+ if (ms == null) unattributed += 1;
214
+ else if (Number.isFinite(sinceMs) && ms >= sinceMs) crashes.push(c);
215
+ }
216
+
217
+ record(
218
+ {
219
+ level: crashes.length > 0 ? 'warn' : 'info',
220
+ src: 'cli',
221
+ event: crashes.length > 0 ? 'crash.detected' : 'crash.probe_clear',
222
+ verb,
223
+ crash_count: Number.isFinite(crashes.length) ? crashes.length : undefined,
224
+ // app_id and message are sends:false — a crashed process name is an
225
+ // unreleased product name and a stack excerpt is free text.
226
+ app_id: (crashes[0] && crashes[0].process) || undefined,
227
+ },
228
+ recordOpts
229
+ );
230
+
231
+ if (envelope.ok === false) {
232
+ // EARNED result: we asked, the device answered, we attribute the answer.
233
+ // See the module comment above for why this key is safe to add.
234
+ // `unattributed` rides alongside `crashes` unconditionally (0 included)
235
+ // so "genuinely clean" and "device returned something unplaceable in
236
+ // time" are always distinguishable, never just when the count is > 0.
237
+ envelope.data = { ...(envelope.data || {}), crashes, unattributed };
238
+ if (crashes.length > 0) amendHint(envelope, crashHint(crashes));
239
+ else if (unattributed > 0) amendHint(envelope, unattributedHint(unattributed));
240
+ } else {
241
+ // ok:true only happens for the empty-`elements` trigger. `data` stays the
242
+ // elements array untouched; the result is prose-only, on ALL THREE
243
+ // determined outcomes (crash found, unattributed report found, confirmed
244
+ // clear). See the module comment above.
245
+ if (crashes.length > 0) amendHint(envelope, crashHint(crashes));
246
+ else if (unattributed > 0) amendHint(envelope, unattributedHint(unattributed));
247
+ else amendHint(envelope, NO_CRASH_HINT);
248
+ }
249
+ } catch (err) {
250
+ // Total by construction. A broken probe must never turn "tap failed:
251
+ // element not found" into "tap failed AND crash probing is broken" — that
252
+ // buries the error the caller actually needs.
253
+ try {
254
+ record(
255
+ {
256
+ level: 'warn',
257
+ src: 'cli',
258
+ event: 'crash.probe_failed',
259
+ verb,
260
+ message: (err && err.message) || String(err),
261
+ },
262
+ recordOpts
263
+ );
264
+ } catch (_) {
265
+ /* record() is already total; this is belt and suspenders */
266
+ }
267
+ }
268
+ }
269
+
270
+ module.exports = {
271
+ probeCrashes,
272
+ shouldProbe,
273
+ CRASH_PROBE_TIMEOUT_MS,
274
+ PROBE_EXEMPT_VERBS,
275
+ PROBE_KINDS,
276
+ };