All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m5s
Замер на проде 2026-08-29. Три независимых запуска camoufox через прод-прокси, идём по карточкам до первого отказа — каждый раз одно и то же: карточка #1 даёт 200 и ~1 МБ с SSR-стейтом, карточка #2 даёт 401 и страницу отказа на 26 625 байт. A/B на восьми карточках: свой браузер на каждую — 4 из 4 успеха, один браузер на четыре — 1 из 4. Прямой выход с сервера и выход через прокси неразличимы (1 из 8 в обоих условиях), то есть дело не в IP. Свежего КОНТЕКСТА не хватает: в контрольном замере каждая карточка бралась через browser.new_page(), в новом изолированном контексте, — и всё равно отказ со второй. Признак живёт на уровне процесса, camoufox генерирует отпечаток при запуске, а не при создании контекста. Отсюда: reset_context (#3118) эту задачу не решает в принципе. Цена перезапуска — 0.6 с (3.5 с только первый, холодный). - PROVIDERS: добавлен "domclick" (+ host-detect). Раньше он проваливался в generic и делил браузер со счётчиком страниц с прочим трафиком — при пороге перезапуска 1 это было бы неверно. - BROWSER_RECYCLE_PAGES стал поставщик-зависимым (_resolve_recycle_pages + BROWSER_RECYCLE_PAGES_{PROVIDER}), по образцу BROWSER_BLOCK_IMAGES_{PROVIDER} из #3185. Код-дефолт domclick=1, остальным прежние 15 — у Авито и Циана узор другой и своего замера под него нет. Отдельно починены ~24 холостых охранника в тестах. Они делали monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000), чтобы запретить перезапуск браузера; после перехода на словарь этот патч перестал на что-либо влиять, и набор оставался зелёным лишь потому, что ни один тест не делает 15 страниц подряд. Теперь патчится _RECYCLE_PAGES_BY_PROVIDER, а сама глобальная константа убрана, чтобы её не патчили снова. Проверено мутацией: при пороге 1 для всех провайдеров падают ровно три теста, которые этот дефолт и проверяют, остальные 155 удерживаются — значит охранники работают.
276 lines
12 KiB
Python
276 lines
12 KiB
Python
"""test_server_pow_challenge.py — QRATOR PoW-челлендж Авито в /fetch (#3045).
|
||
|
||
Проблема, установленная замером на проде 2026-08-21: Авито за QRATOR отдаёт
|
||
proof-of-work челлендж (JS считает PoW, ставит куку pow_solved, через
|
||
setTimeout(3000) делает `window.location = location.href` — self-reload, URL не
|
||
меняется). Фиксированной паузы BROWSER_WAIT_MS (6с) на цепочку
|
||
«PoW → таймер 3с → перезагрузка → гидрация» хватало не всегда: живой замер дал
|
||
2 успеха из 6 карточек, 4 отказа — все «проверка безопасности», НЕ бан по IP.
|
||
|
||
Тестируется _fetch_once целиком (не только вынесенный polling-хелпер), чтобы
|
||
проверить весь путь: goto → content() → детект → (ожидание | ошибка) → return.
|
||
|
||
camoufox НЕ запускается: _browsers[provider] — поддельный browser/page,
|
||
возвращающие заранее заданную последовательность content(). wait_for_timeout
|
||
на фейковой page — no-op (без реального asyncio.sleep), поэтому тесты идут
|
||
мгновенно независимо от величины BROWSER_CHALLENGE_WAIT_MS/BROWSER_WAIT_MS.
|
||
|
||
Запуск (из tradein-mvp/browser/)::
|
||
|
||
python -m pytest test_server_pow_challenge.py -q
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import importlib.util
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
|
||
_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)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Чистое per-provider состояние на каждый тест (зеркалит test_server_smoke.py)."""
|
||
monkeypatch.setattr(server, "_browsers", {})
|
||
monkeypatch.setattr(server, "_browser_cms", {})
|
||
monkeypatch.setattr(server, "_page_counters", {})
|
||
monkeypatch.setattr(server, "_locks", {})
|
||
monkeypatch.setattr(server, "_retry_tasks", {})
|
||
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
|
||
|
||
|
||
_CHALLENGE_HTML = (
|
||
"<html><body><script>"
|
||
"document.addEventListener('DOMContentLoaded', function() {"
|
||
" if (getCookie('pow_solved')) {"
|
||
" setTimeout(function() { window.location = location.href; }, 3000);"
|
||
" return;"
|
||
" }"
|
||
" startPow(1);"
|
||
"});"
|
||
"</script></body></html>"
|
||
)
|
||
_BAN_HTML = "<html><body>Доступ ограничен: проблема с IP</body></html>"
|
||
_REAL_HTML = "<html><body>listing card content</body></html>"
|
||
|
||
|
||
class _ChallengePage:
|
||
"""Поддельная page: отдаёт заданную последовательность content() по вызовам.
|
||
|
||
После исчерпания списка повторяет последний элемент (имитирует «страница
|
||
осталась в этом состоянии»). Фиксирует goto/wait_for_timeout-вызовы для
|
||
проверки, что бюджет ожидания не тратится там, где не должен.
|
||
|
||
Элемент последовательности может быть исключением — тогда content() его
|
||
поднимает. Это нужно, чтобы воспроизвести гонку с self-reload челленджа:
|
||
playwright кидает «Execution context was destroyed» ровно в момент той
|
||
перезагрузки, которую мы ждём, и на моках без этого дефект не виден.
|
||
"""
|
||
|
||
def __init__(self, html_sequence: list[str | Exception]) -> None:
|
||
self._html_sequence = html_sequence
|
||
self._call_count = 0
|
||
self.goto_urls: list[str] = []
|
||
self.wait_for_timeout_calls: list[int] = []
|
||
self.closed = 0
|
||
|
||
async def route(self, pattern: str, handler: Any) -> None:
|
||
return None
|
||
|
||
async def goto(self, url: str, **kwargs: Any) -> None:
|
||
self.goto_urls.append(url)
|
||
|
||
async def wait_for_timeout(self, ms: int) -> None:
|
||
self.wait_for_timeout_calls.append(ms)
|
||
|
||
async def content(self) -> str:
|
||
idx = min(self._call_count, len(self._html_sequence) - 1)
|
||
item = self._html_sequence[idx]
|
||
self._call_count += 1
|
||
if isinstance(item, Exception):
|
||
raise item
|
||
return item
|
||
|
||
async def close(self) -> None:
|
||
self.closed += 1
|
||
|
||
|
||
class _ChallengeBrowser:
|
||
def __init__(self, page: _ChallengePage) -> None:
|
||
self._page = page
|
||
|
||
async def new_page(self) -> _ChallengePage:
|
||
return self._page
|
||
|
||
|
||
def _install(
|
||
monkeypatch: pytest.MonkeyPatch, page: _ChallengePage, provider: str = "avito"
|
||
) -> None:
|
||
server._browsers[provider] = _ChallengeBrowser(page)
|
||
monkeypatch.setattr(
|
||
server, "_RECYCLE_PAGES_BY_PROVIDER",
|
||
dict.fromkeys(server.PROVIDERS, 10_000),
|
||
)
|
||
|
||
|
||
# ── детекторы: чистые функции ────────────────────────────────────────────────────
|
||
|
||
|
||
def test_is_pow_challenge_true_on_start_pow_marker() -> None:
|
||
assert server._is_pow_challenge(_CHALLENGE_HTML) is True
|
||
|
||
|
||
def test_is_pow_challenge_true_on_title_marker() -> None:
|
||
html = "<html><body>Доступ ограничен: проверка безопасности</body></html>"
|
||
assert server._is_pow_challenge(html) is True
|
||
|
||
|
||
def test_is_pow_challenge_false_on_ban_page() -> None:
|
||
"""Бан-страница НЕ должна ложно матчиться как челлендж — разные ветки."""
|
||
assert server._is_pow_challenge(_BAN_HTML) is False
|
||
|
||
|
||
def test_is_pow_challenge_false_on_real_content() -> None:
|
||
assert server._is_pow_challenge(_REAL_HTML) is False
|
||
|
||
|
||
def test_is_ban_page_true_on_ip_problem_marker() -> None:
|
||
assert server._is_ban_page(_BAN_HTML) is True
|
||
|
||
|
||
def test_is_ban_page_false_on_challenge_page() -> None:
|
||
assert server._is_ban_page(_CHALLENGE_HTML) is False
|
||
|
||
|
||
def test_is_ban_page_false_on_real_content() -> None:
|
||
assert server._is_ban_page(_REAL_HTML) is False
|
||
|
||
|
||
def test_challenge_wait_budget_defaults_to_30s(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Дефолт BROWSER_CHALLENGE_WAIT_MS=30000 без переопределения env."""
|
||
monkeypatch.delenv("BROWSER_CHALLENGE_WAIT_MS", raising=False)
|
||
import os
|
||
|
||
assert int(os.environ.get("BROWSER_CHALLENGE_WAIT_MS", "30000")) == 30000
|
||
|
||
|
||
# ── _fetch_once: сценарии ────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_fetch_once_waits_out_challenge_then_returns_real_content(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Челлендж на первых двух опросах, третий — уже реальный контент → он и вернётся."""
|
||
page = _ChallengePage([_CHALLENGE_HTML, _CHALLENGE_HTML, _REAL_HTML])
|
||
_install(monkeypatch, page)
|
||
|
||
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
|
||
|
||
assert html == _REAL_HTML
|
||
assert page.closed == 1
|
||
# Обычная гидро-пауза + минимум одна пауза опроса челленджа + финальная
|
||
# догидрация тем же BROWSER_WAIT_MS (второй таймаут не изобретаем).
|
||
assert server.BROWSER_WAIT_MS in page.wait_for_timeout_calls
|
||
assert 1000 in page.wait_for_timeout_calls # шаг опроса
|
||
|
||
|
||
def test_fetch_once_raises_challenge_timeout_when_budget_exhausted(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Челлендж не снимается никогда → ChallengeTimeoutError, а не заглушка-контент."""
|
||
monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 2000)
|
||
page = _ChallengePage([_CHALLENGE_HTML]) # всегда челлендж (последний элемент повторяется)
|
||
_install(monkeypatch, page)
|
||
|
||
with pytest.raises(server.ChallengeTimeoutError):
|
||
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
|
||
|
||
# finally всё равно закрывает страницу, несмотря на исключение.
|
||
assert page.closed == 1
|
||
|
||
|
||
def test_fetch_once_raises_ban_error_immediately_without_spending_budget(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Бан-страница («проблема с IP») → своя ошибка сразу, БЕЗ цикла опроса."""
|
||
page = _ChallengePage([_BAN_HTML])
|
||
_install(monkeypatch, page)
|
||
|
||
with pytest.raises(server.BanPageDetectedError):
|
||
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
|
||
|
||
assert page.closed == 1
|
||
# Единственный wait_for_timeout — обычная гидро-пауза ДО детекта; опроса
|
||
# челленджа (доп. паузы по 1000мс) не было — бюджет не потрачен.
|
||
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
|
||
|
||
|
||
def test_fetch_once_normal_page_without_markers_unaffected(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Страница без маркеров челленджа/бана → поведение как раньше, без polling-ветки."""
|
||
page = _ChallengePage([_REAL_HTML])
|
||
_install(monkeypatch, page)
|
||
|
||
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
|
||
|
||
assert html == _REAL_HTML
|
||
assert page.closed == 1
|
||
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
|
||
assert page.goto_urls == ["https://www.avito.ru/card/1"]
|
||
|
||
|
||
# ── гонка с self-reload челленджа (#3045, найдено при ревью ветки) ──────────────
|
||
#
|
||
# Челлендж перезагружает страницу САМ. Вызов page.content(), попавший ровно в этот
|
||
# момент, кидает «Execution context was destroyed» — то есть цикл ожидания падал бы
|
||
# именно на успешном исходе, ради которого написан. На моках без явной имитации
|
||
# это не воспроизводится, поэтому тесты ниже поднимают исключение из content().
|
||
|
||
_NAV_RACE = RuntimeError(
|
||
"Execution context was destroyed, most likely because of a navigation."
|
||
)
|
||
|
||
|
||
def test_navigation_race_during_reload_is_not_a_failure(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""content() упал на перезагрузке → опрашиваем дальше, отдаём настоящий HTML."""
|
||
page = _ChallengePage([_CHALLENGE_HTML, _NAV_RACE, _REAL_HTML])
|
||
_install(monkeypatch, page)
|
||
|
||
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
|
||
|
||
assert html == _REAL_HTML
|
||
|
||
|
||
def test_permanent_navigation_race_raises_challenge_timeout(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Навигация не прекращается → своя ошибка, а не сырое исключение playwright."""
|
||
page = _ChallengePage([_CHALLENGE_HTML, _NAV_RACE])
|
||
_install(monkeypatch, page)
|
||
|
||
with pytest.raises(server.ChallengeTimeoutError):
|
||
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
|
||
|
||
|
||
def test_unrelated_content_error_still_propagates(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Глушим ТОЛЬКО гонку навигации; упавший браузер должен всплыть как есть."""
|
||
boom = RuntimeError("Target page, context or browser has been closed")
|
||
page = _ChallengePage([_CHALLENGE_HTML, boom])
|
||
_install(monkeypatch, page)
|
||
|
||
with pytest.raises(RuntimeError, match="has been closed"):
|
||
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
|