From c0d05b08e2c6ddc9acab07626d7d140c96784af1 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Wed, 6 May 2026 00:49:58 +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 --- lib/percy.rb | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/percy.rb b/lib/percy.rb index b733020..84e8ae2 100644 --- a/lib/percy.rb +++ b/lib/percy.rb @@ -79,10 +79,15 @@ def self.snapshot(driver, name, options = {}) begin percy_dom_script = fetch_percy_dom driver.execute_script(percy_dom_script) - dom_snapshot = if responsive_snapshot_capture?(options) - capture_responsive_dom(driver, options, percy_dom_script: percy_dom_script) + + # Merge .percy.yml config options with snapshot options (snapshot options take priority) + config_options = @cli_config&.dig('snapshot') || {} + merged_options = config_options.merge(options) + + dom_snapshot = if responsive_snapshot_capture?(merged_options) + capture_responsive_dom(driver, merged_options, percy_dom_script: percy_dom_script) else - get_serialized_dom(driver, options, percy_dom_script: percy_dom_script) + get_serialized_dom(driver, merged_options, percy_dom_script: percy_dom_script) end response = fetch('percy/snapshot', From 3a32b11389d031ba995abcc8d962640e59ba0350 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Tue, 16 Jun 2026 20:14:47 +0530 Subject: [PATCH 2/4] fix: normalize key types so per-snapshot options override config Ref: PER-8053 --- lib/percy.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/percy.rb b/lib/percy.rb index 912ad92..8f7e1aa 100644 --- a/lib/percy.rb +++ b/lib/percy.rb @@ -82,7 +82,11 @@ def self.snapshot(driver, name, options = {}) # Merge .percy.yml config options with snapshot options (snapshot options take priority) config_options = @cli_config&.dig('snapshot') || {} - merged_options = config_options.merge(options) + # Config keys are strings (JSON parse); per-call options use symbols, as + # do downstream consumers (responsive_snapshot_capture?, capture_responsive_dom). + # Symbolize config keys so per-call options override matching config keys + # instead of producing duplicate logical keys (per-call options take priority). + merged_options = config_options.transform_keys(&:to_sym).merge(options) dom_snapshot = if responsive_snapshot_capture?(merged_options) capture_responsive_dom(driver, merged_options, percy_dom_script: percy_dom_script) From 93e11f7285c3bfa21d2f024ea65177599e23e379 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Tue, 16 Jun 2026 20:30:50 +0530 Subject: [PATCH 3/4] test: cover .percy.yml config <-> per-snapshot merge precedence Ref: PER-8053 --- spec/lib/percy/percy_spec.rb | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/spec/lib/percy/percy_spec.rb b/spec/lib/percy/percy_spec.rb index 975558f..381934e 100644 --- a/spec/lib/percy/percy_spec.rb +++ b/spec/lib/percy/percy_spec.rb @@ -297,6 +297,45 @@ expect(data).to eq('sync_data') end + + it 'merges .percy.yml config with per-snapshot options (per-call wins)' do + # Healthcheck returns a config whose `snapshot` block carries a + # config-only key (enableJavaScript) and a percyCSS value that the + # per-snapshot call will override. + stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/healthcheck") + .to_return( + status: 200, + body: { + success: true, + config: {'snapshot' => {'enableJavaScript' => true, 'percyCSS' => 'FROM_CONFIG'}}, + }.to_json, + headers: {'x-percy-core-version': '1.0.0'}, + ) + + stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/dom.js") + .to_return(status: 200, body: fetch_script_string, headers: {}) + + stub_request(:post, 'http://localhost:5338/percy/snapshot') + .to_return(status: 200, body: '{"success":true}', headers: {}) + + # Capture the argument passed to PercyDOM.serialize so we can assert how + # config and per-call options were merged before serialization. + captured_serialize_call = nil + allow(page).to receive(:execute_script).and_wrap_original do |original, script, *args| + captured_serialize_call = script if script.to_s.include?('PercyDOM.serialize') + original.call(script, *args) + end + + visit 'index.html' + Percy.snapshot(page, 'Name', percyCSS: 'FROM_CALL') + + expect(captured_serialize_call).to_not be_nil + serialized = JSON.parse(captured_serialize_call[/PercyDOM\.serialize\((.*)\)/m, 1]) + # Config-only key still reaches serialize... + expect(serialized['enableJavaScript']).to eq(true) + # ...and the per-call option wins over the config value. + expect(serialized['percyCSS']).to eq('FROM_CALL') + end end end From 49a19d1ab029777582cbbb9d21b4a742796658c5 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Wed, 17 Jun 2026 00:50:31 +0530 Subject: [PATCH 4/4] feat: deep-merge .percy.yml config with per-snapshot options Ref: PER-8053 --- lib/percy.rb | 26 ++++++++++++++++-- spec/lib/percy/percy_spec.rb | 53 +++++++++++++++++++++++++++++++++++- spec/spec_helper.rb | 21 ++++++++++++-- 3 files changed, 94 insertions(+), 6 deletions(-) diff --git a/lib/percy.rb b/lib/percy.rb index 8f7e1aa..10ce2e5 100644 --- a/lib/percy.rb +++ b/lib/percy.rb @@ -66,6 +66,25 @@ def self.create_region( region end + # Recursively convert all Hash keys (at every nesting level) to symbols so + # config (string keys from JSON) and per-call options (symbol keys) merge on + # consistent keys. Arrays are walked; scalars are returned as-is. + def self.deep_symbolize(obj) + case obj + when Hash then obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) } + when Array then obj.map { |e| deep_symbolize(e) } + else obj + end + end + + # Deep-merge `override` onto `base`: nested Hashes merge recursively, while + # arrays and scalars from `override` replace those in `base`. + def self.deep_merge_options(base, override) + base.merge(override) do |_key, old_val, new_val| + old_val.is_a?(Hash) && new_val.is_a?(Hash) ? deep_merge_options(old_val, new_val) : new_val + end + end + def self.snapshot(driver, name, options = {}) return unless percy_enabled? @@ -84,9 +103,10 @@ def self.snapshot(driver, name, options = {}) config_options = @cli_config&.dig('snapshot') || {} # Config keys are strings (JSON parse); per-call options use symbols, as # do downstream consumers (responsive_snapshot_capture?, capture_responsive_dom). - # Symbolize config keys so per-call options override matching config keys - # instead of producing duplicate logical keys (per-call options take priority). - merged_options = config_options.transform_keys(&:to_sym).merge(options) + # Deep-symbolize both sides so nested keys are consistent, then deep-merge + # so nested Hashes merge recursively (per-call wins at leaves; arrays/scalars + # replace) instead of a shallow top-level overwrite dropping config siblings. + merged_options = deep_merge_options(deep_symbolize(config_options), deep_symbolize(options)) dom_snapshot = if responsive_snapshot_capture?(merged_options) capture_responsive_dom(driver, merged_options, percy_dom_script: percy_dom_script) diff --git a/spec/lib/percy/percy_spec.rb b/spec/lib/percy/percy_spec.rb index 381934e..8aaa41e 100644 --- a/spec/lib/percy/percy_spec.rb +++ b/spec/lib/percy/percy_spec.rb @@ -240,7 +240,18 @@ {status: 200, body: '{"success":true}', headers: {}} end - driver = Selenium::WebDriver.for :firefox + driver = + if ENV['CHROME_BIN'] && !ENV['CHROME_BIN'].empty? + chrome_opts = Selenium::WebDriver::Chrome::Options.new + chrome_opts.binary = ENV['CHROME_BIN'] + chrome_opts.add_argument('--headless=new') + chrome_opts.add_argument('--no-sandbox') + chrome_opts.add_argument('--disable-gpu') + chrome_opts.add_argument('--disable-dev-shm-usage') + Selenium::WebDriver.for :chrome, options: chrome_opts + else + Selenium::WebDriver.for :firefox + end begin # Use the Capybara fixture server (already running for this describe block) # instead of the percy test-mode server endpoint which is not available under @@ -336,6 +347,46 @@ # ...and the per-call option wins over the config value. expect(serialized['percyCSS']).to eq('FROM_CALL') end + + it 'deep-merges nested config and per-snapshot options (sibling kept, leaf overridden)' do + # Config `snapshot` block carries a nested `discovery` hash; the per-call + # discovery only overrides one leaf, so the sibling key must survive. + stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/healthcheck") + .to_return( + status: 200, + body: { + success: true, + config: { + 'snapshot' => { + 'discovery' => {'networkIdleTimeout' => 50, 'disableCache' => false}, + }, + }, + }.to_json, + headers: {'x-percy-core-version': '1.0.0'}, + ) + + stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/dom.js") + .to_return(status: 200, body: fetch_script_string, headers: {}) + + stub_request(:post, 'http://localhost:5338/percy/snapshot') + .to_return(status: 200, body: '{"success":true}', headers: {}) + + captured_serialize_call = nil + allow(page).to receive(:execute_script).and_wrap_original do |original, script, *args| + captured_serialize_call = script if script.to_s.include?('PercyDOM.serialize') + original.call(script, *args) + end + + visit 'index.html' + Percy.snapshot(page, 'Name', discovery: {disableCache: true}) + + expect(captured_serialize_call).to_not be_nil + serialized = JSON.parse(captured_serialize_call[/PercyDOM\.serialize\((.*)\)/m, 1]) + # Sibling from config survives; per-call leaf overrides the config value. + expect(serialized['discovery']).to eq( + 'networkIdleTimeout' => 50, 'disableCache' => true, + ) + end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 070742f..4886cf0 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -35,8 +35,25 @@ Kernel.srand config.seed # See https://github.com/teamcapybara/capybara#selecting-the-driver for other options - Capybara.default_driver = :selenium_headless - Capybara.javascript_driver = :selenium_headless + # Default to Firefox headless (matches CI), but when a Chromium/Chrome binary is + # provided via CHROME_BIN (e.g. the containerised e2e image), register and use a + # headless Chrome driver pointing at it instead. + if ENV['CHROME_BIN'] && !ENV['CHROME_BIN'].empty? + Capybara.register_driver :selenium_chrome_headless_bin do |app| + options = Selenium::WebDriver::Chrome::Options.new + options.binary = ENV['CHROME_BIN'] + options.add_argument('--headless=new') + options.add_argument('--no-sandbox') + options.add_argument('--disable-gpu') + options.add_argument('--disable-dev-shm-usage') + Capybara::Selenium::Driver.new(app, browser: :chrome, options: options) + end + Capybara.default_driver = :selenium_chrome_headless_bin + Capybara.javascript_driver = :selenium_chrome_headless_bin + else + Capybara.default_driver = :selenium_headless + Capybara.javascript_driver = :selenium_headless + end # Setup for Capybara to test Jekyll static files served by Rack Capybara.server_port = 3003