Снимок элемента картинки на текущей странице (без повторной загрузки 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>
167 lines
5.9 KiB
Python
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())
|