| #!/usr/bin/env node |
| |
| /** |
| Licensed to the Apache Software Foundation (ASF) under one |
| or more contributor license agreements. See the NOTICE file |
| distributed with this work for additional information |
| regarding copyright ownership. The ASF licenses this file |
| to you 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. |
| */ |
| const path = require('path'); |
| const cp = require('child_process'); |
| const Q = require('q'); |
| const shell = require('shelljs'); |
| const randomstring = require('randomstring'); |
| const fs = require('fs'); |
| const wd = require('wd'); |
| const SauceLabs = require('saucelabs'); |
| const sauceConnectLauncher = require('sauce-connect-launcher'); |
| const { logger, exec, execPromise, utilities } = require('./utils'); |
| const appPatcher = require('./appium/helpers/appPatcher'); |
| |
| class ParamedicSauceLabs { |
| constructor (config, runner) { |
| this.config = config; |
| this.runner = runner; |
| |
| this.platformId = this.config.getPlatformId(); |
| this.isAndroid = this.platformId === utilities.ANDROID; |
| this.isBrowser = this.platformId === utilities.BROWSER; |
| this.isIos = this.platformId === utilities.IOS; |
| } |
| |
| checkSauceRequirements () { |
| if (!this.isAndroid && !this.isIos && !this.isBrowser) { |
| logger.warn('Saucelabs only supports Android and iOS (and browser), falling back to testing locally.'); |
| this.config.setShouldUseSauce(false); |
| } else if (!this.config.getSauceKey()) { |
| throw new Error('Saucelabs key not set. Please set it via environmental variable ' + |
| utilities.SAUCE_KEY_ENV_VAR + ' or pass it with the --sauceKey parameter.'); |
| } else if (!this.config.getSauceUser()) { |
| throw new Error('Saucelabs user not set. Please set it via environmental variable ' + |
| utilities.SAUCE_USER_ENV_VAR + ' or pass it with the --sauceUser parameter.'); |
| } else if (!this.runner.shouldWaitForTestResult()) { |
| // don't throw, just silently disable Sauce |
| this.config.setShouldUseSauce(false); |
| } |
| } |
| |
| packageApp () { |
| switch (this.platformId) { |
| case utilities.IOS: { |
| return Q.Promise((resolve, reject) => { |
| const zipCommand = 'zip -r ' + this.getPackageName() + ' ' + this.getBinaryName(); |
| shell.pushd(this.getPackageFolder()); |
| shell.rm('-rf', this.getPackageName()); |
| console.log('Running command: ' + zipCommand + ' in dir: ' + shell.pwd()); |
| exec(zipCommand, (code) => { |
| shell.popd(); |
| if (code) { |
| reject('zip command returned with error code ' + code); |
| } else { |
| resolve(); |
| } |
| }); |
| }); |
| } |
| case utilities.ANDROID: |
| break; // don't need to zip the app for Android |
| case utilities.BROWSER: |
| break; // don't need to bundle the app on Browser platform at all |
| default: |
| throw new Error('Don\'t know how to package the app for platform: ' + this.platformId); |
| } |
| return Q.resolve(); |
| } |
| |
| uploadApp () { |
| logger.normal('cordova-paramedic: uploading ' + this.getAppName() + ' to Sauce Storage'); |
| |
| const sauceUser = this.config.getSauceUser(); |
| const key = this.config.getSauceKey(); |
| const uploadURI = encodeURI('https://saucelabs.com/rest/v1/storage/' + sauceUser + '/' + this.getAppName() + '?overwrite=true'); |
| const filePath = this.getPackagedPath(); |
| const uploadCommand = |
| 'curl -u ' + sauceUser + ':' + key + |
| ' -X POST -H "Content-Type: application/octet-stream" ' + |
| uploadURI + ' --data-binary "@' + filePath + '"'; |
| |
| return execPromise(uploadCommand); |
| } |
| |
| getPackagedPath () { |
| return path.join(this.getPackageFolder(), this.getPackageName()); |
| } |
| |
| getPackageFolder () { |
| const packageDirs = this.getPackageFolders(); |
| let foundDir = null; |
| |
| packageDirs.forEach((dir) => { |
| if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) { |
| foundDir = dir; |
| } |
| }); |
| |
| if (foundDir) return foundDir; |
| |
| throw new Error('Couldn\'t locate a built app directory. Looked here: ' + packageDirs); |
| } |
| |
| getPackageFolders () { |
| let packageFolders; |
| |
| switch (this.platformId) { |
| case utilities.ANDROID: |
| packageFolders = [ |
| path.join(this.runner.tempFolder.name, 'platforms', 'android', 'app', 'build', 'outputs', 'apk', 'debug'), |
| path.join(this.runner.tempFolder.name, 'platforms', 'android', 'build', 'outputs', 'apk') |
| ]; |
| break; |
| |
| case utilities.IOS: |
| packageFolders = [path.join(this.runner.tempFolder.name, 'platforms', 'ios', 'build', 'emulator')]; |
| break; |
| |
| default: |
| throw new Error('Don\t know where the package folder is for the platform: ' + this.platformId); |
| } |
| |
| return packageFolders; |
| } |
| |
| getPackageName () { |
| let packageName; |
| |
| switch (this.platformId) { |
| case utilities.IOS: |
| packageName = 'HelloCordova.zip'; |
| break; |
| |
| case utilities.ANDROID: |
| packageName = this.getBinaryName(); |
| break; |
| |
| default: |
| throw new Error('Don\'t know what the package name is for platform: ' + this.platformId); |
| } |
| |
| return packageName; |
| } |
| |
| getBinaryName () { |
| let binaryName; |
| |
| switch (this.platformId) { |
| case utilities.ANDROID: { |
| shell.pushd(this.getPackageFolder()); |
| const apks = shell.ls('*debug.apk'); |
| |
| if (apks.length > 0) { |
| binaryName = apks.reduce((previous, current) => { |
| // if there is any apk for x86, take it |
| if (current.indexOf('x86') >= 0) return current; |
| |
| // if not, just take the first one |
| return previous; |
| }); |
| } else { |
| throw new Error('Couldn\'t locate built apk'); |
| } |
| |
| shell.popd(); |
| break; |
| } |
| case utilities.IOS: |
| binaryName = 'HelloCordova.app'; |
| break; |
| |
| default: |
| throw new Error('Don\'t know the binary name for the platform: ' + this.platformId); |
| } |
| |
| return binaryName; |
| } |
| |
| // Returns a name of the file at the SauceLabs storage |
| getAppName () { |
| // exit if we did this before |
| if (this.appName) return this.appName; |
| |
| let appName = randomstring.generate(); |
| |
| switch (this.platformId) { |
| case utilities.ANDROID: |
| appName += '.apk'; |
| break; |
| |
| case utilities.IOS: |
| appName += '.zip'; |
| break; |
| |
| default: |
| throw new Error('Don\'t know the app name for the platform: ' + this.platformId); |
| } |
| |
| this.appName = appName; // save for additional function calls |
| return appName; |
| } |
| |
| displaySauceDetails (buildName) { |
| if (!this.config.shouldUseSauce()) return Q(); |
| |
| if (!buildName) { |
| buildName = this.config.getBuildName(); |
| } |
| |
| const d = Q.defer(); |
| |
| logger.normal('Getting saucelabs jobs details...\n'); |
| |
| const sauce = new SauceLabs({ |
| username: this.config.getSauceUser(), |
| password: this.config.getSauceKey() |
| }); |
| |
| sauce.getJobs((err, jobs) => { |
| if (err) { |
| console.log(err); |
| } |
| |
| let found = false; |
| for (const job in jobs) { |
| if (Object.prototype.hasOwnProperty.call(jobs, job) && jobs[job].name && jobs[job].name.indexOf(buildName) === 0) { |
| const jobUrl = 'https://saucelabs.com/beta/tests/' + jobs[job].id; |
| logger.normal('============================================================================================'); |
| logger.normal('Job name: ' + jobs[job].name); |
| logger.normal('Job ID: ' + jobs[job].id); |
| logger.normal('Job URL: ' + jobUrl); |
| logger.normal('Video: ' + jobs[job].video_url); |
| logger.normal('Appium logs: ' + jobs[job].log_url); |
| if (this.isAndroid) { |
| logger.normal('Logcat logs: ' + 'https://saucelabs.com/jobs/' + jobs[job].id + '/logcat.log'); |
| } |
| logger.normal('============================================================================================'); |
| logger.normal(''); |
| found = true; |
| } |
| } |
| |
| if (!found) { |
| logger.warn('Can not find saucelabs job. Logs and video will be unavailable.'); |
| } |
| d.resolve(); |
| }); |
| |
| return d.promise; |
| } |
| |
| getSauceCaps () { |
| this.runner.sauceBuildName = this.runner.sauceBuildName || this.config.getBuildName(); |
| const caps = { |
| name: this.runner.sauceBuildName, |
| idleTimeout: '100', // in seconds |
| maxDuration: utilities.SAUCE_MAX_DURATION, |
| tunnelIdentifier: this.config.getSauceTunnelId() |
| }; |
| |
| switch (this.platformId) { |
| case utilities.ANDROID: |
| caps.platformName = 'Android'; |
| caps.appPackage = 'io.cordova.hellocordova'; |
| caps.appActivity = 'io.cordova.hellocordova.MainActivity'; |
| caps.app = 'sauce-storage:' + this.getAppName(); |
| caps.deviceType = 'phone'; |
| caps.deviceOrientation = 'portrait'; |
| caps.appiumVersion = this.config.getSauceAppiumVersion(); |
| caps.deviceName = this.config.getSauceDeviceName(); |
| caps.platformVersion = this.config.getSaucePlatformVersion(); |
| break; |
| |
| case utilities.IOS: |
| caps.platformName = 'iOS'; |
| caps.autoAcceptAlerts = true; |
| caps.waitForAppScript = 'true;'; |
| caps.app = 'sauce-storage:' + this.getAppName(); |
| caps.deviceType = 'phone'; |
| caps.deviceOrientation = 'portrait'; |
| caps.appiumVersion = this.config.getSauceAppiumVersion(); |
| caps.deviceName = this.config.getSauceDeviceName(); |
| caps.platformVersion = this.config.getSaucePlatformVersion(); |
| break; |
| |
| case utilities.BROWSER: |
| caps.browserName = this.config.getSauceDeviceName() || 'chrome'; |
| caps.version = this.config.getSaucePlatformVersion() || '45.0'; |
| caps.platform = caps.browserName.indexOf('Edge') > 0 ? 'Windows 10' : 'macOS 10.13'; |
| // setting from env.var here and not in the config |
| // because for any other platform we don't need to put the sauce connect up |
| // unless the tunnel id is explicitly passed (means that user wants it anyway) |
| if (!caps.tunnelIdentifier && process.env[utilities.SAUCE_TUNNEL_ID_ENV_VAR]) { |
| caps.tunnelIdentifier = process.env[utilities.SAUCE_TUNNEL_ID_ENV_VAR]; |
| } else if (!caps.tunnelIdentifier) { |
| throw new Error('Testing browser platform on Sauce Labs requires Sauce Connect tunnel. Please specify tunnel identifier via --sauceTunnelId'); |
| } |
| break; |
| |
| default: |
| throw new Error('Don\'t know the Sauce caps for the platform: ' + this.platformId); |
| } |
| |
| return caps; |
| } |
| |
| connectWebdriver () { |
| const user = this.config.getSauceUser(); |
| const key = this.config.getSauceKey(); |
| const caps = this.getSauceCaps(); |
| |
| logger.normal('cordova-paramedic: connecting webdriver'); |
| const spamDots = setInterval(() => { |
| process.stdout.write('.'); |
| }, 1000); |
| |
| wd.configureHttp({ |
| timeout: utilities.WD_TIMEOUT, |
| retryDelay: utilities.WD_RETRY_DELAY, |
| retries: utilities.WD_RETRIES |
| }); |
| |
| const driver = wd.promiseChainRemote(utilities.SAUCE_HOST, utilities.SAUCE_PORT, user, key); |
| return driver |
| .init(caps) |
| .then(() => { |
| clearInterval(spamDots); |
| process.stdout.write('\n'); |
| }, (error) => { |
| clearInterval(spamDots); |
| process.stdout.write('\n'); |
| throw (error); |
| }); |
| } |
| |
| connectSauceConnect () { |
| const isBrowser = this.isBrowser; |
| |
| // on platforms other than browser, only run sauce connect if user explicitly asks for it |
| if (!isBrowser && !this.config.getSauceTunnelId()) return Q(); |
| // on browser, run sauce connect in any case |
| if (isBrowser && !this.config.getSauceTunnelId()) { |
| this.config.setSauceTunnelId(process.env[utilities.SAUCE_TUNNEL_ID_ENV_VAR] || this.config.getBuildName()); |
| } |
| |
| return Q.Promise((resolve, reject) => { |
| logger.info('cordova-paramedic: Starting Sauce Connect...'); |
| sauceConnectLauncher({ |
| username: this.config.getSauceUser(), |
| accessKey: this.config.getSauceKey(), |
| tunnelIdentifier: this.config.getSauceTunnelId(), |
| connectRetries: utilities.SAUCE_CONNECT_CONNECTION_RETRIES, |
| connectRetryTimeout: utilities.SAUCE_CONNECT_CONNECTION_TIMEOUT, |
| downloadRetries: utilities.SAUCE_CONNECT_DOWNLOAD_RETRIES, |
| downloadRetryTimeout: utilities.SAUCE_CONNECT_DOWNLOAD_TIMEOUT |
| }, (err, sauceConnectProcess) => { |
| if (err) reject(err); |
| |
| this.sauceConnectProcess = sauceConnectProcess; |
| logger.info('cordova-paramedic: Sauce Connect ready'); |
| resolve(); |
| }); |
| }); |
| } |
| |
| runSauceTests () { |
| logger.warn('... on SauceLabs'); |
| logger.warn('---------------------------------------------------------'); |
| |
| let isTestPassed = false; |
| let pollForResults; |
| let driver; |
| let runProcess = null; |
| |
| if (!this.config.runMainTests()) { |
| logger.normal('Skipping main tests...'); |
| return Q(utilities.TEST_PASSED); |
| } |
| |
| logger.info('cordova-paramedic: running tests with sauce'); |
| |
| return Q() |
| .then(() => { |
| // Build + "Upload" app |
| if (!this.isBrowser) { |
| return this.buildApp() |
| .then(() => this.packageApp()) |
| .then(() => this.uploadApp()); |
| } |
| |
| // for browser, we need to serve the app for Sauce Connect |
| // we do it by just running "cordova run" and ignoring the chrome instance that pops up |
| return Q().then(() => { |
| appPatcher.addCspSource(this.runner.tempFolder.name, 'connect-src', 'http://*'); |
| appPatcher.permitAccess(this.runner.tempFolder.name, '*'); |
| return this.runner.getCommandForStartingTests(); |
| }).then((command) => { |
| console.log('$ ' + command); |
| runProcess = cp.exec(command, () => { |
| // a precaution not to try to kill some other process |
| runProcess = null; |
| }); |
| }); |
| }) |
| .then(() => this.connectSauceConnect()) |
| .then(() => { |
| driver = this.connectWebdriver(); |
| |
| if (this.isBrowser) { |
| return driver.get('http://localhost:8000/cdvtests/index.html'); |
| } |
| |
| return driver; |
| }) |
| .then(() => { |
| if (this.config.getUseTunnel() || this.isBrowser) { |
| return driver; |
| } |
| |
| return driver |
| .getWebviewContext() |
| .then((webview) => driver.context(webview)); |
| }) |
| .then(() => { |
| if (this.isIos) { |
| logger.normal('cordova-paramedic: navigating to a test page'); |
| return driver |
| .sleep(1000) |
| .elementByXPath('//*[text() = "Auto Tests"]') |
| .click(); |
| } |
| |
| return driver; |
| }) |
| .then(() => { |
| logger.normal('cordova-paramedic: connecting to app'); |
| |
| const plugins = this.config.getPlugins(); |
| let skipBuster = false; |
| |
| // skip permission buster for splashscreen and inappbrowser plugins |
| // it hangs the test run on Android 7 for some reason |
| for (let i = 0; i < plugins.length; i++) { |
| if (plugins[i].indexOf('cordova-plugin-splashscreen') >= 0 || plugins[i].indexOf('cordova-plugin-inappbrowser') >= 0) { |
| skipBuster = true; |
| } |
| } |
| // always skip buster for browser platform |
| if (this.isBrowser) { |
| skipBuster = true; |
| } |
| |
| if (!this.config.getUseTunnel()) { |
| let polling = false; |
| |
| pollForResults = setInterval(() => { |
| if (!polling) { |
| polling = true; |
| driver.pollForEvents(this.platformId, skipBuster) |
| .then((events) => { |
| for (let i = 0; i < events.length; i++) { |
| this.runner.server.emit(events[i].eventName, events[i].eventObject); |
| } |
| |
| polling = false; |
| }) |
| .fail((error) => { |
| logger.warn('cordova-paramedic, pollForResults error: ' + error); |
| polling = false; |
| }); |
| } |
| }, 2500); |
| } |
| |
| return this.runner.waitForTests(); |
| }) |
| .then((result) => { |
| logger.normal('cordova-paramedic: Tests finished'); |
| isTestPassed = result; |
| }, (error) => { |
| logger.normal('cordova-paramedic: Tests failed to complete; ending session. The error is:\n' + error.stack); |
| }) |
| .fin(() => { |
| if (pollForResults) { |
| clearInterval(pollForResults); |
| } |
| if (driver && typeof driver.quit === 'function') { |
| return driver.quit(); |
| } |
| }) |
| .fin(() => { |
| if (this.isBrowser && !this.runner.browserPatched) { |
| // we need to kill chrome |
| this.runner.killEmulatorProcess(); |
| } |
| if (runProcess) { |
| // as well as we need to kill the spawned node process serving our app |
| return Q.Promise((resolve) => { |
| utilities.killProcess(runProcess.pid, () => { |
| resolve(); |
| }); |
| }); |
| } |
| }) |
| .fin(() => { |
| if (this.sauceConnectProcess) { |
| logger.info('cordova-paramedic: Closing Sauce Connect process...'); |
| return Q.Promise((resolve) => { |
| this.sauceConnectProcess.close(() => { |
| logger.info('cordova-paramedic: Successfully closed Sauce Connect process'); |
| resolve(); |
| }); |
| }); |
| } |
| }) |
| .then(() => { |
| return isTestPassed; |
| }); |
| } |
| |
| buildApp () { |
| const command = this.getCommandForBuilding(); |
| |
| logger.normal('cordova-paramedic: running command ' + command); |
| |
| return execPromise(command) |
| .then((output) => { |
| if (output.indexOf('BUILD FAILED') >= 0) { |
| throw new Error('Unable to build the project.'); |
| } |
| }, (output) => { |
| // this trace is automatically available in verbose mode |
| // so we check for this flag to not trace twice |
| if (!this.config.verbose) { |
| logger.normal(output); |
| } |
| |
| throw new Error('Unable to build the project.'); |
| }); |
| } |
| |
| getCommandForBuilding () { |
| let cmd = this.config.getCli() + ' build ' + this.platformId + utilities.PARAMEDIC_COMMON_CLI_ARGS; |
| if (this.config.getArgs()) { |
| cmd += ' ' + this.config.getArgs(); |
| } |
| return cmd; |
| } |
| } |
| |
| module.exports = ParamedicSauceLabs; |