|
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; |
|
} |