From f50ac6514d5710e031139e09b5de493092cd2437 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Fri, 10 Jul 2026 19:36:44 +0530 Subject: [PATCH] fix: merge .percy.yml config into serialize options (PER-8053) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- percy/snapshot.py | 35 +++++++++++++++++++++ tests/test_snapshot_units.py | 60 ++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/percy/snapshot.py b/percy/snapshot.py index cb63685..dfacfeb 100644 --- a/percy/snapshot.py +++ b/percy/snapshot.py @@ -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. @@ -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. diff --git a/tests/test_snapshot_units.py b/tests/test_snapshot_units.py index 12492d8..dd57d07 100644 --- a/tests/test_snapshot_units.py +++ b/tests/test_snapshot_units.py @@ -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 @@ -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': ''} + 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):