Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 95 additions & 62 deletions test/integration/ios-simulator-e2e-visibility-scroll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): CliJsonResult {
return {
Expand All @@ -13,84 +16,114 @@ function result(status: number, details?: Record<string, unknown>): 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]);
});
87 changes: 57 additions & 30 deletions test/integration/ios-simulator-e2e/live-assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CliJsonResult>;
/** Gives a surface that was still moving a bounded pause before the next read. */
settle: () => Promise<void>;
scroll: (step: ScrollSearchStep, index: number) => Promise<unknown>;
};

export async function assertElementTextAfterScrolling(
context: LiveContext,
selector: string,
expected: string,
): Promise<void> {
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);
}

Expand All @@ -78,34 +98,41 @@ export async function assertElementTextAfterScrolling(
*/
export async function searchForVisibleElement(
selector: string,
probeVisibility: (attempt: number) => Promise<CliJsonResult>,
scrollAfterAttempt: (attempt: number) => Promise<unknown>,
device: ScrollSearchDevice,
): Promise<void> {
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<boolean> => {
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,
Expand Down
Loading