react-native-tapline 1.3.0 → 1.4.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
@@ -2,7 +2,7 @@ import { type Entry, type Snapshot } from './inspect';
2
2
  import { type ElementSource } from './source';
3
3
  import { type AuditType } from './checks';
4
4
  import { type Fiber } from './fiber';
5
- import { type LogEntry, type TaplineElement } from './types';
5
+ import { type Frame, type LogEntry, type TaplineElement } from './types';
6
6
  export { tree } from './inspect';
7
7
  /** Tune how long the app must be free of React commits after an action, and the longest wait (AGENT-TOOLS §6). */
8
8
  export declare function configureActions(next: {
@@ -72,12 +72,16 @@ export declare function back(rawParams: unknown): Promise<{
72
72
  /**
73
73
  * What `press` and `longPress` answer: the element, `closedKeyboard: true` when the keyboard was
74
74
  * closed first, and `forced` (the reason word) when `force` let through what the checks refuse.
75
+ * `scrolled` says the element was off screen and was scrolled into view first (§16.2); on a dry run,
76
+ * `scrolls` says it would be and `point` is where the touch is expected after the scroll.
75
77
  */
76
78
  export type PressResult = {
77
79
  element: TaplineElement;
78
80
  closedKeyboard?: true;
79
81
  forced?: string;
80
82
  dryRun?: true;
83
+ scrolled?: true;
84
+ scrolls?: true;
81
85
  point?: {
82
86
  x: number;
83
87
  y: number;
@@ -112,6 +116,12 @@ export declare function typeText(rawParams: unknown): Promise<{
112
116
  closedKeyboard?: true;
113
117
  forced?: string;
114
118
  dryRun?: true;
119
+ scrolled?: true;
120
+ scrolls?: true;
121
+ point?: {
122
+ x: number;
123
+ y: number;
124
+ };
115
125
  } & SettleOutcome>;
116
126
  /** What `scroll` answers; `target`, `reached` and `unchanged` only with `to: "target"`. */
117
127
  export type ScrollResult = {
@@ -139,6 +149,8 @@ export declare function toggle(rawParams: unknown): Promise<{
139
149
  unchanged?: true;
140
150
  forced?: string;
141
151
  dryRun?: true;
152
+ scrolled?: true;
153
+ scrolls?: true;
142
154
  point?: {
143
155
  x: number;
144
156
  y: number;
@@ -153,9 +165,17 @@ export declare function inspect(rawParams: unknown): Promise<{
153
165
  element: TaplineElement;
154
166
  source: ElementSource;
155
167
  }>;
168
+ /** What is known about the element of an issue whose full element could not be built: where it is, and what it says. */
169
+ export interface AuditWhere {
170
+ frame?: Frame;
171
+ text?: string;
172
+ label?: string;
173
+ }
156
174
  export interface AuditIssue {
157
175
  type: AuditType;
158
176
  element: TaplineElement | null;
177
+ /** Set when `element` is null but something is known (its frame in points, its text or label). */
178
+ where?: AuditWhere;
159
179
  problem: string;
160
180
  fix: string;
161
181
  /** Where the app's code wrote the element (development builds), as Metro names the file. */
package/dist/actions.js CHANGED
@@ -29,6 +29,7 @@ const keyboard_1 = require("./keyboard");
29
29
  const native_1 = require("./native");
30
30
  const inspect_1 = require("./inspect");
31
31
  const source_1 = require("./source");
32
+ const closest_1 = require("./closest");
32
33
  const checks_1 = require("./checks");
33
34
  const alerts_1 = require("./alerts");
34
35
  const fiber_1 = require("./fiber");
@@ -306,6 +307,44 @@ function notFoundMessage(target) {
306
307
  return `nothing on screen at (${target.x}, ${target.y}); look, then pick another target.`;
307
308
  }
308
309
  }
310
+ /** Where an off-screen frame lies from the screen: `above`, `below`, `left` or `right`; null when any of it is on screen. */
311
+ function sideOffScreen(frame, screen) {
312
+ if (visiblePart(frame, screen) !== null)
313
+ return null;
314
+ if (frame.y + frame.height <= 0)
315
+ return 'above';
316
+ if (frame.y >= screen.height)
317
+ return 'below';
318
+ if (frame.x + frame.width <= 0)
319
+ return 'left';
320
+ return 'right';
321
+ }
322
+ /**
323
+ * A `not_found`, with up to 3 closest matches when there are any (AGENT-TOOLS 1.12 §16.2):
324
+ * `Closest: e12 button "Save draft" id=save-draft, e30 text "Saved" (off screen, below).` An element
325
+ * the list doesn't show gets its ref here, so the agent can use it (refs count on from the highest given).
326
+ */
327
+ async function notFoundError(snapshot, target) {
328
+ const base = notFoundMessage(target);
329
+ if (target.kind !== 'testID' && target.kind !== 'label' && target.kind !== 'text')
330
+ return new types_1.TaplineError('not_found', base);
331
+ const pool = inFrontLayer(snapshot.entries.filter((entry) => entry.info.interesting && !entry.tab));
332
+ 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]);
333
+ const lines = [];
334
+ for (const entry of picks) {
335
+ if (!entry)
336
+ continue;
337
+ const { info } = entry;
338
+ const shown = (0, inspect_1.cutText)(info.text !== '' ? info.text : (info.label ?? ''), 0, inspect_1.MAX_TEXT);
339
+ const frame = await (0, inspect_1.measureFiber)(entry.node.fiber);
340
+ const side = frame ? sideOffScreen(frame, snapshot.screen) : null;
341
+ const line = [(0, inspect_1.refFor)(entry.node.fiber), (0, inspect_1.roleName)(info), shown !== '' ? `"${shown}"` : '', info.testID ? `id=${info.testID}` : '']
342
+ .filter((part) => part !== '')
343
+ .join(' ');
344
+ lines.push(side ? `${line} (off screen, ${side})` : line);
345
+ }
346
+ return new types_1.TaplineError('not_found', lines.length > 0 ? `${base} Closest: ${lines.join(', ')}.` : base);
347
+ }
309
348
  /**
310
349
  * What a `label` target matches: the element's own accessibility label, else its text. iOS forms
311
350
  * the label of an element that has none from the text inside it, and Tapwright reads that label, so
@@ -504,7 +543,7 @@ async function resolveTarget(raw, hint) {
504
543
  }
505
544
  const [only, ...rest] = matches;
506
545
  if (!only)
507
- throw new types_1.TaplineError('not_found', notFoundMessage(target));
546
+ throw await notFoundError(snapshot, target);
508
547
  if (rest.length > 0) {
509
548
  const shown = matches.slice(0, 5);
510
549
  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));
@@ -911,25 +950,46 @@ async function pressLike(kind, rawParams) {
911
950
  const { route: _route, ...outcome } = await back({});
912
951
  return { element: (0, inspect_1.toElement)(entry, null), ...outcome };
913
952
  }
914
- const actor = findActionable(snapshot, entry, kind, false);
915
953
  const handlerName = kind === 'press' ? 'onPress' : 'onLongPress';
916
954
  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`);
955
+ const plan = (snap, picked) => {
956
+ const found = findActionable(snap, picked, kind, false);
957
+ if (!found) {
958
+ if (picked.info.disabled)
959
+ throw notActionable('disabled', `${describe(picked)} is disabled; ${ENABLE_FIRST}.`);
960
+ throw notActionable('no_action', `${describe(picked)} has no ${actionName} action`);
961
+ }
962
+ if (found.info.disabled || picked.info.disabled) {
963
+ throw notActionable('disabled', `${describe(found)} is disabled; ${ENABLE_FIRST}.`);
964
+ }
965
+ // A finger on an input focuses it (AGENT-TOOLS v1.3): an input never answers no_action to a tap.
966
+ const isInput = kind === 'press' && found.info.actions.includes('type');
967
+ const own = isInput ? null : (0, inspect_1.findHandler)(found.node, handlerName);
968
+ if (!own && !isInput) {
969
+ throw notActionable('no_action', `${describe(found)} has no ${actionName} action`);
970
+ }
971
+ return { actor: found, handler: own, focusing: isInput };
972
+ };
973
+ let planned = plan(snapshot, entry);
974
+ // Off screen, in the tree: scroll it into view first (AGENT-TOOLS 1.12 §16.2). The policy ran before this call.
975
+ let reach = {};
976
+ let current = snapshot;
977
+ if (params.force !== true) {
978
+ // What doesn't depend on where the element is comes first: an open alert is answered before anything scrolls.
979
+ assertNoBlockingAlert(planned.actor);
980
+ reach = await scrollIntoReach(snapshot, planned.actor, params.dryRun === true);
981
+ if (reach.scrolled) {
982
+ const again = await reresolve(planned.actor, kind, params.target);
983
+ current = again.snapshot;
984
+ planned = plan(again.snapshot, again.entry);
985
+ await assertRefoundOnScreen(current, planned.actor, reach);
986
+ }
930
987
  }
988
+ const { actor, handler, focusing } = planned;
931
989
  const { element, frame } = await elementFor(actor);
932
- const forced = await checkedOrForced(params.force === true, () => assertReachable(snapshot, actor, frame));
990
+ if (reach.scrolls)
991
+ return { element, dryRun: true, scrolls: true, ...(reach.point ? { point: reach.point } : {}), settledMs: 0 };
992
+ const forced = await checkedAfterScroll(params.force === true, reach, () => assertReachable(current, actor, frame));
933
993
  // A dry run (AGENT-TOOLS 1.8 §11.4): every check ran; nothing is touched.
934
994
  if (params.dryRun === true)
935
995
  return { element, dryRun: true, ...pointOf(frame), ...forced, settledMs: 0 };
@@ -944,7 +1004,7 @@ async function pressLike(kind, rawParams) {
944
1004
  if (own)
945
1005
  callHandler('onPress', own, [pressEvent(actor.node.fiber, frame)]);
946
1006
  }
947
- return { element, ...keyboardNote(closedKeyboard), ...forced, ...(await settleOutcome()) };
1007
+ return { element, ...(reach.scrolled ? { scrolled: true } : {}), ...keyboardNote(closedKeyboard), ...forced, ...(await settleOutcome()) };
948
1008
  }
949
1009
  /** Where a finger would land: the middle of the element (points, rounded). */
950
1010
  function pointOf(frame) {
@@ -1118,13 +1178,44 @@ async function typeText(rawParams) {
1118
1178
  throw badParams('text is required (or pass submit: true)');
1119
1179
  // Deleting from the end edits what is there, so it never replaces the whole text.
1120
1180
  const replace = params.replace !== false && !deleting;
1121
- const { snapshot, entry } = await resolveInput(null, params.target);
1181
+ let { snapshot, entry } = await resolveInput(null, params.target);
1122
1182
  if (entry.info.disabled)
1123
1183
  throw notActionable('disabled', `${describe(entry)} is not editable; ${ENABLE_FIRST}.`);
1184
+ // Off screen, in the tree: scroll it into view first (AGENT-TOOLS 1.12 §16.2; the policy ran before this
1185
+ // call). The input that already has focus is typed into without touching it, so it is not scrolled to.
1186
+ let reach = {};
1187
+ if (params.force !== true)
1188
+ assertNoBlockingAlert(entry);
1189
+ if (params.force !== true && !ownsInstance(entry, keyboard.focusedInput())) {
1190
+ reach = await scrollIntoReach(snapshot, entry, params.dryRun === true);
1191
+ if (reach.scrolled) {
1192
+ let again;
1193
+ try {
1194
+ again = await resolveInput(null, { ref: (0, inspect_1.refFor)(entry.node.fiber) });
1195
+ }
1196
+ catch (error) {
1197
+ // a list unmounted the row while scrolling: find it again by the original target
1198
+ const refind = error instanceof types_1.TaplineError && error.code === 'not_found' ? refindTarget(entry, params.target) : null;
1199
+ if (refind === null)
1200
+ throw error;
1201
+ again = await resolveInput(null, refind);
1202
+ }
1203
+ assertSameTarget(entry, again.entry);
1204
+ snapshot = again.snapshot;
1205
+ entry = again.entry;
1206
+ if (entry.info.disabled)
1207
+ throw notActionable('disabled', `${describe(entry)} is not editable; ${ENABLE_FIRST}.`);
1208
+ await assertRefoundOnScreen(snapshot, entry, reach);
1209
+ }
1210
+ }
1211
+ if (reach.scrolls) {
1212
+ const { element } = await elementFor(entry);
1213
+ return { element, value: (0, inspect_1.displayText)((0, inspect_1.currentInputValue)(entry.node)), dryRun: true, scrolls: true, ...(reach.point ? { point: reach.point } : {}), settledMs: 0 };
1214
+ }
1124
1215
  // Typing is a tap that focuses the input, then keys: a person couldn't do it where they couldn't tap,
1125
1216
  // so the checks are a tap's, with the same reasons and the same `force`. The input that already has
1126
1217
  // 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 () => {
1218
+ const forced = await checkedAfterScroll(params.force === true, reach, async () => {
1128
1219
  if (ownsInstance(entry, keyboard.focusedInput())) {
1129
1220
  // An alert takes the keyboard away even from the focused input: a person answers it first.
1130
1221
  assertNoBlockingAlert(entry);
@@ -1180,6 +1271,7 @@ async function typeText(rawParams) {
1180
1271
  element: { ...rest, ...(next === '' && placeholder !== undefined ? { placeholder } : {}), value: shown },
1181
1272
  value: shown,
1182
1273
  ...(secure ? { secure: true } : {}),
1274
+ ...(reach.scrolled ? { scrolled: true } : {}),
1183
1275
  ...keyboardNote(closedKeyboard),
1184
1276
  ...forced,
1185
1277
  ...outcome,
@@ -1268,6 +1360,18 @@ async function scrollToTarget(params) {
1268
1360
  throw badParams('to: "target" needs an element (ref, id, label or text), not a point');
1269
1361
  }
1270
1362
  const { snapshot, entry: wanted } = await resolveTarget(raw);
1363
+ const scrollers = scrollersOf(snapshot, wanted);
1364
+ if (params.dryRun === true) {
1365
+ const first = scrollers[0];
1366
+ const target = (await elementFor(wanted)).element;
1367
+ if (!first)
1368
+ return { element: target, target, offset: { x: 0, y: 0 }, moved: false, dryRun: true, settledMs: 0 };
1369
+ return { element: (await elementFor(first)).element, target, offset: { x: 0, y: 0 }, moved: false, dryRun: true, settledMs: 0 };
1370
+ }
1371
+ return scrollEntryIntoView(snapshot, wanted, scrollers);
1372
+ }
1373
+ /** The scroll views that hold an element, innermost first. */
1374
+ function scrollersOf(snapshot, wanted) {
1271
1375
  const scrollers = [];
1272
1376
  for (let p = wanted.node.parent; p >= 0;) {
1273
1377
  const candidate = snapshot.entries[p];
@@ -1278,26 +1382,22 @@ async function scrollToTarget(params) {
1278
1382
  break;
1279
1383
  p = node.parent;
1280
1384
  }
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
- }
1385
+ return scrollers;
1386
+ }
1387
+ /**
1388
+ * How far the element is from clear inside a scroll view, per axis. The area is the part of the
1389
+ * scroll view that is on screen between the bars. A scroll view that is itself out of view (a
1390
+ * list further down the page) has no such part: there the element only has to be inside the
1391
+ * scroll view, and the outer one brings both onto the screen. Without a scroll view the area is
1392
+ * the screen between the bars.
1393
+ */
1394
+ function shiftsFor(snapshot, wanted) {
1288
1395
  const { width, height } = snapshot.screen;
1289
1396
  const navBar = (0, inspect_1.nativeNavBar)(snapshot);
1290
1397
  const tabBar = (0, inspect_1.nativeTabBar)(snapshot);
1291
1398
  const clearTop = navBar && !(0, inspect_1.inNavBarHeader)(navBar, wanted.node.fiber) ? navBar.bottom : 0;
1292
1399
  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) => {
1400
+ return (frame, area) => {
1301
1401
  let left = Math.max(area?.x ?? 0, 0);
1302
1402
  let right = Math.min(area ? area.x + area.width : width, width);
1303
1403
  let top = Math.max(area?.y ?? 0, clearTop);
@@ -1310,6 +1410,10 @@ async function scrollToTarget(params) {
1310
1410
  }
1311
1411
  return { x: axisShift(frame.x, frame.width, left, right), y: axisShift(frame.y, frame.height, top, bottom) };
1312
1412
  };
1413
+ }
1414
+ /** The scrolling half of `scroll` with `to: "target"`: scrolls the given scroll views until `wanted` is clear. */
1415
+ async function scrollEntryIntoView(snapshot, wanted, scrollers) {
1416
+ const shiftsIn = shiftsFor(snapshot, wanted);
1313
1417
  let settledMs = 0;
1314
1418
  let settleCapMs;
1315
1419
  let scrolled = null;
@@ -1410,6 +1514,174 @@ async function scrollToTarget(params) {
1410
1514
  const { element } = await elementFor(scrolled.entry);
1411
1515
  return { element, target, offset: scrolled.offset, moved: true, reached, ...settle };
1412
1516
  }
1517
+ /** The part of a frame that is on the screen, or null when none of it is. */
1518
+ function visiblePart(frame, screen) {
1519
+ const left = Math.max(frame.x, 0);
1520
+ const top = Math.max(frame.y, 0);
1521
+ const right = Math.min(frame.x + frame.width, screen.width);
1522
+ const bottom = Math.min(frame.y + frame.height, screen.height);
1523
+ return right <= left || bottom <= top ? null : { x: left, y: top, width: right - left, height: bottom - top };
1524
+ }
1525
+ /**
1526
+ * An action on an element that is in the tree but entirely off screen scrolls it into view first, clear
1527
+ * of the bars (§16.2). The caller has run the policy check already. The touch checks (covered, behind an
1528
+ * alert, under a bar) run afterwards, as for any element. Refused as `off_screen`, with the reason, when
1529
+ * no scroll can bring it on screen. `dry` predicts the point after the scroll and scrolls nothing.
1530
+ */
1531
+ async function scrollIntoReach(snapshot, entry, dry) {
1532
+ const frame = await (0, inspect_1.measureFiber)(entry.node.fiber);
1533
+ if (frame === null)
1534
+ return {};
1535
+ // Partly on screen (over the edge, or under a bar): scrolled clear too, with one attempt; if that
1536
+ // can't be done the action goes on as before and the touch checks decide. Never refused for this.
1537
+ const partial = visiblePart(frame, snapshot.screen) !== null;
1538
+ if (partial && !isNotClear(snapshot, entry, frame))
1539
+ return {};
1540
+ const { width, height } = snapshot.screen;
1541
+ const name = describe(entry);
1542
+ const where = `at ${Math.round(frame.x)},${Math.round(frame.y)}; the screen is ${width}x${height}`;
1543
+ const scrollers = scrollersOf(snapshot, entry);
1544
+ if (scrollers.length === 0 && partial)
1545
+ return {};
1546
+ if (scrollers.length === 0) {
1547
+ 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.`);
1548
+ }
1549
+ if (dry) {
1550
+ const shiftsIn = shiftsFor(snapshot, entry);
1551
+ let predicted = frame;
1552
+ for (const scroller of scrollers) {
1553
+ const [area, state] = await Promise.all([(0, inspect_1.measureFiber)(scroller.node.fiber), measureScrollState(scroller)]);
1554
+ if (!area)
1555
+ continue;
1556
+ const horizontal = (0, inspect_1.findProp)(scroller.node, 'horizontal') === true;
1557
+ const shifts = shiftsIn(predicted, area);
1558
+ const wanted = horizontal ? shifts.x : shifts.y;
1559
+ if (Math.abs(wanted) < 1)
1560
+ continue;
1561
+ let delta = wanted;
1562
+ if (state) {
1563
+ const current = horizontal ? state.offset.x : state.offset.y;
1564
+ const max = horizontal ? state.max.x : state.max.y;
1565
+ delta = Math.max(Math.min(0, current), Math.min(current + wanted, Math.max(max, current))) - current;
1566
+ }
1567
+ predicted = horizontal ? { ...predicted, x: predicted.x - delta } : { ...predicted, y: predicted.y - delta };
1568
+ }
1569
+ if (partial) {
1570
+ return predicted.x === frame.x && predicted.y === frame.y ? {} : { scrolls: true, ...pointOf(visiblePart(predicted, snapshot.screen)) };
1571
+ }
1572
+ const seen = visiblePart(predicted, snapshot.screen);
1573
+ if (seen === null) {
1574
+ 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.`);
1575
+ }
1576
+ return { scrolls: true, ...pointOf(seen) };
1577
+ }
1578
+ let outcome;
1579
+ try {
1580
+ outcome = await scrollEntryIntoView(snapshot, entry, scrollers);
1581
+ }
1582
+ catch (error) {
1583
+ if (partial && error instanceof types_1.TaplineError)
1584
+ return {};
1585
+ throw error;
1586
+ }
1587
+ const after = await (0, inspect_1.measureFiber)(entry.node.fiber);
1588
+ if (partial) {
1589
+ if (!outcome.moved)
1590
+ return {};
1591
+ return after === null ? { scrolled: true, unverified: true } : { scrolled: true };
1592
+ }
1593
+ // A list may have unmounted the row on the way: the caller finds it again, and checks it is on screen.
1594
+ if (after === null && outcome.moved)
1595
+ return { scrolled: true, unverified: true };
1596
+ if (after === null || visiblePart(after, snapshot.screen) === null) {
1597
+ 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.`);
1598
+ }
1599
+ return outcome.moved ? { scrolled: true } : {};
1600
+ }
1601
+ /** Whether a partly visible element is off the screen edge or under a bar, and would fit clear of them if scrolled. */
1602
+ function isNotClear(snapshot, entry, frame) {
1603
+ const navBar = (0, inspect_1.nativeNavBar)(snapshot);
1604
+ const tabBar = (0, inspect_1.nativeTabBar)(snapshot);
1605
+ const clearTop = navBar && !(0, inspect_1.inNavBarHeader)(navBar, entry.node.fiber) ? navBar.bottom : 0;
1606
+ const clearBottom = tabBar ? tabBar.top : snapshot.screen.height;
1607
+ // Taller or wider than the room there is: no scroll makes it clear, so it is left as it is.
1608
+ if (frame.height > clearBottom - clearTop || frame.width > snapshot.screen.width)
1609
+ return false;
1610
+ const shift = shiftsFor(snapshot, entry)(frame, null);
1611
+ return Math.abs(shift.x) >= 1 || Math.abs(shift.y) >= 1;
1612
+ }
1613
+ /** A target that finds an element again by what it shows: the app's own target, else its id, label or text. */
1614
+ function refindTarget(entry, original) {
1615
+ if ((0, types_1.isRecord)(original) && original.ref === undefined && original.x === undefined && original.y === undefined)
1616
+ return original;
1617
+ const { testID, label, text } = entry.info;
1618
+ if (testID)
1619
+ return { testID };
1620
+ if (label)
1621
+ return { label };
1622
+ if (text !== '')
1623
+ return { text };
1624
+ return null;
1625
+ }
1626
+ /**
1627
+ * The same element after a scroll, from a fresh snapshot (the list may have laid out other rows on the
1628
+ * way). When a list unmounted the row, the ref is gone: it is found again by the original target.
1629
+ */
1630
+ async function reresolve(entry, hint, original) {
1631
+ let found;
1632
+ try {
1633
+ found = await resolveTarget({ ref: (0, inspect_1.refFor)(entry.node.fiber) }, hint);
1634
+ }
1635
+ catch (error) {
1636
+ if (!(error instanceof types_1.TaplineError) || error.code !== 'not_found')
1637
+ throw error;
1638
+ const again = refindTarget(entry, original);
1639
+ if (again === null)
1640
+ throw error;
1641
+ found = await resolveTarget(again, hint);
1642
+ }
1643
+ assertSameTarget(entry, found.entry);
1644
+ return found;
1645
+ }
1646
+ /**
1647
+ * After a scroll the element found again must still be the one the safety check saw: a list that recycles its rows
1648
+ * can hand the same component another row ("Continue" becomes "Delete account"). A changed id, label or text is
1649
+ * refused before anything is touched (Codex review of 1.4.0).
1650
+ */
1651
+ 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
1653
+ const was = { id: before.info.testID, name: labelOf(before.info) };
1654
+ 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
+ if (was.name === now.name) {
1659
+ was.name = before.info.text;
1660
+ now.name = after.info.text;
1661
+ }
1662
+ 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`);
1663
+ }
1664
+ /** After an element was found again because it could not be measured: it must now be on screen. */
1665
+ async function assertRefoundOnScreen(snapshot, entry, reach) {
1666
+ if (!reach.unverified)
1667
+ return;
1668
+ const frame = await (0, inspect_1.measureFiber)(entry.node.fiber);
1669
+ if (frame === null || visiblePart(frame, snapshot.screen) === null) {
1670
+ 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.`);
1671
+ }
1672
+ }
1673
+ /** `checkedOrForced`, and a refusal that comes after Tapline scrolled to the element says so. */
1674
+ async function checkedAfterScroll(force, reach, check) {
1675
+ try {
1676
+ return await checkedOrForced(force, check);
1677
+ }
1678
+ catch (error) {
1679
+ if (reach.scrolled && error instanceof types_1.TaplineError && error.code === 'not_actionable') {
1680
+ throw new types_1.TaplineError(error.code, `${error.message} (after scrolling to it)`, error.refs, error.candidates);
1681
+ }
1682
+ throw error;
1683
+ }
1684
+ }
1413
1685
  async function scroll(rawParams) {
1414
1686
  const params = paramsOf(rawParams);
1415
1687
  const to = params.to;
@@ -1615,18 +1887,40 @@ async function toggle(rawParams) {
1615
1887
  const params = paramsOf(rawParams);
1616
1888
  if (params.value !== undefined && typeof params.value !== 'boolean')
1617
1889
  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`);
1890
+ const first = await resolveTarget(params.target, 'toggle');
1891
+ const plan = (snap, picked) => {
1892
+ const found = findActionable(snap, picked, 'toggle', true);
1893
+ if (!found)
1894
+ throw notActionable('no_action', `${describe(picked)} is not a switch`);
1895
+ if (found.info.disabled)
1896
+ throw notActionable('disabled', `${describe(found)} is disabled; ${ENABLE_FIRST}.`);
1897
+ const own = (0, inspect_1.findHandler)(found.node, 'onValueChange');
1898
+ if (!own)
1899
+ throw notActionable('no_action', `${describe(found)} is not a switch`);
1900
+ return { control: found, handler: own };
1901
+ };
1902
+ let snapshot = first.snapshot;
1903
+ let planned = plan(snapshot, first.entry);
1904
+ // Off screen, in the tree: scroll it into view first (AGENT-TOOLS 1.12 §16.2). The policy ran before this call.
1905
+ let reach = {};
1906
+ if (params.force !== true) {
1907
+ assertNoBlockingAlert(planned.control);
1908
+ reach = await scrollIntoReach(snapshot, planned.control, params.dryRun === true);
1909
+ if (reach.scrolled) {
1910
+ const again = await reresolve(planned.control, 'toggle', params.target);
1911
+ snapshot = again.snapshot;
1912
+ planned = plan(snapshot, again.entry);
1913
+ await assertRefoundOnScreen(snapshot, planned.control, reach);
1914
+ }
1915
+ }
1916
+ const { control, handler } = planned;
1627
1917
  const current = control.info.checked === true;
1628
1918
  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)));
1919
+ if (reach.scrolls) {
1920
+ const { element } = await elementFor(control);
1921
+ return { element, value: next, dryRun: true, scrolls: true, ...(reach.point ? { point: reach.point } : {}), settledMs: 0 };
1922
+ }
1923
+ const forced = await checkedAfterScroll(params.force === true, reach, async () => assertReachable(snapshot, control, await (0, inspect_1.measureFiber)(control.node.fiber)));
1630
1924
  if (params.dryRun === true) {
1631
1925
  const { element, frame } = await elementFor(control);
1632
1926
  return { element, value: next, dryRun: true, ...pointOf(frame), ...forced, settledMs: 0 };
@@ -1634,13 +1928,13 @@ async function toggle(rawParams) {
1634
1928
  if (typeof params.value === 'boolean' && control.info.checked === params.value) {
1635
1929
  // Already there: a person wouldn't touch it, so the handler isn't called and the keyboard stays.
1636
1930
  const { element } = await elementFor(control);
1637
- return { element, value: params.value, unchanged: true, settledMs: 0 };
1931
+ return { element, value: params.value, unchanged: true, ...(reach.scrolled ? { scrolled: true } : {}), settledMs: 0 };
1638
1932
  }
1639
1933
  const closedKeyboard = closeKeyboardFor(control);
1640
1934
  callHandler('onValueChange', handler, [next]);
1641
1935
  const outcome = await settleOutcome();
1642
1936
  const { element } = await elementFor(control);
1643
- return { element: { ...element, checked: next }, value: next, ...keyboardNote(closedKeyboard), ...forced, ...outcome };
1937
+ return { element: { ...element, checked: next }, value: next, ...(reach.scrolled ? { scrolled: true } : {}), ...keyboardNote(closedKeyboard), ...forced, ...outcome };
1644
1938
  }
1645
1939
  /* -------------------------------------------------------------------------- */
1646
1940
  /* openURL / logs / reload / screenshot */
@@ -1683,6 +1977,19 @@ async function inspect(rawParams) {
1683
1977
  const { element } = await elementFor(entry);
1684
1978
  return { element, source: source ?? { components: [], location: null, props: {} } };
1685
1979
  }
1980
+ /** The frame, text and label a finding knows when its element could not be built; null when it knows none. */
1981
+ function whereOf(finding) {
1982
+ const text = finding.entry?.info.text ?? '';
1983
+ const label = finding.entry?.info.label ?? null;
1984
+ const where = {
1985
+ ...(finding.frame !== null && [finding.frame.x, finding.frame.y, finding.frame.width, finding.frame.height].every(Number.isFinite)
1986
+ ? { frame: finding.frame }
1987
+ : {}),
1988
+ ...(text !== '' ? { text: text.length > 80 ? `${text.slice(0, 79)}…` : text } : {}),
1989
+ ...(label !== null && label !== '' ? { label: label.length > 80 ? `${label.slice(0, 79)}…` : label } : {}),
1990
+ };
1991
+ return Object.keys(where).length > 0 ? where : null;
1992
+ }
1686
1993
  /** `audit {types?}` (AGENT-TOOLS 1.8 §10): the screen's accessibility problems, each with its source line in development. */
1687
1994
  async function audit(rawParams) {
1688
1995
  const params = paramsOf(rawParams);
@@ -1707,11 +2014,21 @@ async function audit(rawParams) {
1707
2014
  const issues = [];
1708
2015
  for (const finding of findings) {
1709
2016
  const source = finding.entry !== null ? sources[next++] : undefined;
1710
- const element = finding.entry !== null ? (await elementFor(finding.entry, finding.frame)).element : null;
2017
+ let element = null;
2018
+ if (finding.entry !== null) {
2019
+ try {
2020
+ element = (await elementFor(finding.entry, finding.frame)).element;
2021
+ }
2022
+ catch {
2023
+ element = null; // the finding still says where it is, below
2024
+ }
2025
+ }
2026
+ const where = element === null ? whereOf(finding) : null;
1711
2027
  const location = source?.location ?? null;
1712
2028
  issues.push({
1713
2029
  type: finding.type,
1714
2030
  element,
2031
+ ...(where !== null ? { where } : {}),
1715
2032
  problem: finding.problem,
1716
2033
  fix: finding.fix,
1717
2034
  ...(location !== null ? { at: { file: location.file, line: location.line, direct: location.direct } } : {}),