blob: 877b22de73e0c9eda2b4c6df3f49cd35b832a6c5 [file] [log] [blame]
#!/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;