"""Пакет 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 == "page" 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())