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,1415 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.buildIosSimctlHealth = buildIosSimctlHealth;
5
+ exports.buildIosSimctlVerdict = buildIosSimctlVerdict;
6
+ exports.asyncStorageFileNameForKey = asyncStorageFileNameForKey;
7
+ exports.execFileCommand = execFileCommand;
8
+ exports.formatStoredProfileEventLog = formatStoredProfileEventLog;
9
+ exports.main = main;
10
+ exports.parseArgs = parseArgs;
11
+ exports.parsePositiveInteger = parsePositiveInteger;
12
+ exports.parseSimctlDevices = parseSimctlDevices;
13
+ exports.normalizeConflictingBundleIds = normalizeConflictingBundleIds;
14
+ exports.readAsyncStorageValueSync = readAsyncStorageValueSync;
15
+ exports.readProfileStorageJson = readProfileStorageJson;
16
+ exports.resolveAsyncStorageDirectory = resolveAsyncStorageDirectory;
17
+ exports.runIosSimctlCapture = runIosSimctlCapture;
18
+ exports.seedProfileSessionStorage = seedProfileSessionStorage;
19
+ exports.selectSimulator = selectSimulator;
20
+ exports.usage = usage;
21
+ const { execFile } = require('node:child_process');
22
+ const crypto = require('node:crypto');
23
+ const fs = require('node:fs');
24
+ const fsp = require('node:fs/promises');
25
+ const os = require('node:os');
26
+ const path = require('node:path');
27
+ const { buildAgentSummaryMarkdown } = require('../core/agent-summary');
28
+ const { createArtifactLayout } = require('../core/artifact-layout');
29
+ const { writeJsonArtifact, writeTextArtifact } = require('../core/artifact-writer');
30
+ const { SCHEMAS, assertValidJson } = require('../core/schema-validator');
31
+ const { hasHelpFlag, writeUsage } = require('./cli');
32
+ const { createIosSimctlDriver, formatIosSimctlRawOutput, } = require('./ios-simctl-driver');
33
+ const PROFILE_STORAGE_PREFIX = 'agent-scenario-loop';
34
+ const PROFILE_STORAGE_SCHEMA = '1';
35
+ const PROFILE_EVENT_STORAGE_KEY = `${PROFILE_STORAGE_PREFIX}.profile-events.${PROFILE_STORAGE_SCHEMA}`;
36
+ const PROFILE_SIGNAL_STORAGE_KEY = `${PROFILE_STORAGE_PREFIX}.profile-signals.${PROFILE_STORAGE_SCHEMA}`;
37
+ const PROFILE_SESSION_STORAGE_KEY = `${PROFILE_STORAGE_PREFIX}.profile-session.${PROFILE_STORAGE_SCHEMA}`;
38
+ const PROFILE_COMMAND_STORAGE_KEY = `${PROFILE_STORAGE_PREFIX}.profile-commands.${PROFILE_STORAGE_SCHEMA}`;
39
+ const PROFILE_SESSION_ENTRIES_STORAGE_KEY = `${PROFILE_STORAGE_PREFIX}.profile-session-entries.${PROFILE_STORAGE_SCHEMA}`;
40
+ const PROFILE_STORAGE_RESET_KEYS = [
41
+ PROFILE_EVENT_STORAGE_KEY,
42
+ PROFILE_SIGNAL_STORAGE_KEY,
43
+ PROFILE_COMMAND_STORAGE_KEY,
44
+ PROFILE_SESSION_ENTRIES_STORAGE_KEY,
45
+ ];
46
+ const DEFAULT_PROFILE_STORAGE_KEYS = {
47
+ command: PROFILE_COMMAND_STORAGE_KEY,
48
+ event: PROFILE_EVENT_STORAGE_KEY,
49
+ session: PROFILE_SESSION_STORAGE_KEY,
50
+ sessionEntries: PROFILE_SESSION_ENTRIES_STORAGE_KEY,
51
+ signal: PROFILE_SIGNAL_STORAGE_KEY,
52
+ };
53
+ const SCREENSHOT_EXTENSIONS = new Set(['bmp', 'gif', 'jpeg', 'png', 'tiff']);
54
+ const SIMULATOR_LAUNCH_ENV_KEYS = [
55
+ 'DYLD_INSERT_LIBRARIES',
56
+ 'NATIVE_DEVTOOLS_IOS_CDP_SOCKET',
57
+ ];
58
+ const HOST_DIAGNOSTIC_REPORT_MAX_AGE_MS = 10 * 60 * 1000;
59
+ const HOST_DIAGNOSTIC_REPORT_SEARCH_LIMIT = 25;
60
+ /**
61
+ * Builds a filesystem-safe raw artifact suffix for a bundle identifier.
62
+ *
63
+ * @param {string} bundleId
64
+ * @returns {string}
65
+ */
66
+ function rawBundleIdSuffix(bundleId) {
67
+ return bundleId.replace(/[^A-Za-z0-9._-]+/gu, '-').slice(0, 80) || 'bundle';
68
+ }
69
+ /**
70
+ * Resolves app-owned AsyncStorage keys for profile-session control and evidence.
71
+ *
72
+ * @param {Partial<ProfileStorageKeys> | undefined} overrides
73
+ * @returns {ProfileStorageKeys}
74
+ */
75
+ function resolveProfileStorageKeys(overrides) {
76
+ return {
77
+ command: overrides?.command || DEFAULT_PROFILE_STORAGE_KEYS.command,
78
+ event: overrides?.event || DEFAULT_PROFILE_STORAGE_KEYS.event,
79
+ session: overrides?.session || DEFAULT_PROFILE_STORAGE_KEYS.session,
80
+ sessionEntries: overrides?.sessionEntries || DEFAULT_PROFILE_STORAGE_KEYS.sessionEntries,
81
+ signal: overrides?.signal || DEFAULT_PROFILE_STORAGE_KEYS.signal,
82
+ };
83
+ }
84
+ /**
85
+ * Resolves the default macOS host crash-report directory used by Simulator apps.
86
+ *
87
+ * @returns {string}
88
+ */
89
+ function defaultDiagnosticReportsDir() {
90
+ return path.join(os.homedir(), 'Library', 'Logs', 'DiagnosticReports');
91
+ }
92
+ /**
93
+ * Builds a short raw evidence filename for an attached host diagnostic report.
94
+ *
95
+ * @param {string} bundleId
96
+ * @returns {string}
97
+ */
98
+ function hostDiagnosticReportRawFileName(bundleId) {
99
+ return `ios-host-diagnostic-report-${rawBundleIdSuffix(bundleId)}.ips`;
100
+ }
101
+ /**
102
+ * Searches recent macOS host crash reports for the launched iOS bundle id.
103
+ *
104
+ * @param {{bundleId: string, diagnosticReportsDir?: string | null}} options
105
+ * @returns {Promise<HostDiagnosticReportProbe>}
106
+ */
107
+ async function inspectHostDiagnosticReport({ bundleId, diagnosticReportsDir, }) {
108
+ const reportsDir = diagnosticReportsDir || defaultDiagnosticReportsDir();
109
+ const searchRawFileName = 'ios-host-diagnostic-report-search.txt';
110
+ const reportRawFileName = hostDiagnosticReportRawFileName(bundleId);
111
+ const startedAt = Date.now();
112
+ const lines = [
113
+ `searchDir=${reportsDir}`,
114
+ `bundleId=${bundleId}`,
115
+ `maxAgeMs=${HOST_DIAGNOSTIC_REPORT_MAX_AGE_MS}`,
116
+ `limit=${HOST_DIAGNOSTIC_REPORT_SEARCH_LIMIT}`,
117
+ ];
118
+ try {
119
+ const entries = await fsp.readdir(reportsDir, { withFileTypes: true });
120
+ const candidates = await Promise.all(entries
121
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.ips'))
122
+ .map(async (entry) => {
123
+ const filePath = path.join(reportsDir, entry.name);
124
+ const stat = await fsp.stat(filePath);
125
+ return { filePath, modifiedAtMs: stat.mtimeMs, name: entry.name };
126
+ }));
127
+ const recentCandidates = candidates
128
+ .filter((candidate) => startedAt - candidate.modifiedAtMs <= HOST_DIAGNOSTIC_REPORT_MAX_AGE_MS)
129
+ .sort((a, b) => b.modifiedAtMs - a.modifiedAtMs)
130
+ .slice(0, HOST_DIAGNOSTIC_REPORT_SEARCH_LIMIT);
131
+ lines.push(`candidateCount=${candidates.length}`);
132
+ lines.push(`recentCandidateCount=${recentCandidates.length}`);
133
+ for (const candidate of recentCandidates) {
134
+ lines.push(`checked=${candidate.name}`);
135
+ const content = await fsp.readFile(candidate.filePath, 'utf8');
136
+ if (!content.includes(`"bundleID":"${bundleId}"`) && !content.includes(`"bundleID" : "${bundleId}"`)) {
137
+ continue;
138
+ }
139
+ lines.push(`matched=${candidate.filePath}`);
140
+ return {
141
+ metadata: {
142
+ matched: true,
143
+ modifiedAt: new Date(candidate.modifiedAtMs).toISOString(),
144
+ rawPath: `raw/${reportRawFileName}`,
145
+ reportPath: candidate.filePath,
146
+ searchRawPath: `raw/${searchRawFileName}`,
147
+ },
148
+ raw: {
149
+ [reportRawFileName]: content,
150
+ [searchRawFileName]: lines.join('\n'),
151
+ },
152
+ };
153
+ }
154
+ lines.push('matched=');
155
+ return {
156
+ metadata: {
157
+ matched: false,
158
+ reportPath: null,
159
+ searchRawPath: `raw/${searchRawFileName}`,
160
+ },
161
+ raw: {
162
+ [searchRawFileName]: lines.join('\n'),
163
+ },
164
+ };
165
+ }
166
+ catch (error) {
167
+ lines.push(`error=${error instanceof Error ? error.message : String(error)}`);
168
+ return {
169
+ metadata: {
170
+ error: error instanceof Error ? error.message : String(error),
171
+ matched: false,
172
+ reportPath: null,
173
+ searchRawPath: `raw/${searchRawFileName}`,
174
+ },
175
+ raw: {
176
+ [searchRawFileName]: lines.join('\n'),
177
+ },
178
+ };
179
+ }
180
+ }
181
+ /**
182
+ * Prints CLI usage to stderr.
183
+ *
184
+ * @returns {void}
185
+ */
186
+ function usage(output = process.stderr) {
187
+ writeUsage([
188
+ 'Usage: asl-ios-simctl [--xcrun <path>] [--device <udid|booted>] [--bundle <id>] [--run-id <id>] [--out <dir>]',
189
+ '',
190
+ 'Checks iOS simulator readiness and writes health.json, verdict.json, agent-summary.md, and raw simctl evidence.',
191
+ 'Use --launch with --bundle <id> to launch the app before capturing a bounded simulator log window.',
192
+ 'Use --screenshot to save a simulator screenshot into captures/ios-screenshot.png.',
193
+ 'Use --screenshot-type, --screenshot-display, or --screenshot-mask to pass supported simctl screenshot options.',
194
+ 'Use --profile-session-storage <scenario> with --bundle <id> to seed the app profile session before launch.',
195
+ 'Use --profile-session-storage-key, --profile-command-storage-key, --profile-event-storage-key, --profile-signal-storage-key, and --profile-session-entries-storage-key to target app-owned AsyncStorage keys.',
196
+ 'Use --collect-profile-storage with --bundle <id> to collect stored profile events after the capture window.',
197
+ 'Use --diagnostic-reports-dir <path> when host crash reports live outside ~/Library/Logs/DiagnosticReports.',
198
+ ], output);
199
+ }
200
+ /**
201
+ * Resolves the capture filename for the selected simctl screenshot type.
202
+ *
203
+ * @param {string | undefined} screenshotType
204
+ * @returns {string}
205
+ */
206
+ function screenshotCaptureFileName(screenshotType) {
207
+ const normalized = screenshotType?.toLowerCase();
208
+ const extension = normalized && SCREENSHOT_EXTENSIONS.has(normalized) ? normalized : 'png';
209
+ return `ios-screenshot.${extension}`;
210
+ }
211
+ /**
212
+ * Parses `--key value` arguments for the iOS simctl capture CLI.
213
+ *
214
+ * @param {string[]} argv
215
+ * @returns {CliArgs}
216
+ */
217
+ function parseArgs(argv) {
218
+ const args = {};
219
+ for (let index = 0; index < argv.length; index += 1) {
220
+ const token = argv[index];
221
+ if (token === '--') {
222
+ continue;
223
+ }
224
+ if (!token || !token.startsWith('--')) {
225
+ continue;
226
+ }
227
+ const key = token.slice(2);
228
+ const value = argv[index + 1];
229
+ if (value && !value.startsWith('--')) {
230
+ args[key] = value;
231
+ index += 1;
232
+ }
233
+ else {
234
+ args[key] = true;
235
+ }
236
+ }
237
+ return args;
238
+ }
239
+ /**
240
+ * Parses a positive integer CLI value, falling back when absent or invalid.
241
+ *
242
+ * @param {string | boolean | undefined} value
243
+ * @param {number} fallback
244
+ * @returns {number}
245
+ */
246
+ function parsePositiveInteger(value, fallback) {
247
+ if (typeof value !== 'string') {
248
+ return fallback;
249
+ }
250
+ const parsed = Number(value);
251
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
252
+ }
253
+ /**
254
+ * Reads the process id from `simctl launch` output.
255
+ *
256
+ * @param {string} output
257
+ * @returns {string | null}
258
+ */
259
+ function parseSimctlLaunchPid(output) {
260
+ const match = /:\s*(?<pid>\d+)\s*$/u.exec(output.trim());
261
+ return match?.groups?.pid ?? null;
262
+ }
263
+ /**
264
+ * Escapes a value for a CoreSimulator log predicate string literal.
265
+ *
266
+ * @param {string} value
267
+ * @returns {string}
268
+ */
269
+ function escapeLogPredicateString(value) {
270
+ return value.replace(/\\/gu, '\\\\').replace(/"/gu, '\\"');
271
+ }
272
+ /**
273
+ * Builds a simulator log predicate for the launched app lifecycle.
274
+ *
275
+ * @param {{bundleId: string, pid?: string | null}} options
276
+ * @returns {string}
277
+ */
278
+ function buildIosAppLifecycleLogPredicate({ bundleId, pid = null, }) {
279
+ const bundlePredicate = `eventMessage CONTAINS "${escapeLogPredicateString(bundleId)}"`;
280
+ return pid
281
+ ? `${bundlePredicate} AND eventMessage CONTAINS "${escapeLogPredicateString(pid)}"`
282
+ : bundlePredicate;
283
+ }
284
+ /**
285
+ * Detects native app exits that invalidate simulator capture evidence.
286
+ *
287
+ * @param {string} output
288
+ * @returns {'crash' | 'exit' | null}
289
+ */
290
+ function classifyIosAppLifecycleInstability(output) {
291
+ for (const line of String(output).split(/\r?\n/u)) {
292
+ if (/xpcservice<com\.apple\.WebKit|Browser Engine helper|WebContent|WebKit\.Networking|WebKit\.GPU/iu.test(line)) {
293
+ continue;
294
+ }
295
+ if (/\bSIG[A-Z0-9_]+\b|[Ss]egmentation fault|EXC_[A-Z_]+|scene-creation-failed/u.test(line)) {
296
+ return 'crash';
297
+ }
298
+ if (/Process exited|exited with context|termination reported by launchd/iu.test(line)) {
299
+ return 'exit';
300
+ }
301
+ }
302
+ return null;
303
+ }
304
+ /**
305
+ * Reads the current target application state from `simctl appinfo` output.
306
+ *
307
+ * @param {string} output
308
+ * @returns {string | null}
309
+ */
310
+ function parseIosAppInfoApplicationState(output) {
311
+ const trimmed = output.trim();
312
+ if (trimmed.length === 0) {
313
+ return null;
314
+ }
315
+ try {
316
+ const parsed = JSON.parse(trimmed);
317
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
318
+ const state = parsed.ApplicationState;
319
+ return typeof state === 'string' && state.trim().length > 0 ? state.trim() : null;
320
+ }
321
+ }
322
+ catch {
323
+ // simctl appinfo has emitted plist-like text on some Xcode versions.
324
+ }
325
+ const match = /ApplicationState\s*["'=:\s]+\s*"?([A-Za-z]+)"?/u.exec(trimmed);
326
+ return match?.[1] ?? null;
327
+ }
328
+ /**
329
+ * Reports whether a parsed iOS application state still owns the foreground surface.
330
+ *
331
+ * @param {string | null} state
332
+ * @returns {boolean}
333
+ */
334
+ function isIosAppInfoForegroundState(state) {
335
+ return state === 'ForegroundRunning' || state === 'ForegroundSuspended';
336
+ }
337
+ /**
338
+ * Normalizes configured sibling bundle ids, excluding the selected target bundle.
339
+ *
340
+ * @param {{bundleId: string | null, conflictingBundleIds: string[]}} options
341
+ * @returns {string[]}
342
+ */
343
+ function normalizeConflictingBundleIds({ bundleId, conflictingBundleIds, }) {
344
+ const ids = new Set();
345
+ for (const candidate of conflictingBundleIds) {
346
+ const normalized = typeof candidate === 'string' ? candidate.trim() : '';
347
+ if (normalized.length === 0 || normalized === bundleId) {
348
+ continue;
349
+ }
350
+ ids.add(normalized);
351
+ }
352
+ return [...ids].sort();
353
+ }
354
+ /**
355
+ * Creates a short random run id for iOS simulator capture runs.
356
+ *
357
+ * @returns {string}
358
+ */
359
+ function createRunId() {
360
+ return crypto.randomBytes(6).toString('hex');
361
+ }
362
+ /**
363
+ * Resolves React Native AsyncStorage's iOS storage directory for an app data container.
364
+ *
365
+ * @param {{dataContainer: string, bundleId: string}} options
366
+ * @returns {string}
367
+ */
368
+ function resolveAsyncStorageDirectory({ bundleId, dataContainer, }) {
369
+ return path.join(dataContainer, 'Library', 'Application Support', bundleId, 'RCTAsyncLocalStorage_V1');
370
+ }
371
+ /**
372
+ * Returns the native iOS AsyncStorage spill-file name for a key.
373
+ *
374
+ * @param {string} key
375
+ * @returns {string}
376
+ */
377
+ function asyncStorageFileNameForKey(key) {
378
+ return crypto.createHash('md5').update(key).digest('hex');
379
+ }
380
+ /**
381
+ * Reads the native iOS AsyncStorage manifest when it exists.
382
+ *
383
+ * @param {string} storageDir
384
+ * @returns {AsyncStorageManifest}
385
+ */
386
+ function readAsyncStorageManifestSync(storageDir) {
387
+ const manifestPath = path.join(storageDir, 'manifest.json');
388
+ if (!fs.existsSync(manifestPath)) {
389
+ return {};
390
+ }
391
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
392
+ }
393
+ /**
394
+ * Writes the native iOS AsyncStorage manifest with stable formatting.
395
+ *
396
+ * @param {{manifest: AsyncStorageManifest, storageDir: string}} options
397
+ * @returns {Promise<void>}
398
+ */
399
+ async function writeAsyncStorageManifest({ manifest, storageDir, }) {
400
+ await fsp.mkdir(storageDir, { recursive: true });
401
+ await fsp.writeFile(path.join(storageDir, 'manifest.json'), `${JSON.stringify(manifest)}\n`, 'utf8');
402
+ }
403
+ /**
404
+ * Reads one native iOS AsyncStorage value from an inline manifest or spill file.
405
+ *
406
+ * @param {{key: string, storageDir: string}} options
407
+ * @returns {string | null}
408
+ */
409
+ function readAsyncStorageValueSync({ key, storageDir, }) {
410
+ const manifest = readAsyncStorageManifestSync(storageDir);
411
+ const value = manifest[key];
412
+ if (typeof value === 'string') {
413
+ return value;
414
+ }
415
+ if (value !== null) {
416
+ return null;
417
+ }
418
+ const spillPath = path.join(storageDir, asyncStorageFileNameForKey(key));
419
+ return fs.existsSync(spillPath) ? fs.readFileSync(spillPath, 'utf8') : null;
420
+ }
421
+ /**
422
+ * Removes stale native iOS AsyncStorage spill files for keys the runner resets.
423
+ *
424
+ * @param {{keys: string[], storageDir: string}} options
425
+ * @returns {Promise<void>}
426
+ */
427
+ async function removeAsyncStorageSpillFiles({ keys, storageDir, }) {
428
+ await Promise.all(keys.map((key) => fsp.rm(path.join(storageDir, asyncStorageFileNameForKey(key)), { force: true })));
429
+ }
430
+ /**
431
+ * Seeds the app profile-session AsyncStorage key before launching the iOS app.
432
+ *
433
+ * @param {{bundleId: string, commands?: IosProfileSessionStorageCommand[], dataContainer: string, runId: string, scenario: string, startedAt?: number}} options
434
+ * @returns {Promise<{manifestPath: string, storageDir: string, session: Record<string, unknown>}>}
435
+ */
436
+ async function seedProfileSessionStorage({ bundleId, commands = [], dataContainer, profileStorageKeys = DEFAULT_PROFILE_STORAGE_KEYS, runId, scenario, startedAt = Date.now(), }) {
437
+ const storageDir = resolveAsyncStorageDirectory({ bundleId, dataContainer });
438
+ const manifest = readAsyncStorageManifestSync(storageDir);
439
+ const resetKeys = [
440
+ profileStorageKeys.event,
441
+ profileStorageKeys.signal,
442
+ profileStorageKeys.command,
443
+ profileStorageKeys.sessionEntries,
444
+ ];
445
+ for (const key of resetKeys) {
446
+ delete manifest[key];
447
+ }
448
+ const session = {
449
+ active: true,
450
+ scenario,
451
+ runId,
452
+ startedAt,
453
+ };
454
+ const queuedCommands = commands.map((profileCommand, index) => ({
455
+ id: profileCommand.id ?? `ios-storage-command-${index + 1}-${profileCommand.command}`,
456
+ scenario,
457
+ runId,
458
+ command: profileCommand.command,
459
+ timestamp: typeof profileCommand.timestamp === 'number' ? profileCommand.timestamp : startedAt + index + 1,
460
+ }));
461
+ manifest[profileStorageKeys.session] = JSON.stringify(session);
462
+ if (queuedCommands.length > 0) {
463
+ manifest[profileStorageKeys.command] = JSON.stringify(queuedCommands);
464
+ }
465
+ await removeAsyncStorageSpillFiles({
466
+ keys: [profileStorageKeys.session, ...resetKeys],
467
+ storageDir,
468
+ });
469
+ await writeAsyncStorageManifest({ manifest, storageDir });
470
+ return {
471
+ commands: queuedCommands,
472
+ manifestPath: path.join(storageDir, 'manifest.json'),
473
+ session,
474
+ storageDir,
475
+ };
476
+ }
477
+ /**
478
+ * Reads JSON stored by the app profile-session AsyncStorage bridge.
479
+ *
480
+ * @param {{bundleId: string, dataContainer: string, key: string, fallback: unknown}} options
481
+ * @returns {unknown}
482
+ */
483
+ function readProfileStorageJson({ bundleId, dataContainer, fallback, key, }) {
484
+ const storageDir = resolveAsyncStorageDirectory({ bundleId, dataContainer });
485
+ const rawValue = readAsyncStorageValueSync({ key, storageDir });
486
+ if (!rawValue) {
487
+ return fallback;
488
+ }
489
+ return JSON.parse(rawValue);
490
+ }
491
+ /**
492
+ * Formats stored profile events as the canonical profile-event log payload.
493
+ *
494
+ * @param {Record<string, unknown>[]} events
495
+ * @returns {string}
496
+ */
497
+ function formatStoredProfileEventLog(events) {
498
+ return events
499
+ .map((event) => {
500
+ const timestamp = typeof event.timestamp === 'number' && Number.isFinite(event.timestamp)
501
+ ? new Date(event.timestamp).toISOString()
502
+ : new Date(0).toISOString();
503
+ return `${timestamp} ios-simctl [profile-event] ${JSON.stringify(event)}`;
504
+ })
505
+ .join('\n');
506
+ }
507
+ /**
508
+ * Runs a command and captures stdout, stderr, and exit code without throwing.
509
+ *
510
+ * @param {string} command
511
+ * @param {string[]} args
512
+ * @returns {Promise<CommandResult>}
513
+ */
514
+ function execFileCommand(command, args) {
515
+ return new Promise((resolve) => {
516
+ execFile(command, args, { encoding: 'utf8' }, (error, stdout, stderr) => {
517
+ resolve({
518
+ command,
519
+ args,
520
+ exitCode: error && typeof error.code === 'number' ? error.code : error ? 1 : 0,
521
+ stderr,
522
+ stdout,
523
+ });
524
+ });
525
+ });
526
+ }
527
+ /**
528
+ * Waits for the requested capture window.
529
+ *
530
+ * @param {number} ms
531
+ * @returns {Promise<void>}
532
+ */
533
+ function delay(ms) {
534
+ return new Promise((resolve) => {
535
+ setTimeout(resolve, ms);
536
+ });
537
+ }
538
+ /**
539
+ * Parses `xcrun simctl list devices` output into simulator rows.
540
+ *
541
+ * @param {string} output
542
+ * @returns {IosSimulator[]}
543
+ */
544
+ function parseSimctlDevices(output) {
545
+ return String(output)
546
+ .split(/\r?\n/u)
547
+ .map((line) => line.trim())
548
+ .map((line) => {
549
+ const match = /^(?<name>.+) \((?<udid>[0-9A-F-]+)\) \((?<state>[^)]+)\)$/u.exec(line);
550
+ if (!match?.groups) {
551
+ return null;
552
+ }
553
+ return {
554
+ name: match.groups.name,
555
+ state: match.groups.state,
556
+ udid: match.groups.udid,
557
+ };
558
+ })
559
+ .filter((simulator) => Boolean(simulator));
560
+ }
561
+ /**
562
+ * Selects a simulator by explicit UDID or the first booted simulator.
563
+ *
564
+ * @param {IosSimulator[]} simulators
565
+ * @param {string | null | undefined} device
566
+ * @returns {IosSimulator | null}
567
+ */
568
+ function selectSimulator(simulators, device) {
569
+ if (device && device !== 'booted') {
570
+ return simulators.find((simulator) => simulator.udid === device) ?? null;
571
+ }
572
+ return simulators.find((simulator) => simulator.state === 'Booted') ?? null;
573
+ }
574
+ /**
575
+ * Creates scalar health-check metadata for an agent-readable next action.
576
+ *
577
+ * @param {string} nextActionCode
578
+ * @param {string} nextAction
579
+ * @returns {NextActionHint}
580
+ */
581
+ function nextActionHint(nextActionCode, nextAction) {
582
+ return {
583
+ nextAction,
584
+ nextActionCode,
585
+ };
586
+ }
587
+ /**
588
+ * Creates a raw artifact filename for a simulator launch environment key.
589
+ *
590
+ * @param {string} key
591
+ * @returns {string}
592
+ */
593
+ function launchEnvironmentRawFileName(key) {
594
+ return `ios-launch-env-${key.toLowerCase().replace(/[^a-z0-9]+/gu, '-')}.txt`;
595
+ }
596
+ /**
597
+ * Reports whether the requested capture path can mutate simulator app lifecycle.
598
+ *
599
+ * @param {{deepLinks: IosSimctlDeepLink[], launch: boolean, profileSessionStorage: IosProfileSessionStorageSeed | null, terminateBeforeLaunch: boolean}} options
600
+ * @returns {boolean}
601
+ */
602
+ function mutatesSimulatorLifecycle({ deepLinks, launch, profileSessionStorage, terminateBeforeLaunch, }) {
603
+ return launch || terminateBeforeLaunch || Boolean(profileSessionStorage) || deepLinks.length > 0;
604
+ }
605
+ /**
606
+ * Reads simulator launch environment values that can silently inject runner behavior.
607
+ *
608
+ * @param {{deviceUdid: string, executor: CommandExecutor, xcrunPath: string}} options
609
+ * @returns {Promise<SimulatorLaunchEnvironmentProbe>}
610
+ */
611
+ async function inspectSimulatorLaunchEnvironment({ deviceUdid, executor, xcrunPath, }) {
612
+ const values = {};
613
+ const raw = {};
614
+ for (const key of SIMULATOR_LAUNCH_ENV_KEYS) {
615
+ const result = await executor(xcrunPath, ['simctl', 'spawn', deviceUdid, 'launchctl', 'getenv', key]);
616
+ const rawFileName = launchEnvironmentRawFileName(key);
617
+ const output = [result.stdout, result.stderr].filter(Boolean).join('\n');
618
+ raw[rawFileName] = output;
619
+ values[key] = {
620
+ args: result.args,
621
+ exitCode: result.exitCode,
622
+ rawPath: `raw/${rawFileName}`,
623
+ value: result.exitCode === 0 ? result.stdout.trim() : null,
624
+ };
625
+ }
626
+ return {
627
+ clean: Object.values(values).every((entry) => (entry.exitCode !== 0 || typeof entry.value !== 'string' || entry.value.length === 0)),
628
+ metadata: values,
629
+ raw,
630
+ };
631
+ }
632
+ /**
633
+ * Builds a health artifact from iOS simulator capture checks.
634
+ *
635
+ * @param {{runId: string, checks: Record<string, unknown>[]}} options
636
+ * @returns {Record<string, unknown>}
637
+ */
638
+ function buildIosSimctlHealth({ runId, checks }) {
639
+ const failed = checks.some((check) => check.status === 'failed');
640
+ return assertValidJson({
641
+ schemaVersion: '1.0.0',
642
+ scenarioId: 'ios-simctl-capture',
643
+ flowId: 'ios-simctl-capture',
644
+ runId,
645
+ healthStatus: failed ? 'failed' : 'passed',
646
+ checks,
647
+ }, SCHEMAS.health, 'Health artifact');
648
+ }
649
+ /**
650
+ * Builds a verdict artifact for iOS simulator capture readiness.
651
+ *
652
+ * @param {{runId: string, health: Record<string, unknown>}} options
653
+ * @returns {Record<string, unknown>}
654
+ */
655
+ function buildIosSimctlVerdict({ runId, health }) {
656
+ const passed = health.healthStatus === 'passed';
657
+ return assertValidJson({
658
+ schemaVersion: '1.0.0',
659
+ scenarioId: 'ios-simctl-capture',
660
+ flowId: 'ios-simctl-capture',
661
+ runId,
662
+ healthStatus: health.healthStatus,
663
+ verdictStatus: passed ? 'not_evaluated' : 'inconclusive',
664
+ budgetChecks: [],
665
+ summary: passed
666
+ ? 'iOS simctl capture passed; no product budget has been evaluated.'
667
+ : 'iOS simctl capture failed; runtime scenario execution is not ready.',
668
+ }, SCHEMAS.verdict, 'Verdict artifact');
669
+ }
670
+ /**
671
+ * Runs iOS simulator readiness checks and writes raw simctl evidence.
672
+ *
673
+ * @param {IosSimctlCaptureOptions} options
674
+ * @returns {Promise<IosSimctlCaptureResult>}
675
+ */
676
+ async function runIosSimctlCapture({ bundleId = null, collectProfileStorage = false, conflictingBundleIds = [], deepLinks = [], delay: wait = delay, device = null, diagnosticReportsDir = null, executor = execFileCommand, launch = false, logLast = '2m', outputDir = path.resolve('artifacts/ios-simctl-capture'), profileSessionStorage = null, profileStorageKeys: profileStorageKeyOverrides, runId = createRunId(), screenshot = false, screenshotDisplay, screenshotMask, screenshotType, terminateBeforeLaunch = false, waitMs = 0, xcrunPath = 'xcrun', } = {}) {
677
+ const runDir = path.resolve(outputDir);
678
+ const layout = createArtifactLayout({ outputDir: runDir });
679
+ const rawDir = layout.raw;
680
+ await fsp.mkdir(rawDir, { recursive: true });
681
+ const profileStorageKeys = resolveProfileStorageKeys(profileStorageKeyOverrides);
682
+ const raw = {};
683
+ const captures = {
684
+ screenshot: null,
685
+ };
686
+ const checks = [];
687
+ const deepLinkResults = [];
688
+ const devicesOutput = await executor(xcrunPath, ['simctl', 'list', 'devices']);
689
+ const simctlAvailable = devicesOutput.exitCode === 0;
690
+ raw['ios-simctl-devices.txt'] = [devicesOutput.stdout, devicesOutput.stderr].filter(Boolean).join('\n');
691
+ checks.push({
692
+ name: 'ios_simctl_available',
693
+ status: simctlAvailable ? 'passed' : 'failed',
694
+ source: 'runner',
695
+ code: simctlAvailable ? 'ios_simctl_available' : 'ios_simctl_unavailable',
696
+ message: simctlAvailable ? 'simctl device listing succeeded.' : 'simctl device listing failed.',
697
+ ...(!simctlAvailable
698
+ ? {
699
+ metadata: nextActionHint('fix_xcrun_simctl', 'Select a working Xcode with xcode-select, finish any first-launch setup, or pass --xcrun with a working xcrun binary. If direct xcrun works but the Node runner fails from an agent sandbox, rerun with simulator/CoreSimulator access outside the sandbox.'),
700
+ }
701
+ : {}),
702
+ });
703
+ const simulators = parseSimctlDevices(devicesOutput.stdout);
704
+ const simulator = simctlAvailable ? selectSimulator(simulators, device) : null;
705
+ const selectedDevice = device || 'booted';
706
+ const simulatorBooted = Boolean(simulator && simulator.state === 'Booted');
707
+ checks.push({
708
+ name: 'ios_simulator_booted',
709
+ status: simulatorBooted ? 'passed' : 'failed',
710
+ source: 'runner',
711
+ code: simulatorBooted ? 'ios_simulator_booted' : 'ios_simulator_missing',
712
+ message: simulatorBooted && simulator
713
+ ? `Selected iOS simulator ${simulator.name} (${simulator.udid}).`
714
+ : device
715
+ ? `No booted iOS simulator matched ${device}.`
716
+ : 'No booted iOS simulator was found.',
717
+ ...(!simulatorBooted
718
+ ? {
719
+ metadata: nextActionHint('boot_ios_simulator', 'Boot an iOS simulator, install the required simulator runtime if needed, or pass --device with a booted simulator UDID.'),
720
+ }
721
+ : {}),
722
+ });
723
+ const metadata = {
724
+ bundleId,
725
+ collectProfileStorage,
726
+ conflictingBundleIds: [],
727
+ deepLinks,
728
+ deepLinkResults,
729
+ diagnosticReportsDir: diagnosticReportsDir || defaultDiagnosticReportsDir(),
730
+ launch,
731
+ logLast,
732
+ profileStorageKeys,
733
+ profileSessionStorage: profileSessionStorage
734
+ ? {
735
+ commandCount: Array.isArray(profileSessionStorage.commands) ? profileSessionStorage.commands.length : 0,
736
+ runId: profileSessionStorage.runId,
737
+ scenario: profileSessionStorage.scenario,
738
+ startedAt: profileSessionStorage.startedAt ?? null,
739
+ }
740
+ : null,
741
+ screenshot,
742
+ selectedDevice,
743
+ selectedSimulator: simulator,
744
+ terminateBeforeLaunch,
745
+ waitMs,
746
+ xcrunPath,
747
+ };
748
+ if (simulator && simulator.state === 'Booted') {
749
+ const launchEnvironmentNeedsCleanState = mutatesSimulatorLifecycle({
750
+ deepLinks,
751
+ launch,
752
+ profileSessionStorage,
753
+ terminateBeforeLaunch,
754
+ });
755
+ const launchEnvironment = await inspectSimulatorLaunchEnvironment({
756
+ deviceUdid: simulator.udid,
757
+ executor,
758
+ xcrunPath,
759
+ });
760
+ Object.assign(raw, launchEnvironment.raw);
761
+ const launchEnvironmentProbeAvailable = Object.values(launchEnvironment.metadata).some((entry) => (typeof entry === 'object'
762
+ && entry !== null
763
+ && entry.exitCode === 0));
764
+ const launchEnvironmentCleanEnough = launchEnvironment.clean || !launchEnvironmentNeedsCleanState;
765
+ checks.push({
766
+ name: 'ios_simulator_launch_environment_clean',
767
+ status: launchEnvironmentProbeAvailable
768
+ ? launchEnvironmentCleanEnough ? 'passed' : 'failed'
769
+ : 'warning',
770
+ source: 'runner',
771
+ code: launchEnvironmentProbeAvailable
772
+ ? launchEnvironmentCleanEnough
773
+ ? 'ios_simulator_launch_environment_clean'
774
+ : 'ios_simulator_launch_environment_contaminated'
775
+ : 'ios_simulator_launch_environment_unavailable',
776
+ message: launchEnvironmentProbeAvailable
777
+ ? launchEnvironmentCleanEnough
778
+ ? 'Simulator launch environment has no known hidden runner injection for this capture mode.'
779
+ : 'Simulator launch environment contains hidden runner injection that can contaminate simctl proof.'
780
+ : 'Could not inspect simulator launch environment.',
781
+ ...(launchEnvironmentProbeAvailable && !launchEnvironmentCleanEnough
782
+ ? {
783
+ metadata: nextActionHint('clear_ios_simulator_launch_environment', `Clear ${SIMULATOR_LAUNCH_ENV_KEYS.join(' and ')} for the selected simulator, or use the runner that owns that injected environment instead of simctl proof.`),
784
+ }
785
+ : {}),
786
+ });
787
+ metadata.launchEnvironment = launchEnvironment.metadata;
788
+ const driver = createIosSimctlDriver({
789
+ deviceUdid: simulator.udid,
790
+ executor,
791
+ xcrunPath,
792
+ });
793
+ let dataContainerPath = null;
794
+ let hasInstalledConflictingBundle = false;
795
+ let launchedAppPid = null;
796
+ const lifecycleMutationBlocked = launchEnvironmentProbeAvailable && !launchEnvironmentCleanEnough;
797
+ if (bundleId) {
798
+ const appContainer = await executor(xcrunPath, ['simctl', 'get_app_container', simulator.udid, bundleId, 'app']);
799
+ raw['ios-app-container.txt'] = [appContainer.stdout, appContainer.stderr].filter(Boolean).join('\n');
800
+ const appInstalled = appContainer.exitCode === 0 && appContainer.stdout.trim().length > 0;
801
+ checks.push({
802
+ name: 'ios_app_installed',
803
+ status: appInstalled ? 'passed' : 'failed',
804
+ source: 'runner',
805
+ code: appInstalled
806
+ ? 'ios_app_installed'
807
+ : 'ios_app_missing',
808
+ message: appInstalled
809
+ ? `App ${bundleId} is installed.`
810
+ : `App ${bundleId} is not installed on ${simulator.udid}.`,
811
+ ...(!appInstalled
812
+ ? {
813
+ metadata: nextActionHint('install_ios_app', 'Build and install the app on the selected simulator, or rerun with --bundle set to the installed bundle id.'),
814
+ }
815
+ : {}),
816
+ });
817
+ metadata.appContainer = {
818
+ rawPath: 'raw/ios-app-container.txt',
819
+ };
820
+ const checkedConflictingBundleIds = normalizeConflictingBundleIds({
821
+ bundleId,
822
+ conflictingBundleIds,
823
+ });
824
+ if (checkedConflictingBundleIds.length > 0) {
825
+ const installedConflictingBundleIds = [];
826
+ const conflictChecks = [];
827
+ for (const [index, conflictingBundleId] of checkedConflictingBundleIds.entries()) {
828
+ const rawFileName = `ios-conflicting-bundle-${index + 1}-${rawBundleIdSuffix(conflictingBundleId)}.txt`;
829
+ const conflictContainer = await executor(xcrunPath, [
830
+ 'simctl',
831
+ 'get_app_container',
832
+ simulator.udid,
833
+ conflictingBundleId,
834
+ 'app',
835
+ ]);
836
+ raw[rawFileName] = [conflictContainer.stdout, conflictContainer.stderr].filter(Boolean).join('\n');
837
+ const installed = conflictContainer.exitCode === 0 && conflictContainer.stdout.trim().length > 0;
838
+ if (installed) {
839
+ installedConflictingBundleIds.push(conflictingBundleId);
840
+ }
841
+ conflictChecks.push({
842
+ bundleId: conflictingBundleId,
843
+ installed,
844
+ rawPath: `raw/${rawFileName}`,
845
+ });
846
+ }
847
+ hasInstalledConflictingBundle = installedConflictingBundleIds.length > 0;
848
+ checks.push({
849
+ name: 'ios_conflicting_bundles_absent',
850
+ status: hasInstalledConflictingBundle ? 'failed' : 'passed',
851
+ source: 'runner',
852
+ code: hasInstalledConflictingBundle
853
+ ? 'ios_conflicting_bundles_installed'
854
+ : 'ios_conflicting_bundles_absent',
855
+ message: hasInstalledConflictingBundle
856
+ ? `Conflicting iOS bundle id(s) are installed on ${simulator.udid}: ${installedConflictingBundleIds.join(', ')}.`
857
+ : 'No configured conflicting iOS bundle ids are installed on the selected simulator.',
858
+ ...(hasInstalledConflictingBundle
859
+ ? {
860
+ metadata: nextActionHint('uninstall_ios_conflicting_bundles', 'Uninstall the conflicting app variant(s) from the selected simulator, or use a clean simulator dedicated to the target bundle before trusting launch, deep-link, or profile-session evidence.'),
861
+ }
862
+ : {}),
863
+ });
864
+ metadata.conflictingBundleIds = {
865
+ checked: conflictChecks,
866
+ installed: installedConflictingBundleIds,
867
+ };
868
+ }
869
+ if (collectProfileStorage || profileSessionStorage) {
870
+ const dataContainer = await executor(xcrunPath, [
871
+ 'simctl',
872
+ 'get_app_container',
873
+ simulator.udid,
874
+ bundleId,
875
+ 'data',
876
+ ]);
877
+ raw['ios-data-container.txt'] = [dataContainer.stdout, dataContainer.stderr].filter(Boolean).join('\n');
878
+ dataContainerPath = dataContainer.exitCode === 0 && dataContainer.stdout.trim().length > 0
879
+ ? dataContainer.stdout.trim()
880
+ : null;
881
+ checks.push({
882
+ name: 'ios_data_container_available',
883
+ status: dataContainerPath ? 'passed' : 'failed',
884
+ source: 'runner',
885
+ code: dataContainerPath ? 'ios_data_container_available' : 'ios_data_container_missing',
886
+ message: dataContainerPath
887
+ ? `App data container for ${bundleId} is available.`
888
+ : `App data container for ${bundleId} was not available.`,
889
+ ...(!dataContainerPath
890
+ ? {
891
+ metadata: nextActionHint('inspect_ios_data_container', 'Confirm the app is installed and has launched at least once so simctl can resolve its data container.'),
892
+ }
893
+ : {}),
894
+ });
895
+ metadata.dataContainer = {
896
+ rawPath: 'raw/ios-data-container.txt',
897
+ };
898
+ }
899
+ }
900
+ if (terminateBeforeLaunch && !hasInstalledConflictingBundle && !lifecycleMutationBlocked) {
901
+ if (!bundleId) {
902
+ checks.push({
903
+ name: 'ios_app_terminated',
904
+ status: 'failed',
905
+ source: 'runner',
906
+ code: 'ios_terminate_missing_bundle',
907
+ message: 'App termination was requested, but no bundle id was provided.',
908
+ metadata: nextActionHint('provide_ios_bundle', 'Rerun with --bundle set to the installed iOS bundle id when termination is requested.'),
909
+ });
910
+ }
911
+ else {
912
+ const terminateResult = await driver.terminateBundle(bundleId);
913
+ raw[terminateResult.rawFileName] = formatIosSimctlRawOutput(terminateResult);
914
+ const terminateOutput = `${terminateResult.stdout}\n${terminateResult.stderr}`;
915
+ const notRunning = /not running|No such process|found nothing to terminate|The operation couldn't be completed/iu.test(terminateOutput);
916
+ const terminatePassed = terminateResult.exitCode === 0 || notRunning;
917
+ checks.push({
918
+ name: 'ios_app_terminated',
919
+ status: terminatePassed ? 'passed' : 'failed',
920
+ source: 'runner',
921
+ code: terminatePassed ? 'ios_app_terminated' : 'ios_app_terminate_failed',
922
+ message: terminatePassed
923
+ ? `Terminated app ${bundleId} before capture.`
924
+ : `Failed to terminate app ${bundleId} before capture.`,
925
+ ...(!terminatePassed
926
+ ? {
927
+ metadata: nextActionHint('inspect_ios_terminate', 'Inspect raw/ios-terminate.txt, confirm the bundle id is installed on the selected simulator, then rerun.'),
928
+ }
929
+ : {}),
930
+ });
931
+ metadata.terminateResult = {
932
+ args: terminateResult.args,
933
+ exitCode: terminateResult.exitCode,
934
+ rawPath: 'raw/ios-terminate.txt',
935
+ };
936
+ }
937
+ }
938
+ if (profileSessionStorage && !hasInstalledConflictingBundle && !lifecycleMutationBlocked) {
939
+ if (!bundleId || !dataContainerPath) {
940
+ checks.push({
941
+ name: 'ios_profile_session_seeded',
942
+ status: 'failed',
943
+ source: 'runner',
944
+ code: 'ios_profile_session_seed_missing_container',
945
+ message: 'Profile-session storage seeding needs both bundle id and app data container.',
946
+ metadata: nextActionHint('fix_ios_profile_session_storage', 'Provide --bundle, install and launch the app once, then rerun so the native AsyncStorage container exists.'),
947
+ });
948
+ }
949
+ else {
950
+ const seeded = await seedProfileSessionStorage({
951
+ bundleId,
952
+ ...(Array.isArray(profileSessionStorage.commands)
953
+ ? { commands: profileSessionStorage.commands }
954
+ : {}),
955
+ dataContainer: dataContainerPath,
956
+ profileStorageKeys,
957
+ runId: profileSessionStorage.runId,
958
+ scenario: profileSessionStorage.scenario,
959
+ ...(typeof profileSessionStorage.startedAt === 'number'
960
+ ? { startedAt: profileSessionStorage.startedAt }
961
+ : {}),
962
+ });
963
+ raw['ios-profile-session-seed.json'] = JSON.stringify({
964
+ commands: seeded.commands,
965
+ session: seeded.session,
966
+ }, null, 2);
967
+ checks.push({
968
+ name: 'ios_profile_session_seeded',
969
+ status: 'passed',
970
+ source: 'runner',
971
+ code: 'ios_profile_session_seeded',
972
+ message: `Seeded profile session ${profileSessionStorage.scenario}/${profileSessionStorage.runId} into app storage.`,
973
+ });
974
+ metadata.profileSessionSeed = {
975
+ commandCount: seeded.commands.length,
976
+ rawPath: 'raw/ios-profile-session-seed.json',
977
+ };
978
+ }
979
+ }
980
+ if (launch && !hasInstalledConflictingBundle && !lifecycleMutationBlocked) {
981
+ if (!bundleId) {
982
+ checks.push({
983
+ name: 'ios_app_launched',
984
+ status: 'failed',
985
+ source: 'runner',
986
+ code: 'ios_launch_missing_bundle',
987
+ message: 'App launch was requested, but no bundle id was provided.',
988
+ metadata: nextActionHint('provide_ios_bundle', 'Rerun with --bundle set to the installed iOS bundle id when --launch is enabled.'),
989
+ });
990
+ }
991
+ else {
992
+ const launchResult = await driver.launchBundle(bundleId);
993
+ const launchPassed = launchResult.exitCode === 0;
994
+ launchedAppPid = launchPassed ? parseSimctlLaunchPid(launchResult.stdout) : null;
995
+ raw[launchResult.rawFileName] = formatIosSimctlRawOutput(launchResult);
996
+ checks.push({
997
+ name: 'ios_app_launched',
998
+ status: launchPassed ? 'passed' : 'failed',
999
+ source: 'runner',
1000
+ code: launchPassed ? 'ios_app_launched' : 'ios_app_launch_failed',
1001
+ message: launchPassed ? `Launched app ${bundleId}.` : `Failed to launch app ${bundleId}.`,
1002
+ ...(!launchPassed
1003
+ ? {
1004
+ metadata: nextActionHint('inspect_ios_launch', 'Inspect raw/ios-launch.txt, confirm the bundle id and simulator runtime are valid, and verify the app opens manually.'),
1005
+ }
1006
+ : {}),
1007
+ });
1008
+ metadata.launchResult = {
1009
+ args: launchResult.args,
1010
+ exitCode: launchResult.exitCode,
1011
+ pid: launchedAppPid,
1012
+ rawPath: 'raw/ios-launch.txt',
1013
+ };
1014
+ }
1015
+ }
1016
+ for (const [index, deepLink] of (hasInstalledConflictingBundle || lifecycleMutationBlocked ? [] : deepLinks).entries()) {
1017
+ const rawFileName = `ios-deep-link-${index + 1}.txt`;
1018
+ const deepLinkResult = await driver.openDeepLink({ rawFileName, url: deepLink.url });
1019
+ const deepLinkOpened = deepLinkResult.exitCode === 0;
1020
+ raw[deepLinkResult.rawFileName] = formatIosSimctlRawOutput(deepLinkResult);
1021
+ deepLinkResults.push({
1022
+ args: deepLinkResult.args,
1023
+ exitCode: deepLinkResult.exitCode,
1024
+ label: deepLink.label ?? null,
1025
+ rawPath: `raw/${deepLinkResult.rawFileName}`,
1026
+ url: deepLink.url,
1027
+ waitMs: deepLink.waitMs ?? 0,
1028
+ });
1029
+ checks.push({
1030
+ name: 'ios_deep_link_opened',
1031
+ status: deepLinkOpened ? 'passed' : 'failed',
1032
+ source: 'runner',
1033
+ code: deepLinkOpened ? 'ios_deep_link_opened' : 'ios_deep_link_failed',
1034
+ message: deepLinkOpened
1035
+ ? `Opened iOS deep link ${deepLink.label ?? index + 1}.`
1036
+ : `Failed to open iOS deep link ${deepLink.label ?? index + 1}.`,
1037
+ ...(!deepLinkOpened
1038
+ ? {
1039
+ metadata: nextActionHint('inspect_ios_deep_link', `Inspect raw/${deepLinkResult.rawFileName}, verify the app URL scheme, and confirm the app is installed on the selected simulator.`),
1040
+ }
1041
+ : {}),
1042
+ });
1043
+ if (deepLink.waitMs && deepLink.waitMs > 0) {
1044
+ await wait(deepLink.waitMs);
1045
+ checks.push({
1046
+ name: 'ios_deep_link_waited',
1047
+ status: 'passed',
1048
+ source: 'runner',
1049
+ code: 'ios_deep_link_waited',
1050
+ message: `Waited ${deepLink.waitMs}ms after iOS deep link ${deepLink.label ?? index + 1}.`,
1051
+ });
1052
+ }
1053
+ }
1054
+ if (waitMs > 0) {
1055
+ await wait(waitMs);
1056
+ checks.push({
1057
+ name: 'ios_capture_window_waited',
1058
+ status: 'passed',
1059
+ source: 'runner',
1060
+ code: 'ios_capture_window_waited',
1061
+ message: `Waited ${waitMs}ms before capturing iOS simulator logs.`,
1062
+ });
1063
+ }
1064
+ if (launch && bundleId && !hasInstalledConflictingBundle) {
1065
+ const appLifecycleLog = await executor(xcrunPath, [
1066
+ 'simctl',
1067
+ 'spawn',
1068
+ simulator.udid,
1069
+ 'log',
1070
+ 'show',
1071
+ '--style',
1072
+ 'compact',
1073
+ '--last',
1074
+ logLast,
1075
+ '--predicate',
1076
+ buildIosAppLifecycleLogPredicate({
1077
+ bundleId,
1078
+ pid: launchedAppPid,
1079
+ }),
1080
+ ]);
1081
+ const appLifecycleRawFileName = 'ios-app-lifecycle-log.txt';
1082
+ const appLifecycleOutput = [appLifecycleLog.stdout, appLifecycleLog.stderr].filter(Boolean).join('\n');
1083
+ const appLifecycleCaptured = appLifecycleLog.exitCode === 0;
1084
+ const appLifecycleInstability = appLifecycleCaptured
1085
+ ? classifyIosAppLifecycleInstability(appLifecycleOutput)
1086
+ : null;
1087
+ raw[appLifecycleRawFileName] = appLifecycleOutput;
1088
+ const hostDiagnosticReport = appLifecycleInstability
1089
+ ? await inspectHostDiagnosticReport({ bundleId, diagnosticReportsDir })
1090
+ : null;
1091
+ const appLifecycleCrashed = appLifecycleInstability === 'crash' || Boolean(hostDiagnosticReport?.metadata?.matched);
1092
+ const appLifecycleAmbiguousExit = appLifecycleInstability === 'exit' && !hostDiagnosticReport?.metadata?.matched;
1093
+ if (hostDiagnosticReport) {
1094
+ Object.assign(raw, hostDiagnosticReport.raw);
1095
+ }
1096
+ checks.push({
1097
+ name: 'ios_app_lifecycle_stable',
1098
+ status: !appLifecycleCaptured ? 'warning' : appLifecycleCrashed ? 'failed' : appLifecycleAmbiguousExit ? 'warning' : 'passed',
1099
+ source: 'runner',
1100
+ code: !appLifecycleCaptured
1101
+ ? 'ios_app_lifecycle_log_unavailable'
1102
+ : appLifecycleCrashed
1103
+ ? 'ios_app_exited_during_capture'
1104
+ : appLifecycleAmbiguousExit
1105
+ ? 'ios_app_lifecycle_exit_unconfirmed'
1106
+ : 'ios_app_lifecycle_stable',
1107
+ message: !appLifecycleCaptured
1108
+ ? 'Could not inspect the launched iOS app lifecycle log.'
1109
+ : appLifecycleCrashed
1110
+ ? `App ${bundleId} exited during the simulator capture window.`
1111
+ : appLifecycleAmbiguousExit
1112
+ ? `Simulator logs mentioned an app exit for ${bundleId}, but no matching host crash report was found.`
1113
+ : `No native app exit was found for ${bundleId} during the simulator capture window.`,
1114
+ ...(!appLifecycleCaptured
1115
+ ? {
1116
+ metadata: nextActionHint('inspect_ios_app_lifecycle', `Inspect raw/${appLifecycleRawFileName}, confirm xcrun simctl log access works for the selected simulator, then rerun the capture.`),
1117
+ }
1118
+ : appLifecycleCrashed || appLifecycleAmbiguousExit
1119
+ ? {
1120
+ metadata: nextActionHint(appLifecycleCrashed ? 'inspect_ios_app_crash' : 'confirm_ios_app_lifecycle', appLifecycleCrashed && hostDiagnosticReport?.metadata?.matched
1121
+ ? `Inspect raw/${appLifecycleRawFileName} and ${hostDiagnosticReport.metadata.rawPath}; do not trust timing or profile evidence until the app remains foregrounded.`
1122
+ : `Inspect raw/${appLifecycleRawFileName}, raw/ios-host-diagnostic-report-search.txt, and simulator UI/process evidence before treating this as an app crash.`),
1123
+ }
1124
+ : {}),
1125
+ });
1126
+ metadata.appLifecycle = {
1127
+ args: appLifecycleLog.args,
1128
+ exitCode: appLifecycleLog.exitCode,
1129
+ pid: launchedAppPid,
1130
+ rawPath: `raw/${appLifecycleRawFileName}`,
1131
+ ...(hostDiagnosticReport ? { hostDiagnosticReport: hostDiagnosticReport.metadata } : {}),
1132
+ };
1133
+ if (appLifecycleInstability) {
1134
+ checks.push({
1135
+ name: 'ios_host_diagnostic_report_attached',
1136
+ status: hostDiagnosticReport?.metadata?.matched ? 'passed' : 'warning',
1137
+ source: 'runner',
1138
+ code: hostDiagnosticReport?.metadata?.matched
1139
+ ? 'ios_host_diagnostic_report_attached'
1140
+ : 'ios_host_diagnostic_report_missing',
1141
+ message: hostDiagnosticReport?.metadata?.matched
1142
+ ? 'Attached the latest matching host DiagnosticReports crash file.'
1143
+ : 'Could not find a recent matching host DiagnosticReports crash file.',
1144
+ ...(!hostDiagnosticReport?.metadata?.matched
1145
+ ? {
1146
+ metadata: nextActionHint('inspect_host_diagnostic_reports', 'Inspect raw/ios-host-diagnostic-report-search.txt and the host DiagnosticReports directory; Simulator may write the crash report after this capture window.'),
1147
+ }
1148
+ : {}),
1149
+ });
1150
+ }
1151
+ const appInfoResult = await driver.appInfo(bundleId);
1152
+ const appInfoOutput = formatIosSimctlRawOutput(appInfoResult);
1153
+ const applicationState = parseIosAppInfoApplicationState(appInfoOutput);
1154
+ const appInfoCaptured = appInfoResult.exitCode === 0;
1155
+ const targetForeground = appInfoCaptured && isIosAppInfoForegroundState(applicationState);
1156
+ raw[appInfoResult.rawFileName] = appInfoOutput;
1157
+ checks.push({
1158
+ name: 'ios_target_app_foreground',
1159
+ status: !appInfoCaptured || !applicationState ? 'warning' : targetForeground ? 'passed' : 'failed',
1160
+ source: 'runner',
1161
+ code: !appInfoCaptured
1162
+ ? 'ios_target_app_info_unavailable'
1163
+ : targetForeground
1164
+ ? 'ios_target_app_foreground'
1165
+ : applicationState
1166
+ ? 'ios_target_app_backgrounded'
1167
+ : 'ios_target_app_state_unknown',
1168
+ message: !appInfoCaptured
1169
+ ? `Could not inspect foreground state for ${bundleId}.`
1170
+ : targetForeground
1171
+ ? `Target app ${bundleId} remained foreground-owned after capture.`
1172
+ : applicationState
1173
+ ? `Target app ${bundleId} was ${applicationState} after capture.`
1174
+ : `Target app ${bundleId} foreground state was not reported by simctl appinfo.`,
1175
+ ...(!appInfoCaptured
1176
+ ? {
1177
+ metadata: nextActionHint('inspect_ios_app_info', `Inspect raw/${appInfoResult.rawFileName}; if simctl appinfo is unavailable on this Xcode, use a host runner that can prove target foreground ownership before trusting screenshot evidence.`),
1178
+ }
1179
+ : !targetForeground
1180
+ ? {
1181
+ metadata: nextActionHint(applicationState ? 'restore_ios_target_foreground' : 'confirm_ios_target_foreground', applicationState
1182
+ ? `The target app reported ${applicationState}. Inspect raw/${appInfoResult.rawFileName}, confirm the selected bundle and dev-client URL, and rerun on a simulator where ${bundleId} owns the foreground surface.`
1183
+ : `Inspect raw/${appInfoResult.rawFileName} and simulator UI evidence; this Xcode/simulator did not report ApplicationState.`),
1184
+ }
1185
+ : {}),
1186
+ });
1187
+ metadata.appInfo = {
1188
+ applicationState,
1189
+ args: appInfoResult.args,
1190
+ exitCode: appInfoResult.exitCode,
1191
+ rawPath: `raw/${appInfoResult.rawFileName}`,
1192
+ };
1193
+ }
1194
+ if (screenshot) {
1195
+ await fsp.mkdir(layout.captures, { recursive: true });
1196
+ const screenshotFileName = screenshotCaptureFileName(screenshotType);
1197
+ const screenshotPath = path.join(layout.captures, screenshotFileName);
1198
+ const screenshotResult = await driver.screenshot({
1199
+ outputPath: screenshotPath,
1200
+ ...(screenshotDisplay ? { display: screenshotDisplay } : {}),
1201
+ ...(screenshotMask ? { mask: screenshotMask } : {}),
1202
+ ...(screenshotType ? { imageType: screenshotType } : {}),
1203
+ });
1204
+ raw[screenshotResult.rawFileName] = formatIosSimctlRawOutput(screenshotResult);
1205
+ const screenshotCaptured = screenshotResult.exitCode === 0 && fs.existsSync(screenshotPath);
1206
+ if (screenshotCaptured) {
1207
+ captures.screenshot = `captures/${screenshotFileName}`;
1208
+ }
1209
+ checks.push({
1210
+ name: 'ios_screenshot_captured',
1211
+ status: screenshotCaptured ? 'passed' : 'failed',
1212
+ source: 'runner',
1213
+ code: screenshotCaptured ? 'ios_screenshot_captured' : 'ios_screenshot_failed',
1214
+ message: screenshotCaptured ? 'Captured iOS simulator screenshot.' : 'iOS simulator screenshot capture failed.',
1215
+ ...(!screenshotCaptured
1216
+ ? {
1217
+ metadata: nextActionHint('inspect_ios_screenshot', `Inspect raw/${screenshotResult.rawFileName}, confirm the simulator window is available, then rerun the screenshot capture.`),
1218
+ }
1219
+ : {}),
1220
+ });
1221
+ metadata.screenshot = {
1222
+ args: screenshotResult.args,
1223
+ capturePath: captures.screenshot,
1224
+ exitCode: screenshotResult.exitCode,
1225
+ options: {
1226
+ ...(screenshotDisplay ? { display: screenshotDisplay } : {}),
1227
+ ...(screenshotMask ? { mask: screenshotMask } : {}),
1228
+ ...(screenshotType ? { type: screenshotType } : {}),
1229
+ },
1230
+ rawPath: `raw/${screenshotResult.rawFileName}`,
1231
+ };
1232
+ }
1233
+ const log = await driver.readLogs({ last: logLast });
1234
+ const logsCaptured = log.exitCode === 0;
1235
+ raw[log.rawFileName] = formatIosSimctlRawOutput(log);
1236
+ checks.push({
1237
+ name: 'ios_logs_captured',
1238
+ status: logsCaptured ? 'passed' : 'failed',
1239
+ source: 'runner',
1240
+ code: logsCaptured ? 'ios_logs_captured' : 'ios_logs_failed',
1241
+ message: logsCaptured ? `Captured iOS simulator logs from the last ${logLast}.` : 'iOS simulator log capture failed.',
1242
+ ...(!logsCaptured
1243
+ ? {
1244
+ metadata: nextActionHint('inspect_ios_logs', `Inspect raw/${log.rawFileName}, confirm xcrun simctl log access works for the selected simulator, then rerun the capture.`),
1245
+ }
1246
+ : {}),
1247
+ });
1248
+ metadata.logs = {
1249
+ args: log.args,
1250
+ exitCode: log.exitCode,
1251
+ rawPath: `raw/${log.rawFileName}`,
1252
+ };
1253
+ if (collectProfileStorage) {
1254
+ if (!bundleId || !dataContainerPath) {
1255
+ checks.push({
1256
+ name: 'ios_profile_storage_collected',
1257
+ status: 'failed',
1258
+ source: 'runner',
1259
+ code: 'ios_profile_storage_missing_container',
1260
+ message: 'Profile storage collection needs both bundle id and app data container.',
1261
+ metadata: nextActionHint('fix_ios_profile_storage', 'Provide --bundle, install and launch the app once, then rerun profile storage collection.'),
1262
+ });
1263
+ }
1264
+ else {
1265
+ try {
1266
+ const storedEvents = readProfileStorageJson({
1267
+ bundleId,
1268
+ dataContainer: dataContainerPath,
1269
+ fallback: [],
1270
+ key: profileStorageKeys.event,
1271
+ });
1272
+ const storedEntries = readProfileStorageJson({
1273
+ bundleId,
1274
+ dataContainer: dataContainerPath,
1275
+ fallback: [],
1276
+ key: profileStorageKeys.sessionEntries,
1277
+ });
1278
+ const events = Array.isArray(storedEvents)
1279
+ ? storedEvents.filter((event) => Boolean(event) && typeof event === 'object' && !Array.isArray(event))
1280
+ : [];
1281
+ raw['ios-profile-events.json'] = JSON.stringify(events, null, 2);
1282
+ raw['ios-profile-events.log'] = formatStoredProfileEventLog(events);
1283
+ raw['ios-profile-session-entries.json'] = JSON.stringify(storedEntries, null, 2);
1284
+ checks.push({
1285
+ name: 'ios_profile_storage_collected',
1286
+ status: 'passed',
1287
+ source: 'runner',
1288
+ code: 'ios_profile_storage_collected',
1289
+ message: `Collected ${events.length} stored profile event${events.length === 1 ? '' : 's'} from app storage.`,
1290
+ });
1291
+ metadata.profileStorage = {
1292
+ eventCount: events.length,
1293
+ eventsRawPath: 'raw/ios-profile-events.json',
1294
+ logRawPath: 'raw/ios-profile-events.log',
1295
+ sessionEntriesRawPath: 'raw/ios-profile-session-entries.json',
1296
+ };
1297
+ }
1298
+ catch (error) {
1299
+ raw['ios-profile-storage-error.txt'] = error instanceof Error ? error.message : String(error);
1300
+ checks.push({
1301
+ name: 'ios_profile_storage_collected',
1302
+ status: 'failed',
1303
+ source: 'runner',
1304
+ code: 'ios_profile_storage_collect_failed',
1305
+ message: 'Failed to collect stored profile events from app storage.',
1306
+ metadata: nextActionHint('inspect_ios_profile_storage', 'Inspect raw/ios-profile-storage-error.txt and confirm the app writes profile events to the expected AsyncStorage keys.'),
1307
+ });
1308
+ }
1309
+ }
1310
+ }
1311
+ }
1312
+ else if (launch || terminateBeforeLaunch || profileSessionStorage || collectProfileStorage || deepLinks.length > 0) {
1313
+ checks.push({
1314
+ name: 'ios_capture_window_started',
1315
+ status: 'failed',
1316
+ source: 'runner',
1317
+ code: 'ios_capture_window_no_simulator',
1318
+ message: 'iOS capture window setup was requested, but no booted simulator was selected.',
1319
+ metadata: nextActionHint('boot_ios_simulator', 'Boot an iOS simulator or pass --device with a booted simulator UDID before requesting launch, storage, or deep-link capture.'),
1320
+ });
1321
+ }
1322
+ const health = buildIosSimctlHealth({ runId, checks });
1323
+ const verdict = buildIosSimctlVerdict({ runId, health });
1324
+ const agentSummary = buildAgentSummaryMarkdown({ health, verdict });
1325
+ await Promise.all(Object.entries(raw).map(([fileName, content]) => fsp.writeFile(path.join(rawDir, fileName), `${content.trimEnd()}\n`, 'utf8')));
1326
+ await fsp.writeFile(path.join(rawDir, 'ios-metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
1327
+ await writeJsonArtifact({
1328
+ filePath: layout.health,
1329
+ value: health,
1330
+ schema: SCHEMAS.health,
1331
+ label: 'Health artifact',
1332
+ });
1333
+ await writeJsonArtifact({
1334
+ filePath: layout.verdict,
1335
+ value: verdict,
1336
+ schema: SCHEMAS.verdict,
1337
+ label: 'Verdict artifact',
1338
+ });
1339
+ await writeTextArtifact({
1340
+ filePath: layout.agentSummary,
1341
+ content: agentSummary,
1342
+ });
1343
+ return {
1344
+ agentSummary,
1345
+ captures,
1346
+ health,
1347
+ metadata,
1348
+ raw,
1349
+ runDir,
1350
+ simulator,
1351
+ verdict,
1352
+ };
1353
+ }
1354
+ /**
1355
+ * Runs the ios-simctl capture CLI.
1356
+ *
1357
+ * @returns {Promise<void>}
1358
+ */
1359
+ async function main() {
1360
+ const argv = process.argv.slice(2);
1361
+ if (hasHelpFlag(argv)) {
1362
+ usage(process.stdout);
1363
+ return;
1364
+ }
1365
+ const args = parseArgs(argv);
1366
+ const runId = typeof args['run-id'] === 'string' ? args['run-id'] : createRunId();
1367
+ const profileSessionStorageEnabled = typeof args['profile-session-storage'] === 'string';
1368
+ const profileStorageKeys = resolveProfileStorageKeys({
1369
+ ...(typeof args['profile-command-storage-key'] === 'string' ? { command: args['profile-command-storage-key'] } : {}),
1370
+ ...(typeof args['profile-event-storage-key'] === 'string' ? { event: args['profile-event-storage-key'] } : {}),
1371
+ ...(typeof args['profile-session-storage-key'] === 'string' ? { session: args['profile-session-storage-key'] } : {}),
1372
+ ...(typeof args['profile-session-entries-storage-key'] === 'string' ? { sessionEntries: args['profile-session-entries-storage-key'] } : {}),
1373
+ ...(typeof args['profile-signal-storage-key'] === 'string' ? { signal: args['profile-signal-storage-key'] } : {}),
1374
+ });
1375
+ const result = await runIosSimctlCapture({
1376
+ ...(typeof args.bundle === 'string' ? { bundleId: args.bundle } : {}),
1377
+ collectProfileStorage: profileSessionStorageEnabled ||
1378
+ args['collect-profile-storage'] === true ||
1379
+ args['collect-profile-storage'] === 'true',
1380
+ ...(typeof args.device === 'string' ? { device: args.device } : {}),
1381
+ ...(typeof args['diagnostic-reports-dir'] === 'string'
1382
+ ? { diagnosticReportsDir: args['diagnostic-reports-dir'] }
1383
+ : {}),
1384
+ launch: args.launch === true || args.launch === 'true',
1385
+ ...(typeof args['log-last'] === 'string' ? { logLast: args['log-last'] } : {}),
1386
+ ...(typeof args.out === 'string' ? { outputDir: args.out } : {}),
1387
+ profileStorageKeys,
1388
+ ...(profileSessionStorageEnabled && typeof args['profile-session-storage'] === 'string'
1389
+ ? {
1390
+ profileSessionStorage: {
1391
+ runId,
1392
+ scenario: args['profile-session-storage'],
1393
+ },
1394
+ }
1395
+ : {}),
1396
+ runId,
1397
+ screenshot: args.screenshot === true || args.screenshot === 'true',
1398
+ ...(typeof args['screenshot-display'] === 'string' ? { screenshotDisplay: args['screenshot-display'] } : {}),
1399
+ ...(typeof args['screenshot-mask'] === 'string' ? { screenshotMask: args['screenshot-mask'] } : {}),
1400
+ ...(typeof args['screenshot-type'] === 'string' ? { screenshotType: args['screenshot-type'] } : {}),
1401
+ terminateBeforeLaunch: args['terminate-before-launch'] === true || args['terminate-before-launch'] === 'true',
1402
+ waitMs: parsePositiveInteger(args['wait-ms'], 0),
1403
+ ...(typeof args.xcrun === 'string' ? { xcrunPath: args.xcrun } : {}),
1404
+ });
1405
+ process.stdout.write(`${result.runDir}\n`);
1406
+ if (result.health.healthStatus !== 'passed') {
1407
+ process.exitCode = 1;
1408
+ }
1409
+ }
1410
+ if (require.main === module) {
1411
+ main().catch((error) => {
1412
+ console.error(error instanceof Error ? error.message : String(error));
1413
+ process.exitCode = 1;
1414
+ });
1415
+ }