react-native-tapline 1.3.1 → 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.
Files changed (48) hide show
  1. package/dist/actions.d.ts +66 -1
  2. package/dist/actions.js +483 -51
  3. package/dist/actions.js.map +1 -1
  4. package/dist/checks.d.ts +43 -1
  5. package/dist/checks.js +17 -5
  6. package/dist/checks.js.map +1 -1
  7. package/dist/client.js +11 -1
  8. package/dist/client.js.map +1 -1
  9. package/dist/closest.d.ts +30 -0
  10. package/dist/closest.js +101 -0
  11. package/dist/closest.js.map +1 -0
  12. package/dist/commands.d.ts +9 -0
  13. package/dist/commands.js +135 -2
  14. package/dist/commands.js.map +1 -1
  15. package/dist/glass.d.ts +3 -0
  16. package/dist/glass.js +81 -0
  17. package/dist/glass.js.map +1 -0
  18. package/dist/glassclip.d.ts +18 -0
  19. package/dist/glassclip.js +69 -0
  20. package/dist/glassclip.js.map +1 -0
  21. package/dist/index.d.ts +5 -1
  22. package/dist/index.js +10 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/inspect.d.ts +7 -1
  25. package/dist/inspect.js +16 -3
  26. package/dist/inspect.js.map +1 -1
  27. package/dist/locale.d.ts +67 -0
  28. package/dist/locale.js +224 -0
  29. package/dist/locale.js.map +1 -0
  30. package/dist/logs.d.ts +2 -0
  31. package/dist/logs.js +7 -0
  32. package/dist/logs.js.map +1 -1
  33. package/dist/media.d.ts +71 -0
  34. package/dist/media.js +516 -0
  35. package/dist/media.js.map +1 -0
  36. package/dist/methods.js +6 -1
  37. package/dist/methods.js.map +1 -1
  38. package/dist/network.d.ts +20 -2
  39. package/dist/network.js +750 -3
  40. package/dist/network.js.map +1 -1
  41. package/dist/types.d.ts +8 -2
  42. package/dist/types.js.map +1 -1
  43. package/dist/uxcheck.d.ts +57 -0
  44. package/dist/uxcheck.js +222 -0
  45. package/dist/uxcheck.js.map +1 -0
  46. package/dist/wait.js +30 -3
  47. package/dist/wait.js.map +1 -1
  48. package/package.json +1 -1
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;
@@ -29,7 +31,10 @@ const keyboard_1 = require("./keyboard");
29
31
  const native_1 = require("./native");
30
32
  const inspect_1 = require("./inspect");
31
33
  const source_1 = require("./source");
34
+ const closest_1 = require("./closest");
35
+ const glassclip_1 = require("./glassclip");
32
36
  const checks_1 = require("./checks");
37
+ const uxcheck_1 = require("./uxcheck");
33
38
  const alerts_1 = require("./alerts");
34
39
  const fiber_1 = require("./fiber");
35
40
  const logs_1 = require("./logs");
@@ -306,6 +311,44 @@ function notFoundMessage(target) {
306
311
  return `nothing on screen at (${target.x}, ${target.y}); look, then pick another target.`;
307
312
  }
308
313
  }
314
+ /** Where an off-screen frame lies from the screen: `above`, `below`, `left` or `right`; null when any of it is on screen. */
315
+ function sideOffScreen(frame, screen) {
316
+ if (visiblePart(frame, screen) !== null)
317
+ return null;
318
+ if (frame.y + frame.height <= 0)
319
+ return 'above';
320
+ if (frame.y >= screen.height)
321
+ return 'below';
322
+ if (frame.x + frame.width <= 0)
323
+ return 'left';
324
+ return 'right';
325
+ }
326
+ /**
327
+ * A `not_found`, with up to 3 closest matches when there are any (AGENT-TOOLS 1.12 §16.2):
328
+ * `Closest: e12 button "Save draft" id=save-draft, e30 text "Saved" (off screen, below).` An element
329
+ * the list doesn't show gets its ref here, so the agent can use it (refs count on from the highest given).
330
+ */
331
+ async function notFoundError(snapshot, target) {
332
+ const base = notFoundMessage(target);
333
+ if (target.kind !== 'testID' && target.kind !== 'label' && target.kind !== 'text')
334
+ return new types_1.TaplineError('not_found', base);
335
+ const pool = inFrontLayer(snapshot.entries.filter((entry) => entry.info.interesting && !entry.tab));
336
+ const picks = (0, closest_1.closestMatches)(target.kind, target.value, pool.map((entry) => ({ testID: entry.info.testID, label: entry.info.label, text: entry.info.text }))).map((index) => pool[index]);
337
+ const lines = [];
338
+ for (const entry of picks) {
339
+ if (!entry)
340
+ continue;
341
+ const { info } = entry;
342
+ const shown = (0, inspect_1.cutText)(info.text !== '' ? info.text : (info.label ?? ''), 0, inspect_1.MAX_TEXT);
343
+ const frame = await (0, inspect_1.measureFiber)(entry.node.fiber);
344
+ const side = frame ? sideOffScreen(frame, snapshot.screen) : null;
345
+ const line = [(0, inspect_1.refFor)(entry.node.fiber), (0, inspect_1.roleName)(info), shown !== '' ? `"${shown}"` : '', info.testID ? `id=${info.testID}` : '']
346
+ .filter((part) => part !== '')
347
+ .join(' ');
348
+ lines.push(side ? `${line} (off screen, ${side})` : line);
349
+ }
350
+ return new types_1.TaplineError('not_found', lines.length > 0 ? `${base} Closest: ${lines.join(', ')}.` : base);
351
+ }
309
352
  /**
310
353
  * What a `label` target matches: the element's own accessibility label, else its text. iOS forms
311
354
  * the label of an element that has none from the text inside it, and Tapwright reads that label, so
@@ -448,7 +491,7 @@ function inFrontLayer(matches) {
448
491
  return inAlert.length > 0 ? inAlert : matches;
449
492
  }
450
493
  /** Resolve a protocol target to exactly one element, or throw not_found / ambiguous. */
451
- async function resolveTarget(raw, hint) {
494
+ async function resolveTarget(raw, hint, glassOnly = false) {
452
495
  const target = parseTarget(raw);
453
496
  const snapshot = (0, inspect_1.takeSnapshot)();
454
497
  if (target.kind === 'ref') {
@@ -492,7 +535,14 @@ async function resolveTarget(raw, hint) {
492
535
  }
493
536
  return { snapshot, entry: hit };
494
537
  }
495
- 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
+ }
496
546
  if (matches.length === 0)
497
547
  await refuseBehindSheet(snapshot, target);
498
548
  if (target.index !== undefined) {
@@ -504,7 +554,7 @@ async function resolveTarget(raw, hint) {
504
554
  }
505
555
  const [only, ...rest] = matches;
506
556
  if (!only)
507
- throw new types_1.TaplineError('not_found', notFoundMessage(target));
557
+ throw await notFoundError(snapshot, target);
508
558
  if (rest.length > 0) {
509
559
  const shown = matches.slice(0, 5);
510
560
  throw new types_1.TaplineError('ambiguous', `${matches.length} elements match ${describeTarget(target)}.`, shown.map((entry) => (0, inspect_1.refFor)(entry.node.fiber)), await candidateElements(shown));
@@ -896,7 +946,8 @@ function focusInput(entry, frame) {
896
946
  }
897
947
  async function pressLike(kind, rawParams) {
898
948
  const params = paramsOf(rawParams);
899
- 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);
900
951
  if (entry.tab) {
901
952
  if (kind === 'longPress')
902
953
  throw notActionable('no_action', `${describe(entry)} has no long-press action`);
@@ -911,25 +962,46 @@ async function pressLike(kind, rawParams) {
911
962
  const { route: _route, ...outcome } = await back({});
912
963
  return { element: (0, inspect_1.toElement)(entry, null), ...outcome };
913
964
  }
914
- const actor = findActionable(snapshot, entry, kind, false);
915
965
  const handlerName = kind === 'press' ? 'onPress' : 'onLongPress';
916
966
  const actionName = kind === 'press' ? 'tap' : 'long-press';
917
- if (!actor) {
918
- if (entry.info.disabled)
919
- throw notActionable('disabled', `${describe(entry)} is disabled; ${ENABLE_FIRST}.`);
920
- throw notActionable('no_action', `${describe(entry)} has no ${actionName} action`);
921
- }
922
- if (actor.info.disabled || entry.info.disabled) {
923
- throw notActionable('disabled', `${describe(actor)} is disabled; ${ENABLE_FIRST}.`);
924
- }
925
- // A finger on an input focuses it (AGENT-TOOLS v1.3): an input never answers no_action to a tap.
926
- const focusing = kind === 'press' && actor.info.actions.includes('type');
927
- const handler = focusing ? null : (0, inspect_1.findHandler)(actor.node, handlerName);
928
- if (!handler && !focusing) {
929
- throw notActionable('no_action', `${describe(actor)} has no ${actionName} action`);
967
+ const plan = (snap, picked) => {
968
+ const found = findActionable(snap, picked, kind, false);
969
+ if (!found) {
970
+ if (picked.info.disabled)
971
+ throw notActionable('disabled', `${describe(picked)} is disabled; ${ENABLE_FIRST}.`);
972
+ throw notActionable('no_action', `${describe(picked)} has no ${actionName} action`);
973
+ }
974
+ if (found.info.disabled || picked.info.disabled) {
975
+ throw notActionable('disabled', `${describe(found)} is disabled; ${ENABLE_FIRST}.`);
976
+ }
977
+ // A finger on an input focuses it (AGENT-TOOLS v1.3): an input never answers no_action to a tap.
978
+ const isInput = kind === 'press' && found.info.actions.includes('type');
979
+ const own = isInput ? null : (0, inspect_1.findHandler)(found.node, handlerName);
980
+ if (!own && !isInput) {
981
+ throw notActionable('no_action', `${describe(found)} has no ${actionName} action`);
982
+ }
983
+ return { actor: found, handler: own, focusing: isInput };
984
+ };
985
+ let planned = plan(snapshot, entry);
986
+ // Off screen, in the tree: scroll it into view first (AGENT-TOOLS 1.12 §16.2). The policy ran before this call.
987
+ let reach = {};
988
+ let current = snapshot;
989
+ if (params.force !== true) {
990
+ // What doesn't depend on where the element is comes first: an open alert is answered before anything scrolls.
991
+ assertNoBlockingAlert(planned.actor);
992
+ reach = await scrollIntoReach(snapshot, planned.actor, params.dryRun === true, params.noScroll === true);
993
+ if (reach.scrolled) {
994
+ const again = await reresolve(planned.actor, kind, params.target);
995
+ current = again.snapshot;
996
+ planned = plan(again.snapshot, again.entry);
997
+ await assertRefoundOnScreen(current, planned.actor, reach);
998
+ }
930
999
  }
1000
+ const { actor, handler, focusing } = planned;
931
1001
  const { element, frame } = await elementFor(actor);
932
- const forced = await checkedOrForced(params.force === true, () => assertReachable(snapshot, actor, frame));
1002
+ if (reach.scrolls)
1003
+ return { element, dryRun: true, scrolls: true, ...(reach.point ? { point: reach.point } : {}), settledMs: 0 };
1004
+ const forced = await checkedAfterScroll(params.force === true, reach, () => assertReachable(current, actor, frame));
933
1005
  // A dry run (AGENT-TOOLS 1.8 §11.4): every check ran; nothing is touched.
934
1006
  if (params.dryRun === true)
935
1007
  return { element, dryRun: true, ...pointOf(frame), ...forced, settledMs: 0 };
@@ -944,7 +1016,61 @@ async function pressLike(kind, rawParams) {
944
1016
  if (own)
945
1017
  callHandler('onPress', own, [pressEvent(actor.node.fiber, frame)]);
946
1018
  }
947
- return { element, ...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
+ }
948
1074
  }
949
1075
  /** Where a finger would land: the middle of the element (points, rounded). */
950
1076
  function pointOf(frame) {
@@ -1069,9 +1195,9 @@ async function moveFocusAboveKeyboard(entry) {
1069
1195
  function keyboardNote(closed) {
1070
1196
  return closed ? { closedKeyboard: true } : {};
1071
1197
  }
1072
- async function resolveInput(snapshot, rawTarget) {
1198
+ async function resolveInput(snapshot, rawTarget, glassOnly = false) {
1073
1199
  if (rawTarget !== undefined && rawTarget !== null) {
1074
- const { snapshot: snap, entry } = await resolveTarget(rawTarget, 'type');
1200
+ const { snapshot: snap, entry } = await resolveTarget(rawTarget, 'type', glassOnly);
1075
1201
  const input = findActionable(snap, entry, 'type', true);
1076
1202
  if (!input)
1077
1203
  throw notActionable('no_action', `${describe(entry)} is not an input`);
@@ -1118,13 +1244,44 @@ async function typeText(rawParams) {
1118
1244
  throw badParams('text is required (or pass submit: true)');
1119
1245
  // Deleting from the end edits what is there, so it never replaces the whole text.
1120
1246
  const replace = params.replace !== false && !deleting;
1121
- const { snapshot, entry } = await resolveInput(null, params.target);
1247
+ let { snapshot, entry } = await resolveInput(null, params.target, params.noScroll === true);
1122
1248
  if (entry.info.disabled)
1123
1249
  throw notActionable('disabled', `${describe(entry)} is not editable; ${ENABLE_FIRST}.`);
1250
+ // Off screen, in the tree: scroll it into view first (AGENT-TOOLS 1.12 §16.2; the policy ran before this
1251
+ // call). The input that already has focus is typed into without touching it, so it is not scrolled to.
1252
+ let reach = {};
1253
+ if (params.force !== true)
1254
+ assertNoBlockingAlert(entry);
1255
+ if (params.force !== true && !ownsInstance(entry, keyboard.focusedInput())) {
1256
+ reach = await scrollIntoReach(snapshot, entry, params.dryRun === true, params.noScroll === true);
1257
+ if (reach.scrolled) {
1258
+ let again;
1259
+ try {
1260
+ again = await resolveInput(null, { ref: (0, inspect_1.refFor)(entry.node.fiber) });
1261
+ }
1262
+ catch (error) {
1263
+ // a list unmounted the row while scrolling: find it again by the original target
1264
+ const refind = error instanceof types_1.TaplineError && error.code === 'not_found' ? refindTarget(entry, params.target) : null;
1265
+ if (refind === null)
1266
+ throw error;
1267
+ again = await resolveInput(null, refind);
1268
+ }
1269
+ assertSameTarget(entry, again.entry);
1270
+ snapshot = again.snapshot;
1271
+ entry = again.entry;
1272
+ if (entry.info.disabled)
1273
+ throw notActionable('disabled', `${describe(entry)} is not editable; ${ENABLE_FIRST}.`);
1274
+ await assertRefoundOnScreen(snapshot, entry, reach);
1275
+ }
1276
+ }
1277
+ if (reach.scrolls) {
1278
+ const { element } = await elementFor(entry);
1279
+ return { element, value: (0, inspect_1.displayText)((0, inspect_1.currentInputValue)(entry.node)), dryRun: true, scrolls: true, ...(reach.point ? { point: reach.point } : {}), settledMs: 0 };
1280
+ }
1124
1281
  // Typing is a tap that focuses the input, then keys: a person couldn't do it where they couldn't tap,
1125
1282
  // so the checks are a tap's, with the same reasons and the same `force`. The input that already has
1126
1283
  // focus is the exception: the keyboard is up for it and a person types without touching it.
1127
- const forced = await checkedOrForced(params.force === true, async () => {
1284
+ const forced = await checkedAfterScroll(params.force === true, reach, async () => {
1128
1285
  if (ownsInstance(entry, keyboard.focusedInput())) {
1129
1286
  // An alert takes the keyboard away even from the focused input: a person answers it first.
1130
1287
  assertNoBlockingAlert(entry);
@@ -1180,6 +1337,7 @@ async function typeText(rawParams) {
1180
1337
  element: { ...rest, ...(next === '' && placeholder !== undefined ? { placeholder } : {}), value: shown },
1181
1338
  value: shown,
1182
1339
  ...(secure ? { secure: true } : {}),
1340
+ ...(reach.scrolled ? { scrolled: true } : {}),
1183
1341
  ...keyboardNote(closedKeyboard),
1184
1342
  ...forced,
1185
1343
  ...outcome,
@@ -1268,6 +1426,18 @@ async function scrollToTarget(params) {
1268
1426
  throw badParams('to: "target" needs an element (ref, id, label or text), not a point');
1269
1427
  }
1270
1428
  const { snapshot, entry: wanted } = await resolveTarget(raw);
1429
+ const scrollers = scrollersOf(snapshot, wanted);
1430
+ if (params.dryRun === true) {
1431
+ const first = scrollers[0];
1432
+ const target = (await elementFor(wanted)).element;
1433
+ if (!first)
1434
+ return { element: target, target, offset: { x: 0, y: 0 }, moved: false, dryRun: true, settledMs: 0 };
1435
+ return { element: (await elementFor(first)).element, target, offset: { x: 0, y: 0 }, moved: false, dryRun: true, settledMs: 0 };
1436
+ }
1437
+ return scrollEntryIntoView(snapshot, wanted, scrollers);
1438
+ }
1439
+ /** The scroll views that hold an element, innermost first. */
1440
+ function scrollersOf(snapshot, wanted) {
1271
1441
  const scrollers = [];
1272
1442
  for (let p = wanted.node.parent; p >= 0;) {
1273
1443
  const candidate = snapshot.entries[p];
@@ -1278,26 +1448,22 @@ async function scrollToTarget(params) {
1278
1448
  break;
1279
1449
  p = node.parent;
1280
1450
  }
1281
- if (params.dryRun === true) {
1282
- const first = scrollers[0];
1283
- const target = (await elementFor(wanted)).element;
1284
- if (!first)
1285
- return { element: target, target, offset: { x: 0, y: 0 }, moved: false, dryRun: true, settledMs: 0 };
1286
- return { element: (await elementFor(first)).element, target, offset: { x: 0, y: 0 }, moved: false, dryRun: true, settledMs: 0 };
1287
- }
1451
+ return scrollers;
1452
+ }
1453
+ /**
1454
+ * How far the element is from clear inside a scroll view, per axis. The area is the part of the
1455
+ * scroll view that is on screen between the bars. A scroll view that is itself out of view (a
1456
+ * list further down the page) has no such part: there the element only has to be inside the
1457
+ * scroll view, and the outer one brings both onto the screen. Without a scroll view the area is
1458
+ * the screen between the bars.
1459
+ */
1460
+ function shiftsFor(snapshot, wanted) {
1288
1461
  const { width, height } = snapshot.screen;
1289
1462
  const navBar = (0, inspect_1.nativeNavBar)(snapshot);
1290
1463
  const tabBar = (0, inspect_1.nativeTabBar)(snapshot);
1291
1464
  const clearTop = navBar && !(0, inspect_1.inNavBarHeader)(navBar, wanted.node.fiber) ? navBar.bottom : 0;
1292
1465
  const clearBottom = tabBar ? tabBar.top : height;
1293
- /**
1294
- * How far the element is from clear inside a scroll view, per axis. The area is the part of the
1295
- * scroll view that is on screen between the bars. A scroll view that is itself out of view (a
1296
- * list further down the page) has no such part: there the element only has to be inside the
1297
- * scroll view, and the outer one brings both onto the screen. Without a scroll view the area is
1298
- * the screen between the bars.
1299
- */
1300
- const shiftsIn = (frame, area) => {
1466
+ return (frame, area) => {
1301
1467
  let left = Math.max(area?.x ?? 0, 0);
1302
1468
  let right = Math.min(area ? area.x + area.width : width, width);
1303
1469
  let top = Math.max(area?.y ?? 0, clearTop);
@@ -1310,6 +1476,10 @@ async function scrollToTarget(params) {
1310
1476
  }
1311
1477
  return { x: axisShift(frame.x, frame.width, left, right), y: axisShift(frame.y, frame.height, top, bottom) };
1312
1478
  };
1479
+ }
1480
+ /** The scrolling half of `scroll` with `to: "target"`: scrolls the given scroll views until `wanted` is clear. */
1481
+ async function scrollEntryIntoView(snapshot, wanted, scrollers) {
1482
+ const shiftsIn = shiftsFor(snapshot, wanted);
1313
1483
  let settledMs = 0;
1314
1484
  let settleCapMs;
1315
1485
  let scrolled = null;
@@ -1410,6 +1580,184 @@ async function scrollToTarget(params) {
1410
1580
  const { element } = await elementFor(scrolled.entry);
1411
1581
  return { element, target, offset: scrolled.offset, moved: true, reached, ...settle };
1412
1582
  }
1583
+ /** The part of a frame that is on the screen, or null when none of it is. */
1584
+ function visiblePart(frame, screen) {
1585
+ const left = Math.max(frame.x, 0);
1586
+ const top = Math.max(frame.y, 0);
1587
+ const right = Math.min(frame.x + frame.width, screen.width);
1588
+ const bottom = Math.min(frame.y + frame.height, screen.height);
1589
+ return right <= left || bottom <= top ? null : { x: left, y: top, width: right - left, height: bottom - top };
1590
+ }
1591
+ /**
1592
+ * An action on an element that is in the tree but entirely off screen scrolls it into view first, clear
1593
+ * of the bars (§16.2). The caller has run the policy check already. The touch checks (covered, behind an
1594
+ * alert, under a bar) run afterwards, as for any element. Refused as `off_screen`, with the reason, when
1595
+ * no scroll can bring it on screen. `dry` predicts the point after the scroll and scrolls nothing.
1596
+ */
1597
+ async function scrollIntoReach(snapshot, entry, dry, noScroll = false) {
1598
+ const frame = await (0, inspect_1.measureFiber)(entry.node.fiber);
1599
+ if (frame === null)
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
+ }
1608
+ // Partly on screen (over the edge, or under a bar): scrolled clear too, with one attempt; if that
1609
+ // can't be done the action goes on as before and the touch checks decide. Never refused for this.
1610
+ const partial = visiblePart(frame, snapshot.screen) !== null;
1611
+ if (partial && !isNotClear(snapshot, entry, frame))
1612
+ return {};
1613
+ const { width, height } = snapshot.screen;
1614
+ const name = describe(entry);
1615
+ const where = `at ${Math.round(frame.x)},${Math.round(frame.y)}; the screen is ${width}x${height}`;
1616
+ const scrollers = scrollersOf(snapshot, entry);
1617
+ if (scrollers.length === 0 && partial)
1618
+ return {};
1619
+ if (scrollers.length === 0) {
1620
+ throw notActionable('off_screen', `${name} is off screen (${where}). It isn't inside a scroll view, so no scroll can bring it on screen; pass force: true to call its handler anyway.`);
1621
+ }
1622
+ if (dry) {
1623
+ const shiftsIn = shiftsFor(snapshot, entry);
1624
+ let predicted = frame;
1625
+ for (const scroller of scrollers) {
1626
+ const [area, state] = await Promise.all([(0, inspect_1.measureFiber)(scroller.node.fiber), measureScrollState(scroller)]);
1627
+ if (!area)
1628
+ continue;
1629
+ const horizontal = (0, inspect_1.findProp)(scroller.node, 'horizontal') === true;
1630
+ const shifts = shiftsIn(predicted, area);
1631
+ const wanted = horizontal ? shifts.x : shifts.y;
1632
+ if (Math.abs(wanted) < 1)
1633
+ continue;
1634
+ let delta = wanted;
1635
+ if (state) {
1636
+ const current = horizontal ? state.offset.x : state.offset.y;
1637
+ const max = horizontal ? state.max.x : state.max.y;
1638
+ delta = Math.max(Math.min(0, current), Math.min(current + wanted, Math.max(max, current))) - current;
1639
+ }
1640
+ predicted = horizontal ? { ...predicted, x: predicted.x - delta } : { ...predicted, y: predicted.y - delta };
1641
+ }
1642
+ if (partial) {
1643
+ return predicted.x === frame.x && predicted.y === frame.y ? {} : { scrolls: true, ...pointOf(visiblePart(predicted, snapshot.screen)) };
1644
+ }
1645
+ const seen = visiblePart(predicted, snapshot.screen);
1646
+ if (seen === null) {
1647
+ throw notActionable('off_screen', `${name} is off screen (${where}), and its scroll view can't bring it on screen: it is as far as it goes; pass force: true to call its handler anyway.`);
1648
+ }
1649
+ return { scrolls: true, ...pointOf(seen) };
1650
+ }
1651
+ let outcome;
1652
+ try {
1653
+ outcome = await scrollEntryIntoView(snapshot, entry, scrollers);
1654
+ }
1655
+ catch (error) {
1656
+ if (partial && error instanceof types_1.TaplineError)
1657
+ return {};
1658
+ throw error;
1659
+ }
1660
+ const after = await (0, inspect_1.measureFiber)(entry.node.fiber);
1661
+ if (partial) {
1662
+ if (!outcome.moved)
1663
+ return {};
1664
+ return after === null ? { scrolled: true, unverified: true } : { scrolled: true };
1665
+ }
1666
+ // A list may have unmounted the row on the way: the caller finds it again, and checks it is on screen.
1667
+ if (after === null && outcome.moved)
1668
+ return { scrolled: true, unverified: true };
1669
+ if (after === null || visiblePart(after, snapshot.screen) === null) {
1670
+ throw notActionable('off_screen', `${name} is off screen (${where}), and scrolling its scroll view${outcome.moved ? '' : ' (already as far as it goes)'} didn't bring it on screen; pass force: true to call its handler anyway.`);
1671
+ }
1672
+ return outcome.moved ? { scrolled: true } : {};
1673
+ }
1674
+ /** Whether a partly visible element is off the screen edge or under a bar, and would fit clear of them if scrolled. */
1675
+ function isNotClear(snapshot, entry, frame) {
1676
+ const navBar = (0, inspect_1.nativeNavBar)(snapshot);
1677
+ const tabBar = (0, inspect_1.nativeTabBar)(snapshot);
1678
+ const clearTop = navBar && !(0, inspect_1.inNavBarHeader)(navBar, entry.node.fiber) ? navBar.bottom : 0;
1679
+ const clearBottom = tabBar ? tabBar.top : snapshot.screen.height;
1680
+ // Taller or wider than the room there is: no scroll makes it clear, so it is left as it is.
1681
+ if (frame.height > clearBottom - clearTop || frame.width > snapshot.screen.width)
1682
+ return false;
1683
+ const shift = shiftsFor(snapshot, entry)(frame, null);
1684
+ return Math.abs(shift.x) >= 1 || Math.abs(shift.y) >= 1;
1685
+ }
1686
+ /** A target that finds an element again by what it shows: the app's own target, else its id, label or text. */
1687
+ function refindTarget(entry, original) {
1688
+ if ((0, types_1.isRecord)(original) && original.ref === undefined && original.x === undefined && original.y === undefined)
1689
+ return original;
1690
+ const { testID, label, text } = entry.info;
1691
+ if (testID)
1692
+ return { testID };
1693
+ if (label)
1694
+ return { label };
1695
+ if (text !== '')
1696
+ return { text };
1697
+ return null;
1698
+ }
1699
+ /**
1700
+ * The same element after a scroll, from a fresh snapshot (the list may have laid out other rows on the
1701
+ * way). When a list unmounted the row, the ref is gone: it is found again by the original target.
1702
+ */
1703
+ async function reresolve(entry, hint, original, checkSame = true) {
1704
+ let found;
1705
+ try {
1706
+ found = await resolveTarget({ ref: (0, inspect_1.refFor)(entry.node.fiber) }, hint);
1707
+ }
1708
+ catch (error) {
1709
+ if (!(error instanceof types_1.TaplineError) || error.code !== 'not_found')
1710
+ throw error;
1711
+ const again = refindTarget(entry, original);
1712
+ if (again === null)
1713
+ throw error;
1714
+ found = await resolveTarget(again, hint);
1715
+ }
1716
+ if (checkSame)
1717
+ assertSameTarget(entry, found.entry);
1718
+ return found;
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
+ }
1724
+ /**
1725
+ * After a scroll the element found again must still be the one the safety check saw: a list that recycles its rows
1726
+ * can hand the same component another row ("Continue" becomes "Delete account"). A changed id, label or text is
1727
+ * refused before anything is touched (Codex review of 1.4.0).
1728
+ */
1729
+ function assertSameTarget(before, after) {
1730
+ if (sameTarget(before, after))
1731
+ return;
1732
+ const was = { id: before.info.testID, name: labelOf(before.info) };
1733
+ const now = { id: after.info.testID, name: labelOf(after.info) };
1734
+ if (was.name === now.name) {
1735
+ was.name = before.info.text;
1736
+ now.name = after.info.text;
1737
+ }
1738
+ throw new types_1.TaplineError('not_found', `the element changed while the list scrolled (it was ${JSON.stringify(was.name || was.id || "?")}, now ${JSON.stringify(now.name || now.id || "?")}); nothing was touched: look, then try again`);
1739
+ }
1740
+ /** After an element was found again because it could not be measured: it must now be on screen. */
1741
+ async function assertRefoundOnScreen(snapshot, entry, reach) {
1742
+ if (!reach.unverified)
1743
+ return;
1744
+ const frame = await (0, inspect_1.measureFiber)(entry.node.fiber);
1745
+ if (frame === null || visiblePart(frame, snapshot.screen) === null) {
1746
+ throw notActionable('off_screen', `${describe(entry)} is off screen, and scrolling its scroll view didn't bring it on screen; pass force: true to call its handler anyway.`);
1747
+ }
1748
+ }
1749
+ /** `checkedOrForced`, and a refusal that comes after Tapline scrolled to the element says so. */
1750
+ async function checkedAfterScroll(force, reach, check) {
1751
+ try {
1752
+ return await checkedOrForced(force, check);
1753
+ }
1754
+ catch (error) {
1755
+ if (reach.scrolled && error instanceof types_1.TaplineError && error.code === 'not_actionable') {
1756
+ throw new types_1.TaplineError(error.code, `${error.message} (after scrolling to it)`, error.refs, error.candidates);
1757
+ }
1758
+ throw error;
1759
+ }
1760
+ }
1413
1761
  async function scroll(rawParams) {
1414
1762
  const params = paramsOf(rawParams);
1415
1763
  const to = params.to;
@@ -1615,18 +1963,40 @@ async function toggle(rawParams) {
1615
1963
  const params = paramsOf(rawParams);
1616
1964
  if (params.value !== undefined && typeof params.value !== 'boolean')
1617
1965
  throw badParams('value must be a boolean');
1618
- const { snapshot, entry } = await resolveTarget(params.target, 'toggle');
1619
- const control = findActionable(snapshot, entry, 'toggle', true);
1620
- if (!control)
1621
- throw notActionable('no_action', `${describe(entry)} is not a switch`);
1622
- if (control.info.disabled)
1623
- throw notActionable('disabled', `${describe(control)} is disabled; ${ENABLE_FIRST}.`);
1624
- const handler = (0, inspect_1.findHandler)(control.node, 'onValueChange');
1625
- if (!handler)
1626
- throw notActionable('no_action', `${describe(control)} is not a switch`);
1966
+ const first = await resolveTarget(params.target, 'toggle', params.noScroll === true);
1967
+ const plan = (snap, picked) => {
1968
+ const found = findActionable(snap, picked, 'toggle', true);
1969
+ if (!found)
1970
+ throw notActionable('no_action', `${describe(picked)} is not a switch`);
1971
+ if (found.info.disabled)
1972
+ throw notActionable('disabled', `${describe(found)} is disabled; ${ENABLE_FIRST}.`);
1973
+ const own = (0, inspect_1.findHandler)(found.node, 'onValueChange');
1974
+ if (!own)
1975
+ throw notActionable('no_action', `${describe(found)} is not a switch`);
1976
+ return { control: found, handler: own };
1977
+ };
1978
+ let snapshot = first.snapshot;
1979
+ let planned = plan(snapshot, first.entry);
1980
+ // Off screen, in the tree: scroll it into view first (AGENT-TOOLS 1.12 §16.2). The policy ran before this call.
1981
+ let reach = {};
1982
+ if (params.force !== true) {
1983
+ assertNoBlockingAlert(planned.control);
1984
+ reach = await scrollIntoReach(snapshot, planned.control, params.dryRun === true, params.noScroll === true);
1985
+ if (reach.scrolled) {
1986
+ const again = await reresolve(planned.control, 'toggle', params.target);
1987
+ snapshot = again.snapshot;
1988
+ planned = plan(snapshot, again.entry);
1989
+ await assertRefoundOnScreen(snapshot, planned.control, reach);
1990
+ }
1991
+ }
1992
+ const { control, handler } = planned;
1627
1993
  const current = control.info.checked === true;
1628
1994
  const next = typeof params.value === 'boolean' ? params.value : !current;
1629
- const forced = await checkedOrForced(params.force === true, async () => assertReachable(snapshot, control, await (0, inspect_1.measureFiber)(control.node.fiber)));
1995
+ if (reach.scrolls) {
1996
+ const { element } = await elementFor(control);
1997
+ return { element, value: next, dryRun: true, scrolls: true, ...(reach.point ? { point: reach.point } : {}), settledMs: 0 };
1998
+ }
1999
+ const forced = await checkedAfterScroll(params.force === true, reach, async () => assertReachable(snapshot, control, await (0, inspect_1.measureFiber)(control.node.fiber)));
1630
2000
  if (params.dryRun === true) {
1631
2001
  const { element, frame } = await elementFor(control);
1632
2002
  return { element, value: next, dryRun: true, ...pointOf(frame), ...forced, settledMs: 0 };
@@ -1634,13 +2004,13 @@ async function toggle(rawParams) {
1634
2004
  if (typeof params.value === 'boolean' && control.info.checked === params.value) {
1635
2005
  // Already there: a person wouldn't touch it, so the handler isn't called and the keyboard stays.
1636
2006
  const { element } = await elementFor(control);
1637
- return { element, value: params.value, unchanged: true, settledMs: 0 };
2007
+ return { element, value: params.value, unchanged: true, ...(reach.scrolled ? { scrolled: true } : {}), settledMs: 0 };
1638
2008
  }
1639
2009
  const closedKeyboard = closeKeyboardFor(control);
1640
2010
  callHandler('onValueChange', handler, [next]);
1641
2011
  const outcome = await settleOutcome();
1642
2012
  const { element } = await elementFor(control);
1643
- return { element: { ...element, checked: next }, value: next, ...keyboardNote(closedKeyboard), ...forced, ...outcome };
2013
+ return { element: { ...element, checked: next }, value: next, ...(reach.scrolled ? { scrolled: true } : {}), ...keyboardNote(closedKeyboard), ...forced, ...outcome };
1644
2014
  }
1645
2015
  /* -------------------------------------------------------------------------- */
1646
2016
  /* openURL / logs / reload / screenshot */
@@ -1742,6 +2112,68 @@ async function audit(rawParams) {
1742
2112
  }
1743
2113
  return { issues, checked: [...types] };
1744
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
+ }
1745
2177
  async function getLogs(rawParams) {
1746
2178
  const params = paramsOf(rawParams);
1747
2179
  if (params.since !== undefined && typeof params.since !== 'number')