react-native-tapline 1.3.1 → 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 +12 -0
- package/dist/actions.js +340 -46
- package/dist/actions.js.map +1 -1
- package/dist/client.js +7 -0
- package/dist/client.js.map +1 -1
- package/dist/closest.d.ts +30 -0
- package/dist/closest.js +101 -0
- package/dist/closest.js.map +1 -0
- package/dist/commands.d.ts +9 -0
- package/dist/commands.js +135 -2
- package/dist/commands.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/inspect.js +4 -2
- package/dist/inspect.js.map +1 -1
- package/dist/locale.d.ts +67 -0
- package/dist/locale.js +224 -0
- package/dist/locale.js.map +1 -0
- package/dist/logs.d.ts +2 -0
- package/dist/logs.js +7 -0
- package/dist/logs.js.map +1 -1
- package/dist/network.d.ts +20 -2
- package/dist/network.js +731 -3
- package/dist/network.js.map +1 -1
- package/dist/types.d.ts +3 -2
- package/package.json +1 -1
package/dist/actions.d.ts
CHANGED
|
@@ -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;
|
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
|
|
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
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
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
|
|
1619
|
-
const
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
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
|
-
|
|
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 */
|