diff --git a/test/integration/android-emulator-e2e/live-assertions.ts b/test/integration/android-emulator-e2e/live-assertions.ts index cb6ff84bc2..de2d6a9588 100644 --- a/test/integration/android-emulator-e2e/live-assertions.ts +++ b/test/integration/android-emulator-e2e/live-assertions.ts @@ -9,6 +9,7 @@ import { assertNonEmptyFile, createLiveDeviceAssertions, } from '../live-device-e2e/assertions.ts'; +import { createVisibilityScroll } from '../live-device-e2e/visibility-scroll.ts'; import type { CliJsonResult } from '../cli-json.ts'; import type { AndroidEmulatorBehaviorId } from './behavior-coverage.ts'; import { type LiveContext, runStep, verifyCommand } from './live-harness.ts'; @@ -22,6 +23,11 @@ export const { assertElementText, assertWaitSelector, assertWaitText, capturePng PUBLIC_COMMANDS.wait, ); +export const { scrollUntilVisible } = createVisibilityScroll< + AndroidEmulatorBehaviorId, + LiveContext +>(runStep); + export function assertDiffLine( result: CliJsonResult, kind: SnapshotDiffLine['kind'], diff --git a/test/integration/android-emulator-e2e/live-automation-scenario.ts b/test/integration/android-emulator-e2e/live-automation-scenario.ts index 0dddb2824e..2b25e95207 100644 --- a/test/integration/android-emulator-e2e/live-automation-scenario.ts +++ b/test/integration/android-emulator-e2e/live-automation-scenario.ts @@ -12,6 +12,7 @@ import { assertWaitText, capturePng, requireAndroidResourceId, + scrollUntilVisible, } from './live-assertions.ts'; import { type LiveContext, runStep, verifyBehavior, verifyCommand } from './live-harness.ts'; @@ -126,7 +127,12 @@ export async function assertAutomationSystem(context: LiveContext): Promise(runStep); export async function assertElementTextAfterScrolling( context: LiveContext, selector: string, expected: string, ): Promise { - await searchForVisibleElement( - selector, - (attempt) => - runStep( - context, - `check ${selector} visibility after scroll (attempt ${attempt})`, - ['is', 'visible', selector], - { allowFailure: true }, - ), - (attempt) => - runStep(context, `scroll toward ${selector} after attempt ${attempt}`, [ - 'scroll', - 'down', - '0.75', - ]).then(() => undefined), - ); + await scrollUntilVisible(context, selector); await assertElementText(context, selector, expected); } -/** - * Searches by semantic visibility rather than selector existence. An offscreen node can exist in - * the accessibility tree, so a successful `wait ` is not sufficient evidence to skip - * scrolling. The callbacks keep this live-device policy deterministic and unit-testable without a - * simulator. - */ -export async function searchForVisibleElement( - selector: string, - probeVisibility: (attempt: number) => Promise, - scrollAfterAttempt: (attempt: number) => Promise, -): Promise { - let stallRetriesLeft = SCROLL_SEARCH_STALL_RETRIES; - let lastFailure: CliJsonResult | undefined; - - for (let attempt = 1; attempt <= SCROLL_SEARCH_ATTEMPTS;) { - const probe = await probeVisibility(attempt); - if (probe.status === 0) return; - lastFailure = probe; - - // The snapshot never came back, so the surface was never read. Scrolling here would move the - // surface for a reason unrelated to visibility and spend an attempt on no evidence. - if (probe.json?.error?.details?.captureStalled === true && stallRetriesLeft > 0) { - stallRetriesLeft -= 1; - continue; - } - - attempt += 1; - if (attempt <= SCROLL_SEARCH_ATTEMPTS) { - await scrollAfterAttempt(attempt - 1); - } - } - assert.fail( - `${selector} did not become visible after scrolling\nlast visibility probe: ${JSON.stringify(lastFailure?.json ?? null)}`, - ); -} - function requireNode( result: CliJsonResult, identifier: string, diff --git a/test/integration/ios-simulator-e2e-visibility-scroll.test.ts b/test/integration/live-device-e2e-visibility-scroll.test.ts similarity index 72% rename from test/integration/ios-simulator-e2e-visibility-scroll.test.ts rename to test/integration/live-device-e2e-visibility-scroll.test.ts index 4824634682..ec357a8800 100644 --- a/test/integration/ios-simulator-e2e-visibility-scroll.test.ts +++ b/test/integration/live-device-e2e-visibility-scroll.test.ts @@ -2,7 +2,7 @@ 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 } from './live-device-e2e/visibility-scroll.ts'; function result(status: number, details?: Record): CliJsonResult { return { @@ -33,6 +33,25 @@ test('an existing offscreen element scrolls until the visibility probe passes', assert.deepEqual(scrollAttempts, [1]); }); +test('an exhausted budget spends a final probe as a real step so evidence is captured', async () => { + const evidenceProbes: number[] = []; + + await assert.rejects( + searchForVisibleElement( + 'id="automation-press"', + async () => result(1), + async () => {}, + async () => { + evidenceProbes.push(1); + return result(1); + }, + ), + /did not become visible after scrolling/, + ); + + assert.deepEqual(evidenceProbes, [1]); +}); + test('a stalled capture retries without scrolling or consuming an attempt', async () => { const probes = [result(1, { captureStalled: true }), result(0)]; const probeAttempts: number[] = []; diff --git a/test/integration/live-device-e2e/visibility-scroll.ts b/test/integration/live-device-e2e/visibility-scroll.ts new file mode 100644 index 0000000000..5af5b39328 --- /dev/null +++ b/test/integration/live-device-e2e/visibility-scroll.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; + +import type { CliJsonResult } from '../cli-json.ts'; +import type { LiveDeviceContext } from './runtime.ts'; + +type RunStep = ( + context: Context, + step: string, + args: string[], + options?: { allowFailure?: boolean }, +) => Promise; + +const SCROLL_SEARCH_ATTEMPTS = 4; +// A stalled capture says nothing about where the element is, so it must not consume the scroll +// budget outright; a couple of retries absorb a slow runner without masking a real absence. +const SCROLL_SEARCH_STALL_RETRIES = 2; +// One finger path per attempt. `scroll` is a gesture, not an offset — app scroll physics decide +// where the content lands — so the search re-probes rather than trusting a single amount. +const SCROLL_SEARCH_AMOUNT = '0.75'; + +/** + * Searches by semantic visibility rather than selector existence. An offscreen node can exist in + * the accessibility tree, so a successful `wait ` is not sufficient evidence to skip + * scrolling. The callbacks keep this live-device policy deterministic and unit-testable without a + * device. + */ +export async function searchForVisibleElement( + selector: string, + probeVisibility: (attempt: number) => Promise, + scrollAfterAttempt: (attempt: number) => Promise, + probeForEvidence?: () => Promise, +): Promise { + let stallRetriesLeft = SCROLL_SEARCH_STALL_RETRIES; + let lastFailure: CliJsonResult | undefined; + + for (let attempt = 1; attempt <= SCROLL_SEARCH_ATTEMPTS;) { + const probe = await probeVisibility(attempt); + if (probe.status === 0) return; + lastFailure = probe; + + // The snapshot never came back, so the surface was never read. Scrolling here would move the + // surface for a reason unrelated to visibility and spend an attempt on no evidence. + if (probe.json?.error?.details?.captureStalled === true && stallRetriesLeft > 0) { + stallRetriesLeft -= 1; + continue; + } + + attempt += 1; + if (attempt <= SCROLL_SEARCH_ATTEMPTS) { + await scrollAfterAttempt(attempt - 1); + } + } + // The probes above run with `allowFailure`, so none of them reached the harness's failed-step + // evidence capture. Spend one more as a real step: it fails the same way and writes the + // screenshot, snapshot and device facts that say what was on screen instead. + await probeForEvidence?.(); + assert.fail( + `${selector} did not become visible after scrolling\nlast visibility probe: ${JSON.stringify(lastFailure?.json ?? null)}`, + ); +} + +/** + * Binds {@link searchForVisibleElement} to a platform's `runStep`, so every live scenario reveals + * a canary the same way: probe visibility, scroll one finger path, probe again. + */ +export function createVisibilityScroll< + BehaviorId extends string, + Context extends LiveDeviceContext, +>(runStep: RunStep) { + async function scrollUntilVisible(context: Context, selector: string): Promise { + await searchForVisibleElement( + selector, + (attempt) => + runStep( + context, + `check ${selector} visibility after scroll (attempt ${attempt})`, + ['is', 'visible', selector], + { allowFailure: true }, + ), + (attempt) => + runStep(context, `scroll toward ${selector} after attempt ${attempt}`, [ + 'scroll', + 'down', + SCROLL_SEARCH_AMOUNT, + ]).then(() => undefined), + () => + runStep(context, `probe ${selector} after exhausting the scroll budget`, [ + 'is', + 'visible', + selector, + ]), + ); + } + + return { scrollUntilVisible }; +}