@percy/selenium-webdriver
Version:
Selenium client library for visual testing with Percy
353 lines (316 loc) • 13.3 kB
JavaScript
// Collect client and environment information
const sdkPkg = require('./package.json');
let seleniumPkg;
try {
seleniumPkg = require('selenium-webdriver/package.json');
} catch {
/* istanbul ignore next */
seleniumPkg = { name: 'unknown', version: 'unknown' };
}
const CLIENT_INFO = `${sdkPkg.name}/${sdkPkg.version}`;
const ENV_INFO = `${seleniumPkg.name}/${seleniumPkg.version}`;
const utils = require('@percy/sdk-utils');
const { DriverMetadata } = require('./driverMetadata');
const log = utils.logger('selenium-webdriver');
const CS_MAX_SCREENSHOT_LIMIT = 25000;
const SCROLL_DEFAULT_SLEEP_TIME = 0.45; // 450ms
const getWidthsForMultiDOM = (userPassedWidths, eligibleWidths) => {
// Deep copy of eligible mobile widths
let allWidths = [];
if (eligibleWidths?.mobile?.length !== 0) {
allWidths = allWidths.concat(eligibleWidths?.mobile);
}
if (userPassedWidths.length !== 0) {
allWidths = allWidths.concat(userPassedWidths);
} else {
allWidths = allWidths.concat(eligibleWidths.config);
}
return [...new Set(allWidths)].filter(e => e); // Removing duplicates
};
async function changeWindowDimensionAndWait(driver, width, height, resizeCount) {
try {
const caps = await driver.getCapabilities();
if (typeof driver?.sendDevToolsCommand === 'function' && caps.getBrowserName() === 'chrome' && process.env.PERCY_DISABLE_CDP_RESIZE !== 'true') {
await driver?.sendDevToolsCommand('Emulation.setDeviceMetricsOverride', {
height,
width,
deviceScaleFactor: 1,
mobile: false
});
} else {
await driver.manage().window().setRect({ width, height });
}
} catch (e) {
log.debug(`Resizing using CDP failed, falling back to driver resize for width ${width}`, e);
await driver.manage().window().setRect({ width, height });
}
try {
await driver.wait(async () => {
/* istanbul ignore next: no instrumenting injected code */
await driver.executeScript('return window.resizeCount') === resizeCount;
}, 1000);
} catch (e) {
log.debug(`Timed out waiting for window resize event for width ${width}`, e);
}
}
// Captures responsive DOM snapshots across different widths
async function captureResponsiveDOM(driver, options) {
const widths = getWidthsForMultiDOM(options.widths || [], utils.percy?.widths);
const domSnapshots = [];
const windowSize = await driver.manage().window().getRect();
let currentWidth = windowSize.width; let currentHeight = windowSize.height;
let lastWindowWidth = currentWidth;
let resizeCount = 0;
// Setup the resizeCount listener if not present
/* istanbul ignore next: no instrumenting injected code */
await driver.executeScript('PercyDOM.waitForResize()');
let height = currentHeight;
if (process.env.PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT) {
height = await driver.executeScript(`return window.outerHeight - window.innerHeight + ${utils.percy?.config?.snapshot?.minHeight}`);
}
for (let width of widths) {
if (lastWindowWidth !== width) {
resizeCount++;
await changeWindowDimensionAndWait(driver, width, height, resizeCount);
lastWindowWidth = width;
}
if (process.env.PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE) {
await driver.navigate().refresh();
await driver.executeScript(await utils.fetchPercyDOM());
}
if (process.env.RESPONSIVE_CAPTURE_SLEEP_TIME) {
await new Promise(resolve => setTimeout(resolve, parseInt(process.env.RESPONSIVE_CAPTURE_SLEEP_TIME) * 1000));
}
if (process.env.PERCY_ENABLE_LAZY_LOADING_SCROLL) {
await module.exports.slowScrollToBottom(driver);
}
let domSnapshot = await captureSerializedDOM(driver, options);
domSnapshot.width = width;
domSnapshots.push(domSnapshot);
}
// Reset window size back to original dimensions
await changeWindowDimensionAndWait(driver, currentWidth, currentHeight, resizeCount + 1);
return domSnapshots;
}
async function captureSerializedDOM(driver, options) {
/* istanbul ignore next: no instrumenting injected code */
let { domSnapshot } = await driver.executeScript(options => ({
/* eslint-disable-next-line no-undef */
domSnapshot: PercyDOM.serialize(options)
}), options);
/* istanbul ignore next: no instrumenting injected code */
domSnapshot.cookies = await driver.manage().getCookies() || [];
return domSnapshot;
}
function isResponsiveDOMCaptureValid(options) {
if (utils.percy?.config?.percy?.deferUploads) {
return false;
}
return (
options?.responsive_snapshot_capture ||
options?.responsiveSnapshotCapture ||
utils.percy?.config?.snapshot?.responsiveSnapshotCapture ||
false
);
}
async function captureDOM(driver, options = {}) {
const responsiveSnapshotCapture = isResponsiveDOMCaptureValid(options);
if (responsiveSnapshotCapture) {
return await captureResponsiveDOM(driver, options);
} else {
return await captureSerializedDOM(driver, options);
}
}
async function currentURL(driver, options) {
/* istanbul ignore next: no instrumenting injected code */
let { url } = await driver.executeScript(options => ({
/* eslint-disable-next-line no-undef */
url: document.URL
}), options);
return url;
}
const createRegion = function({
boundingBox = null,
elementXpath = null,
elementCSS = null,
padding = null,
algorithm = 'ignore',
diffSensitivity = null,
imageIgnoreThreshold = null,
carouselsEnabled = null,
bannersEnabled = null,
adsEnabled = null,
diffIgnoreThreshold = null
} = {}) {
const elementSelector = {};
if (boundingBox) elementSelector.boundingBox = boundingBox;
if (elementXpath) elementSelector.elementXpath = elementXpath;
if (elementCSS) elementSelector.elementCSS = elementCSS;
const region = {
algorithm,
elementSelector
};
if (padding) {
region.padding = padding;
}
const configuration = {};
if (['standard', 'intelliignore'].includes(algorithm)) {
if (diffSensitivity !== null) configuration.diffSensitivity = diffSensitivity;
if (imageIgnoreThreshold !== null) configuration.imageIgnoreThreshold = imageIgnoreThreshold;
if (carouselsEnabled !== null) configuration.carouselsEnabled = carouselsEnabled;
if (bannersEnabled !== null) configuration.bannersEnabled = bannersEnabled;
if (adsEnabled !== null) configuration.adsEnabled = adsEnabled;
}
if (Object.keys(configuration).length > 0) {
region.configuration = configuration;
}
const assertion = {};
if (diffIgnoreThreshold !== null) {
assertion.diffIgnoreThreshold = diffIgnoreThreshold;
}
if (Object.keys(assertion).length > 0) {
region.assertion = assertion;
}
return region;
};
// Take a DOM snapshot and post it to the snapshot endpoint
const percySnapshot = async function percySnapshot(driver, name, options) {
if (!driver) throw new Error('An instance of the selenium driver object is required.');
if (!name) throw new Error('The `name` argument is required.');
if (!(await module.exports.isPercyEnabled())) {
if (process.env.PERCY_RAISE_ERROR === 'true') {
throw new Error('Percy is not running, disabling snapshots.');
} else {
return;
}
}
if (utils.percy?.type === 'automate') {
throw new Error('Invalid function call - percySnapshot(). Please use percyScreenshot() function while using Percy with Automate. For more information on usage of percyScreenshot, refer https://www.browserstack.com/docs/percy/integrate/functional-and-visual');
}
try {
// Inject the DOM serialization script
await driver.executeScript(await utils.fetchPercyDOM());
// Serialize and capture the DOM
/* istanbul ignore next: no instrumenting injected code */
let domSnapshot = await captureDOM(driver, options);
let url = await currentURL(driver, options);
// Post the DOM to the snapshot endpoint with snapshot options and other info
const response = await utils.postSnapshot({
...options,
environmentInfo: ENV_INFO,
clientInfo: CLIENT_INFO,
domSnapshot,
name,
url
});
return response?.body?.data;
} catch (error) {
// Handle errors
log.error(`Could not take DOM snapshot "${name}"`);
log.error(error);
if (process.env.PERCY_RAISE_ERROR === 'true') {
throw error;
}
}
};
module.exports = percySnapshot;
module.exports.percySnapshot = percySnapshot;
module.exports.createRegion = createRegion;
module.exports.request = async function request(data) {
return await utils.captureAutomateScreenshot(data);
}; // To mock in test case
const getElementIdFromElements = async function getElementIdFromElements(elements) {
return Promise.all(elements.map(e => e.getId()));
};
module.exports.percyScreenshot = async function percyScreenshot(driver, name, options) {
if (!driver || typeof driver === 'string') {
// Unable to test this as couldnt define `browser` from test mjs file
try {
// browser is defined in wdio context
// eslint-disable-next-line no-undef
[driver, name, options] = [browser, driver, name];
} catch (e) { // ReferenceError: browser is not defined.
driver = undefined;
}
}
if (!driver) throw new Error('An instance of the selenium driver object is required.');
if (!name) throw new Error('The `name` argument is required.');
if (!(await module.exports.isPercyEnabled())) {
if (process.env.PERCY_RAISE_ERROR === 'true') {
throw new Error('Percy is not running, disabling snapshots.');
} else {
return;
}
}
if (utils.percy?.type !== 'automate') {
throw new Error('Invalid function call - percyScreenshot(). Please use percySnapshot() function for taking screenshot. percyScreenshot() should be used only while using Percy with Automate. For more information on usage of PercySnapshot(), refer doc for your language https://www.browserstack.com/docs/percy/integrate/overview');
}
try {
const driverData = new DriverMetadata(driver);
if (options) {
if ('ignoreRegionSeleniumElements' in options) {
options.ignore_region_selenium_elements = options.ignoreRegionSeleniumElements;
delete options.ignoreRegionSeleniumElements;
}
if ('considerRegionSeleniumElements' in options) {
options.consider_region_selenium_elements = options.considerRegionSeleniumElements;
delete options.considerRegionSeleniumElements;
}
if ('ignore_region_selenium_elements' in options) {
options.ignore_region_selenium_elements = await getElementIdFromElements(options.ignore_region_selenium_elements);
}
if ('consider_region_selenium_elements' in options) {
options.consider_region_selenium_elements = await getElementIdFromElements(options.consider_region_selenium_elements);
}
}
// Post the driver details to the automate screenshot endpoint with snapshot options and other info
const response = await module.exports.request({
environmentInfo: ENV_INFO,
clientInfo: CLIENT_INFO,
sessionId: await driverData.getSessionId(),
commandExecutorUrl: await driverData.getCommandExecutorUrl(),
capabilities: await driverData.getCapabilities(),
snapshotName: name,
options
});
return response?.body?.data;
} catch (error) {
// Handle errors
log.error(`Could not take Screenshot "${name}"`);
log.error(error.stack);
if (process.env.PERCY_RAISE_ERROR === 'true') {
throw error;
}
}
};
// jasmine cannot mock individual functions, hence adding isPercyEnabled to the exports object
// also need to define this at the end of the file or else default exports will over-ride this
module.exports.isPercyEnabled = async function isPercyEnabled() {
return await utils.isPercyEnabled();
};
module.exports.slowScrollToBottom = async (driver, scrollSleep = SCROLL_DEFAULT_SLEEP_TIME) => {
if (process.env.PERCY_LAZY_LOAD_SCROLL_TIME) {
scrollSleep = parseFloat(process.env.PERCY_LAZY_LOAD_SCROLL_TIME);
}
const scrollHeightCommand = 'return Math.max(document.body.scrollHeight, document.body.clientHeight, document.body.offsetHeight, document.documentElement.scrollHeight, document.documentElement.clientHeight, document.documentElement.offsetHeight);';
let scrollHeight = Math.min(await driver.executeScript(scrollHeightCommand), CS_MAX_SCREENSHOT_LIMIT);
const clientHeight = await driver.executeScript('return document.documentElement.clientHeight');
let current = 0;
let page = 1;
// Break the loop if maximum scroll height 25000px is reached
while (scrollHeight > current && current < CS_MAX_SCREENSHOT_LIMIT) {
current = clientHeight * page;
page += 1;
await driver.executeScript(`window.scrollTo(0, ${current})`);
await new Promise(resolve => setTimeout(resolve, scrollSleep * 1000));
// Recalculate scroll height for dynamically loaded pages
scrollHeight = await driver.executeScript(scrollHeightCommand);
}
// Get back to top
await driver.executeScript('window.scrollTo(0, 0)');
let sleepAfterScroll = 1;
if (process.env.PERCY_SLEEP_AFTER_LAZY_LOAD_COMPLETE) {
sleepAfterScroll = parseFloat(process.env.PERCY_SLEEP_AFTER_LAZY_LOAD_COMPLETE);
}
await new Promise(resolve => setTimeout(resolve, sleepAfterScroll * 1000));
};