Skip to content

Instantly share code, notes, and snippets.

@ngraf
Last active June 17, 2020 15:35
Show Gist options
  • Save ngraf/c62209a24d8e2ff6a67a57c307f6049e to your computer and use it in GitHub Desktop.
Save ngraf/c62209a24d8e2ff6a67a57c307f6049e to your computer and use it in GitHub Desktop.
Proof of Concept: BrowserMobProxy helper for CodeceptJS - DRAFT v0.1

Proof of Concept: BrowserMobProxy + CodeceptJS

Problem

You cannot access network traffic in regular Selenium webdriver setup.

Solution

You use BrowserMobProxy as a proxy in your tests. You can then read traffic logged by BrowserMobProxy and make your test pass/fail.

Before: CodeceptJS -> Webdriver -> Browser -> Website

After: CodeceptJS -> Webdriver -> Browser -> BrowserMobProxy -> Website

CodeceptJS now can ask BrowserMobProxy via helper:

  • seeTraffic
  • dontSeeTraffic
  • saveTraffic
  • getHar
  • rewriteTraffic

How to steps

  1. Download and start BrowserMobProxy server locally https://github.com/lightbody/browsermob-proxy
  2. Add npm package "browsermob-proxy" to your test suite dependencies: npm install browsermob-proxy
  3. Create a new helper "Browsermob.js" with the content of the snippet.
  4. Adjust codecept.conf.jsto load "Browsermob" helper. See snippet "codecept.conf.js (excerpt)" for details.
  5. Run "Example_test.js". Expected result: Test sucessfully verifies traffic has been fired.
const assert = require('assert');
const Proxy = require('browsermob-proxy').Proxy;
const fs = require('fs');
const event = require('codeceptjs').event;
const output = require('codeceptjs').output;
const container = require('codeceptjs').container;
class Browsermob extends Helper {
constructor(config)
{
super(config);
this.bmpProxyPort = config.bmpProxyPort || Math.floor(Math.random() * 99 + 8081); // if not configured: random number between 8081-8180
this.bmpPort = config.bmpPort || 8080;
this.bmpHost = config.host || 'localhost';
this.proxy = new Proxy({
host: this.bmpHost,
port: this.bmpPort,
proxyPort: this.bmpProxyPort
});
// Add behavior "Before" each test
event.dispatcher.on(event.test.before, async function (test) {
if (test.title.indexOf('@bmp') !== -1) {
// Configure browser driver to use proxy
const proxyConfig = {
proxyType:"MANUAL",
httpProxy: this.bmpHost + ':' + this.bmpProxyPort,
sslProxy: this.bmpHost + ':' + this.bmpProxyPort
};
// Save original proxy settings. We will reset to them after the test.
this.previousProxyConfig = detectBrowserHelper().config.capabilities.proxy;
detectBrowserHelper().config.capabilities.proxy = proxyConfig;
}
}.bind(this));
// Add behavior "After" each test
event.dispatcher.on(event.test.after, async function (test) {
if (test.title.indexOf('@bmp') !== -1) {
// Recover original proxy config to not sabotage other non-browsermobproxy tests.
detectBrowserHelper().config.capabilities.proxy = this.previousProxyConfig;
}
}.bind(this));
return this;
}
/**
*
* @returns {Promise<void>}
*/
startProxy() {
return new Promise(function (resolve, reject) {
this.proxy.start(this.bmpProxyPort, function (err, resp) {
if (err) {
const errMessage = '[ERROR] (BrowserMobProxy) Error starting proxy.' +
` Address of server: ${this.bmpHost}:${this.bmpPort}. Error message: '${err}'`;
reject(errMessage);
} else {
this.proxy.startHAR(this.bmpProxyPort, null, null, true, function (err, resp) {
if (!err) {
output.debug('[DEBUG] (BrowserMobProxy) Recording of HAR started on port ' + this.bmpProxyPort + ' ...');
resolve()
} else {
const errorMessage = ('[ERROR] (BrowserMobProxy) Error starting HAR: ' + err);
this.proxy.stop(data.port, function () {
});
reject(errorMessage);
}
}.bind(this));
}
}.bind(this));
}.bind(this));
}
getHar() {
return new Promise(function (resolve, reject) {
this.proxy.getHAR(this.bmpProxyPort, function (err, resp) {
if (! err) {
resolve(JSON.parse(resp))
} else {
reject('[ERROR] (BrowserMobProxy) Error getting HAR file: "' + err + '"')
}
});
}.bind(this));
}
stopProxy() {
return new Promise(function (resolve, reject) {
this.proxy.stop(this.bmpProxyPort, function(err) {
if (err) {
reject('[WARNING] (BrowserMobProxy) Error occured during stop of proxy on port ' + this.bmpProxyPort);
} else {
output.debug('[DEBUG] (BrowserMobProxy) Proxy stopped on port ' + this.bmpProxyPort);
resolve();
}
}.bind(this));
}.bind(this));
}
/**
* Verifies that network traffic contains a certain entry.
*
* @param name Short description of what you test. This value has no functionality, it just helps with the reports.
* @param url Part of URL is sufficient, f.e. the domain.
* @param parameters (optional) Expected parameters.
* This is an object of multiple key/value pairs, where the key is the name of the parameter
* and the value the expected value of the parameter.
* @param timeout timeout in seconds to wait for traffic to be present. Default value: 5
* @param advancedParameters Specify even more parameters that must be present in traffic. These parameters are only
* tested when environment variable "STRICT" exists and is set to value "true"
* @returns {Promise<void>}
*/
async seeTraffic(name, url, parameters, advancedParameters, timeout = 5 ) {
let testresult = false;
if (typeof parameters !== "object" && ! typeof parameters === "undefined") {
throw Error(
'Invalid argument for method "seeTraffic": Parameter "parameters" is not of type "Object".'
+ ' This would be a valid example: { "t" : "pageview"}'
);
}
const browserHelper = detectBrowserHelper();
if (typeof browserHelper.config.capabilities.proxy !== 'undefined' && ! browserHelper.config.capabilities.proxy.sslProxy) {
throw Error(
'[ERROR] BrowserMobProxy is not used as a proxy. This is expected when using "I.seeTraffic".'
+ 'To fix this you should add "@bmp" to the name of your test Scenario.'
);
}
for (let i = 0; i <= timeout * 2; i++) {
testresult = await this.isInTraffic(name, url, parameters, advancedParameters);
if (true === testresult) {
// console.log('INFO: traffic found for "' + name + '" after -- ' + (i * 500) + ' -- milliseconds');
return true;
}
await new Promise(done => setTimeout(done, 500));
}
assert.fail(testresult);
}
async isInTraffic(name, url, parameters, advancedParameters) {
let success = false;
let matches = 0;
let advancedResults = false;
const har = await this.getHar();
// Get entries from HAR
const entries = har.log.entries;
// Iterate over all entries
for (let entry of entries) {
// Does "url" match?
if (entry.request.url.indexOf(url) !== -1) {
// Does all "parameterValuePairs" match?
if (typeof parameters === "undefined"
|| allParameterValuePairsMatch(entry.request.queryString, entry.request.postData, parameters)
) {
matches++;
success = true;
if (typeof advancedParameters !== "undefined" && process.env.STRICT !== 'false') {
advancedResults = allParameterValuePairsMatchExtreme(entry.request.queryString, advancedParameters);
if (advancedResults !== true) {
success = false;
}
}
}
}
}
if (matches > 1) {
assert.fail(
`Network traffic for "${name}" was found ${matches} times. Only 1 time is expected.
Expected: Part of URL: "${url}", Parameters: "${JSON.stringify(parameters)}"`
);
}
if (success) {
return true;
}
// No matching entry found in traffic. Let the test fail.
// Use this for debugging the traffic:
//fs.writeFileSync('output/traffic-debug.har', JSON.stringify(har, null, "\t"), 'utf8');
return `Expected content in network traffic was not found for "${name}".
Expected: Part of URL: "${url}", Parameters: "${JSON.stringify(parameters)}"
${advancedResults ? advancedResults : ''}`
;
}
async dontSeeTraffic(name, url, parameters) {
const I = this;
try {
await I.seeTraffic(name,url,parameters, undefined, 0);
} catch (err) {
if (err.code === 'ERR_ASSERTION') {
// verification succesful
return true;
}
}
assert.fail(`Content in network traffic was unexpectedly found for "${name}".
Expected: Part of URL: "${url}", Parameters: "${JSON.stringify(parameters)}"
Content of network traffic saved in dir: "./output/traffic"`)
}
/**
* Modifies rewrite rules of BrowserMobProxy
*
* Example rewrite configuration to block traffic:
* {
* matchRegex : 'https://cdn.optimizely.com.*',
* replace : ''
* }
*/
rewriteTraffic(configuration) {
this.proxy.rewrite(this.bmpProxyPort, configuration, () => {});
}
/**
* Saves network traffic to .har file on disk.
*
* @param name Prefix added to filename. Filename will always contain timestamp. Pattern "name-1239809122.har". Default value: "Traffic"
* @param folder The folder the .har file will saved in. Default value: "output/traffic"
*/
async saveTraffic(name = 'Traffic', folder = 'output/traffic/') {
const I = this;
try {
if (folder.slice(-1) != '/' ) {
folder = folder + '/';
}
// Use this for debugging the traffic:
!fs.existsSync(folder) && fs.mkdirSync(folder);
const harFilename = name + '-' + new Date().getTime() + '.har';
fs.writeFileSync(folder + harFilename, JSON.stringify(await I.getHar(), null, "\t"), 'utf8');
console.log('Traffic saved to "file://' + process.cwd() + '/' + folder + harFilename + '"');
} catch (err) {
console.log('WARN: Writing traffic to HAR-file failed. Error message: "' + err + '"')
}
}
}
module.exports = Browsermob;
function allParameterValuePairsMatch(queryString, postData, parameterValuePairs) {
let parameterMatched = 0;
let success = false;
// Check content of "queryString"
for (let expectedKey in parameterValuePairs) {
let continueSearch = false;
let expectedValue = parameterValuePairs[expectedKey];
for (let queryParameter of queryString) {
if (queryParameter.name === expectedKey) {
if (queryParameter.value === expectedValue) {
parameterMatched++;
if (parameterMatched === Object.keys(parameterValuePairs).length) {
continueSearch = false;
success = true;
break;
} else {
continueSearch = true;
}
}
}
}
if (continueSearch === false) {
break;
}
}
// Nothing found so far, check content of "postData"
if (success === false && postData && postData.text) {
for (let parameterValuePairKey in parameterValuePairs) {
let continueSearch = false;
let expectedKey = parameterValuePairKey;
let expectedValue = parameterValuePairs[parameterValuePairKey];
if ( (expectedValue === undefined && postData.text.indexOf(expectedKey + '=') !== -1)
|| (postData.text.indexOf(expectedKey + '=' + expectedValue) !== -1)
) {
parameterMatched++;
if (parameterMatched === Object.keys(parameterValuePairs).length) {
continueSearch = false;
success = true;
} else {
continueSearch = true;
}
}
if (! continueSearch) {
break;
}
}
}
return success;
}
/**
* More advanced check if all request parameters match with the expectations
*
* @param queryString
* @param advancedExpectedParameterValuePairs
* @returns {*}
*/
function allParameterValuePairsMatchExtreme(queryString, advancedExpectedParameterValuePairs) {
let littleReport = '\nAdvanced test results:\n';
let success = true;
for (let expectedKey in advancedExpectedParameterValuePairs) {
let paramaterFound = false;
let expectedValue = advancedExpectedParameterValuePairs[expectedKey];
for (let queryParameter of queryString) {
if (queryParameter.name === expectedKey) {
paramaterFound = true;
if (expectedValue === undefined) {
littleReport += ' ' + expectedKey.padStart(10, ' ') + '\n';
} else if (queryParameter.value === expectedValue) {
littleReport += ' ' + expectedKey.padStart(10, ' ') + ' = ' + expectedValue + '\n'
} else {
littleReport += ' ✖ ' + expectedKey.padStart(10, ' ') + ' = ' + expectedValue + ' -> actual value: "' + queryParameter.value + '"\n';
success = false;
}
}
}
if (paramaterFound === false) {
littleReport += ' ✖ ' + expectedKey.padStart(10, ' ') + (expectedValue ? ' = ' + expectedValue : '') + ' -> parameter not found in request\n';
success = false;
}
}
return success ? true : littleReport;
}
function detectBrowserHelper() {
const browserHelpers = ['WebDriver','Protractor','Puppeteer','TestCafe','Nigthmare','Playwright'];
const configuredHelpers = codeceptjs.container.helpers();
for (const helper of browserHelpers) {
if (Object.keys(configuredHelpers).indexOf(helper) >= 0) return codeceptjs.container.helpers(helper);
}
throw new Error(`[ERROR] No browser helper configured in CodeceptJS config. Expecting one of these: ${browserHelpers}`);
}
exports.config = {
helpers: {
Browsermob: {
require: './src/helpers/Browsermob.js',
/* Local */
host: 'localhost',
bmpPort: 8080,
bmpProxyPort: 8081
},
},
}
Feature('Test of network traffic');
Before(async (I) => {
await I.startProxy();
});
After(async (I) => {
await I.saveTraffic('CodeceptJS');
await I.stopProxy();
});
Scenario('Google analytics pageview at codeceptjs.io @bmp', async (I) => {
I.amOnPage('https://codecept.io/');
I.seeTraffic('Google Analytics "pageview"', 'google-analytics.com', {t: 'pageview', dl: 'https://codecept.io/'});
/* any name for reporting */ /* matching url */ /* matching parameters */
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment