Skip to content

Commit

Permalink
AG-38180 Improve 'trusted-click-element' — check for 'containsText' o…
Browse files Browse the repository at this point in the history
…f all matched selectors. #468

Squashed commit of the following:

commit e050074
Author: Adam Wróblewski <[email protected]>
Date:   Tue Dec 3 09:19:03 2024 +0100

    Update docs
    Rename `element` to `selector`

commit c773ffd
Author: Slava Leleka <[email protected]>
Date:   Tue Dec 3 11:05:51 2024 +0300

    src/helpers/open-shadow-dom-utils.ts edited online with Bitbucket

commit a990771
Author: Adam Wróblewski <[email protected]>
Date:   Mon Dec 2 14:36:52 2024 +0100

    Improve 'trusted-click-element' — check for 'containsText' of all matched selectors
  • Loading branch information
AdamWr committed Dec 4, 2024
1 parent eb2f95b commit d12f6bf
Show file tree
Hide file tree
Showing 4 changed files with 128 additions and 28 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic

## [Unreleased]

### Changed

- `trusted-click-element` scriptlet, now when `containsText` is used then it will search for all given selectors
and click on the first element with matched text [#468]

### Fixed

- issue with `trusted-click-element` scriptlet when `delay` was used and the element was removed
and added again before it was clicked [#391]

[Unreleased]: https://github.com/AdguardTeam/Scriptlets/compare/v2.0.1...HEAD
[#391]: https://github.com/AdguardTeam/Scriptlets/issues/391
[#468]: https://github.com/AdguardTeam/Scriptlets/issues/468

## [v2.0.1] - 2024-11-13

Expand Down
56 changes: 51 additions & 5 deletions src/helpers/open-shadow-dom-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import { flatten } from './array-utils';
export const findHostElements = (rootElement: Element | ShadowRoot | null): HTMLElement[] => {
const hosts: HTMLElement[] = [];
if (rootElement) {
// Element.querySelectorAll() returns list of elements
// which are defined in DOM of Element.
// Meanwhile, inner DOM of the element with shadowRoot property
// is absolutely another DOM and which can not be reached by querySelectorAll('*')
// Element.querySelectorAll() returns list of elements
// which are defined in DOM of Element.
// Meanwhile, inner DOM of the element with shadowRoot property
// is absolutely another DOM and which can not be reached by querySelectorAll('*')
const domElems = rootElement.querySelectorAll('*');
domElems.forEach((el) => {
if (el.shadowRoot) {
Expand Down Expand Up @@ -73,6 +73,47 @@ export const pierceShadowDom = (

type QueryFunc = typeof document.querySelector;

/**
* 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.
*/
export function doesElementContainText(
element: Element,
matchRegexp: RegExp,
): boolean {
const { textContent } = element;
if (!textContent) {
return false;
}
return matchRegexp.test(textContent);
}

/**
* Finds an element within the given root element that matches the specified element
* and contains text matching the provided regular expression.
*
* @param rootElement - The root element to search within.
* @param selector - The element to find.
* @param matchRegexp - The regular expression to match the text content of the elements.
* @returns The first element that matches the criteria, or null if no such element is found.
*/
export function findElementWithText(
rootElement: Element,
selector: string,
matchRegexp: RegExp,
): Element | null {
const elements = rootElement.querySelectorAll(selector);
for (let i = 0; i < elements.length; i += 1) {
if (doesElementContainText(elements[i], matchRegexp)) {
return elements[i];
}
}
return null;
}

/**
* Retrieves the first Element that matches the selector, with the ability
* to select elements from inside open shadow-dom.
Expand All @@ -82,15 +123,20 @@ type QueryFunc = typeof document.querySelector;
* to find the element containing shadow root, and shadow root selector, to find the element inside shadow dom.
* @param context The Element or Document which is the context for the query.
* @param context.querySelector The querySelector function to use.
* @param textContent The text content to match.
* @returns The first Element within the document that matches the specified selector, or null if no matches are found.
*/
export function queryShadowSelector(
selector: string,
context: { querySelector: QueryFunc } = document.documentElement,
textContent: RegExp | null = null,
): ReturnType<QueryFunc> {
const SHADOW_COMBINATOR = ' >>> ';
const pos = selector.indexOf(SHADOW_COMBINATOR);
if (pos === -1) {
if (textContent) {
return findElementWithText(context as Element, selector, textContent);
}
return context.querySelector(selector);
}

Expand All @@ -101,5 +147,5 @@ export function queryShadowSelector(
}

const shadowRootSelector = selector.slice(pos + SHADOW_COMBINATOR.length).trim();
return queryShadowSelector(shadowRootSelector, elem.shadowRoot);
return queryShadowSelector(shadowRootSelector, elem.shadowRoot, textContent);
}
32 changes: 9 additions & 23 deletions src/scriptlets/trusted-click-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
logMessage,
parseMatchArg,
queryShadowSelector,
doesElementContainText,
findElementWithText,
} from '../helpers';
import { type Source } from './scriptlets';

Expand All @@ -16,6 +18,9 @@ import { type Source } from './scriptlets';
* @description
* Clicks selected elements in a strict sequence, ordered by selectors passed,
* and waiting for them to render in the DOM first.
* First matched element is clicked unless `containsText` is specified.
* If `containsText` is specified, then it searches for all given selectors and clicks
* the first element containing the specified text.
* Deactivates after all elements have been clicked or by 10s timeout.
*
* ### Syntax
Expand Down Expand Up @@ -304,24 +309,6 @@ export function trustedClickElement(

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 Down Expand Up @@ -430,9 +417,6 @@ export function trustedClickElement(

// Skip already clicked elements
if (!elementObj.clicked) {
if (textMatchRegexp && !doesElementContainText(elementObj.element, textMatchRegexp)) {
continue;
}
// Checks if node is connected to a Document object,
// if not, try to find the element again
// https://github.com/AdguardTeam/Scriptlets/issues/391
Expand Down Expand Up @@ -480,7 +464,7 @@ export function trustedClickElement(
if (!selector) {
return;
}
const element = queryShadowSelector(selector);
const element = queryShadowSelector(selector, document.documentElement, textMatchRegexp);
if (!element) {
return;
}
Expand Down Expand Up @@ -544,7 +528,7 @@ export function trustedClickElement(
if (!selector) {
return false;
}
const element = queryShadowSelector(selector);
const element = queryShadowSelector(selector, document.documentElement, textMatchRegexp);
return !!element;
});
if (foundElements) {
Expand Down Expand Up @@ -585,4 +569,6 @@ trustedClickElement.injections = [
logMessage,
parseMatchArg,
queryShadowSelector,
doesElementContainText,
findElementWithText,
];
62 changes: 62 additions & 0 deletions tests/scriptlets/trusted-click-element.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,35 @@ test('extraMatch - text match, matched', (assert) => {
}, 150);
});

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

const ELEM_COUNT = 1;
// Check hit func execution, one element should be clicked, and one should not be clicked (3)
const ASSERTIONS = ELEM_COUNT + 1 + 1;
assert.expect(ASSERTIONS);
const done = assert.async();

const selectorsString = `#${PANEL_ID} > [id^="${CLICKABLE_NAME}"]`;

runScriptlet(name, [selectorsString, EXTRA_MATCH_STR]);
const panel = createPanel();
const clickableNotMatched = createClickable(1, 'Not match');
const clickableMatched = createClickable(1, textToMatch);
const clickableMatchedShouldNotBeClicked = createClickable(1, textToMatch);
panel.appendChild(clickableNotMatched);
panel.appendChild(clickableMatched);
panel.appendChild(clickableMatchedShouldNotBeClicked);

setTimeout(() => {
assert.ok(clickableMatched.getAttribute('clicked'), 'Element should be clicked');
assert.notOk(clickableMatchedShouldNotBeClicked.getAttribute('clicked'), 'Element should NOT 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/';
Expand Down Expand Up @@ -894,3 +923,36 @@ test('Closed shadow dom element clicked', (assert) => {
done();
}, 150);
});

test('Closed shadow dom element clicked - text', (assert) => {
const textToMatch = 'Accept cookie';
const EXTRA_MATCH_STR = `containsText:${textToMatch}`;

const ELEM_COUNT = 1;
// Check hit func execution, one element should be clicked, and one should not be clicked (3)
const ASSERTIONS = ELEM_COUNT + 1 + 1;
assert.expect(ASSERTIONS);
const done = assert.async();

const selectorsString = `#${PANEL_ID} >>> div > [id^="${CLICKABLE_NAME}"]`;

runScriptlet(name, [selectorsString, EXTRA_MATCH_STR]);

const panel = createPanel();
const shadowRoot = panel.attachShadow({ mode: 'closed' });
const div = document.createElement('div');
const clickableNotMatched = createClickable(1, 'Not match');
const clickableMatched = createClickable(1, textToMatch);
const clickableMatchedShouldNOTBeClicked = createClickable(1, textToMatch);
div.appendChild(clickableNotMatched);
div.appendChild(clickableMatched);
div.appendChild(clickableMatchedShouldNOTBeClicked);
shadowRoot.appendChild(div);

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

0 comments on commit d12f6bf

Please sign in to comment.