@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/index.mjs ADDED
@@ -0,0 +1,1057 @@
1
+ // src/config.ts
2
+ function defineRnDriverConfig(config) {
3
+ return config;
4
+ }
5
+
6
+ // src/constants.ts
7
+ var ENV = {
8
+ metroUrl: "RN_METRO_URL",
9
+ deviceName: "RN_DEVICE_NAME",
10
+ timeout: "RN_TIMEOUT",
11
+ touchBackend: "RN_TOUCH_BACKEND",
12
+ xctestPort: "RN_TOUCH_XCTEST_PORT",
13
+ xctestTokenFile: "RN_TOUCH_XCTEST_TOKEN_FILE",
14
+ instrumentationPort: "RN_TOUCH_INSTRUMENTATION_PORT",
15
+ instrumentationTokenFile: "RN_TOUCH_INSTRUMENTATION_TOKEN_FILE",
16
+ androidSerial: "ANDROID_SERIAL"
17
+ };
18
+ var TOUCH_BACKEND = {
19
+ ios: "xctest",
20
+ android: "instrumentation"
21
+ };
22
+ var DEFAULTS = {
23
+ metroHost: "127.0.0.1",
24
+ metroPort: 8081,
25
+ metroReadyTimeoutMs: 9e4,
26
+ companionPort: 9999,
27
+ /** Covers a cold `xcodebuild test` build, not just process startup (FU-2). */
28
+ iosCompanionReadyTimeoutMs: 3e5,
29
+ androidCompanionReadyTimeoutMs: 45e3,
30
+ hermesTargetTimeoutMs: 6e4,
31
+ appLaunchAttempts: 3,
32
+ driverTimeoutMs: 3e4,
33
+ androidGradleTasks: [":app:assembleDebug", ":app:assembleDebugAndroidTest"],
34
+ androidAppApkPath: "android/app/build/outputs/apk/debug/app-debug.apk",
35
+ androidTestApkPath: "android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk",
36
+ instrumentationClass: "com.rndriver.touchcompanion.RNDriverTouchCompanion",
37
+ /** UI-test method the iOS companion exposes as a long-running server. */
38
+ xctestServerTest: "RNDriverTouchCompanionTests/testRunServer",
39
+ /** Device-private filename the Android companion reads its token from. */
40
+ androidTokenFileName: "rn-driver-touch-token"
41
+ };
42
+ var SECRET_PLACEHOLDER = "<token-file>";
43
+
44
+ // src/plan/env.ts
45
+ function buildIosDriverEnv(resolved, metro, timeoutMs) {
46
+ return {
47
+ [ENV.touchBackend]: TOUCH_BACKEND.ios,
48
+ [ENV.metroUrl]: metro.url,
49
+ [ENV.deviceName]: resolved.simName,
50
+ [ENV.timeout]: String(timeoutMs ?? DEFAULTS.driverTimeoutMs),
51
+ [ENV.xctestPort]: String(resolved.touchPort),
52
+ [ENV.xctestTokenFile]: resolved.tokenFile
53
+ };
54
+ }
55
+ function buildAndroidDriverEnv(resolved, metro, deviceName, timeoutMs) {
56
+ return {
57
+ [ENV.touchBackend]: TOUCH_BACKEND.android,
58
+ [ENV.metroUrl]: metro.url,
59
+ [ENV.deviceName]: deviceName,
60
+ [ENV.androidSerial]: resolved.serial,
61
+ [ENV.timeout]: String(timeoutMs ?? DEFAULTS.driverTimeoutMs),
62
+ [ENV.instrumentationPort]: String(resolved.touchPort),
63
+ [ENV.instrumentationTokenFile]: resolved.tokenFile
64
+ };
65
+ }
66
+
67
+ // src/plan/shared.ts
68
+ function cmd(command, args) {
69
+ return { command, args };
70
+ }
71
+ function npx(args) {
72
+ return { command: "npx", args };
73
+ }
74
+ function shell(command) {
75
+ return { command: "sh", args: ["-c", command] };
76
+ }
77
+ function metroStartStep(metro) {
78
+ return {
79
+ id: "metro.start",
80
+ stage: "metro",
81
+ description: metro.reuseExisting ? `Start Metro (reuse if running) ${metro.url}` : `Start Metro ${metro.url}`,
82
+ action: {
83
+ type: "command",
84
+ background: true,
85
+ processKey: "metro",
86
+ command: shell(metro.command ?? `npx expo start --localhost --port ${metro.port}`)
87
+ }
88
+ };
89
+ }
90
+ function playwrightCommand(playwright, specs, passthrough) {
91
+ const args = ["playwright", "test"];
92
+ if (playwright?.config) args.push("--config", playwright.config);
93
+ const effectiveSpecs = specs.length > 0 ? specs : playwright?.specs ?? [];
94
+ args.push(...effectiveSpecs, ...passthrough);
95
+ args.push("--reporter=line");
96
+ return npx(args);
97
+ }
98
+
99
+ // src/plan/android.ts
100
+ function planAndroid(input) {
101
+ const { android, metro, resolved, playwright, timeoutMs, specs, passthrough, hermesDeviceName } = input;
102
+ const serial = resolved.serial;
103
+ const gradleTasks = android.gradleTasks ?? [...DEFAULTS.androidGradleTasks];
104
+ const appApk = android.appApkPath ?? DEFAULTS.androidAppApkPath;
105
+ const testApk = android.testApkPath ?? DEFAULTS.androidTestApkPath;
106
+ const steps = [];
107
+ const push = (step) => steps.push(step);
108
+ push({
109
+ id: "android.prebuild",
110
+ stage: "build",
111
+ description: "Generate Android project (expo prebuild)",
112
+ action: {
113
+ type: "command",
114
+ command: npx(["expo", "prebuild", "--platform", "android", "--no-install"])
115
+ },
116
+ skippable: true
117
+ });
118
+ push({
119
+ id: "android.gradle",
120
+ stage: "build",
121
+ description: `Build app + androidTest APKs (${gradleTasks.join(" ")})`,
122
+ action: {
123
+ type: "command",
124
+ command: { command: "./gradlew", args: gradleTasks, cwd: "android" }
125
+ },
126
+ skippable: true
127
+ });
128
+ push({
129
+ id: "android.install-app",
130
+ stage: "build",
131
+ description: `Install app APK`,
132
+ action: { type: "command", command: adb(serial, ["install", "-r", appApk]) },
133
+ skippable: true
134
+ });
135
+ push({
136
+ id: "android.install-test",
137
+ stage: "build",
138
+ description: `Install androidTest APK`,
139
+ action: { type: "command", command: adb(serial, ["install", "-r", "-t", testApk]) },
140
+ skippable: true
141
+ });
142
+ push({
143
+ id: "android.install-token",
144
+ stage: "companion",
145
+ description: "Install companion token into app private files",
146
+ action: {
147
+ type: "command",
148
+ command: {
149
+ ...adbShellScript(
150
+ serial,
151
+ `run-as ${android.packageName} sh -c 'mkdir -p files && cat > files/${resolved.deviceTokenFileName} && chmod 600 files/${resolved.deviceTokenFileName}'`
152
+ ),
153
+ stdinFromFile: resolved.tokenFile
154
+ }
155
+ }
156
+ });
157
+ push(metroStartStep(metro));
158
+ push({
159
+ id: "metro.ready",
160
+ stage: "metro",
161
+ description: `Wait for Metro at ${metro.url}`,
162
+ action: {
163
+ type: "probe",
164
+ probe: { kind: "metro-status", metroUrl: metro.url, timeoutMs: metro.readyTimeoutMs }
165
+ }
166
+ });
167
+ push({
168
+ id: "android.reverse-metro",
169
+ stage: "device",
170
+ description: `adb reverse tcp:${metro.port}`,
171
+ action: {
172
+ type: "command",
173
+ command: adb(serial, ["reverse", `tcp:${metro.port}`, `tcp:${metro.port}`])
174
+ }
175
+ });
176
+ if (metro.port !== DEFAULTS.metroPort) {
177
+ push({
178
+ id: "android.reverse-default",
179
+ stage: "device",
180
+ description: `adb reverse tcp:${DEFAULTS.metroPort} -> tcp:${metro.port}`,
181
+ action: {
182
+ type: "command",
183
+ command: adb(serial, ["reverse", `tcp:${DEFAULTS.metroPort}`, `tcp:${metro.port}`])
184
+ }
185
+ });
186
+ }
187
+ push({
188
+ id: "android.debug-host",
189
+ stage: "device",
190
+ description: "Write app debug_http_host",
191
+ action: {
192
+ type: "command",
193
+ command: {
194
+ // Single adb-shell arg so the redirect runs inside the run-as'd app-uid
195
+ // shell (the shared_prefs path is app-private; the outer `shell` uid
196
+ // cannot write it).
197
+ ...adbShellScript(
198
+ serial,
199
+ `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'`
200
+ ),
201
+ stdinContents: debugHostXml(`localhost:${metro.port}`)
202
+ }
203
+ }
204
+ });
205
+ const launchCommand = launchCommandFor(android, serial);
206
+ push(launchStep("android.launch-1", android, serial));
207
+ push(hermesStep("android.hermes-1", android, metro, resolved, hermesDeviceName, launchCommand));
208
+ push({
209
+ id: "android.forward-clean",
210
+ stage: "companion",
211
+ description: `Clear stale adb forward tcp:${resolved.touchPort}`,
212
+ action: {
213
+ type: "command",
214
+ command: adb(serial, ["forward", "--remove", `tcp:${resolved.touchPort}`]),
215
+ allowFailure: true
216
+ }
217
+ });
218
+ push({
219
+ id: "android.forward",
220
+ stage: "companion",
221
+ description: `adb forward tcp:${resolved.touchPort}`,
222
+ action: {
223
+ type: "command",
224
+ command: adb(serial, ["forward", `tcp:${resolved.touchPort}`, `tcp:${resolved.touchPort}`])
225
+ }
226
+ });
227
+ push({
228
+ id: "android.instrument-start",
229
+ stage: "companion",
230
+ description: `Start instrumentation companion on port ${resolved.touchPort}`,
231
+ action: {
232
+ type: "command",
233
+ background: true,
234
+ processKey: "companion",
235
+ command: adb(serial, [
236
+ "shell",
237
+ "am",
238
+ "instrument",
239
+ "-e",
240
+ "rnDriverAuthTokenFile",
241
+ resolved.deviceTokenFileName,
242
+ "-e",
243
+ "rnDriverPort",
244
+ String(resolved.touchPort),
245
+ "-w",
246
+ resolved.instrumentationTarget
247
+ ])
248
+ }
249
+ });
250
+ push({
251
+ id: "android.instrument-ready",
252
+ stage: "companion",
253
+ description: "Wait for companion to accept a hello",
254
+ action: {
255
+ type: "probe",
256
+ probe: {
257
+ kind: "instrumentation-hello",
258
+ port: resolved.touchPort,
259
+ tokenFile: resolved.tokenFile,
260
+ timeoutMs: resolved.companionReadyTimeoutMs
261
+ }
262
+ }
263
+ });
264
+ push(launchStep("android.launch-2", android, serial));
265
+ push(hermesStep("android.hermes-2", android, metro, resolved, hermesDeviceName, launchCommand));
266
+ const cleanup = [
267
+ {
268
+ type: "kill-process",
269
+ processKey: "companion",
270
+ description: "Stop instrumentation companion"
271
+ },
272
+ {
273
+ type: "command",
274
+ command: adb(serial, ["reverse", "--remove", `tcp:${metro.port}`]),
275
+ description: "Remove metro reverse"
276
+ },
277
+ // Mirror android.reverse-default: when Metro is off 8081 the plan also adds a
278
+ // `tcp:8081 -> tcp:<port>` fallback reverse, so cleanup must remove BOTH or a
279
+ // stale 8081 mapping wedges the next run (REQ-CLEAN-003).
280
+ ...metro.port === DEFAULTS.metroPort ? [] : [
281
+ {
282
+ type: "command",
283
+ command: adb(serial, ["reverse", "--remove", `tcp:${DEFAULTS.metroPort}`]),
284
+ description: "Remove fallback 8081 metro reverse"
285
+ }
286
+ ],
287
+ {
288
+ type: "command",
289
+ command: adb(serial, ["forward", "--remove", `tcp:${resolved.touchPort}`]),
290
+ description: "Remove companion forward"
291
+ },
292
+ {
293
+ type: "command",
294
+ command: adb(serial, [
295
+ "shell",
296
+ "run-as",
297
+ android.packageName,
298
+ "rm",
299
+ "-f",
300
+ `files/${resolved.deviceTokenFileName}`
301
+ ]),
302
+ description: "Remove device token file"
303
+ },
304
+ {
305
+ type: "command",
306
+ command: adb(serial, ["shell", "am", "force-stop", android.packageName]),
307
+ description: "Force-stop app"
308
+ },
309
+ { type: "kill-process", processKey: "metro", description: "Stop runner-owned Metro" },
310
+ { type: "remove-file", path: resolved.tokenFile, description: "Remove per-run token file" }
311
+ ];
312
+ return {
313
+ platform: "android",
314
+ steps,
315
+ cleanup,
316
+ driverEnv: buildAndroidDriverEnv(resolved, metro, hermesDeviceName, timeoutMs),
317
+ playwright: playwrightCommand(playwright, specs, passthrough)
318
+ };
319
+ }
320
+ function launchCommandFor(android, serial) {
321
+ return adb(serial, [
322
+ "shell",
323
+ "am",
324
+ "start",
325
+ "-W",
326
+ "-n",
327
+ `${android.packageName}/${android.activity}`
328
+ ]);
329
+ }
330
+ function launchStep(id, android, serial) {
331
+ return {
332
+ id,
333
+ stage: "app-launch",
334
+ description: `Launch ${android.packageName}/${android.activity}`,
335
+ action: { type: "command", command: launchCommandFor(android, serial) }
336
+ };
337
+ }
338
+ function hermesStep(id, android, metro, resolved, deviceNameMatch, launchCommand) {
339
+ return {
340
+ id,
341
+ stage: "hermes-target",
342
+ description: "Wait for Hermes target",
343
+ action: {
344
+ type: "probe",
345
+ probe: {
346
+ kind: "hermes-target",
347
+ platform: "android",
348
+ metroUrl: metro.url,
349
+ appId: android.packageName,
350
+ deviceNameMatch,
351
+ timeoutMs: resolved.hermesTimeoutMs
352
+ },
353
+ // REQ-AND-005: on a transient miss, re-issue `am start` and re-probe.
354
+ // `appLaunchAttempts` total attempts ⇒ that many minus the first = retries.
355
+ retry: { command: launchCommand, max: DEFAULTS.appLaunchAttempts - 1 }
356
+ }
357
+ };
358
+ }
359
+ function debugHostXml(host) {
360
+ return `<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
361
+ <map>
362
+ <string name="debug_http_host">${host}</string>
363
+ </map>
364
+ `;
365
+ }
366
+ function adb(serial, args) {
367
+ return { command: "adb", args: ["-s", serial, ...args] };
368
+ }
369
+ function adbShellScript(serial, remote) {
370
+ return { command: "adb", args: ["-s", serial, "shell", remote] };
371
+ }
372
+
373
+ // src/plan/ios.ts
374
+ function planIos(input) {
375
+ const { ios, metro, resolved, playwright, timeoutMs, specs, passthrough } = input;
376
+ const isDevClient = ios.launch.kind === "expo-dev-client";
377
+ const steps = [];
378
+ const push = (step) => steps.push(step);
379
+ push({
380
+ id: "ios.boot",
381
+ stage: "device",
382
+ description: `Boot simulator ${resolved.simName}`,
383
+ action: {
384
+ type: "command",
385
+ command: xcrun(["simctl", "boot", resolved.simUdid]),
386
+ allowFailure: true
387
+ }
388
+ });
389
+ push({
390
+ id: "ios.boot-wait",
391
+ stage: "device",
392
+ description: `Wait for ${resolved.simName} to finish booting`,
393
+ action: { type: "command", command: xcrun(["simctl", "bootstatus", resolved.simUdid, "-b"]) }
394
+ });
395
+ push({
396
+ id: "ios.prebuild",
397
+ stage: "build",
398
+ description: "Generate iOS project (expo prebuild)",
399
+ action: {
400
+ type: "command",
401
+ command: npx(["expo", "prebuild", "--platform", "ios", "--no-install"])
402
+ },
403
+ skippable: true
404
+ });
405
+ push({
406
+ id: "ios.scaffold",
407
+ stage: "build",
408
+ description: "Scaffold XCTest companion target",
409
+ action: {
410
+ type: "command",
411
+ // Pass the resolved UI-test scheme so a custom `ios.uitestScheme` scaffolds
412
+ // the SAME target that companion startup later builds (default is
413
+ // `${appScheme}UITests`).
414
+ command: npx([
415
+ "rn-driver-xctest-scaffold",
416
+ "--ios-dir",
417
+ "ios",
418
+ "--project-name",
419
+ ios.appScheme,
420
+ "--uitest-scheme",
421
+ resolved.uitestScheme
422
+ ])
423
+ },
424
+ skippable: true
425
+ });
426
+ push({
427
+ id: "ios.runtime-config",
428
+ stage: "build",
429
+ description: "Write companion runtime config (port + token-file ref)",
430
+ action: {
431
+ type: "write-file",
432
+ path: resolved.runtimeConfigFile,
433
+ contents: runtimeConfigJson(resolved, ios),
434
+ mode: 384
435
+ }
436
+ });
437
+ push({
438
+ id: "ios.pods",
439
+ stage: "build",
440
+ description: "Install CocoaPods",
441
+ action: { type: "command", command: cmd("pod", ["install", "--project-directory=ios"]) },
442
+ skippable: true
443
+ });
444
+ push(metroStartStep(metro));
445
+ push({
446
+ id: "metro.ready",
447
+ stage: "metro",
448
+ description: `Wait for Metro at ${metro.url}`,
449
+ action: {
450
+ type: "probe",
451
+ probe: { kind: "metro-status", metroUrl: metro.url, timeoutMs: metro.readyTimeoutMs }
452
+ }
453
+ });
454
+ push({
455
+ id: "ios.packager-host-location",
456
+ stage: "device",
457
+ description: "Point app at Metro (RCT_jsLocation)",
458
+ action: {
459
+ type: "command",
460
+ command: xcrun([
461
+ "simctl",
462
+ "spawn",
463
+ resolved.simUdid,
464
+ "defaults",
465
+ "write",
466
+ ios.bundleId,
467
+ "RCT_jsLocation",
468
+ `${metro.host}:${metro.port}`
469
+ ]),
470
+ allowFailure: true
471
+ }
472
+ });
473
+ push({
474
+ id: "ios.packager-host-scheme",
475
+ stage: "device",
476
+ description: "Point app at Metro (RCT_packager_scheme)",
477
+ action: {
478
+ type: "command",
479
+ command: xcrun([
480
+ "simctl",
481
+ "spawn",
482
+ resolved.simUdid,
483
+ "defaults",
484
+ "write",
485
+ ios.bundleId,
486
+ "RCT_packager_scheme",
487
+ "http"
488
+ ]),
489
+ allowFailure: true
490
+ }
491
+ });
492
+ push({
493
+ id: "ios.build-app",
494
+ stage: "build",
495
+ description: `Build app scheme ${ios.appScheme}`,
496
+ action: {
497
+ type: "command",
498
+ command: xcodebuild([
499
+ "build",
500
+ "-workspace",
501
+ ios.workspace,
502
+ "-scheme",
503
+ ios.appScheme,
504
+ "-destination",
505
+ resolved.destination,
506
+ `RCT_METRO_PORT=${metro.port}`
507
+ ])
508
+ },
509
+ skippable: true
510
+ });
511
+ for (const [key, value] of Object.entries(ios.defaults ?? {})) {
512
+ push({
513
+ id: `ios.seed.${key}`,
514
+ stage: "device",
515
+ description: `Seed default ${key}`,
516
+ action: {
517
+ type: "command",
518
+ command: xcrun([
519
+ "simctl",
520
+ "spawn",
521
+ resolved.simUdid,
522
+ "defaults",
523
+ "write",
524
+ ios.bundleId,
525
+ ...defaultsArgs(key, value)
526
+ ]),
527
+ allowFailure: true
528
+ }
529
+ });
530
+ }
531
+ push({
532
+ id: "ios.free-port",
533
+ stage: "companion",
534
+ description: `Free stale listener on port ${resolved.touchPort}`,
535
+ action: { type: "free-port", port: resolved.touchPort }
536
+ });
537
+ push({
538
+ id: "ios.companion-start",
539
+ stage: "companion",
540
+ description: `Start XCTest companion on port ${resolved.touchPort}`,
541
+ action: {
542
+ type: "command",
543
+ background: true,
544
+ processKey: "companion",
545
+ command: xcodebuild(
546
+ [
547
+ "test",
548
+ "-workspace",
549
+ ios.workspace,
550
+ "-scheme",
551
+ resolved.uitestScheme,
552
+ "-destination",
553
+ resolved.destination,
554
+ `-only-testing:${resolved.uitestScheme}/${DEFAULTS.xctestServerTest}`,
555
+ `RCT_METRO_PORT=${metro.port}`
556
+ ],
557
+ {
558
+ RN_TOUCH_XCTEST_PORT: String(resolved.touchPort),
559
+ RN_TOUCH_XCTEST_CONFIG_FILE: resolved.runtimeConfigFile
560
+ }
561
+ )
562
+ }
563
+ });
564
+ push({
565
+ id: "ios.companion-ready",
566
+ stage: "companion",
567
+ description: "Wait for companion to accept a hello",
568
+ action: {
569
+ type: "probe",
570
+ probe: {
571
+ kind: "xctest-hello",
572
+ port: resolved.touchPort,
573
+ tokenFile: resolved.tokenFile,
574
+ timeoutMs: resolved.companionReadyTimeoutMs
575
+ }
576
+ }
577
+ });
578
+ if (isDevClient) {
579
+ push({
580
+ id: "ios.terminate-before-launch",
581
+ stage: "app-launch",
582
+ description: "Terminate any running instance (cold launch requires it)",
583
+ action: {
584
+ type: "command",
585
+ command: xcrun(["simctl", "terminate", resolved.simUdid, ios.bundleId]),
586
+ allowFailure: true
587
+ }
588
+ });
589
+ push({
590
+ id: "ios.launch",
591
+ stage: "app-launch",
592
+ description: `Cold-launch dev client via --initialUrl ${resolved.initialUrl}`,
593
+ action: {
594
+ type: "command",
595
+ command: xcrun([
596
+ "simctl",
597
+ "launch",
598
+ resolved.simUdid,
599
+ ios.bundleId,
600
+ "--initialUrl",
601
+ resolved.initialUrl
602
+ ])
603
+ }
604
+ });
605
+ }
606
+ push({
607
+ id: "ios.hermes",
608
+ stage: "hermes-target",
609
+ description: "Wait for Hermes target",
610
+ action: {
611
+ type: "probe",
612
+ probe: {
613
+ kind: "hermes-target",
614
+ platform: "ios",
615
+ metroUrl: metro.url,
616
+ appId: ios.bundleId,
617
+ deviceNameMatch: resolved.simName,
618
+ timeoutMs: resolved.hermesTimeoutMs
619
+ }
620
+ }
621
+ });
622
+ const cleanup = [
623
+ { type: "kill-process", processKey: "companion", description: "Stop XCTest companion" },
624
+ {
625
+ type: "free-port",
626
+ port: resolved.touchPort,
627
+ description: "Free companion port (reap sim-hosted child)"
628
+ },
629
+ { type: "kill-process", processKey: "metro", description: "Stop runner-owned Metro" },
630
+ { type: "remove-file", path: resolved.tokenFile, description: "Remove per-run token file" },
631
+ // REQ-SEC-004: the per-run runtime config (port + token-file ref) is written
632
+ // into the UI-test target every run; remove it so no generated artifact is
633
+ // left in the app project.
634
+ {
635
+ type: "remove-file",
636
+ path: resolved.runtimeConfigFile,
637
+ description: "Remove per-run companion runtime config"
638
+ }
639
+ ];
640
+ return {
641
+ platform: "ios",
642
+ steps,
643
+ cleanup,
644
+ driverEnv: buildIosDriverEnv(resolved, metro, timeoutMs),
645
+ playwright: playwrightCommand(playwright, specs, passthrough)
646
+ };
647
+ }
648
+ function runtimeConfigJson(resolved, ios) {
649
+ return JSON.stringify({
650
+ port: resolved.touchPort,
651
+ authTokenFile: resolved.tokenFile,
652
+ launch: ios.launch.mode
653
+ });
654
+ }
655
+ function defaultsArgs(key, value) {
656
+ if (typeof value === "boolean") return [key, "-bool", value ? "YES" : "NO"];
657
+ if (typeof value === "number") return [key, "-int", String(value)];
658
+ return [key, value];
659
+ }
660
+ function xcrun(args) {
661
+ return { command: "xcrun", args };
662
+ }
663
+ function xcodebuild(args, env) {
664
+ return env ? { command: "env", args: ["-u", "LD", "xcodebuild", ...args], env } : { command: "env", args: ["-u", "LD", "xcodebuild", ...args] };
665
+ }
666
+
667
+ // src/plan/resolved.ts
668
+ function resolveMetro(metro, overrides = {}) {
669
+ const host = overrides.host ?? metro?.host ?? DEFAULTS.metroHost;
670
+ const fromUrl = parseMetroUrl(overrides.url ?? metro?.url);
671
+ const port = fromUrl?.port ?? overrides.port ?? metro?.port ?? DEFAULTS.metroPort;
672
+ const resolvedHost = fromUrl?.host ?? host;
673
+ const url = fromUrl?.url ?? `http://${resolvedHost}:${port}`;
674
+ return {
675
+ url,
676
+ host: resolvedHost,
677
+ port,
678
+ command: metro?.command,
679
+ reuseExisting: metro?.reuseExisting ?? false,
680
+ readyTimeoutMs: metro?.readyTimeoutMs ?? DEFAULTS.metroReadyTimeoutMs
681
+ };
682
+ }
683
+ function parseMetroUrl(raw) {
684
+ if (!raw) return void 0;
685
+ const parsed = new URL(raw);
686
+ const port = parsed.port ? Number.parseInt(parsed.port, 10) : parsed.protocol === "https:" ? 443 : 80;
687
+ return { url: raw.replace(/\/$/, ""), host: parsed.hostname, port };
688
+ }
689
+ function uitestScheme(ios) {
690
+ return ios.uitestScheme ?? `${ios.appScheme}UITests`;
691
+ }
692
+ function instrumentationTarget(android) {
693
+ return android.instrumentationTarget ?? `${android.packageName}.test/${DEFAULTS.instrumentationClass}`;
694
+ }
695
+ function placeholderIos(ios, metro) {
696
+ return {
697
+ simUdid: "<sim-udid>",
698
+ simName: "<sim-name>",
699
+ destination: ios.destination ?? "platform=iOS Simulator,id=<sim-udid>",
700
+ uitestScheme: uitestScheme(ios),
701
+ touchPort: ios.companion?.port ?? DEFAULTS.companionPort,
702
+ companionReadyTimeoutMs: ios.companion?.readyTimeoutMs ?? DEFAULTS.iosCompanionReadyTimeoutMs,
703
+ hermesTimeoutMs: DEFAULTS.hermesTargetTimeoutMs,
704
+ tokenFile: SECRET_PLACEHOLDER,
705
+ runtimeConfigFile: "<runtime-config>",
706
+ initialUrl: ios.launch.initialUrl ?? metro.url
707
+ };
708
+ }
709
+ function placeholderAndroid(android, _metro) {
710
+ return {
711
+ serial: "<android-serial>",
712
+ touchPort: android.companion?.port ?? DEFAULTS.companionPort,
713
+ companionReadyTimeoutMs: android.companion?.readyTimeoutMs ?? DEFAULTS.androidCompanionReadyTimeoutMs,
714
+ hermesTimeoutMs: DEFAULTS.hermesTargetTimeoutMs,
715
+ tokenFile: SECRET_PLACEHOLDER,
716
+ deviceTokenFileName: DEFAULTS.androidTokenFileName,
717
+ instrumentationTarget: instrumentationTarget(android)
718
+ };
719
+ }
720
+
721
+ // src/build-plan.ts
722
+ function buildDryRunPlan(config, platform, opts = {}) {
723
+ const metro = resolveMetro(config.metro, opts.metroOverrides ?? {});
724
+ const specs = opts.specs ?? [];
725
+ const passthrough = opts.passthrough ?? [];
726
+ if (platform === "ios") {
727
+ const ios = config.ios;
728
+ if (!ios) throw new Error("config.ios is required to plan the ios platform");
729
+ return planIos({
730
+ ios,
731
+ metro,
732
+ resolved: placeholderIos(ios, metro),
733
+ playwright: config.playwright,
734
+ timeoutMs: config.timeoutMs,
735
+ specs,
736
+ passthrough
737
+ });
738
+ }
739
+ const android = config.android;
740
+ if (!android) throw new Error("config.android is required to plan the android platform");
741
+ return planAndroid({
742
+ android,
743
+ metro,
744
+ resolved: placeholderAndroid(android),
745
+ playwright: config.playwright,
746
+ timeoutMs: config.timeoutMs,
747
+ specs,
748
+ passthrough,
749
+ hermesDeviceName: "<android-device>"
750
+ });
751
+ }
752
+
753
+ // src/print-plan.ts
754
+ function renderPlan(plan) {
755
+ const lines = [];
756
+ lines.push(`Plan (${plan.platform}) \u2014 ${plan.steps.length} steps`);
757
+ for (const step of plan.steps) {
758
+ const tag = `[${step.stage}]`.padEnd(15);
759
+ const skip = step.skippable ? " (skip-build: skipped)" : "";
760
+ lines.push(` ${tag}${step.id} \u2014 ${step.description}${skip}`);
761
+ lines.push(` ${renderAction(step.action)}`);
762
+ }
763
+ lines.push("");
764
+ lines.push("Driver env (handed to Playwright):");
765
+ for (const [key, value] of Object.entries(plan.driverEnv)) {
766
+ lines.push(` ${key}=${value}`);
767
+ }
768
+ lines.push("");
769
+ lines.push("Playwright:");
770
+ lines.push(` ${renderCommand(plan.playwright)}`);
771
+ lines.push("");
772
+ lines.push("Cleanup (defensive, idempotent):");
773
+ for (const action of plan.cleanup) {
774
+ lines.push(` - ${action.description}: ${renderCleanup(action)}`);
775
+ }
776
+ return lines.join("\n");
777
+ }
778
+ function renderAction(action) {
779
+ switch (action.type) {
780
+ case "command":
781
+ return `${action.background ? "spawn " : "$ "}${renderCommand(action.command)}${action.allowFailure ? " (best-effort)" : ""}`;
782
+ case "write-file":
783
+ return `write ${action.path}${action.mode ? ` (mode ${action.mode.toString(8)})` : ""}`;
784
+ case "free-port":
785
+ return `free-port ${action.port}`;
786
+ case "probe":
787
+ return `probe ${renderProbe(action.probe)}`;
788
+ default: {
789
+ const _exhaustive = action;
790
+ throw new Error(`unhandled action: ${JSON.stringify(_exhaustive)}`);
791
+ }
792
+ }
793
+ }
794
+ function renderCleanup(action) {
795
+ switch (action.type) {
796
+ case "kill-process":
797
+ return `kill ${action.processKey}`;
798
+ case "free-port":
799
+ return `free-port ${action.port}`;
800
+ case "remove-file":
801
+ return `rm ${action.path}`;
802
+ case "command":
803
+ return `$ ${renderCommand(action.command)}`;
804
+ default: {
805
+ const _exhaustive = action;
806
+ throw new Error(`unhandled cleanup: ${JSON.stringify(_exhaustive)}`);
807
+ }
808
+ }
809
+ }
810
+ function renderCommand(command) {
811
+ const env = command.env ? `${Object.entries(command.env).map(([k, v]) => `${k}=${v}`).join(" ")} ` : "";
812
+ const stdin = command.stdinFromFile ? ` < ${command.stdinFromFile}` : command.stdinContents ? " < <stdin>" : "";
813
+ const cwd = command.cwd ? ` (cwd: ${command.cwd})` : "";
814
+ return `${env}${command.command} ${command.args.join(" ")}${stdin}${cwd}`.trim();
815
+ }
816
+ function renderProbe(probe) {
817
+ switch (probe.kind) {
818
+ case "metro-status":
819
+ return `metro-status ${probe.metroUrl} (\u2264${probe.timeoutMs}ms)`;
820
+ case "hermes-target":
821
+ return `hermes-target ${probe.appId}${probe.deviceNameMatch ? ` @ ${probe.deviceNameMatch}` : ""} (\u2264${probe.timeoutMs}ms)`;
822
+ case "xctest-hello":
823
+ return `xctest-hello :${probe.port} (\u2264${probe.timeoutMs}ms)`;
824
+ case "instrumentation-hello":
825
+ return `instrumentation-hello :${probe.port} (\u2264${probe.timeoutMs}ms)`;
826
+ default: {
827
+ const _exhaustive = probe;
828
+ throw new Error(`unhandled probe: ${JSON.stringify(_exhaustive)}`);
829
+ }
830
+ }
831
+ }
832
+
833
+ // src/validate.ts
834
+ var LAUNCH_MODES = ["launch", "activate", "attach"];
835
+ var LAUNCH_KINDS = ["plain", "expo-dev-client"];
836
+ var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["metro", "ios", "android", "playwright", "timeoutMs"]);
837
+ var METRO_KEYS = /* @__PURE__ */ new Set(["url", "command", "host", "port", "reuseExisting", "readyTimeoutMs"]);
838
+ var LAUNCH_KEYS = /* @__PURE__ */ new Set(["mode", "kind", "initialUrl"]);
839
+ var COMPANION_KEYS = /* @__PURE__ */ new Set(["port", "readyTimeoutMs"]);
840
+ var IOS_KEYS = /* @__PURE__ */ new Set([
841
+ "bundleId",
842
+ "workspace",
843
+ "appScheme",
844
+ "uitestScheme",
845
+ "destination",
846
+ "launch",
847
+ "companion",
848
+ "defaults"
849
+ ]);
850
+ var ANDROID_KEYS = /* @__PURE__ */ new Set([
851
+ "packageName",
852
+ "activity",
853
+ "gradleTasks",
854
+ "appApkPath",
855
+ "testApkPath",
856
+ "instrumentationTarget",
857
+ "launch",
858
+ "companion"
859
+ ]);
860
+ var PLAYWRIGHT_KEYS = /* @__PURE__ */ new Set(["config", "specs"]);
861
+ function validateConfig(config, platforms) {
862
+ const errors = [];
863
+ if (!isRecord(config)) {
864
+ return {
865
+ ok: false,
866
+ errors: ["config: expected an object exported as default from rn-driver.config"]
867
+ };
868
+ }
869
+ reportUnknownKeys("config", config, TOP_LEVEL_KEYS, errors);
870
+ if (config.timeoutMs !== void 0 && !isPositiveNumber(config.timeoutMs)) {
871
+ errors.push("config.timeoutMs: expected a positive number");
872
+ }
873
+ validateMetro(config.metro, errors);
874
+ validatePlaywright(config.playwright, errors);
875
+ if (platforms.includes("ios")) validateIos(config.ios, errors);
876
+ if (platforms.includes("android")) validateAndroid(config.android, errors);
877
+ return { ok: errors.length === 0, errors };
878
+ }
879
+ function validateMetro(metro, errors) {
880
+ if (metro === void 0) return;
881
+ if (!isRecord(metro)) {
882
+ errors.push("config.metro: expected an object");
883
+ return;
884
+ }
885
+ reportUnknownKeys("config.metro", metro, METRO_KEYS, errors);
886
+ optionalString("config.metro.url", metro.url, errors);
887
+ optionalString("config.metro.command", metro.command, errors);
888
+ optionalString("config.metro.host", metro.host, errors);
889
+ if (metro.port !== void 0 && !isPort(metro.port))
890
+ errors.push("config.metro.port: expected a port (1-65535)");
891
+ if (metro.reuseExisting !== void 0 && typeof metro.reuseExisting !== "boolean") {
892
+ errors.push("config.metro.reuseExisting: expected a boolean");
893
+ }
894
+ if (metro.readyTimeoutMs !== void 0 && !isPositiveNumber(metro.readyTimeoutMs)) {
895
+ errors.push("config.metro.readyTimeoutMs: expected a positive number");
896
+ }
897
+ }
898
+ function validatePlaywright(playwright, errors) {
899
+ if (playwright === void 0) return;
900
+ if (!isRecord(playwright)) {
901
+ errors.push("config.playwright: expected an object");
902
+ return;
903
+ }
904
+ reportUnknownKeys("config.playwright", playwright, PLAYWRIGHT_KEYS, errors);
905
+ optionalString("config.playwright.config", playwright.config, errors);
906
+ if (playwright.specs !== void 0 && !isStringArray(playwright.specs)) {
907
+ errors.push("config.playwright.specs: expected an array of strings");
908
+ }
909
+ }
910
+ function validateIos(ios, errors) {
911
+ if (!isRecord(ios)) {
912
+ errors.push('config.ios: required when platform "ios" is selected (expected an object)');
913
+ return;
914
+ }
915
+ reportUnknownKeys("config.ios", ios, IOS_KEYS, errors);
916
+ requireString("config.ios.bundleId", ios.bundleId, errors);
917
+ requireString("config.ios.workspace", ios.workspace, errors);
918
+ requireString("config.ios.appScheme", ios.appScheme, errors);
919
+ optionalString("config.ios.uitestScheme", ios.uitestScheme, errors);
920
+ optionalString("config.ios.destination", ios.destination, errors);
921
+ validateCompanion("config.ios.companion", ios.companion, errors);
922
+ if (ios.defaults !== void 0) {
923
+ if (!isRecord(ios.defaults)) {
924
+ errors.push("config.ios.defaults: expected an object of key -> string|number|boolean");
925
+ } else {
926
+ for (const [key, value] of Object.entries(ios.defaults)) {
927
+ const kind = typeof value;
928
+ if (kind !== "string" && kind !== "number" && kind !== "boolean") {
929
+ errors.push(`config.ios.defaults.${key}: expected string|number|boolean`);
930
+ }
931
+ }
932
+ }
933
+ }
934
+ const launch = validateLaunch("config.ios.launch", ios.launch, errors);
935
+ if (launch && launch.kind === "expo-dev-client" && launch.mode !== "attach") {
936
+ errors.push(
937
+ 'config.ios.launch: kind "expo-dev-client" requires mode "attach" (the host owns the launch)'
938
+ );
939
+ }
940
+ if (launch && launch.mode === "attach" && launch.kind !== "expo-dev-client") {
941
+ errors.push(
942
+ 'config.ios.launch: mode "attach" requires kind "expo-dev-client"; a plain app uses mode "launch" or "activate" (the companion launches it)'
943
+ );
944
+ }
945
+ }
946
+ function validateAndroid(android, errors) {
947
+ if (!isRecord(android)) {
948
+ errors.push('config.android: required when platform "android" is selected (expected an object)');
949
+ return;
950
+ }
951
+ reportUnknownKeys("config.android", android, ANDROID_KEYS, errors);
952
+ requireAndroidPackage("config.android.packageName", android.packageName, errors);
953
+ requireAndroidActivity("config.android.activity", android.activity, errors);
954
+ optionalString("config.android.appApkPath", android.appApkPath, errors);
955
+ optionalString("config.android.testApkPath", android.testApkPath, errors);
956
+ if (android.instrumentationTarget !== void 0)
957
+ requireInstrumentationTarget(
958
+ "config.android.instrumentationTarget",
959
+ android.instrumentationTarget,
960
+ errors
961
+ );
962
+ if (android.gradleTasks !== void 0 && !isStringArray(android.gradleTasks)) {
963
+ errors.push("config.android.gradleTasks: expected an array of strings");
964
+ }
965
+ validateCompanion("config.android.companion", android.companion, errors);
966
+ validateLaunch("config.android.launch", android.launch, errors);
967
+ }
968
+ function validateCompanion(path, companion, errors) {
969
+ if (companion === void 0) return;
970
+ if (!isRecord(companion)) {
971
+ errors.push(`${path}: expected an object`);
972
+ return;
973
+ }
974
+ reportUnknownKeys(path, companion, COMPANION_KEYS, errors);
975
+ if (companion.port !== void 0 && !isPort(companion.port))
976
+ errors.push(`${path}.port: expected a port (1-65535)`);
977
+ if (companion.readyTimeoutMs !== void 0 && !isPositiveNumber(companion.readyTimeoutMs)) {
978
+ errors.push(`${path}.readyTimeoutMs: expected a positive number`);
979
+ }
980
+ }
981
+ function validateLaunch(path, launch, errors) {
982
+ if (!isRecord(launch)) {
983
+ errors.push(`${path}: required (expected an object with mode and kind)`);
984
+ return void 0;
985
+ }
986
+ reportUnknownKeys(path, launch, LAUNCH_KEYS, errors);
987
+ optionalString(`${path}.initialUrl`, launch.initialUrl, errors);
988
+ const modeOk = typeof launch.mode === "string" && LAUNCH_MODES.includes(launch.mode);
989
+ const kindOk = typeof launch.kind === "string" && LAUNCH_KINDS.includes(launch.kind);
990
+ if (!modeOk) errors.push(`${path}.mode: expected one of ${LAUNCH_MODES.join(", ")}`);
991
+ if (!kindOk) errors.push(`${path}.kind: expected one of ${LAUNCH_KINDS.join(", ")}`);
992
+ return modeOk && kindOk ? { mode: launch.mode, kind: launch.kind } : void 0;
993
+ }
994
+ function isRecord(value) {
995
+ return typeof value === "object" && value !== null && !Array.isArray(value);
996
+ }
997
+ function isPositiveNumber(value) {
998
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
999
+ }
1000
+ function isPort(value) {
1001
+ return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535;
1002
+ }
1003
+ function isStringArray(value) {
1004
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
1005
+ }
1006
+ function requireString(path, value, errors) {
1007
+ if (typeof value !== "string" || value.trim() === "")
1008
+ errors.push(`${path}: required non-empty string`);
1009
+ }
1010
+ var ANDROID_PACKAGE_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
1011
+ var ANDROID_ACTIVITY_RE = /^\.?[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*$/;
1012
+ function requireAndroidPackage(path, value, errors) {
1013
+ if (typeof value !== "string" || value.trim() === "") {
1014
+ errors.push(`${path}: required non-empty string`);
1015
+ return;
1016
+ }
1017
+ if (!ANDROID_PACKAGE_RE.test(value))
1018
+ errors.push(`${path}: expected a valid Android application id (e.g. com.company.app)`);
1019
+ }
1020
+ function requireAndroidActivity(path, value, errors) {
1021
+ if (typeof value !== "string" || value.trim() === "") {
1022
+ errors.push(`${path}: required non-empty string`);
1023
+ return;
1024
+ }
1025
+ if (!ANDROID_ACTIVITY_RE.test(value))
1026
+ errors.push(`${path}: expected an activity name (e.g. .MainActivity)`);
1027
+ }
1028
+ 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_]*)*$/;
1029
+ function requireInstrumentationTarget(path, value, errors) {
1030
+ if (typeof value !== "string" || value.trim() === "") {
1031
+ errors.push(`${path}: expected a non-empty string`);
1032
+ return;
1033
+ }
1034
+ if (!ANDROID_INSTRUMENTATION_RE.test(value))
1035
+ errors.push(`${path}: expected an am instrument target (e.g. com.app.test/com.app.Runner)`);
1036
+ }
1037
+ function optionalString(path, value, errors) {
1038
+ if (value !== void 0 && typeof value !== "string") errors.push(`${path}: expected a string`);
1039
+ }
1040
+ function reportUnknownKeys(path, value, allowed, errors) {
1041
+ for (const key of Object.keys(value)) {
1042
+ if (!allowed.has(key)) errors.push(`${path}.${key}: unknown key`);
1043
+ }
1044
+ }
1045
+ var ConfigValidationError = class extends Error {
1046
+ errors;
1047
+ constructor(errors) {
1048
+ super(`Invalid rn-driver config:
1049
+ - ${errors.join("\n - ")}`);
1050
+ this.name = "ConfigValidationError";
1051
+ this.errors = errors;
1052
+ }
1053
+ };
1054
+
1055
+ export { ConfigValidationError, buildDryRunPlan, defineRnDriverConfig, renderPlan, validateConfig };
1056
+ //# sourceMappingURL=index.mjs.map
1057
+ //# sourceMappingURL=index.mjs.map