Skip to content

Commit

Permalink
Feat: MultiDOM-v2 Capture (#553)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
Amit3200 and chinmay-browserstack authored Oct 4, 2024
1 parent c441967 commit fc5cd13
Show file tree
Hide file tree
Showing 3 changed files with 243 additions and 12 deletions.
133 changes: 123 additions & 10 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,140 @@ 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');
}

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,
Expand All @@ -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
Expand All @@ -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');
}
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
118 changes: 118 additions & 0 deletions test/index.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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', () => {
Expand Down

0 comments on commit fc5cd13

Please sign in to comment.