Skip to content

Commit 4fdb02d

Browse files
qloniknovemberborn
authored andcommitted
Implement experimental t.try() assertion
1 parent 782c2d8 commit 4fdb02d

16 files changed

+1254
-119
lines changed

Diff for: docs/06-configuration.md

+11-2
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ AVA has a minimum depth of `3`.
164164

165165
## Experiments
166166

167-
From time to time, AVA will implement experimental features. These may change or be removed at any time, not just when there's a new major version. You can opt-in to such a feature by enabling it in the `nonSemVerExperiments` configuration.
167+
From time to time, AVA will implement experimental features. These may change or be removed at any time, not just when there's a new major version. You can opt in to such a feature by enabling it in the `nonSemVerExperiments` configuration.
168168

169169
`ava.config.js`:
170170
```js
@@ -175,6 +175,15 @@ export default {
175175
};
176176
```
177177

178-
There are currently no such features available.
178+
You can opt in to the new `t.try()` assertion by specifying `tryAssertion`:
179+
180+
`ava.config.js`:
181+
```js
182+
export default {
183+
nonSemVerExperiments: {
184+
tryAssertion: true
185+
}
186+
};
187+
```
179188

180189
[CLI]: ./05-command-line.md

Diff for: index.d.ts

+71
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ export type ThrowsExpectation = {
2525
name?: string;
2626
};
2727

28+
export type CommitDiscardOptions = {
29+
/**
30+
* Whether the logs should be included in those of the parent test.
31+
*/
32+
retainLogs?: boolean
33+
}
34+
2835
/** Options that can be passed to the `t.snapshot()` assertion. */
2936
export type SnapshotOptions = {
3037
/** 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 {
363370
log: LogFn;
364371
plan: PlanFn;
365372
timeout: TimeoutFn;
373+
try: TryFn<Context>;
366374
}
367375

368376
export interface LogFn {
@@ -392,6 +400,69 @@ export interface TimeoutFn {
392400
(ms: number): void;
393401
}
394402

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+
395466
/** The `t` value passed to implementations for tests & hooks declared with the `.cb` modifier. */
396467
export interface CbExecutionContext<Context = unknown> extends ExecutionContext<Context> {
397468
/**

Diff for: lib/load-config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const pkgConf = require('pkg-conf');
66

77
const NO_SUCH_FILE = Symbol('no ava.config.js file');
88
const MISSING_DEFAULT_EXPORT = Symbol('missing default export');
9-
const EXPERIMENTS = new Set([]);
9+
const EXPERIMENTS = new Set(['tryAssertion']);
1010

1111
function loadConfig({configFile, resolveFrom = process.cwd(), defaults = {}} = {}) { // eslint-disable-line complexity
1212
let packageConf = pkgConf.sync('ava', {cwd: resolveFrom});

Diff for: lib/parse-test-args.js

+15
Original file line numberDiff line numberDiff line change
@@ -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;

Diff for: lib/runner.js

+29-27
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const Emittery = require('emittery');
33
const matcher = require('matcher');
44
const ContextRef = require('./context-ref');
55
const createChain = require('./create-chain');
6+
const parseTestArgs = require('./parse-test-args');
67
const snapshotManager = require('./snapshot-manager');
78
const serializeError = require('./serialize-error');
89
const Runnable = require('./test');
@@ -11,6 +12,7 @@ class Runner extends Emittery {
1112
constructor(options = {}) {
1213
super();
1314

15+
this.experiments = options.experiments || {};
1416
this.failFast = options.failFast === true;
1517
this.failWithoutAssertions = options.failWithoutAssertions !== false;
1618
this.file = options.file;
@@ -39,12 +41,21 @@ class Runner extends Emittery {
3941
};
4042

4143
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+
4253
let hasStarted = false;
4354
let scheduledStart = false;
4455
const meta = Object.freeze({
4556
file: options.file
4657
});
47-
this.chain = createChain((metadata, args) => { // eslint-disable-line complexity
58+
this.chain = createChain((metadata, testArgs) => { // eslint-disable-line complexity
4859
if (hasStarted) {
4960
throw new Error('All tests and hooks must be declared synchronously in your test file, and cannot be nested within other tests or hooks.');
5061
}
@@ -57,40 +68,33 @@ class Runner extends Emittery {
5768
});
5869
}
5970

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);
6672

6773
if (metadata.todo) {
6874
if (implementations.length > 0) {
6975
throw new TypeError('`todo` tests are not allowed to have an implementation. Use `test.skip()` for tests with an implementation.');
7076
}
7177

72-
if (specifiedTitle === undefined || specifiedTitle === '') {
78+
if (!rawTitle) { // Either undefined or a string.
7379
throw new TypeError('`todo` tests require a title');
7480
}
7581

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}`);
8084
}
8185

8286
if (this.match.length > 0) {
8387
// --match selects TODO tests.
84-
if (matcher([specifiedTitle], this.match).length === 1) {
88+
if (matcher([rawTitle], this.match).length === 1) {
8589
metadata.exclusive = true;
8690
this.runOnlyExclusive = true;
8791
}
8892
}
8993

90-
this.tasks.todo.push({title: specifiedTitle, metadata});
94+
this.tasks.todo.push({title: rawTitle, metadata});
9195
this.emit('stateChange', {
9296
type: 'declared-test',
93-
title: specifiedTitle,
97+
title: rawTitle,
9498
knownFailing: false,
9599
todo: true
96100
});
@@ -100,15 +104,13 @@ class Runner extends Emittery {
100104
}
101105

102106
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);
106108

107-
if (title !== undefined && typeof title !== 'string') {
109+
if (isSet && !isValid) {
108110
throw new TypeError('Test & hook titles must be strings');
109111
}
110112

111-
if (title === undefined || title === '') {
113+
if (isEmpty) {
112114
if (metadata.type === 'test') {
113115
throw new TypeError('Tests must have a title');
114116
} else if (metadata.always) {
@@ -118,12 +120,8 @@ class Runner extends Emittery {
118120
}
119121
}
120122

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}`);
127125
}
128126

129127
const task = {
@@ -162,6 +160,7 @@ class Runner extends Emittery {
162160
todo: false,
163161
failing: false,
164162
callback: false,
163+
inline: false, // Set for attempt metadata created by `t.try()`
165164
always: false
166165
}, meta);
167166
}
@@ -269,6 +268,7 @@ class Runner extends Emittery {
269268
async runHooks(tasks, contextRef, titleSuffix) {
270269
const hooks = tasks.map(task => new Runnable({
271270
contextRef,
271+
experiments: this.experiments,
272272
failWithoutAssertions: false,
273273
fn: task.args.length === 0 ?
274274
task.implementation :
@@ -309,14 +309,16 @@ class Runner extends Emittery {
309309
// Only run the test if all `beforeEach` hooks passed.
310310
const test = new Runnable({
311311
contextRef,
312+
experiments: this.experiments,
312313
failWithoutAssertions: this.failWithoutAssertions,
313314
fn: task.args.length === 0 ?
314315
task.implementation :
315316
t => task.implementation.apply(null, [t].concat(task.args)),
316317
compareTestSnapshot: this.boundCompareTestSnapshot,
317318
updateSnapshots: this.updateSnapshots,
318319
metadata: task.metadata,
319-
title: task.title
320+
title: task.title,
321+
registerUniqueTitle: this.registerUniqueTitle
320322
});
321323

322324
const result = await this.runSingle(test);

Diff for: lib/snapshot-manager.js

+39-20
Original file line numberDiff line numberDiff line change
@@ -305,45 +305,64 @@ class Manager {
305305
compare(options) {
306306
const hash = md5Hex(options.belongsTo);
307307
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];
311309

312-
if (options.index === entries.length) {
310+
if (!snapshotBuffer) {
313311
if (!this.recordNewSnapshots) {
314312
return {pass: false};
315313
}
316314

315+
if (options.deferRecording) {
316+
const record = this.deferRecord(hash, options);
317+
return {pass: true, record};
318+
}
319+
317320
this.record(hash, options);
318321
return {pass: true};
319322
}
320323

321-
const snapshotBuffer = entries[options.index];
322324
const actual = concordance.deserialize(snapshotBuffer, concordanceOptions);
323-
324325
const expected = concordance.describe(options.expected, concordanceOptions);
325326
const pass = concordance.compareDescriptors(actual, expected);
326327

327328
return {actual, expected, pass};
328329
}
329330

330-
record(hash, options) {
331+
deferRecord(hash, options) {
331332
const descriptor = concordance.describe(options.expected, concordanceOptions);
332-
333-
this.hasChanges = true;
334333
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-
341334
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();
347366
}
348367

349368
save() {

0 commit comments

Comments
 (0)