diff --git a/docs/05-command-line.md b/docs/05-command-line.md index 4abdf07a3..b1e842fa2 100644 --- a/docs/05-command-line.md +++ b/docs/05-command-line.md @@ -6,7 +6,7 @@ Translations: [Français](https://github.com/avajs/ava-docs/blob/master/fr_FR/do $ npx ava --help Usage - ava [ ...] + ava [ ...] Options --watch, -w Re-run tests when tests and source files change @@ -27,6 +27,7 @@ $ npx ava --help Examples ava ava test.js test2.js + ava test.js:5 test2.js:4-5,9 ava test-*.js ava test @@ -125,6 +126,48 @@ test(function foo(t) { }); ``` +## Running tests residing on a given line + +Sometimes during development it's helpful to run a test that is on a specific line. AVA allows you to run tests based on their location in the source code. + +Match the test on line `5`: + +```console +npx ava test.js:5 +``` + +It is also possible to use a range: + +```console +npx ava test.js:5-10 +``` + +Using multiple ranges is also possible: + +```console +npx ava test.js:5-10,18,10 +``` + +To run a test you can use any line from where you call `test` until your next call to the `test` function, as an example you can use any of the lines from 3 to 6 in the following code to run `foo`. + +```js + 1 import test from 'ava'; + 2 + 3 test('foo', t => { + 4 t.pass(); + 5 }); + 6 + 7 test('bar', async t => { + 8 const bar = Promise.resolve('bar'); + 9 t.is(await bar, 'bar'); +10 }); +``` + +> Please note that using any number more than the maximum line-number will execute the last test. + +Using `--match` beside this feature will only match the tests on the given area. + + ## Resetting AVA's cache AVA caches the compiled test and helper files. It automatically recompiles these files when you change them. AVA tries its best to detect changes to your Babel configuration files, plugins and presets. If it seems like your latest Babel configuration isn't being applied, however, you can run AVA with the `--reset-cache` flag to reset AVA's cache. If set, all files in the `node_modules/.cache/ava` directory are deleted. Run AVA as normal to apply your new Babel configuration. diff --git a/lib/api.js b/lib/api.js index c9cb29295..245e80bd3 100644 --- a/lib/api.js +++ b/lib/api.js @@ -51,7 +51,12 @@ class Api extends Emittery { } async run(files = [], runtimeOptions = {}) { - files = files.map(file => path.resolve(this.options.resolveTestsFrom, file)); + const ranges = new Map(runtimeOptions.ranges || []); + files = files.map(file => { + const filename = path.resolve(this.options.resolveTestsFrom, file); + ranges.set(filename, ranges.get(file)); + return filename; + }); const apiOptions = this.options; @@ -223,7 +228,8 @@ class Api extends Emittery { ...apiOptions, 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 + runOnlyExclusive: apiOptions.match.length > 0 || runtimeOptions.runOnlyExclusive === true, + lines: ranges.get(file) || [] }; if (precompilation) { options.cacheDir = precompilation.cacheDir; diff --git a/lib/cli.js b/lib/cli.js index b5d3c3497..b08c42bee 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -8,6 +8,7 @@ const meow = require('meow'); const Promise = require('bluebird'); const isCi = require('is-ci'); const loadConfig = require('./load-config'); +const rangeParser = require('./range'); // Bluebird specific Promise.longStackTraces(); @@ -35,7 +36,7 @@ exports.run = async () => { // eslint-disable-line complexity const cli = meow(` Usage - ava [ ...] + ava [ ...] Options --watch, -w Re-run tests when tests and source files change @@ -56,6 +57,7 @@ exports.run = async () => { // eslint-disable-line complexity Examples ava ava test.js test2.js + ava test.js:5 test2.js:4-5,9 ava test-*.js ava test @@ -280,7 +282,14 @@ exports.run = async () => { // eslint-disable-line complexity }); watcher.observeStdin(process.stdin); } else { - const runStatus = await api.run(files); + const {filenames, ranges, ignored} = rangeParser.parseFileSelections(files); + if (ignored.length > 0) { + for (const file of ignored) { + console.error(`Ignoring other tests from ${file} since you selected lines.`); + } + } + + const runStatus = await api.run(filenames, {ranges}); process.exitCode = runStatus.suggestExitCode({matching: match.length > 0}); reporter.endRun(); } diff --git a/lib/range.js b/lib/range.js new file mode 100644 index 000000000..204c2e78d --- /dev/null +++ b/lib/range.js @@ -0,0 +1,53 @@ +'use strict'; +const rangeParser = require('parse-numeric-range'); + +function parseFileSelections(files) { + const ranges = new Map(); + const ignored = []; + const filenames = []; + + for (const file of files) { + const [actualFilename, lines] = parseFileSelection(file); + + if (!ranges.has(actualFilename)) { + ranges.set(actualFilename, []); + filenames.push(actualFilename); + } + + if (lines.length === 0 && ranges.get(actualFilename).length > 0) { + ignored.push(file); + continue; + } + + ranges.set(actualFilename, ranges.get(actualFilename).concat(lines)); + } + + for (const file of filenames) { + ranges.set(file, [...new Set(ranges.get(file))]); + } + + return { + filenames, + ranges, + ignored: [...new Set(ignored)] + }; +} + +exports.parseFileSelections = parseFileSelections; + +const rangeRegExp = /^(\d+(-\d+)?,?)+$/; + +function parseFileSelection(file) { + const colonIndex = file.lastIndexOf(':'); + const mightHaveRange = colonIndex > -1 && file[colonIndex + 1] !== '\\'; + const rangeStr = file.slice(colonIndex + 1); + const actualFilename = file.slice(0, colonIndex); + + if (mightHaveRange && rangeRegExp.test(rangeStr)) { + return [actualFilename, rangeParser.parse(rangeStr)]; + } + + return [file, []]; +} + +exports.parseFileSelection = parseFileSelection; diff --git a/lib/runner.js b/lib/runner.js index 18af02f7a..356533ad4 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -1,6 +1,8 @@ 'use strict'; const Emittery = require('emittery'); const matcher = require('matcher'); +const callsites = require('callsites'); +const sourceMapSupport = require('source-map-support'); const ContextRef = require('./context-ref'); const createChain = require('./create-chain'); const snapshotManager = require('./snapshot-manager'); @@ -15,6 +17,7 @@ class Runner extends Emittery { this.failWithoutAssertions = options.failWithoutAssertions !== false; this.file = options.file; this.match = options.match || []; + this.lines = options.lines || []; this.projectDir = options.projectDir; this.recordNewSnapshots = options.recordNewSnapshots === true; this.runOnlyExclusive = options.runOnlyExclusive === true; @@ -22,6 +25,7 @@ class Runner extends Emittery { this.snapshotDir = options.snapshotDir; this.updateSnapshots = options.updateSnapshots; + this.callsiteLineNumbers = []; this.activeRunnables = new Set(); this.boundCompareTestSnapshot = this.compareTestSnapshot.bind(this); this.interrupted = false; @@ -52,11 +56,29 @@ class Runner extends Emittery { if (!scheduledStart) { scheduledStart = true; process.nextTick(() => { + this.callsiteLineNumbers.push(Infinity); + this.callsiteLineNumbers.sort((a, b) => a - b); hasStarted = true; this.start(); }); } + if (this.lines.length > 0) { + let callsiteLineNumber; + + for (const jsCallsite of callsites()) { + const callsite = sourceMapSupport.wrapCallSite(jsCallsite); + const fileName = callsite.getFileName(); + if (jsCallsite === this.file || fileName === this.file) { + callsiteLineNumber = callsite.getLineNumber(); + break; + } + } + + metadata.callsiteLineNumber = callsiteLineNumber; + this.callsiteLineNumbers.push(callsiteLineNumber); + } + const specifiedTitle = typeof args[0] === 'string' ? args.shift() : undefined; @@ -347,10 +369,21 @@ class Runner extends Emittery { } async start() { + const isIgnoredByLineSelection = task => { + if (this.lines.length === 0) { + return false; + } + + const lineNumber = task.metadata.callsiteLineNumber; + const endingLineNumber = this.callsiteLineNumbers[this.callsiteLineNumbers.indexOf(lineNumber) + 1] - 1; + + return !this.lines.some(line => line >= lineNumber && line <= endingLineNumber); + }; + const concurrentTests = []; const serialTests = []; for (const task of this.tasks.serial) { - if (this.runOnlyExclusive && !task.metadata.exclusive) { + if (isIgnoredByLineSelection(task) || (this.runOnlyExclusive && !task.metadata.exclusive)) { continue; } @@ -368,7 +401,7 @@ class Runner extends Emittery { } for (const task of this.tasks.concurrent) { - if (this.runOnlyExclusive && !task.metadata.exclusive) { + if (isIgnoredByLineSelection(task) || (this.runOnlyExclusive && !task.metadata.exclusive)) { continue; } @@ -390,7 +423,7 @@ class Runner extends Emittery { } for (const task of this.tasks.todo) { - if (this.runOnlyExclusive && !task.metadata.exclusive) { + if (isIgnoredByLineSelection(task) || (this.runOnlyExclusive && !task.metadata.exclusive)) { continue; } diff --git a/lib/worker/subprocess.js b/lib/worker/subprocess.js index 90b829d6a..836149bc9 100644 --- a/lib/worker/subprocess.js +++ b/lib/worker/subprocess.js @@ -34,6 +34,7 @@ ipc.options.then(options => { failFast: options.failFast, failWithoutAssertions: options.failWithoutAssertions, file: options.file, + lines: options.lines, match: options.match, projectDir: options.projectDir, recordNewSnapshots: options.recordNewSnapshots, diff --git a/package-lock.json b/package-lock.json index d5b03fcb8..34cc349da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1150,10 +1150,9 @@ "integrity": "sha1-qEq8glpV70yysCi9dOIFpluaSZY=" }, "callsites": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.0.0.tgz", - "integrity": "sha512-tWnkwu9YEq2uzlBDI4RcLn8jrFvF9AOi8PxDNU3hZZjJcjkcRAq3vCI+vZcg1SuxISDYe86k9VZFwAxDiJGoAw==", - "dev": true + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" }, "camelcase": { "version": "4.1.0", @@ -5092,7 +5091,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" } } @@ -6610,6 +6609,11 @@ "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==" }, + "parse-numeric-range": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/parse-numeric-range/-/parse-numeric-range-0.0.2.tgz", + "integrity": "sha1-tPCdQTx6282Yf26SM8e0shDJOOQ=" + }, "parse-passwd": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", diff --git a/package.json b/package.json index 3dd582f64..3c6b0915a 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "array-uniq": "^2.1.0", "arrify": "^2.0.1", "bluebird": "^3.5.5", + "callsites": "^3.1.0", "chalk": "^2.4.2", "chokidar": "^3.0.2", "chunkd": "^1.0.0", @@ -116,6 +117,7 @@ "observable-to-promise": "^1.0.0", "ora": "^3.4.0", "package-hash": "^4.0.0", + "parse-numeric-range": "0.0.2", "pkg-conf": "^3.1.0", "plur": "^3.1.1", "pretty-ms": "^5.0.0", diff --git a/test/api.js b/test/api.js index b26984fce..73c749612 100644 --- a/test/api.js +++ b/test/api.js @@ -1212,3 +1212,25 @@ test('`esm` package support', t => { t.is(runStatus.stats.passedTests, 1); }); }); + +test('`range` should filter tests', t => { + const api = apiCreator(); + const tests = []; + + api.on('run', plan => { + plan.status.on('stateChange', evt => { + if (evt.type === 'test-failed' || evt.type === 'test-passed') { + tests.push(evt.title); + } + }); + }); + + const filename = path.join(__dirname, 'fixture/range.js'); + + return api.run([filename], {ranges: new Map([[filename, [8]]])}) + .then(runStatus => { + t.strictDeepEqual(tests, ['unicorn']); + t.is(runStatus.stats.passedTests, 1); + t.is(runStatus.stats.declaredTests, 3); + }); +}); diff --git a/test/fixture/range.js b/test/fixture/range.js new file mode 100644 index 000000000..8da169021 --- /dev/null +++ b/test/fixture/range.js @@ -0,0 +1,14 @@ +import test from '../..'; + +test('foo', t => { + t.pass(); +}); + +test('unicorn', t => { + t.pass(); +}); + +test('rainbow', t => { + t.is(1, 1); + t.pass(); +}); diff --git a/test/range.js b/test/range.js new file mode 100644 index 000000000..fe9ecc295 --- /dev/null +++ b/test/range.js @@ -0,0 +1,36 @@ +'use strict'; +require('../lib/chalk').set(); + +const {test} = require('tap'); +const rangeParser = require('../lib/range'); + +test('range parse', t => { + t.strictDeepEqual(rangeParser.parseFileSelection('a.js:3'), ['a.js', [3]]); + t.strictDeepEqual(rangeParser.parseFileSelection('a.js:3-5'), ['a.js', [3, 4, 5]]); + t.strictDeepEqual(rangeParser.parseFileSelection('a.js:3-5,8'), ['a.js', [3, 4, 5, 8]]); + t.strictDeepEqual(rangeParser.parseFileSelection('a.js:3-5,8-9'), ['a.js', [3, 4, 5, 8, 9]]); + t.strictDeepEqual(rangeParser.parseFileSelection('a.js:3-5x'), ['a.js:3-5x', []]); + t.strictDeepEqual(rangeParser.parseFileSelection('c:\\path\\file.js'), ['c:\\path\\file.js', []]); + t.strictDeepEqual(rangeParser.parseFileSelection('c:\\path\\file.js:27'), ['c:\\path\\file.js', [27]]); + t.end(); +}); + +test('range basic functionality', t => { + const case1 = rangeParser.parseFileSelections(['a.js:5', 'a.js:6-8']); + t.strictDeepEqual(case1, { + filenames: ['a.js'], + ignored: [], + ranges: new Map([['a.js', [5, 6, 7, 8]]]) + }); + t.end(); +}); + +test('range ignore file when there is selection', t => { + const case1 = rangeParser.parseFileSelections(['a.js:5', 'a.js:6-8', 'a.js', 'b.js']); + t.strictDeepEqual(case1, { + filenames: ['a.js', 'b.js'], + ignored: ['a.js'], + ranges: new Map([['a.js', [5, 6, 7, 8]], ['b.js', []]]) + }); + t.end(); +});