"use strict"; /** * Steps to manage screenshots and images. * * These methods are mixed with glacejs [Steps](https://glacejs.github.io/glace-core/Steps.html) * class and available via its instance [$](https://glacejs.github.io/glace-core/global.html#$) * in tests. * * @mixin ImageSteps */ var fs = require("fs"); var path = require("path"); var fse = require("fs-extra"); var screenshot = require("desktop-screenshot"); var sharp = require("sharp"); var temp = require("temp").track(); var uuid = require("uuid/v4"); var U = require("glace-utils"); var LOG = U.logger; require("./fixtures"); var image = require("./image"); sharp.cache(false); var ImageSteps = { __fs: fs, __screenshot: screenshot, __sharp: sharp, makeScreenshot: async function (opts) { /** * Step to make screenshot of browser or display. * * @async * @memberOf ImageSteps * @method makeScreenshot * @instance * @arg {object} [opts] - Step options. * @arg {string} [opts.imageName] - Screenshot name. File extension * `.png` will be added automatically. Default value is dynamically * generated on each call with algorithm `uuid`. * @arg {string} [opts.imageDirPath] - Screenshot folder path. * @arg {boolean} [opts.by] - Screenshot variant. Supported * values are `selenium`, `html2canvas`, `system`. By default * `selenium` if browser is launched, `system` otherwise. * @arg {?string} [opts.cssSelector=null] - CSS selector of DOM element * which of screenshot should be made. * @arg {?string} [opts.backColor=null] - Background color for html2canvas. * Transparent by default. * @arg {?function} [opts.preHook=null] - Function which will be called * in `Steps` context in the beginning of step. * @arg {?function} [opts.postHook=null] - Function which will be called * in `Steps` context at the end of step. * @arg {?string} [opts.element=null] - Web element name which should be * screenshotted. * @arg {boolean} [opts.check=true] - Flag to check screenshot is saved * or no. * @return {Promise<string>} Path to saved screenshot. * @throws {AssertionError} If screenshot isn't saved. * @example * * await $.makeScreenshot(); // saves screenshot with generated file name * await $.makeScreenshot({ imageName: "my image" }); // saves screenshot with custom name * await $.makeScreenshot({ imageName: "my image", element: "searchButton" }); */ opts = U.defVal(opts, {}); var imageName = U.defVal(opts.imageName, uuid()); imageName = U.toKebab(imageName); if (!imageName.endsWith(".png")) imageName += ".png"; var imagePath = U.mkpath( U.defVal(opts.imageDirPath, getScreensDir()), imageName); var by = U.defVal(opts.by); var cssSelector = U.defVal(opts.cssSelector); var backColor = U.defVal(opts.backColor); var preHook = U.defVal(opts.preHook); var postHook = U.defVal(opts.postHook); var element = U.defVal(opts.element); var check = U.defVal(opts.check, true); if (!by) { if (this.webdriver && await this.webdriver.session()) { by = "selenium"; } else { by = "system"; } } allure.step(`Screenshot with ${by}`); expect(["selenium", "html2canvas", "system"]).to.include(by); LOG.info(`Making screenshot with ${by}...`); if (preHook) await preHook.call(this); if (by === "selenium") { await this._seleniumScreenshot(imagePath); }; if (by === "html2canvas") { await this._canvasScreenshot( imagePath, { cssSelector: cssSelector, backColor: backColor }); }; if (by === "system") { await this._displayScreenshot(imagePath); }; if (element) { await this._cutElement(imagePath, element, { path: imagePath }); }; if (check) { expect(fs.existsSync(imagePath), `Screenshot isn't saved to '${imagePath}'`).to.be.true; expect(await image(imagePath).isTransparent(), `Screenshot '${imagePath}' has empty content`).to.be.false; }; if (postHook) await postHook.call(this); CONF.test.curCase.addScreenshot(imagePath); LOG.info(`Screenshot is saved to ${imagePath}`); allure.attachImage("screenshot", imagePath); allure.pass(); return imagePath; }, checkImagesEquivalence: async function (actualImage, expectedImage, opts) { /** * Step to check whether two image are equal or not equal. * * @async * @memberOf ImageSteps * @method checkImagesEquivalence * @instance * @arg {string} actualImage - Path to actual image. * @arg {string} expectedImage - Path to expected image. * @arg {object} [opts] - Helper options. * @arg {number} [opts.threshold=0.05] - Threshold of divergence. * @arg {boolean} [opts.shouldBe=true] - Flag to check whether * image are equal or not equal. * @arg {string[]} [opts.elements=[]] - List of elements on image which * should be verified. * @arg {string} [opts.diffDirPath] - Path to diffs folder. * @return {Promise<void>} * @throws {AssertionError} If result of images comparison doesn't pass * requested parameters. * @example * * await $.checkImagesEquivalence("./actual.png", "./expected.png"); * await $.checkImagesEquivalence("./actual.png", "./expected.png", { shouldBe: false }); */ opts = U.defVal(opts, {}); var elements = U.defVal(opts.elements, []); if (opts.shouldBe === false) { allure.step(`Check that image ${actualImage} isn't equal to ${expectedImage}`); } else { allure.step(`Check that image ${actualImage} is equal to ${expectedImage}`); } LOG.info("Checking images equivalence..."); await this._checkImagesEquivalence(actualImage, expectedImage, opts); for (var element of elements) { var actualElImage = await this._cutElement( actualImage, element, { name: `actual ${element}` }); var expectedElImage = await this._cutElement( expectedImage, element, { name: `expected ${element}` }); await this._checkImagesEquivalence( actualElImage, expectedElImage, opts); }; LOG.info("Images equivalence is checked"); allure.pass(); }, checkImageInclusion: async function (fullImage, includedImage, opts) { /** * Step to check one image includes or doesn't include another image. * * @async * @memberOf ImageSteps * @method checkImageInclusion * @instance * @arg {string} fullImage - Path to image which may include. * @arg {string} includedImage - Path to image which may be included. * @arg {object} [opts] - Step options. * @arg {object} [opts.matchedDirPath] - Folder path to save matched image. * @arg {number} [opts.threshold=0.05] - Threshold of divergence. * @arg {boolean} [opts.shouldBe=true] - Flag to check whether * image are equal or not equal. * @return {Promise<void>} * @throws {AssertionError} If result of images inclusion doesn't pass * requested parameters. * @example * * await $.checkImageInclusion("./full.png", "./part.png"); * await $.checkImageInclusion("./full.png", "./part.png", { shouldBe: false }); */ opts = U.defVal(opts, {}); var threshold = U.defVal(opts.threshold, 0.05); var shouldBe = U.defVal(opts.shouldBe, true); if (shouldBe) { allure.step(`Check that image ${fullImage} includes ${includedImage}`); } else { allure.step(`Check that image ${fullImage} doesn't include ${includedImage}`); } var matchedImagePath = U.mkpath( U.defVal( opts.matchedDirPath, path.resolve(getScreensDir(), "inclusions")), uuid() + ".png"); LOG.info("Checking images inclusion..."); var result = await image(fullImage) .includes(includedImage, { tolerance: threshold, matchedPath: matchedImagePath }); if (shouldBe) { expect(result.isIncluded, `Image '${fullImage}' doesn't include '${includedImage}' but should`) .be.true; } else { expect(result.isIncluded, `Image '${fullImage}' includes '${includedImage}' but shouldn't`) .be.false; }; LOG.info("Images inclusion is checked"); allure.pass(); }, resizeImage: async function (imgPath, percent, opts) { /** * Step to resize image. * * @async * @memberOf ImageSteps * @method resizeImage * @instance * @arg {string} imgPath - Path to resizing image. * @arg {string} [percent] - Percent to resize, for example `150%`. * @arg {object} [opts] - Step options. * @arg {string|number} [opts.width] - Width of resized image in pixels or percent. * @arg {string|number} [opts.height] - Height of resized image in pixels or percent. * @arg {boolean} [opts.check=true] - Check that image is resized. * @return {Promise} * @throws {AssertionError} If image path doesn't exist. * @throws {AssertionError} If image can't be resized. * @example * * await $.resizeImage(imgPath, "75%"); * await $.resizeImage(imgPath, { width: "150%", height: "125%" }); * await $.resizeImage(imgPath, { width: 800, height: 600 }); */ var metadata, width, height, wPercent, hPercent; expect(fs.existsSync(imgPath), `Image '${imgPath}' doesn't exist`).to.be.true; if (typeof(percent) === "object") { opts = percent; percent = undefined; } opts = U.defVal(opts, {}); var check = U.defVal(opts.check, true); if (typeof(opts.width) === "string" && opts.width.endsWith("%")) { wPercent = parseInt(opts.width); } else if (opts.width === undefined) { wPercent = 100; } else { width = opts.width; } if (typeof(opts.height) === "string" && opts.height.endsWith("%")) { hPercent = parseInt(opts.height); } else if (opts.height === undefined) { hPercent = 100; } else { height = opts.height; } if (typeof(percent) === "string" && percent.endsWith("%")) { wPercent = hPercent = parseInt(percent); } if (wPercent && hPercent) { metadata = await sharp(imgPath).metadata(); width = Math.ceil(metadata.width * wPercent / 100); height = Math.ceil(metadata.height * hPercent / 100); } allure.step(`Resize image ${imgPath} to [width=${width}, height=${height}]`); LOG.info(`Resizing image '${imgPath}' to [width=${width}, height=${height}]...`); var tmpPath = temp.path({ suffix: path.extname(imgPath) }); await sharp(imgPath).resize(width, height).toFile(tmpPath); if (check) { metadata = await sharp(tmpPath).metadata(); expect({ width: metadata.width, height: metadata.height }, `Can't resize image '${imgPath}'`) .to.be.eql({ width: width, height: height }); } fs.unlinkSync(imgPath); fse.moveSync(tmpPath, imgPath); LOG.info("Image is resized"); allure.attachImage("resized", imgPath); allure.pass(); }, checkOrMakeScreenshot: async function (imageName, opts) { /** * Step to check or make screenshot of browser or display. * * @async * @memberOf ImageSteps * @method checkOrMakeScreenshot * @instance * @arg {string} imageName - Screenshot name. File extension * `.png` will be added automatically. * @arg {object} [opts] - Step options. * @arg {string} [opts.imageDirPath] - Screenshot folder path. * @arg {string} [opts.expectedDirPath] - Expected screenshot path. * @arg {boolean} [opts.by=selenium] - Screenshot variant. Supported * values are `selenium`, `html2canvas`, `system`. * @arg {?string} [opts.cssSelector=null] - C$ selector of DOM element * which of screenshot should be made. * @arg {?string} [opts.backColor=null] - Background color for html2canvas. * Transparent by default. * @arg {?function} [opts.preHook=null] - Function which will be called * in `Steps` context in the beginning of step. * @arg {?function} [opts.postHook=null] - Function which will be called * in `Steps` context at the end of step. * @arg {string[]} [opts.elements=[]] - List of elements on image which * should be verified. * @arg {string} [opts.diffDirPath] - Path to diffs folder. * @arg {boolean} [opts.check=true] - Flag to check screenshot is saved * or no. * @return {Promise<void>} * @throws {AssertionError} If screenshot isn't saved. * @example * * await $.checkOrMakeScreenshot("my image"); * await $.checkOrMakeScreenshot("my image", { by: "html2canvas" }); */ imageName = U.toKebab(imageName); opts = U.defVal(opts, {}); opts.imageName = imageName; var actualImage = await this.makeScreenshot(opts); if (CONF.compareImages) { var testName = CONF.test.curCase ? U.toKebab(CONF.test.curCase.name) : ""; var expectedImage = path.resolve( U.defVal( opts.expectedDirPath, path.resolve(CONF.resourcesDir, testName, "screenshots")), imageName + ".png"); await this.checkImagesEquivalence( actualImage, expectedImage, opts); }; }, /** * Helper to check two images are equal or not equal. * * @ignore * @async * @method * @protected * @instance * @arg {string} actualImage - Path to actual image. * @arg {string} expectedImage - Path to expected image. * @arg {object} [opts] - Helper options. * @arg {string} [opts.diffDirPath] - Path to diffs folder. * @arg {number} [opts.threshold=0.05] - Threshold of divergence. * @arg {boolean} [opts.shouldBe=true] - Flag to check whether * image are equal or not equal. * @return {Promise<void>} * @throws {AssertionError} - If actual image doesn't exist. * @throws {AssertionError} - If expected image doesn't exist. * @throws {AssertionError} - If result of images comparison don't pass * requested parameters. */ _checkImagesEquivalence: async function (actualImage, expectedImage, opts) { expect(fs.existsSync(actualImage), `Actual image '${actualImage}' doesn't exist`) .to.be.true; expect(fs.existsSync(expectedImage), `Expected image '${expectedImage}' doesn't exist`) .to.be.true; opts = U.defVal(opts, {}); var threshold = U.defVal(opts.threshold, 0.05); var shouldBe = U.defVal(opts.shouldBe, true); var diffImage = U.mkpath( U.defVal( opts.diffDirPath, path.resolve(getScreensDir(), "diffs")), uuid() + ".png"); var percentage = await image(actualImage).equalTo( expectedImage, { tolerance: threshold, diffPath: diffImage }); if (shouldBe) { expect(percentage, `Image '${actualImage}' isn't equal to '${expectedImage}' ` + `but should. Diff image is '${diffImage}'`).be.lte(threshold); } else { expect(percentage, `Image '${actualImage}' is equal to '${expectedImage}' ` + `but shouldn't. Diff image is '${diffImage}'`).be.gte(threshold); }; }, /** * Helper to make screenshot of display. * * @ignore * @async * @method * @protected * @instance * @arg {string} imagePath - Path to screenshot which will be saved. * @return {Promise<void>} */ _displayScreenshot: async function (imagePath) { await new Promise((resolve, reject) => { this.__screenshot(imagePath, err => { if (err) return reject(err); resolve(); }); }); }, /** * Helper to make screenshot with selenium. * * @ignore * @async * @method * @protected * @instance * @arg {string} imagePath - Path to screenshot which will be saved. * @return {Promise<void>} */ _seleniumScreenshot: async function (imagePath) { await this.webdriver.saveScreenshot(imagePath); }, /** * Helper to make screenshot with html2canvas. * * @ignore * @async * @method * @protected * @instance * @arg {string} imagePath - Path to screenshot which will be saved. * @arg {object} [opts] - Helper options. * @arg {?string} [opts.cssSelector=null] - C$ selector of DOM element * which of screenshot should be made. * @arg {?string} [opts.backColor=null] - Background color, transparent by * default. * @arg {number} [opts.timeout=30000] - Time to wait for screenshot is * rendered, ms * @return {Promise<void>} * @throws {Error} - If screenshot will not be rendered during timeout. */ _canvasScreenshot: async function (imagePath, opts) { opts = U.defVal(opts, {}); var cssSelector = U.defVal(opts.cssSelector); var backColor = U.defVal(opts.backColor); var timeout = U.defVal(opts.timeout, 30000); var errMsg = "Can't make screenshot"; if (cssSelector) errMsg += " of element with selector " + cssSelector; /* istanbul ignore next */ await this.webdriver.execute(function (cssSelector, backColor) { function makeScreenshot () { var element; if (cssSelector) { element = document.querySelector(cssSelector); } else { element = document.body; }; html2canvas( element, { backgroundColor: backColor, useCORS: true // capture images from another domains }).then(function (canvas) { window.__screenshot = canvas .toDataURL() .split("data:image/png;base64,")[1]; }); }; if (typeof(html2canvas) !== "undefined") { makeScreenshot(); return; }; var script = document.createElement("script"); script.onload = makeScreenshot; script.src = "http://html2canvas.hertzen.com/dist/html2canvas.min.js"; document.body.appendChild(script); }, cssSelector, backColor); var screenBase64 = await this.webdriver.waitUntil(async () => { /* istanbul ignore next */ return (await this.webdriver.execute(function () { var result; if (window.__screenshot) { result = window.__screenshot; delete window.__screenshot; } else { result = false; }; return result; })).value; }, timeout, errMsg); fs.writeFileSync(imagePath, screenBase64, "base64"); }, /** * Helper to cut element from image. * * @ignore * @async * @method * @protected * @instance * @arg {string} imagePath - Path to image which element will be cut from. * @arg {string} elementName - Name of element which will be cut. * @arg {object} [opts] - Helper options. * @arg {string} [opts.path] - Path to cut image. * @arg {string} [opts.dirPath] - Folder path to cut image. * @arg {string} [opts.name] - Name of cut image with element. * @return {Promise<string>} - Path to cut image. * @throws {AssertionError} - If original image doesn't exist. * @throws {AssertionError} - If DOM element is not registered in config. * @throws {AssertionError} - If cut image is not saved. */ _cutElement: async function (imagePath, elementName, opts) { expect(this.__fs.existsSync(imagePath), `Image ${imagePath} doesn't exist`).be.true; var eLoc = await this.__getElementLocation(elementName, imagePath); opts = U.defVal(opts, {}); var targetName = U.toKebab(U.defVal(opts.name, uuid())); if (!targetName.endsWith(".png")) targetName += ".png"; var targetPath = U.mkpath( U.defVal( opts.path, path.resolve( U.defVal(opts.dirPath, path.resolve(getScreensDir(), "cut-elements")), targetName))); if (imagePath === targetPath) { this.__fs.renameSync(imagePath, imagePath + ".tmp"); imagePath += ".tmp"; }; await new Promise((resolve, reject) => { this.__sharp(imagePath) .extract({ left: eLoc.x, top: eLoc.y, width: eLoc.width, height: eLoc.height }) .crop(sharp.strategy.entropy) .toFile(targetPath, err => { if (err) reject(err); resolve(); }); }); expect(this.__fs.existsSync(targetPath, `Image ${targetPath} isn't saved`)).be.true; if (imagePath.endsWith(".tmp")) this.__fs.unlinkSync(imagePath); return targetPath; }, /** * Helper to get element location. * * @ignore * @async * @method * @private * @instance * @arg {string} name - Element name. * @arg {string} imagePath - Path to image. * @return {object} - Dict with `x`, `y`, `width`, `height` keys. */ __getElementLocation: async function (name, imagePath) { var element = await this.getElement(name); var imageInfo = await new Promise((resolve, reject) => { this.__sharp(imagePath).toBuffer((err, outputBuffer, info) => { if (err) reject(err); resolve(info); }); }); imageInfo.x = 0; imageInfo.y = 0; var eLoc = await element.location(); return U.objOnScreenPos(eLoc, imageInfo); }, }; module.exports = ImageSteps; /** * Helper to get screenshots folder. * * @ignore * @function */ var getScreensDir = () => { return path.resolve(CONF.report.testDir || CONF.report.dir, "screenshots"); };