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) +* * * +