react-native-tapline 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/actions.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type Entry, type Snapshot } from './inspect';
2
2
  import { type ElementSource } from './source';
3
3
  import { type AuditType } from './checks';
4
+ import { type UxAction, type UxCheckName, type UxKind } from './uxcheck';
4
5
  import { type Fiber } from './fiber';
5
6
  import { type Frame, type LogEntry, type TaplineElement } from './types';
6
7
  export { tree } from './inspect';
@@ -60,7 +61,7 @@ export declare function labelOf(info: {
60
61
  /** What the caller means to do; a point target then picks only an element that does it. */
61
62
  type ActionHint = 'press' | 'longPress' | 'toggle' | 'scroll' | 'type';
62
63
  /** Resolve a protocol target to exactly one element, or throw not_found / ambiguous. */
63
- export declare function resolveTarget(raw: unknown, hint?: ActionHint): Promise<Resolved>;
64
+ export declare function resolveTarget(raw: unknown, hint?: ActionHint, glassOnly?: boolean): Promise<Resolved>;
64
65
  /** The nearest listed element at or above a host fiber, for naming what covers a target. */
65
66
  export declare function describeHit(snapshot: Snapshot, hit: Fiber, actor?: Entry): string;
66
67
  /** `back`: what the back button or gesture does, on the focused screen; with a sheet open, closes the sheet. */
@@ -69,6 +70,15 @@ export declare function back(rawParams: unknown): Promise<{
69
70
  closed?: 'sheet';
70
71
  dryRun?: true;
71
72
  } & SettleOutcome>;
73
+ /** What happened to a double tap's second tap, and how long after the first it came. */
74
+ export type PressSecond = {
75
+ done: true;
76
+ afterMs: number;
77
+ } | {
78
+ done: false;
79
+ reason: string;
80
+ afterMs: number;
81
+ };
72
82
  /**
73
83
  * What `press` and `longPress` answer: the element, `closedKeyboard: true` when the keyboard was
74
84
  * closed first, and `forced` (the reason word) when `force` let through what the checks refuse.
@@ -86,6 +96,8 @@ export type PressResult = {
86
96
  x: number;
87
97
  y: number;
88
98
  };
99
+ /** With `again`: the second tap of a double tap. */
100
+ second?: PressSecond;
89
101
  } & SettleOutcome;
90
102
  export declare function press(params: unknown): Promise<PressResult>;
91
103
  export declare function longPress(params: unknown): Promise<PressResult>;
@@ -142,6 +154,28 @@ export type ScrollResult = {
142
154
  dryRun?: true;
143
155
  } & SettleOutcome;
144
156
  export declare function scroll(rawParams: unknown): Promise<ScrollResult>;
157
+ interface ScrollState {
158
+ /** The real content offset; negative at rest when the system adds an inset. */
159
+ offset: {
160
+ x: number;
161
+ y: number;
162
+ };
163
+ /** The furthest offset a scrollTo can reach. */
164
+ max: {
165
+ x: number;
166
+ y: number;
167
+ };
168
+ visible: {
169
+ x: number;
170
+ y: number;
171
+ };
172
+ }
173
+ /**
174
+ * The scroll view's real offset, from where its content container sits inside it. On the new
175
+ * architecture `measureInWindow` includes the content offset, so scroll frame minus content frame
176
+ * is the offset. Null when either can't be measured (old architecture, nothing laid out).
177
+ */
178
+ export declare function measureScrollState(entry: Entry): Promise<ScrollState | null>;
145
179
  export declare function toggle(rawParams: unknown): Promise<{
146
180
  element: TaplineElement;
147
181
  value: boolean;
@@ -190,6 +224,25 @@ export declare function audit(rawParams: unknown): Promise<{
190
224
  issues: AuditIssue[];
191
225
  checked: AuditType[];
192
226
  }>;
227
+ export interface UxIssue {
228
+ kind: UxKind;
229
+ element: TaplineElement | null;
230
+ where?: AuditWhere;
231
+ problem: string;
232
+ basis: string;
233
+ at?: {
234
+ file: string;
235
+ line: number;
236
+ direct: boolean;
237
+ };
238
+ }
239
+ /** `ux_check {wayBack?, earlier?}` (AGENT-TOOLS 1.12 §17.3): the screen against the usability basics. */
240
+ export declare function uxCheck(rawParams: unknown): Promise<{
241
+ issues: UxIssue[];
242
+ checked: UxCheckName[];
243
+ choices: number;
244
+ actions: UxAction[];
245
+ }>;
193
246
  export declare function getLogs(rawParams: unknown): Promise<{
194
247
  entries: LogEntry[];
195
248
  }>;
package/dist/actions.js CHANGED
@@ -15,10 +15,12 @@ exports.longPress = longPress;
15
15
  exports.setKeyboardControl = setKeyboardControl;
16
16
  exports.typeText = typeText;
17
17
  exports.scroll = scroll;
18
+ exports.measureScrollState = measureScrollState;
18
19
  exports.toggle = toggle;
19
20
  exports.openURL = openURL;
20
21
  exports.inspect = inspect;
21
22
  exports.audit = audit;
23
+ exports.uxCheck = uxCheck;
22
24
  exports.getLogs = getLogs;
23
25
  exports.reload = reload;
24
26
  exports.screenshotAvailable = screenshotAvailable;
@@ -30,7 +32,9 @@ const native_1 = require("./native");
30
32
  const inspect_1 = require("./inspect");
31
33
  const source_1 = require("./source");
32
34
  const closest_1 = require("./closest");
35
+ const glassclip_1 = require("./glassclip");
33
36
  const checks_1 = require("./checks");
37
+ const uxcheck_1 = require("./uxcheck");
34
38
  const alerts_1 = require("./alerts");
35
39
  const fiber_1 = require("./fiber");
36
40
  const logs_1 = require("./logs");
@@ -487,7 +491,7 @@ function inFrontLayer(matches) {
487
491
  return inAlert.length > 0 ? inAlert : matches;
488
492
  }
489
493
  /** Resolve a protocol target to exactly one element, or throw not_found / ambiguous. */
490
- async function resolveTarget(raw, hint) {
494
+ async function resolveTarget(raw, hint, glassOnly = false) {
491
495
  const target = parseTarget(raw);
492
496
  const snapshot = (0, inspect_1.takeSnapshot)();
493
497
  if (target.kind === 'ref') {
@@ -531,7 +535,14 @@ async function resolveTarget(raw, hint) {
531
535
  }
532
536
  return { snapshot, entry: hit };
533
537
  }
534
- const matches = inFrontLayer(matchEntries(snapshot, target));
538
+ let matches = inFrontLayer(matchEntries(snapshot, target));
539
+ // A journey in a user's eyes (§17.1): visible text matches only what shows on the glass, not what a scroll view
540
+ // clips or the keyboard covers (1.5 review, finding 11).
541
+ if (glassOnly && target.kind === 'text' && matches.length > 0) {
542
+ const scrollers = await (0, glassclip_1.measureScrollers)(snapshot);
543
+ const frames = await Promise.all(matches.map((entry) => (0, inspect_1.measureFiber)(entry.node.fiber)));
544
+ matches = matches.filter((entry, i) => (0, glassclip_1.visiblePartOnGlass)(snapshot, entry, frames[i] ?? null, scrollers) !== null);
545
+ }
535
546
  if (matches.length === 0)
536
547
  await refuseBehindSheet(snapshot, target);
537
548
  if (target.index !== undefined) {
@@ -935,7 +946,8 @@ function focusInput(entry, frame) {
935
946
  }
936
947
  async function pressLike(kind, rawParams) {
937
948
  const params = paramsOf(rawParams);
938
- const { snapshot, entry } = await resolveTarget(params.target, kind);
949
+ const afterMs = kind === 'press' ? againAfter(params.again) : undefined;
950
+ const { snapshot, entry } = await resolveTarget(params.target, kind, params.noScroll === true);
939
951
  if (entry.tab) {
940
952
  if (kind === 'longPress')
941
953
  throw notActionable('no_action', `${describe(entry)} has no long-press action`);
@@ -977,7 +989,7 @@ async function pressLike(kind, rawParams) {
977
989
  if (params.force !== true) {
978
990
  // What doesn't depend on where the element is comes first: an open alert is answered before anything scrolls.
979
991
  assertNoBlockingAlert(planned.actor);
980
- reach = await scrollIntoReach(snapshot, planned.actor, params.dryRun === true);
992
+ reach = await scrollIntoReach(snapshot, planned.actor, params.dryRun === true, params.noScroll === true);
981
993
  if (reach.scrolled) {
982
994
  const again = await reresolve(planned.actor, kind, params.target);
983
995
  current = again.snapshot;
@@ -1004,7 +1016,61 @@ async function pressLike(kind, rawParams) {
1004
1016
  if (own)
1005
1017
  callHandler('onPress', own, [pressEvent(actor.node.fiber, frame)]);
1006
1018
  }
1007
- return { element, ...(reach.scrolled ? { scrolled: true } : {}), ...keyboardNote(closedKeyboard), ...forced, ...(await settleOutcome()) };
1019
+ const second = afterMs === undefined ? undefined : await pressAgain(actor, params, afterMs);
1020
+ return {
1021
+ element,
1022
+ ...(reach.scrolled ? { scrolled: true } : {}),
1023
+ ...keyboardNote(closedKeyboard),
1024
+ ...forced,
1025
+ ...(second !== undefined ? { second } : {}),
1026
+ ...(await settleOutcome()),
1027
+ };
1028
+ }
1029
+ /** `again`: a second tap this many milliseconds after the first (a stress double tap, AGENT-TOOLS 1.12 §17.4). */
1030
+ function againAfter(raw) {
1031
+ if (raw === undefined)
1032
+ return undefined;
1033
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 20 || raw > 1000) {
1034
+ throw badParams('again must be milliseconds from 20 to 1000');
1035
+ }
1036
+ return Math.round(raw);
1037
+ }
1038
+ /**
1039
+ * The second tap of a double tap, made `afterMs` after the first on whatever is there by then, as a finger would
1040
+ * land: the app may have disabled or removed the control (that is the guard against a double submit working).
1041
+ * It goes through the same checks as a tap; a refusal is the answer's `second`, never an error.
1042
+ */
1043
+ async function pressAgain(first, params, afterMs) {
1044
+ const started = Date.now();
1045
+ await new Promise((resolve) => setTimeout(resolve, afterMs));
1046
+ try {
1047
+ const again = await reresolve(first, 'press', params.target, false);
1048
+ const found = findActionable(again.snapshot, again.entry, 'press', false);
1049
+ if (!found || found.info.disabled || again.entry.info.disabled) {
1050
+ return { done: false, reason: found ? 'disabled' : 'no_action', afterMs: Date.now() - started };
1051
+ }
1052
+ // The second tap is refused as `changed` when the id, label or text is no longer what the first tap found: a
1053
+ // control that renames itself ("Added 1") and a list row that was recycled are both another element now.
1054
+ if (!sameTarget(first, again.entry))
1055
+ return { done: false, reason: 'changed', afterMs: Date.now() - started };
1056
+ const handler = (0, inspect_1.findHandler)(found.node, 'onPress');
1057
+ if (!handler)
1058
+ return { done: false, reason: 'no_action', afterMs: Date.now() - started };
1059
+ const { frame } = await elementFor(found);
1060
+ if (params.force !== true)
1061
+ await assertReachable(again.snapshot, found, frame);
1062
+ const at = Date.now() - started;
1063
+ callHandler('onPress', handler, [pressEvent(found.node.fiber, frame)]);
1064
+ return { done: true, afterMs: at };
1065
+ }
1066
+ catch (error) {
1067
+ const reason = error instanceof types_1.TaplineError
1068
+ ? error.code === 'not_actionable'
1069
+ ? (error.message.split(':')[0] ?? error.code)
1070
+ : error.code
1071
+ : 'internal';
1072
+ return { done: false, reason, afterMs: Date.now() - started };
1073
+ }
1008
1074
  }
1009
1075
  /** Where a finger would land: the middle of the element (points, rounded). */
1010
1076
  function pointOf(frame) {
@@ -1129,9 +1195,9 @@ async function moveFocusAboveKeyboard(entry) {
1129
1195
  function keyboardNote(closed) {
1130
1196
  return closed ? { closedKeyboard: true } : {};
1131
1197
  }
1132
- async function resolveInput(snapshot, rawTarget) {
1198
+ async function resolveInput(snapshot, rawTarget, glassOnly = false) {
1133
1199
  if (rawTarget !== undefined && rawTarget !== null) {
1134
- const { snapshot: snap, entry } = await resolveTarget(rawTarget, 'type');
1200
+ const { snapshot: snap, entry } = await resolveTarget(rawTarget, 'type', glassOnly);
1135
1201
  const input = findActionable(snap, entry, 'type', true);
1136
1202
  if (!input)
1137
1203
  throw notActionable('no_action', `${describe(entry)} is not an input`);
@@ -1178,7 +1244,7 @@ async function typeText(rawParams) {
1178
1244
  throw badParams('text is required (or pass submit: true)');
1179
1245
  // Deleting from the end edits what is there, so it never replaces the whole text.
1180
1246
  const replace = params.replace !== false && !deleting;
1181
- let { snapshot, entry } = await resolveInput(null, params.target);
1247
+ let { snapshot, entry } = await resolveInput(null, params.target, params.noScroll === true);
1182
1248
  if (entry.info.disabled)
1183
1249
  throw notActionable('disabled', `${describe(entry)} is not editable; ${ENABLE_FIRST}.`);
1184
1250
  // Off screen, in the tree: scroll it into view first (AGENT-TOOLS 1.12 §16.2; the policy ran before this
@@ -1187,7 +1253,7 @@ async function typeText(rawParams) {
1187
1253
  if (params.force !== true)
1188
1254
  assertNoBlockingAlert(entry);
1189
1255
  if (params.force !== true && !ownsInstance(entry, keyboard.focusedInput())) {
1190
- reach = await scrollIntoReach(snapshot, entry, params.dryRun === true);
1256
+ reach = await scrollIntoReach(snapshot, entry, params.dryRun === true, params.noScroll === true);
1191
1257
  if (reach.scrolled) {
1192
1258
  let again;
1193
1259
  try {
@@ -1528,10 +1594,17 @@ function visiblePart(frame, screen) {
1528
1594
  * alert, under a bar) run afterwards, as for any element. Refused as `off_screen`, with the reason, when
1529
1595
  * no scroll can bring it on screen. `dry` predicts the point after the scroll and scrolls nothing.
1530
1596
  */
1531
- async function scrollIntoReach(snapshot, entry, dry) {
1597
+ async function scrollIntoReach(snapshot, entry, dry, noScroll = false) {
1532
1598
  const frame = await (0, inspect_1.measureFiber)(entry.node.fiber);
1533
1599
  if (frame === null)
1534
1600
  return {};
1601
+ // A journey in a user's eyes (`noScroll`, §17.1): nothing scrolls by itself. An element wholly off screen is refused.
1602
+ if (noScroll) {
1603
+ // On the glass, not just on screen: a scroll view clips it, the keyboard covers it (1.5 review, finding 11).
1604
+ if ((0, glassclip_1.visiblePartOnGlass)(snapshot, entry, frame, await (0, glassclip_1.measureScrollers)(snapshot)) !== null)
1605
+ return {};
1606
+ throw notActionable('off_screen', `${describe(entry)} is off screen; in a journey nothing scrolls by itself: scroll the way a person would, then try again.`);
1607
+ }
1535
1608
  // Partly on screen (over the edge, or under a bar): scrolled clear too, with one attempt; if that
1536
1609
  // can't be done the action goes on as before and the touch checks decide. Never refused for this.
1537
1610
  const partial = visiblePart(frame, snapshot.screen) !== null;
@@ -1627,7 +1700,7 @@ function refindTarget(entry, original) {
1627
1700
  * The same element after a scroll, from a fresh snapshot (the list may have laid out other rows on the
1628
1701
  * way). When a list unmounted the row, the ref is gone: it is found again by the original target.
1629
1702
  */
1630
- async function reresolve(entry, hint, original) {
1703
+ async function reresolve(entry, hint, original, checkSame = true) {
1631
1704
  let found;
1632
1705
  try {
1633
1706
  found = await resolveTarget({ ref: (0, inspect_1.refFor)(entry.node.fiber) }, hint);
@@ -1640,21 +1713,24 @@ async function reresolve(entry, hint, original) {
1640
1713
  throw error;
1641
1714
  found = await resolveTarget(again, hint);
1642
1715
  }
1643
- assertSameTarget(entry, found.entry);
1716
+ if (checkSame)
1717
+ assertSameTarget(entry, found.entry);
1644
1718
  return found;
1645
1719
  }
1720
+ /** The same element by id, label and text, each on its own (a row can keep its id and label while its text changes). */
1721
+ function sameTarget(before, after) {
1722
+ return before.info.testID === after.info.testID && before.info.label === after.info.label && before.info.text === after.info.text;
1723
+ }
1646
1724
  /**
1647
1725
  * After a scroll the element found again must still be the one the safety check saw: a list that recycles its rows
1648
1726
  * can hand the same component another row ("Continue" becomes "Delete account"). A changed id, label or text is
1649
1727
  * refused before anything is touched (Codex review of 1.4.0).
1650
1728
  */
1651
1729
  function assertSameTarget(before, after) {
1652
- // id, label and text each on their own: a row can keep its id and accessibility label while its text changes
1730
+ if (sameTarget(before, after))
1731
+ return;
1653
1732
  const was = { id: before.info.testID, name: labelOf(before.info) };
1654
1733
  const now = { id: after.info.testID, name: labelOf(after.info) };
1655
- const same = was.id === now.id && before.info.label === after.info.label && before.info.text === after.info.text;
1656
- if (same)
1657
- return;
1658
1734
  if (was.name === now.name) {
1659
1735
  was.name = before.info.text;
1660
1736
  now.name = after.info.text;
@@ -1887,7 +1963,7 @@ async function toggle(rawParams) {
1887
1963
  const params = paramsOf(rawParams);
1888
1964
  if (params.value !== undefined && typeof params.value !== 'boolean')
1889
1965
  throw badParams('value must be a boolean');
1890
- const first = await resolveTarget(params.target, 'toggle');
1966
+ const first = await resolveTarget(params.target, 'toggle', params.noScroll === true);
1891
1967
  const plan = (snap, picked) => {
1892
1968
  const found = findActionable(snap, picked, 'toggle', true);
1893
1969
  if (!found)
@@ -1905,7 +1981,7 @@ async function toggle(rawParams) {
1905
1981
  let reach = {};
1906
1982
  if (params.force !== true) {
1907
1983
  assertNoBlockingAlert(planned.control);
1908
- reach = await scrollIntoReach(snapshot, planned.control, params.dryRun === true);
1984
+ reach = await scrollIntoReach(snapshot, planned.control, params.dryRun === true, params.noScroll === true);
1909
1985
  if (reach.scrolled) {
1910
1986
  const again = await reresolve(planned.control, 'toggle', params.target);
1911
1987
  snapshot = again.snapshot;
@@ -2036,6 +2112,68 @@ async function audit(rawParams) {
2036
2112
  }
2037
2113
  return { issues, checked: [...types] };
2038
2114
  }
2115
+ /** `ux_check {wayBack?, earlier?}` (AGENT-TOOLS 1.12 §17.3): the screen against the usability basics. */
2116
+ async function uxCheck(rawParams) {
2117
+ const params = paramsOf(rawParams);
2118
+ if (params.wayBack !== undefined && typeof params.wayBack !== 'boolean')
2119
+ throw badParams('wayBack must be true or false');
2120
+ const earlierRaw = params.earlier;
2121
+ let earlier;
2122
+ if (earlierRaw !== undefined && earlierRaw !== null) {
2123
+ if (!Array.isArray(earlierRaw))
2124
+ throw badParams('earlier must be a list of {label, id?}');
2125
+ earlier = [];
2126
+ for (const item of earlierRaw) {
2127
+ if (!(0, types_1.isRecord)(item) || typeof item.label !== 'string')
2128
+ throw badParams('earlier must be a list of {label, id?}');
2129
+ earlier.push({ label: item.label, ...(typeof item.id === 'string' && item.id !== '' ? { id: item.id } : {}) });
2130
+ }
2131
+ }
2132
+ if (params.glass !== undefined && typeof params.glass !== 'boolean')
2133
+ throw badParams('glass must be true or false');
2134
+ const result = await (0, uxcheck_1.uxScreen)({
2135
+ ...(params.glass === true ? { glass: true } : {}),
2136
+ ...(params.wayBack === true ? { wayBack: true } : {}),
2137
+ ...(earlier !== undefined ? { earlier } : {}),
2138
+ });
2139
+ let sources = [];
2140
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
2141
+ const fibers = result.findings.flatMap((f) => (f.entry !== null ? [f.entry.node.fiber] : []));
2142
+ try {
2143
+ sources = fibers.length > 0 ? await (0, source_1.sourcesOf)(fibers) : [];
2144
+ }
2145
+ catch {
2146
+ sources = []; // Metro unreachable: the report still stands, without lines
2147
+ }
2148
+ }
2149
+ let next = 0;
2150
+ const issues = [];
2151
+ for (const finding of result.findings) {
2152
+ const source = finding.entry !== null ? sources[next++] : undefined;
2153
+ let element = null;
2154
+ if (finding.entry !== null) {
2155
+ try {
2156
+ element = (await elementFor(finding.entry, finding.frame)).element;
2157
+ }
2158
+ catch {
2159
+ element = null;
2160
+ }
2161
+ }
2162
+ const where = element === null && finding.entry !== null
2163
+ ? whereOf({ type: 'description', entry: finding.entry, frame: finding.frame, problem: '', fix: '' })
2164
+ : null;
2165
+ const location = source?.location ?? null;
2166
+ issues.push({
2167
+ kind: finding.kind,
2168
+ element,
2169
+ ...(where !== null ? { where } : {}),
2170
+ problem: finding.problem,
2171
+ basis: finding.basis,
2172
+ ...(location !== null ? { at: { file: location.file, line: location.line, direct: location.direct } } : {}),
2173
+ });
2174
+ }
2175
+ return { issues, checked: result.checked, choices: result.choices, actions: result.actions };
2176
+ }
2039
2177
  async function getLogs(rawParams) {
2040
2178
  const params = paramsOf(rawParams);
2041
2179
  if (params.since !== undefined && typeof params.since !== 'number')