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.
- package/README.md +11 -0
- package/bin/mauto-session-daemon.js +85 -16
- package/package.json +1 -1
- package/src/cli.js +724 -59
- package/src/device/bridge.js +30 -0
- package/src/device/connection.js +23 -4
- package/src/device/crash-model.js +110 -0
- package/src/device/device-call.js +145 -0
- package/src/device/failure-probe.js +276 -0
- package/src/device/mobile-mcp-tools.js +64 -0
- package/src/device/resolve-connection.js +8 -10
- package/src/device/session-daemon.js +265 -51
- package/src/device/session-handle.js +53 -0
- package/src/device/session-log.js +5 -16
- package/src/guide/content/execute.agnostic.md +48 -5
- package/src/guide/content/execute.aware.md +42 -1
- package/src/guide/content/execute.invariants.md +1 -0
- package/src/observe/accepts.js +108 -0
- package/src/observe/event.js +241 -0
- package/src/observe/failure-capture.js +166 -0
- package/src/observe/flush.js +177 -0
- package/src/observe/paths.js +125 -0
- package/src/observe/recorder.js +178 -0
- package/src/observe/settings.js +51 -0
- package/src/observe/sinks/file.js +101 -0
- package/src/observe/sinks/stderr.js +20 -0
- package/src/observe/spool.js +199 -0
- package/src/observe/telemetry.js +143 -0
- package/src/observe/trace.js +174 -0
- package/src/observe/transport.js +169 -0
- package/src/observe/vocab.js +96 -0
- package/src/output/envelope.js +6 -0
- package/src/result/capability-catalog.js +14 -0
- package/src/result/store.js +98 -1
- package/src/schemas/config_schema.json +11 -0
- package/src/schemas/result_schema.json +55 -0
- package/src/setup/scaffold.js +27 -0
- package/src/util/log-rotate.js +34 -0
- package/src/workspace/discover.js +69 -0
package/src/device/bridge.js
CHANGED
|
@@ -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 };
|
package/src/device/connection.js
CHANGED
|
@@ -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
|
-
// `
|
|
28
|
-
//
|
|
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 {
|
|
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
|
+
};
|