touchpress 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { $ as Tree, A as deviceNameForSlot, B as ProbeResult, C as openSession, D as ResolvedOptions, E as ReadyQuery, F as StepOptions, G as CaptureOptions, H as formatFailure, I as Typed, J as DeviceInfo, K as DeviceDriver, L as renderTitle, M as ActionRecord, N as ActionSink, O as TOUCHPRESS_DEFAULTS, P as EvidenceFile, Q as Settled, R as silentSink, S as SessionState, T as DeviceChoice, U as probe, V as ProbeTarget, W as Binding, X as OpenRequest, Y as DeviceSelection, Z as ScrollDirection, _ as RoleOptions, _t as Screen, a as ExpectedValue, at as Filter, b as DeviceSession, bt as renderScreen, c as ScrollSearch, ct as TextMatch, d as directionToward, dt as textMatch, et as Check, f as ActionOptions, ft as PinnedRef, g as Locator, gt as Resolution, h as FilterOptions, ht as Rect, i as ErrorInfo, it as evaluate, j as parseDeviceOptions, k as TouchpressOptions, l as ScrollTrail, lt as describeQuery, m as FillOptions, mt as RawSnapshot, n as PreflightReport, nt as Verdict, o as TouchpressError, ot as Query, p as Device, pt as Platform, q as DeviceFailure, r as preflight, rt as describeCheck, s as ScrollDevice, st as Role, t as PreflightDevice, tt as CheckName, u as createScrollSearch, ut as normalizeText, v as TextOptions, vt as ScreenNode, w as sessionName, x as OpenSessionInput, xt as resolve, y as createDevice, yt as parseScreen, z as ProbeOptions } from "../preflight-Czhj9QFM.mjs";
1
+ import { $ as DeviceSelection, A as ResolvedOptions, B as renderTitle, C as DeviceSession, Ct as parseScreen, D as sessionName, E as openSession, F as ActionRecord, G as formatFailure, H as ProbeOptions, I as ActionSink, J as Binding, K as probe, L as EvidenceFile, M as TouchpressOptions, N as deviceNameForSlot, O as DeviceChoice, P as parseDeviceOptions, Q as DeviceInfo, R as StepOptions, S as createDevice, St as ScreenNode, T as SessionState, Tt as resolve, U as ProbeResult, V as silentSink, W as ProbeTarget, X as DeviceDriver, Y as CaptureOptions, Z as DeviceFailure, _ as Keyboard, _t as Platform, a as ExpectedValue, at as CheckName, b as TextOptions, bt as Resolution, c as ScrollSearch, ct as evaluate, d as directionToward, dt as Role, et as OpenRequest, f as ActionOptions, ft as TextMatch, g as FilterOptions, gt as PinnedRef, h as FillOptions, ht as textMatch, i as ErrorInfo, it as Check, j as TOUCHPRESS_DEFAULTS, k as ReadyQuery, l as ScrollTrail, lt as Filter, m as Device, mt as normalizeText, n as PreflightReport, nt as Settled, o as TouchpressError, ot as Verdict, p as BackOptions, pt as describeQuery, q as BackMode, r as preflight, rt as Tree, s as ScrollDevice, st as describeCheck, t as PreflightDevice, tt as ScrollDirection, u as createScrollSearch, ut as Query, v as Locator, vt as RawSnapshot, w as OpenSessionInput, wt as renderScreen, x as TypeOptions, xt as Screen, y as RoleOptions, yt as Rect, z as Typed } from "../preflight-CqFFdaAv.mjs";
2
2
  //#region src/core/evidence.d.ts
3
3
  /**
4
4
  * Never throws. A capture that fails records a note and returns, because masking
@@ -70,4 +70,4 @@ declare function toPixelBox(rect: {
70
70
  /** Moves a box into the coordinates of a crop taken at `origin`. */
71
71
  declare function relativeTo(box: PixelBox, origin: PixelBox): PixelBox;
72
72
  //#endregion
73
- export { type ActionOptions, type ActionRecord, type ActionSink, type Binding, type CaptureOptions, type Check, type CheckName, type CompareOptions, type Comparison, type Device, type DeviceChoice, type DeviceDriver, type DeviceFailure, type DeviceInfo, type DeviceSelection, type DeviceSession, type ErrorInfo, type EvidenceFile, type ExpectedValue, type FillOptions, type Filter, type FilterOptions, type Locator, type OpenRequest, type OpenSessionInput, type PinnedRef, type PixelBox, type Platform, type PreflightDevice, type PreflightReport, type ProbeOptions, type ProbeResult, type ProbeTarget, type Query, type RawSnapshot, type ReadyQuery, type Rect, type Resolution, type ResolvedOptions, type Role, type RoleOptions, type Screen, type ScreenNode, type ScrollDevice, type ScrollDirection, type ScrollSearch, type ScrollTrail, type SessionState, type Settled, type Size, type StepOptions, TOUCHPRESS_DEFAULTS, type TextMatch, type TextOptions, TouchpressError, type TouchpressOptions, type Tree, type Typed, type Verdict, captureEvidence, compareScreenshot, createDevice, createScrollSearch, cropScreenshot, describeCheck, describeQuery, deviceNameForSlot, directionToward, evaluate, formatFailure, normalizeText, openSession, parseDeviceOptions, parseScreen, preflight, probe, relativeTo, renderScreen, renderTitle, resolve, sessionName, silentSink, sizeOf, textMatch, toPixelBox };
73
+ export { type ActionOptions, type ActionRecord, type ActionSink, type BackMode, type BackOptions, type Binding, type CaptureOptions, type Check, type CheckName, type CompareOptions, type Comparison, type Device, type DeviceChoice, type DeviceDriver, type DeviceFailure, type DeviceInfo, type DeviceSelection, type DeviceSession, type ErrorInfo, type EvidenceFile, type ExpectedValue, type FillOptions, type Filter, type FilterOptions, type Keyboard, type Locator, type OpenRequest, type OpenSessionInput, type PinnedRef, type PixelBox, type Platform, type PreflightDevice, type PreflightReport, type ProbeOptions, type ProbeResult, type ProbeTarget, type Query, type RawSnapshot, type ReadyQuery, type Rect, type Resolution, type ResolvedOptions, type Role, type RoleOptions, type Screen, type ScreenNode, type ScrollDevice, type ScrollDirection, type ScrollSearch, type ScrollTrail, type SessionState, type Settled, type Size, type StepOptions, TOUCHPRESS_DEFAULTS, type TextMatch, type TextOptions, TouchpressError, type TouchpressOptions, type Tree, type TypeOptions, type Typed, type Verdict, captureEvidence, compareScreenshot, createDevice, createScrollSearch, cropScreenshot, describeCheck, describeQuery, deviceNameForSlot, directionToward, evaluate, formatFailure, normalizeText, openSession, parseDeviceOptions, parseScreen, preflight, probe, relativeTo, renderScreen, renderTitle, resolve, sessionName, silentSink, sizeOf, textMatch, toPixelBox };
@@ -1,2 +1,2 @@
1
- import { A as normalizeText, C as parseScreen, D as deviceNameForSlot, E as TOUCHPRESS_DEFAULTS, M as TouchpressError, O as parseDeviceOptions, S as silentSink, T as resolve, a as sizeOf, d as createScrollSearch, f as directionToward, g as evaluate, h as describeCheck, i as relativeTo, j as textMatch, k as describeQuery, l as captureEvidence, m as probe, n as compareScreenshot, o as toPixelBox, p as formatFailure, r as cropScreenshot, t as preflight, u as createDevice, v as openSession, w as renderScreen, x as renderTitle, y as sessionName } from "../preflight-CWeJYtS7.mjs";
1
+ import { A as normalizeText, C as parseScreen, D as deviceNameForSlot, E as TOUCHPRESS_DEFAULTS, M as TouchpressError, O as parseDeviceOptions, S as silentSink, T as resolve, a as sizeOf, d as createScrollSearch, f as directionToward, g as evaluate, h as describeCheck, i as relativeTo, j as textMatch, k as describeQuery, l as captureEvidence, m as probe, n as compareScreenshot, o as toPixelBox, p as formatFailure, r as cropScreenshot, t as preflight, u as createDevice, v as openSession, w as renderScreen, x as renderTitle, y as sessionName } from "../preflight-sFz236o3.mjs";
2
2
  export { TOUCHPRESS_DEFAULTS, TouchpressError, captureEvidence, compareScreenshot, createDevice, createScrollSearch, cropScreenshot, describeCheck, describeQuery, deviceNameForSlot, directionToward, evaluate, formatFailure, normalizeText, openSession, parseDeviceOptions, parseScreen, preflight, probe, relativeTo, renderScreen, renderTitle, resolve, sessionName, silentSink, sizeOf, textMatch, toPixelBox };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { E as ReadyQuery, _t as Screen, a as ExpectedValue, at as Filter, b as DeviceSession, ct as TextMatch, g as Locator, h as FilterOptions, ht as Rect, i as ErrorInfo, k as TouchpressOptions$1, n as PreflightReport, o as TouchpressError, ot as Query, p as Device$1, pt as Platform, r as preflight, st as Role, t as PreflightDevice, vt as ScreenNode } from "./preflight-Czhj9QFM.mjs";
1
+ import { C as DeviceSession, M as TouchpressOptions$1, St as ScreenNode, _ as Keyboard, _t as Platform, a as ExpectedValue, dt as Role, ft as TextMatch, g as FilterOptions, i as ErrorInfo, k as ReadyQuery, lt as Filter, m as Device$1, n as PreflightReport, o as TouchpressError, p as BackOptions, r as preflight, t as PreflightDevice, ut as Query, v as Locator, x as TypeOptions, xt as Screen, yt as Rect } from "./preflight-CqFFdaAv.mjs";
2
2
  import { ExpectMatcherState } from "@playwright/test";
3
3
  //#region src/ai/options.d.ts
4
4
  /**
@@ -143,4 +143,4 @@ declare const expect: import("@playwright/test").Expect<{
143
143
  type TouchpressOptions = TouchpressOptions$1 & AiOptions;
144
144
  type Device = Device$1 & AiDevice;
145
145
  //#endregion
146
- export { type ActOptions, type AiDevice, type AiModel, type AiOptions, Device, type ErrorInfo, type ExpectedValue, type ExtractOptions, type ExtractSchema, type Filter, type FilterOptions, type Locator, type Platform, type PreflightDevice, type PreflightReport, type Query, type ReadyQuery, type Rect, type Role, type Screen, type ScreenNode, type ScreenshotOptions, type TextMatch, TouchpressError, TouchpressOptions, expect, preflight, setupTest, test };
146
+ export { type ActOptions, type AiDevice, type AiModel, type AiOptions, type BackOptions, Device, type ErrorInfo, type ExpectedValue, type ExtractOptions, type ExtractSchema, type Filter, type FilterOptions, type Keyboard, type Locator, type Platform, type PreflightDevice, type PreflightReport, type Query, type ReadyQuery, type Rect, type Role, type Screen, type ScreenNode, type ScreenshotOptions, type TextMatch, TouchpressError, TouchpressOptions, type TypeOptions, expect, preflight, setupTest, test };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { C as parseScreen, E as TOUCHPRESS_DEFAULTS, M as TouchpressError, O as parseDeviceOptions, S as silentSink, T as resolve, _ as createQueue, a as sizeOf, b as sleep, c as createClient, i as relativeTo, j as textMatch, l as captureEvidence, n as compareScreenshot, o as toPixelBox, r as cropScreenshot, s as createAgentDeviceDriver, t as preflight, u as createDevice, v as openSession, w as renderScreen, x as renderTitle } from "./preflight-CWeJYtS7.mjs";
1
+ import { C as parseScreen, E as TOUCHPRESS_DEFAULTS, M as TouchpressError, O as parseDeviceOptions, S as silentSink, T as resolve, _ as createQueue, a as sizeOf, b as sleep, c as createClient, i as relativeTo, j as textMatch, l as captureEvidence, n as compareScreenshot, o as toPixelBox, r as cropScreenshot, s as createAgentDeviceDriver, t as preflight, u as createDevice, v as openSession, w as renderScreen, x as renderTitle } from "./preflight-sFz236o3.mjs";
2
2
  import { expect as expect$1, test as test$1 } from "@playwright/test";
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { dirname } from "node:path";
@@ -233,6 +233,11 @@ declare function describeCheck(check: Check): string;
233
233
  //#endregion
234
234
  //#region src/core/driver.d.ts
235
235
  type ScrollDirection = 'up' | 'down' | 'left' | 'right';
236
+ /**
237
+ * `in-app` is the navigation control the app draws. `system` is the platform
238
+ * gesture or key, which iOS does not have.
239
+ */
240
+ type BackMode = 'in-app' | 'system';
236
241
  /**
237
242
  * A session binds on its first command, so this rides on `open` and on every
238
243
  * command after it. A call sent without selection lands on whichever device the
@@ -262,6 +267,8 @@ type Binding = {
262
267
  readonly deviceLabel: string;
263
268
  readonly appId: string;
264
269
  readonly stateDir: string | null;
270
+ /** What lets a driver reach the device with a tool of its own. Null when the open response named none. */
271
+ readonly udid: string | null;
265
272
  };
266
273
  type Settled = {
267
274
  readonly settled: boolean;
@@ -291,6 +298,14 @@ type DeviceFailure = {
291
298
  } | {
292
299
  readonly kind: 'ambiguous';
293
300
  readonly detail: string;
301
+ } |
302
+ /**
303
+ * The pinned node owns no touch point outside the interactive nodes drawn
304
+ * over it, so the driver refused rather than guess which one was meant.
305
+ */
306
+ {
307
+ readonly kind: 'covered';
308
+ readonly detail: string;
294
309
  } | {
295
310
  readonly kind: 'timeout';
296
311
  readonly detail: string;
@@ -334,6 +349,24 @@ type DeviceDriver = {
334
349
  tap(ref: PinnedRef, options: SettleOptions): Promise<Settled>;
335
350
  longPress(ref: PinnedRef, durationMs: number, options: SettleOptions): Promise<Settled>;
336
351
  fill(ref: PinnedRef, text: string, options: SettleOptions): Promise<Settled>;
352
+ /** Takes no target, because the platform decides what goes back. */
353
+ back(mode: BackMode, options: SettleOptions): Promise<Settled>;
354
+ /**
355
+ * Types into whatever holds focus. The one mutation with no `PinnedRef`,
356
+ * because a field the app focused for itself may carry nothing to select on.
357
+ */
358
+ type(text: string, options: SettleOptions): Promise<Settled>;
359
+ /**
360
+ * Discards the app's stored state. Whether that reaches every store the app
361
+ * writes to is the driver's problem, not the core's.
362
+ */
363
+ clearAppState(app: string): Promise<void>;
364
+ /**
365
+ * Resets the device's keychain, which on an iOS simulator is shared by every
366
+ * app on it. Kept apart from `clearAppState` for that reason. A no-op on a
367
+ * platform whose app data clear already covers the app's secure store.
368
+ */
369
+ resetKeychain(): Promise<void>;
337
370
  /** Returns nothing because the driver's scroll response carries no settle observation. */
338
371
  scroll(direction: ScrollDirection, options: SettleOptions): Promise<void>;
339
372
  dismissDevOverlay(): Promise<void>;
@@ -434,6 +467,11 @@ type ActionRecord = {
434
467
  } | {
435
468
  readonly kind: 'typed';
436
469
  readonly typed: Typed;
470
+ } |
471
+ /** Where an action landed after the driver refused the node the locator matched. */
472
+ {
473
+ readonly kind: 'retarget';
474
+ readonly target: string;
437
475
  } | {
438
476
  readonly kind: 'scroll';
439
477
  readonly direction: ScrollDirection;
@@ -443,6 +481,14 @@ type ActionRecord = {
443
481
  } | {
444
482
  readonly kind: 'relaunch';
445
483
  readonly app: string;
484
+ } | {
485
+ readonly kind: 'back';
486
+ readonly mode: BackMode;
487
+ } | {
488
+ readonly kind: 'clear-state';
489
+ readonly app: string;
490
+ } | {
491
+ readonly kind: 'clear-keychain';
446
492
  } | {
447
493
  readonly kind: 'dismiss-overlay';
448
494
  } | {
@@ -622,6 +668,10 @@ type SessionDevice = {
622
668
  tap(ref: PinnedRef, budgetMs: number): Promise<Settled>;
623
669
  longPress(ref: PinnedRef, durationMs: number, budgetMs: number): Promise<Settled>;
624
670
  fill(ref: PinnedRef, text: string, budgetMs: number): Promise<Settled>;
671
+ back(mode: BackMode, budgetMs: number): Promise<Settled>;
672
+ type(text: string, budgetMs: number): Promise<Settled>;
673
+ clearAppState(app: string): Promise<void>;
674
+ resetKeychain(): Promise<void>;
625
675
  scroll(direction: ScrollDirection, budgetMs: number): Promise<void>;
626
676
  };
627
677
  type DeviceSession = {
@@ -636,6 +686,18 @@ type DeviceSession = {
636
686
  screenshot(path: string): Promise<string>;
637
687
  /** Relaunches the app and re-runs the ready gate. Reported as one step. */
638
688
  relaunch(sink: ActionSink): Promise<void>;
689
+ /**
690
+ * Discards the app's stored state, then relaunches and re-runs the ready
691
+ * gate, because an app holding a cleared session in memory has not been
692
+ * cleared. Reported as one step with the relaunch nested under it.
693
+ */
694
+ clearState(sink: ActionSink): Promise<void>;
695
+ /**
696
+ * Resets the simulator's keychain, which every app on it shares. Separate
697
+ * from `clearState` so an app-scoped clear never wipes another app's store.
698
+ * Reported as one step, with a note on Android where there is nothing to reset.
699
+ */
700
+ clearKeychain(sink: ActionSink): Promise<void>;
639
701
  dismissDevOverlay(): Promise<void>;
640
702
  awaitReady(deadline: number): Promise<void>;
641
703
  /** Idempotent. Never shuts the simulator down, and reaches `closed` even when the driver call fails. */
@@ -691,6 +753,25 @@ type FilterOptions = {
691
753
  type FillOptions = ActionOptions & {
692
754
  secret?: boolean;
693
755
  };
756
+ type BackOptions = ActionOptions & {
757
+ mode?: BackMode;
758
+ };
759
+ /** `secret` reports a character count instead of the text, the way `fill`'s does. */
760
+ type TypeOptions = ActionOptions & {
761
+ secret?: boolean;
762
+ };
763
+ /**
764
+ * Playwright's `page.keyboard`, cut down to what a device keyboard can do with
765
+ * no target. Everything else touchpress offers needs a locator.
766
+ */
767
+ type Keyboard = {
768
+ /**
769
+ * Types into whatever holds focus, for a field with nothing to select on,
770
+ * such as a one-time-code input the app focused for itself. Nothing is read
771
+ * back, because there is no target to read.
772
+ */
773
+ type(text: string, options?: TypeOptions): Promise<void>;
774
+ };
694
775
  type Device = {
695
776
  /** Matches a node's accessibility name or its value. */
696
777
  getByText(text: string | RegExp, options?: TextOptions): Locator;
@@ -699,6 +780,20 @@ type Device = {
699
780
  getByTestId(testId: string): Locator;
700
781
  locator(query: Query): Locator;
701
782
  scroll(direction: ScrollDirection): Promise<void>;
783
+ /**
784
+ * Goes back. Defaults to the platform gesture on Android and to the app's own
785
+ * navigation control on iOS, which has no system back to press.
786
+ */
787
+ goBack(options?: BackOptions): Promise<void>;
788
+ readonly keyboard: Keyboard;
789
+ /** Discards the app's stored state, then relaunches and waits for the ready gate again. */
790
+ clearState(): Promise<void>;
791
+ /**
792
+ * Resets the simulator's keychain, which is shared by every app on it, so
793
+ * it is not part of `clearState`. A no-op on Android, where clearing state
794
+ * already removes the app's keystore entries.
795
+ */
796
+ clearKeychain(): Promise<void>;
702
797
  /** Relaunches the app and waits for the ready gate again. */
703
798
  relaunch(): Promise<void>;
704
799
  /** Never automatic, because hiding the overlay would suppress a warning a test might want to see. */
@@ -833,6 +928,14 @@ type ErrorInfo = {
833
928
  readonly attempts: number;
834
929
  readonly timeoutMs: number;
835
930
  readonly screen: string;
931
+ } | {
932
+ readonly kind: 'untappable';
933
+ readonly locator: string;
934
+ readonly screen: string;
935
+ } |
936
+ /** Carries no field, because the rejected text is the one thing this error may not hold. */
937
+ {
938
+ readonly kind: 'type-rejected';
836
939
  } | {
837
940
  readonly kind: 'driver';
838
941
  readonly command: string;
@@ -882,4 +985,4 @@ type PreflightReport = {
882
985
  */
883
986
  declare function preflight(options: Partial<TouchpressOptions>, driver?: DeviceDriver): Promise<PreflightReport>;
884
987
  //#endregion
885
- export { Tree as $, deviceNameForSlot as A, ProbeResult as B, openSession as C, ResolvedOptions as D, ReadyQuery as E, StepOptions as F, CaptureOptions as G, formatFailure as H, Typed as I, DeviceInfo as J, DeviceDriver as K, renderTitle as L, ActionRecord as M, ActionSink as N, TOUCHPRESS_DEFAULTS as O, EvidenceFile as P, Settled as Q, silentSink as R, SessionState as S, DeviceChoice as T, probe as U, ProbeTarget as V, Binding as W, OpenRequest as X, DeviceSelection as Y, ScrollDirection as Z, RoleOptions as _, Screen as _t, ExpectedValue as a, Filter as at, DeviceSession as b, renderScreen as bt, ScrollSearch as c, TextMatch as ct, directionToward as d, textMatch as dt, Check as et, ActionOptions as f, PinnedRef as ft, Locator as g, Resolution as gt, FilterOptions as h, Rect as ht, ErrorInfo as i, evaluate as it, parseDeviceOptions as j, TouchpressOptions as k, ScrollTrail as l, describeQuery as lt, FillOptions as m, RawSnapshot as mt, PreflightReport as n, Verdict as nt, TouchpressError as o, Query as ot, Device as p, Platform as pt, DeviceFailure as q, preflight as r, describeCheck as rt, ScrollDevice as s, Role as st, PreflightDevice as t, CheckName as tt, createScrollSearch as u, normalizeText as ut, TextOptions as v, ScreenNode as vt, sessionName as w, OpenSessionInput as x, resolve as xt, createDevice as y, parseScreen as yt, ProbeOptions as z };
988
+ export { DeviceSelection as $, ResolvedOptions as A, renderTitle as B, DeviceSession as C, parseScreen as Ct, sessionName as D, openSession as E, ActionRecord as F, formatFailure as G, ProbeOptions as H, ActionSink as I, Binding as J, probe as K, EvidenceFile as L, TouchpressOptions as M, deviceNameForSlot as N, DeviceChoice as O, parseDeviceOptions as P, DeviceInfo as Q, StepOptions as R, createDevice as S, ScreenNode as St, SessionState as T, resolve as Tt, ProbeResult as U, silentSink as V, ProbeTarget as W, DeviceDriver as X, CaptureOptions as Y, DeviceFailure as Z, Keyboard as _, Platform as _t, ExpectedValue as a, CheckName as at, TextOptions as b, Resolution as bt, ScrollSearch as c, evaluate as ct, directionToward as d, Role as dt, OpenRequest as et, ActionOptions as f, TextMatch as ft, FilterOptions as g, PinnedRef as gt, FillOptions as h, textMatch as ht, ErrorInfo as i, Check as it, TOUCHPRESS_DEFAULTS as j, ReadyQuery as k, ScrollTrail as l, Filter as lt, Device as m, normalizeText as mt, PreflightReport as n, Settled as nt, TouchpressError as o, Verdict as ot, BackOptions as p, describeQuery as pt, BackMode as q, preflight as r, Tree as rt, ScrollDevice as s, describeCheck as st, PreflightDevice as t, ScrollDirection as tt, createScrollSearch as u, Query as ut, Locator as v, RawSnapshot as vt, OpenSessionInput as w, renderScreen as wt, TypeOptions as x, Screen as xt, RoleOptions as y, Rect as yt, Typed as z };
@@ -1,3 +1,5 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
1
3
  import { createAgentDeviceClient, normalizeAgentDeviceError } from "agent-device";
2
4
  import pixelmatch from "pixelmatch";
3
5
  import { PNG } from "pngjs";
@@ -58,6 +60,24 @@ function formatError(info) {
58
60
  `Screen:`,
59
61
  info.screen
60
62
  ].join("\n");
63
+ case "untappable": return [
64
+ `The matched node has no touch point of its own, and no enclosing control was found.`,
65
+ ``,
66
+ `Locator: ${info.locator}`,
67
+ ``,
68
+ `Name the control that receives the touch, with getByRole, or with`,
69
+ `locator({ role: 'button', where: (node) => ... }).`,
70
+ ``,
71
+ `Screen:`,
72
+ info.screen
73
+ ].join("\n");
74
+ case "type-rejected": return [
75
+ "The driver cannot type text whose first word looks like a node reference.",
76
+ "It reads a leading @ followed by a name with a digit in it, or by ref, node,",
77
+ "element or el, as a ref to resolve rather than as characters to send.",
78
+ "",
79
+ "Type it through the field instead, with fill on a locator for that field."
80
+ ].join("\n");
61
81
  case "driver": return `${info.command} failed: ${describeFailure(info.failure)}`;
62
82
  case "ai-not-configured": return ["device.act and device.extract need a model.", "Set use.aiModel to a gateway model id, such as 'anthropic/claude-sonnet-5', or to a provider model instance."].join("\n");
63
83
  case "ai-missing-peer": return ["device.act and device.extract need the optional peer dependency 'ai'.", "Install it with: pnpm add -D ai"].join("\n");
@@ -111,6 +131,7 @@ function describeFailure(failure) {
111
131
  case "session-rebound": return `the session is already bound to ${failure.boundTo} (${failure.detail})`;
112
132
  case "stale-ref": return `the screen changed before the action reached it (${failure.detail})`;
113
133
  case "ambiguous": return `the driver matched more than one element (${failure.detail})`;
134
+ case "covered": return `the node owns no touch point of its own (${failure.detail})`;
114
135
  case "timeout": return `the driver timed out (${failure.detail})`;
115
136
  case "unknown": return `${failure.code}: ${failure.detail}${failure.logPath === null ? "" : `\nDiagnostics: ${failure.logPath}`}`;
116
137
  default: throw new Error(`unhandled failure ${JSON.stringify(failure)}`);
@@ -600,6 +621,56 @@ function matchedText(node, query) {
600
621
  function absorbAncestors(matched, query) {
601
622
  return matched.filter((candidate) => !matched.some((other) => other !== candidate && isDescendant(other, candidate) && matchedText(other, query) === matchedText(candidate, query)));
602
623
  }
624
+ /** `tab-bar` is left out because it is a container of buttons rather than a control that takes the touch. */
625
+ const INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
626
+ "button",
627
+ "link",
628
+ "switch",
629
+ "slider",
630
+ "text-field",
631
+ "secure-text-field",
632
+ "cell"
633
+ ]);
634
+ /**
635
+ * The control a tap on `node` would really land on, or null when the screen
636
+ * offers none.
637
+ *
638
+ * A React Native pressable puts the accessible name on one node and the touch
639
+ * handler on another. On Android the two are siblings, so a locator that
640
+ * matched the label pins a ref that owns no touch point of its own. The button
641
+ * drawn under the label is the thing a finger hits, and its rect is what says
642
+ * so.
643
+ *
644
+ * A descendant wins over an enclosing node, because a control inside the
645
+ * matched node is what that node was labelling. Among several, the smallest
646
+ * wins, so a row of buttons inside one card does not hand back the card.
647
+ */
648
+ function touchTargetFor(screen, node) {
649
+ const candidates = [];
650
+ for (const other of screen.nodes) {
651
+ if (other === node || !other.enabled || !INTERACTIVE_ROLES.has(other.role)) continue;
652
+ if (other.rect !== null) candidates.push({
653
+ node: other,
654
+ rect: other.rect
655
+ });
656
+ }
657
+ const inside = smallest(candidates.filter((candidate) => isDescendant(candidate.node, node)));
658
+ if (inside !== null) return inside;
659
+ const own = node.rect;
660
+ if (own === null) return null;
661
+ return smallest(candidates.filter((candidate) => encloses(candidate.rect, own)));
662
+ }
663
+ function encloses(outer, inner) {
664
+ return outer.x <= inner.x && outer.y <= inner.y && outer.x + outer.width >= inner.x + inner.width && outer.y + outer.height >= inner.y + inner.height;
665
+ }
666
+ function smallest(candidates) {
667
+ let best = null;
668
+ for (const candidate of candidates) if (best === null || areaOf(candidate.rect) < areaOf(best.rect)) best = candidate;
669
+ return best === null ? null : best.node;
670
+ }
671
+ function areaOf(rect) {
672
+ return rect.width * rect.height;
673
+ }
603
674
  function isDescendant(node, ancestor) {
604
675
  for (let walk = node.parent; walk !== null; walk = walk.parent) if (walk === ancestor) return true;
605
676
  return false;
@@ -676,9 +747,13 @@ function renderTitle(record) {
676
747
  case "long-press": return `longPress ${describeQuery(record.query)} for ${String(record.durationMs)}ms`;
677
748
  case "fill": return `fill ${describeQuery(record.query)}`;
678
749
  case "typed": return renderTyped(record.typed);
750
+ case "retarget": return `retarget to ${record.target}`;
679
751
  case "scroll": return `scroll ${record.direction}`;
680
752
  case "scroll-into-view": return `scrollIntoView ${describeQuery(record.query)}`;
681
753
  case "relaunch": return `relaunch ${record.app}`;
754
+ case "back": return `back (${record.mode})`;
755
+ case "clear-state": return `clear state of ${record.app}`;
756
+ case "clear-keychain": return "clear keychain";
682
757
  case "dismiss-overlay": return "dismiss the React Native dev overlay";
683
758
  case "screenshot": return `screenshot ${record.path}`;
684
759
  case "act": return `act "${truncate(record.instruction, PROMPT_LIMIT)}"`;
@@ -805,6 +880,10 @@ function createSession(driver, options, name, binding) {
805
880
  tap: (ref, budgetMs) => driver.tap(ref, settle(budgetMs)),
806
881
  longPress: (ref, durationMs, budgetMs) => driver.longPress(ref, durationMs, settle(budgetMs)),
807
882
  fill: (ref, text, budgetMs) => driver.fill(ref, text, settle(budgetMs)),
883
+ back: (mode, budgetMs) => driver.back(mode, settle(budgetMs)),
884
+ type: (text, budgetMs) => driver.type(text, settle(budgetMs)),
885
+ clearAppState: (app) => driver.clearAppState(app),
886
+ resetKeychain: () => driver.resetKeychain(),
808
887
  scroll: (direction, budgetMs) => driver.scroll(direction, settle(budgetMs))
809
888
  };
810
889
  function run(body) {
@@ -839,15 +918,8 @@ function createSession(driver, options, name, binding) {
839
918
  screen: renderScreen(screen)
840
919
  });
841
920
  }
842
- return {
843
- name,
844
- options,
845
- state: () => state,
846
- failure: () => state.phase === "broken" ? state.failure : null,
847
- run,
848
- screen: () => run((one) => one.capture()),
849
- screenshot: (path) => queue.enqueue(() => driver.screenshot(path)),
850
- relaunch: (sink) => sink.step(renderTitle({
921
+ function relaunch(sink) {
922
+ return sink.step(renderTitle({
851
923
  kind: "relaunch",
852
924
  app: options.app
853
925
  }), async () => {
@@ -858,6 +930,27 @@ function createSession(driver, options, name, binding) {
858
930
  }));
859
931
  if (options.dismissDevOverlay) await run(() => driver.dismissDevOverlay());
860
932
  await awaitReady(Date.now() + options.launchTimeout);
933
+ });
934
+ }
935
+ return {
936
+ name,
937
+ options,
938
+ state: () => state,
939
+ failure: () => state.phase === "broken" ? state.failure : null,
940
+ run,
941
+ screen: () => run((one) => one.capture()),
942
+ screenshot: (path) => queue.enqueue(() => driver.screenshot(path)),
943
+ relaunch,
944
+ clearState: (sink) => sink.step(renderTitle({
945
+ kind: "clear-state",
946
+ app: options.app
947
+ }), async () => {
948
+ await run((one) => one.clearAppState(options.app));
949
+ await relaunch(sink);
950
+ }),
951
+ clearKeychain: (sink) => sink.step(renderTitle({ kind: "clear-keychain" }), async () => {
952
+ if (options.platform === "android") sink.note("keychain", "nothing to reset on Android, clearing state covers the keystore");
953
+ await run((one) => one.resetKeychain());
861
954
  }),
862
955
  dismissDevOverlay: () => run(() => driver.dismissDevOverlay()),
863
956
  awaitReady,
@@ -1169,6 +1262,25 @@ function createDevice(session, sink) {
1169
1262
  }), async () => {
1170
1263
  await session.run((device) => device.scroll(direction, session.options.actionTimeout));
1171
1264
  }),
1265
+ goBack: (options) => {
1266
+ const mode = options?.mode ?? (session.options.platform === "android" ? "system" : "in-app");
1267
+ return sink.step(renderTitle({
1268
+ kind: "back",
1269
+ mode
1270
+ }), async () => {
1271
+ const budget = options?.timeout ?? session.options.actionTimeout;
1272
+ await session.run((device) => device.back(mode, budget));
1273
+ });
1274
+ },
1275
+ keyboard: { type: (text, options) => sink.step(renderTitle({
1276
+ kind: "typed",
1277
+ typed: typedText(text, options)
1278
+ }), async () => {
1279
+ const budget = options?.timeout ?? session.options.actionTimeout;
1280
+ await session.run((device) => device.type(text, budget));
1281
+ }) },
1282
+ clearState: () => session.clearState(sink),
1283
+ clearKeychain: () => session.clearKeychain(sink),
1172
1284
  relaunch: () => session.relaunch(sink),
1173
1285
  dismissDevOverlay: () => sink.step(renderTitle({ kind: "dismiss-overlay" }), () => session.dismissDevOverlay()),
1174
1286
  screen: () => session.screen(),
@@ -1297,10 +1409,14 @@ function perform(session, sink, record, options, dispatch, write) {
1297
1409
  try {
1298
1410
  settled = await dispatch(device, pin(screen, resolution.node), deadline - Date.now());
1299
1411
  } catch (error) {
1300
- if (failureOf(error)?.kind !== "stale-ref" || retriedStaleRef) throw error;
1301
- retriedStaleRef = true;
1302
- screen = await device.capture();
1303
- continue;
1412
+ const failure = failureOf(error);
1413
+ if (failure?.kind === "stale-ref" && !retriedStaleRef) {
1414
+ retriedStaleRef = true;
1415
+ screen = await device.capture();
1416
+ continue;
1417
+ }
1418
+ if (failure?.kind !== "covered" || record.kind === "fill") throw error;
1419
+ settled = await retarget(device, sink, dispatch, locator, screen, resolution.node, deadline - Date.now());
1304
1420
  }
1305
1421
  attempts += 1;
1306
1422
  if (!settled.settled) sink.note("settle", `${renderTitle(record)} finished before the screen went quiet`);
@@ -1386,6 +1502,38 @@ function reportingScrolls(device, sink) {
1386
1502
  }), () => device.scroll(direction, budgetMs))
1387
1503
  };
1388
1504
  }
1505
+ /**
1506
+ * A React Native pressable splits the accessible name off the touch handler, so
1507
+ * a locator naming what a user reads can pin a node the driver refuses. The
1508
+ * control covering that node is what the finger would have hit, so the action
1509
+ * goes there and the report names it, rather than asking the author to describe
1510
+ * a node they cannot see.
1511
+ *
1512
+ * One hop only. A second refusal means the retarget was the wrong reading of
1513
+ * the screen, and repeating it would only walk further from what was asked for.
1514
+ */
1515
+ async function retarget(device, sink, dispatch, locator, screen, matched, budgetMs) {
1516
+ const target = touchTargetFor(screen, matched);
1517
+ if (target === null) throw untappable(locator, screen);
1518
+ return await sink.step(renderTitle({
1519
+ kind: "retarget",
1520
+ target: describeNode(target)
1521
+ }), async () => {
1522
+ try {
1523
+ return await dispatch(device, pin(screen, target), budgetMs);
1524
+ } catch (error) {
1525
+ if (failureOf(error)?.kind !== "covered") throw error;
1526
+ throw untappable(locator, screen);
1527
+ }
1528
+ }, { box: true });
1529
+ }
1530
+ function untappable(locator, screen) {
1531
+ return new TouchpressError({
1532
+ kind: "untappable",
1533
+ locator,
1534
+ screen: renderScreen(screen)
1535
+ });
1536
+ }
1389
1537
  function ambiguous(locator, nodes, screen) {
1390
1538
  return new TouchpressError({
1391
1539
  kind: "strict-mode",
@@ -1443,6 +1591,16 @@ function expectedOf(confirmation) {
1443
1591
  default: throw new Error(`unhandled confirmation ${JSON.stringify(confirmation)}`);
1444
1592
  }
1445
1593
  }
1594
+ /** A blind type has no node to ask about secrecy, so only the caller can say. */
1595
+ function typedText(text, options) {
1596
+ return options?.secret === true ? {
1597
+ kind: "hidden",
1598
+ length: text.length
1599
+ } : {
1600
+ kind: "text",
1601
+ value: text
1602
+ };
1603
+ }
1446
1604
  /** What the report says was typed, verbatim, which the normalized confirmation no longer holds. */
1447
1605
  function typedOf(role, write) {
1448
1606
  if (role === "secure-text-field" || write.secret) return {
@@ -1522,18 +1680,23 @@ function messageOf(error) {
1522
1680
  function createClient() {
1523
1681
  return createAgentDeviceClient({ responseLevel: "full" });
1524
1682
  }
1683
+ const execFileAsync = promisify(execFile);
1684
+ const spawnCommand = async (file, args) => {
1685
+ await execFileAsync(file, [...args]);
1686
+ };
1525
1687
  /**
1526
1688
  * The only file that imports `agent-device`. Two jobs: translate domain requests
1527
1689
  * into client calls carrying the session and device selection, and translate the
1528
1690
  * driver's error codes into `DeviceFailure`. Snapshot parsing is not one of
1529
1691
  * them, because role normalization and label rules live in `core/screen.ts`.
1530
1692
  */
1531
- function createAgentDeviceDriver(client, session, selection) {
1693
+ function createAgentDeviceDriver(client, session, selection, runCommand = spawnCommand) {
1532
1694
  const where = {
1533
1695
  session,
1534
1696
  platform: selection.platform,
1535
1697
  ...selection.name === null ? {} : { device: selection.name }
1536
1698
  };
1699
+ let udid = null;
1537
1700
  async function run(command, body) {
1538
1701
  try {
1539
1702
  return await body();
@@ -1560,12 +1723,14 @@ function createAgentDeviceDriver(client, session, selection) {
1560
1723
  relaunch: request.relaunch,
1561
1724
  ...request.url === null ? {} : { url: request.url }
1562
1725
  });
1726
+ udid = result.identifiers.udid ?? result.identifiers.deviceId ?? null;
1563
1727
  return {
1564
1728
  session: result.session,
1565
1729
  platform: selection.platform,
1566
1730
  deviceLabel: result.device?.name ?? result.identifiers.deviceName ?? selection.name ?? selection.platform,
1567
1731
  appId: result.appBundleId ?? result.appId ?? request.app,
1568
- stateDir: result.sessionStateDir ?? null
1732
+ stateDir: result.sessionStateDir ?? null,
1733
+ udid
1569
1734
  };
1570
1735
  }),
1571
1736
  capture: (options) => run("snapshot", () => client.capture.snapshot({
@@ -1595,6 +1760,56 @@ function createAgentDeviceDriver(client, session, selection) {
1595
1760
  text,
1596
1761
  ...settle(options)
1597
1762
  }))),
1763
+ back: (mode, options) => run("back", async () => toSettled(await client.command.back({
1764
+ ...where,
1765
+ mode,
1766
+ ...settle(options)
1767
+ }))),
1768
+ /**
1769
+ * Scrubbed rather than run through `run`, because agent-device echoes the
1770
+ * text it rejected and a password has no business in a report.
1771
+ */
1772
+ type: async (text, _options) => {
1773
+ if (looksLikeRef(text)) throw new TouchpressError({ kind: "type-rejected" });
1774
+ try {
1775
+ await client.interactions.type({
1776
+ ...where,
1777
+ text
1778
+ });
1779
+ return {
1780
+ settled: false,
1781
+ waitedMs: 0
1782
+ };
1783
+ } catch (error) {
1784
+ throw new TouchpressError({
1785
+ kind: "driver",
1786
+ command: "type",
1787
+ failure: withoutText(classifyError(error), text)
1788
+ });
1789
+ }
1790
+ },
1791
+ clearAppState: (app) => run("clearAppState", async () => {
1792
+ await client.settings.update({
1793
+ ...where,
1794
+ setting: "clear-app-state",
1795
+ state: "clear",
1796
+ app
1797
+ });
1798
+ }),
1799
+ /**
1800
+ * agent-device has no keychain command, and the simulator keychain is where
1801
+ * clerk-ios and expo-secure-store keep a session. Lives in the driver
1802
+ * because it needs the udid and a process, neither of which the core has.
1803
+ */
1804
+ resetKeychain: () => run("resetKeychain", async () => {
1805
+ if (selection.platform !== "ios") return;
1806
+ await runCommand("xcrun", [
1807
+ "simctl",
1808
+ "keychain",
1809
+ udid ?? "booted",
1810
+ "reset"
1811
+ ]);
1812
+ }),
1598
1813
  scroll: (direction, options) => run("scroll", async () => {
1599
1814
  await client.interactions.scroll({
1600
1815
  ...where,
@@ -1635,6 +1850,25 @@ function readNormalized(error) {
1635
1850
  return normalizeAgentDeviceError(error);
1636
1851
  }
1637
1852
  /**
1853
+ * agent-device 0.20.10's own check, copied rather than approximated so the two
1854
+ * agree on what it will refuse. `@e12` and `@ref-x` are refs, while `@word` and
1855
+ * `@ home` are text.
1856
+ */
1857
+ function looksLikeRef(text) {
1858
+ const word = text.trim().split(/\s+/, 1)[0];
1859
+ if (word === void 0 || !word.startsWith("@") || word.length < 3) return false;
1860
+ const rest = word.slice(1);
1861
+ return /^[A-Za-z_-]*\d[\w-]*$/i.test(rest) || /^(?:ref|node|element|el)[\w-]*$/i.test(rest);
1862
+ }
1863
+ /** Every failure kind carries a `detail`, and the driver puts what it was given into it. */
1864
+ function withoutText(failure, text) {
1865
+ if (text === "") return failure;
1866
+ return {
1867
+ ...failure,
1868
+ detail: failure.detail.split(text).join("<typed text>")
1869
+ };
1870
+ }
1871
+ /**
1638
1872
  * Timeouts and transport faults both arrive as `COMMAND_FAILED`, so the message
1639
1873
  * and `details.reason` separate them. That matches against upstream text, and it
1640
1874
  * is confined to this function for exactly that reason.
@@ -1681,6 +1915,10 @@ function classifyError(error) {
1681
1915
  kind: "stale-ref",
1682
1916
  detail: message
1683
1917
  };
1918
+ if (details["reason"] === "covered_by_interactive_descendants") return {
1919
+ kind: "covered",
1920
+ detail: message
1921
+ };
1684
1922
  if (/timed out|timeout/i.test(message)) return {
1685
1923
  kind: "timeout",
1686
1924
  detail: message
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "touchpress",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "End-to-end testing for mobile apps.",
5
5
  "homepage": "https://github.com/wobsoriano/touchpress#readme",
6
6
  "bugs": {