Skip to content

Commit

Permalink
AG-30776 Add ability to click on the element with specified text in `…
Browse files Browse the repository at this point in the history
…trusted-click-element` scriptlet. #409

Squashed commit of the following:

commit ca8289f
Author: Adam Wróblewski <adam@adguard.com>
Date:   Thu May 23 18:13:48 2024 +0200

    Add separate const

commit 609c544
Author: Adam Wróblewski <adam@adguard.com>
Date:   Thu May 23 17:24:35 2024 +0200

    Update toRegExp

commit 1f671bd
Author: Adam Wróblewski <adam@adguard.com>
Date:   Thu May 23 17:06:14 2024 +0200

    Use null instead of empty string

commit 628e64f
Author: Adam Wróblewski <adam@adguard.com>
Date:   Thu May 23 16:36:59 2024 +0200

    Convert to TypeScript

commit a47011e
Author: Adam Wróblewski <adam@adguard.com>
Date:   Thu May 23 14:23:47 2024 +0200

    Rename `elementContainsText` to `doesElementContainText` and refactor it

commit 7fab2a8
Author: Adam Wróblewski <adam@adguard.com>
Date:   Thu May 23 13:18:49 2024 +0200

    Add ability to click on the element with specified text in `trusted-click-element` scriptlet
  • Loading branch information
AdamWr committed May 24, 2024
1 parent 0dae952 commit e9343fd
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 22 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic
- `isRedirectResourceCompatibleWithAdg()` method to check compatibility of redirect resources with AdGuard
without needing the full rule text [#420]
- `trusted-replace-outbound-text` scriptlet [#410]
- ability to click on the element with specified text in `trusted-click-element` scriptlet [#409]
- `trusted-dispatch-event` scriptlet [#382]

### Deprecated
Expand All @@ -28,6 +29,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic
[#425]: https://github.com/AdguardTeam/Scriptlets/issues/425
[#420]: https://github.com/AdguardTeam/Scriptlets/issues/420
[#410]: https://github.com/AdguardTeam/Scriptlets/issues/410
[#409]: https://github.com/AdguardTeam/Scriptlets/issues/409
[#382]: https://github.com/AdguardTeam/Scriptlets/issues/382

## [v1.10.25] - 2024-03-28
Expand Down
5 changes: 3 additions & 2 deletions src/helpers/string-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,11 @@ export const escapeRegExp = (str: string): string => str.replace(/[.*+?^${}()|[\
* if string contains valid regexp flags it will be converted to regexp with flags
* TODO think about nested dependencies, but be careful with dependency loops
*
* @param input literal string or regexp pattern; defaults to '' (empty string)
* @param rawInput literal string or regexp pattern; defaults to '' (empty string)
* @returns regular expression; defaults to /.?/
*/
export const toRegExp = (input: RawStrPattern = ''): RegExp => {
export const toRegExp = (rawInput?: RawStrPattern | null): RegExp => {
const input = rawInput || '';
const DEFAULT_VALUE = '.?';
const FORWARD_SLASH = '/';
if (input === '') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,16 @@ import {
* ```
* <!-- markdownlint-disable-next-line line-length -->
* - `selectors` — required, string with query selectors delimited by comma. The scriptlet supports `>>>` combinator to select elements inside open shadow DOM. For usage, see example below.
* - `extraMatch` — optional, extra condition to check on a page; allows to match `cookie` and `localStorage`;
* - `extraMatch` — optional, extra condition to check on a page;
* allows to match `cookie`, `localStorage` and specified text;
* can be set as `name:key[=value]` where `value` is optional.
* If `cookie`/`localStorage` starts with `!` then the element will only be clicked
* if specified cookie/localStorage item does not exist.
* if specified `cookie`/`localStorage` item does not exist.
* 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:
* and each of them should match the syntax. Possible `names`:
* - `cookie` — test string or regex against cookies on a page
* - `localStorage` — check if localStorage item is present
* - `containsText` — check if clicked element contains specified text
* - `delay` — optional, time in ms to delay scriptlet execution, defaults to instant execution.
*
* <!-- markdownlint-disable line-length -->
Expand Down Expand Up @@ -82,6 +84,12 @@ import {
* example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"], input[type="submit"][value="akkoord"]', 'cookie:cmpconsent, localStorage:promo', '250')
* ```
*
* 1. Click element only if clicked element contains text `Accept cookie`
*
* ```adblock
* example.com#%#//scriptlet('trusted-click-element', 'button', 'containsText:Accept cookie')
* ```
*
* 1. Click element only if cookie with name `cmpconsent` does not exist
*
* ```adblock
Expand All @@ -105,7 +113,12 @@ import {
* @added v1.7.3.
*/
/* eslint-enable max-len */
export function trustedClickElement(source, selectors, extraMatch = '', delay = NaN) {
export function trustedClickElement(
source: Source,
selectors: string,
extraMatch = '',
delay = NaN,
) {
if (!selectors) {
return;
}
Expand All @@ -115,16 +128,17 @@ export function trustedClickElement(source, selectors, extraMatch = '', delay =
const STATIC_CLICK_DELAY_MS = 150;
const COOKIE_MATCH_MARKER = 'cookie:';
const LOCAL_STORAGE_MATCH_MARKER = 'localStorage:';
const TEXT_MATCH_MARKER = 'containsText:';
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:)/;
const EXTRA_MATCH_DELIMITER = /(,\s*){1}(?=!?cookie:|!?localStorage:|containsText:)/;

const sleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs));
const sleep = (delayMs: number) => new Promise((resolve) => setTimeout(resolve, delayMs));

let parsedDelay;
if (delay) {
parsedDelay = parseInt(delay, 10);
parsedDelay = parseInt(String(delay), 10);
const isValidDelay = !Number.isNaN(parsedDelay) || parsedDelay < OBSERVER_TIMEOUT_MS;
if (!isValidDelay) {
// eslint-disable-next-line max-len
Expand All @@ -136,8 +150,9 @@ export function trustedClickElement(source, selectors, extraMatch = '', delay =

let canClick = !parsedDelay;

const cookieMatches = [];
const localStorageMatches = [];
const cookieMatches: string[] = [];
const localStorageMatches: string[] = [];
let textMatches = '';
let isInvertedMatchCookie = false;
let isInvertedMatchLocalStorage = false;

Expand All @@ -161,6 +176,11 @@ export function trustedClickElement(source, selectors, extraMatch = '', delay =
const localStorageMatch = matchValue.replace(LOCAL_STORAGE_MATCH_MARKER, '');
localStorageMatches.push(localStorageMatch);
}
if (matchStr.includes(TEXT_MATCH_MARKER)) {
const { matchValue } = parseMatchArg(matchStr);
const textMatch = matchValue.replace(TEXT_MATCH_MARKER, '');
textMatches = textMatch;
}
});
}

Expand All @@ -179,8 +199,8 @@ export function trustedClickElement(source, selectors, extraMatch = '', delay =
const valueMatch = parsedCookieMatches[key] ? toRegExp(parsedCookieMatches[key]) : null;
const keyMatch = toRegExp(key);

return cookieKeys.some((key) => {
const keysMatched = keyMatch.test(key);
return cookieKeys.some((cookieKey) => {
const keysMatched = keyMatch.test(cookieKey);
if (!keysMatched) {
return false;
}
Expand All @@ -190,7 +210,13 @@ export function trustedClickElement(source, selectors, extraMatch = '', delay =
return true;
}

return valueMatch.test(parsedCookies[key]);
const parsedCookieValue = parsedCookies[cookieKey];

if (!parsedCookieValue) {
return false;
}

return valueMatch.test(parsedCookieValue);
});
});

Expand All @@ -213,6 +239,26 @@ export function trustedClickElement(source, selectors, extraMatch = '', delay =
}
}

const textMatchRegexp = textMatches ? toRegExp(textMatches) : null;

/**
* Checks if an element contains the specified text.
*
* @param element - The element to check.
* @param matchRegexp - The text to match.
* @returns True if the element contains the specified text, otherwise false.
*/
const doesElementContainText = (
element: Element,
matchRegexp: RegExp,
): boolean => {
const { textContent } = element;
if (!textContent) {
return false;
}
return matchRegexp.test(textContent);
};

/**
* Create selectors array and swap selectors to null on finding it's element
*
Expand All @@ -221,17 +267,17 @@ export function trustedClickElement(source, selectors, extraMatch = '', delay =
* - always know on what index corresponding element should be put
* - prevent selectors from being queried multiple times
*/
let selectorsSequence = selectors
let selectorsSequence: Array<string | null> = selectors
.split(SELECTORS_DELIMITER)
.map((selector) => selector.trim());

const createElementObj = (element) => {
const createElementObj = (element: any): Object => {
return {
element: element || null,
clicked: false,
};
};
const elementsSequence = Array(selectorsSequence.length).fill(createElementObj());
const elementsSequence = Array(selectorsSequence.length).fill(createElementObj(null));

/**
* Go through elementsSequence from left to right, clicking on found elements
Expand All @@ -253,6 +299,9 @@ export function trustedClickElement(source, selectors, extraMatch = '', delay =
}
// Skip already clicked elements
if (!elementObj.clicked) {
if (textMatchRegexp && !doesElementContainText(elementObj.element, textMatchRegexp)) {
continue;
}
elementObj.element.click();
elementObj.clicked = true;
}
Expand All @@ -266,7 +315,7 @@ export function trustedClickElement(source, selectors, extraMatch = '', delay =
}
};

const handleElement = (element, i) => {
const handleElement = (element: Element, i: number) => {
const elementObj = createElementObj(element);
elementsSequence[i] = elementObj;

Expand All @@ -283,8 +332,8 @@ export function trustedClickElement(source, selectors, extraMatch = '', delay =
* when delay is getting off after the last mutation took place.
*
*/
const findElements = (mutations, observer) => {
const fulfilledSelectors = [];
const findElements = (mutations: MutationRecord[], observer: MutationObserver) => {
const fulfilledSelectors: string[] = [];
selectorsSequence.forEach((selector, i) => {
if (!selector) {
return;
Expand All @@ -300,7 +349,9 @@ export function trustedClickElement(source, selectors, extraMatch = '', delay =

// selectorsSequence should be modified after the loop to not break loop indexation
selectorsSequence = selectorsSequence.map((selector) => {
return fulfilledSelectors.includes(selector) ? null : selector;
return selector && fulfilledSelectors.includes(selector)
? null
: selector;
});

// Disconnect observer after finding all elements
Expand Down
100 changes: 99 additions & 1 deletion tests/scriptlets/trusted-click-element.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ const createSelectorsString = (clickOrder) => {
};

// Create clickable element with it's count as id and assertion as onclick
const createClickable = (elementNum) => {
const createClickable = (elementNum, text = '') => {
const clickableId = `${CLICKABLE_NAME}${elementNum}`;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = clickableId;
checkbox.textContent = text;
checkbox.onclick = (e) => {
e.currentTarget.setAttribute('clicked', true);
window.clickOrder.push(elementNum);
Expand Down Expand Up @@ -229,6 +230,78 @@ test('extraMatch - single cookie match, matched', (assert) => {
clearCookie(cookieKey1);
});

test('extraMatch - text match, matched', (assert) => {
const textToMatch = 'Accept cookie';
const EXTRA_MATCH_STR = `containsText:${textToMatch}`;

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, textToMatch);
panel.appendChild(clickable);

setTimeout(() => {
assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked');
assert.strictEqual(window.hit, 'FIRED', 'hit func executed');
done();
}, 150);
});

test('extraMatch - text match regexp, matched', (assert) => {
const textToMatch = 'Reject foo bar cookie';
const EXTRA_MATCH_STR = 'containsText:/Reject.*cookie/';

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, textToMatch);
panel.appendChild(clickable);

setTimeout(() => {
assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked');
assert.strictEqual(window.hit, 'FIRED', 'hit func executed');
done();
}, 150);
});

test('extraMatch - text match, not matched', (assert) => {
const textToMatch = 'foo';
const EXTRA_MATCH_STR = `containsText:${textToMatch}`;

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, 'bar');
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);
});

test('extraMatch - single cookie match, not matched', (assert) => {
const cookieKey1 = 'first';
const cookieKey2 = 'second';
Expand Down Expand Up @@ -430,6 +503,31 @@ test('extraMatch - single cookie revert, click', (assert) => {
}, 150);
});

test('extraMatch - cookie revert + text match, click', (assert) => {
const textToMatch = 'Continue';
const cookieKey = 'revertTextTest';
const EXTRA_MATCH_STR = `!cookie:${cookieKey}, containsText:${textToMatch}`;

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, textToMatch);
panel.appendChild(clickable);

setTimeout(() => {
assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked');
assert.strictEqual(window.hit, 'FIRED', 'hit func executed');
done();
}, 150);
});

test('extraMatch - single cookie with value revert match, should click', (assert) => {
const cookieKey = 'clickValue';
const cookieVal = 'true';
Expand Down

0 comments on commit e9343fd

Please sign in to comment.