agent-scenario-loop 0.1.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -0
  3. package/app/profile-session.ts +812 -0
  4. package/core/config-template.json +41 -0
  5. package/dist/core/agent-summary.d.ts +15 -0
  6. package/dist/core/agent-summary.js +177 -0
  7. package/dist/core/artifact-contract.d.ts +151 -0
  8. package/dist/core/artifact-contract.js +897 -0
  9. package/dist/core/artifact-layout.d.ts +56 -0
  10. package/dist/core/artifact-layout.js +61 -0
  11. package/dist/core/artifact-writer.d.ts +44 -0
  12. package/dist/core/artifact-writer.js +55 -0
  13. package/dist/core/comparison.d.ts +133 -0
  14. package/dist/core/comparison.js +294 -0
  15. package/dist/core/evidence-interpreter.d.ts +28 -0
  16. package/dist/core/evidence-interpreter.js +69 -0
  17. package/dist/core/execution-plan.d.ts +44 -0
  18. package/dist/core/execution-plan.js +95 -0
  19. package/dist/core/planner.d.ts +132 -0
  20. package/dist/core/planner.js +812 -0
  21. package/dist/core/ports.d.ts +198 -0
  22. package/dist/core/ports.js +146 -0
  23. package/dist/core/run-index.d.ts +62 -0
  24. package/dist/core/run-index.js +143 -0
  25. package/dist/core/schema-validator.d.ts +86 -0
  26. package/dist/core/schema-validator.js +407 -0
  27. package/dist/index.d.ts +11 -0
  28. package/dist/index.js +27 -0
  29. package/dist/runner/agent-device-driver.d.ts +126 -0
  30. package/dist/runner/agent-device-driver.js +168 -0
  31. package/dist/runner/agent-device.d.ts +295 -0
  32. package/dist/runner/agent-device.js +1271 -0
  33. package/dist/runner/android-adb-driver.d.ts +175 -0
  34. package/dist/runner/android-adb-driver.js +399 -0
  35. package/dist/runner/android-adb.d.ts +254 -0
  36. package/dist/runner/android-adb.js +1618 -0
  37. package/dist/runner/argent-driver.d.ts +183 -0
  38. package/dist/runner/argent-driver.js +297 -0
  39. package/dist/runner/argent.d.ts +349 -0
  40. package/dist/runner/argent.js +1211 -0
  41. package/dist/runner/check-plan.d.ts +45 -0
  42. package/dist/runner/check-plan.js +210 -0
  43. package/dist/runner/cli.d.ts +20 -0
  44. package/dist/runner/cli.js +23 -0
  45. package/dist/runner/compare-latest.d.ts +99 -0
  46. package/dist/runner/compare-latest.js +233 -0
  47. package/dist/runner/compare.d.ts +58 -0
  48. package/dist/runner/compare.js +157 -0
  49. package/dist/runner/demo-loop.d.ts +45 -0
  50. package/dist/runner/demo-loop.js +170 -0
  51. package/dist/runner/example-android-live.d.ts +137 -0
  52. package/dist/runner/example-android-live.js +454 -0
  53. package/dist/runner/example-ios-live.d.ts +137 -0
  54. package/dist/runner/example-ios-live.js +471 -0
  55. package/dist/runner/host-doctor.d.ts +131 -0
  56. package/dist/runner/host-doctor.js +628 -0
  57. package/dist/runner/init-project.d.ts +88 -0
  58. package/dist/runner/init-project.js +263 -0
  59. package/dist/runner/ios-simctl-driver.d.ts +69 -0
  60. package/dist/runner/ios-simctl-driver.js +97 -0
  61. package/dist/runner/ios-simctl.d.ts +254 -0
  62. package/dist/runner/ios-simctl.js +1415 -0
  63. package/dist/runner/live-android.d.ts +137 -0
  64. package/dist/runner/live-android.js +539 -0
  65. package/dist/runner/live-comparison.d.ts +67 -0
  66. package/dist/runner/live-comparison.js +147 -0
  67. package/dist/runner/live-ios.d.ts +137 -0
  68. package/dist/runner/live-ios.js +460 -0
  69. package/dist/runner/live-proof-summary.d.ts +263 -0
  70. package/dist/runner/live-proof-summary.js +465 -0
  71. package/dist/runner/live-proof.d.ts +467 -0
  72. package/dist/runner/live-proof.js +920 -0
  73. package/dist/runner/local-env.d.ts +64 -0
  74. package/dist/runner/local-env.js +155 -0
  75. package/dist/runner/profile-android.d.ts +82 -0
  76. package/dist/runner/profile-android.js +671 -0
  77. package/dist/runner/profile-ios.d.ts +108 -0
  78. package/dist/runner/profile-ios.js +532 -0
  79. package/dist/runner/profile-mobile.d.ts +254 -0
  80. package/dist/runner/profile-mobile.js +1307 -0
  81. package/dist/runner/validate-project.d.ts +273 -0
  82. package/dist/runner/validate-project.js +1501 -0
  83. package/docs/adapters.md +145 -0
  84. package/docs/api.md +94 -0
  85. package/docs/authoring.md +196 -0
  86. package/docs/concepts.md +136 -0
  87. package/docs/consumer-rehearsal.md +115 -0
  88. package/docs/contracts.md +267 -0
  89. package/docs/live-proofs.md +270 -0
  90. package/docs/principles.md +46 -0
  91. package/examples/event-logs/app-startup-baseline.log +4 -0
  92. package/examples/event-logs/app-startup-current.log +4 -0
  93. package/examples/minimal-app/README.md +70 -0
  94. package/examples/mobile-app/README.md +302 -0
  95. package/examples/mobile-app/app.json +22 -0
  96. package/examples/mobile-app/asl/package-scripts.json +32 -0
  97. package/examples/mobile-app/asl.config.json +37 -0
  98. package/examples/mobile-app/event-logs/android-app-startup.log +4 -0
  99. package/examples/mobile-app/event-logs/android-open-close-cycle.log +12 -0
  100. package/examples/mobile-app/event-logs/android-scroll-settle.log +12 -0
  101. package/examples/mobile-app/event-logs/app-startup.log +4 -0
  102. package/examples/mobile-app/event-logs/open-close-cycle.log +12 -0
  103. package/examples/mobile-app/event-logs/scroll-settle.log +12 -0
  104. package/examples/mobile-app/index.ts +20 -0
  105. package/examples/mobile-app/metro.config.js +20 -0
  106. package/examples/mobile-app/package.json +62 -0
  107. package/examples/mobile-app/patches/expo-modules-jsi@56.0.10.patch +19 -0
  108. package/examples/mobile-app/plugins/with-ios-build-compat.js +271 -0
  109. package/examples/mobile-app/pnpm-lock.yaml +4440 -0
  110. package/examples/mobile-app/runner-manifests/evidence-provider.json +79 -0
  111. package/examples/mobile-app/runner-manifests/primary-runner.json +19 -0
  112. package/examples/mobile-app/scenarios/android/app-startup-video.json +73 -0
  113. package/examples/mobile-app/scenarios/android/app-startup.json +44 -0
  114. package/examples/mobile-app/scenarios/android/open-close-cycle.json +54 -0
  115. package/examples/mobile-app/scenarios/android/scroll-settle.json +49 -0
  116. package/examples/mobile-app/scenarios/ios/app-startup.json +44 -0
  117. package/examples/mobile-app/scenarios/ios/open-close-cycle.json +54 -0
  118. package/examples/mobile-app/scenarios/ios/scroll-settle.json +49 -0
  119. package/examples/mobile-app/scenarios/mobile/app-startup.json +91 -0
  120. package/examples/mobile-app/scenarios/mobile/open-close-cycle.json +160 -0
  121. package/examples/mobile-app/scenarios/mobile/scroll-settle.json +148 -0
  122. package/examples/mobile-app/scripts/asl-capture-accessibility-provider.mjs +112 -0
  123. package/examples/mobile-app/scripts/asl-capture-profiler-provider.mjs +127 -0
  124. package/examples/mobile-app/src/devtools/profile-session.ts +7 -0
  125. package/examples/mobile-app/src/example-screen.tsx +322 -0
  126. package/examples/mobile-app/tsconfig.json +16 -0
  127. package/examples/mobile-app/tsconfig.typecheck.json +13 -0
  128. package/examples/runners/README.md +44 -0
  129. package/examples/runners/adb-android.json +25 -0
  130. package/examples/runners/agent-device-android.json +27 -0
  131. package/examples/runners/agent-device-ios.json +27 -0
  132. package/examples/runners/argent-android.json +32 -0
  133. package/examples/runners/argent-ios.json +32 -0
  134. package/examples/runners/argent-react-profiler-provider.json +15 -0
  135. package/examples/runners/axe-accessibility-provider.json +24 -0
  136. package/examples/runners/manual-log-ingest.json +9 -0
  137. package/examples/runners/rozenite-profiler-provider.json +9 -0
  138. package/examples/runners/script-accessibility-provider.json +24 -0
  139. package/examples/runners/script-memory-provider.json +24 -0
  140. package/examples/runners/script-network-provider.json +24 -0
  141. package/examples/runners/script-profiler-provider.json +30 -0
  142. package/examples/runners/xcodebuildmcp-ios.json +29 -0
  143. package/examples/scenarios/ios/app-startup.json +28 -0
  144. package/examples/scenarios/ios/open-close-cycle.json +35 -0
  145. package/examples/scenarios/mobile/app-startup.json +72 -0
  146. package/examples/scenarios/mobile/media-open-close.json +141 -0
  147. package/examples/scenarios/mobile/open-close-cycle.json +135 -0
  148. package/examples/scenarios/mobile/scroll-settle.json +106 -0
  149. package/package.json +240 -0
  150. package/schemas/budget-verdict.schema.json +115 -0
  151. package/schemas/causal-run.schema.json +279 -0
  152. package/schemas/comparison.schema.json +196 -0
  153. package/schemas/health.schema.json +108 -0
  154. package/schemas/live-proof-set.schema.json +195 -0
  155. package/schemas/live-proof.schema.json +413 -0
  156. package/schemas/manifest.schema.json +204 -0
  157. package/schemas/metrics.schema.json +137 -0
  158. package/schemas/project-validation.schema.json +343 -0
  159. package/schemas/runner-capabilities.schema.json +217 -0
  160. package/schemas/scenario.schema.json +400 -0
  161. package/schemas/verdict.schema.json +88 -0
  162. package/templates/evidence-provider.json +83 -0
  163. package/templates/gitignore-snippet +9 -0
  164. package/templates/integration-readme.md +125 -0
  165. package/templates/mobile-scenario.json +133 -0
  166. package/templates/package-scripts.json +32 -0
  167. package/templates/primary-runner.json +19 -0
  168. package/templates/project.config.json +37 -0
  169. package/templates/scripts/asl-capture-accessibility-provider.mjs +112 -0
  170. package/templates/scripts/asl-capture-profiler-provider.mjs +127 -0
@@ -0,0 +1,1618 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER = void 0;
5
+ exports.buildAndroidHealth = buildAndroidHealth;
6
+ exports.buildAndroidVerdict = buildAndroidVerdict;
7
+ exports.buildReactNativeDebugHostPreferenceCommand = buildReactNativeDebugHostPreferenceCommand;
8
+ exports.escapeAndroidPreferenceXml = escapeAndroidPreferenceXml;
9
+ exports.execFileCommand = execFileCommand;
10
+ exports.main = main;
11
+ exports.parseAdbDevices = parseAdbDevices;
12
+ exports.parseArgs = parseArgs;
13
+ exports.parsePositiveInteger = parsePositiveInteger;
14
+ exports.parseReactNativeDebugHostPort = parseReactNativeDebugHostPort;
15
+ exports.resolveAndroidAdbDriverSteps = resolveAndroidAdbDriverSteps;
16
+ exports.applyAndroidSelectorResolution = applyAndroidSelectorResolution;
17
+ exports.buildAndroidSelectorHealthMetadata = buildAndroidSelectorHealthMetadata;
18
+ exports.needsAndroidSelectorResolution = needsAndroidSelectorResolution;
19
+ exports.runAndroidAdbDriverStep = runAndroidAdbDriverStep;
20
+ exports.runAndroidAdbPreflight = runAndroidAdbPreflight;
21
+ exports.selectDevice = selectDevice;
22
+ exports.usage = usage;
23
+ const { execFile } = require('node:child_process');
24
+ const crypto = require('node:crypto');
25
+ const fsp = require('node:fs/promises');
26
+ const path = require('node:path');
27
+ const { buildAgentSummaryMarkdown } = require('../core/agent-summary');
28
+ const { createArtifactLayout } = require('../core/artifact-layout');
29
+ const { writeJsonArtifact, writeTextArtifact } = require('../core/artifact-writer');
30
+ const { SCHEMAS, assertValidJson } = require('../core/schema-validator');
31
+ const { hasHelpFlag, writeUsage } = require('./cli');
32
+ const { buildAndroidScrollCoordinatesFromBounds, createAndroidAdbDriver, formatAndroidAdbRawOutput, quoteAndroidShellArg, resolveAndroidSelectorFromUiTree, } = require('./android-adb-driver');
33
+ const ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER = '__ASL_ANDROID_DEVICE_EPOCH_MS__';
34
+ exports.ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER = ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER;
35
+ const ANDROID_READY_LOG_POLL_MS = 1000;
36
+ /**
37
+ * Prints CLI usage to stderr.
38
+ *
39
+ * @returns {void}
40
+ */
41
+ function usage(output = process.stderr) {
42
+ writeUsage([
43
+ 'Usage: asl-android-adb [--adb <path>] [--serial <device>] [--package <name>] [--run-id <id>] [--out <dir>]',
44
+ '',
45
+ 'Checks adb/device readiness and writes health.json, verdict.json, agent-summary.md, and raw adb evidence.',
46
+ 'Use --capture-logcat [--logcat-lines <count>] to attach a bounded adb logcat snapshot under raw/adb-logcat.txt.',
47
+ 'Use --clear-logcat --launch [--launch-wait-ms <ms>] --wait-ms <ms> with --package <name> to capture a bounded app launch window.',
48
+ 'Use --react-native-debug-host <host:port> with --package <name> to set the app debug server and adb reverse for React Native dev builds.',
49
+ 'Use --android-dev-client-url <url> [--android-dev-client-wait-ms <ms>] [--android-dev-client-ready-pattern <pattern>] to open an Expo dev-client session before scenario deep links.',
50
+ ], output);
51
+ }
52
+ /**
53
+ * Parses `--key value` arguments for the Android adb preflight CLI.
54
+ *
55
+ * @param {string[]} argv
56
+ * @returns {CliArgs}
57
+ */
58
+ function parseArgs(argv) {
59
+ const args = {};
60
+ for (let index = 0; index < argv.length; index += 1) {
61
+ const token = argv[index];
62
+ if (token === '--') {
63
+ continue;
64
+ }
65
+ if (!token || !token.startsWith('--')) {
66
+ continue;
67
+ }
68
+ const key = token.slice(2);
69
+ const value = argv[index + 1];
70
+ if (value && !value.startsWith('--')) {
71
+ args[key] = value;
72
+ index += 1;
73
+ }
74
+ else {
75
+ args[key] = true;
76
+ }
77
+ }
78
+ return args;
79
+ }
80
+ /**
81
+ * Creates a short random run id for Android preflight runs.
82
+ *
83
+ * @returns {string}
84
+ */
85
+ function createRunId() {
86
+ return crypto.randomBytes(6).toString('hex');
87
+ }
88
+ /**
89
+ * Parses a positive integer CLI value, falling back when absent or invalid.
90
+ *
91
+ * @param {string | boolean | undefined} value
92
+ * @param {number} fallback
93
+ * @returns {number}
94
+ */
95
+ function parsePositiveInteger(value, fallback) {
96
+ if (typeof value !== 'string') {
97
+ return fallback;
98
+ }
99
+ const parsed = Number(value);
100
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
101
+ }
102
+ /**
103
+ * Runs a command and captures stdout, stderr, and exit code without throwing.
104
+ *
105
+ * @param {string} command
106
+ * @param {string[]} args
107
+ * @returns {Promise<CommandResult>}
108
+ */
109
+ function execFileCommand(command, args) {
110
+ return new Promise((resolve) => {
111
+ execFile(command, args, (error, stdout, stderr) => {
112
+ resolve({
113
+ command,
114
+ args,
115
+ exitCode: error && typeof error.code === 'number' ? error.code : error ? 1 : 0,
116
+ stderr,
117
+ stdout,
118
+ });
119
+ });
120
+ });
121
+ }
122
+ /**
123
+ * Waits for the requested capture window.
124
+ *
125
+ * @param {number} ms
126
+ * @returns {Promise<void>}
127
+ */
128
+ function delay(ms) {
129
+ return new Promise((resolve) => {
130
+ setTimeout(resolve, ms);
131
+ });
132
+ }
133
+ /**
134
+ * Parses `adb devices -l` output into device rows.
135
+ *
136
+ * @param {string} output
137
+ * @returns {AndroidDevice[]}
138
+ */
139
+ function parseAdbDevices(output) {
140
+ return String(output)
141
+ .split(/\r?\n/u)
142
+ .slice(1)
143
+ .map((line) => line.trim())
144
+ .filter(Boolean)
145
+ .map((line) => {
146
+ const [serial = '', state = '', ...rest] = line.split(/\s+/u);
147
+ return {
148
+ serial,
149
+ state,
150
+ description: rest.join(' '),
151
+ };
152
+ })
153
+ .filter((device) => device.serial.length > 0);
154
+ }
155
+ /**
156
+ * Selects an Android device by explicit serial or first online device.
157
+ *
158
+ * @param {AndroidDevice[]} devices
159
+ * @param {string | null | undefined} serial
160
+ * @returns {AndroidDevice | null}
161
+ */
162
+ function selectDevice(devices, serial) {
163
+ if (serial) {
164
+ return devices.find((device) => device.serial === serial) ?? null;
165
+ }
166
+ return devices.find((device) => device.state === 'device') ?? null;
167
+ }
168
+ /**
169
+ * Creates scalar health-check metadata for an agent-readable next action.
170
+ *
171
+ * @param {string} nextActionCode
172
+ * @param {string} nextAction
173
+ * @returns {NextActionHint}
174
+ */
175
+ function nextActionHint(nextActionCode, nextAction) {
176
+ return {
177
+ nextAction,
178
+ nextActionCode,
179
+ };
180
+ }
181
+ /**
182
+ * Detects adb daemon/socket failures that are distinct from a genuinely missing device.
183
+ *
184
+ * @param {CommandResult} result
185
+ * @returns {boolean}
186
+ */
187
+ function isAdbDaemonUnavailable(result) {
188
+ if (result.exitCode === 0) {
189
+ return false;
190
+ }
191
+ const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
192
+ return (output.includes('cannot connect to daemon') ||
193
+ output.includes('failed to check server version') ||
194
+ output.includes('could not install *smartsocket* listener') ||
195
+ output.includes('adb server didn'));
196
+ }
197
+ /**
198
+ * Builds the health check details for device selection failures.
199
+ *
200
+ * @param {{devicesOutput: CommandResult, serial?: string | null}} options
201
+ * @returns {{code: string, message: string, metadata: NextActionHint}}
202
+ */
203
+ function buildAndroidDeviceFailure({ devicesOutput, serial, }) {
204
+ if (isAdbDaemonUnavailable(devicesOutput)) {
205
+ return {
206
+ code: 'adb_daemon_unreachable',
207
+ message: 'adb devices could not reach or start the adb daemon.',
208
+ metadata: nextActionHint('rerun_with_adb_daemon_access', 'Start the adb daemon from a host shell or rerun the live proof with host adb daemon access, then confirm `adb devices -l` lists the target device as `device`.'),
209
+ };
210
+ }
211
+ return {
212
+ code: 'android_device_missing',
213
+ message: serial
214
+ ? `No online Android device matched serial ${serial}.`
215
+ : 'No online Android device was found.',
216
+ metadata: nextActionHint('select_android_device', 'Start or unlock an Android emulator/device, confirm it appears as `device` in adb devices -l, or pass --serial for the intended device.'),
217
+ };
218
+ }
219
+ /**
220
+ * Reads the TCP port from a React Native debug server host string.
221
+ *
222
+ * @param {string} debugHost
223
+ * @returns {number | null}
224
+ */
225
+ function parseReactNativeDebugHostPort(debugHost) {
226
+ if (debugHost.includes('://')) {
227
+ return null;
228
+ }
229
+ const match = /:(?<port>\d+)$/u.exec(debugHost);
230
+ const port = match?.groups?.port ? Number(match.groups.port) : NaN;
231
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
232
+ }
233
+ /**
234
+ * Escapes text for the Android shared preference XML file.
235
+ *
236
+ * @param {string} value
237
+ * @returns {string}
238
+ */
239
+ function escapeAndroidPreferenceXml(value) {
240
+ return value
241
+ .replace(/&/gu, '&amp;')
242
+ .replace(/</gu, '&lt;')
243
+ .replace(/>/gu, '&gt;')
244
+ .replace(/"/gu, '&quot;')
245
+ .replace(/'/gu, '&apos;');
246
+ }
247
+ /**
248
+ * Builds a device-side shell command that writes React Native debug host preferences.
249
+ *
250
+ * @param {{debugHost: string, packageName: string}} options
251
+ * @returns {string}
252
+ */
253
+ function buildReactNativeDebugHostPreferenceCommand({ debugHost, packageName, }) {
254
+ const preferenceFile = `shared_prefs/${packageName}_preferences.xml`;
255
+ const lines = [
256
+ '<?xml version="1.0" encoding="utf-8" standalone="yes" ?>',
257
+ '<map>',
258
+ ` <string name="debug_http_host">${escapeAndroidPreferenceXml(debugHost)}</string>`,
259
+ '</map>',
260
+ ];
261
+ return [
262
+ `cd ${quoteAndroidShellArg(`/data/data/${packageName}`)}`,
263
+ 'mkdir -p shared_prefs',
264
+ [
265
+ `printf ${quoteAndroidShellArg('%s\\n')}`,
266
+ ...lines.map((line) => quoteAndroidShellArg(line)),
267
+ `> ${quoteAndroidShellArg(preferenceFile)}`,
268
+ ].join(' '),
269
+ ].join(' && ');
270
+ }
271
+ /**
272
+ * Escapes a string for insertion as a SQLite string literal.
273
+ *
274
+ * @param {string} value
275
+ * @returns {string}
276
+ */
277
+ function escapeSqliteString(value) {
278
+ return value.replace(/'/gu, "''");
279
+ }
280
+ /**
281
+ * Builds SQL that updates React Native AsyncStorage's Android RKStorage table.
282
+ *
283
+ * @param {{clearKeys?: string[], key: string, value: string}} options
284
+ * @returns {string}
285
+ */
286
+ function buildAndroidAsyncStorageWriteSql({ clearKeys = [], key, value, }) {
287
+ return [
288
+ 'PRAGMA busy_timeout=5000;',
289
+ ...clearKeys.map((clearKey) => (`DELETE FROM catalystLocalStorage WHERE key='${escapeSqliteString(clearKey)}';`)),
290
+ `INSERT OR REPLACE INTO catalystLocalStorage (key,value) VALUES ('${escapeSqliteString(key)}','${escapeSqliteString(value)}');`,
291
+ ].join('\n');
292
+ }
293
+ /**
294
+ * Builds a package-scoped command for writing one Android AsyncStorage value.
295
+ *
296
+ * @param {{packageName: string, write: AndroidAsyncStorageWrite}} options
297
+ * @returns {string}
298
+ */
299
+ function buildAndroidAsyncStorageWriteCommand({ packageName, write, }) {
300
+ const sql = buildAndroidAsyncStorageWriteSql({
301
+ ...(write.clearKeys ? { clearKeys: write.clearKeys } : {}),
302
+ key: write.key,
303
+ value: write.value,
304
+ });
305
+ return [
306
+ 'run-as',
307
+ quoteAndroidShellArg(packageName),
308
+ 'sqlite3',
309
+ quoteAndroidShellArg('databases/RKStorage'),
310
+ quoteAndroidShellArg(sql),
311
+ ].join(' ');
312
+ }
313
+ /**
314
+ * Reads the selected device clock as epoch milliseconds for app-side timing.
315
+ *
316
+ * @param {{adbPath: string, deviceSerial: string, executor: CommandExecutor}} options
317
+ * @returns {Promise<CommandResult & {epochMs: number | null}>}
318
+ */
319
+ async function readAndroidDeviceEpochMs({ adbPath, deviceSerial, executor, }) {
320
+ const result = await executor(adbPath, ['-s', deviceSerial, 'shell', 'date', '+%s']);
321
+ const seconds = Number(result.stdout.trim());
322
+ const epochMs = result.exitCode === 0 && Number.isFinite(seconds) && seconds > 0
323
+ ? Math.round(seconds * 1000)
324
+ : null;
325
+ return {
326
+ ...result,
327
+ epochMs,
328
+ };
329
+ }
330
+ /**
331
+ * Combines one adb command result into raw evidence text.
332
+ *
333
+ * @param {CommandResult} result
334
+ * @returns {string}
335
+ */
336
+ function formatAndroidCommandRawOutput(result) {
337
+ return [
338
+ `$ adb ${result.args.join(' ')}`,
339
+ `exitCode=${result.exitCode}`,
340
+ result.stdout,
341
+ result.stderr,
342
+ ].filter(Boolean).join('\n');
343
+ }
344
+ /**
345
+ * Parses Android `pidof` output into stable process ids.
346
+ *
347
+ * @param {string} output
348
+ * @returns {string[]}
349
+ */
350
+ function parseAndroidPidofOutput(output) {
351
+ return String(output)
352
+ .trim()
353
+ .split(/\s+/u)
354
+ .filter((pid) => /^\d+$/u.test(pid));
355
+ }
356
+ /**
357
+ * Escapes text so it can be embedded in a regular expression.
358
+ *
359
+ * @param {string} value
360
+ * @returns {string}
361
+ */
362
+ function escapeRegExp(value) {
363
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
364
+ }
365
+ /**
366
+ * Finds crash-like Android logcat windows for one app process.
367
+ *
368
+ * Android crash logs often split the signal line from the `Process:` line, so
369
+ * this scans a small neighborhood around each crash marker before deciding it
370
+ * belongs to the launched app.
371
+ *
372
+ * @param {{logText: string, packageName: string, pids?: string[]}} options
373
+ * @returns {AndroidAppLifecycleScan}
374
+ */
375
+ function scanAndroidAppLifecycleLog({ logText, packageName, pids = [], }) {
376
+ const lines = String(logText).split(/\r?\n/u);
377
+ const packagePattern = new RegExp(escapeRegExp(packageName), 'u');
378
+ const pidPatterns = pids.map((pid) => new RegExp(`\\b${escapeRegExp(pid)}\\b`, 'u'));
379
+ const crashPattern = /\b(FATAL EXCEPTION|Fatal signal|SIGSEGV|SIGABRT|ANR in|Force finishing activity|has died|Process .* died)\b/iu;
380
+ const evidence = new Set();
381
+ for (let index = 0; index < lines.length; index += 1) {
382
+ const line = lines[index] ?? '';
383
+ if (!crashPattern.test(line)) {
384
+ continue;
385
+ }
386
+ const windowStart = Math.max(0, index - 3);
387
+ const windowEnd = Math.min(lines.length, index + 4);
388
+ const windowLines = lines.slice(windowStart, windowEnd);
389
+ const windowText = windowLines.join('\n');
390
+ const belongsToApp = packagePattern.test(windowText)
391
+ || pidPatterns.some((pattern) => pattern.test(windowText));
392
+ if (belongsToApp) {
393
+ for (const evidenceLine of windowLines) {
394
+ if (evidenceLine.trim()) {
395
+ evidence.add(evidenceLine);
396
+ }
397
+ }
398
+ }
399
+ }
400
+ return {
401
+ crashed: evidence.size > 0,
402
+ evidence: Array.from(evidence),
403
+ };
404
+ }
405
+ /**
406
+ * Counts readiness markers in a bounded Android logcat snapshot.
407
+ *
408
+ * @param {{logText: string, pattern: string}} options
409
+ * @returns {number}
410
+ */
411
+ function countAndroidReadyLogMatches({ logText, pattern, }) {
412
+ try {
413
+ return Array.from(String(logText).matchAll(new RegExp(pattern, 'gu'))).length;
414
+ }
415
+ catch {
416
+ return String(logText).split(pattern).length - 1;
417
+ }
418
+ }
419
+ /**
420
+ * Waits until a startup deep link has produced an expected Android log marker.
421
+ *
422
+ * @param {{driver: import('./android-adb-driver').AndroidAdbDriver, logcatLines: number, pattern: string, quietMs: number, rawFileName: string, timeoutMs: number, wait: (ms: number) => Promise<void>}} options
423
+ * @returns {Promise<{ready: boolean, result: import('./android-adb-driver').AndroidAdbCommandResult}>}
424
+ */
425
+ async function waitForAndroidReadyLog({ driver, logcatLines, pattern, quietMs, rawFileName, timeoutMs, wait, }) {
426
+ const deadline = Date.now() + timeoutMs;
427
+ let lastMatchCount = -1;
428
+ let lastChangeAt = Date.now();
429
+ let result = await driver.readLogs({ lines: logcatLines, rawFileName });
430
+ for (;;) {
431
+ const matchCount = countAndroidReadyLogMatches({
432
+ logText: `${result.stdout}\n${result.stderr}`,
433
+ pattern,
434
+ });
435
+ if (matchCount !== lastMatchCount) {
436
+ lastMatchCount = matchCount;
437
+ lastChangeAt = Date.now();
438
+ }
439
+ const ready = matchCount > 0 && Date.now() - lastChangeAt >= quietMs;
440
+ if (ready || Date.now() >= deadline) {
441
+ return { ready, result };
442
+ }
443
+ await wait(ANDROID_READY_LOG_POLL_MS);
444
+ result = await driver.readLogs({ lines: logcatLines, rawFileName });
445
+ }
446
+ }
447
+ /**
448
+ * Builds a runner health artifact from adb preflight checks.
449
+ *
450
+ * @param {{runId: string, checks: Record<string, unknown>[]}} options
451
+ * @returns {Record<string, unknown>}
452
+ */
453
+ function buildAndroidHealth({ runId, checks }) {
454
+ const failed = checks.some((check) => check.status === 'failed');
455
+ return assertValidJson({
456
+ schemaVersion: '1.0.0',
457
+ scenarioId: 'android-adb-preflight',
458
+ flowId: 'android-adb-preflight',
459
+ runId,
460
+ healthStatus: failed ? 'failed' : 'passed',
461
+ checks,
462
+ }, SCHEMAS.health, 'Health artifact');
463
+ }
464
+ /**
465
+ * Builds a verdict artifact for adb preflight readiness.
466
+ *
467
+ * @param {{runId: string, health: Record<string, unknown>}} options
468
+ * @returns {Record<string, unknown>}
469
+ */
470
+ function buildAndroidVerdict({ runId, health }) {
471
+ const passed = health.healthStatus === 'passed';
472
+ return assertValidJson({
473
+ schemaVersion: '1.0.0',
474
+ scenarioId: 'android-adb-preflight',
475
+ flowId: 'android-adb-preflight',
476
+ runId,
477
+ healthStatus: health.healthStatus,
478
+ verdictStatus: passed ? 'not_evaluated' : 'inconclusive',
479
+ budgetChecks: [],
480
+ summary: passed
481
+ ? 'Android adb preflight passed; no product budget has been evaluated.'
482
+ : 'Android adb preflight failed; runtime scenario execution is not ready.',
483
+ }, SCHEMAS.verdict, 'Verdict artifact');
484
+ }
485
+ /**
486
+ * Builds the driver steps for this adb capture window.
487
+ *
488
+ * @param {{captureLogcat: boolean, driverSteps: AndroidAdbDriverStep[], logcatLines: number, waitMs: number}} options
489
+ * @returns {AndroidAdbDriverStep[]}
490
+ */
491
+ function resolveAndroidAdbDriverSteps({ captureLogcat, driverSteps, logcatLines, waitMs, }) {
492
+ if (driverSteps.length > 0) {
493
+ let readLogsIndex = 0;
494
+ const resolved = driverSteps.map((step, index) => {
495
+ const actionIndex = index + 1;
496
+ if (step.driverAction === 'readLogs') {
497
+ readLogsIndex += 1;
498
+ }
499
+ return {
500
+ ...step,
501
+ ...(step.driverAction === 'readLogs' ? { lines: step.lines ?? logcatLines } : {}),
502
+ rawFileName: step.rawFileName ?? defaultAndroidAdbRawFileName({
503
+ driverAction: step.driverAction,
504
+ index: actionIndex,
505
+ readLogsIndex,
506
+ }),
507
+ ...(step.driverAction === 'record'
508
+ ? {
509
+ captureFileName: step.captureFileName ?? defaultAndroidAdbCaptureFileName({
510
+ driverAction: step.driverAction,
511
+ index: actionIndex,
512
+ }),
513
+ }
514
+ : {}),
515
+ required: step.required !== false,
516
+ };
517
+ });
518
+ if (captureLogcat && readLogsIndex === 0) {
519
+ resolved.push({
520
+ driverAction: 'readLogs',
521
+ lines: logcatLines,
522
+ rawFileName: 'adb-logcat.txt',
523
+ required: true,
524
+ ...(waitMs > 0 ? { waitMs } : {}),
525
+ });
526
+ }
527
+ return resolved;
528
+ }
529
+ return captureLogcat
530
+ ? [{
531
+ driverAction: 'readLogs',
532
+ lines: logcatLines,
533
+ rawFileName: 'adb-logcat.txt',
534
+ required: true,
535
+ ...(waitMs > 0 ? { waitMs } : {}),
536
+ }]
537
+ : [];
538
+ }
539
+ /**
540
+ * Returns the default raw evidence filename for one adb driver action.
541
+ *
542
+ * @param {{driverAction: AndroidAdbDriverStep['driverAction'], index: number, readLogsIndex: number}} options
543
+ * @returns {string}
544
+ */
545
+ function defaultAndroidAdbRawFileName({ driverAction, index, readLogsIndex, }) {
546
+ if (driverAction === 'readLogs') {
547
+ return readLogsIndex === 1 ? 'adb-logcat.txt' : `adb-logcat-${readLogsIndex}.txt`;
548
+ }
549
+ const suffix = index === 1 ? '' : `-${index}`;
550
+ if (driverAction === 'inspectTree') {
551
+ return `adb-ui-tree${suffix}.xml`;
552
+ }
553
+ if (driverAction === 'assertVisible') {
554
+ return `adb-assert-visible${suffix}.xml`;
555
+ }
556
+ if (driverAction === 'screenshot') {
557
+ return `adb-screenshot${suffix}.png`;
558
+ }
559
+ return `adb-${driverAction}${suffix}.txt`;
560
+ }
561
+ /**
562
+ * Returns the default capture filename for one adb driver action.
563
+ *
564
+ * @param {{driverAction: AndroidAdbDriverStep['driverAction'], index: number}} options
565
+ * @returns {string}
566
+ */
567
+ function defaultAndroidAdbCaptureFileName({ driverAction, index, }) {
568
+ const suffix = index === 1 ? '' : `-${index}`;
569
+ if (driverAction === 'record') {
570
+ return `adb-record${suffix}.mp4`;
571
+ }
572
+ return `adb-${driverAction}${suffix}`;
573
+ }
574
+ /**
575
+ * Builds a stable health code suffix for an adb driver action.
576
+ *
577
+ * @param {AndroidAdbDriverStep['driverAction']} driverAction
578
+ * @returns {string}
579
+ */
580
+ function androidDriverActionCode(driverAction) {
581
+ return driverAction.replace(/[A-Z]/gu, (letter) => `_${letter.toLowerCase()}`);
582
+ }
583
+ /**
584
+ * Returns whether a driver step can derive missing coordinates from a selector.
585
+ *
586
+ * @param {AndroidAdbDriverStep} driverStep
587
+ * @returns {boolean}
588
+ */
589
+ function needsAndroidSelectorResolution(driverStep) {
590
+ if (!driverStep.selector) {
591
+ return false;
592
+ }
593
+ if (driverStep.driverAction === 'tap') {
594
+ return typeof driverStep.x !== 'number' || typeof driverStep.y !== 'number';
595
+ }
596
+ return driverStep.driverAction === 'scroll' && (typeof driverStep.startX !== 'number' ||
597
+ typeof driverStep.startY !== 'number' ||
598
+ typeof driverStep.endX !== 'number' ||
599
+ typeof driverStep.endY !== 'number');
600
+ }
601
+ /**
602
+ * Applies a resolved selector to one tap or scroll driver step.
603
+ *
604
+ * @param {{driverStep: AndroidAdbDriverStep, resolution: import('./android-adb-driver').AndroidSelectorResolution}} options
605
+ * @returns {AndroidAdbDriverStep}
606
+ */
607
+ function applyAndroidSelectorResolution({ driverStep, resolution, }) {
608
+ if (driverStep.driverAction === 'tap') {
609
+ return {
610
+ ...driverStep,
611
+ x: driverStep.x ?? resolution.centerX,
612
+ y: driverStep.y ?? resolution.centerY,
613
+ };
614
+ }
615
+ if (driverStep.driverAction === 'scroll') {
616
+ const coordinates = buildAndroidScrollCoordinatesFromBounds(resolution.bounds);
617
+ return {
618
+ ...driverStep,
619
+ endX: driverStep.endX ?? coordinates.endX,
620
+ endY: driverStep.endY ?? coordinates.endY,
621
+ startX: driverStep.startX ?? coordinates.startX,
622
+ startY: driverStep.startY ?? coordinates.startY,
623
+ };
624
+ }
625
+ return driverStep;
626
+ }
627
+ /**
628
+ * Converts a selector into scalar health-check metadata fields.
629
+ *
630
+ * @param {import('./android-adb-driver').AndroidSelector | undefined} selector
631
+ * @returns {Record<string, string>}
632
+ */
633
+ function buildAndroidSelectorHealthMetadata(selector) {
634
+ if (!selector) {
635
+ return {};
636
+ }
637
+ return {
638
+ selectorKind: selector.kind,
639
+ selectorValue: selector.value,
640
+ ...(selector.match ? { selectorMatch: selector.match } : {}),
641
+ };
642
+ }
643
+ /**
644
+ * Returns a compact single-line adb driver failure preview.
645
+ *
646
+ * @param {import('./android-adb-driver').AndroidAdbCommandResult} driverResult
647
+ * @returns {string | null}
648
+ */
649
+ function previewAndroidDriverFailure(driverResult) {
650
+ const preview = [driverResult.stderr, driverResult.stdout]
651
+ .filter(Boolean)
652
+ .join('\n')
653
+ .replace(/\s+/gu, ' ')
654
+ .trim();
655
+ return preview ? preview.slice(0, 500) : null;
656
+ }
657
+ /**
658
+ * Builds next-action metadata for failed adb driver steps.
659
+ *
660
+ * @param {{driverResult: import('./android-adb-driver').AndroidAdbCommandResult, isReadLogs: boolean}} options
661
+ * @returns {Record<string, string>}
662
+ */
663
+ function buildAndroidDriverFailureMetadata({ driverResult, isReadLogs, }) {
664
+ const failurePreview = previewAndroidDriverFailure(driverResult);
665
+ const diagnostic = `${driverResult.stderr}\n${driverResult.stdout}`;
666
+ const uiAutomationBusy = /uiautomationservice|uiautomator|already registered|\/sdcard\/agent-scenario-loop-ui\.xml|killed/iu
667
+ .test(diagnostic);
668
+ const metadata = uiAutomationBusy
669
+ ? nextActionHint('reset_android_uiautomator', 'Android UIAutomator could not provide a UI tree, likely because another automation session owns the service. Close or reset competing UI automation sessions, then rerun the capture before treating this as an app or selector failure.')
670
+ : nextActionHint(isReadLogs ? 'inspect_android_logcat_capture' : 'inspect_android_driver_action', isReadLogs
671
+ ? `Inspect raw/${driverResult.rawFileName}, confirm adb logcat access for the selected device, and rerun the capture.`
672
+ : `Inspect raw/${driverResult.rawFileName}, confirm the device is interactive and the action metadata is valid, then rerun the capture.`);
673
+ return {
674
+ ...metadata,
675
+ ...(failurePreview ? { failurePreview } : {}),
676
+ };
677
+ }
678
+ /**
679
+ * Runs one normalized adb driver step through the Android driver adapter.
680
+ *
681
+ * @param {{driver: import('./android-adb-driver').AndroidAdbDriver, driverStep: AndroidAdbDriverStep, logcatLines: number}} options
682
+ * @returns {Promise<import('./android-adb-driver').AndroidAdbCommandResult>}
683
+ */
684
+ async function runAndroidAdbDriverStep({ capturesDir, driver, driverStep, logcatLines, }) {
685
+ if (driverStep.driverAction === 'readLogs') {
686
+ return driver.readLogs({
687
+ lines: driverStep.lines ?? logcatLines,
688
+ ...(typeof driverStep.rawFileName === 'string' ? { rawFileName: driverStep.rawFileName } : {}),
689
+ });
690
+ }
691
+ if (driverStep.driverAction === 'inspectTree') {
692
+ return driver.inspectTree({
693
+ ...(typeof driverStep.rawFileName === 'string' ? { rawFileName: driverStep.rawFileName } : {}),
694
+ });
695
+ }
696
+ if (driverStep.driverAction === 'assertVisible') {
697
+ if (!driverStep.selector) {
698
+ return {
699
+ action: 'assertVisible',
700
+ args: [],
701
+ command: 'adb',
702
+ exitCode: 1,
703
+ rawFileName: driverStep.rawFileName ?? 'adb-assert-visible.xml',
704
+ stderr: 'assertVisible driver action requires a selector.',
705
+ stdout: '',
706
+ };
707
+ }
708
+ return driver.assertVisible({
709
+ ...(typeof driverStep.rawFileName === 'string' ? { rawFileName: driverStep.rawFileName } : {}),
710
+ selector: driverStep.selector,
711
+ });
712
+ }
713
+ if (driverStep.driverAction === 'record') {
714
+ const captureFileName = driverStep.captureFileName ?? 'adb-record.mp4';
715
+ return driver.record({
716
+ ...(typeof driverStep.durationSeconds === 'number' ? { durationSeconds: driverStep.durationSeconds } : {}),
717
+ outputPath: path.join(capturesDir, captureFileName),
718
+ ...(typeof driverStep.rawFileName === 'string' ? { rawFileName: driverStep.rawFileName } : {}),
719
+ ...(typeof driverStep.remotePath === 'string' ? { remotePath: driverStep.remotePath } : {}),
720
+ });
721
+ }
722
+ if (driverStep.driverAction === 'screenshot') {
723
+ return driver.screenshot({
724
+ ...(typeof driverStep.rawFileName === 'string' ? { rawFileName: driverStep.rawFileName } : {}),
725
+ });
726
+ }
727
+ if (driverStep.driverAction === 'scroll') {
728
+ if (typeof driverStep.startX !== 'number' ||
729
+ typeof driverStep.startY !== 'number' ||
730
+ typeof driverStep.endX !== 'number' ||
731
+ typeof driverStep.endY !== 'number') {
732
+ return {
733
+ action: 'scroll',
734
+ args: [],
735
+ command: 'adb',
736
+ exitCode: 1,
737
+ rawFileName: driverStep.rawFileName ?? 'adb-scroll.txt',
738
+ stderr: 'scroll driver action requires startX, startY, endX, and endY.',
739
+ stdout: '',
740
+ };
741
+ }
742
+ return driver.scroll({
743
+ ...(typeof driverStep.durationMs === 'number' ? { durationMs: driverStep.durationMs } : {}),
744
+ endX: driverStep.endX,
745
+ endY: driverStep.endY,
746
+ ...(typeof driverStep.rawFileName === 'string' ? { rawFileName: driverStep.rawFileName } : {}),
747
+ startX: driverStep.startX,
748
+ startY: driverStep.startY,
749
+ });
750
+ }
751
+ if (typeof driverStep.x !== 'number' || typeof driverStep.y !== 'number') {
752
+ return {
753
+ action: 'tap',
754
+ args: [],
755
+ command: 'adb',
756
+ exitCode: 1,
757
+ rawFileName: driverStep.rawFileName ?? 'adb-tap.txt',
758
+ stderr: 'tap driver action requires x and y.',
759
+ stdout: '',
760
+ };
761
+ }
762
+ return driver.tap({
763
+ ...(typeof driverStep.rawFileName === 'string' ? { rawFileName: driverStep.rawFileName } : {}),
764
+ x: driverStep.x,
765
+ y: driverStep.y,
766
+ });
767
+ }
768
+ /**
769
+ * Runs Android adb readiness checks and writes the preflight artifact set.
770
+ *
771
+ * @param {AndroidPreflightOptions} options
772
+ * @returns {Promise<AndroidPreflightResult>}
773
+ */
774
+ async function runAndroidAdbPreflight({ adbPath = 'adb', captureLogcat = false, clearLogcat = false, deepLinks = [], delay: wait = delay, driverSteps = [], executor = execFileCommand, launch = false, launchWaitMs = 0, logcatLines = 1000, outputDir = path.resolve('artifacts/android-adb-preflight'), packageName = null, reactNativeDebugHost = null, runId = createRunId(), serial = null, startupDeepLinks = [], storageWrites = [], waitMs = 0, } = {}) {
775
+ const runDir = path.resolve(outputDir);
776
+ const layout = createArtifactLayout({ outputDir: runDir });
777
+ const rawDir = layout.raw;
778
+ await fsp.mkdir(rawDir, { recursive: true });
779
+ const raw = {};
780
+ const checks = [];
781
+ const version = await executor(adbPath, ['version']);
782
+ const adbAvailable = version.exitCode === 0;
783
+ raw['adb-version.txt'] = [version.stdout, version.stderr].filter(Boolean).join('\n');
784
+ checks.push({
785
+ name: 'adb_available',
786
+ status: adbAvailable ? 'passed' : 'failed',
787
+ source: 'runner',
788
+ code: adbAvailable ? 'adb_available' : 'adb_unavailable',
789
+ message: adbAvailable ? 'adb command is available.' : 'adb command could not be executed.',
790
+ ...(!adbAvailable
791
+ ? {
792
+ metadata: nextActionHint('fix_adb_command', 'Install Android platform-tools or pass --adb with a working adb binary, then rerun the capture.'),
793
+ }
794
+ : {}),
795
+ });
796
+ const devicesOutput = adbAvailable
797
+ ? await executor(adbPath, ['devices', '-l'])
798
+ : {
799
+ command: adbPath,
800
+ args: ['devices', '-l'],
801
+ exitCode: 1,
802
+ stderr: 'adb unavailable',
803
+ stdout: '',
804
+ };
805
+ raw['adb-devices.txt'] = [devicesOutput.stdout, devicesOutput.stderr].filter(Boolean).join('\n');
806
+ const devices = parseAdbDevices(devicesOutput.stdout);
807
+ const device = selectDevice(devices, serial);
808
+ const deviceOnline = Boolean(device && device.state === 'device');
809
+ const deviceFailure = !deviceOnline
810
+ ? buildAndroidDeviceFailure({ devicesOutput, serial })
811
+ : null;
812
+ checks.push({
813
+ name: 'android_device_connected',
814
+ status: deviceOnline ? 'passed' : 'failed',
815
+ source: 'runner',
816
+ code: deviceOnline ? 'android_device_connected' : deviceFailure?.code,
817
+ message: deviceOnline && device
818
+ ? `Selected Android device ${device.serial}.`
819
+ : deviceFailure?.message,
820
+ ...(!deviceOnline
821
+ ? {
822
+ metadata: deviceFailure?.metadata,
823
+ }
824
+ : {}),
825
+ });
826
+ const metadata = {
827
+ adbPath,
828
+ captureLogcat,
829
+ clearLogcat,
830
+ deepLinks,
831
+ devices,
832
+ driverSteps,
833
+ launch,
834
+ logcatLines,
835
+ selectedDevice: device,
836
+ packageName,
837
+ reactNativeDebugHost,
838
+ startupDeepLinks,
839
+ storageWrites: storageWrites.map((write) => ({
840
+ clearKeys: write.clearKeys,
841
+ key: write.key,
842
+ label: write.label,
843
+ waitMs: write.waitMs,
844
+ })),
845
+ waitMs,
846
+ };
847
+ const resolvedDriverSteps = resolveAndroidAdbDriverSteps({
848
+ captureLogcat,
849
+ driverSteps,
850
+ logcatLines,
851
+ waitMs,
852
+ });
853
+ if (device && device.state === 'device') {
854
+ const shellPrefix = ['-s', device.serial, 'shell'];
855
+ const driver = createAndroidAdbDriver({
856
+ adbPath,
857
+ deviceSerial: device.serial,
858
+ executor,
859
+ });
860
+ const [model, release, sdk] = await Promise.all([
861
+ executor(adbPath, [...shellPrefix, 'getprop', 'ro.product.model']),
862
+ executor(adbPath, [...shellPrefix, 'getprop', 'ro.build.version.release']),
863
+ executor(adbPath, [...shellPrefix, 'getprop', 'ro.build.version.sdk']),
864
+ ]);
865
+ metadata.deviceProperties = {
866
+ model: model.stdout.trim(),
867
+ release: release.stdout.trim(),
868
+ sdk: sdk.stdout.trim(),
869
+ };
870
+ raw['adb-device-properties.txt'] = [
871
+ `model=${model.stdout.trim()}`,
872
+ `release=${release.stdout.trim()}`,
873
+ `sdk=${sdk.stdout.trim()}`,
874
+ ].join('\n');
875
+ let selectedPackageInstalled = false;
876
+ if (packageName) {
877
+ const packageCheck = await executor(adbPath, [...shellPrefix, 'pm', 'path', packageName]);
878
+ raw['adb-package.txt'] = [packageCheck.stdout, packageCheck.stderr].filter(Boolean).join('\n');
879
+ const packageInstalled = packageCheck.exitCode === 0 && packageCheck.stdout.includes('package:');
880
+ selectedPackageInstalled = packageInstalled;
881
+ checks.push({
882
+ name: 'android_package_installed',
883
+ status: packageInstalled ? 'passed' : 'failed',
884
+ source: 'runner',
885
+ code: packageInstalled
886
+ ? 'android_package_installed'
887
+ : 'android_package_missing',
888
+ message: packageInstalled
889
+ ? `Package ${packageName} is installed.`
890
+ : `Package ${packageName} is not installed on ${device.serial}.`,
891
+ ...(!packageInstalled
892
+ ? {
893
+ metadata: nextActionHint('install_android_package', 'Build and install the app on the selected device, or rerun with --package set to the installed application id.'),
894
+ }
895
+ : {}),
896
+ });
897
+ }
898
+ if (reactNativeDebugHost) {
899
+ const reactNativeDebugPort = parseReactNativeDebugHostPort(reactNativeDebugHost);
900
+ if (!packageName) {
901
+ checks.push({
902
+ name: 'android_react_native_debug_host_configured',
903
+ status: 'failed',
904
+ source: 'runner',
905
+ code: 'android_react_native_debug_host_missing_package',
906
+ message: 'React Native debug host setup was requested, but --package was not provided.',
907
+ metadata: nextActionHint('provide_android_package', 'Rerun with --package set to the installed Android application id when --react-native-debug-host is enabled.'),
908
+ });
909
+ }
910
+ else if (!selectedPackageInstalled) {
911
+ checks.push({
912
+ name: 'android_react_native_debug_host_configured',
913
+ status: 'failed',
914
+ source: 'runner',
915
+ code: 'android_react_native_debug_host_package_missing',
916
+ message: `React Native debug host setup requires installed package ${packageName}.`,
917
+ metadata: nextActionHint('install_android_package', 'Build and install the app on the selected device before configuring the React Native debug host.'),
918
+ });
919
+ }
920
+ else if (!reactNativeDebugPort) {
921
+ checks.push({
922
+ name: 'android_react_native_debug_host_configured',
923
+ status: 'failed',
924
+ source: 'runner',
925
+ code: 'android_react_native_debug_host_invalid',
926
+ message: `React Native debug host ${reactNativeDebugHost} must be a host:port value without a URL scheme.`,
927
+ metadata: nextActionHint('fix_react_native_debug_host', 'Pass a React Native debug host such as localhost:8097, not a full http:// URL.'),
928
+ });
929
+ }
930
+ else {
931
+ const reverseResult = await executor(adbPath, [
932
+ '-s',
933
+ device.serial,
934
+ 'reverse',
935
+ `tcp:${reactNativeDebugPort}`,
936
+ `tcp:${reactNativeDebugPort}`,
937
+ ]);
938
+ const preferenceCommand = buildReactNativeDebugHostPreferenceCommand({
939
+ debugHost: reactNativeDebugHost,
940
+ packageName,
941
+ });
942
+ const preferenceResult = await executor(adbPath, [
943
+ '-s',
944
+ device.serial,
945
+ 'shell',
946
+ 'run-as',
947
+ packageName,
948
+ 'sh',
949
+ '-c',
950
+ quoteAndroidShellArg(preferenceCommand),
951
+ ]);
952
+ const reversePassed = reverseResult.exitCode === 0;
953
+ const preferencePassed = preferenceResult.exitCode === 0;
954
+ raw['adb-react-native-reverse.txt'] = formatAndroidCommandRawOutput(reverseResult);
955
+ raw['adb-react-native-debug-host.txt'] = formatAndroidCommandRawOutput(preferenceResult);
956
+ checks.push({
957
+ name: 'android_react_native_reverse_configured',
958
+ status: reversePassed ? 'passed' : 'failed',
959
+ source: 'runner',
960
+ code: reversePassed
961
+ ? 'android_react_native_reverse_configured'
962
+ : 'android_react_native_reverse_failed',
963
+ message: reversePassed
964
+ ? `Configured adb reverse for React Native debug port ${reactNativeDebugPort}.`
965
+ : `Failed to configure adb reverse for React Native debug port ${reactNativeDebugPort}.`,
966
+ ...(!reversePassed
967
+ ? {
968
+ metadata: nextActionHint('inspect_android_react_native_reverse', 'Inspect raw/adb-react-native-reverse.txt, confirm the selected device supports adb reverse, then rerun the capture.'),
969
+ }
970
+ : {}),
971
+ });
972
+ checks.push({
973
+ name: 'android_react_native_debug_host_configured',
974
+ status: preferencePassed ? 'passed' : 'failed',
975
+ source: 'runner',
976
+ code: preferencePassed
977
+ ? 'android_react_native_debug_host_configured'
978
+ : 'android_react_native_debug_host_failed',
979
+ message: preferencePassed
980
+ ? `Configured React Native debug host ${reactNativeDebugHost} for ${packageName}.`
981
+ : `Failed to configure React Native debug host ${reactNativeDebugHost} for ${packageName}.`,
982
+ ...(!preferencePassed
983
+ ? {
984
+ metadata: nextActionHint('inspect_android_react_native_debug_host', 'Inspect raw/adb-react-native-debug-host.txt, confirm the app is debuggable and run-as works for the package, then rerun the capture.'),
985
+ }
986
+ : {}),
987
+ });
988
+ metadata.reactNativeDebugHostSetup = {
989
+ debugHost: reactNativeDebugHost,
990
+ port: reactNativeDebugPort,
991
+ preferenceRawPath: 'raw/adb-react-native-debug-host.txt',
992
+ reverseRawPath: 'raw/adb-react-native-reverse.txt',
993
+ };
994
+ }
995
+ }
996
+ if (clearLogcat) {
997
+ const clear = await driver.clearLogs();
998
+ const logcatCleared = clear.exitCode === 0;
999
+ raw[clear.rawFileName] = formatAndroidAdbRawOutput(clear);
1000
+ checks.push({
1001
+ name: 'android_logcat_cleared',
1002
+ status: logcatCleared ? 'passed' : 'failed',
1003
+ source: 'runner',
1004
+ code: logcatCleared ? 'android_logcat_cleared' : 'android_logcat_clear_failed',
1005
+ message: logcatCleared ? 'Cleared adb logcat before capture.' : 'adb logcat clear failed.',
1006
+ ...(!logcatCleared
1007
+ ? {
1008
+ metadata: nextActionHint('inspect_adb_logcat_clear', `Inspect raw/${clear.rawFileName}, confirm the selected device allows logcat access, then rerun the capture.`),
1009
+ }
1010
+ : {}),
1011
+ });
1012
+ metadata.logcatClear = {
1013
+ args: clear.args,
1014
+ exitCode: clear.exitCode,
1015
+ rawPath: `raw/${clear.rawFileName}`,
1016
+ };
1017
+ }
1018
+ const appLifecycleMetadata = {};
1019
+ let lifecyclePackageName = null;
1020
+ let knownLifecyclePids = [];
1021
+ if (launch) {
1022
+ if (!packageName) {
1023
+ checks.push({
1024
+ name: 'android_package_launched',
1025
+ status: 'failed',
1026
+ source: 'runner',
1027
+ code: 'android_launch_missing_package',
1028
+ message: 'Package launch was requested, but --package was not provided.',
1029
+ metadata: nextActionHint('provide_android_package', 'Rerun with --package set to the installed Android application id when --launch is enabled.'),
1030
+ });
1031
+ }
1032
+ else {
1033
+ const launchResult = await driver.launchPackage(packageName);
1034
+ const launchPassed = launchResult.exitCode === 0;
1035
+ raw[launchResult.rawFileName] = formatAndroidAdbRawOutput(launchResult);
1036
+ checks.push({
1037
+ name: 'android_package_launched',
1038
+ status: launchPassed ? 'passed' : 'failed',
1039
+ source: 'runner',
1040
+ code: launchPassed ? 'android_package_launched' : 'android_package_launch_failed',
1041
+ message: launchPassed
1042
+ ? `Launched package ${packageName}.`
1043
+ : `Failed to launch package ${packageName}.`,
1044
+ ...(!launchPassed
1045
+ ? {
1046
+ metadata: nextActionHint('inspect_android_launch', `Inspect raw/${launchResult.rawFileName}, verify the package has a launcher activity, and confirm the app can open manually on the device.`),
1047
+ }
1048
+ : {}),
1049
+ });
1050
+ metadata.launchResult = {
1051
+ args: launchResult.args,
1052
+ exitCode: launchResult.exitCode,
1053
+ rawPath: `raw/${launchResult.rawFileName}`,
1054
+ };
1055
+ if (launchPassed && launchWaitMs > 0) {
1056
+ await wait(launchWaitMs);
1057
+ checks.push({
1058
+ name: 'android_launch_waited',
1059
+ status: 'passed',
1060
+ source: 'runner',
1061
+ code: 'android_launch_waited',
1062
+ message: `Waited ${launchWaitMs}ms after Android package launch.`,
1063
+ });
1064
+ metadata.launchWaitMs = launchWaitMs;
1065
+ }
1066
+ if (launchPassed) {
1067
+ lifecyclePackageName = packageName;
1068
+ const pidofAfterLaunch = await executor(adbPath, [
1069
+ '-s',
1070
+ device.serial,
1071
+ 'shell',
1072
+ 'pidof',
1073
+ packageName,
1074
+ ]);
1075
+ const rawPath = 'raw/adb-app-pidof-after-launch.txt';
1076
+ raw['adb-app-pidof-after-launch.txt'] = formatAndroidCommandRawOutput(pidofAfterLaunch);
1077
+ const afterLaunchPids = parseAndroidPidofOutput(pidofAfterLaunch.stdout);
1078
+ knownLifecyclePids = afterLaunchPids;
1079
+ const runningAfterLaunch = pidofAfterLaunch.exitCode === 0 && afterLaunchPids.length > 0;
1080
+ checks.push({
1081
+ name: 'android_app_process_running_after_launch',
1082
+ status: runningAfterLaunch ? 'passed' : 'failed',
1083
+ source: 'runner',
1084
+ code: runningAfterLaunch
1085
+ ? 'android_app_process_running_after_launch'
1086
+ : 'android_app_not_running_after_launch',
1087
+ message: runningAfterLaunch
1088
+ ? `Package ${packageName} is running after launch with PID ${afterLaunchPids.join(', ')}.`
1089
+ : `Package ${packageName} is not running after launch.`,
1090
+ ...(!runningAfterLaunch
1091
+ ? {
1092
+ metadata: nextActionHint('inspect_android_app_launch', `Inspect ${rawPath} and the app's device logs to find why the launched process exited before evidence capture.`),
1093
+ }
1094
+ : {}),
1095
+ });
1096
+ Object.assign(appLifecycleMetadata, {
1097
+ afterLaunchPids,
1098
+ afterLaunchRawPath: rawPath,
1099
+ });
1100
+ }
1101
+ }
1102
+ }
1103
+ for (const [index, deepLink] of startupDeepLinks.entries()) {
1104
+ const rawFileName = `adb-startup-deep-link-${index + 1}.txt`;
1105
+ const deepLinkResult = await driver.openDeepLink({
1106
+ packageName,
1107
+ rawFileName,
1108
+ url: deepLink.url,
1109
+ });
1110
+ const deepLinkOpened = deepLinkResult.exitCode === 0;
1111
+ raw[deepLinkResult.rawFileName] = formatAndroidAdbRawOutput(deepLinkResult);
1112
+ checks.push({
1113
+ name: 'android_startup_deep_link_opened',
1114
+ status: deepLinkOpened ? 'passed' : 'failed',
1115
+ source: 'runner',
1116
+ code: deepLinkOpened ? 'android_startup_deep_link_opened' : 'android_startup_deep_link_failed',
1117
+ message: deepLinkOpened
1118
+ ? `Opened Android startup deep link ${deepLink.label ?? index + 1}.`
1119
+ : `Failed to open Android startup deep link ${deepLink.label ?? index + 1}.`,
1120
+ ...(!deepLinkOpened
1121
+ ? {
1122
+ metadata: nextActionHint('inspect_android_startup_deep_link', `Inspect raw/${deepLinkResult.rawFileName}, verify the dev-client URL and package intent filter, then rerun the capture.`),
1123
+ }
1124
+ : {}),
1125
+ });
1126
+ if (deepLink.waitMs && deepLink.waitMs > 0) {
1127
+ await wait(deepLink.waitMs);
1128
+ checks.push({
1129
+ name: 'android_startup_deep_link_waited',
1130
+ status: 'passed',
1131
+ source: 'runner',
1132
+ code: 'android_startup_deep_link_waited',
1133
+ message: `Waited ${deepLink.waitMs}ms after Android startup deep link ${deepLink.label ?? index + 1}.`,
1134
+ });
1135
+ }
1136
+ if (deepLink.readyLogPattern) {
1137
+ const rawFileName = `adb-startup-deep-link-${index + 1}-ready-log.txt`;
1138
+ const readyLog = await waitForAndroidReadyLog({
1139
+ driver,
1140
+ logcatLines,
1141
+ pattern: deepLink.readyLogPattern,
1142
+ quietMs: deepLink.readyLogQuietMs ?? 0,
1143
+ rawFileName,
1144
+ timeoutMs: deepLink.readyLogTimeoutMs ?? 60000,
1145
+ wait,
1146
+ });
1147
+ raw[rawFileName] = formatAndroidAdbRawOutput(readyLog.result);
1148
+ checks.push({
1149
+ name: 'android_startup_deep_link_ready',
1150
+ status: readyLog.ready ? 'passed' : 'failed',
1151
+ source: 'runner',
1152
+ code: readyLog.ready
1153
+ ? 'android_startup_deep_link_ready'
1154
+ : 'android_startup_deep_link_not_ready',
1155
+ message: readyLog.ready
1156
+ ? `Android startup deep link ${deepLink.label ?? index + 1} emitted readiness log evidence.`
1157
+ : `Android startup deep link ${deepLink.label ?? index + 1} did not emit readiness log evidence before timeout.`,
1158
+ ...(!readyLog.ready
1159
+ ? {
1160
+ metadata: nextActionHint('inspect_android_startup_deep_link_ready_log', `Inspect raw/${rawFileName}, confirm the dev-client loaded the app bundle, and increase the ready timeout only if the app is still making progress.`),
1161
+ }
1162
+ : {}),
1163
+ });
1164
+ }
1165
+ }
1166
+ for (const [index, write] of storageWrites.entries()) {
1167
+ const rawFileName = `adb-async-storage-write-${index + 1}.txt`;
1168
+ if (!packageName) {
1169
+ checks.push({
1170
+ name: 'android_async_storage_written',
1171
+ status: 'failed',
1172
+ source: 'runner',
1173
+ code: 'android_async_storage_missing_package',
1174
+ message: 'Android AsyncStorage write was requested, but --package was not provided.',
1175
+ metadata: nextActionHint('provide_android_package', 'Rerun with --package set to the installed Android application id when Android AsyncStorage profile-session storage is enabled.'),
1176
+ });
1177
+ continue;
1178
+ }
1179
+ let resolvedWrite = write;
1180
+ if (write.value.includes(ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER)) {
1181
+ const deviceEpoch = await readAndroidDeviceEpochMs({
1182
+ adbPath,
1183
+ deviceSerial: device.serial,
1184
+ executor,
1185
+ });
1186
+ raw['adb-device-epoch-ms.txt'] = formatAndroidCommandRawOutput(deviceEpoch);
1187
+ const deviceClockRead = typeof deviceEpoch.epochMs === 'number';
1188
+ checks.push({
1189
+ name: 'android_device_clock_read',
1190
+ status: deviceClockRead ? 'passed' : 'failed',
1191
+ source: 'runner',
1192
+ code: deviceClockRead ? 'android_device_clock_read' : 'android_device_clock_unavailable',
1193
+ message: deviceClockRead
1194
+ ? `Read Android device clock as ${deviceEpoch.epochMs}ms since epoch.`
1195
+ : 'Failed to read Android device clock for AsyncStorage timing evidence.',
1196
+ ...(!deviceClockRead
1197
+ ? {
1198
+ metadata: nextActionHint('inspect_android_device_clock', 'Inspect raw/adb-device-epoch-ms.txt and confirm the selected Android device supports `adb shell date +%s` before using storage-backed timing evidence.'),
1199
+ }
1200
+ : {}),
1201
+ });
1202
+ if (!deviceClockRead || deviceEpoch.epochMs === null) {
1203
+ continue;
1204
+ }
1205
+ resolvedWrite = {
1206
+ ...write,
1207
+ value: write.value.replaceAll(ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER, String(deviceEpoch.epochMs)),
1208
+ };
1209
+ }
1210
+ const writeResult = await executor(adbPath, [
1211
+ '-s',
1212
+ device.serial,
1213
+ 'shell',
1214
+ buildAndroidAsyncStorageWriteCommand({ packageName, write: resolvedWrite }),
1215
+ ]);
1216
+ const writePassed = writeResult.exitCode === 0;
1217
+ raw[rawFileName] = formatAndroidCommandRawOutput(writeResult);
1218
+ checks.push({
1219
+ name: 'android_async_storage_written',
1220
+ status: writePassed ? 'passed' : 'failed',
1221
+ source: 'runner',
1222
+ code: writePassed ? 'android_async_storage_written' : 'android_async_storage_write_failed',
1223
+ message: writePassed
1224
+ ? `Wrote Android AsyncStorage value ${write.label ?? index + 1}.`
1225
+ : `Failed to write Android AsyncStorage value ${write.label ?? index + 1}.`,
1226
+ ...(!writePassed
1227
+ ? {
1228
+ metadata: nextActionHint('inspect_android_async_storage_write', `Inspect raw/${rawFileName}, confirm the app is debuggable and sqlite3 can access RKStorage through run-as, then rerun the capture.`),
1229
+ }
1230
+ : {}),
1231
+ });
1232
+ if (write.waitMs && write.waitMs > 0) {
1233
+ await wait(write.waitMs);
1234
+ checks.push({
1235
+ name: 'android_async_storage_waited',
1236
+ status: 'passed',
1237
+ source: 'runner',
1238
+ code: 'android_async_storage_waited',
1239
+ message: `Waited ${write.waitMs}ms after Android AsyncStorage write ${write.label ?? index + 1}.`,
1240
+ });
1241
+ }
1242
+ }
1243
+ for (const [index, deepLink] of deepLinks.entries()) {
1244
+ const rawFileName = `adb-deep-link-${index + 1}.txt`;
1245
+ const deepLinkResult = await driver.openDeepLink({
1246
+ packageName,
1247
+ rawFileName,
1248
+ url: deepLink.url,
1249
+ });
1250
+ const deepLinkOpened = deepLinkResult.exitCode === 0;
1251
+ raw[deepLinkResult.rawFileName] = formatAndroidAdbRawOutput(deepLinkResult);
1252
+ checks.push({
1253
+ name: 'android_deep_link_opened',
1254
+ status: deepLinkOpened ? 'passed' : 'failed',
1255
+ source: 'runner',
1256
+ code: deepLinkOpened ? 'android_deep_link_opened' : 'android_deep_link_failed',
1257
+ message: deepLinkOpened
1258
+ ? `Opened Android deep link ${deepLink.label ?? index + 1}.`
1259
+ : `Failed to open Android deep link ${deepLink.label ?? index + 1}.`,
1260
+ ...(!deepLinkOpened
1261
+ ? {
1262
+ metadata: nextActionHint('inspect_android_deep_link', `Inspect raw/${deepLinkResult.rawFileName}, verify the app scheme/intent filter, and rerun with --package if the intent must target one app.`),
1263
+ }
1264
+ : {}),
1265
+ });
1266
+ if (deepLink.waitMs && deepLink.waitMs > 0) {
1267
+ await wait(deepLink.waitMs);
1268
+ checks.push({
1269
+ name: 'android_deep_link_waited',
1270
+ status: 'passed',
1271
+ source: 'runner',
1272
+ code: 'android_deep_link_waited',
1273
+ message: `Waited ${deepLink.waitMs}ms after Android deep link ${deepLink.label ?? index + 1}.`,
1274
+ });
1275
+ }
1276
+ if (deepLinkOpened && packageName && !lifecyclePackageName) {
1277
+ lifecyclePackageName = packageName;
1278
+ const pidofAfterDeepLink = await executor(adbPath, [
1279
+ '-s',
1280
+ device.serial,
1281
+ 'shell',
1282
+ 'pidof',
1283
+ packageName,
1284
+ ]);
1285
+ const rawPath = 'raw/adb-app-pidof-after-deep-link.txt';
1286
+ raw['adb-app-pidof-after-deep-link.txt'] = formatAndroidCommandRawOutput(pidofAfterDeepLink);
1287
+ const afterDeepLinkPids = parseAndroidPidofOutput(pidofAfterDeepLink.stdout);
1288
+ knownLifecyclePids = afterDeepLinkPids;
1289
+ const runningAfterDeepLink = pidofAfterDeepLink.exitCode === 0 && afterDeepLinkPids.length > 0;
1290
+ checks.push({
1291
+ name: 'android_app_process_running_after_deep_link',
1292
+ status: runningAfterDeepLink ? 'passed' : 'failed',
1293
+ source: 'runner',
1294
+ code: runningAfterDeepLink
1295
+ ? 'android_app_process_running_after_deep_link'
1296
+ : 'android_app_not_running_after_deep_link',
1297
+ message: runningAfterDeepLink
1298
+ ? `Package ${packageName} is running after deep link with PID ${afterDeepLinkPids.join(', ')}.`
1299
+ : `Package ${packageName} is not running after opening the deep link.`,
1300
+ ...(!runningAfterDeepLink
1301
+ ? {
1302
+ metadata: nextActionHint('inspect_android_deep_link_launch', `Inspect ${rawPath} and the app's device logs to find why the package-targeted deep link did not leave the app process running.`),
1303
+ }
1304
+ : {}),
1305
+ });
1306
+ Object.assign(appLifecycleMetadata, {
1307
+ afterDeepLinkPids,
1308
+ afterDeepLinkRawPath: rawPath,
1309
+ });
1310
+ }
1311
+ }
1312
+ const driverActionMetadata = [];
1313
+ const logcatMetadata = [];
1314
+ const selectorResolutionMetadata = [];
1315
+ for (const [index, driverStep] of resolvedDriverSteps.entries()) {
1316
+ if (driverStep.waitMs && driverStep.waitMs > 0) {
1317
+ await wait(driverStep.waitMs);
1318
+ checks.push({
1319
+ name: 'android_capture_window_waited',
1320
+ status: 'passed',
1321
+ source: 'runner',
1322
+ code: 'android_capture_window_waited',
1323
+ message: `Waited ${driverStep.waitMs}ms before running adb driver action ${driverStep.driverAction}.`,
1324
+ metadata: {
1325
+ driverAction: driverStep.driverAction,
1326
+ ...(driverStep.stepId ? { stepId: driverStep.stepId } : {}),
1327
+ },
1328
+ });
1329
+ }
1330
+ let executableDriverStep = driverStep;
1331
+ if (needsAndroidSelectorResolution(driverStep)) {
1332
+ const selectorRawFileName = `adb-selector-tree-${index + 1}.xml`;
1333
+ const treeResult = await driver.inspectTree({ rawFileName: selectorRawFileName });
1334
+ raw[treeResult.rawFileName] = formatAndroidAdbRawOutput(treeResult);
1335
+ const resolution = treeResult.exitCode === 0 && driverStep.selector
1336
+ ? resolveAndroidSelectorFromUiTree({
1337
+ selector: driverStep.selector,
1338
+ uiTreeXml: treeResult.stdout,
1339
+ })
1340
+ : null;
1341
+ const resolved = Boolean(resolution);
1342
+ if (resolution) {
1343
+ executableDriverStep = applyAndroidSelectorResolution({
1344
+ driverStep,
1345
+ resolution,
1346
+ });
1347
+ }
1348
+ checks.push({
1349
+ name: 'android_selector_resolved',
1350
+ status: resolved ? 'passed' : driverStep.required === false ? 'warning' : 'failed',
1351
+ source: 'runner',
1352
+ code: resolved ? 'android_selector_resolved' : 'android_selector_resolution_failed',
1353
+ message: resolved
1354
+ ? `Resolved Android selector for adb driver action ${driverStep.driverAction}.`
1355
+ : `Failed to resolve Android selector for adb driver action ${driverStep.driverAction}.`,
1356
+ metadata: {
1357
+ driverAction: driverStep.driverAction,
1358
+ ...buildAndroidSelectorHealthMetadata(driverStep.selector),
1359
+ ...(!resolved
1360
+ ? nextActionHint('fix_android_selector', `Inspect raw/${treeResult.rawFileName}, update the scenario selector, or provide explicit adb coordinates for this driver action.`)
1361
+ : {}),
1362
+ ...(driverStep.stepId ? { stepId: driverStep.stepId } : {}),
1363
+ },
1364
+ });
1365
+ selectorResolutionMetadata.push({
1366
+ ...(resolution ? { bounds: resolution.bounds } : {}),
1367
+ driverAction: driverStep.driverAction,
1368
+ rawPath: `raw/${treeResult.rawFileName}`,
1369
+ ...(driverStep.selector ? { selector: driverStep.selector } : {}),
1370
+ status: resolved ? 'passed' : 'failed',
1371
+ ...(driverStep.stepId ? { stepId: driverStep.stepId } : {}),
1372
+ });
1373
+ }
1374
+ const driverResult = await runAndroidAdbDriverStep({
1375
+ capturesDir: layout.captures,
1376
+ driver,
1377
+ driverStep: executableDriverStep,
1378
+ logcatLines,
1379
+ });
1380
+ raw[driverResult.rawFileName] = formatAndroidAdbRawOutput(driverResult);
1381
+ const failed = driverResult.exitCode !== 0;
1382
+ const codeSuffix = androidDriverActionCode(driverStep.driverAction);
1383
+ const isReadLogs = driverStep.driverAction === 'readLogs';
1384
+ checks.push({
1385
+ name: isReadLogs ? 'android_logcat_captured' : `android_${codeSuffix}`,
1386
+ status: failed && driverStep.required === false ? 'warning' : failed ? 'failed' : 'passed',
1387
+ source: 'runner',
1388
+ code: isReadLogs
1389
+ ? driverResult.exitCode === 0 ? 'android_logcat_captured' : 'android_logcat_failed'
1390
+ : driverResult.exitCode === 0 ? `android_${codeSuffix}_completed` : `android_${codeSuffix}_failed`,
1391
+ message: isReadLogs
1392
+ ? driverResult.exitCode === 0
1393
+ ? `Captured the last ${driverStep.lines ?? logcatLines} adb logcat lines.`
1394
+ : 'adb logcat capture failed.'
1395
+ : driverResult.exitCode === 0
1396
+ ? `Completed adb driver action ${driverStep.driverAction}.`
1397
+ : `adb driver action ${driverStep.driverAction} failed.`,
1398
+ metadata: {
1399
+ driverAction: executableDriverStep.driverAction,
1400
+ ...buildAndroidSelectorHealthMetadata(executableDriverStep.selector),
1401
+ ...(failed ? buildAndroidDriverFailureMetadata({ driverResult, isReadLogs }) : {}),
1402
+ ...(executableDriverStep.stepId ? { stepId: executableDriverStep.stepId } : {}),
1403
+ },
1404
+ });
1405
+ const actionMetadata = {
1406
+ args: driverResult.args,
1407
+ driverAction: executableDriverStep.driverAction,
1408
+ exitCode: driverResult.exitCode,
1409
+ ...(driverResult.capturePath
1410
+ ? { capturePath: `captures/${path.basename(driverResult.capturePath)}` }
1411
+ : {}),
1412
+ rawPath: `raw/${driverResult.rawFileName}`,
1413
+ ...(executableDriverStep.selector ? { selector: executableDriverStep.selector } : {}),
1414
+ ...(executableDriverStep.stepId ? { stepId: executableDriverStep.stepId } : {}),
1415
+ };
1416
+ driverActionMetadata.push(actionMetadata);
1417
+ if (executableDriverStep.driverAction === 'readLogs') {
1418
+ logcatMetadata.push(actionMetadata);
1419
+ }
1420
+ }
1421
+ if (selectorResolutionMetadata.length > 0) {
1422
+ metadata.selectorResolutions = selectorResolutionMetadata;
1423
+ }
1424
+ if (driverActionMetadata.length > 0) {
1425
+ metadata.driverActions = driverActionMetadata;
1426
+ }
1427
+ if (logcatMetadata.length === 1) {
1428
+ metadata.logcat = logcatMetadata[0];
1429
+ }
1430
+ else if (logcatMetadata.length > 1) {
1431
+ metadata.logcat = logcatMetadata;
1432
+ }
1433
+ if (lifecyclePackageName) {
1434
+ const pidofAfterCapture = await executor(adbPath, [
1435
+ '-s',
1436
+ device.serial,
1437
+ 'shell',
1438
+ 'pidof',
1439
+ lifecyclePackageName,
1440
+ ]);
1441
+ const pidofAfterCaptureRawPath = 'raw/adb-app-pidof-after-capture.txt';
1442
+ raw['adb-app-pidof-after-capture.txt'] = formatAndroidCommandRawOutput(pidofAfterCapture);
1443
+ const afterCapturePids = parseAndroidPidofOutput(pidofAfterCapture.stdout);
1444
+ const lifecycleLogLines = Math.max(logcatLines, 200);
1445
+ const lifecycleLog = await driver.readLogs({
1446
+ lines: lifecycleLogLines,
1447
+ rawFileName: 'adb-app-lifecycle-log.txt',
1448
+ });
1449
+ raw[lifecycleLog.rawFileName] = formatAndroidAdbRawOutput(lifecycleLog);
1450
+ const allKnownPids = Array.from(new Set([...knownLifecyclePids, ...afterCapturePids]));
1451
+ const scan = lifecycleLog.exitCode === 0
1452
+ ? scanAndroidAppLifecycleLog({
1453
+ logText: `${lifecycleLog.stdout}\n${lifecycleLog.stderr}`,
1454
+ packageName: lifecyclePackageName,
1455
+ pids: allKnownPids,
1456
+ })
1457
+ : { crashed: false, evidence: [] };
1458
+ const runningAfterCapture = pidofAfterCapture.exitCode === 0 && afterCapturePids.length > 0;
1459
+ const lifecycleStatus = !runningAfterCapture || scan.crashed
1460
+ ? 'failed'
1461
+ : lifecycleLog.exitCode === 0
1462
+ ? 'passed'
1463
+ : 'warning';
1464
+ checks.push({
1465
+ name: 'android_app_lifecycle_stable',
1466
+ status: lifecycleStatus,
1467
+ source: 'runner',
1468
+ code: !runningAfterCapture
1469
+ ? 'android_app_exited_during_capture'
1470
+ : scan.crashed
1471
+ ? 'android_app_crashed_during_capture'
1472
+ : lifecycleLog.exitCode === 0
1473
+ ? 'android_app_lifecycle_stable'
1474
+ : 'android_app_lifecycle_log_unavailable',
1475
+ message: !runningAfterCapture
1476
+ ? `Package ${lifecyclePackageName} was not running after evidence capture.`
1477
+ : scan.crashed
1478
+ ? `Package ${lifecyclePackageName} emitted crash evidence during capture.`
1479
+ : lifecycleLog.exitCode === 0
1480
+ ? `Package ${lifecyclePackageName} remained running with no crash evidence in the bounded log window.`
1481
+ : `Could not read Android lifecycle logs for package ${lifecyclePackageName}.`,
1482
+ ...(!runningAfterCapture || scan.crashed
1483
+ ? {
1484
+ metadata: nextActionHint('inspect_android_app_crash', `Inspect raw/${lifecycleLog.rawFileName} and ${pidofAfterCaptureRawPath}; scenario timing evidence is not trustworthy until the app stays alive.`),
1485
+ }
1486
+ : lifecycleLog.exitCode !== 0
1487
+ ? {
1488
+ metadata: nextActionHint('inspect_android_lifecycle_log', `Inspect raw/${lifecycleLog.rawFileName}; lifecycle log capture failed but the app process was still running.`),
1489
+ }
1490
+ : {}),
1491
+ });
1492
+ metadata.appLifecycle = {
1493
+ ...appLifecycleMetadata,
1494
+ afterCapturePids,
1495
+ afterCaptureRawPath: pidofAfterCaptureRawPath,
1496
+ crashEvidence: scan.evidence,
1497
+ lifecycleLogLines,
1498
+ lifecycleLogRawPath: `raw/${lifecycleLog.rawFileName}`,
1499
+ packageName: lifecyclePackageName,
1500
+ };
1501
+ }
1502
+ }
1503
+ else {
1504
+ if (clearLogcat || launch || startupDeepLinks.length > 0 || storageWrites.length > 0) {
1505
+ checks.push({
1506
+ name: 'android_capture_window_started',
1507
+ status: 'failed',
1508
+ source: 'runner',
1509
+ code: 'android_capture_window_no_device',
1510
+ message: 'Android capture window setup was requested, but no online Android device was selected.',
1511
+ metadata: nextActionHint('select_android_device', 'Start or unlock an Android emulator/device, confirm it appears as `device` in adb devices -l, then rerun the capture.'),
1512
+ });
1513
+ }
1514
+ if (resolvedDriverSteps.some((step) => step.driverAction === 'readLogs')) {
1515
+ checks.push({
1516
+ name: 'android_logcat_captured',
1517
+ status: 'failed',
1518
+ source: 'runner',
1519
+ code: 'android_logcat_no_device',
1520
+ message: 'adb logcat capture was requested, but no online Android device was selected.',
1521
+ metadata: nextActionHint('select_android_device', 'Start or unlock an Android emulator/device before requesting logcat capture.'),
1522
+ });
1523
+ }
1524
+ if (resolvedDriverSteps.some((step) => step.driverAction !== 'readLogs')) {
1525
+ checks.push({
1526
+ name: 'android_driver_actions_completed',
1527
+ status: 'failed',
1528
+ source: 'runner',
1529
+ code: 'android_driver_actions_no_device',
1530
+ message: 'adb driver actions were requested, but no online Android device was selected.',
1531
+ metadata: nextActionHint('select_android_device', 'Start or unlock an Android emulator/device before running adb driver actions.'),
1532
+ });
1533
+ }
1534
+ }
1535
+ const health = buildAndroidHealth({ runId, checks });
1536
+ const verdict = buildAndroidVerdict({ runId, health });
1537
+ const agentSummary = buildAgentSummaryMarkdown({ health, verdict });
1538
+ await Promise.all(Object.entries(raw).map(([fileName, content]) => fsp.writeFile(path.join(rawDir, fileName), `${content.trimEnd()}\n`, 'utf8')));
1539
+ await fsp.writeFile(path.join(rawDir, 'android-metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
1540
+ await writeJsonArtifact({
1541
+ filePath: layout.health,
1542
+ value: health,
1543
+ schema: SCHEMAS.health,
1544
+ label: 'Health artifact',
1545
+ });
1546
+ await writeJsonArtifact({
1547
+ filePath: layout.verdict,
1548
+ value: verdict,
1549
+ schema: SCHEMAS.verdict,
1550
+ label: 'Verdict artifact',
1551
+ });
1552
+ await writeTextArtifact({
1553
+ filePath: layout.agentSummary,
1554
+ content: agentSummary,
1555
+ });
1556
+ return {
1557
+ agentSummary,
1558
+ device,
1559
+ health,
1560
+ metadata,
1561
+ raw,
1562
+ runDir,
1563
+ verdict,
1564
+ };
1565
+ }
1566
+ /**
1567
+ * Runs the android-adb preflight CLI.
1568
+ *
1569
+ * @returns {Promise<void>}
1570
+ */
1571
+ async function main() {
1572
+ const argv = process.argv.slice(2);
1573
+ if (hasHelpFlag(argv)) {
1574
+ usage(process.stdout);
1575
+ return;
1576
+ }
1577
+ const args = parseArgs(argv);
1578
+ const result = await runAndroidAdbPreflight({
1579
+ ...(typeof args.adb === 'string' ? { adbPath: args.adb } : {}),
1580
+ captureLogcat: args['capture-logcat'] === true || args['capture-logcat'] === 'true',
1581
+ clearLogcat: args['clear-logcat'] === true || args['clear-logcat'] === 'true',
1582
+ launch: args.launch === true || args.launch === 'true',
1583
+ launchWaitMs: parsePositiveInteger(args['launch-wait-ms'], 0),
1584
+ logcatLines: parsePositiveInteger(args['logcat-lines'], 1000),
1585
+ ...(typeof args.out === 'string' ? { outputDir: args.out } : {}),
1586
+ ...(typeof args.package === 'string' ? { packageName: args.package } : {}),
1587
+ ...(typeof args['react-native-debug-host'] === 'string'
1588
+ ? { reactNativeDebugHost: args['react-native-debug-host'] }
1589
+ : {}),
1590
+ ...(typeof args['run-id'] === 'string' ? { runId: args['run-id'] } : {}),
1591
+ ...(typeof args.serial === 'string' ? { serial: args.serial } : {}),
1592
+ ...(typeof args['android-dev-client-url'] === 'string'
1593
+ ? {
1594
+ startupDeepLinks: [{
1595
+ label: 'android-dev-client-url',
1596
+ ...(typeof args['android-dev-client-ready-pattern'] === 'string'
1597
+ ? { readyLogPattern: args['android-dev-client-ready-pattern'] }
1598
+ : {}),
1599
+ readyLogQuietMs: parsePositiveInteger(args['android-dev-client-ready-quiet-ms'], 0),
1600
+ readyLogTimeoutMs: parsePositiveInteger(args['android-dev-client-ready-timeout-ms'], 60000),
1601
+ url: args['android-dev-client-url'],
1602
+ waitMs: parsePositiveInteger(args['android-dev-client-wait-ms'], 1000),
1603
+ }],
1604
+ }
1605
+ : {}),
1606
+ waitMs: parsePositiveInteger(args['wait-ms'], 0),
1607
+ });
1608
+ process.stdout.write(`${result.runDir}\n`);
1609
+ if (result.health.healthStatus !== 'passed') {
1610
+ process.exitCode = 1;
1611
+ }
1612
+ }
1613
+ if (require.main === module) {
1614
+ main().catch((error) => {
1615
+ console.error(error instanceof Error ? error.message : String(error));
1616
+ process.exitCode = 1;
1617
+ });
1618
+ }