Skip to content
Open
Show file tree
Hide file tree
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
18 changes: 15 additions & 3 deletions percy/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ def fetch_percy_dom():
response.raise_for_status()
return response.text

def _deep_merge(base, override):
merged = dict(base)
for key, value in override.items():
existing = merged.get(key)
merged[key] = _deep_merge(existing, value) \
if isinstance(existing, dict) and isinstance(value, dict) else value
return merged

# pylint: disable=too-many-arguments, too-many-branches, too-many-locals
def create_region(
boundingBox=None,
Expand Down Expand Up @@ -995,19 +1003,23 @@ def percy_snapshot(driver, name, **kwargs):
expose_closed_shadow_roots(driver)
cookies = driver.get_cookies()

# Merge .percy.yml config options with snapshot options (snapshot options take priority)
config_options = data['config'].get('snapshot') or {}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Guard config itself against null, not just `config['snapshot']

This fixes snapshot being null, but data['config'] itself can be None: is_percy_enabled() does data.get('config', {}), which only defaults when the key is absent — a healthcheck returning {"config": null} makes data['config'] None, and .get('snapshot') then raises AttributeError. The codebase's own _resolve_readiness_config defends against exactly this (config = percy_config or {}, see snapshot.py:198-200).

Suggestion:

Suggested change
config_options = data['config'].get('snapshot') or {}
config_options = (data['config'] or {}).get('snapshot') or {}

Reviewer: stack:code-review

merged_kwargs = _deep_merge(config_options, kwargs)

# Serialize and capture the DOM
if is_responsive_snapshot_capture(data['config'], **kwargs):
if is_responsive_snapshot_capture(data['config'], **merged_kwargs):
dom_snapshot = capture_responsive_dom(
driver=driver,
cookies=cookies,
config=data['config'],
percy_dom_script=percy_dom_script,
**kwargs,
**merged_kwargs,
)
else:
dom_snapshot = get_serialized_dom(
driver, cookies, percy_dom_script=percy_dom_script,
percy_config=data.get('config'), **kwargs)
percy_config=data.get('config'), **merged_kwargs)

# Strip SDK-local `readiness` from the snapshot POST body. The CLI
# already has it via healthcheck; sending it again here risks future
Expand Down
100 changes: 100 additions & 0 deletions tests/test_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,59 @@ def test_raise_error_poa_token_with_snapshot(self):
"/docs/percy/integrate/functional-and-visual", str(context.exception))


# pylint: disable=too-few-public-methods
class TestConfigDeepMerge(unittest.TestCase):
"""Unit test for deep-merging .percy.yml config with per-snapshot options
using a fully-mocked WebDriver, so it never launches a real browser."""

def setUp(self):
local.is_percy_enabled.cache_clear()
local.fetch_percy_dom.cache_clear()
httpretty.enable()

def tearDown(self):
httpretty.disable()
httpretty.reset()

@patch('selenium.webdriver.Chrome')
def test_deep_merges_config_with_per_snapshot_options(self, MockChrome):
driver = MockChrome.return_value
# Capture the argument passed to PercyDOM.serialize(...)
serialize_calls = []

def execute_script(script):
if script.startswith('return PercyDOM.serialize('):
serialize_calls.append(
json.loads(script[len('return PercyDOM.serialize('):-1]))
return { 'html': 'some_dom' }
return ''

driver.execute_script.side_effect = execute_script
driver.get_cookies.return_value = []
driver.find_elements.return_value = []
mock_logger()
# config carries a nested `discovery` object with two leaves.
mock_healthcheck(config={
'snapshot': {
'discovery': { 'networkIdleTimeout': 50, 'disableCache': False }
}
})
mock_snapshot()

with patch.object(driver, 'current_url', 'http://localhost:8000/'):
with patch.object(driver, 'capabilities', new={ 'browserName': 'chrome' }):
# per-call overrides only one leaf of the nested discovery object
percy_snapshot(driver, 'Snapshot 1',
discovery={ 'disableCache': True })

self.assertEqual(len(serialize_calls), 1)
serialized = serialize_calls[0]
# sibling leaf kept from config, overridden leaf takes per-call value
self.assertEqual(serialized['discovery'], {
'networkIdleTimeout': 50, 'disableCache': True
})


class TestReadinessGate(unittest.TestCase):
"""Unit tests for _wait_for_ready / _resolve_readiness_config using a
fully-mocked WebDriver. Bypasses real geckodriver/Firefox traffic, so
Expand Down Expand Up @@ -634,6 +687,53 @@ def test_get_serialized_dom_attaches_diagnostics(self):

self.assertEqual(dom_snapshot['readiness_diagnostics'], {'passed': True})

class TestConfigPerCallMergePrecedence(unittest.TestCase):
"""Unit tests for merging .percy.yml `config.snapshot` options with
per-snapshot kwargs before PercyDOM.serialize. Per-call kwargs win;
config-only keys are still forwarded to serialize."""

def setUp(self):
local.is_percy_enabled.cache_clear()
local.fetch_percy_dom.cache_clear()
httpretty.enable()

def tearDown(self):
httpretty.disable()
httpretty.reset()

def test_config_and_per_call_options_merge_into_serialize(self):
"""`config.snapshot` options reach PercyDOM.serialize, and a per-call
kwarg overrides the same key from config (per-call priority)."""
mock_healthcheck(config={'snapshot': {
'enableJavaScript': True, 'percyCSS': 'FROM_CONFIG'}})
mock_snapshot()

# Fully-mocked driver so we can capture the exact string passed to
# execute_script for the PercyDOM.serialize call. No real browser.
driver = Mock()
driver.execute_script.return_value = {'html': '<html></html>'}
driver.execute_async_script.return_value = None
driver.get_cookies.return_value = []
driver.current_url = 'http://localhost:8000/'
driver.find_elements.return_value = []

percy_snapshot(driver, 'name', percyCSS='FROM_CALL')

serialize_call = next(
c for c in driver.execute_script.call_args_list
if 'PercyDOM.serialize' in c.args[0]
)
serialize_options = json.loads(
serialize_call.args[0]
.split('PercyDOM.serialize(', 1)[1]
.rsplit(')', 1)[0]
)
# config-only key reached serialize
self.assertEqual(serialize_options['enableJavaScript'], True)
# per-call kwarg overrode the config value
self.assertEqual(serialize_options['percyCSS'], 'FROM_CALL')


class TestPercyScreenshot(unittest.TestCase):
@classmethod
def setUpClass(cls):
Expand Down
Loading