├── .all-contributorsrc ├── .babelrc ├── .github ├── issue_template.md ├── stale.yml └── workflows │ └── nodejs.yml ├── .gitignore ├── .prettierrc ├── .vscode └── launch.json ├── CONTRIBUTING.MD ├── LICENSE ├── README.md ├── Troubleshooting.md ├── branding ├── Github Readme Banner.psd └── small-logo.psd ├── image.png ├── integration ├── cypress.json ├── cypress │ ├── fixtures │ │ └── example.json │ ├── integration │ │ └── basic │ │ │ └── basic-functionality.js │ ├── plugins │ │ └── index.js │ └── support │ │ ├── commands.js │ │ └── index.js ├── kill.js ├── package.json ├── projects │ └── basic │ │ ├── __snapshots__ │ │ └── test-snapshot-failure.spec.js.snap │ │ ├── app.js │ │ ├── babel.config.js │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── test-all-good.spec.js │ │ ├── test-few-failure.spec.js │ │ ├── test-only.spec.js │ │ ├── test-snapshot-failure.spec.js │ │ ├── test-snapshot-text.spec.js │ │ └── yarn.lock └── yarn.lock ├── nodemon.json ├── package.json ├── scripts ├── webpack.server.config.js └── webpack.ui.config.js ├── server ├── api │ ├── app │ │ ├── app.ts │ │ └── resolver.ts │ ├── index.ts │ ├── runner │ │ ├── resolver.ts │ │ ├── status.ts │ │ └── type.ts │ └── workspace │ │ ├── coverage.ts │ │ ├── resolver.ts │ │ ├── summary.ts │ │ ├── test-file.ts │ │ ├── test-item.ts │ │ ├── test-result │ │ ├── console-log.ts │ │ ├── file-result.ts │ │ └── test-item-result.ts │ │ ├── tree.ts │ │ └── workspace.ts ├── event-emitter │ └── index.ts ├── index.ts ├── logger.ts ├── services │ ├── ast │ │ ├── inspector.ts │ │ └── parser.ts │ ├── cli.ts │ ├── config-resolver.ts │ ├── file-watcher │ │ └── index.ts │ ├── jest-manager │ │ ├── cli-args.ts │ │ ├── index.ts │ │ └── scripts │ │ │ ├── patch.js │ │ │ └── reporter.js │ ├── project.ts │ ├── result-handler-api.ts │ ├── results.ts │ └── types.ts ├── static-files.ts └── typings.d.ts ├── tsconfig.json ├── tsconfig.server.json ├── ui ├── apollo-client.ts ├── app.gql ├── app.tsx ├── assets │ ├── favicon.ico │ └── logo.png ├── components │ └── button.tsx ├── container.tsx ├── coverage-panel │ └── index.tsx ├── error.tsx ├── hooks │ └── use-keys.ts ├── index.tsx ├── loading.tsx ├── query.gql ├── runner-status-query.gql ├── runner-status-subs.gql ├── search │ └── index.tsx ├── set-selected-file.gql ├── sidebar │ ├── execution-indicator.tsx │ ├── file-item.tsx │ ├── index.tsx │ ├── logo.tsx │ ├── run.gql │ ├── set-collect-coverage.gql │ ├── set-watch-mode.gql │ ├── should-collect-coverage.gql │ ├── summary │ │ └── index.tsx │ ├── transformer.ts │ └── tree.tsx ├── split-panel-style.ts ├── stop-runner.gql ├── summary-query.gql ├── summary-subscription.gql ├── test-file │ ├── console-panel │ │ └── index.tsx │ ├── error-panel │ │ └── index.tsx │ ├── file-items-subscription.gql │ ├── index.tsx │ ├── open-failure.gql │ ├── query.gql │ ├── result.gql │ ├── run-file.gql │ ├── subscription.gql │ ├── summary │ │ ├── index.tsx │ │ ├── open-in-editor.gql │ │ └── open-snap-in-editor.gql │ ├── test-indicator.tsx │ ├── test-item.tsx │ ├── transformer.ts │ ├── update-snapshot.gql │ └── use-subscription.tsx ├── theme.ts └── typings.d.ts └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "duncanbeevers", 10 | "name": "Duncan Beevers", 11 | "avatar_url": "https://avatars0.githubusercontent.com/u/7367?v=4", 12 | "profile": "http://www.duncanbeevers.com", 13 | "contributions": [ 14 | "code" 15 | ] 16 | }, 17 | { 18 | "login": "M4cs", 19 | "name": "Max Bridgland", 20 | "avatar_url": "https://avatars3.githubusercontent.com/u/34947910?v=4", 21 | "profile": "https://github.com/M4cs", 22 | "contributions": [ 23 | "doc", 24 | "ideas", 25 | "bug", 26 | "code" 27 | ] 28 | }, 29 | { 30 | "login": "yurm04", 31 | "name": "Yuraima Estevez", 32 | "avatar_url": "https://avatars0.githubusercontent.com/u/4642404?v=4", 33 | "profile": "https://github.com/yurm04", 34 | "contributions": [ 35 | "code" 36 | ] 37 | }, 38 | { 39 | "login": "jake-nz", 40 | "name": "Jake Crosby", 41 | "avatar_url": "https://avatars2.githubusercontent.com/u/437471?v=4", 42 | "profile": "http://jake.nz", 43 | "contributions": [ 44 | "code" 45 | ] 46 | }, 47 | { 48 | "login": "gavinhenderson", 49 | "name": "Gavin Henderson", 50 | "avatar_url": "https://avatars1.githubusercontent.com/u/1359202?v=4", 51 | "profile": "http://gavinhenderson.me", 52 | "contributions": [ 53 | "code" 54 | ] 55 | }, 56 | { 57 | "login": "briwa", 58 | "name": "briwa", 59 | "avatar_url": "https://avatars1.githubusercontent.com/u/8046636?v=4", 60 | "profile": "https://briwa.github.io", 61 | "contributions": [ 62 | "code" 63 | ] 64 | }, 65 | { 66 | "login": "Luanf", 67 | "name": "Luan Ferreira", 68 | "avatar_url": "https://avatars0.githubusercontent.com/u/9099705?v=4", 69 | "profile": "https://github.com/Luanf", 70 | "contributions": [ 71 | "code" 72 | ] 73 | }, 74 | { 75 | "login": "cse-tushar", 76 | "name": "Tushar Gupta", 77 | "avatar_url": "https://avatars3.githubusercontent.com/u/12570521?v=4", 78 | "profile": "https://github.com/cse-tushar", 79 | "contributions": [ 80 | "code" 81 | ] 82 | }, 83 | { 84 | "login": "agustif", 85 | "name": "Agusti Fernandez", 86 | "avatar_url": "https://avatars3.githubusercontent.com/u/6601142?v=4", 87 | "profile": "https://agu.st/", 88 | "contributions": [ 89 | "code", 90 | "ideas" 91 | ] 92 | }, 93 | { 94 | "login": "moos", 95 | "name": "Moos", 96 | "avatar_url": "https://avatars2.githubusercontent.com/u/233047?v=4", 97 | "profile": "http://blog.42at.com", 98 | "contributions": [ 99 | "bug", 100 | "code", 101 | "doc" 102 | ] 103 | }, 104 | { 105 | "login": "MacZel", 106 | "name": "MacZel", 107 | "avatar_url": "https://avatars3.githubusercontent.com/u/25805810?v=4", 108 | "profile": "http://maciejzelek.space", 109 | "contributions": [ 110 | "code", 111 | "ideas" 112 | ] 113 | }, 114 | { 115 | "login": "krazylegz", 116 | "name": "Vikram Dighe", 117 | "avatar_url": "https://avatars2.githubusercontent.com/u/36250?v=4", 118 | "profile": "https://github.com/krazylegz", 119 | "contributions": [ 120 | "code" 121 | ] 122 | }, 123 | { 124 | "login": "jsmey", 125 | "name": "John Smey", 126 | "avatar_url": "https://avatars2.githubusercontent.com/u/10177710?v=4", 127 | "profile": "https://github.com/jsmey", 128 | "contributions": [ 129 | "code", 130 | "ideas", 131 | "bug" 132 | ] 133 | }, 134 | { 135 | "login": "BuckAMayzing", 136 | "name": "BuckAMayzing", 137 | "avatar_url": "https://avatars2.githubusercontent.com/u/19292614?v=4", 138 | "profile": "https://github.com/BuckAMayzing", 139 | "contributions": [ 140 | "code", 141 | "bug" 142 | ] 143 | }, 144 | { 145 | "login": "rahulakrishna", 146 | "name": "Rahul A. Krishna", 147 | "avatar_url": "https://avatars2.githubusercontent.com/u/10240002?v=4", 148 | "profile": "http://rahulakrishna.github.io", 149 | "contributions": [ 150 | "code", 151 | "ideas", 152 | "tool" 153 | ] 154 | }, 155 | { 156 | "login": "amilajack", 157 | "name": "Amila Welihinda", 158 | "avatar_url": "https://avatars1.githubusercontent.com/u/6374832?v=4", 159 | "profile": "https://amilajack.com", 160 | "contributions": [ 161 | "infra" 162 | ] 163 | }, 164 | { 165 | "login": "gregveres", 166 | "name": "gregveres", 167 | "avatar_url": "https://avatars2.githubusercontent.com/u/12899823?v=4", 168 | "profile": "https://github.com/gregveres", 169 | "contributions": [ 170 | "bug", 171 | "code" 172 | ] 173 | }, 174 | { 175 | "login": "adamkleingit", 176 | "name": "adam klein", 177 | "avatar_url": "https://avatars3.githubusercontent.com/u/889418?v=4", 178 | "profile": "http://adamklein.dev", 179 | "contributions": [ 180 | "test", 181 | "code" 182 | ] 183 | }, 184 | { 185 | "login": "rbarbazz", 186 | "name": "Raphaël Barbazza", 187 | "avatar_url": "https://avatars1.githubusercontent.com/u/42906704?v=4", 188 | "profile": "http://www.raphaelbarbazza.com", 189 | "contributions": [ 190 | "code" 191 | ] 192 | }, 193 | { 194 | "login": "philals", 195 | "name": "Phil Alsford", 196 | "avatar_url": "https://avatars3.githubusercontent.com/u/8849355?v=4", 197 | "profile": "https://philalsford.com", 198 | "contributions": [ 199 | "doc" 200 | ] 201 | } 202 | ], 203 | "contributorsPerLine": 7, 204 | "projectName": "majestic", 205 | "projectOwner": "Raathigesh", 206 | "repoType": "github", 207 | "repoHost": "https://github.com", 208 | "skipCi": true 209 | } 210 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-react", 4 | [ 5 | "@babel/preset-typescript", 6 | { 7 | "isTSX": true, 8 | "allExtensions": true 9 | } 10 | ], 11 | "@babel/preset-env" 12 | ], 13 | "plugins": [ 14 | ["@babel/plugin-proposal-decorators", { "legacy": true }], 15 | ["@babel/plugin-proposal-class-properties", { "loose": true }], 16 | "@babel/plugin-proposal-object-rest-spread", 17 | "babel-plugin-styled-components" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | ## Is this a bug report or a feature request? 2 | 3 | Please specify whether this is a feature request or a bug. 4 | 5 | ## Version Info 6 | 7 | - Version of Majestic: 8 | - Version of Jest: 9 | - Version of Node: 10 | - Operating System: 11 | 12 | ## Reproduction Repo 13 | 14 | If this is a bug report, please provide a minimal github repository where this mentioned issue is reproducible. Majestic makes certain 15 | assumptions regarding the test setup and it's very hard to guess the issue without looking at the exact configurations that you are using. 16 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | daysUntilStale: 30 2 | daysUntilClose: 7 3 | onlyLabels: [🦄 Need more info] 4 | markComment: > 5 | This issue has been automatically marked as stale because it has not had 6 | recent activity. It will be closed if no further activity occurs. Thank you 7 | for your contributions. 8 | closeComment: false 9 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [12.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: npm install, build, and test 20 | run: | 21 | yarn install 22 | yarn prod 23 | yarn integration 24 | env: 25 | CI: true 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "runtimeArgs": ["-r", "./node_modules/ts-node/register"], 12 | "args": ["${workspaceFolder}/server/index.ts", "--noOpen"], 13 | "console": "integratedTerminal", 14 | "env": { 15 | "TS_NODE_PROJECT": "./tsconfig.server.json", 16 | "ROOT": "" 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /CONTRIBUTING.MD: -------------------------------------------------------------------------------- 1 | ### Preparing Majestic 2 | 3 | - Clone this repository 4 | - Install dependencies with `yarn install` 5 | 6 | ### Running Majestic 7 | 8 | Majestic has 2 main components as follows 9 | 10 | - The UI written in React JS and GraphQL 11 | - The UI source is in `./ui` - Running `yarn ui` will start the webpack dev server 12 | - A Node JS GraphQL server 13 | - The server source is in `./server` 14 | - Create a sample projector use one of your project with Jest so you can test your changes 15 | - If you are using VSCode, edit the `\.vscode\launch.json` file and change the `ROOT` directory to your sample project and then you can press `F5` to run the server. 16 | - If you are not using VSCode, edit the `\server\services\cli.ts` file and change the root path so you test with your sample project and then running `yarn watch-server` will start the server in watch mode 17 | 18 | ## Running integration test 19 | 20 | We have a couple of integration tests written using [Cypress](https://www.cypress.io/) available in the `./integration` folder. 21 | 22 | To run the integration test 23 | 24 | - Do a production build by running `yarn prod` 25 | - `cd ./integration` 26 | - Run `yarn prepare-packages` to install required packages 27 | - Run `yarn run-integration` to run the integration tests 28 | 29 | ### Building Production Bundle 30 | 31 | The UI is built by Webpack and the server is also built by Webpack to decrease install times. 32 | 33 | Run `yarn prod` to build a production bundle and the artifacts would be available in `dist` folder. 34 | 35 | ### Publishing a new release 36 | 37 | Running `yarn ship` will perform a production build and will publish a new version to npm. 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Raathigeshan Kugarajan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 | Join the community on Spectrum 12 | 13 |
14 | 15 |
16 | 17 | Majestic is a GUI for [Jest](https://jestjs.io/) 18 | 19 | - ✅ Run all the tests or a single file 20 | - ⏱ Toggle watch mode 21 | - 📸 Update snapshots 22 | - ❌ Examine test failures as they happen 23 | - ⏲ Console.log() to the UI for debugging 24 | - 🚔 Built-in coverage report 25 | - 🔍 Search tests 26 | - 💎 Works with flow and typescript projects 27 | - 📦 Works with Create react app 28 | 29 | > Majestic supports Jest 20 and above 30 | 31 | ### Get started 32 | 33 | Run majestic via `npx` in a project directory 34 | 35 | ```bash 36 | cd ./my-jest-project # go into a project with Jest 37 | npx majestic # execute majestic 38 | ``` 39 | 40 | or install Majestic globally via Yarn and run majestic 41 | 42 | ```bash 43 | yarn global add majestic # install majestic globally 44 | cd ./my-jest-project # go into a project with Jest 45 | majestic # execute majestic 46 | ``` 47 | 48 | or install Majestic globally via Npm and run majestic 49 | 50 | ```bash 51 | npm install majestic -g # install majestic globally 52 | cd ./my-jest-project # go into a project with Jest 53 | majestic # execute majestic 54 | ``` 55 | 56 | ### Running as an app 57 | 58 | Running with the `--app` flag will launch Majestic as a chrome app. 59 | 60 | ### Optional configuration 61 | 62 | You can configure Majestic by adding `majestic` key to `package.json`. 63 | 64 | ```javascript 65 | // package.json 66 | { 67 | "majestic": { 68 | // if majestic fails to find the Jest package, you can provide it here. Should be relative to the package.json 69 | "jestScriptPath": "../node_modules/jest/bin/jest.js", 70 | // if you want to pass additional arguments to Jest, do it here 71 | "args": ['--config=./path/to/config/file/jest.config.js'], 72 | // environment variables to pass to the process 73 | "env": { 74 | "CI": "true" 75 | } 76 | } 77 | } 78 | ``` 79 | 80 | #### Optional configuration in project with multiple Jest configuration files 81 | 82 | ```javascript 83 | { 84 | "majestic": { 85 | "jestScriptPath": "../node_modules/jest/bin/jest.js", 86 | "configs": { 87 | "config1": { 88 | "args": [], 89 | "env": {} 90 | }, 91 | "config2": { 92 | "args": [], 93 | "env": {} 94 | } 95 | } 96 | } 97 | } 98 | ``` 99 | 100 | ### Arguments 101 | 102 | `--config` - Will use this config from the list supplied in optional configuration. 103 | 104 | `--debug` - Will output extra debug info to console. Helps with debugging. 105 | 106 | `--noOpen` - Will prevent from automatically opening the UI url in the browser. 107 | 108 | `--port` - Will use this port if available, else Majestic will pick another free port. 109 | 110 | `--version` - Will print the version of Majestic and will exit. 111 | 112 | ### Shortcut keys 113 | 114 | `alt+t` - run all tests 115 | 116 | `alt+enter` - run selected file 117 | 118 | `alt+w` - watch 119 | 120 | `alt+s` - search 121 | 122 | `escape` - close search 123 | 124 | ### Troubleshooting 125 | 126 | Have a look at some of the [common workarounds](./Troubleshooting.md). 127 | 128 | ### Contribute 129 | 130 | Have a look at the [contribution guide](./CONTRIBUTING.MD). 131 | 132 | ## Contributors 133 | 134 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 |

Duncan Beevers

💻

Max Bridgland

📖 🤔 🐛 💻

Yuraima Estevez

💻

Jake Crosby

💻

Gavin Henderson

💻

briwa

💻

Luan Ferreira

💻

Tushar Gupta

💻

Agusti Fernandez

💻 🤔

Moos

🐛 💻 📖

MacZel

💻 🤔

Vikram Dighe

💻

John Smey

💻 🤔 🐛

BuckAMayzing

💻 🐛

Rahul A. Krishna

💻 🤔 🔧

Amila Welihinda

🚇

gregveres

🐛 💻

adam klein

⚠️ 💻

Raphaël Barbazza

💻

Phil Alsford

📖
167 | 168 | 169 | 170 | 171 | 172 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 173 | -------------------------------------------------------------------------------- /Troubleshooting.md: -------------------------------------------------------------------------------- 1 | #### Custom react-scripts 2 | 3 | If you're using a custom [react-scripts](https://www.npmjs.com/package/react-scripts) in your CRA app, set `jestScriptPath` to your script path. e.g.: 4 | 5 | ``` 6 | "jestScriptPath": "./node_modules/my-react-scripts/scripts/test.js" 7 | ``` 8 | 9 | #### Absolute import paths 10 | 11 | Set `NODE_PATH` in the majestic env config: 12 | 13 | ``` 14 | "env": { 15 | "NODE_PATH": "./src" 16 | } 17 | ``` 18 | 19 | #### Mocked networks 20 | 21 | When using [nock](https://github.com/nock/nock) (or other mock proxies) and get an error: 22 | 23 | > (node:50245) UnhandledPromiseRejectionWarning: FetchError: request to http://localhost:4000/test-result failed, reason: Nock: Not allow net connect for "localhost:4000/test-result" 24 | 25 | make sure to re-enable net connection after the test completes, e.g. (in a setup file) : 26 | 27 | ``` 28 | beforeAll(() => { 29 | nock.disableNetConnect(); 30 | }); 31 | 32 | afterAll(() => { 33 | nock.enableNetConnect(); 34 | }); 35 | ``` 36 | -------------------------------------------------------------------------------- /branding/Github Readme Banner.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Raathigesh/majestic/2bb0047188722610c7251e53717ce731bf5ec65e/branding/Github Readme Banner.psd -------------------------------------------------------------------------------- /branding/small-logo.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Raathigesh/majestic/2bb0047188722610c7251e53717ce731bf5ec65e/branding/small-logo.psd -------------------------------------------------------------------------------- /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Raathigesh/majestic/2bb0047188722610c7251e53717ce731bf5ec65e/image.png -------------------------------------------------------------------------------- /integration/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectId": "q19erz" 3 | } 4 | -------------------------------------------------------------------------------- /integration/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /integration/cypress/integration/basic/basic-functionality.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('basic', () => { 4 | beforeEach(() => { 5 | cy.visit('http://localhost:9000', { 6 | timeout: 9000, 7 | }); 8 | }); 9 | 10 | after(() => { 11 | cy.exec('yarn kill-app'); 12 | }); 13 | 14 | it('should display passing test count', () => { 15 | cy.wait(2000); 16 | cy.getByText('test-all-good.spec.js').click({ force: true }); 17 | cy.getByText('Run').click(); 18 | cy.wait(5000); 19 | cy.queryByText('6 Passing tests').should('exist'); 20 | }); 21 | 22 | it('should display failure tests', () => { 23 | cy.wait(2000); 24 | cy.getByText('test-few-failure.spec.js').click({ force: true }); 25 | cy.wait(2000); 26 | cy.getByText('Run').click(); 27 | cy.wait(5000); 28 | cy.queryByText('5 Passing tests').should('exist'); 29 | }); 30 | 31 | it('should show update snapshot button', () => { 32 | cy.wait(2000); 33 | cy.getByText('test-snapshot-failure.spec.js').click({ force: true }); 34 | cy.wait(2000); 35 | cy.getByText('Run').click(); 36 | cy.wait(5000); 37 | cy.queryByText('Update Snapshot').should('exist'); 38 | }); 39 | 40 | it('should not show update snapshot button', () => { 41 | cy.wait(2000); 42 | cy.getByText('test-snapshot-text.spec.js').click({ force: true }); 43 | cy.wait(2000); 44 | cy.getByText('Run').click(); 45 | cy.wait(5000); 46 | cy.queryByText('Update Snapshot').should('not.exist'); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /integration/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | }; 18 | -------------------------------------------------------------------------------- /integration/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | import 'cypress-testing-library/add-commands'; 27 | -------------------------------------------------------------------------------- /integration/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /integration/kill.js: -------------------------------------------------------------------------------- 1 | const fkill = require('fkill'); 2 | 3 | fkill(':9000', { 4 | force: true, 5 | tree: true, 6 | }) 7 | .then(() => { 8 | console.log('Killed process'); 9 | }) 10 | .catch(e => { 11 | console.log("Couldn't kill process: ", e); 12 | }); 13 | -------------------------------------------------------------------------------- /integration/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "integration", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "prepare-packages": "yarn && cd ./projects/basic && yarn", 8 | "open-tests": "cypress open", 9 | "run-tests": "wait-on http://localhost:9000 && cypress run --record --key a7d33ff7-5893-4158-9ec2-f71b32138c8b", 10 | "kill-app": "node ./kill.js", 11 | "prepare-basic-app": "node ./kill.js && cd ./projects/basic && node ../../../dist/server/index.js --port=9000 --debug", 12 | "integration-app": "concurrently --success=last \"yarn prepare-basic-app\" \"yarn run-tests\"", 13 | "run-integration": "yarn integration-app", 14 | "run-in-ci": "yarn prepare-packages && yarn run-integration" 15 | }, 16 | "dependencies": { 17 | "concurrently": "^4.1.0", 18 | "cypress": "^3.2.0", 19 | "cypress-testing-library": "^2.3.6", 20 | "fkill": "^6.0.0", 21 | "wait-on": "^3.2.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /integration/projects/basic/__snapshots__/test-snapshot-failure.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`test Snapsh0t test 1`] = ` 4 |
7 | Hello world 8 |
9 | `; 10 | -------------------------------------------------------------------------------- /integration/projects/basic/app.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | class App extends Component { 4 | render() { 5 | return
Hello world 123
; 6 | } 7 | } 8 | 9 | export default App; 10 | -------------------------------------------------------------------------------- /integration/projects/basic/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current', 8 | }, 9 | }, 10 | ], 11 | '@babel/preset-react', 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /integration/projects/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-jest", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "test": "jest" 8 | }, 9 | "dependencies": { 10 | "@babel/preset-react": "^7.0.0", 11 | "jest": "^25.3.0", 12 | "react": "^16.8.4", 13 | "react-dom": "^16.8.4", 14 | "react-test-renderer": "^16.8.4" 15 | }, 16 | "devDependencies": { 17 | "@babel/core": "^7.3.4", 18 | "@babel/preset-env": "^7.3.4", 19 | "babel-jest": "^25.3.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /integration/projects/basic/test-all-good.spec.js: -------------------------------------------------------------------------------- 1 | describe('test', () => { 2 | it('should add third', () => { 3 | expect(5).toBe(5); 4 | }); 5 | 6 | it('should add 1', () => { 7 | expect(5).toBe(5); 8 | }); 9 | 10 | it('should add 2', () => { 11 | expect(5).toBe(5); 12 | }); 13 | 14 | it('should add 3', () => { 15 | expect(5).toBe(5); 16 | }); 17 | 18 | it('should add 4', () => { 19 | expect(5).toBe(5); 20 | }); 21 | 22 | it('should add 5', () => { 23 | expect(5).toBe(5); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /integration/projects/basic/test-few-failure.spec.js: -------------------------------------------------------------------------------- 1 | describe('test', () => { 2 | it('should add third', () => { 3 | expect(5).toBe(6); 4 | }); 5 | 6 | it('should add 1', () => { 7 | expect(5).toBe(5); 8 | }); 9 | 10 | it('should add 2', () => { 11 | expect(5).toBe(5); 12 | }); 13 | 14 | it('should add 3', () => { 15 | expect(5).toBe(5); 16 | }); 17 | 18 | it('should add 4', () => { 19 | expect(5).toBe(5); 20 | }); 21 | 22 | it('should add 5', () => { 23 | expect(5).toBe(5); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /integration/projects/basic/test-only.spec.js: -------------------------------------------------------------------------------- 1 | describe('describe', () => { 2 | it('it', () => {}); 3 | test('test', () => {}); 4 | }); 5 | 6 | describe.only('describe.only', () => { 7 | it('it', () => {}); 8 | test('test', () => {}); 9 | it.only('it.only', () => {}); 10 | test.only('test.only', () => {}); 11 | }); 12 | 13 | fdescribe('fdescribe', () => { 14 | it('it', () => {}); 15 | fit('fit', () => {}); 16 | }); 17 | -------------------------------------------------------------------------------- /integration/projects/basic/test-snapshot-failure.spec.js: -------------------------------------------------------------------------------- 1 | import renderer from 'react-test-renderer'; 2 | import React from 'react'; 3 | import App from './app'; 4 | 5 | describe('test', () => { 6 | it('Snapsh0t test', () => { 7 | // Make sure we don't use 'snapshot' because it fools the snapshot button 8 | const tree = renderer.create().toJSON(); 9 | expect(tree).toMatchSnapshot(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /integration/projects/basic/test-snapshot-text.spec.js: -------------------------------------------------------------------------------- 1 | describe('test', () => { 2 | it('Should not show snapshot button', () => { 3 | expect('snapshot').toBe('a snapshot'); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [".git", "node_modules"], 3 | "watch": ["server"], 4 | "exec": "ts-node --project ./tsconfig.server.json ./server/index.ts", 5 | "ext": "ts" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "majestic", 3 | "version": "1.8.1", 4 | "engines": { 5 | "node": ">=7.10.1" 6 | }, 7 | "main": "index.js", 8 | "license": "MIT", 9 | "scripts": { 10 | "ui": "webpack-dev-server --env.development --config ./scripts/webpack.ui.config.js", 11 | "server": "ts-node --project ./tsconfig.server.json ./server/index.ts", 12 | "build-server": "cross-env BABEL_ENV='production' webpack --env.production --config ./scripts/webpack.server.config.js", 13 | "build-ui": "cross-env BABEL_ENV='production' rimraf dist && webpack --env.production --config ./scripts/webpack.ui.config.js", 14 | "prod": "npm run build-ui && npm run build-server", 15 | "watch-server": "nodemon", 16 | "ship": "npm run prod && np --yolo", 17 | "integration": "cd ./integration && yarn run-in-ci" 18 | }, 19 | "dependencies": { 20 | "node-fetch": "^2.3.0", 21 | "open": "^6.0.0", 22 | "read-pkg-up": "^4.0.0" 23 | }, 24 | "devDependencies": { 25 | "@babel/core": "^7.1.2", 26 | "@babel/parser": "^7.2.3", 27 | "@babel/plugin-proposal-class-properties": "^7.0.0", 28 | "@babel/plugin-proposal-decorators": "^7.1.2", 29 | "@babel/plugin-proposal-object-rest-spread": "^7.0.0", 30 | "@babel/plugin-proposal-optional-chaining": "^7.8.3", 31 | "@babel/polyfill": "^7.0.0", 32 | "@babel/preset-env": "^7.0.0", 33 | "@babel/preset-react": "^7.0.0", 34 | "@babel/preset-typescript": "^7.0.0", 35 | "@babel/traverse": "^7.2.3", 36 | "@types/babel-traverse": "^6.25.4", 37 | "@types/chokidar": "^1.7.5", 38 | "@types/express": "^4.16.0", 39 | "@types/istanbul-lib-coverage": "^2.0.0", 40 | "@types/istanbul-lib-source-maps": "^1.2.1", 41 | "@types/react": "^16.8.6", 42 | "@types/react-dom": "^16.8.2", 43 | "@types/react-split-pane": "^0.1.67", 44 | "@types/styled-components": "^4.1.4", 45 | "@types/styled-system": "^3.1.0", 46 | "ansi-to-html": "^0.6.10", 47 | "apollo-client": "^2.3.8", 48 | "apollo-client-preset": "^1.0.8", 49 | "apollo-link": "^1.2.3", 50 | "apollo-link-ws": "^1.0.9", 51 | "apollo-utilities": "^1.0.21", 52 | "awesome-typescript-loader": "^5.2.1", 53 | "babel-loader": "^8.0.2", 54 | "babel-plugin-styled-components": "^1.10.6", 55 | "body-parser": "^1.18.3", 56 | "chokidar": "^2.0.4", 57 | "chrome-launcher": "^0.10.5", 58 | "consola": "^2.5.7", 59 | "copy-webpack-plugin": "^5.0.1", 60 | "cross-env": "^5.2.0", 61 | "css-loader": "^1.0.0", 62 | "file-loader": "^3.0.1", 63 | "get-port": "^4.2.0", 64 | "graphql-tag": "^2.9.2", 65 | "graphql-yoga": "^1.16.1", 66 | "html-webpack-include-assets-plugin": "^1.0.5", 67 | "html-webpack-plugin": "^3.2.0", 68 | "html-webpack-template": "^6.2.0", 69 | "istanbul-lib-coverage": "^2.0.3", 70 | "istanbul-lib-source-maps": "^3.0.2", 71 | "launch-editor": "^2.2.1", 72 | "lodash.throttle": "^4.1.1", 73 | "minimist": "^1.2.0", 74 | "nanoid": "^2.0.0", 75 | "nodemon": "^1.18.3", 76 | "np": "^4.0.2", 77 | "react": "^16.8.3", 78 | "react-apollo": "^2.1.11", 79 | "react-apollo-hooks": "^0.2.1", 80 | "react-dom": "^16.8.3", 81 | "react-feather": "^1.1.4", 82 | "react-inspector": "^3.0.0", 83 | "react-split-pane": "^0.1.84", 84 | "react-spring": "^8.0.9", 85 | "react-tippy": "^1.2.3", 86 | "react-virtualized-auto-sizer": "^1.0.2", 87 | "react-window": "^1.6.2", 88 | "reflect-metadata": "^0.1.12", 89 | "resolve-pkg": "^1.0.0", 90 | "rimraf": "^2.6.2", 91 | "style-loader": "^0.23.0", 92 | "styled-components": "^4.1.3", 93 | "styled-system": "^3.1.11", 94 | "svg-inline-loader": "^0.8.0", 95 | "svg-react-loader": "^0.4.5", 96 | "ts-node": "^7.0.1", 97 | "type-graphql": "^0.14.0", 98 | "typeface-open-sans": "^0.0.54", 99 | "typescript": "^3.0.1", 100 | "uglifyjs-webpack-plugin": "^2.0.0", 101 | "url-loader": "^1.1.1", 102 | "webpack": "^4.17.1", 103 | "webpack-cli": "^3.1.0", 104 | "webpack-dev-server": "^3.2.1" 105 | }, 106 | "resolutions": { 107 | "graphql": "^0.13.0" 108 | }, 109 | "bin": { 110 | "majestic": "./dist/server/index.js" 111 | }, 112 | "files": [ 113 | "/dist/**", 114 | "/yarn.lock" 115 | ] 116 | } 117 | -------------------------------------------------------------------------------- /scripts/webpack.server.config.js: -------------------------------------------------------------------------------- 1 | const CopyPlugin = require('copy-webpack-plugin'); 2 | const webpack = require('webpack'); 3 | const path = require('path'); 4 | 5 | module.exports = env => ({ 6 | entry: './server/index.ts', 7 | mode: 'production', 8 | target: 'node', 9 | output: { 10 | path: path.resolve(__dirname, '../dist/server'), 11 | filename: 'index.js', 12 | libraryTarget: 'commonjs2', 13 | }, 14 | resolve: { 15 | mainFields: ['main'], 16 | extensions: ['.ts', '.js', '.jsx'], 17 | }, 18 | optimization: { 19 | minimize: false, 20 | }, 21 | devtool: 'source-map', 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.js$/, 26 | exclude: /(node_modules)/, 27 | loader: 'babel-loader', 28 | }, 29 | { 30 | test: /\.ts$/, 31 | exclude: /(node_modules)/, 32 | loader: 'awesome-typescript-loader', 33 | options: { 34 | transpileOnly: true, 35 | configFileName: './tsconfig.server.json', 36 | }, 37 | }, 38 | ], 39 | }, 40 | plugins: [ 41 | new webpack.DefinePlugin({ 42 | PRODUCTION: env.production === 'production', 43 | }), 44 | new CopyPlugin([ 45 | { from: './server/services/jest-manager/scripts', to: './scripts' }, 46 | ]), 47 | new webpack.BannerPlugin({ 48 | banner: '#!/usr/bin/env node', 49 | raw: true, 50 | }), 51 | ], 52 | externals: ['read-pkg-up', 'open'], 53 | node: { 54 | __dirname: false, 55 | }, 56 | }); 57 | -------------------------------------------------------------------------------- /scripts/webpack.ui.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 2 | const webpack = require('webpack'); 3 | const path = require('path'); 4 | 5 | module.exports = env => ({ 6 | entry: './ui/index.tsx', 7 | mode: env.production ? 'production' : 'development', 8 | output: { 9 | path: path.resolve(__dirname, '../dist/ui'), 10 | filename: 'ui.bundle.js', 11 | }, 12 | resolve: { 13 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 14 | }, 15 | devServer: { 16 | contentBase: path.resolve(__dirname, '../dist/ui'), 17 | hot: true, 18 | port: 9000, 19 | }, 20 | devtool: 'source-map', 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.(js|jsx|ts|tsx)$/, 25 | exclude: /(node_modules)/, 26 | loader: 'babel-loader', 27 | }, 28 | { 29 | test: /\.css$/, 30 | use: ['style-loader', 'css-loader'], 31 | }, 32 | { 33 | test: /\.(woff|woff2)(\?v=\d+\.\d+\.\d+)?$/, 34 | use: { 35 | loader: 'url-loader', 36 | options: { 37 | limit: 50000, 38 | }, 39 | }, 40 | }, 41 | { 42 | test: /\.(graphql|gql)$/, 43 | exclude: /node_modules/, 44 | loader: 'graphql-tag/loader', 45 | }, 46 | { 47 | test: /\.(png|svg|jpg|gif)$/, 48 | use: [ 49 | { 50 | loader: 'file-loader', 51 | options: { 52 | name: '[name].[ext]', 53 | }, 54 | }, 55 | ], 56 | }, 57 | ], 58 | }, 59 | plugins: [ 60 | new HtmlWebpackPlugin({ 61 | title: 'Majestic', 62 | template: require('html-webpack-template'), 63 | appMountId: 'root', 64 | inject: false, 65 | favicon: './ui/assets/favicon.ico', 66 | }), 67 | new webpack.HotModuleReplacementPlugin(), 68 | new webpack.DefinePlugin({ 69 | PRODUCTION: env.production === true, 70 | }), 71 | ], 72 | }); 73 | -------------------------------------------------------------------------------- /server/api/app/app.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from "type-graphql"; 2 | 3 | @ObjectType() 4 | export class App { 5 | @Field({ nullable: true }) 6 | selectedFile: string; 7 | } 8 | -------------------------------------------------------------------------------- /server/api/app/resolver.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Mutation, Arg, Query } from "type-graphql"; 2 | import * as launch from "launch-editor"; 3 | import { App } from "./app"; 4 | import FileWatcher, { WatcherEvents } from "../../services/file-watcher"; 5 | import { pubsub } from "../../event-emitter"; 6 | import { dirname, basename } from "path"; 7 | 8 | @Resolver(App) 9 | export default class AppResolver { 10 | private appInstance: App; 11 | private fileWatcher: FileWatcher; 12 | 13 | constructor() { 14 | this.fileWatcher = new FileWatcher(); 15 | this.appInstance = new App(); 16 | } 17 | 18 | @Query(returns => App) 19 | app() { 20 | return this.appInstance; 21 | } 22 | 23 | @Mutation(returns => App) 24 | setSelectedFile(@Arg("path", { nullable: true }) path: string) { 25 | this.appInstance.selectedFile = path; 26 | 27 | if (path) { 28 | this.fileWatcher.watch(path); 29 | pubsub.publish(WatcherEvents.FILE_CHANGE, { 30 | id: WatcherEvents.FILE_CHANGE, 31 | payload: { 32 | path 33 | } 34 | }); 35 | } 36 | 37 | return this.appInstance; 38 | } 39 | 40 | @Mutation(returns => String) 41 | openInEditor(@Arg("path") path: string) { 42 | launch(path, process.env.EDITOR || "code", (path: string, err: any) => { 43 | console.log("Failed to open file in editor. You may need to install the code command to your PATH if you are using VSCode: ", err); 44 | }); 45 | 46 | return ""; 47 | } 48 | 49 | @Mutation(returns => String) 50 | openSnapInEditor(@Arg("path") path: string) { 51 | var dir = dirname(path) 52 | var file = basename(path); 53 | 54 | var snap = dir + '/__snapshots__/' + file + '.snap' 55 | console.log("opening the snapshot:", snap); 56 | this.openInEditor(snap); 57 | 58 | return ""; 59 | } 60 | 61 | @Mutation(returns => String) 62 | 63 | openFailure(@Arg("failure") failure: string) { 64 | // The following regex matches the first line of the form: \w at () 65 | // it captures and returns that in the second position of the match array 66 | let re = new RegExp('^\\s+at.*?\\((.*?)\\)$', 'm'); 67 | let match = failure.match(re); 68 | if (match && match.length === 2) { 69 | const path = match[1]; 70 | launch(path, process.env.EDITOR || "code", (path: string, err: any) => { 71 | console.log("Failed to open file in editor. You may need to install the code command to your PATH if you are using VSCode: ", err); 72 | }); 73 | } 74 | else { 75 | console.log("Failed to find a path to a file to load in the failure string."); 76 | } 77 | return ""; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /server/api/index.ts: -------------------------------------------------------------------------------- 1 | import { buildSchema } from "type-graphql"; 2 | import { pubsub } from "../event-emitter"; 3 | import Workspace from "./workspace/resolver"; 4 | import Runner from "./runner/resolver"; 5 | import App from "./app/resolver"; 6 | 7 | export async function getSchema() { 8 | return await buildSchema({ 9 | resolvers: [Workspace, Runner, App], 10 | pubSub: pubsub as any 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /server/api/runner/resolver.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Resolver, 3 | Mutation, 4 | Arg, 5 | Query, 6 | Subscription, 7 | Root 8 | } from "type-graphql"; 9 | import { Runner } from "./type"; 10 | import JestManager, { 11 | RunnerEvents, 12 | RunnerEvent 13 | } from "../../services/jest-manager"; 14 | import Workspace from "../../services/project"; 15 | import { root } from "../../services/cli"; 16 | import { RunnerStatus } from "./status"; 17 | import { pubsub } from "../../event-emitter"; 18 | import ConfigResolver from "../../services/config-resolver"; 19 | 20 | @Resolver(Runner) 21 | export default class RunnerResolver { 22 | private jestManager: JestManager; 23 | private workspace: Workspace; 24 | private isRunning: boolean; 25 | private activeFile: string; 26 | private isWatching: boolean = false; 27 | private collectCoverage: boolean = false; 28 | 29 | constructor() { 30 | this.workspace = new Workspace(root); 31 | const configResolver = new ConfigResolver(); 32 | const majesticConfig = configResolver.getConfig(root); 33 | this.jestManager = new JestManager(this.workspace, majesticConfig); 34 | } 35 | 36 | @Query(returns => RunnerStatus) 37 | runnerStatus() { 38 | const status = new RunnerStatus(); 39 | status.activeFile = this.activeFile; 40 | status.running = this.isRunning; 41 | status.watching = this.isWatching; 42 | return status; 43 | } 44 | 45 | @Query(returns => Boolean) 46 | shouldCollectCoverage() { 47 | return this.collectCoverage; 48 | } 49 | 50 | @Subscription(returns => RunnerStatus, { 51 | topics: [ 52 | RunnerEvents.RUNNER_STARTED, 53 | RunnerEvents.RUNNER_STOPPED, 54 | RunnerEvents.RUNNER_WATCH_MODE_CHANGE, 55 | RunnerEvents.RUNNER_ACTIVE_FILE_CHANGE 56 | ] 57 | }) 58 | runnerStatusChange(@Root() event: RunnerEvent) { 59 | this.isRunning = 60 | event.payload.isRunning !== undefined 61 | ? event.payload.isRunning 62 | : this.isRunning; 63 | 64 | const status = new RunnerStatus(); 65 | status.activeFile = this.activeFile; 66 | status.running = this.isRunning; 67 | status.watching = this.isWatching; 68 | return status; 69 | } 70 | 71 | @Mutation(returns => String, { nullable: true }) 72 | runFile(@Arg("path") path: string) { 73 | this.activeFile = path; 74 | 75 | if (this.isWatching && this.isRunning) { 76 | pubsub.publish(RunnerEvents.RUNNER_ACTIVE_FILE_CHANGE, { 77 | id: RunnerEvents.RUNNER_ACTIVE_FILE_CHANGE, 78 | payload: {} 79 | }); 80 | 81 | return this.jestManager.switchToAnotherFile(path); 82 | } 83 | 84 | return this.jestManager.runSingleFile( 85 | path, 86 | this.isWatching, 87 | this.collectCoverage 88 | ); 89 | } 90 | 91 | @Mutation(returns => String, { nullable: true }) 92 | run() { 93 | this.activeFile = ""; 94 | this.isRunning = true; 95 | return this.jestManager.run(this.isWatching, this.collectCoverage); 96 | } 97 | 98 | @Mutation(returns => String, { nullable: true }) 99 | stop() { 100 | return this.jestManager.stop(); 101 | } 102 | 103 | @Mutation(returns => String, { nullable: true }) 104 | updateSnapshot(@Arg("path") path: string) { 105 | this.activeFile = path; 106 | return this.jestManager.updateSnapshotToFile(path); 107 | } 108 | 109 | @Mutation(returns => RunnerStatus, { nullable: true }) 110 | toggleWatch(@Arg("watch") watch: boolean) { 111 | this.isWatching = watch; 112 | 113 | pubsub.publish(RunnerEvents.RUNNER_WATCH_MODE_CHANGE, { 114 | id: RunnerEvents.RUNNER_WATCH_MODE_CHANGE, 115 | payload: {} 116 | }); 117 | } 118 | 119 | @Mutation(returns => Boolean) 120 | setCollectCoverage(@Arg("collect") collect: boolean) { 121 | this.collectCoverage = collect; 122 | return this.collectCoverage; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /server/api/runner/status.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, ID } from "type-graphql"; 2 | 3 | @ObjectType() 4 | export class RunnerStatus { 5 | @Field({ nullable: true }) 6 | running: boolean; 7 | 8 | @Field({ nullable: true }) 9 | activeFile: string; 10 | 11 | @Field({ nullable: true }) 12 | watching: boolean; 13 | } 14 | -------------------------------------------------------------------------------- /server/api/runner/type.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, ID } from "type-graphql"; 2 | 3 | @ObjectType() 4 | export class Runner { 5 | @Field() 6 | status: string; 7 | 8 | @Field() 9 | config: string; 10 | } 11 | -------------------------------------------------------------------------------- /server/api/workspace/coverage.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from "type-graphql"; 2 | 3 | @ObjectType() 4 | export class CoverageSummary { 5 | @Field({ nullable: true }) 6 | statement: number = 0; 7 | 8 | @Field({ nullable: true }) 9 | function: number = 0; 10 | 11 | @Field({ nullable: true }) 12 | branch: number = 0; 13 | 14 | @Field({ nullable: true }) 15 | line: number = 0; 16 | } 17 | -------------------------------------------------------------------------------- /server/api/workspace/resolver.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Resolver, 3 | Arg, 4 | Query, 5 | Subscription, 6 | Root, 7 | Mutation 8 | } from "type-graphql"; 9 | import * as throttle from "lodash.throttle"; 10 | import { Workspace } from "./workspace"; 11 | import Project from "../../services/project"; 12 | import { root } from "../../services/cli"; 13 | import { RunnerEvents } from "../../services/jest-manager"; 14 | import { TestFile } from "./test-file"; 15 | import { inspect } from "../../services/ast/inspector"; 16 | import { TestFileResult } from "./test-result/file-result"; 17 | import { 18 | Events, 19 | ResultEvent, 20 | SummaryEvent 21 | } from "../../services/result-handler-api"; 22 | import Results from "../../services/results"; 23 | import { WatcherEvents, FileChangeEvent } from "../../services/file-watcher"; 24 | import { Summary } from "./summary"; 25 | import { pubsub } from "../../event-emitter"; 26 | import ConfigResolver from "../../services/config-resolver"; 27 | import { MajesticConfig } from "../../services/types"; 28 | 29 | const SummaryEvent: "SummaryEvent" = "SummaryEvent"; 30 | 31 | @Resolver(Workspace) 32 | export default class WorkspaceResolver { 33 | private project: Project; 34 | private results: Results; 35 | private majesticConfig: MajesticConfig; 36 | 37 | constructor() { 38 | this.project = new Project(root); 39 | const configResolver = new ConfigResolver(); 40 | this.majesticConfig = configResolver.getConfig(root); 41 | this.results = new Results(root); 42 | this.results.getCoverageReportPath(this.majesticConfig); 43 | 44 | pubsub.publish("WorkspaceInitialized", { 45 | coverageDirectory: this.results.coverageDirectory 46 | }); 47 | 48 | this.results.checkIfCoverageReportExists(); 49 | 50 | pubsub.subscribe(Events.TEST_RESULT, ({ payload }: any) => { 51 | const result = new TestFileResult(); 52 | result.path = payload.path; 53 | result.failureMessage = payload.failureMessage; 54 | result.numPassingTests = payload.numPassingTests; 55 | result.numFailingTests = payload.numFailingTests; 56 | result.numPendingTests = payload.numPendingTests; 57 | result.testResults = payload.testResults; 58 | result.consoleLogs = payload.console; 59 | this.results.setTestReport(payload.path, result); 60 | this.notifySummaryChange(); 61 | }); 62 | 63 | pubsub.subscribe(Events.TEST_START, ({ payload }: any) => { 64 | this.results.setTestStart(payload.path); 65 | this.notifySummaryChange(); 66 | }); 67 | 68 | pubsub.subscribe(Events.RUN_SUMMARY, ({ payload }: any) => { 69 | const { 70 | numFailedTests, 71 | numPassedTests, 72 | numPassedTestSuites, 73 | numFailedTestSuites 74 | } = payload.summary; 75 | 76 | this.results.setSummary( 77 | numPassedTests, 78 | numFailedTests, 79 | numPassedTestSuites, 80 | numFailedTestSuites 81 | ); 82 | this.notifySummaryChange(); 83 | }); 84 | 85 | pubsub.subscribe(Events.RUN_COMPLETE, ({ payload }) => { 86 | this.results.mapCoverage(payload.coverageMap); 87 | 88 | setTimeout(() => { 89 | this.results.checkIfCoverageReportExists(); 90 | this.notifySummaryChange(); 91 | }, 2000); 92 | }); 93 | 94 | pubsub.subscribe(RunnerEvents.RUNNER_STOPPED, () => { 95 | this.results.markExecutingAsStopped(); 96 | }); 97 | } 98 | 99 | private notifySummaryChange = throttle(() => { 100 | pubsub.publish(SummaryEvent, {}); 101 | }, 1000); 102 | 103 | @Query(returns => Workspace) 104 | workspace() { 105 | const workspace = new Workspace(); 106 | workspace.projectRoot = this.project.projectRoot; 107 | workspace.name = "Jest project"; 108 | 109 | const fileMap = this.project.getFilesList(this.majesticConfig); 110 | workspace.files = Object.entries(fileMap).map(([key, value]: any) => ({ 111 | name: value.name, 112 | path: value.path, 113 | parent: value.parent, 114 | type: value.type 115 | })); 116 | 117 | return workspace; 118 | } 119 | 120 | @Query(returns => TestFile) 121 | async file(@Arg("path") path: string) { 122 | const file = new TestFile(); 123 | file.items = await inspect(path); 124 | return file; 125 | } 126 | 127 | @Query(returns => TestFileResult, { nullable: true }) 128 | result(@Arg("path") path: string) { 129 | const result = this.results.getResult(path); 130 | return result ? result : null; 131 | } 132 | 133 | @Subscription(returns => TestFile, { 134 | topics: [WatcherEvents.FILE_CHANGE] 135 | }) 136 | async fileChange(@Root() event: FileChangeEvent, @Arg("path") path: string) { 137 | const file = new TestFile(); 138 | file.items = await inspect(event.payload.path); 139 | return file; 140 | } 141 | 142 | @Subscription(returns => TestFileResult, { 143 | topics: [ 144 | Events.TEST_START, 145 | Events.TEST_RESULT, 146 | RunnerEvents.RUNNER_STOPPED 147 | ], 148 | filter: ({ payload: { payload }, args }) => { 149 | return payload.path === args.path; 150 | } 151 | }) 152 | async changeToResult( 153 | @Root() event: ResultEvent, 154 | @Arg("path") path: string 155 | ): Promise { 156 | const payload = event.payload; 157 | const result = new TestFileResult(); 158 | if (event.id === Events.TEST_START) { 159 | const existingResults = this.results.getResult(path); 160 | if (existingResults) { 161 | result.testResults = existingResults.testResults; 162 | } 163 | } 164 | else if (event.id === Events.TEST_RESULT) { 165 | result.path = path; 166 | result.failureMessage = payload.failureMessage; 167 | result.numPassingTests = payload.numPassingTests; 168 | result.numFailingTests = payload.numFailingTests; 169 | result.numPendingTests = payload.numPendingTests; 170 | result.testResults = payload.testResults; 171 | result.consoleLogs = payload.console; 172 | } 173 | return result; 174 | } 175 | 176 | @Subscription(returns => Summary, { 177 | topics: [SummaryEvent] 178 | }) 179 | async changeToSummary(@Root() event: SummaryEvent): Promise { 180 | const { 181 | numFailedTests, 182 | numPassedTests, 183 | numPassedTestSuites, 184 | numFailedTestSuites 185 | } = this.results.getSummary(); 186 | 187 | const summary = new Summary(); 188 | summary.numFailedTests = numFailedTests; 189 | summary.numPassedTests = numPassedTests; 190 | summary.numPassedTestSuites = numPassedTestSuites; 191 | summary.numFailedTestSuites = numFailedTestSuites; 192 | summary.failedTests = this.results.getFailedTests(); 193 | summary.executingTests = this.results.getExecutingTests(); 194 | summary.passingTests = this.results.getPassedTests(); 195 | summary.coverage = this.results.getCoverage(); 196 | summary.haveCoverageReport = this.results.doesHaveCoverageReport(); 197 | return summary; 198 | } 199 | 200 | @Query(returns => Summary, { nullable: true }) 201 | summary() { 202 | const { 203 | numFailedTests, 204 | numPassedTests, 205 | numPassedTestSuites, 206 | numFailedTestSuites 207 | } = this.results.getSummary(); 208 | const result = new Summary(); 209 | result.numFailedTests = numFailedTests; 210 | result.numPassedTests = numPassedTests; 211 | result.numPassedTestSuites = numPassedTestSuites; 212 | result.numFailedTestSuites = numFailedTestSuites; 213 | result.failedTests = this.results.getFailedTests(); 214 | result.executingTests = this.results.getExecutingTests(); 215 | result.passingTests = this.results.getPassedTests(); 216 | result.coverage = this.results.getCoverage(); 217 | result.haveCoverageReport = this.results.doesHaveCoverageReport(); 218 | return result; 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /server/api/workspace/summary.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from "type-graphql"; 2 | import { CoverageSummary } from "./coverage"; 3 | 4 | @ObjectType() 5 | export class Summary { 6 | @Field({ nullable: true }) 7 | numPassedTests: number = 0; 8 | 9 | @Field({ nullable: true }) 10 | numFailedTests: number = 0; 11 | 12 | @Field({ nullable: true }) 13 | numPassedTestSuites: number = 0; 14 | 15 | @Field({ nullable: true }) 16 | numFailedTestSuites: number = 0; 17 | 18 | @Field(returns => [String]) 19 | passingTests: string[] = []; 20 | 21 | @Field(returns => [String]) 22 | failedTests: string[] = []; 23 | 24 | @Field(returns => [String]) 25 | executingTests: string[] = []; 26 | 27 | @Field(returns => CoverageSummary, { nullable: true }) 28 | coverage: CoverageSummary; 29 | 30 | @Field(returns => Boolean, { nullable: true }) 31 | haveCoverageReport: boolean; 32 | } 33 | -------------------------------------------------------------------------------- /server/api/workspace/test-file.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from "type-graphql"; 2 | import { TestItem } from "./test-item"; 3 | 4 | @ObjectType() 5 | export class TestFile { 6 | @Field(returns => [TestItem]) 7 | items: TestItem[]; 8 | } 9 | -------------------------------------------------------------------------------- /server/api/workspace/test-item.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from "type-graphql"; 2 | 3 | export type TestItemType = "describe" | "it" | "todo"; 4 | 5 | @ObjectType() 6 | export class TestItem { 7 | @Field() 8 | id: string; 9 | 10 | @Field({ nullable: true }) 11 | name: string; 12 | 13 | @Field() 14 | type: TestItemType; 15 | 16 | @Field({ nullable: true }) 17 | parent?: string; 18 | 19 | @Field() 20 | only: boolean; 21 | } 22 | -------------------------------------------------------------------------------- /server/api/workspace/test-result/console-log.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from "type-graphql"; 2 | 3 | @ObjectType() 4 | export class ConsoleLog { 5 | @Field({ nullable: true }) 6 | message: string; 7 | 8 | @Field({ nullable: true }) 9 | origin: string; 10 | 11 | @Field({ nullable: true }) 12 | type: string; 13 | } 14 | -------------------------------------------------------------------------------- /server/api/workspace/test-result/file-result.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from "type-graphql"; 2 | import { TestItemResult } from "./test-item-result"; 3 | import { ConsoleLog } from "./console-log"; 4 | 5 | @ObjectType() 6 | export class TestFileResult { 7 | @Field({ nullable: true }) 8 | path: string; 9 | 10 | @Field({ nullable: true }) 11 | numFailingTests: number = 0; 12 | 13 | @Field({ nullable: true }) 14 | numPassingTests: number = 0; 15 | 16 | @Field({ nullable: true }) 17 | numPendingTests: number = 0; 18 | 19 | @Field({ nullable: true }) 20 | failureMessage: string; 21 | 22 | @Field(returns => TestItemResult, { nullable: true }) 23 | testResults: TestItemResult[] | null; 24 | 25 | @Field(returns => ConsoleLog, { nullable: true }) 26 | consoleLogs: ConsoleLog[]; 27 | } 28 | -------------------------------------------------------------------------------- /server/api/workspace/test-result/test-item-result.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from "type-graphql"; 2 | 3 | @ObjectType() 4 | export class TestItemResult { 5 | @Field() 6 | title: string; 7 | 8 | @Field() 9 | numPassingAsserts: number; 10 | 11 | @Field() 12 | status: string; 13 | 14 | @Field(returns => [String]) 15 | failureMessages: string[] = []; 16 | 17 | @Field(returns => [String]) 18 | ancestorTitles: string[] = []; 19 | 20 | @Field() 21 | duration: number; 22 | } 23 | -------------------------------------------------------------------------------- /server/api/workspace/tree.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, ID } from "type-graphql"; 2 | 3 | @ObjectType() 4 | export class Item { 5 | @Field() 6 | path: string; 7 | 8 | @Field() 9 | name: string; 10 | 11 | @Field() 12 | type: "directory" | "file"; 13 | 14 | @Field({ nullable: true }) 15 | parent?: string; 16 | } 17 | -------------------------------------------------------------------------------- /server/api/workspace/workspace.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, ID } from "type-graphql"; 2 | import { Item } from "./tree"; 3 | 4 | @ObjectType() 5 | export class Workspace { 6 | @Field() 7 | projectRoot: string; 8 | 9 | @Field() 10 | name: string; 11 | 12 | @Field(type => [Item]) 13 | files: Item[]; 14 | } 15 | -------------------------------------------------------------------------------- /server/event-emitter/index.ts: -------------------------------------------------------------------------------- 1 | import { PubSub } from "graphql-yoga"; 2 | 3 | export const pubsub = new PubSub(); 4 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLServer } from "graphql-yoga"; 2 | import "reflect-metadata"; 3 | import { getSchema } from "./api"; 4 | import resultHandlerApi from "./services/result-handler-api"; 5 | import getPort from "get-port"; 6 | import * as parseArgs from "minimist"; 7 | import * as chromeLauncher from "chrome-launcher"; 8 | import * as opn from "open"; 9 | import "consola"; 10 | import { initializeStaticRoutes } from "./static-files"; 11 | import { root } from "./services/cli"; 12 | import * as readPkgUp from "read-pkg-up"; 13 | 14 | const pkg = readPkgUp.sync({ 15 | cwd: __dirname 16 | }).pkg; 17 | declare var consola: any; 18 | 19 | const args = parseArgs(process.argv); 20 | const defaultPort = args.port || 4000; 21 | process.env.DEBUG_LOG = args.debug ? "log" : ""; 22 | 23 | if (args.root) { 24 | process.env.ROOT = args.root; 25 | } 26 | 27 | if (args.version) { 28 | console.log(`v${pkg.version}`); 29 | process.exit(); 30 | } 31 | 32 | async function main() { 33 | try { 34 | const schema: any = await getSchema(); 35 | const server = new GraphQLServer({ schema }); 36 | initializeStaticRoutes(server.express, root); 37 | resultHandlerApi(server.express); 38 | 39 | const port = await getPort({ port: defaultPort }); 40 | // this will be used by the jest reporter 41 | process.env.MAJESTIC_PORT = port.toString(); 42 | 43 | server.start( 44 | { 45 | port, 46 | playground: "/debug" 47 | }, 48 | async () => { 49 | const url = `http://localhost:${port}`; 50 | console.log(`⚡ Majestic v${pkg.version} is running at ${url} `); 51 | 52 | if (args.app) { 53 | await chromeLauncher.launch({ 54 | startingUrl: url, 55 | chromeFlags: [`--app=${url}`] 56 | }); 57 | } else if (!args.noOpen) { 58 | opn(url); 59 | } 60 | } 61 | ); 62 | } catch (e) { 63 | consola.error(e); 64 | } 65 | } 66 | 67 | main(); 68 | -------------------------------------------------------------------------------- /server/logger.ts: -------------------------------------------------------------------------------- 1 | declare var consola: any; 2 | 3 | export function debugLog(tag: string, ...args: any) { 4 | if (process.env.DEBUG_LOG !== "") { 5 | consola.info({ 6 | tag, 7 | args 8 | }); 9 | } 10 | } 11 | 12 | export function executeAndLog( 13 | tag: string, 14 | message: string, 15 | execute: () => any 16 | ) { 17 | if (process.env.DEBUG_LOG !== "") { 18 | consola.info({ 19 | tag, 20 | args: [message, execute()] 21 | }); 22 | } 23 | } 24 | 25 | export function createLogger(tag: string) { 26 | return (...args: any) => debugLog(tag, ...args); 27 | } 28 | -------------------------------------------------------------------------------- /server/services/ast/inspector.ts: -------------------------------------------------------------------------------- 1 | import traverse from "@babel/traverse"; 2 | import * as nanoid from "nanoid"; 3 | import { parse } from "./parser"; 4 | import { readFile } from "fs"; 5 | import { TestItem, TestItemType } from "../../api/workspace/test-item"; 6 | 7 | export async function inspect(path: string): Promise { 8 | return new Promise((resolve, reject) => { 9 | readFile( 10 | path, 11 | { 12 | encoding: "utf8" 13 | }, 14 | (err, code) => { 15 | if (err) { 16 | reject(err); 17 | } 18 | 19 | let ast; 20 | try { 21 | ast = parse(path, code); 22 | } catch (e) { 23 | reject(e); 24 | } 25 | 26 | const result: TestItem[] = []; 27 | 28 | traverse(ast, { 29 | CallExpression(path: any) { 30 | if (path.scope.block.type === "Program") { 31 | findItems(path, result); 32 | } 33 | } 34 | }); 35 | resolve(result); 36 | } 37 | ); 38 | }); 39 | } 40 | 41 | function getTemplateLiteralName(path: any) { 42 | let currentExpressionIndex = 0; 43 | const { expressions, quasis } = path.node.arguments[0]; 44 | 45 | return `\`${quasis.reduce((finalText: String, q: any) => { 46 | if ( 47 | expressions[currentExpressionIndex] && 48 | q.end === expressions[currentExpressionIndex].start - 2 49 | ) { 50 | const formattedExpression = `${q.value.raw}\$\{${expressions[currentExpressionIndex].name}\}`; 51 | currentExpressionIndex += 1; 52 | 53 | return finalText.concat(formattedExpression); 54 | } else { 55 | return finalText.concat(q.value.raw); 56 | } 57 | }, '')}\``; 58 | } 59 | 60 | function findItems(path: any, result: TestItem[], parentId?: any) { 61 | let type: string; 62 | let only: boolean = false; 63 | if (path.node.callee.name === "fdescribe") { 64 | type = "describe"; 65 | only = true; 66 | } else if (path.node.callee.name === "fit") { 67 | type = "it"; 68 | only = true; 69 | } else if ( 70 | path.node.callee.property && 71 | path.node.callee.property.name === "only" 72 | ) { 73 | type = path.node.callee.object.name; 74 | only = true; 75 | } else if (path.node.callee.name === "test") { 76 | type = "it"; 77 | } else if ( 78 | path.node.callee.property && 79 | path.node.callee.property.name === "todo" 80 | ) { 81 | type = "todo"; 82 | } else { 83 | type = path.node.callee.name; 84 | } 85 | 86 | if (type === "describe") { 87 | let describe: any; 88 | if (path.node.arguments[0].type === "TemplateLiteral") { 89 | describe = { 90 | id: nanoid(), 91 | type: "describe" as TestItemType, 92 | name: getTemplateLiteralName(path), 93 | only, 94 | parent: parentId 95 | }; 96 | } else { 97 | describe = { 98 | id: nanoid(), 99 | type: "describe" as TestItemType, 100 | name: path.node.arguments[0].value, 101 | only, 102 | parent: parentId 103 | }; 104 | } 105 | result.push(describe); 106 | path.skip(); 107 | path.traverse({ 108 | CallExpression(itPath: any) { 109 | findItems(itPath, result, describe.id); 110 | } 111 | }); 112 | } else if (type === "it") { 113 | if (path.node.arguments[0].type === "TemplateLiteral") { 114 | result.push({ 115 | id: nanoid(), 116 | type: "it", 117 | name: getTemplateLiteralName(path), 118 | only, 119 | parent: parentId 120 | }); 121 | } else { 122 | result.push({ 123 | id: nanoid(), 124 | type: "it", 125 | name: path.node.arguments[0].value, 126 | only, 127 | parent: parentId 128 | }); 129 | } 130 | } else if (type === "todo") { 131 | if (path.node.arguments[0].type === "TemplateLiteral") { 132 | result.push({ 133 | id: nanoid(), 134 | type: "todo", 135 | name: getTemplateLiteralName(path), 136 | only, 137 | parent: parentId 138 | }); 139 | } else { 140 | result.push({ 141 | id: nanoid(), 142 | type: "todo", 143 | name: path.node.arguments[0].value, 144 | only, 145 | parent: parentId 146 | }); 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /server/services/ast/parser.ts: -------------------------------------------------------------------------------- 1 | import * as parser from "@babel/parser"; 2 | import { extname } from "path"; 3 | 4 | export function parse(path: string, code: string) { 5 | const isTS = [".ts", ".tsx"].indexOf(extname(path).toLowerCase()) > -1; 6 | const additionalPlugin = isTS ? "typescript" : "flow"; 7 | 8 | return parser.parse(code, { 9 | sourceType: "module", 10 | plugins: ["jsx", "classProperties", "optionalChaining", additionalPlugin] 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /server/services/cli.ts: -------------------------------------------------------------------------------- 1 | export const root = process.env.ROOT || process.cwd(); 2 | -------------------------------------------------------------------------------- /server/services/config-resolver.ts: -------------------------------------------------------------------------------- 1 | import * as parseArgs from "minimist"; 2 | import * as readPkgUp from "read-pkg-up"; 3 | import * as resolvePkg from "resolve-pkg"; 4 | import { MajesticConfig } from "./types"; 5 | import { platform } from "os"; 6 | import { join } from "path"; 7 | import { existsSync } from "fs"; 8 | import { createLogger } from "../logger"; 9 | 10 | declare var consola: any; 11 | const log = createLogger("Config Resolver"); 12 | 13 | export default class ConfigResolver { 14 | public getConfig(projectRoot: string): MajesticConfig { 15 | let jestScriptPath = null; 16 | let args: string[] = []; 17 | let env: any = {}; 18 | const configFromPkgJson = this.getConfigFromPackageJson(projectRoot) || {}; 19 | 20 | const jestScriptPathFromPackage = configFromPkgJson.jestScriptPath 21 | ? join(projectRoot, configFromPkgJson.jestScriptPath) 22 | : null; 23 | 24 | if (this.isBootstrappedWithCreateReactApp(projectRoot)) { 25 | log("Project identified as Create react app"); 26 | 27 | jestScriptPath = 28 | jestScriptPathFromPackage || 29 | this.getJestScriptForCreateReactApp(projectRoot); 30 | args = ["--env=jsdom"]; 31 | env = { 32 | CI: "true" 33 | }; 34 | } else { 35 | log("Majestic configuration from Package.json: ", configFromPkgJson); 36 | 37 | jestScriptPath = 38 | jestScriptPathFromPackage || this.getJestScriptPath(projectRoot); 39 | } 40 | 41 | const configArg = parseArgs(process.argv).config; 42 | 43 | if (configArg && configFromPkgJson.configs) { 44 | args = [...args, ...(configFromPkgJson.configs[configArg].args || [])]; 45 | env = { ...env, ...(configFromPkgJson.configs[configArg].env || {}) }; 46 | } else { 47 | args = [...args, ...(configFromPkgJson.args || [])]; 48 | env = { ...env, ...(configFromPkgJson.env || {}) }; 49 | } 50 | 51 | const majesticConfig = { 52 | jestScriptPath: `"${jestScriptPath}"`, 53 | args, 54 | env 55 | }; 56 | 57 | log("Resolved Majestic config :", majesticConfig); 58 | return majesticConfig; 59 | } 60 | 61 | private getJestScriptPath(projectRoot: string) { 62 | const path = resolvePkg("jest", { 63 | cwd: projectRoot 64 | }); 65 | log("Path of resolved Jest script: ", path); 66 | 67 | if (!path) { 68 | consola.error( 69 | "🚨 Majestic was unable to find Jest package in node_modules folder. But you can provide the path manually. Please take a look at the documentation at https://github.com/Raathigesh/majestic." 70 | ); 71 | process.exit(); 72 | } 73 | return join(path, "bin/jest.js"); 74 | } 75 | 76 | private getJestScriptForCreateReactApp(projectRoot: string) { 77 | const path = resolvePkg("react-scripts", { 78 | cwd: projectRoot 79 | }); 80 | return join(path, "scripts/test.js"); 81 | } 82 | 83 | private getPackageJson(rootPath: string) { 84 | return readPkgUp.sync({ 85 | cwd: rootPath 86 | }).pkg; 87 | } 88 | 89 | private getConfigFromPackageJson(projectRoot: string) { 90 | const packageJson = this.getPackageJson(projectRoot); 91 | if (packageJson.majestic) { 92 | return packageJson.majestic; 93 | } 94 | return null; 95 | } 96 | 97 | private isBootstrappedWithCreateReactApp(rootPath: string): boolean { 98 | return ( 99 | this.hasExecutable(rootPath, "node_modules/.bin/react-scripts") || 100 | this.hasExecutable( 101 | rootPath, 102 | "node_modules/react-scripts/node_modules/.bin/jest" 103 | ) || 104 | this.hasExecutable(rootPath, "node_modules/react-native-scripts") 105 | ); 106 | } 107 | 108 | private hasExecutable(rootPath: string, executablePath: string): boolean { 109 | const ext = platform() === "win32" ? ".cmd" : ""; 110 | const absolutePath = join(rootPath, executablePath + ext); 111 | return existsSync(absolutePath); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /server/services/file-watcher/index.ts: -------------------------------------------------------------------------------- 1 | import { pubsub } from "../../event-emitter"; 2 | import { watch } from "fs"; 3 | import { createLogger } from "../../logger"; 4 | 5 | const log = createLogger("File watcher"); 6 | 7 | export const WatcherEvents = { 8 | FILE_CHANGE: "FILE_CHANGE" 9 | }; 10 | 11 | export interface FileChangeEvent { 12 | id: string; 13 | payload: { 14 | path: string; 15 | }; 16 | } 17 | 18 | export default class FileWatcher { 19 | private watcher: any; 20 | 21 | watch(filePath: string) { 22 | if (this.watcher) { 23 | this.watcher.close(); 24 | log("Closed existing file watcher"); 25 | } 26 | 27 | log("Watching file :", filePath); 28 | this.watcher = watch(filePath, () => { 29 | log("File changed", filePath); 30 | pubsub.publish(WatcherEvents.FILE_CHANGE, { 31 | id: WatcherEvents.FILE_CHANGE, 32 | payload: { 33 | path: filePath 34 | } 35 | }); 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /server/services/jest-manager/cli-args.ts: -------------------------------------------------------------------------------- 1 | export const ShowConfig = "--showConfig"; 2 | -------------------------------------------------------------------------------- /server/services/jest-manager/index.ts: -------------------------------------------------------------------------------- 1 | import { spawn, ChildProcess, execSync } from "child_process"; 2 | import { join } from "path"; 3 | import Project from "../project"; 4 | import { pubsub } from "../../event-emitter"; 5 | import { MajesticConfig } from "../types"; 6 | import { createLogger } from "../../logger"; 7 | 8 | const log = createLogger("Jest Manager"); 9 | 10 | export const RunnerEvents = { 11 | RUNNER_STARTED: "RunnerStarted", 12 | RUNNER_STOPPED: "RunnerStopped", 13 | RUNNER_WATCH_MODE_CHANGE: "WatchModeChanged", 14 | RUNNER_ACTIVE_FILE_CHANGE: "RunnerActiveFileChange" 15 | }; 16 | 17 | export interface RunnerEvent { 18 | id: string; 19 | payload: { 20 | isRunning: boolean; 21 | }; 22 | } 23 | 24 | export default class JestManager { 25 | project: Project; 26 | process: ChildProcess; 27 | config: MajesticConfig; 28 | 29 | constructor(project: Project, config: MajesticConfig) { 30 | this.project = project; 31 | this.config = config; 32 | } 33 | 34 | run(watch: boolean, collectCoverage: boolean) { 35 | this.executeJest( 36 | [ 37 | "--reporters", 38 | this.getReporterPath(), 39 | ...(watch ? [this.getWatchFlag()] : []) 40 | ], 41 | true, 42 | true, 43 | collectCoverage 44 | ); 45 | } 46 | 47 | runSingleFile(path: string, watch: boolean, collectCoverage: boolean) { 48 | this.executeJest( 49 | [ 50 | this.getPatternForPath(path), 51 | ...(watch ? [this.getWatchFlag()] : []), 52 | "--reporters", 53 | "default", 54 | this.getReporterPath(), 55 | "--verbose=false" // this would allow jest to include console output in the result of reporter 56 | ], 57 | !watch, // while watching, can not inherit stdio because we want to write back and interact with the process 58 | false, 59 | collectCoverage 60 | ); 61 | } 62 | 63 | updateSnapshotToFile(path: string) { 64 | this.executeJest( 65 | [ 66 | this.getPatternForPath(path), 67 | "-u", 68 | "--reporters", 69 | this.getReporterPath() 70 | ], 71 | false, 72 | false, 73 | false 74 | ); 75 | } 76 | 77 | switchToAnotherFile(path: string) { 78 | this.executeInSequence([ 79 | { 80 | fn: () => this.process.stdin && this.process.stdin.write("p"), 81 | delay: 0 82 | }, 83 | { 84 | fn: () => 85 | this.process.stdin && 86 | this.process.stdin.write(this.getPatternForPath(path)), 87 | delay: 100 88 | }, 89 | { 90 | fn: () => 91 | this.process.stdin && 92 | this.process.stdin.write(new Buffer("0d", "hex").toString()), 93 | delay: 200 94 | } 95 | ]); 96 | } 97 | 98 | executeJest( 99 | args: string[] = [], 100 | inherit: boolean, 101 | shouldReportSummary: boolean, 102 | collectCoverage: boolean 103 | ) { 104 | if (!this.config.jestScriptPath) { 105 | throw new Error("Jest script path is empty"); 106 | } 107 | 108 | this.reportStart(); 109 | 110 | const finalArgs = [ 111 | "-r", 112 | this.getPatchFilePath(), 113 | this.config.jestScriptPath, 114 | ...(this.config.args || []), 115 | "--colors", 116 | ...(collectCoverage 117 | ? ["--collectCoverage=true"] 118 | : ["--collectCoverage=false"]), 119 | ...args 120 | ]; 121 | 122 | const finalEnv = { 123 | ...(this.config.env || {}), 124 | MAJESTIC_PORT: process.env.MAJESTIC_PORT, 125 | REPORT_SUMMARY: shouldReportSummary ? "report" : "" 126 | }; 127 | 128 | log("Executing Jest with :", finalArgs, finalEnv); 129 | 130 | this.process = spawn("node", finalArgs, { 131 | cwd: this.project.projectRoot, 132 | shell: true, 133 | stdio: inherit ? "inherit" : "pipe", 134 | env: { ...(process.env || {}), ...finalEnv } 135 | }); 136 | 137 | this.process.on("exit", () => { 138 | this.reportStop(); 139 | }); 140 | 141 | this.process.stdout && 142 | this.process.stdout.on("data", (data: string) => { 143 | console.log(data.toString().trim()); 144 | }); 145 | 146 | this.process.stderr && 147 | this.process.stderr.on("data", (data: string) => { 148 | console.log(data.toString().trim()); 149 | }); 150 | } 151 | 152 | getReporterPath() { 153 | return `"${join(__dirname, "./scripts/reporter.js")}"`; 154 | } 155 | 156 | getPatchFilePath() { 157 | return `"${join(__dirname, "./scripts/patch.js")}"`; 158 | } 159 | 160 | getPatternForPath(path: string) { 161 | let replacePattern = /\//g; 162 | if (process.platform === "win32") { 163 | replacePattern = /\\/g; 164 | } 165 | return `^${path.replace(replacePattern, ".")}$`; 166 | } 167 | 168 | reportStart() { 169 | pubsub.publish(RunnerEvents.RUNNER_STARTED, { 170 | id: RunnerEvents.RUNNER_STARTED, 171 | payload: { 172 | isRunning: true 173 | } 174 | }); 175 | } 176 | 177 | stop() { 178 | if (this.process) { 179 | if (process.platform === "win32") { 180 | // Windows doesn't exit the process when it should. 181 | spawn("taskkill", ["/pid", "" + this.process.pid, "/T", "/F"]); 182 | } else { 183 | this.process.kill(); 184 | } 185 | 186 | this.reportStop(); 187 | } 188 | } 189 | 190 | reportStop() { 191 | pubsub.publish(RunnerEvents.RUNNER_STOPPED, { 192 | id: RunnerEvents.RUNNER_STOPPED, 193 | payload: { 194 | isRunning: false 195 | } 196 | }); 197 | } 198 | 199 | async executeInSequence( 200 | funcs: Array<{ 201 | fn: () => void; 202 | delay: number; 203 | }> 204 | ) { 205 | for (const { fn, delay } of funcs) { 206 | await this.setTimeoutPromisify(fn, delay); 207 | } 208 | } 209 | 210 | setTimeoutPromisify(fn: () => void, delay: number) { 211 | return new Promise(resolve => { 212 | setTimeout(() => { 213 | fn(); 214 | resolve(); 215 | }, delay); 216 | }); 217 | } 218 | 219 | getWatchFlag() { 220 | return this.isInGitRepository() ? "--watch" : "--watchAll"; 221 | } 222 | 223 | isInGitRepository() { 224 | try { 225 | execSync("git rev-parse --is-inside-work-tree", { stdio: "ignore" }); 226 | return true; 227 | } catch (e) { 228 | return false; 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /server/services/jest-manager/scripts/patch.js: -------------------------------------------------------------------------------- 1 | // Monkey patch the stdin with setRawMode so jest would think it's running from a terminal 2 | process.stdin.setRawMode = () => {}; 3 | -------------------------------------------------------------------------------- /server/services/jest-manager/scripts/reporter.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch'); 2 | 3 | function send(type, body) { 4 | fetch('http://localhost:' + process.env.MAJESTIC_PORT + '/' + type, { 5 | method: 'post', 6 | body: JSON.stringify(body), 7 | headers: { 'Content-Type': 'application/json' }, 8 | }); 9 | } 10 | 11 | class MyCustomReporter { 12 | constructor(globalConfig, options) { 13 | this._globalConfig = globalConfig; 14 | this._options = options; 15 | } 16 | 17 | onTestStart(test) { 18 | send('test-start', { 19 | path: test.path, 20 | }); 21 | } 22 | 23 | onTestResult(test, testResult, aggregatedResult) { 24 | send('test-result', { 25 | path: testResult.testFilePath, 26 | failureMessage: testResult.failureMessage, 27 | numFailingTests: testResult.numFailingTests, 28 | numPassingTests: testResult.numPassingTests, 29 | numPendingTests: testResult.numPendingTests, 30 | testResults: (testResult.testResults || []).map(result => ({ 31 | title: result.title, 32 | numPassingAsserts: result.numPassingAsserts, 33 | status: result.status, 34 | failureMessages: result.failureMessages, 35 | ancestorTitles: result.ancestorTitles, 36 | duration: result.duration, 37 | })), 38 | aggregatedResult: 39 | process.env.REPORT_SUMMARY === 'report' 40 | ? { 41 | numFailedTests: aggregatedResult.numFailedTests, 42 | numPassedTests: aggregatedResult.numPassedTests, 43 | numPassedTestSuites: aggregatedResult.numPassedTestSuites, 44 | numFailedTestSuites: aggregatedResult.numFailedTestSuites, 45 | } 46 | : null, 47 | console: testResult.console, 48 | }); 49 | } 50 | 51 | onRunStart(results) {} 52 | 53 | onRunComplete(contexts, results) { 54 | send('run-complete', { 55 | coverageMap: results.coverageMap, 56 | }); 57 | } 58 | } 59 | 60 | module.exports = MyCustomReporter; 61 | -------------------------------------------------------------------------------- /server/services/project.ts: -------------------------------------------------------------------------------- 1 | import { TreeMap, MajesticConfig } from "./types"; 2 | import { spawnSync } from "child_process"; 3 | import { sep, join, extname, normalize } from "path"; 4 | import { createLogger } from "../logger"; 5 | 6 | const log = createLogger("Project"); 7 | 8 | export default class Project { 9 | public projectRoot: string; 10 | 11 | constructor(root: string) { 12 | this.projectRoot = normalize(root); 13 | } 14 | 15 | getFilesList(config: MajesticConfig) { 16 | const configProcess = spawnSync( 17 | "node", 18 | [config.jestScriptPath, ...(config.args || []), "--listTests", "--json"], 19 | { 20 | cwd: this.projectRoot, 21 | shell: true, 22 | stdio: "pipe", 23 | env: { 24 | CI: "true", 25 | ...(config.env || {}), 26 | ...process.env 27 | } 28 | } 29 | ); 30 | 31 | const filesStr = configProcess.stdout.toString().trim(); 32 | const files: string[] = JSON.parse(filesStr); 33 | log("Identified test files: ", files); 34 | 35 | const relativeFiles = files.map(file => file.replace(this.projectRoot, "")); 36 | const map: TreeMap = { 37 | "/": { 38 | name: this.projectRoot.split(sep).pop() || "", 39 | type: "directory", 40 | path: this.projectRoot, 41 | parent: undefined 42 | } 43 | }; 44 | 45 | relativeFiles.forEach(path => { 46 | const tokens = path.split(sep).filter(token => token.trim() !== ""); 47 | let currentPath = ""; 48 | let parentPath = ""; 49 | tokens.forEach((token, i) => { 50 | currentPath = `${currentPath}${sep}${token}`; 51 | const type = [".jsx", ".tsx", ".ts", ".js"].includes( 52 | extname(currentPath) 53 | ) 54 | ? "file" 55 | : "directory"; 56 | if (!map[currentPath]) { 57 | map[currentPath] = { 58 | name: token, 59 | type, 60 | path: join(this.projectRoot, currentPath), 61 | parent: join(this.projectRoot, parentPath) 62 | }; 63 | } 64 | parentPath = currentPath; 65 | }); 66 | }); 67 | 68 | return map; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /server/services/result-handler-api.ts: -------------------------------------------------------------------------------- 1 | import { Application } from "express"; 2 | import * as bodyParser from "body-parser"; 3 | import { pubsub } from "../event-emitter"; 4 | import { createLogger } from "../logger"; 5 | 6 | const log = createLogger("Report API"); 7 | 8 | export const Events = { 9 | TEST_START: "TEST_START", 10 | TEST_RESULT: "TEST_RESULT", 11 | RUN_START: "RUN_START", 12 | RUN_COMPLETE: "RUN_COMPLETE", 13 | RUN_SUMMARY: "RUN_SUMMARY" 14 | }; 15 | 16 | export interface ResultEvent { 17 | id: string; 18 | payload: any; 19 | } 20 | 21 | export interface SummaryEvent { 22 | id: string; 23 | payload: { 24 | summary: { 25 | numPassedTests: number; 26 | numFailedTests: number; 27 | numPassedTestSuites: number; 28 | numFailedTestSuites: number; 29 | }; 30 | }; 31 | } 32 | 33 | export default function handlerApi(expressApp: Application) { 34 | expressApp.use( 35 | bodyParser.json({ 36 | limit: "50mb" 37 | }) 38 | ); 39 | expressApp.post("/test-start", ({ body }, res) => { 40 | log("File execution start reported ", body.path); 41 | 42 | pubsub.publish(Events.TEST_START, { 43 | id: Events.TEST_START, 44 | payload: { 45 | path: body.path 46 | } 47 | }); 48 | res.send("ok"); 49 | }); 50 | 51 | expressApp.post("/test-result", ({ body }, res) => { 52 | log("File result reported ", body.path); 53 | 54 | pubsub.publish(Events.TEST_RESULT, { 55 | id: Events.TEST_RESULT, 56 | payload: body 57 | }); 58 | 59 | if (body.aggregatedResult) { 60 | pubsub.publish(Events.RUN_SUMMARY, { 61 | id: Events.RUN_SUMMARY, 62 | payload: { 63 | summary: body.aggregatedResult 64 | } 65 | }); 66 | } 67 | 68 | res.send("ok"); 69 | }); 70 | 71 | expressApp.post("/run-start", (req, res) => { 72 | pubsub.publish(Events.RUN_START, { 73 | id: Events.RUN_START, 74 | payload: req.body 75 | }); 76 | res.send("ok"); 77 | }); 78 | 79 | expressApp.post("/run-complete", (req, res) => { 80 | pubsub.publish(Events.RUN_COMPLETE, { 81 | id: Events.RUN_COMPLETE, 82 | payload: req.body 83 | }); 84 | res.send("ok"); 85 | }); 86 | } 87 | -------------------------------------------------------------------------------- /server/services/results.ts: -------------------------------------------------------------------------------- 1 | import { createSourceMapStore, MapStore } from "istanbul-lib-source-maps"; 2 | import { createCoverageMap, CoverageMap } from "istanbul-lib-coverage"; 3 | import { existsSync } from "fs"; 4 | import { join } from "path"; 5 | import { MajesticConfig } from "./types"; 6 | import { spawnSync } from "child_process"; 7 | import { createLogger } from "../logger"; 8 | import { TestFileResult } from "../api/workspace/test-result/file-result"; 9 | 10 | const log = createLogger("Results"); 11 | 12 | export type TestFileStatus = "IDLE" | "EXECUTING"; 13 | export interface CoverageSummary { 14 | statement: number; 15 | line: number; 16 | function: number; 17 | branch: number; 18 | } 19 | export default class Results { 20 | private projectRoot: string = ""; 21 | private results: { 22 | [path: string]: TestFileResult; 23 | } = {}; 24 | 25 | private testStatus: { 26 | [path: string]: { 27 | isExecuting: boolean; 28 | containsFailure: boolean; 29 | }; 30 | } = {}; 31 | 32 | private summary: { 33 | numFailedTests: number; 34 | numPassedTests: number; 35 | numPassedTestSuites: number; 36 | numFailedTestSuites: number; 37 | }; 38 | 39 | private coverage: CoverageSummary = { 40 | statement: 0, 41 | line: 0, 42 | function: 0, 43 | branch: 0 44 | }; 45 | 46 | private haveCoverageReport: boolean = false; 47 | 48 | public coverageFilePath: string = ""; 49 | public coverageDirectory: string = ""; 50 | 51 | constructor(projectRoot: string) { 52 | this.projectRoot = projectRoot; 53 | this.results = {}; 54 | this.summary = { 55 | numFailedTests: 0, 56 | numPassedTests: 0, 57 | numPassedTestSuites: 0, 58 | numFailedTestSuites: 0 59 | }; 60 | 61 | this.checkIfCoverageReportExists(); 62 | } 63 | 64 | public setTestStart(path: string) { 65 | if (!this.testStatus[path]) { 66 | this.testStatus[path] = { 67 | isExecuting: false, 68 | containsFailure: false 69 | }; 70 | } 71 | this.testStatus[path].isExecuting = true; 72 | } 73 | 74 | public setTestReport(path: string, report: any) { 75 | this.results[path] = report; 76 | this.testStatus[path].isExecuting = false; 77 | 78 | if (report.numFailingTests > 0) { 79 | this.testStatus[path].containsFailure = true; 80 | } else { 81 | this.testStatus[path].containsFailure = false; 82 | } 83 | } 84 | 85 | public getResult(path: string): TestFileResult | null { 86 | return this.results[path] || null; 87 | } 88 | 89 | public setSummary( 90 | passedTests: number, 91 | failedTests: number, 92 | numPassedTestSuites: number, 93 | numFailedTestSuites: number 94 | ) { 95 | this.summary = { 96 | numFailedTests: failedTests, 97 | numPassedTests: passedTests, 98 | numPassedTestSuites, 99 | numFailedTestSuites 100 | }; 101 | } 102 | 103 | public markExecutingAsStopped() { 104 | this.testStatus = Object.entries(this.testStatus).reduce( 105 | (acc, [key, value]) => ({ 106 | [key]: { 107 | ...value, 108 | isExecuting: false 109 | }, 110 | ...acc 111 | }), 112 | {} 113 | ); 114 | } 115 | 116 | public getSummary() { 117 | return this.summary; 118 | } 119 | 120 | public getFailedTests() { 121 | return Object.entries(this.testStatus) 122 | .filter(([path, status]) => { 123 | return status.containsFailure; 124 | }) 125 | .map(([path]) => path); 126 | } 127 | 128 | public getPassedTests() { 129 | return Object.entries(this.testStatus) 130 | .filter(([path, status]) => { 131 | return !status.containsFailure && !status.isExecuting; 132 | }) 133 | .map(([path]) => path); 134 | } 135 | 136 | public getExecutingTests() { 137 | return Object.entries(this.testStatus) 138 | .filter(([path, status]) => { 139 | return status.isExecuting === true; 140 | }) 141 | .map(([path]) => path); 142 | } 143 | 144 | public mapCoverage(data: any) { 145 | if (!data) { 146 | this.coverage = { 147 | statement: 0, 148 | branch: 0, 149 | function: 0, 150 | line: 0 151 | }; 152 | 153 | return; 154 | } 155 | 156 | const sourceMapStore = createSourceMapStore(); 157 | const coverageMap = createCoverageMap(data); 158 | const transformed = sourceMapStore.transformCoverage(coverageMap); 159 | const coverageSummary = transformed.map.getCoverageSummary(); 160 | 161 | const statementCoverage = coverageSummary.statements.pct as any; 162 | const branchCoverage = coverageSummary.branches.pct as any; 163 | const functionCoverage = coverageSummary.functions.pct as any; 164 | const lineCoverage = coverageSummary.lines.pct as any; 165 | 166 | this.coverage = { 167 | statement: statementCoverage === "Unknown" ? 0 : statementCoverage, 168 | branch: branchCoverage === "Unknown" ? 0 : branchCoverage, 169 | function: functionCoverage === "Unknown" ? 0 : functionCoverage, 170 | line: lineCoverage === "Unknown" ? 0 : lineCoverage 171 | }; 172 | } 173 | 174 | public checkIfCoverageReportExists() { 175 | this.haveCoverageReport = existsSync(this.coverageFilePath); 176 | return this.haveCoverageReport; 177 | } 178 | 179 | public getCoverage() { 180 | return this.coverage; 181 | } 182 | 183 | public doesHaveCoverageReport() { 184 | return this.haveCoverageReport; 185 | } 186 | 187 | public getCoverageReportPath(config: MajesticConfig) { 188 | try { 189 | const configProcess = spawnSync( 190 | "node", 191 | [ 192 | config.jestScriptPath, 193 | ...(config.args || []), 194 | "--showConfig", 195 | "--json" 196 | ], 197 | { 198 | cwd: this.projectRoot, 199 | shell: true, 200 | stdio: "pipe", 201 | env: { 202 | CI: "true", 203 | ...(config.env || {}), 204 | ...process.env 205 | } 206 | } 207 | ); 208 | 209 | let filesStr = configProcess.stdout.toString().trim(); 210 | if (filesStr === "") { 211 | filesStr = configProcess.stderr.toString().trim(); 212 | } 213 | 214 | const defaultCoveragePath = join(this.projectRoot, "coverage"); 215 | const jestConfig = JSON.parse(filesStr); 216 | this.coverageDirectory = 217 | (jestConfig.globalConfig && 218 | jestConfig.globalConfig.coverageDirectory) || 219 | defaultCoveragePath; 220 | this.coverageFilePath = join( 221 | this.coverageDirectory, 222 | "/lcov-report/index.html" 223 | ); 224 | } catch (e) { 225 | log( 226 | `Error occured while obtaining Jest cofiguration for coverage report ${e.toString()}` 227 | ); 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /server/services/types.ts: -------------------------------------------------------------------------------- 1 | export interface DirectoryItem { 2 | name: string; 3 | path: string; 4 | type: "directory" | "file"; 5 | children?: DirectoryItem[]; 6 | } 7 | 8 | export interface TreeMap { 9 | [path: string]: { 10 | name: string; 11 | path: string; 12 | parent?: string; 13 | type: "directory" | "file"; 14 | }; 15 | } 16 | 17 | export interface MajesticConfig { 18 | jestScriptPath: string; 19 | args?: string[]; 20 | env?: { [key: string]: string }; 21 | } 22 | -------------------------------------------------------------------------------- /server/static-files.ts: -------------------------------------------------------------------------------- 1 | import * as exp from "express"; 2 | import { resolve, join } from "path"; 3 | import { pubsub } from "./event-emitter"; 4 | 5 | export function initializeStaticRoutes(express: exp.Application, root: string) { 6 | express.get("/", (req, res) => 7 | res.sendFile("./ui/index.html", { 8 | root: resolve(__dirname, "..") 9 | }) 10 | ); 11 | express.get("/ui.bundle.js", (req, res) => 12 | res.sendFile("./ui/ui.bundle.js", { 13 | root: resolve(__dirname, "..") 14 | }) 15 | ); 16 | express.get("/favicon.ico", (req, res) => 17 | res.sendFile("./ui/favicon.ico", { 18 | root: resolve(__dirname, "..") 19 | }) 20 | ); 21 | express.get("/logo.png", (req, res) => 22 | res.sendFile("./ui/logo.png", { 23 | root: resolve(__dirname, "..") 24 | }) 25 | ); 26 | 27 | pubsub.subscribe("WorkspaceInitialized", ({ coverageDirectory }) => { 28 | if (coverageDirectory && coverageDirectory.trim() !== "") { 29 | express.use("/coverage", exp.static(coverageDirectory)); 30 | } 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /server/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module "directory-tree"; 2 | declare module "micromatch"; 3 | declare module "@babel/traverse"; 4 | declare module "nanoid"; 5 | declare module "read-pkg-up"; 6 | declare module "open"; 7 | declare module "launch-editor"; 8 | declare module "*.json"; 9 | declare module "lodash.throttle"; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es2015", 4 | "allowSyntheticDefaultImports": true, 5 | "target": "es5", 6 | "lib": ["es6", "dom", "es2017.object"], 7 | "sourceMap": true, 8 | "allowJs": true, 9 | "jsx": "react", 10 | "moduleResolution": "node", 11 | "forceConsistentCasingInFileNames": true, 12 | "noImplicitReturns": false, 13 | "noImplicitThis": true, 14 | "noImplicitAny": true, 15 | "strictNullChecks": true, 16 | "suppressImplicitAnyIndexErrors": true, 17 | "noUnusedLocals": false, 18 | "experimentalDecorators": true, 19 | "skipLibCheck": true, 20 | "emitDecoratorMetadata": true 21 | }, 22 | "exclude": [] 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "./server", 4 | "outDir": "./dist/server", 5 | "module": "commonjs", 6 | "target": "es2016", 7 | "lib": ["es6", "dom", "es2017.object", "esnext.asynciterable"], 8 | "sourceMap": true, 9 | "allowJs": true, 10 | "jsx": "react", 11 | "moduleResolution": "node", 12 | "forceConsistentCasingInFileNames": false, 13 | "noImplicitReturns": false, 14 | "noImplicitThis": true, 15 | "noImplicitAny": false, 16 | "strictNullChecks": false, 17 | "suppressImplicitAnyIndexErrors": true, 18 | "noUnusedLocals": false, 19 | "experimentalDecorators": true, 20 | "emitDecoratorMetadata": true, 21 | "skipLibCheck": true, 22 | "newLine": "LF", 23 | "resolveJsonModule": true 24 | }, 25 | "include": ["server"] 26 | } 27 | -------------------------------------------------------------------------------- /ui/apollo-client.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient, HttpLink, InMemoryCache } from "apollo-client-preset"; 2 | import { WebSocketLink } from "apollo-link-ws"; 3 | import { getMainDefinition } from "apollo-utilities"; 4 | import { split } from "apollo-link"; 5 | 6 | declare var PRODUCTION: boolean; 7 | 8 | let WS_URL = "ws://localhost:4000"; 9 | let HTTP_URL = "http://localhost:4000"; 10 | if (PRODUCTION) { 11 | const WS_PROTOCOL = window.location.protocol === "https:" ? "wss:" : "ws:"; 12 | WS_URL = `${WS_PROTOCOL}//${window.location.host}`; 13 | HTTP_URL = `${window.location.protocol}//${window.location.host}`; 14 | } 15 | 16 | export function getAPIUrl() { 17 | return HTTP_URL; 18 | } 19 | 20 | const wsLink = new WebSocketLink({ 21 | uri: WS_URL, 22 | options: { 23 | reconnect: true 24 | } 25 | }); 26 | 27 | const httpLink = new HttpLink({ uri: HTTP_URL }); 28 | 29 | const link = split( 30 | ({ query }: any) => { 31 | const { kind, operation } = getMainDefinition(query); 32 | return kind === "OperationDefinition" && operation === "subscription"; 33 | }, 34 | wsLink, 35 | httpLink 36 | ); 37 | 38 | const client = new ApolloClient({ 39 | link, 40 | cache: new InMemoryCache() 41 | }); 42 | 43 | export default client; 44 | -------------------------------------------------------------------------------- /ui/app.gql: -------------------------------------------------------------------------------- 1 | { 2 | app { 3 | selectedFile 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ui/app.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import styled from "styled-components"; 3 | import SplitPane from "react-split-pane"; 4 | import { useQuery, useMutation } from "react-apollo-hooks"; 5 | import Sidebar from "./sidebar"; 6 | import TestFile from "./test-file"; 7 | import APP from "./app.gql"; 8 | import WORKSPACE from "./query.gql"; 9 | import useKeys from "./hooks/use-keys"; 10 | import useSubscription from "./test-file/use-subscription"; 11 | import SUMMARY_QUERY from "./summary-query.gql"; 12 | import SUMMARY_SUBS from "./summary-subscription.gql"; 13 | import RUNNER_STATUS_QUERY from "./runner-status-query.gql"; 14 | import RUNNER_STATUS_SUBS from "./runner-status-subs.gql"; 15 | import STOP_RUNNER from "./stop-runner.gql"; 16 | import { Search } from "./search"; 17 | import SET_SELECTED_FILE from "./set-selected-file.gql"; 18 | import { Workspace } from "../server/api/workspace/workspace"; 19 | import { color } from "styled-system"; 20 | import { RunnerStatus } from "../server/api/runner/status"; 21 | import { Summary } from "../server/api/workspace/summary"; 22 | import CoveragePanel from "./coverage-panel"; 23 | 24 | const ContainerDiv = styled.div` 25 | display: flex; 26 | flex-direction: row; 27 | width: 100%; 28 | `; 29 | 30 | const PlaceHolder = styled.div` 31 | display: flex; 32 | height: 100%; 33 | ${color} 34 | `; 35 | 36 | interface AppResult { 37 | app: { selectedFile: string }; 38 | } 39 | 40 | interface WorkspaceResult { 41 | workspace: Workspace; 42 | } 43 | 44 | export default function App() { 45 | const { 46 | data: { 47 | app: { selectedFile } 48 | }, 49 | refetch 50 | } = useQuery(APP); 51 | 52 | const { 53 | data: { workspace }, 54 | refetch: refetchFiles 55 | } = useQuery(WORKSPACE); 56 | 57 | const { data: summary }: { data: Summary } = useSubscription( 58 | SUMMARY_QUERY, 59 | SUMMARY_SUBS, 60 | {}, 61 | result => result.summary, 62 | result => result.changeToSummary, 63 | "Summary Sub" 64 | ); 65 | 66 | const { data: runnerStatus }: { data: RunnerStatus } = useSubscription( 67 | RUNNER_STATUS_QUERY, 68 | RUNNER_STATUS_SUBS, 69 | {}, 70 | result => result.runnerStatus, 71 | result => result.runnerStatusChange, 72 | "Runner subs" 73 | ); 74 | 75 | const setSelectedFile = useMutation(SET_SELECTED_FILE); 76 | const handleFileSelection = (path: string | null) => { 77 | if (path !== null) { 78 | setShowCoverage(false); 79 | } 80 | 81 | setSelectedFile({ 82 | variables: { 83 | path 84 | } 85 | }); 86 | refetch(); 87 | }; 88 | 89 | const stopRunner = useMutation(STOP_RUNNER); 90 | 91 | const [isSearchOpen, setSearchOpen] = useState(false); 92 | const keys = useKeys(); 93 | if (isSearchOpen && keys.has("Escape")) { 94 | setSearchOpen(false); 95 | } 96 | 97 | const [showCoverage, setShowCoverage] = useState(false); 98 | 99 | return ( 100 | 101 | 108 | { 116 | setSearchOpen(true); 117 | }} 118 | onRefreshFiles={() => { 119 | refetchFiles(); 120 | }} 121 | onStop={() => { 122 | stopRunner(); 123 | }} 124 | onShowCoverage={() => { 125 | setShowCoverage(!showCoverage); 126 | }} 127 | /> 128 | {showCoverage && } 129 | {selectedFile ? ( 130 | { 139 | stopRunner(); 140 | }} 141 | /> 142 | ) : ( 143 | 144 | )} 145 | 146 | setSearchOpen(false)} 151 | onItemClick={path => { 152 | handleFileSelection(path); 153 | setSearchOpen(false); 154 | }} 155 | /> 156 | 157 | ); 158 | } 159 | -------------------------------------------------------------------------------- /ui/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Raathigesh/majestic/2bb0047188722610c7251e53717ce731bf5ec65e/ui/assets/favicon.ico -------------------------------------------------------------------------------- /ui/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Raathigesh/majestic/2bb0047188722610c7251e53717ce731bf5ec65e/ui/assets/logo.png -------------------------------------------------------------------------------- /ui/components/button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { space, color, fontSize } from "styled-system"; 4 | 5 | const StyledButton = styled.button` 6 | display: flex; 7 | align-items: center; 8 | color: ${props => (props.minimal ? "#ffffff" : "#242326")}; 9 | text-align: center; 10 | transition: all 0.5s; 11 | border: 1px solid #ffd062; 12 | border-radius: 3px; 13 | background-color: ${props => (props.minimal ? "transparent" : "#FFD062")}; 14 | cursor: pointer; 15 | margin-right: 5px; 16 | padding: 6px; 17 | ${color}; 18 | ${fontSize}; 19 | &:hover { 20 | background-color: ${props => (props.bg ? props.bg : "#ffd062")}; 21 | } 22 | 23 | &:focus { 24 | outline: none; 25 | } 26 | `; 27 | 28 | const Spacer = styled.div` 29 | width: 5px; 30 | `; 31 | 32 | export default function Button(props: any) { 33 | return ( 34 | 35 | {props.icon} 36 | {props.icon && props.children && } 37 | {props.children} 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /ui/container.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, Suspense } from "react"; 2 | import { ApolloProvider as ApolloHooksProvider } from "react-apollo-hooks"; 3 | import { ApolloProvider } from "react-apollo"; 4 | import { ThemeProvider } from "styled-components"; 5 | import client from "./apollo-client"; 6 | import App from "./app"; 7 | import theme from "./theme"; 8 | import { createGlobalStyle } from "styled-components"; 9 | import splitPanelCSS from "./split-panel-style"; 10 | import "typeface-open-sans"; 11 | import Loading from "./loading"; 12 | import { ErrorBoundary } from "./error"; 13 | 14 | const GlobalStyle = createGlobalStyle` 15 | body { font-family: 'Open sans'; font-size: 13px; margin: 0px;} 16 | ${splitPanelCSS} 17 | `; 18 | 19 | export default class Container extends Component { 20 | render() { 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | }> 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ui/coverage-panel/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { getAPIUrl } from "../apollo-client"; 4 | 5 | const Frame = styled.iframe` 6 | width: 100%; 7 | height: 100%; 8 | `; 9 | 10 | export default function CoveragePanel() { 11 | return ( 12 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /ui/error.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import styled from "styled-components"; 3 | 4 | const Container = styled.div` 5 | display: flex; 6 | flex-direction: column; 7 | width: 100%; 8 | height: 100vh; 9 | align-items: center; 10 | justify-content: center; 11 | background-color: #262529; 12 | color: #fdc055; 13 | font-size: 25px; 14 | font-weight: 500; 15 | `; 16 | 17 | const Loader = styled.div` 18 | margin-bottom: 20px; 19 | svg { 20 | text-align: center; 21 | margin: auto; 22 | width: 60px; 23 | height: 60px; 24 | } 25 | 26 | #icon-stop-circle .stopping { 27 | animation-name: stopping; 28 | animation-duration: 5s; 29 | animation-timing-function: ease-in-out; 30 | animation-iteration-count: infinite; 31 | transform-origin: center center; 32 | } 33 | 34 | @keyframes stopping { 35 | from, 36 | 50%, 37 | to { 38 | opacity: 1; 39 | fill: #ea3970; 40 | stroke: none; 41 | } 42 | 43 | 25%, 44 | 75% { 45 | opacity: 0; 46 | } 47 | } 48 | `; 49 | 50 | const Message = styled.div` 51 | font-size: 15px; 52 | `; 53 | 54 | export class ErrorBoundary extends Component { 55 | state = { 56 | didError: false 57 | }; 58 | 59 | componentDidCatch() { 60 | this.setState({ 61 | didError: true 62 | }); 63 | } 64 | 65 | render() { 66 | if (!this.state.didError) { 67 | return this.props.children; 68 | } 69 | 70 | return ( 71 | 72 | 73 | 85 | 86 | 87 | 88 | 89 | 90 | Oops, Something went wrong. Check the terminal for exact error 91 | message! 92 | 93 | 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /ui/hooks/use-keys.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function hasKeys(expectedKeys: string[], pressedKeys: Map ) { 4 | return expectedKeys.every(k => pressedKeys.has(k)); 5 | }; 6 | 7 | export default function useKeys() { 8 | const [keys, setKeys] = useState(new Map()); 9 | const hotKeys = ["Alt", "Enter", "Escape", "s", "t", "w"]; 10 | 11 | function downHandler({ key }:KeyboardEvent) { 12 | // only update state for keys we are watching 13 | if (hotKeys.includes(key)) { 14 | keys.set(key, true); 15 | // create a new Map object to guarantee that state updates 16 | setKeys(new Map(keys)); 17 | } 18 | } 19 | 20 | const upHandler = ({ key }:KeyboardEvent) => { 21 | if (hotKeys.includes(key)) { 22 | keys.delete(key); 23 | setKeys(new Map(keys)); 24 | } 25 | }; 26 | 27 | useEffect(() => { 28 | window.addEventListener("keydown", downHandler); 29 | window.addEventListener("keyup", upHandler); 30 | return () => { 31 | window.removeEventListener("keydown", downHandler); 32 | window.removeEventListener("keyup", upHandler); 33 | }; 34 | }, []); 35 | 36 | return keys; 37 | } 38 | -------------------------------------------------------------------------------- /ui/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "@babel/polyfill"; 4 | import Container from "./container"; 5 | import "react-tippy/dist/tippy.css"; 6 | 7 | ReactDOM.render(, document.getElementById("root")); 8 | 9 | if ((module as any).hot) { 10 | (module as any).hot.accept("./container", () => { 11 | const NextApp = require("./container").default; 12 | ReactDOM.render(, document.getElementById("root")); 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /ui/loading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const Container = styled.div` 5 | display: flex; 6 | flex-direction: column; 7 | width: 100%; 8 | height: 100vh; 9 | align-items: center; 10 | justify-content: center; 11 | background-color: #262529; 12 | color: #fdc055; 13 | font-size: 25px; 14 | font-weight: 500; 15 | `; 16 | 17 | const Loader = styled.div` 18 | margin-bottom: 20px; 19 | svg { 20 | text-align: center; 21 | margin: auto; 22 | width: 60px; 23 | height: 60px; 24 | } 25 | 26 | #icon-crop-button { 27 | animation: cropped 1s alternate infinite ease-in-out; 28 | transform-origin: center; 29 | fill: aliceblue; 30 | } 31 | 32 | @-webkit-keyframes cropped { 33 | 0% { 34 | transform: rotate(0deg) scale(1); 35 | } 36 | 37 | 50% { 38 | transform: rotate(90deg) scale(0.9); 39 | } 40 | 41 | 100% { 42 | transform: rotate(180deg) scale(1); 43 | } 44 | } 45 | 46 | @keyframes cropped { 47 | 0% { 48 | transform: rotate(0deg) scale(1); 49 | } 50 | 51 | 50% { 52 | transform: rotate(90deg) scale(0.9); 53 | } 54 | 55 | 100% { 56 | transform: rotate(180deg) scale(1); 57 | } 58 | } 59 | `; 60 | 61 | const Message = styled.div` 62 | font-size: 15px; 63 | `; 64 | 65 | export default function Loading() { 66 | return ( 67 | 68 | 69 | 74 | 75 | 76 | 77 | Getting things ready for you 78 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /ui/query.gql: -------------------------------------------------------------------------------- 1 | { 2 | workspace { 3 | projectRoot 4 | name 5 | files { 6 | path 7 | name 8 | type 9 | parent 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /ui/runner-status-query.gql: -------------------------------------------------------------------------------- 1 | { 2 | runnerStatus { 3 | running 4 | activeFile 5 | watching 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /ui/runner-status-subs.gql: -------------------------------------------------------------------------------- 1 | subscription { 2 | runnerStatusChange { 3 | running 4 | activeFile 5 | watching 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /ui/search/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import styled from "styled-components"; 3 | import { Item } from "../../server/api/workspace/tree"; 4 | import { color } from "styled-system"; 5 | 6 | const Drop = styled.div` 7 | position: absolute; 8 | background-color: #444444; 9 | opacity: 0.7; 10 | top: 0; 11 | left: 0; 12 | right: 0; 13 | bottom: 0px; 14 | z-index: 1; 15 | `; 16 | 17 | const Container = styled.div` 18 | width: 700px; 19 | max-height: 500px; 20 | position: absolute; 21 | z-index: 1; 22 | margin-left: auto; 23 | margin-right: auto; 24 | left: 0; 25 | right: 0; 26 | top: 150px; 27 | padding: 20px; 28 | border-radius: 4px; 29 | display: flex; 30 | flex-direction: column; 31 | ${color}; 32 | `; 33 | 34 | const ItemContainer = styled.div` 35 | display: flex; 36 | padding: 5px; 37 | cursor: pointer; 38 | color: #fefefe; 39 | white-space: nowrap; 40 | overflow: hidden; 41 | text-overflow: ellipsis; 42 | border-radius: 1px; 43 | min-height: 20px; 44 | 45 | &:hover { 46 | background-color: #404148; 47 | } 48 | `; 49 | 50 | const ResultContainer = styled.div` 51 | display: flex; 52 | flex-direction: column; 53 | overflow: auto; 54 | `; 55 | 56 | const SearchBox = styled.input` 57 | padding: 5px; 58 | border: none; 59 | width: 99%; 60 | margin-bottom: 15px; 61 | padding: 5px; 62 | font-size: 13px; 63 | border-radius: 2px; 64 | 65 | &:focus { 66 | outline: none; 67 | } 68 | `; 69 | 70 | interface Props { 71 | projectRoot: string; 72 | show: boolean; 73 | files: Item[]; 74 | onItemClick: (path: string) => void; 75 | onClose: () => void; 76 | } 77 | 78 | export function Search({ 79 | projectRoot, 80 | files, 81 | show, 82 | onItemClick, 83 | onClose 84 | }: Props) { 85 | const onlyFiles = files.filter(file => file.type === "file"); 86 | 87 | const [query, setQuery] = useState(""); 88 | const searchBoxRef = useRef(null); 89 | useEffect(() => { 90 | if (searchBoxRef && searchBoxRef.current) { 91 | searchBoxRef.current.focus(); 92 | } 93 | }, [show]); 94 | 95 | if (!show) return null; 96 | 97 | return ( 98 | 99 | 100 | 101 | { 106 | setQuery(event.target.value); 107 | }} 108 | /> 109 | 110 | {onlyFiles 111 | .filter(file => 112 | file.path.toLowerCase().includes(query.toLowerCase()) 113 | ) 114 | .map((file: any, index: number) => ( 115 | { 118 | onItemClick(file.path); 119 | }} 120 | > 121 | {file.path.toLowerCase().replace(projectRoot.toLowerCase(), "")} 122 | 123 | ))} 124 | 125 | 126 | 127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /ui/set-selected-file.gql: -------------------------------------------------------------------------------- 1 | mutation SetSelectedFile($path: String) { 2 | setSelectedFile(path: $path) { 3 | selectedFile 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ui/sidebar/execution-indicator.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function ExecutionIndicator() { 4 | return ( 5 | 14 | 15 | 23 | 24 | 25 | 33 | 34 | 35 | 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /ui/sidebar/file-item.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react"; 2 | import styled from "styled-components"; 3 | import { 4 | File, 5 | Folder, 6 | ChevronRight, 7 | ChevronDown, 8 | Frown, 9 | ZapOff, 10 | } from "react-feather"; 11 | import { color } from "styled-system"; 12 | import { TreeNode } from "./transformer"; 13 | import ExecutionIndicator from "./execution-indicator"; 14 | 15 | const Container = styled.div` 16 | display: flex; 17 | flex-direction: column; 18 | margin-left: 20px; 19 | ${color}; 20 | `; 21 | 22 | const Content = styled.div` 23 | display: flex; 24 | align-items: center; 25 | padding: 2.5px; 26 | cursor: pointer; 27 | color: ${(props) => 28 | props.failed ? "#FE5339" : props.passing ? "#19E28D" : null}; 29 | background-color: ${(props) => (props.selected ? "#444444" : null)}; 30 | border-radius: 3px; 31 | margin-bottom: 2px; 32 | font-weight: 600; 33 | 34 | &:hover { 35 | background-color: #444444; 36 | } 37 | `; 38 | 39 | const Label = styled.div` 40 | margin-left: 5px; 41 | font-size: 12px; 42 | overflow: hidden; 43 | white-space: nowrap; 44 | text-overflow: ellipsis; 45 | `; 46 | 47 | const EmptyChevron = styled.div` 48 | width: 5px; 49 | `; 50 | 51 | const ExecutionWrapper = styled.div``; 52 | 53 | interface Props { 54 | item: TreeNode; 55 | style: any; 56 | selectedFile: string; 57 | setSelectedFile: (path: string) => void; 58 | onToggle: (path: string, isCollapsed: boolean) => void; 59 | } 60 | 61 | function FileItem({ 62 | item, 63 | selectedFile, 64 | setSelectedFile, 65 | onToggle, 66 | style, 67 | }: Props) { 68 | const Icon = 69 | item.type === "directory" ? Folder : item.haveFailure ? ZapOff : File; 70 | let Chevron: any = EmptyChevron; 71 | if (item.type === "directory") { 72 | Chevron = item.isCollapsed ? ChevronRight : ChevronDown; 73 | } 74 | 75 | const handleClick = () => { 76 | if (item.type === "file") { 77 | setSelectedFile(item.path); 78 | } 79 | 80 | if (item.type === "directory") { 81 | onToggle(item.path, !item.isCollapsed); 82 | } 83 | }; 84 | 85 | return ( 86 | 93 | 100 | 101 | {!item.isExecuting && } 102 | {item.isExecuting && ( 103 | 104 | 105 | 106 | )} 107 | 108 | 109 | 110 | ); 111 | } 112 | 113 | export default memo(FileItem, (pre: Props, next: Props) => { 114 | return ( 115 | pre.item.isExecuting === next.item.isExecuting && 116 | pre.item.isCollapsed === next.item.isCollapsed && 117 | pre.selectedFile === next.selectedFile 118 | ); 119 | }); 120 | -------------------------------------------------------------------------------- /ui/sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import styled from "styled-components"; 3 | import { useMutation, useQuery } from "react-apollo-hooks"; 4 | import { space, color } from "styled-system"; 5 | import { Tooltip } from "react-tippy"; 6 | import SET_WATCH_MODE from "./set-watch-mode.gql"; 7 | import SHOULD_COLLECT_COVERAGE from "./should-collect-coverage.gql"; 8 | import SET_COLLECT_COVERAGE from "./set-collect-coverage.gql"; 9 | import { Workspace } from "../../server/api/workspace/workspace"; 10 | import { transform, filterFailure } from "./transformer"; 11 | import Summary from "./summary"; 12 | import { Summary as SummaryType } from "../../server/api/workspace/summary"; 13 | import RUN from "./run.gql"; 14 | import useKeys, { hasKeys } from "../hooks/use-keys"; 15 | import { 16 | Play, 17 | Eye, 18 | Search, 19 | RefreshCw, 20 | ZapOff, 21 | StopCircle, 22 | FileText, 23 | Layers, 24 | ChevronDown, 25 | ChevronRight 26 | } from "react-feather"; 27 | import Button from "../components/button"; 28 | import { RunnerStatus } from "../../server/api/runner/status"; 29 | import Tree from "./tree"; 30 | import Logo from "./logo"; 31 | 32 | const Container = styled.div` 33 | ${space}; 34 | ${color}; 35 | height: 100vh; 36 | `; 37 | 38 | const ActionsPanel = styled.div` 39 | ${space} 40 | display: flex; 41 | justify-content: space-between; 42 | `; 43 | 44 | const RightActionPanel = styled.div` 45 | display: flex; 46 | `; 47 | 48 | const FileHeader = styled.div` 49 | ${space} 50 | display: flex; 51 | justify-content: space-between; 52 | align-items: center; 53 | font-size: 13px; 54 | `; 55 | 56 | const FilesHeader = styled.div` 57 | font-weight: 400; 58 | font-size: 11px; 59 | `; 60 | 61 | const RightFilesAction = styled.div` 62 | display: flex; 63 | `; 64 | 65 | interface Props { 66 | selectedFile: string; 67 | workspace: Workspace; 68 | summary: SummaryType | undefined; 69 | runnerStatus?: RunnerStatus; 70 | showCoverage: boolean; 71 | onSelectedFileChange: (path: string) => void; 72 | onSearchOpen: () => void; 73 | onRefreshFiles: () => void; 74 | onStop: () => void; 75 | onShowCoverage: () => void; 76 | } 77 | 78 | export default function TestExplorer ({ 79 | selectedFile, 80 | workspace, 81 | onSelectedFileChange, 82 | summary, 83 | showCoverage, 84 | runnerStatus, 85 | onSearchOpen, 86 | onRefreshFiles, 87 | onStop, 88 | onShowCoverage 89 | }: Props) { 90 | const failedItems = (summary && summary.failedTests) || []; 91 | const executingItems = (summary && summary.executingTests) || []; 92 | const passingTests = (summary && summary.passingTests) || []; 93 | 94 | const run = useMutation(RUN); 95 | 96 | const [collapsedItems, setCollapsedItems] = useState({}); 97 | const handleFileToggle = (path: string, isCollapsed: boolean) => { 98 | setCollapsedItems({ 99 | ...collapsedItems, 100 | [path]: isCollapsed 101 | }); 102 | }; 103 | 104 | const [showFailedTests, setShowFailedTests] = useState(false); 105 | 106 | const items = workspace.files; 107 | const root = items[0]; 108 | let files = transform( 109 | root as any, 110 | executingItems, 111 | failedItems, 112 | passingTests, 113 | collapsedItems, 114 | showFailedTests, 115 | items 116 | ); 117 | 118 | const onCollapseAll = () => { 119 | const newCollapsedItems = {}; 120 | files.forEach(file => { 121 | if (file.type === "directory" && file.parent) { 122 | newCollapsedItems[file.path] = true; 123 | } 124 | }); 125 | setCollapsedItems(newCollapsedItems) 126 | } 127 | const onExpandAll = () => { 128 | setCollapsedItems({}) 129 | } 130 | 131 | if (showFailedTests && failedItems.length) { 132 | files = filterFailure(files); 133 | } 134 | 135 | const { 136 | data: { shouldCollectCoverage }, 137 | refetch: refetchCoverageFlag 138 | } = useQuery(SHOULD_COLLECT_COVERAGE); 139 | const setCollectCoverage = useMutation(SET_COLLECT_COVERAGE); 140 | 141 | const handleFileSelection = (path: string) => { 142 | onSelectedFileChange(path); 143 | }; 144 | 145 | const setWatchMode = useMutation(SET_WATCH_MODE); 146 | const handleSetWatchModel = (watch: boolean) => { 147 | setWatchMode({ 148 | variables: { 149 | watch 150 | } 151 | }); 152 | }; 153 | 154 | const isRunning = runnerStatus && runnerStatus.running; 155 | const keys = useKeys(); 156 | if (hasKeys(["Alt", "t"], keys)) { 157 | run(); 158 | } else if (hasKeys(["Alt", "w"], keys)) { 159 | if (runnerStatus) { 160 | handleSetWatchModel(!runnerStatus.watching); 161 | } 162 | } else if (hasKeys(["Alt", "s"], keys)) { 163 | onSearchOpen(); 164 | } 165 | 166 | return ( 167 | 168 | 169 | 170 | 171 | 184 | 185 | 186 | 187 | 200 | 201 | 202 | 215 | 216 | 217 | 225 | 226 | 227 | 228 | 229 | 230 | Tests 231 | 232 | {summary && summary.failedTests && summary.failedTests.length > 0 && ( 233 | 238 | 247 | 248 | )} 249 | {!showFailedTests && ( 250 | 251 | 258 | 259 | )} 260 | {!showFailedTests && ( 261 | 262 | 269 | 270 | )} 271 | {summary && summary.haveCoverageReport && ( 272 | 277 | 286 | 287 | )} 288 | 289 | 298 | 299 | 300 | 301 | 307 | 308 | ); 309 | } 310 | -------------------------------------------------------------------------------- /ui/sidebar/logo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import logo from "../assets/logo.png"; 4 | 5 | const Container = styled.div` 6 | font-size: 25px; 7 | text-align: center; 8 | margin-bottom: 15px; 9 | `; 10 | 11 | export default function Logo() { 12 | return ( 13 | 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /ui/sidebar/run.gql: -------------------------------------------------------------------------------- 1 | mutation { 2 | run 3 | } 4 | -------------------------------------------------------------------------------- /ui/sidebar/set-collect-coverage.gql: -------------------------------------------------------------------------------- 1 | mutation SetCollectCoverage($collect: Boolean!) { 2 | setCollectCoverage(collect: $collect) 3 | } 4 | -------------------------------------------------------------------------------- /ui/sidebar/set-watch-mode.gql: -------------------------------------------------------------------------------- 1 | mutation SetWatchMode($watch: Boolean!) { 2 | toggleWatch(watch: $watch) { 3 | watching 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ui/sidebar/should-collect-coverage.gql: -------------------------------------------------------------------------------- 1 | { 2 | shouldCollectCoverage 3 | } 4 | -------------------------------------------------------------------------------- /ui/sidebar/summary/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { space } from "styled-system"; 4 | import { useSpring, animated } from "react-spring"; 5 | import { CheckCircle, ZapOff, Layers } from "react-feather"; 6 | import { Summary } from "../../../server/api/workspace/summary"; 7 | 8 | const Container = styled.div` 9 | ${space}; 10 | `; 11 | 12 | const Row = styled.div` 13 | display: flex; 14 | font-size: 16px; 15 | margin-bottom: 5px; 16 | `; 17 | 18 | const Cell = styled.div` 19 | display: flex; 20 | flex-direction: column; 21 | flex-grow: 1; 22 | `; 23 | 24 | const Label = styled.div` 25 | font-size: 12px; 26 | color: #dcdbdb; 27 | `; 28 | 29 | const Value = styled.div` 30 | font-size: 20px; 31 | color: ${props => (props.failed ? "#FF4F56" : "#19E28D")}; 32 | `; 33 | 34 | const CoverageLabel = styled.div` 35 | font-size: 10px; 36 | color: #dcdbdb; 37 | `; 38 | 39 | const CoverageValue = styled.div` 40 | font-size: 14px; 41 | `; 42 | 43 | const Coverage = styled.div` 44 | margin-top: 10px; 45 | `; 46 | 47 | interface Props { 48 | summary: Summary | undefined; 49 | } 50 | 51 | export default function SummaryPanel({ summary }: Props) { 52 | const passedSuitesProps = useSpring({ 53 | number: summary && summary.numPassedTestSuites | 0, 54 | from: { number: 0 } 55 | } as any); 56 | 57 | const failedSuitesProps = useSpring({ 58 | number: summary && summary.numFailedTestSuites | 0, 59 | from: { number: 0 } 60 | } as any); 61 | 62 | const passedTestProps = useSpring({ 63 | number: summary && summary.numPassedTests | 0, 64 | from: { number: 0 } 65 | } as any); 66 | 67 | const failedTestProps = useSpring({ 68 | number: summary && summary.numFailedTests | 0, 69 | from: { number: 0 } 70 | } as any); 71 | 72 | const coverage = summary && summary.coverage; 73 | const haveCoverage = 74 | coverage && 75 | (coverage.branch || 76 | coverage.function || 77 | coverage.line || 78 | coverage.statement); 79 | 80 | return ( 81 | 82 | 83 | 84 | 85 | 86 | {(passedSuitesProps as any).number.interpolate((value: any) => 87 | value.toFixed() 88 | )} 89 | 90 | 91 | 94 | 95 | 96 | 97 | 98 | {(failedSuitesProps as any).number.interpolate((value: any) => 99 | value.toFixed() 100 | )} 101 | 102 | 103 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | {(passedTestProps as any).number.interpolate((value: any) => 113 | value.toFixed() 114 | )} 115 | 116 | 117 | 120 | 121 | 122 | 123 | 124 | {(failedTestProps as any).number.interpolate((value: any) => 125 | value.toFixed() 126 | )} 127 | 128 | 129 | 132 | 133 | 134 | {!!haveCoverage && ( 135 | 136 | 137 | 138 | 139 | {summary && summary.coverage && summary.coverage.statement}% 140 | 141 | 142 | Stmts 143 | 144 | 145 | 146 | 147 | {summary && summary.coverage && summary.coverage.branch}% 148 | 149 | 150 | Branch 151 | 152 | 153 | 154 | 155 | {summary && summary.coverage && summary.coverage.function}% 156 | 157 | 158 | Funcs 159 | 160 | 161 | 162 | 163 | {summary && summary.coverage && summary.coverage.line}% 164 | 165 | 166 | Lines 167 | 168 | 169 | 170 | 171 | )} 172 | 173 | ); 174 | } 175 | -------------------------------------------------------------------------------- /ui/sidebar/transformer.ts: -------------------------------------------------------------------------------- 1 | import { Item } from "../../server/api/workspace/tree"; 2 | 3 | export interface TreeNode extends Item { 4 | name: string; 5 | path: string; 6 | isCollapsed: boolean; 7 | haveFailure: boolean; 8 | passing: boolean; 9 | isExecuting: boolean; 10 | hierarchy: number; 11 | } 12 | 13 | export function transform( 14 | item: TreeNode, 15 | executingTests: string[], 16 | failedFiles: string[], 17 | passingTests: string[], 18 | collapsedFiles: { [path: string]: boolean }, 19 | showFailedTests: boolean, 20 | items: Item[], 21 | results: TreeNode[] = [], 22 | hierarchy = 0 23 | ) { 24 | const isCollapsed = collapsedFiles[item.path] && !showFailedTests; // when showing failed tests, keep all expanded 25 | const haveFailure = failedFiles.indexOf(item.path) > -1; 26 | const nextChildren = getChildren(item.path, items); 27 | 28 | const treeItem = { 29 | type: item.type, 30 | name: item.name, 31 | path: item.path, 32 | parent: item.parent, 33 | hierarchy: hierarchy, 34 | isCollapsed: isCollapsed, 35 | passing: passingTests.indexOf(item.path) > -1, 36 | haveFailure, 37 | isExecuting: executingTests.indexOf(item.path) > -1 38 | }; 39 | 40 | results.push(treeItem); 41 | 42 | if (!isCollapsed) { 43 | nextChildren.forEach(item => { 44 | transform( 45 | item as any, 46 | executingTests, 47 | failedFiles, 48 | passingTests, 49 | collapsedFiles, 50 | showFailedTests, 51 | items, 52 | results, 53 | hierarchy + 1 54 | ); 55 | }); 56 | } 57 | 58 | return results; 59 | } 60 | 61 | export const filterFailure = (results: TreeNode[]) => { 62 | const finalResults = []; 63 | for (let i = results.length - 1; i >= 0; i--) { 64 | const item = results[i]; 65 | if (item.type === "file" && item.haveFailure === true) { 66 | finalResults.push(item); 67 | } else if (item.type === "directory") { 68 | const hasFailedChildren = haveFailedChildren(item.path, finalResults); 69 | if (hasFailedChildren) { 70 | finalResults.push(item); 71 | } 72 | } 73 | } 74 | return finalResults.reverse(); 75 | }; 76 | 77 | function haveFailedChildren(path: string, results: TreeNode[]) { 78 | return ( 79 | results.filter( 80 | result => 81 | result.parent === path && 82 | (result.haveFailure === true || result.type === "directory") 83 | ).length > 0 84 | ); 85 | } 86 | 87 | function sortAsc(a: Item, b: Item){ 88 | return a.name > b.name ? 1 : -1; 89 | } 90 | 91 | function getChildren(path: string, files: Item[]) { 92 | const fileList = files.filter(file => file.parent === path); 93 | return fileList.sort(sortAsc); 94 | } 95 | -------------------------------------------------------------------------------- /ui/sidebar/tree.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { FixedSizeList as List } from "react-window"; 4 | import AutoResizer from "react-virtualized-auto-sizer"; 5 | import FileItem from "./file-item"; 6 | import { TreeNode } from "./transformer"; 7 | 8 | const FileTreeContainer = styled.div` 9 | overflow: auto; 10 | height: calc(100vh - 173px); 11 | margin-left: -20px; 12 | `; 13 | 14 | interface Props { 15 | results: TreeNode[]; 16 | selectedFile: string; 17 | onFileSelection: (path: string) => void; 18 | onToggle: (path: string, isCollapsed: boolean) => void; 19 | } 20 | 21 | export default function Tree({ 22 | results, 23 | selectedFile, 24 | onFileSelection, 25 | onToggle 26 | }: Props) { 27 | return ( 28 | 29 | 30 | {({ height, width }: any) => { 31 | return ( 32 | 38 | {({ index, style }: any) => ( 39 | 47 | )} 48 | 49 | ); 50 | }} 51 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /ui/split-panel-style.ts: -------------------------------------------------------------------------------- 1 | const splitPanelCSS = ` 2 | .Resizer { 3 | background: #404148; 4 | opacity: .8; 5 | z-index: 1; 6 | box-sizing: border-box; 7 | background-clip: padding-box; 8 | } 9 | 10 | .Resizer:hover { 11 | transition: all 2s ease; 12 | } 13 | 14 | .Resizer.horizontal { 15 | height: 11px; 16 | margin: -5px 0; 17 | border-top: 5px solid rgba(255, 255, 255, 0); 18 | border-bottom: 5px solid rgba(255, 255, 255, 0); 19 | cursor: row-resize; 20 | width: 100%; 21 | } 22 | 23 | .Resizer.horizontal:hover { 24 | border-top: 5px solid rgba(0, 0, 0, 0.5); 25 | border-bottom: 5px solid rgba(0, 0, 0, 0.5); 26 | } 27 | 28 | .Resizer.vertical { 29 | width: 11px; 30 | margin: 0 -5px; 31 | border-left: 3px solid rgba(255, 255, 255, 0.0); 32 | border-right: 3px solid rgba(255, 255, 255, 0.0); 33 | cursor: col-resize; 34 | } 35 | 36 | .Resizer.vertical:hover { 37 | border-left: 3px solid rgba(0, 0, 0, 0.5); 38 | border-right: 3px solid rgba(0, 0, 0, 0.5); 39 | } 40 | .Resizer.disabled { 41 | cursor: not-allowed; 42 | } 43 | .Resizer.disabled:hover { 44 | border-color: transparent; 45 | } 46 | `; 47 | 48 | export default splitPanelCSS; 49 | -------------------------------------------------------------------------------- /ui/stop-runner.gql: -------------------------------------------------------------------------------- 1 | mutation { 2 | stop 3 | } 4 | -------------------------------------------------------------------------------- /ui/summary-query.gql: -------------------------------------------------------------------------------- 1 | query { 2 | summary { 3 | numPassedTests 4 | numFailedTests 5 | numPassedTestSuites 6 | numFailedTestSuites 7 | failedTests 8 | executingTests 9 | passingTests 10 | coverage { 11 | statement 12 | function 13 | branch 14 | line 15 | } 16 | haveCoverageReport 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ui/summary-subscription.gql: -------------------------------------------------------------------------------- 1 | subscription { 2 | changeToSummary { 3 | numPassedTests 4 | numFailedTests 5 | numPassedTestSuites 6 | numFailedTestSuites 7 | failedTests 8 | executingTests 9 | passingTests 10 | coverage { 11 | statement 12 | function 13 | branch 14 | line 15 | } 16 | haveCoverageReport 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ui/test-file/console-panel/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { ObjectInspector, chromeDark } from "react-inspector"; 4 | import { ConsoleLog } from "../../../server/api/workspace/test-result/console-log"; 5 | import { AlertCircle, XCircle, MessageSquare } from "react-feather"; 6 | 7 | const Container = styled.div` 8 | display: flex; 9 | flex-direction: column; 10 | padding: 10px; 11 | background-color: #404148; 12 | border-radius: 5px; 13 | margin-bottom: 10px; 14 | `; 15 | 16 | const Header = styled.div` 17 | font-size: 11px; 18 | color: white; 19 | margin-bottom: 5px; 20 | `; 21 | 22 | const Content = styled.pre` 23 | display: flex; 24 | margin-bottom: 3px; 25 | font-size: 12px; 26 | border-radius: 3px; 27 | padding: 4px; 28 | font-family: monospace; 29 | `; 30 | 31 | const Logs = styled.div` 32 | display: flex; 33 | flex-direction: column; 34 | max-height: 300px; 35 | overflow: auto; 36 | `; 37 | 38 | const IconWrapper = styled.div` 39 | margin-right: 5px; 40 | margin-top: 1px; 41 | `; 42 | 43 | const cleanAnsiCodes = (str: string) => str.replace(/\x1B\[(\d+)m/g, ""); 44 | 45 | function getIcon(type: String) { 46 | let icon = null; 47 | switch (type) { 48 | case "warn": 49 | icon = ; 50 | break; 51 | case "error": 52 | icon = ; 53 | break; 54 | case "log": 55 | icon = ; 56 | break; 57 | } 58 | 59 | return {icon}; 60 | } 61 | 62 | interface Props { 63 | consoleLogs: ConsoleLog[]; 64 | } 65 | 66 | 67 | export default function ConsolePanel({ consoleLogs }: Props) { 68 | return ( 69 | 70 |
Console logs from the file
71 | 72 | {consoleLogs.map((log, index) => { 73 | let result = log.message; 74 | try { 75 | result = eval("(" + log.message + ")"); 76 | } catch (e) { 77 | console.log(e); 78 | } 79 | 80 | if (typeof result === "string") { 81 | return ( 82 | 83 | {getIcon(log.type)} 84 | {cleanAnsiCodes(result)} 85 | 86 | ); 87 | } 88 | 89 | return ( 90 | 91 | {getIcon(log.type)} 92 | 103 | 104 | ); 105 | })} 106 | 107 |
108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /ui/test-file/error-panel/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import * as Convert from "ansi-to-html"; 4 | 5 | const convert = new Convert({ 6 | colors: { 7 | 1: "#FF4F56", 8 | 2: "#19E28D" 9 | } 10 | }); 11 | 12 | const Container = styled.div` 13 | padding: 10px; 14 | background-color: #404148; 15 | border-radius: 5px; 16 | margin-bottom: 10px; 17 | `; 18 | 19 | interface Props { 20 | failureMessage: string; 21 | } 22 | 23 | function escapeHtml(unsafe: string) { 24 | return unsafe 25 | .replace(/&/g, "&") 26 | .replace(//g, ">") 28 | .replace(/"/g, """) 29 | .replace(/'/g, "'"); 30 | } 31 | 32 | export default function ErrorPanel({ failureMessage }: Props) { 33 | if (!failureMessage || failureMessage.trim() === "") { 34 | return null; 35 | } 36 | 37 | return ( 38 | 39 |
44 |     
45 |   );
46 | }
47 | 


--------------------------------------------------------------------------------
/ui/test-file/file-items-subscription.gql:
--------------------------------------------------------------------------------
 1 | subscription($path: String!) {
 2 |   fileChange(path: $path) {
 3 |     items {
 4 |       id
 5 |       name
 6 |       type
 7 |       parent
 8 |       only
 9 |     }
10 |   }
11 | }
12 | 


--------------------------------------------------------------------------------
/ui/test-file/index.tsx:
--------------------------------------------------------------------------------
  1 | import React, { memo } from "react";
  2 | import styled from "styled-components";
  3 | import { space, color } from "styled-system";
  4 | import { useMutation } from "react-apollo-hooks";
  5 | import FILEITEMS_SUB from "./file-items-subscription.gql";
  6 | import FILEITEMS from "./query.gql";
  7 | import RUNFILE from "./run-file.gql";
  8 | import UPDATE_SNAPSHOT from "./update-snapshot.gql";
  9 | import FILERESULTSUB from "./subscription.gql";
 10 | import RESULT from "./result.gql";
 11 | import Test from "./test-item";
 12 | import { transform } from "./transformer";
 13 | import useSubscription from "./use-subscription";
 14 | import FileSummary from "./summary";
 15 | import { TestFileResult } from "../../server/api/workspace/test-result/file-result";
 16 | import { TestFile as TestFileModel } from "../../server/api/workspace/test-file";
 17 | import ConsolePanel from "./console-panel";
 18 | import ErrorPanel from "./error-panel";
 19 | import useKeys, { hasKeys } from "../hooks/use-keys";
 20 | 
 21 | const Container = styled.div`
 22 |   ${space};
 23 |   ${color};
 24 |   height: 100vh;
 25 |   padding-left: 20px;
 26 | `;
 27 | 
 28 | const Content = styled.div`
 29 |   overflow: auto;
 30 |   height: calc(100vh - 118px);
 31 | 
 32 |   ${({ dim }: any) => dim && `
 33 |   opacity: .5;
 34 |   `}
 35 | `;
 36 | 
 37 | const TestItemsContainer = styled.div`
 38 |   margin-left: -25px;
 39 | `;
 40 | 
 41 | interface Props {
 42 |   selectedFilePath: string;
 43 |   isRunning: boolean;
 44 |   projectRoot: string;
 45 |   onStop: () => void;
 46 | }
 47 | 
 48 | function TestFile({ selectedFilePath, isRunning, projectRoot, onStop }: Props) {
 49 |   const { data: fileItemResult }: { data: TestFileModel } = useSubscription(
 50 |     FILEITEMS,
 51 |     FILEITEMS_SUB,
 52 |     {
 53 |       path: selectedFilePath
 54 |     },
 55 |     result => result.file,
 56 |     result => result.fileChange
 57 |   );
 58 | 
 59 |   const suiteCount = ((fileItemResult && fileItemResult.items) || []).filter(
 60 |     fileItem => fileItem.type === "describe"
 61 |   ).length;
 62 | 
 63 |   const testCount = ((fileItemResult && fileItemResult.items) || []).filter(
 64 |     fileItem => fileItem.type === "it"
 65 |   ).length;
 66 | 
 67 |   const todoCount = ((fileItemResult && fileItemResult.items) || []).filter(
 68 |     fileItem => fileItem.type === "todo"
 69 |   ).length;
 70 | 
 71 |   const runFile = useMutation(RUNFILE, {
 72 |     variables: {
 73 |       path: selectedFilePath
 74 |     }
 75 |   });
 76 | 
 77 |   const updateSnapshot = useMutation(UPDATE_SNAPSHOT, {
 78 |     variables: {
 79 |       path: selectedFilePath
 80 |     }
 81 |   });
 82 | 
 83 |   const {
 84 |     data: result,
 85 |     loading
 86 |   }: { data: TestFileResult; loading: boolean } = useSubscription(
 87 |     RESULT,
 88 |     FILERESULTSUB,
 89 |     {
 90 |       path: selectedFilePath
 91 |     },
 92 |     result => result.result,
 93 |     result => result.changeToResult
 94 |   );
 95 | 
 96 |   const isUpdating = isRunning && (result ===  null ||(result.numPassingTests === 0 && result.numFailingTests === 0));
 97 | 
 98 |   const roots = (fileItemResult.items || []).filter(
 99 |     item => item.parent === null
100 |   );
101 |   const keys = useKeys();
102 |   if (hasKeys(["Alt", "Enter"], keys)) {
103 |     runFile();
104 |   }
105 |   return (
106 |     
107 |        {
119 |           runFile();
120 |         }}
121 |         onStop={onStop}
122 |         onSnapshotUpdate={() => {
123 |           updateSnapshot();
124 |         }}
125 |       />
126 |       
127 |         {result && result.testResults && result.testResults.length === 0 && (
128 |           
129 |         )}
130 |         {result && result.consoleLogs && result.consoleLogs.length > 0 && (
131 |           
132 |         )}
133 |         {fileItemResult && (
134 |           
135 |             {roots.map(item => {
136 |               const tree = transform(
137 |                 item as any,
138 |                 fileItemResult.items as any,
139 |                 0
140 |               ) as any;
141 |               return ;
142 |             })}
143 |           
144 |         )}
145 |       
146 |     
147 |   );
148 | }
149 | 
150 | export default memo(TestFile, (pre: Props, next: Props) => {
151 |   return (
152 |     pre.isRunning === next.isRunning &&
153 |     pre.selectedFilePath === next.selectedFilePath
154 |   );
155 | });
156 | 


--------------------------------------------------------------------------------
/ui/test-file/open-failure.gql:
--------------------------------------------------------------------------------
1 | mutation OpenFailure($failure: String!) {
2 |   openFailure(failure: $failure)
3 | }
4 | 


--------------------------------------------------------------------------------
/ui/test-file/query.gql:
--------------------------------------------------------------------------------
 1 | query FileItems($path: String!) {
 2 |   file(path: $path) {
 3 |     items {
 4 |       id
 5 |       name
 6 |       type
 7 |       parent
 8 |       only
 9 |     }
10 |   }
11 | }
12 | 


--------------------------------------------------------------------------------
/ui/test-file/result.gql:
--------------------------------------------------------------------------------
 1 | query Results($path: String!) {
 2 |   result(path: $path) {
 3 |     path
 4 |     numFailingTests
 5 |     numPassingTests
 6 |     failureMessage
 7 |     testResults {
 8 |       title
 9 |       numPassingAsserts
10 |       status
11 |       failureMessages
12 |       ancestorTitles
13 |       duration
14 |     }
15 |     consoleLogs {
16 |       message
17 |       type
18 |       origin
19 |     }
20 |   }
21 | }
22 | 


--------------------------------------------------------------------------------
/ui/test-file/run-file.gql:
--------------------------------------------------------------------------------
1 | mutation RunFile($path: String!) {
2 |   runFile(path: $path)
3 | }
4 | 


--------------------------------------------------------------------------------
/ui/test-file/subscription.gql:
--------------------------------------------------------------------------------
 1 | subscription Results($path: String!) {
 2 |   changeToResult(path: $path) {
 3 |     path
 4 |     numFailingTests
 5 |     numPassingTests
 6 |     failureMessage
 7 |     testResults {
 8 |       title
 9 |       numPassingAsserts
10 |       status
11 |       failureMessages
12 |       ancestorTitles
13 |       duration
14 |     }
15 |     consoleLogs {
16 |       message
17 |       type
18 |       origin
19 |     }
20 |   }
21 | }
22 | 


--------------------------------------------------------------------------------
/ui/test-file/summary/index.tsx:
--------------------------------------------------------------------------------
  1 | import React from "react";
  2 | import styled from "styled-components";
  3 | import { space, fontSize, color } from "styled-system";
  4 | import { useSpring, animated } from "react-spring";
  5 | import {
  6 |   Folder,
  7 |   Code,
  8 |   Play,
  9 |   StopCircle,
 10 |   Camera,
 11 |   CheckCircle,
 12 |   Frown,
 13 |   ZapOff,
 14 |   Circle,
 15 |   Eye
 16 | } from "react-feather";
 17 | import Button from "../../components/button";
 18 | import OPEN_IN_EDITOR from "./open-in-editor.gql";
 19 | import OPEN_SNAP_IN_EDITOR from "./open-snap-in-editor.gql";
 20 | import { Tooltip } from "react-tippy";
 21 | import { useMutation } from "react-apollo-hooks";
 22 | 
 23 | const Container = styled.div`
 24 |   position: relative;
 25 |   ${space};
 26 |   ${color};
 27 |   border-radius: 3px;
 28 |   display: flex;
 29 |   justify-content: space-between;
 30 |   margin-bottom: 10px;
 31 |   overflow: hidden;
 32 |   flex-wrap: wrap;
 33 | `;
 34 | 
 35 | const ContainerBG = styled(animated.div)`
 36 |   @keyframes MOVE-BG {
 37 |     from {
 38 |       transform: translateX(0);
 39 |     }
 40 |     to {
 41 |       transform: translateX(27px);
 42 |     }
 43 |   }
 44 |   border-radius: 3px;
 45 |   position: absolute;
 46 |   top: 0;
 47 |   bottom: 0;
 48 |   right: 0;
 49 |   left: -46px;
 50 |   background: repeating-linear-gradient(
 51 |     45deg,
 52 |     #404148,
 53 |     #404148 10px,
 54 |     #242326 10px,
 55 |     #242326 20px
 56 |   );
 57 | 
 58 |   animation-name: MOVE-BG;
 59 |   animation-duration: 0.5s;
 60 |   animation-timing-function: linear;
 61 |   animation-iteration-count: infinite;
 62 | `;
 63 | 
 64 | const RightContainer = styled.div`
 65 |   z-index: 1;
 66 | `;
 67 | 
 68 | const InfoContainer = styled.div`
 69 |   display: flex;
 70 | `;
 71 | 
 72 | const Info = styled.div`
 73 |   display: flex;
 74 |   align-items: center;
 75 |   margin-right: 15px;
 76 |   font-weight: 600;
 77 |   ${color}
 78 | `;
 79 | 
 80 | const InfoLabel = styled.div`
 81 |   margin-left: 5px;
 82 | `;
 83 | 
 84 | const FilePath = styled.div`
 85 |   ${fontSize};
 86 |   ${space};
 87 |   word-break: break-all;
 88 |   font-weight: 600;
 89 |   margin-right: 5px;
 90 | `;
 91 | 
 92 | const ActionPanel = styled.div`
 93 |   display: flex;
 94 |   align-items: center;
 95 |   z-index: 1;
 96 | `;
 97 | 
 98 | const LoadingResult = styled.div`
 99 |   color: #d9eef2;
100 |   margin-right: 10px;
101 |   font-size: 12px;
102 | `;
103 | 
104 | interface Props {
105 |   path: string;
106 |   projectRoot: string;
107 |   suiteCount: number;
108 |   testCount: number;
109 |   todoCount: number;
110 |   passingTests: number;
111 |   failingTests: number;
112 |   isRunning: boolean;
113 |   isUpdating: boolean;
114 |   isLoadingResult: boolean;
115 |   onRun: () => void;
116 |   onStop: () => void;
117 |   onSnapshotUpdate: () => void;
118 |   haveSnapshotFailures: boolean;
119 | }
120 | 
121 | export default function FileSummary({
122 |   path,
123 |   projectRoot,
124 |   suiteCount,
125 |   testCount,
126 |   todoCount,
127 |   passingTests,
128 |   failingTests,
129 |   isRunning,
130 |   isUpdating,
131 |   isLoadingResult,
132 |   onRun,
133 |   onStop,
134 |   onSnapshotUpdate,
135 | }: Props) {
136 |   const Icon = isRunning ? StopCircle : Play;
137 | 
138 |   const openInEditor = useMutation(OPEN_IN_EDITOR, {
139 |     variables: {
140 |       path
141 |     }
142 |   });
143 | 
144 |   const openSnapshotInEditor = useMutation(OPEN_SNAP_IN_EDITOR, {
145 |     variables: {
146 |       path
147 |     }
148 |   });
149 | 
150 |   return (
151 |     
152 |       {( isUpdating || isLoadingResult) && }
153 |       
154 |         
155 |           {path.replace(projectRoot, "")}
156 |         
157 | 
158 |         
159 |           
160 |              {suiteCount} Suites
161 |           
162 |           
163 |              {testCount} Tests
164 |           
165 |           
166 |             {" "}
167 |             {passingTests} Passing tests
168 |           
169 |           
170 |             {" "}
171 |             {failingTests} Failing tests
172 |           
173 |         
174 |       
175 |       
176 |         {isLoadingResult && Loading test results}
177 |         
178 |           
191 |         
192 |         
193 |           
215 |           
216 |         
217 |