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,108 @@
1
+ #!/usr/bin/env node
2
+ declare const buildProfileHealth: any, buildProfileVerdict: any, buildVerdictBudgetChecks: any, parseArgs: any, usage: any;
3
+ type IosProfileOptions = {
4
+ agentDeviceExecutor?: import('./agent-device').CommandExecutor;
5
+ comparisonLane?: string;
6
+ delay?: (ms: number) => Promise<void>;
7
+ executor?: import('./ios-simctl').CommandExecutor;
8
+ };
9
+ type IosSimctlProfileCommand = {
10
+ command: string;
11
+ label?: string;
12
+ waitMs?: number;
13
+ };
14
+ /**
15
+ * Resolves the simctl capture output directory for a profile run.
16
+ *
17
+ * @param {{args: import('./profile-mobile').CliArgs, runId: string}} options
18
+ * @returns {string}
19
+ */
20
+ declare function resolveSimctlCaptureOutputDir({ args, runId, }: {
21
+ args: import('./profile-mobile').CliArgs;
22
+ runId: string;
23
+ }): string;
24
+ /**
25
+ * Resolves the iOS bundle id from explicit CLI input or project config.
26
+ *
27
+ * @param {{args: import('./profile-mobile').CliArgs, config: Record<string, unknown>}} options
28
+ * @returns {string | null}
29
+ */
30
+ declare function resolveIosBundleId({ args, config, }: {
31
+ args: import('./profile-mobile').CliArgs;
32
+ config: Record<string, any>;
33
+ }): string | null;
34
+ /**
35
+ * Resolves optional sibling iOS bundle ids that make simulator targeting ambiguous.
36
+ *
37
+ * @param {Record<string, unknown>} config
38
+ * @returns {string[]}
39
+ */
40
+ declare function resolveIosConflictingBundleIds(config: Record<string, any>): string[];
41
+ /**
42
+ * Builds a profile-session deep link for the example app or another configured app.
43
+ *
44
+ * @param {{config: Record<string, unknown>, action: 'start' | 'command', scenario: string, runId: string, command?: string}} options
45
+ * @returns {string}
46
+ */
47
+ declare function buildProfileSessionUrl({ action, command, config, runId, scenario, }: {
48
+ action: 'start' | 'command';
49
+ command?: string;
50
+ config: Record<string, any>;
51
+ runId: string;
52
+ scenario: string;
53
+ }): string;
54
+ /**
55
+ * Derives a storage-backed profile capture window from scenario waits and cycles.
56
+ *
57
+ * @param {Record<string, unknown>} scenario
58
+ * @returns {number}
59
+ */
60
+ declare function deriveProfileSessionCaptureWaitMs(scenario: Record<string, any>): number;
61
+ /**
62
+ * Resolves the iOS capture wait, keeping explicit CLI waits authoritative.
63
+ *
64
+ * @param {{args: import('./profile-mobile').CliArgs, scenario: Record<string, unknown>, profileSessionEnabled: boolean}} options
65
+ * @returns {number}
66
+ */
67
+ declare function resolveProfileSessionCaptureWaitMs({ args, profileSessionEnabled, scenario, }: {
68
+ args: import('./profile-mobile').CliArgs;
69
+ profileSessionEnabled: boolean;
70
+ scenario: Record<string, any>;
71
+ }): number;
72
+ /**
73
+ * Expands scenario-declared iOS commands for a simctl capture profile session.
74
+ *
75
+ * @param {Record<string, unknown>} scenario
76
+ * @returns {IosSimctlProfileCommand[]}
77
+ */
78
+ declare function resolveIosSimctlProfileCommands(scenario: Record<string, any>): IosSimctlProfileCommand[];
79
+ /**
80
+ * Returns true when the scenario asks iOS simctl capture to preserve a screenshot.
81
+ *
82
+ * @param {Record<string, unknown>} scenario
83
+ * @returns {boolean}
84
+ */
85
+ declare function requiresIosSimctlScreenshot(scenario: Record<string, any>): boolean;
86
+ /**
87
+ * Summarizes failed simctl capture checks for CLI errors.
88
+ *
89
+ * @param {Record<string, unknown>} health
90
+ * @returns {string}
91
+ */
92
+ declare function summarizeFailedIosChecks(health: Record<string, unknown>): string;
93
+ /**
94
+ * Runs the iOS log-ingest profile artifact pipeline.
95
+ *
96
+ * @param {import('./profile-mobile').CliArgs} args
97
+ * @param {IosProfileOptions} [options]
98
+ * @returns {Promise<import('./profile-mobile').ProfileRunResult>}
99
+ */
100
+ declare function runProfileIos(args: import('./profile-mobile').CliArgs, options?: IosProfileOptions): Promise<import('./profile-mobile').ProfileRunResult>;
101
+ /**
102
+ * Runs the profile-ios CLI.
103
+ *
104
+ * @returns {Promise<void>}
105
+ */
106
+ declare function main(): Promise<void>;
107
+ export { buildProfileHealth, buildProfileVerdict, buildVerdictBudgetChecks, buildProfileSessionUrl, deriveProfileSessionCaptureWaitMs, main, parseArgs, resolveIosBundleId, resolveIosConflictingBundleIds, resolveIosSimctlProfileCommands, resolveProfileSessionCaptureWaitMs, resolveSimctlCaptureOutputDir, requiresIosSimctlScreenshot, runProfileIos, summarizeFailedIosChecks, usage, };
108
+ export type { CliArgs, ProfileRunResult, } from './profile-mobile';
@@ -0,0 +1,532 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.usage = exports.parseArgs = exports.buildVerdictBudgetChecks = exports.buildProfileVerdict = exports.buildProfileHealth = void 0;
5
+ exports.buildProfileSessionUrl = buildProfileSessionUrl;
6
+ exports.deriveProfileSessionCaptureWaitMs = deriveProfileSessionCaptureWaitMs;
7
+ exports.main = main;
8
+ exports.resolveIosBundleId = resolveIosBundleId;
9
+ exports.resolveIosConflictingBundleIds = resolveIosConflictingBundleIds;
10
+ exports.resolveIosSimctlProfileCommands = resolveIosSimctlProfileCommands;
11
+ exports.resolveProfileSessionCaptureWaitMs = resolveProfileSessionCaptureWaitMs;
12
+ exports.resolveSimctlCaptureOutputDir = resolveSimctlCaptureOutputDir;
13
+ exports.requiresIosSimctlScreenshot = requiresIosSimctlScreenshot;
14
+ exports.runProfileIos = runProfileIos;
15
+ exports.summarizeFailedIosChecks = summarizeFailedIosChecks;
16
+ const crypto = require('node:crypto');
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+ const { hasHelpFlag } = require('./cli');
20
+ const { buildScenarioExecutionPlan } = require('../core/execution-plan');
21
+ const { buildProfileHealth, buildProfileVerdict, buildVerdictBudgetChecks, parseArgs, readScalarArg, runProfileCli, runProfileMobile, usage, } = require('./profile-mobile');
22
+ exports.buildProfileHealth = buildProfileHealth;
23
+ exports.buildProfileVerdict = buildProfileVerdict;
24
+ exports.buildVerdictBudgetChecks = buildVerdictBudgetChecks;
25
+ exports.parseArgs = parseArgs;
26
+ exports.usage = usage;
27
+ const { runIosSimctlCapture } = require('./ios-simctl');
28
+ const { runAgentDeviceCapture } = require('./agent-device');
29
+ const { loadAslLocalEnv, readStringArgOrEnv } = require('./local-env');
30
+ const PROFILE_SESSION_CAPTURE_BOOTSTRAP_MS = 1000;
31
+ const PROFILE_SESSION_CAPTURE_MAX_MS = 30000;
32
+ const DEFAULT_IOS_PROFILE_SESSION_STORAGE_KEY = 'agent-scenario-loop.profile-session.1';
33
+ const DEFAULT_IOS_PROFILE_COMMAND_STORAGE_KEY = 'agent-scenario-loop.profile-commands.1';
34
+ const DEFAULT_IOS_PROFILE_EVENT_STORAGE_KEY = 'agent-scenario-loop.profile-events.1';
35
+ const DEFAULT_IOS_PROFILE_SIGNAL_STORAGE_KEY = 'agent-scenario-loop.profile-signals.1';
36
+ const DEFAULT_IOS_PROFILE_SESSION_ENTRIES_STORAGE_KEY = 'agent-scenario-loop.profile-session-entries.1';
37
+ /**
38
+ * Reads and parses a JSON object from disk.
39
+ *
40
+ * @param {string} filePath
41
+ * @returns {Record<string, unknown>}
42
+ */
43
+ function readJson(filePath) {
44
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
45
+ }
46
+ /**
47
+ * Checks whether a boolean-style CLI flag is enabled.
48
+ *
49
+ * @param {string | boolean | undefined} value
50
+ * @returns {boolean}
51
+ */
52
+ function isEnabled(value) {
53
+ return value === true || value === 'true';
54
+ }
55
+ /**
56
+ * Reads a positive integer from unknown scenario adapter metadata.
57
+ *
58
+ * @param {unknown} value
59
+ * @param {number} fallback
60
+ * @returns {number}
61
+ */
62
+ function readPositiveInteger(value, fallback) {
63
+ const parsed = typeof value === 'string' ? Number(value) : value;
64
+ return typeof parsed === 'number' && Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
65
+ }
66
+ /**
67
+ * Reads the number of scenario iterations that can emit app-owned truth events.
68
+ *
69
+ * @param {Record<string, unknown>} scenario
70
+ * @returns {number}
71
+ */
72
+ function readScenarioIterationCount(scenario) {
73
+ return readPositiveInteger(scenario.defaultIterations, readPositiveInteger(scenario.cycles?.iterations, 1));
74
+ }
75
+ /**
76
+ * Creates a short run id when simctl capture must share the id with profile artifacts.
77
+ *
78
+ * @returns {string}
79
+ */
80
+ function createRunId() {
81
+ return crypto.randomBytes(6).toString('hex');
82
+ }
83
+ /**
84
+ * Resolves the simctl capture output directory for a profile run.
85
+ *
86
+ * @param {{args: import('./profile-mobile').CliArgs, runId: string}} options
87
+ * @returns {string}
88
+ */
89
+ function resolveSimctlCaptureOutputDir({ args, runId, }) {
90
+ if (typeof args['simctl-out'] === 'string') {
91
+ return path.resolve(args['simctl-out']);
92
+ }
93
+ if (typeof args.out === 'string') {
94
+ return path.resolve(args.out, '_ios-simctl-captures', runId);
95
+ }
96
+ return path.resolve('artifacts/ios-simctl-captures', runId);
97
+ }
98
+ /**
99
+ * Resolves the agent-device capture output directory for a profile run.
100
+ *
101
+ * @param {{args: import('./profile-mobile').CliArgs, runId: string}} options
102
+ * @returns {string}
103
+ */
104
+ function resolveAgentDeviceCaptureOutputDir({ args, runId, }) {
105
+ if (typeof args['agent-device-out'] === 'string') {
106
+ return path.resolve(args['agent-device-out']);
107
+ }
108
+ if (typeof args.out === 'string') {
109
+ return path.resolve(args.out, '_agent-device-captures', runId);
110
+ }
111
+ return path.resolve('artifacts/agent-device-captures', runId);
112
+ }
113
+ /**
114
+ * Resolves the iOS bundle id from explicit CLI input or project config.
115
+ *
116
+ * @param {{args: import('./profile-mobile').CliArgs, config: Record<string, unknown>}} options
117
+ * @returns {string | null}
118
+ */
119
+ function resolveIosBundleId({ args, config, }) {
120
+ if (typeof args.bundle === 'string') {
121
+ return args.bundle;
122
+ }
123
+ return typeof config.app?.iosBundleId === 'string' ? config.app.iosBundleId : null;
124
+ }
125
+ /**
126
+ * Resolves optional sibling iOS bundle ids that make simulator targeting ambiguous.
127
+ *
128
+ * @param {Record<string, unknown>} config
129
+ * @returns {string[]}
130
+ */
131
+ function resolveIosConflictingBundleIds(config) {
132
+ const configured = config.app?.iosConflictingBundleIds;
133
+ return Array.isArray(configured)
134
+ ? configured.filter((value) => typeof value === 'string' && value.trim().length > 0)
135
+ : [];
136
+ }
137
+ /**
138
+ * Builds a profile-session deep link for the example app or another configured app.
139
+ *
140
+ * @param {{config: Record<string, unknown>, action: 'start' | 'command', scenario: string, runId: string, command?: string}} options
141
+ * @returns {string}
142
+ */
143
+ function buildProfileSessionUrl({ action, command, config, runId, scenario, }) {
144
+ const scheme = typeof config.app?.profileSessionScheme === 'string'
145
+ ? config.app.profileSessionScheme
146
+ : typeof config.app?.scheme === 'string'
147
+ ? config.app.scheme
148
+ : 'app';
149
+ const params = new URLSearchParams({ runId, scenario });
150
+ if (action === 'command' && command) {
151
+ params.set('command', command);
152
+ }
153
+ return `${scheme}://profile-session/${action}?${params.toString()}`;
154
+ }
155
+ /**
156
+ * Reads iOS-specific wait metadata from a normalized execution step.
157
+ *
158
+ * @param {import('../core/execution-plan').ScenarioExecutionStep} step
159
+ * @returns {number}
160
+ */
161
+ function readStepWaitMs(step) {
162
+ const iosSimctlOptions = step.adapterOptions?.iosSimctl;
163
+ if (iosSimctlOptions && typeof iosSimctlOptions === 'object' && !Array.isArray(iosSimctlOptions)) {
164
+ const waitMs = iosSimctlOptions.waitMs;
165
+ if (typeof waitMs === 'number' && Number.isInteger(waitMs) && waitMs > 0) {
166
+ return waitMs;
167
+ }
168
+ }
169
+ return readPositiveInteger(step.timeoutMs, 0);
170
+ }
171
+ /**
172
+ * Derives a storage-backed profile capture window from scenario waits and cycles.
173
+ *
174
+ * @param {Record<string, unknown>} scenario
175
+ * @returns {number}
176
+ */
177
+ function deriveProfileSessionCaptureWaitMs(scenario) {
178
+ const executionPlan = buildScenarioExecutionPlan(scenario);
179
+ const iterations = readScenarioIterationCount(scenario);
180
+ const perIterationWaitMs = executionPlan.steps.reduce((total, step) => {
181
+ if (step.kind === 'command') {
182
+ return total + readStepWaitMs(step);
183
+ }
184
+ if (step.portMethod === 'waitForTruthEvent') {
185
+ return total + readPositiveInteger(step.timeoutMs, 0);
186
+ }
187
+ return total;
188
+ }, 0);
189
+ const derivedWaitMs = PROFILE_SESSION_CAPTURE_BOOTSTRAP_MS + (perIterationWaitMs * iterations);
190
+ return Math.min(Math.max(derivedWaitMs, PROFILE_SESSION_CAPTURE_BOOTSTRAP_MS), PROFILE_SESSION_CAPTURE_MAX_MS);
191
+ }
192
+ /**
193
+ * Resolves the iOS capture wait, keeping explicit CLI waits authoritative.
194
+ *
195
+ * @param {{args: import('./profile-mobile').CliArgs, scenario: Record<string, unknown>, profileSessionEnabled: boolean}} options
196
+ * @returns {number}
197
+ */
198
+ function resolveProfileSessionCaptureWaitMs({ args, profileSessionEnabled, scenario, }) {
199
+ const explicitWaitMs = readScalarArg(args['wait-ms']);
200
+ if (explicitWaitMs !== undefined) {
201
+ return readPositiveInteger(explicitWaitMs, 0);
202
+ }
203
+ return profileSessionEnabled ? deriveProfileSessionCaptureWaitMs(scenario) : 0;
204
+ }
205
+ /**
206
+ * Expands portable scenario command steps into iOS profile-session commands.
207
+ *
208
+ * @param {Record<string, unknown>} scenario
209
+ * @returns {IosSimctlProfileCommand[]}
210
+ */
211
+ function resolveExecutionPlanProfileCommands(scenario) {
212
+ const executionPlan = buildScenarioExecutionPlan(scenario);
213
+ const repeat = readPositiveInteger(scenario.defaultIterations, readPositiveInteger(scenario.cycles?.iterations, 1));
214
+ const commands = executionPlan.steps
215
+ .filter((step) => step.portMethod === 'executeStep' && typeof step.command === 'string')
216
+ .map((step) => ({
217
+ command: step.command,
218
+ label: step.id,
219
+ waitMs: readStepWaitMs(step),
220
+ }));
221
+ return Array.from({ length: repeat }).flatMap(() => commands);
222
+ }
223
+ /**
224
+ * Expands scenario-declared iOS commands for a simctl capture profile session.
225
+ *
226
+ * @param {Record<string, unknown>} scenario
227
+ * @returns {IosSimctlProfileCommand[]}
228
+ */
229
+ function resolveIosSimctlProfileCommands(scenario) {
230
+ const iosSimctlOptions = scenario.adapterOptions?.iosSimctl;
231
+ if (!iosSimctlOptions || !Array.isArray(iosSimctlOptions.commands)) {
232
+ return resolveExecutionPlanProfileCommands(scenario);
233
+ }
234
+ const repeat = readPositiveInteger(iosSimctlOptions.repeat, readPositiveInteger(scenario.defaultIterations, 1));
235
+ const commands = [];
236
+ for (let iteration = 0; iteration < repeat; iteration += 1) {
237
+ for (const command of iosSimctlOptions.commands) {
238
+ if (!command || typeof command.command !== 'string') {
239
+ continue;
240
+ }
241
+ commands.push({
242
+ command: command.command,
243
+ ...(typeof command.label === 'string' ? { label: command.label } : {}),
244
+ waitMs: readPositiveInteger(command.waitMs, 0),
245
+ });
246
+ }
247
+ }
248
+ return commands;
249
+ }
250
+ /**
251
+ * Returns true when the scenario asks iOS simctl capture to preserve a screenshot.
252
+ *
253
+ * @param {Record<string, unknown>} scenario
254
+ * @returns {boolean}
255
+ */
256
+ function requiresIosSimctlScreenshot(scenario) {
257
+ const executionPlan = buildScenarioExecutionPlan(scenario);
258
+ return executionPlan.steps.some((step) => step.driverAction === 'screenshot' || step.artifact === 'screenshot');
259
+ }
260
+ /**
261
+ * Appends one repeatable profile capture argument without losing caller-provided values.
262
+ *
263
+ * @param {{args: import('./profile-mobile').CliArgs, value: string}} options
264
+ * @returns {string | boolean | Array<string | boolean>}
265
+ */
266
+ function appendCaptureArg({ args, value, }) {
267
+ const existing = args.capture;
268
+ return existing === undefined ? value : Array.isArray(existing) ? [...existing, value] : [existing, value];
269
+ }
270
+ /**
271
+ * Summarizes failed simctl capture checks for CLI errors.
272
+ *
273
+ * @param {Record<string, unknown>} health
274
+ * @returns {string}
275
+ */
276
+ function summarizeFailedIosChecks(health) {
277
+ const checks = Array.isArray(health.checks) ? health.checks : [];
278
+ const failedChecks = checks
279
+ .filter((check) => check?.status === 'failed')
280
+ .map((check) => (typeof check.message === 'string'
281
+ ? check.message
282
+ : typeof check.code === 'string'
283
+ ? check.code
284
+ : 'unknown failure'));
285
+ return failedChecks.length > 0 ? ` Failed checks: ${failedChecks.join(' ')}` : '';
286
+ }
287
+ /**
288
+ * Summarizes failed agent-device checks for CLI errors.
289
+ *
290
+ * @param {Record<string, unknown>} health
291
+ * @returns {string}
292
+ */
293
+ function summarizeFailedAgentDeviceChecks(health) {
294
+ const checks = Array.isArray(health.checks) ? health.checks : [];
295
+ const failedChecks = checks
296
+ .filter((check) => check?.status === 'failed')
297
+ .map((check) => (typeof check.message === 'string'
298
+ ? check.message
299
+ : typeof check.code === 'string'
300
+ ? check.code
301
+ : 'unknown failure'));
302
+ return failedChecks.length > 0 ? ` Failed checks: ${failedChecks.join(' ')}` : '';
303
+ }
304
+ /**
305
+ * Appends screenshots from an agent-device capture as profile capture inputs.
306
+ *
307
+ * @param {{args: import('./profile-mobile').CliArgs, capture: import('./agent-device').AgentDeviceCaptureResult}} options
308
+ * @returns {import('./profile-mobile').CliArgs}
309
+ */
310
+ function appendAgentDeviceCaptureArgs({ args, capture, }) {
311
+ let captureArg = args.capture;
312
+ for (const screenshot of capture.captures.screenshots) {
313
+ captureArg = appendCaptureArg({
314
+ args: captureArg === undefined ? {} : { capture: captureArg },
315
+ value: `screenshot:${path.join(capture.runDir, screenshot)}`,
316
+ });
317
+ }
318
+ return captureArg === undefined ? args : { ...args, capture: captureArg };
319
+ }
320
+ /**
321
+ * Runs the iOS log-ingest profile artifact pipeline.
322
+ *
323
+ * @param {import('./profile-mobile').CliArgs} args
324
+ * @param {IosProfileOptions} [options]
325
+ * @returns {Promise<import('./profile-mobile').ProfileRunResult>}
326
+ */
327
+ async function runProfileIos(args, options = {}) {
328
+ if (!isEnabled(args['simctl-capture']) && !isEnabled(args['agent-device-capture'])) {
329
+ return runProfileMobile(args, {
330
+ ...(options.comparisonLane ? { comparisonLane: options.comparisonLane } : {}),
331
+ defaultDriver: 'ios-simctl',
332
+ ...(typeof args['simctl-artifacts'] === 'string' ? { interactionDriver: 'ios-simctl' } : {}),
333
+ platform: 'ios',
334
+ });
335
+ }
336
+ if (typeof args.config !== 'string' || typeof args.scenario !== 'string') {
337
+ throw new Error('Both --config and --scenario are required.');
338
+ }
339
+ const config = readJson(path.resolve(args.config));
340
+ const scenario = readJson(path.resolve(args.scenario));
341
+ const runId = typeof args['run-id'] === 'string' ? args['run-id'] : createRunId();
342
+ const profileSessionEnabled = isEnabled(args['profile-session']);
343
+ const profileSessionStorageEnabled = isEnabled(args['profile-session-storage']);
344
+ const profileSessionStorageKey = readStringArgOrEnv(args['ios-profile-session-storage-key'], [
345
+ 'ASL_IOS_PROFILE_SESSION_STORAGE_KEY',
346
+ 'ASL_EXAMPLE_IOS_PROFILE_SESSION_STORAGE_KEY',
347
+ ]) ?? DEFAULT_IOS_PROFILE_SESSION_STORAGE_KEY;
348
+ const profileCommandStorageKey = readStringArgOrEnv(args['ios-profile-command-storage-key'], [
349
+ 'ASL_IOS_PROFILE_COMMAND_STORAGE_KEY',
350
+ 'ASL_EXAMPLE_IOS_PROFILE_COMMAND_STORAGE_KEY',
351
+ ]) ?? DEFAULT_IOS_PROFILE_COMMAND_STORAGE_KEY;
352
+ const profileEventStorageKey = readStringArgOrEnv(args['ios-profile-event-storage-key'], [
353
+ 'ASL_IOS_PROFILE_EVENT_STORAGE_KEY',
354
+ 'ASL_EXAMPLE_IOS_PROFILE_EVENT_STORAGE_KEY',
355
+ ]) ?? DEFAULT_IOS_PROFILE_EVENT_STORAGE_KEY;
356
+ const profileSignalStorageKey = readStringArgOrEnv(args['ios-profile-signal-storage-key'], [
357
+ 'ASL_IOS_PROFILE_SIGNAL_STORAGE_KEY',
358
+ 'ASL_EXAMPLE_IOS_PROFILE_SIGNAL_STORAGE_KEY',
359
+ ]) ?? DEFAULT_IOS_PROFILE_SIGNAL_STORAGE_KEY;
360
+ const profileSessionEntriesStorageKey = readStringArgOrEnv(args['ios-profile-session-entries-storage-key'], [
361
+ 'ASL_IOS_PROFILE_SESSION_ENTRIES_STORAGE_KEY',
362
+ 'ASL_EXAMPLE_IOS_PROFILE_SESSION_ENTRIES_STORAGE_KEY',
363
+ ]) ?? DEFAULT_IOS_PROFILE_SESSION_ENTRIES_STORAGE_KEY;
364
+ const iosDevClientUrl = readStringArgOrEnv(args['ios-dev-client-url'], [
365
+ 'ASL_IOS_DEV_CLIENT_URL',
366
+ 'ASL_EXAMPLE_IOS_DEV_CLIENT_URL',
367
+ ]);
368
+ const iosDevClientWaitMs = readPositiveInteger(readStringArgOrEnv(args['ios-dev-client-wait-ms'], [
369
+ 'ASL_IOS_DEV_CLIENT_WAIT_MS',
370
+ 'ASL_EXAMPLE_IOS_DEV_CLIENT_WAIT_MS',
371
+ ]), 1000);
372
+ const scenarioName = typeof scenario.name === 'string' ? scenario.name : path.basename(args.scenario, '.json');
373
+ const profileSessionCommands = profileSessionEnabled ? resolveIosSimctlProfileCommands(scenario) : [];
374
+ const iosDevClientDeepLinks = iosDevClientUrl
375
+ ? [
376
+ {
377
+ label: 'ios-dev-client-url',
378
+ url: iosDevClientUrl,
379
+ waitMs: iosDevClientWaitMs,
380
+ },
381
+ ]
382
+ : [];
383
+ const profileSessionDeepLinks = profileSessionEnabled && !profileSessionStorageEnabled
384
+ ? [
385
+ {
386
+ label: 'profile-session-start',
387
+ url: buildProfileSessionUrl({
388
+ action: 'start',
389
+ config,
390
+ runId,
391
+ scenario: scenarioName,
392
+ }),
393
+ waitMs: readPositiveInteger(readScalarArg(args['command-wait-ms']), 250),
394
+ },
395
+ ...profileSessionCommands.map((profileCommand, index) => ({
396
+ label: profileCommand.label ?? `profile-command-${index + 1}`,
397
+ url: buildProfileSessionUrl({
398
+ action: 'command',
399
+ command: profileCommand.command,
400
+ config,
401
+ runId,
402
+ scenario: scenarioName,
403
+ }),
404
+ waitMs: profileCommand.waitMs,
405
+ })),
406
+ ]
407
+ : [];
408
+ const deepLinks = [...iosDevClientDeepLinks, ...profileSessionDeepLinks];
409
+ const shouldLaunchWithSimctl = isEnabled(args.launch) && !(profileSessionStorageEnabled && iosDevClientUrl);
410
+ const simctlCapture = isEnabled(args['simctl-capture'])
411
+ ? await runIosSimctlCapture({
412
+ bundleId: resolveIosBundleId({ args, config }),
413
+ collectProfileStorage: profileSessionStorageEnabled,
414
+ conflictingBundleIds: resolveIosConflictingBundleIds(config),
415
+ deepLinks,
416
+ ...(options.delay ? { delay: options.delay } : {}),
417
+ ...(typeof args.device === 'string' ? { device: args.device } : {}),
418
+ ...(options.executor ? { executor: options.executor } : {}),
419
+ launch: shouldLaunchWithSimctl,
420
+ ...(typeof args['log-last'] === 'string' ? { logLast: args['log-last'] } : {}),
421
+ outputDir: resolveSimctlCaptureOutputDir({ args, runId }),
422
+ ...(profileSessionStorageEnabled
423
+ ? {
424
+ profileStorageKeys: {
425
+ command: profileCommandStorageKey,
426
+ event: profileEventStorageKey,
427
+ session: profileSessionStorageKey,
428
+ sessionEntries: profileSessionEntriesStorageKey,
429
+ signal: profileSignalStorageKey,
430
+ },
431
+ profileSessionStorage: {
432
+ commands: profileSessionCommands.map((profileCommand, index) => ({
433
+ command: profileCommand.command,
434
+ id: `ios-storage-command-${index + 1}`,
435
+ ...(typeof profileCommand.label === 'string' ? { label: profileCommand.label } : {}),
436
+ })),
437
+ runId,
438
+ scenario: scenarioName,
439
+ },
440
+ terminateBeforeLaunch: true,
441
+ }
442
+ : {
443
+ terminateBeforeLaunch: isEnabled(args['terminate-before-launch']),
444
+ }),
445
+ runId,
446
+ screenshot: isEnabled(args.screenshot) || requiresIosSimctlScreenshot(scenario),
447
+ waitMs: resolveProfileSessionCaptureWaitMs({
448
+ args,
449
+ profileSessionEnabled,
450
+ scenario,
451
+ }),
452
+ ...(typeof args.xcrun === 'string' ? { xcrunPath: args.xcrun } : {}),
453
+ })
454
+ : null;
455
+ if (simctlCapture && simctlCapture.health.healthStatus !== 'passed') {
456
+ throw new Error(`iOS simctl capture failed; inspect ${simctlCapture.runDir}/agent-summary.md.${summarizeFailedIosChecks(simctlCapture.health)}`);
457
+ }
458
+ const agentDeviceCapture = isEnabled(args['agent-device-capture'])
459
+ ? await runAgentDeviceCapture({
460
+ ...(typeof args['agent-device'] === 'string' ? { agentDevicePath: args['agent-device'] } : {}),
461
+ app: typeof args['agent-device-app'] === 'string'
462
+ ? args['agent-device-app']
463
+ : resolveIosBundleId({ args, config }),
464
+ ...(options.agentDeviceExecutor ? { executor: options.agentDeviceExecutor } : {}),
465
+ ...(typeof args['agent-device-device'] === 'string' ? { device: args['agent-device-device'] } : {}),
466
+ ...(typeof args['agent-device-session'] === 'string' ? { session: args['agent-device-session'] } : {}),
467
+ ...(typeof args['agent-device-session-mode'] === 'string'
468
+ ? { sessionMode: args['agent-device-session-mode'] }
469
+ : {}),
470
+ ...(typeof args.device === 'string' ? { udid: args.device } : {}),
471
+ open: isEnabled(args['agent-device-open']),
472
+ outputDir: resolveAgentDeviceCaptureOutputDir({ args, runId }),
473
+ platform: 'ios',
474
+ runId,
475
+ scenario,
476
+ waitMs: readPositiveInteger(readScalarArg(args['agent-device-wait-ms']), 0),
477
+ })
478
+ : null;
479
+ if (agentDeviceCapture && agentDeviceCapture.health.healthStatus !== 'passed') {
480
+ throw new Error(`agent-device capture failed; inspect ${agentDeviceCapture.runDir}/agent-summary.md.${summarizeFailedAgentDeviceChecks(agentDeviceCapture.health)}`);
481
+ }
482
+ const baseProfileArgs = {
483
+ ...args,
484
+ 'run-id': runId,
485
+ ...(simctlCapture ? { 'simctl-artifacts': simctlCapture.runDir } : {}),
486
+ ...(simctlCapture?.captures.screenshot
487
+ ? { capture: appendCaptureArg({
488
+ args,
489
+ value: `screenshot:${path.join(simctlCapture.runDir, simctlCapture.captures.screenshot)}`,
490
+ }) }
491
+ : {}),
492
+ };
493
+ if (simctlCapture) {
494
+ delete baseProfileArgs.events;
495
+ }
496
+ const profileArgs = agentDeviceCapture
497
+ ? appendAgentDeviceCaptureArgs({ args: baseProfileArgs, capture: agentDeviceCapture })
498
+ : baseProfileArgs;
499
+ return runProfileMobile(profileArgs, {
500
+ ...(options.comparisonLane ? { comparisonLane: options.comparisonLane } : {}),
501
+ defaultDriver: 'ios-simctl',
502
+ interactionDriver: agentDeviceCapture ? 'agent-device' : 'ios-simctl',
503
+ platform: 'ios',
504
+ });
505
+ }
506
+ /**
507
+ * Runs the profile-ios CLI.
508
+ *
509
+ * @returns {Promise<void>}
510
+ */
511
+ async function main() {
512
+ const argv = process.argv.slice(2);
513
+ if (hasHelpFlag(argv)) {
514
+ usage({ binaryName: 'asl-profile-ios', output: process.stdout, platform: 'ios' });
515
+ return;
516
+ }
517
+ loadAslLocalEnv();
518
+ const args = parseArgs(argv);
519
+ if (typeof args.config !== 'string' || typeof args.scenario !== 'string') {
520
+ usage({ binaryName: 'asl-profile-ios', platform: 'ios' });
521
+ process.exitCode = 1;
522
+ return;
523
+ }
524
+ const result = await runProfileIos(args);
525
+ process.stdout.write(`${result.runDir}\n`);
526
+ }
527
+ if (require.main === module) {
528
+ main().catch((error) => {
529
+ console.error(error instanceof Error ? error.message : String(error));
530
+ process.exitCode = 1;
531
+ });
532
+ }