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