diff --git a/README.md b/README.md
index b2d148a9e..69f5d926c 100644
--- a/README.md
+++ b/README.md
@@ -499,10 +499,9 @@ and open needed HTML file from `tests/dist` in your browser with devtools
## How to update wiki
-```
-yarn wiki:update
-```
-`about-scriptlets.md` and `about-redirects.md` are being built from JSDoc notation of corresponding scriptlets and redirects source files with `@scriptlet/@redirect` and `@description` tags.
+There are two scripts to update wiki:
+1. `yarn wiki:build-table` — checks compatibility data updates and updates the compatibility table. Should be run manually while the release preparation.
+2. `yarn wiki:build-docs` — updates wiki pages `about-scriptlets.md` and `about-redirects.md`. They are being generated from JSDoc-type comments of corresponding scriptlets and redirects source files due to `@scriptlet`/`@redirect` and `@description` tags. Runs automatically while the release build.
## Browser Compatibility
| Chrome | Edge | Firefox | IE | Opera | Safari |
diff --git a/bamboo-specs/build.yaml b/bamboo-specs/build.yaml
index 9af2e2f4b..4bd6c8f32 100644
--- a/bamboo-specs/build.yaml
+++ b/bamboo-specs/build.yaml
@@ -35,6 +35,8 @@ Build:
yarn install ${bamboo.varsYarn}
yarn build
+ yarn wiki:build-docs
+
rm -rf node_modules
- inject-variables:
file: dist/build.txt
diff --git a/package.json b/package.json
index 81f2c59a0..39e12ca33 100644
--- a/package.json
+++ b/package.json
@@ -10,9 +10,8 @@
"gui-test": "babel-node scripts/build-tests.js && open http://localhost:8585 && node ./tests/server.js",
"lint": "eslint .",
"lint-staged": "lint-staged",
+ "wiki:build-table": "node ./scripts/check-sources-updates.js && node ./scripts/build-compatibility-table.js",
"wiki:build-docs": "node scripts/build-docs.js",
- "wiki:check-updates": "node ./scripts/check-sources-updates.js",
- "wiki:update": "yarn wiki:check-updates && node ./scripts/build-compatibility-table.js",
"prepublishOnly": "yarn build",
"increment": "yarn version --patch --no-git-tag-version"
},
diff --git a/scripts/build-compatibility-table.js b/scripts/build-compatibility-table.js
index 4165352a3..f1ab4179c 100644
--- a/scripts/build-compatibility-table.js
+++ b/scripts/build-compatibility-table.js
@@ -1,80 +1,115 @@
+const path = require('path');
const fs = require('fs');
-const os = require('os');
+const { EOL } = require('os');
+
const {
REMOVED_MARKER,
+ WIKI_DIR_PATH,
COMPATIBILITY_TABLE_DATA_PATH,
- WIKI_COMPATIBILITY_TABLE_PATH,
} = require('./constants');
+const COMPATIBILITY_TABLE_OUTPUT_FILENAME = 'compatibility-table.md';
+
+/**
+ * Path to **output** wiki compatibility table file
+ */
+const WIKI_COMPATIBILITY_TABLE_PATH = path.resolve(
+ __dirname,
+ WIKI_DIR_PATH,
+ COMPATIBILITY_TABLE_OUTPUT_FILENAME,
+);
+
+/**
+ * @typedef {Object} CompatibilityItem
+ * @property {string} adg
+ * @property {string} abp
+ * @property {string} ubo
+ */
+
/**
- * Returns data for compatibility tables
+ * @typedef {Object} CompatibilityData
+ * @property {CompatibilityItem[]} scriptlets
+ * @property {CompatibilityItem[]} redirects
*/
-function getTableData() {
+
+/**
+ * Returns data for compatibility table
+ *
+ * @returns {CompatibilityData} input compatibility data from json
+ */
+const getTableData = () => {
const rawData = fs.readFileSync(COMPATIBILITY_TABLE_DATA_PATH);
- const parsed = JSON.parse(rawData);
- return parsed;
-}
+ return JSON.parse(rawData);
+};
/**
* Returns markdown row of compatibility table
- * @param {{
- * adg: string,
- * ubo: string,
- * abp: string
- * }} item { an }
+ *
+ * @param {'scriptlets'|'redirects'} id
+ * @param {CompatibilityItem} item params object
+ * @param {string} item.adg AdGuard name
+ * @param {string} item.abp Adblock Plus name
+ * @param {string} item.ubo uBlock name
+ *
+ * @returns {string} markdown table row
*/
-const getRow = (id, item) => {
+const getRow = (id, { adg, abp, ubo }) => {
let adgCell = '';
- if (item.adg) {
- adgCell = item.adg.includes(REMOVED_MARKER)
- ? item.adg
- : `[${item.adg}](../wiki/about-${id}.md#${item.adg})`;
+ if (adg) {
+ adgCell = adg.includes(REMOVED_MARKER)
+ ? adg
+ : `[${adg}](${WIKI_DIR_PATH}/about-${id}.md#${adg})`;
}
- return `| ${adgCell} | ${item.ubo || ''} | ${item.abp || ''} |${os.EOL}`;
+ return `| ${adgCell} | ${ubo || ''} | ${abp || ''} |${EOL}`;
};
/**
* Generates table header
+ *
+ * @returns {string}
*/
const getTableHeader = () => {
- let res = `| AdGuard | uBO | Adblock Plus |${os.EOL}`;
- res += `|---|---|---|${os.EOL}`;
+ let res = `| AdGuard | uBO | Adblock Plus |${EOL}`;
+ res += `|---|---|---|${EOL}`;
return res;
};
/**
- * Builds markdown string with scriptlets compatibility table
- * @param {Array} data array with scriptlets names
+ * Builds markdown string of scriptlets/redirect compatibility table
+ * @param {string} title title for scriptlets or redirects
+ * @param {CompatibilityItem[]} data array of scriptlets or redirects compatibility data items
+ * @param {'scriptlets'|'redirects'} id
+ *
+ * @returns {string} scriptlets or redirects compatibility table
*/
-function buildTable(title, data = [], id = '') {
+const buildTable = (title, data = [], id = '') => {
// title
- let res = `# ${title}${os.EOL}${os.EOL}`;
+ let res = `# ${title}${EOL}${EOL}`;
// header
res += getTableHeader();
// rows
res += data
- .map((item) => {
- const row = getRow(id, item);
- return row;
- })
+ .map((item) => getRow(id, item))
.join('');
return res;
-}
+};
/**
- * Save tables to compatibility table
+ * Saves tables to compatibility table
+ *
+ * @param {string[]} args
*/
-function saveTables(...args) {
- const res = args.join(`${os.EOL}${os.EOL}`);
+const saveTables = (...args) => {
+ const res = args.join(`${EOL}${EOL}`);
fs.writeFileSync(WIKI_COMPATIBILITY_TABLE_PATH, res);
-}
+};
/**
- * Entry function
+ * Builds full compatibility table
*/
-function init() {
+const buildCompatibilityTable = () => {
const { scriptlets, redirects } = getTableData();
const scriptletsTable = buildTable(
@@ -89,6 +124,6 @@ function init() {
);
saveTables(scriptletsTable, redirectsTable);
-}
+};
-init();
+buildCompatibilityTable();
diff --git a/scripts/build-docs.js b/scripts/build-docs.js
index 922037326..ab67e62a5 100644
--- a/scripts/build-docs.js
+++ b/scripts/build-docs.js
@@ -1,138 +1,92 @@
-const dox = require('dox');
const fs = require('fs');
const path = require('path');
-
const yaml = require('js-yaml');
+const { EOL } = require('os');
-const SCRIPTLETS_FILES_DIRECTORY = '../src/scriptlets';
-const REDIRECTS_FILES_DIRECTORY = '../src/redirects';
-const STATIC_REDIRECTS = '../src/redirects/static-redirects.yml';
-const BLOCKING_REDIRECTS = '../src/redirects/blocking-redirects.yml';
-
-const ABOUT_SCRIPTLETS_PATH = path.resolve(__dirname, '../wiki/about-scriptlets.md');
-const ABOUT_REDIRECTS_PATH = path.resolve(__dirname, '../wiki/about-redirects.md');
-
-// files which are not scriptlets or redirects in their directories
-const NON_SCRIPTLETS_FILES = [
- 'index.js',
- 'scriptlets.js',
- 'scriptlets-list.js',
- 'scriptlets-wrapper.js',
- 'scriptlets-umd-wrapper.js',
-];
-const NON_REDIRECTS_FILES = [
- 'index.js',
- 'redirects.js',
- 'redirects-list.js',
-];
+const { getDataFromFiles } = require('./helpers');
-/**
- * Gets list of files
- * @param {string} dirPath path to directory
- */
-const getFilesList = (dirPath) => {
- const filesList = fs.readdirSync(path.resolve(__dirname, dirPath), { encoding: 'utf8' })
- .filter((el) => el.includes('.js'));
- return filesList;
-};
+const {
+ WIKI_DIR_PATH,
+ scriptletsFilenames,
+ redirectsFilenames,
+ SCRIPTLETS_SRC_RELATIVE_DIR_PATH,
+ REDIRECTS_SRC_RELATIVE_DIR_PATH,
+} = require('./constants');
-const scriptletsFilesList = getFilesList(SCRIPTLETS_FILES_DIRECTORY)
- .filter((el) => !NON_SCRIPTLETS_FILES.includes(el));
+const STATIC_REDIRECTS_FILENAME = 'static-redirects.yml';
+const BLOCKING_REDIRECTS_FILENAME = 'blocking-redirects.yml';
-const redirectsFilesList = getFilesList(REDIRECTS_FILES_DIRECTORY)
- .filter((el) => !NON_REDIRECTS_FILES.includes(el));
+// eslint-disable-next-line max-len
+const STATIC_REDIRECTS_RELATIVE_SOURCE = `${REDIRECTS_SRC_RELATIVE_DIR_PATH}/${STATIC_REDIRECTS_FILENAME}`;
-/**
- * Gets required comments from file.
- * In one file might be comments describing scriptlet and redirect as well.
- * @param {string} srcPath path to file
- */
-const getComments = (srcPath) => {
- const srcCode = fs.readFileSync(srcPath, { encoding: 'utf8' });
- const parsedCommentsFromFile = dox.parseComments(srcCode);
- const describingComment = Object.values(parsedCommentsFromFile)
- .filter((comment) => {
- const [base] = comment.tags;
- const isNeededComment = (base
- && (base.type === 'scriptlet' || base.type === 'redirect'));
- return isNeededComment;
- });
-
- if (describingComment.length === 0) {
- throw new Error(`No description in ${srcPath}.
-Please add one OR edit the list of NON_SCRIPTLETS_FILES / NON_REDIRECTS_FILES.`);
- }
+const staticRedirectsPath = path.resolve(__dirname, STATIC_REDIRECTS_RELATIVE_SOURCE);
- return describingComment;
-};
+const blockingRedirectsPath = path.resolve(
+ __dirname,
+ REDIRECTS_SRC_RELATIVE_DIR_PATH,
+ BLOCKING_REDIRECTS_FILENAME,
+);
-/**
- * Convert parsed comments to objects
- * @param {object} requiredComments parsed comments for one file
- * @param {string} sourcePath path to file
- */
-const prepareData = (requiredComments, sourcePath) => {
- return requiredComments.map((el) => {
- const [base, sup] = el.tags;
- return {
- type: base.type,
- name: base.string,
- description: sup.string,
- source: sourcePath,
- };
- });
-};
+const ABOUT_SCRIPTLETS_FILENAME = 'about-scriptlets.md';
+const ABOUT_REDIRECTS_FILENAME = 'about-redirects.md';
-/**
- * Gets data objects which describe every required comment in one directory
- * @param {array} filesList list of files in directory
- * @param {string} directoryPath path to directory
- */
-const getDataFromFiles = (filesList, directoryPath) => {
- const pathToDir = path.resolve(__dirname, directoryPath);
- return filesList.map((file) => {
- const pathToFile = path.resolve(pathToDir, file);
- const requiredComments = getComments(pathToFile);
-
- return prepareData(requiredComments, `${directoryPath}/${file}`);
- });
-};
+const aboutScriptletsPath = path.resolve(__dirname, WIKI_DIR_PATH, ABOUT_SCRIPTLETS_FILENAME);
+const aboutRedirectsPath = path.resolve(__dirname, WIKI_DIR_PATH, ABOUT_REDIRECTS_FILENAME);
/**
* Collects required comments from files and
* returns describing object for scriptlets and redirects
*/
const manageDataFromFiles = () => {
- // eslint-disable-next-line max-len
- const dataFromScriptletsFiles = getDataFromFiles(scriptletsFilesList, SCRIPTLETS_FILES_DIRECTORY);
- const dataFromRedirectsFiles = getDataFromFiles(redirectsFilesList, REDIRECTS_FILES_DIRECTORY);
+ const dataFromScriptletsFiles = getDataFromFiles(
+ scriptletsFilenames,
+ SCRIPTLETS_SRC_RELATIVE_DIR_PATH,
+ );
+ const dataFromRedirectsFiles = getDataFromFiles(
+ redirectsFilenames,
+ REDIRECTS_SRC_RELATIVE_DIR_PATH,
+ );
const fullData = dataFromScriptletsFiles.concat(dataFromRedirectsFiles).flat(Infinity);
- const scriptletsData = fullData.filter((el) => {
- return el.type === 'scriptlet';
- });
- const redirectsData = fullData.filter((el) => {
- return el.type === 'redirect';
- });
+ const scriptletsData = fullData.filter(({ type }) => type === 'scriptlet');
+ const redirectsData = fullData.filter(({ type }) => type === 'redirect');
return { scriptletsData, redirectsData };
};
/**
- * Generates markdown list and describing text
- * @param {object} data array of filtered objects - scriptlets or redirects
+ * @typedef { import('./helpers').DescribingCommentData } DescribingCommentData
*/
-const generateMD = (data) => {
- const output = data.reduce((acc, el) => {
- acc.list.push(`* [${el.name}](#${el.name})\n`);
- const typeOfSrc = el.type === 'scriptlet' ? 'Scriptlet' : 'Redirect';
+/**
+ * @typedef {Object} MarkdownData
+ * @property {string} list table of content
+ * @property {string} body main content which
+ */
- const body = `### ⚡️ ${el.name}
-${el.description}\n
-[${typeOfSrc} source](${el.source})
-* * *\n\n`;
+/**
+ * Generates markdown list and describing text.
+ *
+ * @param {DescribingCommentData[]} dataItems array of comment data objects
+ *
+ * @returns {MarkdownData}
+ */
+const getMarkdownData = (dataItems) => {
+ const output = dataItems.reduce((acc, {
+ name,
+ type,
+ description,
+ source,
+ }) => {
+ acc.list.push(`* [${name}](#${name})${EOL}`);
+
+ const typeOfSrc = type === 'scriptlet' ? 'Scriptlet' : 'Redirect';
+
+ const body = `### ⚡️ ${name}
+${description}${EOL}
+[${typeOfSrc} source](${source})
+* * *${EOL}${EOL}`;
acc.body.push(body);
return acc;
@@ -146,22 +100,24 @@ ${el.description}\n
/**
* Generates markdown list and describing text for static redirect resources
+ *
+ * @returns {MarkdownData}
*/
-const mdForStaticRedirects = () => {
- const staticRedirects = fs.readFileSync(path.resolve(__dirname, STATIC_REDIRECTS), { encoding: 'utf8' });
+const getMarkdownDataForStaticRedirects = () => {
+ const staticRedirects = fs.readFileSync(path.resolve(__dirname, staticRedirectsPath), { encoding: 'utf8' });
const parsedStaticRedirects = yaml.safeLoad(staticRedirects);
- const output = parsedStaticRedirects.reduce((acc, el) => {
- if (el.description) {
- acc.list.push(`* [${el.title}](#${el.title})\n`);
+ const output = parsedStaticRedirects.reduce((acc, { title, description }) => {
+ if (description) {
+ acc.list.push(`* [${title}](#${title})${EOL}`);
- const body = `### ⚡️ ${el.title}
-${el.description}
-[Redirect source](${STATIC_REDIRECTS})
-* * *\n\n`;
+ const body = `### ⚡️ ${title}
+${description}
+[Redirect source](${STATIC_REDIRECTS_RELATIVE_SOURCE})
+* * *${EOL}${EOL}`;
acc.body.push(body);
} else {
- throw new Error(`No description for ${el.title}`);
+ throw new Error(`No description for ${title}`);
}
return acc;
@@ -175,24 +131,28 @@ ${el.description}
/**
* Generates markdown list and describing text for blocking redirect resources, i.e click2load.html
+ *
+ * @returns {MarkdownData}
*/
-const mdForBlockingRedirects = () => {
- const BLOCKING_REDIRECTS_SOURCE_DIR = '../src/redirects/blocking-redirects';
+const getMarkdownDataForBlockingRedirects = () => {
+ const BLOCKING_REDIRECTS_SOURCE_SUB_DIR = 'blocking-redirects';
+ // eslint-disable-next-line max-len
+ const BLOCKING_REDIRECTS_RELATIVE_SOURCE = `${REDIRECTS_SRC_RELATIVE_DIR_PATH}/${BLOCKING_REDIRECTS_SOURCE_SUB_DIR}`;
- const blockingRedirects = fs.readFileSync(path.resolve(__dirname, BLOCKING_REDIRECTS), { encoding: 'utf8' });
+ const blockingRedirects = fs.readFileSync(blockingRedirectsPath, { encoding: 'utf8' });
const parsedBlockingRedirects = yaml.safeLoad(blockingRedirects);
- const output = parsedBlockingRedirects.reduce((acc, el) => {
- if (el.description) {
- acc.list.push(`* [${el.title}](#${el.title})\n`);
+ const output = parsedBlockingRedirects.reduce((acc, { title, description }) => {
+ if (description) {
+ acc.list.push(`* [${title}](#${title})${EOL}`);
- const body = `### ⚡️ ${el.title}
-${el.description}
-[Redirect source](${BLOCKING_REDIRECTS_SOURCE_DIR}/${el.title})
-* * *\n\n`;
+ const body = `### ⚡️ ${title}
+${description}
+[Redirect source](${BLOCKING_REDIRECTS_RELATIVE_SOURCE}/${title})
+* * *${EOL}${EOL}`;
acc.body.push(body);
} else {
- throw new Error(`No description for ${el.title}`);
+ throw new Error(`No description for ${title}`);
}
return acc;
@@ -205,33 +165,37 @@ ${el.description}
};
/**
- * Entry function
+ * Builds about wiki pages for scriptlets and redirects
*/
-function init() {
+const buildWikiAboutPages = () => {
try {
- const scriptletsMarkdownData = generateMD(manageDataFromFiles().scriptletsData);
- const redirectsMarkdownData = generateMD(manageDataFromFiles().redirectsData);
- const staticRedirectsMarkdownData = mdForStaticRedirects();
- const blockingRedirectsMarkdownData = mdForBlockingRedirects();
+ const filesData = manageDataFromFiles();
+ const scriptletsMarkdownData = getMarkdownData(filesData.scriptletsData);
+ const redirectsMarkdownData = getMarkdownData(filesData.redirectsData);
+ const staticRedirectsMarkdownData = getMarkdownDataForStaticRedirects();
+ const blockingRedirectsMarkdownData = getMarkdownDataForBlockingRedirects();
+
+ const scriptletsPageContent = `## Available Scriptlets
+${scriptletsMarkdownData.list}* * *
+${scriptletsMarkdownData.body}`;
+ fs.writeFileSync(
+ path.resolve(__dirname, aboutScriptletsPath),
+ scriptletsPageContent,
+ );
/* eslint-disable max-len */
- const scriptletsAbout = `## Available Scriptlets\n${scriptletsMarkdownData.list}* * *\n${scriptletsMarkdownData.body}`;
- fs.writeFileSync(path.resolve(__dirname, ABOUT_SCRIPTLETS_PATH), scriptletsAbout);
-
- const redirectsAbout = `## Available Redirect resources
+ const redirectsPageContent = `## Available Redirect resources
${staticRedirectsMarkdownData.list}${redirectsMarkdownData.list}${blockingRedirectsMarkdownData.list}* * *
${staticRedirectsMarkdownData.body}${redirectsMarkdownData.body}${blockingRedirectsMarkdownData.body}`;
/* eslint-enable max-len */
- fs.writeFileSync(path.resolve(__dirname, ABOUT_REDIRECTS_PATH), redirectsAbout);
+ fs.writeFileSync(
+ path.resolve(__dirname, aboutRedirectsPath),
+ redirectsPageContent,
+ );
} catch (e) {
// eslint-disable-next-line no-console
console.log(e.message);
}
-}
-
-init();
-
-module.exports = {
- redirectsFilesList,
- getDataFromFiles,
};
+
+buildWikiAboutPages();
diff --git a/scripts/build-redirects.js b/scripts/build-redirects.js
index eccc95e47..d12437e47 100644
--- a/scripts/build-redirects.js
+++ b/scripts/build-redirects.js
@@ -13,19 +13,20 @@ import generateHtml from 'rollup-plugin-generate-html';
import { minify } from 'terser';
import * as redirectsList from '../src/redirects/redirects-list';
import { version } from '../package.json';
-import { redirectsFilesList, getDataFromFiles } from './build-docs';
-import { writeFile } from './helpers';
import { rollupStandard } from './rollup-runners';
+import { writeFile, getDataFromFiles } from './helpers';
+import { redirectsFilenames, REDIRECTS_SRC_RELATIVE_DIR_PATH } from './constants';
const FILE_NAME = 'redirects.yml';
const CORELIBS_FILE_NAME = 'redirects.json';
const PATH_TO_DIST = './dist';
+
const RESULT_PATH = path.resolve(PATH_TO_DIST, FILE_NAME);
const REDIRECT_FILES_PATH = path.resolve(PATH_TO_DIST, 'redirect-files');
const CORELIBS_RESULT_PATH = path.resolve(PATH_TO_DIST, CORELIBS_FILE_NAME);
+// TODO: check if constants may be used
const DIST_REDIRECT_FILES = 'dist/redirect-files';
-const REDIRECTS_DIRECTORY = '../src/redirects';
const STATIC_REDIRECTS_PATH = './src/redirects/static-redirects.yml';
const BLOCKING_REDIRECTS_PATH = './src/redirects/blocking-redirects.yml';
const banner = `#
@@ -134,9 +135,12 @@ const getJsRedirects = async (options = {}) => {
};
}));
- const redirectsDescriptions = getDataFromFiles(redirectsFilesList, REDIRECTS_DIRECTORY)
- .flat(1);
+ const redirectsDescriptions = getDataFromFiles(
+ redirectsFilenames,
+ REDIRECTS_SRC_RELATIVE_DIR_PATH,
+ ).flat(1);
+ // TODO: seems like duplicate of already existed code
/**
* Returns first line of describing comment from redirect resource file
* @param {string} rrName redirect resource name
@@ -166,7 +170,7 @@ const getJsRedirects = async (options = {}) => {
throw new Error(`Couldn't find source for non-static redirect: ${fileName}`);
};
- const jsRedirects = redirectsFilesList.map((filename) => complementJsRedirects(filename));
+ const jsRedirects = redirectsFilenames.map((filename) => complementJsRedirects(filename));
return jsRedirects;
};
diff --git a/scripts/build-tests.js b/scripts/build-tests.js
index 760bcb998..d75871f96 100644
--- a/scripts/build-tests.js
+++ b/scripts/build-tests.js
@@ -81,10 +81,10 @@ const getTestConfigs = () => {
const MULTIPLE_TEST_FILES_DIRS = [
'scriptlets',
'redirects',
+ 'helpers',
];
const ONE_TEST_FILE_DIRS = [
'lib-tests',
- 'helpers',
];
const multipleFilesConfigs = MULTIPLE_TEST_FILES_DIRS
diff --git a/scripts/check-sources-updates.js b/scripts/check-sources-updates.js
index fc4ba4a66..ba6700d20 100644
--- a/scripts/check-sources-updates.js
+++ b/scripts/check-sources-updates.js
@@ -191,7 +191,7 @@ async function checkForUBOScriptletsUpdates() {
/**
* UBO redirects github page
*/
-const UBO_REDIRECTS_DIRECTORY_FILE = 'https://raw.githubusercontent.com/gorhill/uBlock/master/src/js/redirect-engine.js';
+const UBO_REDIRECTS_DIRECTORY_FILE = 'https://raw.githubusercontent.com/gorhill/uBlock/master/src/js/redirect-resources.js';
/**
* Make request to UBO repo(master), parses and returns the list of UBO redirects
@@ -201,7 +201,7 @@ async function getCurrentUBORedirects() {
let { data } = await axios.get(UBO_REDIRECTS_DIRECTORY_FILE);
console.log('Done.');
- const startTrigger = 'const redirectableResources = new Map([';
+ const startTrigger = 'export default new Map([';
const endTrigger = ']);';
const startIndex = data.indexOf(startTrigger);
@@ -270,7 +270,7 @@ async function getCurrentABPSnippets() { // eslint-disable-line no-unused-vars
/**
* Checks for ABP Snippets updates
*/
-async function checkForABPScriptletssUpdates() {
+async function checkForABPScriptletsUpdates() {
const oldList = getScriptletsFromTable('abp');
// ABP_SNIPPETS_FILE is unavailable
// TODO: fix later, AG-11891
@@ -333,7 +333,7 @@ async function checkForABPRedirectsUpdates() {
const UBORedirectsDiff = await checkForUBORedirectsUpdates();
const UBOScriptletsDiff = await checkForUBOScriptletsUpdates();
const ABPRedirectsDiff = await checkForABPRedirectsUpdates();
- const ABPScriptletsDiff = await checkForABPScriptletssUpdates();
+ const ABPScriptletsDiff = await checkForABPScriptletsUpdates();
if (UBORedirectsDiff) {
markTableWithDiff(UBORedirectsDiff, 'redirects', 'ubo');
@@ -368,6 +368,6 @@ async function checkForABPRedirectsUpdates() {
${added.length ? `Added: ${added}.` : ''}
`;
- throw new Error(message);
+ console.log(message);
}
}());
diff --git a/scripts/constants.js b/scripts/constants.js
index 26a21d482..8f9c90c00 100644
--- a/scripts/constants.js
+++ b/scripts/constants.js
@@ -1,25 +1,53 @@
const path = require('path');
+const { getFilesList } = require('./helpers');
+
/**
* Rules which were removed from the list should be marked with it
*/
const REMOVED_MARKER = '(removed)';
-const COMPATIBILITY_TABLE_INPUT_FILE = './compatibility-table.json';
-const COMPATIBILITY_TABLE_OUTPUT_FILE = '../wiki/compatibility-table.md';
-
+const COMPATIBILITY_TABLE_INPUT_FILENAME = 'compatibility-table.json';
/**
- * Path to compatibility data source json
+ * Path to **input** compatibility data source json
*/
-const COMPATIBILITY_TABLE_DATA_PATH = path.resolve(__dirname, COMPATIBILITY_TABLE_INPUT_FILE);
+const COMPATIBILITY_TABLE_DATA_PATH = path.resolve(__dirname, COMPATIBILITY_TABLE_INPUT_FILENAME);
-/**
- * Path to file with compatibility tables
- */
-const WIKI_COMPATIBILITY_TABLE_PATH = path.resolve(__dirname, COMPATIBILITY_TABLE_OUTPUT_FILE);
+const WIKI_DIR_PATH = '../wiki';
+
+const SRC_RELATIVE_DIR = '../src';
+const SRC_SCRIPTLETS_SUB_DIR = 'scriptlets';
+const SRC_REDIRECTS_SUB_DIR = 'redirects';
+
+const SCRIPTLETS_SRC_RELATIVE_DIR_PATH = `${SRC_RELATIVE_DIR}/${SRC_SCRIPTLETS_SUB_DIR}`;
+const REDIRECTS_SRC_RELATIVE_DIR_PATH = `${SRC_RELATIVE_DIR}/${SRC_REDIRECTS_SUB_DIR}`;
+
+// files which are not scriptlets in the source directory
+const NON_SCRIPTLETS_FILES = [
+ 'index.js',
+ 'scriptlets.js',
+ 'scriptlets-list.js',
+ 'scriptlets-wrapper.js',
+ 'scriptlets-umd-wrapper.js',
+];
+const scriptletsFilenames = getFilesList(SCRIPTLETS_SRC_RELATIVE_DIR_PATH)
+ .filter((el) => !NON_SCRIPTLETS_FILES.includes(el));
+
+// files which are not redirects in the source directory
+const NON_REDIRECTS_FILES = [
+ 'index.js',
+ 'redirects.js',
+ 'redirects-list.js',
+];
+const redirectsFilenames = getFilesList(REDIRECTS_SRC_RELATIVE_DIR_PATH)
+ .filter((el) => !NON_REDIRECTS_FILES.includes(el));
module.exports = {
REMOVED_MARKER,
COMPATIBILITY_TABLE_DATA_PATH,
- WIKI_COMPATIBILITY_TABLE_PATH,
+ WIKI_DIR_PATH,
+ SCRIPTLETS_SRC_RELATIVE_DIR_PATH,
+ REDIRECTS_SRC_RELATIVE_DIR_PATH,
+ scriptletsFilenames,
+ redirectsFilenames,
};
diff --git a/scripts/helpers.js b/scripts/helpers.js
index 4863bbc8e..b3d126b64 100644
--- a/scripts/helpers.js
+++ b/scripts/helpers.js
@@ -1,9 +1,125 @@
-import path from 'path';
-import fs from 'fs-extra';
+const path = require('path');
+const fs = require('fs-extra');
+const dox = require('dox');
-export const writeFile = async (filePath, content) => {
+/**
+ * Asynchronously writes data to a file, replacing the file if it already exists.
+ *
+ * @param {string} filePath absolute path to file
+ * @param {string} content content to write to the file
+ */
+const writeFile = async (filePath, content) => {
const dirname = path.dirname(filePath);
await fs.ensureDir(dirname);
await fs.writeFile(filePath, content);
};
+
+/**
+ * Gets list of `.js` files in directory
+ * @param {string} relativeDirPath relative path to directory
+ * @returns {string[]} array of file names
+ */
+const getFilesList = (relativeDirPath) => {
+ return fs.readdirSync(path.resolve(__dirname, relativeDirPath), { encoding: 'utf8' })
+ .filter((el) => el.includes('.js'));
+};
+
+/**
+ * @typedef {Object} CommentTag
+ * @property {string} type tag name
+ * @property {string} string text following the tag
+ */
+
+/**
+ * Returns parsed tags data which we use to describe the sources:
+ * - `@scriptlet`/`@redirect` to describe the type and name of source;
+ * - `@description` actual description for scriptlet or redirect.
+ * required comments from file.
+ * In one file might be comments describing scriptlet and redirect as well.
+ *
+ * @param {string} filePath absolute path to file
+ *
+ * @returns {CommentTag[]}
+ */
+const getDescribingCommentTags = (filePath) => {
+ const fileContent = fs.readFileSync(filePath, { encoding: 'utf8' });
+ const parsedFileComments = dox.parseComments(fileContent);
+ const describingComment = parsedFileComments
+ // get rid of not needed comments data
+ .filter(({ tags }) => {
+ // '@scriptlet', '@redirect', and 'description'
+ // are parser by dox.parseComments() as `tags`
+ if (tags.length === 0) {
+ return false;
+ }
+ const [base] = tags;
+ return base?.type === 'scriptlet'
+ || base?.type === 'redirect';
+ });
+
+ if (describingComment.length === 0) {
+ throw new Error(`No description in ${filePath}.
+Please add one OR edit the list of NON_SCRIPTLETS_FILES / NON_REDIRECTS_FILES.`);
+ }
+
+ if (describingComment.length > 1) {
+ throw new Error(`File should have one description comment: ${filePath}.`);
+ }
+
+ // eventually only one comment data item should left
+ return describingComment[0].tags;
+};
+
+/**
+ * @typedef {Object} DescribingCommentData
+ *
+ * Collected data from jsdoc-type comment for every scriptlet or redirect.
+ *
+ * @property {string} type parsed instance tag:
+ * 'scriptlet' for '@scriptlet', 'redirect' for '@redirect'
+ * @property {string} name name of instance which goes after the instance tag
+ * @property {string} description description, goes after `@description` tag
+ * @property {string} source relative path to source of scriptlet or redirect from wiki/about page
+ */
+
+/**
+ * Converts parsed comment to data object.
+ *
+ * @param {CommentTag[]} commentTags parsed tags from describing comment
+ * @param {string} source relative path to file
+ *
+ * @returns {DescribingCommentData}
+ */
+const prepareCommentsData = (commentTags, source) => {
+ const [base, sup] = commentTags;
+ return {
+ type: base.type,
+ name: base.string,
+ description: sup.string,
+ source,
+ };
+};
+
+/**
+ * Gets data objects which describe every required comment in one directory
+ * @param {string[]} filesList list of files in directory
+ * @param {string} relativeDirPath relative path to directory
+ *
+ * @returns {DescribingCommentData}
+ */
+const getDataFromFiles = (filesList, relativeDirPath) => {
+ const pathToDir = path.resolve(__dirname, relativeDirPath);
+ return filesList.map((file) => {
+ const pathToFile = path.resolve(pathToDir, file);
+ const requiredCommentTags = getDescribingCommentTags(pathToFile);
+
+ return prepareCommentsData(requiredCommentTags, `${relativeDirPath}/${file}`);
+ });
+};
+
+module.exports = {
+ writeFile,
+ getFilesList,
+ getDataFromFiles,
+};
diff --git a/src/helpers/cookie-utils.js b/src/helpers/cookie-utils.js
new file mode 100644
index 000000000..a7532cabb
--- /dev/null
+++ b/src/helpers/cookie-utils.js
@@ -0,0 +1,148 @@
+import { nativeIsNaN } from './number-utils';
+
+/**
+ * Checks whether the input path is supported
+ *
+ * @param {string} rawPath input path
+ *
+ * @returns {boolean}
+ */
+export const isValidCookieRawPath = (rawPath) => rawPath === '/' || rawPath === 'none';
+
+/**
+ * Returns 'path=/' if rawPath is '/'
+ * or empty string '' for other cases, `rawPath === 'none'` included
+ *
+ * @param {string} rawPath
+ *
+ * @returns {string}
+ */
+export const getCookiePath = (rawPath) => {
+ if (rawPath === '/') {
+ return 'path=/';
+ }
+ // otherwise do not set path as invalid
+ // the same for pathArg === 'none'
+ //
+ return '';
+};
+
+/**
+ * Combines input cookie name, value, and path into string.
+ *
+ * @param {string} rawName
+ * @param {string} rawValue
+ * @param {string} rawPath
+ *
+ * @returns {string} string OR `null` if path is not supported
+ */
+export const concatCookieNameValuePath = (rawName, rawValue, rawPath) => {
+ const log = console.log.bind(console); // eslint-disable-line no-console
+ if (!isValidCookieRawPath(rawPath)) {
+ log(`Invalid cookie path: '${rawPath}'`);
+ return null;
+ }
+ // eslint-disable-next-line max-len
+ return `${encodeURIComponent(rawName)}=${encodeURIComponent(rawValue)}; ${getCookiePath(rawPath)}`;
+};
+
+/**
+ * Gets supported cookie value
+ *
+ * @param {string} value input cookie value
+ *
+ * @returns {string|null} valid cookie string if ok OR null if not
+ */
+export const getLimitedCookieValue = (value) => {
+ if (!value) {
+ return null;
+ }
+ const log = console.log.bind(console); // eslint-disable-line no-console
+ let validValue;
+ if (value === 'true') {
+ validValue = 'true';
+ } else if (value === 'True') {
+ validValue = 'True';
+ } else if (value === 'false') {
+ validValue = 'false';
+ } else if (value === 'False') {
+ validValue = 'False';
+ } else if (value === 'yes') {
+ validValue = 'yes';
+ } else if (value === 'Yes') {
+ validValue = 'Yes';
+ } else if (value === 'Y') {
+ validValue = 'Y';
+ } else if (value === 'no') {
+ validValue = 'no';
+ } else if (value === 'ok') {
+ validValue = 'ok';
+ } else if (value === 'OK') {
+ validValue = 'OK';
+ } else if (/^\d+$/.test(value)) {
+ validValue = parseFloat(value);
+ if (nativeIsNaN(validValue)) {
+ log(`Invalid cookie value: '${value}'`);
+ return null;
+ }
+ if (Math.abs(validValue) < 0 || Math.abs(validValue) > 15) {
+ log(`Invalid cookie value: '${value}'`);
+ return null;
+ }
+ } else {
+ return null;
+ }
+
+ return validValue;
+};
+
+/**
+ * Parses cookie string into object
+ * @param {string} cookieString string that conforms to document.cookie format
+ * @returns {Object} key:value object that corresponds with incoming cookies keys and values
+ */
+export const parseCookieString = (cookieString) => {
+ const COOKIE_DELIMITER = '=';
+ const COOKIE_PAIRS_DELIMITER = ';';
+
+ // Get raw cookies
+ const cookieChunks = cookieString.split(COOKIE_PAIRS_DELIMITER);
+ const cookieData = {};
+
+ cookieChunks.forEach((singleCookie) => {
+ let cookieKey;
+ let cookieValue;
+ const delimiterIndex = singleCookie.indexOf(COOKIE_DELIMITER);
+ if (delimiterIndex === -1) {
+ cookieKey = singleCookie.trim();
+ } else {
+ cookieKey = singleCookie.slice(0, delimiterIndex).trim();
+ cookieValue = singleCookie.slice(delimiterIndex + 1);
+ }
+ // Save cookie key=value data with null instead of empty ('') values
+ cookieData[cookieKey] = cookieValue || null;
+ });
+
+ return cookieData;
+};
+
+/**
+ * Check if cookie with specified name and value is present in a cookie string
+ * @param {string} cookieString
+ * @param {string} name
+ * @param {string} value
+ * @returns {boolean}
+ */
+export const isCookieSetWithValue = (cookieString, name, value) => {
+ return cookieString.split(';')
+ .some((cookieStr) => {
+ const pos = cookieStr.indexOf('=');
+ if (pos === -1) {
+ return false;
+ }
+ const cookieName = cookieStr.slice(0, pos).trim();
+ const cookieValue = cookieStr.slice(pos + 1).trim();
+
+ return name === cookieName && value === cookieValue;
+ });
+};
diff --git a/src/helpers/get-descriptor-addon.js b/src/helpers/get-descriptor-addon.js
index 2740826f7..f8fb9bb60 100644
--- a/src/helpers/get-descriptor-addon.js
+++ b/src/helpers/get-descriptor-addon.js
@@ -1,7 +1,9 @@
+import { randomId } from './random-id';
/**
* Prevent infinite loops when trapping props that could be used by scriptlet's own helpers
* Example: window.RegExp, that is used by matchStackTrace > toRegExp
*
+ * https://github.com/AdguardTeam/Scriptlets/issues/251
* https://github.com/AdguardTeam/Scriptlets/issues/226
* https://github.com/AdguardTeam/Scriptlets/issues/232
*
@@ -12,9 +14,21 @@ export function getDescriptorAddon() {
isAbortingSuspended: false,
isolateCallback(cb, ...args) {
this.isAbortingSuspended = true;
- const result = cb(...args);
- this.isAbortingSuspended = false;
- return result;
+ // try...catch is required in case if there are more than one inline scripts
+ // which should be aborted.
+ // so after the first successful abortion, `cb(...args);` will throw error,
+ // and we should not stop on that and continue to abort other scripts
+ try {
+ const result = cb(...args);
+ this.isAbortingSuspended = false;
+ return result;
+ } catch {
+ this.isAbortingSuspended = false;
+ const rid = randomId();
+ // It's necessary to throw error
+ // otherwise script will be not aborted
+ throw new ReferenceError(rid);
+ }
},
};
}
diff --git a/src/helpers/index.js b/src/helpers/index.js
index 6a3765f4f..56700c1cc 100644
--- a/src/helpers/index.js
+++ b/src/helpers/index.js
@@ -14,10 +14,10 @@ export * from './observer';
export * from './match-stack';
export * from './open-shadow-dom-utils';
export * from './array-utils';
-export * from './prepare-cookie';
+export * from './cookie-utils';
export * from './number-utils';
export * from './adjust-set-utils';
-export * from './fetch-utils';
+export * from './request-utils';
export * from './object-utils';
export * from './prevent-window-open-utils';
export * from './add-event-listener-utils';
@@ -26,3 +26,4 @@ export * from './regexp-utils';
export * from './random-response';
export * from './get-descriptor-addon';
export * from './parse-flags';
+export * from './match-request-props';
diff --git a/src/helpers/match-request-props.js b/src/helpers/match-request-props.js
new file mode 100644
index 000000000..1d2925506
--- /dev/null
+++ b/src/helpers/match-request-props.js
@@ -0,0 +1,39 @@
+import {
+ getMatchPropsData,
+ validateParsedData,
+ parseMatchProps,
+} from './request-utils';
+
+/**
+ * Checks if given propsToMatch string matches with given request data
+ * This is used by prevent-xhr, prevent-fetch, trusted-replace-xhr-response
+ * and trusted-replace-fetch-response scriptlets
+ * @param {string} propsToMatch
+ * @param {Object} requestData object with standard properties of fetch/xhr like url, method etc
+ * @returns {boolean}
+ */
+export const matchRequestProps = (propsToMatch, requestData) => {
+ if (propsToMatch === '' || propsToMatch === '*') {
+ return true;
+ }
+
+ let isMatched;
+
+ const parsedData = parseMatchProps(propsToMatch);
+ if (!validateParsedData(parsedData)) {
+ // eslint-disable-next-line no-console
+ console.log(`Invalid parameter: ${propsToMatch}`);
+ isMatched = false;
+ } else {
+ const matchData = getMatchPropsData(parsedData);
+ // prevent only if all props match
+ isMatched = Object.keys(matchData)
+ .every((matchKey) => {
+ const matchValue = matchData[matchKey];
+ return Object.prototype.hasOwnProperty.call(requestData, matchKey)
+ && matchValue.test(requestData[matchKey]);
+ });
+ }
+
+ return isMatched;
+};
diff --git a/src/helpers/noop.js b/src/helpers/noop.js
index cdd9e5e6b..b4cca11ec 100644
--- a/src/helpers/noop.js
+++ b/src/helpers/noop.js
@@ -53,10 +53,11 @@ export const noopObject = () => ({});
export const noopPromiseReject = () => Promise.reject(); // eslint-disable-line compat/compat
/**
- * Returns Promise object that is resolved with a response
- * @param {string} [responseBody='{}'] value of response body
+ * Returns Promise object that is resolved value of response body
+ * @param {string} [url=''] value of response url to set on response object
+ * @param {string} [response='default'] value of response type to set on response object
*/
-export const noopPromiseResolve = (responseBody = '{}') => {
+export const noopPromiseResolve = (responseBody = '{}', responseUrl = '', responseType = 'default') => {
if (typeof Response === 'undefined') {
return;
}
@@ -65,6 +66,14 @@ export const noopPromiseResolve = (responseBody = '{}') => {
status: 200,
statusText: 'OK',
});
+
+ // Mock response' url & type to avoid adb checks
+ // https://github.com/AdguardTeam/Scriptlets/issues/216
+ Object.defineProperties(response, {
+ url: { value: responseUrl },
+ type: { value: responseType },
+ });
+
// eslint-disable-next-line compat/compat, consistent-return
return Promise.resolve(response);
};
diff --git a/src/helpers/prepare-cookie.js b/src/helpers/prepare-cookie.js
deleted file mode 100644
index 29e1abb28..000000000
--- a/src/helpers/prepare-cookie.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import { nativeIsNaN } from './number-utils';
-/**
- * Prepares cookie string if given parameters are ok
- * @param {string} name cookie name to set
- * @param {string} value cookie value to set
- * @returns {string|null} cookie string if ok OR null if not
- */
-export const prepareCookie = (name, value) => {
- if (!name || !value) {
- return null;
- }
-
- let valueToSet;
- if (value === 'true') {
- valueToSet = 'true';
- } else if (value === 'True') {
- valueToSet = 'True';
- } else if (value === 'false') {
- valueToSet = 'false';
- } else if (value === 'False') {
- valueToSet = 'False';
- } else if (value === 'yes') {
- valueToSet = 'yes';
- } else if (value === 'Yes') {
- valueToSet = 'Yes';
- } else if (value === 'Y') {
- valueToSet = 'Y';
- } else if (value === 'no') {
- valueToSet = 'no';
- } else if (value === 'ok') {
- valueToSet = 'ok';
- } else if (value === 'OK') {
- valueToSet = 'OK';
- } else if (/^\d+$/.test(value)) {
- valueToSet = parseFloat(value);
- if (nativeIsNaN(valueToSet)) {
- return null;
- }
- if (Math.abs(valueToSet) < 0 || Math.abs(valueToSet) > 15) {
- return null;
- }
- } else {
- return null;
- }
-
- const pathToSet = 'path=/;';
- // eslint-disable-next-line max-len
- const cookieData = `${encodeURIComponent(name)}=${encodeURIComponent(valueToSet)}; ${pathToSet}`;
-
- return cookieData;
-};
diff --git a/src/helpers/fetch-utils.js b/src/helpers/request-utils.js
similarity index 72%
rename from src/helpers/fetch-utils.js
rename to src/helpers/request-utils.js
index 0fd3dcbb7..bc93ee3c7 100644
--- a/src/helpers/fetch-utils.js
+++ b/src/helpers/request-utils.js
@@ -58,6 +58,25 @@ export const getFetchData = (args) => {
return fetchPropsObj;
};
+/**
+ * Collect xhr.open arguments to object
+ * @param {string} method
+ * @param {string} url
+ * @param {string} async
+ * @param {string} user
+ * @param {string} password
+ * @returns {Object}
+ */
+export const getXhrData = (method, url, async, user, password) => {
+ return {
+ method,
+ url,
+ async,
+ user,
+ password,
+ };
+};
+
/**
* Parse propsToMatch input string into object;
* used for prevent-fetch and prevent-xhr
@@ -67,18 +86,40 @@ export const getFetchData = (args) => {
export const parseMatchProps = (propsToMatchStr) => {
const PROPS_DIVIDER = ' ';
const PAIRS_MARKER = ':';
+ const LEGAL_MATCH_PROPS = [
+ 'method',
+ 'url',
+ 'headers',
+ 'body',
+ 'mode',
+ 'credentials',
+ 'cache',
+ 'redirect',
+ 'referrer',
+ 'referrerPolicy',
+ 'integrity',
+ 'keepalive',
+ 'signal',
+ 'async',
+ ];
const propsObj = {};
const props = propsToMatchStr.split(PROPS_DIVIDER);
props.forEach((prop) => {
const dividerInd = prop.indexOf(PAIRS_MARKER);
- if (dividerInd === -1) {
- propsObj.url = prop;
- } else {
- const key = prop.slice(0, dividerInd);
+
+ const key = prop.slice(0, dividerInd);
+ const hasLegalMatchProp = LEGAL_MATCH_PROPS.indexOf(key) !== -1;
+
+ if (hasLegalMatchProp) {
const value = prop.slice(dividerInd + 1);
propsObj[key] = value;
+ } else {
+ // Escape multiple colons in prop
+ // i.e regex value and/or url with protocol specified, with or without 'url:' match prop
+ // https://github.com/AdguardTeam/Scriptlets/issues/216#issuecomment-1178591463
+ propsObj.url = prop;
}
});
diff --git a/src/helpers/string-utils.js b/src/helpers/string-utils.js
index a4b79b384..5f5252707 100644
--- a/src/helpers/string-utils.js
+++ b/src/helpers/string-utils.js
@@ -96,7 +96,7 @@ export const startsWith = (str, prefix) => {
export const endsWith = (str, ending) => {
// if str === '', (str && false) will return ''
// that's why it has to be !!str
- return !!str && str.indexOf(ending) === str.length - ending.length;
+ return !!str && str.lastIndexOf(ending) === str.length - ending.length;
};
export const substringAfter = (str, separator) => {
diff --git a/src/redirects/google-ima3.js b/src/redirects/google-ima3.js
index ea260a08e..8c237908a 100644
--- a/src/redirects/google-ima3.js
+++ b/src/redirects/google-ima3.js
@@ -105,8 +105,6 @@ export function GoogleIma3(source) {
},
};
- let managerLoaded = false;
-
const EventHandler = function () {
this.listeners = new Map();
this._dispatch = function (e) {
@@ -205,27 +203,27 @@ export function GoogleIma3(source) {
};
AdsLoader.prototype.getVersion = () => VERSION;
AdsLoader.prototype.requestAds = function (adsRequest, userRequestContext) {
- if (!managerLoaded) {
- managerLoaded = true;
-
- requestAnimationFrame(() => {
- const { ADS_MANAGER_LOADED } = AdsManagerLoadedEvent.Type;
- // eslint-disable-next-line max-len
- this._dispatch(new ima.AdsManagerLoadedEvent(ADS_MANAGER_LOADED, adsRequest, userRequestContext));
- });
-
- const e = new ima.AdError(
- 'adPlayError',
- 1205,
- 1205,
- 'The browser prevented playback initiated without user interaction.',
+ requestAnimationFrame(() => {
+ const { ADS_MANAGER_LOADED } = AdsManagerLoadedEvent.Type;
+ const event = new ima.AdsManagerLoadedEvent(
+ ADS_MANAGER_LOADED,
adsRequest,
userRequestContext,
);
- requestAnimationFrame(() => {
- this._dispatch(new ima.AdErrorEvent(e));
- });
- }
+ this._dispatch(event);
+ });
+
+ const e = new ima.AdError(
+ 'adPlayError',
+ 1205,
+ 1205,
+ 'The browser prevented playback initiated without user interaction.',
+ adsRequest,
+ userRequestContext,
+ );
+ requestAnimationFrame(() => {
+ this._dispatch(new ima.AdErrorEvent(e));
+ });
};
const AdsRenderingSettings = noopFunc;
diff --git a/src/redirects/googlesyndication-adsbygoogle.js b/src/redirects/googlesyndication-adsbygoogle.js
index 326c01c25..d65b72438 100644
--- a/src/redirects/googlesyndication-adsbygoogle.js
+++ b/src/redirects/googlesyndication-adsbygoogle.js
@@ -32,7 +32,9 @@ export function GoogleSyndicationAdsByGoogle(source) {
for (const key of Object.keys(arg)) {
if (typeof arg[key] === 'function') {
try {
- arg[key].call();
+ // https://github.com/AdguardTeam/Scriptlets/issues/252
+ // argument "{}" is needed to fix issue with undefined argument
+ arg[key].call(this, {});
} catch {
/* empty */
}
diff --git a/src/redirects/metrika-yandex-tag.js b/src/redirects/metrika-yandex-tag.js
index 75a495ef0..068551c3b 100644
--- a/src/redirects/metrika-yandex-tag.js
+++ b/src/redirects/metrika-yandex-tag.js
@@ -104,7 +104,6 @@ export function metrikaYandexTag(source) {
function ym(id, funcName, ...args) {
return api[funcName] && api[funcName](id, ...args);
}
- ym.a = [];
function init(id) {
// yaCounter object should provide api
@@ -114,12 +113,13 @@ export function metrikaYandexTag(source) {
if (typeof window.ym === 'undefined') {
window.ym = ym;
+ ym.a = [];
} else if (window.ym && window.ym.a) {
- // Get id for yaCounter object
- const counters = window.ym.a;
-
+ // Keep initial counters array intact
+ ym.a = window.ym.a;
window.ym = ym;
- counters.forEach((params) => {
+
+ window.ym.a.forEach((params) => {
const id = params[0];
init(id);
});
diff --git a/src/redirects/pardot-1.0.js b/src/redirects/pardot-1.0.js
index 92fa72d8e..eb2d312b1 100644
--- a/src/redirects/pardot-1.0.js
+++ b/src/redirects/pardot-1.0.js
@@ -10,9 +10,10 @@ import {
* @redirect pardot-1.0
*
* @description
- * Mocks the pd.js file of Salesforce
+ * Mocks the pd.js file of Salesforce.
* https://pi.pardot.com/pd.js
* https://developer.salesforce.com/docs/marketing/pardot/overview
+ *
* **Example**
* ```
* ||pi.pardot.com/pd.js$script,redirect=pardot
diff --git a/src/scriptlets/prevent-fetch.js b/src/scriptlets/prevent-fetch.js
index 061322f02..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 */
@@ -30,7 +31,7 @@ import {
*
* **Syntax**
* ```
- * example.org#%#//scriptlet('prevent-fetch'[, propsToMatch[, responseBody]])
+ * example.org#%#//scriptlet('prevent-fetch'[, propsToMatch[, responseBody[, responseType]]])
* ```
*
* - `propsToMatch` - optional, string of space-separated properties to match; possible props:
@@ -41,8 +42,12 @@ import {
* - responseBody - optional, string for defining response body value, defaults to `emptyObj`. Possible values:
* - `emptyObj` - empty object
* - `emptyArr` - empty array
+ * - responseType - optional, string for defining response type, defaults to `default`. Possible values:
+ * - default
+ * - opaque
+ *
* > Usage with no arguments will log fetch calls to browser console;
- * which is useful for debugging but permitted for production filter lists.
+ * which is useful for debugging but not permitted for production filter lists.
*
* **Examples**
* 1. Log all fetch calls
@@ -82,7 +87,7 @@ import {
* ```
*/
/* eslint-enable max-len */
-export function preventFetch(source, propsToMatch, responseBody = 'emptyObj') {
+export function preventFetch(source, propsToMatch, responseBody = 'emptyObj', responseType = 'default') {
// 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
@@ -92,6 +97,9 @@ export function preventFetch(source, propsToMatch, responseBody = 'emptyObj') {
return;
}
+ // eslint-disable-next-line no-console
+ const log = console.log.bind(console);
+
let strResponseBody;
if (responseBody === 'emptyObj') {
strResponseBody = '{}';
@@ -101,37 +109,28 @@ export function preventFetch(source, propsToMatch, responseBody = 'emptyObj') {
return;
}
+ // Skip disallowed response types
+ if (!(responseType === 'default' || responseType === 'opaque')) {
+ // eslint-disable-next-line no-console
+ log(`Invalid parameter: ${responseType}`);
+ return;
+ }
+
const handlerWrapper = (target, thisArg, args) => {
let shouldPrevent = false;
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);
+ return noopPromiseResolve(strResponseBody, fetchData.url, responseType);
}
return Reflect.apply(target, thisArg, args);
@@ -156,11 +155,9 @@ preventFetch.injections = [
hit,
getFetchData,
objectToString,
- parseMatchProps,
- validateParsedData,
- getMatchPropsData,
noopPromiseResolve,
getWildcardSymbol,
+ matchRequestProps,
toRegExp,
isValidStrPattern,
escapeRegExp,
@@ -168,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 bec3bde8a..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 */
@@ -47,7 +48,7 @@ import {
* - value — range on numbers, for example `100-300`, limited to 500000 characters
*
* > Usage with no arguments will log XMLHttpRequest objects to browser console;
- * which is useful for debugging but permitted for production filter lists.
+ * which is useful for debugging but not allowed for production filter lists.
*
* **Examples**
* 1. Log all XMLHttpRequests
@@ -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 cba47c6b2..86f6031b1 100644
--- a/src/scriptlets/scriptlets-list.js
+++ b/src/scriptlets/scriptlets-list.js
@@ -1,6 +1,7 @@
/**
* This file must export all scriptlets which should be accessible
*/
+export * from './trusted-click-element';
export * from './abort-on-property-read';
export * from './abort-on-property-write';
export * from './prevent-setTimeout';
@@ -47,3 +48,7 @@ export * from './close-window';
export * from './prevent-refresh';
export * from './prevent-element-src-loading';
export * from './no-topics';
+export * from './trusted-replace-xhr-response';
+export * from './xml-prune';
+export * from './trusted-set-cookie';
+export * from './trusted-replace-fetch-response';
diff --git a/src/scriptlets/set-cookie-reload.js b/src/scriptlets/set-cookie-reload.js
index 97a14c755..128126fb3 100644
--- a/src/scriptlets/set-cookie-reload.js
+++ b/src/scriptlets/set-cookie-reload.js
@@ -1,19 +1,26 @@
import {
hit,
nativeIsNaN,
- prepareCookie,
+ isCookieSetWithValue,
+ getLimitedCookieValue,
+ concatCookieNameValuePath,
+ // following helpers should be imported and injected
+ // because they are used by helpers above
+ isValidCookieRawPath,
+ getCookiePath,
} from '../helpers/index';
/**
* @scriptlet set-cookie-reload
*
* @description
- * Sets a cookie with the specified name and value, and then reloads the current page.
+ * Sets a cookie with the specified name and value, and path,
+ * and reloads the current page after the cookie setting.
* If reloading option is not needed, use [set-cookie](#set-cookie) scriptlet.
*
* **Syntax**
* ```
- * example.org#%#//scriptlet('set-cookie-reload', name, value)
+ * example.org#%#//scriptlet('set-cookie-reload', name, value[, path])
* ```
*
* - `name` - required, cookie name to be set
@@ -25,34 +32,34 @@ import {
* - `yes` / `Yes` / `Y`
* - `no`
* - `ok` / `OK`
+ * - `path` - optional, cookie path, defaults to `/`; possible values:
+ * - `/` — root path
+ * - `none` — to set no path at all
*
* **Examples**
* ```
* example.org#%#//scriptlet('set-cookie-reload', 'checking', 'ok')
*
* example.org#%#//scriptlet('set-cookie-reload', 'gdpr-settings-cookie', '1')
+ *
+ * example.org#%#//scriptlet('set-cookie-reload', 'cookie-set', 'true', 'none')
* ```
*/
-export function setCookieReload(source, name, value) {
- const isCookieSetWithValue = (name, value) => {
- return document.cookie.split(';')
- .some((cookieStr) => {
- const pos = cookieStr.indexOf('=');
- if (pos === -1) {
- return false;
- }
- const cookieName = cookieStr.slice(0, pos).trim();
- const cookieValue = cookieStr.slice(pos + 1).trim();
+export function setCookieReload(source, name, value, path = '/') {
+ if (isCookieSetWithValue(name, value)) {
+ return;
+ }
- return name === cookieName && value === cookieValue;
- });
- };
+ // eslint-disable-next-line no-console
+ const log = console.log.bind(console);
- if (isCookieSetWithValue(name, value)) {
+ const validValue = getLimitedCookieValue(value);
+ if (validValue === null) {
+ log(`Invalid cookie value: '${validValue}'`);
return;
}
- const cookieData = prepareCookie(name, value);
+ const cookieData = concatCookieNameValuePath(name, validValue, path);
if (cookieData) {
document.cookie = cookieData;
@@ -60,7 +67,7 @@ export function setCookieReload(source, name, value) {
// Only reload the page if cookie was set
// https://github.com/AdguardTeam/Scriptlets/issues/212
- if (isCookieSetWithValue(name, value)) {
+ if (isCookieSetWithValue(document.cookie, name, value)) {
window.location.reload();
}
}
@@ -70,4 +77,12 @@ setCookieReload.names = [
'set-cookie-reload',
];
-setCookieReload.injections = [hit, nativeIsNaN, prepareCookie];
+setCookieReload.injections = [
+ hit,
+ nativeIsNaN,
+ isCookieSetWithValue,
+ getLimitedCookieValue,
+ concatCookieNameValuePath,
+ isValidCookieRawPath,
+ getCookiePath,
+];
diff --git a/src/scriptlets/set-cookie.js b/src/scriptlets/set-cookie.js
index d6f23ad75..c686ef498 100644
--- a/src/scriptlets/set-cookie.js
+++ b/src/scriptlets/set-cookie.js
@@ -1,15 +1,25 @@
-import { hit, nativeIsNaN, prepareCookie } from '../helpers/index';
+import {
+ hit,
+ nativeIsNaN,
+ isCookieSetWithValue,
+ getLimitedCookieValue,
+ concatCookieNameValuePath,
+ // following helpers should be imported and injected
+ // because they are used by helpers above
+ isValidCookieRawPath,
+ getCookiePath,
+} from '../helpers/index';
/* eslint-disable max-len */
/**
* @scriptlet set-cookie
*
* @description
- * Sets a cookie with the specified name and value. Cookie path defaults to root.
+ * Sets a cookie with the specified name, value, and path.
*
* **Syntax**
* ```
- * example.org#%#//scriptlet('set-cookie', name, value)
+ * example.org#%#//scriptlet('set-cookie', name, value[, path])
* ```
*
* - `name` - required, cookie name to be set
@@ -21,17 +31,31 @@ import { hit, nativeIsNaN, prepareCookie } from '../helpers/index';
* - `yes` / `Yes` / `Y`
* - `no`
* - `ok` / `OK`
+ * - `path` - optional, cookie path, defaults to `/`; possible values:
+ * - `/` — root path
+ * - `none` — to set no path at all
*
* **Examples**
* ```
- * example.org#%#//scriptlet('set-cookie', 'ReadlyCookieConsent', '1')
+ * example.org#%#//scriptlet('set-cookie', 'CookieConsent', '1')
*
* example.org#%#//scriptlet('set-cookie', 'gdpr-settings-cookie', 'true')
+ *
+ * example.org#%#//scriptlet('set-cookie', 'cookie_consent', 'ok', 'none')
* ```
*/
/* eslint-enable max-len */
-export function setCookie(source, name, value) {
- const cookieData = prepareCookie(name, value);
+export function setCookie(source, name, value, path = '/') {
+ // eslint-disable-next-line no-console
+ const log = console.log.bind(console);
+
+ const validValue = getLimitedCookieValue(value);
+ if (validValue === null) {
+ log(`Invalid cookie value: '${validValue}'`);
+ return;
+ }
+
+ const cookieData = concatCookieNameValuePath(name, validValue, path);
if (cookieData) {
hit(source);
@@ -43,4 +67,12 @@ setCookie.names = [
'set-cookie',
];
-setCookie.injections = [hit, nativeIsNaN, prepareCookie];
+setCookie.injections = [
+ hit,
+ nativeIsNaN,
+ isCookieSetWithValue,
+ getLimitedCookieValue,
+ concatCookieNameValuePath,
+ isValidCookieRawPath,
+ getCookiePath,
+];
diff --git a/src/scriptlets/trusted-click-element.js b/src/scriptlets/trusted-click-element.js
new file mode 100644
index 000000000..c364c0dcb
--- /dev/null
+++ b/src/scriptlets/trusted-click-element.js
@@ -0,0 +1,302 @@
+import {
+ hit,
+ toRegExp,
+ parseCookieString,
+} from '../helpers/index';
+
+/* eslint-disable max-len */
+/**
+ * @scriptlet trusted-click-element
+ *
+ * @description
+ * Clicks selected elements in a strict sequence, ordered by selectors passed, and waiting for them to render in the DOM first.
+ * Deactivates after all elements have been clicked or by 10s timeout.
+ *
+ * **Syntax**
+ * ```
+ * example.com#%#//scriptlet('trusted-click-element', selectors[, extraMatch[, delay]])
+ * ```
+ *
+ * - `selectors` — required, string with query selectors delimited by comma
+ * - `extraMatch` — optional, extra condition to check on a page; allows to match `cookie` and `localStorage`; can be set as `name:key[=value]` where `value` is optional.
+ * Multiple conditions are allowed inside one `extraMatch` but they should be delimited by comma and each of them should match the syntax. Possible `name`s:
+ * - `cookie` - test string or regex against cookies on a page
+ * - `localStorage` - check if localStorage item is present
+ * - 'delay' - optional, time in ms to delay scriptlet execution, defaults to instant execution.
+ * **Examples**
+ * 1. Click single element by selector
+ * ```
+ * example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]')
+ * ```
+ *
+ * 2. Delay click execution by 500ms
+ * ```
+ * example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]', '', '500')
+ * ```
+ *
+ * 3. Click multiple elements by selector with a delay
+ * ```
+ * example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"], button[name='check"], input[type="submit"][value="akkoord"]', '', '500')
+ * ```
+ *
+ * 4. Match cookies by keys using regex and string
+ * ```
+ * example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]', 'cookie:userConsentCommunity, cookie:/cmpconsent|cmp/')
+ * ```
+ *
+ * 5. Match by cookie key=value pairs using regex and string
+ * ```
+ * example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]', 'cookie:userConsentCommunity=true, cookie:/cmpconsent|cmp/=/[a-z]{1,5}/')
+ * ```
+ *
+ * 6. Match by localStorage item 'promo' key
+ * ```
+ * example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]', 'localStorage:promo')
+ * ```
+ *
+ * 7. Click multiple elements with delay and matching by both cookie string and localStorage item
+ * ```
+ * example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"], input[type="submit"][value="akkoord"]', 'cookie:cmpconsent, localStorage:promo', '250')
+ * ```
+ */
+/* eslint-enable max-len */
+export function trustedClickElement(source, selectors, extraMatch = '', delay = NaN) {
+ if (!selectors) {
+ return;
+ }
+ // eslint-disable-next-line no-console
+ const log = console.log.bind(console);
+
+ const OBSERVER_TIMEOUT_MS = 10000;
+ const THROTTLE_DELAY_MS = 20;
+ const COOKIE_MATCH_MARKER = 'cookie:';
+ const LOCAL_STORAGE_MATCH_MARKER = 'localStorage:';
+ const SELECTORS_DELIMITER = ',';
+ const COOKIE_STRING_DELIMITER = ';';
+ // Regex to split match pairs by commas, avoiding the ones included in regexes
+ const EXTRA_MATCH_DELIMITER = /(,\s*){1}(?=cookie:|localStorage:)/;
+
+ let parsedDelay;
+ if (delay) {
+ parsedDelay = parseInt(delay, 10);
+ const isValidDelay = !Number.isNaN(parsedDelay) || parsedDelay < OBSERVER_TIMEOUT_MS;
+ if (!isValidDelay) {
+ log(`Passed delay '${delay}' is invalid or bigger than ${OBSERVER_TIMEOUT_MS} ms`);
+ return;
+ }
+ }
+
+ let canClick = !parsedDelay;
+
+ const cookieMatches = [];
+ const localStorageMatches = [];
+
+ if (extraMatch) {
+ // Get all match marker:value pairs from argument
+ const parsedExtraMatch = extraMatch
+ .split(EXTRA_MATCH_DELIMITER)
+ .map((matchStr) => matchStr.trim());
+
+ // Filter match pairs by marker
+ parsedExtraMatch.forEach((matchStr) => {
+ if (matchStr.indexOf(COOKIE_MATCH_MARKER) > -1) {
+ const cookieMatch = matchStr.replace(COOKIE_MATCH_MARKER, '');
+ cookieMatches.push(cookieMatch);
+ }
+ if (matchStr.indexOf(LOCAL_STORAGE_MATCH_MARKER) > -1) {
+ const localStorageMatch = matchStr.replace(LOCAL_STORAGE_MATCH_MARKER, '');
+ localStorageMatches.push(localStorageMatch);
+ }
+ });
+ }
+
+ if (cookieMatches.length > 0) {
+ const parsedCookieMatches = parseCookieString(cookieMatches.join(COOKIE_STRING_DELIMITER));
+ const parsedCookies = parseCookieString(document.cookie);
+ const cookieKeys = Object.keys(parsedCookies);
+ if (cookieKeys.length === 0) {
+ return;
+ }
+
+ const cookiesMatched = Object.keys(parsedCookieMatches).every((key) => {
+ // Avoid getting /.?/ result from toRegExp on undefined
+ // as cookie may be set without value,
+ // on which cookie parsing will return cookieKey:undefined pair
+ const valueMatch = parsedCookieMatches[key] ? toRegExp(parsedCookieMatches[key]) : null;
+ const keyMatch = toRegExp(key);
+
+ return cookieKeys.some((key) => {
+ const keysMatched = keyMatch.test(key);
+ if (!keysMatched) {
+ return false;
+ }
+
+ // Key matching is enough if cookie value match is not specified
+ if (!valueMatch) {
+ return true;
+ }
+
+ return valueMatch.test(parsedCookies[key]);
+ });
+ });
+
+ if (!cookiesMatched) {
+ return;
+ }
+ }
+
+ if (localStorageMatches.length > 0) {
+ const localStorageMatched = localStorageMatches
+ .every((str) => {
+ const itemValue = window.localStorage.getItem(str);
+ return itemValue || itemValue === '';
+ });
+ if (!localStorageMatched) {
+ return;
+ }
+ }
+
+ /**
+ * Create selectors array and swap selectors to null on finding it's element
+ *
+ * Selectors / nulls should not be (re)moved from array to:
+ * - keep track of selectors order
+ * - always know on what index corresponding element should be put
+ * - prevent selectors from being queried multiple times
+ */
+ let selectorsSequence = selectors
+ .split(SELECTORS_DELIMITER)
+ .map((selector) => selector.trim());
+
+ const createElementObj = (element) => {
+ return {
+ element: element || null,
+ clicked: false,
+ };
+ };
+ const elementsSequence = Array(selectorsSequence.length).fill(createElementObj());
+
+ /**
+ * Go through elementsSequence from left to right, clicking on found elements
+ *
+ * Element should not be clicked if it is already clicked,
+ * or a previous element is not found or clicked yet
+ */
+ const clickElementsBySequence = () => {
+ for (let i = 0; i < elementsSequence.length; i += 1) {
+ const elementObj = elementsSequence[i];
+ // Stop clicking if that pos element is not found yet
+ if (!elementObj.element) {
+ break;
+ }
+ // Skip already clicked elements
+ if (!elementObj.clicked) {
+ elementObj.element.click();
+ elementObj.clicked = true;
+ }
+ }
+
+ const allElementsClicked = elementsSequence
+ .every((elementObj) => elementObj.clicked === true);
+ if (allElementsClicked) {
+ // At this stage observer is already disconnected
+ hit(source);
+ }
+ };
+
+ const handleElement = (element, i) => {
+ const elementObj = createElementObj(element);
+ elementsSequence[i] = elementObj;
+
+ if (canClick) {
+ clickElementsBySequence();
+ }
+ };
+
+ /**
+ * Query all selectors from queue on each mutation
+ * Each selector is swapped to null in selectorsSequence on founding corresponding element
+ *
+ * We start looking for elements before possible delay is over, to avoid cases
+ * when delay is getting off after the last mutation took place.
+ *
+ */
+ const findElements = (mutations, observer) => {
+ const fulfilledSelectors = [];
+ selectorsSequence.forEach((selector, i) => {
+ if (!selector) {
+ return;
+ }
+ const element = document.querySelector(selector);
+ if (!element) {
+ return;
+ }
+
+ handleElement(element, i);
+ fulfilledSelectors.push(selector);
+ });
+
+ // selectorsSequence should be modified after the loop to not break loop indexation
+ selectorsSequence = selectorsSequence.map((selector) => {
+ return fulfilledSelectors.indexOf(selector) === -1 ? selector : null;
+ });
+
+ // Disconnect observer after finding all elements
+ const allSelectorsFulfilled = selectorsSequence.every((selector) => selector === null);
+ if (allSelectorsFulfilled) {
+ observer.disconnect();
+ }
+ };
+
+ const throttle = (cb, ms) => {
+ let wait = false;
+ let savedArgs;
+ const wrapper = (...args) => {
+ if (wait) {
+ savedArgs = args;
+ return;
+ }
+
+ cb(...args);
+ wait = true;
+
+ setTimeout(() => {
+ wait = false;
+ if (savedArgs) {
+ wrapper(savedArgs);
+ savedArgs = null;
+ }
+ }, ms);
+ };
+ return wrapper;
+ };
+
+ // eslint-disable-next-line compat/compat
+ const observer = new MutationObserver(throttle(findElements, THROTTLE_DELAY_MS));
+ observer.observe(document.documentElement, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+
+ if (parsedDelay) {
+ setTimeout(() => {
+ // Click previously collected elements
+ clickElementsBySequence();
+ canClick = true;
+ }, parsedDelay);
+ }
+
+ setTimeout(() => observer.disconnect(), OBSERVER_TIMEOUT_MS);
+}
+
+trustedClickElement.names = [
+ 'trusted-click-element',
+ // trusted scriptlets support no aliases
+];
+
+trustedClickElement.injections = [
+ hit,
+ toRegExp,
+ parseCookieString,
+];
diff --git a/src/scriptlets/trusted-replace-fetch-response.js b/src/scriptlets/trusted-replace-fetch-response.js
new file mode 100644
index 000000000..7dc999643
--- /dev/null
+++ b/src/scriptlets/trusted-replace-fetch-response.js
@@ -0,0 +1,210 @@
+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);
+ log(`Response body can't be converted to text: ${fetchDataStr}`);
+ 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
new file mode 100644
index 000000000..99c3a6953
--- /dev/null
+++ b/src/scriptlets/trusted-replace-xhr-response.js
@@ -0,0 +1,248 @@
+import {
+ hit,
+ toRegExp,
+ objectToString,
+ getWildcardSymbol,
+ matchRequestProps,
+ getXhrData,
+ // following helpers should be imported and injected
+ // because they are used by helpers above
+ getMatchPropsData,
+ validateParsedData,
+ parseMatchProps,
+ isValidStrPattern,
+ escapeRegExp,
+ isEmptyObject,
+ getObjectEntries,
+} from '../helpers/index';
+
+/* eslint-disable max-len */
+/**
+ * @scriptlet trusted-replace-xhr-response
+ *
+ * @description
+ * Replaces response content of `xhr` requests if **all** given parameters match.
+ *
+ * **Syntax**
+ * ```
+ * example.org#%#//scriptlet('trusted-replace-xhr-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 matched content with. Empty string to remove content.
+ * - propsToMatch — optional, string of space-separated properties to match for extra condition; possible props:
+ * - string or regular expression for matching the URL passed to `.open()` call;
+ * - colon-separated pairs name:value where
+ * - name - name is string or regular expression for matching XMLHttpRequest property name
+ * - value is string or regular expression for matching the value of the option passed to `.open()` call
+ *
+ * > Usage with no arguments will log XMLHttpRequest objects to browser console;
+ * which is useful for debugging but not permitted for production filter lists.
+ *
+ * **Examples**
+ * 1. Log all XMLHttpRequests
+ * ```
+ * example.org#%#//scriptlet('trusted-replace-xhr-response')
+ * ```
+ *
+ * 2. Replace text content of XMLHttpRequests with specific url
+ * ```
+ * example.org#%#//scriptlet('trusted-replace-xhr-response', 'adb_detect:true', 'adb_detect:false', 'example.org')
+ * example.org#%#//scriptlet('trusted-replace-xhr-response', '/#EXT-X-VMAP-AD-BREAK[\s\S]*?/', '#EXT-X-ENDLIST', 'example.org')
+ * ```
+ *
+ * 3. Remove all text content of XMLHttpRequests with specific request method
+ * ```
+ * example.org#%#//scriptlet('trusted-replace-xhr-response', '*', '', 'method:GET')
+ * ```
+ *
+ * 4. Replace text content of XMLHttpRequests matching by URL regex and request methods
+ * ```
+ * example.org#%#//scriptlet('trusted-replace-xhr-response', '/#EXT-X-VMAP-AD-BREAK[\s\S]*?/', '#EXT-X-ENDLIST', '/\.m3u8/ method:/GET|HEAD/')
+ * ```
+ * 5. Remove all text content of all XMLHttpRequests for example.com
+ * ```
+ * example.org#%#//scriptlet('trusted-replace-xhr-response', '*', '', 'example.com')
+ * ```
+ */
+/* eslint-enable max-len */
+export function trustedReplaceXhrResponse(source, pattern = '', replacement = '', propsToMatch = '') {
+ // do nothing if browser does not support Proxy (e.g. Internet Explorer)
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
+ if (typeof Proxy === '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 nativeOpen = window.XMLHttpRequest.prototype.open;
+ const nativeSend = window.XMLHttpRequest.prototype.send;
+
+ let shouldReplace = false;
+ let xhrData;
+ let requestHeaders = [];
+
+ const openWrapper = (target, thisArg, args) => {
+ xhrData = getXhrData(...args);
+
+ if (shouldLog) {
+ // Log if no propsToMatch given
+ const logMessage = `log: xhr( ${objectToString(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) => {
+ // Collect headers
+ requestHeaders.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 = async (target, thisArg, args) => {
+ if (!shouldReplace) {
+ return Reflect.apply(target, thisArg, args);
+ }
+
+ /**
+ * 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;
+ }
+
+ const patternRegexp = pattern === getWildcardSymbol()
+ ? toRegExp()
+ : toRegExp(pattern);
+
+ const modifiedContent = content.replace(patternRegexp, replacement);
+
+ // 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, {
+ readyState: { value: readyState },
+ response: { value: modifiedContent },
+ responseText: { value: modifiedContent },
+ responseURL: { value: responseURL },
+ responseXML: { value: responseXML },
+ status: { value: status },
+ statusText: { value: statusText },
+ });
+
+ // 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
+ requestHeaders.forEach((header) => {
+ const name = header[0];
+ const value = header[1];
+
+ forgedRequest.setRequestHeader(name, value);
+ });
+ requestHeaders = [];
+
+ try {
+ nativeSend.call(forgedRequest, args);
+ } catch {
+ return Reflect.apply(target, thisArg, args);
+ }
+ return undefined;
+ };
+
+ const openHandler = {
+ apply: openWrapper,
+ };
+
+ const sendHandler = {
+ apply: sendWrapper,
+ };
+
+ XMLHttpRequest.prototype.open = new Proxy(XMLHttpRequest.prototype.open, openHandler);
+ XMLHttpRequest.prototype.send = new Proxy(XMLHttpRequest.prototype.send, sendHandler);
+}
+
+trustedReplaceXhrResponse.names = [
+ 'trusted-replace-xhr-response',
+ // trusted scriptlets support no aliases
+];
+
+trustedReplaceXhrResponse.injections = [
+ hit,
+ toRegExp,
+ objectToString,
+ getWildcardSymbol,
+ matchRequestProps,
+ getXhrData,
+ getMatchPropsData,
+ validateParsedData,
+ parseMatchProps,
+ isValidStrPattern,
+ escapeRegExp,
+ isEmptyObject,
+ getObjectEntries,
+];
diff --git a/src/scriptlets/trusted-set-cookie.js b/src/scriptlets/trusted-set-cookie.js
new file mode 100644
index 000000000..9e2f05a80
--- /dev/null
+++ b/src/scriptlets/trusted-set-cookie.js
@@ -0,0 +1,165 @@
+import {
+ hit,
+ nativeIsNaN,
+ isCookieSetWithValue,
+ concatCookieNameValuePath,
+ // following helpers should be imported and injected
+ // because they are used by helpers above
+ isValidCookieRawPath,
+ getCookiePath,
+} from '../helpers/index';
+
+/* eslint-disable max-len */
+/**
+ * @scriptlet trusted-set-cookie
+ *
+ * @description
+ * Sets a cookie with arbitrary name and value, with optional path
+ * and the ability to reload the page after cookie was set.
+ *
+ * **Syntax**
+ * ```
+ * example.org#%#//scriptlet('trusted-set-cookie', name, value[, offsetExpiresSec[, reload[, path]]])
+ * ```
+ *
+ * - `name` - required, cookie name to be set
+ * - `value` - required, cookie value. Possible values:
+ * - arbitrary value
+ * - empty string for no value
+ * - `$now$` keyword for setting current time
+ * - 'offsetExpiresSec' - optional, offset from current time in seconds, after which cookie should expire; defaults to no offset
+ * Possible values:
+ * - positive integer in seconds
+ * - `1year` keyword for setting expiration date to one year
+ * - `1day` keyword for setting expiration date to one day
+ * - 'reload' - optional, boolean. Argument for reloading page after cookie is set. Defaults to `false`
+ * - `path` - optional, argument for setting cookie path, defaults to `/`; possible values:
+ * - `/` — root path
+ * - `none` — to set no path at all
+ *
+ * **Examples**
+ * 1. Set cookie
+ * ```
+ * example.org#%#//scriptlet('trusted-set-cookie', 'cmpconsent', 'accept')
+ * example.org#%#//scriptlet('trusted-set-cookie', 'cmpconsent', '1-accept_1')
+ * ```
+ *
+ * 2. Set cookie with `new Date().getTime()` value
+ * ```
+ * example.org#%#//scriptlet('trusted-set-cookie', 'cmpconsent', '$now')
+ * ```
+ *
+ * 3. Set cookie which will expire in 3 days
+ * ```
+ * example.org#%#//scriptlet('trusted-set-cookie', 'cmpconsent', 'accept', '259200')
+ * ```
+ *
+ * 4. Set cookie which will expire in one year
+ * ```
+ * example.org#%#//scriptlet('trusted-set-cookie', 'cmpconsent', 'accept', '1year')
+ * ```
+ * 5. Reload the page if cookie was successfully set
+ * ```
+ * example.org#%#//scriptlet('trusted-set-cookie', 'cmpconsent', 'decline', '', 'true')
+ * ```
+ *
+ * 6. Set cookie with no path
+ * ```
+ * example.org#%#//scriptlet('trusted-set-cookie', 'cmpconsent', 'decline', '', '', 'none')
+ * ```
+ */
+/* eslint-enable max-len */
+
+export function trustedSetCookie(source, name, value, offsetExpiresSec = '', reload = 'false', path = '/') {
+ // eslint-disable-next-line no-console
+ const log = console.log.bind(console);
+
+ if (typeof name === 'undefined') {
+ log('Cookie name should be specified.');
+ return;
+ }
+
+ if (typeof value === 'undefined') {
+ log('Cookie value should be specified.');
+ return;
+ }
+
+ // Prevent infinite reloads if cookie was already set or blocked by the browser
+ // https://github.com/AdguardTeam/Scriptlets/issues/212
+ if (reload === 'true' && isCookieSetWithValue(document.cookie, name, value)) {
+ return;
+ }
+
+ const NOW_VALUE_KEYWORD = '$now$';
+ const ONE_YEAR_EXPIRATION_KEYWORD = '1year';
+ const ONE_DAY_EXPIRATION_KEYWORD = '1day';
+
+ let parsedValue;
+
+ if (value === NOW_VALUE_KEYWORD) {
+ // Set cookie value to current time if corresponding keyword was passed
+ const date = new Date();
+ const currentTime = date.getTime();
+
+ parsedValue = currentTime.toString();
+ } else {
+ parsedValue = value;
+ }
+
+ let cookieToSet = concatCookieNameValuePath(name, parsedValue, path);
+ if (!cookieToSet) {
+ return;
+ }
+
+ // Set expiration date if offsetExpiresSec was passed
+ if (offsetExpiresSec) {
+ const MS_IN_SEC = 1000;
+ const SECONDS_IN_YEAR = 365 * 24 * 60 * 60;
+ const SECONDS_IN_DAY = 24 * 60 * 60;
+
+ let parsedOffsetExpiresSec;
+
+ // Set predefined expire value if corresponding keyword was passed
+ if (offsetExpiresSec === ONE_YEAR_EXPIRATION_KEYWORD) {
+ parsedOffsetExpiresSec = SECONDS_IN_YEAR;
+ } else if (offsetExpiresSec === ONE_DAY_EXPIRATION_KEYWORD) {
+ parsedOffsetExpiresSec = SECONDS_IN_DAY;
+ } else {
+ parsedOffsetExpiresSec = Number.parseInt(offsetExpiresSec, 10);
+
+ // If offsetExpiresSec has been parsed to NaN - do not set cookie at all
+ if (Number.isNaN(parsedOffsetExpiresSec)) {
+ log(`log: Invalid offsetExpiresSec value: ${offsetExpiresSec}`);
+ return;
+ }
+ }
+
+ const expires = Date.now() + parsedOffsetExpiresSec * MS_IN_SEC;
+ cookieToSet += ` expires=${new Date(expires).toUTCString()};`;
+ }
+
+ if (cookieToSet) {
+ document.cookie = cookieToSet;
+ hit(source);
+
+ // Only reload the page if cookie was set
+ // https://github.com/AdguardTeam/Scriptlets/issues/212
+ if (reload === 'true' && isCookieSetWithValue(document.cookie, name, value)) {
+ window.location.reload();
+ }
+ }
+}
+
+trustedSetCookie.names = [
+ 'trusted-set-cookie',
+ // trusted scriptlets support no aliases
+];
+
+trustedSetCookie.injections = [
+ hit,
+ nativeIsNaN,
+ isCookieSetWithValue,
+ concatCookieNameValuePath,
+ isValidCookieRawPath,
+ getCookiePath,
+];
diff --git a/src/scriptlets/xml-prune.js b/src/scriptlets/xml-prune.js
new file mode 100644
index 000000000..c1de27b30
--- /dev/null
+++ b/src/scriptlets/xml-prune.js
@@ -0,0 +1,217 @@
+import {
+ hit,
+ toRegExp,
+ startsWith,
+ endsWith,
+} from '../helpers/index';
+
+/* eslint-disable max-len */
+/**
+ * @scriptlet xml-prune
+ *
+ * @description
+ * Removes an element from the specified XML.
+ *
+ *
+ * **Syntax**
+ * ```
+ * example.org#%#//scriptlet('xml-prune'[, propsToMatch[, optionalProp[, urlToMatch]]])
+ * ```
+ *
+ * - `propsToMatch` - optional, selector of elements which will be removed from XML
+ * - `optionalProp` - optional, selector of elements that must occur in XML document
+ * - `urlToMatch` - optional, string or regular expression for matching the request's URL
+ * > Usage with no arguments will log response payload and URL to browser console;
+ * which is useful for debugging but prohibited for production filter lists.
+ *
+ * **Examples**
+ * 1. Remove `Period` tag whose `id` contains `-ad-` from all requests
+ * ```
+ * example.org#%#//scriptlet('xml-prune', 'Period[id*="-ad-"]')
+ * ```
+ *
+ * 2. Remove `Period` tag whose `id` contains `-ad-`, only if XML contains `SegmentTemplate`
+ * ```
+ * example.org#%#//scriptlet('xml-prune', 'Period[id*="-ad-"]', 'SegmentTemplate')
+ * ```
+ *
+ * 3. Remove `Period` tag whose `id` contains `-ad-`, only if request's URL contains `.mpd`
+ * ```
+ * example.org#%#//scriptlet('xml-prune', 'Period[id*="-ad-"]', '', '.mpd')
+ * ```
+ *
+ * 4. Call with no arguments will log response payload and URL at the console
+ * ```
+ * example.org#%#//scriptlet('xml-prune')
+ * ```
+ *
+ * 5. Call with only `urlToMatch` argument will log response payload and URL only for the matched URL
+ * ```
+ * example.org#%#//scriptlet('xml-prune', '', '', '.mpd')
+ * ```
+ */
+/* eslint-enable max-len */
+
+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
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect
+ if (typeof Reflect === 'undefined'
+ || typeof fetch === 'undefined'
+ || typeof Proxy === 'undefined'
+ || typeof Response === 'undefined') {
+ return;
+ }
+
+ let shouldPruneResponse = true;
+ // eslint-disable-next-line no-console
+ const log = console.log.bind(console);
+ if (!propsToRemove) {
+ // If "propsToRemove" is not defined, then response shouldn't be pruned
+ // but it should be logged in browser console
+ shouldPruneResponse = false;
+ }
+
+ const urlMatchRegexp = toRegExp(urlToMatch);
+
+ const isXML = (text) => {
+ // Check if "text" starts with "<" and check if it ends with ">"
+ // If so, then it might be an XML file and should be pruned or logged
+ const trimedText = text.trim();
+ if (startsWith(trimedText, '<') && endsWith(trimedText, '>')) {
+ return true;
+ }
+ return false;
+ };
+
+ const pruneXML = (text) => {
+ if (!isXML(text)) {
+ shouldPruneResponse = false;
+ return text;
+ }
+ const xmlParser = new DOMParser();
+ const xmlDoc = xmlParser.parseFromString(text, 'text/xml');
+ const errorNode = xmlDoc.querySelector('parsererror');
+ if (errorNode) {
+ return text;
+ }
+ if (optionalProp !== '' && xmlDoc.querySelector(optionalProp) === null) {
+ shouldPruneResponse = false;
+ return text;
+ }
+ const elems = xmlDoc.querySelectorAll(propsToRemove);
+ if (!elems.length) {
+ shouldPruneResponse = false;
+ return text;
+ }
+ elems.forEach((elem) => {
+ elem.remove();
+ });
+ const serializer = new XMLSerializer();
+ text = serializer.serializeToString(xmlDoc);
+ return text;
+ };
+
+ const xhrWrapper = (target, thisArg, args) => {
+ const xhrURL = args[1];
+ if (typeof xhrURL !== 'string' || xhrURL.length === 0) {
+ 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)) {
+ log(`XMLHttpRequest.open() URL: ${xhrURL}\nresponse: ${response}`);
+ }
+ } 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;
+ }
+ }
+ });
+ }
+ return Reflect.apply(target, thisArg, args);
+ };
+
+ const xhrHandler = {
+ apply: xhrWrapper,
+ };
+ // eslint-disable-next-line max-len
+ window.XMLHttpRequest.prototype.open = new Proxy(window.XMLHttpRequest.prototype.open, xhrHandler);
+
+ // eslint-disable-next-line compat/compat
+ const nativeFetch = window.fetch;
+
+ const fetchWrapper = (target, thisArg, args) => {
+ const fetchURL = 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)) {
+ log(`fetch URL: ${fetchURL}\nresponse text: ${text}`);
+ }
+ 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,
+ });
+ }
+ // 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;
+ return Reflect.apply(target, thisArg, args);
+ });
+ });
+ }
+ return Reflect.apply(target, thisArg, args);
+ };
+
+ const fetchHandler = {
+ apply: fetchWrapper,
+ };
+ // eslint-disable-next-line compat/compat
+ window.fetch = new Proxy(window.fetch, fetchHandler);
+}
+
+xmlPrune.names = [
+ 'xml-prune',
+ // aliases are needed for matching the related scriptlet converted into our syntax
+ 'xml-prune.js',
+ 'ubo-xml-prune.js',
+ 'ubo-xml-prune',
+];
+
+xmlPrune.injections = [
+ hit,
+ toRegExp,
+ startsWith,
+ endsWith,
+];
diff --git a/tests/helpers.js b/tests/helpers.js
index 8ed45f9f0..bfda76242 100644
--- a/tests/helpers.js
+++ b/tests/helpers.js
@@ -59,4 +59,12 @@ export const runRedirect = (name, verbose = true) => {
evalWrapper(resultString);
};
+/**
+ * Clear cookie by name
+ * @param {string} cName
+ */
+export const clearCookie = (cName) => {
+ document.cookie = `${cName}=; max-age=0`;
+};
+
export const isSafariBrowser = () => navigator.vendor === 'Apple Computer, Inc.';
diff --git a/tests/helpers/fetch-utils.test.js b/tests/helpers/fetch-utils.test.js
new file mode 100644
index 000000000..03440fd39
--- /dev/null
+++ b/tests/helpers/fetch-utils.test.js
@@ -0,0 +1,87 @@
+import { parseMatchProps } from '../../src/helpers';
+
+const { test, module } = QUnit;
+const name = 'scriptlets-redirects helpers';
+
+module(name);
+
+const GET_METHOD = 'GET';
+const METHOD_PROP = 'method';
+const URL_PROP = 'url';
+
+const URL1 = 'example.com';
+const URL2 = 'http://example.com';
+const URL3 = '/^https?://example.org/';
+const URL4 = '/^https?://example.org/section#user:45/comments/';
+
+test('Test parseMatchProps with different url props, simple input', (assert) => {
+ assert.strictEqual(parseMatchProps(URL1).url, URL1, 'No url match prop, no protocol, not regexp');
+ assert.strictEqual(parseMatchProps(`url:${URL1}`).url, URL1, 'url match prop, no protocol, not regexp');
+
+ assert.strictEqual(parseMatchProps(URL2).url, URL2, 'No url match prop, has protocol, not regexp');
+ assert.strictEqual(parseMatchProps(`url:${URL2}`).url, URL2, 'url match prop, has protocol, not regexp');
+
+ assert.strictEqual(parseMatchProps(URL3).url, URL3, 'No url match prop, has protocol, regexp');
+ assert.strictEqual(parseMatchProps(`url:${URL3}`).url, URL3, 'url match prop, has protocol, regexp');
+
+ assert.strictEqual(parseMatchProps(URL4).url, URL4, 'No url match prop, has protocol, regexp, extra colon in url');
+ assert.strictEqual(parseMatchProps(`url:${URL4}`).url, URL4, 'url match prop, has protocol, extra colon in url');
+});
+
+test('Test parseMatchProps with different url props, mixed input', (assert) => {
+ const INPUT1 = `${URL1} ${METHOD_PROP}:${GET_METHOD}`;
+ const expected1 = {
+ url: URL1,
+ [METHOD_PROP]: GET_METHOD,
+ };
+ assert.deepEqual(parseMatchProps(INPUT1), expected1, 'No url match prop, no protocol, not regexp');
+
+ const INPUT1_PREFIXED = `${URL_PROP}:${URL1} ${METHOD_PROP}:${GET_METHOD}`;
+ const expectedPrefixed1 = {
+ url: URL1,
+ [METHOD_PROP]: GET_METHOD,
+ };
+ assert.deepEqual(parseMatchProps(INPUT1_PREFIXED), expectedPrefixed1, 'Has url match prop, no protocol, not regexp');
+
+ const INPUT2 = `${URL2} ${METHOD_PROP}:${GET_METHOD}`;
+ const expected2 = {
+ url: URL2,
+ [METHOD_PROP]: GET_METHOD,
+ };
+ assert.deepEqual(parseMatchProps(INPUT2), expected2, 'No url match prop, has protocol, not regexp');
+
+ const INPUT2_PREFIXED = `${URL_PROP}:${URL2} ${METHOD_PROP}:${GET_METHOD}`;
+ const expectedPrefixed2 = {
+ url: URL2,
+ [METHOD_PROP]: GET_METHOD,
+ };
+ assert.deepEqual(parseMatchProps(INPUT2_PREFIXED), expectedPrefixed2, 'Has url match prop, has protocol, not regexp');
+
+ const INPUT3 = `${URL3} ${METHOD_PROP}:${GET_METHOD}`;
+ const expected3 = {
+ url: URL3,
+ [METHOD_PROP]: GET_METHOD,
+ };
+ assert.deepEqual(parseMatchProps(INPUT3), expected3, 'No url match prop, has protocol, regexp');
+
+ const INPUT3_PREFIXED = `${URL_PROP}:${URL3} ${METHOD_PROP}:${GET_METHOD}`;
+ const expectedPrefixed3 = {
+ url: URL3,
+ [METHOD_PROP]: GET_METHOD,
+ };
+ assert.deepEqual(parseMatchProps(INPUT3_PREFIXED), expectedPrefixed3, 'Has url match prop, has protocol, regexp');
+
+ const INPUT4 = `${URL4} ${METHOD_PROP}:${GET_METHOD}`;
+ const expected4 = {
+ url: URL4,
+ [METHOD_PROP]: GET_METHOD,
+ };
+ assert.deepEqual(parseMatchProps(INPUT4), expected4, 'No url match prop, has protocol, regexp, extra colon in url');
+
+ const INPUT4_PREFIXED = `${URL_PROP}:${URL4} ${METHOD_PROP}:${GET_METHOD}`;
+ const expectedPrefixed4 = {
+ url: URL4,
+ [METHOD_PROP]: GET_METHOD,
+ };
+ assert.deepEqual(parseMatchProps(INPUT4_PREFIXED), expectedPrefixed4, 'Has url match prop, has protocol, regexp, extra colon in url');
+});
diff --git a/tests/helpers/index.test.js b/tests/helpers/index.test.js
index bd992d1d5..d5adb43a6 100644
--- a/tests/helpers/index.test.js
+++ b/tests/helpers/index.test.js
@@ -1,110 +1,5 @@
-import {
- toRegExp,
- getNumberFromString,
- noopPromiseResolve,
- matchStackTrace,
-} from '../../src/helpers';
-
-const { test, module } = QUnit;
-const name = 'scriptlets-redirects helpers';
-
-module(name);
-
-test('Test toRegExp for valid inputs', (assert) => {
- const DEFAULT_VALUE = '.?';
- const defaultRegexp = new RegExp(DEFAULT_VALUE);
- let inputStr;
- let expRegex;
-
- inputStr = '/abc/';
- expRegex = /abc/;
- assert.deepEqual(toRegExp(inputStr), expRegex);
-
- inputStr = '/[a-z]{1,9}/';
- expRegex = /[a-z]{1,9}/;
- assert.deepEqual(toRegExp(inputStr), expRegex);
-
- inputStr = '';
- assert.deepEqual(toRegExp(inputStr), defaultRegexp);
-});
-
-test('Test toRegExp for invalid inputs', (assert) => {
- let inputStr;
-
- assert.throws(() => {
- inputStr = '/\\/';
- toRegExp(inputStr);
- });
-
- assert.throws(() => {
- inputStr = '/[/';
- toRegExp(inputStr);
- });
-
- assert.throws(() => {
- inputStr = '/*/';
- toRegExp(inputStr);
- });
-
- assert.throws(() => {
- inputStr = '/[0-9]++/';
- toRegExp(inputStr);
- });
-});
-
-test('Test getNumberFromString for all data types inputs', (assert) => {
- let inputValue;
-
- // Boolean
- inputValue = true;
- assert.strictEqual(getNumberFromString(inputValue), null);
-
- // null
- inputValue = null;
- assert.strictEqual(getNumberFromString(inputValue), null);
-
- // undefined
- inputValue = undefined;
- assert.strictEqual(getNumberFromString(inputValue), null);
-
- // undefined
- inputValue = undefined;
- assert.strictEqual(getNumberFromString(inputValue), null);
-
- // number
- inputValue = 123;
- assert.strictEqual(getNumberFromString(inputValue), 123);
-
- // valid string
- inputValue = '123parsable';
- assert.strictEqual(getNumberFromString(inputValue), 123);
-
- // invalid string
- inputValue = 'not parsable 123';
- assert.strictEqual(getNumberFromString(inputValue), null);
-
- // object
- inputValue = { test: 'test' };
- assert.strictEqual(getNumberFromString(inputValue), null);
-
- // array
- inputValue = ['test'];
- assert.strictEqual(getNumberFromString(inputValue), null);
-});
-
-test('Test noopPromiseResolve for valid response.body values', async (assert) => {
- const objResponse = await noopPromiseResolve('{}');
- const objBody = await objResponse.json();
-
- const arrResponse = await noopPromiseResolve('[]');
- const arrBody = await arrResponse.json();
-
- assert.ok(typeof objBody === 'object' && !objBody.length);
- assert.ok(Array.isArray(arrBody) && !arrBody.length);
-});
-
-test('Test matchStackTrace for working with getNativeRegexpTest helper', async (assert) => {
- const match = matchStackTrace('stack', new Error().stack);
-
- assert.ok(!match);
-});
+import './fetch-utils.test';
+import './match-stack.test';
+import './noop.test';
+import './number-utils.test';
+import './string-utils.test';
diff --git a/tests/helpers/match-stack.test.js b/tests/helpers/match-stack.test.js
new file mode 100644
index 000000000..ebd90405d
--- /dev/null
+++ b/tests/helpers/match-stack.test.js
@@ -0,0 +1,12 @@
+import { matchStackTrace } from '../../src/helpers';
+
+const { test, module } = QUnit;
+const name = 'scriptlets-redirects helpers';
+
+module(name);
+
+test('Test matchStackTrace for working with getNativeRegexpTest helper', async (assert) => {
+ const match = matchStackTrace('stack', new Error().stack);
+
+ assert.ok(!match);
+});
diff --git a/tests/helpers/noop.test.js b/tests/helpers/noop.test.js
new file mode 100644
index 000000000..510386fad
--- /dev/null
+++ b/tests/helpers/noop.test.js
@@ -0,0 +1,24 @@
+import { noopPromiseResolve } from '../../src/helpers';
+
+const { test, module } = QUnit;
+const name = 'scriptlets-redirects helpers';
+
+module(name);
+
+test('Test noopPromiseResolve for valid response props', async (assert) => {
+ const TEST_URL = 'url';
+ const TEST_TYPE = 'opaque';
+ const objResponse = await noopPromiseResolve('{}');
+ const objBody = await objResponse.json();
+
+ const arrResponse = await noopPromiseResolve('[]');
+ const arrBody = await arrResponse.json();
+
+ const responseWithUrl = await noopPromiseResolve('{}', TEST_URL);
+ const responseWithType = await noopPromiseResolve('{}', '', TEST_TYPE);
+
+ assert.ok(responseWithUrl.url === TEST_URL);
+ assert.ok(typeof objBody === 'object' && !objBody.length);
+ assert.ok(Array.isArray(arrBody) && !arrBody.length);
+ assert.strictEqual(responseWithType.type, TEST_TYPE);
+});
diff --git a/tests/helpers/number-utils.test.js b/tests/helpers/number-utils.test.js
new file mode 100644
index 000000000..eb6e6f242
--- /dev/null
+++ b/tests/helpers/number-utils.test.js
@@ -0,0 +1,86 @@
+import { getNumberFromString } from '../../src/helpers';
+
+const { test, module } = QUnit;
+const name = 'scriptlets-redirects helpers';
+
+module(name);
+
+test('Test getNumberFromString for all data types inputs', (assert) => {
+ let inputValue;
+
+ // Boolean
+ inputValue = true;
+ assert.strictEqual(getNumberFromString(inputValue), null);
+
+ // null
+ inputValue = null;
+ assert.strictEqual(getNumberFromString(inputValue), null);
+
+ // undefined
+ inputValue = undefined;
+ assert.strictEqual(getNumberFromString(inputValue), null);
+
+ // undefined
+ inputValue = undefined;
+ assert.strictEqual(getNumberFromString(inputValue), null);
+
+ // number
+ inputValue = 123;
+ assert.strictEqual(getNumberFromString(inputValue), 123);
+
+ // valid string
+ inputValue = '123parsable';
+ assert.strictEqual(getNumberFromString(inputValue), 123);
+
+ // invalid string
+ inputValue = 'not parsable 123';
+ assert.strictEqual(getNumberFromString(inputValue), null);
+
+ // object
+ inputValue = { test: 'test' };
+ assert.strictEqual(getNumberFromString(inputValue), null);
+
+ // array
+ inputValue = ['test'];
+ assert.strictEqual(getNumberFromString(inputValue), null);
+});
+
+test('Test getNumberFromString for all data types inputs', (assert) => {
+ let inputValue;
+
+ // Boolean
+ inputValue = true;
+ assert.strictEqual(getNumberFromString(inputValue), null);
+
+ // null
+ inputValue = null;
+ assert.strictEqual(getNumberFromString(inputValue), null);
+
+ // undefined
+ inputValue = undefined;
+ assert.strictEqual(getNumberFromString(inputValue), null);
+
+ // undefined
+ inputValue = undefined;
+ assert.strictEqual(getNumberFromString(inputValue), null);
+
+ // number
+ inputValue = 123;
+ assert.strictEqual(getNumberFromString(inputValue), 123);
+
+ // valid string
+ inputValue = '123parsable';
+ assert.strictEqual(getNumberFromString(inputValue), 123);
+
+ // invalid string
+ inputValue = 'not parsable 123';
+ assert.strictEqual(getNumberFromString(inputValue), null);
+
+ // object
+ inputValue = { test: 'test' };
+ assert.strictEqual(getNumberFromString(inputValue), null);
+
+ // array
+ inputValue = ['test'];
+ assert.strictEqual(getNumberFromString(inputValue), null);
+});
diff --git a/tests/helpers/string-utils.test.js b/tests/helpers/string-utils.test.js
new file mode 100644
index 000000000..50114b75e
--- /dev/null
+++ b/tests/helpers/string-utils.test.js
@@ -0,0 +1,48 @@
+import { toRegExp } from '../../src/helpers';
+
+const { test, module } = QUnit;
+const name = 'scriptlets-redirects helpers';
+
+module(name);
+
+test('Test toRegExp for valid inputs', (assert) => {
+ const DEFAULT_VALUE = '.?';
+ const defaultRegexp = new RegExp(DEFAULT_VALUE);
+ let inputStr;
+ let expRegex;
+
+ inputStr = '/abc/';
+ expRegex = /abc/;
+ assert.deepEqual(toRegExp(inputStr), expRegex);
+
+ inputStr = '/[a-z]{1,9}/';
+ expRegex = /[a-z]{1,9}/;
+ assert.deepEqual(toRegExp(inputStr), expRegex);
+
+ inputStr = '';
+ assert.deepEqual(toRegExp(inputStr), defaultRegexp);
+});
+
+test('Test toRegExp for invalid inputs', (assert) => {
+ let inputStr;
+
+ assert.throws(() => {
+ inputStr = '/\\/';
+ toRegExp(inputStr);
+ });
+
+ assert.throws(() => {
+ inputStr = '/[/';
+ toRegExp(inputStr);
+ });
+
+ assert.throws(() => {
+ inputStr = '/*/';
+ toRegExp(inputStr);
+ });
+
+ assert.throws(() => {
+ inputStr = '/[0-9]++/';
+ toRegExp(inputStr);
+ });
+});
diff --git a/tests/redirects/google-ima3.test.js b/tests/redirects/google-ima3.test.js
index 9b62a0833..12971cb2e 100644
--- a/tests/redirects/google-ima3.test.js
+++ b/tests/redirects/google-ima3.test.js
@@ -43,3 +43,29 @@ test('Ima mocked', (assert) => {
assert.strictEqual(adError.adsRequest, 'adsRequest', 'AdError adsRequest saved');
assert.strictEqual(adError.userRequestContext, 'userRequestContext', 'AdError request context saved');
});
+
+test('Ima - run requestAds function twice', (assert) => {
+ // Test for https://github.com/AdguardTeam/Scriptlets/issues/255
+ const done = assert.async();
+
+ runRedirect(name);
+
+ let number = 0;
+ const test = () => {
+ number += 1;
+ };
+ const { ima } = window.google;
+ const AdsLoader = new ima.AdsLoader();
+ AdsLoader.addEventListener(ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, test);
+
+ AdsLoader.requestAds();
+ requestAnimationFrame(() => {
+ assert.strictEqual(number, 1, 'number is equal to 1');
+ });
+
+ AdsLoader.requestAds();
+ requestAnimationFrame(() => {
+ assert.strictEqual(number, 2, 'number is equal to 2');
+ done();
+ });
+});
diff --git a/tests/redirects/googlesyndication-adsbygoogle.test.js b/tests/redirects/googlesyndication-adsbygoogle.test.js
index 0a10fcdf0..72f6dc2f9 100644
--- a/tests/redirects/googlesyndication-adsbygoogle.test.js
+++ b/tests/redirects/googlesyndication-adsbygoogle.test.js
@@ -69,8 +69,17 @@ test('Redirect testing', (assert) => {
assert.strictEqual(window.adsbygoogle.length, undefined, 'adsbygoogle.length check');
assert.strictEqual(window.adsbygoogle.push.length, 1, 'push.length check');
- const pushCallback = () => {
- assert.ok(true, 'callback was called');
+ const pushCallback = (arg) => {
+ try {
+ // Test for https://github.com/AdguardTeam/Scriptlets/issues/252
+ // If arg is not defined then error will be thrown
+ if (arg.whatever) {
+ arg.whatever = 1;
+ }
+ assert.ok(typeof arg !== 'undefined', 'arg is defined');
+ } catch (error) {
+ assert.ok(false, 'something went wrong');
+ }
};
const pushArg = {
test: 'test',
diff --git a/tests/scriptlets/abort-current-inline-script.test.js b/tests/scriptlets/abort-current-inline-script.test.js
index 05d160534..71199fe75 100644
--- a/tests/scriptlets/abort-current-inline-script.test.js
+++ b/tests/scriptlets/abort-current-inline-script.test.js
@@ -20,9 +20,9 @@ module(name, { beforeEach, afterEach });
const onError = (assert) => (message) => {
const browserErrorMessage = 'Script error.';
- const nodePuppeteerErrorMessageRgx = /Reference error/g;
+ const nodePuppeteerErrorMessageRgx = /Reference error|ReferenceError/g;
const checkResult = message === browserErrorMessage
- || message.test(nodePuppeteerErrorMessageRgx);
+ || nodePuppeteerErrorMessageRgx.test(message);
assert.ok(checkResult);
};
@@ -246,3 +246,24 @@ test('Protected from infinite loop when prop is used in a helper', (assert) => {
assert.strictEqual(window.hit, undefined, 'hit should NOT fire');
});
+
+test('searches script by regexp - abort few inline scripts', (assert) => {
+ window.onerror = onError(assert);
+ window.shouldBeAborted = true;
+ window.shouldNotBeAborted = false;
+ const property = 'console.log';
+ const shouldBeAborted = 'shouldBeAborted';
+ const shouldNotBeAborted = 'shouldNotBeAborted';
+ const search = '/test|abcd|1234|qwerty/';
+ const scriptletArgs = [property, search];
+ runScriptlet(name, scriptletArgs);
+ addAndRemoveInlineScript(`window.${property}('test'); window.${shouldBeAborted} = false;`);
+ addAndRemoveInlineScript(`window.${property}('abcd'); window.${shouldBeAborted} = false;`);
+ addAndRemoveInlineScript(`window.${property}('1234'); window.${shouldBeAborted} = false;`);
+ addAndRemoveInlineScript(`window.${property}('should not be aborted'); window.${shouldNotBeAborted} = true;`);
+ addAndRemoveInlineScript(`window.${property}('qwerty'); window.${shouldBeAborted} = false;`);
+
+ assert.strictEqual(window.shouldBeAborted, true, 'initial value of shouldBeAborted has not changed');
+ assert.strictEqual(window.shouldNotBeAborted, true, 'value of shouldBeAborted has been changed from false to true');
+ assert.strictEqual(window.hit, 'FIRED', 'hit fired');
+});
diff --git a/tests/scriptlets/index.test.js b/tests/scriptlets/index.test.js
index 0be3c1cb6..d45c9f8d3 100644
--- a/tests/scriptlets/index.test.js
+++ b/tests/scriptlets/index.test.js
@@ -44,3 +44,8 @@ import './close-window.test';
import './prevent-refresh.test';
import './prevent-element-src-loading.test';
import './no-topics.test';
+import './trusted-replace-xhr-response.test';
+import './xml-prune.test';
+import './trusted-click-element.test';
+import './trusted-set-cookie.test';
+import './trusted-replace-fetch-response.test';
diff --git a/tests/scriptlets/prevent-fetch.test.js b/tests/scriptlets/prevent-fetch.test.js
index cc4935230..4233d71df 100644
--- a/tests/scriptlets/prevent-fetch.test.js
+++ b/tests/scriptlets/prevent-fetch.test.js
@@ -287,4 +287,48 @@ if (!isSupported) {
assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
done();
});
+
+ test('simple fetch - valid response type', async (assert) => {
+ const OPAQUE_RESPONSE_TYPE = 'opaque';
+ const INPUT_JSON_PATH = `${FETCH_OBJECTS_PATH}/test01.json`;
+ const init = {
+ method: 'GET',
+ };
+
+ runScriptlet(name, ['*', '', OPAQUE_RESPONSE_TYPE]);
+ const done = assert.async();
+
+ const response = await fetch(INPUT_JSON_PATH, init);
+
+ assert.strictEqual(response.type, OPAQUE_RESPONSE_TYPE, 'Response type is set');
+ assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
+ done();
+ });
+
+ test('simple fetch - invalid response type', async (assert) => {
+ const INVALID_RESPONSE_TYPE = 'invalid_type';
+ const BASIC_RESPONSE_TYPE = 'basic';
+ const INPUT_JSON_PATH = `${FETCH_OBJECTS_PATH}/test01.json`;
+ const init = {
+ method: 'GET',
+ };
+
+ const expectedJson = {
+ a1: 1,
+ b2: 'test',
+ c3: 3,
+ };
+
+ runScriptlet(name, ['*', '', INVALID_RESPONSE_TYPE]);
+ const done = assert.async();
+
+ const response = await fetch(INPUT_JSON_PATH, init);
+ const actualJson = await response.json();
+
+ assert.deepEqual(actualJson, expectedJson, 'Request is not modified');
+
+ assert.strictEqual(response.type, BASIC_RESPONSE_TYPE, 'Response type is not modified');
+ assert.strictEqual(window.hit, undefined, 'hit function fired');
+ done();
+ });
}
diff --git a/tests/scriptlets/set-cookie-reload.test.js b/tests/scriptlets/set-cookie-reload.test.js
index 0f6dd04a8..6075424d3 100644
--- a/tests/scriptlets/set-cookie-reload.test.js
+++ b/tests/scriptlets/set-cookie-reload.test.js
@@ -1,5 +1,9 @@
/* eslint-disable no-underscore-dangle */
-import { runScriptlet, clearGlobalProps } from '../helpers';
+import {
+ runScriptlet,
+ clearGlobalProps,
+ clearCookie,
+} from '../helpers';
const { test, module } = QUnit;
const name = 'set-cookie-reload';
@@ -14,10 +18,6 @@ const afterEach = () => {
clearGlobalProps('hit', '__debug');
};
-const clearCookie = (cName) => {
- document.cookie = `${cName}=; max-age=0`;
-};
-
module(name, { beforeEach, afterEach });
test('Set cookie with valid value', (assert) => {
diff --git a/tests/scriptlets/set-cookie.test.js b/tests/scriptlets/set-cookie.test.js
index 869e70cf9..31c4338bd 100644
--- a/tests/scriptlets/set-cookie.test.js
+++ b/tests/scriptlets/set-cookie.test.js
@@ -1,5 +1,9 @@
/* eslint-disable no-underscore-dangle */
-import { runScriptlet, clearGlobalProps } from '../helpers';
+import {
+ runScriptlet,
+ clearGlobalProps,
+ clearCookie,
+} from '../helpers';
const { test, module } = QUnit;
const name = 'set-cookie';
@@ -16,10 +20,6 @@ const afterEach = () => {
module(name, { beforeEach, afterEach });
-const clearCookie = (cName) => {
- document.cookie = `${cName}=; max-age=0`;
-};
-
test('Set cookie with valid value', (assert) => {
let cName = '__test-cookie_OK';
let cValue = 'OK';
diff --git a/tests/scriptlets/test-files/manifestMPD.mpd b/tests/scriptlets/test-files/manifestMPD.mpd
new file mode 100644
index 000000000..68aa64c84
--- /dev/null
+++ b/tests/scriptlets/test-files/manifestMPD.mpd
@@ -0,0 +1,32 @@
+
+
+ https://vod-gcs-cedexis.cbsaavideo.com/intl_vms/2017/02/17/879659075884/609941_cenc_precon_dash/
+
+ https://dai.google.com/segments/redirect/c/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/scriptlets/trusted-click-element.test.js b/tests/scriptlets/trusted-click-element.test.js
new file mode 100644
index 000000000..663f60625
--- /dev/null
+++ b/tests/scriptlets/trusted-click-element.test.js
@@ -0,0 +1,335 @@
+/* eslint-disable no-underscore-dangle, no-console */
+import { runScriptlet, clearGlobalProps } from '../helpers';
+import { concatCookieNameValuePath } from '../../src/helpers';
+
+const { test, module } = QUnit;
+const name = 'trusted-click-element';
+
+const PANEL_ID = 'panel';
+const CLICKABLE_NAME = 'clickable';
+const SELECTORS_DELIMITER = ',';
+
+// Generate selectors for each clickable element
+const createSelectorsString = (clickOrder) => {
+ const selectors = clickOrder.map((elemNum) => `#${PANEL_ID} > #${CLICKABLE_NAME}${elemNum}`);
+ return selectors.join(SELECTORS_DELIMITER);
+};
+
+// Create clickable element with it's count as id and assertion as onclick
+const createClickable = (elementNum) => {
+ const clickableId = `${CLICKABLE_NAME}${elementNum}`;
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.id = clickableId;
+ checkbox.onclick = (e) => {
+ e.currentTarget.setAttribute('clicked', true);
+ window.clickOrder.push(elementNum);
+ };
+ return checkbox;
+};
+
+const createPanel = () => {
+ const panel = document.createElement('div');
+ panel.id = PANEL_ID;
+ document.body.appendChild(panel);
+ return panel;
+};
+
+const removePanel = () => document.getElementById('panel').remove();
+
+const clearCookie = (cName) => {
+ document.cookie = `${cName}=; max-age=0`;
+};
+
+const beforeEach = () => {
+ window.__debug = () => {
+ window.hit = 'FIRED';
+ };
+ window.clickOrder = [];
+};
+
+const afterEach = () => {
+ removePanel();
+ clearGlobalProps('hit', '__debug', 'clickOrder');
+};
+
+module(name, { beforeEach, afterEach });
+
+test('Single element clicked', (assert) => {
+ const ELEM_COUNT = 1;
+ // Check elements for being clicked and hit func execution
+ const ASSERTIONS = ELEM_COUNT + 1;
+ assert.expect(ASSERTIONS);
+ const done = assert.async();
+
+ const selectorsString = `#${PANEL_ID} > #${CLICKABLE_NAME}${ELEM_COUNT}`;
+
+ runScriptlet(name, [selectorsString]);
+ const panel = createPanel();
+ const clickable = createClickable(1);
+ panel.appendChild(clickable);
+
+ setTimeout(() => {
+ assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked');
+ assert.strictEqual(window.hit, 'FIRED', 'hit func executed');
+ done();
+ }, 150);
+});
+
+test('Single element clicked, delay is set', (assert) => {
+ const ELEM_COUNT = 1;
+ const DELAY = 300;
+ // Check elements for being clicked and hit func execution
+ const ASSERTIONS = (ELEM_COUNT + 1) * 2;
+ assert.expect(ASSERTIONS);
+ const done = assert.async();
+ const done2 = assert.async();
+ const selectorsString = `#${PANEL_ID} > #${CLICKABLE_NAME}${ELEM_COUNT}`;
+
+ runScriptlet(name, [selectorsString, '', DELAY]);
+ const panel = createPanel();
+ const clickable = createClickable(1);
+ panel.appendChild(clickable);
+
+ setTimeout(() => {
+ assert.notOk(clickable.getAttribute('clicked'), 'Element should not be clicked before delay');
+ assert.strictEqual(window.hit, undefined, 'hit should not fire before delay');
+ done();
+ }, 200);
+
+ setTimeout(() => {
+ assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked after delay');
+ assert.strictEqual(window.hit, 'FIRED', 'hit func executed after delay');
+ done2();
+ }, 400);
+});
+
+test('Multiple elements clicked', (assert) => {
+ const CLICK_ORDER = [1, 2, 3];
+ // Assert elements for being clicked, hit func execution & click order
+ const ASSERTIONS = CLICK_ORDER.length + 2;
+ assert.expect(ASSERTIONS);
+ const done = assert.async();
+
+ const selectorsString = createSelectorsString(CLICK_ORDER);
+
+ runScriptlet(name, [selectorsString]);
+ const panel = createPanel();
+ const clickables = [];
+ CLICK_ORDER.forEach((number) => {
+ const clickable = createClickable(number);
+ panel.appendChild(clickable);
+ clickables.push(clickable);
+ });
+
+ setTimeout(() => {
+ clickables.forEach((clickable) => {
+ assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked');
+ });
+ assert.strictEqual(CLICK_ORDER.join(), window.clickOrder.join(), 'Elements were clicked in a given order');
+ assert.strictEqual(window.hit, 'FIRED', 'hit func executed');
+ done();
+ }, 150);
+});
+
+test('Multiple elements clicked, non-ordered render', (assert) => {
+ const CLICK_ORDER = [2, 1, 3];
+ // Assert elements for being clicked, hit func execution & click order
+ const ASSERTIONS = CLICK_ORDER.length + 2;
+ assert.expect(ASSERTIONS);
+ const done = assert.async();
+
+ const selectorsString = createSelectorsString(CLICK_ORDER);
+
+ runScriptlet(name, [selectorsString]);
+ const panel = createPanel();
+ const clickables = [];
+ CLICK_ORDER.forEach((number) => {
+ const clickable = createClickable(number);
+ panel.appendChild(clickable);
+ clickables.push(clickable);
+ });
+
+ setTimeout(() => {
+ clickables.forEach((clickable) => {
+ assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked');
+ });
+ assert.strictEqual(CLICK_ORDER.join(), window.clickOrder.join(), 'Elements were clicked in a given order');
+ assert.strictEqual(window.hit, 'FIRED', 'hit func executed');
+ done();
+ }, 150);
+});
+
+test('extraMatch - single cookie match, matched', (assert) => {
+ const cookieKey1 = 'first';
+ const cookieData = concatCookieNameValuePath(cookieKey1, 'true', '/');
+ document.cookie = cookieData;
+ const EXTRA_MATCH_STR = `cookie:${cookieKey1}`;
+
+ const ELEM_COUNT = 1;
+ // Check elements for being clicked and hit func execution
+ const ASSERTIONS = ELEM_COUNT + 1;
+ assert.expect(ASSERTIONS);
+ const done = assert.async();
+
+ const selectorsString = `#${PANEL_ID} > #${CLICKABLE_NAME}${ELEM_COUNT}`;
+
+ runScriptlet(name, [selectorsString, EXTRA_MATCH_STR]);
+ const panel = createPanel();
+ const clickable = createClickable(1);
+ panel.appendChild(clickable);
+
+ setTimeout(() => {
+ assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked');
+ assert.strictEqual(window.hit, 'FIRED', 'hit func executed');
+ done();
+ }, 150);
+ clearCookie(cookieKey1);
+});
+
+test('extraMatch - single cookie match, not matched', (assert) => {
+ const cookieKey1 = 'first';
+ const cookieKey2 = 'second';
+ const cookieData = concatCookieNameValuePath(cookieKey1, 'true', '/');
+ document.cookie = cookieData;
+ const EXTRA_MATCH_STR = `cookie:${cookieKey2}`;
+
+ const ELEM_COUNT = 1;
+ // Check elements for being clicked and hit func execution
+ const ASSERTIONS = ELEM_COUNT + 1;
+ assert.expect(ASSERTIONS);
+ const done = assert.async();
+
+ const selectorsString = `#${PANEL_ID} > #${CLICKABLE_NAME}${ELEM_COUNT}`;
+
+ runScriptlet(name, [selectorsString, EXTRA_MATCH_STR]);
+ const panel = createPanel();
+ const clickable = createClickable(1);
+ panel.appendChild(clickable);
+
+ setTimeout(() => {
+ assert.notOk(clickable.getAttribute('clicked'), 'Element should not be clicked');
+ assert.strictEqual(window.hit, undefined, 'hit should not fire');
+ done();
+ }, 150);
+ clearCookie(cookieKey1);
+});
+
+test('extraMatch - string+regex cookie input, matched', (assert) => {
+ const cookieKey1 = 'first';
+ const cookieVal1 = 'true';
+ const cookieData1 = concatCookieNameValuePath(cookieKey1, cookieVal1, '/');
+ document.cookie = cookieData1;
+ const EXTRA_MATCH_STR = 'cookie:/firs/=true';
+
+ const ELEM_COUNT = 1;
+ // Check elements for being clicked and hit func execution
+ const ASSERTIONS = ELEM_COUNT + 1;
+ assert.expect(ASSERTIONS);
+ const done = assert.async();
+
+ const selectorsString = `#${PANEL_ID} > #${CLICKABLE_NAME}${ELEM_COUNT}`;
+
+ runScriptlet(name, [selectorsString, EXTRA_MATCH_STR]);
+ const panel = createPanel();
+ const clickable = createClickable(1);
+ panel.appendChild(clickable);
+
+ setTimeout(() => {
+ assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked');
+ assert.strictEqual(window.hit, 'FIRED', 'hit func executed');
+ done();
+ }, 150);
+ clearCookie(cookieKey1);
+});
+
+test('extraMatch - single localStorage match, matched', (assert) => {
+ const itemName = 'item';
+ window.localStorage.setItem(itemName, 'value');
+ const EXTRA_MATCH_STR = `localStorage:${itemName}`;
+
+ const ELEM_COUNT = 1;
+ // Check elements for being clicked and hit func execution
+ const ASSERTIONS = ELEM_COUNT + 1;
+ assert.expect(ASSERTIONS);
+ const done = assert.async();
+
+ const selectorsString = `#${PANEL_ID} > #${CLICKABLE_NAME}${ELEM_COUNT}`;
+
+ runScriptlet(name, [selectorsString, EXTRA_MATCH_STR]);
+ const panel = createPanel();
+ const clickable = createClickable(1);
+ panel.appendChild(clickable);
+
+ setTimeout(() => {
+ assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked');
+ assert.strictEqual(window.hit, 'FIRED', 'hit func executed');
+ done();
+ }, 150);
+ window.localStorage.clear();
+});
+
+test('extraMatch - single localStorage match, not matched', (assert) => {
+ const itemName = 'item';
+ const itemName2 = 'key';
+ window.localStorage.setItem(itemName, 'value');
+ const EXTRA_MATCH_STR = `localStorage:${itemName2}`;
+
+ const ELEM_COUNT = 1;
+ // Check elements for being clicked and hit func execution
+ const ASSERTIONS = ELEM_COUNT + 1;
+ assert.expect(ASSERTIONS);
+ const done = assert.async();
+
+ const selectorsString = `#${PANEL_ID} > #${CLICKABLE_NAME}${ELEM_COUNT}`;
+
+ runScriptlet(name, [selectorsString, EXTRA_MATCH_STR]);
+ const panel = createPanel();
+ const clickable = createClickable(1);
+ panel.appendChild(clickable);
+
+ setTimeout(() => {
+ assert.notOk(clickable.getAttribute('clicked'), 'Element should not be clicked');
+ assert.strictEqual(window.hit, undefined, 'hit should not fire');
+ done();
+ }, 150);
+ window.localStorage.clear();
+});
+
+test('extraMatch - complex string+regex cookie input & whitespaces & comma in regex, matched', (assert) => {
+ const cookieKey1 = 'first';
+ const cookieVal1 = 'true';
+ const cookieData1 = concatCookieNameValuePath(cookieKey1, cookieVal1, '/');
+ const cookieKey2 = 'sec';
+ const cookieVal2 = '1-1';
+ const cookieData2 = concatCookieNameValuePath(cookieKey2, cookieVal2, '/');
+ const cookieKey3 = 'third';
+ const cookieVal3 = 'true';
+ const cookieData3 = concatCookieNameValuePath(cookieKey3, cookieVal3, '/');
+
+ document.cookie = cookieData1;
+ document.cookie = cookieData2;
+ document.cookie = cookieData3;
+
+ const EXTRA_MATCH_STR = 'cookie:/firs/=true,cookie:sec=/(1-1){1,2}/, cookie:third=true';
+
+ const ELEM_COUNT = 1;
+ // Check elements for being clicked and hit func execution
+ const ASSERTIONS = ELEM_COUNT + 1;
+ assert.expect(ASSERTIONS);
+ const done = assert.async();
+
+ const selectorsString = `#${PANEL_ID} > #${CLICKABLE_NAME}${ELEM_COUNT}`;
+
+ runScriptlet(name, [selectorsString, EXTRA_MATCH_STR]);
+ const panel = createPanel();
+ const clickable = createClickable(1);
+ panel.appendChild(clickable);
+
+ setTimeout(() => {
+ assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked');
+ assert.strictEqual(window.hit, 'FIRED', 'hit func executed');
+ done();
+ }, 150);
+ clearCookie(cookieKey1);
+});
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();
+ });
+}
diff --git a/tests/scriptlets/trusted-replace-xhr-response.test.js b/tests/scriptlets/trusted-replace-xhr-response.test.js
new file mode 100644
index 000000000..347496c58
--- /dev/null
+++ b/tests/scriptlets/trusted-replace-xhr-response.test.js
@@ -0,0 +1,168 @@
+/* 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-xhr-response';
+
+const FETCH_OBJECTS_PATH = './test-files';
+const nativeXhrOpen = XMLHttpRequest.prototype.open;
+const nativeXhrSend = XMLHttpRequest.prototype.send;
+const nativeConsole = console.log;
+
+const beforeEach = () => {
+ window.__debug = () => {
+ window.hit = 'FIRED';
+ };
+};
+
+const afterEach = () => {
+ clearGlobalProps('hit', '__debug');
+ XMLHttpRequest.prototype.open = nativeXhrOpen;
+ XMLHttpRequest.prototype.send = nativeXhrSend;
+ console.log = nativeConsole;
+};
+
+module(name, { beforeEach, afterEach });
+
+const isSupported = typeof Proxy !== 'undefined';
+
+if (isSupported) {
+ test('No args, logging', async (assert) => {
+ const METHOD = 'GET';
+ const URL = `${FETCH_OBJECTS_PATH}/test01.json`;
+
+ const done = assert.async();
+
+ // mock console.log function for log checking
+ console.log = function log(input) {
+ if (input.indexOf('trace') > -1) {
+ return;
+ }
+ const EXPECTED_LOG_STR = `xhr( method:"${METHOD}" url:"${URL}" )`;
+ assert.ok(startsWith(input, EXPECTED_LOG_STR), 'console.hit input');
+ };
+ const PATTERN = '';
+ const REPLACEMENT = '';
+
+ runScriptlet(name, [PATTERN, REPLACEMENT]);
+
+ const xhr = new XMLHttpRequest();
+ xhr.open(METHOD, URL);
+ xhr.onload = () => {
+ assert.strictEqual(xhr.readyState, 4, 'Response done');
+ assert.ok(xhr.response, 'Response data exists');
+ assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
+ done();
+ };
+ xhr.send();
+ });
+
+ test('Matched, string pattern', async (assert) => {
+ const METHOD = 'GET';
+ const URL = `${FETCH_OBJECTS_PATH}/test01.json`;
+ const PATTERN = /[-0-9]+/;
+ const REPLACEMENT = 'a';
+ const MATCH_DATA = [PATTERN, REPLACEMENT, `${URL} method:${METHOD}`];
+
+ runScriptlet(name, MATCH_DATA);
+
+ const done = assert.async();
+
+ const xhr = new XMLHttpRequest();
+ xhr.open(METHOD, URL);
+ xhr.onload = () => {
+ assert.strictEqual(xhr.readyState, 4, 'Response done');
+ assert.ok(!PATTERN.test(xhr.response), 'Response has been modified');
+ assert.ok(!PATTERN.test(xhr.responseText), 'Response text has been modified');
+
+ assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
+ done();
+ };
+ xhr.send();
+ });
+
+ test('Matched, regex pattern', async (assert) => {
+ const METHOD = 'GET';
+ const URL = `${FETCH_OBJECTS_PATH}/test01.json`;
+ const PATTERN = '/a1/';
+ const REPLACEMENT = 'x';
+ const MATCH_DATA = [PATTERN, REPLACEMENT, `${URL} method:${METHOD}`];
+
+ runScriptlet(name, MATCH_DATA);
+
+ const done = assert.async();
+
+ const xhr = new XMLHttpRequest();
+ xhr.open(METHOD, URL);
+ xhr.onload = () => {
+ assert.strictEqual(xhr.readyState, 4, 'Response done');
+ assert.ok(xhr.response.includes(REPLACEMENT) && !xhr.response.includes(PATTERN), 'Response has been modified');
+ assert.ok(xhr.responseText.includes(REPLACEMENT) && !xhr.responseText.includes(PATTERN), 'Response text has been modified');
+
+ assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
+ done();
+ };
+ xhr.send();
+ });
+
+ test('Not matched, response and responseText are intact', async (assert) => {
+ const METHOD = 'GET';
+ const URL = `${FETCH_OBJECTS_PATH}/test01.json`;
+ const PATTERN = 'a1';
+ const REPLACEMENT = 'x';
+ const MATCH_DATA = [
+ PATTERN,
+ REPLACEMENT,
+ `${FETCH_OBJECTS_PATH}/not_test01.json method:${METHOD}`,
+ ];
+
+ runScriptlet(name, MATCH_DATA);
+
+ const done = assert.async();
+
+ const xhr = new XMLHttpRequest();
+ xhr.open(METHOD, URL);
+ xhr.onload = () => {
+ assert.strictEqual(xhr.readyState, 4, 'Response done');
+ assert.notOk(xhr.response.includes(REPLACEMENT), 'Response is intact');
+ assert.notOk(xhr.responseText.includes(REPLACEMENT), 'Response text is intact');
+
+ assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
+ done();
+ };
+ xhr.send();
+ });
+
+ test('Matched, listeners after .send work', async (assert) => {
+ const METHOD = 'GET';
+ const URL = `${FETCH_OBJECTS_PATH}/test01.json`;
+ const PATTERN = 'a1';
+ const REPLACEMENT = 'x';
+ const MATCH_DATA = [PATTERN, REPLACEMENT, `${URL} method:${METHOD}`];
+
+ runScriptlet(name, MATCH_DATA);
+
+ const done1 = assert.async();
+ const done2 = assert.async();
+ const done3 = assert.async();
+ assert.expect(0);
+
+ const xhr = new XMLHttpRequest();
+ xhr.open(METHOD, URL);
+ xhr.send();
+ xhr.addEventListener('load', () => {
+ done1();
+ });
+ xhr.onload = () => {
+ done2();
+ };
+ xhr.addEventListener('loadend', () => {
+ done3();
+ });
+ });
+} else {
+ test('unsupported', (assert) => {
+ assert.ok(true, 'Browser does not support it');
+ });
+}
diff --git a/tests/scriptlets/trusted-set-cookie.test.js b/tests/scriptlets/trusted-set-cookie.test.js
new file mode 100644
index 000000000..3fecbf393
--- /dev/null
+++ b/tests/scriptlets/trusted-set-cookie.test.js
@@ -0,0 +1,116 @@
+/* eslint-disable no-underscore-dangle */
+import {
+ runScriptlet,
+ clearGlobalProps,
+ clearCookie,
+} from '../helpers';
+
+const { test, module } = QUnit;
+const name = 'trusted-set-cookie';
+
+const beforeEach = () => {
+ window.__debug = () => {
+ window.hit = 'FIRED';
+ };
+};
+
+const afterEach = () => {
+ clearGlobalProps('hit', '__debug');
+};
+
+module(name, { beforeEach, afterEach });
+
+test('Set cookie string', (assert) => {
+ let cName = '__test-cookie_OK';
+ let cValue = 'OK';
+ runScriptlet(name, [cName, cValue]);
+ assert.strictEqual(window.hit, 'FIRED', 'Hit was fired');
+ assert.strictEqual(document.cookie.includes(cName), true, 'Cookie name has been set');
+ assert.strictEqual(document.cookie.includes(cValue), true, 'Cookie value has been set');
+ clearCookie(cName);
+
+ cName = '__test-cookie_0';
+ cValue = 0;
+ runScriptlet(name, [cName, cValue, '', '']);
+ assert.strictEqual(window.hit, 'FIRED', 'Hit was fired');
+ assert.strictEqual(document.cookie.includes(cName), true, 'Cookie name has been set');
+ assert.strictEqual(document.cookie.includes(cValue), true, 'Cookie value has been set');
+ clearCookie(cName);
+
+ cName = 'trackingSettings';
+ cValue = '{%22ads%22:false%2C%22performance%22:false}';
+ runScriptlet(name, [cName, cValue]);
+
+ assert.strictEqual(window.hit, 'FIRED', 'Hit was fired');
+ assert.strictEqual(document.cookie.includes(cName), true, 'Cookie name has been set');
+ assert.strictEqual(document.cookie.includes(encodeURIComponent(cValue)), true, 'Cookie value has been set');
+ clearCookie(cName);
+});
+
+test('Set cookie with current time value', (assert) => {
+ const cName = '__test-cookie_OK';
+ const cValue = '$now$';
+
+ runScriptlet(name, [cName, cValue]);
+
+ assert.strictEqual(window.hit, 'FIRED', 'Hit was fired');
+ assert.strictEqual(document.cookie.includes(cName), true, 'Cookie name has been set');
+
+ // Some time will pass between calling scriptlet
+ // and qunit running assertion
+ const tolerance = 20;
+ const cookieValue = document.cookie.split('=')[1];
+ const currentTime = new Date().getTime();
+ const timeDiff = currentTime - cookieValue;
+
+ assert.ok(timeDiff < tolerance, 'Cookie value has been set to current time');
+
+ clearCookie(cName);
+});
+
+test('Set cookie with expires', (assert) => {
+ const cName = '__test-cookie_expires';
+ const cValue = 'expires';
+ const expiresSec = 2;
+
+ runScriptlet(name, [cName, cValue, `${expiresSec}`]);
+
+ assert.strictEqual(window.hit, 'FIRED', 'Hit was fired');
+ assert.strictEqual(document.cookie.includes(cName), true, 'Cookie name has been set');
+ assert.strictEqual(document.cookie.includes(cValue), true, 'Cookie value has been set');
+
+ const done = assert.async();
+
+ setTimeout(() => {
+ assert.strictEqual(document.cookie.includes(cName), false, 'Cookie name has been deleted');
+ assert.strictEqual(document.cookie.includes(cValue), false, 'Cookie value has been deleted');
+ clearCookie(cName);
+ done();
+ }, expiresSec * 1000);
+});
+
+test('Set cookie with negative expires', (assert) => {
+ const cName = '__test-cookie_expires_negative';
+ const cValue = 'expires';
+ const expiresSec = -2;
+
+ runScriptlet(name, [cName, cValue, `${expiresSec}`]);
+
+ assert.strictEqual(window.hit, 'FIRED', 'Hit was fired');
+ assert.strictEqual(document.cookie.includes(cName), false, 'Cookie name has not been set');
+ assert.strictEqual(document.cookie.includes(cValue), false, 'Cookie value has not been set');
+ clearCookie(cName);
+});
+
+test('Set cookie with invalid expires', (assert) => {
+ const cName = '__test-cookie_expires_invalid';
+ const cValue = 'expires';
+ const expiresSec = 'invalid_value';
+
+ runScriptlet(name, [cName, cValue, `${expiresSec}`]);
+
+ assert.strictEqual(window.hit, undefined, 'Hit was not fired');
+ assert.strictEqual(document.cookie.includes(cName), false, 'Cookie name has not been set');
+ assert.strictEqual(document.cookie.includes(cValue), false, 'Cookie value has not been set');
+ clearCookie(cName);
+});
diff --git a/tests/scriptlets/xml-prune.test.js b/tests/scriptlets/xml-prune.test.js
new file mode 100644
index 000000000..e851b6f0f
--- /dev/null
+++ b/tests/scriptlets/xml-prune.test.js
@@ -0,0 +1,322 @@
+/* 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 = 'xml-prune';
+
+const MPD_OBJECTS_PATH = './test-files/manifestMPD.mpd';
+const GET_METHOD = 'GET';
+const nativeFetch = fetch;
+const nativeXhrOpen = XMLHttpRequest.prototype.open;
+const nativeConsole = console.log;
+
+const beforeEach = () => {
+ window.__debug = () => {
+ window.hit = 'FIRED';
+ };
+};
+
+const afterEach = () => {
+ clearGlobalProps('hit', '__debug');
+ fetch = nativeFetch; // eslint-disable-line no-global-assign
+ console.log = nativeConsole;
+ XMLHttpRequest.prototype.open = nativeXhrOpen;
+};
+
+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('Checking if alias name works', (assert) => {
+ const adgParams = {
+ name,
+ engine: 'test',
+ verbose: true,
+ };
+ const uboParams = {
+ name: 'xml-prune.js',
+ engine: 'test',
+ verbose: true,
+ };
+
+ const codeByAdgParams = window.scriptlets.invoke(adgParams);
+ const codeByUboParams = window.scriptlets.invoke(uboParams);
+
+ assert.strictEqual(codeByAdgParams, codeByUboParams, 'ubo name - ok');
+ });
+
+ test('fetch - no prune (log)', async (assert) => {
+ const done = assert.async();
+
+ // mock console.log function for log checking
+ console.log = function log(input) {
+ if (input.indexOf('trace') > -1) {
+ return;
+ }
+ const EXPECTED_LOG_STR_START = `fetch URL: ${MPD_OBJECTS_PATH}`;
+ assert.ok(startsWith(input, EXPECTED_LOG_STR_START), 'console.hit input');
+ assert.ok(input.indexOf('pre-roll-1-ad-1') > -1);
+ };
+
+ runScriptlet(name);
+
+ const response = await fetch(MPD_OBJECTS_PATH);
+ const responseMPD = await response.text();
+
+ assert.ok(responseMPD.indexOf('pre-roll-1-ad-1') > -1);
+ assert.strictEqual(window.hit, undefined, 'should not hit');
+ done();
+ });
+
+ test('fetch URL does not match - no prune', async (assert) => {
+ const MATCH_DATA = "Period[id*='-ad-']";
+ const OPTIONAL_MATCH = '';
+ const MATCH_URL = 'noPrune';
+ const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL];
+
+ runScriptlet(name, scriptletArgs);
+
+ const done = assert.async();
+
+ const response = await fetch(MPD_OBJECTS_PATH);
+ const responseMPD = await response.text();
+
+ assert.ok(responseMPD.indexOf('pre-roll-1-ad-1') > -1);
+ assert.strictEqual(window.hit, undefined, 'should not hit');
+ done();
+ });
+
+ test('fetch match URL, element to remove does not match - no prune', async (assert) => {
+ const MATCH_DATA = "Period[id*='do-no-match']";
+ const OPTIONAL_MATCH = '';
+ const MATCH_URL = '.mpd';
+ const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL];
+
+ runScriptlet(name, scriptletArgs);
+
+ const done = assert.async();
+
+ const response = await fetch(MPD_OBJECTS_PATH);
+ const responseMPD = await response.text();
+
+ assert.ok(responseMPD.indexOf('pre-roll-1-ad-1') > -1);
+ assert.strictEqual(window.hit, undefined, 'should not hit');
+ done();
+ });
+
+ test('fetch match URL, optional argument does not match - no prune', async (assert) => {
+ const MATCH_DATA = "Period[id*='-ad-']";
+ const OPTIONAL_MATCH = 'DO_NOT_MATCH';
+ const MATCH_URL = '.mpd';
+ const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL];
+
+ runScriptlet(name, scriptletArgs);
+
+ const done = assert.async();
+
+ const response = await fetch(MPD_OBJECTS_PATH);
+ const responseMPD = await response.text();
+
+ assert.ok(responseMPD.indexOf('pre-roll-1-ad-1') > -1);
+ assert.strictEqual(window.hit, undefined, 'should not hit');
+ done();
+ });
+
+ test('fetch - remove ads', async (assert) => {
+ const MATCH_DATA = ["Period[id*='-ad-']"];
+
+ runScriptlet(name, MATCH_DATA);
+
+ const done = assert.async();
+
+ const response = await fetch(MPD_OBJECTS_PATH);
+ const responseMPD = await response.text();
+
+ assert.notOk(responseMPD.indexOf('pre-roll-1-ad-1') > -1);
+ assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
+ done();
+ });
+
+ test('fetch match URL - remove ads', async (assert) => {
+ const MATCH_DATA = "Period[id*='-ad-']";
+ const OPTIONAL_MATCH = '';
+ const MATCH_URL = '.mpd';
+ const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL];
+
+ runScriptlet(name, scriptletArgs);
+
+ const done = assert.async();
+
+ const response = await fetch(MPD_OBJECTS_PATH);
+ const responseMPD = await response.text();
+
+ assert.notOk(responseMPD.indexOf('pre-roll-1-ad-1') > -1);
+ assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
+ done();
+ });
+
+ test('fetch match URL, match optional argument - remove ads', async (assert) => {
+ const MATCH_DATA = "Period[id*='-ad-']";
+ const OPTIONAL_MATCH = 'AdaptationSet';
+ const MATCH_URL = '.mpd';
+ const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL];
+
+ runScriptlet(name, scriptletArgs);
+
+ const done = assert.async();
+
+ const response = await fetch(MPD_OBJECTS_PATH);
+ const responseMPD = await response.text();
+
+ assert.notOk(responseMPD.indexOf('pre-roll-1-ad-1') > -1);
+ assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
+ done();
+ });
+
+ test('xhr - no prune (log)', async (assert) => {
+ const done = assert.async();
+
+ console.log = function log(input) {
+ if (input.indexOf('trace') > -1) {
+ return;
+ }
+ const EXPECTED_LOG_STR_START = `XMLHttpRequest.open() URL: ${MPD_OBJECTS_PATH}`;
+ assert.ok(startsWith(input, EXPECTED_LOG_STR_START), 'console.hit input');
+ assert.ok(input.indexOf('pre-roll-1-ad-1') > -1, 'console.hit input');
+ };
+
+ runScriptlet(name);
+
+ const xhr = new XMLHttpRequest();
+ xhr.open(GET_METHOD, MPD_OBJECTS_PATH);
+ xhr.onload = () => {
+ assert.ok(xhr.responseText.indexOf('pre-roll-1-ad-1') > -1);
+ assert.strictEqual(window.hit, undefined, 'should not hit');
+ done();
+ };
+ xhr.send();
+ });
+
+ test('xhr URL does not match - no prune', async (assert) => {
+ const MATCH_DATA = "Period[id*='-ad-']";
+ const OPTIONAL_MATCH = '';
+ const MATCH_URL = 'noPrune';
+ const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL];
+
+ runScriptlet(name, scriptletArgs);
+
+ const done = assert.async();
+
+ const xhr = new XMLHttpRequest();
+ xhr.open(GET_METHOD, MPD_OBJECTS_PATH);
+ xhr.onload = () => {
+ assert.ok(xhr.responseText.indexOf('pre-roll-1-ad-1') > -1);
+ assert.strictEqual(window.hit, undefined, 'should not hit');
+ done();
+ };
+ xhr.send();
+ });
+
+ test('xhr match URL, element to remove does not match - no prune', async (assert) => {
+ const MATCH_DATA = "Period[id*='do-no-match']";
+ const OPTIONAL_MATCH = '';
+ const MATCH_URL = '.mpd';
+ const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL];
+
+ runScriptlet(name, scriptletArgs);
+
+ const done = assert.async();
+
+ const xhr = new XMLHttpRequest();
+ xhr.open(GET_METHOD, MPD_OBJECTS_PATH);
+ xhr.onload = () => {
+ assert.ok(xhr.responseText.indexOf('pre-roll-1-ad-1') > -1);
+ assert.strictEqual(window.hit, undefined, 'should not hit');
+ done();
+ };
+ xhr.send();
+ });
+
+ test('xhr match URL, optional argument does not match - no prune', async (assert) => {
+ const MATCH_DATA = "Period[id*='-ad-']";
+ const OPTIONAL_MATCH = 'DO_NOT_MATCH';
+ const MATCH_URL = '.mpd';
+ const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL];
+
+ runScriptlet(name, scriptletArgs);
+
+ const done = assert.async();
+
+ const xhr = new XMLHttpRequest();
+ xhr.open(GET_METHOD, MPD_OBJECTS_PATH);
+ xhr.onload = () => {
+ assert.ok(xhr.responseText.indexOf('pre-roll-1-ad-1') > -1);
+ assert.strictEqual(window.hit, undefined, 'should not hit');
+ done();
+ };
+ xhr.send();
+ });
+
+ test('xhr - remove ads', 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.onload = () => {
+ assert.notOk(xhr.responseText.indexOf('pre-roll-1-ad-1') > -1);
+ assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
+ done();
+ };
+ xhr.send();
+ });
+
+ test('xhr match URL - remove ads', async (assert) => {
+ const MATCH_DATA = "Period[id*='-ad-']";
+ const OPTIONAL_MATCH = '';
+ const MATCH_URL = '.mpd';
+ const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL];
+
+ runScriptlet(name, scriptletArgs);
+
+ const done = assert.async();
+
+ const xhr = new XMLHttpRequest();
+ xhr.open(GET_METHOD, MPD_OBJECTS_PATH);
+ xhr.onload = () => {
+ assert.notOk(xhr.responseText.indexOf('pre-roll-1-ad-1') > -1);
+ assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
+ done();
+ };
+ xhr.send();
+ });
+
+ test('xhr match URL, match optional argument - remove ads', async (assert) => {
+ const MATCH_DATA = "Period[id*='-ad-']";
+ const OPTIONAL_MATCH = 'AdaptationSet';
+ const MATCH_URL = '.mpd';
+ const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL];
+
+ runScriptlet(name, scriptletArgs);
+
+ const done = assert.async();
+
+ const xhr = new XMLHttpRequest();
+ xhr.open(GET_METHOD, MPD_OBJECTS_PATH);
+ xhr.onload = () => {
+ assert.notOk(xhr.responseText.indexOf('pre-roll-1-ad-1') > -1);
+ assert.strictEqual(window.hit, 'FIRED', 'hit function fired');
+ done();
+ };
+ xhr.send();
+ });
+}
diff --git a/wiki/about-redirects.md b/wiki/about-redirects.md
index af2edfc01..e446ff986 100644
--- a/wiki/about-redirects.md
+++ b/wiki/about-redirects.md
@@ -413,9 +413,10 @@ https://github.com/gorhill/uBlock/wiki/Resources-Library#noeval-silentjs-
### ⚡️ pardot-1.0
-Mocks the pd.js file of Salesforce
+Mocks the pd.js file of Salesforce.
https://pi.pardot.com/pd.js
https://developer.salesforce.com/docs/marketing/pardot/overview
+
**Example**
```
||pi.pardot.com/pd.js$script,redirect=pardot
diff --git a/wiki/about-scriptlets.md b/wiki/about-scriptlets.md
index 847dad792..d7f769e3a 100644
--- a/wiki/about-scriptlets.md
+++ b/wiki/about-scriptlets.md
@@ -45,6 +45,7 @@
* [set-local-storage-item](#set-local-storage-item)
* [set-popads-dummy](#set-popads-dummy)
* [set-session-storage-item](#set-session-storage-item)
+* [xml-prune](#xml-prune)
* * *
### ⚡️ abort-current-inline-script
@@ -1493,6 +1494,8 @@ Creates a constant property and assigns it one of the values from the predefined
> Actually, it's not a constant. Please note, that it can be rewritten with a value of a different type.
+> If empty object is present in chain it will be trapped until chain leftovers appear.
+
Related UBO scriptlet:
https://github.com/gorhill/uBlock/wiki/Resources-Library#set-constantjs-
@@ -1554,12 +1557,13 @@ example.org#%#//scriptlet('set-constant', 'document.third', 'trueFunc', 'checkin
### ⚡️ set-cookie-reload
-Sets a cookie with the specified name and value, and then reloads the current page.
+Sets a cookie with the specified name and value, and path,
+and reloads the current page after the cookie setting.
If reloading option is not needed, use [set-cookie](#set-cookie) scriptlet.
**Syntax**
```
-example.org#%#//scriptlet('set-cookie-reload', name, value)
+example.org#%#//scriptlet('set-cookie-reload', name, value[, path])
```
- `name` - required, cookie name to be set
@@ -1571,12 +1575,17 @@ example.org#%#//scriptlet('set-cookie-reload', name, value)
- `yes` / `Yes` / `Y`
- `no`
- `ok` / `OK`
+- `path` - optional, cookie path, defaults to `/`; possible values:
+ - `/` — root path
+ - `none` — to set no path at all
**Examples**
```
example.org#%#//scriptlet('set-cookie-reload', 'checking', 'ok')
example.org#%#//scriptlet('set-cookie-reload', 'gdpr-settings-cookie', '1')
+
+example.org#%#//scriptlet('set-cookie-reload', 'cookie-set', 'true', 'none')
```
[Scriptlet source](../src/scriptlets/set-cookie-reload.js)
@@ -1584,11 +1593,11 @@ example.org#%#//scriptlet('set-cookie-reload', 'gdpr-settings-cookie', '1')
### ⚡️ set-cookie
-Sets a cookie with the specified name and value. Cookie path defaults to root.
+Sets a cookie with the specified name, value, and path.
**Syntax**
```
-example.org#%#//scriptlet('set-cookie', name, value)
+example.org#%#//scriptlet('set-cookie', name, value[, path])
```
- `name` - required, cookie name to be set
@@ -1600,12 +1609,17 @@ example.org#%#//scriptlet('set-cookie', name, value)
- `yes` / `Yes` / `Y`
- `no`
- `ok` / `OK`
+- `path` - optional, cookie path, defaults to `/`; possible values:
+ - `/` — root path
+ - `none` — to set no path at all
**Examples**
```
-example.org#%#//scriptlet('set-cookie', 'ReadlyCookieConsent', '1')
+example.org#%#//scriptlet('set-cookie', 'CookieConsent', '1')
example.org#%#//scriptlet('set-cookie', 'gdpr-settings-cookie', 'true')
+
+example.org#%#//scriptlet('set-cookie', 'cookie_consent', 'ok', 'none')
```
[Scriptlet source](../src/scriptlets/set-cookie.js)
@@ -1692,3 +1706,48 @@ example.org#%#//scriptlet('set-session-storage-item', 'exit-intent-marketing', '
[Scriptlet source](../src/scriptlets/set-session-storage-item.js)
* * *
+### ⚡️ xml-prune
+
+Removes an element from the specified XML.
+
+
+**Syntax**
+```
+example.org#%#//scriptlet('xml-prune'[, propsToMatch[, optionalProp[, urlToMatch]]])
+```
+
+- `propsToMatch` - optional, selector of elements which will be removed from XML
+- `optionalProp` - optional, selector of elements that must occur in XML document
+- `urlToMatch` - optional, string or regular expression for matching the request's URL
+> Usage with no arguments will log response payload and URL to browser console;
+which is useful for debugging but prohibited for production filter lists.
+
+**Examples**
+1. Remove `Period` tag whose `id` contains `-ad-` from all requests
+ ```
+ example.org#%#//scriptlet('xml-prune', 'Period[id*="-ad-"]')
+ ```
+
+2. Remove `Period` tag whose `id` contains `-ad-`, only if XML contains `SegmentTemplate`
+ ```
+ example.org#%#//scriptlet('xml-prune', 'Period[id*="-ad-"]', 'SegmentTemplate')
+ ```
+
+3. Remove `Period` tag whose `id` contains `-ad-`, only if request's URL contains `.mpd`
+ ```
+ example.org#%#//scriptlet('xml-prune', 'Period[id*="-ad-"]', '', '.mpd')
+ ```
+
+4. Call with no arguments will log response payload and URL at the console
+ ```
+ example.org#%#//scriptlet('xml-prune')
+ ```
+
+5. Call with only `urlToMatch` argument will log response payload and URL only for the matched URL
+ ```
+ example.org#%#//scriptlet('xml-prune', '', '', '.mpd')
+ ```
+
+[Scriptlet source](../src/scriptlets/xml-prune.js)
+* * *
+