Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
329 changes: 328 additions & 1 deletion tests/test_snapshot_units.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
"""Focused unit tests for snapshot.py helper functions and their error/
fallback paths. These run as plain unittests (no Percy CLI needed) and
target branches the end-to-end suite in test_snapshot.py does not reach."""
import importlib
import os
import unittest
from unittest.mock import patch, MagicMock
from unittest.mock import patch, MagicMock, Mock

import percy.snapshot as local

Expand Down Expand Up @@ -115,5 +116,331 @@ def test_invalid_min_height_falls_back_to_window_height(self):
self.assertEqual(result, [])


class TestResizeSettleSecondsParsing(unittest.TestCase):
def test_invalid_env_value_falls_back_to_default(self):
# Module-level parse: an unparseable PERCY_RESIZE_SETTLE_SECONDS must
# fall back to the 0.5s default instead of raising at import time.
try:
with patch.dict(os.environ, {'PERCY_RESIZE_SETTLE_SECONDS': 'not-a-float'}):
importlib.reload(local)
self.assertEqual(local.RESIZE_SETTLE_SECONDS, 0.5)
finally:
# Restore module globals from the real (unpatched) environment.
importlib.reload(local)


class TestShouldSkipIframeEdgeCases(unittest.TestCase):
@staticmethod
def _meta(src, percy_id):
return {
'src': src, 'srcdoc': None, 'percyElementId': percy_id,
'dataPercyIgnore': False, 'matchesIgnoreSelector': False, 'index': 0,
}

def test_unparseable_origin_is_skipped(self):
# src passes the unsupported-scheme check but has no scheme://netloc,
# so get_origin returns None -> "invalid URL" skip branch.
iframe = self._meta('not-a-url', 'pid-1')
self.assertTrue(local._should_skip_iframe(iframe, 'http://main.example.com'))

def test_cross_origin_without_percy_element_id_is_skipped(self):
iframe = self._meta('https://other.example.com/widget', None)
self.assertTrue(local._should_skip_iframe(iframe, 'http://main.example.com'))


def _tree_ctx(max_depth=3):
return {
'max_frame_depth': max_depth,
'ignore_selectors': [],
'serialize_options': {},
'percy_dom_script': 'PERCY_DOM',
}


class TestProcessFrameTreeGuards(unittest.TestCase):
@staticmethod
def _meta(src, percy_id, ignore=False):
return {
'src': src, 'srcdoc': None, 'percyElementId': percy_id,
'dataPercyIgnore': ignore, 'matchesIgnoreSelector': False, 'index': 0,
}

def test_depth_beyond_max_returns_empty(self):
driver = MagicMock()
result = local.process_frame_tree(
driver, self._meta('https://x.example.com/', 'pid-x'), 4, set(),
_tree_ctx(max_depth=3))
self.assertEqual(result, [])
driver.switch_to.frame.assert_not_called()

def test_missing_iframe_element_returns_empty(self):
driver = MagicMock()
# querySelector by data-percy-element-id finds nothing (DOM mutated).
driver.execute_script.side_effect = [None]
result = local.process_frame_tree(
driver, self._meta('https://x.example.com/', 'pid-x'), 1, set(),
_tree_ctx())
self.assertEqual(result, [])
driver.switch_to.frame.assert_not_called()

def test_document_url_read_failure_is_treated_as_unsupported(self):
driver = MagicMock()
driver.execute_script.side_effect = [
Mock(name='iframe_element'), # querySelector
Exception('document.URL blew up'), # post-switch URL read
]
result = local.process_frame_tree(
driver, self._meta('https://x.example.com/', 'pid-x'), 1, set(),
_tree_ctx())
self.assertEqual(result, [])
driver.switch_to.parent_frame.assert_called_once()

def test_empty_serialize_result_returns_empty(self):
driver = MagicMock()
driver.execute_script.side_effect = [
Mock(name='iframe_element'), # querySelector
'https://x.example.com/page', # post-switch document.URL
None, # inject PercyDOM
None, # serialize returned nothing
]
result = local.process_frame_tree(
driver, self._meta('https://x.example.com/page', 'pid-x'), 1, set(),
_tree_ctx())
self.assertEqual(result, [])

def test_nested_enumeration_failure_keeps_parent_capture(self):
driver = MagicMock()
driver.execute_script.side_effect = [
Mock(name='iframe_element'),
'https://x.example.com/page',
None,
{'snapshot': {'html': '<x/>'}, 'frameUrl': 'https://x.example.com/page'},
Exception('nested enumerate blew up'),
]
result = local.process_frame_tree(
driver, self._meta('https://x.example.com/page', 'pid-x'), 1, set(),
_tree_ctx())
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['iframeData']['percyElementId'], 'pid-x')

def test_skipped_nested_child_is_not_recursed_into(self):
driver = MagicMock()
driver.execute_script.side_effect = [
Mock(name='iframe_element'),
'https://x.example.com/page',
None,
{'snapshot': {'html': '<x/>'}, 'frameUrl': 'https://x.example.com/page'},
# nested enumeration returns one child carrying data-percy-ignore
[self._meta('https://c.example.com/', 'pid-c', ignore=True)],
]
result = local.process_frame_tree(
driver, self._meta('https://x.example.com/page', 'pid-x'), 1, set(),
_tree_ctx())
self.assertEqual(len(result), 1)
# Only one frame switch happened — the ignored child was never entered.
self.assertEqual(driver.switch_to.frame.call_count, 1)

def test_nested_child_capture_is_collected(self):
driver = MagicMock()
driver.execute_script.side_effect = [
# depth-1 frame
Mock(name='iframe_element_x'),
'https://x.example.com/page',
None,
{'snapshot': {'html': '<x/>'}, 'frameUrl': 'https://x.example.com/page'},
[self._meta('https://c.example.com/', 'pid-c')],
# nested depth-2 frame
Mock(name='iframe_element_c'),
'https://c.example.com/',
None,
{'snapshot': {'html': '<c/>'}, 'frameUrl': 'https://c.example.com/'},
[],
]
result = local.process_frame_tree(
driver, self._meta('https://x.example.com/page', 'pid-x'), 1, set(),
_tree_ctx())
pids = [e['iframeData']['percyElementId'] for e in result]
self.assertEqual(pids, ['pid-x', 'pid-c'])

def test_default_content_failure_is_swallowed_at_depth_1(self):
driver = MagicMock()
driver.execute_script.side_effect = [
Mock(name='iframe_element'),
'https://x.example.com/page',
Exception('inject blew up'),
]
driver.switch_to.parent_frame.side_effect = Exception('lost parent')
driver.switch_to.default_content.side_effect = Exception('lost everything')
# At depth 1 a total context loss must not raise — the walk just ends.
result = local.process_frame_tree(
driver, self._meta('https://x.example.com/page', 'pid-x'), 1, set(),
_tree_ctx())
self.assertEqual(result, [])

def test_context_lost_links_original_error_as_cause(self):
driver = MagicMock()
driver.execute_script.side_effect = [
Mock(name='iframe_element'),
'https://c.example.com/',
Exception('inject blew up'),
]
driver.switch_to.parent_frame.side_effect = Exception('lost parent')
# depth > 1 with a captured processing error: PercyContextLost is
# raised and chains the original error for diagnosis.
with self.assertRaises(local.PercyContextLost) as cm:
local.process_frame_tree(
driver, self._meta('https://c.example.com/', 'pid-c'), 2, set(),
_tree_ctx())
self.assertEqual(cm.exception.partial_capture, [])
self.assertIsNotNone(cm.exception.__cause__)


class TestCaptureCorsIframesErrorHandling(unittest.TestCase):
def test_unexpected_error_returns_empty_list(self):
driver = MagicMock()
# Enumeration "succeeds" but yields a malformed (non-dict) entry;
# the resulting AttributeError is swallowed by the outer guard.
driver.execute_script.side_effect = [[None]]
result = local._capture_cors_iframes(
driver, 'http://main.example.com/', _tree_ctx())
self.assertEqual(result, [])


class TestExposeClosedShadowRootsEdgeCases(unittest.TestCase):
def test_capabilities_access_failure_is_treated_as_non_chromium(self):
cdp_calls = []

class StubDriver:
@property
def capabilities(self):
raise Exception('capabilities unavailable')

def execute_cdp_cmd(self, cmd, params):
cdp_calls.append((cmd, params))

local.expose_closed_shadow_roots(StubDriver())
self.assertEqual(cdp_calls, [])

def test_missing_object_ids_are_skipped(self):
driver = MagicMock()
driver.capabilities = {'browserName': 'chrome'}

def cdp(cmd, _params):
if cmd == 'DOM.getDocument':
return {'root': {
'backendNodeId': 1,
'shadowRoots': [{'backendNodeId': 2, 'shadowRootType': 'closed'}],
'children': [],
}}
if cmd == 'DOM.resolveNode':
return {'object': {}} # no objectId -> pair is skipped
return {}
driver.execute_cdp_cmd.side_effect = cdp

local.expose_closed_shadow_roots(driver)
cdp_cmds = [c.args[0] for c in driver.execute_cdp_cmd.call_args_list]
self.assertNotIn('Runtime.callFunctionOn', cdp_cmds)

def test_resolve_node_failure_is_swallowed(self):
driver = MagicMock()
driver.capabilities = {'browserName': 'chrome'}

def cdp(cmd, _params):
if cmd == 'DOM.getDocument':
return {'root': {
'backendNodeId': 1,
'shadowRoots': [{'backendNodeId': 2, 'shadowRootType': 'closed'}],
'children': [],
}}
if cmd == 'DOM.resolveNode':
raise Exception('node gone')
return {}
driver.execute_cdp_cmd.side_effect = cdp

local.expose_closed_shadow_roots(driver) # must not raise

def test_get_document_failure_and_dom_disable_failure_are_swallowed(self):
driver = MagicMock()
driver.capabilities = {'browserName': 'chrome'}

def cdp(cmd, _params):
if cmd == 'DOM.enable':
return {}
raise Exception(f'{cmd} failed') # DOM.getDocument AND DOM.disable
driver.execute_cdp_cmd.side_effect = cdp

local.expose_closed_shadow_roots(driver) # must not raise


class TestGetOriginHelpers(unittest.TestCase):
def test_get_origin_returns_none_without_scheme_or_netloc(self):
self.assertIsNone(local.get_origin('not-a-url'))
self.assertIsNone(local.get_origin('http://'))

def test_get_origin_returns_none_when_parsing_raises(self):
# urlparse raises AttributeError on non-str input
self.assertIsNone(local.get_origin(123))

def test_get_origin_compat_shim_returns_empty_string(self):
self.assertEqual(local._get_origin('https://a.example.com/x'),
'https://a.example.com')
self.assertEqual(local._get_origin('not-a-url'), '')


class TestGetSerializedDomPageUrlFailure(unittest.TestCase):
def test_current_url_failure_degrades_to_no_page_url(self):
class StubDriver:
def __init__(self):
self.calls = 0

def execute_script(self, _script, *_args):
self.calls += 1
if self.calls == 1:
return {'html': '<html/>'} # main serialize
return [] # iframe enumeration

@property
def current_url(self):
raise Exception('no url available')

dom = local.get_serialized_dom(
StubDriver(), [{'name': 'k', 'value': 'v'}],
percy_dom_script='PERCY_DOM')
self.assertNotIn('corsIframes', dom)
self.assertEqual(dom['cookies'], [{'name': 'k', 'value': 'v'}])


class TestResponsiveDebugDump(unittest.TestCase):
def tearDown(self):
if os.path.exists('output_file.json'):
os.remove('output_file.json')

@staticmethod
def _driver():
driver = MagicMock()
driver.get_window_size.return_value = {'width': 1000, 'height': 800}
return driver

@patch('percy.snapshot.PERCY_DEBUG', True)
@patch('percy.snapshot.change_window_dimension_and_wait', MagicMock())
@patch('percy.snapshot._wait_for_ready', MagicMock(return_value=None))
@patch('percy.snapshot.get_responsive_widths', MagicMock(return_value=[]))
def test_debug_dump_is_written_when_percy_debug(self):
result = local.capture_responsive_dom(
self._driver(), {}, {}, percy_dom_script='PERCY_DOM')
self.assertEqual(result, [])
self.assertTrue(os.path.exists('output_file.json'))

@patch('percy.snapshot.PERCY_DEBUG', True)
@patch('percy.snapshot.change_window_dimension_and_wait', MagicMock())
@patch('percy.snapshot._wait_for_ready', MagicMock(return_value=None))
@patch('percy.snapshot.get_responsive_widths', MagicMock(return_value=[]))
@patch('percy.snapshot.json.dump', side_effect=TypeError('not serializable'))
def test_debug_dump_failure_is_logged_not_raised(self, _mock_dump):
result = local.capture_responsive_dom(
self._driver(), {}, {}, percy_dom_script='PERCY_DOM')
self.assertEqual(result, [])


if __name__ == '__main__':
unittest.main()
Loading