Skip to content

Commit 598ea05

Browse files
committed
fix; make the webview determination more stable
- update images - add more tests
1 parent db18129 commit 598ea05

9 files changed

Lines changed: 51 additions & 118 deletions

packages/image-comparison-core/src/helpers/utils.test.ts

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,7 @@ describe('utils', () => {
679679
.mockResolvedValueOnce(undefined) // injectWebviewOverlay
680680
.mockResolvedValueOnce(undefined) // executeNativeClick
681681
.mockResolvedValueOnce({ x: 150, y: 300, width: 100, height: 100 }) // getMobileWebviewClickAndDimensions
682+
.mockResolvedValueOnce({ vs: 'visible', focus: true }) // visibilityState debug check
682683

683684
const result = await getMobileViewPortPosition({
684685
browserInstance: mockBrowserInstance,
@@ -715,6 +716,7 @@ describe('utils', () => {
715716
width: overlayWidth,
716717
height: overlayHeight,
717718
}) // getMobileWebviewClickAndDimensions (rounded integers from overlay)
719+
.mockResolvedValueOnce({ vs: 'visible', focus: true }) // visibilityState debug check
718720

719721
const result = await getMobileViewPortPosition({
720722
browserInstance: mockBrowserInstance,
@@ -735,16 +737,15 @@ describe('utils', () => {
735737

736738
it('should retry and succeed on Android when overlay returns zeros (Start Surface blocking)', async () => {
737739
const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {})
738-
const infoSpy = vi.spyOn(log, 'info').mockImplementation(() => {})
739740

740741
vi.mocked(mockBrowserInstance.execute)
741742
// --- Attempt 1: overlay returns zeros (Start Surface is blocking) ---
742743
.mockResolvedValueOnce(undefined) // loadBase64Html (blob)
743744
.mockResolvedValueOnce(undefined) // injectWebviewOverlay
744745
.mockResolvedValueOnce(undefined) // executeNativeClick (screen center)
745746
.mockResolvedValueOnce({ x: 0, y: 0, width: 0, height: 0 }) // getMobileWebviewClickAndDimensions
746-
// --- Dismiss Start Surface: tap tab thumbnail area ---
747-
.mockResolvedValueOnce(undefined) // executeNativeClick (thumbnail tap)
747+
// --- Dismiss Start Surface: Back button ---
748+
.mockResolvedValueOnce(undefined) // mobile: pressKey (Back button)
748749
// --- Attempt 2: overlay returns valid data ---
749750
.mockResolvedValueOnce(undefined) // loadBase64Html (blob)
750751
.mockResolvedValueOnce(undefined) // injectWebviewOverlay
@@ -761,41 +762,32 @@ describe('utils', () => {
761762
expect(warnSpy).toHaveBeenCalledWith(
762763
expect.stringContaining('overlay did not receive the native click')
763764
)
764-
expect(infoSpy).toHaveBeenCalledWith(
765-
expect.stringContaining('Tapping tab thumbnail area')
766-
)
767765
expect(mockBrowserInstance.url).toHaveBeenCalledWith('http://example.com')
768766
expect(result.viewport.width).toBe(100)
769767
expect(result.viewport.height).toBe(100)
770768

771769
warnSpy.mockRestore()
772-
infoSpy.mockRestore()
773770
})
774771

775-
it('should return initialDeviceRectangles after all retries are exhausted on Android', async () => {
772+
it('should return initialDeviceRectangles after all retries are exhausted on Android', { timeout: 15000 }, async () => {
776773
const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {})
777-
const infoSpy = vi.spyOn(log, 'info').mockImplementation(() => {})
778774
const errorSpy = vi.spyOn(log, 'error').mockImplementation(() => {})
779775

780776
const zeroOverlay = { x: 0, y: 0, width: 0, height: 0 }
781-
vi.mocked(mockBrowserInstance.execute)
782-
// --- Attempt 1 ---
783-
.mockResolvedValueOnce(undefined) // loadBase64Html
784-
.mockResolvedValueOnce(undefined) // injectWebviewOverlay
785-
.mockResolvedValueOnce(undefined) // executeNativeClick (center)
786-
.mockResolvedValueOnce(zeroOverlay) // getMobileWebviewClickAndDimensions
787-
.mockResolvedValueOnce(undefined) // dismissal tap
788-
// --- Attempt 2 ---
789-
.mockResolvedValueOnce(undefined) // loadBase64Html
790-
.mockResolvedValueOnce(undefined) // injectWebviewOverlay
791-
.mockResolvedValueOnce(undefined) // executeNativeClick (center)
792-
.mockResolvedValueOnce(zeroOverlay) // getMobileWebviewClickAndDimensions
793-
.mockResolvedValueOnce(undefined) // dismissal tap
794-
// --- Attempt 3 (last, no dismissal after) ---
795-
.mockResolvedValueOnce(undefined) // loadBase64Html
796-
.mockResolvedValueOnce(undefined) // injectWebviewOverlay
797-
.mockResolvedValueOnce(undefined) // executeNativeClick (center)
798-
.mockResolvedValueOnce(zeroOverlay) // getMobileWebviewClickAndDimensions
777+
const attemptMocks = () => [
778+
undefined, // loadBase64Html
779+
undefined, // injectWebviewOverlay
780+
undefined, // executeNativeClick (center)
781+
zeroOverlay, // getMobileWebviewClickAndDimensions
782+
]
783+
const mocked = vi.mocked(mockBrowserInstance.execute)
784+
// 5 attempts: attempts 1-4 have Back button dismissal, last attempt has none
785+
for (let i = 0; i < 5; i++) {
786+
for (const val of attemptMocks()) { mocked.mockResolvedValueOnce(val) }
787+
if (i < 4) {
788+
mocked.mockResolvedValueOnce(undefined) // mobile: pressKey (Back button)
789+
}
790+
}
799791

800792
const result = await getMobileViewPortPosition({
801793
browserInstance: mockBrowserInstance,
@@ -804,15 +796,14 @@ describe('utils', () => {
804796
isIOS: false,
805797
})
806798

807-
expect(warnSpy).toHaveBeenCalledTimes(3)
799+
expect(warnSpy).toHaveBeenCalledTimes(5)
808800
expect(errorSpy).toHaveBeenCalledWith(
809-
expect.stringContaining('Viewport measurement failed after 3 attempts')
801+
expect.stringContaining('Viewport measurement failed after 5 attempts')
810802
)
811803
expect(mockBrowserInstance.url).toHaveBeenCalledWith('http://example.com')
812804
expect(result).toEqual(DEVICE_RECTANGLES)
813805

814806
warnSpy.mockRestore()
815-
infoSpy.mockRestore()
816807
errorSpy.mockRestore()
817808
})
818809

@@ -825,6 +816,7 @@ describe('utils', () => {
825816
.mockResolvedValueOnce(undefined) // injectWebviewOverlay
826817
.mockResolvedValueOnce(undefined) // executeNativeClick (center, 'mobile: tap')
827818
.mockResolvedValueOnce({ x: 0, y: 0, width: 0, height: 0 }) // getMobileWebviewClickAndDimensions
819+
.mockResolvedValueOnce({ vs: 'visible', focus: true }) // visibilityState debug check
828820

829821
const result = await getMobileViewPortPosition({
830822
browserInstance: mockBrowserInstance,

packages/image-comparison-core/src/helpers/utils.ts

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -494,13 +494,13 @@ export async function executeNativeClick({ browserInstance, isIOS, x, y }: Execu
494494
/**
495495
* The maximum number of times we attempt to measure the viewport position on Android.
496496
* Chrome may start in the "Start Surface" (tab thumbnail overview) which blocks
497-
* native clicks from reaching the webview overlay. Each retry taps the top-left
498-
* area of the screen where the tab thumbnail typically sits, dismissing the Start
499-
* Surface so the next measurement attempt succeeds.
497+
* native clicks from reaching the webview overlay. Each retry dismisses the Start
498+
* Surface via a combination of native taps and WebDriver URL navigation, then
499+
* re-attempts the measurement.
500500
*
501501
* iOS does not suffer from this issue, so only a single attempt is made there.
502502
*/
503-
const MAX_ANDROID_VIEWPORT_MEASUREMENT_RETRIES = 3
503+
const MAX_ANDROID_VIEWPORT_MEASUREMENT_RETRIES = 5
504504

505505
/**
506506
* Get the mobile viewport position, we determine this by:
@@ -546,6 +546,9 @@ export async function getMobileViewPortPosition({
546546
const { y, x, width, height } = await browserInstance.execute(getMobileWebviewClickAndDimensions, '[data-test="ics-overlay"]')
547547

548548
// 4b. On Android, validate the overlay data.
549+
// NOTE: for future detection of Chrome's Start Surface, `document.visibilityState`
550+
// and `document.hasFocus()` can be used as a more direct signal. When the Start
551+
// Surface is active the webview reports visibility: "hidden" and hasFocus: false.
549552
// When width and height are both 0 the native click never reached the overlay,
550553
// which typically means Chrome's Start Surface or tab overview is blocking the webview.
551554
if (isAndroid && width === 0 && height === 0) {
@@ -556,7 +559,7 @@ export async function getMobileViewPortPosition({
556559
)
557560

558561
if (attempt < maxAttempts) {
559-
await dismissAndroidStartSurface({ browserInstance, screenWidth, screenHeight })
562+
await dismissAndroidStartSurface({ browserInstance })
560563
}
561564

562565
continue
@@ -598,30 +601,31 @@ export async function getMobileViewPortPosition({
598601
}
599602

600603
/**
601-
* Attempt to dismiss Chrome's "Start Surface" (tab thumbnail overview) on Android.
604+
* Attempt to dismiss Chrome's "Start Surface" (tab thumbnail overview) on Android
605+
* by pressing the Android Back button (KEYCODE_BACK = 4).
602606
*
603-
* The Start Surface places the first tab thumbnail in the top-left quadrant of
604-
* the screen. A native tap at ~15 % of the screen's width/height reliably hits
605-
* that thumbnail across phone and tablet form factors, opening the active tab
606-
* and dismissing the overlay so subsequent viewport measurements can succeed.
607+
* The Back button is preferred over tapping a tab thumbnail because:
608+
* - It reliably exits the tab overview regardless of orientation or device
609+
* - It doesn't risk accidentally closing a tab by hitting the "X" button
610+
*
611+
* // --- Commented-out tap approach kept for reference ---
612+
* // const isLandscape = screenWidth > screenHeight
613+
* // const pct = isLandscape ? 0.30 : 0.15
614+
* // const tapX = Math.round(screenWidth * pct)
615+
* // const tapY = Math.round(screenHeight * pct)
616+
* // console.log(`[VIEWPORT-DEBUG] dismissStartSurface: tapping (${tapX}, ${tapY}) on ${screenWidth}x${screenHeight} (${isLandscape ? 'landscape' : 'portrait'})`)
617+
* // await executeNativeClick({ browserInstance, isIOS: false, x: tapX, y: tapY })
607618
*/
608619
async function dismissAndroidStartSurface({
609620
browserInstance,
610-
screenWidth,
611-
screenHeight,
612621
}: {
613622
browserInstance: WebdriverIO.Browser
614-
screenWidth: number
615-
screenHeight: number
616623
}): Promise<void> {
617624
try {
618-
const thumbX = Math.round(screenWidth * 0.15)
619-
const thumbY = Math.round(screenHeight * 0.15)
620-
log.info(`Tapping tab thumbnail area (${thumbX}, ${thumbY}) to dismiss Start Surface`)
621-
await executeNativeClick({ browserInstance, isIOS: false, x: thumbX, y: thumbY })
625+
await browserInstance.execute('mobile: pressKey', { keycode: 4 })
622626
await waitFor(1500)
623627
} catch (error) {
624-
log.warn('Failed to dismiss Chrome Start Surface via native tap', error)
628+
log.warn('Failed to dismiss Chrome Start Surface via Back button', error)
625629
}
626630
}
627631

packages/image-comparison-core/src/methods/screenshots.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export async function getMobileFullPageNativeWebScreenshotsData(browserInstance:
4141
const effectiveViewportHeight = hasNoBottomBar && hasHomeBar
4242
? viewportHeight - Math.round(homeBar.height / (isAndroid ? devicePixelRatio : 1))
4343
: viewportHeight
44-
const viewportWidth= Math.round(viewport.width / (isAndroid ? devicePixelRatio : 1))
44+
const viewportWidth = Math.round(viewport.width / (isAndroid ? devicePixelRatio : 1))
4545
const viewportX = Math.round(viewport.x / (isAndroid ? devicePixelRatio : 1))
4646
const viewportY = Math.round(viewport.y / (isAndroid ? devicePixelRatio : 1))
4747
// Start with an empty array, during the scroll it will be filled because a page could also have a lazy loading
126 KB
Loading
138 KB
Loading
133 KB
Loading
91.4 KB
Loading

tests/specs/mobile.web.spec.ts

Lines changed: 5 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -207,74 +207,19 @@ interface SkipRule {
207207
const skipRules: SkipRule[] = [
208208
// Android devices
209209
{
210-
// @TODO: remove when fixed
211-
titleIncludes: 'compare a screen with ignore elements',
212-
deviceName: 'Pixel 9 Pro',
213-
platformName: 'Android',
214-
platformVersions: ['15'],
215-
orientations: ['portrait'],
216-
reason: '1px difference in the ignore elements screenshot',
217-
},
218-
{
219-
// @TODO: remove when fixed
220-
titleIncludes: 'compare a screen with ignore elements',
221-
deviceName: 'Galaxy Tab S8',
222-
platformName: 'Android',
223-
platformVersions: ['13'],
224-
orientations: ['landscape', 'portrait'],
225-
reason: '1px difference in the ignore elements screenshot',
226-
},
227-
{
228-
// @TODO: remove when fixed
229-
titleIncludes: 'compare a screen with ignore elements',
230-
deviceName: 'Galaxy Tab S8',
231-
platformName: 'Android',
232-
platformVersions: ['14'],
233-
orientations: ['portrait'],
234-
reason: '1px difference in the ignore elements screenshot',
235-
},
236-
{
237-
// @TODO: remove when fixed
238-
titleIncludes: 'should compare a full page screenshot with ignore elements',
239-
deviceName: 'Galaxy Tab S8',
240-
platformName: 'Android',
241-
platformVersions: ['14'],
242-
orientations: ['portrait', 'landscape'],
243-
reason: 'it always starts with the tabbed view, so it will break at the start of the screenshot',
244-
},
245-
{
246-
// @TODO: remove when fixed
247-
titleIncludes: ['compare a screen with ignore elements', 'compare a screen successful'],
248-
deviceName: 'Galaxy Tab S8',
249-
platformName: 'Android',
250-
platformVersions: ['14'],
251-
orientations: ['landscape', 'portrait'],
252-
reason: 'Fully ignored in the screenshot so it will never find a difference',
253-
},
254-
{
255-
// @TODO: remove when fixed
256-
titleIncludes: 'compare a full page screenshot successful',
257-
deviceName: 'Galaxy Tab S8',
258-
platformName: 'Android',
259-
platformVersions: ['13', '14'],
260-
orientations: ['landscape', 'portrait'],
261-
reason: 'There are difference in the full page screenshot that might be related to things introduced in PR #1126',
262-
},
263-
{
264-
// @TODO: remove when fixed
265210
titleIncludes: 'compare a screen with ignore elements',
266211
deviceName: 'Pixel 4',
267212
platformName: 'Android',
268-
platformVersions: ['13'],
213+
platformVersions: ['11'],
269214
orientations: ['landscape', 'portrait'],
270-
reason: 'Fully ignored in the screenshot so it will never find a difference',
215+
reason: 'Elements not visible in the screenshot, no value in testing',
271216
},
272217
{
273-
titleIncludes: 'compare a screen successful',
218+
titleIncludes: 'compare a screen with ignore elements',
274219
deviceName: 'Pixel 4',
275220
platformName: 'Android',
276-
platformVersions: ['13'],
277-
orientations: ['portrait'],
221+
platformVersions: ['12', '13'],
222+
orientations: ['landscape'],
278223
reason: 'Elements not visible in the screenshot, no value in testing',
279224
},
280225
{
@@ -285,14 +230,6 @@ const skipRules: SkipRule[] = [
285230
orientations: ['landscape'],
286231
reason: 'Elements not visible in the screenshot, no value in testing',
287232
},
288-
{
289-
titleIncludes: 'compare a screen with ignore elements',
290-
deviceName: 'Pixel 4',
291-
platformName: 'Android',
292-
platformVersions: ['11', '12'],
293-
orientations: ['landscape', 'portrait'],
294-
reason: 'Elements not visible in the screenshot, no value in testing',
295-
},
296233
// iOS devices
297234
{
298235
titleIncludes: 'compare a screen with ignore elements',

0 commit comments

Comments
 (0)