diff --git a/src/helpers/match-request-props.js b/src/helpers/match-request-props.js index d8de05df0..1d2925506 100644 --- a/src/helpers/match-request-props.js +++ b/src/helpers/match-request-props.js @@ -13,6 +13,10 @@ import { * @returns {boolean} */ export const matchRequestProps = (propsToMatch, requestData) => { + if (propsToMatch === '' || propsToMatch === '*') { + return true; + } + let isMatched; const parsedData = parseMatchProps(propsToMatch); diff --git a/src/scriptlets/prevent-fetch.js b/src/scriptlets/prevent-fetch.js index ae8d9d7ab..6d2982a42 100644 --- a/src/scriptlets/prevent-fetch.js +++ b/src/scriptlets/prevent-fetch.js @@ -2,11 +2,9 @@ import { hit, getFetchData, objectToString, - parseMatchProps, - validateParsedData, - getMatchPropsData, noopPromiseResolve, getWildcardSymbol, + matchRequestProps, // following helpers should be imported and injected // because they are used by helpers above toRegExp, @@ -16,6 +14,9 @@ import { getRequestData, getObjectEntries, getObjectFromEntries, + parseMatchProps, + validateParsedData, + getMatchPropsData, } from '../helpers/index'; /* eslint-disable max-len */ @@ -96,6 +97,9 @@ export function preventFetch(source, propsToMatch, responseBody = 'emptyObj', re return; } + // eslint-disable-next-line no-console + const log = console.log.bind(console); + let strResponseBody; if (responseBody === 'emptyObj') { strResponseBody = '{}'; @@ -108,7 +112,7 @@ export function preventFetch(source, propsToMatch, responseBody = 'emptyObj', re // Skip disallowed response types if (!(responseType === 'default' || responseType === 'opaque')) { // eslint-disable-next-line no-console - console.log(`Invalid parameter: ${responseType}`); + log(`Invalid parameter: ${responseType}`); return; } @@ -117,29 +121,13 @@ export function preventFetch(source, propsToMatch, responseBody = 'emptyObj', re const fetchData = getFetchData(args); if (typeof propsToMatch === 'undefined') { // log if no propsToMatch given - const logMessage = `log: fetch( ${objectToString(fetchData)} )`; - hit(source, logMessage); - } else if (propsToMatch === '' || propsToMatch === getWildcardSymbol()) { - // prevent all fetch calls - shouldPrevent = true; - } else { - const parsedData = parseMatchProps(propsToMatch); - if (!validateParsedData(parsedData)) { - // eslint-disable-next-line no-console - console.log(`Invalid parameter: ${propsToMatch}`); - shouldPrevent = false; - } else { - const matchData = getMatchPropsData(parsedData); - // prevent only if all props match - shouldPrevent = Object.keys(matchData) - .every((matchKey) => { - const matchValue = matchData[matchKey]; - return Object.prototype.hasOwnProperty.call(fetchData, matchKey) - && matchValue.test(fetchData[matchKey]); - }); - } + log(`fetch( ${objectToString(fetchData)} )`); + hit(source); + return Reflect.apply(target, thisArg, args); } + shouldPrevent = matchRequestProps(propsToMatch); + if (shouldPrevent) { hit(source); return noopPromiseResolve(strResponseBody, fetchData.url, responseType); @@ -167,11 +155,9 @@ preventFetch.injections = [ hit, getFetchData, objectToString, - parseMatchProps, - validateParsedData, - getMatchPropsData, noopPromiseResolve, getWildcardSymbol, + matchRequestProps, toRegExp, isValidStrPattern, escapeRegExp, @@ -179,4 +165,7 @@ preventFetch.injections = [ getRequestData, getObjectEntries, getObjectFromEntries, + parseMatchProps, + validateParsedData, + getMatchPropsData, ]; diff --git a/src/scriptlets/prevent-xhr.js b/src/scriptlets/prevent-xhr.js index f6ec99d49..71c9fb302 100644 --- a/src/scriptlets/prevent-xhr.js +++ b/src/scriptlets/prevent-xhr.js @@ -2,12 +2,10 @@ import { hit, objectToString, getWildcardSymbol, - parseMatchProps, - validateParsedData, - getMatchPropsData, getRandomIntInclusive, getRandomStrByLength, generateRandomResponse, + matchRequestProps, // following helpers should be imported and injected // because they are used by helpers above toRegExp, @@ -18,6 +16,9 @@ import { getNumberFromString, nativeIsFinite, nativeIsNaN, + parseMatchProps, + validateParsedData, + getMatchPropsData, } from '../helpers/index'; /* eslint-disable max-len */ @@ -94,6 +95,9 @@ export function preventXHR(source, propsToMatch, customResponseText) { return; } + // eslint-disable-next-line no-console + const log = console.log.bind(console); + let shouldPrevent = false; let response = ''; let responseText = ''; @@ -107,27 +111,10 @@ export function preventXHR(source, propsToMatch, customResponseText) { responseUrl = xhrData.url; if (typeof propsToMatch === 'undefined') { // Log if no propsToMatch given - const logMessage = `log: xhr( ${objectToString(xhrData)} )`; - hit(source, logMessage); - } else if (propsToMatch === '' || propsToMatch === getWildcardSymbol()) { - // Prevent all fetch calls - shouldPrevent = true; + log(`log: xhr( ${objectToString(xhrData)} )`); + hit(source); } else { - const parsedData = parseMatchProps(propsToMatch); - if (!validateParsedData(parsedData)) { - // eslint-disable-next-line no-console - console.log(`Invalid parameter: ${propsToMatch}`); - shouldPrevent = false; - } else { - const matchData = getMatchPropsData(parsedData); - // prevent only if all props match - shouldPrevent = Object.keys(matchData) - .every((matchKey) => { - const matchValue = matchData[matchKey]; - return Object.prototype.hasOwnProperty.call(xhrData, matchKey) - && matchValue.test(xhrData[matchKey]); - }); - } + shouldPrevent = matchRequestProps(propsToMatch); } return Reflect.apply(target, thisArg, args); @@ -151,8 +138,7 @@ export function preventXHR(source, propsToMatch, customResponseText) { if (randomText) { responseText = randomText; } else { - // eslint-disable-next-line no-console - console.log(`Invalid range: ${customResponseText}`); + log(`Invalid range: ${customResponseText}`); } } // Mock response object @@ -205,9 +191,7 @@ preventXHR.injections = [ hit, objectToString, getWildcardSymbol, - parseMatchProps, - validateParsedData, - getMatchPropsData, + matchRequestProps, getRandomIntInclusive, getRandomStrByLength, generateRandomResponse, @@ -219,4 +203,7 @@ preventXHR.injections = [ getNumberFromString, nativeIsFinite, nativeIsNaN, + parseMatchProps, + validateParsedData, + getMatchPropsData, ]; diff --git a/src/scriptlets/scriptlets-list.js b/src/scriptlets/scriptlets-list.js index c3b4f3ba3..f00ab89f7 100644 --- a/src/scriptlets/scriptlets-list.js +++ b/src/scriptlets/scriptlets-list.js @@ -50,3 +50,4 @@ export * from './prevent-element-src-loading'; export * from './no-topics'; export * from './trusted-replace-xhr-response'; export * from './xml-prune'; +export * from './trusted-replace-fetch-response'; diff --git a/src/scriptlets/trusted-replace-fetch-response.js b/src/scriptlets/trusted-replace-fetch-response.js new file mode 100644 index 000000000..a3472c501 --- /dev/null +++ b/src/scriptlets/trusted-replace-fetch-response.js @@ -0,0 +1,211 @@ +import { + hit, + getFetchData, + objectToString, + getWildcardSymbol, + matchRequestProps, + // following helpers should be imported and injected + // because they are used by helpers above + toRegExp, + isValidStrPattern, + escapeRegExp, + isEmptyObject, + getRequestData, + getObjectEntries, + getObjectFromEntries, + parseMatchProps, + validateParsedData, + getMatchPropsData, +} from '../helpers/index'; + +/* eslint-disable max-len */ +/** + * @scriptlet trusted-replace-fetch-response + * + * @description + * Replaces response text content of `fetch` requests if **all** given parameters match. + * + * **Syntax** + * ``` + * example.org#%#//scriptlet('trusted-replace-fetch-response'[, pattern, replacement[, propsToMatch]]) + * ``` + * + * - pattern - optional, argument for matching contents of responseText that should be replaced. If set, `replacement` is required; + * possible values: + * - '*' to match all text content + * - non-empty string + * - regular expression + * - replacement — optional, should be set if `pattern` is set. String to replace the response text content matched by `pattern`. + * Empty string to remove content. Defaults to empty string. + * - propsToMatch - optional, string of space-separated properties to match; possible props: + * - string or regular expression for matching the URL passed to fetch call; empty string, wildcard `*` or invalid regular expression will match all fetch calls + * - colon-separated pairs `name:value` where + * - `name` is [`init` option name](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters) + * - `value` is string or regular expression for matching the value of the option passed to fetch call; invalid regular expression will cause any value matching + * + * > Usage with no arguments will log fetch calls to browser console; + * which is useful for debugging but only allowed for production filter lists. + * + * > Scriptlet does nothing if response body can't be converted to text. + * + * **Examples** + * 1. Log all fetch calls + * ``` + * example.org#%#//scriptlet('trusted-replace-fetch-response') + * ``` + * + * 2. Replace response text content of fetch requests with specific url + * ``` + * example.org#%#//scriptlet('trusted-replace-fetch-response', 'adb_detect:true', 'adb_detect:false', 'example.org') + * example.org#%#//scriptlet('trusted-replace-fetch-response', '/#EXT-X-VMAP-AD-BREAK[\s\S]*?/', '#EXT-X-ENDLIST', 'example.org') + * ``` + * + * 3. Remove all text content of fetch responses with specific request method + * ``` + * example.org#%#//scriptlet('trusted-replace-fetch-response', '*', '', 'method:GET') + * ``` + * + * 4. Replace response text content of fetch requests matching by URL regex and request methods + * ``` + * example.org#%#//scriptlet('trusted-replace-fetch-response', '/#EXT-X-VMAP-AD-BREAK[\s\S]*?/', '#EXT-X-ENDLIST', '/\.m3u8/ method:/GET|HEAD/') + * ``` + * 5. Remove text content of all fetch responses for example.com + * ``` + * example.org#%#//scriptlet('trusted-replace-fetch-response', '*', '', 'example.com') + * ``` + */ +/* eslint-enable max-len */ +export function trustedReplaceFetchResponse(source, pattern = '', replacement = '', propsToMatch = '') { + // do nothing if browser does not support fetch or Proxy (e.g. Internet Explorer) + // https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy + if (typeof fetch === 'undefined' + || typeof Proxy === 'undefined' + || typeof Response === 'undefined') { + return; + } + + // eslint-disable-next-line no-console + const log = console.log.bind(console); + + // Only allow pattern as empty string for logging purposes + if (pattern === '' && replacement !== '') { + const logMessage = 'log: Pattern argument should not be empty string.'; + log(source, logMessage); + return; + } + const shouldLog = pattern === '' && replacement === ''; + + const nativeFetch = fetch; + + let shouldReplace = false; + let fetchData; + + const handlerWrapper = async (target, thisArg, args) => { + fetchData = getFetchData(args); + + if (shouldLog) { + // log if no propsToMatch given + log(`fetch( ${objectToString(fetchData)} )`); + hit(source); + return Reflect.apply(target, thisArg, args); + } + + shouldReplace = matchRequestProps(propsToMatch, fetchData); + + if (!shouldReplace) { + return Reflect.apply(target, thisArg, args); + } + + /** + * Create new Response object using original response' properties + * and given text as body content + * @param {Response} response original response to copy properties from + * @param {string} textContent text to set as body content + * @returns {Response} + */ + const forgeResponse = (response, textContent) => { + const { + bodyUsed, + headers, + ok, + redirected, + status, + statusText, + type, + url, + } = response; + + // eslint-disable-next-line compat/compat + const forgedResponse = new Response(textContent, { + status, + statusText, + headers, + }); + + // Manually set properties which can't be set by Response constructor + Object.defineProperties(forgedResponse, { + url: { value: url }, + type: { value: type }, + ok: { value: ok }, + bodyUsed: { value: bodyUsed }, + redirected: { value: redirected }, + }); + + return forgedResponse; + }; + + return nativeFetch(...args) + .then((response) => { + return response.text() + .then((bodyText) => { + const patternRegexp = pattern === getWildcardSymbol() + ? toRegExp() + : toRegExp(pattern); + + const modifiedTextContent = bodyText.replace(patternRegexp, replacement); + const forgedResponse = forgeResponse(response, modifiedTextContent); + + hit(source); + return forgedResponse; + }) + .catch(() => { + // log if response body can't be converted to a string + const fetchDataStr = objectToString(fetchData); + const logMessage = `log: Response body can't be converted to text: ${fetchDataStr}`; + log(source, logMessage); + return Reflect.apply(target, thisArg, args); + }); + }) + .catch(() => Reflect.apply(target, thisArg, args)); + }; + + const fetchHandler = { + apply: handlerWrapper, + }; + + fetch = new Proxy(fetch, fetchHandler); // eslint-disable-line no-global-assign +} + +trustedReplaceFetchResponse.names = [ + 'trusted-replace-fetch-response', + +]; + +trustedReplaceFetchResponse.injections = [ + hit, + getFetchData, + objectToString, + getWildcardSymbol, + matchRequestProps, + toRegExp, + isValidStrPattern, + escapeRegExp, + isEmptyObject, + getRequestData, + getObjectEntries, + getObjectFromEntries, + parseMatchProps, + validateParsedData, + getMatchPropsData, +]; diff --git a/src/scriptlets/trusted-replace-xhr-response.js b/src/scriptlets/trusted-replace-xhr-response.js index 8f2d83062..99c3a6953 100644 --- a/src/scriptlets/trusted-replace-xhr-response.js +++ b/src/scriptlets/trusted-replace-xhr-response.js @@ -31,7 +31,7 @@ import { * - pattern - optional, argument for matching contents of responseText that should be replaced. If set, `replacement` is required; * possible values: * - '*' to match all text content - * - string + * - non-empty string * - regular expression * - replacement — optional, should be set if `pattern` is set. String to replace matched content with. Empty string to remove content. * - propsToMatch — optional, string of space-separated properties to match for extra condition; possible props: @@ -77,12 +77,18 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = '' return; } - if (typeof pattern === 'undefined' || typeof replacement === 'undefined') { + // eslint-disable-next-line no-console + const log = console.log.bind(console); + + // Only allow pattern as empty string for logging purposes + if (pattern === '' && replacement !== '') { + const logMessage = 'log: Pattern argument should not be empty string.'; + log(source, logMessage); return; } - // eslint-disable-next-line no-console - const log = console.log.bind(console); + const shouldLog = pattern === '' && replacement === ''; + const nativeOpen = window.XMLHttpRequest.prototype.open; const nativeSend = window.XMLHttpRequest.prototype.send; @@ -93,14 +99,16 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = '' const openWrapper = (target, thisArg, args) => { xhrData = getXhrData(...args); - if (pattern === '' && replacement === '') { + if (shouldLog) { // Log if no propsToMatch given const logMessage = `log: xhr( ${objectToString(xhrData)} )`; - log(source, logMessage); - } else { - shouldReplace = matchRequestProps(propsToMatch, xhrData); + log(logMessage); + hit(source); + return Reflect.apply(target, thisArg, args); } + shouldReplace = matchRequestProps(propsToMatch, xhrData); + // Trap setRequestHeader of target xhr object to mimic request headers later if (shouldReplace) { const setRequestHeaderWrapper = (target, thisArg, args) => { @@ -131,9 +139,9 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = '' * to be able to collect response data without triggering * listeners on original XHR object */ - const replacingRequest = new XMLHttpRequest(); - replacingRequest.addEventListener('readystatechange', () => { - if (replacingRequest.readyState !== 4) { + const forgedRequest = new XMLHttpRequest(); + forgedRequest.addEventListener('readystatechange', () => { + if (forgedRequest.readyState !== 4) { return; } @@ -145,7 +153,7 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = '' responseXML, status, statusText, - } = replacingRequest; + } = forgedRequest; // Extract content from response const content = responseText || response; @@ -154,7 +162,7 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = '' } const patternRegexp = pattern === getWildcardSymbol() - ? toRegExp + ? toRegExp() : toRegExp(pattern); const modifiedContent = content.replace(patternRegexp, replacement); @@ -186,7 +194,7 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = '' hit(source); }); - nativeOpen.apply(replacingRequest, [xhrData.method, xhrData.url]); + nativeOpen.apply(forgedRequest, [xhrData.method, xhrData.url]); // Mimic request headers before sending // setRequestHeader can only be called on open request objects @@ -194,12 +202,12 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = '' const name = header[0]; const value = header[1]; - replacingRequest.setRequestHeader(name, value); + forgedRequest.setRequestHeader(name, value); }); requestHeaders = []; try { - nativeSend.call(replacingRequest, args); + nativeSend.call(forgedRequest, args); } catch { return Reflect.apply(target, thisArg, args); } diff --git a/tests/scriptlets/index.test.js b/tests/scriptlets/index.test.js index 5b75d3ebe..865db978d 100644 --- a/tests/scriptlets/index.test.js +++ b/tests/scriptlets/index.test.js @@ -47,3 +47,4 @@ import './no-topics.test'; import './trusted-replace-xhr-response.test'; import './xml-prune.test'; import './trusted-click-element.test'; +import './trusted-replace-fetch-response.test'; diff --git a/tests/scriptlets/trusted-replace-fetch-response.test.js b/tests/scriptlets/trusted-replace-fetch-response.test.js new file mode 100644 index 000000000..6133c8f6d --- /dev/null +++ b/tests/scriptlets/trusted-replace-fetch-response.test.js @@ -0,0 +1,219 @@ +/* eslint-disable no-underscore-dangle, no-console */ +import { runScriptlet, clearGlobalProps } from '../helpers'; +import { startsWith } from '../../src/helpers/string-utils'; + +const { test, module } = QUnit; +const name = 'trusted-replace-fetch-response'; + +const FETCH_OBJECTS_PATH = './test-files'; +const nativeFetch = fetch; +const nativeConsole = console.log; +const nativeResponseJson = Response.prototype.json; + +const beforeEach = () => { + window.__debug = () => { + window.hit = 'FIRED'; + }; +}; + +const afterEach = () => { + clearGlobalProps('hit', '__debug'); + fetch = nativeFetch; // eslint-disable-line no-global-assign + console.log = nativeConsole; + Response.prototype.json = nativeResponseJson; +}; + +module(name, { beforeEach, afterEach }); + +const isSupported = typeof fetch !== 'undefined' && typeof Proxy !== 'undefined' && typeof Response !== 'undefined'; + +if (!isSupported) { + test('unsupported', (assert) => { + assert.ok(true, 'Browser does not support it'); + }); +} else { + test('No arguments, no replacement, logging', async (assert) => { + const INPUT_JSON_PATH = `${FETCH_OBJECTS_PATH}/test01.json`; + const TEST_METHOD = 'GET'; + const init = { + method: TEST_METHOD, + }; + const expectedJson = { + a1: 1, + b2: 'test', + c3: 3, + }; + const done = assert.async(); + + // mock console.log function for log checking + console.log = function log(input) { + if (input.indexOf('trace') > -1) { + return; + } + // eslint-disable-next-line max-len + const EXPECTED_LOG_STR_START = `fetch( url:"${INPUT_JSON_PATH}" method:"${TEST_METHOD}"`; + assert.ok(startsWith(input, EXPECTED_LOG_STR_START), 'console.hit input'); + }; + + // no args -> just logging, no replacements + runScriptlet(name); + + const response = await fetch(INPUT_JSON_PATH, init); + const actualJson = await response.json(); + + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + assert.deepEqual(actualJson, expectedJson); + done(); + }); + + test('Match all requests, replace by substring', async (assert) => { + const INPUT_JSON_PATH = `${FETCH_OBJECTS_PATH}/test01.json`; + const TEST_METHOD = 'GET'; + const init = { + method: TEST_METHOD, + }; + + const done = assert.async(); + + const PATTERN = 'test'; + const REPLACEMENT = 'new content'; + runScriptlet(name, [PATTERN, REPLACEMENT]); + + const response = await fetch(INPUT_JSON_PATH, init); + const actualJson = await response.json(); + + const textContent = JSON.stringify(actualJson); + + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + assert.notOk(textContent.includes(PATTERN), 'Pattern is removed'); + assert.ok(textContent.includes(REPLACEMENT), 'New content is set'); + done(); + }); + + test('Match all requests, replace by regex', async (assert) => { + const INPUT_JSON_PATH = `${FETCH_OBJECTS_PATH}/test01.json`; + const TEST_METHOD = 'GET'; + const init = { + method: TEST_METHOD, + }; + + const done = assert.async(); + + const PATTERN = /test/; + const REPLACEMENT = 'new content'; + runScriptlet(name, [PATTERN, REPLACEMENT]); + + const response = await fetch(INPUT_JSON_PATH, init); + const actualJson = await response.json(); + + const textContent = JSON.stringify(actualJson); + + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + assert.notOk(PATTERN.test(textContent), 'Pattern is removed'); + assert.ok(textContent.includes(REPLACEMENT), 'New content is set'); + done(); + }); + + test('Match request by url and method, remove all text content', async (assert) => { + const INPUT_JSON_PATH = `${FETCH_OBJECTS_PATH}/test01.json`; + const TEST_METHOD = 'GET'; + const init = { + method: TEST_METHOD, + }; + + const done = assert.async(); + + const PATTERN = ''; + const REPLACEMENT = ''; + const PROPS_TO_MATCH = 'test01 method:GET'; + runScriptlet(name, [PATTERN, REPLACEMENT, PROPS_TO_MATCH]); + + const response = await fetch(INPUT_JSON_PATH, init); + const actualTextContent = await response.text(); + + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + assert.strictEqual(actualTextContent, '', 'Content is removed'); + done(); + }); + + test('Unmatched request\'s content is not modified', async (assert) => { + const INPUT_JSON_PATH = `${FETCH_OBJECTS_PATH}/test01.json`; + const TEST_METHOD = 'GET'; + const init = { + method: TEST_METHOD, + }; + + const expectedJson = { + a1: 1, + b2: 'test', + c3: 3, + }; + + const PATTERN = ''; + const REPLACEMENT = ''; + const PROPS_TO_MATCH = 'test99 method:POST'; + runScriptlet(name, [PATTERN, REPLACEMENT, PROPS_TO_MATCH]); + + const done = assert.async(); + const response = await fetch(INPUT_JSON_PATH, init); + const actualJson = await response.json(); + + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + assert.deepEqual(actualJson, expectedJson, 'Content is intact'); + done(); + }); + + test('Forged response props are copied properly', async (assert) => { + const INPUT_JSON_PATH = `${FETCH_OBJECTS_PATH}/test01.json`; + const TEST_METHOD = 'GET'; + const init = { + method: TEST_METHOD, + }; + + const done = assert.async(); + + const PATTERN = ''; + const REPLACEMENT = ''; + const PROPS_TO_MATCH = 'test01 method:GET'; + + const expectedResponse = await fetch(INPUT_JSON_PATH, init); + + runScriptlet(name, [PATTERN, REPLACEMENT, PROPS_TO_MATCH]); + + const actualResponse = await fetch(INPUT_JSON_PATH, init); + + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + + const { + bodyUsedExpected, + headersExpected, + okExpected, + redirectedExpected, + statusExpected, + statusTextExpected, + typeExpected, + urlExpected, + } = expectedResponse; + + const { + bodyUsed, + headers, + ok, + redirected, + status, + statusText, + type, + url, + } = actualResponse; + + assert.strictEqual(bodyUsed, bodyUsedExpected, 'response prop is copied'); + assert.strictEqual(headers, headersExpected, 'response prop is copied'); + assert.strictEqual(ok, okExpected, 'response prop is copied'); + assert.strictEqual(redirected, redirectedExpected, 'response prop is copied'); + assert.strictEqual(status, statusExpected, 'response prop is copied'); + assert.strictEqual(statusText, statusTextExpected, 'response prop is copied'); + assert.strictEqual(type, typeExpected, 'response prop is copied'); + assert.strictEqual(url, urlExpected, 'response prop is copied'); + done(); + }); +}