Skip to content

Commit 1100c37

Browse files
AndreyBelymAlexanderMoskovkin
authored andcommitted
Implement Browserstack Automate support. Add status reporting (closes #22) (#23)
* Implement Browserstack Automate support (closes #22) * Fix README
1 parent 5b72e49 commit 1100c37

21 files changed

Lines changed: 832 additions & 166 deletions

Gulpfile.js

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
var path = require('path');
22
var gulp = require('gulp');
33
var babel = require('gulp-babel');
4-
var mocha = require('gulp-mocha');
54
var sequence = require('gulp-sequence');
65
var del = require('del');
76
var nodeVersion = require('node-version');
@@ -42,43 +41,68 @@ gulp.task('build', ['lint', 'clean'], function () {
4241
.pipe(gulp.dest('lib'));
4342
});
4443

45-
gulp.task('test-mocha', ['build'], function () {
44+
function testMocha () {
4645
if (!process.env.BROWSERSTACK_USERNAME || !process.env.BROWSERSTACK_ACCESS_KEY)
4746
throw new Error('Specify your credentials by using the BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY environment variables to authenticate to BrowserStack.');
4847

48+
49+
var mochaCmd = path.join(__dirname, 'node_modules/.bin/mocha');
50+
51+
var mochaOpts = [
52+
'--ui', 'bdd',
53+
'--reporter', 'spec',
54+
'--timeout', typeof v8debug === 'undefined' ? 2000 : Infinity,
55+
'test/mocha/**/*test.js'
56+
];
57+
58+
// NOTE: we must add the parent of plugin directory to NODE_PATH, otherwise testcafe will not be able
59+
// to find the plugin. So this function starts mocha with proper NODE_PATH.
4960
process.env.NODE_PATH = PACKAGE_SEARCH_PATH;
5061

51-
return gulp
52-
.src('test/mocha/**.js')
53-
.pipe(mocha({
54-
ui: 'bdd',
55-
reporter: 'spec',
56-
timeout: typeof v8debug === 'undefined' ? 2000 : Infinity // NOTE: disable timeouts in debug
57-
}));
62+
return execa(mochaCmd, mochaOpts, { stdio: 'inherit' });
63+
}
64+
65+
gulp.task('test-mocha', ['build'], function () {
66+
process.env.BROWSERSTACK_USE_AUTOMATE = 0;
67+
68+
return testMocha();
5869
});
5970

60-
gulp.task('test-testcafe', ['build'], function () {
71+
gulp.task('test-mocha-automate', ['build'], function () {
72+
process.env.BROWSERSTACK_USE_AUTOMATE = 1;
73+
74+
return testMocha();
75+
});
76+
77+
function testTestcafe () {
6178
if (!process.env.BROWSERSTACK_USERNAME || !process.env.BROWSERSTACK_ACCESS_KEY)
6279
throw new Error('Specify your credentials by using the BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY environment variables to authenticate to BrowserStack.');
6380

6481
var testCafeCmd = path.join(__dirname, 'node_modules/.bin/testcafe');
6582

6683
var testCafeOpts = [
67-
'browserstack:chrome,browserstack:Google Pixel,browserstack:iPhone SE',
68-
'test/testcafe/**/*.js',
84+
'browserstack:chrome:windows 10,browserstack:Google Pixel@7.1,browserstack:iPhone 8',
85+
'test/testcafe/**/*test.js',
6986
'-s', '.screenshots'
7087
];
7188

7289
// NOTE: we must add the parent of plugin directory to NODE_PATH, otherwise testcafe will not be able
7390
// to find the plugin. So this function starts testcafe with proper NODE_PATH.
7491
process.env.NODE_PATH = PACKAGE_SEARCH_PATH;
7592

76-
var child = execa(testCafeCmd, testCafeOpts);
93+
return execa(testCafeCmd, testCafeOpts, { stdio: 'inherit' });
94+
}
95+
96+
gulp.task('test-testcafe', ['build'], function () {
97+
process.env.BROWSERSTACK_USE_AUTOMATE = '0';
98+
99+
return testTestcafe();
100+
});
77101

78-
child.stdout.pipe(process.stdout);
79-
child.stderr.pipe(process.stderr);
102+
gulp.task('test-testcafe-automate', ['build'], function () {
103+
process.env.BROWSERSTACK_USE_AUTOMATE = '1';
80104

81-
return child;
105+
return testTestcafe();
82106
});
83107

84-
gulp.task('test', sequence('test-mocha', 'test-testcafe'));
108+
gulp.task('test', sequence('test-mocha', 'test-mocha-automate', 'test-testcafe', 'test-testcafe-automate'));

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,18 @@ Proxy options can be passed via envrionment variables.
4949
- `BROWSERSTACK_FORCE_PROXY` - if it's not empty, forces all traffic of Browserstack local binary to go through the proxy,
5050
- `BROWSERSTACK_FORCE_LOCAL` - if it's not empty, forces all traffic of Browserstack local binary to go through the local machine
5151

52+
## Browserstack JS Testing and Browserstack Automate
53+
Browserstack offers two APIs for browser testing:
54+
- [Browserstack JS Testing](https://www.browserstack.com/javascript-testing-api)
55+
- [Browserstack Automate](https://www.browserstack.com/automate)
56+
57+
JS testing supports more types of devices (compare: [JS Testing devices](https://www.browserstack.com/list-of-browsers-and-platforms?product=js_testing)
58+
vs [Automate devices](https://www.browserstack.com/list-of-browsers-and-platforms?product=automate)),
59+
while Automate allows for much longer tests ([2 hours](https://www.browserstack.com/automate/timeouts) vs [30 minutes](https://github.com/browserstack/api#timeout300))
60+
and provides some additional features (like the window resizing functionality).
61+
62+
TestCafe uses the JS Testing API by default. In order to use Browserstack Automate,
63+
set the `BROWSERSTACK_USE_AUTOMATE` environment variable to `1`.
64+
5265
## Author
5366
Developer Express Inc. (https://devexpress.com)

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,15 @@
4747
"babel-preset-stage-3": "^6.11.0",
4848
"chai": "^3.5.0",
4949
"del": "^2.2.2",
50-
"execa": "^0.4.0",
50+
"execa": "^0.9.0",
5151
"gulp": "^3.9.0",
5252
"gulp-babel": "^6.1.2",
5353
"gulp-eslint": "^3.0.1",
54-
"gulp-mocha": "^3.0.1",
5554
"gulp-sequence": "^0.4.6",
55+
"mocha": "^5.0.1",
5656
"node-version": "^1.0.0",
5757
"publish-please": "^2.1.4",
58-
"testcafe": "^0.13.0",
58+
"testcafe": "latest",
5959
"tmp": "0.0.31"
6060
}
6161
}

src/backends/automate.js

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import Promise from 'pinkie';
2+
import jimp from 'jimp';
3+
import BaseBackend from './base';
4+
import requestApiBase from '../utils/request-api';
5+
import createBrowserstackStatus from '../utils/create-browserstack-status';
6+
7+
8+
const API_POLLING_INTERVAL = 80000;
9+
10+
const BROWSERSTACK_API_PATHS = {
11+
browserList: {
12+
url: 'https://api.browserstack.com/automate/browsers.json'
13+
},
14+
15+
newSession: {
16+
url: 'http://hub-cloud.browserstack.com/wd/hub/session',
17+
method: 'POST'
18+
},
19+
20+
openUrl: id => ({
21+
url: `http://hub-cloud.browserstack.com/wd/hub/session/${id}/url`,
22+
method: 'POST'
23+
}),
24+
25+
getWindowSize: id => ({
26+
url: `http://hub-cloud.browserstack.com/wd/hub/session/${id}/window/current/size`
27+
}),
28+
29+
setWindowSize: id => ({
30+
url: `http://hub-cloud.browserstack.com/wd/hub/session/${id}/window/current/size`,
31+
method: 'POST'
32+
}),
33+
34+
maximizeWindow: id => ({
35+
url: `http://hub-cloud.browserstack.com/wd/hub/session/${id}/window/current/maximize`,
36+
method: 'POST'
37+
}),
38+
39+
getUrl: id => ({
40+
url: `http://hub-cloud.browserstack.com/wd/hub/session/${id}/url`
41+
}),
42+
43+
deleteSession: id => ({
44+
url: `http://hub-cloud.browserstack.com/wd/hub/session/${id}`,
45+
method: 'DELETE'
46+
}),
47+
48+
screenshot: id => ({
49+
url: `http://hub-cloud.browserstack.com/wd/hub/session/${id}/screenshot`
50+
}),
51+
52+
getStatus: id => ({
53+
url: `https://api.browserstack.com/automate/sessions/${id}.json`
54+
}),
55+
56+
setStatus: id => ({
57+
url: `https://api.browserstack.com/automate/sessions/${id}.json`,
58+
method: 'PUT'
59+
})
60+
};
61+
62+
63+
function requestApi (path, params) {
64+
return requestApiBase(path, params)
65+
.then(response => {
66+
if (response.status !== 0)
67+
throw new Error(`API error ${response.status}: ${response.value.message}`);
68+
69+
return response;
70+
});
71+
}
72+
73+
function getCorrectedSize (currentClientAreaSize, currentWindowSize, requestedSize) {
74+
var horizontalChrome = currentWindowSize.width - currentClientAreaSize.width;
75+
var verticalChrome = currentWindowSize.height - currentClientAreaSize.height;
76+
77+
return {
78+
width: requestedSize.width + horizontalChrome,
79+
height: requestedSize.height + verticalChrome
80+
};
81+
}
82+
83+
export default class AutomateBackend extends BaseBackend {
84+
constructor (...args) {
85+
super(...args);
86+
87+
this.sessions = {};
88+
}
89+
90+
async _requestSessionUrl (id) {
91+
var sessionInfo = await requestApiBase(BROWSERSTACK_API_PATHS.getStatus(this.sessions[id].sessionId));
92+
93+
return sessionInfo['automation_session']['browser_url'];
94+
}
95+
96+
async _requestCurrentWindowSize (id) {
97+
var currentWindowSizeData = await requestApi(BROWSERSTACK_API_PATHS.getWindowSize(this.sessions[id].sessionId));
98+
99+
return {
100+
width: currentWindowSizeData.value.width,
101+
height: currentWindowSizeData.value.height
102+
};
103+
}
104+
105+
async getBrowsersList () {
106+
var platformsInfo = await requestApiBase(BROWSERSTACK_API_PATHS.browserList);
107+
108+
return platformsInfo.reverse();
109+
}
110+
111+
getSessionUrl (id) {
112+
return this.sessions[id] ? this.sessions[id].sessionUrl : '';
113+
}
114+
115+
async openBrowser (id, pageUrl, capabilities) {
116+
var { localIdentifier, local, ...restCapabilities } = capabilities;
117+
118+
capabilities = {
119+
'browserstack.localIdentifier': localIdentifier,
120+
'browserstack.local': local,
121+
...restCapabilities
122+
};
123+
124+
this.sessions[id] = await requestApi(BROWSERSTACK_API_PATHS.newSession, {
125+
body: { desiredCapabilities: capabilities },
126+
127+
executeImmediately: true
128+
});
129+
130+
this.sessions[id].sessionUrl = await this._requestSessionUrl(id);
131+
132+
var sessionId = this.sessions[id].sessionId;
133+
134+
this.sessions[id].interval = setInterval(() => requestApi(BROWSERSTACK_API_PATHS.getUrl(sessionId), { executeImmediately: true }), API_POLLING_INTERVAL);
135+
136+
await requestApi(BROWSERSTACK_API_PATHS.openUrl(sessionId), { body: { url: pageUrl } });
137+
}
138+
139+
async closeBrowser (id) {
140+
clearInterval(this.sessions[id].interval);
141+
142+
await requestApi(BROWSERSTACK_API_PATHS.deleteSession(this.sessions[id].sessionId));
143+
}
144+
145+
async takeScreenshot (id, screenshotPath) {
146+
return new Promise(async (resolve, reject) => {
147+
var base64Data = await requestApi(BROWSERSTACK_API_PATHS.screenshot(this.sessions[id].sessionId));
148+
var buffer = Buffer.from(base64Data.value, 'base64');
149+
150+
jimp
151+
.read(buffer)
152+
.then(image => image.write(screenshotPath, resolve))
153+
.catch(reject);
154+
});
155+
}
156+
157+
async resizeWindow (id, width, height, currentWidth, currentHeight) {
158+
var sessionId = this.sessions[id].sessionId;
159+
var currentWindowSize = await this._requestCurrentWindowSize(id);
160+
var currentClientAreaSize = { width: currentWidth, height: currentHeight };
161+
var requestedSize = { width, height };
162+
var correctedSize = getCorrectedSize(currentClientAreaSize, currentWindowSize, requestedSize);
163+
164+
await requestApi(BROWSERSTACK_API_PATHS.setWindowSize(sessionId), { body: correctedSize });
165+
}
166+
167+
async maximizeWindow (id) {
168+
await requestApi(BROWSERSTACK_API_PATHS.maximizeWindow(this.sessions[id].sessionId));
169+
}
170+
171+
async reportJobResult (id, jobResult, jobData, possibleResults) {
172+
var sessionId = this.sessions[id].sessionId;
173+
var jobStatus = createBrowserstackStatus(jobResult, jobData, possibleResults);
174+
175+
await requestApiBase(BROWSERSTACK_API_PATHS.setStatus(sessionId), { body: jobStatus });
176+
}
177+
}

src/backends/base.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export default class BaseBackend {
2+
constructor (reportWarning) {
3+
this.reportWarning = reportWarning;
4+
}
5+
6+
async getBrowsersList () {
7+
throw new Error('Not implemented');
8+
}
9+
10+
async openBrowser (/*id, pageUrl, capabilities*/) {
11+
throw new Error('Not implemented');
12+
}
13+
14+
async closeBrowser (/*id*/) {
15+
throw new Error('Not implemented');
16+
}
17+
18+
async takeScreenshot (/*id, path*/) {
19+
throw new Error('Not implemented');
20+
}
21+
22+
async resizeWindow (/*id, width, height, currentWidth, currentHeight*/) {
23+
throw new Error('Not implemented');
24+
}
25+
26+
async maximizeWindow (/*id*/) {
27+
throw new Error('Not implemented');
28+
}
29+
30+
async reportJobResult (/*id, jobStatus, jobData, possibleStatuses*/) {
31+
throw new Error('Not implemented');
32+
}
33+
34+
getSessionUrl (/*id*/) {
35+
throw new Error('Not implemented');
36+
}
37+
}

0 commit comments

Comments
 (0)