Files
Michał Pierzchała e3cdbcdb37 fix(test): give the iOS scroll search a capture-sized wait budget (#1514)
`assertElementTextAfterScrolling` waited 1000ms per attempt. The wait's whole
budget goes to its first capture, so that budget has to cover one snapshot of
the current surface; on a loaded simulator it does not, and every attempt fails
with `wait_capture_stalled` instead of reporting the element as off-screen.

This broke the iOS Smoke Tests lane on main. #1484 moved the smoke automation
scenario onto this helper for `automation-press` and `automation-longpress`;
before that it was only reached from the full lane, so the tight budget went
unnoticed. Every sibling wait in the same scenario already budgets 2500ms or
more (`label="Settings"` 10000, `alert wait` 5000, `wait text` 2500).

Two changes:

- Raise the per-attempt budget to 2500ms, matching the nearest sibling.
- Stop spending a scroll attempt on a stalled capture. A stall means the
  snapshot never came back, so the surface was never read — it is not evidence
  the element is off-screen, and scrolling on it moves the surface for an
  unrelated reason. Two stall retries absorb a slow runner; a genuine absence
  still consumes attempts and still fails.

The final assertion now carries the last wait's JSON, so a future failure says
whether it stalled or genuinely never found the element.


Claude-Session: https://claude.ai/code/session_01RXQLYV7etZx3gcXsUsrQJ8

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-31 09:10:44 +02:00

99 lines
3.8 KiB
TypeScript

import assert from 'node:assert/strict';
import { PUBLIC_COMMANDS } from '../../../src/command-catalog.ts';
import {
assertFilesDiffer,
assertJsonContains,
assertMp4File,
assertNonEmptyFile,
createLiveDeviceAssertions,
} from '../live-device-e2e/assertions.ts';
import type { CliJsonResult } from '../cli-json.ts';
import type { IosSimulatorBehaviorId } from './behavior-coverage.ts';
import { type LiveContext, runStep, verifyCommand } from './live-harness.ts';
export { assertFilesDiffer, assertJsonContains, assertMp4File, assertNonEmptyFile };
export const { assertElementText, assertWaitText, capturePng } = createLiveDeviceAssertions<
IosSimulatorBehaviorId,
LiveContext
>(runStep, verifyCommand, PUBLIC_COMMANDS.wait);
// The whole budget goes to the first capture of each attempt, so it has to cover one snapshot of
// the current surface — 1s did not on a loaded simulator, and every sibling wait in these scenarios
// already budgets 2.5s or more.
const SCROLL_SEARCH_WAIT_MS = 2500;
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;
export async function assertElementTextAfterScrolling(
context: LiveContext,
selector: string,
expected: string,
): Promise<void> {
let stallRetriesLeft = SCROLL_SEARCH_STALL_RETRIES;
let lastFailure: CliJsonResult | undefined;
for (let attempt = 1; attempt <= SCROLL_SEARCH_ATTEMPTS; ) {
const visible = await runStep(
context,
`wait for ${selector} after scroll (attempt ${attempt})`,
['wait', selector, String(SCROLL_SEARCH_WAIT_MS)],
{ allowFailure: true },
);
if (visible.status === 0) {
await assertElementText(context, selector, expected);
return;
}
lastFailure = visible;
// 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 (visible.json?.error?.details?.captureStalled === true && stallRetriesLeft > 0) {
stallRetriesLeft -= 1;
continue;
}
attempt += 1;
if (attempt <= SCROLL_SEARCH_ATTEMPTS) {
await runStep(context, `scroll toward ${selector}`, ['scroll', 'down', '0.75']);
}
}
assert.fail(
`${selector} did not become visible after scrolling\nlast wait: ${JSON.stringify(lastFailure?.json ?? null)}`,
);
}
function requireNode(
result: CliJsonResult,
identifier: string,
): { label?: unknown; rect?: { height: number; width: number; x: number; y: number } } {
const nodes = Array.isArray(result.json?.data?.nodes) ? result.json.data.nodes : [];
const node = nodes.find(
(candidate: { identifier?: unknown }) => candidate.identifier === identifier,
);
assert.ok(node, `snapshot missing ${identifier}: ${JSON.stringify(result.json)}`);
return node;
}
export function requireNodeRect(
result: CliJsonResult,
identifier: string,
): { height: number; width: number; x: number; y: number } {
const rect = requireNode(result, identifier).rect;
assert.ok(rect, `snapshot node ${identifier} has no rect: ${JSON.stringify(result.json)}`);
for (const value of [rect.x, rect.y, rect.width, rect.height]) {
assert.ok(Number.isFinite(value), `snapshot node ${identifier} has invalid rect`);
}
return rect;
}
export function requireDevice(result: CliJsonResult, udid: string): { booted?: unknown } {
const devices = Array.isArray(result.json?.data?.devices) ? result.json.data.devices : [];
const device = devices.find((candidate: { id?: unknown }) => candidate.id === udid);
assert.ok(device, `device inventory missing ${udid}: ${JSON.stringify(result.json)}`);
return device;
}