test: юнит-тесты и сквозные тесты с Chrome (#21)
- tests/unit (make test, без браузера, ~5 с): схемы и валидация, /v1/solve с подменённым движком (коды ошибок, слоты, сессии, UA и cookies профиля, маскирование секретов в логах), действия, оркестратор антибота, профиль и отпечаток - tests/browser (make test-browser, в Docker): example.com, UA один раз и замена cookies на httpbin, применение и стабильность отпечатка, прохождение Cloudflare - requirements-dev.txt, make sync-dev; Makefile вызывает pip через python -m pip (у перенесённого .venv битый shebang bin/pip) - fix: в CloudflareSolver вызов bounding_box() у неожиданной корутины — ветка без iframe всегда падала с AttributeError Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
58100fa45d
commit
0ae5b0c83f
13 files changed
+773
-85
No files matched your search
@@ -0,0 +1,136 @@
|
||||
"""Сквозные тесты с настоящим Chrome и сетью. Запускаются в Docker (нужны Chrome и Xvfb):
|
||||
|
||||
make test-browser
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from api import profile
|
||||
|
||||
pytestmark = pytest.mark.browser
|
||||
|
||||
CF_URL = "https://freelancehunt.com/projects"
|
||||
CF_SELECTOR = 'a[href*="/project/"]'
|
||||
|
||||
# то, что обычно входит в хеш fingerprint-библиотек и видно странице
|
||||
FP_JS = """async () => {
|
||||
const c = document.createElement('canvas'); c.width = 240; c.height = 60;
|
||||
const x = c.getContext('2d'); x.textBaseline = 'top'; x.font = '16px Arial';
|
||||
x.fillStyle = '#f60'; x.fillRect(10, 10, 100, 30); x.fillStyle = '#069'; x.fillText('Robotex fp', 4, 20);
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(c.toDataURL()));
|
||||
const uad = await navigator.userAgentData.getHighEntropyValues(['fullVersionList']);
|
||||
document.body.innerHTML = '<pre id="fp">' + JSON.stringify({
|
||||
ua: navigator.userAgent,
|
||||
ua_ch: uad.fullVersionList.map(b => b.brand + '/' + b.version.split('.')[0]).join(','),
|
||||
screen: [screen.width, screen.height, devicePixelRatio],
|
||||
canvas: Array.from(new Uint8Array(buf)).slice(0, 6).map(b => b.toString(16).padStart(2, '0')).join(''),
|
||||
}) + '</pre>';
|
||||
}"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(main, "USER_DATA_ROOT", tmp_path)
|
||||
return TestClient(main.app)
|
||||
|
||||
|
||||
def solve(client, session_id=None, **body):
|
||||
client.cookies.clear()
|
||||
if session_id:
|
||||
client.cookies.set("session_id", session_id)
|
||||
resp = client.post("/v1/solve", json={"actions": [], **body})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json() | {"session_id": resp.cookies["session_id"]}
|
||||
|
||||
|
||||
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"},
|
||||
])
|
||||
assert data["status"] == "ok", data
|
||||
assert data["actions"][1]["result"] == "Learn more"
|
||||
assert len(data["screenshot"]) > 1000
|
||||
|
||||
|
||||
def test_action_failed_on_real_page(client):
|
||||
data = solve(client, url="https://example.com", actions=[{"type": "inner_html", "selector": "#missing"}])
|
||||
assert data["code"] == "action_failed"
|
||||
assert data["error"] == "inner_html: selector not found: #missing"
|
||||
|
||||
|
||||
def test_user_agent_once_and_cookies_replace(client):
|
||||
url = "https://httpbin.org/cookies"
|
||||
read = [{"type": "inner_html", "selector": "pre"}]
|
||||
cookie = lambda name: {"name": name, "value": "1", "url": "https://httpbin.org", "expires": 4102444800}
|
||||
|
||||
first = solve(client, url=url, user_agent="First/1", cookies=[cookie("a")], actions=read)
|
||||
sid = first["session_id"]
|
||||
assert first["user_agent"] == "First/1"
|
||||
assert json.loads(first["actions"][0]["result"])["cookies"] == {"a": "1"}
|
||||
|
||||
# повторный UA игнорируется; без cookies в запросе профильные сохраняются
|
||||
second = solve(client, sid, url=url, user_agent="Second/2", actions=read)
|
||||
assert second["user_agent"] == "First/1"
|
||||
assert json.loads(second["actions"][0]["result"])["cookies"] == {"a": "1"}
|
||||
|
||||
# переданные cookies заменяют профильные
|
||||
third = solve(client, sid, url=url, cookies=[cookie("b")], actions=read)
|
||||
assert json.loads(third["actions"][0]["result"])["cookies"] == {"b": "1"}
|
||||
|
||||
|
||||
def test_generated_fingerprint_applied_and_stable(client, monkeypatch):
|
||||
async def page_fp(page):
|
||||
await page.evaluate(FP_JS)
|
||||
return json.loads(await page.inner_html("#fp"))
|
||||
|
||||
results = {}
|
||||
original = main.make_page_action
|
||||
|
||||
def capture(payload):
|
||||
action = original(payload)
|
||||
|
||||
async def wrapped(page):
|
||||
out = await action(page)
|
||||
out["fp"] = await page_fp(page)
|
||||
return out
|
||||
return wrapped
|
||||
|
||||
monkeypatch.setattr(main, "make_page_action", capture)
|
||||
|
||||
class Spy(main.AsyncStealthySession):
|
||||
async def fetch(self, url, page_action):
|
||||
out = await super().fetch(url, page_action=page_action)
|
||||
results.setdefault(self._user_data_dir, []).append(out["fp"])
|
||||
return out
|
||||
|
||||
monkeypatch.setattr(main, "AsyncStealthySession", Spy)
|
||||
|
||||
sids = [solve(client, url="https://example.com")["session_id"] for _ in range(4)]
|
||||
solve(client, sids[0], url="https://example.com")
|
||||
|
||||
major = profile.chrome_major()
|
||||
fps = {}
|
||||
for d, measured in results.items():
|
||||
saved = profile.load(main.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"]]
|
||||
assert f"Chrome/{major}." in m["ua"] and f"Google Chrome/{major}" in m["ua_ch"]
|
||||
assert all(m == measured[0] for m in measured), "fingerprint changed within a profile"
|
||||
fps[d] = measured[0]
|
||||
assert len(fps) == 4
|
||||
print("distinct fingerprints:", len({json.dumps(f, sort_keys=True) for f in fps.values()}), "of", len(fps))
|
||||
|
||||
|
||||
def test_cloudflare(client):
|
||||
data = solve(client, url=CF_URL, captcha_type="cloudflare", timeout=120, actions=[
|
||||
{"type": "wait_for", "selector": CF_SELECTOR, "timeout": 60},
|
||||
])
|
||||
assert data["status"] == "ok", data.get("error")
|
||||
assert "cf_clearance" in {c["name"] for c in data["cookies"]}
|
||||
assert uuid.UUID(data["session_id"])
|
||||
Reference in new issue
Block a user