Skip to content
Closed
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
6 changes: 6 additions & 0 deletions test/integration/android-emulator-e2e/live-assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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'],
Expand Down
14 changes: 12 additions & 2 deletions test/integration/android-emulator-e2e/live-automation-scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
assertWaitText,
capturePng,
requireAndroidResourceId,
scrollUntilVisible,
} from './live-assertions.ts';
import { type LiveContext, runStep, verifyBehavior, verifyCommand } from './live-harness.ts';

Expand Down Expand Up @@ -126,7 +127,12 @@ export async function assertAutomationSystem(context: LiveContext): Promise<void
'fixture automation-window value changed to landscape and back to portrait',
);

await runStep(context, 'reveal input canaries', ['scroll', 'down', '0.7']);
// `scroll` is a gesture and app scroll physics decide the final offset, so a single blind
// amount cannot guarantee the canary is on screen — least of all right after a rotation
// round-trip has relaid the list out. Anchor first: the search only scrolls down, so a canary
// the rotation left ABOVE the viewport is unreachable without returning to a known position.
await runStep(context, 'restore automation route top after Android rotation', ['scroll', 'top']);
await scrollUntilVisible(context, 'id="automation-press"');
await runStep(context, 'press semantic canary', ['press', 'id="automation-press"']);
await assertWaitText(context, 'Last input: press');
verifyCommand(context, C.press, 'semantic press updates durable fixture input state');
Expand Down Expand Up @@ -175,7 +181,11 @@ export async function assertAutomationSystem(context: LiveContext): Promise<void
verifyCommand(context, C.alert, 'alert wait/get/dismiss/accept produce fixture-visible results');

await assertHomeAndRecentsRestoration(context);
await runStep(context, 'reveal Android alert canary for diff baseline', ['scroll', 'down', '1']);
await runStep(context, 'restore automation route top before the diff baseline', [
'scroll',
'top',
]);
await scrollUntilVisible(context, 'id="automation-open-alert"');
await runStep(context, 'establish automation diff baseline', ['snapshot', '-i']);
await runStep(context, 'return from automation route with Back', ['back']);
const diff = await runStep(context, 'observe automation-to-settings diff', [
Expand Down
59 changes: 3 additions & 56 deletions test/integration/ios-simulator-e2e/live-assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,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 { IosSimulatorBehaviorId } from './behavior-coverage.ts';
import { type LiveContext, runStep, verifyCommand } from './live-harness.ts';
Expand All @@ -21,71 +22,17 @@ export const { assertElementText, assertWaitSelector, assertWaitText, capturePng
PUBLIC_COMMANDS.wait,
);

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;
const { scrollUntilVisible } = createVisibilityScroll<IosSimulatorBehaviorId, LiveContext>(runStep);

export async function assertElementTextAfterScrolling(
context: LiveContext,
selector: string,
expected: string,
): Promise<void> {
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 <selector>` 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<CliJsonResult>,
scrollAfterAttempt: (attempt: number) => Promise<void>,
): Promise<void> {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): CliJsonResult {
return {
Expand Down Expand Up @@ -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[] = [];
Expand Down
96 changes: 96 additions & 0 deletions test/integration/live-device-e2e/visibility-scroll.ts
Original file line number Diff line number Diff line change
@@ -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: Context,
step: string,
args: string[],
options?: { allowFailure?: boolean },
) => Promise<CliJsonResult>;

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 <selector>` 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<CliJsonResult>,
scrollAfterAttempt: (attempt: number) => Promise<void>,
probeForEvidence?: () => Promise<CliJsonResult>,
): Promise<void> {
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<BehaviorId>,
>(runStep: RunStep<Context>) {
async function scrollUntilVisible(context: Context, selector: string): Promise<void> {
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 };
}