@astur-mobile/cli 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,917 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { constants } from 'node:fs';
4
+ import { realpathSync } from 'node:fs';
5
+ import { access, mkdir, writeFile } from 'node:fs/promises';
6
+ import { dirname } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { createAndroidDriver } from '@astur-mobile/android';
9
+ import { AsturError, AsturRuntime } from '@astur-mobile/core';
10
+ import { createIosDriver } from '@astur-mobile/ios';
11
+ import { __testing as inspectorServerTesting, startInspectorServer } from './inspectorServer.js';
12
+ import { buildInitFiles, defaultInitAnswers, promptInitAnswers } from './scaffold.js';
13
+ export async function main(argv = process.argv.slice(2)) {
14
+ const [command = 'help', ...rest] = argv;
15
+ switch (command) {
16
+ case 'doctor':
17
+ await doctor(rest);
18
+ break;
19
+ case 'devices':
20
+ await devices(rest);
21
+ break;
22
+ case 'init':
23
+ await init(rest);
24
+ break;
25
+ case 'test':
26
+ await runPlaywright(rest);
27
+ break;
28
+ case 'codegen':
29
+ await codegen(rest);
30
+ break;
31
+ case 'inspect':
32
+ await codegen(rest);
33
+ break;
34
+ case '-h':
35
+ case '--help':
36
+ case 'help':
37
+ help();
38
+ break;
39
+ default:
40
+ console.error(`Unknown command: ${command}`);
41
+ help();
42
+ process.exitCode = 1;
43
+ }
44
+ }
45
+ function createRuntime() {
46
+ const runtime = new AsturRuntime().register(createAndroidDriver());
47
+ if (supportsLocalIos()) {
48
+ runtime.register(createIosDriver());
49
+ }
50
+ return runtime;
51
+ }
52
+ async function doctor(args) {
53
+ const checks = await createRuntime().doctor();
54
+ if (!supportsLocalIos()) {
55
+ checks.push(createIosSkipCheck());
56
+ }
57
+ printChecks(checks, { verbose: args.includes('--verbose') });
58
+ if (checks.some((check) => check.status === 'fail')) {
59
+ process.exitCode = 1;
60
+ }
61
+ }
62
+ async function devices(args) {
63
+ const platform = args.includes('--android') ? 'android' : args.includes('--ios') ? 'ios' : undefined;
64
+ if (platform === 'ios' && !supportsLocalIos()) {
65
+ printHeader('devices', 'Connected devices and available simulators');
66
+ console.log(`\n${colors.yellow('iOS devices are not available on this host.')}`);
67
+ console.log(colors.dim('Local iOS automation requires macOS with Xcode.'));
68
+ return;
69
+ }
70
+ const found = await createRuntime().listDevices(platform);
71
+ printDevices(found);
72
+ }
73
+ async function init(args) {
74
+ printHeader('init', 'Create Astur Playwright config and starter test');
75
+ const useDefaults = args.includes('--yes') || args.includes('-y') || !process.stdin.isTTY;
76
+ const answers = useDefaults ? defaultInitAnswers() : await promptInitAnswers();
77
+ for (const file of buildInitFiles(answers)) {
78
+ await writeIfMissing(file.path, file.contents);
79
+ }
80
+ console.log('\nCreated Astur starter files.');
81
+ console.log(colors.dim('Next: run `npx astur-mobile doctor`, then `npx astur-mobile test`.'));
82
+ }
83
+ async function codegen(args) {
84
+ const parsed = parseCodegenArgs(args);
85
+ if (parsed.help) {
86
+ printCodegenHelp();
87
+ return;
88
+ }
89
+ if (parsed.platform === 'ios' && !supportsLocalIos()) {
90
+ printHeader('codegen', 'Playwright-style mobile inspector bootstrap');
91
+ console.log(`\n${colors.yellow('iOS codegen requires macOS with Xcode.')}`);
92
+ process.exitCode = 1;
93
+ return;
94
+ }
95
+ const runtime = createRuntime();
96
+ const available = await runtime.listDevices(parsed.platform);
97
+ const selected = selectCodegenDevice(available, parsed.deviceId, parsed.deviceKind);
98
+ if (!selected) {
99
+ printHeader('codegen', 'Playwright-style mobile inspector bootstrap');
100
+ console.log(`\n${colors.yellow('No matching online/booted device was found.')}`);
101
+ console.log(colors.dim('Run `astur devices` to list available targets.'));
102
+ process.exitCode = 1;
103
+ return;
104
+ }
105
+ // ── Live inspector path (default) ─────────────────────────────────────────
106
+ if (parsed.ui) {
107
+ printHeader('codegen', 'Astur Inspector UI');
108
+ // Create device without fetching tree — server streams everything
109
+ const config = buildCodegenConfig(selected, parsed);
110
+ printCodegenPreparation(selected, config);
111
+ let device;
112
+ let selectedDevice = selected;
113
+ try {
114
+ device = await createCodegenDevice(runtime, config, {
115
+ forceIosAppInstall: selectedDevice.platform === 'ios' && Boolean(parsed.appPath)
116
+ });
117
+ if (parsed.launch && config.app) {
118
+ await device.app.launch();
119
+ }
120
+ }
121
+ catch (err) {
122
+ console.error(`\n${colors.red('Failed to connect to device:')} ${formatCodegenConnectionError(err)}`);
123
+ process.exitCode = 1;
124
+ return;
125
+ }
126
+ let handle;
127
+ try {
128
+ handle = await startInspectorServer(device.inspector(codegenInspectorOptions(selectedDevice)), selectedDevice, {
129
+ captureScreenshot: () => device.screenshot().catch(() => undefined),
130
+ performDeviceAction: (action) => runInspectorDeviceAction(device, action),
131
+ performTap: (point) => device.tap(point),
132
+ performSwipe: (gesture) => device.swipe(gesture),
133
+ installApp: (path) => device.app.install(path),
134
+ performAppAction: async (action, options) => {
135
+ if (action === 'launch' && device.deviceInfo.platform === 'ios') {
136
+ const identifier = requireInspectorAppIdentifier(options.identifier);
137
+ const nextConfig = buildCodegenConfig(selectedDevice, {
138
+ ...parsed,
139
+ platform: selectedDevice.platform,
140
+ deviceId: selectedDevice.id,
141
+ appId: identifier,
142
+ launch: false
143
+ });
144
+ const nextDevice = await createCodegenDevice(runtime, nextConfig, {
145
+ forceIosAppInstall: selectedDevice.platform === 'ios' && hasConfiguredAppPath(nextConfig)
146
+ });
147
+ await nextDevice.app.launch({ app: { bundleId: identifier } });
148
+ const previousDevice = device;
149
+ device = nextDevice;
150
+ await previousDevice.close().catch(() => undefined);
151
+ return {
152
+ device: selectedDevice,
153
+ inspector: nextDevice.inspector(codegenInspectorOptions(selectedDevice))
154
+ };
155
+ }
156
+ await runInspectorAppAction(device, action, options);
157
+ },
158
+ listDevices: () => runtime.listDevices(),
159
+ switchDevice: async (deviceId) => {
160
+ const devices = await runtime.listDevices();
161
+ const next = selectCodegenDevice(devices, deviceId, parsed.deviceKind);
162
+ if (!next) {
163
+ throw new Error(`No ready device found for ${deviceId}.`);
164
+ }
165
+ const nextConfig = buildCodegenConfig(next, {
166
+ ...parsed,
167
+ platform: next.platform,
168
+ deviceId: next.id,
169
+ launch: false
170
+ });
171
+ const nextDevice = await createCodegenDevice(runtime, nextConfig, {
172
+ forceIosAppInstall: next.platform === 'ios' && hasConfiguredAppPath(nextConfig)
173
+ });
174
+ const previousDevice = device;
175
+ device = nextDevice;
176
+ selectedDevice = next;
177
+ await previousDevice.close().catch(() => undefined);
178
+ return {
179
+ device: next,
180
+ inspector: nextDevice.inspector(codegenInspectorOptions(next))
181
+ };
182
+ },
183
+ onListen: (port) => {
184
+ const url = `http://localhost:${port}`;
185
+ console.log(`\n${colors.bold('device')} ${selectedDevice.platform} ${selectedDevice.name} (${selectedDevice.id})`);
186
+ console.log(`${colors.bold('ui')} ${colors.green('live')} ${colors.dim(url)}`);
187
+ console.log(`${colors.bold('ready')} ${colors.yellow('pending')} ${colors.dim('the mirror becomes live on the first screen frame; the UI tree can finish loading separately')}`);
188
+ openBrowser(url);
189
+ }
190
+ });
191
+ }
192
+ catch (err) {
193
+ console.error(`\n${colors.red('Failed to start inspector server:')} ${String(err)}`);
194
+ await device.close().catch(() => undefined);
195
+ process.exitCode = 1;
196
+ return;
197
+ }
198
+ // Guard against re-entrancy: an impatient user mashing Ctrl-C would
199
+ // otherwise fire cleanup() repeatedly, each call re-attaching exit/error
200
+ // listeners to the agent ChildProcess (the MaxListenersExceededWarning) and
201
+ // racing multiple teardown chains.
202
+ let cleaningUp = false;
203
+ const cleanup = async () => {
204
+ if (cleaningUp) {
205
+ return;
206
+ }
207
+ cleaningUp = true;
208
+ handle.close();
209
+ await device.close().catch(() => undefined);
210
+ process.exit(0);
211
+ };
212
+ process.once('SIGINT', cleanup);
213
+ process.once('SIGTERM', cleanup);
214
+ await new Promise(() => undefined);
215
+ return;
216
+ }
217
+ // ── CLI / JSON path ────────────────────────────────────────────────────────
218
+ const bootstrap = await bootstrapCodegen(runtime, selected, parsed);
219
+ if (parsed.json) {
220
+ console.log(JSON.stringify({
221
+ command: 'codegen',
222
+ selectedDevice: {
223
+ id: bootstrap.selected.id,
224
+ name: bootstrap.selected.name,
225
+ platform: bootstrap.selected.platform,
226
+ kind: bootstrap.selected.kind,
227
+ state: bootstrap.selected.state
228
+ },
229
+ app: {
230
+ launched: bootstrap.launched,
231
+ warning: bootstrap.launchWarning
232
+ },
233
+ tree: {
234
+ nodes: bootstrap.treeNodeCount,
235
+ visibleNodes: bootstrap.visibleNodeCount
236
+ },
237
+ suggestion: bootstrap.suggestion?.code,
238
+ suggestions: bootstrap.suggestions.map((candidate) => ({
239
+ code: candidate.code,
240
+ score: candidate.score
241
+ }))
242
+ }, null, 2));
243
+ await bootstrap.device.close().catch(() => undefined);
244
+ return;
245
+ }
246
+ await bootstrap.device.close().catch(() => undefined);
247
+ printHeader('codegen', 'Playwright-style mobile inspector bootstrap');
248
+ console.log(`\n${colors.bold('device')} ${bootstrap.selected.platform} ${bootstrap.selected.name} (${bootstrap.selected.id})`);
249
+ console.log(`${colors.bold('mirror')} ready for inspector frontend attachment`);
250
+ if (bootstrap.launched) {
251
+ console.log(`${colors.bold('app')} launched on selected device`);
252
+ }
253
+ else if (bootstrap.launchWarning) {
254
+ console.log(`${colors.bold('app')} ${colors.yellow('launch skipped')} ${colors.dim(`(${bootstrap.launchWarning})`)}`);
255
+ }
256
+ else {
257
+ console.log(`${colors.bold('app')} ${colors.dim('not launched (pass --app or --app-id for automatic launch)')}`);
258
+ }
259
+ console.log(`${colors.bold('tree')} ${bootstrap.treeNodeCount} nodes (${bootstrap.visibleNodeCount} visible)`);
260
+ if (bootstrap.suggestion) {
261
+ console.log(`${colors.bold('locator')} ${colors.green(bootstrap.suggestion.code)} ${colors.dim(`score=${bootstrap.suggestion.score}`)}`);
262
+ }
263
+ console.log(`\n${colors.bold('next')} start recording interactions in the inspector UI layer and convert actions into Astur codegen snippets.`);
264
+ }
265
+ function runPlaywright(args) {
266
+ const bin = process.platform === 'win32' ? 'npx.cmd' : 'npx';
267
+ const child = spawn(bin, ['playwright', 'test', ...args], {
268
+ stdio: 'inherit',
269
+ shell: false
270
+ });
271
+ return new Promise((resolve, reject) => {
272
+ child.on('error', reject);
273
+ child.on('exit', (code, signal) => {
274
+ if (signal) {
275
+ reject(new Error(`Playwright was terminated by ${signal}.`));
276
+ return;
277
+ }
278
+ process.exitCode = code ?? 1;
279
+ resolve();
280
+ });
281
+ });
282
+ }
283
+ async function writeIfMissing(path, contents) {
284
+ try {
285
+ await access(path, constants.F_OK);
286
+ console.log(`Skipped existing ${path}`);
287
+ return;
288
+ }
289
+ catch {
290
+ await mkdir(dirname(path), { recursive: true });
291
+ await writeFile(path, contents, 'utf8');
292
+ console.log(`Created ${path}`);
293
+ }
294
+ }
295
+ function printChecks(checks, options = {}) {
296
+ printHeader('doctor', 'Environment diagnostics');
297
+ for (const [group, groupChecks] of groupChecksByPlatform(checks)) {
298
+ console.log(`\n${colors.dim('◦')} ${colors.bold(group)}`);
299
+ for (const check of groupChecks) {
300
+ const status = renderStatus(check.status);
301
+ const message = options.verbose ? check.message : compactMessage(check.message);
302
+ console.log(` ${status} ${colors.bold(check.label.padEnd(18))} ${message}`);
303
+ if (!options.verbose && check.message !== message) {
304
+ console.log(` ${colors.dim('details hidden; run astur doctor --verbose')}`);
305
+ }
306
+ if (check.fix) {
307
+ console.log(` ${colors.dim('fix')} ${colors.yellow(check.fix)}`);
308
+ }
309
+ }
310
+ }
311
+ const failures = checks.filter((check) => check.status === 'fail').length;
312
+ const warnings = checks.filter((check) => check.status === 'warn').length;
313
+ const skipped = checks.filter((check) => check.status === 'skip').length;
314
+ const skippedSuffix = skipped > 0 ? `, ${skipped} skipped` : '';
315
+ const summary = failures > 0
316
+ ? colors.red(`${failures} failed, ${warnings} warning(s)${skippedSuffix}`)
317
+ : warnings > 0
318
+ ? colors.yellow(`${warnings} warning(s)${skippedSuffix}, ready with limits`)
319
+ : skipped > 0
320
+ ? colors.cyan(`${skipped} skipped, ready for supported platforms`)
321
+ : colors.green('all checks passed');
322
+ console.log(`\n${colors.dim('summary')} ${summary}`);
323
+ printNextSteps(checks);
324
+ }
325
+ function printDevices(devices) {
326
+ printHeader('devices', 'Connected devices and available simulators');
327
+ if (!devices.length) {
328
+ console.log(`\n${colors.yellow('No devices found.')}`);
329
+ console.log(colors.dim('Start an Android emulator, connect a device, or install an iOS simulator runtime.'));
330
+ return;
331
+ }
332
+ console.log('');
333
+ for (const device of devices) {
334
+ const version = device.osVersion ? ` ${device.osVersion}` : '';
335
+ const state = renderDeviceState(device.state);
336
+ const platform = device.platform === 'android' ? colors.green('android') : colors.cyan('ios');
337
+ console.log(` ${platform.padEnd(16)} ${colors.dim(device.kind.padEnd(9))} ${state} ${colors.dim(device.id)} ${device.name}${version}`);
338
+ }
339
+ }
340
+ function help() {
341
+ printHeader('cli', 'Native mobile automation');
342
+ console.log(`
343
+ ${colors.bold('Usage')}
344
+ astur doctor Check Android, iOS, and agent prerequisites
345
+ astur doctor --verbose
346
+ astur devices List connected devices and available simulators
347
+ astur init Create starter Playwright config and test interactively
348
+ astur init --yes Create starter files with Android emulator defaults
349
+ astur codegen Bootstrap runtime-backed inspector/codegen session
350
+ astur test [args] Run Playwright Test
351
+ astur inspect Alias for astur codegen
352
+ `);
353
+ }
354
+ function printCodegenHelp() {
355
+ printHeader('codegen', 'Playwright-style mobile inspector bootstrap');
356
+ console.log(`
357
+ ${colors.bold('Usage')}
358
+ astur codegen [options]
359
+
360
+ ${colors.bold('Options')}
361
+ --android Target Android devices only
362
+ --ios Target iOS devices only
363
+ --emulator Target Android emulators only
364
+ --simulator Target iOS simulators only
365
+ --real Target real devices only
366
+ --platform <name> Explicit platform: android or ios
367
+ --device <id> Prefer a specific device id/UDID
368
+ --app <path> App path to install/use for launch (.apk, .app, .ipa)
369
+ --app-id <id> Installed package (Android) or bundle id (iOS)
370
+ --ui Open Astur Inspector UI (default)
371
+ --no-ui Print CLI summary only
372
+ --no-launch Skip app launch even if app metadata is available
373
+ --json Print machine-readable bootstrap output
374
+ -h, --help Show this help
375
+ `);
376
+ }
377
+ function printHeader(command, subtitle) {
378
+ console.log(`${colors.cyan('Astur')} ${colors.dim('›')} ${colors.bold(command)}`);
379
+ console.log(colors.dim(subtitle));
380
+ }
381
+ function groupChecksByPlatform(checks) {
382
+ const groups = new Map();
383
+ for (const check of checks) {
384
+ const key = check.id.startsWith('android.')
385
+ ? 'Android'
386
+ : check.id.startsWith('ios.')
387
+ ? 'iOS'
388
+ : 'General';
389
+ groups.set(key, [...(groups.get(key) ?? []), check]);
390
+ }
391
+ return [...groups.entries()];
392
+ }
393
+ function renderStatus(status) {
394
+ switch (status) {
395
+ case 'pass':
396
+ return colors.green('✓ PASS');
397
+ case 'warn':
398
+ return colors.yellow('⚠ WARN');
399
+ case 'fail':
400
+ return colors.red('✕ FAIL');
401
+ case 'skip':
402
+ return colors.cyan('• SKIP');
403
+ }
404
+ }
405
+ function renderDeviceState(state) {
406
+ switch (state) {
407
+ case 'online':
408
+ case 'booted':
409
+ return colors.green(state.padEnd(8));
410
+ case 'offline':
411
+ case 'unauthorized':
412
+ return colors.red(state.padEnd(8));
413
+ case 'shutdown':
414
+ return colors.yellow(state.padEnd(8));
415
+ case 'unknown':
416
+ return colors.dim(state.padEnd(8));
417
+ }
418
+ }
419
+ function compactMessage(message) {
420
+ const lines = message
421
+ .split(/\r?\n/)
422
+ .map((line) => line.trim())
423
+ .filter(Boolean);
424
+ return lines[0] ?? message;
425
+ }
426
+ function printNextSteps(checks) {
427
+ const actionable = checks.filter((check) => check.status !== 'pass' && check.fix);
428
+ if (!actionable.length) {
429
+ return;
430
+ }
431
+ console.log(`\n${colors.bold('next steps')}`);
432
+ for (const [index, check] of actionable.entries()) {
433
+ console.log(` ${index + 1}. ${colors.bold(check.label)}: ${check.fix}`);
434
+ }
435
+ }
436
+ function supportsLocalIos() {
437
+ return process.platform === 'darwin';
438
+ }
439
+ function parseCodegenArgs(args) {
440
+ const parsed = {
441
+ help: false,
442
+ json: false,
443
+ ui: true,
444
+ launch: true
445
+ };
446
+ for (let index = 0; index < args.length; index += 1) {
447
+ const token = args[index];
448
+ switch (token) {
449
+ case '-h':
450
+ case '--help':
451
+ parsed.help = true;
452
+ break;
453
+ case '--json':
454
+ parsed.json = true;
455
+ parsed.ui = false;
456
+ break;
457
+ case '--ui':
458
+ parsed.ui = true;
459
+ break;
460
+ case '--no-ui':
461
+ parsed.ui = false;
462
+ break;
463
+ case '--launch':
464
+ parsed.launch = true;
465
+ break;
466
+ case '--no-launch':
467
+ parsed.launch = false;
468
+ break;
469
+ case '--android':
470
+ parsed.platform = 'android';
471
+ break;
472
+ case '--ios':
473
+ parsed.platform = 'ios';
474
+ break;
475
+ case '--emulator':
476
+ parsed.platform = 'android';
477
+ parsed.deviceKind = 'emulator';
478
+ break;
479
+ case '--simulator':
480
+ parsed.platform = 'ios';
481
+ parsed.deviceKind = 'simulator';
482
+ break;
483
+ case '--real':
484
+ parsed.deviceKind = 'real';
485
+ break;
486
+ case '--platform': {
487
+ const value = args[index + 1];
488
+ if (!value) {
489
+ throw new Error('--platform requires a value: android or ios');
490
+ }
491
+ if (value !== 'android' && value !== 'ios') {
492
+ throw new Error(`Unsupported --platform value: ${value}`);
493
+ }
494
+ parsed.platform = value;
495
+ index += 1;
496
+ break;
497
+ }
498
+ case '--device': {
499
+ const value = args[index + 1];
500
+ if (!value) {
501
+ throw new Error('--device requires a value');
502
+ }
503
+ parsed.deviceId = value;
504
+ index += 1;
505
+ break;
506
+ }
507
+ case '--app': {
508
+ const value = args[index + 1];
509
+ if (!value) {
510
+ throw new Error('--app requires a value');
511
+ }
512
+ parsed.appPath = value;
513
+ index += 1;
514
+ break;
515
+ }
516
+ case '--app-id': {
517
+ const value = args[index + 1];
518
+ if (!value) {
519
+ throw new Error('--app-id requires a value');
520
+ }
521
+ parsed.appId = value;
522
+ index += 1;
523
+ break;
524
+ }
525
+ default:
526
+ if (token.startsWith('-')) {
527
+ throw new Error(`Unknown codegen option: ${token}`);
528
+ }
529
+ throw new Error(`Unexpected positional argument for codegen: ${token}`);
530
+ }
531
+ }
532
+ return parsed;
533
+ }
534
+ function selectCodegenDevice(devices, deviceId, deviceKind) {
535
+ const isReady = (device) => device.state === 'online' || device.state === 'booted';
536
+ const isSelectable = (device) => isReady(device) || (deviceKind === 'simulator' && device.state === 'shutdown');
537
+ const matchesKind = (device) => !deviceKind || device.kind === deviceKind;
538
+ if (deviceId) {
539
+ return devices.find((device) => device.id === deviceId && matchesKind(device) && isSelectable(device));
540
+ }
541
+ return devices.find((device) => matchesKind(device) && isReady(device))
542
+ ?? devices.find((device) => matchesKind(device) && isSelectable(device));
543
+ }
544
+ async function bootstrapCodegen(runtime, selected, parsed) {
545
+ const config = buildCodegenConfig(selected, parsed);
546
+ const device = await runtime.createDevice(config);
547
+ let launched = false;
548
+ let launchWarning;
549
+ try {
550
+ if (parsed.launch && config.app) {
551
+ await device.app.launch();
552
+ launched = true;
553
+ }
554
+ else if (parsed.launch && !config.app) {
555
+ launchWarning = 'no app was provided';
556
+ }
557
+ const inspector = device.inspector({ pollIntervalMs: 500 });
558
+ const update = await firstTreeUpdate(inspector);
559
+ const nodes = flattenTree(update.root);
560
+ const locatorContext = await bestLocatorContext(device, update.root);
561
+ return {
562
+ selected,
563
+ launched,
564
+ launchWarning,
565
+ tree: update.root,
566
+ treeNodeCount: nodes.length,
567
+ visibleNodeCount: nodes.filter((node) => node.visible).length,
568
+ suggestions: locatorContext?.suggestions ?? [],
569
+ suggestion: locatorContext?.suggestions[0],
570
+ device
571
+ };
572
+ }
573
+ catch (err) {
574
+ await device.close().catch(() => undefined);
575
+ throw err;
576
+ }
577
+ }
578
+ function buildCodegenConfig(selected, parsed) {
579
+ const appId = parsed.appId ?? defaultCodegenAppId(selected.platform);
580
+ const config = {
581
+ platform: selected.platform,
582
+ device: {
583
+ id: selected.id
584
+ }
585
+ };
586
+ if (!parsed.appPath && !appId) {
587
+ return config;
588
+ }
589
+ if (selected.platform === 'android') {
590
+ config.app = {
591
+ path: parsed.appPath,
592
+ packageName: appId
593
+ };
594
+ return config;
595
+ }
596
+ config.app = {
597
+ path: parsed.appPath,
598
+ bundleId: appId
599
+ };
600
+ config.timeout = 60_000;
601
+ config.agent = {
602
+ mode: 'required',
603
+ install: true,
604
+ legacyFallback: 'never',
605
+ launchTimeout: 60_000,
606
+ commandTimeout: 20_000
607
+ };
608
+ return config;
609
+ }
610
+ function printCodegenPreparation(selected, config) {
611
+ const details = codegenPreparationDetails(selected, config);
612
+ console.log(`\n${colors.bold('status')} ${colors.yellow('preparing')} ${details.title}`);
613
+ for (const detail of details.notes) {
614
+ console.log(` ${colors.dim(detail)}`);
615
+ }
616
+ }
617
+ function codegenPreparationDetails(selected, config) {
618
+ if (selected.platform === 'ios') {
619
+ const notes = [
620
+ 'Astur is installing/starting the bundled XCUITest agent and preparing the app.',
621
+ 'The mirror becomes usable after the first screen frame; the UI tree may continue loading separately.'
622
+ ];
623
+ if (selected.kind === 'real') {
624
+ notes.unshift('First real-device runs can take a few minutes while Xcode builds and signs the agent.');
625
+ }
626
+ if (!config.app) {
627
+ notes.push('No app was provided, so Astur will inspect the currently available device state.');
628
+ }
629
+ return {
630
+ title: selected.kind === 'real' ? 'iOS real device' : 'iOS simulator',
631
+ notes
632
+ };
633
+ }
634
+ return {
635
+ title: selected.kind === 'emulator' ? 'Android emulator' : 'Android device',
636
+ notes: [
637
+ 'Astur is connecting to the device, preparing automation services, and launching the app when configured.',
638
+ 'The mirror becomes usable after the first screen frame; the UI tree may continue loading separately.'
639
+ ]
640
+ };
641
+ }
642
+ function codegenInspectorOptions(device) {
643
+ return {
644
+ pollIntervalMs: device.platform === 'ios' ? 2_000 : 500
645
+ };
646
+ }
647
+ function formatCodegenConnectionError(error) {
648
+ if (!(error instanceof AsturError)) {
649
+ return error instanceof Error ? error.message : String(error);
650
+ }
651
+ const lines = [`${error.code}: ${error.message}`];
652
+ const details = isRecord(error.details) ? error.details : undefined;
653
+ const bundleId = typeof details?.bundleId === 'string' ? details.bundleId : undefined;
654
+ switch (error.code) {
655
+ case 'IOS_APP_INSTALL_SIGNATURE_INVALID':
656
+ lines.push('Fix: rebuild or re-sign the IPA with a non-expired provisioning profile that includes this iPhone UDID, then run codegen again.');
657
+ break;
658
+ case 'IOS_APP_SIGNATURE_NOT_TRUSTED':
659
+ lines.push('Fix: install an IPA signed for this iPhone UDID, trust the developer profile on the phone, and make sure --app-id matches the IPA bundle id.');
660
+ if (bundleId) {
661
+ lines.push(`Bundle id: ${bundleId}`);
662
+ }
663
+ break;
664
+ case 'IOS_DEVELOPMENT_TEAM_REQUIRED':
665
+ lines.push('Fix: set ASTUR_IOS_DEVELOPMENT_TEAM to your Apple development team id, then run codegen again.');
666
+ break;
667
+ case 'IOS_SIGNING_KEYCHAIN_LOCKED':
668
+ lines.push('Fix: unlock the macOS login keychain or allow codesign/Xcode access to the Apple Development certificate.');
669
+ break;
670
+ case 'IOS_AGENT_HOST_REQUIRED':
671
+ lines.push('Fix: set ASTUR_IOS_AGENT_HOST to a Mac IP address reachable by the iPhone.');
672
+ break;
673
+ }
674
+ const commandOutput = typeof details?.commandOutput === 'string' ? details.commandOutput.trim() : '';
675
+ if (commandOutput) {
676
+ const tail = commandOutput.split(/\r?\n/).slice(-12).join('\n');
677
+ lines.push(`${colors.dim('install output:')}\n${tail}`);
678
+ }
679
+ const xcodebuildOutput = typeof details?.xcodebuildOutput === 'string' ? details.xcodebuildOutput.trim() : '';
680
+ if (xcodebuildOutput) {
681
+ const tail = xcodebuildOutput.split(/\r?\n/).slice(-12).join('\n');
682
+ lines.push(`${colors.dim('xcodebuild tail:')}\n${tail}`);
683
+ }
684
+ return lines.join('\n');
685
+ }
686
+ function isRecord(value) {
687
+ return typeof value === 'object' && value !== null;
688
+ }
689
+ async function createCodegenDevice(runtime, config, options = {}) {
690
+ if (!options.forceIosAppInstall || config.platform !== 'ios') {
691
+ return runtime.createDevice(config);
692
+ }
693
+ const previous = process.env.ASTUR_IOS_APP_FORCE_INSTALL;
694
+ process.env.ASTUR_IOS_APP_FORCE_INSTALL = '1';
695
+ try {
696
+ return await runtime.createDevice(config);
697
+ }
698
+ finally {
699
+ if (previous === undefined) {
700
+ delete process.env.ASTUR_IOS_APP_FORCE_INSTALL;
701
+ }
702
+ else {
703
+ process.env.ASTUR_IOS_APP_FORCE_INSTALL = previous;
704
+ }
705
+ }
706
+ }
707
+ function hasConfiguredAppPath(config) {
708
+ return typeof config.app === 'string'
709
+ ? config.app.length > 0
710
+ : Boolean(config.app?.path);
711
+ }
712
+ function defaultCodegenAppId(platform) {
713
+ if (process.env.ASTUR_CODEGEN_APP_ID) {
714
+ return process.env.ASTUR_CODEGEN_APP_ID;
715
+ }
716
+ if (platform === 'android') {
717
+ return process.env.ASTUR_ANDROID_PACKAGE_NAME ?? process.env.ASTUR_ANDROID_APP_ID;
718
+ }
719
+ return process.env.ASTUR_IOS_BUNDLE_ID
720
+ ?? process.env.ASTUR_AUT_BUNDLE_ID
721
+ ?? 'com.astur.demo';
722
+ }
723
+ async function runInspectorDeviceAction(device, action) {
724
+ switch (action) {
725
+ case 'refresh':
726
+ case 'tree.refresh':
727
+ return;
728
+ case 'orientation.portrait':
729
+ await device.orientation.portrait();
730
+ return;
731
+ case 'orientation.landscape':
732
+ await device.orientation.landscape();
733
+ return;
734
+ case 'keyboard.dismiss':
735
+ await device.keyboard.dismiss();
736
+ return;
737
+ case 'device.lock':
738
+ await device.system.lock();
739
+ return;
740
+ case 'device.unlock':
741
+ await device.system.unlock();
742
+ return;
743
+ case 'navigation.back':
744
+ await device.navigation.back();
745
+ return;
746
+ case 'navigation.home':
747
+ await device.navigation.home();
748
+ return;
749
+ case 'navigation.recents':
750
+ await device.navigation.recentApps();
751
+ return;
752
+ }
753
+ }
754
+ async function runInspectorAppAction(device, action, options) {
755
+ switch (action) {
756
+ case 'launch': {
757
+ const identifier = requireInspectorAppIdentifier(options.identifier);
758
+ await device.app.launch({
759
+ app: device.deviceInfo.platform === 'android'
760
+ ? { packageName: identifier }
761
+ : { bundleId: identifier }
762
+ });
763
+ return;
764
+ }
765
+ case 'clearData':
766
+ await device.app.clearData(requireInspectorAppIdentifier(options.identifier));
767
+ return;
768
+ case 'clearCache':
769
+ await device.app.clearCache(requireInspectorAppIdentifier(options.identifier));
770
+ return;
771
+ case 'grantPermission':
772
+ await device.permissions.grant(requireInspectorPermission(options.permission), requireInspectorAppIdentifier(options.identifier));
773
+ return;
774
+ case 'revokePermission':
775
+ await device.permissions.revoke(requireInspectorPermission(options.permission), requireInspectorAppIdentifier(options.identifier));
776
+ return;
777
+ }
778
+ }
779
+ function requireInspectorAppIdentifier(identifier) {
780
+ const value = identifier?.trim();
781
+ if (!value) {
782
+ throw new Error('Package or bundle id is required.');
783
+ }
784
+ return value;
785
+ }
786
+ function requireInspectorPermission(permission) {
787
+ const value = permission?.trim();
788
+ if (!value) {
789
+ throw new Error('Permission is required.');
790
+ }
791
+ return value;
792
+ }
793
+ async function firstTreeUpdate(inspector) {
794
+ for await (const update of inspector.subscribeTree({ maxUpdates: 1 })) {
795
+ return update;
796
+ }
797
+ throw new Error('Failed to receive an initial UI tree update for codegen bootstrap.');
798
+ }
799
+ async function bestLocatorContext(device, root) {
800
+ const target = flattenTree(root).find((node) => {
801
+ return node.visible
802
+ && node.enabled
803
+ && node.type !== 'android.root'
804
+ && node.type !== 'ios.root'
805
+ && Boolean(node.id || node.label || node.text);
806
+ });
807
+ if (!target) {
808
+ return undefined;
809
+ }
810
+ const selector = selectorFromNode(target);
811
+ if (!selector) {
812
+ return undefined;
813
+ }
814
+ const suggestions = await device.suggestLocators(selector);
815
+ if (!suggestions.length) {
816
+ return undefined;
817
+ }
818
+ return { suggestions };
819
+ }
820
+ function openBrowser(url) {
821
+ try {
822
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
823
+ spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
824
+ }
825
+ catch {
826
+ // ignore
827
+ }
828
+ }
829
+ function selectorFromNode(node) {
830
+ if (node.id) {
831
+ return {
832
+ strategy: 'id',
833
+ value: node.id,
834
+ exact: true
835
+ };
836
+ }
837
+ if (node.label) {
838
+ return {
839
+ strategy: 'accessibility',
840
+ value: node.label,
841
+ exact: true
842
+ };
843
+ }
844
+ if (node.text) {
845
+ return {
846
+ strategy: 'text',
847
+ value: node.text,
848
+ exact: true
849
+ };
850
+ }
851
+ return undefined;
852
+ }
853
+ function flattenTree(root) {
854
+ return [root, ...root.children.flatMap((child) => flattenTree(child))];
855
+ }
856
+ function createIosSkipCheck() {
857
+ const host = hostName();
858
+ return {
859
+ id: 'ios.platform',
860
+ label: 'iOS platform',
861
+ status: 'skip',
862
+ message: `Local iOS automation requires macOS with Xcode. Current host is ${host}.`,
863
+ fix: 'Use macOS for iOS simulators/devices, or use Android-only checks on this host.'
864
+ };
865
+ }
866
+ function hostName() {
867
+ switch (process.platform) {
868
+ case 'win32':
869
+ return 'Windows';
870
+ case 'linux':
871
+ return 'Linux';
872
+ case 'darwin':
873
+ return 'macOS';
874
+ default:
875
+ return process.platform;
876
+ }
877
+ }
878
+ const shouldColor = process.env.NO_COLOR === undefined && process.stdout.isTTY;
879
+ const colors = {
880
+ bold: (value) => color(value, '1'),
881
+ dim: (value) => color(value, '2'),
882
+ green: (value) => color(value, '32'),
883
+ yellow: (value) => color(value, '33'),
884
+ red: (value) => color(value, '31'),
885
+ cyan: (value) => color(value, '36')
886
+ };
887
+ function color(value, code) {
888
+ return shouldColor ? `\u001b[${code}m${value}\u001b[0m` : value;
889
+ }
890
+ function isDirectEntry() {
891
+ if (!process.argv[1]) {
892
+ return false;
893
+ }
894
+ try {
895
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
896
+ }
897
+ catch {
898
+ return fileURLToPath(import.meta.url) === process.argv[1];
899
+ }
900
+ }
901
+ export const __testing = {
902
+ printChecks,
903
+ buildInitFiles,
904
+ defaultInitAnswers,
905
+ parseCodegenArgs,
906
+ selectCodegenDevice,
907
+ buildCodegenConfig,
908
+ codegenPreparationDetails,
909
+ inspectorServer: inspectorServerTesting
910
+ };
911
+ if (isDirectEntry()) {
912
+ main().catch((error) => {
913
+ console.error(error instanceof Error ? error.message : String(error));
914
+ process.exitCode = 1;
915
+ });
916
+ }
917
+ //# sourceMappingURL=index.js.map