"""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)"