@delacour/warden-e2e 0.12.1 → 0.14.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/index.mjs CHANGED
@@ -3,21 +3,25 @@ import {
3
3
  E2eError2,
4
4
  a11yTargetOf,
5
5
  canObserve,
6
+ isOnScreen,
6
7
  fieldValue,
7
8
  normalizeText,
8
9
  matchesText,
9
10
  renderSnapshot,
10
11
  renderHeader,
11
- resolve,
12
12
  isLocator,
13
13
  pickOne,
14
14
  textOf,
15
15
  formatNativeTail,
16
16
  captureNativeLog,
17
+ bridgeStallFromSpan,
18
+ formatBridgeStall,
17
19
  isRecoverableLoss,
20
+ parseDuration,
18
21
  runStep,
19
22
  attemptSession,
20
23
  getSession,
24
+ peekSession,
21
25
  accessibleName,
22
26
  announce,
23
27
  failing,
@@ -42,7 +46,7 @@ import {
42
46
  registrationDefaults,
43
47
  markOnly,
44
48
  WARDEN_E2E_VERSION2
45
- } from "./chunk-rbbn127e.mjs";
49
+ } from "./chunk-zk3wxa70.mjs";
46
50
  // ../../packages/e2e/src/expect/expect.ts
47
51
  import { expect as bunExpect } from "bun:test";
48
52
 
@@ -146,6 +150,15 @@ ${screen}`, screen);
146
150
  var NEGATION_GRACE_MS = 1000;
147
151
  var NEGATION_QUIET_MS = 250;
148
152
  var NOT_FOUND = "element not found";
153
+ var UNKNOWN = "unknown (degraded read)";
154
+ function reactShown(matches, screen) {
155
+ const [only] = matches;
156
+ return only !== undefined && only.frame.width > 0 && only.frame.height > 0 && isOnScreen(only.tapPoint, screen);
157
+ }
158
+ function reactText(matches) {
159
+ const [only] = matches;
160
+ return only?.text === undefined ? undefined : normalizeText(only.text);
161
+ }
149
162
  function missing() {
150
163
  return { kind: "missing" };
151
164
  }
@@ -203,7 +216,11 @@ class LocatorAssertions {
203
216
  call: "toBeVisible()",
204
217
  expected: "visible",
205
218
  strict: true,
206
- judge: (entry) => entry === undefined ? judged(false, "not found") : judged(entry.visible, entry.visible ? "visible" : "hidden")
219
+ judge: (entry) => entry === undefined ? judged(false, "not found") : judged(entry.visible, entry.visible ? "visible" : "hidden"),
220
+ judgeReact: (matches, screen) => {
221
+ const shown = reactShown(matches, screen);
222
+ return judged(shown, shown ? "visible (React tree)" : "hidden (React tree)");
223
+ }
207
224
  }, options);
208
225
  }
209
226
  async toBeHidden(options = {}) {
@@ -212,7 +229,11 @@ class LocatorAssertions {
212
229
  call: "toBeHidden()",
213
230
  expected: "hidden",
214
231
  strict: true,
215
- judge: (entry) => entry === undefined ? judged(true, "not found") : judged(!entry.visible, entry.visible ? "visible" : "hidden")
232
+ judge: (entry) => entry === undefined ? judged(true, "not found") : judged(!entry.visible, entry.visible ? "visible" : "hidden"),
233
+ judgeReact: (matches, screen) => {
234
+ const shown = reactShown(matches, screen);
235
+ return judged(!shown, shown ? "visible (React tree)" : "hidden (React tree)");
236
+ }
216
237
  }, options);
217
238
  }
218
239
  async toHaveText(expected, options = {}) {
@@ -227,6 +248,12 @@ class LocatorAssertions {
227
248
  return missing();
228
249
  const value = textOf(entry);
229
250
  return judged(matchesText(value, expected, { exact }), JSON.stringify(value));
251
+ },
252
+ judgeReact: (matches) => {
253
+ const value = reactText(matches);
254
+ if (value === undefined)
255
+ return;
256
+ return judged(matchesText(value, expected, { exact }), `${JSON.stringify(value)} (React tree)`);
230
257
  }
231
258
  }, options);
232
259
  }
@@ -242,6 +269,12 @@ class LocatorAssertions {
242
269
  return missing();
243
270
  const value = textOf(entry);
244
271
  return judged(containsText(value, expected, ignoreCase), JSON.stringify(value));
272
+ },
273
+ judgeReact: (matches) => {
274
+ const value = reactText(matches);
275
+ if (value === undefined)
276
+ return;
277
+ return judged(containsText(value, expected, ignoreCase), `${JSON.stringify(value)} (React tree)`);
245
278
  }
246
279
  }, options);
247
280
  }
@@ -377,7 +410,8 @@ class LocatorAssertions {
377
410
  call: `toHaveCount(${expected})`,
378
411
  expected: String(expected),
379
412
  strict: false,
380
- judge: (_entry, count) => judged(count === expected, String(count))
413
+ judge: (_entry, count) => judged(count === expected, String(count)),
414
+ judgeReact: (matches) => judged(matches.length === expected, `${matches.length} (React tree)`)
381
415
  }, options);
382
416
  }
383
417
  async assert(spec, options) {
@@ -405,11 +439,12 @@ class LocatorAssertions {
405
439
  return { done: false, state: { received: "(screen unreadable)" } };
406
440
  }
407
441
  const { observed } = observation;
408
- const entries = resolve(this.locator.expr, observed.index);
409
- if (spec.strict && entries.length > 1)
410
- pickOne(rt, this.locator.expr, entries, observed);
411
- const { seen, context } = await judgementContext(spec, observed);
412
- const judgement = spec.judge(entries[0], entries.length, context);
442
+ const look = await this.judgeLook(spec, observed);
443
+ if (look.kind === "unsettled") {
444
+ hold.broken();
445
+ return { done: false, state: look.last };
446
+ }
447
+ const { judgement, seen } = look;
413
448
  const verdict = await hold.next(judgement.kind === "judged" && judgement.matches !== negate, observed.driver);
414
449
  if (verdict === "pass")
415
450
  return { done: true, value: true };
@@ -424,8 +459,35 @@ class LocatorAssertions {
424
459
  }, timeoutMs);
425
460
  if (result.status === "done")
426
461
  return;
462
+ const degraded = result.last?.degraded;
463
+ if (degraded !== undefined)
464
+ throw this.locator.degradedFailure(degraded, timeoutMs, result.last?.snapshot);
427
465
  throw this.failure(spec, result.last);
428
466
  }
467
+ async judgeLook(spec, observed) {
468
+ const rt = this.locator.runtime;
469
+ const resolved = await this.locator.resolveIn(observed);
470
+ if (resolved.kind === "unknown") {
471
+ return { kind: "unsettled", last: { received: UNKNOWN, snapshot: observed.snapshot, degraded: resolved.read } };
472
+ }
473
+ if (resolved.kind === "react") {
474
+ const { matches } = resolved;
475
+ if (spec.strict && matches.length > 1 && spec.judgeReact !== undefined) {
476
+ const message = `${this.locator} matched ${matches.length} components in the React tree`;
477
+ throw new E2eError2("LOCATOR_AMBIGUOUS", message, rt.excerpt(observed.snapshot), matches.length);
478
+ }
479
+ const judgement = spec.judgeReact?.(matches, observed.snapshot.screen);
480
+ if (judgement === undefined) {
481
+ return { kind: "unsettled", last: { received: "only in the React tree", snapshot: observed.snapshot } };
482
+ }
483
+ return { kind: "judged", judgement, seen: observed.snapshot };
484
+ }
485
+ const { entries } = resolved;
486
+ if (spec.strict && entries.length > 1)
487
+ pickOne(rt, this.locator.expr, entries, observed);
488
+ const { seen, context } = await judgementContext(spec, observed);
489
+ return { kind: "judged", judgement: spec.judge(entries[0], entries.length, context), seen };
490
+ }
429
491
  failure(spec, last) {
430
492
  const { negate } = this.context;
431
493
  const lines = [
@@ -572,6 +634,116 @@ function createExpect(deps) {
572
634
  return call;
573
635
  }
574
636
  var expect = createExpect({ softSink: currentSoftSink });
637
+ // ../../packages/e2e/src/run.ts
638
+ var DEFAULT_TIMEOUT_MS = 60000;
639
+ var MESSAGE_TAIL = 2000;
640
+ var invalid = (message) => new E2eError2("INVALID_ARGUMENT", `run: ${message}`);
641
+ function timeoutMs(value) {
642
+ if (value === undefined)
643
+ return DEFAULT_TIMEOUT_MS;
644
+ if (typeof value === "number") {
645
+ if (!Number.isFinite(value) || value <= 0)
646
+ throw invalid(`timeout must be over 0, got ${String(value)}`);
647
+ return value;
648
+ }
649
+ const parsed = parseDuration(value);
650
+ if (!parsed.success)
651
+ throw invalid(`timeout: ${parsed.error}`);
652
+ return parsed.data;
653
+ }
654
+ function argvOf(command) {
655
+ if (typeof command === "string") {
656
+ if (command.trim() === "")
657
+ throw invalid("command is empty");
658
+ return ["sh", "-c", command];
659
+ }
660
+ if (command.length === 0 || command[0] === "")
661
+ throw invalid("argv is empty");
662
+ return [...command];
663
+ }
664
+ var programOf = (command) => typeof command === "string" ? "sh -c" : command[0] ?? "";
665
+ function envOf(extra) {
666
+ const merged = { ...process.env, ...extra };
667
+ const env = {};
668
+ for (const [key, value] of Object.entries(merged))
669
+ if (value !== undefined)
670
+ env[key] = value;
671
+ return env;
672
+ }
673
+ function tail(text) {
674
+ const trimmed = text.trim();
675
+ return trimmed.length > MESSAGE_TAIL ? `\u2026${trimmed.slice(-MESSAGE_TAIL)}` : trimmed;
676
+ }
677
+ function resultOf(exitCode, stdout, stderr, ms) {
678
+ return {
679
+ ok: exitCode === 0,
680
+ exitCode,
681
+ stdout,
682
+ stderr,
683
+ ms,
684
+ json() {
685
+ try {
686
+ return JSON.parse(stdout);
687
+ } catch {
688
+ throw invalid(`stdout is not JSON: ${tail(stdout).slice(0, 200) || "(empty)"}`);
689
+ }
690
+ }
691
+ };
692
+ }
693
+ async function exec(command, options) {
694
+ const argv = argvOf(command);
695
+ const limit = timeoutMs(options.timeout);
696
+ const started = Date.now();
697
+ let proc;
698
+ try {
699
+ proc = Bun.spawn(argv, {
700
+ cwd: options.cwd,
701
+ env: envOf(options.env),
702
+ stdin: "pipe",
703
+ stdout: "pipe",
704
+ stderr: "pipe"
705
+ });
706
+ } catch (error) {
707
+ const reason = error instanceof Error ? error.message : String(error);
708
+ throw new E2eError2("RUN_FAILED", `run: could not start ${programOf(command)}: ${reason}`);
709
+ }
710
+ if (options.input !== undefined)
711
+ proc.stdin.write(options.input);
712
+ await proc.stdin.end();
713
+ let timedOut = false;
714
+ const timer = setTimeout(() => {
715
+ timedOut = true;
716
+ proc.kill("SIGKILL");
717
+ }, limit);
718
+ try {
719
+ const [stdout, stderr, exitCode] = await Promise.all([
720
+ new Response(proc.stdout).text(),
721
+ new Response(proc.stderr).text(),
722
+ proc.exited
723
+ ]);
724
+ const result = resultOf(exitCode, stdout, stderr, Date.now() - started);
725
+ if (timedOut) {
726
+ throw new E2eError2("TIMEOUT", `run: ${programOf(command)} did not finish within ${limit} ms (killed)`);
727
+ }
728
+ if (!result.ok && options.check !== false) {
729
+ const output = tail(stderr) || tail(stdout);
730
+ throw new E2eError2("RUN_FAILED", `run: ${programOf(command)} failed with exit ${exitCode}${output === "" ? "" : `
731
+ ${output}`}`);
732
+ }
733
+ return result;
734
+ } finally {
735
+ clearTimeout(timer);
736
+ }
737
+ }
738
+ function createRun(deps) {
739
+ return (command, options = {}) => deps.span("run", () => exec(command, options), {
740
+ verdict: (result) => result.ok ? { ok: true } : { ok: false, code: "RUN_FAILED" },
741
+ detail: (result) => ({ exitCode: result.exitCode })
742
+ });
743
+ }
744
+ var run = createRun({
745
+ span: (op, fn, options) => peekSession()?.span(op, fn, options) ?? fn()
746
+ });
575
747
  // ../../packages/e2e/src/runner/test.ts
576
748
  import { afterAll as bunAfterAll, beforeAll as bunBeforeAll, describe as bunDescribe, test as bunTest } from "bun:test";
577
749
 
@@ -598,6 +770,36 @@ function missingRequirements(target, requires) {
598
770
  import { readdirSync } from "fs";
599
771
  import { join as join2 } from "path";
600
772
 
773
+ // ../../packages/e2e/src/runner/bridge-evidence.ts
774
+ function bridgeEvidence() {
775
+ let fallbacks = 0;
776
+ let first;
777
+ let last;
778
+ return {
779
+ observe(event) {
780
+ if (event.type !== "span" || event.detail === undefined)
781
+ return;
782
+ const detail = event.detail;
783
+ const stall = bridgeStallFromSpan(detail);
784
+ const reason = typeof detail.fallback === "string" ? detail.fallback : undefined;
785
+ const fellBack = reason !== undefined && detail.backend !== "axbridge";
786
+ if (stall === undefined && !fellBack)
787
+ return;
788
+ const read = {
789
+ ...fellBack ? { reason } : {},
790
+ ...event.ok || event.code === undefined ? {} : { code: event.code },
791
+ ...stall
792
+ };
793
+ fallbacks += 1;
794
+ first ??= read;
795
+ last = read;
796
+ },
797
+ summary() {
798
+ return first === undefined || last === undefined ? undefined : { fallbacks, first, last };
799
+ }
800
+ };
801
+ }
802
+
601
803
  // ../../packages/e2e/src/runner/failure.ts
602
804
  import { mkdirSync, writeFileSync } from "fs";
603
805
  import { join } from "path";
@@ -670,7 +872,9 @@ async function captureSnapshot(driver, dir, ms, capture) {
670
872
  }
671
873
  const rendered = renderSnapshot(result.data, { interactiveOnly: false, refs: new Map });
672
874
  const header = renderHeader({ snapshot: result.data, rendered, interactiveOnly: false });
673
- writeFileSync(join(dir, SCREEN_AT_FAILURE), `${[header, ...rendered.lines].join(`
875
+ const fellBack = fallbackLine(result.data);
876
+ const lines = fellBack === undefined ? [header, ...rendered.lines] : [header, fellBack, ...rendered.lines];
877
+ writeFileSync(join(dir, SCREEN_AT_FAILURE), `${lines.join(`
674
878
  `)}
675
879
  `);
676
880
  capture.files.push(SCREEN_AT_FAILURE);
@@ -678,6 +882,12 @@ async function captureSnapshot(driver, dir, ms, capture) {
678
882
  capture.problems.push(`snapshot: ${message(cause)}`);
679
883
  }
680
884
  }
885
+ function fallbackLine(snapshot) {
886
+ if (snapshot.fellBackFrom === undefined)
887
+ return;
888
+ const head = `# read on ${snapshot.backend}: ${snapshot.fellBackFrom}`;
889
+ return snapshot.bridgeStall === undefined ? `${head} did not answer` : `${head} stalled (${formatBridgeStall(snapshot.bridgeStall)})`;
890
+ }
681
891
  async function captureDevtoolsArtifacts(facet, dir, timeoutMs = CAPTURE_TIMEOUT_MS, budget = unlimited) {
682
892
  const capture = { files: [], problems: [] };
683
893
  const ms = Math.min(timeoutMs, budget());
@@ -947,8 +1157,10 @@ async function runAttemptOnce(session, spec) {
947
1157
  };
948
1158
  const files = new Set;
949
1159
  let binding;
1160
+ const bridge = bridgeEvidence();
950
1161
  const trace = openTrace(join2(dir, "trace.jsonl"), (event) => {
951
1162
  files.add("trace.jsonl");
1163
+ bridge.observe(event);
952
1164
  print(formatConsoleLine(event, { clock, short, depth: binding?.context.depth ?? 0 }));
953
1165
  });
954
1166
  const controller = new AbortController;
@@ -981,7 +1193,7 @@ async function runAttemptOnce(session, spec) {
981
1193
  session.leaveAttempt(ctx);
982
1194
  trace.flush();
983
1195
  outcome = failIf(outcome, cleanupErrors.length > 0, () => cleanupErrors[0]);
984
- return concludeAttempt(session, spec, ctx, { startedAt, device }, outcome, print, clock);
1196
+ return concludeAttempt(session, spec, ctx, { startedAt, device, bridge: bridge.summary() }, outcome, print, clock);
985
1197
  }
986
1198
  async function raceDeadline(controller, timeoutMs, run) {
987
1199
  let deadlineHit = false;
@@ -1042,6 +1254,7 @@ function buildResult(spec, ctx, run, outcome) {
1042
1254
  ...spec.only ? { only: true } : {},
1043
1255
  ...outcome.failed ? { error: errorInfo(outcome.error) } : {},
1044
1256
  ...ctx.files.size > 0 ? { artifacts: { dir: ctx.dir, files: [...ctx.files].sort() } } : {},
1257
+ ...run.bridge === undefined ? {} : { bridge: run.bridge },
1045
1258
  device: run.device
1046
1259
  };
1047
1260
  }
@@ -1277,5 +1490,6 @@ export {
1277
1490
  beforeEach,
1278
1491
  describe,
1279
1492
  expect,
1493
+ run,
1280
1494
  test
1281
1495
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/warden-e2e",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "description": "TypeScript e2e testing for React Native and Expo on iOS simulators and Android emulators, run by `warden e2e`: test, expect, screen, device, app",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,7 +41,7 @@
41
41
  "LICENSE"
42
42
  ],
43
43
  "peerDependencies": {
44
- "@delacour/warden": "0.12.1"
44
+ "@delacour/warden": "0.14.0"
45
45
  },
46
46
  "dependencies": {
47
47
  "zod": "^4.4.3"