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,1271 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.agentDeviceDriverActionCode = agentDeviceDriverActionCode;
5
+ exports.buildAgentDeviceHealth = buildAgentDeviceHealth;
6
+ exports.buildAgentDeviceVerdict = buildAgentDeviceVerdict;
7
+ exports.buildAgentDeviceSelectorHealthMetadata = buildAgentDeviceSelectorHealthMetadata;
8
+ exports.checkAgentDeviceAvailability = checkAgentDeviceAvailability;
9
+ exports.defaultAgentDeviceCaptureFileName = defaultAgentDeviceCaptureFileName;
10
+ exports.defaultAgentDeviceRawFileName = defaultAgentDeviceRawFileName;
11
+ exports.execFileCommand = execFileCommand;
12
+ exports.execFileCommandWithTimeout = execFileCommandWithTimeout;
13
+ exports.isAgentDeviceSelector = isAgentDeviceSelector;
14
+ exports.main = main;
15
+ exports.parseArgs = parseArgs;
16
+ exports.parseAgentDeviceSessionMode = parseAgentDeviceSessionMode;
17
+ exports.parseRequiredPlatforms = parseRequiredPlatforms;
18
+ exports.readAgentDeviceSessions = readAgentDeviceSessions;
19
+ exports.readAgentDeviceStepOptions = readAgentDeviceStepOptions;
20
+ exports.resolveAgentDeviceDriverSteps = resolveAgentDeviceDriverSteps;
21
+ exports.runAgentDeviceCapture = runAgentDeviceCapture;
22
+ exports.runAgentDeviceDriverStep = runAgentDeviceDriverStep;
23
+ exports.usage = usage;
24
+ exports.validateAgentDeviceDriverSteps = validateAgentDeviceDriverSteps;
25
+ exports.writeAgentDeviceAvailabilityArtifacts = writeAgentDeviceAvailabilityArtifacts;
26
+ const { execFile } = require('node:child_process');
27
+ const crypto = require('node:crypto');
28
+ const fs = require('node:fs');
29
+ const fsp = require('node:fs/promises');
30
+ const path = require('node:path');
31
+ const { buildAgentSummaryMarkdown } = require('../core/agent-summary');
32
+ const { createArtifactLayout } = require('../core/artifact-layout');
33
+ const { writeJsonArtifact, writeTextArtifact } = require('../core/artifact-writer');
34
+ const { buildScenarioExecutionPlan } = require('../core/execution-plan');
35
+ const { SCHEMAS, assertValidJson } = require('../core/schema-validator');
36
+ const { hasHelpFlag, writeUsage } = require('./cli');
37
+ const { createAgentDeviceDriver, formatAgentDeviceRawOutput, } = require('./agent-device-driver');
38
+ const { loadAslLocalEnv, readStringArgOrEnv } = require('./local-env');
39
+ const DEFAULT_AGENT_DEVICE_REQUIRED_COMMANDS = [
40
+ 'open',
41
+ 'snapshot',
42
+ 'screenshot',
43
+ 'is',
44
+ 'click',
45
+ 'scroll',
46
+ 'logs',
47
+ 'devices',
48
+ 'session list',
49
+ ];
50
+ /**
51
+ * Prints CLI usage.
52
+ *
53
+ * @returns {void}
54
+ */
55
+ function usage(output = process.stderr) {
56
+ writeUsage([
57
+ 'Usage: asl-agent-device --platform <ios|android> --scenario <path> [--out <dir>] [--run-id <id>]',
58
+ '',
59
+ 'Executes scenario-declared portable driver actions through the external agent-device CLI.',
60
+ 'Writes health.json, verdict.json, agent-summary.md, raw command transcripts, and capture artifacts.',
61
+ 'Use --check --out <dir> to verify the configured agent-device command surface and preserve availability artifacts.',
62
+ 'Use --open --app <bundle-or-package> to open the app before running driver actions.',
63
+ 'Use --udid <id> for iOS simulators or --serial <id> for Android devices.',
64
+ 'Use --session <name> [--session-mode reuse|bind] to reuse an existing session or bind a named session to direct target flags.',
65
+ 'Use --command-timeout-ms <ms> to bound each external agent-device invocation.',
66
+ 'Use --require-platforms ios,android with --check when device discovery must prove booted OS targets.',
67
+ ], output);
68
+ }
69
+ /**
70
+ * Parses `--key value` CLI arguments.
71
+ *
72
+ * @param {string[]} argv
73
+ * @returns {CliArgs}
74
+ */
75
+ function parseArgs(argv) {
76
+ const args = {};
77
+ for (let index = 0; index < argv.length; index += 1) {
78
+ const token = argv[index];
79
+ if (token === '--') {
80
+ continue;
81
+ }
82
+ if (!token?.startsWith('--')) {
83
+ continue;
84
+ }
85
+ const key = token.slice(2);
86
+ const value = argv[index + 1];
87
+ if (value && !value.startsWith('--')) {
88
+ args[key] = value;
89
+ index += 1;
90
+ }
91
+ else {
92
+ args[key] = true;
93
+ }
94
+ }
95
+ return args;
96
+ }
97
+ /**
98
+ * Reads and parses a JSON object from disk.
99
+ *
100
+ * @param {string} filePath
101
+ * @returns {Record<string, unknown>}
102
+ */
103
+ function readJson(filePath) {
104
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
105
+ }
106
+ /**
107
+ * Checks whether a boolean-style flag is enabled.
108
+ *
109
+ * @param {string | boolean | undefined} value
110
+ * @returns {boolean}
111
+ */
112
+ function isEnabled(value) {
113
+ return value === true || value === 'true';
114
+ }
115
+ /**
116
+ * Reads a positive integer from CLI or scenario metadata.
117
+ *
118
+ * @param {unknown} value
119
+ * @param {number} fallback
120
+ * @returns {number}
121
+ */
122
+ function readPositiveInteger(value, fallback) {
123
+ const parsed = typeof value === 'string' ? Number(value) : value;
124
+ return typeof parsed === 'number' && Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
125
+ }
126
+ /**
127
+ * Reads a finite number from adapter metadata.
128
+ *
129
+ * @param {unknown} value
130
+ * @returns {number | undefined}
131
+ */
132
+ function readFiniteNumber(value) {
133
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
134
+ }
135
+ /**
136
+ * Creates a short random run id.
137
+ *
138
+ * @returns {string}
139
+ */
140
+ function createRunId() {
141
+ return crypto.randomBytes(6).toString('hex');
142
+ }
143
+ /**
144
+ * Runs a command and captures stdout, stderr, and exit code without throwing.
145
+ *
146
+ * @param {string} command
147
+ * @param {string[]} args
148
+ * @returns {Promise<CommandResult>}
149
+ */
150
+ function execFileCommand(command, args) {
151
+ return execFileCommandWithTimeout(command, args);
152
+ }
153
+ /**
154
+ * Runs a command with a bounded timeout and captures stdout, stderr, and exit code without throwing.
155
+ *
156
+ * @param {string} command
157
+ * @param {string[]} args
158
+ * @param {number} [timeoutMs]
159
+ * @returns {Promise<CommandResult>}
160
+ */
161
+ function execFileCommandWithTimeout(command, args, timeoutMs = 60_000) {
162
+ return new Promise((resolve) => {
163
+ execFile(command, args, { timeout: timeoutMs }, (error, stdout, stderr) => {
164
+ resolve({
165
+ command,
166
+ args,
167
+ exitCode: error && typeof error.code === 'number' ? error.code : error ? 1 : 0,
168
+ stderr: [
169
+ stderr,
170
+ error?.killed || error?.signal === 'SIGTERM' ? `agent-device command timed out after ${timeoutMs}ms.` : '',
171
+ ].filter(Boolean).join('\n'),
172
+ stdout,
173
+ });
174
+ });
175
+ });
176
+ }
177
+ /**
178
+ * Returns a compact single-line preview for command diagnostics.
179
+ *
180
+ * @param {string} value
181
+ * @returns {string | undefined}
182
+ */
183
+ function previewCommandOutput(value) {
184
+ const preview = value.replace(/\s+/gu, ' ').trim();
185
+ return preview.length > 240 ? `${preview.slice(0, 237)}...` : preview || undefined;
186
+ }
187
+ /**
188
+ * Classifies an agent-device availability failure into the next operational step.
189
+ *
190
+ * @param {CommandResult} result
191
+ * @returns {Record<string, string>}
192
+ */
193
+ function classifyAgentDeviceAvailabilityFailure(result) {
194
+ const diagnostic = `${result.stderr}\n${result.stdout}`;
195
+ if (/operation not permitted|permission denied|sandbox|eacces|eperm|daemon|\.agent-device|cannot bind|smartsocket/iu.test(diagnostic)) {
196
+ return {
197
+ failureClass: 'host_access',
198
+ nextAction: 'Rerun agent-device availability with host/device access before treating this as an app, scenario, or runner regression.',
199
+ nextActionCode: 'rerun_with_host_access',
200
+ };
201
+ }
202
+ if (/timed out|timeout/iu.test(diagnostic)) {
203
+ return {
204
+ failureClass: 'timeout',
205
+ nextAction: 'Confirm agent-device can run without prompts, increase --command-timeout-ms if it is legitimately slow, then rerun the availability check.',
206
+ nextActionCode: 'increase_agent_device_timeout',
207
+ };
208
+ }
209
+ if (/enoent|not found|command not found|no such file or directory/iu.test(diagnostic)) {
210
+ return {
211
+ failureClass: 'missing_binary',
212
+ nextAction: 'Install agent-device or pass the correct binary with --agent-device before starting live proof.',
213
+ nextActionCode: 'configure_agent_device_binary',
214
+ };
215
+ }
216
+ return {
217
+ failureClass: 'command_surface',
218
+ nextAction: 'Inspect the failed agent-device command output, fix the command surface, then rerun the availability check before starting live proof.',
219
+ nextActionCode: 'inspect_agent_device_availability',
220
+ };
221
+ }
222
+ /**
223
+ * Parses a comma-separated platform requirement list for availability checks.
224
+ *
225
+ * @param {unknown} value
226
+ * @returns {import('./agent-device-driver').AgentDevicePlatform[]}
227
+ */
228
+ function parseRequiredPlatforms(value) {
229
+ if (typeof value !== 'string') {
230
+ return [];
231
+ }
232
+ return value
233
+ .split(',')
234
+ .map((platform) => platform.trim())
235
+ .filter((platform) => ['android', 'apple', 'ios', 'linux', 'macos'].includes(platform));
236
+ }
237
+ /**
238
+ * Parses how a named agent-device session should participate in target selection.
239
+ *
240
+ * @param {unknown} value
241
+ * @returns {AgentDeviceSessionMode}
242
+ */
243
+ function parseAgentDeviceSessionMode(value) {
244
+ if (value === undefined || value === false) {
245
+ return 'reuse';
246
+ }
247
+ if (value === 'bind' || value === 'reuse') {
248
+ return value;
249
+ }
250
+ throw new Error('--session-mode must be either reuse or bind.');
251
+ }
252
+ /**
253
+ * Builds one availability check result from an agent-device command execution.
254
+ *
255
+ * @param {{code: string, expectedPattern: RegExp, name: string, result: CommandResult}} options
256
+ * @returns {AgentDeviceAvailabilityCheck}
257
+ */
258
+ function buildAgentDeviceAvailabilityCheck({ code, expectedPattern, name, result, }) {
259
+ const output = `${result.stdout}\n${result.stderr}`;
260
+ const passed = result.exitCode === 0 && expectedPattern.test(output);
261
+ const check = {
262
+ args: result.args,
263
+ code,
264
+ command: result.command,
265
+ exitCode: result.exitCode,
266
+ message: passed ? `${name} is available.` : `${name} did not return the expected agent-device output.`,
267
+ name,
268
+ status: passed ? 'passed' : 'failed',
269
+ };
270
+ if (!passed) {
271
+ const stderrPreview = previewCommandOutput(result.stderr);
272
+ const stdoutPreview = previewCommandOutput(result.stdout);
273
+ check.metadata = classifyAgentDeviceAvailabilityFailure(result);
274
+ if (stderrPreview) {
275
+ check.stderrPreview = stderrPreview;
276
+ }
277
+ if (stdoutPreview) {
278
+ check.stdoutPreview = stdoutPreview;
279
+ }
280
+ }
281
+ return check;
282
+ }
283
+ /**
284
+ * Parses agent-device device discovery JSON.
285
+ *
286
+ * @param {CommandResult} result
287
+ * @returns {Array<Record<string, unknown>>}
288
+ */
289
+ function readAgentDeviceDiscoveryDevices(result) {
290
+ if (result.exitCode !== 0 || result.stdout.trim().length === 0) {
291
+ return [];
292
+ }
293
+ try {
294
+ const parsed = JSON.parse(result.stdout);
295
+ const data = parsed.data && typeof parsed.data === 'object' && !Array.isArray(parsed.data)
296
+ ? parsed.data
297
+ : null;
298
+ return Array.isArray(data?.devices)
299
+ ? data.devices.filter((device) => Boolean(device) && typeof device === 'object' && !Array.isArray(device))
300
+ : [];
301
+ }
302
+ catch {
303
+ return [];
304
+ }
305
+ }
306
+ /**
307
+ * Parses agent-device active session JSON.
308
+ *
309
+ * @param {CommandResult} result
310
+ * @returns {Array<Record<string, unknown>>}
311
+ */
312
+ function readAgentDeviceSessions(result) {
313
+ if (result.exitCode !== 0 || result.stdout.trim().length === 0) {
314
+ return [];
315
+ }
316
+ try {
317
+ const parsed = JSON.parse(result.stdout);
318
+ const data = parsed.data && typeof parsed.data === 'object' && !Array.isArray(parsed.data)
319
+ ? parsed.data
320
+ : parsed;
321
+ const sessions = Array.isArray(data.sessions)
322
+ ? data.sessions
323
+ : Array.isArray(parsed.sessions)
324
+ ? parsed.sessions
325
+ : [];
326
+ return sessions.filter((session) => Boolean(session) && typeof session === 'object' && !Array.isArray(session));
327
+ }
328
+ catch {
329
+ return [];
330
+ }
331
+ }
332
+ /**
333
+ * Formats active agent-device sessions for compact health metadata.
334
+ *
335
+ * @param {Array<Record<string, unknown>>} sessions
336
+ * @returns {string}
337
+ */
338
+ function summarizeAgentDeviceSessions(sessions) {
339
+ return sessions
340
+ .slice(0, 8)
341
+ .map((session, index) => {
342
+ const name = typeof session.name === 'string'
343
+ ? session.name
344
+ : typeof session.id === 'string'
345
+ ? session.id
346
+ : `session-${index + 1}`;
347
+ const platform = typeof session.platform === 'string' ? session.platform : null;
348
+ const target = typeof session.target === 'string' ? session.target : null;
349
+ const device = typeof session.device === 'string'
350
+ ? session.device
351
+ : typeof session.deviceId === 'string'
352
+ ? session.deviceId
353
+ : typeof session.udid === 'string'
354
+ ? session.udid
355
+ : typeof session.serial === 'string'
356
+ ? session.serial
357
+ : null;
358
+ return [name, platform, target, device].filter(Boolean).join(':');
359
+ })
360
+ .join(', ');
361
+ }
362
+ /**
363
+ * Returns the stable session name that agent-device accepts.
364
+ *
365
+ * @param {Record<string, unknown>} session
366
+ * @returns {string | null}
367
+ */
368
+ function readAgentDeviceSessionName(session) {
369
+ return typeof session.name === 'string' && session.name.length > 0
370
+ ? session.name
371
+ : typeof session.id === 'string' && session.id.length > 0
372
+ ? session.id
373
+ : null;
374
+ }
375
+ /**
376
+ * Returns known device identifiers attached to one agent-device session.
377
+ *
378
+ * @param {Record<string, unknown>} session
379
+ * @returns {string[]}
380
+ */
381
+ function readAgentDeviceSessionTargets(session) {
382
+ return [
383
+ session.id,
384
+ session.deviceId,
385
+ session.device_id,
386
+ session.device_udid,
387
+ session.serial,
388
+ session.udid,
389
+ ].filter((value) => typeof value === 'string' && value.length > 0);
390
+ }
391
+ /**
392
+ * Selects a single active session for the requested platform and device target.
393
+ *
394
+ * @param {{platform: import('./agent-device-driver').AgentDevicePlatform, requestedTarget: string | null, sessions: Array<Record<string, unknown>>, target: string}} options
395
+ * @returns {string | null}
396
+ */
397
+ function selectAgentDeviceSession({ platform, requestedTarget, sessions, target, }) {
398
+ const platformCandidates = sessions.filter((session) => session.platform === platform &&
399
+ (typeof session.target !== 'string' || session.target === target));
400
+ const targetCandidates = requestedTarget
401
+ ? platformCandidates.filter((session) => readAgentDeviceSessionTargets(session).includes(requestedTarget))
402
+ : platformCandidates;
403
+ if (targetCandidates.length !== 1) {
404
+ return null;
405
+ }
406
+ const [selectedSession] = targetCandidates;
407
+ return selectedSession ? readAgentDeviceSessionName(selectedSession) : null;
408
+ }
409
+ /**
410
+ * Builds an agent-readable availability addendum with device and session counts.
411
+ *
412
+ * @param {AgentDeviceAvailabilityResult} result
413
+ * @returns {string}
414
+ */
415
+ function buildAgentDeviceAvailabilitySummary(result) {
416
+ const sessionSummary = summarizeAgentDeviceSessions(result.sessions);
417
+ return [
418
+ '',
419
+ '## agent-device availability',
420
+ '',
421
+ `- Devices: ${result.devices.length}`,
422
+ `- Active sessions: ${result.sessions.length}`,
423
+ ...(sessionSummary ? [`- Session hints: ${sessionSummary}`] : []),
424
+ '',
425
+ ].join('\n');
426
+ }
427
+ /**
428
+ * Checks whether device discovery found a booted mobile target for one platform.
429
+ *
430
+ * @param {Array<Record<string, unknown>>} devices
431
+ * @param {import('./agent-device-driver').AgentDevicePlatform} platform
432
+ * @returns {boolean}
433
+ */
434
+ function hasBootedMobilePlatform(devices, platform) {
435
+ return devices.some((device) => device.platform === platform &&
436
+ device.target === 'mobile' &&
437
+ device.booted === true);
438
+ }
439
+ /**
440
+ * Verifies that the configured agent-device command exposes ASL-required surfaces.
441
+ *
442
+ * @param {AgentDeviceAvailabilityOptions} options
443
+ * @returns {Promise<AgentDeviceAvailabilityResult>}
444
+ */
445
+ async function checkAgentDeviceAvailability({ agentDevicePath = 'agent-device', commandTimeoutMs = 30_000, executor, requiredCommands = DEFAULT_AGENT_DEVICE_REQUIRED_COMMANDS, requiredPlatforms = [], } = {}) {
446
+ const run = executor ?? ((command, args) => execFileCommandWithTimeout(command, args, commandTimeoutMs));
447
+ const checks = [];
448
+ const help = await run(agentDevicePath, ['--help']);
449
+ checks.push(buildAgentDeviceAvailabilityCheck({
450
+ code: 'agent_device_help_available',
451
+ expectedPattern: /CLI to control iOS and Android devices/u,
452
+ name: 'agent_device_help',
453
+ result: help,
454
+ }));
455
+ for (const commandName of requiredCommands) {
456
+ const commandLabel = commandName.replace(/\s+/gu, '_');
457
+ const pattern = commandName === 'session list'
458
+ ? /\bsession\s+list\b/u
459
+ : new RegExp(`\\b${commandName.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}\\b`, 'u');
460
+ checks.push(buildAgentDeviceAvailabilityCheck({
461
+ code: `agent_device_command_${commandLabel}_available`,
462
+ expectedPattern: pattern,
463
+ name: `agent_device_command_${commandLabel}`,
464
+ result: help,
465
+ }));
466
+ }
467
+ const devicesResult = await run(agentDevicePath, ['devices', '--json']);
468
+ const devices = readAgentDeviceDiscoveryDevices(devicesResult);
469
+ const discoveryPassed = devicesResult.exitCode === 0 && devices.length > 0;
470
+ const devicesCheck = {
471
+ args: devicesResult.args,
472
+ code: 'agent_device_devices_available',
473
+ command: devicesResult.command,
474
+ exitCode: devicesResult.exitCode,
475
+ message: discoveryPassed
476
+ ? `agent-device discovered ${devices.length} device(s).`
477
+ : 'agent-device did not return any discoverable devices.',
478
+ name: 'agent_device_devices',
479
+ status: discoveryPassed ? 'passed' : 'failed',
480
+ };
481
+ if (!discoveryPassed) {
482
+ const stderrPreview = previewCommandOutput(devicesResult.stderr);
483
+ const stdoutPreview = previewCommandOutput(devicesResult.stdout);
484
+ devicesCheck.metadata = classifyAgentDeviceAvailabilityFailure(devicesResult);
485
+ if (stderrPreview) {
486
+ devicesCheck.stderrPreview = stderrPreview;
487
+ }
488
+ if (stdoutPreview) {
489
+ devicesCheck.stdoutPreview = stdoutPreview;
490
+ }
491
+ }
492
+ checks.push(devicesCheck);
493
+ const sessionsResult = await run(agentDevicePath, ['session', 'list', '--json']);
494
+ const sessions = readAgentDeviceSessions(sessionsResult);
495
+ const sessionsPassed = sessionsResult.exitCode === 0;
496
+ const sessionsCheck = {
497
+ args: sessionsResult.args,
498
+ code: 'agent_device_sessions_available',
499
+ command: sessionsResult.command,
500
+ exitCode: sessionsResult.exitCode,
501
+ message: sessionsPassed
502
+ ? `agent-device reported ${sessions.length} active session(s).`
503
+ : 'agent-device could not list active sessions.',
504
+ metadata: {
505
+ sessionCount: sessions.length,
506
+ ...(sessions.length > 0 ? { activeSessions: summarizeAgentDeviceSessions(sessions) } : {}),
507
+ },
508
+ name: 'agent_device_sessions',
509
+ status: sessionsPassed ? 'passed' : 'failed',
510
+ };
511
+ if (!sessionsPassed) {
512
+ const stderrPreview = previewCommandOutput(sessionsResult.stderr);
513
+ const stdoutPreview = previewCommandOutput(sessionsResult.stdout);
514
+ sessionsCheck.metadata = {
515
+ ...sessionsCheck.metadata,
516
+ ...classifyAgentDeviceAvailabilityFailure(sessionsResult),
517
+ };
518
+ if (stderrPreview) {
519
+ sessionsCheck.stderrPreview = stderrPreview;
520
+ }
521
+ if (stdoutPreview) {
522
+ sessionsCheck.stdoutPreview = stdoutPreview;
523
+ }
524
+ }
525
+ checks.push(sessionsCheck);
526
+ for (const platform of requiredPlatforms) {
527
+ const passed = hasBootedMobilePlatform(devices, platform);
528
+ checks.push({
529
+ args: devicesResult.args,
530
+ code: `agent_device_booted_${platform}_available`,
531
+ command: devicesResult.command,
532
+ exitCode: devicesResult.exitCode,
533
+ message: passed
534
+ ? `agent-device discovered a booted ${platform} mobile target.`
535
+ : `agent-device did not discover a booted ${platform} mobile target.`,
536
+ name: `agent_device_booted_${platform}`,
537
+ status: passed ? 'passed' : 'failed',
538
+ });
539
+ }
540
+ const status = checks.every((check) => check.status === 'passed') ? 'passed' : 'failed';
541
+ return {
542
+ agentDevicePath,
543
+ checks,
544
+ devices,
545
+ requiredCommands,
546
+ requiredPlatforms,
547
+ sessions,
548
+ status,
549
+ };
550
+ }
551
+ /**
552
+ * Waits for the requested capture window.
553
+ *
554
+ * @param {number} ms
555
+ * @returns {Promise<void>}
556
+ */
557
+ function delay(ms) {
558
+ return new Promise((resolve) => {
559
+ setTimeout(resolve, ms);
560
+ });
561
+ }
562
+ /**
563
+ * Creates scalar health-check metadata for an agent-readable next action.
564
+ *
565
+ * @param {string} nextActionCode
566
+ * @param {string} nextAction
567
+ * @returns {NextActionHint}
568
+ */
569
+ function nextActionHint(nextActionCode, nextAction) {
570
+ return {
571
+ nextAction,
572
+ nextActionCode,
573
+ };
574
+ }
575
+ /**
576
+ * Normalizes CLI diagnostic text into scalar health metadata.
577
+ *
578
+ * @param {string} value
579
+ * @returns {string}
580
+ */
581
+ function normalizeAgentDeviceDiagnosticText(value) {
582
+ return value.replace(/\s+/gu, ' ').trim().slice(0, 500);
583
+ }
584
+ /**
585
+ * Reads structured agent-device JSON errors from stdout or stderr.
586
+ *
587
+ * @param {{stdout: string, stderr: string}} result
588
+ * @returns {AgentDeviceErrorMetadata}
589
+ */
590
+ function readAgentDeviceErrorMetadata(result) {
591
+ for (const content of [result.stdout, result.stderr]) {
592
+ const trimmed = content.trim();
593
+ if (!trimmed.startsWith('{')) {
594
+ continue;
595
+ }
596
+ try {
597
+ const parsed = JSON.parse(trimmed);
598
+ const error = parsed.error && typeof parsed.error === 'object' && !Array.isArray(parsed.error)
599
+ ? parsed.error
600
+ : null;
601
+ if (!error) {
602
+ continue;
603
+ }
604
+ return {
605
+ ...(typeof error.code === 'string'
606
+ ? { agentDeviceErrorCode: normalizeAgentDeviceDiagnosticText(error.code) }
607
+ : {}),
608
+ ...(typeof error.message === 'string'
609
+ ? { agentDeviceErrorMessage: normalizeAgentDeviceDiagnosticText(error.message) }
610
+ : {}),
611
+ ...(typeof error.hint === 'string'
612
+ ? { agentDeviceErrorHint: normalizeAgentDeviceDiagnosticText(error.hint) }
613
+ : {}),
614
+ ...(typeof error.diagnosticId === 'string'
615
+ ? { agentDeviceDiagnosticId: normalizeAgentDeviceDiagnosticText(error.diagnosticId) }
616
+ : {}),
617
+ };
618
+ }
619
+ catch {
620
+ continue;
621
+ }
622
+ }
623
+ return {};
624
+ }
625
+ /**
626
+ * Reads a quoted agent-device session name from a diagnostic message.
627
+ *
628
+ * @param {string | undefined} message
629
+ * @returns {string | null}
630
+ */
631
+ function readDiagnosticSessionName(message) {
632
+ if (!message) {
633
+ return null;
634
+ }
635
+ return /session "([^"]+)"/u.exec(message)?.[1] ?? null;
636
+ }
637
+ /**
638
+ * Builds the most specific next-action hint available from an agent-device failure.
639
+ *
640
+ * @param {AgentDeviceFailureHintOptions} options
641
+ * @returns {NextActionHint}
642
+ */
643
+ function buildAgentDeviceFailureHint({ defaultNextAction, defaultNextActionCode, errorMetadata, rawFileName, }) {
644
+ const errorCode = errorMetadata.agentDeviceErrorCode;
645
+ const errorMessage = errorMetadata.agentDeviceErrorMessage;
646
+ const sessionName = readDiagnosticSessionName(errorMessage);
647
+ if (errorCode === 'DEVICE_IN_USE') {
648
+ return nextActionHint('reuse_agent_device_session', sessionName
649
+ ? `Device is already owned by agent-device session "${sessionName}". Reuse that session with --agent-device-session ${sessionName}, close it, or choose another device before rerunning.`
650
+ : 'Device is already owned by another agent-device session. Reuse the owning session with --agent-device-session, close it, or choose another device before rerunning.');
651
+ }
652
+ if (errorMessage && /bound to .* cannot be used with --platform=/u.test(errorMessage)) {
653
+ return nextActionHint('select_agent_device_session', 'The selected agent-device session is bound to another platform or device. Use a platform-specific --agent-device-session, close the bound session, or rerun without the conflicting session.');
654
+ }
655
+ if (errorMessage && /No active session\. Run open first/u.test(errorMessage)) {
656
+ return nextActionHint('open_agent_device_session', `agent-device has no active session for this action. Inspect raw/${rawFileName}, make the app open step pass, or pass an existing --agent-device-session before rerunning.`);
657
+ }
658
+ if (errorMessage && /session lock policy/u.test(errorMessage)) {
659
+ return nextActionHint('fix_agent_device_session_lock', 'agent-device rejected the command because session lock policy conflicts with target selectors. Reuse the locked session directly, remove conflicting target selectors, or close the session before rerunning.');
660
+ }
661
+ return nextActionHint(defaultNextActionCode, defaultNextAction);
662
+ }
663
+ /**
664
+ * Builds a health artifact from agent-device capture checks.
665
+ *
666
+ * @param {{runId: string, checks: Record<string, unknown>[]}} options
667
+ * @returns {Record<string, unknown>}
668
+ */
669
+ function buildAgentDeviceHealth({ runId, checks }) {
670
+ const failed = checks.some((check) => check.status === 'failed');
671
+ return assertValidJson({
672
+ schemaVersion: '1.0.0',
673
+ scenarioId: 'agent-device-capture',
674
+ flowId: 'agent-device-capture',
675
+ runId,
676
+ healthStatus: failed ? 'failed' : 'passed',
677
+ checks,
678
+ }, SCHEMAS.health, 'Health artifact');
679
+ }
680
+ /**
681
+ * Builds a verdict artifact for agent-device capture readiness.
682
+ *
683
+ * @param {{runId: string, health: Record<string, unknown>}} options
684
+ * @returns {Record<string, unknown>}
685
+ */
686
+ function buildAgentDeviceVerdict({ runId, health }) {
687
+ const passed = health.healthStatus === 'passed';
688
+ return assertValidJson({
689
+ schemaVersion: '1.0.0',
690
+ scenarioId: 'agent-device-capture',
691
+ flowId: 'agent-device-capture',
692
+ runId,
693
+ healthStatus: health.healthStatus,
694
+ verdictStatus: passed ? 'not_evaluated' : 'inconclusive',
695
+ budgetChecks: [],
696
+ summary: passed
697
+ ? 'agent-device capture passed; no product budget has been evaluated.'
698
+ : 'agent-device capture failed; runtime scenario execution is not ready.',
699
+ }, SCHEMAS.verdict, 'Verdict artifact');
700
+ }
701
+ /**
702
+ * Converts one agent-device availability check into a schema-safe health check.
703
+ *
704
+ * @param {AgentDeviceAvailabilityCheck} check
705
+ * @returns {Record<string, unknown>}
706
+ */
707
+ function agentDeviceAvailabilityHealthCheck(check) {
708
+ return {
709
+ name: check.name,
710
+ status: check.status,
711
+ source: 'runner',
712
+ code: check.code,
713
+ message: check.message,
714
+ metadata: {
715
+ command: check.command,
716
+ args: check.args.join(' '),
717
+ exitCode: check.exitCode,
718
+ ...(check.stderrPreview ? { stderrPreview: check.stderrPreview } : {}),
719
+ ...(check.stdoutPreview ? { stdoutPreview: check.stdoutPreview } : {}),
720
+ ...(check.metadata ?? {}),
721
+ },
722
+ };
723
+ }
724
+ /**
725
+ * Writes ASL artifacts for an agent-device command-surface availability check.
726
+ *
727
+ * @param {AgentDeviceAvailabilityArtifactOptions} options
728
+ * @returns {Promise<{agentSummary: string, health: Record<string, unknown>, runDir: string, verdict: Record<string, unknown>}>}
729
+ */
730
+ async function writeAgentDeviceAvailabilityArtifacts({ outputDir, result, runId = createRunId(), }) {
731
+ const runDir = path.resolve(outputDir);
732
+ const layout = createArtifactLayout({ outputDir: runDir });
733
+ const checks = result.checks.map(agentDeviceAvailabilityHealthCheck);
734
+ const health = assertValidJson({
735
+ schemaVersion: '1.0.0',
736
+ scenarioId: 'agent-device-availability',
737
+ flowId: 'agent-device-availability',
738
+ runId,
739
+ healthStatus: result.status,
740
+ checks,
741
+ }, SCHEMAS.health, 'Health artifact');
742
+ const verdict = assertValidJson({
743
+ schemaVersion: '1.0.0',
744
+ scenarioId: 'agent-device-availability',
745
+ flowId: 'agent-device-availability',
746
+ runId,
747
+ healthStatus: health.healthStatus,
748
+ verdictStatus: result.status === 'passed' ? 'not_evaluated' : 'inconclusive',
749
+ budgetChecks: [],
750
+ summary: result.status === 'passed'
751
+ ? 'agent-device command surface is available; no product budget has been evaluated.'
752
+ : 'agent-device command surface is unavailable; fix runner environment health before live proof.',
753
+ }, SCHEMAS.verdict, 'Verdict artifact');
754
+ const agentSummary = [
755
+ buildAgentSummaryMarkdown({ health, verdict }).trimEnd(),
756
+ buildAgentDeviceAvailabilitySummary(result),
757
+ ].join('\n');
758
+ await fsp.mkdir(layout.raw, { recursive: true });
759
+ await writeJsonArtifact({
760
+ filePath: layout.health,
761
+ value: health,
762
+ schema: SCHEMAS.health,
763
+ label: 'Health artifact',
764
+ });
765
+ await writeJsonArtifact({
766
+ filePath: layout.verdict,
767
+ value: verdict,
768
+ schema: SCHEMAS.verdict,
769
+ label: 'Verdict artifact',
770
+ });
771
+ await writeTextArtifact({
772
+ filePath: layout.agentSummary,
773
+ content: agentSummary,
774
+ });
775
+ await fsp.writeFile(path.join(layout.raw, 'agent-device-availability.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
776
+ return {
777
+ agentSummary,
778
+ health,
779
+ runDir,
780
+ verdict,
781
+ };
782
+ }
783
+ /**
784
+ * Reads agent-device adapter metadata from a normalized scenario step.
785
+ *
786
+ * @param {ScenarioExecutionStep} step
787
+ * @returns {Record<string, unknown>}
788
+ */
789
+ function readAgentDeviceStepOptions(step) {
790
+ const agentDeviceOptions = step.adapterOptions?.agentDevice;
791
+ return agentDeviceOptions && typeof agentDeviceOptions === 'object' && !Array.isArray(agentDeviceOptions)
792
+ ? agentDeviceOptions
793
+ : {};
794
+ }
795
+ /**
796
+ * Returns true when a normalized step has a portable selector.
797
+ *
798
+ * @param {unknown} value
799
+ * @returns {value is import('./agent-device-driver').AgentDeviceSelector}
800
+ */
801
+ function isAgentDeviceSelector(value) {
802
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
803
+ return false;
804
+ }
805
+ const selector = value;
806
+ return typeof selector.kind === 'string' && typeof selector.value === 'string' && selector.value.length > 0;
807
+ }
808
+ /**
809
+ * Returns the default raw file name for one agent-device action.
810
+ *
811
+ * @param {{driverAction: AgentDeviceDriverStep['driverAction'], index: number}} options
812
+ * @returns {string}
813
+ */
814
+ function defaultAgentDeviceRawFileName({ driverAction, index, }) {
815
+ return `agent-device-${driverAction}-${index}.txt`;
816
+ }
817
+ /**
818
+ * Returns the default capture file name for one agent-device action.
819
+ *
820
+ * @param {{driverAction: AgentDeviceDriverStep['driverAction'], index: number}} options
821
+ * @returns {string}
822
+ */
823
+ function defaultAgentDeviceCaptureFileName({ driverAction, index, }) {
824
+ return `agent-device-${driverAction}-${index}.png`;
825
+ }
826
+ /**
827
+ * Expands normalized scenario steps into agent-device driver actions.
828
+ *
829
+ * @param {Record<string, unknown>} scenario
830
+ * @returns {AgentDeviceDriverStep[]}
831
+ */
832
+ function resolveAgentDeviceDriverSteps(scenario) {
833
+ const executionPlan = buildScenarioExecutionPlan(scenario);
834
+ return executionPlan.steps
835
+ .filter((step) => ['assertVisible', 'inspectTree', 'readLogs', 'screenshot', 'scroll', 'tap'].includes(String(step.driverAction)))
836
+ .map((step, index) => {
837
+ const agentDeviceOptions = readAgentDeviceStepOptions(step);
838
+ const action = step.driverAction;
839
+ const actionIndex = index + 1;
840
+ return {
841
+ driverAction: action,
842
+ ...(typeof agentDeviceOptions.amount === 'string' ? { amount: agentDeviceOptions.amount } : {}),
843
+ ...(typeof agentDeviceOptions.captureFileName === 'string' && agentDeviceOptions.captureFileName.length > 0
844
+ ? { captureFileName: agentDeviceOptions.captureFileName }
845
+ : action === 'screenshot'
846
+ ? { captureFileName: defaultAgentDeviceCaptureFileName({ driverAction: action, index: actionIndex }) }
847
+ : {}),
848
+ ...(typeof agentDeviceOptions.direction === 'string' ? { direction: agentDeviceOptions.direction } : {}),
849
+ ...(typeof readFiniteNumber(agentDeviceOptions.durationMs) === 'number'
850
+ ? { durationMs: readFiniteNumber(agentDeviceOptions.durationMs) }
851
+ : {}),
852
+ ...(typeof readFiniteNumber(agentDeviceOptions.endX) === 'number' ? { endX: readFiniteNumber(agentDeviceOptions.endX) } : {}),
853
+ ...(typeof readFiniteNumber(agentDeviceOptions.endY) === 'number' ? { endY: readFiniteNumber(agentDeviceOptions.endY) } : {}),
854
+ ...(typeof readFiniteNumber(agentDeviceOptions.pixels) === 'number' ? { pixels: readFiniteNumber(agentDeviceOptions.pixels) } : {}),
855
+ rawFileName: typeof agentDeviceOptions.rawFileName === 'string' && agentDeviceOptions.rawFileName.length > 0
856
+ ? agentDeviceOptions.rawFileName
857
+ : defaultAgentDeviceRawFileName({ driverAction: action, index: actionIndex }),
858
+ ...(typeof agentDeviceOptions.ref === 'string' ? { ref: agentDeviceOptions.ref } : {}),
859
+ required: step.required !== false,
860
+ ...(isAgentDeviceSelector(step.selector) ? { selector: step.selector } : {}),
861
+ stepId: step.id,
862
+ ...(typeof readFiniteNumber(agentDeviceOptions.startX) === 'number' ? { startX: readFiniteNumber(agentDeviceOptions.startX) } : {}),
863
+ ...(typeof readFiniteNumber(agentDeviceOptions.startY) === 'number' ? { startY: readFiniteNumber(agentDeviceOptions.startY) } : {}),
864
+ waitMs: readPositiveInteger(agentDeviceOptions.waitMs ?? step.timeoutMs, 0),
865
+ ...(typeof readFiniteNumber(agentDeviceOptions.x) === 'number' ? { x: readFiniteNumber(agentDeviceOptions.x) } : {}),
866
+ ...(typeof readFiniteNumber(agentDeviceOptions.y) === 'number' ? { y: readFiniteNumber(agentDeviceOptions.y) } : {}),
867
+ };
868
+ });
869
+ }
870
+ /**
871
+ * Returns profile-time validation errors for agent-device driver steps.
872
+ *
873
+ * @param {AgentDeviceDriverStep[]} driverSteps
874
+ * @returns {string[]}
875
+ */
876
+ function validateAgentDeviceDriverSteps(driverSteps) {
877
+ const errors = [];
878
+ for (const step of driverSteps) {
879
+ const stepLabel = step.stepId ? `step \`${step.stepId}\`` : 'unnamed step';
880
+ if (step.driverAction === 'tap' && !step.selector && !step.ref && (typeof step.x !== 'number' || typeof step.y !== 'number')) {
881
+ errors.push(`${stepLabel} uses driverAction \`tap\` but is missing a selector, adapterOptions.agentDevice.ref, or adapterOptions.agentDevice.x/y.`);
882
+ }
883
+ if (step.driverAction === 'assertVisible' && !step.selector) {
884
+ errors.push(`${stepLabel} uses driverAction \`assertVisible\` but is missing a portable selector.`);
885
+ }
886
+ }
887
+ return errors;
888
+ }
889
+ /**
890
+ * Builds scalar health metadata for one portable selector.
891
+ *
892
+ * @param {import('./agent-device-driver').AgentDeviceSelector | undefined} selector
893
+ * @returns {Record<string, string>}
894
+ */
895
+ function buildAgentDeviceSelectorHealthMetadata(selector) {
896
+ if (!selector) {
897
+ return {};
898
+ }
899
+ return {
900
+ selectorKind: selector.kind,
901
+ selectorValue: selector.value,
902
+ ...(selector.match ? { selectorMatch: selector.match } : {}),
903
+ };
904
+ }
905
+ /**
906
+ * Runs one agent-device driver action.
907
+ *
908
+ * @param {{capturesDir: string, driver: import('./agent-device-driver').AgentDeviceDriver, driverStep: AgentDeviceDriverStep}} options
909
+ * @returns {Promise<import('./agent-device-driver').AgentDeviceCommandResult>}
910
+ */
911
+ async function runAgentDeviceDriverStep({ capturesDir, driver, driverStep, }) {
912
+ if (driverStep.driverAction === 'assertVisible' && driverStep.selector) {
913
+ return driver.assertVisible({
914
+ selector: driverStep.selector,
915
+ ...(driverStep.rawFileName ? { rawFileName: driverStep.rawFileName } : {}),
916
+ });
917
+ }
918
+ if (driverStep.driverAction === 'inspectTree') {
919
+ return driver.inspectTree({
920
+ ...(driverStep.rawFileName ? { rawFileName: driverStep.rawFileName } : {}),
921
+ });
922
+ }
923
+ if (driverStep.driverAction === 'readLogs') {
924
+ return driver.readLogs({
925
+ ...(driverStep.rawFileName ? { rawFileName: driverStep.rawFileName } : {}),
926
+ });
927
+ }
928
+ if (driverStep.driverAction === 'screenshot') {
929
+ return driver.screenshot({
930
+ outputPath: path.join(capturesDir, driverStep.captureFileName ?? 'agent-device-screenshot.png'),
931
+ ...(driverStep.rawFileName ? { rawFileName: driverStep.rawFileName } : {}),
932
+ });
933
+ }
934
+ if (driverStep.driverAction === 'scroll') {
935
+ return driver.scroll({
936
+ ...(driverStep.amount ? { amount: driverStep.amount } : {}),
937
+ ...(driverStep.direction ? { direction: driverStep.direction } : {}),
938
+ ...(typeof driverStep.durationMs === 'number' ? { durationMs: driverStep.durationMs } : {}),
939
+ ...(typeof driverStep.endX === 'number' ? { endX: driverStep.endX } : {}),
940
+ ...(typeof driverStep.endY === 'number' ? { endY: driverStep.endY } : {}),
941
+ ...(typeof driverStep.pixels === 'number' ? { pixels: driverStep.pixels } : {}),
942
+ ...(driverStep.rawFileName ? { rawFileName: driverStep.rawFileName } : {}),
943
+ ...(typeof driverStep.startX === 'number' ? { startX: driverStep.startX } : {}),
944
+ ...(typeof driverStep.startY === 'number' ? { startY: driverStep.startY } : {}),
945
+ });
946
+ }
947
+ if (driverStep.driverAction === 'tap') {
948
+ return driver.tap({
949
+ ...(driverStep.rawFileName ? { rawFileName: driverStep.rawFileName } : {}),
950
+ ...(driverStep.ref ? { ref: driverStep.ref } : {}),
951
+ ...(driverStep.selector ? { selector: driverStep.selector } : {}),
952
+ ...(typeof driverStep.x === 'number' ? { x: driverStep.x } : {}),
953
+ ...(typeof driverStep.y === 'number' ? { y: driverStep.y } : {}),
954
+ });
955
+ }
956
+ throw new Error(`Unsupported agent-device driver action: ${driverStep.driverAction}`);
957
+ }
958
+ /**
959
+ * Builds a stable health code suffix for one agent-device driver action.
960
+ *
961
+ * @param {AgentDeviceDriverStep['driverAction']} driverAction
962
+ * @returns {string}
963
+ */
964
+ function agentDeviceDriverActionCode(driverAction) {
965
+ return driverAction.replace(/[A-Z]/gu, (match) => `_${match.toLowerCase()}`);
966
+ }
967
+ /**
968
+ * Runs scenario-declared portable actions through agent-device and writes artifacts.
969
+ *
970
+ * @param {AgentDeviceCaptureOptions} options
971
+ * @returns {Promise<AgentDeviceCaptureResult>}
972
+ */
973
+ async function runAgentDeviceCapture({ agentDevicePath = 'agent-device', app = null, commandTimeoutMs = 60_000, delay: wait = delay, device = null, driverSteps, executor, open = false, outputDir = path.resolve('artifacts/agent-device-capture'), platform, runId = createRunId(), scenario = null, serial = null, session = null, sessionMode = 'reuse', target = 'mobile', udid = null, waitMs = 0, }) {
974
+ const run = executor ?? ((command, args) => execFileCommandWithTimeout(command, args, commandTimeoutMs));
975
+ const runDir = path.resolve(outputDir);
976
+ const layout = createArtifactLayout({ outputDir: runDir });
977
+ const rawDir = layout.raw;
978
+ await fsp.mkdir(rawDir, { recursive: true });
979
+ await fsp.mkdir(layout.captures, { recursive: true });
980
+ const raw = {};
981
+ const captures = {
982
+ screenshots: [],
983
+ };
984
+ const checks = [];
985
+ const driverActionMetadata = [];
986
+ const resolvedDriverSteps = driverSteps ?? (scenario ? resolveAgentDeviceDriverSteps(scenario) : []);
987
+ const driverStepErrors = validateAgentDeviceDriverSteps(resolvedDriverSteps);
988
+ if (driverStepErrors.length > 0) {
989
+ throw new Error(`Invalid agent-device driver step metadata: ${driverStepErrors.join(' ')}`);
990
+ }
991
+ const requestedTarget = udid ?? serial ?? device ?? null;
992
+ let sessionName = typeof session === 'string' && session.length > 0 ? session : null;
993
+ let sessionSelectionMode = sessionName ? 'explicit' : 'none';
994
+ if (!sessionName) {
995
+ const sessionList = await run(agentDevicePath, ['session', 'list', '--json']);
996
+ raw['agent-device-session-list.txt'] = formatAgentDeviceRawOutput(sessionList);
997
+ if (sessionList.exitCode === 0) {
998
+ const selectedSession = selectAgentDeviceSession({
999
+ platform,
1000
+ requestedTarget,
1001
+ sessions: readAgentDeviceSessions(sessionList),
1002
+ target,
1003
+ });
1004
+ if (selectedSession) {
1005
+ sessionName = selectedSession;
1006
+ sessionSelectionMode = 'auto';
1007
+ checks.push({
1008
+ name: 'agent_device_session_selected',
1009
+ status: 'passed',
1010
+ source: 'runner',
1011
+ code: 'agent_device_session_auto_selected',
1012
+ message: `Selected agent-device session ${selectedSession} for ${platform}.`,
1013
+ });
1014
+ }
1015
+ }
1016
+ }
1017
+ const normalizedSessionMode = parseAgentDeviceSessionMode(sessionMode);
1018
+ const sessionOwnsTarget = Boolean(sessionName) && (normalizedSessionMode === 'reuse' || !requestedTarget);
1019
+ const driver = createAgentDeviceDriver({
1020
+ agentDevicePath,
1021
+ ...(!sessionOwnsTarget && device ? { device } : {}),
1022
+ executor: run,
1023
+ platform,
1024
+ ...(!sessionOwnsTarget && serial ? { serial } : {}),
1025
+ ...(sessionName ? { session: sessionName } : {}),
1026
+ ...(!sessionOwnsTarget ? { target } : {}),
1027
+ ...(!sessionOwnsTarget && udid ? { udid } : {}),
1028
+ });
1029
+ const metadata = {
1030
+ app,
1031
+ device,
1032
+ driverActions: [],
1033
+ open,
1034
+ platform,
1035
+ ...(requestedTarget ? { requestedTarget } : {}),
1036
+ selectedTarget: sessionOwnsTarget ? sessionName : requestedTarget,
1037
+ session: sessionName,
1038
+ sessionSelectionMode,
1039
+ sessionMode: normalizedSessionMode,
1040
+ target,
1041
+ targetSelectionMode: sessionOwnsTarget ? 'session' : sessionName ? 'session_bind' : 'direct',
1042
+ };
1043
+ if (open) {
1044
+ if (!app) {
1045
+ checks.push({
1046
+ name: 'agent_device_opened',
1047
+ status: 'failed',
1048
+ source: 'runner',
1049
+ code: 'agent_device_open_missing_app',
1050
+ message: 'agent-device app open was requested, but no app id or URL was provided.',
1051
+ metadata: nextActionHint('provide_agent_device_app', 'Pass --app with a bundle id, package name, app name, or URL before requesting --open.'),
1052
+ });
1053
+ }
1054
+ else {
1055
+ const openResult = await driver.open({ appOrUrl: app });
1056
+ raw[openResult.rawFileName] = formatAgentDeviceRawOutput(openResult);
1057
+ const errorMetadata = openResult.exitCode !== 0 ? readAgentDeviceErrorMetadata(openResult) : {};
1058
+ checks.push({
1059
+ name: 'agent_device_opened',
1060
+ status: openResult.exitCode === 0 ? 'passed' : 'failed',
1061
+ source: 'runner',
1062
+ code: openResult.exitCode === 0 ? 'agent_device_opened' : 'agent_device_open_failed',
1063
+ message: openResult.exitCode === 0 ? `Opened ${app} with agent-device.` : `Failed to open ${app} with agent-device.`,
1064
+ ...(openResult.exitCode !== 0
1065
+ ? {
1066
+ metadata: {
1067
+ ...buildAgentDeviceFailureHint({
1068
+ defaultNextAction: `Inspect raw/${openResult.rawFileName}, confirm the selected device is available, and rerun the capture.`,
1069
+ defaultNextActionCode: 'inspect_agent_device_open',
1070
+ errorMetadata,
1071
+ rawFileName: openResult.rawFileName,
1072
+ }),
1073
+ ...errorMetadata,
1074
+ },
1075
+ }
1076
+ : {}),
1077
+ });
1078
+ }
1079
+ }
1080
+ if (waitMs > 0) {
1081
+ await wait(waitMs);
1082
+ checks.push({
1083
+ name: 'agent_device_capture_window_waited',
1084
+ status: 'passed',
1085
+ source: 'runner',
1086
+ code: 'agent_device_capture_window_waited',
1087
+ message: `Waited ${waitMs}ms before running agent-device driver actions.`,
1088
+ });
1089
+ }
1090
+ for (const driverStep of resolvedDriverSteps) {
1091
+ if (driverStep.waitMs && driverStep.waitMs > 0) {
1092
+ await wait(driverStep.waitMs);
1093
+ checks.push({
1094
+ name: 'agent_device_driver_action_waited',
1095
+ status: 'passed',
1096
+ source: 'runner',
1097
+ code: 'agent_device_driver_action_waited',
1098
+ message: `Waited ${driverStep.waitMs}ms before running agent-device driver action ${driverStep.driverAction}.`,
1099
+ metadata: {
1100
+ driverAction: driverStep.driverAction,
1101
+ ...(driverStep.stepId ? { stepId: driverStep.stepId } : {}),
1102
+ },
1103
+ });
1104
+ }
1105
+ const driverResult = await runAgentDeviceDriverStep({
1106
+ capturesDir: layout.captures,
1107
+ driver,
1108
+ driverStep,
1109
+ });
1110
+ raw[driverResult.rawFileName] = formatAgentDeviceRawOutput(driverResult);
1111
+ const failed = driverResult.exitCode !== 0;
1112
+ const errorMetadata = failed ? readAgentDeviceErrorMetadata(driverResult) : {};
1113
+ const codeSuffix = agentDeviceDriverActionCode(driverStep.driverAction);
1114
+ checks.push({
1115
+ name: `agent_device_${codeSuffix}`,
1116
+ status: failed && driverStep.required === false ? 'warning' : failed ? 'failed' : 'passed',
1117
+ source: 'runner',
1118
+ code: driverResult.exitCode === 0 ? `agent_device_${codeSuffix}_completed` : `agent_device_${codeSuffix}_failed`,
1119
+ message: driverResult.exitCode === 0
1120
+ ? `Completed agent-device driver action ${driverStep.driverAction}.`
1121
+ : `agent-device driver action ${driverStep.driverAction} failed.`,
1122
+ metadata: {
1123
+ driverAction: driverStep.driverAction,
1124
+ ...(failed
1125
+ ? buildAgentDeviceFailureHint({
1126
+ defaultNextAction: `Inspect raw/${driverResult.rawFileName}, confirm the device is interactive and the action metadata is valid, then rerun the capture.`,
1127
+ defaultNextActionCode: 'inspect_agent_device_driver_action',
1128
+ errorMetadata,
1129
+ rawFileName: driverResult.rawFileName,
1130
+ })
1131
+ : {}),
1132
+ ...(failed ? errorMetadata : {}),
1133
+ ...buildAgentDeviceSelectorHealthMetadata(driverStep.selector),
1134
+ ...(driverStep.stepId ? { stepId: driverStep.stepId } : {}),
1135
+ },
1136
+ });
1137
+ const actionMetadata = {
1138
+ args: driverResult.args,
1139
+ driverAction: driverStep.driverAction,
1140
+ exitCode: driverResult.exitCode,
1141
+ ...(driverResult.capturePath
1142
+ ? { capturePath: `captures/${path.basename(driverResult.capturePath)}` }
1143
+ : {}),
1144
+ rawPath: `raw/${driverResult.rawFileName}`,
1145
+ ...(driverStep.selector ? { selector: driverStep.selector } : {}),
1146
+ ...(driverStep.stepId ? { stepId: driverStep.stepId } : {}),
1147
+ };
1148
+ driverActionMetadata.push(actionMetadata);
1149
+ if (driverStep.driverAction === 'screenshot' && driverResult.exitCode === 0 && driverResult.capturePath) {
1150
+ captures.screenshots.push(`captures/${path.basename(driverResult.capturePath)}`);
1151
+ }
1152
+ }
1153
+ metadata.driverActions = driverActionMetadata;
1154
+ metadata.captures = captures;
1155
+ const health = buildAgentDeviceHealth({ runId, checks });
1156
+ const verdict = buildAgentDeviceVerdict({ runId, health });
1157
+ const agentSummary = buildAgentSummaryMarkdown({ health, verdict });
1158
+ await Promise.all(Object.entries(raw).map(([fileName, content]) => fsp.writeFile(path.join(rawDir, fileName), `${content.trimEnd()}\n`, 'utf8')));
1159
+ await fsp.writeFile(path.join(rawDir, 'agent-device-metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
1160
+ await writeJsonArtifact({
1161
+ filePath: layout.health,
1162
+ value: health,
1163
+ schema: SCHEMAS.health,
1164
+ label: 'Health artifact',
1165
+ });
1166
+ await writeJsonArtifact({
1167
+ filePath: layout.verdict,
1168
+ value: verdict,
1169
+ schema: SCHEMAS.verdict,
1170
+ label: 'Verdict artifact',
1171
+ });
1172
+ await writeTextArtifact({
1173
+ filePath: layout.agentSummary,
1174
+ content: agentSummary,
1175
+ });
1176
+ return {
1177
+ agentSummary,
1178
+ captures,
1179
+ health,
1180
+ metadata,
1181
+ raw,
1182
+ runDir,
1183
+ verdict,
1184
+ };
1185
+ }
1186
+ /**
1187
+ * Runs the agent-device capture CLI.
1188
+ *
1189
+ * @returns {Promise<void>}
1190
+ */
1191
+ async function main() {
1192
+ const argv = process.argv.slice(2);
1193
+ if (hasHelpFlag(argv)) {
1194
+ usage(process.stdout);
1195
+ return;
1196
+ }
1197
+ loadAslLocalEnv();
1198
+ const args = parseArgs(argv);
1199
+ const commandTimeoutMsValue = readStringArgOrEnv(args['command-timeout-ms'], ['ASL_AGENT_DEVICE_COMMAND_TIMEOUT_MS']);
1200
+ const commandTimeoutMs = readPositiveInteger(commandTimeoutMsValue, 60_000);
1201
+ const agentDevicePath = readStringArgOrEnv(args['agent-device'], ['ASL_AGENT_DEVICE_BIN']);
1202
+ if (args.check === true || args.check === 'true') {
1203
+ const requiredPlatforms = readStringArgOrEnv(args['require-platforms'], ['ASL_AGENT_DEVICE_REQUIRED_PLATFORMS']);
1204
+ const result = await checkAgentDeviceAvailability({
1205
+ ...(agentDevicePath ? { agentDevicePath } : {}),
1206
+ commandTimeoutMs,
1207
+ requiredPlatforms: parseRequiredPlatforms(requiredPlatforms),
1208
+ });
1209
+ if (typeof args.out === 'string') {
1210
+ await writeAgentDeviceAvailabilityArtifacts({
1211
+ outputDir: args.out,
1212
+ result,
1213
+ ...(typeof args['run-id'] === 'string' ? { runId: args['run-id'] } : {}),
1214
+ });
1215
+ }
1216
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1217
+ if (result.status !== 'passed') {
1218
+ process.exitCode = 1;
1219
+ }
1220
+ return;
1221
+ }
1222
+ if (typeof args.platform !== 'string' || typeof args.scenario !== 'string') {
1223
+ usage();
1224
+ process.exitCode = 1;
1225
+ return;
1226
+ }
1227
+ if (!['android', 'apple', 'ios', 'linux', 'macos'].includes(args.platform)) {
1228
+ throw new Error('--platform must be one of android, ios, macos, linux, or apple.');
1229
+ }
1230
+ const platform = args.platform;
1231
+ const app = readStringArgOrEnv(args.app, platform === 'android'
1232
+ ? ['ASL_ANDROID_APP_ID', 'ASL_EXAMPLE_ANDROID_APP_ID']
1233
+ : ['ASL_IOS_APP_ID', 'ASL_EXAMPLE_IOS_APP_ID']);
1234
+ const serial = readStringArgOrEnv(args.serial, ['ASL_ANDROID_SERIAL', 'ASL_EXAMPLE_ANDROID_SERIAL']);
1235
+ const session = readStringArgOrEnv(args.session, platform === 'android'
1236
+ ? ['ASL_ANDROID_AGENT_DEVICE_SESSION', 'ASL_EXAMPLE_ANDROID_AGENT_DEVICE_SESSION']
1237
+ : ['ASL_IOS_AGENT_DEVICE_SESSION', 'ASL_EXAMPLE_IOS_AGENT_DEVICE_SESSION']);
1238
+ const sessionMode = readStringArgOrEnv(args['session-mode'], platform === 'android'
1239
+ ? ['ASL_ANDROID_AGENT_DEVICE_SESSION_MODE', 'ASL_EXAMPLE_ANDROID_AGENT_DEVICE_SESSION_MODE']
1240
+ : ['ASL_IOS_AGENT_DEVICE_SESSION_MODE', 'ASL_EXAMPLE_IOS_AGENT_DEVICE_SESSION_MODE']);
1241
+ const udid = readStringArgOrEnv(args.udid, ['ASL_IOS_UDID', 'ASL_EXAMPLE_IOS_UDID']);
1242
+ const result = await runAgentDeviceCapture({
1243
+ ...(agentDevicePath ? { agentDevicePath } : {}),
1244
+ ...(app ? { app } : {}),
1245
+ commandTimeoutMs,
1246
+ ...(typeof args.device === 'string' ? { device: args.device } : {}),
1247
+ ...(typeof args.out === 'string' ? { outputDir: args.out } : {}),
1248
+ open: isEnabled(args.open),
1249
+ platform,
1250
+ ...(typeof args['run-id'] === 'string' ? { runId: args['run-id'] } : {}),
1251
+ scenario: readJson(path.resolve(args.scenario)),
1252
+ ...(serial ? { serial } : {}),
1253
+ ...(session ? { session } : {}),
1254
+ ...(sessionMode ? { sessionMode: parseAgentDeviceSessionMode(sessionMode) } : {}),
1255
+ ...(typeof args.target === 'string' && ['desktop', 'mobile', 'tv'].includes(args.target)
1256
+ ? { target: args.target }
1257
+ : {}),
1258
+ ...(udid ? { udid } : {}),
1259
+ waitMs: readPositiveInteger(args['wait-ms'], 0),
1260
+ });
1261
+ process.stdout.write(`${result.runDir}\n`);
1262
+ if (result.health.healthStatus !== 'passed') {
1263
+ process.exitCode = 1;
1264
+ }
1265
+ }
1266
+ if (require.main === module) {
1267
+ main().catch((error) => {
1268
+ console.error(error instanceof Error ? error.message : String(error));
1269
+ process.exitCode = 1;
1270
+ });
1271
+ }