diff --git a/CHANGELOG.md b/CHANGELOG.md index 824088f72..5d5565c0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- issue with pruning when `addEventListener` was used before calling `send()` method + in `m3u-prune` and `xml-prune` scriptlets [#315](https://github.com/AdguardTeam/Scriptlets/issues/315) - issue with `updateTargetingFromMap()` method in `googletagservices-gpt` redirect [#293](https://github.com/AdguardTeam/Scriptlets/issues/293) - website reloading if `$now$`/`$currentDate$` value is used diff --git a/src/scriptlets/m3u-prune.js b/src/scriptlets/m3u-prune.js index a31189914..c29e41e83 100644 --- a/src/scriptlets/m3u-prune.js +++ b/src/scriptlets/m3u-prune.js @@ -2,6 +2,18 @@ import { hit, toRegExp, logMessage, + getXhrData, + objectToString, + matchRequestProps, + // following helpers should be imported and injected + // because they are used by helpers above + getMatchPropsData, + getRequestProps, + validateParsedData, + parseMatchProps, + isValidStrPattern, + escapeRegExp, + isEmptyObject, } from '../helpers/index'; /* eslint-disable max-len */ @@ -46,7 +58,7 @@ import { */ /* eslint-enable max-len */ -export function m3uPrune(source, propsToRemove, urlToMatch) { +export function m3uPrune(source, propsToRemove, urlToMatch = '') { // 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 @@ -308,46 +320,142 @@ export function m3uPrune(source, propsToRemove, urlToMatch) { .join('\n'); }; - const xhrWrapper = (target, thisArg, args) => { - const xhrURL = args[1]; - if (typeof xhrURL !== 'string' || xhrURL.length === 0) { + const nativeOpen = window.XMLHttpRequest.prototype.open; + const nativeSend = window.XMLHttpRequest.prototype.send; + + let xhrData; + + const openWrapper = (target, thisArg, args) => { + // eslint-disable-next-line prefer-spread + xhrData = getXhrData.apply(null, args); + + if (matchRequestProps(source, urlToMatch, xhrData)) { + thisArg.shouldBePrevented = true; + } + + // Trap setRequestHeader of target xhr object to mimic request headers later + if (thisArg.shouldBePrevented) { + thisArg.collectedHeaders = []; + const setRequestHeaderWrapper = (target, thisArg, args) => { + // Collect headers + thisArg.collectedHeaders.push(args); + return Reflect.apply(target, thisArg, args); + }; + + const setRequestHeaderHandler = { + apply: setRequestHeaderWrapper, + }; + + // setRequestHeader can only be called on open xhr object, + // so we can safely proxy it here + thisArg.setRequestHeader = new Proxy(thisArg.setRequestHeader, setRequestHeaderHandler); + } + + return Reflect.apply(target, thisArg, args); + }; + + const sendWrapper = (target, thisArg, args) => { + const allowedResponseTypeValues = ['', 'text']; + // Do nothing if request do not match + // or response type is not a string + if (!thisArg.shouldBePrevented || !allowedResponseTypeValues.includes(thisArg.responseType)) { return Reflect.apply(target, thisArg, args); } - if (urlMatchRegexp.test(xhrURL)) { - thisArg.addEventListener('readystatechange', function pruneResponse() { - if (thisArg.readyState === 4) { - const { response } = thisArg; - thisArg.removeEventListener('readystatechange', pruneResponse); - // If "propsToRemove" is not defined, then response should be logged only - if (!propsToRemove) { - if (isM3U(response)) { - const message = `XMLHttpRequest.open() URL: ${xhrURL}\nresponse: ${response}`; - logMessage(source, message); - } - } else { - shouldPruneResponse = isPruningNeeded(response, removeM3ULineRegexp); - } - if (shouldPruneResponse) { - const prunedResponseContent = pruneM3U(response); - Object.defineProperty(thisArg, 'response', { - value: prunedResponseContent, - }); - Object.defineProperty(thisArg, 'responseText', { - value: prunedResponseContent, - }); - hit(source); - } + + /** + * Create separate XHR request with original request's input + * to be able to collect response data without triggering + * listeners on original XHR object + */ + const forgedRequest = new XMLHttpRequest(); + forgedRequest.addEventListener('readystatechange', () => { + if (forgedRequest.readyState !== 4) { + return; + } + + const { + readyState, + response, + responseText, + responseURL, + responseXML, + status, + statusText, + } = forgedRequest; + + // Extract content from response + const content = responseText || response; + if (typeof content !== 'string') { + return; + } + + if (!propsToRemove) { + if (isM3U(response)) { + const message = `XMLHttpRequest.open() URL: ${responseURL}\nresponse: ${response}`; + logMessage(source, message); } + } else { + shouldPruneResponse = isPruningNeeded(response, removeM3ULineRegexp); + } + const responseContent = shouldPruneResponse ? pruneM3U(response) : response; + // Manually put required values into target XHR object + // as thisArg can't be redefined and XHR objects can't be (re)assigned or copied + Object.defineProperties(thisArg, { + // original values + readyState: { value: readyState, writable: false }, + responseURL: { value: responseURL, writable: false }, + responseXML: { value: responseXML, writable: false }, + status: { value: status, writable: false }, + statusText: { value: statusText, writable: false }, + // modified values + response: { value: responseContent, writable: false }, + responseText: { value: responseContent, writable: false }, }); + + // Mock events + setTimeout(() => { + const stateEvent = new Event('readystatechange'); + thisArg.dispatchEvent(stateEvent); + + const loadEvent = new Event('load'); + thisArg.dispatchEvent(loadEvent); + + const loadEndEvent = new Event('loadend'); + thisArg.dispatchEvent(loadEndEvent); + }, 1); + hit(source); + }); + + nativeOpen.apply(forgedRequest, [xhrData.method, xhrData.url]); + + // Mimic request headers before sending + // setRequestHeader can only be called on open request objects + thisArg.collectedHeaders.forEach((header) => { + const name = header[0]; + const value = header[1]; + + forgedRequest.setRequestHeader(name, value); + }); + thisArg.collectedHeaders = []; + + try { + nativeSend.call(forgedRequest, args); + } catch { + return Reflect.apply(target, thisArg, args); } - return Reflect.apply(target, thisArg, args); + return undefined; }; - const xhrHandler = { - apply: xhrWrapper, + const openHandler = { + apply: openWrapper, }; - // eslint-disable-next-line max-len - window.XMLHttpRequest.prototype.open = new Proxy(window.XMLHttpRequest.prototype.open, xhrHandler); + + const sendHandler = { + apply: sendWrapper, + }; + + XMLHttpRequest.prototype.open = new Proxy(XMLHttpRequest.prototype.open, openHandler); + XMLHttpRequest.prototype.send = new Proxy(XMLHttpRequest.prototype.send, sendHandler); const nativeFetch = window.fetch; @@ -358,12 +466,16 @@ export function m3uPrune(source, propsToRemove, urlToMatch) { } if (urlMatchRegexp.test(fetchURL)) { const response = await nativeFetch(...args); + // It's required to fix issue with - Request with body": Failed to execute 'fetch' on 'Window': + // Cannot construct a Request with a Request object that has already been used. + // For example, it occurs on youtube when scriptlet is used without arguments + const clonedResponse = response.clone(); const responseText = await response.text(); // If "propsToRemove" is not defined, then response should be logged only if (!propsToRemove && isM3U(responseText)) { const message = `fetch URL: ${fetchURL}\nresponse text: ${responseText}`; logMessage(source, message); - return Reflect.apply(target, thisArg, args); + return clonedResponse; } if (isPruningNeeded(responseText, removeM3ULineRegexp)) { const prunedText = pruneM3U(responseText); @@ -374,7 +486,7 @@ export function m3uPrune(source, propsToRemove, urlToMatch) { headers: response.headers, }); } - return Reflect.apply(target, thisArg, args); + return clonedResponse; } return Reflect.apply(target, thisArg, args); }; @@ -397,4 +509,14 @@ m3uPrune.injections = [ hit, toRegExp, logMessage, + getXhrData, + objectToString, + matchRequestProps, + getMatchPropsData, + getRequestProps, + validateParsedData, + parseMatchProps, + isValidStrPattern, + escapeRegExp, + isEmptyObject, ]; diff --git a/src/scriptlets/xml-prune.js b/src/scriptlets/xml-prune.js index 5c071872e..ee6885655 100644 --- a/src/scriptlets/xml-prune.js +++ b/src/scriptlets/xml-prune.js @@ -2,6 +2,18 @@ import { hit, logMessage, toRegExp, + getXhrData, + objectToString, + matchRequestProps, + // following helpers should be imported and injected + // because they are used by helpers above + getMatchPropsData, + getRequestProps, + validateParsedData, + parseMatchProps, + isValidStrPattern, + escapeRegExp, + isEmptyObject, } from '../helpers/index'; /* eslint-disable max-len */ @@ -52,7 +64,7 @@ import { */ /* eslint-enable max-len */ -export function xmlPrune(source, propsToRemove, optionalProp = '', urlToMatch) { +export function xmlPrune(source, propsToRemove, optionalProp = '', urlToMatch = '') { // do nothing if browser does not support Reflect, 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 @@ -64,13 +76,7 @@ export function xmlPrune(source, propsToRemove, optionalProp = '', urlToMatch) { return; } - let shouldPruneResponse = true; - - if (!propsToRemove) { - // If "propsToRemove" is not defined, then response shouldn't be pruned - // but it should be logged in browser console - shouldPruneResponse = false; - } + let shouldPruneResponse = false; const urlMatchRegexp = toRegExp(urlToMatch); @@ -95,6 +101,14 @@ export function xmlPrune(source, propsToRemove, optionalProp = '', urlToMatch) { return xmlDocument; }; + const isPruningNeeded = (response, propsToRemove) => { + if (!isXML(response)) { + return false; + } + const docXML = createXMLDocument(response); + return !!docXML.querySelector(propsToRemove); + }; + const pruneXML = (text) => { if (!isXML(text)) { shouldPruneResponse = false; @@ -122,86 +136,175 @@ export function xmlPrune(source, propsToRemove, optionalProp = '', urlToMatch) { return text; }; - const xhrWrapper = (target, thisArg, args) => { - const xhrURL = args[1]; - if (typeof xhrURL !== 'string' || xhrURL.length === 0) { + const nativeOpen = window.XMLHttpRequest.prototype.open; + const nativeSend = window.XMLHttpRequest.prototype.send; + + let xhrData; + + const openWrapper = (target, thisArg, args) => { + // eslint-disable-next-line prefer-spread + xhrData = getXhrData.apply(null, args); + + if (matchRequestProps(source, urlToMatch, xhrData)) { + thisArg.shouldBePrevented = true; + } + + // Trap setRequestHeader of target xhr object to mimic request headers later + if (thisArg.shouldBePrevented) { + thisArg.collectedHeaders = []; + const setRequestHeaderWrapper = (target, thisArg, args) => { + // Collect headers + thisArg.collectedHeaders.push(args); + return Reflect.apply(target, thisArg, args); + }; + + const setRequestHeaderHandler = { + apply: setRequestHeaderWrapper, + }; + + // setRequestHeader can only be called on open xhr object, + // so we can safely proxy it here + thisArg.setRequestHeader = new Proxy(thisArg.setRequestHeader, setRequestHeaderHandler); + } + + return Reflect.apply(target, thisArg, args); + }; + + const sendWrapper = (target, thisArg, args) => { + const allowedResponseTypeValues = ['', 'text']; + // Do nothing if request do not match + // or response type is not a string + if (!thisArg.shouldBePrevented || !allowedResponseTypeValues.includes(thisArg.responseType)) { return Reflect.apply(target, thisArg, args); } - if (urlMatchRegexp.test(xhrURL)) { - thisArg.addEventListener('readystatechange', function pruneResponse() { - if (thisArg.readyState === 4) { - const { response } = thisArg; - thisArg.removeEventListener('readystatechange', pruneResponse); - if (!shouldPruneResponse) { - if (isXML(response)) { - const message = `XMLHttpRequest.open() URL: ${xhrURL}\nresponse: ${response}`; - logMessage(source, message); - logMessage(source, createXMLDocument(response), true, false); - } - } else { - const prunedResponseContent = pruneXML(response); - if (shouldPruneResponse) { - Object.defineProperty(thisArg, 'response', { - value: prunedResponseContent, - }); - Object.defineProperty(thisArg, 'responseText', { - value: prunedResponseContent, - }); - hit(source); - } - // In case if response shouldn't be pruned - // pruneXML sets shouldPruneResponse to false - // so it's necessary to set it to true again - // otherwise response will be only logged - shouldPruneResponse = true; - } + + /** + * Create separate XHR request with original request's input + * to be able to collect response data without triggering + * listeners on original XHR object + */ + const forgedRequest = new XMLHttpRequest(); + forgedRequest.addEventListener('readystatechange', () => { + if (forgedRequest.readyState !== 4) { + return; + } + + const { + readyState, + response, + responseText, + responseURL, + responseXML, + status, + statusText, + } = forgedRequest; + + // Extract content from response + const content = responseText || response; + if (typeof content !== 'string') { + return; + } + + if (!propsToRemove) { + if (isXML(response)) { + const message = `XMLHttpRequest.open() URL: ${responseURL}\nresponse: ${response}`; + logMessage(source, message); + logMessage(source, createXMLDocument(response), true, false); } + } else { + shouldPruneResponse = isPruningNeeded(response, propsToRemove); + } + const responseContent = shouldPruneResponse ? pruneXML(response) : response; + // Manually put required values into target XHR object + // as thisArg can't be redefined and XHR objects can't be (re)assigned or copied + Object.defineProperties(thisArg, { + // original values + readyState: { value: readyState, writable: false }, + responseURL: { value: responseURL, writable: false }, + responseXML: { value: responseXML, writable: false }, + status: { value: status, writable: false }, + statusText: { value: statusText, writable: false }, + // modified values + response: { value: responseContent, writable: false }, + responseText: { value: responseContent, writable: false }, }); + + // Mock events + setTimeout(() => { + const stateEvent = new Event('readystatechange'); + thisArg.dispatchEvent(stateEvent); + + const loadEvent = new Event('load'); + thisArg.dispatchEvent(loadEvent); + + const loadEndEvent = new Event('loadend'); + thisArg.dispatchEvent(loadEndEvent); + }, 1); + hit(source); + }); + + nativeOpen.apply(forgedRequest, [xhrData.method, xhrData.url]); + + // Mimic request headers before sending + // setRequestHeader can only be called on open request objects + thisArg.collectedHeaders.forEach((header) => { + const name = header[0]; + const value = header[1]; + + forgedRequest.setRequestHeader(name, value); + }); + thisArg.collectedHeaders = []; + + try { + nativeSend.call(forgedRequest, args); + } catch { + return Reflect.apply(target, thisArg, args); } - return Reflect.apply(target, thisArg, args); + return undefined; + }; + + const openHandler = { + apply: openWrapper, }; - const xhrHandler = { - apply: xhrWrapper, + const sendHandler = { + apply: sendWrapper, }; - // eslint-disable-next-line max-len - window.XMLHttpRequest.prototype.open = new Proxy(window.XMLHttpRequest.prototype.open, xhrHandler); + + XMLHttpRequest.prototype.open = new Proxy(XMLHttpRequest.prototype.open, openHandler); + XMLHttpRequest.prototype.send = new Proxy(XMLHttpRequest.prototype.send, sendHandler); const nativeFetch = window.fetch; - const fetchWrapper = (target, thisArg, args) => { + const fetchWrapper = async (target, thisArg, args) => { const fetchURL = args[0] instanceof Request ? args[0].url : args[0]; if (typeof fetchURL !== 'string' || fetchURL.length === 0) { return Reflect.apply(target, thisArg, args); } if (urlMatchRegexp.test(fetchURL)) { - return nativeFetch.apply(this, args).then((response) => { - return response.text().then((text) => { - if (!shouldPruneResponse) { - if (isXML(text)) { - const message = `fetch URL: ${fetchURL}\nresponse text: ${text}`; - logMessage(source, message); - logMessage(source, createXMLDocument(text), true, false); - } - return Reflect.apply(target, thisArg, args); - } - const prunedText = pruneXML(text); - if (shouldPruneResponse) { - hit(source); - return new Response(prunedText, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - } - // If response shouldn't be pruned - // pruneXML sets shouldPruneResponse to false - // so it's necessary to set it to true again - // otherwise response will be only logged - shouldPruneResponse = true; - return Reflect.apply(target, thisArg, args); + const response = await nativeFetch(...args); + // It's required to fix issue with - Request with body": Failed to execute 'fetch' on 'Window': + // Cannot construct a Request with a Request object that has already been used. + // For example, it occurs on youtube when scriptlet is used without arguments + const clonedResponse = response.clone(); + const responseText = await response.text(); + shouldPruneResponse = isPruningNeeded(responseText, propsToRemove); + if (!shouldPruneResponse) { + const message = `fetch URL: ${fetchURL}\nresponse text: ${responseText}`; + logMessage(source, message); + logMessage(source, createXMLDocument(responseText), true, false); + return clonedResponse; + } + const prunedText = pruneXML(responseText); + if (shouldPruneResponse) { + hit(source); + return new Response(prunedText, { + status: response.status, + statusText: response.statusText, + headers: response.headers, }); - }); + } + return clonedResponse; } return Reflect.apply(target, thisArg, args); }; @@ -225,4 +328,14 @@ xmlPrune.injections = [ hit, logMessage, toRegExp, + getXhrData, + objectToString, + matchRequestProps, + getMatchPropsData, + getRequestProps, + validateParsedData, + parseMatchProps, + isValidStrPattern, + escapeRegExp, + isEmptyObject, ]; diff --git a/tests/scriptlets/m3u-prune.test.js b/tests/scriptlets/m3u-prune.test.js index 2d1fc9e6f..0a4dd4d67 100644 --- a/tests/scriptlets/m3u-prune.test.js +++ b/tests/scriptlets/m3u-prune.test.js @@ -369,7 +369,6 @@ if (!isSupported) { xhr.responseText.includes('tvessaiprod.nbcuni.com/video/'), 'line with "tvessaiprod.nbcuni.com/video/" should not be removed', ); - assert.strictEqual(window.hit, undefined, 'should not hit'); done(); }; xhr.send(); @@ -386,7 +385,6 @@ if (!isSupported) { xhr.open(METHOD, M3U8_PATH); xhr.onload = () => { assert.ok(xhr.responseText.includes('#EXT-X-VMAP-AD-BREAK')); - assert.strictEqual(window.hit, undefined, 'should not hit'); done(); }; xhr.send(); @@ -410,7 +408,6 @@ if (!isSupported) { xhr.responseText.includes('tvessaiprod.nbcuni.com/video/'), 'line with "tvessaiprod.nbcuni.com/video/" should not be removed', ); - assert.strictEqual(window.hit, undefined, 'should not hit'); done(); }; xhr.send(); @@ -434,7 +431,6 @@ if (!isSupported) { xhr.responseText.includes('#EXT-X-VMAP-AD-BREAK'), 'line with "#EXT-X-VMAP-AD-BREAK" should not be removed', ); - assert.strictEqual(window.hit, undefined, 'should not hit'); done(); }; xhr.send(); @@ -661,3 +657,99 @@ if (!isSupported) { xhr.send(); }); } + +test('xhr - remove ads - addEventListener', async (assert) => { + const METHOD = 'GET'; + const M3U8_PATH = M3U8_OBJECTS_PATH_01; + const MATCH_DATA = 'tvessaiprod.nbcuni.com/video/'; + + runScriptlet(name, [MATCH_DATA]); + + const done = assert.async(); + + const xhr = new XMLHttpRequest(); + xhr.open(METHOD, M3U8_PATH); + xhr.addEventListener('readystatechange', () => { + if (xhr.responseText && xhr.readyState >= 3) { + assert.notOk( + xhr.responseText.includes('tvessaiprod.nbcuni.com/video/'), + 'check if "tvessaiprod.nbcuni.com/video/" has been removed', + ); + assert.notOk( + xhr.responseText.includes('#EXT-X-CUE:TYPE="SpliceOut"'), + 'check if "#EXT-X-CUE:TYPE="SpliceOut"" has been removed', + ); + assert.notOk( + xhr.responseText.includes('#EXT-X-CUE-IN'), + 'check if "#EXT-X-CUE-IN" has been removed', + ); + assert.notOk( + xhr.responseText.includes('#EXT-X-ASSET:CAID'), + 'check if "#EXT-X-ASSET:CAID" has been removed', + ); + assert.notOk( + xhr.responseText.includes('#EXT-X-SCTE35:'), + 'check if "#EXT-X-SCTE35:" has been removed', + ); + } + }); + xhr.onload = () => { + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + done(); + }; + xhr.send(); +}); + +// This test is for issue with - Request with body": Failed to execute 'fetch' on 'Window': +// Cannot construct a Request with a Request object that has already been used +// This problem occurs in the web browser, but there is no such problem when the test is executed in the node +test('fetch - remove ads Request with body', async (assert) => { + const requestOptions = { + method: 'POST', + body: { + 0: 1, + }, + }; + const request = new Request(M3U8_OBJECTS_PATH_01, requestOptions); + const MATCH_DATA = 'tvessaiprod.nbcuni.com/video/'; + + runScriptlet(name, [MATCH_DATA]); + + const done = assert.async(); + + const response = await fetch(request); + const responseM3U8 = await response.text(); + + // Required in case if request returns 405 (Method Not Allowed) + if (response.status !== 200) { + assert.strictEqual( + responseM3U8, '', + 'Empty response, request is blocked - error 405 (Method Not Allowed)', + ); + assert.strictEqual(window.hit, undefined, 'should not hit'); + done(); + } else { + assert.notOk( + responseM3U8.includes('tvessaiprod.nbcuni.com/video/'), + 'check if "tvessaiprod.nbcuni.com/video/" has been removed', + ); + assert.notOk( + responseM3U8.includes('#EXT-X-CUE:TYPE="SpliceOut"'), + 'check if "#EXT-X-CUE:TYPE="SpliceOut"" has been removed', + ); + assert.notOk( + responseM3U8.includes('#EXT-X-CUE-IN'), + 'check if "#EXT-X-CUE-IN" has been removed', + ); + assert.notOk( + responseM3U8.includes('#EXT-X-ASSET:CAID'), + 'check if "#EXT-X-ASSET:CAID" has been removed', + ); + assert.notOk( + responseM3U8.includes('#EXT-X-SCTE35:'), + 'check if "#EXT-X-SCTE35:" has been removed', + ); + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + done(); + } +}); diff --git a/tests/scriptlets/xml-prune.test.js b/tests/scriptlets/xml-prune.test.js index cac4f573f..fc397a5b3 100644 --- a/tests/scriptlets/xml-prune.test.js +++ b/tests/scriptlets/xml-prune.test.js @@ -211,8 +211,10 @@ if (!isSupported) { if (input.includes('trace')) { return; } - const EXPECTED_LOG_STR_START = `xml-prune: XMLHttpRequest.open() URL: ${MPD_OBJECTS_PATH}`; - assert.ok(input.startsWith(EXPECTED_LOG_STR_START), 'console.hit input'); + const EXPECTED_LOG_STR_START = 'xml-prune: XMLHttpRequest.open() URL:'; + const EXPECTED_LOG_PATH = `${(MPD_OBJECTS_PATH).slice(1)}`; + assert.ok(input.startsWith(EXPECTED_LOG_STR_START), 'console.hit input EXPECTED_LOG_STR_START'); + assert.ok(input.includes(EXPECTED_LOG_PATH), 'console.hit input EXPECTED_LOG_PATH'); assert.ok(input.includes('pre-roll-1-ad-1'), 'console.hit input'); done(); } @@ -225,7 +227,6 @@ if (!isSupported) { xhr.open(GET_METHOD, MPD_OBJECTS_PATH); xhr.onload = () => { assert.ok(xhr.responseText.includes('pre-roll-1-ad-1')); - assert.strictEqual(window.hit, undefined, 'should not hit'); done(); }; xhr.send(); @@ -245,7 +246,6 @@ if (!isSupported) { xhr.open(GET_METHOD, MPD_OBJECTS_PATH); xhr.onload = () => { assert.ok(xhr.responseText.includes('pre-roll-1-ad-1')); - assert.strictEqual(window.hit, undefined, 'should not hit'); done(); }; xhr.send(); @@ -265,7 +265,6 @@ if (!isSupported) { xhr.open(GET_METHOD, MPD_OBJECTS_PATH); xhr.onload = () => { assert.ok(xhr.responseText.includes('pre-roll-1-ad-1')); - assert.strictEqual(window.hit, undefined, 'should not hit'); done(); }; xhr.send(); @@ -285,7 +284,6 @@ if (!isSupported) { xhr.open(GET_METHOD, MPD_OBJECTS_PATH); xhr.onload = () => { assert.ok(xhr.responseText.includes('pre-roll-1-ad-1')); - assert.strictEqual(window.hit, undefined, 'should not hit'); done(); }; xhr.send(); @@ -365,3 +363,59 @@ if (!isSupported) { xhr.send(); }); } + +test('xhr - remove ads - addEventListener', async (assert) => { + const MATCH_DATA = ["Period[id*='-ad-']"]; + + runScriptlet(name, MATCH_DATA); + + const done = assert.async(); + + const xhr = new XMLHttpRequest(); + xhr.open(GET_METHOD, MPD_OBJECTS_PATH); + xhr.addEventListener('readystatechange', () => { + if (xhr.responseText && xhr.readyState >= 3) { + assert.notOk(xhr.responseText.includes('pre-roll-1-ad-1'), 'check if "re-roll-1-ad-1" has been removed'); + } + }); + xhr.onload = () => { + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + done(); + }; + xhr.send(); +}); + +// This test is for issue with - Request with body": Failed to execute 'fetch' on 'Window': +// Cannot construct a Request with a Request object that has already been used +// This problem occurs in the web browser, but there is no such problem when the test is executed in the node +test('fetch - remove ads Request with body', async (assert) => { + const requestOptions = { + method: 'POST', + body: { + 0: 1, + }, + }; + const request = new Request(MPD_OBJECTS_PATH, requestOptions); + const MATCH_DATA = ["Period[id*='-ad-']"]; + + runScriptlet(name, MATCH_DATA); + + const done = assert.async(); + + const response = await fetch(request); + const responseMPD = await response.text(); + + // Required in case if request returns 405 (Method Not Allowed) + if (response.status !== 200) { + assert.strictEqual( + responseMPD, '', + 'Empty response, request is blocked - error 405 (Method Not Allowed)', + ); + assert.strictEqual(window.hit, undefined, 'should not hit'); + done(); + } else { + assert.notOk(responseMPD.includes('pre-roll-1-ad-1')); + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + done(); + } +});