diff --git a/src/check_selectors.py b/src/check_selectors.py new file mode 100644 index 0000000..7c5fc55 --- /dev/null +++ b/src/check_selectors.py @@ -0,0 +1,217 @@ +"""Report which selectors resolve against the Rewards UI you actually get. + +The Rewards markup differs between markets and changes between deploys, so a +selector that works for one account silently finds nothing for another. This +walks every selector and prints what resolved, what is absent, and what broke. + +It completes no activities and claims no points. It only reads, and opens the +points breakdown and daily set panels, which award nothing. + + poetry run python src/check_selectors.py + +Paste the output into a bug report. Absent is a normal result for a task the +variant does not ship. FAILED is what needs fixing. +""" + +import sys +import time + +from selenium import webdriver +from selenium.webdriver.common.by import By + +import element_selectors +from constants import USER_DATA_DIR, PROFILE_NAME + +RENDER_TIMEOUT = 60 + + +def build_driver(): + options = webdriver.EdgeOptions() + + options.add_experimental_option("excludeSwitches", ["enable-automation"]) + options.add_experimental_option("useAutomationExtension", False) + options.add_argument("--disable-blink-features=AutomationControlled") + options.add_argument(f"--user-data-dir={USER_DATA_DIR}") + options.add_argument(f"--profile-directory={PROFILE_NAME}") + + return webdriver.Edge(options=options) + + +def wait_until(predicate, timeout=RENDER_TIMEOUT): + deadline = time.time() + timeout + + while time.time() < deadline: + try: + if predicate(): + return True + except Exception: + pass + + time.sleep(2) + + return False + + +class Report: + def __init__(self): + self.rows = [] + + def record(self, name, status, detail=""): + self.rows.append((name, status, detail)) + print(f" {status:<8} {name:<44} {detail}") + + def check(self, name, fn, optional=False): + try: + value = fn() + except Exception as exc: + self.record(name, "ABSENT" if optional else "FAILED", type(exc).__name__) + return None + + if isinstance(value, list): + detail = f"{len(value)} element(s)" + + if not value and optional: + self.record(name, "ABSENT", "0 elements") + return value + elif isinstance(value, tuple): + detail = str(value) + else: + try: + detail = repr((value.text or "").replace("\n", " | ")[:44]) + except Exception: + detail = "" + + self.record(name, "OK", detail) + return value + + def summary(self): + counts = {"OK": 0, "ABSENT": 0, "FAILED": 0} + + for _, status, _ in self.rows: + counts[status] = counts.get(status, 0) + 1 + + print(f"\nOK={counts['OK']} ABSENT={counts['ABSENT']} FAILED={counts['FAILED']}") + + return counts["FAILED"] + + +def describe_environment(driver, report): + print("\n## environment") + + caps = driver.capabilities + + print(f" browser {caps.get('browserVersion')}") + print(f" msedgedriver {caps.get('msedge', {}).get('msedgedriverVersion', '?').split(' ')[0]}") + print(f" selenium {getattr(__import__('selenium'), '__version__', '?')}") + print(f" python {sys.version.split()[0]}") + print(f" page lang {driver.find_element(By.TAG_NAME, 'html').get_attribute('lang')!r}") + print(f" viewport {driver.execute_script('return [window.innerWidth, window.innerHeight];')}") + + sections = [ + s.get_dom_attribute("id") + for s in driver.find_elements(By.XPATH, "/html/body/div[2]/div[2]/div/main/section") + ] + print(f" earn sections {sections}") + + duplicates = [i for i in set(sections) if i and sections.count(i) > 1] + if duplicates: + print(f" duplicated ids {duplicates}") + + +def main(): + driver = build_driver() + elements = element_selectors.ElementSelectionUtils(driver) + report = Report() + + try: + driver.get("https://rewards.bing.com/earn") + + rendered = wait_until(lambda: elements.get_points_breakdown_button() is not None) + + if not rendered: + print("The earn page never finished rendering.") + print("In the EU the cookie consent banner blocks it until answered, and it") + print("cannot be dismissed reliably from selenium. Open the profile in a") + print("normal Edge window, answer the banner once, then run this again.") + return 2 + + describe_environment(driver, report) + + print("\n## navigation") + report.check("get_earn_tab", elements.get_earn_tab) + report.check("get_dashboard_tab", elements.get_dashboard_tab) + report.check("get_points_breakdown_button", elements.get_points_breakdown_button) + + print("\n## daily set") + opener = report.check("get_open_daily_set_button", elements.get_open_daily_set_button) + + if opener is not None: + driver.execute_script("arguments[0].scrollIntoView({block:'center'});", opener) + time.sleep(1) + driver.execute_script("arguments[0].click();", opener) + wait_until(lambda: elements.get_sidebar_section() is not None, 30) + report.check("get_daily_set_elements", elements.get_daily_set_elements) + + try: + driver.execute_script( + "arguments[0].click();", elements.get_generic_sidebar_close_button() + ) + time.sleep(2) + except Exception: + driver.get("https://rewards.bing.com/earn") + wait_until(lambda: elements.get_points_breakdown_button() is not None) + + print("\n## optional tasks") + report.check("get_explore_on_bing_elements", elements.get_explore_on_bing_elements, optional=True) + report.check("get_open_visual_search_sidebar", elements.get_open_visual_search_sidebar, optional=True) + + print("\n## cards") + cards = report.check("get_all_misc_cards", elements.get_all_misc_cards) + + if cards: + for index, card in enumerate(cards, start=1): + try: + points = elements.get_card_point_value(card) + done = elements.card_is_complete(card) + description = elements.extract_card_descriptions(card)[:38] + print(f" card[{index}] points={points:<4} completed={done!s:<5} {description!r}") + except Exception as exc: + print(f" card[{index}] unreadable: {type(exc).__name__}") + + print("\n## points breakdown") + driver.get("https://rewards.bing.com/earn") + wait_until(lambda: elements.get_points_breakdown_button() is not None) + driver.execute_script("arguments[0].click();", elements.get_points_breakdown_button()) + wait_until(lambda: elements.get_sidebar_section() is not None, 30) + + report.check("get_sidebar_section", elements.get_sidebar_section) + report.check( + "get_points_earned_from_searches_on_points_breakdown", + elements.get_points_earned_from_searches_on_points_breakdown, + ) + report.check("get_close_button_on_points_breakdown", elements.get_close_button_on_points_breakdown) + + print("\n## bonus") + driver.get("https://rewards.bing.com/dashboard") + wait_until(lambda: elements.get_bonus_button_on_dashboard() is not None, 30) + report.check("get_bonus_button_on_dashboard", elements.get_bonus_button_on_dashboard, optional=True) + + print("\n## bing") + driver.get("https://www.bing.com/") + wait_until(lambda: bool(driver.find_elements(By.TAG_NAME, "textarea")), 30) + report.check("get_bing_search_bar", elements.get_bing_search_bar) + + failures = report.summary() + + if failures: + print("\nFAILED entries are selectors that should have resolved on this page.") + else: + print("\nEvery selector that this variant ships resolved.") + + return 1 if failures else 0 + finally: + driver.quit() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/element_selectors.py b/src/element_selectors.py index 6563a59..f86e937 100644 --- a/src/element_selectors.py +++ b/src/element_selectors.py @@ -6,6 +6,25 @@ from selenium.common.exceptions import NoSuchElementException, StaleElementRefer from selenium import webdriver +class Labels: + """Visible labels the selectors match on. + + The Rewards markup carries no stable hooks for these controls, so they have + to be found by their text. That makes the lookups language dependent even + though they are market independent: a Rewards UI rendered in another + language needs these translated, and there is exactly one place to do it. + + Matching is case insensitive and by substring unless noted. + """ + + POINTS_BREAKDOWN = "points breakdown" + READY_TO_CLAIM = "ready to claim" + CLAIM = "claim" # exact label preferred, substring as fallback + DAILY_SET_STREAK = "daily set streak" + CARD_COMPLETED = "completed" + VISUAL_SEARCH = ("visual search", "image search") + + class ElementSelectionUtils: """Selectors for the Rewards UI. @@ -131,7 +150,7 @@ class ElementSelectionUtils: # "daily set streak" rather than "daily set", because the level up # section also has "Complete the Daily Set for 7 days in a row". try: - return self._button_containing("daily set streak") + return self._button_containing(Labels.DAILY_SET_STREAK) except NoSuchElementException: return self._streaks_button(3) @@ -156,7 +175,7 @@ class ElementSelectionUtils: # ------------------------------------------------------------------ def get_open_visual_search_sidebar(self): - for needle in ("visual search", "image search"): + for needle in Labels.VISUAL_SEARCH: try: return self._button_containing(needle) except NoSuchElementException: @@ -210,7 +229,7 @@ class ElementSelectionUtils: except NoSuchElementException: return False - return "completed" in (status or "").lower() + return Labels.CARD_COMPLETED in (status or "").lower() def get_card_point_value(self, card: WebElement): try: @@ -244,7 +263,7 @@ return ( # ------------------------------------------------------------------ def get_points_breakdown_button(self): - return self._button_containing("points breakdown") + return self._button_containing(Labels.POINTS_BREAKDOWN) def get_close_button_on_points_breakdown(self): return self.get_generic_sidebar_close_button() @@ -290,7 +309,7 @@ return ( # ------------------------------------------------------------------ def get_bonus_button_on_dashboard(self): - return self._button_containing("ready to claim") + return self._button_containing(Labels.READY_TO_CLAIM) def get_claim_bonus_points_button(self): sidebar = self.get_sidebar_section() @@ -300,14 +319,14 @@ return ( # would hit the "Ready to claim" heading before the actual Claim button. for button in buttons: try: - if (button.text or "").strip().lower() == "claim": + if (button.text or "").strip().lower() == Labels.CLAIM: return button except StaleElementReferenceException: continue for button in buttons: try: - if "claim" in (button.text or "").lower(): + if Labels.CLAIM in (button.text or "").lower(): return button except StaleElementReferenceException: continue diff --git a/src/rewards_tasks.py b/src/rewards_tasks.py index de1b966..cd92bad 100644 --- a/src/rewards_tasks.py +++ b/src/rewards_tasks.py @@ -85,8 +85,11 @@ class RewardsTaskUtils: explore_on_bing_links = self.elements.get_explore_on_bing_elements() if not explore_on_bing_links: - print("[INFO] No Explore on Bing section in this UI variant, skipping.") - return + # Raise rather than return, so complete_all_tasks reports this as + # [SKIP]. Returning quietly made it print [OK] for a task that never + # ran, which is exactly the kind of false success a scheduled run + # must not produce. + raise NoSuchElementException("no Explore on Bing section in this UI variant") for card in explore_on_bing_links: desc = self.elements.extract_card_descriptions(card)