Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support to upload app to app-automate. #3573

Merged
merged 9 commits into from Feb 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/http/http.js
@@ -1,6 +1,7 @@
const ContentTypes = {
JSON: 'application/json',
JSON_WITH_CHARSET: 'application/json; charset=utf-8'
JSON_WITH_CHARSET: 'application/json; charset=utf-8',
MULTIPART_FORM_DATA: 'multipart/form-data'
};

const Headers = {
Expand Down
43 changes: 41 additions & 2 deletions lib/http/request.js
Expand Up @@ -2,6 +2,8 @@ const EventEmitter = require('events');
const http = require('http');
const https = require('https');
const dns = require('dns');
const path = require('path');

const HttpUtil = require('./http.js');
const HttpOptions = require('./options.js');
const Auth = require('./auth.js');
Expand Down Expand Up @@ -88,8 +90,15 @@ class HttpRequest extends EventEmitter {
options.data = '';
}

if (options.multiPartFormData) {
this.multiPartFormData = options.multiPartFormData;
this.formBoundary = `----NightwatchFormBoundary${Math.random().toString(16).slice(2)}`;

this.setFormData(this.formBoundary);
} else {
this.setData(options);
}
this.params = options.data;
this.setData(options);
this.contentLength = this.data.length;
this.use_ssl = this.httpOpts.use_ssl || options.use_ssl;
this.reqOptions = this.createHttpOptions(options);
Expand Down Expand Up @@ -364,7 +373,9 @@ class HttpRequest extends EventEmitter {
this.reqOptions.headers[HttpUtil.Headers.ACCEPT] = HttpUtil.ContentTypes.JSON;
}

if (this.contentLength > 0) {
if (this.multiPartFormData) {
this.reqOptions.headers[HttpUtil.Headers.CONTENT_TYPE] = `${HttpUtil.ContentTypes.MULTIPART_FORM_DATA}; boundary=${this.formBoundary}`;
} else if (this.contentLength > 0) {
this.reqOptions.headers[HttpUtil.Headers.CONTENT_TYPE] = HttpUtil.ContentTypes.JSON_WITH_CHARSET;
}

Expand All @@ -391,6 +402,34 @@ class HttpRequest extends EventEmitter {
return this;
}

setFormData(boundary) {
const crlf = '\r\n';
const delimiter = `${crlf}--${boundary}`;
const closeDelimiter = `${delimiter}--`;

const bufferArray = [];
for (const [fieldName, fieldValue] of Object.entries(this.multiPartFormData)) {
// set header
let header = `Content-Disposition: form-data; name="${fieldName}"`;
if (fieldValue.filePath) {
const fileName = path.basename(fieldValue.filePath);
header += `; filename="${fileName}"`;
}
bufferArray.push(Buffer.from(delimiter + crlf + header + crlf + crlf));

// set data
const {readFileSync} = require('fs');
if (fieldValue.filePath) {
bufferArray.push(readFileSync(fieldValue.filePath));
} else {
bufferArray.push(Buffer.from(fieldValue.data));
}
}
bufferArray.push(Buffer.from(closeDelimiter));

this.data = Buffer.concat(bufferArray);
}

hasCredentials() {
return Utils.isObject(this.httpOpts.credentials) && this.httpOpts.credentials.username;
}
Expand Down
95 changes: 93 additions & 2 deletions lib/transport/selenium-webdriver/browserstack/appAutomate.js
@@ -1,5 +1,9 @@
const path = require('path');
const Utils = require('../../../utils');
const BrowserStack = require('./browserstack.js');

const {Logger} = Utils;

class AppAutomate extends BrowserStack {
get ApiUrl() {
return `https://api.browserstack.com/${this.productNamespace}`;
Expand All @@ -9,10 +13,97 @@ class AppAutomate extends BrowserStack {
return 'app-automate';
}

createSessionOptions() {
async createSessionOptions() {
this.extractAppiumOptions();

return this.desiredCapabilities;
const options = this.desiredCapabilities;
if (options && (options.appUploadUrl || options.appUploadPath)) {
await this.uploadAppToBrowserStack(options);
}

return options;
}

async uploadAppToBrowserStack(options) {
const {appUploadPath, appUploadUrl} = options;

const multiPartFormData = {};
if (appUploadPath) {
multiPartFormData['file'] = {
filePath: path.resolve(appUploadPath)
};
} else if (appUploadUrl) {
multiPartFormData['url'] = {
data: appUploadUrl
};
}

if (options['appium:app'] && !options['appium:app'].startsWith('bs://')) {
multiPartFormData['custom_id'] = {
data: options['appium:app']
};
}

// eslint-disable-next-line no-console
console.log(Logger.colors.stack_trace(`Uploading app to BrowserStack from '${appUploadPath || appUploadUrl}'...`));

try {
const response = await this.sendHttpRequest({
url: 'https://api-cloud.browserstack.com/app-automate/upload',
method: 'POST',
use_ssl: true,
port: 443,
auth: {
user: this.username,
pass: this.accessKey
},
multiPartFormData
});

if (response.error) {
const errMessage = 'App upload to BrowserStack failed. Original error: ' + response.error;

throw new Error(errMessage);
}

if (!response.app_url) {
const errMessage = 'App upload was unsuccessful. Got response: ' + response;

throw new Error(errMessage);
}

// eslint-disable-next-line no-console
console.log(Logger.colors.green(Utils.symbols.ok), Logger.colors.stack_trace('App upload successful!'), '\n');

if (!response.custom_id) {
// custom_id not being used
options['appium:app'] = response.app_url;

// to display url when test suite is finished
this.uploadedAppUrl = response.app_url;
}
} catch (err) {
err.help = [];

if (appUploadPath) {
err.help.push('Check if you have entered correct file path in \'appUploadPath\' desired capability.');
} else if (appUploadUrl) {
err.help.push('Check if you have entered correct publicly available file URL in \'appUploadUrl\' desired capability.');
}

if (err.message.includes('BROWSERSTACK_INVALID_CUSTOM_ID')) {
err.help.push('Check if \'appium:app\' or \'appium:options\' > app desired capability is correctly set to BrowserStack app url or required custom ID.');
}

err.help.push(
'See BrowserStack app-upload docs for more details: https://www.browserstack.com/docs/app-automate/api-reference/appium/apps#upload-an-app',
'More details on setting custom ID for app: https://www.browserstack.com/docs/app-automate/appium/upload-app-define-custom-id'
);

Logger.error(err);

throw err;
}
}

createDriver({options}) {
Expand Down
20 changes: 15 additions & 5 deletions lib/transport/selenium-webdriver/browserstack/browserstack.js
Expand Up @@ -138,11 +138,21 @@ class Browserstack extends AppiumBaseServer {

async testSuiteFinished(failures) {
try {
const reason = failures instanceof Error ? `${failures.name}: ${failures.message}` : '';
await this.sendReasonToBrowserstack(!!failures, reason);
// eslint-disable-next-line no-console
console.log('\n ' + 'See more info, video, & screenshots on Browserstack:\n' +
' ' + Logger.colors.light_cyan(`https://${this.productNamespace}.browserstack.com/builds/${this.buildId}/sessions/${this.sessionId}`));
if (this.sessionId) {
const reason = failures instanceof Error ? `${failures.name}: ${failures.message}` : '';
await this.sendReasonToBrowserstack(!!failures, reason);
// eslint-disable-next-line no-console
console.log('\n ' + 'See more info, video, & screenshots on Browserstack:\n' +
' ' + Logger.colors.light_cyan(`https://${this.productNamespace}.browserstack.com/builds/${this.buildId}/sessions/${this.sessionId}`));
}

if (this.uploadedAppUrl) {
// App was uploaded to BrowserStack and custom_id not being used
// eslint-disable-next-line no-console
console.log('\n ' + Logger.colors.light_cyan(
`Please set 'appium:app' capability to '${this.uploadedAppUrl}' to avoid uploading the app again in future runs.`
) + '\n');
}

this.sessionId = null;

Expand Down
2 changes: 1 addition & 1 deletion lib/transport/selenium-webdriver/index.js
Expand Up @@ -269,7 +269,7 @@ class Transport extends BaseTransport {
const startTime = new Date();
const {host, port, start_process} = this.settings.webdriver;
const portStr = port ? `port ${port}` : 'auto-generated port';
const options = this.createSessionOptions(argv);
const options = await this.createSessionOptions(argv);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createSessionOptions doesn't return a Promise.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does now, for AppAutomate, since I am uploading the app to BrowserStack inside this method only, after creating all the session options. It also made more sense to do it inside createSessionOptions since options are not final until the app is uploaded to BrowserStack. And for the other implementations of createSessionOptions, adding await here shouldn't have any effect.

I tried doing this inside this.createDriver also (just so that I don't have to add this await) but the problem there was that the spinner (Connecting to localhost on 4723...) was overwriting the console messages related to app upload (Uploading app to BrowserStack... and App upload successful!) and the spinner is called just after calling this.createSessionOptions method.


if (start_process) {
if (this.usingSeleniumServer) {
Expand Down