agent-scenario-loop 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -0
  3. package/app/profile-session.ts +812 -0
  4. package/core/config-template.json +41 -0
  5. package/dist/core/agent-summary.d.ts +15 -0
  6. package/dist/core/agent-summary.js +177 -0
  7. package/dist/core/artifact-contract.d.ts +151 -0
  8. package/dist/core/artifact-contract.js +897 -0
  9. package/dist/core/artifact-layout.d.ts +56 -0
  10. package/dist/core/artifact-layout.js +61 -0
  11. package/dist/core/artifact-writer.d.ts +44 -0
  12. package/dist/core/artifact-writer.js +55 -0
  13. package/dist/core/comparison.d.ts +133 -0
  14. package/dist/core/comparison.js +294 -0
  15. package/dist/core/evidence-interpreter.d.ts +28 -0
  16. package/dist/core/evidence-interpreter.js +69 -0
  17. package/dist/core/execution-plan.d.ts +44 -0
  18. package/dist/core/execution-plan.js +95 -0
  19. package/dist/core/planner.d.ts +132 -0
  20. package/dist/core/planner.js +812 -0
  21. package/dist/core/ports.d.ts +198 -0
  22. package/dist/core/ports.js +146 -0
  23. package/dist/core/run-index.d.ts +62 -0
  24. package/dist/core/run-index.js +143 -0
  25. package/dist/core/schema-validator.d.ts +86 -0
  26. package/dist/core/schema-validator.js +407 -0
  27. package/dist/index.d.ts +11 -0
  28. package/dist/index.js +27 -0
  29. package/dist/runner/agent-device-driver.d.ts +126 -0
  30. package/dist/runner/agent-device-driver.js +168 -0
  31. package/dist/runner/agent-device.d.ts +295 -0
  32. package/dist/runner/agent-device.js +1271 -0
  33. package/dist/runner/android-adb-driver.d.ts +175 -0
  34. package/dist/runner/android-adb-driver.js +399 -0
  35. package/dist/runner/android-adb.d.ts +254 -0
  36. package/dist/runner/android-adb.js +1618 -0
  37. package/dist/runner/argent-driver.d.ts +183 -0
  38. package/dist/runner/argent-driver.js +297 -0
  39. package/dist/runner/argent.d.ts +349 -0
  40. package/dist/runner/argent.js +1211 -0
  41. package/dist/runner/check-plan.d.ts +45 -0
  42. package/dist/runner/check-plan.js +210 -0
  43. package/dist/runner/cli.d.ts +20 -0
  44. package/dist/runner/cli.js +23 -0
  45. package/dist/runner/compare-latest.d.ts +99 -0
  46. package/dist/runner/compare-latest.js +233 -0
  47. package/dist/runner/compare.d.ts +58 -0
  48. package/dist/runner/compare.js +157 -0
  49. package/dist/runner/demo-loop.d.ts +45 -0
  50. package/dist/runner/demo-loop.js +170 -0
  51. package/dist/runner/example-android-live.d.ts +137 -0
  52. package/dist/runner/example-android-live.js +454 -0
  53. package/dist/runner/example-ios-live.d.ts +137 -0
  54. package/dist/runner/example-ios-live.js +471 -0
  55. package/dist/runner/host-doctor.d.ts +131 -0
  56. package/dist/runner/host-doctor.js +628 -0
  57. package/dist/runner/init-project.d.ts +88 -0
  58. package/dist/runner/init-project.js +263 -0
  59. package/dist/runner/ios-simctl-driver.d.ts +69 -0
  60. package/dist/runner/ios-simctl-driver.js +97 -0
  61. package/dist/runner/ios-simctl.d.ts +254 -0
  62. package/dist/runner/ios-simctl.js +1415 -0
  63. package/dist/runner/live-android.d.ts +137 -0
  64. package/dist/runner/live-android.js +539 -0
  65. package/dist/runner/live-comparison.d.ts +67 -0
  66. package/dist/runner/live-comparison.js +147 -0
  67. package/dist/runner/live-ios.d.ts +137 -0
  68. package/dist/runner/live-ios.js +460 -0
  69. package/dist/runner/live-proof-summary.d.ts +263 -0
  70. package/dist/runner/live-proof-summary.js +465 -0
  71. package/dist/runner/live-proof.d.ts +467 -0
  72. package/dist/runner/live-proof.js +920 -0
  73. package/dist/runner/local-env.d.ts +64 -0
  74. package/dist/runner/local-env.js +155 -0
  75. package/dist/runner/profile-android.d.ts +82 -0
  76. package/dist/runner/profile-android.js +671 -0
  77. package/dist/runner/profile-ios.d.ts +108 -0
  78. package/dist/runner/profile-ios.js +532 -0
  79. package/dist/runner/profile-mobile.d.ts +254 -0
  80. package/dist/runner/profile-mobile.js +1307 -0
  81. package/dist/runner/validate-project.d.ts +273 -0
  82. package/dist/runner/validate-project.js +1501 -0
  83. package/docs/adapters.md +145 -0
  84. package/docs/api.md +94 -0
  85. package/docs/authoring.md +196 -0
  86. package/docs/concepts.md +136 -0
  87. package/docs/consumer-rehearsal.md +115 -0
  88. package/docs/contracts.md +267 -0
  89. package/docs/live-proofs.md +270 -0
  90. package/docs/principles.md +46 -0
  91. package/examples/event-logs/app-startup-baseline.log +4 -0
  92. package/examples/event-logs/app-startup-current.log +4 -0
  93. package/examples/minimal-app/README.md +70 -0
  94. package/examples/mobile-app/README.md +302 -0
  95. package/examples/mobile-app/app.json +22 -0
  96. package/examples/mobile-app/asl/package-scripts.json +32 -0
  97. package/examples/mobile-app/asl.config.json +37 -0
  98. package/examples/mobile-app/event-logs/android-app-startup.log +4 -0
  99. package/examples/mobile-app/event-logs/android-open-close-cycle.log +12 -0
  100. package/examples/mobile-app/event-logs/android-scroll-settle.log +12 -0
  101. package/examples/mobile-app/event-logs/app-startup.log +4 -0
  102. package/examples/mobile-app/event-logs/open-close-cycle.log +12 -0
  103. package/examples/mobile-app/event-logs/scroll-settle.log +12 -0
  104. package/examples/mobile-app/index.ts +20 -0
  105. package/examples/mobile-app/metro.config.js +20 -0
  106. package/examples/mobile-app/package.json +62 -0
  107. package/examples/mobile-app/patches/expo-modules-jsi@56.0.10.patch +19 -0
  108. package/examples/mobile-app/plugins/with-ios-build-compat.js +271 -0
  109. package/examples/mobile-app/pnpm-lock.yaml +4440 -0
  110. package/examples/mobile-app/runner-manifests/evidence-provider.json +79 -0
  111. package/examples/mobile-app/runner-manifests/primary-runner.json +19 -0
  112. package/examples/mobile-app/scenarios/android/app-startup-video.json +73 -0
  113. package/examples/mobile-app/scenarios/android/app-startup.json +44 -0
  114. package/examples/mobile-app/scenarios/android/open-close-cycle.json +54 -0
  115. package/examples/mobile-app/scenarios/android/scroll-settle.json +49 -0
  116. package/examples/mobile-app/scenarios/ios/app-startup.json +44 -0
  117. package/examples/mobile-app/scenarios/ios/open-close-cycle.json +54 -0
  118. package/examples/mobile-app/scenarios/ios/scroll-settle.json +49 -0
  119. package/examples/mobile-app/scenarios/mobile/app-startup.json +91 -0
  120. package/examples/mobile-app/scenarios/mobile/open-close-cycle.json +160 -0
  121. package/examples/mobile-app/scenarios/mobile/scroll-settle.json +148 -0
  122. package/examples/mobile-app/scripts/asl-capture-accessibility-provider.mjs +112 -0
  123. package/examples/mobile-app/scripts/asl-capture-profiler-provider.mjs +127 -0
  124. package/examples/mobile-app/src/devtools/profile-session.ts +7 -0
  125. package/examples/mobile-app/src/example-screen.tsx +322 -0
  126. package/examples/mobile-app/tsconfig.json +16 -0
  127. package/examples/mobile-app/tsconfig.typecheck.json +13 -0
  128. package/examples/runners/README.md +44 -0
  129. package/examples/runners/adb-android.json +25 -0
  130. package/examples/runners/agent-device-android.json +27 -0
  131. package/examples/runners/agent-device-ios.json +27 -0
  132. package/examples/runners/argent-android.json +32 -0
  133. package/examples/runners/argent-ios.json +32 -0
  134. package/examples/runners/argent-react-profiler-provider.json +15 -0
  135. package/examples/runners/axe-accessibility-provider.json +24 -0
  136. package/examples/runners/manual-log-ingest.json +9 -0
  137. package/examples/runners/rozenite-profiler-provider.json +9 -0
  138. package/examples/runners/script-accessibility-provider.json +24 -0
  139. package/examples/runners/script-memory-provider.json +24 -0
  140. package/examples/runners/script-network-provider.json +24 -0
  141. package/examples/runners/script-profiler-provider.json +30 -0
  142. package/examples/runners/xcodebuildmcp-ios.json +29 -0
  143. package/examples/scenarios/ios/app-startup.json +28 -0
  144. package/examples/scenarios/ios/open-close-cycle.json +35 -0
  145. package/examples/scenarios/mobile/app-startup.json +72 -0
  146. package/examples/scenarios/mobile/media-open-close.json +141 -0
  147. package/examples/scenarios/mobile/open-close-cycle.json +135 -0
  148. package/examples/scenarios/mobile/scroll-settle.json +106 -0
  149. package/package.json +240 -0
  150. package/schemas/budget-verdict.schema.json +115 -0
  151. package/schemas/causal-run.schema.json +279 -0
  152. package/schemas/comparison.schema.json +196 -0
  153. package/schemas/health.schema.json +108 -0
  154. package/schemas/live-proof-set.schema.json +195 -0
  155. package/schemas/live-proof.schema.json +413 -0
  156. package/schemas/manifest.schema.json +204 -0
  157. package/schemas/metrics.schema.json +137 -0
  158. package/schemas/project-validation.schema.json +343 -0
  159. package/schemas/runner-capabilities.schema.json +217 -0
  160. package/schemas/scenario.schema.json +400 -0
  161. package/schemas/verdict.schema.json +88 -0
  162. package/templates/evidence-provider.json +83 -0
  163. package/templates/gitignore-snippet +9 -0
  164. package/templates/integration-readme.md +125 -0
  165. package/templates/mobile-scenario.json +133 -0
  166. package/templates/package-scripts.json +32 -0
  167. package/templates/primary-runner.json +19 -0
  168. package/templates/project.config.json +37 -0
  169. package/templates/scripts/asl-capture-accessibility-provider.mjs +112 -0
  170. package/templates/scripts/asl-capture-profiler-provider.mjs +127 -0
@@ -0,0 +1,671 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.usage = exports.parseArgs = void 0;
5
+ exports.deriveProfileSessionCaptureWaitMs = deriveProfileSessionCaptureWaitMs;
6
+ exports.main = main;
7
+ exports.resolveAndroidAdbProfileCommands = resolveAndroidAdbProfileCommands;
8
+ exports.resolveAndroidAdbDriverSteps = resolveAndroidAdbDriverSteps;
9
+ exports.resolveProfileSessionCaptureWaitMs = resolveProfileSessionCaptureWaitMs;
10
+ exports.readAndroidAdbVideoCapturePath = readAndroidAdbVideoCapturePath;
11
+ exports.validateAndroidAdbDriverSteps = validateAndroidAdbDriverSteps;
12
+ exports.runProfileAndroid = runProfileAndroid;
13
+ exports.summarizeFailedAndroidChecks = summarizeFailedAndroidChecks;
14
+ const crypto = require('node:crypto');
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+ const { hasHelpFlag } = require('./cli');
18
+ const { ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER, parsePositiveInteger, runAndroidAdbPreflight, } = require('./android-adb');
19
+ const { parseArgs, readScalarArg, runProfileMobile, usage, } = require('./profile-mobile');
20
+ exports.parseArgs = parseArgs;
21
+ exports.usage = usage;
22
+ const { buildScenarioExecutionPlan } = require('../core/execution-plan');
23
+ const { runAgentDeviceCapture } = require('./agent-device');
24
+ const { loadAslLocalEnv, readStringArgOrEnv } = require('./local-env');
25
+ const PROFILE_SESSION_CAPTURE_BOOTSTRAP_MS = 1000;
26
+ const PROFILE_SESSION_CAPTURE_MAX_MS = 120000;
27
+ const DEFAULT_ANDROID_PROFILE_SESSION_STORAGE_KEY = 'agent-scenario-loop.profile-session.1';
28
+ const DEFAULT_ANDROID_PROFILE_COMMAND_STORAGE_KEY = 'agent-scenario-loop.profile-commands.1';
29
+ /**
30
+ * Reads and parses a JSON object from disk.
31
+ *
32
+ * @param {string} filePath
33
+ * @returns {Record<string, unknown>}
34
+ */
35
+ function readJson(filePath) {
36
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
37
+ }
38
+ /**
39
+ * Checks whether a boolean-style CLI flag is enabled.
40
+ *
41
+ * @param {string | boolean | undefined} value
42
+ * @returns {boolean}
43
+ */
44
+ function isEnabled(value) {
45
+ return value === true || value === 'true';
46
+ }
47
+ /**
48
+ * Reads a positive integer from unknown scenario adapter metadata.
49
+ *
50
+ * @param {unknown} value
51
+ * @param {number} fallback
52
+ * @returns {number}
53
+ */
54
+ function readPositiveInteger(value, fallback) {
55
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback;
56
+ }
57
+ /**
58
+ * Reads the number of scenario iterations that can emit app-owned truth events.
59
+ *
60
+ * @param {Record<string, unknown>} scenario
61
+ * @returns {number}
62
+ */
63
+ function readScenarioIterationCount(scenario) {
64
+ return readPositiveInteger(scenario.defaultIterations, readPositiveInteger(scenario.cycles?.iterations, 1));
65
+ }
66
+ /**
67
+ * Creates a short run id when adb capture must share the id with profile artifacts.
68
+ *
69
+ * @returns {string}
70
+ */
71
+ function createRunId() {
72
+ return crypto.randomBytes(6).toString('hex');
73
+ }
74
+ /**
75
+ * Resolves the adb capture output directory for a profile run.
76
+ *
77
+ * @param {{args: import('./profile-mobile').CliArgs, runId: string}} options
78
+ * @returns {string}
79
+ */
80
+ function resolveAdbCaptureOutputDir({ args, runId, }) {
81
+ if (typeof args['adb-out'] === 'string') {
82
+ return path.resolve(args['adb-out']);
83
+ }
84
+ if (typeof args.out === 'string') {
85
+ return path.resolve(args.out, '_adb-captures', runId);
86
+ }
87
+ return path.resolve('artifacts/android-adb-captures', runId);
88
+ }
89
+ /**
90
+ * Resolves the agent-device capture output directory for a profile run.
91
+ *
92
+ * @param {{args: import('./profile-mobile').CliArgs, runId: string}} options
93
+ * @returns {string}
94
+ */
95
+ function resolveAgentDeviceCaptureOutputDir({ args, runId, }) {
96
+ if (typeof args['agent-device-out'] === 'string') {
97
+ return path.resolve(args['agent-device-out']);
98
+ }
99
+ if (typeof args.out === 'string') {
100
+ return path.resolve(args.out, '_agent-device-captures', runId);
101
+ }
102
+ return path.resolve('artifacts/agent-device-captures', runId);
103
+ }
104
+ /**
105
+ * Resolves the Android package name from explicit CLI input or project config.
106
+ *
107
+ * @param {{args: import('./profile-mobile').CliArgs, config: Record<string, unknown>}} options
108
+ * @returns {string | null}
109
+ */
110
+ function resolveAndroidPackageName({ args, config, }) {
111
+ if (typeof args.package === 'string') {
112
+ return args.package;
113
+ }
114
+ return typeof config.app?.androidPackage === 'string' ? config.app.androidPackage : null;
115
+ }
116
+ /**
117
+ * Builds a profile-session deep link for the example app or another configured app.
118
+ *
119
+ * @param {{config: Record<string, unknown>, action: 'start' | 'command', scenario: string, runId: string, command?: string}} options
120
+ * @returns {string}
121
+ */
122
+ function buildProfileSessionUrl({ action, command, config, runId, scenario, }) {
123
+ const scheme = typeof config.app?.profileSessionScheme === 'string'
124
+ ? config.app.profileSessionScheme
125
+ : typeof config.app?.scheme === 'string'
126
+ ? config.app.scheme
127
+ : 'app';
128
+ const params = new URLSearchParams({ runId, scenario });
129
+ if (action === 'command' && command) {
130
+ params.set('command', command);
131
+ }
132
+ return `${scheme}://profile-session/${action}?${params.toString()}`;
133
+ }
134
+ /**
135
+ * Builds Android AsyncStorage writes for one profile-session run.
136
+ *
137
+ * @param {{commands: AndroidAdbProfileCommand[], commandStorageKey: string, commandWaitMs: number, runId: string, scenario: string, sessionStorageKey: string}} options
138
+ * @returns {import('./android-adb').AndroidAsyncStorageWrite[]}
139
+ */
140
+ function buildProfileSessionStorageWrites({ commands, commandStorageKey, commandWaitMs, runId, scenario, sessionStorageKey, }) {
141
+ return [
142
+ {
143
+ clearKeys: [commandStorageKey],
144
+ key: sessionStorageKey,
145
+ label: 'profile-session-start',
146
+ value: JSON.stringify({
147
+ active: true,
148
+ scenario,
149
+ runId,
150
+ startedAt: ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER,
151
+ }).replace(`"${ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER}"`, ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER),
152
+ waitMs: commandWaitMs,
153
+ },
154
+ ...commands.map((profileCommand, index) => {
155
+ const timestamp = Date.now() + index + 1;
156
+ return {
157
+ key: commandStorageKey,
158
+ label: profileCommand.label ?? `profile-command-${index + 1}`,
159
+ value: JSON.stringify([{
160
+ id: `${timestamp}-${scenario}-${profileCommand.command}`,
161
+ scenario,
162
+ runId,
163
+ command: profileCommand.command,
164
+ timestamp,
165
+ }]),
166
+ ...(typeof profileCommand.waitMs === 'number' ? { waitMs: profileCommand.waitMs } : {}),
167
+ };
168
+ }),
169
+ ];
170
+ }
171
+ /**
172
+ * Reads Android-specific wait metadata from a normalized execution step.
173
+ *
174
+ * @param {import('../core/execution-plan').ScenarioExecutionStep} step
175
+ * @returns {number}
176
+ */
177
+ function readStepWaitMs(step) {
178
+ const androidAdbOptions = step.adapterOptions?.androidAdb;
179
+ if (androidAdbOptions && typeof androidAdbOptions === 'object' && !Array.isArray(androidAdbOptions)) {
180
+ const waitMs = androidAdbOptions.waitMs;
181
+ if (typeof waitMs === 'number' && Number.isInteger(waitMs) && waitMs > 0) {
182
+ return waitMs;
183
+ }
184
+ }
185
+ return readPositiveInteger(step.timeoutMs, 0);
186
+ }
187
+ /**
188
+ * Derives a logcat-backed profile capture window from scenario waits and cycles.
189
+ *
190
+ * @param {Record<string, unknown>} scenario
191
+ * @returns {number}
192
+ */
193
+ function deriveProfileSessionCaptureWaitMs(scenario) {
194
+ const executionPlan = buildScenarioExecutionPlan(scenario);
195
+ const iterations = readScenarioIterationCount(scenario);
196
+ const perIterationWaitMs = executionPlan.steps.reduce((total, step) => {
197
+ if (step.kind === 'command') {
198
+ return total + readStepWaitMs(step);
199
+ }
200
+ if (step.portMethod === 'waitForTruthEvent') {
201
+ return total + readPositiveInteger(step.timeoutMs, 0);
202
+ }
203
+ return total;
204
+ }, 0);
205
+ const derivedWaitMs = PROFILE_SESSION_CAPTURE_BOOTSTRAP_MS + (perIterationWaitMs * iterations);
206
+ return Math.min(Math.max(derivedWaitMs, PROFILE_SESSION_CAPTURE_BOOTSTRAP_MS), PROFILE_SESSION_CAPTURE_MAX_MS);
207
+ }
208
+ /**
209
+ * Resolves the Android adb capture wait, keeping explicit CLI waits authoritative.
210
+ *
211
+ * @param {{args: import('./profile-mobile').CliArgs, scenario: Record<string, unknown>, profileSessionEnabled: boolean}} options
212
+ * @returns {number}
213
+ */
214
+ function resolveProfileSessionCaptureWaitMs({ args, profileSessionEnabled, scenario, }) {
215
+ const explicitWaitMs = readScalarArg(args['wait-ms']);
216
+ if (explicitWaitMs !== undefined) {
217
+ return parsePositiveInteger(explicitWaitMs, 0);
218
+ }
219
+ return profileSessionEnabled ? deriveProfileSessionCaptureWaitMs(scenario) : 0;
220
+ }
221
+ /**
222
+ * Reads Android adb adapter metadata from a normalized scenario step.
223
+ *
224
+ * @param {import('../core/execution-plan').ScenarioExecutionStep} step
225
+ * @returns {Record<string, unknown>}
226
+ */
227
+ function readAndroidAdbStepOptions(step) {
228
+ const androidAdbOptions = step.adapterOptions?.androidAdb;
229
+ return androidAdbOptions && typeof androidAdbOptions === 'object' && !Array.isArray(androidAdbOptions)
230
+ ? androidAdbOptions
231
+ : {};
232
+ }
233
+ /**
234
+ * Reads a finite number from Android adb adapter metadata.
235
+ *
236
+ * @param {unknown} value
237
+ * @returns {number | undefined}
238
+ */
239
+ function readFiniteNumber(value) {
240
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
241
+ }
242
+ /**
243
+ * Returns true when a normalized execution step has a portable selector adb can try to resolve.
244
+ *
245
+ * @param {unknown} value
246
+ * @returns {value is import('./android-adb-driver').AndroidSelector}
247
+ */
248
+ function isAndroidSelector(value) {
249
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
250
+ return false;
251
+ }
252
+ const selector = value;
253
+ return typeof selector.kind === 'string' && typeof selector.value === 'string' && selector.value.length > 0;
254
+ }
255
+ /**
256
+ * Appends one repeatable profile capture argument without losing caller-provided values.
257
+ *
258
+ * @param {{args: import('./profile-mobile').CliArgs, value: string}} options
259
+ * @returns {string | boolean | Array<string | boolean>}
260
+ */
261
+ function appendCaptureArg({ args, value, }) {
262
+ const existing = args.capture;
263
+ return existing === undefined ? value : Array.isArray(existing) ? [...existing, value] : [existing, value];
264
+ }
265
+ /**
266
+ * Expands portable scenario command steps into Android profile-session commands.
267
+ *
268
+ * @param {Record<string, unknown>} scenario
269
+ * @returns {AndroidAdbProfileCommand[]}
270
+ */
271
+ function resolveExecutionPlanProfileCommands(scenario) {
272
+ const executionPlan = buildScenarioExecutionPlan(scenario);
273
+ const repeat = readPositiveInteger(scenario.defaultIterations, readPositiveInteger(scenario.cycles?.iterations, 1));
274
+ const commands = executionPlan.steps
275
+ .filter((step) => step.portMethod === 'executeStep' && typeof step.command === 'string')
276
+ .map((step) => ({
277
+ command: step.command,
278
+ label: step.id,
279
+ waitMs: readStepWaitMs(step),
280
+ }));
281
+ return Array.from({ length: repeat }).flatMap(() => commands);
282
+ }
283
+ /**
284
+ * Expands normalized scenario evidence steps into Android adb driver actions.
285
+ *
286
+ * @param {Record<string, unknown>} scenario
287
+ * @returns {import('./android-adb').AndroidAdbDriverStep[]}
288
+ */
289
+ function resolveAndroidAdbDriverSteps(scenario) {
290
+ const executionPlan = buildScenarioExecutionPlan(scenario);
291
+ let readLogsIndex = 0;
292
+ return executionPlan.steps
293
+ .filter((step) => ['assertVisible', 'inspectTree', 'readLogs', 'record', 'screenshot', 'scroll', 'tap'].includes(String(step.driverAction)))
294
+ .map((step) => {
295
+ const androidAdbOptions = readAndroidAdbStepOptions(step);
296
+ if (step.driverAction === 'readLogs') {
297
+ readLogsIndex += 1;
298
+ }
299
+ const rawFileName = typeof androidAdbOptions.rawFileName === 'string' && androidAdbOptions.rawFileName.length > 0
300
+ ? androidAdbOptions.rawFileName
301
+ : step.driverAction === 'readLogs'
302
+ ? readLogsIndex === 1
303
+ ? 'adb-logcat.txt'
304
+ : `adb-logcat-${readLogsIndex}.txt`
305
+ : undefined;
306
+ return {
307
+ driverAction: step.driverAction,
308
+ ...(typeof androidAdbOptions.captureFileName === 'string' && androidAdbOptions.captureFileName.length > 0
309
+ ? { captureFileName: androidAdbOptions.captureFileName }
310
+ : {}),
311
+ ...(typeof readFiniteNumber(androidAdbOptions.durationSeconds) === 'number'
312
+ ? { durationSeconds: readFiniteNumber(androidAdbOptions.durationSeconds) }
313
+ : {}),
314
+ ...(step.driverAction === 'readLogs' ? { lines: readPositiveInteger(androidAdbOptions.logcatLines, 1000) } : {}),
315
+ ...(typeof rawFileName === 'string' ? { rawFileName } : {}),
316
+ required: step.required,
317
+ ...(isAndroidSelector(step.selector) ? { selector: step.selector } : {}),
318
+ stepId: step.id,
319
+ ...(typeof readFiniteNumber(androidAdbOptions.durationMs) === 'number'
320
+ ? { durationMs: readFiniteNumber(androidAdbOptions.durationMs) }
321
+ : {}),
322
+ ...(typeof readFiniteNumber(androidAdbOptions.endX) === 'number' ? { endX: readFiniteNumber(androidAdbOptions.endX) } : {}),
323
+ ...(typeof readFiniteNumber(androidAdbOptions.endY) === 'number' ? { endY: readFiniteNumber(androidAdbOptions.endY) } : {}),
324
+ ...(typeof readFiniteNumber(androidAdbOptions.startX) === 'number' ? { startX: readFiniteNumber(androidAdbOptions.startX) } : {}),
325
+ ...(typeof readFiniteNumber(androidAdbOptions.startY) === 'number' ? { startY: readFiniteNumber(androidAdbOptions.startY) } : {}),
326
+ ...(typeof androidAdbOptions.remotePath === 'string' && androidAdbOptions.remotePath.length > 0
327
+ ? { remotePath: androidAdbOptions.remotePath }
328
+ : {}),
329
+ waitMs: readStepWaitMs(step),
330
+ ...(typeof readFiniteNumber(androidAdbOptions.x) === 'number' ? { x: readFiniteNumber(androidAdbOptions.x) } : {}),
331
+ ...(typeof readFiniteNumber(androidAdbOptions.y) === 'number' ? { y: readFiniteNumber(androidAdbOptions.y) } : {}),
332
+ };
333
+ });
334
+ }
335
+ /**
336
+ * Reads the first video capture produced by adb driver actions.
337
+ *
338
+ * @param {Record<string, unknown>} metadata
339
+ * @returns {string | null}
340
+ */
341
+ function readAndroidAdbVideoCapturePath(metadata) {
342
+ const actions = Array.isArray(metadata.driverActions) ? metadata.driverActions : [];
343
+ const recordAction = actions.find((action) => action.driverAction === 'record' &&
344
+ action.exitCode === 0 &&
345
+ typeof action.capturePath === 'string');
346
+ return typeof recordAction?.capturePath === 'string' ? recordAction.capturePath : null;
347
+ }
348
+ /**
349
+ * Returns profile-time validation errors for adb driver steps.
350
+ *
351
+ * @param {import('./android-adb').AndroidAdbDriverStep[]} driverSteps
352
+ * @returns {string[]}
353
+ */
354
+ function validateAndroidAdbDriverSteps(driverSteps) {
355
+ const errors = [];
356
+ for (const step of driverSteps) {
357
+ const stepLabel = step.stepId ? `step \`${step.stepId}\`` : 'unnamed step';
358
+ if (step.driverAction === 'tap' && !step.selector && (typeof step.x !== 'number' || typeof step.y !== 'number')) {
359
+ errors.push(`${stepLabel} uses driverAction \`tap\` but is missing adapterOptions.androidAdb.x/y.`);
360
+ }
361
+ if (step.driverAction === 'assertVisible' && !step.selector) {
362
+ errors.push(`${stepLabel} uses driverAction \`assertVisible\` but is missing a portable selector.`);
363
+ }
364
+ if (step.driverAction === 'scroll' &&
365
+ !step.selector &&
366
+ (typeof step.startX !== 'number' ||
367
+ typeof step.startY !== 'number' ||
368
+ typeof step.endX !== 'number' ||
369
+ typeof step.endY !== 'number')) {
370
+ errors.push(`${stepLabel} uses driverAction \`scroll\` but is missing adapterOptions.androidAdb.startX/startY/endX/endY.`);
371
+ }
372
+ }
373
+ return errors;
374
+ }
375
+ /**
376
+ * Expands scenario-declared Android commands for an adb capture profile session.
377
+ *
378
+ * @param {Record<string, unknown>} scenario
379
+ * @returns {AndroidAdbProfileCommand[]}
380
+ */
381
+ function resolveAndroidAdbProfileCommands(scenario) {
382
+ const androidAdbOptions = scenario.adapterOptions?.androidAdb;
383
+ if (!androidAdbOptions || !Array.isArray(androidAdbOptions.commands)) {
384
+ return resolveExecutionPlanProfileCommands(scenario);
385
+ }
386
+ const repeat = readPositiveInteger(androidAdbOptions.repeat, readPositiveInteger(scenario.defaultIterations, 1));
387
+ const commands = [];
388
+ for (let iteration = 0; iteration < repeat; iteration += 1) {
389
+ for (const command of androidAdbOptions.commands) {
390
+ if (!command || typeof command.command !== 'string') {
391
+ continue;
392
+ }
393
+ commands.push({
394
+ command: command.command,
395
+ ...(typeof command.label === 'string' ? { label: command.label } : {}),
396
+ waitMs: readPositiveInteger(command.waitMs, 0),
397
+ });
398
+ }
399
+ }
400
+ return commands;
401
+ }
402
+ /**
403
+ * Summarizes failed adb capture checks for CLI errors.
404
+ *
405
+ * @param {Record<string, unknown>} health
406
+ * @returns {string}
407
+ */
408
+ function summarizeFailedAndroidChecks(health) {
409
+ const checks = Array.isArray(health.checks) ? health.checks : [];
410
+ const failedChecks = checks
411
+ .filter((check) => check?.status === 'failed')
412
+ .map((check) => (typeof check.message === 'string'
413
+ ? check.message
414
+ : typeof check.code === 'string'
415
+ ? check.code
416
+ : 'unknown failure'));
417
+ return failedChecks.length > 0 ? ` Failed checks: ${failedChecks.join(' ')}` : '';
418
+ }
419
+ /**
420
+ * Summarizes failed agent-device checks for CLI errors.
421
+ *
422
+ * @param {Record<string, unknown>} health
423
+ * @returns {string}
424
+ */
425
+ function summarizeFailedAgentDeviceChecks(health) {
426
+ const checks = Array.isArray(health.checks) ? health.checks : [];
427
+ const failedChecks = checks
428
+ .filter((check) => check?.status === 'failed')
429
+ .map((check) => (typeof check.message === 'string'
430
+ ? check.message
431
+ : typeof check.code === 'string'
432
+ ? check.code
433
+ : 'unknown failure'));
434
+ return failedChecks.length > 0 ? ` Failed checks: ${failedChecks.join(' ')}` : '';
435
+ }
436
+ /**
437
+ * Appends screenshots from an agent-device capture as profile capture inputs.
438
+ *
439
+ * @param {{args: import('./profile-mobile').CliArgs, capture: import('./agent-device').AgentDeviceCaptureResult}} options
440
+ * @returns {import('./profile-mobile').CliArgs}
441
+ */
442
+ function appendAgentDeviceCaptureArgs({ args, capture, }) {
443
+ let captureArg = args.capture;
444
+ for (const screenshot of capture.captures.screenshots) {
445
+ captureArg = appendCaptureArg({
446
+ args: captureArg === undefined ? {} : { capture: captureArg },
447
+ value: `screenshot:${path.join(capture.runDir, screenshot)}`,
448
+ });
449
+ }
450
+ return captureArg === undefined ? args : { ...args, capture: captureArg };
451
+ }
452
+ /**
453
+ * Runs the Android profile artifact pipeline.
454
+ *
455
+ * @param {import('./profile-mobile').CliArgs} args
456
+ * @param {AndroidProfileOptions} [options]
457
+ * @returns {Promise<import('./profile-mobile').ProfileRunResult>}
458
+ */
459
+ async function runProfileAndroid(args, options = {}) {
460
+ if (!isEnabled(args['adb-capture']) && !isEnabled(args['agent-device-capture'])) {
461
+ return runProfileMobile(args, {
462
+ ...(options.comparisonLane ? { comparisonLane: options.comparisonLane } : {}),
463
+ defaultDriver: 'adb-logcat',
464
+ ...(typeof args['adb-artifacts'] === 'string' ? { interactionDriver: 'adb-logcat' } : {}),
465
+ platform: 'android',
466
+ });
467
+ }
468
+ if (typeof args.config !== 'string' || typeof args.scenario !== 'string') {
469
+ throw new Error('Both --config and --scenario are required.');
470
+ }
471
+ const config = readJson(path.resolve(args.config));
472
+ const scenario = readJson(path.resolve(args.scenario));
473
+ const runId = typeof args['run-id'] === 'string' ? args['run-id'] : createRunId();
474
+ const adbCaptureEnabled = isEnabled(args['adb-capture']);
475
+ const agentDeviceCaptureEnabled = isEnabled(args['agent-device-capture']);
476
+ const profileSessionEnabled = isEnabled(args['profile-session']);
477
+ const profileSessionStorageEnabled = isEnabled(args['android-profile-session-storage']);
478
+ const profileSessionStorageKey = readStringArgOrEnv(args['android-profile-session-storage-key'], [
479
+ 'ASL_ANDROID_PROFILE_SESSION_STORAGE_KEY',
480
+ 'ASL_EXAMPLE_ANDROID_PROFILE_SESSION_STORAGE_KEY',
481
+ ]) ?? DEFAULT_ANDROID_PROFILE_SESSION_STORAGE_KEY;
482
+ const profileCommandStorageKey = readStringArgOrEnv(args['android-profile-command-storage-key'], [
483
+ 'ASL_ANDROID_PROFILE_COMMAND_STORAGE_KEY',
484
+ 'ASL_EXAMPLE_ANDROID_PROFILE_COMMAND_STORAGE_KEY',
485
+ ]) ?? DEFAULT_ANDROID_PROFILE_COMMAND_STORAGE_KEY;
486
+ const androidDevClientUrl = readStringArgOrEnv(args['android-dev-client-url'], [
487
+ 'ASL_ANDROID_DEV_CLIENT_URL',
488
+ 'ASL_EXAMPLE_ANDROID_DEV_CLIENT_URL',
489
+ ]);
490
+ const androidDevClientWaitMs = parsePositiveInteger(readStringArgOrEnv(args['android-dev-client-wait-ms'], [
491
+ 'ASL_ANDROID_DEV_CLIENT_WAIT_MS',
492
+ 'ASL_EXAMPLE_ANDROID_DEV_CLIENT_WAIT_MS',
493
+ ]), 1000);
494
+ const androidDevClientReadyPattern = readStringArgOrEnv(args['android-dev-client-ready-pattern'], [
495
+ 'ASL_ANDROID_DEV_CLIENT_READY_PATTERN',
496
+ 'ASL_EXAMPLE_ANDROID_DEV_CLIENT_READY_PATTERN',
497
+ ]);
498
+ const androidDevClientReadyQuietMs = parsePositiveInteger(readStringArgOrEnv(args['android-dev-client-ready-quiet-ms'], [
499
+ 'ASL_ANDROID_DEV_CLIENT_READY_QUIET_MS',
500
+ 'ASL_EXAMPLE_ANDROID_DEV_CLIENT_READY_QUIET_MS',
501
+ ]), 0);
502
+ const androidDevClientReadyTimeoutMs = parsePositiveInteger(readStringArgOrEnv(args['android-dev-client-ready-timeout-ms'], [
503
+ 'ASL_ANDROID_DEV_CLIENT_READY_TIMEOUT_MS',
504
+ 'ASL_EXAMPLE_ANDROID_DEV_CLIENT_READY_TIMEOUT_MS',
505
+ ]), 60000);
506
+ const scenarioName = typeof scenario.name === 'string' ? scenario.name : path.basename(args.scenario, '.json');
507
+ const driverSteps = adbCaptureEnabled ? resolveAndroidAdbDriverSteps(scenario) : [];
508
+ if (adbCaptureEnabled) {
509
+ const driverStepErrors = validateAndroidAdbDriverSteps(driverSteps);
510
+ if (driverStepErrors.length > 0) {
511
+ throw new Error(`Invalid Android adb driver step metadata: ${driverStepErrors.join(' ')}`);
512
+ }
513
+ }
514
+ const profileSessionCommands = profileSessionEnabled ? resolveAndroidAdbProfileCommands(scenario) : [];
515
+ const commandWaitMs = parsePositiveInteger(readScalarArg(args['command-wait-ms']), 250);
516
+ const profileSessionDeepLinks = profileSessionEnabled && !profileSessionStorageEnabled
517
+ ? [
518
+ {
519
+ label: 'profile-session-start',
520
+ url: buildProfileSessionUrl({
521
+ action: 'start',
522
+ config,
523
+ runId,
524
+ scenario: scenarioName,
525
+ }),
526
+ waitMs: commandWaitMs,
527
+ },
528
+ ...profileSessionCommands.map((profileCommand, index) => ({
529
+ label: profileCommand.label ?? `profile-command-${index + 1}`,
530
+ url: buildProfileSessionUrl({
531
+ action: 'command',
532
+ command: profileCommand.command,
533
+ config,
534
+ runId,
535
+ scenario: scenarioName,
536
+ }),
537
+ waitMs: profileCommand.waitMs,
538
+ })),
539
+ ]
540
+ : [];
541
+ const profileSessionStorageWrites = profileSessionEnabled && profileSessionStorageEnabled
542
+ ? buildProfileSessionStorageWrites({
543
+ commandStorageKey: profileCommandStorageKey,
544
+ commandWaitMs,
545
+ commands: profileSessionCommands,
546
+ runId,
547
+ scenario: scenarioName,
548
+ sessionStorageKey: profileSessionStorageKey,
549
+ })
550
+ : [];
551
+ const startupDeepLinks = androidDevClientUrl
552
+ ? [
553
+ {
554
+ label: 'android-dev-client-url',
555
+ ...(androidDevClientReadyPattern ? { readyLogPattern: androidDevClientReadyPattern } : {}),
556
+ readyLogQuietMs: androidDevClientReadyQuietMs,
557
+ readyLogTimeoutMs: androidDevClientReadyTimeoutMs,
558
+ url: androidDevClientUrl,
559
+ waitMs: androidDevClientWaitMs,
560
+ },
561
+ ]
562
+ : [];
563
+ const adbCapture = adbCaptureEnabled
564
+ ? await runAndroidAdbPreflight({
565
+ ...(typeof args.adb === 'string' ? { adbPath: args.adb } : {}),
566
+ captureLogcat: true,
567
+ clearLogcat: isEnabled(args['clear-logcat']),
568
+ deepLinks: profileSessionDeepLinks,
569
+ ...(options.delay ? { delay: options.delay } : {}),
570
+ ...(options.executor ? { executor: options.executor } : {}),
571
+ driverSteps,
572
+ launch: isEnabled(args.launch),
573
+ launchWaitMs: parsePositiveInteger(readScalarArg(args['launch-wait-ms']), 0),
574
+ logcatLines: parsePositiveInteger(readScalarArg(args['logcat-lines']), 1000),
575
+ outputDir: resolveAdbCaptureOutputDir({ args, runId }),
576
+ packageName: resolveAndroidPackageName({ args, config }),
577
+ ...(typeof args['react-native-debug-host'] === 'string'
578
+ ? { reactNativeDebugHost: args['react-native-debug-host'] }
579
+ : {}),
580
+ runId,
581
+ ...(typeof args.serial === 'string' ? { serial: args.serial } : {}),
582
+ startupDeepLinks,
583
+ storageWrites: profileSessionStorageWrites,
584
+ waitMs: resolveProfileSessionCaptureWaitMs({
585
+ args,
586
+ profileSessionEnabled,
587
+ scenario,
588
+ }),
589
+ })
590
+ : null;
591
+ if (adbCapture && adbCapture.health.healthStatus !== 'passed') {
592
+ throw new Error(`Android adb capture failed; inspect ${adbCapture.runDir}/agent-summary.md.${summarizeFailedAndroidChecks(adbCapture.health)}`);
593
+ }
594
+ const agentDeviceCapture = agentDeviceCaptureEnabled
595
+ ? await runAgentDeviceCapture({
596
+ ...(typeof args['agent-device'] === 'string' ? { agentDevicePath: args['agent-device'] } : {}),
597
+ app: typeof args['agent-device-app'] === 'string'
598
+ ? args['agent-device-app']
599
+ : resolveAndroidPackageName({ args, config }),
600
+ ...(options.agentDeviceExecutor ? { executor: options.agentDeviceExecutor } : {}),
601
+ ...(typeof args['agent-device-device'] === 'string' ? { device: args['agent-device-device'] } : {}),
602
+ ...(typeof args['agent-device-session'] === 'string' ? { session: args['agent-device-session'] } : {}),
603
+ ...(typeof args['agent-device-session-mode'] === 'string'
604
+ ? { sessionMode: args['agent-device-session-mode'] }
605
+ : {}),
606
+ ...(typeof args.serial === 'string' ? { serial: args.serial } : {}),
607
+ open: isEnabled(args['agent-device-open']),
608
+ outputDir: resolveAgentDeviceCaptureOutputDir({ args, runId }),
609
+ platform: 'android',
610
+ runId,
611
+ scenario,
612
+ waitMs: parsePositiveInteger(readScalarArg(args['agent-device-wait-ms']), 0),
613
+ })
614
+ : null;
615
+ if (agentDeviceCapture && agentDeviceCapture.health.healthStatus !== 'passed') {
616
+ throw new Error(`agent-device capture failed; inspect ${agentDeviceCapture.runDir}/agent-summary.md.${summarizeFailedAgentDeviceChecks(agentDeviceCapture.health)}`);
617
+ }
618
+ const videoCapturePath = adbCapture ? readAndroidAdbVideoCapturePath(adbCapture.metadata) : null;
619
+ const baseProfileArgs = {
620
+ ...args,
621
+ ...(adbCapture ? { 'adb-artifacts': adbCapture.runDir } : {}),
622
+ ...(videoCapturePath
623
+ ? {
624
+ capture: appendCaptureArg({
625
+ args,
626
+ value: `video:${path.join(adbCapture?.runDir ?? '', videoCapturePath)}`,
627
+ }),
628
+ }
629
+ : {}),
630
+ 'run-id': runId,
631
+ };
632
+ if (adbCapture) {
633
+ delete baseProfileArgs.events;
634
+ }
635
+ const profileArgs = agentDeviceCapture
636
+ ? appendAgentDeviceCaptureArgs({ args: baseProfileArgs, capture: agentDeviceCapture })
637
+ : baseProfileArgs;
638
+ return runProfileMobile(profileArgs, {
639
+ ...(options.comparisonLane ? { comparisonLane: options.comparisonLane } : {}),
640
+ defaultDriver: 'adb-logcat',
641
+ interactionDriver: agentDeviceCapture ? 'agent-device' : 'adb-logcat',
642
+ platform: 'android',
643
+ });
644
+ }
645
+ /**
646
+ * Runs the profile-android CLI.
647
+ *
648
+ * @returns {Promise<void>}
649
+ */
650
+ async function main() {
651
+ const argv = process.argv.slice(2);
652
+ if (hasHelpFlag(argv)) {
653
+ usage({ binaryName: 'asl-profile-android', output: process.stdout, platform: 'android' });
654
+ return;
655
+ }
656
+ const args = parseArgs(argv);
657
+ loadAslLocalEnv();
658
+ if (typeof args.config !== 'string' || typeof args.scenario !== 'string') {
659
+ usage({ binaryName: 'asl-profile-android', platform: 'android' });
660
+ process.exitCode = 1;
661
+ return;
662
+ }
663
+ const result = await runProfileAndroid(args);
664
+ process.stdout.write(`${result.runDir}\n`);
665
+ }
666
+ if (require.main === module) {
667
+ main().catch((error) => {
668
+ console.error(error instanceof Error ? error.message : String(error));
669
+ process.exitCode = 1;
670
+ });
671
+ }