diff --git a/lib/http/http.js b/lib/http/http.js index 799b461cdf..2c5f5caf62 100644 --- a/lib/http/http.js +++ b/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 = { diff --git a/lib/http/request.js b/lib/http/request.js index 496f3ffdaf..c0bd74a84c 100644 --- a/lib/http/request.js +++ b/lib/http/request.js @@ -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'); @@ -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); @@ -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; } @@ -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; } diff --git a/lib/transport/selenium-webdriver/browserstack/appAutomate.js b/lib/transport/selenium-webdriver/browserstack/appAutomate.js index 79563ae3a2..988a57e766 100644 --- a/lib/transport/selenium-webdriver/browserstack/appAutomate.js +++ b/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}`; @@ -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}) { diff --git a/lib/transport/selenium-webdriver/browserstack/browserstack.js b/lib/transport/selenium-webdriver/browserstack/browserstack.js index ee7160acd0..5d11e8d4a2 100644 --- a/lib/transport/selenium-webdriver/browserstack/browserstack.js +++ b/lib/transport/selenium-webdriver/browserstack/browserstack.js @@ -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; diff --git a/lib/transport/selenium-webdriver/index.js b/lib/transport/selenium-webdriver/index.js index fac23ca2f1..4a8824c122 100644 --- a/lib/transport/selenium-webdriver/index.js +++ b/lib/transport/selenium-webdriver/index.js @@ -270,7 +270,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); if (start_process) { if (this.usingSeleniumServer) { diff --git a/test/src/core/testCreateSession.js b/test/src/core/testCreateSession.js index 251ae1de77..6c4737c456 100644 --- a/test/src/core/testCreateSession.js +++ b/test/src/core/testCreateSession.js @@ -1,5 +1,6 @@ const assert = require('assert'); const nock = require('nock'); +const path = require('path'); const Nocks = require('../../lib/nocks.js'); const Nightwatch = require('../../lib/nightwatch.js'); @@ -583,7 +584,7 @@ describe('test Request With Credentials', function () { }); }); - it('Test create session with browserstack and browserName set to null', async function () { + it('Test create session with browserstack and browserName set to null (App Automate)', async function () { nock('https://hub.browserstack.com') .post('/wd/hub/session') .reply(201, function (uri, requestBody) { @@ -592,6 +593,7 @@ describe('test Request With Credentials', function () { capabilities: { firstMatch: [{}], alwaysMatch: { + 'appium:app': 'bs://878bdf21505f0004ce', 'bstack:options': { local: 'false', userName: 'test_user', @@ -608,11 +610,22 @@ describe('test Request With Credentials', function () { return { value: { sessionId: '1352110219202', - capabilities: requestBody.capabilities + capabilities: { + platform: 'MAC', + platformName: 'iOS', + deviceName: 'iPhone 12', + realMobile: true + } } }; }); + nock('https://api-cloud.browserstack.com') + .post('/app-automate/upload') + .reply(200, { + app_url: 'bs://878bdf21505f0004ce' + }); + nock('https://api.browserstack.com') .get('/app-automate/builds.json') .reply(200, [ @@ -656,7 +669,8 @@ describe('test Request With Credentials', function () { browserName: null, chromeOptions: { w3c: false - } + }, + appUploadUrl: 'https://some_host.com/app.apk' }, parallel: false @@ -671,20 +685,139 @@ describe('test Request With Credentials', function () { assert.deepStrictEqual(result, { sessionId: '1352110219202', capabilities: { - firstMatch: [{}], - alwaysMatch: { - 'bstack:options': { - local: 'false', - userName: 'test_user', - accessKey: 'test_key', - osVersion: '14', - deviceName: 'iPhone 12', - realMobile: 'true', - buildName: 'Nightwatch Programmatic Api Demo' + platform: 'MAC', + platformName: 'iOS', + deviceName: 'iPhone 12', + realMobile: true + } + }); + + assert.strictEqual(client.transport.uploadedAppUrl, 'bs://878bdf21505f0004ce'); + }); + + it('Test create session with Browserstack App Automate using custom id', async function () { + nock('https://hub.browserstack.com') + .post('/wd/hub/session') + .reply(201, function (uri, requestBody) { + assert.deepEqual(requestBody, { + capabilities: { + firstMatch: [{}], + alwaysMatch: { + 'appium:automationName': 'UiAutomator2', + 'appium:platformVersion': '9.0', + 'appium:deviceName': 'Google Pixel 3', + 'appium:app': 'sample_app', + 'browserName': '', + 'bstack:options': { + local: 'false', + userName: 'test_user', + accessKey: 'test_key', + realMobile: true, + buildName: 'Nightwatch Programmatic Api Demo' + } + } + } + }); + + return { + value: { + sessionId: '1352110219202', + capabilities: { + platform: 'LINUX', + platformName: 'Android', + deviceName: '8B3X12Y71', + automationName: 'uiautomator2', + platformVersion: '9', + realMobile: true + } + } + }; + }); + + nock('https://api-cloud.browserstack.com') + .post('/app-automate/upload') + .reply(200, function (uri, requestBody) { + const body = requestBody.toString(); + assert.strictEqual(body.includes('name="custom_id"'), true); + assert.strictEqual(body.includes('sample_app'), true); + assert.strictEqual(body.includes('name="file"; filename="nightwatch.json"'), true); + + return { + app_url: 'bs://878bdf21505f0004ce', + custom_id: 'sample_app', + shareable_id: 'test_user/sample_app' + }; + }); + + nock('https://api.browserstack.com') + .get('/app-automate/builds.json') + .reply(200, [ + { + automation_build: { + name: 'WIN_CHROME_PROD_SANITY_LIVE_1831', + duration: 47, + status: 'running', + hashed_id: '8dd73aad3365429dec0ec12cf64c0c475a22dasds', + build_tag: null + } + }, + { + automation_build: { + name: 'External monitoring - aps - 2022-08-30', + duration: 44, + status: 'done', + hashed_id: '8dd73aad3365429dec0ec12cf64c0c475a22dasdk', + build_tag: null } } + ]); + + const client = Nightwatch.createClient({ + webdriver: { + start_process: false + }, + selenium: { + host: 'hub.browserstack.com', + port: 443 + }, + desiredCapabilities: { + 'bstack:options': { + local: 'false', + userName: 'test_user', + accessKey: 'test_key', + realMobile: true + }, + 'appium:options': { + automationName: 'UiAutomator2', + app: 'sample_app', + deviceName: 'Google Pixel 3', + platformVersion: '9.0' + }, + browserName: '', + appUploadPath: path.resolve(__dirname, '../../extra/nightwatch.json') + }, + parallel: false + }); + + client.mergeCapabilities({ + name: 'Try 1', + build: 'Nightwatch Programmatic Api Demo' + }); + + const result = await client.createSession(); + assert.deepStrictEqual(result, { + sessionId: '1352110219202', + capabilities: { + platform: 'LINUX', + platformName: 'Android', + deviceName: '8B3X12Y71', + automationName: 'uiautomator2', + platformVersion: '9', + realMobile: true } }); + + assert.strictEqual(client.transport.uploadedAppUrl, undefined); }); it('Test create session with browserstack and when buildName is not set', async function () { diff --git a/test/src/index/testRequest.js b/test/src/index/testRequest.js index 366187c06d..f63bcbdfec 100644 --- a/test/src/index/testRequest.js +++ b/test/src/index/testRequest.js @@ -1,6 +1,8 @@ const nock = require('nock'); const assert = require('assert'); const mockery = require('mockery'); +const fs = require('fs'); +const path = require('path'); const common = require('../../common.js'); const HttpRequest = common.require('http/request.js'); @@ -50,10 +52,19 @@ describe('test HttpRequest', function() { sessionId: '123456-789' }); + nock('https://api-cloud.browserstack.com') + .post('/app-automate/upload') + .reply(200, { + app_url: 'bs://878bdf21505f0004ce', + custom_id: 'some_app' + }); + callback(); }); afterEach(function () { + mockery.deregisterAll(); + mockery.resetCache(); mockery.disable(); }); @@ -405,5 +416,50 @@ describe('test HttpRequest', function() { }); + it('send POST request with multi-part form data', function(done) { + mockery.registerMock('fs', { + readFileSync() { + return Buffer.from('app-data'); + } + }); + + const options = { + method: 'POST', + url: 'https://api-cloud.browserstack.com/app-automate/upload', + use_ssl: true, + port: 443, + multiPartFormData: { + file: { + filePath: 'some/path/app.apk' + }, + custom_id: { + data: 'some_app' + } + } + }; + + const request = new HttpRequest(options); + request.on('success', function () { + done(); + }).send(); + + const boundary = request.formBoundary; + const data = `\r\n--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="app.apk"\r\n\r\napp-data` + + `\r\n--${boundary}\r\nContent-Disposition: form-data; name="custom_id"\r\n\r\nsome_app` + + `\r\n--${boundary}--`; + const bufferData = Buffer.from(data); + assert.deepStrictEqual(request.data, bufferData); + assert.strictEqual(request.contentLength, bufferData.length); + assert.strictEqual(request.use_ssl, true); + + const opts = request.reqOptions; + assert.strictEqual(opts.path, '/app-automate/upload'); + assert.strictEqual(opts.host, 'api-cloud.browserstack.com'); + assert.strictEqual(opts.port, 443); + assert.strictEqual(opts.method, 'POST'); + assert.strictEqual(opts.headers['content-type'], `multipart/form-data; boundary=${boundary}`); + assert.strictEqual(opts.headers['content-length'], bufferData.length); + assert.ok(opts.headers['User-Agent'].startsWith('nightwatch.js/')); + }); });