Skip to content
Open
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
25 changes: 22 additions & 3 deletions percy/screenshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -494,15 +509,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") or {}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Null-config guard branch is untested

The ... or {} guard handles config.snapshot being present-but-None, which is the stated motivation of this revision, but no test exercises that branch — only the populated-config path is covered.

Suggestion: Optionally add a case where config = {"snapshot": None} and assert percy_snapshot serializes/posts without raising. Non-blocking; the guard is a well-understood one-line idiom.

Reviewer: stack:code-review

merged_kwargs = _deep_merge(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.
Expand Down
127 changes: 127 additions & 0 deletions tests/test_screenshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
log,
_resolve_readiness_config,
_wait_for_ready,
_deep_merge,
)
import percy.screenshot as local

Expand Down Expand Up @@ -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}}},
Expand Down Expand Up @@ -534,6 +555,111 @@ 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": "<html></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")

@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": "<html></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"}
Expand Down Expand Up @@ -716,6 +842,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)
Expand Down
Loading