UNPKG

@wdio/visual-service

Version:

Image comparison / visual regression testing for WebdriverIO

363 lines (362 loc) 16.5 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 'webdriver-image-comparison'; import { SevereServiceError } from 'webdriverio'; import { determineNativeContext, 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'; 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; _isNativeContext; constructor(options, _, config) { super(options); this.#config = config; this._isNativeContext = undefined; 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; this._isNativeContext = determineNativeContext(this.#browser); if (!this.#browser.isMultiremote) { log.info('Adding commands to global browser'); await this.#addCommandsToBrowser(this.#browser); } else { await this.#extendMultiremoteBrowser(capabilities); } if (browser.isMultiremote) { this.#setupMultiremoteContextListener(); } /** * 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); } afterCommand(commandName, _args, result, error) { // This is for the cases where in the E2E tests we switch to a WEBVIEW or back to NATIVE_APP context if (commandName === 'getContext' && error === undefined && typeof result === 'string') { // Multiremote logic is handled in the `before` method during an event listener this._isNativeContext = this.#browser?.isMultiremote ? this._isNativeContext : result.includes('NATIVE'); } } #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 each browser in the Multi Remote */ for (const browserName of browserNames) { log.info(`Adding commands to Multi Browser: ${browserName}`); const browserInstance = browser.getInstance(browserName); await this.#addCommandsToBrowser(browserInstance); } /** * 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 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(currentBrowser) { const instanceData = await getInstanceData(currentBrowser); const isNativeContext = getNativeContext(this.#browser, currentBrowser, this._isNativeContext); for (const [commandName, command] of Object.entries(elementCommands)) { this.#addElementCommand(currentBrowser, commandName, command, instanceData, isNativeContext); } for (const [commandName, command] of Object.entries(pageCommands)) { this.#addPageCommand(currentBrowser, commandName, command, instanceData, isNativeContext); } } /** * Add new element commands to the browser object */ #addElementCommand(browser, commandName, command, instanceData, isNativeContext) { log.info(`Adding element command "${commandName}" to browser object`); const self = this; browser.addCommand(commandName, function (element, tag, elementOptions = {}) { const elementOptionsKey = commandName === 'saveElement' ? 'saveElementOptions' : 'checkElementOptions'; return command({ methods: { executor: (fn, ...args) => { return this.execute.bind(browser)(fn, ...args); }, getElementRect: this.getElementRect.bind(browser), screenShot: this.takeScreenshot.bind(browser), takeElementScreenshot: this.takeElementScreenshot.bind(browser), }, instanceData, folders: getFolders(elementOptions, self.folders, self.#getBaselineFolder()), element, tag, [elementOptionsKey]: { wic: self.defaultOptions, method: elementOptions, }, isNativeContext, testContext: enrichTestContext({ commandName, currentTestContext: self.#testContext, instanceData, tag, }) }); }); } /** * Add new page commands to the browser object */ #addPageCommand(browser, commandName, command, instanceData, isNativeContext) { log.info(`Adding browser command "${commandName}" to browser object`); const self = this; const pageOptionsKey = PAGE_OPTIONS_MAP[commandName]; if (commandName === 'waitForStorybookComponentToBeLoaded') { browser.addCommand(commandName, (options) => waitForStorybookComponentToBeLoaded(options)); } else { browser.addCommand(commandName, function (tag, pageOptions = {}) { return command({ methods: { executor: (fn, ...args) => { return this.execute.bind(browser)(fn, ...args); }, getElementRect: this.getElementRect.bind(browser), screenShot: this.takeScreenshot.bind(browser), }, instanceData, folders: getFolders(pageOptions, self.folders, self.#getBaselineFolder()), tag, [pageOptionsKey]: { wic: self.defaultOptions, method: pageOptions, }, isNativeContext, testContext: enrichTestContext({ commandName, currentTestContext: self.#testContext, instanceData, tag, }) }); }); } } #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, pageOptions = {}) { const returnData = {}; const elementOptionsKey = commandName === 'saveElement' ? 'saveElementOptions' : 'checkElementOptions'; for (const browserName of browserNames) { const browserInstance = browser.getInstance(browserName); const isNativeContext = getNativeContext(self.#browser, browserInstance, self._isNativeContext); const instanceData = await getInstanceData(browserInstance); returnData[browserName] = await command({ methods: { executor: (fn, ...args) => { return this.execute.bind(browser)(fn, ...args); }, getElementRect: browserInstance.getElementRect.bind(browserInstance), screenShot: browserInstance.takeScreenshot.bind(browserInstance), takeElementScreenshot: browserInstance.takeElementScreenshot.bind(browserInstance), }, instanceData, folders: getFolders(pageOptions, self.folders, self.#getBaselineFolder()), tag, element, [elementOptionsKey]: { wic: self.defaultOptions, method: pageOptions, }, isNativeContext, testContext: enrichTestContext({ commandName, currentTestContext: self.#testContext, instanceData, tag, }) }); } 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); } else { 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 isNativeContext = getNativeContext(self.#browser, browserInstance, self._isNativeContext); const instanceData = await getInstanceData(browserInstance); returnData[browserName] = await command({ methods: { executor: (fn, ...args) => { return this.execute.bind(browser)(fn, ...args); }, getElementRect: browserInstance.getElementRect.bind(browserInstance), screenShot: browserInstance.takeScreenshot.bind(browserInstance), }, instanceData, folders: getFolders(pageOptions, self.folders, self.#getBaselineFolder()), tag, [pageOptionsKey]: { wic: self.defaultOptions, method: pageOptions, }, isNativeContext, testContext: enrichTestContext({ commandName, currentTestContext: self.#testContext, instanceData, tag, }) }); } return returnData; }); } } #setupMultiremoteContextListener() { const multiremoteBrowser = this.#browser; const browserInstances = multiremoteBrowser.instances; for (const instanceName of browserInstances) { const instance = multiremoteBrowser[instanceName]; instance.on('result', (result) => { if (result.command === 'getContext') { const value = result.result.value; const sessionId = instance.sessionId; if (typeof this._isNativeContext !== 'object' || this._isNativeContext === null) { this._isNativeContext = {}; } this._isNativeContext[sessionId] = value.includes('NATIVE'); } }); } } #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".`); } }