touchpress 0.1.2 → 0.1.3

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 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";
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
@@ -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-BHYloK0j.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 { 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";
2
2
  import { ExpectMatcherState } from "@playwright/test";
3
3
  //#region src/ai/options.d.ts
4
4
  /**
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-BHYloK0j.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";
@@ -58,6 +58,17 @@ function formatError(info) {
58
58
  `Screen:`,
59
59
  info.screen
60
60
  ].join("\n");
61
+ case "untappable": return [
62
+ `The matched node has no touch point of its own, and no enclosing control was found.`,
63
+ ``,
64
+ `Locator: ${info.locator}`,
65
+ ``,
66
+ `Name the control that receives the touch, with getByRole, or with`,
67
+ `locator({ role: 'button', where: (node) => ... }).`,
68
+ ``,
69
+ `Screen:`,
70
+ info.screen
71
+ ].join("\n");
61
72
  case "driver": return `${info.command} failed: ${describeFailure(info.failure)}`;
62
73
  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
74
  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 +122,7 @@ function describeFailure(failure) {
111
122
  case "session-rebound": return `the session is already bound to ${failure.boundTo} (${failure.detail})`;
112
123
  case "stale-ref": return `the screen changed before the action reached it (${failure.detail})`;
113
124
  case "ambiguous": return `the driver matched more than one element (${failure.detail})`;
125
+ case "covered": return `the node owns no touch point of its own (${failure.detail})`;
114
126
  case "timeout": return `the driver timed out (${failure.detail})`;
115
127
  case "unknown": return `${failure.code}: ${failure.detail}${failure.logPath === null ? "" : `\nDiagnostics: ${failure.logPath}`}`;
116
128
  default: throw new Error(`unhandled failure ${JSON.stringify(failure)}`);
@@ -600,6 +612,56 @@ function matchedText(node, query) {
600
612
  function absorbAncestors(matched, query) {
601
613
  return matched.filter((candidate) => !matched.some((other) => other !== candidate && isDescendant(other, candidate) && matchedText(other, query) === matchedText(candidate, query)));
602
614
  }
615
+ /** `tab-bar` is left out because it is a container of buttons rather than a control that takes the touch. */
616
+ const INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
617
+ "button",
618
+ "link",
619
+ "switch",
620
+ "slider",
621
+ "text-field",
622
+ "secure-text-field",
623
+ "cell"
624
+ ]);
625
+ /**
626
+ * The control a tap on `node` would really land on, or null when the screen
627
+ * offers none.
628
+ *
629
+ * A React Native pressable puts the accessible name on one node and the touch
630
+ * handler on another. On Android the two are siblings, so a locator that
631
+ * matched the label pins a ref that owns no touch point of its own. The button
632
+ * drawn under the label is the thing a finger hits, and its rect is what says
633
+ * so.
634
+ *
635
+ * A descendant wins over an enclosing node, because a control inside the
636
+ * matched node is what that node was labelling. Among several, the smallest
637
+ * wins, so a row of buttons inside one card does not hand back the card.
638
+ */
639
+ function touchTargetFor(screen, node) {
640
+ const candidates = [];
641
+ for (const other of screen.nodes) {
642
+ if (other === node || !other.enabled || !INTERACTIVE_ROLES.has(other.role)) continue;
643
+ if (other.rect !== null) candidates.push({
644
+ node: other,
645
+ rect: other.rect
646
+ });
647
+ }
648
+ const inside = smallest(candidates.filter((candidate) => isDescendant(candidate.node, node)));
649
+ if (inside !== null) return inside;
650
+ const own = node.rect;
651
+ if (own === null) return null;
652
+ return smallest(candidates.filter((candidate) => encloses(candidate.rect, own)));
653
+ }
654
+ function encloses(outer, inner) {
655
+ 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;
656
+ }
657
+ function smallest(candidates) {
658
+ let best = null;
659
+ for (const candidate of candidates) if (best === null || areaOf(candidate.rect) < areaOf(best.rect)) best = candidate;
660
+ return best === null ? null : best.node;
661
+ }
662
+ function areaOf(rect) {
663
+ return rect.width * rect.height;
664
+ }
603
665
  function isDescendant(node, ancestor) {
604
666
  for (let walk = node.parent; walk !== null; walk = walk.parent) if (walk === ancestor) return true;
605
667
  return false;
@@ -676,6 +738,7 @@ function renderTitle(record) {
676
738
  case "long-press": return `longPress ${describeQuery(record.query)} for ${String(record.durationMs)}ms`;
677
739
  case "fill": return `fill ${describeQuery(record.query)}`;
678
740
  case "typed": return renderTyped(record.typed);
741
+ case "retarget": return `retarget to ${record.target}`;
679
742
  case "scroll": return `scroll ${record.direction}`;
680
743
  case "scroll-into-view": return `scrollIntoView ${describeQuery(record.query)}`;
681
744
  case "relaunch": return `relaunch ${record.app}`;
@@ -1297,10 +1360,14 @@ function perform(session, sink, record, options, dispatch, write) {
1297
1360
  try {
1298
1361
  settled = await dispatch(device, pin(screen, resolution.node), deadline - Date.now());
1299
1362
  } catch (error) {
1300
- if (failureOf(error)?.kind !== "stale-ref" || retriedStaleRef) throw error;
1301
- retriedStaleRef = true;
1302
- screen = await device.capture();
1303
- continue;
1363
+ const failure = failureOf(error);
1364
+ if (failure?.kind === "stale-ref" && !retriedStaleRef) {
1365
+ retriedStaleRef = true;
1366
+ screen = await device.capture();
1367
+ continue;
1368
+ }
1369
+ if (failure?.kind !== "covered" || record.kind === "fill") throw error;
1370
+ settled = await retarget(device, sink, dispatch, locator, screen, resolution.node, deadline - Date.now());
1304
1371
  }
1305
1372
  attempts += 1;
1306
1373
  if (!settled.settled) sink.note("settle", `${renderTitle(record)} finished before the screen went quiet`);
@@ -1386,6 +1453,38 @@ function reportingScrolls(device, sink) {
1386
1453
  }), () => device.scroll(direction, budgetMs))
1387
1454
  };
1388
1455
  }
1456
+ /**
1457
+ * A React Native pressable splits the accessible name off the touch handler, so
1458
+ * a locator naming what a user reads can pin a node the driver refuses. The
1459
+ * control covering that node is what the finger would have hit, so the action
1460
+ * goes there and the report names it, rather than asking the author to describe
1461
+ * a node they cannot see.
1462
+ *
1463
+ * One hop only. A second refusal means the retarget was the wrong reading of
1464
+ * the screen, and repeating it would only walk further from what was asked for.
1465
+ */
1466
+ async function retarget(device, sink, dispatch, locator, screen, matched, budgetMs) {
1467
+ const target = touchTargetFor(screen, matched);
1468
+ if (target === null) throw untappable(locator, screen);
1469
+ return await sink.step(renderTitle({
1470
+ kind: "retarget",
1471
+ target: describeNode(target)
1472
+ }), async () => {
1473
+ try {
1474
+ return await dispatch(device, pin(screen, target), budgetMs);
1475
+ } catch (error) {
1476
+ if (failureOf(error)?.kind !== "covered") throw error;
1477
+ throw untappable(locator, screen);
1478
+ }
1479
+ }, { box: true });
1480
+ }
1481
+ function untappable(locator, screen) {
1482
+ return new TouchpressError({
1483
+ kind: "untappable",
1484
+ locator,
1485
+ screen: renderScreen(screen)
1486
+ });
1487
+ }
1389
1488
  function ambiguous(locator, nodes, screen) {
1390
1489
  return new TouchpressError({
1391
1490
  kind: "strict-mode",
@@ -1681,6 +1780,10 @@ function classifyError(error) {
1681
1780
  kind: "stale-ref",
1682
1781
  detail: message
1683
1782
  };
1783
+ if (details["reason"] === "covered_by_interactive_descendants") return {
1784
+ kind: "covered",
1785
+ detail: message
1786
+ };
1684
1787
  if (/timed out|timeout/i.test(message)) return {
1685
1788
  kind: "timeout",
1686
1789
  detail: message
@@ -291,6 +291,14 @@ type DeviceFailure = {
291
291
  } | {
292
292
  readonly kind: 'ambiguous';
293
293
  readonly detail: string;
294
+ } |
295
+ /**
296
+ * The pinned node owns no touch point outside the interactive nodes drawn
297
+ * over it, so the driver refused rather than guess which one was meant.
298
+ */
299
+ {
300
+ readonly kind: 'covered';
301
+ readonly detail: string;
294
302
  } | {
295
303
  readonly kind: 'timeout';
296
304
  readonly detail: string;
@@ -434,6 +442,11 @@ type ActionRecord = {
434
442
  } | {
435
443
  readonly kind: 'typed';
436
444
  readonly typed: Typed;
445
+ } |
446
+ /** Where an action landed after the driver refused the node the locator matched. */
447
+ {
448
+ readonly kind: 'retarget';
449
+ readonly target: string;
437
450
  } | {
438
451
  readonly kind: 'scroll';
439
452
  readonly direction: ScrollDirection;
@@ -833,6 +846,10 @@ type ErrorInfo = {
833
846
  readonly attempts: number;
834
847
  readonly timeoutMs: number;
835
848
  readonly screen: string;
849
+ } | {
850
+ readonly kind: 'untappable';
851
+ readonly locator: string;
852
+ readonly screen: string;
836
853
  } | {
837
854
  readonly kind: 'driver';
838
855
  readonly command: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "touchpress",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "End-to-end testing for mobile apps.",
5
5
  "homepage": "https://github.com/wobsoriano/touchpress#readme",
6
6
  "bugs": {