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,1307 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.buildProfileHealth = buildProfileHealth;
5
+ exports.buildProviderCommandFailureHealth = buildProviderCommandFailureHealth;
6
+ exports.buildProfileVerdict = buildProfileVerdict;
7
+ exports.buildVerdictBudgetChecks = buildVerdictBudgetChecks;
8
+ exports.parseArgs = parseArgs;
9
+ exports.buildEvidenceAttachmentManifest = buildEvidenceAttachmentManifest;
10
+ exports.readScalarArg = readScalarArg;
11
+ exports.resolveAppId = resolveAppId;
12
+ exports.resolveArtifactRoot = resolveArtifactRoot;
13
+ exports.resolveAttachedEvidence = resolveAttachedEvidence;
14
+ exports.resolveComparisonLane = resolveComparisonLane;
15
+ exports.resolveEventLogPath = resolveEventLogPath;
16
+ exports.resolveInteractionDriver = resolveInteractionDriver;
17
+ exports.runProfileCli = runProfileCli;
18
+ exports.runProfileMobile = runProfileMobile;
19
+ exports.hashScenarioContract = hashScenarioContract;
20
+ exports.usage = usage;
21
+ const { execFile } = require('node:child_process');
22
+ const fs = require('node:fs');
23
+ const fsp = require('node:fs/promises');
24
+ const path = require('node:path');
25
+ const crypto = require('node:crypto');
26
+ const { buildAgentSummaryMarkdown } = require('../core/agent-summary');
27
+ const { createArtifactLayout } = require('../core/artifact-layout');
28
+ const { writeJsonArtifact, writeTextArtifact } = require('../core/artifact-writer');
29
+ const { buildBudgetVerdict, buildCausalRun, buildCausalTimeline, buildManifest, buildMetricsFromProfileEvents, buildSummaryMarkdown, extractProfileEvents, } = require('../core/artifact-contract');
30
+ const { SCHEMAS, assertValidJson } = require('../core/schema-validator');
31
+ const { writeUsage } = require('./cli');
32
+ const CAPTURE_EVIDENCE_KINDS = new Set(['screenshot', 'uiTree', 'video']);
33
+ const PROVIDER_EVIDENCE_KINDS = new Set(['accessibility', 'logs', 'profiler']);
34
+ const SIGNAL_EVIDENCE_KINDS = new Set(['js', 'memory', 'network']);
35
+ /**
36
+ * Prints CLI usage to stderr.
37
+ *
38
+ * @returns {void}
39
+ */
40
+ function usage({ binaryName, output = process.stderr, platform, }) {
41
+ const lines = [
42
+ `Usage: ${binaryName} --config <path> --scenario <path> [--events <path>] [--out <dir>] [--run-id <id>]`,
43
+ '',
44
+ `Reads scenario metadata plus profile-event evidence and writes the artifact layout for one ${platform} profile run.`,
45
+ 'Use --comparison-lane <id> to keep latest-trusted baselines inside a stable proof lane.',
46
+ 'Use repeated --provider <manifest> to execute declared evidence-provider commands before artifact writing.',
47
+ 'Use repeated --signal <js|memory|network>:<path> to attach provider signal artifacts.',
48
+ 'Use repeated --capture <screenshot|video|uiTree>:<path> to attach named capture artifacts.',
49
+ ];
50
+ if (platform === 'android') {
51
+ lines.push('Use --adb-artifacts <dir> to read raw/adb-logcat.txt from a prior asl-android-adb capture.');
52
+ lines.push('Use --adb-capture [--clear-logcat] [--launch] [--launch-wait-ms <ms>] [--wait-ms <ms>] to capture adb logcat before profiling.');
53
+ lines.push('Use --android-dev-client-url <url> [--android-dev-client-wait-ms <ms>] [--android-dev-client-ready-pattern <pattern>] with --adb-capture to open an Expo dev-client session before profile-session deep links.');
54
+ lines.push('Use --android-profile-session-storage with --profile-session to seed startup control through Android AsyncStorage.');
55
+ lines.push('Use --profile-session with --adb-capture to start the app profile session and execute scenario-declared Android commands.');
56
+ }
57
+ else {
58
+ lines.push('Use --simctl-artifacts <dir> to read raw/ios-simctl-log.txt from a prior iOS simctl capture.');
59
+ lines.push('Use --simctl-capture [--launch] [--wait-ms <ms>] to capture iOS simulator logs before profiling.');
60
+ lines.push('Use --profile-session with --simctl-capture to start the app profile session and execute scenario-declared iOS commands.');
61
+ lines.push('Use --profile-session-storage with --profile-session to seed startup control through iOS AsyncStorage and collect stored truth events.');
62
+ }
63
+ lines.push('Use --agent-device-capture to execute scenario-declared portable driver actions through agent-device and attach its captures.');
64
+ lines.push('Use --agent-device-session-mode bind when a named agent-device session should still receive the configured Android serial or iOS UDID.');
65
+ writeUsage(lines, output);
66
+ }
67
+ /**
68
+ * Parses `--key value` arguments for a mobile profile runner.
69
+ *
70
+ * @param {string[]} argv
71
+ * @returns {CliArgs}
72
+ */
73
+ function parseArgs(argv) {
74
+ const args = {};
75
+ for (let index = 0; index < argv.length; index += 1) {
76
+ const token = argv[index];
77
+ if (!token) {
78
+ continue;
79
+ }
80
+ if (!token.startsWith('--')) {
81
+ continue;
82
+ }
83
+ const key = token.slice(2);
84
+ const value = argv[index + 1];
85
+ const hasValue = typeof value === 'string' && !value.startsWith('--');
86
+ const resolvedValue = hasValue ? value : true;
87
+ const existingValue = args[key];
88
+ if (Array.isArray(existingValue)) {
89
+ existingValue.push(resolvedValue);
90
+ }
91
+ else if (existingValue !== undefined) {
92
+ args[key] = [existingValue, resolvedValue];
93
+ }
94
+ else {
95
+ args[key] = resolvedValue;
96
+ }
97
+ if (hasValue) {
98
+ index += 1;
99
+ }
100
+ }
101
+ return args;
102
+ }
103
+ /**
104
+ * Returns a scalar CLI flag value, ignoring repeated values for scalar-only flags.
105
+ *
106
+ * @param {string | boolean | Array<string | boolean> | undefined} value
107
+ * @returns {string | boolean | undefined}
108
+ */
109
+ function readScalarArg(value) {
110
+ return Array.isArray(value) ? undefined : value;
111
+ }
112
+ /**
113
+ * Reads and parses a JSON file.
114
+ *
115
+ * @param {string} filePath
116
+ * @returns {Record<string, unknown>}
117
+ */
118
+ function readJson(filePath) {
119
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
120
+ }
121
+ /**
122
+ * Creates a directory and any missing parents.
123
+ *
124
+ * @param {string} dirPath
125
+ * @returns {Promise<void>}
126
+ */
127
+ async function ensureDir(dirPath) {
128
+ await fsp.mkdir(dirPath, { recursive: true });
129
+ }
130
+ /**
131
+ * Returns string values supplied for a repeatable CLI option.
132
+ *
133
+ * @param {CliArgs} args
134
+ * @param {string} key
135
+ * @returns {string[]}
136
+ */
137
+ function readRepeatableArgValues(args, key) {
138
+ const value = args[key];
139
+ const values = Array.isArray(value) ? value : value === undefined ? [] : [value];
140
+ return values.map((entry) => {
141
+ if (typeof entry !== 'string') {
142
+ throw new Error(`--${key} requires a value.`);
143
+ }
144
+ return entry;
145
+ });
146
+ }
147
+ /**
148
+ * Parses a `kind:path` evidence attachment value.
149
+ *
150
+ * @param {{argName: string, allowedKinds: Set<string>, value: string}} options
151
+ * @returns {{kind: string, sourcePath: string}}
152
+ */
153
+ function parseEvidenceArg({ allowedKinds, argName, value, }) {
154
+ const separatorIndex = value.indexOf(':');
155
+ if (separatorIndex <= 0 || separatorIndex === value.length - 1) {
156
+ throw new Error(`--${argName} must use <kind>:<path>.`);
157
+ }
158
+ const kind = value.slice(0, separatorIndex);
159
+ if (!allowedKinds.has(kind)) {
160
+ throw new Error(`Unsupported --${argName} kind "${kind}".`);
161
+ }
162
+ return {
163
+ kind,
164
+ sourcePath: path.resolve(value.slice(separatorIndex + 1)),
165
+ };
166
+ }
167
+ /**
168
+ * Hashes one provider artifact without recording its source path in public metadata.
169
+ *
170
+ * @param {string} filePath
171
+ * @returns {Promise<string>}
172
+ */
173
+ async function hashFileSha256(filePath) {
174
+ const content = await fsp.readFile(filePath);
175
+ return crypto.createHash('sha256').update(content).digest('hex');
176
+ }
177
+ /**
178
+ * Runs one provider command without a shell and captures its output.
179
+ *
180
+ * @param {{command: string, args: string[], cwd?: string, env?: Record<string, string>}} options
181
+ * @returns {Promise<ProviderCommandResult>}
182
+ */
183
+ function execProviderCommand({ args, command, cwd, env, }) {
184
+ return new Promise((resolve) => {
185
+ execFile(command, args, {
186
+ ...(cwd ? { cwd } : {}),
187
+ env: env ? { ...process.env, ...env } : process.env,
188
+ }, (error, stdout, stderr) => {
189
+ resolve({
190
+ args,
191
+ command,
192
+ exitCode: error && typeof error.code === 'number' ? error.code : error ? 1 : 0,
193
+ stderr,
194
+ stdout,
195
+ });
196
+ });
197
+ });
198
+ }
199
+ /**
200
+ * Replaces provider command placeholders with run-local paths and ids.
201
+ *
202
+ * @param {string} value
203
+ * @param {ProviderCommandContext} context
204
+ * @returns {string}
205
+ */
206
+ function applyProviderPlaceholders(value, context) {
207
+ return value
208
+ .replaceAll('{capturesDir}', context.capturesDir)
209
+ .replaceAll('{platform}', context.platform)
210
+ .replaceAll('{providerDir}', context.providerDir)
211
+ .replaceAll('{rawDir}', context.rawDir)
212
+ .replaceAll('{runDir}', context.runDir)
213
+ .replaceAll('{runId}', context.runId)
214
+ .replaceAll('{scenarioId}', context.scenarioId);
215
+ }
216
+ /**
217
+ * Resolves a provider command path after placeholder expansion.
218
+ *
219
+ * @param {{context: ProviderCommandContext, manifestDir: string, value: string}} options
220
+ * @returns {string}
221
+ */
222
+ function resolveProviderPath({ context, manifestDir, value, }) {
223
+ const resolved = applyProviderPlaceholders(value, context);
224
+ return path.isAbsolute(resolved) ? resolved : path.resolve(manifestDir, resolved);
225
+ }
226
+ /**
227
+ * Makes a provider id safe for run-local raw artifact filenames.
228
+ *
229
+ * @param {string} value
230
+ * @returns {string}
231
+ */
232
+ function safeProviderSegment(value) {
233
+ return value.replace(/[^a-z0-9-]+/giu, '-').replace(/^-|-$/gu, '') || 'provider';
234
+ }
235
+ /**
236
+ * Converts one provider-declared output into an attachment copy plan.
237
+ *
238
+ * @param {{layout: ReturnType<typeof createArtifactLayout>, output: ProviderCommandOutput, providerId: string, sourcePath: string}} options
239
+ * @returns {EvidenceAttachmentInput}
240
+ */
241
+ function buildProviderEvidenceInput({ layout, output, providerId, sourcePath, }) {
242
+ const fileName = path.basename(sourcePath);
243
+ if (output.channel === 'signal') {
244
+ if (!SIGNAL_EVIDENCE_KINDS.has(output.kind)) {
245
+ throw new Error(`Provider output ${providerId}/${output.path} uses signal channel with unsupported kind "${output.kind}".`);
246
+ }
247
+ const kind = output.kind;
248
+ return {
249
+ channel: 'signal',
250
+ destinationPath: path.join(layout.signals[kind], fileName),
251
+ kind,
252
+ manifestPath: `signals/${kind}/${fileName}`,
253
+ sourcePath,
254
+ };
255
+ }
256
+ if (output.channel === 'capture') {
257
+ if (!CAPTURE_EVIDENCE_KINDS.has(output.kind)) {
258
+ throw new Error(`Provider output ${providerId}/${output.path} uses capture channel with unsupported kind "${output.kind}".`);
259
+ }
260
+ return {
261
+ channel: 'capture',
262
+ destinationPath: path.join(layout.captures, fileName),
263
+ kind: output.kind,
264
+ manifestPath: `captures/${fileName}`,
265
+ sourcePath,
266
+ };
267
+ }
268
+ if (!PROVIDER_EVIDENCE_KINDS.has(output.kind) && !SIGNAL_EVIDENCE_KINDS.has(output.kind)) {
269
+ throw new Error(`Provider output ${providerId}/${output.path} uses unsupported provider kind "${output.kind}".`);
270
+ }
271
+ return {
272
+ channel: 'provider',
273
+ destinationPath: path.join(layout.raw, 'providers', providerId, fileName),
274
+ kind: output.kind,
275
+ manifestPath: `raw/providers/${providerId}/${fileName}`,
276
+ sourcePath,
277
+ };
278
+ }
279
+ /**
280
+ * Fails when provider command ids would collide in raw command records.
281
+ *
282
+ * @param {{providerCommands?: ProviderCommand[], providerId: string}} options
283
+ * @returns {void}
284
+ */
285
+ function assertUniqueProviderCommandIds({ providerCommands = [], providerId, }) {
286
+ const seen = new Set();
287
+ for (const providerCommand of providerCommands) {
288
+ if (seen.has(providerCommand.id)) {
289
+ throw new Error(`Evidence provider \`${providerId}\` declares duplicate providerCommand id \`${providerCommand.id}\`.`);
290
+ }
291
+ seen.add(providerCommand.id);
292
+ }
293
+ }
294
+ /**
295
+ * Executes declared evidence-provider commands and returns their output attachments.
296
+ *
297
+ * @param {{args: CliArgs, layout: ReturnType<typeof createArtifactLayout>, platform: ProfilePlatform, runDir: string, runId: string, scenarioId: string}} options
298
+ * @returns {Promise<ProviderCommandExecution>}
299
+ */
300
+ async function executeProviderCommands({ args, layout, platform, runDir, runId, scenarioId, }) {
301
+ const failures = [];
302
+ const inputs = [];
303
+ const providerManifestPaths = readRepeatableArgValues(args, 'provider');
304
+ if (providerManifestPaths.length === 0) {
305
+ return { failures, inputs };
306
+ }
307
+ const commandRecordDir = path.join(layout.raw, 'provider-commands');
308
+ await ensureDir(commandRecordDir);
309
+ for (const providerManifestPath of providerManifestPaths) {
310
+ const absoluteManifestPath = path.resolve(providerManifestPath);
311
+ const manifestDir = path.dirname(absoluteManifestPath);
312
+ const provider = assertValidJson(readJson(absoluteManifestPath), SCHEMAS.runnerCapabilities, 'Evidence provider manifest');
313
+ if (provider.kind !== 'evidenceProvider') {
314
+ throw new Error(`Provider manifest must use kind "evidenceProvider": ${absoluteManifestPath}`);
315
+ }
316
+ const providerId = safeProviderSegment(String(provider.runnerId ?? path.basename(absoluteManifestPath, '.json')));
317
+ if (Array.isArray(provider.platforms) && !provider.platforms.includes(platform)) {
318
+ failures.push({
319
+ commandId: 'platform-compatibility',
320
+ code: 'provider_platform_unsupported',
321
+ exitCode: null,
322
+ message: `Evidence provider ${providerId} does not support selected platform "${platform}".`,
323
+ name: 'evidence_provider_platform_supported',
324
+ nextAction: `Use a provider manifest whose platforms include "${platform}", or run this scenario on one of the provider's supported platforms.`,
325
+ nextActionCode: 'select_supported_provider_platform',
326
+ phase: 'prepare',
327
+ providerId,
328
+ });
329
+ continue;
330
+ }
331
+ assertUniqueProviderCommandIds({
332
+ providerCommands: provider.providerCommands,
333
+ providerId,
334
+ });
335
+ const providerDir = path.join(layout.raw, 'providers', providerId);
336
+ await ensureDir(providerDir);
337
+ const context = {
338
+ capturesDir: layout.captures,
339
+ platform,
340
+ providerDir,
341
+ rawDir: layout.raw,
342
+ runDir,
343
+ runId,
344
+ scenarioId,
345
+ };
346
+ for (const providerCommand of provider.providerCommands ?? []) {
347
+ const resolvedCommand = applyProviderPlaceholders(providerCommand.command, context);
348
+ const resolvedArgs = (providerCommand.args ?? []).map((arg) => applyProviderPlaceholders(arg, context));
349
+ const resolvedCwd = providerCommand.cwd
350
+ ? resolveProviderPath({ context, manifestDir, value: providerCommand.cwd })
351
+ : manifestDir;
352
+ const resolvedEnv = Object.fromEntries(Object.entries(providerCommand.env ?? {}).map(([key, value]) => [key, applyProviderPlaceholders(value, context)]));
353
+ const commandResult = await execProviderCommand({
354
+ args: resolvedArgs,
355
+ command: resolvedCommand,
356
+ cwd: resolvedCwd,
357
+ env: resolvedEnv,
358
+ });
359
+ const commandRecordFileName = `${providerId}-${providerCommand.id}.json`;
360
+ const commandRecordPath = path.join(commandRecordDir, commandRecordFileName);
361
+ await fsp.writeFile(commandRecordPath, `${JSON.stringify({
362
+ args: commandResult.args,
363
+ command: commandResult.command,
364
+ exitCode: commandResult.exitCode,
365
+ phase: providerCommand.phase,
366
+ providerId,
367
+ stderr: commandResult.stderr,
368
+ stdout: commandResult.stdout,
369
+ }, null, 2)}\n`, 'utf8');
370
+ if (commandResult.exitCode !== 0) {
371
+ failures.push({
372
+ commandId: providerCommand.id,
373
+ code: 'provider_command_failed',
374
+ exitCode: commandResult.exitCode,
375
+ message: `Evidence provider command ${providerId}/${providerCommand.id} failed with exit code ${commandResult.exitCode}.`,
376
+ name: 'evidence_provider_command_completed',
377
+ nextAction: `Inspect raw/provider-commands/${commandRecordFileName}, fix the provider command or its environment, then rerun the profile.`,
378
+ nextActionCode: 'fix_provider_command',
379
+ phase: providerCommand.phase,
380
+ providerId,
381
+ rawPath: `raw/provider-commands/${commandRecordFileName}`,
382
+ });
383
+ continue;
384
+ }
385
+ for (const output of providerCommand.outputs) {
386
+ inputs.push(buildProviderEvidenceInput({
387
+ layout,
388
+ output,
389
+ providerId,
390
+ sourcePath: resolveProviderPath({ context, manifestDir, value: output.path }),
391
+ }));
392
+ }
393
+ }
394
+ }
395
+ return { failures, inputs };
396
+ }
397
+ /**
398
+ * Converts internal attachment copy plans into manifest-safe metadata.
399
+ *
400
+ * @param {EvidenceAttachment[]} attachments
401
+ * @returns {Record<string, unknown>[]}
402
+ */
403
+ function buildEvidenceAttachmentManifest(attachments) {
404
+ return attachments.map((attachment) => ({
405
+ channel: attachment.channel,
406
+ kind: attachment.kind,
407
+ path: attachment.manifestPath,
408
+ sha256: attachment.sha256,
409
+ sizeBytes: attachment.sizeBytes,
410
+ sourceFileName: attachment.sourceFileName,
411
+ }));
412
+ }
413
+ /**
414
+ * Validates provider artifact files and resolves their stable run destinations.
415
+ *
416
+ * @param {{args: CliArgs, layout: ReturnType<typeof createArtifactLayout>, providerInputs?: EvidenceAttachmentInput[]}} options
417
+ * @returns {Promise<AttachedEvidence>}
418
+ */
419
+ async function resolveAttachedEvidence({ args, layout, providerInputs = [], }) {
420
+ const attached = {
421
+ attachments: [],
422
+ captures: {
423
+ screenshots: [],
424
+ uiTree: null,
425
+ video: null,
426
+ },
427
+ copies: [],
428
+ signals: {
429
+ js: [],
430
+ memory: [],
431
+ network: [],
432
+ },
433
+ };
434
+ const destinationPaths = new Set();
435
+ const addCopy = async ({ channel, destinationPath, kind, manifestPath, sourcePath, }) => {
436
+ const stat = await fsp.stat(sourcePath).catch(() => null);
437
+ if (!stat?.isFile()) {
438
+ throw new Error(`Evidence artifact does not exist or is not a file: ${sourcePath}`);
439
+ }
440
+ if (destinationPaths.has(destinationPath)) {
441
+ throw new Error(`Duplicate evidence artifact destination: ${manifestPath}`);
442
+ }
443
+ destinationPaths.add(destinationPath);
444
+ const attachment = {
445
+ channel,
446
+ destinationPath,
447
+ kind,
448
+ manifestPath,
449
+ sha256: await hashFileSha256(sourcePath),
450
+ sourceFileName: path.basename(sourcePath),
451
+ sourcePath,
452
+ sizeBytes: stat.size,
453
+ };
454
+ attached.attachments.push(attachment);
455
+ attached.copies.push(attachment);
456
+ };
457
+ const addAttachmentInput = async (input) => {
458
+ if (input.channel === 'signal') {
459
+ if (!SIGNAL_EVIDENCE_KINDS.has(input.kind)) {
460
+ throw new Error(`Signal evidence kind "${input.kind}" is not supported.`);
461
+ }
462
+ attached.signals[input.kind].push(input.manifestPath);
463
+ }
464
+ else if (input.channel === 'capture') {
465
+ if (input.kind === 'screenshot') {
466
+ attached.captures.screenshots.push(input.manifestPath);
467
+ }
468
+ else if (input.kind === 'uiTree' || input.kind === 'video') {
469
+ if (attached.captures[input.kind]) {
470
+ throw new Error(`Duplicate capture kind "${input.kind}".`);
471
+ }
472
+ attached.captures[input.kind] = input.manifestPath;
473
+ }
474
+ else {
475
+ throw new Error(`Capture evidence kind "${input.kind}" is not supported.`);
476
+ }
477
+ }
478
+ await addCopy(input);
479
+ };
480
+ for (const input of providerInputs) {
481
+ await addAttachmentInput(input);
482
+ }
483
+ for (const value of readRepeatableArgValues(args, 'signal')) {
484
+ const parsed = parseEvidenceArg({
485
+ allowedKinds: SIGNAL_EVIDENCE_KINDS,
486
+ argName: 'signal',
487
+ value,
488
+ });
489
+ const fileName = path.basename(parsed.sourcePath);
490
+ const manifestPath = `signals/${parsed.kind}/${fileName}`;
491
+ await addAttachmentInput({
492
+ channel: 'signal',
493
+ destinationPath: path.join(layout.signals[parsed.kind], fileName),
494
+ kind: parsed.kind,
495
+ manifestPath,
496
+ sourcePath: parsed.sourcePath,
497
+ });
498
+ }
499
+ for (const value of readRepeatableArgValues(args, 'capture')) {
500
+ const parsed = parseEvidenceArg({
501
+ allowedKinds: CAPTURE_EVIDENCE_KINDS,
502
+ argName: 'capture',
503
+ value,
504
+ });
505
+ const fileName = path.basename(parsed.sourcePath);
506
+ const manifestPath = `captures/${fileName}`;
507
+ if (parsed.kind === 'screenshot') {
508
+ await addAttachmentInput({
509
+ channel: 'capture',
510
+ destinationPath: path.join(layout.captures, fileName),
511
+ kind: parsed.kind,
512
+ manifestPath,
513
+ sourcePath: parsed.sourcePath,
514
+ });
515
+ continue;
516
+ }
517
+ if (attached.captures[parsed.kind]) {
518
+ throw new Error(`Duplicate --capture kind "${parsed.kind}".`);
519
+ }
520
+ await addAttachmentInput({
521
+ channel: 'capture',
522
+ destinationPath: path.join(layout.captures, fileName),
523
+ kind: parsed.kind,
524
+ manifestPath,
525
+ sourcePath: parsed.sourcePath,
526
+ });
527
+ }
528
+ return attached;
529
+ }
530
+ /**
531
+ * Copies validated provider artifacts into the run artifact folder.
532
+ *
533
+ * @param {EvidenceAttachment[]} copies
534
+ * @returns {Promise<void>}
535
+ */
536
+ async function copyAttachedEvidence(copies) {
537
+ for (const copy of copies) {
538
+ if (path.resolve(copy.sourcePath) === path.resolve(copy.destinationPath)) {
539
+ continue;
540
+ }
541
+ await fsp.copyFile(copy.sourcePath, copy.destinationPath);
542
+ }
543
+ }
544
+ /**
545
+ * Creates a short random run id for manual profile runs.
546
+ *
547
+ * @returns {string}
548
+ */
549
+ function createRunId() {
550
+ return crypto.randomBytes(6).toString('hex');
551
+ }
552
+ /**
553
+ * Returns a path reference that can move with the artifact folder when possible.
554
+ *
555
+ * @param {string} targetPath
556
+ * @returns {string}
557
+ */
558
+ function toPortablePathReference(targetPath) {
559
+ const cwdRelativePath = path.relative(process.cwd(), targetPath);
560
+ if (cwdRelativePath &&
561
+ !cwdRelativePath.startsWith('..') &&
562
+ !path.isAbsolute(cwdRelativePath)) {
563
+ return cwdRelativePath;
564
+ }
565
+ return path.basename(targetPath);
566
+ }
567
+ /**
568
+ * Builds scenario health from profile metrics.
569
+ *
570
+ * @param {{scenario: Record<string, unknown>, runId: string, metrics: Record<string, unknown>}} options
571
+ * @returns {Record<string, unknown>}
572
+ */
573
+ function buildProfileHealth({ scenario, runId, metrics, }) {
574
+ const passed = metrics.status === 'passed';
575
+ return assertValidJson({
576
+ schemaVersion: '1.0.0',
577
+ scenarioId: scenario.name,
578
+ ...(typeof scenario.flowId === 'string' ? { flowId: scenario.flowId } : {}),
579
+ runId,
580
+ healthStatus: passed ? 'passed' : 'failed',
581
+ checks: [
582
+ {
583
+ name: 'truth_events_complete',
584
+ status: passed ? 'passed' : 'failed',
585
+ source: 'truth',
586
+ code: passed ? 'truth_events_complete' : 'truth_events_incomplete',
587
+ message: passed
588
+ ? 'Profile events completed every expected iteration.'
589
+ : 'Profile events did not complete every expected iteration.',
590
+ metadata: {
591
+ failures: typeof metrics.failures === 'number' ? metrics.failures : null,
592
+ timeouts: typeof metrics.timeouts === 'number' ? metrics.timeouts : null,
593
+ },
594
+ },
595
+ ],
596
+ }, SCHEMAS.health, 'Health artifact');
597
+ }
598
+ /**
599
+ * Builds failed scenario health from evidence-provider command failures.
600
+ *
601
+ * @param {{failures: ProviderCommandFailure[], runId: string, scenario: Record<string, unknown>}} options
602
+ * @returns {Record<string, unknown>}
603
+ */
604
+ function buildProviderCommandFailureHealth({ failures, runId, scenario, }) {
605
+ return assertValidJson({
606
+ schemaVersion: '1.0.0',
607
+ scenarioId: scenario.name,
608
+ ...(typeof scenario.flowId === 'string' ? { flowId: scenario.flowId } : {}),
609
+ runId,
610
+ healthStatus: 'failed',
611
+ checks: failures.map((failure) => ({
612
+ name: failure.name ?? 'evidence_provider_command_completed',
613
+ status: 'failed',
614
+ source: 'evidence',
615
+ code: failure.code ?? 'provider_command_failed',
616
+ message: failure.message ?? `Evidence provider command ${failure.providerId}/${failure.commandId} failed with exit code ${failure.exitCode}.`,
617
+ metadata: {
618
+ commandId: failure.commandId,
619
+ exitCode: failure.exitCode,
620
+ nextAction: failure.nextAction ?? `Inspect ${failure.rawPath}, fix the provider command or its environment, then rerun the profile.`,
621
+ nextActionCode: failure.nextActionCode ?? 'fix_provider_command',
622
+ phase: failure.phase,
623
+ providerId: failure.providerId,
624
+ ...(failure.rawPath ? { rawPath: failure.rawPath } : {}),
625
+ },
626
+ })),
627
+ }, SCHEMAS.health, 'Health artifact');
628
+ }
629
+ /**
630
+ * Converts profile budget evaluation checks into verdict budget checks.
631
+ *
632
+ * @param {Record<string, unknown> | null | undefined} budgetEvaluation
633
+ * @returns {Record<string, unknown>[]}
634
+ */
635
+ function buildVerdictBudgetChecks(budgetEvaluation) {
636
+ if (!Array.isArray(budgetEvaluation?.checks)) {
637
+ return [];
638
+ }
639
+ return budgetEvaluation.checks.map((check) => ({
640
+ name: String(check.name ?? 'unknown budget'),
641
+ source: 'milestone',
642
+ metric: String(budgetEvaluation.metric ?? check.name ?? 'profile budget'),
643
+ unit: check.unit === 'count' ? 'count' : 'ms',
644
+ expected: check.limit,
645
+ actual: check.actual ?? null,
646
+ pass: Boolean(check.pass),
647
+ }));
648
+ }
649
+ /**
650
+ * Builds product verdict from profile metrics and budget evaluation.
651
+ *
652
+ * @param {{scenario: Record<string, unknown>, runId: string, health: Record<string, unknown>, metrics: Record<string, unknown>}} options
653
+ * @returns {Record<string, unknown>}
654
+ */
655
+ function buildProfileVerdict({ scenario, runId, health, metrics, }) {
656
+ const healthPassed = health.healthStatus === 'passed';
657
+ const budgetEvaluation = metrics.budgetEvaluation;
658
+ const budgetChecks = buildVerdictBudgetChecks(budgetEvaluation);
659
+ const verdictStatus = !healthPassed
660
+ ? 'inconclusive'
661
+ : budgetEvaluation
662
+ ? budgetEvaluation.pass
663
+ ? 'passed'
664
+ : 'failed'
665
+ : 'not_evaluated';
666
+ return assertValidJson({
667
+ schemaVersion: '1.0.0',
668
+ scenarioId: scenario.name,
669
+ ...(typeof scenario.flowId === 'string' ? { flowId: scenario.flowId } : {}),
670
+ runId,
671
+ healthStatus: health.healthStatus,
672
+ verdictStatus,
673
+ ...(budgetChecks.length > 0 ? { budgetChecks } : {}),
674
+ summary: !healthPassed
675
+ ? 'Scenario health did not pass; do not compare or optimize from this run.'
676
+ : budgetEvaluation
677
+ ? `Profile budgets ${budgetEvaluation.pass ? 'passed' : 'failed'}.`
678
+ : 'Scenario health passed; no profile budgets were configured.',
679
+ }, SCHEMAS.verdict, 'Verdict artifact');
680
+ }
681
+ /**
682
+ * Resolves the configured artifact root for a platform profile run.
683
+ *
684
+ * @param {{args: CliArgs, config: Record<string, unknown>, configPath: string, platform: ProfilePlatform}} options
685
+ * @returns {string}
686
+ */
687
+ function resolveArtifactRoot({ args, config, configPath, platform, }) {
688
+ if (typeof args.out === 'string') {
689
+ return path.resolve(args.out);
690
+ }
691
+ const platformRootKey = `${platform}ArtifactsRoot`;
692
+ const configuredPlatformRoot = config.paths?.[platformRootKey];
693
+ if (typeof configuredPlatformRoot === 'string') {
694
+ return path.resolve(path.dirname(configPath), configuredPlatformRoot);
695
+ }
696
+ const configuredArtifactRoot = config.paths?.artifactRoot;
697
+ if (typeof configuredArtifactRoot === 'string') {
698
+ return path.resolve(path.dirname(configPath), configuredArtifactRoot, platform);
699
+ }
700
+ return path.resolve(path.dirname(configPath), 'artifacts', platform);
701
+ }
702
+ /**
703
+ * Resolves the app identifier used in profile manifests.
704
+ *
705
+ * @param {{config: Record<string, unknown>, platform: ProfilePlatform}} options
706
+ * @returns {string}
707
+ */
708
+ function resolveAppId({ config, platform }) {
709
+ if (platform === 'android') {
710
+ return typeof config.app?.androidPackage === 'string' ? config.app.androidPackage : 'com.example.app';
711
+ }
712
+ return typeof config.app?.iosBundleId === 'string' ? config.app.iosBundleId : 'com.example.app';
713
+ }
714
+ /**
715
+ * Resolves the interaction driver recorded in profile artifacts.
716
+ *
717
+ * @param {{config: Record<string, unknown>, options: ProfileMobileOptions, scenario: Record<string, unknown>}} options
718
+ * @returns {string}
719
+ */
720
+ function resolveInteractionDriver({ config, options, scenario, }) {
721
+ return options.interactionDriver || scenario.interactionDriver || config.drivers?.default || options.defaultDriver;
722
+ }
723
+ /**
724
+ * Resolves the stable comparison lane used for historical baseline selection.
725
+ *
726
+ * @param {{args: CliArgs, options: ProfileMobileOptions, scenario: Record<string, unknown>}} options
727
+ * @returns {string | undefined}
728
+ */
729
+ function resolveComparisonLane({ args, options, scenario, }) {
730
+ const cliLane = readScalarArg(args['comparison-lane']);
731
+ if (typeof cliLane === 'string' && cliLane.trim().length > 0) {
732
+ return cliLane.trim();
733
+ }
734
+ if (typeof options.comparisonLane === 'string' && options.comparisonLane.trim().length > 0) {
735
+ return options.comparisonLane.trim();
736
+ }
737
+ return typeof scenario.comparisonLane === 'string' && scenario.comparisonLane.trim().length > 0
738
+ ? scenario.comparisonLane.trim()
739
+ : undefined;
740
+ }
741
+ /**
742
+ * Resolves the profile event log source from explicit logs or prior adb artifacts.
743
+ *
744
+ * @param {{args: CliArgs, platform: ProfilePlatform}} options
745
+ * @returns {string | null}
746
+ */
747
+ function resolveEventLogPath({ args, platform }) {
748
+ if (typeof args.events === 'string') {
749
+ return path.resolve(args.events);
750
+ }
751
+ if (platform === 'android' && typeof args['adb-artifacts'] === 'string') {
752
+ return path.resolve(args['adb-artifacts'], 'raw', 'adb-logcat.txt');
753
+ }
754
+ if (platform === 'ios' && typeof args['simctl-artifacts'] === 'string') {
755
+ const storedEventLogPath = path.resolve(args['simctl-artifacts'], 'raw', 'ios-profile-events.log');
756
+ if (fs.existsSync(storedEventLogPath)) {
757
+ return storedEventLogPath;
758
+ }
759
+ return path.resolve(args['simctl-artifacts'], 'raw', 'ios-simctl-log.txt');
760
+ }
761
+ return null;
762
+ }
763
+ /**
764
+ * Reads a JSON artifact if it exists and contains an object.
765
+ *
766
+ * @param {string} filePath
767
+ * @returns {Record<string, unknown> | null}
768
+ */
769
+ function readOptionalJsonObject(filePath) {
770
+ if (!fs.existsSync(filePath)) {
771
+ return null;
772
+ }
773
+ const value = JSON.parse(fs.readFileSync(filePath, 'utf8'));
774
+ return value && typeof value === 'object' && !Array.isArray(value)
775
+ ? value
776
+ : null;
777
+ }
778
+ /**
779
+ * Builds an Android target label from adb capture metadata.
780
+ *
781
+ * @param {Record<string, unknown>} metadata
782
+ * @returns {RuntimeTarget | null}
783
+ */
784
+ function resolveAndroidRuntimeTarget(metadata) {
785
+ const selectedDevice = metadata.selectedDevice && typeof metadata.selectedDevice === 'object' && !Array.isArray(metadata.selectedDevice)
786
+ ? metadata.selectedDevice
787
+ : null;
788
+ const deviceProperties = metadata.deviceProperties && typeof metadata.deviceProperties === 'object' && !Array.isArray(metadata.deviceProperties)
789
+ ? metadata.deviceProperties
790
+ : null;
791
+ const serial = typeof selectedDevice?.serial === 'string' ? selectedDevice.serial : null;
792
+ if (!serial) {
793
+ return null;
794
+ }
795
+ const model = typeof deviceProperties?.model === 'string' && deviceProperties.model.trim().length > 0
796
+ ? deviceProperties.model.trim()
797
+ : 'android device';
798
+ const release = typeof deviceProperties?.release === 'string' && deviceProperties.release.trim().length > 0
799
+ ? ` Android ${deviceProperties.release.trim()}`
800
+ : '';
801
+ const sdk = typeof deviceProperties?.sdk === 'string' && deviceProperties.sdk.trim().length > 0
802
+ ? ` API ${deviceProperties.sdk.trim()}`
803
+ : '';
804
+ return {
805
+ name: `${model}${release}${sdk}`.trim(),
806
+ udid: serial,
807
+ };
808
+ }
809
+ /**
810
+ * Builds an iOS target label from simctl capture metadata.
811
+ *
812
+ * @param {Record<string, unknown>} metadata
813
+ * @returns {RuntimeTarget | null}
814
+ */
815
+ function resolveIosRuntimeTarget(metadata) {
816
+ const selectedSimulator = metadata.selectedSimulator && typeof metadata.selectedSimulator === 'object' && !Array.isArray(metadata.selectedSimulator)
817
+ ? metadata.selectedSimulator
818
+ : null;
819
+ const name = typeof selectedSimulator?.name === 'string' ? selectedSimulator.name : null;
820
+ const udid = typeof selectedSimulator?.udid === 'string' ? selectedSimulator.udid : null;
821
+ if (!name || !udid) {
822
+ return null;
823
+ }
824
+ return {
825
+ name,
826
+ udid,
827
+ };
828
+ }
829
+ /**
830
+ * Resolves the runtime target attached to adb or simctl capture artifacts.
831
+ *
832
+ * @param {{args: CliArgs, platform: ProfilePlatform}} options
833
+ * @returns {RuntimeTarget}
834
+ */
835
+ function resolveRuntimeTarget({ args, platform }) {
836
+ if (platform === 'android' && typeof args['adb-artifacts'] === 'string') {
837
+ const metadata = readOptionalJsonObject(path.resolve(args['adb-artifacts'], 'raw', 'android-metadata.json'));
838
+ const target = metadata ? resolveAndroidRuntimeTarget(metadata) : null;
839
+ if (target) {
840
+ return target;
841
+ }
842
+ }
843
+ if (platform === 'ios' && typeof args['simctl-artifacts'] === 'string') {
844
+ const metadata = readOptionalJsonObject(path.resolve(args['simctl-artifacts'], 'raw', 'ios-metadata.json'));
845
+ const target = metadata ? resolveIosRuntimeTarget(metadata) : null;
846
+ if (target) {
847
+ return target;
848
+ }
849
+ }
850
+ return {
851
+ name: platform === 'android' ? 'unknown android device' : 'unknown',
852
+ udid: 'unknown',
853
+ };
854
+ }
855
+ /**
856
+ * Resolves the profile scenario name from modern or legacy scenario identity fields.
857
+ *
858
+ * @param {{scenario: Record<string, unknown>, scenarioPath: string}} options
859
+ * @returns {string}
860
+ */
861
+ function resolveProfileScenarioName({ scenario, scenarioPath, }) {
862
+ if (typeof scenario.name === 'string' && scenario.name.length > 0) {
863
+ return scenario.name;
864
+ }
865
+ if (typeof scenario.id === 'string' && scenario.id.length > 0) {
866
+ return scenario.id;
867
+ }
868
+ return path.basename(scenarioPath, '.json');
869
+ }
870
+ /**
871
+ * Serializes JSON with stable object key ordering for reproducible hashes.
872
+ *
873
+ * @param {unknown} value
874
+ * @returns {string}
875
+ */
876
+ function stableJsonStringify(value) {
877
+ if (Array.isArray(value)) {
878
+ return `[${value.map(stableJsonStringify).join(',')}]`;
879
+ }
880
+ if (value && typeof value === 'object') {
881
+ const record = value;
882
+ return `{${Object.keys(record)
883
+ .sort()
884
+ .map((key) => `${JSON.stringify(key)}:${stableJsonStringify(record[key])}`)
885
+ .join(',')}}`;
886
+ }
887
+ return JSON.stringify(value) ?? 'null';
888
+ }
889
+ /**
890
+ * Creates a stable fingerprint for the scenario contract used by one run.
891
+ *
892
+ * @param {Record<string, unknown>} scenario
893
+ * @returns {string}
894
+ */
895
+ function hashScenarioContract(scenario) {
896
+ return crypto.createHash('sha256').update(stableJsonStringify(scenario)).digest('hex');
897
+ }
898
+ /**
899
+ * Returns true when a value is a plain object record.
900
+ *
901
+ * @param {unknown} value
902
+ * @returns {value is Record<string, unknown>}
903
+ */
904
+ function isRecord(value) {
905
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
906
+ }
907
+ /**
908
+ * Reads a positive integer from scenario metadata.
909
+ *
910
+ * @param {unknown} value
911
+ * @param {number} fallback
912
+ * @returns {number}
913
+ */
914
+ function readPositiveInteger(value, fallback) {
915
+ const parsed = typeof value === 'string' ? Number(value) : value;
916
+ return typeof parsed === 'number' && Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
917
+ }
918
+ /**
919
+ * Resolves the expected profile-event iteration count for a scenario.
920
+ *
921
+ * @param {Record<string, unknown>} scenario
922
+ * @returns {number}
923
+ */
924
+ function resolveExpectedIterations(scenario) {
925
+ if (typeof scenario.defaultIterations === 'number' || typeof scenario.defaultIterations === 'string') {
926
+ return readPositiveInteger(scenario.defaultIterations, 1);
927
+ }
928
+ return readPositiveInteger(isRecord(scenario.cycles) ? scenario.cycles.iterations : undefined, 1);
929
+ }
930
+ /**
931
+ * Finds a milestone event name by milestone id.
932
+ *
933
+ * @param {Record<string, unknown>} scenario
934
+ * @param {unknown} milestoneId
935
+ * @returns {string | null}
936
+ */
937
+ function findMilestoneEvent(scenario, milestoneId) {
938
+ if (typeof milestoneId !== 'string' || !Array.isArray(scenario.milestones)) {
939
+ return null;
940
+ }
941
+ for (const milestone of scenario.milestones) {
942
+ if (!isRecord(milestone)) {
943
+ continue;
944
+ }
945
+ if (milestone.id === milestoneId && typeof milestone.event === 'string') {
946
+ return milestone.event;
947
+ }
948
+ }
949
+ return null;
950
+ }
951
+ /**
952
+ * Builds a milestone-id to event-name lookup for schema-era scenarios.
953
+ *
954
+ * @param {Record<string, unknown>} scenario
955
+ * @returns {Record<string, string>}
956
+ */
957
+ function buildMilestoneEventLookup(scenario) {
958
+ const lookup = {};
959
+ if (!Array.isArray(scenario.milestones)) {
960
+ return lookup;
961
+ }
962
+ for (const milestone of scenario.milestones) {
963
+ if (!isRecord(milestone) || typeof milestone.id !== 'string' || typeof milestone.event !== 'string') {
964
+ continue;
965
+ }
966
+ lookup[milestone.id] = milestone.event;
967
+ }
968
+ return lookup;
969
+ }
970
+ /**
971
+ * Derives cycle metric event names from schema-era milestone budgets when needed.
972
+ *
973
+ * @param {Record<string, unknown>} scenario
974
+ * @returns {Record<string, string> | null}
975
+ */
976
+ function resolveProfileMetricEvents(scenario) {
977
+ if (isRecord(scenario.metricEvents)) {
978
+ return scenario.metricEvents;
979
+ }
980
+ if (!Array.isArray(scenario.budgets)) {
981
+ return null;
982
+ }
983
+ const milestoneEvents = buildMilestoneEventLookup(scenario);
984
+ if (milestoneEvents.openRequested &&
985
+ milestoneEvents.opened &&
986
+ milestoneEvents.closeRequested &&
987
+ milestoneEvents.dismissed) {
988
+ return {
989
+ closeRequested: milestoneEvents.closeRequested,
990
+ dismissed: milestoneEvents.dismissed,
991
+ opened: milestoneEvents.opened,
992
+ openRequested: milestoneEvents.openRequested,
993
+ };
994
+ }
995
+ for (const budget of scenario.budgets) {
996
+ if (!isRecord(budget)) {
997
+ continue;
998
+ }
999
+ const fromEvent = findMilestoneEvent(scenario, budget.fromMilestone);
1000
+ const toEvent = findMilestoneEvent(scenario, budget.toMilestone);
1001
+ if (!fromEvent && toEvent) {
1002
+ return {
1003
+ milestone: toEvent,
1004
+ };
1005
+ }
1006
+ if (fromEvent && toEvent) {
1007
+ return {
1008
+ closeRequested: toEvent,
1009
+ dismissed: toEvent,
1010
+ opened: fromEvent,
1011
+ openRequested: fromEvent,
1012
+ };
1013
+ }
1014
+ }
1015
+ return null;
1016
+ }
1017
+ /**
1018
+ * Maps schema-era milestone budget fields to the legacy profile budget keys.
1019
+ *
1020
+ * @param {{budget: Record<string, unknown>, metric: string}} options
1021
+ * @returns {string | null}
1022
+ */
1023
+ function resolveProfileBudgetKey({ budget, metric, }) {
1024
+ const suffix = metric === 'p95' ? 'P95Ms' : metric === 'p50' ? 'P50Ms' : null;
1025
+ if (!suffix) {
1026
+ return null;
1027
+ }
1028
+ if (budget.fromMilestone === 'openRequested' && budget.toMilestone === 'opened') {
1029
+ return `open${suffix}`;
1030
+ }
1031
+ if (budget.fromMilestone === 'closeRequested' && budget.toMilestone === 'dismissed') {
1032
+ return `close${suffix}`;
1033
+ }
1034
+ return `cycle${suffix}`;
1035
+ }
1036
+ /**
1037
+ * Normalizes schema-era budget arrays into the profile budget evaluator shape.
1038
+ *
1039
+ * @param {Record<string, unknown>} scenario
1040
+ * @returns {Record<string, unknown> | null}
1041
+ */
1042
+ function resolveProfileBudgets(scenario) {
1043
+ if (isRecord(scenario.budgets)) {
1044
+ return scenario.budgets;
1045
+ }
1046
+ if (!Array.isArray(scenario.budgets)) {
1047
+ return null;
1048
+ }
1049
+ const pass = {};
1050
+ for (const budget of scenario.budgets) {
1051
+ if (!isRecord(budget) || typeof budget.limit !== 'number') {
1052
+ continue;
1053
+ }
1054
+ if (budget.metric === 'p95' || budget.metric === 'p50') {
1055
+ const budgetKey = resolveProfileBudgetKey({ budget, metric: budget.metric });
1056
+ if (budgetKey) {
1057
+ pass[budgetKey] = budget.limit;
1058
+ }
1059
+ }
1060
+ else if (budget.metric === 'failures') {
1061
+ pass.failures = budget.limit;
1062
+ }
1063
+ else if (budget.metric === 'timeouts') {
1064
+ pass.timeouts = budget.limit;
1065
+ }
1066
+ }
1067
+ return Object.keys(pass).length > 0
1068
+ ? {
1069
+ metric: 'milestone budget',
1070
+ pass,
1071
+ }
1072
+ : null;
1073
+ }
1074
+ /**
1075
+ * Runs the mobile log-ingest profile artifact pipeline.
1076
+ *
1077
+ * @param {CliArgs} args
1078
+ * @param {ProfileMobileOptions} options
1079
+ * @returns {Promise<ProfileRunResult>}
1080
+ */
1081
+ async function runProfileMobile(args, options) {
1082
+ if (typeof args.config !== 'string' || typeof args.scenario !== 'string') {
1083
+ throw new Error('Both --config and --scenario are required.');
1084
+ }
1085
+ const configPath = path.resolve(args.config);
1086
+ const scenarioPath = path.resolve(args.scenario);
1087
+ const config = readJson(configPath);
1088
+ const scenario = readJson(scenarioPath);
1089
+ const scenarioName = resolveProfileScenarioName({ scenario, scenarioPath });
1090
+ const profileScenario = { ...scenario, name: scenarioName };
1091
+ const scenarioHash = hashScenarioContract(profileScenario);
1092
+ const expectedIterations = resolveExpectedIterations(profileScenario);
1093
+ const profileMetricEvents = resolveProfileMetricEvents(profileScenario);
1094
+ const profileBudgets = resolveProfileBudgets(profileScenario);
1095
+ const runId = typeof args['run-id'] === 'string' ? args['run-id'] : createRunId();
1096
+ const artifactRoot = resolveArtifactRoot({ args, config, configPath, platform: options.platform });
1097
+ const runDir = path.join(artifactRoot, scenarioName, runId);
1098
+ const layout = createArtifactLayout({ outputDir: runDir });
1099
+ const rawDir = layout.raw;
1100
+ const capturesDir = layout.captures;
1101
+ const startedAt = new Date().toISOString();
1102
+ const eventLogPath = resolveEventLogPath({ args, platform: options.platform });
1103
+ const interactionDriver = resolveInteractionDriver({ config, options, scenario });
1104
+ const comparisonLane = resolveComparisonLane({ args, options, scenario });
1105
+ await ensureDir(rawDir);
1106
+ await ensureDir(capturesDir);
1107
+ await ensureDir(layout.signals.js);
1108
+ await ensureDir(layout.signals.memory);
1109
+ await ensureDir(layout.signals.network);
1110
+ const providerExecution = await executeProviderCommands({
1111
+ args,
1112
+ layout,
1113
+ platform: options.platform,
1114
+ runDir,
1115
+ runId,
1116
+ scenarioId: scenarioName,
1117
+ });
1118
+ if (providerExecution.failures.length > 0) {
1119
+ const health = buildProviderCommandFailureHealth({
1120
+ failures: providerExecution.failures,
1121
+ runId,
1122
+ scenario: profileScenario,
1123
+ });
1124
+ const verdict = buildProfileVerdict({ scenario: profileScenario, runId, health, metrics: {} });
1125
+ const agentSummary = buildAgentSummaryMarkdown({ health, verdict });
1126
+ await writeJsonArtifact({
1127
+ filePath: layout.health,
1128
+ value: health,
1129
+ schema: SCHEMAS.health,
1130
+ label: 'Health artifact',
1131
+ });
1132
+ await writeJsonArtifact({
1133
+ filePath: layout.verdict,
1134
+ value: verdict,
1135
+ schema: SCHEMAS.verdict,
1136
+ label: 'Verdict artifact',
1137
+ });
1138
+ await writeTextArtifact({
1139
+ filePath: layout.agentSummary,
1140
+ content: agentSummary,
1141
+ });
1142
+ return {
1143
+ runDir,
1144
+ health,
1145
+ verdict,
1146
+ };
1147
+ }
1148
+ const attachedEvidence = await resolveAttachedEvidence({ args, layout, providerInputs: providerExecution.inputs });
1149
+ const eventLogText = eventLogPath ? await fsp.readFile(eventLogPath, 'utf8') : '';
1150
+ const events = extractProfileEvents(eventLogText, {
1151
+ scenario: scenarioName,
1152
+ runId,
1153
+ });
1154
+ const runtimeTarget = resolveRuntimeTarget({ args, platform: options.platform });
1155
+ const metrics = buildMetricsFromProfileEvents({
1156
+ scenario: scenarioName,
1157
+ runId,
1158
+ events,
1159
+ expectedIterations,
1160
+ budgets: profileBudgets,
1161
+ cycleEventNames: profileMetricEvents,
1162
+ artifacts: {
1163
+ captures: attachedEvidence.captures,
1164
+ signals: attachedEvidence.signals,
1165
+ },
1166
+ });
1167
+ const manifest = buildManifest({
1168
+ scenario: scenarioName,
1169
+ scenarioHash,
1170
+ runId,
1171
+ platform: options.platform,
1172
+ status: metrics.status,
1173
+ endedAt: new Date().toISOString(),
1174
+ interactionDriver,
1175
+ comparisonLane,
1176
+ startedAt,
1177
+ simulator: runtimeTarget,
1178
+ bundleId: resolveAppId({ config, platform: options.platform }),
1179
+ gitSha: 'unknown',
1180
+ toolVersions: {
1181
+ node: process.version,
1182
+ },
1183
+ artifacts: {
1184
+ causalRun: 'causal-run.json',
1185
+ budgetVerdict: 'budget-verdict.json',
1186
+ manifest: 'manifest.json',
1187
+ metrics: 'metrics.json',
1188
+ summary: 'summary.md',
1189
+ scenario: toPortablePathReference(scenarioPath),
1190
+ raw: {
1191
+ interactionLog: eventLogPath ? `raw/${path.basename(eventLogPath)}` : 'raw/interaction.log',
1192
+ deviceLog: 'raw/device.log',
1193
+ },
1194
+ captures: {
1195
+ screenshots: attachedEvidence.captures.screenshots,
1196
+ video: attachedEvidence.captures.video ?? 'captures/run.mp4',
1197
+ uiTree: attachedEvidence.captures.uiTree ?? 'captures/ui-tree.json',
1198
+ },
1199
+ signals: {
1200
+ js: attachedEvidence.signals.js,
1201
+ memory: attachedEvidence.signals.memory,
1202
+ network: attachedEvidence.signals.network,
1203
+ },
1204
+ evidenceAttachments: buildEvidenceAttachmentManifest(attachedEvidence.attachments),
1205
+ },
1206
+ });
1207
+ const timeline = buildCausalTimeline({
1208
+ events,
1209
+ startedAt,
1210
+ phaseMap: scenario.timelinePhases ?? null,
1211
+ owner: scenario.flowId ?? scenarioName,
1212
+ });
1213
+ const causalRun = buildCausalRun({
1214
+ scenario: profileScenario,
1215
+ flowId: scenario.flowId ?? scenarioName,
1216
+ runId,
1217
+ platform: options.platform,
1218
+ buildFlavor: 'unknown',
1219
+ interactionDriver,
1220
+ trigger: scenario.trigger ?? null,
1221
+ budgets: isRecord(profileBudgets?.pass) ? profileBudgets.pass : null,
1222
+ timeline,
1223
+ artifacts: manifest.artifacts,
1224
+ manifest,
1225
+ metrics,
1226
+ });
1227
+ const budgetVerdict = buildBudgetVerdict({
1228
+ flowId: scenario.flowId ?? scenarioName,
1229
+ runId,
1230
+ budgetEvaluation: metrics.budgetEvaluation ?? null,
1231
+ });
1232
+ const health = buildProfileHealth({ scenario: profileScenario, runId, metrics });
1233
+ const verdict = buildProfileVerdict({ scenario: profileScenario, runId, health, metrics });
1234
+ const agentSummary = buildAgentSummaryMarkdown({ health, verdict });
1235
+ const summary = buildSummaryMarkdown({ manifest, metrics });
1236
+ await writeJsonArtifact({
1237
+ filePath: layout.health,
1238
+ value: health,
1239
+ schema: SCHEMAS.health,
1240
+ label: 'Health artifact',
1241
+ });
1242
+ await writeJsonArtifact({
1243
+ filePath: layout.verdict,
1244
+ value: verdict,
1245
+ schema: SCHEMAS.verdict,
1246
+ label: 'Verdict artifact',
1247
+ });
1248
+ await writeTextArtifact({
1249
+ filePath: layout.agentSummary,
1250
+ content: agentSummary,
1251
+ });
1252
+ await writeJsonArtifact({
1253
+ filePath: layout.profile.manifest,
1254
+ value: manifest,
1255
+ schema: SCHEMAS.manifest,
1256
+ label: 'Manifest artifact',
1257
+ });
1258
+ await writeJsonArtifact({
1259
+ filePath: layout.profile.metrics,
1260
+ value: metrics,
1261
+ schema: SCHEMAS.metrics,
1262
+ label: 'Metrics artifact',
1263
+ });
1264
+ await writeJsonArtifact({
1265
+ filePath: layout.profile.causalRun,
1266
+ value: causalRun,
1267
+ schema: SCHEMAS.causalRun,
1268
+ label: 'Causal run artifact',
1269
+ });
1270
+ if (budgetVerdict) {
1271
+ await writeJsonArtifact({
1272
+ filePath: layout.profile.budgetVerdict,
1273
+ value: budgetVerdict,
1274
+ schema: SCHEMAS.budgetVerdict,
1275
+ label: 'Budget verdict artifact',
1276
+ });
1277
+ }
1278
+ await writeTextArtifact({
1279
+ filePath: layout.profile.summary,
1280
+ content: summary,
1281
+ });
1282
+ if (eventLogPath) {
1283
+ await fsp.copyFile(eventLogPath, path.join(rawDir, path.basename(eventLogPath)));
1284
+ }
1285
+ await copyAttachedEvidence(attachedEvidence.copies);
1286
+ return {
1287
+ runDir,
1288
+ health,
1289
+ verdict,
1290
+ };
1291
+ }
1292
+ /**
1293
+ * Runs a platform-specific profile CLI.
1294
+ *
1295
+ * @param {{argv: string[], binaryName: string, platform: ProfilePlatform, defaultDriver: string}} options
1296
+ * @returns {Promise<void>}
1297
+ */
1298
+ async function runProfileCli({ argv, binaryName, defaultDriver, platform, }) {
1299
+ const args = parseArgs(argv);
1300
+ if (typeof args.config !== 'string' || typeof args.scenario !== 'string') {
1301
+ usage({ binaryName, platform });
1302
+ process.exitCode = 1;
1303
+ return;
1304
+ }
1305
+ const result = await runProfileMobile(args, { defaultDriver, platform });
1306
+ process.stdout.write(`${result.runDir}\n`);
1307
+ }