Skip to content

Commit 01349fe

Browse files
committed
test: cover bundle/general/decorator/temp-dir edge cases
Add pure-logic tests for the page-bundle helpers (resource filtering of failed/canceled/data-uri/non-bundleable, filename derivation, CSS url() rewrite and inline skipping data-uris and unknown refs), normalize_synthetic_xpath, the retry decorator's callback re-raise branches, the temp-dir persistent-lock swallow path, and page-level ws-address resolution from port + page_id.
1 parent e199bca commit 01349fe

5 files changed

Lines changed: 158 additions & 3 deletions

File tree

tests/integration/test_connection_handler.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,19 @@ async def flood_events():
356356
assert len(events) == 25
357357

358358

359+
@pytest.mark.asyncio
360+
async def test_resolves_page_level_address_from_port_and_page_id(cdp_server):
361+
"""A handler given a port + page_id connects via the page-level devtools path."""
362+
cdp_server.set_result('Page.enable', {})
363+
handler = ConnectionHandler(connection_port=cdp_server.port, page_id='abc')
364+
try:
365+
result = await handler.execute_command({'method': 'Page.enable'})
366+
assert result['result'] == {}
367+
assert cdp_server.total_connections >= 1
368+
finally:
369+
await handler.close()
370+
371+
359372
@pytest.mark.asyncio
360373
async def test_context_manager_runs_commands_and_has_repr(cdp_server):
361374
"""The async context manager yields a usable handler and closes it on exit."""

tests/unit/test_bundle.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Pure-logic tests for the page-bundle helpers (no browser, no I/O).
2+
3+
These functions transform the resource tree and rewrite asset URLs for the
4+
offline ``save_bundle`` zip. The end-to-end behaviour is covered by the
5+
real-Chrome suite; here we pin the branchy edge cases of the pure helpers:
6+
which resources are skipped, how filenames are derived, and how data-URI /
7+
unknown URLs are left untouched while known ones are rewritten or inlined.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from pydoll.protocol.network.types import ResourceType
13+
from pydoll.utils.bundle import (
14+
build_asset_filename,
15+
collect_frame_resources,
16+
filter_fetchable_resources,
17+
inline_css_urls,
18+
rewrite_css_urls,
19+
)
20+
21+
22+
def _res(url, rtype=ResourceType.STYLESHEET, mime='text/css', **extra):
23+
return {'url': url, 'type': rtype, 'mimeType': mime, **extra}
24+
25+
26+
def test_filter_skips_failed_canceled_data_uri_page_and_nonbundleable():
27+
page_url = 'http://x/'
28+
resources = [
29+
('f', _res('http://x/a.css')),
30+
('f', _res('http://x/b.css', failed=True)),
31+
('f', _res('http://x/c.css', canceled=True)),
32+
('f', _res('data:image/png;base64,AA', ResourceType.IMAGE, 'image/png')),
33+
('f', _res(page_url, ResourceType.DOCUMENT, 'text/html')),
34+
('f', _res('http://x/data.json', ResourceType.XHR, 'application/json')),
35+
]
36+
kept = [res['url'] for _fid, res in filter_fetchable_resources(resources, page_url)]
37+
assert kept == ['http://x/a.css']
38+
39+
40+
def test_collect_frame_resources_recurses_into_child_frames():
41+
tree = {
42+
'frame': {'id': 'root'},
43+
'resources': [_res('http://x/a.css')],
44+
'childFrames': [
45+
{
46+
'frame': {'id': 'child'},
47+
'resources': [_res('http://x/b.js', ResourceType.SCRIPT, 'text/javascript')],
48+
}
49+
],
50+
}
51+
assert sorted(fid for fid, _res_ in collect_frame_resources(tree)) == ['child', 'root']
52+
53+
54+
def test_build_asset_filename_derives_basename_and_extension():
55+
assert build_asset_filename('http://x', 'text/css', 0).endswith('resource.css')
56+
assert build_asset_filename('http://x/style', 'text/css', 1).endswith('style.css')
57+
assert build_asset_filename('http://x/a.png', 'image/png', 2).endswith('a.png')
58+
59+
60+
def test_rewrite_css_urls_skips_data_uri_and_unknown_rewrites_known():
61+
css = (
62+
'a{background:url("data:image/png;base64,AA")}'
63+
'b{background:url("http://x/unknown.png")}'
64+
'c{background:url("http://x/known.png")}'
65+
)
66+
asset_map = {'http://x/known.png': ('0000_known.png', b'', 'image/png', ResourceType.IMAGE)}
67+
result = rewrite_css_urls(css, 'http://x/style.css', asset_map)
68+
assert 'data:image/png;base64,AA' in result
69+
assert 'http://x/unknown.png' in result
70+
assert 'url("0000_known.png")' in result
71+
72+
73+
def test_inline_css_urls_skips_data_uri_and_unknown_inlines_known():
74+
css = (
75+
'a{background:url("data:image/png;base64,AA")}'
76+
'b{background:url("http://x/unknown.png")}'
77+
'c{background:url("http://x/known.png")}'
78+
)
79+
asset_map = {'http://x/known.png': ('f', b'\x89PNG', 'image/png', ResourceType.IMAGE)}
80+
result = inline_css_urls(css, 'http://x/style.css', asset_map)
81+
assert 'data:image/png;base64,AA' in result
82+
assert 'http://x/unknown.png' in result
83+
assert 'url("data:image/png;base64,' in result

tests/unit/test_decorators.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,16 +118,36 @@ async def test_callback_without_instance(self):
118118
"""Test callback that doesn't accept instance argument."""
119119
# Callback that doesn't accept arguments
120120
callback_called = False
121-
121+
122122
async def simple_callback():
123123
nonlocal callback_called
124124
callback_called = True
125-
125+
126126
config = RetryConfig(on_retry=simple_callback)
127127
await config.call_callback(MagicMock())
128-
128+
129129
assert callback_called
130130

131+
@pytest.mark.asyncio
132+
async def test_callback_reraises_non_typeerror(self):
133+
"""A callback that fails with a non-TypeError propagates that error."""
134+
async def boom(_instance):
135+
raise ValueError('callback failed')
136+
137+
config = RetryConfig(on_retry=boom)
138+
with pytest.raises(ValueError):
139+
await config.call_callback(object())
140+
141+
@pytest.mark.asyncio
142+
async def test_callback_reraises_inner_error_when_noarg_retry_also_fails(self):
143+
"""When the no-arg retry fallback itself fails, that inner error propagates."""
144+
async def zero_arg_then_boom():
145+
raise RuntimeError('inner failure')
146+
147+
config = RetryConfig(on_retry=zero_arg_then_boom)
148+
with pytest.raises(RuntimeError):
149+
await config.call_callback(object())
150+
131151

132152
class TestRetryDecoratorBasic:
133153
"""Test basic retry decorator functionality."""

tests/unit/test_temp_dir_manager.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,28 @@ def test_handle_cleanup_error_recovers_known_chrome_lock():
7373
(PermissionError, PermissionError(), None),
7474
)
7575
assert processed == ['/profile/CrashpadMetrics-active.pma']
76+
77+
78+
def test_handle_cleanup_error_recovers_safe_browsing_lock():
79+
manager = TempDirectoryManager()
80+
processed = []
81+
manager.handle_cleanup_error(
82+
processed.append,
83+
'/profile/Safe Browsing/data',
84+
(PermissionError, PermissionError(), None),
85+
)
86+
assert processed == ['/profile/Safe Browsing/data']
87+
88+
89+
def test_handle_cleanup_error_swallows_persistently_locked_chrome_file():
90+
manager = TempDirectoryManager()
91+
92+
def always_locked(_path):
93+
raise PermissionError()
94+
95+
# A known Chrome lock file that never unlocks is logged and ignored, not raised.
96+
manager.handle_cleanup_error(
97+
always_locked,
98+
'/profile/CrashpadMetrics-active.pma',
99+
(PermissionError, PermissionError(), None),
100+
)

tests/unit/test_utils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,25 @@
1313
get_browser_ws_address,
1414
has_return_outside_function,
1515
is_script_already_function,
16+
normalize_synthetic_xpath,
1617
validate_browser_paths,
1718
extract_text_from_html,
1819
)
1920

2021

22+
def test_normalize_synthetic_xpath_extracts_inner_xpath():
23+
assert normalize_synthetic_xpath('//*[@xpath="//div[@id=\'x\']"]') == "//div[@id='x']"
24+
25+
26+
def test_normalize_synthetic_xpath_passes_through_plain_selector():
27+
assert normalize_synthetic_xpath('//div[@id="x"]') == '//div[@id="x"]'
28+
29+
30+
def test_normalize_synthetic_xpath_returns_input_when_unparseable():
31+
assert normalize_synthetic_xpath("//*[@xpath='x']") == "//*[@xpath='x']"
32+
assert normalize_synthetic_xpath('//*[@xpath="abc') == '//*[@xpath="abc'
33+
34+
2135
class TestUtils:
2236
"""
2337
Test class for utility functions in the pydoll.utils module.

0 commit comments

Comments
 (0)