From fc5cd137960a0e6992425bbe02efe4db00956566 Mon Sep 17 00:00:00 2001 From: Amit Singh Sansoya Date: Fri, 4 Oct 2024 11:58:46 +0530 Subject: [PATCH] Feat: MultiDOM-v2 Capture (#553) * Feat: MultiDOM-v2 Capture * Update JS lint * Adding Specs at v1 * Fixing signature * Adding specs for multi-dom * Adding coverage for specs * Adding Specs * Resolving comments * CLI Config Support * Fix for chrome cdp command execution --------- Co-authored-by: Chinmay Maheshwari --- index.js | 133 ++++++++++++++++++++++++++++++++++++++++---- package.json | 4 +- test/index.test.mjs | 118 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 12 deletions(-) diff --git a/index.js b/index.js index 7f6cf48..ab6ffb9 100644 --- a/index.js +++ b/index.js @@ -11,13 +11,129 @@ 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 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') { + 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()'); + for (let width of widths) { + if (lastWindowWidth !== width) { + resizeCount++; + await changeWindowDimensionAndWait(driver, width, currentHeight, resizeCount); + lastWindowWidth = width; + } + + if (process.env.RESPONSIVE_CAPTURE_SLEEP_TIME) { + await new Promise(resolve => setTimeout(resolve, parseInt(process.env.RESPONSIVE_CAPTURE_SLEEP_TIME) * 1000)); + } + + 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; +} // Take a DOM snapshot and post it to the snapshot endpoint -module.exports = async function percySnapshot(driver, name, options) { +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())) return; - let log = utils.logger('selenium-webdriver'); 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'); } @@ -25,15 +141,10 @@ module.exports = async function percySnapshot(driver, name, options) { 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, url } = await driver.executeScript(options => ({ - /* eslint-disable-next-line no-undef */ - domSnapshot: PercyDOM.serialize(options), - url: document.URL - }), options); - + 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, @@ -51,6 +162,9 @@ module.exports = async function percySnapshot(driver, name, options) { } }; +module.exports = percySnapshot; +module.exports.percySnapshot = percySnapshot; + module.exports.request = async function request(data) { return await utils.captureAutomateScreenshot(data); }; // To mock in test case @@ -74,7 +188,6 @@ module.exports.percyScreenshot = async function percyScreenshot(driver, name, op 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())) return; - let log = utils.logger('selenium-webdriver'); 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'); } diff --git a/package.json b/package.json index 1215c4a..eac62cf 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,11 @@ "test:types": "tsd" }, "dependencies": { - "@percy/sdk-utils": "^1.28.0", + "@percy/sdk-utils": "^1.29.5-beta.0", "node-request-interceptor": "^0.6.3" }, "devDependencies": { - "@percy/cli": "^1.28.0", + "@percy/cli": "^1.29.5-beta.0", "@types/selenium-webdriver": "^4.0.9", "cross-env": "^7.0.2", "eslint": "^8.27.0", diff --git a/test/index.test.mjs b/test/index.test.mjs index 4ddfaa3..8db141a 100644 --- a/test/index.test.mjs +++ b/test/index.test.mjs @@ -7,10 +7,29 @@ const { percyScreenshot } = percySnapshot; describe('percySnapshot', () => { let driver; + let mockedDriver; beforeAll(async function() { driver = await new webdriver.Builder() .forBrowser('firefox').build(); + + mockedDriver = { + getCapabilities: jasmine.createSpy('sendDevToolsCommand').and.returnValue({ getBrowserName: () => 'chrome'}), + sendDevToolsCommand: jasmine.createSpy('sendDevToolsCommand').and.returnValue(Promise.resolve()), + manage: jasmine.createSpy('manage').and.returnValue({ + window: jasmine.createSpy('window').and.returnValue({ + setRect: jasmine.createSpy('setRect').and.returnValue(Promise.resolve()), + getRect: jasmine.createSpy('getRect').and.returnValue(Promise.resolve({ + width: 1024, + height: 768 + })) + }), + getCookies: jasmine.createSpy('getCookies').and.returnValue(Promise.resolve({})) + }), + executeScript: jasmine.createSpy('executeScript').and.returnValue(Promise.resolve(1)), + wait: jasmine.createSpy('wait').and.returnValue(Promise.resolve(1)) + }; + global.CDP_SUPPORT_SELENIUM = true; }); afterAll(async () => { @@ -77,6 +96,105 @@ describe('percySnapshot', () => { } expect(error).toEqual('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'); }); + + it('posts snapshots to percy server with responsiveSnapshotCapture true', async () => { + await driver.manage().window().setRect({ width: 1380, height: 1024 }); + await percySnapshot(driver, 'Snapshot 1', { responsiveSnapshotCapture: true, widths: [1380] }); + expect(await helpers.get('logs')).toEqual(jasmine.arrayContaining([ + 'Snapshot found: Snapshot 1', + `- url: ${helpers.testSnapshotURL}`, + jasmine.stringMatching(/clientInfo: @percy\/selenium-webdriver\/.+/), + jasmine.stringMatching(/environmentInfo: selenium-webdriver\/.+/) + ])); + }); + + it('posts snapshots to percy server with responsiveSnapshotCapture false', async () => { + await percySnapshot(driver, 'Snapshot 1', { responsiveSnapshotCapture: false, widths: [1380] }); + + expect(await helpers.get('logs')).toEqual(jasmine.arrayContaining([ + 'Snapshot found: Snapshot 1', + `- url: ${helpers.testSnapshotURL}`, + jasmine.stringMatching(/clientInfo: @percy\/selenium-webdriver\/.+/), + jasmine.stringMatching(/environmentInfo: selenium-webdriver\/.+/) + ])); + }); + + it('posts snapshots to percy server with responsiveSnapshotCapture with mobile', async () => { + spyOn(percySnapshot, 'isPercyEnabled').and.returnValue(Promise.resolve(true)); + utils.percy.widths = { mobile: [1125], widths: [1280] }; + + await driver.manage().window().setRect({ width: 1280, height: 1024 }); + await percySnapshot(driver, 'Snapshot 1', { responsiveSnapshotCapture: true }); + + expect(await helpers.get('logs')).toEqual(jasmine.arrayContaining([ + 'Snapshot found: Snapshot 1', + `- url: ${helpers.testSnapshotURL}`, + jasmine.stringMatching(/clientInfo: @percy\/selenium-webdriver\/.+/), + jasmine.stringMatching(/environmentInfo: selenium-webdriver\/.+/) + ])); + }); + + it('multiDOM should not run when deferUploads is true', async () => { + spyOn(percySnapshot, 'isPercyEnabled').and.returnValue(Promise.resolve(true)); + utils.percy.config = { percy: { deferUploads: true } }; + spyOn(mockedDriver, 'sendDevToolsCommand').and.callThrough(); + spyOn(mockedDriver.manage().window(), 'setRect').and.callThrough(); + + await percySnapshot(mockedDriver, 'Test Snapshot', { responsiveSnapshotCapture: true }); + + expect(mockedDriver.sendDevToolsCommand).not.toHaveBeenCalled(); + expect(mockedDriver.manage().window().setRect).not.toHaveBeenCalled(); + }); + + it('should call sendDevToolsCommand for chrome and not setRect', async () => { + spyOn(mockedDriver, 'sendDevToolsCommand').and.callThrough(); + spyOn(mockedDriver.manage().window(), 'setRect').and.callThrough(); + utils.percy.widths = { mobile: [], widths: [1280] }; + + await percySnapshot(mockedDriver, 'Test Snapshot', { responsiveSnapshotCapture: true }); + + expect(mockedDriver.sendDevToolsCommand).toHaveBeenCalledWith('Emulation.setDeviceMetricsOverride', { + height: jasmine.any(Number), + width: jasmine.any(Number), + deviceScaleFactor: 1, + mobile: false + }); + expect(mockedDriver.manage().window().setRect).not.toHaveBeenCalled(); + }); + + it('should fall back to setRect when sendDevToolsCommand fails', async () => { + const windowManager = mockedDriver.manage().window(); + spyOn(mockedDriver, 'sendDevToolsCommand').and.rejectWith(new Error('CDP Command Failed')); + spyOn(windowManager, 'setRect').and.callThrough(); + utils.percy.widths = { mobile: [1125], widths: [375, 1280] }; + + await percySnapshot(mockedDriver, 'Test Snapshot', { responsiveSnapshotCapture: true }); + + expect(mockedDriver.sendDevToolsCommand).toHaveBeenCalled(); + expect(windowManager.setRect).toHaveBeenCalledWith({ + width: jasmine.any(Number), + height: jasmine.any(Number) + }); + }); + + it('should log a timeout error when resizeCount fails', async () => { + spyOn(mockedDriver, 'sendDevToolsCommand').and.rejectWith(new Error('TimeoutError')); + utils.percy.widths = { mobile: [1125], widths: [375, 1280, 1280] }; + + await percySnapshot(mockedDriver, 'Test Snapshot', { responsiveSnapshotCapture: true }); + + expect(mockedDriver.executeScript).not.toHaveBeenCalledWith('return window.resizeCount'); + }); + + it('should wait if RESPONSIVE_CAPTURE_SLEEP_TIME is set', async () => { + process.env.RESPONSIVE_CAPTURE_SLEEP_TIME = 1; + spyOn(global, 'setTimeout').and.callThrough(); + + await percySnapshot(mockedDriver, 'Test Snapshot', { responsiveSnapshotCapture: true }); + + expect(setTimeout).toHaveBeenCalled(); + delete process.env.RESPONSIVE_CAPTURE_SLEEP_TIME; + }); }); describe('percyScreenshot', () => {