Python-клиент robotex: sync/async, сессии, задачи v2, ошибки
Пакет в client/ (зависимость — httpx), установка через pip из git или wheel (make client-build). Тесты против api и воркера из harness. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
11ba83140d
commit
cb85e5f3ab
16 files changed
+1004
-1
No files matched your search
@@ -0,0 +1,163 @@
|
||||
"""Пакет robotex (client/) против api и воркера из tests/harness.py."""
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import robotex
|
||||
from api import actions
|
||||
from robotex import Action
|
||||
|
||||
from .conftest import FakeSession, fake_execute
|
||||
|
||||
TEXT = [{"type": "inner_text", "ok": True, "result": "Example", "duration_ms": 3}]
|
||||
|
||||
|
||||
class AsyncOverSync(httpx.AsyncBaseTransport):
|
||||
"""Синхронный транспорт TestClient для AsyncRobotex: запрос уходит в поток."""
|
||||
|
||||
def __init__(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
async def handle_async_request(self, request):
|
||||
await request.aread()
|
||||
response = await asyncio.to_thread(self.transport.handle_request, request)
|
||||
return httpx.Response(response.status_code, headers=response.headers, content=response.read())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rx(env):
|
||||
with robotex.Robotex("http://testserver", env.api_key, transport=env.client._transport, poll_interval=0.01) as client:
|
||||
yield client
|
||||
|
||||
|
||||
def test_action_builders():
|
||||
assert Action.fill("#a", "x").to_dict() == {"type": "fill", "selector": "#a", "value": "x"}
|
||||
assert Action.delay(2).to_dict() == {"type": "delay", "value": "2"}
|
||||
assert Action.wait_for("#a", 5).to_dict() == {"type": "wait_for", "selector": "#a", "timeout": 5}
|
||||
assert Action.pass_challenge().to_dict() == {"type": "pass_challenge"}
|
||||
with pytest.raises(ValueError):
|
||||
Action.captcha("#c", "funcaptcha")
|
||||
|
||||
|
||||
def test_settings_from_env(monkeypatch):
|
||||
monkeypatch.delenv("ROBOTEX_URL", raising=False)
|
||||
with pytest.raises(ValueError, match="ROBOTEX_URL"):
|
||||
robotex.Robotex(api_key="k")
|
||||
monkeypatch.setenv("ROBOTEX_URL", "http://h/")
|
||||
monkeypatch.setenv("ROBOTEX_API_KEY", "k")
|
||||
with robotex.Robotex() as client:
|
||||
assert str(client._http.base_url) == "http://h"
|
||||
|
||||
|
||||
def test_health(rx):
|
||||
assert rx.health()["browser_slots"] == 2
|
||||
|
||||
|
||||
def test_auth_error(env):
|
||||
with robotex.Robotex("http://testserver", "rbx_wrong", transport=env.client._transport) as client:
|
||||
with pytest.raises(robotex.AuthError) as e:
|
||||
client.run("https://example.com")
|
||||
assert e.value.status_code == 401
|
||||
|
||||
|
||||
def test_run(rx, monkeypatch):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute(TEXT))
|
||||
result = rx.run("https://example.com", [Action.inner_text("h1")], html=True)
|
||||
assert result.ok and result.results == ["Example"]
|
||||
assert result.html == "<body>page</body>" and result.user_agent == "UA/engine"
|
||||
assert result.cookie("cf_clearance") == "secret-cookie"
|
||||
assert result.session_id
|
||||
|
||||
|
||||
def test_calls_without_session_are_isolated(rx):
|
||||
assert rx.run("https://example.com").session_id != rx.run("https://example.com").session_id
|
||||
|
||||
|
||||
def test_solve(rx, monkeypatch):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute(TEXT))
|
||||
result = rx.solve("https://example.com", [Action.inner_text("h1")], screenshot=True)
|
||||
assert result.ok and result.results == ["Example"]
|
||||
assert result.screenshot_bytes() == b"png"
|
||||
|
||||
|
||||
def test_no_slots(rx, env):
|
||||
env.heartbeat(free_slots=0)
|
||||
with pytest.raises(robotex.NoSlotsError) as e:
|
||||
rx.solve("https://example.com")
|
||||
assert e.value.retry_after
|
||||
|
||||
|
||||
def test_invalid_request(rx):
|
||||
with pytest.raises(robotex.InvalidRequest, match="url is required"):
|
||||
rx.run(actions=[Action.click("#a")])
|
||||
with pytest.raises(robotex.InvalidRequest, match="unknown action type"):
|
||||
rx.run("https://example.com", [{"type": "dance"}])
|
||||
|
||||
|
||||
def test_result_error(rx, monkeypatch):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute([{"type": "click", "ok": False, "result": "no #a", "duration_ms": 1}]))
|
||||
result = rx.run("https://example.com", [Action.click("#a")])
|
||||
assert not result.ok and result.code == "action_failed"
|
||||
with pytest.raises(robotex.ResultError) as e:
|
||||
result.raise_for_status()
|
||||
assert e.value.result is result
|
||||
|
||||
|
||||
def test_request_failed(rx):
|
||||
FakeSession.error = RuntimeError("net::ERR_TIMED_OUT")
|
||||
result = rx.run("https://example.com")
|
||||
assert result.code == "request_failed"
|
||||
|
||||
|
||||
def test_task_cancelled(rx, env):
|
||||
env.auto = False
|
||||
ref = rx.submit("https://example.com")
|
||||
assert env.call(env.store.cancel, ref.task_id)
|
||||
with pytest.raises(robotex.TaskFailed) as e:
|
||||
ref.wait()
|
||||
assert e.value.status == "cancelled"
|
||||
|
||||
|
||||
def test_submit_and_poll(rx, env):
|
||||
env.auto = False
|
||||
ref = rx.submit("https://example.com")
|
||||
assert ref.status().status == "queued"
|
||||
with pytest.raises(robotex.TaskTimeout):
|
||||
ref.wait(timeout=0.05)
|
||||
env.deliver_pending()
|
||||
assert ref.wait().ok
|
||||
with pytest.raises(robotex.NotFound):
|
||||
rx.task(ref.task_id, "00000000-0000-0000-0000-000000000000")
|
||||
|
||||
|
||||
def test_session_holds_window(rx, env):
|
||||
with rx.session(idle_timeout=5) as s:
|
||||
s.run("https://example.com")
|
||||
s.run(actions=[Action.inner_text("h1")]) # без url — в том же окне
|
||||
assert len(FakeSession.instances) == 1
|
||||
assert FakeSession.instances[0].calls == [("fetch", "https://example.com"), ("run", None)]
|
||||
session_id = s.id
|
||||
assert FakeSession.instances[0].closed
|
||||
assert not rx.close_session(session_id)
|
||||
|
||||
# сохранённую сессию можно продолжить: тот же профиль
|
||||
resumed = rx.session(session_id, idle_timeout=0)
|
||||
assert resumed.run("https://example.com").session_id == session_id
|
||||
|
||||
|
||||
def test_async_client(env, monkeypatch):
|
||||
monkeypatch.setattr(actions, "execute", fake_execute(TEXT))
|
||||
|
||||
async def main():
|
||||
transport = AsyncOverSync(env.client._transport)
|
||||
async with robotex.AsyncRobotex("http://testserver", env.api_key, transport=transport, poll_interval=0.01) as rx:
|
||||
assert (await rx.health())["free_slots"] == 2
|
||||
assert (await rx.solve("https://example.com")).ok
|
||||
async with rx.session(idle_timeout=5) as s:
|
||||
await s.run("https://example.com")
|
||||
result = await s.run(actions=[Action.inner_text("h1")])
|
||||
assert result.results == ["Example"] and result.session_id == s.id
|
||||
assert len(FakeSession.instances) == 2 and FakeSession.instances[1].closed
|
||||
|
||||
asyncio.run(main())
|
||||
Reference in new issue
Block a user