fix(privacy): Procedural filters matching against dynamically added child elements (#29139)

* For procedural filtering, check child nodes when failing to match root element to the selector.

* Add browser test for element with child procedural filter match.

* Add iOS test for element with child procedural filter match.

* Remove outdated procedural filters script sync comment after landing https://github.com/brave/brave-browser/issues/43262.
This commit is contained in:
StephenHeaps
2025-05-25 14:23:50 -04:00
committed by GitHub
parent 28bdc598b1
commit 81935db0e9
8 changed files with 116 additions and 19 deletions
@@ -2761,6 +2761,27 @@ IN_PROC_BROWSER_TEST_F(AdBlockServiceTest, ProceduralFilterHasText) {
}
}
// Test procedural filters matching child of dynamically added element
IN_PROC_BROWSER_TEST_F(AdBlockServiceTest,
ProceduralFilterDynamicAddedChildHasText) {
UpdateAdBlockInstanceWithRules(
"a.com##.procedural-filter-child-node-class:has-text(View in App)");
GURL tab_url =
embedded_test_server()->GetURL("a.com", "/cosmetic_filtering.html");
NavigateToURL(tab_url);
content::WebContents* contents = web_contents();
auto result = EvalJs(contents,
R"(
addElementWithChildDynamically();
waitCSSSelector('.procedural-filter-child-node-class', 'display', 'none')
)");
ASSERT_TRUE(result.error.empty());
EXPECT_EQ(base::Value(true), result.value);
}
// Test `matches-attr` procedural filters
IN_PROC_BROWSER_TEST_F(AdBlockServiceTest, ProceduralFilterMatchesAttr) {
UpdateAdBlockInstanceWithRules(
@@ -299,9 +299,13 @@ const onMutations = (mutations: MutationRecord[], observer: MutationObserver) =>
if (CC.hasProceduralActions) {
const addedElements : Element[] = [];
mutations.forEach(mutation =>
mutation.addedNodes.length !== 0 && mutation.addedNodes.forEach(n =>
mutation.addedNodes.length !== 0 && mutation.addedNodes.forEach(n => {
n.nodeType === Node.ELEMENT_NODE && addedElements.push(n as Element)
)
const childNodes = (n as Element).querySelectorAll('*')
childNodes.length !== 0 && childNodes.forEach(c => {
c.nodeType === Node.ELEMENT_NODE && addedElements.push(c as Element)
})
})
)
if (addedElements.length !== 0) {
executeProceduralActions(addedElements);
@@ -357,9 +357,15 @@ import { applyCompiledSelector, compileProceduralSelector } from './procedural_f
if (CC.hasProceduralActions) {
const addedElements = [];
mutations.forEach(mutation =>
mutation.addedNodes.length !== 0 && mutation.addedNodes.forEach(n =>
n.nodeType === Node.ELEMENT_NODE && addedElements.push(n)
)
mutation.addedNodes.length !== 0 && mutation.addedNodes.forEach(n => {
if (n.nodeType === Node.ELEMENT_NODE) {
addedElements.push(n)
const childNodes = n.querySelectorAll('*')
childNodes.length !== 0 && childNodes.forEach(c => {
c.nodeType === Node.ELEMENT_NODE && addedElements.push(c)
})
}
})
)
if (addedElements.length !== 0) {
executeProceduralActions(addedElements);
@@ -3,15 +3,6 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
/**
*
* This file should be kept up to date with
* https://github.com/brave-experiments/procedural-filters-js
* until the full implementation can be shared with iOS inside
* the brave-core repo.
*
*/
/**
*
* src/declarations.d.ts
@@ -209,13 +200,13 @@ const _nextSiblingElement = (element: HTMLElement): HTMLElement | null => {
const _allChildren = (element: HTMLElement): HTMLElement[] => {
return W.Array.from(element.children)
.map(e => _asHTMLElement(e))
.filter(e => e !== null) as HTMLElement[]
.filter(e => e !== null)
}
const _allChildrenRecursive = (element: HTMLElement): HTMLElement[] => {
return W.Array.from(element.querySelectorAll(':scope *'))
.map(e => _asHTMLElement(e))
.filter(e => e !== null) as HTMLElement[]
.filter(e => e !== null)
}
const _stripCssOperator = (operator: string, selector: string) => {
@@ -134,6 +134,28 @@
document.body.appendChild(div);
}
const addElementWithChildDynamically = () => {
let root = document.documentElement;
const rootElement = document.createElement('div');
rootElement.setAttribute('class', 'procedural-filter-root-node-class');
const childNode = document.createElement('div');
childNode.setAttribute('class', 'procedural-filter-child-node-class');
rootElement.appendChild(childNode);
const p = document.createElement('p');
p.textContent = 'View in App';
childNode.appendChild(p);
const childNode2 = document.createElement('div');
childNode2.setAttribute('class', 'procedural-filter-child-node-class');
rootElement.appendChild(childNode2);
const span = document.createElement('span');
span.textContent = 'View in App';
childNode2.appendChild(span);
root.appendChild(rootElement);
}
const onload = () => {
const localFrame = document.createElement('iframe');
localFrame.id = 'local-iframe';
@@ -149,7 +171,6 @@
setTimeout(displaySvalInBody, 100);
setTimeout(displaySvalInLocalFrame, 200);
setTimeout(addElementForProceduralFilter, 500);
};
window.onload = onload;
</script>
@@ -25,7 +25,8 @@
'localFrameElement': results.localFrameElement,
'hasTextDisplayIsNone': results.hasTextDisplayIsNone,
'hasDisplayIsNone': results.hasDisplayIsNone,
'delayedHasTextHidden': results.delayedHasTextHidden
'delayedHasTextHidden': results.delayedHasTextHidden,
'delayedChildHasTextHidden': results.delayedChildHasTextHidden,
})
}
@@ -47,7 +48,8 @@
localFrameElement: false,
hasTextDisplayIsNone: false,
hasDisplayIsNone: false,
delayedHasTextHidden: false
delayedHasTextHidden: false,
delayedChildHasTextHidden: []
}
elements.forEach((node) => {
@@ -112,6 +114,21 @@
}
})
const elementsWithClass = document.querySelectorAll('[class]')
elementsWithClass.forEach((node) => {
if (!node.hasAttribute('class')) {
return
}
if (node.getAttribute('class') === 'procedural-filter-child-node-class') {
const nodeDisplay = window.getComputedStyle(node).display
// 2 elements have this class, we want to test both their display values
const delayedChildHasTextHidden = results.delayedChildHasTextHidden
delayedChildHasTextHidden.push(nodeDisplay === 'none')
results.delayedChildHasTextHidden = delayedChildHasTextHidden
}
})
return results
}
@@ -37,6 +37,7 @@ final class ScriptExecutionTests: XCTestCase {
let hasTextDisplayIsNone: Bool
let hasDisplayIsNone: Bool
let delayedHasTextHidden: Bool
let delayedChildHasTextHidden: [Bool]
}
override class func setUp() {
@@ -263,6 +264,7 @@ final class ScriptExecutionTests: XCTestCase {
"brave.com###test-has-text:has-text(hide me)",
"brave.com###test-has:has(a.banner-link)",
"brave.com###test-delayed-has-text:has-text(hide me)",
"brave.com##.procedural-filter-child-node-class:has-text(View in App)",
].joined(separator: "\n")
)
let cosmeticFilterModel = try engine.cosmeticFilterModel(forFrameURL: siteURL)!
@@ -407,6 +409,12 @@ final class ScriptExecutionTests: XCTestCase {
// Now wait for the pump which takes a few seconds (The pump unhides 1st party elements).
try await Task.sleep(seconds: 5)
let addDynamicElementsJavascript = """
addElementForProceduralFilter();
addElementWithChildDynamically();
"""
try await viewController.webView.evaluateJavaScript(addDynamicElementsJavascript)
// Execute a script that will test the cosmetic filters page
let testURL = Bundle.module.url(forResource: "cosmetic-filter-tests", withExtension: "js")!
let source = try String(contentsOf: testURL)
@@ -469,6 +477,13 @@ final class ScriptExecutionTests: XCTestCase {
XCTAssertTrue(resultsAfterPump?.hasTextDisplayIsNone ?? false)
XCTAssertTrue(resultsAfterPump?.hasDisplayIsNone ?? false)
XCTAssertTrue(resultsAfterPump?.delayedHasTextHidden ?? false)
if let delayedChildHasTextHidden = resultsAfterPump?.delayedChildHasTextHidden {
// expecting 2 elements with the same id to be hidden
XCTAssertEqual(delayedChildHasTextHidden.count, 2)
XCTAssertTrue(delayedChildHasTextHidden.allSatisfy({ $0 }))
} else {
XCTFail("delayedChildHasTextHidden missing in results")
}
// Test for local frames
XCTAssertTrue(resultsAfterPump?.localFrameElement ?? false)
}
+22
View File
@@ -24,6 +24,28 @@ function addElementsDynamically() {
}
}
function addElementWithChildDynamically() {
let root = document.documentElement;
const rootElement = document.createElement('div');
rootElement.setAttribute('class', 'procedural-filter-root-node-class');
const childNode = document.createElement('div');
childNode.setAttribute('class', 'procedural-filter-child-node-class');
rootElement.appendChild(childNode);
const p = document.createElement('p');
p.textContent = 'View in App';
childNode.appendChild(p);
const childNode2 = document.createElement('div');
childNode2.setAttribute('class', 'procedural-filter-child-node-class');
rootElement.appendChild(childNode2);
const span = document.createElement('span');
span.textContent = 'View in App';
childNode2.appendChild(span);
root.appendChild(rootElement);
}
let didWait = false;
function check(selector, predicate) {