UNPKG

@ekolga/codeceptjs-resemblehelper

Version:

Market.kz Only. Resemble Js helper for CodeceptJS, with Support for Playwright, Webdriver, TestCafe, Puppeteer & Appium

538 lines (474 loc) 18.8 kB
const resemble = require("resemblejs"); const fs = require('fs'); const assert = require('assert'); const mkdirp = require('mkdirp'); const getDirName = require('path').dirname; const AWS = require('aws-sdk'); const path = require('path'); const sizeOf = require('image-size'); const Container = require('codeceptjs/lib/container'); /** * Resemble.js helper class for CodeceptJS, this allows screen comparison * @author Puneet Kala */ class ResembleHelper extends Helper { constructor(config) { super(config); this.baseFolder = this.resolvePath(config.baseFolder); this.diffFolder = this.resolvePath(config.diffFolder); this.screenshotFolder = global.output_dir + "/"; this.prepareBaseImage = config.prepareBaseImage; } resolvePath(folderPath) { if (!path.isAbsolute(folderPath)) { return path.resolve(global.codecept_dir, folderPath) + "/"; } return folderPath; } _resolveRelativePath(folderPath) { let absolutePathOfImage = folderPath; if (!path.isAbsolute(absolutePathOfImage)) { absolutePathOfImage = path.resolve(global.codecept_dir, absolutePathOfImage) + "/"; } let absolutePathOfReportFolder = global.output_dir; // support mocha if (Container.mocha() && typeof Container.mocha().options.reporterOptions.reportDir !== 'undefined') { absolutePathOfReportFolder = Container.mocha().options.reporterOptions.reportDir; } // support mocha-multi-reporters if (Container.mocha() && typeof Container.mocha().options.reporterOptions.mochawesome.options.reportDir !== 'undefined') { absolutePathOfReportFolder = Container.mocha().options.reporterOptions.mochawesome.options.reportDir; } return path.relative(absolutePathOfReportFolder, absolutePathOfImage); } /** * Compare Images * * @param image * @param options * @returns {Promise<resolve | reject>} */ async _compareImages(image, options) { const baseImage = this._getBaseImagePath(image, options); const actualImage = this._getActualImagePath(image); const diffImage = this._getDiffImagePath(image); // check whether the base and the screenshot images are present. fs.access(baseImage, fs.constants.F_OK | fs.constants.R_OK, (err) => { if (err) { throw new Error( `${baseImage} ${err.code === 'ENOENT' ? 'base image does not exist' : 'base image has an access error'}`); } }); fs.access(actualImage, fs.constants.F_OK | fs.constants.R_OK, (err) => { if (err) { throw new Error( `${actualImage} ${err.code === 'ENOENT' ? 'screenshot image does not exist' : 'screenshot image has an access error'}`); } }); return new Promise((resolve, reject) => { if (options.outputSettings) { options.outputSettings = {}; } if (typeof options.needsSameDimension === 'undefined') { options.needsSameDimension = true; } resemble.outputSettings({ boundingBox: options.boundingBox, ignoredBox: options.ignoredBox, ...options.outputSettings, }); this.debug("Tolerance Level Provided " + options.tolerance); const tolerance = options.tolerance; resemble.compare(baseImage, actualImage, options, (err, data) => { if (err) { reject(err); } else { if (options.needsSameDimension && !data.isSameDimensions) { let dimensions1 = sizeOf(baseImage); let dimensions2 = sizeOf(actualImage); reject(new Error(`The base image is of ${dimensions1.height} X ${dimensions1.width} and actual image is of ${dimensions2.height} X ${dimensions2.width}. Please use images of same dimensions so as to avoid any unexpected results.`)); } resolve(data); if (data.misMatchPercentage >= tolerance) { if (!fs.existsSync(getDirName(diffImage))) { fs.mkdirSync(getDirName(diffImage)); } fs.writeFileSync(diffImage, data.getBuffer()); const diffImagePath = path.join(process.cwd(), diffImage); this.debug(`Diff Image File Saved to: ${diffImagePath}`); } } }); }); } /** * * @param image * @param options * @returns {Promise<*>} */ async _fetchMisMatchPercentage(image, options) { const result = this._compareImages(image, options); const data = await Promise.resolve(result); return data.misMatchPercentage; } /** * Take screenshot of individual element. * @param selector selector of the element to be screenshotted * @param name name of the image * @returns {Promise<void>} */ async screenshotElement(selector, name) { const helper = this._getHelper(); if (this.helpers['Puppeteer'] || this.helpers['Playwright']) { await helper.waitForVisible(selector); const els = await helper._locate(selector); if (!els.length) throw new Error(`Element ${selector} couldn't be located`); const el = els[0]; await el.screenshot({path: `${global.output_dir}/${name}.png`}); } else if (this.helpers['WebDriver']) { await helper.waitForVisible(selector); const els = await helper._locate(selector); if (!els.length) throw new Error(`Element ${selector} couldn't be located`); const el = els[0]; await el.saveScreenshot(this.screenshotFolder + name + '.png'); } else if (this.helpers['TestCafe']) { await helper.waitForVisible(selector); const els = await helper._locate(selector); if (!await els.count) throw new Error(`Element ${selector} couldn't be located`); const { t } = this.helpers['TestCafe']; await t.takeElementScreenshot(els, name); } else throw new Error("Method only works with Playwright, Puppeteer, WebDriver or TestCafe helpers."); } /** * This method attaches image attachments of the base, screenshot and diff to the allure reporter when the mismatch exceeds tolerance. * @param baseImage * @param misMatch * @param options * @returns {Promise<void>} */ async _addAttachment(baseImage, misMatch, options) { const allure = codeceptjs.container.plugins('allure'); if (allure !== undefined && misMatch >= options.tolerance) { allure.addAttachment('Base Image', fs.readFileSync(this._getBaseImagePath(baseImage, options)), 'image/png'); allure.addAttachment('Screenshot Image', fs.readFileSync(this._getActualImagePath(baseImage)), 'image/png'); allure.addAttachment('Diff Image', fs.readFileSync(this._getDiffImagePath(baseImage)), 'image/png'); } } /** * This method attaches context, and images to Mochawesome reporter when the mismatch exceeds tolerance. * @param baseImage * @param misMatch * @param options * @returns {Promise<void>} */ async _addMochaContext(baseImage, misMatch, options) { const mocha = this.helpers['Mochawesome']; if (mocha !== undefined && misMatch >= options.tolerance) { await mocha.addMochawesomeContext("Base Image"); await mocha.addMochawesomeContext(this._resolveRelativePath(this._getBaseImagePath(baseImage, options))); await mocha.addMochawesomeContext("ScreenShot Image"); await mocha.addMochawesomeContext(this._resolveRelativePath(this._getActualImagePath(baseImage))); await mocha.addMochawesomeContext("Diff Image"); await mocha.addMochawesomeContext(this._resolveRelativePath(this._getDiffImagePath(baseImage))); } } /** * This method uploads the diff and screenshot images into the bucket with diff image under bucketName/diff/diffImage and the screenshot image as * bucketName/output/ssImage * @param accessKeyId * @param secretAccessKey * @param region * @param bucketName * @param baseImage * @param options * @returns {Promise<void>} */ async _upload(accessKeyId, secretAccessKey, region, bucketName, baseImage, options) { console.log("Starting Upload... "); const s3 = new AWS.S3({ accessKeyId: accessKeyId, secretAccessKey: secretAccessKey, region: region }); fs.readFile(this._getActualImagePath(baseImage), (err, data) => { if (err) throw err; let base64data = new Buffer(data, 'binary'); const params = { Bucket: bucketName, Key: `output/${baseImage}`, Body: base64data }; s3.upload(params, (uErr, uData) => { if (uErr) throw uErr; console.log(`Screenshot Image uploaded successfully at ${uData.Location}`); }); }); fs.readFile(this._getDiffImagePath(baseImage), (err, data) => { if (err) console.log("Diff image not generated"); else { let base64data = new Buffer(data, 'binary'); const params = { Bucket: bucketName, Key: `diff/Diff_${baseImage}`, Body: base64data }; s3.upload(params, (uErr, uData) => { if (uErr) throw uErr; console.log(`Diff Image uploaded successfully at ${uData.Location}`) }); } }); // If prepareBaseImage is false, then it won't upload the baseImage. However, this parameter is not considered if the config file has a prepareBaseImage set to true. if (this._getPrepareBaseImage(options)) { const baseImageName = this._getBaseImageName(baseImage, options); fs.readFile(this._getBaseImagePath(baseImage, options), (err, data) => { if (err) throw err; else { let base64data = new Buffer(data, 'binary'); const params = { Bucket: bucketName, Key: `base/${baseImageName}`, Body: base64data }; s3.upload(params, (uErr, uData) => { if (uErr) throw uErr; console.log(`Base Image uploaded at ${uData.Location}`) }); } }); } else { console.log("Not Uploading base Image"); } } /** * This method downloads base images from specified bucket into the base folder as mentioned in config file. * @param accessKeyId * @param secretAccessKey * @param region * @param bucketName * @param baseImage * @param options * @returns {Promise<void>} */ _download(accessKeyId, secretAccessKey, region, bucketName, baseImage, options) { console.log("Starting Download..."); const baseImageName = this._getBaseImageName(baseImage, options); const s3 = new AWS.S3({ accessKeyId: accessKeyId, secretAccessKey: secretAccessKey, region: region }); const params = { Bucket: bucketName, Key: `base/${baseImageName}` }; return new Promise((resolve) => { s3.getObject(params, (err, data) => { if (err) console.error(err); console.log(this._getBaseImagePath(baseImage, options)); fs.writeFileSync(this._getBaseImagePath(baseImage, options), data.Body); resolve("File Downloaded Successfully"); }); }); } /** * Check Visual Difference for Base and Screenshot Image * @param baseImage Name of the Base Image (Base Image path is taken from Configuration) * @param {any} [options] Options ex {prepareBaseImage: true, tolerance: 5} along with Resemble JS Options, read more here: https://github.com/rsmbl/Resemble.js * @returns {Promise<void>} */ async seeVisualDiff(baseImage, options) { await this._assertVisualDiff(undefined, baseImage, options); } /** * See Visual Diff for an Element on a Page * * @param selector Selector which has to be compared expects these -> CSS|XPath|ID * @param baseImage Base Image for comparison * @param {any} [options] Options ex {prepareBaseImage: true, tolerance: 5} along with Resemble JS Options, read more here: https://github.com/rsmbl/Resemble.js * @returns {Promise<void>} */ async seeVisualDiffForElement(selector, baseImage, options) { await this._assertVisualDiff(selector, baseImage, options); } async _assertVisualDiff(selector, baseImage, options) { if (!options) { options = {}; options.tolerance = 0; } const awsC = this.config.aws; if (this._getPrepareBaseImage(options)) { await this._prepareBaseImage(baseImage, options); } else if (awsC !== undefined) { await this._download(awsC.accessKeyId, awsC.secretAccessKey, awsC.region, awsC.bucketName, baseImage, options); } if (selector) { options.boundingBox = await this._getBoundingBox(selector); } const misMatch = await this._fetchMisMatchPercentage(baseImage, options); this._addAttachment(baseImage, misMatch, options); this._addMochaContext(baseImage, misMatch, options); if (awsC !== undefined) { await this._upload(awsC.accessKeyId, awsC.secretAccessKey, awsC.region, awsC.bucketName, baseImage, options) } this.debug("MisMatch Percentage Calculated is " + misMatch + " for baseline " + baseImage); if (!options.skipFailure) { assert(misMatch <= options.tolerance, "Screenshot does not match with the baseline " + baseImage + " when MissMatch Percentage is " + misMatch); } } /** * Function to prepare Base Images from Screenshots * * @param screenShotImage Name of the screenshot Image (Screenshot Image Path is taken from Configuration) * @param options */ async _prepareBaseImage(screenShotImage, options) { const baseImage = this._getBaseImagePath(screenShotImage, options); const actualImage = this._getActualImagePath(screenShotImage); await this._createDir(baseImage); fs.access(actualImage, fs.constants.F_OK | fs.constants.W_OK, (err) => { if (err) { throw new Error( `${actualImage} ${err.code === 'ENOENT' ? 'does not exist' : 'is read-only'}`); } }); fs.access(this.baseFolder, fs.constants.F_OK | fs.constants.W_OK, (err) => { if (err) { throw new Error( `${this.baseFolder} ${err.code === 'ENOENT' ? 'does not exist' : 'is read-only'}`); } }); fs.copyFileSync(actualImage, baseImage); } /** * Function to create Directory * @param directory * @returns {Promise<void>} * @private */ _createDir(directory) { mkdirp.sync(getDirName(directory)); } /** * Function to fetch Bounding box for an element, fetched using selector * * @param selector CSS|XPath|ID selector * @returns {Promise<{boundingBox: {left: *, top: *, right: *, bottom: *}}>} */ async _getBoundingBox(selector) { const helper = this._getHelper(); await helper.waitForVisible(selector); const els = await helper._locate(selector); if (this.helpers['TestCafe']) { if (await els.count != 1) throw new Error(`Element ${selector} couldn't be located or isn't unique on the page`); } else { if (!els.length) throw new Error(`Element ${selector} couldn't be located`); } let location, size; if (this.helpers['Puppeteer'] || this.helpers['Playwright']) { const el = els[0]; const box = await el.boundingBox(); size = location = box; } if (this.helpers['WebDriver'] || this.helpers['Appium']) { const el = els[0]; location = await el.getLocation(); size = await el.getSize(); } if (this.helpers['WebDriverIO']) { location = await helper.browser.getLocation(selector); size = await helper.browser.getElementSize(selector); } if (this.helpers['TestCafe']) { return await els.boundingClientRect; } if (!size) { throw new Error("Cannot get element size!"); } const bottom = size.height + location.y; const right = size.width + location.x; const boundingBox = { left: location.x, top: location.y, right: right, bottom: bottom }; this.debugSection('Area', JSON.stringify(boundingBox)); return boundingBox; } _getHelper() { if (this.helpers['Puppeteer']) { return this.helpers['Puppeteer']; } if (this.helpers['WebDriver']) { return this.helpers['WebDriver']; } if (this.helpers['Appium']) { return this.helpers['Appium']; } if (this.helpers['WebDriverIO']) { return this.helpers['WebDriverIO']; } if (this.helpers['TestCafe']) { return this.helpers['TestCafe']; } if (this.helpers['Playwright']) { return this.helpers['Playwright']; } throw new Error('No matching helper found. Supported helpers: Playwright/WebDriver/Appium/Puppeteer/TestCafe'); } /** * Returns the final name of the expected base image, without a path * @param image Name of the base-image, without path * @param options Helper options * @returns {string} */ _getBaseImageName(image, options) { return (options.compareWithImage ? options.compareWithImage : image); } /** * Returns the path to the expected base image * @param image Name of the base-image, without path * @param options Helper options * @returns {string} */ _getBaseImagePath(image, options) { return this.baseFolder + this._getBaseImageName(image, options); } /** * Returns the path to the actual screenshot image * @param image Name of the image, without path * @returns {string} */ _getActualImagePath(image) { return this.screenshotFolder + image; } /** * Returns the path to the image that displays differences between base and actual image. * @param image Name of the image, without path * @returns {string} */ _getDiffImagePath(image) { const diffImage = "Diff_" + image.split(".")[0] + ".png"; return this.diffFolder + diffImage; } /** * Returns the final `prepareBaseImage` flag after evaluating options and config values * @param options Helper options * @returns {boolean} */ _getPrepareBaseImage(options) { if ('undefined' !== typeof options.prepareBaseImage) { // Cast to bool with `!!` for backwards compatibility return !! options.prepareBaseImage; } else { // Compare with `true` for backwards compatibility return true === this.prepareBaseImage; } } } module.exports = ResembleHelper;