@delacour/warden-e2e 0.13.0 → 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,17 +3,19 @@ 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,
18
20
  parseDuration,
19
21
  runStep,
@@ -44,7 +46,7 @@ import {
44
46
  registrationDefaults,
45
47
  markOnly,
46
48
  WARDEN_E2E_VERSION2
47
- } from "./chunk-tnr47n9c.mjs";
49
+ } from "./chunk-zk3wxa70.mjs";
48
50
  // ../../packages/e2e/src/expect/expect.ts
49
51
  import { expect as bunExpect } from "bun:test";
50
52
 
@@ -148,6 +150,15 @@ ${screen}`, screen);
148
150
  var NEGATION_GRACE_MS = 1000;
149
151
  var NEGATION_QUIET_MS = 250;
150
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
+ }
151
162
  function missing() {
152
163
  return { kind: "missing" };
153
164
  }
@@ -205,7 +216,11 @@ class LocatorAssertions {
205
216
  call: "toBeVisible()",
206
217
  expected: "visible",
207
218
  strict: true,
208
- 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
+ }
209
224
  }, options);
210
225
  }
211
226
  async toBeHidden(options = {}) {
@@ -214,7 +229,11 @@ class LocatorAssertions {
214
229
  call: "toBeHidden()",
215
230
  expected: "hidden",
216
231
  strict: true,
217
- 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
+ }
218
237
  }, options);
219
238
  }
220
239
  async toHaveText(expected, options = {}) {
@@ -229,6 +248,12 @@ class LocatorAssertions {
229
248
  return missing();
230
249
  const value = textOf(entry);
231
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)`);
232
257
  }
233
258
  }, options);
234
259
  }
@@ -244,6 +269,12 @@ class LocatorAssertions {
244
269
  return missing();
245
270
  const value = textOf(entry);
246
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)`);
247
278
  }
248
279
  }, options);
249
280
  }
@@ -379,7 +410,8 @@ class LocatorAssertions {
379
410
  call: `toHaveCount(${expected})`,
380
411
  expected: String(expected),
381
412
  strict: false,
382
- 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)`)
383
415
  }, options);
384
416
  }
385
417
  async assert(spec, options) {
@@ -407,11 +439,12 @@ class LocatorAssertions {
407
439
  return { done: false, state: { received: "(screen unreadable)" } };
408
440
  }
409
441
  const { observed } = observation;
410
- const entries = resolve(this.locator.expr, observed.index);
411
- if (spec.strict && entries.length > 1)
412
- pickOne(rt, this.locator.expr, entries, observed);
413
- const { seen, context } = await judgementContext(spec, observed);
414
- 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;
415
448
  const verdict = await hold.next(judgement.kind === "judged" && judgement.matches !== negate, observed.driver);
416
449
  if (verdict === "pass")
417
450
  return { done: true, value: true };
@@ -426,8 +459,35 @@ class LocatorAssertions {
426
459
  }, timeoutMs);
427
460
  if (result.status === "done")
428
461
  return;
462
+ const degraded = result.last?.degraded;
463
+ if (degraded !== undefined)
464
+ throw this.locator.degradedFailure(degraded, timeoutMs, result.last?.snapshot);
429
465
  throw this.failure(spec, result.last);
430
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
+ }
431
491
  failure(spec, last) {
432
492
  const { negate } = this.context;
433
493
  const lines = [
@@ -710,6 +770,36 @@ function missingRequirements(target, requires) {
710
770
  import { readdirSync } from "fs";
711
771
  import { join as join2 } from "path";
712
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
+
713
803
  // ../../packages/e2e/src/runner/failure.ts
714
804
  import { mkdirSync, writeFileSync } from "fs";
715
805
  import { join } from "path";
@@ -782,7 +872,9 @@ async function captureSnapshot(driver, dir, ms, capture) {
782
872
  }
783
873
  const rendered = renderSnapshot(result.data, { interactiveOnly: false, refs: new Map });
784
874
  const header = renderHeader({ snapshot: result.data, rendered, interactiveOnly: false });
785
- 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(`
786
878
  `)}
787
879
  `);
788
880
  capture.files.push(SCREEN_AT_FAILURE);
@@ -790,6 +882,12 @@ async function captureSnapshot(driver, dir, ms, capture) {
790
882
  capture.problems.push(`snapshot: ${message(cause)}`);
791
883
  }
792
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
+ }
793
891
  async function captureDevtoolsArtifacts(facet, dir, timeoutMs = CAPTURE_TIMEOUT_MS, budget = unlimited) {
794
892
  const capture = { files: [], problems: [] };
795
893
  const ms = Math.min(timeoutMs, budget());
@@ -1059,8 +1157,10 @@ async function runAttemptOnce(session, spec) {
1059
1157
  };
1060
1158
  const files = new Set;
1061
1159
  let binding;
1160
+ const bridge = bridgeEvidence();
1062
1161
  const trace = openTrace(join2(dir, "trace.jsonl"), (event) => {
1063
1162
  files.add("trace.jsonl");
1163
+ bridge.observe(event);
1064
1164
  print(formatConsoleLine(event, { clock, short, depth: binding?.context.depth ?? 0 }));
1065
1165
  });
1066
1166
  const controller = new AbortController;
@@ -1093,7 +1193,7 @@ async function runAttemptOnce(session, spec) {
1093
1193
  session.leaveAttempt(ctx);
1094
1194
  trace.flush();
1095
1195
  outcome = failIf(outcome, cleanupErrors.length > 0, () => cleanupErrors[0]);
1096
- return concludeAttempt(session, spec, ctx, { startedAt, device }, outcome, print, clock);
1196
+ return concludeAttempt(session, spec, ctx, { startedAt, device, bridge: bridge.summary() }, outcome, print, clock);
1097
1197
  }
1098
1198
  async function raceDeadline(controller, timeoutMs, run) {
1099
1199
  let deadlineHit = false;
@@ -1154,6 +1254,7 @@ function buildResult(spec, ctx, run, outcome) {
1154
1254
  ...spec.only ? { only: true } : {},
1155
1255
  ...outcome.failed ? { error: errorInfo(outcome.error) } : {},
1156
1256
  ...ctx.files.size > 0 ? { artifacts: { dir: ctx.dir, files: [...ctx.files].sort() } } : {},
1257
+ ...run.bridge === undefined ? {} : { bridge: run.bridge },
1157
1258
  device: run.device
1158
1259
  };
1159
1260
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/warden-e2e",
3
- "version": "0.13.0",
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.13.0"
44
+ "@delacour/warden": "0.14.0"
45
45
  },
46
46
  "dependencies": {
47
47
  "zod": "^4.4.3"
package/preload.mjs CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  settleDetailOf,
12
12
  DEFAULT_SETTLE_QUIET_MS,
13
13
  DEFAULT_SETTLE_TIMEOUT_MS,
14
+ hashFallbackQuietMs,
14
15
  settleByHash,
15
16
  snapshotHash,
16
17
  ok,
@@ -35,7 +36,7 @@ import {
35
36
  selectorExpression,
36
37
  renderSnapshot,
37
38
  renderHeader,
38
- locateByTestIdViaReact,
39
+ lookUpTestIdViaReact,
39
40
  isLocator,
40
41
  Locator,
41
42
  scrollUntilVisible,
@@ -44,6 +45,10 @@ import {
44
45
  execError,
45
46
  driverError,
46
47
  driverErr,
48
+ bridgeStallSchema,
49
+ toBridgeStall,
50
+ noteBridgeStall,
51
+ formatBridgeStall,
47
52
  logEventSchema,
48
53
  fatalEventSchema,
49
54
  responseSchema,
@@ -109,7 +114,7 @@ import {
109
114
  onlyWasSeen,
110
115
  WORKSPACE_VERSION,
111
116
  WARDEN_E2E_VERSION2
112
- } from "./chunk-tnr47n9c.mjs";
117
+ } from "./chunk-zk3wxa70.mjs";
113
118
 
114
119
  // ../../packages/e2e/src/preload.ts
115
120
  import { afterAll, setDefaultTimeout } from "bun:test";
@@ -7819,6 +7824,8 @@ function toSnapshot(data, takenAt) {
7819
7824
  snapshot.modal = data.modal;
7820
7825
  if (data.fallback?.from === "axbridge")
7821
7826
  snapshot.fellBackFrom = "axbridge";
7827
+ if (data.fallback?.stall)
7828
+ snapshot.bridgeStall = toBridgeStall(data.fallback.stall);
7822
7829
  return snapshot;
7823
7830
  }
7824
7831
 
@@ -8407,6 +8414,14 @@ var REBIND_TIMEOUT_MS = 1e4;
8407
8414
  var COLD_BRIDGE_BUDGET_MS = 60000;
8408
8415
  var COLD_BRIDGE_FIRST_WAIT_MS = 2000;
8409
8416
  var COLD_BRIDGE_WAIT_MS = 5000;
8417
+ var BRIDGE_COLD_DEADLINE_MS = 12000;
8418
+ var AX_FALLBACK_MARGIN_MS = 8000;
8419
+ var COLD_READ_TIMEOUT_MS = BRIDGE_COLD_DEADLINE_MS + AX_FALLBACK_MARGIN_MS;
8420
+ function coldReadTimeoutMs(env = process.env) {
8421
+ const raw = Number(env.WARDEN_SIMD_BRIDGE_COLD_DEADLINE_MS);
8422
+ const deadline = Number.isFinite(raw) && raw > BRIDGE_COLD_DEADLINE_MS ? raw : BRIDGE_COLD_DEADLINE_MS;
8423
+ return deadline + AX_FALLBACK_MARGIN_MS;
8424
+ }
8410
8425
  var GESTURE_MARGIN_MS = 5000;
8411
8426
  var FRONTMOST_TIMEOUT_MS = 3000;
8412
8427
  var LAUNCH_TIMEOUT_MS = 30000;
@@ -8480,6 +8495,23 @@ function noteSnapshotError(detail) {
8480
8495
  if (typeof value === "string")
8481
8496
  noteSpanDetail(to, value);
8482
8497
  }
8498
+ const stall = bridgeStallSchema.safeParse(detail.stall);
8499
+ if (stall.success)
8500
+ noteBridgeStall(toBridgeStall(stall.data));
8501
+ }
8502
+ function noteFallback(fallback, log) {
8503
+ const stall = fallback.stall && toBridgeStall(fallback.stall);
8504
+ const why = stall ? ` (${formatBridgeStall(stall)})` : "";
8505
+ log?.(`[ios] snapshot fell back ${fallback.from} -> ${fallback.to}: ${fallback.reason}${why}`);
8506
+ noteSpanDetail("fallback", fallback.reason);
8507
+ if (stall)
8508
+ noteBridgeStall(stall);
8509
+ }
8510
+ function noteSnapshotTarget(target) {
8511
+ if (!target)
8512
+ return;
8513
+ noteSpanDetail("targetPid", target.pid);
8514
+ noteSpanDetail("targetSource", target.source);
8483
8515
  }
8484
8516
  function noteHelperHealth(health) {
8485
8517
  noteSpanDetail("bridge", health.ax.bridge);
@@ -8495,6 +8527,10 @@ function noteHelperHealth(health) {
8495
8527
  noteSpanDetail("coreSimulator", health.coreSimulatorVersion);
8496
8528
  if (health.frontmost)
8497
8529
  noteSpanDetail("frontmostUnresolvedAgoMs", health.frontmost.unresolvedAgoMs);
8530
+ if (health.bridgeRecycles) {
8531
+ noteSpanDetail("bridgeRecycles", health.bridgeRecycles.count);
8532
+ noteSpanDetail("bridgeRecyclesExhausted", health.bridgeRecycles.exhausted);
8533
+ }
8498
8534
  }
8499
8535
  function coldBridgeWait(error, attempt, spentMs, budgetMs) {
8500
8536
  if (!bridgeNotReady(error))
@@ -8595,6 +8631,9 @@ function createIosDriver(swap, simctl, opts) {
8595
8631
  let settleAxFailures = 0;
8596
8632
  let rebindUnsupported = false;
8597
8633
  let bridgeProven = false;
8634
+ let warmGeneration;
8635
+ const coldTimeoutMs = coldReadTimeoutMs();
8636
+ const readOpts = () => warmGeneration === swap.generation() ? {} : { minTimeoutMs: coldTimeoutMs };
8598
8637
  let gestureUnsupported = false;
8599
8638
  let appOpsOutdated = false;
8600
8639
  const capabilities = {
@@ -8690,7 +8729,8 @@ function createIosDriver(swap, simctl, opts) {
8690
8729
  }
8691
8730
  let failureDiagnosed = false;
8692
8731
  const fetchSnapshot = async (o) => {
8693
- const result = await request("snapshot", params({ interactiveOnly: o?.interactiveOnly, backend: o?.backend }));
8732
+ const generation = swap.generation();
8733
+ const result = await request("snapshot", params({ interactiveOnly: o?.interactiveOnly, backend: o?.backend }), readOpts());
8694
8734
  if (!result.success) {
8695
8735
  noteSnapshotError(result.error.detail);
8696
8736
  if (!failureDiagnosed) {
@@ -8703,15 +8743,12 @@ function createIosDriver(swap, simctl, opts) {
8703
8743
  }
8704
8744
  failureDiagnosed = false;
8705
8745
  const { fallback, target } = result.data;
8746
+ if (result.data.backend === "axbridge" && !fallback)
8747
+ warmGeneration = generation;
8706
8748
  noteSpanDetail("backend", result.data.backend);
8707
- if (fallback) {
8708
- opts.log?.(`[ios] snapshot fell back ${fallback.from} -> ${fallback.to}: ${fallback.reason}`);
8709
- noteSpanDetail("fallback", fallback.reason);
8710
- }
8711
- if (target) {
8712
- noteSpanDetail("targetPid", target.pid);
8713
- noteSpanDetail("targetSource", target.source);
8714
- }
8749
+ if (fallback)
8750
+ noteFallback(fallback, opts.log);
8751
+ noteSnapshotTarget(target);
8715
8752
  return { success: true, data: toSnapshot(result.data, now()) };
8716
8753
  };
8717
8754
  async function snapshotOnce(o) {
@@ -8774,7 +8811,7 @@ function createIosDriver(swap, simctl, opts) {
8774
8811
  if (!snap.success)
8775
8812
  throw new DriverFailure(snap.error);
8776
8813
  return snapshotHash(snap.data);
8777
- }, { quietMs: o.quietMs, timeoutMs: o.timeoutMs, now, sleep });
8814
+ }, { quietMs: hashFallbackQuietMs(o.quietMs), timeoutMs: o.timeoutMs, now, sleep });
8778
8815
  noteSpanDetail("via", "hash");
8779
8816
  return { success: true, data: { ...outcome, via: "hash" } };
8780
8817
  } catch (error) {
@@ -8785,6 +8822,7 @@ function createIosDriver(swap, simctl, opts) {
8785
8822
  }
8786
8823
  async function settleAfterLaunch(bundleId) {
8787
8824
  lastLaunched = bundleId;
8825
+ warmGeneration = undefined;
8788
8826
  launchedBundle = bundleId;
8789
8827
  if (await rebind(bundleId))
8790
8828
  launchedBundle = undefined;
@@ -8882,7 +8920,7 @@ function createIosDriver(swap, simctl, opts) {
8882
8920
  return info;
8883
8921
  }
8884
8922
  async function readAlert(backend) {
8885
- const result = await request("alertButtons", params({ backend }));
8923
+ const result = await request("alertButtons", params({ backend }), readOpts());
8886
8924
  if (!result.success)
8887
8925
  return result;
8888
8926
  return { success: true, data: result.data === null ? null : toAlertInfo(result.data) };
@@ -8938,7 +8976,7 @@ function createIosDriver(swap, simctl, opts) {
8938
8976
  health,
8939
8977
  snapshot,
8940
8978
  async elementAtPoint(point, o) {
8941
- const result = await request("elementAtPoint", params({ x: point.x, y: point.y, reachability: o?.reachability }));
8979
+ const result = await request("elementAtPoint", params({ x: point.x, y: point.y, reachability: o?.reachability }), readOpts());
8942
8980
  if (!result.success)
8943
8981
  return result;
8944
8982
  return {
@@ -9466,7 +9504,12 @@ function createIosDeviceDriver(client, devicectl, opts) {
9466
9504
  if (!snap.success)
9467
9505
  throw new DriverFailure2(snap.error);
9468
9506
  return snapshotHash(snap.data);
9469
- }, { quietMs: o.quietMs ?? DEFAULT_QUIET_MS2, timeoutMs: o.timeoutMs ?? DEFAULT_SETTLE_TIMEOUT_MS3, now, sleep });
9507
+ }, {
9508
+ quietMs: hashFallbackQuietMs(o.quietMs ?? DEFAULT_QUIET_MS2),
9509
+ timeoutMs: o.timeoutMs ?? DEFAULT_SETTLE_TIMEOUT_MS3,
9510
+ now,
9511
+ sleep
9512
+ });
9470
9513
  noteSpanDetail("via", "hash");
9471
9514
  return { success: true, data: { ...outcome, via: "hash" } };
9472
9515
  } catch (error) {
@@ -10455,7 +10498,7 @@ function createDevice({
10455
10498
  if (native.error.code !== "E_UNSUPPORTED")
10456
10499
  throw toE2eError(native.error, "settle");
10457
10500
  const polled = await settleByHash(async () => snapshotHash(await readSnapshot()), {
10458
- quietMs,
10501
+ quietMs: hashFallbackQuietMs(quietMs),
10459
10502
  timeoutMs,
10460
10503
  now: clock.now,
10461
10504
  sleep: clock.sleep
@@ -10534,13 +10577,11 @@ var EXCERPT_LINES = 40;
10534
10577
  var MAX_ALERT_ANSWERS = 3;
10535
10578
  var REACT_THROTTLE_MS = 450;
10536
10579
  var REACT_TIMEOUT_MS = 2000;
10537
- var FIRST_READER_RETRY_MS = 5000;
10538
10580
 
10539
10581
  class Runtime {
10540
10582
  host;
10541
10583
  reactCache;
10542
10584
  indexed = new WeakMap;
10543
- firstReaderRetryAt = 0;
10544
10585
  constructor(host) {
10545
10586
  this.host = host;
10546
10587
  }
@@ -10580,7 +10621,7 @@ class Runtime {
10580
10621
  const result = canObserve(driver) ? await driver.observe({ reuse }) : await driver.snapshot();
10581
10622
  if (!result.success)
10582
10623
  return { ok: false, error: result.error };
10583
- const snapshot = await this.restoreDegraded(driver, result.data);
10624
+ const snapshot = result.data;
10584
10625
  const observation = {
10585
10626
  ok: true,
10586
10627
  observed: { driver, snapshot, index: this.indexOf(snapshot) }
@@ -10594,16 +10635,6 @@ class Runtime {
10594
10635
  reuse = false;
10595
10636
  }
10596
10637
  }
10597
- async restoreDegraded(driver, snapshot) {
10598
- const first = snapshot.fellBackFrom;
10599
- if (first === undefined || this.clock.now() < this.firstReaderRetryAt)
10600
- return snapshot;
10601
- const again = await driver.snapshot({ backend: first });
10602
- if (again.success && again.data.backend === first)
10603
- return again.data;
10604
- this.firstReaderRetryAt = this.clock.now() + FIRST_READER_RETRY_MS;
10605
- return snapshot;
10606
- }
10607
10638
  earlierThan(driver, before, look) {
10608
10639
  const after = canObserve(driver) ? driver.previous?.() : undefined;
10609
10640
  const same = after !== undefined && after.generation === before.generation && after.snapshot === look;
@@ -10640,18 +10671,25 @@ class Runtime {
10640
10671
  throw driverFailure("snapshot", result.last ?? { code: "E_TIMEOUT", message: "no snapshot", retryable: true });
10641
10672
  }
10642
10673
  async reactMatches(testId, observed) {
10674
+ const lookup = await this.reactLookup(testId, observed);
10675
+ return lookup.answered ? lookup.matches : [];
10676
+ }
10677
+ hasReactTree() {
10678
+ return this.host.devtools?.() !== undefined;
10679
+ }
10680
+ async reactLookup(testId, observed) {
10643
10681
  const facet = this.host.devtools?.();
10644
10682
  if (facet === undefined)
10645
- return [];
10683
+ return { answered: false };
10646
10684
  const key = typeof testId === "string" ? `s:${testId}` : `r:${testId.toString()}`;
10647
10685
  const cached = this.reactCache;
10648
10686
  if (cached !== undefined && cached.key === key && this.clock.now() - cached.at < REACT_THROTTLE_MS) {
10649
- return cached.matches;
10687
+ return cached.lookup;
10650
10688
  }
10651
10689
  const view = { platform: observed.snapshot.platform, screen: observed.snapshot.screen };
10652
- const matches = await locateByTestIdViaReact(facet, testId, view, { timeoutMs: REACT_TIMEOUT_MS });
10653
- this.reactCache = { key, at: this.clock.now(), matches };
10654
- return matches;
10690
+ const lookup = await lookUpTestIdViaReact(facet, testId, view, { timeoutMs: REACT_TIMEOUT_MS });
10691
+ this.reactCache = { key, at: this.clock.now(), lookup };
10692
+ return lookup;
10655
10693
  }
10656
10694
  excerpt(snapshot) {
10657
10695
  const rendered = renderSnapshot(snapshot, { interactiveOnly: true, refs: new Map, maxLines: EXCERPT_LINES });
@@ -0,0 +1,37 @@
1
+ import { z } from "zod";
2
+ import { type SpanDetail } from "./span-detail";
3
+ /**
4
+ * What one iOS `axbridge` fallback or stall looked like, as `warden-simd` reports it (`fallback.stall` on a snapshot,
5
+ * `detail.stall` on a failed read). Recorded in `trace.jsonl` (the `stall*` span fields), `results.jsonl` (`bridge`)
6
+ * and under the header of `screen-at-failure.txt`.
7
+ */
8
+ export declare const bridgeStallSchema: z.ZodObject<{
9
+ stage: z.ZodString;
10
+ readMs: z.ZodOptional<z.ZodNumber>;
11
+ deadlineMs: z.ZodOptional<z.ZodNumber>;
12
+ guestAgeMs: z.ZodOptional<z.ZodNumber>;
13
+ cold: z.ZodBoolean;
14
+ cooldownRemainingMs: z.ZodOptional<z.ZodNumber>;
15
+ probes: z.ZodNumber;
16
+ recycles: z.ZodOptional<z.ZodNumber>;
17
+ recyclesExhausted: z.ZodOptional<z.ZodBoolean>;
18
+ }, z.core.$loose>;
19
+ export type BridgeStall = {
20
+ stage: string;
21
+ readMs?: number;
22
+ deadlineMs?: number;
23
+ guestAgeMs?: number;
24
+ cold: boolean;
25
+ cooldownRemainingMs?: number;
26
+ probes: number;
27
+ recycles?: number;
28
+ recyclesExhausted?: boolean;
29
+ };
30
+ /** Only the fields of the schema, so a newer helper's extra keys stay off the span and out of the results. */
31
+ export declare function toBridgeStall(wire: z.infer<typeof bridgeStallSchema>): BridgeStall;
32
+ /** Leaves the stall on the span timing the read (`stallStage`, `stallReadMs`, …). */
33
+ export declare function noteBridgeStall(stall: BridgeStall): void;
34
+ /** The stall a span recorded, or undefined when it has none. */
35
+ export declare function bridgeStallFromSpan(detail: SpanDetail | undefined): BridgeStall | undefined;
36
+ /** One line for a log or a report: `stage read · read 4012 ms of a 4000 ms deadline · warm · …`. */
37
+ export declare function formatBridgeStall(stall: BridgeStall): string;
@@ -1,6 +1,7 @@
1
1
  import type { CameraCondition, ClockCondition, HostFault, LocationCondition, NetworkCondition, PermissionName, PermissionState, TextSize } from "../../../core/src/conditions/conditions.types";
2
2
  import type { AsyncResult } from "../../../types/src/result";
3
3
  import type { ScreenReaderState } from "../a11y/a11y.types";
4
+ import type { BridgeStall } from "./bridge-stall";
4
5
  /**
5
6
  * The device driver contract every layer of warden-e2e builds on: the spec API (`screen`, `device`,
6
7
  * `app`), `warden ui`, specs and the devtools facet all talk to a `Driver`. Implementations:
@@ -136,9 +137,12 @@ export type Snapshot = {
136
137
  modal?: ModalInfo;
137
138
  /**
138
139
  * Set when `backend` answered because this reader failed or was skipped first (iOS: `axbridge` stalled, `ax` read in
139
- * its place). The fallback reader can lack what the first one has, so a missing node on such a snapshot is not final.
140
+ * its place). The fallback reader can lack what the first one has, so a missing node on such a snapshot is not final:
141
+ * the iOS helper probes the first reader in the background, and a later read is back on it once it answers.
140
142
  */
141
143
  fellBackFrom?: SnapshotBackend;
144
+ /** iOS: what the bridge failure behind `fellBackFrom` looked like, when the helper reported it */
145
+ bridgeStall?: BridgeStall;
142
146
  };
143
147
  export type Orientation = "portrait" | "portraitUpsideDown" | "landscapeLeft" | "landscapeRight";
144
148
  export type HardwareButton = "home" | "back" | "lock" | "volumeUp" | "volumeDown" | "shake";
@@ -91,6 +91,16 @@ export type OpenIosDriverOptions = {
91
91
  * Once a read or a rebind has succeeded the normal per-call budgets apply.
92
92
  */
93
93
  export declare const COLD_BRIDGE_BUDGET_MS = 60000;
94
+ /**
95
+ * warden-simd's deadline for the first `axbridge` read of a cold guest (helper start, a recycled guest, an app just
96
+ * launched): `BridgeDeadline.defaultColdReadMs`, or `WARDEN_SIMD_BRIDGE_COLD_DEADLINE_MS`. On a 3-vCPU runner that
97
+ * first read takes 4-11 s; warm reads keep the 4 s deadline.
98
+ */
99
+ export declare const BRIDGE_COLD_DEADLINE_MS = 12000;
100
+ /** The request timeout of a read while the bridge is cold, so the client never gives up before the helper's deadline. */
101
+ export declare const COLD_READ_TIMEOUT_MS: number;
102
+ /** {@link COLD_READ_TIMEOUT_MS}, following a `WARDEN_SIMD_BRIDGE_COLD_DEADLINE_MS` the helper also reads. */
103
+ export declare function coldReadTimeoutMs(env?: Readonly<Record<string, string | undefined>>): number;
94
104
  /** USB HID keyboard-page usage ids for the keys `Driver.key` offers. */
95
105
  export declare const KEY_USAGE: Readonly<Record<KeyName, number>>;
96
106
  /** The fields of a failed snapshot's `detail` the helper sets (which reader, why, both readers' errors), on its span. */
@@ -21,6 +21,8 @@ export type SimdEventMap = {
21
21
  };
22
22
  export type RequestOptions = {
23
23
  timeoutMs?: number;
24
+ /** a floor for this request's timeout (a cold bridge read): raises the default or `timeoutMs`, never lowers it */
25
+ minTimeoutMs?: number;
24
26
  };
25
27
  export type SimdClient = {
26
28
  /** the handshake of the current helper process (refreshed after a respawn) */
@@ -192,6 +192,10 @@ export declare const healthDataSchema: z.ZodObject<{
192
192
  unresolvedAgoMs: z.ZodNumber;
193
193
  method: z.ZodString;
194
194
  }, z.core.$strip>>;
195
+ bridgeRecycles: z.ZodOptional<z.ZodObject<{
196
+ count: z.ZodNumber;
197
+ exhausted: z.ZodBoolean;
198
+ }, z.core.$strip>>;
195
199
  state: z.ZodOptional<z.ZodString>;
196
200
  uptimeMs: z.ZodOptional<z.ZodNumber>;
197
201
  }, z.core.$loose>;
@@ -214,6 +218,17 @@ export declare const snapshotDataSchema: z.ZodObject<{
214
218
  from: z.ZodString;
215
219
  to: z.ZodString;
216
220
  reason: z.ZodString;
221
+ stall: z.ZodOptional<z.ZodObject<{
222
+ stage: z.ZodString;
223
+ readMs: z.ZodOptional<z.ZodNumber>;
224
+ deadlineMs: z.ZodOptional<z.ZodNumber>;
225
+ guestAgeMs: z.ZodOptional<z.ZodNumber>;
226
+ cold: z.ZodBoolean;
227
+ cooldownRemainingMs: z.ZodOptional<z.ZodNumber>;
228
+ probes: z.ZodNumber;
229
+ recycles: z.ZodOptional<z.ZodNumber>;
230
+ recyclesExhausted: z.ZodOptional<z.ZodBoolean>;
231
+ }, z.core.$loose>>;
217
232
  }, z.core.$strip>>;
218
233
  target: z.ZodOptional<z.ZodObject<{
219
234
  pid: z.ZodNumber;
@@ -362,6 +377,10 @@ export declare const opSchemas: {
362
377
  unresolvedAgoMs: z.ZodNumber;
363
378
  method: z.ZodString;
364
379
  }, z.core.$strip>>;
380
+ bridgeRecycles: z.ZodOptional<z.ZodObject<{
381
+ count: z.ZodNumber;
382
+ exhausted: z.ZodBoolean;
383
+ }, z.core.$strip>>;
365
384
  state: z.ZodOptional<z.ZodString>;
366
385
  uptimeMs: z.ZodOptional<z.ZodNumber>;
367
386
  }, z.core.$loose>;
@@ -383,6 +402,17 @@ export declare const opSchemas: {
383
402
  from: z.ZodString;
384
403
  to: z.ZodString;
385
404
  reason: z.ZodString;
405
+ stall: z.ZodOptional<z.ZodObject<{
406
+ stage: z.ZodString;
407
+ readMs: z.ZodOptional<z.ZodNumber>;
408
+ deadlineMs: z.ZodOptional<z.ZodNumber>;
409
+ guestAgeMs: z.ZodOptional<z.ZodNumber>;
410
+ cold: z.ZodBoolean;
411
+ cooldownRemainingMs: z.ZodOptional<z.ZodNumber>;
412
+ probes: z.ZodNumber;
413
+ recycles: z.ZodOptional<z.ZodNumber>;
414
+ recyclesExhausted: z.ZodOptional<z.ZodBoolean>;
415
+ }, z.core.$loose>>;
386
416
  }, z.core.$strip>>;
387
417
  target: z.ZodOptional<z.ZodObject<{
388
418
  pid: z.ZodNumber;