@delacour/warden-e2e 0.2.0 → 0.4.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
@@ -31,7 +31,7 @@ import {
31
31
  registrationDefaults,
32
32
  markOnly,
33
33
  WARDEN_E2E_VERSION2
34
- } from "./chunk-7ns30pbp.mjs";
34
+ } from "./chunk-3874jt2j.mjs";
35
35
  // ../../packages/e2e/src/expect/expect.ts
36
36
  import { expect as bunExpect } from "bun:test";
37
37
 
@@ -742,60 +742,60 @@ async function runAttemptOnce(session, spec) {
742
742
  const bound = attemptSession(session, () => binding ?? { context: ctx, finished: false });
743
743
  print(formatConsoleLine({ type: "test-start", test: { file: spec.file, title: [...spec.title], startedAt, device } }, { clock }));
744
744
  session.enterAttempt(ctx);
745
+ const teardowns = [];
746
+ const state = {};
747
+ let outcome = await raceDeadline(controller, use.timeoutMs, () => setUpAndRun(bound, spec, use, controller.signal, state, teardowns));
748
+ outcome = failIf(outcome, ctx.softFailures.length > 0, () => softFailureError(ctx.softFailures));
749
+ if (outcome.failed)
750
+ await captureFailureArtifacts(session, ctx, budget, startedAt);
751
+ const cleanupErrors = await runCleanup(session, spec, ctx, state.bag, teardowns, budget, (context) => {
752
+ binding = { context, finished: false };
753
+ });
754
+ const finished = new E2eError2("SESSION", "attempt finished");
755
+ if (!controller.signal.aborted)
756
+ controller.abort(finished);
757
+ binding = { context: { ...ctx, signal: AbortSignal.abort(finished), depth: 0 }, finished: true };
758
+ session.leaveAttempt(ctx);
759
+ trace.flush();
760
+ outcome = failIf(outcome, cleanupErrors.length > 0, () => cleanupErrors[0]);
761
+ return concludeAttempt(session, spec, ctx, { startedAt, device }, outcome, print, clock);
762
+ }
763
+ async function raceDeadline(controller, timeoutMs, run) {
745
764
  let deadlineHit = false;
746
765
  const timer = setTimeout(() => {
747
766
  deadlineHit = true;
748
- controller.abort(new E2eError2("TIMEOUT", `test timed out after ${use.timeoutMs}ms`));
749
- }, use.timeoutMs);
767
+ controller.abort(new E2eError2("TIMEOUT", `test timed out after ${timeoutMs}ms`));
768
+ }, timeoutMs);
750
769
  const deadline = new Promise((_, reject) => {
751
770
  controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { once: true });
752
771
  });
753
772
  deadline.catch(() => {
754
773
  return;
755
774
  });
756
- const teardowns = [];
757
- const state = {};
758
- let failed = false;
759
- let failure;
760
775
  try {
761
- await Promise.race([setUpAndRun(bound, spec, use, controller.signal, state, teardowns), deadline]);
776
+ await Promise.race([run(), deadline]);
777
+ return { failed: false, failure: undefined, deadlineHit };
762
778
  } catch (error) {
763
- failed = true;
764
- failure = error;
779
+ return { failed: true, failure: error, deadlineHit };
765
780
  } finally {
766
781
  clearTimeout(timer);
767
782
  }
768
- if (!failed && ctx.softFailures.length > 0) {
769
- failed = true;
770
- failure = softFailureError(ctx.softFailures);
771
- }
772
- if (failed)
773
- await captureFailureArtifacts(session, ctx, budget, startedAt);
774
- const cleanupErrors = await runCleanup(session, spec, ctx, state.bag, teardowns, budget, (context) => {
775
- binding = { context, finished: false };
776
- });
777
- const finished = new E2eError2("SESSION", "attempt finished");
778
- if (!controller.signal.aborted)
779
- controller.abort(finished);
780
- binding = { context: { ...ctx, signal: AbortSignal.abort(finished), depth: 0 }, finished: true };
781
- session.leaveAttempt(ctx);
782
- trace.flush();
783
- if (!failed && cleanupErrors.length > 0) {
784
- failed = true;
785
- failure = cleanupErrors[0];
786
- }
787
- for (const shot of listScreenshots(dir))
788
- files.add(shot);
789
- const outcome = failed ? { failed: true, error: failure, timedOut: deadlineHit } : { failed: false };
790
- const result = buildResult(spec, ctx, { startedAt, device }, outcome);
783
+ }
784
+ function failIf(outcome, present, failure) {
785
+ return outcome.failed || !present ? outcome : { ...outcome, failed: true, failure: failure() };
786
+ }
787
+ async function concludeAttempt(session, spec, ctx, run, outcome, print, clock) {
788
+ for (const shot of listScreenshots(ctx.dir))
789
+ ctx.files.add(shot);
790
+ const result = buildResult(spec, ctx, run, outcome.failed ? { failed: true, error: outcome.failure, timedOut: outcome.deadlineHit } : { failed: false });
791
791
  print(formatConsoleLine({ type: "test-end", result }, { clock }));
792
- if (failed && session.recoverHid !== undefined && isHidLost(failure) && await session.recoverHid()) {
792
+ if (outcome.failed && session.recoverHid !== undefined && isHidLost(outcome.failure) && await session.recoverHid()) {
793
793
  return "again";
794
794
  }
795
- if (!failed || spec.final)
795
+ if (!outcome.failed || spec.final)
796
796
  session.results.append(result);
797
- if (failed)
798
- throw failure instanceof Error ? failure : new Error(String(failure));
797
+ if (outcome.failed)
798
+ throw outcome.failure instanceof Error ? outcome.failure : new Error(String(outcome.failure));
799
799
  return;
800
800
  }
801
801
  function softFailureError(failures) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/warden-e2e",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "TypeScript spec API for `warden e2e`: test, expect, screen, device, app",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,7 +37,7 @@
37
37
  "LICENSE"
38
38
  ],
39
39
  "peerDependencies": {
40
- "@delacour/warden": "0.2.0"
40
+ "@delacour/warden": "0.4.0"
41
41
  },
42
42
  "dependencies": {
43
43
  "zod": "^4.4.3"
package/preload.mjs CHANGED
@@ -68,7 +68,7 @@ import {
68
68
  onlyWasSeen,
69
69
  WORKSPACE_VERSION,
70
70
  WARDEN_E2E_VERSION2
71
- } from "./chunk-7ns30pbp.mjs";
71
+ } from "./chunk-3874jt2j.mjs";
72
72
 
73
73
  // ../../packages/e2e/src/preload.ts
74
74
  import { afterAll, setDefaultTimeout } from "bun:test";
@@ -3238,8 +3238,8 @@ function createSimctl(deps) {
3238
3238
 
3239
3239
  // ../../packages/e2e/src/driver/ios/type-budget.ts
3240
3240
  var TYPE_BASE_MS = 1e4;
3241
- var TYPE_PER_CHAR_MS = 70;
3242
- var TYPE_PER_REPEAT_MS = 300;
3241
+ var TYPE_PER_CHAR_MS = 100;
3242
+ var TYPE_PER_REPEAT_MS = 200;
3243
3243
  function typeTextBudgetMs(text) {
3244
3244
  const characters = Array.from(text);
3245
3245
  let repeats = 0;
@@ -3257,6 +3257,8 @@ var SETTLE_MARGIN_MS = 2000;
3257
3257
  var REBIND_TIMEOUT_MS = 3000;
3258
3258
  var GESTURE_MARGIN_MS = 5000;
3259
3259
  var FRONTMOST_TIMEOUT_MS = 3000;
3260
+ var LAUNCH_TIMEOUT_MS = 30000;
3261
+ var APP_OP_TIMEOUT_MS = 15000;
3260
3262
  var KEY_USAGE = {
3261
3263
  Enter: 40,
3262
3264
  Escape: 41,
@@ -3311,6 +3313,25 @@ function nativeDetail(data) {
3311
3313
  noteSpanDetail("frontmostPid", frontmostPid);
3312
3314
  return detail;
3313
3315
  }
3316
+ function noteRebind(data) {
3317
+ noteSpanDetail("rebind", data.bound ? "bound" : data.reason ?? "unbound");
3318
+ noteSpanDetail("rebindMs", data.waitedMs);
3319
+ if (data.restarts !== undefined && data.restarts > 0)
3320
+ noteSpanDetail("rebindRestarts", data.restarts);
3321
+ if (data.recycled === true)
3322
+ noteSpanDetail("rebindRecycled", true);
3323
+ }
3324
+ function healthDetail(d) {
3325
+ return {
3326
+ axDefault: d.ax.default,
3327
+ ...d.ax.bridge === "degraded" ? { axBridge: "degraded" } : {},
3328
+ ...d.ax.lastError ? { axLastError: d.ax.lastError } : {},
3329
+ ...d.ax.error !== undefined ? { axError: d.ax.error } : {},
3330
+ ...d.socket ? { socket: d.socket } : {},
3331
+ ...d.xcode ? { xcode: d.xcode } : {},
3332
+ ...d.state ? { state: d.state } : {}
3333
+ };
3334
+ }
3314
3335
 
3315
3336
  class DriverFailure extends Error {
3316
3337
  error;
@@ -3329,6 +3350,9 @@ function restOf(error, text) {
3329
3350
  function decodeBase64(png) {
3330
3351
  return new Uint8Array(Buffer.from(png, "base64"));
3331
3352
  }
3353
+ function predatesOp(error) {
3354
+ return error.code === "E_UNSUPPORTED" || error.code === "E_BAD_REQUEST" && /unknown (input )?op/.test(error.message);
3355
+ }
3332
3356
  function createIosDriver(swap, simctl, opts) {
3333
3357
  const { udid, now, sleep } = opts;
3334
3358
  const { client } = swap;
@@ -3340,6 +3364,7 @@ function createIosDriver(swap, simctl, opts) {
3340
3364
  let settleUnsupported = !nativeSettle;
3341
3365
  let rebindUnsupported = false;
3342
3366
  let gestureUnsupported = false;
3367
+ let appOpsOutdated = false;
3343
3368
  const capabilities = {
3344
3369
  pinch: true,
3345
3370
  maxPointers: 2,
@@ -3407,6 +3432,30 @@ function createIosDriver(swap, simctl, opts) {
3407
3432
  }
3408
3433
  return result;
3409
3434
  };
3435
+ async function appOp(op, p, timeoutMs) {
3436
+ if (appOpsOutdated) {
3437
+ noteSpanDetail("via", "simctl");
3438
+ return;
3439
+ }
3440
+ const result = await request(op, p, { timeoutMs });
3441
+ if (!result.success && predatesOp(result.error)) {
3442
+ appOpsOutdated = true;
3443
+ noteSpanDetail("via", "simctl");
3444
+ noteSpanDetail("reason", "helper_outdated");
3445
+ opts.log?.(`[ios] this warden-simd predates the \`${op}\` op (${result.error.code}: ${result.error.message}); app lifecycle goes through simctl until the helper is restarted (warden ui close --udid ${udid}) or rebuilt (bun run --cwd packages/e2e native:build)`);
3446
+ return;
3447
+ }
3448
+ noteSpanDetail("via", "simd");
3449
+ return result;
3450
+ }
3451
+ async function appOutcome(viaHelper, fallback, note) {
3452
+ if (viaHelper === undefined)
3453
+ return fallback();
3454
+ if (!viaHelper.success)
3455
+ return viaHelper;
3456
+ note(viaHelper.data);
3457
+ return okVoid;
3458
+ }
3410
3459
  const fetchSnapshot = async (o) => {
3411
3460
  const result = await request("snapshot", params({ interactiveOnly: o?.interactiveOnly, backend: o?.backend }));
3412
3461
  if (!result.success)
@@ -3444,11 +3493,17 @@ function createIosDriver(swap, simctl, opts) {
3444
3493
  const rest = restOf(typed.error, text);
3445
3494
  if (rest === undefined)
3446
3495
  return typed;
3447
- const copied = await simctl.pbcopy(rest);
3448
- if (!copied.success)
3449
- return copied;
3450
3496
  opts.log?.(`[ios] typeText: pasting ${rest.length} unmappable character(s)`);
3451
- return sendVoid("paste");
3497
+ return pasteText(rest);
3498
+ }
3499
+ async function pasteText(text) {
3500
+ if (text === "")
3501
+ return ok(undefined);
3502
+ const copied = await simctl.pbcopy(text);
3503
+ const result = copied.success ? await sendVoid("paste") : copied;
3504
+ if (!result.success)
3505
+ opts.log?.(`[ios] pasteText failed (${result.error.code}): ${result.error.message}`);
3506
+ return result;
3452
3507
  }
3453
3508
  async function hashSettle(o) {
3454
3509
  try {
@@ -3477,36 +3532,37 @@ function createIosDriver(swap, simctl, opts) {
3477
3532
  opts.log?.(`[ios] rebind after launch failed (${reply.error.code}): ${reply.error.message}`);
3478
3533
  return false;
3479
3534
  }
3480
- noteSpanDetail("rebind", reply.data.bound ? "bound" : reply.data.reason ?? "unbound");
3481
- noteSpanDetail("rebindMs", reply.data.waitedMs);
3482
- if (reply.data.restarts !== undefined && reply.data.restarts > 0)
3483
- noteSpanDetail("rebindRestarts", reply.data.restarts);
3484
- if (reply.data.recycled === true)
3485
- noteSpanDetail("rebindRecycled", true);
3535
+ noteRebind(reply.data);
3486
3536
  return reply.data.bound;
3487
3537
  }
3538
+ async function settleViaHelper(quietMs, timeoutMs) {
3539
+ const expected = launchedBundle;
3540
+ const native = await request("settle", { quietMs, settleTimeoutMs: timeoutMs, ...expected === undefined ? {} : { bundleId: expected } }, { timeoutMs: timeoutMs + SETTLE_MARGIN_MS });
3541
+ if (native.success) {
3542
+ if (expected !== undefined && native.data.reason !== "frontmost_mismatch")
3543
+ launchedBundle = undefined;
3544
+ return { success: true, data: nativeDetail(native.data) };
3545
+ }
3546
+ if (native.error.code === "E_TIMEOUT") {
3547
+ noteSpanDetail("reason", "helper_timeout");
3548
+ return {
3549
+ success: true,
3550
+ data: { quiet: false, waitedMs: timeoutMs + SETTLE_MARGIN_MS, reason: "helper_timeout" }
3551
+ };
3552
+ }
3553
+ if (native.error.code !== "E_UNSUPPORTED")
3554
+ return native;
3555
+ settleUnsupported = true;
3556
+ opts.log?.("[ios] native settle unsupported, polling snapshot hashes");
3557
+ return;
3558
+ }
3488
3559
  async function settle(o = {}) {
3489
3560
  const quietMs = o.quietMs ?? DEFAULT_QUIET_MS;
3490
3561
  const timeoutMs = o.timeoutMs ?? DEFAULT_SETTLE_TIMEOUT_MS2;
3491
3562
  if (!settleUnsupported) {
3492
- const expected = launchedBundle;
3493
- const native = await request("settle", { quietMs, settleTimeoutMs: timeoutMs, ...expected === undefined ? {} : { bundleId: expected } }, { timeoutMs: timeoutMs + SETTLE_MARGIN_MS });
3494
- if (native.success) {
3495
- if (expected !== undefined && native.data.reason !== "frontmost_mismatch")
3496
- launchedBundle = undefined;
3497
- return { success: true, data: nativeDetail(native.data) };
3498
- }
3499
- if (native.error.code === "E_TIMEOUT") {
3500
- noteSpanDetail("reason", "helper_timeout");
3501
- return {
3502
- success: true,
3503
- data: { quiet: false, waitedMs: timeoutMs + SETTLE_MARGIN_MS, reason: "helper_timeout" }
3504
- };
3505
- }
3506
- if (native.error.code !== "E_UNSUPPORTED")
3563
+ const native = await settleViaHelper(quietMs, timeoutMs);
3564
+ if (native)
3507
3565
  return native;
3508
- settleUnsupported = true;
3509
- opts.log?.("[ios] native settle unsupported, polling snapshot hashes");
3510
3566
  }
3511
3567
  return hashSettle({ quietMs, timeoutMs });
3512
3568
  }
@@ -3564,15 +3620,7 @@ function createIosDriver(swap, simctl, opts) {
3564
3620
  transport: d.hid.transport,
3565
3621
  bridge: d.ax.bridge === "degraded" ? "available" : d.ax.bridge,
3566
3622
  screen: d.screen,
3567
- detail: {
3568
- axDefault: d.ax.default,
3569
- ...d.ax.bridge === "degraded" ? { axBridge: "degraded" } : {},
3570
- ...d.ax.lastError ? { axLastError: d.ax.lastError } : {},
3571
- ...d.ax.error !== undefined ? { axError: d.ax.error } : {},
3572
- ...d.socket ? { socket: d.socket } : {},
3573
- ...d.xcode ? { xcode: d.xcode } : {},
3574
- ...d.state ? { state: d.state } : {}
3575
- }
3623
+ detail: healthDetail(d)
3576
3624
  }
3577
3625
  };
3578
3626
  }
@@ -3619,6 +3667,7 @@ function createIosDriver(swap, simctl, opts) {
3619
3667
  typeText,
3620
3668
  key: (key, modifiers) => sendVoid("key", params({ usage: KEY_USAGE[key], modifiers: modifiers && [...modifiers] })),
3621
3669
  selectAll: () => sendVoid("selectAll"),
3670
+ pasteText,
3622
3671
  pressButton,
3623
3672
  screenshot,
3624
3673
  orientation,
@@ -3635,7 +3684,8 @@ function createIosDriver(swap, simctl, opts) {
3635
3684
  },
3636
3685
  app: {
3637
3686
  launch: async (bundleId, o) => {
3638
- const launched = await simctl.launch(bundleId, o);
3687
+ const viaHelper = await appOp("launch", params({ bundleId, args: o?.args && [...o.args], env: o?.env, terminateRunning: o?.relaunch !== false }), LAUNCH_TIMEOUT_MS);
3688
+ const launched = await appOutcome(viaHelper, () => simctl.launch(bundleId, o), (data) => noteSpanDetail("pid", data.pid));
3639
3689
  if (launched.success) {
3640
3690
  launchedBundle = bundleId;
3641
3691
  if (await rebind(bundleId))
@@ -3644,12 +3694,18 @@ function createIosDriver(swap, simctl, opts) {
3644
3694
  return launched;
3645
3695
  },
3646
3696
  terminate: async (bundleId) => {
3647
- const terminated = await simctl.terminate(bundleId);
3697
+ const viaHelper = await appOp("terminate", { bundleId }, APP_OP_TIMEOUT_MS);
3698
+ const terminated = await appOutcome(viaHelper, () => simctl.terminate(bundleId), (data) => noteSpanDetail("terminated", data.terminated));
3648
3699
  if (terminated.success && launchedBundle === bundleId)
3649
3700
  launchedBundle = undefined;
3650
3701
  return terminated;
3651
3702
  },
3652
- openUrl: (url) => simctl.openUrl(url),
3703
+ openUrl: async (url) => {
3704
+ const viaHelper = await appOp("openUrl", { url }, APP_OP_TIMEOUT_MS);
3705
+ return appOutcome(viaHelper, () => simctl.openUrl(url), () => {
3706
+ return;
3707
+ });
3708
+ },
3653
3709
  install: (path) => simctl.install(path),
3654
3710
  uninstall: (bundleId) => simctl.uninstall(bundleId),
3655
3711
  isInstalled: (bundleId) => simctl.isInstalled(bundleId),
@@ -4761,6 +4817,10 @@ ${read.error}
4761
4817
  }
4762
4818
  });
4763
4819
  }
4820
+ function scrubRunnerEnv(env) {
4821
+ delete env.BUN_BE_BUN;
4822
+ }
4823
+ scrubRunnerEnv(process.env);
4764
4824
  if (!hasDefaultDriverFactory()) {
4765
4825
  setDefaultDriverFactory(defaultDriverFactory);
4766
4826
  setDefaultNativeLog(realNativeLogDeps(bunExec, androidTools(process.env).adb));
@@ -4784,5 +4844,6 @@ export {
4784
4844
  BUN_TIMEOUT_MARGIN_MS2 as BUN_TIMEOUT_MARGIN_MS,
4785
4845
  installPreload,
4786
4846
  isCi,
4847
+ scrubRunnerEnv,
4787
4848
  versionMismatch
4788
4849
  };
@@ -9,6 +9,8 @@ export type ExecOptions = {
9
9
  timeoutMs?: number;
10
10
  /** aborting kills the command (SIGTERM), like a timeout */
11
11
  signal?: AbortSignal;
12
+ /** each line of stdout and stderr as it arrives (no trailing newline); the result still carries the whole output */
13
+ onLine?: (line: string) => void;
12
14
  };
13
15
  /** Injectable process runner — providers take this so tests can fake `simctl`/`adb`. */
14
16
  export type Exec = (cmd: readonly string[], opts?: ExecOptions) => Promise<ExecResult>;
@@ -31,6 +31,13 @@ export interface DeviceProvider {
31
31
  bootReady?(id: string, timeoutMs: number): Promise<BootReady>;
32
32
  /** Probe for a device `bootReady` just booted and saw ready. Absent = `probe`. */
33
33
  probeBooted?(id: string): AsyncResult<void>;
34
+ /**
35
+ * Best effort, called for a device this claim is about to boot from shutdown (after a create, or a
36
+ * shutdown reuse), never for an already-booted one. A failure is logged by the provider, never fatal.
37
+ */
38
+ prepareBoot?(id: string): Promise<void>;
39
+ /** Called once the device is ready, before the health probe. Best effort. */
40
+ afterReady?(id: string): Promise<void>;
34
41
  }
35
42
  /** `bootReady`: a boot failure is an error; a device that booted but never got ready is unhealthy. */
36
43
  export type BootReady = {
@@ -6,6 +6,11 @@ export type CdpOptions = {
6
6
  /** connect timeout and the default per-request timeout */
7
7
  timeoutMs?: number;
8
8
  };
9
+ /** How a socket closed: the WebSocket close code and reason (Metro's proxy puts a `[TAG]` in the reason). */
10
+ export type CloseInfo = {
11
+ code: number;
12
+ reason: string;
13
+ };
9
14
  export type CdpClient = {
10
15
  /** `Runtime.evaluate`-style JSON-RPC call; resolves with the reply's `result`. */
11
16
  send: (method: string, params?: Record<string, unknown>, options?: {
@@ -13,8 +18,8 @@ export type CdpClient = {
13
18
  }) => Promise<DevtoolsResult<unknown>>;
14
19
  /** Subscribe to a CDP event by method name; returns the unsubscribe function. */
15
20
  on: (event: string, handler: (params: unknown) => void) => () => void;
16
- /** Called when the socket closes on its own (not after `close()`). */
17
- onClose: (handler: () => void) => () => void;
21
+ /** Called when the socket closes on its own (not after `close()`), with the close code and reason Metro sent. */
22
+ onClose: (handler: (info: CloseInfo) => void) => () => void;
18
23
  /** Every event of every method, in arrival order (a wildcard `on`). */
19
24
  onAny: (handler: (method: string, params: unknown) => void) => () => void;
20
25
  /**
@@ -54,6 +54,15 @@ export type CdpTapEvent = {
54
54
  };
55
55
  export type CdpTap = (event: CdpTapEvent) => void;
56
56
  export type ConsoleLevel = "log" | "info" | "warn" | "error" | "debug";
57
+ /** One stack frame; `line`/`col` are 1-based. Raw frames carry the bundle URL, symbolicated ones the source file. */
58
+ export type StackFrame = {
59
+ file: string;
60
+ line: number;
61
+ col: number;
62
+ fn?: string;
63
+ /** library / runtime internals Metro marks collapsible */
64
+ collapsed?: boolean;
65
+ };
57
66
  /** One captured console message (`console`), uncaught exception (`exception`) or `mark()` (`note`). */
58
67
  export type ConsoleLine = {
59
68
  /** epoch ms (the runtime's own timestamp when it sent one) */
@@ -67,6 +76,10 @@ export type ConsoleLine = {
67
76
  line: number;
68
77
  col: number;
69
78
  };
79
+ /** the runtime's stack when it sent one; the broker rewrites it to original files (symbolication) */
80
+ stack?: StackFrame[];
81
+ /** Metro's code frame around the first app frame (symbolication) */
82
+ codeFrame?: string;
70
83
  /** each console argument rendered on its own (console messages only) */
71
84
  args?: string[];
72
85
  };
@@ -1,4 +1,5 @@
1
1
  import { type Result } from "../../../types/src/result";
2
+ import { type CloseInfo } from "./cdp";
2
3
  import { type CdpTap, type DevtoolsError, type DevtoolsResult, type EvaluateError, type Fetch, type Sleep } from "./devtools.types";
3
4
  import { type SelectedTarget } from "./inspector";
4
5
  /** The one binding warden installs; scripts call `__warden_cb(JSON.stringify({ requestId, … }))`. */
@@ -35,6 +36,11 @@ export type ConnectDevtoolsOptions = {
35
36
  taps?: readonly CdpTap[];
36
37
  /** how long `reloadJs` waits for the app to start tearing its JS context down (default 5 s) */
37
38
  reloadWaitMs?: number;
39
+ /**
40
+ * Asked when the socket closes on its own. `true` = another debugger took the page: do not reconnect (that would
41
+ * kick it in turn), close the session and emit `yielded`. Default: always reconnect.
42
+ */
43
+ yieldOnClose?: (info: CloseInfo) => boolean;
38
44
  };
39
45
  export type DevtoolsSession = {
40
46
  /** `Runtime.evaluate` by value. A thrown JS error fails with `exceptionDetails`. Waits out a reconnect. */
@@ -64,7 +70,7 @@ export type DevtoolsSession = {
64
70
  /** Tap on every CDP event, handshake events flagged `replay`; survives reconnects. */
65
71
  onEvent: (tap: CdpTap) => () => void;
66
72
  evaluateWithBinding: (script: string, requestId: string, timeoutMs?: number) => Promise<DevtoolsResult<unknown>>;
67
- /** `reconnected` (new target attached) and `lost` (gave up reconnecting) */
73
+ /** `reconnected` (new target attached), `lost` (gave up reconnecting), `yielded` (`yieldOnClose` said stop) */
68
74
  on: <E extends keyof SessionEvents>(event: E, handler: (arg: SessionEvents[E]) => void) => () => void;
69
75
  close: () => void;
70
76
  };
@@ -72,6 +78,7 @@ export type ReloadMethod = "expo" | "devsettings" | "http";
72
78
  export type SessionEvents = {
73
79
  reconnected: SelectedTarget;
74
80
  lost: DevtoolsError;
81
+ yielded: CloseInfo;
75
82
  };
76
83
  /**
77
84
  * Attach to the app's Hermes runtime through Metro's inspector proxy. Connect sequence (same as Chrome's Fusebox
@@ -2,20 +2,21 @@ import type { Exec } from "../../../../core/src/exec";
2
2
  import type { E2eEnv } from "../../env";
3
3
  import type { ProbesFrontmost, ReadsAlertOn } from "../alerts";
4
4
  import type { Driver, DriverResult, KeyName, Logger } from "../driver.types";
5
- import type { SelectsAll } from "../typing";
5
+ import type { PastesText, SelectsAll } from "../typing";
6
6
  import { type SwappableClient } from "./ios-session";
7
7
  import { type Simctl } from "./simctl";
8
8
  import { type SimdClient } from "./simd-client";
9
9
  import type { SimdSpawn } from "./simd-transport";
10
10
  /**
11
- * The iOS `Driver`: input and AX through `warden-simd` (socket when `WARDEN_SIMD_SOCKET` is set, else a
12
- * helper spawned on stdio), app lifecycle / clipboard / appearance through `xcrun simctl`. Fallbacks:
13
- * `typeText` pastes what the HID keyboard map cannot type, `settle` polls snapshot hashes when the
14
- * helper has no native quiescence, `screenshot` uses `simctl io` when the helper cannot.
11
+ * The iOS `Driver`: input, AX and app launch / terminate / openUrl through `warden-simd` (socket when
12
+ * `WARDEN_SIMD_SOCKET` is set, else a helper spawned on stdio); install / uninstall / isRunning / clearData,
13
+ * clipboard and appearance through `xcrun simctl`. Fallbacks: `typeText` pastes what the HID keyboard map
14
+ * cannot type, `settle` polls snapshot hashes when the helper has no native quiescence, `screenshot` uses
15
+ * `simctl io` when the helper cannot, and a helper built before the app ops leaves app lifecycle to `simctl`.
15
16
  */
16
17
  export type IosDriverEnv = Pick<E2eEnv, "simd" | "simdSocket" | "helperDir">;
17
18
  /** The iOS driver is a `Driver` plus the optional capabilities of `driver/alerts.ts`. */
18
- export type IosDriver = Driver & SelectsAll & ReadsAlertOn & ProbesFrontmost;
19
+ export type IosDriver = Driver & SelectsAll & PastesText & ReadsAlertOn & ProbesFrontmost;
19
20
  export type OpenIosDriverOptions = {
20
21
  udid: string;
21
22
  env?: IosDriverEnv;
@@ -275,6 +275,21 @@ export declare const frontmostDataSchema: z.ZodObject<{
275
275
  frontmostPid: z.ZodOptional<z.ZodNumber>;
276
276
  }, z.core.$loose>;
277
277
  export type FrontmostData = z.infer<typeof frontmostDataSchema>;
278
+ /**
279
+ * `launch{bundleId, args?, env?, terminateRunning?}`: the helper starts the app in the process that already holds
280
+ * CoreSimulator (no `simctl` process per launch). `args` follow the executable, `env` reaches the app as given, and
281
+ * `terminateRunning` (default true) ends a running instance first; false brings a running app to the front instead.
282
+ * `pid` is the process that is running when the op answers.
283
+ */
284
+ export declare const launchDataSchema: z.ZodObject<{
285
+ pid: z.ZodNumber;
286
+ }, z.core.$loose>;
287
+ export type LaunchData = z.infer<typeof launchDataSchema>;
288
+ /** `terminate{bundleId}`: `terminated: false` says nothing was running, which is the wanted state, not an error. */
289
+ export declare const terminateDataSchema: z.ZodObject<{
290
+ terminated: z.ZodBoolean;
291
+ }, z.core.$loose>;
292
+ export type TerminateData = z.infer<typeof terminateDataSchema>;
278
293
  /**
279
294
  * `orientation`: a real read may also say `faceUp` / `faceDown` / `unknown` (flat device); `source` tells
280
295
  * whether the value was read from the device, is the last one this helper wrote, or is an assumed portrait.
@@ -401,6 +416,12 @@ export declare const opSchemas: {
401
416
  assumed: "assumed";
402
417
  }>>;
403
418
  }, z.core.$strip>;
419
+ readonly launch: z.ZodObject<{
420
+ pid: z.ZodNumber;
421
+ }, z.core.$loose>;
422
+ readonly terminate: z.ZodObject<{
423
+ terminated: z.ZodBoolean;
424
+ }, z.core.$loose>;
404
425
  };
405
426
  export type OpDataMap = {
406
427
  health: HealthData;
@@ -412,6 +433,8 @@ export type OpDataMap = {
412
433
  alertButtons: AlertButtonsData;
413
434
  frontmost: FrontmostData;
414
435
  orientation: OrientationData;
436
+ launch: LaunchData;
437
+ terminate: TerminateData;
415
438
  };
416
439
  export type OpData<Op extends string> = Op extends keyof OpDataMap ? OpDataMap[Op] : unknown;
417
440
  export declare function hasOpSchema(op: string): op is keyof typeof opSchemas;
@@ -1,6 +1,10 @@
1
- /** Per-request budget for the helper's `typeText`: it holds, paces and (for a repeated key) waits per character. */
1
+ /**
2
+ * Per-request budget for the helper's `typeText`: it holds, paces and (for a repeated key) waits per character.
3
+ * The helper's defaults (pace 5, hold 20, repeat gap 50) cost ~38 ms per character on a quiet local simulator; the
4
+ * budget allows ~100 ms (the EAS VirtualMac ran ~3.7x the nominal sleeps) and 200 ms per repeated key.
5
+ */
2
6
  export declare const TYPE_BASE_MS = 10000;
3
- export declare const TYPE_PER_CHAR_MS = 70;
4
- export declare const TYPE_PER_REPEAT_MS = 300;
5
- /** Base + ~70 ms per character + 300 ms per character equal to the one before it. */
7
+ export declare const TYPE_PER_CHAR_MS = 100;
8
+ export declare const TYPE_PER_REPEAT_MS = 200;
9
+ /** Base + ~100 ms per character + 200 ms per character equal to the one before it. */
6
10
  export declare function typeTextBudgetMs(text: string): number;
@@ -8,7 +8,9 @@ import type { AlertInfo, Driver, DriverResult, Platform, Snapshot } from "./driv
8
8
  * the one driver: whatever calls it, an input moves the *generation* on and the kept snapshot dies with the old one.
9
9
  * A snapshot is also dropped once it is older than the platform's max age, input or not (the app can change by itself).
10
10
  * Which reads may reuse one is the caller's call (`observe({ reuse })`): only the first look of a wait does, so a
11
- * poll never spins on a tree that stopped being true.
11
+ * poll never spins on a tree that stopped being true. A `settle` is a wait, not an input: the kept snapshot predates
12
+ * it and is dropped, but nothing was pressed, so `previous().sinceInputMs` still counts from the last real input; one
13
+ * that reports `quiet` is the app's word that the input has landed, and then no input is recent at all.
12
14
  */
13
15
  /** How old a kept snapshot may be and still answer a read. iOS reads cost ~100-300 ms; an Android dump takes ~1.9 s. */
14
16
  export declare const OBSERVATION_MAX_AGE_MS: Record<Platform, number>;
@@ -42,6 +42,16 @@ export type SetsText = {
42
42
  setText(text: string): DriverResult<void>;
43
43
  };
44
44
  export declare function canSetText(driver: Driver): driver is Driver & SetsText;
45
+ /**
46
+ * Optional fast path next to the frozen `Driver`: put `text` on the device pasteboard and paste it into the focused
47
+ * field with one Cmd+V (iOS `simctl.pbcopy` then `warden-simd` `paste`), replacing the selection. Cost is flat (~400 ms)
48
+ * where typing is 70-260 ms per character. A pasteboard write that cannot be confirmed is an error (nothing was
49
+ * pasted) and the caller types the old way.
50
+ */
51
+ export type PastesText = {
52
+ pasteText(text: string): DriverResult<void>;
53
+ };
54
+ export declare function canPasteText(driver: Driver): driver is Driver & PastesText;
45
55
  /** Types `text` at the caret. Android types the whole string in one call. */
46
56
  export declare function typeText(driver: Driver, sleep: Sleep, text: string): DriverResult<void>;
47
57
  /** Presses `key` `times` times, paced like typed characters. */
@@ -27,3 +27,8 @@ export declare function isCi(env: Readonly<Record<string, string | undefined>>):
27
27
  * session eagerly and, in `afterAll`, closes it and (under `CI`) fails the run if a `.only` was collected.
28
28
  */
29
29
  export declare function installPreload(deps: PreloadDeps): void;
30
+ /**
31
+ * `warden e2e` may run this process as a compiled warden with `BUN_BE_BUN=1`; drop it so a `warden` the spec spawns
32
+ * (`WARDEN_BIN`, `device.perf`) runs as warden, not bun.
33
+ */
34
+ export declare function scrubRunnerEnv(env: Record<string, string | undefined>): void;
@@ -101,6 +101,12 @@ export type ClearMethod = "auto" | "counted" | "setText";
101
101
  export declare const SETTEXT_ENV = "WARDEN_ANDROID_SETTEXT";
102
102
  /** Whether `env` opts `auto` fills into `setText` (`SETTEXT_ENV`). */
103
103
  export declare function setTextOptedIn(env: Readonly<Record<string, string | undefined>>): boolean;
104
+ /**
105
+ * How `focusClearType` puts text in. `paste` writes it to the pasteboard and sends one Cmd+V on a driver that offers
106
+ * `pasteText` (iOS: ~400 ms flat against 70-260 ms per typed character), and types when it does not or when the paste
107
+ * fails; `type` always types.
108
+ */
109
+ export type FillVia = "paste" | "type";
104
110
  /**
105
111
  * Taps `point` and empties what `entry` shows: select-all and one Backspace when the driver offers it, else one
106
112
  * Backspace per character (see `focusAndSelect`). The clear routine behind the spec `clear` and `warden ui clear`.
@@ -117,5 +123,9 @@ export declare function focusAndClear(driver: Driver, sleep: Sleep, point: Point
117
123
  * select-all then typing; the read-back after a `fill` checks it took, and the counted retry types. An empty field gains
118
124
  * nothing from it, and a secure one cannot be read back, so both are typed as before. A `setText` that fails falls back
119
125
  * to select-all and typing, without a second tap.
126
+ *
127
+ * With `via: "paste"` (the default) on a driver with `pasteText` (iOS), the text replaces the selection with one paste,
128
+ * secure fields included (they keep their Backspace-per-dot clearing). A paste that fails (pasteboard write not
129
+ * confirmed) is typed in the same attempt; nothing was pasted, so the selection is still there to be replaced.
120
130
  */
121
- export declare function focusClearType(driver: Driver, sleep: Sleep, point: Point, entry: NodeEntry | undefined, text: string, method?: ClearMethod, env?: Readonly<Record<string, string | undefined>>): DriverResult<void>;
131
+ export declare function focusClearType(driver: Driver, sleep: Sleep, point: Point, entry: NodeEntry | undefined, text: string, method?: ClearMethod, env?: Readonly<Record<string, string | undefined>>, via?: FillVia): DriverResult<void>;