Skip to content

Commit f50ac65

Browse files
fix: merge .percy.yml config into serialize options (PER-8053)
get_serialized_dom serialized only the raw per-call kwargs, so global .percy.yml `snapshot` config (enableJavaScript, percyCSS, discovery.*, etc.) never reached PercyDOM.serialize. The config-merge that PR #223 introduced was dropped when the CORS-iframe refactor (#226) rewrote this path. Restore it: deep-merge config.snapshot into the serialize kwargs (per-call wins at the leaves) after the readiness gate and before serialize, so it also flows to the CORS-iframe context (serialize_options) and the responsive-capture path. Adds unit tests asserting a config-only key reaches serialize, per-call wins, deep-merge keeps config siblings, and the helper degrades gracefully on malformed config — a regression guard the unit suite previously lacked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9188fc9 commit f50ac65

2 files changed

Lines changed: 95 additions & 0 deletions

File tree

percy/snapshot.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,34 @@ def __init__(self, message, partial_capture=None):
673673
super().__init__(message)
674674
self.partial_capture = partial_capture or []
675675

676+
def _deep_merge(base, override):
677+
"""Recursively merge `override` onto `base`. Nested dicts are merged key by
678+
key; per-call (override) values win at the leaves; lists and scalars
679+
replace rather than concatenate/merge."""
680+
merged = dict(base)
681+
for key, value in override.items():
682+
existing = merged.get(key)
683+
merged[key] = (
684+
_deep_merge(existing, value)
685+
if isinstance(existing, dict) and isinstance(value, dict)
686+
else value
687+
)
688+
return merged
689+
690+
691+
def _merge_config_into_serialize_options(percy_config, kwargs):
692+
"""PER-8053: deep-merge the global .percy.yml `snapshot` config into the
693+
per-call serialize options so config-only keys (e.g. enableJavaScript,
694+
percyCSS, discovery.*) reach PercyDOM.serialize. Per-call kwargs win at the
695+
leaves. Defensive against a missing/non-dict `config.snapshot`."""
696+
if not isinstance(percy_config, dict):
697+
return kwargs
698+
config_snapshot = percy_config.get('snapshot') or {}
699+
if not isinstance(config_snapshot, dict) or not config_snapshot:
700+
return kwargs
701+
return _deep_merge(config_snapshot, kwargs)
702+
703+
676704
def _resolve_readiness_config(percy_config, kwargs):
677705
"""Shallow-merge global (.percy.yml) readiness config with per-snapshot
678706
override. Per-snapshot keys win; unspecified keys are inherited.
@@ -783,6 +811,13 @@ def get_serialized_dom(driver, cookies, percy_config=None, percy_dom_script=None
783811
# per width.
784812
if not skip_readiness:
785813
readiness_diagnostics = _wait_for_ready(driver, percy_config, kwargs)
814+
# PER-8053: deep-merge the global .percy.yml snapshot config into the
815+
# per-call serialize options (per-call wins) BEFORE serialize, so config-only
816+
# keys reach PercyDOM.serialize. Readiness ran above on the raw per-call
817+
# kwargs, so any `readiness` pulled in from config here is harmless — it's
818+
# stripped next. The merged kwargs also feed the CORS-iframe context below
819+
# (`serialize_options`), so config reaches nested-frame serialize too.
820+
kwargs = _merge_config_into_serialize_options(percy_config, kwargs)
786821
# Strip `readiness` from kwargs before forwarding — it's an SDK-local
787822
# concern; the CLI already has it from healthcheck and a top-level
788823
# `readiness` in the POST body is brittle against future validators.

tests/test_snapshot_units.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
fallback paths. These run as plain unittests (no Percy CLI needed) and
44
target branches the end-to-end suite in test_snapshot.py does not reach."""
55
import importlib
6+
import json
67
import os
78
import unittest
89
from unittest.mock import patch, MagicMock, Mock
@@ -58,6 +59,65 @@ def test_iframe_origin_error_is_skipped(self, _mock_origin):
5859
self.assertNotIn('corsIframes', result)
5960

6061

62+
class TestConfigMergeIntoSerialize(unittest.TestCase):
63+
"""PER-8053: the global .percy.yml `snapshot` config must be deep-merged
64+
into the per-call serialize options (per-call wins at the leaves) BEFORE
65+
PercyDOM.serialize. Regression guard: a CORS-iframe refactor once dropped
66+
this merge so config-only keys never reached serialize."""
67+
68+
def _capture_serialize(self, percy_config, **kwargs):
69+
"""Run get_serialized_dom against a stub driver and return the exact
70+
options object handed to PercyDOM.serialize."""
71+
captured = {}
72+
driver = MagicMock()
73+
74+
def _exec(script, *_args):
75+
if 'PercyDOM.serialize' in script:
76+
start = script.index('serialize(') + len('serialize(')
77+
end = script.rindex(')')
78+
captured['opts'] = json.loads(script[start:end])
79+
return {'html': '<html></html>'}
80+
return None
81+
82+
driver.execute_script.side_effect = _exec
83+
local.get_serialized_dom(driver, {'c': 1}, percy_config=percy_config, **kwargs)
84+
return captured['opts']
85+
86+
def test_config_only_key_reaches_serialize(self):
87+
opts = self._capture_serialize({'snapshot': {'enableJavaScript': True}})
88+
self.assertTrue(opts.get('enableJavaScript'))
89+
90+
def test_per_call_option_wins_over_config(self):
91+
opts = self._capture_serialize(
92+
{'snapshot': {'percyCSS': 'from-config'}}, percyCSS='from-call')
93+
self.assertEqual(opts['percyCSS'], 'from-call')
94+
95+
def test_deep_merge_keeps_sibling_and_per_call_nested_wins(self):
96+
opts = self._capture_serialize(
97+
{'snapshot': {'discovery': {'networkIdleTimeout': 50, 'disableCache': False}}},
98+
discovery={'disableCache': True})
99+
self.assertEqual(opts['discovery']['networkIdleTimeout'], 50) # config sibling kept
100+
self.assertTrue(opts['discovery']['disableCache']) # per-call nested wins
101+
102+
def test_no_config_leaves_per_call_untouched(self):
103+
opts = self._capture_serialize(None, percyCSS='only-call')
104+
self.assertEqual(opts, {'percyCSS': 'only-call'})
105+
106+
# --- helper defensive branches (called directly: these degrade to the raw
107+
# per-call kwargs rather than raising, for any malformed config shape) ---
108+
def test_merge_helper_non_dict_config_returns_kwargs(self):
109+
self.assertEqual(
110+
local._merge_config_into_serialize_options('nope', {'a': 1}), {'a': 1})
111+
112+
def test_merge_helper_non_dict_snapshot_returns_kwargs(self):
113+
self.assertEqual(
114+
local._merge_config_into_serialize_options({'snapshot': 'x'}, {'a': 1}), {'a': 1})
115+
116+
def test_merge_helper_empty_snapshot_returns_kwargs(self):
117+
self.assertEqual(
118+
local._merge_config_into_serialize_options({'snapshot': {}}, {'a': 1}), {'a': 1})
119+
120+
61121
class TestGetResponsiveWidths(unittest.TestCase):
62122
@patch('percy.snapshot.requests.get')
63123
def test_non_list_widths_raises_upgrade_hint(self, mock_get):

0 commit comments

Comments
 (0)