├── .gitignore ├── .husky └── pre-commit ├── test ├── fixture1 │ ├── tags.js │ └── spec1.cy.js ├── tags-imported-from-another-file.js ├── jsx.js ├── for-of.js ├── resolve-exports.js ├── required-tags.js ├── effective-tags.js ├── exclusive.js ├── find-effective-tags.js ├── format-tags.js ├── filter-by-effective-tags.js ├── only-tags.js ├── resolve-imports.js ├── template.js ├── dynamic-names.js ├── basic.js ├── pending.js ├── count-tags.js ├── resolved-tags.js ├── comment.js ├── tags.js ├── typescript.js ├── visit-each-test.js ├── format.js ├── structure.js └── counts.js ├── ava.config.mjs ├── .prettierrc.json ├── test-cy ├── spec-a.js └── spec-b.js ├── renovate.json ├── demo └── spec.js ├── ast-demo └── show-ast.js ├── bin ├── find-test-names.js ├── update-test-count.js └── print-tests.js ├── src ├── relative-path-resolver.js ├── update-text.js ├── format-test-list.js ├── resolve-exports.js ├── resolve-imports.js └── index.js ├── .github └── workflows │ └── ci.yml ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm test 2 | -------------------------------------------------------------------------------- /test/fixture1/tags.js: -------------------------------------------------------------------------------- 1 | export const userTag = '@user' 2 | -------------------------------------------------------------------------------- /test/fixture1/spec1.cy.js: -------------------------------------------------------------------------------- 1 | import { userTag } from './tags.js' 2 | 3 | it('works 1') 4 | 5 | it('works 2') 6 | -------------------------------------------------------------------------------- /ava.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | files: [ 3 | 'test/*.js', // Adjust the pattern as needed 4 | ], 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /test-cy/spec-a.js: -------------------------------------------------------------------------------- 1 | describe('Suite A', () => { 2 | it('works 1', () => {}) 3 | it('works 2', { tags: 'A' }, () => {}) 4 | }) 5 | -------------------------------------------------------------------------------- /test-cy/spec-b.js: -------------------------------------------------------------------------------- 1 | describe('Suite B', () => { 2 | it('works 1', () => {}) 3 | it('works 2', { tags: 'A', requiredTags: 'smoke' }, () => {}) 4 | }) 5 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "automerge": true, 4 | "major": { 5 | "automerge": false 6 | }, 7 | "minor": { 8 | "automerge": true 9 | }, 10 | "prConcurrentLimit": 3, 11 | "prHourlyLimit": 2, 12 | "schedule": ["after 10pm and before 5am on every weekday", "every weekend"], 13 | "masterIssue": true, 14 | "labels": ["type: dependencies", "renovate"] 15 | } 16 | -------------------------------------------------------------------------------- /test/tags-imported-from-another-file.js: -------------------------------------------------------------------------------- 1 | const { findEffectiveTestTagsIn } = require('../src') 2 | const test = require('ava') 3 | const path = require('path') 4 | 5 | test('individual tags imported from another file', (t) => { 6 | t.plan(0) 7 | 8 | const fullName = path.join(__dirname, './fixture1/spec1.cy.js') 9 | const result = findEffectiveTestTagsIn(fullName) 10 | console.log(result) 11 | }) 12 | -------------------------------------------------------------------------------- /demo/spec.js: -------------------------------------------------------------------------------- 1 | // example Mocha / Cypress spec 2 | describe('first describe', () => { 3 | it('works 1', () => {}) 4 | }) 5 | 6 | it('works 2', () => {}) 7 | 8 | describe('parent suite', () => { 9 | describe('inner suite', () => { 10 | it('loads', () => {}) 11 | }) 12 | }) 13 | 14 | it.skip('pending test', () => {}) 15 | 16 | // test name is a variable, not a literal string 17 | const testName = 'nice' 18 | it(testName, () => {}) 19 | -------------------------------------------------------------------------------- /ast-demo/show-ast.js: -------------------------------------------------------------------------------- 1 | const babel = require('@babel/parser') 2 | 3 | const source = ` 4 | /** 5 | * beginning 6 | */ 7 | 8 | // this is a comment 9 | const foo = 'bar' 10 | 11 | // JSX 12 | const Coounter = () =>
Count
13 | ` 14 | 15 | const plugins = [ 16 | 'estree', // To generate estree compatible AST 17 | 'jsx', 18 | ] 19 | 20 | const AST = babel.parse(source, { 21 | plugins, 22 | sourceType: 'script', 23 | }).program 24 | 25 | console.log(AST.body[0].leadingComments) 26 | -------------------------------------------------------------------------------- /bin/find-test-names.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require('path') 4 | const fs = require('fs') 5 | 6 | require('simple-bin-help')({ 7 | minArguments: 3, 8 | packagePath: path.join(__dirname, '..', 'package.json'), 9 | help: 'use: npx find-test-names ', 10 | }) 11 | 12 | const filename = process.argv[2] 13 | const { getTestNames } = require('..') 14 | const source = fs.readFileSync(filename, 'utf8') 15 | const result = getTestNames(source) 16 | console.log('describe names:', result.suiteNames.join(', ')) 17 | console.log('test names:', result.testNames.join(', ')) 18 | -------------------------------------------------------------------------------- /test/jsx.js: -------------------------------------------------------------------------------- 1 | const { stripIndent } = require('common-tags') 2 | const { getTestNames } = require('..') 3 | const test = require('ava') 4 | 5 | // https://github.com/bahmutov/find-test-names/issues/64 6 | test('jsx', (t) => { 7 | t.plan(1) 8 | const source = stripIndent` 9 | describe('parent', () => { 10 | it('has jsx component', () => { 11 | cy.mount() 12 | }) 13 | }) 14 | ` 15 | const result = getTestNames(source) 16 | t.deepEqual(result, { 17 | suiteNames: ['parent'], 18 | testNames: ['has jsx component'], 19 | tests: [ 20 | { type: 'test', pending: false, name: 'has jsx component' }, 21 | { type: 'suite', pending: false, name: 'parent' }, 22 | ], 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /test/for-of.js: -------------------------------------------------------------------------------- 1 | const { stripIndent } = require('common-tags') 2 | const test = require('ava') 3 | const { getTestNames } = require('../src') 4 | 5 | test('for of loop', (t) => { 6 | t.plan(1) 7 | const source = stripIndent` 8 | describe('foo', () => { 9 | it('bar', () => { 10 | for (const c of []); 11 | }) 12 | }) 13 | ` 14 | const result = getTestNames(source) 15 | t.deepEqual(result, { 16 | suiteNames: ['foo'], 17 | testNames: ['bar'], 18 | tests: [ 19 | { 20 | name: 'bar', 21 | type: 'test', 22 | pending: false, 23 | }, 24 | { 25 | name: 'foo', 26 | type: 'suite', 27 | pending: false, 28 | }, 29 | ], 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /test/resolve-exports.js: -------------------------------------------------------------------------------- 1 | const { resolveExports } = require('../src/resolve-exports') 2 | const { stripIndent } = require('common-tags') 3 | const test = require('ava') 4 | 5 | test('finds the named exports', (t) => { 6 | t.plan(1) 7 | const source = stripIndent` 8 | export const foo = 'foo' 9 | export const bar = 'bar' 10 | ` 11 | const result = resolveExports(source) 12 | t.deepEqual(result, { foo: 'foo', bar: 'bar' }) 13 | }) 14 | 15 | test('finds the named exported object', (t) => { 16 | t.plan(1) 17 | const source = stripIndent` 18 | export const TAGS = { 19 | foo: 'foo', 20 | bar: 'bar' 21 | } 22 | ` 23 | const result = resolveExports(source) 24 | t.deepEqual(result, { TAGS: { foo: 'foo', bar: 'bar' } }) 25 | }) 26 | -------------------------------------------------------------------------------- /src/relative-path-resolver.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const debug = require('debug')('find-test-names') 3 | const fs = require('fs') 4 | 5 | function relativePathResolver(currentFilename) { 6 | return function (relativePath) { 7 | if (!currentFilename) { 8 | return 9 | } 10 | 11 | const dir = require('path').dirname(currentFilename) 12 | const resolved = path.resolve(dir, relativePath) 13 | debug( 14 | 'resolved "%s" wrt "%s" to "%s"', 15 | relativePath, 16 | currentFilename, 17 | resolved, 18 | ) 19 | 20 | const exists = fs.existsSync(resolved) 21 | if (!exists) { 22 | debug('"%s" does not exist', resolved) 23 | return 24 | } 25 | 26 | return fs.readFileSync(resolved, 'utf-8') 27 | } 28 | } 29 | 30 | module.exports = { relativePathResolver } 31 | -------------------------------------------------------------------------------- /test/required-tags.js: -------------------------------------------------------------------------------- 1 | const { findEffectiveTestTags } = require('../src') 2 | const { stripIndent } = require('common-tags') 3 | const test = require('ava') 4 | 5 | test('applies required tags to the tests inside the suite', (t) => { 6 | t.plan(1) 7 | const source = stripIndent` 8 | describe('parent', {requiredTags: '@top'}, () => { 9 | describe('child', () => { 10 | it('works a', () => {}) 11 | it('works b', () => {}) 12 | }) 13 | }) 14 | ` 15 | const result = findEffectiveTestTags(source) 16 | // the required tags ARE effective tags too 17 | const expected = { 18 | 'parent child works a': { 19 | effectiveTags: ['@top'], 20 | requiredTags: ['@top'], 21 | }, 22 | 'parent child works b': { 23 | effectiveTags: ['@top'], 24 | requiredTags: ['@top'], 25 | }, 26 | } 27 | t.deepEqual(result, expected) 28 | }) 29 | -------------------------------------------------------------------------------- /test/effective-tags.js: -------------------------------------------------------------------------------- 1 | const { getTestNames, setEffectiveTags } = require('../src') 2 | const { stripIndent } = require('common-tags') 3 | const test = require('ava') 4 | 5 | test('visits each test with effective tags', (t) => { 6 | t.plan(3) 7 | const source = stripIndent` 8 | describe('parent', {tags: '@user'}, () => { 9 | describe('child', {tags: '@auth'}, () => { 10 | it('works a', {tags: '@one'}, () => {}) 11 | it('works b', () => {}) 12 | }) 13 | }) 14 | ` 15 | const result = getTestNames(source, true) 16 | t.deepEqual(result.testCount, 2) 17 | 18 | setEffectiveTags(result.structure) 19 | const tests = result.structure[0].suites[0].tests 20 | const firstTestTags = tests[0].effectiveTags 21 | t.deepEqual(firstTestTags, ['@auth', '@one', '@user']) 22 | const secondTestTags = tests[1].effectiveTags 23 | t.deepEqual(secondTestTags, ['@auth', '@user']) 24 | }) 25 | -------------------------------------------------------------------------------- /src/update-text.js: -------------------------------------------------------------------------------- 1 | const { stripIndent } = require('common-tags') 2 | 3 | const START = '' 4 | const END = '' 5 | 6 | function getCountTable(totals) { 7 | return stripIndent` 8 | Test status | Count 9 | ---|--- 10 | Total | ${totals.passed} 11 | Pending | ${totals.pending} 12 | ` 13 | } 14 | 15 | function updateText(text, totals) { 16 | const startIndex = text.indexOf(START) 17 | if (startIndex === -1) { 18 | throw new Error('Could not find cypress-test-counts comment') 19 | } 20 | 21 | const endIndex = text.indexOf(END) 22 | if (endIndex === -1) { 23 | throw new Error('Could not find cypress-test-counts-end comment') 24 | } 25 | 26 | const start = text.slice(0, startIndex) 27 | const end = text.slice(endIndex) 28 | const updatedTable = getCountTable(totals) 29 | return start + START + '\n' + updatedTable + '\n' + end 30 | } 31 | 32 | module.exports = { 33 | updateText, 34 | getCountTable, 35 | } 36 | -------------------------------------------------------------------------------- /bin/update-test-count.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require('path') 4 | const fs = require('fs') 5 | const tinyglobby = require('tinyglobby') 6 | const debug = require('debug')('find-test-names') 7 | 8 | require('simple-bin-help')({ 9 | minArguments: 4, 10 | packagePath: path.join(__dirname, '..', 'package.json'), 11 | help: "use: npx update-test-count filename.md 'file pattern'", 12 | }) 13 | 14 | const filename = process.argv[2] 15 | const pattern = process.argv[3] 16 | debug('using pattern "%s"', pattern) 17 | 18 | const filenames = tinyglobby.globSync(pattern) 19 | debug('found %d files', filenames.length) 20 | debug(filenames) 21 | 22 | const { getTestNames } = require('../src') 23 | 24 | const allTests = [] 25 | filenames.forEach((filename) => { 26 | const source = fs.readFileSync(filename, 'utf8') 27 | const result = getTestNames(source) 28 | console.log(result) 29 | allTests.push(...result.tests) 30 | }) 31 | 32 | debug('found %d tests', allTests.length) 33 | debug(allTests) 34 | 35 | // console.log('describe names:', result.suiteNames.join(', ')) 36 | // console.log('test names:', result.testNames.join(', ')) 37 | 38 | // TODO: write the tests into the Markdown file 39 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-24.04 7 | name: Build and test 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v4 11 | 12 | - name: Use Node 20+ 13 | # https://github.com/actions/setup-node 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: '22' 17 | 18 | - name: Install dependencies 19 | uses: bahmutov/npm-install@v1 20 | 21 | - name: Stop exclusive tests 22 | run: npm run stop-only 23 | 24 | - name: Check dependencies 25 | run: npm run deps 26 | 27 | - name: Run tests 28 | run: npm test 29 | 30 | - name: Run bin file 31 | run: npm run demo 32 | 33 | - name: Run print tests script 34 | run: npm run demo-print 35 | 36 | - name: Run update Markdown file 37 | run: npm run demo-update-md 38 | 39 | - name: Semantic Release 🚀 40 | if: github.ref == 'refs/heads/main' 41 | uses: cycjimmy/semantic-release-action@v4 42 | with: 43 | branch: main 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 47 | -------------------------------------------------------------------------------- /test/exclusive.js: -------------------------------------------------------------------------------- 1 | const { stripIndent } = require('common-tags') 2 | const test = require('ava') 3 | const { getTestNames } = require('..') 4 | 5 | test('exclusive test', (t) => { 6 | t.plan(1) 7 | const source = stripIndent` 8 | it.only('bar', () => {}) 9 | ` 10 | const result = getTestNames(source) 11 | // console.log(result) 12 | t.deepEqual(result, { 13 | suiteNames: [], 14 | testNames: ['bar'], 15 | tests: [{ type: 'test', pending: false, exclusive: true, name: 'bar' }], 16 | }) 17 | }) 18 | 19 | test('exclusive test flag in the structure', (t) => { 20 | t.plan(1) 21 | const source = stripIndent` 22 | it.only('bar', () => {}) 23 | ` 24 | const result = getTestNames(source, true) 25 | console.log(result) 26 | t.deepEqual(result, { 27 | suiteNames: [], 28 | testNames: ['bar'], 29 | tests: [{ type: 'test', pending: false, exclusive: true, name: 'bar' }], 30 | structure: [ 31 | { 32 | name: 'bar', 33 | tags: undefined, 34 | requiredTags: undefined, 35 | pending: false, 36 | exclusive: true, 37 | type: 'test', 38 | fullName: 'bar', 39 | }, 40 | ], 41 | testCount: 1, 42 | pendingTestCount: 0, 43 | fullTestNames: ['bar'], 44 | fullSuiteNames: [], 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /bin/print-tests.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require('path') 4 | const fs = require('fs') 5 | const tinyglobby = require('tinyglobby') 6 | const debug = require('debug')('find-test-names') 7 | const { getTestNames, formatTestList } = require('..') 8 | 9 | require('simple-bin-help')({ 10 | minArguments: 3, 11 | packagePath: path.join(__dirname, '..', 'package.json'), 12 | help: "use: npx find-tests 'spec file pattern'", 13 | }) 14 | 15 | const pattern = process.argv[2] 16 | debug('using pattern "%s"', pattern) 17 | 18 | const filenames = tinyglobby.globSync(pattern) 19 | debug('found %d files', filenames.length) 20 | debug(filenames) 21 | 22 | const allTests = [] 23 | filenames.forEach((filename) => { 24 | const source = fs.readFileSync(filename, 'utf8') 25 | const result = getTestNames(source, true) 26 | console.log(filename) 27 | // console.log(JSON.stringify(result.structure, null, 2)) 28 | // console.dir(result.structure, { depth: 5 }) 29 | // console.log('%j', result.structure) 30 | // const s = treeify(result.structure) 31 | // console.log(s) 32 | console.log(formatTestList(result.structure)) 33 | console.log('') 34 | allTests.push(...result.tests) 35 | }) 36 | 37 | // debug('found %d tests', allTests.length) 38 | // debug(allTests) 39 | 40 | // console.log('describe names:', result.suiteNames.join(', ')) 41 | // console.log('test names:', result.testNames.join(', ')) 42 | 43 | // TODO: write the tests into the Markdown file 44 | -------------------------------------------------------------------------------- /test/find-effective-tags.js: -------------------------------------------------------------------------------- 1 | const { findEffectiveTestTags, findEffectiveTestTagsIn } = require('../src') 2 | const { stripIndent } = require('common-tags') 3 | const { join } = require('path') 4 | const test = require('ava') 5 | 6 | test('finds effective test tags for each test', (t) => { 7 | t.plan(1) 8 | const source = stripIndent` 9 | describe('parent', {tags: '@user'}, () => { 10 | describe('child', {tags: '@auth'}, () => { 11 | it('works a', {tags: '@one'}, () => {}) 12 | it('works b', () => {}) 13 | }) 14 | }) 15 | it('sits at the top', {tags: '@root'}, () => {}) 16 | it.skip('has no tags') 17 | ` 18 | const result = findEffectiveTestTags(source) 19 | const expected = { 20 | 'sits at the top': { effectiveTags: ['@root'], requiredTags: [] }, 21 | 'parent child works a': { 22 | effectiveTags: ['@auth', '@one', '@user'], 23 | requiredTags: [], 24 | }, 25 | 'parent child works b': { 26 | effectiveTags: ['@auth', '@user'], 27 | requiredTags: [], 28 | }, 29 | 'has no tags': { effectiveTags: [], requiredTags: [] }, 30 | } 31 | t.deepEqual(result, expected) 32 | }) 33 | 34 | test('finds effective test tags in a file', (t) => { 35 | t.plan(1) 36 | const specFilename = join(__dirname, '..', 'test-cy', 'spec-a.js') 37 | const result = findEffectiveTestTagsIn(specFilename) 38 | const expected = { 39 | 'Suite A works 1': { effectiveTags: [], requiredTags: [] }, 40 | 'Suite A works 2': { effectiveTags: ['A'], requiredTags: [] }, 41 | } 42 | t.deepEqual(result, expected) 43 | }) 44 | -------------------------------------------------------------------------------- /test/format-tags.js: -------------------------------------------------------------------------------- 1 | const { stripIndent } = require('common-tags') 2 | const test = require('ava') 3 | const { formatTestList } = require('../src/format-test-list') 4 | 5 | test('tests with tags', (t) => { 6 | t.plan(1) 7 | const tests = [ 8 | { 9 | name: 'first', 10 | tags: ['tag1', 'tag2'], 11 | }, 12 | { 13 | name: 'second', 14 | tags: ['@sanity'], 15 | }, 16 | { 17 | name: 'last', 18 | tags: undefined, 19 | }, 20 | ] 21 | const s = formatTestList(tests) 22 | t.deepEqual( 23 | s, 24 | stripIndent` 25 | ├─ first [tag1, tag2] 26 | ├─ second [@sanity] 27 | └─ last 28 | `, 29 | ) 30 | }) 31 | 32 | test('suite with tags', (t) => { 33 | t.plan(1) 34 | const tests = [ 35 | { 36 | name: 'parent suite', 37 | tags: ['@user'], 38 | type: 'suite', 39 | }, 40 | ] 41 | const s = formatTestList(tests) 42 | t.deepEqual( 43 | s, 44 | stripIndent` 45 | └─ parent suite [@user] 46 | └─ (empty) 47 | `, 48 | ) 49 | }) 50 | 51 | test('tests with required tags', (t) => { 52 | t.plan(1) 53 | const tests = [ 54 | { 55 | name: 'first', 56 | requiredTags: ['tag1', 'tag2'], 57 | }, 58 | { 59 | name: 'second', 60 | tags: ['@sanity'], 61 | }, 62 | { 63 | name: 'both', 64 | tags: ['one'], 65 | requiredTags: ['two'], 66 | }, 67 | ] 68 | const s = formatTestList(tests) 69 | t.deepEqual( 70 | s, 71 | stripIndent` 72 | ├─ first [[tag1, tag2]] 73 | ├─ second [@sanity] 74 | └─ both [one] [[two]] 75 | `, 76 | ) 77 | }) 78 | -------------------------------------------------------------------------------- /test/filter-by-effective-tags.js: -------------------------------------------------------------------------------- 1 | const { 2 | getTestNames, 3 | setEffectiveTags, 4 | filterByEffectiveTags, 5 | } = require('../src') 6 | const { stripIndent } = require('common-tags') 7 | const test = require('ava') 8 | 9 | const source = stripIndent` 10 | describe('parent', {tags: '@user'}, () => { 11 | describe('child', {tags: '@auth'}, () => { 12 | it('works a', {tags: '@one'}, () => {}) 13 | it('works b', () => {}) 14 | }) 15 | }) 16 | describe('outside', {tags: '@new'}, () => { 17 | it('works c', () => {}) 18 | }) 19 | ` 20 | 21 | test('filters tests by effective tags', (t) => { 22 | t.plan(8) 23 | const result = getTestNames(source, true) 24 | t.deepEqual(result.testCount, 3) 25 | 26 | setEffectiveTags(result.structure) 27 | const testsOne = filterByEffectiveTags(result.structure, ['@one']) 28 | // finds a single test 29 | t.deepEqual(testsOne.length, 1) 30 | t.deepEqual(testsOne[0].name, 'works a') 31 | 32 | const testsNew = filterByEffectiveTags(result.structure, ['@new']) 33 | // finds a single test 34 | t.deepEqual(testsNew.length, 1) 35 | t.deepEqual(testsNew[0].name, 'works c') 36 | 37 | const testsAuth = filterByEffectiveTags(result.structure, ['@auth']) 38 | t.deepEqual(testsAuth.length, 2) 39 | t.deepEqual(testsAuth[0].name, 'works a') 40 | t.deepEqual(testsAuth[1].name, 'works b') 41 | }) 42 | 43 | test('filters tests by effective tags from the source code', (t) => { 44 | t.plan(3) 45 | // call "filterByEffectiveTags" with the source code string and tags 46 | const filtered = filterByEffectiveTags(source, ['@user']) 47 | t.deepEqual(filtered.length, 2) 48 | t.deepEqual(filtered[0].name, 'works a') 49 | t.deepEqual(filtered[1].name, 'works b') 50 | }) 51 | -------------------------------------------------------------------------------- /test/only-tags.js: -------------------------------------------------------------------------------- 1 | const { findEffectiveTestTags } = require('../src') 2 | const { stripIndent } = require('common-tags') 3 | const test = require('ava') 4 | 5 | test('finds only tags in a single test', (t) => { 6 | t.plan(1) 7 | // confirm "requiredTags" works 8 | const source = stripIndent` 9 | it('works', {tags: '@one', requiredTags: '@special'}, () => {}) 10 | ` 11 | const result = findEffectiveTestTags(source) 12 | const expected = { 13 | // required tag is also an effective tag 14 | works: { effectiveTags: ['@one', '@special'], requiredTags: ['@special'] }, 15 | } 16 | t.deepEqual(result, expected) 17 | }) 18 | 19 | test('applies suite only tags to the tests', (t) => { 20 | t.plan(1) 21 | const source = stripIndent` 22 | describe('parent', {requiredTags: '@special'}, () => { 23 | it('works', {tags: '@one'}, () => {}) 24 | }) 25 | ` 26 | const result = findEffectiveTestTags(source) 27 | const expected = { 28 | 'parent works': { 29 | // the required tag from the parent applies to the child test 30 | // as an effective tag 31 | effectiveTags: ['@one', '@special'], 32 | requiredTags: ['@special'], 33 | }, 34 | } 35 | t.deepEqual(result, expected) 36 | }) 37 | 38 | test('combines suite and test only tags', (t) => { 39 | t.plan(1) 40 | const source = stripIndent` 41 | describe('parent', {requiredTags: '@special'}, () => { 42 | it('works', {requiredTags: '@super'}, () => {}) 43 | }) 44 | ` 45 | const result = findEffectiveTestTags(source) 46 | const expected = { 47 | 'parent works': { 48 | // required tags also act as effective tags 49 | effectiveTags: ['@special', '@super'], 50 | requiredTags: ['@special', '@super'], 51 | }, 52 | } 53 | t.deepEqual(result, expected) 54 | }) 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "find-test-names", 3 | "version": "0.0.0-development", 4 | "description": "Given a Mocha / Cypress spec file, returns the list of suite and test names", 5 | "main": "src", 6 | "files": [ 7 | "bin", 8 | "src" 9 | ], 10 | "bin": { 11 | "find-test-names": "bin/find-test-names.js", 12 | "update-test-count": "bin/update-test-count.js", 13 | "print-tests": "bin/print-tests.js" 14 | }, 15 | "scripts": { 16 | "test": "ava --config ava.config.mjs", 17 | "semantic-release": "semantic-release", 18 | "demo": "DEBUG=find-test-names node bin/find-test-names.js demo/spec.js", 19 | "demo-update-md": "DEBUG=find-test-names node bin/update-test-count.js out.md 'test-cy/**/*.js'", 20 | "demo-print": "node bin/print-tests.js 'test-cy/**/*.js'", 21 | "stop-only": "DEBUG=stop-only stop-only --folder test --exclude exclusive.js", 22 | "prepare": "husky", 23 | "deps": "npm audit --report --omit dev" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/bahmutov/find-test-names.git" 28 | }, 29 | "keywords": [ 30 | "mocha", 31 | "cypress", 32 | "tests" 33 | ], 34 | "author": "Gleb Bahmutov ", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/bahmutov/find-test-names/issues" 38 | }, 39 | "homepage": "https://github.com/bahmutov/find-test-names#readme", 40 | "dependencies": { 41 | "@babel/parser": "^7.27.2", 42 | "@babel/plugin-syntax-jsx": "^7.27.1", 43 | "acorn-walk": "^8.2.0", 44 | "debug": "^4.3.3", 45 | "simple-bin-help": "^1.8.0", 46 | "tinyglobby": "^0.2.13" 47 | }, 48 | "devDependencies": { 49 | "ava": "6.4.1", 50 | "common-tags": "1.8.2", 51 | "husky": "9.1.7", 52 | "prettier": "3.6.2", 53 | "semantic-release": "24.2.9", 54 | "stop-only": "3.4.4" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /test/resolve-imports.js: -------------------------------------------------------------------------------- 1 | const { resolveImports } = require('../src/resolve-imports') 2 | const { stripIndent } = require('common-tags') 3 | const test = require('ava') 4 | 5 | const fileProvider = (relativePath) => { 6 | if (relativePath === './file-a') { 7 | return stripIndent` 8 | export const foo = 'foo' 9 | export const bar = 'bar' 10 | ` 11 | } 12 | 13 | if (relativePath === './file-b') { 14 | return stripIndent` 15 | export const TAGS = { 16 | user: '@user', 17 | sanity: '@sanity', 18 | } 19 | ` 20 | } 21 | } 22 | 23 | test('finds the imports', (t) => { 24 | t.plan(1) 25 | const source = stripIndent` 26 | import { foo } from './file-a' 27 | ` 28 | 29 | const result = resolveImports(source, fileProvider) 30 | t.deepEqual(result, { foo: 'foo' }) 31 | }) 32 | 33 | test('renames the import', (t) => { 34 | t.plan(1) 35 | const source = stripIndent` 36 | import { foo as FOOBAR } from './file-a' 37 | ` 38 | 39 | const result = resolveImports(source, fileProvider) 40 | t.deepEqual(result, { FOOBAR: 'foo' }) 41 | }) 42 | 43 | test('two imports', (t) => { 44 | t.plan(1) 45 | const source = stripIndent` 46 | import { foo, bar } from './file-a' 47 | ` 48 | 49 | const result = resolveImports(source, fileProvider) 50 | t.deepEqual(result, { foo: 'foo', bar: 'bar' }) 51 | }) 52 | 53 | test('non-existent import', (t) => { 54 | t.plan(1) 55 | const source = stripIndent` 56 | import { quux } from './file-a' 57 | ` 58 | 59 | const result = resolveImports(source, fileProvider) 60 | t.deepEqual(result, {}) 61 | }) 62 | 63 | test('finds the exported object', (t) => { 64 | t.plan(1) 65 | const source = stripIndent` 66 | import { TAGS } from './file-b' 67 | ` 68 | 69 | const result = resolveImports(source, fileProvider) 70 | t.deepEqual(result, { TAGS: { user: '@user', sanity: '@sanity' } }) 71 | }) 72 | -------------------------------------------------------------------------------- /src/format-test-list.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const spacer = ' ' 3 | const spacerNeighbour = '│ ' 4 | const lastSpacer = '└─ ' 5 | const middleSpacer = '├─ ' 6 | const pendingLastSpacer = '└⊙ ' 7 | const pendingMiddleSpacer = '├⊙ ' 8 | const exclusiveLastSpacer = '└> ' 9 | const exclusiveMiddleSpacer = '├> ' 10 | 11 | function formatTestList(tests, indent = 0) { 12 | const lastIndex = tests.length - 1 13 | if (!tests.length) { 14 | return `${spacer.repeat(indent > 0 ? indent - 1 : 0)}└─ (empty)` 15 | } 16 | 17 | const testsN = tests.length 18 | const lines = tests.map((test, k) => { 19 | let start 20 | if (k === lastIndex) { 21 | start = test.pending 22 | ? pendingLastSpacer 23 | : test.exclusive 24 | ? exclusiveLastSpacer 25 | : lastSpacer 26 | } else { 27 | start = test.pending 28 | ? pendingMiddleSpacer 29 | : test.exclusive 30 | ? exclusiveMiddleSpacer 31 | : middleSpacer 32 | } 33 | 34 | let nameLine = test.name ? `${start}${test.name}` : `${start}` 35 | if (Array.isArray(test.tags)) { 36 | nameLine += ` [${test.tags.join(', ')}]` 37 | } 38 | 39 | if (Array.isArray(test.requiredTags)) { 40 | nameLine += ` [[${test.requiredTags.join(', ')}]]` 41 | } 42 | 43 | if (test.type === 'suite') { 44 | const children = [].concat(test.tests, test.suites).filter(Boolean) 45 | const nested = formatTestList(children, indent + 1) 46 | const nestedLines = nested.split('\n') 47 | const includeSpacer = k < testsN - 1 ? spacerNeighbour : spacer 48 | const nestedLinesWithIndent = nestedLines.map((s) => { 49 | return includeSpacer + s 50 | }) 51 | 52 | const suiteLines = `${nameLine}` + '\n' + nestedLinesWithIndent.join('\n') 53 | return suiteLines 54 | } else { 55 | return nameLine 56 | } 57 | }) 58 | 59 | return lines.flat(1).join('\n') 60 | } 61 | 62 | module.exports = { formatTestList } 63 | -------------------------------------------------------------------------------- /test/template.js: -------------------------------------------------------------------------------- 1 | const { stripIndent } = require('common-tags') 2 | const test = require('ava') 3 | const { getTestNames } = require('..') 4 | 5 | test('template literal test title', (t) => { 6 | t.plan(1) 7 | const source = stripIndent` 8 | describe('foo', () => { 9 | it(\`bar\`, () => {}) 10 | }) 11 | ` 12 | const result = getTestNames(source) 13 | t.deepEqual(result, { 14 | suiteNames: ['foo'], 15 | testNames: ['bar'], 16 | tests: [ 17 | { 18 | name: 'bar', 19 | type: 'test', 20 | pending: false, 21 | }, 22 | { 23 | name: 'foo', 24 | type: 'suite', 25 | pending: false, 26 | }, 27 | ], 28 | }) 29 | }) 30 | 31 | test('template literal suite title', (t) => { 32 | t.plan(1) 33 | const source = stripIndent` 34 | describe(\`foo\`, () => { 35 | it("bar", () => {}) 36 | }) 37 | ` 38 | const result = getTestNames(source) 39 | t.deepEqual(result, { 40 | suiteNames: ['foo'], 41 | testNames: ['bar'], 42 | tests: [ 43 | { 44 | name: 'bar', 45 | type: 'test', 46 | pending: false, 47 | }, 48 | { 49 | name: 'foo', 50 | type: 'suite', 51 | pending: false, 52 | }, 53 | ], 54 | }) 55 | }) 56 | 57 | test('template literal test title with variables', (t) => { 58 | t.plan(1) 59 | const source = stripIndent` 60 | describe('foo', () => { 61 | it(\`bar \${k + 1} the end\`, () => {}) 62 | }) 63 | ` 64 | const result = getTestNames(source) 65 | // the test name should skip all variables and expressions 66 | // and just concatenate the literal parts 67 | t.deepEqual(result, { 68 | suiteNames: ['foo'], 69 | testNames: ['bar the end'], 70 | tests: [ 71 | { 72 | name: 'bar the end', 73 | type: 'test', 74 | pending: false, 75 | }, 76 | { 77 | name: 'foo', 78 | type: 'suite', 79 | pending: false, 80 | }, 81 | ], 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /test/dynamic-names.js: -------------------------------------------------------------------------------- 1 | const { stripIndent } = require('common-tags') 2 | const test = require('ava') 3 | const { getTestNames } = require('../src') 4 | 5 | test('variable as test name', (t) => { 6 | t.plan(1) 7 | // instead of a string, this test's name is a variable 8 | const source = stripIndent` 9 | const name = 'works' 10 | it(name, () => {}) 11 | ` 12 | const result = getTestNames(source) 13 | // the result should have a test without a name 14 | t.deepEqual(result, { 15 | suiteNames: [], 16 | testNames: [], 17 | tests: [{ type: 'test', pending: false }], 18 | }) 19 | }) 20 | 21 | test('variable as test name then tags', (t) => { 22 | t.plan(1) 23 | // instead of a string, this test's name is a variable 24 | const source = stripIndent` 25 | const name = 'works' 26 | it(name, { tags: ['@first', '@second'] }, () => {}) 27 | ` 28 | const result = getTestNames(source) 29 | // console.dir(result, { depth: null }) 30 | // the result should have a test without a name 31 | // and have the list of tags 32 | t.deepEqual(result, { 33 | suiteNames: [], 34 | testNames: [], 35 | tests: [{ type: 'test', pending: false, tags: ['@first', '@second'] }], 36 | }) 37 | }) 38 | 39 | test('concatenated strings', (t) => { 40 | t.plan(1) 41 | const source = stripIndent` 42 | it('super' + ' ' + 'test', { tags: ['@first', '@second'] }, () => {}) 43 | ` 44 | const result = getTestNames(source) 45 | // console.dir(result, { depth: null }) 46 | // the result should have a test without a name 47 | // and have the list of tags 48 | t.deepEqual(result, { 49 | suiteNames: [], 50 | testNames: [], 51 | tests: [{ type: 'test', pending: false, tags: ['@first', '@second'] }], 52 | }) 53 | }) 54 | 55 | test('member expression', (t) => { 56 | t.plan(1) 57 | const source = stripIndent` 58 | const names = { 59 | first: 'my test', 60 | } 61 | it(names.first, { tags: ['@first', '@second'] }, () => {}) 62 | ` 63 | const result = getTestNames(source) 64 | // console.dir(result, { depth: null }) 65 | // the result should have a test without a name 66 | // and have the list of tags 67 | t.deepEqual(result, { 68 | suiteNames: [], 69 | testNames: [], 70 | tests: [{ type: 'test', pending: false, tags: ['@first', '@second'] }], 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /test/basic.js: -------------------------------------------------------------------------------- 1 | const { stripIndent } = require('common-tags') 2 | const test = require('ava') 3 | const { getTestNames } = require('..') 4 | 5 | test('basic', (t) => { 6 | t.plan(1) 7 | const source = stripIndent` 8 | describe('foo', () => { 9 | it('bar', () => {}) 10 | }) 11 | ` 12 | const result = getTestNames(source) 13 | t.deepEqual(result, { 14 | suiteNames: ['foo'], 15 | testNames: ['bar'], 16 | tests: [ 17 | { 18 | name: 'bar', 19 | type: 'test', 20 | pending: false, 21 | }, 22 | { 23 | name: 'foo', 24 | type: 'suite', 25 | pending: false, 26 | }, 27 | ], 28 | }) 29 | }) 30 | 31 | test('ES6 modules with import keyword', (t) => { 32 | t.plan(1) 33 | const source = stripIndent` 34 | import {foo} from './foo' 35 | describe('foo', () => { 36 | it('bar', () => {}) 37 | }) 38 | ` 39 | const result = getTestNames(source) 40 | t.deepEqual(result, { 41 | suiteNames: ['foo'], 42 | testNames: ['bar'], 43 | tests: [ 44 | { 45 | name: 'bar', 46 | type: 'test', 47 | pending: false, 48 | }, 49 | { 50 | name: 'foo', 51 | type: 'suite', 52 | pending: false, 53 | }, 54 | ], 55 | }) 56 | }) 57 | 58 | test('context', (t) => { 59 | t.plan(1) 60 | const source = stripIndent` 61 | context('parent', () => {}) 62 | context.skip('does not work', () => {}) 63 | ` 64 | const result = getTestNames(source) 65 | t.deepEqual(result, { 66 | suiteNames: ['does not work', 'parent'], 67 | testNames: [], 68 | tests: [ 69 | { name: 'parent', type: 'suite', pending: false }, 70 | { name: 'does not work', type: 'suite', pending: true }, 71 | ], 72 | }) 73 | }) 74 | 75 | test('specify', (t) => { 76 | t.plan(1) 77 | const source = stripIndent` 78 | describe('foo', () => { 79 | specify('bar', () => {}) 80 | }) 81 | ` 82 | const result = getTestNames(source) 83 | t.deepEqual(result, { 84 | suiteNames: ['foo'], 85 | testNames: ['bar'], 86 | tests: [ 87 | { 88 | name: 'bar', 89 | type: 'test', 90 | pending: false, 91 | }, 92 | { 93 | name: 'foo', 94 | type: 'suite', 95 | pending: false, 96 | }, 97 | ], 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /src/resolve-exports.js: -------------------------------------------------------------------------------- 1 | const babel = require('@babel/parser') 2 | const walk = require('acorn-walk') 3 | const debug = require('debug')('find-test-names') 4 | 5 | const base = walk.make({}) 6 | 7 | const plugins = [ 8 | 'jsx', 9 | 'estree', // To generate estree compatible AST 10 | 'typescript', 11 | ] 12 | 13 | function ignore(_node, _st, _c) {} 14 | 15 | /** 16 | * The proxy ignores all AST nodes for which acorn has no base visitor. 17 | * This includes TypeScript specific nodes like TSInterfaceDeclaration, 18 | * but also babel-specific nodes like ClassPrivateProperty. 19 | * 20 | * Since describe / it are CallExpressions, ignoring nodes should not affect 21 | * the test name extraction. 22 | */ 23 | const proxy = new Proxy(base, { 24 | get: function (target, prop) { 25 | if (target[prop]) { 26 | return Reflect.get(...arguments) 27 | } 28 | 29 | return ignore 30 | }, 31 | }) 32 | 33 | function resolveExportsInAst(AST, proxy) { 34 | const exportedValues = {} 35 | 36 | walk.ancestor( 37 | AST, 38 | { 39 | ExportNamedDeclaration(node) { 40 | // console.log(node) 41 | if (node.declaration.type === 'VariableDeclaration') { 42 | node.declaration.declarations.forEach((declaration) => { 43 | const { id, init } = declaration 44 | if (id.type === 'Identifier') { 45 | if (init?.type === 'Literal') { 46 | exportedValues[id.name] = init.value 47 | } else if (init?.type === 'ObjectExpression') { 48 | const obj = {} 49 | init.properties.forEach((prop) => { 50 | const value = prop.value 51 | if (value.type === 'Literal') { 52 | obj[prop.key.name] = value.value 53 | } 54 | }) 55 | 56 | exportedValues[id.name] = obj 57 | } 58 | } 59 | }) 60 | } 61 | }, 62 | }, 63 | proxy, 64 | ) 65 | 66 | return exportedValues 67 | } 68 | 69 | function resolveExports(source) { 70 | let AST 71 | try { 72 | debug('parsing source as a script for exports') 73 | AST = babel.parse(source, { 74 | plugins, 75 | sourceType: 'script', 76 | }).program 77 | debug('success!') 78 | } catch (e) { 79 | debug('parsing source as a module for exports') 80 | 81 | try { 82 | AST = babel.parse(source, { 83 | plugins, 84 | sourceType: 'module', 85 | }).program 86 | debug('success for exports!') 87 | } catch (e) { 88 | console.error(e) 89 | console.error(source) 90 | } 91 | } 92 | 93 | return resolveExportsInAst(AST, proxy) 94 | } 95 | 96 | module.exports = { 97 | resolveExports, 98 | } 99 | -------------------------------------------------------------------------------- /test/pending.js: -------------------------------------------------------------------------------- 1 | const { stripIndent } = require('common-tags') 2 | const test = require('ava') 3 | const { getTestNames } = require('..') 4 | 5 | test('pending test', (t) => { 6 | t.plan(1) 7 | const source = stripIndent` 8 | it('works') 9 | ` 10 | const result = getTestNames(source) 11 | t.deepEqual(result, { 12 | suiteNames: [], 13 | testNames: ['works'], 14 | tests: [{ name: 'works', type: 'test', pending: true }], 15 | }) 16 | }) 17 | 18 | test('pending suite', (t) => { 19 | t.plan(1) 20 | const source = stripIndent` 21 | describe('parent') 22 | ` 23 | const result = getTestNames(source) 24 | t.deepEqual(result, { 25 | suiteNames: ['parent'], 26 | testNames: [], 27 | tests: [{ name: 'parent', type: 'suite', pending: true }], 28 | }) 29 | }) 30 | 31 | test('skipped test', (t) => { 32 | t.plan(1) 33 | const source = stripIndent` 34 | describe('foo', () => { 35 | it.skip('bar', () => {}) 36 | }) 37 | ` 38 | const result = getTestNames(source) 39 | t.deepEqual(result, { 40 | suiteNames: ['foo'], 41 | testNames: ['bar'], 42 | tests: [ 43 | { 44 | name: 'bar', 45 | type: 'test', 46 | pending: true, 47 | }, 48 | { 49 | name: 'foo', 50 | type: 'suite', 51 | pending: false, 52 | }, 53 | ], 54 | }) 55 | }) 56 | 57 | test('skipped suite', (t) => { 58 | t.plan(1) 59 | const source = stripIndent` 60 | describe.skip('foo', () => { 61 | it('bar', () => {}) 62 | }) 63 | ` 64 | const result = getTestNames(source) 65 | t.deepEqual(result, { 66 | suiteNames: ['foo'], 67 | testNames: ['bar'], 68 | tests: [ 69 | { 70 | name: 'bar', 71 | type: 'test', 72 | pending: false, 73 | }, 74 | { 75 | name: 'foo', 76 | type: 'suite', 77 | pending: true, 78 | }, 79 | ], 80 | }) 81 | }) 82 | 83 | test('pending test with tags without a callback', (t) => { 84 | t.plan(1) 85 | const source = stripIndent` 86 | it('works', {tags: '@basic'}) 87 | ` 88 | const result = getTestNames(source) 89 | t.deepEqual(result, { 90 | suiteNames: [], 91 | testNames: ['works'], 92 | tests: [{ name: 'works', type: 'test', pending: true, tags: ['@basic'] }], 93 | }) 94 | }) 95 | 96 | test('pending suite with tags without a callback', (t) => { 97 | t.plan(1) 98 | const source = stripIndent` 99 | describe('works', {tags: '@basic'}) 100 | ` 101 | const result = getTestNames(source) 102 | t.deepEqual(result, { 103 | suiteNames: ['works'], 104 | testNames: [], 105 | tests: [{ name: 'works', type: 'suite', pending: true, tags: ['@basic'] }], 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /test/count-tags.js: -------------------------------------------------------------------------------- 1 | const { getTestNames, visitEachTest, countTags } = require('../src') 2 | const { stripIndent } = require('common-tags') 3 | const test = require('ava') 4 | 5 | test('tags apply from the suite to the tests', (t) => { 6 | t.plan(1) 7 | const source = stripIndent` 8 | describe('parent', {tags: '@basic'}, () => { 9 | it('works a', () => {}) 10 | it('works b', () => {}) 11 | }) 12 | ` 13 | const result = getTestNames(source, true) 14 | const counts = countTags(result.structure) 15 | t.deepEqual(counts, { '@basic': 2 }) 16 | }) 17 | 18 | test('tags apply from all parent suites', (t) => { 19 | t.plan(1) 20 | const source = stripIndent` 21 | describe('parent', {tags: '@basic'}, () => { 22 | describe('inner', () => { 23 | it('works a', () => {}) 24 | it('works b', () => {}) 25 | }) 26 | }) 27 | ` 28 | const result = getTestNames(source, true) 29 | const counts = countTags(result.structure) 30 | t.deepEqual(counts, { '@basic': 2 }) 31 | }) 32 | 33 | // https://github.com/bahmutov/find-test-names/issues/95 34 | test('and required tags apply from all parent suites', (t) => { 35 | t.plan(1) 36 | const source = stripIndent` 37 | describe('parent', {requiredTags: '@basic'}, () => { 38 | describe('inner', () => { 39 | it('works a', () => {}) 40 | it('works b', () => {}) 41 | }) 42 | }) 43 | ` 44 | const result = getTestNames(source, true) 45 | const counts = countTags(result.structure) 46 | t.deepEqual(counts, { '@basic': 2 }) 47 | }) 48 | 49 | test('combines all tags', (t) => { 50 | t.plan(1) 51 | const source = stripIndent` 52 | describe('parent', {tags: '@one'}, () => { 53 | describe('inner', {tags: ['@two', '@three'] }, () => { 54 | it('works a', {tags: '@four' }, () => {}) 55 | it('works b', {tags: '@five' }, () => {}) 56 | }) 57 | }) 58 | ` 59 | const result = getTestNames(source, true) 60 | const counts = countTags(result.structure) 61 | t.deepEqual(counts, { 62 | '@one': 2, 63 | '@two': 2, 64 | '@three': 2, 65 | '@four': 1, 66 | '@five': 1, 67 | }) 68 | }) 69 | 70 | test('counts the required tags', (t) => { 71 | t.plan(1) 72 | const source = stripIndent` 73 | it('works a', () => {}) 74 | it('works b', {requiredTags: '@user'}, () => {}) 75 | ` 76 | const result = getTestNames(source, true) 77 | const counts = countTags(result.structure) 78 | t.deepEqual(counts, { '@user': 1 }) 79 | }) 80 | 81 | test('combines counts', (t) => { 82 | t.plan(1) 83 | const source = stripIndent` 84 | it('works a', {tags: '@user'}, () => {}) 85 | it('works b', {requiredTags: '@user'}, () => {}) 86 | ` 87 | const result = getTestNames(source, true) 88 | const counts = countTags(result.structure) 89 | t.deepEqual(counts, { '@user': 2 }) 90 | }) 91 | -------------------------------------------------------------------------------- /test/resolved-tags.js: -------------------------------------------------------------------------------- 1 | const { stripIndent } = require('common-tags') 2 | const test = require('ava') 3 | const { getTestNames } = require('../src') 4 | 5 | test('resolves a single local constant tag', (t) => { 6 | t.plan(1) 7 | const source = stripIndent` 8 | const foo = '@foo'; 9 | 10 | it('works', { tags: foo }, () => {}) 11 | ` 12 | const result = getTestNames(source) 13 | // console.log(result) 14 | t.deepEqual(result, { 15 | suiteNames: [], 16 | testNames: ['works'], 17 | tests: [ 18 | { 19 | name: 'works', 20 | tags: ['@foo'], 21 | type: 'test', 22 | pending: false, 23 | }, 24 | ], 25 | }) 26 | }) 27 | 28 | test('resolves a single local constant required tag', (t) => { 29 | t.plan(1) 30 | const source = stripIndent` 31 | const foo = '@foo'; 32 | 33 | it('works', { requiredTags: foo }, () => {}) 34 | ` 35 | const result = getTestNames(source) 36 | // console.log(result) 37 | t.deepEqual(result, { 38 | suiteNames: [], 39 | testNames: ['works'], 40 | tests: [ 41 | { 42 | name: 'works', 43 | requiredTags: ['@foo'], 44 | type: 'test', 45 | pending: false, 46 | }, 47 | ], 48 | }) 49 | }) 50 | 51 | test('resolves a list of tags with a local constant', (t) => { 52 | t.plan(1) 53 | const source = stripIndent` 54 | const bar = '@bar'; 55 | 56 | it('works', { tags: ['@foo', bar] }, () => {}) 57 | ` 58 | const result = getTestNames(source) 59 | t.deepEqual(result, { 60 | suiteNames: [], 61 | testNames: ['works'], 62 | tests: [ 63 | { 64 | name: 'works', 65 | tags: ['@foo', '@bar'], 66 | type: 'test', 67 | pending: false, 68 | }, 69 | ], 70 | }) 71 | }) 72 | 73 | test('resolves a property of an object as a single tag', (t) => { 74 | t.plan(1) 75 | const source = stripIndent` 76 | const TAGS = { 77 | foo: '@foo', 78 | } 79 | 80 | it('works', { tags: TAGS.foo }, () => {}) 81 | ` 82 | const result = getTestNames(source) 83 | // console.log(result) 84 | t.deepEqual(result, { 85 | suiteNames: [], 86 | testNames: ['works'], 87 | tests: [ 88 | { 89 | name: 'works', 90 | tags: ['@foo'], 91 | type: 'test', 92 | pending: false, 93 | }, 94 | ], 95 | }) 96 | }) 97 | 98 | test('resolves a property of an object from an array', (t) => { 99 | t.plan(1) 100 | const source = stripIndent` 101 | const TAGS = { 102 | foo: '@foo', 103 | } 104 | 105 | it('works', { tags: ['@sanity', TAGS.foo] }, () => {}) 106 | ` 107 | const result = getTestNames(source) 108 | // console.log(result) 109 | t.deepEqual(result, { 110 | suiteNames: [], 111 | testNames: ['works'], 112 | tests: [ 113 | { 114 | name: 'works', 115 | tags: ['@sanity', '@foo'], 116 | type: 'test', 117 | pending: false, 118 | }, 119 | ], 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /src/resolve-imports.js: -------------------------------------------------------------------------------- 1 | const babel = require('@babel/parser') 2 | const walk = require('acorn-walk') 3 | const debug = require('debug')('find-test-names') 4 | const { resolveExports } = require('./resolve-exports') 5 | 6 | const base = walk.make({}) 7 | 8 | const plugins = [ 9 | 'jsx', 10 | 'estree', // To generate estree compatible AST 11 | 'typescript', 12 | ] 13 | 14 | function ignore(_node, _st, _c) {} 15 | 16 | /** 17 | * The proxy ignores all AST nodes for which acorn has no base visitor. 18 | * This includes TypeScript specific nodes like TSInterfaceDeclaration, 19 | * but also babel-specific nodes like ClassPrivateProperty. 20 | * 21 | * Since describe / it are CallExpressions, ignoring nodes should not affect 22 | * the test name extraction. 23 | */ 24 | const proxy = new Proxy(base, { 25 | get: function (target, prop) { 26 | if (target[prop]) { 27 | return Reflect.get(...arguments) 28 | } 29 | 30 | return ignore 31 | }, 32 | }) 33 | 34 | function resolveImportsInAst(AST, fileProvider) { 35 | const importedValues = {} 36 | 37 | // console.dir(AST, { depth: null }) 38 | 39 | walk.ancestor( 40 | AST, 41 | { 42 | ImportDeclaration(node) { 43 | // console.log(node) 44 | // from where... 45 | const fromWhere = node.source?.value 46 | if (!fromWhere) { 47 | return 48 | } 49 | debug('importing from "%s"', fromWhere) 50 | const source = fileProvider(fromWhere) 51 | if (!source) { 52 | debug('could not find source for "%s"', fromWhere) 53 | return 54 | } 55 | const exportedValues = resolveExports(source) 56 | if (!exportedValues) { 57 | debug('could not find any exports in "%s"', fromWhere) 58 | return 59 | } 60 | 61 | node.specifiers.forEach((specifier) => { 62 | const importedName = specifier.imported.name 63 | const localName = specifier.local.name 64 | debug('importing "%s" as "%s"', importedName, localName) 65 | if (!exportedValues[importedName]) { 66 | debug('could not find export "%s" in "%s"', importedName, fromWhere) 67 | return 68 | } 69 | importedValues[localName] = exportedValues[importedName] 70 | }) 71 | }, 72 | }, 73 | proxy, 74 | ) 75 | 76 | return importedValues 77 | } 78 | 79 | function resolveImports(source, fileProvider) { 80 | let AST 81 | try { 82 | debug('parsing source as a script for imports') 83 | AST = babel.parse(source, { 84 | plugins, 85 | sourceType: 'script', 86 | }).program 87 | debug('success!') 88 | } catch (e) { 89 | debug('parsing source as a module for imports') 90 | AST = babel.parse(source, { 91 | plugins, 92 | sourceType: 'module', 93 | }).program 94 | debug('success!') 95 | } 96 | 97 | return resolveImportsInAst(AST, fileProvider) 98 | } 99 | 100 | module.exports = { 101 | resolveImports, 102 | resolveImportsInAst, 103 | } 104 | -------------------------------------------------------------------------------- /test/comment.js: -------------------------------------------------------------------------------- 1 | const { stripIndent } = require('common-tags') 2 | const test = require('ava') 3 | const { getTestNames } = require('..') 4 | 5 | test('includes the comment', (t) => { 6 | t.plan(1) 7 | const source = stripIndent` 8 | // this is a suite called foo 9 | describe('foo', () => { 10 | // this is the test comment 11 | it('bar', () => {}) 12 | }) 13 | ` 14 | const result = getTestNames(source) 15 | // the leading comment before the test is extracted 16 | t.deepEqual(result, { 17 | suiteNames: ['foo'], 18 | testNames: ['bar'], 19 | tests: [ 20 | { 21 | type: 'test', 22 | pending: false, 23 | name: 'bar', 24 | comment: 'this is the test comment', 25 | }, 26 | { type: 'suite', pending: false, name: 'foo' }, 27 | ], 28 | }) 29 | }) 30 | 31 | test('grabs the last comment line onle', (t) => { 32 | t.plan(1) 33 | const source = stripIndent` 34 | // this is a suite called foo 35 | describe('foo', () => { 36 | // line 1 37 | // line 2 38 | // line 3 39 | it('bar', () => {}) 40 | }) 41 | ` 42 | const result = getTestNames(source) 43 | // the leading comment before the test is extracted 44 | t.deepEqual(result, { 45 | suiteNames: ['foo'], 46 | testNames: ['bar'], 47 | tests: [ 48 | { 49 | type: 'test', 50 | pending: false, 51 | name: 'bar', 52 | comment: 'line 3', 53 | }, 54 | { type: 'suite', pending: false, name: 'foo' }, 55 | ], 56 | }) 57 | }) 58 | 59 | test('skipped test includes the comment', (t) => { 60 | t.plan(1) 61 | const source = stripIndent` 62 | // this is a suite called foo 63 | describe('foo', () => { 64 | // this test is skipped 65 | it.skip('bar', () => {}) 66 | }) 67 | ` 68 | const result = getTestNames(source) 69 | // the leading comment before the test is extracted 70 | t.deepEqual(result, { 71 | suiteNames: ['foo'], 72 | testNames: ['bar'], 73 | tests: [ 74 | { 75 | type: 'test', 76 | pending: true, 77 | name: 'bar', 78 | comment: 'this test is skipped', 79 | }, 80 | { type: 'suite', pending: false, name: 'foo' }, 81 | ], 82 | }) 83 | }) 84 | 85 | test('includes the comment for several tests', (t) => { 86 | t.plan(1) 87 | const source = stripIndent` 88 | // this is a suite called foo 89 | describe('foo', () => { 90 | 91 | // test foo 92 | it('foo', () => {}) 93 | 94 | // test bar 95 | it('bar', () => {}) 96 | 97 | // something here 98 | // skipped test baz 99 | it.skip('baz', () => {}) 100 | }) 101 | ` 102 | const result = getTestNames(source) 103 | // the leading comment before the test is extracted 104 | t.deepEqual(result, { 105 | suiteNames: ['foo'], 106 | testNames: ['bar', 'baz', 'foo'], 107 | tests: [ 108 | { 109 | type: 'test', 110 | pending: false, 111 | name: 'foo', 112 | comment: 'test foo', 113 | }, 114 | { 115 | type: 'test', 116 | pending: false, 117 | name: 'bar', 118 | comment: 'test bar', 119 | }, 120 | { 121 | comment: 'skipped test baz', 122 | type: 'test', 123 | pending: true, 124 | name: 'baz', 125 | }, 126 | { type: 'suite', pending: false, name: 'foo' }, 127 | ], 128 | }) 129 | }) 130 | -------------------------------------------------------------------------------- /test/tags.js: -------------------------------------------------------------------------------- 1 | const { stripIndent } = require('common-tags') 2 | const test = require('ava') 3 | const { getTestNames } = require('../src') 4 | 5 | test('test with a single string tag', (t) => { 6 | t.plan(1) 7 | const source = stripIndent` 8 | describe('foo', () => { 9 | it('bar', {tags: '@one'}, () => {}) 10 | }) 11 | ` 12 | const result = getTestNames(source) 13 | t.deepEqual(result, { 14 | suiteNames: ['foo'], 15 | testNames: ['bar'], 16 | tests: [ 17 | { 18 | name: 'bar', 19 | tags: ['@one'], 20 | type: 'test', 21 | pending: false, 22 | }, 23 | { 24 | name: 'foo', 25 | type: 'suite', 26 | pending: false, 27 | }, 28 | ], 29 | }) 30 | }) 31 | 32 | test('test with tags', (t) => { 33 | t.plan(1) 34 | const source = stripIndent` 35 | describe('foo', () => { 36 | it('bar', {tags: ['@one']}, () => {}) 37 | }) 38 | ` 39 | const result = getTestNames(source) 40 | t.deepEqual(result, { 41 | suiteNames: ['foo'], 42 | testNames: ['bar'], 43 | tests: [ 44 | { 45 | name: 'bar', 46 | tags: ['@one'], 47 | type: 'test', 48 | pending: false, 49 | }, 50 | { 51 | name: 'foo', 52 | type: 'suite', 53 | pending: false, 54 | }, 55 | ], 56 | }) 57 | }) 58 | 59 | test('skipped test with tags', (t) => { 60 | t.plan(1) 61 | const source = stripIndent` 62 | describe('foo', () => { 63 | it.skip('bar', {tags: ['@one']}, () => {}) 64 | }) 65 | ` 66 | const result = getTestNames(source) 67 | t.deepEqual(result, { 68 | suiteNames: ['foo'], 69 | testNames: ['bar'], 70 | tests: [ 71 | { 72 | name: 'bar', 73 | tags: ['@one'], 74 | type: 'test', 75 | pending: true, 76 | }, 77 | { 78 | name: 'foo', 79 | type: 'suite', 80 | pending: false, 81 | }, 82 | ], 83 | }) 84 | }) 85 | 86 | test('describe with tags', (t) => { 87 | t.plan(1) 88 | const source = stripIndent` 89 | describe('foo', {tags: ['@one', '@two']}, () => { 90 | it('bar', () => {}) 91 | }) 92 | ` 93 | const result = getTestNames(source) 94 | t.deepEqual(result, { 95 | suiteNames: ['foo'], 96 | testNames: ['bar'], 97 | tests: [ 98 | { 99 | name: 'bar', 100 | type: 'test', 101 | pending: false, 102 | }, 103 | { 104 | name: 'foo', 105 | tags: ['@one', '@two'], 106 | type: 'suite', 107 | pending: false, 108 | }, 109 | ], 110 | }) 111 | }) 112 | 113 | test('spread config parameter', (t) => { 114 | t.plan(1) 115 | const source = stripIndent` 116 | const VIEWPORT = { 117 | viewportHeight: 800, 118 | viewportWidth: 360, 119 | }; 120 | it('works', { tags: '@foo', ...VIEWPORT }, () => {}) 121 | ` 122 | const result = getTestNames(source) 123 | t.deepEqual(result, { 124 | suiteNames: [], 125 | testNames: ['works'], 126 | tests: [ 127 | { 128 | name: 'works', 129 | tags: ['@foo'], 130 | type: 'test', 131 | pending: false, 132 | }, 133 | ], 134 | }) 135 | }) 136 | 137 | test('spread config parameter with required tags', (t) => { 138 | t.plan(1) 139 | const source = stripIndent` 140 | const VIEWPORT = { 141 | viewportHeight: 800, 142 | viewportWidth: 360, 143 | }; 144 | it('works', { requiredTags: '@foo', ...VIEWPORT }, () => {}) 145 | ` 146 | const result = getTestNames(source) 147 | t.deepEqual(result, { 148 | suiteNames: [], 149 | testNames: ['works'], 150 | tests: [ 151 | { 152 | name: 'works', 153 | requiredTags: ['@foo'], 154 | type: 'test', 155 | pending: false, 156 | }, 157 | ], 158 | }) 159 | }) 160 | -------------------------------------------------------------------------------- /test/typescript.js: -------------------------------------------------------------------------------- 1 | const { stripIndent } = require('common-tags') 2 | const test = require('ava') 3 | const { getTestNames } = require('..') 4 | 5 | test('typescript annotation', (t) => { 6 | t.plan(1) 7 | const source = stripIndent` 8 | describe('TypeScript spec', () => { 9 | it('works', () => { 10 | const person = { 11 | name: 'Joe', 12 | } 13 | 14 | cy.wrap(person).should('have.property', 'name', 'Joe') 15 | }) 16 | 17 | it('loads', () => { 18 | const n: number = 1 19 | cy.wrap(n).should('eq', 1) 20 | }) 21 | }) 22 | ` 23 | const result = getTestNames(source) 24 | 25 | t.deepEqual(result, { 26 | suiteNames: ['TypeScript spec'], 27 | testNames: ['loads', 'works'], 28 | tests: [ 29 | { 30 | name: 'works', 31 | type: 'test', 32 | pending: false, 33 | }, 34 | { 35 | name: 'loads', 36 | type: 'test', 37 | pending: false, 38 | }, 39 | { 40 | name: 'TypeScript spec', 41 | type: 'suite', 42 | pending: false, 43 | }, 44 | ], 45 | }) 46 | }) 47 | 48 | test('typescript interface', (t) => { 49 | t.plan(1) 50 | const source = stripIndent` 51 | interface Person { 52 | name: string 53 | } 54 | 55 | describe('TypeScript spec', () => { 56 | it('works', () => {}) 57 | }) 58 | ` 59 | const result = getTestNames(source) 60 | 61 | t.deepEqual(result, { 62 | suiteNames: ['TypeScript spec'], 63 | testNames: ['works'], 64 | tests: [ 65 | { 66 | name: 'works', 67 | type: 'test', 68 | pending: false, 69 | }, 70 | { 71 | name: 'TypeScript spec', 72 | type: 'suite', 73 | pending: false, 74 | }, 75 | ], 76 | }) 77 | }) 78 | 79 | test('typescript type', (t) => { 80 | t.plan(1) 81 | const source = stripIndent` 82 | type Person = { 83 | name: string 84 | } 85 | 86 | describe('TypeScript spec', () => { 87 | it('works', () => {}) 88 | }) 89 | ` 90 | const result = getTestNames(source) 91 | 92 | t.deepEqual(result, { 93 | suiteNames: ['TypeScript spec'], 94 | testNames: ['works'], 95 | tests: [ 96 | { 97 | name: 'works', 98 | type: 'test', 99 | pending: false, 100 | }, 101 | { 102 | name: 'TypeScript spec', 103 | type: 'suite', 104 | pending: false, 105 | }, 106 | ], 107 | }) 108 | }) 109 | 110 | test('typescript enum', (t) => { 111 | t.plan(1) 112 | const source = stripIndent` 113 | enum Person { 114 | name = 'name', 115 | age = 'age' 116 | } 117 | 118 | describe('TypeScript spec', () => { 119 | it('works', () => {}) 120 | }) 121 | ` 122 | const result = getTestNames(source) 123 | 124 | t.deepEqual(result, { 125 | suiteNames: ['TypeScript spec'], 126 | testNames: ['works'], 127 | tests: [ 128 | { 129 | name: 'works', 130 | type: 'test', 131 | pending: false, 132 | }, 133 | { 134 | name: 'TypeScript spec', 135 | type: 'suite', 136 | pending: false, 137 | }, 138 | ], 139 | }) 140 | }) 141 | 142 | test('typescript class with field modifiers', (t) => { 143 | t.plan(1) 144 | const source = stripIndent` 145 | class Test { 146 | private foo = 'private' 147 | 148 | public bar = 'public' 149 | 150 | protected baz = 'protected' 151 | 152 | public createSuite() { 153 | it('loads', () => {}) 154 | } 155 | } 156 | 157 | describe('TypeScript spec', () => { 158 | it('works', () => {}) 159 | }) 160 | ` 161 | const result = getTestNames(source) 162 | 163 | t.deepEqual(result, { 164 | suiteNames: ['TypeScript spec'], 165 | testNames: ['loads', 'works'], 166 | tests: [ 167 | { 168 | name: 'loads', 169 | type: 'test', 170 | pending: false, 171 | }, 172 | { 173 | name: 'works', 174 | type: 'test', 175 | pending: false, 176 | }, 177 | { 178 | name: 'TypeScript spec', 179 | type: 'suite', 180 | pending: false, 181 | }, 182 | ], 183 | }) 184 | }) 185 | 186 | test('typescript pending test', (t) => { 187 | t.plan(1) 188 | const source = stripIndent` 189 | describe('TypeScript spec', () => { 190 | it('needs to be added') 191 | }) 192 | ` 193 | const result = getTestNames(source) 194 | 195 | t.deepEqual(result, { 196 | suiteNames: ['TypeScript spec'], 197 | testNames: ['needs to be added'], 198 | tests: [ 199 | { 200 | name: 'needs to be added', 201 | type: 'test', 202 | pending: true, 203 | }, 204 | { 205 | name: 'TypeScript spec', 206 | type: 'suite', 207 | pending: false, 208 | }, 209 | ], 210 | }) 211 | }) 212 | -------------------------------------------------------------------------------- /test/visit-each-test.js: -------------------------------------------------------------------------------- 1 | const { getTestNames, visitEachTest, countTags } = require('../src') 2 | const { stripIndent } = require('common-tags') 3 | const test = require('ava') 4 | 5 | test('visits two tests', (t) => { 6 | t.plan(2) 7 | const source = stripIndent` 8 | it('works a', () => {}) 9 | it('works b', () => {}) 10 | ` 11 | const result = getTestNames(source, true) 12 | t.deepEqual(result.testCount, 2) 13 | 14 | let counter = 0 15 | visitEachTest(result.structure, (test) => { 16 | counter += 1 17 | }) 18 | t.deepEqual(counter, 2) 19 | }) 20 | 21 | test('visits the tests inside the suite', (t) => { 22 | t.plan(1) 23 | const source = stripIndent` 24 | describe('parent', () => { 25 | it('works a', () => {}) 26 | it('works b', () => {}) 27 | }) 28 | ` 29 | const result = getTestNames(source, true) 30 | 31 | let counter = 0 32 | visitEachTest(result.structure, (test) => { 33 | counter += 1 34 | }) 35 | t.deepEqual(counter, 2) 36 | }) 37 | 38 | test('visits the tests inside the inner suite', (t) => { 39 | t.plan(1) 40 | const source = stripIndent` 41 | describe('parent', () => { 42 | describe('inner', () => { 43 | it('works a', () => {}) 44 | it('works b', () => {}) 45 | }) 46 | }) 47 | ` 48 | const result = getTestNames(source, true) 49 | 50 | let counter = 0 51 | visitEachTest(result.structure, (test) => { 52 | counter += 1 53 | }) 54 | t.deepEqual(counter, 2) 55 | }) 56 | 57 | test('visits each suite', (t) => { 58 | t.plan(1) 59 | const source = stripIndent` 60 | describe('parent', () => { 61 | describe('inner 1', () => { 62 | it('works a', () => {}) 63 | it('works b', () => {}) 64 | }) 65 | 66 | describe('inner 2', () => { 67 | it('works a', () => {}) 68 | it('works b', () => {}) 69 | }) 70 | }) 71 | ` 72 | const result = getTestNames(source, true) 73 | 74 | let counter = 0 75 | visitEachTest(result.structure, (test) => { 76 | counter += 1 77 | }) 78 | t.deepEqual(counter, 4) 79 | }) 80 | 81 | test('passes the test info to the callback', (t) => { 82 | t.plan(8) 83 | const source = stripIndent` 84 | describe('parent', () => { 85 | describe('inner 1', () => { 86 | it('works a', {tags: '@user'}, () => {}) 87 | }) 88 | 89 | describe('inner 2', () => { 90 | it('works b', () => {}) 91 | }) 92 | }) 93 | ` 94 | const result = getTestNames(source, true) 95 | 96 | let counter = 0 97 | visitEachTest(result.structure, (test) => { 98 | t.is(test.type, `test`) 99 | t.is(test.pending, false) 100 | if (test.name === 'works a') { 101 | t.deepEqual(test.tags, ['@user']) 102 | } else { 103 | t.is(test.name, 'works b') 104 | t.is(test.tags, undefined) 105 | } 106 | 107 | counter += 1 108 | }) 109 | t.deepEqual(counter, 2) 110 | }) 111 | 112 | test('collects the test tags', (t) => { 113 | t.plan(2) 114 | const source = stripIndent` 115 | describe('parent', () => { 116 | describe('inner 1', () => { 117 | it('works a', {tags: '@user'}, () => {}) 118 | }) 119 | 120 | describe('inner 2', () => { 121 | it('works b', {tags: ['@tag1', '@tag2']} , () => {}) 122 | }) 123 | 124 | it('works c', {tags: '@tag1'}, () => {}) 125 | }) 126 | ` 127 | const result = getTestNames(source, true) 128 | // in place experimentation 129 | const tags = {} 130 | visitEachTest(result.structure, (test) => { 131 | if (!test.tags) { 132 | return 133 | } 134 | // normalize the tags to be an array of strings 135 | const list = [].concat(test.tags) 136 | list.forEach((tag) => { 137 | if (!(tag in tags)) { 138 | tags[tag] = 1 139 | } else { 140 | tags[tag] += 1 141 | } 142 | }) 143 | }) 144 | t.deepEqual(tags, { 145 | '@user': 1, 146 | '@tag1': 2, 147 | '@tag2': 1, 148 | }) 149 | 150 | // library function 151 | const foundTags = countTags(result.structure) 152 | t.deepEqual(tags, foundTags) 153 | }) 154 | 155 | test('passes the parent suite parameter', (t) => { 156 | t.plan(3) 157 | const source = stripIndent` 158 | describe('parent', () => { 159 | it('works a', () => {}) 160 | it('works b', () => {}) 161 | }) 162 | ` 163 | const result = getTestNames(source, true) 164 | 165 | let counter = 0 166 | visitEachTest(result.structure, (test, parentSuite) => { 167 | counter += 1 168 | t.is(parentSuite.name, 'parent') 169 | }) 170 | t.deepEqual(counter, 2) 171 | }) 172 | 173 | test('passes the correct suite parameter', (t) => { 174 | t.plan(3) 175 | const source = stripIndent` 176 | describe('parent', () => { 177 | it('works a', () => {}) 178 | 179 | describe('inner', () => { 180 | it('works b', () => {}) 181 | }) 182 | }) 183 | ` 184 | const result = getTestNames(source, true) 185 | 186 | let counter = 0 187 | visitEachTest(result.structure, (test, parentSuite) => { 188 | counter += 1 189 | if (test.name === 'works a') { 190 | t.is(parentSuite.name, 'parent') 191 | } else if (test.name === 'works b') { 192 | t.is(parentSuite.name, 'inner') 193 | } 194 | }) 195 | t.deepEqual(counter, 2) 196 | }) 197 | -------------------------------------------------------------------------------- /test/format.js: -------------------------------------------------------------------------------- 1 | const { stripIndent } = require('common-tags') 2 | const test = require('ava') 3 | const { formatTestList } = require('../src/format-test-list') 4 | 5 | test('just tests', (t) => { 6 | t.plan(1) 7 | const tests = [ 8 | { 9 | name: 'first', 10 | }, 11 | { 12 | name: 'second', 13 | }, 14 | { 15 | name: 'last', 16 | }, 17 | ] 18 | const s = formatTestList(tests) 19 | t.deepEqual( 20 | s, 21 | stripIndent` 22 | ├─ first 23 | ├─ second 24 | └─ last 25 | `, 26 | ) 27 | }) 28 | 29 | test('suite with tests', (t) => { 30 | t.plan(1) 31 | const tests = [ 32 | { 33 | name: 'parent suite', 34 | type: 'suite', 35 | tests: [ 36 | { 37 | name: 'first', 38 | }, 39 | { 40 | name: 'second', 41 | }, 42 | { 43 | name: 'last', 44 | }, 45 | ], 46 | }, 47 | ] 48 | const s = formatTestList(tests) 49 | t.deepEqual( 50 | s, 51 | stripIndent` 52 | └─ parent suite 53 | ├─ first 54 | ├─ second 55 | └─ last 56 | `, 57 | ) 58 | }) 59 | 60 | test('two suites', (t) => { 61 | t.plan(1) 62 | const tests = [ 63 | { 64 | name: 'suite A', 65 | type: 'suite', 66 | tests: [ 67 | { 68 | name: 'first', 69 | }, 70 | { 71 | name: 'second', 72 | }, 73 | { 74 | name: 'last', 75 | }, 76 | ], 77 | }, 78 | { 79 | name: 'suite B', 80 | type: 'suite', 81 | tests: [ 82 | { 83 | name: 'first', 84 | }, 85 | { 86 | name: 'second', 87 | }, 88 | { 89 | name: 'last', 90 | }, 91 | ], 92 | }, 93 | ] 94 | const s = formatTestList(tests) 95 | t.deepEqual( 96 | s, 97 | stripIndent` 98 | ├─ suite A 99 | │ ├─ first 100 | │ ├─ second 101 | │ └─ last 102 | └─ suite B 103 | ├─ first 104 | ├─ second 105 | └─ last 106 | `, 107 | ) 108 | }) 109 | 110 | test('nested suites', (t) => { 111 | t.plan(1) 112 | const tests = [ 113 | { 114 | name: 'suite A', 115 | type: 'suite', 116 | tests: [ 117 | { 118 | name: 'first', 119 | }, 120 | { 121 | name: 'second', 122 | }, 123 | { 124 | name: 'suite B', 125 | type: 'suite', 126 | tests: [ 127 | { 128 | name: 'test a', 129 | }, 130 | { 131 | name: 'test b', 132 | }, 133 | ], 134 | }, 135 | { 136 | name: 'last', 137 | }, 138 | ], 139 | }, 140 | ] 141 | const s = formatTestList(tests) 142 | t.deepEqual( 143 | s, 144 | stripIndent` 145 | └─ suite A 146 | ├─ first 147 | ├─ second 148 | ├─ suite B 149 | │ ├─ test a 150 | │ └─ test b 151 | └─ last 152 | `, 153 | ) 154 | }) 155 | 156 | test('three suites', (t) => { 157 | t.plan(1) 158 | const tests = [ 159 | { 160 | name: 'suite A', 161 | type: 'suite', 162 | tests: [ 163 | { 164 | name: 'suite B', 165 | type: 'suite', 166 | tests: [ 167 | { 168 | name: 'suite C', 169 | type: 'suite', 170 | tests: [ 171 | { 172 | name: 'test a', 173 | }, 174 | { 175 | name: 'test b', 176 | }, 177 | ], 178 | }, 179 | ], 180 | }, 181 | ], 182 | }, 183 | ] 184 | const s = formatTestList(tests) 185 | t.deepEqual( 186 | s, 187 | stripIndent` 188 | └─ suite A 189 | └─ suite B 190 | └─ suite C 191 | ├─ test a 192 | └─ test b 193 | `, 194 | ) 195 | }) 196 | 197 | test('no tests', (t) => { 198 | t.plan(1) 199 | const tests = [] 200 | const s = formatTestList(tests) 201 | t.deepEqual( 202 | s, 203 | stripIndent` 204 | └─ (empty) 205 | `, 206 | ) 207 | }) 208 | 209 | test('no tests with indent 1', (t) => { 210 | t.plan(1) 211 | const tests = [] 212 | const s = formatTestList(tests, 1) 213 | t.deepEqual(s, '└─ (empty)') 214 | }) 215 | 216 | test('no tests with indent 2', (t) => { 217 | t.plan(1) 218 | const tests = [] 219 | const s = formatTestList(tests, 2) 220 | t.deepEqual(s, ' └─ (empty)') 221 | }) 222 | 223 | test('pending test', (t) => { 224 | t.plan(1) 225 | const tests = [ 226 | { 227 | name: 'first', 228 | }, 229 | { 230 | name: 'second', 231 | pending: true, 232 | }, 233 | { 234 | name: 'last', 235 | pending: true, 236 | }, 237 | ] 238 | const s = formatTestList(tests) 239 | t.deepEqual( 240 | s, 241 | stripIndent` 242 | ├─ first 243 | ├⊙ second 244 | └⊙ last 245 | `, 246 | ) 247 | }) 248 | 249 | test('pending suite', (t) => { 250 | t.plan(1) 251 | const tests = [ 252 | { 253 | name: 'pending suite', 254 | type: 'suite', 255 | pending: true, 256 | tests: [ 257 | { 258 | name: 'a test', 259 | }, 260 | ], 261 | }, 262 | ] 263 | const s = formatTestList(tests) 264 | t.deepEqual( 265 | s, 266 | stripIndent` 267 | └⊙ pending suite 268 | └─ a test 269 | `, 270 | ) 271 | }) 272 | 273 | test('exclusive test', (t) => { 274 | t.plan(1) 275 | const tests = [ 276 | { 277 | name: 'first', 278 | }, 279 | { 280 | name: 'second', 281 | exclusive: true, 282 | }, 283 | { 284 | name: 'last', 285 | exclusive: true, 286 | }, 287 | ] 288 | const s = formatTestList(tests) 289 | t.deepEqual( 290 | s, 291 | stripIndent` 292 | ├─ first 293 | ├> second 294 | └> last 295 | `, 296 | ) 297 | }) 298 | 299 | // https://github.com/bahmutov/find-test-names/issues/15 300 | test('inner suite', (t) => { 301 | t.plan(1) 302 | const tests = [ 303 | { 304 | name: 'parent suite', 305 | tags: ['@main'], 306 | pending: false, 307 | type: 'suite', 308 | tests: [{ name: 'works', tags: undefined, pending: false, type: 'test' }], 309 | suites: [ 310 | { 311 | name: 'inner suite', 312 | tags: undefined, 313 | pending: false, 314 | type: 'suite', 315 | tests: [ 316 | { 317 | name: 'shows something', 318 | tags: ['@user'], 319 | pending: false, 320 | type: 'test', 321 | }, 322 | ], 323 | suites: [], 324 | }, 325 | ], 326 | }, 327 | ] 328 | const s = formatTestList(tests) 329 | t.deepEqual( 330 | s, 331 | stripIndent` 332 | └─ parent suite [@main] 333 | ├─ works 334 | └─ inner suite 335 | └─ shows something [@user] 336 | `, 337 | ) 338 | }) 339 | 340 | // https://github.com/bahmutov/find-test-names/issues/18 341 | test('vertical bars', (t) => { 342 | t.plan(1) 343 | const tests = [ 344 | { 345 | name: 'suite A', 346 | type: 'suite', 347 | suites: [ 348 | { 349 | name: 'inner one', 350 | type: 'suite', 351 | }, 352 | { 353 | name: 'inner two', 354 | type: 'suite', 355 | }, 356 | ], 357 | tests: [ 358 | { 359 | name: 'works', 360 | type: 'test', 361 | }, 362 | ], 363 | }, 364 | ] 365 | const s = formatTestList(tests) 366 | // console.log(s) 367 | t.deepEqual( 368 | s, 369 | stripIndent` 370 | └─ suite A 371 | ├─ works 372 | ├─ inner one 373 | │ └─ (empty) 374 | └─ inner two 375 | └─ (empty) 376 | `, 377 | ) 378 | }) 379 | -------------------------------------------------------------------------------- /test/structure.js: -------------------------------------------------------------------------------- 1 | const { stripIndent } = require('common-tags') 2 | const test = require('ava') 3 | const { getTestNames } = require('../src') 4 | 5 | test('handles empty string', (t) => { 6 | t.plan(1) 7 | const source = '' 8 | const result = getTestNames(source, true) 9 | 10 | t.deepEqual(result.structure, []) 11 | }) 12 | 13 | test('handles pending test', (t) => { 14 | t.plan(1) 15 | const source = stripIndent` 16 | it('will be added later') 17 | ` 18 | const result = getTestNames(source, true) 19 | 20 | t.deepEqual(result.structure, [ 21 | { 22 | fullName: 'will be added later', 23 | name: 'will be added later', 24 | pending: true, 25 | tags: undefined, 26 | requiredTags: undefined, 27 | type: 'test', 28 | }, 29 | ]) 30 | }) 31 | 32 | test('handles a single test', (t) => { 33 | t.plan(1) 34 | const source = stripIndent` 35 | it('works', () => {}) 36 | ` 37 | const result = getTestNames(source, true) 38 | 39 | t.deepEqual(result.structure, [ 40 | { 41 | fullName: 'works', 42 | name: 'works', 43 | pending: false, 44 | tags: undefined, 45 | requiredTags: undefined, 46 | type: 'test', 47 | }, 48 | ]) 49 | }) 50 | 51 | test('extract complex structure', (t) => { 52 | t.plan(3) 53 | const source = stripIndent` 54 | it('top', () => {}) 55 | 56 | describe('foo', {tags: ['@one', '@two']}, () => { 57 | 58 | describe('foobar', {tags: ['@four']}, () => { 59 | it('bar', {tags: ['@three']}, () => {}) 60 | 61 | it('quox', {tags: ['@five']}, () => {}) 62 | }); 63 | 64 | it('blipp', {tags: []}, () => {}) 65 | }) 66 | 67 | it('baz', {tags: ['@one']}, () => {}) 68 | ` 69 | const result = getTestNames(source, true) 70 | 71 | t.deepEqual(result.fullTestNames, [ 72 | 'baz', 73 | 'foo blipp', 74 | 'foo foobar bar', 75 | 'foo foobar quox', 76 | 'top', 77 | ]) 78 | 79 | t.deepEqual(result.fullSuiteNames, ['foo', 'foo foobar']) 80 | 81 | t.deepEqual(result.structure, [ 82 | { 83 | fullName: 'top', 84 | name: 'top', 85 | tags: undefined, 86 | requiredTags: undefined, 87 | type: 'test', 88 | pending: false, 89 | }, 90 | { 91 | fullName: 'foo', 92 | name: 'foo', 93 | type: 'suite', 94 | pending: false, 95 | suiteCount: 1, 96 | testCount: 3, 97 | pendingTestCount: 0, 98 | suites: [ 99 | { 100 | fullName: 'foo foobar', 101 | name: 'foobar', 102 | type: 'suite', 103 | pending: false, 104 | suiteCount: 0, 105 | testCount: 2, 106 | pendingTestCount: 0, 107 | suites: [], 108 | tests: [ 109 | { 110 | fullName: 'foo foobar bar', 111 | name: 'bar', 112 | tags: ['@three'], 113 | requiredTags: undefined, 114 | type: 'test', 115 | pending: false, 116 | }, 117 | { 118 | fullName: 'foo foobar quox', 119 | name: 'quox', 120 | tags: ['@five'], 121 | requiredTags: undefined, 122 | type: 'test', 123 | pending: false, 124 | }, 125 | ], 126 | tags: ['@four'], 127 | requiredTags: undefined, 128 | }, 129 | ], 130 | tests: [ 131 | { 132 | fullName: 'foo blipp', 133 | name: 'blipp', 134 | tags: undefined, 135 | requiredTags: undefined, 136 | type: 'test', 137 | pending: false, 138 | }, 139 | ], 140 | tags: ['@one', '@two'], 141 | requiredTags: undefined, 142 | }, 143 | { 144 | fullName: 'baz', 145 | name: 'baz', 146 | tags: ['@one'], 147 | requiredTags: undefined, 148 | type: 'test', 149 | pending: false, 150 | }, 151 | ]) 152 | }) 153 | 154 | test('structure with empty suites', (t) => { 155 | t.plan(3) 156 | const source = stripIndent` 157 | it('top', () => {}) 158 | 159 | describe('foo', {tags: ['@one', '@two']}, () => { 160 | describe('empty before', () => { 161 | describe('empty before nested', () => {}) 162 | }) 163 | 164 | describe('foobar', {tags: ['@four']}, () => { 165 | it('bar', {tags: ['@three']}, () => {}) 166 | 167 | it('quox', {tags: ['@five']}, () => {}) 168 | }); 169 | 170 | it('blipp', {tags: []}, () => {}) 171 | 172 | describe('empty after', () => { 173 | describe('empty after nested', () => {}) 174 | }) 175 | 176 | }) 177 | 178 | it('baz', {tags: ['@one']}, () => {}) 179 | ` 180 | const result = getTestNames(source, true) 181 | 182 | t.deepEqual(result.fullTestNames, [ 183 | 'baz', 184 | 'foo blipp', 185 | 'foo foobar bar', 186 | 'foo foobar quox', 187 | 'top', 188 | ]) 189 | 190 | t.deepEqual(result.fullSuiteNames, [ 191 | 'foo', 192 | 'foo empty after', 193 | 'foo empty after empty after nested', 194 | 'foo empty before', 195 | 'foo empty before empty before nested', 196 | 'foo foobar', 197 | ]) 198 | 199 | t.deepEqual(result.structure, [ 200 | { 201 | fullName: 'top', 202 | name: 'top', 203 | type: 'test', 204 | pending: false, 205 | tags: undefined, 206 | requiredTags: undefined, 207 | }, 208 | { 209 | fullName: 'foo', 210 | name: 'foo', 211 | type: 'suite', 212 | pending: false, 213 | tags: ['@one', '@two'], 214 | requiredTags: undefined, 215 | suiteCount: 5, 216 | testCount: 3, 217 | pendingTestCount: 0, 218 | suites: [ 219 | { 220 | fullName: 'foo empty before', 221 | name: 'empty before', 222 | type: 'suite', 223 | pending: false, 224 | suiteCount: 1, 225 | testCount: 0, 226 | pendingTestCount: 0, 227 | suites: [ 228 | { 229 | fullName: 'foo empty before empty before nested', 230 | name: 'empty before nested', 231 | type: 'suite', 232 | pending: false, 233 | suiteCount: 0, 234 | testCount: 0, 235 | pendingTestCount: 0, 236 | suites: [], 237 | tests: [], 238 | tags: undefined, 239 | requiredTags: undefined, 240 | }, 241 | ], 242 | tests: [], 243 | tags: undefined, 244 | requiredTags: undefined, 245 | }, 246 | { 247 | fullName: 'foo foobar', 248 | name: 'foobar', 249 | type: 'suite', 250 | pending: false, 251 | suiteCount: 0, 252 | testCount: 2, 253 | pendingTestCount: 0, 254 | suites: [], 255 | tests: [ 256 | { 257 | fullName: 'foo foobar bar', 258 | name: 'bar', 259 | tags: ['@three'], 260 | requiredTags: undefined, 261 | type: 'test', 262 | pending: false, 263 | }, 264 | { 265 | fullName: 'foo foobar quox', 266 | name: 'quox', 267 | tags: ['@five'], 268 | requiredTags: undefined, 269 | type: 'test', 270 | pending: false, 271 | }, 272 | ], 273 | tags: ['@four'], 274 | requiredTags: undefined, 275 | }, 276 | { 277 | fullName: 'foo empty after', 278 | name: 'empty after', 279 | type: 'suite', 280 | pending: false, 281 | testCount: 0, 282 | suiteCount: 1, 283 | pendingTestCount: 0, 284 | suites: [ 285 | { 286 | fullName: 'foo empty after empty after nested', 287 | name: 'empty after nested', 288 | type: 'suite', 289 | pending: false, 290 | testCount: 0, 291 | pendingTestCount: 0, 292 | suiteCount: 0, 293 | suites: [], 294 | tests: [], 295 | tags: undefined, 296 | requiredTags: undefined, 297 | }, 298 | ], 299 | tests: [], 300 | tags: undefined, 301 | requiredTags: undefined, 302 | }, 303 | ], 304 | tests: [ 305 | { 306 | fullName: 'foo blipp', 307 | name: 'blipp', 308 | tags: undefined, 309 | requiredTags: undefined, 310 | type: 'test', 311 | pending: false, 312 | }, 313 | ], 314 | }, 315 | { 316 | fullName: 'baz', 317 | name: 'baz', 318 | tags: ['@one'], 319 | requiredTags: undefined, 320 | type: 'test', 321 | pending: false, 322 | }, 323 | ]) 324 | }) 325 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # find-test-names [![ci](https://github.com/bahmutov/find-test-names/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bahmutov/find-test-names/actions/workflows/ci.yml) 2 | 3 | > Given a Mocha / Cypress spec file, returns the list of suite and test names 4 | 5 | ## Install 6 | 7 | ```shell 8 | # install using NPM, probably as a dev dependency 9 | $ npm i -D find-test-names 10 | # install using Yarn 11 | $ yarn add -D find-test-names 12 | ``` 13 | 14 | ## Use 15 | 16 | ```js 17 | const { getTestNames } = require('find-test-names') 18 | const result = getTestNames(specSourceCode) 19 | // { "suiteNames": [], "testNames": [], "tests": [] } 20 | ``` 21 | 22 | The `tests` is a list with each test and suite name, and optional list of tags. 23 | 24 | ```js 25 | // spec.js 26 | it('works', {tags: ['@user']}, () => { ... }) 27 | // found test names 28 | // { tests: [{ name: 'works', tags: ['@user'] }] } 29 | ``` 30 | 31 | ### withStructure 32 | 33 | You can get the entire structure of suites and tests by passing `true` argument 34 | 35 | ```js 36 | const result = getTestNames(specSourceCode, true) 37 | // use the result.structure array 38 | ``` 39 | 40 | To view this in action, use `npm run demo-structure` which points at [bin/find-tests.js](./bin/find-tests.js) 41 | 42 | ### Pending tests 43 | 44 | The tests `it.skip` are extracted and have the property `pending: true` 45 | 46 | ### setEffectiveTags 47 | 48 | Often, you want to have each test and see which tags it has and what parent tags apply to it. You can compute for each test a list of _effective_ tags and set it for each test. 49 | 50 | ```js 51 | // example spec code 52 | describe('parent', { tags: '@user' }, () => { 53 | describe('parent', { tags: '@auth' }, () => { 54 | it('works a', { tags: '@one' }, () => {}) 55 | it('works b', () => {}) 56 | }) 57 | }) 58 | ``` 59 | 60 | ```js 61 | const { getTestNames, setEffectiveTags } = require('find-test-names') 62 | const result = getTestNames(source, true) 63 | setEffectiveTags(result.structure) 64 | ``` 65 | 66 | If you traverse the `result.structure`, the test "works a" will have the `effectiveTags` list with `@user, @auth, @one`, and the test "works b" will have the `effectiveTags` list with `@user, @auth, @one`. 67 | 68 | ### filterByEffectiveTags 69 | 70 | Once you `setEffectiveTags`, you can filter all tests by an effective tag. For example, to fid all tests with the given tag: 71 | 72 | ```js 73 | const { 74 | getTestNames, 75 | setEffectiveTags, 76 | filterByEffectiveTags, 77 | } = require('find-test-names') 78 | const result = getTestNames(source, true) 79 | setEffectiveTags(result.structure) 80 | const tests = filterByEffectiveTags(result.structure, ['@one']) 81 | ``` 82 | 83 | Returns individual test objects. 84 | 85 | Tip: you can pass the source code and the tags to the `filterByEffectiveTags` function and let it parse it 86 | 87 | ```js 88 | const filtered = filterByEffectiveTags(source, ['@user']) 89 | ``` 90 | 91 | ### findEffectiveTestTags 92 | 93 | Returns a single object with full test titles as keys. For each key, the value is the list of effective tags. See the [find-effective-tags.js](./test/find-effective-tags.js) spec file. 94 | 95 | ### findEffectiveTestTagsIn 96 | 97 | You can use the utility method `findEffectiveTestTagsIn(filename)` to let this module read the file from disk and find the effective tags that apply to each test by its full title. 98 | 99 | ### requiredTags 100 | 101 | The test and suite options object can have another list of tags called `requiredTags` 102 | 103 | ```js 104 | it( 105 | 'works a', 106 | { 107 | tags: '@one', 108 | requiredTags: ['@data'], 109 | }, 110 | () => {}, 111 | ) 112 | ``` 113 | 114 | These tags also will be extracted and the effective required tags from the parent suites applied to the children tests. 115 | 116 | ### Supports 117 | 118 | - JSX in the source code 119 | - TypeScript 120 | 121 | ### tag variables 122 | 123 | Typically, test tags are static literals, like 124 | 125 | ```js 126 | it('works', { tags: '@user' }) 127 | ``` 128 | 129 | But sometimes you want to use variables to set them. To be able to statically analyze the source files, this package currently supports: 130 | 131 | - local constants 132 | 133 | ```js 134 | const USER = '@user' 135 | 136 | // in the same file 137 | it('works', { tags: USER }) 138 | it('works', { tags: ['@sanity', USER] }) 139 | ``` 140 | 141 | - local objects with literal property access 142 | 143 | ```js 144 | const TAGS = { 145 | user: '@user', 146 | } 147 | 148 | // in the same file 149 | it('works', { tags: TAGS.user }) 150 | it('works', { tags: ['@sanity', TAGS.user] }) 151 | ``` 152 | 153 | ### Bin 154 | 155 | This package includes [bin/find-test-names.js](./bin/find-test-names.js) that you can use from the command line 156 | 157 | ```shell 158 | $ npx find-test-names 159 | # prints the describe and test names found in the spec file 160 | ``` 161 | 162 | ### print-tests 163 | 164 | Print found suites an tests 165 | 166 | ```shell 167 | $ npx print-tests 168 | ``` 169 | 170 | For example, in this repo 171 | 172 | ``` 173 | $ npx print-tests 'test-cy/**/*.js' 174 | 175 | test-cy/spec-a.js 176 | └─ Suite A 177 | ├─ works 1 178 | └─ works 2 179 | 180 | test-cy/spec-b.js 181 | └─ Suite B 182 | ├─ works 1 183 | └─ works 2 184 | ``` 185 | 186 | Pending tests and suites are marked with `⊙` character like this: 187 | 188 | ``` 189 | ├─ first 190 | ├⊙ second 191 | └⊙ last 192 | ``` 193 | 194 | Exclusive tests are shown with `>` character like this: 195 | 196 | ``` 197 | ├─ first 198 | ├> second 199 | └─ last 200 | ``` 201 | 202 | If there are tags, they are shown after the name 203 | 204 | ``` 205 | ├─ first [tag1, tag2] 206 | ├─ second [@sanity] 207 | └─ last 208 | ``` 209 | 210 | If there are required test tags, they are shown after the test name using double brackets `[[ ]]` 211 | 212 | ``` 213 | ├─ first [tag1, tag2] 214 | ├─ second [@sanity] [[clean]] 215 | └─ last 216 | ``` 217 | 218 | ### Unknown test names 219 | 220 | Sometimes a test name comes from a variable, not from a literal string. 221 | 222 | ```js 223 | // test name is a variable, not a literal string 224 | const testName = 'nice' 225 | it(testName, () => {}) 226 | ``` 227 | 228 | In that case, the tags are still extracted. When printing, such tests have name ``. 229 | 230 | ### comment lines 231 | 232 | If the test function has preceding comment lines, the comment line right before the test is extracted and included 233 | 234 | ```js 235 | // line 1 236 | // line 2 237 | // line 3 238 | it('works', ...) 239 | // extracted test object will have 240 | // name: "works", 241 | // comment: "line 3" 242 | ``` 243 | 244 | ## Debugging 245 | 246 | Run with the environment variable `DEBUG=find-test-names` to see verbose logs 247 | 248 | ``` 249 | $ DEBUG=find-test-names npx find-test-names 250 | ... 251 | 2023-02-28T12:45:21.074Z find-test-names parsing source as a script 252 | 2023-02-28T12:45:21.083Z find-test-names success! 253 | 2023-02-28T12:45:21.084Z find-test-names found test "has jsx component" 254 | 2023-02-28T12:45:21.084Z find-test-names found describe "parent" 255 | ``` 256 | 257 | ## Small print 258 | 259 | Author: Gleb Bahmutov <gleb.bahmutov@gmail.com> © 2021 260 | 261 | - [@bahmutov](https://twitter.com/bahmutov) 262 | - [glebbahmutov.com](https://glebbahmutov.com) 263 | - [blog](https://glebbahmutov.com/blog) 264 | - [videos](https://www.youtube.com/glebbahmutov) 265 | - [presentations](https://slides.com/bahmutov) 266 | - [cypress.tips](https://cypress.tips) 267 | - [Cypress Tips & Tricks Newsletter](https://cypresstips.substack.com/) 268 | - [my Cypress courses](https://cypress.tips/courses) 269 | 270 | License: MIT - do anything with the code, but don't blame me if it does not work. 271 | 272 | Support: if you find any problems with this module, email / tweet / 273 | [open issue](https://github.com/bahmutov/find-test-names/issues) on Github 274 | 275 | ## MIT License 276 | 277 | Copyright (c) 2021 Gleb Bahmutov <gleb.bahmutov@gmail.com> 278 | 279 | Permission is hereby granted, free of charge, to any person 280 | obtaining a copy of this software and associated documentation 281 | files (the "Software"), to deal in the Software without 282 | restriction, including without limitation the rights to use, 283 | copy, modify, merge, publish, distribute, sublicense, and/or sell 284 | copies of the Software, and to permit persons to whom the 285 | Software is furnished to do so, subject to the following 286 | conditions: 287 | 288 | The above copyright notice and this permission notice shall be 289 | included in all copies or substantial portions of the Software. 290 | 291 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 292 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 293 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 294 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 295 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 296 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 297 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 298 | OTHER DEALINGS IN THE SOFTWARE. 299 | -------------------------------------------------------------------------------- /test/counts.js: -------------------------------------------------------------------------------- 1 | const { stripIndent } = require('common-tags') 2 | const test = require('ava') 3 | const { getTestNames } = require('../src') 4 | 5 | // common structure for two tests we expect to find 6 | const twoTests = [ 7 | { 8 | fullName: 'works a', 9 | name: 'works a', 10 | type: 'test', 11 | pending: false, 12 | tags: undefined, 13 | requiredTags: undefined, 14 | }, 15 | { 16 | fullName: 'works b', 17 | name: 'works b', 18 | type: 'test', 19 | pending: false, 20 | tags: undefined, 21 | requiredTags: undefined, 22 | }, 23 | ] 24 | 25 | test('just tests have no count', (t) => { 26 | t.plan(3) 27 | const source = stripIndent` 28 | it('works a', () => {}) 29 | it('works b', () => {}) 30 | ` 31 | const result = getTestNames(source, true) 32 | t.deepEqual(result.structure, twoTests) 33 | t.deepEqual(result.testCount, 2) 34 | t.deepEqual(result.pendingTestCount, 0) 35 | }) 36 | 37 | test('tests with pending', (t) => { 38 | t.plan(2) 39 | const source = stripIndent` 40 | it('works a', () => {}) 41 | it('works b') 42 | ` 43 | const result = getTestNames(source, true) 44 | t.deepEqual(result.testCount, 2) 45 | t.deepEqual(result.pendingTestCount, 1) 46 | }) 47 | 48 | test('suite counts the tests inside', (t) => { 49 | t.plan(3) 50 | const source = stripIndent` 51 | describe('loads', () => { 52 | it('works a', () => {}) 53 | it('works b', () => {}) 54 | }) 55 | ` 56 | const result = getTestNames(source, true) 57 | t.deepEqual(result.structure, [ 58 | { 59 | fullName: 'loads', 60 | name: 'loads', 61 | type: 'suite', 62 | pending: false, 63 | tags: undefined, 64 | requiredTags: undefined, 65 | suites: [], 66 | suiteCount: 0, 67 | // counts all tests inside 68 | testCount: 2, 69 | tests: twoTests.map((test) => ({ 70 | ...test, 71 | fullName: `loads ${test.name}`, 72 | })), 73 | pendingTestCount: 0, 74 | }, 75 | ]) 76 | t.deepEqual(result.testCount, 2) 77 | t.deepEqual(result.pendingTestCount, 0) 78 | }) 79 | 80 | test('suite counts the tests inside inner suites', (t) => { 81 | t.plan(1) 82 | const source = stripIndent` 83 | describe('parent', () => { 84 | describe('inner1', () => { 85 | it('works a', () => {}) 86 | it('works b', () => {}) 87 | }) 88 | describe('inner2', () => { 89 | it('works a', () => {}) 90 | it('works b', () => {}) 91 | }) 92 | }) 93 | ` 94 | const result = getTestNames(source, true) 95 | t.deepEqual(result.structure, [ 96 | { 97 | fullName: 'parent', 98 | name: 'parent', 99 | type: 'suite', 100 | pending: false, 101 | tags: undefined, 102 | requiredTags: undefined, 103 | pendingTestCount: 0, 104 | suites: [ 105 | { 106 | fullName: 'parent inner1', 107 | name: 'inner1', 108 | type: 'suite', 109 | pending: false, 110 | tags: undefined, 111 | requiredTags: undefined, 112 | suites: [], 113 | suiteCount: 0, 114 | testCount: 2, 115 | tests: twoTests.map((test) => ({ 116 | ...test, 117 | fullName: `parent inner1 ${test.name}`, 118 | })), 119 | pendingTestCount: 0, 120 | }, 121 | { 122 | fullName: 'parent inner2', 123 | name: 'inner2', 124 | type: 'suite', 125 | pending: false, 126 | tags: undefined, 127 | requiredTags: undefined, 128 | suites: [], 129 | suiteCount: 0, 130 | testCount: 2, 131 | tests: twoTests.map((test) => ({ 132 | ...test, 133 | fullName: `parent inner2 ${test.name}`, 134 | })), 135 | pendingTestCount: 0, 136 | }, 137 | ], 138 | suiteCount: 2, 139 | // counts all the test inside 140 | testCount: 4, 141 | tests: [], 142 | }, 143 | ]) 144 | }) 145 | 146 | test('handles counts in deeply nested structure', (t) => { 147 | t.plan(1) 148 | const source = stripIndent` 149 | describe('foo', () => { 150 | 151 | describe('child1', () => { 152 | it('bar', () => {}) 153 | 154 | it('quox', () => {}) 155 | }); 156 | 157 | describe('child2', () => { 158 | it('baz', () => {}) 159 | 160 | describe('grandchild1', () => { 161 | it('grandchild1-test', () => {}) 162 | 163 | describe('greatgrandchild1', () => { 164 | it('greatgrandchild1-test', () => {}) 165 | }); 166 | }); 167 | 168 | }); 169 | 170 | it('blipp', () => {}) 171 | }) 172 | ` 173 | const result = getTestNames(source, true) 174 | 175 | t.deepEqual(result.structure, [ 176 | { 177 | name: 'foo', 178 | fullName: 'foo', 179 | type: 'suite', 180 | pending: false, 181 | tags: undefined, 182 | requiredTags: undefined, 183 | testCount: 6, 184 | pendingTestCount: 0, 185 | suiteCount: 4, 186 | suites: [ 187 | { 188 | name: 'child1', 189 | fullName: 'foo child1', 190 | type: 'suite', 191 | pending: false, 192 | suites: [], 193 | testCount: 2, 194 | pendingTestCount: 0, 195 | suiteCount: 0, 196 | tests: [ 197 | { 198 | name: 'bar', 199 | fullName: 'foo child1 bar', 200 | tags: undefined, 201 | requiredTags: undefined, 202 | type: 'test', 203 | pending: false, 204 | }, 205 | { 206 | name: 'quox', 207 | fullName: 'foo child1 quox', 208 | tags: undefined, 209 | requiredTags: undefined, 210 | type: 'test', 211 | pending: false, 212 | }, 213 | ], 214 | tags: undefined, 215 | requiredTags: undefined, 216 | }, 217 | { 218 | name: 'child2', 219 | fullName: 'foo child2', 220 | type: 'suite', 221 | pending: false, 222 | suiteCount: 2, 223 | testCount: 3, 224 | pendingTestCount: 0, 225 | tags: undefined, 226 | requiredTags: undefined, 227 | suites: [ 228 | { 229 | name: 'grandchild1', 230 | fullName: 'foo child2 grandchild1', 231 | type: 'suite', 232 | pending: false, 233 | suiteCount: 1, 234 | tags: undefined, 235 | requiredTags: undefined, 236 | testCount: 2, 237 | pendingTestCount: 0, 238 | suites: [ 239 | { 240 | name: 'greatgrandchild1', 241 | fullName: 'foo child2 grandchild1 greatgrandchild1', 242 | type: 'suite', 243 | pending: false, 244 | suiteCount: 0, 245 | tags: undefined, 246 | requiredTags: undefined, 247 | testCount: 1, 248 | pendingTestCount: 0, 249 | suites: [], 250 | tests: [ 251 | { 252 | name: 'greatgrandchild1-test', 253 | fullName: 254 | 'foo child2 grandchild1 greatgrandchild1 greatgrandchild1-test', 255 | pending: false, 256 | tags: undefined, 257 | requiredTags: undefined, 258 | type: 'test', 259 | }, 260 | ], 261 | }, 262 | ], 263 | tests: [ 264 | { 265 | name: 'grandchild1-test', 266 | fullName: 'foo child2 grandchild1 grandchild1-test', 267 | pending: false, 268 | tags: undefined, 269 | requiredTags: undefined, 270 | type: 'test', 271 | }, 272 | ], 273 | }, 274 | ], 275 | tests: [ 276 | { 277 | name: 'baz', 278 | fullName: 'foo child2 baz', 279 | pending: false, 280 | tags: undefined, 281 | requiredTags: undefined, 282 | type: 'test', 283 | }, 284 | ], 285 | }, 286 | ], 287 | tests: [ 288 | { 289 | name: 'blipp', 290 | fullName: 'foo blipp', 291 | tags: undefined, 292 | requiredTags: undefined, 293 | type: 'test', 294 | pending: false, 295 | }, 296 | ], 297 | }, 298 | ]) 299 | }) 300 | 301 | test('counts pending tests', (t) => { 302 | t.plan(3) 303 | const source = stripIndent` 304 | describe('foo', () => { 305 | it.skip('bar', () => {}) 306 | }) 307 | ` 308 | const result = getTestNames(source, true) 309 | t.deepEqual(result.structure, [ 310 | { 311 | fullName: 'foo', 312 | name: 'foo', 313 | type: 'suite', 314 | pending: false, 315 | tags: undefined, 316 | requiredTags: undefined, 317 | suiteCount: 0, 318 | suites: [], 319 | testCount: 1, 320 | pendingTestCount: 1, 321 | tests: [ 322 | { 323 | fullName: 'foo bar', 324 | name: 'bar', 325 | type: 'test', 326 | pending: true, 327 | tags: undefined, 328 | requiredTags: undefined, 329 | }, 330 | ], 331 | }, 332 | ]) 333 | 334 | t.deepEqual(result.testCount, 1) 335 | t.deepEqual(result.pendingTestCount, 1) 336 | }) 337 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const babel = require('@babel/parser') 2 | const walk = require('acorn-walk') 3 | const debug = require('debug')('find-test-names') 4 | const { formatTestList } = require('./format-test-list') 5 | const { resolveImportsInAst } = require('./resolve-imports') 6 | const { relativePathResolver } = require('./relative-path-resolver') 7 | 8 | const isDescribeName = (name) => name === 'describe' || name === 'context' 9 | 10 | const isDescribe = (node) => 11 | node.type === 'CallExpression' && isDescribeName(node.callee.name) 12 | 13 | const isDescribeSkip = (node) => 14 | node.type === 'CallExpression' && 15 | node.callee.type === 'MemberExpression' && 16 | isDescribeName(node.callee.object.name) && 17 | node.callee.property.name === 'skip' 18 | 19 | const isIt = (node) => 20 | node.type === 'CallExpression' && 21 | (node.callee.name === 'it' || node.callee.name === 'specify') 22 | 23 | const isItSkip = (node) => 24 | node.type === 'CallExpression' && 25 | node.callee.type === 'MemberExpression' && 26 | (node.callee.object.name === 'it' || node.callee.object.name === 'specify') && 27 | node.callee.property.name === 'skip' 28 | 29 | const isItOnly = (node) => 30 | node.type === 'CallExpression' && 31 | node.callee.type === 'MemberExpression' && 32 | (node.callee.object.name === 'it' || node.callee.object.name === 'specify') && 33 | node.callee.property.name === 'only' 34 | 35 | // list of known static constant variable declarations (in the current file) 36 | const constants = new Map() 37 | 38 | const getResolvedTag = (node) => { 39 | if (node.type === 'Literal') { 40 | return node.value 41 | } else if (node.type === 'Identifier') { 42 | debug('tag is a potential local identifier "%s"', node.name) 43 | if (constants.has(node.name)) { 44 | const tagValue = constants.get(node.name) 45 | debug('found constant value "%s" for the tag "%s"', tagValue, node.name) 46 | return tagValue 47 | } 48 | } else if (node.type === 'MemberExpression') { 49 | const key = `${node.object.name}.${node.property.name}` 50 | if (constants.has(key)) { 51 | const tagValue = constants.get(key) 52 | debug('found constant value "%s" for the tag "%s"', tagValue, key) 53 | return tagValue 54 | } 55 | } 56 | } 57 | 58 | /** 59 | * Finds "tags" field in the test node. 60 | * Could be a single string or an array of strings. 61 | * 62 | * it('name', {tags: '@smoke'}, () => ...) 63 | */ 64 | const getTags = (source, node) => { 65 | if (node.arguments.length < 2) { 66 | // pending tests don't have tags 67 | return 68 | } 69 | 70 | if (node.arguments[1].type === 'ObjectExpression') { 71 | // extract any possible tags 72 | const tags = node.arguments[1].properties.find((node) => { 73 | return node.key?.name === 'tags' 74 | }) 75 | if (tags) { 76 | if (tags.value.type === 'ArrayExpression') { 77 | return tags.value.elements.map(getResolvedTag) 78 | } else if ( 79 | tags.value.type === 'Literal' || 80 | tags.value.type === 'Identifier' || 81 | tags.value.type === 'MemberExpression' 82 | ) { 83 | return [getResolvedTag(tags.value)] 84 | } 85 | } 86 | } 87 | } 88 | 89 | /** 90 | * Finds the "requiredTags" field in the test node. 91 | * Could be a single string or an array of strings. 92 | * 93 | * it('name', {requiredTags: '@smoke'}, () => ...) 94 | */ 95 | const getRequiredTags = (source, node) => { 96 | if (node.arguments.length < 2) { 97 | // pending tests don't have tags 98 | return 99 | } 100 | 101 | if (node.arguments[1].type === 'ObjectExpression') { 102 | // extract any possible tags 103 | const tags = node.arguments[1].properties.find((node) => { 104 | return node.key?.name === 'requiredTags' 105 | }) 106 | if (tags) { 107 | if (tags.value.type === 'ArrayExpression') { 108 | return tags.value.elements.map(getResolvedTag) 109 | } else if ( 110 | tags.value.type === 'Literal' || 111 | tags.value.type === 'Identifier' 112 | ) { 113 | return [getResolvedTag(tags.value)] 114 | } 115 | } 116 | } 117 | } 118 | 119 | // extracts the test name from the literal or template literal node 120 | // if the test name is a variable, returns undefined 121 | const extractTestName = (node) => { 122 | if (node.type === 'TemplateLiteral') { 123 | return node.quasis.map((q) => q.value.cooked.trim()).join(' ') 124 | } else if (node.type === 'Literal') { 125 | return node.value 126 | } else { 127 | debug('Not sure how to get the test name from this source node') 128 | debug(node) 129 | return undefined 130 | } 131 | } 132 | 133 | const plugins = [ 134 | 'jsx', 135 | 'estree', // To generate estree compatible AST 136 | 'typescript', 137 | ] 138 | 139 | function ignore(_node, _st, _c) {} 140 | 141 | const base = walk.make({}) 142 | 143 | /** 144 | * The proxy ignores all AST nodes for which acorn has no base visitor. 145 | * This includes TypeScript specific nodes like TSInterfaceDeclaration, 146 | * but also babel-specific nodes like ClassPrivateProperty. 147 | * 148 | * Since describe / it are CallExpressions, ignoring nodes should not affect 149 | * the test name extraction. 150 | */ 151 | const proxy = new Proxy(base, { 152 | get: function (target, prop) { 153 | if (target[prop]) { 154 | return Reflect.get(...arguments) 155 | } 156 | 157 | return ignore 158 | }, 159 | }) 160 | 161 | const getDescribe = (node, source, pending = false) => { 162 | const name = extractTestName(node.arguments[0]) 163 | const suiteInfo = { 164 | type: 'suite', 165 | pending, 166 | } 167 | if (typeof name !== 'undefined') { 168 | suiteInfo.name = name 169 | } 170 | 171 | if (pending) { 172 | suiteInfo.pending = true 173 | } 174 | 175 | if (!pending) { 176 | // the suite might be pending by the virtue of only having the name 177 | // example: describe("is pending") 178 | if (node.arguments.length === 1) { 179 | suiteInfo.pending = true 180 | } else if ( 181 | node.arguments.length === 2 && 182 | node.arguments[1].type === 'ObjectExpression' 183 | ) { 184 | // the suite has a name and a config object 185 | // but now callback, thus it is pending 186 | suiteInfo.pending = true 187 | } 188 | } 189 | 190 | const tags = getTags(source, node) 191 | if (Array.isArray(tags) && tags.length > 0) { 192 | suiteInfo.tags = tags 193 | } 194 | 195 | const requiredTags = getRequiredTags(source, node) 196 | if (Array.isArray(requiredTags) && requiredTags.length > 0) { 197 | suiteInfo.requiredTags = requiredTags 198 | } 199 | 200 | const suite = { 201 | name, 202 | tags: suiteInfo.tags, 203 | requiredTags: suiteInfo.requiredTags, 204 | pending: suiteInfo.pending, 205 | type: 'suite', 206 | tests: [], 207 | suites: [], 208 | testCount: 0, 209 | suiteCount: 0, 210 | } 211 | 212 | return { suiteInfo, suite } 213 | } 214 | 215 | const getIt = (node, source, pending = false) => { 216 | const name = extractTestName(node.arguments[0]) 217 | const testInfo = { 218 | type: 'test', 219 | pending, 220 | } 221 | if (typeof name !== 'undefined') { 222 | testInfo.name = name 223 | } 224 | 225 | if (!pending) { 226 | // the test might be pending by the virtue of only having the name 227 | // example: it("is pending") 228 | if (node.arguments.length === 1) { 229 | testInfo.pending = true 230 | } else if ( 231 | node.arguments.length === 2 && 232 | node.arguments[1].type === 'ObjectExpression' 233 | ) { 234 | // the test has a name and a config object 235 | // but now callback, thus it is pending 236 | testInfo.pending = true 237 | } 238 | } 239 | 240 | const tags = getTags(source, node) 241 | if (Array.isArray(tags) && tags.length > 0) { 242 | testInfo.tags = tags 243 | } 244 | const requiredTags = getRequiredTags(source, node) 245 | if (Array.isArray(requiredTags) && requiredTags.length > 0) { 246 | testInfo.requiredTags = requiredTags 247 | } 248 | 249 | const test = { 250 | name, 251 | tags: testInfo.tags, 252 | requiredTags: testInfo.requiredTags, 253 | pending: testInfo.pending, 254 | type: 'test', 255 | } 256 | 257 | return { testInfo, test } 258 | } 259 | 260 | /** 261 | * This function returns a tree structure which contains the test and all of its new suite parents. 262 | * 263 | * Loops over the ancestor nodes of a it / it.skip node 264 | * until it finds an already known suite node or the top of the tree. 265 | * 266 | * It uses a suite cache by node to make sure no tests / suites are added twice. 267 | * It still has to walk the whole tree for every test in order to aggregate the suite / test counts. 268 | * 269 | * Technical details: 270 | * acorn-walk does depth first traversal, 271 | * i.e. walk.ancestor is called with the deepest node first, usually an "it", 272 | * and a list of its ancestors. (other AST walkers traverse from the top) 273 | * 274 | * Since the tree generation starts from it nodes, this function cannot find 275 | * suites without tests. 276 | * This is handled by getOrphanSuiteAncestorsForSuite 277 | * 278 | */ 279 | const getSuiteAncestorsForTest = ( 280 | test, 281 | source, 282 | ancestors, 283 | nodes, 284 | fullSuiteNames, 285 | ) => { 286 | let knownNode = false 287 | let suiteBranches = [] 288 | let prevSuite 289 | let directParentSuite = null 290 | let suiteCount = 0 291 | 292 | for (var i = ancestors.length - 1; i >= 0; i--) { 293 | const node = ancestors[i] 294 | const describe = isDescribe(node) 295 | const skip = isDescribeSkip(node) 296 | 297 | if (describe || skip) { 298 | let suite 299 | 300 | knownNode = nodes.has(node.callee) 301 | 302 | if (knownNode) { 303 | suite = nodes.get(node.callee) 304 | } else { 305 | const result = getDescribe(node, source, skip) 306 | suite = result.suite 307 | nodes.set(node.callee, suite) 308 | } 309 | 310 | if (prevSuite) { 311 | suiteCount++ 312 | suite.suites.push(prevSuite) 313 | } 314 | 315 | if (!directParentSuite) { 316 | // found this test's direct parent suite 317 | directParentSuite = suite 318 | } 319 | 320 | suite.testCount++ 321 | suite.suiteCount += suiteCount 322 | 323 | prevSuite = knownNode ? null : suite 324 | suiteBranches.unshift(suite) 325 | } 326 | } 327 | 328 | // walked tree to the top 329 | if (suiteBranches.length) { 330 | // Compute the full names of suite and test, i.e. prepend all parent suite names 331 | const suiteNameWithParentSuiteNames = computeParentSuiteNames( 332 | suiteBranches, 333 | fullSuiteNames, 334 | ) 335 | 336 | test.fullName = `${suiteNameWithParentSuiteNames} ${test.name}` 337 | 338 | directParentSuite.tests.push(test) 339 | 340 | return { 341 | suite: !knownNode && prevSuite, // only return the suite if it hasn't been found before 342 | topLevelTest: false, 343 | } 344 | } else { 345 | // top level test 346 | test.fullName = test.name 347 | return { suite: null, topLevelTest: true } 348 | } 349 | } 350 | 351 | /** 352 | * This function is used to find (nested) empty describes. 353 | * 354 | * Loops over the ancestor nodes of a describe / describe.skip node 355 | * and return a tree of unknown suites. 356 | * 357 | * It uses the same nodes cache as getSuiteAncestorsForTest to make sure 358 | * no suites are added twice / no unnecessary nodes are walked. 359 | */ 360 | const getOrphanSuiteAncestorsForSuite = ( 361 | ancestors, 362 | source, 363 | nodes, 364 | fullSuiteNames, 365 | ) => { 366 | let prevSuite 367 | let suiteBranches = [] 368 | let knownNode = false 369 | let suiteCount = 0 370 | 371 | for (var i = ancestors.length - 1; i >= 0; i--) { 372 | // in the first iteration the ancestor is identical to the node 373 | const ancestor = ancestors[i] 374 | 375 | const describe = isDescribe(ancestor) 376 | const skip = isDescribeSkip(ancestor) 377 | 378 | if (describe || skip) { 379 | if (nodes.has(ancestor.callee)) { 380 | if (i === 0) { 381 | // If the deepest node in the tree is known, we don't need to walk up 382 | break 383 | } 384 | 385 | // Reached an already known suite 386 | knownNode = true 387 | const suite = nodes.get(ancestor.callee) 388 | 389 | if (prevSuite) { 390 | // Add new child suite to suite 391 | suite.suites.push(prevSuite) 392 | prevSuite = null 393 | } 394 | 395 | suite.suiteCount += suiteCount 396 | suiteBranches.unshift(suite) 397 | } else { 398 | const { suite } = getDescribe(ancestor, source, skip) 399 | 400 | if (prevSuite) { 401 | suite.suites.push(prevSuite) 402 | suite.suiteCount += suiteCount 403 | } 404 | 405 | suiteCount++ 406 | 407 | nodes.set(ancestor.callee, suite) 408 | prevSuite = knownNode ? null : suite 409 | suiteBranches.unshift(suite) 410 | } 411 | } 412 | } 413 | 414 | computeParentSuiteNames(suiteBranches, fullSuiteNames) 415 | 416 | if (!knownNode) { 417 | // walked tree to the top and found new suite(s) 418 | return prevSuite 419 | } 420 | 421 | return null 422 | } 423 | 424 | /** 425 | * Compute the full names of suites in an array of branches, i.e. prepend all parent suite names 426 | */ 427 | function computeParentSuiteNames(suiteBranches, fullSuiteNames) { 428 | let suiteNameWithParentSuiteNames = '' 429 | 430 | suiteBranches.forEach((suite) => { 431 | suite.fullName = `${suiteNameWithParentSuiteNames} ${suite.name}`.trim() 432 | fullSuiteNames.add(suite.fullName) 433 | 434 | suiteNameWithParentSuiteNames = suite.fullName 435 | }) 436 | 437 | return suiteNameWithParentSuiteNames 438 | } 439 | 440 | function countPendingTests(suite) { 441 | if (!suite.type === 'suite') { 442 | throw new Error('Expected suite') 443 | } 444 | 445 | const pendingTestsN = suite.tests.reduce((count, test) => { 446 | if (test.type === 'test' && test.pending) { 447 | return count + 1 448 | } 449 | return count 450 | }, 0) 451 | 452 | const pendingTestsInSuitesN = suite.suites.reduce((count, suite) => { 453 | const pending = countPendingTests(suite) 454 | suite.pendingTestCount = pending 455 | return count + pending 456 | }, 0) 457 | 458 | return pendingTestsN + pendingTestsInSuitesN 459 | } 460 | 461 | /** 462 | * Looks at the tests and counts how many tests in each suite 463 | * are pending. The parent suites use the sum of the inner 464 | * suite counts. 465 | * Warning: modifies the input structure 466 | */ 467 | function countTests(structure) { 468 | let testCount = 0 469 | let pendingTestCount = 0 470 | structure.forEach((t) => { 471 | if (t.type === 'suite') { 472 | testCount += t.testCount 473 | const pending = countPendingTests(t) 474 | if (typeof pending !== 'number') { 475 | console.error(t) 476 | throw new Error('Could not count pending tests') 477 | } 478 | t.pendingTestCount = pending 479 | pendingTestCount += pending 480 | } else { 481 | testCount += 1 482 | if (t.pending) { 483 | pendingTestCount += 1 484 | } 485 | } 486 | }) 487 | return { testCount, pendingTestCount } 488 | } 489 | 490 | function collectSuiteTagsUp(suite) { 491 | const tags = [] 492 | while (suite) { 493 | tags.push(...(suite.tags || [])) 494 | suite = suite.parent 495 | } 496 | return tags 497 | } 498 | 499 | function collectSuiteRequiredTagsUp(suite) { 500 | const tags = [] 501 | while (suite) { 502 | tags.push(...(suite.requiredTags || [])) 503 | suite = suite.parent 504 | } 505 | return tags 506 | } 507 | 508 | /** 509 | * Synchronous tree walker, calls the given callback for each test. 510 | * @param {object} structure 511 | * @param {function} fn Receives the test as argument 512 | */ 513 | function visitEachTest(structure, fn, parentSuite) { 514 | structure.forEach((t) => { 515 | if (t.type === 'suite') { 516 | visitEachTest(t.tests, fn, t) 517 | visitEachTest(t.suites, fn) 518 | } else { 519 | fn(t, parentSuite) 520 | } 521 | }) 522 | } 523 | 524 | function visitEachNode(structure, fn, parentSuite) { 525 | structure.forEach((t) => { 526 | fn(t, parentSuite) 527 | if (t.type === 'suite') { 528 | visitEachNode(t.tests, fn, t) 529 | visitEachNode(t.suites, fn, t) 530 | } 531 | }) 532 | } 533 | 534 | function concatTags(tags, requiredTags) { 535 | return [].concat(tags || []).concat(requiredTags || []) 536 | } 537 | 538 | /** 539 | * Counts the tags found on the tests. 540 | * @param {object} structure 541 | * @returns {object} with tags as keys and counts for each 542 | */ 543 | function countTags(structure) { 544 | setParentSuite(structure) 545 | 546 | const tags = {} 547 | visitEachTest(structure, (test, parentSuite) => { 548 | // normalize the tags to be an array of strings 549 | const list = concatTags(test.tags, test.requiredTags) 550 | list.forEach((tag) => { 551 | if (!(tag in tags)) { 552 | tags[tag] = 1 553 | } else { 554 | tags[tag] += 1 555 | } 556 | }) 557 | 558 | // also consider the effective tags by traveling up 559 | // the parent chain of suites 560 | const suiteTags = collectSuiteTagsUp(parentSuite) 561 | suiteTags.forEach((tag) => { 562 | if (!(tag in tags)) { 563 | tags[tag] = 1 564 | } else { 565 | tags[tag] += 1 566 | } 567 | }) 568 | 569 | // plus the required tag up the chain of parents 570 | const suiteRequiredTags = collectSuiteRequiredTagsUp(parentSuite) 571 | suiteRequiredTags.forEach((tag) => { 572 | if (!(tag in tags)) { 573 | tags[tag] = 1 574 | } else { 575 | tags[tag] += 1 576 | } 577 | }) 578 | }) 579 | 580 | return tags 581 | } 582 | 583 | function combineTags(tags, suiteTags) { 584 | // normalize the tags to be an array of strings 585 | const ownTags = [].concat(tags || []) 586 | const allTags = [...ownTags, ...suiteTags] 587 | const uniqueTags = [...new Set(allTags)] 588 | const sortedTags = [...new Set(uniqueTags)].sort() 589 | return sortedTags 590 | } 591 | 592 | /** 593 | * Visits each test and counts its tags and its parents' tags 594 | * to compute the "effective" tags list. 595 | */ 596 | function setEffectiveTags(structure) { 597 | setParentSuite(structure) 598 | 599 | visitEachTest(structure, (test, parentSuite) => { 600 | // also consider the effective tags by traveling up 601 | // the parent chain of suites 602 | const suiteTags = collectSuiteTagsUp(parentSuite) 603 | test.effectiveTags = combineTags(test.tags, suiteTags) 604 | 605 | // collect the required tags up the suite parents 606 | const suiteRequiredTags = collectSuiteRequiredTagsUp(parentSuite) 607 | test.requiredTags = combineTags(test.requiredTags, suiteRequiredTags) 608 | 609 | // note, the required tags are also EFFECTIVE tags, so combine them 610 | test.effectiveTags = [...test.effectiveTags, ...test.requiredTags].sort() 611 | }) 612 | 613 | return structure 614 | } 615 | 616 | /** 617 | * Visits each individual test in the structure and checks if it 618 | * has any effective tags from the given list. 619 | */ 620 | function filterByEffectiveTags(structure, tags, relativeFilename) { 621 | if (typeof structure === 'string') { 622 | // we got passed the input source code 623 | // so let's parse it first 624 | const result = getTestNames(structure, true, relativeFilename) 625 | setEffectiveTags(result.structure) 626 | return filterByEffectiveTags(result.structure, tags) 627 | } 628 | 629 | const filteredTests = [] 630 | visitEachTest(structure, (test) => { 631 | const hasTag = tags.some((tag) => test.effectiveTags.includes(tag)) 632 | if (hasTag) { 633 | filteredTests.push(test) 634 | } 635 | }) 636 | return filteredTests 637 | } 638 | 639 | function setParentSuite(structure) { 640 | visitEachNode(structure, (test, parentSuite) => { 641 | if (parentSuite) { 642 | test.parent = parentSuite 643 | } 644 | }) 645 | } 646 | 647 | function getLeadingComment(ancestors) { 648 | if (ancestors.length > 1) { 649 | const a = ancestors[ancestors.length - 2] 650 | if (a.leadingComments && a.leadingComments.length) { 651 | // grab the last comment line 652 | const firstComment = a.leadingComments[a.leadingComments.length - 1] 653 | if (firstComment.type === 'CommentLine') { 654 | const leadingComment = firstComment.value 655 | if (leadingComment.trim()) { 656 | return leadingComment.trim() 657 | } 658 | } 659 | } 660 | } 661 | } 662 | 663 | /** 664 | * Returns all suite and test names found in the given JavaScript 665 | * source code (Mocha / Cypress syntax) 666 | * @param {string} source 667 | * @param {boolean} withStructure - return nested structure of suites and tests 668 | */ 669 | function getTestNames(source, withStructure, currentFilename) { 670 | // should we pass the ecma version here? 671 | let AST 672 | try { 673 | debug('parsing source as a script') 674 | AST = babel.parse(source, { 675 | plugins, 676 | sourceType: 'script', 677 | }).program 678 | debug('success!') 679 | } catch (e) { 680 | debug('parsing source as a module') 681 | AST = babel.parse(source, { 682 | plugins, 683 | sourceType: 'module', 684 | }).program 685 | debug('success!') 686 | } 687 | 688 | const suiteNames = [] 689 | const testNames = [] 690 | // suite names with parent suite names prepended 691 | const fullSuiteNames = new Set() 692 | // test names with parent suite names prepended 693 | const fullTestNames = [] 694 | // mixed entries for describe and tests 695 | // each entry has name and possibly a list of tags 696 | const tests = [] 697 | 698 | // Map of known nodes keyed: callee => value: suite 699 | let nodes = new Map() 700 | 701 | // Tree of describes and tests 702 | let structure = [] 703 | 704 | debug('clearing local file constants') 705 | constants.clear() 706 | 707 | // first, see if we can resolve any imports 708 | // that resolve as constants 709 | const filePathProvider = relativePathResolver(currentFilename) 710 | debug('constructed file path provider wrt %s', currentFilename) 711 | const resolvedImports = resolveImportsInAst(AST, filePathProvider) 712 | debug('resolved imports %o', resolvedImports) 713 | 714 | walk.ancestor( 715 | AST, 716 | { 717 | VariableDeclaration(node) { 718 | if (node.kind === 'const') { 719 | // console.log(node.declarations) 720 | node.declarations 721 | .filter((decl) => decl.type === 'VariableDeclarator') 722 | .filter((decl) => decl.id.type === 'Identifier') 723 | .filter( 724 | (decl) => 725 | decl.init && 726 | (decl.init.type === 'Literal' || 727 | decl.init.type === 'ObjectExpression'), 728 | ) 729 | .forEach((decl) => { 730 | if (decl.init.type === 'ObjectExpression') { 731 | // console.log(decl.init.properties) 732 | decl.init.properties 733 | .filter( 734 | (prop) => 735 | prop.type === 'Property' && 736 | prop.key.type === 'Identifier' && 737 | prop.value.type === 'Literal', 738 | ) 739 | .forEach((prop) => { 740 | // object like "const foo = { bar: 'baz' }" 741 | // for each property, save the constant to "foo.bar" value 742 | const key = `${decl.id.name}.${prop.key.name}` 743 | const value = prop.value.value 744 | constants.set(key, value) 745 | debug(`found property constant ${key} = ${value}`) 746 | }) 747 | } else { 748 | // literal like "const foo = 'bar'" 749 | constants.set(decl.id.name, decl.init.value) 750 | debug(`found constant ${decl.id.name} = ${decl.init.value}`) 751 | } 752 | }) 753 | } 754 | }, 755 | CallExpression(node, ancestors) { 756 | if (isDescribe(node)) { 757 | const { suiteInfo } = getDescribe(node, source) 758 | 759 | debug('found describe "%s"', suiteInfo.name) 760 | 761 | const suite = getOrphanSuiteAncestorsForSuite( 762 | ancestors, 763 | source, 764 | nodes, 765 | fullSuiteNames, 766 | ) 767 | 768 | if (suite) { 769 | structure.push(suite) 770 | } 771 | 772 | suiteNames.push(suiteInfo.name) 773 | tests.push(suiteInfo) 774 | } else if (isDescribeSkip(node)) { 775 | const { suiteInfo } = getDescribe(node, source, true) 776 | 777 | debug('found describe.skip "%s"', suiteInfo.name) 778 | 779 | const suite = getOrphanSuiteAncestorsForSuite( 780 | ancestors, 781 | source, 782 | nodes, 783 | fullSuiteNames, 784 | ) 785 | 786 | if (suite) { 787 | structure.push(suite) 788 | } 789 | 790 | suiteNames.push(suiteInfo.name) 791 | tests.push(suiteInfo) 792 | } else if (isIt(node)) { 793 | const { testInfo, test } = getIt(node, source) 794 | 795 | debug('found test "%s"', testInfo.name) 796 | const comment = getLeadingComment(ancestors) 797 | if (comment) { 798 | testInfo.comment = comment 799 | debug('found leading test comment "%s", comment') 800 | } 801 | 802 | const { suite, topLevelTest } = getSuiteAncestorsForTest( 803 | test, 804 | source, 805 | ancestors, 806 | nodes, 807 | fullSuiteNames, 808 | ) 809 | 810 | if (suite) { 811 | structure.push(suite) 812 | } else if (topLevelTest) { 813 | structure.push(test) 814 | } 815 | 816 | if (typeof testInfo.name !== 'undefined') { 817 | testNames.push(testInfo.name) 818 | fullTestNames.push(test.fullName) 819 | } 820 | 821 | tests.push(testInfo) 822 | } else if (isItSkip(node)) { 823 | const { testInfo, test } = getIt(node, source, true) 824 | debug('found it.skip "%s"', testInfo.name) 825 | 826 | const comment = getLeadingComment(ancestors) 827 | if (comment) { 828 | testInfo.comment = comment 829 | debug('found leading skipped test comment "%s", comment') 830 | } 831 | 832 | const { suite, topLevelTest } = getSuiteAncestorsForTest( 833 | test, 834 | source, 835 | ancestors, 836 | nodes, 837 | fullSuiteNames, 838 | ) 839 | 840 | if (suite) { 841 | structure.push(suite) 842 | } else if (topLevelTest) { 843 | structure.push(test) 844 | } 845 | 846 | if (typeof testInfo.name !== 'undefined') { 847 | testNames.push(testInfo.name) 848 | fullTestNames.push(test.fullName) 849 | } 850 | 851 | tests.push(testInfo) 852 | } else if (isItOnly(node)) { 853 | const { testInfo, test } = getIt(node, source, false) 854 | testInfo.exclusive = true 855 | test.exclusive = true 856 | debug('found it.only "%s"', testInfo.name) 857 | 858 | const comment = getLeadingComment(ancestors) 859 | if (comment) { 860 | testInfo.comment = comment 861 | debug('found leading only test comment "%s", comment') 862 | } 863 | 864 | const { suite, topLevelTest } = getSuiteAncestorsForTest( 865 | test, 866 | source, 867 | ancestors, 868 | nodes, 869 | fullSuiteNames, 870 | ) 871 | 872 | if (suite) { 873 | structure.push(suite) 874 | } else if (topLevelTest) { 875 | structure.push(test) 876 | } 877 | 878 | if (typeof testInfo.name !== 'undefined') { 879 | testNames.push(testInfo.name) 880 | fullTestNames.push(test.fullName) 881 | } 882 | 883 | tests.push(testInfo) 884 | } 885 | }, 886 | }, 887 | proxy, 888 | ) 889 | 890 | const sortedSuiteNames = suiteNames.sort() 891 | const sortedTestNames = testNames.sort() 892 | const sortedFullTestNames = [...fullTestNames].sort() 893 | const sortedFullSuiteNames = [...fullSuiteNames].sort() 894 | const result = { 895 | suiteNames: sortedSuiteNames, 896 | testNames: sortedTestNames, 897 | tests, 898 | } 899 | 900 | if (withStructure) { 901 | const counts = countTests(structure) 902 | result.structure = structure 903 | result.testCount = counts.testCount 904 | result.pendingTestCount = counts.pendingTestCount 905 | result.fullTestNames = sortedFullTestNames 906 | result.fullSuiteNames = sortedFullSuiteNames 907 | } 908 | 909 | return result 910 | } 911 | 912 | /** Given the test source code, finds all tests 913 | * and returns a single object with all test titles. 914 | * Each key is the full test title. 915 | * The value is a list of effective tags for this test. 916 | */ 917 | function findEffectiveTestTags(source, currentFilename) { 918 | if (typeof source !== 'string') { 919 | throw new Error('Expected a string source') 920 | } 921 | 922 | const result = getTestNames(source, true, currentFilename) 923 | setEffectiveTags(result.structure) 924 | 925 | const testTags = {} 926 | visitEachTest(result.structure, (test, parentSuite) => { 927 | // console.log(test) 928 | if (typeof test.fullName !== 'string') { 929 | console.error(test) 930 | throw new Error('Cannot find the full name for test') 931 | } 932 | testTags[test.fullName] = { 933 | effectiveTags: test.effectiveTags, 934 | requiredTags: test.requiredTags, 935 | } 936 | }) 937 | 938 | // console.log(testTags) 939 | return testTags 940 | } 941 | 942 | /** 943 | * Reads the source code of the given spec file from disk 944 | * and finds all tests and their effective tags 945 | */ 946 | function findEffectiveTestTagsIn(specFilename) { 947 | const { readFileSync } = require('fs') 948 | const source = readFileSync(specFilename, 'utf8') 949 | return findEffectiveTestTags(source, specFilename) 950 | } 951 | 952 | module.exports = { 953 | getTestNames, 954 | formatTestList, 955 | countTests, 956 | visitEachTest, 957 | countTags, 958 | visitEachNode, 959 | setParentSuite, 960 | setEffectiveTags, 961 | filterByEffectiveTags, 962 | findEffectiveTestTags, 963 | findEffectiveTestTagsIn, 964 | } 965 | --------------------------------------------------------------------------------