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:
2026-09-09 13:56:28 +02:00
co-authored by Claude Sonnet 5
parent a3bb7639d2
commit 86c3a95cb8
7 changed files with 540 additions and 225 deletions
+156
View File
@@ -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()