diff options
Diffstat (limited to 'node_modules/ava/lib')
20 files changed, 897 insertions, 305 deletions
diff --git a/node_modules/ava/lib/assert.js b/node_modules/ava/lib/assert.js index c16e11a1a..a0e9fe82c 100644 --- a/node_modules/ava/lib/assert.js +++ b/node_modules/ava/lib/assert.js @@ -1,12 +1,32 @@ 'use strict'; +const concordance = require('concordance'); const coreAssert = require('core-assert'); -const deepEqual = require('lodash.isequal'); const observableToPromise = require('observable-to-promise'); const isObservable = require('is-observable'); const isPromise = require('is-promise'); -const jestDiff = require('jest-diff'); +const concordanceOptions = require('./concordance-options').default; +const concordanceDiffOptions = require('./concordance-options').diff; const enhanceAssert = require('./enhance-assert'); -const formatAssertError = require('./format-assert-error'); +const snapshotManager = require('./snapshot-manager'); + +function formatDescriptorDiff(actualDescriptor, expectedDescriptor, options) { + options = Object.assign({}, options, concordanceDiffOptions); + return { + label: 'Difference:', + formatted: concordance.diffDescriptors(actualDescriptor, expectedDescriptor, options) + }; +} + +function formatDescriptorWithLabel(label, descriptor) { + return { + label, + formatted: concordance.formatDescriptor(descriptor, concordanceOptions) + }; +} + +function formatWithLabel(label, value) { + return formatDescriptorWithLabel(label, concordance.describe(value, concordanceOptions)); +} class AssertionError extends Error { constructor(opts) { @@ -19,6 +39,11 @@ class AssertionError extends Error { this.operator = opts.operator; this.values = opts.values || []; + // Raw expected and actual objects are stored for custom reporters + // (such as wallaby.js), that manage worker processes directly and + // use the values for custom diff views + this.raw = opts.raw; + // Reserved for power-assert statements this.statements = []; @@ -41,7 +66,6 @@ function wrapAssertions(callbacks) { const fail = callbacks.fail; const noop = () => {}; - const makeNoop = () => noop; const makeRethrow = reason => () => { throw reason; }; @@ -59,31 +83,27 @@ function wrapAssertions(callbacks) { }, is(actual, expected, message) { - if (actual === expected) { + if (Object.is(actual, expected)) { pass(this); } else { - const diff = formatAssertError.formatDiff(actual, expected); - const values = diff ? [diff] : [ - formatAssertError.formatWithLabel('Actual:', actual), - formatAssertError.formatWithLabel('Must be strictly equal to:', expected) - ]; - + const actualDescriptor = concordance.describe(actual, concordanceOptions); + const expectedDescriptor = concordance.describe(expected, concordanceOptions); fail(this, new AssertionError({ assertion: 'is', message, - operator: '===', - values + raw: {actual, expected}, + values: [formatDescriptorDiff(actualDescriptor, expectedDescriptor)] })); } }, not(actual, expected, message) { - if (actual === expected) { + if (Object.is(actual, expected)) { fail(this, new AssertionError({ assertion: 'not', message, - operator: '!==', - values: [formatAssertError.formatWithLabel('Value is strictly equal:', actual)] + raw: {actual, expected}, + values: [formatWithLabel('Value is the same as:', actual)] })); } else { pass(this); @@ -91,29 +111,30 @@ function wrapAssertions(callbacks) { }, deepEqual(actual, expected, message) { - if (deepEqual(actual, expected)) { + const result = concordance.compare(actual, expected, concordanceOptions); + if (result.pass) { pass(this); } else { - const diff = formatAssertError.formatDiff(actual, expected); - const values = diff ? [diff] : [ - formatAssertError.formatWithLabel('Actual:', actual), - formatAssertError.formatWithLabel('Must be deeply equal to:', expected) - ]; - + const actualDescriptor = result.actual || concordance.describe(actual, concordanceOptions); + const expectedDescriptor = result.expected || concordance.describe(expected, concordanceOptions); fail(this, new AssertionError({ assertion: 'deepEqual', message, - values + raw: {actual, expected}, + values: [formatDescriptorDiff(actualDescriptor, expectedDescriptor)] })); } }, notDeepEqual(actual, expected, message) { - if (deepEqual(actual, expected)) { + const result = concordance.compare(actual, expected, concordanceOptions); + if (result.pass) { + const actualDescriptor = result.actual || concordance.describe(actual, concordanceOptions); fail(this, new AssertionError({ assertion: 'notDeepEqual', message, - values: [formatAssertError.formatWithLabel('Value is deeply equal:', actual)] + raw: {actual, expected}, + values: [formatDescriptorWithLabel('Value is deeply equal:', actualDescriptor)] })); } else { pass(this); @@ -131,7 +152,7 @@ function wrapAssertions(callbacks) { assertion: 'throws', improperUsage: true, message: '`t.throws()` must be called with a function, Promise, or Observable', - values: [formatAssertError.formatWithLabel('Called with:', fn)] + values: [formatWithLabel('Called with:', fn)] })); return; } @@ -160,15 +181,13 @@ function wrapAssertions(callbacks) { }, coreAssertThrowsErrorArg); return actual; } catch (err) { - const values = threw ? - [formatAssertError.formatWithLabel('Threw unexpected exception:', actual)] : - null; - throw new AssertionError({ assertion: 'throws', message, stack, - values + values: threw ? + [formatWithLabel('Threw unexpected exception:', actual)] : + null }); } }; @@ -176,7 +195,14 @@ function wrapAssertions(callbacks) { if (promise) { // Record stack before it gets lost in the promise chain. const stack = getStack(); - const intermediate = promise.then(makeNoop, makeRethrow).then(fn => test(fn, stack)); + const intermediate = promise.then(value => { + throw new AssertionError({ + assertion: 'throws', + message: 'Expected promise to be rejected, but it was resolved instead', + values: [formatWithLabel('Resolved with:', value)] + }); + }, reason => test(makeRethrow(reason), stack)); + pending(this, intermediate); // Don't reject the returned promise, even if the assertion fails. return intermediate.catch(noop); @@ -202,7 +228,7 @@ function wrapAssertions(callbacks) { assertion: 'notThrows', improperUsage: true, message: '`t.notThrows()` must be called with a function, Promise, or Observable', - values: [formatAssertError.formatWithLabel('Called with:', fn)] + values: [formatWithLabel('Called with:', fn)] })); return; } @@ -215,7 +241,7 @@ function wrapAssertions(callbacks) { assertion: 'notThrows', message, stack, - values: [formatAssertError.formatWithLabel('Threw:', err.actual)] + values: [formatWithLabel('Threw:', err.actual)] }); } }; @@ -242,28 +268,57 @@ function wrapAssertions(callbacks) { fail(this, new AssertionError({ assertion: 'ifError', message, - values: [formatAssertError.formatWithLabel('Error:', actual)] + values: [formatWithLabel('Error:', actual)] })); } else { pass(this); } }, - snapshot(actual, message) { - const state = this._test.getSnapshotState(); - const result = state.match(this.title, actual); + snapshot(expected, optionsOrMessage, message) { + const options = {}; + if (typeof optionsOrMessage === 'string') { + message = optionsOrMessage; + } else if (optionsOrMessage) { + options.id = optionsOrMessage.id; + } + options.expected = expected; + options.message = message; + + let result; + try { + result = this._test.compareWithSnapshot(options); + } catch (err) { + if (!(err instanceof snapshotManager.SnapshotError)) { + throw err; + } + + const improperUsage = {name: err.name, snapPath: err.snapPath}; + if (err instanceof snapshotManager.VersionMismatchError) { + improperUsage.snapVersion = err.snapVersion; + improperUsage.expectedVersion = err.expectedVersion; + } + + fail(this, new AssertionError({ + assertion: 'snapshot', + message: message || 'Could not compare snapshot', + improperUsage + })); + return; + } + if (result.pass) { pass(this); - } else { - const diff = jestDiff(result.expected.trim(), result.actual.trim(), {expand: true}) - // Remove annotation - .split('\n') - .slice(3) - .join('\n'); + } else if (result.actual) { fail(this, new AssertionError({ assertion: 'snapshot', message: message || 'Did not match snapshot', - values: [{label: 'Difference:', formatted: diff}] + values: [formatDescriptorDiff(result.actual, result.expected, {invert: true})] + })); + } else { + fail(this, new AssertionError({ + assertion: 'snapshot', + message: message || 'No snapshot available, run with --update-snapshots' })); } } @@ -276,7 +331,7 @@ function wrapAssertions(callbacks) { assertion: 'truthy', message, operator: '!!', - values: [formatAssertError.formatWithLabel('Value is not truthy:', actual)] + values: [formatWithLabel('Value is not truthy:', actual)] }); } }, @@ -287,7 +342,7 @@ function wrapAssertions(callbacks) { assertion: 'falsy', message, operator: '!', - values: [formatAssertError.formatWithLabel('Value is not falsy:', actual)] + values: [formatWithLabel('Value is not falsy:', actual)] }); } }, @@ -297,7 +352,7 @@ function wrapAssertions(callbacks) { throw new AssertionError({ assertion: 'true', message, - values: [formatAssertError.formatWithLabel('Value is not `true`:', actual)] + values: [formatWithLabel('Value is not `true`:', actual)] }); } }, @@ -307,7 +362,7 @@ function wrapAssertions(callbacks) { throw new AssertionError({ assertion: 'false', message, - values: [formatAssertError.formatWithLabel('Value is not `false`:', actual)] + values: [formatWithLabel('Value is not `false`:', actual)] }); } }, @@ -318,7 +373,7 @@ function wrapAssertions(callbacks) { assertion: 'regex', improperUsage: true, message: '`t.regex()` must be called with a string', - values: [formatAssertError.formatWithLabel('Called with:', string)] + values: [formatWithLabel('Called with:', string)] }); } if (!(regex instanceof RegExp)) { @@ -326,7 +381,7 @@ function wrapAssertions(callbacks) { assertion: 'regex', improperUsage: true, message: '`t.regex()` must be called with a regular expression', - values: [formatAssertError.formatWithLabel('Called with:', regex)] + values: [formatWithLabel('Called with:', regex)] }); } @@ -335,8 +390,8 @@ function wrapAssertions(callbacks) { assertion: 'regex', message, values: [ - formatAssertError.formatWithLabel('Value must match expression:', string), - formatAssertError.formatWithLabel('Regular expression:', regex) + formatWithLabel('Value must match expression:', string), + formatWithLabel('Regular expression:', regex) ] }); } @@ -348,7 +403,7 @@ function wrapAssertions(callbacks) { assertion: 'notRegex', improperUsage: true, message: '`t.notRegex()` must be called with a string', - values: [formatAssertError.formatWithLabel('Called with:', string)] + values: [formatWithLabel('Called with:', string)] }); } if (!(regex instanceof RegExp)) { @@ -356,7 +411,7 @@ function wrapAssertions(callbacks) { assertion: 'notRegex', improperUsage: true, message: '`t.notRegex()` must be called with a regular expression', - values: [formatAssertError.formatWithLabel('Called with:', regex)] + values: [formatWithLabel('Called with:', regex)] }); } @@ -365,8 +420,8 @@ function wrapAssertions(callbacks) { assertion: 'notRegex', message, values: [ - formatAssertError.formatWithLabel('Value must not match expression:', string), - formatAssertError.formatWithLabel('Regular expression:', regex) + formatWithLabel('Value must not match expression:', string), + formatWithLabel('Regular expression:', regex) ] }); } diff --git a/node_modules/ava/lib/ava-files.js b/node_modules/ava/lib/ava-files.js index dd9a2ee6d..cfdc9f202 100644 --- a/node_modules/ava/lib/ava-files.js +++ b/node_modules/ava/lib/ava-files.js @@ -265,7 +265,7 @@ class AvaFiles { ignored = getDefaultIgnorePatterns().concat(ignored, overrideDefaultIgnorePatterns); if (paths.length === 0) { - paths = ['package.json', '**/*.js']; + paths = ['package.json', '**/*.js', '**/*.snap']; } paths = paths.concat(this.files); diff --git a/node_modules/ava/lib/babel-config.js b/node_modules/ava/lib/babel-config.js index c3be0dcfb..62e841f05 100644 --- a/node_modules/ava/lib/babel-config.js +++ b/node_modules/ava/lib/babel-config.js @@ -5,7 +5,7 @@ const chalk = require('chalk'); const figures = require('figures'); const configManager = require('hullabaloo-config-manager'); const md5Hex = require('md5-hex'); -const mkdirp = require('mkdirp'); +const makeDir = require('make-dir'); const colors = require('./colors'); function validate(conf) { @@ -19,7 +19,7 @@ function validate(conf) { if (!conf || (typeof conf === 'string' && !isValidShortcut)) { let message = colors.error(figures.cross); message += ' Unexpected Babel configuration for AVA. '; - message += 'See ' + chalk.underline('https://github.com/avajs/ava#es2015-support') + ' for allowed values.'; + message += 'See ' + chalk.underline('https://github.com/avajs/ava#es2017-support') + ' for allowed values.'; throw new Error(message); } @@ -90,7 +90,7 @@ function build(projectDir, cacheDir, userOptions, powerAssert) { const seed = md5Hex([process.versions.node, projectDir]); // Ensure cacheDir exists - mkdirp.sync(cacheDir); + makeDir.sync(cacheDir); // The file names predict where valid options may be cached, and thus should // include the seed. @@ -136,7 +136,7 @@ function build(projectDir, cacheDir, userOptions, powerAssert) { return resolveOptions(baseConfig, cache, optionsFile, verifierFile); }) .then(cacheKeys => ({ - getOptions: require(optionsFile).getOptions, // eslint-disable-line import/no-dynamic-require + getOptions: require(optionsFile).getOptions, // Include the seed in the cache keys used to store compilation results. cacheKeys: Object.assign({seed}, cacheKeys) })); diff --git a/node_modules/ava/lib/cli.js b/node_modules/ava/lib/cli.js index f6213f107..5649a8190 100644 --- a/node_modules/ava/lib/cli.js +++ b/node_modules/ava/lib/cli.js @@ -118,6 +118,10 @@ exports.run = () => { throw new Error(colors.error(figures.cross) + ' Watch mode is not available in CI, as it prevents AVA from terminating.'); } + if (cli.flags.concurrency === '') { + throw new Error(colors.error(figures.cross) + ' The --concurrency and -c flags must be provided the maximum number of test files to run at once.'); + } + if (hasFlag('--require') || hasFlag('-r')) { throw new Error(colors.error(figures.cross) + ' The --require and -r flags are deprecated. Requirements should be configured in package.json - see documentation.'); } diff --git a/node_modules/ava/lib/concordance-options.js b/node_modules/ava/lib/concordance-options.js new file mode 100644 index 000000000..18b4b0c77 --- /dev/null +++ b/node_modules/ava/lib/concordance-options.js @@ -0,0 +1,130 @@ +'use strict'; +const ansiStyles = require('ansi-styles'); +const chalk = require('chalk'); +const stripAnsi = require('strip-ansi'); +const cloneDeepWith = require('lodash.clonedeepwith'); +const reactPlugin = require('@concordance/react'); +const options = require('./globals').options; + +// Wrap Concordance's React plugin. Change the name to avoid collisions if in +// the future users can register plugins themselves. +const avaReactPlugin = Object.assign({}, reactPlugin, {name: 'ava-plugin-react'}); +const plugins = [avaReactPlugin]; + +const forceColor = new chalk.constructor({enabled: true}); + +const colorTheme = { + boolean: ansiStyles.yellow, + circular: forceColor.grey('[Circular]'), + date: { + invalid: forceColor.red('invalid'), + value: ansiStyles.blue + }, + diffGutters: { + actual: forceColor.red('-') + ' ', + expected: forceColor.green('+') + ' ', + padding: ' ' + }, + error: { + ctor: {open: ansiStyles.grey.open + '(', close: ')' + ansiStyles.grey.close}, + name: ansiStyles.magenta + }, + function: { + name: ansiStyles.blue, + stringTag: ansiStyles.magenta + }, + global: ansiStyles.magenta, + item: {after: forceColor.grey(',')}, + list: {openBracket: forceColor.grey('['), closeBracket: forceColor.grey(']')}, + mapEntry: {after: forceColor.grey(',')}, + maxDepth: forceColor.grey('…'), + null: ansiStyles.yellow, + number: ansiStyles.yellow, + object: { + openBracket: forceColor.grey('{'), + closeBracket: forceColor.grey('}'), + ctor: ansiStyles.magenta, + stringTag: {open: ansiStyles.magenta.open + '@', close: ansiStyles.magenta.close}, + secondaryStringTag: {open: ansiStyles.grey.open + '@', close: ansiStyles.grey.close} + }, + property: { + after: forceColor.grey(','), + keyBracket: {open: forceColor.grey('['), close: forceColor.grey(']')}, + valueFallback: forceColor.grey('…') + }, + react: { + functionType: forceColor.grey('\u235F'), + openTag: { + start: forceColor.grey('<'), + end: forceColor.grey('>'), + selfClose: forceColor.grey('/'), + selfCloseVoid: ' ' + forceColor.grey('/') + }, + closeTag: { + open: forceColor.grey('</'), + close: forceColor.grey('>') + }, + tagName: ansiStyles.magenta, + attribute: { + separator: '=', + value: { + openBracket: forceColor.grey('{'), + closeBracket: forceColor.grey('}'), + string: { + line: {open: forceColor.blue('"'), close: forceColor.blue('"'), escapeQuote: '"'} + } + } + }, + child: { + openBracket: forceColor.grey('{'), + closeBracket: forceColor.grey('}') + } + }, + regexp: { + source: {open: ansiStyles.blue.open + '/', close: '/' + ansiStyles.blue.close}, + flags: ansiStyles.yellow + }, + stats: {separator: forceColor.grey('---')}, + string: { + open: ansiStyles.blue.open, + close: ansiStyles.blue.close, + line: {open: forceColor.blue('\''), close: forceColor.blue('\'')}, + multiline: {start: forceColor.blue('`'), end: forceColor.blue('`')}, + controlPicture: ansiStyles.grey, + diff: { + insert: { + open: ansiStyles.bgGreen.open + ansiStyles.black.open, + close: ansiStyles.black.close + ansiStyles.bgGreen.close + }, + delete: { + open: ansiStyles.bgRed.open + ansiStyles.black.open, + close: ansiStyles.black.close + ansiStyles.bgRed.close + }, + equal: ansiStyles.blue, + insertLine: { + open: ansiStyles.green.open, + close: ansiStyles.green.close + }, + deleteLine: { + open: ansiStyles.red.open, + close: ansiStyles.red.close + } + } + }, + symbol: ansiStyles.yellow, + typedArray: { + bytes: ansiStyles.yellow + }, + undefined: ansiStyles.yellow +}; + +const plainTheme = cloneDeepWith(colorTheme, value => { + if (typeof value === 'string') { + return stripAnsi(value); + } +}); + +const theme = options.color === false ? plainTheme : colorTheme; +exports.default = {maxDepth: 3, plugins, theme}; +exports.diff = {maxDepth: 1, plugins, theme}; +exports.snapshotManager = {plugins, theme: plainTheme}; diff --git a/node_modules/ava/lib/enhance-assert.js b/node_modules/ava/lib/enhance-assert.js index 7808765b7..6e127b3d6 100644 --- a/node_modules/ava/lib/enhance-assert.js +++ b/node_modules/ava/lib/enhance-assert.js @@ -1,6 +1,7 @@ 'use strict'; +const concordance = require('concordance'); const dotProp = require('dot-prop'); -const formatValue = require('./format-assert-error').formatValue; +const concordanceOptions = require('./concordance-options').default; // When adding patterns, don't forget to add to // https://github.com/avajs/babel-preset-transform-test-files/blob/master/espower-patterns.json @@ -37,7 +38,10 @@ const formatter = context => { return args .map(arg => { const range = getNode(ast, arg.espath).range; - return [computeStatement(tokens, range), formatValue(arg.value, {maxDepth: 1})]; + const statement = computeStatement(tokens, range); + + const formatted = concordance.format(arg.value, concordanceOptions); + return [statement, formatted]; }) .reverse(); }; diff --git a/node_modules/ava/lib/format-assert-error.js b/node_modules/ava/lib/format-assert-error.js deleted file mode 100644 index a899af463..000000000 --- a/node_modules/ava/lib/format-assert-error.js +++ /dev/null @@ -1,121 +0,0 @@ -'use strict'; -const prettyFormat = require('@ava/pretty-format'); -const reactTestPlugin = require('@ava/pretty-format/plugins/ReactTestComponent'); -const chalk = require('chalk'); -const diff = require('diff'); -const DiffMatchPatch = require('diff-match-patch'); -const indentString = require('indent-string'); -const globals = require('./globals'); - -function formatValue(value, options) { - return prettyFormat(value, Object.assign({ - callToJSON: false, - plugins: [reactTestPlugin], - highlight: globals.options.color !== false - }, options)); -} -exports.formatValue = formatValue; - -const cleanUp = line => { - if (line[0] === '+') { - return `${chalk.green('+')} ${line.slice(1)}`; - } - - if (line[0] === '-') { - return `${chalk.red('-')} ${line.slice(1)}`; - } - - if (line.match(/@@/)) { - return null; - } - - if (line.match(/\\ No newline/)) { - return null; - } - - return ` ${line}`; -}; - -const getType = value => { - const type = typeof value; - if (type === 'object') { - if (type === null) { - return 'null'; - } - if (Array.isArray(value)) { - return 'array'; - } - } - return type; -}; - -function formatDiff(actual, expected) { - const actualType = getType(actual); - const expectedType = getType(expected); - if (actualType !== expectedType) { - return null; - } - - if (actualType === 'array' || actualType === 'object') { - const formatted = diff.createPatch('string', formatValue(actual), formatValue(expected)) - .split('\n') - .slice(4) - .map(cleanUp) - .filter(Boolean) - .join('\n') - .trimRight(); - - return {label: 'Difference:', formatted}; - } - - if (actualType === 'string') { - const formatted = new DiffMatchPatch() - .diff_main(formatValue(actual, {highlight: false}), formatValue(expected, {highlight: false})) - .map(part => { - if (part[0] === 1) { - return chalk.bgGreen.black(part[1]); - } - - if (part[0] === -1) { - return chalk.bgRed.black(part[1]); - } - - return chalk.red(part[1]); - }) - .join('') - .trimRight(); - - return {label: 'Difference:', formatted}; - } - - return null; -} -exports.formatDiff = formatDiff; - -function formatWithLabel(label, value) { - return {label, formatted: formatValue(value)}; -} -exports.formatWithLabel = formatWithLabel; - -function formatSerializedError(error) { - if (error.statements.length === 0 && error.values.length === 0) { - return null; - } - - let result = error.values - .map(value => `${value.label}\n\n${indentString(value.formatted, 2).trimRight()}\n`) - .join('\n'); - - if (error.statements.length > 0) { - if (error.values.length > 0) { - result += '\n'; - } - - result += error.statements - .map(statement => `${statement[0]}\n${chalk.grey('=>')} ${statement[1]}\n`) - .join('\n'); - } - - return result; -} -exports.formatSerializedError = formatSerializedError; diff --git a/node_modules/ava/lib/main.js b/node_modules/ava/lib/main.js index 52618e8b7..1b03cc854 100644 --- a/node_modules/ava/lib/main.js +++ b/node_modules/ava/lib/main.js @@ -11,6 +11,7 @@ const runner = new Runner({ failWithoutAssertions: opts.failWithoutAssertions, file: opts.file, match: opts.match, + projectDir: opts.projectDir, serial: opts.serial, updateSnapshots: opts.updateSnapshots }); diff --git a/node_modules/ava/lib/process-adapter.js b/node_modules/ava/lib/process-adapter.js index b50f37398..5f9c0d79d 100644 --- a/node_modules/ava/lib/process-adapter.js +++ b/node_modules/ava/lib/process-adapter.js @@ -94,7 +94,7 @@ exports.installDependencyTracking = (dependencies, testPath) => { require.extensions[ext] = (module, filename) => { if (filename !== testPath) { - dependencies.push(filename); + dependencies.add(filename); } wrappedHandler(module, filename); diff --git a/node_modules/ava/lib/reporters/format-serialized-error.js b/node_modules/ava/lib/reporters/format-serialized-error.js new file mode 100644 index 000000000..6ab59e47c --- /dev/null +++ b/node_modules/ava/lib/reporters/format-serialized-error.js @@ -0,0 +1,26 @@ +'use strict'; +const chalk = require('chalk'); +const trimOffNewlines = require('trim-off-newlines'); + +function formatSerializedError(error) { + const printMessage = error.values.length === 0 ? + Boolean(error.message) : + !error.values[0].label.startsWith(error.message); + + if (error.statements.length === 0 && error.values.length === 0) { + return {formatted: null, printMessage}; + } + + let formatted = ''; + for (const value of error.values) { + formatted += `${value.label}\n\n${trimOffNewlines(value.formatted)}\n\n`; + } + + for (const statement of error.statements) { + formatted += `${statement[0]}\n${chalk.grey('=>')} ${trimOffNewlines(statement[1])}\n\n`; + } + + formatted = trimOffNewlines(formatted); + return {formatted, printMessage}; +} +module.exports = formatSerializedError; diff --git a/node_modules/ava/lib/reporters/improper-usage-messages.js b/node_modules/ava/lib/reporters/improper-usage-messages.js index 0a2626638..298ef79a5 100644 --- a/node_modules/ava/lib/reporters/improper-usage-messages.js +++ b/node_modules/ava/lib/reporters/improper-usage-messages.js @@ -7,15 +7,48 @@ exports.forError = error => { } const assertion = error.assertion; - if (assertion !== 'throws' || !assertion === 'notThrows') { - return null; - } - - return `Try wrapping the first argument to \`t.${assertion}()\` in a function: + if (assertion === 'throws' || assertion === 'notThrows') { + return `Try wrapping the first argument to \`t.${assertion}()\` in a function: ${chalk.cyan(`t.${assertion}(() => { `)}${chalk.grey('/* your code here */')}${chalk.cyan(' })')} Visit the following URL for more details: ${chalk.blue.underline('https://github.com/avajs/ava#throwsfunctionpromise-error-message')}`; + } else if (assertion === 'snapshot') { + const name = error.improperUsage.name; + const snapPath = error.improperUsage.snapPath; + + if (name === 'ChecksumError') { + return `The snapshot file is corrupted. + +File path: ${chalk.yellow(snapPath)} + +Please run AVA again with the ${chalk.cyan('--update-snapshots')} flag to recreate it.`; + } + + if (name === 'LegacyError') { + return `The snapshot file was created with AVA 0.19. It's not supported by this AVA version. + +File path: ${chalk.yellow(snapPath)} + +Please run AVA again with the ${chalk.cyan('--update-snapshots')} flag to upgrade.`; + } + + if (name === 'VersionMismatchError') { + const snapVersion = error.improperUsage.snapVersion; + const expectedVersion = error.improperUsage.expectedVersion; + const upgradeMessage = snapVersion < expectedVersion ? + `Please run AVA again with the ${chalk.cyan('--update-snapshots')} flag to upgrade.` : + 'You should upgrade AVA.'; + + return `The snapshot file is v${snapVersion}, but only v${expectedVersion} is supported. + +File path: ${chalk.yellow(snapPath)} + +${upgradeMessage}`; + } + } + + return null; }; diff --git a/node_modules/ava/lib/reporters/mini.js b/node_modules/ava/lib/reporters/mini.js index df481a76a..8acfab8e7 100644 --- a/node_modules/ava/lib/reporters/mini.js +++ b/node_modules/ava/lib/reporters/mini.js @@ -8,33 +8,14 @@ const chalk = require('chalk'); const cliTruncate = require('cli-truncate'); const cross = require('figures').cross; const indentString = require('indent-string'); -const formatAssertError = require('../format-assert-error'); +const ansiEscapes = require('ansi-escapes'); +const trimOffNewlines = require('trim-off-newlines'); const extractStack = require('../extract-stack'); const codeExcerpt = require('../code-excerpt'); const colors = require('../colors'); +const formatSerializedError = require('./format-serialized-error'); const improperUsageMessages = require('./improper-usage-messages'); -// TODO(@jamestalamge): This should be fixed in log-update and ansi-escapes once we are confident it's a good solution. -const CSI = '\u001B['; -const ERASE_LINE = CSI + '2K'; -const CURSOR_TO_COLUMN_0 = CSI + '0G'; -const CURSOR_UP = CSI + '1A'; - -// Returns a string that will erase `count` lines from the end of the terminal. -function eraseLines(count) { - let clear = ''; - - for (let i = 0; i < count; i++) { - clear += ERASE_LINE + (i < count - 1 ? CURSOR_UP : ''); - } - - if (count) { - clear += CURSOR_TO_COLUMN_0; - } - - return clear; -} - class MiniReporter { constructor(options) { this.options = Object.assign({}, options); @@ -151,38 +132,37 @@ class MiniReporter { time = chalk.gray.dim('[' + new Date().toLocaleTimeString('en-US', {hour12: false}) + ']'); } - let status = this.reportCounts(time); + let status = this.reportCounts(time) + '\n'; if (this.rejectionCount > 0) { - status += '\n ' + colors.error(this.rejectionCount, plur('rejection', this.rejectionCount)); + status += ' ' + colors.error(this.rejectionCount, plur('rejection', this.rejectionCount)) + '\n'; } if (this.exceptionCount > 0) { - status += '\n ' + colors.error(this.exceptionCount, plur('exception', this.exceptionCount)); + status += ' ' + colors.error(this.exceptionCount, plur('exception', this.exceptionCount)) + '\n'; } if (runStatus.previousFailCount > 0) { - status += '\n ' + colors.error(runStatus.previousFailCount, 'previous', plur('failure', runStatus.previousFailCount), 'in test files that were not rerun'); + status += ' ' + colors.error(runStatus.previousFailCount, 'previous', plur('failure', runStatus.previousFailCount), 'in test files that were not rerun') + '\n'; } if (this.knownFailureCount > 0) { for (const test of runStatus.knownFailures) { const title = test.title; - status += '\n\n ' + colors.title(title); + status += '\n ' + colors.title(title) + '\n'; // TODO: Output description with link // status += colors.stack(description); } } + status += '\n'; if (this.failCount > 0) { - runStatus.errors.forEach((test, index) => { + runStatus.errors.forEach(test => { if (!test.error) { return; } - const beforeSpacing = index === 0 ? '\n\n' : '\n\n\n\n'; - - status += beforeSpacing + ' ' + colors.title(test.title) + '\n'; + status += ' ' + colors.title(test.title) + '\n'; if (test.error.source) { status += ' ' + colors.errorSource(test.error.source.file + ':' + test.error.source.line) + '\n'; @@ -192,28 +172,32 @@ class MiniReporter { } } - if (test.error.message) { - status += '\n' + indentString(test.error.message, 2) + '\n'; - } - if (test.error.avaAssertionError) { - const formatted = formatAssertError.formatSerializedError(test.error); - if (formatted) { - status += '\n' + indentString(formatted, 2); + const result = formatSerializedError(test.error); + if (result.printMessage) { + status += '\n' + indentString(test.error.message, 2) + '\n'; + } + + if (result.formatted) { + status += '\n' + indentString(result.formatted, 2) + '\n'; } const message = improperUsageMessages.forError(test.error); if (message) { status += '\n' + indentString(message, 2) + '\n'; } + } else if (test.error.message) { + status += '\n' + indentString(test.error.message, 2) + '\n'; } if (test.error.stack) { const extracted = extractStack(test.error.stack); if (extracted.includes('\n')) { - status += '\n' + indentString(colors.errorStack(extracted), 2); + status += '\n' + indentString(colors.errorStack(extracted), 2) + '\n'; } } + + status += '\n\n\n'; }); } @@ -225,7 +209,7 @@ class MiniReporter { } if (err.type === 'exception' && err.name === 'AvaError') { - status += '\n\n ' + colors.error(cross + ' ' + err.message); + status += ' ' + colors.error(cross + ' ' + err.message) + '\n\n'; } else { const title = err.type === 'rejection' ? 'Unhandled Rejection' : 'Uncaught Exception'; let description = err.stack ? err.stack.trimRight() : JSON.stringify(err); @@ -233,23 +217,23 @@ class MiniReporter { const errorTitle = err.name ? description[0] : 'Threw non-error: ' + description[0]; const errorStack = description.slice(1).join('\n'); - status += '\n\n ' + colors.title(title) + '\n'; + status += ' ' + colors.title(title) + '\n'; status += ' ' + colors.stack(errorTitle) + '\n'; - status += colors.errorStack(errorStack); + status += colors.errorStack(errorStack) + '\n\n'; } }); } if (runStatus.failFastEnabled === true && runStatus.remainingCount > 0 && runStatus.failCount > 0) { const remaining = 'At least ' + runStatus.remainingCount + ' ' + plur('test was', 'tests were', runStatus.remainingCount) + ' skipped.'; - status += '\n\n ' + colors.information('`--fail-fast` is on. ' + remaining); + status += ' ' + colors.information('`--fail-fast` is on. ' + remaining) + '\n\n'; } if (runStatus.hasExclusive === true && runStatus.remainingCount > 0) { - status += '\n\n ' + colors.information('The .only() modifier is used in some tests.', runStatus.remainingCount, plur('test', runStatus.remainingCount), plur('was', 'were', runStatus.remainingCount), 'not run'); + status += ' ' + colors.information('The .only() modifier is used in some tests.', runStatus.remainingCount, plur('test', runStatus.remainingCount), plur('was', 'were', runStatus.remainingCount), 'not run'); } - return status + '\n\n'; + return '\n' + trimOffNewlines(status) + '\n'; } section() { return '\n' + chalk.gray.dim('\u2500'.repeat(process.stdout.columns || 80)); @@ -284,7 +268,7 @@ class MiniReporter { } // Erase the existing status message, plus the last log line. - str += eraseLines(ct); + str += ansiEscapes.eraseLines(ct); // Rewrite the last log line. str += lastLine; diff --git a/node_modules/ava/lib/reporters/verbose.js b/node_modules/ava/lib/reporters/verbose.js index 1be43ce5e..cd47683e8 100644 --- a/node_modules/ava/lib/reporters/verbose.js +++ b/node_modules/ava/lib/reporters/verbose.js @@ -4,10 +4,11 @@ const prettyMs = require('pretty-ms'); const figures = require('figures'); const chalk = require('chalk'); const plur = require('plur'); -const formatAssertError = require('../format-assert-error'); +const trimOffNewlines = require('trim-off-newlines'); const extractStack = require('../extract-stack'); const codeExcerpt = require('../code-excerpt'); const colors = require('../colors'); +const formatSerializedError = require('./format-serialized-error'); const improperUsageMessages = require('./improper-usage-messages'); class VerboseReporter { @@ -70,7 +71,7 @@ class VerboseReporter { return output; } finish(runStatus) { - let output = '\n'; + let output = ''; const lines = [ runStatus.failCount > 0 ? @@ -86,23 +87,23 @@ class VerboseReporter { if (lines.length > 0) { lines[0] += ' ' + chalk.gray.dim('[' + new Date().toLocaleTimeString('en-US', {hour12: false}) + ']'); - output += lines.join('\n'); + output += lines.join('\n') + '\n'; } if (runStatus.knownFailureCount > 0) { runStatus.knownFailures.forEach(test => { - output += '\n\n\n ' + colors.error(test.title); + output += '\n\n ' + colors.error(test.title) + '\n'; }); } + output += '\n'; if (runStatus.failCount > 0) { - runStatus.tests.forEach((test, index) => { + runStatus.tests.forEach(test => { if (!test.error) { return; } - const beforeSpacing = index === 0 ? '\n\n' : '\n\n\n\n'; - output += beforeSpacing + ' ' + colors.title(test.title) + '\n'; + output += ' ' + colors.title(test.title) + '\n'; if (test.error.source) { output += ' ' + colors.errorSource(test.error.source.file + ':' + test.error.source.line) + '\n'; @@ -112,41 +113,45 @@ class VerboseReporter { } } - if (test.error.message) { - output += '\n' + indentString(test.error.message, 2) + '\n'; - } - if (test.error.avaAssertionError) { - const formatted = formatAssertError.formatSerializedError(test.error); - if (formatted) { - output += '\n' + indentString(formatted, 2); + const result = formatSerializedError(test.error); + if (result.printMessage) { + output += '\n' + indentString(test.error.message, 2) + '\n'; + } + + if (result.formatted) { + output += '\n' + indentString(result.formatted, 2) + '\n'; } const message = improperUsageMessages.forError(test.error); if (message) { output += '\n' + indentString(message, 2) + '\n'; } + } else if (test.error.message) { + output += '\n' + indentString(test.error.message, 2) + '\n'; } if (test.error.stack) { const extracted = extractStack(test.error.stack); if (extracted.includes('\n')) { - output += '\n' + indentString(colors.errorStack(extracted), 2); + output += '\n' + indentString(colors.errorStack(extracted), 2) + '\n'; } } + + output += '\n\n\n'; }); } if (runStatus.failFastEnabled === true && runStatus.remainingCount > 0 && runStatus.failCount > 0) { const remaining = 'At least ' + runStatus.remainingCount + ' ' + plur('test was', 'tests were', runStatus.remainingCount) + ' skipped.'; - output += '\n\n\n ' + colors.information('`--fail-fast` is on. ' + remaining); + output += ' ' + colors.information('`--fail-fast` is on. ' + remaining) + '\n\n'; } if (runStatus.hasExclusive === true && runStatus.remainingCount > 0) { - output += '\n\n\n ' + colors.information('The .only() modifier is used in some tests.', runStatus.remainingCount, plur('test', runStatus.remainingCount), plur('was', 'were', runStatus.remainingCount), 'not run'); + output += ' ' + colors.information('The .only() modifier is used in some tests.', runStatus.remainingCount, plur('test', runStatus.remainingCount), plur('was', 'were', runStatus.remainingCount), 'not run'); } - return output + '\n'; + return '\n' + trimOffNewlines(output) + '\n'; } section() { return chalk.gray.dim('\u2500'.repeat(process.stdout.columns || 80)); diff --git a/node_modules/ava/lib/run-status.js b/node_modules/ava/lib/run-status.js index 6526f7bdc..8e095655a 100644 --- a/node_modules/ava/lib/run-status.js +++ b/node_modules/ava/lib/run-status.js @@ -40,6 +40,7 @@ class RunStatus extends EventEmitter { this.stats = []; this.tests = []; this.failFastEnabled = opts.failFast || false; + this.updateSnapshots = opts.updateSnapshots || false; autoBind(this); } @@ -73,6 +74,7 @@ class RunStatus extends EventEmitter { } handleTeardown(data) { this.emit('dependencies', data.file, data.dependencies, this); + this.emit('touchedFiles', data.touchedFiles); } handleStats(stats) { this.emit('stats', stats, this); diff --git a/node_modules/ava/lib/runner.js b/node_modules/ava/lib/runner.js index 5f0edacb2..bda2132fd 100644 --- a/node_modules/ava/lib/runner.js +++ b/node_modules/ava/lib/runner.js @@ -2,9 +2,9 @@ const EventEmitter = require('events'); const path = require('path'); const Bluebird = require('bluebird'); -const jestSnapshot = require('jest-snapshot'); const optionChain = require('option-chain'); const matcher = require('matcher'); +const snapshotManager = require('./snapshot-manager'); const TestCollection = require('./test-collection'); const validateTest = require('./validate-test'); @@ -49,16 +49,17 @@ class Runner extends EventEmitter { this.file = options.file; this.match = options.match || []; + this.projectDir = options.projectDir; this.serial = options.serial; this.updateSnapshots = options.updateSnapshots; this.hasStarted = false; this.results = []; - this.snapshotState = null; + this.snapshots = null; this.tests = new TestCollection({ bail: options.bail, failWithoutAssertions: options.failWithoutAssertions, - getSnapshotState: () => this.getSnapshotState() + compareTestSnapshot: this.compareTestSnapshot.bind(this) }); this.chain = optionChain(chainableMethods, (opts, args) => { @@ -179,26 +180,31 @@ class Runner extends EventEmitter { return stats; } - getSnapshotState() { - if (this.snapshotState) { - return this.snapshotState; + compareTestSnapshot(options) { + if (!this.snapshots) { + this.snapshots = snapshotManager.load({ + name: path.basename(this.file), + projectDir: this.projectDir, + relFile: path.relative(this.projectDir, this.file), + testDir: path.dirname(this.file), + updating: this.updateSnapshots + }); + this.emit('dependency', this.snapshots.snapPath); } - const name = path.basename(this.file) + '.snap'; - const dir = path.dirname(this.file); - - const snapshotPath = path.join(dir, '__snapshots__', name); - const testPath = this.file; - const update = this.updateSnapshots; - - const state = jestSnapshot.initializeSnapshotState(testPath, update, snapshotPath); - this.snapshotState = state; - return state; + return this.snapshots.compare(options); } saveSnapshotState() { - if (this.snapshotState) { - this.snapshotState.save(this.updateSnapshots); + if (this.snapshots) { + const files = this.snapshots.save(); + if (files) { + this.emit('touched', files); + } + } else if (this.updateSnapshots) { + // TODO: There may be unused snapshot files if no test caused the + // snapshots to be loaded. Prune them. But not if tests (including hooks!) + // were skipped. Perhaps emit a warning if this occurs? } } diff --git a/node_modules/ava/lib/snapshot-manager.js b/node_modules/ava/lib/snapshot-manager.js new file mode 100644 index 000000000..ea1246585 --- /dev/null +++ b/node_modules/ava/lib/snapshot-manager.js @@ -0,0 +1,396 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); + +const writeFileAtomic = require('@ava/write-file-atomic'); +const concordance = require('concordance'); +const indentString = require('indent-string'); +const makeDir = require('make-dir'); +const md5Hex = require('md5-hex'); +const Buffer = require('safe-buffer').Buffer; + +const concordanceOptions = require('./concordance-options').snapshotManager; + +// Increment if encoding layout or Concordance serialization versions change. Previous AVA versions will not be able to +// decode buffers generated by a newer version, so changing this value will require a major version bump of AVA itself. +// The version is encoded as an unsigned 16 bit integer. +const VERSION = 1; + +const VERSION_HEADER = Buffer.alloc(2); +VERSION_HEADER.writeUInt16LE(VERSION); + +// The decoder matches on the trailing newline byte (0x0A). +const READABLE_PREFIX = Buffer.from(`AVA Snapshot v${VERSION}\n`, 'ascii'); +const REPORT_SEPARATOR = Buffer.from('\n\n', 'ascii'); +const REPORT_TRAILING_NEWLINE = Buffer.from('\n', 'ascii'); + +const MD5_HASH_LENGTH = 16; + +class SnapshotError extends Error { + constructor(message, snapPath) { + super(message); + this.name = 'SnapshotError'; + this.snapPath = snapPath; + } +} +exports.SnapshotError = SnapshotError; + +class ChecksumError extends SnapshotError { + constructor(snapPath) { + super('Checksum mismatch', snapPath); + this.name = 'ChecksumError'; + } +} +exports.ChecksumError = ChecksumError; + +class VersionMismatchError extends SnapshotError { + constructor(snapPath, version) { + super('Unexpected snapshot version', snapPath); + this.name = 'VersionMismatchError'; + this.snapVersion = version; + this.expectedVersion = VERSION; + } +} +exports.VersionMismatchError = VersionMismatchError; + +const LEGACY_SNAPSHOT_HEADER = Buffer.from('// Jest Snapshot v1'); +function isLegacySnapshot(buffer) { + return LEGACY_SNAPSHOT_HEADER.equals(buffer.slice(0, LEGACY_SNAPSHOT_HEADER.byteLength)); +} + +class LegacyError extends SnapshotError { + constructor(snapPath) { + super('Legacy snapshot file', snapPath); + this.name = 'LegacyError'; + } +} +exports.LegacyError = LegacyError; + +function tryRead(file) { + try { + return fs.readFileSync(file); + } catch (err) { + if (err.code === 'ENOENT') { + return null; + } + + throw err; + } +} + +function withoutLineEndings(buffer) { + let newLength = buffer.byteLength - 1; + while (buffer[newLength] === 0x0A || buffer[newLength] === 0x0D) { + newLength--; + } + return buffer.slice(0, newLength); +} + +function formatEntry(label, descriptor) { + if (label) { + label = `> ${label}\n\n`; + } + const codeBlock = indentString(concordance.formatDescriptor(descriptor, concordanceOptions), 4); + return Buffer.from(label + codeBlock, 'utf8'); +} + +function combineEntries(entries) { + const buffers = []; + let byteLength = 0; + + const sortedKeys = Array.from(entries.keys()).sort(); + for (const key of sortedKeys) { + const keyBuffer = Buffer.from(`\n\n## ${key}\n\n`, 'utf8'); + buffers.push(keyBuffer); + byteLength += keyBuffer.byteLength; + + const formattedEntries = entries.get(key); + const last = formattedEntries[formattedEntries.length - 1]; + for (const entry of formattedEntries) { + buffers.push(entry); + byteLength += entry.byteLength; + + if (entry !== last) { + buffers.push(REPORT_SEPARATOR); + byteLength += REPORT_SEPARATOR.byteLength; + } + } + } + + return {buffers, byteLength}; +} + +function generateReport(relFile, snapFile, entries) { + const combined = combineEntries(entries); + const buffers = combined.buffers; + let byteLength = combined.byteLength; + + const header = Buffer.from(`# Snapshot report for \`${relFile}\` + +The actual snapshot is saved in \`${snapFile}\`. + +Generated by [AVA](https://ava.li).`, 'utf8'); + buffers.unshift(header); + byteLength += header.byteLength; + + buffers.push(REPORT_TRAILING_NEWLINE); + byteLength += REPORT_TRAILING_NEWLINE.byteLength; + return Buffer.concat(buffers, byteLength); +} + +function appendReportEntries(existingReport, entries) { + const combined = combineEntries(entries); + const buffers = combined.buffers; + let byteLength = combined.byteLength; + + const prepend = withoutLineEndings(existingReport); + buffers.unshift(prepend); + byteLength += prepend.byteLength; + + return Buffer.concat(buffers, byteLength); +} + +function encodeSnapshots(buffersByHash) { + const buffers = []; + let byteOffset = 0; + + // Entry start and end pointers are relative to the header length. This means + // it's possible to append new entries to an existing snapshot file, without + // having to rewrite pointers for existing entries. + const headerLength = Buffer.alloc(4); + buffers.push(headerLength); + byteOffset += 4; + + // Allows 65535 hashes (tests or identified snapshots) per file. + const numHashes = Buffer.alloc(2); + numHashes.writeUInt16LE(buffersByHash.size); + buffers.push(numHashes); + byteOffset += 2; + + const entries = []; + for (const pair of buffersByHash) { + const hash = pair[0]; + const snapshotBuffers = pair[1]; + + buffers.push(Buffer.from(hash, 'hex')); + byteOffset += MD5_HASH_LENGTH; + + // Allows 65535 snapshots per hash. + const numSnapshots = Buffer.alloc(2); + numSnapshots.writeUInt16LE(snapshotBuffers.length, 0); + buffers.push(numSnapshots); + byteOffset += 2; + + for (const value of snapshotBuffers) { + // Each pointer is 32 bits, restricting the total, uncompressed buffer to + // 4 GiB. + const start = Buffer.alloc(4); + const end = Buffer.alloc(4); + entries.push({start, end, value}); + + buffers.push(start, end); + byteOffset += 8; + } + } + + headerLength.writeUInt32LE(byteOffset, 0); + + let bodyOffset = 0; + for (const entry of entries) { + const start = bodyOffset; + const end = bodyOffset + entry.value.byteLength; + entry.start.writeUInt32LE(start, 0); + entry.end.writeUInt32LE(end, 0); + buffers.push(entry.value); + bodyOffset = end; + } + byteOffset += bodyOffset; + + const compressed = zlib.gzipSync(Buffer.concat(buffers, byteOffset)); + const md5sum = crypto.createHash('md5').update(compressed).digest(); + return Buffer.concat([ + READABLE_PREFIX, + VERSION_HEADER, + md5sum, + compressed + ], READABLE_PREFIX.byteLength + VERSION_HEADER.byteLength + MD5_HASH_LENGTH + compressed.byteLength); +} + +function decodeSnapshots(buffer, snapPath) { + if (isLegacySnapshot(buffer)) { + throw new LegacyError(snapPath); + } + + // The version starts after the readable prefix, which is ended by a newline + // byte (0x0A). + const versionOffset = buffer.indexOf(0x0A) + 1; + const version = buffer.readUInt16LE(versionOffset); + if (version !== VERSION) { + throw new VersionMismatchError(snapPath, version); + } + + const md5sumOffset = versionOffset + 2; + const compressedOffset = md5sumOffset + MD5_HASH_LENGTH; + const compressed = buffer.slice(compressedOffset); + + const md5sum = crypto.createHash('md5').update(compressed).digest(); + const expectedSum = buffer.slice(md5sumOffset, compressedOffset); + if (!md5sum.equals(expectedSum)) { + throw new ChecksumError(snapPath); + } + + const decompressed = zlib.gunzipSync(compressed); + let byteOffset = 0; + + const headerLength = decompressed.readUInt32LE(byteOffset); + byteOffset += 4; + + const snapshotsByHash = new Map(); + const numHashes = decompressed.readUInt16LE(byteOffset); + byteOffset += 2; + + for (let count = 0; count < numHashes; count++) { + const hash = decompressed.toString('hex', byteOffset, byteOffset + MD5_HASH_LENGTH); + byteOffset += MD5_HASH_LENGTH; + + const numSnapshots = decompressed.readUInt16LE(byteOffset); + byteOffset += 2; + + const snapshotsBuffers = new Array(numSnapshots); + for (let index = 0; index < numSnapshots; index++) { + const start = decompressed.readUInt32LE(byteOffset) + headerLength; + byteOffset += 4; + const end = decompressed.readUInt32LE(byteOffset) + headerLength; + byteOffset += 4; + snapshotsBuffers[index] = decompressed.slice(start, end); + } + + // Allow for new entries to be appended to an existing header, which could + // lead to the same hash being present multiple times. + if (snapshotsByHash.has(hash)) { + snapshotsByHash.set(hash, snapshotsByHash.get(hash).concat(snapshotsBuffers)); + } else { + snapshotsByHash.set(hash, snapshotsBuffers); + } + } + + return snapshotsByHash; +} + +class Manager { + constructor(options) { + this.appendOnly = options.appendOnly; + this.dir = options.dir; + this.relFile = options.relFile; + this.reportFile = options.reportFile; + this.snapFile = options.snapFile; + this.snapPath = options.snapPath; + this.snapshotsByHash = options.snapshotsByHash; + + this.hasChanges = false; + this.reportEntries = new Map(); + } + + compare(options) { + const hash = md5Hex(options.belongsTo); + const entries = this.snapshotsByHash.get(hash) || []; + if (options.index > entries.length) { + throw new RangeError(`Cannot record snapshot ${options.index} for ${JSON.stringify(options.belongsTo)}, exceeds expected index of ${entries.length}`); + } + if (options.index === entries.length) { + this.record(hash, options); + return {pass: true}; + } + + const snapshotBuffer = entries[options.index]; + const actual = concordance.deserialize(snapshotBuffer, concordanceOptions); + + const expected = concordance.describe(options.expected, concordanceOptions); + const pass = concordance.compareDescriptors(actual, expected); + + return {actual, expected, pass}; + } + + record(hash, options) { + const descriptor = concordance.describe(options.expected, concordanceOptions); + + this.hasChanges = true; + const snapshot = concordance.serialize(descriptor); + if (this.snapshotsByHash.has(hash)) { + this.snapshotsByHash.get(hash).push(snapshot); + } else { + this.snapshotsByHash.set(hash, [snapshot]); + } + + const entry = formatEntry(options.label, descriptor); + if (this.reportEntries.has(options.belongsTo)) { + this.reportEntries.get(options.belongsTo).push(entry); + } else { + this.reportEntries.set(options.belongsTo, [entry]); + } + } + + save() { + if (!this.hasChanges) { + return null; + } + + const snapPath = this.snapPath; + const buffer = encodeSnapshots(this.snapshotsByHash); + + const reportPath = path.join(this.dir, this.reportFile); + const existingReport = this.appendOnly ? tryRead(reportPath) : null; + const reportBuffer = existingReport ? + appendReportEntries(existingReport, this.reportEntries) : + generateReport(this.relFile, this.snapFile, this.reportEntries); + + makeDir.sync(this.dir); + const tmpSnapPath = writeFileAtomic.sync(snapPath, buffer); + const tmpReportPath = writeFileAtomic.sync(reportPath, reportBuffer); + + return [tmpSnapPath, tmpReportPath, snapPath, reportPath]; + } +} + +function determineSnapshotDir(projectDir, testDir) { + const parts = new Set(path.relative(projectDir, testDir).split(path.sep)); + if (parts.has('__tests__')) { + return path.join(testDir, '__snapshots__'); + } else if (parts.has('test') || parts.has('tests')) { // Accept tests, even though it's not in the default test patterns + return path.join(testDir, 'snapshots'); + } + return testDir; +} + +function load(options) { + const dir = determineSnapshotDir(options.projectDir, options.testDir); + const reportFile = `${options.name}.md`; + const snapFile = `${options.name}.snap`; + const snapPath = path.join(dir, snapFile); + + let appendOnly = !options.updating; + let snapshotsByHash; + + if (!options.updating) { + const buffer = tryRead(snapPath); + if (buffer) { + snapshotsByHash = decodeSnapshots(buffer, snapPath); + } else { + appendOnly = false; + } + } + + return new Manager({ + appendOnly, + dir, + relFile: options.relFile, + reportFile, + snapFile, + snapPath, + snapshotsByHash: snapshotsByHash || new Map() + }); +} +exports.load = load; diff --git a/node_modules/ava/lib/test-collection.js b/node_modules/ava/lib/test-collection.js index 5404cb119..91c604e06 100644 --- a/node_modules/ava/lib/test-collection.js +++ b/node_modules/ava/lib/test-collection.js @@ -11,7 +11,7 @@ class TestCollection extends EventEmitter { this.bail = options.bail; this.failWithoutAssertions = options.failWithoutAssertions; - this.getSnapshotState = options.getSnapshotState; + this.compareTestSnapshot = options.compareTestSnapshot; this.hasExclusive = false; this.testCount = 0; @@ -133,7 +133,7 @@ class TestCollection extends EventEmitter { contextRef, failWithoutAssertions: false, fn: hook.fn, - getSnapshotState: this.getSnapshotState, + compareTestSnapshot: this.compareTestSnapshot, metadata: hook.metadata, onResult: this._emitTestResult, title @@ -150,7 +150,7 @@ class TestCollection extends EventEmitter { contextRef, failWithoutAssertions: this.failWithoutAssertions, fn: test.fn, - getSnapshotState: this.getSnapshotState, + compareTestSnapshot: this.compareTestSnapshot, metadata: test.metadata, onResult: this._emitTestResult, title: test.title diff --git a/node_modules/ava/lib/test-worker.js b/node_modules/ava/lib/test-worker.js index 2df7f745d..0061775f0 100644 --- a/node_modules/ava/lib/test-worker.js +++ b/node_modules/ava/lib/test-worker.js @@ -17,18 +17,18 @@ } } -/* eslint-enable import/order */ -const Bluebird = require('bluebird'); -const currentlyUnhandled = require('currently-unhandled')(); -const isObj = require('is-obj'); const adapter = require('./process-adapter'); const globals = require('./globals'); -const serializeError = require('./serialize-error'); const opts = adapter.opts; -const testPath = opts.file; globals.options = opts; +/* eslint-enable import/order */ +const Bluebird = require('bluebird'); +const currentlyUnhandled = require('currently-unhandled')(); +const isObj = require('is-obj'); +const serializeError = require('./serialize-error'); + // Bluebird specific Bluebird.longStackTraces(); @@ -37,16 +37,28 @@ Bluebird.longStackTraces(); adapter.installSourceMapSupport(); adapter.installPrecompilerHook(); -const dependencies = []; +const testPath = opts.file; + +const dependencies = new Set(); adapter.installDependencyTracking(dependencies, testPath); +const touchedFiles = new Set(); + // Set when main.js is required (since test files should have `require('ava')`). let runner = null; exports.setRunner = newRunner => { runner = newRunner; + runner.on('dependency', file => { + dependencies.add(file); + }); + runner.on('touched', files => { + for (const file of files) { + touchedFiles.add(file); + } + }); }; -require(testPath); // eslint-disable-line import/no-dynamic-require +require(testPath); // If AVA was not required, show an error if (!runner) { @@ -121,8 +133,12 @@ process.on('ava-teardown', () => { // Include dependencies in the final teardown message. This ensures the full // set of dependencies is included no matter how the process exits, unless - // it flat out crashes. - adapter.send('teardown', {dependencies}); + // it flat out crashes. Also include any files that AVA touched during the + // test run. This allows the watcher to ignore modifications to those files. + adapter.send('teardown', { + dependencies: Array.from(dependencies), + touchedFiles: Array.from(touchedFiles) + }); }); process.on('ava-exit', () => { diff --git a/node_modules/ava/lib/test.js b/node_modules/ava/lib/test.js index a9b0fb1d9..58be54d32 100644 --- a/node_modules/ava/lib/test.js +++ b/node_modules/ava/lib/test.js @@ -1,13 +1,19 @@ 'use strict'; const isGeneratorFn = require('is-generator-fn'); const co = require('co-with-promise'); +const concordance = require('concordance'); const observableToPromise = require('observable-to-promise'); const isPromise = require('is-promise'); const isObservable = require('is-observable'); const plur = require('plur'); const assert = require('./assert'); -const formatAssertError = require('./format-assert-error'); const globals = require('./globals'); +const concordanceOptions = require('./concordance-options').default; + +function formatErrorValue(label, error) { + const formatted = concordance.format(error, concordanceOptions); + return {label, formatted}; +} class SkipApi { constructor(test) { @@ -26,8 +32,10 @@ const captureStack = start => { class ExecutionContext { constructor(test) { - this._test = test; - this.skip = new SkipApi(test); + Object.defineProperties(this, { + _test: {value: test}, + skip: {value: new SkipApi(test)} + }); } plan(ct) { @@ -67,7 +75,6 @@ class ExecutionContext { this._test.trackThrows(null); } } -Object.defineProperty(ExecutionContext.prototype, 'context', {enumerable: true}); { const assertions = assert.wrapAssertions({ @@ -98,11 +105,19 @@ class Test { this.contextRef = options.contextRef; this.failWithoutAssertions = options.failWithoutAssertions; this.fn = isGeneratorFn(options.fn) ? co.wrap(options.fn) : options.fn; - this.getSnapshotState = options.getSnapshotState; this.metadata = options.metadata; this.onResult = options.onResult; this.title = options.title; + this.snapshotInvocationCount = 0; + this.compareWithSnapshot = assertionOptions => { + const belongsTo = assertionOptions.id || this.title; + const expected = assertionOptions.expected; + const index = assertionOptions.id ? 0 : this.snapshotInvocationCount++; + const label = assertionOptions.id ? '' : assertionOptions.message || `Snapshot ${this.snapshotInvocationCount}`; + return options.compareTestSnapshot({belongsTo, expected, index, label}); + }; + this.assertCount = 0; this.assertError = undefined; this.calledEnd = false; @@ -139,7 +154,7 @@ class Test { actual: err, message: 'Callback called with an error', stack, - values: [formatAssertError.formatWithLabel('Error:', err)] + values: [formatErrorValue('Callback called with an error:', err)] })); } @@ -234,7 +249,7 @@ class Test { const values = []; if (err) { - values.push(formatAssertError.formatWithLabel(`The following error was thrown, possibly before \`t.${pending.assertion}()\` could be called:`, err)); + values.push(formatErrorValue(`The following error was thrown, possibly before \`t.${pending.assertion}()\` could be called:`, err)); } this.saveFirstError(new assert.AssertionError({ @@ -297,7 +312,7 @@ class Test { this.saveFirstError(new assert.AssertionError({ message: 'Error thrown in test', stack: result.error instanceof Error && result.error.stack, - values: [formatAssertError.formatWithLabel('Error:', result.error)] + values: [formatErrorValue('Error thrown in test:', result.error)] })); } return this.finish(); @@ -361,7 +376,7 @@ class Test { this.saveFirstError(new assert.AssertionError({ message: 'Rejected promise returned by test', stack: err instanceof Error && err.stack, - values: [formatAssertError.formatWithLabel('Rejection reason:', err)] + values: [formatErrorValue('Rejected promise returned by test. Reason:', err)] })); } }) diff --git a/node_modules/ava/lib/watcher.js b/node_modules/ava/lib/watcher.js index 3d7094ffb..c90c810f0 100644 --- a/node_modules/ava/lib/watcher.js +++ b/node_modules/ava/lib/watcher.js @@ -16,18 +16,23 @@ function rethrowAsync(err) { }); } +const MIN_DEBOUNCE_DELAY = 10; +const INITIAL_DEBOUNCE_DELAY = 100; + class Debouncer { constructor(watcher) { this.watcher = watcher; this.timer = null; this.repeat = false; } - debounce() { + debounce(delay) { if (this.timer) { this.again = true; return; } + delay = delay ? Math.max(delay, MIN_DEBOUNCE_DELAY) : INITIAL_DEBOUNCE_DELAY; + const timer = setTimeout(() => { this.watcher.busy.then(() => { // Do nothing if debouncing was canceled while waiting for the busy @@ -39,14 +44,14 @@ class Debouncer { if (this.again) { this.timer = null; this.again = false; - this.debounce(); + this.debounce(delay / 2); } else { this.watcher.runAfterChanges(); this.timer = null; this.again = false; } }); - }, 10); + }, delay); this.timer = timer; } @@ -79,7 +84,8 @@ class Watcher { this.clearLogOnNextRun = true; this.runVector = 0; - this.run = specificFiles => { + this.previousFiles = files; + this.run = (specificFiles, updateSnapshots) => { if (this.runVector > 0) { const cleared = this.clearLogOnNextRun && logger.clear(); if (!cleared) { @@ -111,7 +117,9 @@ class Watcher { } } - this.busy = api.run(specificFiles || files, {runOnlyExclusive}) + this.touchedFiles.clear(); + this.previousFiles = specificFiles || files; + this.busy = api.run(this.previousFiles, {runOnlyExclusive, updateSnapshots: updateSnapshots === true}) .then(runStatus => { runStatus.previousFailCount = this.sumPreviousFailures(currentVector); logger.finish(runStatus); @@ -125,6 +133,9 @@ class Watcher { this.testDependencies = []; this.trackTestDependencies(api, sources); + this.touchedFiles = new Set(); + this.trackTouchedFiles(api); + this.filesWithExclusiveTests = []; this.trackExclusivity(api); @@ -179,6 +190,15 @@ class Watcher { this.testDependencies.push(new TestDependency(file, sources)); } } + trackTouchedFiles(api) { + api.on('test-run', runStatus => { + runStatus.on('touchedFiles', files => { + for (const file of files) { + this.touchedFiles.add(nodePath.relative(process.cwd(), file)); + } + }); + }); + } trackExclusivity(api) { api.on('stats', stats => { this.updateExclusivity(stats.file, stats.hasExclusive); @@ -255,7 +275,7 @@ class Watcher { stdin.on('data', data => { data = data.trim().toLowerCase(); - if (data !== 'r' && data !== 'rs') { + if (data !== 'r' && data !== 'rs' && data !== 'u') { return; } @@ -267,7 +287,11 @@ class Watcher { // the busy promise to fulfil this.debouncer.cancel(); this.clearLogOnNextRun = false; - this.rerunAll(); + if (data === 'u') { + this.updatePreviousSnapshots(); + } else { + this.rerunAll(); + } }); }); } @@ -275,11 +299,22 @@ class Watcher { this.dirtyStates = {}; this.run(); } + updatePreviousSnapshots() { + this.dirtyStates = {}; + this.run(this.previousFiles, true); + } runAfterChanges() { const dirtyStates = this.dirtyStates; this.dirtyStates = {}; - const dirtyPaths = Object.keys(dirtyStates); + const dirtyPaths = Object.keys(dirtyStates).filter(path => { + if (this.touchedFiles.has(path)) { + debug('Ignoring known touched file %s', path); + this.touchedFiles.delete(path); + return false; + } + return true; + }); const dirtyTests = dirtyPaths.filter(this.avaFiles.isTest); const dirtySources = diff(dirtyPaths, dirtyTests); const addedOrChangedTests = dirtyTests.filter(path => dirtyStates[path] !== 'unlink'); @@ -309,7 +344,8 @@ class Watcher { // Rerun all tests if source files were changed that could not be traced to // specific tests if (testsBySource.length !== dirtySources.length) { - debug('Sources remain that cannot be traced to specific tests. Rerunning all tests'); + debug('Sources remain that cannot be traced to specific tests: %O', dirtySources); + debug('Rerunning all tests'); this.run(); return; } |