From 99e0cfde3d69b048d6cd58c29d7f2d22bbfa9b9e Mon Sep 17 00:00:00 2001 From: Austin Brogle <3267754+broglea@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:51:48 -0700 Subject: [PATCH] fix: prevent Anna's Archive download countdown resets by preserving browser sessions (#1325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Observed bug Anna's Archive slow-download pages can return a JavaScript countdown before a download link is available. The internal browser returns that waiting-room HTML and closes its incognito session. The downloader then sleeps and fetches the URL again, which can create a new queue session and **restart the countdown instead of reaching the download link**. ## Fix - **Preserve the queue session:** keep the original browser tab open while the site's own countdown and automatic navigation finish. HTTP 200 and cached-cookie waiting-room responses enter the same flow. - **Return a consistent page:** capture HTML and readiness together in one browser evaluation so navigation cannot pair a new page's status with stale protection-page HTML. Share cache validation and page-readiness rules across their callers. - **Keep waiting cancellable and bounded:** poll cancellation while a slow browser read remains pending, rather than repeatedly cancelling and reissuing it. Apply a **300-second waiting-room limit** within the existing browser watchdog. - **Report queue timeouts accurately:** preserve the timeout across the helper-process boundary and stop the solve without restarting the browser or rotating mirrors. Waiting-room detection is limited to Anna's Archive `/slow_download/` pages containing an actual `.js-partner-countdown` element. The site controls the countdown and refresh. External-bypasser behavior and file-transfer timeouts are unchanged; the PR adds no deployment configuration or dependencies. ## Validation Validated at `c81e0a2`: | Check | Result | | --- | --- | | Full Linux unit suite | **2,978 passed** on Python 3.14 in a non-root environment with entrypoint test stubs enabled | | Focused regression coverage | **30 passed**, covering countdown completion, zero timers, navigation, both cookie-cache paths, cancellation, stuck queues, slow reads, and timeout propagation | | Navigation-race regression | Fails against the previous PR implementation and passes with the fix | | Python static checks | Ruff lint/format, BasedPyright for backend and tests, and Vulture passed | | Real Chromium fixture | Queue cookie persisted through 1.5-second DOM reads and one automatic refresh; the CDP connection survived multiple polling intervals | | Live source check | Observed **19 → 14 → 9 → 4 → download link** while retaining the browser session; the patched browser path also completed the waiting room | Full unit-suite command: ```sh pytest tests/ -n 2 --tb=short -m "not integration and not e2e" ``` The live check validates waiting-room completion and link resolution. Remote file-host availability remains a separate concern. The unit suite emitted two existing Authlib deprecation warnings. --- shelfmark/bypass/internal_bypasser.py | 104 ++++++++- shelfmark/bypass/waiting_room.py | 16 ++ shelfmark/download/http.py | 12 ++ tests/bypass/test_aa_waiting_room.py | 294 ++++++++++++++++++++++++++ 4 files changed, 423 insertions(+), 3 deletions(-) create mode 100644 shelfmark/bypass/waiting_room.py create mode 100644 tests/bypass/test_aa_waiting_room.py diff --git a/shelfmark/bypass/internal_bypasser.py b/shelfmark/bypass/internal_bypasser.py index 8d31dd3a..fe9c6024 100644 --- a/shelfmark/bypass/internal_bypasser.py +++ b/shelfmark/bypass/internal_bypasser.py @@ -37,6 +37,7 @@ from shelfmark.bypass.cookie_store import ( store_extracted_cookies, ) from shelfmark.bypass.fingerprint import get_screen_size +from shelfmark.bypass.waiting_room import WaitingRoomTimeoutError, is_aa_waiting_room from shelfmark.config import env from shelfmark.config.env import LOG_DIR from shelfmark.config.settings import RECORDING_DIR @@ -85,11 +86,22 @@ _HELPER_IDLE_TIMEOUT_DEFAULT = 180.0 # How long to wait for a solved page to produce its document before the attempt is # abandoned. SeleniumBase's own get_page_source() allows one second; see _read_page_source. _PAGE_SOURCE_TIMEOUT_DEFAULT = 20.0 +# Leave time inside the existing browser watchdog for challenge solving and cleanup. +_AA_WAITING_ROOM_TIMEOUT_SECONDS = 300.0 +_AA_WAITING_ROOM_POLL_SECONDS = 1.0 _PARENT_WATCHDOG_INTERVAL_SECONDS = 5.0 # How much of ffmpeg's stderr to quote when reporting that it died. _FFMPEG_ERROR_TAIL_CHARS = 500 +class _WaitingRoomSnapshot(TypedDict): + html: str + title: str + body: str + url: str + waiting: bool + + class _DisplayState(TypedDict): ffmpeg: subprocess.Popen[bytes] | None ffmpeg_output: Path | None @@ -454,6 +466,14 @@ async def _detect_challenge_type(page: Any) -> str: async def _is_bypassed(page: Any, *, escape_emojis: bool = True) -> bool: """Check if the protection has been bypassed.""" title, body, current_url = await _get_page_info(page) + return _is_bypassed_content(title, body, current_url, escape_emojis=escape_emojis) + + +def _is_bypassed_content( + title: str, body: str, current_url: str, *, escape_emojis: bool = True +) -> bool: + """Apply the same protection checks to one consistent page snapshot.""" + title, body = title.lower(), body.lower() body_len = len(body.strip()) # Long page content = probably bypassed @@ -831,6 +851,75 @@ async def _read_page_source(page: Any) -> str: return await element.get_html_async() +async def _read_waiting_room_snapshot( + page: Any, cancel_flag: Event | None +) -> _WaitingRoomSnapshot | None: + """Read one DOM snapshot while still checking cancellation during a stalled read.""" + task = asyncio.create_task( + page.evaluate("""({ + html: document.documentElement?.outerHTML || '', + title: document.title, + body: document.body?.innerText || '', + url: location.href, + waiting: !!document.querySelector('.js-partner-countdown') + })""") + ) + try: + while not task.done(): + _check_cancellation(cancel_flag, "Bypass cancelled in Anna's waiting room") + # Keep a slow CDP request alive. Cancelling and reissuing it on every + # poll can break the listener when a late response targets a cancelled + # SeleniumBase transaction. Cancel only when this browser is unwinding. + await asyncio.wait({task}, timeout=_AA_WAITING_ROOM_POLL_SECONDS) + _check_cancellation(cancel_flag, "Bypass cancelled in Anna's waiting room") + return task.result() + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +async def _wait_for_aa_download_page( + page: Any, url: str, html: str, cancel_flag: Event | None = None +) -> str: + """Let the waiting room's own JavaScript countdown and navigation finish. + + Returning the timer HTML closes this incognito browser. Sleeping in the HTTP + downloader and fetching again then starts a different session, losing queue state. + Keep the original tab alive, including through zero and automatic page reloads. + """ + if not is_aa_waiting_room(url, html): + return html + + logger.info("Waiting for Anna's Archive queue in the same browser session...") + started = time.monotonic() + try: + async with asyncio.timeout(_AA_WAITING_ROOM_TIMEOUT_SECONDS): + while True: + try: + # Read readiness and HTML atomically: navigation between separate + # CDP reads could validate a new page but return an old interstitial. + snapshot = await _read_waiting_room_snapshot(page, cancel_flag) + except _CDP_OPERATION_ERRORS: + # A frame/context can disappear during automatic navigation. + snapshot = None + if ( + snapshot + and not snapshot["waiting"] + and _is_bypassed_content(snapshot["title"], snapshot["body"], snapshot["url"]) + ): + logger.info( + "Anna's Archive waiting room finished after %.0fs", + time.monotonic() - started, + ) + return snapshot["html"] + # A zero timer, empty document, or protection page is not completion. + await asyncio.sleep(_AA_WAITING_ROOM_POLL_SECONDS) + except TimeoutError as exc: + raise WaitingRoomTimeoutError( + f"Anna's Archive waiting room did not finish within {_AA_WAITING_ROOM_TIMEOUT_SECONDS:g}s" + ) from exc + + async def _get(url: str, driver: Any, cancel_flag: Event | None = None) -> str: """Fetch URL with Cloudflare bypass using a CDP browser.""" _check_cancellation(cancel_flag, "Bypass cancelled before starting") @@ -853,8 +942,10 @@ async def _get(url: str, driver: Any, cancel_flag: Event | None = None) -> str: logger.debug("Starting bypass process...") if await _bypass(page, cancel_flag=cancel_flag): + html = await _read_page_source(page) + html = await _wait_for_aa_download_page(page, url, html, cancel_flag) await _extract_cookies_from_cdp(driver, page, url) - return await _read_page_source(page) + return html logger.warning("Bypass completed but page still shows protection") try: @@ -904,7 +995,8 @@ def _run_bypass_in_current_process(url: str, retry: int, cancel_flag: Event | No result = await _get(url, driver, cancel_flag) if result: return result - except BypassCancelledError: + except BypassCancelledError, WaitingRoomTimeoutError: + # Retrying would restart the same queue in another browser. raise except _CDP_OPERATION_ERRORS as e: error_details = f"{type(e).__name__}: {e}" @@ -1215,6 +1307,8 @@ def _get_via_subprocess(url: str, retry: int, cancel_flag: Event | None = None) trace = result.get("traceback") if trace: logger.debug("Internal bypasser helper traceback: %s", trace) + if error_type == WaitingRoomTimeoutError.__name__: + raise WaitingRoomTimeoutError(error) msg = f"{error_type}: {error}" raise RuntimeError(msg) @@ -1534,6 +1628,10 @@ def _try_with_cached_cookies(url: str, hostname: str) -> str | None: verify=get_ssl_verify(url), ) if response.status_code == HTTPStatus.OK: + if is_aa_waiting_room(url, response.text): + # Clearance is valid, but HTTP cannot run the queue's JavaScript. + # Enforce this here for both cache checks, including the locked one. + return None logger.debug("Cached cookies worked, skipped Chrome bypass") return response.text if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: @@ -1606,7 +1704,7 @@ def get_bypassed_page( try: response_html = get(attempt_url, cancel_flag=cancel_flag) - except BypassCancelledError: + except BypassCancelledError, WaitingRoomTimeoutError: raise except _CDP_OPERATION_ERRORS + _REQUEST_OPERATION_ERRORS: _check_cancellation(cancel_flag, "Bypass cancelled") diff --git a/shelfmark/bypass/waiting_room.py b/shelfmark/bypass/waiting_room.py new file mode 100644 index 00000000..3b7d18ed --- /dev/null +++ b/shelfmark/bypass/waiting_room.py @@ -0,0 +1,16 @@ +"""Recognize Anna's Archive pages that require a live JavaScript timer.""" + +from urllib.parse import urlparse + +from bs4 import BeautifulSoup + + +class WaitingRoomTimeoutError(TimeoutError): + """The source waiting room did not finish within the browser session budget.""" + + +def is_aa_waiting_room(url: str, html: str) -> bool: + """Match the download route and actual timer element, not a script reference.""" + return urlparse(url).path.startswith("/slow_download/") and bool( + BeautifulSoup(html, "html.parser").select_one(".js-partner-countdown") + ) diff --git a/shelfmark/download/http.py b/shelfmark/download/http.py index f58c0b78..dc201a9e 100644 --- a/shelfmark/download/http.py +++ b/shelfmark/download/http.py @@ -12,6 +12,7 @@ from tqdm import tqdm from shelfmark.bypass import BypassCancelledError, ChallengeNotSolvedError, cookie_store from shelfmark.bypass.challenge import challenge_marker +from shelfmark.bypass.waiting_room import WaitingRoomTimeoutError, is_aa_waiting_room from shelfmark.core import search_deadline from shelfmark.core.config import config as app_config from shelfmark.core.logger import setup_logger @@ -440,6 +441,9 @@ def html_get_page( except _STATUS_CALLBACK_ERRORS: logger.debug("Rate-limit status callback failed", exc_info=True) return _fail(str(e), bypass_url) + except WaitingRoomTimeoutError as e: + logger.info("Waiting room timed out: %s", e) + return _fail(str(e), bypass_url) except ChallengeNotSolvedError as e: # Not a bypasser malfunction: it ran, and the host answered with something it # cannot clear - DDoS-Guard's manual CAPTCHA, typically. Must precede the @@ -705,6 +709,14 @@ def html_get_page( continue response.raise_for_status() + if ( + _bypass_handoff_allowed() + and not _is_using_external_bypasser() + and is_aa_waiting_room(current_url, response.text) + ): + # A successful HTTP response can still need a live browser: the + # queue's JavaScript must finish in the session that entered it. + return _run_bypasser(current_url) if success_delay > 0: time.sleep(success_delay) return _result(response.text, response.url) diff --git a/tests/bypass/test_aa_waiting_room.py b/tests/bypass/test_aa_waiting_room.py new file mode 100644 index 00000000..946ef89d --- /dev/null +++ b/tests/bypass/test_aa_waiting_room.py @@ -0,0 +1,294 @@ +"""Keep Anna's JavaScript waiting room alive until its own navigation completes.""" + +import asyncio +import threading +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +import requests + +import shelfmark.bypass.internal_bypasser as b +from shelfmark.bypass import BypassCancelledError +from shelfmark.bypass.waiting_room import WaitingRoomTimeoutError, is_aa_waiting_room + +URL = "https://annas-archive.gl/slow_download/abc/0/0" +WAIT = '25' +ZERO = WAIT.replace(">25<", ">0<") +READY = 'Download now' + + +def _snapshot(html=READY, *, waiting=False, title="Anna's Archive", body=None): + return { + "html": html, + "waiting": waiting, + "title": title, + "url": URL, + "body": body + if body is not None + else "Your download is ready. Follow the download link to obtain the requested file.", + } + + +@pytest.fixture +def cached_http(monkeypatch): + response = SimpleNamespace(status_code=200, text=WAIT) + cleared = Mock() + monkeypatch.setattr(b, "get_cf_cookies_for_domain", lambda _: {"cf_clearance": "valid"}) + monkeypatch.setattr(b, "clear_cf_cookies", cleared) + monkeypatch.setattr(b.requests, "get", Mock(return_value=response)) + monkeypatch.setattr(b.network, "host_cooldown_remaining", lambda _: 0) + return response, cleared + + +@pytest.mark.parametrize( + ("url", "html", "expected"), + [ + (URL, WAIT, True), + (URL, ZERO, True), + (URL, READY, False), + (URL.replace("/slow_download/abc/0/0", "/search"), WAIT, False), + (URL, '', False), + ], +) +def test_waiting_room_detection(url, html, expected): + assert is_aa_waiting_room(url, html) is expected + + +def test_get_keeps_same_tab_through_zero_reload_and_protection(monkeypatch): + page = SimpleNamespace( + wait=AsyncMock(), + get_current_url=AsyncMock(return_value=URL), + get_title=AsyncMock(return_value="Anna's Archive"), + ) + driver = SimpleNamespace(get=AsyncMock(return_value=page)) + monkeypatch.setattr(b, "_bypass", AsyncMock(return_value=True)) + # A transient protocol error and a protection page can occur during navigation. + read = AsyncMock(return_value=WAIT) + page.evaluate = AsyncMock( + side_effect=[ + _snapshot(WAIT, waiting=True), + _snapshot(ZERO, waiting=True), + b.ProtocolException("navigating"), + _snapshot("DDOS-GUARD", title="DDOS-GUARD"), + _snapshot("", body=""), + _snapshot(), + ] + ) + monkeypatch.setattr(b, "_read_page_source", read) + cookies = AsyncMock() + monkeypatch.setattr(b, "_extract_cookies_from_cdp", cookies) + monkeypatch.setattr(b.asyncio, "sleep", AsyncMock()) + + assert asyncio.run(b._get(URL, driver)) == READY + driver.get.assert_awaited_once_with(URL) + read.assert_awaited_once_with(page) + cookies.assert_awaited_once_with(driver, page, URL) + + +def test_wait_can_be_cancelled(monkeypatch): + cancel = threading.Event() + + async def cancel_during_sleep(_delay): + cancel.set() + + monkeypatch.setattr(b.asyncio, "sleep", cancel_during_sleep) + page = SimpleNamespace(evaluate=AsyncMock(return_value=_snapshot(WAIT, waiting=True))) + with pytest.raises(BypassCancelledError): + asyncio.run(b._wait_for_aa_download_page(page, URL, WAIT, cancel)) + + +def test_stuck_queue_has_a_deadline(monkeypatch): + monkeypatch.setattr(b, "_AA_WAITING_ROOM_TIMEOUT_SECONDS", 0.01) + with pytest.raises(WaitingRoomTimeoutError, match="waiting room"): + page = SimpleNamespace(evaluate=AsyncMock(return_value=_snapshot(WAIT, waiting=True))) + asyncio.run(b._wait_for_aa_download_page(page, URL, WAIT)) + + +def test_queue_timeout_does_not_open_another_browser(monkeypatch): + driver = object() + create = AsyncMock(return_value=driver) + close = AsyncMock() + get = AsyncMock(side_effect=WaitingRoomTimeoutError("Queue timed out")) + monkeypatch.setattr(b, "_create_cdp_browser", create) + monkeypatch.setattr(b, "_close_cdp_driver", close) + monkeypatch.setattr(b, "_get", get) + monkeypatch.setattr(b, "_CDP_WORKER", SimpleNamespace(run=lambda task, **kw: asyncio.run(task))) + with pytest.raises(WaitingRoomTimeoutError, match="Queue timed out"): + b._run_bypass_in_current_process(URL, retry=10) + create.assert_awaited_once() + get.assert_awaited_once() + close.assert_awaited_once_with(driver) + + +@pytest.mark.parametrize("html", [READY, "ordinary page"]) +def test_non_waiting_page_returns_without_polling(html): + assert asyncio.run(b._wait_for_aa_download_page(object(), URL, html)) == html + + +@pytest.mark.parametrize( + ("enabled", "external", "fallback", "handoff"), + [ + (True, False, True, True), + (False, False, True, False), + (True, True, True, False), + (True, False, False, False), + ], +) +def test_http_200_timer_handoff_honors_browser_settings( + monkeypatch, enabled, external, fallback, handoff +): + import shelfmark.download.http as http + + response = requests.Response() + response.status_code = 200 + response.url = URL + response._content = WAIT.encode() + monkeypatch.setattr(http.requests, "get", lambda *a, **kw: response) + monkeypatch.setattr(http, "_apply_cf_bypass", lambda *a: {}) + monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: enabled) + monkeypatch.setattr(http, "_is_using_external_bypasser", lambda: external) + monkeypatch.setattr(http, "_bypass_grace_seconds", lambda: 450) + calls = [] + monkeypatch.setattr(http, "get_bypassed_page", lambda url, *a: calls.append(url) or READY) + selector = SimpleNamespace(rewrite=lambda url: url) + result = http.html_get_page( + URL, retry=1, selector=selector, success_delay=0, allow_bypasser_fallback=fallback + ) + assert result == (READY if handoff else WAIT) + assert calls == ([URL] if handoff else []) + + +def test_deadline_also_bounds_a_stalled_page_read(monkeypatch): + async def stalled_read(_expression): + await asyncio.Event().wait() + + monkeypatch.setattr(b, "_AA_WAITING_ROOM_TIMEOUT_SECONDS", 0.01) + page = SimpleNamespace(evaluate=stalled_read) + with pytest.raises(WaitingRoomTimeoutError): + asyncio.run(b._wait_for_aa_download_page(page, URL, WAIT)) + + +def test_helper_preserves_waiting_room_timeout_type(monkeypatch): + monkeypatch.setattr( + b, + "_BYPASS_HELPER", + SimpleNamespace( + run=lambda *args: { + "ok": False, + "error_type": "WaitingRoomTimeoutError", + "error": "Queue timed out", + } + ), + ) + with pytest.raises(WaitingRoomTimeoutError, match="Queue timed out"): + b._get_via_subprocess(URL, retry=10) + + +def test_queue_timeout_does_not_rotate_mirror_and_restart_wait(monkeypatch): + monkeypatch.setattr(b.network, "host_cooldown_remaining", lambda _: 0) + monkeypatch.setattr(b, "_try_with_cached_cookies", lambda *_: None) + + def timeout(*args, **kwargs): + raise WaitingRoomTimeoutError("Queue timed out") + + monkeypatch.setattr(b, "get", timeout) + # No rotation method: trying to rotate after this failure is an error. + selector = SimpleNamespace(rewrite=lambda url: url) + with pytest.raises(WaitingRoomTimeoutError, match="Queue timed out"): + b.get_bypassed_page(URL, selector) + + +def test_http_reports_queue_timeout_without_blaming_cloudflare(monkeypatch): + import shelfmark.download.http as http + + def timeout(*args, **kwargs): + raise WaitingRoomTimeoutError("Anna's Archive waiting room did not finish within 300s") + + monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True) + monkeypatch.setattr(http, "_bypass_grace_seconds", lambda: 450) + monkeypatch.setattr(http, "get_bypassed_page", timeout) + selector = SimpleNamespace(rewrite=lambda url: url, last_failure=None) + result = http.html_get_page(URL, retry=10, selector=selector, use_bypasser=True) + assert result == "" + assert selector.last_failure == "Anna's Archive waiting room did not finish within 300s" + + +@pytest.mark.parametrize("docker_mode", [False, True]) +@pytest.mark.parametrize("cached", [WAIT, READY]) +def test_cached_pages_through_both_entry_points(monkeypatch, cached_http, docker_mode, cached): + response, cleared = cached_http + response.text = cached + monkeypatch.setattr(b.env, "DOCKERMODE", docker_mode) + monkeypatch.delenv(b._BYPASS_CHILD_ENV, raising=False) + calls = [] + target = "_get_via_subprocess" if docker_mode else "_run_bypass_in_current_process" + monkeypatch.setattr(b, target, lambda url, *a, **kw: calls.append(url) or READY) + selector = SimpleNamespace(rewrite=lambda url: url) + + assert b.get_bypassed_page(URL, selector) == READY + assert calls == ([URL] if cached == WAIT else []) + # A waiting room does not invalidate the clearance cookies it was served with. + cleared.assert_not_called() + + +def test_page_readiness_and_returned_html_belong_to_the_same_snapshot(monkeypatch): + old_page = "DDOS-GUARD" + page = SimpleNamespace( + evaluate=AsyncMock( + side_effect=[ + _snapshot(old_page, title="DDOS-GUARD"), + _snapshot(), + ] + ) + ) + # Reproduce navigation after the HTML read: a separate live-page check already + # sees a ready page. The old loop incorrectly returned the captured interstitial. + monkeypatch.setattr(b, "_read_page_source", AsyncMock(return_value=old_page)) + monkeypatch.setattr(b, "_is_bypassed", AsyncMock(return_value=True)) + monkeypatch.setattr(b.asyncio, "sleep", AsyncMock()) + assert asyncio.run(b._wait_for_aa_download_page(page, URL, WAIT)) == READY + + +def test_cancellation_interrupts_a_stalled_browser_read(monkeypatch): + cancel = threading.Event() + + async def stalled_read(_expression): + cancel.set() + await asyncio.Event().wait() + + monkeypatch.setattr(b, "_AA_WAITING_ROOM_POLL_SECONDS", 0.01) + page = SimpleNamespace(evaluate=stalled_read) + with pytest.raises(BypassCancelledError): + asyncio.run(b._wait_for_aa_download_page(page, URL, WAIT, cancel)) + + +def test_cancellation_after_read_wins_over_ready_result(monkeypatch): + cancel = threading.Event() + + async def finish_and_cancel(_expression): + cancel.set() + return _snapshot() + + page = SimpleNamespace(evaluate=finish_and_cancel) + with pytest.raises(BypassCancelledError): + asyncio.run(b._wait_for_aa_download_page(page, URL, WAIT, cancel)) + + +@pytest.mark.parametrize("transient", [TimeoutError(), TypeError("missing CDP result"), None]) +def test_transient_read_failure_keeps_the_same_tab(monkeypatch, transient): + page = SimpleNamespace(evaluate=AsyncMock(side_effect=[transient, _snapshot()])) + monkeypatch.setattr(b.asyncio, "sleep", AsyncMock()) + assert asyncio.run(b._wait_for_aa_download_page(page, URL, WAIT)) == READY + assert page.evaluate.await_count == 2 + + +def test_slow_snapshot_is_not_cancelled_and_reissued(monkeypatch): + async def slow_read(_expression): + await asyncio.sleep(0.03) + return _snapshot() + + monkeypatch.setattr(b, "_AA_WAITING_ROOM_POLL_SECONDS", 0.005) + page = SimpleNamespace(evaluate=AsyncMock(side_effect=slow_read)) + assert asyncio.run(b._wait_for_aa_download_page(page, URL, WAIT)) == READY + page.evaluate.assert_awaited_once()