touchpress 0.1.3 → 0.2.1

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-DJH10uBo.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-BHYloK0j.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-D1aux7ic.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-DJH10uBo.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-BHYloK0j.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-D1aux7ic.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;
@@ -342,6 +349,24 @@ type DeviceDriver = {
342
349
  tap(ref: PinnedRef, options: SettleOptions): Promise<Settled>;
343
350
  longPress(ref: PinnedRef, durationMs: number, options: SettleOptions): Promise<Settled>;
344
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>;
345
370
  /** Returns nothing because the driver's scroll response carries no settle observation. */
346
371
  scroll(direction: ScrollDirection, options: SettleOptions): Promise<void>;
347
372
  dismissDevOverlay(): Promise<void>;
@@ -456,6 +481,14 @@ type ActionRecord = {
456
481
  } | {
457
482
  readonly kind: 'relaunch';
458
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';
459
492
  } | {
460
493
  readonly kind: 'dismiss-overlay';
461
494
  } | {
@@ -635,6 +668,10 @@ type SessionDevice = {
635
668
  tap(ref: PinnedRef, budgetMs: number): Promise<Settled>;
636
669
  longPress(ref: PinnedRef, durationMs: number, budgetMs: number): Promise<Settled>;
637
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>;
638
675
  scroll(direction: ScrollDirection, budgetMs: number): Promise<void>;
639
676
  };
640
677
  type DeviceSession = {
@@ -649,6 +686,18 @@ type DeviceSession = {
649
686
  screenshot(path: string): Promise<string>;
650
687
  /** Relaunches the app and re-runs the ready gate. Reported as one step. */
651
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>;
652
701
  dismissDevOverlay(): Promise<void>;
653
702
  awaitReady(deadline: number): Promise<void>;
654
703
  /** Idempotent. Never shuts the simulator down, and reaches `closed` even when the driver call fails. */
@@ -704,6 +753,25 @@ type FilterOptions = {
704
753
  type FillOptions = ActionOptions & {
705
754
  secret?: boolean;
706
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
+ };
707
775
  type Device = {
708
776
  /** Matches a node's accessibility name or its value. */
709
777
  getByText(text: string | RegExp, options?: TextOptions): Locator;
@@ -712,6 +780,20 @@ type Device = {
712
780
  getByTestId(testId: string): Locator;
713
781
  locator(query: Query): Locator;
714
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>;
715
797
  /** Relaunches the app and waits for the ready gate again. */
716
798
  relaunch(): Promise<void>;
717
799
  /** Never automatic, because hiding the overlay would suppress a warning a test might want to see. */
@@ -850,6 +932,10 @@ type ErrorInfo = {
850
932
  readonly kind: 'untappable';
851
933
  readonly locator: string;
852
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';
853
939
  } | {
854
940
  readonly kind: 'driver';
855
941
  readonly command: string;
@@ -899,4 +985,4 @@ type PreflightReport = {
899
985
  */
900
986
  declare function preflight(options: Partial<TouchpressOptions>, driver?: DeviceDriver): Promise<PreflightReport>;
901
987
  //#endregion
902
- 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";
@@ -49,11 +51,11 @@ function formatError(info) {
49
51
  info.screen
50
52
  ].join("\n");
51
53
  case "fill-unconfirmed": return [
52
- `fill left the field holding something else after ${String(info.attempts)} attempts in ${String(info.timeoutMs)}ms.`,
54
+ info.actual === null ? `fill lost the field after ${String(info.attempts)} attempts in ${String(info.timeoutMs)}ms.` : `fill left the field holding something else after ${String(info.attempts)} attempts in ${String(info.timeoutMs)}ms.`,
53
55
  ``,
54
56
  `Locator: ${info.locator}`,
55
57
  `Expected value: ${describeValue(info.expected)}${info.expected.kind === "masked" ? " (a secure field reports a mask, not its contents)" : ""}`,
56
- `Actual value: ${info.actual === null ? "the locator stopped resolving" : describeValue(info.actual)}`,
58
+ `Actual value: ${info.actual === null ? "unknown, the field no longer resolved after the write" : describeValue(info.actual)}`,
57
59
  ``,
58
60
  `Screen:`,
59
61
  info.screen
@@ -69,6 +71,13 @@ function formatError(info) {
69
71
  `Screen:`,
70
72
  info.screen
71
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");
72
81
  case "driver": return `${info.command} failed: ${describeFailure(info.failure)}`;
73
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");
74
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");
@@ -742,6 +751,9 @@ function renderTitle(record) {
742
751
  case "scroll": return `scroll ${record.direction}`;
743
752
  case "scroll-into-view": return `scrollIntoView ${describeQuery(record.query)}`;
744
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";
745
757
  case "dismiss-overlay": return "dismiss the React Native dev overlay";
746
758
  case "screenshot": return `screenshot ${record.path}`;
747
759
  case "act": return `act "${truncate(record.instruction, PROMPT_LIMIT)}"`;
@@ -868,6 +880,10 @@ function createSession(driver, options, name, binding) {
868
880
  tap: (ref, budgetMs) => driver.tap(ref, settle(budgetMs)),
869
881
  longPress: (ref, durationMs, budgetMs) => driver.longPress(ref, durationMs, settle(budgetMs)),
870
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(),
871
887
  scroll: (direction, budgetMs) => driver.scroll(direction, settle(budgetMs))
872
888
  };
873
889
  function run(body) {
@@ -902,15 +918,8 @@ function createSession(driver, options, name, binding) {
902
918
  screen: renderScreen(screen)
903
919
  });
904
920
  }
905
- return {
906
- name,
907
- options,
908
- state: () => state,
909
- failure: () => state.phase === "broken" ? state.failure : null,
910
- run,
911
- screen: () => run((one) => one.capture()),
912
- screenshot: (path) => queue.enqueue(() => driver.screenshot(path)),
913
- relaunch: (sink) => sink.step(renderTitle({
921
+ function relaunch(sink) {
922
+ return sink.step(renderTitle({
914
923
  kind: "relaunch",
915
924
  app: options.app
916
925
  }), async () => {
@@ -921,6 +930,27 @@ function createSession(driver, options, name, binding) {
921
930
  }));
922
931
  if (options.dismissDevOverlay) await run(() => driver.dismissDevOverlay());
923
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());
924
954
  }),
925
955
  dismissDevOverlay: () => run(() => driver.dismissDevOverlay()),
926
956
  awaitReady,
@@ -1232,6 +1262,25 @@ function createDevice(session, sink) {
1232
1262
  }), async () => {
1233
1263
  await session.run((device) => device.scroll(direction, session.options.actionTimeout));
1234
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),
1235
1284
  relaunch: () => session.relaunch(sink),
1236
1285
  dismissDevOverlay: () => sink.step(renderTitle({ kind: "dismiss-overlay" }), () => session.dismissDevOverlay()),
1237
1286
  screen: () => session.screen(),
@@ -1339,6 +1388,7 @@ function perform(session, sink, record, options, dispatch, write) {
1339
1388
  let retriedStaleRef = false;
1340
1389
  let attempts = 0;
1341
1390
  let target = record.query;
1391
+ let written = null;
1342
1392
  let lastActual = null;
1343
1393
  let screen = await device.capture();
1344
1394
  const unconfirmed = (expected) => new TouchpressError({
@@ -1377,6 +1427,7 @@ function perform(session, sink, record, options, dispatch, write) {
1377
1427
  typed: typedOf(resolution.node.role, write)
1378
1428
  }), () => Promise.resolve(), { box: true });
1379
1429
  target = identityOf(screen, resolution.node);
1430
+ written = confirmation;
1380
1431
  screen = await device.capture();
1381
1432
  lastActual = valueAt(screen, target);
1382
1433
  if (lastActual !== null && holds(confirmation, lastActual)) {
@@ -1393,13 +1444,16 @@ function perform(session, sink, record, options, dispatch, write) {
1393
1444
  continue;
1394
1445
  }
1395
1446
  const remaining = deadline - Date.now();
1396
- if (remaining <= 0) throw new TouchpressError({
1397
- kind: "not-found",
1398
- locator,
1399
- timeoutMs: timeout,
1400
- screen: renderScreen(screen),
1401
- scrolled: search.trail()
1402
- });
1447
+ if (remaining <= 0) {
1448
+ if (written !== null) throw unconfirmed(expectedOf(written));
1449
+ throw new TouchpressError({
1450
+ kind: "not-found",
1451
+ locator,
1452
+ timeoutMs: timeout,
1453
+ screen: renderScreen(screen),
1454
+ scrolled: search.trail()
1455
+ });
1456
+ }
1403
1457
  if (attempts === 0 && await search.step(screen, target, remaining)) {
1404
1458
  screen = await device.capture();
1405
1459
  continue;
@@ -1542,6 +1596,16 @@ function expectedOf(confirmation) {
1542
1596
  default: throw new Error(`unhandled confirmation ${JSON.stringify(confirmation)}`);
1543
1597
  }
1544
1598
  }
1599
+ /** A blind type has no node to ask about secrecy, so only the caller can say. */
1600
+ function typedText(text, options) {
1601
+ return options?.secret === true ? {
1602
+ kind: "hidden",
1603
+ length: text.length
1604
+ } : {
1605
+ kind: "text",
1606
+ value: text
1607
+ };
1608
+ }
1545
1609
  /** What the report says was typed, verbatim, which the normalized confirmation no longer holds. */
1546
1610
  function typedOf(role, write) {
1547
1611
  if (role === "secure-text-field" || write.secret) return {
@@ -1571,18 +1635,29 @@ function valueAt(screen, target) {
1571
1635
  /**
1572
1636
  * How the written node is found again on the next snapshot.
1573
1637
  *
1574
- * The driver copies an ancestor's identifier onto every descendant that
1575
- * inherits it, so a testId names this node alone only when it resolves to this
1576
- * node alone. Two inheriting siblings would otherwise turn a landed fill into a
1577
- * strict-mode failure. Position in the tree is the fallback.
1638
+ * A testId rarely names one node. SwiftUI puts one identifier on a field, its
1639
+ * placeholder, and the button under it, and the driver copies an ancestor's
1640
+ * identifier onto every inheriting descendant. The role narrows those to the
1641
+ * field, and failing that the field's position among nodes of its role stands
1642
+ * in. Never the raw tree index: the software keyboard inserts its windows at the
1643
+ * top of the tree, which shifts every index but adds no text field.
1578
1644
  */
1579
1645
  function identityOf(screen, node) {
1580
1646
  if (node.testId !== null) {
1581
- const byTestId = { testId: textMatch(node.testId, true) };
1647
+ const byTestId = {
1648
+ role: node.role,
1649
+ testId: textMatch(node.testId, true)
1650
+ };
1582
1651
  const resolution = resolve(screen, byTestId);
1583
1652
  if (resolution.outcome === "one" && resolution.node === node) return byTestId;
1584
1653
  }
1585
- return { where: (other) => other.index === node.index };
1654
+ const byRole = { role: node.role };
1655
+ const index = matchesOf(screen, byRole).indexOf(node);
1656
+ if (index === -1) throw new Error(`${node.ref} does not resolve under its own role`);
1657
+ return {
1658
+ ...byRole,
1659
+ index
1660
+ };
1586
1661
  }
1587
1662
  //#endregion
1588
1663
  //#region src/core/evidence.ts
@@ -1621,18 +1696,23 @@ function messageOf(error) {
1621
1696
  function createClient() {
1622
1697
  return createAgentDeviceClient({ responseLevel: "full" });
1623
1698
  }
1699
+ const execFileAsync = promisify(execFile);
1700
+ const spawnCommand = async (file, args) => {
1701
+ await execFileAsync(file, [...args]);
1702
+ };
1624
1703
  /**
1625
1704
  * The only file that imports `agent-device`. Two jobs: translate domain requests
1626
1705
  * into client calls carrying the session and device selection, and translate the
1627
1706
  * driver's error codes into `DeviceFailure`. Snapshot parsing is not one of
1628
1707
  * them, because role normalization and label rules live in `core/screen.ts`.
1629
1708
  */
1630
- function createAgentDeviceDriver(client, session, selection) {
1709
+ function createAgentDeviceDriver(client, session, selection, runCommand = spawnCommand) {
1631
1710
  const where = {
1632
1711
  session,
1633
1712
  platform: selection.platform,
1634
1713
  ...selection.name === null ? {} : { device: selection.name }
1635
1714
  };
1715
+ let udid = null;
1636
1716
  async function run(command, body) {
1637
1717
  try {
1638
1718
  return await body();
@@ -1659,12 +1739,14 @@ function createAgentDeviceDriver(client, session, selection) {
1659
1739
  relaunch: request.relaunch,
1660
1740
  ...request.url === null ? {} : { url: request.url }
1661
1741
  });
1742
+ udid = result.identifiers.udid ?? result.identifiers.deviceId ?? null;
1662
1743
  return {
1663
1744
  session: result.session,
1664
1745
  platform: selection.platform,
1665
1746
  deviceLabel: result.device?.name ?? result.identifiers.deviceName ?? selection.name ?? selection.platform,
1666
1747
  appId: result.appBundleId ?? result.appId ?? request.app,
1667
- stateDir: result.sessionStateDir ?? null
1748
+ stateDir: result.sessionStateDir ?? null,
1749
+ udid
1668
1750
  };
1669
1751
  }),
1670
1752
  capture: (options) => run("snapshot", () => client.capture.snapshot({
@@ -1694,6 +1776,56 @@ function createAgentDeviceDriver(client, session, selection) {
1694
1776
  text,
1695
1777
  ...settle(options)
1696
1778
  }))),
1779
+ back: (mode, options) => run("back", async () => toSettled(await client.command.back({
1780
+ ...where,
1781
+ mode,
1782
+ ...settle(options)
1783
+ }))),
1784
+ /**
1785
+ * Scrubbed rather than run through `run`, because agent-device echoes the
1786
+ * text it rejected and a password has no business in a report.
1787
+ */
1788
+ type: async (text, _options) => {
1789
+ if (looksLikeRef(text)) throw new TouchpressError({ kind: "type-rejected" });
1790
+ try {
1791
+ await client.interactions.type({
1792
+ ...where,
1793
+ text
1794
+ });
1795
+ return {
1796
+ settled: false,
1797
+ waitedMs: 0
1798
+ };
1799
+ } catch (error) {
1800
+ throw new TouchpressError({
1801
+ kind: "driver",
1802
+ command: "type",
1803
+ failure: withoutText(classifyError(error), text)
1804
+ });
1805
+ }
1806
+ },
1807
+ clearAppState: (app) => run("clearAppState", async () => {
1808
+ await client.settings.update({
1809
+ ...where,
1810
+ setting: "clear-app-state",
1811
+ state: "clear",
1812
+ app
1813
+ });
1814
+ }),
1815
+ /**
1816
+ * agent-device has no keychain command, and the simulator keychain is where
1817
+ * clerk-ios and expo-secure-store keep a session. Lives in the driver
1818
+ * because it needs the udid and a process, neither of which the core has.
1819
+ */
1820
+ resetKeychain: () => run("resetKeychain", async () => {
1821
+ if (selection.platform !== "ios") return;
1822
+ await runCommand("xcrun", [
1823
+ "simctl",
1824
+ "keychain",
1825
+ udid ?? "booted",
1826
+ "reset"
1827
+ ]);
1828
+ }),
1697
1829
  scroll: (direction, options) => run("scroll", async () => {
1698
1830
  await client.interactions.scroll({
1699
1831
  ...where,
@@ -1734,6 +1866,25 @@ function readNormalized(error) {
1734
1866
  return normalizeAgentDeviceError(error);
1735
1867
  }
1736
1868
  /**
1869
+ * agent-device 0.20.10's own check, copied rather than approximated so the two
1870
+ * agree on what it will refuse. `@e12` and `@ref-x` are refs, while `@word` and
1871
+ * `@ home` are text.
1872
+ */
1873
+ function looksLikeRef(text) {
1874
+ const word = text.trim().split(/\s+/, 1)[0];
1875
+ if (word === void 0 || !word.startsWith("@") || word.length < 3) return false;
1876
+ const rest = word.slice(1);
1877
+ return /^[A-Za-z_-]*\d[\w-]*$/i.test(rest) || /^(?:ref|node|element|el)[\w-]*$/i.test(rest);
1878
+ }
1879
+ /** Every failure kind carries a `detail`, and the driver puts what it was given into it. */
1880
+ function withoutText(failure, text) {
1881
+ if (text === "") return failure;
1882
+ return {
1883
+ ...failure,
1884
+ detail: failure.detail.split(text).join("<typed text>")
1885
+ };
1886
+ }
1887
+ /**
1737
1888
  * Timeouts and transport faults both arrive as `COMMAND_FAILED`, so the message
1738
1889
  * and `details.reason` separate them. That matches against upstream text, and it
1739
1890
  * is confined to this function for exactly that reason.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "touchpress",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "End-to-end testing for mobile apps.",
5
5
  "homepage": "https://github.com/wobsoriano/touchpress#readme",
6
6
  "bugs": {