Skip to content

Commit 566203d

Browse files
martomoDavertMik
authored andcommitted
Improve handling of connection with remote browser using Puppeteer (#1402)
* Disconnect from remote browser instead of terminating running instance. * Ensure clean session on remote browser by closing any open pages once connected to endpoint. * Abort running of tests when connection with remote browser was lost. * Display error message when unable to connect with remote browser. * Update Puppeteer helper documentation for connecting with remote browser using websocket. * Restored 'test-helpers' service in order to easily run helper tests locally in Docker. * Added tests to cover Puppeteer helper behavior when connected to remote browser. * Rely on protocol error when connection was lost instead of listening for 'disconnected' event. Considering it is not possible to unbind event handler using '.off' until node version 10. * Move tests for remote browser behavior to main Puppeteer test file. Avoiding unnecessary multiple execution of unit/runner tests with Puppeteer helper.
1 parent 9709cf1 commit 566203d

File tree

5 files changed

+191
-29
lines changed

5 files changed

+191
-29
lines changed

docs/helpers/Puppeteer.md

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
**Extends Helper**
66

77
Uses [Google Chrome's Puppeteer][1] library to run tests inside headless Chrome.
8-
Browser control is executed via DevTools without Selenium.
8+
Browser control is executed via DevTools Protocol (instead of Selenium).
99
This helper works with a browser out of the box with no additional tools required to install.
1010

1111
Requires `puppeteer` package to be installed.
@@ -27,10 +27,9 @@ This helper should be configured in codecept.json or codecept.conf.js
2727
- `getPageTimeout` (optional, default: '0') config option to set maximum navigation time in milliseconds.
2828
- `waitForTimeout`: (optional) default wait\* timeout in ms. Default: 1000.
2929
- `windowSize`: (optional) default window size. Set a dimension like `640x480`.
30-
- `WSEndpoint`: (optional) Chrome websocket URL to use remote browser
3130
- `userAgent`: (optional) user-agent string.
3231
- `manualStart`: (optional, default: false) - do not start browser before a test, start it manually inside a helper with `this.helpers["Puppeteer"]._startBrowser()`.
33-
- `chrome`: (optional) pass additional [Puppeteer run options][3]. Example
32+
- `chrome`: (optional) pass additional [Puppeteer run options][3].
3433

3534
#### Example #1: Wait for 0 network connections.
3635

@@ -75,15 +74,23 @@ This helper should be configured in codecept.json or codecept.conf.js
7574
}
7675
```
7776

78-
#### Example #4: Using remote WS endpoint
77+
#### Example #4: Connect to remote browser by specifying [websocket endpoint][4]
7978

80-
```js
81-
"chrome": {
82-
"executablePath" : "/path/to/Chrome",
83-
"browserWSEndpoint": "ws://localhost:3000"
79+
```json
80+
{
81+
"helpers": {
82+
"Puppeteer" : {
83+
"url": "http://localhost",
84+
"chrome": {
85+
"browserWSEndpoint": "ws://localhost:9222/devtools/browser/c5aa6160-b5bc-4d53-bb49-6ecb36cd2e0a"
86+
}
87+
}
88+
}
8489
}
8590
```
8691

92+
Note: When connecting to remote browser `show` and specific `chrome` options (e.g. `headless` or `devtools`) are ignored.
93+
8794
## Access From Helpers
8895

8996
Receive Puppeteer client from a custom helper by accessing `browser` for the Browser object or `page` for the current Page object:
@@ -169,13 +176,13 @@ Set current page
169176

170177
#### Parameters
171178

172-
- `page` **[object][4]** page to set
179+
- `page` **[object][5]** page to set
173180

174181
### acceptPopup
175182

176183
Accepts the active JavaScript native popup window, as created by window.alert|window.confirm|window.prompt.
177184
Don't confuse popups with modal windows, as created by [various
178-
libraries][5].
185+
libraries][6].
179186

180187
### amAcceptingPopups
181188

@@ -494,7 +501,7 @@ I.dragSlider('#slider', -70);
494501
Executes async script on page.
495502
Provided function should execute a passed callback (as first argument) to signal it is finished.
496503

497-
Example: In Vue.js to make components completely rendered we are waiting for [nextTick][6].
504+
Example: In Vue.js to make components completely rendered we are waiting for [nextTick][7].
498505

499506
```js
500507
I.executeAsyncScript(function(done) {
@@ -766,7 +773,7 @@ I.openNewTab();
766773
### pressKey
767774
768775
Presses a key on a focused element.
769-
Special keys like 'Enter', 'Control', [etc][7]
776+
Special keys like 'Enter', 'Control', [etc][8]
770777
will be replaced with corresponding unicode.
771778
If modifier key is used (Control, Command, Alt, Shift) in array, it will be released afterwards.
772779
@@ -1385,10 +1392,12 @@ I.waitUrlEquals('http://127.0.0.1:8000/info');
13851392

13861393
[3]: https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#puppeteerlaunchoptions
13871394

1388-
[4]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object
1395+
[4]: https://chromedevtools.github.io/devtools-protocol/#how-do-i-access-the-browser-target
1396+
1397+
[5]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object
13891398

1390-
[5]: http://jster.net/category/windows-modals-popups
1399+
[6]: http://jster.net/category/windows-modals-popups
13911400

1392-
[6]: https://vuejs.org/v2/api/#Vue-nextTick
1401+
[7]: https://vuejs.org/v2/api/#Vue-nextTick
13931402

1394-
[7]: https://code.google.com/p/selenium/wiki/JsonWireProtocol#/session/:sessionId/element/:id/value
1403+
[8]: https://code.google.com/p/selenium/wiki/JsonWireProtocol#/session/:sessionId/element/:id/value

lib/helper/Puppeteer.js

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const {
2222
} = require('../colorUtils');
2323
const path = require('path');
2424
const ElementNotFound = require('./errors/ElementNotFound');
25+
const RemoteBrowserConnectionRefused = require('./errors/RemoteBrowserConnectionRefused');
2526
const Popup = require('./extras/Popup');
2627
const Console = require('./extras/Console');
2728

@@ -32,7 +33,7 @@ const consoleLogStore = new Console();
3233

3334
/**
3435
* Uses [Google Chrome's Puppeteer](https://github.com/GoogleChrome/puppeteer) library to run tests inside headless Chrome.
35-
* Browser control is executed via DevTools without Selenium.
36+
* Browser control is executed via DevTools Protocol (instead of Selenium).
3637
* This helper works with a browser out of the box with no additional tools required to install.
3738
*
3839
* Requires `puppeteer` package to be installed.
@@ -54,10 +55,9 @@ const consoleLogStore = new Console();
5455
* * `getPageTimeout` (optional, default: '0') config option to set maximum navigation time in milliseconds.
5556
* * `waitForTimeout`: (optional) default wait* timeout in ms. Default: 1000.
5657
* * `windowSize`: (optional) default window size. Set a dimension like `640x480`.
57-
* * `WSEndpoint`: (optional) Chrome websocket URL to use remote browser
5858
* * `userAgent`: (optional) user-agent string.
5959
* * `manualStart`: (optional, default: false) - do not start browser before a test, start it manually inside a helper with `this.helpers["Puppeteer"]._startBrowser()`.
60-
* * `chrome`: (optional) pass additional [Puppeteer run options](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#puppeteerlaunchoptions). Example
60+
* * `chrome`: (optional) pass additional [Puppeteer run options](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#puppeteerlaunchoptions).
6161
*
6262
*
6363
* #### Example #1: Wait for 0 network connections.
@@ -103,14 +103,22 @@ const consoleLogStore = new Console();
103103
* }
104104
* ```
105105
*
106-
* #### Example #4: Using remote WS endpoint
106+
* #### Example #4: Connect to remote browser by specifying [websocket endpoint](https://chromedevtools.github.io/devtools-protocol/#how-do-i-access-the-browser-target)
107107
*
108-
* ```js
109-
* "chrome": {
110-
* "executablePath" : "/path/to/Chrome",
111-
* "browserWSEndpoint": "ws://localhost:3000"
112-
* }
113-
* ```
108+
* ```json
109+
* {
110+
* "helpers": {
111+
* "Puppeteer" : {
112+
* "url": "http://localhost",
113+
* "chrome": {
114+
* "browserWSEndpoint": "ws://localhost:9222/devtools/browser/c5aa6160-b5bc-4d53-bb49-6ecb36cd2e0a"
115+
* }
116+
* }
117+
* }
118+
* }
119+
* ```
120+
*
121+
* Note: When connecting to remote browser `show` and specific `chrome` options (e.g. `headless` or `devtools`) are ignored.
114122
*
115123
* ## Access From Helpers
116124
*
@@ -132,7 +140,7 @@ class Puppeteer extends Helper {
132140
puppeteer = requireg('puppeteer');
133141

134142
// set defaults
135-
143+
this.isRemoteBrowser = false;
136144
this.isRunning = false;
137145

138146
// override defaults with config
@@ -162,6 +170,7 @@ class Puppeteer extends Helper {
162170
_setConfig(config) {
163171
this.options = this._validateConfig(config);
164172
this.puppeteerOptions = Object.assign({ headless: !this.options.show }, this.options.chrome);
173+
this.isRemoteBrowser = !!this.puppeteerOptions.browserWSEndpoint;
165174
popupStore.defaultAction = this.options.defaultPopupAction;
166175
}
167176

@@ -384,7 +393,23 @@ class Puppeteer extends Helper {
384393
}
385394

386395
async _startBrowser() {
387-
this.browser = this.puppeteerOptions.browserWSEndpoint ? await puppeteer.connect(this.puppeteerOptions) : await puppeteer.launch(this.puppeteerOptions);
396+
if (this.isRemoteBrowser) {
397+
try {
398+
this.browser = await puppeteer.connect(this.puppeteerOptions);
399+
} catch (err) {
400+
if (err.toString().indexOf('ECONNREFUSED')) {
401+
throw new RemoteBrowserConnectionRefused(err);
402+
}
403+
throw err;
404+
}
405+
406+
// Clear any prior existing pages when connecting to remote websocket
407+
await this.browser.newPage();
408+
await this.closeOtherTabs();
409+
} else {
410+
this.browser = await puppeteer.launch(this.puppeteerOptions);
411+
}
412+
388413
this.browser.on('targetcreated', target => target.page().then(page => targetCreatedHandler.call(this, page)));
389414
this.browser.on('targetchanged', (target) => {
390415
this.debugSection('Url', target.url());
@@ -416,7 +441,11 @@ class Puppeteer extends Helper {
416441
this._setPage(null);
417442
this.context = null;
418443
popupStore.clear();
419-
await this.browser.close();
444+
if (this.isRemoteBrowser) {
445+
await this.browser.disconnect();
446+
} else {
447+
await this.browser.close();
448+
}
420449
}
421450

422451
async _evaluateHandeInContext(...args) {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
function RemoteBrowserConnectionRefused(err) {
2+
this.message = 'Cannot connect to websocket endpoint.\n\n';
3+
this.message += 'Please make sure remote browser is running and accessible.';
4+
this.stack = err.error || err;
5+
}
6+
7+
RemoteBrowserConnectionRefused.prototype = Object.create(Error.prototype);
8+
9+
module.exports = RemoteBrowserConnectionRefused;

test/docker-compose.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,20 @@ services:
99
- ..:/codecept
1010
- node_modules:/codecept/node_modules
1111

12+
test-helpers:
13+
build: ..
14+
entrypoint: /codecept/node_modules/.bin/mocha --invert --fgrep Appium
15+
command: test/helper
16+
working_dir: /codecept
17+
env_file: .env
18+
depends_on:
19+
- selenium.chrome
20+
- php
21+
- json_server
22+
volumes:
23+
- ..:/codecept
24+
- node_modules:/codecept/node_modules
25+
1226
test-rest:
1327
build: ..
1428
entrypoint: /codecept/node_modules/.bin/mocha

test/helper/Puppeteer_test.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const TestHelper = require('../support/TestHelper');
22
const Puppeteer = require('../../lib/helper/Puppeteer');
3+
const puppeteer = require('puppeteer');
34
const should = require('chai').should();
45
const assert = require('assert');
56
const path = require('path');
@@ -558,3 +559,103 @@ describe('Puppeteer', function () {
558559
});
559560
});
560561
});
562+
563+
let remoteBrowser;
564+
async function createRemoteBrowser() {
565+
if (remoteBrowser) {
566+
await remoteBrowser.close();
567+
}
568+
remoteBrowser = await puppeteer.launch({
569+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
570+
headless: true,
571+
});
572+
remoteBrowser.on('disconnected', () => {
573+
remoteBrowser = null;
574+
});
575+
return remoteBrowser;
576+
}
577+
578+
const helperConfig = {
579+
chrome: {
580+
browserWSEndpoint: 'ws://localhost:9222/devtools/browser/<id>',
581+
// Following options are ignored with remote browser
582+
headless: false,
583+
devtools: true,
584+
},
585+
// Important in order to handle remote browser state before starting/stopping browser
586+
manualStart: true,
587+
url: siteUrl,
588+
waitForTimeout: 5000,
589+
waitForAction: 500,
590+
windowSize: '500x700',
591+
};
592+
593+
describe('Puppeteer (remote browser)', function () {
594+
this.timeout(35000);
595+
this.retries(1);
596+
597+
before(() => {
598+
global.codecept_dir = path.join(__dirname, '/../data');
599+
I = new Puppeteer(helperConfig);
600+
I._init();
601+
return I._beforeSuite();
602+
});
603+
604+
beforeEach(async () => {
605+
// Mimick remote session by creating another browser instance
606+
await createRemoteBrowser();
607+
// Set websocket endpoint to other browser instance
608+
helperConfig.chrome.browserWSEndpoint = await remoteBrowser.wsEndpoint();
609+
I._setConfig(helperConfig);
610+
611+
return I._before();
612+
});
613+
614+
afterEach(() => {
615+
return I._after()
616+
.then(() => {
617+
remoteBrowser && remoteBrowser.close();
618+
});
619+
});
620+
621+
describe('#_startBrowser', () => {
622+
it('should throw an exception when endpoint is unreachable', async () => {
623+
helperConfig.chrome.browserWSEndpoint = 'ws://unreachable/';
624+
I._setConfig(helperConfig);
625+
try {
626+
await I._startBrowser();
627+
throw Error('It should never get this far');
628+
} catch (e) {
629+
e.message.should.include('Cannot connect to websocket endpoint.\n\nPlease make sure remote browser is running and accessible.');
630+
}
631+
});
632+
633+
it('should clear any prior existing pages on remote browser', async () => {
634+
const remotePages = await remoteBrowser.pages();
635+
assert.equal(remotePages.length, 1);
636+
for (let p = 1; p < 5; p++) {
637+
await remoteBrowser.newPage();
638+
}
639+
const existingPages = await remoteBrowser.pages();
640+
assert.equal(existingPages.length, 5);
641+
642+
await I._startBrowser();
643+
// Session was cleared
644+
let currentPages = await remoteBrowser.pages();
645+
assert.equal(currentPages.length, 1);
646+
647+
let numPages = await I.grabNumberOfOpenTabs();
648+
assert.equal(numPages, 1);
649+
650+
await I.openNewTab();
651+
652+
numPages = await I.grabNumberOfOpenTabs();
653+
assert.equal(numPages, 2);
654+
655+
await I._stopBrowser();
656+
657+
currentPages = await remoteBrowser.pages();
658+
assert.equal(currentPages.length, 2);
659+
});
660+
});
661+
});

0 commit comments

Comments
 (0)