"""test_server_challenge_reload.py — сайдкар сам перезагружает зависший PoW-челлендж. Повод — ручная сессия 2026-08-29 через узел 10 (asocks-mobile-2, прод-прокси, headful-камуфокс, путь «яндекс-поиск → выдача Домклика → карточка»). Запись сети: 102.7с GET /pokupka/kvartiry/vtorichka → 401 107.7с выдана кука qrator_jsr (PoW пошёл) 115.1с GET /pokupka/kvartiry/vtorichka → 401 ← перезагрузка человеком 118.4с GET /pokupka/kvartiry/vtorichka → 200 ← вторая перезагрузка 119.6с выдана кука qrator_jsid2 (ПРОПУСК) 124.6с GET /card/sale__flat__2078134349 → 200 То есть допущение «страница перезагрузит себя сама после решения PoW», на котором был построен _wait_out_pow_challenge, выполняется НЕ всегда: здесь пропуск выдался только после навигаций, сделанных руками. Пока сайдкар лишь опрашивал content(), такая страница гарантированно доживала до BROWSER_CHALLENGE_WAIT_MS и уходила в ChallengeTimeoutError, а бэкфилл засчитывал это в блоки. Здесь проверяется, что сайдкар делает то же, что сделал человек: подождав BROWSER_CHALLENGE_RELOAD_AFTER_MS, перезагружает страницу сам, до BROWSER_CHALLENGE_MAX_RELOADS раз, и что это не ломает прежние пути. camoufox НЕ запускается: поддельная page с заданной последовательностью content(). wait_for_timeout — no-op, поэтому тесты идут мгновенно. Запуск (из tradein-mvp/browser/):: python -m pytest test_server_challenge_reload.py -q """ from __future__ import annotations import asyncio import importlib.util from pathlib import Path from typing import Any import pytest _SERVER_PATH = Path(__file__).resolve().parent / "server.py" _spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH) assert _spec is not None and _spec.loader is not None server = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(server) PENDING = "проверка безопасности" DONE = "listing card content" class _Page: """Поддельная page: content() идёт по списку, reload() двигает курсор дальше. `reload_unblocks_at` — индекс, начиная с которого content() отдаёт DONE, но ТОЛЬКО если к этому моменту была хотя бы одна перезагрузка. Так моделируется именно наблюдавшееся поведение: сама по себе страница не «дозревает», её расколдовывает повторная навигация. """ def __init__(self, *, unblock_after_reloads: int | None = None) -> None: self.reload_calls: list[dict[str, Any]] = [] self.wait_for_timeout_calls: list[int] = [] self._unblock_after_reloads = unblock_after_reloads self.reload_error: Exception | None = None async def wait_for_timeout(self, ms: int) -> None: self.wait_for_timeout_calls.append(ms) async def reload(self, **kwargs: Any) -> None: if self.reload_error is not None: raise self.reload_error self.reload_calls.append(kwargs) async def content(self) -> str: if ( self._unblock_after_reloads is not None and len(self.reload_calls) >= self._unblock_after_reloads ): return DONE return PENDING @pytest.fixture(autouse=True) def _fast_budget(monkeypatch: pytest.MonkeyPatch) -> None: """Короткий бюджет + отключённая догидрация: тест про перезагрузки, не про сон.""" monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 30_000) monkeypatch.setattr(server, "BROWSER_CHALLENGE_RELOAD_AFTER_MS", 8_000) monkeypatch.setattr(server, "BROWSER_CHALLENGE_MAX_RELOADS", 2) monkeypatch.setattr(server, "BROWSER_WAIT_MS", 0) monkeypatch.setattr(server, "_locks_guard", asyncio.Lock()) def _wait(page: _Page) -> str: return asyncio.run( server._wait_out_pow_challenge( page, "domclick", "https://example.test/card/1", is_pending=lambda h: h == PENDING ) ) def test_hung_challenge_is_reloaded_and_resolves() -> None: """Главный сценарий: сама страница не оживает, оживает после перезагрузки.""" page = _Page(unblock_after_reloads=1) assert _wait(page) == DONE assert len(page.reload_calls) == 1 def test_reload_not_used_when_challenge_clears_on_its_own() -> None: """Страница, которая перезагружает себя сама, лишней навигации не получает.""" page = _Page(unblock_after_reloads=0) # DONE сразу, ещё до первого опроса assert _wait(page) == DONE assert page.reload_calls == [] def test_reload_count_is_capped() -> None: """Вечно висящий челлендж не превращается в долбёжку — не больше лимита.""" page = _Page(unblock_after_reloads=None) # не оживает никогда with pytest.raises(server.ChallengeTimeoutError): _wait(page) assert len(page.reload_calls) == server.BROWSER_CHALLENGE_MAX_RELOADS def test_reload_disabled_by_zero_threshold(monkeypatch: pytest.MonkeyPatch) -> None: """BROWSER_CHALLENGE_RELOAD_AFTER_MS=0 возвращает ровно прежнее поведение.""" monkeypatch.setattr(server, "BROWSER_CHALLENGE_RELOAD_AFTER_MS", 0) page = _Page(unblock_after_reloads=None) with pytest.raises(server.ChallengeTimeoutError): _wait(page) assert page.reload_calls == [] def test_failed_reload_does_not_abort_the_wait() -> None: """Перезагрузка — помощь, а не обязательный шаг: её падение не роняет фетч. Ошибка навигации здесь означает лишь, что помочь не удалось; опрос обязан продолжиться в прежнем бюджете и упереться в честный ChallengeTimeoutError, а не вывалиться наружу чужим исключением. """ page = _Page(unblock_after_reloads=None) page.reload_error = RuntimeError("Navigation timeout of 30000ms exceeded") with pytest.raises(server.ChallengeTimeoutError): _wait(page) assert page.reload_calls == []