@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/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-rbbn127e.mjs";
117
+ } from "./chunk-zk3wxa70.mjs";
113
118
 
114
119
  // ../../packages/e2e/src/preload.ts
115
120
  import { afterAll, setDefaultTimeout } from "bun:test";
@@ -7728,6 +7733,7 @@ function locateHelper(input) {
7728
7733
  function resolveHelper(o) {
7729
7734
  if (o.helperPath !== undefined)
7730
7735
  return { success: true, data: o.helperPath };
7736
+ const version = o.version ?? o.env?.wardenVersion;
7731
7737
  const located = locateHelper({
7732
7738
  env: { [ENV_NAMES.simd]: o.env?.simd, [ENV_NAMES.helperDir]: o.env?.helperDir },
7733
7739
  execPath: o.execPath ?? process.execPath,
@@ -7735,7 +7741,7 @@ function resolveHelper(o) {
7735
7741
  wardenHome: o.wardenHome ?? wardenHome(),
7736
7742
  exists: o.exists ?? existsSync7,
7737
7743
  realpath: o.realpath ?? realpathSync,
7738
- ...o.version ? { version: o.version } : {}
7744
+ ...version ? { version } : {}
7739
7745
  });
7740
7746
  return located.success ? { success: true, data: located.data.path } : located;
7741
7747
  }
@@ -7816,6 +7822,10 @@ function toSnapshot(data, takenAt) {
7816
7822
  };
7817
7823
  if (data.modal)
7818
7824
  snapshot.modal = data.modal;
7825
+ if (data.fallback?.from === "axbridge")
7826
+ snapshot.fellBackFrom = "axbridge";
7827
+ if (data.fallback?.stall)
7828
+ snapshot.bridgeStall = toBridgeStall(data.fallback.stall);
7819
7829
  return snapshot;
7820
7830
  }
7821
7831
 
@@ -8401,6 +8411,17 @@ var DEFAULT_QUIET_MS = 250;
8401
8411
  var DEFAULT_SETTLE_TIMEOUT_MS2 = 5000;
8402
8412
  var SETTLE_MARGIN_MS = 2000;
8403
8413
  var REBIND_TIMEOUT_MS = 1e4;
8414
+ var COLD_BRIDGE_BUDGET_MS = 60000;
8415
+ var COLD_BRIDGE_FIRST_WAIT_MS = 2000;
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
+ }
8404
8425
  var GESTURE_MARGIN_MS = 5000;
8405
8426
  var FRONTMOST_TIMEOUT_MS = 3000;
8406
8427
  var LAUNCH_TIMEOUT_MS = 30000;
@@ -8474,6 +8495,23 @@ function noteSnapshotError(detail) {
8474
8495
  if (typeof value === "string")
8475
8496
  noteSpanDetail(to, value);
8476
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);
8477
8515
  }
8478
8516
  function noteHelperHealth(health) {
8479
8517
  noteSpanDetail("bridge", health.ax.bridge);
@@ -8489,6 +8527,36 @@ function noteHelperHealth(health) {
8489
8527
  noteSpanDetail("coreSimulator", health.coreSimulatorVersion);
8490
8528
  if (health.frontmost)
8491
8529
  noteSpanDetail("frontmostUnresolvedAgoMs", health.frontmost.unresolvedAgoMs);
8530
+ if (health.bridgeRecycles) {
8531
+ noteSpanDetail("bridgeRecycles", health.bridgeRecycles.count);
8532
+ noteSpanDetail("bridgeRecyclesExhausted", health.bridgeRecycles.exhausted);
8533
+ }
8534
+ }
8535
+ function coldBridgeWait(error, attempt, spentMs, budgetMs) {
8536
+ if (!bridgeNotReady(error))
8537
+ return;
8538
+ const wait = attempt === 1 ? COLD_BRIDGE_FIRST_WAIT_MS : COLD_BRIDGE_WAIT_MS;
8539
+ return spentMs + wait >= budgetMs ? undefined : wait;
8540
+ }
8541
+ async function retryColdSnapshot(read, ctx) {
8542
+ const started = ctx.now();
8543
+ let waited = 0;
8544
+ for (let attempt = 1;; attempt++) {
8545
+ const result = await read();
8546
+ const spent = Math.max(ctx.now() - started, waited);
8547
+ const wait = result.success ? undefined : coldBridgeWait(result.error, attempt, spent, ctx.budgetMs);
8548
+ if (wait === undefined) {
8549
+ if (attempt > 1)
8550
+ noteSpanDetail("coldBridgeMs", spent);
8551
+ return result;
8552
+ }
8553
+ ctx.log?.(`[ios] snapshot try ${attempt} failed, the bridge is not up yet: retrying`);
8554
+ await ctx.sleep(wait);
8555
+ waited += wait;
8556
+ }
8557
+ }
8558
+ function bridgeNotReady(error) {
8559
+ return error.code === "E_TIMEOUT" || error.code === "E_AX_UNAVAILABLE";
8492
8560
  }
8493
8561
  function noteRebind(data) {
8494
8562
  noteSpanDetail("rebind", data.bound ? "bound" : data.reason ?? "unbound");
@@ -8562,6 +8630,10 @@ function createIosDriver(swap, simctl, opts) {
8562
8630
  let settleUnsupported = !nativeSettle;
8563
8631
  let settleAxFailures = 0;
8564
8632
  let rebindUnsupported = false;
8633
+ let bridgeProven = false;
8634
+ let warmGeneration;
8635
+ const coldTimeoutMs = coldReadTimeoutMs();
8636
+ const readOpts = () => warmGeneration === swap.generation() ? {} : { minTimeoutMs: coldTimeoutMs };
8565
8637
  let gestureUnsupported = false;
8566
8638
  let appOpsOutdated = false;
8567
8639
  const capabilities = {
@@ -8657,7 +8729,8 @@ function createIosDriver(swap, simctl, opts) {
8657
8729
  }
8658
8730
  let failureDiagnosed = false;
8659
8731
  const fetchSnapshot = async (o) => {
8660
- 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());
8661
8734
  if (!result.success) {
8662
8735
  noteSnapshotError(result.error.detail);
8663
8736
  if (!failureDiagnosed) {
@@ -8670,18 +8743,15 @@ function createIosDriver(swap, simctl, opts) {
8670
8743
  }
8671
8744
  failureDiagnosed = false;
8672
8745
  const { fallback, target } = result.data;
8746
+ if (result.data.backend === "axbridge" && !fallback)
8747
+ warmGeneration = generation;
8673
8748
  noteSpanDetail("backend", result.data.backend);
8674
- if (fallback) {
8675
- opts.log?.(`[ios] snapshot fell back ${fallback.from} -> ${fallback.to}: ${fallback.reason}`);
8676
- noteSpanDetail("fallback", fallback.reason);
8677
- }
8678
- if (target) {
8679
- noteSpanDetail("targetPid", target.pid);
8680
- noteSpanDetail("targetSource", target.source);
8681
- }
8749
+ if (fallback)
8750
+ noteFallback(fallback, opts.log);
8751
+ noteSnapshotTarget(target);
8682
8752
  return { success: true, data: toSnapshot(result.data, now()) };
8683
8753
  };
8684
- async function snapshot(o) {
8754
+ async function snapshotOnce(o) {
8685
8755
  const generation = swap.generation();
8686
8756
  const first = await fetchSnapshot(o);
8687
8757
  if (first.success) {
@@ -8702,6 +8772,19 @@ function createIosDriver(swap, simctl, opts) {
8702
8772
  axFailures = 0;
8703
8773
  return fetchSnapshot(o);
8704
8774
  }
8775
+ async function snapshot(o) {
8776
+ if (bridgeProven)
8777
+ return snapshotOnce(o);
8778
+ const result = await retryColdSnapshot(() => snapshotOnce(o), {
8779
+ now,
8780
+ sleep,
8781
+ budgetMs: opts.coldBridgeBudgetMs ?? COLD_BRIDGE_BUDGET_MS,
8782
+ ...opts.log ? { log: opts.log } : {}
8783
+ });
8784
+ if (result.success)
8785
+ bridgeProven = true;
8786
+ return result;
8787
+ }
8705
8788
  async function typeText(text) {
8706
8789
  const typed = await sendVoid("typeText", { text }, typeTextBudgetMs(text));
8707
8790
  if (typed.success || typed.error.code !== "E_UNSUPPORTED_TEXT")
@@ -8728,7 +8811,7 @@ function createIosDriver(swap, simctl, opts) {
8728
8811
  if (!snap.success)
8729
8812
  throw new DriverFailure(snap.error);
8730
8813
  return snapshotHash(snap.data);
8731
- }, { quietMs: o.quietMs, timeoutMs: o.timeoutMs, now, sleep });
8814
+ }, { quietMs: hashFallbackQuietMs(o.quietMs), timeoutMs: o.timeoutMs, now, sleep });
8732
8815
  noteSpanDetail("via", "hash");
8733
8816
  return { success: true, data: { ...outcome, via: "hash" } };
8734
8817
  } catch (error) {
@@ -8739,6 +8822,7 @@ function createIosDriver(swap, simctl, opts) {
8739
8822
  }
8740
8823
  async function settleAfterLaunch(bundleId) {
8741
8824
  lastLaunched = bundleId;
8825
+ warmGeneration = undefined;
8742
8826
  launchedBundle = bundleId;
8743
8827
  if (await rebind(bundleId))
8744
8828
  launchedBundle = undefined;
@@ -8762,6 +8846,8 @@ function createIosDriver(swap, simctl, opts) {
8762
8846
  return false;
8763
8847
  }
8764
8848
  noteRebind(reply.data);
8849
+ if (reply.data.bound)
8850
+ bridgeProven = true;
8765
8851
  return reply.data.bound;
8766
8852
  }
8767
8853
  function noteSettleAxFailure(message) {
@@ -8834,7 +8920,7 @@ function createIosDriver(swap, simctl, opts) {
8834
8920
  return info;
8835
8921
  }
8836
8922
  async function readAlert(backend) {
8837
- const result = await request("alertButtons", params({ backend }));
8923
+ const result = await request("alertButtons", params({ backend }), readOpts());
8838
8924
  if (!result.success)
8839
8925
  return result;
8840
8926
  return { success: true, data: result.data === null ? null : toAlertInfo(result.data) };
@@ -8890,7 +8976,7 @@ function createIosDriver(swap, simctl, opts) {
8890
8976
  health,
8891
8977
  snapshot,
8892
8978
  async elementAtPoint(point, o) {
8893
- 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());
8894
8980
  if (!result.success)
8895
8981
  return result;
8896
8982
  return {
@@ -9082,6 +9168,7 @@ async function openIosDriver(o) {
9082
9168
  now,
9083
9169
  sleep,
9084
9170
  log,
9171
+ ...o.coldBridgeBudgetMs !== undefined ? { coldBridgeBudgetMs: o.coldBridgeBudgetMs } : {},
9085
9172
  ...network ? { network } : {},
9086
9173
  a11y: () => iosA11yFor(o, now, sleep),
9087
9174
  nativeAudit: (ctx) => nativeAuditFor(o, exec, log, ctx),
@@ -9417,7 +9504,12 @@ function createIosDeviceDriver(client, devicectl, opts) {
9417
9504
  if (!snap.success)
9418
9505
  throw new DriverFailure2(snap.error);
9419
9506
  return snapshotHash(snap.data);
9420
- }, { 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
+ });
9421
9513
  noteSpanDetail("via", "hash");
9422
9514
  return { success: true, data: { ...outcome, via: "hash" } };
9423
9515
  } catch (error) {
@@ -10406,7 +10498,7 @@ function createDevice({
10406
10498
  if (native.error.code !== "E_UNSUPPORTED")
10407
10499
  throw toE2eError(native.error, "settle");
10408
10500
  const polled = await settleByHash(async () => snapshotHash(await readSnapshot()), {
10409
- quietMs,
10501
+ quietMs: hashFallbackQuietMs(quietMs),
10410
10502
  timeoutMs,
10411
10503
  now: clock.now,
10412
10504
  sleep: clock.sleep
@@ -10529,15 +10621,16 @@ class Runtime {
10529
10621
  const result = canObserve(driver) ? await driver.observe({ reuse }) : await driver.snapshot();
10530
10622
  if (!result.success)
10531
10623
  return { ok: false, error: result.error };
10624
+ const snapshot = result.data;
10532
10625
  const observation = {
10533
10626
  ok: true,
10534
- observed: { driver, snapshot: result.data, index: this.indexOf(result.data) }
10627
+ observed: { driver, snapshot, index: this.indexOf(snapshot) }
10535
10628
  };
10536
10629
  if (before !== undefined)
10537
10630
  observation.earlier = this.earlierThan(driver, before, result.data);
10538
10631
  if (options.alerts === false || answered >= MAX_ALERT_ANSWERS)
10539
10632
  return observation;
10540
- if (!await this.answeredAlert(driver, result.data))
10633
+ if (!await this.answeredAlert(driver, snapshot))
10541
10634
  return observation;
10542
10635
  reuse = false;
10543
10636
  }
@@ -10578,18 +10671,25 @@ class Runtime {
10578
10671
  throw driverFailure("snapshot", result.last ?? { code: "E_TIMEOUT", message: "no snapshot", retryable: true });
10579
10672
  }
10580
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) {
10581
10681
  const facet = this.host.devtools?.();
10582
10682
  if (facet === undefined)
10583
- return [];
10683
+ return { answered: false };
10584
10684
  const key = typeof testId === "string" ? `s:${testId}` : `r:${testId.toString()}`;
10585
10685
  const cached = this.reactCache;
10586
10686
  if (cached !== undefined && cached.key === key && this.clock.now() - cached.at < REACT_THROTTLE_MS) {
10587
- return cached.matches;
10687
+ return cached.lookup;
10588
10688
  }
10589
10689
  const view = { platform: observed.snapshot.platform, screen: observed.snapshot.screen };
10590
- const matches = await locateByTestIdViaReact(facet, testId, view, { timeoutMs: REACT_TIMEOUT_MS });
10591
- this.reactCache = { key, at: this.clock.now(), matches };
10592
- 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;
10593
10693
  }
10594
10694
  excerpt(snapshot) {
10595
10695
  const rendered = renderSnapshot(snapshot, { interactiveOnly: true, refs: new Map, maxLines: EXCERPT_LINES });
@@ -18,6 +18,7 @@ type RunSettings = {
18
18
  passes: number;
19
19
  count?: number | undefined;
20
20
  profile?: string | undefined;
21
+ runtime?: string | undefined;
21
22
  devices?: Array<{
22
23
  profile: string;
23
24
  default: boolean;
@@ -141,6 +142,7 @@ export declare const e2eSuiteSchema: z.ZodObject<{
141
142
  retry: z.ZodDefault<z.ZodNumber>;
142
143
  count: z.ZodOptional<z.ZodNumber>;
143
144
  profile: z.ZodOptional<z.ZodString>;
145
+ runtime: z.ZodOptional<z.ZodString>;
144
146
  app: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
145
147
  installMode: z.ZodOptional<z.ZodEnum<{
146
148
  clean: "clean";
@@ -333,6 +333,7 @@ export declare const wardenConfigSchema: z.ZodObject<{
333
333
  retry: z.ZodDefault<z.ZodNumber>;
334
334
  count: z.ZodOptional<z.ZodNumber>;
335
335
  profile: z.ZodOptional<z.ZodString>;
336
+ runtime: z.ZodOptional<z.ZodString>;
336
337
  app: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
337
338
  installMode: z.ZodOptional<z.ZodEnum<{
338
339
  clean: "clean";
@@ -687,6 +687,16 @@ export interface WardenE2eSuite {
687
687
  count?: number | undefined;
688
688
  /** Device profile (not with `devices`). */
689
689
  profile?: string | undefined;
690
+ /**
691
+ * iOS simulator runtime the run's devices boot on, and whose golden they clone: `latest`, `iOS-26-5` or `26.5`.
692
+ * Pin it so the run (locally and on CI) sees one accessibility tree: values change between iOS releases.
693
+ * A runtime that is not installed fails the run before any device is claimed, listing the installed ones; it never
694
+ * falls back to another. Applies to every `devices` leg. Skipped on Android. `--runtime` overrides it.
695
+ * `warden golden key --profile <profile> --runtime <runtime>` names the golden archive the run uses.
696
+ *
697
+ * @example "27.0"
698
+ */
699
+ runtime?: string | undefined;
690
700
  /** Ensure the app on every device first. */
691
701
  app?: WardenAppOption | undefined;
692
702
  /** Ports leased for the run (`<from>[:<span>]`), exported as `WARDEN_PORT_<i>` / `WARDEN_PORTS` to serve, setup and every spec process. */
@@ -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:
@@ -134,6 +135,14 @@ export type Snapshot = {
134
135
  flat: Element[];
135
136
  truncated: boolean;
136
137
  modal?: ModalInfo;
138
+ /**
139
+ * Set when `backend` answered because this reader failed or was skipped first (iOS: `axbridge` stalled, `ax` read in
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.
142
+ */
143
+ fellBackFrom?: SnapshotBackend;
144
+ /** iOS: what the bridge failure behind `fellBackFrom` looked like, when the helper reported it */
145
+ bridgeStall?: BridgeStall;
137
146
  };
138
147
  export type Orientation = "portrait" | "portraitUpsideDown" | "landscapeLeft" | "landscapeRight";
139
148
  export type HardwareButton = "home" | "back" | "lock" | "volumeUp" | "volumeDown" | "shake";
@@ -25,7 +25,7 @@ import type { SimdSpawn } from "./simd-transport";
25
25
  * cannot type, `settle` polls snapshot hashes when the helper has no native quiescence, `screenshot` uses
26
26
  * `simctl io` when the helper cannot, and a helper built before the app ops leaves app lifecycle to `simctl`.
27
27
  */
28
- export type IosDriverEnv = Pick<E2eEnv, "simd" | "simdSocket" | "helperDir">;
28
+ export type IosDriverEnv = Pick<E2eEnv, "simd" | "simdSocket" | "helperDir"> & Partial<Pick<E2eEnv, "wardenVersion">>;
29
29
  /** The iOS driver is a `Driver` plus the optional capabilities of `driver/alerts.ts`. */
30
30
  export type IosDriver = Driver & SelectsAll & PastesText & ReadsAlertOn & ProbesFrontmost & ReadsA11y & ReadsA11yEvents & AuditsNatively;
31
31
  export type OpenIosDriverOptions = {
@@ -46,6 +46,8 @@ export type OpenIosDriverOptions = {
46
46
  client?: SimdClient;
47
47
  now?: () => number;
48
48
  sleep?: (ms: number) => Promise<void>;
49
+ /** how long the session's first snapshot waits for an in-sim bridge that is up but silent (default 60 s); 0 tries once */
50
+ coldBridgeBudgetMs?: number;
49
51
  /** for `locateHelper` / `$WARDEN_HOME/simd/<version>`; defaults read the process */
50
52
  version?: string;
51
53
  wardenHome?: string;
@@ -82,6 +84,23 @@ export type OpenIosDriverOptions = {
82
84
  /** test seams for the XCUITest audit's runner */
83
85
  nativeAudit?: Pick<IosNativeAuditOptions, "ensureBuild" | "spawn" | "connect">;
84
86
  };
87
+ /**
88
+ * How long the first snapshot of a session waits for an in-sim bridge that is up but silent. A freshly cloned
89
+ * simulator on a loaded host can take longer than one read's budget (axbridge 4 s + AX fallback 5 s) to answer the
90
+ * first time, and every read after it is fast, so the first one retries while the failure says "not ready yet".
91
+ * Once a read or a rebind has succeeded the normal per-call budgets apply.
92
+ */
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;
85
104
  /** USB HID keyboard-page usage ids for the keys `Driver.key` offers. */
86
105
  export declare const KEY_USAGE: Readonly<Record<KeyName, number>>;
87
106
  /** The fields of a failed snapshot's `detail` the helper sets (which reader, why, both readers' errors), on its span. */
@@ -93,6 +112,8 @@ export declare function createIosDriver(swap: SwappableClient, simctl: Simctl, o
93
112
  now: () => number;
94
113
  sleep: (ms: number) => Promise<void>;
95
114
  log?: Logger;
115
+ /** how long the first snapshot waits for a bridge that is not up yet (default {@link COLD_BRIDGE_BUDGET_MS}) */
116
+ coldBridgeBudgetMs?: number;
96
117
  /** told about every successful `clock` op */
97
118
  clockRecorder?: ClockRecorder;
98
119
  /** device settings (`ios-settings.ts`); absent: those ops answer `E_UNSUPPORTED` */
@@ -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;
@@ -33,6 +33,14 @@ export declare function settleDetailOf(outcome: SettleOutcome): SettleDetail;
33
33
  export declare const DEFAULT_SETTLE_QUIET_MS = 250;
34
34
  /** How long a settle waits for quiet before it gives up. */
35
35
  export declare const DEFAULT_SETTLE_TIMEOUT_MS = 3000;
36
+ /**
37
+ * The shortest window a snapshot-hash settle fallback waits (iOS, and `device.settle` on a driver without one). A navigation push exposes both screens at their final
38
+ * frames for 300-460 ms before the old one leaves the tree, so two equal reads 250 ms apart can settle on that mixed
39
+ * tree. The helper's own snapshot fallback (`FrameStability.minQuietMs`) uses the same floor.
40
+ */
41
+ export declare const HASH_FALLBACK_MIN_QUIET_MS = 500;
42
+ /** The quiet window a hash fallback uses for a caller's `quietMs`. */
43
+ export declare function hashFallbackQuietMs(quietMs: number): number;
36
44
  /**
37
45
  * Snapshot-hash settle: poll a UI fingerprint until it stays unchanged for `quietMs`
38
46
  * (two consecutive equal hashes and ≥ quietMs since the last change), or give up at `timeoutMs`.
@@ -1,5 +1,7 @@
1
1
  import type { ErrorResult } from "../../types/src/result";
2
- export type E2eErrorCode = "LOCATOR_NOT_FOUND" | "LOCATOR_AMBIGUOUS" | "INVALID_LOCATOR" | "ACTION_FAILED" | "TIMEOUT" | "NOT_ACTIONABLE" | "INVALID_ARGUMENT" | "SESSION" | "DRIVER" | "ASSERTION" | "PERF_BUDGET";
2
+ export type E2eErrorCode = "LOCATOR_NOT_FOUND" | "LOCATOR_AMBIGUOUS" | "INVALID_LOCATOR" | "ACTION_FAILED" | "TIMEOUT" | "NOT_ACTIONABLE" | "INVALID_ARGUMENT" | "SESSION" | "DRIVER" | "ASSERTION" | "PERF_BUDGET" | "RUN_FAILED"
3
+ /** a read that fell back from its first reader (or came back an empty root) could not confirm the locator either way */
4
+ | "READ_DEGRADED";
3
5
  /** Plain shape of a spec error: what `e2eErr()` accepts and what a `Result` carries. */
4
6
  export type E2eErrorShape = {
5
7
  code: E2eErrorCode;
@@ -81,6 +81,11 @@ export declare class LocatorAssertions {
81
81
  toHaveCount(expected: number, options?: AssertionOptions): Promise<void>;
82
82
  private assert;
83
83
  private poll;
84
+ /**
85
+ * What one look says about `spec`: a judgement (from the accessibility tree, or from the React tree for a degraded
86
+ * read it answered), or `unsettled` when the read was degraded and nothing could say whether the locator is there.
87
+ */
88
+ private judgeLook;
84
89
  /** The screen is rendered here, from the last snapshot read, because only a failure needs the text. */
85
90
  private failure;
86
91
  }
@@ -10,6 +10,8 @@ export { expect } from "./expect/expect";
10
10
  export type { PerfBreach, PerfBudgets } from "./perf/budgets";
11
11
  export type { DevicePerf } from "./perf/device-perf";
12
12
  export type { PerfMetrics, PerfReport, PerfStartOptions } from "./perf/perf.types";
13
+ export type { Run, RunCommand, RunDuration, RunOptions, RunResult } from "./run";
14
+ export { run } from "./run";
13
15
  export type { App, Device, Screen, SystemAlerts, TestFixtures } from "./runner/fixtures.types";
14
16
  export { afterAll, afterEach, beforeAll, beforeEach, describe, test } from "./runner/test";
15
17
  export type { Locator, LocatorReading, PinchOptions, RotateOptions, TapOptions, TapUntil } from "./screen/locator";
@@ -0,0 +1,48 @@
1
+ import type { SpanRunner } from "./runner/span";
2
+ /** Milliseconds, or a duration string (`500ms`, `30s`, `2m`). */
3
+ export type RunDuration = string | number;
4
+ export type RunOptions = {
5
+ /** working directory; default the spec process's (the project root under `warden e2e`) */
6
+ cwd?: string;
7
+ /** merged over the spec's environment; `undefined` removes a variable */
8
+ env?: Record<string, string | undefined>;
9
+ /** kill the command after this long and throw `TIMEOUT`; default `60s` */
10
+ timeout?: RunDuration;
11
+ /** written to the command's stdin */
12
+ input?: string;
13
+ /** throw `RUN_FAILED` on a non-zero exit; default true. `false` returns the result with `ok: false` */
14
+ check?: boolean;
15
+ };
16
+ export type RunResult = {
17
+ /** exit code was 0 */
18
+ ok: boolean;
19
+ exitCode: number;
20
+ stdout: string;
21
+ stderr: string;
22
+ /** wall time of the command */
23
+ ms: number;
24
+ /** `stdout` parsed as JSON; throws `INVALID_ARGUMENT` when it is not JSON */
25
+ json(): unknown;
26
+ };
27
+ /**
28
+ * A shell command (`string`, run with `sh -c`) or an argv (`string[]`, no shell: nothing is expanded or split).
29
+ */
30
+ export type RunCommand = string | readonly string[];
31
+ export type Run = (command: RunCommand, options?: RunOptions) => Promise<RunResult>;
32
+ export type RunDeps = {
33
+ /** times the call as a span in the running attempt's trace */
34
+ span: SpanRunner;
35
+ };
36
+ /** `run` with its span runner injected (tests); specs use {@link run}. */
37
+ export declare function createRun(deps: RunDeps): Run;
38
+ /**
39
+ * Runs a command on the host (the machine running the spec, not the device) and returns its exit code and output.
40
+ * Use it to seed a backend, call a script, or reset state before a test. A non-zero exit throws `RUN_FAILED`
41
+ * (`check: false` returns it instead), a command over `timeout` (default 60 s) is killed and throws `TIMEOUT`.
42
+ * Inside a test it is one `run` span in `trace.jsonl` (exit code only, never the command).
43
+ *
44
+ * @example
45
+ * await run("bun scripts/seed.ts");
46
+ * const { id } = (await run(["bun", "scripts/create-user.ts", "--json"])).json() as { id: string };
47
+ */
48
+ export declare const run: Run;
@@ -0,0 +1,12 @@
1
+ import type { TestResult } from "./results";
2
+ import type { TraceEvent } from "./trace";
3
+ export type BridgeEvidence = NonNullable<TestResult["bridge"]>;
4
+ /**
5
+ * Collects an attempt's iOS bridge fallbacks from its spans for `results.jsonl` (`bridge`): reads answered by `ax`
6
+ * in place of `axbridge`, and failed reads that carried a stall report. A read the bridge answered by pid (frontmost
7
+ * unresolved) is not one.
8
+ */
9
+ export declare function bridgeEvidence(): {
10
+ observe(event: TraceEvent): void;
11
+ summary(): BridgeEvidence | undefined;
12
+ };