@unrulysystems/rn-playwright-driver-runner 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @unrulysystems/rn-playwright-driver-runner
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#30](https://github.com/unrulysystems/rn-playwright-driver/pull/30) [`4bdbe20`](https://github.com/unrulysystems/rn-playwright-driver/commit/4bdbe2073f7b613f6bfb8d5a13db5e0ca000e1c5) Thanks [@alleneubank](https://github.com/alleneubank)! - Upgrade the dogfood example and native module tooling to Expo SDK 56.
8
+
9
+ The driver now sends a React Native inspector-compatible WebSocket Origin when
10
+ attaching to Hermes CDP, which keeps Expo SDK 56 dev-client debugging connected
11
+ on localhost Metro servers. The runner also fast-fails companion readiness when
12
+ captured iOS or Android companion logs contain terminal build, test, or
13
+ instrumentation failure markers instead of waiting for the full probe timeout.
14
+
15
+ ## 0.1.1
16
+
17
+ ### Patch Changes
18
+
19
+ - [#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.
20
+
21
+ The runner now ships a Node-compatible `rn-driver` bin, supports Android
22
+ Expo dev-client deep-link launch, and documents runner-owned lifecycle
23
+ boundaries. The driver Playwright fixture resolves `@playwright/test` from the
24
+ consumer project so npm and Yarn installs use the app's Playwright instance.
25
+
3
26
  ## 0.1.0
4
27
 
5
28
  ### 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
@@ -54,6 +54,10 @@ var DEFAULTS = {
54
54
  androidTokenFileName: "rn-driver-touch-token"
55
55
  };
56
56
  var SECRET_PLACEHOLDER = "<token-file>";
57
+ var COMPANION_FAILURE_MARKERS = {
58
+ ios: ["** BUILD FAILED **", "** TEST FAILED **"],
59
+ android: ["INSTRUMENTATION_FAILED", "Process crashed"]
60
+ };
57
61
 
58
62
  // src/plan/env.ts
59
63
  function buildIosDriverEnv(resolved, metro, timeoutMs) {
@@ -216,9 +220,10 @@ function planAndroid(input) {
216
220
  }
217
221
  }
218
222
  });
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));
223
+ const launch1Command = launchCommandFor(android, resolved, serial, { forceStopBefore: true });
224
+ const launch2Command = launchCommandFor(android, resolved, serial, { forceStopBefore: false });
225
+ push(launchStep("android.launch-1", android, launch1Command));
226
+ push(hermesStep("android.hermes-1", android, metro, resolved, hermesDeviceName, launch1Command));
222
227
  push({
223
228
  id: "android.forward-clean",
224
229
  stage: "companion",
@@ -272,11 +277,14 @@ function planAndroid(input) {
272
277
  port: resolved.touchPort,
273
278
  tokenFile: resolved.tokenFile,
274
279
  timeoutMs: resolved.companionReadyTimeoutMs
275
- }
280
+ },
281
+ // Abort early if `am instrument` reports the companion failed to start (crash / missing
282
+ // androidTest target) instead of waiting out the readiness budget.
283
+ failureMarkers: COMPANION_FAILURE_MARKERS.android
276
284
  }
277
285
  });
278
- push(launchStep("android.launch-2", android, serial));
279
- push(hermesStep("android.hermes-2", android, metro, resolved, hermesDeviceName, launchCommand));
286
+ push(launchStep("android.launch-2", android, launch2Command));
287
+ push(hermesStep("android.hermes-2", android, metro, resolved, hermesDeviceName, launch2Command));
280
288
  const cleanup = [
281
289
  {
282
290
  type: "kill-process",
@@ -331,22 +339,34 @@ function planAndroid(input) {
331
339
  playwright: playwrightCommand(playwright, specs, passthrough)
332
340
  };
333
341
  }
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) {
342
+ function launchCommandFor(android, resolved, serial, opts) {
343
+ if (android.launch.kind === "plain") {
344
+ return adb(serial, [
345
+ "shell",
346
+ "am",
347
+ "start",
348
+ "-W",
349
+ "-n",
350
+ `${android.packageName}/${android.activity}`
351
+ ]);
352
+ }
353
+ if (!android.scheme) {
354
+ throw new Error('android.scheme is required when android.launch.kind is "expo-dev-client"');
355
+ }
356
+ const launchScript = `am start -a android.intent.action.VIEW -d ${shellSingleQuote(
357
+ devClientUrl(android.scheme, resolved.initialUrl)
358
+ )}`;
359
+ return adbShellScript(
360
+ serial,
361
+ opts.forceStopBefore ? `am force-stop ${android.packageName} && ${launchScript}` : launchScript
362
+ );
363
+ }
364
+ function launchStep(id, android, command) {
345
365
  return {
346
366
  id,
347
367
  stage: "app-launch",
348
368
  description: `Launch ${android.packageName}/${android.activity}`,
349
- action: { type: "command", command: launchCommandFor(android, serial) }
369
+ action: { type: "command", command }
350
370
  };
351
371
  }
352
372
  function hermesStep(id, android, metro, resolved, deviceNameMatch, launchCommand) {
@@ -380,6 +400,12 @@ function debugHostXml(host) {
380
400
  function adb(serial, args) {
381
401
  return { command: "adb", args: ["-s", serial, ...args] };
382
402
  }
403
+ function devClientUrl(scheme, initialUrl) {
404
+ return `${scheme}://expo-development-client/?url=${initialUrl}`;
405
+ }
406
+ function shellSingleQuote(value) {
407
+ return `'${value.replaceAll("'", "'\\''")}'`;
408
+ }
383
409
  function adbShellScript(serial, remote) {
384
410
  return { command: "adb", args: ["-s", serial, "shell", remote] };
385
411
  }
@@ -425,8 +451,7 @@ function planIos(input) {
425
451
  // Pass the resolved UI-test scheme so a custom `ios.uitestScheme` scaffolds
426
452
  // the SAME target that companion startup later builds (default is
427
453
  // `${appScheme}UITests`).
428
- command: npx([
429
- "rn-driver-xctest-scaffold",
454
+ command: cmd("node_modules/.bin/rn-driver-xctest-scaffold", [
430
455
  "--ios-dir",
431
456
  "ios",
432
457
  "--project-name",
@@ -586,7 +611,10 @@ function planIos(input) {
586
611
  port: resolved.touchPort,
587
612
  tokenFile: resolved.tokenFile,
588
613
  timeoutMs: resolved.companionReadyTimeoutMs
589
- }
614
+ },
615
+ // Abort early if `xcodebuild test` reports a build/test failure (it lingers "alive" after, so
616
+ // the 300s readiness budget would otherwise be burnt waiting for a companion that cannot bind).
617
+ failureMarkers: COMPANION_FAILURE_MARKERS.ios
590
618
  }
591
619
  });
592
620
  if (isDevClient) {
@@ -720,7 +748,7 @@ function placeholderIos(ios, metro) {
720
748
  initialUrl: ios.launch.initialUrl ?? metro.url
721
749
  };
722
750
  }
723
- function placeholderAndroid(android, _metro) {
751
+ function placeholderAndroid(android, metro) {
724
752
  return {
725
753
  serial: "<android-serial>",
726
754
  touchPort: android.companion?.port ?? DEFAULTS.companionPort,
@@ -728,7 +756,8 @@ function placeholderAndroid(android, _metro) {
728
756
  hermesTimeoutMs: DEFAULTS.hermesTargetTimeoutMs,
729
757
  tokenFile: SECRET_PLACEHOLDER,
730
758
  deviceTokenFileName: DEFAULTS.androidTokenFileName,
731
- instrumentationTarget: instrumentationTarget(android)
759
+ instrumentationTarget: instrumentationTarget(android),
760
+ initialUrl: android.launch.initialUrl ?? metro.url
732
761
  };
733
762
  }
734
763
 
@@ -755,7 +784,7 @@ function buildDryRunPlan(config, platform, opts = {}) {
755
784
  return planAndroid({
756
785
  android,
757
786
  metro,
758
- resolved: placeholderAndroid(android),
787
+ resolved: placeholderAndroid(android, metro),
759
788
  playwright: config.playwright,
760
789
  timeoutMs: config.timeoutMs,
761
790
  specs,
@@ -841,8 +870,10 @@ function renderAction(action) {
841
870
  return `write ${action.path}${action.mode ? ` (mode ${action.mode.toString(8)})` : ""}`;
842
871
  case "free-port":
843
872
  return `free-port ${action.port}`;
844
- case "probe":
845
- return `probe ${renderProbe(action.probe)}`;
873
+ case "probe": {
874
+ const fastFail = action.failureMarkers?.length ? ` [fast-fail on: ${action.failureMarkers.join(", ")}]` : "";
875
+ return `probe ${renderProbe(action.probe)}${fastFail}`;
876
+ }
846
877
  default: {
847
878
  const _exhaustive = action;
848
879
  throw new Error(`unhandled action: ${JSON.stringify(_exhaustive)}`);
@@ -888,6 +919,25 @@ function renderProbe(probe) {
888
919
  }
889
920
  }
890
921
 
922
+ // src/runner/probe-failure.ts
923
+ var ProbeFailure = class extends Error {
924
+ marker;
925
+ constructor(marker, detail) {
926
+ super(
927
+ `companion process reported a terminal failure ("${marker}") \u2014 aborting the readiness wait. The build/test failed; it will not become ready.${detail ? `
928
+ ${detail}` : ""}`
929
+ );
930
+ this.name = "ProbeFailure";
931
+ this.marker = marker;
932
+ }
933
+ };
934
+ function findFailureMarker(log, markers) {
935
+ for (const marker of markers) {
936
+ if (log.includes(marker)) return marker;
937
+ }
938
+ return null;
939
+ }
940
+
891
941
  // src/runner/execute.ts
892
942
  var StageError = class extends Error {
893
943
  stage;
@@ -955,22 +1005,30 @@ async function runStep(step, runner, opts, processes, isAlive) {
955
1005
  case "probe": {
956
1006
  const key = processKeyForProbe(action.probe);
957
1007
  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);
1008
+ const watch = key !== null && action.failureMarkers && action.failureMarkers.length > 0 ? { logPath: logPathFor(opts.logDir, key), failureMarkers: action.failureMarkers } : void 0;
1009
+ try {
1010
+ let ready = await runner.probe(action.probe, aliveFn, watch);
1011
+ let remaining = action.retry?.max ?? 0;
1012
+ while (!ready && remaining > 0) {
1013
+ remaining -= 1;
1014
+ if (action.retry) {
1015
+ runner.log(`retry ${step.id}: re-running launch (${remaining} attempt(s) left)`);
1016
+ await runner.exec(action.retry.command);
1017
+ }
1018
+ ready = await runner.probe(action.probe, aliveFn, watch);
965
1019
  }
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
- );
1020
+ if (!ready) {
1021
+ throw new StageError(
1022
+ step.stage,
1023
+ step.id,
1024
+ `readiness timed out after ${action.probe.timeoutMs}ms`
1025
+ );
1026
+ }
1027
+ } catch (error) {
1028
+ if (error instanceof ProbeFailure) {
1029
+ throw new StageError(step.stage, step.id, error.message);
1030
+ }
1031
+ throw error;
974
1032
  }
975
1033
  return;
976
1034
  }
@@ -1109,10 +1167,15 @@ var NodeProcessRunner = class {
1109
1167
  }
1110
1168
  if (pids.length > 0) await delay(1e3);
1111
1169
  }
1112
- probe(probe, isAlive) {
1170
+ probe(probe, isAlive, watch) {
1113
1171
  const deadline = Date.now() + probe.timeoutMs;
1114
1172
  const attempt = async () => {
1115
1173
  for (; ; ) {
1174
+ if (watch) {
1175
+ const log = await readWatchedLog(watch.logPath);
1176
+ const marker = findFailureMarker(log, watch.failureMarkers);
1177
+ if (marker) throw new ProbeFailure(marker, lastLines(log));
1178
+ }
1116
1179
  if (!isAlive()) return false;
1117
1180
  if (await probeOnce(probe)) return true;
1118
1181
  if (Date.now() >= deadline) return false;
@@ -1157,6 +1220,19 @@ function delay(ms) {
1157
1220
  setTimeout(resolve, ms);
1158
1221
  });
1159
1222
  }
1223
+ async function readWatchedLog(path4) {
1224
+ try {
1225
+ return await promises.readFile(path4, "utf8");
1226
+ } catch (error) {
1227
+ throw new Error(
1228
+ `cannot read companion log for fast-fail marker detection (${path4}): ${String(error)}`,
1229
+ { cause: error }
1230
+ );
1231
+ }
1232
+ }
1233
+ function lastLines(text, n) {
1234
+ return text.split("\n").filter((line) => line.trim().length > 0).slice(-12).join("\n");
1235
+ }
1160
1236
  async function probeOnce(probe) {
1161
1237
  switch (probe.kind) {
1162
1238
  case "metro-status":
@@ -1295,7 +1371,7 @@ async function resolveIosTarget(ios, metro, opts) {
1295
1371
  initialUrl: ios.launch.initialUrl ?? metro.url
1296
1372
  };
1297
1373
  }
1298
- async function resolveAndroidTarget(android, _metro, opts) {
1374
+ async function resolveAndroidTarget(android, metro, opts) {
1299
1375
  const serial = await selectSerial(opts.device);
1300
1376
  await requireBooted(serial);
1301
1377
  const deviceName = (await capture("adb", ["-s", serial, "shell", "getprop", "ro.product.model"])).trim();
@@ -1308,7 +1384,8 @@ async function resolveAndroidTarget(android, _metro, opts) {
1308
1384
  hermesTimeoutMs: DEFAULTS.hermesTargetTimeoutMs,
1309
1385
  tokenFile,
1310
1386
  deviceTokenFileName: DEFAULTS.androidTokenFileName,
1311
- instrumentationTarget: instrumentationTarget(android)
1387
+ instrumentationTarget: instrumentationTarget(android),
1388
+ initialUrl: android.launch.initialUrl ?? metro.url
1312
1389
  },
1313
1390
  deviceName
1314
1391
  };
@@ -1413,6 +1490,7 @@ var IOS_KEYS = /* @__PURE__ */ new Set([
1413
1490
  var ANDROID_KEYS = /* @__PURE__ */ new Set([
1414
1491
  "packageName",
1415
1492
  "activity",
1493
+ "scheme",
1416
1494
  "gradleTasks",
1417
1495
  "appApkPath",
1418
1496
  "testApkPath",
@@ -1514,6 +1592,8 @@ function validateAndroid(android, errors) {
1514
1592
  reportUnknownKeys("config.android", android, ANDROID_KEYS, errors);
1515
1593
  requireAndroidPackage("config.android.packageName", android.packageName, errors);
1516
1594
  requireAndroidActivity("config.android.activity", android.activity, errors);
1595
+ if (android.scheme !== void 0)
1596
+ requireAppScheme("config.android.scheme", android.scheme, errors);
1517
1597
  optionalString("config.android.appApkPath", android.appApkPath, errors);
1518
1598
  optionalString("config.android.testApkPath", android.testApkPath, errors);
1519
1599
  if (android.instrumentationTarget !== void 0)
@@ -1526,7 +1606,10 @@ function validateAndroid(android, errors) {
1526
1606
  errors.push("config.android.gradleTasks: expected an array of strings");
1527
1607
  }
1528
1608
  validateCompanion("config.android.companion", android.companion, errors);
1529
- validateLaunch("config.android.launch", android.launch, errors);
1609
+ const launch = validateLaunch("config.android.launch", android.launch, errors);
1610
+ if (launch?.kind === "expo-dev-client" && android.scheme === void 0) {
1611
+ errors.push('config.android.scheme: required when android.launch.kind is "expo-dev-client"');
1612
+ }
1530
1613
  }
1531
1614
  function validateCompanion(path4, companion, errors) {
1532
1615
  if (companion === void 0) return;
@@ -1572,6 +1655,7 @@ function requireString(path4, value, errors) {
1572
1655
  }
1573
1656
  var ANDROID_PACKAGE_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
1574
1657
  var ANDROID_ACTIVITY_RE = /^\.?[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*$/;
1658
+ var APP_SCHEME_RE = /^[a-z][a-z0-9+.-]*$/;
1575
1659
  function requireAndroidPackage(path4, value, errors) {
1576
1660
  if (typeof value !== "string" || value.trim() === "") {
1577
1661
  errors.push(`${path4}: required non-empty string`);
@@ -1588,6 +1672,17 @@ function requireAndroidActivity(path4, value, errors) {
1588
1672
  if (!ANDROID_ACTIVITY_RE.test(value))
1589
1673
  errors.push(`${path4}: expected an activity name (e.g. .MainActivity)`);
1590
1674
  }
1675
+ function requireAppScheme(path4, value, errors) {
1676
+ if (typeof value !== "string" || value.trim() === "") {
1677
+ errors.push(`${path4}: required non-empty string`);
1678
+ return;
1679
+ }
1680
+ if (!APP_SCHEME_RE.test(value)) {
1681
+ errors.push(
1682
+ `${path4}: expected a valid URL scheme (lowercase letter, then lowercase letters, digits, +, ., or -)`
1683
+ );
1684
+ }
1685
+ }
1591
1686
  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
1687
  function requireInstrumentationTarget(path4, value, errors) {
1593
1688
  if (typeof value !== "string" || value.trim() === "") {