Add PUT/PATCH/DELETE/HEAD, full aiohttp/httpx param parity, screenshots,
async-with tab claiming, and full documentation - get/post/put/patch/delete/head now accept the same request shape as aiohttp/httpx: data/json/files/cookies/auth/allow_redirects/ follow_redirects/max_redirects, plus the existing session= for named cookie-persisting contexts. - screenshot(url, path=None) captures a PNG, saved to disk or returned as base64. - `async with stealth as page` claims a tab directly, task-safe for concurrent use on one shared instance (keyed off asyncio.current_task, not instance state). - Strip all docstrings/comments from the source (author line excepted), per house style. - README.md rewritten as the complete documentation: rationale, JA3/JA4 comparison, dependencies, construction/laziness/lifetime, every method with examples, named sessions, configuration, error handling, testing. - STEALTH_PATCHES.md: a literal, patch-by-patch account of every launch argument and JS patch applied, with rationale and verification method. - 20 new tests: full HTTP-method coverage, cookies, auth, multipart, download, render, screenshot, named-session isolation, retry/error paths, and guaranteed page/context cleanup under exception. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116i7dYLNZTXNR7Lqf439bV
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
import pytest
|
||||
|
||||
from stealthii import Stealthii, StealthRequestError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def stealth():
|
||||
s = Stealthii()
|
||||
yield s
|
||||
await s.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_json(stealth):
|
||||
resp = await stealth.get('https://httpbin.org/get', params={'a': '1'})
|
||||
assert resp.status == 200
|
||||
assert (await resp.json())['args'] == {'a': '1'}
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_json_body(stealth):
|
||||
resp = await stealth.post('https://httpbin.org/post', json={'hello': 'world'})
|
||||
assert resp.status == 200
|
||||
assert (await resp.json())['json'] == {'hello': 'world'}
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_form_data(stealth):
|
||||
resp = await stealth.post('https://httpbin.org/post', data={'x': '1', 'y': '2'})
|
||||
assert resp.status == 200
|
||||
assert (await resp.json())['form'] == {'x': '1', 'y': '2'}
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_put(stealth):
|
||||
resp = await stealth.put('https://httpbin.org/put', json={'k': 'v'})
|
||||
assert resp.status == 200
|
||||
assert (await resp.json())['json'] == {'k': 'v'}
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch(stealth):
|
||||
resp = await stealth.patch('https://httpbin.org/patch', json={'k': 'v'})
|
||||
assert resp.status == 200
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete(stealth):
|
||||
resp = await stealth.delete('https://httpbin.org/delete')
|
||||
assert resp.status == 200
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_head(stealth):
|
||||
resp = await stealth.head('https://httpbin.org/get')
|
||||
assert resp.status == 200
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_call_cookies_layer_on_context_jar(stealth):
|
||||
resp = await stealth.get('https://httpbin.org/cookies', cookies={'flavor': 'choc'})
|
||||
assert (await resp.json())['cookies'] == {'flavor': 'choc'}
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_auth(stealth):
|
||||
ok = await stealth.get('https://httpbin.org/basic-auth/user/pass', auth=('user', 'pass'))
|
||||
assert ok.status == 200
|
||||
bad = await stealth.get('https://httpbin.org/basic-auth/user/pass', auth=('user', 'wrong'))
|
||||
assert bad.status == 401
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_binary(stealth):
|
||||
data = await stealth.download('https://httpbin.org/image/png')
|
||||
assert data[:8] == b'\x89PNG\r\n\x1a\n'
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_multipart_file_upload(stealth):
|
||||
resp = await stealth.post(
|
||||
'https://httpbin.org/post',
|
||||
data={'field': 'value'},
|
||||
files={'upload': ('name.txt', b'file-content')},
|
||||
)
|
||||
body = await resp.json()
|
||||
assert body['form'] == {'field': 'value'}
|
||||
assert body['files']['upload'] == 'file-content'
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_returns_text_and_links(stealth):
|
||||
out = await stealth.render('https://example.com', collect_links=True)
|
||||
assert out['status'] == 200
|
||||
assert 'Example Domain' in out['text']
|
||||
assert isinstance(out['links'], list)
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_screenshot_base64_and_path(stealth, tmp_path):
|
||||
b64 = await stealth.screenshot('https://example.com')
|
||||
assert len(b64) > 1000
|
||||
|
||||
out_path = str(tmp_path / 'shot.png')
|
||||
result_path = await stealth.screenshot('https://example.com', path=out_path)
|
||||
assert result_path == out_path
|
||||
with open(out_path, 'rb') as f:
|
||||
assert f.read(8) == b'\x89PNG\r\n\x1a\n'
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_named_session_persists_cookies_across_calls(stealth):
|
||||
await stealth.get('https://httpbin.org/cookies/set/persisted/yes', session='warm')
|
||||
resp = await stealth.get('https://httpbin.org/cookies', session='warm')
|
||||
assert (await resp.json())['cookies'] == {'persisted': 'yes'}
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_calls_do_not_share_named_session_cookies(stealth):
|
||||
await stealth.get('https://httpbin.org/cookies/set/persisted/yes', session='isolated')
|
||||
resp = await stealth.get('https://httpbin.org/cookies')
|
||||
assert (await resp.json())['cookies'] == {}
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_raises_stealth_request_error_on_unreachable_host(stealth):
|
||||
with pytest.raises(StealthRequestError):
|
||||
await stealth.get('https://this-host-does-not-exist.invalid/', timeout=3)
|
||||
|
||||
|
||||
@pytest.mark.online
|
||||
@pytest.mark.asyncio
|
||||
async def test_page_and_context_close_even_when_caller_raises(stealth):
|
||||
pages = []
|
||||
with pytest.raises(ValueError):
|
||||
async with stealth.page() as pg:
|
||||
pages.append(pg)
|
||||
raise ValueError('boom')
|
||||
assert pages[0].is_closed()
|
||||
Reference in New Issue
Block a user