Skip to content

Commit e34c82d

Browse files
committed
fix(image-comparison-core): use device platformVersion for Android hybrid status bar fallback
- Use the device's platform version to select the correct offsets entry for the hybrid-app status bar
1 parent de9579e commit e34c82d

8 files changed

Lines changed: 231 additions & 6 deletions

File tree

.changeset/implement-web-ignore-regions.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ Add `ignore` support to all web screenshot methods (`saveScreen`/`checkScreen`,
99

1010
### Changes
1111

12-
- **Ignore regions for full-page screenshots** — new `determineWebFullPageIgnoreRegions` function that calculates ignore-region rectangles for full-page screenshots, including a `fullPageCropTopPaddingCSS` correction for mobile scroll-and-stitch scenarios where the address-bar shadow padding shifts element positions
13-
- **Consolidated `ignoreRegionPadding`** — moved `ignoreRegionPadding` into `BaseWebScreenshotOptions` so it is inherited by all web methods instead of being duplicated per method
14-
- **Fix `isAndroidNativeWebScreenshot` type** — ensure `nativeWebScreenshot` is always a boolean (was accidentally an object for LambdaTest capabilities), preventing ignore-region DPR scaling failures
15-
- **Fix viewport rounding for mobile** — restore `Math.round()` in `injectWebviewOverlay` and remove `Math.min` clamping in `getMobileViewPortPosition` to prevent 1-pixel crop shifts during full-page stitching
16-
- **Fix `scrollElementIntoView` for scrolled pages** — account for `currentPosition` (existing scroll offset) when computing the target scroll position, so elements are scrolled into view correctly when the page is already scrolled
17-
- **Dismiss Chrome Start Surface on Android** — when Chrome's tab-overview UI blocks the webview overlay, automatically press the Android Back button (up to 4 retries) to restore the active tab before measuring the viewport
12+
- **Ignore regions for full-page screenshots:** new `determineWebFullPageIgnoreRegions` function that calculates ignore-region rectangles for full-page screenshots, including a `fullPageCropTopPaddingCSS` correction for mobile scroll-and-stitch scenarios where the address-bar shadow padding shifts element positions
13+
- **Consolidated `ignoreRegionPadding`:** moved `ignoreRegionPadding` into `BaseWebScreenshotOptions` so it is inherited by all web methods instead of being duplicated per method
14+
- **Fix `isAndroidNativeWebScreenshot` type:** ensure `nativeWebScreenshot` is always a boolean (was accidentally an object for LambdaTest capabilities), preventing ignore-region DPR scaling failures
15+
- **Fix viewport rounding for mobile:** restore `Math.round()` in `injectWebviewOverlay` and remove `Math.min` clamping in `getMobileViewPortPosition` to prevent 1-pixel crop shifts during full-page stitching
16+
- **Fix `scrollElementIntoView` for scrolled pages:** account for `currentPosition` (existing scroll offset) when computing the target scroll position, so elements are scrolled into view correctly when the page is already scrolled
17+
- **Dismiss Chrome Start Surface on Android:** when Chrome's tab-overview UI blocks the webview overlay, automatically press the Android Back button (up to 4 retries) to restore the active tab before measuring the viewport
18+
- **Add hybrid status bar blockout:** on hybrid apps the statusbar was not blocked out which could result in flaky tests regarding battery and reception
1819

1920
# Committers: 1
2021

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ export interface CommonCheckVariables {
169169

170170
/** Optional instance data (not all methods need these) */
171171
platformName?: string;
172+
platformVersion?: string;
172173
isIOS?: boolean;
173174

174175
/** WIC options */
@@ -235,6 +236,8 @@ export interface BaseExecuteCompareOptions {
235236
isAndroidNativeWebScreenshot: boolean;
236237
/** Optional: platform name */
237238
platformName?: string;
239+
/** Optional: platform version (e.g. "14.0" for Android API 14, "17.0" for iOS) */
240+
platformVersion?: string;
238241
/** Optional: whether this is iOS */
239242
isIOS?: boolean;
240243
/** Optional: whether this is hybrid app */

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,7 @@ export function extractCommonCheckVariables(
696696

697697
// Optional instance data
698698
...(instanceData.platformName && { platformName: instanceData.platformName }),
699+
...(instanceData.platformVersion && { platformVersion: instanceData.platformVersion }),
699700
...(instanceData.isIOS !== undefined && { isIOS: instanceData.isIOS }),
700701

701702
// WIC options
@@ -766,6 +767,7 @@ export function buildBaseExecuteCompareOptions(
766767
isAndroidNativeWebScreenshot: commonCheckVariables.isAndroidNativeWebScreenshot,
767768
// Add optional properties from commonCheckVariables if they exist
768769
...(commonCheckVariables.platformName && { platformName: commonCheckVariables.platformName }),
770+
...(commonCheckVariables.platformVersion && { platformVersion: commonCheckVariables.platformVersion }),
769771
...(commonCheckVariables.isIOS !== undefined && { isIOS: commonCheckVariables.isIOS }),
770772
...(commonCheckVariables.isHybridApp !== undefined && { isHybridApp: commonCheckVariables.isHybridApp }),
771773
}

packages/image-comparison-core/src/methods/images.interfaces.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ export interface ImageCompareOptions {
4747
isAndroid: boolean;
4848
/** If this is a native web screenshot */
4949
isAndroidNativeWebScreenshot: boolean;
50+
/** If this is a hybrid (native + webview) app; enables status bar fallback in webview when overlay reports zero */
51+
isHybridApp?: boolean;
52+
/** Platform version from the device (e.g. "14.0" for Android API 14); used for Android status bar fallback lookup */
53+
platformVersion?: string;
5054
}
5155

5256
export interface WicImageCompareOptions extends BaseImageCompareOptions, BaseMobileBlockOutOptions {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,8 @@ export async function executeImageCompare(
434434
blockOutStatusBar: imageCompareOptions.blockOutStatusBar,
435435
blockOutToolBar: imageCompareOptions.blockOutToolBar,
436436
},
437+
isHybridApp: options.isHybridApp,
438+
platformVersion: options.platformVersion,
437439
actualFilePath: isViewPortScreenshot ? undefined : actualFilePath,
438440
})
439441

packages/image-comparison-core/src/methods/rectangles.interfaces.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,10 @@ export interface PrepareIgnoreRectanglesOptions {
128128
blockOutStatusBar?: boolean;
129129
blockOutToolBar?: boolean;
130130
};
131+
/** Whether this is a hybrid (native + webview) app; enables status bar fallback when overlay reports zero */
132+
isHybridApp?: boolean;
133+
/** Platform version from the device (e.g. "14.0"); used for Android API level lookup in fallback */
134+
platformVersion?: string;
131135
actualFilePath?: string;
132136
}
133137

packages/image-comparison-core/src/methods/rectangles.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1550,5 +1550,146 @@ describe('rectangles', () => {
15501550
bottom: 70,
15511551
})
15521552
})
1553+
1554+
it('should add hybrid-app status bar fallback when statusBarAndAddressBar height is 0 (iOS)', async () => {
1555+
const deviceRectangles = createDeviceRectanglesWithData({
1556+
screenSize: { width: 375, height: 812 },
1557+
statusBarAndAddressBar: { x: 0, y: 0, width: 375, height: 0 },
1558+
bottomBar: { y: 0, x: 0, width: 0, height: 0 },
1559+
})
1560+
const options = createPrepareIgnoreRectanglesOptions({
1561+
deviceRectangles,
1562+
isMobile: true,
1563+
isNativeContext: false,
1564+
isAndroid: false,
1565+
isAndroidNativeWebScreenshot: true,
1566+
isViewPortScreenshot: true,
1567+
devicePixelRatio: 3,
1568+
imageCompareOptions: {
1569+
blockOutStatusBar: true,
1570+
},
1571+
})
1572+
1573+
const result = await prepareIgnoreRectangles(options)
1574+
1575+
expect(result.hasIgnoreRectangles).toBe(true)
1576+
const statusBarBox = result.ignoredBoxes.find(
1577+
(b: { top: number }) => b.top === 0
1578+
) as { left: number; top: number; right: number; bottom: number }
1579+
expect(statusBarBox).toBeDefined()
1580+
expect(statusBarBox.left).toBe(0)
1581+
expect(statusBarBox.top).toBe(0)
1582+
expect(statusBarBox.right).toBe(1125)
1583+
expect(statusBarBox.bottom).toBe(132)
1584+
})
1585+
1586+
it('should add hybrid-app status bar fallback when isHybridApp is true (iOS)', async () => {
1587+
const deviceRectangles = createDeviceRectanglesWithData({
1588+
screenSize: { width: 390, height: 844 },
1589+
statusBarAndAddressBar: { x: 0, y: 0, width: 390, height: 47 },
1590+
})
1591+
const options = createPrepareIgnoreRectanglesOptions({
1592+
deviceRectangles,
1593+
isMobile: true,
1594+
isNativeContext: false,
1595+
isAndroid: false,
1596+
isAndroidNativeWebScreenshot: true,
1597+
isViewPortScreenshot: true,
1598+
devicePixelRatio: 2,
1599+
isHybridApp: true,
1600+
imageCompareOptions: {
1601+
blockOutStatusBar: true,
1602+
},
1603+
})
1604+
1605+
const result = await prepareIgnoreRectangles(options)
1606+
1607+
expect(result.hasIgnoreRectangles).toBe(true)
1608+
const statusBarBoxes = result.ignoredBoxes.filter(
1609+
(b: { top: number }) => b.top === 0
1610+
) as Array<{ left: number; top: number; right: number; bottom: number }>
1611+
expect(statusBarBoxes.length).toBeGreaterThanOrEqual(1)
1612+
})
1613+
1614+
it('should add hybrid-app status bar fallback for Android when overlay reports zero', async () => {
1615+
const deviceRectangles = createDeviceRectanglesWithData({
1616+
screenSize: { width: 412, height: 869 },
1617+
statusBarAndAddressBar: { x: 0, y: 0, width: 412, height: 0 },
1618+
bottomBar: { y: 0, x: 0, width: 0, height: 0 },
1619+
})
1620+
const options = createPrepareIgnoreRectanglesOptions({
1621+
deviceRectangles,
1622+
isMobile: true,
1623+
isNativeContext: false,
1624+
isAndroid: true,
1625+
isAndroidNativeWebScreenshot: true,
1626+
isViewPortScreenshot: true,
1627+
devicePixelRatio: 2,
1628+
imageCompareOptions: {
1629+
blockOutStatusBar: true,
1630+
},
1631+
})
1632+
1633+
const result = await prepareIgnoreRectangles(options)
1634+
1635+
expect(result.hasIgnoreRectangles).toBe(true)
1636+
const statusBarBox = result.ignoredBoxes.find(
1637+
(b: { top: number }) => b.top === 0
1638+
) as { left: number; top: number; right: number; bottom: number }
1639+
expect(statusBarBox).toBeDefined()
1640+
expect(statusBarBox.bottom).toBe(24)
1641+
})
1642+
1643+
it('should use device platformVersion for Android hybrid status bar fallback when in ANDROID_OFFSETS list', async () => {
1644+
const deviceRectangles = createDeviceRectanglesWithData({
1645+
screenSize: { width: 412, height: 869 },
1646+
statusBarAndAddressBar: { x: 0, y: 0, width: 412, height: 0 },
1647+
})
1648+
const options = createPrepareIgnoreRectanglesOptions({
1649+
deviceRectangles,
1650+
isMobile: true,
1651+
isNativeContext: false,
1652+
isAndroid: true,
1653+
isViewPortScreenshot: true,
1654+
devicePixelRatio: 2,
1655+
imageCompareOptions: { blockOutStatusBar: true },
1656+
platformVersion: '12',
1657+
})
1658+
1659+
const result = await prepareIgnoreRectangles(options)
1660+
1661+
expect(result.hasIgnoreRectangles).toBe(true)
1662+
const statusBarBox = result.ignoredBoxes.find(
1663+
(b: { top: number }) => b.top === 0
1664+
) as { left: number; top: number; right: number; bottom: number }
1665+
expect(statusBarBox).toBeDefined()
1666+
expect(statusBarBox.bottom).toBe(24)
1667+
})
1668+
1669+
it('should fall back to latest API level for Android when platformVersion not in ANDROID_OFFSETS', async () => {
1670+
const deviceRectangles = createDeviceRectanglesWithData({
1671+
screenSize: { width: 412, height: 869 },
1672+
statusBarAndAddressBar: { x: 0, y: 0, width: 412, height: 0 },
1673+
})
1674+
const options = createPrepareIgnoreRectanglesOptions({
1675+
deviceRectangles,
1676+
isMobile: true,
1677+
isNativeContext: false,
1678+
isAndroid: true,
1679+
isViewPortScreenshot: true,
1680+
devicePixelRatio: 2,
1681+
imageCompareOptions: { blockOutStatusBar: true },
1682+
platformVersion: '99',
1683+
})
1684+
1685+
const result = await prepareIgnoreRectangles(options)
1686+
1687+
expect(result.hasIgnoreRectangles).toBe(true)
1688+
const statusBarBox = result.ignoredBoxes.find(
1689+
(b: { top: number }) => b.top === 0
1690+
) as { left: number; top: number; right: number; bottom: number }
1691+
expect(statusBarBox).toBeDefined()
1692+
expect(statusBarBox.bottom).toBe(24)
1693+
})
15531694
})
15541695
})

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Jimp } from 'jimp'
2+
import { ANDROID_OFFSETS, IOS_OFFSETS } from '../helpers/constants.js'
23
import { calculateDprData, getBase64ScreenshotSize, isObject } from '../helpers/utils.js'
34
import { getElementPositionAndroid, getElementPositionDesktop, getElementWebviewPosition } from './elementPosition.js'
45
import type {
@@ -561,6 +562,59 @@ export async function determineDeviceBlockOuts({ isAndroid, screenCompareOptions
561562
return rectangles
562563
}
563564

565+
/**
566+
* Return a status bar rectangle for hybrid-app fallback when the overlay reports zero height.
567+
* Uses IOS_OFFSETS / ANDROID_OFFSETS so the system status bar is blocked out in webview context.
568+
* Android: uses device platformVersion (e.g. "14.0") as API level when present and in list; otherwise latest.
569+
* iOS: keyed by screen size only (no OS version in data). When device not in list, uses latest entry.
570+
*/
571+
function getHybridAppStatusBarFallback(
572+
deviceRectangles: DeviceRectangles,
573+
isAndroid: boolean,
574+
platformVersion?: string,
575+
): RectanglesOutput | null {
576+
const { width: screenWidth, height: screenHeight } = deviceRectangles.screenSize
577+
if (screenWidth === 0 || screenHeight === 0) {
578+
return null
579+
}
580+
581+
if (isAndroid) {
582+
const apiLevels = Object.keys(ANDROID_OFFSETS).map(Number)
583+
const latestApiLevel = apiLevels.length > 0 ? Math.max(...apiLevels) : 14
584+
const deviceApiLevel = platformVersion !== undefined ? parseInt(platformVersion, 10) : NaN
585+
const useApiLevel =
586+
Number.isInteger(deviceApiLevel) && apiLevels.includes(deviceApiLevel)
587+
? deviceApiLevel
588+
: latestApiLevel
589+
const statusBarHeight = ANDROID_OFFSETS[useApiLevel as keyof typeof ANDROID_OFFSETS]?.STATUS_BAR ?? 24
590+
return {
591+
x: 0,
592+
y: 0,
593+
width: screenWidth,
594+
height: statusBarHeight,
595+
}
596+
}
597+
598+
const isIphone = screenWidth < 1024 && screenHeight < 1024
599+
const deviceType = isIphone ? 'IPHONE' : 'IPAD'
600+
const portraitHeight = screenWidth > screenHeight ? screenWidth : screenHeight
601+
const keys = Object.keys(IOS_OFFSETS[deviceType]).map(Number)
602+
const exactMatch = keys.includes(portraitHeight)
603+
const offsetPortraitHeight = exactMatch
604+
? portraitHeight
605+
: (keys.length > 0 ? Math.max(...keys) : (isIphone ? 667 : 1024))
606+
const orientation = screenWidth > screenHeight ? 'LANDSCAPE' : 'PORTRAIT'
607+
const currentOffsets = IOS_OFFSETS[deviceType][offsetPortraitHeight]?.[orientation]
608+
const statusBarHeight = currentOffsets?.STATUS_BAR ?? (isIphone ? 44 : 20)
609+
610+
return {
611+
x: 0,
612+
y: 0,
613+
width: screenWidth,
614+
height: statusBarHeight,
615+
}
616+
}
617+
564618
/**
565619
* Prepare all ignore rectangles for image comparison
566620
*/
@@ -576,6 +630,8 @@ export async function prepareIgnoreRectangles(options: PrepareIgnoreRectanglesOp
576630
isAndroidNativeWebScreenshot,
577631
isViewPortScreenshot,
578632
imageCompareOptions,
633+
isHybridApp,
634+
platformVersion,
579635
actualFilePath
580636
} = options
581637

@@ -598,6 +654,18 @@ export async function prepareIgnoreRectangles(options: PrepareIgnoreRectanglesOp
598654
...(determineStatusAddressToolBarRectangles({ deviceRectangles, options: statusAddressToolBarOptions })) || []
599655
)
600656

657+
// Hybrid-app fallback: in webview the overlay often reports statusBarAndAddressBar height 0.
658+
// Use native offsets (IOS_OFFSETS / ANDROID_OFFSETS) so the system status bar is still blocked out.
659+
const needStatusBarFallback =
660+
imageCompareOptions.blockOutStatusBar !== false &&
661+
(isHybridApp === true || deviceRectangles.statusBarAndAddressBar.height === 0)
662+
if (needStatusBarFallback) {
663+
const fallback = getHybridAppStatusBarFallback(deviceRectangles, isAndroid, platformVersion)
664+
if (fallback && fallback.height > 0) {
665+
webStatusAddressToolBarOptions.push(fallback)
666+
}
667+
}
668+
601669
if (webStatusAddressToolBarOptions.length > 0) {
602670
// There's an issue with the resemble lib when all the rectangles are 0,0,0,0, it will see this as a full
603671
// blockout of the image and the comparison will succeed with 0 % difference.

0 commit comments

Comments
 (0)