From 7cf477e229c302226e938ed19ec6a90d7503b223 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Wed, 6 May 2026 00:49:58 +0530 Subject: [PATCH 1/5] 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/screenshot.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/percy/screenshot.py b/percy/screenshot.py index dff7779..717e48b 100644 --- a/percy/screenshot.py +++ b/percy/screenshot.py @@ -494,15 +494,19 @@ def percy_snapshot(page, name, **kwargs): page.evaluate(percy_dom_script) cookies = page.context.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( - page, cookies, percy_dom_script, config=data["config"], **kwargs + page, cookies, percy_dom_script, config=data["config"], **merged_kwargs ) else: dom_snapshot = get_serialized_dom( page, cookies, percy_dom_script, - percy_config=data.get("config"), **kwargs) + percy_config=data.get("config"), **merged_kwargs) # Strip `readiness` from POST body — SDK-local config that the CLI # already has via healthcheck. From 4bf72e0a6a9fb684cc0db52675de57c8c88e569f Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Tue, 16 Jun 2026 20:28:56 +0530 Subject: [PATCH 2/5] test: cover .percy.yml config <-> per-snapshot merge precedence Ref: PER-8053 --- tests/test_screenshot.py | 53 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/test_screenshot.py b/tests/test_screenshot.py index 48191e0..a6e6f3f 100644 --- a/tests/test_screenshot.py +++ b/tests/test_screenshot.py @@ -534,6 +534,59 @@ def test_percy_snapshot_includes_cookies( posted["dom_snapshot"]["cookies"], [{"name": "foo", "value": "bar"}] ) + @patch("requests.post") + @patch("percy.screenshot.fetch_percy_dom") + @patch("percy.screenshot._is_percy_enabled") + def test_percy_snapshot_merges_config_with_per_call_options( + self, mock_is_percy_enabled, mock_fetch_percy_dom, mock_post + ): + """.percy.yml config <-> per-snapshot merge precedence: + config-only keys (enableJavaScript) flow through to PercyDOM.serialize, + and per-call keys (percyCSS) override the config value.""" + mock_is_percy_enabled.return_value = { + "session_type": "web", + "config": { + "snapshot": { + "enableJavaScript": True, + "percyCSS": "FROM_CONFIG", + } + }, + "widths": {}, + "device_details": [], + } + mock_fetch_percy_dom.return_value = "some_js_code" + + # Capture the args passed to the PercyDOM.serialize evaluate call. + serialize_calls = [] + + def evaluate_side_effect(script, *args): + if isinstance(script, str) and "PercyDOM.serialize(" in script: + payload = script[len("PercyDOM.serialize("):-1] + serialize_calls.append(json.loads(payload)) + return {"html": ""} + return None + + page = MagicMock() + page.evaluate.side_effect = evaluate_side_effect + page.context.cookies.return_value = [] + page.frames = [] + page.url = "http://example.com" + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = { + "success": True, + "data": "snapshot_data", + } + + # per-call percyCSS must win over the config value + percy_snapshot(page, "snapshot_name", percyCSS="FROM_CALL") + + self.assertEqual(len(serialize_calls), 1) + serialized_args = serialize_calls[0] + # config-only key flows through + self.assertEqual(serialized_args["enableJavaScript"], True) + # per-call key wins over config + self.assertEqual(serialized_args["percyCSS"], "FROM_CALL") + def test_process_frame_returns_cors_iframe_data(self): page = MagicMock() page.evaluate.return_value = {"percyElementId": "iframe-1"} From 468c5dbd50b3072e0f7f8eac1c2b4b4ee699a7b5 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Tue, 16 Jun 2026 20:47:32 +0530 Subject: [PATCH 3/5] fix: guard null snapshot config; update responsive test for merged options Ref: PER-8053 --- percy/screenshot.py | 2 +- tests/test_screenshot.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/percy/screenshot.py b/percy/screenshot.py index 717e48b..340ff66 100644 --- a/percy/screenshot.py +++ b/percy/screenshot.py @@ -495,7 +495,7 @@ def percy_snapshot(page, name, **kwargs): cookies = page.context.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 diff --git a/tests/test_screenshot.py b/tests/test_screenshot.py index a6e6f3f..0b2cf54 100644 --- a/tests/test_screenshot.py +++ b/tests/test_screenshot.py @@ -769,6 +769,7 @@ def test_percy_snapshot_responsive_capture( [{"name": "foo", "value": "bar"}], "some_js_code", config={"snapshot": {"responsiveSnapshotCapture": True}}, + responsiveSnapshotCapture=True, ) posted = mock_post.call_args.kwargs["json"] self.assertEqual(posted["dom_snapshot"], mock_capture_responsive_dom.return_value) From 712f97f7a1323b6176e5ba9555d34792a396a692 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Tue, 16 Jun 2026 21:02:18 +0530 Subject: [PATCH 4/5] chore: silence pylint unused-argument in test (lint clean) Ref: PER-8053 --- tests/test_screenshot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_screenshot.py b/tests/test_screenshot.py index 0b2cf54..93d795c 100644 --- a/tests/test_screenshot.py +++ b/tests/test_screenshot.py @@ -559,7 +559,7 @@ def test_percy_snapshot_merges_config_with_per_call_options( # Capture the args passed to the PercyDOM.serialize evaluate call. serialize_calls = [] - def evaluate_side_effect(script, *args): + def evaluate_side_effect(script, *_args): if isinstance(script, str) and "PercyDOM.serialize(" in script: payload = script[len("PercyDOM.serialize("):-1] serialize_calls.append(json.loads(payload)) From a7f512f5d5f8cf1659cd8d51da5663911db613f9 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Wed, 17 Jun 2026 00:38:27 +0530 Subject: [PATCH 5/5] feat: deep-merge .percy.yml config with per-snapshot options Ref: PER-8053 --- percy/screenshot.py | 17 +++++++++- tests/test_screenshot.py | 73 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/percy/screenshot.py b/percy/screenshot.py index 340ff66..72fd711 100644 --- a/percy/screenshot.py +++ b/percy/screenshot.py @@ -153,6 +153,21 @@ def process_frame(page, frame, options, percy_dom_script): return None +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 _resolve_readiness_config(percy_config, kwargs): """Shallow-merge global (percy_config.snapshot.readiness) and per-snapshot (kwargs['readiness']) readiness config. Per-snapshot keys win; unspecified @@ -496,7 +511,7 @@ def percy_snapshot(page, 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_screenshot.py b/tests/test_screenshot.py index 93d795c..ba933fb 100644 --- a/tests/test_screenshot.py +++ b/tests/test_screenshot.py @@ -27,6 +27,7 @@ log, _resolve_readiness_config, _wait_for_ready, + _deep_merge, ) import percy.screenshot as local @@ -304,6 +305,26 @@ class TestReadinessGate(unittest.TestCase): fully-mocked Page. Bypasses real Playwright CDP traffic, so cannot hang on real in-page observers like the integration-style tests did.""" + def test_deep_merge_recurses_nested_dicts(self): + merged = _deep_merge( + {"discovery": {"networkIdleTimeout": 50, "disableCache": False}}, + {"discovery": {"disableCache": True}}, + ) + self.assertEqual( + merged, + {"discovery": {"networkIdleTimeout": 50, "disableCache": True}}, + ) + + def test_deep_merge_replaces_lists_and_scalars(self): + merged = _deep_merge( + {"widths": [375, 1280], "name": "a", "discovery": {"x": 1}}, + {"widths": [800], "name": "b", "discovery": 5}, + ) + self.assertEqual( + merged, + {"widths": [800], "name": "b", "discovery": 5}, + ) + def test_resolve_readiness_config_shallow_merges(self): merged = _resolve_readiness_config( {'snapshot': {'readiness': {'preset': 'balanced', 'timeoutMs': 8000}}}, @@ -587,6 +608,58 @@ def evaluate_side_effect(script, *_args): # per-call key wins over config self.assertEqual(serialized_args["percyCSS"], "FROM_CALL") + @patch("requests.post") + @patch("percy.screenshot.fetch_percy_dom") + @patch("percy.screenshot._is_percy_enabled") + def test_percy_snapshot_deep_merges_nested_config( + self, mock_is_percy_enabled, mock_fetch_percy_dom, mock_post + ): + """Nested .percy.yml config <-> per-call merge must DEEP-merge: a per-call + override of one nested key must not wipe out sibling keys from config.""" + mock_is_percy_enabled.return_value = { + "session_type": "web", + "config": { + "snapshot": { + "discovery": { + "networkIdleTimeout": 50, + "disableCache": False, + } + } + }, + "widths": {}, + "device_details": [], + } + mock_fetch_percy_dom.return_value = "some_js_code" + + serialize_calls = [] + + def evaluate_side_effect(script, *_args): + if isinstance(script, str) and "PercyDOM.serialize(" in script: + payload = script[len("PercyDOM.serialize("):-1] + serialize_calls.append(json.loads(payload)) + return {"html": ""} + return None + + page = MagicMock() + page.evaluate.side_effect = evaluate_side_effect + page.context.cookies.return_value = [] + page.frames = [] + page.url = "http://example.com" + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = { + "success": True, + "data": "snapshot_data", + } + + # per-call only overrides discovery.disableCache; networkIdleTimeout must survive + percy_snapshot(page, "snapshot_name", discovery={"disableCache": True}) + + self.assertEqual(len(serialize_calls), 1) + self.assertEqual( + serialize_calls[0]["discovery"], + {"networkIdleTimeout": 50, "disableCache": True}, + ) + def test_process_frame_returns_cors_iframe_data(self): page = MagicMock() page.evaluate.return_value = {"percyElementId": "iframe-1"}