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