Files
robotex/tests/unit/test_client.py
T
protokeyandClaude Opus 5.5 5ed2cfbb3d feature: действие captcha value=image — капча-картинка через 2captcha (#109)
Снимок элемента картинки на текущей странице (без повторной загрузки src) уходит
в 2captcha как обычная капча (method=base64), ответ набирается в поле target
как у typing и возвращается в result действия. Необязательные подсказки:
min_len, max_len, numeric, lang, case_sensitive. Клиент: Action.captcha_image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:17:25 +04:00

167 lines
5.9 KiB
Python

"""Пакет 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")
assert Action.captcha_image("img", "#in", numeric=1, case_sensitive=False).to_dict() == {
"type": "captcha", "selector": "img", "value": "image", "target": "#in", "numeric": 1, "case_sensitive": False,
}
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())