From 706f5180a37404c39b71f30feaa102cc76135604 Mon Sep 17 00:00:00 2001 From: bcaller Date: Wed, 12 Jun 2024 13:51:57 +0100 Subject: [PATCH] Support `:remove`/`:remove-attr`/`:remove-class` adblock filter syntax (#23702) Support :remove/:remove-attr/:remove-class adblock filter syntax with new injected content script. Merge resource dicts to enable new remove keys supported by adblock-rs onMutation search addedNodes instead of document.querySelector Classes: check one exists Attributes: no need for check, just remove --- .../ad_block_service_browsertest.cc | 84 +++++++++++++ .../content/test/cosmetic_merge_unittest.cc | 119 ++++++++++++++++++ .../core/browser/ad_block_service_helper.cc | 42 ++++--- .../renderer/cosmetic_filters_js_handler.cc | 58 +++++++++ .../resources/data/content_cosmetic.ts | 51 ++++++++ components/definitions/global.d.ts | 4 + test/data/cosmetic_filtering.html | 68 +++++++--- 7 files changed, 392 insertions(+), 34 deletions(-) diff --git a/browser/brave_shields/ad_block_service_browsertest.cc b/browser/brave_shields/ad_block_service_browsertest.cc index daebb4190e84..065df4d5d4e6 100644 --- a/browser/brave_shields/ad_block_service_browsertest.cc +++ b/browser/brave_shields/ad_block_service_browsertest.cc @@ -2246,6 +2246,90 @@ IN_PROC_BROWSER_TEST_F(AdBlockServiceTest, CosmeticFilteringCustomStyle) { EXPECT_EQ(base::Value(true), result.value); } +IN_PROC_BROWSER_TEST_F(AdBlockServiceTest, CosmeticFilteringRemoveStatic) { + UpdateAdBlockInstanceWithRules( + "b.com###ad-banner:remove()\n" + "b.com##.whatever:remove()"); + + GURL tab_url = + embedded_test_server()->GetURL("b.com", "/cosmetic_filtering.html"); + NavigateToURL(tab_url); + + content::WebContents* contents = web_contents(); + + EXPECT_EQ(true, EvalJs(contents, "check('#ad-banner', existence(false))")); +} + +IN_PROC_BROWSER_TEST_F(AdBlockServiceTest, CosmeticFilteringRemoveDynamic) { + UpdateAdBlockInstanceWithRules("b.com##.blockme:remove()"); + + GURL tab_url = + embedded_test_server()->GetURL("b.com", "/cosmetic_filtering.html"); + NavigateToURL(tab_url); + + content::WebContents* contents = web_contents(); + + auto result = EvalJs(contents, + "addElementsDynamically();\n" + "wait('.dontblockme', existence(true)).then(() =>" + "wait('.blockme', existence(false)))"); + ASSERT_TRUE(result.error.empty()); + EXPECT_EQ(base::Value(true), result.value); +} + +IN_PROC_BROWSER_TEST_F(AdBlockServiceTest, CosmeticFilteringRemoveAttribute) { + UpdateAdBlockInstanceWithRules( + "b.com##.ad img:remove-attr(something)\n" + "b.com##.ad img:remove-attr(src)\n" + "b.com##.ad img:remove-attr(nothing)\n" + "b.com##img:remove-attr(whatever)"); + + GURL tab_url = + embedded_test_server()->GetURL("b.com", "/cosmetic_filtering.html"); + NavigateToURL(tab_url); + + content::WebContents* contents = web_contents(); + + EXPECT_EQ(true, EvalJs(contents, "check('.ad img', attributes(['alt']))")); + + // Sanity check selector + EXPECT_EQ( + true, + EvalJs(contents, "check('#relative-url-div img', attributes(['src']))")); +} + +IN_PROC_BROWSER_TEST_F(AdBlockServiceTest, + CosmeticFilteringRemoveAttributeDynamic) { + UpdateAdBlockInstanceWithRules("b.com##img.blockme:remove-attr(src)"); + + GURL tab_url = + embedded_test_server()->GetURL("b.com", "/cosmetic_filtering.html"); + NavigateToURL(tab_url); + + content::WebContents* contents = web_contents(); + + auto result = EvalJs(contents, + "addElementsDynamically();\n" + "wait('img.blockme', attributes(['class']))"); + ASSERT_TRUE(result.error.empty()); + EXPECT_EQ(base::Value(true), result.value); +} + +IN_PROC_BROWSER_TEST_F(AdBlockServiceTest, CosmeticFilteringRemoveClass) { + UpdateAdBlockInstanceWithRules( + "b.com##.ad:remove-class(ghi)\n" + "b.com##div:remove-class(whatever)"); + + GURL tab_url = + embedded_test_server()->GetURL("b.com", "/cosmetic_filtering.html"); + NavigateToURL(tab_url); + + content::WebContents* contents = web_contents(); + + EXPECT_EQ(true, EvalJs(contents, "check('.ghi', existence(false))")); + EXPECT_EQ(true, EvalJs(contents, "check('.ad.jkl', classes(['ad', 'jkl']))")); +} + // Test rules overridden by hostname-specific exception rules IN_PROC_BROWSER_TEST_F(AdBlockServiceTest, CosmeticFilteringUnhide) { UpdateAdBlockInstanceWithRules( diff --git a/components/brave_shields/content/test/cosmetic_merge_unittest.cc b/components/brave_shields/content/test/cosmetic_merge_unittest.cc index e7e886ce0cc9..ba610ab26a86 100644 --- a/components/brave_shields/content/test/cosmetic_merge_unittest.cc +++ b/components/brave_shields/content/test/cosmetic_merge_unittest.cc @@ -50,6 +50,9 @@ const char EMPTY_RESOURCES[] = "\"hide_selectors\": [], " "\"style_selectors\": {}, " "\"exceptions\": [], " + "\"remove_selectors\": [], " + "\"remove_attrs\": {}, " + "\"remove_classes\": {}, " "\"injected_script\": \"\", " "\"generichide\": false" "}"; @@ -62,6 +65,15 @@ const char NONEMPTY_RESOURCES[] = "\"d\": [\"color: #000\"]" "}, " "\"exceptions\": [\"e\", \"f\"], " + "\"remove_selectors\": [\"x\", \"y\"], " + "\"remove_attrs\": {" + "\"v\": [\"href\", \"id\"], " + "\"w\": [\"attr\"]" + "}, " + "\"remove_classes\": {" + "\"t\": [\"class-one\", \"class-two\"], " + "\"u\": [\"class-three\"]" + "}, " "\"injected_script\": \"console.log('g')\", " "\"generichide\": false" "}"; @@ -77,6 +89,9 @@ TEST_F(CosmeticResourceMergeTest, MergeTwoEmptyResources) { "\"hide_selectors\": [], " "\"style_selectors\": {}, " "\"exceptions\": [], " + "\"remove_selectors\": [], " + "\"remove_attrs\": {}, " + "\"remove_classes\": {}, " "\"injected_script\": \"\n\", " "\"generichide\": false" "}"; @@ -98,6 +113,15 @@ TEST_F(CosmeticResourceMergeTest, MergeEmptyIntoNonEmpty) { "\"d\": [\"color: #000\"]" "}, " "\"exceptions\": [\"e\", \"f\"], " + "\"remove_selectors\": [\"x\", \"y\"], " + "\"remove_attrs\": {" + "\"v\": [\"href\", \"id\"], " + "\"w\": [\"attr\"]" + "}, " + "\"remove_classes\": {" + "\"t\": [\"class-one\", \"class-two\"], " + "\"u\": [\"class-three\"]" + "}, " "\"injected_script\": \"console.log('g')\n\", " "\"generichide\": false" "}"; @@ -119,6 +143,15 @@ TEST_F(CosmeticResourceMergeTest, MergeNonEmptyIntoEmpty) { "\"d\": [\"color: #000\"]" "}, " "\"exceptions\": [\"e\", \"f\"], " + "\"remove_selectors\": [\"x\", \"y\"], " + "\"remove_attrs\": {" + "\"v\": [\"href\", \"id\"], " + "\"w\": [\"attr\"]" + "}, " + "\"remove_classes\": {" + "\"t\": [\"class-one\", \"class-two\"], " + "\"u\": [\"class-three\"]" + "}, " "\"injected_script\": \"\nconsole.log('g')\", " "\"generichide\": false" "}"; @@ -136,6 +169,13 @@ TEST_F(CosmeticResourceMergeTest, MergeNonEmptyIntoNonEmpty) { "\"k\": [\"color: #111\"]" "}, " "\"exceptions\": [\"l\", \"m\"], " + "\"remove_selectors\": [\"k\"], " + "\"remove_attrs\": {" + "\"s\": [\"attr\"]" + "}, " + "\"remove_classes\": {" + "\"r\": [\"class-six\"]" + "}, " "\"injected_script\": \"console.log('n')\", " "\"generichide\": false" "}"; @@ -150,6 +190,17 @@ TEST_F(CosmeticResourceMergeTest, MergeNonEmptyIntoNonEmpty) { "\"k\": [\"color: #111\"]" "}, " "\"exceptions\": [\"e\", \"f\", \"l\", \"m\"], " + "\"remove_selectors\": [\"x\", \"y\", \"k\"], " + "\"remove_attrs\": {" + "\"v\": [\"href\", \"id\"], " + "\"w\": [\"attr\"], " + "\"s\": [\"attr\"]" + "}, " + "\"remove_classes\": {" + "\"t\": [\"class-one\", \"class-two\"], " + "\"u\": [\"class-three\"], " + "\"r\": [\"class-six\"]" + "}, " "\"injected_script\": \"console.log('g')\nconsole.log('n')\", " "\"generichide\": false" "}"; @@ -168,6 +219,9 @@ TEST_F(CosmeticResourceMergeTest, MergeEmptyForceHide) { "\"hide_selectors\": [], " "\"style_selectors\": {}, " "\"exceptions\": [], " + "\"remove_selectors\": [], " + "\"remove_attrs\": {}, " + "\"remove_classes\": {}, " "\"injected_script\": \"\n\"," "\"generichide\": false, " "\"force_hide_selectors\": []" @@ -186,6 +240,9 @@ TEST_F(CosmeticResourceMergeTest, MergeNonEmptyForceHide) { "\"k\": [\"color: #111\"]" "}, " "\"exceptions\": [\"l\", \"m\"], " + "\"remove_selectors\": [], " + "\"remove_attrs\": {}, " + "\"remove_classes\": {}, " "\"injected_script\": \"console.log('n')\", " "\"generichide\": false" "}"; @@ -200,6 +257,15 @@ TEST_F(CosmeticResourceMergeTest, MergeNonEmptyForceHide) { "\"k\": [\"color: #111\"]" "}, " "\"exceptions\": [\"e\", \"f\", \"l\", \"m\"], " + "\"remove_selectors\": [\"x\", \"y\"], " + "\"remove_attrs\": {" + "\"v\": [\"href\", \"id\"], " + "\"w\": [\"attr\"]" + "}, " + "\"remove_classes\": {" + "\"t\": [\"class-one\", \"class-two\"], " + "\"u\": [\"class-three\"]" + "}, " "\"injected_script\": \"console.log('g')\nconsole.log('n')\"," "\"generichide\": false, " "\"force_hide_selectors\": [\"h\", \"i\"]" @@ -214,6 +280,9 @@ TEST_F(CosmeticResourceMergeTest, MergeNonGenerichideIntoGenerichide) { "\"hide_selectors\": [], " "\"style_selectors\": {}, " "\"exceptions\": [], " + "\"remove_selectors\": [], " + "\"remove_attrs\": {}, " + "\"remove_classes\": {}, " "\"injected_script\": \"\n\", " "\"generichide\": true" "}"; @@ -224,6 +293,9 @@ TEST_F(CosmeticResourceMergeTest, MergeNonGenerichideIntoGenerichide) { "\"hide_selectors\": [], " "\"style_selectors\": {}, " "\"exceptions\": [], " + "\"remove_selectors\": [], " + "\"remove_attrs\": {}, " + "\"remove_classes\": {}, " "\"injected_script\": \"\n\n\", " "\"generichide\": true" "}"; @@ -241,6 +313,9 @@ TEST_F(CosmeticResourceMergeTest, MergeGenerichideIntoNonGenerichide) { "\"k\": [\"color: #111\"]" "}, " "\"exceptions\": [\"l\", \"m\"], " + "\"remove_selectors\": [], " + "\"remove_attrs\": {}, " + "\"remove_classes\": {}, " "\"injected_script\": \"console.log('n')\", " "\"generichide\": true" "}"; @@ -255,6 +330,15 @@ TEST_F(CosmeticResourceMergeTest, MergeGenerichideIntoNonGenerichide) { "\"k\": [\"color: #111\"]" "}, " "\"exceptions\": [\"e\", \"f\", \"l\", \"m\"], " + "\"remove_selectors\": [\"x\", \"y\"], " + "\"remove_attrs\": {" + "\"v\": [\"href\", \"id\"], " + "\"w\": [\"attr\"]" + "}, " + "\"remove_classes\": {" + "\"t\": [\"class-one\", \"class-two\"], " + "\"u\": [\"class-three\"]" + "}, " "\"injected_script\": \"console.log('g')\nconsole.log('n')\", " "\"generichide\": true" "}"; @@ -268,6 +352,9 @@ TEST_F(CosmeticResourceMergeTest, MergeGenerichideIntoGenerichide) { "\"hide_selectors\": [], " "\"style_selectors\": {}, " "\"exceptions\": [], " + "\"remove_selectors\": [], " + "\"remove_attrs\": {}, " + "\"remove_classes\": {}, " "\"injected_script\": \"\", " "\"generichide\": true" "}"; @@ -277,6 +364,9 @@ TEST_F(CosmeticResourceMergeTest, MergeGenerichideIntoGenerichide) { "\"hide_selectors\": [], " "\"style_selectors\": {}, " "\"exceptions\": [], " + "\"remove_selectors\": [], " + "\"remove_attrs\": {}, " + "\"remove_classes\": {}, " "\"injected_script\": \"\n\", " "\"generichide\": true" "}"; @@ -294,6 +384,15 @@ TEST_F(CosmeticResourceMergeTest, MergeStyles) { "\".d\": [\"padding: 0\"]" "}, " "\"exceptions\": [], " + "\"remove_selectors\": [], " + "\"remove_attrs\": {" + "\"v\": [\"href\", \"id\"], " + "\"w\": [\"attr\"]" + "}, " + "\"remove_classes\": {" + "\"t\": [\"class-one\", \"class-two\"], " + "\"u\": [\"class-three\"]" + "}, " "\"injected_script\": \"\", " "\"generichide\": false" "}"; @@ -306,6 +405,15 @@ TEST_F(CosmeticResourceMergeTest, MergeStyles) { "\".a\": [\"background: #fff\"]" "}, " "\"exceptions\": [], " + "\"remove_selectors\": [], " + "\"remove_attrs\": {" + "\"v\": [\"class\", \"data-no\"], " + "\"s\": [\"attr\"]" + "}, " + "\"remove_classes\": {" + "\"t\": [\"class-four\", \"class-five\"], " + "\"r\": [\"class-six\"]" + "}, " "\"injected_script\": \"\", " "\"generichide\": false" "}"; @@ -320,6 +428,17 @@ TEST_F(CosmeticResourceMergeTest, MergeStyles) { "\".d\": [\"padding: 0\"] " "}, " "\"exceptions\": [], " + "\"remove_selectors\": [], " + "\"remove_attrs\": {" + "\"v\": [\"href\", \"id\", \"class\", \"data-no\"], " + "\"w\": [\"attr\"], " + "\"s\": [\"attr\"]" + "}, " + "\"remove_classes\": {" + "\"t\": [\"class-one\", \"class-two\", \"class-four\", \"class-five\"], " + "\"u\": [\"class-three\"], " + "\"r\": [\"class-six\"]" + "}, " "\"injected_script\": \"\n\", " "\"generichide\": false" "}"; diff --git a/components/brave_shields/core/browser/ad_block_service_helper.cc b/components/brave_shields/core/browser/ad_block_service_helper.cc index b7b53a22217a..9b442f8e094a 100644 --- a/components/brave_shields/core/browser/ad_block_service_helper.cc +++ b/components/brave_shields/core/browser/ad_block_service_helper.cc @@ -63,30 +63,34 @@ void MergeResourcesInto(base::Value::Dict from, } } - base::Value::Dict* resources_style_selectors = - into.FindDict("style_selectors"); - base::Value::Dict* from_resources_style_selectors = - from.FindDict("style_selectors"); - if (resources_style_selectors && from_resources_style_selectors) { - for (auto [key, value] : *from_resources_style_selectors) { - base::Value::List* resources_entry = - resources_style_selectors->FindList(key); - if (resources_entry) { - DCHECK(value.is_list()); - for (auto& item : value.GetList()) { - resources_entry->Append(std::move(item)); + constexpr std::string_view kDictListKeys[] = { + "style_selectors", "remove_classes", "remove_attrs"}; + for (const auto& key_ : kDictListKeys) { + base::Value::Dict* resources = into.FindDict(key_); + base::Value::Dict* from_resources = from.FindDict(key_); + if (resources && from_resources) { + for (auto [key, value] : *from_resources) { + base::Value::List* resources_entry = resources->FindList(key); + if (resources_entry) { + DCHECK(value.is_list()); + for (auto& item : value.GetList()) { + resources_entry->Append(std::move(item)); + } + } else { + resources->Set(key, std::move(value)); } - } else { - resources_style_selectors->Set(key, std::move(value)); } } } - base::Value::List* resources_exceptions = into.FindList("exceptions"); - base::Value::List* from_resources_exceptions = from.FindList("exceptions"); - if (resources_exceptions && from_resources_exceptions) { - for (auto& exception : *from_resources_exceptions) { - resources_exceptions->Append(std::move(exception)); + constexpr std::string_view kListKeys[] = {"exceptions", "remove_selectors"}; + for (const auto& key_ : kListKeys) { + base::Value::List* resources = into.FindList(key_); + base::Value::List* from_resources = from.FindList(key_); + if (resources && from_resources) { + for (auto& exception : *from_resources) { + resources->Append(std::move(exception)); + } } } diff --git a/components/cosmetic_filters/renderer/cosmetic_filters_js_handler.cc b/components/cosmetic_filters/renderer/cosmetic_filters_js_handler.cc index ac827ddcba56..9654ca01cb5e 100644 --- a/components/cosmetic_filters/renderer/cosmetic_filters_js_handler.cc +++ b/components/cosmetic_filters/renderer/cosmetic_filters_js_handler.cc @@ -116,6 +116,22 @@ const char kHideSelectorsInjectScript[] = }; })();)"; +const char kRemovalsInjectScript[] = + R"((function() { + const CC = window.content_cosmetic; + CC.selectorsToRemove = %s; + const dictToMap = (d) => d === undefined + ? d + : new Map(Object.entries(d)); + CC.classesToRemoveBySelector = dictToMap(%s); + CC.attributesToRemoveBySelector = dictToMap(%s); + CC.hasRemovals = ( + CC.selectorsToRemove !== undefined + || CC.classesToRemoveBySelector !== undefined + || CC.attributesToRemoveBySelector !== undefined + ); + })();)"; + std::string LoadDataResource(const int id) { auto& resource_bundle = ui::ResourceBundle::GetSharedInstance(); if (resource_bundle.IsGzipped(id)) { @@ -459,6 +475,48 @@ void CosmeticFiltersJSHandler::ApplyRules(bool de_amp_enabled) { ExecuteObservingBundleEntryPoint(); CSSRulesRoutine(*resources_dict_); + + bool has_removals = false; + //: remove() + std::string remove_selectors_json; + const auto* remove_selectors_list = + resources_dict_->FindList("remove_selectors"); + if (remove_selectors_list && !remove_selectors_list->empty()) { + base::JSONWriter::Write(*remove_selectors_list, &remove_selectors_json); + has_removals = true; + } else { + remove_selectors_json = "undefined"; + } + + //: remove_classes + std::string remove_classes_json; + const auto* remove_classes_dictionary = + resources_dict_->FindDict("remove_classes"); + if (remove_classes_dictionary && !remove_classes_dictionary->empty()) { + base::JSONWriter::Write(*remove_classes_dictionary, &remove_classes_json); + has_removals = true; + } + + //: remove_attrs + std::string remove_attrs_json; + const auto* remove_attrs_dictionary = + resources_dict_->FindDict("remove_attrs"); + if (remove_attrs_dictionary && !remove_attrs_dictionary->empty()) { + base::JSONWriter::Write(*remove_attrs_dictionary, &remove_attrs_json); + has_removals = true; + } + + if (has_removals) { + // Building a script for removals + std::string new_selectors_script = base::StringPrintf( + kRemovalsInjectScript, remove_selectors_json.c_str(), + remove_classes_json.c_str(), remove_attrs_json.c_str()); + web_frame->ExecuteScriptInIsolatedWorld( + isolated_world_id_, + blink::WebScriptSource( + blink::WebString::FromUTF8(new_selectors_script)), + blink::BackForwardCacheAware::kAllow); + } } void CosmeticFiltersJSHandler::CSSRulesRoutine( diff --git a/components/cosmetic_filters/resources/data/content_cosmetic.ts b/components/cosmetic_filters/resources/data/content_cosmetic.ts index 81596431564a..bcfe59bdcd8c 100644 --- a/components/cosmetic_filters/resources/data/content_cosmetic.ts +++ b/components/cosmetic_filters/resources/data/content_cosmetic.ts @@ -76,6 +76,8 @@ CC.switchToSelectorsPollingThreshold = CC.fetchNewClassIdRulesThrottlingMs = CC.fetchNewClassIdRulesThrottlingMs || undefined +CC.hasRemovals = CC.hasRemovals || false + /** * Provides a new function which can only be scheduled once at a time. * @@ -283,6 +285,18 @@ const onMutations = (mutations: MutationRecord[], observer: MutationObserver) => fetchNewClassIdRules() } + if (CC.hasRemovals) { + const addedElements : Element[] = []; + mutations.forEach(mutation => + mutation.addedNodes.length !== 0 && mutation.addedNodes.forEach(n => + n.nodeType === Node.ELEMENT_NODE && addedElements.push(n as Element) + ) + ) + if (addedElements.length !== 0) { + executeRemovals(addedElements); + } + } + if (eventId) { // Callback to c++ renderer process // @ts-expect-error @@ -629,6 +643,8 @@ const queryAttrsFromDocument = (switchToMutationObserverAtTime?: number) => { fetchNewClassIdRules() + if (CC.hasRemovals) executeRemovals(); + if (eventId) { // Callback to c++ renderer process // @ts-expect-error @@ -692,3 +708,38 @@ const tryScheduleQueuePump = () => { CC.tryScheduleQueuePump = CC.tryScheduleQueuePump || tryScheduleQueuePump tryScheduleQueuePump() + +const executeRemovals = (added?: Element[]) => { + // If passed a list of added elements, do not query the entire document + const findMatchingElements = + (added === undefined) + ? (selector : string) => document.querySelectorAll(selector) + : (selector : string) => added.filter(elem => elem.matches(selector)); + if (CC.selectorsToRemove !== undefined) { + CC.selectorsToRemove.forEach( + selector => findMatchingElements(selector).forEach(elem => elem.remove()) + ); + } + if (CC.classesToRemoveBySelector !== undefined) { + for (const [selector, classesToRemove] of CC.classesToRemoveBySelector) { + findMatchingElements(selector).forEach((elem : Element) => { + // Check if the element has any classes to remove because + // classList.remove(tokens...) always triggers another mutation + // even if nothing was removed. + if(classesToRemove.find(c => elem.classList.contains(c))) { + elem.classList.remove.apply(elem.classList, classesToRemove); + } + }); + } + } + if (CC.attributesToRemoveBySelector !== undefined) { + for (const [selector, attributes] of CC.attributesToRemoveBySelector) { + // We can remove attributes without checking if they exist + findMatchingElements(selector).forEach(elem => + attributes.forEach(elem.removeAttribute.bind(elem)) + ); + } + } +}; + +if (CC.hasRemovals) executeRemovals(); \ No newline at end of file diff --git a/components/definitions/global.d.ts b/components/definitions/global.d.ts index 296122b3bba6..993b33db1f69 100644 --- a/components/definitions/global.d.ts +++ b/components/definitions/global.d.ts @@ -44,6 +44,10 @@ declare global { switchToSelectorsPollingThreshold : number | undefined fetchNewClassIdRulesThrottlingMs : number | undefined tryScheduleQueuePump: (() => void) + selectorsToRemove: string[] | undefined + classesToRemoveBySelector: Map | undefined + attributesToRemoveBySelector: Map | undefined + hasRemovals: boolean } } } diff --git a/test/data/cosmetic_filtering.html b/test/data/cosmetic_filtering.html index 41f844fa2283..58febfaf2bdf 100644 --- a/test/data/cosmetic_filtering.html +++ b/test/data/cosmetic_filtering.html @@ -26,16 +26,10 @@ let didWait = false; -function checkSelector(selector, property, expected) { +function check(selector, predicate) { const checkSelectorInner = () => { let elements = [].slice.call(document.querySelectorAll(selector)); - if (elements.length === 0) - return false - let result = elements.every(e => { - let style = window.getComputedStyle(e); - return style[property] === expected; - }) - return result; + return predicate(elements); }; // The first selector check must occur after the page has had time to load, @@ -52,28 +46,72 @@ } } -async function waitCSSSelector(selector, property, expected) { - if (await checkSelector(selector, property, expected)) { +async function wait(selector, predicate) { + if (await check(selector, predicate)) { return true; } else { - console.log('still waiting for css selector'); + console.log('still waiting for css selector ' + selector); return new Promise(resolve => { setTimeout(function () { - resolve(waitCSSSelector(selector, property, expected)); + resolve(wait(selector, predicate)); }, 200); }); } } +// true if matches and the CSS style of every matched element has the expected value for the property +const cssSelector = (property, expected) => + (elements) => { + if (elements.length === 0) + return false + let result = elements.every(e => { + let style = window.getComputedStyle(e); + return style[property] === expected; + }) + return result; + }; + +function checkSelector(selector, property, expected) { + return check(selector, cssSelector(property, expected)); +} + +const waitCSSSelector = async (selector, property, expected) => wait(selector, cssSelector(property, expected)); + +// true if the selector matches some elements or no elements +const existence = (expectedToHaveElements) => + (elements) => { + return !!elements.length === expectedToHaveElements; + }; + +// true if matched and every matched element has exactly the specified named attributes +const attributes = (expectedAttributes) => + (elements) => { + if (elements.length === 0) + return false; + return elements.every(e => { + return e.attributes.length === expectedAttributes.length && expectedAttributes.every(a => e.hasAttribute(a)); + }); + }; + +// true if matches and every matched element has exactly the specified classes +const classes = (expectedClasses) => + (elements) => { + if (elements.length === 0) + return false; + return elements.every(e => { + return e.classList.length === expectedClasses.length && expectedClasses.every(c => e.classList.contains(c)); + }); + }; +
-
+
-
-
+
+