UNPKG

@wdio/visual-service

Version:

Image comparison / visual regression testing for WebdriverIO

431 lines (430 loc) 20 kB
import logger from '@wdio/logger'; import { expect } from '@wdio/globals'; import { dirname, normalize, resolve } from 'node:path'; import { BaseClass, checkElement, checkFullPageScreen, checkScreen, saveElement, saveFullPageScreen, saveScreen, saveTabbablePage, checkTabbablePage, FOLDERS, DEFAULT_TEST_CONTEXT, } from '@wdio/image-comparison-core'; import { SevereServiceError } from 'webdriverio'; import { enrichTestContext, getFolders, getInstanceData, getNativeContext, } from './utils.js'; import { toMatchScreenSnapshot, toMatchFullPageSnapshot, toMatchElementSnapshot, toMatchTabbablePageSnapshot } from './matcher.js'; import { waitForStorybookComponentToBeLoaded } from './storybook/utils.js'; import { PAGE_OPTIONS_MAP } from './constants.js'; import { ContextManager } from './contextManager.js'; import { wrapWithContext } from './wrapWithContext.js'; const log = logger('@wdio/visual-service'); const elementCommands = { saveElement, checkElement }; const pageCommands = { saveScreen, saveFullPageScreen, saveTabbablePage, checkScreen, checkFullPageScreen, checkTabbablePage, waitForStorybookComponentToBeLoaded, }; export default class WdioImageComparisonService extends BaseClass { #config; #currentFile; #currentFilePath; #testContext; #browser; _contextManager; _contextManagers = new Map(); constructor(options, _, config) { super(options); this.#config = config; this.#testContext = DEFAULT_TEST_CONTEXT; } /** * Set up the service if users want to use it in standalone mode */ async remoteSetup(browser) { await this.before(browser.capabilities, [], browser); } async before(capabilities, _specs, browser) { this.#browser = browser; if (!this.#browser.isMultiremote) { log.info('Adding commands to global browser'); await this.#addCommandsToBrowser(this.#browser); } else { await this.#extendMultiremoteBrowser(capabilities); } // There is an issue with the emulation mode for Chrome or Edge with WebdriverIO v9 // It doesn't set the correct emulation mode for the browser based on the capabilities // So we need to set the emulation mode manually // this is a temporary fix until the issue is fixed in WebdriverIO v9 and enough users have upgraded to the latest version await this.#setEmulation(this.#browser, capabilities); /** * add custom matcher for visual comparison when expect has been added. * this is not the case in standalone mode */ try { expect.extend({ toMatchScreenSnapshot, toMatchFullPageSnapshot, toMatchElementSnapshot, toMatchTabbablePageSnapshot, }); } catch (_err) { log.warn('Expect package not found. This means that the custom matchers `toMatchScreenSnapshot|toMatchFullPageSnapshot|toMatchElementSnapshot|toMatchTabbablePageSnapshot` are not added and can not be used. Please make sure to add it to your `package.json` if you want to use the Visual custom matchers.'); } } async beforeTest(test) { this.#currentFile = (test.file || test.filename); this.#currentFilePath = resolve(dirname(this.#currentFile), FOLDERS.DEFAULT.BASE); this.#testContext = this.#getTestContext(test); } // For Cucumber only beforeScenario(world) { this.#testContext = this.#getTestContext(world); } #getBaselineFolder() { const isDefaultBaselineFolder = normalize(FOLDERS.DEFAULT.BASE) === this.folders.baselineFolder; const baselineFolder = (isDefaultBaselineFolder && this.#currentFilePath ? this.#currentFilePath : this.folders.baselineFolder); /** * support `resolveSnapshotPath` WebdriverIO option * @ref https://webdriver.io/docs/configuration#resolvesnapshotpath * * We only use this option if the baselineFolder is the default one, otherwise the * service option for setting the baselineFolder should be used * * We also check `this.#config` because for standalone usage of the service, the config is not available */ if (this.#config && typeof this.#config.resolveSnapshotPath === 'function' && this.#currentFile && isDefaultBaselineFolder) { return this.#config.resolveSnapshotPath(this.#currentFile, '.png'); } return baselineFolder; } async #extendMultiremoteBrowser(capabilities) { const browser = this.#browser; const browserNames = Object.keys(capabilities); /** * Add all the commands to the global browser object that will execute * on each browser in the Multi Remote * Start with the page commands */ for (const [commandName, command] of Object.entries(pageCommands)) { this.#addMultiremoteCommand(browser, browserNames, commandName, command); } /** * Add all the commands to each browser in the Multi Remote */ for (const browserName of browserNames) { log.info(`Adding commands to Multi Browser: ${browserName}`); const browserInstance = browser.getInstance(browserName); const contextManager = new ContextManager(browserInstance); this._contextManagers?.set(browserName, contextManager); await this.#addCommandsToBrowser(browserInstance); } /** * Add all the element commands to the global browser object that will execute * on each browser in the Multi Remote */ for (const [commandName, command] of Object.entries(elementCommands)) { this.#addMultiremoteElementCommand(browser, browserNames, commandName, command); } } /** * Add commands to the "normal" browser object */ async #addCommandsToBrowser(browserInstance) { this._contextManager = new ContextManager(browserInstance); browserInstance.visualService = this; const instanceData = await getInstanceData({ browserInstance, initialDeviceRectangles: this._contextManager.getViewportContext(), isNativeContext: this._contextManager.isNativeContext, }); // Update the context manager with the current viewport this._contextManager.setViewPortContext(instanceData.deviceRectangles); for (const [commandName, command] of Object.entries(elementCommands)) { this.#addElementCommand(browserInstance, commandName, command, instanceData); } for (const [commandName, command] of Object.entries(pageCommands)) { this.#addPageCommand(browserInstance, commandName, command, instanceData); } } /** * Add new element commands to the browser object */ #addElementCommand(browserInstance, commandName, command, initialInstanceData) { log.info(`Adding element command "${commandName}" to browser object`); const elementOptionsKey = commandName === 'saveElement' ? 'saveElementOptions' : 'checkElementOptions'; const self = this; browserInstance.addCommand(commandName, function (element, tag, elementOptions = {}) { const wrapped = wrapWithContext({ browserInstance, command, contextManager: self.contextManager, getArgs: () => { const updatedInstanceData = { ...initialInstanceData, deviceRectangles: self.contextManager.getViewportContext(), }; const isCurrentContextNative = self.contextManager.isNativeContext; return [{ browserInstance, element, folders: getFolders(elementOptions, self.folders, self.#getBaselineFolder()), instanceData: updatedInstanceData, isNativeContext: isCurrentContextNative, tag, [elementOptionsKey]: { wic: self.defaultOptions, method: elementOptions, }, testContext: enrichTestContext({ commandName, currentTestContext: self.#testContext, instanceData: updatedInstanceData, tag, }), }]; } }); return wrapped.call(this); }); } /** * Add new page commands to the browser object */ #addPageCommand(browserInstance, commandName, command, initialInstanceData) { log.info(`Adding browser command "${commandName}" to browser object`); const self = this; const pageOptionsKey = PAGE_OPTIONS_MAP[commandName]; if (commandName === 'waitForStorybookComponentToBeLoaded') { browserInstance.addCommand(commandName, (options) => waitForStorybookComponentToBeLoaded(options)); return; } browserInstance.addCommand(commandName, function (tag, pageOptions = {}) { const wrapped = wrapWithContext({ browserInstance, command, contextManager: self.contextManager, getArgs: () => { const updatedInstanceData = { ...initialInstanceData, deviceRectangles: self.contextManager.getViewportContext() }; const isCurrentContextNative = self.contextManager.isNativeContext; return [{ browserInstance, folders: getFolders(pageOptions, self.folders, self.#getBaselineFolder()), instanceData: updatedInstanceData, isNativeContext: isCurrentContextNative, tag, [pageOptionsKey]: { wic: self.defaultOptions, method: pageOptions, }, testContext: enrichTestContext({ commandName, currentTestContext: self.#testContext, instanceData: updatedInstanceData, tag, }), }]; } }); return wrapped.call(this); }); } #addMultiremoteElementCommand(browser, browserNames, commandName, command) { log.info(`Adding element command "${commandName}" to Multi browser object`); const self = this; browser.addCommand(commandName, async function (element, tag, elementOptions = {}) { const returnData = {}; const elementOptionsKey = commandName === 'saveElement' ? 'saveElementOptions' : 'checkElementOptions'; for (const browserName of browserNames) { const browserInstance = browser.getInstance(browserName); const contextManager = self._contextManagers?.get(browserName); if (!contextManager) { throw new Error(`No ContextManager found for browser instance: ${browserName}`); } const isNativeContext = contextManager.isNativeContext; const initialInstanceData = await getInstanceData({ browserInstance: browserInstance, initialDeviceRectangles: contextManager.getViewportContext(), isNativeContext }); const wrapped = wrapWithContext({ browserInstance, command, contextManager, getArgs: () => { const updatedInstanceData = { ...initialInstanceData, deviceRectangles: contextManager.getViewportContext(), }; return [{ browserInstance, instanceData: updatedInstanceData, folders: getFolders(elementOptions, self.folders, self.#getBaselineFolder()), tag, element, [elementOptionsKey]: { wic: self.defaultOptions, method: elementOptions, }, isNativeContext, testContext: enrichTestContext({ commandName, currentTestContext: self.#testContext, instanceData: updatedInstanceData, tag, }), }]; } }); returnData[browserName] = await wrapped.call(browserInstance); } return returnData; }); } #addMultiremoteCommand(browser, browserNames, commandName, command) { log.info(`Adding browser command "${commandName}" to Multi browser object`); const self = this; if (commandName === 'waitForStorybookComponentToBeLoaded') { browser.addCommand(commandName, waitForStorybookComponentToBeLoaded); return; } browser.addCommand(commandName, async function (tag, pageOptions = {}) { const returnData = {}; const pageOptionsKey = PAGE_OPTIONS_MAP[commandName]; for (const browserName of browserNames) { const browserInstance = browser.getInstance(browserName); const contextManager = self._contextManagers?.get(browserName); if (!contextManager) { throw new Error(`No ContextManager found for browser instance: ${browserName}`); } const isNativeContext = getNativeContext({ capabilities: browserInstance.requestedCapabilities, isMobile: browserInstance.isMobile, }); const initialInstanceData = await getInstanceData({ browserInstance: browserInstance, initialDeviceRectangles: contextManager.getViewportContext(), isNativeContext }); const wrapped = wrapWithContext({ browserInstance, command, contextManager, getArgs: () => { const updatedInstanceData = { ...initialInstanceData, deviceRectangles: contextManager.getViewportContext() }; const isCurrentContextNative = contextManager.isNativeContext; return [{ browserInstance, folders: getFolders(pageOptions, self.folders, self.#getBaselineFolder()), instanceData: updatedInstanceData, isNativeContext: isCurrentContextNative, tag, [pageOptionsKey]: { wic: self.defaultOptions, method: pageOptions, }, testContext: enrichTestContext({ commandName, currentTestContext: self.#testContext, instanceData: updatedInstanceData, tag, }), }]; } }); returnData[browserName] = await wrapped.call(browserInstance); } return returnData; }); } #getTestContext(test) { const framework = this.#config?.framework; if (framework === 'mocha' && test) { return { ...this.#testContext, framework: 'mocha', parent: test.parent, title: test.title, }; } else if (framework === 'jasmine' && test) { /** * When using Jasmine as the framework the title/parent are not set as with mocha. * * `fullName` contains all describe(), and it() separated by a space. * `description` contains the current it() statement. * * e.g.: * With the following configuration * * describe('x', () => { * describe('y', () => { * it('z', () => {}); * }) * }) * * fullName will be "x y z" * description will be "z" * */ const { description: title, fullName } = test; return { ...this.#testContext, framework: 'jasmine', parent: fullName?.replace(` ${title}`, ''), title: title, }; } else if (framework === 'cucumber' && test) { return { ...this.#testContext, framework: 'cucumber', // @ts-ignore parent: test?.gherkinDocument?.feature?.name, title: test?.pickle?.name, }; } throw new SevereServiceError(`Framework ${framework} is not supported by the Visual Service and should be either "mocha", "jasmine" or "cucumber".`); } get contextManager() { if (!this._contextManager) { throw new Error('ContextManager has not been initialized'); } return this._contextManager; } async #setEmulationForBrowser(browserInstance, capabilities) { if (!browserInstance.isBidi) { return; } const chromeMobileEmulation = capabilities['goog:chromeOptions']?.mobileEmulation; const edgeMobileEmulation = capabilities['ms:edgeOptions']?.mobileEmulation; const mobileEmulation = chromeMobileEmulation || edgeMobileEmulation; if (!mobileEmulation) { return; } const { deviceName, deviceMetrics } = mobileEmulation; if (deviceName) { await browserInstance.emulate('device', deviceName); return; } const { pixelRatio: devicePixelRatio = 1, width = 320, height = 658 } = deviceMetrics || {}; await browserInstance.browsingContextSetViewport({ context: await browserInstance.getWindowHandle(), devicePixelRatio, viewport: { width, height } }); } async #setEmulation(browser, capabilities) { if (browser.isMultiremote) { const multiremoteBrowser = browser; for (const browserInstance of Object.values(multiremoteBrowser)) { await this.#setEmulationForBrowser(browserInstance, browserInstance.capabilities); } return; } await this.#setEmulationForBrowser(browser, capabilities); } }