@keepsnappy/appium-driver 0.1.0-alpha.22 → 0.1.0-alpha.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/lists.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { describeSelector, toSelector } from './selectors.js';
2
- import { LONG_SCROLL_CAP, LONG_SCROLL_PLANNED, IOS_TOP_EXTRA } from './gestures.js';
3
- import { COAST_MAX_MS, GestureNotTaken, scrollList, waitCoasted } from './scroll.js';
2
+ import { LONG_SCROLL_CAP, LONG_SCROLL_PLANNED, TOP_STEP, IOS_TOP_EXTRA } from './gestures.js';
3
+ import { COAST_MAX_MS, COAST_POLL_MS, GestureNotTaken, NavigationStuck, rowEdges, rowsUnderList, scrollList, waitCoasted } from './scroll.js';
4
4
  /** Default fixed end-pass gesture count. */
5
5
  export const END_SCROLLS = 3;
6
6
  /** Default end/top cycle count. */
@@ -20,6 +20,10 @@ export const LOOK_POLL_MS = 100;
20
20
  export const CHECK_TIMEOUT_MS = 20_000;
21
21
  /** Initial visibility wait before trying an indicator or fallback scroll. */
22
22
  export const TARGET_SETTLE_MS = 10_000;
23
+ /** A navigation pass that still has not arrived after this long fails (it has no stroke count to end it). */
24
+ export const NAV_MAX_MS = 120_000;
25
+ /** Two navigation strokes in a row that leave the first and last visible rows as they were stop the pass. */
26
+ export const NAV_STUCK_STROKES = 2;
23
27
  /** Finds any part of target on screen through a list handle. Logs lookup/frame commands only. */
24
28
  async function shownIn(t, listEl, target) {
25
29
  const { childOf: _list, ...inList } = toSelector(target);
@@ -36,13 +40,14 @@ async function shownIn(t, listEl, target) {
36
40
  * begins at least lookWindowMs (1000) after it; lookPollMs defaults to 100. Optional settle runs before those looks.
37
41
  * Logs cmd with the gesture count under field, blind/cap, looks/lookMs, status, duration, optional extra and planned overrun.
38
42
  */
39
- export async function scrollUntilShown(t, { cmd, field, cap, blind = 0, planned, settle, look: once, move, missing, extra = () => ({}), lookWindowMs = LOOK_MS, lookPollMs = LOOK_POLL_MS }) {
43
+ export async function scrollUntilShown(t, { cmd, field, cap, blind = 0, planned, settle, look: once, move, missing, extra = () => ({}), lookWindowMs = LOOK_MS, lookPollMs = LOOK_POLL_MS, soft = false, lookBetweenBlind = false }) {
40
44
  await t.ready();
41
45
  const start = t.now();
42
46
  let moves = 0;
43
47
  let looks = 0;
44
48
  let lookMs = 0;
45
49
  let status = 1;
50
+ let reached = true;
46
51
  try {
47
52
  const look = async () => {
48
53
  if (settle && moves > 0)
@@ -65,12 +70,36 @@ export async function scrollUntilShown(t, { cmd, field, cap, blind = 0, planned,
65
70
  lookMs += t.now() - at;
66
71
  }
67
72
  };
73
+ let shownEarly = false;
68
74
  for (; moves < blind; moves++) {
75
+ // A touch on a list that still coasts stops it (iOS): each blind move waits for the one before to come to rest.
76
+ if (moves > 0 && settle)
77
+ await settle();
78
+ if (moves > 0 && lookBetweenBlind) {
79
+ looks++;
80
+ if (await once()) {
81
+ shownEarly = true;
82
+ break;
83
+ }
84
+ }
69
85
  await move();
70
86
  }
87
+ if (shownEarly) {
88
+ status = 0;
89
+ return moves;
90
+ }
91
+ // All of a soft budget sent blind: nothing is left to decide in the step (the navigation pass after it looks).
92
+ if (soft && blind >= cap && moves >= cap) {
93
+ status = 0;
94
+ reached = false;
95
+ return moves;
96
+ }
71
97
  while (!(await look())) {
72
98
  if (moves >= cap) {
73
- throw new Error(missing(cap));
99
+ if (!soft)
100
+ throw new Error(missing(cap));
101
+ reached = false;
102
+ break;
74
103
  }
75
104
  await move();
76
105
  moves++;
@@ -80,22 +109,29 @@ export async function scrollUntilShown(t, { cmd, field, cap, blind = 0, planned,
80
109
  }
81
110
  finally {
82
111
  const over = planned === undefined ? {} : { extra: Math.max(0, moves - planned) };
83
- t.driver.log.push({ ...t.driver.context, cmd, t: t.now(), ms: t.now() - start, status, [field]: moves, blind, cap, looks, lookMs, ...over, ...extra() });
112
+ t.driver.log.push({ ...t.driver.context, cmd, t: t.now(), ms: t.now() - start, status, [field]: moves, blind, cap, looks, lookMs, ...over, ...(reached ? {} : { reached }), ...extra() });
84
113
  }
85
114
  }
86
115
  /** Platform gesture budget: cap (3), plus iosTopExtra (2) only when returning to the top on iOS. No commands or logs. */
87
116
  export const scrollCap = (t, toward, cap = LONG_SCROLL_CAP, iosTopExtra = IOS_TOP_EXTRA) => cap + (t.driver.platform.name === 'ios' && toward === 'top' ? iosTopExtra : 0);
88
117
  const iosHold = (t, cache) => t.driver.platform.name === 'ios' ? (cache.hold ??= {}) : undefined;
89
118
  /**
90
- * Long-scrolls list toward a partly visible target; scoped defaults true. Refetches missing/stale list handles before
119
+ * Brings `target` of `list` into view toward `toward`. Outside a measured step it is a navigation pass (`navigate`: marked `nav`,
120
+ * the navigation stroke, no gesture cap: it ends on the target found by lookup and the rows at rest, and stops when two strokes
121
+ * leave the rows as they were; `blind`, `cap`, `planned`, `iosTopExtra` and `used` do not apply). Inside a step its gestures stay
122
+ * the measured gesture, as below, and nothing is marked `nav`.
123
+ *
124
+ * In a step it long-scrolls list toward a partly visible target; scoped defaults true. Refetches missing/stale list handles before
91
125
  * moving and rereads the frame before every gesture. A shared cache stores the settled frame, held iOS row and moves used.
92
126
  * Defaults: 3 gestures (5 to top on iOS), 2 planned, 20 s frameTimeoutMs, 3 s coast. used subtracts the prior pass budget.
93
- * A to-top gesture is checked (scrollList `mustMove`); its retry is a logged defect and does not use the budget.
127
+ * A to-top gesture is checked (scrollList `mustMove`); its retry is a logged defect and does not use the budget. With `soft`, the
128
+ * cap ends the loop quietly (`reached: false` in the log) where it would throw.
94
129
  * Logs fling-to-end/top with flings, blind/cap, looks/lookMs, extra, relists, settled, status and duration, including failures.
95
130
  */
96
- export async function flingUntilShown(t, list, target, toward, { scoped = true, blind = 0, cache = {}, row, used = 0, cap = LONG_SCROLL_CAP, planned = LONG_SCROLL_PLANNED, iosTopExtra = IOS_TOP_EXTRA, frameTimeoutMs = CHECK_TIMEOUT_MS, coastMaxMs = COAST_MAX_MS, lookWindowMs, lookPollMs } = {}) {
131
+ export async function flingUntilShown(t, list, target, toward, { scoped = true, blind = 0, cache = {}, row, used = 0, cap = LONG_SCROLL_CAP, planned = LONG_SCROLL_PLANNED, iosTopExtra = IOS_TOP_EXTRA, frameTimeoutMs = CHECK_TIMEOUT_MS, coastMaxMs = COAST_MAX_MS, lookWindowMs, lookPollMs, soft } = {}) {
97
132
  let relists = 0;
98
133
  let settled = false;
134
+ let sent = 0;
99
135
  const { childOf: _list, ...anywhere } = toSelector(target);
100
136
  const frame = async () => {
101
137
  if (!cache.at) {
@@ -104,45 +140,59 @@ export async function flingUntilShown(t, list, target, toward, { scoped = true,
104
140
  }
105
141
  return cache.at;
106
142
  };
143
+ const look = async () => {
144
+ const at = await frame();
145
+ if (!scoped) {
146
+ // Under the list handle when the rows are there; the target need not be a row (a header): a miss there asks the whole screen.
147
+ if ((await rowsUnderList(t, row ?? target, cache)) && (await t.driver.findShown(toSelector(anywhere), false, at.el)))
148
+ return true;
149
+ return Boolean(await t.driver.findShown(toSelector(anywhere)));
150
+ }
151
+ try {
152
+ if (await shownIn(t, at.el, target)) {
153
+ return true;
154
+ }
155
+ }
156
+ catch (e) {
157
+ if (e?.wdError !== 'stale element reference') {
158
+ throw e;
159
+ }
160
+ }
161
+ cache.at = { ...at, el: await t.waitVisible(list) };
162
+ relists++;
163
+ try {
164
+ return await shownIn(t, cache.at.el, target);
165
+ }
166
+ catch (e) {
167
+ if (e?.wdError !== 'stale element reference') {
168
+ throw e;
169
+ }
170
+ return false;
171
+ }
172
+ };
173
+ if (t.stepDepth === 0 && !t.navigating) {
174
+ // A navigation stroke coasts far longer than the measured gesture: the pass keeps navigate's own rest bound.
175
+ await navigate(t, list, toward, { until: look, row: row ?? target, scoped, cache, frameTimeoutMs, label: selectorLabel(target), turnRound: true });
176
+ return;
177
+ }
107
178
  cache.used = await scrollUntilShown(t, {
108
179
  cmd: `fling-to-${toward}`,
109
- field: 'flings', lookWindowMs, lookPollMs,
180
+ field: 'flings', lookWindowMs, lookPollMs, soft,
181
+ // A to-top gesture with the first row shown can drag a sheet closed (F7): blind to-top gestures stop once it shows.
182
+ lookBetweenBlind: toward === 'top',
110
183
  cap: Math.max(0, scrollCap(t, toward, cap, iosTopExtra) - used),
111
184
  planned: Math.max(0, planned - used),
112
185
  blind: Math.min(blind, Math.max(0, scrollCap(t, toward, cap, iosTopExtra) - used)),
113
- settle: () => waitCoasted(t, row ?? target, scoped ? { sel: list, el: cache.at?.el } : undefined, undefined, coastMaxMs, iosHold(t, cache)),
186
+ settle: async () => waitCoasted(t, row ?? target, scoped || (await rowsUnderList(t, row ?? target, cache)) ? { sel: list, el: cache.at?.el } : undefined, undefined, coastMaxMs, iosHold(t, cache)),
114
187
  extra: () => ({ relists, settled }),
115
- async look() {
116
- const at = await frame();
117
- if (!scoped) {
118
- return Boolean(await t.driver.findShown(toSelector(anywhere)));
119
- }
120
- try {
121
- if (await shownIn(t, at.el, target)) {
122
- return true;
123
- }
124
- }
125
- catch (e) {
126
- if (e?.wdError !== 'stale element reference') {
127
- throw e;
128
- }
129
- }
130
- cache.at = { ...at, el: await t.waitVisible(list) };
131
- relists++;
132
- try {
133
- return await shownIn(t, cache.at.el, target);
134
- }
135
- catch (e) {
136
- if (e?.wdError !== 'stale element reference') {
137
- throw e;
138
- }
139
- return false;
140
- }
141
- },
188
+ look,
142
189
  async move() {
143
190
  await frame();
144
- // Before a to-top gesture the first row was not shown, so the list has room: the gesture is checked.
145
- await scrollList(t, { list, row: row ?? target, scoped }, toward, toward === 'top' ? { cache, mustMove: true, hold: iosHold(t, cache), coastMaxMs } : { cache });
191
+ // Before a looked-for to-top gesture the first row was not shown, so the list has room: the gesture is checked. Of blind
192
+ // gestures only the first is: the list may reach its top before the last.
193
+ const checked = toward === 'top' && (sent === 0 || blind === 0);
194
+ sent++;
195
+ await scrollList(t, { list, row: row ?? target, scoped }, toward, toward === 'top' ? { cache, mustMove: checked, hold: iosHold(t, cache), coastMaxMs } : { cache });
146
196
  },
147
197
  missing: () => `${selectorLabel(target)} not on screen after ${scrollCap(t, toward, cap, iosTopExtra)} long scrolls to the ${toward} of ${selectorLabel(list)}`,
148
198
  });
@@ -151,6 +201,99 @@ const selectorLabel = (sel) => {
151
201
  const s = toSelector(sel);
152
202
  return s.text ?? s.id ?? describeSelector(s);
153
203
  };
204
+ /**
205
+ * A navigation pass: gets `list` toward its top or end until `until` finds the target, with the navigation stroke, between `nav`
206
+ * markers (`Session.navigation`; never inside a measured step). Before the pass and after each stroke it waits for the list to
207
+ * come to rest: its first and last row (`rowEdges`) read twice, 300 ms apart, are the same. The pass ends when the target is
208
+ * found by lookup with the list at rest; toward the end, a stroke that rests with no target waits `pageWaitMs` for one (a page
209
+ * lands only once the list rests at its loaded end) before the next. No frame test decides arrival. There is no stroke count: when
210
+ * two strokes in a row leave those rows as they were after the list had moved, the target was passed: the pass turns round once
211
+ * with `NAV_STROKE.precise` drags; when two strokes leave them as they were again (or the list never moved) it throws `NavigationStuck` (counted in `t.defects.navStuck`; the run
212
+ * is invalid), and `maxMs` bounds the pass in time. Logs `nav` with toward, strokes, arrived, status and ms, also on failure.
213
+ */
214
+ export async function navigate(t, list, toward, { until, row, field, scoped = true, cache = {}, pageWaitMs = 0, coastMaxMs, frameTimeoutMs = CHECK_TIMEOUT_MS, maxMs = NAV_MAX_MS, label = selectorLabel(list), catchUp = false, turnRound = false, restBefore = true }) {
215
+ await t.ready();
216
+ const start = t.now();
217
+ const rest = coastMaxMs ?? (t.driver.platform.name === 'ios' ? PAGE_COAST_MAX_MS_IOS : PAGE_COAST_MAX_MS);
218
+ const spec = { list, row, field, scoped };
219
+ let strokes = 0;
220
+ let arrived = false;
221
+ try {
222
+ await t.navigation(async () => {
223
+ if (!cache.at)
224
+ cache.at = await t.settledFrame(list, frameTimeoutMs);
225
+ // A stroke coasts several screens: one can carry a mid-list target past the view to the list's edge. Stalled at that edge
226
+ // with the target unseen, the pass turns round once with drags that move a list less than its own height (no coast).
227
+ let way = toward;
228
+ let turned = false;
229
+ let moved = false;
230
+ const atRest = async () => {
231
+ const deadline = t.now() + rest;
232
+ let reads = 1;
233
+ for (let last = await rowEdges(t, spec, cache);;) {
234
+ await t.driver.sleep(COAST_POLL_MS);
235
+ const now = await rowEdges(t, spec, cache);
236
+ reads++;
237
+ // An empty read (no row found: a list re-rendering as a page lands) is never a rest.
238
+ if (now === last && now !== '')
239
+ return now;
240
+ // The bound is for a list that keeps moving, so it needs at least two comparisons: one read can outlast it on a real
241
+ // device (A23 gate, iPhone in TapIn's live room: a row lookup took 6 s, the first read found no row, the second came at 16 s).
242
+ if (t.now() >= deadline && reads >= 3)
243
+ throw new Error(`${selectorLabel(list)} still moving ${rest} ms ${strokes === 0 ? 'before the first navigation stroke' : 'after a navigation stroke'} toward the ${way}`);
244
+ last = now;
245
+ }
246
+ };
247
+ const waitFor = async () => {
248
+ for (const from = t.now();;) {
249
+ if (await until())
250
+ return true;
251
+ if (t.now() - from >= pageWaitMs)
252
+ return false;
253
+ await t.driver.sleep(PAGE_POLL_MS);
254
+ }
255
+ };
256
+ // A caller that saw the list at rest (an end step that waited out its coast and its page) takes one read for the stall
257
+ // baseline instead of two (gate 41000166764: a read is a 2.2 s page source in the live room).
258
+ let rows = restBefore ? await atRest() : await rowEdges(t, spec, cache);
259
+ let before = null;
260
+ let stalled = 0;
261
+ for (;;) {
262
+ if (strokes === 0 ? await until() : await waitFor()) {
263
+ arrived = true;
264
+ return;
265
+ }
266
+ if (before !== null)
267
+ stalled = rows === before ? stalled + 1 : 0;
268
+ if (before !== null && rows !== before)
269
+ moved = true;
270
+ // A list no stroke ever moved is stuck as it is; one that moved and then stopped stands at an edge.
271
+ if (stalled >= NAV_STUCK_STROKES && turnRound && !turned && moved) {
272
+ turned = true;
273
+ way = toward === 'end' ? 'top' : 'end';
274
+ stalled = 0;
275
+ before = null;
276
+ }
277
+ if (stalled >= NAV_STUCK_STROKES) {
278
+ t.defects.navStuck++;
279
+ throw new NavigationStuck(`${selectorLabel(list)} did not move after ${NAV_STUCK_STROKES} navigation strokes toward the ${way} (${label} not found; rows ${JSON.stringify(rows.split('\n'))})`);
280
+ }
281
+ if (t.now() - start > maxMs)
282
+ throw new Error(`${label} not reached after ${maxMs} ms of navigation toward the ${way} of ${selectorLabel(list)}`);
283
+ before = rows;
284
+ await scrollList(t, spec, way, { cache, ...(turned ? { precise: true } : {}) });
285
+ strokes++;
286
+ rows = await atRest();
287
+ }
288
+ });
289
+ }
290
+ finally {
291
+ if (catchUp && strokes > 0)
292
+ t.defects.navCatchUp++;
293
+ t.driver.log.push({ ...t.driver.context, cmd: 'nav', t: t.now(), ms: t.now() - start, status: arrived ? 0 : 1, toward, list: selectorLabel(list), strokes, arrived });
294
+ }
295
+ return { strokes, arrived, ms: t.now() - start };
296
+ }
154
297
  /** Keeps parent scoping only when requested. */
155
298
  function shownOf(row, scoped) {
156
299
  const { childOf: _list, ...anywhere } = toSelector(row);
@@ -178,17 +321,17 @@ export function passChecked(t, list, target, toward, scoped, cache, row, options
178
321
  };
179
322
  }
180
323
  /** Finds a loaded row without checking its frame; a stale parent is reported to the caller. Logs lookups only. */
181
- async function rowLoaded(t, list, row, scoped, listEl, field) {
182
- const found = await loadedRow(t, list, row, scoped, listEl, field);
324
+ async function rowLoaded(t, list, row, scoped, listEl, field, under) {
325
+ const found = await loadedRow(t, list, row, scoped, listEl, field, under);
183
326
  return found === 'stale' ? found : found !== null;
184
327
  }
185
- async function loadedRow(t, list, row, scoped, listEl, field) {
328
+ async function loadedRow(t, list, row, scoped, listEl, field, under) {
186
329
  const { childOf: _list, ...inList } = toSelector(row);
187
330
  const all = t.driver.platform.locators(inList);
188
331
  const call = field === 'text' ? /textMatches/ : /descriptionMatches/;
189
332
  const locators = t.driver.platform.name === 'android' && field ? all.filter(l => call.test(l.value)) : all;
190
333
  try {
191
- const parent = scoped ? (listEl ?? (await t.waitVisible(list))) : undefined;
334
+ const parent = scoped ? (listEl ?? (await t.waitVisible(list))) : under;
192
335
  for (const { using, value } of locators.length ? locators : all) {
193
336
  const el = await t.driver.find(using, value, parent);
194
337
  if (el)
@@ -215,26 +358,27 @@ async function rowEndsInside(t, list, listRect, row, scoped, listEl, field) {
215
358
  return t.driver.platform.name === 'android' ? r.y + r.height < bottom : r.y + r.height <= bottom;
216
359
  }
217
360
  /**
218
- * Performs scrolls (3) end gestures. A gesture with no page marker pauses pauseMs (500). Otherwise it waits for coast
219
- * (Android 6000 ms, iOS 10000), optionally adds one gesture if beforePages was not reached, then waits pageWaitMs (3000)
220
- * for the page's first loaded row, polling every pagePollMs (300). A page that has not landed by then while its beforePages
221
- * row is cut by (or touches) the list's bottom edge gets that one gesture instead, and a new wait; waitMs counts from the
222
- * last gesture's coast. scoped defaults true; field narrows Android lookups.
361
+ * Performs scrolls (3) end gestures, exactly that many and no other: a page that has not landed never gets a catch-up gesture
362
+ * here (a navigation pass outside the step fetches it). A gesture with no page marker pauses pauseMs (500). Otherwise it waits
363
+ * for coast (Android 6000 ms, iOS 10000), then waitMs (3000) for the page's first loaded row, polling every pagePollMs (300),
364
+ * counted from the coast. A page that has not landed by then fails the pass, or with `deferPages` is logged as not landed.
365
+ * scoped defaults true; field narrows Android lookups. `beforePages` is no longer read.
223
366
  * The pass is taken to start at the top, so its first gesture is checked (scrollList `mustMove`): one that moved nothing is
224
367
  * sent once more and counted in `t.defects`; `unmoved` is 1 then, and 2 when the retry moved nothing either (GestureNotTaken).
225
- * Logs fling-to-end: flings, supplied pageNumbers (or descriptions), coastMs, waitMs, per-page extra, unmoved, pauseMs, settled,
226
- * status and duration. Page checks do not require visibility. All limits and frameTimeoutMs (20000) are configurable.
368
+ * Logs fling-to-end: flings, supplied pageNumbers (or descriptions), coastMs, waitMs, landed (per page), unmoved, pauseMs,
369
+ * settled, status and duration. Page checks do not require visibility. All limits and frameTimeoutMs (20000) are configurable.
227
370
  */
228
- export async function scrollToEnd(t, list, cache, { pages, pageNumbers, beforePages = [], scoped = true, field, any, scrolls = END_SCROLLS, pauseMs = PAGE_PAUSE_MS, pageWaitMs = PAGE_WAIT_MS, pagePollMs = PAGE_POLL_MS, coastMaxMs = PAGE_COAST_MAX_MS, coastMaxMsIos = PAGE_COAST_MAX_MS_IOS, frameTimeoutMs = CHECK_TIMEOUT_MS }) {
371
+ export async function scrollToEnd(t, list, cache, { pages, pageNumbers, scoped = true, field, any, scrolls = END_SCROLLS, pauseMs = PAGE_PAUSE_MS, pageWaitMs = PAGE_WAIT_MS, pagePollMs = PAGE_POLL_MS, coastMaxMs = PAGE_COAST_MAX_MS, coastMaxMsIos = PAGE_COAST_MAX_MS_IOS, frameTimeoutMs = CHECK_TIMEOUT_MS, deferPages = false }) {
229
372
  await t.ready();
230
373
  const start = t.now();
231
374
  let status = 1;
232
375
  let settled = false;
233
376
  const coastMs = [];
234
377
  const waitMs = [];
235
- const extra = [];
378
+ const landed = [];
236
379
  let unmoved = 0;
237
380
  const spec = { list, row: any, field, scoped };
381
+ const coastBound = t.driver.platform.name === 'ios' ? coastMaxMsIos : coastMaxMs;
238
382
  try {
239
383
  if (!cache.at) {
240
384
  cache.at = await t.settledFrame(list, frameTimeoutMs);
@@ -244,7 +388,7 @@ export async function scrollToEnd(t, list, cache, { pages, pageNumbers, beforePa
244
388
  // A pass starts at the top, so its first fling has room and is checked.
245
389
  let sent;
246
390
  try {
247
- sent = await scrollList(t, spec, 'end', { cache, hold: iosHold(t, cache), mustMove: i === 0, coastMaxMs: t.driver.platform.name === 'ios' ? coastMaxMsIos : coastMaxMs });
391
+ sent = await scrollList(t, spec, 'end', { cache, hold: iosHold(t, cache), mustMove: i === 0, coastMaxMs: coastBound });
248
392
  }
249
393
  catch (e) {
250
394
  if (e instanceof GestureNotTaken)
@@ -259,66 +403,38 @@ export async function scrollToEnd(t, list, cache, { pages, pageNumbers, beforePa
259
403
  continue;
260
404
  }
261
405
  const coastFrom = t.now();
262
- const stopped = await waitCoasted(t, any, scoped ? { sel: list, el: cache.at.el } : undefined, field, (t.driver.platform.name === 'ios' ? coastMaxMsIos : coastMaxMs), iosHold(t, cache));
406
+ const stopped = await waitCoasted(t, any, scoped || (await rowsUnderList(t, any, cache)) ? { sel: list, el: cache.at.el } : undefined, field, coastBound, iosHold(t, cache));
263
407
  coastMs.push(t.now() - coastFrom);
264
408
  if (!stopped)
265
- throw new Error(`${selectorLabel(list)} still moving ${(t.driver.platform.name === 'ios' ? coastMaxMsIos : coastMaxMs)} ms after a long scroll to the end`);
266
- extra.push(0);
267
- const before = beforePages[i];
268
- let reached = true;
269
- if (before && t.driver.platform.name === 'android') {
270
- let found = await rowLoaded(t, list, before, scoped, cache.at.el, field);
271
- if (found === 'stale' || (found !== true && scoped)) {
272
- cache.at = { ...cache.at, el: await t.waitVisible(list) };
273
- found = await rowLoaded(t, list, before, scoped, cache.at.el, field);
274
- }
275
- reached = found === true;
276
- }
277
- else if (before) {
278
- reached = await t.isVisible(shownOf(before, scoped));
279
- }
280
- const again = async () => {
281
- extra[i] = 1;
282
- await scrollList(t, spec, 'end', { cache });
283
- if (!(await waitCoasted(t, any, scoped ? { sel: list, el: cache.at.el } : undefined, field, (t.driver.platform.name === 'ios' ? coastMaxMsIos : coastMaxMs), iosHold(t, cache)))) {
284
- throw new Error(`${selectorLabel(list)} still moving ${(t.driver.platform.name === 'ios' ? coastMaxMsIos : coastMaxMs)} ms after a long scroll to the end`);
285
- }
286
- };
287
- if (!reached)
288
- await again();
289
- let from = t.now();
409
+ throw new Error(`${selectorLabel(list)} still moving ${coastBound} ms after a long scroll to the end`);
410
+ const from = t.now();
290
411
  let listEl = cache.at.el;
291
412
  let refound = !scoped;
413
+ let found = false;
292
414
  for (;;) {
293
- const found = await rowLoaded(t, list, page, scoped, listEl, field);
294
- if (found === true)
295
- break;
296
- if (found === 'stale' || !refound) {
415
+ // Unscoped Android rows that sit under the list are looked up under its handle (a whole-screen lookup costs ~4×).
416
+ const under = !scoped && (await rowsUnderList(t, any, cache)) ? listEl : undefined;
417
+ found = await rowLoaded(t, list, page, scoped, listEl, field, under);
418
+ if (found === 'stale' || (found !== true && !refound)) {
297
419
  refound = true;
298
420
  listEl = await t.waitVisible(list);
299
421
  cache.at = { ...cache.at, el: listEl };
300
- if ((await rowLoaded(t, list, page, scoped, listEl, field)) === true)
301
- break;
302
- }
303
- if (t.now() - from >= pageWaitMs) {
304
- // The app loads a page only once the list rests at its loaded end; a cut last row means it rests short of it.
305
- if (before && !extra[i] && !(await rowEndsInside(t, list, cache.at.rect, before, scoped, listEl, field))) {
306
- await again();
307
- from = t.now();
308
- continue;
309
- }
310
- waitMs.push(t.now() - from);
311
- throw new Error(`${selectorLabel(page)} not loaded ${pageWaitMs} ms after a long scroll to the end of ${selectorLabel(list)}`);
422
+ found = await rowLoaded(t, list, page, scoped, listEl, field, under === undefined ? undefined : listEl);
312
423
  }
424
+ if (found === true || t.now() - from >= pageWaitMs)
425
+ break;
313
426
  await t.driver.sleep(pagePollMs);
314
427
  }
315
428
  waitMs.push(t.now() - from);
429
+ landed.push(found === true);
430
+ if (found !== true && !deferPages)
431
+ throw new Error(`${selectorLabel(page)} not loaded ${pageWaitMs} ms after a long scroll to the end of ${selectorLabel(list)}`);
316
432
  }
317
433
  status = 0;
318
434
  }
319
435
  finally {
320
436
  const numbers = pageNumbers ?? pages.map(p => describeSelector(toSelector(p)));
321
- t.driver.log.push({ ...t.driver.context, cmd: 'fling-to-end', t: t.now(), ms: t.now() - start, status, flings: scrolls, pages: numbers, coastMs, waitMs, extra, unmoved, pauseMs, settled });
437
+ t.driver.log.push({ ...t.driver.context, cmd: 'fling-to-end', t: t.now(), ms: t.now() - start, status, flings: scrolls, pages: numbers, coastMs, waitMs, landed, unmoved, pauseMs, settled });
322
438
  }
323
439
  }
324
440
  /**
@@ -344,30 +460,84 @@ export function endChecked(t, list, lastPage, scoped, field, { checkTimeoutMs =
344
460
  };
345
461
  }
346
462
  /**
347
- * Runs cycles (3) end/top measured step pairs with names from stepName(toward, oneBasedCycle). Only cycle one waits for
348
- * page markers; every end check requires lastPage loaded. Shares frame/row cache and each top pass's budget with its
349
- * check. frameTimeoutMs feeds the end frame wait; checkTimeoutMs feeds its end check. fling configures top passes/checks. Logs step markers and nested helper summaries.
463
+ * Runs cycles (3) end/top measured step pairs with names from stepName(toward, oneBasedCycle). Each step sends exactly its
464
+ * planned controlled gestures, the same in every run: the end step `scrolls` (3) end gestures, the top step 2, the top one looking
465
+ * before the second (a gesture at the top drags a sheet) and stopping quietly short of the top. Nothing is checked inside a step.
466
+ * After each step's end marker a navigation pass (`navigate`, marked `nav`, evidence only) finishes the way: to the loaded end
467
+ * until a lastPage row is loaded (strokes needed there are counted as the harness defect `navCatchUp`), to the top until `first`
468
+ * shows; the pass ends on lookups and rows at rest, never on frames. Then the end check (lastPage loaded, checkTimeoutMs for the
469
+ * end check) and the top check (first shown). Only cycle one waits for page markers. Shares the frame/row cache across steps and
470
+ * passes. frameTimeoutMs feeds the end frame wait. `fling` configures the top step: its `cap` and `iosTopExtra` no longer widen
471
+ * it. Logs step markers, nav passes and nested helper summaries.
350
472
  */
351
473
  export async function scrollCycles(t, { list, first, lastPage, cycles = SCROLL_CYCLES, stepName, fling = {}, ...options }) {
352
474
  const cache = {};
353
475
  const scoped = options.scoped ?? true;
476
+ const topPlanned = fling.planned ?? TOP_STEP.gestures;
477
+ const lastLoaded = async () => {
478
+ const loaded = await rowLoaded(t, list, lastPage, scoped, undefined, options.field);
479
+ return (loaded === 'stale' ? await rowLoaded(t, list, lastPage, scoped, undefined, options.field) : loaded) === true;
480
+ };
481
+ const firstShown = () => t.isVisible(shownOf(first, scoped));
354
482
  for (let i = 1; i <= cycles; i++) {
355
483
  await t.waitVisible(list);
356
- await t.step(stepName('end', i), () => scrollToEnd(t, list, cache, { ...options, pages: i === 1 ? options.pages : [], pageNumbers: i === 1 ? options.pageNumbers : undefined }), endChecked(t, list, lastPage, scoped, options.field, options));
357
- await t.step(stepName('top', i), () => flingUntilShown(t, list, first, 'top', { ...fling, scoped, cache, row: options.any }), passChecked(t, list, first, 'top', scoped, cache, options.any, fling));
484
+ await t.step(stepName('end', i), () => scrollToEnd(t, list, cache, { ...options, pages: i === 1 ? options.pages : [], pageNumbers: i === 1 ? options.pageNumbers : undefined, deferPages: true }));
485
+ // The end step waited out its last gesture's coast (and page): the list is at rest.
486
+ await navigate(t, list, 'end', { until: lastLoaded, row: options.any, field: options.field, scoped, cache, pageWaitMs: options.pageWaitMs ?? PAGE_WAIT_MS, label: selectorLabel(lastPage), catchUp: true, restBefore: false });
487
+ await endChecked(t, list, lastPage, scoped, options.field, options)();
488
+ await t.step(stepName('top', i), () => flingUntilShown(t, list, first, 'top', { ...fling, scoped, cache, row: options.any, cap: topPlanned, planned: topPlanned, iosTopExtra: 0, blind: topPlanned, used: 0, soft: true }));
489
+ await navigate(t, list, 'top', { until: firstShown, row: options.any, field: options.field, scoped, cache, coastMaxMs: fling.coastMaxMs, label: selectorLabel(first) });
490
+ await t.waitAll([{ shown: shownOf(first, scoped) }], fling.checkTimeoutMs ?? CHECK_TIMEOUT_MS);
491
+ }
492
+ }
493
+ /**
494
+ * A list paged by a marker at its end ("Show more"), measured the D1 way: per page a step of exactly `gestures` controlled
495
+ * gestures toward the end (each after the coast of the one before, nothing looked for inside the step), then a navigation pass
496
+ * outside the step finishes the way to the page's marker, then `onMore`. The measured count is a constant, wherever the list
497
+ * comes to rest (gate runs 10-30: TapIn's hand-written loop looked before every gesture, audience to-end sent 10/10/8).
498
+ */
499
+ export async function pagedToEnd(t, { list, row, pages, gestures = 1, more, stepName, onMore, field, scoped = true, coastMaxMs }) {
500
+ const cache = {};
501
+ for (let p = 1; p <= pages; p++) {
502
+ const marker = more(p);
503
+ await t.waitVisible(list);
504
+ await t.step(stepName(p), () => flingUntilShown(t, list, marker, 'end', { scoped, cache, row, cap: gestures, planned: gestures, blind: gestures, iosTopExtra: 0, used: 0, soft: true, ...(coastMaxMs ? { coastMaxMs } : {}) }));
505
+ const { childOf: _list, ...anywhere } = toSelector(marker);
506
+ await navigate(t, list, 'end', { until: () => t.isVisible(scoped ? marker : anywhere), row, field, scoped, cache, pageWaitMs: PAGE_WAIT_MS, label: selectorLabel(marker) });
507
+ await onMore(p);
508
+ // The marker's action loads more rows: the next page starts from a list frame read again.
509
+ cache.at = undefined;
358
510
  }
359
511
  }
512
+ /**
513
+ * The measured way back to the top of a list: a step of TOP_STEP.gestures (2) controlled gestures, fewer only when the first row
514
+ * already shows after the first (F7: a gesture at the top drags a sheet), then a navigation pass outside the step to the first row.
515
+ * The same as `scrollCycles`' top half, for lists a journey pages itself (gate runs 10-30: audience to-top sent 6/6/5).
516
+ */
517
+ export async function topMeasured(t, { list, first, row, stepName, field, scoped = true, coastMaxMs, checkTimeoutMs = CHECK_TIMEOUT_MS }) {
518
+ const cache = {};
519
+ const top = TOP_STEP.gestures;
520
+ await t.waitVisible(list);
521
+ await t.step(stepName, () => flingUntilShown(t, list, first, 'top', { scoped, cache, row, cap: top, planned: top, iosTopExtra: 0, blind: top, used: 0, soft: true, ...(coastMaxMs ? { coastMaxMs } : {}) }));
522
+ await navigate(t, list, 'top', { until: () => t.isVisible(shownOf(first, scoped)), row, field, scoped, cache, label: selectorLabel(first) });
523
+ await t.waitAll([{ shown: shownOf(first, scoped) }], checkTimeoutMs);
524
+ }
360
525
  /**
361
526
  * Waits settleMs (10000) for target, then taps an optional indicator and/or runs move until visible, at most cap (3)
362
- * times, coasting on row after each action. Logs cmd (scroll-to-target) with tapped, swipes, extra above planned (2),
363
- * status and duration. An initially visible target returns without a summary. missing customizes the failure message.
527
+ * times, coasting on row after each action. Outside a measured step this is a navigation pass: marked `nav`, and the list
528
+ * gestures `move` sends are the navigation stroke (the pass has no list to read rows on, so `cap` still ends it). Logs cmd
529
+ * (scroll-to-target) with tapped, swipes, extra above planned (2), status and duration. An initially visible target returns without a summary. missing customizes the failure message.
364
530
  */
365
- export async function scrollToTarget(t, { target, indicator, row, move, settleMs = TARGET_SETTLE_MS, cap = LONG_SCROLL_CAP, planned = LONG_SCROLL_PLANNED, cmd = 'scroll-to-target', missing = n => `${selectorLabel(target)} not on screen after ${n} long scrolls` }) {
531
+ export async function scrollToTarget(t, options) {
532
+ const { target, settleMs = TARGET_SETTLE_MS } = options;
366
533
  try {
367
534
  await t.waitVisible(target, settleMs);
368
535
  return;
369
536
  }
370
537
  catch { }
538
+ await t.navigationUnlessStep(() => bringToTarget(t, options));
539
+ }
540
+ async function bringToTarget(t, { target, indicator, row, move, cap = LONG_SCROLL_CAP, planned = LONG_SCROLL_PLANNED, cmd = 'scroll-to-target', missing = n => `${selectorLabel(target)} not on screen after ${n} long scrolls` }) {
371
541
  const start = t.now();
372
542
  let swipes = 0;
373
543
  let tapped = false;