agent-scenario-loop 0.1.2 → 0.1.4

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 (87) hide show
  1. package/README.md +9 -9
  2. package/app/profile-session.ts +352 -12
  3. package/dist/core/agent-summary.d.ts +3 -2
  4. package/dist/core/agent-summary.js +44 -2
  5. package/dist/core/artifact-contract.d.ts +28 -8
  6. package/dist/core/artifact-contract.js +676 -26
  7. package/dist/core/comparison.d.ts +57 -3
  8. package/dist/core/comparison.js +113 -1
  9. package/dist/core/planner.d.ts +32 -1
  10. package/dist/core/planner.js +144 -0
  11. package/dist/core/run-index.d.ts +4 -0
  12. package/dist/core/run-index.js +55 -1
  13. package/dist/core/schema-validator.d.ts +2 -0
  14. package/dist/core/schema-validator.js +2 -0
  15. package/dist/runner/android-adb-driver.d.ts +7 -2
  16. package/dist/runner/android-adb-driver.js +7 -1
  17. package/dist/runner/android-adb.d.ts +40 -5
  18. package/dist/runner/android-adb.js +1046 -664
  19. package/dist/runner/compare-latest.d.ts +8 -4
  20. package/dist/runner/compare-latest.js +24 -5
  21. package/dist/runner/example-android-live.d.ts +10 -1
  22. package/dist/runner/example-android-live.js +55 -0
  23. package/dist/runner/example-ios-live.d.ts +10 -1
  24. package/dist/runner/example-ios-live.js +55 -0
  25. package/dist/runner/ios-simctl.d.ts +6 -0
  26. package/dist/runner/ios-simctl.js +7 -0
  27. package/dist/runner/live-comparison.d.ts +2 -2
  28. package/dist/runner/live-comparison.js +2 -1
  29. package/dist/runner/live-proof-summary.d.ts +5 -4
  30. package/dist/runner/live-proof-summary.js +12 -2
  31. package/dist/runner/live-proof.d.ts +3 -2
  32. package/dist/runner/live-proof.js +9 -2
  33. package/dist/runner/profile-android.d.ts +16 -1
  34. package/dist/runner/profile-android.js +364 -26
  35. package/dist/runner/profile-ios.d.ts +13 -2
  36. package/dist/runner/profile-ios.js +341 -19
  37. package/dist/runner/profile-mobile.d.ts +39 -3
  38. package/dist/runner/profile-mobile.js +1054 -42
  39. package/dist/runner/validate-project.js +3 -0
  40. package/dist/scripts/consumer-rehearsal.d.ts +119 -0
  41. package/dist/scripts/consumer-rehearsal.js +757 -0
  42. package/dist/scripts/downstream-local-package-gate.d.ts +2 -0
  43. package/dist/scripts/downstream-local-package-gate.js +264 -0
  44. package/dist/scripts/package-smoke.d.ts +96 -0
  45. package/dist/scripts/package-smoke.js +2282 -0
  46. package/dist/scripts/release-readiness.d.ts +2 -0
  47. package/dist/scripts/release-readiness.js +520 -0
  48. package/docs/adapters.md +7 -1
  49. package/docs/api.md +2 -2
  50. package/docs/architecture.md +90 -0
  51. package/docs/authoring.md +39 -3
  52. package/docs/concepts.md +3 -24
  53. package/docs/consumer-rehearsal.md +31 -1
  54. package/docs/contracts.md +45 -101
  55. package/docs/external-adapter-protocol.md +219 -0
  56. package/docs/live-proofs.md +86 -3
  57. package/docs/principles.md +9 -15
  58. package/examples/mobile-app/README.md +12 -0
  59. package/examples/mobile-app/runner-manifests/evidence-provider.json +3 -3
  60. package/examples/mobile-app/runner-manifests/primary-runner.json +1 -0
  61. package/examples/mobile-app/scripts/asl-capture-profiler-provider.mjs +25 -0
  62. package/examples/runners/README.md +4 -3
  63. package/examples/runners/adb-android.json +1 -0
  64. package/examples/runners/agent-device-android.json +1 -0
  65. package/examples/runners/agent-device-ios.json +1 -0
  66. package/examples/runners/argent-android.json +1 -0
  67. package/examples/runners/argent-ios.json +1 -0
  68. package/examples/runners/axe-accessibility-provider.json +2 -2
  69. package/examples/runners/script-accessibility-provider.json +2 -2
  70. package/examples/runners/script-memory-provider.json +2 -2
  71. package/examples/runners/script-network-provider.json +2 -2
  72. package/examples/runners/script-profiler-provider.json +2 -2
  73. package/examples/runners/xcodebuildmcp-ios.json +1 -0
  74. package/package.json +12 -3
  75. package/schemas/causal-run.schema.json +85 -2
  76. package/schemas/comparison.schema.json +130 -2
  77. package/schemas/external-adapter-message.schema.json +693 -0
  78. package/schemas/health.schema.json +72 -0
  79. package/schemas/live-proof-set.schema.json +1 -1
  80. package/schemas/live-proof.schema.json +14 -6
  81. package/schemas/manifest.schema.json +515 -4
  82. package/schemas/profiler.schema.json +243 -0
  83. package/schemas/runner-capabilities.schema.json +28 -2
  84. package/schemas/scenario.schema.json +34 -2
  85. package/templates/evidence-provider.json +3 -3
  86. package/templates/primary-runner.json +1 -0
  87. package/templates/scripts/asl-capture-profiler-provider.mjs +20 -0
@@ -5,8 +5,10 @@ exports.ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER = void 0;
5
5
  exports.buildAndroidHealth = buildAndroidHealth;
6
6
  exports.buildAndroidVerdict = buildAndroidVerdict;
7
7
  exports.buildReactNativeDebugHostPreferenceCommand = buildReactNativeDebugHostPreferenceCommand;
8
+ exports.deriveAndroidAdbCaptureWatchdogBudget = deriveAndroidAdbCaptureWatchdogBudget;
8
9
  exports.escapeAndroidPreferenceXml = escapeAndroidPreferenceXml;
9
10
  exports.execFileCommand = execFileCommand;
11
+ exports.execFileCommandWithTimeout = execFileCommandWithTimeout;
10
12
  exports.main = main;
11
13
  exports.parseAdbDevices = parseAdbDevices;
12
14
  exports.parseArgs = parseArgs;
@@ -32,6 +34,24 @@ const { hasHelpFlag, writeUsage } = require('./cli');
32
34
  const { buildAndroidScrollCoordinatesFromBounds, createAndroidAdbDriver, formatAndroidAdbRawOutput, quoteAndroidShellArg, resolveAndroidSelectorFromUiTree, } = require('./android-adb-driver');
33
35
  const ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER = '__ASL_ANDROID_DEVICE_EPOCH_MS__';
34
36
  exports.ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER = ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER;
37
+ const DEFAULT_ADB_COMMAND_TIMEOUT_MS = 30000;
38
+ const ANDROID_ADB_CAPTURE_WATCHDOG_FLOOR_MS = 15000;
39
+ const ANDROID_ADB_CAPTURE_WATCHDOG_CEILING_MS = 120000;
40
+ const ANDROID_ADB_CAPTURE_COMMAND_OVERHEAD_MS = 3000;
41
+ const ANDROID_ADB_CAPTURE_COMMAND_OVERHEAD_CEILING_MS = 45000;
42
+ /**
43
+ * Replaces Android device-clock placeholders in JSON payload strings.
44
+ *
45
+ * @param {string} value
46
+ * @param {number} epochMs
47
+ * @returns {string}
48
+ */
49
+ function resolveAndroidDeviceEpochPlaceholders(value, epochMs) {
50
+ return value
51
+ .replace(new RegExp(`"${ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}\\+(\\d+)"`, 'gu'), (_match, offset) => String(epochMs + Number(offset)))
52
+ .replace(new RegExp(`"${ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}"`, 'gu'), String(epochMs))
53
+ .replaceAll(ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER, String(epochMs));
54
+ }
35
55
  const ANDROID_READY_LOG_POLL_MS = 1000;
36
56
  /**
37
57
  * Prints CLI usage to stderr.
@@ -43,6 +63,7 @@ function usage(output = process.stderr) {
43
63
  'Usage: asl-android-adb [--adb <path>] [--serial <device>] [--package <name>] [--run-id <id>] [--out <dir>]',
44
64
  '',
45
65
  'Checks adb/device readiness and writes health.json, verdict.json, agent-summary.md, and raw adb evidence.',
66
+ 'Use --command-timeout-ms <ms> to bound each adb invocation.',
46
67
  'Use --capture-logcat [--logcat-lines <count>] to attach a bounded adb logcat snapshot under raw/adb-logcat.txt.',
47
68
  'Use --clear-logcat --launch [--launch-wait-ms <ms>] --wait-ms <ms> with --package <name> to capture a bounded app launch window.',
48
69
  'Use --react-native-debug-host <host:port> with --package <name> to set the app debug server and adb reverse for React Native dev builds.',
@@ -106,15 +127,32 @@ function parsePositiveInteger(value, fallback) {
106
127
  * @param {string[]} args
107
128
  * @returns {Promise<CommandResult>}
108
129
  */
109
- function execFileCommand(command, args) {
130
+ function execFileCommand(command, args, options = {}) {
131
+ return execFileCommandWithTimeout(command, args, DEFAULT_ADB_COMMAND_TIMEOUT_MS, options);
132
+ }
133
+ /**
134
+ * Runs a command with a bounded timeout and captures stdout, stderr, and exit code without throwing.
135
+ *
136
+ * @param {string} command
137
+ * @param {string[]} args
138
+ * @param {number} timeoutMs
139
+ * @returns {Promise<CommandResult>}
140
+ */
141
+ function execFileCommandWithTimeout(command, args, timeoutMs = DEFAULT_ADB_COMMAND_TIMEOUT_MS, options = {}) {
110
142
  return new Promise((resolve) => {
111
- execFile(command, args, (error, stdout, stderr) => {
143
+ execFile(command, args, { encoding: options.encoding === 'buffer' ? 'buffer' : 'utf8', timeout: timeoutMs }, (error, stdout, stderr) => {
144
+ const timedOut = Boolean(error?.killed || error?.signal === 'SIGTERM');
145
+ const stdoutText = Buffer.isBuffer(stdout) ? stdout.toString('utf8') : stdout;
146
+ const stderrText = Buffer.isBuffer(stderr) ? stderr.toString('utf8') : stderr;
112
147
  resolve({
113
148
  command,
114
149
  args,
115
150
  exitCode: error && typeof error.code === 'number' ? error.code : error ? 1 : 0,
116
- stderr,
117
- stdout,
151
+ stderr: [stderrText, timedOut ? `adb command timed out after ${timeoutMs}ms.` : ''].filter(Boolean).join('\n'),
152
+ stdout: stdoutText,
153
+ ...(options.encoding === 'buffer'
154
+ ? { stdoutBuffer: Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout, 'utf8') }
155
+ : {}),
118
156
  });
119
157
  });
120
158
  });
@@ -192,7 +230,8 @@ function isAdbDaemonUnavailable(result) {
192
230
  return (output.includes('cannot connect to daemon') ||
193
231
  output.includes('failed to check server version') ||
194
232
  output.includes('could not install *smartsocket* listener') ||
195
- output.includes('adb server didn'));
233
+ output.includes('adb server didn') ||
234
+ output.includes('adb command timed out'));
196
235
  }
197
236
  /**
198
237
  * Builds the health check details for device selection failures.
@@ -482,6 +521,239 @@ function buildAndroidVerdict({ runId, health }) {
482
521
  : 'Android adb preflight failed; runtime scenario execution is not ready.',
483
522
  }, SCHEMAS.verdict, 'Verdict artifact');
484
523
  }
524
+ /**
525
+ * Converts an unexpected runner failure into artifact-safe metadata.
526
+ *
527
+ * @param {unknown} error
528
+ * @returns {Record<string, unknown>}
529
+ */
530
+ function normalizeAndroidRunnerFailure(error) {
531
+ if (error instanceof Error) {
532
+ return {
533
+ name: error.name,
534
+ message: error.message,
535
+ ...(typeof error.stack === 'string' ? { stack: error.stack } : {}),
536
+ };
537
+ }
538
+ if (error && typeof error === 'object') {
539
+ const record = error;
540
+ return Object.fromEntries(Object.entries(record).filter(([, value]) => (value === null ||
541
+ ['boolean', 'number', 'string'].includes(typeof value) ||
542
+ Array.isArray(value))));
543
+ }
544
+ return {
545
+ message: String(error),
546
+ };
547
+ }
548
+ /**
549
+ * Formats unexpected runner failure metadata as a raw transcript.
550
+ *
551
+ * @param {Record<string, unknown>} failure
552
+ * @returns {string}
553
+ */
554
+ function formatAndroidRunnerFailureRaw(failure) {
555
+ return Object.entries(failure)
556
+ .map(([key, value]) => {
557
+ const formatted = value && typeof value === 'object' && !Array.isArray(value)
558
+ ? JSON.stringify(value)
559
+ : Array.isArray(value)
560
+ ? value.join(' ')
561
+ : String(value);
562
+ return `${key}: ${formatted}`;
563
+ })
564
+ .join('\n');
565
+ }
566
+ /**
567
+ * Sums only positive integer-ish durations.
568
+ *
569
+ * @param {Array<number | undefined>} values
570
+ * @returns {number}
571
+ */
572
+ function sumPositiveDurations(values) {
573
+ let total = 0;
574
+ for (const value of values) {
575
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
576
+ total += value;
577
+ }
578
+ }
579
+ return total;
580
+ }
581
+ /**
582
+ * Derives a whole-capture adb watchdog from declared runner waits and command bounds.
583
+ *
584
+ * @param {AndroidPreflightOptions & {commandTimeoutMs: number}} options
585
+ * @returns {AndroidAdbCaptureWatchdogBudget}
586
+ */
587
+ function deriveAndroidAdbCaptureWatchdogBudget({ captureLogcat = false, captureWatchdogMs, clearLogcat = false, commandTimeoutMs, deepLinks = [], driverSteps = [], launch = false, launchWaitMs = 0, packageName = null, reactNativeDebugHost = null, startupDeepLinks = [], storageWrites = [], waitMs = 0, }) {
588
+ if (typeof captureWatchdogMs === 'number' && Number.isFinite(captureWatchdogMs) && captureWatchdogMs > 0) {
589
+ return {
590
+ ceilingMs: ANDROID_ADB_CAPTURE_WATCHDOG_CEILING_MS,
591
+ commandBudgetMs: 0,
592
+ commandUnits: 0,
593
+ declaredWaitMs: captureWatchdogMs,
594
+ floorMs: ANDROID_ADB_CAPTURE_WATCHDOG_FLOOR_MS,
595
+ perCommandOverheadMs: 0,
596
+ source: 'override',
597
+ timeoutMs: Math.ceil(captureWatchdogMs),
598
+ };
599
+ }
600
+ const resolvedDriverSteps = resolveAndroidAdbDriverSteps({
601
+ captureLogcat,
602
+ driverSteps,
603
+ logcatLines: 1,
604
+ waitMs,
605
+ });
606
+ const declaredWaitMs = sumPositiveDurations([
607
+ launchWaitMs,
608
+ ...startupDeepLinks.map((deepLink) => deepLink.waitMs),
609
+ ...startupDeepLinks.map((deepLink) => deepLink.readyLogTimeoutMs),
610
+ ...storageWrites.map((write) => write.waitMs),
611
+ ...deepLinks.map((deepLink) => deepLink.waitMs),
612
+ ...resolvedDriverSteps.map((step) => step.waitMs),
613
+ ...resolvedDriverSteps.map((step) => step.durationMs),
614
+ ...resolvedDriverSteps.map((step) => (typeof step.durationSeconds === 'number' ? step.durationSeconds * 1000 : undefined)),
615
+ ]);
616
+ const startupReadyPolls = startupDeepLinks.filter((deepLink) => deepLink.readyLogPattern).length;
617
+ const storageClockReads = storageWrites.filter((write) => write.value.includes(ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER)).length;
618
+ const selectorInspections = resolvedDriverSteps.filter((step) => needsAndroidSelectorResolution(step)).length;
619
+ const lifecycleChecks = packageName && (launch || deepLinks.length > 0) ? 2 : 0;
620
+ const commandUnits = 6 +
621
+ (packageName ? 1 : 0) +
622
+ (reactNativeDebugHost ? 2 : 0) +
623
+ (clearLogcat ? 1 : 0) +
624
+ (launch ? 2 : 0) +
625
+ startupDeepLinks.length +
626
+ startupReadyPolls +
627
+ storageWrites.length +
628
+ storageClockReads +
629
+ deepLinks.length +
630
+ (packageName ? deepLinks.length : 0) +
631
+ resolvedDriverSteps.length +
632
+ selectorInspections +
633
+ lifecycleChecks;
634
+ const perCommandOverheadMs = Math.min(commandTimeoutMs, ANDROID_ADB_CAPTURE_COMMAND_OVERHEAD_MS);
635
+ const commandBudgetMs = Math.min(commandUnits * perCommandOverheadMs, ANDROID_ADB_CAPTURE_COMMAND_OVERHEAD_CEILING_MS);
636
+ const bufferedBudgetMs = commandBudgetMs + declaredWaitMs + 5000;
637
+ const ceilingMs = Math.max(ANDROID_ADB_CAPTURE_WATCHDOG_CEILING_MS, bufferedBudgetMs);
638
+ return {
639
+ ceilingMs,
640
+ commandBudgetMs,
641
+ commandUnits,
642
+ declaredWaitMs,
643
+ floorMs: ANDROID_ADB_CAPTURE_WATCHDOG_FLOOR_MS,
644
+ perCommandOverheadMs,
645
+ source: 'derived',
646
+ timeoutMs: Math.min(ceilingMs, Math.max(ANDROID_ADB_CAPTURE_WATCHDOG_FLOOR_MS, bufferedBudgetMs)),
647
+ };
648
+ }
649
+ /**
650
+ * Builds the immediate raw checkpoint written before the first adb call.
651
+ *
652
+ * @param {{adbPath: string, captureWatchdog: AndroidAdbCaptureWatchdogBudget, commandTimeoutMs: number, deadlineEpochMs: number, metadata: Record<string, unknown>, runId: string, startedAt: string}} options
653
+ * @returns {Record<string, unknown>}
654
+ */
655
+ function buildAndroidAdbCaptureStartedCheckpoint({ adbPath, captureWatchdog, commandTimeoutMs, deadlineEpochMs, metadata, runId, startedAt, }) {
656
+ return {
657
+ status: 'started',
658
+ message: 'Android adb capture started and the whole-capture watchdog deadline was derived.',
659
+ runId,
660
+ adbPath,
661
+ commandTimeoutMs,
662
+ startedAt,
663
+ watchdog: {
664
+ ...captureWatchdog,
665
+ armed: true,
666
+ deadlineEpochMs,
667
+ deadlineIso: new Date(deadlineEpochMs).toISOString(),
668
+ },
669
+ selectedInputs: {
670
+ captureLogcat: metadata.captureLogcat,
671
+ clearLogcat: metadata.clearLogcat,
672
+ deepLinks: metadata.deepLinks,
673
+ driverSteps: metadata.driverSteps,
674
+ launch: metadata.launch,
675
+ logcatLines: metadata.logcatLines,
676
+ packageName: metadata.packageName,
677
+ reactNativeDebugHost: metadata.reactNativeDebugHost,
678
+ startupDeepLinks: metadata.startupDeepLinks,
679
+ storageWrites: metadata.storageWrites,
680
+ waitMs: metadata.waitMs,
681
+ },
682
+ };
683
+ }
684
+ /**
685
+ * Creates the classified whole-capture watchdog error.
686
+ *
687
+ * @param {AndroidAdbCaptureWatchdogBudget} watchdog
688
+ * @returns {AndroidAdbCaptureWatchdogError}
689
+ */
690
+ function createAndroidAdbCaptureWatchdogError(watchdog) {
691
+ const error = new Error(`Android adb capture did not complete within ${watchdog.timeoutMs}ms.`);
692
+ return Object.assign(error, {
693
+ code: 'android_adb_runner_liveness_timeout',
694
+ watchdog,
695
+ });
696
+ }
697
+ /**
698
+ * Checks whether an error came from the capture watchdog.
699
+ *
700
+ * @param {unknown} error
701
+ * @returns {error is AndroidAdbCaptureWatchdogError}
702
+ */
703
+ function isAndroidAdbCaptureWatchdogError(error) {
704
+ return error instanceof Error &&
705
+ error.code === 'android_adb_runner_liveness_timeout';
706
+ }
707
+ /**
708
+ * Runs the mutable capture body with a single whole-capture timeout.
709
+ *
710
+ * @param {{body: () => Promise<void>, watchdog: AndroidAdbCaptureWatchdogBudget}} options
711
+ * @returns {Promise<void>}
712
+ */
713
+ async function runAndroidAdbCaptureBodyWithWatchdog({ body, watchdog, }) {
714
+ let timer = null;
715
+ try {
716
+ await Promise.race([
717
+ body(),
718
+ new Promise((_resolve, reject) => {
719
+ timer = setTimeout(() => reject(createAndroidAdbCaptureWatchdogError(watchdog)), watchdog.timeoutMs);
720
+ }),
721
+ ]);
722
+ }
723
+ finally {
724
+ if (timer) {
725
+ clearTimeout(timer);
726
+ }
727
+ }
728
+ }
729
+ /**
730
+ * Writes the Android adb artifact set.
731
+ *
732
+ * @param {{agentSummary: string, health: Record<string, unknown>, layout: ReturnType<typeof createArtifactLayout>, metadata: Record<string, unknown>, raw: Record<string, string | Uint8Array>, rawDir: string, verdict: Record<string, unknown>}} options
733
+ * @returns {Promise<void>}
734
+ */
735
+ async function writeAndroidAdbArtifacts({ agentSummary, health, layout, metadata, raw, rawDir, verdict, }) {
736
+ await Promise.all(Object.entries(raw).map(([fileName, content]) => (content instanceof Uint8Array
737
+ ? fsp.writeFile(path.join(rawDir, fileName), content)
738
+ : fsp.writeFile(path.join(rawDir, fileName), `${content.trimEnd()}\n`, 'utf8'))));
739
+ await fsp.writeFile(path.join(rawDir, 'android-metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
740
+ await writeJsonArtifact({
741
+ filePath: layout.health,
742
+ value: health,
743
+ schema: SCHEMAS.health,
744
+ label: 'Health artifact',
745
+ });
746
+ await writeJsonArtifact({
747
+ filePath: layout.verdict,
748
+ value: verdict,
749
+ schema: SCHEMAS.verdict,
750
+ label: 'Verdict artifact',
751
+ });
752
+ await writeTextArtifact({
753
+ filePath: layout.agentSummary,
754
+ content: agentSummary,
755
+ });
756
+ }
485
757
  /**
486
758
  * Builds the driver steps for this adb capture window.
487
759
  *
@@ -771,68 +1043,39 @@ async function runAndroidAdbDriverStep({ capturesDir, driver, driverStep, logcat
771
1043
  * @param {AndroidPreflightOptions} options
772
1044
  * @returns {Promise<AndroidPreflightResult>}
773
1045
  */
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, } = {}) {
1046
+ async function runAndroidAdbPreflight({ adbPath = 'adb', captureLogcat = false, captureWatchdogMs: captureWatchdogMsOverride, clearLogcat = false, commandTimeoutMs = DEFAULT_ADB_COMMAND_TIMEOUT_MS, deepLinks = [], delay: wait = delay, driverSteps = [], executor = (command, args, options) => execFileCommandWithTimeout(command, args, commandTimeoutMs, options), 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
1047
  const runDir = path.resolve(outputDir);
776
1048
  const layout = createArtifactLayout({ outputDir: runDir });
777
1049
  const rawDir = layout.raw;
778
1050
  await fsp.mkdir(rawDir, { recursive: true });
779
1051
  const raw = {};
780
1052
  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
- : {}),
1053
+ let device = null;
1054
+ const captureWatchdog = deriveAndroidAdbCaptureWatchdogBudget({
1055
+ captureLogcat,
1056
+ ...(typeof captureWatchdogMsOverride === 'number' ? { captureWatchdogMs: captureWatchdogMsOverride } : {}),
1057
+ clearLogcat,
1058
+ commandTimeoutMs,
1059
+ deepLinks,
1060
+ driverSteps,
1061
+ launch,
1062
+ launchWaitMs,
1063
+ packageName,
1064
+ reactNativeDebugHost,
1065
+ startupDeepLinks,
1066
+ storageWrites,
1067
+ waitMs,
825
1068
  });
826
1069
  const metadata = {
827
1070
  adbPath,
828
1071
  captureLogcat,
1072
+ captureWatchdog,
829
1073
  clearLogcat,
1074
+ commandTimeoutMs,
830
1075
  deepLinks,
831
- devices,
832
1076
  driverSteps,
833
1077
  launch,
834
1078
  logcatLines,
835
- selectedDevice: device,
836
1079
  packageName,
837
1080
  reactNativeDebugHost,
838
1081
  startupDeepLinks,
@@ -844,714 +1087,852 @@ async function runAndroidAdbPreflight({ adbPath = 'adb', captureLogcat = false,
844
1087
  })),
845
1088
  waitMs,
846
1089
  };
847
- const resolvedDriverSteps = resolveAndroidAdbDriverSteps({
848
- captureLogcat,
849
- driverSteps,
850
- logcatLines,
851
- waitMs,
1090
+ const captureStartedAt = new Date();
1091
+ const captureDeadlineEpochMs = captureStartedAt.getTime() + captureWatchdog.timeoutMs;
1092
+ const captureStartedCheckpoint = buildAndroidAdbCaptureStartedCheckpoint({
1093
+ adbPath,
1094
+ captureWatchdog,
1095
+ commandTimeoutMs,
1096
+ deadlineEpochMs: captureDeadlineEpochMs,
1097
+ metadata,
1098
+ runId,
1099
+ startedAt: captureStartedAt.toISOString(),
852
1100
  });
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,
1101
+ const captureStartedRawFileName = 'adb-capture-started.json';
1102
+ raw[captureStartedRawFileName] = JSON.stringify(captureStartedCheckpoint, null, 2);
1103
+ metadata.captureStarted = {
1104
+ rawPath: `raw/${captureStartedRawFileName}`,
1105
+ startedAt: captureStartedCheckpoint.startedAt,
1106
+ watchdog: captureStartedCheckpoint.watchdog,
1107
+ };
1108
+ await fsp.writeFile(path.join(rawDir, captureStartedRawFileName), `${JSON.stringify(captureStartedCheckpoint, null, 2)}\n`, 'utf8');
1109
+ const runCaptureBody = async () => {
1110
+ const version = await executor(adbPath, ['version']);
1111
+ const adbAvailable = version.exitCode === 0;
1112
+ raw['adb-version.txt'] = [version.stdout, version.stderr].filter(Boolean).join('\n');
1113
+ checks.push({
1114
+ name: 'adb_available',
1115
+ status: adbAvailable ? 'passed' : 'failed',
1116
+ source: 'runner',
1117
+ code: adbAvailable ? 'adb_available' : 'adb_unavailable',
1118
+ message: adbAvailable ? 'adb command is available.' : 'adb command could not be executed.',
1119
+ ...(!adbAvailable
1120
+ ? {
1121
+ metadata: nextActionHint('fix_adb_command', 'Install Android platform-tools or pass --adb with a working adb binary, then rerun the capture.'),
1122
+ }
1123
+ : {}),
859
1124
  });
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
- : {}),
1125
+ const devicesOutput = adbAvailable
1126
+ ? await executor(adbPath, ['devices', '-l'])
1127
+ : {
1128
+ command: adbPath,
1129
+ args: ['devices', '-l'],
1130
+ exitCode: 1,
1131
+ stderr: 'adb unavailable',
1132
+ stdout: '',
1133
+ };
1134
+ raw['adb-devices.txt'] = [devicesOutput.stdout, devicesOutput.stderr].filter(Boolean).join('\n');
1135
+ const devices = parseAdbDevices(devicesOutput.stdout);
1136
+ metadata.devices = devices;
1137
+ device = selectDevice(devices, serial);
1138
+ metadata.selectedDevice = device;
1139
+ const deviceOnline = Boolean(device && device.state === 'device');
1140
+ const deviceFailure = !deviceOnline
1141
+ ? buildAndroidDeviceFailure({ devicesOutput, serial })
1142
+ : null;
1143
+ checks.push({
1144
+ name: 'android_device_connected',
1145
+ status: deviceOnline ? 'passed' : 'failed',
1146
+ source: 'runner',
1147
+ code: deviceOnline ? 'android_device_connected' : deviceFailure?.code,
1148
+ message: deviceOnline && device
1149
+ ? `Selected Android device ${device.serial}.`
1150
+ : deviceFailure?.message,
1151
+ ...(!deviceOnline
1152
+ ? {
1153
+ metadata: deviceFailure?.metadata,
1154
+ }
1155
+ : {}),
1156
+ });
1157
+ const resolvedDriverSteps = resolveAndroidAdbDriverSteps({
1158
+ captureLogcat,
1159
+ driverSteps,
1160
+ logcatLines,
1161
+ waitMs,
1162
+ });
1163
+ if (device && device.state === 'device') {
1164
+ const shellPrefix = ['-s', device.serial, 'shell'];
1165
+ const driver = createAndroidAdbDriver({
1166
+ adbPath,
1167
+ deviceSerial: device.serial,
1168
+ executor,
896
1169
  });
897
- }
898
- if (reactNativeDebugHost) {
899
- const reactNativeDebugPort = parseReactNativeDebugHostPort(reactNativeDebugHost);
900
- if (!packageName) {
1170
+ const [model, release, sdk] = await Promise.all([
1171
+ executor(adbPath, [...shellPrefix, 'getprop', 'ro.product.model']),
1172
+ executor(adbPath, [...shellPrefix, 'getprop', 'ro.build.version.release']),
1173
+ executor(adbPath, [...shellPrefix, 'getprop', 'ro.build.version.sdk']),
1174
+ ]);
1175
+ metadata.deviceProperties = {
1176
+ model: model.stdout.trim(),
1177
+ release: release.stdout.trim(),
1178
+ sdk: sdk.stdout.trim(),
1179
+ };
1180
+ raw['adb-device-properties.txt'] = [
1181
+ `model=${model.stdout.trim()}`,
1182
+ `release=${release.stdout.trim()}`,
1183
+ `sdk=${sdk.stdout.trim()}`,
1184
+ ].join('\n');
1185
+ let selectedPackageInstalled = false;
1186
+ if (packageName) {
1187
+ const packageCheck = await executor(adbPath, [...shellPrefix, 'pm', 'path', packageName]);
1188
+ raw['adb-package.txt'] = [packageCheck.stdout, packageCheck.stderr].filter(Boolean).join('\n');
1189
+ const packageInstalled = packageCheck.exitCode === 0 && packageCheck.stdout.includes('package:');
1190
+ selectedPackageInstalled = packageInstalled;
901
1191
  checks.push({
902
- name: 'android_react_native_debug_host_configured',
903
- status: 'failed',
1192
+ name: 'android_package_installed',
1193
+ status: packageInstalled ? 'passed' : 'failed',
904
1194
  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.'),
1195
+ code: packageInstalled
1196
+ ? 'android_package_installed'
1197
+ : 'android_package_missing',
1198
+ message: packageInstalled
1199
+ ? `Package ${packageName} is installed.`
1200
+ : `Package ${packageName} is not installed on ${device.serial}.`,
1201
+ ...(!packageInstalled
1202
+ ? {
1203
+ metadata: nextActionHint('install_android_package', 'Build and install the app on the selected device, or rerun with --package set to the installed application id.'),
1204
+ }
1205
+ : {}),
908
1206
  });
909
1207
  }
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
- });
1208
+ if (reactNativeDebugHost) {
1209
+ const reactNativeDebugPort = parseReactNativeDebugHostPort(reactNativeDebugHost);
1210
+ if (!packageName) {
1211
+ checks.push({
1212
+ name: 'android_react_native_debug_host_configured',
1213
+ status: 'failed',
1214
+ source: 'runner',
1215
+ code: 'android_react_native_debug_host_missing_package',
1216
+ message: 'React Native debug host setup was requested, but --package was not provided.',
1217
+ metadata: nextActionHint('provide_android_package', 'Rerun with --package set to the installed Android application id when --react-native-debug-host is enabled.'),
1218
+ });
1219
+ }
1220
+ else if (!selectedPackageInstalled) {
1221
+ checks.push({
1222
+ name: 'android_react_native_debug_host_configured',
1223
+ status: 'failed',
1224
+ source: 'runner',
1225
+ code: 'android_react_native_debug_host_package_missing',
1226
+ message: `React Native debug host setup requires installed package ${packageName}.`,
1227
+ metadata: nextActionHint('install_android_package', 'Build and install the app on the selected device before configuring the React Native debug host.'),
1228
+ });
1229
+ }
1230
+ else if (!reactNativeDebugPort) {
1231
+ checks.push({
1232
+ name: 'android_react_native_debug_host_configured',
1233
+ status: 'failed',
1234
+ source: 'runner',
1235
+ code: 'android_react_native_debug_host_invalid',
1236
+ message: `React Native debug host ${reactNativeDebugHost} must be a host:port value without a URL scheme.`,
1237
+ metadata: nextActionHint('fix_react_native_debug_host', 'Pass a React Native debug host such as localhost:8097, not a full http:// URL.'),
1238
+ });
1239
+ }
1240
+ else {
1241
+ const reverseResult = await executor(adbPath, [
1242
+ '-s',
1243
+ device.serial,
1244
+ 'reverse',
1245
+ `tcp:${reactNativeDebugPort}`,
1246
+ `tcp:${reactNativeDebugPort}`,
1247
+ ]);
1248
+ const preferenceCommand = buildReactNativeDebugHostPreferenceCommand({
1249
+ debugHost: reactNativeDebugHost,
1250
+ packageName,
1251
+ });
1252
+ const preferenceResult = await executor(adbPath, [
1253
+ '-s',
1254
+ device.serial,
1255
+ 'shell',
1256
+ 'run-as',
1257
+ packageName,
1258
+ 'sh',
1259
+ '-c',
1260
+ quoteAndroidShellArg(preferenceCommand),
1261
+ ]);
1262
+ const reversePassed = reverseResult.exitCode === 0;
1263
+ const preferencePassed = preferenceResult.exitCode === 0;
1264
+ raw['adb-react-native-reverse.txt'] = formatAndroidCommandRawOutput(reverseResult);
1265
+ raw['adb-react-native-debug-host.txt'] = formatAndroidCommandRawOutput(preferenceResult);
1266
+ checks.push({
1267
+ name: 'android_react_native_reverse_configured',
1268
+ status: reversePassed ? 'passed' : 'failed',
1269
+ source: 'runner',
1270
+ code: reversePassed
1271
+ ? 'android_react_native_reverse_configured'
1272
+ : 'android_react_native_reverse_failed',
1273
+ message: reversePassed
1274
+ ? `Configured adb reverse for React Native debug port ${reactNativeDebugPort}.`
1275
+ : `Failed to configure adb reverse for React Native debug port ${reactNativeDebugPort}.`,
1276
+ ...(!reversePassed
1277
+ ? {
1278
+ 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.'),
1279
+ }
1280
+ : {}),
1281
+ });
1282
+ checks.push({
1283
+ name: 'android_react_native_debug_host_configured',
1284
+ status: preferencePassed ? 'passed' : 'failed',
1285
+ source: 'runner',
1286
+ code: preferencePassed
1287
+ ? 'android_react_native_debug_host_configured'
1288
+ : 'android_react_native_debug_host_failed',
1289
+ message: preferencePassed
1290
+ ? `Configured React Native debug host ${reactNativeDebugHost} for ${packageName}.`
1291
+ : `Failed to configure React Native debug host ${reactNativeDebugHost} for ${packageName}.`,
1292
+ ...(!preferencePassed
1293
+ ? {
1294
+ 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.'),
1295
+ }
1296
+ : {}),
1297
+ });
1298
+ metadata.reactNativeDebugHostSetup = {
1299
+ debugHost: reactNativeDebugHost,
1300
+ port: reactNativeDebugPort,
1301
+ preferenceRawPath: 'raw/adb-react-native-debug-host.txt',
1302
+ reverseRawPath: 'raw/adb-react-native-reverse.txt',
1303
+ };
1304
+ }
919
1305
  }
920
- else if (!reactNativeDebugPort) {
1306
+ if (clearLogcat) {
1307
+ const clear = await driver.clearLogs();
1308
+ const logcatCleared = clear.exitCode === 0;
1309
+ raw[clear.rawFileName] = formatAndroidAdbRawOutput(clear);
921
1310
  checks.push({
922
- name: 'android_react_native_debug_host_configured',
923
- status: 'failed',
1311
+ name: 'android_logcat_cleared',
1312
+ status: logcatCleared ? 'passed' : 'failed',
924
1313
  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.'),
1314
+ code: logcatCleared ? 'android_logcat_cleared' : 'android_logcat_clear_failed',
1315
+ message: logcatCleared ? 'Cleared adb logcat before capture.' : 'adb logcat clear failed.',
1316
+ ...(!logcatCleared
1317
+ ? {
1318
+ metadata: nextActionHint('inspect_adb_logcat_clear', `Inspect raw/${clear.rawFileName}, confirm the selected device allows logcat access, then rerun the capture.`),
1319
+ }
1320
+ : {}),
928
1321
  });
1322
+ metadata.logcatClear = {
1323
+ args: clear.args,
1324
+ exitCode: clear.exitCode,
1325
+ rawPath: `raw/${clear.rawFileName}`,
1326
+ };
929
1327
  }
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,
1328
+ const appLifecycleMetadata = {};
1329
+ let lifecyclePackageName = null;
1330
+ let knownLifecyclePids = [];
1331
+ if (launch) {
1332
+ if (!packageName) {
1333
+ checks.push({
1334
+ name: 'android_package_launched',
1335
+ status: 'failed',
1336
+ source: 'runner',
1337
+ code: 'android_launch_missing_package',
1338
+ message: 'Package launch was requested, but --package was not provided.',
1339
+ metadata: nextActionHint('provide_android_package', 'Rerun with --package set to the installed Android application id when --launch is enabled.'),
1340
+ });
1341
+ }
1342
+ else {
1343
+ const launchResult = await driver.launchPackage(packageName);
1344
+ const launchPassed = launchResult.exitCode === 0;
1345
+ raw[launchResult.rawFileName] = formatAndroidAdbRawOutput(launchResult);
1346
+ checks.push({
1347
+ name: 'android_package_launched',
1348
+ status: launchPassed ? 'passed' : 'failed',
1349
+ source: 'runner',
1350
+ code: launchPassed ? 'android_package_launched' : 'android_package_launch_failed',
1351
+ message: launchPassed
1352
+ ? `Launched package ${packageName}.`
1353
+ : `Failed to launch package ${packageName}.`,
1354
+ ...(!launchPassed
1355
+ ? {
1356
+ 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.`),
1357
+ }
1358
+ : {}),
1359
+ });
1360
+ metadata.launchResult = {
1361
+ args: launchResult.args,
1362
+ exitCode: launchResult.exitCode,
1363
+ rawPath: `raw/${launchResult.rawFileName}`,
1364
+ };
1365
+ if (launchPassed && launchWaitMs > 0) {
1366
+ await wait(launchWaitMs);
1367
+ checks.push({
1368
+ name: 'android_launch_waited',
1369
+ status: 'passed',
1370
+ source: 'runner',
1371
+ code: 'android_launch_waited',
1372
+ message: `Waited ${launchWaitMs}ms after Android package launch.`,
1373
+ });
1374
+ metadata.launchWaitMs = launchWaitMs;
1375
+ }
1376
+ if (launchPassed) {
1377
+ lifecyclePackageName = packageName;
1378
+ const pidofAfterLaunch = await executor(adbPath, [
1379
+ '-s',
1380
+ device.serial,
1381
+ 'shell',
1382
+ 'pidof',
1383
+ packageName,
1384
+ ]);
1385
+ const rawPath = 'raw/adb-app-pidof-after-launch.txt';
1386
+ raw['adb-app-pidof-after-launch.txt'] = formatAndroidCommandRawOutput(pidofAfterLaunch);
1387
+ const afterLaunchPids = parseAndroidPidofOutput(pidofAfterLaunch.stdout);
1388
+ knownLifecyclePids = afterLaunchPids;
1389
+ const runningAfterLaunch = pidofAfterLaunch.exitCode === 0 && afterLaunchPids.length > 0;
1390
+ checks.push({
1391
+ name: 'android_app_process_running_after_launch',
1392
+ status: runningAfterLaunch ? 'passed' : 'failed',
1393
+ source: 'runner',
1394
+ code: runningAfterLaunch
1395
+ ? 'android_app_process_running_after_launch'
1396
+ : 'android_app_not_running_after_launch',
1397
+ message: runningAfterLaunch
1398
+ ? `Package ${packageName} is running after launch with PID ${afterLaunchPids.join(', ')}.`
1399
+ : `Package ${packageName} is not running after launch.`,
1400
+ ...(!runningAfterLaunch
1401
+ ? {
1402
+ metadata: nextActionHint('inspect_android_app_launch', `Inspect ${rawPath} and the app's device logs to find why the launched process exited before evidence capture.`),
1403
+ }
1404
+ : {}),
1405
+ });
1406
+ Object.assign(appLifecycleMetadata, {
1407
+ afterLaunchPids,
1408
+ afterLaunchRawPath: rawPath,
1409
+ });
1410
+ }
1411
+ }
1412
+ }
1413
+ let startupReady = true;
1414
+ for (const [index, deepLink] of startupDeepLinks.entries()) {
1415
+ const rawFileName = `adb-startup-deep-link-${index + 1}.txt`;
1416
+ const deepLinkResult = await driver.openDeepLink({
940
1417
  packageName,
1418
+ rawFileName,
1419
+ url: deepLink.url,
941
1420
  });
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);
1421
+ const deepLinkOpened = deepLinkResult.exitCode === 0;
1422
+ if (!deepLinkOpened) {
1423
+ startupReady = false;
1424
+ }
1425
+ raw[deepLinkResult.rawFileName] = formatAndroidAdbRawOutput(deepLinkResult);
956
1426
  checks.push({
957
- name: 'android_react_native_reverse_configured',
958
- status: reversePassed ? 'passed' : 'failed',
1427
+ name: 'android_startup_deep_link_opened',
1428
+ status: deepLinkOpened ? 'passed' : 'failed',
959
1429
  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
1430
+ code: deepLinkOpened ? 'android_startup_deep_link_opened' : 'android_startup_deep_link_failed',
1431
+ message: deepLinkOpened
1432
+ ? `Opened Android startup deep link ${deepLink.label ?? index + 1}.`
1433
+ : `Failed to open Android startup deep link ${deepLink.label ?? index + 1}.`,
1434
+ ...(!deepLinkOpened
967
1435
  ? {
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.'),
1436
+ metadata: nextActionHint('inspect_android_startup_deep_link', `Inspect raw/${deepLinkResult.rawFileName}, verify the dev-client URL and package intent filter, then rerun the capture.`),
969
1437
  }
970
1438
  : {}),
971
1439
  });
1440
+ if (deepLink.waitMs && deepLink.waitMs > 0) {
1441
+ await wait(deepLink.waitMs);
1442
+ checks.push({
1443
+ name: 'android_startup_deep_link_waited',
1444
+ status: 'passed',
1445
+ source: 'runner',
1446
+ code: 'android_startup_deep_link_waited',
1447
+ message: `Waited ${deepLink.waitMs}ms after Android startup deep link ${deepLink.label ?? index + 1}.`,
1448
+ });
1449
+ }
1450
+ if (deepLink.readyLogPattern) {
1451
+ const rawFileName = `adb-startup-deep-link-${index + 1}-ready-log.txt`;
1452
+ const readyLog = await waitForAndroidReadyLog({
1453
+ driver,
1454
+ logcatLines,
1455
+ pattern: deepLink.readyLogPattern,
1456
+ quietMs: deepLink.readyLogQuietMs ?? 0,
1457
+ rawFileName,
1458
+ timeoutMs: deepLink.readyLogTimeoutMs ?? 60000,
1459
+ wait,
1460
+ });
1461
+ if (!readyLog.ready) {
1462
+ startupReady = false;
1463
+ }
1464
+ raw[rawFileName] = formatAndroidAdbRawOutput(readyLog.result);
1465
+ checks.push({
1466
+ name: 'android_startup_deep_link_ready',
1467
+ status: readyLog.ready ? 'passed' : 'failed',
1468
+ source: 'runner',
1469
+ code: readyLog.ready
1470
+ ? 'android_startup_deep_link_ready'
1471
+ : 'android_startup_deep_link_not_ready',
1472
+ message: readyLog.ready
1473
+ ? `Android startup deep link ${deepLink.label ?? index + 1} emitted readiness log evidence.`
1474
+ : `Android startup deep link ${deepLink.label ?? index + 1} did not emit readiness log evidence before timeout.`,
1475
+ ...(!readyLog.ready
1476
+ ? {
1477
+ 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.`),
1478
+ }
1479
+ : {}),
1480
+ });
1481
+ }
1482
+ }
1483
+ if (!startupReady && (storageWrites.length > 0 || deepLinks.length > 0)) {
972
1484
  checks.push({
973
- name: 'android_react_native_debug_host_configured',
974
- status: preferencePassed ? 'passed' : 'failed',
1485
+ name: 'android_startup_ready_for_control',
1486
+ status: 'failed',
975
1487
  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
- : {}),
1488
+ code: 'android_startup_not_ready_for_control',
1489
+ message: 'Android startup readiness failed before profile-session control was delivered.',
1490
+ metadata: nextActionHint('verify_android_startup_readiness', 'Inspect startup deep-link and ready-log artifacts, confirm the app loaded the expected bundle, then rerun before delivering profile-session storage or command deep links.'),
987
1491
  });
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
1492
  }
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.`),
1493
+ for (const [index, write] of storageWrites.entries()) {
1494
+ const rawFileName = `adb-async-storage-write-${index + 1}.txt`;
1495
+ if (!startupReady) {
1496
+ checks.push({
1497
+ name: 'android_async_storage_written',
1498
+ status: 'failed',
1499
+ source: 'runner',
1500
+ code: 'android_async_storage_skipped_startup_not_ready',
1501
+ message: `Skipped Android AsyncStorage value ${write.label ?? index + 1} because startup readiness failed.`,
1502
+ metadata: nextActionHint('verify_android_startup_readiness', 'Rerun after Android startup readiness passes; ASL will not write profile-session storage into an app that has not proven bundle readiness.'),
1503
+ });
1504
+ continue;
1505
+ }
1506
+ if (!packageName) {
1507
+ checks.push({
1508
+ name: 'android_async_storage_written',
1509
+ status: 'failed',
1510
+ source: 'runner',
1511
+ code: 'android_async_storage_missing_package',
1512
+ message: 'Android AsyncStorage write was requested, but --package was not provided.',
1513
+ metadata: nextActionHint('provide_android_package', 'Rerun with --package set to the installed Android application id when Android AsyncStorage profile-session storage is enabled.'),
1514
+ });
1515
+ continue;
1516
+ }
1517
+ let resolvedWrite = write;
1518
+ if (write.value.includes(ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER)) {
1519
+ const deviceEpoch = await readAndroidDeviceEpochMs({
1520
+ adbPath,
1521
+ deviceSerial: device.serial,
1522
+ executor,
1523
+ });
1524
+ raw['adb-device-epoch-ms.txt'] = formatAndroidCommandRawOutput(deviceEpoch);
1525
+ const deviceClockRead = typeof deviceEpoch.epochMs === 'number';
1526
+ checks.push({
1527
+ name: 'android_device_clock_read',
1528
+ status: deviceClockRead ? 'passed' : 'failed',
1529
+ source: 'runner',
1530
+ code: deviceClockRead ? 'android_device_clock_read' : 'android_device_clock_unavailable',
1531
+ message: deviceClockRead
1532
+ ? `Read Android device clock as ${deviceEpoch.epochMs}ms since epoch.`
1533
+ : 'Failed to read Android device clock for AsyncStorage timing evidence.',
1534
+ ...(!deviceClockRead
1535
+ ? {
1536
+ 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.'),
1537
+ }
1538
+ : {}),
1539
+ });
1540
+ if (!deviceClockRead || deviceEpoch.epochMs === null) {
1541
+ continue;
1009
1542
  }
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) {
1543
+ resolvedWrite = {
1544
+ ...write,
1545
+ value: resolveAndroidDeviceEpochPlaceholders(write.value, deviceEpoch.epochMs),
1546
+ };
1547
+ }
1548
+ const writeResult = await executor(adbPath, [
1549
+ '-s',
1550
+ device.serial,
1551
+ 'shell',
1552
+ buildAndroidAsyncStorageWriteCommand({ packageName, write: resolvedWrite }),
1553
+ ]);
1554
+ const writePassed = writeResult.exitCode === 0;
1555
+ raw[rawFileName] = formatAndroidCommandRawOutput(writeResult);
1023
1556
  checks.push({
1024
- name: 'android_package_launched',
1025
- status: 'failed',
1557
+ name: 'android_async_storage_written',
1558
+ status: writePassed ? 'passed' : 'failed',
1026
1559
  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.'),
1560
+ code: writePassed ? 'android_async_storage_written' : 'android_async_storage_write_failed',
1561
+ message: writePassed
1562
+ ? `Wrote Android AsyncStorage value ${write.label ?? index + 1}.`
1563
+ : `Failed to write Android AsyncStorage value ${write.label ?? index + 1}.`,
1564
+ ...(!writePassed
1565
+ ? {
1566
+ 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.`),
1567
+ }
1568
+ : {}),
1030
1569
  });
1570
+ if (write.waitMs && write.waitMs > 0) {
1571
+ await wait(write.waitMs);
1572
+ checks.push({
1573
+ name: 'android_async_storage_waited',
1574
+ status: 'passed',
1575
+ source: 'runner',
1576
+ code: 'android_async_storage_waited',
1577
+ message: `Waited ${write.waitMs}ms after Android AsyncStorage write ${write.label ?? index + 1}.`,
1578
+ });
1579
+ }
1031
1580
  }
1032
- else {
1033
- const launchResult = await driver.launchPackage(packageName);
1034
- const launchPassed = launchResult.exitCode === 0;
1035
- raw[launchResult.rawFileName] = formatAndroidAdbRawOutput(launchResult);
1581
+ for (const [index, deepLink] of deepLinks.entries()) {
1582
+ const rawFileName = `adb-deep-link-${index + 1}.txt`;
1583
+ if (!startupReady) {
1584
+ checks.push({
1585
+ name: 'android_deep_link_opened',
1586
+ status: 'failed',
1587
+ source: 'runner',
1588
+ code: 'android_deep_link_skipped_startup_not_ready',
1589
+ message: `Skipped Android deep link ${deepLink.label ?? index + 1} because startup readiness failed.`,
1590
+ metadata: nextActionHint('verify_android_startup_readiness', 'Rerun after Android startup readiness passes; ASL will not deliver profile-session command deep links into an app that has not proven bundle readiness.'),
1591
+ });
1592
+ continue;
1593
+ }
1594
+ const deepLinkResult = await driver.openDeepLink({
1595
+ packageName,
1596
+ rawFileName,
1597
+ url: deepLink.url,
1598
+ });
1599
+ const deepLinkOpened = deepLinkResult.exitCode === 0;
1600
+ raw[deepLinkResult.rawFileName] = formatAndroidAdbRawOutput(deepLinkResult);
1036
1601
  checks.push({
1037
- name: 'android_package_launched',
1038
- status: launchPassed ? 'passed' : 'failed',
1602
+ name: 'android_deep_link_opened',
1603
+ status: deepLinkOpened ? 'passed' : 'failed',
1039
1604
  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
1605
+ code: deepLinkOpened ? 'android_deep_link_opened' : 'android_deep_link_failed',
1606
+ message: deepLinkOpened
1607
+ ? `Opened Android deep link ${deepLink.label ?? index + 1}.`
1608
+ : `Failed to open Android deep link ${deepLink.label ?? index + 1}.`,
1609
+ ...(!deepLinkOpened
1045
1610
  ? {
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.`),
1611
+ 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.`),
1047
1612
  }
1048
1613
  : {}),
1049
1614
  });
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);
1615
+ if (deepLink.waitMs && deepLink.waitMs > 0) {
1616
+ await wait(deepLink.waitMs);
1057
1617
  checks.push({
1058
- name: 'android_launch_waited',
1618
+ name: 'android_deep_link_waited',
1059
1619
  status: 'passed',
1060
1620
  source: 'runner',
1061
- code: 'android_launch_waited',
1062
- message: `Waited ${launchWaitMs}ms after Android package launch.`,
1621
+ code: 'android_deep_link_waited',
1622
+ message: `Waited ${deepLink.waitMs}ms after Android deep link ${deepLink.label ?? index + 1}.`,
1063
1623
  });
1064
- metadata.launchWaitMs = launchWaitMs;
1065
1624
  }
1066
- if (launchPassed) {
1625
+ if (deepLinkOpened && packageName && !lifecyclePackageName) {
1067
1626
  lifecyclePackageName = packageName;
1068
- const pidofAfterLaunch = await executor(adbPath, [
1627
+ const pidofAfterDeepLink = await executor(adbPath, [
1069
1628
  '-s',
1070
1629
  device.serial,
1071
1630
  'shell',
1072
1631
  'pidof',
1073
1632
  packageName,
1074
1633
  ]);
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;
1634
+ const rawPath = 'raw/adb-app-pidof-after-deep-link.txt';
1635
+ raw['adb-app-pidof-after-deep-link.txt'] = formatAndroidCommandRawOutput(pidofAfterDeepLink);
1636
+ const afterDeepLinkPids = parseAndroidPidofOutput(pidofAfterDeepLink.stdout);
1637
+ knownLifecyclePids = afterDeepLinkPids;
1638
+ const runningAfterDeepLink = pidofAfterDeepLink.exitCode === 0 && afterDeepLinkPids.length > 0;
1080
1639
  checks.push({
1081
- name: 'android_app_process_running_after_launch',
1082
- status: runningAfterLaunch ? 'passed' : 'failed',
1640
+ name: 'android_app_process_running_after_deep_link',
1641
+ status: runningAfterDeepLink ? 'passed' : 'failed',
1083
1642
  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
1643
+ code: runningAfterDeepLink
1644
+ ? 'android_app_process_running_after_deep_link'
1645
+ : 'android_app_not_running_after_deep_link',
1646
+ message: runningAfterDeepLink
1647
+ ? `Package ${packageName} is running after deep link with PID ${afterDeepLinkPids.join(', ')}.`
1648
+ : `Package ${packageName} is not running after opening the deep link.`,
1649
+ ...(!runningAfterDeepLink
1091
1650
  ? {
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.`),
1651
+ 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.`),
1093
1652
  }
1094
1653
  : {}),
1095
1654
  });
1096
1655
  Object.assign(appLifecycleMetadata, {
1097
- afterLaunchPids,
1098
- afterLaunchRawPath: rawPath,
1656
+ afterDeepLinkPids,
1657
+ afterDeepLinkRawPath: rawPath,
1099
1658
  });
1100
1659
  }
1101
1660
  }
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.`),
1661
+ const driverActionMetadata = [];
1662
+ const logcatMetadata = [];
1663
+ const selectorResolutionMetadata = [];
1664
+ for (const [index, driverStep] of resolvedDriverSteps.entries()) {
1665
+ if (driverStep.waitMs && driverStep.waitMs > 0) {
1666
+ await wait(driverStep.waitMs);
1667
+ checks.push({
1668
+ name: 'android_capture_window_waited',
1669
+ status: 'passed',
1670
+ source: 'runner',
1671
+ code: 'android_capture_window_waited',
1672
+ message: `Waited ${driverStep.waitMs}ms before running adb driver action ${driverStep.driverAction}.`,
1673
+ metadata: {
1674
+ driverAction: driverStep.driverAction,
1675
+ ...(driverStep.stepId ? { stepId: driverStep.stepId } : {}),
1676
+ },
1677
+ });
1678
+ }
1679
+ let executableDriverStep = driverStep;
1680
+ if (needsAndroidSelectorResolution(driverStep)) {
1681
+ const selectorRawFileName = `adb-selector-tree-${index + 1}.xml`;
1682
+ const treeResult = await driver.inspectTree({ rawFileName: selectorRawFileName });
1683
+ raw[treeResult.rawFileName] = formatAndroidAdbRawOutput(treeResult);
1684
+ const resolution = treeResult.exitCode === 0 && driverStep.selector
1685
+ ? resolveAndroidSelectorFromUiTree({
1686
+ selector: driverStep.selector,
1687
+ uiTreeXml: treeResult.stdout,
1688
+ })
1689
+ : null;
1690
+ const resolved = Boolean(resolution);
1691
+ if (resolution) {
1692
+ executableDriverStep = applyAndroidSelectorResolution({
1693
+ driverStep,
1694
+ resolution,
1695
+ });
1123
1696
  }
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({
1697
+ checks.push({
1698
+ name: 'android_selector_resolved',
1699
+ status: resolved ? 'passed' : driverStep.required === false ? 'warning' : 'failed',
1700
+ source: 'runner',
1701
+ code: resolved ? 'android_selector_resolved' : 'android_selector_resolution_failed',
1702
+ message: resolved
1703
+ ? `Resolved Android selector for adb driver action ${driverStep.driverAction}.`
1704
+ : `Failed to resolve Android selector for adb driver action ${driverStep.driverAction}.`,
1705
+ metadata: {
1706
+ driverAction: driverStep.driverAction,
1707
+ ...buildAndroidSelectorHealthMetadata(driverStep.selector),
1708
+ ...(!resolved
1709
+ ? nextActionHint('fix_android_selector', `Inspect raw/${treeResult.rawFileName}, update the scenario selector, or provide explicit adb coordinates for this driver action.`)
1710
+ : {}),
1711
+ ...(driverStep.stepId ? { stepId: driverStep.stepId } : {}),
1712
+ },
1713
+ });
1714
+ selectorResolutionMetadata.push({
1715
+ ...(resolution ? { bounds: resolution.bounds } : {}),
1716
+ driverAction: driverStep.driverAction,
1717
+ rawPath: `raw/${treeResult.rawFileName}`,
1718
+ ...(driverStep.selector ? { selector: driverStep.selector } : {}),
1719
+ status: resolved ? 'passed' : 'failed',
1720
+ ...(driverStep.stepId ? { stepId: driverStep.stepId } : {}),
1721
+ });
1722
+ }
1723
+ const driverResult = await runAndroidAdbDriverStep({
1724
+ capturesDir: layout.captures,
1139
1725
  driver,
1726
+ driverStep: executableDriverStep,
1140
1727
  logcatLines,
1141
- pattern: deepLink.readyLogPattern,
1142
- quietMs: deepLink.readyLogQuietMs ?? 0,
1143
- rawFileName,
1144
- timeoutMs: deepLink.readyLogTimeoutMs ?? 60000,
1145
- wait,
1146
1728
  });
1147
- raw[rawFileName] = formatAndroidAdbRawOutput(readyLog.result);
1729
+ raw[driverResult.rawFileName] = formatAndroidAdbRawOutput(driverResult);
1730
+ const failed = driverResult.exitCode !== 0;
1731
+ const codeSuffix = androidDriverActionCode(driverStep.driverAction);
1732
+ const isReadLogs = driverStep.driverAction === 'readLogs';
1148
1733
  checks.push({
1149
- name: 'android_startup_deep_link_ready',
1150
- status: readyLog.ready ? 'passed' : 'failed',
1734
+ name: isReadLogs ? 'android_logcat_captured' : `android_${codeSuffix}`,
1735
+ status: failed && driverStep.required === false ? 'warning' : failed ? 'failed' : 'passed',
1151
1736
  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,
1737
+ code: isReadLogs
1738
+ ? driverResult.exitCode === 0 ? 'android_logcat_captured' : 'android_logcat_failed'
1739
+ : driverResult.exitCode === 0 ? `android_${codeSuffix}_completed` : `android_${codeSuffix}_failed`,
1740
+ message: isReadLogs
1741
+ ? driverResult.exitCode === 0
1742
+ ? `Captured the last ${driverStep.lines ?? logcatLines} adb logcat lines.`
1743
+ : 'adb logcat capture failed.'
1744
+ : driverResult.exitCode === 0
1745
+ ? `Completed adb driver action ${driverStep.driverAction}.`
1746
+ : `adb driver action ${driverStep.driverAction} failed.`,
1747
+ metadata: {
1748
+ driverAction: executableDriverStep.driverAction,
1749
+ ...buildAndroidSelectorHealthMetadata(executableDriverStep.selector),
1750
+ ...(failed ? buildAndroidDriverFailureMetadata({ driverResult, isReadLogs }) : {}),
1751
+ ...(executableDriverStep.stepId ? { stepId: executableDriverStep.stepId } : {}),
1752
+ },
1185
1753
  });
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
- }
1754
+ const actionMetadata = {
1755
+ args: driverResult.args,
1756
+ driverAction: executableDriverStep.driverAction,
1757
+ exitCode: driverResult.exitCode,
1758
+ ...(driverResult.capturePath
1759
+ ? { capturePath: `captures/${path.basename(driverResult.capturePath)}` }
1200
1760
  : {}),
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)),
1761
+ rawPath: `raw/${driverResult.rawFileName}`,
1762
+ ...(executableDriverStep.selector ? { selector: executableDriverStep.selector } : {}),
1763
+ ...(executableDriverStep.stepId ? { stepId: executableDriverStep.stepId } : {}),
1208
1764
  };
1765
+ driverActionMetadata.push(actionMetadata);
1766
+ if (executableDriverStep.driverAction === 'readLogs') {
1767
+ logcatMetadata.push(actionMetadata);
1768
+ }
1209
1769
  }
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
- });
1770
+ if (selectorResolutionMetadata.length > 0) {
1771
+ metadata.selectorResolutions = selectorResolutionMetadata;
1241
1772
  }
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
- });
1773
+ if (driverActionMetadata.length > 0) {
1774
+ metadata.driverActions = driverActionMetadata;
1775
+ }
1776
+ if (logcatMetadata.length === 1) {
1777
+ metadata.logcat = logcatMetadata[0];
1275
1778
  }
1276
- if (deepLinkOpened && packageName && !lifecyclePackageName) {
1277
- lifecyclePackageName = packageName;
1278
- const pidofAfterDeepLink = await executor(adbPath, [
1779
+ else if (logcatMetadata.length > 1) {
1780
+ metadata.logcat = logcatMetadata;
1781
+ }
1782
+ if (lifecyclePackageName) {
1783
+ const pidofAfterCapture = await executor(adbPath, [
1279
1784
  '-s',
1280
1785
  device.serial,
1281
1786
  'shell',
1282
1787
  'pidof',
1283
- packageName,
1788
+ lifecyclePackageName,
1284
1789
  ]);
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;
1790
+ const pidofAfterCaptureRawPath = 'raw/adb-app-pidof-after-capture.txt';
1791
+ raw['adb-app-pidof-after-capture.txt'] = formatAndroidCommandRawOutput(pidofAfterCapture);
1792
+ const afterCapturePids = parseAndroidPidofOutput(pidofAfterCapture.stdout);
1793
+ const lifecycleLogLines = Math.max(logcatLines, 200);
1794
+ const lifecycleLog = await driver.readLogs({
1795
+ lines: lifecycleLogLines,
1796
+ rawFileName: 'adb-app-lifecycle-log.txt',
1797
+ });
1798
+ raw[lifecycleLog.rawFileName] = formatAndroidAdbRawOutput(lifecycleLog);
1799
+ const allKnownPids = Array.from(new Set([...knownLifecyclePids, ...afterCapturePids]));
1800
+ const scan = lifecycleLog.exitCode === 0
1801
+ ? scanAndroidAppLifecycleLog({
1802
+ logText: `${lifecycleLog.stdout}\n${lifecycleLog.stderr}`,
1803
+ packageName: lifecyclePackageName,
1804
+ pids: allKnownPids,
1805
+ })
1806
+ : { crashed: false, evidence: [] };
1807
+ const runningAfterCapture = pidofAfterCapture.exitCode === 0 && afterCapturePids.length > 0;
1808
+ const lifecycleStatus = !runningAfterCapture || scan.crashed
1809
+ ? 'failed'
1810
+ : lifecycleLog.exitCode === 0
1811
+ ? 'passed'
1812
+ : 'warning';
1290
1813
  checks.push({
1291
- name: 'android_app_process_running_after_deep_link',
1292
- status: runningAfterDeepLink ? 'passed' : 'failed',
1814
+ name: 'android_app_lifecycle_stable',
1815
+ status: lifecycleStatus,
1293
1816
  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
1817
+ code: !runningAfterCapture
1818
+ ? 'android_app_exited_during_capture'
1819
+ : scan.crashed
1820
+ ? 'android_app_crashed_during_capture'
1821
+ : lifecycleLog.exitCode === 0
1822
+ ? 'android_app_lifecycle_stable'
1823
+ : 'android_app_lifecycle_log_unavailable',
1824
+ message: !runningAfterCapture
1825
+ ? `Package ${lifecyclePackageName} was not running after evidence capture.`
1826
+ : scan.crashed
1827
+ ? `Package ${lifecyclePackageName} emitted crash evidence during capture.`
1828
+ : lifecycleLog.exitCode === 0
1829
+ ? `Package ${lifecyclePackageName} remained running with no crash evidence in the bounded log window.`
1830
+ : `Could not read Android lifecycle logs for package ${lifecyclePackageName}.`,
1831
+ ...(!runningAfterCapture || scan.crashed
1301
1832
  ? {
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.`),
1833
+ metadata: nextActionHint('inspect_android_app_crash', `Inspect raw/${lifecycleLog.rawFileName} and ${pidofAfterCaptureRawPath}; scenario timing evidence is not trustworthy until the app stays alive.`),
1303
1834
  }
1304
- : {}),
1305
- });
1306
- Object.assign(appLifecycleMetadata, {
1307
- afterDeepLinkPids,
1308
- afterDeepLinkRawPath: rawPath,
1835
+ : lifecycleLog.exitCode !== 0
1836
+ ? {
1837
+ metadata: nextActionHint('inspect_android_lifecycle_log', `Inspect raw/${lifecycleLog.rawFileName}; lifecycle log capture failed but the app process was still running.`),
1838
+ }
1839
+ : {}),
1309
1840
  });
1841
+ metadata.appLifecycle = {
1842
+ ...appLifecycleMetadata,
1843
+ afterCapturePids,
1844
+ afterCaptureRawPath: pidofAfterCaptureRawPath,
1845
+ crashEvidence: scan.evidence,
1846
+ lifecycleLogLines,
1847
+ lifecycleLogRawPath: `raw/${lifecycleLog.rawFileName}`,
1848
+ packageName: lifecyclePackageName,
1849
+ };
1310
1850
  }
1311
1851
  }
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);
1852
+ else {
1853
+ if (clearLogcat || launch || startupDeepLinks.length > 0 || storageWrites.length > 0) {
1318
1854
  checks.push({
1319
- name: 'android_capture_window_waited',
1320
- status: 'passed',
1855
+ name: 'android_capture_window_started',
1856
+ status: 'failed',
1321
1857
  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
- },
1858
+ code: 'android_capture_window_no_device',
1859
+ message: 'Android capture window setup was requested, but no online Android device was selected.',
1860
+ 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.'),
1328
1861
  });
1329
1862
  }
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
- }
1863
+ if (resolvedDriverSteps.some((step) => step.driverAction === 'readLogs')) {
1348
1864
  checks.push({
1349
- name: 'android_selector_resolved',
1350
- status: resolved ? 'passed' : driverStep.required === false ? 'warning' : 'failed',
1865
+ name: 'android_logcat_captured',
1866
+ status: 'failed',
1351
1867
  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 } : {}),
1868
+ code: 'android_logcat_no_device',
1869
+ message: 'adb logcat capture was requested, but no online Android device was selected.',
1870
+ metadata: nextActionHint('select_android_device', 'Start or unlock an Android emulator/device before requesting logcat capture.'),
1372
1871
  });
1373
1872
  }
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);
1873
+ if (resolvedDriverSteps.some((step) => step.driverAction !== 'readLogs')) {
1874
+ checks.push({
1875
+ name: 'android_driver_actions_completed',
1876
+ status: 'failed',
1877
+ source: 'runner',
1878
+ code: 'android_driver_actions_no_device',
1879
+ message: 'adb driver actions were requested, but no online Android device was selected.',
1880
+ metadata: nextActionHint('select_android_device', 'Start or unlock an Android emulator/device before running adb driver actions.'),
1881
+ });
1419
1882
  }
1420
1883
  }
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
- }
1884
+ };
1885
+ try {
1886
+ await runAndroidAdbCaptureBodyWithWatchdog({
1887
+ body: runCaptureBody,
1888
+ watchdog: captureWatchdog,
1889
+ });
1502
1890
  }
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
- }
1891
+ catch (error) {
1892
+ const timedOut = isAndroidAdbCaptureWatchdogError(error);
1893
+ const failure = {
1894
+ ...normalizeAndroidRunnerFailure(error),
1895
+ ...(timedOut
1896
+ ? {
1897
+ code: error.code,
1898
+ watchdog: error.watchdog,
1899
+ }
1900
+ : {}),
1901
+ };
1902
+ const rawFileName = timedOut ? 'adb-runner-watchdog-timeout.txt' : 'adb-runner-failure.txt';
1903
+ raw[rawFileName] = formatAndroidRunnerFailureRaw(failure);
1904
+ metadata.runnerFailure = {
1905
+ ...failure,
1906
+ rawPath: `raw/${rawFileName}`,
1907
+ collectedRawArtifacts: Object.keys(raw).map((fileName) => `raw/${fileName}`),
1908
+ };
1909
+ checks.push({
1910
+ name: 'android_adb_capture_liveness',
1911
+ status: 'failed',
1912
+ source: 'runner',
1913
+ code: timedOut ? 'android_adb_runner_liveness_timeout' : 'android_adb_runner_liveness_failure',
1914
+ message: timedOut
1915
+ ? `Android adb capture did not complete before the ${captureWatchdog.timeoutMs}ms watchdog deadline.`
1916
+ : 'Android adb capture stopped before normal artifact finalization.',
1917
+ metadata: {
1918
+ ...nextActionHint(timedOut ? 'inspect_android_adb_runner_timeout' : 'inspect_android_adb_runner_failure', timedOut
1919
+ ? `Inspect raw/${rawFileName}, raw/android-metadata.json, and the last collected raw adb artifact to identify the command or declared wait that exceeded the whole-capture watchdog.`
1920
+ : `Inspect raw/${rawFileName} and raw/android-metadata.json to determine which adb or runner step stopped before finalization, then rerun with a bounded command timeout if the host command stalled.`),
1921
+ rawPath: `raw/${rawFileName}`,
1922
+ },
1923
+ });
1534
1924
  }
1535
1925
  const health = buildAndroidHealth({ runId, checks });
1536
1926
  const verdict = buildAndroidVerdict({ runId, health });
1537
1927
  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,
1928
+ await writeAndroidAdbArtifacts({
1929
+ agentSummary,
1930
+ health,
1931
+ layout,
1932
+ metadata,
1933
+ raw,
1934
+ rawDir,
1935
+ verdict,
1555
1936
  });
1556
1937
  return {
1557
1938
  agentSummary,
@@ -1579,6 +1960,7 @@ async function main() {
1579
1960
  ...(typeof args.adb === 'string' ? { adbPath: args.adb } : {}),
1580
1961
  captureLogcat: args['capture-logcat'] === true || args['capture-logcat'] === 'true',
1581
1962
  clearLogcat: args['clear-logcat'] === true || args['clear-logcat'] === 'true',
1963
+ commandTimeoutMs: parsePositiveInteger(args['command-timeout-ms'], DEFAULT_ADB_COMMAND_TIMEOUT_MS),
1582
1964
  launch: args.launch === true || args.launch === 'true',
1583
1965
  launchWaitMs: parsePositiveInteger(args['launch-wait-ms'], 0),
1584
1966
  logcatLines: parsePositiveInteger(args['logcat-lines'], 1000),