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:
protokeyandClaude Opus 5.5 committed 2026-10-13 19:13:52 +04:00
1 parent fe509c25c3
commit 7b511d5d38
38 files changed
+2142 -545

No files matched your search

View File
Whitespace-only changes.
View File
Whitespace-only changes.
+45 -16
View File
@@ -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
+5
View File
@@ -1,5 +1,10 @@
import os
import pytest
# брокер dramatiq в памяти: tasks.broker создаётся при импорте
os.environ.setdefault("RABBITMQ_URL", "stub://")
@pytest.fixture
def anyio_backend():
+65
View File
@@ -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)
+76
View File
@@ -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")
View File
Whitespace-only changes.
+80
View File
@@ -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
+63
View File
@@ -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
-240
View File
@@ -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}
+74
View File
@@ -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}]
+200
View File
@@ -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"]
+180
View File
@@ -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