From f6afa0103b81447ece64878c64a6b108be10bf1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 12:01:49 +0200 Subject: [PATCH] test(ios): recover the E2E visibility search from a scroll that overshoots its target A controlled iOS scroll can still release with inertia when the host is loaded: the app misses the slow tail of the eased drag and UIKit flings with a mid-drag velocity (~420 pt/s measured), so one scroll down 0.75 moved content 719-852 pt instead of 439-505 pt and carried the one-row automation-longpress target past the viewport. The forward-only search then had nowhere to go (#2491). The search now follows three forward scrolls (2.25 viewports, unchanged) with three shorter reverse scrolls, whose travel plus a measured fling stays inside one viewport, and gives a surface the read reports as unsettled a bounded pause before re-reading it instead of re-reading at once. --- ...os-simulator-e2e-visibility-scroll.test.ts | 157 +++++++++++------- .../ios-simulator-e2e/live-assertions.ts | 87 ++++++---- 2 files changed, 152 insertions(+), 92 deletions(-) diff --git a/test/integration/ios-simulator-e2e-visibility-scroll.test.ts b/test/integration/ios-simulator-e2e-visibility-scroll.test.ts index fd79c11b4d..ae2458bb30 100644 --- a/test/integration/ios-simulator-e2e-visibility-scroll.test.ts +++ b/test/integration/ios-simulator-e2e-visibility-scroll.test.ts @@ -2,7 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { CliJsonResult } from './cli-json.ts'; -import { searchForVisibleElement } from './ios-simulator-e2e/live-assertions.ts'; +import { + searchForVisibleElement, + type ScrollSearchDevice, +} from './ios-simulator-e2e/live-assertions.ts'; function result(status: number, details?: Record): CliJsonResult { return { @@ -13,84 +16,114 @@ function result(status: number, details?: Record): CliJsonResul }; } -test('an existing offscreen element scrolls until the visibility probe passes', async () => { - const probes = [result(1), result(0)]; - const probeAttempts: number[] = []; - const scrollAttempts: number[] = []; - - await searchForVisibleElement( - 'id="automation-longpress"', - async (attempt) => { - probeAttempts.push(attempt); - return probes.shift() ?? result(1); +const UNSETTLED = { unsettledGesture: { action: 'scroll', positionals: [] } }; + +/** + * A vertical list the search drives. Offsets are in viewports; the target is visible while the + * offset lies inside `visible`. Each scroll moves by the next planned travel, clamped to the list + * bounds. `movesUntilSettled` keeps the surface moving after every scroll that moved until the + * search pauses to settle it, so each read before that misses with `unsettledGesture`: the CI + * failure shape. + */ +function list(options: { + visible?: readonly [number, number]; + downTravel?: readonly number[]; + end?: number; + movesUntilSettled?: boolean; +}) { + const downTravel = [...(options.downTravel ?? [])]; + const end = options.end ?? 10; + let offset = 0; + let moving = false; + const log: string[] = []; + const device: ScrollSearchDevice = { + probeVisibility: async (probe) => { + log.push(`probe ${probe}`); + const [from, to] = options.visible ?? [Infinity, Infinity]; + if (!moving && offset >= from && offset <= to) return result(0); + return result(1, moving ? UNSETTLED : { reason: 'selector_not_found' }); }, - async (attempt) => { - scrollAttempts.push(attempt); + settle: async () => { + log.push('settle'); + moving = false; }, - ); + scroll: async (step) => { + const travel = step.direction === 'down' ? (downTravel.shift() ?? 0.75) : -0.5; + const next = Math.min(end, Math.max(0, offset + travel)); + const moved = next !== offset; + offset = next; + moving = options.movesUntilSettled === true && moved; + log.push(`scroll ${step.direction} ${step.amount}`); + }, + }; + return { device, log }; +} - assert.deepEqual(probeAttempts, [1, 2]); - assert.deepEqual(scrollAttempts, [1]); +test('an existing offscreen element scrolls until the visibility probe passes', async () => { + const { device, log } = list({ visible: [0.5, 1.2] }); + + await searchForVisibleElement('id="target"', device); + + assert.deepEqual(log, ['probe 1', 'scroll down 0.75', 'probe 2']); }); test('a stalled capture retries without scrolling or consuming an attempt', async () => { const probes = [result(1, { captureStalled: true }), result(0)]; - const probeAttempts: number[] = []; - const scrollAttempts: number[] = []; - - await searchForVisibleElement( - 'id="automation-longpress"', - async (attempt) => { - probeAttempts.push(attempt); - return probes.shift() ?? result(1); - }, - async (attempt) => { - scrollAttempts.push(attempt); + const scrolls: string[] = []; + + await searchForVisibleElement('id="target"', { + probeVisibility: async () => probes.shift() ?? result(1), + settle: async () => assert.fail('a stalled capture is not a moving surface'), + scroll: async (step) => { + scrolls.push(step.direction); }, - ); + }); - assert.deepEqual(probeAttempts, [1, 1]); - assert.deepEqual(scrollAttempts, []); + assert.deepEqual([probes.length, scrolls], [0, []]); }); -/** - * The CI failure shape: the element is on screen only at offset 1, and the first read after each - * scroll lands on a surface still moving, so it misses with `unsettledGesture`. - */ -function listWithUnsettledFirstReads(visibleAt?: number) { - let offset = 0; - let moving = false; - const probes: number[] = []; - const scrolls: number[] = []; - const probe = async (attempt: number) => { - probes.push(attempt); - const unsettled = moving; - moving = false; - if (!unsettled && offset === visibleAt) return result(0); - return result(1, unsettled ? { unsettledGesture: { action: 'scroll', positionals: [] } } : {}); - }; - const scroll = async (attempt: number) => { - scrolls.push(attempt); - offset += 1; - moving = true; - }; - return { probes, scrolls, probe, scroll }; -} +test('an unsettled miss waits for the surface to settle and re-reads at the same offset', async () => { + const { device, log } = list({ visible: [0.5, 1.2], movesUntilSettled: true }); -test('an unsettled miss after the scroll that reached the element is re-read at the same offset', async () => { - const list = listWithUnsettledFirstReads(1); + await searchForVisibleElement('id="target"', device); + + assert.deepEqual(log, ['probe 1', 'scroll down 0.75', 'probe 2', 'settle', 'probe 3']); +}); - await searchForVisibleElement('id="target"', list.probe, list.scroll); +test('a forward scroll that overshoots the element is recovered by scrolling back', async () => { + // Measured on an iOS 26 simulator under host load: one `scroll down 0.75` moved content 852 pt + // instead of its usual 439-505 pt, which carried a one-row target past the viewport. + const { device, log } = list({ + visible: [0.6, 1.25], + downTravel: [1.4, 0.75, 0.75], + end: 2.2, + movesUntilSettled: true, + }); - assert.deepEqual([list.probes, list.scrolls], [[1, 2, 2], [1]]); + await searchForVisibleElement('id="target"', device); + + assert.deepEqual( + log.filter((entry) => entry.startsWith('scroll')), + ['scroll down 0.75', 'scroll down 0.75', 'scroll down 0.75', 'scroll up 0.5', 'scroll up 0.5'], + ); }); -test('a real absence still fails after the forward scrolls, naming every step', async () => { - const list = listWithUnsettledFirstReads(); +test('a real absence still fails after both sweeps, naming every step', async () => { + const { device, log } = list({ movesUntilSettled: true }); await assert.rejects( - searchForVisibleElement('id="target"', list.probe, list.scroll), - /scroll after attempt 3: [\s\S]*probe 4:/, + searchForVisibleElement('id="target"', device), + /scroll down 3: [\s\S]*scroll up 6: [\s\S]*probe \d+:/, + ); + assert.deepEqual( + log.filter((entry) => entry.startsWith('scroll')), + [ + 'scroll down 0.75', + 'scroll down 0.75', + 'scroll down 0.75', + 'scroll up 0.5', + 'scroll up 0.5', + 'scroll up 0.5', + ], ); - assert.deepEqual(list.scrolls, [1, 2, 3]); }); diff --git a/test/integration/ios-simulator-e2e/live-assertions.ts b/test/integration/ios-simulator-e2e/live-assertions.ts index af9cdbf757..b0e33d0092 100644 --- a/test/integration/ios-simulator-e2e/live-assertions.ts +++ b/test/integration/ios-simulator-e2e/live-assertions.ts @@ -40,33 +40,53 @@ export function snapshotNodes(result: { json?: any }): LiveSnapshotNode[] { return nodes as LiveSnapshotNode[]; } -const SCROLL_SEARCH_ATTEMPTS = 4; +/** + * Three forward scrolls reach 2.25 viewports of finger travel. The reverse steps are shorter: a + * controlled iOS scroll can still carry post-release inertia under host load (ADR 0013), so a + * forward step can carry a short target past the viewport, and a reverse step plus that inertia + * must stay inside one viewport so the reverse sweep cannot skip it again. + */ +const FORWARD = { direction: 'down', amount: '0.75' } as const; +const REVERSE = { direction: 'up', amount: '0.5' } as const; +const SCROLL_SEARCH_PLAN = [FORWARD, FORWARD, FORWARD, REVERSE, REVERSE, REVERSE]; // A stalled capture, or one taken while the last scroll was still moving, says nothing about where // the element is, so re-reading it must not consume a scroll. A couple of re-reads per scroll absorb // a slow runner without masking a real absence. const SCROLL_SEARCH_REREADS = 2; +const SETTLE_MS = '1000'; + +export type ScrollSearchStep = (typeof SCROLL_SEARCH_PLAN)[number]; + +export type ScrollSearchDevice = { + probeVisibility: (probe: number) => Promise; + /** Gives a surface that was still moving a bounded pause before the next read. */ + settle: () => Promise; + scroll: (step: ScrollSearchStep, index: number) => Promise; +}; export async function assertElementTextAfterScrolling( context: LiveContext, selector: string, expected: string, ): Promise { - await searchForVisibleElement( - selector, - (attempt) => + await searchForVisibleElement(selector, { + probeVisibility: (probe) => runStep( context, - `check ${selector} visibility after scroll (attempt ${attempt})`, + `check ${selector} visibility (probe ${probe})`, ['is', 'visible', selector], { allowFailure: true }, ), - (attempt) => - runStep(context, `scroll toward ${selector} after attempt ${attempt}`, [ + settle: async () => { + await runStep(context, `settle before re-reading ${selector}`, ['wait', SETTLE_MS]); + }, + scroll: (step, index) => + runStep(context, `scroll ${step.direction} toward ${selector} (scroll ${index})`, [ 'scroll', - 'down', - '0.75', + step.direction, + step.amount, ]).then((result) => result.json?.data), - ); + }); await assertElementText(context, selector, expected); } @@ -78,34 +98,41 @@ export async function assertElementTextAfterScrolling( */ export async function searchForVisibleElement( selector: string, - probeVisibility: (attempt: number) => Promise, - scrollAfterAttempt: (attempt: number) => Promise, + device: ScrollSearchDevice, ): Promise { - let rereadsLeft = SCROLL_SEARCH_REREADS; const history: string[] = []; - - for (let attempt = 1; attempt <= SCROLL_SEARCH_ATTEMPTS;) { - const probe = await probeVisibility(attempt); - history.push(`probe ${attempt}: ${JSON.stringify(probe.json ?? { status: probe.status })}`); - if (probe.status === 0) return; - - const details = probe.json?.error?.details; - const readNothing = details?.captureStalled === true || details?.unsettledGesture !== undefined; - if (readNothing && rereadsLeft > 0) { - rereadsLeft -= 1; - continue; + let probes = 0; + const readWindow = async (): Promise => { + for (let rereads = 0; ; rereads += 1) { + probes += 1; + const result = await device.probeVisibility(probes); + history.push(`probe ${probes}: ${JSON.stringify(result.json ?? { status: result.status })}`); + if (result.status === 0) return true; + const unread = unreadSurface(result); + if (unread === undefined || rereads === SCROLL_SEARCH_REREADS) return false; + if (unread === 'moving') { + await device.settle(); + history.push(`settled for ${SETTLE_MS} ms`); + } } + }; - attempt += 1; - if (attempt <= SCROLL_SEARCH_ATTEMPTS) { - const scrolled = await scrollAfterAttempt(attempt - 1); - history.push(`scroll after attempt ${attempt - 1}: ${JSON.stringify(scrolled ?? null)}`); - rereadsLeft = SCROLL_SEARCH_REREADS; - } + for (const [index, step] of SCROLL_SEARCH_PLAN.entries()) { + if (await readWindow()) return; + const scrolled = await device.scroll(step, index + 1); + history.push(`scroll ${step.direction} ${index + 1}: ${JSON.stringify(scrolled ?? null)}`); } + if (await readWindow()) return; assert.fail(`${selector} did not become visible after scrolling\n${history.join('\n')}`); } +/** Why a missed probe says nothing about where the element is, if it says nothing. */ +function unreadSurface(result: CliJsonResult): 'moving' | 'stalled' | undefined { + const details = result.json?.error?.details; + if (details?.unsettledGesture !== undefined) return 'moving'; + return details?.captureStalled === true ? 'stalled' : undefined; +} + function requireNode( result: CliJsonResult, identifier: string,