From bf988110a9adc3d6ae5d7a09f7ef232e167b3f59 Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Mon, 20 Apr 2026 11:31:57 +0530 Subject: [PATCH] feat: PER-7348 add waitForReady() call before serialize() Adds the readiness gate from percy/cli#2184. New wait_for_ready() helper runs PercyDOM.waitForReady via driver.execute_async_script (callback signal) before the existing PercyDOM.serialize execute_script inside get_serialized_dom. The return value is attached to the domSnapshot hash as 'readiness_diagnostics'. serialize is unchanged. Config precedence: options[:readiness] (or 'readiness') > @cli_config.dig('snapshot','readiness') > {} (CLI applies balanced default). Backward compat via in-browser typeof guard. Disabled preset skips the execute_async_script. Graceful on any StandardError. Tests (RSpec): happy path (execute_async_script called with waitForReady + typeof guard, diagnostics on snapshot), per-snapshot config embedded, disabled preset skips execute_async_script, and execute_async_script raising leaves serialize intact. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/percy.rb | 36 ++++++++++++++++++++++++ spec/lib/percy/percy_spec.rb | 53 ++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/lib/percy.rb b/lib/percy.rb index b733020..74ace58 100644 --- a/lib/percy.rb +++ b/lib/percy.rb @@ -113,8 +113,44 @@ def self.get_browser_instance(driver) driver.manage end + # Readiness gate (PER-7348): runs PercyDOM.waitForReady via + # execute_async_script BEFORE serialize. Graceful on old CLIs that lack the + # method. Returns readiness diagnostics (or nil) for attachment to domSnapshot. + # + # Config precedence: options[:readiness] / options['readiness'] > + # @cli_config.snapshot.readiness > {} (CLI applies balanced default). + # preset='disabled' skips the script call entirely. + def self.wait_for_ready(driver, options) + readiness_config = options[:readiness] || options['readiness'] + if readiness_config.nil? + readiness_config = @cli_config&.dig('snapshot', 'readiness') || {} + end + return nil if readiness_config.is_a?(Hash) && readiness_config['preset'] == 'disabled' + return nil if readiness_config.is_a?(Hash) && readiness_config[:preset] == 'disabled' + begin + script = <<~JS + var cfg = #{readiness_config.to_json}; + var done = arguments[arguments.length - 1]; + try { + if (typeof PercyDOM !== 'undefined' && typeof PercyDOM.waitForReady === 'function') { + PercyDOM.waitForReady(cfg).then(function(r){ done(r); }).catch(function(){ done(); }); + } else { done(); } + } catch (e) { done(); } + JS + driver.execute_async_script(script) + rescue StandardError => e + log("waitForReady failed, proceeding to serialize: #{e}", 'debug') + nil + end + end + def self.get_serialized_dom(driver, options, percy_dom_script: nil) + # Readiness gate before serialize (PER-7348). Graceful on old CLI. + readiness_diagnostics = wait_for_ready(driver, options) dom_snapshot = driver.execute_script("return PercyDOM.serialize(#{options.to_json})") + if readiness_diagnostics && dom_snapshot.is_a?(Hash) + dom_snapshot['readiness_diagnostics'] = readiness_diagnostics + end begin page_origin = get_origin(driver.current_url) iframes = percy_dom_script ? driver.find_elements(:tag_name, 'iframe') : [] diff --git a/spec/lib/percy/percy_spec.rb b/spec/lib/percy/percy_spec.rb index c97f243..c677460 100644 --- a/spec/lib/percy/percy_spec.rb +++ b/spec/lib/percy/percy_spec.rb @@ -748,6 +748,59 @@ expect(dom['corsIframes'].length).to eq(1) expect(dom['corsIframes'][0]['frameUrl']).to eq('https://other.example.com/page') end + + # --- Readiness gate (PER-7348) -------------------------------------- + + it 'runs waitForReady before serialize and attaches diagnostics' do + allow(driver).to receive(:execute_async_script).and_return( + 'ok' => true, 'timed_out' => false + ) + allow(driver).to receive(:execute_script).and_return({'html' => ''}) + allow(driver).to receive(:current_url).and_return('http://main.example.com/') + allow(driver).to receive(:find_elements).and_return([]) + + dom = Percy.get_serialized_dom(driver, {}) + expect(driver).to have_received(:execute_async_script) do |script| + expect(script).to include('waitForReady') + expect(script).to include("typeof PercyDOM.waitForReady === 'function'") + end + expect(dom['readiness_diagnostics']).to eq('ok' => true, 'timed_out' => false) + end + + it 'embeds per-snapshot readiness config in the script' do + allow(driver).to receive(:execute_async_script).and_return(nil) + allow(driver).to receive(:execute_script).and_return({'html' => ''}) + allow(driver).to receive(:current_url).and_return('http://main.example.com/') + allow(driver).to receive(:find_elements).and_return([]) + + Percy.get_serialized_dom(driver, { readiness: { preset: 'strict', stabilityWindowMs: 500 } }) + expect(driver).to have_received(:execute_async_script) do |script| + expect(script).to include('"preset":"strict"') + expect(script).to include('"stabilityWindowMs":500') + end + end + + it 'skips execute_async_script when preset is disabled' do + allow(driver).to receive(:execute_script).and_return({'html' => ''}) + allow(driver).to receive(:current_url).and_return('http://main.example.com/') + allow(driver).to receive(:find_elements).and_return([]) + expect(driver).to_not receive(:execute_async_script) + + dom = Percy.get_serialized_dom(driver, { readiness: { preset: 'disabled' } }) + expect(dom).to_not have_key('readiness_diagnostics') + expect(dom['html']).to eq('') + end + + it 'still serializes when execute_async_script raises' do + allow(driver).to receive(:execute_async_script).and_raise(StandardError, 'readiness boom') + allow(driver).to receive(:execute_script).and_return({'html' => ''}) + allow(driver).to receive(:current_url).and_return('http://main.example.com/') + allow(driver).to receive(:find_elements).and_return([]) + + dom = Percy.get_serialized_dom(driver, {}) + expect(dom).to_not have_key('readiness_diagnostics') + expect(dom['html']).to eq('') + end end describe '.change_window_dimension_and_wait' do