├── .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 | [![Build Status](https://github.com/hustcc/jest-electron/workflows/build/badge.svg)](https://github.com/hustcc/jest-electron/actions) 8 | [![npm](https://img.shields.io/npm/v/jest-electron.svg)](https://www.npmjs.com/package/jest-electron) 9 | [![npm](https://img.shields.io/npm/dm/jest-electron.svg)](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 |
115 |
${code}
116 |
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 | --------------------------------------------------------------------------------