ava 2.3.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/index.d.ts CHANGED
@@ -25,6 +25,13 @@ export type ThrowsExpectation = {
25
25
  name?: string;
26
26
  };
27
27
 
28
+ export type CommitDiscardOptions = {
29
+ /**
30
+ * Whether the logs should be included in those of the parent test.
31
+ */
32
+ retainLogs?: boolean
33
+ }
34
+
28
35
  /** Options that can be passed to the `t.snapshot()` assertion. */
29
36
  export type SnapshotOptions = {
30
37
  /** If provided and not an empty string, used to select the snapshot to compare the `expected` value against. */
@@ -363,6 +370,7 @@ export interface ExecutionContext<Context = unknown> extends Assertions {
363
370
  log: LogFn;
364
371
  plan: PlanFn;
365
372
  timeout: TimeoutFn;
373
+ try: TryFn<Context>;
366
374
  }
367
375
 
368
376
  export interface LogFn {
@@ -392,6 +400,69 @@ export interface TimeoutFn {
392
400
  (ms: number): void;
393
401
  }
394
402
 
403
+ export interface TryFn<Context = unknown> {
404
+ /**
405
+ * Requires opt-in. Attempt to run some assertions. The result must be explicitly committed or discarded or else
406
+ * the test will fail. A macro may be provided. The title may help distinguish attempts from
407
+ * one another.
408
+ */
409
+ <Args extends any[]>(title: string, fn: EitherMacro<Args, Context>, ...args: Args): Promise<TryResult>;
410
+
411
+ /**
412
+ * Requires opt-in. Attempt to run some assertions. The result must be explicitly committed or discarded or else
413
+ * the test will fail. A macro may be provided. The title may help distinguish attempts from
414
+ * one another.
415
+ */
416
+ <Args extends any[]>(title: string, fn: [EitherMacro<Args, Context>, ...EitherMacro<Args, Context>[]], ...args: Args): Promise<TryResult[]>;
417
+
418
+ /**
419
+ * Requires opt-in. Attempt to run some assertions. The result must be explicitly committed or discarded or else
420
+ * the test will fail. A macro may be provided.
421
+ */
422
+ <Args extends any[]>(fn: EitherMacro<Args, Context>, ...args: Args): Promise<TryResult>;
423
+
424
+ /**
425
+ * Requires opt-in. Attempt to run some assertions. The result must be explicitly committed or discarded or else
426
+ * the test will fail. A macro may be provided.
427
+ */
428
+ <Args extends any[]>(fn: [EitherMacro<Args, Context>, ...EitherMacro<Args, Context>[]], ...args: Args): Promise<TryResult[]>;
429
+ }
430
+
431
+ export interface AssertionError extends Error {}
432
+
433
+ export interface TryResult {
434
+ /**
435
+ * Title of the attempt, helping you tell attempts aparts.
436
+ */
437
+ title: string;
438
+
439
+ /**
440
+ * Indicates whether all assertions passed, or at least one failed.
441
+ */
442
+ passed: boolean;
443
+
444
+ /**
445
+ * Errors raised for each failed assertion.
446
+ */
447
+ errors: AssertionError[];
448
+
449
+ /**
450
+ * Logs created during the attempt using `t.log()`. Contains formatted values.
451
+ */
452
+ logs: string[];
453
+
454
+ /**
455
+ * Commit the attempt. Counts as one assertion for the plan count. If the
456
+ * attempt failed, calling this will also cause your test to fail.
457
+ */
458
+ commit(options?: CommitDiscardOptions): void;
459
+
460
+ /**
461
+ * Discard the attempt.
462
+ */
463
+ discard(options?: CommitDiscardOptions): void;
464
+ }
465
+
395
466
  /** The `t` value passed to implementations for tests & hooks declared with the `.cb` modifier. */
396
467
  export interface CbExecutionContext<Context = unknown> extends ExecutionContext<Context> {
397
468
  /**
package/lib/api.js CHANGED
@@ -149,6 +149,7 @@ class Api extends Emittery {
149
149
 
150
150
  await this.emit('run', {
151
151
  clearLogOnNextRun: runtimeOptions.clearLogOnNextRun === true,
152
+ experiments: Object.keys(apiOptions.experiments),
152
153
  failFastEnabled: failFast,
153
154
  filePathPrefix: commonPathPrefix(files),
154
155
  files,
@@ -323,7 +324,7 @@ class Api extends Emittery {
323
324
  const forkExecArgv = execArgv.slice();
324
325
  let flagName = '--inspect';
325
326
  const oldValue = forkExecArgv[inspectArgIndex];
326
- if (oldValue.indexOf('brk') > 0) {
327
+ if (oldValue.includes('brk')) {
327
328
  flagName += '-brk';
328
329
  }
329
330
 
package/lib/cli.js CHANGED
@@ -238,6 +238,7 @@ exports.run = async () => { // eslint-disable-line complexity
238
238
  color: conf.color,
239
239
  compileEnhancements: conf.compileEnhancements !== false,
240
240
  concurrency: conf.concurrency ? parseInt(conf.concurrency, 10) : 0,
241
+ experiments: conf.nonSemVerExperiments,
241
242
  extensions,
242
243
  failFast: conf.failFast,
243
244
  failWithoutAssertions: conf.failWithoutAssertions !== false,
@@ -6,6 +6,7 @@ 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
11
  function loadConfig({configFile, resolveFrom = process.cwd(), defaults = {}} = {}) { // eslint-disable-line complexity
11
12
  let packageConf = pkgConf.sync('ava', {cwd: resolveFrom});
@@ -82,7 +83,20 @@ function loadConfig({configFile, resolveFrom = process.cwd(), defaults = {}} = {
82
83
  }
83
84
  }
84
85
 
85
- return {...defaults, ...fileConf, ...packageConf, projectDir};
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`);
96
+ }
97
+ }
98
+
99
+ return config;
86
100
  }
87
101
 
88
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
 
@@ -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 {
@@ -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() {
package/lib/test.js CHANGED
@@ -6,6 +6,7 @@ const isObservable = require('is-observable');
6
6
  const plur = require('plur');
7
7
  const assert = require('./assert');
8
8
  const nowAndTimers = require('./now-and-timers');
9
+ const parseTestArgs = require('./parse-test-args');
9
10
  const concordanceOptions = require('./concordance-options').default;
10
11
 
11
12
  function formatErrorValue(label, error) {
@@ -67,6 +68,95 @@ class ExecutionContext extends assert.Assertions {
67
68
  this.timeout = ms => {
68
69
  test.timeout(ms);
69
70
  };
71
+
72
+ this.try = async (...attemptArgs) => {
73
+ if (test.experiments.tryAssertion !== true) {
74
+ throw new Error('t.try() is currently an experiment. Opt in by setting `nonSemVerExperiments.tryAssertion` to `true`.');
75
+ }
76
+
77
+ const {args, buildTitle, implementations, receivedImplementationArray} = parseTestArgs(attemptArgs);
78
+
79
+ if (implementations.length === 0) {
80
+ throw new TypeError('Expected an implementation.');
81
+ }
82
+
83
+ const attemptPromises = implementations.map(implementation => {
84
+ let {title, isSet, isValid, isEmpty} = buildTitle(implementation);
85
+
86
+ if (!isSet || isEmpty) {
87
+ title = `${test.title} (attempt ${test.attemptCount + 1})`;
88
+ } else if (!isValid) {
89
+ throw new TypeError('`t.try()` titles must be strings'); // Throw synchronously!
90
+ }
91
+
92
+ if (!test.registerUniqueTitle(title)) {
93
+ throw new Error(`Duplicate test title: ${title}`);
94
+ }
95
+
96
+ return {implementation, title};
97
+ }).map(async ({implementation, title}) => {
98
+ let committed = false;
99
+ let discarded = false;
100
+
101
+ const {assertCount, deferredSnapshotRecordings, errors, logs, passed, snapshotCount, startingSnapshotCount} = await test.runAttempt(title, t => implementation(t, ...args));
102
+
103
+ return {
104
+ errors,
105
+ logs: [...logs], // Don't allow modification of logs.
106
+ passed,
107
+ title,
108
+ commit: ({retainLogs = true} = {}) => {
109
+ if (committed) {
110
+ return;
111
+ }
112
+
113
+ if (discarded) {
114
+ test.saveFirstError(new Error('Can\'t commit a result that was previously discarded'));
115
+ return;
116
+ }
117
+
118
+ committed = true;
119
+ test.finishAttempt({
120
+ assertCount,
121
+ commit: true,
122
+ deferredSnapshotRecordings,
123
+ errors,
124
+ logs,
125
+ passed,
126
+ retainLogs,
127
+ snapshotCount,
128
+ startingSnapshotCount
129
+ });
130
+ },
131
+ discard: ({retainLogs = false} = {}) => {
132
+ if (committed) {
133
+ test.saveFirstError(new Error('Can\'t discard a result that was previously committed'));
134
+ return;
135
+ }
136
+
137
+ if (discarded) {
138
+ return;
139
+ }
140
+
141
+ discarded = true;
142
+ test.finishAttempt({
143
+ assertCount: 0,
144
+ commit: false,
145
+ deferredSnapshotRecordings,
146
+ errors,
147
+ logs,
148
+ passed,
149
+ retainLogs,
150
+ snapshotCount,
151
+ startingSnapshotCount
152
+ });
153
+ }
154
+ };
155
+ });
156
+
157
+ const results = await Promise.all(attemptPromises);
158
+ return receivedImplementationArray ? results : results[0];
159
+ };
70
160
  }
71
161
 
72
162
  get end() {
@@ -99,32 +189,74 @@ class ExecutionContext extends assert.Assertions {
99
189
  class Test {
100
190
  constructor(options) {
101
191
  this.contextRef = options.contextRef;
192
+ this.experiments = options.experiments || {};
102
193
  this.failWithoutAssertions = options.failWithoutAssertions;
103
194
  this.fn = options.fn;
104
195
  this.metadata = options.metadata;
105
196
  this.title = options.title;
197
+ this.registerUniqueTitle = options.registerUniqueTitle;
106
198
  this.logs = [];
107
199
 
108
- this.snapshotInvocationCount = 0;
109
- this.compareWithSnapshot = assertionOptions => {
110
- const belongsTo = assertionOptions.id || this.title;
111
- const {expected} = assertionOptions;
112
- const index = assertionOptions.id ? 0 : this.snapshotInvocationCount++;
113
- const label = assertionOptions.id ? '' : assertionOptions.message || `Snapshot ${this.snapshotInvocationCount}`;
114
- return options.compareTestSnapshot({belongsTo, expected, index, label});
200
+ const {snapshotBelongsTo = this.title, nextSnapshotIndex = 0} = options;
201
+ this.snapshotBelongsTo = snapshotBelongsTo;
202
+ this.nextSnapshotIndex = nextSnapshotIndex;
203
+ this.snapshotCount = 0;
204
+
205
+ const deferRecording = this.metadata.inline;
206
+ this.deferredSnapshotRecordings = [];
207
+ this.compareWithSnapshot = ({expected, id, message}) => {
208
+ this.snapshotCount++;
209
+
210
+ // TODO: In a breaking change, reject non-undefined, falsy IDs and messages.
211
+ const belongsTo = id || snapshotBelongsTo;
212
+ const index = id ? 0 : this.nextSnapshotIndex++;
213
+ const label = id ? '' : message || `Snapshot ${index + 1}`; // Human-readable labels start counting at 1.
214
+
215
+ const {record, ...result} = options.compareTestSnapshot({belongsTo, deferRecording, expected, index, label});
216
+ if (record) {
217
+ this.deferredSnapshotRecordings.push(record);
218
+ }
219
+
220
+ return result;
115
221
  };
116
222
 
117
223
  this.skipSnapshot = () => {
118
224
  if (options.updateSnapshots) {
119
225
  this.addFailedAssertion(new Error('Snapshot assertions cannot be skipped when updating snapshots'));
120
226
  } else {
121
- this.snapshotInvocationCount++;
227
+ this.nextSnapshotIndex++;
228
+ this.snapshotCount++;
122
229
  this.countPassedAssertion();
123
230
  }
124
231
  };
125
232
 
233
+ this.runAttempt = async (title, fn) => {
234
+ if (this.finishing) {
235
+ this.saveFirstError(new Error('Running a `t.try()`, but the test has already finished'));
236
+ }
237
+
238
+ this.attemptCount++;
239
+ this.pendingAttemptCount++;
240
+
241
+ const {contextRef, snapshotBelongsTo, nextSnapshotIndex, snapshotCount: startingSnapshotCount} = this;
242
+ const attempt = new Test({
243
+ ...options,
244
+ fn,
245
+ metadata: {...options.metadata, callback: false, failing: false, inline: true},
246
+ contextRef: contextRef.copy(),
247
+ snapshotBelongsTo,
248
+ nextSnapshotIndex,
249
+ title
250
+ });
251
+
252
+ const {deferredSnapshotRecordings, error, logs, passed, assertCount, snapshotCount} = await attempt.run();
253
+ const errors = error ? [error] : [];
254
+ return {assertCount, deferredSnapshotRecordings, errors, logs, passed, snapshotCount, startingSnapshotCount};
255
+ };
256
+
126
257
  this.assertCount = 0;
127
258
  this.assertError = undefined;
259
+ this.attemptCount = 0;
128
260
  this.calledEnd = false;
129
261
  this.duration = null;
130
262
  this.endCallbackFinisher = null;
@@ -133,11 +265,12 @@ class Test {
133
265
  this.finishDueToTimeout = null;
134
266
  this.finishing = false;
135
267
  this.pendingAssertionCount = 0;
268
+ this.pendingAttemptCount = 0;
136
269
  this.pendingThrowsAssertion = null;
137
270
  this.planCount = null;
138
271
  this.startedAt = 0;
139
- this.timeoutTimer = null;
140
272
  this.timeoutMs = 0;
273
+ this.timeoutTimer = null;
141
274
  }
142
275
 
143
276
  bindEndCallback() {
@@ -147,7 +280,11 @@ class Test {
147
280
  };
148
281
  }
149
282
 
150
- throw new Error('`t.end()`` is not supported in this context. To use `t.end()` as a callback, you must use "callback mode" via `test.cb(testName, fn)`');
283
+ if (this.metadata.inline) {
284
+ throw new Error('`t.end()` is not supported inside `t.try()`');
285
+ } else {
286
+ throw new Error('`t.end()` is not supported in this context. To use `t.end()` as a callback, you must use "callback mode" via `test.cb(testName, fn)`');
287
+ }
151
288
  }
152
289
 
153
290
  endCallback(error, savedError) {
@@ -181,6 +318,10 @@ class Test {
181
318
  this.saveFirstError(new Error('Assertion passed, but test has already finished'));
182
319
  }
183
320
 
321
+ if (this.pendingAttemptCount > 0) {
322
+ this.saveFirstError(new Error('Assertion passed, but an attempt is pending. Use the attempt’s assertions instead'));
323
+ }
324
+
184
325
  this.assertCount++;
185
326
  this.refreshTimeout();
186
327
  }
@@ -191,7 +332,11 @@ class Test {
191
332
 
192
333
  addPendingAssertion(promise) {
193
334
  if (this.finishing) {
194
- this.saveFirstError(new Error('Assertion passed, but test has already finished'));
335
+ this.saveFirstError(new Error('Assertion started, but test has already finished'));
336
+ }
337
+
338
+ if (this.pendingAttemptCount > 0) {
339
+ this.saveFirstError(new Error('Assertion started, but an attempt is pending. Use the attempt’s assertions instead'));
195
340
  }
196
341
 
197
342
  this.assertCount++;
@@ -211,11 +356,53 @@ class Test {
211
356
  this.saveFirstError(new Error('Assertion failed, but test has already finished'));
212
357
  }
213
358
 
359
+ if (this.pendingAttemptCount > 0) {
360
+ this.saveFirstError(new Error('Assertion failed, but an attempt is pending. Use the attempt’s assertions instead'));
361
+ }
362
+
214
363
  this.assertCount++;
215
364
  this.refreshTimeout();
216
365
  this.saveFirstError(error);
217
366
  }
218
367
 
368
+ finishAttempt({commit, deferredSnapshotRecordings, errors, logs, passed, retainLogs, snapshotCount, startingSnapshotCount}) {
369
+ if (this.finishing) {
370
+ if (commit) {
371
+ this.saveFirstError(new Error('`t.try()` result was committed, but the test has already finished'));
372
+ } else {
373
+ this.saveFirstError(new Error('`t.try()` result was discarded, but the test has already finished'));
374
+ }
375
+ }
376
+
377
+ if (commit) {
378
+ this.assertCount++;
379
+
380
+ if (startingSnapshotCount === this.snapshotCount) {
381
+ this.snapshotCount += snapshotCount;
382
+ this.nextSnapshotIndex += snapshotCount;
383
+ for (const record of deferredSnapshotRecordings) {
384
+ record();
385
+ }
386
+ } else {
387
+ this.saveFirstError(new Error('Cannot commit `t.try()` result. Do not run concurrent snapshot assertions when using `t.try()`'));
388
+ }
389
+ }
390
+
391
+ this.pendingAttemptCount--;
392
+
393
+ if (commit && !passed) {
394
+ this.saveFirstError(errors[0]);
395
+ }
396
+
397
+ if (retainLogs) {
398
+ for (const log of logs) {
399
+ this.addLog(log);
400
+ }
401
+ }
402
+
403
+ this.refreshTimeout();
404
+ }
405
+
219
406
  saveFirstError(error) {
220
407
  if (!this.assertError) {
221
408
  this.assertError = error;
@@ -279,11 +466,27 @@ class Test {
279
466
  }
280
467
 
281
468
  verifyAssertions() {
282
- if (!this.assertError) {
283
- if (this.failWithoutAssertions && !this.calledEnd && this.planCount === null && this.assertCount === 0) {
469
+ if (this.assertError) {
470
+ return;
471
+ }
472
+
473
+ if (this.pendingAttemptCount > 0) {
474
+ this.saveFirstError(new Error('Test finished, but not all attempts were committed or discarded'));
475
+ return;
476
+ }
477
+
478
+ if (this.pendingAssertionCount > 0) {
479
+ this.saveFirstError(new Error('Test finished, but an assertion is still pending'));
480
+ return;
481
+ }
482
+
483
+ if (this.failWithoutAssertions) {
484
+ if (this.planCount !== null) {
485
+ return; // `verifyPlan()` will report an error already.
486
+ }
487
+
488
+ if (this.assertCount === 0 && !this.calledEnd) {
284
489
  this.saveFirstError(new Error('Test finished without running any assertions'));
285
- } else if (this.pendingAssertionCount > 0) {
286
- this.saveFirstError(new Error('Test finished, but an assertion is still pending'));
287
490
  }
288
491
  }
289
492
  }
@@ -476,11 +679,14 @@ class Test {
476
679
  }
477
680
 
478
681
  return {
682
+ deferredSnapshotRecordings: this.deferredSnapshotRecordings,
479
683
  duration: this.duration,
480
684
  error,
481
685
  logs: this.logs,
482
686
  metadata: this.metadata,
483
687
  passed,
688
+ snapshotCount: this.snapshotCount,
689
+ assertCount: this.assertCount,
484
690
  title: this.title
485
691
  };
486
692
  }
@@ -31,6 +31,7 @@ ipc.options.then(options => {
31
31
  }
32
32
 
33
33
  const runner = new Runner({
34
+ experiments: options.experiments,
34
35
  failFast: options.failFast,
35
36
  failWithoutAssertions: options.failWithoutAssertions,
36
37
  file: options.file,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ava",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Testing can be a drag. AVA helps you get it done.",
5
5
  "license": "MIT",
6
6
  "repository": "avajs/ava",
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "scripts": {
13
13
  "lint": "xo",
14
- "test:tap": "tap --no-esm",
14
+ "test:tap": "tap",
15
15
  "test:typescript": "tsc --noEmit -p test/ts-types",
16
16
  "test": "npm run lint && npm run test:typescript && npm run test:tap"
17
17
  },
@@ -59,11 +59,11 @@
59
59
  "dependencies": {
60
60
  "@ava/babel-preset-stage-4": "^4.0.0",
61
61
  "@ava/babel-preset-transform-test-files": "^6.0.0",
62
- "@babel/core": "^7.5.5",
63
- "@babel/generator": "^7.5.5",
62
+ "@babel/core": "^7.6.0",
63
+ "@babel/generator": "^7.6.0",
64
64
  "@concordance/react": "^2.0.0",
65
65
  "ansi-escapes": "^4.2.1",
66
- "ansi-styles": "^4.0.0",
66
+ "ansi-styles": "^4.1.0",
67
67
  "arr-flatten": "^1.1.0",
68
68
  "array-union": "^2.1.0",
69
69
  "array-uniq": "^2.1.0",
@@ -132,27 +132,26 @@
132
132
  "write-file-atomic": "^3.0.0"
133
133
  },
134
134
  "devDependencies": {
135
- "@types/node": "^10.14.15",
135
+ "@types/node": "^10.14.18",
136
136
  "cli-table3": "^0.5.1",
137
- "codecov": "^3.5.0",
138
137
  "delay": "^4.3.0",
139
- "execa": "^2.0.3",
138
+ "execa": "^2.0.4",
140
139
  "get-stream": "^5.1.0",
141
140
  "git-branch": "^2.0.1",
142
- "has-ansi": "^3.0.0",
141
+ "has-ansi": "^4.0.0",
143
142
  "lolex": "^4.2.0",
144
- "proxyquire": "^2.1.2",
143
+ "proxyquire": "^2.1.3",
145
144
  "react": "^16.9.0",
146
145
  "react-test-renderer": "^16.9.0",
147
146
  "replace-string": "^3.0.0",
148
147
  "signal-exit": "^3.0.0",
149
- "sinon": "^7.4.1",
148
+ "sinon": "^7.4.2",
150
149
  "source-map-fixtures": "^2.1.0",
151
- "tap": "^14.6.1",
150
+ "tap": "^14.6.4",
152
151
  "temp-write": "^4.0.0",
153
152
  "touch": "^3.1.0",
154
153
  "ts-node": "^8.3.0",
155
- "typescript": "^3.5.3",
154
+ "typescript": "^3.6.3",
156
155
  "xo": "^0.24.0",
157
156
  "zen-observable": "^0.8.14"
158
157
  },
package/profile.js CHANGED
@@ -145,6 +145,7 @@ runStatus.observeWorker({
145
145
  }, file);
146
146
 
147
147
  reporter.startRun({
148
+ experiments: [],
148
149
  failFastEnabled: false,
149
150
  files: [file],
150
151
  matching: false,
package/readme.md CHANGED
@@ -216,6 +216,7 @@ It's the [Andromeda galaxy](https://simple.wikipedia.org/wiki/Andromeda_galaxy).
216
216
  - [AVA stickers, t-shirts, etc](https://www.redbubble.com/people/sindresorhus/works/30330590-ava-logo)
217
217
  - [Awesome list](https://github.com/avajs/awesome-ava)
218
218
  - [AVA Casts](http://avacasts.com)
219
+ - [Do you like AVA? Donate here!](https://opencollective.com/ava)
219
220
  - [More…](https://github.com/avajs/awesome-ava)
220
221
 
221
222
  ## Team