UNPKG

creevey

Version:

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

371 lines (317 loc) 14.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.loadTestsFromStories = loadTestsFromStories; exports.saveStoriesJson = saveStoriesJson; exports.saveTestsJson = saveTestsJson; exports.storybookApi = void 0; var _path = _interopRequireDefault(require("path")); var _fs = require("fs"); var _cluster = require("cluster"); var _crypto = require("crypto"); var _chokidar = _interopRequireDefault(require("chokidar")); var _types = require("../types"); var _utils = require("./utils"); var _lodash = require("lodash"); var _messages = require("./messages"); var _helpers = require("./storybook/helpers"); var _logger = require("./logger"); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } let storybookApi = null; exports.storybookApi = storybookApi; function storyTestFabric(delay, testFn) { return async function storyTest() { var _testFn$call; delay ? await new Promise(resolve => setTimeout(resolve, delay)) : void 0; await ((_testFn$call = testFn === null || testFn === void 0 ? void 0 : testFn.call(this)) !== null && _testFn$call !== void 0 ? _testFn$call : this.expect(await this.takeScreenshot()).to.matchImage()); }; } function createCreeveyTest(browser, storyMeta, skipOptions, testName) { const { kind, name: story, id: storyId } = storyMeta; const path = [kind, story, testName, browser].filter(_types.isDefined); const skip = skipOptions ? (0, _utils.shouldSkip)(browser, { kind, story }, skipOptions, testName) : false; const id = (0, _crypto.createHash)('sha1').update(path.join('/')).digest('hex'); return { id, skip, browser, testName, storyPath: [...kind.split('/').map(x => x.trim()), story], storyId }; } function convertStories(browsers, stories) { const tests = {}; (Array.isArray(stories) ? stories : Object.values(stories)).forEach(storyMeta => { // TODO Skip docsOnly stories for now if (storyMeta.parameters.docsOnly) return; browsers.forEach(browserName => { var _storyMeta$parameters; const { delay, tests: storyTests, skip } = (_storyMeta$parameters = storyMeta.parameters.creevey) !== null && _storyMeta$parameters !== void 0 ? _storyMeta$parameters : {}; // typeof tests === "undefined" => rootSuite -> kindSuite -> storyTest -> [browsers.png] // typeof tests === "function" => rootSuite -> kindSuite -> storyTest -> browser -> [images.png] // typeof tests === "object" => rootSuite -> kindSuite -> storySuite -> test -> [browsers.png] // typeof tests === "object" => rootSuite -> kindSuite -> storySuite -> test -> browser -> [images.png] if (!storyTests) { const test = createCreeveyTest(browserName, storyMeta, skip); tests[test.id] = { ...test, storyId: storyMeta.id, story: storyMeta, fn: storyTestFabric(delay) }; return; } Object.entries(storyTests).forEach(([testName, testFn]) => { const test = createCreeveyTest(browserName, storyMeta, skip, testName); tests[test.id] = { ...test, storyId: storyMeta.id, story: storyMeta, fn: storyTestFabric(delay, testFn) }; }); }); }); return tests; } async function initStorybookEnvironment() { // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call (await Promise.resolve().then(() => _interopRequireWildcard(require('jsdom-global')))).default(undefined, { url: 'http://localhost' }); // NOTE Cutoff `jsdom` part from userAgent, because storybook check enviroment and create events channel if runs in browser // https://github.com/storybookjs/storybook/blob/v5.2.8/lib/core/src/client/preview/start.js#L98 // Example: "Mozilla/5.0 (linux) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/15.2.1" Object.defineProperty(window.navigator, 'userAgent', { value: window.navigator.userAgent.split(' ').filter(token => !token.startsWith('jsdom')).join(' ') }); const { logger } = await (0, _helpers.importStorybookClientLogger)(); // NOTE: Disable duplication warnings for >=6.2 storybook if (_cluster.isWorker) logger.warn = _types.noop; // NOTE: disable logger for 5.x storybook logger.debug = _types.noop; return Promise.resolve().then(() => _interopRequireWildcard(require('./storybook/entry'))); } function watchStories(channel, watcher, initialFiles) { const watchingFiles = initialFiles; let storiesByFiles = new Map(); (0, _messages.subscribeOn)('shutdown', () => void watcher.close()); watcher.add(Array.from(watchingFiles)); watcher.on('change', filePath => storiesByFiles.set(_path.default.isAbsolute(filePath) ? filePath : `./${filePath.replace(/\\/g, '/')}`, [])); watcher.on('unlink', filePath => storiesByFiles.set(_path.default.isAbsolute(filePath) ? filePath : `./${filePath.replace(/\\/g, '/')}`, [])); return data => { const stories = (0, _helpers.isStorybookVersionLessThan)(6) ? data.stories : flatStories(data); const files = new Set(Object.values(stories).map(story => story.parameters.fileName)); const addedFiles = Array.from(files).filter(filePath => !watchingFiles.has(filePath)); const removedFiles = Array.from(watchingFiles).filter(filePath => !files.has(filePath)); watcher.add(addedFiles); addedFiles.forEach(filePath => { watchingFiles.add(filePath); storiesByFiles.set(filePath, []); }); removedFiles.forEach(filePath => watchingFiles.delete(filePath)); Object.values(stories).forEach(story => { var _storiesByFiles$get; return (_storiesByFiles$get = storiesByFiles.get(story.parameters.fileName)) === null || _storiesByFiles$get === void 0 ? void 0 : _storiesByFiles$get.push(story); }); channel.emit('storiesUpdated', storiesByFiles); storiesByFiles = new Map(); }; } // TODO use the storybook version, after the fix of skip option API function flatStories({ globalParameters, kindParameters, stories }) { Object.values(stories).forEach(story => { // NOTE: Copy-paste merge parameters from storybook story.parameters = (0, _lodash.mergeWith)({}, globalParameters, kindParameters[story.kind], story.parameters, (objValue, srcValue) => Array.isArray(objValue) ? objValue.concat(srcValue) : undefined); }); return stories; } function loadStoriesFromBundle(watch) { const bundlePath = _path.default.join((0, _utils.getCreeveyCache)(), 'storybook/main.js'); if (watch) { (0, _messages.subscribeOn)('webpack', message => { if (message.type != 'rebuild succeeded') return; Object.values(global.__CREEVEY_HMR_DATA__).filter(({ callback }) => callback).forEach(({ data, callback }) => callback(data)); delete require.cache[bundlePath]; Promise.resolve(`${bundlePath}`).then(s => _interopRequireWildcard(require(s))); }); } Promise.resolve(`${bundlePath}`).then(s => _interopRequireWildcard(require(s))); } async function loadStoriesDirectly(config, { watcher, debug }) { const { toRequireContext } = await (0, _helpers.importStorybookCoreCommon)(); const { addParameters, configure } = await Promise.resolve().then(() => _interopRequireWildcard(require('./storybook/entry'))); const requireContext = await (await Promise.resolve().then(() => _interopRequireWildcard(require('./loaders/babel/register')))).default(config, debug); const preview = (() => { try { return require.resolve(`${config.storybookDir}/preview`); } catch (_) { /* noop */ } })(); const { stories } = (await Promise.resolve(`${require.resolve(`${config.storybookDir}/main`)}`).then(s => _interopRequireWildcard(require(s)))).default; const contexts = stories.map(input => { const { path: storiesPath, recursive, match } = toRequireContext(input); watcher === null || watcher === void 0 ? void 0 : watcher.add(_path.default.resolve(config.storybookDir, storiesPath)); return () => requireContext(storiesPath, recursive, new RegExp(match)); }); let disposeCallback = data => void data; Object.assign(module, { hot: { data: {}, accept() { /* noop */ }, dispose(callback) { disposeCallback = callback; } } }); async function startStorybook() { if (preview) { const { parameters, globals, globalTypes } = await Promise.resolve(`${preview}`).then(s => _interopRequireWildcard(require(s))); if (parameters) addParameters(parameters); if (globals) addParameters({ globals }); if (globalTypes) addParameters({ globalTypes }); } try { configure(contexts.map(ctx => ctx()), module, false); } catch (error) { if (_cluster.isMaster) _logger.logger.error(error); } } watcher === null || watcher === void 0 ? void 0 : watcher.add(config.storybookDir); watcher === null || watcher === void 0 ? void 0 : watcher.on('all', (_event, filename) => { var _module$hot; delete require.cache[filename]; disposeCallback((_module$hot = module.hot) === null || _module$hot === void 0 ? void 0 : _module$hot.data); void startStorybook(); }); void startStorybook(); } async function loadStorybook(config, { watch, debug }, storiesListener) { exports.storybookApi = storybookApi = await initStorybookEnvironment(); const Events = await (0, _helpers.importStorybookCoreEvents)(); const { channel } = storybookApi; channel.removeAllListeners(Events.CURRENT_STORY_WAS_SET); channel.on('storiesUpdated', storiesListener); let watcher = null; if (watch) watcher = _chokidar.default.watch([], { ignoreInitial: true }); const loadPromise = new Promise(resolve => { channel.once(Events.SET_STORIES, data => { const stories = (0, _helpers.isStorybookVersionLessThan)(6) ? data.stories : flatStories(data); const files = new Set(Object.values(stories).map(story => story.parameters.fileName)); if (watcher) channel.on(Events.SET_STORIES, watchStories(channel, watcher, files)); resolve(stories); }); }); if (config.useWebpackToExtractTests) loadStoriesFromBundle(watch);else void loadStoriesDirectly(config, { watcher, debug }); return loadPromise; } async function loadTestsFromStories(config, browsers, { watch = false, debug = false, update }) { const testIdsByFiles = new Map(); const stories = await loadStorybook(config, { debug, watch }, storiesByFiles => { const testsDiff = {}; Array.from(storiesByFiles.entries()).forEach(([filename, stories]) => { var _testIdsByFiles$get$f, _testIdsByFiles$get; const tests = convertStories(browsers, stories); const changed = Object.keys(tests); const removed = (_testIdsByFiles$get$f = (_testIdsByFiles$get = testIdsByFiles.get(filename)) === null || _testIdsByFiles$get === void 0 ? void 0 : _testIdsByFiles$get.filter(testId => !tests[testId])) !== null && _testIdsByFiles$get$f !== void 0 ? _testIdsByFiles$get$f : []; if (changed.length == 0) testIdsByFiles.delete(filename);else testIdsByFiles.set(filename, changed); Object.assign(testsDiff, tests); removed.forEach(testId => testsDiff[testId] = undefined); }); update === null || update === void 0 ? void 0 : update(testsDiff); }); const tests = convertStories(browsers, stories); Object.values(tests).filter(_types.isDefined).forEach(({ id, story: { parameters: { fileName } } }) => { var _testIdsByFiles$get2; return (// TODO Don't use filename as a key, due possible collisions if two require.context with same structure of modules are defined testIdsByFiles.set(fileName, [...((_testIdsByFiles$get2 = testIdsByFiles.get(fileName)) !== null && _testIdsByFiles$get2 !== void 0 ? _testIdsByFiles$get2 : []), id]) ); }); return tests; } function saveStoriesJson(extract) { var _storybookApi, _storiesData$stories; const outputDir = typeof extract == 'boolean' ? 'storybook-static' : extract; const storiesData = (_storybookApi = storybookApi) === null || _storybookApi === void 0 ? void 0 : _storybookApi.clientApi.store().getStoriesJsonData(); // TODO Fix args stories (0, _utils.removeProps)(storiesData !== null && storiesData !== void 0 ? storiesData : {}, ['stories', () => true, 'parameters', '__isArgsStory']); Object.values((_storiesData$stories = storiesData === null || storiesData === void 0 ? void 0 : storiesData.stories) !== null && _storiesData$stories !== void 0 ? _storiesData$stories : {}).forEach(story => (0, _types.isObject)(story) && 'parameters' in story && (0, _types.isObject)(story.parameters) && delete story.parameters.__isArgsStory); (0, _fs.mkdirSync)(outputDir, { recursive: true }); (0, _fs.writeFileSync)(_path.default.join(outputDir, 'stories.json'), JSON.stringify(storiesData, null, 2)); } function saveTestsJson(tests, dstPath = process.cwd()) { (0, _fs.mkdirSync)(dstPath, { recursive: true }); (0, _fs.writeFileSync)(_path.default.join(dstPath, 'tests.json'), // eslint-disable-next-line @typescript-eslint/no-unsafe-return JSON.stringify(tests, (_, value) => (0, _types.isFunction)(value) ? value.toString() : value, 2)); }