creevey
Version:
Cross-browser screenshot testing tool for Storybook with fancy UI Runner
546 lines (458 loc) • 22.4 kB
JavaScript
;
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);
}
}