@unrulysystems/rn-playwright-driver-runner 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs ADDED
@@ -0,0 +1,1891 @@
1
+ import { mkdtemp, readFile, writeFile, chmod, rm } from 'fs/promises';
2
+ import net from 'net';
3
+ import { tmpdir } from 'os';
4
+ import path from 'path';
5
+ import { promisify, parseArgs } from 'util';
6
+ import { existsSync, createReadStream, openSync } from 'fs';
7
+ import { pathToFileURL } from 'url';
8
+ import { execFile, spawn } from 'child_process';
9
+ import { randomBytes } from 'crypto';
10
+
11
+ // src/cli.ts
12
+
13
+ // src/constants.ts
14
+ var ENV = {
15
+ metroUrl: "RN_METRO_URL",
16
+ deviceName: "RN_DEVICE_NAME",
17
+ timeout: "RN_TIMEOUT",
18
+ touchBackend: "RN_TOUCH_BACKEND",
19
+ xctestPort: "RN_TOUCH_XCTEST_PORT",
20
+ xctestTokenFile: "RN_TOUCH_XCTEST_TOKEN_FILE",
21
+ instrumentationPort: "RN_TOUCH_INSTRUMENTATION_PORT",
22
+ instrumentationTokenFile: "RN_TOUCH_INSTRUMENTATION_TOKEN_FILE",
23
+ androidSerial: "ANDROID_SERIAL"
24
+ };
25
+ var TOUCH_BACKEND = {
26
+ ios: "xctest",
27
+ android: "instrumentation"
28
+ };
29
+ var DEFAULTS = {
30
+ metroHost: "127.0.0.1",
31
+ metroPort: 8081,
32
+ metroReadyTimeoutMs: 9e4,
33
+ companionPort: 9999,
34
+ /** Covers a cold `xcodebuild test` build, not just process startup (FU-2). */
35
+ iosCompanionReadyTimeoutMs: 3e5,
36
+ androidCompanionReadyTimeoutMs: 45e3,
37
+ hermesTargetTimeoutMs: 6e4,
38
+ appLaunchAttempts: 3,
39
+ driverTimeoutMs: 3e4,
40
+ androidGradleTasks: [":app:assembleDebug", ":app:assembleDebugAndroidTest"],
41
+ androidAppApkPath: "android/app/build/outputs/apk/debug/app-debug.apk",
42
+ androidTestApkPath: "android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk",
43
+ instrumentationClass: "com.rndriver.touchcompanion.RNDriverTouchCompanion",
44
+ /** UI-test method the iOS companion exposes as a long-running server. */
45
+ xctestServerTest: "RNDriverTouchCompanionTests/testRunServer",
46
+ /** Device-private filename the Android companion reads its token from. */
47
+ androidTokenFileName: "rn-driver-touch-token"
48
+ };
49
+ var SECRET_PLACEHOLDER = "<token-file>";
50
+
51
+ // src/plan/env.ts
52
+ function buildIosDriverEnv(resolved, metro, timeoutMs) {
53
+ return {
54
+ [ENV.touchBackend]: TOUCH_BACKEND.ios,
55
+ [ENV.metroUrl]: metro.url,
56
+ [ENV.deviceName]: resolved.simName,
57
+ [ENV.timeout]: String(timeoutMs ?? DEFAULTS.driverTimeoutMs),
58
+ [ENV.xctestPort]: String(resolved.touchPort),
59
+ [ENV.xctestTokenFile]: resolved.tokenFile
60
+ };
61
+ }
62
+ function buildAndroidDriverEnv(resolved, metro, deviceName, timeoutMs) {
63
+ return {
64
+ [ENV.touchBackend]: TOUCH_BACKEND.android,
65
+ [ENV.metroUrl]: metro.url,
66
+ [ENV.deviceName]: deviceName,
67
+ [ENV.androidSerial]: resolved.serial,
68
+ [ENV.timeout]: String(timeoutMs ?? DEFAULTS.driverTimeoutMs),
69
+ [ENV.instrumentationPort]: String(resolved.touchPort),
70
+ [ENV.instrumentationTokenFile]: resolved.tokenFile
71
+ };
72
+ }
73
+
74
+ // src/plan/shared.ts
75
+ function cmd(command, args) {
76
+ return { command, args };
77
+ }
78
+ function npx(args) {
79
+ return { command: "npx", args };
80
+ }
81
+ function shell(command) {
82
+ return { command: "sh", args: ["-c", command] };
83
+ }
84
+ function metroStartStep(metro) {
85
+ return {
86
+ id: "metro.start",
87
+ stage: "metro",
88
+ description: metro.reuseExisting ? `Start Metro (reuse if running) ${metro.url}` : `Start Metro ${metro.url}`,
89
+ action: {
90
+ type: "command",
91
+ background: true,
92
+ processKey: "metro",
93
+ command: shell(metro.command ?? `npx expo start --localhost --port ${metro.port}`)
94
+ }
95
+ };
96
+ }
97
+ function playwrightCommand(playwright, specs, passthrough) {
98
+ const args = ["playwright", "test"];
99
+ if (playwright?.config) args.push("--config", playwright.config);
100
+ const effectiveSpecs = specs.length > 0 ? specs : playwright?.specs ?? [];
101
+ args.push(...effectiveSpecs, ...passthrough);
102
+ args.push("--reporter=line");
103
+ return npx(args);
104
+ }
105
+
106
+ // src/plan/android.ts
107
+ function planAndroid(input) {
108
+ const { android, metro, resolved, playwright, timeoutMs, specs, passthrough, hermesDeviceName } = input;
109
+ const serial = resolved.serial;
110
+ const gradleTasks = android.gradleTasks ?? [...DEFAULTS.androidGradleTasks];
111
+ const appApk = android.appApkPath ?? DEFAULTS.androidAppApkPath;
112
+ const testApk = android.testApkPath ?? DEFAULTS.androidTestApkPath;
113
+ const steps = [];
114
+ const push = (step) => steps.push(step);
115
+ push({
116
+ id: "android.prebuild",
117
+ stage: "build",
118
+ description: "Generate Android project (expo prebuild)",
119
+ action: {
120
+ type: "command",
121
+ command: npx(["expo", "prebuild", "--platform", "android", "--no-install"])
122
+ },
123
+ skippable: true
124
+ });
125
+ push({
126
+ id: "android.gradle",
127
+ stage: "build",
128
+ description: `Build app + androidTest APKs (${gradleTasks.join(" ")})`,
129
+ action: {
130
+ type: "command",
131
+ command: { command: "./gradlew", args: gradleTasks, cwd: "android" }
132
+ },
133
+ skippable: true
134
+ });
135
+ push({
136
+ id: "android.install-app",
137
+ stage: "build",
138
+ description: `Install app APK`,
139
+ action: { type: "command", command: adb(serial, ["install", "-r", appApk]) },
140
+ skippable: true
141
+ });
142
+ push({
143
+ id: "android.install-test",
144
+ stage: "build",
145
+ description: `Install androidTest APK`,
146
+ action: { type: "command", command: adb(serial, ["install", "-r", "-t", testApk]) },
147
+ skippable: true
148
+ });
149
+ push({
150
+ id: "android.install-token",
151
+ stage: "companion",
152
+ description: "Install companion token into app private files",
153
+ action: {
154
+ type: "command",
155
+ command: {
156
+ ...adbShellScript(
157
+ serial,
158
+ `run-as ${android.packageName} sh -c 'mkdir -p files && cat > files/${resolved.deviceTokenFileName} && chmod 600 files/${resolved.deviceTokenFileName}'`
159
+ ),
160
+ stdinFromFile: resolved.tokenFile
161
+ }
162
+ }
163
+ });
164
+ push(metroStartStep(metro));
165
+ push({
166
+ id: "metro.ready",
167
+ stage: "metro",
168
+ description: `Wait for Metro at ${metro.url}`,
169
+ action: {
170
+ type: "probe",
171
+ probe: { kind: "metro-status", metroUrl: metro.url, timeoutMs: metro.readyTimeoutMs }
172
+ }
173
+ });
174
+ push({
175
+ id: "android.reverse-metro",
176
+ stage: "device",
177
+ description: `adb reverse tcp:${metro.port}`,
178
+ action: {
179
+ type: "command",
180
+ command: adb(serial, ["reverse", `tcp:${metro.port}`, `tcp:${metro.port}`])
181
+ }
182
+ });
183
+ if (metro.port !== DEFAULTS.metroPort) {
184
+ push({
185
+ id: "android.reverse-default",
186
+ stage: "device",
187
+ description: `adb reverse tcp:${DEFAULTS.metroPort} -> tcp:${metro.port}`,
188
+ action: {
189
+ type: "command",
190
+ command: adb(serial, ["reverse", `tcp:${DEFAULTS.metroPort}`, `tcp:${metro.port}`])
191
+ }
192
+ });
193
+ }
194
+ push({
195
+ id: "android.debug-host",
196
+ stage: "device",
197
+ description: "Write app debug_http_host",
198
+ action: {
199
+ type: "command",
200
+ command: {
201
+ // Single adb-shell arg so the redirect runs inside the run-as'd app-uid
202
+ // shell (the shared_prefs path is app-private; the outer `shell` uid
203
+ // cannot write it).
204
+ ...adbShellScript(
205
+ serial,
206
+ `run-as ${android.packageName} sh -c 'mkdir -p /data/data/${android.packageName}/shared_prefs && cat > /data/data/${android.packageName}/shared_prefs/${android.packageName}_preferences.xml'`
207
+ ),
208
+ stdinContents: debugHostXml(`localhost:${metro.port}`)
209
+ }
210
+ }
211
+ });
212
+ const launchCommand = launchCommandFor(android, serial);
213
+ push(launchStep("android.launch-1", android, serial));
214
+ push(hermesStep("android.hermes-1", android, metro, resolved, hermesDeviceName, launchCommand));
215
+ push({
216
+ id: "android.forward-clean",
217
+ stage: "companion",
218
+ description: `Clear stale adb forward tcp:${resolved.touchPort}`,
219
+ action: {
220
+ type: "command",
221
+ command: adb(serial, ["forward", "--remove", `tcp:${resolved.touchPort}`]),
222
+ allowFailure: true
223
+ }
224
+ });
225
+ push({
226
+ id: "android.forward",
227
+ stage: "companion",
228
+ description: `adb forward tcp:${resolved.touchPort}`,
229
+ action: {
230
+ type: "command",
231
+ command: adb(serial, ["forward", `tcp:${resolved.touchPort}`, `tcp:${resolved.touchPort}`])
232
+ }
233
+ });
234
+ push({
235
+ id: "android.instrument-start",
236
+ stage: "companion",
237
+ description: `Start instrumentation companion on port ${resolved.touchPort}`,
238
+ action: {
239
+ type: "command",
240
+ background: true,
241
+ processKey: "companion",
242
+ command: adb(serial, [
243
+ "shell",
244
+ "am",
245
+ "instrument",
246
+ "-e",
247
+ "rnDriverAuthTokenFile",
248
+ resolved.deviceTokenFileName,
249
+ "-e",
250
+ "rnDriverPort",
251
+ String(resolved.touchPort),
252
+ "-w",
253
+ resolved.instrumentationTarget
254
+ ])
255
+ }
256
+ });
257
+ push({
258
+ id: "android.instrument-ready",
259
+ stage: "companion",
260
+ description: "Wait for companion to accept a hello",
261
+ action: {
262
+ type: "probe",
263
+ probe: {
264
+ kind: "instrumentation-hello",
265
+ port: resolved.touchPort,
266
+ tokenFile: resolved.tokenFile,
267
+ timeoutMs: resolved.companionReadyTimeoutMs
268
+ }
269
+ }
270
+ });
271
+ push(launchStep("android.launch-2", android, serial));
272
+ push(hermesStep("android.hermes-2", android, metro, resolved, hermesDeviceName, launchCommand));
273
+ const cleanup = [
274
+ {
275
+ type: "kill-process",
276
+ processKey: "companion",
277
+ description: "Stop instrumentation companion"
278
+ },
279
+ {
280
+ type: "command",
281
+ command: adb(serial, ["reverse", "--remove", `tcp:${metro.port}`]),
282
+ description: "Remove metro reverse"
283
+ },
284
+ // Mirror android.reverse-default: when Metro is off 8081 the plan also adds a
285
+ // `tcp:8081 -> tcp:<port>` fallback reverse, so cleanup must remove BOTH or a
286
+ // stale 8081 mapping wedges the next run (REQ-CLEAN-003).
287
+ ...metro.port === DEFAULTS.metroPort ? [] : [
288
+ {
289
+ type: "command",
290
+ command: adb(serial, ["reverse", "--remove", `tcp:${DEFAULTS.metroPort}`]),
291
+ description: "Remove fallback 8081 metro reverse"
292
+ }
293
+ ],
294
+ {
295
+ type: "command",
296
+ command: adb(serial, ["forward", "--remove", `tcp:${resolved.touchPort}`]),
297
+ description: "Remove companion forward"
298
+ },
299
+ {
300
+ type: "command",
301
+ command: adb(serial, [
302
+ "shell",
303
+ "run-as",
304
+ android.packageName,
305
+ "rm",
306
+ "-f",
307
+ `files/${resolved.deviceTokenFileName}`
308
+ ]),
309
+ description: "Remove device token file"
310
+ },
311
+ {
312
+ type: "command",
313
+ command: adb(serial, ["shell", "am", "force-stop", android.packageName]),
314
+ description: "Force-stop app"
315
+ },
316
+ { type: "kill-process", processKey: "metro", description: "Stop runner-owned Metro" },
317
+ { type: "remove-file", path: resolved.tokenFile, description: "Remove per-run token file" }
318
+ ];
319
+ return {
320
+ platform: "android",
321
+ steps,
322
+ cleanup,
323
+ driverEnv: buildAndroidDriverEnv(resolved, metro, hermesDeviceName, timeoutMs),
324
+ playwright: playwrightCommand(playwright, specs, passthrough)
325
+ };
326
+ }
327
+ function launchCommandFor(android, serial) {
328
+ return adb(serial, [
329
+ "shell",
330
+ "am",
331
+ "start",
332
+ "-W",
333
+ "-n",
334
+ `${android.packageName}/${android.activity}`
335
+ ]);
336
+ }
337
+ function launchStep(id, android, serial) {
338
+ return {
339
+ id,
340
+ stage: "app-launch",
341
+ description: `Launch ${android.packageName}/${android.activity}`,
342
+ action: { type: "command", command: launchCommandFor(android, serial) }
343
+ };
344
+ }
345
+ function hermesStep(id, android, metro, resolved, deviceNameMatch, launchCommand) {
346
+ return {
347
+ id,
348
+ stage: "hermes-target",
349
+ description: "Wait for Hermes target",
350
+ action: {
351
+ type: "probe",
352
+ probe: {
353
+ kind: "hermes-target",
354
+ platform: "android",
355
+ metroUrl: metro.url,
356
+ appId: android.packageName,
357
+ deviceNameMatch,
358
+ timeoutMs: resolved.hermesTimeoutMs
359
+ },
360
+ // REQ-AND-005: on a transient miss, re-issue `am start` and re-probe.
361
+ // `appLaunchAttempts` total attempts ⇒ that many minus the first = retries.
362
+ retry: { command: launchCommand, max: DEFAULTS.appLaunchAttempts - 1 }
363
+ }
364
+ };
365
+ }
366
+ function debugHostXml(host) {
367
+ return `<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
368
+ <map>
369
+ <string name="debug_http_host">${host}</string>
370
+ </map>
371
+ `;
372
+ }
373
+ function adb(serial, args) {
374
+ return { command: "adb", args: ["-s", serial, ...args] };
375
+ }
376
+ function adbShellScript(serial, remote) {
377
+ return { command: "adb", args: ["-s", serial, "shell", remote] };
378
+ }
379
+
380
+ // src/plan/ios.ts
381
+ function planIos(input) {
382
+ const { ios, metro, resolved, playwright, timeoutMs, specs, passthrough } = input;
383
+ const isDevClient = ios.launch.kind === "expo-dev-client";
384
+ const steps = [];
385
+ const push = (step) => steps.push(step);
386
+ push({
387
+ id: "ios.boot",
388
+ stage: "device",
389
+ description: `Boot simulator ${resolved.simName}`,
390
+ action: {
391
+ type: "command",
392
+ command: xcrun(["simctl", "boot", resolved.simUdid]),
393
+ allowFailure: true
394
+ }
395
+ });
396
+ push({
397
+ id: "ios.boot-wait",
398
+ stage: "device",
399
+ description: `Wait for ${resolved.simName} to finish booting`,
400
+ action: { type: "command", command: xcrun(["simctl", "bootstatus", resolved.simUdid, "-b"]) }
401
+ });
402
+ push({
403
+ id: "ios.prebuild",
404
+ stage: "build",
405
+ description: "Generate iOS project (expo prebuild)",
406
+ action: {
407
+ type: "command",
408
+ command: npx(["expo", "prebuild", "--platform", "ios", "--no-install"])
409
+ },
410
+ skippable: true
411
+ });
412
+ push({
413
+ id: "ios.scaffold",
414
+ stage: "build",
415
+ description: "Scaffold XCTest companion target",
416
+ action: {
417
+ type: "command",
418
+ // Pass the resolved UI-test scheme so a custom `ios.uitestScheme` scaffolds
419
+ // the SAME target that companion startup later builds (default is
420
+ // `${appScheme}UITests`).
421
+ command: npx([
422
+ "rn-driver-xctest-scaffold",
423
+ "--ios-dir",
424
+ "ios",
425
+ "--project-name",
426
+ ios.appScheme,
427
+ "--uitest-scheme",
428
+ resolved.uitestScheme
429
+ ])
430
+ },
431
+ skippable: true
432
+ });
433
+ push({
434
+ id: "ios.runtime-config",
435
+ stage: "build",
436
+ description: "Write companion runtime config (port + token-file ref)",
437
+ action: {
438
+ type: "write-file",
439
+ path: resolved.runtimeConfigFile,
440
+ contents: runtimeConfigJson(resolved, ios),
441
+ mode: 384
442
+ }
443
+ });
444
+ push({
445
+ id: "ios.pods",
446
+ stage: "build",
447
+ description: "Install CocoaPods",
448
+ action: { type: "command", command: cmd("pod", ["install", "--project-directory=ios"]) },
449
+ skippable: true
450
+ });
451
+ push(metroStartStep(metro));
452
+ push({
453
+ id: "metro.ready",
454
+ stage: "metro",
455
+ description: `Wait for Metro at ${metro.url}`,
456
+ action: {
457
+ type: "probe",
458
+ probe: { kind: "metro-status", metroUrl: metro.url, timeoutMs: metro.readyTimeoutMs }
459
+ }
460
+ });
461
+ push({
462
+ id: "ios.packager-host-location",
463
+ stage: "device",
464
+ description: "Point app at Metro (RCT_jsLocation)",
465
+ action: {
466
+ type: "command",
467
+ command: xcrun([
468
+ "simctl",
469
+ "spawn",
470
+ resolved.simUdid,
471
+ "defaults",
472
+ "write",
473
+ ios.bundleId,
474
+ "RCT_jsLocation",
475
+ `${metro.host}:${metro.port}`
476
+ ]),
477
+ allowFailure: true
478
+ }
479
+ });
480
+ push({
481
+ id: "ios.packager-host-scheme",
482
+ stage: "device",
483
+ description: "Point app at Metro (RCT_packager_scheme)",
484
+ action: {
485
+ type: "command",
486
+ command: xcrun([
487
+ "simctl",
488
+ "spawn",
489
+ resolved.simUdid,
490
+ "defaults",
491
+ "write",
492
+ ios.bundleId,
493
+ "RCT_packager_scheme",
494
+ "http"
495
+ ]),
496
+ allowFailure: true
497
+ }
498
+ });
499
+ push({
500
+ id: "ios.build-app",
501
+ stage: "build",
502
+ description: `Build app scheme ${ios.appScheme}`,
503
+ action: {
504
+ type: "command",
505
+ command: xcodebuild([
506
+ "build",
507
+ "-workspace",
508
+ ios.workspace,
509
+ "-scheme",
510
+ ios.appScheme,
511
+ "-destination",
512
+ resolved.destination,
513
+ `RCT_METRO_PORT=${metro.port}`
514
+ ])
515
+ },
516
+ skippable: true
517
+ });
518
+ for (const [key, value] of Object.entries(ios.defaults ?? {})) {
519
+ push({
520
+ id: `ios.seed.${key}`,
521
+ stage: "device",
522
+ description: `Seed default ${key}`,
523
+ action: {
524
+ type: "command",
525
+ command: xcrun([
526
+ "simctl",
527
+ "spawn",
528
+ resolved.simUdid,
529
+ "defaults",
530
+ "write",
531
+ ios.bundleId,
532
+ ...defaultsArgs(key, value)
533
+ ]),
534
+ allowFailure: true
535
+ }
536
+ });
537
+ }
538
+ push({
539
+ id: "ios.free-port",
540
+ stage: "companion",
541
+ description: `Free stale listener on port ${resolved.touchPort}`,
542
+ action: { type: "free-port", port: resolved.touchPort }
543
+ });
544
+ push({
545
+ id: "ios.companion-start",
546
+ stage: "companion",
547
+ description: `Start XCTest companion on port ${resolved.touchPort}`,
548
+ action: {
549
+ type: "command",
550
+ background: true,
551
+ processKey: "companion",
552
+ command: xcodebuild(
553
+ [
554
+ "test",
555
+ "-workspace",
556
+ ios.workspace,
557
+ "-scheme",
558
+ resolved.uitestScheme,
559
+ "-destination",
560
+ resolved.destination,
561
+ `-only-testing:${resolved.uitestScheme}/${DEFAULTS.xctestServerTest}`,
562
+ `RCT_METRO_PORT=${metro.port}`
563
+ ],
564
+ {
565
+ RN_TOUCH_XCTEST_PORT: String(resolved.touchPort),
566
+ RN_TOUCH_XCTEST_CONFIG_FILE: resolved.runtimeConfigFile
567
+ }
568
+ )
569
+ }
570
+ });
571
+ push({
572
+ id: "ios.companion-ready",
573
+ stage: "companion",
574
+ description: "Wait for companion to accept a hello",
575
+ action: {
576
+ type: "probe",
577
+ probe: {
578
+ kind: "xctest-hello",
579
+ port: resolved.touchPort,
580
+ tokenFile: resolved.tokenFile,
581
+ timeoutMs: resolved.companionReadyTimeoutMs
582
+ }
583
+ }
584
+ });
585
+ if (isDevClient) {
586
+ push({
587
+ id: "ios.terminate-before-launch",
588
+ stage: "app-launch",
589
+ description: "Terminate any running instance (cold launch requires it)",
590
+ action: {
591
+ type: "command",
592
+ command: xcrun(["simctl", "terminate", resolved.simUdid, ios.bundleId]),
593
+ allowFailure: true
594
+ }
595
+ });
596
+ push({
597
+ id: "ios.launch",
598
+ stage: "app-launch",
599
+ description: `Cold-launch dev client via --initialUrl ${resolved.initialUrl}`,
600
+ action: {
601
+ type: "command",
602
+ command: xcrun([
603
+ "simctl",
604
+ "launch",
605
+ resolved.simUdid,
606
+ ios.bundleId,
607
+ "--initialUrl",
608
+ resolved.initialUrl
609
+ ])
610
+ }
611
+ });
612
+ }
613
+ push({
614
+ id: "ios.hermes",
615
+ stage: "hermes-target",
616
+ description: "Wait for Hermes target",
617
+ action: {
618
+ type: "probe",
619
+ probe: {
620
+ kind: "hermes-target",
621
+ platform: "ios",
622
+ metroUrl: metro.url,
623
+ appId: ios.bundleId,
624
+ deviceNameMatch: resolved.simName,
625
+ timeoutMs: resolved.hermesTimeoutMs
626
+ }
627
+ }
628
+ });
629
+ const cleanup = [
630
+ { type: "kill-process", processKey: "companion", description: "Stop XCTest companion" },
631
+ {
632
+ type: "free-port",
633
+ port: resolved.touchPort,
634
+ description: "Free companion port (reap sim-hosted child)"
635
+ },
636
+ { type: "kill-process", processKey: "metro", description: "Stop runner-owned Metro" },
637
+ { type: "remove-file", path: resolved.tokenFile, description: "Remove per-run token file" },
638
+ // REQ-SEC-004: the per-run runtime config (port + token-file ref) is written
639
+ // into the UI-test target every run; remove it so no generated artifact is
640
+ // left in the app project.
641
+ {
642
+ type: "remove-file",
643
+ path: resolved.runtimeConfigFile,
644
+ description: "Remove per-run companion runtime config"
645
+ }
646
+ ];
647
+ return {
648
+ platform: "ios",
649
+ steps,
650
+ cleanup,
651
+ driverEnv: buildIosDriverEnv(resolved, metro, timeoutMs),
652
+ playwright: playwrightCommand(playwright, specs, passthrough)
653
+ };
654
+ }
655
+ function runtimeConfigJson(resolved, ios) {
656
+ return JSON.stringify({
657
+ port: resolved.touchPort,
658
+ authTokenFile: resolved.tokenFile,
659
+ launch: ios.launch.mode
660
+ });
661
+ }
662
+ function defaultsArgs(key, value) {
663
+ if (typeof value === "boolean") return [key, "-bool", value ? "YES" : "NO"];
664
+ if (typeof value === "number") return [key, "-int", String(value)];
665
+ return [key, value];
666
+ }
667
+ function xcrun(args) {
668
+ return { command: "xcrun", args };
669
+ }
670
+ function xcodebuild(args, env) {
671
+ return env ? { command: "env", args: ["-u", "LD", "xcodebuild", ...args], env } : { command: "env", args: ["-u", "LD", "xcodebuild", ...args] };
672
+ }
673
+
674
+ // src/plan/resolved.ts
675
+ function resolveMetro(metro, overrides = {}) {
676
+ const host = overrides.host ?? metro?.host ?? DEFAULTS.metroHost;
677
+ const fromUrl = parseMetroUrl(overrides.url ?? metro?.url);
678
+ const port = fromUrl?.port ?? overrides.port ?? metro?.port ?? DEFAULTS.metroPort;
679
+ const resolvedHost = fromUrl?.host ?? host;
680
+ const url = fromUrl?.url ?? `http://${resolvedHost}:${port}`;
681
+ return {
682
+ url,
683
+ host: resolvedHost,
684
+ port,
685
+ command: metro?.command,
686
+ reuseExisting: metro?.reuseExisting ?? false,
687
+ readyTimeoutMs: metro?.readyTimeoutMs ?? DEFAULTS.metroReadyTimeoutMs
688
+ };
689
+ }
690
+ function parseMetroUrl(raw) {
691
+ if (!raw) return void 0;
692
+ const parsed = new URL(raw);
693
+ const port = parsed.port ? Number.parseInt(parsed.port, 10) : parsed.protocol === "https:" ? 443 : 80;
694
+ return { url: raw.replace(/\/$/, ""), host: parsed.hostname, port };
695
+ }
696
+ function uitestScheme(ios) {
697
+ return ios.uitestScheme ?? `${ios.appScheme}UITests`;
698
+ }
699
+ function instrumentationTarget(android) {
700
+ return android.instrumentationTarget ?? `${android.packageName}.test/${DEFAULTS.instrumentationClass}`;
701
+ }
702
+ function placeholderIos(ios, metro) {
703
+ return {
704
+ simUdid: "<sim-udid>",
705
+ simName: "<sim-name>",
706
+ destination: ios.destination ?? "platform=iOS Simulator,id=<sim-udid>",
707
+ uitestScheme: uitestScheme(ios),
708
+ touchPort: ios.companion?.port ?? DEFAULTS.companionPort,
709
+ companionReadyTimeoutMs: ios.companion?.readyTimeoutMs ?? DEFAULTS.iosCompanionReadyTimeoutMs,
710
+ hermesTimeoutMs: DEFAULTS.hermesTargetTimeoutMs,
711
+ tokenFile: SECRET_PLACEHOLDER,
712
+ runtimeConfigFile: "<runtime-config>",
713
+ initialUrl: ios.launch.initialUrl ?? metro.url
714
+ };
715
+ }
716
+ function placeholderAndroid(android, _metro) {
717
+ return {
718
+ serial: "<android-serial>",
719
+ touchPort: android.companion?.port ?? DEFAULTS.companionPort,
720
+ companionReadyTimeoutMs: android.companion?.readyTimeoutMs ?? DEFAULTS.androidCompanionReadyTimeoutMs,
721
+ hermesTimeoutMs: DEFAULTS.hermesTargetTimeoutMs,
722
+ tokenFile: SECRET_PLACEHOLDER,
723
+ deviceTokenFileName: DEFAULTS.androidTokenFileName,
724
+ instrumentationTarget: instrumentationTarget(android)
725
+ };
726
+ }
727
+
728
+ // src/build-plan.ts
729
+ function buildDryRunPlan(config, platform, opts = {}) {
730
+ const metro = resolveMetro(config.metro, opts.metroOverrides ?? {});
731
+ const specs = opts.specs ?? [];
732
+ const passthrough = opts.passthrough ?? [];
733
+ if (platform === "ios") {
734
+ const ios = config.ios;
735
+ if (!ios) throw new Error("config.ios is required to plan the ios platform");
736
+ return planIos({
737
+ ios,
738
+ metro,
739
+ resolved: placeholderIos(ios, metro),
740
+ playwright: config.playwright,
741
+ timeoutMs: config.timeoutMs,
742
+ specs,
743
+ passthrough
744
+ });
745
+ }
746
+ const android = config.android;
747
+ if (!android) throw new Error("config.android is required to plan the android platform");
748
+ return planAndroid({
749
+ android,
750
+ metro,
751
+ resolved: placeholderAndroid(android),
752
+ playwright: config.playwright,
753
+ timeoutMs: config.timeoutMs,
754
+ specs,
755
+ passthrough,
756
+ hermesDeviceName: "<android-device>"
757
+ });
758
+ }
759
+ var DEFAULT_CONFIG_NAMES = [
760
+ "rn-driver.config.ts",
761
+ "rn-driver.config.mts",
762
+ "rn-driver.config.mjs",
763
+ "rn-driver.config.js"
764
+ ];
765
+ var ConfigNotFoundError = class extends Error {
766
+ constructor(searchedFrom, configPath) {
767
+ super(
768
+ configPath ? `Config file not found: ${configPath}` : `No rn-driver.config.{ts,mts,mjs,js} found searching up from ${searchedFrom}`
769
+ );
770
+ this.name = "ConfigNotFoundError";
771
+ }
772
+ };
773
+ var defaultImporter = (absolutePath) => import(pathToFileURL(absolutePath).href);
774
+ async function loadConfig(opts) {
775
+ const importer = opts.importer ?? defaultImporter;
776
+ const fileExists = opts.fileExists ?? existsSync;
777
+ const resolvedPath = opts.configPath ? path.resolve(opts.cwd, opts.configPath) : findConfigUp(opts.cwd, fileExists);
778
+ if (!resolvedPath || !fileExists(resolvedPath)) {
779
+ throw new ConfigNotFoundError(opts.cwd, opts.configPath);
780
+ }
781
+ const namespace = await importer(resolvedPath);
782
+ const config = extractDefault(namespace);
783
+ return { path: resolvedPath, config };
784
+ }
785
+ function extractDefault(namespace) {
786
+ if (namespace && typeof namespace === "object" && "default" in namespace) {
787
+ return namespace.default;
788
+ }
789
+ return namespace;
790
+ }
791
+ function findConfigUp(startDir, fileExists) {
792
+ let dir = path.resolve(startDir);
793
+ for (; ; ) {
794
+ for (const name of DEFAULT_CONFIG_NAMES) {
795
+ const candidate = path.join(dir, name);
796
+ if (fileExists(candidate)) return candidate;
797
+ }
798
+ const parent = path.dirname(dir);
799
+ if (parent === dir) return void 0;
800
+ dir = parent;
801
+ }
802
+ }
803
+
804
+ // src/print-plan.ts
805
+ function renderPlan(plan) {
806
+ const lines = [];
807
+ lines.push(`Plan (${plan.platform}) \u2014 ${plan.steps.length} steps`);
808
+ for (const step of plan.steps) {
809
+ const tag = `[${step.stage}]`.padEnd(15);
810
+ const skip = step.skippable ? " (skip-build: skipped)" : "";
811
+ lines.push(` ${tag}${step.id} \u2014 ${step.description}${skip}`);
812
+ lines.push(` ${renderAction(step.action)}`);
813
+ }
814
+ lines.push("");
815
+ lines.push("Driver env (handed to Playwright):");
816
+ for (const [key, value] of Object.entries(plan.driverEnv)) {
817
+ lines.push(` ${key}=${value}`);
818
+ }
819
+ lines.push("");
820
+ lines.push("Playwright:");
821
+ lines.push(` ${renderCommand(plan.playwright)}`);
822
+ lines.push("");
823
+ lines.push("Cleanup (defensive, idempotent):");
824
+ for (const action of plan.cleanup) {
825
+ lines.push(` - ${action.description}: ${renderCleanup(action)}`);
826
+ }
827
+ return lines.join("\n");
828
+ }
829
+ function renderAction(action) {
830
+ switch (action.type) {
831
+ case "command":
832
+ return `${action.background ? "spawn " : "$ "}${renderCommand(action.command)}${action.allowFailure ? " (best-effort)" : ""}`;
833
+ case "write-file":
834
+ return `write ${action.path}${action.mode ? ` (mode ${action.mode.toString(8)})` : ""}`;
835
+ case "free-port":
836
+ return `free-port ${action.port}`;
837
+ case "probe":
838
+ return `probe ${renderProbe(action.probe)}`;
839
+ default: {
840
+ const _exhaustive = action;
841
+ throw new Error(`unhandled action: ${JSON.stringify(_exhaustive)}`);
842
+ }
843
+ }
844
+ }
845
+ function renderCleanup(action) {
846
+ switch (action.type) {
847
+ case "kill-process":
848
+ return `kill ${action.processKey}`;
849
+ case "free-port":
850
+ return `free-port ${action.port}`;
851
+ case "remove-file":
852
+ return `rm ${action.path}`;
853
+ case "command":
854
+ return `$ ${renderCommand(action.command)}`;
855
+ default: {
856
+ const _exhaustive = action;
857
+ throw new Error(`unhandled cleanup: ${JSON.stringify(_exhaustive)}`);
858
+ }
859
+ }
860
+ }
861
+ function renderCommand(command) {
862
+ const env = command.env ? `${Object.entries(command.env).map(([k, v]) => `${k}=${v}`).join(" ")} ` : "";
863
+ const stdin = command.stdinFromFile ? ` < ${command.stdinFromFile}` : command.stdinContents ? " < <stdin>" : "";
864
+ const cwd = command.cwd ? ` (cwd: ${command.cwd})` : "";
865
+ return `${env}${command.command} ${command.args.join(" ")}${stdin}${cwd}`.trim();
866
+ }
867
+ function renderProbe(probe) {
868
+ switch (probe.kind) {
869
+ case "metro-status":
870
+ return `metro-status ${probe.metroUrl} (\u2264${probe.timeoutMs}ms)`;
871
+ case "hermes-target":
872
+ return `hermes-target ${probe.appId}${probe.deviceNameMatch ? ` @ ${probe.deviceNameMatch}` : ""} (\u2264${probe.timeoutMs}ms)`;
873
+ case "xctest-hello":
874
+ return `xctest-hello :${probe.port} (\u2264${probe.timeoutMs}ms)`;
875
+ case "instrumentation-hello":
876
+ return `instrumentation-hello :${probe.port} (\u2264${probe.timeoutMs}ms)`;
877
+ default: {
878
+ const _exhaustive = probe;
879
+ throw new Error(`unhandled probe: ${JSON.stringify(_exhaustive)}`);
880
+ }
881
+ }
882
+ }
883
+
884
+ // src/runner/execute.ts
885
+ var StageError = class extends Error {
886
+ stage;
887
+ stepId;
888
+ constructor(stage, stepId, message) {
889
+ super(`[${stage}] ${stepId}: ${message}`);
890
+ this.name = "StageError";
891
+ this.stage = stage;
892
+ this.stepId = stepId;
893
+ }
894
+ };
895
+ async function executePlan(plan, runner, opts) {
896
+ const processes = /* @__PURE__ */ new Map();
897
+ const isAlive = (key) => {
898
+ const handle = processes.get(key);
899
+ return handle ? runner.isAlive(handle) : true;
900
+ };
901
+ try {
902
+ for (const step of plan.steps) {
903
+ if (opts.skipBuild && step.skippable) {
904
+ if (opts.verbose) runner.log(`skip (skip-build): ${step.id}`);
905
+ continue;
906
+ }
907
+ if (opts.skipStep && await opts.skipStep(step)) {
908
+ if (opts.verbose) runner.log(`skip: ${step.id}`);
909
+ continue;
910
+ }
911
+ await runStep(step, runner, opts, processes, isAlive);
912
+ }
913
+ runner.log(`Running Playwright: ${plan.playwright.command} ${plan.playwright.args.join(" ")}`);
914
+ const result = await runner.exec({
915
+ ...plan.playwright,
916
+ env: { ...plan.playwright.env, ...plan.driverEnv }
917
+ });
918
+ return { playwrightCode: result.code };
919
+ } finally {
920
+ await runCleanup(plan.cleanup, runner, opts, processes);
921
+ }
922
+ }
923
+ async function runStep(step, runner, opts, processes, isAlive) {
924
+ if (opts.verbose) runner.log(`\u2192 ${step.id}: ${step.description}`);
925
+ const action = step.action;
926
+ switch (action.type) {
927
+ case "command": {
928
+ if (action.background) {
929
+ const key = action.processKey ?? step.id;
930
+ const handle = runner.spawn(action.command, { key, logPath: logPathFor(opts.logDir, key) });
931
+ processes.set(key, handle);
932
+ return;
933
+ }
934
+ const result = await runner.exec(action.command);
935
+ if (result.code !== 0 && !action.allowFailure) {
936
+ throw new StageError(step.stage, step.id, `command exited ${result.code}`);
937
+ }
938
+ return;
939
+ }
940
+ case "write-file": {
941
+ await runner.writeFile(action.path, action.contents, action.mode);
942
+ return;
943
+ }
944
+ case "free-port": {
945
+ await runner.freePort(action.port);
946
+ return;
947
+ }
948
+ case "probe": {
949
+ const key = processKeyForProbe(action.probe);
950
+ const aliveFn = key === null ? () => true : () => isAlive(key);
951
+ let ready = await runner.probe(action.probe, aliveFn);
952
+ let remaining = action.retry?.max ?? 0;
953
+ while (!ready && remaining > 0) {
954
+ remaining -= 1;
955
+ if (action.retry) {
956
+ runner.log(`retry ${step.id}: re-running launch (${remaining} attempt(s) left)`);
957
+ await runner.exec(action.retry.command);
958
+ }
959
+ ready = await runner.probe(action.probe, aliveFn);
960
+ }
961
+ if (!ready) {
962
+ throw new StageError(
963
+ step.stage,
964
+ step.id,
965
+ `readiness timed out after ${action.probe.timeoutMs}ms`
966
+ );
967
+ }
968
+ return;
969
+ }
970
+ default: {
971
+ const _exhaustive = action;
972
+ throw new Error(`unhandled action: ${JSON.stringify(_exhaustive)}`);
973
+ }
974
+ }
975
+ }
976
+ async function runCleanup(actions, runner, opts, processes) {
977
+ for (const action of actions) {
978
+ if (opts.skipCleanup && opts.skipCleanup(action)) continue;
979
+ try {
980
+ switch (action.type) {
981
+ case "kill-process": {
982
+ const handle = processes.get(action.processKey);
983
+ if (handle) await runner.kill(handle);
984
+ break;
985
+ }
986
+ case "free-port":
987
+ await runner.freePort(action.port);
988
+ break;
989
+ case "remove-file":
990
+ await runner.removeFile(action.path);
991
+ break;
992
+ case "command":
993
+ await runner.exec(action.command);
994
+ break;
995
+ default: {
996
+ const _exhaustive = action;
997
+ throw new Error(`unhandled cleanup: ${JSON.stringify(_exhaustive)}`);
998
+ }
999
+ }
1000
+ } catch (error) {
1001
+ runner.log(`cleanup ${action.description} failed: ${String(error)}`);
1002
+ }
1003
+ }
1004
+ }
1005
+ function processKeyForProbe(probe) {
1006
+ switch (probe.kind) {
1007
+ case "metro-status":
1008
+ return "metro";
1009
+ case "xctest-hello":
1010
+ case "instrumentation-hello":
1011
+ return "companion";
1012
+ case "hermes-target":
1013
+ return null;
1014
+ default: {
1015
+ const _exhaustive = probe;
1016
+ throw new Error(`unhandled probe: ${JSON.stringify(_exhaustive)}`);
1017
+ }
1018
+ }
1019
+ }
1020
+ function logPathFor(logDir, key) {
1021
+ return `${logDir}/${key}.log`;
1022
+ }
1023
+ var PROBE_INTERVAL_MS = 1e3;
1024
+ var NodeProcessRunner = class {
1025
+ children = /* @__PURE__ */ new Map();
1026
+ exec(spec, _opts) {
1027
+ return new Promise((resolve, reject) => {
1028
+ const usesStdin = Boolean(spec.stdinFromFile) || spec.stdinContents !== void 0;
1029
+ const child = spawn(spec.command, [...spec.args], {
1030
+ cwd: spec.cwd,
1031
+ env: { ...process.env, ...spec.env },
1032
+ stdio: [usesStdin ? "pipe" : "inherit", "inherit", "inherit"]
1033
+ });
1034
+ child.on("error", reject);
1035
+ if (usesStdin && child.stdin) {
1036
+ if (spec.stdinFromFile) {
1037
+ createReadStream(spec.stdinFromFile).pipe(child.stdin);
1038
+ } else {
1039
+ child.stdin.end(spec.stdinContents ?? "");
1040
+ }
1041
+ }
1042
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout: "", stderr: "" }));
1043
+ });
1044
+ }
1045
+ spawn(spec, opts) {
1046
+ const fd = openSync(opts.logPath, "a");
1047
+ const child = spawn(spec.command, [...spec.args], {
1048
+ cwd: spec.cwd,
1049
+ env: { ...process.env, ...spec.env },
1050
+ detached: true,
1051
+ stdio: ["ignore", fd, fd]
1052
+ });
1053
+ child.unref();
1054
+ this.children.set(opts.key, child);
1055
+ return { key: opts.key, pid: child.pid };
1056
+ }
1057
+ isAlive(handle) {
1058
+ const child = this.children.get(handle.key);
1059
+ if (!child || child.exitCode !== null || child.signalCode !== null) return false;
1060
+ if (handle.pid === void 0) return false;
1061
+ try {
1062
+ process.kill(handle.pid, 0);
1063
+ return true;
1064
+ } catch {
1065
+ return false;
1066
+ }
1067
+ }
1068
+ async kill(handle) {
1069
+ const child = this.children.get(handle.key);
1070
+ if (!child || handle.pid === void 0) return;
1071
+ killGroup(handle.pid, "SIGTERM");
1072
+ await delay(500);
1073
+ if (this.isAlive(handle)) killGroup(handle.pid, "SIGKILL");
1074
+ this.children.delete(handle.key);
1075
+ }
1076
+ /**
1077
+ * Best-effort synchronous teardown for signal handlers: SIGKILL every tracked
1078
+ * child's process group so a Ctrl-C / SIGTERM does not orphan the detached
1079
+ * Metro/companion processes (REQ-CLEAN-001, signal clause). Synchronous because
1080
+ * a signal handler cannot await before `process.exit`.
1081
+ */
1082
+ killAll() {
1083
+ for (const child of this.children.values()) {
1084
+ if (child.pid !== void 0) killGroup(child.pid, "SIGKILL");
1085
+ }
1086
+ this.children.clear();
1087
+ }
1088
+ async writeFile(path4, contents, mode) {
1089
+ await writeFile(path4, contents);
1090
+ if (mode !== void 0) await chmod(path4, mode);
1091
+ }
1092
+ async removeFile(path4) {
1093
+ await rm(path4, { force: true });
1094
+ }
1095
+ async freePort(port) {
1096
+ const pids = await this.lsofPids(port);
1097
+ for (const pid of pids) {
1098
+ try {
1099
+ process.kill(pid, "SIGTERM");
1100
+ } catch {
1101
+ }
1102
+ }
1103
+ if (pids.length > 0) await delay(1e3);
1104
+ }
1105
+ probe(probe, isAlive) {
1106
+ const deadline = Date.now() + probe.timeoutMs;
1107
+ const attempt = async () => {
1108
+ for (; ; ) {
1109
+ if (!isAlive()) return false;
1110
+ if (await probeOnce(probe)) return true;
1111
+ if (Date.now() >= deadline) return false;
1112
+ await delay(PROBE_INTERVAL_MS);
1113
+ }
1114
+ };
1115
+ return attempt();
1116
+ }
1117
+ log(line) {
1118
+ process.stderr.write(`${line}
1119
+ `);
1120
+ }
1121
+ lsofPids(port) {
1122
+ return new Promise((resolve) => {
1123
+ const child = spawn("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
1124
+ stdio: ["ignore", "pipe", "ignore"]
1125
+ });
1126
+ let out = "";
1127
+ child.stdout?.on("data", (chunk) => {
1128
+ out += chunk.toString();
1129
+ });
1130
+ child.on("error", () => resolve([]));
1131
+ child.on("close", () => {
1132
+ const pids = out.split("\n").map((line) => Number.parseInt(line.trim(), 10)).filter((pid) => Number.isInteger(pid) && pid > 0);
1133
+ resolve(pids);
1134
+ });
1135
+ });
1136
+ }
1137
+ };
1138
+ function killGroup(pid, signal) {
1139
+ try {
1140
+ process.kill(-pid, signal);
1141
+ } catch {
1142
+ try {
1143
+ process.kill(pid, signal);
1144
+ } catch {
1145
+ }
1146
+ }
1147
+ }
1148
+ function delay(ms) {
1149
+ return new Promise((resolve) => {
1150
+ setTimeout(resolve, ms);
1151
+ });
1152
+ }
1153
+ async function probeOnce(probe) {
1154
+ switch (probe.kind) {
1155
+ case "metro-status":
1156
+ return metroStatusOk(probe.metroUrl);
1157
+ case "hermes-target":
1158
+ return hermesTargetPresent(probe);
1159
+ case "xctest-hello":
1160
+ return xctestHelloOk(probe.port, probe.tokenFile);
1161
+ case "instrumentation-hello":
1162
+ return instrumentationHelloOk(probe.port, probe.tokenFile);
1163
+ default: {
1164
+ const _exhaustive = probe;
1165
+ throw new Error(`unhandled probe: ${JSON.stringify(_exhaustive)}`);
1166
+ }
1167
+ }
1168
+ }
1169
+ async function metroStatusOk(metroUrl) {
1170
+ try {
1171
+ const response = await fetch(`${metroUrl}/status`, {
1172
+ signal: AbortSignal.timeout(PROBE_INTERVAL_MS)
1173
+ });
1174
+ const body = await response.text();
1175
+ return body.includes("packager-status:running");
1176
+ } catch {
1177
+ return false;
1178
+ }
1179
+ }
1180
+ async function hermesTargetPresent(probe) {
1181
+ try {
1182
+ const response = await fetch(`${probe.metroUrl}/json`, {
1183
+ signal: AbortSignal.timeout(PROBE_INTERVAL_MS)
1184
+ });
1185
+ if (!response.ok) return false;
1186
+ const targets = await response.json();
1187
+ return targets.some((target) => {
1188
+ const isReactNative = String(target.title ?? "").includes("Hermes") || target.vm === "Hermes" || String(target.description ?? "").includes("React Native");
1189
+ if (!isReactNative || target.appId !== probe.appId) return false;
1190
+ if (!probe.deviceNameMatch) return true;
1191
+ return String(target.deviceName ?? "").includes(probe.deviceNameMatch);
1192
+ });
1193
+ } catch {
1194
+ return false;
1195
+ }
1196
+ }
1197
+ async function xctestHelloOk(port, tokenFile) {
1198
+ const WebSocketCtor = globalThis.WebSocket;
1199
+ if (!WebSocketCtor)
1200
+ throw new Error(
1201
+ "global WebSocket is required for the xctest probe (run under bun or Node >= 22)"
1202
+ );
1203
+ const authToken = await readTokenFile(tokenFile);
1204
+ return new Promise((resolve) => {
1205
+ const socket = new WebSocketCtor(`ws://127.0.0.1:${port}`);
1206
+ const timer = setTimeout(() => {
1207
+ close(socket);
1208
+ resolve(false);
1209
+ }, PROBE_INTERVAL_MS);
1210
+ socket.addEventListener("open", () => {
1211
+ socket.send(
1212
+ JSON.stringify({
1213
+ id: 1,
1214
+ type: "hello",
1215
+ protocolVersion: 1,
1216
+ client: "rn-driver-runner",
1217
+ ...authToken ? { authToken } : {}
1218
+ })
1219
+ );
1220
+ });
1221
+ socket.addEventListener("message", (event) => {
1222
+ clearTimeout(timer);
1223
+ close(socket);
1224
+ try {
1225
+ const payload = JSON.parse(String(event.data));
1226
+ resolve(payload.id === 1 && payload.ok === true);
1227
+ } catch {
1228
+ resolve(false);
1229
+ }
1230
+ });
1231
+ socket.addEventListener("error", () => {
1232
+ clearTimeout(timer);
1233
+ resolve(false);
1234
+ });
1235
+ });
1236
+ }
1237
+ async function instrumentationHelloOk(port, tokenFile) {
1238
+ try {
1239
+ const token = await readTokenFile(tokenFile);
1240
+ const controller = new AbortController();
1241
+ const timer = setTimeout(() => controller.abort(), PROBE_INTERVAL_MS);
1242
+ const response = await fetch(`http://127.0.0.1:${port}/command`, {
1243
+ method: "POST",
1244
+ headers: {
1245
+ "content-type": "application/json",
1246
+ ...token ? { "x-rn-driver-auth": token } : {}
1247
+ },
1248
+ body: JSON.stringify({ type: "hello" }),
1249
+ signal: controller.signal
1250
+ });
1251
+ clearTimeout(timer);
1252
+ const payload = await response.json().catch(() => void 0);
1253
+ return response.ok && payload?.ok === true;
1254
+ } catch {
1255
+ return false;
1256
+ }
1257
+ }
1258
+ async function readTokenFile(path4) {
1259
+ try {
1260
+ const token = (await readFile(path4, "utf8")).trim();
1261
+ return token === "" ? void 0 : token;
1262
+ } catch {
1263
+ return void 0;
1264
+ }
1265
+ }
1266
+ function close(socket) {
1267
+ try {
1268
+ socket.close();
1269
+ } catch {
1270
+ }
1271
+ }
1272
+ var run = promisify(execFile);
1273
+ async function resolveIosTarget(ios, metro, opts) {
1274
+ const { udid, name } = await selectSimulator(ios, opts.device);
1275
+ await terminateStaleOnOtherSims(udid, ios.bundleId);
1276
+ const tokenFile = await mintTokenFile();
1277
+ const scheme = uitestScheme(ios);
1278
+ return {
1279
+ simUdid: udid,
1280
+ simName: name,
1281
+ destination: ios.destination ?? `platform=iOS Simulator,id=${udid}`,
1282
+ uitestScheme: scheme,
1283
+ touchPort: ios.companion?.port ?? DEFAULTS.companionPort,
1284
+ companionReadyTimeoutMs: ios.companion?.readyTimeoutMs ?? DEFAULTS.iosCompanionReadyTimeoutMs,
1285
+ hermesTimeoutMs: DEFAULTS.hermesTargetTimeoutMs,
1286
+ tokenFile,
1287
+ runtimeConfigFile: path.join("ios", scheme, "RNDriverTouchCompanionRuntimeConfig.json"),
1288
+ initialUrl: ios.launch.initialUrl ?? metro.url
1289
+ };
1290
+ }
1291
+ async function resolveAndroidTarget(android, _metro, opts) {
1292
+ const serial = await selectSerial(opts.device);
1293
+ await requireBooted(serial);
1294
+ const deviceName = (await capture("adb", ["-s", serial, "shell", "getprop", "ro.product.model"])).trim();
1295
+ const tokenFile = await mintTokenFile();
1296
+ return {
1297
+ resolved: {
1298
+ serial,
1299
+ touchPort: android.companion?.port ?? DEFAULTS.companionPort,
1300
+ companionReadyTimeoutMs: android.companion?.readyTimeoutMs ?? DEFAULTS.androidCompanionReadyTimeoutMs,
1301
+ hermesTimeoutMs: DEFAULTS.hermesTargetTimeoutMs,
1302
+ tokenFile,
1303
+ deviceTokenFileName: DEFAULTS.androidTokenFileName,
1304
+ instrumentationTarget: instrumentationTarget(android)
1305
+ },
1306
+ deviceName
1307
+ };
1308
+ }
1309
+ async function selectSimulator(ios, deviceOverride) {
1310
+ const data = JSON.parse(
1311
+ await capture("xcrun", ["simctl", "list", "devices", "available", "--json"])
1312
+ );
1313
+ const all = Object.entries(data.devices ?? {}).flatMap(([runtime, list]) => list.map((device) => ({ ...device, runtime }))).filter((device) => device.isAvailable !== false);
1314
+ return pickSimulator(all, deviceOverride, ios.destination);
1315
+ }
1316
+ function pickSimulator(devices, deviceOverride, destination) {
1317
+ const explicitUdid = parseUdid(deviceOverride) ?? parseUdid(destination);
1318
+ if (explicitUdid) {
1319
+ const match = devices.find((device) => device.udid === explicitUdid);
1320
+ if (!match) throw new Error(`requested iOS simulator not found: ${explicitUdid}`);
1321
+ return { udid: match.udid, name: match.name };
1322
+ }
1323
+ if (deviceOverride) {
1324
+ const byName = devices.find((device) => device.name === deviceOverride) ?? devices.find((device) => device.name.includes(deviceOverride));
1325
+ if (!byName) throw new Error(`requested iOS simulator not found by name: ${deviceOverride}`);
1326
+ return { udid: byName.udid, name: byName.name };
1327
+ }
1328
+ const byNewest = (a, b) => compareRuntime(runtimeVersion(b.runtime), runtimeVersion(a.runtime));
1329
+ const iphones = devices.filter((device) => device.name.startsWith("iPhone"));
1330
+ const booted = iphones.filter((device) => device.state === "Booted").sort(byNewest);
1331
+ const pick = booted[0] ?? [...iphones].sort(byNewest)[0];
1332
+ if (!pick) throw new Error("no available iPhone simulator found");
1333
+ return { udid: pick.udid, name: pick.name };
1334
+ }
1335
+ async function terminateStaleOnOtherSims(keepUdid, bundleId) {
1336
+ const booted = await capture("xcrun", ["simctl", "list", "devices", "booted"]);
1337
+ const udids = booted.match(/[0-9A-Fa-f-]{36}/g) ?? [];
1338
+ for (const udid of udids) {
1339
+ if (udid === keepUdid) continue;
1340
+ await capture("xcrun", ["simctl", "terminate", udid, bundleId]).catch(() => "");
1341
+ }
1342
+ }
1343
+ async function selectSerial(deviceOverride) {
1344
+ await capture("adb", ["start-server"]).catch(() => "");
1345
+ if (deviceOverride) {
1346
+ await run("adb", ["-s", deviceOverride, "get-state"]);
1347
+ return deviceOverride;
1348
+ }
1349
+ const devices = await capture("adb", ["devices"]);
1350
+ const serial = devices.split("\n").slice(1).map((line) => line.trim().split(/\s+/)).find((cols) => cols[1] === "device" && cols[0]?.startsWith("emulator-"))?.[0];
1351
+ if (!serial) throw new Error("no booted emulator found in `adb devices`");
1352
+ return serial;
1353
+ }
1354
+ async function requireBooted(serial) {
1355
+ const state = (await capture("adb", ["-s", serial, "get-state"])).trim();
1356
+ if (state !== "device") throw new Error(`adb device ${serial} is not ready (state: ${state})`);
1357
+ const booted = (await capture("adb", ["-s", serial, "shell", "getprop", "sys.boot_completed"])).trim();
1358
+ if (booted !== "1") throw new Error(`adb device ${serial} has not completed boot`);
1359
+ }
1360
+ async function mintTokenFile() {
1361
+ const dir = await mkdtemp(path.join(tmpdir(), "rn-driver-token-"));
1362
+ const file = path.join(dir, "token");
1363
+ await writeFile(file, randomBytes(16).toString("hex"));
1364
+ await chmod(file, 384);
1365
+ return file;
1366
+ }
1367
+ async function capture(command, args) {
1368
+ const { stdout } = await run(command, args, { maxBuffer: 16 * 1024 * 1024 });
1369
+ return stdout.toString();
1370
+ }
1371
+ function parseUdid(value) {
1372
+ if (!value) return void 0;
1373
+ const match = value.match(/id=([0-9A-Fa-f-]{36})/) ?? value.match(/^([0-9A-Fa-f-]{36})$/);
1374
+ return match?.[1];
1375
+ }
1376
+ function runtimeVersion(runtime) {
1377
+ const match = runtime.match(/iOS-([0-9-]+)$/);
1378
+ return match?.[1] ? match[1].split("-").map((part) => Number.parseInt(part, 10)) : [0];
1379
+ }
1380
+ function compareRuntime(a, b) {
1381
+ const length = Math.max(a.length, b.length);
1382
+ for (let index = 0; index < length; index += 1) {
1383
+ const delta = (a[index] ?? 0) - (b[index] ?? 0);
1384
+ if (delta !== 0) return delta;
1385
+ }
1386
+ return 0;
1387
+ }
1388
+
1389
+ // src/validate.ts
1390
+ var LAUNCH_MODES = ["launch", "activate", "attach"];
1391
+ var LAUNCH_KINDS = ["plain", "expo-dev-client"];
1392
+ var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["metro", "ios", "android", "playwright", "timeoutMs"]);
1393
+ var METRO_KEYS = /* @__PURE__ */ new Set(["url", "command", "host", "port", "reuseExisting", "readyTimeoutMs"]);
1394
+ var LAUNCH_KEYS = /* @__PURE__ */ new Set(["mode", "kind", "initialUrl"]);
1395
+ var COMPANION_KEYS = /* @__PURE__ */ new Set(["port", "readyTimeoutMs"]);
1396
+ var IOS_KEYS = /* @__PURE__ */ new Set([
1397
+ "bundleId",
1398
+ "workspace",
1399
+ "appScheme",
1400
+ "uitestScheme",
1401
+ "destination",
1402
+ "launch",
1403
+ "companion",
1404
+ "defaults"
1405
+ ]);
1406
+ var ANDROID_KEYS = /* @__PURE__ */ new Set([
1407
+ "packageName",
1408
+ "activity",
1409
+ "gradleTasks",
1410
+ "appApkPath",
1411
+ "testApkPath",
1412
+ "instrumentationTarget",
1413
+ "launch",
1414
+ "companion"
1415
+ ]);
1416
+ var PLAYWRIGHT_KEYS = /* @__PURE__ */ new Set(["config", "specs"]);
1417
+ function validateConfig(config, platforms) {
1418
+ const errors = [];
1419
+ if (!isRecord(config)) {
1420
+ return {
1421
+ ok: false,
1422
+ errors: ["config: expected an object exported as default from rn-driver.config"]
1423
+ };
1424
+ }
1425
+ reportUnknownKeys("config", config, TOP_LEVEL_KEYS, errors);
1426
+ if (config.timeoutMs !== void 0 && !isPositiveNumber(config.timeoutMs)) {
1427
+ errors.push("config.timeoutMs: expected a positive number");
1428
+ }
1429
+ validateMetro(config.metro, errors);
1430
+ validatePlaywright(config.playwright, errors);
1431
+ if (platforms.includes("ios")) validateIos(config.ios, errors);
1432
+ if (platforms.includes("android")) validateAndroid(config.android, errors);
1433
+ return { ok: errors.length === 0, errors };
1434
+ }
1435
+ function validateMetro(metro, errors) {
1436
+ if (metro === void 0) return;
1437
+ if (!isRecord(metro)) {
1438
+ errors.push("config.metro: expected an object");
1439
+ return;
1440
+ }
1441
+ reportUnknownKeys("config.metro", metro, METRO_KEYS, errors);
1442
+ optionalString("config.metro.url", metro.url, errors);
1443
+ optionalString("config.metro.command", metro.command, errors);
1444
+ optionalString("config.metro.host", metro.host, errors);
1445
+ if (metro.port !== void 0 && !isPort(metro.port))
1446
+ errors.push("config.metro.port: expected a port (1-65535)");
1447
+ if (metro.reuseExisting !== void 0 && typeof metro.reuseExisting !== "boolean") {
1448
+ errors.push("config.metro.reuseExisting: expected a boolean");
1449
+ }
1450
+ if (metro.readyTimeoutMs !== void 0 && !isPositiveNumber(metro.readyTimeoutMs)) {
1451
+ errors.push("config.metro.readyTimeoutMs: expected a positive number");
1452
+ }
1453
+ }
1454
+ function validatePlaywright(playwright, errors) {
1455
+ if (playwright === void 0) return;
1456
+ if (!isRecord(playwright)) {
1457
+ errors.push("config.playwright: expected an object");
1458
+ return;
1459
+ }
1460
+ reportUnknownKeys("config.playwright", playwright, PLAYWRIGHT_KEYS, errors);
1461
+ optionalString("config.playwright.config", playwright.config, errors);
1462
+ if (playwright.specs !== void 0 && !isStringArray(playwright.specs)) {
1463
+ errors.push("config.playwright.specs: expected an array of strings");
1464
+ }
1465
+ }
1466
+ function validateIos(ios, errors) {
1467
+ if (!isRecord(ios)) {
1468
+ errors.push('config.ios: required when platform "ios" is selected (expected an object)');
1469
+ return;
1470
+ }
1471
+ reportUnknownKeys("config.ios", ios, IOS_KEYS, errors);
1472
+ requireString("config.ios.bundleId", ios.bundleId, errors);
1473
+ requireString("config.ios.workspace", ios.workspace, errors);
1474
+ requireString("config.ios.appScheme", ios.appScheme, errors);
1475
+ optionalString("config.ios.uitestScheme", ios.uitestScheme, errors);
1476
+ optionalString("config.ios.destination", ios.destination, errors);
1477
+ validateCompanion("config.ios.companion", ios.companion, errors);
1478
+ if (ios.defaults !== void 0) {
1479
+ if (!isRecord(ios.defaults)) {
1480
+ errors.push("config.ios.defaults: expected an object of key -> string|number|boolean");
1481
+ } else {
1482
+ for (const [key, value] of Object.entries(ios.defaults)) {
1483
+ const kind = typeof value;
1484
+ if (kind !== "string" && kind !== "number" && kind !== "boolean") {
1485
+ errors.push(`config.ios.defaults.${key}: expected string|number|boolean`);
1486
+ }
1487
+ }
1488
+ }
1489
+ }
1490
+ const launch = validateLaunch("config.ios.launch", ios.launch, errors);
1491
+ if (launch && launch.kind === "expo-dev-client" && launch.mode !== "attach") {
1492
+ errors.push(
1493
+ 'config.ios.launch: kind "expo-dev-client" requires mode "attach" (the host owns the launch)'
1494
+ );
1495
+ }
1496
+ if (launch && launch.mode === "attach" && launch.kind !== "expo-dev-client") {
1497
+ errors.push(
1498
+ 'config.ios.launch: mode "attach" requires kind "expo-dev-client"; a plain app uses mode "launch" or "activate" (the companion launches it)'
1499
+ );
1500
+ }
1501
+ }
1502
+ function validateAndroid(android, errors) {
1503
+ if (!isRecord(android)) {
1504
+ errors.push('config.android: required when platform "android" is selected (expected an object)');
1505
+ return;
1506
+ }
1507
+ reportUnknownKeys("config.android", android, ANDROID_KEYS, errors);
1508
+ requireAndroidPackage("config.android.packageName", android.packageName, errors);
1509
+ requireAndroidActivity("config.android.activity", android.activity, errors);
1510
+ optionalString("config.android.appApkPath", android.appApkPath, errors);
1511
+ optionalString("config.android.testApkPath", android.testApkPath, errors);
1512
+ if (android.instrumentationTarget !== void 0)
1513
+ requireInstrumentationTarget(
1514
+ "config.android.instrumentationTarget",
1515
+ android.instrumentationTarget,
1516
+ errors
1517
+ );
1518
+ if (android.gradleTasks !== void 0 && !isStringArray(android.gradleTasks)) {
1519
+ errors.push("config.android.gradleTasks: expected an array of strings");
1520
+ }
1521
+ validateCompanion("config.android.companion", android.companion, errors);
1522
+ validateLaunch("config.android.launch", android.launch, errors);
1523
+ }
1524
+ function validateCompanion(path4, companion, errors) {
1525
+ if (companion === void 0) return;
1526
+ if (!isRecord(companion)) {
1527
+ errors.push(`${path4}: expected an object`);
1528
+ return;
1529
+ }
1530
+ reportUnknownKeys(path4, companion, COMPANION_KEYS, errors);
1531
+ if (companion.port !== void 0 && !isPort(companion.port))
1532
+ errors.push(`${path4}.port: expected a port (1-65535)`);
1533
+ if (companion.readyTimeoutMs !== void 0 && !isPositiveNumber(companion.readyTimeoutMs)) {
1534
+ errors.push(`${path4}.readyTimeoutMs: expected a positive number`);
1535
+ }
1536
+ }
1537
+ function validateLaunch(path4, launch, errors) {
1538
+ if (!isRecord(launch)) {
1539
+ errors.push(`${path4}: required (expected an object with mode and kind)`);
1540
+ return void 0;
1541
+ }
1542
+ reportUnknownKeys(path4, launch, LAUNCH_KEYS, errors);
1543
+ optionalString(`${path4}.initialUrl`, launch.initialUrl, errors);
1544
+ const modeOk = typeof launch.mode === "string" && LAUNCH_MODES.includes(launch.mode);
1545
+ const kindOk = typeof launch.kind === "string" && LAUNCH_KINDS.includes(launch.kind);
1546
+ if (!modeOk) errors.push(`${path4}.mode: expected one of ${LAUNCH_MODES.join(", ")}`);
1547
+ if (!kindOk) errors.push(`${path4}.kind: expected one of ${LAUNCH_KINDS.join(", ")}`);
1548
+ return modeOk && kindOk ? { mode: launch.mode, kind: launch.kind } : void 0;
1549
+ }
1550
+ function isRecord(value) {
1551
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1552
+ }
1553
+ function isPositiveNumber(value) {
1554
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
1555
+ }
1556
+ function isPort(value) {
1557
+ return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535;
1558
+ }
1559
+ function isStringArray(value) {
1560
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
1561
+ }
1562
+ function requireString(path4, value, errors) {
1563
+ if (typeof value !== "string" || value.trim() === "")
1564
+ errors.push(`${path4}: required non-empty string`);
1565
+ }
1566
+ var ANDROID_PACKAGE_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
1567
+ var ANDROID_ACTIVITY_RE = /^\.?[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*$/;
1568
+ function requireAndroidPackage(path4, value, errors) {
1569
+ if (typeof value !== "string" || value.trim() === "") {
1570
+ errors.push(`${path4}: required non-empty string`);
1571
+ return;
1572
+ }
1573
+ if (!ANDROID_PACKAGE_RE.test(value))
1574
+ errors.push(`${path4}: expected a valid Android application id (e.g. com.company.app)`);
1575
+ }
1576
+ function requireAndroidActivity(path4, value, errors) {
1577
+ if (typeof value !== "string" || value.trim() === "") {
1578
+ errors.push(`${path4}: required non-empty string`);
1579
+ return;
1580
+ }
1581
+ if (!ANDROID_ACTIVITY_RE.test(value))
1582
+ errors.push(`${path4}: expected an activity name (e.g. .MainActivity)`);
1583
+ }
1584
+ var ANDROID_INSTRUMENTATION_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*\/[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*$/;
1585
+ function requireInstrumentationTarget(path4, value, errors) {
1586
+ if (typeof value !== "string" || value.trim() === "") {
1587
+ errors.push(`${path4}: expected a non-empty string`);
1588
+ return;
1589
+ }
1590
+ if (!ANDROID_INSTRUMENTATION_RE.test(value))
1591
+ errors.push(`${path4}: expected an am instrument target (e.g. com.app.test/com.app.Runner)`);
1592
+ }
1593
+ function optionalString(path4, value, errors) {
1594
+ if (value !== void 0 && typeof value !== "string") errors.push(`${path4}: expected a string`);
1595
+ }
1596
+ function reportUnknownKeys(path4, value, allowed, errors) {
1597
+ for (const key of Object.keys(value)) {
1598
+ if (!allowed.has(key)) errors.push(`${path4}.${key}: unknown key`);
1599
+ }
1600
+ }
1601
+ function assertValid(config, platforms) {
1602
+ const result = validateConfig(config, platforms);
1603
+ if (!result.ok) {
1604
+ throw new ConfigValidationError(result.errors);
1605
+ }
1606
+ }
1607
+ var ConfigValidationError = class extends Error {
1608
+ errors;
1609
+ constructor(errors) {
1610
+ super(`Invalid rn-driver config:
1611
+ - ${errors.join("\n - ")}`);
1612
+ this.name = "ConfigValidationError";
1613
+ this.errors = errors;
1614
+ }
1615
+ };
1616
+
1617
+ // src/cli.ts
1618
+ var STAGE_EXIT_CODES = {
1619
+ config: 10,
1620
+ metro: 11,
1621
+ device: 12,
1622
+ build: 13,
1623
+ companion: 14,
1624
+ "app-launch": 15,
1625
+ "hermes-target": 16,
1626
+ playwright: 1,
1627
+ cleanup: 17
1628
+ };
1629
+ async function run2(argv) {
1630
+ const { command, specs, passthrough, flags } = parseCliArgs(argv);
1631
+ if (flags.help) {
1632
+ printHelp();
1633
+ return 0;
1634
+ }
1635
+ if (command !== "test") {
1636
+ process.stderr.write(`Unknown command: ${command ?? "(none)"}
1637
+ `);
1638
+ printHelp();
1639
+ return 2;
1640
+ }
1641
+ let platforms;
1642
+ try {
1643
+ platforms = resolvePlatforms(flags.platform);
1644
+ } catch (error) {
1645
+ process.stderr.write(`${error.message}
1646
+ `);
1647
+ return 2;
1648
+ }
1649
+ let config;
1650
+ try {
1651
+ const loaded = await loadConfig({
1652
+ cwd: process.cwd(),
1653
+ ...flags.config ? { configPath: flags.config } : {}
1654
+ });
1655
+ assertValid(loaded.config, platforms);
1656
+ config = loaded.config;
1657
+ } catch (error) {
1658
+ if (error instanceof ConfigValidationError || error instanceof ConfigNotFoundError) {
1659
+ process.stderr.write(`${error.message}
1660
+ `);
1661
+ return 2;
1662
+ }
1663
+ throw error;
1664
+ }
1665
+ if (flags.dryRun) {
1666
+ for (const platform of platforms) {
1667
+ process.stdout.write(
1668
+ `${renderPlan(buildDryRunPlan(config, platform, { specs, passthrough }))}
1669
+
1670
+ `
1671
+ );
1672
+ }
1673
+ return 0;
1674
+ }
1675
+ const runner = new NodeProcessRunner();
1676
+ const logDir = await mkdtemp(path.join(tmpdir(), "rn-driver-logs-"));
1677
+ const onSignal = (signal) => {
1678
+ process.stderr.write(`
1679
+ Received ${signal} \u2014 terminating runner-owned processes\u2026
1680
+ `);
1681
+ runner.killAll();
1682
+ process.exit(130);
1683
+ };
1684
+ const onSigint = () => onSignal("SIGINT");
1685
+ const onSigterm = () => onSignal("SIGTERM");
1686
+ process.on("SIGINT", onSigint);
1687
+ process.on("SIGTERM", onSigterm);
1688
+ try {
1689
+ let exitCode = 0;
1690
+ for (const platform of platforms) {
1691
+ runner.log(`
1692
+ === platform: ${platform} ===`);
1693
+ const code = await runPlatform(platform, config, {
1694
+ runner,
1695
+ logDir,
1696
+ flags,
1697
+ specs,
1698
+ passthrough
1699
+ });
1700
+ if (code !== 0) exitCode = code;
1701
+ }
1702
+ return exitCode;
1703
+ } finally {
1704
+ process.removeListener("SIGINT", onSigint);
1705
+ process.removeListener("SIGTERM", onSigterm);
1706
+ }
1707
+ }
1708
+ async function runPlatform(platform, config, ctx) {
1709
+ const metro = resolveMetro(config.metro);
1710
+ const reuseMetro = metro.reuseExisting && await metroRunning(metro.url);
1711
+ if (reuseMetro) ctx.runner.log(`Reusing Metro already running at ${metro.url}`);
1712
+ if (!reuseMetro && await portInUse(metro.host, metro.port)) {
1713
+ process.stderr.write(
1714
+ `
1715
+ FAILED [${platform}] at stage [metro] metro.preflight: ${metro.host}:${metro.port} is already in use. Free it, set metro.reuseExisting, or choose another metro.port.
1716
+ `
1717
+ );
1718
+ return STAGE_EXIT_CODES.metro;
1719
+ }
1720
+ let plan;
1721
+ try {
1722
+ plan = await buildPlatformPlan(platform, config, metro, ctx);
1723
+ } catch (error) {
1724
+ process.stderr.write(`
1725
+ FAILED [${platform}] at stage [device]: ${error.message}
1726
+ `);
1727
+ return STAGE_EXIT_CODES.device;
1728
+ }
1729
+ try {
1730
+ const result = await executePlan(plan, ctx.runner, {
1731
+ logDir: ctx.logDir,
1732
+ skipBuild: ctx.flags.skipBuild,
1733
+ verbose: ctx.flags.verbose,
1734
+ skipStep: (step) => reuseMetro && step.id === "metro.start",
1735
+ skipCleanup: (action) => reuseMetro && action.type === "kill-process" && action.processKey === "metro"
1736
+ });
1737
+ if (result.playwrightCode === 0) {
1738
+ ctx.runner.log(`PASS: ${platform} e2e`);
1739
+ } else {
1740
+ ctx.runner.log(`FAIL: ${platform} Playwright exited ${result.playwrightCode}`);
1741
+ }
1742
+ return result.playwrightCode;
1743
+ } catch (error) {
1744
+ if (error instanceof StageError) {
1745
+ process.stderr.write(
1746
+ `
1747
+ FAILED [${platform}] at stage [${error.stage}] ${error.stepId}: ${error.message}
1748
+ `
1749
+ );
1750
+ process.stderr.write(`Logs: ${ctx.logDir}
1751
+ `);
1752
+ await printLogTail(ctx.logDir, "metro");
1753
+ await printLogTail(ctx.logDir, "companion");
1754
+ return STAGE_EXIT_CODES[error.stage];
1755
+ }
1756
+ throw error;
1757
+ }
1758
+ }
1759
+ async function buildPlatformPlan(platform, config, metro, ctx) {
1760
+ if (platform === "ios") {
1761
+ if (!config.ios) throw new Error("config.ios is required for the ios platform");
1762
+ const resolved2 = await resolveIosTarget(config.ios, metro, deviceOpt(ctx.flags.device));
1763
+ return planIos({
1764
+ ios: config.ios,
1765
+ metro,
1766
+ resolved: resolved2,
1767
+ playwright: config.playwright,
1768
+ timeoutMs: config.timeoutMs,
1769
+ specs: ctx.specs,
1770
+ passthrough: ctx.passthrough
1771
+ });
1772
+ }
1773
+ if (!config.android) throw new Error("config.android is required for the android platform");
1774
+ const { resolved, deviceName } = await resolveAndroidTarget(
1775
+ config.android,
1776
+ metro,
1777
+ deviceOpt(ctx.flags.device)
1778
+ );
1779
+ return planAndroid({
1780
+ android: config.android,
1781
+ metro,
1782
+ resolved,
1783
+ playwright: config.playwright,
1784
+ timeoutMs: config.timeoutMs,
1785
+ specs: ctx.specs,
1786
+ passthrough: ctx.passthrough,
1787
+ hermesDeviceName: deviceName
1788
+ });
1789
+ }
1790
+ function deviceOpt(device) {
1791
+ return device ? { device } : {};
1792
+ }
1793
+ async function metroRunning(metroUrl) {
1794
+ try {
1795
+ const response = await fetch(`${metroUrl}/status`, { signal: AbortSignal.timeout(2e3) });
1796
+ return (await response.text()).includes("packager-status:running");
1797
+ } catch {
1798
+ return false;
1799
+ }
1800
+ }
1801
+ async function printLogTail(logDir, key, lines = 20) {
1802
+ try {
1803
+ const content = await readFile(path.join(logDir, `${key}.log`), "utf8");
1804
+ const tail = content.split("\n").filter((line) => line.length > 0).slice(-lines);
1805
+ if (tail.length > 0)
1806
+ process.stderr.write(`
1807
+ --- ${key} log (last ${tail.length} lines) ---
1808
+ ${tail.join("\n")}
1809
+ `);
1810
+ } catch {
1811
+ }
1812
+ }
1813
+ function portInUse(host, port) {
1814
+ return new Promise((resolve) => {
1815
+ const socket = net.connect({ host, port });
1816
+ const finish = (inUse) => {
1817
+ socket.destroy();
1818
+ resolve(inUse);
1819
+ };
1820
+ socket.setTimeout(1e3);
1821
+ socket.once("connect", () => finish(true));
1822
+ socket.once("timeout", () => finish(false));
1823
+ socket.once("error", () => finish(false));
1824
+ });
1825
+ }
1826
+ function resolvePlatforms(platform) {
1827
+ if (!platform) throw new Error("--platform <ios|android|all> is required");
1828
+ if (platform === "all") return ["ios", "android"];
1829
+ if (platform === "ios" || platform === "android") return [platform];
1830
+ throw new Error(`--platform must be one of ios, android, all (got: ${platform})`);
1831
+ }
1832
+ function parseCliArgs(argv) {
1833
+ const dashDash = argv.indexOf("--");
1834
+ const before = dashDash === -1 ? argv : argv.slice(0, dashDash);
1835
+ const passthrough = dashDash === -1 ? [] : argv.slice(dashDash + 1);
1836
+ const { values, positionals } = parseArgs({
1837
+ args: before,
1838
+ options: {
1839
+ platform: { type: "string", short: "p" },
1840
+ config: { type: "string", short: "c" },
1841
+ device: { type: "string", short: "d" },
1842
+ "dry-run": { type: "boolean" },
1843
+ "skip-build": { type: "boolean" },
1844
+ verbose: { type: "boolean" },
1845
+ help: { type: "boolean", short: "h" }
1846
+ },
1847
+ allowPositionals: true
1848
+ });
1849
+ const [command = "test", ...specs] = positionals;
1850
+ return {
1851
+ command,
1852
+ specs,
1853
+ passthrough,
1854
+ flags: {
1855
+ platform: values.platform,
1856
+ config: values.config,
1857
+ device: values.device,
1858
+ dryRun: values["dry-run"] ?? false,
1859
+ skipBuild: values["skip-build"] ?? false,
1860
+ verbose: values.verbose ?? false,
1861
+ help: values.help ?? false
1862
+ }
1863
+ };
1864
+ }
1865
+ function printHelp() {
1866
+ process.stdout.write(`
1867
+ rn-driver \u2014 config-backed cross-platform RN Playwright e2e runner
1868
+
1869
+ Usage:
1870
+ rn-driver test --platform <ios|android|all> [options] [specs...] [-- <playwright args>]
1871
+
1872
+ Options:
1873
+ -p, --platform ios | android | all (required)
1874
+ -c, --config Path to rn-driver.config.{ts,mjs,js} (default: searched upward)
1875
+ -d, --device Simulator udid / emulator serial override
1876
+ --dry-run Print the resolved plan and exit (no side effects)
1877
+ --skip-build Reuse an already-built native project
1878
+ --verbose Stream per-step progress
1879
+ -h, --help Show help
1880
+
1881
+ Examples:
1882
+ rn-driver test --platform ios
1883
+ rn-driver test --platform android --skip-build
1884
+ rn-driver test --platform all --dry-run
1885
+ rn-driver test --platform ios e2e/integration/counter.spec.ts
1886
+ `);
1887
+ }
1888
+
1889
+ export { run2 as run };
1890
+ //# sourceMappingURL=cli.mjs.map
1891
+ //# sourceMappingURL=cli.mjs.map