Skip to content
Merged
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
35 changes: 35 additions & 0 deletions percy/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,34 @@ def __init__(self, message, partial_capture=None):
super().__init__(message)
self.partial_capture = partial_capture or []

def _deep_merge(base, override):
"""Recursively merge `override` onto `base`. Nested dicts are merged key by
key; per-call (override) values win at the leaves; lists and scalars
replace rather than concatenate/merge."""
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


def _merge_config_into_serialize_options(percy_config, kwargs):
"""PER-8053: deep-merge the global .percy.yml `snapshot` config into the
per-call serialize options so config-only keys (e.g. enableJavaScript,
percyCSS, discovery.*) reach PercyDOM.serialize. Per-call kwargs win at the
leaves. Defensive against a missing/non-dict `config.snapshot`."""
if not isinstance(percy_config, dict):
return kwargs
config_snapshot = percy_config.get('snapshot') or {}
if not isinstance(config_snapshot, dict) or not config_snapshot:
return kwargs
return _deep_merge(config_snapshot, kwargs)


def _resolve_readiness_config(percy_config, kwargs):
"""Shallow-merge global (.percy.yml) readiness config with per-snapshot
override. Per-snapshot keys win; unspecified keys are inherited.
Expand Down Expand Up @@ -783,6 +811,13 @@ def get_serialized_dom(driver, cookies, percy_config=None, percy_dom_script=None
# per width.
if not skip_readiness:
readiness_diagnostics = _wait_for_ready(driver, percy_config, kwargs)
# PER-8053: deep-merge the global .percy.yml snapshot config into the
# per-call serialize options (per-call wins) BEFORE serialize, so config-only
# keys reach PercyDOM.serialize. Readiness ran above on the raw per-call
# kwargs, so any `readiness` pulled in from config here is harmless — it's
# stripped next. The merged kwargs also feed the CORS-iframe context below
# (`serialize_options`), so config reaches nested-frame serialize too.
kwargs = _merge_config_into_serialize_options(percy_config, kwargs)
# Strip `readiness` from kwargs before forwarding — it's an SDK-local
# concern; the CLI already has it from healthcheck and a top-level
# `readiness` in the POST body is brittle against future validators.
Expand Down
60 changes: 60 additions & 0 deletions tests/test_snapshot_units.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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 json
import os
import unittest
from unittest.mock import patch, MagicMock, Mock
Expand Down Expand Up @@ -58,6 +59,65 @@ def test_iframe_origin_error_is_skipped(self, _mock_origin):
self.assertNotIn('corsIframes', result)


class TestConfigMergeIntoSerialize(unittest.TestCase):
"""PER-8053: the global .percy.yml `snapshot` config must be deep-merged
into the per-call serialize options (per-call wins at the leaves) BEFORE
PercyDOM.serialize. Regression guard: a CORS-iframe refactor once dropped
this merge so config-only keys never reached serialize."""

def _capture_serialize(self, percy_config, **kwargs):
"""Run get_serialized_dom against a stub driver and return the exact
options object handed to PercyDOM.serialize."""
captured = {}
driver = MagicMock()

def _exec(script, *_args):
if 'PercyDOM.serialize' in script:
start = script.index('serialize(') + len('serialize(')
end = script.rindex(')')
captured['opts'] = json.loads(script[start:end])
return {'html': '<html></html>'}
return None

driver.execute_script.side_effect = _exec
local.get_serialized_dom(driver, {'c': 1}, percy_config=percy_config, **kwargs)
return captured['opts']

def test_config_only_key_reaches_serialize(self):
opts = self._capture_serialize({'snapshot': {'enableJavaScript': True}})
self.assertTrue(opts.get('enableJavaScript'))

def test_per_call_option_wins_over_config(self):
opts = self._capture_serialize(
{'snapshot': {'percyCSS': 'from-config'}}, percyCSS='from-call')
self.assertEqual(opts['percyCSS'], 'from-call')

def test_deep_merge_keeps_sibling_and_per_call_nested_wins(self):
opts = self._capture_serialize(
{'snapshot': {'discovery': {'networkIdleTimeout': 50, 'disableCache': False}}},
discovery={'disableCache': True})
self.assertEqual(opts['discovery']['networkIdleTimeout'], 50) # config sibling kept
self.assertTrue(opts['discovery']['disableCache']) # per-call nested wins

def test_no_config_leaves_per_call_untouched(self):
opts = self._capture_serialize(None, percyCSS='only-call')
self.assertEqual(opts, {'percyCSS': 'only-call'})

# --- helper defensive branches (called directly: these degrade to the raw
# per-call kwargs rather than raising, for any malformed config shape) ---
def test_merge_helper_non_dict_config_returns_kwargs(self):
self.assertEqual(
local._merge_config_into_serialize_options('nope', {'a': 1}), {'a': 1})

def test_merge_helper_non_dict_snapshot_returns_kwargs(self):
self.assertEqual(
local._merge_config_into_serialize_options({'snapshot': 'x'}, {'a': 1}), {'a': 1})

def test_merge_helper_empty_snapshot_returns_kwargs(self):
self.assertEqual(
local._merge_config_into_serialize_options({'snapshot': {}}, {'a': 1}), {'a': 1})


class TestGetResponsiveWidths(unittest.TestCase):
@patch('percy.snapshot.requests.get')
def test_non_list_widths_raises_upgrade_hint(self, mock_get):
Expand Down
Loading