Skip to content

Instantly share code, notes, and snippets.

@ngraf
Last active February 4, 2024 03:15
Show Gist options
  • Save ngraf/554b8ea51a6bcda47cd8975c76a2343f to your computer and use it in GitHub Desktop.
Save ngraf/554b8ea51a6bcda47cd8975c76a2343f to your computer and use it in GitHub Desktop.
CodeceptJS helper for test/block/mock network traffic with Playwright

About

A CodeceptJS helper to test, block or mock network traffic with the help of Playwright. The code is considered experimental and may contain some flaws. Maybe in future when it is mature enough it can become part of a library.

Features:

  • I.mockTraffic
  • I.blockTraffic
    • Example: I.blockTraffic('https://www.example.com/assets/*')
  • I.startRecordingTraffic
  • I.seeTraffic
  • I.dontSeeTraffic
  • I.stopRecordingTraffic

Usage

  1. Add file PlaywrightNetwork.js from this gist to your test suite, e.g. to "src/helpers/PlaywrightNetwork.js"

  2. Add configuration in codeceptjs.conf.js:

    helpers:  {
      PlaywrightNetwork: {
        require: 'src/helpers/PlaywrightNetwork.js',
      },
    }
  1. Use helper in CodeceptJS scenario like this:
 Scenario('Test network traffic', ({I} => {
   await I.startRecordingTraffic();
   I.amOnPage('https://opencollective.com/codeceptjs');
   await I.seeTraffic('pageview event', 'https://plausible.io/api/event');

   // Or even more advanced:
   await I.seeTraffic("pageview event", "https://plausible.io/api/event", {
     d: "opencollective.com",
     n: "pageview",
     r: undefined,
     u: "https://opencollective.com/codeceptjs"
   });
 });
const assert = require('assert');
/**
* Helps you test network traffic with Playwright.
*
* - mockTraffic
* - blockTraffic
* - startRecordingTraffic
* - seeTraffic
* - dontSeeTraffic
* - stopRecordingTraffic
*
* Example usage:
*
* await I.startRecordingTraffic();
* I.amOnPage('https://opencollective.com/codeceptjs');
* await I.seeTraffic('pageview event', 'https://plausible.io/api/event');
*
* or even more advanced with check of values sent to API:
*
* await I.seeTraffic(
* "pageview event",
* "https://plausible.io/api/event",
* {
* d: "opencollective.com",
* n: "pageview",
* r: undefined,
* u: "https://opencollective.com/codeceptjs"
* }
* );
*/
class PlaywrightNetwork extends codecept_helper {
constructor(config)
{
super(config);
this.requests = []
this.recording = false;
this.recordedAtLeastOnce = false;
}
/**
* Starts recording of network traffic.
* This also resets recorded network requests.
*
* @return {Promise<void>}
*/
async startRecordingTraffic() {
this.flushTraffic();
this.recording = true;
this.recordedAtLeastOnce = true;
const playwright = this.helpers.Playwright
if (playwright) {
const {page} = playwright;
// page.removeAllListeners('request'); // Taken from original. Is this needed ?
// await page.setRequestInterception(true); // Taken from original. It this needed ?
page.on('request', async (request) => {
const information = {
url: request.url(),
requestHeaders: request.headers(),
requestPostData: request.postData(),
}
// console.log('REQUEST', information);
this.requests.push(information)
})
}
}
/**
* Blocks traffic for URL.
*
* @param url URL to block . URL can contain * for wildcards. Example: https://www.example.com* to block all traffic for that domain.
*/
blockTraffic(url) {
const playwright = this.helpers.Playwright
if ( playwright) {
const { page } = playwright;
page.route(url, (route) => {
route
.abort()
// Sometimes it happens that browser has been closed in the meantime. It is ok to ignore error then.
.catch((e) => {});
})
}
}
/**
* Mocks traffic for URL(s).
* This is a powerful feature to manipulate network traffic. Can be used e.g. to stabilize your tests.
*
* @param urls string|Array These are the URL(s) to mock, e.g. "/fooapi/*" or "['/fooapi_1/*', '/barapi_2/*']"
* @param responseString string The string to return in fake response.
* @param contentType Content type of response. If not specified default value 'application/json' is used.
*/
mockTraffic(urls, responseString, contentType = 'application/json') {
const playwright = this.helpers.Playwright
if ( playwright) {
const {page} = playwright;
const headers = {'access-control-allow-origin': '*'} // Required to mock cross-domain requests
if (typeof urls === 'string') {
urls = [urls]
}
urls.forEach((url) => {
page.route(url, (route) => {
if (page.isClosed()) {
// Sometimes it happens that browser has been closed in the meantime.
// In this case we just don't fullfill to prevent error in test scenario.
return
}
route.fulfill({
contentType,
headers,
body: responseString
})
});
})
}
}
/**
* Resets all recorded network requests.
*/
flushTraffic() {
this.requests = []
}
/**
* Stops recording of network traffic. Recorded traffic is not flashed.
*/
stopRecordingTraffic() {
const playwright = this.helpers.Playwright
if (playwright) {
const {page} = this.helpers.Playwright;
page.removeAllListeners('request'); // Is this needed ?
}
}
/**
* Verifies that a certain request is part of network traffic.
*
* @param name A name of that request. Can be any value. Only relevant to have a more meaningful error message in case of fail.
* @param url Expected URL of request in network traffic
* @param parameters Expected parameters of that request in network traffic
* @param timeout Timeout to wait for request in seconds. Default is 10 seconds.
* @return {Promise<boolean>}
*/
async seeTraffic(name, url, parameters = undefined, timeout = 10) {
if ( ! this.recordedAtLeastOnce) {
throw new Error('Failure in test automation. You use "I.seeInTraffic", but "I.startRecordingTraffic" was never called before.')
}
for (let i = 0; i <= timeout * 2; i++) {
const found = this._isInTraffic(url, parameters);
if (found) {
// console.info('INFO: traffic found for "' + name + '" after -- ' + (i * 500) + ' -- milliseconds');
return true;
}
await new Promise((done) => setTimeout(done, 500));
}
// I know the test will fail.
// Now the answer is because of mismatches or timeout
if (parameters && this._isInTraffic(url)) {
const advancedTestResults = createAdvancedTestResults(url, parameters, this.requests);
assert.fail(
`Traffic named "${name}" found correct URL ${url}, BUT the query parameters did not match:\n`
+ `${advancedTestResults}`
)
} else {
assert.fail(
`Traffic named "${name}" not found in recorded traffic within ${timeout} seconds.\n`
+ `Expected url: ${url}.\n`
+ `Recorded traffic:\n${this._getTrafficDump()}`
)
}
}
/**
* Returns full URL of request matching parameter "urlMatch".
* @param urlMatch String Regular expression string the wanted URL must match
* @return {Promise<*>}
*/
async getTrafficUrl(urlMatch) {
for (const i in this.requests) {
if (this.requests.hasOwnProperty(i)) {
const request = this.requests[i];
if (request.url && request.url.match(new RegExp(urlMatch))) {
return request.url
}
}
}
assert.fail(`Method "getTrafficUrl" failed: No request found in traffic that matches ${urlMatch}`);
}
/**
* Verifies that a certain request is not part of network traffic.
* @param name A name of that request. Can be any value. Only relevant to have a more meaningful error message in case of fail.
* @param url Expected URL of request in network traffic
*/
dontSeeTraffic(name, url) {
if ( ! this.recordedAtLeastOnce) {
throw new Error('Failure in test automation. You use "I.dontSeeTraffic", but "I.startRecordingTraffic" was never called before.')
}
if (this._isInTraffic(url)) {
assert.fail(`Traffic with name "${name}" (URL: "${url}') found, but was not expected to be found.`);
}
}
/**
* Checks if URL with parameters is part of network traffic. Returns true or false. Internal method for this helper.
*
* @param url URL to look for.
* @param parameters Parameters that this URL needs to contain
* @return {boolean} Whether or not URL with parameters is part of network traffic.
* @private
*/
_isInTraffic(url, parameters) {
let isInTraffic = false;
this.requests.forEach( (request) => {
if (isInTraffic) {
return; // We already found traffic. Continue with next request
}
if (!request.url.match(new RegExp(url))) {
return; // url not found in this request. continue with next request
}
// URL has matched. Now we check the parameters
if (parameters) {
const advancedReport = allParameterValuePairsMatchExtreme(extractQueryObjects(request.url), parameters);
if (advancedReport === true) {
isInTraffic = true;
}
} else {
isInTraffic = true;
}
})
return isInTraffic;
}
/**
* Returns all URLs of all network requests recorded so far during execution of test scenario.
*
* @return {string} List of URLs recorded as a string, seperaeted by new lines after each URL
* @private
*/
_getTrafficDump() {
let dumpedTraffic = ''
this.requests.forEach( (request) => {
dumpedTraffic += `${request.url}\n`
})
return dumpedTraffic
}
}
module.exports = PlaywrightNetwork;
/**
* Creates advanced test results for a network traffic check.
* @param url URL to search for
* @param parameters Parameters that this URL needs to contain
* @param requests All recorded requests
* @return {string|*}
*/
const createAdvancedTestResults = (url, parameters, requests) => {
if (parameters === undefined) {
// Advanced test results only applies when expected parameters are set
return ''
}
let urlFound = false;
let advancedResults;
requests.forEach( (request) => {
if (urlFound) {
return;
}
if (!request.url.match(new RegExp(url))) {
return; // url not found in this request. continue with next request
}
urlFound = true;
// Url found. Now we create advanced test report for that URL and show which parameters failed
advancedResults = allParameterValuePairsMatchExtreme(extractQueryObjects(request.url), parameters)
})
return advancedResults;
}
/**
* Converts a string of GET parameters into an array of parameter objects.
* Each parameter object contains the properties "name" and "value".
*
* @param queryString string A string of parameters
* @return {[]|*[]}
*/
const extractQueryObjects = (queryString) => {
if (queryString.indexOf('?') === -1 ) {
return [];
}
const queryObjects = [];
const queryPart = queryString.split('?')[1];
const queryParameters = queryPart.split('&');
queryParameters.forEach((queryParameter) => {
const keyValue = queryParameter.split('=');
const queryObject = {};
// eslint-disable-next-line prefer-destructuring
queryObject.name = keyValue[0];
queryObject.value = decodeURIComponent(keyValue[1]);
queryObjects.push( queryObject );
})
return queryObjects
}
/**
* More advanced check if all request parameters match with the expectations
*
* @param queryStringObject
* @param advancedExpectedParameterValuePairs
* @returns {*}
*/
const allParameterValuePairsMatchExtreme = (queryStringObject, advancedExpectedParameterValuePairs) => {
let littleReport = '\nQuery parameters:\n';
let success = true;
for (const expectedKey in advancedExpectedParameterValuePairs) {
if (!Object.prototype.hasOwnProperty.call(advancedExpectedParameterValuePairs, expectedKey)) {
continue
}
let parameterFound = false;
const expectedValue = advancedExpectedParameterValuePairs[expectedKey];
for (const queryParameter of queryStringObject) {
if (queryParameter.name === expectedKey) {
parameterFound = true;
if (expectedValue === undefined) {
littleReport += ` ${expectedKey.padStart(10, ' ')}\n`;
} else if (typeof expectedValue === 'object' && expectedValue.base64) {
const decodedActualValue = Buffer.from(queryParameter.value, 'base64').toString('utf8')
if (decodedActualValue === expectedValue.base64) {
littleReport += ` ${expectedKey.padStart(10, ' ')} = base64(${expectedValue.base64})\n`
} else {
littleReport += ` ✖ ${expectedKey.padStart(10, ' ')} = base64(${expectedValue.base64}) -> actual value: "base64(${decodedActualValue})"\n`;
success = false;
}
} 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 (parameterFound === false) {
littleReport += ` ✖ ${expectedKey.padStart(10, ' ')}${expectedValue ? ` = ${JSON.stringify(expectedValue)}` : ''} -> parameter not found in request\n`;
success = false;
}
}
return success ? true : littleReport;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment