Skip to content

Commit

Permalink
AG-31632 Improve 'prevent-xhr' — add missed events. #414
Browse files Browse the repository at this point in the history
Squashed commit of the following:

commit 1656521
Merge: dcbf829 7e1c53d
Author: jellizaveta <[email protected]>
Date:   Thu Oct 10 12:44:16 2024 +0300

    merge master

commit dcbf829
Author: jellizaveta <[email protected]>
Date:   Wed Oct 9 14:49:23 2024 +0300

    update compatibility-table

commit 2f927e7
Author: jellizaveta <[email protected]>
Date:   Wed Oct 9 14:42:11 2024 +0300

    Load and loadend event simulation

commit 959076c
Author: jellizaveta <[email protected]>
Date:   Wed Oct 9 12:06:35 2024 +0300

    update comments

commit 0609abc
Author: jellizaveta <[email protected]>
Date:   Tue Oct 8 19:26:20 2024 +0300

    add events

commit ffda310
Author: jellizaveta <[email protected]>
Date:   Tue Oct 8 17:45:34 2024 +0300

    Fix Event

commit 8d09da3
Author: jellizaveta <[email protected]>
Date:   Tue Oct 8 17:00:06 2024 +0300

    speed up the definition of xhr.status

commit 236aa85
Author: jellizaveta <[email protected]>
Date:   Mon Oct 7 20:50:56 2024 +0300

    remove redundant comments

commit f63a197
Author: jellizaveta <[email protected]>
Date:   Mon Oct 7 20:33:48 2024 +0300

    Update changelog

commit 5820dce
Merge: 62d9c60 90eaf3d
Author: jellizaveta <[email protected]>
Date:   Mon Oct 7 20:32:52 2024 +0300

    Merge branch 'master' into fix/AG-31632

commit 62d9c60
Author: jellizaveta <[email protected]>
Date:   Mon Oct 7 20:27:42 2024 +0300

    AG-31632 Improve 'prevent-xhr' — add missed events. #414
  • Loading branch information
jellizaveta committed Oct 10, 2024
1 parent 7e1c53d commit f5d1bb6
Show file tree
Hide file tree
Showing 3 changed files with 158 additions and 46 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic

- set response `ok` to `false` by `prevent-fetch` if response type is `opaque` [#441]
- improve `prevent-xhr` — modify response [#415]
- improve `prevent-xhr` — add missed events [#414]

[Unreleased]: https://github.com/AdguardTeam/Scriptlets/compare/v1.12.1...HEAD
[#451]: https://github.com/AdguardTeam/Scriptlets/issues/451
[#415]: https://github.com/AdguardTeam/Scriptlets/issues/415
[#414]: https://github.com/AdguardTeam/Scriptlets/issues/414
[#441]: https://github.com/AdguardTeam/Scriptlets/issues/441

## [v1.12.1] - 2024-09-20
Expand Down
96 changes: 53 additions & 43 deletions src/scriptlets/prevent-xhr.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,6 @@ export function preventXHR(source, propsToMatch, customResponseText) {
}

const nativeOpen = window.XMLHttpRequest.prototype.open;
const nativeSend = window.XMLHttpRequest.prototype.send;
const nativeGetResponseHeader = window.XMLHttpRequest.prototype.getResponseHeader;
const nativeGetAllResponseHeaders = window.XMLHttpRequest.prototype.getAllResponseHeaders;

Expand Down Expand Up @@ -182,47 +181,64 @@ export function preventXHR(source, propsToMatch, customResponseText) {
* listeners on original XHR object
*/
const forgedRequest = new XMLHttpRequest();
forgedRequest.addEventListener('readystatechange', () => {
if (forgedRequest.readyState !== 4) {
return;
}

const {
readyState,
responseURL,
responseXML,
statusText,
} = forgedRequest;

// Mock response object
Object.defineProperties(thisArg, {
// original values
readyState: { value: readyState, writable: false },
statusText: { value: statusText, writable: false },
// If the request is blocked, responseURL is an empty string
responseURL: { value: responseURL || thisArg.xhrData.url, writable: false },
responseXML: { value: responseXML, writable: false },
// modified values
status: { value: 200, writable: false },
response: { value: modifiedResponse, writable: false },
responseText: { value: modifiedResponseText, writable: false },
});

// Mock events
setTimeout(() => {
const stateEvent = new Event('readystatechange');
thisArg.dispatchEvent(stateEvent);

const loadEvent = new Event('load');
thisArg.dispatchEvent(loadEvent);
/**
* Used to manually simulate the progression of the readyState property.
* By using Object.defineProperty, the function ensures
* that the readyState can be modified and configured appropriately,
* while allowing the property to be writable.
* @param {number} state - request status number.
*/
const transitionReadyState = (state) => {
if (state === 4) {
const {
responseURL,
responseXML,
} = forgedRequest;

const loadEndEvent = new Event('loadend');
thisArg.dispatchEvent(loadEndEvent);
}, 1);
// Mock response object
Object.defineProperties(thisArg, {
readyState: { value: 4, writable: false },
statusText: { value: 'OK', writable: false },
responseURL: { value: responseURL || thisArg.xhrData.url, writable: false },
responseXML: { value: responseXML, writable: false },
status: { value: 200, writable: false },
response: { value: modifiedResponse, writable: false },
responseText: { value: modifiedResponseText, writable: false },
});
hit(source);
} else {
Object.defineProperty(thisArg, 'readyState', {
value: state,
writable: true,
configurable: true,
});
}
const stateEvent = new Event('readystatechange');
thisArg.dispatchEvent(stateEvent);
};

hit(source);
// All events added to avoid problems with anti-adblockers
// https://github.com/AdguardTeam/Scriptlets/issues/414
forgedRequest.addEventListener('readystatechange', () => {
// simulate the lifecycle
transitionReadyState(1);
const loadStartEvent = new ProgressEvent('loadstart');
thisArg.dispatchEvent(loadStartEvent);
transitionReadyState(2);
transitionReadyState(3);
const progressEvent = new ProgressEvent('progress');
thisArg.dispatchEvent(progressEvent);
transitionReadyState(4);
});

setTimeout(() => {
const loadEvent = new ProgressEvent('load');
thisArg.dispatchEvent(loadEvent);
const loadEndEvent = new ProgressEvent('loadend');
thisArg.dispatchEvent(loadEndEvent);
}, 1);

nativeOpen.apply(forgedRequest, [thisArg.xhrData.method, thisArg.xhrData.url]);

// Mimic request headers before sending
Expand All @@ -233,12 +249,6 @@ export function preventXHR(source, propsToMatch, customResponseText) {
forgedRequest.setRequestHeader(name, value);
});

try {
nativeSend.call(forgedRequest, args);
} catch {
return Reflect.apply(target, thisArg, args);
}

return undefined;
};

Expand Down
106 changes: 103 additions & 3 deletions tests/scriptlets/prevent-xhr.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,66 @@ if (isSupported) {
assert.strictEqual(codeByAdgParams, codeByUboParams, 'ubo name - ok');
});

test('Args, method matched, check xhr status, randomize response (length:25000-30000)', async (assert) => {
const METHOD = 'GET';
const URL = `${FETCH_OBJECTS_PATH}/test01.json`;
const MATCH_DATA = ['method:GET', 'length:25000-30000'];

runScriptlet(name, MATCH_DATA);

const done = assert.async();

const xhr = new XMLHttpRequest();

runScriptlet(name, MATCH_DATA);

xhr.open(METHOD, URL);
xhr.send();

assert.strictEqual(xhr.status, 200, 'status set to 200');
assert.ok(xhr.response.length >= 25000, 'Response randomized');
assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
done();
});

test('Check if all 4 readyState events were fired on request', async (assert) => {
const METHOD = 'GET';
const URL = `${FETCH_OBJECTS_PATH}/test01.json`;
const MATCH_DATA = [''];
const done = assert.async();

runScriptlet(name, MATCH_DATA);

// track each readyState event
const xhrEvents = [false, false, false, false];

// track the last fired readyState to ensure no skipping
let lastReadyState = 0;

const xhr = new XMLHttpRequest();

xhr.onreadystatechange = () => {
// ensure no states are skipped
assert.ok(
xhr.readyState >= lastReadyState,
`readyState moved forward from ${lastReadyState} to ${xhr.readyState}`,
);
lastReadyState = xhr.readyState;

// mark each readyState event as fired
xhrEvents[xhr.readyState - 1] = true;

if (xhr.readyState === 4) {
assert.strictEqual(xhr.responseURL, URL, 'URL mocked');
assert.ok(xhrEvents.every((event) => event), 'All readyState change events were fired');
done();
}
};

xhr.open(METHOD, URL);
xhr.send();
});

test('No args, logging', async (assert) => {
const METHOD = 'GET';
const URL = `${FETCH_OBJECTS_PATH}/test01.json`;
Expand Down Expand Up @@ -147,7 +207,7 @@ if (isSupported) {
assert.strictEqual(xhr.getAllResponseHeaders(), expectedAllHeaders, 'getAllResponseHeaders() is mocked');
});

test('Args, method matched, randomize response (length:25000-30000)', async (assert) => {
test('Args, method matched, check different events, randomize response (length:25000-30000)', async (assert) => {
const METHOD = 'GET';
const URL = `${FETCH_OBJECTS_PATH}/test01.json`;
const MATCH_DATA = ['method:GET', 'length:25000-30000'];
Expand All @@ -156,17 +216,57 @@ if (isSupported) {

const done = assert.async();

let loadStartEventFired = false;
let progressEventFired = false;
let loadEventFired = false;
let loadEndEventFired = false;

const checkLoadEndEvent = () => {
assert.strictEqual(loadEndEventFired, true, 'loadend event fired');
assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
done();
};

const handleEvent = (event) => {
switch (event.type) {
case 'loadstart':
loadStartEventFired = true;
assert.strictEqual(event.target.readyState, 1, 'readyState is set to 1');
break;
case 'progress':
progressEventFired = true;
assert.strictEqual(event.target.readyState, 3, 'readyState is set to 3');
break;
case 'load':
loadEventFired = true;
assert.strictEqual(event.target.readyState, 4, 'readyState is set to 4');
break;
case 'loadend':
loadEndEventFired = true;
assert.strictEqual(event.target.readyState, 4, 'readyState is set to 4');
checkLoadEndEvent();
break;
default:
break;
}
};

const xhr = new XMLHttpRequest();
xhr.open(METHOD, URL);
const eventNames = ['loadstart', 'progress', 'load', 'loadend'];
eventNames.forEach((eventName) => {
xhr.addEventListener(eventName, handleEvent);
});
xhr.onload = () => {
assert.strictEqual(xhr.readyState, 4, 'Response done');
assert.strictEqual(typeof xhr.response, 'string', 'Response mocked');
assert.ok(
xhr.response.length > 20000,
`Response randomized, response length: ${xhr.response.length}`,
);
assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
done();
assert.strictEqual(loadStartEventFired, true, 'loadstart event fired');
assert.strictEqual(progressEventFired, true, 'progress event fired');
assert.strictEqual(loadEventFired, true, 'load event fired');
};
xhr.send();
});
Expand Down

0 comments on commit f5d1bb6

Please sign in to comment.