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.
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/app/profile-session.ts +812 -0
- package/core/config-template.json +41 -0
- package/dist/core/agent-summary.d.ts +15 -0
- package/dist/core/agent-summary.js +177 -0
- package/dist/core/artifact-contract.d.ts +151 -0
- package/dist/core/artifact-contract.js +897 -0
- package/dist/core/artifact-layout.d.ts +56 -0
- package/dist/core/artifact-layout.js +61 -0
- package/dist/core/artifact-writer.d.ts +44 -0
- package/dist/core/artifact-writer.js +55 -0
- package/dist/core/comparison.d.ts +133 -0
- package/dist/core/comparison.js +294 -0
- package/dist/core/evidence-interpreter.d.ts +28 -0
- package/dist/core/evidence-interpreter.js +69 -0
- package/dist/core/execution-plan.d.ts +44 -0
- package/dist/core/execution-plan.js +95 -0
- package/dist/core/planner.d.ts +132 -0
- package/dist/core/planner.js +812 -0
- package/dist/core/ports.d.ts +198 -0
- package/dist/core/ports.js +146 -0
- package/dist/core/run-index.d.ts +62 -0
- package/dist/core/run-index.js +143 -0
- package/dist/core/schema-validator.d.ts +86 -0
- package/dist/core/schema-validator.js +407 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +27 -0
- package/dist/runner/agent-device-driver.d.ts +126 -0
- package/dist/runner/agent-device-driver.js +168 -0
- package/dist/runner/agent-device.d.ts +295 -0
- package/dist/runner/agent-device.js +1271 -0
- package/dist/runner/android-adb-driver.d.ts +175 -0
- package/dist/runner/android-adb-driver.js +399 -0
- package/dist/runner/android-adb.d.ts +254 -0
- package/dist/runner/android-adb.js +1618 -0
- package/dist/runner/argent-driver.d.ts +183 -0
- package/dist/runner/argent-driver.js +297 -0
- package/dist/runner/argent.d.ts +349 -0
- package/dist/runner/argent.js +1211 -0
- package/dist/runner/check-plan.d.ts +45 -0
- package/dist/runner/check-plan.js +210 -0
- package/dist/runner/cli.d.ts +20 -0
- package/dist/runner/cli.js +23 -0
- package/dist/runner/compare-latest.d.ts +99 -0
- package/dist/runner/compare-latest.js +233 -0
- package/dist/runner/compare.d.ts +58 -0
- package/dist/runner/compare.js +157 -0
- package/dist/runner/demo-loop.d.ts +45 -0
- package/dist/runner/demo-loop.js +170 -0
- package/dist/runner/example-android-live.d.ts +137 -0
- package/dist/runner/example-android-live.js +454 -0
- package/dist/runner/example-ios-live.d.ts +137 -0
- package/dist/runner/example-ios-live.js +471 -0
- package/dist/runner/host-doctor.d.ts +131 -0
- package/dist/runner/host-doctor.js +628 -0
- package/dist/runner/init-project.d.ts +88 -0
- package/dist/runner/init-project.js +263 -0
- package/dist/runner/ios-simctl-driver.d.ts +69 -0
- package/dist/runner/ios-simctl-driver.js +97 -0
- package/dist/runner/ios-simctl.d.ts +254 -0
- package/dist/runner/ios-simctl.js +1415 -0
- package/dist/runner/live-android.d.ts +137 -0
- package/dist/runner/live-android.js +539 -0
- package/dist/runner/live-comparison.d.ts +67 -0
- package/dist/runner/live-comparison.js +147 -0
- package/dist/runner/live-ios.d.ts +137 -0
- package/dist/runner/live-ios.js +460 -0
- package/dist/runner/live-proof-summary.d.ts +263 -0
- package/dist/runner/live-proof-summary.js +465 -0
- package/dist/runner/live-proof.d.ts +467 -0
- package/dist/runner/live-proof.js +920 -0
- package/dist/runner/local-env.d.ts +64 -0
- package/dist/runner/local-env.js +155 -0
- package/dist/runner/profile-android.d.ts +82 -0
- package/dist/runner/profile-android.js +671 -0
- package/dist/runner/profile-ios.d.ts +108 -0
- package/dist/runner/profile-ios.js +532 -0
- package/dist/runner/profile-mobile.d.ts +254 -0
- package/dist/runner/profile-mobile.js +1307 -0
- package/dist/runner/validate-project.d.ts +273 -0
- package/dist/runner/validate-project.js +1501 -0
- package/docs/adapters.md +145 -0
- package/docs/api.md +94 -0
- package/docs/authoring.md +196 -0
- package/docs/concepts.md +136 -0
- package/docs/consumer-rehearsal.md +115 -0
- package/docs/contracts.md +267 -0
- package/docs/live-proofs.md +270 -0
- package/docs/principles.md +46 -0
- package/examples/event-logs/app-startup-baseline.log +4 -0
- package/examples/event-logs/app-startup-current.log +4 -0
- package/examples/minimal-app/README.md +70 -0
- package/examples/mobile-app/README.md +302 -0
- package/examples/mobile-app/app.json +22 -0
- package/examples/mobile-app/asl/package-scripts.json +32 -0
- package/examples/mobile-app/asl.config.json +37 -0
- package/examples/mobile-app/event-logs/android-app-startup.log +4 -0
- package/examples/mobile-app/event-logs/android-open-close-cycle.log +12 -0
- package/examples/mobile-app/event-logs/android-scroll-settle.log +12 -0
- package/examples/mobile-app/event-logs/app-startup.log +4 -0
- package/examples/mobile-app/event-logs/open-close-cycle.log +12 -0
- package/examples/mobile-app/event-logs/scroll-settle.log +12 -0
- package/examples/mobile-app/index.ts +20 -0
- package/examples/mobile-app/metro.config.js +20 -0
- package/examples/mobile-app/package.json +62 -0
- package/examples/mobile-app/patches/expo-modules-jsi@56.0.10.patch +19 -0
- package/examples/mobile-app/plugins/with-ios-build-compat.js +271 -0
- package/examples/mobile-app/pnpm-lock.yaml +4440 -0
- package/examples/mobile-app/runner-manifests/evidence-provider.json +79 -0
- package/examples/mobile-app/runner-manifests/primary-runner.json +19 -0
- package/examples/mobile-app/scenarios/android/app-startup-video.json +73 -0
- package/examples/mobile-app/scenarios/android/app-startup.json +44 -0
- package/examples/mobile-app/scenarios/android/open-close-cycle.json +54 -0
- package/examples/mobile-app/scenarios/android/scroll-settle.json +49 -0
- package/examples/mobile-app/scenarios/ios/app-startup.json +44 -0
- package/examples/mobile-app/scenarios/ios/open-close-cycle.json +54 -0
- package/examples/mobile-app/scenarios/ios/scroll-settle.json +49 -0
- package/examples/mobile-app/scenarios/mobile/app-startup.json +91 -0
- package/examples/mobile-app/scenarios/mobile/open-close-cycle.json +160 -0
- package/examples/mobile-app/scenarios/mobile/scroll-settle.json +148 -0
- package/examples/mobile-app/scripts/asl-capture-accessibility-provider.mjs +112 -0
- package/examples/mobile-app/scripts/asl-capture-profiler-provider.mjs +127 -0
- package/examples/mobile-app/src/devtools/profile-session.ts +7 -0
- package/examples/mobile-app/src/example-screen.tsx +322 -0
- package/examples/mobile-app/tsconfig.json +16 -0
- package/examples/mobile-app/tsconfig.typecheck.json +13 -0
- package/examples/runners/README.md +44 -0
- package/examples/runners/adb-android.json +25 -0
- package/examples/runners/agent-device-android.json +27 -0
- package/examples/runners/agent-device-ios.json +27 -0
- package/examples/runners/argent-android.json +32 -0
- package/examples/runners/argent-ios.json +32 -0
- package/examples/runners/argent-react-profiler-provider.json +15 -0
- package/examples/runners/axe-accessibility-provider.json +24 -0
- package/examples/runners/manual-log-ingest.json +9 -0
- package/examples/runners/rozenite-profiler-provider.json +9 -0
- package/examples/runners/script-accessibility-provider.json +24 -0
- package/examples/runners/script-memory-provider.json +24 -0
- package/examples/runners/script-network-provider.json +24 -0
- package/examples/runners/script-profiler-provider.json +30 -0
- package/examples/runners/xcodebuildmcp-ios.json +29 -0
- package/examples/scenarios/ios/app-startup.json +28 -0
- package/examples/scenarios/ios/open-close-cycle.json +35 -0
- package/examples/scenarios/mobile/app-startup.json +72 -0
- package/examples/scenarios/mobile/media-open-close.json +141 -0
- package/examples/scenarios/mobile/open-close-cycle.json +135 -0
- package/examples/scenarios/mobile/scroll-settle.json +106 -0
- package/package.json +240 -0
- package/schemas/budget-verdict.schema.json +115 -0
- package/schemas/causal-run.schema.json +279 -0
- package/schemas/comparison.schema.json +196 -0
- package/schemas/health.schema.json +108 -0
- package/schemas/live-proof-set.schema.json +195 -0
- package/schemas/live-proof.schema.json +413 -0
- package/schemas/manifest.schema.json +204 -0
- package/schemas/metrics.schema.json +137 -0
- package/schemas/project-validation.schema.json +343 -0
- package/schemas/runner-capabilities.schema.json +217 -0
- package/schemas/scenario.schema.json +400 -0
- package/schemas/verdict.schema.json +88 -0
- package/templates/evidence-provider.json +83 -0
- package/templates/gitignore-snippet +9 -0
- package/templates/integration-readme.md +125 -0
- package/templates/mobile-scenario.json +133 -0
- package/templates/package-scripts.json +32 -0
- package/templates/primary-runner.json +19 -0
- package/templates/project.config.json +37 -0
- package/templates/scripts/asl-capture-accessibility-provider.mjs +112 -0
- 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
|
+
}
|