UNPKG

find-test-names

Version:

Given a Mocha / Cypress spec file, returns the list of suite and test names

965 lines (840 loc) 26.6 kB
const babel = require('@babel/parser') const walk = require('acorn-walk') const debug = require('debug')('find-test-names') const { formatTestList } = require('./format-test-list') const { resolveImportsInAst } = require('./resolve-imports') const { relativePathResolver } = require('./relative-path-resolver') const isDescribeName = (name) => name === 'describe' || name === 'context' const isDescribe = (node) => node.type === 'CallExpression' && isDescribeName(node.callee.name) const isDescribeSkip = (node) => node.type === 'CallExpression' && node.callee.type === 'MemberExpression' && isDescribeName(node.callee.object.name) && node.callee.property.name === 'skip' const isIt = (node) => node.type === 'CallExpression' && (node.callee.name === 'it' || node.callee.name === 'specify') const isItSkip = (node) => node.type === 'CallExpression' && node.callee.type === 'MemberExpression' && (node.callee.object.name === 'it' || node.callee.object.name === 'specify') && node.callee.property.name === 'skip' const isItOnly = (node) => node.type === 'CallExpression' && node.callee.type === 'MemberExpression' && (node.callee.object.name === 'it' || node.callee.object.name === 'specify') && node.callee.property.name === 'only' // list of known static constant variable declarations (in the current file) const constants = new Map() const getResolvedTag = (node) => { if (node.type === 'Literal') { return node.value } else if (node.type === 'Identifier') { debug('tag is a potential local identifier "%s"', node.name) if (constants.has(node.name)) { const tagValue = constants.get(node.name) debug('found constant value "%s" for the tag "%s"', tagValue, node.name) return tagValue } } else if (node.type === 'MemberExpression') { const key = `${node.object.name}.${node.property.name}` if (constants.has(key)) { const tagValue = constants.get(key) debug('found constant value "%s" for the tag "%s"', tagValue, key) return tagValue } } } /** * Finds "tags" field in the test node. * Could be a single string or an array of strings. * * it('name', {tags: '@smoke'}, () => ...) */ const getTags = (source, node) => { if (node.arguments.length < 2) { // pending tests don't have tags return } if (node.arguments[1].type === 'ObjectExpression') { // extract any possible tags const tags = node.arguments[1].properties.find((node) => { return node.key?.name === 'tags' }) if (tags) { if (tags.value.type === 'ArrayExpression') { return tags.value.elements.map(getResolvedTag) } else if ( tags.value.type === 'Literal' || tags.value.type === 'Identifier' || tags.value.type === 'MemberExpression' ) { return [getResolvedTag(tags.value)] } } } } /** * Finds the "requiredTags" field in the test node. * Could be a single string or an array of strings. * * it('name', {requiredTags: '@smoke'}, () => ...) */ const getRequiredTags = (source, node) => { if (node.arguments.length < 2) { // pending tests don't have tags return } if (node.arguments[1].type === 'ObjectExpression') { // extract any possible tags const tags = node.arguments[1].properties.find((node) => { return node.key?.name === 'requiredTags' }) if (tags) { if (tags.value.type === 'ArrayExpression') { return tags.value.elements.map(getResolvedTag) } else if ( tags.value.type === 'Literal' || tags.value.type === 'Identifier' ) { return [getResolvedTag(tags.value)] } } } } // extracts the test name from the literal or template literal node // if the test name is a variable, returns undefined const extractTestName = (node) => { if (node.type === 'TemplateLiteral') { return node.quasis.map((q) => q.value.cooked.trim()).join(' ') } else if (node.type === 'Literal') { return node.value } else { debug('Not sure how to get the test name from this source node') debug(node) return undefined } } const plugins = [ 'jsx', 'estree', // To generate estree compatible AST 'typescript', ] function ignore(_node, _st, _c) {} const base = walk.make({}) /** * The proxy ignores all AST nodes for which acorn has no base visitor. * This includes TypeScript specific nodes like TSInterfaceDeclaration, * but also babel-specific nodes like ClassPrivateProperty. * * Since describe / it are CallExpressions, ignoring nodes should not affect * the test name extraction. */ const proxy = new Proxy(base, { get: function (target, prop) { if (target[prop]) { return Reflect.get(...arguments) } return ignore }, }) const getDescribe = (node, source, pending = false) => { const name = extractTestName(node.arguments[0]) const suiteInfo = { type: 'suite', pending, } if (typeof name !== 'undefined') { suiteInfo.name = name } if (pending) { suiteInfo.pending = true } if (!pending) { // the suite might be pending by the virtue of only having the name // example: describe("is pending") if (node.arguments.length === 1) { suiteInfo.pending = true } else if ( node.arguments.length === 2 && node.arguments[1].type === 'ObjectExpression' ) { // the suite has a name and a config object // but now callback, thus it is pending suiteInfo.pending = true } } const tags = getTags(source, node) if (Array.isArray(tags) && tags.length > 0) { suiteInfo.tags = tags } const requiredTags = getRequiredTags(source, node) if (Array.isArray(requiredTags) && requiredTags.length > 0) { suiteInfo.requiredTags = requiredTags } const suite = { name, tags: suiteInfo.tags, requiredTags: suiteInfo.requiredTags, pending: suiteInfo.pending, type: 'suite', tests: [], suites: [], testCount: 0, suiteCount: 0, } return { suiteInfo, suite } } const getIt = (node, source, pending = false) => { const name = extractTestName(node.arguments[0]) const testInfo = { type: 'test', pending, } if (typeof name !== 'undefined') { testInfo.name = name } if (!pending) { // the test might be pending by the virtue of only having the name // example: it("is pending") if (node.arguments.length === 1) { testInfo.pending = true } else if ( node.arguments.length === 2 && node.arguments[1].type === 'ObjectExpression' ) { // the test has a name and a config object // but now callback, thus it is pending testInfo.pending = true } } const tags = getTags(source, node) if (Array.isArray(tags) && tags.length > 0) { testInfo.tags = tags } const requiredTags = getRequiredTags(source, node) if (Array.isArray(requiredTags) && requiredTags.length > 0) { testInfo.requiredTags = requiredTags } const test = { name, tags: testInfo.tags, requiredTags: testInfo.requiredTags, pending: testInfo.pending, type: 'test', } return { testInfo, test } } /** * This function returns a tree structure which contains the test and all of its new suite parents. * * Loops over the ancestor nodes of a it / it.skip node * until it finds an already known suite node or the top of the tree. * * It uses a suite cache by node to make sure no tests / suites are added twice. * It still has to walk the whole tree for every test in order to aggregate the suite / test counts. * * Technical details: * acorn-walk does depth first traversal, * i.e. walk.ancestor is called with the deepest node first, usually an "it", * and a list of its ancestors. (other AST walkers traverse from the top) * * Since the tree generation starts from it nodes, this function cannot find * suites without tests. * This is handled by getOrphanSuiteAncestorsForSuite * */ const getSuiteAncestorsForTest = ( test, source, ancestors, nodes, fullSuiteNames, ) => { let knownNode = false let suiteBranches = [] let prevSuite let directParentSuite = null let suiteCount = 0 for (var i = ancestors.length - 1; i >= 0; i--) { const node = ancestors[i] const describe = isDescribe(node) const skip = isDescribeSkip(node) if (describe || skip) { let suite knownNode = nodes.has(node.callee) if (knownNode) { suite = nodes.get(node.callee) } else { const result = getDescribe(node, source, skip) suite = result.suite nodes.set(node.callee, suite) } if (prevSuite) { suiteCount++ suite.suites.push(prevSuite) } if (!directParentSuite) { // found this test's direct parent suite directParentSuite = suite } suite.testCount++ suite.suiteCount += suiteCount prevSuite = knownNode ? null : suite suiteBranches.unshift(suite) } } // walked tree to the top if (suiteBranches.length) { // Compute the full names of suite and test, i.e. prepend all parent suite names const suiteNameWithParentSuiteNames = computeParentSuiteNames( suiteBranches, fullSuiteNames, ) test.fullName = `${suiteNameWithParentSuiteNames} ${test.name}` directParentSuite.tests.push(test) return { suite: !knownNode && prevSuite, // only return the suite if it hasn't been found before topLevelTest: false, } } else { // top level test test.fullName = test.name return { suite: null, topLevelTest: true } } } /** * This function is used to find (nested) empty describes. * * Loops over the ancestor nodes of a describe / describe.skip node * and return a tree of unknown suites. * * It uses the same nodes cache as getSuiteAncestorsForTest to make sure * no suites are added twice / no unnecessary nodes are walked. */ const getOrphanSuiteAncestorsForSuite = ( ancestors, source, nodes, fullSuiteNames, ) => { let prevSuite let suiteBranches = [] let knownNode = false let suiteCount = 0 for (var i = ancestors.length - 1; i >= 0; i--) { // in the first iteration the ancestor is identical to the node const ancestor = ancestors[i] const describe = isDescribe(ancestor) const skip = isDescribeSkip(ancestor) if (describe || skip) { if (nodes.has(ancestor.callee)) { if (i === 0) { // If the deepest node in the tree is known, we don't need to walk up break } // Reached an already known suite knownNode = true const suite = nodes.get(ancestor.callee) if (prevSuite) { // Add new child suite to suite suite.suites.push(prevSuite) prevSuite = null } suite.suiteCount += suiteCount suiteBranches.unshift(suite) } else { const { suite } = getDescribe(ancestor, source, skip) if (prevSuite) { suite.suites.push(prevSuite) suite.suiteCount += suiteCount } suiteCount++ nodes.set(ancestor.callee, suite) prevSuite = knownNode ? null : suite suiteBranches.unshift(suite) } } } computeParentSuiteNames(suiteBranches, fullSuiteNames) if (!knownNode) { // walked tree to the top and found new suite(s) return prevSuite } return null } /** * Compute the full names of suites in an array of branches, i.e. prepend all parent suite names */ function computeParentSuiteNames(suiteBranches, fullSuiteNames) { let suiteNameWithParentSuiteNames = '' suiteBranches.forEach((suite) => { suite.fullName = `${suiteNameWithParentSuiteNames} ${suite.name}`.trim() fullSuiteNames.add(suite.fullName) suiteNameWithParentSuiteNames = suite.fullName }) return suiteNameWithParentSuiteNames } function countPendingTests(suite) { if (!suite.type === 'suite') { throw new Error('Expected suite') } const pendingTestsN = suite.tests.reduce((count, test) => { if (test.type === 'test' && test.pending) { return count + 1 } return count }, 0) const pendingTestsInSuitesN = suite.suites.reduce((count, suite) => { const pending = countPendingTests(suite) suite.pendingTestCount = pending return count + pending }, 0) return pendingTestsN + pendingTestsInSuitesN } /** * Looks at the tests and counts how many tests in each suite * are pending. The parent suites use the sum of the inner * suite counts. * Warning: modifies the input structure */ function countTests(structure) { let testCount = 0 let pendingTestCount = 0 structure.forEach((t) => { if (t.type === 'suite') { testCount += t.testCount const pending = countPendingTests(t) if (typeof pending !== 'number') { console.error(t) throw new Error('Could not count pending tests') } t.pendingTestCount = pending pendingTestCount += pending } else { testCount += 1 if (t.pending) { pendingTestCount += 1 } } }) return { testCount, pendingTestCount } } function collectSuiteTagsUp(suite) { const tags = [] while (suite) { tags.push(...(suite.tags || [])) suite = suite.parent } return tags } function collectSuiteRequiredTagsUp(suite) { const tags = [] while (suite) { tags.push(...(suite.requiredTags || [])) suite = suite.parent } return tags } /** * Synchronous tree walker, calls the given callback for each test. * @param {object} structure * @param {function} fn Receives the test as argument */ function visitEachTest(structure, fn, parentSuite) { structure.forEach((t) => { if (t.type === 'suite') { visitEachTest(t.tests, fn, t) visitEachTest(t.suites, fn) } else { fn(t, parentSuite) } }) } function visitEachNode(structure, fn, parentSuite) { structure.forEach((t) => { fn(t, parentSuite) if (t.type === 'suite') { visitEachNode(t.tests, fn, t) visitEachNode(t.suites, fn, t) } }) } function concatTags(tags, requiredTags) { return [].concat(tags || []).concat(requiredTags || []) } /** * Counts the tags found on the tests. * @param {object} structure * @returns {object} with tags as keys and counts for each */ function countTags(structure) { setParentSuite(structure) const tags = {} visitEachTest(structure, (test, parentSuite) => { // normalize the tags to be an array of strings const list = concatTags(test.tags, test.requiredTags) list.forEach((tag) => { if (!(tag in tags)) { tags[tag] = 1 } else { tags[tag] += 1 } }) // also consider the effective tags by traveling up // the parent chain of suites const suiteTags = collectSuiteTagsUp(parentSuite) suiteTags.forEach((tag) => { if (!(tag in tags)) { tags[tag] = 1 } else { tags[tag] += 1 } }) // plus the required tag up the chain of parents const suiteRequiredTags = collectSuiteRequiredTagsUp(parentSuite) suiteRequiredTags.forEach((tag) => { if (!(tag in tags)) { tags[tag] = 1 } else { tags[tag] += 1 } }) }) return tags } function combineTags(tags, suiteTags) { // normalize the tags to be an array of strings const ownTags = [].concat(tags || []) const allTags = [...ownTags, ...suiteTags] const uniqueTags = [...new Set(allTags)] const sortedTags = [...new Set(uniqueTags)].sort() return sortedTags } /** * Visits each test and counts its tags and its parents' tags * to compute the "effective" tags list. */ function setEffectiveTags(structure) { setParentSuite(structure) visitEachTest(structure, (test, parentSuite) => { // also consider the effective tags by traveling up // the parent chain of suites const suiteTags = collectSuiteTagsUp(parentSuite) test.effectiveTags = combineTags(test.tags, suiteTags) // collect the required tags up the suite parents const suiteRequiredTags = collectSuiteRequiredTagsUp(parentSuite) test.requiredTags = combineTags(test.requiredTags, suiteRequiredTags) // note, the required tags are also EFFECTIVE tags, so combine them test.effectiveTags = [...test.effectiveTags, ...test.requiredTags].sort() }) return structure } /** * Visits each individual test in the structure and checks if it * has any effective tags from the given list. */ function filterByEffectiveTags(structure, tags, relativeFilename) { if (typeof structure === 'string') { // we got passed the input source code // so let's parse it first const result = getTestNames(structure, true, relativeFilename) setEffectiveTags(result.structure) return filterByEffectiveTags(result.structure, tags) } const filteredTests = [] visitEachTest(structure, (test) => { const hasTag = tags.some((tag) => test.effectiveTags.includes(tag)) if (hasTag) { filteredTests.push(test) } }) return filteredTests } function setParentSuite(structure) { visitEachNode(structure, (test, parentSuite) => { if (parentSuite) { test.parent = parentSuite } }) } function getLeadingComment(ancestors) { if (ancestors.length > 1) { const a = ancestors[ancestors.length - 2] if (a.leadingComments && a.leadingComments.length) { // grab the last comment line const firstComment = a.leadingComments[a.leadingComments.length - 1] if (firstComment.type === 'CommentLine') { const leadingComment = firstComment.value if (leadingComment.trim()) { return leadingComment.trim() } } } } } /** * Returns all suite and test names found in the given JavaScript * source code (Mocha / Cypress syntax) * @param {string} source * @param {boolean} withStructure - return nested structure of suites and tests */ function getTestNames(source, withStructure, currentFilename) { // should we pass the ecma version here? let AST try { debug('parsing source as a script') AST = babel.parse(source, { plugins, sourceType: 'script', }).program debug('success!') } catch (e) { debug('parsing source as a module') AST = babel.parse(source, { plugins, sourceType: 'module', }).program debug('success!') } const suiteNames = [] const testNames = [] // suite names with parent suite names prepended const fullSuiteNames = new Set() // test names with parent suite names prepended const fullTestNames = [] // mixed entries for describe and tests // each entry has name and possibly a list of tags const tests = [] // Map of known nodes keyed: callee => value: suite let nodes = new Map() // Tree of describes and tests let structure = [] debug('clearing local file constants') constants.clear() // first, see if we can resolve any imports // that resolve as constants const filePathProvider = relativePathResolver(currentFilename) debug('constructed file path provider wrt %s', currentFilename) const resolvedImports = resolveImportsInAst(AST, filePathProvider) debug('resolved imports %o', resolvedImports) walk.ancestor( AST, { VariableDeclaration(node) { if (node.kind === 'const') { // console.log(node.declarations) node.declarations .filter((decl) => decl.type === 'VariableDeclarator') .filter((decl) => decl.id.type === 'Identifier') .filter( (decl) => decl.init && (decl.init.type === 'Literal' || decl.init.type === 'ObjectExpression'), ) .forEach((decl) => { if (decl.init.type === 'ObjectExpression') { // console.log(decl.init.properties) decl.init.properties .filter( (prop) => prop.type === 'Property' && prop.key.type === 'Identifier' && prop.value.type === 'Literal', ) .forEach((prop) => { // object like "const foo = { bar: 'baz' }" // for each property, save the constant to "foo.bar" value const key = `${decl.id.name}.${prop.key.name}` const value = prop.value.value constants.set(key, value) debug(`found property constant ${key} = ${value}`) }) } else { // literal like "const foo = 'bar'" constants.set(decl.id.name, decl.init.value) debug(`found constant ${decl.id.name} = ${decl.init.value}`) } }) } }, CallExpression(node, ancestors) { if (isDescribe(node)) { const { suiteInfo } = getDescribe(node, source) debug('found describe "%s"', suiteInfo.name) const suite = getOrphanSuiteAncestorsForSuite( ancestors, source, nodes, fullSuiteNames, ) if (suite) { structure.push(suite) } suiteNames.push(suiteInfo.name) tests.push(suiteInfo) } else if (isDescribeSkip(node)) { const { suiteInfo } = getDescribe(node, source, true) debug('found describe.skip "%s"', suiteInfo.name) const suite = getOrphanSuiteAncestorsForSuite( ancestors, source, nodes, fullSuiteNames, ) if (suite) { structure.push(suite) } suiteNames.push(suiteInfo.name) tests.push(suiteInfo) } else if (isIt(node)) { const { testInfo, test } = getIt(node, source) debug('found test "%s"', testInfo.name) const comment = getLeadingComment(ancestors) if (comment) { testInfo.comment = comment debug('found leading test comment "%s", comment') } const { suite, topLevelTest } = getSuiteAncestorsForTest( test, source, ancestors, nodes, fullSuiteNames, ) if (suite) { structure.push(suite) } else if (topLevelTest) { structure.push(test) } if (typeof testInfo.name !== 'undefined') { testNames.push(testInfo.name) fullTestNames.push(test.fullName) } tests.push(testInfo) } else if (isItSkip(node)) { const { testInfo, test } = getIt(node, source, true) debug('found it.skip "%s"', testInfo.name) const comment = getLeadingComment(ancestors) if (comment) { testInfo.comment = comment debug('found leading skipped test comment "%s", comment') } const { suite, topLevelTest } = getSuiteAncestorsForTest( test, source, ancestors, nodes, fullSuiteNames, ) if (suite) { structure.push(suite) } else if (topLevelTest) { structure.push(test) } if (typeof testInfo.name !== 'undefined') { testNames.push(testInfo.name) fullTestNames.push(test.fullName) } tests.push(testInfo) } else if (isItOnly(node)) { const { testInfo, test } = getIt(node, source, false) testInfo.exclusive = true test.exclusive = true debug('found it.only "%s"', testInfo.name) const comment = getLeadingComment(ancestors) if (comment) { testInfo.comment = comment debug('found leading only test comment "%s", comment') } const { suite, topLevelTest } = getSuiteAncestorsForTest( test, source, ancestors, nodes, fullSuiteNames, ) if (suite) { structure.push(suite) } else if (topLevelTest) { structure.push(test) } if (typeof testInfo.name !== 'undefined') { testNames.push(testInfo.name) fullTestNames.push(test.fullName) } tests.push(testInfo) } }, }, proxy, ) const sortedSuiteNames = suiteNames.sort() const sortedTestNames = testNames.sort() const sortedFullTestNames = [...fullTestNames].sort() const sortedFullSuiteNames = [...fullSuiteNames].sort() const result = { suiteNames: sortedSuiteNames, testNames: sortedTestNames, tests, } if (withStructure) { const counts = countTests(structure) result.structure = structure result.testCount = counts.testCount result.pendingTestCount = counts.pendingTestCount result.fullTestNames = sortedFullTestNames result.fullSuiteNames = sortedFullSuiteNames } return result } /** Given the test source code, finds all tests * and returns a single object with all test titles. * Each key is the full test title. * The value is a list of effective tags for this test. */ function findEffectiveTestTags(source, currentFilename) { if (typeof source !== 'string') { throw new Error('Expected a string source') } const result = getTestNames(source, true, currentFilename) setEffectiveTags(result.structure) const testTags = {} visitEachTest(result.structure, (test, parentSuite) => { // console.log(test) if (typeof test.fullName !== 'string') { console.error(test) throw new Error('Cannot find the full name for test') } testTags[test.fullName] = { effectiveTags: test.effectiveTags, requiredTags: test.requiredTags, } }) // console.log(testTags) return testTags } /** * Reads the source code of the given spec file from disk * and finds all tests and their effective tags */ function findEffectiveTestTagsIn(specFilename) { const { readFileSync } = require('fs') const source = readFileSync(specFilename, 'utf8') return findEffectiveTestTags(source, specFilename) } module.exports = { getTestNames, formatTestList, countTests, visitEachTest, countTags, visitEachNode, setParentSuite, setEffectiveTags, filterByEffectiveTags, findEffectiveTestTags, findEffectiveTestTagsIn, }