├── .eslintrc.json ├── .gitignore ├── .nvmrc ├── .prettierrc ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── index.js ├── package.json └── src ├── __mocks__ └── fs.js ├── dirname ├── index.js └── index.test.js ├── follow-exports ├── index.js └── index.test.js ├── follow-props ├── index.js └── index.test.js ├── gather-all ├── index.js ├── index.test.js └── readme.md ├── get ├── index.js └── index.test.js ├── is-typescript-path ├── index.js └── index.test.js ├── metadata-merger ├── index.js └── index.test.js ├── metadata-parser ├── index.js └── tests │ ├── __fixtures__ │ ├── enums.ts │ ├── heading.tsx │ └── simple.ts │ ├── index.test.js │ └── ts.test.js ├── parse-jsdoc ├── index.js └── index.test.js ├── parser ├── component-resolve.js ├── index.js ├── parse.js ├── print.js ├── react-docgen-parse.js ├── react-docgen-parse.test.js └── visit.js ├── path-finder ├── index.js └── index.test.js ├── prepare-story ├── index.js └── index.test.js ├── promises ├── first.js ├── invert.js ├── promise.js └── tests │ ├── first.test.js │ ├── invert.test.js │ └── promise.test.js ├── read-file ├── index.js └── index.test.js ├── read-folder ├── index.js └── index.test.js ├── resolve-node-modules └── index.js ├── resolve-path ├── index.js └── index.test.js └── testkit-parser ├── __fixtures__ ├── driver.js └── driver2.js ├── get-comments.js ├── get-export.js ├── get-object-descriptor.js ├── index.js ├── index.test.js ├── tests ├── comments.test.js ├── export.test.js ├── imports.test.js ├── methods.test.js ├── nested-objects.test.js └── optimizations.test.js └── utils ├── find-identifier-node.js ├── flatten.js ├── follow-import.js ├── get-exported-node.js ├── get-return-value.js ├── is-testkit.js ├── is-testkit.test.js ├── optimizations.js ├── parse-driver.js └── reduce-to-object.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true, 6 | "node": true, 7 | "jest": true 8 | }, 9 | "extends": "eslint:recommended", 10 | "parserOptions": { 11 | "ecmaVersion": 8, 12 | "ecmaFeatures": { 13 | "jsx": true, 14 | "experimentalObjectRestSpread": true 15 | } 16 | }, 17 | "rules": { 18 | "indent": [ 19 | "error", 20 | 2 21 | ], 22 | "linebreak-style": [ 23 | "error", 24 | "unix" 25 | ], 26 | "quotes": [ 27 | "error", 28 | "single" 29 | ], 30 | "semi": [ 31 | "error", 32 | "always" 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | node_modules 3 | npm-debug.log 4 | .idea 5 | .vscode 6 | yarn.lock 7 | package-lock.json 8 | coverage 9 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 8.9.1 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "trailingComma": "es5", 6 | "bracketSpacing": true 7 | } 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - '8.9.1' 5 | cache: 6 | directories: 7 | - node_modules 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | #### Types of changes 8 | * **Added** for new features. 9 | * **Changed** for changes in existing functionality. 10 | * **Deprecated** for soon-to-be removed features. 11 | * **Removed** for now removed features. 12 | * **Fixed** for any bug fixes. 13 | * **Security** in case of vulnerabilities. 14 | 15 | ## [3.6.0] - 2019-11-12 16 | ### Changed 17 | - `metadataMerger`, `pathFinder` & `prepareStory` - support es5 webpack loader [b4cc163](https://github.com/wix/react-autodocs-utils/commit/b4cc163) 18 | 19 | ## [3.5.7] - 2019-10-18 20 | ### Added 21 | - `metadataParser` - support `@autodocs-component` identifier comment to force props parser for [1346e9c](https://github.com/wix/react-autodocs-utils/commit/1346e9c) 22 | 23 | ## [3.5.6] - 2019-09-18 24 | ### Fixed 25 | - `testkitParser` - support import specifiers with local names, like `import { something as somethingElse } from '...';` [b3bb77f](https://github.com/wix/react-auto1346e9cdocs-utils/commit/b3bb77f) 26 | 27 | ## [3.5.5] - 2019-09-05 28 | ### Fixed 29 | - `prepareStory` - fix function to support nested storyConfig objects [adca935](https://github.com/wix/react-autodocs-utils/commit/adca935) 30 | 31 | ## [3.5.4] - 2019-08-26 32 | ### Fixed 33 | - `testkitParser` - remove `standalone/` parts from paths while parsing, according to convention [0477c39](https://github.com/wix/react-autodocs-utils/commit/0477c39) 34 | 35 | ## [3.5.3] - 2019-08-26 36 | ### Fixed 37 | - `testkitParser` - some refactors and usage of full path as cwd (not dirnamed one) [dd3b723](https://github.com/wix/react-autodocs-utils/commit/dd3b723b71ea38fb5cbd8ae1ad3847a63d765bbd) 38 | 39 | ## [3.5.2] - 2019-08-21 40 | ### Fixed 41 | - `reactDocgenParse` - pass filename to babel parser to prevent it from failing [c95e241](https://github.com/wix/react-autodocs-utils/commit/c95e241) 42 | 43 | ## [3.5.1] - 2019-08-21 44 | ### Fixed 45 | - `followExports` - stop following exports when `export default` found [5d66cc3](https://github.com/wix/react-autodocs-utils/commit/5d66cc3458270cb1a634b7519f34b47a20101880) 46 | 47 | ## [3.5.0] - 2019-04-24 48 | ### Added 49 | - add `tags` array to prop object with parsed jsdoc annotations [48f8b7ab](https://github.com/wix/react-autodocs-utils/commit/48f8b7abc2736efb454909f1ebc3f47f2acda9cf) 50 | 51 | ## [3.4.3] - 2019-04-10 52 | ### Fixed 53 | - better resolve node_modules [c866860f](https://github.com/wix/react-autodocs-utils/commit/c866860f9bb96d1014a1d4679d51473267df8dce) 54 | 55 | ## [3.4.2] - 2019-02-13 56 | ### Changed 57 | - allow `pathFinder` to not include `componentPath` [#16](https://github.com/wix/react-autodocs-utils/pull/16) 58 | 59 | ## [3.4.1] - 2019-01-29 60 | ### Added 61 | - add `dynamicImport` plugin to babel parser [#15](https://github.com/wix/react-autodocs-utils/pull/15) 62 | 63 | ## [3.4.0] - 2018-12-19 64 | ### Added 65 | - Gather `README.API.md` files with the component metadata [#14](https://github.com/wix/react-autodocs-utils/pull/14) 66 | 67 | 68 | ## [3.3.0] - 2018-12-18 69 | ### Added 70 | - support jsdoc type of annotations in testkit comments [#13](https://github.com/wix/react-autodocs-utils/pull/13) 71 | 72 | 73 | ## [3.2.0] - 2018-11-29 74 | ### Fixed 75 | - various fixes for testkit parser 76 | [10](https://github.com/wix/react-autodocs-utils/pull/10) 77 | [11](https://github.com/wix/react-autodocs-utils/pull/11) 78 | [12](https://github.com/wix/react-autodocs-utils/pull/12) 79 | 80 | 81 | ## [3.1.3] - 2018-11-29 82 | ### Changed 83 | - remove `console.warn` about React.Component [75e5813](https://github.com/wix/react-autodocs-utils/commit/75e58138b1b0722f8b317fcc169e261cd651466f) 84 | 85 | 86 | ## [3.1.2] - 2018-11-08 87 | ### Changed 88 | - upgrade react-docgen dependency [3590ae33](https://github.com/wix/react-autodocs-utils/commit/3590ae332375074d3cfb322c5d536aa207151ab4) 89 | 90 | ## [3.1.1] - 2018-11-06 91 | ### Added 92 | - resolve `withFocusable` hoc [967f1211](https://github.com/wix/react-autodocs-utils/commit/967f1211af5f9a46ae0736278886223eadb293df) 93 | 94 | 95 | ## [3.1.0] - 2018-09-24 96 | ### Added 97 | - new `drivers` array in JSON output with component testkit metadata [#8](https://github.com/wix/react-autodocs-utils/pull/8) 98 | 99 | ### Types of changes 100 | * **Added** for new features. 101 | * **Changed** for changes in existing functionality. 102 | * **Deprecated** for soon-to-be removed features. 103 | * **Removed** for now removed features. 104 | * **Fixed** for any bug fixes. 105 | * **Security** in case of vulnerabilities. 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Autodocs Utils 2 | 3 | # REPO MOVED TO https://github.com/wix/wix-ui/tree/master/packages/react-autodocs-utils 4 | ## CONTENT HERE IS ARCHIVED 5 | 6 | [![Build Status](https://travis-ci.org/wix/react-autodocs-utils.svg?branch=master)](https://travis-ci.org/wix/react-autodocs-utils) 7 | 8 | A collection of React component parsers for automating documentation. 9 | 10 | ## Install 11 | 12 | `npm i react-autodocs-utils --save-dev` 13 | 14 | ## Use 15 | 16 | ```js 17 | const reactAutodocsUtils = require('react-autodocs-utils'); 18 | const path = './path/to/react-component.js'; 19 | const componentMetadata = reactAutodocsUtils(path); 20 | ``` 21 | 22 | `componentMetadata` is an object with metadata of component. 23 | 24 | ## Example 25 | 26 | given `component.js`: 27 | 28 | ```js 29 | import React from 'react'; 30 | import {oneOf, node} from 'prop-types'; 31 | 32 | export class Component extends React.PureComponent { 33 | static propTypes = { 34 | thing: oneOf(['first', 'second']), 35 | 36 | /** i am description about `children` prop */ 37 | children: node.isRequired 38 | } 39 | 40 | render() { 41 | return
; 42 | } 43 | } 44 | ``` 45 | 46 | `reactAutodocsUtils('./component.js')` Will return a JSON: 47 | 48 | 49 | ```js 50 | { 51 | "props": { 52 | "thing": { 53 | "type": { 54 | "name": "enum", 55 | "value": [ 56 | { 57 | "value": "'first'", 58 | "computed": false 59 | }, 60 | { 61 | "value": "'second'", 62 | "computed": false 63 | } 64 | ] 65 | }, 66 | "required": false, 67 | "description": "" 68 | }, 69 | "children": { 70 | "type": { 71 | "name": "node" 72 | }, 73 | "required": true, 74 | "description": "i am description about `children` prop" 75 | } 76 | }, 77 | "description": "", 78 | "displayName": "Component", 79 | "methods": [], 80 | "readme": "source of `./readme.md` if exists, otherwise empty string", 81 | "readmeAccessibility": "source of `./readme.accessibility.md` if exists, otherwise empty string", 82 | "readmeTestkit": "source of `./readme.testkit.md` if exists, otherwise empty string", 83 | 84 | // metadata of exported methods in *.driver.js, *.protractor.driver.js or *.pupeteer.driver.js 85 | "drivers": [ 86 | { 87 | "file": "component.driver.js", 88 | "descriptor": [ 89 | { 90 | "name": "click", 91 | "args": [], 92 | "type": "function" 93 | } 94 | ] 95 | }, 96 | { 97 | "file": "component.pupeteer.driver.js", 98 | "descriptor": [ 99 | { 100 | "name": "element", 101 | "args": [], 102 | "type": "function" 103 | } 104 | ] 105 | } 106 | ] 107 | } 108 | ``` 109 | 110 | With this information it is easy to display documentation with regular React components. 111 | 112 | It is used heavily in 113 | [wix-storybook-utils](https://github.com/wix/wix-ui/tree/master/packages/wix-storybook-utils). 114 | Live example available at 115 | [wix-style-react](https://wix.github.io/wix-style-react/?selectedKind=3.%20Inputs&selectedStory=3.6%20DatePicker&full=0&addons=0&stories=1&panelRight=0) storybook. 116 | 117 | ## Contribute 118 | 119 | * `git clone git@github.com:wix/react-autodocs-utils.git` 120 | * `npm i` 121 | * `npm test` 122 | 123 | [Jest](https://facebook.github.io/jest/) used to run tests. 124 | * `jest --watch` 125 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const [/* execPath */, /* path */, ...args] = process.argv; 2 | 3 | const gatherAll = require('./src/gather-all'); 4 | 5 | gatherAll(args[0]) 6 | .then(parsed => console.log(JSON.stringify(parsed, null, 2))) 7 | .catch(console.log); 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-autodocs-utils", 3 | "version": "3.6.0", 4 | "description": "Set of utilities for extracting meta information about react components mostly to generate automated documentation", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "test:watch": "jest --watch" 9 | }, 10 | "keywords": [ 11 | "react", 12 | "documentation", 13 | "docs", 14 | "autodocs", 15 | "generated", 16 | "automatic", 17 | "metadata", 18 | "extract", 19 | "utils", 20 | "proptypes" 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/wix/react-autodocs-utils.git" 25 | }, 26 | "bugs": { 27 | "url": "https://github.com/wix/react-autodocs-utils/issues" 28 | }, 29 | "author": "Arijus Šukys (http://arijus.net)", 30 | "license": "MIT", 31 | "devDependencies": { 32 | "@types/react": "^16.3.11", 33 | "cista": "0.0.3", 34 | "jest": "^24.8.0" 35 | }, 36 | "dependencies": { 37 | "@babel/parser": "^7.5.5", 38 | "@babel/traverse": "^7.5.5", 39 | "@babel/types": "^7.5.5", 40 | "doctrine": "^3.0.0", 41 | "react-docgen": "^4.1.1", 42 | "react-docgen-typescript": "^1.12.5", 43 | "recast": "0.13.0", 44 | "typescript": "^2.7.1" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/__mocks__/fs.js: -------------------------------------------------------------------------------- 1 | /* global jest, Promise */ 2 | 3 | const pathSep = require('path').sep; 4 | const fs = jest.genMockFromModule('fs'); 5 | 6 | let mockFS = {}; 7 | 8 | fs.__setFS = tree => (mockFS = tree); 9 | 10 | fs.lstat = (path, callback) => 11 | getNodeInTree(mockFS)(path) 12 | .then(source => typeof source !== 'string') 13 | .catch(() => Promise.resolve(false)) 14 | .then(isDir => 15 | callback(null, { 16 | isDirectory: () => isDir, 17 | }) 18 | ); 19 | 20 | const getNodeInTree = tree => path => 21 | new Promise((resolve, reject) => { 22 | const [contents] = path 23 | .split(pathSep) 24 | .reduce( 25 | ([, /* contents */ cwd], pathPart) => 26 | cwd 27 | ? cwd && cwd[pathPart] 28 | ? [cwd[pathPart], cwd[pathPart]] 29 | : [null, cwd[pathPart]] 30 | : reject(new Error(`ERROR: Trying to read non existing path "${path}" in mocked files`)), 31 | [null, tree] 32 | ); 33 | 34 | return contents ? resolve(contents) : reject(new Error(`Can't read path "${path}" from mocked files`)); 35 | }); 36 | 37 | fs.readFile = (path, encoding, callback) => 38 | getNodeInTree(mockFS)(path) 39 | .then(source => callback(null, source)) 40 | .catch(e => callback(e, null)); 41 | 42 | fs.readdir = (path, encoding, callback) => 43 | path === '.' 44 | ? callback(null, Object.keys(mockFS)) 45 | : getNodeInTree(mockFS)(path) 46 | .then(folder => callback(null, Object.keys(folder))) 47 | .catch(e => callback(e, null)); 48 | 49 | module.exports = fs; 50 | -------------------------------------------------------------------------------- /src/dirname/index.js: -------------------------------------------------------------------------------- 1 | const { extname: pathExtname, dirname: pathDirname } = require('path'); 2 | 3 | // dirname : String -> String 4 | const dirname = path => (pathExtname(path) ? pathDirname(path) : path); 5 | 6 | module.exports = dirname; 7 | -------------------------------------------------------------------------------- /src/dirname/index.test.js: -------------------------------------------------------------------------------- 1 | /* global describe it expect */ 2 | 3 | const dirname = require('./'); 4 | 5 | describe('dirname', () => { 6 | const pathsAndDirnames = [ 7 | ['', ''], 8 | ['folder', 'folder'], 9 | ['folder/index.js', 'folder'], 10 | ['folder/deeper', 'folder/deeper'], 11 | ['folder/deeper', 'folder/deeper'], 12 | ]; 13 | pathsAndDirnames.map(([path, expectedDirname]) => 14 | it(`should return '${expectedDirname}' when given '${path}'`, () => expect(dirname(path)).toBe(expectedDirname)) 15 | ); 16 | }); 17 | -------------------------------------------------------------------------------- /src/follow-exports/index.js: -------------------------------------------------------------------------------- 1 | /* global Promise */ 2 | 3 | const namedTypes = require('recast').types.namedTypes; 4 | 5 | const parse = require('../parser/parse'); 6 | const visit = require('../parser/visit'); 7 | const readFile = require('../read-file'); 8 | const get = require('../get'); 9 | const resolvePath = require('../resolve-path'); 10 | 11 | /** 12 | * extractPath is used to take exported path from source 13 | */ 14 | // extractPath : (source: string, path: string) -> Promise 15 | const extractPath = source => 16 | new Promise(resolve => { 17 | const ast = parse(source); 18 | 19 | const exportDeclarations = ast.program.body.filter(node => 20 | ['ExportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration', 'ExportDefaultDeclaration'].some( 21 | checker => namedTypes[checker].check(node) 22 | ) 23 | ); 24 | 25 | if (exportDeclarations.length === 1) { 26 | const [node] = exportDeclarations; 27 | 28 | // node without source is not a simple export, we handle those elsewhere 29 | if (node.source) { 30 | resolve(node.source.value); 31 | return; 32 | } 33 | } 34 | 35 | visit(ast)({ 36 | // export {default} from 'path'; 37 | ExportNamedDeclaration(path) { 38 | const isSpecifierDefault = path.node.specifiers.some(({ exported }) => exported.name === 'default'); 39 | 40 | if (isSpecifierDefault) { 41 | resolve(path.node.source.value); 42 | return false; 43 | } 44 | 45 | // export const Component = withStylable(Component) 46 | // export const Component = createHOC(Component) 47 | path.traverse({ 48 | CallExpression(path) { 49 | const isWithHOC = ['withStylable', 'createHOC', 'withFocusable'].some(name => 50 | path.get('callee').isIdentifier({ name }) 51 | ); 52 | 53 | if (isWithHOC) { 54 | const componentName = path.get('arguments')[0].node.name; 55 | 56 | visit(ast)({ 57 | ImportDeclaration(path) { 58 | const componentImportSpecifier = path.node.specifiers.find( 59 | ({ local: { name } }) => name === componentName 60 | ); 61 | 62 | if (componentImportSpecifier) { 63 | resolve(path.node.source.value); 64 | return false; 65 | } 66 | }, 67 | }); 68 | } 69 | }, 70 | }); 71 | }, 72 | 73 | // module.exports = require('path') 74 | AssignmentExpression(path) { 75 | const isDefaultExport = [ 76 | path.get('left.object').isIdentifier({ name: 'module' }), 77 | path.get('left.property').isIdentifier({ name: 'exports' }), 78 | path.get('right.callee').isIdentifier({ name: 'require' }), 79 | ].every(i => i); 80 | 81 | if (isDefaultExport) { 82 | resolve(path.node.right.arguments[0].value); 83 | } 84 | }, 85 | 86 | // export default withClasses(Component); 87 | ExportDefaultDeclaration(path) { 88 | const getter = get(path.node); 89 | 90 | // TODO: refactor multiple into generic HOC resolution 91 | const isWithClasses = [ 92 | path.get('declaration').isCallExpression(), 93 | path.get('declaration.callee').isIdentifier({ name: 'withClasses' }), 94 | ].every(i => i); 95 | 96 | const componentName = isWithClasses ? getter('declaration.arguments.0.name') : getter('declaration.name'); 97 | 98 | visit(ast)({ 99 | ImportDeclaration(path) { 100 | const componentImport = path.node.specifiers.find(specifier => specifier.local.name === componentName); 101 | 102 | if (componentImport) { 103 | resolve(path.node.source.value); 104 | } 105 | }, 106 | }); 107 | }, 108 | }); 109 | 110 | // when unable to extract path, we assume that there's no more export and 111 | // current source is what we should parse for props 112 | resolve(null); 113 | }); 114 | 115 | // followExports (source: string, path: string) => Promise<{source: String, path: String}> 116 | const followExports = (source, path = '') => 117 | extractPath(source, path).then(extractedPath => 118 | extractedPath 119 | ? resolvePath(path, extractedPath).then(resolvedPath => 120 | readFile(resolvedPath) 121 | .then(({ source, path }) => followExports(source, path)) 122 | .catch(e => console.log(`ERROR: unable to read ${resolvedPath}`, e)) 123 | ) 124 | : { source, path } 125 | ); 126 | 127 | module.exports = followExports; 128 | -------------------------------------------------------------------------------- /src/follow-exports/index.test.js: -------------------------------------------------------------------------------- 1 | /* global describe it expect jest */ 2 | 3 | const followExports = require('./'); 4 | 5 | const cista = require('cista'); 6 | 7 | describe('followExports()', () => { 8 | describe('given source', () => { 9 | describe('which does not have exports', () => { 10 | it('should return original source', () => { 11 | const source = 'const hey = "now"'; 12 | 13 | return expect(followExports(source)).resolves.toEqual({ source, path: '' }); 14 | }); 15 | }); 16 | 17 | describe('which has module.exports', () => { 18 | it('should return source of that export', () => { 19 | const source = "module.exports = require('./index.js')"; 20 | 21 | const fakeFs = cista({ 22 | 'index.js': 'hello', 23 | }); 24 | 25 | return expect(followExports(source, fakeFs.dir)).resolves.toEqual({ 26 | source: 'hello', 27 | path: fakeFs.dir + '/index.js', 28 | }); 29 | }); 30 | 31 | it('should return source of resolved file without exports', () => { 32 | const source = "export {default} from './file.js'"; 33 | 34 | const fakeFs = cista({ 35 | 'node_modules/file.js': 'export {default} from "../nested/deep/index.js"', 36 | 'nested/deep/index.js': 'export {default} from "../sibling.js"', 37 | 'nested/sibling.js': 'hello', 38 | }); 39 | 40 | return expect(followExports(source, fakeFs.dir + '/node_modules')).resolves.toEqual({ 41 | source: 'hello', 42 | path: fakeFs.dir + '/nested/sibling.js', 43 | }); 44 | }); 45 | }); 46 | 47 | describe('which has `withStylable` HOC', () => { 48 | it('should resolve component', () => { 49 | const source = ` 50 | import { 51 | Component as CoreComponent, 52 | ComponentProps as CoreComponentProps 53 | } from 'wix-ui-core/Component'; 54 | import {withStylable} from 'wix-ui-core'; 55 | 56 | export const Component = withStylable( 57 | CoreComponent, 58 | {}, 59 | i => i, 60 | {} 61 | ); 62 | `; 63 | 64 | const fakeFs = cista({ 65 | 'node_modules/wix-ui-core/Component.js': 'hello', 66 | }); 67 | 68 | return expect(followExports(source, fakeFs.dir + '/root')).resolves.toEqual({ 69 | source: 'hello', 70 | path: fakeFs.dir + '/node_modules/wix-ui-core/Component.js', 71 | }); 72 | }); 73 | 74 | // TODO: this would be a helpful feature, leaving skipped test for future reference 75 | describe.skip('with props interface which is in same file', () => { 76 | it('should add additional `composedProps` property with parsed interface', () => { 77 | const source = ` 78 | import { Component as CoreComponent } from 'wix-ui-core/Component'; 79 | import {withStylable} from 'wix-ui-core'; 80 | 81 | export interface AdditionalProps { 82 | /** font size of the text */ 83 | size?: Size; 84 | 85 | /** is the text type is secondary. Affects the font color */ 86 | secondary?: boolean; 87 | 88 | /** skin color of the text */ 89 | skin?: Skin; 90 | 91 | /** is the text has dark or light skin */ 92 | light?: boolean; 93 | 94 | /** is the text bold */ 95 | bold?: boolean; 96 | } 97 | 98 | export const Component = withStylable( 99 | CoreComponent, 100 | {}, 101 | i => i, 102 | {} 103 | ); 104 | `; 105 | 106 | fs.__setFS({ 107 | 'index.ts': source, 108 | 109 | node_modules: { 110 | 'wix-ui-core': { 111 | 'Component.js': 'hello', 112 | }, 113 | }, 114 | }); 115 | 116 | return expect(followExports(source, '')).resolves.toEqual({ 117 | source: 'hello', 118 | path: 'node_modules/wix-ui-core/Component.js', 119 | composedProps: { 120 | size: { 121 | name: 'size', 122 | required: false, 123 | type: { name: 'Size' }, 124 | description: 'font size of the text', 125 | defaultValue: undefined, 126 | }, 127 | skin: { 128 | name: 'skin', 129 | required: false, 130 | type: { name: 'string' }, 131 | description: 'skin color of the text', 132 | defaultValue: undefined, 133 | }, 134 | secondary: { 135 | name: 'secondary', 136 | required: false, 137 | type: { name: 'boolean' }, 138 | description: 'is the text type is secondary. Affects the font color', 139 | defaultValue: undefined, 140 | }, 141 | light: { 142 | name: 'light', 143 | required: false, 144 | type: { name: 'boolean' }, 145 | description: 'is the text has dark or light skin', 146 | defaultValue: undefined, 147 | }, 148 | bold: { 149 | name: 'bold', 150 | required: false, 151 | type: { name: 'boolean' }, 152 | description: 'is the text bold', 153 | defaultValue: undefined, 154 | }, 155 | }, 156 | }); 157 | }); 158 | }); 159 | }); 160 | 161 | describe('which has `createHOC` HOC', () => { 162 | it('should resolve component', () => { 163 | const source = ` 164 | import { 165 | Component as CoreComponent 166 | } from './Label.js'; 167 | 168 | export const Label = createHOC(CoreComponent); 169 | `; 170 | 171 | const fakeFs = cista({ 172 | 'index.js': source, 173 | 'Label.js': 'hello', 174 | }); 175 | 176 | return expect(followExports(source, fakeFs.dir)).resolves.toEqual({ 177 | source: 'hello', 178 | path: fakeFs.dir + '/Label.js', 179 | }); 180 | }); 181 | }); 182 | 183 | describe('which has createHOC(withFocusable())', () => { 184 | it('should resolve component', () => { 185 | const source = ` 186 | import { Component as CoreComponent } from './Badge.js'; 187 | export const Label = createHOC(withFocusable(CoreComponent)); 188 | `; 189 | 190 | const fakeFs = cista({ 191 | 'index.js': source, 192 | 'Badge.js': 'hello', 193 | }); 194 | 195 | return expect(followExports(source, fakeFs.dir)).resolves.toEqual({ 196 | source: 'hello', 197 | path: fakeFs.dir + '/Badge.js', 198 | }); 199 | }); 200 | }); 201 | 202 | describe('which exports with `withClasses` hoc', () => { 203 | it('should return source of component', () => { 204 | const source = "module.exports = require('./dist/src/components/component');"; 205 | 206 | const fakeFs = cista({ 207 | 'index.js': source, 208 | 'src/components/component/index.js': ` 209 | import Component from "./component.js"; 210 | export default withClasses(Component, styles)`, 211 | 'src/components/component/component.js': 'hello', 212 | }); 213 | 214 | return expect(followExports(source, fakeFs.dir)).resolves.toEqual({ 215 | source: 'hello', 216 | path: fakeFs.dir + '/src/components/component/component.js', 217 | }); 218 | }); 219 | }); 220 | 221 | describe('which has a single non default export', () => { 222 | it('should return source of component', () => { 223 | const source = "export {Component, AnythingElse} from './thing.js'\n'should ignore me'"; 224 | 225 | const fakeFs = cista({ 226 | 'thing.js': 'hey!', 227 | }); 228 | 229 | return expect(followExports(source, fakeFs.dir)).resolves.toEqual({ 230 | source: 'hey!', 231 | path: fakeFs.dir + '/thing.js', 232 | }); 233 | }); 234 | }); 235 | 236 | describe('which has multiple exports including default one', () => { 237 | it('should stop following exports and return current source', () => { 238 | const source = ` 239 | export { something } from './constants.js'; 240 | export default 1; 241 | `; 242 | 243 | return expect(followExports(source, '')).resolves.toEqual({ 244 | source, 245 | path: '', 246 | }); 247 | }); 248 | }); 249 | 250 | describe('which has `export default Identifier`', () => { 251 | it('should return source of current file when Identifier is declared', () => { 252 | const source = ` 253 | import { Component as SomethingElse } from 'shouldnt-go-here'; 254 | export { something } from 'shouldnt-go-here'; 255 | class Component {}; 256 | export default Component; 257 | `; 258 | 259 | return expect(followExports(source, '')).resolves.toEqual({ 260 | path: '', 261 | source, 262 | }); 263 | }); 264 | 265 | it('should return source of exported Identifier', () => { 266 | const source = ` 267 | import Component from \'./Component\'; 268 | export default Component; 269 | `; 270 | 271 | const fakeFs = cista({ 272 | 'Component/index.js': 'hello', 273 | }); 274 | 275 | return expect(followExports(source, fakeFs.dir)).resolves.toEqual({ 276 | source: 'hello', 277 | path: fakeFs.dir + '/Component/index.js', 278 | }); 279 | }); 280 | }); 281 | }); 282 | }); 283 | -------------------------------------------------------------------------------- /src/follow-props/index.js: -------------------------------------------------------------------------------- 1 | /* global Promise */ 2 | 3 | const { join: pathJoin, dirname: pathDirname } = require('path'); 4 | const { reactDocgenParse } = require('../parser/react-docgen-parse'); 5 | 6 | const readFile = require('../read-file'); 7 | const followExports = require('../follow-exports'); 8 | const resolveNodeModules = require('../resolve-node-modules'); 9 | 10 | const parseDocgen = (source, path) => 11 | new Promise((resolve, reject) => { 12 | const parsed = reactDocgenParse({ source, path }); 13 | 14 | return parsed.composes 15 | ? reject(parsed) // we'll handle composed props in catch 16 | : resolve(parsed); 17 | }); 18 | 19 | const mergeComponentProps = components => 20 | components.reduce( 21 | (acc, component) => ({ 22 | ...component.props, 23 | ...acc, 24 | }), 25 | {} 26 | ); 27 | 28 | const followComposedProps = (parsed, currentPath) => 29 | Promise.all( 30 | parsed.composes.map(composedPath => { 31 | const readablePathPromise = composedPath.startsWith('.') 32 | ? Promise.resolve(pathJoin(pathDirname(currentPath), composedPath)) 33 | : resolveNodeModules(currentPath, composedPath.replace(/(dist\/|standalone\/)/g, '')); 34 | 35 | return readablePathPromise.then(readFile); 36 | }) 37 | ) 38 | 39 | .then(composedSourcesAndPaths => 40 | Promise.all(composedSourcesAndPaths.map(({ source, path }) => followExports(source, path))) 41 | ) 42 | 43 | .then(composedSourcesAndPaths => 44 | Promise.all( 45 | composedSourcesAndPaths.map(({ source, path }) => ({ 46 | parsed: reactDocgenParse({ source, path }), 47 | path, 48 | })) 49 | ) 50 | ) 51 | 52 | .then(parsedComponents => { 53 | // here we receive list of object containing parsed component 54 | // props. some of them may contain composed props from other 55 | // components, in which case we followProps again recursively 56 | 57 | const withComposed = parsedComponents 58 | .filter(({ parsed }) => parsed.composes) 59 | .map(({ parsed, path }) => followComposedProps(parsed, path)); 60 | 61 | const withoutComposed = parsedComponents 62 | .filter(({ parsed }) => !parsed.composes) 63 | .map(({ parsed }) => Promise.resolve(parsed)); 64 | 65 | return Promise.all([Promise.all(withComposed), Promise.all(withoutComposed)]).then( 66 | ([withComposed, withoutComposed]) => [...withComposed, ...withoutComposed] 67 | ); 68 | }) 69 | 70 | .then(mergeComponentProps) 71 | 72 | .then(composedProps => { 73 | const allProps = { 74 | ...parsed, 75 | props: { ...parsed.props, ...composedProps }, 76 | }; 77 | 78 | // eslint-disable-next-line no-unused-vars 79 | const { composes, ...otherProps } = allProps; 80 | 81 | return otherProps; 82 | }); 83 | 84 | const followProps = ({ source, path }) => 85 | parseDocgen(source, path) 86 | // if resolved, no need to follow props, no need for .then 87 | // if rejected, need to follow props 88 | .catch(parsed => followComposedProps(parsed, path)) 89 | .catch(e => console.log(`ERROR: Unable to handle composed props for Component at ${path}`, e)); 90 | 91 | module.exports = followProps; 92 | -------------------------------------------------------------------------------- /src/follow-props/index.test.js: -------------------------------------------------------------------------------- 1 | /* global jest describe it expect */ 2 | 3 | const cista = require('cista'); 4 | 5 | // this function was extracted from `metadataParser` which 6 | // has the functionality of `followProps` tested over there. 7 | // hence, not many tests live here but behaviour is covered. 8 | const followProps = require('./'); 9 | 10 | describe('followProps()', () => { 11 | describe('given component with deeply composed props that live in sibling folders', () => { 12 | it('should resolve paths correctly', () => { 13 | const entrySource = ` 14 | import PropTypes from 'prop-types'; 15 | import React from 'react'; 16 | import OtherComponent from '../../OtherComponent/OtherComponent'; 17 | 18 | export default class EntryComponent extends React.Component { 19 | static propTypes = { 20 | ...OtherComponent.propTypes, 21 | disabled: PropTypes.bool 22 | }; 23 | 24 | render() { 25 | return
; 26 | } 27 | }`; 28 | 29 | const fakeFs = cista({ 30 | 'src/Backoffice/Component/index.js': entrySource, 31 | 'src/OtherComponent/OtherComponent.js': ` 32 | import React from "react"; 33 | import PropTypes from "prop-types"; 34 | import OneUpComponent from "../OneUpComponent"; 35 | 36 | export default class Component extends React.Component { 37 | static propTypes = { 38 | ...OneUpComponent.propTypes, 39 | link: PropTypes.string 40 | }; 41 | 42 | render() { 43 | return (
); 44 | } 45 | }`, 46 | 47 | 'src/OneUpComponent/index.js': ` 48 | import React from "react"; 49 | import PropTypes from "prop-types"; 50 | const component = () =>
; 51 | component.propTypes = { 52 | veryDeep: PropTypes.bool.isRequired 53 | } 54 | export default component; 55 | `, 56 | }); 57 | 58 | return expect( 59 | followProps({ source: entrySource, path: fakeFs.dir + '/src/Backoffice/Component/index.js' }) 60 | ).resolves.toEqual({ 61 | description: '', 62 | displayName: 'EntryComponent', 63 | methods: [], 64 | props: { 65 | disabled: { 66 | description: '', 67 | required: false, 68 | type: { name: 'bool' }, 69 | }, 70 | link: { 71 | description: '', 72 | required: false, 73 | type: { name: 'string' }, 74 | }, 75 | veryDeep: { 76 | description: '', 77 | required: true, 78 | type: { name: 'bool' }, 79 | }, 80 | }, 81 | }); 82 | }); 83 | }); 84 | 85 | describe('given component that spreads props from absolute node_modules dist path', () => { 86 | it('should remove `dist/` from path', () => { 87 | const entrySource = `import PropTypes from "prop-types"; 88 | import React from "react"; 89 | import OtherComponent from "wix-ui-backoffice/dist/src/Component"; 90 | 91 | export default class EntryComponent extends React.Component { 92 | static propTypes = { 93 | ...OtherComponent.propTypes 94 | }; 95 | 96 | render() { 97 | return
; 98 | } 99 | }`; 100 | 101 | const fakeFs = cista({ 102 | index: entrySource, 103 | 'node_modules/wix-ui-backoffice/src/Component.js': ` 104 | import PropTypes from "prop-types"; 105 | import React from "react"; 106 | 107 | export default class Component extends React.Component { 108 | static propTypes = { 109 | theThing: PropTypes.bool.isRequired 110 | }; 111 | 112 | render() { 113 | return
; 114 | } 115 | }`, 116 | }); 117 | 118 | return expect(followProps({ source: entrySource, path: fakeFs.dir + '/index' })).resolves.toEqual({ 119 | description: '', 120 | displayName: 'EntryComponent', 121 | methods: [], 122 | props: { 123 | theThing: { 124 | description: '', 125 | required: true, 126 | type: { name: 'bool' }, 127 | }, 128 | }, 129 | }); 130 | }); 131 | 132 | it('should remove `standalone/` from path', () => { 133 | const entrySource = `import PropTypes from "prop-types"; 134 | import React from "react"; 135 | import OtherComponent from "wix-ui-backoffice/dist/standalone/src/standalone-Component"; 136 | 137 | export default class EntryComponent extends React.Component { 138 | static propTypes = { 139 | ...OtherComponent.propTypes 140 | }; 141 | 142 | render() { 143 | return
; 144 | } 145 | }`; 146 | 147 | const fakeFs = cista({ 148 | index: entrySource, 149 | 'node_modules/wix-ui-backoffice/src/standalone-Component.js': ` 150 | import PropTypes from "prop-types"; 151 | import React from "react"; 152 | 153 | export default class Component extends React.Component { 154 | static propTypes = { 155 | theThing: PropTypes.bool.isRequired 156 | }; 157 | 158 | render() { 159 | return
; 160 | } 161 | }`, 162 | }); 163 | 164 | return expect(followProps({ source: entrySource, path: fakeFs.dir + '/index' })).resolves.toEqual({ 165 | description: '', 166 | displayName: 'EntryComponent', 167 | methods: [], 168 | props: { 169 | theThing: { 170 | description: '', 171 | required: true, 172 | type: { name: 'bool' }, 173 | }, 174 | }, 175 | }); 176 | }); 177 | }); 178 | }); 179 | -------------------------------------------------------------------------------- /src/gather-all/index.js: -------------------------------------------------------------------------------- 1 | /* global Promise */ 2 | 3 | const pathJoin = require('path').join; 4 | const dirname = require('../dirname'); 5 | const readFolder = require('../read-folder'); 6 | const readFile = require('../read-file'); 7 | const metadataParser = require('../metadata-parser'); 8 | const testkitParser = require('../testkit-parser'); 9 | const isTestkit = require('../testkit-parser/utils/is-testkit'); 10 | 11 | // containsFile : List String -> Bool -> Promise 12 | const containsFile = files => name => { 13 | const file = files.find(f => f.toLowerCase() === name.toLowerCase()); 14 | 15 | return file ? Promise.resolve(file) : Promise.reject(); 16 | }; 17 | 18 | // isPath : String -> Promise 19 | const isPath = path => 20 | path ? Promise.resolve(path) : Promise.reject('Error: gatherAll is missing required `path` argument'); 21 | 22 | // error : String -> Promise 23 | const error = message => Promise.reject(new Error(message)); 24 | 25 | const gatherAll = path => 26 | isPath(path) 27 | .catch(e => { 28 | throw e; 29 | }) 30 | 31 | .then(path => metadataParser(path).catch(e => error(`Unable to parse component in path "${path}", reason: ${e}`))) 32 | 33 | .then(metadata => Promise.all([metadata, readFolder(path)])) 34 | 35 | .then(async ([metadata, files]) => { 36 | const readMarkdown = markdownPath => 37 | containsFile(files)(markdownPath) 38 | .then(file => readFile(pathJoin(dirname(path), file))) 39 | .then(({ source }) => source) 40 | .catch(() => Promise.resolve('')); 41 | 42 | const readme = readMarkdown('readme.md'); 43 | const readmeApi = readMarkdown('readme.api.md'); 44 | const readmeAccessibility = readMarkdown('readme.accessibility.md'); 45 | const readmeTestkit = readMarkdown('readme.testkit.md'); 46 | const testkits = await Promise.all( 47 | files.filter(isTestkit).map(testkitPath => testkitParser(pathJoin(dirname(path), testkitPath))) 48 | ); 49 | 50 | return Promise.all([metadata, readme, readmeApi, readmeAccessibility, readmeTestkit, testkits]).then( 51 | ([metadata, readme, readmeApi, readmeAccessibility, readmeTestkit, testkits]) => ({ 52 | ...metadata, 53 | readme, 54 | readmeApi, 55 | readmeAccessibility, 56 | readmeTestkit, 57 | 58 | // TODO: this entry should be named testkits, but keeping it as `drivers` to prevent breaking change 59 | drivers: testkits, 60 | }) 61 | ); 62 | }); 63 | 64 | module.exports = gatherAll; 65 | -------------------------------------------------------------------------------- /src/gather-all/index.test.js: -------------------------------------------------------------------------------- 1 | /* global jest describe it expect beforeEach */ 2 | 3 | const gatherAll = require('./'); 4 | const cista = require('cista'); 5 | 6 | const componentSourceMock = ` 7 | import PropTypes from "prop-types"; 8 | const component = () =>
; 9 | component.propTypes = { test: PropTypes.string.isRequired }; 10 | export default component; 11 | `; 12 | 13 | const metadataMock = { 14 | description: '', 15 | methods: [], 16 | props: { 17 | test: { 18 | description: '', 19 | required: true, 20 | type: { 21 | name: 'string', 22 | }, 23 | }, 24 | }, 25 | }; 26 | 27 | const readmeMock = '# Hello readme!'; 28 | const readmeApiMock = '# Hello API!'; 29 | const readmeAccessibilityMock = '# Hello Accessiblity!'; 30 | const readmeTestkitMock = '# Hello Testkit!'; 31 | 32 | describe('gatherAll', () => { 33 | describe('given path argument', () => { 34 | describe('which is empty folder', () => { 35 | it('should reject with error', () => { 36 | const fakeFs = cista({ 37 | 'some-path': {}, 38 | }); 39 | 40 | return gatherAll(fakeFs.dir + '/some-path').catch(({ message }) => { 41 | expect(message).toMatch(`Unable to parse component in path "${fakeFs.dir}/some-path", reason:`); 42 | }); 43 | }); 44 | }); 45 | 46 | describe('which is folder with index.js, README.md, README.api.md, README.accessibility.md and README.testkit.md', () => { 47 | it('should resolve with component metadata', () => { 48 | const fakeFs = cista({ 49 | 'component-folder/index.js': componentSourceMock, 50 | 'component-folder/readme.md': readmeMock, 51 | 'component-folder/readme.api.md': readmeApiMock, 52 | 'component-folder/readme.accessibility.md': readmeAccessibilityMock, 53 | 'component-folder/readme.testkit.md': readmeTestkitMock, 54 | }); 55 | 56 | return expect(gatherAll(fakeFs.dir + '/component-folder')).resolves.toEqual({ 57 | ...metadataMock, 58 | displayName: 'component', 59 | readme: readmeMock, 60 | readmeApi: readmeApiMock, 61 | readmeAccessibility: readmeAccessibilityMock, 62 | readmeTestkit: readmeTestkitMock, 63 | drivers: [], 64 | }); 65 | }); 66 | }); 67 | 68 | describe('which is folder with index.js and some markdowns with various cases', () => { 69 | it('should resolve with component metadata', () => { 70 | const fakeFs = cista({ 71 | 'component-folder/index.js': componentSourceMock, 72 | 'component-folder/README.md': readmeMock, 73 | 'component-folder/readme.API.md': readmeApiMock, 74 | 'component-folder/readme.accessibility.md': readmeAccessibilityMock, 75 | 'component-folder/README.testkit.md': readmeTestkitMock, 76 | }); 77 | 78 | return expect(gatherAll(fakeFs.dir + '/component-folder')).resolves.toEqual({ 79 | ...metadataMock, 80 | displayName: 'component', 81 | readme: readmeMock, 82 | readmeApi: readmeApiMock, 83 | readmeAccessibility: readmeAccessibilityMock, 84 | readmeTestkit: readmeTestkitMock, 85 | drivers: [], 86 | }); 87 | }); 88 | }); 89 | 90 | describe('which is folder with component importing from node_modules', () => { 91 | it('should resolve with component metadata', () => { 92 | const fakeFs = cista({ 93 | 'src/components/Badge/index.js': `import * as React from "react"; 94 | import {Badge as CoreBadge} from "wix-ui-core/Badge"; 95 | const component = () =>
; 96 | component.propTypes = { 97 | ...CoreBadge.propTypes, 98 | }; 99 | export default component; 100 | `, 101 | 'node_modules/wix-ui-core/Badge.js': `module.exports = require("./dist/src/components/Badge");`, 102 | 'node_modules/wix-ui-core/src/components/Badge/index.js': componentSourceMock, 103 | }); 104 | 105 | return expect(gatherAll(fakeFs.dir + '/src/components/Badge')).resolves.toEqual({ 106 | description: '', 107 | methods: [], 108 | displayName: 'component', 109 | props: { 110 | test: { 111 | description: '', 112 | required: true, 113 | type: { 114 | name: 'string', 115 | }, 116 | }, 117 | }, 118 | readme: '', 119 | readmeApi: '', 120 | readmeAccessibility: '', 121 | readmeTestkit: '', 122 | drivers: [], 123 | }); 124 | }); 125 | }); 126 | 127 | describe('which is folder with component importing from non direct node_modules', () => { 128 | it('should resolve with component metadata', () => { 129 | const fakeFs = cista({ 130 | 'library/src/components/Badge/index.js': `import * as React from "react"; 131 | import {Badge as CoreBadge} from "wix-ui-core/Badge"; 132 | const component = () =>
; 133 | component.propTypes = { 134 | ...CoreBadge.propTypes, 135 | }; 136 | export default component; 137 | `, 138 | 'node_modules/wix-ui-core/Badge.js': 'module.exports = require("./dist/src/components/Badge");', 139 | 'node_modules/wix-ui-core/src/components/Badge/index.js': componentSourceMock, 140 | }); 141 | 142 | return expect(gatherAll(fakeFs.dir + '/library/src/components/Badge')).resolves.toEqual({ 143 | description: '', 144 | methods: [], 145 | displayName: 'component', 146 | props: { 147 | test: { 148 | description: '', 149 | required: true, 150 | type: { 151 | name: 'string', 152 | }, 153 | }, 154 | }, 155 | readme: '', 156 | readmeApi: '', 157 | readmeAccessibility: '', 158 | readmeTestkit: '', 159 | drivers: [], 160 | }); 161 | }); 162 | }); 163 | 164 | describe('which is folder with components of various extensions', () => { 165 | it('should resolve with component metadata', () => { 166 | const fakeFs = cista({ 167 | 'folder/index.jsx': ` 168 | import PropTypes from "prop-types"; 169 | import composed from "some-module/Component"; 170 | const component = () =>
; 171 | component.propTypes = { ...composed.propTypes }; 172 | export default component; 173 | `, 174 | 'folder/readme.md': '# Hello readme!', 175 | 'folder/readme.api.md': '# Hello API!', 176 | 'folder/readme.accessibility.md': '# Hello Accessiblity!', 177 | 'folder/readme.testkit.md': '# Hello Testkit!', 178 | 'node_modules/some-module/Component.js': ` 179 | import PropTypes from "prop-types"; 180 | const component = () =>
; 181 | component.propTypes = { test: PropTypes.string.isRequired }; 182 | export default component; 183 | `, 184 | }); 185 | 186 | return expect(gatherAll(fakeFs.dir + '/folder')).resolves.toEqual({ 187 | ...metadataMock, 188 | displayName: 'component', 189 | readme: readmeMock, 190 | readmeApi: readmeApiMock, 191 | readmeAccessibility: readmeAccessibilityMock, 192 | readmeTestkit: readmeTestkitMock, 193 | drivers: [], 194 | }); 195 | }); 196 | 197 | it('should resolve real world scenario', () => { 198 | const fakeFs = cista({ 199 | 'src/components/Badge/index.js': ` 200 | import * as React from "react"; 201 | import {oneOf} from "prop-types"; 202 | import {Badge as CoreBadge} from "wix-ui-core/Badge"; 203 | export class Badge extends React.PureComponent { 204 | static propTypes = { 205 | ...CoreBadge.propTypes, 206 | skin: oneOf(["red", "blue"]) 207 | } 208 | 209 | render() { 210 | return
; 211 | } 212 | }`, 213 | 'src/components/Badge/readme.md': '# Hello readme!', 214 | 'src/components/Badge/readme.api.md': '# Hello API!', 215 | 'src/components/Badge/readme.accessibility.md': '# Hello Accessiblity!', 216 | 'src/components/Badge/readme.testkit.md': '# Hello Testkit!', 217 | 'node_modules/wix-ui-core/Badge.js': `module.exports = require("./dist/src/components/Badge");`, 218 | 'node_modules/wix-ui-core/src/components/Badge/index.js': ` 219 | import * as React from "react"; 220 | import BadgeComponent from "./Badge"; 221 | import {withClasses} from "wix-ui-jss"; 222 | import {styles} from "./styles"; 223 | export default withClasses(BadgeComponent, styles) 224 | `, 225 | 'node_modules/wix-ui-core/src/components/Badge/Badge.jsx': ` 226 | import * as React from "react"; 227 | import {string} from "prop-types"; 228 | const Badge = () =>
; 229 | Badge.propTypes = { 230 | children: string 231 | } 232 | export default Badge; 233 | `, 234 | }); 235 | 236 | return expect(gatherAll(fakeFs.dir + '/src/components/Badge')).resolves.toEqual({ 237 | description: '', 238 | methods: [], 239 | displayName: 'Badge', 240 | props: { 241 | skin: { 242 | description: '', 243 | type: { 244 | name: 'enum', 245 | value: [{ computed: false, value: '"red"' }, { computed: false, value: '"blue"' }], 246 | }, 247 | required: false, 248 | }, 249 | children: { 250 | type: { 251 | name: 'string', 252 | }, 253 | description: '', 254 | required: false, 255 | }, 256 | }, 257 | readme: readmeMock, 258 | readmeApi: readmeApiMock, 259 | readmeAccessibility: readmeAccessibilityMock, 260 | readmeTestkit: readmeTestkitMock, 261 | drivers: [], 262 | }); 263 | }); 264 | }); 265 | 266 | describe('which is path to concrete file', () => { 267 | it('should resolve with component metadata', () => { 268 | const fakeFs = cista({ 269 | 'index.js': componentSourceMock, 270 | 'readme.md': readmeMock, 271 | 'readme.api.md': readmeApiMock, 272 | 'readme.accessibility.md': readmeAccessibilityMock, 273 | 'readme.testkit.md': readmeTestkitMock, 274 | }); 275 | 276 | return expect(gatherAll(fakeFs.dir + '/index.js')).resolves.toEqual({ 277 | ...metadataMock, 278 | displayName: 'component', 279 | readme: readmeMock, 280 | readmeApi: readmeApiMock, 281 | readmeAccessibility: readmeAccessibilityMock, 282 | readmeTestkit: readmeTestkitMock, 283 | drivers: [], 284 | }); 285 | }); 286 | }); 287 | }); 288 | 289 | describe('when called without path', () => { 290 | it('should reject promise with error', () => 291 | expect(gatherAll()).rejects.toEqual('Error: gatherAll is missing required `path` argument')); 292 | }); 293 | }); 294 | -------------------------------------------------------------------------------- /src/gather-all/readme.md: -------------------------------------------------------------------------------- 1 | `gatherAll` is a function that tries to get as much data about component 2 | as possible. 3 | 4 | This is the entry point. Internally it uses `metadataParser`, `fileReader` `pathFinder` and such 5 | -------------------------------------------------------------------------------- /src/get/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * helper function to retrieve deeply nested properties 3 | * cause i don't need lodash, it's easy enough to implement 4 | * 5 | * given 6 | * 7 | * object = { 8 | * nested: { 9 | * deeply: { 10 | * array: [ 'first', { second: 'hello' } ] 11 | * } 12 | * } 13 | * } 14 | * 15 | * get(object)('nested.deeply.array.1.second') // <-- 'hello' 16 | */ 17 | 18 | // get -> Object a -> String -> a 19 | const get = object => path => 20 | path 21 | .split('.') 22 | .reduce( 23 | (resultObject, pathPart) => (resultObject && resultObject[pathPart] ? resultObject[pathPart] : null), 24 | object 25 | ); 26 | 27 | module.exports = get; 28 | -------------------------------------------------------------------------------- /src/get/index.test.js: -------------------------------------------------------------------------------- 1 | /* global describe it expect */ 2 | 3 | const get = require('./'); 4 | 5 | describe('get()', () => { 6 | it('should return function when called', () => { 7 | expect(typeof get()).toBe('function'); 8 | }); 9 | 10 | describe('given object path "descriptor"', () => { 11 | it('should return value', () => { 12 | const object = { 13 | nested: { 14 | deeply: { 15 | array: ['first', { second: 'hello' }], 16 | }, 17 | }, 18 | }; 19 | 20 | const path = 'nested.deeply.array.1.second'; 21 | expect(get(object)(path)).toEqual('hello'); 22 | }); 23 | }); 24 | 25 | describe('given path "descriptor" pointing to non existing property', () => { 26 | it('should return null', () => { 27 | const object = { 28 | nested: { 29 | deeply: 'hello', 30 | }, 31 | }; 32 | 33 | const path = 'are.you.still.there.?'; 34 | expect(get(object)(path)).toEqual(null); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/is-typescript-path/index.js: -------------------------------------------------------------------------------- 1 | const pathExtname = require('path').extname; 2 | 3 | const tsExtensions = ['.ts', '.tsx']; 4 | 5 | const ensureString = a => (typeof a === 'string' ? a : ''); 6 | 7 | const isTypescriptPath = path => tsExtensions.includes(pathExtname(ensureString(path))); 8 | 9 | module.exports = isTypescriptPath; 10 | -------------------------------------------------------------------------------- /src/is-typescript-path/index.test.js: -------------------------------------------------------------------------------- 1 | /* global describe it expect */ 2 | 3 | const isTypescript = require('./'); 4 | 5 | describe('isTypescript', () => { 6 | it('should be defined', () => { 7 | expect(typeof isTypescript).toBe('function'); 8 | }); 9 | 10 | const notTS = ['', 'thing', 'index.js', 'index', null, [], '.ts.index']; 11 | const isTS = ['index.ts', 'index.tsx', 'index.test.ts']; 12 | 13 | notTS.map(assert => it(`should return false given ${assert}`, () => expect(isTypescript(assert)).toBe(false))); 14 | 15 | isTS.map(assert => it(`should return true given ${assert}`, () => expect(isTypescript(assert)).toBe(true))); 16 | }); 17 | -------------------------------------------------------------------------------- /src/metadata-merger/index.js: -------------------------------------------------------------------------------- 1 | /* global Promise */ 2 | 3 | const recast = require('recast'); 4 | const types = require('@babel/types'); 5 | 6 | const get = require('../get'); 7 | const parse = require('../parser/parse'); 8 | const visit = require('../parser/visit'); 9 | const print = require('../parser/print'); 10 | const builders = recast.types.builders; 11 | 12 | const metadataMerger = source => metadata => 13 | new Promise((resolve, reject) => 14 | source && metadata 15 | ? resolve(parse(source)) 16 | : reject('ERROR: unable to merge `metadata` into exported story config, ensure `source` & `metadata` are defined') 17 | ).then(ast => { 18 | metadata = Object.keys(metadata).length ? metadata : { props: {} }; 19 | const metadataAST = parse(`(${JSON.stringify(metadata)})`); 20 | 21 | let metadataProperties; 22 | 23 | visit(metadataAST)({ 24 | ObjectExpression(path) { 25 | metadataProperties = path.node.properties; 26 | path.skip(); 27 | }, 28 | }); 29 | 30 | if (!metadataProperties) { 31 | return Promise.reject('ERROR: Unable to merge metadata with source'); 32 | } 33 | 34 | const handleExportObject = (path, node) => { 35 | const { configNode } = [ 36 | { 37 | pattern: types.isObjectExpression(node), 38 | configNode: node, 39 | }, 40 | { 41 | pattern: types.isIdentifier(node), 42 | configNode: get(path)(`scope.bindings.${node.name}.path.node.init`), 43 | }, 44 | ].find(({ pattern }) => pattern); 45 | 46 | if (configNode) { 47 | configNode.properties.push( 48 | builders.objectProperty(builders.identifier('_metadata'), builders.objectExpression(metadataProperties)) 49 | ); 50 | } 51 | }; 52 | 53 | visit(ast)({ 54 | ExportDefaultDeclaration(path) { 55 | handleExportObject(path, path.node.declaration); 56 | }, 57 | 58 | ExpressionStatement(path) { 59 | const isModuleExports = [ 60 | types.isMemberExpression(path.node.expression.left), 61 | get(path)('node.expression.left.object.name') === 'module', 62 | get(path)('node.expression.left.property.name') === 'exports', 63 | ].every(Boolean); 64 | 65 | if (isModuleExports) { 66 | handleExportObject(path, path.node.expression.right); 67 | } 68 | }, 69 | }); 70 | 71 | return print(ast); 72 | }); 73 | 74 | module.exports = metadataMerger; 75 | -------------------------------------------------------------------------------- /src/metadata-merger/index.test.js: -------------------------------------------------------------------------------- 1 | /* global describe it expect */ 2 | 3 | const metadataMerger = require('./'); 4 | 5 | describe('metadataMerger', () => { 6 | describe('when erroneous input given', () => { 7 | it('should reject promise with message', () => 8 | expect(metadataMerger()()).rejects.toEqual( 9 | 'ERROR: unable to merge `metadata` into exported story config, ensure `source` & `metadata` are defined' 10 | )); 11 | }); 12 | 13 | describe('with 2 curried calls', () => { 14 | it('should return promise', () => { 15 | expect(metadataMerger('"test"')({}).then).toBeDefined(); 16 | }); 17 | 18 | describe('when export default', () => { 19 | it('should add `_metadata` to story config', () => { 20 | const source = 'export default { a: 1, b: 2 }'; 21 | const metadata = { hello: 1, goodbye: { forReal: 'bye' } }; 22 | const expectation = `export default { 23 | a: 1, 24 | b: 2, 25 | _metadata: { 26 | "hello": 1, 27 | "goodbye": { 28 | "forReal": "bye" 29 | } 30 | } 31 | };`; 32 | 33 | return expect(metadataMerger(source)(metadata)).resolves.toEqual(expectation); 34 | }); 35 | 36 | it('should add `_metadata` to referenced story config', () => { 37 | const source = ` 38 | const config = { a: 1, b: { c: 2 } }; 39 | export default config; 40 | `; 41 | const metadata = { whatIs: 'love' }; 42 | const expectation = `const config = { 43 | a: 1, 44 | b: { 45 | c: 2 46 | }, 47 | _metadata: { 48 | "whatIs": "love" 49 | } 50 | }; 51 | export default config;`; 52 | 53 | return expect(metadataMerger(source)(metadata)).resolves.toEqual(expectation); 54 | }); 55 | }); 56 | 57 | describe('when module.exports', () => { 58 | it('should add `_metadata` to story config', () => { 59 | const source = 'module.exports = { a: 1, b: 2 }'; 60 | const metadata = { hello: 1, goodbye: { forReal: 'bye' } }; 61 | const expectation = `module.exports = { 62 | a: 1, 63 | b: 2, 64 | _metadata: { 65 | "hello": 1, 66 | "goodbye": { 67 | "forReal": "bye" 68 | } 69 | } 70 | };`; 71 | 72 | return expect(metadataMerger(source)(metadata)).resolves.toEqual(expectation); 73 | }); 74 | 75 | it('should add `_metadata` to referenced story config', () => { 76 | const source = ` 77 | const config = { a: 1, b: { c: 2 } }; 78 | module.exports = config; 79 | `; 80 | const metadata = { whatIs: 'love' }; 81 | const expectation = `const config = { 82 | a: 1, 83 | b: { 84 | c: 2 85 | }, 86 | _metadata: { 87 | "whatIs": "love" 88 | } 89 | }; 90 | module.exports = config;`; 91 | 92 | return expect(metadataMerger(source)(metadata)).resolves.toEqual(expectation); 93 | }); 94 | }); 95 | }); 96 | 97 | describe('when metadata is provided empty object', () => { 98 | it('should add props key', () => { 99 | const source = ` 100 | const config = { a: 1, b: { c: 2 } }; 101 | export default config; 102 | `; 103 | const metadata = {}; 104 | const expectation = `const config = { 105 | a: 1, 106 | b: { 107 | c: 2 108 | }, 109 | _metadata: { 110 | "props": {} 111 | } 112 | }; 113 | export default config;`; 114 | 115 | return expect(metadataMerger(source)(metadata)).resolves.toEqual(expectation); 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /src/metadata-parser/index.js: -------------------------------------------------------------------------------- 1 | const readFile = require('../read-file'); 2 | const parse = require('../parser'); 3 | 4 | module.exports = (path = '') => readFile(path).then(parse); 5 | -------------------------------------------------------------------------------- /src/metadata-parser/tests/__fixtures__/enums.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | export interface Props { 3 | /** this is a text prop */ 4 | text?: 'first' | 'second' | 'third'; 5 | number?: 1 | 2 | 3; 6 | } 7 | 8 | /** This is the component */ 9 | export class Component extends React.Component { 10 | render() { 11 | return
{this.props.text}
; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/metadata-parser/tests/__fixtures__/heading.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export type Appearance = 'H1' | 'H2' | 'H3' | 'H4' | 'H5'; 4 | export type Skin = 'dark' | 'light'; 5 | 6 | export interface Props { 7 | /** skin color of the heading */ 8 | skin?: Skin; 9 | 10 | /** typography of the heading */ 11 | appearance?: Appearance; 12 | } 13 | 14 | const defaultProps: Props = { 15 | appearance: 'H1', 16 | skin: 'dark', 17 | }; 18 | 19 | export class Heading extends React.PureComponent { 20 | static defaultProps: Props = defaultProps; 21 | 22 | render() { 23 | return
; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/metadata-parser/tests/__fixtures__/simple.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | export interface Props { 3 | /** this is a text prop */ 4 | text?: string; 5 | } 6 | 7 | /** This is the component */ 8 | export class Component extends React.Component { 9 | render() { 10 | return
{this.props.text}
; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/metadata-parser/tests/index.test.js: -------------------------------------------------------------------------------- 1 | /* global Promise describe it expect jest afterEach beforeAll afterAll */ 2 | 3 | const metadataParser = require('../'); 4 | const cista = require('cista'); 5 | 6 | const rootMock = { 7 | description: '', 8 | methods: [], 9 | props: {}, 10 | }; 11 | 12 | describe('metadataParser()', () => { 13 | describe('when called without parameters', () => { 14 | it('should reject with error', () => 15 | expect(metadataParser()).rejects.toEqual( 16 | new Error('ERROR: Missing required `path` argument when calling `readFile`') 17 | )); 18 | }); 19 | 20 | describe('given existing path', () => { 21 | describe('with component source', () => { 22 | describe('that has no useful data', () => { 23 | it('should return initial object for functional component', () => { 24 | const fakeFs = cista({ 25 | 'simple-functional.js': `import React from "react"; 26 | export default () =>
Hello World!
;`, 27 | }); 28 | 29 | return expect(metadataParser(fakeFs.dir + '/simple-functional.js')).resolves.toEqual(rootMock); 30 | }); 31 | 32 | it('should return initial object for class component', () => { 33 | const fakeFs = cista({ 34 | 'simple-class.js': `import React from "react"; 35 | export default class extends React.Component { 36 | render() { return
; } 37 | }`, 38 | }); 39 | 40 | return expect(metadataParser(fakeFs.dir + '/simple-class.js')).resolves.toEqual({ props: {} }); 41 | }); 42 | }); 43 | 44 | describe('that has props', () => { 45 | it('should return correct object for functional component', () => { 46 | const fakeFs = cista({ 47 | 'functional-with-props.js': `import React from "react"; 48 | import PropTypes from "prop-types"; 49 | const component = () =>
; 50 | component.propTypes = { 51 | /** hello comment */ 52 | hello: PropTypes.bool, 53 | 54 | /** goodbye comment */ 55 | goodbye: PropTypes.string.isRequired, 56 | 57 | /** Mr. Deez 58 | * Nuts 59 | * */ 60 | nuts: PropTypes.oneOf(["deez", "deeez"]) 61 | }; 62 | export default component; 63 | `, 64 | }); 65 | 66 | return expect(metadataParser(fakeFs.dir + '/functional-with-props.js')).resolves.toEqual({ 67 | description: '', 68 | methods: [], 69 | displayName: 'component', 70 | props: { 71 | hello: { 72 | description: 'hello comment', 73 | required: false, 74 | type: { name: 'bool' }, 75 | }, 76 | 77 | goodbye: { 78 | description: 'goodbye comment', 79 | required: true, 80 | type: { name: 'string' }, 81 | }, 82 | 83 | nuts: { 84 | description: 'Mr. Deez\n Nuts', 85 | required: false, 86 | type: { 87 | name: 'enum', 88 | value: [{ computed: false, value: '"deez"' }, { computed: false, value: '"deeez"' }], 89 | }, 90 | }, 91 | }, 92 | }); 93 | }); 94 | 95 | it('should return correct object for class component', () => { 96 | const fakeFs = cista({ 97 | 'class-with-props.js': `import React from "react"; 98 | import PropTypes from "prop-types"; 99 | export default class Component extends React.Component { 100 | static propTypes = { 101 | /** hello comment */ 102 | hello: PropTypes.bool, 103 | 104 | /** goodbye comment */ 105 | goodbye: PropTypes.string.isRequired, 106 | 107 | /** Mr. Deez 108 | * Nuts 109 | * */ 110 | nuts: PropTypes.oneOf(["deez", "deeez"]) 111 | }; 112 | 113 | render() { 114 | return ""; 115 | } 116 | } 117 | `, 118 | }); 119 | 120 | return expect(metadataParser(fakeFs.dir + '/class-with-props.js')).resolves.toEqual({ 121 | description: '', 122 | methods: [], 123 | displayName: 'Component', 124 | props: { 125 | hello: { 126 | description: 'hello comment', 127 | required: false, 128 | type: { name: 'bool' }, 129 | }, 130 | 131 | goodbye: { 132 | description: 'goodbye comment', 133 | required: true, 134 | type: { name: 'string' }, 135 | }, 136 | 137 | nuts: { 138 | description: 'Mr. Deez\n Nuts', 139 | required: false, 140 | type: { 141 | name: 'enum', 142 | value: [{ computed: false, value: '"deez"' }, { computed: false, value: '"deeez"' }], 143 | }, 144 | }, 145 | }, 146 | }); 147 | }); 148 | }); 149 | 150 | describe('that has spread props', () => { 151 | it('should return correct object for functional component', () => { 152 | const fakeFs = cista({ 153 | 'spread-functional.js': `import React from "react"; 154 | import PropTypes from "prop-types"; 155 | import moreProps from "./more-props.js"; 156 | import evenMoreProps from "./even-more-props.js"; 157 | const component = () =>
Hello World!
; 158 | component.propTypes = { 159 | ...moreProps.propTypes, 160 | ...evenMoreProps, 161 | shapeProp: PropTypes.shape({ 162 | stringProp: PropTypes.string, 163 | funcProp: PropTypes.func.isRequired 164 | }) 165 | }; 166 | export default component; 167 | `, 168 | 169 | 'more-props.js': ` 170 | import React from "react"; 171 | import PropTypes from "prop-types"; 172 | const component = ({propFromAnotherFile}) =>
; 173 | component.propTypes = { 174 | propFromAnotherFile: PropTypes.bool.isRequired 175 | }; 176 | export default component; 177 | `, 178 | 179 | 'even-more-props.js': `import React from "react"; 180 | import PropTypes from "prop-types"; 181 | import goDeeperProps from "./go-deeper-props.js"; 182 | const component = ({ propFromYetAnotherFile }) =>
; 183 | component.propTypes = { 184 | ...goDeeperProps.propTypes, 185 | propFromYetAnotherFile: PropTypes.string.isRequired 186 | }; 187 | export default component; 188 | `, 189 | 190 | 'go-deeper-props.js': `import React from "react"; 191 | import PropTypes from "prop-types"; 192 | const component = ({ propFromDeep }) =>
; 193 | component.propTypes = { 194 | propFromDeep: PropTypes.string.isRequired 195 | }; 196 | export default component; 197 | `, 198 | }); 199 | 200 | return expect(metadataParser(fakeFs.dir + '/spread-functional.js')).resolves.toEqual({ 201 | ...rootMock, 202 | displayName: 'component', 203 | props: { 204 | propFromAnotherFile: { 205 | description: '', 206 | type: { 207 | name: 'bool', 208 | }, 209 | required: true, 210 | }, 211 | propFromYetAnotherFile: { 212 | description: '', 213 | type: { 214 | name: 'string', 215 | }, 216 | required: true, 217 | }, 218 | shapeProp: { 219 | description: '', 220 | required: false, 221 | type: { 222 | name: 'shape', 223 | value: { 224 | funcProp: { 225 | name: 'func', 226 | required: true, 227 | }, 228 | stringProp: { 229 | name: 'string', 230 | required: false, 231 | }, 232 | }, 233 | }, 234 | }, 235 | propFromDeep: { 236 | description: '', 237 | type: { 238 | name: 'string', 239 | }, 240 | required: true, 241 | }, 242 | }, 243 | }); 244 | }); 245 | }); 246 | }); 247 | 248 | describe('with `export default from ...`', () => { 249 | it('should follow that export', () => { 250 | const fakeFs = cista({ 251 | 'index.js': 'export {default} from "./component.js";', 252 | 253 | 'component.js': `/** I am the one who props */ 254 | const component = () =>
; 255 | export default component;`, 256 | }); 257 | 258 | return expect(metadataParser(fakeFs.dir + '/index.js')).resolves.toEqual({ 259 | ...rootMock, 260 | displayName: 'component', 261 | description: 'I am the one who props', 262 | }); 263 | }); 264 | 265 | it('should follow many nested exports', () => { 266 | const fakeFs = cista({ 267 | 'index.js': 'export {default} from "./sibling.js"', 268 | 'sibling.js': 'export {default} from "./nested/deep/component.js"', 269 | 'nested/deep/component.js': 'export {default} from "../component.js"', 270 | 'nested/component.js': 271 | '/** You got me */\n const component = () =>
;\n export default component;', 272 | }); 273 | 274 | return expect(metadataParser(fakeFs.dir + '/index.js')).resolves.toEqual({ 275 | ...rootMock, 276 | displayName: 'component', 277 | description: 'You got me', 278 | }); 279 | }); 280 | 281 | it('should follow a mix of proxy modules and components', () => { 282 | const fakeFs = cista({ 283 | 'MyComponent/index.js': 'export {default} from "./implementation";', 284 | 'MyComponent/implementation.js': 285 | 'import React from "react";\n import Proxied from "../AnotherComponent/implementation";\n export default class MyComponent extends React.Component {\n static propTypes = {\n ...Proxied.propTypes\n }\n render() {\n return (
);\n }\n }\n ', 286 | 'AnotherComponent/implementation.js': 287 | 'import PropTypes from "prop-types";\n const component = () =>
;\n component.propTypes = {\n exportedProp: PropTypes.string.isRequired\n };\n export default component;', 288 | }); 289 | 290 | return expect(metadataParser(fakeFs.dir + '/MyComponent/index.js')).resolves.toEqual({ 291 | description: '', 292 | displayName: 'MyComponent', 293 | methods: [], 294 | props: { 295 | exportedProp: { 296 | description: '', 297 | required: true, 298 | type: { 299 | name: 'string', 300 | }, 301 | }, 302 | }, 303 | }); 304 | }); 305 | }); 306 | 307 | describe("with `module.exports = require('path')`", () => { 308 | it('should follow that export', () => { 309 | const fakeFs = cista({ 310 | 'index.js': 'module.exports = require("./component")', 311 | 'component.js': ` 312 | import React from "react"; 313 | /** looking for you */ 314 | const component = () =>
; 315 | export default component; 316 | `, 317 | }); 318 | 319 | return expect(metadataParser(fakeFs.dir + '/index.js')).resolves.toEqual({ 320 | ...rootMock, 321 | displayName: 'component', 322 | description: 'looking for you', 323 | }); 324 | }); 325 | 326 | it('should remove `dist/` from path', () => { 327 | // TODO: yeah well removing `dist` is quite an assumption 328 | const fakeFs = cista({ 329 | 'index.js': 'module.exports = require("./dist/src/component")', 330 | 331 | 'src/component.jsx': ` 332 | import React from "react"; 333 | /** what a lovely day */ 334 | const component = () =>
; 335 | export default component; 336 | `, 337 | }); 338 | 339 | return expect(metadataParser(fakeFs.dir + '/index.js')).resolves.toEqual({ 340 | ...rootMock, 341 | displayName: 'component', 342 | description: 'what a lovely day', 343 | }); 344 | }); 345 | }); 346 | 347 | describe('with non `index.js` entry', () => { 348 | it('should resolve entry file correctly', () => { 349 | const fakeFs = cista({ 350 | 'index.js': 'export {default} from "./Component"', 351 | 'Component.jsx': `import React from "react"; 352 | /** jsx component */ 353 | export default () =>
;`, 354 | }); 355 | 356 | return expect(metadataParser(fakeFs.dir + '/index.js')).resolves.toEqual({ 357 | ...rootMock, 358 | description: 'jsx component', 359 | }); 360 | }); 361 | }); 362 | 363 | describe('with source containing decorators', () => { 364 | it('should not fail parsing', () => { 365 | const fakeFs = cista({ 366 | 'index.js': `import React from "react"; 367 | /** jsx component */ 368 | @Inject("formState") 369 | @Observer 370 | class ILikeTurtles extends React.Component {} 371 | export default ILikeTurtles;`, 372 | }); 373 | 374 | return expect(metadataParser(fakeFs.dir + '/index.js')).resolves.toEqual({ 375 | ...rootMock, 376 | description: 'jsx component', 377 | displayName: 'ILikeTurtles', 378 | }); 379 | }); 380 | }); 381 | 382 | describe('with source containing dynamic imports', () => { 383 | it('should not fail parsing', () => { 384 | const fakeFs = cista({ 385 | 'index.js': `import React from "react"; 386 | /** component description */ 387 | class ILikeWaffles extends React.Component {} 388 | ILikeWaffles.compoundComponent = () => import("./path"); 389 | export default ILikeWaffles;`, 390 | }); 391 | 392 | return expect(metadataParser(fakeFs.dir + '/index.js')).resolves.toEqual({ 393 | ...rootMock, 394 | description: 'component description', 395 | displayName: 'ILikeWaffles', 396 | methods: [expect.objectContaining({ name: 'compoundComponent' })], 397 | }); 398 | }); 399 | }); 400 | 401 | describe('with jsdoc descriptions', () => { 402 | it('should add `tags` property to prop with jsdoc annotations', () => { 403 | const fakeFs = cista({ 404 | 'with-jsdoc.js': `import React from "react"; 405 | import PropTypes from "prop-types"; 406 | /** component description */ 407 | const deprecationIsMyMedication = () =>
; 408 | deprecationIsMyMedication.propTypes = { 409 | /** deprecated prop comment 410 | * @deprecated 411 | * */ 412 | deprecatedProp: PropTypes.bool, 413 | 414 | /** deprecated with text 415 | * @deprecated since forever 416 | * */ 417 | deprecatedWithText: PropTypes.string.isRequired, 418 | }; 419 | export default deprecationIsMyMedication; 420 | `, 421 | }); 422 | 423 | return expect(metadataParser(fakeFs.dir + '/with-jsdoc.js')).resolves.toEqual({ 424 | description: 'component description', 425 | displayName: 'deprecationIsMyMedication', 426 | methods: [], 427 | props: { 428 | deprecatedProp: { 429 | description: 'deprecated prop comment', 430 | required: false, 431 | type: { 432 | name: 'bool', 433 | }, 434 | tags: [{ title: 'deprecated', description: null }], 435 | }, 436 | deprecatedWithText: { 437 | description: 'deprecated with text', 438 | required: true, 439 | type: { 440 | name: 'string', 441 | }, 442 | tags: [{ title: 'deprecated', description: 'since forever' }], 443 | }, 444 | }, 445 | }); 446 | }); 447 | }); 448 | 449 | describe('with @autodocs-component annotation', () => { 450 | it('should force props parsing', () => { 451 | const fakeFs = cista({ 452 | 'with-annotation.js': `import React from "react"; 453 | import PropTypes from "prop-types"; 454 | export const weirdComponent /** @autodocs-component */ = () => () => () => {}; 455 | weirdComponent.propTypes = { 456 | awwyis: PropTypes.bool, 457 | breadcrumbs: PropTypes.string.isRequired, 458 | }; 459 | `, 460 | }); 461 | 462 | return expect(metadataParser(fakeFs.dir + '/with-annotation.js')).resolves.toEqual({ 463 | description: '', 464 | displayName: 'weirdComponent', 465 | methods: [], 466 | props: { 467 | awwyis: { 468 | description: '', 469 | required: false, 470 | type: { 471 | name: 'bool', 472 | }, 473 | }, 474 | breadcrumbs: { 475 | description: '', 476 | required: true, 477 | type: { 478 | name: 'string', 479 | }, 480 | }, 481 | }, 482 | }); 483 | }); 484 | }); 485 | }); 486 | 487 | describe('given component importing from other modules', () => { 488 | it('should resolve node_modules path', () => { 489 | const fakeFs = cista({ 490 | 'MyComponent/index.js': `export {default} from "wix-ui-backoffice/Component"`, 491 | 'node_modules/wix-ui-backoffice/Component/index.js': `export {default} from "./Component.js"`, 492 | 'node_modules/wix-ui-backoffice/Component/Component.js': `import React from "react";\n /** backoffice component */\n export default () =>
;`, 493 | }); 494 | 495 | return expect(metadataParser(fakeFs.dir + '/MyComponent/index.js')).resolves.toEqual({ 496 | ...rootMock, 497 | description: 'backoffice component', 498 | }); 499 | }); 500 | 501 | it('should resolve deep node_modules path', () => { 502 | const fakeFs = cista({ 503 | 'MyComponent/index.js': `export {default} from "wix-ui-backoffice/Component"`, 504 | 505 | 'node_modules/wix-ui-backoffice/Component/index.js': `export {default} from "../src/components/Component"`, 506 | 507 | 'node_modules/wix-ui-backoffice/src/components/Component.js': `import React from "react"; 508 | import CoreProps from "wix-ui-core/Component"; 509 | /** backoffice component */ 510 | const component = () =>
; 511 | component.propTypes = { 512 | ...CoreProps 513 | } 514 | export default component;`, 515 | 516 | 'node_modules/wix-ui-backoffice/node_modules/wix-ui-core/Component.jsx': `import React from "react" 517 | import PropTypes from "prop-types"; 518 | const component = () =>
; 519 | component.propTypes = { 520 | /** hello from core */ 521 | coreProp: PropTypes.func 522 | }; 523 | export default component;`, 524 | }); 525 | 526 | return expect(metadataParser(fakeFs.dir + '/MyComponent/index.js')).resolves.toEqual({ 527 | description: 'backoffice component', 528 | methods: [], 529 | displayName: 'component', 530 | props: { 531 | coreProp: { 532 | required: false, 533 | description: 'hello from core', 534 | type: { name: 'func' }, 535 | }, 536 | }, 537 | }); 538 | }); 539 | }); 540 | }); 541 | -------------------------------------------------------------------------------- /src/metadata-parser/tests/ts.test.js: -------------------------------------------------------------------------------- 1 | /* global describe it expect */ 2 | 3 | const { join: pathJoin } = require('path'); 4 | const metadataParser = require('../'); 5 | 6 | const fixturePath = path => pathJoin(__dirname, '__fixtures__', path); 7 | 8 | describe('given component written in typescript', () => { 9 | it('should parse metadata', () => 10 | expect(metadataParser(fixturePath('simple.ts'))).resolves.toEqual({ 11 | description: 'This is the component', 12 | displayName: 'Component', 13 | methods: [], 14 | props: { 15 | text: expect.objectContaining({ 16 | name: 'text', 17 | defaultValue: null, 18 | required: false, 19 | description: 'this is a text prop', 20 | type: { name: 'string' }, 21 | }), 22 | }, 23 | })); 24 | 25 | it('should parse metadata', () => 26 | expect(metadataParser(fixturePath('heading.tsx'))).resolves.toEqual({ 27 | description: '', 28 | displayName: 'Heading', 29 | methods: [], 30 | props: { 31 | skin: expect.objectContaining({ 32 | name: 'skin', 33 | defaultValue: { value: 'dark' }, 34 | required: false, 35 | description: 'skin color of the heading', 36 | type: { name: 'Skin' }, 37 | }), 38 | appearance: expect.objectContaining({ 39 | name: 'appearance', 40 | defaultValue: { value: 'H1' }, 41 | required: false, 42 | description: 'typography of the heading', 43 | type: { name: 'Appearance' }, 44 | }), 45 | }, 46 | })); 47 | }); 48 | -------------------------------------------------------------------------------- /src/parse-jsdoc/index.js: -------------------------------------------------------------------------------- 1 | /* global Promise */ 2 | const doctrine = require('doctrine'); 3 | 4 | const iterateProps = props => fn => 5 | Object.entries(props).reduce( 6 | (finalProps, [propName, prop]) => ({ 7 | ...finalProps, 8 | [propName]: fn(prop), 9 | }), 10 | {} 11 | ); 12 | 13 | const parseJSDoc = props => 14 | new Promise(resolve => { 15 | const parsedProps = iterateProps(props)(prop => { 16 | const { description, tags } = doctrine.parse(prop.description || '', { unwrap: true }); 17 | 18 | return tags.length ? { ...prop, description, tags } : prop; 19 | }); 20 | 21 | resolve(parsedProps); 22 | }); 23 | 24 | module.exports = parseJSDoc; 25 | -------------------------------------------------------------------------------- /src/parse-jsdoc/index.test.js: -------------------------------------------------------------------------------- 1 | /* global Promise */ 2 | 3 | const parseJSDoc = require('.'); 4 | 5 | describe('parseJSDoc', () => { 6 | it('should be defined', () => { 7 | expect(typeof parseJSDoc).toEqual('function'); 8 | }); 9 | 10 | it('should return promise', () => { 11 | expect(parseJSDoc() instanceof Promise).toEqual(true); 12 | }); 13 | 14 | describe('given object of props', () => { 15 | it('should resolve with same shape of input object', () => { 16 | const props = { 17 | someProp: { 18 | description: '', 19 | type: { 20 | name: 'string', 21 | }, 22 | required: true, 23 | }, 24 | }; 25 | 26 | return expect(parseJSDoc(props)).resolves.toEqual(props); 27 | }); 28 | }); 29 | 30 | describe('given object of props with descriptions', () => { 31 | it('should add `tags` to prop object', () => { 32 | const props = { 33 | one: { 34 | description: `@deprecated`, 35 | type: { 36 | name: 'string', 37 | }, 38 | required: true, 39 | }, 40 | 41 | oneWithDescription: { 42 | description: `@deprecated since forever`, 43 | type: { 44 | name: 'string', 45 | }, 46 | required: true, 47 | }, 48 | 49 | multiple: { 50 | description: ` 51 | hello there 52 | @function multiple 53 | @deprecated 54 | `, 55 | type: { 56 | name: 'function', 57 | }, 58 | required: true, 59 | }, 60 | }; 61 | 62 | return expect(parseJSDoc(props)).resolves.toEqual( 63 | expect.objectContaining({ 64 | one: expect.objectContaining({ 65 | tags: [{ description: null, title: 'deprecated' }], 66 | }), 67 | 68 | oneWithDescription: expect.objectContaining({ 69 | tags: [{ description: 'since forever', title: 'deprecated' }], 70 | }), 71 | 72 | multiple: expect.objectContaining({ 73 | tags: [ 74 | { description: null, title: 'function', name: 'multiple' }, 75 | { description: null, title: 'deprecated' }, 76 | ], 77 | }), 78 | }) 79 | ); 80 | }); 81 | }); 82 | 83 | describe('given object of props without descriptions', () => { 84 | it('should resolve with same shape of input object', () => { 85 | const props = { 86 | someProp: { 87 | type: { 88 | name: 'string', 89 | }, 90 | required: true, 91 | }, 92 | }; 93 | 94 | return expect(parseJSDoc(props)).resolves.toEqual(props); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/parser/component-resolve.js: -------------------------------------------------------------------------------- 1 | const recast = require('recast'); 2 | const { utils } = require('react-docgen'); 3 | 4 | const { 5 | isExportsOrModuleAssignment, 6 | isReactComponentClass, 7 | isReactCreateClassCall, 8 | isStatelessComponent, 9 | normalizeClassDefinition, 10 | resolveExportDeclaration, 11 | resolveToValue, 12 | } = utils; 13 | 14 | const n = recast.types.namedTypes; 15 | 16 | const ERROR_MULTIPLE_DEFINITIONS = 'Multiple exported component definitions found.'; 17 | 18 | const isReactComponentExtendedClass = path => { 19 | const node = path.node; 20 | 21 | if (!n.ClassDeclaration.check(node) && !n.ClassExpression.check(node)) { 22 | return false; 23 | } 24 | 25 | if (!node.superClass) { 26 | return false; 27 | } 28 | 29 | return true; 30 | }; 31 | 32 | const isAnnotatedComponent = path => { 33 | const leadingComments = path.node.leadingComments; 34 | if (!leadingComments) { 35 | return false; 36 | } 37 | 38 | // Search for the @autodocs-component in identifier comment 39 | return leadingComments.some(comment => comment.value.includes('@autodocs-component')); 40 | }; 41 | 42 | const isComponentDefinition = path => 43 | [ 44 | isReactCreateClassCall, 45 | isReactComponentClass, 46 | isReactComponentExtendedClass, 47 | isStatelessComponent, 48 | isAnnotatedComponent, 49 | ].some(fn => fn(path)); 50 | 51 | const resolveHOC = path => { 52 | const node = path.node; 53 | 54 | if (n.CallExpression.check(node) && !isReactCreateClassCall(path)) { 55 | if (node.arguments.length) { 56 | return resolveHOC(path.get('arguments', node.arguments.length - 1)); 57 | } 58 | } 59 | 60 | return path; 61 | }; 62 | 63 | const resolveDefinition = definition => { 64 | if (isReactCreateClassCall(definition)) { 65 | // return argument 66 | const resolvedPath = resolveToValue(definition.get('arguments', 0)); 67 | if (n.ObjectExpression.check(resolvedPath.node)) { 68 | return resolvedPath; 69 | } 70 | } else if (isReactComponentClass(definition) || isReactComponentExtendedClass(definition)) { 71 | normalizeClassDefinition(definition); 72 | return definition; 73 | } else if (isStatelessComponent(definition)) { 74 | return definition; 75 | } else if (isAnnotatedComponent) { 76 | return definition; 77 | } 78 | 79 | return null; 80 | }; 81 | 82 | const componentResolver = (ast, recast) => { 83 | let definition; 84 | 85 | const exportDeclaration = path => { 86 | const definitions = resolveExportDeclaration(path).reduce((acc, definition) => { 87 | if (isComponentDefinition(definition)) { 88 | acc.push(definition); 89 | } else { 90 | const resolved = resolveToValue(resolveHOC(definition)); 91 | if (isComponentDefinition(resolved)) { 92 | acc.push(resolved); 93 | } 94 | } 95 | 96 | return acc; 97 | }, []); 98 | 99 | if (definitions.length === 0) { 100 | return false; 101 | } 102 | 103 | if (definitions.length > 1 || definition) { 104 | // If a file exports multiple components, ... complain! 105 | throw new Error(ERROR_MULTIPLE_DEFINITIONS); 106 | } 107 | 108 | definition = resolveDefinition(definitions[0]); 109 | return false; 110 | }; 111 | 112 | recast.visit(ast, { 113 | visitExportDeclaration: exportDeclaration, 114 | visitExportNamedDeclaration: exportDeclaration, 115 | visitExportDefaultDeclaration: exportDeclaration, 116 | 117 | visitAssignmentExpression: path => { 118 | // Ignore anything that is not `exports.X = ...;` or 119 | // `module.exports = ...;` 120 | if (!isExportsOrModuleAssignment(path)) { 121 | return false; 122 | } 123 | 124 | // Resolve the value of the right hand side. It should resolve to a call 125 | // expression, something like React.createClass 126 | path = resolveToValue(path.get('right')); 127 | 128 | if (!isComponentDefinition(path)) { 129 | path = resolveToValue(resolveHOC(path)); 130 | if (!isComponentDefinition(path)) { 131 | return false; 132 | } 133 | } 134 | 135 | if (definition) { 136 | // If a file exports multiple components, ... complain! 137 | throw new Error(ERROR_MULTIPLE_DEFINITIONS); 138 | } 139 | 140 | definition = resolveDefinition(path); 141 | 142 | return false; 143 | }, 144 | }); 145 | 146 | return definition; 147 | }; 148 | 149 | module.exports = componentResolver; 150 | -------------------------------------------------------------------------------- /src/parser/index.js: -------------------------------------------------------------------------------- 1 | /* global Promise */ 2 | 3 | const followExports = require('../follow-exports'); 4 | const followProps = require('../follow-props'); 5 | const parseJSDoc = require('../parse-jsdoc'); 6 | 7 | const parser = ({ source, path }) => 8 | followExports(source, path) 9 | .then(followProps) 10 | .then(async metadata => ({ 11 | ...metadata, 12 | props: await parseJSDoc(metadata.props), 13 | })); 14 | 15 | module.exports = parser; 16 | -------------------------------------------------------------------------------- /src/parser/parse.js: -------------------------------------------------------------------------------- 1 | const babelParser = require('@babel/parser'); 2 | 3 | const parse = source => 4 | babelParser.parse(source, { 5 | plugins: [ 6 | ['decorators', { decoratorsBeforeExport: true }], 7 | 'jsx', 8 | 'typescript', 9 | 'classProperties', 10 | 'objectRestSpread', 11 | 'dynamicImport', 12 | ], 13 | sourceType: 'module', 14 | }); 15 | 16 | module.exports = parse; 17 | -------------------------------------------------------------------------------- /src/parser/print.js: -------------------------------------------------------------------------------- 1 | const generate = require('@babel/generator').default; 2 | 3 | const print = ast => generate(ast).code; 4 | 5 | module.exports = print; 6 | -------------------------------------------------------------------------------- /src/parser/react-docgen-parse.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const javascriptParser = require('react-docgen'); 3 | const typescriptParser = require('react-docgen-typescript'); 4 | 5 | const isTypescriptPath = require('../is-typescript-path'); 6 | const componentResolve = require('./component-resolve'); 7 | 8 | const ensurePropsKey = object => ({ props: {}, ...object }); 9 | 10 | const parseTypescript = path => ensurePropsKey(typescriptParser.parse(path)[0] || {}); // react-docgen-typescript returns array, so 11 | 12 | const parseJavascript = (source = '', sourcePath = '') => { 13 | let parsed; 14 | 15 | try { 16 | parsed = javascriptParser.parse(source, componentResolve, null, { 17 | filename: path.basename(sourcePath), 18 | }); 19 | } catch (e) { 20 | parsed = {}; 21 | } 22 | 23 | return ensurePropsKey(parsed); 24 | }; 25 | 26 | const reactDocgenParse = ({ source = '', path = '' }) => 27 | isTypescriptPath(path) ? parseTypescript(path) : parseJavascript(source, path); 28 | 29 | module.exports = { reactDocgenParse, parseJavascript, parseTypescript }; 30 | -------------------------------------------------------------------------------- /src/parser/react-docgen-parse.test.js: -------------------------------------------------------------------------------- 1 | /* global describe it expect jest */ 2 | 3 | const { reactDocgenParse } = require('./react-docgen-parse'); 4 | 5 | describe('reactDocgenParse', () => { 6 | describe('given source containing unknown component shape', () => { 7 | it('should return object with empty props key', () => { 8 | const source = 'export default 42;'; 9 | expect(reactDocgenParse({ source })).toEqual({ props: {} }); 10 | }); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/parser/visit.js: -------------------------------------------------------------------------------- 1 | const traverse = require('@babel/traverse').default; 2 | 3 | const visit = ast => visitor => traverse(ast, visitor); 4 | 5 | module.exports = visit; 6 | -------------------------------------------------------------------------------- /src/path-finder/index.js: -------------------------------------------------------------------------------- 1 | /* global Promise */ 2 | 3 | const types = require('@babel/types'); 4 | 5 | const parse = require('../parser/parse'); 6 | const visit = require('../parser/visit'); 7 | const get = require('../get'); 8 | 9 | const extractKeyFromObject = objectExpression => key => 10 | objectExpression.properties.find(({ key: { name } }) => name === key); 11 | 12 | const pathFinder = (source = '') => { 13 | const ast = parse(source); 14 | 15 | return new Promise((resolve, reject) => { 16 | const extractComponentPath = (path, node) => { 17 | if (types.isObjectExpression(node)) { 18 | const getValue = key => extractKeyFromObject(node)(key); 19 | const componentPath = getValue('componentPath'); 20 | 21 | if (componentPath) { 22 | return resolve(componentPath.value.value); 23 | } else { 24 | const componentReference = get(getValue('component'))('value.name'); 25 | 26 | if (!componentReference) { 27 | return resolve(null); 28 | } 29 | 30 | visit(ast)({ 31 | ImportDeclaration(path) { 32 | const componentPath = get(path)('node.specifiers').find( 33 | ({ local: { name } }) => name === componentReference 34 | ); 35 | 36 | if (componentPath) { 37 | resolve(get(path)('node.source.value')); 38 | return false; 39 | } 40 | }, 41 | }); 42 | 43 | resolve(componentReference); 44 | } 45 | } 46 | 47 | if (types.isIdentifier(node)) { 48 | const binding = path.scope.bindings[node.name]; 49 | 50 | if (binding) { 51 | visit(ast)({ 52 | VariableDeclarator(path) { 53 | if (types.isIdentifier(binding.identifier, { name: binding.identifier.name })) { 54 | const componentPath = extractKeyFromObject(path.node.init)('componentPath').value.value; 55 | resolve(componentPath); 56 | } 57 | }, 58 | }); 59 | } 60 | } 61 | }; 62 | 63 | visit(ast)({ 64 | ExportDefaultDeclaration(path) { 65 | extractComponentPath(path, path.node.declaration); 66 | }, 67 | 68 | ExpressionStatement(path) { 69 | const isModuleExports = [ 70 | types.isMemberExpression(path.node.expression.left), 71 | get(path)('node.expression.left.object.name') === 'module', 72 | get(path)('node.expression.left.property.name') === 'exports', 73 | ].every(Boolean); 74 | 75 | if (isModuleExports) { 76 | extractComponentPath(path, path.node.expression.right); 77 | } 78 | }, 79 | }); 80 | 81 | reject(new Error('ERROR: Unable to resolve path to component')); 82 | }); 83 | }; 84 | 85 | module.exports = pathFinder; 86 | -------------------------------------------------------------------------------- /src/path-finder/index.test.js: -------------------------------------------------------------------------------- 1 | /* global it describe expect */ 2 | 3 | const pathFinder = require('./'); 4 | 5 | describe('pathFinder()', () => { 6 | describe('given `componentPath`', () => { 7 | const path = '.' + 'hello'.repeat(Math.random() * 19); 8 | 9 | const sourceTestCases = [ 10 | `export default { componentPath: '${path}' }`, 11 | 12 | `const config = { 13 | componentPath: '${path}' 14 | }; 15 | export default config; 16 | `, 17 | ]; 18 | 19 | sourceTestCases.map(source => 20 | it('should resolve promise with value of `componentPath`', () => 21 | expect(pathFinder(source)).resolves.toEqual(path)) 22 | ); 23 | }); 24 | 25 | describe('given `componentPath` in `module.exports`', () => { 26 | const path = '.' + 'hello'.repeat(Math.random() * 19); 27 | 28 | const sourceTestCases = [ 29 | `module.exports = { componentPath: '${path}' }`, 30 | 31 | `const config = { 32 | componentPath: '${path}' 33 | }; 34 | module.exports = config; 35 | `, 36 | ]; 37 | 38 | sourceTestCases.map(source => 39 | it('should resolve promise with value of `componentPath`', () => 40 | expect(pathFinder(source)).resolves.toEqual(path)) 41 | ); 42 | }); 43 | 44 | describe('given incomplete story config', () => { 45 | it('should return null', () => { 46 | const source = 'export default { sections: [] };'; 47 | return expect(pathFinder(source)).resolves.null; 48 | }); 49 | }); 50 | 51 | describe('given `component` without `componentPath`', () => { 52 | describe('when `component` is imported', () => { 53 | const path = './path'; 54 | const sourceTestCases = [ 55 | `import Component from '${path}'; 56 | export default { 57 | component: Component 58 | }`, 59 | 60 | `import * as Component from '${path}' 61 | export default { 62 | component: Component 63 | }`, 64 | 65 | `import {Component} from '${path}' 66 | export default { 67 | component: Component 68 | }`, 69 | 70 | `import {Component, Something, Different} from '${path}' 71 | export default { 72 | component: Component 73 | }`, 74 | 75 | `import {Component as ComponentAlias} from '${path}' 76 | export default { 77 | component: ComponentAlias 78 | }`, 79 | 80 | `import defaultExport, {Component as ComponentAlias} from '${path}' 81 | export default { 82 | component: ComponentAlias 83 | }`, 84 | ]; 85 | 86 | sourceTestCases.map(source => 87 | it('should resolve promise with path to component', () => expect(pathFinder(source)).resolves.toEqual(path)) 88 | ); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/prepare-story/index.js: -------------------------------------------------------------------------------- 1 | /* global Promise */ 2 | 3 | const types = require('@babel/types'); 4 | const visit = require('../parser/visit'); 5 | const parse = require('../parser/parse'); 6 | const print = require('../parser/print'); 7 | const get = require('../get'); 8 | 9 | const prepareStory = storyConfig => source => 10 | new Promise((resolve, reject) => 11 | source && !!storyConfig 12 | ? resolve(source) 13 | : reject('ERROR: unable to prepare story, both `storyConfig` and `source` must be provided') 14 | ) 15 | 16 | .then(parse) 17 | 18 | .then(ast => { 19 | let isES5 = true; 20 | 21 | visit(ast)({ 22 | ExportDefaultDeclaration() { 23 | isES5 = false; 24 | return false; 25 | }, 26 | }); 27 | 28 | if (isES5) { 29 | // add requires 30 | ast.program.body.unshift(parse('const { storiesOf } = require("@storybook/react")')); 31 | ast.program.body.unshift(parse('const story = require("wix-storybook-utils/Story").default')); 32 | } else { 33 | // add imports 34 | ast.program.body.unshift(parse('import { storiesOf } from "@storybook/react"')); 35 | ast.program.body.unshift(parse('import story from "wix-storybook-utils/Story"')); 36 | } 37 | 38 | return ast; 39 | }) 40 | 41 | .then(ast => { 42 | // TODO: this is not too good, unfortunatelly, i cant return 43 | // rejected promise from within visitor, babylon complains 44 | let error = null; 45 | 46 | const configAST = parse(`(${JSON.stringify(storyConfig)})`); 47 | let configProperties; 48 | 49 | visit(configAST)({ 50 | ObjectExpression(path) { 51 | const storiesOfProperty = types.objectProperty(types.identifier('storiesOf'), types.identifier('storiesOf')); 52 | 53 | path.node.properties.push(storiesOfProperty); 54 | 55 | configProperties = path.node.properties; 56 | path.stop(); 57 | }, 58 | }); 59 | 60 | const handleExportObject = (path, node) => { 61 | const exportsObject = types.isObjectExpression(node); 62 | const exportsIdentifier = types.isIdentifier(node); 63 | 64 | if (exportsIdentifier) { 65 | const referenceName = node.name; 66 | const configObject = path.scope.bindings[referenceName].path.node.init; 67 | 68 | if (!configObject.properties) { 69 | error = `ERROR: storybook config must export an object, exporting ${configObject.type} instead`; 70 | return false; 71 | } 72 | 73 | configObject.properties.push( 74 | types.objectProperty(types.identifier('_config'), types.objectExpression(configProperties)) 75 | ); 76 | 77 | return types.callExpression(types.identifier('story'), [node]); 78 | } 79 | 80 | if (exportsObject) { 81 | node.properties.push( 82 | types.objectProperty(types.identifier('_config'), types.objectExpression(configProperties)) 83 | ); 84 | 85 | // wrap exported object with `story()` 86 | return types.callExpression(types.identifier('story'), [node]); 87 | } 88 | }; 89 | 90 | visit(ast)({ 91 | ExportDefaultDeclaration(path) { 92 | path.node.declaration = handleExportObject(path, path.node.declaration); 93 | return false; 94 | }, 95 | 96 | ExpressionStatement(path) { 97 | const isModuleExports = [ 98 | types.isMemberExpression(path.node.expression.left), 99 | get(path)('node.expression.left.object.name') === 'module', 100 | get(path)('node.expression.left.property.name') === 'exports', 101 | ].every(Boolean); 102 | 103 | if (isModuleExports) { 104 | path.node.expression.right = handleExportObject(path, path.node.expression.right); 105 | } 106 | }, 107 | }); 108 | 109 | return error ? Promise.reject(error) : ast; 110 | }) 111 | 112 | .then(print); 113 | 114 | module.exports = prepareStory; 115 | -------------------------------------------------------------------------------- /src/prepare-story/index.test.js: -------------------------------------------------------------------------------- 1 | /* global describe it expect */ 2 | 3 | const prepareStory = require('./'); 4 | 5 | describe('prepareStory', () => { 6 | describe('given erroneous input', () => { 7 | it('should reject promise with message', () => 8 | expect(prepareStory()()).rejects.toEqual( 9 | 'ERROR: unable to prepare story, both `storyConfig` and `source` must be provided' 10 | )); 11 | }); 12 | 13 | describe('with 2 curried calls', () => { 14 | it('should return promise', () => { 15 | expect(prepareStory({})('test').then).toBeDefined(); 16 | }); 17 | 18 | it('should reject with error when exported config is not an object', () => { 19 | const source = `const something = "hello"; 20 | export default something;`; 21 | 22 | return expect(prepareStory({})(source)).rejects.toMatch('ERROR'); 23 | }); 24 | 25 | it('should wrap exported object with `story()`', () => { 26 | const source = 'export default { a: 1 };'; 27 | const expectation = `import story from "wix-storybook-utils/Story"; 28 | 29 | import { storiesOf } from "@storybook/react"; 30 | 31 | export default story({ 32 | a: 1, 33 | _config: { 34 | storiesOf: storiesOf 35 | } 36 | });`; 37 | 38 | return expect(prepareStory({})(source)).resolves.toEqual(expectation); 39 | }); 40 | 41 | it('should add _config to exported object', () => { 42 | const source = 'export default { a: 1 };'; 43 | const config = { a: 1 }; 44 | const expectation = `import story from "wix-storybook-utils/Story"; 45 | 46 | import { storiesOf } from "@storybook/react"; 47 | 48 | export default story({ 49 | a: 1, 50 | _config: { 51 | "a": 1, 52 | storiesOf: storiesOf 53 | } 54 | });`; 55 | 56 | return expect(prepareStory(config)(source)).resolves.toEqual(expectation); 57 | }); 58 | 59 | it('should work with referenced story config', () => { 60 | const source = ` 61 | const config = { a: 1, b: { c: 'hey' } }; 62 | export default config; 63 | `; 64 | const config = { hello: 'config!', time: { to: { say: { good: 'buy' } } } }; 65 | const expectation = `import story from "wix-storybook-utils/Story"; 66 | 67 | import { storiesOf } from "@storybook/react"; 68 | 69 | const config = { 70 | a: 1, 71 | b: { 72 | c: 'hey' 73 | }, 74 | _config: { 75 | "hello": "config!", 76 | "time": { 77 | "to": { 78 | "say": { 79 | "good": "buy" 80 | } 81 | } 82 | }, 83 | storiesOf: storiesOf 84 | } 85 | }; 86 | export default story(config);`; 87 | 88 | return expect(prepareStory(config)(source)).resolves.toEqual(expectation); 89 | }); 90 | 91 | it('should work with spread properties', () => { 92 | const source = ` 93 | const stuff = { thing: { moreThings: ['hello'] } }; 94 | export default { 95 | a: 1, 96 | b: { 97 | ...stuff, 98 | c: ['d'] 99 | } 100 | }; 101 | `; 102 | 103 | const config = { 'i-am-config': 'yes' }; 104 | 105 | const expectation = `import story from "wix-storybook-utils/Story"; 106 | 107 | import { storiesOf } from "@storybook/react"; 108 | 109 | const stuff = { 110 | thing: { 111 | moreThings: ['hello'] 112 | } 113 | }; 114 | export default story({ 115 | a: 1, 116 | b: { ...stuff, 117 | c: ['d'] 118 | }, 119 | _config: { 120 | "i-am-config": "yes", 121 | storiesOf: storiesOf 122 | } 123 | });`; 124 | 125 | return expect(prepareStory(config)(source)).resolves.toEqual(expectation); 126 | }); 127 | 128 | it('should work with existing nested properties', () => { 129 | const source = ` 130 | const stuff = { thing: { moreThings: ['hello'] } }; 131 | const callMe = () => true; 132 | export default { 133 | a: 1, 134 | b: { 135 | ...stuff, 136 | c: ['d'] 137 | }, 138 | d: { 139 | e: { 140 | f: { 141 | hello: callMe('maybe') 142 | } 143 | } 144 | } 145 | }; 146 | `; 147 | 148 | const config = { 149 | 'i-am-config': 'yes', 150 | nested: { 151 | oh: { 152 | boy: { 153 | thing: 'hey there!', 154 | }, 155 | }, 156 | }, 157 | }; 158 | 159 | const expectation = `import story from "wix-storybook-utils/Story"; 160 | 161 | import { storiesOf } from "@storybook/react"; 162 | 163 | const stuff = { 164 | thing: { 165 | moreThings: ['hello'] 166 | } 167 | }; 168 | 169 | const callMe = () => true; 170 | 171 | export default story({ 172 | a: 1, 173 | b: { ...stuff, 174 | c: ['d'] 175 | }, 176 | d: { 177 | e: { 178 | f: { 179 | hello: callMe('maybe') 180 | } 181 | } 182 | }, 183 | _config: { 184 | "i-am-config": "yes", 185 | "nested": { 186 | "oh": { 187 | "boy": { 188 | "thing": "hey there!" 189 | } 190 | } 191 | }, 192 | storiesOf: storiesOf 193 | } 194 | });`; 195 | 196 | return expect(prepareStory(config)(source)).resolves.toEqual(expectation); 197 | }); 198 | 199 | it('should work with module.exports', () => { 200 | const source = 'module.exports = { a: 1 };'; 201 | const config = { a: 1 }; 202 | const expectation = `const story = require("wix-storybook-utils/Story").default; 203 | 204 | const { 205 | storiesOf 206 | } = require("@storybook/react"); 207 | 208 | module.exports = story({ 209 | a: 1, 210 | _config: { 211 | "a": 1, 212 | storiesOf: storiesOf 213 | } 214 | });`; 215 | 216 | return expect(prepareStory(config)(source)).resolves.toEqual(expectation); 217 | }); 218 | 219 | it('should work with referenced module.exports', () => { 220 | const source = ` 221 | const stuff = { thing: { moreThings: ['hello'] } }; 222 | const callMe = () => true; 223 | const reference = { 224 | a: 1, 225 | b: { 226 | ...stuff, 227 | c: ['d'] 228 | }, 229 | d: { 230 | e: { 231 | f: { 232 | hello: callMe('maybe') 233 | } 234 | } 235 | } 236 | }; 237 | 238 | module.exports = reference; 239 | `; 240 | 241 | const config = { 242 | 'i-am-config': 'yes', 243 | nested: { 244 | oh: { 245 | boy: { 246 | thing: 'hey there!', 247 | }, 248 | }, 249 | }, 250 | }; 251 | 252 | const expectation = `const story = require("wix-storybook-utils/Story").default; 253 | 254 | const { 255 | storiesOf 256 | } = require("@storybook/react"); 257 | 258 | const stuff = { 259 | thing: { 260 | moreThings: ['hello'] 261 | } 262 | }; 263 | 264 | const callMe = () => true; 265 | 266 | const reference = { 267 | a: 1, 268 | b: { ...stuff, 269 | c: ['d'] 270 | }, 271 | d: { 272 | e: { 273 | f: { 274 | hello: callMe('maybe') 275 | } 276 | } 277 | }, 278 | _config: { 279 | "i-am-config": "yes", 280 | "nested": { 281 | "oh": { 282 | "boy": { 283 | "thing": "hey there!" 284 | } 285 | } 286 | }, 287 | storiesOf: storiesOf 288 | } 289 | }; 290 | module.exports = story(reference);`; 291 | 292 | return expect(prepareStory(config)(source)).resolves.toEqual(expectation); 293 | }); 294 | }); 295 | }); 296 | -------------------------------------------------------------------------------- /src/promises/first.js: -------------------------------------------------------------------------------- 1 | /* global Promise */ 2 | 3 | const invert = require('./invert'); 4 | 5 | const first = promises => invert(Promise.all(promises.map(invert))); 6 | 7 | module.exports = first; 8 | -------------------------------------------------------------------------------- /src/promises/invert.js: -------------------------------------------------------------------------------- 1 | /* global Promise */ 2 | 3 | const invert = promise => new Promise((resolve, reject) => promise.then(reject).catch(resolve)); 4 | 5 | module.exports = invert; 6 | -------------------------------------------------------------------------------- /src/promises/promise.js: -------------------------------------------------------------------------------- 1 | /* global Promise */ 2 | 3 | /** 4 | * used to turn node.js callback style to promises 5 | */ 6 | 7 | // promise : Function => (...args) => Promise 8 | const promise = fn => (...args) => 9 | new Promise((resolve, reject) => fn(...args, (err, payload) => (err ? reject(err) : resolve(payload)))); 10 | 11 | module.exports = promise; 12 | -------------------------------------------------------------------------------- /src/promises/tests/first.test.js: -------------------------------------------------------------------------------- 1 | /* global describe it expect Promise */ 2 | 3 | const first = require('../first'); 4 | 5 | describe('promise `first`', () => { 6 | it('should resolve with first resolved value', () => expect(first([Promise.resolve('hey')])).resolves.toEqual('hey')); 7 | 8 | it('should resolve with first even when multiple', () => 9 | expect(first([Promise.resolve('first'), Promise.resolve('second')])).resolves.toEqual('first')); 10 | 11 | it('should ignore rejected', () => 12 | expect( 13 | first([Promise.reject('first'), Promise.reject('second'), Promise.resolve('third'), Promise.reject('fourth')]) 14 | ).resolves.toEqual('third')); 15 | }); 16 | -------------------------------------------------------------------------------- /src/promises/tests/invert.test.js: -------------------------------------------------------------------------------- 1 | /* global describe it expect Promise */ 2 | 3 | const invert = require('../invert'); 4 | 5 | describe('promise `invert`', () => { 6 | it('should resolve given rejected promise', () => 7 | expect(invert(Promise.reject('my man'))).resolves.toEqual('my man')); 8 | 9 | it('should reject given resolved promise', () => 10 | expect(invert(Promise.resolve('my lady'))).rejects.toEqual('my lady')); 11 | }); 12 | -------------------------------------------------------------------------------- /src/promises/tests/promise.test.js: -------------------------------------------------------------------------------- 1 | /* global describe it expect */ 2 | 3 | const promise = require('../promise'); 4 | 5 | const success = (arg, callback) => setImmediate(() => callback(null, arg)); 6 | 7 | const failure = (arg, callback) => setImmediate(() => callback('oh no :(')); 8 | 9 | describe('Promise', () => { 10 | it('should be defined', () => { 11 | expect(typeof promise).toBe('function'); 12 | }); 13 | 14 | describe('when currying function and arguments', () => { 15 | it('should resolve promise when success', () => expect(promise(success)('hello')).resolves.toEqual('hello')); 16 | 17 | it('should reject promise when failure', () => 18 | expect(promise(failure)("anything, doesn't matter")).rejects.toEqual('oh no :(')); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/read-file/index.js: -------------------------------------------------------------------------------- 1 | /* global Promise */ 2 | 3 | const { readFile: fsReadFileAsync, lstat: fsLstat } = require('fs'); 4 | const { join: pathJoin, extname: pathExtname } = require('path'); 5 | 6 | const promise = require('../promises/promise'); 7 | const promiseFirst = require('../promises/first'); 8 | 9 | const fsReadFile = promise(fsReadFileAsync); 10 | const lstat = promise(fsLstat); 11 | 12 | const TYPESCRIPT_EXT = ['.ts', '.tsx']; 13 | const SUPPORTED_FILE_EXT = ['.js', '.jsx', ...TYPESCRIPT_EXT]; 14 | 15 | const isTypescript = path => TYPESCRIPT_EXT.includes(pathExtname(path)); 16 | 17 | const tryReadWithExtension = entryPath => 18 | promiseFirst( 19 | SUPPORTED_FILE_EXT.map(extension => { 20 | const path = entryPath + extension; 21 | 22 | return fsReadFile(path, 'utf8').then(source => ({ source, path })); 23 | }) 24 | ); 25 | 26 | const isDir = path => 27 | lstat(path) 28 | .then(stats => stats.isDirectory()) 29 | .catch(err => Promise.reject(`ERROR: Unable to get file stats for ${path}, ${err}`)); 30 | 31 | const readEntryFile = path => 32 | isDir(path) 33 | .then(isDir => (isDir ? pathJoin(path, 'index') : path)) 34 | 35 | .catch(isDirError => 36 | tryReadWithExtension(path) 37 | .then(({ path }) => path) 38 | .catch(e => { 39 | throw new Error(`ERROR: Unable to read component entry file at "${path}". ${e} ${isDirError}`); 40 | }) 41 | ) 42 | 43 | .then(path => 44 | pathExtname(path) 45 | ? fsReadFile(path, 'utf8') 46 | .then(source => ({ source, path, isTypescript: isTypescript(path) })) 47 | .catch(() => 48 | tryReadWithExtension(path).then(({ path, source }) => ({ 49 | source, 50 | path, 51 | isTypescript: isTypescript(path), 52 | })) 53 | ) 54 | : tryReadWithExtension(path) 55 | ); 56 | 57 | const ensurePath = path => 58 | path && path.length 59 | ? Promise.resolve(path) 60 | : Promise.reject(new Error('ERROR: Missing required `path` argument when calling `readFile`')); 61 | 62 | // readFile : String -> Promise<{ source: String, path: String }> 63 | const readFile = (path = '') => ensurePath(path).then(readEntryFile); 64 | 65 | module.exports = readFile; 66 | -------------------------------------------------------------------------------- /src/read-file/index.test.js: -------------------------------------------------------------------------------- 1 | /* global describe it expect jest */ 2 | 3 | const cista = require('cista'); 4 | 5 | const readFile = require('./'); 6 | 7 | describe('readFile', () => { 8 | describe('given existing path', () => { 9 | it('should resolve with file content', () => { 10 | const content = 'hello file content'; 11 | const fakeFs = cista({ 12 | 'test.file': content, 13 | }); 14 | 15 | return expect(readFile(fakeFs.dir + '/test.file')).resolves.toEqual({ 16 | source: content, 17 | path: fakeFs.dir + '/test.file', 18 | isTypescript: false, 19 | }); 20 | }); 21 | 22 | it('should resolve with correct isTypescript flag', () => { 23 | const jsFiles = ['index.js', 'index.jsx']; 24 | const tsFiles = ['index.ts', 'index.tsx', 'index.d.ts']; 25 | const allFiles = [...jsFiles, ...tsFiles]; 26 | 27 | const fakeFs = cista(allFiles.reduce((acc, path) => ({ ...acc, [path]: ' ' }), {})); 28 | 29 | const expectFlag = isTypescript => files => files.map(() => expect.objectContaining({ isTypescript })); 30 | 31 | return expect(Promise.all(allFiles.map(files => readFile(fakeFs.dir + '/' + files)))).resolves.toEqual([ 32 | ...expectFlag(false)(jsFiles), 33 | ...expectFlag(true)(tsFiles), 34 | ]); 35 | }); 36 | }); 37 | 38 | describe('given non existing path', () => { 39 | it('should reject with error', () => expect(readFile('you-dont-exist')).rejects.toBeDefined()); 40 | }); 41 | 42 | describe('given dotted suffix without extension', () => { 43 | it('should resolve with file content', () => { 44 | const content = 'hello file content'; 45 | const fakeFs = cista({ 46 | 'test.file.js': content, 47 | }); 48 | 49 | return expect(readFile(fakeFs.dir + '/test.file')).resolves.toEqual({ 50 | source: content, 51 | path: fakeFs.dir + '/test.file.js', 52 | isTypescript: false, 53 | }); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/read-folder/index.js: -------------------------------------------------------------------------------- 1 | /* global Promise */ 2 | 3 | const { readdir } = require('fs'); 4 | const dirname = require('../dirname'); 5 | const promise = require('../promises/promise'); 6 | 7 | const promiseReaddir = promise(readdir); 8 | 9 | const readFolder = path => promiseReaddir(dirname(path), 'utf8'); 10 | 11 | module.exports = readFolder; 12 | -------------------------------------------------------------------------------- /src/read-folder/index.test.js: -------------------------------------------------------------------------------- 1 | /* global describe it expect jest */ 2 | 3 | const cista = require('cista'); 4 | 5 | const readFolder = require('./'); 6 | 7 | describe('readFolder', () => { 8 | describe('given path to folder', () => { 9 | it('should resolve with array of filenames', () => { 10 | const fakeFs = cista({ 11 | 'folder-name/file.js': '', 12 | 'folder-name/file2.js': '', 13 | 'folder-name/folder/file.js': '', 14 | }); 15 | 16 | return expect(readFolder(fakeFs.dir + '/folder-name')).resolves.toEqual(['file.js', 'file2.js', 'folder']); 17 | }); 18 | }); 19 | 20 | describe('given path to file', () => { 21 | it('should resolve with array of filename', () => { 22 | const fakeFs = cista({ 23 | 'folder/index.js': '', 24 | 'folder/some-file': '', 25 | 'folder/another_folder': '', 26 | }); 27 | 28 | return expect(readFolder(fakeFs.dir + '/folder/index.js')).resolves.toEqual([ 29 | 'another_folder', 30 | 'index.js', 31 | 'some-file', 32 | ]); 33 | }); 34 | }); 35 | 36 | describe('given non existing path', () => { 37 | it('should reject promise', () => expect(readFolder('you-dont-exist')).rejects.toBeDefined()); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/resolve-node-modules/index.js: -------------------------------------------------------------------------------- 1 | /* global Promise */ 2 | 3 | const readFolder = require('../read-folder'); 4 | const path = require('path'); 5 | 6 | const resolveNodeModulesPath = (cwd, modulePath) => { 7 | const checkPath = path.dirname(cwd); 8 | 9 | return readFolder(path.join(checkPath, 'node_modules')) 10 | .then(nodeModulesFiles => { 11 | const candidate = nodeModulesFiles.find(f => modulePath.match(f)); 12 | 13 | return candidate 14 | ? path.join(checkPath, 'node_modules', modulePath) 15 | : checkPath !== '.' 16 | ? resolveNodeModulesPath(checkPath, modulePath) 17 | : Promise.reject(`ERROR: Unable to resolve node_modules path in "${modulePath}"`); 18 | }) 19 | 20 | .catch(e => 21 | checkPath !== '.' 22 | ? resolveNodeModulesPath(checkPath, modulePath) 23 | : Promise.reject(`ERROR: Unable to resolve node_modules path in "${modulePath}, ${e}"`) 24 | ); 25 | }; 26 | 27 | module.exports = resolveNodeModulesPath; 28 | -------------------------------------------------------------------------------- /src/resolve-path/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const dirname = require('../dirname'); 3 | const resolveNodeModulesPath = require('../resolve-node-modules'); 4 | 5 | /** 6 | * resolvePath is used to resolve relative and/or real path to 7 | * node_modules 8 | */ 9 | // resolvePath : (cwd: string, relativePath: string) -> Promise 10 | const resolvePath = (cwd, relativePath) => { 11 | const desiredPath = relativePath.replace(/(dist\/|standalone\/)/g, ''); 12 | 13 | return relativePath.startsWith('.') 14 | ? Promise.resolve(path.join(dirname(cwd), desiredPath)) 15 | : resolveNodeModulesPath(cwd, desiredPath); 16 | }; 17 | 18 | module.exports = resolvePath; 19 | -------------------------------------------------------------------------------- /src/resolve-path/index.test.js: -------------------------------------------------------------------------------- 1 | /* global describe it expect jest */ 2 | 3 | const cista = require('cista'); 4 | 5 | const resolvePath = require('./'); 6 | 7 | describe('resolvePath', () => { 8 | describe('given relative path without cwd', () => { 9 | it('should resolve with correct path to file', () => { 10 | const fakeFs = cista({ 11 | 'folder/file.js': '', 12 | }); 13 | 14 | return expect(resolvePath(fakeFs.dir, './folder/file.js')).resolves.toEqual(fakeFs.dir + '/folder/file.js'); 15 | }); 16 | }); 17 | 18 | describe('given relative path with cwd', () => { 19 | it('should resolve with correct path to file', () => { 20 | const fakeFs = cista({ 21 | 'folder/file.js': '', 22 | }); 23 | 24 | return expect(resolvePath(fakeFs.dir + '/folder', './file.js')).resolves.toEqual(fakeFs.dir + '/folder/file.js'); 25 | }); 26 | 27 | it('should remove /dist & standalone parts from path', () => { 28 | const fakeFs = cista({ 29 | 'src/folder/standalone.dist.js': '', 30 | }); 31 | 32 | return expect(resolvePath(fakeFs.dir + '/src', './standalone/folder/dist/standalone.dist.js')).resolves.toEqual( 33 | fakeFs.dir + '/src/folder/standalone.dist.js' 34 | ); 35 | }); 36 | }); 37 | 38 | describe('given absolute path', () => { 39 | it('should resolve with correct path to node_modules', () => { 40 | const fakeFs = cista({ 41 | 'node_modules/wix-ui-core/folder/file.js': '', 42 | }); 43 | 44 | return expect(resolvePath(fakeFs.dir + '/root', 'wix-ui-core/folder/file.js')).resolves.toEqual( 45 | fakeFs.dir + '/node_modules/wix-ui-core/folder/file.js' 46 | ); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/testkit-parser/__fixtures__/driver.js: -------------------------------------------------------------------------------- 1 | import { driver as anotherDriver } from './driver2.js'; 2 | 3 | export const driver = () => ({ 4 | method: () => {}, 5 | methodWithArguments: (a, b, c) => {}, 6 | nested: { 7 | method: () => {}, 8 | }, 9 | ...anotherDriver(), 10 | }); 11 | -------------------------------------------------------------------------------- /src/testkit-parser/__fixtures__/driver2.js: -------------------------------------------------------------------------------- 1 | export const driver = () => ({ 2 | method: () => {}, 3 | }); 4 | -------------------------------------------------------------------------------- /src/testkit-parser/get-comments.js: -------------------------------------------------------------------------------- 1 | const flatten = require('./utils/flatten'); 2 | const doctrine = require('doctrine'); 3 | 4 | const supportedAnnotations = { 5 | '@deprecated': 'isDeprecated', 6 | }; 7 | 8 | const normalizeLine = line => 9 | line 10 | .replace(/^(\s*\*\s*)/, '') 11 | .replace(/(\s*\*\s*)$/, '') 12 | .trim(); 13 | 14 | const convertCommentNodesToLines = nodes => flatten(nodes.map(({ value }) => value.split('\n').map(normalizeLine))); 15 | 16 | const extractAnnotations = lines => { 17 | const defaultMetadata = { description: '', annotations: {} }; 18 | return lines.reduce((methodMetadata, commentLine) => { 19 | if (supportedAnnotations[commentLine]) { 20 | // line matches annotation: do not add it to description and set annotation key to object metadata 21 | const annotationKey = supportedAnnotations[commentLine]; 22 | return { 23 | ...methodMetadata, 24 | annotations: { ...methodMetadata.annotations, [annotationKey]: true }, 25 | }; 26 | } 27 | 28 | const description = [methodMetadata.description, commentLine].filter(Boolean).join('\n'); 29 | 30 | return { ...methodMetadata, description }; 31 | }, defaultMetadata); 32 | }; 33 | 34 | const getComments = node => { 35 | const {leadingComments} = node; 36 | if (!leadingComments) { 37 | return {}; 38 | } 39 | 40 | if (leadingComments.length === 1 && leadingComments[0].type === 'CommentBlock') { 41 | const leadingComment = leadingComments[0]; 42 | const ast = doctrine.parse(leadingComment.value,{ unwrap: true }); 43 | 44 | // For backward compatibility of `isDeprecated` prop 45 | const deprecatedTag = ast.tags.find(t => t.title === 'deprecated'); 46 | if (deprecatedTag) { 47 | ast.isDeprecated = true; 48 | } 49 | 50 | return ast; 51 | } else { 52 | const lines = convertCommentNodesToLines(node.leadingComments); 53 | const { annotations, description } = extractAnnotations(lines); 54 | return { ...annotations, description }; 55 | } 56 | }; 57 | 58 | module.exports = getComments; 59 | -------------------------------------------------------------------------------- /src/testkit-parser/get-export.js: -------------------------------------------------------------------------------- 1 | const getNodeDescriptor = require('./get-object-descriptor'); 2 | const parseDriver = require('./utils/parse-driver'); 3 | const getExportedNode = require('./utils/get-exported-node'); 4 | 5 | module.exports = async (code, exportName, cwd) => { 6 | const ast = parseDriver(code); 7 | const node = await getExportedNode({ ast, exportName, cwd }); 8 | return getNodeDescriptor({ node, ast: node.ast || ast, cwd }); 9 | }; 10 | -------------------------------------------------------------------------------- /src/testkit-parser/get-object-descriptor.js: -------------------------------------------------------------------------------- 1 | const types = require('@babel/types'); 2 | 3 | const findIdentifierNode = require('./utils/find-identifier-node'); 4 | const getComments = require('./get-comments'); 5 | const flatten = require('./utils/flatten'); 6 | const getReturnValue = require('./utils/get-return-value'); 7 | const reduceToObject = require('./utils/reduce-to-object'); 8 | 9 | const getArgument = param => { 10 | if (types.isObjectPattern(param)) { 11 | const keys = param.properties.map(({ key: { name } }) => name); 12 | return { name: `{${keys.join(', ')}}` }; 13 | } 14 | 15 | if (types.isIdentifier(param)) { 16 | return { name: param.name }; 17 | } 18 | 19 | if (types.isAssignmentPattern(param)) { 20 | return { name: param.left.name }; 21 | } 22 | 23 | if (types.isRestElement(param)) { 24 | return { name: `...${param.argument.name}` }; 25 | } 26 | 27 | throw `not supported: getArgument ${param.type}`; 28 | }; 29 | 30 | const isFunction = node => 31 | [types.isArrowFunctionExpression, types.isFunctionDeclaration, types.isFunctionExpression, types.isObjectMethod].some( 32 | checker => checker(node) 33 | ); 34 | 35 | const isValue = node => [types.isBooleanLiteral, types.isNumericLiteral].some(checker => checker(node)); 36 | 37 | const getObjectProperties = async ({ node, ast, cwd }) => { 38 | const properties = await Promise.all( 39 | node.properties.map(async property => { 40 | if (property.type === 'SpreadElement') { 41 | const object = await reduceToObject({ 42 | ast: node.ast || ast, 43 | cwd: node.cwd || cwd, 44 | node: property.argument, 45 | }); 46 | 47 | return object.properties; 48 | } 49 | 50 | return property; 51 | }) 52 | ); 53 | 54 | return flatten(properties); 55 | }; 56 | 57 | const getMemberProperty = async ({ node, ast, cwd }) => { 58 | const object = await reduceToObject({ node: node.object, ast, cwd }); 59 | const properties = await getObjectProperties({ node: object, ast, cwd }); 60 | const property = properties.find(property => property.key.name === node.property.name); 61 | return property.value; 62 | }; 63 | 64 | const createDescriptor = async ({ node, ast, cwd }) => { 65 | switch (true) { 66 | case isFunction(node): 67 | return { 68 | args: node.params.map(getArgument), 69 | type: 'function', 70 | }; 71 | 72 | case types.isIdentifier(node): 73 | try { 74 | return createDescriptor({ 75 | node: await findIdentifierNode({ name: node.name, ast, cwd }), 76 | ast, 77 | cwd, 78 | }); 79 | } catch (e) { 80 | if (e instanceof ReferenceError) { 81 | // identifier is not declared - probably a function argument 82 | return { 83 | type: 'unknown', 84 | }; 85 | } 86 | 87 | throw e; 88 | } 89 | 90 | case types.isObjectExpression(node): 91 | return { 92 | type: 'object', 93 | props: await getObjectDescriptor({ node, ast, cwd }), 94 | }; 95 | 96 | case isValue(node): 97 | return { type: 'value' }; 98 | 99 | case types.isCallExpression(node): 100 | return createDescriptor({ 101 | node: await getReturnValue({ node: node.callee, ast, cwd }), 102 | ast, 103 | cwd, 104 | }); 105 | 106 | case types.isMemberExpression(node): 107 | return createDescriptor({ 108 | node: await getMemberProperty({ node: node, ast, cwd }), 109 | ast, 110 | cwd, 111 | }); 112 | 113 | case types.isLogicalExpression(node): 114 | return await createDescriptor({ node: node.right, ast, cwd }); 115 | } 116 | 117 | throw `Cannot resolve arguments for ${node.type}`; 118 | }; 119 | 120 | const getPropertyDescriptor = async ({ node, ast, cwd }) => { 121 | const valueNode = types.isObjectMethod(node) ? node : node.value; 122 | const descriptor = await createDescriptor({ node: valueNode, ast, cwd }); 123 | const comments = getComments(node); 124 | const name = node.key.name; 125 | 126 | return { name, ...descriptor, ...comments }; 127 | }; 128 | 129 | const getSpreadDescriptor = async ({ node, ast, cwd }) => { 130 | if (types.isObjectExpression(node)) { 131 | return getObjectDescriptor({ node: node, ast, cwd }); 132 | } 133 | 134 | switch (true) { 135 | case types.isIdentifier(node): 136 | try { 137 | return await getObjectDescriptor({ 138 | node: await reduceToObject({ node, ast, cwd }), 139 | ast, 140 | cwd, 141 | }); 142 | } catch (e) { 143 | return { 144 | name: node.name, 145 | type: 'error', 146 | }; 147 | } 148 | 149 | case types.isCallExpression(node): 150 | return getObjectDescriptor({ 151 | node: await reduceToObject({ node: node, ast, cwd }), 152 | ast, 153 | cwd, 154 | }); 155 | 156 | case types.isMemberExpression(node): 157 | const propertyDescriptor = await createDescriptor({ 158 | node: await getMemberProperty({ node: node, ast, cwd }), 159 | ast, 160 | cwd, 161 | }); 162 | 163 | return propertyDescriptor.props; 164 | 165 | default: 166 | throw Error(`Unsupported spread for type ${node.type}`); 167 | } 168 | }; 169 | 170 | const getObjectDescriptor = async ({ node, ast, cwd }) => { 171 | const scopedAst = node.ast || ast; 172 | const scopedCwd = node.cwd || cwd; 173 | const methodPromises = node.properties.map(node => 174 | types.isSpreadElement(node) 175 | ? getSpreadDescriptor({ node: node.argument, ast: scopedAst, cwd: scopedCwd }) 176 | : getPropertyDescriptor({ node: node, ast: scopedAst, cwd: scopedCwd }) 177 | ); 178 | 179 | return flatten(await Promise.all(methodPromises)); 180 | }; 181 | 182 | module.exports = getObjectDescriptor; 183 | -------------------------------------------------------------------------------- /src/testkit-parser/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const getExport = require('./get-export'); 4 | const readFile = require('../read-file'); 5 | 6 | async function testkitParser(filePath) { 7 | const { source } = await readFile(filePath); 8 | const file = path.basename(filePath); 9 | 10 | return getExport(source, undefined, filePath).then( 11 | descriptor => ({ file, descriptor }), 12 | error => ({ file, error: error.stack ? error.stack.toString() : error }) 13 | ); 14 | } 15 | 16 | module.exports = testkitParser; 17 | -------------------------------------------------------------------------------- /src/testkit-parser/index.test.js: -------------------------------------------------------------------------------- 1 | const testkitParser = require('.'); 2 | 3 | describe('testkitParser', () => { 4 | it('should be defined', () => { 5 | expect(typeof testkitParser).toEqual('function'); 6 | }); 7 | 8 | describe('given testkit which has spread properties', () => { 9 | it('should parse correctly', () => { 10 | const expectedOutput = { 11 | descriptor: [ 12 | { args: [], name: 'method', type: 'function' }, 13 | { args: [{ name: 'a' }, { name: 'b' }, { name: 'c' }], name: 'methodWithArguments', type: 'function' }, 14 | { 15 | name: 'nested', 16 | type: 'object', 17 | props: [ 18 | { 19 | args: [], 20 | name: 'method', 21 | type: 'function', 22 | }, 23 | ], 24 | }, 25 | { args: [], name: 'method', type: 'function' }, 26 | ], 27 | file: 'driver.js', 28 | }; 29 | 30 | return expect(testkitParser(__dirname + '/__fixtures__/driver.js')).resolves.toEqual(expectedOutput); 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/testkit-parser/tests/comments.test.js: -------------------------------------------------------------------------------- 1 | const getExport = require('../get-export'); 2 | 3 | describe('get method comments', () => { 4 | const testCases = [ 5 | { 6 | spec: 'line comment', 7 | code: ` 8 | export default () => ({ 9 | // method description within single line 10 | method: () => {} 11 | })`, 12 | expected: [{ name: 'method', type: 'function', args: [], description: 'method description within single line' }], 13 | }, 14 | { 15 | spec: 'block comment', 16 | code: ` 17 | export default () => ({ 18 | /** method description within block */ 19 | method: () => {} 20 | })`, 21 | expected: [{ name: 'method', type: 'function', args: [], description: 'method description within block' , tags: []}], 22 | }, 23 | { 24 | spec: 'multi-line block comment', 25 | code: ` 26 | export default () => ({ 27 | /** 28 | * method description within block 29 | */ 30 | method: () => {} 31 | })`, 32 | expected: [{ name: 'method', type: 'function', args: [], description: 'method description within block', tags: [] }], 33 | }, 34 | { 35 | spec: 'multiple comments', 36 | code: ` 37 | export default () => ({ 38 | // method description 39 | /** within multiple comments */ 40 | method: () => {} 41 | })`, 42 | expected: [ 43 | { 44 | name: 'method', 45 | type: 'function', 46 | args: [], 47 | description: `method description 48 | within multiple comments`, 49 | }, 50 | ], 51 | }, 52 | { 53 | spec: 'annotation: deprecated', 54 | code: ` 55 | export default () => ({ 56 | /** 57 | * Focus related testing is done in e2e tests only. 58 | * @deprecated 59 | */ 60 | method: () => {} 61 | })`, 62 | expected: [ 63 | { 64 | name: 'method', 65 | type: 'function', 66 | args: [], 67 | description: 'Focus related testing is done in e2e tests only.', 68 | isDeprecated: true, 69 | tags: [{description: null, title: 'deprecated'}] 70 | }, 71 | ], 72 | }, 73 | { 74 | spec: 'object method comment', 75 | code: ` 76 | export default () => ({ 77 | // comment 78 | method() { 79 | 80 | } 81 | })`, 82 | expected: [{ name: 'method', type: 'function', args: [], description: 'comment' }], 83 | }, 84 | ]; 85 | 86 | testCases.forEach(({ spec, code, expected }) => { 87 | it(`should parse ${spec}`, async () => { 88 | const result = await getExport(code); 89 | expect(result).toEqual(expected); 90 | }); 91 | }); 92 | 93 | describe('jsdoc tags', () => { 94 | it('@param', async () => { 95 | const result = await getExport(` 96 | export default () => ({ 97 | /** 98 | * method description within block 99 | * @param {string} txt a param description 100 | */ 101 | method: () => {} 102 | })`); 103 | expect(result).toEqual([{ 104 | name: 'method', 105 | type: 'function', 106 | args: [], 107 | description: 'method description within block' , 108 | tags: [{ 109 | title: 'param', 110 | name: 'txt', 111 | description : 'a param description', 112 | type: {name: 'string', type: 'NameExpression'} 113 | }] 114 | }]); 115 | }); 116 | 117 | it('@returns', async () => { 118 | const result = await getExport(` 119 | export default () => ({ 120 | /** 121 | * method description within block 122 | * @returns {boolean} true or false 123 | */ 124 | method: () => {} 125 | })`); 126 | expect(result).toEqual([{ 127 | name: 'method', 128 | type: 'function', 129 | args: [], 130 | description: 'method description within block' , 131 | tags: [{ 132 | title: 'returns', 133 | description : 'true or false', 134 | type: {name: 'boolean', type: 'NameExpression'} 135 | }] 136 | }]); 137 | }); 138 | }) 139 | 140 | }); 141 | -------------------------------------------------------------------------------- /src/testkit-parser/tests/export.test.js: -------------------------------------------------------------------------------- 1 | const getExport = require('../get-export'); 2 | 3 | describe('get default export', () => { 4 | const testCases = [ 5 | { 6 | spec: 'export default anonymous function without block statement', 7 | code: ` 8 | const a = 1; 9 | export default () => ({ 10 | method: () => {} 11 | })`, 12 | }, 13 | { 14 | spec: 'export default anonymous function with block statement', 15 | code: ` 16 | const a = 1; 17 | export default () => { 18 | return { 19 | method: () => {} 20 | } 21 | }`, 22 | }, 23 | { 24 | spec: 'export default function', 25 | code: ` 26 | const a = 1; 27 | export default function () { 28 | return { 29 | method: () => {} 30 | } 31 | }`, 32 | }, 33 | { 34 | spec: 'arrow function symbol without block statement', 35 | code: ` 36 | const driver = () => ({ 37 | method: () => {} 38 | }); 39 | export default driver; 40 | `, 41 | }, 42 | { 43 | spec: 'arrow function symbol with block statement', 44 | code: ` 45 | const driver = () => { 46 | return { 47 | method: () => {} 48 | } 49 | }; 50 | export default driver; 51 | `, 52 | }, 53 | { 54 | spec: 'function declaration symbol', 55 | code: ` 56 | function driver() { 57 | return { 58 | method: function() {} 59 | } 60 | }; 61 | export default driver; 62 | `, 63 | }, 64 | { 65 | spec: 'returned identifier', 66 | code: ` 67 | function driver() { 68 | const a = { 69 | method: function() {} 70 | }; 71 | return a; 72 | }; 73 | export default driver; 74 | `, 75 | }, 76 | { 77 | spec: 'call expression in arrow function body', 78 | code: ` 79 | const internalDriverFactory = () => ({ 80 | method: () => {} 81 | }); 82 | const driverFactory = () => internalDriverFactory(); 83 | 84 | export default driverFactory; 85 | `, 86 | }, 87 | ]; 88 | 89 | const expected = [{ name: 'method', type: 'function', args: [] }]; 90 | 91 | testCases.forEach(({ spec, code }) => { 92 | it(`should parse ${spec}`, async () => { 93 | const result = await getExport(code); 94 | expect(result).toEqual(expected); 95 | }); 96 | }); 97 | }); 98 | 99 | describe('get named export', () => { 100 | const testCases = [ 101 | { 102 | spec: 'const DriverFactory pattern', 103 | code: ` 104 | export const myDriverFactory = () => ({ 105 | method: function() {} 106 | }); 107 | `, 108 | }, 109 | { 110 | spec: 'identifier DriverFactory pattern', 111 | code: ` 112 | const myDriverFactory = () => ({ 113 | method: function() {} 114 | }); 115 | export { myDriverFactory as driver } 116 | `, 117 | }, 118 | { 119 | spec: 'single named export', 120 | code: ` 121 | const myFactory = () => ({ 122 | method: function() {} 123 | }); 124 | export { myFactory } 125 | `, 126 | }, 127 | ]; 128 | const expected = [{ name: 'method', type: 'function', args: [] }]; 129 | testCases.forEach(({ spec, code }) => { 130 | it(`shoud parse ${spec}`, async () => { 131 | const result = await getExport(code); 132 | expect(result).toEqual(expected); 133 | }); 134 | }); 135 | 136 | it('shoud fail parsing given multiple named exports without DriverFactory pattern', async () => { 137 | const code = ` 138 | const myFactory = () => ({ 139 | method: function() {} 140 | }); 141 | const anotherFactory = () => ({ }) 142 | export { myFactory, anotherFactory } 143 | ` 144 | 145 | await expect(getExport(code)).rejects.toEqual(Error('export "default" not found')); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /src/testkit-parser/tests/imports.test.js: -------------------------------------------------------------------------------- 1 | const getExport = require('../get-export'); 2 | 3 | jest.mock('fs'); 4 | const fs = require('fs'); 5 | 6 | describe('import parsing', () => { 7 | const testCases = [ 8 | { 9 | spec: 'default arrow function without block statement', 10 | code: ` 11 | import driver from './driver.js'; 12 | export default () => ({ 13 | driver 14 | })`, 15 | files: { 16 | 'driver.js': `export default () => ({ 17 | method: arg => {} 18 | })`, 19 | }, 20 | }, 21 | { 22 | spec: 'default arrow function with block statement', 23 | code: ` 24 | import driver from './driver.js'; 25 | export default () => ({ 26 | driver 27 | })`, 28 | files: { 29 | 'driver.js': `export default () => { 30 | return { 31 | method: arg => {} 32 | } 33 | }`, 34 | }, 35 | }, 36 | { 37 | spec: 'default function', 38 | code: ` 39 | import driver from './driver.js'; 40 | export default () => ({ 41 | driver 42 | })`, 43 | files: { 44 | 'driver.js': `export default function() { 45 | return { 46 | method: arg => {} 47 | } 48 | }`, 49 | }, 50 | }, 51 | { 52 | spec: 'identifier in imported file', 53 | code: ` 54 | import driver from './driver.js'; 55 | export default () => ({ 56 | driver 57 | })`, 58 | files: { 59 | 'driver.js': ` 60 | const symbol = { 61 | method: arg => {} 62 | }; 63 | export default function() { 64 | return symbol; 65 | }`, 66 | }, 67 | }, 68 | { 69 | spec: 'named arrow function', 70 | code: ` 71 | import {driver} from './driver.js'; 72 | export default () => ({ 73 | driver 74 | })`, 75 | files: { 76 | 'driver.js': ` 77 | export const driver = () => ({ 78 | method: arg => {} 79 | }); 80 | export default () => ({ 81 | anotherMethod: () => {} 82 | })`, 83 | }, 84 | }, 85 | { 86 | spec: 'factory function', 87 | code: ` 88 | import driverFactory from './driver.js'; 89 | const driver = driverFactory(); 90 | export default () => ({ 91 | driver 92 | })`, 93 | files: { 94 | 'driver.js': ` 95 | export default () => ({ 96 | method: arg => {} 97 | })`, 98 | }, 99 | }, 100 | { 101 | spec: 'member expression', 102 | code: ` 103 | import driverFactory from './driver.js'; 104 | export default () => ({ 105 | driver: driverFactory().anotherDriver 106 | })`, 107 | files: { 108 | 'driver.js': ` 109 | export default () => ({ 110 | anotherDriver: { 111 | method: arg => {} 112 | } 113 | })`, 114 | }, 115 | }, 116 | { 117 | spec: 'object spread on factory function', 118 | code: ` 119 | import driverFactory from './driver.js'; 120 | export default () => ({ 121 | ...driverFactory() 122 | }) 123 | `, 124 | files: { 125 | 'driver.js': ` 126 | export default () => ({ 127 | driver: { 128 | method: arg => {} 129 | } 130 | }) 131 | `, 132 | }, 133 | }, 134 | { 135 | spec: 'export { x as y } from z', 136 | code: ` 137 | export { internalDriver as driverFactory } from './driver.js'; 138 | `, 139 | files: { 140 | 'driver.js': ` 141 | export const internalDriver = () => ({ 142 | driver: { 143 | method: arg => {} 144 | } 145 | }); 146 | `, 147 | }, 148 | }, 149 | { 150 | spec: 'export { x as y } from node_modules/z', 151 | code: ` 152 | export { internalDriver as driverFactory } from 'library/dist/driver.js'; 153 | `, 154 | files: { 155 | node_modules: { 156 | library: { 157 | 'driver.js': ` 158 | export const internalDriver = () => ({ 159 | driver: { 160 | method: arg => {} 161 | } 162 | }); 163 | `, 164 | }, 165 | }, 166 | }, 167 | }, 168 | { 169 | spec: 'Multi-level imports', 170 | code: ` 171 | export { 172 | buttonNextDriverFactory as textButtonDriverFactory, 173 | } from 'libraryA/dist/driver'; 174 | `, 175 | files: { 176 | node_modules: { 177 | libraryA: { 178 | 'driver.ts': ` 179 | import { baseUniDriverFactory } from 'libraryB/driver'; 180 | 181 | export const buttonNextDriverFactory = (base: any): any => { 182 | return { 183 | driver: {...baseUniDriverFactory(base)} 184 | }; 185 | }; 186 | `, 187 | }, 188 | libraryB: { 189 | 'driver.js': ` 190 | export const baseUniDriverFactory = (base: any): any => { 191 | return { 192 | method: (arg) => {} 193 | }; 194 | }; 195 | `, 196 | }, 197 | }, 198 | }, 199 | }, 200 | { 201 | spec: 'relative path in node_modules', 202 | code: ` 203 | export { buttonNextDriverFactory } from 'library/dist/driver'; 204 | `, 205 | files: { 206 | node_modules: { 207 | library: { 208 | 'driver.ts': ` 209 | export { buttonNextDriverFactory } from './dist/src/driverFactory'; 210 | `, 211 | src: { 212 | 'driverFactory.ts': `export const buttonNextDriverFactory = (base: any): any => ({ 213 | driver: { 214 | method: (arg) => {} 215 | } 216 | });`, 217 | }, 218 | }, 219 | }, 220 | }, 221 | }, 222 | { 223 | spec: 're-exported identifier', 224 | code: ` 225 | export { buttonNextDriverFactory } from 'library/dist/driver'; 226 | `, 227 | files: { 228 | node_modules: { 229 | library: { 230 | 'driver.ts': ` 231 | module.exports = require('./dist/src/driverFactory'); 232 | `, 233 | src: { 234 | 'driverFactory.ts': `export const buttonNextDriverFactory = (base: any): any => ({ 235 | driver: { 236 | method: (arg) => {} 237 | } 238 | });`, 239 | }, 240 | }, 241 | }, 242 | }, 243 | }, 244 | { 245 | spec: 're-exported default as ExportNamedDeclaration', 246 | code: ` 247 | export { 248 | default, 249 | } from './component'; 250 | `, 251 | files: { 252 | 'component.js': ` 253 | export default (base: any): any => ({ 254 | driver: { 255 | method: (arg) => {} 256 | } 257 | }); 258 | `, 259 | }, 260 | }, 261 | { 262 | spec: 'imported identifiers in spread element', 263 | code: ` 264 | import internalDriverFactory from './folder/internal.js'; 265 | export default () => ({ 266 | ...internalDriverFactory() 267 | }); 268 | `, 269 | files: { 270 | folder: { 271 | 'internal.js': ` 272 | import anotherDriverFactory from './another-internal.js'; 273 | export default () => ({ 274 | ...anotherDriverFactory() 275 | }); 276 | `, 277 | 'another-internal.js': ` 278 | export default () => ({ 279 | driver: { 280 | method: (arg) => {} 281 | } 282 | }); 283 | `, 284 | }, 285 | }, 286 | }, 287 | { 288 | spec: 'member expression with imported spread', 289 | code: ` 290 | import internalDriverFactory from './folder/internal.js'; 291 | const internalDriver = internalDriverFactory(); 292 | export default () => ({ 293 | driver: { 294 | method: internalDriver.method 295 | } 296 | }); 297 | `, 298 | files: { 299 | folder: { 300 | 'internal.js': ` 301 | import anotherDriverFactory from './another-internal.js'; 302 | const anotherDriver = anotherDriverFactory(); 303 | export default () => ({ 304 | ...anotherDriver 305 | }); 306 | `, 307 | 'another-internal.js': ` 308 | export default () => ({ 309 | method: (arg) => {} 310 | }); 311 | `, 312 | }, 313 | }, 314 | }, 315 | { 316 | spec: 'imported member expression via named default export with internal spread', 317 | code: ` 318 | import driverFactory from './folder/internal'; 319 | export default () => { 320 | const driver = driverFactory(); 321 | return { 322 | driver: { method: driver.method }, 323 | }; 324 | }; 325 | `, 326 | files: { 327 | folder: { 328 | 'internal.js': ` 329 | export { 330 | default, 331 | } from './another-internal'; 332 | `, 333 | 'another-internal.js': ` 334 | const driverFactory = () => { 335 | const driver = { 336 | method: arg => {} 337 | }; 338 | return { ...driver }; 339 | }; 340 | export default driverFactory; 341 | `, 342 | }, 343 | }, 344 | }, 345 | ]; 346 | 347 | const expected = [ 348 | { 349 | name: 'driver', 350 | type: 'object', 351 | props: [{ name: 'method', type: 'function', args: [{ name: 'arg' }] }], 352 | }, 353 | ]; 354 | 355 | testCases.forEach(({ spec, code, files }) => { 356 | it(`should parse ${spec}`, async () => { 357 | fs.__setFS(files); 358 | const result = await getExport(code); 359 | expect(result).toEqual(expected); 360 | }); 361 | }); 362 | }); 363 | -------------------------------------------------------------------------------- /src/testkit-parser/tests/methods.test.js: -------------------------------------------------------------------------------- 1 | const getExport = require('../get-export'); 2 | 3 | describe('get object methods', () => { 4 | const testCases = [ 5 | { 6 | spec: 'no args', 7 | code: ` 8 | export default () => ({ 9 | methodA: () => {}, 10 | methodB: () => {} 11 | })`, 12 | expected: [{ name: 'methodA', type: 'function', args: [] }, { name: 'methodB', type: 'function', args: [] }], 13 | }, 14 | { 15 | spec: 'one arg', 16 | code: ` 17 | export default () => ({ 18 | methodA: arg => {}, 19 | methodB: (arg) => {} 20 | })`, 21 | expected: [ 22 | { name: 'methodA', type: 'function', args: [{ name: 'arg' }] }, 23 | { name: 'methodB', type: 'function', args: [{ name: 'arg' }] }, 24 | ], 25 | }, 26 | { 27 | spec: 'multiple args', 28 | code: ` 29 | export default () => ({ 30 | methodA: (arg1, arg2) => {} 31 | })`, 32 | expected: [{ name: 'methodA', type: 'function', args: [{ name: 'arg1' }, { name: 'arg2' }] }], 33 | }, 34 | { 35 | spec: 'function declaration', 36 | code: ` 37 | export default () => ({ 38 | methodA: function (arg1, arg2) {} 39 | })`, 40 | expected: [{ name: 'methodA', type: 'function', args: [{ name: 'arg1' }, { name: 'arg2' }] }], 41 | }, 42 | { 43 | spec: 'object destructuring', 44 | code: ` 45 | export default () => ({ 46 | methodA: ({ arg1, arg2 }, arg3) => {} 47 | })`, 48 | expected: [{ name: 'methodA', type: 'function', args: [{ name: '{arg1, arg2}' }, { name: 'arg3' }] }], 49 | }, 50 | { 51 | spec: 'arrow function identifier', 52 | code: ` 53 | const methodA = ({ arg1, arg2 }) => {} 54 | export default () => ({ 55 | methodA 56 | })`, 57 | expected: [{ name: 'methodA', type: 'function', args: [{ name: '{arg1, arg2}' }] }], 58 | }, 59 | { 60 | spec: 'function identifier', 61 | code: ` 62 | const methodA = function({ arg1, arg2 }) {} 63 | export default () => ({ 64 | methodA 65 | })`, 66 | expected: [{ name: 'methodA', type: 'function', args: [{ name: '{arg1, arg2}' }] }], 67 | }, 68 | { 69 | spec: 'function declaration', 70 | code: ` 71 | function methodA({ arg1, arg2 }) { }; 72 | export default () => ({ 73 | methodA 74 | })`, 75 | expected: [{ name: 'methodA', type: 'function', args: [{ name: '{arg1, arg2}' }] }], 76 | }, 77 | { 78 | spec: 'object spread', 79 | code: ` 80 | const subDriver = { 81 | methodB: () => {}, 82 | methodC: (arg1, arg2) => {}, 83 | } 84 | export default () => ({ 85 | methodA: () => {}, 86 | ...subDriver 87 | })`, 88 | expected: [ 89 | { name: 'methodA', type: 'function', args: [] }, 90 | { name: 'methodB', type: 'function', args: [] }, 91 | { name: 'methodC', type: 'function', args: [{ name: 'arg1' }, { name: 'arg2' }] }, 92 | ], 93 | }, 94 | { 95 | spec: 'assignment pattern', 96 | code: ` 97 | export default () => ({ 98 | method: (arg = 1) => {} 99 | })`, 100 | expected: [{ name: 'method', type: 'function', args: [{ name: 'arg' }] }], 101 | }, 102 | { 103 | spec: 'non-function keys', 104 | code: ` 105 | export default () => ({ 106 | notMethod: true, 107 | number: 1 108 | })`, 109 | expected: [{ name: 'notMethod', type: 'value' }, { name: 'number', type: 'value' }], 110 | }, 111 | { 112 | spec: 'constructor arg as key', 113 | code: ` 114 | export default ({ arg }) => ({ 115 | arg 116 | })`, 117 | expected: [{ name: 'arg', type: 'unknown' }], 118 | }, 119 | { 120 | spec: 'logical expression', 121 | code: ` 122 | export default ({ arg }) => { 123 | const driver = arg && { method: () => {} } 124 | return { 125 | driver 126 | } 127 | }`, 128 | expected: [ 129 | { 130 | name: 'driver', 131 | type: 'object', 132 | props: [{ name: 'method', type: 'function', args: [] }], 133 | }, 134 | ], 135 | }, 136 | { 137 | spec: 'object spread on function call', 138 | code: ` 139 | const factory = () => ({ 140 | driver: { 141 | method: () => {} 142 | } 143 | }); 144 | 145 | export default () => { 146 | return { 147 | ...factory() 148 | } 149 | }; 150 | `, 151 | expected: [ 152 | { 153 | name: 'driver', 154 | type: 'object', 155 | props: [{ name: 'method', type: 'function', args: [] }], 156 | }, 157 | ], 158 | }, 159 | { 160 | spec: 'object method', 161 | code: ` 162 | export default () => ({ 163 | method(arg) { } 164 | }); 165 | `, 166 | expected: [ 167 | { 168 | name: 'method', 169 | type: 'function', 170 | args: [{ name: 'arg' }], 171 | }, 172 | ], 173 | }, 174 | { 175 | spec: 'object spread on call expression', 176 | code: ` 177 | const driverFactory = () => ({ 178 | method: arg => {} 179 | }); 180 | export default () => { 181 | const driver = driverFactory(); 182 | return { ...driver } 183 | } 184 | `, 185 | expected: [ 186 | { 187 | name: 'method', 188 | type: 'function', 189 | args: [{ name: 'arg' }], 190 | }, 191 | ], 192 | }, 193 | { 194 | spec: 'object assign', 195 | code: ` 196 | const composedDriverFactory = () => { 197 | const driver = () => ({ 198 | method: () => {} 199 | }); 200 | 201 | const composedDriver = Object.assign(driver, { 202 | anotherMethod: arg => {} 203 | }); 204 | 205 | return { driver: composedDriver }; 206 | }; 207 | 208 | export default composedDriverFactory; 209 | `, 210 | expected: [ 211 | { 212 | name: 'driver', 213 | type: 'object', 214 | props: [ 215 | { name: 'method', type: 'function', args: [] }, 216 | { name: 'anotherMethod', type: 'function', args: [{ name: 'arg' }] }, 217 | ], 218 | }, 219 | ], 220 | }, 221 | { 222 | spec: 'method factory', 223 | code: ` 224 | const driver = () => { 225 | const methodFactory = type => arg => ({}) 226 | 227 | return { 228 | method: methodFactory('value'), 229 | }; 230 | }; 231 | 232 | export default driver; 233 | `, 234 | expected: [{ name: 'method', type: 'function', args: [{ name: 'arg' }] }], 235 | }, 236 | { 237 | spec: 'runtime-dependant driver', 238 | code: ` 239 | const driverFactory = (runtimeValue) => { 240 | const driver = runtimeValue 241 | ? { anotherMethod: () => {}} 242 | : {}; 243 | 244 | return { 245 | method: () => {}, 246 | ...driver 247 | }; 248 | }; 249 | 250 | export default driverFactory; 251 | `, 252 | expected: [{ name: 'method', type: 'function', args: [] }, { name: 'driver', type: 'error' }], 253 | }, 254 | { 255 | spec: 'object spread on member expression', 256 | code: ` 257 | const driver = () => { 258 | const wrappedDriver = { 259 | driver: { 260 | method: arg => {} 261 | } 262 | } 263 | 264 | return { 265 | driver: { 266 | ...wrappedDriver.driver 267 | } 268 | }; 269 | }; 270 | 271 | export default driver; 272 | `, 273 | expected: [ 274 | { 275 | name: 'driver', 276 | type: 'object', 277 | props: [{ name: 'method', type: 'function', args: [{ name: 'arg' }] }], 278 | }, 279 | ], 280 | }, 281 | { 282 | spec: 'rest argument', 283 | code: ` 284 | export default () => ({ 285 | method: (arg1, arg2, ...args) => {} 286 | }) 287 | `, 288 | expected: [ 289 | { 290 | name: 'method', 291 | type: 'function', 292 | args: [{ name: 'arg1' }, { name: 'arg2' }, { name: '...args' }], 293 | }, 294 | ], 295 | }, 296 | ]; 297 | 298 | testCases.forEach(({ spec, code, expected }) => { 299 | it(`should parse ${spec}`, async () => { 300 | const result = await getExport(code); 301 | expect(result).toEqual(expected); 302 | }); 303 | }); 304 | }); 305 | -------------------------------------------------------------------------------- /src/testkit-parser/tests/nested-objects.test.js: -------------------------------------------------------------------------------- 1 | const getExport = require('../get-export'); 2 | 3 | describe('nested object parsing', () => { 4 | const testCases = [ 5 | { 6 | spec: 'inline', 7 | code: ` 8 | export default () => ({ 9 | driver: { 10 | method: () => {} 11 | } 12 | })`, 13 | expected: [ 14 | { 15 | name: 'driver', 16 | type: 'object', 17 | props: [{ name: 'method', type: 'function', args: [] }], 18 | }, 19 | ], 20 | }, 21 | { 22 | spec: 'symbol', 23 | code: ` 24 | const driver = { 25 | method: () => {} 26 | }; 27 | export default () => ({ 28 | driver 29 | })`, 30 | expected: [ 31 | { 32 | name: 'driver', 33 | type: 'object', 34 | props: [{ name: 'method', type: 'function', args: [] }], 35 | }, 36 | ], 37 | }, 38 | { 39 | spec: 'mixed inline & symbol', 40 | code: ` 41 | const driver = { 42 | method: () => {} 43 | }; 44 | export default () => ({ 45 | wrapper: { 46 | driver 47 | } 48 | })`, 49 | expected: [ 50 | { 51 | name: 'wrapper', 52 | type: 'object', 53 | props: [ 54 | { 55 | name: 'driver', 56 | type: 'object', 57 | props: [{ name: 'method', type: 'function', args: [] }], 58 | }, 59 | ], 60 | }, 61 | ], 62 | }, 63 | { 64 | spec: 'mixed inline & symbol with spreading', 65 | code: ` 66 | const anotherDriver = { 67 | method: () => {} 68 | }; 69 | const driver = { 70 | ...anotherDriver 71 | }; 72 | export default () => ({ 73 | wrapper: { 74 | driver 75 | } 76 | })`, 77 | expected: [ 78 | { 79 | name: 'wrapper', 80 | type: 'object', 81 | props: [ 82 | { 83 | name: 'driver', 84 | type: 'object', 85 | props: [{ name: 'method', type: 'function', args: [] }], 86 | }, 87 | ], 88 | }, 89 | ], 90 | }, 91 | { 92 | spec: 'identifier in function scope', 93 | code: ` 94 | export default () => { 95 | const driver = { 96 | method: () => {} 97 | } 98 | return { 99 | driver 100 | } 101 | }`, 102 | expected: [ 103 | { 104 | name: 'driver', 105 | type: 'object', 106 | props: [{ name: 'method', type: 'function', args: [] }], 107 | }, 108 | ], 109 | }, 110 | { 111 | spec: 'member expression function', 112 | code: ` 113 | const driver = { 114 | method: () => {} 115 | }; 116 | export default () => { 117 | return { 118 | driver: { 119 | method: driver.method 120 | } 121 | } 122 | }; 123 | `, 124 | expected: [ 125 | { 126 | name: 'driver', 127 | type: 'object', 128 | props: [{ name: 'method', type: 'function', args: [] }], 129 | } 130 | ] 131 | } 132 | ]; 133 | 134 | testCases.slice(testCases.length - 1).forEach(({ spec, code, expected }) => { 135 | it(`should parse ${spec}`, async () => { 136 | const result = await getExport(code); 137 | expect(result).toEqual(expected); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /src/testkit-parser/tests/optimizations.test.js: -------------------------------------------------------------------------------- 1 | const { optimizeSource } = require('../utils/optimizations'); 2 | 3 | describe('optimizations', () => { 4 | describe('optimizeSource', () => { 5 | it('should rewrite mergeDrivers to spread', () => { 6 | const sourceCode = 'return mergeDrivers(publicDriver, focusableDriver)'; 7 | 8 | expect(optimizeSource(sourceCode)).toEqual('return {...publicDriver, ...focusableDriver}'); 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/testkit-parser/utils/find-identifier-node.js: -------------------------------------------------------------------------------- 1 | const types = require('@babel/types'); 2 | const visit = require('../../parser/visit'); 3 | const followImport = require('./follow-import'); 4 | 5 | const findNodeOrImport = ({ ast, name }) => { 6 | return new Promise((resolve, reject) => { 7 | let found = false; 8 | visit(ast)({ 9 | enter(path) { 10 | if (path.node.id && path.node.id.name === name) { 11 | path.stop(); 12 | found = true; 13 | resolve({ node: path.node }); 14 | } else if (types.isImportDeclaration(path.node)) { 15 | let isDefaultExport = false; 16 | let importedName = ''; 17 | let isImportedIdentifier = path.node.specifiers.some(specifier => { 18 | const matchesName = specifier.local.name === name; 19 | if (matchesName) { 20 | isDefaultExport = types.isImportDefaultSpecifier(specifier); 21 | importedName = specifier.imported && specifier.imported.name; 22 | return true; 23 | } 24 | }); 25 | if (isImportedIdentifier) { 26 | found = true; 27 | resolve({ isImport: true, isDefaultExport, importedName, sourcePath: path.node.source.value }); 28 | } 29 | } 30 | }, 31 | }); 32 | 33 | if (!found) { 34 | reject(new ReferenceError(`Node with id ${name} not found`)); 35 | } 36 | }); 37 | }; 38 | 39 | const findIdentifierNode = async ({ name, ast, cwd }) => { 40 | const { node, isImport, sourcePath, isDefaultExport, importedName } = await findNodeOrImport({ ast, name }); 41 | if (isImport) { 42 | return followImport({ 43 | sourcePath, 44 | cwd, 45 | exportName: isDefaultExport ? undefined : importedName || name, 46 | }); 47 | } 48 | return node.init || node; 49 | }; 50 | 51 | module.exports = findIdentifierNode; 52 | -------------------------------------------------------------------------------- /src/testkit-parser/utils/flatten.js: -------------------------------------------------------------------------------- 1 | const flatten = arr => arr.reduce((acc, x) => acc.concat(x), []); 2 | 3 | module.exports = flatten; 4 | -------------------------------------------------------------------------------- /src/testkit-parser/utils/follow-import.js: -------------------------------------------------------------------------------- 1 | const readFile = require('../../read-file'); 2 | const parseDriver = require('./parse-driver'); 3 | const resolvePath = require('../../resolve-path'); 4 | const path = require('path'); 5 | const getExportedNode = data => { 6 | // Wrapper prevents circular dependency 7 | // 1) utils/follow-import.js > utils/get-exported-node.js 8 | // 2) utils/find-identifier-node.js > utils/follow-import.js > utils/get-exported-node.js > utils/reduce-to-object.js 9 | return require('./get-exported-node')(data); 10 | }; 11 | 12 | const followImport = async ({ cwd = '', sourcePath, exportName }) => { 13 | const finalPath = await resolvePath(cwd, sourcePath); 14 | const { source } = await readFile(finalPath); 15 | const ast = parseDriver(source); 16 | const scopedCwd = path.dirname(finalPath); 17 | const exportedNode = await getExportedNode({ ast, exportName, cwd: scopedCwd }); 18 | const scopedAst = exportedNode.ast || ast; 19 | return Object.assign(exportedNode, { ast: scopedAst, cwd: scopedCwd }); 20 | }; 21 | 22 | module.exports = followImport; 23 | -------------------------------------------------------------------------------- /src/testkit-parser/utils/get-exported-node.js: -------------------------------------------------------------------------------- 1 | const visit = require('../../parser/visit'); 2 | const reduceToObject = require('./reduce-to-object'); 3 | const followImport = require('./follow-import'); 4 | 5 | const DEFAULT_EXPORT = 'default'; 6 | 7 | const byName = name => ({ node }) => node.name === name; 8 | 9 | const byPattern = regex => ({ node }) => regex.test(node.name); 10 | 11 | const findNamedExportDeclaration = (nodes, predicate) => { 12 | const exportedNode = nodes.find(predicate); 13 | if (exportedNode) { 14 | return exportedNode.init || exportedNode; 15 | } 16 | }; 17 | 18 | const getFirstExportIfOnlyOneExists = nodes => nodes.length === 1 && nodes[0]; 19 | 20 | const isCommonJsExport = node => 21 | node.type === 'MemberExpression' && node.object.name === 'module' && node.property.name === 'exports'; 22 | 23 | const isCommonJsImport = node => node.type === 'CallExpression' && node.callee.name === 'require'; 24 | 25 | module.exports = async ({ ast, exportName = DEFAULT_EXPORT, cwd }) => { 26 | let exportedNode; 27 | let exportDefaultNode; 28 | let exportNamedNodes = []; 29 | 30 | visit(ast)({ 31 | ExportDefaultDeclaration(path) { 32 | exportDefaultNode = path.node.declaration; 33 | }, 34 | 35 | ExportNamedDeclaration(path) { 36 | const isSpecifierDefault = path.node.specifiers.some(({ exported }) => exported.name === 'default'); 37 | if (isSpecifierDefault) { 38 | exportDefaultNode = { 39 | source: path.node.source.value, 40 | local: { name: exportName }, 41 | }; 42 | } else { 43 | const exportSource = path.node.source && path.node.source.value; 44 | path.traverse({ 45 | VariableDeclarator(path) { 46 | exportNamedNodes.push({ node: path.node.id }); 47 | }, 48 | ExportSpecifier(path) { 49 | exportNamedNodes.push({ 50 | node: exportSource ? path.node.exported : path.node.local, 51 | local: path.node.local, 52 | source: exportSource, 53 | }); 54 | }, 55 | }); 56 | } 57 | }, 58 | 59 | AssignmentExpression({ node }) { 60 | if (isCommonJsExport(node.left) && isCommonJsImport(node.right)) { 61 | const source = node.right.arguments[0].value; 62 | exportNamedNodes.push({ 63 | source, 64 | local: { name: exportName }, 65 | node: { name: exportName }, 66 | }); 67 | } 68 | }, 69 | }); 70 | 71 | if (exportName === DEFAULT_EXPORT) { 72 | exportedNode = 73 | exportDefaultNode || 74 | findNamedExportDeclaration(exportNamedNodes, byPattern(/DriverFactory$/i)) || 75 | getFirstExportIfOnlyOneExists(exportNamedNodes); 76 | } else { 77 | exportedNode = findNamedExportDeclaration(exportNamedNodes, byName(exportName)); 78 | } 79 | 80 | if (exportedNode.source) { 81 | exportedNode = await followImport({ cwd, sourcePath: exportedNode.source, exportName: exportedNode.local.name }); 82 | } else if (exportedNode.node) { 83 | exportedNode = exportedNode.node; 84 | } 85 | 86 | if (!exportedNode) { 87 | throw Error(`export "${exportName}" not found`); 88 | } 89 | 90 | return await reduceToObject({ node: exportedNode, ast: exportedNode.ast || ast, cwd }); 91 | }; 92 | -------------------------------------------------------------------------------- /src/testkit-parser/utils/get-return-value.js: -------------------------------------------------------------------------------- 1 | const types = require('@babel/types'); 2 | const findIdentifierNode = require('./find-identifier-node'); 3 | 4 | const visitors = { 5 | ArrowFunctionExpression: ({ node }) => node.body, 6 | FunctionDeclaration: ({ node }) => node.body, 7 | Identifier: ({ node, ast, cwd }) => findIdentifierNode({ name: node.name, ast, cwd }), 8 | BlockStatement: ({ node }) => node.body.find(types.isReturnStatement), 9 | CallExpression: ({ node }) => node.callee, 10 | }; 11 | 12 | const getReturnValue = async ({ node, ast, cwd }) => { 13 | if (types.isArrowFunctionExpression(node) && !Array.isArray(node.body)) { 14 | return node.body; 15 | } 16 | 17 | if (types.isReturnStatement(node.type)) { 18 | return node.argument; 19 | } 20 | 21 | const visitor = visitors[node.type]; 22 | if (!visitor) { 23 | return node; 24 | } 25 | 26 | return await getReturnValue({ 27 | ast, 28 | node: await visitor({ node, ast, cwd }), 29 | cwd, 30 | }); 31 | }; 32 | 33 | module.exports = getReturnValue; 34 | -------------------------------------------------------------------------------- /src/testkit-parser/utils/is-testkit.js: -------------------------------------------------------------------------------- 1 | const isTestkit = path => (path.includes('.private') ? false : /\.driver\.(js|ts)x?$/.test(path)); 2 | 3 | module.exports = isTestkit; 4 | -------------------------------------------------------------------------------- /src/testkit-parser/utils/is-testkit.test.js: -------------------------------------------------------------------------------- 1 | const isTestkit = require('./is-testkit'); 2 | 3 | describe('isTestkit', () => { 4 | [ 5 | ['file.driver.js', true], 6 | ['file.protractor.driver.js', true], 7 | ['file.protractor.uni.driver.js', true], 8 | ['file.puppeteer.unidriver.private.driver.js', false], 9 | ['file.private.driver.js', false], 10 | ['file.private.uni.driver.js', false], 11 | ['index.js', false], 12 | ['file.js', false], 13 | ['driver.js', false], 14 | ].map(([assertion, expecation]) => { 15 | it(`should return ${expecation} for ${assertion}`, () => { 16 | expect(isTestkit(assertion)).toEqual(expecation); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/testkit-parser/utils/optimizations.js: -------------------------------------------------------------------------------- 1 | const types = require('@babel/types'); 2 | const visit = require('../../parser/visit'); 3 | 4 | const mergeDriversToSpread = sourceCode => { 5 | const regexMergeDrivers = /mergeDrivers\(([^,\\)\s]+),\s*([^,\\)\s]+)\s*\)/g; 6 | return sourceCode.replace(regexMergeDrivers, '{...$1, ...$2}'); 7 | }; 8 | 9 | const optimizeSource = sourceCode => mergeDriversToSpread(sourceCode); 10 | 11 | const isObjectAssign = node => 12 | types.isMemberExpression(node) && node.object.name === 'Object' && node.property.name === 'assign'; 13 | 14 | const replaceObjectAssignWithSpread = ast => { 15 | visit(ast)({ 16 | CallExpression(path) { 17 | if (isObjectAssign(path.node.callee)) { 18 | path.replaceWith(types.objectExpression(path.node.arguments.map(arg => types.spreadElement(arg)))); 19 | } 20 | }, 21 | }); 22 | return ast; 23 | }; 24 | 25 | const optimizeAST = ast => replaceObjectAssignWithSpread(ast); 26 | 27 | module.exports = { 28 | optimizeSource, 29 | optimizeAST, 30 | }; 31 | -------------------------------------------------------------------------------- /src/testkit-parser/utils/parse-driver.js: -------------------------------------------------------------------------------- 1 | const parse = require('../../parser/parse'); 2 | const { optimizeSource, optimizeAST } = require('./optimizations'); 3 | 4 | module.exports = source => optimizeAST(parse(optimizeSource(source))); 5 | -------------------------------------------------------------------------------- /src/testkit-parser/utils/reduce-to-object.js: -------------------------------------------------------------------------------- 1 | const types = require('@babel/types'); 2 | const resolveIdentifier = require('./find-identifier-node'); 3 | 4 | const visitors = { 5 | ArrowFunctionExpression: ({ node }) => node.body, 6 | FunctionDeclaration: ({ node }) => node.body, 7 | CallExpression: ({ node }) => node.callee, 8 | BlockStatement: ({ node }) => (Array.isArray(node.body) ? node.body.find(types.isReturnStatement) : node.body), 9 | ReturnStatement: ({ node }) => node.argument, 10 | Identifier: ({ node, ast, cwd }) => resolveIdentifier({ name: node.name, ast, cwd }), 11 | }; 12 | 13 | const reduceToObject = async ({ ast, node, cwd }) => { 14 | if (types.isObjectExpression(node)) { 15 | return node; 16 | } 17 | const visitor = visitors[node.type]; 18 | if (!visitor) { 19 | throw Error(`reduceToObject: not implemented for ${node.type}`); 20 | } 21 | 22 | return await reduceToObject({ 23 | ast, 24 | node: await visitor({ node, ast, cwd }), 25 | cwd, 26 | }); 27 | }; 28 | 29 | module.exports = reduceToObject; 30 | --------------------------------------------------------------------------------