Skip to content

Commit

Permalink
Run tests at selected line numbers
Browse files Browse the repository at this point in the history
Co-Authored-By: Mark Wubben <mark@novemberborn.net>
  • Loading branch information
ulken and novemberborn committed Apr 26, 2020
1 parent 75cbc3b commit 1222ce9
Show file tree
Hide file tree
Showing 41 changed files with 762 additions and 73 deletions.
64 changes: 63 additions & 1 deletion docs/05-command-line.md
Expand Up @@ -15,7 +15,8 @@ Commands:

Positionals:
pattern Glob patterns to select what test files to run. Leave empty if you
want AVA to run all test files instead [string]
want AVA to run all test files instead. Add a colon and specify line
numbers of specific tests to run [string]

Options:
--version Show version number [boolean]
Expand All @@ -42,6 +43,7 @@ Options:
Examples:
ava
ava test.js
ava test.js:4,7-9
```

*Note that the CLI will use your local install of AVA when available, even when run globally.*
Expand Down Expand Up @@ -149,6 +151,66 @@ test(function foo(t) {
});
```

## Running tests at specific line numbers

AVA lets you run tests exclusively by referring to their line numbers. Target a single line, a range of lines or both. You can select any line number of a test.

The format is a comma-separated list of `[X|Y-Z]` where `X`, `Y` and `Z` are integers between `1` and the last line number of the file.

This feature is only available from the command line. It won't work if you use tools like `ts-node/register` or `@babel/register`, and it does not currently work with `@ava/babel` and `@ava/typescript`.

### Running a single test

To only run a particular test in a file, append the line number of the test to the path or pattern passed to AVA.

Given the following test file:

`test.js`

```js
1: test('unicorn', t => {
2: t.pass();
3: });
4:
5: test('rainbow', t => {
6: t.fail();
7: });
```

Running `npx ava test.js:2` for would run the `unicorn` test. In fact you could use any line number between `1` and `3`.

### Running multiple tests

To run multiple tests, either target them one by one or select a range of line numbers. As line numbers are given per file, you can run multiple files with different line numbers for each file. If the same file is provided multiple times, line numbers are merged and only run once.

### Examples

Single line numbers:

```console
npx ava test.js:2,9
```

Range:

```console
npx ava test.js:4-7
```

Mix of single line number and range:

```console
npx ava test.js:4,9-12
```

Different files:

```console
npx ava test.js:3 test2.js:4,7-9
```

When running a file with and without line numbers, line numbers take precedence.

## Resetting AVA's cache

AVA may cache certain files, especially when you use our [`@ava/babel`](https://github.com/avajs/babel) provider. If it seems like your latest changes aren't being picked up by AVA you can reset the cache by running:
Expand Down
11 changes: 9 additions & 2 deletions lib/api.js
Expand Up @@ -16,6 +16,7 @@ const isCi = require('./is-ci');
const RunStatus = require('./run-status');
const fork = require('./fork');
const serializeError = require('./serialize-error');
const {getApplicableLineNumbers} = require('./line-numbers');

function resolveModules(modules) {
return arrify(modules).map(name => {
Expand Down Expand Up @@ -118,7 +119,11 @@ class Api extends Emittery {
if (filter.length === 0) {
selectedFiles = testFiles;
} else {
selectedFiles = globs.applyTestFileFilter({cwd: this.options.projectDir, filter, testFiles});
selectedFiles = globs.applyTestFileFilter({
cwd: this.options.projectDir,
filter: filter.map(({pattern}) => pattern),
testFiles
});
}
}
} catch (error) {
Expand Down Expand Up @@ -209,9 +214,11 @@ class Api extends Emittery {
return;
}

const lineNumbers = getApplicableLineNumbers(globs.normalizeFileForMatching(apiOptions.projectDir, file), filter);
const options = {
...apiOptions,
providerStates,
lineNumbers,
recordNewSnapshots: !isCi,
// If we're looking for matches, run every single test process in exclusive-only mode
runOnlyExclusive: apiOptions.match.length > 0 || runtimeOptions.runOnlyExclusive === true
Expand All @@ -223,7 +230,7 @@ class Api extends Emittery {
}

const worker = fork(file, options, apiOptions.nodeArguments);
runStatus.observeWorker(worker, file);
runStatus.observeWorker(worker, file, {selectingLines: lineNumbers.length > 0});

pendingWorkers.add(worker);
worker.promise.then(() => {
Expand Down
15 changes: 11 additions & 4 deletions lib/cli.js
Expand Up @@ -120,7 +120,7 @@ exports.run = async () => { // eslint-disable-line complexity
})
.command('* [<pattern>...]', 'Run tests', yargs => yargs.options(FLAGS).positional('pattern', {
array: true,
describe: 'Glob patterns to select what test files to run. Leave empty if you want AVA to run all test files instead',
describe: 'Glob patterns to select what test files to run. Leave empty if you want AVA to run all test files instead. Add a colon and specify line numbers of specific tests to run',
type: 'string'
}))
.command(
Expand All @@ -143,7 +143,7 @@ exports.run = async () => { // eslint-disable-line complexity
}
}).positional('pattern', {
demand: true,
describe: 'Glob patterns to select a single test file to debug',
describe: 'Glob patterns to select a single test file to debug. Add a colon and specify line numbers of specific tests to run',
type: 'string'
}),
argv => {
Expand All @@ -163,6 +163,7 @@ exports.run = async () => { // eslint-disable-line complexity
})
.example('$0')
.example('$0 test.js')
.example('$0 test.js:4,7-9')
.help();

const combined = {...conf};
Expand Down Expand Up @@ -263,9 +264,10 @@ exports.run = async () => { // eslint-disable-line complexity
const TapReporter = require('./reporters/tap');
const Watcher = require('./watcher');
const normalizeExtensions = require('./extensions');
const {normalizeGlobs, normalizePatterns} = require('./globs');
const {normalizeGlobs, normalizePattern} = require('./globs');
const normalizeNodeArguments = require('./node-arguments');
const validateEnvironmentVariables = require('./environment-variables');
const {splitPatternAndLineNumbers} = require('./line-numbers');
const providerManager = require('./provider-manager');

let pkg;
Expand Down Expand Up @@ -349,7 +351,12 @@ exports.run = async () => { // eslint-disable-line complexity
const match = combined.match === '' ? [] : arrify(combined.match);

const input = debug ? debug.files : (argv.pattern || []);
const filter = normalizePatterns(input.map(fileOrPattern => path.relative(projectDir, path.resolve(process.cwd(), fileOrPattern))));
const filter = input
.map(pattern => splitPatternAndLineNumbers(pattern))
.map(({pattern, ...rest}) => ({
pattern: normalizePattern(path.relative(projectDir, path.resolve(process.cwd(), pattern))),
...rest
}));

const api = new Api({
cacheEnabled: combined.cache !== false,
Expand Down
4 changes: 3 additions & 1 deletion lib/fork.js
Expand Up @@ -48,7 +48,9 @@ module.exports = (file, options, execArgv = process.execArgv) => {
let forcedExit = false;
const send = evt => {
if (subprocess.connected && !finished && !forcedExit) {
subprocess.send({ava: evt});
subprocess.send({ava: evt}, () => {
// Disregard errors.
});
}
};

Expand Down
26 changes: 15 additions & 11 deletions lib/globs.js
Expand Up @@ -23,23 +23,27 @@ const defaultIgnoredByWatcherPatterns = [

const buildExtensionPattern = extensions => extensions.length === 1 ? extensions[0] : `{${extensions.join(',')}}`;

function normalizePatterns(patterns) {
function normalizePattern(pattern) {
// Always use `/` in patterns, harmonizing matching across platforms
if (process.platform === 'win32') {
patterns = patterns.map(pattern => slash(pattern));
pattern = slash(pattern);
}

return patterns.map(pattern => {
if (pattern.startsWith('./')) {
return pattern.slice(2);
}
if (pattern.startsWith('./')) {
return pattern.slice(2);
}

if (pattern.startsWith('!./')) {
return `!${pattern.slice(3)}`;
}
if (pattern.startsWith('!./')) {
return `!${pattern.slice(3)}`;
}

return pattern;
});
return pattern;
}

exports.normalizePattern = normalizePattern;

function normalizePatterns(patterns) {
return patterns.map(pattern => normalizePattern(pattern));
}

exports.normalizePatterns = normalizePatterns;
Expand Down
64 changes: 64 additions & 0 deletions lib/line-numbers.js
@@ -0,0 +1,64 @@
'use strict';

const micromatch = require('micromatch');
const flatten = require('lodash/flatten');

const NUMBER_REGEX = /^\d+$/;
const RANGE_REGEX = /^(?<startGroup>\d+)-(?<endGroup>\d+)$/;
const LINE_NUMBERS_REGEX = /^(?:\d+(?:-\d+)?,?)+$/;
const DELIMITER = ':';

const distinctArray = array => [...new Set(array)];
const sortNumbersAscending = array => {
const sorted = [...array];
sorted.sort((a, b) => a - b);
return sorted;
};

const parseNumber = string => Number.parseInt(string, 10);
const removeAllWhitespace = string => string.replace(/\s/g, '');
const range = (start, end) => new Array(end - start + 1).fill(start).map((element, index) => element + index);

const parseLineNumbers = suffix => sortNumbersAscending(distinctArray(flatten(
suffix.split(',').map(part => {
if (NUMBER_REGEX.test(part)) {
return parseNumber(part);
}

const {groups: {startGroup, endGroup}} = RANGE_REGEX.exec(part);
const start = parseNumber(startGroup);
const end = parseNumber(endGroup);

if (start > end) {
return range(end, start);
}

return range(start, end);
})
)));

function splitPatternAndLineNumbers(pattern) {
const parts = pattern.split(DELIMITER);
if (parts.length === 1) {
return {pattern, lineNumbers: null};
}

const suffix = removeAllWhitespace(parts.pop());
if (!LINE_NUMBERS_REGEX.test(suffix)) {
return {pattern, lineNumbers: null};
}

return {pattern: parts.join(DELIMITER), lineNumbers: parseLineNumbers(suffix)};
}

exports.splitPatternAndLineNumbers = splitPatternAndLineNumbers;

function getApplicableLineNumbers(normalizedFilePath, filter) {
return sortNumbersAscending(distinctArray(flatten(
filter
.filter(({pattern, lineNumbers}) => lineNumbers && micromatch.isMatch(normalizedFilePath, pattern))
.map(({lineNumbers}) => lineNumbers)
)));
}

exports.getApplicableLineNumbers = getApplicableLineNumbers;
32 changes: 29 additions & 3 deletions lib/reporters/mini.js
Expand Up @@ -109,8 +109,10 @@ class MiniReporter {
this.failures = [];
this.filesWithMissingAvaImports = new Set();
this.filesWithoutDeclaredTests = new Set();
this.filesWithoutMatchedLineNumbers = new Set();
this.internalErrors = [];
this.knownFailures = [];
this.lineNumberErrors = [];
this.matching = false;
this.prefixTitle = (testFile, title) => title;
this.previousFailures = 0;
Expand Down Expand Up @@ -148,6 +150,8 @@ class MiniReporter {
}

consumeStateChange(evt) { // eslint-disable-line complexity
const fileStats = this.stats && evt.testFile ? this.stats.byFile.get(evt.testFile) : null;

switch (evt.type) {
case 'declared-test':
// Ignore
Expand All @@ -164,6 +168,10 @@ class MiniReporter {
this.writeWithCounts(colors.error(`${figures.cross} Internal error`));
}

break;
case 'line-number-selection-error':
this.lineNumberErrors.push(evt);
this.writeWithCounts(colors.information(`${figures.warning} Could not parse ${this.relativeFile(evt.testFile)} for line number selection`));
break;
case 'missing-ava-import':
this.filesWithMissingAvaImports.add(evt.testFile);
Expand Down Expand Up @@ -203,15 +211,18 @@ class MiniReporter {
this.unhandledRejections.push(evt);
break;
case 'worker-failed':
if (this.stats.byFile.get(evt.testFile).declaredTests === 0) {
if (fileStats.declaredTests === 0) {
this.filesWithoutDeclaredTests.add(evt.testFile);
}

break;
case 'worker-finished':
if (this.stats.byFile.get(evt.testFile).declaredTests === 0) {
if (fileStats.declaredTests === 0) {
this.filesWithoutDeclaredTests.add(evt.testFile);
this.writeWithCounts(colors.error(`${figures.cross} No tests found in ${this.relativeFile(evt.testFile)}`));
} else if (fileStats.selectingLines && fileStats.selectedTests === 0) {
this.filesWithoutMatchedLineNumbers.add(evt.testFile);
this.writeWithCounts(colors.error(`${figures.cross} Line numbers for ${this.relativeFile(evt.testFile)} did not match any tests`));
}

break;
Expand Down Expand Up @@ -432,7 +443,22 @@ class MiniReporter {
}
}

if (this.filesWithMissingAvaImports.size > 0 || this.filesWithoutDeclaredTests.size > 0) {
if (this.lineNumberErrors.length > 0) {
for (const evt of this.lineNumberErrors) {
this.lineWriter.writeLine(colors.information(`${figures.warning} Could not parse ${this.relativeFile(evt.testFile)} for line number selection`));
}
}

if (this.filesWithoutMatchedLineNumbers.size > 0) {
for (const testFile of this.filesWithoutMatchedLineNumbers) {
if (!this.filesWithMissingAvaImports.has(testFile) && !this.filesWithoutDeclaredTests.has(testFile)) {
this.lineWriter.writeLine(colors.error(`${figures.cross} Line numbers for ${this.relativeFile(testFile)} did not match any tests`) + firstLinePostfix);
firstLinePostfix = '';
}
}
}

if (this.filesWithMissingAvaImports.size > 0 || this.filesWithoutDeclaredTests.size > 0 || this.filesWithoutMatchedLineNumbers.size > 0) {
this.lineWriter.writeLine();
}

Expand Down
5 changes: 5 additions & 0 deletions lib/reporters/verbose.js
Expand Up @@ -132,6 +132,9 @@ class VerboseReporter {
this.lineWriter.writeLine();
this.lineWriter.writeLine();
break;
case 'line-number-selection-error':
this.lineWriter.writeLine(colors.information(`${figures.warning} Could not parse ${this.relativeFile(evt.testFile)} for line number selection`));
break;
case 'missing-ava-import':
this.filesWithMissingAvaImports.add(evt.testFile);
this.lineWriter.writeLine(colors.error(`${figures.cross} No tests found in ${this.relativeFile(evt.testFile)}, make sure to import "ava" at the top of your test file`));
Expand Down Expand Up @@ -203,6 +206,8 @@ class VerboseReporter {
if (!evt.forcedExit && !this.filesWithMissingAvaImports.has(evt.testFile)) {
if (fileStats.declaredTests === 0) {
this.lineWriter.writeLine(colors.error(`${figures.cross} No tests found in ${this.relativeFile(evt.testFile)}`));
} else if (fileStats.selectingLines && fileStats.selectedTests === 0) {
this.lineWriter.writeLine(colors.error(`${figures.cross} Line numbers for ${this.relativeFile(evt.testFile)} did not match any tests`));
} else if (!this.failFastEnabled && fileStats.remainingTests > 0) {
this.lineWriter.writeLine(colors.error(`${figures.cross} ${fileStats.remainingTests} ${plur('test', fileStats.remainingTests)} remaining in ${this.relativeFile(evt.testFile)}`));
}
Expand Down

0 comments on commit 1222ce9

Please sign in to comment.