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