From 581e878ed631e817338f21097ea6d807f31ef054 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Fri, 9 Oct 2026 09:15:41 -0400 Subject: [PATCH] fix: measure the iOS viewport again when a Safari tip takes the native tap On the first Safari start of a new iOS 26 simulator, a Safari tip takes the native tap of the viewport measurement, so the overlay reports 0x0. The empty viewport made full page screenshots fail with a negative scroll position. Check the overlay data on iOS too and measure again (3 attempts). Replace the unit test that expected the 0x0 result. Co-Authored-By: Claude Opus 5.5 --- .changeset/ios-viewport-safari-tip.md | 7 +++ .../src/helpers/utils.test.ts | 51 ++++++++++++++++--- .../src/helpers/utils.ts | 44 ++++++++++------ 3 files changed, 79 insertions(+), 23 deletions(-) create mode 100644 .changeset/ios-viewport-safari-tip.md diff --git a/.changeset/ios-viewport-safari-tip.md b/.changeset/ios-viewport-safari-tip.md new file mode 100644 index 000000000..db1b3d0a0 --- /dev/null +++ b/.changeset/ios-viewport-safari-tip.md @@ -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). diff --git a/packages/image-comparison-core/src/helpers/utils.test.ts b/packages/image-comparison-core/src/helpers/utils.test.ts index 62b845505..6d1746b89 100644 --- a/packages/image-comparison-core/src/helpers/utils.test.ts +++ b/packages/image-comparison-core/src/helpers/utils.test.ts @@ -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, @@ -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 () => { diff --git a/packages/image-comparison-core/src/helpers/utils.ts b/packages/image-comparison-core/src/helpers/utils.ts index bf003f7d6..af8198c63 100644 --- a/packages/image-comparison-core/src/helpers/utils.ts +++ b/packages/image-comparison-core/src/helpers/utils.ts @@ -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 @@ -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, @@ -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 @@ -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 }) } @@ -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)