"""test_server_reuse_context.py — тёплый переиспользуемый browser context (#3118). Проблема: browser.new_page() создаёт НОВЫЙ изолированный context на КАЖДЫЙ /fetch, поэтому залитый ОДИН РАЗ снимок cookies (см. cookies в /fetch) не видит Set-Cookie от площадки — живой qrator_jsid2 DomClick (~2.5ч TTL) протухает после первого же запроса. reuse_context=True держит per-provider context живым между вызовами; куки вливаются ТОЛЬКО в момент его создания. reset_context=True выбрасывает сожжённый context ДО работы. Проверяет: 1. reuse_context=True — context создаётся ОДИН раз на несколько _fetch_once, куки вливаются только при создании (не повторно); 2. reset_context=True — старый context закрывается и выбрасывается, следующий _fetch_once создаёт новый (с новой инъекцией кук); 3. reuse_context=False (дефолт) — поведение НЕ меняется: browser.new_page() на каждый вызов, context вообще не создаётся/не трогается; 4. _close_browser закрывает и выбрасывает сохранённый context провайдера. camoufox НЕ запускается — browser/context/page подделаны. Запуск (из tradein-mvp/browser/):: python -m pytest test_server_reuse_context.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) @pytest.fixture(autouse=True) def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(server, "_browsers", {}) monkeypatch.setattr(server, "_browser_cms", {}) monkeypatch.setattr(server, "_contexts", {}) monkeypatch.setattr(server, "_anchor_pages", {}) monkeypatch.setattr(server, "_page_counters", {}) monkeypatch.setattr(server, "_locks", {}) monkeypatch.setattr(server, "_last_goto_at", {}) # #3205: recycle threshold теперь per-provider (_RECYCLE_PAGES_BY_PROVIDER), не # единый BROWSER_RECYCLE_PAGES — патчим весь словарь высоким порогом, чтобы тесты # reuse/reset context (несколько _fetch_once подряд, включая domclick=1 по коду) # не триггерили настоящий _relaunch_browser/camoufox. monkeypatch.setattr( server, "_RECYCLE_PAGES_BY_PROVIDER", {p: 10_000 for p in server.PROVIDERS} ) class _FakePage: """Поддельная page: фиксирует goto/close, минимальный контракт _fetch_once.""" def __init__(self) -> None: self.goto_urls: list[str] = [] self.closed = 0 def is_closed(self) -> bool: return 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: return None async def content(self) -> str: # Содержит __SSR_STATE__ (#3196): для provider="domclick" это единственный # признак "успешной" страницы (см. server._is_domclick_success) — без него # generic-контент теста уходил бы в ветку ожидания рукопожатия и падал по # ChallengeTimeoutError. avito/cian игнорируют эту строку (их детекторы # ищут другие маркеры), так что для них поведение не меняется. return "ok" async def close(self) -> None: self.closed += 1 class _FakeContext: """Поддельный BrowserContext: фиксирует add_cookies/new_page/close.""" def __init__(self) -> None: self.add_cookies_calls: list[list[dict]] = [] self.pages: list[_FakePage] = [] self.closed = 0 async def add_cookies(self, cookies: list[dict]) -> None: self.add_cookies_calls.append(cookies) async def new_page(self) -> _FakePage: page = _FakePage() self.pages.append(page) return page async def close(self) -> None: self.closed += 1 class _FakeReuseBrowser: """Поддельный Browser: считает new_context()/new_page() вызовы, копит контексты.""" def __init__(self) -> None: self.contexts_created: list[_FakeContext] = [] self.new_page_calls = 0 async def new_context(self) -> _FakeContext: ctx = _FakeContext() self.contexts_created.append(ctx) return ctx async def new_page(self) -> _FakePage: self.new_page_calls += 1 return _FakePage() # ── reuse_context=True: один context на несколько fetch ─────────────────────────── def test_reuse_context_creates_context_once_across_fetches() -> None: browser = _FakeReuseBrowser() server._browsers["avito"] = browser async def _run() -> None: await server._fetch_once("avito", "https://www.avito.ru/1", reuse_context=True) await server._fetch_once("avito", "https://www.avito.ru/2", reuse_context=True) await server._fetch_once("avito", "https://www.avito.ru/3", reuse_context=True) asyncio.run(_run()) assert len(browser.contexts_created) == 1, "context должен создаваться РОВНО один раз" ctx = browser.contexts_created[0] assert len(ctx.pages) == 3, "каждый fetch — своя страница ИЗ переиспользуемого context" assert browser.new_page_calls == 0, "browser.new_page() не должен зваться при reuse_context" # Все страницы закрыты, context — нет (остаётся жить). assert all(p.closed == 1 for p in ctx.pages) assert ctx.closed == 0 assert server._contexts["avito"] is ctx def test_reuse_context_injects_cookies_only_on_creation() -> None: browser = _FakeReuseBrowser() server._browsers["domclick"] = browser cookies = {"qrator_jsid2": "live-token"} async def _run() -> None: await server._fetch_once( "domclick", "https://domclick.ru/card/1", cookies=cookies, reuse_context=True ) await server._fetch_once( "domclick", "https://domclick.ru/card/2", cookies=cookies, reuse_context=True ) asyncio.run(_run()) ctx = browser.contexts_created[0] assert len(ctx.add_cookies_calls) == 1, "повторный fetch НЕ должен вливать куки заново" injected = ctx.add_cookies_calls[0] assert { "name": "qrator_jsid2", "value": "live-token", "domain": ".domclick.ru", "path": "/", } in injected # ── reset_context=True: старый context выбрасывается, следующий — заново ────────── def test_reset_context_discards_old_context_and_creates_new() -> None: browser = _FakeReuseBrowser() server._browsers["domclick"] = browser async def _run() -> None: await server._fetch_once( "domclick", "https://domclick.ru/1", cookies={"a": "1"}, reuse_context=True ) await server._fetch_once( "domclick", "https://domclick.ru/2", cookies={"a": "2"}, reuse_context=True, reset_context=True, ) asyncio.run(_run()) assert len(browser.contexts_created) == 2, "reset_context должен вызвать пересоздание" old_ctx, new_ctx = browser.contexts_created assert old_ctx.closed == 1, "старый context должен быть закрыт" assert new_ctx.add_cookies_calls == [ [{"name": "a", "value": "2", "domain": ".domclick.ru", "path": "/"}] ] assert server._contexts["domclick"] is new_ctx def test_reset_context_without_reuse_is_noop_for_new_page_path() -> None: """reset_context=True без reuse_context (не должно ломать текущий new_page-путь).""" browser = _FakeReuseBrowser() server._browsers["avito"] = browser html = asyncio.run( server._fetch_once("avito", "https://www.avito.ru/1", reset_context=True) ) assert html == "ok" assert browser.new_page_calls == 1 assert browser.contexts_created == [] # ── reuse_context=False (дефолт): golden-parity, поведение не меняется ──────────── def test_reuse_context_false_uses_new_page_every_time() -> None: browser = _FakeReuseBrowser() server._browsers["cian"] = browser async def _run() -> None: await server._fetch_once("cian", "https://www.cian.ru/1") await server._fetch_once("cian", "https://www.cian.ru/2") asyncio.run(_run()) assert browser.new_page_calls == 2 assert browser.contexts_created == [], "reuse_context=False не должен создавать context" assert "cian" not in server._contexts def test_reuse_context_false_still_injects_cookies_into_page_context( monkeypatch: pytest.MonkeyPatch, ) -> None: """Golden-parity: cookies без reuse_context вливаются в page.context, как раньше. Домен с #3261 — регистрируемый (.yandex.ru), а не хост из url (.realty.yandex.ru): оба места инъекции считают его одной функцией, иначе поведение путей reuse_context=true/false разъехалось бы. """ class _FakePageContext: def __init__(self) -> None: self.add_cookies_calls: list[list[dict]] = [] async def add_cookies(self, cookies: list[dict]) -> None: self.add_cookies_calls.append(cookies) class _PageWithContext(_FakePage): def __init__(self) -> None: super().__init__() self.context = _FakePageContext() class _Browser: def __init__(self) -> None: self.page = _PageWithContext() async def new_page(self) -> _PageWithContext: return self.page browser = _Browser() server._browsers["yandex"] = browser asyncio.run( server._fetch_once("yandex", "https://realty.yandex.ru/1", cookies={"sid": "x"}) ) assert browser.page.context.add_cookies_calls == [ [{"name": "sid", "value": "x", "domain": ".yandex.ru", "path": "/"}] ] # ── _close_browser закрывает переиспользуемый context (#3118) ───────────────────── def test_close_browser_closes_and_pops_reusable_context() -> None: ctx = _FakeContext() server._contexts["avito"] = ctx asyncio.run(server._close_browser("avito")) assert ctx.closed == 1 assert "avito" not in server._contexts def test_close_browser_noop_when_no_context() -> None: """Нет сохранённого context — _close_browser не падает (no-op ветка).""" asyncio.run(server._close_browser("generic")) assert "generic" not in server._contexts # ── якорная вкладка: origin открывается ОДИН раз на context ─────────────────── # # До правки origin переоткрывался goto-ом перед КАЖДОЙ карточкой, и полная # навигация по выдаче оплачивалась заново. Замер 29.08.2026 на 131 карточке: # 4-5 с на карточку с живой вкладкой против 17-52 с с переоткрытием. _ORIGIN = "https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka" def _run_fetches(browser, n: int, **kw) -> None: async def _go() -> None: server._browsers["domclick"] = browser for i in range(n): await server._fetch_once( "domclick", f"https://ekaterinburg.domclick.ru/card/{i}", **kw ) asyncio.run(_go()) def test_anchor_page_opened_once_for_many_fetches() -> None: browser = _FakeReuseBrowser() _run_fetches(browser, 3, origin=_ORIGIN, reuse_context=True) ctx = browser.contexts_created[0] anchor_visits = [p for p in ctx.pages if _ORIGIN in p.goto_urls] assert len(anchor_visits) == 1, "origin должен открываться ровно одной вкладкой" assert server._anchor_pages["domclick"] is anchor_visits[0] def test_card_pages_do_not_revisit_origin() -> None: """Целевые вкладки идут СРАЗУ на карточку — в этом вся экономия.""" browser = _FakeReuseBrowser() _run_fetches(browser, 3, origin=_ORIGIN, reuse_context=True) ctx = browser.contexts_created[0] card_pages = [p for p in ctx.pages if any("/card/" in u for u in p.goto_urls)] assert len(card_pages) == 3 for p in card_pages: assert _ORIGIN not in p.goto_urls def test_without_reuse_context_origin_still_revisited() -> None: """avito/cian/yandex/generic не включают reuse_context — им ничего не меняем.""" browser = _FakeReuseBrowser() async def _go() -> None: server._browsers["avito"] = browser for i in range(2): await server._fetch_once( "avito", f"https://www.avito.ru/{i}", origin="https://www.avito.ru/" ) asyncio.run(_go()) assert server._anchor_pages == {} assert browser.new_page_calls == 2 def test_reset_context_drops_anchor() -> None: """Сброс контекста уносит якорь: иначе следующий запрос возьмёт мёртвую вкладку.""" browser = _FakeReuseBrowser() _run_fetches(browser, 1, origin=_ORIGIN, reuse_context=True) assert "domclick" in server._anchor_pages _run_fetches(browser, 1, origin=_ORIGIN, reuse_context=True, reset_context=True) # Контекст пересоздан → якорь тоже новый, а не унаследованный от закрытого. assert len(browser.contexts_created) == 2 assert server._anchor_pages["domclick"] in browser.contexts_created[1].pages def test_anchor_failure_falls_back_to_old_behaviour() -> None: """Якорь — оптимизация. Не поднялся → работаем как раньше, а не падаем.""" browser = _FakeReuseBrowser() async def _go() -> None: server._browsers["domclick"] = browser async def _fail(provider: str, origin: str) -> bool: return False original = server._ensure_anchor_page server._ensure_anchor_page = _fail try: await server._fetch_once( "domclick", "https://ekaterinburg.domclick.ru/card/1", origin=_ORIGIN, reuse_context=True, ) finally: server._ensure_anchor_page = original asyncio.run(_go()) ctx = browser.contexts_created[0] assert any(_ORIGIN in p.goto_urls for p in ctx.pages), "должен быть откат на goto(origin)"