UNPKG

creevey

Version:

Cross-browser screenshot testing tool for Storybook with fancy UI Runner

546 lines (458 loc) 22.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.updateStorybookGlobals = updateStorybookGlobals; exports.getBrowser = getBrowser; exports.switchStory = switchStory; var _http = _interopRequireDefault(require("http")); var _https = _interopRequireDefault(require("https")); var _pngjs = require("pngjs"); var _loglevel = require("loglevel"); var _loglevelPluginPrefix = _interopRequireDefault(require("loglevel-plugin-prefix")); var _seleniumWebdriver = require("selenium-webdriver"); var _types = require("../../types"); var _messages = require("../messages"); var _os = require("os"); var _utils = require("../utils"); var _capabilities = require("selenium-webdriver/lib/capabilities"); var _helpers = require("../storybook/helpers"); var _logger = require("../logger"); var _chalk = _interopRequireDefault(require("chalk")); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } const DOCKER_INTERNAL = 'host.docker.internal'; let browserLogger = _logger.logger; function getSessionData(grid, sessionId = '') { const gridUrl = new URL(grid); gridUrl.pathname = `/host/${sessionId}`; return new Promise((resolve, reject) => (gridUrl.protocol == 'https:' ? _https.default : _http.default).get(gridUrl.toString(), res => { if (res.statusCode !== 200) { var _res$statusCode; return reject(new Error(`Couldn't get session data for ${sessionId}. Status code: ${(_res$statusCode = res.statusCode) !== null && _res$statusCode !== void 0 ? _res$statusCode : 'Unknown'}`)); } let data = ''; res.setEncoding('utf8'); res.on('data', chunk => data += chunk); res.on('end', () => { try { resolve(JSON.parse(data)); } catch (error) { var _error$stack; reject(`Couldn't get session data for ${sessionId}. ${error instanceof Error ? (_error$stack = error.stack) !== null && _error$stack !== void 0 ? _error$stack : error.message : error}`); } }); })); } async function resolveStorybookUrl(storybookUrl, checkUrl) { browserLogger.debug('Resolving storybook url'); const addresses = [DOCKER_INTERNAL].concat(...Object.values((0, _os.networkInterfaces)()).filter(_types.isDefined).map(network => network.filter(info => info.family == 'IPv4').map(info => info.address))); for (const ip of addresses) { const resolvedUrl = storybookUrl.replace(_utils.LOCALHOST_REGEXP, ip); browserLogger.debug(`Checking storybook availability on ${_chalk.default.magenta(resolvedUrl)}`); if (await checkUrl(resolvedUrl)) { browserLogger.debug(`Resolved storybook url ${_chalk.default.magenta(resolvedUrl)}`); return resolvedUrl; } } const error = new Error('Please specify `storybookUrl` with IP address that accessible from remote browser'); error.name = 'ResolveUrlError'; throw error; } async function openUrlAndWaitForPageSource(browser, url, predicate) { let source = ''; await browser.get(url); do { try { source = await browser.getPageSource(); } catch (_) {// NOTE: Firefox can raise exception "curContainer.frame.document.documentElement is null" } } while (predicate(source)); return source; } function getUrlChecker(browser) { return async url => { try { // NOTE: Before trying a new url, reset the current one browserLogger.debug(`Opening ${_chalk.default.magenta('about:blank')} page`); await openUrlAndWaitForPageSource(browser, 'about:blank', source => !source.includes('<body></body>')); browserLogger.debug(`Opening ${_chalk.default.magenta(url)} and checking the page source`); const source = await openUrlAndWaitForPageSource(browser, url, // NOTE: IE11 can return only `head` without body source => source.length == 0 || !/<body([^>]*>).+<\/body>/s.test(source)); // NOTE: This is the most optimal way to check if we in storybook or not // We don't use any page load strategies except `NONE` // because other add significant delay and some of them don't work in earlier chrome versions // Browsers always load page successful even it's failed // So we just check `#root` element browserLogger.debug(`Checking ${_chalk.default.cyan('#root')} existence on ${_chalk.default.magenta(url)}`); return source.includes('<div id="root"></div>'); } catch (error) { return false; } }; } function waitForStorybook(browser) { browserLogger.debug('Waiting for `load` event to make sure that storybook is initiated'); return browser.executeAsyncScript(function (callback) { if (document.readyState == 'complete') return callback(); window.addEventListener('load', function () { callback(); }); }); } async function resetMousePosition(browser) { var _ref, _await$browser$getCap, _await$browser$getCap2, _await$browser$getCap3; browserLogger.debug('Resetting mouse position to the top-left corner'); const browserName = (await browser.getCapabilities()).getBrowserName(); const [browserVersion] = (_ref = (_await$browser$getCap = (_await$browser$getCap2 = (await browser.getCapabilities()).getBrowserVersion()) === null || _await$browser$getCap2 === void 0 ? void 0 : _await$browser$getCap2.split('.')) !== null && _await$browser$getCap !== void 0 ? _await$browser$getCap : (_await$browser$getCap3 = (await browser.getCapabilities()).get('version')) === null || _await$browser$getCap3 === void 0 ? void 0 : _await$browser$getCap3.split('.')) !== null && _ref !== void 0 ? _ref : []; // NOTE Reset mouse position to support keweb selenium grid browser versions if (browserName == 'chrome' && browserVersion == '70') { const { top, left, width, height } = await browser.executeScript(function () { const bodyRect = document.body.getBoundingClientRect(); return { top: bodyRect.top, left: bodyRect.left, width: bodyRect.width, height: bodyRect.height }; }); // NOTE Bridge mode doesn't support `Origin.VIEWPORT`, move mouse relative await browser.actions({ bridge: true }).move({ origin: browser.findElement(_seleniumWebdriver.By.css('body')), x: Math.ceil(-1 * width / 2) - left, y: Math.ceil(-1 * height / 2) - top }).perform(); } else if (browserName == 'firefox' && browserVersion == '61') { // NOTE Firefox for some reason moving by 0 x 0 move cursor in bottom left corner :sad: await browser.actions().move({ origin: _seleniumWebdriver.Origin.VIEWPORT, x: 0, y: 1 }).perform(); } else { // NOTE IE don't emit move events until force window focus or connect by RDP on virtual machine await browser.actions().move({ origin: _seleniumWebdriver.Origin.VIEWPORT, x: 0, y: 0 }).perform(); } } async function resizeViewport(browser, viewport) { const windowRect = await browser.manage().window().getRect(); const { innerWidth, innerHeight } = await browser.executeScript(function () { return { innerWidth: window.innerWidth, innerHeight: window.innerHeight }; }); browserLogger.debug(`Resizing viewport from ${innerWidth}x${innerHeight} to ${viewport.width}x${viewport.height}`); const dWidth = windowRect.width - innerWidth; const dHeight = windowRect.height - innerHeight; await browser.manage().window().setRect({ width: viewport.width + dWidth, height: viewport.height + dHeight }); } const getScrollBarWidth = (() => { let scrollBarWidth = null; return async browser => { if (scrollBarWidth != null) return Promise.resolve(scrollBarWidth); scrollBarWidth = await browser.executeScript(function () { // eslint-disable-next-line no-var var div = document.createElement('div'); div.innerHTML = 'a'; // NOTE: In IE clientWidth is 0 if this div is empty. div.style.overflowY = 'scroll'; document.body.appendChild(div); // eslint-disable-next-line no-var var widthDiff = div.offsetWidth - div.clientWidth; document.body.removeChild(div); return widthDiff; }); return scrollBarWidth; }; })(); // NOTE Firefox and Safari take viewport screenshot without scrollbars async function hasScrollBar(browser) { var _await$browser$getCap4, _await$browser$getCap5; const browserName = (await browser.getCapabilities()).getBrowserName(); const [browserVersion] = (_await$browser$getCap4 = (_await$browser$getCap5 = (await browser.getCapabilities()).getBrowserVersion()) === null || _await$browser$getCap5 === void 0 ? void 0 : _await$browser$getCap5.split('.')) !== null && _await$browser$getCap4 !== void 0 ? _await$browser$getCap4 : []; return browserName != 'Safari' && // NOTE This need to work with keweb selenium grid !(browserName == 'firefox' && browserVersion == '61'); } async function takeCompositeScreenshot(browser, windowRect, elementRect) { const screens = []; const isScreenshotWithoutScrollBar = !(await hasScrollBar(browser)); const scrollBarWidth = await getScrollBarWidth(browser); // NOTE Sometimes viewport has been scrolled somewhere const normalizedElementRect = { left: elementRect.left - windowRect.left, right: elementRect.left + elementRect.width - windowRect.left, top: elementRect.top - windowRect.top, bottom: elementRect.top + elementRect.height - windowRect.top }; const isFitHorizontally = windowRect.width >= elementRect.width + normalizedElementRect.left; const isFitVertically = windowRect.height >= elementRect.height + normalizedElementRect.top; const viewportWidth = windowRect.width - (isFitVertically ? 0 : scrollBarWidth); const viewportHeight = windowRect.height - (isFitHorizontally ? 0 : scrollBarWidth); const cols = Math.ceil(elementRect.width / viewportWidth); const rows = Math.ceil(elementRect.height / viewportHeight); const xOffset = Math.round(isFitHorizontally ? normalizedElementRect.left : Math.max(0, cols * viewportWidth - elementRect.width)); const yOffset = Math.round(isFitVertically ? normalizedElementRect.top : Math.max(0, rows * viewportHeight - elementRect.height)); for (let row = 0; row < rows; row += 1) { for (let col = 0; col < cols; col += 1) { const dx = Math.min(viewportWidth * col + normalizedElementRect.left, Math.max(0, normalizedElementRect.right - viewportWidth)); const dy = Math.min(viewportHeight * row + normalizedElementRect.top, Math.max(0, normalizedElementRect.bottom - viewportHeight)); await browser.executeScript(function (x, y) { window.scrollTo(x, y); }, dx, dy); screens.push(await browser.takeScreenshot()); } } const images = screens.map(s => Buffer.from(s, 'base64')).map(b => _pngjs.PNG.sync.read(b)); const compositeImage = new _pngjs.PNG({ width: Math.round(elementRect.width), height: Math.round(elementRect.height) }); for (let y = 0; y < compositeImage.height; y += 1) { for (let x = 0; x < compositeImage.width; x += 1) { const col = Math.floor(x / viewportWidth); const row = Math.floor(y / viewportHeight); const isLastCol = cols - col == 1; const isLastRow = rows - row == 1; const scrollOffset = isFitVertically || isScreenshotWithoutScrollBar ? 0 : scrollBarWidth; const i = (y * compositeImage.width + x) * 4; const j = // NOTE compositeImage(x, y) => image(x, y) (y % viewportHeight * (viewportWidth + scrollOffset) + x % viewportWidth) * 4 + ( // NOTE Offset for last row/col image isLastRow ? yOffset * (viewportWidth + scrollOffset) * 4 : 0) + (isLastCol ? xOffset * 4 : 0); const image = images[row * cols + col]; compositeImage.data[i + 0] = image.data[j + 0]; compositeImage.data[i + 1] = image.data[j + 1]; compositeImage.data[i + 2] = image.data[j + 2]; compositeImage.data[i + 3] = image.data[j + 3]; } } return _pngjs.PNG.sync.write(compositeImage).toString('base64'); } async function takeScreenshot(browser, captureElement, ignoreElements) { let screenshot; const ignoreStyles = await insertIgnoreStyles(browser, ignoreElements); try { if (!captureElement) { browserLogger.debug('Capturing viewport screenshot'); screenshot = await browser.takeScreenshot(); browserLogger.debug('Viewport screenshot is captured'); } else { browserLogger.debug(`Checking is element ${_chalk.default.cyan(captureElement)} fit into viewport`); const rects = await browser.executeScript(function (selector) { window.scrollTo(0, 0); // TODO Maybe we should remove same code from `resetMousePosition` // eslint-disable-next-line no-var var element = document.querySelector(selector); if (!element) return; // eslint-disable-next-line no-var var elementRect = element.getBoundingClientRect(); return { elementRect: { top: elementRect.top, left: elementRect.left, width: elementRect.width, height: elementRect.height }, windowRect: { top: Math.round(window.scrollY || window.pageYOffset), left: Math.round(window.scrollX || window.pageXOffset), width: window.innerWidth, height: window.innerHeight } }; }, captureElement); const { elementRect, windowRect } = rects !== null && rects !== void 0 ? rects : {}; if (!elementRect || !windowRect) throw new Error(`Couldn't find element with selector: '${captureElement}'`); const isFitIntoViewport = elementRect.width + elementRect.left <= windowRect.width && elementRect.height + elementRect.top <= windowRect.height; if (isFitIntoViewport) browserLogger.debug(`Capturing ${_chalk.default.cyan(captureElement)}`);else browserLogger.debug(`Capturing composite screenshot image of ${_chalk.default.cyan(captureElement)}`); screenshot = isFitIntoViewport ? await browser.findElement(_seleniumWebdriver.By.css(captureElement)).takeScreenshot() : // TODO pointer-events: none, need to research await takeCompositeScreenshot(browser, windowRect, elementRect); browserLogger.debug(`${_chalk.default.cyan(captureElement)} is captured`); } } finally { await removeIgnoreStyles(browser, ignoreStyles); } return screenshot; } async function selectStory(browser, { id, kind, name }, waitForReady = false) { browserLogger.debug(`Triggering 'SetCurrentStory' event with storyId ${_chalk.default.magenta(id)}`); const errorMessage = await browser.executeAsyncScript(function (id, kind, name, shouldWaitForReady, callback) { if (typeof window.__CREEVEY_SELECT_STORY__ == 'undefined') { return callback("Creevey can't switch story. This may happened if forget to add `creevey` addon to your storybook config, or storybook not loaded in browser due syntax error."); } window.__CREEVEY_SELECT_STORY__(id, kind, name, shouldWaitForReady, callback); }, id, kind, name, waitForReady); if (errorMessage) throw new Error(errorMessage); } async function updateStorybookGlobals(browser, globals) { if ((0, _helpers.isStorybookVersionLessThan)(6)) { browserLogger.warn('Globals are not supported by Storybook versions less than 6'); return; } browserLogger.debug('Applying storybook globals'); await browser.executeScript(function (globals) { window.__CREEVEY_UPDATE_GLOBALS__(globals); }, globals); } function appendIframePath(url) { return `${url.replace(/\/$/, '')}/iframe.html`; } async function openStorybookPage(browser, storybookUrl, resolver) { if (!_utils.LOCALHOST_REGEXP.test(storybookUrl)) { return browser === null || browser === void 0 ? void 0 : browser.get(appendIframePath(storybookUrl)); } try { if (resolver) { browserLogger.debug('Resolving storybook url with custom resolver'); const resolvedUrl = await resolver(); browserLogger.debug(`Resolver storybook url ${resolvedUrl}`); return browser.get(appendIframePath(resolvedUrl)); } // NOTE: getUrlChecker already calls `browser.get` so we don't need another one return void (await resolveStorybookUrl(appendIframePath(storybookUrl), getUrlChecker(browser))); } catch (error) { browserLogger.error('Failed to resolve storybook URL', error instanceof Error ? error.message : ''); throw error; } } async function getBrowser(config, browserConfig) { const { gridUrl = config.gridUrl, storybookUrl: address = config.storybookUrl, limit, viewport, _storybookGlobals, ...userCapabilities } = browserConfig; void limit; const { browserName } = userCapabilities; const realAddress = address; let browser = null; // TODO Define some capabilities explicitly and define typings const capabilities = new _seleniumWebdriver.Capabilities(userCapabilities); capabilities.setPageLoadStrategy(_capabilities.PageLoadStrategy.NONE); (0, _messages.subscribeOn)('shutdown', () => { var _browser; (_browser = browser) === null || _browser === void 0 ? void 0 : _browser.quit().finally(() => // eslint-disable-next-line no-process-exit process.exit()); browser = null; }); try { var _await$browser$getSes; const url = new URL(gridUrl); url.username = url.username ? '********' : ''; url.password = url.password ? '********' : ''; browserLogger.debug(`(${browserName}) Connecting to Selenium ${_chalk.default.magenta(url.toString())}`); browser = await new _seleniumWebdriver.Builder().usingServer(gridUrl).withCapabilities(capabilities).build(); const sessionId = (_await$browser$getSes = await browser.getSession()) === null || _await$browser$getSes === void 0 ? void 0 : _await$browser$getSes.getId(); let browserHost = ''; try { const { Name } = await getSessionData(gridUrl, sessionId); if (typeof Name == 'string') browserHost = Name; } catch (_) { /* noop */ } browserLogger.debug(`(${browserName}) Connected successful with ${[_chalk.default.green(browserHost), _chalk.default.magenta(sessionId)].filter(Boolean).join(':')}`); browserLogger = (0, _loglevel.getLogger)(sessionId); _loglevelPluginPrefix.default.apply(browserLogger, { format(level) { const levelColor = _logger.colors[level.toUpperCase()]; return `[${browserName}:${_chalk.default.gray(browserHost || sessionId)}] ${levelColor(level)} =>`; } }); await (0, _utils.runSequence)([() => { var _browser2; return (_browser2 = browser) === null || _browser2 === void 0 ? void 0 : _browser2.manage().setTimeouts({ pageLoad: 5000, script: 60000 }); }, () => viewport && browser && resizeViewport(browser, viewport), () => browser && openStorybookPage(browser, realAddress, config.resolveStorybookUrl), () => browser && waitForStorybook(browser)], () => !_utils.isShuttingDown.current); } catch (originalError) { var _await$browser$getCur, _browser4; if (_utils.isShuttingDown.current) { var _browser3; (_browser3 = browser) === null || _browser3 === void 0 ? void 0 : _browser3.quit().catch(_types.noop); return null; } if (originalError instanceof Error && originalError.name == 'ResolveUrlError') throw originalError; const error = new Error(`Can't load storybook root page by URL ${(_await$browser$getCur = await ((_browser4 = browser) === null || _browser4 === void 0 ? void 0 : _browser4.getCurrentUrl())) !== null && _await$browser$getCur !== void 0 ? _await$browser$getCur : realAddress}`); if (originalError instanceof Error) error.stack = originalError.stack; throw error; } if (_storybookGlobals) { await updateStorybookGlobals(browser, _storybookGlobals); } return browser; } async function switchStory() { var _this$currentTest, _this$currentTest$ctx, _parameters$creevey; let testOrSuite = this.currentTest; if (!testOrSuite) throw new Error("Can't switch story, because test context doesn't have 'currentTest' field"); this.testScope.length = 0; this.testScope.push(this.browserName); while ((_testOrSuite = testOrSuite) !== null && _testOrSuite !== void 0 && _testOrSuite.title) { var _testOrSuite; this.testScope.push(testOrSuite.title); testOrSuite = testOrSuite.parent; } const story = (_this$currentTest = this.currentTest) === null || _this$currentTest === void 0 ? void 0 : (_this$currentTest$ctx = _this$currentTest.ctx) === null || _this$currentTest$ctx === void 0 ? void 0 : _this$currentTest$ctx.story; if (!story) throw new Error(`Current test '${this.testScope.join('/')}' context doesn't have 'story' field`); const { id, kind, name, parameters } = story; const { captureElement = '#root', waitForReady, ignoreElements } = (_parameters$creevey = parameters.creevey) !== null && _parameters$creevey !== void 0 ? _parameters$creevey : {}; browserLogger.debug(`Switching to story ${_chalk.default.cyan(kind)}/${_chalk.default.cyan(name)} by id ${_chalk.default.magenta(id)}`); await resetMousePosition(this.browser); await selectStory(this.browser, { id, kind, name }, waitForReady); browserLogger.debug(`Story ${_chalk.default.magenta(id)} ready for capturing`); if (captureElement) Object.defineProperty(this, 'captureElement', { enumerable: true, configurable: true, get: () => this.browser.findElement(_seleniumWebdriver.By.css(captureElement)) });else Reflect.deleteProperty(this, 'captureElement'); this.takeScreenshot = () => takeScreenshot(this.browser, captureElement, ignoreElements); this.testScope.reverse(); } async function insertIgnoreStyles(browser, ignoreElements) { const ignoreSelectors = Array.prototype.concat(ignoreElements).filter(Boolean); if (!ignoreSelectors.length) return null; browserLogger.debug('Hiding ignored elements before capturing'); return await browser.executeScript(function (ignoreSelectors) { return window.__CREEVEY_INSERT_IGNORE_STYLES__(ignoreSelectors); }, ignoreSelectors); } async function removeIgnoreStyles(browser, ignoreStyles) { if (ignoreStyles) { browserLogger.debug('Revert hiding ignored elements'); await browser.executeScript(function (ignoreStyles) { window.__CREEVEY_REMOVE_IGNORE_STYLES__(ignoreStyles); }, ignoreStyles); } }