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
49 changes: 49 additions & 0 deletions .changeset/dull-snakes-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
"webdriver-image-comparison": patch
"@wdio/visual-service": patch
---

Optimize Mobile and Emulated device support

## 🐛 Bugfixes

### #969 Fix layer injection on mobile devices

On some devices the layer injection, to determine the exact position of the webview, was failing. It exceeded the appium timeout and returned an error like

```logs
[1] [0-0] 2025-05-23T08:04:11.788Z INFO webdriver: COMMAND getUrl()
[1] [0-0] 2025-05-23T08:04:11.789Z INFO webdriver: [GET] https://hub-cloud.browserstack.com/wd/hub/session/xxxxx/url
[1] [0-0] 2025-05-23T08:04:12.036Z INFO webdriver: RESULT about:blank
[1] [0-0] 2025-05-23T08:04:12.038Z INFO webdriver: COMMAND navigateTo("data:text/html;base64,CiAgICAgICAgPG .... LONG LIST OF CHARACTERS=")
[1] [0-0] 2025-05-23T08:04:12.038Z INFO webdriver: [POST] https://hub-cloud.browserstack.com/wd/hub/session/xxxx/url
[1] [0-0] 2025-05-23T08:04:12.038Z INFO webdriver: DATA {
[1] [0-0] url: 'data:text/html;base64,CiAgICAgICAgPGh0bWw.... LONG LIST OF CHARACTERS='
[1] [0-0] }
[1] [0-0] 2025-05-23T08:05:42.132Z ERROR @wdio/utils:shim: Error: WebDriverError: The operation was aborted due to timeout when running "url" with method "POST" and args "{"url":"data:text/html;base64,CiAgICAgICAgPGh0b.... LONG LIST OF CHARACTERS="}"
[1] [0-0] at FetchRequest._libRequest (file:///xxxxxxx/node_modules/webdriver/build/node.js:1836:13)
[1] [0-0] 2025-05-23T08:05:42.132Z DEBUG @wdio/utils:shim: Finished to run "before" hook in 91147ms
[1] [0-0] at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
[1] [0-0] at async FetchRequest._request (file:///C:/xxxxxx/node_modules/webdriver/build/node.js:1846:20)
[1] [0-0] at Browser.wrapCommandFn (c:/Projects/xxxxxx/node_modules/@wdio/utils/build/index.js:907:23)
[1] [0-0] at Browser.url (c:/Projects/xxxxxxx/node_modules/webdriverio/build/node.js:5682:3)
[1] [0-0] at Browser.wrapCommandFn (c:/Projects/xxxxxx/node_modules/@wdio/utils/build/index.js:907:23)
[1] [0-0] at async loadBase64Html (file:///C:/Projects/xxxxxx/node_modules/webdriver-image-comparison/dist/helpers/utils.js:377:5)
[1] [0-0] at async getMobileViewPortPosition (file:///C:/Projects/xxxxxx/node_modules/webdriver-image-comparison/dist/helpers/utils.js:417:9)
[1] [0-0] at async getMobileInstanceData (file:///C:/Projects/xxxxxx/node_modules/@wdio/visual-service/dist/utils.js:58:28)
[1] [0-0] at async getInstanceData (file:///C:/Projects/xxxxxxx/node_modules/@wdio/visual-service/dist/utils.js:189:77)
[1] [0-0] 2025-05-23T08:05:42.144Z INFO @wdio/browserstack-service: Update job with sessionId xxxxx
```

This was caused by the `await url(`data:text/html;base64,${base64Html}`)` that injected the layer. Some browsers couldn't handle the `data:text/html;base64`.

We now fixed that with a different injection. It was tested on Android/iOS and on Sims/Emus/Real Devices and it worked

### Improve determining if a device is emulated

In a previous release we added a function to determine if a device was emulated. This resulted in incorrect screen sizes that were used for the files names for devices. This caused or failing baselines, or new files to be created because the screen sizes were not available
We now improved the check and the correct screen sizes are added again to the file names and made sure that the previous generated base line could be used again.

## Committers: 1

- Wim Selles ([@wswebcreation](https://github.com/wswebcreation))
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"test.ocr.saucelabs.desktop": "SAUCE=true wdio tests/configs/wdio.ocr.saucelabs.conf.ts",
"test.ocr.lambdatest.desktop": "wdio tests/configs/wdio.ocr.lambdatest.conf.ts",
"test.unit.coverage": "vitest --coverage",
"test.bs.real.device": "wdio tests/configs/browserstack.real.device.conf.ts",
"test.saucelabs.app": "wdio ./tests/configs/wdio.saucelabs.app.conf.ts",
"test.saucelabs.emu.app": "cross-env SAUCE_ENV=emu wdio ./tests/configs/wdio.saucelabs.app.conf.ts",
"test.saucelabs.sims.app": "cross-env SAUCE_ENV=sims wdio ./tests/configs/wdio.saucelabs.app.conf.ts",
Expand Down Expand Up @@ -71,6 +72,7 @@
"@vitest/coverage-v8": "^3.1.1",
"@vitest/ui": "^3.1.1",
"@wdio/appium-service": "^9.13.0",
"@wdio/browserstack-service": "^9.14.0",
"@wdio/cli": "^9.14.0",
"@wdio/local-runner": "^9.14.0",
"@wdio/sauce-service": "^9.14.0",
Expand All @@ -95,4 +97,4 @@
"wdio-lambdatest-service": "^4.0.0"
},
"packageManager": "pnpm@9.15.9+sha256.cf86a7ad764406395d4286a6d09d730711720acc6d93e9dce9ac7ac4dc4a28a7"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,35 @@ exports[`getScreenDimensions > should get the needed screen dimensions 1`] = `
}
`;

exports[`getScreenDimensions > should get the needed screen dimensions for a real device 1`] = `
{
"dimensions": {
"body": {
"offsetHeight": 0,
"scrollHeight": 0,
},
"html": {
"clientHeight": 0,
"clientWidth": 0,
"offsetHeight": 0,
"scrollHeight": 0,
"scrollWidth": 0,
},
"window": {
"devicePixelRatio": 1,
"innerHeight": 768,
"innerWidth": 1024,
"isEmulated": false,
"isLandscape": true,
"outerHeight": 768,
"outerWidth": 1024,
"screenHeight": 0,
"screenWidth": 0,
},
},
}
`;

exports[`getScreenDimensions > should get the needed screen dimensions if the outerHeight and outerWidth are set to 0 1`] = `
{
"dimensions": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,24 @@ import { CONFIGURABLE } from '../mocks/mocks.js'
import getScreenDimensions from './getScreenDimensions.js'

describe('getScreenDimensions', () => {
it('should get the needed screen dimensions for a real device', () => {
Object.defineProperty(window, 'matchMedia', {
value: vi.fn().mockImplementation(() => ({
matches: true,
})),
...CONFIGURABLE,
})
expect(getScreenDimensions(true)).toMatchSnapshot()
})

it('should get the needed screen dimensions', () => {
Object.defineProperty(window, 'matchMedia', {
value: vi.fn().mockImplementation(() => ({
matches: true,
})),
...CONFIGURABLE,
})
expect(getScreenDimensions()).toMatchSnapshot()
expect(getScreenDimensions(false)).toMatchSnapshot()
})

it('should get the needed screen dimensions if the outerHeight and outerWidth are set to 0', () => {
Expand All @@ -27,7 +37,7 @@ describe('getScreenDimensions', () => {
...CONFIGURABLE,
})

expect(getScreenDimensions()).toMatchSnapshot()
expect(getScreenDimensions(false)).toMatchSnapshot()
})

it('should return zeroed dimensions if the document attributes are null', () => {
Expand All @@ -40,7 +50,7 @@ describe('getScreenDimensions', () => {
...CONFIGURABLE,
})

expect(getScreenDimensions()).toMatchSnapshot()
expect(getScreenDimensions(false)).toMatchSnapshot()
})

it('should detect mobile emulation and return emulated dimensions', () => {
Expand All @@ -67,7 +77,7 @@ describe('getScreenDimensions', () => {
...CONFIGURABLE,
})

const dimensions = getScreenDimensions()
const dimensions = getScreenDimensions(false)

Object.defineProperty(window, 'screen', {
value: originalScreen,
Expand Down Expand Up @@ -106,7 +116,7 @@ describe('getScreenDimensions', () => {
...CONFIGURABLE,
})

const dimensions = getScreenDimensions()
const dimensions = getScreenDimensions(false)

Object.defineProperty(window, 'screen', {
value: originalScreen,
Expand Down Expand Up @@ -145,7 +155,7 @@ describe('getScreenDimensions', () => {
...CONFIGURABLE,
})

const dimensions = getScreenDimensions()
const dimensions = getScreenDimensions(false)

Object.defineProperty(window, 'screen', {
value: originalScreen,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import type { ScreenDimensions } from './screenDimensions.interfaces.js'
/**
* Get all the screen dimensions
*/
export default function getScreenDimensions(): ScreenDimensions {
export default function getScreenDimensions(isMobile: boolean): ScreenDimensions {
// We need to determine if the screen is emulated, because that would return different values
const width = window.innerWidth
const height = window.innerHeight
const dpr = window.devicePixelRatio || 1
const minEdge = Math.min(width, height)
const maxEdge = Math.max(width, height)
const isLikelyEmulated =
!isMobile && // Only check for emulated on desktop
dpr >= 2 && // High-DPI signal
minEdge <= 800 && // Catch phones/tablets in portrait/landscape
maxEdge <= 1280 && // Conservative max for emulated tablet sizes
Expand Down
25 changes: 12 additions & 13 deletions packages/webdriver-image-comparison/src/helpers/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,27 +673,25 @@ describe('utils', () => {
})

describe('loadBase64Html', () => {
const mockUrl = vi.fn()
const mockExecutor = vi.fn()

afterEach(() => {
vi.clearAllMocks()
})

it('should call url with base64 html and skip executor for Android', async () => {
await loadBase64Html({ executor: mockExecutor, isIOS: false, url: mockUrl })
it('should call executor with blob URL creation for all platforms', async () => {
await loadBase64Html({ executor: mockExecutor, isIOS: false })

expect(mockUrl).toHaveBeenCalledTimes(1)
expect(mockUrl.mock.calls[0][0]).toMatch(/^data:text\/html;base64,/)
expect(mockExecutor).not.toHaveBeenCalled()
expect(mockExecutor).toHaveBeenCalledTimes(1)
expect(mockExecutor).toHaveBeenCalledWith(expect.any(Function), expect.any(String))
})

it('should call url with base64 html and call executor for iOS', async () => {
await loadBase64Html({ executor: mockExecutor, isIOS: true, url: mockUrl })
it('should call executor with blob URL creation and checkMetaTag for iOS', async () => {
await loadBase64Html({ executor: mockExecutor, isIOS: true })

expect(mockUrl).toHaveBeenCalledTimes(1)
expect(mockUrl.mock.calls[0][0]).toMatch(/^data:text\/html;base64,/)
expect(mockExecutor).toHaveBeenCalledWith(checkMetaTag)
expect(mockExecutor).toHaveBeenCalledTimes(2)
expect(mockExecutor).toHaveBeenNthCalledWith(1, expect.any(Function), expect.any(String))
expect(mockExecutor).toHaveBeenNthCalledWith(2, checkMetaTag)
})
})

Expand Down Expand Up @@ -733,7 +731,7 @@ describe('utils', () => {
logWarnMock.mockRestore()
})

it('should throw the error if its not a known Appium command error', async () => {
it('should throw the error if it\'s not a known Appium command error', async () => {
const executor = vi.fn().mockRejectedValueOnce(new Error('Some unexpected error'))

await expect(executeNativeClick({ executor, isIOS: false, ...coords }))
Expand Down Expand Up @@ -767,6 +765,7 @@ describe('utils', () => {

it('should return correct device rectangles for iOS WebView flow', async () => {
mockExecutor
.mockResolvedValueOnce(undefined) // executor for the blob (loadBase64Html)
.mockResolvedValueOnce(undefined) // checkMetaTag (loadBase64Html)
.mockResolvedValueOnce(undefined) // injectWebviewOverlay
.mockResolvedValueOnce(undefined) // nativeClick
Expand All @@ -778,7 +777,7 @@ describe('utils', () => {
})

expect(mockGetUrl).toHaveBeenCalled()
expect(mockUrl).toHaveBeenCalledTimes(2)
expect(mockUrl).toHaveBeenCalledTimes(1)
expect(mockExecutor).toHaveBeenCalledWith(injectWebviewOverlay, false)
expect(mockExecutor).toHaveBeenCalledWith(getMobileWebviewClickAndDimensions, '[data-test="ics-overlay"]')

Expand Down
12 changes: 7 additions & 5 deletions packages/webdriver-image-comparison/src/helpers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ export async function getMobileScreenSize({
/**
* Load a base64 HTML page in the browser
*/
export async function loadBase64Html({ executor, isIOS, url }: {executor:Executor, isIOS:boolean, url:any}): Promise<void> {
export async function loadBase64Html({ executor, isIOS }: {executor:Executor, isIOS:boolean}): Promise<void> {
const htmlContent = `
<html>
<head>
Expand All @@ -457,9 +457,11 @@ export async function loadBase64Html({ executor, isIOS, url }: {executor:Executo
</body>
</html>`

const base64Html = Buffer.from(htmlContent).toString('base64')

await url(`data:text/html;base64,${base64Html}`)
await executor((htmlContent) => {
const blob = new Blob([htmlContent], { type: 'text/html' })
const blobUrl = URL.createObjectURL(blob)
window.location.href = blobUrl
}, htmlContent)

if (isIOS) {
await executor(checkMetaTag)
Expand Down Expand Up @@ -519,7 +521,7 @@ export async function getMobileViewPortPosition({
if (!isNativeContext && (isIOS || (isAndroid && nativeWebScreenshot))) {
const currentUrl = await getUrl()
// 1. Load a base64 HTML page
await loadBase64Html({ executor, isIOS, url })
await loadBase64Html({ executor, isIOS })
// 2. Inject an overlay on top of the webview with an event listener that stores the click position in the webview
await executor(injectWebviewOverlay, isAndroid)
// 3. Click on the overlay in the center of the screen with a native click
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default async function getEnrichedInstanceData(
addShadowPadding: boolean,
): Promise<EnrichedInstanceData> {
// Get the current browser data
const browserData = await executor(getScreenDimensions)
const browserData = await executor(getScreenDimensions, instanceOptions.isMobile)
const { addressBarShadowPadding, toolBarShadowPadding, browserName, nativeWebScreenshot, platformName } = instanceOptions

// Determine some constants
Expand Down
Loading
Loading