gendesign/tradein-mvp/browser/test_server_reuse_context.py
bot-backend 18ae509f98
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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 1m27s
test(tradein/browser): домен инжектируемых кук — регистрируемый, не хост из url
Новый test_server_cookie_domain.py: схлопывание поддомена, хост из двух
лейблов без изменений, оба места инъекции зовут одну функцию.

Три существующих теста закрепляли как раз то поведение, которое оказалось
багом (.ekaterinburg.domclick.ru / .www.avito.ru / .realty.yandex.ru), —
переведены на новый контракт, докстринги объясняют почему.

Прогон сайдкара целиком: 206 passed.
2026-08-30 01:51:02 +03:00

393 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 "<html>ok<script>window.__SSR_STATE__={}</script></html>"
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 == "<html>ok<script>window.__SSR_STATE__={}</script></html>"
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)"