touchpress 0.1.1 → 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-aUba9u1k.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-aUba9u1k.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)}`);
@@ -419,7 +431,9 @@ const IOS_ROLES = {
419
431
  * Native 0.86 sample app, except the trailing widget block, which this app never
420
432
  * renders and stays a plausible guess. `android.view.ViewGroup` is what every
421
433
  * React Native `View` reports, testID containers included, so it is stated here
422
- * rather than left to the `other` fall-through in `roleOf`.
434
+ * rather than left to the `other` fall-through in `roleOf`. A password field is
435
+ * an `EditText` too. agent-device 0.20.10 parses the tree's `password` flag but
436
+ * does not emit it, so once it does, map it to `secure-text-field` here.
423
437
  */
424
438
  const ANDROID_ROLES = {
425
439
  "android.widget.Button": "button",
@@ -598,6 +612,56 @@ function matchedText(node, query) {
598
612
  function absorbAncestors(matched, query) {
599
613
  return matched.filter((candidate) => !matched.some((other) => other !== candidate && isDescendant(other, candidate) && matchedText(other, query) === matchedText(candidate, query)));
600
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
+ }
601
665
  function isDescendant(node, ancestor) {
602
666
  for (let walk = node.parent; walk !== null; walk = walk.parent) if (walk === ancestor) return true;
603
667
  return false;
@@ -674,6 +738,7 @@ function renderTitle(record) {
674
738
  case "long-press": return `longPress ${describeQuery(record.query)} for ${String(record.durationMs)}ms`;
675
739
  case "fill": return `fill ${describeQuery(record.query)}`;
676
740
  case "typed": return renderTyped(record.typed);
741
+ case "retarget": return `retarget to ${record.target}`;
677
742
  case "scroll": return `scroll ${record.direction}`;
678
743
  case "scroll-into-view": return `scrollIntoView ${describeQuery(record.query)}`;
679
744
  case "relaunch": return `relaunch ${record.app}`;
@@ -1295,10 +1360,14 @@ function perform(session, sink, record, options, dispatch, write) {
1295
1360
  try {
1296
1361
  settled = await dispatch(device, pin(screen, resolution.node), deadline - Date.now());
1297
1362
  } catch (error) {
1298
- if (failureOf(error)?.kind !== "stale-ref" || retriedStaleRef) throw error;
1299
- retriedStaleRef = true;
1300
- screen = await device.capture();
1301
- 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());
1302
1371
  }
1303
1372
  attempts += 1;
1304
1373
  if (!settled.settled) sink.note("settle", `${renderTitle(record)} finished before the screen went quiet`);
@@ -1384,6 +1453,38 @@ function reportingScrolls(device, sink) {
1384
1453
  }), () => device.scroll(direction, budgetMs))
1385
1454
  };
1386
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
+ }
1387
1488
  function ambiguous(locator, nodes, screen) {
1388
1489
  return new TouchpressError({
1389
1490
  kind: "strict-mode",
@@ -1393,27 +1494,37 @@ function ambiguous(locator, nodes, screen) {
1393
1494
  });
1394
1495
  }
1395
1496
  function confirmationOf(role, write) {
1497
+ const length = write.text.length;
1396
1498
  if (role === "secure-text-field") return {
1397
1499
  kind: "mask",
1398
- length: write.text.length
1500
+ length
1399
1501
  };
1400
1502
  const value = normalizeText(write.text);
1401
1503
  return write.secret ? {
1402
1504
  kind: "secret",
1403
- value
1505
+ value,
1506
+ length
1404
1507
  } : {
1405
1508
  kind: "open",
1406
- value
1509
+ value,
1510
+ length
1407
1511
  };
1408
1512
  }
1409
1513
  function holds(confirmation, actual) {
1410
1514
  switch (confirmation.kind) {
1411
- case "mask": return actual.length === confirmation.length && new Set(actual).size <= 1;
1515
+ case "mask": return isMask(actual, confirmation.length);
1412
1516
  case "secret":
1413
- case "open": return actual === confirmation.value;
1517
+ case "open": return actual === confirmation.value || isMask(actual, confirmation.length);
1414
1518
  default: throw new Error(`unhandled confirmation ${JSON.stringify(confirmation)}`);
1415
1519
  }
1416
1520
  }
1521
+ /**
1522
+ * Requiring one repeated character stops a placeholder of the right length, which
1523
+ * is what an untouched password field reports, from passing as a landed write.
1524
+ */
1525
+ function isMask(actual, length) {
1526
+ return actual.length === length && new Set(actual).size <= 1;
1527
+ }
1417
1528
  function expectedOf(confirmation) {
1418
1529
  switch (confirmation.kind) {
1419
1530
  case "mask": return {
@@ -1669,6 +1780,10 @@ function classifyError(error) {
1669
1780
  kind: "stale-ref",
1670
1781
  detail: message
1671
1782
  };
1783
+ if (details["reason"] === "covered_by_interactive_descendants") return {
1784
+ kind: "covered",
1785
+ detail: message
1786
+ };
1672
1787
  if (/timed out|timeout/i.test(message)) return {
1673
1788
  kind: "timeout",
1674
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.1",
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": {