diff --git a/percy/snapshot.py b/percy/snapshot.py index cb63685..0860075 100644 --- a/percy/snapshot.py +++ b/percy/snapshot.py @@ -94,6 +94,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, @@ -995,19 +1003,23 @@ def percy_snapshot(driver, name, **kwargs): expose_closed_shadow_roots(driver) cookies = driver.get_cookies() + # Merge .percy.yml config options with snapshot options (snapshot options take priority) + config_options = data['config'].get('snapshot') or {} + 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( 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_dom_script=percy_dom_script, - percy_config=data.get('config'), **kwargs) + percy_config=data.get('config'), **merged_kwargs) # Strip SDK-local `readiness` from the snapshot POST body. The CLI # already has it via healthcheck; sending it again here risks future diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py index 37e258e..20d50da 100644 --- a/tests/test_snapshot.py +++ b/tests/test_snapshot.py @@ -498,6 +498,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 @@ -634,6 +687,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):