diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5e0fd33 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, +and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by +the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all +other entities that control, are controlled by, or are under common +control with that entity. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the +direction or management of such entity, whether by contract or +otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity +exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical +transformation or translation of a Source form, including but +not limited to compiled object code, generated documentation, +and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or +Object form, made available under the License, as indicated by a +copyright notice that is included in or attached to the work +(an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based on (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including +the original version of the Work and any modifications or additions +to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of +the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, +and issue tracking systems that are managed by, or on behalf of, the +Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity +on behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the +Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, +where such license applies only to those patent claims licensable +by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) +with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the +Work or Derivative Works thereof in any medium, with or without +modifications, and in Source or Object form, provided that You +meet the following conditions: + +(a) You must give any other recipients of the Work or +Derivative Works a copy of this License; and + +(b) You must cause any modified files to carry prominent notices +stating that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works +that You distribute, all copyright, patent, trademark, and +attribution notices from the Source form of the Work, +excluding those notices that do not pertain to any part of +the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its +distribution, then any Derivative Works that You distribute must +include a readable copy of the attribution notices contained +within such NOTICE file, excluding those notices that do not +pertain to any part of the Derivative Works, in at least one +of the following places: within a NOTICE text file distributed +as part of the Derivative Works; within the Source form or +documentation, if provided along with the Derivative Works; or, +within a display generated by the Derivative Works, if and +wherever such third-party notices normally appear. The contents +of the NOTICE file are for informational purposes only and +do not modify the License. You may add Your own attribution +notices within Derivative Works that You distribute, alongside +or as an addendum to the NOTICE text from the Work, provided +that such additional attribution notices cannot be construed +as modifying the License. + +You may add Your own copyright statement to Your modifications and +may provide additional or different license terms and conditions +for use, reproduction, or distribution of Your modifications, or +for any such Derivative Works as a whole, provided Your use, +reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, +any Contribution intentionally submitted for inclusion in the Work +by You to the Licensor shall be under the terms and conditions of +this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify +the terms of any separate license agreement you may have executed +with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade +names, trademarks, service marks, or product names of the Licensor, +except as required for reasonable and customary use in describing the +origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or +agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, +unless required by applicable law (such as deliberate and grossly +negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, +incidental, or consequential damages of any character arising as a +result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing +the Work or Derivative Works thereof, You may choose to offer, +and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this +License. However, in accepting such obligations, You may act only +on Your own behalf and on Your sole responsibility, not on behalf +of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following +boilerplate notice, with the fields enclosed by brackets "{}" +replaced with your own identifying information. (Don't include +the brackets!) The text should be enclosed in the appropriate +comment syntax for the file format. We also recommend that a +file or class name and description of purpose be included on the +same "printed page" as the copyright notice for easier +identification within third-party archives. + +Copyright {yyyy} {name of copyright owner} + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea8bd0b --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +[![Travis Build Status](https://travis-ci.org/GoogleChrome/selenium-assistant.svg?branch=master)](https://travis-ci.org/GoogleChrome/selenium-assistant) [![Coverage Status](https://coveralls.io/repos/github/GoogleChrome/selenium-assistant/badge.svg?branch=master)](https://coveralls.io/github/GoogleChrome/selenium-assistant?branch=master) [![Dependency Status](https://david-dm.org/googlechrome/selenium-assistant.svg)](https://david-dm.org/googlechrome/selenium-assistant) [![devDependency Status](https://david-dm.org/googlechrome/selenium-assistant/dev-status.svg)](https://david-dm.org/googlechrome/selenium-assistant#info=devDependencies) + +# selenium-assistant + +It can be a challenge to manage which browsers you can / can't run your tests against +as well as blocking those that have known issues. + +This library is a simple set of helpers that helps find the browsers available +in the current environment the tests are run on and adding a set of simple +methods to check for versions and print useful info for logs. + +

+ View Docs Here +

+ +# Contributing + +If you wish to help with this project, please feel free to raise an issue, +raise a PR or add tests to prove bad behaviour. + +To run the tests simple run: + + npm run test diff --git a/package.json b/package.json new file mode 100644 index 0000000..122ddf4 --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "selenium-assistant", + "version": "0.5.3", + "description": "A node module to help with use of selenium driver", + "main": "src/index.js", + "scripts": { + "publish-release": "publish-release.sh", + "publish-docs": "publish-docs.sh", + "build": "echo 'Skip Build Step.'", + "bundle": "./project/create-release-bundle.sh", + "build-docs": "jsdoc -c ./jsdoc.conf -d ", + "lint": "eslint './**/*.js'", + "test": "npm run lint && mocha", + "istanbul": "npm run lint && istanbul cover _mocha", + "coveralls": "cat ./coverage/lcov.info | coveralls" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/GoogleChrome/selenium-assistant.git" + }, + "keywords": [ + "selenium", + "webdriver" + ], + "author": "Matt Gaunt", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/GoogleChrome/selenium-assistant/issues" + }, + "homepage": "https://github.com/GoogleChrome/selenium-assistant#readme", + "devDependencies": { + "chai": "^3.5.0", + "chromedriver": "^2.24.0", + "coveralls": "^2.11.12", + "eslint": "^3.5.0", + "eslint-config-google": "^0.6.0", + "istanbul": "^0.4.5", + "jsdoc": "^3.4.1", + "mocha": "^3.0.2", + "npm-publish-scripts": "2.0.4", + "operadriver": "^0.2.2", + "sinon": "^1.17.5" + }, + "dependencies": { + "chalk": "^1.1.3", + "del": "^2.2.0", + "dmg": "^0.1.0", + "fs-extra": "^0.30.0", + "mkdirp": "^0.5.1", + "request": "^2.72.0", + "selenium-webdriver": "^3.0.0-beta-2", + "which": "^1.2.11", + "yauzl": "^2.5.0" + } +} diff --git a/src/application-state.js b/src/application-state.js new file mode 100644 index 0000000..382af72 --- /dev/null +++ b/src/application-state.js @@ -0,0 +1,87 @@ +/* + Copyright 2016 Google Inc. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +'use strict'; + +const path = require('path'); + +/** + * This class is a super basic class that stores shared state across the + * classes in this library / module. + * + * @private + */ +class ApplicationState { + constructor() { + this._installDir = this.getDefaultInstallLocation(); + this._addGeckoDriverToPath(); + } + + /** + * This method is required until geckodriver can be installed + * and added to the path as an NPM module. + */ + _addGeckoDriverToPath() { + // Add geckodriver's path to process path + process.env.PATH += ':' + path.join(this._installDir, 'geckodriver'); + } + + /** + * To define where browsers should be installed and searched for, + * define the path by calling this method. + * @param {String} newInstallDir The path to install new browsers in to. + */ + setInstallDirectory(newInstallDir) { + if (newInstallDir) { + this._installDir = path.resolve(newInstallDir); + } else { + this._installDir = this.getDefaultInstallLocation(); + } + + this._addGeckoDriverToPath(); + } + + /** + * To get ther current path of where Browsers are installed and searched for, + * call this method. + * @return {String} The current path for installed browsers. + */ + getInstallDirectory() { + return this._installDir; + } + + /** + * When this library is used a default path is used for installing and + * searched for browsers. This allows multiple projects using this library + * to share the same browsers, saving space on the users machine. + * @return {String} The default path for installed browsers. + */ + getDefaultInstallLocation() { + let installLocation; + const homeLocation = process.env.HOME || process.env.USERPROFILE; + if (homeLocation) { + installLocation = homeLocation; + } else { + installLocation = '.'; + } + + const folderName = process.platform === 'win32' ? + 'selenium-assistant' : '.selenium-assistant'; + return path.join(installLocation, folderName); + } +} + +module.exports = new ApplicationState(); diff --git a/src/browser-manager.js b/src/browser-manager.js new file mode 100644 index 0000000..42fc05c --- /dev/null +++ b/src/browser-manager.js @@ -0,0 +1,89 @@ +/* + Copyright 2016 Google Inc. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +'use strict'; + +const ChromeWebDriverBrowser = require('./webdriver-browser/chrome'); +const FirefoxWebDriverBrowser = require('./webdriver-browser/firefox'); +const OperaWebDriverBrowser = require('./webdriver-browser/opera'); +const SafariWebDriverBrowser = require('./webdriver-browser/safari'); + +/** + * This class is a simple helper to define the possible permutations of + * browsers and create the objects which are returned by + * {@link SeleniumAssistant}. + * + * @private + */ +class BrowserManager { + /** + *

This method returns the full list of browsers this library supports, + * regardless of whether the current environment has access to them or not. + *

+ * + *

As more browsers are specifically added, this list will grow.

+ * + * @return {Array} An array of all the possible browsers + * this library supports. + */ + getSupportedBrowsers() { + return [ + this.createWebDriverBrowser('chrome', 'stable'), + this.createWebDriverBrowser('chrome', 'beta'), + this.createWebDriverBrowser('chrome', 'unstable'), + + this.createWebDriverBrowser('firefox', 'stable'), + this.createWebDriverBrowser('firefox', 'beta'), + this.createWebDriverBrowser('firefox', 'unstable'), + + this.createWebDriverBrowser('opera', 'stable'), + this.createWebDriverBrowser('opera', 'beta'), + this.createWebDriverBrowser('opera', 'unstable'), + + this.createWebDriverBrowser('safari', 'stable'), + this.createWebDriverBrowser('safari', 'beta') + ]; + } + + /** + *

A very simple method to create a {@link WebDriverBrowser} instance + * with the current config based on minimal config.

+ * + *

This method will throw if you request a browser that this + * library doesn't support.

+ * + * @param {String} browserId The selenium browser Id 'chrome', 'firefox', etc + * @param {String} release The release you want the browser to be on + * 'stable', 'beta' or 'unstable' + * @return {WebDriverBrowser} An instance of the browser you requested. + */ + createWebDriverBrowser(browserId, release) { + switch (browserId) { + case 'chrome': + return new ChromeWebDriverBrowser(release); + case 'firefox': + return new FirefoxWebDriverBrowser(release); + case 'opera': + return new OperaWebDriverBrowser(release); + case 'safari': + return new SafariWebDriverBrowser(release); + default: + throw new Error('Unknown web driver browser request: ', browserId); + } + } +} + +module.exports = new BrowserManager(); diff --git a/src/download-manager.js b/src/download-manager.js new file mode 100644 index 0000000..f57f308 --- /dev/null +++ b/src/download-manager.js @@ -0,0 +1,711 @@ +/* + Copyright 2016 Google Inc. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +'use strict'; + +const spawn = require('child_process').spawn; +const path = require('path'); +const fs = require('fs'); +const request = require('request'); +const mkdirp = require('mkdirp'); +const del = require('del'); +const dmg = require('dmg'); +const fse = require('fs-extra'); +const yauzl = require('yauzl'); + +const application = require('./application-state.js'); +const browserManager = require('./browser-manager.js'); + +/** + * The download manager's sole job is to download browsers and drivers. + * The executable paths for these downloaded browsers will be discovered + * by the individual {@link WebDriverBrowser} instances. + * + * @private + */ +class DownloadManager { + + _getFirefoxDriverDownloadURL() { + return new Promise((resolve, reject) => { + const requestOptions = { + url: 'https://api.github.com/repos/mozilla/geckodriver/releases', + headers: { + 'User-Agent': 'request' + } + }; + if (process.env.GH_TOKEN) { + requestOptions.url = requestOptions.url + '?access_token=' + + process.env.GH_TOKEN; + } + request(requestOptions, + (err, response) => { + if (err) { + return reject(err); + } + + if (response.statusCode !== 200) { + return reject(new Error('Unable to get valid response for Github. ' + + JSON.parse(response.body).message)); + } + const allReleases = JSON.parse(response.body); + let selectedRelease; + const blackList = [ + // v0.10.0 requires selenium-webdriver 3.0.0 + // 'v0.10.0' + ]; + for (let i = 0; i < allReleases.length; i++) { + const release = allReleases[i]; + + // Block releases here if needed + if (blackList.indexOf(release['tag_name']) !== -1) { // eslint-disable-line dot-notation + continue; + } + + if (!selectedRelease) { + selectedRelease = release; + } + } + const releaseAssets = selectedRelease.assets; + switch (process.platform) { + case 'linux': + releaseAssets.forEach(download => { + if (download.name.indexOf('linux64') !== -1) { + return resolve({ + url: download.browser_download_url, + name: download.name + }); + } + }); + break; + case 'darwin': + releaseAssets.forEach(download => { + if (download.name.indexOf('mac') !== -1) { + return resolve({ + url: download.browser_download_url, + name: download.name + }); + } + }); + break; + case 'windows': + releaseAssets.forEach(download => { + if (download.name.indexOf('win64') !== -1) { + return resolve({ + url: download.browser_download_url, + name: download.name + }); + } + }); + break; + default: + return reject(new Error('Unsupported platform: ' + + process.platform)); + } + + return reject(new Error('Unable to find appropriate download for ' + + 'this environment.')); + }); + }); + } + + /** + * This method retrieves the Firefox Gecko driver, and makes it + * executable. It's installed to the directory defined via the + * application get/setInstallDir() and it's added to the processes + * PATH for use by selenium-webdriver. + * @return {Promise} Resolves when the driver is downloaded + */ + downloadFirefoxDriver() { + let geckoDriverPath = path.join( + application.getInstallDirectory(), 'geckodriver'); + + // TODO: Should check if geckodriver is up to date but at the moment + // updates seem critical so probably worth always checking for latest + + return new Promise((resolve, reject) => { + mkdirp(geckoDriverPath, err => { + if (err) { + return reject(err); + } + resolve(); + }); + }) + .then(() => { + return this._getFirefoxDriverDownloadURL(); + }) + .then(downloadInfo => { + return new Promise((resolve, reject) => { + // Make sure we know what we are dealing with + const expectExtension = '.tar.gz'; + if (downloadInfo.name.indexOf(expectExtension) !== + (downloadInfo.name.length - expectExtension.length)) { + return reject(new Error(`Unexpected file extension for ` + + `${downloadInfo.name}`)); + } + + const filePath = path.join(geckoDriverPath, downloadInfo.name); + const file = fs.createWriteStream(filePath); + + request(downloadInfo.url, err => { + if (err) { + return reject(err); + } + + resolve({ + filename: downloadInfo.name, + filePath: filePath + }); + }) + .pipe(file); + }); + }) + .then(fileInfo => { + return new Promise(function(resolve, reject) { + const untarProcess = spawn('tar', [ + 'xvzf', + fileInfo.filePath, + '-C', + geckoDriverPath + ]); + + untarProcess.on('exit', code => { + if (code === 0) { + try { + const extractedFileName = 'geckodriver'; + fs.chmodSync(path.join(geckoDriverPath, extractedFileName), + '755'); + return resolve(fileInfo.filePath); + } catch (err) { + return reject(err); + } + } + + reject(new Error('Unable to extract tar')); + }); + }); + }) + .then(filePath => { + return del(filePath, {force: true}); + }); + } + + /** + * This method will download a browser if it is needed (i.e. can't be found + * in the usual system location or in the install directory). + * @param {String} browserId This is the Selenium ID of the browser you wish + * to download ('chrome', 'firefox', 'opera'). + * @param {String} release This downloads the browser on a particular track + * and can be 'stable', 'beta' or 'unstable' + * @param {Boolean} [force=false] + * If you want to install the browser regardless + * of any existing installs of the process, pass + * in true. + * @return {Promise} Promise resolves once the browser has been + * downloaded and ready for use. + */ + downloadBrowser(browserId, release, force) { + let forceDownload = force || false; + let installDir = application.getInstallDirectory(); + + const browserInstance = browserManager + .createWebDriverBrowser(browserId, release); + if (!forceDownload && browserInstance.isValid()) { + return Promise.resolve(); + } + + switch (browserId) { + case 'chrome': + return this._downlaodChrome(release, installDir); + case 'firefox': + return this._downloadFirefox(release, installDir); + case 'opera': + return this._downloadOpera(release, installDir); + default: + throw new Error(`Apologies, but ${browserId} can't be downloaded ` + + `with this tool`); + } + } + + _downlaodChrome(release, installDir) { + let downloadUrl; + let fileExtension = null; + let chromeProduct = null; + let chromeOSXAppName = null; + + switch (release) { + case 'stable': + chromeProduct = 'google-chrome-stable'; + break; + case 'beta': + chromeProduct = 'google-chrome-beta'; + break; + case 'unstable': + chromeProduct = 'google-chrome-unstable'; + break; + default: + throw new Error('Unknown release.', release); + } + + switch (process.platform) { + case 'linux': { + fileExtension = 'deb'; + downloadUrl = `https://dl.google.com/linux/direct/${chromeProduct}_current_amd64.deb`; + break; + } + case 'darwin': + fileExtension = 'dmg'; + switch (release) { + case 'stable': + // Must leave in GGRO as without it, the dmg will be for an + // old version of Chrome + downloadUrl = `https://dl.google.com/chrome/mac/stable/GGRO/` + + `googlechrome.dmg`; + chromeOSXAppName = 'Google Chrome.app'; + break; + case 'beta': + downloadUrl = `https://dl.google.com/chrome/mac/beta/` + + `GoogleChrome.dmg`; + chromeOSXAppName = 'Google Chrome.app'; + break; + case 'unstable': + downloadUrl = `https://dl.google.com/chrome/mac/dev/` + + `GoogleChrome.dmg`; + chromeOSXAppName = 'Google Chrome.app'; + break; + default: + throw new Error('Unknown release type: ' + release); + } + break; + default: + throw new Error('Unsupport platform.', process.platform); + } + + const finalBrowserPath = path.join(installDir, 'chrome', release); + return new Promise((resolve, reject) => { + mkdirp(installDir, err => { + if (err) { + return reject(err); + } + resolve(); + }); + }) + .then(() => { + return new Promise((resolve, reject) => { + const filePath = path.join(installDir, chromeProduct + '.' + + fileExtension); + const file = fs.createWriteStream(filePath); + request(downloadUrl, err => { + if (err) { + return reject(err); + } + + resolve(filePath); + }) + .pipe(file); + }); + }) + .then(filePath => { + return new Promise((resolve, reject) => { + mkdirp(finalBrowserPath, err => { + if (err) { + return reject(err); + } + resolve(filePath); + }); + }); + }) + .then(filePath => { + switch (fileExtension) { + case 'deb': + return new Promise(function(resolve, reject) { + // dpkg -x app.deb /path/to/target/dir/ + const dpkgProcess = spawn('dpkg', [ + '-x', + filePath, + finalBrowserPath + ], {stdio: 'inherit'}); + + dpkgProcess.on('exit', code => { + if (code === 0) { + return resolve(filePath); + } + + reject(new Error('Unable to extract deb')); + }); + }); + case 'dmg': + return new Promise((resolve, reject) => { + dmg.mount(filePath, (err, mountedPath) => { + if (err) { + return reject(err); + } + + fse.copySync( + path.join(mountedPath, chromeOSXAppName), + path.join(installDir, 'chrome', release, chromeOSXAppName) + ); + + dmg.unmount(mountedPath, err => { + if (err) { + console.error('Unable to unmount dmg.'); + reject(err); + } + + resolve(); + }); + }); + }); + default: + throw new Error('Unknown file extension: ', fileExtension); + } + }) + .then(filePath => { + return del(filePath, {force: true}); + }); + } + + _downloadFirefox(release, installDir) { + let ffProduct = null; + let ffPlatformId = null; + let fileExtension = null; + let firefoxMacApp = null; + + switch (release) { + case 'stable': + firefoxMacApp = 'Firefox.app'; + ffProduct = 'firefox-latest'; + break; + case 'beta': + firefoxMacApp = 'Firefox.app'; + ffProduct = 'firefox-beta-latest'; + break; + case 'unstable': + firefoxMacApp = 'FirefoxNightly.app'; + ffProduct = 'firefox-nightly-latest'; + break; + default: + throw new Error('Unknown release.', release); + } + + switch (process.platform) { + case 'linux': + ffPlatformId = 'linux64'; + fileExtension = '.tar.gz'; + break; + case 'darwin': + ffPlatformId = 'osx'; + fileExtension = '.dmg'; + break; + default: + throw new Error('Unsupport platform.', process.platform); + } + + const downloadUrl = `https://download.mozilla.org/?product=${ffProduct}&lang=en-US&os=${ffPlatformId}`; + return new Promise((resolve, reject) => { + mkdirp(installDir, err => { + if (err) { + return reject(err); + } + resolve(); + }); + }) + .then(() => { + return new Promise((resolve, reject) => { + const filePath = path.join(installDir, ffProduct + fileExtension); + const file = fs.createWriteStream(filePath); + request(downloadUrl, err => { + if (err) { + return reject(err); + } + + resolve(filePath); + }) + .pipe(file); + }); + }) + .then(filePath => { + return new Promise((resolve, reject) => { + mkdirp(path.join(installDir, 'firefox', release), err => { + if (err) { + return reject(err); + } + resolve(filePath); + }); + }); + }) + .then(filePath => { + if (fileExtension === '.tar.gz') { + return new Promise((resolve, reject) => { + const untarProcess = spawn('tar', [ + 'xvjf', + filePath, + '--directory', + path.join(installDir, 'firefox', release), + '--strip-components', + 1 + ]); + + untarProcess.on('exit', code => { + if (code === 0) { + return resolve(filePath); + } + + reject(new Error('Unable to extract tar')); + }); + }); + } else if (fileExtension === '.dmg') { + return new Promise((resolve, reject) => { + dmg.mount(filePath, (err, mountedPath) => { + if (err) { + return reject(err); + } + + fse.copySync( + path.join(mountedPath, firefoxMacApp), + path.join(installDir, 'firefox', release, firefoxMacApp) + ); + + dmg.unmount(mountedPath, err => { + if (err) { + console.error('Unable to unmount dmg.'); + reject(err); + } + + resolve(); + }); + }); + }); + } + + throw new Error('Unable to handle downloaded file: ', downloadUrl); + }) + .then(filePath => { + return del(filePath, {force: true}); + }); + } + + _downloadOpera(release, installDir) { + let downloadUrl; + let fileExtension = null; + let operaProduct = null; + let operaOSXAppName = null; + + switch (release) { + case 'stable': + operaProduct = 'opera-stable'; + break; + case 'beta': + operaProduct = 'opera-beta'; + break; + case 'unstable': + operaProduct = 'opera-unstable'; + break; + default: + throw new Error('Unknown release.', release); + } + + switch (process.platform) { + /** case 'linux': + fileExtension = 'deb'; + switch (release) { + case 'stable': + downloadUrl = 'http://www.opera.com/download/get/?id=39598&location=413¬hanks=yes&sub=marine'; + break; + case 'beta': + downloadUrl = 'http://www.opera.com/download/get/?id=39574&location=410¬hanks=yes&sub=marine'; + break; + case 'unstable': + downloadUrl = 'http://www.opera.com/download/get/?id=39580&location=413¬hanks=yes&sub=marine'; + break; + default: + throw new Error('Unknown release.', release); + } + break;**/ + case 'darwin': + fileExtension = 'zip'; + switch (release) { + case 'stable': + operaOSXAppName = 'Opera Installer.app'; + downloadUrl = 'http://net.geo.opera.com/opera/stable/mac'; + break; + case 'beta': + operaOSXAppName = 'Opera Beta Installer.app'; + downloadUrl = 'http://net.geo.opera.com/opera/beta/mac'; + break; + case 'unstable': + operaOSXAppName = 'Opera Developer Installer.app'; + downloadUrl = 'http://net.geo.opera.com/opera/developer/mac'; + break; + default: + throw new Error('Unknown release.', release); + } + break; + default: + throw new Error('Unsupported platform for opera: ' + process.platform); + } + + const finalBrowserPath = path.join(installDir, 'opera', release); + return new Promise((resolve, reject) => { + mkdirp(installDir, err => { + if (err) { + return reject(err); + } + resolve(); + }); + }) + .then(() => { + return new Promise((resolve, reject) => { + const filePath = path.join(installDir, operaProduct + '.' + + fileExtension); + const file = fs.createWriteStream(filePath); + request(downloadUrl, err => { + if (err) { + return reject(err); + } + + resolve(filePath); + }) + .pipe(file); + }); + }) + .then(filePath => { + // On Os X, the Installer runs so you can't define where to save it. + if (process.platform !== 'darwin') { + return new Promise((resolve, reject) => { + mkdirp(finalBrowserPath, err => { + if (err) { + return reject(err); + } + resolve(filePath); + }); + }); + } + + return filePath; + }) + .then(filePath => { + switch (fileExtension) { + case 'deb': + return new Promise(function(resolve, reject) { + // dpkg -x app.deb /path/to/target/dir/ + const dpkgProcess = spawn('dpkg', [ + '-x', + filePath, + finalBrowserPath + ], {stdio: 'inherit'}); + + dpkgProcess.on('exit', code => { + if (code === 0) { + return resolve(filePath); + } + + reject(new Error('Unable to extract deb')); + }); + }); + case 'zip': + return new Promise(function(resolve, reject) { + yauzl.open(filePath, {lazyEntries: true}, function(err, zipfile) { + if (err) { + return reject(err); + } + + zipfile.readEntry(); + zipfile.on('entry', entry => { + try { + // directory file names end with '/' + if (/\/$/.test(entry.fileName)) { + mkdirp.sync( + path.join( + application.getInstallDirectory(), + entry.fileName + ) + ); + + zipfile.readEntry(); + } else { + // file entry + zipfile.openReadStream(entry, (err, readStream) => { + if (err) { + return reject(err); + } + + // ensure parent directory exists + mkdirp.sync( + path.join( + application.getInstallDirectory(), + path.dirname(entry.fileName) + ) + ); + + const entryPath = path.join( + application.getInstallDirectory(), + entry.fileName + ); + readStream.pipe( + fs.createWriteStream(entryPath) + ); + + readStream.on('end', () => { + fs.chmodSync(entryPath, '755'); + + zipfile.readEntry(); + }); + }); + } + } catch (err) { + reject(err); + } + }); + zipfile.on('end', () => { + zipfile.close(); + resolve(filePath); + }); + }); + }) + .then(filePath => { + const currentAppPath = path.join(application.getInstallDirectory(), + operaOSXAppName); + return new Promise((resolve, reject) => { + const openProcess = spawn('open', [ + '--wait-apps', + currentAppPath + ], {stdio: 'inherit'}); + + openProcess.on('exit', code => { + if (code === 0) { + // It worked + return resolve(filePath); + } + + reject(new Error('Unable to open installer ' + code)); + }); + }) + .then(filePath => { + return del(currentAppPath, {force: true}) + .then(() => filePath); + }); + }); + default: + throw new Error('Unknown file extension'); + } + }) + .then(filePath => { + return del(filePath, {force: true}); + }); + } +} + +module.exports = new DownloadManager(); diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..15ba590 --- /dev/null +++ b/src/index.js @@ -0,0 +1,254 @@ +/* + Copyright 2016 Google Inc. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +'use strict'; + +const chalk = require('chalk'); + +const application = require('./application-state.js'); +const browserManager = require('./browser-manager.js'); +const downloadManager = require('./download-manager.js'); + +/** + * SeleniumAssistant is a class that makes + * it easier to download, interegate and launch a browser + * for running tests with selenium. + * + * @example Usage in Node + * const seleniumAssistant = require('selenium-assistant'); + * seleniumAssistant.printAvailableBrowserInfo(); + * + * const browsers = seleniumAssistant.getAvailableBrowsers(); + * browsers.forEach(browser => { + * console.log(browsers.getPrettyName()); + * console.log(browsers.getReleaseName()); + * }); + */ +class SeleniumAssistant { + + /** + * This returns the path of where browsers are downloaded to. + * @return {String} Path of downloaded browsers + */ + getBrowserInstallDir() { + return application.getInstallDirectory(); + } + + /** + * To change where browsers are downloaded to, call this method + * before calling {@link downloadBrowser} and + * {@link getAvailableBrowsers}. + * + * By default, this will install under `.selenium-assistant` in + * your home directory on OS X and Linux, or just `selenium-assistant` + * in your home directory on Windows. + * + * @param {String} newInstallDir Path to download browsers to. Pass in + * null to use default path. + */ + setBrowserInstallDir(newInstallDir) { + application.setInstallDirectory(newInstallDir); + } + + /** + *

The downloadBrowser() function is a helper method what will + * grab a browser on a specific release channel.

+ * + *

If the request browser is already installed, it will resolve + * the promise and not download anything.

+ * + *

This is somewhat experimental, so be prepared for issues.

+ * + * @param {String} browserId The selenium id of the browser you wish + * to download. + * @param {String} release String of the release channel, can be + * 'stable', 'beta' or 'unstable' + * @param {Boolean} [force=false] Force download of a browser regardless + * of whether it exists already or not. + * @return {Promise} A promise is returned which resolves + * once the browser has been downloaded. + */ + downloadBrowser(browserId, release, force) { + return downloadManager.downloadBrowser(browserId, release, force); + } + + /** + * At the time of writing Firefox doesn't have a friendly node wrapper + * for it's selenium driver (June 2016), so this method will get it + * and install it in the current directory so tests can find it. + * @return {Promise} Resolves when the requires Firefox driver is + * doesnloaded. + */ + downloadFirefoxDriver() { + return downloadManager.downloadFirefoxDriver(); + } + + /** + * If you want a specific browser you can use to retrieve although + * you should use {@link WebDriverBrowser#isValid} to check if the + * browser is available in the current environment. + * + * @param {String} browserId The selenium id of the browser you want. + * @param {String} release The release of the browser you want. Either + * 'stable', 'beta' or 'unstable.' + * @return {WebDriverBrowser} The WebDriverBrowser instance that represents + * your request. + */ + getBrowser(browserId, release) { + return browserManager.createWebDriverBrowser(browserId, release); + } + + /** + *

This method returns a list of discovered browsers in the current + * environment.

+ * + *

This method will throw an error if run on a platform other than + * OS X and Linux.

+ * + * @return {Array} Array of browsers discovered in the + * current environment. + */ + getAvailableBrowsers() { + if (process.platform !== 'darwin' && process.platform !== 'linux') { + throw new Error('Sorry this library only supports OS X and Linux.'); + } + + let webdriveBrowsers = browserManager.getSupportedBrowsers(); + webdriveBrowsers = webdriveBrowsers.filter(webdriverBrowser => { + if (!webdriverBrowser.isValid()) { + return false; + } + + return true; + }); + + return webdriveBrowsers; + } + + /** + *

This method prints out a table of info for all available browsers + * on the current environment.

+ * + *

Useful if you are testing on travis and want to see what tests + * should be running.

+ * + * @param {Boolean} [printToConsole=true] - If you wish to prevent + * the table being printed to the console, you can suppress it by + * passing in false and simply use the string response. + * @return {String} Returns table of information as a string. + */ + printAvailableBrowserInfo(printToConsole) { + if (typeof printToConsole === 'undefined') { + printToConsole = true; + } + + var browsers = this.getAvailableBrowsers(); + const rows = []; + rows.push([ + 'Browser Name', + 'Browser Version', + 'Path' + ]); + + browsers.forEach(browser => { + rows.push([ + browser.getPrettyName(), + browser.getVersionNumber().toString(), + browser.getExecutablePath() + ]); + }); + + const noOfColumns = rows[0].length; + const rowLengths = []; + for (let i = 0; i < noOfColumns; i++) { + let currentRowMaxLength = 0; + rows.forEach(row => { + currentRowMaxLength = Math.max( + currentRowMaxLength, row[i].length); + }); + rowLengths[i] = currentRowMaxLength; + } + + let totalRowLength = rowLengths.reduce((a, b) => a + b, 0); + + // Account for spaces and markers + totalRowLength += (noOfColumns * 3) + 1; + + let outputString = chalk.gray('-'.repeat(totalRowLength)) + '\n'; + rows.forEach((row, rowIndex) => { + const color = rowIndex === 0 ? chalk.bold : chalk.blue; + let coloredRows = row.map((column, columnIndex) => { + const padding = rowLengths[columnIndex] - column.length; + if (padding > 0) { + return color(column) + ' '.repeat(padding); + } + return color(column); + }); + + let rowString = coloredRows.join(' | '); + + outputString += '| ' + rowString + ' |\n'; + }); + + outputString += chalk.gray('-'.repeat(totalRowLength)) + '\n'; + + if (printToConsole) { + console.log(outputString); + } + + return outputString; + } + + /** + *

Once a web driver is no longer needed call this method to kill it. The + * promise resolves once the browser is closed and clean up has been done.

+ * + *

This is a basic helper that adds a timeout to the end of killling + * driver to account for shutdown time and the issues that can cause.

+ * + * @param {WebDriver} driver Instance of a {@link http://selenium.googlecode.com/git/docs/api/javascript/class_webdriver_WebDriver.html | WebDriver} + * @return {Promise} Promise that resolves once the browser is killed. + */ + killWebDriver(driver) { + if (typeof driver === 'undefined' || driver === null) { + return Promise.resolve(); + } + + if (!driver.quit || typeof driver.quit !== 'function') { + return Promise.reject(new Error('Unable to find a quit method on the ' + + 'web driver.')); + } + + // Sometimes calling driver.quit() on Chrome, doesn't work, + // so this timeout offers a semi-decent fallback + let quitTimeout; + return new Promise(resolve => { + quitTimeout = setTimeout(resolve, 2000); + + driver.quit() + .then(resolve, resolve); + }) + .then(() => { + clearTimeout(quitTimeout); + + return new Promise((resolve, reject) => { + setTimeout(resolve, 2000); + }); + }); + } +} + +module.exports = new SeleniumAssistant(); diff --git a/src/webdriver-browser/chrome.js b/src/webdriver-browser/chrome.js new file mode 100644 index 0000000..6a6b716 --- /dev/null +++ b/src/webdriver-browser/chrome.js @@ -0,0 +1,165 @@ +/* + Copyright 2016 Google Inc. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const which = require('which'); +const chalk = require('chalk'); +const seleniumChrome = require('selenium-webdriver/chrome'); +const WebDriverBrowser = require('./web-driver-browser'); +const application = require('../application-state.js'); + +/** + *

Handles the prettyName and executable path for Chrome browser.

+ * + * @private + * @extends WebDriverBrowser + */ +class ChromeWebDriverBrowser extends WebDriverBrowser { + constructor(release) { + let prettyName = 'Google Chrome'; + + if (release === 'stable') { + prettyName += ' Stable'; + } else if (release === 'beta') { + prettyName += ' Beta'; + } else if (release === 'unstable') { + prettyName += ' Dev'; + } + + super( + prettyName, + release, + 'chrome', + new seleniumChrome.Options() + ); + } + + _findInInstallDir() { + let defaultDir = application.getInstallDirectory(); + let expectedPath; + if (process.platform === 'linux') { + let chromeSubPath = 'chrome/google-chrome'; + if (this._release === 'beta') { + chromeSubPath = 'chrome-beta/google-chrome-beta'; + } else if (this._release === 'unstable') { + chromeSubPath = 'chrome-unstable/google-chrome-unstable'; + } + + expectedPath = path.join( + defaultDir, 'chrome', this._release, 'opt/google/', + chromeSubPath); + } else if (process.platform === 'darwin') { + let chromeAppName = 'Google Chrome'; + if (this._release === 'beta') { + chromeAppName = 'Google Chrome'; + } else if (this._release === 'unstable') { + chromeAppName = 'Google Chrome'; + } + + expectedPath = path.join( + defaultDir, 'chrome', this._release, chromeAppName + '.app', + 'Contents/MacOS/' + chromeAppName + ); + } + + try { + // This will throw if it's not found + fs.lstatSync(expectedPath); + return expectedPath; + } catch (error) {} + + return null; + } + + /** + * Returns the executable for the browser + * @return {String} Path of executable + */ + getExecutablePath() { + const installDirExecutable = this._findInInstallDir(); + if (installDirExecutable) { + // We have a path for the browser + return installDirExecutable; + } + + try { + switch (process.platform) { + case 'darwin': + // Chrome on OS X + switch (this._release) { + case 'stable': + return '/Applications/Google Chrome.app/' + + 'Contents/MacOS/Google Chrome'; + case 'beta': + return '/Applications/Google Chrome Beta.app/' + + 'Contents/MacOS/Google Chrome Beta'; + case 'unstable': + return '/Applications/Google Chrome Dev.app/' + + 'Contents/MacOS/Google Chrome Dev'; + default: + throw new Error('Unknown release: ' + this._release); + } + case 'linux': + // Chrome on linux + switch (this._release) { + case 'stable': + return which.sync('google-chrome'); + case 'beta': + return which.sync('google-chrome-beta'); + case 'unstable': + return which.sync('google-chrome-unstable'); + default: + throw new Error('Unknown release: ' + this._release); + } + default: + throw new Error('Sorry, this platform isn\'t supported'); + } + } catch (err) {} + + return null; + } + + /** + * A version number for the browser. This is the major version number + * (i.e. for 48.0.1293, this would return 18) + * @return {Integer} The major version number of this browser + */ + getVersionNumber() { + const chromeVersion = this.getRawVersionString(); + if (!chromeVersion) { + return -1; + } + + const regexMatch = chromeVersion.match(/(\d+)\.\d+\.\d+\.\d+/); + if (regexMatch === null) { + console.warn(chalk.red('Warning:') + ' Unable to parse version number ' + + 'from Chrome: ', this.getExecutablePath()); + return -1; + } + + return parseInt(regexMatch[1], 10); + } + + _getMinSupportedVersion() { + // ChromeDriver only works on Chrome 47+ + return 47; + } +} + +module.exports = ChromeWebDriverBrowser; diff --git a/src/webdriver-browser/firefox.js b/src/webdriver-browser/firefox.js new file mode 100644 index 0000000..591b911 --- /dev/null +++ b/src/webdriver-browser/firefox.js @@ -0,0 +1,164 @@ +/* + Copyright 2016 Google Inc. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const which = require('which'); +const chalk = require('chalk'); +const seleniumFirefox = require('selenium-webdriver/firefox'); +const WebDriverBrowser = require('./web-driver-browser'); +const application = require('../application-state.js'); + +/** + *

Handles the prettyName and executable path for Firefox browser.

+ * + *

For Firefox Beta and Firefox Nightly please define FF_BETA_PATH and + * FF_NIGHTLY_PATH as environment variables. This is due to Firefox using + * the same executable name for all releases.

+ * + * @private + * @extends WebDriverBrowser + */ +class FirefoxWebDriverBrowser extends WebDriverBrowser { + /** + * Pass in the release version this instance should represent and it will + * try to find the browser in the current environment and set up a new + * {@link WebDriverBrowser} instance. + * @param {String} release The name of the release this instance should + * represent. Either 'stable', 'beta' or 'unstable'. + */ + constructor(release) { + let prettyName = 'Firefox'; + + if (release === 'stable') { + prettyName += ' Stable'; + } else if (release === 'beta') { + prettyName += ' Beta'; + } else if (release === 'unstable') { + prettyName += ' Nightly'; + } + + super( + prettyName, + release, + 'firefox', + new seleniumFirefox.Options() + ); + } + + _findInInstallDir() { + let defaultDir = application.getInstallDirectory(); + if (process.platform === 'linux') { + const expectedPath = path.join( + defaultDir, 'firefox', this._release, 'firefox'); + + try { + // This will throw if it's not found + fs.lstatSync(expectedPath); + return expectedPath; + } catch (error) {} + } else if (process.platform === 'darwin') { + // Find OS X expected path + let firefoxAppName; + if (this._release === 'unstable') { + firefoxAppName = 'FirefoxNightly.app'; + } else { + firefoxAppName = 'Firefox.app'; + } + + const expectedPath = path.join( + defaultDir, 'firefox', this._release, firefoxAppName, + 'Contents/MacOS/firefox' + ); + + try { + // This will throw if it's not found + fs.lstatSync(expectedPath); + return expectedPath; + } catch (error) {} + } + + return null; + } + + /** + * Returns the executable for the browser + * @return {String} Path of executable + */ + getExecutablePath() { + const installDirExecutable = this._findInInstallDir(); + if (installDirExecutable) { + // We have a path for the browser + return installDirExecutable; + } + + try { + switch (process.platform) { + case 'darwin': + // Firefox Beta on OS X overrides Firefox stable, so realistically + // this location could return ff stable as beta, but at least it will + // only be returned once. + if (this._release === 'stable') { + return '/Applications/Firefox.app/Contents/MacOS/firefox'; + } else if (this._release === 'unstable') { + return '/Applications/FirefoxNightly.app/Contents/MacOS/firefox'; + } + break; + case 'linux': + // Stable firefox on Linux is the only known location we can find + // otherwise it's jsut a .tar.gz that users have to put anywhere + if (this._release === 'stable') { + return which.sync('firefox'); + } + break; + default: + throw new Error('Sorry, this platform isn\'t supported'); + } + } catch (err) {} + + return null; + } + + /** + * A version number for the browser. This is the major version number + * (i.e. for 48.0.1293, this would return 18) + * @return {Integer} The major version number of this browser + */ + getVersionNumber() { + const firefoxVersion = this.getRawVersionString(); + if (!firefoxVersion) { + return -1; + } + + const regexMatch = firefoxVersion.match(/(\d+)\.\d/); + if (regexMatch === null) { + console.warn(chalk.red('Warning:') + ' Unable to parse version number ' + + 'from Firefox: ', this.getExecutablePath()); + return -1; + } + + return parseInt(regexMatch[1], 10); + } + + _getMinSupportedVersion() { + // Firefox Marionette only works on Firefox 47+ + return 47; + } +} + +module.exports = FirefoxWebDriverBrowser; diff --git a/src/webdriver-browser/opera.js b/src/webdriver-browser/opera.js new file mode 100644 index 0000000..77b8d73 --- /dev/null +++ b/src/webdriver-browser/opera.js @@ -0,0 +1,147 @@ +/* + Copyright 2016 Google Inc. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const which = require('which'); +const chalk = require('chalk'); +const seleniumOpera = require('selenium-webdriver/opera'); +const WebDriverBrowser = require('./web-driver-browser'); +const application = require('../application-state.js'); + +/** + *

Handles the prettyName and executable path for Opera browser.

+ * + * @private + * @extends WebDriverBrowser + */ +class OperaWebDriverBrowser extends WebDriverBrowser { + /** + * Create an Opera representation of a {@link WebDriverBrowser} + * instance on a specific channel. + * @param {String} release The channel of Opera you want to get, either + * 'stable', 'beta' or 'unstable' + */ + constructor(release) { + let prettyName = 'Opera'; + + if (release === 'stable') { + prettyName += ' Stable'; + } else if (release === 'beta') { + prettyName += ' Beta'; + } else if (release === 'unstable') { + prettyName += ' Developer'; + } + + super( + prettyName, + release, + 'opera', + new seleniumOpera.Options() + ); + } + + _findInInstallDir() { + let defaultDir = application.getInstallDirectory(); + let expectedPath; + if (process.platform === 'linux') { + let operaBinary = 'opera'; + if (this._release === 'beta') { + operaBinary = 'opera-beta'; + } else if (this._release === 'unstable') { + operaBinary = 'opera-developer'; + } + + expectedPath = path.join( + defaultDir, 'opera', this._release, 'usr/bin', operaBinary); + } else if (process.platform === 'darwin') { + // Can't control where Opera is installed due to installer. + // Just use global path + return null; + } + + try { + // This will throw if it's not found + fs.lstatSync(expectedPath); + return expectedPath; + } catch (error) {} + return null; + } + + /** + * Returns the executable for the browser + * @return {String} Path of executable + */ + getExecutablePath() { + const installDirExecutable = this._findInInstallDir(); + if (installDirExecutable) { + // We have a path for the browser + return installDirExecutable; + } + + try { + if (this._release === 'stable') { + if (process.platform === 'darwin') { + return '/Applications/Opera.app/' + + 'Contents/MacOS/Opera'; + } else if (process.platform === 'linux') { + return which.sync('opera'); + } + } else if (this._release === 'beta') { + if (process.platform === 'darwin') { + return '/Applications/Opera Beta.app/' + + 'Contents/MacOS/Opera'; + } else if (process.platform === 'linux') { + return which.sync('opera-beta'); + } + } else if (this._release === 'unstable') { + if (process.platform === 'darwin') { + return '/Applications/Opera Developer.app/' + + 'Contents/MacOS/Opera'; + } else if (process.platform === 'linux') { + return which.sync('opera-developer'); + } + } + } catch (err) {} + + return null; + } + + /** + * A version number for the browser. This is the major version number + * (i.e. for 48.0.1293, this would return 18) + * @return {Integer} The major version number of this browser + */ + getVersionNumber() { + const operaVersion = this.getRawVersionString(); + if (!operaVersion) { + return -1; + } + + const regexMatch = operaVersion.match(/(\d+)\.\d+\.\d+\.\d+/); + if (regexMatch === null) { + console.warn(chalk.red('Warning:') + ' Unable to parse version number ' + + 'from Opera: ', this.getExecutablePath()); + return -1; + } + + return parseInt(regexMatch[1], 10); + } +} + +module.exports = OperaWebDriverBrowser; diff --git a/src/webdriver-browser/safari.js b/src/webdriver-browser/safari.js new file mode 100644 index 0000000..0b2ad28 --- /dev/null +++ b/src/webdriver-browser/safari.js @@ -0,0 +1,147 @@ +/* + Copyright 2016 Google Inc. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +'use strict'; + +const fs = require('fs'); +const chalk = require('chalk'); +const seleniumSafari = require('selenium-webdriver/safari'); +const WebDriverBrowser = require('./web-driver-browser'); + +/** + *

Handles the prettyName and executable path for Safari browser.

+ * + * @private + * @extends WebDriverBrowser + */ +class SafariWebDriverBrowser extends WebDriverBrowser { + /** + * Create an Safari representation of a {@link WebDriverBrowser} + * instance on a specific channel. + * @param {String} release The channel of Opera you want to get, either + * 'stable', 'beta' or 'unstable' + */ + constructor(release) { + let prettyName = 'Safari'; + + if (release === 'stable') { + prettyName += ' Stable'; + } else if (release === 'beta') { + prettyName += ' Technology Preview'; + } else if (release === 'unstable') { + throw new Error('Only stable and beta versions available ' + + 'for this browser'); + } + + super( + prettyName, + release, + 'safari', + new seleniumSafari.Options() + ); + } + + _findInInstallDir() { + return null; + } + + /** + * Returns the executable for the browser + * @return {String} Path of executable + */ + getExecutablePath() { + const installDirExecutable = this._findInInstallDir(); + if (installDirExecutable) { + // We have a path for the browser + return installDirExecutable; + } + + try { + if (this._release === 'stable') { + if (process.platform === 'darwin') { + return '/Applications/Safari.app/' + + 'Contents/MacOS/Safari'; + } + } else if (this._release === 'beta') { + if (process.platform === 'darwin') { + return '/Applications/Safari Technology Preview.app/' + + 'Contents/MacOS/Safari Technology Preview'; + } + } + } catch (err) {} + + return null; + } + + getRawVersionString() { + const executablePath = this.getExecutablePath(); + if (!executablePath) { + return null; + } + + let versionListPath; + if (this._release === 'stable') { + versionListPath = '/Applications/Safari.app/Contents/version.plist'; + } else if (this._release === 'beta') { + versionListPath = '/Applications/Safari Technology Preview.app/' + + 'Contents/version.plist'; + } + try { + const versionDoc = fs.readFileSync(versionListPath).toString(); + /* eslint-disable no-useless-escape */ + const results = new RegExp( + 'CFBundleShortVersionString' + + '[\\s]+([\\d]+.[\\d]+.[\\d]+)', 'g') + .exec(versionDoc); + /* eslint-enable no-useless-escape */ + if (results) { + return results[1]; + } + } catch (err) { + console.warn(chalk.red('WARNING') + ': Unable to get a version string ' + + 'for ' + this.getPrettyName()); + } + + return null; + } + + /** + * A version number for the browser. This is the major version number + * (i.e. for 48.0.1293, this would return 18) + * @return {Integer} The major version number of this browser + */ + getVersionNumber() { + const safariVersion = this.getRawVersionString(); + if (!safariVersion) { + return -1; + } + + const regexMatch = safariVersion.match(/(\d+)\.\d+\.\d+/); + if (regexMatch === null) { + console.warn(chalk.red('Warning:') + ' Unable to parse version number ' + + 'from Safari: ', this.getExecutablePath()); + return -1; + } + + return parseInt(regexMatch[1], 10); + } + + static getAvailableReleases() { + return ['stable', 'beta']; + } +} + +module.exports = SafariWebDriverBrowser; diff --git a/src/webdriver-browser/web-driver-browser.js b/src/webdriver-browser/web-driver-browser.js new file mode 100644 index 0000000..1af873f --- /dev/null +++ b/src/webdriver-browser/web-driver-browser.js @@ -0,0 +1,264 @@ +/* + Copyright 2016 Google Inc. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +'use strict'; + +const execSync = require('child_process').execSync; +const fs = require('fs'); +const chalk = require('chalk'); +const webdriver = require('selenium-webdriver'); + +/** + *

A base class that is designed to be extended to handle browser specific + * values.

+ * + *

An instance of this class helps find and start browsers using selenium.

+ * + *

Instances of this class are returned by + * [automatedBrowserTesting.getDiscoverableBrowsers()]{@link AutomatedBrowserTesting#getDiscoverableBrowsers}

+ */ +class WebDriverBrowser { + /** + *

This constructor will throw an error should any of the inputs be + * invalid / unexpected.

+ * + * @param {String} prettyName A user friendly name of the browser + * @param {String} release Release type of browser (can be either + * 'stable', 'beta' or 'unstable') + * @param {String} seleniumBrowserId An id of the browser that will be + * accepted by selenium (either 'chrome' or 'firefox') + * @param {SeleniumOptions} seleniumOptions This is an instance of either + * `selenium-webdriver/firefox` or `selenium-webdriver/chrome` + */ + constructor(prettyName, release, seleniumBrowserId, seleniumOptions) { + if (typeof prettyName !== 'string' || prettyName.length === 0) { + throw new Error('Invalid prettyName value: ', prettyName); + } + + if (release !== 'stable' && release !== 'beta' && release !== 'unstable') { + throw new Error('Unexpected browser release given: ', release); + } + + if ( + seleniumBrowserId !== 'chrome' && + seleniumBrowserId !== 'firefox' && + seleniumBrowserId !== 'opera' && + seleniumBrowserId !== 'safari' + ) { + throw new Error('Unexpected browser ID given: ', seleniumBrowserId); + } + + this._prettyName = prettyName; + this._release = release; + this._seleniumBrowserId = seleniumBrowserId; + this._seleniumOptions = seleniumOptions; + } + + /* eslint-disable valid-jsdoc */ + /** + * To get the path of the browsers executable file, call this method. + * @return {String} Path of the browsers executable file or null if + * it can't be found. + */ + getExecutablePath() { + throw new Error('getExecutablePath() must be overriden by subclasses'); + } + /* eslint-enable valid-jsdoc */ + + /** + * If you need to identify a browser based on it's version number but + * the high level version number isn't specific enough, you can use the + * raw version string (this will be the result of calling the browser + * executable with an appropriate flag to get the version) + * @return {String} Raw string that identifies the browser + */ + getRawVersionString() { + const executablePath = this.getExecutablePath(); + if (!executablePath) { + return null; + } + + try { + return execSync(`"${executablePath}" --version`) + .toString(); + } catch (err) { + console.warn(chalk.red('WARNING') + ': Unable to get a version string ' + + 'for ' + this.getPrettyName()); + } + + return null; + } + + /* eslint-disable valid-jsdoc */ + /** + *

This method returns an integer if it can be determined from + * the browser executable or -1 if the version is unknown.

+ * + *

A scenario where it will be unable to produce a valid version + * is if the browsers executable path can't be found.

+ * + * @return {Integer} Version number if it can be found + */ + getVersionNumber() { + throw new Error('getVersionNumber() must be overriden by subclasses'); + } + /* eslint-enable valid-jsdoc */ + + _getMinSupportedVersion() { + return false; + } + + /** + *

This method returns true if the instance can be found and can create a + * selenium driver that will launch the expected browser.

+ * + *

A scenario where it will be unable to produce a valid selenium driver + * is if the browsers executable path can't be found.

+ * + * @return {Boolean} True if a selenium driver can be produced + */ + isValid() { + const executablePath = this.getExecutablePath(); + if (!executablePath) { + return false; + } + + try { + // This will throw if it's not found + fs.lstatSync(executablePath); + + const minVersion = this._getMinSupportedVersion(); + if (minVersion) { + return this.getVersionNumber() >= minVersion; + } + + return true; + } catch (error) {} + + return false; + } + + /** + * A user friendly name for the browser + * @return {String} A user friendly name for the browser + */ + getPrettyName() { + return this._prettyName; + } + + /** + *

The release name for this browser, either 'stable', 'beta', + * 'unstable'.

+ * + *

Useful if you only want to test or not test on a particular release + * type.

+ * @return {String} Release name of browser. 'stable', 'beta' or 'unstable' + */ + getReleaseName() { + return this._release; + } + + /** + * This returns the browser ID that Selenium recognises. + * + * @return {String} The Selenium ID of this browser + */ + getSeleniumBrowserId() { + return this._seleniumBrowserId; + } + + /** + * The selenium options passed to webdriver's `Builder` method. This + * will have the executable path set for the browser so you should + * manipulate these options rather than create entirely new objects. + * + * @return {SeleniumOptions} An instance of either + * `selenium-webdriver/firefox` or `selenium-webdriver/chrome` + */ + getSeleniumOptions() { + return this._seleniumOptions; + } + + /** + * If changes are made to the selenium options, call this method to + * set them before calling {@link getSeleniumDriver}. + * @param {SeleniumOptions} options An instance of + * `selenium-webdriver/firefox` or `selenium-webdriver/chrome` + */ + setSeleniumOptions(options) { + this._seleniumOptions = options; + } + + /** + *

This method returns the preconfigured builder used by getSeleniumDriver().

+ * + *

This is useful if you wish to customise the builder with additional options + * (i.e. customise the proxy of the driver.)

+ * + *

For more info, see: + * {@link https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_Builder.html | WebDriverBuilder Docs}

+ * + * @return {WebDriverBuilder} Builder that resolves to a webdriver instance. + */ + getSeleniumDriverBuilder() { + const seleniumOptions = this.getSeleniumOptions(); + + if (seleniumOptions.setChromeBinaryPath) { + seleniumOptions.setChromeBinaryPath(this.getExecutablePath()); + } else if (seleniumOptions.setOperaBinaryPath) { + seleniumOptions.setOperaBinaryPath(this.getExecutablePath()); + } else if (seleniumOptions.setBinary) { + seleniumOptions.setBinary(this.getExecutablePath()); + } else if (seleniumOptions.setCleanSession) { + // This is a safari options, there is no way we can define + // an executable path :( + } else { + throw new Error('Unknown selenium options object'); + } + + return new webdriver + .Builder() + .forBrowser(this.getSeleniumBrowserId()) + .setChromeOptions(seleniumOptions) + .setFirefoxOptions(seleniumOptions) + .setOperaOptions(seleniumOptions) + .setSafariOptions(seleniumOptions) + .setEdgeOptions(seleniumOptions); + } + + /** + *

This method resolves to a webdriver instance of this browser instance.

+ * + *

For more info, see: + * {@link http://selenium.googlecode.com/git/docs/api/javascript/class_webdriver_WebDriver.html | WebDriver Docs}

+ * + * @return {Promise} [description] + */ + getSeleniumDriver() { + try { + const builder = this.getSeleniumDriverBuilder(); + return builder.buildAsync(); + } catch (err) { + return Promise.reject(err); + } + } + + static getAvailableReleases() { + return ['stable', 'beta', 'unstable']; + } +} + +module.exports = WebDriverBrowser;