diff --git a/src/cli.js b/src/cli.js index bd3bdf16eee3..d7fa100fa9a3 100644 --- a/src/cli.js +++ b/src/cli.js @@ -2,14 +2,13 @@ import runTests from './test_runner'; import { getStorybook } from '@kadira/storybook'; +import Runner from './test_runner'; import path from 'path'; -import fs from 'fs'; import program from 'commander'; import chokidar from 'chokidar'; import EventEmitter from 'events'; import loadBabelConfig from '@kadira/storybook/dist/server/babel_config'; - -const { jasmine } = global; +import { filterStorybook } from './util'; program .option('-c, --config-dir [dir-name]', @@ -27,7 +26,8 @@ const { configDir = './.storybook', polyfills: polyfillsPath = require.resolve('./default_config/polyfills.js'), loaders: loadersPath = require.resolve('./default_config/loaders.js'), -} = program + grep +} = program; const configPath = path.resolve(`${configDir}`, 'config'); @@ -41,6 +41,7 @@ require('babel-polyfill'); // load loaders const loaders = require(path.resolve(loadersPath)); + Object.keys(loaders).forEach(ext => { const loader = loaders[ext]; require.extensions[`.${ext}`] = (m, filepath) => loader(filepath); @@ -49,7 +50,15 @@ Object.keys(loaders).forEach(ext => { // load polyfills require(path.resolve(polyfillsPath)); -async function main () { +// set userAgent so storybook knows we're storyshots +if(!global.navigator) { + global.navigator = {} +}; +global.navigator.userAgent = 'storyshots'; + +const runner = new Runner(program); + +async function main() { try { require(configPath); const storybook = require('@kadira/storybook').getStorybook(); @@ -57,15 +66,15 @@ async function main () { // Channel for addons is created by storybook manager from the client side. // We need to polyfill it for the server side. - const channel = new EventEmitter() + const channel = new EventEmitter(); addons.setChannel(channel); - await runTests(storybook, program); - } catch(e) { + await runner.run(filterStorybook(storybook, grep)); + } catch (e) { console.log(e.stack); } } -if(program.watch) { +if (program.watch) { var watcher = chokidar.watch('.', { ignored: 'node_modules', // TODO: Should node_modules also be watched? persistent: true @@ -78,11 +87,11 @@ if(program.watch) { // changes were made. Object.keys(require.cache).forEach(key => { delete require.cache[key]; - }) + }); main(); }); - }) + }); } main(); diff --git a/src/default_config/polyfills.js b/src/default_config/polyfills.js index 102293e9f653..9900b9a107d9 100644 --- a/src/default_config/polyfills.js +++ b/src/default_config/polyfills.js @@ -9,7 +9,7 @@ Object.keys(document.defaultView).forEach((property) => { }); global.navigator = { - userAgent: 'node.js', + userAgent: 'storyshots', }; global.localStorage = global.window.localStorage = { diff --git a/src/test_runner/index.js b/src/test_runner/index.js new file mode 100644 index 000000000000..5a79a069a4b3 --- /dev/null +++ b/src/test_runner/index.js @@ -0,0 +1,126 @@ +import chalk from 'chalk'; +import SnapshotRunner from './snapshot_runner'; + +export default class Runner { + constructor(options) { + const { + configDir = './.storybook', + update, + updateInteractive: interactive, + } = options; + + this.configDir = configDir; + this.update = update; + this.interactive = interactive; + + this.testState = { + added: 0, + matched: 0, + unmatched: 0, + updated: 0, + obsolete: 0, + errored: 0, + }; + + this.runner = new SnapshotRunner(configDir); + } + + updateState(result) { + this.testState[result.state]++; + logState(result); + } + + completed() { + logSummary(this.testState); + } + + async run(storybook) { + const options = { + update: this.update, + interactive: this.interactive, + }; + + for (const group of storybook) { + try { + this.runner.startKind(group.kind); + this.updateState({state: 'started-kind', name: group.kind}); + for (const story of group.stories) { + try { + const result = await this.runner.runStory(story, options); + this.updateState({...result, name: story.name}); + } catch (err) { + // Error on story + this.updateState({state: 'errored', message: err, name: story.name}); + } + } + this.runner.endKind(options); + } catch (err) { + // Error on kind + this.updateState({state: 'errored-kind', message: err}); + } + } + + this.completed(); + } +} + +function logState({state, name, message}) { + switch (state) { + case 'added': + process.stdout.write(chalk.cyan(`+ ${name}: Added`)); + break; + case 'updated': + process.stdout.write(chalk.cyan(`● ${name}: Updated`)); + break; + case 'matched': + process.stdout.write(chalk.green(`✓ ${name}`)); + break; + case 'unmatched': + process.stdout.write('\n'); + process.stdout.write(chalk.red(`✗ ${name}\n`)); + process.stdout.write(' ' + message.split('\n').join('\n ')); + process.stdout.write('\n'); + break; + case 'errored': + case 'errored-kind': + process.stdout.write('\n'); + process.stdout.write(chalk.red(`✗ ${name}: ERROR\n`)); + const output = message.stack || message; + process.stdout.write(chalk.dim(' ' + output.split('\n').join('\n '))); + process.stdout.write('\n'); + break; + case 'started-kind': + process.stdout.write('\n'); + process.stdout.write(chalk.underline(name)); + break; + default: + process.stdout.write(`Error occured when testing ${state}`); + } + process.stdout.write('\n'); +} + +function logSummary(state) { + const { added, matched, unmatched, updated, errored, obsolete } = state; + const total = added + matched + unmatched + updated + errored; + process.stdout.write(chalk.bold('Test summary\n')); + process.stdout.write(`> ${total} stories tested.\n`); + if (matched > 0) { + process.stdout.write(chalk.green(`> ${matched}/${total} stories matched with snapshots.\n`)); + } + if (unmatched > 0) { + process.stdout.write(chalk.red(`> ${unmatched}/${total} differ from snapshots.\n`)); + } + if (updated > 0) { + process.stdout.write(chalk.cyan(`> ${updated} snapshots updated to match current stories.\n`)); + } + if (added > 0) { + process.stdout.write(chalk.cyan(`> ${added} snapshots newly added.\n`)); + } + if (errored > 0) { + process.stdout.write(chalk.red(`> ${errored} tests errored.\n`)); + } + if (obsolete > 0) { + process.stdout.write(chalk.cyan(`> ${obsolete} unused snapshots remaining. Run with -u to remove them.\n`)); + } +} + diff --git a/src/test_runner/snapshot_runner.js b/src/test_runner/snapshot_runner.js new file mode 100644 index 000000000000..31a819facfee --- /dev/null +++ b/src/test_runner/snapshot_runner.js @@ -0,0 +1,95 @@ +import path from 'path'; +import jestSnapshot from 'jest-snapshot'; +import ReactTestRenderer from 'react-test-renderer'; +import diff from 'jest-diff'; +import promptly from 'promptly'; + +export default class SnapshotRunner { + constructor(configDir) { + this.configDir = configDir; + this.kind = ''; + } + + startKind(kind) { + const filePath = path.resolve(this.configDir, kind); + + const fakeJasmine = { + Spec: () => {} + }; + this.state = jestSnapshot.getSnapshotState(fakeJasmine, filePath); + this.kind = kind; + } + + async runStory(story, {update, interactive}) { + this.state.setSpecName(story.name); + this.state.setCounter(0); + const snapshot = this.state.snapshot; + + const key = story.name; + const hasSnapshot = snapshot.has(key); + const context = { kind: this.kind, story }; + const tree = story.render(context); + const renderer = ReactTestRenderer.create(tree); + const actual = renderer.toJSON(); + + if (!snapshot.fileExists() || !hasSnapshot) { + // If the file does not exist of snapshot of this name is not present + // add it. + snapshot.add(key, actual); + return {state: 'added'}; + } + + const matches = snapshot.matches(key, actual); + const pass = matches.pass; + if (pass) { + // Snapshot matches with the story + return {state: 'matched'}; + } + + // Snapshot does not match story + if (update) { + snapshot.add(key, actual); + return {state: 'updated'}; + } + + const diffMessage = diff( + matches.expected.trim(), + matches.actual.trim(), + { + aAnnotation: 'Snapshot', + bAnnotation: 'Current story', + }, + ); + + if (interactive) { + const shouldUpdate = await this.confirmUpate(diffMessage); + if (shouldUpdate) { + snapshot.add(key, actual); + return {state: 'updated'}; + } + } + + return {state: 'unmatched', message: diffMessage}; + } + + endKind({update}) { + const snapshot = this.state.snapshot; + if (update) { + snapshot.removeUncheckedKeys(); + } + snapshot.save(update); + } + + async confirmUpate(diffMessage) { + process.stdout.write('\nReceived story is different from stored snapshot.\n'); + process.stdout.write(' ' + diffMessage.split('\n').join('\n ')); + let ans = await promptly.prompt('Should this snapshot be updated?(y/n)'); + while (ans !== 'y' && ans !== 'n') { + process.stdout.write('Enter only y (yes) or n (no)\n'); + ans = await promptly.prompt('Should this snapshot be updated?(y/n)'); + } + process.stdout.write('\n'); + + return ans === 'y'; + } +}