react-native-tapline 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +86 -2
- package/TaplineNative.podspec +14 -0
- package/dist/actions.d.ts +160 -0
- package/dist/actions.js +1883 -0
- package/dist/actions.js.map +1 -0
- package/dist/agent.d.ts +79 -0
- package/dist/agent.js +255 -0
- package/dist/agent.js.map +1 -0
- package/dist/alerts.d.ts +35 -0
- package/dist/alerts.js +437 -0
- package/dist/alerts.js.map +1 -0
- package/dist/awake.d.ts +2 -0
- package/dist/awake.js +53 -0
- package/dist/awake.js.map +1 -0
- package/dist/checks.d.ts +19 -0
- package/dist/checks.js +318 -0
- package/dist/checks.js.map +1 -0
- package/dist/client.d.ts +111 -0
- package/dist/client.js +446 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.d.ts +24 -0
- package/dist/commands.js +111 -0
- package/dist/commands.js.map +1 -0
- package/dist/events.d.ts +5 -0
- package/dist/events.js +22 -0
- package/dist/events.js.map +1 -0
- package/dist/feed.d.ts +117 -0
- package/dist/feed.js +440 -0
- package/dist/feed.js.map +1 -0
- package/dist/fiber.d.ts +43 -0
- package/dist/fiber.js +100 -0
- package/dist/fiber.js.map +1 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +133 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect.d.ts +245 -0
- package/dist/inspect.js +1343 -0
- package/dist/inspect.js.map +1 -0
- package/dist/keyboard.d.ts +10 -0
- package/dist/keyboard.js +63 -0
- package/dist/keyboard.js.map +1 -0
- package/dist/link.d.ts +21 -0
- package/dist/link.js +63 -0
- package/dist/link.js.map +1 -0
- package/dist/live.d.ts +30 -0
- package/dist/live.js +279 -0
- package/dist/live.js.map +1 -0
- package/dist/logs.d.ts +16 -0
- package/dist/logs.js +171 -0
- package/dist/logs.js.map +1 -0
- package/dist/methods.d.ts +3 -0
- package/dist/methods.js +47 -0
- package/dist/methods.js.map +1 -0
- package/dist/native.d.ts +34 -0
- package/dist/native.js +63 -0
- package/dist/native.js.map +1 -0
- package/dist/network.d.ts +27 -0
- package/dist/network.js +238 -0
- package/dist/network.js.map +1 -0
- package/dist/register.d.ts +24 -0
- package/dist/register.js +180 -0
- package/dist/register.js.map +1 -0
- package/dist/settle.d.ts +27 -0
- package/dist/settle.js +70 -0
- package/dist/settle.js.map +1 -0
- package/dist/storage.d.ts +10 -0
- package/dist/storage.js +45 -0
- package/dist/storage.js.map +1 -0
- package/dist/touches.d.ts +15 -0
- package/dist/touches.js +89 -0
- package/dist/touches.js.map +1 -0
- package/dist/types.d.ts +168 -0
- package/dist/types.js +32 -0
- package/dist/types.js.map +1 -0
- package/dist/wait.d.ts +8 -0
- package/dist/wait.js +99 -0
- package/dist/wait.js.map +1 -0
- package/expo-module.config.json +7 -0
- package/ios/TaplineNativeModule.swift +79 -0
- package/metro.d.ts +10 -0
- package/metro.js +40 -0
- package/package.json +100 -4
- package/register.d.ts +1 -0
- package/register.js +3 -0
package/dist/actions.js
ADDED
|
@@ -0,0 +1,1883 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.tree = void 0;
|
|
4
|
+
exports.configureActions = configureActions;
|
|
5
|
+
exports.onActed = onActed;
|
|
6
|
+
exports.settle = settle;
|
|
7
|
+
exports.settleOutcome = settleOutcome;
|
|
8
|
+
exports.parseTarget = parseTarget;
|
|
9
|
+
exports.labelOf = labelOf;
|
|
10
|
+
exports.resolveTarget = resolveTarget;
|
|
11
|
+
exports.describeHit = describeHit;
|
|
12
|
+
exports.back = back;
|
|
13
|
+
exports.press = press;
|
|
14
|
+
exports.longPress = longPress;
|
|
15
|
+
exports.setKeyboardControl = setKeyboardControl;
|
|
16
|
+
exports.typeText = typeText;
|
|
17
|
+
exports.scroll = scroll;
|
|
18
|
+
exports.toggle = toggle;
|
|
19
|
+
exports.openURL = openURL;
|
|
20
|
+
exports.getLogs = getLogs;
|
|
21
|
+
exports.reload = reload;
|
|
22
|
+
exports.screenshotAvailable = screenshotAvailable;
|
|
23
|
+
exports.screenshot = screenshot;
|
|
24
|
+
exports.swipe = swipe;
|
|
25
|
+
const react_native_1 = require("react-native");
|
|
26
|
+
const keyboard_1 = require("./keyboard");
|
|
27
|
+
const native_1 = require("./native");
|
|
28
|
+
const inspect_1 = require("./inspect");
|
|
29
|
+
const alerts_1 = require("./alerts");
|
|
30
|
+
const fiber_1 = require("./fiber");
|
|
31
|
+
const logs_1 = require("./logs");
|
|
32
|
+
const register_1 = require("./register");
|
|
33
|
+
const settle_1 = require("./settle");
|
|
34
|
+
const types_1 = require("./types");
|
|
35
|
+
var inspect_2 = require("./inspect");
|
|
36
|
+
Object.defineProperty(exports, "tree", { enumerable: true, get: function () { return inspect_2.tree; } });
|
|
37
|
+
/** What follows `disabled`: `force` never acts on a disabled element (the check comes first), so the app has to enable it. */
|
|
38
|
+
const ENABLE_FIRST = 'the app has to enable it first (force doesn\'t apply)';
|
|
39
|
+
function notActionable(reason, message) {
|
|
40
|
+
return new types_1.TaplineError('not_actionable', `${reason}: ${message}`);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Runs the checks for what a person could do. Without `force` a refusal is thrown as usual. With
|
|
44
|
+
* it the checks still run, and what they would have refused comes back as `{ forced: <reason> }`,
|
|
45
|
+
* so the answer can say that a person couldn't have done this (AGENT-TOOLS v1.6). Anything else
|
|
46
|
+
* that goes wrong inside a forced check is ignored: `force` means "act anyway".
|
|
47
|
+
*/
|
|
48
|
+
async function checkedOrForced(force, check) {
|
|
49
|
+
if (!force) {
|
|
50
|
+
await check();
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
await check();
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error instanceof types_1.TaplineError && error.code === 'not_actionable') {
|
|
58
|
+
return { forced: error.message.slice(0, Math.max(0, error.message.indexOf(':'))) || 'not_actionable' };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
const settings = { quietMs: 100, capMs: 2000 };
|
|
64
|
+
/** Tune how long the app must be free of React commits after an action, and the longest wait (AGENT-TOOLS §6). */
|
|
65
|
+
function configureActions(next) {
|
|
66
|
+
if (typeof next.quietMs === 'number')
|
|
67
|
+
settings.quietMs = next.quietMs;
|
|
68
|
+
if (typeof next.capMs === 'number')
|
|
69
|
+
settings.capMs = next.capMs;
|
|
70
|
+
}
|
|
71
|
+
const actedListeners = new Set();
|
|
72
|
+
/** Runs `listener` when an action's handler has run, before the wait for the app to settle. */
|
|
73
|
+
function onActed(listener) {
|
|
74
|
+
actedListeners.add(listener);
|
|
75
|
+
return () => {
|
|
76
|
+
actedListeners.delete(listener);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function notifyActed() {
|
|
80
|
+
for (const listener of [...actedListeners]) {
|
|
81
|
+
try {
|
|
82
|
+
listener();
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// a listener must never break the action
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Waits until the app is still after an action; resolves with the milliseconds that took. */
|
|
90
|
+
async function settle() {
|
|
91
|
+
notifyActed();
|
|
92
|
+
return (await settleNow()).settledMs;
|
|
93
|
+
}
|
|
94
|
+
/** Like `settle`, and says when the wait ended at the cap: spread it into an action's result. */
|
|
95
|
+
async function settleOutcome() {
|
|
96
|
+
notifyActed();
|
|
97
|
+
return settleNow();
|
|
98
|
+
}
|
|
99
|
+
const SHEET_POLL_MS = 100;
|
|
100
|
+
/** Whether `fiber` sits inside `host`. */
|
|
101
|
+
function isInside(fiber, host) {
|
|
102
|
+
for (let up = fiber.return; up; up = up.return)
|
|
103
|
+
if ((0, fiber_1.sameFiber)(up, host))
|
|
104
|
+
return true;
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
/** The fiber to watch in the top open modal: its first element with an action, else its first framed host. */
|
|
108
|
+
async function sheetProbe() {
|
|
109
|
+
const hosts = (0, inspect_1.openModalHosts)();
|
|
110
|
+
const top = hosts[hosts.length - 1];
|
|
111
|
+
if (!top)
|
|
112
|
+
return null;
|
|
113
|
+
const inside = (0, inspect_1.takeSnapshot)().entries.filter((entry) => isInside(entry.node.fiber, top));
|
|
114
|
+
const acting = inside.find((entry) => entry.info.actions.length > 0);
|
|
115
|
+
if (acting)
|
|
116
|
+
return acting.node.fiber;
|
|
117
|
+
for (const entry of inside) {
|
|
118
|
+
const frame = await (0, inspect_1.measureFiber)(entry.node.fiber);
|
|
119
|
+
if (frame && frame.width > 0 && frame.height > 0)
|
|
120
|
+
return entry.node.fiber;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
async function settleNow() {
|
|
125
|
+
const started = Date.now();
|
|
126
|
+
let capped = false;
|
|
127
|
+
const onCap = () => {
|
|
128
|
+
capped = true;
|
|
129
|
+
};
|
|
130
|
+
await (0, settle_1.waitUntilStill)({
|
|
131
|
+
...settings,
|
|
132
|
+
onCommit: (listener) => (0, register_1.onCommit)(() => listener()),
|
|
133
|
+
now: Date.now,
|
|
134
|
+
onCap,
|
|
135
|
+
});
|
|
136
|
+
const left = settings.capMs - (Date.now() - started);
|
|
137
|
+
if (left > 0) {
|
|
138
|
+
// A sheet lifted by a native-driver animation makes no commits: wait for it to stop moving.
|
|
139
|
+
const probe = await sheetProbe().catch(() => null);
|
|
140
|
+
if (probe)
|
|
141
|
+
await (0, settle_1.waitUntilSteady)(() => (0, inspect_1.measureFiber)(probe), { pollMs: SHEET_POLL_MS, capMs: left, now: Date.now, onCap });
|
|
142
|
+
}
|
|
143
|
+
// A sheet being dismissed stays mounted (and covering the screen) until iOS ends the animation.
|
|
144
|
+
if ((0, inspect_1.closingModalHosts)().length > 0) {
|
|
145
|
+
while ((0, inspect_1.closingModalHosts)().length > 0) {
|
|
146
|
+
const remaining = settings.capMs - (Date.now() - started);
|
|
147
|
+
if (remaining <= 0) {
|
|
148
|
+
capped = true;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(SHEET_POLL_MS, remaining)));
|
|
152
|
+
}
|
|
153
|
+
// Unmounting the modal commits: let the app go quiet again, within the cap.
|
|
154
|
+
const afterwards = settings.capMs - (Date.now() - started);
|
|
155
|
+
if (afterwards > 0) {
|
|
156
|
+
await (0, settle_1.waitUntilStill)({
|
|
157
|
+
quietMs: settings.quietMs,
|
|
158
|
+
capMs: afterwards,
|
|
159
|
+
onCommit: (listener) => (0, register_1.onCommit)(() => listener()),
|
|
160
|
+
now: Date.now,
|
|
161
|
+
onCap,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const settledMs = Math.max(0, Math.min(settings.capMs, Date.now() - started));
|
|
166
|
+
return capped && settings.capMs > 0 ? { settledMs, settleCapMs: settings.capMs } : { settledMs };
|
|
167
|
+
}
|
|
168
|
+
function badParams(message) {
|
|
169
|
+
return new types_1.TaplineError('bad_params', message);
|
|
170
|
+
}
|
|
171
|
+
function paramsOf(raw) {
|
|
172
|
+
if (raw === undefined || raw === null)
|
|
173
|
+
return {};
|
|
174
|
+
if (!(0, types_1.isRecord)(raw))
|
|
175
|
+
throw badParams('params must be an object');
|
|
176
|
+
return raw;
|
|
177
|
+
}
|
|
178
|
+
/** An element as element lines name it: `e6 button "Continue"`, or `id=<testID>` without text or label. */
|
|
179
|
+
function describe(entry, maxText = inspect_1.MAX_TEXT) {
|
|
180
|
+
const { info } = entry;
|
|
181
|
+
const shown = (0, inspect_1.cutText)(info.text !== '' ? info.text : (info.label ?? ''), 0, maxText);
|
|
182
|
+
const name = shown !== '' ? `"${shown}"` : info.testID ? `id=${info.testID}` : '';
|
|
183
|
+
return [(0, inspect_1.refFor)(entry.node.fiber), (0, inspect_1.roleName)(info), name].filter((part) => part !== '').join(' ');
|
|
184
|
+
}
|
|
185
|
+
/** Call an app handler; sync throws become errors, async rejections are logged not awaited. */
|
|
186
|
+
function callHandler(name, handler, args) {
|
|
187
|
+
let result;
|
|
188
|
+
try {
|
|
189
|
+
result = handler(...args);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
193
|
+
throw new types_1.TaplineError('internal', `${name} threw: ${message}`);
|
|
194
|
+
}
|
|
195
|
+
if ((0, types_1.isRecord)(result) && typeof result.then === 'function') {
|
|
196
|
+
result.then(undefined, (error) => {
|
|
197
|
+
console.error(`[tapline] ${name} rejected:`, error);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function parseTarget(raw) {
|
|
202
|
+
if (!(0, types_1.isRecord)(raw))
|
|
203
|
+
throw badParams('target must be an object such as {"testID":"login"}');
|
|
204
|
+
let index;
|
|
205
|
+
if (raw.index !== undefined) {
|
|
206
|
+
if (typeof raw.index !== 'number' || !Number.isInteger(raw.index) || raw.index < 0) {
|
|
207
|
+
throw badParams('target.index must be a non-negative integer');
|
|
208
|
+
}
|
|
209
|
+
index = raw.index;
|
|
210
|
+
}
|
|
211
|
+
const kinds = [];
|
|
212
|
+
const withIndex = (target) => (index === undefined ? target : { ...target, index });
|
|
213
|
+
if (raw.ref !== undefined) {
|
|
214
|
+
if (typeof raw.ref !== 'string')
|
|
215
|
+
throw badParams('target.ref must be a string');
|
|
216
|
+
kinds.push(withIndex({ kind: 'ref', ref: raw.ref }));
|
|
217
|
+
}
|
|
218
|
+
for (const key of ['testID', 'label', 'text']) {
|
|
219
|
+
const value = raw[key];
|
|
220
|
+
if (value === undefined)
|
|
221
|
+
continue;
|
|
222
|
+
if (typeof value !== 'string')
|
|
223
|
+
throw badParams(`target.${key} must be a string`);
|
|
224
|
+
// Invisible direction marks never match: strip them from what the agent typed, as the list does.
|
|
225
|
+
kinds.push(withIndex({ kind: key, value: key === 'testID' ? value : (0, inspect_1.displayText)(value) }));
|
|
226
|
+
}
|
|
227
|
+
if (raw.x !== undefined || raw.y !== undefined) {
|
|
228
|
+
if (typeof raw.x !== 'number' || typeof raw.y !== 'number') {
|
|
229
|
+
throw badParams('target.x and target.y must both be numbers');
|
|
230
|
+
}
|
|
231
|
+
kinds.push(withIndex({ kind: 'point', x: raw.x, y: raw.y }));
|
|
232
|
+
}
|
|
233
|
+
const first = kinds[0];
|
|
234
|
+
if (!first)
|
|
235
|
+
throw badParams('target needs one of: ref, testID, label, text, or x and y');
|
|
236
|
+
if (kinds.length > 1)
|
|
237
|
+
throw badParams('target must have exactly one of: ref, testID, label, text, x+y');
|
|
238
|
+
return first;
|
|
239
|
+
}
|
|
240
|
+
function isAncestor(snapshot, ancestor, descendant) {
|
|
241
|
+
for (let p = descendant.node.parent; p >= 0;) {
|
|
242
|
+
if (p === ancestor.node.index)
|
|
243
|
+
return true;
|
|
244
|
+
const next = snapshot.nodes[p];
|
|
245
|
+
if (!next)
|
|
246
|
+
return false;
|
|
247
|
+
p = next.parent;
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Collapse matches that are the same thing seen at two levels of the host tree.
|
|
253
|
+
* 'outer': props like testID propagate down, keep the outermost.
|
|
254
|
+
* 'text': keep the actionable one, otherwise the innermost.
|
|
255
|
+
*/
|
|
256
|
+
function dedupe(snapshot, matches, mode) {
|
|
257
|
+
const drop = new Set();
|
|
258
|
+
for (const a of matches) {
|
|
259
|
+
for (const b of matches) {
|
|
260
|
+
if (a === b || !isAncestor(snapshot, a, b))
|
|
261
|
+
continue;
|
|
262
|
+
if (mode === 'outer') {
|
|
263
|
+
drop.add(b);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
// A container that only scrolls is not a target: its text is its children's text joined.
|
|
267
|
+
const aActs = a.info.actions.some((action) => action !== 'scroll');
|
|
268
|
+
const bActs = b.info.actions.some((action) => action !== 'scroll');
|
|
269
|
+
if (aActs && !bActs)
|
|
270
|
+
drop.add(b);
|
|
271
|
+
else if (!aActs)
|
|
272
|
+
drop.add(a);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return matches.filter((match) => !drop.has(match));
|
|
277
|
+
}
|
|
278
|
+
function describeTarget(target) {
|
|
279
|
+
switch (target.kind) {
|
|
280
|
+
case 'ref':
|
|
281
|
+
return `ref "${target.ref}"`;
|
|
282
|
+
case 'point':
|
|
283
|
+
return `point (${target.x}, ${target.y})`;
|
|
284
|
+
case 'testID':
|
|
285
|
+
return `id "${target.value}"`;
|
|
286
|
+
default:
|
|
287
|
+
return `${target.kind} "${target.value}"`;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/** AGENT-TOOLS §4: what a `not_found` says, per kind of target. */
|
|
291
|
+
function notFoundMessage(target) {
|
|
292
|
+
switch (target.kind) {
|
|
293
|
+
case 'testID':
|
|
294
|
+
return `nothing on screen has id "${target.value}"; look, then pick another target.`;
|
|
295
|
+
case 'label':
|
|
296
|
+
return `nothing on screen has label "${target.value}"; use {text} for a partial match, or look.`;
|
|
297
|
+
case 'text':
|
|
298
|
+
return `nothing on screen shows "${target.value}"; look, then pick another target.`;
|
|
299
|
+
case 'ref':
|
|
300
|
+
return `${target.ref} isn't on screen any more; look again.`;
|
|
301
|
+
case 'point':
|
|
302
|
+
return `nothing on screen at (${target.x}, ${target.y}); look, then pick another target.`;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* What a `label` target matches: the element's own accessibility label, else its text. iOS forms
|
|
307
|
+
* the label of an element that has none from the text inside it, and Tapwright reads that label, so
|
|
308
|
+
* `{label: "OK"}` finds a button that only shows "OK" in both tools.
|
|
309
|
+
*/
|
|
310
|
+
function labelOf(info) {
|
|
311
|
+
return info.label ?? info.text;
|
|
312
|
+
}
|
|
313
|
+
function matchEntries(snapshot, target) {
|
|
314
|
+
const pool = snapshot.entries.filter((entry) => entry.info.interesting);
|
|
315
|
+
switch (target.kind) {
|
|
316
|
+
case 'testID':
|
|
317
|
+
return dedupe(snapshot, pool.filter((e) => e.info.testID === target.value), 'outer');
|
|
318
|
+
case 'label': {
|
|
319
|
+
const wanted = target.value.trim().toLowerCase();
|
|
320
|
+
if (wanted === '')
|
|
321
|
+
return [];
|
|
322
|
+
return dedupe(snapshot, pool.filter((e) => labelOf(e.info).toLowerCase() === wanted), 'outer');
|
|
323
|
+
}
|
|
324
|
+
case 'text': {
|
|
325
|
+
const wanted = target.value.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
326
|
+
if (wanted === '')
|
|
327
|
+
return [];
|
|
328
|
+
const exact = pool.filter((e) => e.info.text.toLowerCase() === wanted);
|
|
329
|
+
if (exact.length > 0)
|
|
330
|
+
return dedupe(snapshot, exact, 'text');
|
|
331
|
+
return dedupe(snapshot, pool.filter((e) => e.info.text.toLowerCase().includes(wanted)), 'text');
|
|
332
|
+
}
|
|
333
|
+
default:
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/** What a point takes, as `Nothing at (x, y) takes <this>.` reads (AGENT-TOOLS §4). */
|
|
338
|
+
const HINT_VERBS = {
|
|
339
|
+
press: 'a tap',
|
|
340
|
+
longPress: 'a long press',
|
|
341
|
+
toggle: 'a toggle',
|
|
342
|
+
scroll: 'a scroll',
|
|
343
|
+
type: 'typing',
|
|
344
|
+
};
|
|
345
|
+
/** The candidates of an `ambiguous` error as full elements, so the agent sees role, id, actions and place. */
|
|
346
|
+
async function candidateElements(entries) {
|
|
347
|
+
const frames = await Promise.all(entries.map((entry) => (0, inspect_1.measureFiber)(entry.node.fiber)));
|
|
348
|
+
return entries.map((entry, i) => (0, inspect_1.toElement)(entry, entry.tab ? null : (frames[i] ?? null)));
|
|
349
|
+
}
|
|
350
|
+
/** How far from the left edge the native navigation bar's back button reaches, in points. */
|
|
351
|
+
const BACK_BUTTON_WIDTH = 100;
|
|
352
|
+
/** A stand-in fiber for the navigation bar's back button, which iOS draws outside React's tree. */
|
|
353
|
+
const backButtonFiber = {
|
|
354
|
+
tag: -1,
|
|
355
|
+
type: 'BackButton',
|
|
356
|
+
elementType: 'BackButton',
|
|
357
|
+
memoizedProps: {},
|
|
358
|
+
stateNode: null,
|
|
359
|
+
child: null,
|
|
360
|
+
sibling: null,
|
|
361
|
+
return: null,
|
|
362
|
+
alternate: null,
|
|
363
|
+
};
|
|
364
|
+
function backButtonEntry(snapshot) {
|
|
365
|
+
const node = {
|
|
366
|
+
index: snapshot.nodes.length,
|
|
367
|
+
parent: -1,
|
|
368
|
+
fiber: backButtonFiber,
|
|
369
|
+
hostType: 'BackButton',
|
|
370
|
+
owners: [],
|
|
371
|
+
text: 'Back',
|
|
372
|
+
isText: false,
|
|
373
|
+
insideText: false,
|
|
374
|
+
};
|
|
375
|
+
return {
|
|
376
|
+
node,
|
|
377
|
+
navBack: true,
|
|
378
|
+
info: {
|
|
379
|
+
type: 'BackButton',
|
|
380
|
+
role: 'button',
|
|
381
|
+
testID: null,
|
|
382
|
+
label: 'Back',
|
|
383
|
+
text: 'Back',
|
|
384
|
+
value: null,
|
|
385
|
+
placeholder: null,
|
|
386
|
+
actions: ['press'],
|
|
387
|
+
disabled: false,
|
|
388
|
+
selected: false,
|
|
389
|
+
checked: null,
|
|
390
|
+
interesting: true,
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
/** An element as the list words it: `[ref ]role "text" id=… @x,y WxH` (the position only when its frame is known). */
|
|
395
|
+
function elementLine(entry, frame, withRef) {
|
|
396
|
+
const { info } = entry;
|
|
397
|
+
const shown = (0, inspect_1.cutText)(info.text !== '' ? info.text : (info.label ?? ''));
|
|
398
|
+
return [
|
|
399
|
+
withRef ? (0, inspect_1.refFor)(entry.node.fiber) : '',
|
|
400
|
+
(0, inspect_1.roleName)(info),
|
|
401
|
+
shown !== '' ? `"${shown}"` : '',
|
|
402
|
+
info.testID ? `id=${info.testID}` : '',
|
|
403
|
+
frame ? `@${Math.round(frame.x)},${Math.round(frame.y)} ${Math.round(frame.width)}x${Math.round(frame.height)}` : '',
|
|
404
|
+
]
|
|
405
|
+
.filter((part) => part !== '')
|
|
406
|
+
.join(' ');
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* AGENT-TOOLS v1.4: a target that matches nothing in the list, but matches an element only hidden
|
|
410
|
+
* because an open sheet covers its screen, is `covered`, not `not_found`. The answer names what is
|
|
411
|
+
* in front (with its ref, so the agent can act on it); `force` doesn't help, the element isn't on
|
|
412
|
+
* the list. An OS alert still answers `behind_alert` first. Returns when nothing hidden matches.
|
|
413
|
+
*/
|
|
414
|
+
async function refuseBehindSheet(snapshot, target) {
|
|
415
|
+
const behind = snapshot.behind?.();
|
|
416
|
+
if (!behind)
|
|
417
|
+
return;
|
|
418
|
+
const matches = matchEntries({ ...snapshot, nodes: behind.nodes, entries: behind.hidden }, target);
|
|
419
|
+
const hidden = matches[target.index ?? 0];
|
|
420
|
+
if (!hidden)
|
|
421
|
+
return;
|
|
422
|
+
assertNoBlockingAlert();
|
|
423
|
+
const [hiddenFrame, inFront] = await Promise.all([(0, inspect_1.measureFiber)(hidden.node.fiber), frontLine(snapshot)]);
|
|
424
|
+
throw notActionable('covered', `${elementLine(hidden, hiddenFrame, false)} is behind ${inFront ?? 'the open sheet'}; close that first.`);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* The line of the first element in front that has a text or an id (with its ref, so the agent can
|
|
428
|
+
* act on it): what names an open sheet in an answer. Undefined when nothing in front has either.
|
|
429
|
+
*/
|
|
430
|
+
async function frontLine(snapshot) {
|
|
431
|
+
const front = snapshot.entries.find((entry) => entry.info.interesting && !entry.tab && (entry.info.text.trim() !== '' || entry.info.testID));
|
|
432
|
+
return front ? elementLine(front, await (0, inspect_1.measureFiber)(front.node.fiber), true) : undefined;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* AGENT-TOOLS v1.5: when several elements match a target and some of them are in the alert that is
|
|
436
|
+
* in front, only those count; a person sees one "Cancel", not the one behind the alert too. With no
|
|
437
|
+
* match in the alert the list is left alone, so a target behind it still answers `behind_alert`.
|
|
438
|
+
* (What an open sheet hides isn't in the list at all, so a sheet needs no such rule.)
|
|
439
|
+
*/
|
|
440
|
+
function inFrontLayer(matches) {
|
|
441
|
+
if (matches.length < 2 || !(0, alerts_1.openAlerts)().some((alert) => alert.standIn))
|
|
442
|
+
return matches;
|
|
443
|
+
const inAlert = matches.filter((entry) => inStandInAlert(entry.node.fiber));
|
|
444
|
+
return inAlert.length > 0 ? inAlert : matches;
|
|
445
|
+
}
|
|
446
|
+
/** Resolve a protocol target to exactly one element, or throw not_found / ambiguous. */
|
|
447
|
+
async function resolveTarget(raw, hint) {
|
|
448
|
+
const target = parseTarget(raw);
|
|
449
|
+
const snapshot = (0, inspect_1.takeSnapshot)();
|
|
450
|
+
if (target.kind === 'ref') {
|
|
451
|
+
const fiber = (0, inspect_1.fiberForRef)(target.ref);
|
|
452
|
+
const entry = fiber ? snapshot.entries.find((e) => (0, fiber_1.sameFiber)(e.node.fiber, fiber)) : undefined;
|
|
453
|
+
if (!entry)
|
|
454
|
+
throw new types_1.TaplineError('not_found', notFoundMessage(target));
|
|
455
|
+
return { snapshot, entry };
|
|
456
|
+
}
|
|
457
|
+
if (target.kind === 'point') {
|
|
458
|
+
// iOS draws the native tab bar over the app: a touch there lands on a tab, not on what's behind.
|
|
459
|
+
const bar = (0, inspect_1.nativeTabBar)(snapshot);
|
|
460
|
+
const tab = bar && target.y >= bar.top ? (0, inspect_1.tabAt)(bar, target.x, snapshot.screen.width) : undefined;
|
|
461
|
+
if (tab)
|
|
462
|
+
return { snapshot, entry: tab };
|
|
463
|
+
// Likewise the navigation bar at the top: a touch there hits the bar, unless it lands on one of
|
|
464
|
+
// the custom items the app put in the header.
|
|
465
|
+
const navBar = (0, inspect_1.nativeNavBar)(snapshot);
|
|
466
|
+
if (navBar && target.y < navBar.bottom) {
|
|
467
|
+
const inHeader = snapshot.entries.filter((e) => !e.tab && (0, inspect_1.inNavBarHeader)(navBar, e.node.fiber));
|
|
468
|
+
const headerFrames = await Promise.all(inHeader.map((e) => (0, inspect_1.measureFiber)(e.node.fiber)));
|
|
469
|
+
const onItem = headerFrames.some((frame) => frame !== null && (0, inspect_1.frameContains)(frame, target.x, target.y));
|
|
470
|
+
if (!onItem) {
|
|
471
|
+
if (navBar.canGoBack && target.x < BACK_BUTTON_WIDTH && (hint === undefined || hint === 'press')) {
|
|
472
|
+
return { snapshot, entry: backButtonEntry(snapshot) };
|
|
473
|
+
}
|
|
474
|
+
throw notActionable('no_action', `Nothing at (${target.x}, ${target.y}) takes ${hint === undefined ? 'a touch' : HINT_VERBS[hint]}: it's on the navigation bar iOS draws.`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const actionable = snapshot.entries.filter((e) => hint === undefined ? e.info.actions.length > 0 : e.info.actions.includes(hint));
|
|
478
|
+
const frames = await Promise.all(actionable.map((e) => (0, inspect_1.measureFiber)(e.node.fiber)));
|
|
479
|
+
let hit;
|
|
480
|
+
actionable.forEach((entry, i) => {
|
|
481
|
+
const frame = frames[i];
|
|
482
|
+
if (frame && (0, inspect_1.frameContains)(frame, target.x, target.y))
|
|
483
|
+
hit = entry; // later in tree order = in front
|
|
484
|
+
});
|
|
485
|
+
if (!hit) {
|
|
486
|
+
const verb = hint === undefined ? 'a touch' : HINT_VERBS[hint];
|
|
487
|
+
throw notActionable('no_action', `Nothing at (${target.x}, ${target.y}) takes ${verb}.`);
|
|
488
|
+
}
|
|
489
|
+
return { snapshot, entry: hit };
|
|
490
|
+
}
|
|
491
|
+
const matches = inFrontLayer(matchEntries(snapshot, target));
|
|
492
|
+
if (matches.length === 0)
|
|
493
|
+
await refuseBehindSheet(snapshot, target);
|
|
494
|
+
if (target.index !== undefined) {
|
|
495
|
+
const picked = matches[target.index];
|
|
496
|
+
if (!picked) {
|
|
497
|
+
throw new types_1.TaplineError('not_found', `Only ${matches.length} element(s) match ${describeTarget(target)}; index ${target.index} is out of range`);
|
|
498
|
+
}
|
|
499
|
+
return { snapshot, entry: picked };
|
|
500
|
+
}
|
|
501
|
+
const [only, ...rest] = matches;
|
|
502
|
+
if (!only)
|
|
503
|
+
throw new types_1.TaplineError('not_found', notFoundMessage(target));
|
|
504
|
+
if (rest.length > 0) {
|
|
505
|
+
const shown = matches.slice(0, 5);
|
|
506
|
+
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));
|
|
507
|
+
}
|
|
508
|
+
return { snapshot, entry: only };
|
|
509
|
+
}
|
|
510
|
+
/** The entry itself, else the nearest ancestor that offers `action`. */
|
|
511
|
+
function selfOrAncestor(snapshot, entry, action) {
|
|
512
|
+
for (let node = entry.node;;) {
|
|
513
|
+
const candidate = snapshot.entries[node.index];
|
|
514
|
+
if (candidate?.info.actions.includes(action))
|
|
515
|
+
return candidate;
|
|
516
|
+
if (node.parent < 0)
|
|
517
|
+
return null;
|
|
518
|
+
const parent = snapshot.nodes[node.parent];
|
|
519
|
+
if (!parent)
|
|
520
|
+
return null;
|
|
521
|
+
node = parent;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function descendantsWith(snapshot, entry, action) {
|
|
525
|
+
return snapshot.entries.filter((candidate) => candidate.info.actions.includes(action) && isAncestor(snapshot, entry, candidate));
|
|
526
|
+
}
|
|
527
|
+
/** Self, then ancestors, then a single descendant offering `action`. */
|
|
528
|
+
function findActionable(snapshot, entry, action, descend) {
|
|
529
|
+
const up = selfOrAncestor(snapshot, entry, action);
|
|
530
|
+
if (up)
|
|
531
|
+
return up;
|
|
532
|
+
if (!descend)
|
|
533
|
+
return null;
|
|
534
|
+
const down = descendantsWith(snapshot, entry, action);
|
|
535
|
+
return down.length === 1 ? (down[0] ?? null) : null;
|
|
536
|
+
}
|
|
537
|
+
async function elementFor(entry, knownFrame) {
|
|
538
|
+
const frame = knownFrame !== undefined ? knownFrame : await (0, inspect_1.measureFiber)(entry.node.fiber);
|
|
539
|
+
return { element: (0, inspect_1.toElement)(entry, frame), frame };
|
|
540
|
+
}
|
|
541
|
+
/* -------------------------------------------------------------------------- */
|
|
542
|
+
/* press / longPress */
|
|
543
|
+
/* -------------------------------------------------------------------------- */
|
|
544
|
+
function nativeTagOf(fiber) {
|
|
545
|
+
const state = fiber.stateNode;
|
|
546
|
+
if ((0, types_1.isRecord)(state)) {
|
|
547
|
+
if (typeof state._nativeTag === 'number')
|
|
548
|
+
return state._nativeTag;
|
|
549
|
+
const canonical = state.canonical;
|
|
550
|
+
if ((0, types_1.isRecord)(canonical) && typeof canonical.nativeTag === 'number')
|
|
551
|
+
return canonical.nativeTag;
|
|
552
|
+
}
|
|
553
|
+
return 0;
|
|
554
|
+
}
|
|
555
|
+
function pressEvent(fiber, frame) {
|
|
556
|
+
const width = frame?.width ?? 0;
|
|
557
|
+
const height = frame?.height ?? 0;
|
|
558
|
+
const pageX = (frame?.x ?? 0) + width / 2;
|
|
559
|
+
const pageY = (frame?.y ?? 0) + height / 2;
|
|
560
|
+
const timestamp = Date.now();
|
|
561
|
+
const target = nativeTagOf(fiber);
|
|
562
|
+
const nativeEvent = {
|
|
563
|
+
locationX: width / 2,
|
|
564
|
+
locationY: height / 2,
|
|
565
|
+
pageX,
|
|
566
|
+
pageY,
|
|
567
|
+
timestamp,
|
|
568
|
+
target,
|
|
569
|
+
identifier: 0,
|
|
570
|
+
touches: [],
|
|
571
|
+
changedTouches: [],
|
|
572
|
+
};
|
|
573
|
+
return {
|
|
574
|
+
nativeEvent,
|
|
575
|
+
target,
|
|
576
|
+
currentTarget: target,
|
|
577
|
+
type: 'press',
|
|
578
|
+
timeStamp: timestamp,
|
|
579
|
+
bubbles: true,
|
|
580
|
+
cancelable: true,
|
|
581
|
+
defaultPrevented: false,
|
|
582
|
+
persist: () => { },
|
|
583
|
+
preventDefault: () => { },
|
|
584
|
+
stopPropagation: () => { },
|
|
585
|
+
isDefaultPrevented: () => false,
|
|
586
|
+
isPropagationStopped: () => false,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
/** The nearest listed element at or above a host fiber, for naming what covers a target. */
|
|
590
|
+
function describeHit(snapshot, hit, actor) {
|
|
591
|
+
for (let fiber = hit; fiber; fiber = fiber.return) {
|
|
592
|
+
const entry = snapshot.entries.find((candidate) => candidate.info.interesting && (0, fiber_1.sameFiber)(candidate.node.fiber, fiber));
|
|
593
|
+
if (entry) {
|
|
594
|
+
return describe(entry, 40);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// Never a native type name ("RCTView"): the agent can't act on it.
|
|
598
|
+
const modal = (0, inspect_1.openModalHosts)().find((host) => (0, inspect_1.isWithin)(hit, host));
|
|
599
|
+
if (modal && !(actor && (0, inspect_1.isWithin)(actor.node.fiber, modal))) {
|
|
600
|
+
const inside = snapshot.entries.filter((e) => e.info.interesting && !e.tab && (0, inspect_1.isWithin)(e.node.fiber, modal));
|
|
601
|
+
const named = inside.find((e) => e.info.actions.length > 0) ?? inside[0];
|
|
602
|
+
return named ? `an open modal (${describe(named, 40)})` : 'an open modal';
|
|
603
|
+
}
|
|
604
|
+
return 'another view';
|
|
605
|
+
}
|
|
606
|
+
/** The part of an element a person can touch must be at least this wide and high. */
|
|
607
|
+
const MIN_VISIBLE_PT = 10;
|
|
608
|
+
/** How many covers are cut away from an element before giving up. */
|
|
609
|
+
const MAX_COVER_CUTS = 4;
|
|
610
|
+
/** `box` without `cut`: the largest of the strips above, below, left and right of it; null when nothing is left. */
|
|
611
|
+
function withoutBox(box, cut) {
|
|
612
|
+
if (box === null)
|
|
613
|
+
return null;
|
|
614
|
+
if (cut.right <= box.left || cut.left >= box.right || cut.bottom <= box.top || cut.top >= box.bottom)
|
|
615
|
+
return box;
|
|
616
|
+
const strips = [
|
|
617
|
+
{ ...box, bottom: Math.min(box.bottom, cut.top) },
|
|
618
|
+
{ ...box, top: Math.max(box.top, cut.bottom) },
|
|
619
|
+
{ ...box, right: Math.min(box.right, cut.left) },
|
|
620
|
+
{ ...box, left: Math.max(box.left, cut.right) },
|
|
621
|
+
];
|
|
622
|
+
let best = null;
|
|
623
|
+
let bestArea = 0;
|
|
624
|
+
for (const strip of strips) {
|
|
625
|
+
const area = Math.max(0, strip.right - strip.left) * Math.max(0, strip.bottom - strip.top);
|
|
626
|
+
if (area > bestArea) {
|
|
627
|
+
best = strip;
|
|
628
|
+
bestArea = area;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return best;
|
|
632
|
+
}
|
|
633
|
+
/** The testID of the view Tapline's in-app alert draws (`TaplineAlertHost`); its buttons and input sit inside it. */
|
|
634
|
+
const STAND_IN_ALERT_ID = 'tapline-alert';
|
|
635
|
+
/** Whether a fiber is inside the stand-in alert's own view. */
|
|
636
|
+
function inStandInAlert(fiber) {
|
|
637
|
+
for (let up = fiber; up; up = up.return) {
|
|
638
|
+
const props = up.memoizedProps;
|
|
639
|
+
if ((0, types_1.isRecord)(props) && props.testID === STAND_IN_ALERT_ID)
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* An alert sits on top of everything: a person has to answer it first. An OS alert can't be pressed
|
|
646
|
+
* from here; Tapline's stand-in covers the whole screen, so anything outside it is behind it, and
|
|
647
|
+
* its own buttons are what to tap. `actor` is the element about to be touched, when there is one.
|
|
648
|
+
*/
|
|
649
|
+
function assertNoBlockingAlert(actor) {
|
|
650
|
+
const blocking = (0, alerts_1.openAlerts)().find((alert) => !alert.standIn);
|
|
651
|
+
if (blocking) {
|
|
652
|
+
const buttons = blocking.buttons.map((button) => `[${button.text}]`).join(' ');
|
|
653
|
+
if ((0, alerts_1.isOwnAlert)(blocking.id)) {
|
|
654
|
+
throw notActionable('behind_alert', `Tapline's own prompt is open: "${blocking.title}" ${buttons}. Only the person holding the phone answers it: ` +
|
|
655
|
+
'ask the user to tap a button. force: true acts anyway.');
|
|
656
|
+
}
|
|
657
|
+
throw notActionable('behind_alert', `An alert is open: "${blocking.title}" ${buttons}. A person would have to answer it first, and Tapline can't press OS alerts: ` +
|
|
658
|
+
"ask the user to tap a button, or start Tapline with alerts: 'stand-in' so its buttons can be pressed. force: true acts anyway.");
|
|
659
|
+
}
|
|
660
|
+
const standIn = (0, alerts_1.openAlerts)().find((alert) => alert.standIn);
|
|
661
|
+
if (standIn && !(actor && inStandInAlert(actor.node.fiber))) {
|
|
662
|
+
const buttons = standIn.buttons.map((button) => `[${button.text}]`).join(' ');
|
|
663
|
+
throw notActionable('behind_alert', `"${standIn.title}" is open with ${buttons}; tap one of them first.`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/** How a refusal names the one call that brings an element onto the screen (AGENT-TOOLS v1.6, shared with Tapwright). */
|
|
667
|
+
const SCROLL_TO_TARGET_HINT = '(scroll with this target and to: "target")';
|
|
668
|
+
/**
|
|
669
|
+
* Refuses what a person couldn't tap: an element off screen, or one with something else on top
|
|
670
|
+
* at the centre of its visible part (Fabric's hit test). Unknown hit results let it through.
|
|
671
|
+
*/
|
|
672
|
+
async function assertReachable(snapshot, actor, frame) {
|
|
673
|
+
assertNoBlockingAlert(actor);
|
|
674
|
+
if (frame === null)
|
|
675
|
+
return;
|
|
676
|
+
const { width, height } = snapshot.screen;
|
|
677
|
+
const left = Math.max(frame.x, 0);
|
|
678
|
+
const top = Math.max(frame.y, 0);
|
|
679
|
+
const right = Math.min(frame.x + frame.width, width);
|
|
680
|
+
const bottom = Math.min(frame.y + frame.height, height);
|
|
681
|
+
const name = describe(actor);
|
|
682
|
+
if (right <= left || bottom <= top) {
|
|
683
|
+
throw notActionable('off_screen', `${name} is off screen (at ${Math.round(frame.x)},${Math.round(frame.y)}; the screen is ${width}x${height}). ` +
|
|
684
|
+
`Scroll to it first ${SCROLL_TO_TARGET_HINT}, or pass force: true to call its handler anyway.`);
|
|
685
|
+
}
|
|
686
|
+
const bar = (0, inspect_1.nativeTabBar)(snapshot);
|
|
687
|
+
const navBar = (0, inspect_1.nativeNavBar)(snapshot);
|
|
688
|
+
const underNavBar = (y) => navBar !== null && y < navBar.bottom && !(0, inspect_1.inNavBarHeader)(navBar, actor.node.fiber);
|
|
689
|
+
// The last hit test, so a point is never asked twice.
|
|
690
|
+
let asked = null;
|
|
691
|
+
const hitAt = async (x, y) => {
|
|
692
|
+
if (asked && asked.x === x && asked.y === y)
|
|
693
|
+
return asked.hit;
|
|
694
|
+
const hit = await (0, inspect_1.hitTest)(x, y);
|
|
695
|
+
asked = { x, y, hit };
|
|
696
|
+
return hit;
|
|
697
|
+
};
|
|
698
|
+
// Why a touch at (x, y) would not reach the actor, or null when it would (an unknown hit lets it through).
|
|
699
|
+
const refusalAt = async (x, y) => {
|
|
700
|
+
if (bar && y >= bar.top) {
|
|
701
|
+
return notActionable('under_tab_bar', `${name} is under the tab bar iOS draws, at (${x}, ${y}): a real tap would switch tabs instead. ` +
|
|
702
|
+
`Scroll it clear of the bar ${SCROLL_TO_TARGET_HINT}, or pass force: true to call its handler anyway.`);
|
|
703
|
+
}
|
|
704
|
+
if (underNavBar(y)) {
|
|
705
|
+
return notActionable('covered', `${name} is under the navigation bar iOS draws, at (${x}, ${y}): a real tap would hit the bar instead. ` +
|
|
706
|
+
`Scroll it clear of the bar ${SCROLL_TO_TARGET_HINT}, or pass force: true to call its handler anyway.`);
|
|
707
|
+
}
|
|
708
|
+
const hit = await hitAt(x, y);
|
|
709
|
+
if (!hit || (0, inspect_1.isWithin)(hit, actor.node.fiber) || (0, inspect_1.isWithin)(actor.node.fiber, hit))
|
|
710
|
+
return null;
|
|
711
|
+
return notActionable('covered', `${name} is covered at (${x}, ${y}) by ${describeHit(snapshot, hit, actor)}: a real tap would land there instead. ` +
|
|
712
|
+
'Dismiss or scroll it out of the way, or pass force: true to call the handler anyway.');
|
|
713
|
+
};
|
|
714
|
+
const refusal = await refusalAt(Math.round((left + right) / 2), Math.round((top + bottom) / 2));
|
|
715
|
+
if (refusal === null)
|
|
716
|
+
return;
|
|
717
|
+
// Touch the visible part (AGENT-TOOLS v1.3): what's left of the element outside the bars and
|
|
718
|
+
// covers, if it is at least 10 x 10 pt, is touched at its centre, as a finger would.
|
|
719
|
+
let part = { left, top, right, bottom };
|
|
720
|
+
if (bar)
|
|
721
|
+
part = withoutBox(part, { left: 0, top: bar.top, right: width, bottom: height });
|
|
722
|
+
if (navBar && !(0, inspect_1.inNavBarHeader)(navBar, actor.node.fiber))
|
|
723
|
+
part = withoutBox(part, { left: 0, top: 0, right: width, bottom: navBar.bottom });
|
|
724
|
+
for (let attempt = 0; attempt < MAX_COVER_CUTS; attempt++) {
|
|
725
|
+
if (part === null || part.right - part.left < MIN_VISIBLE_PT || part.bottom - part.top < MIN_VISIBLE_PT)
|
|
726
|
+
throw refusal;
|
|
727
|
+
const hit = await hitAt(Math.round((part.left + part.right) / 2), Math.round((part.top + part.bottom) / 2));
|
|
728
|
+
if (!hit || (0, inspect_1.isWithin)(hit, actor.node.fiber) || (0, inspect_1.isWithin)(actor.node.fiber, hit))
|
|
729
|
+
return;
|
|
730
|
+
// Something covers that point: cut its rect away and try what is left.
|
|
731
|
+
const cover = await (0, inspect_1.measureFiber)(hit);
|
|
732
|
+
if (!cover)
|
|
733
|
+
throw refusal;
|
|
734
|
+
part = withoutBox(part, { left: cover.x, top: cover.y, right: cover.x + cover.width, bottom: cover.y + cover.height });
|
|
735
|
+
}
|
|
736
|
+
throw refusal;
|
|
737
|
+
}
|
|
738
|
+
/** Switches native tabs the way the tab bar does: through the tab navigator that holds the tab. */
|
|
739
|
+
function selectTab(snapshot, tab) {
|
|
740
|
+
for (const route of [...snapshot.routes].reverse()) {
|
|
741
|
+
const nav = route.navigation;
|
|
742
|
+
if (typeof nav.getState !== 'function' || typeof nav.navigate !== 'function')
|
|
743
|
+
continue;
|
|
744
|
+
let state;
|
|
745
|
+
try {
|
|
746
|
+
state = nav.getState();
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
if (!(0, types_1.isRecord)(state) || !Array.isArray(state.routes))
|
|
752
|
+
continue;
|
|
753
|
+
const target = state.routes.find((candidate) => (0, types_1.isRecord)(candidate) &&
|
|
754
|
+
typeof candidate.name === 'string' &&
|
|
755
|
+
(tab.screenKey !== null ? candidate.key === tab.screenKey : candidate.name === tab.title));
|
|
756
|
+
if ((0, types_1.isRecord)(target) && typeof target.name === 'string') {
|
|
757
|
+
callHandler('navigate', nav.navigate, [target.name]);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
throw new types_1.TaplineError('unsupported', `Can't switch to the "${tab.title}" tab from here; open its deep link with open_url instead.`);
|
|
762
|
+
}
|
|
763
|
+
/** A Modal's `onRequestClose`: on its host fiber, else on the components that render it (the `Modal` itself). */
|
|
764
|
+
function requestCloseOf(host) {
|
|
765
|
+
// Up through the composites only: the first host above belongs to something else (maybe another Modal).
|
|
766
|
+
for (let fiber = host; fiber; fiber = fiber.return) {
|
|
767
|
+
if (fiber !== host && !(0, fiber_1.isCompositeTag)(fiber.tag))
|
|
768
|
+
break;
|
|
769
|
+
const props = (0, types_1.isRecord)(fiber.memoizedProps) ? fiber.memoizedProps : {};
|
|
770
|
+
if (typeof props.onRequestClose === 'function')
|
|
771
|
+
return props.onRequestClose;
|
|
772
|
+
}
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
/** `back`: what the back button or gesture does, on the focused screen; with a sheet open, closes the sheet. */
|
|
776
|
+
async function back(rawParams) {
|
|
777
|
+
paramsOf(rawParams);
|
|
778
|
+
const snapshot = (0, inspect_1.takeSnapshot)();
|
|
779
|
+
const current = (0, inspect_1.currentRoute)(snapshot.routes);
|
|
780
|
+
// An open sheet is what back closes first (AGENT-TOOLS v1.4): the top Modal's own onRequestClose,
|
|
781
|
+
// which is what the system back gesture or a swipe down calls. One already on its way out is left alone.
|
|
782
|
+
const sheet = (0, inspect_1.openModalHosts)().filter((host) => !(0, inspect_1.closingModalHosts)().includes(host)).pop();
|
|
783
|
+
if (sheet) {
|
|
784
|
+
const onRequestClose = requestCloseOf(sheet);
|
|
785
|
+
// Most app sheets have no onRequestClose and close on a tap on the dimmed backdrop around them:
|
|
786
|
+
// a person taps that, so back does.
|
|
787
|
+
const backdrop = onRequestClose ? null : await backdropOf(snapshot);
|
|
788
|
+
if (backdrop) {
|
|
789
|
+
callHandler('onPress', backdrop.onPress, [pressEvent(backdrop.entry.node.fiber, backdrop.frame)]);
|
|
790
|
+
return { route: current?.chain ?? [], closed: 'sheet', ...(await settleOutcome()) };
|
|
791
|
+
}
|
|
792
|
+
if (!onRequestClose) {
|
|
793
|
+
// Named by what it shows, as `covered` names what is in front, so the agent finds its close control.
|
|
794
|
+
const shows = await frontLine(snapshot);
|
|
795
|
+
throw notActionable('no_action', `the open sheet${shows !== undefined ? ` (in front: ${shows})` : ''} has nothing for back to call (its Modal has no onRequestClose); tap its close control.`);
|
|
796
|
+
}
|
|
797
|
+
callHandler('onRequestClose', onRequestClose, []);
|
|
798
|
+
return { route: current?.chain ?? [], closed: 'sheet', ...(await settleOutcome()) };
|
|
799
|
+
}
|
|
800
|
+
if (!current) {
|
|
801
|
+
throw new types_1.TaplineError('unsupported', 'This app has no React Navigation screens Tapline can see, so there is no back.');
|
|
802
|
+
}
|
|
803
|
+
const nav = current.navigation;
|
|
804
|
+
let canGoBack = true;
|
|
805
|
+
try {
|
|
806
|
+
if (typeof nav.canGoBack === 'function')
|
|
807
|
+
canGoBack = nav.canGoBack();
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
canGoBack = true;
|
|
811
|
+
}
|
|
812
|
+
if (!canGoBack || typeof nav.goBack !== 'function') {
|
|
813
|
+
throw notActionable('no_action', `Nothing to go back to from ${current.chain.join(' › ')}.`);
|
|
814
|
+
}
|
|
815
|
+
callHandler('goBack', nav.goBack, []);
|
|
816
|
+
return { route: current.chain, ...(await settleOutcome()) };
|
|
817
|
+
}
|
|
818
|
+
/** How much of the screen, across and down, an element in front must cover to count as a sheet's backdrop. */
|
|
819
|
+
const BACKDROP_SHARE = 0.9;
|
|
820
|
+
/**
|
|
821
|
+
* The backdrop of the open sheet: an element in front that takes a tap, covers (nearly) the whole
|
|
822
|
+
* screen and shows nothing of its own (no text, no label), the dimmed area a person taps to close
|
|
823
|
+
* the sheet. A full-screen control with a text ("Tap to continue") is no backdrop. Null when there is none.
|
|
824
|
+
*/
|
|
825
|
+
async function backdropOf(snapshot) {
|
|
826
|
+
const { width, height } = snapshot.screen;
|
|
827
|
+
for (const entry of snapshot.entries) {
|
|
828
|
+
if (entry.tab || entry.info.disabled || entry.info.text.trim() !== '' || (entry.info.label ?? '') !== '')
|
|
829
|
+
continue;
|
|
830
|
+
const onPress = (0, inspect_1.findHandler)(entry.node, 'onPress');
|
|
831
|
+
if (!onPress)
|
|
832
|
+
continue;
|
|
833
|
+
const frame = await (0, inspect_1.measureFiber)(entry.node.fiber);
|
|
834
|
+
if (frame !== null && frame.width >= width * BACKDROP_SHARE && frame.height >= height * BACKDROP_SHARE) {
|
|
835
|
+
return { entry, frame, onPress };
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return null;
|
|
839
|
+
}
|
|
840
|
+
/** Calls `name()` on the first instance that has it; whether one did. */
|
|
841
|
+
function callInstanceMethod(instances, name) {
|
|
842
|
+
for (const instance of instances) {
|
|
843
|
+
if (!(0, types_1.isRecord)(instance) || typeof instance[name] !== 'function')
|
|
844
|
+
continue;
|
|
845
|
+
try {
|
|
846
|
+
instance[name].call(instance);
|
|
847
|
+
return true;
|
|
848
|
+
}
|
|
849
|
+
catch {
|
|
850
|
+
// try the next candidate
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* What a finger does to a text input: focus it. React Native's TextInput puts `focus()` on its host
|
|
857
|
+
* instance (Paper: the fiber's stateNode; Fabric: `stateNode.canonical`), and `TextInput.State.focusTextInput`
|
|
858
|
+
* takes the same instance. Without either, the app's own onPressIn / onFocus handlers run.
|
|
859
|
+
*/
|
|
860
|
+
/** An input's instances, the host's and its components': the ones a `focus()` may be called on. */
|
|
861
|
+
function inputInstances(entry) {
|
|
862
|
+
return [...(0, inspect_1.publicInstancesOf)(entry.node.fiber), ...entry.node.owners.map((owner) => owner.stateNode)];
|
|
863
|
+
}
|
|
864
|
+
function focusInput(entry, frame) {
|
|
865
|
+
const instances = inputInstances(entry);
|
|
866
|
+
if (callInstanceMethod(instances, 'focus'))
|
|
867
|
+
return;
|
|
868
|
+
try {
|
|
869
|
+
const state = react_native_1.TextInput.State;
|
|
870
|
+
if ((0, types_1.isRecord)(state) && typeof state.focusTextInput === 'function') {
|
|
871
|
+
const host = (0, inspect_1.publicInstancesOf)(entry.node.fiber).find(types_1.isRecord);
|
|
872
|
+
if (host) {
|
|
873
|
+
state.focusTextInput(host);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
catch {
|
|
879
|
+
// fall through to the handlers
|
|
880
|
+
}
|
|
881
|
+
for (const key of ['onPressIn', 'onFocus']) {
|
|
882
|
+
const handler = appHandler(entry, key);
|
|
883
|
+
if (handler)
|
|
884
|
+
callHandler(key, handler, [pressEvent(entry.node.fiber, frame)]);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
async function pressLike(kind, rawParams) {
|
|
888
|
+
const params = paramsOf(rawParams);
|
|
889
|
+
const { snapshot, entry } = await resolveTarget(params.target, kind);
|
|
890
|
+
if (entry.tab) {
|
|
891
|
+
if (kind === 'longPress')
|
|
892
|
+
throw notActionable('no_action', `${describe(entry)} has no long-press action`);
|
|
893
|
+
selectTab(snapshot, entry.tab);
|
|
894
|
+
return { element: (0, inspect_1.toElement)(entry, null), ...keyboardNote(closeKeyboardFor(entry)), ...(await settleOutcome()) };
|
|
895
|
+
}
|
|
896
|
+
if (entry.navBack) {
|
|
897
|
+
const { route: _route, ...outcome } = await back({});
|
|
898
|
+
return { element: (0, inspect_1.toElement)(entry, null), ...outcome };
|
|
899
|
+
}
|
|
900
|
+
const actor = findActionable(snapshot, entry, kind, false);
|
|
901
|
+
const handlerName = kind === 'press' ? 'onPress' : 'onLongPress';
|
|
902
|
+
const actionName = kind === 'press' ? 'tap' : 'long-press';
|
|
903
|
+
if (!actor) {
|
|
904
|
+
if (entry.info.disabled)
|
|
905
|
+
throw notActionable('disabled', `${describe(entry)} is disabled; ${ENABLE_FIRST}.`);
|
|
906
|
+
throw notActionable('no_action', `${describe(entry)} has no ${actionName} action`);
|
|
907
|
+
}
|
|
908
|
+
if (actor.info.disabled || entry.info.disabled) {
|
|
909
|
+
throw notActionable('disabled', `${describe(actor)} is disabled; ${ENABLE_FIRST}.`);
|
|
910
|
+
}
|
|
911
|
+
// A finger on an input focuses it (AGENT-TOOLS v1.3): an input never answers no_action to a tap.
|
|
912
|
+
const focusing = kind === 'press' && actor.info.actions.includes('type');
|
|
913
|
+
const handler = focusing ? null : (0, inspect_1.findHandler)(actor.node, handlerName);
|
|
914
|
+
if (!handler && !focusing) {
|
|
915
|
+
throw notActionable('no_action', `${describe(actor)} has no ${actionName} action`);
|
|
916
|
+
}
|
|
917
|
+
const { element, frame } = await elementFor(actor);
|
|
918
|
+
const forced = await checkedOrForced(params.force === true, () => assertReachable(snapshot, actor, frame));
|
|
919
|
+
const closedKeyboard = closeKeyboardFor(actor);
|
|
920
|
+
if (handler)
|
|
921
|
+
callHandler(handlerName, handler, [pressEvent(actor.node.fiber, frame)]);
|
|
922
|
+
else {
|
|
923
|
+
// A finger focuses the input and also fires the input's own onPress (not an ancestor's: the
|
|
924
|
+
// input takes the touch).
|
|
925
|
+
focusInput(actor, frame);
|
|
926
|
+
const own = appHandler(actor, 'onPress');
|
|
927
|
+
if (own)
|
|
928
|
+
callHandler('onPress', own, [pressEvent(actor.node.fiber, frame)]);
|
|
929
|
+
}
|
|
930
|
+
return { element, ...keyboardNote(closedKeyboard), ...forced, ...(await settleOutcome()) };
|
|
931
|
+
}
|
|
932
|
+
function press(params) {
|
|
933
|
+
return pressLike('press', params);
|
|
934
|
+
}
|
|
935
|
+
function longPress(params) {
|
|
936
|
+
return pressLike('longPress', params);
|
|
937
|
+
}
|
|
938
|
+
/* -------------------------------------------------------------------------- */
|
|
939
|
+
/* type */
|
|
940
|
+
/* -------------------------------------------------------------------------- */
|
|
941
|
+
/** Props supplied by the app, i.e. everything except the host fiber's own (RN wraps handlers there). */
|
|
942
|
+
function appHandler(entry, key) {
|
|
943
|
+
if (entry.node.owners.length === 0)
|
|
944
|
+
return (0, inspect_1.findHandler)(entry.node, key);
|
|
945
|
+
for (const owner of entry.node.owners) {
|
|
946
|
+
const props = (0, types_1.isRecord)(owner.memoizedProps) ? owner.memoizedProps : {};
|
|
947
|
+
const value = props[key];
|
|
948
|
+
if (typeof value === 'function')
|
|
949
|
+
return value;
|
|
950
|
+
}
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
function focusedInputInstance() {
|
|
954
|
+
try {
|
|
955
|
+
const state = react_native_1.TextInput.State;
|
|
956
|
+
if ((0, types_1.isRecord)(state) && typeof state.currentlyFocusedInput === 'function') {
|
|
957
|
+
return state.currentlyFocusedInput();
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
catch {
|
|
961
|
+
// fall through
|
|
962
|
+
}
|
|
963
|
+
return null;
|
|
964
|
+
}
|
|
965
|
+
function ownsInstance(entry, instance) {
|
|
966
|
+
if (instance == null)
|
|
967
|
+
return false;
|
|
968
|
+
if ((0, inspect_1.publicInstancesOf)(entry.node.fiber).includes(instance))
|
|
969
|
+
return true;
|
|
970
|
+
return entry.node.owners.some((owner) => owner.stateNode === instance);
|
|
971
|
+
}
|
|
972
|
+
const defaultKeyboard = {
|
|
973
|
+
focusedInput: focusedInputInstance,
|
|
974
|
+
visible: () => (0, keyboard_1.keyboardState)()?.shown ?? true,
|
|
975
|
+
dismiss: () => {
|
|
976
|
+
try {
|
|
977
|
+
react_native_1.Keyboard.dismiss();
|
|
978
|
+
}
|
|
979
|
+
catch {
|
|
980
|
+
// nothing to close
|
|
981
|
+
}
|
|
982
|
+
},
|
|
983
|
+
blur: (input) => {
|
|
984
|
+
try {
|
|
985
|
+
const state = react_native_1.TextInput.State;
|
|
986
|
+
if ((0, types_1.isRecord)(state) && typeof state.blurTextInput === 'function') {
|
|
987
|
+
state.blurTextInput(input);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
// fall through to the instance's own blur
|
|
993
|
+
}
|
|
994
|
+
callInstanceMethod([input], 'blur');
|
|
995
|
+
},
|
|
996
|
+
top: () => (0, keyboard_1.keyboardState)()?.top ?? null,
|
|
997
|
+
// The instance's own focus() first: on Fabric only the public instance reaches the native field,
|
|
998
|
+
// and handing TextInputState the internal node moves the focus in JavaScript's books alone.
|
|
999
|
+
focus: (instances) => {
|
|
1000
|
+
callInstanceMethod(instances, 'focus');
|
|
1001
|
+
},
|
|
1002
|
+
};
|
|
1003
|
+
let keyboard = defaultKeyboard;
|
|
1004
|
+
/** Inject the keyboard and focus access (tests); pass null to restore the default. */
|
|
1005
|
+
function setKeyboardControl(next) {
|
|
1006
|
+
keyboard = next ?? defaultKeyboard;
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* AGENT-TOOLS v1.4: with the keyboard up, an action on anything but the focused input closes it
|
|
1010
|
+
* first, without submitting, as a person would. Call it after every refusal is decided. Returns
|
|
1011
|
+
* whether it closed the keyboard (the result then says `closedKeyboard: true`).
|
|
1012
|
+
*/
|
|
1013
|
+
function closeKeyboardFor(actor) {
|
|
1014
|
+
const focused = keyboard.focusedInput();
|
|
1015
|
+
if (focused == null || !keyboard.visible())
|
|
1016
|
+
return false;
|
|
1017
|
+
if (!actor.tab && !actor.navBack && ownsInstance(actor, focused))
|
|
1018
|
+
return false;
|
|
1019
|
+
keyboard.dismiss();
|
|
1020
|
+
// `Keyboard.dismiss()` blurs the focused input itself; when it didn't, do it.
|
|
1021
|
+
if (keyboard.focusedInput() != null)
|
|
1022
|
+
keyboard.blur(focused);
|
|
1023
|
+
return true;
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* AGENT-TOOLS v1.6 §4: typing into another input that shows above the keyboard is what a person
|
|
1027
|
+
* does to fill the next field of a form: the tap moves the focus there and the keyboard stays up.
|
|
1028
|
+
* Moves the focus and returns true; false when the keyboard covers the input (even in part), when
|
|
1029
|
+
* it isn't known where the keyboard is, or when the focus didn't move, and then the caller closes
|
|
1030
|
+
* the keyboard the old way.
|
|
1031
|
+
*/
|
|
1032
|
+
/** How much of an input must show above the keyboard for a finger to reach it (AGENT-TOOLS v1.6 §4). */
|
|
1033
|
+
const SHOWS_ABOVE_KEYBOARD_PT = 10;
|
|
1034
|
+
async function moveFocusAboveKeyboard(entry) {
|
|
1035
|
+
const focused = keyboard.focusedInput();
|
|
1036
|
+
if (focused == null || !keyboard.visible() || ownsInstance(entry, focused))
|
|
1037
|
+
return false;
|
|
1038
|
+
const top = keyboard.top?.() ?? null;
|
|
1039
|
+
if (top === null || keyboard.focus === undefined)
|
|
1040
|
+
return false;
|
|
1041
|
+
const frame = await (0, inspect_1.measureFiber)(entry.node.fiber);
|
|
1042
|
+
if (frame === null || frame.y < 0 || Math.min(frame.y + frame.height, top) - frame.y < SHOWS_ABOVE_KEYBOARD_PT)
|
|
1043
|
+
return false;
|
|
1044
|
+
keyboard.focus(inputInstances(entry));
|
|
1045
|
+
return ownsInstance(entry, keyboard.focusedInput());
|
|
1046
|
+
}
|
|
1047
|
+
/** `{ closedKeyboard: true }` for a result when the keyboard was closed, nothing otherwise. */
|
|
1048
|
+
function keyboardNote(closed) {
|
|
1049
|
+
return closed ? { closedKeyboard: true } : {};
|
|
1050
|
+
}
|
|
1051
|
+
async function resolveInput(snapshot, rawTarget) {
|
|
1052
|
+
if (rawTarget !== undefined && rawTarget !== null) {
|
|
1053
|
+
const { snapshot: snap, entry } = await resolveTarget(rawTarget, 'type');
|
|
1054
|
+
const input = findActionable(snap, entry, 'type', true);
|
|
1055
|
+
if (!input)
|
|
1056
|
+
throw notActionable('no_action', `${describe(entry)} is not an input`);
|
|
1057
|
+
return { snapshot: snap, entry: input };
|
|
1058
|
+
}
|
|
1059
|
+
const snap = snapshot ?? (0, inspect_1.takeSnapshot)();
|
|
1060
|
+
const inputs = snap.entries.filter((entry) => (0, inspect_1.isInputNode)(entry.node));
|
|
1061
|
+
if (inputs.length === 0)
|
|
1062
|
+
throw new types_1.TaplineError('not_found', 'No input on screen');
|
|
1063
|
+
const focused = keyboard.focusedInput();
|
|
1064
|
+
const focusedEntry = inputs.find((entry) => ownsInstance(entry, focused));
|
|
1065
|
+
if (focusedEntry)
|
|
1066
|
+
return { snapshot: snap, entry: focusedEntry };
|
|
1067
|
+
const [only, ...rest] = inputs;
|
|
1068
|
+
if (only && rest.length === 0)
|
|
1069
|
+
return { snapshot: snap, entry: only };
|
|
1070
|
+
const shown = inputs.slice(0, 5);
|
|
1071
|
+
throw new types_1.TaplineError('ambiguous', `${inputs.length} inputs on screen; pass a target to pick one:`, shown.map((entry) => (0, inspect_1.refFor)(entry.node.fiber)), await candidateElements(shown));
|
|
1072
|
+
}
|
|
1073
|
+
function setNativeText(entry, text) {
|
|
1074
|
+
for (const instance of (0, inspect_1.publicInstancesOf)(entry.node.fiber)) {
|
|
1075
|
+
if ((0, types_1.isRecord)(instance) && typeof instance.setNativeProps === 'function') {
|
|
1076
|
+
try {
|
|
1077
|
+
instance.setNativeProps({ text });
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
catch {
|
|
1081
|
+
// try the next candidate
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
async function typeText(rawParams) {
|
|
1087
|
+
const params = paramsOf(rawParams);
|
|
1088
|
+
const submit = params.submit === true;
|
|
1089
|
+
if (params.text !== undefined && typeof params.text !== 'string')
|
|
1090
|
+
throw badParams('text must be a string');
|
|
1091
|
+
const backspace = params.backspace;
|
|
1092
|
+
if (backspace !== undefined && (typeof backspace !== 'number' || !Number.isInteger(backspace) || backspace < 0)) {
|
|
1093
|
+
throw badParams('backspace must be a whole number of characters, 0 or more');
|
|
1094
|
+
}
|
|
1095
|
+
const deleting = typeof backspace === 'number' && backspace > 0;
|
|
1096
|
+
if (params.text === undefined && !submit && !deleting)
|
|
1097
|
+
throw badParams('text is required (or pass submit: true)');
|
|
1098
|
+
// Deleting from the end edits what is there, so it never replaces the whole text.
|
|
1099
|
+
const replace = params.replace !== false && !deleting;
|
|
1100
|
+
const { snapshot, entry } = await resolveInput(null, params.target);
|
|
1101
|
+
if (entry.info.disabled)
|
|
1102
|
+
throw notActionable('disabled', `${describe(entry)} is not editable; ${ENABLE_FIRST}.`);
|
|
1103
|
+
// Typing is a tap that focuses the input, then keys: a person couldn't do it where they couldn't tap,
|
|
1104
|
+
// so the checks are a tap's, with the same reasons and the same `force`. The input that already has
|
|
1105
|
+
// focus is the exception: the keyboard is up for it and a person types without touching it.
|
|
1106
|
+
const forced = await checkedOrForced(params.force === true, async () => {
|
|
1107
|
+
if (ownsInstance(entry, keyboard.focusedInput())) {
|
|
1108
|
+
// An alert takes the keyboard away even from the focused input: a person answers it first.
|
|
1109
|
+
assertNoBlockingAlert(entry);
|
|
1110
|
+
}
|
|
1111
|
+
else {
|
|
1112
|
+
await assertReachable(snapshot, entry, await (0, inspect_1.measureFiber)(entry.node.fiber));
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
// Typing into the input that has focus leaves the keyboard alone. Another input above the keyboard
|
|
1116
|
+
// takes the focus with the keyboard up; one the keyboard covers closes it first.
|
|
1117
|
+
const closedKeyboard = !(await moveFocusAboveKeyboard(entry)) && closeKeyboardFor(entry);
|
|
1118
|
+
const current = (0, inspect_1.currentInputValue)(entry.node);
|
|
1119
|
+
// Characters, not UTF-16 units: an emoji is one.
|
|
1120
|
+
const previous = deleting ? Array.from(current).slice(0, -backspace).join('') : current;
|
|
1121
|
+
const typedText = typeof params.text === 'string' ? params.text : undefined;
|
|
1122
|
+
const typed = typedText ?? (deleting ? '' : undefined);
|
|
1123
|
+
const next = typed === undefined ? previous : replace ? typed : previous + typed;
|
|
1124
|
+
const target = nativeTagOf(entry.node.fiber);
|
|
1125
|
+
const controlled = typeof (0, inspect_1.findProp)(entry.node, 'value') === 'string';
|
|
1126
|
+
if (typed !== undefined) {
|
|
1127
|
+
const onChange = appHandler(entry, 'onChange');
|
|
1128
|
+
const onChangeText = appHandler(entry, 'onChangeText');
|
|
1129
|
+
if (!controlled) {
|
|
1130
|
+
(0, inspect_1.rememberTyped)(entry.node, next);
|
|
1131
|
+
setNativeText(entry, next);
|
|
1132
|
+
}
|
|
1133
|
+
if (onChange)
|
|
1134
|
+
callHandler('onChange', onChange, [{ nativeEvent: { text: next, eventCount: 0, target } }]);
|
|
1135
|
+
if (onChangeText)
|
|
1136
|
+
callHandler('onChangeText', onChangeText, [next]);
|
|
1137
|
+
}
|
|
1138
|
+
if (submit) {
|
|
1139
|
+
const event = { nativeEvent: { text: next, target } };
|
|
1140
|
+
const onSubmit = appHandler(entry, 'onSubmitEditing');
|
|
1141
|
+
const onEnd = appHandler(entry, 'onEndEditing');
|
|
1142
|
+
if (onSubmit)
|
|
1143
|
+
callHandler('onSubmitEditing', onSubmit, [event]);
|
|
1144
|
+
if (onEnd)
|
|
1145
|
+
callHandler('onEndEditing', onEnd, [event]);
|
|
1146
|
+
}
|
|
1147
|
+
const outcome = await settleOutcome();
|
|
1148
|
+
const { element } = await elementFor(entry);
|
|
1149
|
+
return { element: { ...element, value: (0, inspect_1.displayText)(next) }, value: (0, inspect_1.displayText)(next), ...keyboardNote(closedKeyboard), ...forced, ...outcome };
|
|
1150
|
+
}
|
|
1151
|
+
const rememberedOffsets = new WeakMap();
|
|
1152
|
+
/** Objects that may expose scroll methods: host public instances first, then list class instances. */
|
|
1153
|
+
function scrollCandidates(entry) {
|
|
1154
|
+
const found = [];
|
|
1155
|
+
for (const instance of (0, inspect_1.publicInstancesOf)(entry.node.fiber))
|
|
1156
|
+
if ((0, types_1.isRecord)(instance))
|
|
1157
|
+
found.push(instance);
|
|
1158
|
+
for (const owner of entry.node.owners)
|
|
1159
|
+
if ((0, types_1.isRecord)(owner.stateNode))
|
|
1160
|
+
found.push(owner.stateNode);
|
|
1161
|
+
// ScrollView exposes its responder, which can also scrollTo
|
|
1162
|
+
for (const candidate of [...found]) {
|
|
1163
|
+
if (typeof candidate.getScrollResponder === 'function') {
|
|
1164
|
+
try {
|
|
1165
|
+
const responder = candidate.getScrollResponder();
|
|
1166
|
+
if ((0, types_1.isRecord)(responder))
|
|
1167
|
+
found.push(responder);
|
|
1168
|
+
}
|
|
1169
|
+
catch {
|
|
1170
|
+
// ignore
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return found;
|
|
1175
|
+
}
|
|
1176
|
+
function method(candidates, name) {
|
|
1177
|
+
for (const candidate of candidates) {
|
|
1178
|
+
const fn = candidate[name];
|
|
1179
|
+
if (typeof fn === 'function')
|
|
1180
|
+
return (arg) => fn.call(candidate, arg);
|
|
1181
|
+
}
|
|
1182
|
+
return null;
|
|
1183
|
+
}
|
|
1184
|
+
function readMetrics(candidates) {
|
|
1185
|
+
for (const candidate of candidates) {
|
|
1186
|
+
const metrics = candidate._scrollMetrics;
|
|
1187
|
+
if ((0, types_1.isRecord)(metrics) && typeof metrics.offset === 'number') {
|
|
1188
|
+
// Lengths of 0 mean "not laid out yet", i.e. unknown.
|
|
1189
|
+
return {
|
|
1190
|
+
offset: metrics.offset,
|
|
1191
|
+
visibleLength: typeof metrics.visibleLength === 'number' && metrics.visibleLength > 0 ? metrics.visibleLength : null,
|
|
1192
|
+
contentLength: typeof metrics.contentLength === 'number' && metrics.contentLength > 0 ? metrics.contentLength : null,
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
function rememberKey(entry, candidates) {
|
|
1199
|
+
const state = entry.node.fiber.stateNode;
|
|
1200
|
+
if (typeof state === 'object' && state !== null)
|
|
1201
|
+
return state;
|
|
1202
|
+
return candidates[0] ?? null;
|
|
1203
|
+
}
|
|
1204
|
+
const DIRECTIONS = ['down', 'up', 'left', 'right'];
|
|
1205
|
+
/** How far inside the clear area a scrolled-to element is placed, in points, when there is room (the same in Tapwright). */
|
|
1206
|
+
const TARGET_MARGIN = 8;
|
|
1207
|
+
/**
|
|
1208
|
+
* How far content must move along one axis to bring [start, start + size] into [from, until], with
|
|
1209
|
+
* the margin when it fits. Positive scrolls forward (down, right). 0 when it is clear already. An
|
|
1210
|
+
* element longer than the area is lined up with its start.
|
|
1211
|
+
*/
|
|
1212
|
+
function axisShift(start, size, from, until) {
|
|
1213
|
+
const room = until - from;
|
|
1214
|
+
const margin = Math.min(TARGET_MARGIN, Math.max(0, (room - size) / 2));
|
|
1215
|
+
if (start < from)
|
|
1216
|
+
return start - (from + margin);
|
|
1217
|
+
if (start + size > until)
|
|
1218
|
+
return Math.min(start + size - (until - margin), start - (from + margin));
|
|
1219
|
+
return 0;
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* `scroll` with `to: "target"` (AGENT-TOOLS v1.6): scrolls the scroll views that hold the element,
|
|
1223
|
+
* innermost first, until it is on screen and clear of the bars iOS draws. One `scrollTo` each, from
|
|
1224
|
+
* measured positions; a second round corrects for a list that laid out more rows on the way.
|
|
1225
|
+
*/
|
|
1226
|
+
async function scrollToTarget(params) {
|
|
1227
|
+
const raw = params.target;
|
|
1228
|
+
if (raw === undefined || raw === null)
|
|
1229
|
+
throw badParams('to: "target" needs a target: the element to bring onto the screen');
|
|
1230
|
+
if ((0, types_1.isRecord)(raw) && (raw.x !== undefined || raw.y !== undefined)) {
|
|
1231
|
+
throw badParams('to: "target" needs an element (ref, id, label or text), not a point');
|
|
1232
|
+
}
|
|
1233
|
+
const { snapshot, entry: wanted } = await resolveTarget(raw);
|
|
1234
|
+
const scrollers = [];
|
|
1235
|
+
for (let p = wanted.node.parent; p >= 0;) {
|
|
1236
|
+
const candidate = snapshot.entries[p];
|
|
1237
|
+
if (candidate?.info.actions.includes('scroll'))
|
|
1238
|
+
scrollers.push(candidate);
|
|
1239
|
+
const node = snapshot.nodes[p];
|
|
1240
|
+
if (!node)
|
|
1241
|
+
break;
|
|
1242
|
+
p = node.parent;
|
|
1243
|
+
}
|
|
1244
|
+
const { width, height } = snapshot.screen;
|
|
1245
|
+
const navBar = (0, inspect_1.nativeNavBar)(snapshot);
|
|
1246
|
+
const tabBar = (0, inspect_1.nativeTabBar)(snapshot);
|
|
1247
|
+
const clearTop = navBar && !(0, inspect_1.inNavBarHeader)(navBar, wanted.node.fiber) ? navBar.bottom : 0;
|
|
1248
|
+
const clearBottom = tabBar ? tabBar.top : height;
|
|
1249
|
+
/**
|
|
1250
|
+
* How far the element is from clear inside a scroll view, per axis. The area is the part of the
|
|
1251
|
+
* scroll view that is on screen between the bars. A scroll view that is itself out of view (a
|
|
1252
|
+
* list further down the page) has no such part: there the element only has to be inside the
|
|
1253
|
+
* scroll view, and the outer one brings both onto the screen. Without a scroll view the area is
|
|
1254
|
+
* the screen between the bars.
|
|
1255
|
+
*/
|
|
1256
|
+
const shiftsIn = (frame, area) => {
|
|
1257
|
+
let left = Math.max(area?.x ?? 0, 0);
|
|
1258
|
+
let right = Math.min(area ? area.x + area.width : width, width);
|
|
1259
|
+
let top = Math.max(area?.y ?? 0, clearTop);
|
|
1260
|
+
let bottom = Math.min(area ? area.y + area.height : height, clearBottom);
|
|
1261
|
+
if (area && (right - left < 1 || bottom - top < 1)) {
|
|
1262
|
+
left = area.x;
|
|
1263
|
+
right = area.x + area.width;
|
|
1264
|
+
top = area.y;
|
|
1265
|
+
bottom = area.y + area.height;
|
|
1266
|
+
}
|
|
1267
|
+
return { x: axisShift(frame.x, frame.width, left, right), y: axisShift(frame.y, frame.height, top, bottom) };
|
|
1268
|
+
};
|
|
1269
|
+
let settledMs = 0;
|
|
1270
|
+
let settleCapMs;
|
|
1271
|
+
let scrolled = null;
|
|
1272
|
+
// A list drawn upside down (`inverted`) moves its content the other way: found out from the first move.
|
|
1273
|
+
const inverted = new Set();
|
|
1274
|
+
for (let round = 0; round < 3; round++) {
|
|
1275
|
+
let movedThisRound = false;
|
|
1276
|
+
for (const scroller of scrollers) {
|
|
1277
|
+
const horizontal = (0, inspect_1.findProp)(scroller.node, 'horizontal') === true;
|
|
1278
|
+
const [targetFrame, scrollFrame, state] = await Promise.all([
|
|
1279
|
+
(0, inspect_1.measureFiber)(wanted.node.fiber),
|
|
1280
|
+
(0, inspect_1.measureFiber)(scroller.node.fiber),
|
|
1281
|
+
measureScrollState(scroller),
|
|
1282
|
+
]);
|
|
1283
|
+
if (!targetFrame || !scrollFrame)
|
|
1284
|
+
continue;
|
|
1285
|
+
const shifts = shiftsIn(targetFrame, scrollFrame);
|
|
1286
|
+
const wantedShift = horizontal ? shifts.x : shifts.y;
|
|
1287
|
+
if (Math.abs(wantedShift) < 1)
|
|
1288
|
+
continue;
|
|
1289
|
+
const shift = inverted.has(scroller) ? -wantedShift : wantedShift;
|
|
1290
|
+
const candidates = scrollCandidates(scroller);
|
|
1291
|
+
const key = rememberKey(scroller, candidates);
|
|
1292
|
+
const metrics = readMetrics(candidates);
|
|
1293
|
+
const current = state?.offset ??
|
|
1294
|
+
(metrics ? (horizontal ? { x: metrics.offset, y: 0 } : { x: 0, y: metrics.offset }) : ((key ? rememberedOffsets.get(key) : undefined) ?? { x: 0, y: 0 }));
|
|
1295
|
+
// Never further back than the start. A scroll view resting at a negative offset (the system's
|
|
1296
|
+
// inset under a large title) can't be asked to go back past where it rests.
|
|
1297
|
+
let x = Math.max(Math.min(0, current.x), current.x + (horizontal ? shift : 0));
|
|
1298
|
+
let y = Math.max(Math.min(0, current.y), current.y + (horizontal ? 0 : shift));
|
|
1299
|
+
if (state) {
|
|
1300
|
+
x = Math.min(x, Math.max(state.max.x, current.x));
|
|
1301
|
+
y = Math.min(y, Math.max(state.max.y, current.y));
|
|
1302
|
+
}
|
|
1303
|
+
// At its start or end already: this scroll view can't bring the element any closer.
|
|
1304
|
+
if (Math.abs(x - current.x) < 1 && Math.abs(y - current.y) < 1)
|
|
1305
|
+
continue;
|
|
1306
|
+
const scrollTo = method(candidates, 'scrollTo');
|
|
1307
|
+
const scrollToOffset = method(candidates, 'scrollToOffset');
|
|
1308
|
+
if (scrollTo)
|
|
1309
|
+
scrollTo({ x, y, animated: false });
|
|
1310
|
+
else if (scrollToOffset)
|
|
1311
|
+
scrollToOffset({ offset: horizontal ? x : y, animated: false });
|
|
1312
|
+
else
|
|
1313
|
+
throw new types_1.TaplineError('unsupported', 'This scroll view exposes no scrollTo');
|
|
1314
|
+
const outcome = await settleOutcome();
|
|
1315
|
+
settledMs += outcome.settledMs;
|
|
1316
|
+
if (outcome.settleCapMs !== undefined)
|
|
1317
|
+
settleCapMs = outcome.settleCapMs;
|
|
1318
|
+
const [after, movedFrame] = await Promise.all([measureScrollState(scroller), (0, inspect_1.measureFiber)(wanted.node.fiber)]);
|
|
1319
|
+
const offset = after ? after.offset : { x, y };
|
|
1320
|
+
if (key)
|
|
1321
|
+
rememberedOffsets.set(key, offset);
|
|
1322
|
+
// What the element did, measured: the scroll view may have held it back, or gone the other way.
|
|
1323
|
+
// (When it can't be measured now, it is taken to have moved as asked.)
|
|
1324
|
+
const asked = horizontal ? x - current.x : y - current.y;
|
|
1325
|
+
const travelled = movedFrame ? (horizontal ? movedFrame.x - targetFrame.x : movedFrame.y - targetFrame.y) : -asked;
|
|
1326
|
+
if (Math.abs(travelled) < 1)
|
|
1327
|
+
continue;
|
|
1328
|
+
if (travelled * asked > 0 && !inverted.has(scroller))
|
|
1329
|
+
inverted.add(scroller);
|
|
1330
|
+
// The answer names the innermost scroll view that moved.
|
|
1331
|
+
if (scrolled === null || scrollers.indexOf(scroller) < scrollers.indexOf(scrolled.entry))
|
|
1332
|
+
scrolled = { entry: scroller, offset };
|
|
1333
|
+
else if (scrolled.entry === scroller)
|
|
1334
|
+
scrolled.offset = offset;
|
|
1335
|
+
movedThisRound = true;
|
|
1336
|
+
}
|
|
1337
|
+
if (!movedThisRound)
|
|
1338
|
+
break;
|
|
1339
|
+
}
|
|
1340
|
+
const finalFrame = await (0, inspect_1.measureFiber)(wanted.node.fiber);
|
|
1341
|
+
let reached = finalFrame !== null && (0, inspect_1.isOnScreen)(finalFrame, snapshot.screen);
|
|
1342
|
+
if (finalFrame !== null && reached) {
|
|
1343
|
+
// Clear inside every scroll view that holds it, and on the screen between the bars.
|
|
1344
|
+
const areas = scrollers.length > 0 ? await Promise.all(scrollers.map((s) => (0, inspect_1.measureFiber)(s.node.fiber))) : [null];
|
|
1345
|
+
for (const area of areas) {
|
|
1346
|
+
const left = shiftsIn(finalFrame, area);
|
|
1347
|
+
if (Math.abs(left.x) >= 1 || Math.abs(left.y) >= 1)
|
|
1348
|
+
reached = false;
|
|
1349
|
+
}
|
|
1350
|
+
const onScreen = shiftsIn(finalFrame, null);
|
|
1351
|
+
if (Math.abs(onScreen.x) >= 1 || Math.abs(onScreen.y) >= 1)
|
|
1352
|
+
reached = false;
|
|
1353
|
+
}
|
|
1354
|
+
const target = (0, inspect_1.toElement)(wanted, finalFrame);
|
|
1355
|
+
const settle = { settledMs, ...(settleCapMs !== undefined ? { settleCapMs } : {}) };
|
|
1356
|
+
if (scrolled === null) {
|
|
1357
|
+
if (reached)
|
|
1358
|
+
return { element: target, target, offset: { x: 0, y: 0 }, moved: false, reached: true, unchanged: true, ...settle };
|
|
1359
|
+
const first = scrollers[0];
|
|
1360
|
+
if (!first)
|
|
1361
|
+
throw notActionable('no_action', `${describe(wanted)} is not inside a scroll view`);
|
|
1362
|
+
const state = await measureScrollState(first);
|
|
1363
|
+
const { element } = await elementFor(first);
|
|
1364
|
+
return { element, target, offset: state?.offset ?? { x: 0, y: 0 }, moved: false, reached: false, ...settle };
|
|
1365
|
+
}
|
|
1366
|
+
const { element } = await elementFor(scrolled.entry);
|
|
1367
|
+
return { element, target, offset: scrolled.offset, moved: true, reached, ...settle };
|
|
1368
|
+
}
|
|
1369
|
+
async function scroll(rawParams) {
|
|
1370
|
+
const params = paramsOf(rawParams);
|
|
1371
|
+
const to = params.to;
|
|
1372
|
+
if (to !== undefined && to !== 'start' && to !== 'end' && to !== 'target')
|
|
1373
|
+
throw badParams('to must be "start", "end" or "target"');
|
|
1374
|
+
if (to === 'target') {
|
|
1375
|
+
if (params.direction !== undefined || params.amount !== undefined || params.fraction !== undefined) {
|
|
1376
|
+
throw badParams('to: "target" takes no direction or amount');
|
|
1377
|
+
}
|
|
1378
|
+
return scrollToTarget(params);
|
|
1379
|
+
}
|
|
1380
|
+
const direction = params.direction === undefined ? 'down' : params.direction;
|
|
1381
|
+
if (!DIRECTIONS.includes(direction))
|
|
1382
|
+
throw badParams('direction must be down, up, left or right');
|
|
1383
|
+
if (params.amount !== undefined && (typeof params.amount !== 'number' || !(params.amount > 0))) {
|
|
1384
|
+
throw badParams('amount must be a positive number of points');
|
|
1385
|
+
}
|
|
1386
|
+
if (params.fraction !== undefined) {
|
|
1387
|
+
if (params.amount !== undefined)
|
|
1388
|
+
throw badParams('use fraction or amount, not both');
|
|
1389
|
+
if (typeof params.fraction !== 'number' || !(params.fraction >= 0.1 && params.fraction <= 1)) {
|
|
1390
|
+
throw badParams('fraction is a share of the scroll area, 0.1 to 1');
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
let entry;
|
|
1394
|
+
let frame = null;
|
|
1395
|
+
if (params.target !== undefined && params.target !== null) {
|
|
1396
|
+
const resolved = await resolveTarget(params.target, 'scroll');
|
|
1397
|
+
const scroller = findActionable(resolved.snapshot, resolved.entry, 'scroll', true);
|
|
1398
|
+
if (!scroller)
|
|
1399
|
+
throw notActionable('no_action', `${describe(resolved.entry)} is not inside a scroll view`);
|
|
1400
|
+
entry = scroller;
|
|
1401
|
+
frame = await (0, inspect_1.measureFiber)(entry.node.fiber);
|
|
1402
|
+
}
|
|
1403
|
+
else {
|
|
1404
|
+
const snapshot = (0, inspect_1.takeSnapshot)();
|
|
1405
|
+
const scrollers = snapshot.entries.filter((e) => e.info.actions.includes('scroll'));
|
|
1406
|
+
const frames = await Promise.all(scrollers.map((e) => (0, inspect_1.measureFiber)(e.node.fiber)));
|
|
1407
|
+
let best = null;
|
|
1408
|
+
let bestFrame = null;
|
|
1409
|
+
let bestArea = -1;
|
|
1410
|
+
for (let i = 0; i < scrollers.length; i++) {
|
|
1411
|
+
const candidate = scrollers[i];
|
|
1412
|
+
if (!candidate)
|
|
1413
|
+
continue;
|
|
1414
|
+
const f = frames[i] ?? null;
|
|
1415
|
+
if (f !== null && !(0, inspect_1.isOnScreen)(f, snapshot.screen))
|
|
1416
|
+
continue;
|
|
1417
|
+
const area = f ? f.width * f.height : 0;
|
|
1418
|
+
if (area > bestArea) {
|
|
1419
|
+
best = candidate;
|
|
1420
|
+
bestFrame = f;
|
|
1421
|
+
bestArea = area;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
if (!best)
|
|
1425
|
+
throw new types_1.TaplineError('not_found', 'No scroll view on screen');
|
|
1426
|
+
entry = best;
|
|
1427
|
+
frame = bestFrame;
|
|
1428
|
+
}
|
|
1429
|
+
const candidates = scrollCandidates(entry);
|
|
1430
|
+
const key = rememberKey(entry, candidates);
|
|
1431
|
+
const horizontal = (0, inspect_1.findProp)(entry.node, 'horizontal') === true;
|
|
1432
|
+
const metrics = readMetrics(candidates);
|
|
1433
|
+
const remembered = key ? rememberedOffsets.get(key) : undefined;
|
|
1434
|
+
// The real position first: Tapline's own record is wrong after a person scrolls or the system clamps.
|
|
1435
|
+
const measured = await measureScrollState(entry);
|
|
1436
|
+
const known = measured !== null || metrics !== null || remembered !== undefined;
|
|
1437
|
+
const current = measured
|
|
1438
|
+
? measured.offset
|
|
1439
|
+
: metrics
|
|
1440
|
+
? horizontal
|
|
1441
|
+
? { x: metrics.offset, y: 0 }
|
|
1442
|
+
: { x: 0, y: metrics.offset }
|
|
1443
|
+
: (remembered ?? { x: 0, y: 0 });
|
|
1444
|
+
const scrollTo = method(candidates, 'scrollTo');
|
|
1445
|
+
const scrollToOffset = method(candidates, 'scrollToOffset');
|
|
1446
|
+
const scrollToEnd = method(candidates, 'scrollToEnd');
|
|
1447
|
+
let offset = { ...current };
|
|
1448
|
+
if (to === 'start') {
|
|
1449
|
+
offset = { x: 0, y: 0 };
|
|
1450
|
+
if (scrollTo)
|
|
1451
|
+
scrollTo({ x: 0, y: 0, animated: false });
|
|
1452
|
+
else if (scrollToOffset)
|
|
1453
|
+
scrollToOffset({ offset: 0, animated: false });
|
|
1454
|
+
else
|
|
1455
|
+
throw new types_1.TaplineError('unsupported', 'This scroll view exposes no scrollTo');
|
|
1456
|
+
}
|
|
1457
|
+
else if (to === 'end') {
|
|
1458
|
+
if (!scrollToEnd)
|
|
1459
|
+
throw new types_1.TaplineError('unsupported', 'This scroll view exposes no scrollToEnd');
|
|
1460
|
+
scrollToEnd({ animated: false });
|
|
1461
|
+
if (measured) {
|
|
1462
|
+
offset = horizontal ? { x: measured.max.x, y: 0 } : { x: 0, y: measured.max.y };
|
|
1463
|
+
}
|
|
1464
|
+
else if (metrics && metrics.contentLength !== null && metrics.visibleLength !== null) {
|
|
1465
|
+
const max = Math.max(0, metrics.contentLength - metrics.visibleLength);
|
|
1466
|
+
offset = horizontal ? { x: max, y: 0 } : { x: 0, y: max };
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
else {
|
|
1470
|
+
const visible = (measured ? (horizontal ? measured.visible.x : measured.visible.y) : null) ??
|
|
1471
|
+
metrics?.visibleLength ??
|
|
1472
|
+
(horizontal ? frame?.width : frame?.height) ??
|
|
1473
|
+
(0, inspect_1.getScreen)().height;
|
|
1474
|
+
const vertical = direction === 'down' || direction === 'up';
|
|
1475
|
+
const areaSize = (vertical ? frame?.height : frame?.width) ?? visible;
|
|
1476
|
+
const amount = typeof params.fraction === 'number'
|
|
1477
|
+
? Math.round(params.fraction * areaSize)
|
|
1478
|
+
: typeof params.amount === 'number'
|
|
1479
|
+
? params.amount
|
|
1480
|
+
: Math.round(visible * 0.8);
|
|
1481
|
+
const dx = direction === 'right' ? amount : direction === 'left' ? -amount : 0;
|
|
1482
|
+
const dy = direction === 'down' ? amount : direction === 'up' ? -amount : 0;
|
|
1483
|
+
let x = Math.max(0, current.x + dx);
|
|
1484
|
+
let y = Math.max(0, current.y + dy);
|
|
1485
|
+
if (measured) {
|
|
1486
|
+
// native clamps to [0, max]; the automatic inset (a negative resting offset) is out of reach
|
|
1487
|
+
x = Math.min(x, measured.max.x);
|
|
1488
|
+
y = Math.min(y, measured.max.y);
|
|
1489
|
+
}
|
|
1490
|
+
else if (metrics && metrics.contentLength !== null && metrics.visibleLength !== null) {
|
|
1491
|
+
const max = Math.max(0, metrics.contentLength - metrics.visibleLength);
|
|
1492
|
+
if (horizontal)
|
|
1493
|
+
x = Math.min(x, max);
|
|
1494
|
+
else
|
|
1495
|
+
y = Math.min(y, max);
|
|
1496
|
+
}
|
|
1497
|
+
offset = { x, y };
|
|
1498
|
+
if (scrollTo)
|
|
1499
|
+
scrollTo({ x, y, animated: false });
|
|
1500
|
+
else if (scrollToOffset)
|
|
1501
|
+
scrollToOffset({ offset: horizontal ? x : y, animated: false });
|
|
1502
|
+
else
|
|
1503
|
+
throw new types_1.TaplineError('unsupported', 'This scroll view exposes no scrollTo');
|
|
1504
|
+
}
|
|
1505
|
+
const outcome = await settleOutcome();
|
|
1506
|
+
// Report where the content really is, not where we asked it to go.
|
|
1507
|
+
const after = await measureScrollState(entry);
|
|
1508
|
+
if (after)
|
|
1509
|
+
offset = after.offset;
|
|
1510
|
+
const moved = !known && !after ? true : Math.abs(offset.x - current.x) >= 1 || Math.abs(offset.y - current.y) >= 1;
|
|
1511
|
+
if (key)
|
|
1512
|
+
rememberedOffsets.set(key, offset);
|
|
1513
|
+
const { element } = await elementFor(entry, frame);
|
|
1514
|
+
return { element, offset, moved, ...outcome };
|
|
1515
|
+
}
|
|
1516
|
+
/** The native type of a scroll view's content container (iOS and Android's horizontal one). */
|
|
1517
|
+
const SCROLL_CONTENT_HOSTS = new Set(['RCTScrollContentView', 'AndroidHorizontalScrollContentView']);
|
|
1518
|
+
const round2 = (value) => Math.round(value * 100) / 100;
|
|
1519
|
+
/**
|
|
1520
|
+
* The scroll view's real offset, from where its content container sits inside it. On the new
|
|
1521
|
+
* architecture `measureInWindow` includes the content offset, so scroll frame minus content frame
|
|
1522
|
+
* is the offset. Null when either can't be measured (old architecture, nothing laid out).
|
|
1523
|
+
*/
|
|
1524
|
+
async function measureScrollState(entry) {
|
|
1525
|
+
const scrollFiber = entry.node.fiber;
|
|
1526
|
+
const horizontal = (0, inspect_1.findProp)(entry.node, 'horizontal') === true;
|
|
1527
|
+
const children = (0, fiber_1.hostChildren)(scrollFiber);
|
|
1528
|
+
// On iOS the RefreshControl is the scroll view's first child, so the first host child is not
|
|
1529
|
+
// the content: pick the content container by its native type, else the largest child.
|
|
1530
|
+
const typed = children.find((child) => typeof child.type === 'string' && SCROLL_CONTENT_HOSTS.has(child.type));
|
|
1531
|
+
const scrollFrame = await (0, inspect_1.measureFiber)(scrollFiber);
|
|
1532
|
+
if (!scrollFrame)
|
|
1533
|
+
return null;
|
|
1534
|
+
let contentFrame = typed ? await (0, inspect_1.measureFiber)(typed) : null;
|
|
1535
|
+
if (!contentFrame && children.length > 1) {
|
|
1536
|
+
const frames = await Promise.all(children.map((child) => (0, inspect_1.measureFiber)(child)));
|
|
1537
|
+
let bestSize = -1;
|
|
1538
|
+
for (const f of frames) {
|
|
1539
|
+
if (!f)
|
|
1540
|
+
continue;
|
|
1541
|
+
const size = horizontal ? f.width : f.height;
|
|
1542
|
+
if (size > bestSize) {
|
|
1543
|
+
bestSize = size;
|
|
1544
|
+
contentFrame = f;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
if (!contentFrame) {
|
|
1549
|
+
const first = (0, fiber_1.firstHostChild)(scrollFiber);
|
|
1550
|
+
contentFrame = first ? await (0, inspect_1.measureFiber)(first) : null;
|
|
1551
|
+
}
|
|
1552
|
+
if (!contentFrame)
|
|
1553
|
+
return null;
|
|
1554
|
+
return {
|
|
1555
|
+
offset: { x: round2(scrollFrame.x - contentFrame.x), y: round2(scrollFrame.y - contentFrame.y) },
|
|
1556
|
+
max: {
|
|
1557
|
+
x: Math.max(0, round2(contentFrame.width - scrollFrame.width)),
|
|
1558
|
+
y: Math.max(0, round2(contentFrame.height - scrollFrame.height)),
|
|
1559
|
+
},
|
|
1560
|
+
visible: { x: scrollFrame.width, y: scrollFrame.height },
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
/* -------------------------------------------------------------------------- */
|
|
1564
|
+
/* toggle */
|
|
1565
|
+
/* -------------------------------------------------------------------------- */
|
|
1566
|
+
async function toggle(rawParams) {
|
|
1567
|
+
const params = paramsOf(rawParams);
|
|
1568
|
+
if (params.value !== undefined && typeof params.value !== 'boolean')
|
|
1569
|
+
throw badParams('value must be a boolean');
|
|
1570
|
+
const { snapshot, entry } = await resolveTarget(params.target, 'toggle');
|
|
1571
|
+
const control = findActionable(snapshot, entry, 'toggle', true);
|
|
1572
|
+
if (!control)
|
|
1573
|
+
throw notActionable('no_action', `${describe(entry)} is not a switch`);
|
|
1574
|
+
if (control.info.disabled)
|
|
1575
|
+
throw notActionable('disabled', `${describe(control)} is disabled; ${ENABLE_FIRST}.`);
|
|
1576
|
+
const handler = (0, inspect_1.findHandler)(control.node, 'onValueChange');
|
|
1577
|
+
if (!handler)
|
|
1578
|
+
throw notActionable('no_action', `${describe(control)} is not a switch`);
|
|
1579
|
+
const current = control.info.checked === true;
|
|
1580
|
+
const next = typeof params.value === 'boolean' ? params.value : !current;
|
|
1581
|
+
const forced = await checkedOrForced(params.force === true, async () => assertReachable(snapshot, control, await (0, inspect_1.measureFiber)(control.node.fiber)));
|
|
1582
|
+
if (typeof params.value === 'boolean' && control.info.checked === params.value) {
|
|
1583
|
+
// Already there: a person wouldn't touch it, so the handler isn't called and the keyboard stays.
|
|
1584
|
+
const { element } = await elementFor(control);
|
|
1585
|
+
return { element, value: params.value, unchanged: true, settledMs: 0 };
|
|
1586
|
+
}
|
|
1587
|
+
const closedKeyboard = closeKeyboardFor(control);
|
|
1588
|
+
callHandler('onValueChange', handler, [next]);
|
|
1589
|
+
const outcome = await settleOutcome();
|
|
1590
|
+
const { element } = await elementFor(control);
|
|
1591
|
+
return { element: { ...element, checked: next }, value: next, ...keyboardNote(closedKeyboard), ...forced, ...outcome };
|
|
1592
|
+
}
|
|
1593
|
+
/* -------------------------------------------------------------------------- */
|
|
1594
|
+
/* openURL / logs / reload / screenshot */
|
|
1595
|
+
/* -------------------------------------------------------------------------- */
|
|
1596
|
+
const OPEN_URL_TIMEOUT_MS = 5000;
|
|
1597
|
+
async function openURL(rawParams) {
|
|
1598
|
+
const params = paramsOf(rawParams);
|
|
1599
|
+
if (typeof params.url !== 'string' || params.url === '')
|
|
1600
|
+
throw badParams('url must be a non-empty string');
|
|
1601
|
+
const url = params.url;
|
|
1602
|
+
let timer;
|
|
1603
|
+
const timeout = new Promise((_, reject) => {
|
|
1604
|
+
timer = setTimeout(() => reject(new types_1.TaplineError('timeout', `Linking.openURL did not finish in ${OPEN_URL_TIMEOUT_MS} ms`)), OPEN_URL_TIMEOUT_MS);
|
|
1605
|
+
});
|
|
1606
|
+
try {
|
|
1607
|
+
await Promise.race([react_native_1.Linking.openURL(url), timeout]);
|
|
1608
|
+
}
|
|
1609
|
+
catch (error) {
|
|
1610
|
+
if (error instanceof types_1.TaplineError)
|
|
1611
|
+
throw error;
|
|
1612
|
+
throw new types_1.TaplineError('internal', `Linking.openURL failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1613
|
+
}
|
|
1614
|
+
finally {
|
|
1615
|
+
if (timer !== undefined)
|
|
1616
|
+
clearTimeout(timer);
|
|
1617
|
+
}
|
|
1618
|
+
return settleOutcome();
|
|
1619
|
+
}
|
|
1620
|
+
async function getLogs(rawParams) {
|
|
1621
|
+
const params = paramsOf(rawParams);
|
|
1622
|
+
if (params.since !== undefined && typeof params.since !== 'number')
|
|
1623
|
+
throw badParams('since must be a number');
|
|
1624
|
+
return { entries: (0, logs_1.getLogEntries)(typeof params.since === 'number' ? params.since : undefined) };
|
|
1625
|
+
}
|
|
1626
|
+
async function reload() {
|
|
1627
|
+
if (typeof __DEV__ === 'undefined' || !__DEV__) {
|
|
1628
|
+
throw new types_1.TaplineError('unsupported', 'reload is only available in development builds');
|
|
1629
|
+
}
|
|
1630
|
+
// Reply first: the reload tears the socket down.
|
|
1631
|
+
setTimeout(() => {
|
|
1632
|
+
try {
|
|
1633
|
+
// An Expo app reloads through Expo (what `reloadAppAsync` from 'expo' calls): in Expo Go,
|
|
1634
|
+
// DevSettings.reload() starts the bundle without Expo's native modules and it fails to load.
|
|
1635
|
+
const expo = globalThis.expo;
|
|
1636
|
+
const expoReload = (0, types_1.isRecord)(expo) ? expo.reloadAppAsync : undefined;
|
|
1637
|
+
if (typeof expoReload === 'function') {
|
|
1638
|
+
void Promise.resolve(expoReload.call(expo, 'Tapline: the agent asked for a reload')).catch(() => react_native_1.DevSettings.reload());
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
react_native_1.DevSettings.reload();
|
|
1642
|
+
}
|
|
1643
|
+
catch {
|
|
1644
|
+
// nothing to do
|
|
1645
|
+
}
|
|
1646
|
+
}, 100);
|
|
1647
|
+
return {};
|
|
1648
|
+
}
|
|
1649
|
+
function loadViewShot() {
|
|
1650
|
+
try {
|
|
1651
|
+
const loaded = require('react-native-view-shot');
|
|
1652
|
+
const candidate = (0, types_1.isRecord)(loaded) && (0, types_1.isRecord)(loaded.default) && loaded.default.captureScreen ? loaded.default : loaded;
|
|
1653
|
+
if ((0, types_1.isRecord)(candidate) && typeof candidate.captureScreen === 'function') {
|
|
1654
|
+
return candidate;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
catch {
|
|
1658
|
+
// not installed
|
|
1659
|
+
}
|
|
1660
|
+
return null;
|
|
1661
|
+
}
|
|
1662
|
+
function screenshotAvailable() {
|
|
1663
|
+
return (react_native_1.Platform.OS === 'ios' && (0, native_1.nativeModule)() !== null) || loadViewShot() !== null;
|
|
1664
|
+
}
|
|
1665
|
+
async function screenshot(rawParams) {
|
|
1666
|
+
const params = paramsOf(rawParams);
|
|
1667
|
+
const format = params.format === 'png' ? 'png' : 'jpg';
|
|
1668
|
+
const quality = typeof params.quality === 'number' && params.quality > 0 && params.quality <= 1 ? params.quality : 0.7;
|
|
1669
|
+
const maxWidth = typeof params.maxWidth === 'number' && params.maxWidth > 0 ? Math.round(params.maxWidth) : 600;
|
|
1670
|
+
// `fresh: false` (the live viewer's frames) draws what is on screen without making iOS render a
|
|
1671
|
+
// new frame; only Tapline's native module can do that.
|
|
1672
|
+
const fresh = params.fresh !== false;
|
|
1673
|
+
const screen = (0, inspect_1.getScreen)();
|
|
1674
|
+
const nativeWidth = Math.round(screen.width * screen.scale);
|
|
1675
|
+
// The live viewer sizes frames by the screen's short side, so a turned phone keeps the preset's
|
|
1676
|
+
// sharpness in px per point (in landscape the width is the long side); a screenshot keeps its width.
|
|
1677
|
+
const shortSide = Math.min(screen.width, screen.height);
|
|
1678
|
+
const target = params.fitShortSide === true && shortSide > 0 ? Math.round((maxWidth * screen.width) / shortSide) : maxWidth;
|
|
1679
|
+
const width = Math.min(target, nativeWidth);
|
|
1680
|
+
const height = Math.round((width * screen.height) / screen.width);
|
|
1681
|
+
const native = react_native_1.Platform.OS === 'ios' && format === 'jpg' ? (0, native_1.nativeModule)() : null;
|
|
1682
|
+
if (native !== null) {
|
|
1683
|
+
try {
|
|
1684
|
+
const data = await native.capture(width, quality, fresh);
|
|
1685
|
+
if (data !== '')
|
|
1686
|
+
return { mime: 'image/jpeg', data, width, height };
|
|
1687
|
+
}
|
|
1688
|
+
catch {
|
|
1689
|
+
// fall back to view-shot below
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
const viewShot = loadViewShot();
|
|
1693
|
+
if (!viewShot) {
|
|
1694
|
+
throw new types_1.TaplineError('unsupported', 'Screenshots need react-native-view-shot in the app (npx expo install react-native-view-shot).');
|
|
1695
|
+
}
|
|
1696
|
+
// iOS renders view-shot's size at the screen scale (400 gives 1200 px on a 3x phone); Android takes pixels.
|
|
1697
|
+
const perUnit = react_native_1.Platform.OS === 'ios' && screen.scale > 0 ? screen.scale : 1;
|
|
1698
|
+
let data;
|
|
1699
|
+
try {
|
|
1700
|
+
data = await viewShot.captureScreen({ format, quality, result: 'base64', width: width / perUnit, height: height / perUnit });
|
|
1701
|
+
}
|
|
1702
|
+
catch (error) {
|
|
1703
|
+
throw new types_1.TaplineError('internal', `captureScreen failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1704
|
+
}
|
|
1705
|
+
return { mime: format === 'png' ? 'image/png' : 'image/jpeg', data, width, height };
|
|
1706
|
+
}
|
|
1707
|
+
/** A swipe's moves come about this often, as a finger's do on a 60 Hz screen. */
|
|
1708
|
+
const SWIPE_STEP_MS = 16;
|
|
1709
|
+
const SWIPE_DEFAULT_SECONDS = 0.35;
|
|
1710
|
+
const HOST_COMPONENT_TAG = 5;
|
|
1711
|
+
/** The host components from `fiber` up to the root, deepest first: where responder handlers live. */
|
|
1712
|
+
function hostChain(fiber) {
|
|
1713
|
+
const chain = [];
|
|
1714
|
+
for (let current = fiber; current; current = current.return) {
|
|
1715
|
+
if (current.tag === HOST_COMPONENT_TAG)
|
|
1716
|
+
chain.push(current);
|
|
1717
|
+
}
|
|
1718
|
+
return chain;
|
|
1719
|
+
}
|
|
1720
|
+
function callResponder(fiber, name, event) {
|
|
1721
|
+
const props = (0, types_1.isRecord)(fiber.memoizedProps) ? fiber.memoizedProps : {};
|
|
1722
|
+
const handler = props[name];
|
|
1723
|
+
if (typeof handler !== 'function')
|
|
1724
|
+
return undefined;
|
|
1725
|
+
return handler(event);
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* React Native's negotiation for who handles a touch: the capture phase from the root down, then
|
|
1729
|
+
* the bubble phase from the deepest view up; the first view whose handler answers true wins.
|
|
1730
|
+
*/
|
|
1731
|
+
function negotiate(chain, capture, bubble, event, skip) {
|
|
1732
|
+
for (const fiber of [...chain].reverse()) {
|
|
1733
|
+
if (fiber !== skip && callResponder(fiber, capture, event) === true)
|
|
1734
|
+
return fiber;
|
|
1735
|
+
}
|
|
1736
|
+
for (const fiber of chain) {
|
|
1737
|
+
if (fiber !== skip && callResponder(fiber, bubble, event) === true)
|
|
1738
|
+
return fiber;
|
|
1739
|
+
}
|
|
1740
|
+
return undefined;
|
|
1741
|
+
}
|
|
1742
|
+
function touchEvent(history, x, y, at, target, active) {
|
|
1743
|
+
const tag = nativeTagOf(target);
|
|
1744
|
+
const touch = { identifier: 0, pageX: x, pageY: y, locationX: x, locationY: y, timestamp: at, target: tag };
|
|
1745
|
+
return {
|
|
1746
|
+
nativeEvent: { ...touch, touches: active ? [touch] : [], changedTouches: [touch] },
|
|
1747
|
+
touchHistory: history,
|
|
1748
|
+
target: tag,
|
|
1749
|
+
currentTarget: tag,
|
|
1750
|
+
timeStamp: at,
|
|
1751
|
+
persist: () => undefined,
|
|
1752
|
+
isPersistent: () => true,
|
|
1753
|
+
preventDefault: () => undefined,
|
|
1754
|
+
stopPropagation: () => undefined,
|
|
1755
|
+
isDefaultPrevented: () => false,
|
|
1756
|
+
isPropagationStopped: () => false,
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
function callProp(fiber, name) {
|
|
1760
|
+
return (0, types_1.isRecord)(fiber.memoizedProps) ? fiber.memoizedProps[name] : undefined;
|
|
1761
|
+
}
|
|
1762
|
+
/** A Modal's own window, which takes any touch nobody inside it takes (so it doesn't fall through). */
|
|
1763
|
+
function isModalHost(fiber) {
|
|
1764
|
+
return typeof fiber.type === 'string' && /ModalHostView/.test(fiber.type);
|
|
1765
|
+
}
|
|
1766
|
+
function isScrollHost(fiber) {
|
|
1767
|
+
return typeof fiber.type === 'string' && /ScrollView|RCTScroll/.test(fiber.type);
|
|
1768
|
+
}
|
|
1769
|
+
/**
|
|
1770
|
+
* `swipe`: a finger dragged from (x0, y0) to (x1, y1) over `seconds`, through React Native's own
|
|
1771
|
+
* touch system: the views under the start point negotiate for it as they would for a finger
|
|
1772
|
+
* (PanResponder, a sheet's swipe to close, a Pressable that gives way once the finger moves), and
|
|
1773
|
+
* the winner gets the moves and the release with a real touch history. Gestures that
|
|
1774
|
+
* react-native-gesture-handler or a native view handle (sliders, maps) don't go through it: they
|
|
1775
|
+
* answer `no_action`, as does a start on a scroll view (`scroll` moves those).
|
|
1776
|
+
*/
|
|
1777
|
+
async function swipe(rawParams) {
|
|
1778
|
+
const params = paramsOf(rawParams);
|
|
1779
|
+
const numbers = [params.x0, params.y0, params.x1, params.y1];
|
|
1780
|
+
if (!numbers.every((value) => typeof value === 'number' && Number.isFinite(value))) {
|
|
1781
|
+
throw badParams('swipe needs x0, y0, x1 and y1 in points');
|
|
1782
|
+
}
|
|
1783
|
+
const [x0, y0, x1, y1] = numbers;
|
|
1784
|
+
const seconds = typeof params.seconds === 'number' && params.seconds > 0 ? Math.min(params.seconds, 5) : SWIPE_DEFAULT_SECONDS;
|
|
1785
|
+
const snapshot = (0, inspect_1.takeSnapshot)();
|
|
1786
|
+
const screen = snapshot.screen;
|
|
1787
|
+
if ([x0, x1].some((x) => x < 0 || x > screen.width) || [y0, y1].some((y) => y < 0 || y > screen.height)) {
|
|
1788
|
+
throw badParams(`swipe points must be on the screen (0 to ${screen.width} across, 0 to ${screen.height} down)`);
|
|
1789
|
+
}
|
|
1790
|
+
assertNoBlockingAlert();
|
|
1791
|
+
// What lies under the start point: the hit test, else the frontmost element whose frame holds it.
|
|
1792
|
+
let start = await (0, inspect_1.hitTest)(x0, y0);
|
|
1793
|
+
if (!start) {
|
|
1794
|
+
const frames = await Promise.all(snapshot.entries.map((entry) => (0, inspect_1.measureFiber)(entry.node.fiber)));
|
|
1795
|
+
snapshot.entries.forEach((entry, i) => {
|
|
1796
|
+
const frame = frames[i];
|
|
1797
|
+
if (frame && (0, inspect_1.frameContains)(frame, x0, y0))
|
|
1798
|
+
start = entry.node.fiber;
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
if (!start)
|
|
1802
|
+
throw notActionable('no_action', `Nothing at (${x0}, ${y0}) to swipe on.`);
|
|
1803
|
+
const origin = start;
|
|
1804
|
+
const chain = hostChain(origin);
|
|
1805
|
+
const begin = Date.now();
|
|
1806
|
+
const record = {
|
|
1807
|
+
touchActive: true,
|
|
1808
|
+
startPageX: x0,
|
|
1809
|
+
startPageY: y0,
|
|
1810
|
+
startTimeStamp: begin,
|
|
1811
|
+
currentPageX: x0,
|
|
1812
|
+
currentPageY: y0,
|
|
1813
|
+
currentTimeStamp: begin,
|
|
1814
|
+
previousPageX: x0,
|
|
1815
|
+
previousPageY: y0,
|
|
1816
|
+
previousTimeStamp: begin,
|
|
1817
|
+
};
|
|
1818
|
+
const history = { touchBank: [record], numberActiveTouches: 1, indexOfSingleActiveTouch: 0, mostRecentTimeStamp: begin };
|
|
1819
|
+
const advance = (x, y, at) => {
|
|
1820
|
+
record.previousPageX = record.currentPageX;
|
|
1821
|
+
record.previousPageY = record.currentPageY;
|
|
1822
|
+
record.previousTimeStamp = record.currentTimeStamp;
|
|
1823
|
+
record.currentPageX = x;
|
|
1824
|
+
record.currentPageY = y;
|
|
1825
|
+
record.currentTimeStamp = at;
|
|
1826
|
+
history.mostRecentTimeStamp = at;
|
|
1827
|
+
};
|
|
1828
|
+
// A native scroll view takes a drag along its axis on the native side, unless a view inside it claims
|
|
1829
|
+
// the moves first (a swipeable row): then the swipe is the scroll view's, and `scroll` moves it.
|
|
1830
|
+
const scrollHost = chain.find(isScrollHost);
|
|
1831
|
+
const alongScroll = scrollHost !== undefined && (callProp(scrollHost, 'horizontal') === true ? Math.abs(x1 - x0) > Math.abs(y1 - y0) : Math.abs(y1 - y0) >= Math.abs(x1 - x0));
|
|
1832
|
+
let claimedOnMove;
|
|
1833
|
+
let responder = negotiate(chain, 'onStartShouldSetResponderCapture', 'onStartShouldSetResponder', touchEvent(history, x0, y0, begin, origin, true));
|
|
1834
|
+
if (responder)
|
|
1835
|
+
callResponder(responder, 'onResponderGrant', touchEvent(history, x0, y0, begin, origin, true));
|
|
1836
|
+
const steps = Math.max(3, Math.min(120, Math.round((seconds * 1000) / SWIPE_STEP_MS)));
|
|
1837
|
+
for (let i = 1; i <= steps; i += 1) {
|
|
1838
|
+
await new Promise((resolve) => setTimeout(resolve, SWIPE_STEP_MS));
|
|
1839
|
+
const x = x0 + ((x1 - x0) * i) / steps;
|
|
1840
|
+
const y = y0 + ((y1 - y0) * i) / steps;
|
|
1841
|
+
const at = Math.max(Date.now(), history.mostRecentTimeStamp + 1);
|
|
1842
|
+
advance(x, y, at);
|
|
1843
|
+
const event = touchEvent(history, x, y, at, origin, true);
|
|
1844
|
+
// A view may take the touch over once the finger moves (a sheet's pan over a button inside it).
|
|
1845
|
+
const taker = negotiate(responder ? hostChain(responder) : chain, 'onMoveShouldSetResponderCapture', 'onMoveShouldSetResponder', event, responder);
|
|
1846
|
+
if (taker) {
|
|
1847
|
+
const yields = responder === undefined || callResponder(responder, 'onResponderTerminationRequest', event) !== false;
|
|
1848
|
+
if (yields) {
|
|
1849
|
+
if (responder)
|
|
1850
|
+
callResponder(responder, 'onResponderTerminate', event);
|
|
1851
|
+
responder = taker;
|
|
1852
|
+
claimedOnMove = taker;
|
|
1853
|
+
callResponder(responder, 'onResponderGrant', event);
|
|
1854
|
+
}
|
|
1855
|
+
else {
|
|
1856
|
+
callResponder(taker, 'onResponderReject', event);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
if (responder)
|
|
1860
|
+
callResponder(responder, 'onResponderMove', event);
|
|
1861
|
+
}
|
|
1862
|
+
const end = Math.max(Date.now(), history.mostRecentTimeStamp + 1);
|
|
1863
|
+
advance(x1, y1, end);
|
|
1864
|
+
record.touchActive = false;
|
|
1865
|
+
history.numberActiveTouches = 0;
|
|
1866
|
+
const finalEvent = touchEvent(history, x1, y1, end, origin, false);
|
|
1867
|
+
const scrollTakesIt = alongScroll && scrollHost !== undefined && !(claimedOnMove !== undefined && claimedOnMove !== scrollHost && (0, inspect_1.isWithin)(claimedOnMove, scrollHost));
|
|
1868
|
+
if (!responder || isScrollHost(responder) || isModalHost(responder) || scrollTakesIt) {
|
|
1869
|
+
if (responder)
|
|
1870
|
+
callResponder(responder, 'onResponderTerminate', finalEvent);
|
|
1871
|
+
const why = responder && isModalHost(responder) && !scrollTakesIt
|
|
1872
|
+
? "only the open sheet's own window took it, so nothing in the sheet handles a swipe (a sheet that closes by a swipe claims the touch when it starts)"
|
|
1873
|
+
: responder || scrollTakesIt
|
|
1874
|
+
? 'it starts on a scroll view, which moves natively: use scroll'
|
|
1875
|
+
: "nothing there takes a swipe through React Native's touch system";
|
|
1876
|
+
throw notActionable('no_action', `Nothing took the swipe from (${x0}, ${y0}): ${why}. Gestures that react-native-gesture-handler or a native view handle (sliders, maps) can't be made from inside the app; Tapwright's swipe makes real ones.`);
|
|
1877
|
+
}
|
|
1878
|
+
callResponder(responder, 'onResponderRelease', finalEvent);
|
|
1879
|
+
const owner = [...snapshot.entries].reverse().find((entry) => entry.info.interesting && (0, inspect_1.isWithin)(responder, entry.node.fiber));
|
|
1880
|
+
const element = owner ? (await elementFor(owner)).element : null;
|
|
1881
|
+
return { element, from: { x: x0, y: y0 }, to: { x: x1, y: y1 }, ...(await settleOutcome()) };
|
|
1882
|
+
}
|
|
1883
|
+
//# sourceMappingURL=actions.js.map
|