├── .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 [](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 |
--------------------------------------------------------------------------------