├── .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 |
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 |
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 | {item.name}
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 | : }
173 | size="sm"
174 | onClick={() => {
175 | if (isRunning) {
176 | onStop();
177 | } else {
178 | run();
179 | }
180 | }}
181 | >
182 | {isRunning ? "Stop" : "Run tests"}
183 |
184 |
185 |
186 |
187 | }
189 | minimal
190 | onClick={() => {
191 | if (runnerStatus) {
192 | handleSetWatchModel(!runnerStatus.watching);
193 | }
194 | }}
195 | >
196 | {runnerStatus && runnerStatus.watching
197 | ? "Stop Watching"
198 | : "Watch"}
199 |
200 |
201 |
202 | {
205 | setCollectCoverage({
206 | variables: {
207 | collect: !shouldCollectCoverage
208 | }
209 | });
210 | refetchCoverageFlag();
211 | }}
212 | >
213 |
214 |
215 |
216 |
217 | {
220 | onSearchOpen();
221 | }}
222 | >
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 | Tests
231 |
232 | {summary && summary.failedTests && summary.failedTests.length > 0 && (
233 |
238 | {
242 | setShowFailedTests(!showFailedTests);
243 | }}
244 | >
245 |
246 |
247 |
248 | )}
249 | {!showFailedTests && (
250 |
251 |
256 |
257 |
258 |
259 | )}
260 | {!showFailedTests && (
261 |
262 |
267 |
268 |
269 |
270 | )}
271 | {summary && summary.haveCoverageReport && (
272 |
277 | {
281 | onShowCoverage();
282 | }}
283 | >
284 |
285 |
286 |
287 | )}
288 |
289 | {
293 | onRefreshFiles();
294 | }}
295 | >
296 |
297 |
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 |
92 | Passing suites
93 |
94 | |
95 |
96 |
97 |
98 | {(failedSuitesProps as any).number.interpolate((value: any) =>
99 | value.toFixed()
100 | )}
101 |
102 |
103 |
104 | Failing suites
105 |
106 | |
107 |
108 |
109 |
110 |
111 |
112 | {(passedTestProps as any).number.interpolate((value: any) =>
113 | value.toFixed()
114 | )}
115 |
116 |
117 |
118 | Passing tests
119 |
120 | |
121 |
122 |
123 |
124 | {(failedTestProps as any).number.interpolate((value: any) =>
125 | value.toFixed()
126 | )}
127 |
128 |
129 |
130 | Failing tests
131 |
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 | }
180 | minimal
181 | onClick={() => {
182 | if (isRunning) {
183 | onStop();
184 | } else {
185 | onRun();
186 | }
187 | }}
188 | >
189 | {isRunning ? "Stop" : "Run"}
190 |
191 |
192 |
193 | }
195 | minimal
196 | onClick={() => {
197 | openInEditor();
198 | }}
199 | />
200 |
201 |
206 | }
209 | onClick={() => {
210 | onSnapshotUpdate();
211 | }}
212 | >
213 | Update Snapshot
214 |
215 |
216 |
217 | }
219 | minimal
220 | onClick={() => {
221 | openSnapshotInEditor();
222 | }}
223 | />
224 |
225 |
226 |
227 | );
228 | }
229 |
--------------------------------------------------------------------------------
/ui/test-file/summary/open-in-editor.gql:
--------------------------------------------------------------------------------
1 | mutation OpenInEditor($path: String!) {
2 | openInEditor(path: $path)
3 | }
4 |
--------------------------------------------------------------------------------
/ui/test-file/summary/open-snap-in-editor.gql:
--------------------------------------------------------------------------------
1 | mutation OpenSnapInEditor($path: String!) {
2 | openSnapInEditor(path: $path)
3 | }
4 |
--------------------------------------------------------------------------------
/ui/test-file/test-indicator.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | CheckCircle,
4 | Circle,
5 | Package,
6 | XCircle,
7 | Zap,
8 | Edit2
9 | } from "react-feather";
10 |
11 | interface Props {
12 | status: string | null | undefined;
13 | describe: boolean;
14 | todo: boolean;
15 | }
16 |
17 | export default function TestIndicator({ status, describe, todo }: Props) {
18 | let Icon = describe ? Package : Zap;
19 | let color = "#AC61FF";
20 |
21 | if (todo) {
22 | return ;
23 | }
24 |
25 | if (!describe) {
26 | if (status === "passed") {
27 | Icon = CheckCircle;
28 | } else if (status === "todo") {
29 | Icon = Circle;
30 | } else if (status === "failed") {
31 | Icon = XCircle;
32 | }
33 | }
34 |
35 | if (status === "passed") {
36 | color = "#50E3C2";
37 | } else if (status === "failed") {
38 | color = "#FF4954";
39 | }
40 |
41 | return ;
42 | }
43 |
--------------------------------------------------------------------------------
/ui/test-file/test-item.tsx:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from "react";
2 | import styled from "styled-components";
3 | import { TestFileItem } from "./transformer";
4 | import { TestFileResult } from "../../server/api/workspace/test-result/file-result";
5 | import TestIndicator from "./test-indicator";
6 | import { color, space } from "styled-system";
7 | import * as Convert from "ansi-to-html";
8 | import OPEN_FAILURE from "./open-failure.gql";
9 | import { useMutation } from "react-apollo-hooks";
10 |
11 | const convert = new Convert({
12 | colors: {
13 | 1: "#FF4F56",
14 | 2: "#19E28D"
15 | }
16 | });
17 |
18 | function getResults(item: TestFileItem, testResult: TestFileResult) {
19 | if (!testResult || !testResult.testResults) {
20 | return null;
21 | }
22 |
23 | return testResult.testResults.find(result => result.title === item.name);
24 | }
25 |
26 | const Container = styled.div`
27 | ${color};
28 | ${space};
29 | padding-left: 25px;
30 | `;
31 |
32 | const Label = styled.div`
33 | display: flex;
34 | align-items: center;
35 | font-weight: 600;
36 | font-size: 13px;
37 |
38 | span {
39 | margin-left: 5px;
40 | }
41 | `;
42 |
43 | const Content = styled.div`
44 | padding: 5px;
45 | display: flex;
46 | flex-direction: column;
47 |
48 | background-color: #262529;
49 | border-radius: 4px;
50 | margin-bottom: 10px;
51 | border: 1px solid ${props => (props.only ? "#9d8301" : "#333437")};
52 | `;
53 |
54 | const FailureMessage = styled.div`
55 | padding-left: 20px;
56 | pre {
57 | overflow: auto;
58 | }
59 | `;
60 |
61 | const Duration = styled.span`
62 | font-weight: 400;
63 | font-size: 12px;
64 | color: #fcd101;
65 | `;
66 |
67 | function escapeHtml(unsafe: string) {
68 | return unsafe
69 | .replace(/&/g, "&")
70 | .replace(//g, ">")
72 | .replace(/"/g, """)
73 | .replace(/'/g, "'");
74 | }
75 |
76 | interface Props {
77 | item: TestFileItem;
78 | result: TestFileResult | null;
79 | }
80 |
81 | export default function Test({
82 | item: { name, only, children },
83 | item,
84 | result
85 | }: Props) {
86 | const testResult = getResults(item, result as any);
87 | const isDurationAvailable = testResult && testResult.duration !== undefined;
88 | const haveFailure = testResult && testResult.failureMessages.length > 0;
89 | const allChildrenPassing = (children || []).every(child => {
90 | if (child.type === "it") {
91 | const childResult = getResults(child, result as any);
92 | return childResult && childResult.status === "passed";
93 | }
94 |
95 | return true;
96 | });
97 |
98 | if (children && children.length > 0) {
99 | }
100 |
101 | const openFailure = useMutation(OPEN_FAILURE, {
102 | variables: {
103 | failure: testResult && testResult.failureMessages ? testResult.failureMessages[0] : ''
104 | }
105 | });
106 |
107 | return (
108 |
109 | openFailure()}>
110 |
111 |
120 | {name}
121 | {isDurationAvailable && (
122 | {testResult && testResult.duration} ms
123 | )}
124 |
125 | {testResult && haveFailure && (
126 |
127 |
134 |
135 | )}
136 |
137 | {children &&
138 | children.map(child => (
139 |
140 | ))}
141 |
142 | );
143 | }
144 |
--------------------------------------------------------------------------------
/ui/test-file/transformer.ts:
--------------------------------------------------------------------------------
1 | import { TestItem } from "../../server/api/workspace/test-item";
2 |
3 | export interface TestFileItem extends TestItem {
4 | children?: TestFileItem[];
5 | index: number;
6 | }
7 |
8 | export function transform(
9 | item: TestFileItem,
10 | items: TestItem[],
11 | index: number = 0,
12 | tree?: TestFileItem
13 | ) {
14 | if (!item) {
15 | return {};
16 | }
17 |
18 | const nextChildren = getChildren(item.id, items);
19 | if (!tree) {
20 | tree = {
21 | id: item.id,
22 | type: item.type,
23 | name: item.name,
24 | parent: item.parent,
25 | only: item.only,
26 | children: nextChildren,
27 | index: index + 1
28 | } as any;
29 | }
30 | item.children = nextChildren as any;
31 | item.children &&
32 | item.children.forEach(item => {
33 | transform(item, items, index + 1, tree);
34 | });
35 | return tree;
36 | }
37 |
38 | function getChildren(id: string, items: TestItem[]) {
39 | return items.filter(item => item.parent === id);
40 | }
41 |
--------------------------------------------------------------------------------
/ui/test-file/update-snapshot.gql:
--------------------------------------------------------------------------------
1 | mutation UpdateSnapshot($path: String!) {
2 | updateSnapshot(path: $path)
3 | }
4 |
--------------------------------------------------------------------------------
/ui/test-file/use-subscription.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { DocumentNode } from "graphql";
3 | import { useApolloClient } from "react-apollo-hooks";
4 |
5 | export default function useSubscription(
6 | query: DocumentNode,
7 | subscriptionQuery: DocumentNode,
8 | variables: any,
9 | queryResultMapper: (result: any) => any,
10 | subResultMapper: (result: any) => any,
11 | name: string = ""
12 | ) {
13 | const client = useApolloClient();
14 | const [result, setResult] = useState({
15 | data: {},
16 | loading: false,
17 | error: null
18 | });
19 |
20 | let subscription: any;
21 |
22 | useEffect(
23 | () => {
24 | if (client) {
25 | setResult({
26 | ...result,
27 | loading: true
28 | });
29 | client
30 | .query({
31 | query,
32 | variables,
33 | fetchPolicy: "network-only"
34 | })
35 | .then(({ data, errors, loading }) => {
36 | console.log(name, data);
37 | setResult({
38 | data: queryResultMapper(data),
39 | error: errors,
40 | loading
41 | });
42 | });
43 | }
44 | },
45 | variables.path ? [variables.path] : []
46 | );
47 |
48 | useEffect(
49 | () => {
50 | if (client) {
51 | console.log("Subbed to", name);
52 | subscription = client
53 | .subscribe({
54 | query: subscriptionQuery,
55 | variables,
56 | fetchPolicy: "network-only"
57 | })
58 | .subscribe({
59 | error: (error: any) => {
60 | setResult({ loading: false, data: result.data, error });
61 | },
62 | next: (nextResult: any) => {
63 | console.log("Sub Result", name, nextResult.data);
64 | const newResult = {
65 | data: subResultMapper(nextResult.data),
66 | error: undefined,
67 | loading: false
68 | };
69 | setResult(newResult);
70 | }
71 | });
72 | }
73 | },
74 | variables.path ? [variables.path] : []
75 | );
76 |
77 | useEffect(
78 | () => {
79 | return () => {
80 | subscription && subscription.unsubscribe();
81 | };
82 | },
83 | variables.path ? [variables.path] : []
84 | );
85 |
86 | return result;
87 | }
88 |
--------------------------------------------------------------------------------
/ui/theme.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | colors: {
3 | veryDark: "#262529",
4 | dark: "#242326",
5 | slightDark: "#404148",
6 | text: "#F5F5F5",
7 | primary: "#FDC055",
8 | danger: "#ff4f56",
9 | success: "#19E28D"
10 | },
11 | space: [0, 2, 4, 8, 16, 32, 64, 128, 256, 512]
12 | };
13 |
--------------------------------------------------------------------------------
/ui/typings.d.ts:
--------------------------------------------------------------------------------
1 | declare module "apollo-link-ws";
2 | declare module "apollo-utilities";
3 | declare module "apollo-link";
4 | declare module "apollo-client-preset";
5 | declare module "*.gql" {
6 | const content: any;
7 | export default content;
8 | }
9 | declare module "resolve-pkg";
10 | declare module "react-virtualized-auto-sizer";
11 | declare module "react-window";
12 | declare module "minimist";
13 | declare module "react-tippy";
14 | declare module "ansi-to-html";
15 | declare module "*.png";
16 | declare module "react-inspector";
17 |
--------------------------------------------------------------------------------