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
12 changes: 12 additions & 0 deletions .changeset/activate-hidden-browsing-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@wdio/visual-service": patch
---

fix: bring a page in a background tab to the front before a check or save command (WebDriver BiDi, Chromium)

In a WebDriver BiDi session with a Chromium-based browser (Chrome, Chromium, Edge) on Linux, a page in a background tab, for example after `browser.newWindow()` (which in WebdriverIO v10 does not switch to the new tab), caused 2 problems:

- The page reports another window size, which changes the image file name. With `autoSaveBaseline`, the check then saved a new baseline and returned 0 without comparing.
- When the page had no new frame for about 300 ms, the screenshot never returned, until the `bidiResponseTimeout` (180 seconds by default). This is a Chromium bug, which also happens with the Chrome DevTools Protocol only: https://issues.chromium.org/issues/571157133

Before each check or save command, the visual service now brings that page to the front with `browsingContext.activate`, as ChromeDriver already does for a WebDriver Classic screenshot. It does this only in BiDi sessions of Chromium-based desktop browsers, and only when the page is hidden. Firefox, Safari, mobile and WebDriver Classic sessions do not change. A failed activation only logs a warning.
55 changes: 55 additions & 0 deletions packages/visual-service/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logger from '@wdio/logger'
import type { Capabilities } from '@wdio/types'
import { getMobileScreenSize, getMobileViewPortPosition, IOS_OFFSETS, NOT_KNOWN } from '@wdio/image-comparison-core'
import type { Folders, InstanceData, TestContext } from '@wdio/image-comparison-core'
Expand All @@ -10,6 +11,8 @@ import type {
WdioIcsOptions,
} from './types.js'

const log = logger('@wdio/visual-service')

/**
* Get the folders data
*
Expand Down Expand Up @@ -414,3 +417,55 @@ export function enrichTestContext(
}
}

/**
* Chromium-based browsers (Chrome, Chromium, Edge)
*/
function isChromium(browserInstance: WebdriverIO.Browser): boolean {
const browserName = (browserInstance.capabilities.browserName ?? '').toLowerCase()
return ['chrome', 'chromium', 'edge'].some((name) => browserName.includes(name))
}

/**
* Bring a page that is in a background tab to the front before a check or save command.
*
* Why: in a WebDriver BiDi session with a Chromium-based browser on Linux, a page in a background tab (for example
* after `browser.newWindow()`, which in WebdriverIO v10 does not switch to the new tab) has 2 problems:
* - it reports `window.outerHeight` = `window.innerHeight`, and the desktop file name uses the outer size, so the
* check uses another file name, saves a new baseline (`autoSaveBaseline`) and passes without a comparison;
* - when the page had no new frame for about 300 ms, `browsingContext.captureScreenshot` (CDP `Page.captureScreenshot`)
* never returns, until the `bidiResponseTimeout` (180 seconds by default). This is a Chromium bug: it also
* happens with CDP only, see https://issues.chromium.org/issues/571157133
* Chrome on macOS and Firefox (BiDi) do not have these problems.
*
* A WebDriver Classic screenshot does not have them either, because ChromeDriver brings the tab to the front first
* (`ActivateWebView` in `ExecuteScreenshot`). The BiDi `browsingContext.captureScreenshot` does not, so do the same
* here with `browsingContext.activate` (CDP `Page.bringToFront`). Side effect: the tab of the page comes to the front.
*
* Scope: BiDi sessions of Chromium-based desktop browsers, and only when the page is hidden. It is not limited to
* Linux: the browser can run on another OS than the test runner (grid, cloud), the hang is also reported on macOS
* with other Chrome versions, and Windows was not tested. It never fails the command.
*
* Reports:
* - Chromium bug: https://issues.chromium.org/issues/571157133
* - WebDriver BiDi spec (what `browsingContext.captureScreenshot` must do for a hidden page):
* https://github.com/w3c/webdriver-bidi/issues/1176
* When Chromium fixes the bug, limit this to the older Chrome versions or remove it.
*/
export async function activateHiddenBrowsingContext(browserInstance: WebdriverIO.Browser, isNativeContext: boolean): Promise<void> {
if (!browserInstance.isBidi || browserInstance.isMobile || isNativeContext || !isChromium(browserInstance)) {
return
}

try {
const isHidden = await browserInstance.execute(() => document.visibilityState === 'hidden')
if (!isHidden) {
return
}

const context = await browserInstance.getWindowHandle()
log.info(`The page is in a background tab, activating the browsing context "${context}" before the screenshot`)
await browserInstance.browsingContextActivate({ context })
} catch (error) {
log.warn(`Could not bring the page in a background tab to the front, the screenshot can fail: ${error}`)
}
}
5 changes: 4 additions & 1 deletion packages/visual-service/src/wrapWithContext.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { InstanceData } from '@wdio/image-comparison-core'
import type { WrapWithContextOptions } from './types.js'
import { getInstanceData } from './utils.js'
import { activateHiddenBrowsingContext, getInstanceData } from './utils.js'

/**
* Wrap the command with the context manager
Expand All @@ -11,6 +11,9 @@ export function wrapWithContext<T extends (...args: any[]) => any>(opts: WrapWit
const { browserInstance, command, contextManager, getArgs } = opts

return async function (this: WebdriverIO.Browser): Promise<ReturnType<T>> {
// Before the command reads the page size and takes the screenshot
await activateHiddenBrowsingContext(browserInstance, contextManager.isNativeContext)

if (contextManager.needsUpdate) {
const instanceData: InstanceData = await getInstanceData({
browserInstance,
Expand Down
76 changes: 76 additions & 0 deletions packages/visual-service/tests/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'
import { mock } from 'vitest-mock-extended'
import {
activateHiddenBrowsingContext,
getBrowserObject,
getDevicePixelRatio, getFolders,
getInstanceData,
Expand Down Expand Up @@ -758,6 +760,80 @@ describe('utils', () => {
})
})

describe('activateHiddenBrowsingContext', () => {
const createBrowser = ({ isBidi = true, isMobile = false, isHidden = true, browserName = 'chrome' } = {}) => mock<WebdriverIO.Browser>({
isBidi,
isMobile,
capabilities: { browserName },
execute: vi.fn().mockResolvedValue(isHidden),
getWindowHandle: vi.fn().mockResolvedValue('context-a'),
browsingContextActivate: vi.fn().mockResolvedValue({}),
})

it('should activate the browsing context when the page is in a background tab', async () => {
const browser = createBrowser()

await activateHiddenBrowsingContext(browser, false)

expect(browser.browsingContextActivate).toHaveBeenCalledWith({ context: 'context-a' })
})

it('should not activate the browsing context when the page is visible', async () => {
const browser = createBrowser({ isHidden: false })

await activateHiddenBrowsingContext(browser, false)

expect(browser.browsingContextActivate).not.toHaveBeenCalled()
})

it('should do nothing in a WebDriver Classic session, on mobile or in a native context', async () => {
const browsers = [createBrowser({ isBidi: false }), createBrowser({ isMobile: true }), createBrowser()]

await activateHiddenBrowsingContext(browsers[0], false)
await activateHiddenBrowsingContext(browsers[1], false)
await activateHiddenBrowsingContext(browsers[2], true)

for (const browser of browsers) {
expect(browser.execute).not.toHaveBeenCalled()
expect(browser.browsingContextActivate).not.toHaveBeenCalled()
}
})

it('should activate the browsing context in Edge and Chromium too', async () => {
for (const browserName of ['MicrosoftEdge', 'msedge', 'chromium', 'chrome-headless-shell']) {
const browser = createBrowser({ browserName })

await activateHiddenBrowsingContext(browser, false)

expect(browser.browsingContextActivate).toHaveBeenCalledWith({ context: 'context-a' })
}
})

it('should do nothing in a browser that is not based on Chromium', async () => {
const browser = createBrowser({ browserName: 'firefox' })

await activateHiddenBrowsingContext(browser, false)

expect(browser.execute).not.toHaveBeenCalled()
expect(browser.browsingContextActivate).not.toHaveBeenCalled()
})

it('should not fail when the browser cannot activate the browsing context', async () => {
const browser = createBrowser()
vi.mocked(browser.browsingContextActivate).mockRejectedValue(new Error('unknown command'))

await expect(activateHiddenBrowsingContext(browser, false)).resolves.toBeUndefined()
})

it('should not fail when the visibility of the page cannot be read', async () => {
const browser = createBrowser()
vi.mocked(browser.execute).mockRejectedValue(new Error('no such frame'))

await expect(activateHiddenBrowsingContext(browser, false)).resolves.toBeUndefined()
expect(browser.browsingContextActivate).not.toHaveBeenCalled()
})
})

describe('getNativeContext', () => {
it('should return false if capabilities is not an object', () => {
expect(getNativeContext({ capabilities: null as any, isMobile: true })).toBe(false)
Expand Down
26 changes: 24 additions & 2 deletions packages/visual-service/tests/wrapWithContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe('wrapWithContext', () => {

it('should call command directly when no update is needed', async () => {
const wrapper = wrapWithContext({
browser: mockBrowser,
browserInstance: mockBrowser,
command: mockCommand,
contextManager: mockContextManager,
getArgs: () => ['arg1']
Expand All @@ -45,7 +45,7 @@ describe('wrapWithContext', () => {
} as any)

const wrapper = wrapWithContext({
browser: mockBrowser,
browserInstance: mockBrowser,
command: mockCommand,
contextManager: mockContextManager,
getArgs: () => ['arg2']
Expand All @@ -59,4 +59,26 @@ describe('wrapWithContext', () => {

getInstanceDataMock.mockRestore()
})

it('should activate a browsing context in a background tab before the command', async () => {
const calls: string[] = []
const activateMock = vi.spyOn(utilsModule, 'activateHiddenBrowsingContext').mockImplementation(async () => {
calls.push('activate')
})
mockCommand.mockImplementation(() => calls.push('command'))

const wrapper = wrapWithContext({
browserInstance: mockBrowser,
command: mockCommand,
contextManager: mockContextManager,
getArgs: () => ['arg3']
})

await wrapper.call(mockBrowser)

expect(activateMock).toHaveBeenCalledWith(mockBrowser, false)
expect(calls).toEqual(['activate', 'command'])

activateMock.mockRestore()
})
})
14 changes: 11 additions & 3 deletions tests/specs/v10.browsingContexts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,23 @@ const fixture = (name: string) => pathToFileURL(join(process.cwd(), 'tests/fixtu
describe('@wdio/visual-service WebdriverIO v10 browsing contexts', () => {
it('captures the page that the browser commands act on after newWindow()', async () => {
await browser.url(fixture('page-a.html'))
// creates the baseline of page A
await browser.checkScreen('v10-page-a')
// creates the baseline of page A; returnAllCompareData also returns the file name
const baseline = await browser.checkScreen('v10-page-a', { returnAllCompareData: true })
expect(baseline).toHaveProperty('fileName')

// v10: the new tab is returned and the browser stays on page A
const pageB = await browser.newWindow(fixture('page-b.html'), { type: 'tab' })

try {
expect(await browser.getUrl()).toContain('page-a.html')
expect(await browser.checkScreen('v10-page-a')).toBe(0)
// Chrome brings the new tab to the front, so page A is now in a background tab. In a BiDi session the
// service activates the browsing context of the browser (page A) before the check, as a WebDriver Classic
// screenshot does. Page B is blue, so a screenshot of page B would not match the red page A
const result = await browser.checkScreen('v10-page-a', { returnAllCompareData: true })
expect(await browser.execute(() => document.visibilityState)).toBe('visible')
// the same file name, folders and mismatch (0) as the baseline: a different file name would save a new
// baseline and pass without comparing
expect(result).toEqual(baseline)
} finally {
// Close the new tab. On Linux headless Chrome, page A is then in a background tab, and a screenshot
// of it hangs (`browsingContext.captureScreenshot` timeout) when the page changes in a later test
Expand Down
Loading