@unrulysystems/rn-playwright-driver-runner 0.1.0 → 0.1.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @unrulysystems/rn-playwright-driver-runner
2
2
 
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#27](https://github.com/unrulysystems/rn-playwright-driver/pull/27) [`b8eb24e`](https://github.com/unrulysystems/rn-playwright-driver/commit/b8eb24e9daef7f00605fe06dcf520089f62f58be) Thanks [@alleneubank](https://github.com/alleneubank)! - Harden the runner and driver fixtures for dev-client dogfooding.
8
+
9
+ The runner now ships a Node-compatible `rn-driver` bin, supports Android
10
+ Expo dev-client deep-link launch, and documents runner-owned lifecycle
11
+ boundaries. The driver Playwright fixture resolves `@playwright/test` from the
12
+ consumer project so npm and Yarn installs use the app's Playwright instance.
13
+
3
14
  ## 0.1.0
4
15
 
5
16
  ### Initial Release
package/README.md CHANGED
@@ -29,7 +29,7 @@ this package; your Playwright specs import the driver directly.)
29
29
  ## Install
30
30
 
31
31
  ```bash
32
- bun add -d @unrulysystems/rn-playwright-driver-runner
32
+ npm install --save-dev @unrulysystems/rn-playwright-driver-runner
33
33
  ```
34
34
 
35
35
  This package orchestrates the platform companions; install and configure them per
@@ -68,6 +68,8 @@ export default defineRnDriverConfig({
68
68
  android: {
69
69
  packageName: 'com.company.app',
70
70
  activity: '.MainActivity',
71
+ // Required only for expo-dev-client launch:
72
+ // scheme: 'companyapp',
71
73
  launch: { mode: 'launch', kind: 'plain' },
72
74
  },
73
75
  playwright: {
@@ -77,7 +79,34 @@ export default defineRnDriverConfig({
77
79
  ```
78
80
 
79
81
  A plain (non dev-client) Expo app uses `launch: { mode: 'launch', kind: 'plain' }`
80
- on iOS the companion launches the app itself.
82
+ on both platforms. The companion launches the app itself on iOS; Android launches
83
+ the configured `packageName`/`activity` directly.
84
+
85
+ For Expo dev-client, the host owns native app launch so the app starts on the
86
+ test Metro instead of stopping at the launcher UI:
87
+
88
+ ```ts
89
+ export default defineRnDriverConfig({
90
+ metro: { command: 'npx expo start --localhost --port 8081' },
91
+ ios: {
92
+ bundleId: 'com.company.app',
93
+ workspace: 'ios/App.xcworkspace',
94
+ appScheme: 'App',
95
+ launch: { mode: 'attach', kind: 'expo-dev-client' },
96
+ },
97
+ android: {
98
+ packageName: 'com.company.app',
99
+ activity: '.MainActivity',
100
+ scheme: 'companyapp',
101
+ launch: { mode: 'launch', kind: 'expo-dev-client' },
102
+ },
103
+ })
104
+ ```
105
+
106
+ `launch.initialUrl` defaults to the resolved Metro URL on iOS and Android. iOS
107
+ uses `simctl launch --initialUrl`; Android uses the configured
108
+ `android.scheme` to open
109
+ `<scheme>://expo-development-client/?url=<resolved-metro-url>`.
81
110
 
82
111
  ## Run
83
112
 
@@ -128,13 +157,57 @@ Token material always travels by `0600` file path (the driver's
128
157
  `--dry-run` output. Cleanup is defensive and idempotent — a crashed prior run
129
158
  never wedges the next (stale companion ports are freed at startup and teardown).
130
159
 
160
+ ### Playwright lifecycle boundary
161
+
162
+ Invoke specs through `rn-driver test`, not a standalone `playwright test`
163
+ command. The runner starts or reuses Metro, builds and launches the native app,
164
+ starts the companion, waits for Hermes, sets the driver env contract, runs
165
+ Playwright, and cleans up the state it owns.
166
+
167
+ Runner-managed Playwright configs should not define app-level `globalSetup` or
168
+ `globalTeardown` that starts/stops Metro, launches the native app, starts/stops a
169
+ touch companion, or removes runner companion state. Keep those hooks for
170
+ test-local concerns only. Specs should consume the environment that
171
+ `rn-driver test` provides.
172
+
173
+ ### Runner env contract
174
+
175
+ These names are the runner-owned surface between native lifecycle setup and
176
+ Playwright/driver execution. Token values are never exposed; only file paths are.
177
+
178
+ | Variable | Scope | Meaning |
179
+ | ---------------------------------------------------------------------- | ------- | -------------------------------------------------- |
180
+ | `RN_METRO_URL` | both | Metro URL the app and driver should use |
181
+ | `RN_DEVICE_NAME` | both | Hermes target device-name pin |
182
+ | `RN_TIMEOUT` | both | Driver request timeout |
183
+ | `RN_TOUCH_BACKEND` | both | `xctest` on iOS, `instrumentation` on Android |
184
+ | `RN_TOUCH_XCTEST_PORT`, `RN_TOUCH_XCTEST_TOKEN_FILE` | iOS | XCTest companion port and token-file path |
185
+ | `RN_TOUCH_INSTRUMENTATION_PORT`, `RN_TOUCH_INSTRUMENTATION_TOKEN_FILE` | Android | Instrumentation companion port and token-file path |
186
+ | `ANDROID_SERIAL` | Android | adb device pin for the selected emulator/device |
187
+
188
+ The runner also owns internal token/config file paths used to start companions
189
+ and configure native test targets. Do not pass token values through argv, inline
190
+ env, or docs.
191
+
192
+ ### Prebuild environment and priming
193
+
194
+ `expo prebuild` runs inside the runner process, so it inherits the runner
195
+ process environment. The intended stable marker for test-only Expo config,
196
+ plugins, or native settings is `RN_E2E=1`; this package does not emit that marker
197
+ yet, so treat it as the planned contract rather than current behavior.
198
+
199
+ The implemented lifecycle already covers prebuild, Metro, app launch, companion
200
+ startup, Hermes waits, Playwright env, and cleanup. Priming controls such as
201
+ `RN_E2E_PRIMED=1` or a `prebuild.clean` option are future design space and are
202
+ not available runner flags today.
203
+
131
204
  ## Requirements
132
205
 
133
- - The `rn-driver` CLI runs under **bun**. The `bin` is a thin bun shim (mirroring
134
- the driver's `rn-inspect`) so a TypeScript `rn-driver.config.ts` loads without a
135
- separate compile step. Install bun: <https://bun.sh>.
206
+ - The `rn-driver` CLI runs under **Node >= 22**. The published `bin` is a thin
207
+ Node shim that loads built JavaScript from `dist`, so npm/Yarn consumers do not
208
+ need bun or nub on `PATH`.
136
209
  - The readiness probes use the global `WebSocket`/`fetch`, so embedding the
137
- library API (the `.` export) standalone requires **bun** or **Node >= 22**.
210
+ library API (the `.` export) standalone requires **Node >= 22**.
138
211
  - The platform companion packages installed and their Expo config plugins added.
139
212
  - `xcrun`/`xcodebuild`/`pod` (iOS) and `adb`/`gradle` (Android) on `PATH`.
140
213
 
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../dist/cli.mjs'
3
+
4
+ try {
5
+ process.exitCode = await run(process.argv.slice(2))
6
+ } catch (error) {
7
+ console.error(error)
8
+ process.exitCode = 1
9
+ }
package/dist/cli.js CHANGED
@@ -216,9 +216,10 @@ function planAndroid(input) {
216
216
  }
217
217
  }
218
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));
219
+ const launch1Command = launchCommandFor(android, resolved, serial, { forceStopBefore: true });
220
+ const launch2Command = launchCommandFor(android, resolved, serial, { forceStopBefore: false });
221
+ push(launchStep("android.launch-1", android, launch1Command));
222
+ push(hermesStep("android.hermes-1", android, metro, resolved, hermesDeviceName, launch1Command));
222
223
  push({
223
224
  id: "android.forward-clean",
224
225
  stage: "companion",
@@ -275,8 +276,8 @@ function planAndroid(input) {
275
276
  }
276
277
  }
277
278
  });
278
- push(launchStep("android.launch-2", android, serial));
279
- push(hermesStep("android.hermes-2", android, metro, resolved, hermesDeviceName, launchCommand));
279
+ push(launchStep("android.launch-2", android, launch2Command));
280
+ push(hermesStep("android.hermes-2", android, metro, resolved, hermesDeviceName, launch2Command));
280
281
  const cleanup = [
281
282
  {
282
283
  type: "kill-process",
@@ -331,22 +332,34 @@ function planAndroid(input) {
331
332
  playwright: playwrightCommand(playwright, specs, passthrough)
332
333
  };
333
334
  }
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) {
335
+ function launchCommandFor(android, resolved, serial, opts) {
336
+ if (android.launch.kind === "plain") {
337
+ return adb(serial, [
338
+ "shell",
339
+ "am",
340
+ "start",
341
+ "-W",
342
+ "-n",
343
+ `${android.packageName}/${android.activity}`
344
+ ]);
345
+ }
346
+ if (!android.scheme) {
347
+ throw new Error('android.scheme is required when android.launch.kind is "expo-dev-client"');
348
+ }
349
+ const launchScript = `am start -a android.intent.action.VIEW -d ${shellSingleQuote(
350
+ devClientUrl(android.scheme, resolved.initialUrl)
351
+ )}`;
352
+ return adbShellScript(
353
+ serial,
354
+ opts.forceStopBefore ? `am force-stop ${android.packageName} && ${launchScript}` : launchScript
355
+ );
356
+ }
357
+ function launchStep(id, android, command) {
345
358
  return {
346
359
  id,
347
360
  stage: "app-launch",
348
361
  description: `Launch ${android.packageName}/${android.activity}`,
349
- action: { type: "command", command: launchCommandFor(android, serial) }
362
+ action: { type: "command", command }
350
363
  };
351
364
  }
352
365
  function hermesStep(id, android, metro, resolved, deviceNameMatch, launchCommand) {
@@ -380,6 +393,12 @@ function debugHostXml(host) {
380
393
  function adb(serial, args) {
381
394
  return { command: "adb", args: ["-s", serial, ...args] };
382
395
  }
396
+ function devClientUrl(scheme, initialUrl) {
397
+ return `${scheme}://expo-development-client/?url=${initialUrl}`;
398
+ }
399
+ function shellSingleQuote(value) {
400
+ return `'${value.replaceAll("'", "'\\''")}'`;
401
+ }
383
402
  function adbShellScript(serial, remote) {
384
403
  return { command: "adb", args: ["-s", serial, "shell", remote] };
385
404
  }
@@ -425,8 +444,7 @@ function planIos(input) {
425
444
  // Pass the resolved UI-test scheme so a custom `ios.uitestScheme` scaffolds
426
445
  // the SAME target that companion startup later builds (default is
427
446
  // `${appScheme}UITests`).
428
- command: npx([
429
- "rn-driver-xctest-scaffold",
447
+ command: cmd("node_modules/.bin/rn-driver-xctest-scaffold", [
430
448
  "--ios-dir",
431
449
  "ios",
432
450
  "--project-name",
@@ -720,7 +738,7 @@ function placeholderIos(ios, metro) {
720
738
  initialUrl: ios.launch.initialUrl ?? metro.url
721
739
  };
722
740
  }
723
- function placeholderAndroid(android, _metro) {
741
+ function placeholderAndroid(android, metro) {
724
742
  return {
725
743
  serial: "<android-serial>",
726
744
  touchPort: android.companion?.port ?? DEFAULTS.companionPort,
@@ -728,7 +746,8 @@ function placeholderAndroid(android, _metro) {
728
746
  hermesTimeoutMs: DEFAULTS.hermesTargetTimeoutMs,
729
747
  tokenFile: SECRET_PLACEHOLDER,
730
748
  deviceTokenFileName: DEFAULTS.androidTokenFileName,
731
- instrumentationTarget: instrumentationTarget(android)
749
+ instrumentationTarget: instrumentationTarget(android),
750
+ initialUrl: android.launch.initialUrl ?? metro.url
732
751
  };
733
752
  }
734
753
 
@@ -755,7 +774,7 @@ function buildDryRunPlan(config, platform, opts = {}) {
755
774
  return planAndroid({
756
775
  android,
757
776
  metro,
758
- resolved: placeholderAndroid(android),
777
+ resolved: placeholderAndroid(android, metro),
759
778
  playwright: config.playwright,
760
779
  timeoutMs: config.timeoutMs,
761
780
  specs,
@@ -1295,7 +1314,7 @@ async function resolveIosTarget(ios, metro, opts) {
1295
1314
  initialUrl: ios.launch.initialUrl ?? metro.url
1296
1315
  };
1297
1316
  }
1298
- async function resolveAndroidTarget(android, _metro, opts) {
1317
+ async function resolveAndroidTarget(android, metro, opts) {
1299
1318
  const serial = await selectSerial(opts.device);
1300
1319
  await requireBooted(serial);
1301
1320
  const deviceName = (await capture("adb", ["-s", serial, "shell", "getprop", "ro.product.model"])).trim();
@@ -1308,7 +1327,8 @@ async function resolveAndroidTarget(android, _metro, opts) {
1308
1327
  hermesTimeoutMs: DEFAULTS.hermesTargetTimeoutMs,
1309
1328
  tokenFile,
1310
1329
  deviceTokenFileName: DEFAULTS.androidTokenFileName,
1311
- instrumentationTarget: instrumentationTarget(android)
1330
+ instrumentationTarget: instrumentationTarget(android),
1331
+ initialUrl: android.launch.initialUrl ?? metro.url
1312
1332
  },
1313
1333
  deviceName
1314
1334
  };
@@ -1413,6 +1433,7 @@ var IOS_KEYS = /* @__PURE__ */ new Set([
1413
1433
  var ANDROID_KEYS = /* @__PURE__ */ new Set([
1414
1434
  "packageName",
1415
1435
  "activity",
1436
+ "scheme",
1416
1437
  "gradleTasks",
1417
1438
  "appApkPath",
1418
1439
  "testApkPath",
@@ -1514,6 +1535,8 @@ function validateAndroid(android, errors) {
1514
1535
  reportUnknownKeys("config.android", android, ANDROID_KEYS, errors);
1515
1536
  requireAndroidPackage("config.android.packageName", android.packageName, errors);
1516
1537
  requireAndroidActivity("config.android.activity", android.activity, errors);
1538
+ if (android.scheme !== void 0)
1539
+ requireAppScheme("config.android.scheme", android.scheme, errors);
1517
1540
  optionalString("config.android.appApkPath", android.appApkPath, errors);
1518
1541
  optionalString("config.android.testApkPath", android.testApkPath, errors);
1519
1542
  if (android.instrumentationTarget !== void 0)
@@ -1526,7 +1549,10 @@ function validateAndroid(android, errors) {
1526
1549
  errors.push("config.android.gradleTasks: expected an array of strings");
1527
1550
  }
1528
1551
  validateCompanion("config.android.companion", android.companion, errors);
1529
- validateLaunch("config.android.launch", android.launch, errors);
1552
+ const launch = validateLaunch("config.android.launch", android.launch, errors);
1553
+ if (launch?.kind === "expo-dev-client" && android.scheme === void 0) {
1554
+ errors.push('config.android.scheme: required when android.launch.kind is "expo-dev-client"');
1555
+ }
1530
1556
  }
1531
1557
  function validateCompanion(path4, companion, errors) {
1532
1558
  if (companion === void 0) return;
@@ -1572,6 +1598,7 @@ function requireString(path4, value, errors) {
1572
1598
  }
1573
1599
  var ANDROID_PACKAGE_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
1574
1600
  var ANDROID_ACTIVITY_RE = /^\.?[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*$/;
1601
+ var APP_SCHEME_RE = /^[a-z][a-z0-9+.-]*$/;
1575
1602
  function requireAndroidPackage(path4, value, errors) {
1576
1603
  if (typeof value !== "string" || value.trim() === "") {
1577
1604
  errors.push(`${path4}: required non-empty string`);
@@ -1588,6 +1615,17 @@ function requireAndroidActivity(path4, value, errors) {
1588
1615
  if (!ANDROID_ACTIVITY_RE.test(value))
1589
1616
  errors.push(`${path4}: expected an activity name (e.g. .MainActivity)`);
1590
1617
  }
1618
+ function requireAppScheme(path4, value, errors) {
1619
+ if (typeof value !== "string" || value.trim() === "") {
1620
+ errors.push(`${path4}: required non-empty string`);
1621
+ return;
1622
+ }
1623
+ if (!APP_SCHEME_RE.test(value)) {
1624
+ errors.push(
1625
+ `${path4}: expected a valid URL scheme (lowercase letter, then lowercase letters, digits, +, ., or -)`
1626
+ );
1627
+ }
1628
+ }
1591
1629
  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
1630
  function requireInstrumentationTarget(path4, value, errors) {
1593
1631
  if (typeof value !== "string" || value.trim() === "") {