Skip to content

Commit b44b82b

Browse files
committed
test: raise coverage to 95% with behavior-focused unit tests
Two parts: 1. Fix the coverage omit globs to match the documented policy. The comment says protocol event TypedDicts and command factories are excluded as tautological declarative layers, but the globs ('protocol/*/events.py', 'commands/*.py') matched nothing — those modules live one level deeper (protocol/cdp/page/ events.py, commands/cdp/page_commands.py). Made them recursive so the coverage target reflects logic, not declarations. 2. Add ~170 unit tests covering previously-untested logic, asserting behavior and state (not call sequences), mirroring the existing fake-connection style: - FirefoxBrowser: a full browser-commands suite (downloads, cookies, user contexts, windows, permissions, interception incl. firing the handler, connect) via a new fake_bidi_browser fixture. - BiDiTab: screenshots, PDF, dialogs, shadow-root collection, network introspection, find locator/raise branches. - BiDiWebElement / BiDiShadowRoot / BiDiKeyboard / FirefoxOptions / BiDiHarRecorder handlers. - CDP Browser: fetch interception + intercepted-request continue/fail/respond, proxy-auth callbacks, worker User-Agent auto-attach, Edge binary resolution, temp-dir preferences. - CubicBezier easing math and SOCKS5 handshake parsing/error paths. Full suite: 898 passed, 2 xfailed; coverage 95%.
1 parent 675a271 commit b44b82b

14 files changed

Lines changed: 1381 additions & 2 deletions

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,11 @@ addopts = '-p no:warnings'
6262
# Declarative layers are excluded from the coverage target: protocol event
6363
# TypedDicts carry no logic, and the command factories are thin CDP-dict builders
6464
# exercised through the methods that call them (1:1 tests would be tautological).
65+
# Patterns are recursive — these modules live under per-domain subpackages
66+
# (e.g. protocol/cdp/page/events.py, commands/cdp/page_commands.py).
6567
omit = [
66-
'pydoll/protocol/*/events.py',
67-
'pydoll/commands/*.py',
68+
'pydoll/protocol/**/events.py',
69+
'pydoll/commands/**',
6870
]
6971

7072
[tool.coverage.report]

tests/bidi/unit/conftest.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import pytest
1616

17+
from pydoll.browser import Firefox
1718
from pydoll.browser.firefox.tab import BiDiTab
1819

1920

@@ -83,6 +84,10 @@ async def clear_callbacks(self) -> None:
8384
"""Remove every registered callback."""
8485
self._callbacks.clear()
8586

87+
async def _ensure_active_connection(self) -> None:
88+
"""No-op: the fake has no real socket to (re)connect."""
89+
return None
90+
8691
async def ping(self) -> bool:
8792
"""A fake connection is always responsive."""
8893
return True
@@ -102,3 +107,16 @@ def fake_bidi_conn() -> FakeBiDiConnection:
102107
def fake_bidi_tab(fake_bidi_conn) -> BiDiTab:
103108
"""A real BiDiTab backed by an in-memory FakeBiDiConnection (no sockets)."""
104109
return BiDiTab('fake-context', fake_bidi_conn)
110+
111+
112+
@pytest.fixture
113+
def fake_bidi_browser(fake_bidi_conn) -> Firefox:
114+
"""A real Firefox browser with its handler swapped for the in-memory fake.
115+
116+
Lets the browser-level translate-only methods (cookies, contexts, windows,
117+
permissions, interception) be tested without a real Firefox or sockets.
118+
"""
119+
browser = Firefox()
120+
browser._connection_handler = fake_bidi_conn
121+
browser._session_id = 'fake-session'
122+
return browser
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
"""Translate-only FirefoxBrowser methods against an in-memory FakeBiDiConnection.
2+
3+
The BiDi counterpart of tests/cdp/unit/test_browser_commands.py: browser-level
4+
methods that turn a Python call into a WebDriver BiDi command and return simple
5+
state — download behaviour, cookies, user contexts, windows, permissions and
6+
request interception. Assertions check the command shape and resulting state, not
7+
that some command was merely emitted. Event-driven flows run against real Firefox
8+
in the integration suite.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import pytest
14+
15+
from pydoll.browser.firefox.tab import BiDiTab
16+
from pydoll.protocol.types import DownloadBehavior, Permission
17+
18+
19+
@pytest.mark.asyncio
20+
async def test_set_download_behavior_allow_carries_destination(fake_bidi_browser, fake_bidi_conn):
21+
await fake_bidi_browser.set_download_behavior(DownloadBehavior.ALLOW, '/tmp/dl')
22+
behavior = fake_bidi_conn.last_command('browser.setDownloadBehavior')['params']['downloadBehavior']
23+
assert behavior['type'] == 'allowed'
24+
assert behavior['destinationFolder'] == '/tmp/dl'
25+
26+
27+
@pytest.mark.asyncio
28+
async def test_set_download_behavior_deny(fake_bidi_browser, fake_bidi_conn):
29+
await fake_bidi_browser.set_download_behavior(DownloadBehavior.DENY)
30+
behavior = fake_bidi_conn.last_command('browser.setDownloadBehavior')['params']['downloadBehavior']
31+
assert behavior['type'] == 'denied'
32+
33+
34+
@pytest.mark.asyncio
35+
async def test_set_download_path_uses_allow_with_path(fake_bidi_browser, fake_bidi_conn):
36+
await fake_bidi_browser.set_download_path('/tmp/here')
37+
behavior = fake_bidi_conn.last_command('browser.setDownloadBehavior')['params']['downloadBehavior']
38+
assert behavior == {'type': 'allowed', 'destinationFolder': '/tmp/here'}
39+
40+
41+
@pytest.mark.asyncio
42+
async def test_set_cookies_sends_set_cookie_per_cookie(fake_bidi_browser, fake_bidi_conn):
43+
await fake_bidi_browser.set_cookies([{'name': 'a', 'value': '1', 'domain': 'example.com'}])
44+
cookie = fake_bidi_conn.last_command('storage.setCookie')['params']['cookie']
45+
assert cookie['name'] == 'a'
46+
assert cookie['value'] == {'type': 'string', 'value': '1'}
47+
assert cookie['domain'] == 'example.com'
48+
49+
50+
@pytest.mark.asyncio
51+
async def test_get_cookies_returns_generic_cookies(fake_bidi_browser, fake_bidi_conn):
52+
fake_bidi_conn.set_result(
53+
'storage.getCookies',
54+
{'cookies': [{
55+
'name': 'sid', 'value': {'type': 'string', 'value': 'xyz'},
56+
'domain': 'example.com', 'path': '/', 'size': 6,
57+
'httpOnly': True, 'secure': True, 'sameSite': 'lax',
58+
}]},
59+
)
60+
cookies = await fake_bidi_browser.get_cookies()
61+
assert cookies[0]['name'] == 'sid'
62+
assert cookies[0]['value'] == 'xyz'
63+
assert cookies[0]['httpOnly'] is True
64+
65+
66+
@pytest.mark.asyncio
67+
async def test_delete_all_cookies_sends_command(fake_bidi_browser, fake_bidi_conn):
68+
await fake_bidi_browser.delete_all_cookies()
69+
assert fake_bidi_conn.commands_for('storage.deleteCookies')
70+
71+
72+
@pytest.mark.asyncio
73+
async def test_create_browser_context_returns_user_context_id(fake_bidi_browser, fake_bidi_conn):
74+
fake_bidi_conn.set_result('browser.createUserContext', {'userContext': 'ctx-1'})
75+
context_id = await fake_bidi_browser.create_browser_context()
76+
assert context_id == 'ctx-1'
77+
78+
79+
@pytest.mark.asyncio
80+
async def test_create_browser_context_with_proxy_carries_manual_config(
81+
fake_bidi_browser, fake_bidi_conn
82+
):
83+
fake_bidi_conn.set_result('browser.createUserContext', {'userContext': 'ctx-2'})
84+
await fake_bidi_browser.create_browser_context(proxy_server='http://127.0.0.1:8080')
85+
proxy = fake_bidi_conn.last_command('browser.createUserContext')['params']['proxy']
86+
assert proxy['proxyType'] == 'manual'
87+
assert proxy['httpProxy'] == 'http://127.0.0.1:8080'
88+
89+
90+
@pytest.mark.asyncio
91+
async def test_delete_browser_context_sends_command(fake_bidi_browser, fake_bidi_conn):
92+
await fake_bidi_browser.delete_browser_context('ctx-1')
93+
assert fake_bidi_conn.last_command('browser.removeUserContext')['params']['userContext'] == 'ctx-1'
94+
95+
96+
@pytest.mark.asyncio
97+
async def test_get_browser_contexts_lists_ids(fake_bidi_browser, fake_bidi_conn):
98+
fake_bidi_conn.set_result(
99+
'browser.getUserContexts',
100+
{'userContexts': [{'userContext': 'default'}, {'userContext': 'ctx-1'}]},
101+
)
102+
assert await fake_bidi_browser.get_browser_contexts() == ['default', 'ctx-1']
103+
104+
105+
@pytest.mark.asyncio
106+
async def test_get_opened_tabs_builds_tabs_from_tree(fake_bidi_browser, fake_bidi_conn):
107+
fake_bidi_conn.set_result(
108+
'browsingContext.getTree',
109+
{'contexts': [{'context': 'c1'}, {'context': 'c2'}]},
110+
)
111+
tabs = await fake_bidi_browser.get_opened_tabs()
112+
assert all(isinstance(tab, BiDiTab) for tab in tabs)
113+
assert [tab._context_id for tab in tabs] == ['c1', 'c2']
114+
115+
116+
@pytest.mark.asyncio
117+
async def test_new_tab_creates_context_and_returns_tab(fake_bidi_browser, fake_bidi_conn):
118+
fake_bidi_conn.set_result('browsingContext.create', {'context': 'new-ctx'})
119+
tab = await fake_bidi_browser.new_tab()
120+
assert isinstance(tab, BiDiTab)
121+
assert tab._context_id == 'new-ctx'
122+
123+
124+
@pytest.mark.asyncio
125+
async def test_get_version_reads_cached_capabilities(fake_bidi_browser):
126+
fake_bidi_browser._capabilities = {
127+
'browserName': 'Firefox', 'browserVersion': '142.0', 'userAgent': 'UA/1',
128+
}
129+
version = await fake_bidi_browser.get_version()
130+
assert version['browserName'] == 'Firefox'
131+
assert version['browserVersion'] == '142.0'
132+
assert version['userAgent'] == 'UA/1'
133+
134+
135+
@pytest.mark.asyncio
136+
async def test_set_window_maximized_sets_state(fake_bidi_browser, fake_bidi_conn):
137+
fake_bidi_conn.set_result(
138+
'browser.getClientWindows', {'clientWindows': [{'clientWindow': 'w1'}]}
139+
)
140+
await fake_bidi_browser.set_window_maximized()
141+
command = fake_bidi_conn.last_command('browser.setClientWindowState')
142+
assert command['params']['clientWindow'] == 'w1'
143+
assert command['params']['state'] == 'maximized'
144+
145+
146+
@pytest.mark.asyncio
147+
async def test_set_window_bounds_carries_dimensions(fake_bidi_browser, fake_bidi_conn):
148+
fake_bidi_conn.set_result(
149+
'browser.getClientWindows', {'clientWindows': [{'clientWindow': 'w1'}]}
150+
)
151+
await fake_bidi_browser.set_window_bounds({'width': 800, 'height': 600})
152+
params = fake_bidi_conn.last_command('browser.setClientWindowState')['params']
153+
assert params['width'] == 800
154+
assert params['height'] == 600
155+
156+
157+
@pytest.mark.asyncio
158+
async def test_grant_permissions_maps_name_and_requires_origin(fake_bidi_browser, fake_bidi_conn):
159+
await fake_bidi_browser.grant_permissions(
160+
[Permission.GEOLOCATION], origin='https://example.com'
161+
)
162+
params = fake_bidi_conn.last_command('permissions.setPermission')['params']
163+
assert params['descriptor']['name'] == 'geolocation'
164+
assert params['origin'] == 'https://example.com'
165+
assert params['state'] == 'granted'
166+
167+
168+
@pytest.mark.asyncio
169+
async def test_grant_permissions_without_origin_raises(fake_bidi_browser):
170+
with pytest.raises(ValueError):
171+
await fake_bidi_browser.grant_permissions([Permission.GEOLOCATION])
172+
173+
174+
@pytest.mark.asyncio
175+
async def test_grant_permissions_warns_on_unsupported(fake_bidi_browser):
176+
with pytest.warns(UserWarning):
177+
await fake_bidi_browser.grant_permissions(
178+
[Permission.PROTECTED_MEDIA_IDENTIFIER], origin='https://example.com'
179+
)
180+
181+
182+
@pytest.mark.asyncio
183+
async def test_reset_permissions_resets_previously_granted(fake_bidi_browser, fake_bidi_conn):
184+
await fake_bidi_browser.grant_permissions(
185+
[Permission.GEOLOCATION], origin='https://example.com'
186+
)
187+
await fake_bidi_browser.reset_permissions()
188+
states = [c['params']['state'] for c in fake_bidi_conn.commands_for('permissions.setPermission')]
189+
assert 'prompt' in states
190+
191+
192+
@pytest.mark.asyncio
193+
async def test_on_subscribes_and_registers_callback(fake_bidi_browser, fake_bidi_conn):
194+
async def cb(_event):
195+
return None
196+
197+
await fake_bidi_browser.on('browsingContext.load', cb)
198+
assert fake_bidi_conn.commands_for('session.subscribe')
199+
200+
201+
@pytest.mark.asyncio
202+
async def test_intercept_requests_adds_intercept_and_subscribes(fake_bidi_browser, fake_bidi_conn):
203+
fake_bidi_conn.set_result('network.addIntercept', {'intercept': 'i1'})
204+
205+
async def cb(_req):
206+
return None
207+
208+
intercept_id = await fake_bidi_browser.intercept_requests(cb)
209+
assert intercept_id == 'i1'
210+
subscribe = fake_bidi_conn.last_command('session.subscribe')
211+
assert 'network.beforeRequestSent' in subscribe['params']['events']
212+
213+
214+
@pytest.mark.asyncio
215+
async def test_intercept_callback_receives_blocked_request(fake_bidi_browser, fake_bidi_conn):
216+
fake_bidi_conn.set_result('network.addIntercept', {'intercept': 'i1'})
217+
received = []
218+
219+
async def cb(req):
220+
received.append(req)
221+
await req.continue_()
222+
223+
await fake_bidi_browser.intercept_requests(cb)
224+
handler = next(iter(fake_bidi_conn._callbacks.values()))['callback']
225+
await handler({'params': {
226+
'isBlocked': True,
227+
'request': {'request': 'r1', 'url': 'http://x/a', 'method': 'GET', 'headers': []},
228+
}})
229+
230+
assert received and received[0].url == 'http://x/a'
231+
assert fake_bidi_conn.commands_for('network.continueRequest')
232+
233+
234+
@pytest.mark.asyncio
235+
async def test_intercept_callback_ignores_unblocked_request(fake_bidi_browser, fake_bidi_conn):
236+
fake_bidi_conn.set_result('network.addIntercept', {'intercept': 'i1'})
237+
received = []
238+
239+
async def cb(req):
240+
received.append(req)
241+
242+
await fake_bidi_browser.intercept_requests(cb)
243+
handler = next(iter(fake_bidi_conn._callbacks.values()))['callback']
244+
await handler({'params': {'isBlocked': False, 'request': {'request': 'r1', 'url': 'http://x'}}})
245+
assert received == []
246+
247+
248+
@pytest.mark.asyncio
249+
async def test_remove_intercept_unsubscribes_on_last(fake_bidi_browser, fake_bidi_conn):
250+
fake_bidi_conn.set_result('network.addIntercept', {'intercept': 'i1'})
251+
252+
async def cb(_req):
253+
return None
254+
255+
intercept_id = await fake_bidi_browser.intercept_requests(cb)
256+
await fake_bidi_browser.remove_intercept(intercept_id)
257+
assert fake_bidi_conn.last_command('network.removeIntercept')['params']['intercept'] == 'i1'
258+
assert fake_bidi_conn.commands_for('session.unsubscribe')
259+
260+
261+
@pytest.mark.asyncio
262+
async def test_browser_set_cookies_carries_optional_attributes(fake_bidi_browser, fake_bidi_conn):
263+
await fake_bidi_browser.set_cookies([{
264+
'name': 'a', 'value': '1', 'domain': 'example.com',
265+
'path': '/p', 'httpOnly': True, 'secure': True, 'expiry': 99, 'sameSite': 'Lax',
266+
}])
267+
cookie = fake_bidi_conn.last_command('storage.setCookie')['params']['cookie']
268+
assert cookie['path'] == '/p'
269+
assert cookie['httpOnly'] is True
270+
assert cookie['secure'] is True
271+
assert cookie['expiry'] == 99
272+
assert cookie['sameSite'] == 'lax'
273+
274+
275+
@pytest.mark.asyncio
276+
async def test_set_download_path_delegates_to_allow(fake_bidi_browser, fake_bidi_conn):
277+
await fake_bidi_browser.set_download_path('/tmp/dl')
278+
behavior = fake_bidi_conn.last_command('browser.setDownloadBehavior')['params']['downloadBehavior']
279+
assert behavior['type'] == 'allowed'
280+
assert behavior['destinationFolder'] == '/tmp/dl'
281+
282+
283+
@pytest.mark.asyncio
284+
async def test_delete_all_cookies_with_context_uses_partition(fake_bidi_browser, fake_bidi_conn):
285+
await fake_bidi_browser.delete_all_cookies(browser_context_id='ctx-1')
286+
params = fake_bidi_conn.last_command('storage.deleteCookies')['params']
287+
assert params['partition']['userContext'] == 'ctx-1'
288+
289+
290+
@pytest.mark.asyncio
291+
async def test_set_window_minimized_sets_state(fake_bidi_browser, fake_bidi_conn):
292+
fake_bidi_conn.set_result(
293+
'browser.getClientWindows', {'clientWindows': [{'clientWindow': 'w1'}]}
294+
)
295+
await fake_bidi_browser.set_window_minimized()
296+
assert fake_bidi_conn.last_command('browser.setClientWindowState')['params']['state'] == 'minimized'
297+
298+
299+
@pytest.mark.asyncio
300+
async def test_create_browser_context_warns_on_proxy_bypass(fake_bidi_browser, fake_bidi_conn):
301+
fake_bidi_conn.set_result('browser.createUserContext', {'userContext': 'c'})
302+
with pytest.warns(UserWarning):
303+
await fake_bidi_browser.create_browser_context(proxy_bypass_list='localhost')
304+
305+
306+
@pytest.mark.asyncio
307+
async def test_reset_permissions_filters_by_context(fake_bidi_browser, fake_bidi_conn):
308+
await fake_bidi_browser.grant_permissions(
309+
[Permission.GEOLOCATION], origin='https://a.com', browser_context_id='ctx-1'
310+
)
311+
await fake_bidi_browser.grant_permissions(
312+
[Permission.NOTIFICATIONS], origin='https://b.com', browser_context_id='ctx-2'
313+
)
314+
fake_bidi_conn.commands.clear()
315+
await fake_bidi_browser.reset_permissions(browser_context_id='ctx-1')
316+
reset = fake_bidi_conn.commands_for('permissions.setPermission')
317+
assert len(reset) == 1
318+
assert reset[0]['params']['origin'] == 'https://a.com'
319+
320+
321+
@pytest.mark.asyncio
322+
async def test_connect_establishes_session_and_returns_first_tab(fake_bidi_browser, fake_bidi_conn):
323+
fake_bidi_conn.set_result('session.new', {'sessionId': 's1', 'capabilities': {}})
324+
fake_bidi_conn.set_result('browsingContext.getTree', {'contexts': [{'context': 'c1'}]})
325+
tab = await fake_bidi_browser.connect('ws://127.0.0.1/session/abc')
326+
assert tab._context_id == 'c1'
327+
assert fake_bidi_browser._session_id == 's1'
328+
329+
330+
@pytest.mark.asyncio
331+
async def test_connect_opens_new_tab_when_no_contexts(fake_bidi_browser, fake_bidi_conn):
332+
fake_bidi_conn.set_result('session.new', {'sessionId': 's1', 'capabilities': {}})
333+
fake_bidi_conn.set_result('browsingContext.getTree', {'contexts': []})
334+
fake_bidi_conn.set_result('browsingContext.create', {'context': 'fresh'})
335+
tab = await fake_bidi_browser.connect('ws://127.0.0.1/session/abc')
336+
assert tab._context_id == 'fresh'

0 commit comments

Comments
 (0)