@astur-mobile/ios 0.1.0-beta.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/dist/index.js ADDED
@@ -0,0 +1,2039 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import { mkdtemp, readFile, readdir, rm, unlink } from 'node:fs/promises';
4
+ import { execFile } from 'node:child_process';
5
+ import { createServer as createHttpServer } from 'node:http';
6
+ import { createServer as createNetServer } from 'node:net';
7
+ import { networkInterfaces, tmpdir } from 'node:os';
8
+ import { dirname, extname, join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { AsturError, connectNativeAgentClient, delay } from '@astur-mobile/core';
11
+ import { run, runText, spawnCommand } from './command.js';
12
+ // Every bundled XCUITest agent is spawned `detached` so it leads its own
13
+ // process group; that lets us tear down xcodebuild *and* the test runner it
14
+ // spawns with a single group signal instead of leaking a session that keeps
15
+ // the simulator/device busy. The WeakSet drives group-vs-direct signalling in
16
+ // signalChildProcess; activeIosAgentPids backs the synchronous exit handler.
17
+ const detachedIosAgentProcesses = new WeakSet();
18
+ const activeIosAgentPids = new Set();
19
+ let iosAgentExitHandlerInstalled = false;
20
+ function trackIosAgentProcess(child) {
21
+ detachedIosAgentProcesses.add(child);
22
+ const pid = child.pid;
23
+ if (pid === undefined) {
24
+ return;
25
+ }
26
+ activeIosAgentPids.add(pid);
27
+ child.once('exit', () => activeIosAgentPids.delete(pid));
28
+ installIosAgentExitHandler();
29
+ }
30
+ // Last-resort cleanup: if the Node process exits for any reason that still runs
31
+ // 'exit' (normal return, process.exit, uncaught exception), synchronously
32
+ // SIGKILL every tracked agent process group so no orphaned xcodebuild session
33
+ // keeps holding the simulator. Catchable termination signals are wired to flow
34
+ // through the same path so the graceful close() runs first when possible.
35
+ function killTrackedIosAgentGroups() {
36
+ for (const pid of activeIosAgentPids) {
37
+ try {
38
+ // Negative pid targets the whole process group (xcodebuild plus the
39
+ // XCUITest runner it spawns), since agents are launched detached in their
40
+ // own group.
41
+ process.kill(-pid, 'SIGKILL');
42
+ }
43
+ catch {
44
+ try {
45
+ process.kill(pid, 'SIGKILL');
46
+ }
47
+ catch {
48
+ // Process already gone.
49
+ }
50
+ }
51
+ }
52
+ }
53
+ function installIosAgentExitHandler() {
54
+ if (iosAgentExitHandlerInstalled) {
55
+ return;
56
+ }
57
+ iosAgentExitHandlerInstalled = true;
58
+ // Normal exit / process.exit / uncaught exception.
59
+ process.once('exit', killTrackedIosAgentGroups);
60
+ // Forcefully closing the terminal sends SIGHUP to the foreground process
61
+ // group. Node does not fire 'exit' for it, and the detached agent group does
62
+ // not receive it (it lives in its own group), so without this the xcodebuild
63
+ // session is orphaned — holding the simulator and writing DerivedData — until
64
+ // the next run reaps it. This process (the CLI for codegen, or the Playwright
65
+ // worker for tests) is in the foreground group and does get SIGHUP, so kill
66
+ // the tracked agent groups synchronously, then terminate with the
67
+ // conventional SIGHUP exit code. The graceful close() still owns SIGINT /
68
+ // SIGTERM and normal completion; this is only the last-resort net.
69
+ process.once('SIGHUP', () => {
70
+ killTrackedIosAgentGroups();
71
+ process.exit(129);
72
+ });
73
+ }
74
+ // Kill leftover XCUITest agent sessions for this project + device before
75
+ // starting a new one. Guards against accumulation when a previous run was
76
+ // SIGKILLed or crashed hard (cases where no in-process cleanup could run).
77
+ async function reapStaleIosAgentSessions(projectPath, deviceId) {
78
+ // Escape hatch for setups that intentionally manage their own agent lifecycle
79
+ // (e.g. an externally launched, shared XCUITest session).
80
+ if (process.env.ASTUR_IOS_AGENT_REAP === '0') {
81
+ return;
82
+ }
83
+ // Match xcodebuild test sessions for this exact project + device. Regex
84
+ // metacharacters in the path (notably '.') stay permissive, which only widens
85
+ // the match slightly while remaining specific to this device id.
86
+ const pattern = `xcodebuild.*${projectPath}.*id=${deviceId}`;
87
+ let stdout;
88
+ try {
89
+ ({ stdout } = await new Promise((resolve) => {
90
+ execFile('pgrep', ['-f', pattern], { encoding: 'utf8' }, (_error, out) => resolve({ stdout: out ?? '' }));
91
+ }));
92
+ }
93
+ catch {
94
+ return;
95
+ }
96
+ const ownPid = process.pid;
97
+ for (const line of stdout.split('\n')) {
98
+ const pid = Number.parseInt(line.trim(), 10);
99
+ if (!Number.isInteger(pid) || pid <= 0 || pid === ownPid || activeIosAgentPids.has(pid)) {
100
+ continue;
101
+ }
102
+ try {
103
+ process.kill(pid, 'SIGTERM');
104
+ }
105
+ catch {
106
+ continue;
107
+ }
108
+ await delay(500);
109
+ try {
110
+ process.kill(pid, 'SIGKILL');
111
+ }
112
+ catch {
113
+ // Exited after SIGTERM.
114
+ }
115
+ }
116
+ }
117
+ export function createIosDriver(options = {}) {
118
+ return new IosDriver(options);
119
+ }
120
+ export class IosDriver {
121
+ platform = 'ios';
122
+ xcrunPath;
123
+ xcodebuildPath;
124
+ constructor(options = {}) {
125
+ this.xcrunPath = options.xcrunPath ?? process.env.ASTUR_XCRUN ?? 'xcrun';
126
+ this.xcodebuildPath = options.xcodebuildPath ?? process.env.ASTUR_XCODEBUILD ?? 'xcodebuild';
127
+ }
128
+ async doctor() {
129
+ const agentProjectPath = resolveDefaultIosAgentProject();
130
+ const [versionResult, simulatorsResult, realDevicesResult] = await Promise.allSettled([
131
+ runText(this.xcodebuildPath, ['-version']),
132
+ this.listSimulators(),
133
+ this.listConnectedDevices()
134
+ ]);
135
+ const checks = [];
136
+ if (versionResult.status === 'fulfilled') {
137
+ checks.push({
138
+ id: 'ios.xcodebuild',
139
+ label: 'Xcode',
140
+ status: 'pass',
141
+ message: firstLine(versionResult.value)
142
+ });
143
+ }
144
+ else {
145
+ checks.push({
146
+ id: 'ios.xcodebuild',
147
+ label: 'Xcode',
148
+ status: 'fail',
149
+ message: versionResult.reason instanceof Error ? versionResult.reason.message : String(versionResult.reason),
150
+ fix: 'Install Xcode and run xcode-select so xcodebuild is available.'
151
+ });
152
+ }
153
+ if (simulatorsResult.status === 'fulfilled') {
154
+ const devices = simulatorsResult.value;
155
+ checks.push({
156
+ id: 'ios.simulators',
157
+ label: 'iOS simulators',
158
+ status: devices.length ? 'pass' : 'warn',
159
+ message: devices.length ? `${devices.length} simulator(s) available.` : 'No iOS simulators were found.',
160
+ fix: devices.length ? undefined : 'Install an iOS simulator runtime from Xcode settings.'
161
+ });
162
+ }
163
+ else {
164
+ checks.push({
165
+ id: 'ios.simulators',
166
+ label: 'iOS simulators',
167
+ status: 'fail',
168
+ message: simulatorsResult.reason instanceof Error ? simulatorsResult.reason.message : String(simulatorsResult.reason)
169
+ });
170
+ }
171
+ if (realDevicesResult.status === 'fulfilled') {
172
+ const devices = realDevicesResult.value;
173
+ const hasRealDevice = devices.some((device) => device.kind === 'real' && device.state === 'online');
174
+ checks.push({
175
+ id: 'ios.real-devices',
176
+ label: 'iOS real devices',
177
+ status: hasRealDevice ? 'pass' : 'warn',
178
+ message: hasRealDevice ? `${devices.length} connected iOS device(s) available.` : 'No connected iOS real devices were found.',
179
+ fix: hasRealDevice
180
+ ? undefined
181
+ : 'Connect a trusted iPhone or iPad with Developer Mode enabled, then run xcrun devicectl list devices.'
182
+ });
183
+ if (hasRealDevice) {
184
+ const inferredTeam = process.env.ASTUR_IOS_DEVELOPMENT_TEAM
185
+ ?? inferIosDevelopmentTeam(agentProjectPath);
186
+ checks.push({
187
+ id: 'ios.real-device-signing',
188
+ label: 'iOS real-device signing',
189
+ status: inferredTeam ? 'pass' : 'warn',
190
+ message: inferredTeam
191
+ ? `Development team ${inferredTeam} is configured.`
192
+ : 'No development team was found for real-device XCUITest signing.',
193
+ fix: inferredTeam
194
+ ? undefined
195
+ : 'Set ASTUR_IOS_DEVELOPMENT_TEAM or select a development team in agents/ios-xctest-agent so Astur can sign the bundled XCUITest runner.'
196
+ });
197
+ }
198
+ }
199
+ else {
200
+ checks.push({
201
+ id: 'ios.real-devices',
202
+ label: 'iOS real devices',
203
+ status: 'warn',
204
+ message: realDevicesResult.reason instanceof Error ? realDevicesResult.reason.message : String(realDevicesResult.reason),
205
+ fix: 'Run xcrun devicectl list devices and confirm the device is trusted, unlocked, and in Developer Mode.'
206
+ });
207
+ }
208
+ const agentProjectAvailable = existsSync(join(agentProjectPath, 'project.pbxproj'));
209
+ checks.push({
210
+ id: 'ios.xctest-agent',
211
+ label: 'XCUITest agent',
212
+ status: agentProjectAvailable ? 'pass' : 'warn',
213
+ message: agentProjectAvailable
214
+ ? 'Bundled Swift XCUITest agent project is available.'
215
+ : 'Native iOS element automation requires the bundled Swift XCUITest agent project.',
216
+ fix: agentProjectAvailable
217
+ ? undefined
218
+ : 'Keep agents/ios-xctest-agent available when running from source or install the published @astur-mobile/ios package assets.'
219
+ });
220
+ return checks;
221
+ }
222
+ async listDevices() {
223
+ const [realDevices, simulators] = await Promise.all([
224
+ this.listConnectedDevices().catch(() => []),
225
+ this.listSimulators().catch(() => [])
226
+ ]);
227
+ return [...realDevices, ...simulators];
228
+ }
229
+ async listDevicesForSelector(selector) {
230
+ if (selector.kind === 'real') {
231
+ return this.listConnectedDevices();
232
+ }
233
+ if (selector.kind === 'simulator') {
234
+ return this.listSimulators();
235
+ }
236
+ return this.listDevices();
237
+ }
238
+ async listSimulators() {
239
+ const output = await runText(this.xcrunPath, ['simctl', 'list', 'devices', 'available', '--json']);
240
+ return parseSimctlDevices(output);
241
+ }
242
+ async listConnectedDevices() {
243
+ try {
244
+ return parseDevicectlDevices(await runDevicectlJson(this.xcrunPath, ['list', 'devices']));
245
+ }
246
+ catch {
247
+ const output = await runText(this.xcrunPath, ['xcdevice', 'list']);
248
+ return parseXcdeviceDevices(output);
249
+ }
250
+ }
251
+ async createSession(capabilities) {
252
+ if (capabilities.device.cloud) {
253
+ throw new AsturError('CLOUD_PROVIDER_NOT_IMPLEMENTED', 'BrowserStack execution is scaffolded by Astur init, but the cloud driver is not implemented in this alpha. Use a local iOS simulator or real device for now.', { cloud: capabilities.device.cloud });
254
+ }
255
+ const resolvedCapabilities = await resolveIosAppMetadata(capabilities);
256
+ const devices = await this.listDevicesForSelector(resolvedCapabilities.device);
257
+ const device = selectDevice(devices, resolvedCapabilities.device);
258
+ if (!device) {
259
+ throw new AsturError('DEVICE_NOT_FOUND', 'No matching iOS device was found.', {
260
+ selector: resolvedCapabilities.device,
261
+ devices
262
+ });
263
+ }
264
+ const readyDevice = await this.ensureDeviceReady(device, resolvedCapabilities.device);
265
+ const preAgentSession = new IosSession(this.xcrunPath, readyDevice, resolvedCapabilities);
266
+ await ensureIosAppInstalled(preAgentSession, resolvedCapabilities);
267
+ const nativeAgent = await this.resolveNativeAgent(resolvedCapabilities, readyDevice);
268
+ return new IosSession(this.xcrunPath, readyDevice, resolvedCapabilities, nativeAgent);
269
+ }
270
+ async ensureDeviceReady(device, selector) {
271
+ if (device.kind !== 'simulator') {
272
+ if (device.state === 'online') {
273
+ return device;
274
+ }
275
+ throw new AsturError('DEVICE_NOT_READY', 'Selected iOS real device is not online.', { device, selector });
276
+ }
277
+ if (device.state === 'booted') {
278
+ return device;
279
+ }
280
+ if (device.state !== 'shutdown') {
281
+ throw new AsturError('DEVICE_NOT_READY', 'Selected iOS simulator is not booted or bootable.', { device, selector });
282
+ }
283
+ if (selector.autoBoot === false) {
284
+ throw new AsturError('DEVICE_NOT_READY', 'Selected iOS simulator is shutdown and autoBoot is disabled.', {
285
+ device,
286
+ selector
287
+ });
288
+ }
289
+ await run(this.xcrunPath, ['simctl', 'boot', device.id]).catch((error) => {
290
+ if (!String(error).includes('Unable to boot device in current state: Booted')) {
291
+ throw new AsturError('IOS_SIMULATOR_BOOT_FAILED', 'Failed to boot selected iOS simulator.', {
292
+ device,
293
+ selector,
294
+ cause: error
295
+ });
296
+ }
297
+ });
298
+ await run(this.xcrunPath, ['simctl', 'bootstatus', device.id, '-b']);
299
+ return {
300
+ ...device,
301
+ state: 'booted'
302
+ };
303
+ }
304
+ async resolveNativeAgent(capabilities, device) {
305
+ if (capabilities.agent.mode === 'off') {
306
+ return undefined;
307
+ }
308
+ const endpoint = capabilities.agent.endpoint ?? process.env.ASTUR_IOS_AGENT_ENDPOINT;
309
+ if (!endpoint) {
310
+ const runtime = await this.tryBootstrapBundledNativeAgent(capabilities, device);
311
+ if (runtime) {
312
+ return runtime;
313
+ }
314
+ if (capabilities.agent.mode === 'required' || !allowsLegacyFallback(capabilities, 'failure')) {
315
+ throw new AsturError('IOS_XCTEST_AGENT_ENDPOINT_REQUIRED', 'iOS native-agent mode is required, but no endpoint or buildable XCUITest agent project was available. Set use.astur.agent.endpoint, ASTUR_IOS_AGENT_ENDPOINT, or keep agents/ios-xctest-agent available.');
316
+ }
317
+ return undefined;
318
+ }
319
+ try {
320
+ const client = await connectNativeAgentClient({
321
+ endpoint,
322
+ platform: 'ios',
323
+ handshakeTimeout: capabilities.agent.launchTimeout,
324
+ commandTimeout: capabilities.agent.commandTimeout
325
+ });
326
+ return {
327
+ client,
328
+ endpoint
329
+ };
330
+ }
331
+ catch (error) {
332
+ if (capabilities.agent.mode === 'required' || !allowsLegacyFallback(capabilities, 'failure')) {
333
+ throw new AsturError('IOS_XCTEST_AGENT_CONNECT_FAILED', `Failed to connect to iOS native agent at ${endpoint}.`, {
334
+ endpoint,
335
+ cause: error
336
+ });
337
+ }
338
+ return undefined;
339
+ }
340
+ }
341
+ async tryBootstrapBundledNativeAgent(capabilities, device) {
342
+ if (!capabilities.agent.install || !['simulator', 'real'].includes(device.kind)) {
343
+ return undefined;
344
+ }
345
+ const app = capabilities.app;
346
+ const bundleId = app?.bundleId ?? app?.packageName;
347
+ if (!bundleId) {
348
+ return undefined;
349
+ }
350
+ const config = resolveIosAgentRuntimeConfig();
351
+ if (!existsSync(config.projectPath)) {
352
+ return undefined;
353
+ }
354
+ // Clear any XCUITest agent session left behind by a previous run that was
355
+ // killed before it could clean up — otherwise it keeps the device busy and
356
+ // contends with the new session (e.g. a wedged simctl/screenshot path).
357
+ await reapStaleIosAgentSessions(config.projectPath, device.id);
358
+ const launchTimeout = Math.max(capabilities.agent.launchTimeout, defaultBundledIosAgentLaunchTimeoutMs);
359
+ const managedDerivedData = !config.derivedDataPath;
360
+ const derivedDataPath = config.derivedDataPath ?? join(iosAgentDerivedDataRoot(), `${pathSafeName(device.id)}-${iosAgentSourceStamp(config.projectPath)}`);
361
+ if (managedDerivedData) {
362
+ // Each agent source version builds into its own DerivedData directory keyed
363
+ // by source stamp. Drop the directories from previous versions for this
364
+ // device so they do not pile up — one full Xcode build each — and exhaust
365
+ // the disk over many runs (especially while the bundled agent is edited).
366
+ await pruneStaleIosAgentDerivedData(device.id, derivedDataPath);
367
+ }
368
+ const attempts = parsePositiveInteger(process.env.ASTUR_IOS_AGENT_START_ATTEMPTS)
369
+ ?? (device.kind === 'real' ? 1 : 2);
370
+ let lastFailure;
371
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
372
+ const bridgeHosts = resolveIosAgentBridgeHosts(device, config);
373
+ const hostPort = config.hostPort ?? await findFreePort(bridgeHosts.bindHost);
374
+ const bridge = await IosAgentBridge.start(hostPort, capabilities.agent.commandTimeout, bridgeHosts);
375
+ const endpoint = bridge.agentEndpoint;
376
+ let agentProcess;
377
+ const output = createBoundedOutputCapture();
378
+ try {
379
+ agentProcess = spawnCommand(this.xcodebuildPath, [
380
+ 'test',
381
+ ...xcodebuildRealDeviceSigningArgs(device, config),
382
+ '-project',
383
+ config.projectPath,
384
+ '-scheme',
385
+ config.scheme,
386
+ '-destination',
387
+ `id=${device.id}`,
388
+ '-derivedDataPath',
389
+ derivedDataPath,
390
+ '-only-testing:AsturIOSAgentUITests/AsturAgentUITests/testAgentServer',
391
+ `ASTUR_AUT_BUNDLE_ID=${bundleId}`,
392
+ 'ASTUR_AUT_LAUNCH=1',
393
+ `ASTUR_IOS_AGENT_BRIDGE_URL=${endpoint}`
394
+ ], {
395
+ stdio: ['ignore', 'pipe', 'pipe'],
396
+ // Own process group so teardown can group-signal xcodebuild together
397
+ // with the XCUITest runner it spawns, instead of leaking a session
398
+ // that keeps the simulator/device busy.
399
+ detached: true,
400
+ env: {
401
+ ...process.env,
402
+ ASTUR_AUT_BUNDLE_ID: bundleId,
403
+ ASTUR_AUT_LAUNCH: '1',
404
+ ASTUR_IOS_AGENT_BRIDGE_URL: endpoint
405
+ }
406
+ });
407
+ trackIosAgentProcess(agentProcess);
408
+ output.attach(agentProcess);
409
+ // When registration wins this race, abort the exit watcher so it
410
+ // detaches its exit/error listeners instead of leaking them on the
411
+ // long-lived agent process.
412
+ const exitWatch = new AbortController();
413
+ const exitGuard = waitForChildExit(agentProcess, undefined, exitWatch.signal).then(({ code, signal }) => {
414
+ // Registration won the race and aborted us — the resolve was the
415
+ // abort sentinel, not a real exit, so don't fail the attempt.
416
+ if (exitWatch.signal.aborted) {
417
+ return undefined;
418
+ }
419
+ throw new AsturError('IOS_XCTEST_AGENT_PROCESS_EXITED', `iOS XCUITest agent process exited before registration (${formatExitStatus(code, signal)}).`, {
420
+ device,
421
+ endpoint,
422
+ projectPath: config.projectPath,
423
+ scheme: config.scheme,
424
+ attempt,
425
+ attempts,
426
+ xcodebuildOutput: output.text()
427
+ });
428
+ });
429
+ let info;
430
+ try {
431
+ info = await Promise.race([bridge.waitForRegistration(launchTimeout), exitGuard]);
432
+ }
433
+ finally {
434
+ // Detaches the child exit/error listeners once we have a winner.
435
+ exitWatch.abort();
436
+ }
437
+ const client = bridge.createClient(info);
438
+ return {
439
+ client,
440
+ endpoint,
441
+ bridge,
442
+ hostPort,
443
+ process: agentProcess
444
+ };
445
+ }
446
+ catch (error) {
447
+ const xcodebuildOutput = output.text();
448
+ lastFailure = {
449
+ error,
450
+ endpoint,
451
+ hostPort,
452
+ xcodebuildOutput
453
+ };
454
+ await terminateChildProcess(agentProcess);
455
+ await bridge.close().catch(() => undefined);
456
+ const unrecoverableRealDeviceFailure = device.kind === 'real' && (isIosDevelopmentTeamMissing(xcodebuildOutput)
457
+ || isIosKeychainOrSigningPrompt(xcodebuildOutput)
458
+ || isIosAppSignatureNotTrusted(xcodebuildOutput));
459
+ if (attempt < attempts && !unrecoverableRealDeviceFailure) {
460
+ await delay(500);
461
+ continue;
462
+ }
463
+ break;
464
+ }
465
+ }
466
+ if (capabilities.agent.mode === 'required' || !allowsLegacyFallback(capabilities, 'failure')) {
467
+ if (device.kind === 'real' && isIosDevelopmentTeamMissing(lastFailure?.xcodebuildOutput)) {
468
+ throw new AsturError('IOS_DEVELOPMENT_TEAM_REQUIRED', 'Real iOS device execution requires signing the bundled Astur XCUITest runner. Set ASTUR_IOS_DEVELOPMENT_TEAM to your Apple development team id, then run the test again.', {
469
+ device,
470
+ endpoint: lastFailure?.endpoint,
471
+ hostPort: lastFailure?.hostPort,
472
+ projectPath: config.projectPath,
473
+ scheme: config.scheme,
474
+ launchTimeout,
475
+ attempts,
476
+ xcodebuildOutput: lastFailure?.xcodebuildOutput,
477
+ cause: lastFailure?.error
478
+ });
479
+ }
480
+ if (device.kind === 'real' && isIosKeychainOrSigningPrompt(lastFailure?.xcodebuildOutput)) {
481
+ throw new AsturError('IOS_SIGNING_KEYCHAIN_LOCKED', 'Real iOS device execution needs access to the macOS signing keychain. Unlock your login keychain or allow codesign access for the Apple Development certificate, then run Astur again.', {
482
+ device,
483
+ endpoint: lastFailure?.endpoint,
484
+ hostPort: lastFailure?.hostPort,
485
+ projectPath: config.projectPath,
486
+ scheme: config.scheme,
487
+ launchTimeout,
488
+ attempts,
489
+ xcodebuildOutput: lastFailure?.xcodebuildOutput,
490
+ cause: lastFailure?.error
491
+ });
492
+ }
493
+ if (device.kind === 'real' && isIosAppSignatureNotTrusted(lastFailure?.xcodebuildOutput)) {
494
+ throw new AsturError('IOS_APP_SIGNATURE_NOT_TRUSTED', `iOS refused to launch ${bundleId} because the app signature, entitlements, provisioning profile, or developer trust state is invalid on this device.`, {
495
+ bundleId,
496
+ device,
497
+ endpoint: lastFailure?.endpoint,
498
+ hostPort: lastFailure?.hostPort,
499
+ projectPath: config.projectPath,
500
+ scheme: config.scheme,
501
+ launchTimeout,
502
+ attempts,
503
+ xcodebuildOutput: lastFailure?.xcodebuildOutput,
504
+ cause: lastFailure?.error
505
+ });
506
+ }
507
+ throw new AsturError('IOS_XCTEST_AGENT_START_FAILED', 'Failed to build or start the iOS XCUITest native agent.', {
508
+ device,
509
+ endpoint: lastFailure?.endpoint,
510
+ hostPort: lastFailure?.hostPort,
511
+ projectPath: config.projectPath,
512
+ scheme: config.scheme,
513
+ launchTimeout,
514
+ attempts,
515
+ xcodebuildOutput: lastFailure?.xcodebuildOutput,
516
+ cause: lastFailure?.error
517
+ });
518
+ }
519
+ return undefined;
520
+ }
521
+ }
522
+ class IosSession {
523
+ deviceInfo;
524
+ capabilities;
525
+ xcrunPath;
526
+ nativeAgent;
527
+ nativeAgentBridge;
528
+ nativeAgentProcess;
529
+ unsupportedAgentMethods = new Set();
530
+ recording;
531
+ constructor(xcrunPath, deviceInfo, capabilities, nativeAgent) {
532
+ this.xcrunPath = xcrunPath;
533
+ this.deviceInfo = deviceInfo;
534
+ this.capabilities = capabilities;
535
+ this.nativeAgent = nativeAgent?.client;
536
+ this.nativeAgentBridge = nativeAgent?.bridge;
537
+ this.nativeAgentProcess = nativeAgent?.process;
538
+ }
539
+ async close() {
540
+ if (this.recording) {
541
+ await this.stopRecording().catch(() => undefined);
542
+ }
543
+ if (this.nativeAgentProcess) {
544
+ // Group-signal (SIGINT, then SIGKILL) so the xcodebuild session and the
545
+ // XCUITest runner it spawned are both torn down — a bare kill on the
546
+ // xcodebuild pid leaves the runner holding the simulator.
547
+ await terminateChildProcess(this.nativeAgentProcess);
548
+ this.nativeAgentProcess = undefined;
549
+ }
550
+ if (this.nativeAgentBridge) {
551
+ await this.nativeAgentBridge.close().catch(() => undefined);
552
+ this.nativeAgentBridge = undefined;
553
+ }
554
+ return;
555
+ }
556
+ async installApp(path) {
557
+ const materialized = await materializeIosInstallPath(path);
558
+ try {
559
+ if (this.isRealDevice()) {
560
+ await this.devicectl(['device', 'install', 'app', '--device', this.deviceInfo.id, materialized.path]);
561
+ }
562
+ else {
563
+ await this.simctl(['install', this.deviceInfo.id, materialized.path]);
564
+ }
565
+ }
566
+ catch (error) {
567
+ if (this.isRealDevice() && isIosAppInstallSignatureInvalid(error)) {
568
+ throw new AsturError('IOS_APP_INSTALL_SIGNATURE_INVALID', `iOS refused to install ${path} because the app signature, embedded provisioning profile, or developer trust state is invalid on this device.`, {
569
+ appPath: path,
570
+ installPath: materialized.path,
571
+ device: this.deviceInfo,
572
+ commandOutput: commandErrorOutput(error),
573
+ cause: error
574
+ });
575
+ }
576
+ throw error;
577
+ }
578
+ finally {
579
+ await materialized.cleanup?.().catch(() => undefined);
580
+ }
581
+ }
582
+ async isAppInstalled(identifier) {
583
+ if (this.isRealDevice()) {
584
+ const app = await this.realDeviceAppInfo(identifier).catch(() => undefined);
585
+ return Boolean(app);
586
+ }
587
+ try {
588
+ await this.simctl(['get_app_container', this.deviceInfo.id, identifier, 'app']);
589
+ return true;
590
+ }
591
+ catch {
592
+ return false;
593
+ }
594
+ }
595
+ async uninstallApp(identifier) {
596
+ if (this.isRealDevice()) {
597
+ await this.devicectl(['device', 'uninstall', 'app', '--device', this.deviceInfo.id, identifier]);
598
+ return;
599
+ }
600
+ await this.simctl(['uninstall', this.deviceInfo.id, identifier]);
601
+ }
602
+ async launchApp(options = {}) {
603
+ if (options.url) {
604
+ await this.openWeb(options.url);
605
+ return;
606
+ }
607
+ const app = options.app ?? this.capabilities.app;
608
+ const bundleId = app?.bundleId ?? app?.packageName;
609
+ if (!bundleId) {
610
+ throw new AsturError('IOS_BUNDLE_ID_REQUIRED', 'iOS launch requires app.bundleId.');
611
+ }
612
+ if (this.canUseNativeAppLifecycle(bundleId, 'app.launch')) {
613
+ const command = await this.tryNativeCommand('app.launch');
614
+ if (command.ok) {
615
+ return;
616
+ }
617
+ }
618
+ if (this.isRealDevice()) {
619
+ await this.devicectl([
620
+ 'device',
621
+ 'process',
622
+ 'launch',
623
+ '--device',
624
+ this.deviceInfo.id,
625
+ '--terminate-existing',
626
+ bundleId
627
+ ]);
628
+ return;
629
+ }
630
+ await this.simctl(['launch', this.deviceInfo.id, bundleId]);
631
+ }
632
+ async terminateApp() {
633
+ const bundleId = this.resolveBundleId();
634
+ if (this.canUseNativeAppLifecycle(bundleId, 'app.terminate')) {
635
+ const command = await this.tryNativeCommand('app.terminate');
636
+ if (command.ok) {
637
+ return;
638
+ }
639
+ }
640
+ if (this.isRealDevice()) {
641
+ await this.terminateRealApp(bundleId);
642
+ return;
643
+ }
644
+ await this.simctl(['terminate', this.deviceInfo.id, bundleId]);
645
+ }
646
+ async clearAppData(_identifier) {
647
+ throw new AsturError('IOS_APP_DATA_CLEAR_NOT_SUPPORTED', 'iOS does not expose direct per-app data clearing through public local tooling. Use device.app.reset({ reinstall: true }) with an app path.');
648
+ }
649
+ async clearAppCache(_identifier) {
650
+ throw new AsturError('IOS_APP_CACHE_CLEAR_NOT_SUPPORTED', 'iOS does not expose direct per-app cache clearing through public local tooling. Use device.app.reset({ reinstall: true }) when a clean app container is required.');
651
+ }
652
+ async grantPermission(identifier, permission) {
653
+ if (this.isRealDevice()) {
654
+ throw new AsturError('IOS_REAL_DEVICE_PERMISSION_CONTROL_NOT_SUPPORTED', 'iOS real devices do not expose reliable per-app permission mutation through Astur yet. Use app-controlled permission prompts or device settings.');
655
+ }
656
+ await this.simctl(['privacy', this.deviceInfo.id, 'grant', normalizeIosPermission(permission), identifier]);
657
+ }
658
+ async revokePermission(identifier, permission) {
659
+ if (this.isRealDevice()) {
660
+ throw new AsturError('IOS_REAL_DEVICE_PERMISSION_CONTROL_NOT_SUPPORTED', 'iOS real devices do not expose reliable per-app permission mutation through Astur yet. Use app-controlled permission prompts or device settings.');
661
+ }
662
+ await this.simctl(['privacy', this.deviceInfo.id, 'revoke', normalizeIosPermission(permission), identifier]);
663
+ }
664
+ async resetApp(options = {}) {
665
+ if (options.reinstall === false) {
666
+ throw new AsturError('IOS_RESET_REQUIRES_REINSTALL', 'iOS reset requires uninstalling and reinstalling the app because simctl has no direct app-data clear command.');
667
+ }
668
+ const app = options.app ?? {
669
+ ...this.capabilities.app,
670
+ path: options.path ?? this.capabilities.app?.path,
671
+ bundleId: options.bundleId ?? this.capabilities.app?.bundleId,
672
+ packageName: options.packageName ?? this.capabilities.app?.packageName
673
+ };
674
+ const bundleId = options.bundleId ?? options.packageName ?? app.bundleId ?? app.packageName;
675
+ const path = options.path ?? app.path;
676
+ if (!bundleId) {
677
+ throw new AsturError('IOS_BUNDLE_ID_REQUIRED', 'iOS reset requires app.bundleId.');
678
+ }
679
+ if (!path) {
680
+ throw new AsturError('APP_PATH_REQUIRED', 'iOS reset requires an app path so Astur can reinstall after uninstall.');
681
+ }
682
+ await this.terminateApp().catch(() => undefined);
683
+ await this.uninstallApp(bundleId).catch(() => undefined);
684
+ await this.installApp(path);
685
+ if (options.launch) {
686
+ await this.launchApp({ app });
687
+ }
688
+ }
689
+ async setOrientation(orientation) {
690
+ const command = await this.tryNativeCommand('device.setOrientation', { orientation });
691
+ if (command.ok) {
692
+ return;
693
+ }
694
+ throw xctestRequired('setting iOS orientation');
695
+ }
696
+ async lockDevice() {
697
+ if (this.isRealDevice()) {
698
+ throw new AsturError('IOS_REAL_DEVICE_LOCK_NOT_SUPPORTED', 'iOS real devices do not expose lock control through devicectl. Lock the device manually when testing lock behavior.');
699
+ }
700
+ await this.simctl(['io', this.deviceInfo.id, 'screenConfig', 'power', 'off']);
701
+ }
702
+ async unlockDevice() {
703
+ if (this.isRealDevice()) {
704
+ throw new AsturError('IOS_REAL_DEVICE_UNLOCK_NOT_SUPPORTED', 'iOS real devices do not expose unlock control through devicectl. Unlock the device manually before starting the run.');
705
+ }
706
+ await this.simctl(['io', this.deviceInfo.id, 'screenConfig', 'power', 'on']);
707
+ }
708
+ async isDeviceLocked() {
709
+ if (this.isRealDevice()) {
710
+ const result = await this.devicectlJson([
711
+ 'device',
712
+ 'info',
713
+ 'lockState',
714
+ '--device',
715
+ this.deviceInfo.id
716
+ ]);
717
+ return result.result?.locked ?? result.result?.lockState?.toLowerCase() === 'locked';
718
+ }
719
+ throw new AsturError('IOS_LOCK_STATE_NOT_SUPPORTED', 'iOS Simulator does not expose a stable lock-state query through simctl.');
720
+ }
721
+ async getTree() {
722
+ const command = await this.tryNativeCommand('tree.get');
723
+ if (command.ok) {
724
+ return command.result;
725
+ }
726
+ throw xctestRequired('reading the iOS UI tree');
727
+ }
728
+ async getViewport() {
729
+ const command = await this.tryNativeCommand('device.viewport');
730
+ if (command.ok) {
731
+ return command.result;
732
+ }
733
+ throw xctestRequired('reading the iOS viewport');
734
+ }
735
+ async findElement(selector) {
736
+ const command = await this.tryNativeCommand('element.find', { selector });
737
+ if (command.ok) {
738
+ return command.result;
739
+ }
740
+ throw xctestRequired('finding iOS native UI elements');
741
+ }
742
+ async findElements(selector) {
743
+ const command = await this.tryNativeCommand('element.findAll', { selector });
744
+ if (command.ok) {
745
+ return command.result;
746
+ }
747
+ throw xctestRequired('finding iOS native UI elements');
748
+ }
749
+ async findManyElements(selectors) {
750
+ const command = await this.tryNativeCommand('element.findMany', { selectors });
751
+ if (command.ok) {
752
+ return command.result;
753
+ }
754
+ throw xctestRequired('finding iOS native UI elements');
755
+ }
756
+ withDefaultElementWait(options) {
757
+ if (options.timeout !== undefined) {
758
+ return options;
759
+ }
760
+ return {
761
+ ...options,
762
+ timeout: this.capabilities.timeout
763
+ };
764
+ }
765
+ async waitForElement(selector, options = {}) {
766
+ const command = await this.tryNativeCommand('element.wait', {
767
+ selector,
768
+ options: this.withDefaultElementWait(options)
769
+ });
770
+ if (command.ok && command.result) {
771
+ return command.result;
772
+ }
773
+ throw xctestRequired('waiting for iOS native UI elements');
774
+ }
775
+ async waitForElementHidden(selector, options = {}) {
776
+ const command = await this.tryNativeCommand('element.wait', {
777
+ selector,
778
+ options: this.withDefaultElementWait({
779
+ ...options,
780
+ state: 'hidden'
781
+ })
782
+ });
783
+ if (command.ok) {
784
+ return;
785
+ }
786
+ throw xctestRequired('waiting for iOS native UI elements to be hidden');
787
+ }
788
+ async tapElement(selector, options = {}) {
789
+ const command = await this.tryNativeCommand('element.tap', {
790
+ selector,
791
+ options: this.withDefaultElementWait(options)
792
+ });
793
+ if (command.ok) {
794
+ return;
795
+ }
796
+ throw xctestRequired('tapping iOS native UI elements');
797
+ }
798
+ async doubleTapElement(selector, options = {}) {
799
+ const command = await this.tryNativeCommand('element.doubleTap', {
800
+ selector,
801
+ options: this.withDefaultElementWait(options)
802
+ });
803
+ if (command.ok) {
804
+ return;
805
+ }
806
+ throw xctestRequired('double-tapping iOS native UI elements');
807
+ }
808
+ async longPressElement(selector, options = {}) {
809
+ const command = await this.tryNativeCommand('element.longPress', {
810
+ selector,
811
+ options: this.withDefaultElementWait(options)
812
+ });
813
+ if (command.ok) {
814
+ return;
815
+ }
816
+ throw xctestRequired('long-pressing iOS native UI elements');
817
+ }
818
+ async fillElement(selector, value, options = {}) {
819
+ const command = await this.tryNativeCommand('element.fill', {
820
+ selector,
821
+ value,
822
+ options: this.withDefaultElementWait(options)
823
+ });
824
+ if (command.ok) {
825
+ return;
826
+ }
827
+ throw xctestRequired('filling iOS native UI elements');
828
+ }
829
+ async dragElement(selector, target, options = {}) {
830
+ const command = await this.tryNativeCommand('element.drag', {
831
+ selector,
832
+ target,
833
+ options: this.withDefaultElementWait(options)
834
+ });
835
+ if (command.ok) {
836
+ return;
837
+ }
838
+ throw xctestRequired('dragging iOS native UI elements');
839
+ }
840
+ async tap(target) {
841
+ const command = await this.tryNativeCommand('gesture.tap', { target });
842
+ if (command.ok) {
843
+ return;
844
+ }
845
+ throw xctestRequired('tapping iOS native UI');
846
+ }
847
+ async doubleTap(target, options = {}) {
848
+ const command = await this.tryNativeCommand('gesture.doubleTap', { target, options });
849
+ if (command.ok) {
850
+ return;
851
+ }
852
+ throw xctestRequired('double-tapping iOS native UI');
853
+ }
854
+ async longPress(target, options = {}) {
855
+ const command = await this.tryNativeCommand('gesture.longPress', { target, options });
856
+ if (command.ok) {
857
+ return;
858
+ }
859
+ throw xctestRequired('long-pressing iOS native UI');
860
+ }
861
+ async fill(selector, value) {
862
+ await this.fillElement(selector, value);
863
+ }
864
+ async pressKey(key) {
865
+ if (['back', 'escape'].includes(key.trim().toLowerCase())) {
866
+ await this.dismissKeyboard();
867
+ return;
868
+ }
869
+ throw xctestRequired('pressing iOS keys');
870
+ }
871
+ async getKeyboardState() {
872
+ const command = await this.tryNativeCommand('keyboard.state');
873
+ if (command.ok) {
874
+ return command.result;
875
+ }
876
+ return { visible: false };
877
+ }
878
+ async dismissKeyboard() {
879
+ const command = await this.tryNativeCommand('keyboard.dismiss');
880
+ if (command.ok) {
881
+ return;
882
+ }
883
+ throw xctestRequired('dismissing the iOS keyboard');
884
+ }
885
+ async swipe(gesture) {
886
+ const command = await this.tryNativeCommand('gesture.swipe', { gesture });
887
+ if (command.ok) {
888
+ return;
889
+ }
890
+ throw xctestRequired('swiping iOS native UI');
891
+ }
892
+ async drag(gesture) {
893
+ const command = await this.tryNativeCommand('gesture.drag', { gesture });
894
+ if (command.ok) {
895
+ return;
896
+ }
897
+ throw xctestRequired('dragging iOS native UI');
898
+ }
899
+ async screenshot() {
900
+ if (this.isRealDevice()) {
901
+ const command = await this.tryNativeCommand('device.screenshot');
902
+ if (command.ok) {
903
+ return Buffer.from(command.result.base64, 'base64');
904
+ }
905
+ throw xctestRequired('capturing a real iOS device screenshot');
906
+ }
907
+ // Prefer native agent screenshot for simulators when available: simctl io screenshot
908
+ // can hang indefinitely when xcodebuild test is concurrently managing the simulator process.
909
+ if (this.nativeAgent?.info.capabilities.includes('device.screenshot')) {
910
+ const command = await this.tryNativeCommand('device.screenshot');
911
+ if (command.ok) {
912
+ return Buffer.from(command.result.base64, 'base64');
913
+ }
914
+ }
915
+ const path = join(tmpdir(), `astur-${process.pid}-${Date.now()}.png`);
916
+ await this.simctl(['io', this.deviceInfo.id, 'screenshot', path]);
917
+ try {
918
+ return await readFile(path);
919
+ }
920
+ finally {
921
+ await unlink(path).catch(() => undefined);
922
+ }
923
+ }
924
+ async startRecording() {
925
+ if (this.isRealDevice()) {
926
+ throw new AsturError('IOS_REAL_DEVICE_RECORDING_NOT_SUPPORTED', 'iOS real-device screen recording is not supported yet. Use screenshots or simulator video for now.');
927
+ }
928
+ if (this.recording) {
929
+ throw new AsturError('RECORDING_ALREADY_STARTED', 'iOS simulator recording is already running for this session.');
930
+ }
931
+ const path = join(tmpdir(), `astur-${process.pid}-${Date.now()}.mp4`);
932
+ const child = spawnCommand(this.xcrunPath, ['simctl', 'io', this.deviceInfo.id, 'recordVideo', path]);
933
+ this.recording = { child, path };
934
+ }
935
+ async stopRecording(options = {}) {
936
+ const recording = this.recording;
937
+ if (!recording) {
938
+ return undefined;
939
+ }
940
+ this.recording = undefined;
941
+ recording.child.kill('SIGINT');
942
+ await waitForProcessExit(recording.child, 5_000);
943
+ if (options.discard) {
944
+ await unlink(recording.path).catch(() => undefined);
945
+ return undefined;
946
+ }
947
+ try {
948
+ return await readFile(recording.path);
949
+ }
950
+ finally {
951
+ await unlink(recording.path).catch(() => undefined);
952
+ }
953
+ }
954
+ async openWeb(url) {
955
+ if (this.isRealDevice()) {
956
+ await this.devicectl([
957
+ 'device',
958
+ 'process',
959
+ 'launch',
960
+ '--device',
961
+ this.deviceInfo.id,
962
+ '--payload-url',
963
+ url,
964
+ 'com.apple.mobilesafari'
965
+ ]);
966
+ return;
967
+ }
968
+ await this.simctl(['openurl', this.deviceInfo.id, url]);
969
+ }
970
+ canUseNativeAppLifecycle(bundleId, method) {
971
+ const configuredBundleId = this.capabilities.app?.bundleId ?? this.capabilities.app?.packageName;
972
+ return Boolean(this.nativeAgent
973
+ && configuredBundleId === bundleId
974
+ && this.nativeAgent.info.capabilities.includes(method));
975
+ }
976
+ async tryNativeCommand(method, params) {
977
+ if (!this.nativeAgent) {
978
+ if (this.capabilities.agent.mode === 'off') {
979
+ return { ok: false };
980
+ }
981
+ if (this.capabilities.agent.mode === 'required' || !allowsLegacyFallback(this.capabilities, 'failure')) {
982
+ throw new AsturError('IOS_XCTEST_AGENT_UNAVAILABLE', `iOS native agent is unavailable while running ${method}.`, {
983
+ method
984
+ });
985
+ }
986
+ return { ok: false };
987
+ }
988
+ if (this.unsupportedAgentMethods.has(method)) {
989
+ if (this.capabilities.agent.mode === 'required' || !allowsLegacyFallback(this.capabilities, 'unsupported')) {
990
+ throw new AsturError('IOS_XCTEST_AGENT_COMMAND_UNSUPPORTED', `iOS XCTest agent does not advertise support for ${method}.`, {
991
+ endpoint: this.nativeAgent.endpoint,
992
+ method,
993
+ capabilities: this.nativeAgent.info.capabilities
994
+ });
995
+ }
996
+ return { ok: false };
997
+ }
998
+ if (!this.nativeAgent.info.capabilities.includes(method)) {
999
+ this.unsupportedAgentMethods.add(method);
1000
+ if (this.capabilities.agent.mode === 'required' || !allowsLegacyFallback(this.capabilities, 'unsupported')) {
1001
+ throw new AsturError('IOS_XCTEST_AGENT_COMMAND_UNSUPPORTED', `iOS XCTest agent does not advertise support for ${method}.`, {
1002
+ endpoint: this.nativeAgent.endpoint,
1003
+ method,
1004
+ capabilities: this.nativeAgent.info.capabilities
1005
+ });
1006
+ }
1007
+ return { ok: false };
1008
+ }
1009
+ try {
1010
+ const result = await this.nativeAgent.command(method, params);
1011
+ return {
1012
+ ok: true,
1013
+ result
1014
+ };
1015
+ }
1016
+ catch (error) {
1017
+ if (isAgentCommandUnsupported(error)) {
1018
+ this.unsupportedAgentMethods.add(method);
1019
+ if (allowsLegacyFallback(this.capabilities, 'unsupported')) {
1020
+ return { ok: false };
1021
+ }
1022
+ throw new AsturError('IOS_XCTEST_AGENT_COMMAND_UNSUPPORTED', `iOS XCTest agent does not support ${method}.`, {
1023
+ endpoint: this.nativeAgent.endpoint,
1024
+ method,
1025
+ cause: error
1026
+ });
1027
+ }
1028
+ if (isAgentCommandFailure(error)) {
1029
+ if (allowsLegacyFallback(this.capabilities, 'failure')) {
1030
+ return { ok: false };
1031
+ }
1032
+ throw new AsturError('IOS_XCTEST_AGENT_COMMAND_FAILED', `iOS native agent command ${method} failed.`, {
1033
+ endpoint: this.nativeAgent.endpoint,
1034
+ method,
1035
+ cause: error
1036
+ });
1037
+ }
1038
+ if (isAgentCommandTimeout(error)) {
1039
+ if (allowsLegacyFallback(this.capabilities, 'failure')) {
1040
+ return { ok: false };
1041
+ }
1042
+ throw new AsturError('IOS_XCTEST_AGENT_COMMAND_TIMEOUT', `iOS native agent command ${method} timed out.`, {
1043
+ endpoint: this.nativeAgent.endpoint,
1044
+ method,
1045
+ cause: error
1046
+ });
1047
+ }
1048
+ this.nativeAgent = undefined;
1049
+ if (!allowsLegacyFallback(this.capabilities, 'failure')) {
1050
+ throw new AsturError('IOS_XCTEST_AGENT_COMMAND_FAILED', `iOS native agent command ${method} failed.`, {
1051
+ method,
1052
+ cause: error
1053
+ });
1054
+ }
1055
+ return { ok: false };
1056
+ }
1057
+ }
1058
+ simctl(args) {
1059
+ return run(this.xcrunPath, ['simctl', ...args]);
1060
+ }
1061
+ devicectl(args) {
1062
+ return run(this.xcrunPath, ['devicectl', ...args]);
1063
+ }
1064
+ devicectlJson(args) {
1065
+ return runDevicectlJson(this.xcrunPath, args);
1066
+ }
1067
+ isRealDevice() {
1068
+ return this.deviceInfo.kind === 'real';
1069
+ }
1070
+ async realDeviceAppInfo(bundleId) {
1071
+ const result = await this.devicectlJson([
1072
+ 'device',
1073
+ 'info',
1074
+ 'apps',
1075
+ '--device',
1076
+ this.deviceInfo.id,
1077
+ '--bundle-id',
1078
+ bundleId
1079
+ ]);
1080
+ return result.result?.apps?.find((app) => app.bundleIdentifier === bundleId);
1081
+ }
1082
+ async terminateRealApp(bundleId) {
1083
+ const app = await this.realDeviceAppInfo(bundleId).catch(() => undefined);
1084
+ const appUrl = app?.url;
1085
+ const processes = await this.devicectlJson([
1086
+ 'device',
1087
+ 'info',
1088
+ 'processes',
1089
+ '--device',
1090
+ this.deviceInfo.id
1091
+ ]).catch(() => undefined);
1092
+ const process = processes?.result?.runningProcesses?.find((candidate) => {
1093
+ if (typeof candidate.processIdentifier !== 'number') {
1094
+ return false;
1095
+ }
1096
+ return appUrl
1097
+ ? candidate.executable?.startsWith(appUrl)
1098
+ : candidate.executable?.includes(`/${bundleId}`) === true;
1099
+ });
1100
+ if (process?.processIdentifier === undefined) {
1101
+ return;
1102
+ }
1103
+ await this.devicectl([
1104
+ 'device',
1105
+ 'process',
1106
+ 'terminate',
1107
+ '--device',
1108
+ this.deviceInfo.id,
1109
+ '--pid',
1110
+ String(process.processIdentifier)
1111
+ ]);
1112
+ }
1113
+ resolveBundleId(app = this.capabilities.app) {
1114
+ const bundleId = app?.bundleId ?? app?.packageName;
1115
+ if (!bundleId) {
1116
+ throw new AsturError('IOS_BUNDLE_ID_REQUIRED', 'iOS app management requires app.bundleId.');
1117
+ }
1118
+ return bundleId;
1119
+ }
1120
+ }
1121
+ class IosAgentBridge {
1122
+ server;
1123
+ commandTimeout;
1124
+ port;
1125
+ endpoint;
1126
+ agentEndpoint;
1127
+ traceEnabled = process.env.ASTUR_IOS_AGENT_TRACE === '1';
1128
+ queue = [];
1129
+ commandPolls = [];
1130
+ pending = new Map();
1131
+ registrationWaiters = [];
1132
+ registration;
1133
+ constructor(server, commandTimeout, port, advertisedHost) {
1134
+ this.server = server;
1135
+ this.commandTimeout = commandTimeout;
1136
+ this.port = port;
1137
+ this.endpoint = `http://127.0.0.1:${port}`;
1138
+ this.agentEndpoint = `http://${formatHostForUrl(advertisedHost)}:${port}`;
1139
+ }
1140
+ static async start(port, commandTimeout, options = {}) {
1141
+ let bridge;
1142
+ const server = createHttpServer((request, response) => {
1143
+ void bridge.handle(request, response);
1144
+ });
1145
+ const bindHost = options.bindHost ?? '127.0.0.1';
1146
+ const advertisedHost = options.advertisedHost ?? bindHost;
1147
+ bridge = new IosAgentBridge(server, commandTimeout, port, advertisedHost);
1148
+ await new Promise((resolve, reject) => {
1149
+ server.once('error', reject);
1150
+ server.listen(port, bindHost, () => {
1151
+ server.off('error', reject);
1152
+ resolve();
1153
+ });
1154
+ });
1155
+ return bridge;
1156
+ }
1157
+ createClient(info) {
1158
+ return {
1159
+ endpoint: this.endpoint,
1160
+ platform: 'ios',
1161
+ info,
1162
+ commandTimeout: this.commandTimeout,
1163
+ command: async (method, params) => {
1164
+ return this.command(method, params);
1165
+ }
1166
+ };
1167
+ }
1168
+ waitForRegistration(timeoutMs) {
1169
+ if (this.registration) {
1170
+ return Promise.resolve(this.registration);
1171
+ }
1172
+ return new Promise((resolve, reject) => {
1173
+ const timer = setTimeout(() => {
1174
+ reject(new AsturError('IOS_XCTEST_AGENT_CONNECT_FAILED', `Timed out waiting for iOS XCUITest agent registration at ${this.endpoint}.`, { endpoint: this.endpoint, timeout: timeoutMs }));
1175
+ }, timeoutMs);
1176
+ this.registrationWaiters.push({ resolve, reject, timer });
1177
+ });
1178
+ }
1179
+ async close() {
1180
+ for (const pending of this.pending.values()) {
1181
+ if (pending.timer) {
1182
+ clearTimeout(pending.timer);
1183
+ }
1184
+ pending.reject(new AsturError('AGENT_DISCONNECTED', 'iOS XCUITest bridge closed before the command completed.', { endpoint: this.endpoint, method: pending.method }));
1185
+ }
1186
+ this.pending.clear();
1187
+ for (const poll of this.commandPolls.splice(0)) {
1188
+ clearTimeout(poll.timer);
1189
+ poll.response.off('close', poll.onClose);
1190
+ if (!poll.response.writableEnded) {
1191
+ poll.response.statusCode = 204;
1192
+ poll.response.end();
1193
+ }
1194
+ }
1195
+ await new Promise((resolve, reject) => {
1196
+ this.server.close((error) => {
1197
+ if (error) {
1198
+ reject(error);
1199
+ return;
1200
+ }
1201
+ resolve();
1202
+ });
1203
+ });
1204
+ }
1205
+ command(method, params) {
1206
+ const id = randomUUID();
1207
+ const command = {
1208
+ id,
1209
+ protocolVersion: '1.0',
1210
+ command: method,
1211
+ method,
1212
+ deadlineMs: Date.now() + this.commandTimeout,
1213
+ payload: params,
1214
+ params
1215
+ };
1216
+ return new Promise((resolve, reject) => {
1217
+ this.pending.set(id, {
1218
+ method,
1219
+ resolve: resolve,
1220
+ reject
1221
+ });
1222
+ this.queue.push(command);
1223
+ this.trace('queue', method, id);
1224
+ this.flushCommandPoll();
1225
+ });
1226
+ }
1227
+ async handle(request, response) {
1228
+ try {
1229
+ if (request.method === 'POST' && request.url === '/register') {
1230
+ await this.handleRegister(request, response);
1231
+ return;
1232
+ }
1233
+ if (request.method === 'GET' && request.url === '/command') {
1234
+ this.handleCommandPoll(response);
1235
+ return;
1236
+ }
1237
+ if (request.method === 'POST' && request.url === '/response') {
1238
+ await this.handleCommandResponse(request, response);
1239
+ return;
1240
+ }
1241
+ writeJson(response, 404, {
1242
+ ok: false,
1243
+ error: {
1244
+ code: 'NOT_FOUND',
1245
+ message: `Unknown iOS bridge route: ${request.method ?? 'GET'} ${request.url ?? '/'}`
1246
+ }
1247
+ });
1248
+ }
1249
+ catch (error) {
1250
+ writeJson(response, 500, {
1251
+ ok: false,
1252
+ error: {
1253
+ code: 'BRIDGE_ERROR',
1254
+ message: error instanceof Error ? error.message : String(error)
1255
+ }
1256
+ });
1257
+ }
1258
+ }
1259
+ async handleRegister(request, response) {
1260
+ const body = await readJson(request);
1261
+ const info = body.result ?? body.data;
1262
+ if (!info || info.platform !== 'ios') {
1263
+ writeJson(response, 400, {
1264
+ ok: false,
1265
+ error: {
1266
+ code: 'INVALID_REGISTRATION',
1267
+ message: 'iOS bridge registration must include iOS native-agent info.'
1268
+ }
1269
+ });
1270
+ return;
1271
+ }
1272
+ this.registration = info;
1273
+ for (const waiter of this.registrationWaiters.splice(0)) {
1274
+ clearTimeout(waiter.timer);
1275
+ waiter.resolve(info);
1276
+ }
1277
+ writeJson(response, 200, { ok: true });
1278
+ }
1279
+ handleCommandPoll(response) {
1280
+ const command = this.queue.shift();
1281
+ if (command) {
1282
+ this.deliverCommand(response, command);
1283
+ return;
1284
+ }
1285
+ const longPollMs = 30_000;
1286
+ const timer = setTimeout(() => {
1287
+ cleanup();
1288
+ response.statusCode = 204;
1289
+ response.end();
1290
+ }, longPollMs);
1291
+ let poll;
1292
+ const cleanup = () => {
1293
+ clearTimeout(timer);
1294
+ response.off('close', onClose);
1295
+ const index = this.commandPolls.indexOf(poll);
1296
+ if (index !== -1) {
1297
+ this.commandPolls.splice(index, 1);
1298
+ }
1299
+ };
1300
+ const onClose = () => {
1301
+ cleanup();
1302
+ };
1303
+ poll = {
1304
+ response,
1305
+ timer,
1306
+ onClose
1307
+ };
1308
+ response.on('close', onClose);
1309
+ this.commandPolls.push(poll);
1310
+ }
1311
+ flushCommandPoll() {
1312
+ while (this.queue.length > 0 && this.commandPolls.length > 0) {
1313
+ const poll = this.commandPolls.shift();
1314
+ const command = this.queue.shift();
1315
+ if (!poll || !command) {
1316
+ return;
1317
+ }
1318
+ clearTimeout(poll.timer);
1319
+ poll.response.off('close', poll.onClose);
1320
+ if (!poll.response.writableEnded) {
1321
+ this.deliverCommand(poll.response, command);
1322
+ }
1323
+ }
1324
+ }
1325
+ deliverCommand(response, command) {
1326
+ this.startCommandTimer(command);
1327
+ this.trace('deliver', command.method, command.id);
1328
+ writeJson(response, 200, command);
1329
+ }
1330
+ startCommandTimer(command) {
1331
+ const pending = this.pending.get(command.id);
1332
+ if (!pending || pending.timer) {
1333
+ return;
1334
+ }
1335
+ pending.timer = setTimeout(() => {
1336
+ this.pending.delete(command.id);
1337
+ this.trace('timeout', command.method, command.id);
1338
+ pending.reject(new AsturError('AGENT_COMMAND_TIMEOUT', `iOS XCUITest agent command ${command.method} timed out.`, { endpoint: this.endpoint, method: command.method, timeout: this.commandTimeout }));
1339
+ }, this.commandTimeout);
1340
+ }
1341
+ async handleCommandResponse(request, response) {
1342
+ const body = await readJson(request);
1343
+ const pending = this.pending.get(body.id);
1344
+ if (!pending) {
1345
+ this.trace('orphan-response', undefined, body.id);
1346
+ writeJson(response, 200, { ok: true });
1347
+ return;
1348
+ }
1349
+ this.pending.delete(body.id);
1350
+ if (pending.timer) {
1351
+ clearTimeout(pending.timer);
1352
+ }
1353
+ this.trace(body.ok ? 'response-ok' : 'response-error', pending.method, body.id);
1354
+ if (body.ok) {
1355
+ pending.resolve(body.result ?? body.data);
1356
+ }
1357
+ else {
1358
+ pending.reject(new AsturError(body.error?.code ?? 'AGENT_COMMAND_FAILED', body.error?.message ?? `iOS XCUITest agent command ${pending.method} failed.`, {
1359
+ endpoint: this.endpoint,
1360
+ method: pending.method,
1361
+ error: body.error
1362
+ }));
1363
+ }
1364
+ writeJson(response, 200, { ok: true });
1365
+ }
1366
+ trace(event, method, id) {
1367
+ if (!this.traceEnabled) {
1368
+ return;
1369
+ }
1370
+ console.log(`[astur/ios-agent] ${event} ${method ?? 'unknown'} ${id}`);
1371
+ }
1372
+ }
1373
+ async function readJson(request) {
1374
+ const chunks = [];
1375
+ for await (const chunk of request) {
1376
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1377
+ }
1378
+ const body = Buffer.concat(chunks).toString('utf8');
1379
+ return body ? JSON.parse(body) : {};
1380
+ }
1381
+ function writeJson(response, status, body) {
1382
+ response.statusCode = status;
1383
+ response.setHeader('content-type', 'application/json');
1384
+ response.end(JSON.stringify(body));
1385
+ }
1386
+ async function ensureIosAppInstalled(session, capabilities) {
1387
+ const app = capabilities.app;
1388
+ if (!app) {
1389
+ return;
1390
+ }
1391
+ const identifier = app.bundleId ?? app.packageName;
1392
+ const forceInstall = process.env.ASTUR_IOS_APP_FORCE_INSTALL === '1';
1393
+ if (identifier === 'dev.astur.iosagent.host' && !app.path) {
1394
+ return;
1395
+ }
1396
+ if (forceInstall && app.path) {
1397
+ await session.installApp(app.path);
1398
+ return;
1399
+ }
1400
+ if (identifier) {
1401
+ const installed = await session.isAppInstalled(identifier).catch(() => false);
1402
+ if (installed) {
1403
+ return;
1404
+ }
1405
+ if (!app.path) {
1406
+ throw new AsturError('IOS_APP_NOT_INSTALLED', `iOS app ${identifier} is not installed on ${session.deviceInfo.name}. Pass --app <Simulator.app> or install the app before starting codegen.`, {
1407
+ identifier,
1408
+ device: session.deviceInfo
1409
+ });
1410
+ }
1411
+ }
1412
+ if (!app.path) {
1413
+ return;
1414
+ }
1415
+ await session.installApp(app.path);
1416
+ }
1417
+ async function resolveIosAppMetadata(capabilities) {
1418
+ const app = capabilities.app;
1419
+ if (!app?.path || app.bundleId || app.packageName) {
1420
+ return capabilities;
1421
+ }
1422
+ const bundleId = await inferIosBundleId(app.path);
1423
+ return {
1424
+ ...capabilities,
1425
+ app: {
1426
+ ...app,
1427
+ bundleId
1428
+ }
1429
+ };
1430
+ }
1431
+ async function inferIosBundleId(path) {
1432
+ const materialized = await materializeIosInstallPath(path);
1433
+ try {
1434
+ const plist = join(materialized.path, 'Info.plist');
1435
+ const result = await run('/usr/libexec/PlistBuddy', ['-c', 'Print:CFBundleIdentifier', plist]);
1436
+ const bundleId = result.stdout.toString('utf8').trim();
1437
+ if (!bundleId) {
1438
+ throw new AsturError('IOS_BUNDLE_ID_INFERENCE_FAILED', `No CFBundleIdentifier was found in ${plist}.`);
1439
+ }
1440
+ return bundleId;
1441
+ }
1442
+ finally {
1443
+ await materialized.cleanup?.().catch(() => undefined);
1444
+ }
1445
+ }
1446
+ function waitForProcessExit(child, timeout) {
1447
+ return new Promise((resolve) => {
1448
+ if (child.exitCode !== null || child.killed) {
1449
+ resolve();
1450
+ return;
1451
+ }
1452
+ const timer = setTimeout(() => {
1453
+ child.kill('SIGKILL');
1454
+ resolve();
1455
+ }, timeout);
1456
+ child.once('exit', () => {
1457
+ clearTimeout(timer);
1458
+ resolve();
1459
+ });
1460
+ });
1461
+ }
1462
+ function resolveIosAgentRuntimeConfig() {
1463
+ return {
1464
+ projectPath: process.env.ASTUR_IOS_AGENT_PROJECT ?? resolveDefaultIosAgentProject(),
1465
+ scheme: process.env.ASTUR_IOS_AGENT_SCHEME ?? 'AsturIOSAgent',
1466
+ hostPort: parseOptionalPort(process.env.ASTUR_IOS_AGENT_PORT),
1467
+ derivedDataPath: process.env.ASTUR_IOS_AGENT_DERIVED_DATA,
1468
+ bridgeHost: process.env.ASTUR_IOS_AGENT_HOST,
1469
+ bridgeBindHost: process.env.ASTUR_IOS_AGENT_BIND_HOST,
1470
+ developmentTeam: process.env.ASTUR_IOS_DEVELOPMENT_TEAM,
1471
+ codeSignIdentity: process.env.ASTUR_IOS_CODE_SIGN_IDENTITY,
1472
+ allowProvisioningUpdates: process.env.ASTUR_IOS_ALLOW_PROVISIONING_UPDATES === '0' ? false : undefined
1473
+ };
1474
+ }
1475
+ function resolveIosAgentBridgeHosts(device, config) {
1476
+ if (device.kind !== 'real') {
1477
+ return {
1478
+ bindHost: config.bridgeBindHost ?? '127.0.0.1',
1479
+ advertisedHost: config.bridgeHost ?? '127.0.0.1'
1480
+ };
1481
+ }
1482
+ const advertisedHost = normalizeBridgeHost(config.bridgeHost)
1483
+ ?? firstUsableCoreDeviceHostAddress()
1484
+ ?? firstUsableHostAddress();
1485
+ if (!advertisedHost) {
1486
+ throw new AsturError('IOS_AGENT_HOST_REQUIRED', 'Real iOS device automation needs a host address the device can reach for the XCUITest bridge. Set ASTUR_IOS_AGENT_HOST to your Mac IP address.');
1487
+ }
1488
+ return {
1489
+ bindHost: normalizeBridgeHost(config.bridgeBindHost) ?? (advertisedHost.includes(':') ? '::' : '0.0.0.0'),
1490
+ advertisedHost
1491
+ };
1492
+ }
1493
+ function xcodebuildRealDeviceSigningArgs(device, config) {
1494
+ if (device.kind !== 'real') {
1495
+ return [];
1496
+ }
1497
+ const args = [
1498
+ '-allowProvisioningDeviceRegistration',
1499
+ 'CODE_SIGNING_ALLOWED=YES',
1500
+ 'CODE_SIGNING_REQUIRED=YES',
1501
+ 'CODE_SIGN_STYLE=Automatic'
1502
+ ];
1503
+ if (config.allowProvisioningUpdates !== false) {
1504
+ args.unshift('-allowProvisioningUpdates');
1505
+ }
1506
+ const developmentTeam = config.developmentTeam ?? inferIosDevelopmentTeam(config.projectPath);
1507
+ if (developmentTeam) {
1508
+ args.push(`DEVELOPMENT_TEAM=${developmentTeam}`);
1509
+ }
1510
+ if (config.codeSignIdentity) {
1511
+ args.push(`CODE_SIGN_IDENTITY=${config.codeSignIdentity}`);
1512
+ }
1513
+ return args;
1514
+ }
1515
+ function isIosDevelopmentTeamMissing(output) {
1516
+ if (!output) {
1517
+ return false;
1518
+ }
1519
+ return output.includes('requires a development team') || output.includes('requires a provisioning profile');
1520
+ }
1521
+ function isIosKeychainOrSigningPrompt(output) {
1522
+ if (!output) {
1523
+ return false;
1524
+ }
1525
+ return /Password:|User interaction is not allowed|errSecInteractionNotAllowed|No signing certificate/i.test(output);
1526
+ }
1527
+ function isIosAppSignatureNotTrusted(output) {
1528
+ if (!output) {
1529
+ return false;
1530
+ }
1531
+ return /invalid code signature|inadequate entitlements|profile has not been explicitly trusted|not been explicitly trusted by the user|Unable to launch .* because it has an invalid/i.test(output);
1532
+ }
1533
+ function isIosAppInstallSignatureInvalid(error) {
1534
+ const output = commandErrorOutput(error);
1535
+ if (!output) {
1536
+ return false;
1537
+ }
1538
+ return /integrity could not be verified|ApplicationVerificationFailed|Failed to install embedded profile|provisioning profile has expired|0xe8008011|embedded profile.*expired/i.test(output);
1539
+ }
1540
+ function commandErrorOutput(error) {
1541
+ if (error instanceof AsturError) {
1542
+ const details = isRecord(error.details) ? error.details : undefined;
1543
+ const stdout = typeof details?.stdout === 'string' ? details.stdout : '';
1544
+ const stderr = typeof details?.stderr === 'string' ? details.stderr : '';
1545
+ return [error.message, stderr, stdout].filter(Boolean).join('\n').trim();
1546
+ }
1547
+ return error instanceof Error ? error.message : String(error);
1548
+ }
1549
+ function isRecord(value) {
1550
+ return typeof value === 'object' && value !== null;
1551
+ }
1552
+ const developmentTeamCache = new Map();
1553
+ function inferIosDevelopmentTeam(projectPath) {
1554
+ const pbxprojPath = join(projectPath, 'project.pbxproj');
1555
+ if (developmentTeamCache.has(pbxprojPath)) {
1556
+ return developmentTeamCache.get(pbxprojPath);
1557
+ }
1558
+ let result;
1559
+ try {
1560
+ const project = readFileSync(pbxprojPath, 'utf8');
1561
+ for (const match of project.matchAll(/DEVELOPMENT_TEAM = "?([A-Z0-9]+)"?;/g)) {
1562
+ const value = match[1]?.trim();
1563
+ if (value) {
1564
+ result = value;
1565
+ break;
1566
+ }
1567
+ }
1568
+ }
1569
+ catch {
1570
+ result = undefined;
1571
+ }
1572
+ developmentTeamCache.set(pbxprojPath, result);
1573
+ return result;
1574
+ }
1575
+ function firstUsableHostAddress() {
1576
+ for (const addresses of Object.values(networkInterfaces())) {
1577
+ for (const address of addresses ?? []) {
1578
+ if (address.family === 'IPv4' && !address.internal) {
1579
+ return address.address;
1580
+ }
1581
+ }
1582
+ }
1583
+ return undefined;
1584
+ }
1585
+ function firstUsableCoreDeviceHostAddress() {
1586
+ for (const [name, addresses] of Object.entries(networkInterfaces())) {
1587
+ if (!name.startsWith('utun')) {
1588
+ continue;
1589
+ }
1590
+ for (const address of addresses ?? []) {
1591
+ if (address.family === 'IPv6' && !address.internal && address.address.toLowerCase().startsWith('fd')) {
1592
+ return address.address;
1593
+ }
1594
+ }
1595
+ }
1596
+ return undefined;
1597
+ }
1598
+ function normalizeBridgeHost(host) {
1599
+ if (!host) {
1600
+ return undefined;
1601
+ }
1602
+ return host.trim().replace(/^\[(.*)\]$/, '$1') || undefined;
1603
+ }
1604
+ function formatHostForUrl(host) {
1605
+ return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
1606
+ }
1607
+ function resolveDefaultIosAgentProject() {
1608
+ const iosPackageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
1609
+ const sourceProject = join(iosPackageRoot, '..', '..', 'agents', 'ios-xctest-agent', 'AsturIOSAgent.xcodeproj');
1610
+ if (existsSync(join(sourceProject, 'project.pbxproj'))) {
1611
+ return sourceProject;
1612
+ }
1613
+ const packagedProject = join(iosPackageRoot, 'assets', 'ios-xctest-agent', 'AsturIOSAgent.xcodeproj');
1614
+ if (existsSync(join(packagedProject, 'project.pbxproj'))) {
1615
+ return packagedProject;
1616
+ }
1617
+ return sourceProject;
1618
+ }
1619
+ function iosAgentSourceStamp(projectPath) {
1620
+ const projectRoot = dirname(projectPath);
1621
+ const candidates = [
1622
+ 'AsturAgent.swift',
1623
+ 'AsturAgentBridgeClient.swift',
1624
+ 'AsturAgentServer.swift',
1625
+ 'AsturAgentUITests.swift',
1626
+ 'AsturIOSAgent.xcodeproj/project.pbxproj'
1627
+ ];
1628
+ const latest = candidates.reduce((max, candidate) => {
1629
+ try {
1630
+ return Math.max(max, statSync(join(projectRoot, candidate)).mtimeMs);
1631
+ }
1632
+ catch {
1633
+ return max;
1634
+ }
1635
+ }, 0);
1636
+ return Math.max(1, Math.floor(latest)).toString(36);
1637
+ }
1638
+ function pathSafeName(value) {
1639
+ return value.replace(/[^a-zA-Z0-9._-]+/g, '-');
1640
+ }
1641
+ export function iosAgentDerivedDataRoot() {
1642
+ return join(tmpdir(), 'astur-ios-agent-derived-data');
1643
+ }
1644
+ /**
1645
+ * Removes the managed XCUITest agent DerivedData directories left behind by
1646
+ * previous agent source versions for a device, keeping only the current build
1647
+ * (`keepPath`). Without this, every change to the bundled Swift agent — and each
1648
+ * source-stamp it produces — leaves a full Xcode build behind, and they
1649
+ * accumulate until the disk fills. Other devices' builds are left untouched.
1650
+ * Best-effort: never throws.
1651
+ */
1652
+ export async function pruneStaleIosAgentDerivedData(deviceId, keepPath, root = iosAgentDerivedDataRoot()) {
1653
+ const prefix = `${pathSafeName(deviceId)}-`;
1654
+ let entries;
1655
+ try {
1656
+ entries = await readdir(root);
1657
+ }
1658
+ catch {
1659
+ return;
1660
+ }
1661
+ await Promise.all(entries.map(async (entry) => {
1662
+ if (!entry.startsWith(prefix) || join(root, entry) === keepPath) {
1663
+ return;
1664
+ }
1665
+ await rm(join(root, entry), { recursive: true, force: true }).catch(() => undefined);
1666
+ }));
1667
+ }
1668
+ function parseOptionalPort(value) {
1669
+ if (!value) {
1670
+ return undefined;
1671
+ }
1672
+ const port = Number(value);
1673
+ if (!Number.isInteger(port) || port <= 0 || port > 65_535) {
1674
+ throw new AsturError('IOS_AGENT_PORT_INVALID', `Invalid iOS agent port: ${value}`);
1675
+ }
1676
+ return port;
1677
+ }
1678
+ function parsePositiveInteger(value) {
1679
+ if (!value) {
1680
+ return undefined;
1681
+ }
1682
+ const parsed = Number(value);
1683
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
1684
+ }
1685
+ function createBoundedOutputCapture(limit = 20_000) {
1686
+ let output = '';
1687
+ const append = (chunk) => {
1688
+ output += chunk.toString();
1689
+ if (output.length > limit) {
1690
+ output = output.slice(output.length - limit);
1691
+ }
1692
+ };
1693
+ return {
1694
+ attach(process) {
1695
+ process.stdout?.on('data', append);
1696
+ process.stderr?.on('data', append);
1697
+ },
1698
+ text() {
1699
+ return output.trim();
1700
+ }
1701
+ };
1702
+ }
1703
+ function waitForChildExit(child, timeoutMs, abortSignal) {
1704
+ if (!child) {
1705
+ return Promise.resolve({ code: null, signal: null });
1706
+ }
1707
+ return new Promise((resolve, reject) => {
1708
+ let timer;
1709
+ const cleanup = () => {
1710
+ if (timer) {
1711
+ clearTimeout(timer);
1712
+ }
1713
+ child.off('exit', onExit);
1714
+ child.off('error', onError);
1715
+ abortSignal?.removeEventListener('abort', onAbort);
1716
+ };
1717
+ const onExit = (code, signal) => {
1718
+ cleanup();
1719
+ resolve({ code, signal });
1720
+ };
1721
+ const onError = (error) => {
1722
+ cleanup();
1723
+ reject(error);
1724
+ };
1725
+ // Lets the loser of a Promise.race detach its exit/error listeners instead
1726
+ // of leaking them on a child that lives for the whole session.
1727
+ const onAbort = () => {
1728
+ cleanup();
1729
+ resolve({ code: null, signal: null });
1730
+ };
1731
+ if (abortSignal?.aborted) {
1732
+ resolve({ code: null, signal: null });
1733
+ return;
1734
+ }
1735
+ child.once('exit', onExit);
1736
+ child.once('error', onError);
1737
+ abortSignal?.addEventListener('abort', onAbort, { once: true });
1738
+ if (timeoutMs !== undefined) {
1739
+ timer = setTimeout(() => {
1740
+ cleanup();
1741
+ resolve({ code: null, signal: null });
1742
+ }, timeoutMs);
1743
+ }
1744
+ });
1745
+ }
1746
+ async function terminateChildProcess(child) {
1747
+ if (!child) {
1748
+ return;
1749
+ }
1750
+ signalChildProcess(child, 'SIGINT');
1751
+ const gracefulExit = await waitForChildExit(child, 2_000).catch(() => ({ code: null, signal: null }));
1752
+ if (gracefulExit.code !== null || gracefulExit.signal !== null) {
1753
+ return;
1754
+ }
1755
+ signalChildProcess(child, 'SIGKILL');
1756
+ await waitForChildExit(child, 1_000).catch(() => undefined);
1757
+ }
1758
+ function signalChildProcess(child, signal) {
1759
+ if (detachedIosAgentProcesses.has(child) && child.pid) {
1760
+ try {
1761
+ process.kill(-child.pid, signal);
1762
+ return;
1763
+ }
1764
+ catch {
1765
+ // Fall through to direct child signaling.
1766
+ }
1767
+ }
1768
+ child.kill(signal);
1769
+ }
1770
+ function formatExitStatus(code, signal) {
1771
+ if (signal) {
1772
+ return `signal ${signal}`;
1773
+ }
1774
+ if (code !== null) {
1775
+ return `exit code ${code}`;
1776
+ }
1777
+ return 'unknown exit status';
1778
+ }
1779
+ function findFreePort(host = '127.0.0.1') {
1780
+ return new Promise((resolve, reject) => {
1781
+ const server = createNetServer();
1782
+ server.unref();
1783
+ server.once('error', reject);
1784
+ server.listen(0, host, () => {
1785
+ const address = server.address();
1786
+ server.close(() => {
1787
+ if (!address || typeof address === 'string') {
1788
+ reject(new AsturError('IOS_AGENT_PORT_UNAVAILABLE', 'Failed to allocate a host port for the iOS agent.'));
1789
+ return;
1790
+ }
1791
+ resolve(address.port);
1792
+ });
1793
+ });
1794
+ });
1795
+ }
1796
+ async function runDevicectlJson(xcrunPath, args) {
1797
+ let lastError;
1798
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
1799
+ const path = join(tmpdir(), `astur-devicectl-${process.pid}-${Date.now()}-${randomUUID()}.json`);
1800
+ try {
1801
+ await run(xcrunPath, ['devicectl', ...args, '--json-output', path, '--quiet']);
1802
+ return JSON.parse(await readFile(path, 'utf8'));
1803
+ }
1804
+ catch (error) {
1805
+ lastError = error;
1806
+ if (attempt < 3) {
1807
+ await delay(750 * attempt);
1808
+ }
1809
+ }
1810
+ finally {
1811
+ await unlink(path).catch(() => undefined);
1812
+ }
1813
+ }
1814
+ throw lastError;
1815
+ }
1816
+ async function waitForIosAgent(endpoint, capabilities) {
1817
+ const deadline = Date.now() + capabilities.agent.launchTimeout;
1818
+ let lastError;
1819
+ while (Date.now() <= deadline) {
1820
+ try {
1821
+ const remaining = Math.max(100, deadline - Date.now());
1822
+ return await connectNativeAgentClient({
1823
+ endpoint,
1824
+ platform: 'ios',
1825
+ handshakeTimeout: Math.min(1_000, remaining),
1826
+ commandTimeout: capabilities.agent.commandTimeout
1827
+ });
1828
+ }
1829
+ catch (error) {
1830
+ lastError = error;
1831
+ await delay(250);
1832
+ }
1833
+ }
1834
+ throw new AsturError('IOS_XCTEST_AGENT_CONNECT_FAILED', `Timed out waiting for iOS XCUITest agent at ${endpoint}.`, {
1835
+ endpoint,
1836
+ timeout: capabilities.agent.launchTimeout,
1837
+ cause: lastError
1838
+ });
1839
+ }
1840
+ export function parseSimctlDevices(output) {
1841
+ const parsed = JSON.parse(output);
1842
+ const devices = [];
1843
+ for (const [runtime, runtimeDevices] of Object.entries(parsed.devices)) {
1844
+ for (const device of runtimeDevices) {
1845
+ if (device.isAvailable === false) {
1846
+ continue;
1847
+ }
1848
+ devices.push({
1849
+ id: device.udid,
1850
+ name: device.name,
1851
+ platform: 'ios',
1852
+ kind: 'simulator',
1853
+ state: normalizeSimctlState(device.state),
1854
+ osVersion: runtimeToVersion(runtime),
1855
+ raw: { runtime, ...device }
1856
+ });
1857
+ }
1858
+ }
1859
+ return devices;
1860
+ }
1861
+ export function parseXcdeviceDevices(output) {
1862
+ const parsed = JSON.parse(output);
1863
+ return parsed
1864
+ .filter((device) => device.simulator === false)
1865
+ .filter((device) => device.available !== false)
1866
+ .filter((device) => device.platform === 'com.apple.platform.iphoneos')
1867
+ .filter((device) => typeof device.identifier === 'string' && typeof device.name === 'string')
1868
+ .map((device) => ({
1869
+ id: device.identifier,
1870
+ name: device.name,
1871
+ platform: 'ios',
1872
+ kind: 'real',
1873
+ state: 'online',
1874
+ osVersion: normalizeAppleDeviceOsVersion(device.operatingSystemVersion),
1875
+ model: device.modelName,
1876
+ raw: device
1877
+ }));
1878
+ }
1879
+ export function parseDevicectlDevices(output) {
1880
+ const parsed = typeof output === 'string' ? JSON.parse(output) : output;
1881
+ return (parsed.result?.devices ?? [])
1882
+ .filter((device) => device.hardwareProperties?.platform === 'iOS')
1883
+ .filter((device) => typeof device.hardwareProperties?.udid === 'string')
1884
+ .map((device) => ({
1885
+ id: device.hardwareProperties.udid,
1886
+ name: device.deviceProperties?.name ?? device.hardwareProperties?.marketingName ?? 'iOS Device',
1887
+ platform: 'ios',
1888
+ kind: 'real',
1889
+ state: normalizeDevicectlDeviceState(device),
1890
+ osVersion: device.deviceProperties?.osVersionNumber,
1891
+ model: device.hardwareProperties?.marketingName,
1892
+ raw: device
1893
+ }));
1894
+ }
1895
+ function normalizeDevicectlDeviceState(device) {
1896
+ if (device.connectionProperties?.pairingState && device.connectionProperties.pairingState !== 'paired') {
1897
+ return 'unauthorized';
1898
+ }
1899
+ if (device.deviceProperties?.bootState === 'booted') {
1900
+ return 'online';
1901
+ }
1902
+ if (device.deviceProperties?.bootState === 'shutdown') {
1903
+ return 'offline';
1904
+ }
1905
+ return 'unknown';
1906
+ }
1907
+ function selectDevice(devices, selector) {
1908
+ return devices.find((device) => {
1909
+ if (selector.id && selector.id !== device.id) {
1910
+ return false;
1911
+ }
1912
+ if (selector.kind && selector.kind !== device.kind) {
1913
+ return false;
1914
+ }
1915
+ if (selector.name instanceof RegExp && !selector.name.test(device.name)) {
1916
+ return false;
1917
+ }
1918
+ if (typeof selector.name === 'string' && selector.name !== device.name) {
1919
+ return false;
1920
+ }
1921
+ return true;
1922
+ });
1923
+ }
1924
+ function normalizeSimctlState(state) {
1925
+ if (state === 'Booted') {
1926
+ return 'booted';
1927
+ }
1928
+ if (state === 'Shutdown') {
1929
+ return 'shutdown';
1930
+ }
1931
+ return 'unknown';
1932
+ }
1933
+ const defaultBundledIosAgentLaunchTimeoutMs = 30_000;
1934
+ function runtimeToVersion(runtime) {
1935
+ const match = runtime.match(/SimRuntime\.iOS-(.+)$/);
1936
+ return match?.[1]?.replaceAll('-', '.');
1937
+ }
1938
+ function normalizeAppleDeviceOsVersion(version) {
1939
+ return version?.split('(')[0]?.trim() || undefined;
1940
+ }
1941
+ function xctestRequired(action) {
1942
+ return new AsturError('XCTEST_AGENT_REQUIRED', `Astur cannot complete ${action} without the iOS XCUITest agent. This is an iOS platform requirement, not an Appium requirement.`);
1943
+ }
1944
+ function normalizeIosPermission(permission) {
1945
+ const value = permission.trim().toLowerCase().replace(/[\s-]+/g, '-');
1946
+ const aliases = {
1947
+ camera: 'camera',
1948
+ microphone: 'microphone',
1949
+ photos: 'photos',
1950
+ photo: 'photos',
1951
+ contacts: 'contacts',
1952
+ calendar: 'calendar',
1953
+ reminders: 'reminders',
1954
+ location: 'location',
1955
+ 'location-always': 'location-always',
1956
+ 'location-when-in-use': 'location',
1957
+ notifications: 'notifications'
1958
+ };
1959
+ return aliases[value] ?? value;
1960
+ }
1961
+ async function materializeIosInstallPath(path) {
1962
+ if (extname(path).toLowerCase() !== '.ipa') {
1963
+ return { path };
1964
+ }
1965
+ const dir = await mkdtemp(join(tmpdir(), 'astur-ios-ipa-'));
1966
+ try {
1967
+ await run('/usr/bin/ditto', ['-x', '-k', path, dir]);
1968
+ const appPath = await findFirstAppBundle(join(dir, 'Payload')) ?? await findFirstAppBundle(dir);
1969
+ if (!appPath) {
1970
+ throw new AsturError('IOS_IPA_APP_NOT_FOUND', `No .app bundle was found inside IPA: ${path}`);
1971
+ }
1972
+ return {
1973
+ path: appPath,
1974
+ cleanup: () => rm(dir, { recursive: true, force: true })
1975
+ };
1976
+ }
1977
+ catch (error) {
1978
+ await rm(dir, { recursive: true, force: true }).catch(() => undefined);
1979
+ throw error;
1980
+ }
1981
+ }
1982
+ async function findFirstAppBundle(root) {
1983
+ let entries;
1984
+ try {
1985
+ entries = await readdir(root, { withFileTypes: true });
1986
+ }
1987
+ catch {
1988
+ return undefined;
1989
+ }
1990
+ for (const entry of entries) {
1991
+ if (!entry.isDirectory()) {
1992
+ continue;
1993
+ }
1994
+ const fullPath = join(root, entry.name);
1995
+ if (entry.name.endsWith('.app')) {
1996
+ return fullPath;
1997
+ }
1998
+ const nested = await findFirstAppBundle(fullPath);
1999
+ if (nested) {
2000
+ return nested;
2001
+ }
2002
+ }
2003
+ return undefined;
2004
+ }
2005
+ function firstLine(value) {
2006
+ return value.split(/\r?\n/).find(Boolean) ?? value.trim();
2007
+ }
2008
+ function isAgentCommandUnsupported(error) {
2009
+ if (!(error instanceof AsturError)) {
2010
+ return false;
2011
+ }
2012
+ return error.code === 'NOT_IMPLEMENTED' || error.code === 'UNKNOWN_COMMAND';
2013
+ }
2014
+ function isAgentCommandFailure(error) {
2015
+ if (!(error instanceof AsturError)) {
2016
+ return false;
2017
+ }
2018
+ return !isAgentTransportFailure(error);
2019
+ }
2020
+ function isAgentCommandTimeout(error) {
2021
+ return error instanceof AsturError && error.code === 'AGENT_COMMAND_TIMEOUT';
2022
+ }
2023
+ function isAgentTransportFailure(error) {
2024
+ return error.code.startsWith('AGENT_') && !isAgentCommandUnsupported(error);
2025
+ }
2026
+ function allowsLegacyFallback(capabilities, reason) {
2027
+ if (capabilities.agent.mode === 'required') {
2028
+ return false;
2029
+ }
2030
+ switch (capabilities.agent.legacyFallback) {
2031
+ case 'never':
2032
+ return false;
2033
+ case 'on-unsupported-command':
2034
+ return reason === 'unsupported';
2035
+ case 'on-agent-failure':
2036
+ return true;
2037
+ }
2038
+ }
2039
+ //# sourceMappingURL=index.js.map