@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/dist/cli.mjs CHANGED
@@ -47,6 +47,10 @@ var DEFAULTS = {
47
47
  androidTokenFileName: "rn-driver-touch-token"
48
48
  };
49
49
  var SECRET_PLACEHOLDER = "<token-file>";
50
+ var COMPANION_FAILURE_MARKERS = {
51
+ ios: ["** BUILD FAILED **", "** TEST FAILED **"],
52
+ android: ["INSTRUMENTATION_FAILED", "Process crashed"]
53
+ };
50
54
 
51
55
  // src/plan/env.ts
52
56
  function buildIosDriverEnv(resolved, metro, timeoutMs) {
@@ -209,9 +213,10 @@ function planAndroid(input) {
209
213
  }
210
214
  }
211
215
  });
212
- const launchCommand = launchCommandFor(android, serial);
213
- push(launchStep("android.launch-1", android, serial));
214
- push(hermesStep("android.hermes-1", android, metro, resolved, hermesDeviceName, launchCommand));
216
+ const launch1Command = launchCommandFor(android, resolved, serial, { forceStopBefore: true });
217
+ const launch2Command = launchCommandFor(android, resolved, serial, { forceStopBefore: false });
218
+ push(launchStep("android.launch-1", android, launch1Command));
219
+ push(hermesStep("android.hermes-1", android, metro, resolved, hermesDeviceName, launch1Command));
215
220
  push({
216
221
  id: "android.forward-clean",
217
222
  stage: "companion",
@@ -265,11 +270,14 @@ function planAndroid(input) {
265
270
  port: resolved.touchPort,
266
271
  tokenFile: resolved.tokenFile,
267
272
  timeoutMs: resolved.companionReadyTimeoutMs
268
- }
273
+ },
274
+ // Abort early if `am instrument` reports the companion failed to start (crash / missing
275
+ // androidTest target) instead of waiting out the readiness budget.
276
+ failureMarkers: COMPANION_FAILURE_MARKERS.android
269
277
  }
270
278
  });
271
- push(launchStep("android.launch-2", android, serial));
272
- 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));
273
281
  const cleanup = [
274
282
  {
275
283
  type: "kill-process",
@@ -324,22 +332,34 @@ function planAndroid(input) {
324
332
  playwright: playwrightCommand(playwright, specs, passthrough)
325
333
  };
326
334
  }
327
- function launchCommandFor(android, serial) {
328
- return adb(serial, [
329
- "shell",
330
- "am",
331
- "start",
332
- "-W",
333
- "-n",
334
- `${android.packageName}/${android.activity}`
335
- ]);
336
- }
337
- function launchStep(id, android, serial) {
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) {
338
358
  return {
339
359
  id,
340
360
  stage: "app-launch",
341
361
  description: `Launch ${android.packageName}/${android.activity}`,
342
- action: { type: "command", command: launchCommandFor(android, serial) }
362
+ action: { type: "command", command }
343
363
  };
344
364
  }
345
365
  function hermesStep(id, android, metro, resolved, deviceNameMatch, launchCommand) {
@@ -373,6 +393,12 @@ function debugHostXml(host) {
373
393
  function adb(serial, args) {
374
394
  return { command: "adb", args: ["-s", serial, ...args] };
375
395
  }
396
+ function devClientUrl(scheme, initialUrl) {
397
+ return `${scheme}://expo-development-client/?url=${initialUrl}`;
398
+ }
399
+ function shellSingleQuote(value) {
400
+ return `'${value.replaceAll("'", "'\\''")}'`;
401
+ }
376
402
  function adbShellScript(serial, remote) {
377
403
  return { command: "adb", args: ["-s", serial, "shell", remote] };
378
404
  }
@@ -418,8 +444,7 @@ function planIos(input) {
418
444
  // Pass the resolved UI-test scheme so a custom `ios.uitestScheme` scaffolds
419
445
  // the SAME target that companion startup later builds (default is
420
446
  // `${appScheme}UITests`).
421
- command: npx([
422
- "rn-driver-xctest-scaffold",
447
+ command: cmd("node_modules/.bin/rn-driver-xctest-scaffold", [
423
448
  "--ios-dir",
424
449
  "ios",
425
450
  "--project-name",
@@ -579,7 +604,10 @@ function planIos(input) {
579
604
  port: resolved.touchPort,
580
605
  tokenFile: resolved.tokenFile,
581
606
  timeoutMs: resolved.companionReadyTimeoutMs
582
- }
607
+ },
608
+ // Abort early if `xcodebuild test` reports a build/test failure (it lingers "alive" after, so
609
+ // the 300s readiness budget would otherwise be burnt waiting for a companion that cannot bind).
610
+ failureMarkers: COMPANION_FAILURE_MARKERS.ios
583
611
  }
584
612
  });
585
613
  if (isDevClient) {
@@ -713,7 +741,7 @@ function placeholderIos(ios, metro) {
713
741
  initialUrl: ios.launch.initialUrl ?? metro.url
714
742
  };
715
743
  }
716
- function placeholderAndroid(android, _metro) {
744
+ function placeholderAndroid(android, metro) {
717
745
  return {
718
746
  serial: "<android-serial>",
719
747
  touchPort: android.companion?.port ?? DEFAULTS.companionPort,
@@ -721,7 +749,8 @@ function placeholderAndroid(android, _metro) {
721
749
  hermesTimeoutMs: DEFAULTS.hermesTargetTimeoutMs,
722
750
  tokenFile: SECRET_PLACEHOLDER,
723
751
  deviceTokenFileName: DEFAULTS.androidTokenFileName,
724
- instrumentationTarget: instrumentationTarget(android)
752
+ instrumentationTarget: instrumentationTarget(android),
753
+ initialUrl: android.launch.initialUrl ?? metro.url
725
754
  };
726
755
  }
727
756
 
@@ -748,7 +777,7 @@ function buildDryRunPlan(config, platform, opts = {}) {
748
777
  return planAndroid({
749
778
  android,
750
779
  metro,
751
- resolved: placeholderAndroid(android),
780
+ resolved: placeholderAndroid(android, metro),
752
781
  playwright: config.playwright,
753
782
  timeoutMs: config.timeoutMs,
754
783
  specs,
@@ -834,8 +863,10 @@ function renderAction(action) {
834
863
  return `write ${action.path}${action.mode ? ` (mode ${action.mode.toString(8)})` : ""}`;
835
864
  case "free-port":
836
865
  return `free-port ${action.port}`;
837
- case "probe":
838
- return `probe ${renderProbe(action.probe)}`;
866
+ case "probe": {
867
+ const fastFail = action.failureMarkers?.length ? ` [fast-fail on: ${action.failureMarkers.join(", ")}]` : "";
868
+ return `probe ${renderProbe(action.probe)}${fastFail}`;
869
+ }
839
870
  default: {
840
871
  const _exhaustive = action;
841
872
  throw new Error(`unhandled action: ${JSON.stringify(_exhaustive)}`);
@@ -881,6 +912,25 @@ function renderProbe(probe) {
881
912
  }
882
913
  }
883
914
 
915
+ // src/runner/probe-failure.ts
916
+ var ProbeFailure = class extends Error {
917
+ marker;
918
+ constructor(marker, detail) {
919
+ super(
920
+ `companion process reported a terminal failure ("${marker}") \u2014 aborting the readiness wait. The build/test failed; it will not become ready.${detail ? `
921
+ ${detail}` : ""}`
922
+ );
923
+ this.name = "ProbeFailure";
924
+ this.marker = marker;
925
+ }
926
+ };
927
+ function findFailureMarker(log, markers) {
928
+ for (const marker of markers) {
929
+ if (log.includes(marker)) return marker;
930
+ }
931
+ return null;
932
+ }
933
+
884
934
  // src/runner/execute.ts
885
935
  var StageError = class extends Error {
886
936
  stage;
@@ -948,22 +998,30 @@ async function runStep(step, runner, opts, processes, isAlive) {
948
998
  case "probe": {
949
999
  const key = processKeyForProbe(action.probe);
950
1000
  const aliveFn = key === null ? () => true : () => isAlive(key);
951
- let ready = await runner.probe(action.probe, aliveFn);
952
- let remaining = action.retry?.max ?? 0;
953
- while (!ready && remaining > 0) {
954
- remaining -= 1;
955
- if (action.retry) {
956
- runner.log(`retry ${step.id}: re-running launch (${remaining} attempt(s) left)`);
957
- await runner.exec(action.retry.command);
1001
+ const watch = key !== null && action.failureMarkers && action.failureMarkers.length > 0 ? { logPath: logPathFor(opts.logDir, key), failureMarkers: action.failureMarkers } : void 0;
1002
+ try {
1003
+ let ready = await runner.probe(action.probe, aliveFn, watch);
1004
+ let remaining = action.retry?.max ?? 0;
1005
+ while (!ready && remaining > 0) {
1006
+ remaining -= 1;
1007
+ if (action.retry) {
1008
+ runner.log(`retry ${step.id}: re-running launch (${remaining} attempt(s) left)`);
1009
+ await runner.exec(action.retry.command);
1010
+ }
1011
+ ready = await runner.probe(action.probe, aliveFn, watch);
958
1012
  }
959
- ready = await runner.probe(action.probe, aliveFn);
960
- }
961
- if (!ready) {
962
- throw new StageError(
963
- step.stage,
964
- step.id,
965
- `readiness timed out after ${action.probe.timeoutMs}ms`
966
- );
1013
+ if (!ready) {
1014
+ throw new StageError(
1015
+ step.stage,
1016
+ step.id,
1017
+ `readiness timed out after ${action.probe.timeoutMs}ms`
1018
+ );
1019
+ }
1020
+ } catch (error) {
1021
+ if (error instanceof ProbeFailure) {
1022
+ throw new StageError(step.stage, step.id, error.message);
1023
+ }
1024
+ throw error;
967
1025
  }
968
1026
  return;
969
1027
  }
@@ -1102,10 +1160,15 @@ var NodeProcessRunner = class {
1102
1160
  }
1103
1161
  if (pids.length > 0) await delay(1e3);
1104
1162
  }
1105
- probe(probe, isAlive) {
1163
+ probe(probe, isAlive, watch) {
1106
1164
  const deadline = Date.now() + probe.timeoutMs;
1107
1165
  const attempt = async () => {
1108
1166
  for (; ; ) {
1167
+ if (watch) {
1168
+ const log = await readWatchedLog(watch.logPath);
1169
+ const marker = findFailureMarker(log, watch.failureMarkers);
1170
+ if (marker) throw new ProbeFailure(marker, lastLines(log));
1171
+ }
1109
1172
  if (!isAlive()) return false;
1110
1173
  if (await probeOnce(probe)) return true;
1111
1174
  if (Date.now() >= deadline) return false;
@@ -1150,6 +1213,19 @@ function delay(ms) {
1150
1213
  setTimeout(resolve, ms);
1151
1214
  });
1152
1215
  }
1216
+ async function readWatchedLog(path4) {
1217
+ try {
1218
+ return await readFile(path4, "utf8");
1219
+ } catch (error) {
1220
+ throw new Error(
1221
+ `cannot read companion log for fast-fail marker detection (${path4}): ${String(error)}`,
1222
+ { cause: error }
1223
+ );
1224
+ }
1225
+ }
1226
+ function lastLines(text, n) {
1227
+ return text.split("\n").filter((line) => line.trim().length > 0).slice(-12).join("\n");
1228
+ }
1153
1229
  async function probeOnce(probe) {
1154
1230
  switch (probe.kind) {
1155
1231
  case "metro-status":
@@ -1288,7 +1364,7 @@ async function resolveIosTarget(ios, metro, opts) {
1288
1364
  initialUrl: ios.launch.initialUrl ?? metro.url
1289
1365
  };
1290
1366
  }
1291
- async function resolveAndroidTarget(android, _metro, opts) {
1367
+ async function resolveAndroidTarget(android, metro, opts) {
1292
1368
  const serial = await selectSerial(opts.device);
1293
1369
  await requireBooted(serial);
1294
1370
  const deviceName = (await capture("adb", ["-s", serial, "shell", "getprop", "ro.product.model"])).trim();
@@ -1301,7 +1377,8 @@ async function resolveAndroidTarget(android, _metro, opts) {
1301
1377
  hermesTimeoutMs: DEFAULTS.hermesTargetTimeoutMs,
1302
1378
  tokenFile,
1303
1379
  deviceTokenFileName: DEFAULTS.androidTokenFileName,
1304
- instrumentationTarget: instrumentationTarget(android)
1380
+ instrumentationTarget: instrumentationTarget(android),
1381
+ initialUrl: android.launch.initialUrl ?? metro.url
1305
1382
  },
1306
1383
  deviceName
1307
1384
  };
@@ -1406,6 +1483,7 @@ var IOS_KEYS = /* @__PURE__ */ new Set([
1406
1483
  var ANDROID_KEYS = /* @__PURE__ */ new Set([
1407
1484
  "packageName",
1408
1485
  "activity",
1486
+ "scheme",
1409
1487
  "gradleTasks",
1410
1488
  "appApkPath",
1411
1489
  "testApkPath",
@@ -1507,6 +1585,8 @@ function validateAndroid(android, errors) {
1507
1585
  reportUnknownKeys("config.android", android, ANDROID_KEYS, errors);
1508
1586
  requireAndroidPackage("config.android.packageName", android.packageName, errors);
1509
1587
  requireAndroidActivity("config.android.activity", android.activity, errors);
1588
+ if (android.scheme !== void 0)
1589
+ requireAppScheme("config.android.scheme", android.scheme, errors);
1510
1590
  optionalString("config.android.appApkPath", android.appApkPath, errors);
1511
1591
  optionalString("config.android.testApkPath", android.testApkPath, errors);
1512
1592
  if (android.instrumentationTarget !== void 0)
@@ -1519,7 +1599,10 @@ function validateAndroid(android, errors) {
1519
1599
  errors.push("config.android.gradleTasks: expected an array of strings");
1520
1600
  }
1521
1601
  validateCompanion("config.android.companion", android.companion, errors);
1522
- validateLaunch("config.android.launch", android.launch, errors);
1602
+ const launch = validateLaunch("config.android.launch", android.launch, errors);
1603
+ if (launch?.kind === "expo-dev-client" && android.scheme === void 0) {
1604
+ errors.push('config.android.scheme: required when android.launch.kind is "expo-dev-client"');
1605
+ }
1523
1606
  }
1524
1607
  function validateCompanion(path4, companion, errors) {
1525
1608
  if (companion === void 0) return;
@@ -1565,6 +1648,7 @@ function requireString(path4, value, errors) {
1565
1648
  }
1566
1649
  var ANDROID_PACKAGE_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
1567
1650
  var ANDROID_ACTIVITY_RE = /^\.?[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*$/;
1651
+ var APP_SCHEME_RE = /^[a-z][a-z0-9+.-]*$/;
1568
1652
  function requireAndroidPackage(path4, value, errors) {
1569
1653
  if (typeof value !== "string" || value.trim() === "") {
1570
1654
  errors.push(`${path4}: required non-empty string`);
@@ -1581,6 +1665,17 @@ function requireAndroidActivity(path4, value, errors) {
1581
1665
  if (!ANDROID_ACTIVITY_RE.test(value))
1582
1666
  errors.push(`${path4}: expected an activity name (e.g. .MainActivity)`);
1583
1667
  }
1668
+ function requireAppScheme(path4, value, errors) {
1669
+ if (typeof value !== "string" || value.trim() === "") {
1670
+ errors.push(`${path4}: required non-empty string`);
1671
+ return;
1672
+ }
1673
+ if (!APP_SCHEME_RE.test(value)) {
1674
+ errors.push(
1675
+ `${path4}: expected a valid URL scheme (lowercase letter, then lowercase letters, digits, +, ., or -)`
1676
+ );
1677
+ }
1678
+ }
1584
1679
  var ANDROID_INSTRUMENTATION_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*\/[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*$/;
1585
1680
  function requireInstrumentationTarget(path4, value, errors) {
1586
1681
  if (typeof value !== "string" || value.trim() === "") {