mirror of
https://github.com/User0332/rewards-farmer.git
synced 2026-09-16 01:31:36 +00:00
Address code review: refactor move_to_and_click retries, simplify wait_for_then_click, resolve conflicts with main
This commit is contained in:
@@ -6,6 +6,20 @@ from selenium.common.exceptions import NoSuchElementException, StaleElementRefer
|
||||
from selenium import webdriver
|
||||
|
||||
|
||||
class ElementNotReady(NoSuchElementException):
|
||||
"""The element is in the page but not usable yet.
|
||||
|
||||
A section this market does not ship and a section that has not finished
|
||||
hydrating both reach the caller as NoSuchElementException, which is why a
|
||||
run could report "not available in this UI variant" for something that was
|
||||
on screen. They need different messages and different next steps, so the
|
||||
second case gets its own type.
|
||||
|
||||
Subclassed rather than separate, so every existing `except
|
||||
NoSuchElementException` keeps catching it.
|
||||
"""
|
||||
|
||||
|
||||
class Labels:
|
||||
"""Visible labels the selectors match on.
|
||||
|
||||
@@ -45,7 +59,9 @@ class ElementSelectionUtils:
|
||||
pick the copy that is visible and actually has content.
|
||||
|
||||
Anything the current variant does not ship raises NoSuchElementException so
|
||||
the caller can skip that task instead of aborting the whole run.
|
||||
the caller can skip that task instead of aborting the whole run. Something
|
||||
that is present but not usable yet raises ElementNotReady instead, because
|
||||
skipping it is the wrong answer and so is the message that goes with it.
|
||||
"""
|
||||
|
||||
def __init__(self, driver: webdriver.Edge):
|
||||
@@ -66,6 +82,11 @@ class ElementSelectionUtils:
|
||||
their `.text` is empty, so returning one produces silent no-ops further
|
||||
up. Raising instead lets the caller's WebDriverWait retry while the page
|
||||
finishes hydrating.
|
||||
|
||||
The two failures are not the same finding. No element with the id means
|
||||
this variant does not ship the section. An id that is there but has no
|
||||
usable copy means it is still rendering, so that one raises
|
||||
ElementNotReady.
|
||||
"""
|
||||
matches = self.driver.find_elements(By.ID, element_id)
|
||||
|
||||
@@ -79,7 +100,7 @@ class ElementSelectionUtils:
|
||||
except StaleElementReferenceException:
|
||||
continue
|
||||
|
||||
raise NoSuchElementException(
|
||||
raise ElementNotReady(
|
||||
f"{element_id!r} is present but no visible copy has content yet"
|
||||
)
|
||||
|
||||
|
||||
+15
-6
@@ -8,7 +8,11 @@ import rewards_tasks
|
||||
from selenium import webdriver
|
||||
from selenium.common.exceptions import SessionNotCreatedException
|
||||
|
||||
HEADLESS = os.environ.get("REWARDS_HEADLESS", "").strip().lower() in ("1", "true", "yes")
|
||||
HEADLESS = os.environ.get("REWARDS_HEADLESS", "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -17,7 +21,7 @@ def build_options(account: accounts.Account) -> webdriver.EdgeOptions:
|
||||
options = webdriver.EdgeOptions()
|
||||
|
||||
options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
options.add_experimental_option('useAutomationExtension', False)
|
||||
options.add_experimental_option("useAutomationExtension", False)
|
||||
options.add_argument("--disable-blink-features=AutomationControlled")
|
||||
options.add_argument(f"--user-data-dir={account.user_data_dir}")
|
||||
options.add_argument(f"--profile-directory={account.profile_name}")
|
||||
@@ -45,7 +49,9 @@ def run_account(account: accounts.Account) -> bool:
|
||||
# the profile nor the other window.
|
||||
logger.error("[FAIL] %s: could not start Edge with this profile.", account.name)
|
||||
logger.error(" profile directory: %s", account.user_data_dir)
|
||||
logger.error(" The usual cause is that this profile is already open in another")
|
||||
logger.error(
|
||||
" The usual cause is that this profile is already open in another"
|
||||
)
|
||||
logger.error(" Edge window, including one left over from a previous run.")
|
||||
logger.error(" driver said: %s", log_utils.exception_summary(exc))
|
||||
|
||||
@@ -63,7 +69,8 @@ def run_account(account: accounts.Account) -> bool:
|
||||
# own error, and the process it is meant to end is dead anyway.
|
||||
logger.warning(
|
||||
"%s: the driver did not shut down cleanly: %s",
|
||||
account.name, log_utils.exception_summary(exc)
|
||||
account.name,
|
||||
log_utils.exception_summary(exc),
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -97,8 +104,10 @@ def main() -> int:
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"[FAIL] %s: %s: %s",
|
||||
account.name, type(exc).__name__, log_utils.exception_summary(exc),
|
||||
exc_info=logger.isEnabledFor(logging.DEBUG)
|
||||
account.name,
|
||||
type(exc).__name__,
|
||||
log_utils.exception_summary(exc),
|
||||
exc_info=logger.isEnabledFor(logging.DEBUG),
|
||||
)
|
||||
|
||||
if len(configured) > 1:
|
||||
|
||||
@@ -43,10 +43,10 @@ MAX_BACKOFF = 300
|
||||
session = requests.Session()
|
||||
|
||||
session.headers.update({
|
||||
"User-Agent": (
|
||||
"RandomVisualSearchImage/1.1 "
|
||||
"(contact: 12345rfdz@gmail.com)"
|
||||
)
|
||||
"User-Agent": (
|
||||
"RandomVisualSearchImage/1.1 "
|
||||
"(contact: 12345rfdz@gmail.com)"
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -55,17 +55,17 @@ session.headers.update({
|
||||
# ============================================================
|
||||
|
||||
def clean_url(url):
|
||||
"""Remove query parameters from Wikimedia URLs."""
|
||||
"""Remove query parameters from Wikimedia URLs."""
|
||||
|
||||
parts = urlsplit(url)
|
||||
parts = urlsplit(url)
|
||||
|
||||
return urlunsplit((
|
||||
parts.scheme,
|
||||
parts.netloc,
|
||||
parts.path,
|
||||
"",
|
||||
"",
|
||||
))
|
||||
return urlunsplit((
|
||||
parts.scheme,
|
||||
parts.netloc,
|
||||
parts.path,
|
||||
"",
|
||||
"",
|
||||
))
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -73,32 +73,32 @@ def clean_url(url):
|
||||
# ============================================================
|
||||
|
||||
def wait_after_429(response, attempt):
|
||||
"""Wait according to Wikimedia's Retry-After header."""
|
||||
"""Wait according to Wikimedia's Retry-After header."""
|
||||
|
||||
retry_after = response.headers.get("Retry-After")
|
||||
retry_after = response.headers.get("Retry-After")
|
||||
|
||||
if retry_after:
|
||||
try:
|
||||
wait_time = int(retry_after)
|
||||
except ValueError:
|
||||
wait_time = min(
|
||||
2 ** attempt,
|
||||
MAX_BACKOFF,
|
||||
)
|
||||
else:
|
||||
wait_time = min(
|
||||
2 ** attempt,
|
||||
MAX_BACKOFF,
|
||||
)
|
||||
if retry_after:
|
||||
try:
|
||||
wait_time = int(retry_after)
|
||||
except ValueError:
|
||||
wait_time = min(
|
||||
2 ** attempt,
|
||||
MAX_BACKOFF,
|
||||
)
|
||||
else:
|
||||
wait_time = min(
|
||||
2 ** attempt,
|
||||
MAX_BACKOFF,
|
||||
)
|
||||
|
||||
wait_time = max(5, wait_time)
|
||||
wait_time = max(5, wait_time)
|
||||
|
||||
print(
|
||||
f"Rate limited. Waiting "
|
||||
f"{wait_time} seconds..."
|
||||
)
|
||||
print(
|
||||
f"Rate limited. Waiting "
|
||||
f"{wait_time} seconds..."
|
||||
)
|
||||
|
||||
time.sleep(wait_time)
|
||||
time.sleep(wait_time)
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -106,51 +106,51 @@ def wait_after_429(response, attempt):
|
||||
# ============================================================
|
||||
|
||||
def download_image(url):
|
||||
"""Download image bytes from Wikimedia."""
|
||||
"""Download image bytes from Wikimedia."""
|
||||
|
||||
url = clean_url(url)
|
||||
url = clean_url(url)
|
||||
|
||||
try:
|
||||
response = session.get(
|
||||
url,
|
||||
timeout=30,
|
||||
allow_redirects=True,
|
||||
)
|
||||
try:
|
||||
response = session.get(
|
||||
url,
|
||||
timeout=30,
|
||||
allow_redirects=True,
|
||||
)
|
||||
|
||||
except (requests.RequestException, ConnectionResetError, OSError) as e:
|
||||
print(f"Download failed: {e}")
|
||||
return None
|
||||
except (requests.RequestException, ConnectionResetError, OSError) as e:
|
||||
print(f"Download failed: {e}")
|
||||
return None
|
||||
|
||||
if response.status_code == 429:
|
||||
wait_after_429(response, 1)
|
||||
return None
|
||||
if response.status_code == 429:
|
||||
wait_after_429(response, 1)
|
||||
return None
|
||||
|
||||
if response.status_code == 403:
|
||||
print("Wikimedia returned 403 Forbidden.")
|
||||
return None
|
||||
if response.status_code == 403:
|
||||
print("Wikimedia returned 403 Forbidden.")
|
||||
return None
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
print(f"HTTP error: {e}")
|
||||
return None
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
print(f"HTTP error: {e}")
|
||||
return None
|
||||
|
||||
content_type = response.headers.get(
|
||||
"Content-Type",
|
||||
"",
|
||||
).lower()
|
||||
content_type = response.headers.get(
|
||||
"Content-Type",
|
||||
"",
|
||||
).lower()
|
||||
|
||||
if not content_type.startswith("image/"):
|
||||
print(
|
||||
f"Not an image: {content_type}"
|
||||
)
|
||||
return None
|
||||
if not content_type.startswith("image/"):
|
||||
print(
|
||||
f"Not an image: {content_type}"
|
||||
)
|
||||
return None
|
||||
|
||||
if not response.content:
|
||||
print("Downloaded image is empty.")
|
||||
return None
|
||||
if not response.content:
|
||||
print("Downloaded image is empty.")
|
||||
return None
|
||||
|
||||
return response.content
|
||||
return response.content
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -158,65 +158,65 @@ def download_image(url):
|
||||
# ============================================================
|
||||
|
||||
def convert_to_jpeg(image_data):
|
||||
"""Convert downloaded image bytes to JPEG."""
|
||||
"""Convert downloaded image bytes to JPEG."""
|
||||
|
||||
try:
|
||||
with Image.open(
|
||||
io.BytesIO(image_data)
|
||||
) as image:
|
||||
try:
|
||||
with Image.open(
|
||||
io.BytesIO(image_data)
|
||||
) as image:
|
||||
|
||||
# JPEG does not support alpha (transparency).
|
||||
# If the image has transparency (RGBA or LA), paste it over a white background.
|
||||
if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
|
||||
background = Image.new("RGB", image.size, (255, 255, 255))
|
||||
if image.mode == "P":
|
||||
image = image.convert("RGBA")
|
||||
background.paste(image, mask=image.split()[-1])
|
||||
jpeg_image = background
|
||||
else:
|
||||
jpeg_image = image.convert("RGB")
|
||||
# JPEG does not support alpha (transparency).
|
||||
# If the image has transparency (RGBA or LA), paste it over a white background.
|
||||
if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
|
||||
background = Image.new("RGB", image.size, (255, 255, 255))
|
||||
if image.mode == "P":
|
||||
image = image.convert("RGBA")
|
||||
background.paste(image, mask=image.split()[-1])
|
||||
jpeg_image = background
|
||||
else:
|
||||
jpeg_image = image.convert("RGB")
|
||||
|
||||
output = io.BytesIO()
|
||||
output = io.BytesIO()
|
||||
|
||||
jpeg_image.save(
|
||||
output,
|
||||
format="JPEG",
|
||||
quality=90,
|
||||
optimize=True,
|
||||
)
|
||||
jpeg_image.save(
|
||||
output,
|
||||
format="JPEG",
|
||||
quality=90,
|
||||
optimize=True,
|
||||
)
|
||||
|
||||
return output.getvalue()
|
||||
return output.getvalue()
|
||||
|
||||
except Exception as e:
|
||||
print(
|
||||
f"JPEG conversion failed: {e}"
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
print(
|
||||
f"JPEG conversion failed: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def generate_fallback_image():
|
||||
"""Generate a synthetic local JPEG image using PIL as a fallback."""
|
||||
print("[INFO] Generating synthetic local fallback image for visual search...")
|
||||
from PIL import ImageDraw
|
||||
import random
|
||||
"""Generate a synthetic local JPEG image using PIL as a fallback."""
|
||||
print("[INFO] Generating synthetic local fallback image for visual search...")
|
||||
from PIL import ImageDraw
|
||||
import random
|
||||
|
||||
img = Image.new("RGB", (800, 600), color=(random.randint(50, 200), random.randint(50, 200), random.randint(50, 200)))
|
||||
draw = ImageDraw.Draw(img)
|
||||
for _ in range(10):
|
||||
x0 = random.randint(0, 700)
|
||||
y0 = random.randint(0, 500)
|
||||
x1 = x0 + random.randint(50, 200)
|
||||
y1 = y0 + random.randint(50, 200)
|
||||
fill = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
|
||||
draw.rectangle([x0, y0, x1, y1], fill=fill)
|
||||
img = Image.new("RGB", (800, 600), color=(random.randint(50, 200), random.randint(50, 200), random.randint(50, 200)))
|
||||
draw = ImageDraw.Draw(img)
|
||||
for _ in range(10):
|
||||
x0 = random.randint(0, 700)
|
||||
y0 = random.randint(0, 500)
|
||||
x1 = x0 + random.randint(50, 200)
|
||||
y1 = y0 + random.randint(50, 200)
|
||||
fill = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
|
||||
draw.rectangle([x0, y0, x1, y1], fill=fill)
|
||||
|
||||
output = io.BytesIO()
|
||||
img.save(output, format="JPEG", quality=90)
|
||||
jpeg_data = output.getvalue()
|
||||
output = io.BytesIO()
|
||||
img.save(output, format="JPEG", quality=90)
|
||||
jpeg_data = output.getvalue()
|
||||
|
||||
OUTPUT_FILE.write_bytes(jpeg_data)
|
||||
print(f"Saved fallback image to {OUTPUT_FILE.absolute()}")
|
||||
return {"title": "Fallback Synthetic Image", "width": 800, "height": 600}
|
||||
OUTPUT_FILE.write_bytes(jpeg_data)
|
||||
print(f"Saved fallback image to {OUTPUT_FILE.absolute()}")
|
||||
return {"title": "Fallback Synthetic Image", "width": 800, "height": 600}
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -225,262 +225,262 @@ def generate_fallback_image():
|
||||
|
||||
def get_random_image():
|
||||
|
||||
for attempt in range(
|
||||
1,
|
||||
MAX_ATTEMPTS + 1,
|
||||
):
|
||||
for attempt in range(
|
||||
1,
|
||||
MAX_ATTEMPTS + 1,
|
||||
):
|
||||
|
||||
if attempt > 1:
|
||||
time.sleep(REQUEST_DELAY)
|
||||
if attempt > 1:
|
||||
time.sleep(REQUEST_DELAY)
|
||||
|
||||
print(
|
||||
f"\nAttempt "
|
||||
f"{attempt}/{MAX_ATTEMPTS}"
|
||||
)
|
||||
print(
|
||||
f"\nAttempt "
|
||||
f"{attempt}/{MAX_ATTEMPTS}"
|
||||
)
|
||||
|
||||
params = {
|
||||
"action": "query",
|
||||
"format": "json",
|
||||
params = {
|
||||
"action": "query",
|
||||
"format": "json",
|
||||
|
||||
"generator": "random",
|
||||
"grnnamespace": 6,
|
||||
"grnlimit": 1,
|
||||
"generator": "random",
|
||||
"grnnamespace": 6,
|
||||
"grnlimit": 1,
|
||||
|
||||
"prop": "imageinfo",
|
||||
"prop": "imageinfo",
|
||||
|
||||
"iiprop": (
|
||||
"url|size|mime|dimensions"
|
||||
),
|
||||
"iiprop": (
|
||||
"url|size|mime|dimensions"
|
||||
),
|
||||
|
||||
"iiurlwidth": THUMBNAIL_WIDTH,
|
||||
}
|
||||
"iiurlwidth": THUMBNAIL_WIDTH,
|
||||
}
|
||||
|
||||
try:
|
||||
response = session.get(
|
||||
API_URL,
|
||||
params=params,
|
||||
timeout=20,
|
||||
)
|
||||
try:
|
||||
response = session.get(
|
||||
API_URL,
|
||||
params=params,
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
except (requests.RequestException, ConnectionResetError, OSError) as e:
|
||||
print(f"API request failed: {e}")
|
||||
continue
|
||||
except (requests.RequestException, ConnectionResetError, OSError) as e:
|
||||
print(f"API request failed: {e}")
|
||||
continue
|
||||
|
||||
if response.status_code == 429:
|
||||
wait_after_429(
|
||||
response,
|
||||
attempt,
|
||||
)
|
||||
continue
|
||||
if response.status_code == 429:
|
||||
wait_after_429(
|
||||
response,
|
||||
attempt,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
try:
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
except (
|
||||
requests.RequestException,
|
||||
ValueError,
|
||||
) as e:
|
||||
print(f"API error: {e}")
|
||||
continue
|
||||
except (
|
||||
requests.RequestException,
|
||||
ValueError,
|
||||
) as e:
|
||||
print(f"API error: {e}")
|
||||
continue
|
||||
|
||||
pages = (
|
||||
data
|
||||
.get("query", {})
|
||||
.get("pages", {})
|
||||
)
|
||||
pages = (
|
||||
data
|
||||
.get("query", {})
|
||||
.get("pages", {})
|
||||
)
|
||||
|
||||
if not pages:
|
||||
print("No page returned.")
|
||||
continue
|
||||
if not pages:
|
||||
print("No page returned.")
|
||||
continue
|
||||
|
||||
page = next(
|
||||
iter(pages.values())
|
||||
)
|
||||
page = next(
|
||||
iter(pages.values())
|
||||
)
|
||||
|
||||
title = page.get(
|
||||
"title",
|
||||
"Unknown",
|
||||
)
|
||||
title = page.get(
|
||||
"title",
|
||||
"Unknown",
|
||||
)
|
||||
|
||||
imageinfo = page.get(
|
||||
"imageinfo"
|
||||
)
|
||||
imageinfo = page.get(
|
||||
"imageinfo"
|
||||
)
|
||||
|
||||
if not imageinfo:
|
||||
print(
|
||||
"No image information."
|
||||
)
|
||||
continue
|
||||
if not imageinfo:
|
||||
print(
|
||||
"No image information."
|
||||
)
|
||||
continue
|
||||
|
||||
info = imageinfo[0]
|
||||
info = imageinfo[0]
|
||||
|
||||
mime = info.get(
|
||||
"mime",
|
||||
"",
|
||||
)
|
||||
mime = info.get(
|
||||
"mime",
|
||||
"",
|
||||
)
|
||||
|
||||
width = info.get(
|
||||
"width",
|
||||
0,
|
||||
)
|
||||
width = info.get(
|
||||
"width",
|
||||
0,
|
||||
)
|
||||
|
||||
height = info.get(
|
||||
"height",
|
||||
0,
|
||||
)
|
||||
height = info.get(
|
||||
"height",
|
||||
0,
|
||||
)
|
||||
|
||||
size = info.get(
|
||||
"size",
|
||||
0,
|
||||
)
|
||||
size = info.get(
|
||||
"size",
|
||||
0,
|
||||
)
|
||||
|
||||
thumbnail_url = info.get(
|
||||
"thumburl"
|
||||
)
|
||||
thumbnail_url = info.get(
|
||||
"thumburl"
|
||||
)
|
||||
|
||||
original_url = info.get(
|
||||
"url"
|
||||
)
|
||||
original_url = info.get(
|
||||
"url"
|
||||
)
|
||||
|
||||
if mime not in {
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
}:
|
||||
print(
|
||||
f"Skipping unsupported type: "
|
||||
f"{mime}"
|
||||
)
|
||||
continue
|
||||
if mime not in {
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
}:
|
||||
print(
|
||||
f"Skipping unsupported type: "
|
||||
f"{mime}"
|
||||
)
|
||||
continue
|
||||
|
||||
if width < MIN_WIDTH or height < MIN_HEIGHT:
|
||||
print(
|
||||
f"Skipping small image: "
|
||||
f"{width}x{height}"
|
||||
)
|
||||
continue
|
||||
if width < MIN_WIDTH or height < MIN_HEIGHT:
|
||||
print(
|
||||
f"Skipping small image: "
|
||||
f"{width}x{height}"
|
||||
)
|
||||
continue
|
||||
|
||||
if size > MAX_FILE_SIZE:
|
||||
print(
|
||||
f"Skipping large image: "
|
||||
f"{size / 1024 / 1024:.1f} MB"
|
||||
)
|
||||
continue
|
||||
if size > MAX_FILE_SIZE:
|
||||
print(
|
||||
f"Skipping large image: "
|
||||
f"{size / 1024 / 1024:.1f} MB"
|
||||
)
|
||||
continue
|
||||
|
||||
if not thumbnail_url:
|
||||
print("No thumbnail URL.")
|
||||
continue
|
||||
if not thumbnail_url:
|
||||
print("No thumbnail URL.")
|
||||
continue
|
||||
|
||||
print(f"Found: {title}")
|
||||
print(
|
||||
f"Size: {width}x{height}"
|
||||
)
|
||||
print(f"Found: {title}")
|
||||
print(
|
||||
f"Size: {width}x{height}"
|
||||
)
|
||||
|
||||
image_data = download_image(
|
||||
thumbnail_url
|
||||
)
|
||||
image_data = download_image(
|
||||
thumbnail_url
|
||||
)
|
||||
|
||||
if image_data is None and original_url:
|
||||
print(
|
||||
"Trying original..."
|
||||
)
|
||||
if image_data is None and original_url:
|
||||
print(
|
||||
"Trying original..."
|
||||
)
|
||||
|
||||
image_data = download_image(
|
||||
original_url
|
||||
)
|
||||
image_data = download_image(
|
||||
original_url
|
||||
)
|
||||
|
||||
if image_data is None:
|
||||
print(
|
||||
"Couldn't download image."
|
||||
)
|
||||
continue
|
||||
if image_data is None:
|
||||
print(
|
||||
"Couldn't download image."
|
||||
)
|
||||
continue
|
||||
|
||||
print("Converting to JPEG...")
|
||||
print("Converting to JPEG...")
|
||||
|
||||
jpeg_data = convert_to_jpeg(
|
||||
image_data
|
||||
)
|
||||
jpeg_data = convert_to_jpeg(
|
||||
image_data
|
||||
)
|
||||
|
||||
if jpeg_data is None:
|
||||
continue
|
||||
if jpeg_data is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
OUTPUT_FILE.write_bytes(
|
||||
jpeg_data
|
||||
)
|
||||
try:
|
||||
OUTPUT_FILE.write_bytes(
|
||||
jpeg_data
|
||||
)
|
||||
|
||||
except OSError as e:
|
||||
print(
|
||||
f"Couldn't save image: {e}"
|
||||
)
|
||||
continue
|
||||
except OSError as e:
|
||||
print(
|
||||
f"Couldn't save image: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
metadata = {
|
||||
"title": title,
|
||||
"source": "Wikimedia Commons",
|
||||
"output_format": "JPEG",
|
||||
metadata = {
|
||||
"title": title,
|
||||
"source": "Wikimedia Commons",
|
||||
"output_format": "JPEG",
|
||||
|
||||
"width": width,
|
||||
"height": height,
|
||||
"width": width,
|
||||
"height": height,
|
||||
|
||||
"original_mime": mime,
|
||||
"original_mime": mime,
|
||||
|
||||
"original_size": size,
|
||||
"original_size": size,
|
||||
|
||||
"jpeg_size": len(
|
||||
jpeg_data
|
||||
),
|
||||
"jpeg_size": len(
|
||||
jpeg_data
|
||||
),
|
||||
|
||||
"original_url": (
|
||||
clean_url(original_url)
|
||||
if original_url
|
||||
else None
|
||||
),
|
||||
"original_url": (
|
||||
clean_url(original_url)
|
||||
if original_url
|
||||
else None
|
||||
),
|
||||
|
||||
"thumbnail_url": (
|
||||
clean_url(thumbnail_url)
|
||||
if thumbnail_url
|
||||
else None
|
||||
),
|
||||
}
|
||||
"thumbnail_url": (
|
||||
clean_url(thumbnail_url)
|
||||
if thumbnail_url
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
try:
|
||||
METADATA_FILE.write_text(
|
||||
json.dumps(
|
||||
metadata,
|
||||
indent=4,
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
METADATA_FILE.write_text(
|
||||
json.dumps(
|
||||
metadata,
|
||||
indent=4,
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
except OSError as e:
|
||||
print(
|
||||
f"Warning: couldn't save "
|
||||
f"metadata: {e}"
|
||||
)
|
||||
except OSError as e:
|
||||
print(
|
||||
f"Warning: couldn't save "
|
||||
f"metadata: {e}"
|
||||
)
|
||||
|
||||
print()
|
||||
print("=" * 50)
|
||||
print("SUCCESS")
|
||||
print("=" * 50)
|
||||
print(
|
||||
f"Image: "
|
||||
f"{OUTPUT_FILE.absolute()}"
|
||||
)
|
||||
print(
|
||||
f"Size: "
|
||||
f"{len(jpeg_data) / 1024:.1f} KB"
|
||||
)
|
||||
print(
|
||||
f"Source: {title}"
|
||||
)
|
||||
print()
|
||||
print("=" * 50)
|
||||
print("SUCCESS")
|
||||
print("=" * 50)
|
||||
print(
|
||||
f"Image: "
|
||||
f"{OUTPUT_FILE.absolute()}"
|
||||
)
|
||||
print(
|
||||
f"Size: "
|
||||
f"{len(jpeg_data) / 1024:.1f} KB"
|
||||
)
|
||||
print(
|
||||
f"Source: {title}"
|
||||
)
|
||||
|
||||
return metadata
|
||||
return metadata
|
||||
|
||||
print("[WARNING] Could not download image from Wikimedia Commons. Generating local fallback image.")
|
||||
return generate_fallback_image()
|
||||
print("[WARNING] Could not download image from Wikimedia Commons. Generating local fallback image.")
|
||||
return generate_fallback_image()
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -488,12 +488,4 @@ def get_random_image():
|
||||
# ============================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
get_random_image()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MAIN
|
||||
# ============================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
get_random_image()
|
||||
get_random_image()
|
||||
+106
-23
@@ -20,10 +20,62 @@ VISUAL_SEARCH_IMAGE_PATH = os.path.abspath("visual_search.jpg")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ElementNeverAppeared(TimeoutException):
|
||||
"""A wait expired without the element ever being in the page.
|
||||
|
||||
WebDriverWait reports only that the wait ran out, so a section this market
|
||||
does not ship and a section that was on screen and slow arrived as the same
|
||||
TimeoutException. Reporting both as "not available in this UI variant" was
|
||||
wrong for the second one, which is what #52 describes.
|
||||
|
||||
Subclassed from TimeoutException so the handlers that already wait on a
|
||||
control being absent, claim_bonus_points and complete_bing_daily_set, keep
|
||||
working unchanged.
|
||||
"""
|
||||
|
||||
|
||||
def task_failure_report(exc: BaseException) -> tuple[str, str]:
|
||||
"""The tag and the reason a failed task is reported with.
|
||||
|
||||
Absence and an expired wait need different next steps. A section this market
|
||||
does not ship is nothing to act on, so it stays a [SKIP]. A section that was
|
||||
on the page and never became usable may have left points behind, so it is
|
||||
reported as a failure instead of being folded into the same sentence.
|
||||
|
||||
Ordered from the most specific case outwards, not by exception hierarchy:
|
||||
ElementNeverAppeared is a TimeoutException and ElementNotReady is a
|
||||
NoSuchElementException, so each has to be tested before the class it
|
||||
refines.
|
||||
"""
|
||||
unavailable = f"not available in this UI variant ({type(exc).__name__})"
|
||||
|
||||
if isinstance(exc, ElementNeverAppeared):
|
||||
return "SKIP", unavailable
|
||||
|
||||
if isinstance(exc, (element_selectors.ElementNotReady, TimeoutException)):
|
||||
return "FAIL", f"on the page but not ready in time ({type(exc).__name__})"
|
||||
|
||||
if isinstance(exc, NoSuchElementException):
|
||||
return "SKIP", unavailable
|
||||
|
||||
return "FAIL", f"{type(exc).__name__}: {log_utils.exception_summary(exc)}"
|
||||
|
||||
|
||||
class RewardsTaskUtils:
|
||||
def __init__(self, driver: webdriver.Edge):
|
||||
self.driver = driver
|
||||
|
||||
# Set headers to spoof the rewards app for the rewards only quests
|
||||
self.driver.execute_cdp_cmd("Network.enable", {})
|
||||
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0 MSRewards/Desktop/1.1.0",
|
||||
"X-Rewards-Source": "msrewards-desktop",
|
||||
}
|
||||
|
||||
self.driver.execute_cdp_cmd("Network.setExtraHTTPHeaders", {"headers": headers})
|
||||
|
||||
self.driver.get("https://rewards.bing.com/")
|
||||
|
||||
self.tab_utils = tab_utils.TabUtils(driver)
|
||||
@@ -48,15 +100,45 @@ class RewardsTaskUtils:
|
||||
return self.driver.find_element(By.XPATH, xpath)
|
||||
|
||||
def wait_for_element(self, element_getter: Callable[[], WebElement | list[WebElement]], timeout: int = 10) -> WebElement | list[WebElement]:
|
||||
# Keep the last reason the getter gave. Without it a wait that expires
|
||||
# cannot say whether the element was missing the whole time or was on
|
||||
# the page and not ready, and those are reported differently.
|
||||
last_error: BaseException | None = None
|
||||
|
||||
def condition(_: webdriver.Edge):
|
||||
nonlocal last_error
|
||||
|
||||
try:
|
||||
element_or_elements = element_getter()
|
||||
except Exception as exc:
|
||||
# Exception rather than a bare except, so Ctrl+C during a
|
||||
# getter ends the run instead of being retried away.
|
||||
last_error = exc
|
||||
|
||||
return element_or_elements
|
||||
except:
|
||||
return False
|
||||
|
||||
return WebDriverWait(self.driver, timeout).until(condition)
|
||||
last_error = None
|
||||
|
||||
return element_or_elements
|
||||
|
||||
try:
|
||||
return WebDriverWait(self.driver, timeout).until(condition)
|
||||
except TimeoutException:
|
||||
# A falsy return means the getter found something and rejected it,
|
||||
# and ElementNotReady means it was there but still rendering. Only
|
||||
# a plain NoSuchElementException every time means it was never
|
||||
# there at all.
|
||||
never_there = (
|
||||
isinstance(last_error, NoSuchElementException)
|
||||
and not isinstance(last_error, element_selectors.ElementNotReady)
|
||||
)
|
||||
|
||||
if not never_there:
|
||||
raise
|
||||
|
||||
raise ElementNeverAppeared(
|
||||
f"nothing matched during the {timeout}s wait: {log_utils.exception_summary(last_error)}"
|
||||
) from last_error
|
||||
|
||||
def switch_to_earn_page(self):
|
||||
self.move_to_and_click(self.elements.get_earn_tab())
|
||||
@@ -65,25 +147,25 @@ class RewardsTaskUtils:
|
||||
self.move_to_and_click(self.elements.get_dashboard_tab())
|
||||
|
||||
def move_to_and_click(self, elem_or_getter: WebElement | Callable[[], WebElement], retries: int = 3):
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
if callable(elem_or_getter):
|
||||
if callable(elem_or_getter):
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
target_elem = elem_or_getter()
|
||||
else:
|
||||
target_elem = elem_or_getter
|
||||
|
||||
self.mouse.move_to_element(target_elem)
|
||||
self.mouse.human_like_click()
|
||||
return
|
||||
except StaleElementReferenceException as exc:
|
||||
if attempt == retries - 1:
|
||||
raise exc
|
||||
print(f"[WARNING] StaleElementReferenceException during click attempt {attempt + 1}/{retries}, retrying...")
|
||||
time.sleep(0.5)
|
||||
self.mouse.move_to_element(target_elem)
|
||||
self.mouse.human_like_click()
|
||||
return
|
||||
except StaleElementReferenceException as exc:
|
||||
if attempt == retries - 1:
|
||||
raise exc
|
||||
logger.warning("StaleElementReferenceException during click attempt %d/%d, retrying...", attempt + 1, retries)
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
self.mouse.move_to_element(elem_or_getter)
|
||||
self.mouse.human_like_click()
|
||||
|
||||
def wait_for_then_click(self, element_getter: Callable[[], WebElement], timeout: int = 10):
|
||||
elem = self.wait_for_element(element_getter, timeout)
|
||||
self.move_to_and_click(element_getter if callable(element_getter) else elem)
|
||||
self.wait_for_element(element_getter, timeout)
|
||||
self.move_to_and_click(element_getter)
|
||||
|
||||
def complete_bing_daily_set(self, expected_activities: int = 3):
|
||||
self.switch_to_earn_page()
|
||||
@@ -347,11 +429,12 @@ class RewardsTaskUtils:
|
||||
try:
|
||||
step()
|
||||
logger.info("[OK] %s", name)
|
||||
except (NoSuchElementException, TimeoutException) as exc:
|
||||
logger.warning("[SKIP] %s: not available in this UI variant (%s)", name, type(exc).__name__)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"[FAIL] %s: %s: %s", name, type(exc).__name__, log_utils.exception_summary(exc),
|
||||
tag, reason = task_failure_report(exc)
|
||||
|
||||
logger.log(
|
||||
logging.WARNING if tag == "SKIP" else logging.ERROR,
|
||||
"[%s] %s: %s", tag, name, reason,
|
||||
exc_info=logger.isEnabledFor(logging.DEBUG)
|
||||
)
|
||||
|
||||
|
||||
@@ -19,87 +19,87 @@ Point = tuple[int, int]
|
||||
|
||||
|
||||
def make_paths() -> tuple[callable, callable]:
|
||||
"""Return independent paths with the same start and end points."""
|
||||
start = (150, 535)
|
||||
end = (950, 535)
|
||||
base_path = get_bezier_path(start, end, intermediate_radius_interval=(150, 210))
|
||||
distorted_path = get_distorted_bezier_path(
|
||||
start,
|
||||
end,
|
||||
intermediate_radius_interval=(150, 210),
|
||||
distortion_zone_time_length=0.08,
|
||||
distortion_frequency=1.0,
|
||||
deviation_interval=(10, 18),
|
||||
)
|
||||
"""Return independent paths with the same start and end points."""
|
||||
start = (150, 535)
|
||||
end = (950, 535)
|
||||
base_path = get_bezier_path(start, end, intermediate_radius_interval=(150, 210))
|
||||
distorted_path = get_distorted_bezier_path(
|
||||
start,
|
||||
end,
|
||||
intermediate_radius_interval=(150, 210),
|
||||
distortion_zone_time_length=0.08,
|
||||
distortion_frequency=1.0,
|
||||
deviation_interval=(10, 18),
|
||||
)
|
||||
|
||||
return base_path, distorted_path
|
||||
return base_path, distorted_path
|
||||
|
||||
|
||||
def sample_path(path: callable, steps: int = 360) -> list[Point]:
|
||||
return [
|
||||
(round(point[0]), round(point[1]))
|
||||
for point in (path(index / (steps - 1)) for index in range(steps))
|
||||
]
|
||||
return [
|
||||
(round(point[0]), round(point[1]))
|
||||
for point in (path(index / (steps - 1)) for index in range(steps))
|
||||
]
|
||||
|
||||
|
||||
def draw_label(screen: pygame.Surface, font: pygame.font.Font, text: str, position: tuple[int, int], color: tuple[int, int, int]) -> None:
|
||||
screen.blit(font.render(text, True, color), position)
|
||||
screen.blit(font.render(text, True, color), position)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
pygame.init()
|
||||
screen = pygame.display.set_mode(WINDOW_SIZE)
|
||||
pygame.display.set_caption("Bezier Path Distortion")
|
||||
clock = pygame.time.Clock()
|
||||
title_font = pygame.font.SysFont("Segoe UI", 28, bold=True)
|
||||
body_font = pygame.font.SysFont("Segoe UI", 19)
|
||||
button_font = pygame.font.SysFont("Segoe UI", 18, bold=True)
|
||||
pygame.init()
|
||||
screen = pygame.display.set_mode(WINDOW_SIZE)
|
||||
pygame.display.set_caption("Bezier Path Distortion")
|
||||
clock = pygame.time.Clock()
|
||||
title_font = pygame.font.SysFont("Segoe UI", 28, bold=True)
|
||||
body_font = pygame.font.SysFont("Segoe UI", 19)
|
||||
button_font = pygame.font.SysFont("Segoe UI", 18, bold=True)
|
||||
|
||||
base_path, distorted_path = make_paths()
|
||||
base_points = sample_path(base_path)
|
||||
distorted_points = sample_path(distorted_path)
|
||||
show_distorted = False
|
||||
button = pygame.Rect(405, 595, 290, 52)
|
||||
base_path, distorted_path = make_paths()
|
||||
base_points = sample_path(base_path)
|
||||
distorted_points = sample_path(distorted_path)
|
||||
show_distorted = False
|
||||
button = pygame.Rect(405, 595, 290, 52)
|
||||
|
||||
running = True
|
||||
while running:
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.QUIT:
|
||||
running = False
|
||||
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
|
||||
if button.collidepoint(event.pos):
|
||||
show_distorted = not show_distorted
|
||||
running = True
|
||||
while running:
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.QUIT:
|
||||
running = False
|
||||
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
|
||||
if button.collidepoint(event.pos):
|
||||
show_distorted = not show_distorted
|
||||
|
||||
screen.fill(BACKGROUND)
|
||||
draw_label(screen, title_font, "Bezier path comparison", (38, 28), PATH_COLOR)
|
||||
draw_label(
|
||||
screen,
|
||||
body_font,
|
||||
"The red path adds temporary offsets to the same underlying curve.",
|
||||
(40, 70),
|
||||
(91, 97, 102),
|
||||
)
|
||||
screen.fill(BACKGROUND)
|
||||
draw_label(screen, title_font, "Bezier path comparison", (38, 28), PATH_COLOR)
|
||||
draw_label(
|
||||
screen,
|
||||
body_font,
|
||||
"The red path adds temporary offsets to the same underlying curve.",
|
||||
(40, 70),
|
||||
(91, 97, 102),
|
||||
)
|
||||
|
||||
pygame.draw.lines(screen, (184, 188, 190), False, base_points, 1)
|
||||
pygame.draw.lines(screen, DISTORTED_PATH_COLOR if show_distorted else PATH_COLOR, False, distorted_points if show_distorted else base_points, 4)
|
||||
pygame.draw.circle(screen, POINT_COLOR, base_points[0], 10)
|
||||
pygame.draw.circle(screen, POINT_COLOR, base_points[-1], 10)
|
||||
pygame.draw.lines(screen, (184, 188, 190), False, base_points, 1)
|
||||
pygame.draw.lines(screen, DISTORTED_PATH_COLOR if show_distorted else PATH_COLOR, False, distorted_points if show_distorted else base_points, 4)
|
||||
pygame.draw.circle(screen, POINT_COLOR, base_points[0], 10)
|
||||
pygame.draw.circle(screen, POINT_COLOR, base_points[-1], 10)
|
||||
|
||||
draw_label(screen, body_font, "A", (base_points[0][0] - 8, base_points[0][1] + 18), PATH_COLOR)
|
||||
draw_label(screen, body_font, "B", (base_points[-1][0] - 8, base_points[-1][1] + 18), PATH_COLOR)
|
||||
draw_label(screen, body_font, "DISTORTED" if show_distorted else "UNDISTORTED", (20, 535), DISTORTED_PATH_COLOR if show_distorted else PATH_COLOR)
|
||||
draw_label(screen, body_font, "A", (base_points[0][0] - 8, base_points[0][1] + 18), PATH_COLOR)
|
||||
draw_label(screen, body_font, "B", (base_points[-1][0] - 8, base_points[-1][1] + 18), PATH_COLOR)
|
||||
draw_label(screen, body_font, "DISTORTED" if show_distorted else "UNDISTORTED", (20, 535), DISTORTED_PATH_COLOR if show_distorted else PATH_COLOR)
|
||||
|
||||
button_color = BUTTON_HOVER_COLOR if button.collidepoint(pygame.mouse.get_pos()) else BUTTON_COLOR
|
||||
pygame.draw.rect(screen, button_color, button, border_radius=7)
|
||||
button_text = "Show undistorted path" if show_distorted else "Show distorted path"
|
||||
text_surface = button_font.render(button_text, True, BUTTON_TEXT_COLOR)
|
||||
screen.blit(text_surface, text_surface.get_rect(center=button.center))
|
||||
button_color = BUTTON_HOVER_COLOR if button.collidepoint(pygame.mouse.get_pos()) else BUTTON_COLOR
|
||||
pygame.draw.rect(screen, button_color, button, border_radius=7)
|
||||
button_text = "Show undistorted path" if show_distorted else "Show distorted path"
|
||||
text_surface = button_font.render(button_text, True, BUTTON_TEXT_COLOR)
|
||||
screen.blit(text_surface, text_surface.get_rect(center=button.center))
|
||||
|
||||
pygame.display.flip()
|
||||
clock.tick(60)
|
||||
pygame.display.flip()
|
||||
clock.tick(60)
|
||||
|
||||
pygame.quit()
|
||||
pygame.quit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
@@ -163,6 +163,19 @@ class DuplicatedContainer(unittest.TestCase):
|
||||
|
||||
self.assertEqual(len(cards), 7)
|
||||
|
||||
def test_a_container_that_is_there_but_empty_is_not_reported_as_missing(self):
|
||||
# Both failures used to raise NoSuchElementException, so a section that
|
||||
# was on the page and still rendering got reported as one this market
|
||||
# does not ship. Waiting is the answer to this one.
|
||||
with self.assertRaises(element_selectors.ElementNotReady):
|
||||
selectors_for(self._driver(visible_links=0, hidden_links=7)).get_all_misc_cards()
|
||||
|
||||
def test_a_container_that_is_absent_is_reported_as_missing(self):
|
||||
with self.assertRaises(NoSuchElementException) as caught:
|
||||
selectors_for(FakeDriver()).get_all_misc_cards()
|
||||
|
||||
self.assertNotIsInstance(caught.exception, element_selectors.ElementNotReady)
|
||||
|
||||
|
||||
class DailySetOpener(unittest.TestCase):
|
||||
"""The opener label has to be distinguished from the level up entry."""
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Tests for what a run says about a task that did not complete.
|
||||
|
||||
The reported reason used to be a guess. Every wait that expired and every
|
||||
lookup that missed produced "not available in this UI variant", so a section
|
||||
that was on the page and slow read exactly like one this market does not ship,
|
||||
which is #52. These pin down which failures are absence and which are not.
|
||||
|
||||
None of them need a browser.
|
||||
|
||||
python -m unittest discover -s tests
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from selenium.common.exceptions import (
|
||||
NoSuchElementException,
|
||||
TimeoutException,
|
||||
WebDriverException,
|
||||
)
|
||||
|
||||
import rewards_tasks
|
||||
from element_selectors import ElementNotReady
|
||||
from fakes import FakeDriver
|
||||
from rewards_tasks import ElementNeverAppeared, task_failure_report
|
||||
|
||||
# Long enough for one poll, short enough that the suite stays quick.
|
||||
# WebDriverWait sleeps 0.5s between attempts, so a wait that expires costs
|
||||
# about that regardless of the timeout asked for.
|
||||
BRIEF = 0.05
|
||||
|
||||
|
||||
def make_tasks():
|
||||
"""A RewardsTaskUtils without the browser its __init__ opens."""
|
||||
tasks = rewards_tasks.RewardsTaskUtils.__new__(rewards_tasks.RewardsTaskUtils)
|
||||
|
||||
tasks.driver = FakeDriver()
|
||||
tasks.tab_utils = types.SimpleNamespace(close_all_other_tabs=lambda: None)
|
||||
|
||||
return tasks
|
||||
|
||||
|
||||
class WaitClassification(unittest.TestCase):
|
||||
"""wait_for_element has to say why it gave up, not just that it did."""
|
||||
|
||||
def test_a_getter_that_never_finds_anything_is_absence(self):
|
||||
def missing():
|
||||
raise NoSuchElementException("no button containing 'visual search streak'")
|
||||
|
||||
with self.assertRaises(ElementNeverAppeared):
|
||||
make_tasks().wait_for_element(missing, timeout=BRIEF)
|
||||
|
||||
def test_a_section_that_is_still_rendering_is_not_absence(self):
|
||||
# The id is in the page, no visible copy has content yet. Waiting
|
||||
# longer is the answer here, skipping the task is not.
|
||||
def not_ready():
|
||||
raise ElementNotReady("'moreactivities' is present but no visible copy has content yet")
|
||||
|
||||
with self.assertRaises(TimeoutException) as caught:
|
||||
make_tasks().wait_for_element(not_ready, timeout=BRIEF)
|
||||
|
||||
self.assertNotIsInstance(caught.exception, ElementNeverAppeared)
|
||||
|
||||
def test_a_getter_that_rejects_what_it_finds_is_not_absence(self):
|
||||
# complete_bing_daily_set holds out for all three activities and
|
||||
# returns False until they are there. The panel itself is open.
|
||||
with self.assertRaises(TimeoutException) as caught:
|
||||
make_tasks().wait_for_element(lambda: [], timeout=BRIEF)
|
||||
|
||||
self.assertNotIsInstance(caught.exception, ElementNeverAppeared)
|
||||
|
||||
def test_an_element_that_arrives_late_is_still_returned(self):
|
||||
attempts = []
|
||||
|
||||
def slow():
|
||||
attempts.append(None)
|
||||
|
||||
if len(attempts) < 2:
|
||||
raise NoSuchElementException("not yet")
|
||||
|
||||
return "the element"
|
||||
|
||||
self.assertEqual(make_tasks().wait_for_element(slow, timeout=5), "the element")
|
||||
# A single lucky first attempt would prove nothing about the retry.
|
||||
self.assertGreater(len(attempts), 1)
|
||||
|
||||
def test_the_getters_own_reason_survives(self):
|
||||
def missing():
|
||||
raise NoSuchElementException("no button containing 'points breakdown'")
|
||||
|
||||
with self.assertRaises(ElementNeverAppeared) as caught:
|
||||
make_tasks().wait_for_element(missing, timeout=BRIEF)
|
||||
|
||||
self.assertIn("points breakdown", str(caught.exception))
|
||||
|
||||
|
||||
class ExistingTimeoutHandlers(unittest.TestCase):
|
||||
"""The new type has to stay catchable where TimeoutException was."""
|
||||
|
||||
def test_it_is_still_a_timeout(self):
|
||||
self.assertTrue(issubclass(ElementNeverAppeared, TimeoutException))
|
||||
|
||||
def test_having_no_bonus_points_is_still_only_a_warning(self):
|
||||
# There is no Claim button when there is nothing to claim, so this
|
||||
# path reaches the wait expecting to be disappointed. If the new type
|
||||
# escaped its `except TimeoutException`, an ordinary run would start
|
||||
# reporting a failed task every day.
|
||||
def bonus_button():
|
||||
pass
|
||||
|
||||
def claim_button():
|
||||
pass
|
||||
|
||||
tasks = make_tasks()
|
||||
tasks.switch_to_dashboard = lambda: None
|
||||
tasks.elements = types.SimpleNamespace(
|
||||
get_bonus_button_on_dashboard=bonus_button,
|
||||
get_claim_bonus_points_button=claim_button,
|
||||
)
|
||||
|
||||
def wait_for_then_click(getter, timeout=10):
|
||||
if getter is claim_button:
|
||||
raise ElementNeverAppeared("nothing matched during the 10s wait")
|
||||
|
||||
tasks.wait_for_then_click = wait_for_then_click
|
||||
|
||||
with self.assertLogs(rewards_tasks.logger, level=logging.WARNING) as captured:
|
||||
tasks.claim_bonus_points()
|
||||
|
||||
self.assertIn("no bonus points to claim", "\n".join(captured.output).lower())
|
||||
|
||||
|
||||
class FailureReport(unittest.TestCase):
|
||||
def test_a_section_this_variant_does_not_ship_is_skipped(self):
|
||||
tag, reason = task_failure_report(
|
||||
NoSuchElementException("no element with id 'moreactivities'")
|
||||
)
|
||||
|
||||
self.assertEqual(tag, "SKIP")
|
||||
self.assertIn("not available in this UI variant", reason)
|
||||
|
||||
def test_a_wait_that_never_saw_the_element_is_skipped(self):
|
||||
tag, reason = task_failure_report(ElementNeverAppeared("nothing matched"))
|
||||
|
||||
self.assertEqual(tag, "SKIP")
|
||||
self.assertIn("not available in this UI variant", reason)
|
||||
|
||||
def test_a_section_that_never_finished_rendering_is_not_skipped(self):
|
||||
tag, reason = task_failure_report(
|
||||
ElementNotReady("'moreactivities' is present but no visible copy has content yet")
|
||||
)
|
||||
|
||||
self.assertEqual(tag, "FAIL")
|
||||
self.assertNotIn("not available", reason)
|
||||
|
||||
def test_an_expired_wait_is_not_skipped(self):
|
||||
# The line in #52, reported for a panel that was on screen the whole
|
||||
# time: "[SKIP] Required searches: not available in this UI variant
|
||||
# (TimeoutException)".
|
||||
tag, reason = task_failure_report(TimeoutException("Message: "))
|
||||
|
||||
self.assertEqual(tag, "FAIL")
|
||||
self.assertNotIn("not available", reason)
|
||||
|
||||
def test_the_exception_name_is_kept(self):
|
||||
# It is the difference between a lookup that missed and a wait that
|
||||
# expired, and someone pasting a log should not lose it.
|
||||
self.assertIn("ElementNeverAppeared", task_failure_report(ElementNeverAppeared("x"))[1])
|
||||
self.assertIn("TimeoutException", task_failure_report(TimeoutException("x"))[1])
|
||||
|
||||
def test_anything_else_keeps_its_own_message(self):
|
||||
tag, reason = task_failure_report(WebDriverException("chrome not reachable"))
|
||||
|
||||
self.assertEqual(tag, "FAIL")
|
||||
self.assertIn("WebDriverException", reason)
|
||||
self.assertIn("chrome not reachable", reason)
|
||||
|
||||
|
||||
class TaskLoop(unittest.TestCase):
|
||||
"""complete_all_tasks, with the six tasks replaced by recorded calls."""
|
||||
|
||||
STEPS = (
|
||||
("Bing daily set", "complete_bing_daily_set"),
|
||||
("Explore on Bing", "complete_explore_on_bing_tasks"),
|
||||
("Visual search", "complete_visual_search"),
|
||||
("Misc cards", "complete_misc_cards"),
|
||||
("Required searches", "complete_required_searches"),
|
||||
("Bonus points", "claim_bonus_points"),
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.ran = []
|
||||
|
||||
def _tasks(self, failures=None):
|
||||
failures = failures or {}
|
||||
tasks = make_tasks()
|
||||
|
||||
for _, attribute in self.STEPS:
|
||||
def step(name=attribute):
|
||||
self.ran.append(name)
|
||||
|
||||
if name in failures:
|
||||
raise failures[name]
|
||||
|
||||
setattr(tasks, attribute, step)
|
||||
|
||||
return tasks
|
||||
|
||||
def _run(self, failures=None):
|
||||
with self.assertLogs(rewards_tasks.logger, level=logging.INFO) as captured:
|
||||
self._tasks(failures).complete_all_tasks()
|
||||
|
||||
return "\n".join(captured.output)
|
||||
|
||||
def test_a_failing_task_does_not_stop_the_ones_after_it(self):
|
||||
self._run({"complete_visual_search": WebDriverException("chrome not reachable")})
|
||||
|
||||
self.assertEqual(self.ran, [attribute for _, attribute in self.STEPS])
|
||||
|
||||
def test_absence_and_an_expired_wait_read_differently(self):
|
||||
output = self._run({
|
||||
"complete_visual_search": ElementNeverAppeared("nothing matched"),
|
||||
"complete_required_searches": TimeoutException("Message: "),
|
||||
})
|
||||
|
||||
self.assertIn("[SKIP] Visual search: not available in this UI variant", output)
|
||||
self.assertIn("[FAIL] Required searches: on the page but not ready in time", output)
|
||||
self.assertIn("[OK] Bing daily set", output)
|
||||
|
||||
def test_a_section_that_never_rendered_is_not_called_unavailable(self):
|
||||
output = self._run({
|
||||
"complete_misc_cards": ElementNotReady(
|
||||
"'moreactivities' is present but no visible copy has content yet"
|
||||
),
|
||||
})
|
||||
|
||||
self.assertIn("[FAIL] Misc cards: on the page but not ready in time", output)
|
||||
self.assertNotIn("Misc cards: not available", output)
|
||||
|
||||
def test_a_task_this_variant_does_not_ship_is_still_skipped(self):
|
||||
output = self._run({
|
||||
"complete_explore_on_bing_tasks": NoSuchElementException(
|
||||
"no Explore on Bing section in this UI variant"
|
||||
),
|
||||
})
|
||||
|
||||
self.assertIn("[SKIP] Explore on Bing: not available in this UI variant", output)
|
||||
|
||||
def test_an_unexpected_failure_still_reports_what_went_wrong(self):
|
||||
output = self._run({"complete_misc_cards": WebDriverException("chrome not reachable")})
|
||||
|
||||
self.assertIn("[FAIL] Misc cards: WebDriverException", output)
|
||||
self.assertIn("chrome not reachable", output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user