├── .eslintrc.js ├── .gitattributes ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── add-to-project.yml │ ├── docs.yml │ ├── release.yml │ ├── semantic.yml │ └── test.yml ├── .gitignore ├── .prettierrc.js ├── .releaserc.json ├── LICENSE ├── README.md ├── api-extractor.json ├── etc └── fiddle-core.api.md ├── jest.config.js ├── package.json ├── src ├── ambient.d.ts ├── command-line.ts ├── fiddle.ts ├── index.ts ├── installer.ts ├── paths.ts ├── runner.ts └── versions.ts ├── tests ├── fiddle.test.ts ├── fixtures │ ├── SHASUMS256.txt │ ├── electron-v12.0.15.zip │ ├── electron-v13.1.7.zip │ ├── fiddles │ │ └── 642fa8daaebea6044c9079e3f8a46390 │ │ │ ├── index.html │ │ │ ├── main.js │ │ │ ├── package.json │ │ │ ├── preload.js │ │ │ ├── renderer.js │ │ │ └── styles.css │ └── releases.json ├── installer.test.ts ├── runner.test.ts └── versions.test.ts ├── tsconfig.eslint.json ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | env: { 3 | node: true 4 | }, 5 | extends: [ 6 | 'eslint:recommended', 7 | 'plugin:@typescript-eslint/recommended', 8 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 9 | 'plugin:prettier/recommended' 10 | ], 11 | ignorePatterns: ['.eslintrc.js', 'jest.config.js', '/coverage', '/dist'], 12 | parser: '@typescript-eslint/parser', 13 | parserOptions: { 14 | ecmaVersion: 'es2018', 15 | lib: ['es2018'], 16 | project: './tsconfig.eslint.json', 17 | sourceType: 'module', 18 | }, 19 | rules: { 20 | // a la carte warnings 21 | '@typescript-eslint/ban-ts-comment': 'off', 22 | '@typescript-eslint/no-non-null-assertion': 'off', 23 | 'no-template-curly-in-string': 'error', 24 | } 25 | } 26 | 27 | module.exports = config; 28 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Source code and markdown files should always use LF as line ending. 2 | *.js text eol=lf 3 | *.json text eol=lf 4 | *.md text eol=lf 5 | *.ts text eol=lf 6 | *.yml text eol=lf 7 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @electron/wg-ecosystem 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | -------------------------------------------------------------------------------- /.github/workflows/add-to-project.yml: -------------------------------------------------------------------------------- 1 | name: Add to Ecosystem WG Project 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | pull_request_target: 8 | types: 9 | - opened 10 | 11 | permissions: {} 12 | 13 | jobs: 14 | add-to-project: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Generate GitHub App token 18 | uses: electron/github-app-auth-action@384fd19694fe7b6dcc9a684746c6976ad78228ae # v1.1.1 19 | id: generate-token 20 | with: 21 | creds: ${{ secrets.ECOSYSTEM_ISSUE_TRIAGE_GH_APP_CREDS }} 22 | org: electron 23 | - name: Add to Project 24 | uses: dsanders11/project-actions/add-item@2134fe7cc71c58b7ae259c82a8e63c6058255678 # v1.7.0 25 | with: 26 | field: Opened 27 | field-value: ${{ github.event.pull_request.created_at || github.event.issue.created_at }} 28 | project-number: 89 29 | token: ${{ steps.generate-token.outputs.token }} 30 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 19 | - name: Setup Node.js 20 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 21 | with: 22 | node-version: 20.x 23 | - name: Check Docs 24 | run: | 25 | yarn 26 | yarn build 27 | yarn docs:ci 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | test: 10 | uses: ./.github/workflows/test.yml 11 | 12 | release: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | needs: test 16 | environment: npm 17 | permissions: 18 | id-token: write # for CFA and npm provenance 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 22 | with: 23 | persist-credentials: false 24 | - name: Setup Node.js 25 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 26 | with: 27 | node-version: 20.x 28 | cache: 'yarn' 29 | - name: Install 30 | run: yarn install --frozen-lockfile 31 | - uses: continuousauth/action@4e8a2573eeb706f6d7300d6a9f3ca6322740b72d # v1.0.5 32 | timeout-minutes: 60 33 | with: 34 | project-id: ${{ secrets.CFA_PROJECT_ID }} 35 | secret: ${{ secrets.CFA_SECRET }} 36 | npm-token: ${{ secrets.NPM_TOKEN }} 37 | -------------------------------------------------------------------------------- /.github/workflows/semantic.yml: -------------------------------------------------------------------------------- 1 | name: "Check Semantic Commit" 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | main: 15 | permissions: 16 | pull-requests: read # for amannn/action-semantic-pull-request to analyze PRs 17 | statuses: write # for amannn/action-semantic-pull-request to mark status of analyzed PR 18 | name: Validate PR Title 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: semantic-pull-request 22 | uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | with: 26 | validateSingleCommit: false 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | schedule: 8 | - cron: '0 22 * * 3' 9 | workflow_call: 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | test: 16 | name: Test 17 | strategy: 18 | matrix: 19 | node-version: 20 | - '20.10' 21 | - '18.18' 22 | - '16.20' 23 | - '14.21' 24 | os: 25 | - macos-latest 26 | - ubuntu-latest 27 | - windows-latest 28 | exclude: 29 | - os: macos-latest 30 | node-version: '14.21' 31 | runs-on: "${{ matrix.os }}" 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 35 | - name: Setup Node.js 36 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 37 | with: 38 | node-version: "${{ matrix.node-version }}" 39 | cache: 'yarn' 40 | - name: Install 41 | run: yarn install --frozen-lockfile 42 | - name: Lint 43 | run: yarn lint 44 | - name: Test 45 | run: yarn test:ci 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | dist/tsdoc-metadata.json 3 | dist/ 4 | node_modules/ 5 | temp/ 6 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: "all", 4 | }; 5 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@semantic-release/commit-analyzer", 4 | "@semantic-release/release-notes-generator", 5 | "@continuous-auth/semantic-release-npm", 6 | "@semantic-release/github" 7 | ], 8 | "branches": ["main"] 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Contributors to the Electron project 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 | # @electron/fiddle-core 2 | 3 | [![Test](https://github.com/electron/fiddle-core/actions/workflows/test.yml/badge.svg)](https://github.com/electron/fiddle-core/actions/workflows/test.yml) 4 | [![NPM](https://img.shields.io/npm/v/@electron/fiddle-core.svg?style=flat)](https://npmjs.org/package/@electron/fiddle-core) 5 | 6 | Run fiddles from anywhere, on any Electron release 7 | 8 | ## CLI 9 | 10 | ```sh 11 | # fiddle-core run ver (gist | repo URL | folder) 12 | # fiddle-core test ver (gist | repo URL | folder) 13 | # fiddle-core bisect ver1 ver2 (gist | repo URL | folder) 14 | # 15 | # Examples: 16 | 17 | $ fiddle-core run 12.0.0 /path/to/fiddle 18 | $ fiddle-core test 12.0.0 642fa8daaebea6044c9079e3f8a46390 19 | $ fiddle-core bisect 8.0.0 13.0.0 https://github.com/my/testcase.git 20 | 21 | 22 | $ fiddle-core bisect 8.0.0 13.0.0 642fa8daaebea6044c9079e3f8a46390 23 | ... 24 | 🏁 finished bisecting across 438 versions... 25 | # 219 🟢 passed 11.0.0-nightly.20200611 (test #1) 26 | # 328 🟢 passed 12.0.0-beta.12 (test #2) 27 | # 342 🟢 passed 12.0.0-beta.29 (test #5) 28 | # 346 🟢 passed 12.0.1 (test #7) 29 | # 347 🔴 failed 12.0.2 (test #9) 30 | # 348 🔴 failed 12.0.3 (test #8) 31 | # 349 🔴 failed 12.0.4 (test #6) 32 | # 356 🔴 failed 12.0.11 (test #4) 33 | # 383 🔴 failed 13.0.0-nightly.20210108 (test #3) 34 | 35 | 🏁 Done bisecting 36 | 🟢 passed 12.0.1 37 | 🔴 failed 12.0.2 38 | Commits between versions: 39 | ↔ https://github.com/electron/electron/compare/v12.0.1...v12.0.2 40 | Done in 28.19s. 41 | ``` 42 | 43 | ## API 44 | 45 | ### Hello, World! 46 | 47 | ```ts 48 | import { Runner } from '@electron/fiddle-core'; 49 | 50 | const runner = await Runner.create(); 51 | const { status } = await runner.run('13.1.7', '/path/to/fiddle'); 52 | console.log(status); 53 | ``` 54 | 55 | ### Running Fiddles 56 | 57 | ```ts 58 | import { Runner } from '@electron/fiddle-core'; 59 | 60 | const runner = await Runner.create(); 61 | 62 | // use a specific Electron version to run code from a local folder 63 | const result = await runner.run('13.1.7', '/path/to/fiddle'); 64 | 65 | // use a specific Electron version to run code from a github gist 66 | const result = await runner.run('14.0.0-beta.17', '642fa8daaebea6044c9079e3f8a46390'); 67 | 68 | // use a specific Electron version to run code from a git repo 69 | const result = await runner.run('15.0.0-alpha.1', 'https://github.com/my/repo.git'); 70 | 71 | // use a specific Electron version to run code from iterable filename/content pairs 72 | const files = new Map([['main.js', '"use strict";']]); 73 | const result = await runner.run('15.0.0-alpha.1', files); 74 | 75 | // bisect a regression test across a range of Electron versions 76 | const result = await runner.bisect('10.0.0', '13.1.7', path_or_gist_or_git_repo); 77 | 78 | // see also `Runner.spawn()` in Advanced Use 79 | ``` 80 | 81 | ### Managing Electron Installations 82 | 83 | ```ts 84 | import { Installer, ProgressObject } from '@electron/fiddle-core'; 85 | 86 | const installer = new Installer(); 87 | installer.on('state-changed', ({version, state}) => { 88 | console.log(`Version "${version}" state changed: "${state}"`); 89 | }); 90 | 91 | // download a version of electron 92 | await installer.ensureDownloaded('12.0.15'); 93 | // expect(installer.state('12.0.5').toBe('downloaded'); 94 | 95 | // download a version with callback 96 | const callback = (progress: ProgressObject) => { 97 | const percent = progress.percent * 100; 98 | console.log(`Current download progress %: ${percent.toFixed(2)}`); 99 | }; 100 | await installer.ensureDownloaded('12.0.15', { 101 | progressCallback: callback, 102 | }); 103 | 104 | // download a version with a specific mirror 105 | const npmMirrors = { 106 | electronMirror: 'https://npmmirror.com/mirrors/electron/', 107 | electronNightlyMirror: 'https://npmmirror.com/mirrors/electron-nightly/', 108 | }, 109 | 110 | await installer.ensureDownloaded('12.0.15', { 111 | mirror: npmMirrors, 112 | }); 113 | 114 | // remove a download 115 | await installer.remove('12.0.15'); 116 | // expect(installer.state('12.0.15').toBe('not-downloaded'); 117 | 118 | // install a specific version for the runner to use 119 | const exec = await installer.install('11.4.10'); 120 | 121 | // Installing with callback and custom mirrors 122 | await installer.install('11.4.10', { 123 | progressCallback: callback, 124 | mirror: npmMirrors, 125 | }); 126 | // expect(installer.state('11.4.10').toBe('installed'); 127 | // expect(fs.accessSync(exec, fs.constants.X_OK)).toBe(true); 128 | ``` 129 | 130 | ### Versions 131 | 132 | ```ts 133 | import { ElectronVersions } from '@electron/fiddle-core'; 134 | 135 | // - querying specific versions 136 | const elves = await ElectronVersions.create(); 137 | // expect(elves.isVersion('12.0.0')).toBe(true); 138 | // expect(elves.isVersion('12.99.99')).toBe(false); 139 | const { versions } = elves; 140 | // expect(versions).find((ver) => ver.version === '12.0.0').not.toBeNull(); 141 | // expect(versions[versions.length - 1]).toStrictEqual(elves.latest); 142 | 143 | // - supported major versions 144 | const { supportedMajors } = elves; 145 | // expect(supportedMajors.length).toBe(4); 146 | 147 | // - querying prerelease branches 148 | const { supportedMajors, prereleaseMajors } = elves; 149 | const newestSupported = Math.max(...supportedMajors); 150 | const oldestPrerelease = Math.min(...prereleaseMajors); 151 | // expect(newestSupported + 1).toBe(oldestPrerelease); 152 | 153 | // - get all releases in a range 154 | let range = releases.inRange('12.0.0', '12.0.15'); 155 | // expect(range.length).toBe(16); 156 | // expect(range.shift().version).toBe('12.0.0'); 157 | // expect(range.pop().version).toBe('12.0.15'); 158 | 159 | // - get all 10-x-y releases 160 | range = releases.inMajor(10); 161 | // expect(range.length).toBe(101); 162 | // expect(range.shift().version).toBe('10.0.0-nightly.20200209'); 163 | // expect(range.pop().version).toBe('10.4.7'); 164 | ``` 165 | 166 | ## Advanced Use 167 | 168 | ### child_process.Spawn 169 | 170 | ```ts 171 | import { Runner } from '@electron/fiddle-core'; 172 | 173 | // third argument is same as node.spawn()'s opts 174 | const child = await runner.spawn('12.0.1', fiddle, nodeSpawnOpts); 175 | 176 | // see also `Runner.run()` and `Runner.bisect()` above 177 | ``` 178 | 179 | ### Using Local Builds 180 | 181 | ```ts 182 | import { Runner } from '@electron/fiddle-core'; 183 | 184 | const runner = await Runner.create(); 185 | const result = await runner.run('/path/to/electron/build', fiddle); 186 | ``` 187 | 188 | ### Using Custom Paths 189 | 190 | ```ts 191 | import { Paths, Runner } from '@electron/fiddle-core'; 192 | 193 | const paths: Paths = { 194 | // where to store zipfiles of downloaded electron versions 195 | electronDownloads: '/tmp/my/electron-downloads', 196 | 197 | // where to install an electron version to be used by the Runner 198 | electronInstall: '/tmp/my/electron-install', 199 | 200 | // where to save temporary copies of fiddles 201 | fiddles: '/tmp/my/fiddles', 202 | 203 | // where to save releases fetched from online 204 | versionsCache: '/tmp/my/releases.json', 205 | }); 206 | 207 | const runner = await Runner.create({ paths }); 208 | ``` 209 | 210 | ### Manually Creating Fiddle Objects 211 | 212 | Runner will do this work for you; but if you want finer-grained control 213 | over the lifecycle of your Fiddle objects, you can instantiate them yourself: 214 | 215 | ```ts 216 | import { FiddleFactory } from '@electron/fiddle-core'; 217 | 218 | const factory = new FiddleFactory(); 219 | 220 | // load a fiddle from a local directory 221 | const fiddle = await factory.from('/path/to/fiddle')); 222 | 223 | // ...or from a gist 224 | const fiddle = await factory.from('642fa8daaebea6044c9079e3f8a46390')); 225 | 226 | // ...or from a git repo 227 | const fiddle = await factory.from('https://github.com/my/testcase.git')); 228 | 229 | // ...or from an iterable of key / value entries 230 | const fiddle = await factory.from([ 231 | ['main.js', '"use strict";'], 232 | ]); 233 | ``` 234 | -------------------------------------------------------------------------------- /api-extractor.json: -------------------------------------------------------------------------------- 1 | /** 2 | * Config file for API Extractor. For more info, please visit: https://api-extractor.com 3 | */ 4 | { 5 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", 6 | 7 | /** 8 | * Optionally specifies another JSON config file that this file extends from. This provides a way for 9 | * standard settings to be shared across multiple projects. 10 | * 11 | * If the path starts with "./" or "../", the path is resolved relative to the folder of the file that contains 12 | * the "extends" field. Otherwise, the first path segment is interpreted as an NPM package name, and will be 13 | * resolved using NodeJS require(). 14 | * 15 | * SUPPORTED TOKENS: none 16 | * DEFAULT VALUE: "" 17 | */ 18 | // "extends": "./shared/api-extractor-base.json" 19 | // "extends": "my-package/include/api-extractor-base.json" 20 | 21 | /** 22 | * Determines the "" token that can be used with other config file settings. The project folder 23 | * typically contains the tsconfig.json and package.json config files, but the path is user-defined. 24 | * 25 | * The path is resolved relative to the folder of the config file that contains the setting. 26 | * 27 | * The default value for "projectFolder" is the token "", which means the folder is determined by traversing 28 | * parent folders, starting from the folder containing api-extractor.json, and stopping at the first folder 29 | * that contains a tsconfig.json file. If a tsconfig.json file cannot be found in this way, then an error 30 | * will be reported. 31 | * 32 | * SUPPORTED TOKENS: 33 | * DEFAULT VALUE: "" 34 | */ 35 | // "projectFolder": "..", 36 | 37 | /** 38 | * (REQUIRED) Specifies the .d.ts file to be used as the starting point for analysis. API Extractor 39 | * analyzes the symbols exported by this module. 40 | * 41 | * The file extension must be ".d.ts" and not ".ts". 42 | * 43 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 44 | * prepend a folder token such as "". 45 | * 46 | * SUPPORTED TOKENS: , , 47 | */ 48 | "mainEntryPointFilePath": "/dist/index.d.ts", 49 | 50 | /** 51 | * A list of NPM package names whose exports should be treated as part of this package. 52 | * 53 | * For example, suppose that Webpack is used to generate a distributed bundle for the project "library1", 54 | * and another NPM package "library2" is embedded in this bundle. Some types from library2 may become part 55 | * of the exported API for library1, but by default API Extractor would generate a .d.ts rollup that explicitly 56 | * imports library2. To avoid this, we can specify: 57 | * 58 | * "bundledPackages": [ "library2" ], 59 | * 60 | * This would direct API Extractor to embed those types directly in the .d.ts rollup, as if they had been 61 | * local files for library1. 62 | */ 63 | "bundledPackages": [], 64 | 65 | /** 66 | * Determines how the TypeScript compiler engine will be invoked by API Extractor. 67 | */ 68 | "compiler": { 69 | /** 70 | * Specifies the path to the tsconfig.json file to be used by API Extractor when analyzing the project. 71 | * 72 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 73 | * prepend a folder token such as "". 74 | * 75 | * Note: This setting will be ignored if "overrideTsconfig" is used. 76 | * 77 | * SUPPORTED TOKENS: , , 78 | * DEFAULT VALUE: "/tsconfig.json" 79 | */ 80 | // "tsconfigFilePath": "/tsconfig.json", 81 | /** 82 | * Provides a compiler configuration that will be used instead of reading the tsconfig.json file from disk. 83 | * The object must conform to the TypeScript tsconfig schema: 84 | * 85 | * http://json.schemastore.org/tsconfig 86 | * 87 | * If omitted, then the tsconfig.json file will be read from the "projectFolder". 88 | * 89 | * DEFAULT VALUE: no overrideTsconfig section 90 | */ 91 | // "overrideTsconfig": { 92 | // . . . 93 | // } 94 | /** 95 | * This option causes the compiler to be invoked with the --skipLibCheck option. This option is not recommended 96 | * and may cause API Extractor to produce incomplete or incorrect declarations, but it may be required when 97 | * dependencies contain declarations that are incompatible with the TypeScript engine that API Extractor uses 98 | * for its analysis. Where possible, the underlying issue should be fixed rather than relying on skipLibCheck. 99 | * 100 | * DEFAULT VALUE: false 101 | */ 102 | // "skipLibCheck": true, 103 | }, 104 | 105 | /** 106 | * Configures how the API report file (*.api.md) will be generated. 107 | */ 108 | "apiReport": { 109 | /** 110 | * (REQUIRED) Whether to generate an API report. 111 | */ 112 | "enabled": true 113 | 114 | /** 115 | * The filename for the API report files. It will be combined with "reportFolder" or "reportTempFolder" to produce 116 | * a full file path. 117 | * 118 | * The file extension should be ".api.md", and the string should not contain a path separator such as "\" or "/". 119 | * 120 | * SUPPORTED TOKENS: , 121 | * DEFAULT VALUE: ".api.md" 122 | */ 123 | // "reportFileName": ".api.md", 124 | 125 | /** 126 | * Specifies the folder where the API report file is written. The file name portion is determined by 127 | * the "reportFileName" setting. 128 | * 129 | * The API report file is normally tracked by Git. Changes to it can be used to trigger a branch policy, 130 | * e.g. for an API review. 131 | * 132 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 133 | * prepend a folder token such as "". 134 | * 135 | * SUPPORTED TOKENS: , , 136 | * DEFAULT VALUE: "/etc/" 137 | */ 138 | // "reportFolder": "/etc/", 139 | 140 | /** 141 | * Specifies the folder where the temporary report file is written. The file name portion is determined by 142 | * the "reportFileName" setting. 143 | * 144 | * After the temporary file is written to disk, it is compared with the file in the "reportFolder". 145 | * If they are different, a production build will fail. 146 | * 147 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 148 | * prepend a folder token such as "". 149 | * 150 | * SUPPORTED TOKENS: , , 151 | * DEFAULT VALUE: "/temp/" 152 | */ 153 | // "reportTempFolder": "/temp/" 154 | }, 155 | 156 | /** 157 | * Configures how the doc model file (*.api.json) will be generated. 158 | */ 159 | "docModel": { 160 | /** 161 | * (REQUIRED) Whether to generate a doc model file. 162 | */ 163 | "enabled": true 164 | 165 | /** 166 | * The output path for the doc model file. The file extension should be ".api.json". 167 | * 168 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 169 | * prepend a folder token such as "". 170 | * 171 | * SUPPORTED TOKENS: , , 172 | * DEFAULT VALUE: "/temp/.api.json" 173 | */ 174 | // "apiJsonFilePath": "/temp/.api.json" 175 | }, 176 | 177 | /** 178 | * Configures how the .d.ts rollup file will be generated. 179 | */ 180 | "dtsRollup": { 181 | /** 182 | * (REQUIRED) Whether to generate the .d.ts rollup file. 183 | */ 184 | "enabled": true 185 | 186 | /** 187 | * Specifies the output path for a .d.ts rollup file to be generated without any trimming. 188 | * This file will include all declarations that are exported by the main entry point. 189 | * 190 | * If the path is an empty string, then this file will not be written. 191 | * 192 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 193 | * prepend a folder token such as "". 194 | * 195 | * SUPPORTED TOKENS: , , 196 | * DEFAULT VALUE: "/dist/.d.ts" 197 | */ 198 | // "untrimmedFilePath": "/dist/.d.ts", 199 | 200 | /** 201 | * Specifies the output path for a .d.ts rollup file to be generated with trimming for a "beta" release. 202 | * This file will include only declarations that are marked as "@public" or "@beta". 203 | * 204 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 205 | * prepend a folder token such as "". 206 | * 207 | * SUPPORTED TOKENS: , , 208 | * DEFAULT VALUE: "" 209 | */ 210 | // "betaTrimmedFilePath": "/dist/-beta.d.ts", 211 | 212 | /** 213 | * Specifies the output path for a .d.ts rollup file to be generated with trimming for a "public" release. 214 | * This file will include only declarations that are marked as "@public". 215 | * 216 | * If the path is an empty string, then this file will not be written. 217 | * 218 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 219 | * prepend a folder token such as "". 220 | * 221 | * SUPPORTED TOKENS: , , 222 | * DEFAULT VALUE: "" 223 | */ 224 | // "publicTrimmedFilePath": "/dist/-public.d.ts", 225 | 226 | /** 227 | * When a declaration is trimmed, by default it will be replaced by a code comment such as 228 | * "Excluded from this release type: exampleMember". Set "omitTrimmingComments" to true to remove the 229 | * declaration completely. 230 | * 231 | * DEFAULT VALUE: false 232 | */ 233 | // "omitTrimmingComments": true 234 | }, 235 | 236 | /** 237 | * Configures how the tsdoc-metadata.json file will be generated. 238 | */ 239 | "tsdocMetadata": { 240 | /** 241 | * Whether to generate the tsdoc-metadata.json file. 242 | * 243 | * DEFAULT VALUE: true 244 | */ 245 | // "enabled": true, 246 | /** 247 | * Specifies where the TSDoc metadata file should be written. 248 | * 249 | * The path is resolved relative to the folder of the config file that contains the setting; to change this, 250 | * prepend a folder token such as "". 251 | * 252 | * The default value is "", which causes the path to be automatically inferred from the "tsdocMetadata", 253 | * "typings" or "main" fields of the project's package.json. If none of these fields are set, the lookup 254 | * falls back to "tsdoc-metadata.json" in the package folder. 255 | * 256 | * SUPPORTED TOKENS: , , 257 | * DEFAULT VALUE: "" 258 | */ 259 | // "tsdocMetadataFilePath": "/dist/tsdoc-metadata.json" 260 | }, 261 | 262 | /** 263 | * Specifies what type of newlines API Extractor should use when writing output files. By default, the output files 264 | * will be written with Windows-style newlines. To use POSIX-style newlines, specify "lf" instead. 265 | * To use the OS's default newline kind, specify "os". 266 | * 267 | * DEFAULT VALUE: "crlf" 268 | */ 269 | // "newlineKind": "crlf", 270 | 271 | /** 272 | * Configures how API Extractor reports error and warning messages produced during analysis. 273 | * 274 | * There are three sources of messages: compiler messages, API Extractor messages, and TSDoc messages. 275 | */ 276 | "messages": { 277 | /** 278 | * Configures handling of diagnostic messages reported by the TypeScript compiler engine while analyzing 279 | * the input .d.ts files. 280 | * 281 | * TypeScript message identifiers start with "TS" followed by an integer. For example: "TS2551" 282 | * 283 | * DEFAULT VALUE: A single "default" entry with logLevel=warning. 284 | */ 285 | "compilerMessageReporting": { 286 | /** 287 | * Configures the default routing for messages that don't match an explicit rule in this table. 288 | */ 289 | "default": { 290 | /** 291 | * Specifies whether the message should be written to the the tool's output log. Note that 292 | * the "addToApiReportFile" property may supersede this option. 293 | * 294 | * Possible values: "error", "warning", "none" 295 | * 296 | * Errors cause the build to fail and return a nonzero exit code. Warnings cause a production build fail 297 | * and return a nonzero exit code. For a non-production build (e.g. when "api-extractor run" includes 298 | * the "--local" option), the warning is displayed but the build will not fail. 299 | * 300 | * DEFAULT VALUE: "warning" 301 | */ 302 | "logLevel": "warning" 303 | 304 | /** 305 | * When addToApiReportFile is true: If API Extractor is configured to write an API report file (.api.md), 306 | * then the message will be written inside that file; otherwise, the message is instead logged according to 307 | * the "logLevel" option. 308 | * 309 | * DEFAULT VALUE: false 310 | */ 311 | // "addToApiReportFile": false 312 | } 313 | 314 | // "TS2551": { 315 | // "logLevel": "warning", 316 | // "addToApiReportFile": true 317 | // }, 318 | // 319 | // . . . 320 | }, 321 | 322 | /** 323 | * Configures handling of messages reported by API Extractor during its analysis. 324 | * 325 | * API Extractor message identifiers start with "ae-". For example: "ae-extra-release-tag" 326 | * 327 | * DEFAULT VALUE: See api-extractor-defaults.json for the complete table of extractorMessageReporting mappings 328 | */ 329 | "extractorMessageReporting": { 330 | "default": { 331 | "logLevel": "warning" 332 | // "addToApiReportFile": false 333 | }, 334 | 335 | "ae-missing-release-tag": { 336 | "logLevel": "none", 337 | "addToApiReportFile": false 338 | } 339 | 340 | // "ae-extra-release-tag": { 341 | // "logLevel": "warning", 342 | // "addToApiReportFile": true 343 | // }, 344 | // 345 | // . . . 346 | }, 347 | 348 | /** 349 | * Configures handling of messages reported by the TSDoc parser when analyzing code comments. 350 | * 351 | * TSDoc message identifiers start with "tsdoc-". For example: "tsdoc-link-tag-unescaped-text" 352 | * 353 | * DEFAULT VALUE: A single "default" entry with logLevel=warning. 354 | */ 355 | "tsdocMessageReporting": { 356 | "default": { 357 | "logLevel": "warning" 358 | // "addToApiReportFile": false 359 | } 360 | 361 | // "tsdoc-link-tag-unescaped-text": { 362 | // "logLevel": "warning", 363 | // "addToApiReportFile": true 364 | // }, 365 | // 366 | // . . . 367 | } 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /etc/fiddle-core.api.md: -------------------------------------------------------------------------------- 1 | ## API Report File for "@electron/fiddle-core" 2 | 3 | > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). 4 | 5 | ```ts 6 | 7 | import { ChildProcess } from 'child_process'; 8 | import { EventEmitter } from 'events'; 9 | import { SemVer } from 'semver'; 10 | import { SpawnOptions } from 'child_process'; 11 | import { Writable } from 'stream'; 12 | 13 | // @public 14 | export class BaseVersions implements Versions { 15 | constructor(versions: unknown); 16 | // (undocumented) 17 | getReleaseInfo(ver: SemOrStr): ReleaseInfo | undefined; 18 | // (undocumented) 19 | inMajor(major: number): SemVer[]; 20 | // (undocumented) 21 | inRange(a: SemOrStr, b: SemOrStr): SemVer[]; 22 | // (undocumented) 23 | isVersion(ver: SemOrStr): boolean; 24 | // (undocumented) 25 | get latest(): SemVer | undefined; 26 | // (undocumented) 27 | get latestStable(): SemVer | undefined; 28 | // (undocumented) 29 | get obsoleteMajors(): number[]; 30 | // (undocumented) 31 | get prereleaseMajors(): number[]; 32 | // (undocumented) 33 | protected setVersions(val: unknown): void; 34 | // (undocumented) 35 | get stableMajors(): number[]; 36 | // (undocumented) 37 | get supportedMajors(): number[]; 38 | // (undocumented) 39 | get versions(): SemVer[]; 40 | } 41 | 42 | // @public (undocumented) 43 | export interface BisectResult { 44 | // (undocumented) 45 | range?: [string, string]; 46 | // (undocumented) 47 | status: 'bisect_succeeded' | 'test_error' | 'system_error'; 48 | } 49 | 50 | // @public (undocumented) 51 | export function compareVersions(a: SemVer, b: SemVer): number; 52 | 53 | // @public (undocumented) 54 | export const DefaultPaths: Paths; 55 | 56 | // @public (undocumented) 57 | export interface ElectronBinary { 58 | // (undocumented) 59 | alreadyExtracted: boolean; 60 | // (undocumented) 61 | path: string; 62 | } 63 | 64 | // @public 65 | export class ElectronVersions extends BaseVersions { 66 | // (undocumented) 67 | static create(paths?: Partial, options?: ElectronVersionsCreateOptions): Promise; 68 | // (undocumented) 69 | fetch(): Promise; 70 | // (undocumented) 71 | inMajor(major: number): SemVer[]; 72 | // (undocumented) 73 | inRange(a: SemOrStr, b: SemOrStr): SemVer[]; 74 | // (undocumented) 75 | isVersion(ver: SemOrStr): boolean; 76 | // (undocumented) 77 | get latest(): SemVer | undefined; 78 | // (undocumented) 79 | get latestStable(): SemVer | undefined; 80 | // (undocumented) 81 | get obsoleteMajors(): number[]; 82 | // (undocumented) 83 | get prereleaseMajors(): number[]; 84 | // (undocumented) 85 | get stableMajors(): number[]; 86 | // (undocumented) 87 | get supportedMajors(): number[]; 88 | // (undocumented) 89 | get versions(): SemVer[]; 90 | } 91 | 92 | // @public (undocumented) 93 | export interface ElectronVersionsCreateOptions { 94 | ignoreCache?: boolean; 95 | initialVersions?: unknown; 96 | } 97 | 98 | // @public (undocumented) 99 | export class Fiddle { 100 | constructor(mainPath: string, // /path/to/main.js 101 | source: string); 102 | // (undocumented) 103 | readonly mainPath: string; 104 | // (undocumented) 105 | remove(): Promise; 106 | // (undocumented) 107 | readonly source: string; 108 | } 109 | 110 | // @public (undocumented) 111 | export class FiddleFactory { 112 | constructor(fiddles?: string); 113 | // (undocumented) 114 | create(src: FiddleSource, options?: FiddleFactoryCreateOptions): Promise; 115 | // (undocumented) 116 | fromEntries(src: Iterable<[string, string]>): Promise; 117 | // (undocumented) 118 | fromFolder(source: string): Promise; 119 | // (undocumented) 120 | fromGist(gistId: string): Promise; 121 | // (undocumented) 122 | fromRepo(url: string, checkout?: string): Promise; 123 | } 124 | 125 | // @public (undocumented) 126 | export interface FiddleFactoryCreateOptions { 127 | // (undocumented) 128 | packAsAsar?: boolean; 129 | } 130 | 131 | // @public 132 | export type FiddleSource = Fiddle | string | Iterable<[string, string]>; 133 | 134 | // @public 135 | export class Installer extends EventEmitter { 136 | constructor(pathsIn?: Partial); 137 | // (undocumented) 138 | ensureDownloaded(version: string, opts?: Partial): Promise; 139 | // (undocumented) 140 | static execSubpath(platform?: string): string; 141 | // (undocumented) 142 | static getExecPath(folder: string): string; 143 | // (undocumented) 144 | install(version: string, opts?: Partial): Promise; 145 | get installedVersion(): string | undefined; 146 | remove(version: string): Promise; 147 | // (undocumented) 148 | state(version: string): InstallState; 149 | } 150 | 151 | // @public (undocumented) 152 | export interface InstallerParams { 153 | // (undocumented) 154 | mirror: Mirrors; 155 | // (undocumented) 156 | progressCallback: (progress: ProgressObject) => void; 157 | } 158 | 159 | // @public 160 | export enum InstallState { 161 | // (undocumented) 162 | downloaded = "downloaded", 163 | // (undocumented) 164 | downloading = "downloading", 165 | // (undocumented) 166 | installed = "installed", 167 | // (undocumented) 168 | installing = "installing", 169 | // (undocumented) 170 | missing = "missing" 171 | } 172 | 173 | // @public (undocumented) 174 | export interface InstallStateEvent { 175 | // (undocumented) 176 | state: InstallState; 177 | // (undocumented) 178 | version: string; 179 | } 180 | 181 | // @public (undocumented) 182 | export interface Mirrors { 183 | // (undocumented) 184 | electronMirror: string; 185 | // (undocumented) 186 | electronNightlyMirror: string; 187 | } 188 | 189 | // @public (undocumented) 190 | export interface Paths { 191 | // (undocumented) 192 | readonly electronDownloads: string; 193 | // (undocumented) 194 | readonly electronInstall: string; 195 | // (undocumented) 196 | readonly fiddles: string; 197 | // (undocumented) 198 | readonly versionsCache: string; 199 | } 200 | 201 | // @public (undocumented) 202 | export type ProgressObject = { 203 | percent: number; 204 | }; 205 | 206 | // @public (undocumented) 207 | export interface ReleaseInfo { 208 | chrome: string; 209 | date: string; 210 | files: Array; 211 | modules: string; 212 | node: string; 213 | openssl: string; 214 | uv: string; 215 | v8: string; 216 | version: string; 217 | zlib: string; 218 | } 219 | 220 | // @public (undocumented) 221 | export function runFromCommandLine(argv: string[]): Promise; 222 | 223 | // @public (undocumented) 224 | export class Runner { 225 | // (undocumented) 226 | bisect(version_a: string | SemVer, version_b: string | SemVer, fiddleIn: FiddleSource, opts?: RunnerSpawnOptions): Promise; 227 | // (undocumented) 228 | static create(opts?: { 229 | installer?: Installer; 230 | fiddleFactory?: FiddleFactory; 231 | paths?: Partial; 232 | versions?: Versions; 233 | }): Promise; 234 | // (undocumented) 235 | static displayResult(result: TestResult): string; 236 | // (undocumented) 237 | run(version: string | SemVer, fiddle: FiddleSource, opts?: RunnerSpawnOptions): Promise; 238 | // (undocumented) 239 | spawn(versionIn: string | SemVer, fiddleIn: FiddleSource, opts?: RunnerSpawnOptions): Promise; 240 | } 241 | 242 | // @public (undocumented) 243 | export interface RunnerOptions { 244 | // (undocumented) 245 | args?: string[]; 246 | // (undocumented) 247 | headless?: boolean; 248 | // (undocumented) 249 | out?: Writable; 250 | // (undocumented) 251 | runFromAsar?: boolean; 252 | // (undocumented) 253 | showConfig?: boolean; 254 | } 255 | 256 | // @public (undocumented) 257 | export type RunnerSpawnOptions = SpawnOptions & RunnerOptions; 258 | 259 | // @public (undocumented) 260 | export type SemOrStr = SemVer | string; 261 | 262 | export { SemVer } 263 | 264 | // @public (undocumented) 265 | export interface TestResult { 266 | // (undocumented) 267 | status: 'test_passed' | 'test_failed' | 'test_error' | 'system_error'; 268 | } 269 | 270 | // @public 271 | export interface Versions { 272 | // (undocumented) 273 | getReleaseInfo(version: SemOrStr): ReleaseInfo | undefined; 274 | // (undocumented) 275 | inMajor(major: number): SemVer[]; 276 | // (undocumented) 277 | inRange(a: SemOrStr, b: SemOrStr): SemVer[]; 278 | // (undocumented) 279 | isVersion(version: SemOrStr): boolean; 280 | readonly latest: SemVer | undefined; 281 | readonly latestStable: SemVer | undefined; 282 | readonly obsoleteMajors: number[]; 283 | readonly prereleaseMajors: number[]; 284 | readonly supportedMajors: number[]; 285 | readonly versions: SemVer[]; 286 | } 287 | 288 | // (No @packageDocumentation comment for this package) 289 | 290 | ``` 291 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | process.env.SPEC_RUNNING = '1'; 2 | 3 | module.exports = { 4 | roots: [ 5 | '/tests' 6 | ], 7 | transform: { 8 | '^.+\\.tsx?$': 'ts-jest' 9 | }, 10 | clearMocks: true, 11 | testRegex: '(/spec/.*|(\\.|/)(test|spec))\\.tsx?$', 12 | moduleFileExtensions: [ 13 | 'ts', 14 | 'tsx', 15 | 'js', 16 | 'jsx', 17 | 'json', 18 | 'node' 19 | ], 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": "https://github.com/electron/fiddle-core", 3 | "homepage": "https://github.com/electron/fiddle-core#readme", 4 | "author": "Charles Kerr ", 5 | "license": "MIT", 6 | "name": "@electron/fiddle-core", 7 | "version": "0.0.0-development", 8 | "description": "Run fiddles from anywhere, on any Electron release", 9 | "main": "dist/index.js", 10 | "bin": { 11 | "fiddle-core": "dist/index.js" 12 | }, 13 | "files": [ 14 | "dist", 15 | "README.md" 16 | ], 17 | "publishConfig": { 18 | "provenance": true 19 | }, 20 | "scripts": { 21 | "build": "tsc -b", 22 | "docs": "api-extractor run --local", 23 | "docs:ci": "api-extractor run", 24 | "lint": "run-p lint:eslint lint:prettier", 25 | "lint:fix": "run-p lint:eslint:fix lint:prettier:fix", 26 | "lint:eslint": "eslint \"./src/**/*.ts\" \"./tests/**/*.ts\"", 27 | "lint:eslint:fix": "eslint --fix \"./src/**/*.ts\" \"./tests/**/*.ts\"", 28 | "lint:prettier": "prettier --check package.json src/**/*.ts tests/**/*.ts", 29 | "lint:prettier:fix": "prettier --write package.json src/**/*.ts tests/**/*.ts", 30 | "make": "run-p build", 31 | "prepublishOnly": "npm run make", 32 | "start": "node dist/index.js", 33 | "test": "jest", 34 | "test:ci": "jest --runInBand --coverage" 35 | }, 36 | "dependencies": { 37 | "@electron/asar": "^3.3.1", 38 | "@electron/get": "^2.0.0", 39 | "debug": "^4.3.3", 40 | "env-paths": "^2.2.1", 41 | "extract-zip": "^2.0.1", 42 | "fs-extra": "^10.0.0", 43 | "getos": "^3.2.1", 44 | "node-fetch": "^2.6.1", 45 | "rimraf": "^4.4.1", 46 | "semver": "^7.3.5", 47 | "simple-git": "^3.5.0" 48 | }, 49 | "devDependencies": { 50 | "@microsoft/api-extractor": "^7.38.3", 51 | "@types/debug": "^4.1.12", 52 | "@types/fs-extra": "^9.0.13", 53 | "@types/getos": "^3.0.4", 54 | "@types/jest": "^29.5.10", 55 | "@types/node": "^14.14.31", 56 | "@types/node-fetch": "^2.6.9", 57 | "@types/semver": "^7.5.6", 58 | "@typescript-eslint/eslint-plugin": "^5.56.0", 59 | "@typescript-eslint/parser": "^5.56.0", 60 | "eslint": "^8.36.0", 61 | "eslint-config-prettier": "^8.8.0", 62 | "eslint-plugin-prettier": "^5.2.1", 63 | "jest": "^29.7.0", 64 | "nock": "^13.3.8", 65 | "npm-run-all": "^4.1.5", 66 | "prettier": "^3.4.2", 67 | "ts-jest": "^29.1.1", 68 | "typescript": "^5.6.3" 69 | }, 70 | "engines": { 71 | "node": ">=14" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/ambient.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface Process { 3 | noAsar?: boolean; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/command-line.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from 'util'; 2 | import debug from 'debug'; 3 | 4 | import { ElectronVersions } from './versions'; 5 | import { Fiddle, FiddleFactory } from './fiddle'; 6 | import { Runner } from './runner'; 7 | 8 | export async function runFromCommandLine(argv: string[]): Promise { 9 | const d = debug('fiddle-core:runFromCommandLine'); 10 | 11 | d(inspect({ argv })); 12 | const versions = await ElectronVersions.create(); 13 | const fiddleFactory = new FiddleFactory(); 14 | const runner = await Runner.create({ versions, fiddleFactory }); 15 | const versionArgs: string[] = []; 16 | 17 | type Cmd = 'bisect' | 'test' | undefined; 18 | let cmd: Cmd = undefined; 19 | let fiddle: Fiddle | undefined = undefined; 20 | 21 | d('argv', inspect(argv)); 22 | for (const param of argv) { 23 | d('param', param); 24 | if (param === 'bisect') { 25 | cmd = 'bisect'; 26 | } else if (param === 'test' || param === 'start' || param === 'run') { 27 | d('it is test'); 28 | cmd = 'test'; 29 | } else if (versions.isVersion(param)) { 30 | versionArgs.push(param); 31 | } else { 32 | fiddle = await fiddleFactory.create(param); 33 | if (fiddle) continue; 34 | console.error( 35 | `Unrecognized parameter "${param}". Must be 'test', 'start', 'bisect', a version, a gist, a folder, or a repo URL.`, 36 | ); 37 | process.exit(1); 38 | } 39 | } 40 | 41 | d(inspect({ cmd, fiddle, versions })); 42 | 43 | if (!cmd) { 44 | console.error( 45 | "Command-line parameters must include one of ['bisect', 'test', 'start']", 46 | ); 47 | process.exit(1); 48 | } 49 | 50 | if (!fiddle) { 51 | console.error('No fiddle specified.'); 52 | process.exit(1); 53 | } 54 | 55 | if (cmd === 'test' && versionArgs.length === 1) { 56 | const result = await runner.run(versionArgs[0], fiddle, { 57 | out: process.stdout, 58 | }); 59 | const vals = ['test_passed', 'test_failed', 'test_error', 'system_error']; 60 | process.exitCode = vals.indexOf(result.status); 61 | return; 62 | } 63 | 64 | if (cmd === 'bisect' && versionArgs.length === 2) { 65 | const result = await runner.bisect(versionArgs[0], versionArgs[1], fiddle, { 66 | out: process.stdout, 67 | }); 68 | const vals = ['bisect_succeeded', 'test_error', 'system_error']; 69 | process.exitCode = vals.indexOf(result.status); 70 | return; 71 | } 72 | 73 | console.error(`Invalid parameters. Got ${cmd}, ${versionArgs.join(', ')}`); 74 | process.exit(1); 75 | } 76 | -------------------------------------------------------------------------------- /src/fiddle.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra'; 2 | import * as path from 'path'; 3 | import * as asar from '@electron/asar'; 4 | import debug from 'debug'; 5 | import simpleGit from 'simple-git'; 6 | import { createHash } from 'crypto'; 7 | 8 | import { DefaultPaths } from './paths'; 9 | 10 | function hashString(str: string): string { 11 | const md5sum = createHash('md5'); 12 | md5sum.update(str); 13 | return md5sum.digest('hex'); 14 | } 15 | 16 | export class Fiddle { 17 | constructor( 18 | public readonly mainPath: string, // /path/to/main.js 19 | public readonly source: string, 20 | ) {} 21 | 22 | public remove(): Promise { 23 | return fs.remove(path.dirname(this.mainPath)); 24 | } 25 | } 26 | 27 | /** 28 | * - Iterable of [string, string] - filename-to-content key/value pairs 29 | * - string of form '/path/to/fiddle' - a fiddle on the filesystem 30 | * - string of form 'https://github.com/my/repo.git' - a git repo fiddle 31 | * - string of form '642fa8daaebea6044c9079e3f8a46390' - a github gist fiddle 32 | */ 33 | export type FiddleSource = Fiddle | string | Iterable<[string, string]>; 34 | 35 | export interface FiddleFactoryCreateOptions { 36 | packAsAsar?: boolean; 37 | } 38 | 39 | export class FiddleFactory { 40 | constructor(private readonly fiddles: string = DefaultPaths.fiddles) {} 41 | 42 | public async fromGist(gistId: string): Promise { 43 | return this.fromRepo(`https://gist.github.com/${gistId}.git`); 44 | } 45 | 46 | public async fromFolder(source: string): Promise { 47 | const d = debug('fiddle-core:FiddleFactory:fromFolder'); 48 | 49 | // make a tmp copy of this fiddle 50 | const folder = path.join(this.fiddles, hashString(source)); 51 | d({ source, folder }); 52 | await fs.remove(folder); 53 | 54 | // Disable asar in case any deps bundle Electron - ex. @electron/remote 55 | const { noAsar } = process; 56 | process.noAsar = true; 57 | await fs.copy(source, folder); 58 | process.noAsar = noAsar; 59 | 60 | return new Fiddle(path.join(folder, 'main.js'), source); 61 | } 62 | 63 | public async fromRepo(url: string, checkout = 'master'): Promise { 64 | const d = debug('fiddle-core:FiddleFactory:fromRepo'); 65 | const folder = path.join(this.fiddles, hashString(url)); 66 | d({ url, checkout, folder }); 67 | 68 | // get the repo 69 | if (!fs.existsSync(folder)) { 70 | d(`cloning "${url}" into "${folder}"`); 71 | const git = simpleGit(); 72 | await git.clone(url, folder, { '--depth': 1 }); 73 | } 74 | 75 | const git = simpleGit(folder); 76 | await git.checkout(checkout); 77 | await git.pull('origin', checkout); 78 | 79 | return new Fiddle(path.join(folder, 'main.js'), url); 80 | } 81 | 82 | public async fromEntries(src: Iterable<[string, string]>): Promise { 83 | const d = debug('fiddle-core:FiddleFactory:fromEntries'); 84 | const map = new Map(src); 85 | 86 | // make a name for the directory that will hold our temp copy of the fiddle 87 | const md5sum = createHash('md5'); 88 | for (const content of map.values()) md5sum.update(content); 89 | const hash = md5sum.digest('hex'); 90 | const folder = path.join(this.fiddles, hash); 91 | d({ folder }); 92 | 93 | // save content to that temp directory 94 | await Promise.all( 95 | [...map.entries()].map(([filename, content]) => 96 | fs.outputFile(path.join(folder, filename), content, 'utf8'), 97 | ), 98 | ); 99 | 100 | return new Fiddle(path.join(folder, 'main.js'), 'entries'); 101 | } 102 | 103 | public async create( 104 | src: FiddleSource, 105 | options?: FiddleFactoryCreateOptions, 106 | ): Promise { 107 | let fiddle: Fiddle; 108 | if (src instanceof Fiddle) { 109 | fiddle = src; 110 | } else if (typeof src === 'string') { 111 | if (fs.existsSync(src)) { 112 | fiddle = await this.fromFolder(src); 113 | } else if (/^[0-9A-Fa-f]{32}$/.test(src)) { 114 | fiddle = await this.fromGist(src); 115 | } else if (/^https:/.test(src) || /\.git$/.test(src)) { 116 | fiddle = await this.fromRepo(src); 117 | } else { 118 | return; 119 | } 120 | } else { 121 | fiddle = await this.fromEntries(src as Iterable<[string, string]>); 122 | } 123 | 124 | const { packAsAsar } = options || {}; 125 | if (packAsAsar) { 126 | fiddle = await this.packageFiddleAsAsar(fiddle); 127 | } 128 | return fiddle; 129 | } 130 | 131 | private async packageFiddleAsAsar(fiddle: Fiddle): Promise { 132 | const sourceDir = path.dirname(fiddle.mainPath); 133 | const asarOutputDir = path.join(this.fiddles, hashString(sourceDir)); 134 | const asarFilePath = path.join(asarOutputDir, 'app.asar'); 135 | 136 | await asar.createPackage(sourceDir, asarFilePath); 137 | const packagedFiddle = new Fiddle(asarFilePath, fiddle.source); 138 | 139 | await fs.remove(sourceDir); 140 | return packagedFiddle; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { DefaultPaths, Paths } from './paths'; 4 | import { 5 | ElectronBinary, 6 | Installer, 7 | InstallerParams, 8 | InstallState, 9 | InstallStateEvent, 10 | Mirrors, 11 | ProgressObject, 12 | } from './installer'; 13 | import { 14 | Fiddle, 15 | FiddleFactory, 16 | FiddleSource, 17 | FiddleFactoryCreateOptions, 18 | } from './fiddle'; 19 | import { 20 | BisectResult, 21 | Runner, 22 | RunnerOptions, 23 | RunnerSpawnOptions, 24 | TestResult, 25 | } from './runner'; 26 | import { 27 | BaseVersions, 28 | ElectronVersions, 29 | ElectronVersionsCreateOptions, 30 | ReleaseInfo, 31 | SemOrStr, 32 | SemVer, 33 | Versions, 34 | compareVersions, 35 | } from './versions'; 36 | import { runFromCommandLine } from './command-line'; 37 | 38 | export { 39 | BaseVersions, 40 | BisectResult, 41 | DefaultPaths, 42 | ElectronBinary, 43 | ElectronVersions, 44 | ElectronVersionsCreateOptions, 45 | Fiddle, 46 | FiddleFactory, 47 | FiddleFactoryCreateOptions, 48 | FiddleSource, 49 | InstallState, 50 | InstallStateEvent, 51 | Installer, 52 | InstallerParams, 53 | Mirrors, 54 | Paths, 55 | ProgressObject, 56 | ReleaseInfo, 57 | Runner, 58 | RunnerOptions, 59 | RunnerSpawnOptions, 60 | SemOrStr, 61 | SemVer, 62 | TestResult, 63 | Versions, 64 | compareVersions, 65 | runFromCommandLine, 66 | }; 67 | 68 | if (require.main === module) { 69 | void runFromCommandLine(process.argv.slice(2)); 70 | } 71 | -------------------------------------------------------------------------------- /src/installer.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra'; 2 | import * as path from 'path'; 3 | import semver from 'semver'; 4 | import debug from 'debug'; 5 | import extract from 'extract-zip'; 6 | import { EventEmitter } from 'events'; 7 | import { rimrafSync } from 'rimraf'; 8 | import { download as electronDownload } from '@electron/get'; 9 | import { inspect } from 'util'; 10 | 11 | import { DefaultPaths, Paths } from './paths'; 12 | 13 | function getZipName(version: string): string { 14 | return `electron-v${version}-${process.platform}-${process.arch}.zip`; 15 | } 16 | 17 | export type ProgressObject = { percent: number }; 18 | 19 | /** 20 | * The state of a current Electron version. 21 | * See {@link Installer.state} to get this value. 22 | * See Installer.on('state-changed') to watch for state changes. 23 | */ 24 | export enum InstallState { 25 | missing = 'missing', 26 | downloading = 'downloading', 27 | downloaded = 'downloaded', 28 | installing = 'installing', 29 | installed = 'installed', 30 | } 31 | 32 | export interface InstallStateEvent { 33 | version: string; 34 | state: InstallState; 35 | } 36 | 37 | export interface Mirrors { 38 | electronMirror: string; 39 | electronNightlyMirror: string; 40 | } 41 | 42 | export interface ElectronBinary { 43 | path: string; 44 | alreadyExtracted: boolean; // to check if it's kept as zipped or not 45 | } 46 | 47 | export interface InstallerParams { 48 | progressCallback: (progress: ProgressObject) => void; 49 | mirror: Mirrors; 50 | } 51 | 52 | /** 53 | * Manage downloading and installing Electron versions. 54 | * 55 | * An Electron release's .zip is downloaded into `paths.electronDownloads`, 56 | * which holds all the downloaded zips. 57 | * 58 | * The installed version is unzipped into `paths.electronInstall`. Only one 59 | * version is installed at a time -- installing a new version overwrites the 60 | * current one in `paths.electronInstall`. 61 | * 62 | * See {@link DefaultPaths} for the default paths. 63 | */ 64 | export class Installer extends EventEmitter { 65 | private readonly paths: Paths; 66 | private readonly stateMap = new Map(); 67 | 68 | constructor(pathsIn: Partial = {}) { 69 | super(); 70 | this.paths = Object.freeze({ ...DefaultPaths, ...pathsIn }); 71 | this.rebuildStates(); 72 | } 73 | 74 | public static execSubpath(platform: string = process.platform): string { 75 | switch (platform) { 76 | case 'darwin': 77 | return 'Electron.app/Contents/MacOS/Electron'; 78 | case 'win32': 79 | return 'electron.exe'; 80 | default: 81 | return 'electron'; 82 | } 83 | } 84 | 85 | public static getExecPath(folder: string): string { 86 | return path.join(folder, Installer.execSubpath()); 87 | } 88 | 89 | public state(version: string): InstallState { 90 | return this.stateMap.get(version) || InstallState.missing; 91 | } 92 | 93 | private setState(version: string, state: InstallState) { 94 | const d = debug('fiddle-core:Installer:setState'); 95 | const oldState = this.state(version); 96 | 97 | if (state === InstallState.missing) { 98 | this.stateMap.delete(version); 99 | } else { 100 | this.stateMap.set(version, state); 101 | } 102 | 103 | const newState = this.state(version); 104 | d(inspect({ version, oldState, newState })); 105 | if (oldState !== newState) { 106 | const event: InstallStateEvent = { version, state: newState }; 107 | d('emitting state-changed', inspect(event)); 108 | this.emit('state-changed', event); 109 | } 110 | } 111 | 112 | private rebuildStates() { 113 | this.stateMap.clear(); 114 | 115 | // currently installed... 116 | try { 117 | const versionFile = path.join(this.paths.electronInstall, 'version'); 118 | const version = fs.readFileSync(versionFile, 'utf8').trim(); 119 | this.setState(version, InstallState.installed); 120 | } catch { 121 | // no current version 122 | } 123 | 124 | this.installing.forEach((version) => { 125 | this.setState(version, InstallState.installing); 126 | }); 127 | 128 | // already downloaded... 129 | const str = `^electron-v(.*)-${process.platform}-${process.arch}.zip$`; 130 | const reg = new RegExp(str); 131 | try { 132 | for (const file of fs.readdirSync(this.paths.electronDownloads)) { 133 | const match = reg.exec(file); 134 | if (match) { 135 | this.setState(match[1], InstallState.downloaded); 136 | } else { 137 | // Case when the download path already has the unzipped electron version 138 | const versionFile = path.join( 139 | this.paths.electronDownloads, 140 | file, 141 | 'version', 142 | ); 143 | 144 | if (fs.existsSync(versionFile)) { 145 | const version = fs.readFileSync(versionFile, 'utf8').trim(); 146 | if (semver.valid(version)) { 147 | this.setState(version, InstallState.downloaded); 148 | } 149 | } 150 | } 151 | } 152 | } catch { 153 | // no download directory yet 154 | } 155 | 156 | // being downloaded now... 157 | for (const version of this.downloading.keys()) { 158 | this.setState(version, InstallState.downloading); 159 | } 160 | } 161 | 162 | /** Removes an Electron download or Electron install from the disk. */ 163 | public async remove(version: string): Promise { 164 | const d = debug('fiddle-core:Installer:remove'); 165 | d(version); 166 | let isBinaryDeleted = false; 167 | // utility to re-run removal functions upon failure 168 | // due to windows filesystem lockfile jank 169 | const rerunner = async ( 170 | path: string, 171 | func: (path: string) => void, 172 | counter = 1, 173 | ): Promise => { 174 | try { 175 | func(path); 176 | return true; 177 | } catch (error) { 178 | console.warn( 179 | `Installer: failed to run ${func.name} for ${version}, but failed`, 180 | error, 181 | ); 182 | if (counter < 4) { 183 | console.log(`Installer: Trying again to run ${func.name}`); 184 | await rerunner(path, func, counter + 1); 185 | } 186 | } 187 | return false; 188 | }; 189 | 190 | const binaryCleaner = (path: string) => { 191 | if (fs.existsSync(path)) { 192 | const { noAsar } = process; 193 | try { 194 | process.noAsar = true; 195 | fs.removeSync(path); 196 | } finally { 197 | process.noAsar = noAsar; 198 | } 199 | } 200 | }; 201 | // get the zip path 202 | const zipPath = path.join( 203 | this.paths.electronDownloads, 204 | getZipName(version), 205 | ); 206 | // Or, maybe the version was already installed and kept in file system 207 | const preInstalledPath = path.join(this.paths.electronDownloads, version); 208 | 209 | const isZipDeleted = await rerunner(zipPath, binaryCleaner); 210 | const isPathDeleted = await rerunner(preInstalledPath, binaryCleaner); 211 | 212 | // maybe uninstall it 213 | if (this.installedVersion === version) { 214 | isBinaryDeleted = await rerunner( 215 | this.paths.electronInstall, 216 | binaryCleaner, 217 | ); 218 | } else { 219 | // If the current version binary doesn't exists 220 | isBinaryDeleted = true; 221 | } 222 | 223 | if ((isZipDeleted || isPathDeleted) && isBinaryDeleted) { 224 | this.setState(version, InstallState.missing); 225 | } else { 226 | // Ideally the execution shouldn't reach this point 227 | console.warn(`Installer: Failed to remove version ${version}`); 228 | } 229 | } 230 | 231 | /** The current Electron installation, if any. */ 232 | public get installedVersion(): string | undefined { 233 | for (const [version, state] of this.stateMap) 234 | if (state === InstallState.installed) return version; 235 | } 236 | 237 | private async download( 238 | version: string, 239 | opts?: Partial, 240 | ): Promise { 241 | let pctDone = 0; 242 | const getProgressCallback = (progress: ProgressObject) => { 243 | if (opts?.progressCallback) { 244 | // Call the user passed callback function 245 | opts.progressCallback(progress); 246 | } 247 | const pct = Math.round(progress.percent * 100); 248 | if (pctDone + 10 <= pct) { 249 | const emoji = pct >= 100 ? '🏁' : '⏳'; 250 | // FIXME(anyone): is there a better place than console.log for this? 251 | console.log(`${emoji} downloading ${version} - ${pct}%`); 252 | pctDone = pct; 253 | } 254 | }; 255 | const zipFile = await electronDownload(version, { 256 | mirrorOptions: { 257 | mirror: opts?.mirror?.electronMirror, 258 | nightlyMirror: opts?.mirror?.electronNightlyMirror, 259 | }, 260 | downloadOptions: { 261 | quiet: true, 262 | getProgressCallback, 263 | }, 264 | }); 265 | return zipFile; 266 | } 267 | 268 | private async ensureDownloadedImpl( 269 | version: string, 270 | opts?: Partial, 271 | ): Promise { 272 | const d = debug(`fiddle-core:Installer:${version}:ensureDownloadedImpl`); 273 | const { electronDownloads } = this.paths; 274 | const zipFile = path.join(electronDownloads, getZipName(version)); 275 | const zipFileExists = fs.existsSync(zipFile); 276 | 277 | const state = this.state(version); 278 | 279 | if (state === InstallState.downloaded) { 280 | const preInstalledPath = path.join(electronDownloads, version); 281 | if (!zipFileExists && fs.existsSync(preInstalledPath)) { 282 | return { 283 | path: preInstalledPath, 284 | alreadyExtracted: true, 285 | }; 286 | } 287 | } 288 | 289 | if (state === InstallState.missing || !zipFileExists) { 290 | d(`"${zipFile}" does not exist; downloading now`); 291 | this.setState(version, InstallState.downloading); 292 | try { 293 | const tempFile = await this.download(version, opts); 294 | await fs.ensureDir(electronDownloads); 295 | await fs.move(tempFile, zipFile); 296 | } catch (err) { 297 | this.setState(version, InstallState.missing); 298 | throw err; 299 | } 300 | this.setState(version, InstallState.downloaded); 301 | d(`"${zipFile}" downloaded`); 302 | } else { 303 | d(`"${zipFile}" exists; no need to download`); 304 | } 305 | 306 | return { 307 | path: zipFile, 308 | alreadyExtracted: false, 309 | }; 310 | } 311 | 312 | /** map of version string to currently-running active Promise */ 313 | private downloading = new Map>(); 314 | 315 | public async ensureDownloaded( 316 | version: string, 317 | opts?: Partial, 318 | ): Promise { 319 | const { downloading: promises } = this; 320 | let promise = promises.get(version); 321 | if (promise) return promise; 322 | 323 | promise = this.ensureDownloadedImpl(version, opts).finally(() => 324 | promises.delete(version), 325 | ); 326 | promises.set(version, promise); 327 | return promise; 328 | } 329 | 330 | /** keep a track of all currently installing versions */ 331 | private installing = new Set(); 332 | 333 | public async install( 334 | version: string, 335 | opts?: Partial, 336 | ): Promise { 337 | const d = debug(`fiddle-core:Installer:${version}:install`); 338 | const { electronInstall } = this.paths; 339 | const isVersionInstalling = this.installing.has(version); 340 | const electronExec = Installer.getExecPath(electronInstall); 341 | 342 | if (isVersionInstalling) { 343 | throw new Error(`Currently installing "${version}"`); 344 | } 345 | 346 | this.installing.add(version); 347 | 348 | try { 349 | // see if the current version (if any) is already `version` 350 | const { installedVersion } = this; 351 | if (installedVersion === version) { 352 | d(`already installed`); 353 | } else { 354 | const { path: source, alreadyExtracted } = await this.ensureDownloaded( 355 | version, 356 | opts, 357 | ); 358 | 359 | // An unzipped version already exists at `electronDownload` path 360 | if (alreadyExtracted) { 361 | await this.installVersionImpl(version, source, () => { 362 | // Simply copy over the files from preinstalled version to `electronInstall` 363 | const { noAsar } = process; 364 | process.noAsar = true; 365 | fs.copySync(source, electronInstall); 366 | process.noAsar = noAsar; 367 | }); 368 | } else { 369 | await this.installVersionImpl(version, source, async () => { 370 | // FIXME(anyone) is there a less awful way to wrangle asar 371 | const { noAsar } = process; 372 | try { 373 | process.noAsar = true; 374 | await extract(source, { dir: electronInstall }); 375 | } finally { 376 | process.noAsar = noAsar; 377 | } 378 | }); 379 | } 380 | } 381 | } finally { 382 | this.installing.delete(version); 383 | } 384 | 385 | // return the full path to the electron executable 386 | d(inspect({ electronExec, version })); 387 | return electronExec; 388 | } 389 | 390 | private async installVersionImpl( 391 | version: string, 392 | source: string, 393 | installCallback: () => Promise | void, 394 | ): Promise { 395 | const { 396 | paths: { electronInstall }, 397 | installedVersion, 398 | } = this; 399 | const d = debug(`fiddle-core:Installer:${version}:install`); 400 | 401 | const originalState = this.state(version); 402 | this.setState(version, InstallState.installing); 403 | try { 404 | d(`installing from "${source}"`); 405 | const { noAsar } = process; 406 | try { 407 | process.noAsar = true; 408 | rimrafSync(electronInstall); 409 | } finally { 410 | process.noAsar = noAsar; 411 | } 412 | 413 | // Call the user defined callback which unzips/copies files content 414 | if (installCallback) { 415 | await installCallback(); 416 | } 417 | } catch (err) { 418 | this.setState(version, originalState); 419 | throw err; 420 | } 421 | 422 | if (installedVersion) { 423 | this.setState(installedVersion, InstallState.downloaded); 424 | } 425 | this.setState(version, InstallState.installed); 426 | } 427 | } 428 | -------------------------------------------------------------------------------- /src/paths.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import envPaths from 'env-paths'; 3 | 4 | export interface Paths { 5 | // folder where electron zipfiles will be cached 6 | readonly electronDownloads: string; 7 | 8 | // folder where an electron download will be unzipped to be run 9 | readonly electronInstall: string; 10 | 11 | // folder where fiddles will be saved 12 | readonly fiddles: string; 13 | 14 | // file where electron releases are cached 15 | readonly versionsCache: string; 16 | } 17 | 18 | const paths = envPaths('fiddle-core', { suffix: '' }); 19 | 20 | export const DefaultPaths: Paths = { 21 | electronDownloads: path.join(paths.data, 'electron', 'zips'), 22 | electronInstall: path.join(paths.data, 'electron', 'current'), 23 | fiddles: path.join(paths.cache, 'fiddles'), 24 | versionsCache: path.join(paths.cache, 'releases.json'), 25 | }; 26 | -------------------------------------------------------------------------------- /src/runner.ts: -------------------------------------------------------------------------------- 1 | import { Writable } from 'stream'; 2 | import { ChildProcess, SpawnOptions, spawn } from 'child_process'; 3 | import * as fs from 'fs'; 4 | import * as os from 'os'; 5 | import * as path from 'path'; 6 | import debug from 'debug'; 7 | import getos from 'getos'; 8 | import { SemVer } from 'semver'; 9 | import { inspect } from 'util'; 10 | 11 | import { Installer } from './installer'; 12 | import { ElectronVersions, Versions } from './versions'; 13 | import { Fiddle, FiddleFactory, FiddleSource } from './fiddle'; 14 | import { DefaultPaths, Paths } from './paths'; 15 | 16 | export interface RunnerOptions { 17 | // extra arguments to be appended to the electron invocation 18 | args?: string[]; 19 | // if true, use xvfb-run on *nix 20 | headless?: boolean; 21 | // where the test's output should be written 22 | out?: Writable; 23 | // whether to show config info (e.g. platform os & arch) in the log 24 | showConfig?: boolean; 25 | // whether to run the fiddle from asar 26 | runFromAsar?: boolean; 27 | } 28 | 29 | const DefaultRunnerOpts: RunnerOptions = { 30 | args: [], 31 | headless: false, 32 | out: process.stdout, 33 | showConfig: true, 34 | } as const; 35 | 36 | export type RunnerSpawnOptions = SpawnOptions & RunnerOptions; 37 | 38 | export interface TestResult { 39 | status: 'test_passed' | 'test_failed' | 'test_error' | 'system_error'; 40 | } 41 | 42 | export interface BisectResult { 43 | range?: [string, string]; 44 | status: 'bisect_succeeded' | 'test_error' | 'system_error'; 45 | } 46 | 47 | export class Runner { 48 | private osInfo = ''; 49 | 50 | private constructor( 51 | private readonly installer: Installer, 52 | private readonly versions: Versions, 53 | private readonly fiddleFactory: FiddleFactory, 54 | ) { 55 | getos((err, result) => (this.osInfo = inspect(result || err))); 56 | } 57 | 58 | public static async create( 59 | opts: { 60 | installer?: Installer; 61 | fiddleFactory?: FiddleFactory; 62 | paths?: Partial; 63 | versions?: Versions; 64 | } = {}, 65 | ): Promise { 66 | const paths = Object.freeze({ ...DefaultPaths, ...(opts.paths || {}) }); 67 | const installer = opts.installer || new Installer(paths); 68 | const versions = opts.versions || (await ElectronVersions.create(paths)); 69 | const factory = opts.fiddleFactory || new FiddleFactory(paths.fiddles); 70 | return new Runner(installer, versions, factory); 71 | } 72 | 73 | /** 74 | * Figure out how to run the user-specified `electron` value. 75 | * 76 | * - if it's an existing directory, look for an execPath in it. 77 | * - if it's an existing file, run it. It's a local build. 78 | * - if it's a version number, delegate to the installer 79 | * 80 | * @param val - a version number, directory, or executable 81 | * @returns a path to an Electron executable 82 | */ 83 | private async getExec(electron: string): Promise { 84 | try { 85 | const stat = fs.statSync(electron); 86 | // if it's on the filesystem but not a directory, use it directly 87 | if (!stat.isDirectory()) return electron; 88 | // if it's on the filesystem as a directory, look for execPath 89 | const name = Installer.getExecPath(electron); 90 | if (fs.existsSync(name)) return name; 91 | } catch { 92 | // if it's a version, install it 93 | if (this.versions.isVersion(electron)) 94 | return await this.installer.install(electron); 95 | } 96 | throw new Error(`Unrecognized electron name: "${electron}"`); 97 | } 98 | 99 | // FIXME(anyone): minor wart, 'source' is incorrect here if it's a local build 100 | private spawnInfo = (version: string, exec: string, fiddle: Fiddle) => 101 | [ 102 | '', 103 | '🧪 Testing', 104 | '', 105 | ` - date: ${new Date().toISOString()}`, 106 | '', 107 | ' - fiddle:', 108 | ` - source: ${fiddle.source}`, 109 | ` - local copy: ${path.dirname(fiddle.mainPath)}`, 110 | '', 111 | ` - electron_version: ${version}`, 112 | ` - source: https://github.com/electron/electron/releases/tag/v${version}`, 113 | ` - local copy: ${path.dirname(exec)}`, 114 | '', 115 | ' - test platform:', 116 | ` - os_arch: ${os.arch()}`, 117 | ` - os_platform: ${process.platform}`, 118 | ` - os_release: ${os.release()}`, 119 | ` - os_version: ${os.version()}`, 120 | ` - getos: ${this.osInfo}`, 121 | '', 122 | ].join('\n'); 123 | 124 | /** If headless specified on *nix, try to run with xvfb-run */ 125 | private static headless( 126 | exec: string, 127 | args: string[], 128 | ): { exec: string; args: string[] } { 129 | if (process.platform !== 'darwin' && process.platform !== 'win32') { 130 | // try to get a free server number 131 | args.unshift('--auto-servernum', exec); 132 | exec = 'xvfb-run'; 133 | } 134 | return { exec, args }; 135 | } 136 | 137 | public async spawn( 138 | versionIn: string | SemVer, 139 | fiddleIn: FiddleSource, 140 | opts: RunnerSpawnOptions = {}, 141 | ): Promise { 142 | const d = debug('fiddle-core:Runner.spawn'); 143 | 144 | // process the input parameters 145 | opts = { ...DefaultRunnerOpts, ...opts }; 146 | const version = versionIn instanceof SemVer ? versionIn.version : versionIn; 147 | const fiddle = await this.fiddleFactory.create(fiddleIn, { 148 | packAsAsar: opts.runFromAsar, 149 | }); 150 | if (!fiddle) throw new Error(`Invalid fiddle: "${inspect(fiddleIn)}"`); 151 | 152 | // set up the electron binary and the fiddle 153 | const electronExec = await this.getExec(version); 154 | let exec = electronExec; 155 | let args = [...(opts.args || []), fiddle.mainPath]; 156 | if (opts.headless) ({ exec, args } = Runner.headless(exec, args)); 157 | 158 | if (opts.out && opts.showConfig) { 159 | opts.out.write(`${this.spawnInfo(version, electronExec, fiddle)}\n`); 160 | } 161 | 162 | d(inspect({ exec, args, opts })); 163 | 164 | const child = spawn(exec, args, opts); 165 | if (opts.out) { 166 | child.stdout?.pipe(opts.out); 167 | child.stderr?.pipe(opts.out); 168 | } 169 | 170 | return child; 171 | } 172 | 173 | private static displayEmoji(result: TestResult): string { 174 | switch (result.status) { 175 | case 'system_error': 176 | return '🟠'; 177 | case 'test_error': 178 | return '🔵'; 179 | case 'test_failed': 180 | return '🔴'; 181 | case 'test_passed': 182 | return '🟢'; 183 | } 184 | } 185 | 186 | public static displayResult(result: TestResult): string { 187 | const text = Runner.displayEmoji(result); 188 | switch (result.status) { 189 | case 'system_error': 190 | return text + ' system error: test did not pass or fail'; 191 | case 'test_error': 192 | return text + ' test error: test did not pass or fail'; 193 | case 'test_failed': 194 | return text + ' failed'; 195 | case 'test_passed': 196 | return text + ' passed'; 197 | } 198 | } 199 | 200 | public async run( 201 | version: string | SemVer, 202 | fiddle: FiddleSource, 203 | opts: RunnerSpawnOptions = DefaultRunnerOpts, 204 | ): Promise { 205 | const subprocess = await this.spawn(version, fiddle, opts); 206 | 207 | return new Promise((resolve) => { 208 | subprocess.on('error', () => { 209 | return resolve({ status: 'system_error' }); 210 | }); 211 | 212 | subprocess.on('exit', (code) => { 213 | if (code === 0) return resolve({ status: 'test_passed' }); 214 | if (code === 1) return resolve({ status: 'test_failed' }); 215 | return resolve({ status: 'test_error' }); 216 | }); 217 | }); 218 | } 219 | 220 | public async bisect( 221 | version_a: string | SemVer, 222 | version_b: string | SemVer, 223 | fiddleIn: FiddleSource, 224 | opts: RunnerSpawnOptions = DefaultRunnerOpts, 225 | ): Promise { 226 | const { out } = opts; 227 | const log = (first: unknown, ...rest: unknown[]) => { 228 | if (out) { 229 | out.write([first, ...rest].join(' ')); 230 | out.write('\n'); 231 | } 232 | }; 233 | 234 | const versions = this.versions.inRange(version_a, version_b); 235 | const fiddle = await this.fiddleFactory.create(fiddleIn); 236 | if (!fiddle) throw new Error(`Invalid fiddle: "${inspect(fiddleIn)}"`); 237 | 238 | const displayIndex = (i: number) => '#' + i.toString().padStart(4, ' '); 239 | 240 | log( 241 | [ 242 | '📐 Bisect Requested', 243 | '', 244 | ` - gist is ${fiddle.source}`, 245 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 246 | ` - the version range is [${version_a.toString()}..${version_b.toString()}]`, 247 | ` - there are ${versions.length} versions in this range:`, 248 | '', 249 | ...versions.map((ver, i) => `${displayIndex(i)} - ${ver.version}`), 250 | ].join('\n'), 251 | ); 252 | 253 | // bisect through the releases 254 | const LEFT_POS = 0; 255 | const RIGHT_POS = versions.length - 1; 256 | let left = LEFT_POS; 257 | let right = RIGHT_POS; 258 | let result: TestResult | undefined = undefined; 259 | const testOrder: (number | undefined)[] = []; 260 | const results = new Array(versions.length); 261 | while (left + 1 < right) { 262 | const mid = Math.round(left + (right - left) / 2); 263 | const ver = versions[mid]; 264 | testOrder.push(mid); 265 | log(`bisecting, range [${left}..${right}], mid ${mid} (${ver.version})`); 266 | 267 | result = await this.run(ver.version, fiddle, opts); 268 | results[mid] = result; 269 | log(`${Runner.displayResult(result)} ${versions[mid].version}\n`); 270 | if (result.status === 'test_passed') { 271 | left = mid; 272 | continue; 273 | } else if (result.status === 'test_failed') { 274 | right = mid; 275 | continue; 276 | } else { 277 | break; 278 | } 279 | } 280 | 281 | // validates the status of the boundary versions if we've reached the end 282 | // of the bisect and one of our pointers is at a boundary. 283 | 284 | const boundaries: Array = []; 285 | if (left === LEFT_POS && !results[LEFT_POS]) boundaries.push(LEFT_POS); 286 | if (right === RIGHT_POS && !results[RIGHT_POS]) boundaries.push(RIGHT_POS); 287 | 288 | for (const position of boundaries) { 289 | const result = await this.run(versions[position].version, fiddle, opts); 290 | results[position] = result; 291 | log(`${Runner.displayResult(result)} ${versions[position].version}\n`); 292 | } 293 | 294 | log(`🏁 finished bisecting across ${versions.length} versions...`); 295 | versions.forEach((ver, i) => { 296 | const n = testOrder.indexOf(i); 297 | if (n === -1) return; 298 | log( 299 | displayIndex(i), 300 | Runner.displayResult(results[i]), 301 | ver, 302 | `(test #${n + 1})`, 303 | ); 304 | }); 305 | 306 | log('\n🏁 Done bisecting'); 307 | const success = 308 | results[left].status === 'test_passed' && 309 | results[right].status === 'test_failed'; 310 | if (success) { 311 | const good = versions[left].version; 312 | const bad = versions[right].version; 313 | log( 314 | [ 315 | `${Runner.displayResult(results[left])} ${good}`, 316 | `${Runner.displayResult(results[right])} ${bad}`, 317 | 'Commits between versions:', 318 | `https://github.com/electron/electron/compare/v${good}...v${bad} ↔`, 319 | ].join('\n'), 320 | ); 321 | 322 | return { 323 | range: [versions[left].version, versions[right].version], 324 | status: 'bisect_succeeded', 325 | }; 326 | } else { 327 | // FIXME: log some failure 328 | if ( 329 | result?.status === 'test_error' || 330 | result?.status === 'system_error' 331 | ) { 332 | return { status: result.status }; 333 | } 334 | 335 | if (results[left].status === results[right].status) { 336 | return { status: 'test_error' }; 337 | } 338 | 339 | return { status: 'system_error' }; 340 | } 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /src/versions.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra'; 2 | import { parse as semverParse, SemVer } from 'semver'; 3 | import debug from 'debug'; 4 | import fetch from 'node-fetch'; 5 | 6 | export { SemVer }; 7 | 8 | import { DefaultPaths, Paths } from './paths'; 9 | 10 | export type SemOrStr = SemVer | string; 11 | 12 | export interface ReleaseInfo { 13 | /** Electron version */ 14 | version: string; 15 | /** Release date */ 16 | date: string; 17 | /** Node.js version */ 18 | node: string; 19 | /** V8 version */ 20 | v8: string; 21 | /** uv version */ 22 | uv: string; 23 | /** zlib version */ 24 | zlib: string; 25 | /** OpenSSL version */ 26 | openssl: string; 27 | /** Node.js modules version */ 28 | modules: string; 29 | /** Chromium version */ 30 | chrome: string; 31 | /** Files included in the release */ 32 | files: Array; 33 | } 34 | 35 | /** 36 | * Interface for an object that manages a list of Electron releases. 37 | * 38 | * See {@link BaseVersions} for testing situations. 39 | * See {@link ElectronVersions} for production. 40 | */ 41 | export interface Versions { 42 | /** Semver-Major numbers of branches that only have prereleases */ 43 | readonly prereleaseMajors: number[]; 44 | 45 | /** Semver-Major numbers of branches that have supported stable releases */ 46 | readonly supportedMajors: number[]; 47 | 48 | /** Semver-Major numbers of branches that are no longer supported */ 49 | readonly obsoleteMajors: number[]; 50 | 51 | /** The latest release (by version, not by date) */ 52 | readonly latest: SemVer | undefined; 53 | 54 | /** The latest stable (by version, not by date) */ 55 | readonly latestStable: SemVer | undefined; 56 | 57 | /** Full list of all known Electron releases, Sorted in branch order. */ 58 | readonly versions: SemVer[]; 59 | 60 | /** @returns true iff `version` is a release that this object knows about */ 61 | isVersion(version: SemOrStr): boolean; 62 | 63 | /** @returns all versions matching that major number. Sorted in branch order. */ 64 | inMajor(major: number): SemVer[]; 65 | 66 | /** @returns all versions in a range, inclusive. Sorted in branch order. */ 67 | inRange(a: SemOrStr, b: SemOrStr): SemVer[]; 68 | 69 | /** @returns {@link ReleaseInfo} iff `version` is a release that this object knows about */ 70 | getReleaseInfo(version: SemOrStr): ReleaseInfo | undefined; 71 | } 72 | 73 | export interface ElectronVersionsCreateOptions { 74 | /** Initial versions to use if there is no cache. When provided, no initial fetch is done */ 75 | initialVersions?: unknown; 76 | 77 | /** Ignore the cache even if it exists and is fresh */ 78 | ignoreCache?: boolean; 79 | } 80 | 81 | export function compareVersions(a: SemVer, b: SemVer): number { 82 | const l = a.compareMain(b); 83 | if (l) return l; 84 | // Electron's approach is nightly -> other prerelease tags -> stable, 85 | // so force `nightly` to sort before other prerelease tags. 86 | const [prea] = a.prerelease; 87 | const [preb] = b.prerelease; 88 | if (prea === 'nightly' && preb !== 'nightly') return -1; 89 | if (prea !== 'nightly' && preb === 'nightly') return 1; 90 | return a.comparePre(b); 91 | } 92 | 93 | // ts type guards 94 | 95 | function hasVersion(val: unknown): val is { version: unknown } { 96 | return typeof val === 'object' && val !== null && 'version' in val; 97 | } 98 | 99 | function isReleaseInfo(val: unknown): val is ReleaseInfo { 100 | return ( 101 | typeof val === 'object' && 102 | val !== null && 103 | 'version' in val && 104 | typeof val.version === 'string' && 105 | 'date' in val && 106 | typeof val.date === 'string' && 107 | 'node' in val && 108 | typeof val.node === 'string' && 109 | 'v8' in val && 110 | typeof val.v8 === 'string' && 111 | 'uv' in val && 112 | typeof val.uv === 'string' && 113 | 'zlib' in val && 114 | typeof val.zlib === 'string' && 115 | 'openssl' in val && 116 | typeof val.openssl === 'string' && 117 | 'modules' in val && 118 | typeof val.modules === 'string' && 119 | 'chrome' in val && 120 | typeof val.chrome === 'string' && 121 | 'files' in val && 122 | isArrayOfStrings(val.files) 123 | ); 124 | } 125 | 126 | function isArrayOfVersionObjects( 127 | val: unknown, 128 | ): val is Array<{ version: string }> { 129 | return ( 130 | Array.isArray(val) && 131 | val.every((item) => hasVersion(item) && typeof item.version === 'string') 132 | ); 133 | } 134 | 135 | function isArrayOfStrings(val: unknown): val is Array { 136 | return Array.isArray(val) && val.every((item) => typeof item === 'string'); 137 | } 138 | 139 | const NUM_SUPPORTED_MAJORS = 3; 140 | 141 | /** 142 | * Implementation of {@link Versions} that does everything except self-populate. 143 | * It needs to be fed version info in its constructor. 144 | * 145 | * In production, use subclass '{@link ElectronVersions}'. This base class is 146 | * useful in testing because it's an easy way to inject fake test data into a 147 | * real Versions object. 148 | */ 149 | export class BaseVersions implements Versions { 150 | private readonly map = new Map(); 151 | private readonly releaseInfo = new Map(); 152 | 153 | protected setVersions(val: unknown): void { 154 | // release info doesn't need to be in sorted order 155 | this.releaseInfo.clear(); 156 | 157 | // build the array 158 | let parsed: Array = []; 159 | if (isArrayOfVersionObjects(val)) { 160 | parsed = val.map(({ version }) => semverParse(version)); 161 | 162 | // build release info 163 | for (const entry of val) { 164 | if (isReleaseInfo(entry)) { 165 | this.releaseInfo.set(entry.version, { 166 | version: entry.version, 167 | date: entry.date, 168 | node: entry.node, 169 | v8: entry.v8, 170 | uv: entry.uv, 171 | zlib: entry.zlib, 172 | openssl: entry.openssl, 173 | modules: entry.modules, 174 | chrome: entry.chrome, 175 | files: [...entry.files], 176 | }); 177 | } 178 | } 179 | } else if (isArrayOfStrings(val)) { 180 | parsed = val.map((version) => semverParse(version)); 181 | } else { 182 | console.warn('Unrecognized versions:', val); 183 | } 184 | 185 | // insert them in sorted order 186 | const semvers = parsed.filter((sem) => Boolean(sem)) as SemVer[]; 187 | semvers.sort((a, b) => compareVersions(a, b)); 188 | this.map.clear(); 189 | for (const sem of semvers) this.map.set(sem.version, sem); 190 | } 191 | 192 | public constructor(versions: unknown) { 193 | this.setVersions(versions); 194 | } 195 | 196 | public get prereleaseMajors(): number[] { 197 | const majors = new Set(); 198 | for (const ver of this.map.values()) { 199 | majors.add(ver.major); 200 | } 201 | for (const ver of this.map.values()) { 202 | if (ver.prerelease.length === 0) { 203 | majors.delete(ver.major); 204 | } 205 | } 206 | return [...majors]; 207 | } 208 | 209 | public get stableMajors(): number[] { 210 | const majors = new Set(); 211 | for (const ver of this.map.values()) { 212 | if (ver.prerelease.length === 0) { 213 | majors.add(ver.major); 214 | } 215 | } 216 | return [...majors]; 217 | } 218 | 219 | public get supportedMajors(): number[] { 220 | return this.stableMajors.slice(-NUM_SUPPORTED_MAJORS); 221 | } 222 | 223 | public get obsoleteMajors(): number[] { 224 | return this.stableMajors.slice(0, -NUM_SUPPORTED_MAJORS); 225 | } 226 | 227 | public get versions(): SemVer[] { 228 | return [...this.map.values()]; 229 | } 230 | 231 | public get latest(): SemVer | undefined { 232 | return this.versions.pop(); 233 | } 234 | 235 | public get latestStable(): SemVer | undefined { 236 | let stable: SemVer | undefined = undefined; 237 | for (const ver of this.map.values()) { 238 | if (ver.prerelease.length === 0) { 239 | stable = ver; 240 | } 241 | } 242 | return stable; 243 | } 244 | 245 | public isVersion(ver: SemOrStr): boolean { 246 | return this.map.has(typeof ver === 'string' ? ver : ver.version); 247 | } 248 | 249 | public inMajor(major: number): SemVer[] { 250 | const versions: SemVer[] = []; 251 | for (const ver of this.map.values()) { 252 | if (ver.major === major) { 253 | versions.push(ver); 254 | } 255 | } 256 | return versions; 257 | } 258 | 259 | public inRange(a: SemOrStr, b: SemOrStr): SemVer[] { 260 | if (typeof a !== 'string') a = a.version; 261 | if (typeof b !== 'string') b = b.version; 262 | 263 | const versions = [...this.map.values()]; 264 | let first = versions.findIndex((ver) => ver.version === a); 265 | let last = versions.findIndex((ver) => ver.version === b); 266 | if (first > last) [first, last] = [last, first]; 267 | return versions.slice(first, last + 1); 268 | } 269 | 270 | public getReleaseInfo(ver: SemOrStr): ReleaseInfo | undefined { 271 | return this.releaseInfo.get(typeof ver === 'string' ? ver : ver.version); 272 | } 273 | } 274 | 275 | /** 276 | * Implementation of Versions that self-populates from release information at 277 | * https://releases.electronjs.org/releases.json . 278 | * 279 | * This is generally what to use in production. 280 | */ 281 | export class ElectronVersions extends BaseVersions { 282 | private constructor( 283 | private readonly versionsCache: string, 284 | private mtimeMs: number, 285 | values: unknown, 286 | ) { 287 | super(values); 288 | } 289 | 290 | private static async fetchVersions(cacheFile: string): Promise { 291 | const d = debug('fiddle-core:ElectronVersions:fetchVersions'); 292 | const url = 'https://releases.electronjs.org/releases.json'; 293 | d('fetching releases list from', url); 294 | const response = await fetch(url); 295 | if (!response.ok) { 296 | throw new Error( 297 | `Fetching versions failed with status code: ${response.status}`, 298 | ); 299 | } 300 | const json = (await response.json()) as unknown; 301 | await fs.outputJson(cacheFile, json); 302 | return json; 303 | } 304 | 305 | private static isCacheFresh(cacheTimeMs: number, now: number): boolean { 306 | const VERSION_CACHE_TTL_MS = 4 * 60 * 60 * 1000; // cache for N hours 307 | return now <= cacheTimeMs + VERSION_CACHE_TTL_MS; 308 | } 309 | 310 | public static async create( 311 | paths: Partial = {}, 312 | options: ElectronVersionsCreateOptions = {}, 313 | ): Promise { 314 | const d = debug('fiddle-core:ElectronVersions:create'); 315 | const { versionsCache } = { ...DefaultPaths, ...paths }; 316 | 317 | // Use initialVersions instead if provided, and don't fetch if so 318 | let versions = options.initialVersions; 319 | let staleCache = false; 320 | const now = Date.now(); 321 | 322 | if (!options.ignoreCache) { 323 | try { 324 | const st = await fs.stat(versionsCache); 325 | versions = await fs.readJson(versionsCache); 326 | staleCache = !ElectronVersions.isCacheFresh(st.mtimeMs, now); 327 | } catch (err) { 328 | d('cache file missing or cannot be read', err); 329 | } 330 | } 331 | 332 | if (!versions || staleCache) { 333 | try { 334 | versions = await ElectronVersions.fetchVersions(versionsCache); 335 | } catch (err) { 336 | d('error fetching versions', err); 337 | if (!versions) { 338 | throw err; 339 | } 340 | } 341 | } 342 | 343 | return new ElectronVersions(versionsCache, now, versions); 344 | } 345 | 346 | // update the cache 347 | public async fetch(): Promise { 348 | const d = debug('fiddle-core:ElectronVersions:fetch'); 349 | const { mtimeMs, versionsCache } = this; 350 | try { 351 | this.mtimeMs = Date.now(); 352 | const versions = await ElectronVersions.fetchVersions(versionsCache); 353 | this.setVersions(versions); 354 | d(`saved "${versionsCache}"`); 355 | } catch (err) { 356 | d('error fetching versions', err); 357 | this.mtimeMs = mtimeMs; 358 | } 359 | } 360 | 361 | // update the cache iff it's too old 362 | private async keepFresh(): Promise { 363 | if (!ElectronVersions.isCacheFresh(this.mtimeMs, Date.now())) { 364 | await this.fetch(); 365 | } 366 | } 367 | 368 | public override get prereleaseMajors(): number[] { 369 | void this.keepFresh(); 370 | return super.prereleaseMajors; 371 | } 372 | public override get stableMajors(): number[] { 373 | void this.keepFresh(); 374 | return super.stableMajors; 375 | } 376 | public override get supportedMajors(): number[] { 377 | void this.keepFresh(); 378 | return super.supportedMajors; 379 | } 380 | public override get obsoleteMajors(): number[] { 381 | void this.keepFresh(); 382 | return super.obsoleteMajors; 383 | } 384 | public override get versions(): SemVer[] { 385 | void this.keepFresh(); 386 | return super.versions; 387 | } 388 | public override get latest(): SemVer | undefined { 389 | void this.keepFresh(); 390 | return super.latest; 391 | } 392 | public override get latestStable(): SemVer | undefined { 393 | void this.keepFresh(); 394 | return super.latestStable; 395 | } 396 | public override isVersion(ver: SemOrStr): boolean { 397 | void this.keepFresh(); 398 | return super.isVersion(ver); 399 | } 400 | public override inMajor(major: number): SemVer[] { 401 | void this.keepFresh(); 402 | return super.inMajor(major); 403 | } 404 | public override inRange(a: SemOrStr, b: SemOrStr): SemVer[] { 405 | void this.keepFresh(); 406 | return super.inRange(a, b); 407 | } 408 | } 409 | -------------------------------------------------------------------------------- /tests/fiddle.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra'; 2 | import * as os from 'os'; 3 | import * as path from 'path'; 4 | import asar from '@electron/asar'; 5 | 6 | import { Fiddle, FiddleFactory } from '../src/index'; 7 | 8 | describe('FiddleFactory', () => { 9 | let tmpdir: string; 10 | let fiddleDir: string; 11 | let fiddleFactory: FiddleFactory; 12 | 13 | beforeEach(async () => { 14 | tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), 'fiddle-core-')); 15 | fiddleDir = path.join(tmpdir, 'fiddles'); 16 | fiddleFactory = new FiddleFactory(fiddleDir); 17 | }); 18 | 19 | afterEach(() => { 20 | fs.removeSync(tmpdir); 21 | }); 22 | 23 | function fiddleFixture(name: string): string { 24 | return path.join(__dirname, 'fixtures', 'fiddles', name); 25 | } 26 | 27 | it.todo('uses the fiddle cache path if none is specified'); 28 | 29 | describe('create()', () => { 30 | it('reads fiddles from local folders', async () => { 31 | const sourceDir = fiddleFixture('642fa8daaebea6044c9079e3f8a46390'); 32 | const fiddle = await fiddleFactory.create(sourceDir); 33 | expect(fiddle).toBeTruthy(); 34 | 35 | // test that the fiddle is a copy of the original 36 | const dirname = path.dirname(fiddle!.mainPath); 37 | expect(dirname).not.toEqual(sourceDir); 38 | 39 | // test that main.js file is created (not app.asar) 40 | expect(path.basename(fiddle!.mainPath)).toBe('main.js'); 41 | 42 | // test that the fiddle is kept in the fiddle cache 43 | expect(path.dirname(dirname)).toBe(fiddleDir); 44 | 45 | // test that the file list is identical 46 | const sourceFiles = fs.readdirSync(sourceDir); 47 | const fiddleFiles = fs.readdirSync(dirname); 48 | expect(fiddleFiles).toStrictEqual(sourceFiles); 49 | 50 | // test that the files' contents are identical 51 | for (const file of fiddleFiles) { 52 | const sourceFile = path.join(sourceDir, file); 53 | const fiddleFile = path.join(dirname, file); 54 | expect(fs.readFileSync(fiddleFile)).toStrictEqual( 55 | fs.readFileSync(sourceFile), 56 | ); 57 | } 58 | }); 59 | 60 | it('reads fiddles from entries', async () => { 61 | const id = 'main.js'; 62 | const content = '"use strict";'; 63 | const files = new Map([[id, content]]); 64 | const fiddle = await fiddleFactory.create(files.entries()); 65 | expect(fiddle).toBeTruthy(); 66 | 67 | // test that the fiddle is kept in the fiddle cache 68 | const dirname = path.dirname(fiddle!.mainPath); 69 | expect(path.dirname(dirname)).toBe(fiddleDir); 70 | 71 | // test that the file list is identical 72 | const sourceFiles = [...files.keys()]; 73 | const fiddleFiles = fs.readdirSync(dirname); 74 | expect(fiddleFiles).toEqual(sourceFiles); 75 | 76 | // test that the files' contents are identical 77 | for (const file of fiddleFiles) { 78 | const source = files.get(file); 79 | const fiddleFile = path.join(dirname, file); 80 | const target = fs.readFileSync(fiddleFile, 'utf8'); 81 | expect(target).toEqual(source); 82 | } 83 | }); 84 | 85 | it('reads fiddles from gists', async () => { 86 | const gistId = '642fa8daaebea6044c9079e3f8a46390'; 87 | const fiddle = await fiddleFactory.create(gistId); 88 | expect(fiddle).toBeTruthy(); 89 | expect(fs.existsSync(fiddle!.mainPath)).toBe(true); 90 | expect(path.basename(fiddle!.mainPath)).toBe('main.js'); 91 | expect(path.dirname(path.dirname(fiddle!.mainPath))).toBe(fiddleDir); 92 | }); 93 | 94 | it('acts as a pass-through when given a fiddle', async () => { 95 | const fiddleIn = new Fiddle('/main/path', 'source'); 96 | const fiddle = await fiddleFactory.create(fiddleIn); 97 | expect(fiddle).toBe(fiddleIn); 98 | }); 99 | 100 | it('packages fiddle into ASAR archive', async () => { 101 | const sourceDir = fiddleFixture('642fa8daaebea6044c9079e3f8a46390'); 102 | const fiddle = await fiddleFactory.create(sourceDir, { 103 | packAsAsar: true, 104 | }); 105 | 106 | function normalizeAsarFiles(files: string[]): string[] { 107 | return files.map( 108 | (f) => f.replace(/^[\\/]/, ''), // Remove leading slash or backslash 109 | ); 110 | } 111 | 112 | // test that app.asar file is created 113 | expect(fiddle).toBeTruthy(); 114 | expect(path.basename(fiddle!.mainPath)).toBe('app.asar'); 115 | 116 | // test that the file list is identical 117 | const dirname: string = fiddle!.mainPath; 118 | const sourceFiles = fs.readdirSync(sourceDir); 119 | const asarFiles = normalizeAsarFiles( 120 | asar.listPackage(dirname, { isPack: false }), 121 | ); 122 | expect(asarFiles).toStrictEqual(sourceFiles); 123 | 124 | // test that the files' contents are identical 125 | for (const file of sourceFiles) { 126 | const sourceFileContent = fs.readFileSync( 127 | path.join(sourceDir, file), 128 | 'utf-8', 129 | ); 130 | const asarFileContent = asar.extractFile(dirname, file).toString(); 131 | expect(asarFileContent).toStrictEqual(sourceFileContent); 132 | } 133 | }); 134 | 135 | it.todo('reads fiddles from git repositories'); 136 | it.todo('refreshes the cache if given a previously-cached git repository'); 137 | 138 | it('returns undefined for unknown input', async () => { 139 | const fiddle = await fiddleFactory.create('fnord'); 140 | expect(fiddle).toBeUndefined(); 141 | }); 142 | }); 143 | }); 144 | 145 | describe('Fiddle', () => { 146 | describe('remove()', () => { 147 | it.todo('removes the fiddle'); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /tests/fixtures/SHASUMS256.txt: -------------------------------------------------------------------------------- 1 | bd9e3c1bc2f36e7fcb735378a45478114a0bcd9b7236573d7d9cd66a9b781eba *electron-v12.0.15-darwin-arm64.zip 2 | bd9e3c1bc2f36e7fcb735378a45478114a0bcd9b7236573d7d9cd66a9b781eba *electron-v12.0.15-darwin-x64.zip 3 | bd9e3c1bc2f36e7fcb735378a45478114a0bcd9b7236573d7d9cd66a9b781eba *electron-v12.0.15-linux-arm64.zip 4 | bd9e3c1bc2f36e7fcb735378a45478114a0bcd9b7236573d7d9cd66a9b781eba *electron-v12.0.15-linux-arm7l.zip 5 | bd9e3c1bc2f36e7fcb735378a45478114a0bcd9b7236573d7d9cd66a9b781eba *electron-v12.0.15-linux-ia32.zip 6 | bd9e3c1bc2f36e7fcb735378a45478114a0bcd9b7236573d7d9cd66a9b781eba *electron-v12.0.15-linux-x64.zip 7 | bd9e3c1bc2f36e7fcb735378a45478114a0bcd9b7236573d7d9cd66a9b781eba *electron-v12.0.15-win32-arm64.zip 8 | bd9e3c1bc2f36e7fcb735378a45478114a0bcd9b7236573d7d9cd66a9b781eba *electron-v12.0.15-win32-ia32.zip 9 | bd9e3c1bc2f36e7fcb735378a45478114a0bcd9b7236573d7d9cd66a9b781eba *electron-v12.0.15-win32-x64.zip 10 | bf24cf6baeaf1f09678894edfe4ec1283de02c9c65d172f0b2e59e8e97b6d5ba *electron-v13.1.7-darwin-arm64.zip 11 | bf24cf6baeaf1f09678894edfe4ec1283de02c9c65d172f0b2e59e8e97b6d5ba *electron-v13.1.7-darwin-x64.zip 12 | bf24cf6baeaf1f09678894edfe4ec1283de02c9c65d172f0b2e59e8e97b6d5ba *electron-v13.1.7-linux-arm64.zip 13 | bf24cf6baeaf1f09678894edfe4ec1283de02c9c65d172f0b2e59e8e97b6d5ba *electron-v13.1.7-linux-arm7l.zip 14 | bf24cf6baeaf1f09678894edfe4ec1283de02c9c65d172f0b2e59e8e97b6d5ba *electron-v13.1.7-linux-ia32.zip 15 | bf24cf6baeaf1f09678894edfe4ec1283de02c9c65d172f0b2e59e8e97b6d5ba *electron-v13.1.7-linux-x64.zip 16 | bf24cf6baeaf1f09678894edfe4ec1283de02c9c65d172f0b2e59e8e97b6d5ba *electron-v13.1.7-win32-arm64.zip 17 | bf24cf6baeaf1f09678894edfe4ec1283de02c9c65d172f0b2e59e8e97b6d5ba *electron-v13.1.7-win32-ia32.zip 18 | bf24cf6baeaf1f09678894edfe4ec1283de02c9c65d172f0b2e59e8e97b6d5ba *electron-v13.1.7-win32-x64.zip 19 | -------------------------------------------------------------------------------- /tests/fixtures/electron-v12.0.15.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electron/fiddle-core/0b1e1e181de09ed34ab68f0d10864a4036b2b68c/tests/fixtures/electron-v12.0.15.zip -------------------------------------------------------------------------------- /tests/fixtures/electron-v13.1.7.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electron/fiddle-core/0b1e1e181de09ed34ab68f0d10864a4036b2b68c/tests/fixtures/electron-v13.1.7.zip -------------------------------------------------------------------------------- /tests/fixtures/fiddles/642fa8daaebea6044c9079e3f8a46390/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Hello World! 9 | 10 | 11 |

Hello World!

12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/fixtures/fiddles/642fa8daaebea6044c9079e3f8a46390/main.js: -------------------------------------------------------------------------------- 1 | // Modules to control application life and create native browser window 2 | const {app, crashReporter, ipcMain, BrowserWindow} = require('electron') 3 | const path = require('path') 4 | 5 | function createWindow () { 6 | // Create the browser window. 7 | const mainWindow = new BrowserWindow({ 8 | webPreferences: { 9 | preload: path.join(__dirname, 'preload.js') 10 | } 11 | }) 12 | mainWindow.loadFile('index.html'); 13 | } 14 | 15 | // This method will be called when Electron has finished 16 | // initialization and is ready to create browser windows. 17 | // Some APIs can only be used after this event occurs. 18 | app.whenReady().then(() => { 19 | 20 | createWindow() 21 | 22 | app.on('activate', function () { 23 | // On macOS it's common to re-create a window in the app when the 24 | // dock icon is clicked and there are no other windows open. 25 | if (BrowserWindow.getAllWindows().length === 0) createWindow() 26 | }) 27 | }) 28 | 29 | // Quit when all windows are closed, except on macOS. There, it's common 30 | // for applications and their menu bar to stay active until the user quits 31 | // explicitly with Cmd + Q. 32 | app.on('window-all-closed', function () { 33 | if (process.platform !== 'darwin') app.quit() 34 | }) 35 | 36 | 37 | function testDone(success, ...logs) { 38 | console.log(`test ${success ? 'passed' : 'failed'}`) 39 | logs.forEach((i) => console.log(i)) 40 | const code = success ? 0 : 1 41 | console.log(`main process exiting with ${code}`); 42 | process.exit(code) 43 | } 44 | 45 | { 46 | if (Number.parseInt(process.versions.electron) >= 9) { 47 | crashReporter.start({ uploadToServer: false, submitURL: '' }) 48 | } 49 | ipcMain.on('test-done', (_, success, ...logs) => testDone(success, ...logs)) 50 | const failIfBadExit = (details) => { 51 | if (details.reason !== 'clean-exit') testDone(false, new Error('trace'), details) 52 | } 53 | app.on('child-process-gone', (_ev, details) => failIfBadExit(details)) 54 | app.on('render-process-gone', (_ev, _, details) => failIfBadExit(details)) 55 | } 56 | -------------------------------------------------------------------------------- /tests/fixtures/fiddles/642fa8daaebea6044c9079e3f8a46390/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mushy-stranger-classify-tmg5b", 3 | "productName": "mushy-stranger-classify-tmg5b", 4 | "description": "My Electron application description", 5 | "keywords": [], 6 | "main": "./main.js", 7 | "version": "1.0.0", 8 | "author": "charles", 9 | "scripts": { 10 | "start": "electron ." 11 | }, 12 | "dependencies": {}, 13 | "devDependencies": { 14 | "electron": "11.4.10" 15 | } 16 | } -------------------------------------------------------------------------------- /tests/fixtures/fiddles/642fa8daaebea6044c9079e3f8a46390/preload.js: -------------------------------------------------------------------------------- 1 | const { contextBridge } = require('electron') 2 | 3 | 4 | // Test helpers 5 | const test = { 6 | assert: (ok, ...logs) => { 7 | if (!ok) test.fail(...logs) 8 | }, 9 | fail: (...logs) => test.done(false, ...logs), 10 | done: (success = true, ...logs) => { 11 | if (!success) logs.unshift(new Error('test failed')) 12 | require('electron').ipcRenderer.send('test-done', success, ...logs) 13 | process.exit(0) 14 | }, 15 | } 16 | 17 | const verstr = process.versions.electron; 18 | const ver = verstr.split('-', 1)[0].split('.').map((tok) => +tok).reduce((acc, cur) => acc * 100 + cur, 0); 19 | console.log(verstr, ver); 20 | test.assert(ver < 120002); // < 12.0.2 21 | test.done(); 22 | 23 | contextBridge.exposeInMainWorld('test', test) -------------------------------------------------------------------------------- /tests/fixtures/fiddles/642fa8daaebea6044c9079e3f8a46390/renderer.js: -------------------------------------------------------------------------------- 1 | // This file is required by the index.html file and will 2 | // be executed in the renderer process for that window. 3 | // No Node.js APIs are available in this process because 4 | // `nodeIntegration` is turned off. Use `preload.js` to 5 | // selectively enable features needed in the rendering 6 | // process. 7 | -------------------------------------------------------------------------------- /tests/fixtures/fiddles/642fa8daaebea6044c9079e3f8a46390/styles.css: -------------------------------------------------------------------------------- 1 | /* Empty */ -------------------------------------------------------------------------------- /tests/installer.test.ts: -------------------------------------------------------------------------------- 1 | import extract from 'extract-zip'; 2 | import * as fs from 'fs-extra'; 3 | import * as os from 'os'; 4 | import * as path from 'path'; 5 | import nock, { Scope } from 'nock'; 6 | 7 | import { 8 | ElectronBinary, 9 | InstallStateEvent, 10 | Installer, 11 | Paths, 12 | InstallState, 13 | } from '../src/index'; 14 | 15 | jest.mock('extract-zip'); 16 | 17 | const extractZip = jest.requireActual('extract-zip'); 18 | 19 | describe('Installer', () => { 20 | let tmpdir: string; 21 | let paths: Pick; 22 | let nockScope: Scope; 23 | let installer: Installer; 24 | const { missing, downloading, downloaded, installing, installed } = 25 | InstallState; 26 | const version12 = '12.0.15' as const; 27 | const version13 = '13.1.7' as const; 28 | const version = version13; 29 | const fixture = (name: string) => path.join(__dirname, 'fixtures', name); 30 | 31 | beforeEach(async () => { 32 | jest 33 | .mocked(extract) 34 | .mockImplementation(async (zipPath: string, opts: extract.Options) => { 35 | await extractZip(zipPath, opts); 36 | }); 37 | tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), 'fiddle-core-')); 38 | paths = { 39 | electronDownloads: path.join(tmpdir, 'downloads'), 40 | electronInstall: path.join(tmpdir, 'install'), 41 | }; 42 | installer = new Installer(paths); 43 | 44 | nock.disableNetConnect(); 45 | nockScope = nock('https://github.com:443'); 46 | nockScope 47 | .persist() 48 | .get(/electron-v13.1.7-.*\.zip$/) 49 | .replyWithFile(200, fixture('electron-v13.1.7.zip'), { 50 | 'Content-Type': 'application/zip', 51 | }) 52 | .get(/electron-v12.0.15-.*\.zip$/) 53 | .replyWithFile(200, fixture('electron-v12.0.15.zip'), { 54 | 'Content-Type': 'application/zip', 55 | }) 56 | .get(/SHASUMS256\.txt$/) 57 | .replyWithFile(200, fixture('SHASUMS256.txt'), { 58 | 'Content-Type': 'text/plain;charset=UTF-8', 59 | }); 60 | }); 61 | 62 | afterEach(() => { 63 | nock.cleanAll(); 64 | nock.enableNetConnect(); 65 | fs.removeSync(tmpdir); 66 | }); 67 | 68 | // test helpers 69 | 70 | async function listenWhile( 71 | installer: Installer, 72 | func: () => Promise, 73 | ) { 74 | const events: InstallStateEvent[] = []; 75 | const listener = (ev: InstallStateEvent) => events.push(ev); 76 | const event = 'state-changed'; 77 | installer.on(event, listener); 78 | const result = await func(); 79 | installer.removeListener(event, listener); 80 | return { events, result }; 81 | } 82 | 83 | async function doRemove(installer: Installer, version: string) { 84 | const func = () => installer.remove(version); 85 | const { events } = await listenWhile(installer, func); 86 | 87 | expect(installer.state(version)).toBe(missing); 88 | 89 | return { events }; 90 | } 91 | 92 | async function doInstall(installer: Installer, version: string) { 93 | let isDownloaded = false; 94 | const progressCallback = () => { 95 | isDownloaded = true; 96 | }; 97 | 98 | // Version is already downloaded and present in local 99 | if (installer.state(version) !== missing) { 100 | isDownloaded = true; 101 | } 102 | const func = () => installer.install(version, { progressCallback }); 103 | const { events, result } = await listenWhile(installer, func); 104 | const exec = result as string; 105 | 106 | const installedVersion = fs 107 | .readFileSync(path.join(paths.electronInstall, 'version'), 'utf-8') 108 | .trim(); 109 | 110 | expect(isDownloaded).toBe(true); 111 | expect(installer.state(version)).toBe(installed); 112 | expect(installer.installedVersion).toBe(version); 113 | expect(installedVersion).toBe(version); 114 | 115 | return { events, exec }; 116 | } 117 | 118 | async function doDownload(installer: Installer, version: string) { 119 | let isDownloaded = false; 120 | const progressCallback = () => { 121 | isDownloaded = true; 122 | }; 123 | 124 | // Version is already downloaded and present in local 125 | if (installer.state(version) !== missing) { 126 | isDownloaded = true; 127 | } 128 | const func = () => 129 | installer.ensureDownloaded(version, { 130 | progressCallback, 131 | }); 132 | const { events, result } = await listenWhile(installer, func); 133 | const binaryConfig = result as ElectronBinary; 134 | const { path: zipfile } = binaryConfig; 135 | 136 | expect(isDownloaded).toBe(true); 137 | expect(fs.existsSync(zipfile)).toBe(true); 138 | expect(installer.state(version)).toBe(downloaded); 139 | 140 | return { events, binaryConfig }; 141 | } 142 | 143 | async function unZipBinary(): Promise { 144 | const extractDir = path.join(paths.electronDownloads, version); 145 | fs.mkdirSync(extractDir, { recursive: true }); 146 | 147 | await extract(fixture('electron-v13.1.7.zip'), { 148 | dir: extractDir, 149 | }); 150 | 151 | return extractDir; 152 | } 153 | 154 | // tests 155 | 156 | describe('getExecPath()', () => { 157 | it.each([ 158 | ['Linux', 'linux', 'electron'], 159 | ['Windows', 'win32', 'electron.exe'], 160 | ['macOS', 'darwin', 'Electron.app/Contents/MacOS/Electron'], 161 | ])( 162 | 'returns the right path on %s', 163 | (_, platform: string, expected: string) => { 164 | const subpath = Installer.execSubpath(platform); 165 | expect(subpath).toBe(expected); 166 | }, 167 | ); 168 | }); 169 | 170 | describe('ensureDownloaded()', () => { 171 | it('downloads the version if needed', async () => { 172 | // setup: version is not installed 173 | expect(installer.state(version)).toBe(missing); 174 | 175 | // test that the zipfile was downloaded 176 | const { events, binaryConfig } = await doDownload(installer, version); 177 | expect(events).toStrictEqual([ 178 | { version, state: downloading }, 179 | { version, state: downloaded }, 180 | ]); 181 | expect(binaryConfig).toHaveProperty('alreadyExtracted', false); 182 | }); 183 | 184 | it('does nothing if the version is already downloaded', async () => { 185 | // setup: version is already installed 186 | const { binaryConfig: config1 } = await doDownload(installer, version); 187 | const { path: zip1 } = config1; 188 | const { ctimeMs } = await fs.stat(zip1); 189 | 190 | // test that ensureDownloaded() did nothing: 191 | const { events, binaryConfig: config2 } = await doDownload( 192 | installer, 193 | version, 194 | ); 195 | const { path: zip2 } = config2; 196 | 197 | expect(zip2).toEqual(zip1); 198 | expect((await fs.stat(zip2)).ctimeMs).toEqual(ctimeMs); 199 | expect(events).toStrictEqual([]); 200 | expect(config1).toStrictEqual({ 201 | path: config2.path, 202 | alreadyExtracted: false, 203 | }); 204 | }); 205 | 206 | it('makes use of the preinstalled electron versions', async () => { 207 | const extractDir = await unZipBinary(); 208 | const { 209 | binaryConfig: { path: zipFile }, 210 | } = await doDownload(installer, version); 211 | // Purposely remove the downloaded zip file 212 | fs.removeSync(zipFile); 213 | 214 | const { binaryConfig } = await doDownload(installer, version); 215 | 216 | expect(binaryConfig).toStrictEqual({ 217 | path: extractDir, 218 | alreadyExtracted: true, 219 | }); 220 | expect(installer.state(version)).toBe(downloaded); 221 | }); 222 | 223 | it('downloads the version if the zip file is missing', async () => { 224 | const { 225 | binaryConfig: { path: zipFile }, 226 | } = await doDownload(installer, version); 227 | // Purposely remove the downloaded zip file 228 | fs.removeSync(zipFile); 229 | expect(installer.state(version)).toBe(downloaded); 230 | 231 | // test that the zipfile was downloaded 232 | const { events, binaryConfig } = await doDownload(installer, version); 233 | expect(events).toStrictEqual([ 234 | { version, state: downloading }, 235 | { version, state: downloaded }, 236 | ]); 237 | expect(binaryConfig).toHaveProperty('alreadyExtracted', false); 238 | expect(nockScope.isDone()); 239 | }); 240 | 241 | it('resets install state on error', async () => { 242 | // setup: version is not installed 243 | expect(installer.state(version)).toBe(missing); 244 | 245 | nock.cleanAll(); 246 | nockScope.get(/.*/).replyWithError('Server Error'); 247 | 248 | await expect(doDownload(installer, version)).rejects.toThrow(Error); 249 | expect(installer.state(version)).toBe(missing); 250 | 251 | expect(nockScope.isDone()); 252 | }); 253 | }); 254 | 255 | describe('remove()', () => { 256 | it('removes a download', async () => { 257 | // setup: version is already installed 258 | await doDownload(installer, version); 259 | 260 | const { events } = await doRemove(installer, version); 261 | expect(events).toStrictEqual([{ version, state: missing }]); 262 | }); 263 | 264 | it('does nothing if the version is missing', async () => { 265 | // setup: version is not installed 266 | expect(installer.state(version)).toBe(missing); 267 | 268 | const { events } = await doRemove(installer, version); 269 | expect(events).toStrictEqual([]); 270 | }); 271 | 272 | it('uninstalls the version if it is installed', async () => { 273 | // setup: version is installed 274 | await doInstall(installer, version); 275 | 276 | const { events } = await doRemove(installer, version); 277 | expect(events).toStrictEqual([{ version, state: missing }]); 278 | expect(installer.installedVersion).toBe(undefined); 279 | }); 280 | 281 | it('removes the preinstalled electron versions', async () => { 282 | const extractDir = await unZipBinary(); 283 | const { 284 | binaryConfig: { path: zipFile }, 285 | } = await doDownload(installer, version); 286 | // Purposely remove the downloaded zip file 287 | fs.removeSync(zipFile); 288 | expect(installer.state(version)).toBe(downloaded); 289 | 290 | const { events } = await doRemove(installer, version); 291 | 292 | expect(fs.existsSync(extractDir)).toBe(false); 293 | expect(events).toStrictEqual([{ version, state: missing }]); 294 | }); 295 | }); 296 | 297 | describe('install()', () => { 298 | it('downloads a version if necessary', async () => { 299 | // setup: version is not downloaded 300 | expect(installer.state(version)).toBe(missing); 301 | expect(installer.installedVersion).toBe(undefined); 302 | 303 | const { events } = await doInstall(installer, version); 304 | expect(events).toStrictEqual([ 305 | { version, state: downloading }, 306 | { version, state: downloaded }, 307 | { version, state: installing }, 308 | { version, state: installed }, 309 | ]); 310 | }); 311 | 312 | it('unzips a version if necessary', async () => { 313 | // setup: version is downloaded but not installed 314 | await doDownload(installer, version); 315 | expect(installer.state(version)).toBe(downloaded); 316 | 317 | const { events } = await doInstall(installer, version); 318 | expect(events).toStrictEqual([ 319 | { version, state: installing }, 320 | { version, state: installed }, 321 | ]); 322 | }); 323 | 324 | it('does nothing if already installed', async () => { 325 | await doInstall(installer, version); 326 | 327 | const { events } = await doInstall(installer, version); 328 | expect(events).toStrictEqual([]); 329 | }); 330 | 331 | it('replaces the previous installation', async () => { 332 | await doInstall(installer, version12); 333 | 334 | const { events } = await doInstall(installer, version13); 335 | 336 | expect(events).toStrictEqual([ 337 | { version: version13, state: downloading }, 338 | { version: version13, state: downloaded }, 339 | { version: version13, state: installing }, 340 | { version: version12, state: downloaded }, 341 | { version: version13, state: installed }, 342 | ]); 343 | }); 344 | 345 | it('installs the already extracted electron version', async () => { 346 | await unZipBinary(); 347 | const { 348 | binaryConfig: { path: zipFile }, 349 | } = await doDownload(installer, version); 350 | 351 | // Purposely remove the downloaded zip file 352 | fs.removeSync(zipFile); 353 | expect(installer.state(version)).toBe(downloaded); 354 | const { events } = await doInstall(installer, version); 355 | expect(events).toStrictEqual([ 356 | { version: '13.1.7', state: 'installing' }, 357 | { version: '13.1.7', state: 'installed' }, 358 | ]); 359 | }); 360 | 361 | it('throws error if already installing', async () => { 362 | const promise = doInstall(installer, version); 363 | try { 364 | await expect(doInstall(installer, version)).rejects.toThrow( 365 | 'Currently installing', 366 | ); 367 | } finally { 368 | await promise; 369 | } 370 | }); 371 | 372 | it('leaves a valid state after an error', async () => { 373 | // setup: version is not installed 374 | expect(installer.state(version)).toBe(missing); 375 | 376 | const spy = jest 377 | .spyOn(installer, 'ensureDownloaded') 378 | .mockRejectedValueOnce(new Error('Download failed')); 379 | await expect(doInstall(installer, version)).rejects.toThrow(Error); 380 | expect(installer.state(version)).toBe(missing); 381 | spy.mockRestore(); 382 | 383 | const { events } = await doInstall(installer, version); 384 | expect(events).toStrictEqual([ 385 | { version, state: downloading }, 386 | { version, state: downloaded }, 387 | { version, state: installing }, 388 | { version, state: installed }, 389 | ]); 390 | }); 391 | 392 | it('resets install state on error', async () => { 393 | // setup: version is downloaded but not installed 394 | await doDownload(installer, version); 395 | expect(installer.state(version)).toBe(downloaded); 396 | 397 | jest.mocked(extract).mockRejectedValue(new Error('Extract error')); 398 | 399 | await expect(doInstall(installer, version)).rejects.toThrow(Error); 400 | expect(installer.state(version)).toBe(downloaded); 401 | }); 402 | }); 403 | 404 | describe('installedVersion', () => { 405 | it('returns undefined if no version is installed', () => { 406 | expect(installer.installedVersion).toBe(undefined); 407 | }); 408 | 409 | it('returns the installed version', async () => { 410 | expect(installer.installedVersion).toBe(undefined); 411 | await doInstall(installer, version); 412 | expect(installer.installedVersion).toBe(version); 413 | }); 414 | }); 415 | 416 | describe('state()', () => { 417 | it("returns 'installed' if the version is installed", async () => { 418 | await doInstall(installer, version); 419 | expect(installer.state(version)).toBe(installed); 420 | }); 421 | 422 | it("returns 'downloaded' if the version is downloaded", async () => { 423 | await doDownload(installer, version); 424 | expect(installer.state(version)).toBe(downloaded); 425 | }); 426 | 427 | it("returns 'missing' if the version is not downloaded", () => { 428 | expect(installer.state(version)).toBe(missing); 429 | }); 430 | 431 | it("returns 'downloaded' if the version is kept extracted", async () => { 432 | expect(installer.state(version)).toBe(missing); 433 | await unZipBinary(); 434 | const { 435 | binaryConfig: { path: zipFile }, 436 | } = await doDownload(installer, version); 437 | // Purposely remove the downloaded zip file 438 | fs.removeSync(zipFile); 439 | await doDownload(installer, version); 440 | 441 | expect(installer.state(version)).toBe(downloaded); 442 | }); 443 | }); 444 | }); 445 | -------------------------------------------------------------------------------- /tests/runner.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Installer, 3 | FiddleFactory, 4 | Runner, 5 | TestResult, 6 | FiddleFactoryCreateOptions, 7 | } from '../src/index'; 8 | import child_process from 'child_process'; 9 | import { EventEmitter } from 'events'; 10 | import * as fs from 'fs-extra'; 11 | import * as path from 'path'; 12 | import * as os from 'os'; 13 | import { Writable } from 'stream'; 14 | 15 | jest.mock('child_process'); 16 | 17 | const mockStdout = jest.fn(); 18 | 19 | const mockSubprocess = { 20 | on: jest.fn(), 21 | stdout: { 22 | on: jest.fn(), 23 | pipe: jest.fn(), 24 | }, 25 | stderr: { 26 | on: jest.fn(), 27 | pipe: jest.fn(), 28 | }, 29 | }; 30 | 31 | interface FakeRunnerOpts { 32 | pathToExecutable?: string; 33 | generatedFiddle?: { 34 | source: string; 35 | mainPath: string; 36 | } | null; 37 | } 38 | 39 | let tmpdir: string; 40 | let versionsCache: string; 41 | 42 | beforeAll(async () => { 43 | tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), 'fiddle-core-')); 44 | 45 | // Copy the releases.json fixture over to populate the versions cache 46 | versionsCache = path.join(tmpdir, 'versions.json'); 47 | const filename = path.join(__dirname, 'fixtures', 'releases.json'); 48 | await fs.outputJSON(versionsCache, await fs.readJson(filename)); 49 | }); 50 | 51 | afterAll(() => { 52 | fs.removeSync(tmpdir); 53 | }); 54 | 55 | async function createFakeRunner({ 56 | pathToExecutable = '/path/to/electron/executable', 57 | generatedFiddle = { 58 | source: 'https://gist.github.com/642fa8daaebea6044c9079e3f8a46390.git', 59 | mainPath: '/path/to/fiddle/', 60 | }, 61 | }: FakeRunnerOpts) { 62 | const runner = await Runner.create({ 63 | installer: { 64 | install: jest.fn().mockResolvedValue(pathToExecutable), 65 | } as Pick as Installer, 66 | fiddleFactory: { 67 | create: jest 68 | .fn() 69 | .mockImplementation((_, options?: FiddleFactoryCreateOptions) => { 70 | if (options?.packAsAsar) 71 | return Promise.resolve({ 72 | ...generatedFiddle, 73 | mainPath: '/path/to/fiddle/app.asar', 74 | }); 75 | return Promise.resolve(generatedFiddle); 76 | }), 77 | } as Pick as FiddleFactory, 78 | paths: { 79 | versionsCache, 80 | }, 81 | }); 82 | 83 | return runner; 84 | } 85 | 86 | describe('Runner', () => { 87 | describe('displayResult()', () => { 88 | it('returns the correct message for each test result status', () => { 89 | expect(Runner.displayResult({ status: 'test_passed' })).toBe('🟢 passed'); 90 | expect(Runner.displayResult({ status: 'test_failed' })).toBe('🔴 failed'); 91 | expect(Runner.displayResult({ status: 'test_error' })).toBe( 92 | '🔵 test error: test did not pass or fail', 93 | ); 94 | expect(Runner.displayResult({ status: 'system_error' })).toBe( 95 | '🟠 system error: test did not pass or fail', 96 | ); 97 | }); 98 | }); 99 | 100 | describe('create()', () => { 101 | it('creates a Runner object with the expected properties', async () => { 102 | const runner = await Runner.create(); 103 | expect(Object.keys(runner)).toEqual([ 104 | 'installer', 105 | 'versions', 106 | 'fiddleFactory', 107 | 'osInfo', 108 | 'spawnInfo', 109 | ]); 110 | }); 111 | }); 112 | 113 | describe('spawn()', () => { 114 | it('spawns a subprocess and prints debug information to stdout', async () => { 115 | const runner = await createFakeRunner({}); 116 | 117 | (child_process.spawn as jest.Mock).mockReturnValueOnce(mockSubprocess); 118 | 119 | await runner.spawn('12.0.1', '642fa8daaebea6044c9079e3f8a46390', { 120 | out: { 121 | write: mockStdout, 122 | } as Pick as Writable, 123 | }); 124 | expect(child_process.spawn).toHaveBeenCalledTimes(1); 125 | expect(child_process.spawn).toHaveBeenCalledWith( 126 | '/path/to/electron/executable', 127 | ['/path/to/fiddle/'], 128 | { 129 | args: [], 130 | headless: false, 131 | out: expect.any(Object) as Writable, 132 | showConfig: true, 133 | }, 134 | ); 135 | 136 | expect(mockSubprocess.stderr.pipe).toHaveBeenCalledWith({ 137 | write: mockStdout, 138 | }); 139 | expect(mockSubprocess.stdout.pipe).toHaveBeenCalledWith({ 140 | write: mockStdout, 141 | }); 142 | expect(mockStdout).toHaveBeenCalledTimes(1); 143 | }); 144 | 145 | (process.platform === 'linux' ? it : it.skip)( 146 | 'can spawn a subprocess in headless mode on Linux', 147 | async function () { 148 | const runner = await createFakeRunner({}); 149 | (child_process.spawn as jest.Mock).mockReturnValueOnce(mockSubprocess); 150 | 151 | await runner.spawn('12.0.1', '642fa8daaebea6044c9079e3f8a46390', { 152 | headless: true, 153 | out: { 154 | write: mockStdout, 155 | } as Pick as Writable, 156 | }); 157 | expect(child_process.spawn).toHaveBeenCalledTimes(1); 158 | expect(child_process.spawn).toHaveBeenCalledWith( 159 | 'xvfb-run', 160 | [ 161 | '--auto-servernum', 162 | '/path/to/electron/executable', 163 | '/path/to/fiddle/', 164 | ], 165 | { 166 | args: [], 167 | headless: true, 168 | out: expect.any(Object) as Writable, 169 | showConfig: true, 170 | }, 171 | ); 172 | }, 173 | ); 174 | 175 | it('hides the debug output if showConfig is false', async () => { 176 | const runner = await createFakeRunner({}); 177 | (child_process.spawn as jest.Mock).mockReturnValueOnce(mockSubprocess); 178 | 179 | await runner.spawn('12.0.1', '642fa8daaebea6044c9079e3f8a46390', { 180 | out: { 181 | write: mockStdout, 182 | } as Pick as Writable, 183 | showConfig: false, 184 | }); 185 | 186 | expect(mockStdout).not.toHaveBeenCalled(); 187 | }); 188 | 189 | it('throws on invalid fiddle', async () => { 190 | const runner = await createFakeRunner({ 191 | generatedFiddle: null, 192 | }); 193 | (child_process.spawn as jest.Mock).mockReturnValueOnce(mockSubprocess); 194 | 195 | await expect(runner.spawn('12.0.1', 'invalid-fiddle')).rejects.toEqual( 196 | new Error(`Invalid fiddle: "'invalid-fiddle'"`), 197 | ); 198 | }); 199 | 200 | it('spawns a subprocess with ASAR path when runFromAsar is true', async () => { 201 | const runner = await createFakeRunner({}); 202 | (child_process.spawn as jest.Mock).mockReturnValueOnce(mockSubprocess); 203 | 204 | await runner.spawn('12.0.1', '642fa8daaebea6044c9079e3f8a46390', { 205 | out: { 206 | write: mockStdout, 207 | } as Pick as Writable, 208 | runFromAsar: true, 209 | }); 210 | 211 | expect(child_process.spawn).toHaveBeenCalledTimes(1); 212 | expect(child_process.spawn).toHaveBeenCalledWith( 213 | '/path/to/electron/executable', 214 | ['/path/to/fiddle/app.asar'], 215 | expect.anything(), 216 | ); 217 | }); 218 | }); 219 | 220 | describe('run()', () => { 221 | it.each([ 222 | ['test_passed', 'exit', 0], 223 | ['test_failed', 'exit', 1], 224 | ['test_error', 'exit', 999], 225 | ['system_error', 'error', 1], 226 | ])( 227 | 'can handle a test with the `%s` status', 228 | async (status, event, exitCode) => { 229 | const runner = await Runner.create(); 230 | const fakeSubprocess = new EventEmitter(); 231 | runner.spawn = jest.fn().mockResolvedValue(fakeSubprocess); 232 | 233 | // delay to ensure that the listeners in run() are set up. 234 | process.nextTick(() => { 235 | fakeSubprocess.emit(event, exitCode); 236 | }); 237 | 238 | const result = await runner.run('fake', 'parameters'); 239 | expect(result).toStrictEqual({ status }); 240 | }, 241 | ); 242 | }); 243 | 244 | describe('bisect()', () => { 245 | it('can bisect a test (right side range)', async () => { 246 | const runner = await createFakeRunner({}); 247 | const resultMap: Map = new Map([ 248 | ['12.0.0', { status: 'test_passed' }], 249 | ['12.0.1', { status: 'test_passed' }], 250 | ['12.0.2', { status: 'test_passed' }], 251 | ['12.0.3', { status: 'test_passed' }], 252 | ['12.0.4', { status: 'test_passed' }], 253 | ['12.0.5', { status: 'test_failed' }], 254 | ]); 255 | runner.run = jest.fn((version) => { 256 | return new Promise((resolve) => 257 | resolve(resultMap.get(version as string) as TestResult), 258 | ); 259 | }); 260 | 261 | const result = await runner.bisect( 262 | '12.0.0', 263 | '12.0.5', 264 | '642fa8daaebea6044c9079e3f8a46390', 265 | ); 266 | expect(result).toStrictEqual({ 267 | range: ['12.0.4', '12.0.5'], 268 | status: 'bisect_succeeded', 269 | }); 270 | }); 271 | 272 | it('can bisect a test (left side range)', async () => { 273 | const runner = await createFakeRunner({}); 274 | const resultMap: Map = new Map([ 275 | ['12.0.0', { status: 'test_passed' }], 276 | ['12.0.1', { status: 'test_passed' }], 277 | ['12.0.2', { status: 'test_failed' }], 278 | ['12.0.3', { status: 'test_failed' }], 279 | ['12.0.4', { status: 'test_failed' }], 280 | ['12.0.5', { status: 'test_failed' }], 281 | ]); 282 | runner.run = jest.fn((version) => { 283 | return new Promise((resolve) => 284 | resolve(resultMap.get(version as string) as TestResult), 285 | ); 286 | }); 287 | 288 | const result = await runner.bisect( 289 | '12.0.0', 290 | '12.0.5', 291 | '642fa8daaebea6044c9079e3f8a46390', 292 | ); 293 | expect(result).toStrictEqual({ 294 | range: ['12.0.1', '12.0.2'], 295 | status: 'bisect_succeeded', 296 | }); 297 | }); 298 | 299 | it('can handle the trivial case', async () => { 300 | const runner = await createFakeRunner({}); 301 | const resultMap: Map = new Map([ 302 | ['12.0.0', { status: 'test_passed' }], 303 | ['12.0.1', { status: 'test_failed' }], 304 | ]); 305 | runner.run = jest.fn((version) => { 306 | return new Promise((resolve) => 307 | resolve(resultMap.get(version as string) as TestResult), 308 | ); 309 | }); 310 | 311 | const result = await runner.bisect( 312 | '12.0.0', 313 | '12.0.1', 314 | '642fa8daaebea6044c9079e3f8a46390', 315 | ); 316 | 317 | expect(result).toStrictEqual({ 318 | range: ['12.0.0', '12.0.1'], 319 | status: 'bisect_succeeded', 320 | }); 321 | }); 322 | 323 | it('throws on invalid fiddle', async () => { 324 | const runner = await createFakeRunner({ 325 | generatedFiddle: null, 326 | }); 327 | 328 | await expect( 329 | runner.bisect('12.0.0', '12.0.5', 'invalid-fiddle'), 330 | ).rejects.toEqual(new Error(`Invalid fiddle: "'invalid-fiddle'"`)); 331 | }); 332 | 333 | it.each([['test_error' as const], ['system_error' as const]])( 334 | 'returns %s status if encountered during a test', 335 | async (status) => { 336 | const runner = await createFakeRunner({}); 337 | const resultMap: Map = new Map([ 338 | ['12.0.0', { status }], 339 | ['12.0.1', { status }], 340 | ['12.0.2', { status }], 341 | ]); 342 | runner.run = jest.fn((version) => { 343 | return new Promise((resolve) => 344 | resolve(resultMap.get(version as string) as TestResult), 345 | ); 346 | }); 347 | 348 | const result = await runner.bisect( 349 | '12.0.0', 350 | '12.0.2', 351 | '642fa8daaebea6044c9079e3f8a46390', 352 | ); 353 | 354 | expect(result).toStrictEqual({ status }); 355 | }, 356 | ); 357 | 358 | it.todo('returns a system_error if no other return condition was met'); 359 | }); 360 | }); 361 | -------------------------------------------------------------------------------- /tests/versions.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra'; 2 | import nock, { Scope } from 'nock'; 3 | import * as os from 'os'; 4 | import * as path from 'path'; 5 | import * as semver from 'semver'; 6 | 7 | import { BaseVersions, ElectronVersions } from '../src/versions'; 8 | 9 | describe('BaseVersions', () => { 10 | let testVersions: BaseVersions; 11 | 12 | beforeEach(async () => { 13 | const filename = path.join(__dirname, 'fixtures', 'releases.json'); 14 | const json = (await fs.readJson(filename)) as unknown; 15 | testVersions = new BaseVersions(json); 16 | }); 17 | 18 | describe('.versions', () => { 19 | it('returns the expected versions', () => { 20 | const { versions } = testVersions; 21 | expect(versions.length).toBe(1061); 22 | expect(versions).toContainEqual( 23 | expect.objectContaining({ version: '13.0.1' }), 24 | ); 25 | expect(versions).not.toContainEqual( 26 | expect.objectContaining({ version: '13.0.2' }), 27 | ); 28 | }); 29 | }); 30 | 31 | describe('majors', () => { 32 | it('returns the expected prerelease majors', () => { 33 | const { prereleaseMajors } = testVersions; 34 | expect(prereleaseMajors).toEqual([14, 15, 16]); 35 | }); 36 | 37 | it('returns stable majors in sorted order', () => { 38 | const { stableMajors } = testVersions; 39 | expect(stableMajors).toEqual([ 40 | 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 41 | ]); 42 | }); 43 | 44 | it('returns supported majors in sorted order', () => { 45 | const { supportedMajors } = testVersions; 46 | expect(supportedMajors).toEqual([11, 12, 13]); 47 | }); 48 | 49 | it('returns obsolete majors in sorted order', () => { 50 | const { obsoleteMajors } = testVersions; 51 | expect(obsoleteMajors).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); 52 | }); 53 | }); 54 | 55 | describe('latest', () => { 56 | it('returns the latest release', () => { 57 | expect(testVersions.latest).not.toBe(undefined); 58 | expect(testVersions.latest!.version).toBe('16.0.0-nightly.20210726'); 59 | }); 60 | }); 61 | 62 | describe('latestStable', () => { 63 | it('returns the latest stable release', () => { 64 | expect(testVersions.latestStable).not.toBe(undefined); 65 | expect(testVersions.latestStable!.version).toBe('13.1.7'); 66 | }); 67 | }); 68 | 69 | describe('inRange()', () => { 70 | it('returns all the versions in a range', () => { 71 | let range = testVersions.inRange('12.0.0', '12.0.15'); 72 | expect(range.length).toBe(16); 73 | expect(range.shift()!.version).toBe('12.0.0'); 74 | expect(range.pop()!.version).toBe('12.0.15'); 75 | 76 | range = testVersions.inRange( 77 | semver.parse('12.0.0')!, 78 | semver.parse('12.0.15')!, 79 | ); 80 | expect(range.length).toBe(16); 81 | expect(range.shift()!.version).toBe('12.0.0'); 82 | expect(range.pop()!.version).toBe('12.0.15'); 83 | }); 84 | }); 85 | 86 | describe('inMajor()', () => { 87 | it('returns all the versions in a branch', () => { 88 | const range = testVersions.inMajor(10); 89 | expect(range.length).toBe(101); 90 | expect(range.shift()!.version).toBe('10.0.0-nightly.20200209'); 91 | expect(range.pop()!.version).toBe('10.4.7'); 92 | }); 93 | }); 94 | 95 | describe('isVersion()', () => { 96 | it('returns true for existing versions', () => { 97 | expect(testVersions.isVersion('13.0.1')).toBe(true); 98 | expect(testVersions.isVersion('13.0.2')).toBe(false); 99 | expect(testVersions.isVersion(semver.parse('13.0.1')!)).toBe(true); 100 | expect(testVersions.isVersion(semver.parse('13.0.2')!)).toBe(false); 101 | }); 102 | }); 103 | 104 | describe('getLatestVersion()', () => { 105 | it('returns the latest version', () => { 106 | const latest = '16.0.0-nightly.20210726'; 107 | expect(testVersions.latest).not.toBe(undefined); 108 | expect(testVersions.latest!.version).toBe(latest); 109 | }); 110 | }); 111 | 112 | describe('getVersionsInRange', () => { 113 | it('includes the expected versions', () => { 114 | const first = '10.0.0'; 115 | const last = '11.0.0'; 116 | const expected = [ 117 | '10.0.0', 118 | '10.0.1', 119 | '10.1.0', 120 | '10.1.1', 121 | '10.1.2', 122 | '10.1.3', 123 | '10.1.4', 124 | '10.1.5', 125 | '10.1.6', 126 | '10.1.7', 127 | '10.2.0', 128 | '10.3.0', 129 | '10.3.1', 130 | '10.3.2', 131 | '10.4.0', 132 | '10.4.1', 133 | '10.4.2', 134 | '10.4.3', 135 | '10.4.4', 136 | '10.4.5', 137 | '10.4.6', 138 | '10.4.7', 139 | '11.0.0-nightly.20200525', 140 | '11.0.0-nightly.20200526', 141 | '11.0.0-nightly.20200529', 142 | '11.0.0-nightly.20200602', 143 | '11.0.0-nightly.20200603', 144 | '11.0.0-nightly.20200604', 145 | '11.0.0-nightly.20200609', 146 | '11.0.0-nightly.20200610', 147 | '11.0.0-nightly.20200611', 148 | '11.0.0-nightly.20200615', 149 | '11.0.0-nightly.20200616', 150 | '11.0.0-nightly.20200617', 151 | '11.0.0-nightly.20200618', 152 | '11.0.0-nightly.20200619', 153 | '11.0.0-nightly.20200701', 154 | '11.0.0-nightly.20200702', 155 | '11.0.0-nightly.20200703', 156 | '11.0.0-nightly.20200706', 157 | '11.0.0-nightly.20200707', 158 | '11.0.0-nightly.20200708', 159 | '11.0.0-nightly.20200709', 160 | '11.0.0-nightly.20200716', 161 | '11.0.0-nightly.20200717', 162 | '11.0.0-nightly.20200720', 163 | '11.0.0-nightly.20200721', 164 | '11.0.0-nightly.20200723', 165 | '11.0.0-nightly.20200724', 166 | '11.0.0-nightly.20200729', 167 | '11.0.0-nightly.20200730', 168 | '11.0.0-nightly.20200731', 169 | '11.0.0-nightly.20200803', 170 | '11.0.0-nightly.20200804', 171 | '11.0.0-nightly.20200805', 172 | '11.0.0-nightly.20200811', 173 | '11.0.0-nightly.20200812', 174 | '11.0.0-nightly.20200822', 175 | '11.0.0-nightly.20200824', 176 | '11.0.0-nightly.20200825', 177 | '11.0.0-nightly.20200826', 178 | '11.0.0-beta.1', 179 | '11.0.0-beta.3', 180 | '11.0.0-beta.4', 181 | '11.0.0-beta.5', 182 | '11.0.0-beta.6', 183 | '11.0.0-beta.7', 184 | '11.0.0-beta.8', 185 | '11.0.0-beta.9', 186 | '11.0.0-beta.11', 187 | '11.0.0-beta.12', 188 | '11.0.0-beta.13', 189 | '11.0.0-beta.16', 190 | '11.0.0-beta.17', 191 | '11.0.0-beta.18', 192 | '11.0.0-beta.19', 193 | '11.0.0-beta.20', 194 | '11.0.0-beta.22', 195 | '11.0.0-beta.23', 196 | '11.0.0', 197 | ] as const; 198 | 199 | let sems = testVersions.inRange(first, last); 200 | expect(sems.map((sem) => sem.version)).toEqual(expected); 201 | sems = testVersions.inRange(last, first); 202 | expect(sems.map((sem) => sem.version)).toEqual(expected); 203 | }); 204 | }); 205 | 206 | describe('getReleaseInfo()', () => { 207 | it('returns release info for a known version', () => { 208 | const version = '16.0.0-nightly.20210726'; 209 | const releaseInfo = testVersions.getReleaseInfo(version); 210 | expect(releaseInfo).not.toBe(undefined); 211 | expect(releaseInfo).toMatchObject({ 212 | version, 213 | chrome: '93.0.4566.0', 214 | date: '2021-07-26', 215 | files: [ 216 | 'darwin-x64', 217 | 'darwin-x64-symbols', 218 | 'linux-ia32', 219 | 'linux-ia32-symbols', 220 | 'linux-x64', 221 | 'linux-x64-symbols', 222 | 'win32-ia32', 223 | 'win32-ia32-symbols', 224 | 'win32-x64', 225 | 'win32-x64-symbols', 226 | ], 227 | modules: '89', 228 | node: '16.5.0', 229 | openssl: '1.1.1', 230 | uv: '1.41.0', 231 | v8: '9.3.278-electron.0', 232 | zlib: '1.2.11', 233 | }); 234 | }); 235 | 236 | it('does not return release info for an unknown version', () => { 237 | const releaseInfo = testVersions.getReleaseInfo('0.0.0'); 238 | expect(releaseInfo).toBe(undefined); 239 | }); 240 | 241 | it('does not return release info if partial info', () => { 242 | const version = '16.0.0-nightly.20210726'; 243 | const partialVersions = new BaseVersions([ 244 | { version, node: '16.5.0', openssl: '1.1.1' }, 245 | ]); 246 | const releaseInfo = partialVersions.getReleaseInfo(version); 247 | expect(releaseInfo).toBe(undefined); 248 | }); 249 | }); 250 | }); 251 | 252 | describe('ElectronVersions', () => { 253 | let nockScope: Scope; 254 | let tmpdir: string; 255 | let versionsCache: string; 256 | const releasesFixturePath = path.join(__dirname, 'fixtures', 'releases.json'); 257 | 258 | beforeAll(async () => { 259 | tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), 'fiddle-core-')); 260 | }); 261 | 262 | beforeEach(async () => { 263 | // Copy the releases.json fixture over to populate the versions cache 264 | versionsCache = path.join(tmpdir, 'versions.json'); 265 | await fs.outputJSON(versionsCache, await fs.readJson(releasesFixturePath)); 266 | 267 | nock.disableNetConnect(); 268 | nockScope = nock('https://releases.electronjs.org'); 269 | }); 270 | 271 | afterEach(() => { 272 | nock.cleanAll(); 273 | nock.enableNetConnect(); 274 | }); 275 | 276 | afterAll(() => { 277 | fs.removeSync(tmpdir); 278 | }); 279 | 280 | describe('.create', () => { 281 | it('does not fetch with a fresh cache', async () => { 282 | await fs.outputJSON(versionsCache, [ 283 | { 284 | version: '0.23.0', 285 | }, 286 | ]); 287 | expect(nockScope.isDone()); // No mocks 288 | const { versions } = await ElectronVersions.create({ versionsCache }); 289 | expect(versions.length).toBe(1); 290 | }); 291 | 292 | it('fetches with a missing cache', async () => { 293 | const scope = nockScope.get('/releases.json').reply( 294 | 200, 295 | JSON.stringify([ 296 | { 297 | version: '0.23.0', 298 | }, 299 | { 300 | version: '0.23.1', 301 | }, 302 | ]), 303 | { 304 | 'Content-Type': 'application/json', 305 | }, 306 | ); 307 | await fs.remove(versionsCache); 308 | const { versions } = await ElectronVersions.create({ versionsCache }); 309 | expect(scope.isDone()); 310 | expect(versions.length).toBe(2); 311 | }); 312 | 313 | it('throws an error with a missing cache and failed fetch', async () => { 314 | const scope = nockScope.get('/releases.json').replyWithError('Error'); 315 | await fs.remove(versionsCache); 316 | await expect(ElectronVersions.create({ versionsCache })).rejects.toThrow( 317 | Error, 318 | ); 319 | expect(scope.isDone()); 320 | }); 321 | 322 | it('throws an error with a missing cache and a non-200 server response', async () => { 323 | const scope = nockScope 324 | .get('/releases.json') 325 | .reply(500, JSON.stringify({ error: true }), { 326 | 'Content-Type': 'application/json', 327 | }); 328 | await fs.remove(versionsCache); 329 | await expect(ElectronVersions.create({ versionsCache })).rejects.toThrow( 330 | Error, 331 | ); 332 | expect(scope.isDone()); 333 | }); 334 | 335 | it('fetches with a stale cache', async () => { 336 | const scope = nockScope.get('/releases.json').reply( 337 | 200, 338 | JSON.stringify([ 339 | { 340 | version: '0.23.0', 341 | }, 342 | { 343 | version: '0.23.1', 344 | }, 345 | { 346 | version: '0.23.2', 347 | }, 348 | ]), 349 | { 350 | 'Content-Type': 'application/json', 351 | }, 352 | ); 353 | const staleCacheMtime = Date.now() / 1000 - 5 * 60 * 60; 354 | await fs.utimes(versionsCache, staleCacheMtime, staleCacheMtime); 355 | const { versions } = await ElectronVersions.create({ versionsCache }); 356 | expect(scope.isDone()); 357 | expect(versions.length).toBe(3); 358 | }); 359 | 360 | it('uses stale cache when fetch fails', async () => { 361 | const scope = nockScope.get('/releases.json').replyWithError('Error'); 362 | const staleCacheMtime = Date.now() / 1000 - 5 * 60 * 60; 363 | await fs.utimes(versionsCache, staleCacheMtime, staleCacheMtime); 364 | const { versions } = await ElectronVersions.create({ versionsCache }); 365 | expect(scope.isDone()); 366 | expect(versions.length).toBe(1061); 367 | }); 368 | 369 | it('uses options.initialVersions if missing cache', async () => { 370 | await fs.remove(versionsCache); 371 | expect(nockScope.isDone()); // No mocks 372 | const initialVersions = [ 373 | { 374 | version: '0.23.0', 375 | }, 376 | { 377 | version: '0.23.1', 378 | }, 379 | ]; 380 | const { versions } = await ElectronVersions.create( 381 | { versionsCache }, 382 | { initialVersions }, 383 | ); 384 | expect(versions.length).toBe(2); 385 | }); 386 | 387 | it('does not use options.initialVersions if cache available', async () => { 388 | await fs.outputJSON(versionsCache, [ 389 | { 390 | version: '0.23.0', 391 | }, 392 | ]); 393 | expect(nockScope.isDone()); // No mocks 394 | const initialVersions = [ 395 | { 396 | version: '0.23.0', 397 | }, 398 | { 399 | version: '0.23.1', 400 | }, 401 | ]; 402 | const { versions } = await ElectronVersions.create( 403 | { versionsCache }, 404 | { initialVersions }, 405 | ); 406 | expect(versions.length).toBe(1); 407 | }); 408 | 409 | it('does not use cache if options.ignoreCache is true', async () => { 410 | await fs.outputJSON(versionsCache, [ 411 | { 412 | version: '0.23.0', 413 | }, 414 | ]); 415 | const scope = nockScope.get('/releases.json').reply( 416 | 200, 417 | JSON.stringify([ 418 | { 419 | version: '0.23.0', 420 | }, 421 | { 422 | version: '0.23.1', 423 | }, 424 | { 425 | version: '0.23.2', 426 | }, 427 | ]), 428 | { 429 | 'Content-Type': 'application/json', 430 | }, 431 | ); 432 | const { versions } = await ElectronVersions.create( 433 | { versionsCache }, 434 | { ignoreCache: true }, 435 | ); 436 | expect(scope.isDone()); 437 | expect(versions.length).toBe(3); 438 | }); 439 | 440 | it('uses options.initialVersions if cache available but options.ignoreCache is true', async () => { 441 | await fs.outputJSON(versionsCache, [ 442 | { 443 | version: '0.23.0', 444 | }, 445 | ]); 446 | expect(nockScope.isDone()); // No mocks 447 | const initialVersions = [ 448 | { 449 | version: '0.23.0', 450 | }, 451 | { 452 | version: '0.23.1', 453 | }, 454 | ]; 455 | const { versions } = await ElectronVersions.create( 456 | { versionsCache }, 457 | { initialVersions, ignoreCache: true }, 458 | ); 459 | expect(versions.length).toBe(2); 460 | }); 461 | }); 462 | 463 | describe('.fetch', () => { 464 | it('updates the cache', async () => { 465 | const electronVersions = await ElectronVersions.create({ versionsCache }); 466 | expect(electronVersions.versions.length).toBe(1061); 467 | 468 | const scope = nockScope.get('/releases.json').reply( 469 | 200, 470 | JSON.stringify([ 471 | { 472 | version: '0.23.0', 473 | }, 474 | { 475 | version: '0.23.1', 476 | }, 477 | { 478 | version: '0.23.2', 479 | }, 480 | { 481 | version: '0.23.3', 482 | }, 483 | ]), 484 | { 485 | 'Content-Type': 'application/json', 486 | }, 487 | ); 488 | await electronVersions.fetch(); 489 | expect(scope.isDone()); 490 | expect(electronVersions.versions.length).toBe(4); 491 | }); 492 | }); 493 | }); 494 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*.ts", "tests/**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "downlevelIteration": true, 5 | "esModuleInterop": true, 6 | "lib": ["es2018"], 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "outDir": "dist", 10 | "resolveJsonModule": true, 11 | "strict": true, 12 | "target": "es2018", 13 | "types": ["jest", "node"] 14 | }, 15 | "include": ["src/**/*.ts"], 16 | } 17 | --------------------------------------------------------------------------------