diff --git a/Gulpfile.js b/Gulpfile.js index ab256d7..60d2ddb 100644 --- a/Gulpfile.js +++ b/Gulpfile.js @@ -64,7 +64,7 @@ gulp.task('test-testcafe', ['build'], function () { var testCafeCmd = path.join(__dirname, 'node_modules/.bin/testcafe'); var testCafeOpts = [ - 'browserstack:chrome', + 'browserstack:chrome,browserstack:Google Pixel,browserstack:iPhone SE', 'test/testcafe/**/*.js', '-s', '.screenshots' ]; diff --git a/src/api-request.js b/src/api-request.js new file mode 100644 index 0000000..a9b4839 --- /dev/null +++ b/src/api-request.js @@ -0,0 +1,54 @@ +import Promise from 'pinkie'; +import request from 'request-promise'; +import delay from './utils/delay'; + + +const BUILD_ID = process.env['BROWSERSTACK_BUILD_ID']; +const PROJECT_NAME = process.env['BROWSERSTACK_PROJECT_NAME']; + +const AUTH_FAILED_ERROR = 'Authentication failed. Please assign the correct username and access key ' + + 'to the BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY environment variables.'; + +const API_REQUEST_DELAY = 100; + +let apiRequestPromise = Promise.resolve(null); + + +export default function (apiPath, params) { + if (!process.env['BROWSERSTACK_USERNAME'] || !process.env['BROWSERSTACK_ACCESS_KEY']) + throw new Error(AUTH_FAILED_ERROR); + + var url = apiPath.url; + + var opts = { + auth: { + user: process.env['BROWSERSTACK_USERNAME'], + pass: process.env['BROWSERSTACK_ACCESS_KEY'], + }, + + qs: Object.assign({}, + BUILD_ID && { build: BUILD_ID }, + PROJECT_NAME && { project: PROJECT_NAME }, + params + ), + + method: apiPath.method || 'GET', + json: !apiPath.binaryStream + }; + + if (apiPath.binaryStream) + opts.encoding = null; + + const currentRequestPromise = apiRequestPromise + .then(() => request(url, opts)) + .catch(error => { + if (error.statusCode === 401) + throw new Error(AUTH_FAILED_ERROR); + + throw error; + }); + + apiRequestPromise = currentRequestPromise.then(() => delay(API_REQUEST_DELAY)); + + return currentRequestPromise; +} diff --git a/src/browser-proxy.js b/src/browser-proxy.js new file mode 100644 index 0000000..82146c6 --- /dev/null +++ b/src/browser-proxy.js @@ -0,0 +1,47 @@ +import http from 'http'; +import { parse as parseUrl } from 'url'; +import Promise from 'pinkie'; + + +module.exports = class BrowserProxy { + constructor (targetHost, targetPort, { proxyPort, responseDelay } = {}) { + this.targetHost = targetHost; + this.targetPort = targetPort; + this.proxyPort = proxyPort; + this.responseDelay = responseDelay || 0; + + this.server = http.createServer((...args) => this._onBrowserRequest(...args)); + + this.server.on('connection', socket => socket.unref()); + } + + _onBrowserRequest (req, res) { + setTimeout(() => { + const parsedRequestUrl = parseUrl(req.url); + const destinationUrl = 'http://' + this.targetHost + ':' + this.targetPort + parsedRequestUrl.path; + + res.statusCode = 302; + + res.setHeader('location', destinationUrl); + res.end(); + }, this.responseDelay); + } + + async init () { + return new Promise((resolve, reject) => { + this.server.listen(this.proxyPort, err => { + if (err) + reject(err); + else { + this.proxyPort = this.server.address().port; + + resolve(); + } + }); + }); + } + + dispose () { + this.server.close(); + } +}; diff --git a/src/index.js b/src/index.js index db60525..9121957 100644 --- a/src/index.js +++ b/src/index.js @@ -1,13 +1,14 @@ +import { parse as parseUrl } from 'url'; import Promise from 'pinkie'; -import request from 'request-promise'; import parseCapabilities from 'desired-capabilities'; import { Local as BrowserstackConnector } from 'browserstack-local'; import jimp from 'jimp'; import OS from 'os-family'; import nodeUrl from 'url'; +import apiRequest from './api-request'; +import BrowserProxy from './browser-proxy'; +import delay from './utils/delay'; -const BUILD_ID = process.env['BROWSERSTACK_BUILD_ID']; -const PROJECT_NAME = process.env['BROWSERSTACK_PROJECT_NAME']; const TESTS_TIMEOUT = process.env['BROWSERSTACK_TEST_TIMEOUT'] || 1800; const BROWSERSTACK_CONNECTOR_DELAY = 10000; @@ -16,8 +17,7 @@ const MINIMAL_WORKER_TIME = 30000; const TESTCAFE_CLOSING_TIMEOUT = 10000; const TOO_SMALL_TIME_FOR_WAITING = MINIMAL_WORKER_TIME - TESTCAFE_CLOSING_TIMEOUT; -const AUTH_FAILED_ERROR = 'Authentication failed. Please assign the correct username and access key ' + - 'to the BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY environment variables.'; +const ANDROID_PROXY_RESPONSE_DELAY = 500; const PROXY_AUTH_RE = /^([^:]*)(?::(.*))?$/; @@ -46,10 +46,6 @@ const identity = x => x; const capitalize = str => str[0].toUpperCase() + str.slice(1); -function delay (ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - function copyOptions (source, destination, transfromFunc = identity) { Object .keys(source) @@ -77,7 +73,7 @@ function createBrowserStackConnector (accessKey) { return new Promise((resolve, reject) => { var connector = new BrowserstackConnector(); var parallelRuns = process.env['BROWSERSTACK_PARALLEL_RUNS']; - + var opts = { key: accessKey, logfile: OS.win ? 'NUL' : '/dev/null', @@ -121,47 +117,16 @@ function destroyBrowserStackConnector (connector) { }); } -function doRequest (apiPath, params) { - if (!process.env['BROWSERSTACK_USERNAME'] || !process.env['BROWSERSTACK_ACCESS_KEY']) - throw new Error(AUTH_FAILED_ERROR); - - var url = apiPath.url; - - var opts = { - auth: { - user: process.env['BROWSERSTACK_USERNAME'], - pass: process.env['BROWSERSTACK_ACCESS_KEY'], - }, - - qs: Object.assign({}, - BUILD_ID && { build: BUILD_ID }, - PROJECT_NAME && { project: PROJECT_NAME }, - params - ), - - method: apiPath.method || 'GET', - json: !apiPath.binaryStream - }; - - if (apiPath.binaryStream) - opts.encoding = null; - - return request(url, opts) - .catch(error => { - if (error.statusCode === 401) - throw new Error(AUTH_FAILED_ERROR); - - throw error; - }); -} - export default { // Multiple browsers support - isMultiBrowser: true, - connectorPromise: Promise.resolve(null), - workers: {}, - platformsInfo: [], - browserNames: [], + isMultiBrowser: true, + + connectorPromise: Promise.resolve(null), + browserProxyPromise: Promise.resolve(null), + + workers: {}, + platformsInfo: [], + browserNames: [], _getConnector () { this.connectorPromise = this.connectorPromise @@ -187,8 +152,35 @@ export default { return this.connectorPromise; }, + _getBrowserProxy (host, port) { + this.browserProxyPromise = this.browserProxyPromise + .then(async browserProxy => { + if (!browserProxy) { + browserProxy = new BrowserProxy(host, port, { responseDelay: ANDROID_PROXY_RESPONSE_DELAY }); + + await browserProxy.init(); + } + + return browserProxy; + }); + + return this.browserProxyPromise; + }, + + _disposeBrowserProxy () { + this.browserProxyPromise = this.browserProxyPromise + .then(async browserProxy => { + if (browserProxy) + await browserProxy.dispose(); + + return null; + }); + + return this.browserProxyPromise; + }, + async _getDeviceList () { - this.platformsInfo = await doRequest(BROWSERSTACK_API_PATHS.browserList); + this.platformsInfo = await apiRequest(BROWSERSTACK_API_PATHS.browserList); this.platformsInfo.reverse(); }, @@ -255,13 +247,20 @@ export default { var capabilities = this._generateCapabilities(browserName); var connector = await this._getConnector(); - capabilities.timeout = TESTS_TIMEOUT; - capabilities.url = pageUrl; - capabilities.name = `TestCafe test run ${id}`; - capabilities.localIdentifier = connector.localIdentifierFlag; + if (capabilities.os.toLowerCase() === 'android') { + const parsedPageUrl = parseUrl(pageUrl); + const browserProxy = await this._getBrowserProxy(parsedPageUrl.hostname, parsedPageUrl.port); + + pageUrl = 'http://' + browserProxy.targetHost + ':' + browserProxy.proxyPort + parsedPageUrl.path; + } + + capabilities.timeout = TESTS_TIMEOUT; + capabilities.url = pageUrl; + capabilities.name = `TestCafe test run ${id}`; + capabilities.localIdentifier = connector.localIdentifierFlag; capabilities['browserstack.local'] = true; - this.workers[id] = await doRequest(BROWSERSTACK_API_PATHS.newWorker, capabilities); + this.workers[id] = await apiRequest(BROWSERSTACK_API_PATHS.newWorker, capabilities); this.workers[id].started = Date.now(); }, @@ -271,12 +270,12 @@ export default { if (workerTime < MINIMAL_WORKER_TIME) { if (workerTime < TOO_SMALL_TIME_FOR_WAITING) - await doRequest(BROWSERSTACK_API_PATHS.deleteWorker(workerId)); + await apiRequest(BROWSERSTACK_API_PATHS.deleteWorker(workerId)); await delay(MINIMAL_WORKER_TIME - workerTime); } - await doRequest(BROWSERSTACK_API_PATHS.deleteWorker(workerId)); + await apiRequest(BROWSERSTACK_API_PATHS.deleteWorker(workerId)); }, @@ -290,6 +289,7 @@ export default { async dispose () { await this._disposeConnector(); + await this._disposeBrowserProxy(); }, @@ -310,7 +310,7 @@ export default { async takeScreenshot (id, screenshotPath) { return new Promise(async (resolve, reject) => { - var buffer = await doRequest(BROWSERSTACK_API_PATHS.screenshot(this.workers[id].id)); + var buffer = await apiRequest(BROWSERSTACK_API_PATHS.screenshot(this.workers[id].id)); jimp .read(buffer) diff --git a/src/utils/delay.js b/src/utils/delay.js new file mode 100644 index 0000000..5f122ac --- /dev/null +++ b/src/utils/delay.js @@ -0,0 +1,6 @@ +import Promise from 'pinkie'; + + +export default function (ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +}