diff --git a/README.md b/README.md index 28a56c2..76265f8 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ it('works', { tags: '@user' }) But sometimes you want to use variables to set them. To be able to statically analyze the source files, this package currently supports: -- local constants +#### local constants ```js const USER = '@user' @@ -138,7 +138,7 @@ it('works', { tags: USER }) it('works', { tags: ['@sanity', USER] }) ``` -- local objects with literal property access +#### Local objects with literal property access ```js const TAGS = { @@ -150,6 +150,96 @@ it('works', { tags: TAGS.user }) it('works', { tags: ['@sanity', TAGS.user] }) ``` +#### Imported values (named imports) + +```js +// tags.js +export const userTag = '@user' +export const adminTag = '@admin' + +// spec.cy.js +import { userTag, adminTag } from './tags' + +it('works for user', { tags: userTag }) +it('works for admin', { tags: adminTag }) +``` + +#### Imported values (default imports) + +```js +// tag.js +export default '@smoke' + +// spec.cy.js +import smokeTag from './tag' + +it('works', { tags: smokeTag }) +``` + +Default object imports are also supported: + +```js +// roles.js +export default { + user: '@user', + admin: '@admin' +} + +// spec.cy.js +import roles from './roles' + +it('works for user', { tags: roles.user }) +it('works for admin', { tags: roles.admin }) +``` + +#### Imported values (namespace imports) + +```js +// tags.js +export const smoke = '@smoke' +export const regression = '@regression' + +// spec.cy.js +import * as Tags from './tags' + +it('smoke test', { tags: Tags.smoke }) +it('regression test', { tags: Tags.regression }) +``` + +#### TypeScript enums + +```js +// tags.ts +export enum TestTags { + smoke = '@smoke', + regression = '@regression' +} + +// spec.cy.js +import { TestTags } from './tags' + +it('smoke test', { tags: TestTags.smoke }) +it('regression test', { tags: TestTags.regression }) +``` + +Namespace imports with enums are also supported: + +```js +import * as TagsModule from './tags' + +it('test', { tags: TagsModule.TestTags.smoke }) +``` + +#### File extension resolution + +Import paths automatically resolve `.js` and `.ts` extensions: + +```js +// Both work the same way +import { tag } from './tags' // resolves to ./tags.js or ./tags.ts +import { tag } from './tags.js' // explicit extension +``` + ### Bin This package includes [bin/find-test-names.js](./bin/find-test-names.js) that you can use from the command line diff --git a/src/index.js b/src/index.js index 6181114..8dd1507 100644 --- a/src/index.js +++ b/src/index.js @@ -34,6 +34,7 @@ const isItOnly = (node) => // list of known static constant variable declarations (in the current file) const constants = new Map() +let resolvedImports = {} const getResolvedTag = (node) => { if (node.type === 'Literal') { @@ -44,14 +45,51 @@ const getResolvedTag = (node) => { const tagValue = constants.get(node.name) debug('found constant value "%s" for the tag "%s"', tagValue, node.name) return tagValue + } else if (resolvedImports[node.name]) { + const tagValue = resolvedImports[node.name] + debug('found imported value "%s" for the tag "%s"', tagValue, node.name) + return tagValue } } else if (node.type === 'MemberExpression') { - const key = `${node.object.name}.${node.property.name}` + // Handle nested member expressions like TagsModule.TestTags.smoke + // We need to traverse from the root and navigate through the object + let current = resolvedImports + const parts = [] + + // Build the path by traversing the member expression tree + const buildPath = (expr) => { + if (expr.type === 'Identifier') { + parts.unshift(expr.name) + } else if (expr.type === 'MemberExpression') { + parts.unshift(expr.property.name) + buildPath(expr.object) + } + } + + buildPath(node) + const key = parts.join('.') + + // First check if it's a constant if (constants.has(key)) { const tagValue = constants.get(key) debug('found constant value "%s" for the tag "%s"', tagValue, key) return tagValue } + + // Then navigate through the resolved imports + for (const part of parts) { + if (current && typeof current === 'object' && part in current) { + current = current[part] + } else { + current = undefined + break + } + } + + if (current !== undefined && typeof current !== 'object') { + debug('found imported value "%s" for the tag "%s"', current, key) + return current + } } } @@ -701,14 +739,15 @@ function getTestNames(source, withStructure, currentFilename) { // Tree of describes and tests let structure = [] - debug('clearing local file constants') + debug('clearing local file constants and resolved imports') constants.clear() + resolvedImports = {} // 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) + resolvedImports = resolveImportsInAst(AST, filePathProvider) debug('resolved imports %o', resolvedImports) walk.ancestor( diff --git a/src/relative-path-resolver.js b/src/relative-path-resolver.js index 087ba5f..1155dcf 100644 --- a/src/relative-path-resolver.js +++ b/src/relative-path-resolver.js @@ -17,13 +17,23 @@ function relativePathResolver(currentFilename) { resolved, ) - const exists = fs.existsSync(resolved) - if (!exists) { - debug('"%s" does not exist', resolved) - return + // Try the original path first + if (fs.existsSync(resolved)) { + return fs.readFileSync(resolved, 'utf-8') + } + + // Try with .ts and .js extensions + const extensions = ['.js', '.ts'] + for (const ext of extensions) { + const resolvedWithExt = resolved + ext + if (fs.existsSync(resolvedWithExt)) { + debug('found "%s" with extension "%s"', resolved, ext) + return fs.readFileSync(resolvedWithExt, 'utf-8') + } } - return fs.readFileSync(resolved, 'utf-8') + debug('"%s[.js|.ts]" does not exist', resolved) + return } } diff --git a/src/resolve-exports.js b/src/resolve-exports.js index 7506888..9dfd6ec 100644 --- a/src/resolve-exports.js +++ b/src/resolve-exports.js @@ -57,6 +57,39 @@ function resolveExportsInAst(AST, proxy) { } } }) + } else if (node.declaration.type === 'TSEnumDeclaration') { + const enumName = node.declaration.id.name + const enumObj = {} + + node.declaration.members.forEach((member) => { + const key = member.id.name + // Only support string enums, as tags are strings + if (member.initializer && member.initializer.type === 'Literal' && typeof member.initializer.value === 'string') { + enumObj[key] = member.initializer.value + } + }) + + exportedValues[enumName] = enumObj + } + }, + ExportDefaultDeclaration(node) { + if (node.declaration.type === 'Literal') { + exportedValues.default = node.declaration.value + } else if (node.declaration.type === 'ObjectExpression') { + const obj = {} + node.declaration.properties.forEach((prop) => { + const value = prop.value + if (value.type === 'Literal') { + obj[prop.key.name] = value.value + } + }) + + exportedValues.default = obj + } else if (node.declaration.type === 'Identifier') { + // export default someVariable + // We would need to resolve the variable, which might be complex + // For now, we just mark that a default export exists + exportedValues.default = node.declaration.name } }, }, diff --git a/src/resolve-imports.js b/src/resolve-imports.js index d50da97..978e570 100644 --- a/src/resolve-imports.js +++ b/src/resolve-imports.js @@ -59,14 +59,31 @@ function resolveImportsInAst(AST, fileProvider) { } node.specifiers.forEach((specifier) => { - const importedName = specifier.imported.name - const localName = specifier.local.name - debug('importing "%s" as "%s"', importedName, localName) - if (!exportedValues[importedName]) { - debug('could not find export "%s" in "%s"', importedName, fromWhere) - return + if (specifier.type === 'ImportDefaultSpecifier') { + // Handle default imports: import foo from 'module' + const localName = specifier.local.name + debug('importing default as "%s"', localName) + if (exportedValues.default) { + importedValues[localName] = exportedValues.default + } else { + debug('could not find default export in "%s"', fromWhere) + } + } else if (specifier.type === 'ImportNamespaceSpecifier') { + // Handle namespace imports: import * as foo from 'module' + const localName = specifier.local.name + debug('importing namespace as "%s"', localName) + importedValues[localName] = exportedValues + } else if (specifier.type === 'ImportSpecifier') { + // Handle named imports: import { foo } from 'module' + const importedName = specifier.imported.name + const localName = specifier.local.name + debug('importing "%s" as "%s"', importedName, localName) + if (!exportedValues[importedName]) { + debug('could not find export "%s" in "%s"', importedName, fromWhere) + return + } + importedValues[localName] = exportedValues[importedName] } - importedValues[localName] = exportedValues[importedName] }) }, }, diff --git a/test/fixture-default/roles.js b/test/fixture-default/roles.js new file mode 100644 index 0000000..621b86a --- /dev/null +++ b/test/fixture-default/roles.js @@ -0,0 +1,5 @@ +export default { + admin: '@admin', + user: '@user-obj', + guest: '@guest' +} diff --git a/test/fixture-default/spec-object.cy.js b/test/fixture-default/spec-object.cy.js new file mode 100644 index 0000000..39e0ee8 --- /dev/null +++ b/test/fixture-default/spec-object.cy.js @@ -0,0 +1,7 @@ +import roles from './roles' + +it('default object test 1', { tags: roles.admin }) + +it('default object test 2', { tags: roles.user }) + +it('default object test 3', { tags: roles.guest }) diff --git a/test/fixture-default/spec.cy.js b/test/fixture-default/spec.cy.js new file mode 100644 index 0000000..91ff56d --- /dev/null +++ b/test/fixture-default/spec.cy.js @@ -0,0 +1,3 @@ +import defaultTag from './tag' + +it('default import test', { tags: defaultTag }) diff --git a/test/fixture-default/tag.js b/test/fixture-default/tag.js new file mode 100644 index 0000000..61c8d20 --- /dev/null +++ b/test/fixture-default/tag.js @@ -0,0 +1 @@ +export default '@default-tag' diff --git a/test/fixture-enum/spec.cy.js b/test/fixture-enum/spec.cy.js new file mode 100644 index 0000000..5c5212e --- /dev/null +++ b/test/fixture-enum/spec.cy.js @@ -0,0 +1,7 @@ +import { TestTags } from './tags' + +it('enum test 1', { tags: TestTags.smoke }) + +it('enum test 2', { tags: TestTags.regression }) + +it('enum test 3', { tags: TestTags.sanity }) diff --git a/test/fixture-enum/tags.ts b/test/fixture-enum/tags.ts new file mode 100644 index 0000000..f936a2b --- /dev/null +++ b/test/fixture-enum/tags.ts @@ -0,0 +1,5 @@ +export enum TestTags { + smoke = '@smoke-enum', + regression = '@regression-enum', + sanity = '@sanity-enum' +} diff --git a/test/fixture-extensions/config.ts b/test/fixture-extensions/config.ts new file mode 100644 index 0000000..a783b36 --- /dev/null +++ b/test/fixture-extensions/config.ts @@ -0,0 +1,2 @@ +export const envTag = '@production' +export const debugTag = '@debug' diff --git a/test/fixture-extensions/spec2.cy.js b/test/fixture-extensions/spec2.cy.js new file mode 100644 index 0000000..d2b9ff5 --- /dev/null +++ b/test/fixture-extensions/spec2.cy.js @@ -0,0 +1,5 @@ +import { smokeTag, regressionTag } from './tags' + +it('test 1', { tags: smokeTag }) + +it('test 2', { tags: regressionTag }) diff --git a/test/fixture-extensions/spec3.cy.js b/test/fixture-extensions/spec3.cy.js new file mode 100644 index 0000000..89c163b --- /dev/null +++ b/test/fixture-extensions/spec3.cy.js @@ -0,0 +1,5 @@ +import { envTag, debugTag } from './config' + +it('test with ts import 1', { tags: envTag }) + +it('test with ts import 2', { tags: debugTag }) diff --git a/test/fixture-extensions/tags.js b/test/fixture-extensions/tags.js new file mode 100644 index 0000000..9497aa7 --- /dev/null +++ b/test/fixture-extensions/tags.js @@ -0,0 +1,2 @@ +export const smokeTag = '@smoke' +export const regressionTag = '@regression' diff --git a/test/fixture-mixed/exports.ts b/test/fixture-mixed/exports.ts new file mode 100644 index 0000000..8668e6a --- /dev/null +++ b/test/fixture-mixed/exports.ts @@ -0,0 +1,2 @@ +export const namedTag = '@named' +export default '@default-mixed' diff --git a/test/fixture-mixed/spec.cy.js b/test/fixture-mixed/spec.cy.js new file mode 100644 index 0000000..e400840 --- /dev/null +++ b/test/fixture-mixed/spec.cy.js @@ -0,0 +1,5 @@ +import defaultMixed, { namedTag } from './exports' + +it('mixed default test', { tags: defaultMixed }) + +it('mixed named test', { tags: namedTag }) diff --git a/test/fixture-namespace/enum-tags.ts b/test/fixture-namespace/enum-tags.ts new file mode 100644 index 0000000..d8c49f9 --- /dev/null +++ b/test/fixture-namespace/enum-tags.ts @@ -0,0 +1,4 @@ +export enum TestTags { + smoke = '@smoke-ns-enum', + regression = '@regression-ns-enum' +} diff --git a/test/fixture-namespace/spec-enum.cy.js b/test/fixture-namespace/spec-enum.cy.js new file mode 100644 index 0000000..3dd3bb1 --- /dev/null +++ b/test/fixture-namespace/spec-enum.cy.js @@ -0,0 +1,5 @@ +import * as TagsModule from './enum-tags' + +it('namespace enum test 1', { tags: TagsModule.TestTags.smoke }) + +it('namespace enum test 2', { tags: TagsModule.TestTags.regression }) diff --git a/test/fixture-namespace/spec.cy.js b/test/fixture-namespace/spec.cy.js new file mode 100644 index 0000000..d7da54b --- /dev/null +++ b/test/fixture-namespace/spec.cy.js @@ -0,0 +1,7 @@ +import * as allTags from './tags' + +it('namespace test 1', { tags: allTags.smokeTag }) + +it('namespace test 2', { tags: allTags.regressionTag }) + +it('namespace test 3', { tags: allTags.userTag }) diff --git a/test/fixture-namespace/tags.js b/test/fixture-namespace/tags.js new file mode 100644 index 0000000..a60330c --- /dev/null +++ b/test/fixture-namespace/tags.js @@ -0,0 +1,3 @@ +export const smokeTag = '@smoke' +export const regressionTag = '@regression' +export const userTag = '@user' diff --git a/test/fixture1/spec1.cy.js b/test/fixture1/spec1.cy.js index ea91618..9d66f50 100644 --- a/test/fixture1/spec1.cy.js +++ b/test/fixture1/spec1.cy.js @@ -1,5 +1,5 @@ import { userTag } from './tags.js' -it('works 1') +it('works 1', { tags: userTag }) -it('works 2') +it('works 2', { tags: userTag }) diff --git a/test/resolve-exports.js b/test/resolve-exports.js index e2d3e7f..e0cb262 100644 --- a/test/resolve-exports.js +++ b/test/resolve-exports.js @@ -23,3 +23,56 @@ test('finds the named exported object', (t) => { const result = resolveExports(source) t.deepEqual(result, { TAGS: { foo: 'foo', bar: 'bar' } }) }) + +test('finds the default export with a literal', (t) => { + t.plan(1) + const source = stripIndent` + export default 'foo' + ` + const result = resolveExports(source) + t.deepEqual(result, { default: 'foo' }) +}) + +test('finds the default export with an object', (t) => { + t.plan(1) + const source = stripIndent` + export default { + foo: 'foo', + bar: 'bar' + } + ` + const result = resolveExports(source) + t.deepEqual(result, { default: { foo: 'foo', bar: 'bar' } }) +}) + +test('finds the default export with an identifier', (t) => { + t.plan(1) + const source = stripIndent` + const myValue = 'foo' + export default myValue + ` + const result = resolveExports(source) + t.deepEqual(result, { default: 'myValue' }) +}) + +test('finds both named and default exports', (t) => { + t.plan(1) + const source = stripIndent` + export const named = 'namedValue' + export default 'defaultValue' + ` + const result = resolveExports(source) + t.deepEqual(result, { named: 'namedValue', default: 'defaultValue' }) +}) + +test('finds the exported TypeScript enum', (t) => { + t.plan(1) + const source = stripIndent` + export enum Tags { + smoke = '@smoke', + regression = '@regression' + } + ` + const result = resolveExports(source) + t.deepEqual(result, { Tags: { smoke: '@smoke', regression: '@regression' } }) +}) diff --git a/test/resolve-imports.js b/test/resolve-imports.js index c78cc7b..1892d07 100644 --- a/test/resolve-imports.js +++ b/test/resolve-imports.js @@ -18,6 +18,28 @@ const fileProvider = (relativePath) => { } ` } + + if (relativePath === './file-c') { + return stripIndent` + export default '@smoke' + ` + } + + if (relativePath === './file-d') { + return stripIndent` + export default { + env: 'production', + debug: 'false' + } + ` + } + + if (relativePath === './file-e') { + return stripIndent` + export const named = 'namedValue' + export default 'defaultValue' + ` + } } test('finds the imports', (t) => { @@ -69,3 +91,53 @@ test('finds the exported object', (t) => { const result = resolveImports(source, fileProvider) t.deepEqual(result, { TAGS: { user: '@user', sanity: '@sanity' } }) }) + +test('imports default export with literal', (t) => { + t.plan(1) + const source = stripIndent` + import defaultTag from './file-c' + ` + + const result = resolveImports(source, fileProvider) + t.deepEqual(result, { defaultTag: '@smoke' }) +}) + +test('imports default export with object', (t) => { + t.plan(1) + const source = stripIndent` + import config from './file-d' + ` + + const result = resolveImports(source, fileProvider) + t.deepEqual(result, { config: { env: 'production', debug: 'false' } }) +}) + +test('imports both named and default exports', (t) => { + t.plan(1) + const source = stripIndent` + import defaultValue, { named } from './file-e' + ` + + const result = resolveImports(source, fileProvider) + t.deepEqual(result, { defaultValue: 'defaultValue', named: 'namedValue' }) +}) + +test('imports namespace', (t) => { + t.plan(1) + const source = stripIndent` + import * as allExports from './file-a' + ` + + const result = resolveImports(source, fileProvider) + t.deepEqual(result, { allExports: { foo: 'foo', bar: 'bar' } }) +}) + +test('imports namespace with default export', (t) => { + t.plan(1) + const source = stripIndent` + import * as allExports from './file-e' + ` + + const result = resolveImports(source, fileProvider) + t.deepEqual(result, { allExports: { named: 'namedValue', default: 'defaultValue' } }) +}) diff --git a/test/tags-imported-from-another-file.js b/test/tags-imported-from-another-file.js index 7bdd53a..3c3e1b4 100644 --- a/test/tags-imported-from-another-file.js +++ b/test/tags-imported-from-another-file.js @@ -3,9 +3,93 @@ const test = require('ava') const path = require('path') test('individual tags imported from another file', (t) => { - t.plan(0) + t.plan(2) const fullName = path.join(__dirname, './fixture1/spec1.cy.js') const result = findEffectiveTestTagsIn(fullName) - console.log(result) + + t.deepEqual(result['works 1'].effectiveTags, ['@user']) + t.deepEqual(result['works 2'].effectiveTags, ['@user']) +}) + +test('imports without file extension (.js)', (t) => { + t.plan(2) + + const fullName = path.join(__dirname, './fixture-extensions/spec2.cy.js') + const result = findEffectiveTestTagsIn(fullName) + + t.deepEqual(result['test 1'].effectiveTags, ['@smoke']) + t.deepEqual(result['test 2'].effectiveTags, ['@regression']) +}) + +test('imports without file extension (.ts)', (t) => { + t.plan(2) + + const fullName = path.join(__dirname, './fixture-extensions/spec3.cy.js') + const result = findEffectiveTestTagsIn(fullName) + + t.deepEqual(result['test with ts import 1'].effectiveTags, ['@production']) + t.deepEqual(result['test with ts import 2'].effectiveTags, ['@debug']) +}) + +test('imports from TypeScript enum', (t) => { + t.plan(3) + + const fullName = path.join(__dirname, './fixture-enum/spec.cy.js') + const result = findEffectiveTestTagsIn(fullName) + + t.deepEqual(result['enum test 1'].effectiveTags, ['@smoke-enum']) + t.deepEqual(result['enum test 2'].effectiveTags, ['@regression-enum']) + t.deepEqual(result['enum test 3'].effectiveTags, ['@sanity-enum']) +}) + +test('default import with string', (t) => { + t.plan(1) + + const fullName = path.join(__dirname, './fixture-default/spec.cy.js') + const result = findEffectiveTestTagsIn(fullName) + + t.deepEqual(result['default import test'].effectiveTags, ['@default-tag']) +}) + +test('default import with object', (t) => { + t.plan(3) + + const fullName = path.join(__dirname, './fixture-default/spec-object.cy.js') + const result = findEffectiveTestTagsIn(fullName) + + t.deepEqual(result['default object test 1'].effectiveTags, ['@admin']) + t.deepEqual(result['default object test 2'].effectiveTags, ['@user-obj']) + t.deepEqual(result['default object test 3'].effectiveTags, ['@guest']) +}) + +test('namespace import', (t) => { + t.plan(3) + + const fullName = path.join(__dirname, './fixture-namespace/spec.cy.js') + const result = findEffectiveTestTagsIn(fullName) + + t.deepEqual(result['namespace test 1'].effectiveTags, ['@smoke']) + t.deepEqual(result['namespace test 2'].effectiveTags, ['@regression']) + t.deepEqual(result['namespace test 3'].effectiveTags, ['@user']) +}) + +test('namespace import with enum', (t) => { + t.plan(2) + + const fullName = path.join(__dirname, './fixture-namespace/spec-enum.cy.js') + const result = findEffectiveTestTagsIn(fullName) + + t.deepEqual(result['namespace enum test 1'].effectiveTags, ['@smoke-ns-enum']) + t.deepEqual(result['namespace enum test 2'].effectiveTags, ['@regression-ns-enum']) +}) + +test('mixed default and named imports', (t) => { + t.plan(2) + + const fullName = path.join(__dirname, './fixture-mixed/spec.cy.js') + const result = findEffectiveTestTagsIn(fullName) + + t.deepEqual(result['mixed default test'].effectiveTags, ['@default-mixed']) + t.deepEqual(result['mixed named test'].effectiveTags, ['@named']) })