react-native-tapline 1.4.0 → 1.6.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.
Files changed (77) hide show
  1. package/TaplineNative.podspec +3 -1
  2. package/dist/actions.d.ts +75 -2
  3. package/dist/actions.js +333 -25
  4. package/dist/actions.js.map +1 -1
  5. package/dist/alerts.d.ts +2 -0
  6. package/dist/alerts.js +5 -0
  7. package/dist/alerts.js.map +1 -1
  8. package/dist/anim.d.ts +25 -0
  9. package/dist/anim.js +311 -0
  10. package/dist/anim.js.map +1 -0
  11. package/dist/appinfo.d.ts +12 -0
  12. package/dist/appinfo.js +78 -0
  13. package/dist/appinfo.js.map +1 -0
  14. package/dist/checks.d.ts +43 -1
  15. package/dist/checks.js +17 -5
  16. package/dist/checks.js.map +1 -1
  17. package/dist/client.d.ts +2 -0
  18. package/dist/client.js +28 -2
  19. package/dist/client.js.map +1 -1
  20. package/dist/commands.js +28 -0
  21. package/dist/commands.js.map +1 -1
  22. package/dist/glass.d.ts +3 -0
  23. package/dist/glass.js +81 -0
  24. package/dist/glass.js.map +1 -0
  25. package/dist/glassclip.d.ts +18 -0
  26. package/dist/glassclip.js +69 -0
  27. package/dist/glassclip.js.map +1 -0
  28. package/dist/guard.d.ts +17 -0
  29. package/dist/guard.js +44 -0
  30. package/dist/guard.js.map +1 -0
  31. package/dist/idle.d.ts +43 -0
  32. package/dist/idle.js +379 -0
  33. package/dist/idle.js.map +1 -0
  34. package/dist/index.d.ts +8 -0
  35. package/dist/index.js +6 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/inspect.d.ts +18 -1
  38. package/dist/inspect.js +48 -8
  39. package/dist/inspect.js.map +1 -1
  40. package/dist/loading.d.ts +17 -0
  41. package/dist/loading.js +96 -0
  42. package/dist/loading.js.map +1 -0
  43. package/dist/locale.js +1 -1
  44. package/dist/locale.js.map +1 -1
  45. package/dist/media.d.ts +71 -0
  46. package/dist/media.js +516 -0
  47. package/dist/media.js.map +1 -0
  48. package/dist/methods.js +14 -1
  49. package/dist/methods.js.map +1 -1
  50. package/dist/move.d.ts +40 -0
  51. package/dist/move.js +125 -0
  52. package/dist/move.js.map +1 -0
  53. package/dist/native.d.ts +5 -0
  54. package/dist/native.js.map +1 -1
  55. package/dist/network.d.ts +4 -0
  56. package/dist/network.js +49 -1
  57. package/dist/network.js.map +1 -1
  58. package/dist/place.d.ts +23 -0
  59. package/dist/place.js +66 -0
  60. package/dist/place.js.map +1 -0
  61. package/dist/placekey.d.ts +29 -0
  62. package/dist/placekey.js +134 -0
  63. package/dist/placekey.js.map +1 -0
  64. package/dist/routetree.d.ts +27 -0
  65. package/dist/routetree.js +216 -0
  66. package/dist/routetree.js.map +1 -0
  67. package/dist/types.d.ts +28 -2
  68. package/dist/types.js +5 -1
  69. package/dist/types.js.map +1 -1
  70. package/dist/uxcheck.d.ts +57 -0
  71. package/dist/uxcheck.js +222 -0
  72. package/dist/uxcheck.js.map +1 -0
  73. package/dist/wait.d.ts +5 -0
  74. package/dist/wait.js +34 -3
  75. package/dist/wait.js.map +1 -1
  76. package/ios/TaplineNativeModule.swift +61 -0
  77. package/package.json +1 -1
package/dist/actions.js CHANGED
@@ -5,6 +5,7 @@ exports.configureActions = configureActions;
5
5
  exports.onActed = onActed;
6
6
  exports.settle = settle;
7
7
  exports.settleOutcome = settleOutcome;
8
+ exports.beginAction = beginAction;
8
9
  exports.parseTarget = parseTarget;
9
10
  exports.labelOf = labelOf;
10
11
  exports.resolveTarget = resolveTarget;
@@ -15,10 +16,12 @@ exports.longPress = longPress;
15
16
  exports.setKeyboardControl = setKeyboardControl;
16
17
  exports.typeText = typeText;
17
18
  exports.scroll = scroll;
19
+ exports.measureScrollState = measureScrollState;
18
20
  exports.toggle = toggle;
19
21
  exports.openURL = openURL;
20
22
  exports.inspect = inspect;
21
23
  exports.audit = audit;
24
+ exports.uxCheck = uxCheck;
22
25
  exports.getLogs = getLogs;
23
26
  exports.reload = reload;
24
27
  exports.screenshotAvailable = screenshotAvailable;
@@ -26,15 +29,21 @@ exports.screenshot = screenshot;
26
29
  exports.swipe = swipe;
27
30
  const react_native_1 = require("react-native");
28
31
  const keyboard_1 = require("./keyboard");
32
+ const guard_1 = require("./guard");
33
+ const network_1 = require("./network");
34
+ const loading_1 = require("./loading");
29
35
  const native_1 = require("./native");
30
36
  const inspect_1 = require("./inspect");
31
37
  const source_1 = require("./source");
32
38
  const closest_1 = require("./closest");
39
+ const glassclip_1 = require("./glassclip");
33
40
  const checks_1 = require("./checks");
41
+ const uxcheck_1 = require("./uxcheck");
34
42
  const alerts_1 = require("./alerts");
35
43
  const fiber_1 = require("./fiber");
36
44
  const logs_1 = require("./logs");
37
45
  const register_1 = require("./register");
46
+ const idle_1 = require("./idle");
38
47
  const settle_1 = require("./settle");
39
48
  const types_1 = require("./types");
40
49
  var inspect_2 = require("./inspect");
@@ -126,14 +135,90 @@ async function sheetProbe() {
126
135
  }
127
136
  return null;
128
137
  }
138
+ /**
139
+ * Where `startedRequests()` stood when the action began (or when the last settle ended): a request begun after
140
+ * it is one the action started (F1).
141
+ */
142
+ let requestMark = 0;
143
+ /** An action is starting: only requests begun from now on count as its own. */
144
+ function beginAction() {
145
+ requestMark = (0, network_1.startedRequests)();
146
+ }
147
+ const FRAME_MS = 16;
148
+ /** Whether an open or closing sheet hides the app's motion from the SDK. */
149
+ function sheetPresent() {
150
+ return (0, inspect_1.openModalHosts)().length > 0 || (0, inspect_1.closingModalHosts)().length > 0;
151
+ }
152
+ /** The elements whose positions say the screen has stopped: the acting ones and the outermost. */
153
+ function watchedFibers() {
154
+ try {
155
+ const entries = (0, inspect_1.takeSnapshot)().entries;
156
+ const picked = new Map();
157
+ for (const entry of [...entries.filter((e) => e.info.actions.length > 0).slice(0, 6), ...entries.slice(0, 6)]) {
158
+ picked.set(entry.node.index, entry.node.fiber);
159
+ }
160
+ return [...picked.values()];
161
+ }
162
+ catch {
163
+ return [];
164
+ }
165
+ }
166
+ function sameFrame(a, b) {
167
+ if (a === null || b === null)
168
+ return a === b;
169
+ return Math.abs(a.x - b.x) <= 1 && Math.abs(a.y - b.y) <= 1 && Math.abs(a.width - b.width) <= 1 && Math.abs(a.height - b.height) <= 1;
170
+ }
171
+ /**
172
+ * A native push, a Lottie or a gesture-handler sheet moves without a commit or an Animated call, so after the
173
+ * idle hold the visible positions are read twice, a frame apart. Says which element moved, and whether React
174
+ * committed in between.
175
+ */
176
+ async function positionsMoved() {
177
+ const before = (0, register_1.commitRevision)();
178
+ const fibers = watchedFibers();
179
+ const read = () => Promise.all(fibers.map((fiber) => (0, inspect_1.measureFiber)(fiber).catch(() => null)));
180
+ const first = await read();
181
+ await new Promise((resolve) => setTimeout(resolve, FRAME_MS));
182
+ const second = await read();
183
+ const at = first.findIndex((frame, i) => !sameFrame(frame, second[i] ?? null));
184
+ return { moving: at >= 0 ? (fibers[at] ?? null) : null, committed: (0, register_1.commitRevision)() !== before };
185
+ }
129
186
  async function settleNow() {
130
187
  const started = Date.now();
131
188
  let capped = false;
132
189
  const onCap = () => {
133
190
  capped = true;
134
191
  };
192
+ // Know-don't-watch (off unless `knowIdle`): answer when every signal the SDK can see is idle and still.
193
+ // An open sheet is out of its sight, so that keeps today's window and position poll.
194
+ let knowSpentMs = 0;
195
+ if ((0, idle_1.isKnowIdle)() && settings.capMs > 0 && !sheetPresent()) {
196
+ const known = await (0, idle_1.waitUntilIdle)({ frames: 2, capMs: settings.capMs, requestMark });
197
+ let moving = null;
198
+ let steady = !known.usedFallback && !known.capped;
199
+ if (steady && !sheetPresent()) {
200
+ const check = await positionsMoved();
201
+ moving = check.moving;
202
+ steady = moving === null && !check.committed;
203
+ }
204
+ if (!known.usedFallback && !sheetPresent() && (steady || known.capped)) {
205
+ requestMark = (0, network_1.startedRequests)();
206
+ const settledMs = Math.max(0, Math.min(settings.capMs, Date.now() - started));
207
+ return known.capped ? { settledMs, settleCapMs: settings.capMs } : { settledMs };
208
+ }
209
+ if (known.capped)
210
+ onCap();
211
+ knowSpentMs = Math.max(0, Date.now() - started);
212
+ // Something moved without a commit: today's poll waits for it to stop.
213
+ const left = settings.capMs - knowSpentMs;
214
+ if (moving && left > 0) {
215
+ await (0, settle_1.waitUntilSteady)(() => (0, inspect_1.measureFiber)(moving), { pollMs: SHEET_POLL_MS, capMs: left, now: Date.now, onCap });
216
+ knowSpentMs = Math.max(0, Date.now() - started);
217
+ }
218
+ }
135
219
  await (0, settle_1.waitUntilStill)({
136
220
  ...settings,
221
+ capMs: settings.capMs - knowSpentMs,
137
222
  onCommit: (listener) => (0, register_1.onCommit)(() => listener()),
138
223
  now: Date.now,
139
224
  onCap,
@@ -168,6 +253,7 @@ async function settleNow() {
168
253
  }
169
254
  }
170
255
  const settledMs = Math.max(0, Math.min(settings.capMs, Date.now() - started));
256
+ requestMark = (0, network_1.startedRequests)();
171
257
  return capped && settings.capMs > 0 ? { settledMs, settleCapMs: settings.capMs } : { settledMs };
172
258
  }
173
259
  function badParams(message) {
@@ -203,6 +289,22 @@ function callHandler(name, handler, args) {
203
289
  });
204
290
  }
205
291
  }
292
+ /**
293
+ * Calls a press handler as `callHandler` does, but a throw is not an error of the action: a person's tap
294
+ * on a button whose handler throws still happened. The message goes to the logs and to the answer's facts
295
+ * (AGENT-TOOLS 1.13 §3). Returns the message, or undefined when the handler didn't throw.
296
+ */
297
+ function callHandlerCaught(name, handler, args) {
298
+ try {
299
+ callHandler(name, handler, args);
300
+ }
301
+ catch (error) {
302
+ const message = error instanceof types_1.TaplineError ? error.message.replace(/^[^:]*threw: /, '') : String(error);
303
+ console.error(`[tapline] ${name} threw: ${message}`);
304
+ return message.slice(0, 500);
305
+ }
306
+ return undefined;
307
+ }
206
308
  function parseTarget(raw) {
207
309
  if (!(0, types_1.isRecord)(raw))
208
310
  throw badParams('target must be an object such as {"testID":"login"}');
@@ -486,8 +588,38 @@ function inFrontLayer(matches) {
486
588
  const inAlert = matches.filter((entry) => inStandInAlert(entry.node.fiber));
487
589
  return inAlert.length > 0 ? inAlert : matches;
488
590
  }
489
- /** Resolve a protocol target to exactly one element, or throw not_found / ambiguous. */
490
- async function resolveTarget(raw, hint) {
591
+ /**
592
+ * Resolve a protocol target to exactly one element, or throw not_found / ambiguous. A target that isn't there while the screen
593
+ * is loading (a request in flight, a spinner) is waited for up to the settle cap before `not_found` is answered (AGENT-TOOLS
594
+ * 1.13 §18.7); the answer after the wait is made from the screen as it is then, so its closest matches are unchanged.
595
+ */
596
+ async function resolveTarget(raw, hint, glassOnly = false, loadingWait = true) {
597
+ try {
598
+ return await resolveNow(raw, hint, glassOnly);
599
+ }
600
+ catch (error) {
601
+ if (!(error instanceof types_1.TaplineError) || error.code !== 'not_found' || !loadingWait || !isNamedTarget(raw) || settings.capMs <= 0)
602
+ throw error;
603
+ await (0, loading_1.waitWhileLoading)({
604
+ requestMark,
605
+ capMs: settings.capMs,
606
+ // Anything but `not_found` (ambiguous, covered) means the target has come.
607
+ isThere: () => resolveNow(raw, hint, glassOnly).then(() => true, (again) => !(again instanceof types_1.TaplineError && again.code === 'not_found')),
608
+ });
609
+ return resolveNow(raw, hint, glassOnly);
610
+ }
611
+ }
612
+ /** A target by id, label or text: the kinds a screen can still be loading. A ref or a point is never waited for. */
613
+ function isNamedTarget(raw) {
614
+ try {
615
+ const kind = parseTarget(raw).kind;
616
+ return kind === 'testID' || kind === 'label' || kind === 'text';
617
+ }
618
+ catch {
619
+ return false;
620
+ }
621
+ }
622
+ async function resolveNow(raw, hint, glassOnly = false) {
491
623
  const target = parseTarget(raw);
492
624
  const snapshot = (0, inspect_1.takeSnapshot)();
493
625
  if (target.kind === 'ref') {
@@ -531,7 +663,14 @@ async function resolveTarget(raw, hint) {
531
663
  }
532
664
  return { snapshot, entry: hit };
533
665
  }
534
- const matches = inFrontLayer(matchEntries(snapshot, target));
666
+ let matches = inFrontLayer(matchEntries(snapshot, target));
667
+ // A journey in a user's eyes (§17.1): visible text matches only what shows on the glass, not what a scroll view
668
+ // clips or the keyboard covers (1.5 review, finding 11).
669
+ if (glassOnly && target.kind === 'text' && matches.length > 0) {
670
+ const scrollers = await (0, glassclip_1.measureScrollers)(snapshot);
671
+ const frames = await Promise.all(matches.map((entry) => (0, inspect_1.measureFiber)(entry.node.fiber)));
672
+ matches = matches.filter((entry, i) => (0, glassclip_1.visiblePartOnGlass)(snapshot, entry, frames[i] ?? null, scrollers) !== null);
673
+ }
535
674
  if (matches.length === 0)
536
675
  await refuseBehindSheet(snapshot, target);
537
676
  if (target.index !== undefined) {
@@ -724,7 +863,8 @@ async function assertReachable(snapshot, actor, frame) {
724
863
  const name = describe(actor);
725
864
  if (right <= left || bottom <= top) {
726
865
  throw notActionable('off_screen', `${name} is off screen (at ${Math.round(frame.x)},${Math.round(frame.y)}; the screen is ${width}x${height}). ` +
727
- `Scroll to it first ${SCROLL_TO_TARGET_HINT}, or pass force: true to call its handler anyway.`);
866
+ 'It lies in a part of the screen that nothing scrolls (behind a fixed bar, or outside every list); look at the screen, then target something else or report it. ' +
867
+ 'Pass force: true to call its handler anyway.');
728
868
  }
729
869
  const bar = (0, inspect_1.nativeTabBar)(snapshot);
730
870
  const navBar = (0, inspect_1.nativeNavBar)(snapshot);
@@ -933,20 +1073,28 @@ function focusInput(entry, frame) {
933
1073
  callHandler(key, handler, [pressEvent(entry.node.fiber, frame)]);
934
1074
  }
935
1075
  }
1076
+ /** The guard a `move` hands to the touch it makes, never part of a plain call. */
1077
+ function recipientGuardOf(params) {
1078
+ return params.recipientGuard === undefined ? undefined : (0, guard_1.parseRecipientGuard)(params.recipientGuard);
1079
+ }
936
1080
  async function pressLike(kind, rawParams) {
937
1081
  const params = paramsOf(rawParams);
938
- const { snapshot, entry } = await resolveTarget(params.target, kind);
1082
+ const afterMs = kind === 'press' ? againAfter(params.again) : undefined;
1083
+ const guard = recipientGuardOf(params);
1084
+ const { snapshot, entry } = await resolveTarget(params.target, kind, params.noScroll === true, params.dryRun !== true);
939
1085
  if (entry.tab) {
940
1086
  if (kind === 'longPress')
941
1087
  throw notActionable('no_action', `${describe(entry)} has no long-press action`);
942
1088
  if (params.dryRun === true)
943
1089
  return { element: (0, inspect_1.toElement)(entry, null), dryRun: true, settledMs: 0 };
1090
+ (0, guard_1.assertRecipient)(guard, entry);
944
1091
  selectTab(snapshot, entry.tab);
945
1092
  return { element: (0, inspect_1.toElement)(entry, null), ...keyboardNote(closeKeyboardFor(entry)), ...(await settleOutcome()) };
946
1093
  }
947
1094
  if (entry.navBack) {
948
1095
  if (params.dryRun === true)
949
1096
  return { element: (0, inspect_1.toElement)(entry, null), dryRun: true, settledMs: 0 };
1097
+ (0, guard_1.assertRecipient)(guard, entry);
950
1098
  const { route: _route, ...outcome } = await back({});
951
1099
  return { element: (0, inspect_1.toElement)(entry, null), ...outcome };
952
1100
  }
@@ -971,21 +1119,24 @@ async function pressLike(kind, rawParams) {
971
1119
  return { actor: found, handler: own, focusing: isInput };
972
1120
  };
973
1121
  let planned = plan(snapshot, entry);
1122
+ // The element the agent named; a scroll may find it again.
1123
+ let touched = entry;
974
1124
  // Off screen, in the tree: scroll it into view first (AGENT-TOOLS 1.12 §16.2). The policy ran before this call.
975
1125
  let reach = {};
976
1126
  let current = snapshot;
977
1127
  if (params.force !== true) {
978
1128
  // What doesn't depend on where the element is comes first: an open alert is answered before anything scrolls.
979
1129
  assertNoBlockingAlert(planned.actor);
980
- reach = await scrollIntoReach(snapshot, planned.actor, params.dryRun === true);
1130
+ reach = await scrollIntoReach(snapshot, planned.actor, params.dryRun === true, params.noScroll === true);
981
1131
  if (reach.scrolled) {
982
1132
  const again = await reresolve(planned.actor, kind, params.target);
983
1133
  current = again.snapshot;
1134
+ touched = again.entry;
984
1135
  planned = plan(again.snapshot, again.entry);
985
1136
  await assertRefoundOnScreen(current, planned.actor, reach);
986
1137
  }
987
1138
  }
988
- const { actor, handler, focusing } = planned;
1139
+ const { actor, handler } = planned;
989
1140
  const { element, frame } = await elementFor(actor);
990
1141
  if (reach.scrolls)
991
1142
  return { element, dryRun: true, scrolls: true, ...(reach.point ? { point: reach.point } : {}), settledMs: 0 };
@@ -993,18 +1144,88 @@ async function pressLike(kind, rawParams) {
993
1144
  // A dry run (AGENT-TOOLS 1.8 §11.4): every check ran; nothing is touched.
994
1145
  if (params.dryRun === true)
995
1146
  return { element, dryRun: true, ...pointOf(frame), ...forced, settledMs: 0 };
1147
+ // The policy checked this very element: refuse when the one that takes the touch is another now (§18.4).
1148
+ (0, guard_1.assertRecipient)(guard, actor);
996
1149
  const closedKeyboard = closeKeyboardFor(actor);
1150
+ const requestsBefore = (0, network_1.startedRequests)();
1151
+ let threw;
1152
+ // An input with no onPress of its own was only focused: no handler ran, so none is reported.
1153
+ let handled = handler !== null;
997
1154
  if (handler)
998
- callHandler(handlerName, handler, [pressEvent(actor.node.fiber, frame)]);
1155
+ threw = callHandlerCaught(handlerName, handler, [pressEvent(actor.node.fiber, frame)]);
999
1156
  else {
1000
1157
  // A finger focuses the input and also fires the input's own onPress (not an ancestor's: the
1001
1158
  // input takes the touch).
1002
1159
  focusInput(actor, frame);
1003
1160
  const own = appHandler(actor, 'onPress');
1004
- if (own)
1005
- callHandler('onPress', own, [pressEvent(actor.node.fiber, frame)]);
1161
+ if (own) {
1162
+ handled = true;
1163
+ threw = callHandlerCaught('onPress', own, [pressEvent(actor.node.fiber, frame)]);
1164
+ }
1165
+ }
1166
+ // Whose handler it was: the touched element's own, or an ancestor's that took the touch (§3).
1167
+ const facts = !handled ? undefined : (0, fiber_1.sameFiber)(actor.node.fiber, touched.node.fiber)
1168
+ ? { kind: 'own', ...(threw !== undefined ? { threw } : {}) }
1169
+ : { kind: 'parent', element, ...(threw !== undefined ? { threw } : {}) };
1170
+ const second = afterMs === undefined ? undefined : await pressAgain(actor, params, afterMs);
1171
+ const settled = await settleOutcome();
1172
+ return {
1173
+ ...(facts !== undefined ? { handler: facts } : {}),
1174
+ startedRequests: Math.max(0, (0, network_1.startedRequests)() - requestsBefore),
1175
+ element,
1176
+ ...(reach.scrolled ? { scrolled: true } : {}),
1177
+ ...keyboardNote(closedKeyboard),
1178
+ ...forced,
1179
+ ...(second !== undefined ? { second } : {}),
1180
+ ...settled,
1181
+ };
1182
+ }
1183
+ /** `again`: a second tap this many milliseconds after the first (a stress double tap, AGENT-TOOLS 1.12 §17.4). */
1184
+ function againAfter(raw) {
1185
+ if (raw === undefined)
1186
+ return undefined;
1187
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 20 || raw > 1000) {
1188
+ throw badParams('again must be milliseconds from 20 to 1000');
1189
+ }
1190
+ return Math.round(raw);
1191
+ }
1192
+ /**
1193
+ * The second tap of a double tap, made `afterMs` after the first on whatever is there by then, as a finger would
1194
+ * land: the app may have disabled or removed the control (that is the guard against a double submit working).
1195
+ * It goes through the same checks as a tap; a refusal is the answer's `second`, never an error.
1196
+ */
1197
+ async function pressAgain(first, params, afterMs) {
1198
+ const started = Date.now();
1199
+ await new Promise((resolve) => setTimeout(resolve, afterMs));
1200
+ try {
1201
+ const again = await reresolve(first, 'press', params.target, false);
1202
+ const found = findActionable(again.snapshot, again.entry, 'press', false);
1203
+ if (!found || found.info.disabled || again.entry.info.disabled) {
1204
+ return { done: false, reason: found ? 'disabled' : 'no_action', afterMs: Date.now() - started };
1205
+ }
1206
+ // The second tap is refused as `changed` when the id, label or text is no longer what the first tap found: a
1207
+ // control that renames itself ("Added 1") and a list row that was recycled are both another element now.
1208
+ if (!sameTarget(first, again.entry))
1209
+ return { done: false, reason: 'changed', afterMs: Date.now() - started };
1210
+ const handler = (0, inspect_1.findHandler)(found.node, 'onPress');
1211
+ if (!handler)
1212
+ return { done: false, reason: 'no_action', afterMs: Date.now() - started };
1213
+ const { frame } = await elementFor(found);
1214
+ if (params.force !== true)
1215
+ await assertReachable(again.snapshot, found, frame);
1216
+ const at = Date.now() - started;
1217
+ // A handler that throws on the second tap was reached: that is the app's failure, not a tap that didn't land.
1218
+ const threw = callHandlerCaught('onPress', handler, [pressEvent(found.node.fiber, frame)]);
1219
+ return threw === undefined ? { done: true, afterMs: at } : { done: true, afterMs: at, threw };
1220
+ }
1221
+ catch (error) {
1222
+ const reason = error instanceof types_1.TaplineError
1223
+ ? error.code === 'not_actionable'
1224
+ ? (error.message.split(':')[0] ?? error.code)
1225
+ : error.code
1226
+ : 'internal';
1227
+ return { done: false, reason, afterMs: Date.now() - started };
1006
1228
  }
1007
- return { element, ...(reach.scrolled ? { scrolled: true } : {}), ...keyboardNote(closedKeyboard), ...forced, ...(await settleOutcome()) };
1008
1229
  }
1009
1230
  /** Where a finger would land: the middle of the element (points, rounded). */
1010
1231
  function pointOf(frame) {
@@ -1129,9 +1350,9 @@ async function moveFocusAboveKeyboard(entry) {
1129
1350
  function keyboardNote(closed) {
1130
1351
  return closed ? { closedKeyboard: true } : {};
1131
1352
  }
1132
- async function resolveInput(snapshot, rawTarget) {
1353
+ async function resolveInput(snapshot, rawTarget, glassOnly = false, loadingWait = true) {
1133
1354
  if (rawTarget !== undefined && rawTarget !== null) {
1134
- const { snapshot: snap, entry } = await resolveTarget(rawTarget, 'type');
1355
+ const { snapshot: snap, entry } = await resolveTarget(rawTarget, 'type', glassOnly, loadingWait);
1135
1356
  const input = findActionable(snap, entry, 'type', true);
1136
1357
  if (!input)
1137
1358
  throw notActionable('no_action', `${describe(entry)} is not an input`);
@@ -1164,6 +1385,12 @@ function setNativeText(entry, text) {
1164
1385
  }
1165
1386
  }
1166
1387
  }
1388
+ function assertFieldKind(entry, kind) {
1389
+ const isSecure = (0, inspect_1.isSecureInput)(entry.node);
1390
+ if (kind !== undefined && (kind === 'secure') !== isSecure) {
1391
+ throw new types_1.TaplineError('bad_params', `${describe(entry)} is ${isSecure ? 'a secure field' : 'not a secure field'}, not a ${kind} field`, undefined, undefined, { secure: isSecure });
1392
+ }
1393
+ }
1167
1394
  async function typeText(rawParams) {
1168
1395
  const params = paramsOf(rawParams);
1169
1396
  const submit = params.submit === true;
@@ -1178,7 +1405,13 @@ async function typeText(rawParams) {
1178
1405
  throw badParams('text is required (or pass submit: true)');
1179
1406
  // Deleting from the end edits what is there, so it never replaces the whole text.
1180
1407
  const replace = params.replace !== false && !deleting;
1181
- let { snapshot, entry } = await resolveInput(null, params.target);
1408
+ const kind = params.kind;
1409
+ if (kind !== undefined && kind !== 'secure' && kind !== 'plain')
1410
+ throw badParams('kind is "secure" or "plain"');
1411
+ let { snapshot, entry } = await resolveInput(null, params.target, params.noScroll === true, params.dryRun !== true);
1412
+ // A test account's value goes only into the kind of field it is for (AGENT-TOOLS 1.13 §18.6). The refusal
1413
+ // comes before anything is typed or scrolled, and says only whether the field is secure, never its text.
1414
+ assertFieldKind(entry, kind);
1182
1415
  if (entry.info.disabled)
1183
1416
  throw notActionable('disabled', `${describe(entry)} is not editable; ${ENABLE_FIRST}.`);
1184
1417
  // Off screen, in the tree: scroll it into view first (AGENT-TOOLS 1.12 §16.2; the policy ran before this
@@ -1187,7 +1420,7 @@ async function typeText(rawParams) {
1187
1420
  if (params.force !== true)
1188
1421
  assertNoBlockingAlert(entry);
1189
1422
  if (params.force !== true && !ownsInstance(entry, keyboard.focusedInput())) {
1190
- reach = await scrollIntoReach(snapshot, entry, params.dryRun === true);
1423
+ reach = await scrollIntoReach(snapshot, entry, params.dryRun === true, params.noScroll === true);
1191
1424
  if (reach.scrolled) {
1192
1425
  let again;
1193
1426
  try {
@@ -1201,6 +1434,8 @@ async function typeText(rawParams) {
1201
1434
  again = await resolveInput(null, refind);
1202
1435
  }
1203
1436
  assertSameTarget(entry, again.entry);
1437
+ // A recycled row may hold another kind of field under the same names.
1438
+ assertFieldKind(again.entry, kind);
1204
1439
  snapshot = again.snapshot;
1205
1440
  entry = again.entry;
1206
1441
  if (entry.info.disabled)
@@ -1528,10 +1763,17 @@ function visiblePart(frame, screen) {
1528
1763
  * alert, under a bar) run afterwards, as for any element. Refused as `off_screen`, with the reason, when
1529
1764
  * no scroll can bring it on screen. `dry` predicts the point after the scroll and scrolls nothing.
1530
1765
  */
1531
- async function scrollIntoReach(snapshot, entry, dry) {
1766
+ async function scrollIntoReach(snapshot, entry, dry, noScroll = false) {
1532
1767
  const frame = await (0, inspect_1.measureFiber)(entry.node.fiber);
1533
1768
  if (frame === null)
1534
1769
  return {};
1770
+ // A journey in a user's eyes (`noScroll`, §17.1): nothing scrolls by itself. An element wholly off screen is refused.
1771
+ if (noScroll) {
1772
+ // On the glass, not just on screen: a scroll view clips it, the keyboard covers it (1.5 review, finding 11).
1773
+ if ((0, glassclip_1.visiblePartOnGlass)(snapshot, entry, frame, await (0, glassclip_1.measureScrollers)(snapshot)) !== null)
1774
+ return {};
1775
+ 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.`);
1776
+ }
1535
1777
  // Partly on screen (over the edge, or under a bar): scrolled clear too, with one attempt; if that
1536
1778
  // can't be done the action goes on as before and the touch checks decide. Never refused for this.
1537
1779
  const partial = visiblePart(frame, snapshot.screen) !== null;
@@ -1627,7 +1869,7 @@ function refindTarget(entry, original) {
1627
1869
  * The same element after a scroll, from a fresh snapshot (the list may have laid out other rows on the
1628
1870
  * way). When a list unmounted the row, the ref is gone: it is found again by the original target.
1629
1871
  */
1630
- async function reresolve(entry, hint, original) {
1872
+ async function reresolve(entry, hint, original, checkSame = true) {
1631
1873
  let found;
1632
1874
  try {
1633
1875
  found = await resolveTarget({ ref: (0, inspect_1.refFor)(entry.node.fiber) }, hint);
@@ -1640,21 +1882,24 @@ async function reresolve(entry, hint, original) {
1640
1882
  throw error;
1641
1883
  found = await resolveTarget(again, hint);
1642
1884
  }
1643
- assertSameTarget(entry, found.entry);
1885
+ if (checkSame)
1886
+ assertSameTarget(entry, found.entry);
1644
1887
  return found;
1645
1888
  }
1889
+ /** The same element by id, label and text, each on its own (a row can keep its id and label while its text changes). */
1890
+ function sameTarget(before, after) {
1891
+ return before.info.testID === after.info.testID && before.info.label === after.info.label && before.info.text === after.info.text;
1892
+ }
1646
1893
  /**
1647
1894
  * After a scroll the element found again must still be the one the safety check saw: a list that recycles its rows
1648
1895
  * can hand the same component another row ("Continue" becomes "Delete account"). A changed id, label or text is
1649
1896
  * refused before anything is touched (Codex review of 1.4.0).
1650
1897
  */
1651
1898
  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
1899
+ if (sameTarget(before, after))
1900
+ return;
1653
1901
  const was = { id: before.info.testID, name: labelOf(before.info) };
1654
1902
  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
1903
  if (was.name === now.name) {
1659
1904
  was.name = before.info.text;
1660
1905
  now.name = after.info.text;
@@ -1709,7 +1954,7 @@ async function scroll(rawParams) {
1709
1954
  let entry;
1710
1955
  let frame = null;
1711
1956
  if (params.target !== undefined && params.target !== null) {
1712
- const resolved = await resolveTarget(params.target, 'scroll');
1957
+ const resolved = await resolveTarget(params.target, 'scroll', undefined, params.dryRun !== true);
1713
1958
  const scroller = findActionable(resolved.snapshot, resolved.entry, 'scroll', true);
1714
1959
  if (!scroller)
1715
1960
  throw notActionable('no_action', `${describe(resolved.entry)} is not inside a scroll view`);
@@ -1887,7 +2132,7 @@ async function toggle(rawParams) {
1887
2132
  const params = paramsOf(rawParams);
1888
2133
  if (params.value !== undefined && typeof params.value !== 'boolean')
1889
2134
  throw badParams('value must be a boolean');
1890
- const first = await resolveTarget(params.target, 'toggle');
2135
+ const first = await resolveTarget(params.target, 'toggle', params.noScroll === true, params.dryRun !== true);
1891
2136
  const plan = (snap, picked) => {
1892
2137
  const found = findActionable(snap, picked, 'toggle', true);
1893
2138
  if (!found)
@@ -1905,7 +2150,7 @@ async function toggle(rawParams) {
1905
2150
  let reach = {};
1906
2151
  if (params.force !== true) {
1907
2152
  assertNoBlockingAlert(planned.control);
1908
- reach = await scrollIntoReach(snapshot, planned.control, params.dryRun === true);
2153
+ reach = await scrollIntoReach(snapshot, planned.control, params.dryRun === true, params.noScroll === true);
1909
2154
  if (reach.scrolled) {
1910
2155
  const again = await reresolve(planned.control, 'toggle', params.target);
1911
2156
  snapshot = again.snapshot;
@@ -1930,6 +2175,7 @@ async function toggle(rawParams) {
1930
2175
  const { element } = await elementFor(control);
1931
2176
  return { element, value: params.value, unchanged: true, ...(reach.scrolled ? { scrolled: true } : {}), settledMs: 0 };
1932
2177
  }
2178
+ (0, guard_1.assertRecipient)(recipientGuardOf(params), control);
1933
2179
  const closedKeyboard = closeKeyboardFor(control);
1934
2180
  callHandler('onValueChange', handler, [next]);
1935
2181
  const outcome = await settleOutcome();
@@ -2036,6 +2282,68 @@ async function audit(rawParams) {
2036
2282
  }
2037
2283
  return { issues, checked: [...types] };
2038
2284
  }
2285
+ /** `ux_check {wayBack?, earlier?}` (AGENT-TOOLS 1.12 §17.3): the screen against the usability basics. */
2286
+ async function uxCheck(rawParams) {
2287
+ const params = paramsOf(rawParams);
2288
+ if (params.wayBack !== undefined && typeof params.wayBack !== 'boolean')
2289
+ throw badParams('wayBack must be true or false');
2290
+ const earlierRaw = params.earlier;
2291
+ let earlier;
2292
+ if (earlierRaw !== undefined && earlierRaw !== null) {
2293
+ if (!Array.isArray(earlierRaw))
2294
+ throw badParams('earlier must be a list of {label, id?}');
2295
+ earlier = [];
2296
+ for (const item of earlierRaw) {
2297
+ if (!(0, types_1.isRecord)(item) || typeof item.label !== 'string')
2298
+ throw badParams('earlier must be a list of {label, id?}');
2299
+ earlier.push({ label: item.label, ...(typeof item.id === 'string' && item.id !== '' ? { id: item.id } : {}) });
2300
+ }
2301
+ }
2302
+ if (params.glass !== undefined && typeof params.glass !== 'boolean')
2303
+ throw badParams('glass must be true or false');
2304
+ const result = await (0, uxcheck_1.uxScreen)({
2305
+ ...(params.glass === true ? { glass: true } : {}),
2306
+ ...(params.wayBack === true ? { wayBack: true } : {}),
2307
+ ...(earlier !== undefined ? { earlier } : {}),
2308
+ });
2309
+ let sources = [];
2310
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
2311
+ const fibers = result.findings.flatMap((f) => (f.entry !== null ? [f.entry.node.fiber] : []));
2312
+ try {
2313
+ sources = fibers.length > 0 ? await (0, source_1.sourcesOf)(fibers) : [];
2314
+ }
2315
+ catch {
2316
+ sources = []; // Metro unreachable: the report still stands, without lines
2317
+ }
2318
+ }
2319
+ let next = 0;
2320
+ const issues = [];
2321
+ for (const finding of result.findings) {
2322
+ const source = finding.entry !== null ? sources[next++] : undefined;
2323
+ let element = null;
2324
+ if (finding.entry !== null) {
2325
+ try {
2326
+ element = (await elementFor(finding.entry, finding.frame)).element;
2327
+ }
2328
+ catch {
2329
+ element = null;
2330
+ }
2331
+ }
2332
+ const where = element === null && finding.entry !== null
2333
+ ? whereOf({ type: 'description', entry: finding.entry, frame: finding.frame, problem: '', fix: '' })
2334
+ : null;
2335
+ const location = source?.location ?? null;
2336
+ issues.push({
2337
+ kind: finding.kind,
2338
+ element,
2339
+ ...(where !== null ? { where } : {}),
2340
+ problem: finding.problem,
2341
+ basis: finding.basis,
2342
+ ...(location !== null ? { at: { file: location.file, line: location.line, direct: location.direct } } : {}),
2343
+ });
2344
+ }
2345
+ return { issues, checked: result.checked, choices: result.choices, actions: result.actions };
2346
+ }
2039
2347
  async function getLogs(rawParams) {
2040
2348
  const params = paramsOf(rawParams);
2041
2349
  if (params.since !== undefined && typeof params.since !== 'number')