feature: архитектура v2 — api + очередь + worker, удержание окна браузера (#78)
- Сервис api (FastAPI, без Chrome): POST /v2/tasks, GET /v2/tasks/{id} (доступ по cookie
сессии), DELETE /v2/sessions/current; /v1/solve — прежний контракт поверх очереди.
- Сервис worker (dramatiq + RabbitMQ): слоты браузера, окна сессий между задачами
(browser_idle_timeout 0–120 мин, простой — TTL ключа в Redis), heartbeat.
- Маршрутизация задач сессии в персональную очередь воркера с открытым окном.
- Redis: задачи, результаты (RESULT_TTL_HOURS), окна, воркеры. Прокси и ключ 2captcha
в Redis не сохраняются.
- Движок: AsyncStealthySession.run() — действия на текущей странице без навигации.
- Два образа (robotex-api, robotex-worker), compose с rabbitmq и redis,
make up/down/logs/smoke, релиз собирает все образы.
- Тесты: api + worker в одном процессе (fakeredis), настоящий воркер dramatiq на
StubBroker, браузерные тесты на новой схеме, smoke для запущенного стенда.
- Документация: README, API (v2), Deployment, Usage.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fe509c25c3
commit
7b511d5d38
38 files changed
+2142
-545
No files matched your search
Whitespace-only changes.
Whitespace-only changes.
+45
-16
@@ -1,16 +1,19 @@
|
||||
"""Сквозные тесты с настоящим Chrome и сетью. Запускаются в Docker (нужны Chrome и Xvfb):
|
||||
"""Сквозные тесты с настоящим Chrome и сетью: api и worker в одном процессе (tests/harness.py), без
|
||||
RabbitMQ и Redis. Запускаются в Docker (нужны Chrome и Xvfb):
|
||||
|
||||
make test-browser
|
||||
|
||||
Весь стенд с очередью проверяет make smoke.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from api import profile
|
||||
from tests.harness import run_env
|
||||
from worker import windows
|
||||
|
||||
pytestmark = pytest.mark.browser
|
||||
|
||||
@@ -34,9 +37,14 @@ FP_JS = """async () => {
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(main, "USER_DATA_ROOT", tmp_path)
|
||||
return TestClient(main.app)
|
||||
def env(monkeypatch, tmp_path):
|
||||
with run_env(monkeypatch, tmp_path) as harness:
|
||||
yield harness
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(env):
|
||||
return env.client
|
||||
|
||||
|
||||
def solve(client, session_id=None, **body):
|
||||
@@ -50,11 +58,11 @@ def solve(client, session_id=None, **body):
|
||||
|
||||
def test_basic_page(client):
|
||||
data = solve(client, url="https://example.com", screenshot=True, actions=[
|
||||
{"type": "wait_for", "selector": "a", "timeout": 10},
|
||||
{"type": "inner_html", "selector": "a"},
|
||||
{"type": "wait_for", "selector": "p", "timeout": 10},
|
||||
{"type": "inner_html", "selector": "title"},
|
||||
])
|
||||
assert data["status"] == "ok", data
|
||||
assert data["actions"][1]["result"] == "Learn more"
|
||||
assert data["actions"][1]["result"] == "Example Domain"
|
||||
assert len(data["screenshot"]) > 1000
|
||||
|
||||
|
||||
@@ -88,7 +96,7 @@ def test_fingerprint_applied_and_stable(client, monkeypatch):
|
||||
return json.loads(await page.inner_html("#fp"))
|
||||
|
||||
results = {}
|
||||
original = main.make_page_action
|
||||
original = windows.make_page_action
|
||||
|
||||
def capture(payload):
|
||||
action = original(payload)
|
||||
@@ -99,22 +107,22 @@ def test_fingerprint_applied_and_stable(client, monkeypatch):
|
||||
return out
|
||||
return wrapped
|
||||
|
||||
monkeypatch.setattr(main, "make_page_action", capture)
|
||||
monkeypatch.setattr(windows, "make_page_action", capture)
|
||||
|
||||
class Spy(main.AsyncStealthySession):
|
||||
async def fetch(self, url, page_action):
|
||||
out = await super().fetch(url, page_action=page_action)
|
||||
class Spy(windows.AsyncStealthySession):
|
||||
async def fetch(self, url, page_action, **kwargs):
|
||||
out = await super().fetch(url, page_action=page_action, **kwargs)
|
||||
results.setdefault(self._user_data_dir, []).append(out["fp"])
|
||||
return out
|
||||
|
||||
monkeypatch.setattr(main, "AsyncStealthySession", Spy)
|
||||
monkeypatch.setattr(windows, "AsyncStealthySession", Spy)
|
||||
|
||||
sids = [solve(client, url="https://example.com")["session_id"] for _ in range(4)]
|
||||
solve(client, sids[0], url="https://example.com")
|
||||
|
||||
fps = {}
|
||||
for d, measured in results.items():
|
||||
saved = profile.load(main.USER_DATA_ROOT / d.rsplit("/", 1)[-1])
|
||||
saved = profile.load(windows.USER_DATA_ROOT / d.rsplit("/", 1)[-1])
|
||||
fp = saved["fingerprint"]
|
||||
for m in measured:
|
||||
assert m["screen"] == [fp["screen"]["width"], fp["screen"]["height"], fp["device_scale_factor"]]
|
||||
@@ -141,3 +149,24 @@ def test_cloudflare(client):
|
||||
assert data["status"] == "ok", data.get("error")
|
||||
assert "cf_clearance" in {c["name"] for c in data["cookies"]}
|
||||
assert uuid.UUID(data["session_id"])
|
||||
|
||||
|
||||
def test_window_held_between_tasks(env):
|
||||
"""browser_idle_timeout: вторая задача без url выполняется в том же окне, на уже открытой странице."""
|
||||
read = [{"type": "inner_html", "selector": "title"}]
|
||||
client = env.client
|
||||
|
||||
first = client.post("/v2/tasks", json={"url": "https://example.com", "actions": read, "browser_idle_timeout": 1})
|
||||
task = client.get(first.json()["url"]).json()
|
||||
assert task["result"]["status"] == "ok", task
|
||||
assert env.pool.used == 1
|
||||
|
||||
second = client.post("/v2/tasks", json={"actions": read, "browser_idle_timeout": 1}).json()
|
||||
result = client.get(second["url"]).json()["result"]
|
||||
assert result["status"] == "ok", result
|
||||
assert result["actions"][0]["result"] == "Example Domain"
|
||||
# окно не перезапускалось: страница не открывалась заново
|
||||
assert result["duration_ms"] < task["result"]["duration_ms"]
|
||||
|
||||
assert client.delete("/v2/sessions/current").json() == {"closed": True}
|
||||
assert env.pool.used == 0
|
||||
@@ -1,5 +1,10 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
# брокер dramatiq в памяти: tasks.broker создаётся при импорте
|
||||
os.environ.setdefault("RABBITMQ_URL", "stub://")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""api и worker в одном процессе без RabbitMQ и Redis: Redis — fakeredis, отправка в очередь подменена
|
||||
доставкой сообщения прямо в WindowPool воркера (tests/unit, tests/browser)."""
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
|
||||
import fakeredis
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from tasks import store as task_store
|
||||
from tasks.broker import RUN_TASK
|
||||
from worker import windows
|
||||
|
||||
WORKER_ID = "w1"
|
||||
|
||||
|
||||
class Harness:
|
||||
def __init__(self, store, pool, redis_sync):
|
||||
self.store = store
|
||||
self.pool = pool
|
||||
self.redis = redis_sync
|
||||
self.messages = []
|
||||
self.outcomes = []
|
||||
# True — сообщение выполняется сразу при отправке; False — копится в messages до deliver()
|
||||
self.auto = True
|
||||
self.client: TestClient | None = None
|
||||
|
||||
async def dispatch(self, message):
|
||||
self.messages.append(message)
|
||||
if self.auto:
|
||||
await self.deliver(message)
|
||||
|
||||
async def deliver(self, message):
|
||||
if message.actor_name == RUN_TASK:
|
||||
self.outcomes.append(await self.pool.handle(*message.args))
|
||||
else:
|
||||
await self.pool.close(*message.args)
|
||||
await self.store.heartbeat(WORKER_ID, self.pool.info())
|
||||
|
||||
def deliver_pending(self):
|
||||
pending, self.messages = self.messages, []
|
||||
for message in pending:
|
||||
self.client.portal.call(self.deliver, message)
|
||||
|
||||
def heartbeat(self, **info):
|
||||
self.redis.set(f"{task_store.PREFIX}:worker:{WORKER_ID}", json.dumps(self.pool.info() | info))
|
||||
|
||||
def call(self, fn, *args):
|
||||
return self.client.portal.call(fn, *args)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def run_env(monkeypatch, tmp_path, slots=2):
|
||||
server = fakeredis.FakeServer()
|
||||
store = task_store.TaskStore(fakeredis.aioredis.FakeRedis(server=server, decode_responses=True))
|
||||
pool = windows.WindowPool(store, WORKER_ID, slots=slots)
|
||||
harness = Harness(store, pool, fakeredis.FakeRedis(server=server, decode_responses=True))
|
||||
monkeypatch.setattr(windows, "USER_DATA_ROOT", tmp_path)
|
||||
monkeypatch.setattr(main, "store", store)
|
||||
monkeypatch.setattr(main, "dispatch", harness.dispatch)
|
||||
harness.heartbeat()
|
||||
with TestClient(main.app) as client:
|
||||
harness.client = client
|
||||
yield harness
|
||||
client.portal.call(pool.close_all)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Проверка запущенного стенда (make up): api, очередь, воркер с Chrome, удержание окна.
|
||||
|
||||
make smoke # http://localhost:8000
|
||||
make smoke API_URL=https://app.robotex.sokolab.xyz
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
URL = "https://example.com"
|
||||
READ_TITLE = [{"type": "inner_html", "selector": "title"}]
|
||||
|
||||
|
||||
def check(cond: bool, what: str) -> None:
|
||||
print((" ok " if cond else " FAIL ") + what)
|
||||
if not cond:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def wait_task(client: httpx.Client, task_id: str, timeout: float = 180) -> dict:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
task = client.get(f"/v2/tasks/{task_id}").json()
|
||||
if task["status"] not in ("queued", "running"):
|
||||
return task
|
||||
time.sleep(0.5)
|
||||
raise TimeoutError(f"task {task_id} not finished in {timeout}s")
|
||||
|
||||
|
||||
def windows(client: httpx.Client) -> int:
|
||||
return sum(w["windows"] for w in client.get("/health").json()["workers"])
|
||||
|
||||
|
||||
def main(base_url: str) -> None:
|
||||
client = httpx.Client(base_url=base_url, timeout=300)
|
||||
|
||||
print("health")
|
||||
health = client.get("/health").json()
|
||||
check(len(health["workers"]) > 0, f"workers: {[w['worker_id'] for w in health['workers']]}, free slots {health['free_slots']}")
|
||||
|
||||
print("v1 /v1/solve")
|
||||
started = time.monotonic()
|
||||
data = client.post("/v1/solve", json={"url": URL, "actions": READ_TITLE}).json()
|
||||
check(data["status"] == "ok", f"status {data['status']} {data.get('error', '')} ({time.monotonic() - started:.1f}s)")
|
||||
check(data["actions"][0]["result"] == "Example Domain", "title = Example Domain")
|
||||
|
||||
print("v2 window held between tasks")
|
||||
v2 = httpx.Client(base_url=base_url, timeout=60) # своя сессия
|
||||
started = time.monotonic()
|
||||
created = v2.post("/v2/tasks", json={"url": URL, "actions": READ_TITLE, "browser_idle_timeout": 2})
|
||||
check(created.status_code == 202, f"POST /v2/tasks -> {created.status_code}")
|
||||
task = wait_task(v2, created.json()["task_id"])
|
||||
check(task["status"] == "done" and task["result"]["status"] == "ok", f"first task done ({time.monotonic() - started:.1f}s)")
|
||||
check(windows(client) >= 1, "window stays open")
|
||||
|
||||
started = time.monotonic()
|
||||
created = v2.post("/v2/tasks", json={"actions": READ_TITLE, "browser_idle_timeout": 2})
|
||||
task = wait_task(v2, created.json()["task_id"])
|
||||
check(
|
||||
task["status"] == "done" and task["result"]["actions"][0]["result"] == "Example Domain",
|
||||
f"second task without url ran in the open window ({time.monotonic() - started:.1f}s)",
|
||||
)
|
||||
|
||||
check(client.get(f"/v2/tasks/{created.json()['task_id']}").status_code == 404, "task hidden from other sessions")
|
||||
|
||||
check(v2.delete("/v2/sessions/current").json() == {"closed": True}, "DELETE /v2/sessions/current")
|
||||
deadline = time.monotonic() + 15
|
||||
while windows(client) and time.monotonic() < deadline:
|
||||
time.sleep(0.5)
|
||||
check(windows(client) == 0, "window closed, slot released")
|
||||
print("all good")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8000")
|
||||
Whitespace-only changes.
@@ -0,0 +1,80 @@
|
||||
"""Сервисы api и worker без браузера: Chrome — FakeSession (окружение — tests/harness.py)."""
|
||||
import pytest
|
||||
|
||||
from api import actions
|
||||
from tests.harness import WORKER_ID, run_env # noqa: F401
|
||||
from worker import windows
|
||||
|
||||
|
||||
class FakeContext:
|
||||
def __init__(self):
|
||||
self.ops = []
|
||||
|
||||
async def cookies(self):
|
||||
return [{"name": "cf_clearance", "value": "secret-cookie"}]
|
||||
|
||||
async def clear_cookies(self):
|
||||
self.ops.append("clear")
|
||||
|
||||
async def add_cookies(self, cookies):
|
||||
self.ops.append(("add", cookies))
|
||||
|
||||
|
||||
class FakePage:
|
||||
def __init__(self, context):
|
||||
self.context = context
|
||||
|
||||
async def screenshot(self, type):
|
||||
return b"png"
|
||||
|
||||
async def inner_html(self, selector):
|
||||
return "<body>page</body>"
|
||||
|
||||
async def evaluate(self, js):
|
||||
return "UA/engine"
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""Вместо AsyncStealthySession: запоминает kwargs и вызовы, page_action выполняет на FakePage."""
|
||||
instances: list["FakeSession"] = []
|
||||
error: Exception | None = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.context = FakeContext()
|
||||
self.calls = []
|
||||
self.closed = False
|
||||
FakeSession.instances.append(self)
|
||||
|
||||
async def start(self):
|
||||
pass
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
async def fetch(self, url, page_action, **kwargs):
|
||||
self.calls.append(("fetch", url))
|
||||
if FakeSession.error:
|
||||
raise FakeSession.error
|
||||
return await page_action(FakePage(self.context))
|
||||
|
||||
async def run(self, page_action, **kwargs):
|
||||
self.calls.append(("run", None))
|
||||
return await page_action(FakePage(self.context))
|
||||
|
||||
|
||||
def fake_execute(result=None, exc=None):
|
||||
async def execute(*args, **kwargs):
|
||||
if exc:
|
||||
raise exc
|
||||
return result if result is not None else []
|
||||
return execute
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(monkeypatch, tmp_path):
|
||||
FakeSession.instances, FakeSession.error = [], None
|
||||
monkeypatch.setattr(windows, "AsyncStealthySession", FakeSession)
|
||||
monkeypatch.setattr(actions, "execute", fake_execute())
|
||||
with run_env(monkeypatch, tmp_path) as harness:
|
||||
yield harness
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Настоящий воркер dramatiq на брокере в памяти: actor'ы, event loop AsyncIO, пул окон и heartbeat."""
|
||||
import asyncio
|
||||
|
||||
import dramatiq
|
||||
import fakeredis
|
||||
import pytest
|
||||
|
||||
from api import actions
|
||||
from tasks import store as task_store
|
||||
from tasks.broker import COMMON_QUEUE, personal_queue, task_message
|
||||
from worker import windows
|
||||
|
||||
from .conftest import FakeSession, fake_execute
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker(monkeypatch, tmp_path):
|
||||
server = fakeredis.FakeServer()
|
||||
monkeypatch.setattr(task_store, "connect", lambda url=None: fakeredis.aioredis.FakeRedis(server=server, decode_responses=True))
|
||||
monkeypatch.setattr(windows, "AsyncStealthySession", FakeSession)
|
||||
monkeypatch.setattr(windows, "USER_DATA_ROOT", tmp_path)
|
||||
monkeypatch.setattr(actions, "execute", fake_execute())
|
||||
FakeSession.instances, FakeSession.error = [], None
|
||||
|
||||
from worker import actors
|
||||
w = dramatiq.Worker(actors.broker, worker_timeout=100, worker_threads=2)
|
||||
w.start()
|
||||
try:
|
||||
yield actors, server
|
||||
finally:
|
||||
w.stop()
|
||||
# при остановке воркер закрывает окна и снимает heartbeat
|
||||
assert all(s.closed for s in FakeSession.instances)
|
||||
assert not fakeredis.FakeRedis(server=server).keys("robotex:worker:*")
|
||||
|
||||
|
||||
SID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
|
||||
def submit(actors, server, queue, idle=0):
|
||||
"""То же, что делает api: задача в Redis, сообщение в очередь; ждём, пока воркер её выполнит."""
|
||||
store = task_store.TaskStore(fakeredis.aioredis.FakeRedis(server=server, decode_responses=True))
|
||||
asyncio.run(store.create("t1", SID, "https://example.com"))
|
||||
actors.broker.enqueue(task_message("t1", SID, {"url": "https://example.com", "actions": []}, idle, queue))
|
||||
actors.broker.join(queue)
|
||||
return fakeredis.FakeRedis(server=server, decode_responses=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("queue", ["common", "personal"])
|
||||
def test_task_executed_from_queue(worker, queue):
|
||||
actors, server = worker
|
||||
redis = submit(actors, server, COMMON_QUEUE if queue == "common" else personal_queue(actors.WORKER_ID))
|
||||
task = redis.hgetall("robotex:task:t1")
|
||||
assert task["status"] == "done", task
|
||||
assert '"status": "ok"' in task["result"]
|
||||
assert redis.exists(f"robotex:worker:{actors.WORKER_ID}")
|
||||
|
||||
|
||||
def test_window_held_until_shutdown(worker):
|
||||
actors, server = worker
|
||||
redis = submit(actors, server, COMMON_QUEUE, idle=5)
|
||||
assert redis.hgetall(f"robotex:session:{SID}")["worker_id"] == actors.WORKER_ID
|
||||
assert not FakeSession.instances[0].closed
|
||||
@@ -1,240 +0,0 @@
|
||||
"""/v1/solve без браузера: движок подменён, проверяются сборка ответа, коды ошибок, слоты, сессии и логи."""
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from api import actions, profile
|
||||
|
||||
|
||||
class FakeContext:
|
||||
async def cookies(self):
|
||||
return [{"name": "cf_clearance", "value": "secret-cookie"}]
|
||||
|
||||
|
||||
class FakePage:
|
||||
context = FakeContext()
|
||||
|
||||
async def screenshot(self, type):
|
||||
return b"png"
|
||||
|
||||
async def inner_html(self, selector):
|
||||
return "<body>page</body>"
|
||||
|
||||
async def evaluate(self, js):
|
||||
return "UA/engine"
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""Вместо AsyncStealthySession: запоминает kwargs и вызывает page_action на FakePage."""
|
||||
calls: list[dict] = []
|
||||
error: Exception | None = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
FakeSession.calls.append(kwargs)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
async def fetch(self, url, page_action):
|
||||
if FakeSession.error:
|
||||
raise FakeSession.error
|
||||
return await page_action(FakePage())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, tmp_path):
|
||||
FakeSession.calls, FakeSession.error = [], None
|
||||
monkeypatch.setattr(main, "AsyncStealthySession", FakeSession)
|
||||
monkeypatch.setattr(main, "USER_DATA_ROOT", tmp_path)
|
||||
monkeypatch.setattr(main, "_slots", asyncio.Semaphore(2))
|
||||
return TestClient(main.app)
|
||||
|
||||
|
||||
def fake_execute(result=None, exc=None):
|
||||
async def execute(*args, **kwargs):
|
||||
if exc:
|
||||
raise exc
|
||||
return result
|
||||
return execute
|
||||
|
||||
|
||||
def post(client, **body):
|
||||
return client.post("/v1/solve", json={"url": "https://example.com", "actions": [], **body})
|
||||
|
||||
|
||||
def test_ok(client, monkeypatch):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute([{"type": "wait_for", "ok": True, "duration_ms": 5}]))
|
||||
resp = post(client, html=True, screenshot=True)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert "code" not in data and "error" not in data
|
||||
assert data["user_agent"] == "UA/engine"
|
||||
assert data["actions"] == [{"type": "wait_for", "ok": True, "duration_ms": 5}]
|
||||
assert data["html"] == "<body>page</body>"
|
||||
assert data["screenshot"] == "cG5n" # base64(b"png")
|
||||
assert data["duration_ms"] >= 0
|
||||
assert uuid.UUID(resp.cookies["session_id"])
|
||||
|
||||
|
||||
def test_html_and_screenshot_only_on_request(client, monkeypatch):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute([]))
|
||||
data = post(client).json()
|
||||
assert "html" not in data and "screenshot" not in data
|
||||
assert data["actions"] == []
|
||||
|
||||
|
||||
def test_action_failed(client, monkeypatch):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute([
|
||||
{"type": "click", "ok": True, "duration_ms": 1},
|
||||
{"type": "inner_html", "ok": False, "result": "selector not found: #x", "duration_ms": 1},
|
||||
]))
|
||||
data = post(client, screenshot=True).json()
|
||||
assert data["status"] == "err"
|
||||
assert data["code"] == "action_failed"
|
||||
assert data["error"] == "inner_html: selector not found: #x"
|
||||
assert data["screenshot"]
|
||||
|
||||
|
||||
def test_challenge_failed(client, monkeypatch):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute(exc=actions.ChallengeFailed("cloudflare not passed: timeout")))
|
||||
data = post(client, screenshot=True).json()
|
||||
assert data["status"] == "err"
|
||||
assert data["code"] == "challenge_failed"
|
||||
assert data["error"] == "cloudflare not passed: timeout"
|
||||
assert data["actions"] == []
|
||||
assert data["cookies"] and data["screenshot"]
|
||||
|
||||
|
||||
def test_request_failed(client):
|
||||
FakeSession.error = RuntimeError("net::ERR_PROXY_CONNECTION_FAILED")
|
||||
data = post(client).json()
|
||||
assert data == {
|
||||
"status": "err", "code": "request_failed", "error": "net::ERR_PROXY_CONNECTION_FAILED",
|
||||
"actions": [], "duration_ms": data["duration_ms"],
|
||||
}
|
||||
|
||||
|
||||
def test_no_slots(client, monkeypatch):
|
||||
monkeypatch.setattr(main, "_slots", asyncio.Semaphore(0))
|
||||
resp = post(client)
|
||||
assert resp.status_code == 503
|
||||
assert resp.headers["retry-after"] == main.RETRY_AFTER
|
||||
assert resp.json() == {"status": "err", "code": "no_slots", "error": "No slots available"}
|
||||
assert FakeSession.calls == []
|
||||
|
||||
|
||||
def test_slot_released_after_request(client, monkeypatch):
|
||||
sem = asyncio.Semaphore(1)
|
||||
monkeypatch.setattr(main, "_slots", sem)
|
||||
monkeypatch.setattr(actions, "execute", fake_execute([]))
|
||||
FakeSession.error = RuntimeError("boom")
|
||||
for _ in range(3):
|
||||
assert post(client).status_code == 200
|
||||
assert not sem.locked()
|
||||
|
||||
|
||||
def test_invalid_request_is_422_without_browser(client):
|
||||
resp = post(client, actions=[{"type": "nope"}])
|
||||
assert resp.status_code == 422
|
||||
assert FakeSession.calls == []
|
||||
|
||||
|
||||
def test_session_cookie_reused(client, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute([]))
|
||||
sid = str(uuid.uuid4())
|
||||
client.cookies.set("session_id", sid)
|
||||
resp = post(client)
|
||||
assert resp.cookies["session_id"] == sid
|
||||
assert FakeSession.calls[0]["user_data_dir"] == str(tmp_path / sid)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["../../etc", "not-a-uuid", "/tmp/x"])
|
||||
def test_bad_session_cookie_gets_new_session(client, monkeypatch, tmp_path, bad):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute([]))
|
||||
client.cookies.set("session_id", bad)
|
||||
resp = post(client)
|
||||
sid = resp.cookies["session_id"]
|
||||
assert sid != bad and uuid.UUID(sid)
|
||||
assert [p.name for p in tmp_path.iterdir()] == [sid]
|
||||
|
||||
|
||||
def test_user_agent_param_rejected(client):
|
||||
resp = post(client, user_agent="First/1")
|
||||
assert resp.status_code == 422
|
||||
assert "take user_agent from the response" in resp.text
|
||||
assert FakeSession.calls == []
|
||||
|
||||
|
||||
def test_fingerprint_passed_to_browser_and_ua_untouched(client, monkeypatch):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute([]))
|
||||
post(client)
|
||||
kwargs = FakeSession.calls[0]
|
||||
assert "useragent" not in kwargs
|
||||
assert set(kwargs["additional_args"]) == {"screen", "device_scale_factor"}
|
||||
|
||||
|
||||
def test_cookies_replace_profile_cookies_only_when_passed(client, monkeypatch):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute([]))
|
||||
post(client)
|
||||
post(client, cookies=[{"name": "a", "value": "1", "url": "https://example.com"}])
|
||||
post(client, cookies=[])
|
||||
assert [c["clear_cookies"] for c in FakeSession.calls] == [False, True, True]
|
||||
|
||||
|
||||
def test_proxy_passed_and_geo_resolved(client, monkeypatch):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute([]))
|
||||
seen = {}
|
||||
|
||||
async def fake_geo(proxy):
|
||||
seen["proxy"] = proxy
|
||||
return {"locale": "de-DE", "timezone_id": "Europe/Berlin"}
|
||||
|
||||
monkeypatch.setattr(main, "resolve_geo", fake_geo)
|
||||
post(client, proxy="http://u:p@host:3128")
|
||||
kwargs = FakeSession.calls[0]
|
||||
assert kwargs["proxy"] == {"server": "http://host:3128", "username": "u", "password": "p"}
|
||||
assert seen["proxy"] == kwargs["proxy"]
|
||||
assert (kwargs["locale"], kwargs["timezone_id"]) == ("de-DE", "Europe/Berlin")
|
||||
|
||||
|
||||
def test_without_proxy_no_geo(client, monkeypatch):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute([]))
|
||||
|
||||
async def fail_geo(proxy):
|
||||
raise AssertionError("geo must not be resolved without proxy")
|
||||
|
||||
monkeypatch.setattr(main, "resolve_geo", fail_geo)
|
||||
post(client)
|
||||
assert FakeSession.calls[0]["proxy"] is None
|
||||
|
||||
|
||||
def test_secrets_not_logged(client, monkeypatch, caplog):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute([]))
|
||||
|
||||
async def no_geo(proxy):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(main, "resolve_geo", no_geo)
|
||||
caplog.set_level(logging.DEBUG, logger="main")
|
||||
post(
|
||||
client, proxy="http://someuser:TopSecret1@host:3128", captcha="KEYSECRET2", html=True,
|
||||
cookies=[{"name": "sid", "value": "CookieSecret3", "url": "https://example.com"}],
|
||||
actions=[{"type": "fill", "selector": "#p", "value": "PassSecret4"}],
|
||||
)
|
||||
log = caplog.text
|
||||
assert "Incoming payload" in log and "Outgoing payload" in log
|
||||
for secret in ("someuser", "TopSecret1", "KEYSECRET2", "CookieSecret3", "PassSecret4", "secret-cookie", "<body>page"):
|
||||
assert secret not in log
|
||||
|
||||
|
||||
def test_health(client):
|
||||
assert client.get("/health").json() == {"status": "ok", "browser_slots": main.BROWSER_SLOTS, "free_slots": 2}
|
||||
@@ -0,0 +1,74 @@
|
||||
import fakeredis
|
||||
import pytest
|
||||
|
||||
from tasks import store as task_store
|
||||
from tasks.store import Status, TaskStore
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store():
|
||||
return TaskStore(fakeredis.aioredis.FakeRedis(decode_responses=True))
|
||||
|
||||
|
||||
async def test_claim_once(store):
|
||||
await store.create("t", "s", "https://example.com")
|
||||
await store.heartbeat("w1", {})
|
||||
assert (await store.get("t"))["status"] == Status.QUEUED
|
||||
assert await store.claim("t", "w1")
|
||||
assert not await store.claim("t", "w2")
|
||||
assert not await store.cancel("t")
|
||||
|
||||
|
||||
async def test_cancelled_task_is_not_claimed(store):
|
||||
await store.create("t", "s", None)
|
||||
assert await store.cancel("t")
|
||||
assert not await store.claim("t", "w1")
|
||||
assert (await store.get("t"))["status"] == Status.CANCELLED
|
||||
|
||||
|
||||
async def test_missing_task_is_not_claimed(store):
|
||||
assert not await store.claim("nope", "w1")
|
||||
|
||||
|
||||
async def test_redelivery_while_running_here_is_skipped(store):
|
||||
await store.create("t", "s", None)
|
||||
await store.heartbeat("w1", {})
|
||||
assert await store.claim("t", "w1")
|
||||
assert not await store.claim("t", "w1", running_here=True)
|
||||
|
||||
|
||||
async def test_task_of_dead_worker_is_reclaimed_limited_times(store):
|
||||
await store.create("t", "s", None)
|
||||
await store.heartbeat("w2", {})
|
||||
assert await store.claim("t", "w1") # w1 умер — heartbeat нет
|
||||
assert await store.claim("t", "w2") # attempts = 2
|
||||
await store.remove_worker("w2")
|
||||
assert not await store.claim("t", "w3")
|
||||
task = await store.get("t")
|
||||
assert task["status"] == Status.FAILED and "worker died" in task["error"]
|
||||
|
||||
|
||||
async def test_task_of_live_worker_is_not_reclaimed(store):
|
||||
await store.create("t", "s", None)
|
||||
await store.heartbeat("w1", {})
|
||||
assert await store.claim("t", "w1")
|
||||
assert not await store.claim("t", "w2")
|
||||
|
||||
|
||||
async def test_finish_sets_result_and_ttl(store):
|
||||
await store.create("t", "s", None)
|
||||
assert await store.redis.ttl("robotex:task:t") == task_store.PENDING_TTL
|
||||
await store.finish("t", {"status": "ok", "actions": []})
|
||||
task = await store.get("t")
|
||||
assert task["status"] == Status.DONE and task["result"] == {"status": "ok", "actions": []}
|
||||
assert await store.redis.ttl("robotex:task:t") == task_store.RESULT_TTL
|
||||
|
||||
|
||||
async def test_live_window_requires_live_worker(store):
|
||||
await store.set_session("s", "w1", 5, 300)
|
||||
assert await store.live_window("s") is None
|
||||
await store.heartbeat("w1", {"browser_slots": 1, "free_slots": 0})
|
||||
assert await store.live_window("s") == {"worker_id": "w1", "idle_timeout": "5"}
|
||||
assert await store.workers() == [{"worker_id": "w1", "browser_slots": 1, "free_slots": 0}]
|
||||
@@ -0,0 +1,200 @@
|
||||
"""/v1/solve поверх очереди: контракт v1 не изменился (#84)."""
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
import main
|
||||
from api import actions
|
||||
from worker import windows
|
||||
from worker.windows import Outcome
|
||||
|
||||
from .conftest import FakeSession, fake_execute
|
||||
|
||||
|
||||
def post(env, **body):
|
||||
return env.client.post("/v1/solve", json={"url": "https://example.com", "actions": [], **body})
|
||||
|
||||
|
||||
def test_ok(env, monkeypatch):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute([{"type": "wait_for", "ok": True, "duration_ms": 5}]))
|
||||
resp = post(env, html=True, screenshot=True)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert "code" not in data and "error" not in data
|
||||
assert data["user_agent"] == "UA/engine"
|
||||
assert data["actions"] == [{"type": "wait_for", "ok": True, "duration_ms": 5}]
|
||||
assert data["html"] == "<body>page</body>"
|
||||
assert data["screenshot"] == "cG5n" # base64(b"png")
|
||||
assert data["duration_ms"] >= 0
|
||||
assert uuid.UUID(resp.cookies["session_id"])
|
||||
|
||||
|
||||
def test_html_and_screenshot_only_on_request(env):
|
||||
data = post(env).json()
|
||||
assert "html" not in data and "screenshot" not in data
|
||||
assert data["actions"] == []
|
||||
|
||||
|
||||
def test_action_failed(env, monkeypatch):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute([
|
||||
{"type": "click", "ok": True, "duration_ms": 1},
|
||||
{"type": "inner_html", "ok": False, "result": "selector not found: #x", "duration_ms": 1},
|
||||
]))
|
||||
data = post(env, screenshot=True).json()
|
||||
assert data["status"] == "err"
|
||||
assert data["code"] == "action_failed"
|
||||
assert data["error"] == "inner_html: selector not found: #x"
|
||||
assert data["screenshot"]
|
||||
|
||||
|
||||
def test_challenge_failed(env, monkeypatch):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute(exc=actions.ChallengeFailed("cloudflare not passed: timeout")))
|
||||
data = post(env, screenshot=True).json()
|
||||
assert data["status"] == "err"
|
||||
assert data["code"] == "challenge_failed"
|
||||
assert data["error"] == "cloudflare not passed: timeout"
|
||||
assert data["actions"] == []
|
||||
assert data["cookies"] and data["screenshot"]
|
||||
|
||||
|
||||
def test_request_failed(env):
|
||||
FakeSession.error = RuntimeError("net::ERR_PROXY_CONNECTION_FAILED")
|
||||
data = post(env).json()
|
||||
assert data == {
|
||||
"status": "err", "code": "request_failed", "error": "net::ERR_PROXY_CONNECTION_FAILED",
|
||||
"actions": [], "duration_ms": data["duration_ms"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("workers", ["busy", "none"])
|
||||
def test_no_slots(env, workers):
|
||||
if workers == "busy":
|
||||
env.heartbeat(free_slots=0)
|
||||
else:
|
||||
env.redis.flushall()
|
||||
resp = post(env)
|
||||
assert resp.status_code == 503
|
||||
assert resp.headers["retry-after"] == main.RETRY_AFTER
|
||||
assert resp.json() == {"status": "err", "code": "no_slots", "error": "No slots available"}
|
||||
assert env.messages == []
|
||||
|
||||
|
||||
def test_not_picked_up_in_time_is_503_and_cancelled(env, monkeypatch):
|
||||
env.auto = False
|
||||
monkeypatch.setattr(main, "V1_QUEUE_WAIT", 0)
|
||||
resp = post(env)
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["code"] == "no_slots"
|
||||
|
||||
# воркер позже достаёт сообщение — отменённую задачу не выполняет
|
||||
env.deliver_pending()
|
||||
assert env.outcomes == [Outcome.SKIPPED]
|
||||
assert FakeSession.instances == []
|
||||
|
||||
|
||||
def test_slot_released_after_request(env):
|
||||
FakeSession.error = RuntimeError("boom")
|
||||
for _ in range(3):
|
||||
assert post(env).status_code == 200
|
||||
assert env.pool.used == 0 and env.pool.windows == {}
|
||||
assert all(s.closed for s in FakeSession.instances)
|
||||
|
||||
|
||||
def test_invalid_request_is_422_without_task(env):
|
||||
resp = post(env, actions=[{"type": "nope"}])
|
||||
assert resp.status_code == 422
|
||||
assert env.messages == []
|
||||
|
||||
|
||||
def test_session_cookie_reused(env, tmp_path):
|
||||
sid = str(uuid.uuid4())
|
||||
env.client.cookies.set("session_id", sid)
|
||||
resp = post(env)
|
||||
assert resp.cookies["session_id"] == sid
|
||||
assert FakeSession.instances[0].kwargs["user_data_dir"] == str(tmp_path / sid)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["../../etc", "not-a-uuid", "/tmp/x"])
|
||||
def test_bad_session_cookie_gets_new_session(env, tmp_path, bad):
|
||||
env.client.cookies.set("session_id", bad)
|
||||
resp = post(env)
|
||||
sid = resp.cookies["session_id"]
|
||||
assert sid != bad and uuid.UUID(sid)
|
||||
assert [p.name for p in tmp_path.iterdir()] == [sid]
|
||||
|
||||
|
||||
def test_user_agent_param_rejected(env):
|
||||
resp = post(env, user_agent="First/1")
|
||||
assert resp.status_code == 422
|
||||
assert "take user_agent from the response" in resp.text
|
||||
assert env.messages == []
|
||||
|
||||
|
||||
def test_fingerprint_passed_to_browser_and_ua_untouched(env):
|
||||
post(env)
|
||||
kwargs = FakeSession.instances[0].kwargs
|
||||
assert "useragent" not in kwargs
|
||||
assert set(kwargs["additional_args"]) == {"screen", "device_scale_factor"}
|
||||
|
||||
|
||||
def test_cookies_replace_profile_cookies_only_when_passed(env):
|
||||
post(env)
|
||||
post(env, cookies=[{"name": "a", "value": "1", "url": "https://example.com"}])
|
||||
post(env, cookies=[])
|
||||
assert [s.kwargs["clear_cookies"] for s in FakeSession.instances] == [False, True, True]
|
||||
|
||||
|
||||
def test_proxy_passed_and_geo_resolved(env, monkeypatch):
|
||||
seen = {}
|
||||
|
||||
async def fake_geo(proxy):
|
||||
seen["proxy"] = proxy
|
||||
return {"locale": "de-DE", "timezone_id": "Europe/Berlin"}
|
||||
|
||||
monkeypatch.setattr(windows, "resolve_geo", fake_geo)
|
||||
post(env, proxy="http://u:p@host:3128")
|
||||
kwargs = FakeSession.instances[0].kwargs
|
||||
assert kwargs["proxy"] == {"server": "http://host:3128", "username": "u", "password": "p"}
|
||||
assert seen["proxy"] == kwargs["proxy"]
|
||||
assert (kwargs["locale"], kwargs["timezone_id"]) == ("de-DE", "Europe/Berlin")
|
||||
|
||||
|
||||
def test_without_proxy_no_geo(env, monkeypatch):
|
||||
async def fail_geo(proxy):
|
||||
raise AssertionError("geo must not be resolved without proxy")
|
||||
|
||||
monkeypatch.setattr(windows, "resolve_geo", fail_geo)
|
||||
post(env)
|
||||
assert FakeSession.instances[0].kwargs["proxy"] is None
|
||||
|
||||
|
||||
def test_secrets_not_logged_nor_stored(env, monkeypatch, caplog):
|
||||
async def no_geo(proxy):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(windows, "resolve_geo", no_geo)
|
||||
caplog.set_level(logging.DEBUG)
|
||||
post(
|
||||
env, proxy="http://someuser:TopSecret1@host:3128", captcha="KEYSECRET2", html=True,
|
||||
cookies=[{"name": "sid", "value": "CookieSecret3", "url": "https://example.com"}],
|
||||
actions=[{"type": "fill", "selector": "#p", "value": "PassSecret4"}],
|
||||
)
|
||||
log = caplog.text
|
||||
assert "Incoming payload" in log and "Outgoing payload" in log
|
||||
for secret in ("someuser", "TopSecret1", "KEYSECRET2", "CookieSecret3", "PassSecret4", "secret-cookie", "<body>page"):
|
||||
assert secret not in log
|
||||
|
||||
# в Redis — только результат (там cookies и html страницы), но не секреты запроса
|
||||
stored = " ".join(str(env.redis.hgetall(k)) for k in env.redis.keys("*task*"))
|
||||
for secret in ("TopSecret1", "KEYSECRET2", "PassSecret4"):
|
||||
assert secret not in stored
|
||||
|
||||
|
||||
def test_health(env):
|
||||
data = env.client.get("/health").json()
|
||||
assert data["status"] == "ok"
|
||||
assert (data["browser_slots"], data["free_slots"]) == (2, 2)
|
||||
assert [w["worker_id"] for w in data["workers"]] == ["w1"]
|
||||
@@ -0,0 +1,180 @@
|
||||
"""/v2: задачи через очередь, доступ по cookie сессии, удержание окна и маршрутизация в него (#80–#83)."""
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from tasks import store as task_store
|
||||
from tasks.broker import COMMON_QUEUE, personal_queue
|
||||
from worker.windows import Outcome
|
||||
|
||||
from .conftest import FakeSession, WORKER_ID
|
||||
|
||||
|
||||
def create(env, **body):
|
||||
return env.client.post("/v2/tasks", json={"actions": [], **body})
|
||||
|
||||
|
||||
def get(env, task_id):
|
||||
return env.client.get(f"/v2/tasks/{task_id}")
|
||||
|
||||
|
||||
def session_key(sid):
|
||||
return f"{task_store.PREFIX}:session:{sid}"
|
||||
|
||||
|
||||
def test_create_and_get_result(env):
|
||||
resp = create(env, url="https://example.com", html=True)
|
||||
assert resp.status_code == 202
|
||||
body = resp.json()
|
||||
assert body["status"] == "queued" and body["url"] == f"/v2/tasks/{body['task_id']}"
|
||||
assert env.messages[0].queue_name == COMMON_QUEUE
|
||||
|
||||
task = get(env, body["task_id"]).json()
|
||||
assert task["status"] == "done"
|
||||
assert task["result"]["status"] == "ok"
|
||||
assert task["result"]["html"] == "<body>page</body>"
|
||||
assert task["result"]["user_agent"] == "UA/engine"
|
||||
|
||||
|
||||
def test_queued_until_worker_takes_it(env):
|
||||
env.auto = False
|
||||
task_id = create(env, url="https://example.com").json()["task_id"]
|
||||
assert get(env, task_id).json() == {"task_id": task_id, "status": "queued"}
|
||||
env.deliver_pending()
|
||||
assert get(env, task_id).json()["status"] == "done"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cookie", [None, "other", "garbage"])
|
||||
def test_task_visible_only_to_its_session(env, cookie):
|
||||
task_id = create(env, url="https://example.com").json()["task_id"]
|
||||
env.client.cookies.clear()
|
||||
if cookie:
|
||||
env.client.cookies.set("session_id", str(uuid.uuid4()) if cookie == "other" else cookie)
|
||||
assert get(env, task_id).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("task_id", [str(uuid.uuid4()), "../etc", "x"])
|
||||
def test_unknown_task_404(env, task_id):
|
||||
create(env, url="https://example.com")
|
||||
assert get(env, task_id).status_code == 404
|
||||
|
||||
|
||||
def test_result_ttl(env):
|
||||
task_id = create(env, url="https://example.com").json()["task_id"]
|
||||
ttl = env.redis.ttl(f"{task_store.PREFIX}:task:{task_id}")
|
||||
assert task_store.RESULT_TTL - 5 < ttl <= task_store.RESULT_TTL
|
||||
|
||||
|
||||
def test_url_required_without_window(env):
|
||||
resp = create(env)
|
||||
assert resp.status_code == 422
|
||||
assert "no open browser window" in resp.text
|
||||
assert env.messages == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [-1, 121])
|
||||
def test_idle_timeout_range(env, value):
|
||||
assert create(env, url="https://example.com", browser_idle_timeout=value).status_code == 422
|
||||
|
||||
|
||||
def test_window_closed_by_default(env):
|
||||
create(env, url="https://example.com")
|
||||
sid = env.client.cookies["session_id"]
|
||||
assert env.pool.windows == {} and env.pool.used == 0
|
||||
assert FakeSession.instances[0].closed
|
||||
assert not env.redis.exists(session_key(sid))
|
||||
|
||||
|
||||
def test_window_held_and_reused(env):
|
||||
create(env, url="https://example.com", browser_idle_timeout=5)
|
||||
sid = env.client.cookies["session_id"]
|
||||
engine = FakeSession.instances[0]
|
||||
assert not engine.closed and env.pool.used == 1
|
||||
assert env.redis.hgetall(session_key(sid)) == {"worker_id": WORKER_ID, "idle_timeout": "5"}
|
||||
assert 290 < env.redis.ttl(session_key(sid)) <= 300
|
||||
|
||||
# следующая задача сессии — в персональную очередь воркера с окном, без url — на текущей странице
|
||||
task_id = create(env, browser_idle_timeout=2).json()["task_id"]
|
||||
assert env.messages[-1].queue_name == personal_queue(WORKER_ID)
|
||||
assert get(env, task_id).json()["result"]["status"] == "ok"
|
||||
assert FakeSession.instances == [engine]
|
||||
assert engine.calls == [("fetch", "https://example.com"), ("run", None)]
|
||||
# простой продлён значением из последней задачи
|
||||
assert 110 < env.redis.ttl(session_key(sid)) <= 120
|
||||
|
||||
# задача с browser_idle_timeout=0 закрывает окно после себя
|
||||
create(env, url="https://example.com/next")
|
||||
assert engine.closed and env.pool.used == 0
|
||||
assert not env.redis.exists(session_key(sid))
|
||||
|
||||
|
||||
def test_cookies_replace_in_open_window(env):
|
||||
create(env, url="https://example.com", browser_idle_timeout=5)
|
||||
create(env, browser_idle_timeout=5, cookies=[{"name": "a", "value": "1", "url": "https://example.com"}])
|
||||
create(env, browser_idle_timeout=5)
|
||||
assert FakeSession.instances[0].context.ops == [
|
||||
"clear", ("add", [{"name": "a", "value": "1", "url": "https://example.com"}]),
|
||||
]
|
||||
|
||||
|
||||
def test_proxy_change_restarts_window(env):
|
||||
create(env, url="https://example.com", browser_idle_timeout=5)
|
||||
create(env, url="https://example.com", browser_idle_timeout=5, proxy="http://host:3128")
|
||||
first, second = FakeSession.instances
|
||||
assert first.closed and not second.closed
|
||||
assert second.kwargs["proxy"] == {"server": "http://host:3128"}
|
||||
assert env.pool.used == 1
|
||||
|
||||
|
||||
def test_failure_in_window_closes_it(env):
|
||||
create(env, url="https://example.com", browser_idle_timeout=5)
|
||||
sid = env.client.cookies["session_id"]
|
||||
FakeSession.error = RuntimeError("net::ERR_TIMED_OUT")
|
||||
task_id = create(env, url="https://example.com", browser_idle_timeout=5).json()["task_id"]
|
||||
assert get(env, task_id).json()["result"]["code"] == "request_failed"
|
||||
assert FakeSession.instances[0].closed and env.pool.used == 0
|
||||
assert not env.redis.exists(session_key(sid))
|
||||
|
||||
|
||||
def test_idle_window_expires(env):
|
||||
create(env, url="https://example.com", browser_idle_timeout=5)
|
||||
sid = env.client.cookies["session_id"]
|
||||
env.call(env.pool.expire_idle)
|
||||
assert env.pool.used == 1
|
||||
|
||||
env.redis.delete(session_key(sid)) # TTL истёк
|
||||
env.call(env.pool.expire_idle)
|
||||
assert env.pool.used == 0 and FakeSession.instances[0].closed
|
||||
|
||||
|
||||
def test_delete_session_closes_window(env):
|
||||
create(env, url="https://example.com", browser_idle_timeout=5)
|
||||
sid = env.client.cookies["session_id"]
|
||||
assert env.client.delete("/v2/sessions/current").json() == {"closed": True}
|
||||
assert env.messages[-1].actor_name == "close_window"
|
||||
assert env.messages[-1].queue_name == personal_queue(WORKER_ID)
|
||||
assert FakeSession.instances[0].closed and env.pool.used == 0
|
||||
assert not env.redis.exists(session_key(sid))
|
||||
assert env.client.delete("/v2/sessions/current").json() == {"closed": False}
|
||||
|
||||
|
||||
def test_open_windows_take_slots(env):
|
||||
for _ in range(2):
|
||||
env.client.cookies.clear()
|
||||
create(env, url="https://example.com", browser_idle_timeout=5)
|
||||
assert env.pool.used == 2
|
||||
|
||||
env.client.cookies.clear()
|
||||
create(env, url="https://example.com")
|
||||
assert env.outcomes[-1] == Outcome.NO_SLOT
|
||||
# /v1/solve без окна при занятых слотах — сразу 503, как раньше
|
||||
env.client.cookies.clear()
|
||||
assert env.client.post("/v1/solve", json={"url": "https://example.com", "actions": []}).status_code == 503
|
||||
|
||||
|
||||
def test_dead_worker_window_goes_to_common_queue(env):
|
||||
create(env, url="https://example.com", browser_idle_timeout=5)
|
||||
env.redis.delete(f"{task_store.PREFIX}:worker:{WORKER_ID}")
|
||||
env.auto = False
|
||||
create(env, url="https://example.com")
|
||||
assert env.messages[-1].queue_name == COMMON_QUEUE
|
||||
Reference in new issue
Block a user