├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── bin.js ├── example ├── detect.js └── launch.js ├── index.js ├── lib ├── browsers.js ├── config.js ├── create_profiles.js ├── darwin │ ├── index.js │ └── util.js ├── detect.js ├── instance.js └── run.js ├── package.json ├── res ├── Preferences ├── operaprefs.ini └── phantom.js ├── test └── browsers.js └── types.d.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 4 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.js text eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | name: Build & test 6 | runs-on: ubuntu-latest 7 | 8 | strategy: 9 | matrix: 10 | node-version: [12.x, 14.x, 16.x, '*'] 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | 19 | - run: npm install 20 | 21 | - run: npm test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This software is released under the MIT license: 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # browser-launcher[![Build Status](https://github.com/httptoolkit/browser-launcher/workflows/CI/badge.svg)](https://github.com/httptoolkit/browser-launcher/actions) [![Get it on npm](https://img.shields.io/npm/v/@httptoolkit/browser-launcher.svg)](https://www.npmjs.com/package/@httptoolkit/browser-launcher) 2 | 3 | > _Part of [HTTP Toolkit](https://httptoolkit.com): powerful tools for building, testing & debugging HTTP(S)_ 4 | 5 | Detect the browser versions available on your system and launch them in an isolated profile for automation & testing purposes. 6 | 7 | You can launch browsers headlessly 8 | (using [Xvfb](http://en.wikipedia.org/wiki/Xvfb) or with [PhantomJS](http://phantomjs.org/)) and set the proxy 9 | configuration on the fly. 10 | 11 | This project is the latest in a long series, each forked from the last: 12 | 13 | * [substack/browser-launcher](https://github.com/substack/browser-launcher) 14 | * [browser-launcher2](https://github.com/benderjs/browser-launcher2). 15 | * [james-proxy/james-browser-launcher](https://github.com/james-proxy/james-browser-launcher) 16 | 17 | Each previous versions seems to now be unmaintained, and this is a core component of [HTTP Toolkit](https://httptoolkit.com), so it's been forked here to ensure it can continue healthy into the future. 18 | 19 | ## Supported browsers 20 | 21 | The goal for this module is to support all major browsers on every desktop platform. 22 | 23 | At the moment, `browser-launcher` supports following browsers on Windows, Unix and OS X: 24 | 25 | - Chrome 26 | - Chromium 27 | - Firefox 28 | - IE (Windows only) 29 | - Chromium-based Edge 30 | - Brave 31 | - Opera 32 | - Safari (Mac only) 33 | - PhantomJS 34 | - Arc (experimental, Mac only) 35 | 36 | ## Setup 37 | 38 | ### Quick usage 39 | 40 | ```bash 41 | > npx @httptoolkit/browser-launcher # Scans for browsers 42 | [ 43 | { 44 | "name": "chrome", 45 | "version": "...", 46 | # ... 47 | }, 48 | # ... 49 | ] 50 | 51 | > npx @httptoolkit/browser-launcher firefox # Launches a browser 52 | firefox launched with PID: XXXXXX 53 | ``` 54 | 55 | If the package is already installed locally, you can use `browser-launcher` to launch it directly instead, either from the `node_modules/.bin` directly, or as binary name with `npx`. 56 | 57 | ### Install 58 | 59 | ``` 60 | npm install @httptoolkit/browser-launcher 61 | ``` 62 | 63 | ## Example 64 | 65 | ### Browser launch 66 | 67 | ```js 68 | const launcher = require('@httptoolkit/browser-launcher'); 69 | 70 | launcher(function(err, launch) { 71 | if (err) { 72 | return console.error(err); 73 | } 74 | 75 | launch('http://httptoolkit.com/', 'chrome', function(err, instance) { 76 | if (err) { 77 | return console.error(err); 78 | } 79 | 80 | console.log('Instance started with PID:', instance.pid); 81 | 82 | instance.on('stop', function(code) { 83 | console.log('Instance stopped with exit code:', code); 84 | }); 85 | }); 86 | }); 87 | ``` 88 | 89 | Outputs: 90 | 91 | ``` 92 | $ node example/launch.js 93 | Instance started with PID: 12345 94 | Instance stopped with exit code: 0 95 | ``` 96 | 97 | ### Browser launch with options 98 | 99 | ```js 100 | var launcher = require('@httptoolkit/browser-launcher'); 101 | 102 | launcher(function(err, launch) { 103 | // ... 104 | launch( 105 | 'http://httptoolkit.com/', 106 | { 107 | browser: 'chrome', 108 | noProxy: [ '127.0.0.1', 'localhost' ], 109 | options: [ 110 | '--disable-web-security', 111 | '--disable-extensions' 112 | ] 113 | }, 114 | function(err, instance) { 115 | // ... 116 | } 117 | ); 118 | }); 119 | ``` 120 | 121 | 122 | ### Browser detection 123 | ```js 124 | var launcher = require('@httptoolkit/browser-launcher'); 125 | 126 | launcher.detect(function(available) { 127 | console.log('Available browsers:'); 128 | console.dir(available); 129 | }); 130 | ``` 131 | 132 | Outputs: 133 | 134 | ```bash 135 | $ node example/detect.js 136 | Available browsers: 137 | [ { name: 'chrome', 138 | version: '36.0.1985.125', 139 | type: 'chrome', 140 | command: 'google-chrome' }, 141 | { name: 'chromium', 142 | version: '36.0.1985.125', 143 | type: 'chrome', 144 | command: 'chromium-browser' }, 145 | { name: 'firefox', 146 | version: '31.0', 147 | type: 'firefox', 148 | command: 'firefox' }, 149 | { name: 'phantomjs', 150 | version: '1.9.7', 151 | type: 'phantom', 152 | command: 'phantomjs' }, 153 | { name: 'opera', 154 | version: '12.16', 155 | type: 'opera', 156 | command: 'opera' } ] 157 | ``` 158 | 159 | ### Detaching the launched browser process from your script 160 | 161 | If you want the opened browser to remain open after killing your script, first, you need to set `options.detached` to `true` (see the API). By default, killing your script will kill the opened browsers. 162 | 163 | Then, if you want your script to immediately return control to the shell, you may additionally call `unref` on the `instance` object in the callback: 164 | 165 | ```js 166 | var launcher = require('@httptoolkit/browser-launcher'); 167 | launcher(function (err, launch) { 168 | launch('http://example.org/', { 169 | browser: 'chrome', 170 | detached: true 171 | }, function(err, instance) { 172 | if (err) { 173 | return console.error(err); 174 | } 175 | 176 | instance.process.unref(); 177 | instance.process.stdin.unref(); 178 | instance.process.stdout.unref(); 179 | instance.process.stderr.unref(); 180 | }); 181 | }); 182 | ``` 183 | 184 | ## API 185 | 186 | ``` js 187 | var launcher = require('@httptoolkit/browser-launcher'); 188 | ``` 189 | 190 | ### `launcher([configPath], callback)` 191 | 192 | Detect available browsers and pass `launch` function to the callback. 193 | 194 | **Parameters:** 195 | 196 | - *String* `configPath` - path to a browser configuration file *(Optional)* 197 | - *Function* `callback(err, launch)` - function called with `launch` function and errors (if any) 198 | 199 | ### `launch(uri, options, callback)` 200 | 201 | Open given URI in a browser and return an instance of it. 202 | 203 | **Parameters:** 204 | 205 | - *String* `uri` - URI to open in a newly started browser 206 | - *Object|String* `options` - configuration options or name of a browser to launch 207 | - *String* `options.browser` - name of a browser to launch 208 | - *String* `options.version` - version of a browser to launch, if none was given, the highest available version will be launched 209 | - *String* `options.proxy` - URI of the proxy server 210 | - *Array* `options.options` - additional command line options 211 | - *Boolean* `options.skipDefaults` - don't supply any default options to browser 212 | - *Boolean* `options.detached` - if true, then killing your script will not kill the opened browser 213 | - *Array|String* `options.noProxy` - An array of strings, containing proxy routes to skip over 214 | - *Boolean* `options.headless` - run a browser in a headless mode (only if **Xvfb** available) 215 | - *String|null* `options.profile` - path to a directory to use for the browser profile, overriding the default. Use `null` to force use of the default system profile (supported for Firefox & Chromium-based browsers only). Note that configuration options like `proxy` & `prefs` can't be used in Firefox with the default system profile. 216 | - *Function* `callback(err, instance)` - function fired when started a browser `instance` or an error occurred 217 | 218 | ### `launch.browsers` 219 | 220 | This property contains an array of all known and available browsers. 221 | 222 | ### `instance` 223 | 224 | Browser instance object. 225 | 226 | **Properties:** 227 | - *String* `command` - command used to start the instance 228 | - *Array* `args` - array of command line arguments used while starting the instance 229 | - *String* `image` - instance's image name 230 | - *String* `processName` - instance's process name 231 | - *Object* `process` - reference to instance's process started with Node's `child_process.spawn` API 232 | - *Number* `pid` - instance's process PID 233 | - *Stream* `stdout` - instance's process STDOUT stream 234 | - *Stream* `stderr` - instance's process STDERR stream 235 | 236 | **Events:** 237 | - `stop` - fired when instance stops 238 | 239 | **Methods:** 240 | - `stop(callback)` - stop the instance and fire the callback once stopped 241 | 242 | ### `launcher.detect(callback)` 243 | 244 | Detects all browsers available. 245 | 246 | **Parameters:** 247 | - *Function* `callback(available)` - function called with array of all recognized browsers 248 | 249 | Each browser contains following properties: 250 | - `name` - name of a browser 251 | - `version` - browser's version 252 | - `type` - type of a browser i.e. browser's family 253 | - `command` - command used to launch a browser 254 | 255 | ### `launcher.update([configFile], callback)` 256 | 257 | Updates the browsers cache file (`~/.config/browser-launcher/config.json` is no `configFile` was given) and creates new profiles for found browsers. 258 | 259 | **Parameters:** 260 | - *String* `configFile` - path to the configuration file *Optional* 261 | - *Function* `callback(err, browsers)` - function called with found browsers and errors (if any) 262 | 263 | ## Known Issues 264 | 265 | - IE8: after several starts and stops, if you manually open IE it will come up with a pop-up asking if we want to restore tabs (#21) 266 | - Chrome @ OSX: it's not possible to launch multiple instances of Chrome at once 267 | 268 | ## License 269 | 270 | MIT 271 | -------------------------------------------------------------------------------- /bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const launcher = require('.'); 3 | 4 | const browserArg = process.argv[2]; 5 | const launchUrl = process.argv[3]; 6 | 7 | if (!browserArg) { 8 | // When run directly, this just lists the available browsers 9 | launcher.detect(function(available) { 10 | console.log(JSON.stringify(available, null, 2)); 11 | }); 12 | } else { 13 | if (browserArg === '--help') { 14 | console.log(`# Usage for @httptoolkit/browser-launcher bin:`); 15 | console.log('To scan for browsers: browser-launcher'); 16 | console.log('To launch a browser: browser-launcher [url]'); 17 | } else { 18 | launcher(function(err, launch) { 19 | if (err) { 20 | return console.error(err); 21 | } 22 | 23 | launch(launchUrl, browserArg, function(err, instance) { 24 | if (err) { 25 | return console.error(err); 26 | } 27 | 28 | console.log(`${browserArg} launched with PID: ${instance.pid}`); 29 | }); 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /example/detect.js: -------------------------------------------------------------------------------- 1 | var launcher = require('../'); 2 | 3 | launcher.detect(function logBrowsers(available) { 4 | console.log('Available browsers:'); 5 | console.dir(available); 6 | }); 7 | -------------------------------------------------------------------------------- /example/launch.js: -------------------------------------------------------------------------------- 1 | var launcher = require('../'); 2 | 3 | launcher(function startBrowser(initErr, launch) { 4 | if (initErr) { 5 | return console.error(initErr); 6 | } 7 | 8 | launch('http://cksource.com/', process.env.BROWSER || 'chrome', function afterLaunch(launchErr, instance) { 9 | if (launchErr) { 10 | return console.error(launchErr); 11 | } 12 | 13 | console.log('Instance started with PID:', instance.pid); 14 | 15 | setTimeout(function stop() { 16 | instance.stop(); 17 | }, 10000); 18 | 19 | instance.on('stop', function logCode(code) { 20 | console.log('Instance stopped with exit code:', code); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var path = require('path'), 2 | pick = require('lodash/pick'), 3 | configModule = require('./lib/config'), 4 | detect = require('./lib/detect'), 5 | run = require('./lib/run'), 6 | createProfiles = require('./lib/create_profiles'); 7 | 8 | /** 9 | * Check the configuration and prepare a launcher function. 10 | * If there's no config ready, detect available browsers first. 11 | * Finally, pass a launcher function to the callback. 12 | * @param {String} [configFile] Path to a configuration file 13 | * @param {Function} callback Callback function 14 | */ 15 | function getLauncher(configFile, callback) { 16 | if (typeof configFile === 'function') { 17 | callback = configFile; 18 | configFile = configModule.defaultConfigFile; 19 | } 20 | 21 | configModule.read(configFile, function (err, config) { 22 | if (!config) { 23 | safeConfigUpdate(configFile, function (err, config) { 24 | if (err) { 25 | callback(err); 26 | } else { 27 | callback(null, wrap(config)); 28 | } 29 | }); 30 | } else { 31 | callback(null, wrap(config)); 32 | } 33 | }); 34 | 35 | function wrap(config) { 36 | var res = launch.bind(null, config); 37 | 38 | res.browsers = config.browsers; 39 | 40 | return res; 41 | } 42 | 43 | function launch(config, uri, options, callback) { 44 | if (typeof options === 'string') { 45 | options = { 46 | browser: options 47 | }; 48 | } 49 | 50 | options = options || {}; 51 | 52 | var version = options.version || options.browser.split('/')[1] || '*', 53 | name = options.browser.toLowerCase().split('/')[0], 54 | runner = run(config, name, version); 55 | 56 | if (!runner) { 57 | // update the list of available browsers and retry 58 | safeConfigUpdate(configFile, function (err, config) { 59 | if (!(runner = run(config, name, version))) { 60 | return callback(name + ' is not installed in your system.'); 61 | } 62 | 63 | runner(uri, options, callback); 64 | }); 65 | } else { 66 | runner(uri, options, callback); 67 | } 68 | } 69 | } 70 | 71 | /** 72 | * Detect available browsers 73 | * @param {Function} callback Callback function 74 | */ 75 | getLauncher.detect = function (callback) { 76 | detect(function (browsers) { 77 | callback(browsers.map(function (browser) { 78 | return pick(browser, ['name', 'version', 'type', 'command']); 79 | })); 80 | }); 81 | }; 82 | 83 | /** 84 | * Detect the available browsers and build appropriate profiles if necessary 85 | */ 86 | function buildConfig(configDir, callback) { 87 | detect(function (browsers) { 88 | createProfiles(browsers, configDir, function (err) { 89 | if (err) { 90 | return callback(err); 91 | } 92 | 93 | callback(null, { 94 | browsers: browsers 95 | }); 96 | }); 97 | }); 98 | } 99 | 100 | function safeConfigUpdate(configFile, callback) { 101 | // Detect browssers etc, and try to update the config file, but return the 102 | // detected config regardless of whether the config file actually works 103 | buildConfig(path.dirname(configFile), (err, config) => { 104 | if (err) { 105 | return callback(err); 106 | } 107 | 108 | configModule.write(configFile, config, function (err) { 109 | if (err) { 110 | console.warn(err); 111 | } 112 | callback(null, config); 113 | }); 114 | }); 115 | } 116 | 117 | /** 118 | * Detect the available browsers and build appropriate profiles if necessary, 119 | * and update the config file with their details. 120 | * @param {String} configFile Path to the configuration file 121 | * @param {Function} callback Callback function 122 | */ 123 | getLauncher.update = function (configFile, callback) { 124 | if (typeof configFile === 'function') { 125 | callback = configFile; 126 | configFile = configModule.defaultConfigFile; 127 | } 128 | 129 | buildConfig(path.dirname(configFile), (err, config) => { 130 | if (err) { 131 | return callback(err); 132 | } 133 | 134 | configModule.write(configFile, config, function (err) { 135 | if (err) { 136 | callback(err); 137 | } else { 138 | callback(null, config); 139 | } 140 | }); 141 | }); 142 | }; 143 | 144 | module.exports = getLauncher; 145 | -------------------------------------------------------------------------------- /lib/browsers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by mitch on 2/29/16. 3 | */ 4 | var omit = require('lodash/omit'); 5 | 6 | var browserDefinitions = { 7 | chrome: { 8 | regex: /Google Chrome (\S+)/, 9 | profile: true, 10 | variants: { 11 | 'chrome': ['google-chrome', 'google-chrome-stable'], 12 | 'chrome-beta': ['google-chrome-beta'], 13 | 'chrome-dev': ['google-chrome-unstable'], 14 | 'chrome-canary': ['google-chrome-canary'] 15 | } 16 | }, 17 | chromium: { 18 | regex: /Chromium (\S+)/, 19 | profile: true, 20 | variants: { 21 | 'chromium': ['chromium', 'chromium-browser'], 22 | 'chromium-dev': ['chromium-dev'] 23 | } 24 | }, 25 | firefox: { 26 | regex: /Mozilla Firefox (\S+)/, 27 | profile: true, 28 | variants: { 29 | 'firefox': ['firefox'], 30 | 'firefox-developer': ['firefox-devedition'], 31 | 'firefox-nightly': ['firefox-nightly'] 32 | } 33 | }, 34 | phantomjs: { 35 | regex: /(\S+)/, 36 | profile: false, 37 | headless: true 38 | }, 39 | safari: { 40 | profile: false, 41 | platforms: ['darwin'] 42 | }, 43 | ie: { 44 | profile: false, 45 | platforms: ['windows'] 46 | }, 47 | msedge: { 48 | regex: /Microsoft Edge (\S+)/, 49 | profile: true, 50 | variants: { 51 | 'msedge': ['msedge', 'microsoft-edge'], 52 | 'msedge-beta': ['msedge-beta', 'microsoft-edge-beta'], 53 | 'msedge-dev': ['msedge-dev', 'microsoft-edge-dev'], 54 | 'msedge-canary': ['msedge-canary', 'microsoft-edge-canary'] 55 | } 56 | }, 57 | brave: { 58 | regex: /Brave Browser (\S+)/, 59 | profile: true, 60 | variants: { 61 | 'brave': ['brave-browser', 'brave', 'brave-browser-stable'], 62 | 'brave-beta': ['brave-browser-beta', 'brave-beta'], 63 | 'brave-dev': ['brave-browser-dev', 'brave-dev'], 64 | 'brave-nightly': ['brave-browser-nightly', 'brave-nightly'] 65 | } 66 | }, 67 | opera: { 68 | regex: /Opera (\S+)/, 69 | profile: true, 70 | variants: { 71 | 'opera': ['opera'], 72 | 'opera-gx' : ['opera-gx'], 73 | 'opera-crypto' : ['opera-crypto'] 74 | } 75 | }, 76 | arc: { 77 | profile: false, // Arc overrides Chromium profile handling 78 | neverStartFresh: true, // Arc gets weird/crashy if you start fresh 79 | platforms: ['darwin'] 80 | } 81 | }; 82 | 83 | /** 84 | * Used to get browser information and configuration. By default, uses internal browser list 85 | * @param {Array} [browserList] list of browsers, configuration and variants 86 | * @constructor 87 | */ 88 | function Browsers(browserList) { 89 | this.browserList = browserList || browserDefinitions; 90 | } 91 | 92 | /** 93 | * Compiles each browser into the relevant data for Linux or Darwin. The structure of each object returned is: 94 | * type: type of browser, e.g.: "chrome", "chromium", "ie" 95 | * darwin: name of browser, used to look up "darwin detector" (see "./darwin" folder) 96 | * linux: array of commands that the browser might run as on a 'nix environment 97 | * regex: extracts version code when browser is run as a command 98 | * 99 | * @returns {Array} list of browser data 100 | */ 101 | Browsers.prototype.browserPlatforms = function browserPlatforms() { 102 | return Object.entries(this.browserList) 103 | .flatMap(([type, browserConfig]) => { 104 | const supportedOnDarwin = !browserConfig.platforms || 105 | browserConfig.platforms.includes('darwin'); 106 | const supportedOnLinux = !browserConfig.platforms || 107 | browserConfig.platforms.includes('linux'); 108 | // No windows check, since win-detect-browsers scans separately 109 | 110 | const variants = browserConfig.variants; 111 | 112 | if (!variants) { 113 | return [{ 114 | type: type, 115 | ...(supportedOnDarwin ? { darwin: type } : {}), 116 | ...(supportedOnLinux ? { linux: [type] } : {}), 117 | regex: browserConfig.regex 118 | }]; 119 | } else { 120 | return Object.keys(variants).map((name) => ({ 121 | type: type, 122 | ...(supportedOnDarwin ? { darwin: name } : {}), 123 | ...(supportedOnLinux ? { linux: variants[name] } : {}), 124 | regex: browserConfig.regex 125 | })); 126 | } 127 | }); 128 | }; 129 | 130 | /** 131 | * Returns the configuration for the browser type specified 132 | * @param {String} type type of browser 133 | * @returns {Object} config for the specified browser type 134 | */ 135 | Browsers.prototype.typeConfig = function typeConfig(type) { 136 | return omit(this.browserList[type], 'variants'); 137 | }; 138 | 139 | module.exports = Browsers; 140 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | var mkdirp = require('mkdirp'); 2 | var fs = require('fs'); 3 | var path = require('path'); 4 | var packageJson = require('../package.json'); 5 | var osenv = require('osenv'); 6 | var defaultConfigFile = osenv.home() + '/.config/' + packageJson.name.split('/')[1] + '/config.json'; 7 | 8 | exports.defaultConfigFile = defaultConfigFile; 9 | 10 | /** 11 | * Read a configuration file 12 | * @param {String} [configFile] Path to the configuration file 13 | * @param {Function} callback Callback function 14 | */ 15 | exports.read = function read(configFile, callback) { 16 | if (typeof configFile === 'function') { 17 | callback = configFile; 18 | configFile = defaultConfigFile; 19 | } 20 | 21 | if (!configFile) { 22 | configFile = defaultConfigFile; 23 | } 24 | 25 | var configDir = path.dirname(configFile); 26 | 27 | mkdirp(configDir, function (mkdirpErr) { 28 | if (mkdirpErr) { 29 | return callback(mkdirpErr); 30 | } 31 | 32 | fs.exists(configFile, function (exists) { 33 | if (exists) { 34 | fs.readFile(configFile, function (readErr, src) { 35 | if (readErr) return callback(readErr); 36 | 37 | let data; 38 | try { 39 | data = JSON.parse(src); 40 | } catch (e) { 41 | return callback(e); 42 | } 43 | 44 | callback(readErr, data, configDir); 45 | }); 46 | } else { 47 | callback(mkdirpErr, null, configDir); 48 | } 49 | }); 50 | }); 51 | }; 52 | 53 | /** 54 | * Write a configuration file 55 | * @param {String} configFile Path to the configuration file 56 | * @param {Object} config Configuration object 57 | * @param {Function} callback Callback function 58 | */ 59 | exports.write = function (configFile, config, callback) { 60 | callback = callback || function () { 61 | }; 62 | 63 | if (typeof configFile === 'object') { 64 | callback = config; 65 | config = configFile; 66 | configFile = defaultConfigFile; 67 | } 68 | 69 | mkdirp(path.dirname(configFile), function (err) { 70 | if (err) { 71 | return callback(err); 72 | } 73 | 74 | fs.writeFile(configFile, JSON.stringify(config, null, 2), callback); 75 | }); 76 | }; 77 | -------------------------------------------------------------------------------- /lib/create_profiles.js: -------------------------------------------------------------------------------- 1 | var mkdirp = require('mkdirp'); 2 | var path = require('path'); 3 | 4 | /** 5 | * Create profiles for the given browsers 6 | * @param {Array.} browsers Array of browsers 7 | * @param {String} configDir Path to a directory, where the profiles should be put 8 | * @param {Function} callback Callback function 9 | */ 10 | module.exports = function createProfiles(browsers, configDir, callback) { 11 | var pending = browsers.length; 12 | 13 | if (!pending) { 14 | callback(); 15 | return; 16 | } 17 | 18 | function checkPending() { 19 | return !--pending && callback(); 20 | } 21 | 22 | function dirName(name, version) { 23 | var dir = name + '-' + version; 24 | return path.join(configDir, dir); 25 | } 26 | 27 | browsers.forEach(function (browser) { 28 | if (browser.type === 'firefox' && browser.profile) { 29 | checkPending(); 30 | } else if (browser.profile) { 31 | browser.profile = dirName(browser.name, browser.version); 32 | 33 | mkdirp(browser.profile, function (err) { 34 | if (err) { 35 | callback(err); 36 | } else { 37 | checkPending(); 38 | } 39 | }); 40 | } else { 41 | checkPending(); 42 | } 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /lib/darwin/index.js: -------------------------------------------------------------------------------- 1 | var util = require('./util'); 2 | 3 | function browser(id, versionKey) { 4 | return { 5 | path: util.find.bind(null, id), 6 | version: util.getInfoKey.bind(null, id, versionKey) 7 | }; 8 | } 9 | 10 | exports.chrome = browser('com.google.Chrome', 'KSVersion'); 11 | exports['chrome-canary'] = browser('com.google.Chrome.canary', 'KSVersion'); 12 | exports['chrome-dev'] = browser('com.google.Chrome.dev', 'KSVersion'); 13 | exports['chrome-beta'] = browser('com.google.Chrome.beta', 'KSVersion'); 14 | exports.chromium = browser('org.chromium.Chromium', 'CFBundleShortVersionString'); 15 | exports.firefox = browser('org.mozilla.firefox', 'CFBundleShortVersionString'); 16 | exports['firefox-developer'] = browser('org.mozilla.firefoxdeveloperedition', 'CFBundleShortVersionString'); 17 | exports['firefox-nightly'] = browser('org.mozilla.nightly', 'CFBundleShortVersionString'); 18 | exports.safari = browser('com.apple.Safari', 'CFBundleShortVersionString'); 19 | exports.opera = browser('com.operasoftware.Opera', 'CFBundleVersion'); 20 | exports["opera-gx"] = browser('com.operasoftware.OperaGX', 'CFBundleVersion'); 21 | exports["opera-crypto"] = browser('com.operasoftware.OperaCrypto', 'CFBundleVersion'); 22 | exports.msedge = browser('com.microsoft.edgemac', 'CFBundleVersion'); 23 | exports['msedge-beta'] = browser('com.microsoft.edgemac.Beta', 'CFBundleVersion'); 24 | exports['msedge-dev'] = browser('com.microsoft.edgemac.Dev', 'CFBundleVersion'); 25 | exports['msedge-canary'] = browser('com.microsoft.edgemac.Canary', 'CFBundleVersion'); 26 | exports.brave = browser('com.brave.Browser', 'CFBundleVersion'); 27 | exports['brave-beta'] = browser('com.brave.Browser.beta', 'CFBundleVersion'); 28 | exports['brave-dev'] = browser('com.brave.Browser.dev', 'CFBundleVersion'); 29 | exports['brave-nightly'] = browser('com.brave.Browser.nightly', 'CFBundleVersion'); 30 | exports['arc'] = browser('company.thebrowser.Browser', 'CFBundleVersion') 31 | -------------------------------------------------------------------------------- /lib/darwin/util.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const plist = require('simple-plist'); 4 | const { findExecutableById } = require('@httptoolkit/osx-find-executable'); 5 | 6 | const infoCache = Object.create(null); 7 | 8 | function parse(file, callback) { 9 | if (infoCache[file]) { 10 | return callback(null, infoCache[file]); 11 | } 12 | 13 | fs.exists(file, function (exists) { 14 | if (!exists) { 15 | return callback('cannot parse non-existent plist', null); 16 | } 17 | 18 | plist.readFile(file, function (err, data) { 19 | infoCache[file] = data; 20 | callback(err, data); 21 | }); 22 | }); 23 | } 24 | 25 | function findBundle(bundleId, callback) { 26 | findExecutableById(bundleId).then((execPath) => { 27 | callback( 28 | null, 29 | // Executable is always ${bundle}/Contents/MacOS/${execName}, 30 | // so we just need to strip the last few levels: 31 | path.dirname(path.dirname(path.dirname(execPath))) 32 | ); 33 | }).catch((err) => callback(err)); 34 | } 35 | 36 | function getInfoPath(p) { 37 | return path.join(p, 'Contents', 'Info.plist'); 38 | } 39 | 40 | function getInfoKey(bundleId, key, callback) { 41 | findBundle(bundleId, function (findErr, bundlePath) { 42 | if (findErr) { 43 | return callback(findErr, null); 44 | } 45 | 46 | parse(getInfoPath(bundlePath), function (infoErr, data) { 47 | if (infoErr) { 48 | return callback(infoErr, null); 49 | } 50 | 51 | callback(null, data[key]); 52 | }); 53 | }); 54 | } 55 | 56 | exports.find = findBundle; 57 | exports.getInfoKey = getInfoKey; 58 | -------------------------------------------------------------------------------- /lib/detect.js: -------------------------------------------------------------------------------- 1 | var spawn = require('child_process').spawn; 2 | var winDetect = require('win-detect-browsers'); 3 | var darwin = require('./darwin'); 4 | var assign = require('lodash/assign'); 5 | var Browsers = require('./browsers'); 6 | var browsers = new Browsers(); 7 | 8 | /** 9 | * Detect all available browsers on Windows systems. 10 | * Pass an array of detected browsers to the callback function when done. 11 | * @param {Function} callback Callback function 12 | */ 13 | function detectWindows(callback) { 14 | winDetect(function (error, found) { 15 | if (error) return callback(error); 16 | 17 | const available = found.map(function (browser) { 18 | const config = browsers.typeConfig(browser.name); 19 | 20 | let configName; 21 | if (browser.channel && browser.channel !== "stable" && browser.channel !== "release") { 22 | configName = `${browser.name}-${browser.channel}`; 23 | } else { 24 | configName = browser.name; 25 | } 26 | 27 | return assign({ 28 | type: browser.name, 29 | name: configName, 30 | command: browser.path, 31 | version: browser.version 32 | }, config); 33 | }); 34 | 35 | callback(null, available); 36 | }); 37 | } 38 | 39 | /** 40 | * Check if the given browser is available (on OSX systems). 41 | * Pass its version and path to the callback function if found. 42 | * @param {String} name Name of a browser 43 | * @param {Function} callback Callback function 44 | */ 45 | function checkDarwin(name, callback) { 46 | darwin[name].version(function (versionErr, version) { 47 | if (versionErr) { 48 | return callback('failed to get version for ' + name); 49 | } 50 | 51 | darwin[name].path(function (pathErr, path) { 52 | if (pathErr) { 53 | return callback('failed to get path for ' + name); 54 | } 55 | 56 | callback(null, version, path); 57 | }); 58 | }); 59 | } 60 | 61 | /** 62 | * Attempt to run browser (on Unix systems) to determine version. 63 | * If found, the version is provided to the callback 64 | * @param {String} name Name of a browser 65 | * @param {RegExp} regex Extracts version from command output 66 | * @param {Function} callback Callback function 67 | */ 68 | function getCommandVersion(name, regex, callback) { 69 | var process; 70 | try { 71 | process = spawn(name, ['--version']); 72 | } catch (e) { 73 | callback(e); 74 | return; 75 | } 76 | 77 | var data = ''; 78 | 79 | process.stdout.on('data', function (buf) { 80 | data += buf; 81 | }); 82 | 83 | process.on('error', function () { 84 | callback('not installed'); 85 | callback = null; 86 | }); 87 | 88 | process.on('close', function (code) { 89 | if (!callback) { 90 | return; 91 | } 92 | 93 | if (code !== 0) { 94 | return callback('not installed'); 95 | } 96 | 97 | var match = regex.exec(data); 98 | var version = match ? match[1] : data.trim(); 99 | callback(null, version); 100 | }); 101 | } 102 | 103 | /** 104 | * Check if the given browser is available (on Unix systems). 105 | * Pass its version and command to the callback function if found. 106 | * @param {Array} commands List of potential commands used to start browser 107 | * @param {RegExp} regex extracts version from browser's command-line output 108 | * @param {Function} callback Callback function 109 | */ 110 | function checkUnix(commands, regex, callback) { 111 | var checkCount = 0; 112 | var detectedVersion; 113 | 114 | commands.forEach(function (command) { 115 | /* 116 | There could be multiple commands run per browser on Linux, and we can't call the callback on _every_ 117 | successful command invocation, because then it will be called more than `browserPlatforms.length` times. 118 | 119 | This callback function performs debouncing, and also takes care of the case when the same browser matches 120 | multiple commands (due to symlinking or whatnot). Only the last _successful_ "check" will be saved and 121 | passed on 122 | */ 123 | getCommandVersion(command, regex, function linuxDone(err, version) { 124 | checkCount++; 125 | if (!err) { 126 | detectedVersion = version; 127 | } 128 | 129 | if (checkCount === commands.length) { 130 | callback(!detectedVersion ? 'Browser not found' : null, detectedVersion, command); 131 | } 132 | }); 133 | }); 134 | } 135 | 136 | /** 137 | * Detect all available web browsers. 138 | * Pass an array of available browsers to the callback function when done. 139 | * @param {Function} callback Callback function 140 | */ 141 | module.exports = function detect(callback) { 142 | if (process.platform === 'win32') { 143 | detectWindows(function (err, browsers) { 144 | if (err) callback([]); 145 | else callback(browsers); 146 | }); 147 | return; 148 | } 149 | 150 | var available = []; 151 | var detectAttempts = 0; 152 | var browserPlatforms = browsers.browserPlatforms(); 153 | 154 | browserPlatforms.forEach(function (browserPlatform) { 155 | function browserDone(err, version, path) { 156 | detectAttempts++; 157 | if (!err) { 158 | var config = browsers.typeConfig(browserPlatform.type); 159 | available.push(assign({}, config, { 160 | type: browserPlatform.type, 161 | name: browserPlatform.darwin, 162 | command: path, 163 | version: version 164 | })); 165 | } 166 | 167 | if (detectAttempts === browserPlatforms.length) { 168 | callback(available); 169 | } 170 | } 171 | 172 | if (process.platform === 'darwin') { 173 | if (darwin[browserPlatform.darwin]) { 174 | // If we have a darwin-specific bundle id to search for, use it: 175 | checkDarwin(browserPlatform.darwin, browserDone); 176 | } else if (browserPlatform.darwin && browserPlatform.linux) { 177 | // If it's darwin-supported, but with no bundle id, search $PATH 178 | // as we do on linux: 179 | checkUnix(browserPlatform.linux, browserPlatform.regex, browserDone); 180 | } else { 181 | browserDone(new Error('Not supported')); 182 | } 183 | } else if (browserPlatform.linux) { 184 | // On Linux, for supported browsers, we always just search $PATH: 185 | checkUnix(browserPlatform.linux, browserPlatform.regex, browserDone); 186 | } else { 187 | browserDone(new Error('Not supported')); 188 | } 189 | }); 190 | }; 191 | -------------------------------------------------------------------------------- /lib/instance.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('events').EventEmitter; 2 | var child = require('child_process'); 3 | var rimraf = require('rimraf'); 4 | var util = require('util'); 5 | 6 | /** 7 | * Web browser instance 8 | * @param {Object} options Configuration options 9 | * @param {Array} options.args Array list of command line arguments 10 | * @param {String} options.command Command used to start an instance's process 11 | * @param {String} options.cwd Instance's current working directory 12 | * @param {Boolean} options.detached Flag telling if the instance should be started in detached mode 13 | * @param {Object} options.env Instance's environment variables 14 | * @param {String} options.image Instance image (used to kill it on Windows) 15 | * @param {String} options.processName Instance process name (used to kill it on OSX) 16 | * @param {String} options.tempDir Temporary directory used by the instance 17 | */ 18 | function Instance(options) { 19 | EventEmitter.call(this); 20 | 21 | this.command = options.command; 22 | this.args = options.args; 23 | this.image = options.image; 24 | this.processName = options.processName; 25 | this.tempDir = options.tempDir; 26 | this.browserType = options.type; // saving the type of browser instance for issue# 49 27 | 28 | this.process = child.spawn(this.command, this.args, { 29 | detached: options.detached, 30 | env: options.env, 31 | cwd: options.cwd 32 | }); 33 | 34 | this.pid = this.process.pid; 35 | this.stdout = this.process.stdout; 36 | this.stderr = this.process.stderr; 37 | 38 | // on Windows Opera uses a launcher which is stopped immediately after opening the browser 39 | // so it makes no sense to bind a listener, though we won't be noticed about crashes... 40 | if (options.name === 'opera' && process.platform === 'win32') { 41 | return; 42 | } 43 | 44 | // trigger "stop" event when the process exits 45 | this.process.on('close', this.emit.bind(this, 'stop')); 46 | 47 | // clean-up the temp directory once the instance stops 48 | if (this.tempDir) { 49 | this.on('stop', function () { 50 | rimraf(this.tempDir, function () { /* .. */ 51 | }); 52 | }.bind(this)); 53 | } 54 | } 55 | 56 | util.inherits(Instance, EventEmitter); 57 | 58 | /** 59 | * Stop the instance 60 | * @param {Function} callback Callback function called when the instance is stopped 61 | */ 62 | Instance.prototype.stop = function (callback) { 63 | if (typeof callback === 'function') { 64 | this.once('stop', callback); 65 | } 66 | 67 | // Opera case - it uses a launcher so we have to kill it somehow without a reference to the process 68 | if (process.platform === 'win32' && this.image) { 69 | child.exec('taskkill /F /IM ' + this.image) 70 | .on('close', this.emit.bind(this, 'stop')); 71 | // ie case on windows machine 72 | } else if (process.platform === 'win32' && this.browserType === 'ie') { 73 | child.exec('taskkill /F /IM iexplore.exe') 74 | .on('close', this.emit.bind(this, 'stop')); 75 | // OSX case with "open" command 76 | } else if (this.command === 'open') { 77 | child.exec('osascript -e \'tell application "' + this.processName + '" to quit\''); 78 | // every other scenario 79 | } else { 80 | this.process.kill(); 81 | } 82 | }; 83 | 84 | module.exports = Instance; 85 | -------------------------------------------------------------------------------- /lib/run.js: -------------------------------------------------------------------------------- 1 | var headless = require('headless'); 2 | var mkdirp = require('mkdirp'); 3 | var os = require('os'); 4 | var fs = require('fs'); 5 | var path = require('path'); 6 | var uid = require('uid').uid; 7 | var assign = require('lodash/assign'); 8 | var Instance = require('./instance'); 9 | var setups = {}; 10 | 11 | /** 12 | * Get the major section of a semver string 13 | * @param {String} version Version string 14 | * @return {Number} major version 15 | */ 16 | function major(version) { 17 | return version.split('.')[0]; 18 | } 19 | 20 | /** 21 | * Copy a file 22 | * @param {String} src Source pathn 23 | * @param {String} dest Destination path 24 | * @param {Function} callback Completion callback 25 | */ 26 | function copy(src, dest, callback) { 27 | var rs = fs.createReadStream(src); 28 | var ws = fs.createWriteStream(dest); 29 | var called = false; 30 | 31 | function done(err) { 32 | if (!called) { 33 | called = true; 34 | callback(err); 35 | } 36 | } 37 | 38 | rs.on('error', done); 39 | ws.on('error', done); 40 | ws.on('close', function () { 41 | done(); 42 | }); 43 | 44 | rs.pipe(ws); 45 | } 46 | 47 | /** 48 | * Check if the given version matches the pattern 49 | * @param {String} version Browser version string 50 | * @param {String} [pattern] Expected version pattern 51 | * @return {Boolean} true if the provided version matches the expected pattern 52 | */ 53 | function matches(version, pattern) { 54 | if (pattern === undefined || pattern === '*') { 55 | return true; 56 | } 57 | 58 | var vs = version.split('.'); 59 | var ps = pattern.split('.'); 60 | 61 | for (var i = 0; i < ps.length; i++) { 62 | if (ps[i] === 'x' || ps[i] === '*') { 63 | continue; 64 | } 65 | 66 | if (ps[i] !== vs[i]) { 67 | return false; 68 | } 69 | } 70 | 71 | return true; 72 | } 73 | 74 | /** 75 | * In the given configuration find a browser matching specified name and version 76 | * @param {Object} config Configuration object 77 | * @param {String} name Browser name 78 | * @param {String} version Browser version 79 | * @return {Object} browser that matches provided name and version 80 | */ 81 | function findMatch(config, name, version) { 82 | var matching = config.browsers.filter(function (b) { 83 | return b.name === name && matches(b.version, version); 84 | }).sort(function (a, b) { 85 | return major(b.version) - major(a.version); 86 | }); 87 | 88 | if (matching.length) { 89 | return matching[0]; 90 | } 91 | } 92 | 93 | function formatNoProxyStandard(options) { 94 | var value = options.noProxy || []; 95 | if (typeof value !== 'string') { 96 | value = value.join(','); 97 | } 98 | return value; 99 | } 100 | 101 | function formatNoProxyChrome(options) { 102 | var value = options.noProxy || []; 103 | if (typeof value !== 'string') { 104 | value = value.join(';'); 105 | } 106 | return value; 107 | } 108 | 109 | /** 110 | * Setup procedure for Firefox browser: 111 | * - create a temporary directory 112 | * - create and write prefs.js file 113 | * - collect command line arguments necessary to launch the browser 114 | * @param {Object} browser Browser object 115 | * @param {Object} options Configuration options 116 | * @param {Function} callback Callback function 117 | */ 118 | setups.firefox = function (browser, options, callback) { 119 | if (options.profile === null) { 120 | // profile: null disables profile setup, so we can skip all of this. Unfortunately 121 | // it's not possible to configure other settings without controlling the profile, 122 | // so we error if you try: 123 | if (options.proxy || options.prefs) { 124 | callback( 125 | new Error( 126 | "Cannot set Firefox proxy and/or prefs options when profile is set to null." 127 | ) 128 | ); 129 | } 130 | 131 | callback(null, options.options, []); 132 | return; 133 | } 134 | 135 | var profileDir = options.profile || path.join(os.tmpdir(), "browser-launcher" + uid(10)); 136 | var file = path.join(profileDir, 'prefs.js'); 137 | var prefs = options.skipDefaults ? {} : { 138 | 'browser.shell.checkDefaultBrowser': false, 139 | 'browser.bookmarks.restore_default_bookmarks': false, 140 | 'dom.disable_open_during_load': false, 141 | 'dom.max_script_run_time': 0, 142 | 'browser.cache.disk.capacity': 0, 143 | 'browser.cache.disk.smart_size.enabled': false, 144 | 'browser.cache.disk.smart_size.first_run': false, 145 | 'browser.sessionstore.resume_from_crash': false, 146 | 'browser.startup.page': 0 147 | }; 148 | 149 | mkdirp.sync(profileDir); 150 | 151 | options.options = options.options || []; 152 | if (!options.profile) { 153 | options.tempDir = profileDir; 154 | } 155 | 156 | if (options.proxy) { 157 | var match = /^(?:http:\/\/)?([^:/]+)(?::(\d+))?/.exec(options.proxy); 158 | var host = JSON.stringify(match[1]); 159 | var port = match[2] || 80; 160 | 161 | assign(prefs, { 162 | 'network.proxy.http': host, 163 | 'network.proxy.http_port': +port, 164 | 'network.proxy.type': 1, 165 | 'network.proxy.no_proxies_on': '"' + formatNoProxyStandard(options) + '"' 166 | }); 167 | } 168 | 169 | if (options.prefs) { 170 | assign(prefs, options.prefs); 171 | } 172 | 173 | prefs = Object.keys(prefs).map(function (name) { 174 | return 'user_pref("' + name + '", ' + prefs[name] + ');'; 175 | }).join('\n'); 176 | 177 | options.options = options.options.concat([ 178 | '--no-remote', 179 | '-profile', profileDir 180 | ]); 181 | 182 | fs.writeFile(file, prefs, function (err) { 183 | if (err) { 184 | callback(err); 185 | } else { 186 | callback(null, options.options, []); 187 | } 188 | }); 189 | }; 190 | 191 | /** 192 | * Setup procedure for IE and Safari browsers: 193 | * - just run callback, can't really set any options 194 | * @param {Object} browser Browser object 195 | * @param {Object} options Configuration options 196 | * @param {Function} callback Callback function 197 | */ 198 | setups.safari = function (browser, options, callback) { 199 | callback(null, [], []); 200 | }; 201 | setups.ie = setups.safari; 202 | 203 | /** 204 | * Setup procedure for Chrome browser: 205 | * - collect command line arguments necessary to launch the browser 206 | * @param {Object} browser Browser object 207 | * @param {Object} options Configuration options 208 | * @param {Function} callback Callback function 209 | */ 210 | setups.chrome = function (browser, options, callback) { 211 | options.options = options.options || []; 212 | var profile = options.profile !== undefined 213 | ? options.profile 214 | : browser.profile; 215 | options.options.push(profile ? '--user-data-dir=' + profile : null); 216 | if (options.proxy) { 217 | options.options.push('--proxy-server=' + options.proxy); 218 | } 219 | 220 | var noProxy = formatNoProxyChrome(options); 221 | if (noProxy) { 222 | options.options.push('--proxy-bypass-list=' + noProxy); 223 | } 224 | 225 | var defaults = [ 226 | '--disable-restore-session-state', 227 | '--no-default-browser-check', 228 | '--disable-popup-blocking', 229 | '--disable-translate', 230 | '--start-maximized', 231 | '--disable-default-apps', 232 | '--disable-sync', 233 | '--enable-fixed-layout', 234 | '--no-first-run', 235 | '--noerrdialogs' 236 | ]; 237 | 238 | callback(null, options.options, defaults); 239 | }; 240 | setups.chromium = setups.chrome; 241 | 242 | // Brave, new MS Edge & Arc are all Chromium based, treated as identical: 243 | setups.msedge = setups.chrome; 244 | setups.brave = setups.chrome; 245 | setups.arc = setups.chrome; 246 | 247 | /** 248 | * Setup procedure for PhantomJS: 249 | * - configure PhantomJS to open res/phantom.js script 250 | * @param {Object} browser Browser object 251 | * @param {Object} options Configuration options 252 | * @param {Function} callback Callback function 253 | */ 254 | setups.phantomjs = function (browser, options, callback) { 255 | options.options = options.options || []; 256 | 257 | callback(null, options.options.concat([ 258 | options.proxy ? '--proxy=' + options.proxy.replace(/^http:\/\//, '') : null, 259 | path.join(__dirname, '../res/phantom.js'), 260 | [] 261 | ])); 262 | }; 263 | 264 | /** 265 | * Setup procedure for Opera browser: 266 | * - copy the default preferences file depending on the Opera version 267 | * (res/operaprefs.ini or res/Preferences) to the profile directory 268 | * - collect command line arguments necessary to launch the browser 269 | * @param {Object} browser Browser object 270 | * @param {Object} options Configuration options 271 | * @param {Function} callback Callback function 272 | */ 273 | setups.opera = function (browser, options, callback) { 274 | var prefs = { 275 | old: 'operaprefs.ini', 276 | blink: 'Preferences' 277 | }; 278 | var engine = { 279 | old: [ 280 | '-nosession', 281 | '-nomail' 282 | ], 283 | // using the same rules as for chrome 284 | blink: [ 285 | '--disable-restore-session-state', 286 | '--no-default-browser-check', 287 | '--disable-popup-blocking', 288 | '--disable-translate', 289 | '--start-maximized', 290 | '--disable-default-apps', 291 | '--disable-sync', 292 | '--enable-fixed-layout', 293 | '--no-first-run', 294 | '--noerrdialogs' 295 | ] 296 | }; 297 | var generation = major(browser.version) >= 15 ? 'blink' : 'old'; 298 | var prefFile = prefs[generation]; 299 | var src = path.join(__dirname, '../res/' + prefFile); 300 | 301 | var profile = options.profile || browser.profile; 302 | mkdirp.sync(profile); // Make sure profile exists 303 | 304 | var dest = path.join(profile, prefFile); 305 | 306 | options.options = options.options || []; 307 | if (generation === 'blink') { 308 | options.options.push(profile ? '--user-data-dir=' + profile : null); 309 | 310 | if (options.proxy) { 311 | options.options.push('--proxy-server=' + options.proxy); 312 | } 313 | 314 | var noProxy = formatNoProxyChrome(options); 315 | if (noProxy) { 316 | options.options.push('--proxy-bypass-list=' + noProxy); 317 | } 318 | } 319 | 320 | copy(src, dest, function (err) { 321 | if (err) { 322 | callback(err); 323 | } else { 324 | callback( 325 | null, 326 | options.options, 327 | engine[generation] 328 | ); 329 | } 330 | }); 331 | }; 332 | 333 | /** 334 | * Run a browser 335 | * @param {Object} config Configuration object 336 | * @param {String} name Browser name 337 | * @param {String} version Browser version 338 | * @return {Function|undefined} function which runs a browser, or undefined if browser can't be located 339 | */ 340 | module.exports = function runBrowser(config, name, version) { 341 | var browser = findMatch(config, name, version); 342 | 343 | if (!browser) { 344 | return undefined; 345 | } 346 | 347 | return function (uri, options, callback) { 348 | function run(customEnv) { 349 | var env = {}; 350 | var cwd = process.cwd(); 351 | 352 | // copy environment variables 353 | Object.keys(process.env).forEach(function (key) { 354 | env[key] = process.env[key]; 355 | }); 356 | 357 | Object.keys(customEnv).forEach(function (key) { 358 | env[key] = customEnv[key]; 359 | }); 360 | 361 | // Shallow clone the browser config, as we may mutate it below 362 | browser = Object.assign({}, browser); 363 | 364 | // setup the browser 365 | setups[browser.type](browser, options, function (err, args, defaultArgs) { 366 | if (err) { 367 | return callback(err); 368 | } 369 | 370 | if (!options.skipDefaults) { 371 | args = args.concat(defaultArgs); 372 | } 373 | 374 | // pass proxy configuration to the new environment 375 | var noProxy = formatNoProxyStandard(options); 376 | if (noProxy && env.no_proxy === undefined) { 377 | env.no_proxy = noProxy; 378 | } 379 | 380 | if (options.proxy && env.http_proxy === undefined) { 381 | env.http_proxy = options.proxy; 382 | } 383 | 384 | if (options.proxy && env.HTTP_PROXY === undefined) { 385 | env.HTTP_PROXY = options.proxy; 386 | } 387 | 388 | // prepare the launch command for Windows systems 389 | if (process.platform === 'win32') { 390 | // ensure all the quotes are removed 391 | browser.command = browser.command.replace(/"/g, ''); 392 | // change directory to the app's base (Chrome) 393 | cwd = path.dirname(browser.command); 394 | } 395 | 396 | // prepare the launch command for OSX systems 397 | if (process.platform === 'darwin' && browser.command.endsWith('.app')) { 398 | // use the binary paths under the hood 399 | 400 | // open --wait-apps --new --fresh -a /Path/To/Executable --args 401 | args.unshift( 402 | '--wait-apps', 403 | ...(!browser.neverStartFresh // Some browsers can't start fresh, so don't bother 404 | ? [ 405 | '--new', 406 | '--fresh', 407 | ] : [] 408 | ), 409 | '-a', 410 | browser.command, 411 | ...((args.length || uri) ? ['--args'] : []), 412 | uri 413 | ); 414 | 415 | browser.processName = browser.command; 416 | browser.command = 'open'; 417 | } else { 418 | args.push(uri); 419 | } 420 | 421 | browser.tempDir = options.tempDir; 422 | 423 | try { 424 | callback(null, new Instance(assign({}, browser, { 425 | args: args.filter(Boolean), 426 | detached: options.detached, 427 | env: env, 428 | cwd: cwd 429 | }))); 430 | } catch (e) { 431 | callback(e); 432 | } 433 | }); 434 | } 435 | 436 | // run a regular browser in a "headless" mode 437 | if (options.headless && !browser.headless) { 438 | headless(function (err, proc, display) { 439 | if (err) { 440 | return callback(err); 441 | } 442 | 443 | run({ 444 | DISPLAY: ':' + display 445 | }); 446 | }); 447 | } else { 448 | run({}); 449 | } 450 | }; 451 | }; 452 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@httptoolkit/browser-launcher", 3 | "version": "2.3.0", 4 | "description": "Detect, launch and stop browser versions", 5 | "main": "index.js", 6 | "types": "./types.d.ts", 7 | "bin": { 8 | "browser-launcher": "./bin.js" 9 | }, 10 | "scripts": { 11 | "build": "tsc --noEmit ./types.d.ts", 12 | "pretest": "npm run build", 13 | "test": "ava" 14 | }, 15 | "directories": { 16 | "example": "example", 17 | "res": "res" 18 | }, 19 | "dependencies": { 20 | "@httptoolkit/osx-find-executable": "^2.0.1", 21 | "headless": "^1.0.0", 22 | "lodash": "^4.17.21", 23 | "mkdirp": "^0.5.0", 24 | "osenv": "^0.1.0", 25 | "rimraf": "^2.6.1", 26 | "simple-plist": "^1.0.0", 27 | "uid": "^2.0.0", 28 | "win-detect-browsers": "^7.0.0" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git://github.com/httptoolkit/browser-launcher.git" 33 | }, 34 | "homepage": "https://github.com/httptoolkit/browser-launcher", 35 | "keywords": [ 36 | "browser", 37 | "headless", 38 | "phantom", 39 | "chrome", 40 | "firefox", 41 | "chromium", 42 | "safari", 43 | "ie", 44 | "opera", 45 | "osx", 46 | "windows" 47 | ], 48 | "author": "Tim Perry ", 49 | "contributors": [ 50 | "James Halliday (http://substack.net)", 51 | "CKSource (http://cksource.com/)", 52 | "benderjs", 53 | "mitchhentges" 54 | ], 55 | "license": "MIT", 56 | "engine": { 57 | "node": ">=12" 58 | }, 59 | "devDependencies": { 60 | "@types/node": "^12.0.2", 61 | "ava": "^0.25.0", 62 | "typescript": "^3.4.5" 63 | }, 64 | "ava": { 65 | "failFast": true, 66 | "files": [ 67 | "test/**/*.js" 68 | ] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /res/Preferences: -------------------------------------------------------------------------------- 1 | { 2 | "browser": { 3 | "check_default_browser": false 4 | }, 5 | "profile": { 6 | "content_settings": { 7 | "clear_on_exit_migrated": true 8 | }, 9 | "default_content_settings": { 10 | "popups": 1 11 | }, 12 | "password_manager_enabled": false 13 | }, 14 | "session": { 15 | "restore_on_startup": 5 16 | }, 17 | "statistics": { 18 | "collection_asked": true, 19 | "collection_enabled": false 20 | } 21 | } -------------------------------------------------------------------------------- /res/operaprefs.ini: -------------------------------------------------------------------------------- 1 | Opera Preferences version 2.1 2 | 3 | [State] 4 | Accept License=1 5 | Run=0 6 | 7 | [User Prefs] 8 | Show Default Browser Dialog=0 9 | Startup Type=2 10 | Home URL=about:blank 11 | Show Close All But Active Dialog=0 12 | Show Close All Dialog=0 13 | Show Crash Log Upload Dialog=0 14 | Show Delete Mail Dialog=0 15 | Show Download Manager Selection Dialog=0 16 | Show Geolocation License Dialog=0 17 | Show Mail Error Dialog=0 18 | Show New Opera Dialog=0 19 | Show Problem Dialog=0 20 | Show Progress Dialog=0 21 | Show Validation Dialog=0 22 | Show Widget Debug Info Dialog=0 23 | Show Startup Dialog=0 24 | Show E-mail Client=0 25 | Show Mail Header Toolbar=0 26 | Show Setupdialog On Start=0 27 | Ask For Usage Stats Percentage=0 28 | Enable Usage Statistics=0 29 | Disable Opera Package AutoUpdate=1 30 | Browser JavaScript=0 31 | 32 | [Install] 33 | Newest Used Version=12.16.1860 34 | -------------------------------------------------------------------------------- /res/phantom.js: -------------------------------------------------------------------------------- 1 | /* global phantom */ 2 | var webpage = require( 'webpage' ), 3 | system = require('system'), 4 | currentUrl = system.args[ system.args.length - 1 ], 5 | page; 6 | 7 | function renderPage( url ) { 8 | page = webpage.create(); 9 | page.windowName = 'my-window'; 10 | page.settings.webSecurityEnabled = false; 11 | 12 | // handle redirects 13 | page.onNavigationRequested = function( url, type, willNavigate, main ) { 14 | if ( main && url != currentUrl ) { 15 | currentUrl = url; 16 | page.close(); 17 | renderPage( url ); 18 | } 19 | }; 20 | 21 | // handle logs 22 | page.onConsoleMessage = function( msg ) { 23 | console.log( 'console: ' + msg ); 24 | }; 25 | 26 | page.onError = function( msg, trace ) { 27 | console.log( 'error:', msg ); 28 | trace.forEach( function( item ) { 29 | console.log( ' ', item.file, ':', item.line ); 30 | } ); 31 | }; 32 | 33 | page.open( url, function( status ) { 34 | if ( status !== 'success' ) { 35 | phantom.exit( 1 ); 36 | } 37 | } ); 38 | } 39 | 40 | renderPage( currentUrl ); 41 | -------------------------------------------------------------------------------- /test/browsers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by mitch on 2/29/16. 3 | */ 4 | import test from 'ava'; 5 | import Browsers from '../lib/browsers'; 6 | 7 | test('browser platforms without any variants', t => { 8 | var browsers = new Browsers({ 9 | firefox: {} 10 | }); 11 | 12 | t.deepEqual(browsers.browserPlatforms(), [{ 13 | type: 'firefox', 14 | darwin: 'firefox', 15 | linux: ['firefox'], 16 | regex: undefined 17 | }]); 18 | }); 19 | 20 | test('browser platforms with multiple variants', t => { 21 | var browsers = new Browsers({ 22 | firefox: { 23 | variants: { 24 | 'firefox': ['firefox'], 25 | 'firefox-developer': ['firefox-developer'] 26 | } 27 | } 28 | }); 29 | 30 | t.deepEqual(browsers.browserPlatforms(), [{ 31 | type: 'firefox', 32 | darwin: 'firefox', 33 | linux: ['firefox'], 34 | regex: undefined 35 | }, { 36 | type: 'firefox', 37 | darwin: 'firefox-developer', 38 | linux: ['firefox-developer'], 39 | regex: undefined 40 | }]); 41 | }); 42 | 43 | test('browser platforms when command is different from variant name', t => { 44 | var browsers = new Browsers({ 45 | chrome: { 46 | variants: { 47 | 'chrome': ['google-chrome'] 48 | } 49 | } 50 | }); 51 | 52 | t.deepEqual(browsers.browserPlatforms(), [{ 53 | type: 'chrome', 54 | darwin: 'chrome', 55 | linux: ['google-chrome'], 56 | regex: undefined 57 | }]); 58 | }); 59 | 60 | test('browser platforms when multiple commands are possible for a variant', t => { 61 | var browsers = new Browsers({ 62 | chrome: { 63 | variants: { 64 | 'chrome': ['google-chrome', 'google-chrome-stable'] 65 | } 66 | } 67 | }); 68 | 69 | t.deepEqual(browsers.browserPlatforms(), [{ 70 | type: 'chrome', 71 | darwin: 'chrome', 72 | linux: ['google-chrome', 'google-chrome-stable'], 73 | regex: undefined 74 | }]); 75 | }); 76 | 77 | test('browser config by type', t => { 78 | var browsers = new Browsers({ 79 | chrome: { 80 | profile: true 81 | }, 82 | firefox: { 83 | profile: false 84 | } 85 | }); 86 | 87 | t.deepEqual(browsers.typeConfig('firefox'), { 88 | profile: false 89 | }); 90 | }); 91 | 92 | test('browser config supports all options', t => { 93 | var browsers = new Browsers({ 94 | chrome: { 95 | startupTime: 1000, 96 | nsaUplink: true, 97 | 'john-cena': 'champ' 98 | } 99 | }); 100 | 101 | t.deepEqual(browsers.typeConfig('chrome'), { 102 | startupTime: 1000, 103 | nsaUplink: true, 104 | 'john-cena': 'champ' 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess } from 'child_process'; 2 | import * as stream from "stream"; 3 | 4 | declare namespace Launcher { 5 | export function detect(callback: (browsers: Browser[]) => void): void; 6 | 7 | interface LaunchOptions { 8 | browser: string; 9 | 10 | version?: string; 11 | proxy?: string; 12 | options?: string[]; 13 | skipDefaults?: boolean; 14 | detached?: boolean; 15 | noProxy?: string | string[]; 16 | headless?: boolean; 17 | prefs?: { [key: string]: any }; 18 | profile?: string | null; 19 | } 20 | 21 | function Launch( 22 | uri: string, 23 | options: string | LaunchOptions, 24 | callback: (error: Error | null, instance: Launcher.BrowserInstance) => void 25 | ): void; 26 | 27 | export type Launch = typeof Launch; 28 | 29 | namespace Launch { 30 | export const browsers: Launcher.Browser[]; 31 | } 32 | 33 | export function update(callback: (error: Error | null, config: object) => void): void; 34 | export function update(configFile: string, callback: (error: Error | null, config: object) => void): void; 35 | 36 | export interface Browser { 37 | name: string; 38 | version: string; 39 | type: string; 40 | command: string; 41 | profile: string | boolean; 42 | } 43 | 44 | export interface BrowserInstance { 45 | command: string; 46 | args: string[]; 47 | image: string; 48 | processName: string; 49 | pid: number; 50 | 51 | process: ChildProcess; 52 | stderr: stream.Readable; 53 | stdout: stream.Readable; 54 | 55 | stop(): void; 56 | } 57 | } 58 | 59 | declare function Launcher(configPath: string, callback: (error: Error | null, launch: typeof Launcher.Launch) => void): void; 60 | 61 | export = Launcher; 62 | --------------------------------------------------------------------------------