├── .github
├── FUNDING.yml
└── workflows
│ └── build.yml
├── .gitignore
├── LICENSE
├── README.md
├── __tests__
├── export.spec.ts
├── import.spec.ts
├── index.spec.ts
├── react.spec.tsx
└── test.ts
├── environment.js
├── package.json
├── runner.js
├── src
├── electron
│ ├── main
│ │ ├── index.css
│ │ ├── index.html
│ │ ├── index.ts
│ │ └── window-pool.ts
│ ├── proc
│ │ └── index.ts
│ └── renderer
│ │ ├── dom.ts
│ │ ├── index.ts
│ │ └── uitl.ts
├── environment.ts
├── index.ts
├── runner.ts
└── utils
│ ├── config.ts
│ ├── constant.ts
│ ├── delay.ts
│ └── uuid.ts
└── tsconfig.json
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | custom: ['https://paypal.me/hustcc', 'https://atool.vip']
13 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: build
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 |
8 | runs-on: macOS-latest
9 |
10 | strategy:
11 | matrix:
12 | node-version: [10.x, 12.x]
13 |
14 | steps:
15 | - uses: actions/checkout@v2
16 | - name: Use Node.js ${{ matrix.node-version }}
17 | uses: actions/setup-node@v2
18 | with:
19 | node-version: ${{ matrix.node-version }}
20 | - name: npm install, build, and test
21 | run: |
22 | npm install
23 | npm run build --if-present
24 | npm test
25 | env:
26 | CI: true
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | lib
3 | coverage
4 | node_modules
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 hustcc
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 | # jest-electron
2 |
3 | > Easiest way to run jest unit test cases in electron.
4 |
5 | When we run unit test in Jest, it is actually running in the node environment, or virtual browser environment(e.g. `JSDOM`) mocked by NodeJS. Sometimes we need a lot of [Jest mocks](https://github.com/jest-community/awesome-jest#mocks) for running code with no throw, such as: jest-canvas-mock, jest-storage-mock, @jest/fake-timers and so on. This is solved by `Jest-Electron`.
6 |
7 | [](https://github.com/hustcc/jest-electron/actions)
8 | [](https://www.npmjs.com/package/jest-electron)
9 | [](https://www.npmjs.com/package/jest-electron)
10 |
11 |
12 | 1. Technological ecology of `Jest`.
13 | 2. Complete and real `browser environment`.
14 | 3. `Multi-renderer` for running performance.
15 | 4. `Running and debug` is better then mock.
16 |
17 |
18 | ## Installation
19 |
20 |
21 | - Add into devDependencies
22 |
23 | ```bash
24 | $ npm i --save-dev jest-electron
25 | ```
26 |
27 | - Update Jest config
28 |
29 | ```diff
30 | {
31 | "jest": {
32 | + "runner": "jest-electron/runner",
33 | + "testEnvironment": "jest-electron/environment"
34 | }
35 | }
36 | ```
37 |
38 | **Notice**: update the `runner` configure, not `testRunner`.
39 |
40 |
41 |
42 | ## Related
43 |
44 | > Those will be helpful when run test case with `jest-electron`.
45 |
46 | - [jest-less-loader](https://github.com/hustcc/jest-less-loader): Run test cases with import `less`, `css` code.
47 | - [jest-url-loader](https://github.com/hustcc/jest-url-loader): Run test cases with import `svg`, `png`, `jpg` or other url file..
48 |
49 |
50 |
51 | ## CI
52 |
53 | > Run test cases with `jest-electron` for continuous integration.
54 |
55 | - **GitHub action**
56 |
57 | Running on `macOS` will be ok.
58 |
59 |
60 | ```diff
61 | - runs-on: ubuntu-latest
62 | + runs-on: macOS-latest
63 | ```
64 |
65 |
66 | - **travis**
67 |
68 | Update `.travis.yml` with electron supported.
69 |
70 | ```diff
71 | language: node_js
72 | node_js:
73 | - "8"
74 | - "9"
75 | - "10"
76 | - "11"
77 | - "12"
78 | + addons:
79 | + apt:
80 | + packages:
81 | + - xvfb
82 | + install:
83 | + - export DISPLAY=':99.0'
84 | + - Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
85 | + - npm install
86 | script:
87 | - npm run test
88 | ```
89 |
90 | Depending on your executor, you might need to disable sandbox and shared memory usage:
91 |
92 | ```bash
93 | export JEST_ELECTRON_STARTUP_ARGS='--disable-dev-shm-usage --no-sandbox'
94 | npm run test
95 | ```
96 |
97 | ## Env
98 |
99 | - **debug mode**
100 |
101 | Keep the electron browser window for debugging, set process env `DEBUG_MODE=1`.
102 |
103 |
104 | ```bash
105 | DEBUG_MODE=1 jest
106 | ```
107 |
108 | - **additional startup arguments**
109 |
110 | Run electron with arbitrary arguments.
111 |
112 | ```bash
113 | JEST_ELECTRON_STARTUP_ARGS='--disable-dev-shm-usage'
114 | ```
115 |
116 | Run electron with `--no-sandbox`, set process env `JEST_ELECTRON_STARTUP_ARGS='--no-sandbox'`.
117 |
118 | ```bash
119 | JEST_ELECTRON_STARTUP_ARGS='--no-sandbox' jest
120 | ```
121 |
122 |
123 | ## License
124 |
125 | MIT@[hustcc](https://github.com/hustcc).
126 |
--------------------------------------------------------------------------------
/__tests__/export.spec.ts:
--------------------------------------------------------------------------------
1 | import { name } from '../src';
2 | import { delay } from '../src/utils/delay';
3 |
4 | describe('jest-electron', () => {
5 | test('async', async () => {
6 | await delay();
7 | expect(name).toBe('jest-electron');
8 | });
9 |
10 | xtest('skip', () => {
11 | expect(1).toBeDefined();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/__tests__/import.spec.ts:
--------------------------------------------------------------------------------
1 | import { sum } from './test';
2 |
3 | describe('jest-electron', () => {
4 | test('sum', () => {
5 | expect(sum(1, 2)).toBe(3);
6 | });
7 | });
8 |
--------------------------------------------------------------------------------
/__tests__/index.spec.ts:
--------------------------------------------------------------------------------
1 | describe('jest-electron', () => {
2 | test('document env', () => {
3 | expect(document).toBeDefined()
4 | });
5 |
6 | test('create dom', () => {
7 | const div = document.createElement('div');
8 | div.innerHTML = 'hello jest-electron';
9 |
10 | document.body.appendChild(div);
11 |
12 | expect(div.innerHTML).toBe('hello jest-electron');
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/__tests__/react.spec.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ReactDOM from 'react-dom';
3 |
4 | describe('jest-electron', () => {
5 | test('react', () => {
6 | const div = document.createElement('div');
7 | document.body.appendChild(div);
8 |
9 | ReactDOM.render(
jest electron run with react
, div);
10 |
11 | expect(div.innerHTML).toBe('jest electron run with react
');
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/__tests__/test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * sum function for test
3 | * @param a
4 | * @param b
5 | */
6 | export function sum(a: number, b: number): number {
7 | console.log(a, b);
8 | return a + b;
9 | }
10 |
--------------------------------------------------------------------------------
/environment.js:
--------------------------------------------------------------------------------
1 | // entry
2 | module.exports = require('./lib/environment').default;
3 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jest-electron",
3 | "version": "0.1.12",
4 | "description": "Easiest way to run jest unit test cases in electron.",
5 | "main": "index.js",
6 | "files": [
7 | "lib",
8 | "runner.js",
9 | "environment.js"
10 | ],
11 | "scripts": {
12 | "test-live": "DEBUG_MODE=1 jest",
13 | "test": "jest",
14 | "cp": "copyfiles -u 1 src/electron/**/*.{html,css} lib",
15 | "build": "rimraf lib && tsc && npm run cp",
16 | "prepublishOnly": "npm run build"
17 | },
18 | "jest": {
19 | "runner": "./runner",
20 | "testEnvironment": "./environment",
21 | "preset": "ts-jest",
22 | "collectCoverage": true,
23 | "testRegex": "(/__tests__/.*\\.(test|spec))\\.tsx?$",
24 | "collectCoverageFrom": [
25 | "src/**/*.ts"
26 | ]
27 | },
28 | "dependencies": {
29 | "electron": "^12",
30 | "jest-haste-map": "~24.9.0",
31 | "jest-message-util": "~24.9.0",
32 | "jest-mock": "~24.9.0",
33 | "jest-resolve": "~24.9.0",
34 | "jest-runner": "~24.9.0",
35 | "jest-runtime": "~24.9.0",
36 | "jest-util": "~24.9.0",
37 | "throat": "^5.0.0",
38 | "tslib": "^1.10.0"
39 | },
40 | "peerDependencies": {
41 | "jest": "^24.0.0"
42 | },
43 | "devDependencies": {
44 | "@types/jest": "^24.0.18",
45 | "@types/react": "^16.9.15",
46 | "copyfiles": "^2.1.1",
47 | "jest": "^24.9.0",
48 | "react": "^16.12.0",
49 | "react-dom": "^16.12.0",
50 | "rimraf": "^3.0.0",
51 | "ts-jest": "^24.0.2",
52 | "typescript": "^3.6.2"
53 | },
54 | "keywords": [
55 | "jest-electron",
56 | "jest-runner",
57 | "jest",
58 | "electron"
59 | ],
60 | "author": "hustcc",
61 | "license": "MIT",
62 | "repository": {
63 | "type": "git",
64 | "url": "git+https://github.com/hustcc/jest-electron.git"
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/runner.js:
--------------------------------------------------------------------------------
1 | // entry
2 | module.exports = require('./lib/runner').default;
3 |
--------------------------------------------------------------------------------
/src/electron/main/index.css:
--------------------------------------------------------------------------------
1 | #__jest-electron-test-results__ {
2 | width: 100%;
3 | font: 20px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif;
4 | font-size: 12px;
5 | color: #888;
6 |
7 | border: 1px dashed #AAA;
8 | padding: 8px 0;
9 | margin-bottom: 16px;
10 | }
11 |
12 | /*result stat*/
13 |
14 | #__jest-electron-test-results-stat__ {
15 | display: flex;
16 | justify-content: flex-end;
17 | }
18 |
19 | #__jest-electron-test-results-stat__ .stat-item {
20 | padding-right: 8px;
21 | }
22 |
23 | #__jest-electron-test-results-stat__ .stat-item span {
24 | font-style: italic;
25 | }
26 |
27 | #__jest-electron-test-results-stat__ .stat-item span.red {
28 | color: red;
29 | }
30 |
31 | #__jest-electron-test-results-stat__ .stat-item span.green {
32 | color: green;
33 | }
34 |
35 | #__jest-electron-test-results-stat__ .stat-item span.black {
36 | color: black;
37 | }
38 |
39 |
40 | /*result list*/
41 |
42 | #__jest-electron-test-results-list__ {
43 | }
44 |
45 | #__jest-electron-test-results-list__ .test-result-suit {
46 | margin-left: 24px;
47 | padding-bottom: 4px;
48 | }
49 |
50 | #__jest-electron-test-results-list__ .test-result-suit .test-result-suit-title {
51 | font-size: 16px;
52 | padding: 4px 0;
53 | color: #444;
54 | }
55 |
56 | #__jest-electron-test-results-list__ .test-result-suit .test-result-suit-results {
57 | margin-left: 16px;
58 | }
59 |
60 | #__jest-electron-test-results-list__ .test-result-block {
61 |
62 | }
63 |
64 | #__jest-electron-test-results-list__ .test-result-block .test-result-info {
65 | display: flex;
66 | flex-direction: row;
67 | align-items: center;
68 | justify-content: flex-start;
69 | }
70 |
71 | #__jest-electron-test-results-list__ .test-result-block .test-result-info .test-result-title {
72 | margin-right: 16px;
73 | }
74 |
75 | #__jest-electron-test-results-list__ .test-result-block .test-result-info .test-result-time {
76 | color: #AAA;
77 | }
78 |
79 | #__jest-electron-test-results-list__ .test-result-block .test-result-info.failed {
80 | cursor: pointer;
81 | }
82 |
83 | #__jest-electron-test-results-list__ .test-result-block .test-result-info.failed::before {
84 | content: '✖';
85 | font-size: 12px;
86 | display: block;
87 | float: left;
88 | margin-right: 5px;
89 | color: #c00;
90 | }
91 |
92 | #__jest-electron-test-results-list__ .test-result-block .test-result-info.passed::before {
93 | content: '✓';
94 | font-size: 12px;
95 | display: block;
96 | float: left;
97 | margin-right: 5px;
98 | color: #00d6b2;
99 | }
100 |
101 | #__jest-electron-test-results-list__ .test-result-block .test-result-info.pending::before {
102 | content: '◔';
103 | font-size: 16px;
104 | display: block;
105 | float: left;
106 | margin-right: 5px;
107 | color: #f8ac30;
108 | }
109 |
110 | /*failure message*/
111 | #__jest-electron-test-results-list__ .test-result-block .test-result-code {
112 | display: none;
113 | }
114 |
115 | #__jest-electron-test-results-list__ .test-result-block .test-result-code.show {
116 | display: block;
117 | }
118 |
119 | #__jest-electron-test-results-list__ .test-result-block .test-result-code pre {
120 | padding: 8px;
121 | border: 1px solid #eee;
122 | word-wrap: break-word;
123 | border-bottom-color: #ddd;
124 | box-shadow: 0 1px 3px #eee;
125 | border-radius: 3px;
126 | overflow-x: auto;
127 | margin-right: 8px;
128 | }
129 |
130 | #__jest-electron-test-results-list__ .test-result-block .test-result-code code {
131 | font-family: monospace!important;
132 | }
133 |
--------------------------------------------------------------------------------
/src/electron/main/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | jest-electron
7 |
8 |
9 |
10 |
11 |
12 |
13 |
passes: 0
14 |
failures: 0
15 |
duration: 0s
16 |
rate: 0%
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/src/electron/main/index.ts:
--------------------------------------------------------------------------------
1 | import { app, BrowserWindow, ipcMain } from 'electron';
2 | import { EventsEnum } from '../../utils/constant';
3 | import { WindowPool } from './window-pool';
4 |
5 | const debugMode = !!process.env.DEBUG_MODE;
6 | const concurrency = Number(process.env.CONCURRENCY);
7 |
8 | // all browser window closed, then kill the while application
9 | app.on('window-all-closed', () => {
10 | app.quit();
11 | });
12 |
13 | app.on('ready', () => {
14 | // create a window pool instance
15 | const windowPool = new WindowPool(concurrency, debugMode);
16 |
17 | // redirect the test cases data, and redirect test result after running in electron
18 | process.on(EventsEnum.ProcMessage, ({ test, id, type }) => {
19 | if (type === EventsEnum.ProcRunTest) {
20 | // send test data into render proc for running
21 | windowPool.runTest(id, test).then(({ result, id }) => {
22 | process.send({ result, id, type: EventsEnum.ProcRunTestResult });
23 | });
24 | } else if (EventsEnum.ProcInitialWin) {
25 | windowPool.clearSaveTests();
26 | process.send({ type: EventsEnum.ProcInitialWinEnd });
27 | } else {
28 | console.error('Invalid message type', type);
29 | }
30 | });
31 |
32 | // electron proc ready
33 | process.send({ type: EventsEnum.ProcReady });
34 | });
35 |
--------------------------------------------------------------------------------
/src/electron/main/window-pool.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'path';
2 | import * as url from 'url';
3 | import throat from 'throat';
4 | import { app, BrowserWindow, ipcMain } from 'electron';
5 | import { EventsEnum } from '../../utils/constant';
6 | import { delay } from '../../utils/delay';
7 | import { uuid } from '../../utils/uuid';
8 | import { Config } from '../../utils/config';
9 |
10 | type Info = {
11 | win: BrowserWindow;
12 | idle: boolean;
13 | tests: any[];
14 | }
15 |
16 | // configure save instance
17 | const config = new Config(app.getPath('userData'));
18 |
19 | /**
20 | * browser window (renderer) pool
21 | */
22 | export class WindowPool {
23 |
24 | private pool: Info[] = [];
25 | private maxSize: number;
26 | private debugMode: boolean;
27 |
28 | // create new browser window instance lock flag
29 | private locked = false;
30 |
31 | constructor(maxSize: number = 1, debugMode: boolean = false) {
32 | // when debug mode, only 1 window can be work
33 | this.maxSize = debugMode ? 1 : maxSize;
34 | this.debugMode = debugMode;
35 |
36 | ipcMain.on(EventsEnum.WebContentsReady, () => {
37 | this.runAllTest();
38 | });
39 | }
40 |
41 | /**
42 | * get a window with thread lock
43 | */
44 | private async get(): Promise {
45 | // if locked, delay and retry
46 | if (this.locked) {
47 | await delay();
48 | return await this.get();
49 | }
50 |
51 | this.locked = true;
52 |
53 | const win = await this.getAsync();
54 |
55 | this.locked = false;
56 |
57 | return win;
58 | }
59 |
60 | /**
61 | * get a window from pool, if not exist, create one, if pool is full, wait and retry
62 | */
63 | private async getAsync(): Promise {
64 | // find a idle window
65 | let info: Info = this.pool.find((info) => info.idle);
66 |
67 | // exist ide window, return it for usage
68 | if (info) return info.win;
69 |
70 | // no idle window
71 | // and the pool is full, delay some time
72 | if (this.isFull()) {
73 | await delay();
74 |
75 | return await this.getAsync();
76 | }
77 |
78 | // pool has space, then create a new window instance
79 | const win = await this.create();
80 |
81 | // put it into pool
82 | this.pool.push({ win, idle: true, tests: [] });
83 |
84 | return win;
85 | }
86 |
87 | /**
88 | * create a valid electron browser window
89 | */
90 | private async create(): Promise {
91 | return new Promise((resolve, reject) => {
92 | const winOpts = {
93 | // read window size from configure file
94 | ...config.read(),
95 | show: this.debugMode,
96 | focusable: this.debugMode,
97 | webPreferences: {
98 | webSecurity: false,
99 | nodeIntegration: true,
100 | contextIsolation: false
101 | },
102 | };
103 |
104 | let win = new BrowserWindow(winOpts);
105 |
106 | // when window close, save window size locally
107 | win.on('close', () => {
108 | const { width, height } = win.getBounds();
109 | config.write({ width, height });
110 | });
111 |
112 | // after window closed, remove it from pool for gc
113 | win.on('closed', () => {
114 | this.removeWin(win);
115 | win = undefined;
116 | });
117 |
118 | const f = url.format({
119 | hash: encodeURIComponent(JSON.stringify({ debugMode: this.debugMode })),
120 | pathname: path.join(__dirname, '/index.html'),
121 | protocol: 'file:',
122 | slashes: true,
123 | });
124 | win.loadURL(f);
125 |
126 | if (this.debugMode) {
127 | // when debug mode, open dev tools
128 | win.webContents.openDevTools();
129 | }
130 |
131 | win.webContents.on('did-finish-load', () => {
132 | // win ready
133 | resolve(win);
134 | });
135 | });
136 | }
137 |
138 | /**
139 | * the proc size of pool
140 | */
141 | public size() {
142 | return this.pool.length;
143 | }
144 |
145 | /**
146 | * whether the pool is full
147 | */
148 | public isFull() {
149 | return this.size() >= this.maxSize;
150 | }
151 |
152 | /**
153 | * set the proc idle status
154 | * @param win
155 | * @param idle
156 | */
157 | private setIdle(win: BrowserWindow, idle: boolean) {
158 | const idx = this.pool.findIndex(info => info.win === win);
159 |
160 | this.pool[idx].idle = idle;
161 | }
162 |
163 | private appendTest(win: BrowserWindow, test: any) {
164 | const idx = this.pool.findIndex(info => info.win === win);
165 |
166 | this.pool[idx].tests.push(test);
167 | }
168 |
169 | /**
170 | * clear all the save tests in memory
171 | */
172 | public clearSaveTests() {
173 | this.pool.forEach(info => {
174 | info.tests = [];
175 | // remove all test result dom
176 | info.win.webContents.send(EventsEnum.ClearTestResults);
177 | });
178 | }
179 |
180 |
181 | private removeWin(win: BrowserWindow) {
182 | const idx = this.pool.findIndex((info) => info.win = win);
183 |
184 | // remove from pool by index
185 | if (idx !== -1) {
186 | this.pool.splice(idx, 1);
187 | }
188 |
189 | win.destroy();
190 | }
191 |
192 | /**
193 | * run test case by send it to renderer
194 | * @param id
195 | * @param test
196 | */
197 | public async runTest(id: string, test: any): Promise {
198 | const win = await this.get();
199 | const result = await this.run(win, id, test);
200 |
201 | this.appendTest(win, test);
202 | return result;
203 | }
204 |
205 | private async runAllTest() {
206 | this.pool.map(async info => {
207 | await Promise.all(info.tests.map(
208 | throat(1, async (test: any) => {
209 | return await this.run(info.win, uuid(), test);
210 | })
211 | ));
212 | });
213 | }
214 |
215 | private async run(win: BrowserWindow, id: string, test: any) {
216 | return new Promise((resolve, reject) => {
217 | this.setIdle(win, false);
218 |
219 | // redirect the test result ti proc
220 | ipcMain.once(id, (event, result) => {
221 | // test case running end, set the window with idle status
222 | this.setIdle(win, true);
223 | // resolve test result
224 | resolve({ result, id });
225 | });
226 |
227 | // send test case into web contents for running
228 | win.webContents.send(EventsEnum.StartRunTest, test, id);
229 | });
230 | }
231 | }
232 |
--------------------------------------------------------------------------------
/src/electron/proc/index.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'path';
2 | import { spawn } from 'child_process';
3 | import * as electron from 'electron';
4 | import { EventsEnum } from '../../utils/constant';
5 | import { uuid } from '../../utils/uuid';
6 | import { delay } from '../../utils/delay';
7 |
8 | /**
9 | * electron proc
10 | */
11 | export class Electron {
12 | public debugMode: boolean;
13 | public concurrency: number;
14 |
15 | private onCloseCallback: Function = () => {};
16 |
17 | private proc: any;
18 |
19 | // thread lock
20 | private lock: boolean = false;
21 |
22 | constructor(debugMode: boolean = false, concurrency: number = 1) {
23 | this.debugMode = debugMode;
24 | this.concurrency = concurrency;
25 | }
26 |
27 | /**
28 | * get a idle electron with lock
29 | */
30 | private async get(): Promise {
31 | if (!this.proc) {
32 |
33 | // lock, then delay and retry
34 | if (this.lock) {
35 | await delay();
36 | return await this.get();
37 | }
38 |
39 | this.lock = true;
40 | this.proc = await this.create();
41 |
42 | // when proc close, kill all electrons
43 | this.proc.on('close', () => {
44 | this.kill();
45 |
46 | this.onCloseCallback();
47 | });
48 |
49 | this.lock = false;
50 | }
51 | return this.proc;
52 | }
53 |
54 | /**
55 | * create an idle electron proc
56 | */
57 | private async create(): Promise {
58 | return new Promise((resolve, reject) => {
59 | // electron starter
60 | const entry = path.join(__dirname, '../main/index');
61 | const args = [ entry ];
62 | if (process.env.JEST_ELECTRON_NO_SANDBOX){
63 | args.splice(0, 0, '--no-sandbox');
64 | };
65 | if (process.env.JEST_ELECTRON_STARTUP_ARGS){
66 | args.splice(0, 0, ...process.env.JEST_ELECTRON_STARTUP_ARGS.split(/\s+/));
67 | };
68 | const proc = spawn(
69 | electron as any,
70 | args,
71 | {
72 | stdio: ['ipc'],
73 | env: {
74 | ...process.env,
75 | DEBUG_MODE: this.debugMode ? 'true' : '',
76 | CONCURRENCY: `${this.concurrency}`,
77 | }
78 | }
79 | );
80 |
81 | const listener = (m) => {
82 | if (m.type === EventsEnum.ProcReady) {
83 | proc.removeListener(EventsEnum.ProcMessage, listener);
84 |
85 | resolve(proc);
86 | }
87 | };
88 |
89 | // send electron ready signal
90 | proc.on(EventsEnum.ProcMessage, listener);
91 | });
92 | }
93 |
94 | /**
95 | * kill all electron proc
96 | */
97 | public kill() {
98 | if (this.proc) {
99 | this.proc.kill();
100 | this.proc = undefined;
101 | }
102 | }
103 |
104 | /**
105 | * run test case
106 | * @param test
107 | */
108 | public runTest(test: any): Promise {
109 | const id = uuid();
110 |
111 | return new Promise((resolve, reject) => {
112 | this.get().then((proc) => {
113 | const listener = ({ result, id: resultId, type }) => {
114 | if (type === EventsEnum.ProcRunTestResult && resultId === id) {
115 | proc.removeListener(EventsEnum.ProcMessage, listener);
116 | // return test result
117 | resolve(result);
118 | }
119 | };
120 |
121 | // listen the running result
122 | proc.on(EventsEnum.ProcMessage, listener);
123 |
124 | // send test data into main thread
125 | proc.send({ type: EventsEnum.ProcRunTest, test, id });
126 | });
127 | });
128 | }
129 |
130 | public initialWin(): Promise {
131 | return new Promise((resolve, reject) => {
132 | this.get().then((proc) => {
133 | const listener = ({ type }) => {
134 | if (type === EventsEnum.ProcInitialWinEnd) {
135 | proc.removeListener(EventsEnum.ProcMessage, listener);
136 | resolve();
137 | }
138 | };
139 |
140 | proc.on(EventsEnum.ProcMessage, listener);
141 |
142 | proc.send({ type: EventsEnum.ProcInitialWin });
143 | });
144 |
145 | })
146 | }
147 |
148 | /**
149 | * when all close, do callback
150 | * @param cb
151 | */
152 | public onClose(cb) {
153 | this.onCloseCallback = cb;
154 | }
155 | }
156 |
157 | export const electronProc: Electron = new Electron();
158 |
--------------------------------------------------------------------------------
/src/electron/renderer/dom.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * show test result with page dom
3 | */
4 | let TS = [];
5 |
6 | /**
7 | * add one test suit result
8 | * @param r
9 | */
10 | export function addResult(r) {
11 | try {
12 | TS.push(r);
13 |
14 | appendTestResultDOM(r);
15 |
16 | updateStatThrottle();
17 | } catch (e) {
18 | console.warn(e);
19 | }
20 | }
21 |
22 | /**
23 | * clear all test result
24 | */
25 | export function clearResult() {
26 | TS = [];
27 |
28 | clearTestResultsDOM();
29 | updateStat();
30 | }
31 |
32 | function throttle(fn, delay = 500) {
33 | let timer = null;
34 |
35 | return function () {
36 | // no timer, run one
37 | if (!timer) {
38 | setTimeout(function() {
39 | clearTimeout(timer);
40 | timer = null;
41 |
42 | fn();
43 | }, delay);
44 | }
45 | }
46 | }
47 |
48 | function getStat() {
49 | return {
50 | pass: TS.reduce((r, curr) => r + curr.numPassingTests, 0),
51 | fail: TS.reduce((r, curr) => r + curr.numFailingTests, 0),
52 | time: TS.reduce((r, curr) => r + (curr.perfStats.end - curr.perfStats.start), 0),
53 | }
54 | }
55 |
56 | function getRatio(pass: number, fail: number): string {
57 | const total = pass + fail;
58 | return total === 0 ? '0%' : (pass / total * 100).toFixed(2) + '%';
59 | }
60 |
61 | function getTime(ms: number): string {
62 | return (ms / 1000).toFixed(1) + 's';
63 | }
64 |
65 | function updateStat() {
66 | // dom object
67 | const $passCount = document.querySelector('#__jest-electron-test-results-stat__ .test-result-pass .stat-indicator');
68 | const $failCount = document.querySelector('#__jest-electron-test-results-stat__ .test-result-fail .stat-indicator');
69 | const $timeCount = document.querySelector('#__jest-electron-test-results-stat__ .test-result-time .stat-indicator');
70 | const $ratioCount = document.querySelector('#__jest-electron-test-results-stat__ .test-result-ratio .stat-indicator');
71 |
72 | const stat = getStat();
73 |
74 | $passCount.innerHTML = `${stat.pass}`;
75 | $failCount.innerHTML = `${stat.fail}`;
76 | $timeCount.innerHTML = `${getTime(stat.time)}`;
77 | $ratioCount.innerHTML = `${getRatio(stat.pass, stat.fail)}`;
78 | }
79 |
80 | const updateStatThrottle = throttle(updateStat);
81 |
82 | function clearTestResultsDOM() {
83 | const $testResults = document.querySelector('#__jest-electron-test-results-list__');
84 | $testResults.innerHTML = '';
85 | }
86 |
87 |
88 | function getTitle(r) {
89 | const tr = r.testResults[0];
90 | return tr ? tr.ancestorTitles[0] : '';
91 | }
92 |
93 | function appendTestResultDOM(r) {
94 | const $testResults = document.querySelector('#__jest-electron-test-results-list__');
95 |
96 | const title = getTitle(r);
97 |
98 | if (!title) return;
99 |
100 | let code = r.failureMessage ? r.failureMessage : '';
101 |
102 | const ts = r.testResults.map((tr) => {
103 | const { title, status, duration, failureMessages } = tr;
104 |
105 | if (!code) {
106 | code = Array.isArray(failureMessages) ? failureMessages[0] : '';
107 | }
108 |
109 | return `
110 |
111 |
${title}
112 |
${duration}ms
113 |
114 |
117 |
`;
118 | });
119 |
120 | const html = `
121 |
${title}
122 |
123 | ${ts.join('')}
124 |
125 |
`;
126 |
127 | $testResults.innerHTML = $testResults.innerHTML + html;
128 | }
129 |
130 | export function bindFailureMessageClickEvent() {
131 | document.addEventListener('click', (e) => {
132 | try {
133 | // @ts-ignore
134 | const node = e.target.parentNode;
135 | if (node.matches('.test-result-info.failed')) {
136 | // failure
137 | const codeClassList = node.parentNode.querySelector('.test-result-code').classList;
138 |
139 | // toggle
140 | codeClassList.contains('show') ? codeClassList.remove('show') : codeClassList.add('show');
141 | }
142 | } catch (e) {
143 | console.warn(e)
144 | }
145 | });
146 | }
147 |
--------------------------------------------------------------------------------
/src/electron/renderer/index.ts:
--------------------------------------------------------------------------------
1 | import { ipcRenderer, remote } from 'electron';
2 | import { EventsEnum } from '../../utils/constant';
3 | import { fail, run } from './uitl';
4 | import { addResult, bindFailureMessageClickEvent, clearResult } from './dom';
5 |
6 | export type Args = {
7 | readonly debugMode?: boolean;
8 | }
9 |
10 | // pass the args by url hash
11 | let args: Args = {};
12 |
13 | try {
14 | args = JSON.parse(decodeURIComponent(window.location.hash.slice(1)));
15 | } catch(e) {}
16 |
17 | const debugMode = args.debugMode;
18 |
19 | if (debugMode) {
20 | console.log(`👏 Jest-Electron is Running...`);
21 | }
22 |
23 | // listen and running test case
24 | ipcRenderer.on(EventsEnum.StartRunTest, async (event, test, id) => {
25 | try {
26 | const result = await run(test);
27 | addResult(result);
28 |
29 | ipcRenderer.send(id, result);
30 | } catch (error) {
31 | ipcRenderer.send(
32 | id,
33 | fail(
34 | test.path,
35 | error,
36 | test.config,
37 | test.globalConfig,
38 | ),
39 | );
40 | console.error(error);
41 | }
42 | });
43 |
44 | ipcRenderer.on(EventsEnum.ClearTestResults, async (event) => {
45 | try {
46 | clearResult();
47 | const tr = document.querySelector('#__jest-electron-test-results__');
48 | document.body.innerHTML = '';
49 | document.body.appendChild(tr);
50 | } catch (e) {
51 | console.warn(e);
52 | }
53 | });
54 |
55 | // web contents ready
56 | bindFailureMessageClickEvent(); // bind event
57 | ipcRenderer.send(EventsEnum.WebContentsReady);
58 |
59 |
--------------------------------------------------------------------------------
/src/electron/renderer/uitl.ts:
--------------------------------------------------------------------------------
1 | // code from https://github.com/facebook/jest/blob/master/packages/jest-runner/src/testWorker.ts
2 | import { ipcRenderer, remote } from 'electron';
3 | import * as Runtime from 'jest-runtime';
4 | import * as HasteMap from 'jest-haste-map';
5 | import * as Resolver from 'jest-resolve';
6 | import { formatExecError, separateMessageFromStack } from 'jest-message-util';
7 | import runTest from 'jest-runner/build/runTest';
8 |
9 | const resolvers = new Map();
10 |
11 | export const getResolver = (config: any, serializableModuleMap: any) => {
12 | if (serializableModuleMap) {
13 | const moduleMap: any = serializableModuleMap ? HasteMap.ModuleMap.fromJSON(serializableModuleMap) : null;
14 |
15 | return Runtime.createResolver(config, moduleMap);
16 | } else {
17 | const name = config.name;
18 | if (!resolvers.has[name]) {
19 | resolvers.set(name, Runtime.createResolver(config, Runtime.createHasteMap(config).readModuleMap()));
20 | }
21 | return resolvers.get(name);
22 | }
23 | };
24 |
25 | export const fail = (testPath: string, err: Error, config: any, globalConfig: any): any => {
26 | const failureMessage = formatExecError(err, config, globalConfig);
27 |
28 | return {
29 | console: null,
30 | failureMessage,
31 | numFailingTests: 1,
32 | numPassingTests: 0,
33 | numPendingTests: 0,
34 | numTodoTests: 0,
35 | perfStats: {
36 | end: new Date(0).getTime(),
37 | start: new Date(0).getTime(),
38 | },
39 | skipped: false,
40 | snapshot: {
41 | added: 0,
42 | fileDeleted: false,
43 | matched: 0,
44 | unchecked: 0,
45 | unmatched: 0,
46 | updated: 0,
47 | uncheckedKeys: [],
48 | },
49 | sourceMaps: {},
50 | testExecError: err,
51 | testFilePath: testPath,
52 | testResults: [],
53 | leaks: false,
54 | openHandles: [],
55 | };
56 | };
57 |
58 | /**
59 | * run test case with runTest method of jest
60 | * @param test
61 | */
62 | export async function run(test) {
63 | return await runTest(
64 | test.path,
65 | test.globalConfig,
66 | test.config,
67 | getResolver(test.config, test.serializableModuleMap),
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/src/environment.ts:
--------------------------------------------------------------------------------
1 | import { FakeTimers, installCommonGlobals } from 'jest-util';
2 | import * as mock from 'jest-mock';
3 |
4 | function isDebugMode() {
5 | return !!process.env.DEBUG_MODE;
6 | }
7 |
8 | // env for electron
9 | // code here https://github.com/facebook-atom/jest-electron-runner/blob/master/packages/electron/src/Environment.js
10 | export default class ElectronEnvironment {
11 | global: any;
12 | moduleMocker: any;
13 | fakeTimers: any;
14 |
15 | electronWindowConsole: any;
16 |
17 | constructor(config: any) {
18 | this.electronWindowConsole = global.console;
19 | this.global = global;
20 |
21 | if (isDebugMode()) {
22 | // defineProperty multi-times will throw
23 | try {
24 | // because of jest will set the console in runTest force, so we should override the console instance of electron
25 | // https://github.com/facebook/jest/blob/6e6a8e827bdf392790ac60eb4d4226af3844cb15/packages/jest-runner/src/runTest.ts#L153
26 | Object.defineProperty(this.global, 'console', {
27 | get: () => {
28 | return this.electronWindowConsole;
29 | },
30 | set: () => {/* do nothing. */},
31 | });
32 |
33 | installCommonGlobals(this.global, config.globals);
34 | } catch (e) {}
35 | }
36 |
37 | this.moduleMocker = new mock.ModuleMocker(global);
38 | this.fakeTimers = {
39 | useFakeTimers() {
40 | throw new Error('fakeTimers are not supported in electron environment');
41 | },
42 | clearAllTimers() {},
43 | };
44 | }
45 |
46 | async setup() {}
47 |
48 | async teardown() {}
49 |
50 | runScript(script: any): any {
51 | return script.runInThisContext();
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export const name = 'jest-electron';
2 |
--------------------------------------------------------------------------------
/src/runner.ts:
--------------------------------------------------------------------------------
1 | import throat from 'throat';
2 | import { electronProc } from './electron/proc';
3 |
4 | const isDebugMode = (): boolean => {
5 | return process.env.DEBUG_MODE === '1';
6 | };
7 |
8 |
9 | /**
10 | * Runner class
11 | */
12 | export default class ElectronRunner {
13 | private _globalConfig: any;
14 | private _debugMode: boolean;
15 |
16 | constructor(globalConfig: any) {
17 | this._globalConfig = globalConfig;
18 | this._debugMode = isDebugMode();
19 | }
20 |
21 | private getConcurrency(testSize): number {
22 | const { maxWorkers, watch, watchAll } = this._globalConfig;
23 | const isWatch = watch || watchAll;
24 |
25 | const concurrency = Math.min(testSize, maxWorkers);
26 |
27 | return isWatch ? Math.ceil(concurrency / 2) : concurrency;
28 | }
29 |
30 | async runTests(
31 | tests: Array,
32 | watcher: any,
33 | onStart: (Test) => void,
34 | onResult: (Test, TestResult) => void,
35 | onFailure: (Test, Error) => void,
36 | ) {
37 | const concurrency = this.getConcurrency(tests.length);
38 |
39 | electronProc.debugMode = this._debugMode;
40 | electronProc.concurrency = concurrency;
41 |
42 | // when the process exit, kill then electron
43 | process.on('exit', () => {
44 | electronProc.kill();
45 | });
46 |
47 | if (this._debugMode) {
48 | electronProc.onClose(() => { process.exit(); });
49 | }
50 |
51 | await electronProc.initialWin();
52 |
53 | await Promise.all(
54 | tests.map(
55 | throat(concurrency, async (test, idx) => {
56 | onStart(test);
57 |
58 | const config = test.context.config;
59 | const globalConfig = this._globalConfig;
60 |
61 | return await electronProc.runTest({
62 | serializableModuleMap: test.context.moduleMap.toJSON(),
63 | config,
64 | globalConfig,
65 | path: test.path,
66 | }).then(testResult => {
67 | testResult.failureMessage != null
68 | ? onFailure(test, testResult.failureMessage)
69 | : onResult(test, testResult);
70 | }).catch(error => {
71 | return onFailure(test, error);
72 | });
73 | }),
74 | ),
75 | );
76 |
77 | // not debug mode, then kill electron after running test cases
78 | if (!this._debugMode) {
79 | electronProc.kill();
80 | }
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/utils/config.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs';
2 | import * as path from 'path';
3 |
4 | const CONFIG_FILE = 'jest-electron.json';
5 |
6 | const DEFAULT_CONFIG = {
7 | height: 800,
8 | width: 1024,
9 | };
10 |
11 | type IConfig = {
12 | readonly width: number;
13 | readonly height: number;
14 | }
15 |
16 | /**
17 | * configure saver class
18 | */
19 | export class Config {
20 |
21 | // save dir
22 | private dir: string;
23 | // save configure
24 | private config: IConfig;
25 |
26 | constructor(dir: string) {
27 | this.dir = dir;
28 | }
29 |
30 | /**
31 | * get the configure save file path
32 | */
33 | private getConfigPath(): string {
34 | return path.resolve(this.dir, CONFIG_FILE);
35 | }
36 |
37 | private readFromFile(): IConfig {
38 | try {
39 | return JSON.parse(fs.readFileSync(this.getConfigPath(), 'utf8'));
40 | } catch (e) {
41 | return DEFAULT_CONFIG;
42 | }
43 | }
44 |
45 | /**
46 | * get the configure of file
47 | */
48 | read(): IConfig {
49 | if (!this.config) {
50 | this.config = this.readFromFile();
51 | }
52 |
53 | return this.config;
54 | }
55 |
56 | /**
57 | * write configure into file
58 | * @param config
59 | * @param flush
60 | */
61 | write(config: IConfig, flush: boolean = false) {
62 | this.config = flush ? config : { ...this.read(), ...config };
63 | try {
64 | fs.writeFileSync(this.getConfigPath(), JSON.stringify(this.config));
65 | } catch (e) {}
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/utils/constant.ts:
--------------------------------------------------------------------------------
1 | export enum EventsEnum {
2 | ProcMessage = 'message',
3 | ProcReady = 'ProcReady',
4 | ProcRunTest = 'ProcRunTest',
5 | ProcRunTestResult = 'ProcRunTestResult',
6 | WebContentsReady = 'WebContentsReady',
7 | StartRunTest = 'StartRunTest',
8 | ClearTestResults = 'ClearTestResults',
9 | ProcInitialWin = 'ProcInitialWin',
10 | ProcInitialWinEnd = 'ProcInitialWinEnd',
11 | }
12 |
--------------------------------------------------------------------------------
/src/utils/delay.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * delay ms use promise
3 | * @param ms
4 | */
5 | export const delay = (ms = 200): Promise => {
6 | return new Promise((resolve, reject) => {
7 | setTimeout(() => {
8 | resolve();
9 | }, ms);
10 | })
11 | };
12 |
--------------------------------------------------------------------------------
/src/utils/uuid.ts:
--------------------------------------------------------------------------------
1 | export const uuid = (): string => {
2 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c: string): string => {
3 | const r = Math.random() * 16 | 0;
4 | const v = c === 'x' ? r : (r & 0x3 | 0x8);
5 | return v.toString(16);
6 | });
7 | };
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "declaration": false,
5 | "sourceMap": false,
6 | "target": "es5",
7 | "outDir": "lib",
8 | "allowSyntheticDefaultImports": true,
9 | "lib": ["esnext", "dom"],
10 | "jsx": "react"
11 | },
12 | "include": ["src"]
13 | }
14 |
--------------------------------------------------------------------------------