ava 2.0.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/fork.js CHANGED
@@ -25,6 +25,7 @@ if (process.env.NODE_PATH) {
25
25
 
26
26
  const describeTTY = tty => ({
27
27
  colorDepth: tty.getColorDepth ? tty.getColorDepth() : undefined,
28
+ hasColors: typeof tty.hasColors === 'function',
28
29
  columns: tty.columns || 80,
29
30
  rows: tty.rows
30
31
  });
package/lib/globs.js CHANGED
@@ -98,17 +98,17 @@ exports.hasExtension = hasExtension;
98
98
  const findFiles = async (cwd, patterns) => {
99
99
  const files = await globby(patterns, {
100
100
  absolute: true,
101
- brace: true,
102
- case: false,
101
+ braceExpansion: true,
102
+ caseSensitiveMatch: false,
103
103
  cwd,
104
104
  dot: false,
105
105
  expandDirectories: false,
106
106
  extglob: true,
107
- followSymlinkedDirectories: true,
107
+ followSymbolicLinks: true,
108
108
  gitignore: false,
109
109
  globstar: true,
110
110
  ignore: defaultIgnorePatterns,
111
- matchBase: false,
111
+ baseNameMatch: false,
112
112
  onlyFiles: true,
113
113
  stats: false,
114
114
  unique: true
@@ -6,12 +6,25 @@ const pkgConf = require('pkg-conf');
6
6
 
7
7
  const NO_SUCH_FILE = Symbol('no ava.config.js file');
8
8
  const MISSING_DEFAULT_EXPORT = Symbol('missing default export');
9
+ const EXPERIMENTS = new Set(['tryAssertion']);
9
10
 
10
- function loadConfig(resolveFrom = process.cwd(), defaults = {}) {
11
- const packageConf = pkgConf.sync('ava', {cwd: resolveFrom});
11
+ function loadConfig({configFile, resolveFrom = process.cwd(), defaults = {}} = {}) { // eslint-disable-line complexity
12
+ let packageConf = pkgConf.sync('ava', {cwd: resolveFrom});
12
13
  const filepath = pkgConf.filepath(packageConf);
13
14
  const projectDir = filepath === null ? resolveFrom : path.dirname(filepath);
14
15
 
16
+ const fileForErrorMessage = configFile || 'ava.config.js';
17
+ const allowConflictWithPackageJson = Boolean(configFile);
18
+
19
+ if (configFile) {
20
+ configFile = path.resolve(configFile); // Relative to CWD
21
+ if (path.basename(configFile) !== path.relative(projectDir, configFile)) {
22
+ throw new Error('Config files must be located next to the package.json file');
23
+ }
24
+ } else {
25
+ configFile = path.join(projectDir, 'ava.config.js');
26
+ }
27
+
15
28
  let fileConf;
16
29
  try {
17
30
  ({default: fileConf = MISSING_DEFAULT_EXPORT} = esm(module, {
@@ -26,49 +39,64 @@ function loadConfig(resolveFrom = process.cwd(), defaults = {}) {
26
39
  },
27
40
  force: true,
28
41
  mode: 'all'
29
- })(path.join(projectDir, 'ava.config.js')));
42
+ })(configFile));
30
43
  } catch (error) {
31
44
  if (error && error.code === 'MODULE_NOT_FOUND') {
32
45
  fileConf = NO_SUCH_FILE;
33
46
  } else {
34
- throw Object.assign(new Error('Error loading ava.config.js'), {parent: error});
47
+ throw Object.assign(new Error(`Error loading ${fileForErrorMessage}`), {parent: error});
35
48
  }
36
49
  }
37
50
 
38
51
  if (fileConf === MISSING_DEFAULT_EXPORT) {
39
- throw new Error('ava.config.js must have a default export, using ES module syntax');
52
+ throw new Error(`${fileForErrorMessage} must have a default export, using ES module syntax`);
40
53
  }
41
54
 
42
55
  if (fileConf !== NO_SUCH_FILE) {
43
- if (Object.keys(packageConf).length > 0) {
44
- throw new Error('Conflicting configuration in ava.config.js and package.json');
56
+ if (allowConflictWithPackageJson) {
57
+ packageConf = {};
58
+ } else if (Object.keys(packageConf).length > 0) {
59
+ throw new Error(`Conflicting configuration in ${fileForErrorMessage} and package.json`);
45
60
  }
46
61
 
47
62
  if (fileConf && typeof fileConf.then === 'function') { // eslint-disable-line promise/prefer-await-to-then
48
- throw new TypeError('ava.config.js must not export a promise');
63
+ throw new TypeError(`${fileForErrorMessage} must not export a promise`);
49
64
  }
50
65
 
51
66
  if (!isPlainObject(fileConf) && typeof fileConf !== 'function') {
52
- throw new TypeError('ava.config.js must export a plain object or factory function');
67
+ throw new TypeError(`${fileForErrorMessage} must export a plain object or factory function`);
53
68
  }
54
69
 
55
70
  if (typeof fileConf === 'function') {
56
71
  fileConf = fileConf({projectDir});
57
72
  if (fileConf && typeof fileConf.then === 'function') { // eslint-disable-line promise/prefer-await-to-then
58
- throw new TypeError('Factory method exported by ava.config.js must not return a promise');
73
+ throw new TypeError(`Factory method exported by ${fileForErrorMessage} must not return a promise`);
59
74
  }
60
75
 
61
76
  if (!isPlainObject(fileConf)) {
62
- throw new TypeError('Factory method exported by ava.config.js must return a plain object');
77
+ throw new TypeError(`Factory method exported by ${fileForErrorMessage} must return a plain object`);
63
78
  }
64
79
  }
65
80
 
66
81
  if ('ava' in fileConf) {
67
- throw new Error('Encountered \'ava\' property in ava.config.js; avoid wrapping the configuration');
82
+ throw new Error(`Encountered 'ava' property in ${fileForErrorMessage}; avoid wrapping the configuration`);
83
+ }
84
+ }
85
+
86
+ const config = {...defaults, nonSemVerExperiments: {}, ...fileConf, ...packageConf, projectDir};
87
+
88
+ const {nonSemVerExperiments: experiments} = config;
89
+ if (!isPlainObject(experiments)) {
90
+ throw new Error(`nonSemVerExperiments from ${fileForErrorMessage} must be an object`);
91
+ }
92
+
93
+ for (const key of Object.keys(experiments)) {
94
+ if (!EXPERIMENTS.has(key)) {
95
+ throw new Error(`nonSemVerExperiments.${key} from ${fileForErrorMessage} is not a supported experiment`);
68
96
  }
69
97
  }
70
98
 
71
- return {...defaults, ...fileConf, ...packageConf, projectDir};
99
+ return config;
72
100
  }
73
101
 
74
102
  module.exports = loadConfig;
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+ function parseTestArgs(args) {
3
+ const rawTitle = typeof args[0] === 'string' ? args.shift() : undefined;
4
+ const receivedImplementationArray = Array.isArray(args[0]);
5
+ const implementations = receivedImplementationArray ? args.shift() : args.splice(0, 1);
6
+
7
+ const buildTitle = implementation => {
8
+ const title = implementation.title ? implementation.title(rawTitle, ...args) : rawTitle;
9
+ return {title, isSet: typeof title !== 'undefined', isValid: typeof title === 'string', isEmpty: !title};
10
+ };
11
+
12
+ return {args, buildTitle, implementations, rawTitle, receivedImplementationArray};
13
+ }
14
+
15
+ module.exports = parseTestArgs;
@@ -135,6 +135,11 @@ class MiniReporter {
135
135
 
136
136
  cliCursor.hide(this.reportStream);
137
137
  this.lineWriter.writeLine();
138
+
139
+ if (plan.experiments.length > 0) {
140
+ this.lineWriter.writeLine(colors.information(`${figures.warning} Experiments are enabled. These are unsupported and may change or be be removed at any time.`));
141
+ }
142
+
138
143
  this.spinner.start();
139
144
  }
140
145
 
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const plur = require('plur');
6
6
  const stripAnsi = require('strip-ansi');
7
7
  const supertap = require('supertap');
8
+ const indentString = require('indent-string');
8
9
 
9
10
  const prefixTitle = require('./prefix-title');
10
11
 
@@ -41,7 +42,7 @@ function dumpError(error) {
41
42
  }
42
43
 
43
44
  if (error.stack) {
44
- obj.at = error.stack.split('\n')[0];
45
+ obj.at = error.stack;
45
46
  }
46
47
 
47
48
  return obj;
@@ -74,7 +75,7 @@ class TapReporter {
74
75
  if (this.stats) {
75
76
  this.reportStream.write(supertap.finish({
76
77
  crashed: this.crashCount,
77
- failed: this.stats.failedHooks + this.stats.failedTests,
78
+ failed: this.stats.failedTests + this.stats.remainingTests,
78
79
  passed: this.stats.passedTests + this.stats.passedKnownFailingTests,
79
80
  skipped: this.stats.skippedTests,
80
81
  todo: this.stats.todoTests
@@ -118,6 +119,21 @@ class TapReporter {
118
119
  }) + os.EOL);
119
120
  }
120
121
 
122
+ writeComment(evt, {error = false, title = this.prefixTitle(evt.testFile, evt.title)}) {
123
+ let formattedTitle = title;
124
+ if (error) {
125
+ formattedTitle = `Failed hook: ${formattedTitle}`;
126
+ }
127
+
128
+ this.reportStream.write(`# ${stripAnsi(formattedTitle)}${os.EOL}`);
129
+ if (evt.logs) {
130
+ for (const log of evt.logs) {
131
+ const logLines = indentString(log, 4).replace(/^ {4}/, ' # ');
132
+ this.reportStream.write(`${logLines}${os.EOL}`);
133
+ }
134
+ }
135
+ }
136
+
121
137
  consumeStateChange(evt) { // eslint-disable-line complexity
122
138
  const fileStats = this.stats && evt.testFile ? this.stats.byFile.get(evt.testFile) : null;
123
139
 
@@ -126,7 +142,10 @@ class TapReporter {
126
142
  // Ignore
127
143
  break;
128
144
  case 'hook-failed':
129
- this.writeTest(evt, {passed: false, todo: false, skip: false});
145
+ this.writeComment(evt, {error: true});
146
+ break;
147
+ case 'hook-finished':
148
+ this.writeComment(evt, {});
130
149
  break;
131
150
  case 'internal-error':
132
151
  this.writeCrash(evt);
@@ -176,7 +195,7 @@ class TapReporter {
176
195
  if (fileStats.declaredTests === 0) {
177
196
  this.writeCrash(evt, `No tests found in ${path.relative('.', evt.testFile)}`);
178
197
  } else if (!this.failFastEnabled && fileStats.remainingTests > 0) {
179
- this.writeCrash(evt, `${fileStats.remainingTests} ${plur('test', fileStats.remainingTests)} remaining in ${path.relative('.', evt.testFile)}`);
198
+ this.writeComment(evt, {title: `${fileStats.remainingTests} ${plur('test', fileStats.remainingTests)} remaining in ${path.relative('.', evt.testFile)}`});
180
199
  }
181
200
  }
182
201
 
@@ -97,6 +97,9 @@ class VerboseReporter {
97
97
  }
98
98
 
99
99
  this.lineWriter.writeLine();
100
+ if (plan.experiments.length > 0) {
101
+ this.lineWriter.writeLine(colors.information(`${figures.warning} Experiments are enabled. These are unsupported and may change or be removed at any time.${os.EOL}`));
102
+ }
100
103
  }
101
104
 
102
105
  consumeStateChange(evt) { // eslint-disable-line complexity
package/lib/runner.js CHANGED
@@ -3,6 +3,7 @@ const Emittery = require('emittery');
3
3
  const matcher = require('matcher');
4
4
  const ContextRef = require('./context-ref');
5
5
  const createChain = require('./create-chain');
6
+ const parseTestArgs = require('./parse-test-args');
6
7
  const snapshotManager = require('./snapshot-manager');
7
8
  const serializeError = require('./serialize-error');
8
9
  const Runnable = require('./test');
@@ -11,6 +12,7 @@ class Runner extends Emittery {
11
12
  constructor(options = {}) {
12
13
  super();
13
14
 
15
+ this.experiments = options.experiments || {};
14
16
  this.failFast = options.failFast === true;
15
17
  this.failWithoutAssertions = options.failWithoutAssertions !== false;
16
18
  this.file = options.file;
@@ -39,12 +41,21 @@ class Runner extends Emittery {
39
41
  };
40
42
 
41
43
  const uniqueTestTitles = new Set();
44
+ this.registerUniqueTitle = title => {
45
+ if (uniqueTestTitles.has(title)) {
46
+ return false;
47
+ }
48
+
49
+ uniqueTestTitles.add(title);
50
+ return true;
51
+ };
52
+
42
53
  let hasStarted = false;
43
54
  let scheduledStart = false;
44
55
  const meta = Object.freeze({
45
56
  file: options.file
46
57
  });
47
- this.chain = createChain((metadata, args) => { // eslint-disable-line complexity
58
+ this.chain = createChain((metadata, testArgs) => { // eslint-disable-line complexity
48
59
  if (hasStarted) {
49
60
  throw new Error('All tests and hooks must be declared synchronously in your test file, and cannot be nested within other tests or hooks.');
50
61
  }
@@ -57,40 +68,33 @@ class Runner extends Emittery {
57
68
  });
58
69
  }
59
70
 
60
- const specifiedTitle = typeof args[0] === 'string' ?
61
- args.shift() :
62
- undefined;
63
- const implementations = Array.isArray(args[0]) ?
64
- args.shift() :
65
- args.splice(0, 1);
71
+ const {args, buildTitle, implementations, rawTitle} = parseTestArgs(testArgs);
66
72
 
67
73
  if (metadata.todo) {
68
74
  if (implementations.length > 0) {
69
75
  throw new TypeError('`todo` tests are not allowed to have an implementation. Use `test.skip()` for tests with an implementation.');
70
76
  }
71
77
 
72
- if (specifiedTitle === undefined || specifiedTitle === '') {
78
+ if (!rawTitle) { // Either undefined or a string.
73
79
  throw new TypeError('`todo` tests require a title');
74
80
  }
75
81
 
76
- if (uniqueTestTitles.has(specifiedTitle)) {
77
- throw new Error(`Duplicate test title: ${specifiedTitle}`);
78
- } else {
79
- uniqueTestTitles.add(specifiedTitle);
82
+ if (!this.registerUniqueTitle(rawTitle)) {
83
+ throw new Error(`Duplicate test title: ${rawTitle}`);
80
84
  }
81
85
 
82
86
  if (this.match.length > 0) {
83
87
  // --match selects TODO tests.
84
- if (matcher([specifiedTitle], this.match).length === 1) {
88
+ if (matcher([rawTitle], this.match).length === 1) {
85
89
  metadata.exclusive = true;
86
90
  this.runOnlyExclusive = true;
87
91
  }
88
92
  }
89
93
 
90
- this.tasks.todo.push({title: specifiedTitle, metadata});
94
+ this.tasks.todo.push({title: rawTitle, metadata});
91
95
  this.emit('stateChange', {
92
96
  type: 'declared-test',
93
- title: specifiedTitle,
97
+ title: rawTitle,
94
98
  knownFailing: false,
95
99
  todo: true
96
100
  });
@@ -100,15 +104,13 @@ class Runner extends Emittery {
100
104
  }
101
105
 
102
106
  for (const implementation of implementations) {
103
- let title = implementation.title ?
104
- implementation.title(specifiedTitle, ...args) :
105
- specifiedTitle;
107
+ let {title, isSet, isValid, isEmpty} = buildTitle(implementation);
106
108
 
107
- if (title !== undefined && typeof title !== 'string') {
109
+ if (isSet && !isValid) {
108
110
  throw new TypeError('Test & hook titles must be strings');
109
111
  }
110
112
 
111
- if (title === undefined || title === '') {
113
+ if (isEmpty) {
112
114
  if (metadata.type === 'test') {
113
115
  throw new TypeError('Tests must have a title');
114
116
  } else if (metadata.always) {
@@ -118,12 +120,8 @@ class Runner extends Emittery {
118
120
  }
119
121
  }
120
122
 
121
- if (metadata.type === 'test') {
122
- if (uniqueTestTitles.has(title)) {
123
- throw new Error(`Duplicate test title: ${title}`);
124
- } else {
125
- uniqueTestTitles.add(title);
126
- }
123
+ if (metadata.type === 'test' && !this.registerUniqueTitle(title)) {
124
+ throw new Error(`Duplicate test title: ${title}`);
127
125
  }
128
126
 
129
127
  const task = {
@@ -162,6 +160,7 @@ class Runner extends Emittery {
162
160
  todo: false,
163
161
  failing: false,
164
162
  callback: false,
163
+ inline: false, // Set for attempt metadata created by `t.try()`
165
164
  always: false
166
165
  }, meta);
167
166
  }
@@ -269,6 +268,7 @@ class Runner extends Emittery {
269
268
  async runHooks(tasks, contextRef, titleSuffix) {
270
269
  const hooks = tasks.map(task => new Runnable({
271
270
  contextRef,
271
+ experiments: this.experiments,
272
272
  failWithoutAssertions: false,
273
273
  fn: task.args.length === 0 ?
274
274
  task.implementation :
@@ -309,6 +309,7 @@ class Runner extends Emittery {
309
309
  // Only run the test if all `beforeEach` hooks passed.
310
310
  const test = new Runnable({
311
311
  contextRef,
312
+ experiments: this.experiments,
312
313
  failWithoutAssertions: this.failWithoutAssertions,
313
314
  fn: task.args.length === 0 ?
314
315
  task.implementation :
@@ -316,7 +317,8 @@ class Runner extends Emittery {
316
317
  compareTestSnapshot: this.boundCompareTestSnapshot,
317
318
  updateSnapshots: this.updateSnapshots,
318
319
  metadata: task.metadata,
319
- title: task.title
320
+ title: task.title,
321
+ registerUniqueTitle: this.registerUniqueTitle
320
322
  });
321
323
 
322
324
  const result = await this.runSingle(test);
@@ -39,7 +39,8 @@ function buildSource(source) {
39
39
  const file = path.resolve(projectDir, source.file.trim());
40
40
  const rel = path.relative(projectDir, file);
41
41
 
42
- const isWithinProject = rel.split(path.sep)[0] !== '..';
42
+ const [segment] = rel.split(path.sep);
43
+ const isWithinProject = segment !== '..' && (process.platform !== 'win32' || !segment.includes(':'));
43
44
  const isDependency = isWithinProject && path.dirname(rel).split(path.sep).includes('node_modules');
44
45
 
45
46
  return {
@@ -51,7 +52,11 @@ function buildSource(source) {
51
52
  }
52
53
 
53
54
  function trySerializeError(err, shouldBeautifyStack) {
54
- const stack = shouldBeautifyStack ? beautifyStack(err.stack) : err.stack;
55
+ let stack = err.savedError ? err.savedError.stack : err.stack;
56
+
57
+ if (shouldBeautifyStack) {
58
+ stack = beautifyStack(stack);
59
+ }
55
60
 
56
61
  const retval = {
57
62
  avaAssertionError: isAvaAssertionError(err),
@@ -305,45 +305,64 @@ class Manager {
305
305
  compare(options) {
306
306
  const hash = md5Hex(options.belongsTo);
307
307
  const entries = this.snapshotsByHash.get(hash) || [];
308
- if (options.index > entries.length) {
309
- throw new RangeError(`Cannot record snapshot ${options.index} for ${JSON.stringify(options.belongsTo)}, exceeds expected index of ${entries.length}`);
310
- }
308
+ const snapshotBuffer = entries[options.index];
311
309
 
312
- if (options.index === entries.length) {
310
+ if (!snapshotBuffer) {
313
311
  if (!this.recordNewSnapshots) {
314
312
  return {pass: false};
315
313
  }
316
314
 
315
+ if (options.deferRecording) {
316
+ const record = this.deferRecord(hash, options);
317
+ return {pass: true, record};
318
+ }
319
+
317
320
  this.record(hash, options);
318
321
  return {pass: true};
319
322
  }
320
323
 
321
- const snapshotBuffer = entries[options.index];
322
324
  const actual = concordance.deserialize(snapshotBuffer, concordanceOptions);
323
-
324
325
  const expected = concordance.describe(options.expected, concordanceOptions);
325
326
  const pass = concordance.compareDescriptors(actual, expected);
326
327
 
327
328
  return {actual, expected, pass};
328
329
  }
329
330
 
330
- record(hash, options) {
331
+ deferRecord(hash, options) {
331
332
  const descriptor = concordance.describe(options.expected, concordanceOptions);
332
-
333
- this.hasChanges = true;
334
333
  const snapshot = concordance.serialize(descriptor);
335
- if (this.snapshotsByHash.has(hash)) {
336
- this.snapshotsByHash.get(hash).push(snapshot);
337
- } else {
338
- this.snapshotsByHash.set(hash, [snapshot]);
339
- }
340
-
341
334
  const entry = formatEntry(options.label, descriptor);
342
- if (this.reportEntries.has(options.belongsTo)) {
343
- this.reportEntries.get(options.belongsTo).push(entry);
344
- } else {
345
- this.reportEntries.set(options.belongsTo, [entry]);
346
- }
335
+
336
+ return () => { // Must be called in order!
337
+ this.hasChanges = true;
338
+
339
+ let snapshots = this.snapshotsByHash.get(hash);
340
+ if (!snapshots) {
341
+ snapshots = [];
342
+ this.snapshotsByHash.set(hash, snapshots);
343
+ }
344
+
345
+ if (options.index > snapshots.length) {
346
+ throw new RangeError(`Cannot record snapshot ${options.index} for ${JSON.stringify(options.belongsTo)}, exceeds expected index of ${snapshots.length}`);
347
+ }
348
+
349
+ if (options.index < snapshots.length) {
350
+ throw new RangeError(`Cannot record snapshot ${options.index} for ${JSON.stringify(options.belongsTo)}, already exists`);
351
+ }
352
+
353
+ snapshots.push(snapshot);
354
+
355
+ if (this.reportEntries.has(options.belongsTo)) {
356
+ this.reportEntries.get(options.belongsTo).push(entry);
357
+ } else {
358
+ this.reportEntries.set(options.belongsTo, [entry]);
359
+ }
360
+ };
361
+ }
362
+
363
+ record(hash, options) {
364
+ const record = this.deferRecord(hash, options);
365
+ record();
347
366
  }
348
367
 
349
368
  save() {