From b9758c696322b13242d3a579de8be493d7fa80c0 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Wed, 6 May 2026 00:49:57 +0530 Subject: [PATCH 1/4] fix: merge .percy.yml config options with snapshot options for serializeDOM Config options from .percy.yml (like widths, minHeight, enableJavaScript, etc.) were not being passed to PercyDOM.serialize(). Only per-snapshot options were used. Now merges both, with per-snapshot options taking priority. Co-Authored-By: Claude Opus 4.6 --- percy/snapshot.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/percy/snapshot.py b/percy/snapshot.py index 5c75387..41aa527 100644 --- a/percy/snapshot.py +++ b/percy/snapshot.py @@ -500,19 +500,23 @@ def percy_snapshot(driver, name, **kwargs): driver.execute_script(percy_dom_script) cookies = driver.get_cookies() + # Merge .percy.yml config options with snapshot options (snapshot options take priority) + config_options = data['config'].get('snapshot', {}) + merged_kwargs = {**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_config=data.get('config'), - percy_dom_script=percy_dom_script, **kwargs) + percy_dom_script=percy_dom_script, **merged_kwargs) # Strip SDK-local `readiness` from the snapshot POST body. The CLI # already has it via healthcheck; sending it again here risks future From 4e94aef01dd047a2636c0bc5cf36755303fab7c7 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Tue, 16 Jun 2026 20:13:44 +0530 Subject: [PATCH 2/4] fix: guard against null snapshot config in merge (avoid TypeError) Ref: PER-8053 --- percy/snapshot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/percy/snapshot.py b/percy/snapshot.py index 41aa527..ba53776 100644 --- a/percy/snapshot.py +++ b/percy/snapshot.py @@ -501,7 +501,7 @@ def percy_snapshot(driver, name, **kwargs): cookies = driver.get_cookies() # Merge .percy.yml config options with snapshot options (snapshot options take priority) - config_options = data['config'].get('snapshot', {}) + config_options = data['config'].get('snapshot') or {} merged_kwargs = {**config_options, **kwargs} # Serialize and capture the DOM From 178d5992633dc5a7ec3522d0fe88d90cdfd31006 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Tue, 16 Jun 2026 20:29:27 +0530 Subject: [PATCH 3/4] test: cover .percy.yml config <-> per-snapshot merge precedence Ref: PER-8053 --- tests/test_snapshot.py | 47 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py index f5800e7..2e82982 100644 --- a/tests/test_snapshot.py +++ b/tests/test_snapshot.py @@ -615,6 +615,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': ''} + 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): From d2339f91a434359a77dc826709d92d13d2a93342 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Wed, 17 Jun 2026 00:40:43 +0530 Subject: [PATCH 4/4] feat: deep-merge .percy.yml config with per-snapshot options Ref: PER-8053 --- percy/snapshot.py | 10 +++++++- tests/test_snapshot.py | 53 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/percy/snapshot.py b/percy/snapshot.py index ba53776..10b4d5a 100644 --- a/percy/snapshot.py +++ b/percy/snapshot.py @@ -88,6 +88,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, @@ -502,7 +510,7 @@ def percy_snapshot(driver, name, **kwargs): # Merge .percy.yml config options with snapshot options (snapshot options take priority) config_options = data['config'].get('snapshot') or {} - merged_kwargs = {**config_options, **kwargs} + merged_kwargs = _deep_merge(config_options, kwargs) # Serialize and capture the DOM if is_responsive_snapshot_capture(data['config'], **merged_kwargs): diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py index 2e82982..b3f8669 100644 --- a/tests/test_snapshot.py +++ b/tests/test_snapshot.py @@ -479,6 +479,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