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
7 changes: 7 additions & 0 deletions .changeset/ios-viewport-safari-tip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@wdio/image-comparison-core": patch
---

fix: measure the iOS viewport again when a Safari tip takes the native tap

To find the position of the webview, the service loads a test page with an overlay and makes a native tap in the center of the screen. On the first Safari start of a new simulator or device (for example in CI), iOS 26 shows a tip ("View Bookmarks, Share Menu, and Open Tabs"). The tap only closed that tip and did not reach the overlay. The service then used an empty viewport (0x0), and full page screenshots failed with "Negative scroll position detected". The service now checks the measurement on iOS too, as it already did on Android, and measures again (up to 3 attempts).
51 changes: 44 additions & 7 deletions packages/image-comparison-core/src/helpers/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,15 +805,51 @@ describe('utils', () => {
errorSpy.mockRestore()
})

it('should not retry on iOS when overlay returns zeros', async () => {
it('should measure again on iOS when the first native tap only closes a Safari tip', async () => {
const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {})

vi.mocked(mockBrowserInstance.execute)
// --- Attempt 1: a Safari tip takes the native tap, the overlay gets nothing ---
.mockResolvedValueOnce(undefined) // loadBase64Html (blob)
.mockResolvedValueOnce(undefined) // checkMetaTag (iOS)
.mockResolvedValueOnce(undefined) // injectWebviewOverlay
.mockResolvedValueOnce({ x: 0, y: 0, width: 0, height: 0 }) // getMobileWebviewClickAndDimensions
.mockResolvedValueOnce({ vs: 'visible', focus: true }) // visibilityState debug check
// --- Attempt 2: the tip is closed, the overlay gets the tap ---
.mockResolvedValueOnce(undefined) // loadBase64Html (blob)
.mockResolvedValueOnce(undefined) // checkMetaTag (iOS)
.mockResolvedValueOnce(undefined) // injectWebviewOverlay
.mockResolvedValueOnce({ x: 150, y: 300, width: 100, height: 100 }) // getMobileWebviewClickAndDimensions

const result = await getMobileViewPortPosition({
browserInstance: mockBrowserInstance,
...baseOptions,
isAndroid: false,
isIOS: true,
})

expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('overlay did not receive the native click')
)
expect(mockBrowserInstance.url).toHaveBeenCalledWith('http://example.com')
expect(result.viewport).toEqual({ y: 100, x: 50, width: 100, height: 100 })
// The Android Back button is not used on iOS
expect(mockBrowserInstance.executeScript).not.toHaveBeenCalledWith('mobile: pressKey', expect.anything())

warnSpy.mockRestore()
})

it('should return initialDeviceRectangles after all attempts are exhausted on iOS', async () => {
const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {})
const errorSpy = vi.spyOn(log, 'error').mockImplementation(() => {})

const mocked = vi.mocked(mockBrowserInstance.execute)
for (let i = 0; i < 3; i++) {
mocked
.mockResolvedValueOnce(undefined) // loadBase64Html (blob)
.mockResolvedValueOnce(undefined) // checkMetaTag (iOS)
.mockResolvedValueOnce(undefined) // injectWebviewOverlay
.mockResolvedValueOnce({ x: 0, y: 0, width: 0, height: 0 }) // getMobileWebviewClickAndDimensions
}

const result = await getMobileViewPortPosition({
browserInstance: mockBrowserInstance,
Expand All @@ -822,14 +858,15 @@ describe('utils', () => {
isIOS: true,
})

// iOS uses a single attempt — no retry, no Start Surface dismissal
expect(warnSpy).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledTimes(3)
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Viewport measurement failed after 3 attempts')
)
expect(mockBrowserInstance.url).toHaveBeenCalledWith('http://example.com')
// The zero overlay values produce a viewport at screen center with 0 dimensions
expect(result.viewport.width).toBe(0)
expect(result.viewport.height).toBe(0)
expect(result).toEqual(DEVICE_RECTANGLES)

warnSpy.mockRestore()
errorSpy.mockRestore()
})

it('should return initialDeviceRectangles if not WebView (native context)', async () => {
Expand Down
44 changes: 28 additions & 16 deletions packages/image-comparison-core/src/helpers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,11 +502,17 @@ export async function executeNativeClick({ browserInstance, isIOS, x, y }: Execu
* native clicks from reaching the webview overlay. Each retry dismisses the Start
* Surface via a combination of native taps and WebDriver URL navigation, then
* re-attempts the measurement.
*
* iOS does not suffer from this issue, so only a single attempt is made there.
*/
const MAX_ANDROID_VIEWPORT_MEASUREMENT_RETRIES = 5

/**
* The maximum number of times we attempt to measure the viewport position on iOS.
* On the first Safari start of a new simulator or device, Safari shows a tip (for example
* "View Bookmarks, Share Menu, and Open Tabs" on iOS 26). The first native tap only closes
* that tip and does not reach the webview overlay, so the next attempt measures correctly.
*/
const MAX_IOS_VIEWPORT_MEASUREMENT_ATTEMPTS = 3

/**
* Get the mobile viewport position, we determine this by:
* 1. Loading a base64 HTML page
Expand All @@ -516,11 +522,12 @@ const MAX_ANDROID_VIEWPORT_MEASUREMENT_RETRIES = 5
* 5. Calculating the position of the viewport based on the click position of the native click vs the overlay
* 6. Returning the calculated values
*
* On Android only: when the overlay reports zero dimensions (width=0, height=0)
* the native click did not reach the webview. This typically means Chrome's Start
* Surface or tab overview is blocking it. The function will retry up to
* MAX_ANDROID_VIEWPORT_MEASUREMENT_RETRIES times, tapping the tab thumbnail area
* between attempts to dismiss the blocking UI.
* When the overlay reports zero dimensions (width=0, height=0) the native click did not reach
* the webview, and the function measures again:
* - Android: Chrome's Start Surface or tab overview is blocking it. Up to
* MAX_ANDROID_VIEWPORT_MEASUREMENT_RETRIES attempts, with the Back button between attempts.
* - iOS: a Safari tip took the tap (first Safari start). Up to MAX_IOS_VIEWPORT_MEASUREMENT_ATTEMPTS
* attempts; the first tap already closed the tip.
*/
export async function getMobileViewPortPosition({
browserInstance,
Expand All @@ -536,7 +543,7 @@ export async function getMobileViewPortPosition({
const currentUrl = await browserInstance.getUrl()
const nativeClickX = screenWidth / 2
const nativeClickY = screenHeight / 2
const maxAttempts = isAndroid ? MAX_ANDROID_VIEWPORT_MEASUREMENT_RETRIES : 1
const maxAttempts = isAndroid ? MAX_ANDROID_VIEWPORT_MEASUREMENT_RETRIES : MAX_IOS_VIEWPORT_MEASUREMENT_ATTEMPTS

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// 1. Load a base64 HTML page
Expand All @@ -550,20 +557,23 @@ export async function getMobileViewPortPosition({
// 4a. Get the data from the overlay and remove it
const { y, x, width, height } = await browserInstance.execute(getMobileWebviewClickAndDimensions, '[data-test="ics-overlay"]')

// 4b. On Android, validate the overlay data.
// 4b. Validate the overlay data.
// When width and height are both 0 the native click never reached the overlay.
// On Android this typically means Chrome's Start Surface or tab overview is blocking the webview.
// NOTE: for future detection of Chrome's Start Surface, `document.visibilityState`
// and `document.hasFocus()` can be used as a more direct signal. When the Start
// Surface is active the webview reports visibility: "hidden" and hasFocus: false.
// When width and height are both 0 the native click never reached the overlay,
// which typically means Chrome's Start Surface or tab overview is blocking the webview.
if (isAndroid && width === 0 && height === 0) {
// On iOS a Safari tip (first Safari start) takes the tap and closes, so the next attempt works.
if (width === 0 && height === 0) {
log.warn(
`Viewport measurement attempt ${attempt}/${maxAttempts}: ` +
'overlay did not receive the native click. ' +
'Chrome may be showing its Start Surface or tab overview.'
(isAndroid
? 'Chrome may be showing its Start Surface or tab overview.'
: 'Safari may be showing a tip, which the tap closed.')
)

if (attempt < maxAttempts) {
if (isAndroid && attempt < maxAttempts) {
await dismissAndroidStartSurface({ browserInstance })
}

Expand Down Expand Up @@ -592,10 +602,12 @@ export async function getMobileViewPortPosition({
return deviceRectangles
}

// All Android retries exhausted — reset the URL and fall through to initialDeviceRectangles
// All attempts exhausted — reset the URL and fall through to initialDeviceRectangles
log.error(
`Viewport measurement failed after ${maxAttempts} attempts. ` +
'Chrome appears stuck in Start Surface or tab overview mode. ' +
(isAndroid
? 'Chrome appears stuck in Start Surface or tab overview mode. '
: 'Something in Safari blocks the native click on the page. ') +
'Returning initial device rectangles; screenshots may have incorrect dimensions.'
)
await browserInstance.url(currentUrl)
Expand Down
Loading