diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0cdd44e..ac08895 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,14 @@ jobs: matrix: os: [ubuntu-latest] ruby: ['2.6', '2.7'] + selenium: [''] + include: + - os: ubuntu-latest + ruby: '3.3' + selenium: '4.50.0' runs-on: ${{ matrix.os }} + env: + SELENIUM_VERSION: ${{ matrix.selenium }} steps: - uses: actions-ecosystem/action-regex-match@v2 id: regex-match @@ -35,8 +42,8 @@ jobs: - uses: actions/cache@v3 with: path: "./vendor/bundle" - key: v1/${{ runner.os }}/ruby-${{ matrix.ruby }}/${{ hashFiles('**/Gemfile.lock') }} - restore-keys: v1/${{ runner.os }}/ruby-${{ matrix.ruby }}/ + key: v1/${{ runner.os }}/ruby-${{ matrix.ruby }}-selenium-${{ matrix.selenium }}/${{ hashFiles('**/Gemfile.lock') }} + restore-keys: v1/${{ runner.os }}/ruby-${{ matrix.ruby }}-selenium-${{ matrix.selenium }}/ - uses: actions/setup-node@v3 with: node-version: 16 diff --git a/Gemfile b/Gemfile index ef9a494..4f87e5b 100644 --- a/Gemfile +++ b/Gemfile @@ -3,6 +3,10 @@ source "https://rubygems.org" # Specify your gem's dependencies in percy-selenium.gemspec gemspec +# Lets CI certify a specific Selenium release (e.g. SELENIUM_VERSION=4.50.0). +selenium_version = ENV.fetch("SELENIUM_VERSION", "") +gem "selenium-webdriver", selenium_version unless selenium_version.empty? + gem "guard-rspec", require: false group :test, :development do diff --git a/lib/percy.rb b/lib/percy.rb index 10ce2e5..605a729 100644 --- a/lib/percy.rb +++ b/lib/percy.rb @@ -18,6 +18,11 @@ module Percy RESPONSIVE_CAPTURE_SLEEP_TIME = ENV['RESPONSIVE_CAPTURE_SLEEP_TIME'] || ENV['RESONSIVE_CAPTURE_SLEEP_TIME'] + CHROMIUM_BROWSERS = %w[chrome chromium msedge microsoftedge chrome-headless-shell].freeze + NON_CDP_BROWSERS = %w[firefox safari].freeze + CLOSED_SHADOW_MAX_HOSTS = 200 + LOCAL_HOSTS = %w[localhost 127.0.0.1 ::1].freeze + def self.responsive_capture_reload_page? val = ENV['PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE'] || ENV['PERCY_RESONSIVE_CAPTURE_RELOAD_PAGE'] || 'false' @@ -138,11 +143,7 @@ def self.snapshot(driver, name, options = {}) end def self.get_browser_instance(driver) - if driver.respond_to?(:driver) && driver.driver.respond_to?(:browser) - return driver.driver.browser.manage - end - - driver.manage + native_driver(driver).manage end # Shallow-merge of global (@cli_config.snapshot.readiness) and per-snapshot @@ -212,6 +213,7 @@ def self.get_serialized_dom(driver, options, percy_dom_script: nil) # Strip `readiness` from forwarded serialize args -- it's consumed by # wait_for_ready upstream, not a PercyDOM.serialize argument. serialize_options = options.reject { |k, _| k.to_s == 'readiness' } + expose_closed_shadow_roots(driver, options) dom_snapshot = driver.execute_script("return PercyDOM.serialize(#{serialize_options.to_json})") # `!nil?` preserves legitimate falsy returns like {} ("gate ran, no # notable diagnostics"). @@ -254,6 +256,190 @@ def self.get_serialized_dom(driver, options, percy_dom_script: nil) dom_snapshot end + # @percy/dom reads closed shadow roots from window.__percyClosedShadowRoots + # (a WeakMap host -> root) because page JS cannot reach them. Fill it with the + # first strategy the session supports: CDP, then BiDi, then classic WebDriver. + def self.expose_closed_shadow_roots(driver, options = {}) + return if options[:disableShadowDOM] || options['disableShadowDOM'] + + native = native_driver(driver) + return if chromium_with_cdp?(native) && expose_closed_shadow_roots_cdp(native) + return if bidi_session?(native) && expose_closed_shadow_roots_bidi(native) + + expose_closed_shadow_roots_classic(native) if closed_shadow_classic_allowed?(native) + rescue StandardError => e + log("Could not expose closed shadow roots: #{e}", 'warn') + end + + CLOSED_SHADOW_SET_FN = 'function(shadowRoot) { ' \ + 'window.__percyClosedShadowRoots = window.__percyClosedShadowRoots || new WeakMap(); ' \ + 'window.__percyClosedShadowRoots.set(this, shadowRoot); }'.freeze + + def self.expose_closed_shadow_roots_cdp(native) + native.execute_cdp('DOM.enable') + root = native.execute_cdp('DOM.getDocument', depth: -1, pierce: true)&.dig('root') + return false if root.nil? + + closed_shadow_pairs(root).each do |host_id, shadow_id| + host = native.execute_cdp('DOM.resolveNode', backendNodeId: host_id).dig('object', 'objectId') + shadow = native.execute_cdp('DOM.resolveNode', backendNodeId: shadow_id) + .dig('object', 'objectId') + next unless host && shadow + + native.execute_cdp( + 'Runtime.callFunctionOn', + functionDeclaration: CLOSED_SHADOW_SET_FN, objectId: host, arguments: [{objectId: shadow}], + ) + end + true + rescue StandardError => e + log("Could not expose closed shadow roots via CDP: #{e}", 'debug') + false + end + + # Child frame documents are skipped: their closed roots would need the + # WeakMap in the frame's own realm. + def self.closed_shadow_pairs(node, pairs = []) + return pairs if node.nil? || node['contentDocument'] + + (node['shadowRoots'] || []).each do |shadow| + if shadow['shadowRootType'] == 'closed' + pairs << [node['backendNodeId'], shadow['backendNodeId']] + end + closed_shadow_pairs(shadow, pairs) + end + (node['children'] || []).each { |child| closed_shadow_pairs(child, pairs) } + pairs + end + + # Open roots are walked in-page; each closed root found needs another call + # because page JS cannot see into it. + BIDI_SHADOW_CANDIDATES_FN = '(root) => { const out = []; ' \ + 'const hostable = ' \ + '/^(article|aside|blockquote|body|div|footer|h[1-6]|header|main|nav|p|section|span)$/; ' \ + 'const walk = (r) => { for (const e of r.querySelectorAll("*")) { ' \ + 'if (e.shadowRoot) walk(e.shadowRoot); ' \ + 'else if (e.localName.includes("-") || hostable.test(e.localName)) out.push(e); } }; ' \ + 'walk(root || document); return out; }'.freeze + BIDI_SHADOW_SET_FN = '(pairs) => { ' \ + 'const m = window.__percyClosedShadowRoots = ' \ + 'window.__percyClosedShadowRoots || new WeakMap(); ' \ + 'for (const [h, s] of pairs) m.set(h, s); }'.freeze + + # Selenium Ruby has no typed script module, so this uses the raw BiDi command. + def self.expose_closed_shadow_roots_bidi(native) + context = native.window_handle + scopes = [nil] + exposed = 0 + until scopes.empty? + scope = scopes.shift + args = scope ? [{sharedId: scope}] : [] + nodes = bidi_call_function(native, context, BIDI_SHADOW_CANDIDATES_FN, args)['value'] || [] + pairs = nodes.map do |node| + shadow = node.dig('value', 'shadowRoot') + [node['sharedId'], shadow['sharedId']] if shadow && shadow.dig('value', 'mode') == 'closed' + end.compact + next if pairs.empty? + + refs = pairs.map do |host, shadow| + {type: 'array', value: [{sharedId: host}, {sharedId: shadow}]} + end + bidi_call_function(native, context, BIDI_SHADOW_SET_FN, [{type: 'array', value: refs}]) + exposed += pairs.length + break if exposed >= closed_shadow_max_hosts + + scopes.concat(pairs.map(&:last)) + end + true + rescue StandardError => e + log("Could not expose closed shadow roots via BiDi: #{e}", 'debug') + false + end + + def self.bidi_call_function(native, context, function, args) + response = native.bidi.send_cmd( + 'script.callFunction', + functionDeclaration: function, awaitPromise: false, target: {context: context}, + arguments: args, resultOwnership: 'none', + serializationOptions: {maxDomDepth: 0, includeShadowTree: 'all'}, + ) + unless response['type'] == 'success' + raise StandardError, "script.callFunction #{response['type']}" + end + + response['result'] || {} + end + + CLASSIC_SHADOW_CANDIDATES_JS = 'const out = []; ' \ + 'const walk = (r) => { for (const e of r.querySelectorAll("*")) { ' \ + 'if (e.shadowRoot) walk(e.shadowRoot); else if (e.localName.includes("-")) out.push(e); } }; ' \ + 'walk(arguments[0] || document); return out;'.freeze + CLASSIC_SHADOW_SET_JS = '(window.__percyClosedShadowRoots = ' \ + 'window.__percyClosedShadowRoots || new WeakMap()).set(arguments[0], arguments[1]);'.freeze + + # W3C Get Element Shadow Root returns closed roots too. It costs one round + # trip per candidate, hence the cap and the local-only default. + def self.expose_closed_shadow_roots_classic(native) + max_hosts = closed_shadow_max_hosts + checked = 0 + scopes = [nil] + capped = false + until scopes.empty? || capped + hosts = native.execute_script(CLASSIC_SHADOW_CANDIDATES_JS, scopes.shift) || [] + hosts.each do |host| + capped = checked >= max_hosts + break if capped + + checked += 1 + shadow = element_shadow_root(host) + next if shadow.nil? + + native.execute_script(CLASSIC_SHADOW_SET_JS, host, shadow) + scopes << shadow + end + end + return unless capped + + log("Closed shadow root scan stopped at #{max_hosts} hosts (PERCY_CLOSED_SHADOW_MAX_HOSTS)", + 'debug',) + end + + # NoSuchShadowRoot means "not a host"; Safari answers null, which the + # binding cannot wrap. Both mean skip. + def self.element_shadow_root(host) + host.shadow_root + rescue StandardError + nil + end + + def self.closed_shadow_max_hosts + value = Integer(ENV['PERCY_CLOSED_SHADOW_MAX_HOSTS'] || CLOSED_SHADOW_MAX_HOSTS) + value > 0 ? value : CLOSED_SHADOW_MAX_HOSTS + rescue ArgumentError, TypeError + CLOSED_SHADOW_MAX_HOSTS + end + + # Remote grids add a full round trip per candidate, so classic is opt-in there. + def self.closed_shadow_classic_allowed?(native) + return true if ENV['PERCY_CLOSED_SHADOW_CLASSIC'] == 'true' + return true if LOCAL_HOSTS.include?(command_executor_host(native)) + + unless @closed_shadow_remote_logged + @closed_shadow_remote_logged = true + log('closed shadow roots skipped on remote session; start the session with ' \ + 'webSocketUrl: true or set PERCY_CLOSED_SHADOW_CLASSIC=true') + end + false + end + + def self.command_executor_host(native) + return nil unless native.respond_to?(:bridge, true) + + URI.parse(native.send(:bridge).http.send(:server_url).to_s).hostname + rescue StandardError + nil + end + def self.unsupported_iframe_src?(src) src.nil? || src.empty? || src == 'about:blank' || src.start_with?('javascript:') || src.start_with?('data:') || src.start_with?('vbscript:') @@ -338,32 +524,212 @@ def self.get_responsive_widths(widths = []) end end - def self.change_window_dimension_and_wait(driver, width, height, resize_count) + # Returns the mechanism that was used (:cdp, :bidi or :window) so the caller + # can undo exactly that on restore. + def self.change_window_dimension_and_wait(driver, width, height, exact: true) log("Attempting to resize window to #{width}x#{height}", 'debug') - begin - if driver.capabilities.browser_name == 'chrome' && driver.respond_to?(:execute_cdp) - driver.execute_cdp('Emulation.setDeviceMetricsOverride', { - height: height, width: width, deviceScaleFactor: 1, mobile: false, - },) - else - get_browser_instance(driver).window.resize_to(width, height) - driver.execute_script("window.dispatchEvent(new Event('resize'));") + mechanism = if exact + set_exact_viewport(driver, width, height) + else + legacy_resize(driver, width, height) + end + + actual = wait_for_viewport_width(driver, width) + if exact && mechanism == :window && actual && actual != width + warn_viewport_mismatch(driver, width, actual) + end + mechanism + end + + # CDP and BiDi emulate the viewport exactly. A window resize cannot go below + # the OS minimum window width (~500px) and includes browser chrome (Edge has + # an 8px border), so it is only the last resort. + def self.set_exact_viewport(driver, width, height) + native = native_driver(driver) + + if chromium_with_cdp?(native) + begin + native.execute_cdp( + 'Emulation.setDeviceMetricsOverride', + height: height, width: width, deviceScaleFactor: 1, mobile: false, + ) + return :cdp + rescue StandardError => e + log("CDP viewport override failed for width #{width}: #{e}", 'debug') end - rescue StandardError => e - log("Resizing using cdp failed, falling back to driver for width #{width} #{e}", 'debug') - get_browser_instance(driver).window.resize_to(width, height) - driver.execute_script("window.dispatchEvent(new Event('resize'));") end + if bidi_session?(native) + begin + native.bidi.send_cmd( + 'browsingContext.setViewport', + context: native.window_handle, + viewport: {width: width, height: height}, + devicePixelRatio: 1, + ) + return :bidi + rescue StandardError => e + log("BiDi viewport override failed for width #{width}: #{e}", 'debug') + end + end + + resize_window_to_viewport(driver, width, height) + :window + end + + # exactResponsiveWidth: false keeps the pre-exact behaviour: CDP on Chrome + # only, otherwise a plain window resize. + def self.legacy_resize(driver, width, height) + native = native_driver(driver) + if browser_name(native) == 'chrome' && native.respond_to?(:execute_cdp) + native.execute_cdp( + 'Emulation.setDeviceMetricsOverride', + height: height, width: width, deviceScaleFactor: 1, mobile: false, + ) + return :cdp + end + + resize_window(driver, width, height) + :window + rescue StandardError => e + log("Resizing using cdp failed, falling back to driver for width #{width} #{e}", 'debug') + resize_window(driver, width, height) + :window + end + + def self.resize_window(driver, width, height) + get_browser_instance(driver).window.resize_to(width, height) + driver.execute_script("window.dispatchEvent(new Event('resize'));") + end + + def self.resize_window_to_viewport(driver, width, height) + resize_window(driver, width, height) + actual = viewport_size(driver)['w'] + # Only a too-narrow viewport (window border) can be corrected; a wider one + # means the OS minimum window width was hit. + return if actual.nil? || actual >= width + + resize_window(driver, width + (width - actual), height) + end + + def self.restore_viewport(driver, width, height, mechanisms) + mechanisms = mechanisms.compact.uniq + native = native_driver(driver) + resize = mechanisms.include?(:window) + + if mechanisms.include?(:cdp) + begin + native.execute_cdp('Emulation.clearDeviceMetricsOverride') + rescue StandardError => e + log("Could not clear CDP viewport override: #{e}", 'debug') + resize = true + end + end + + if mechanisms.include?(:bidi) + begin + clear_bidi_viewport(native) + rescue StandardError => e + log("Could not clear BiDi viewport override: #{e}", 'debug') + resize = true + end + end + + get_browser_instance(driver).window.resize_to(width, height) if resize + end + + # BiDi#send_cmd drops nil params, but resetting the viewport needs an + # explicit `viewport: null`, so this goes through the raw socket. + def self.clear_bidi_viewport(native) + bidi = native.bidi + raise StandardError, 'BiDi binding cannot send a null viewport' unless bidi.respond_to?(:ws) + + message = bidi.ws.send_cmd( + method: 'browsingContext.setViewport', + params: {context: native.window_handle, viewport: nil, devicePixelRatio: nil}, + ) + raise StandardError, message['message'].to_s if message && message['error'] + end + + # Never raises: the override is already applied when this runs, and the + # caller must get the mechanism back so restore_viewport can undo it. + def self.viewport_size(driver) + driver.execute_script('return { w: window.innerWidth, h: window.innerHeight }') || {} + rescue StandardError => e + log("Could not read viewport size: #{e}", 'debug') + {} + end + + def self.wait_for_viewport_width(driver, width) begin - wait = Selenium::WebDriver::Wait.new(timeout: 1) - wait.until { driver.execute_script('return window.resizeCount') == resize_count } - actual_size = driver.execute_script('return { w: window.innerWidth, h: window.innerHeight }') - log("Resize successful. New Viewport Size: #{actual_size['w']}x#{actual_size['h']}", 'debug') + Selenium::WebDriver::Wait.new(timeout: 1).until { viewport_size(driver)['w'] == width } rescue Selenium::WebDriver::Error::TimeoutError - log("Timed out waiting for window resize event for width #{width}", 'debug') + log("Timed out waiting for viewport width #{width}", 'debug') + end + size = viewport_size(driver) + log("New Viewport Size: #{size['w']}x#{size['h']}", 'debug') + size['w'] + end + + def self.warn_viewport_mismatch(driver, width, actual) + log("Viewport width is #{actual} instead of #{width} after window resize", 'debug') + native = native_driver(driver) + session = session_id_of(native) + @viewport_warned_sessions ||= {} + return if @viewport_warned_sessions[session] + + @viewport_warned_sessions[session] = true + log("#{browser_name(native)} could not be resized to exact responsive widths " \ + "(got #{actual}px for #{width}px). Start the session with `webSocketUrl: true` " \ + 'to enable WebDriver BiDi for exact widths.', 'warn',) + end + + def self.session_id_of(native) + native.session_id + rescue StandardError + nil + end + + def self.exact_responsive_width?(options) + [ + options[:exactResponsiveWidth], + options[:exact_responsive_width], + @cli_config&.dig('snapshot', 'exactResponsiveWidth'), + ].each { |value| return value unless value.nil? } + true + end + + def self.native_driver(driver) + if driver.respond_to?(:driver) && driver.driver.respond_to?(:browser) + return driver.driver.browser end + + driver + end + + def self.browser_name(native) + native.capabilities.browser_name.to_s.downcase + rescue StandardError + '' + end + + def self.chromium_with_cdp?(native) + return false unless native.respond_to?(:execute_cdp) + + name = browser_name(native) + CHROMIUM_BROWSERS.include?(name) || !NON_CDP_BROWSERS.include?(name) + end + + # Only a session that was started with `webSocketUrl: true` has a BiDi + # socket; the SDK cannot enable it after the fact. + def self.bidi_session?(native) + return false unless native.respond_to?(:bidi) && native.respond_to?(:capabilities) + + caps = native.capabilities + (caps[:web_socket_url] || caps['webSocketUrl']).is_a?(String) + rescue StandardError + false end def self.capture_responsive_dom(driver, options, percy_dom_script: nil) @@ -374,7 +740,8 @@ def self.capture_responsive_dom(driver, options, percy_dom_script: nil) current_height = window_size.height last_window_width = current_width last_window_height = current_height - resize_count = 0 + exact = exact_responsive_width?(options) + mechanisms = [] driver.execute_script('PercyDOM.waitForResize()') target_height = current_height @@ -394,8 +761,7 @@ def self.capture_responsive_dom(driver, options, percy_dom_script: nil) height = width_dict['height'] || target_height if last_window_width != width || last_window_height != height - resize_count += 1 - change_window_dimension_and_wait(driver, width, height, resize_count) + mechanisms << change_window_dimension_and_wait(driver, width, height, exact: exact) last_window_width = width last_window_height = height end @@ -414,7 +780,6 @@ def self.capture_responsive_dom(driver, options, percy_dom_script: nil) percy_dom_script = fetch_percy_dom driver.execute_script(percy_dom_script) driver.execute_script('PercyDOM.waitForResize()') - resize_count = 0 end sleep(RESPONSIVE_CAPTURE_SLEEP_TIME.to_i) if RESPONSIVE_CAPTURE_SLEEP_TIME @@ -424,7 +789,7 @@ def self.capture_responsive_dom(driver, options, percy_dom_script: nil) dom_snapshots << dom_snapshot end ensure - change_window_dimension_and_wait(driver, current_width, current_height, resize_count + 1) + restore_viewport(driver, current_width, current_height, mechanisms) end dom_snapshots @@ -580,6 +945,8 @@ def self._clear_cache! @percy_enabled = nil @cli_config = nil @session_type = nil + @viewport_warned_sessions = nil + @closed_shadow_remote_logged = nil Cache.clear_cache! end end diff --git a/spec/lib/percy/percy_spec.rb b/spec/lib/percy/percy_spec.rb index b38473b..b25e11d 100644 --- a/spec/lib/percy/percy_spec.rb +++ b/spec/lib/percy/percy_spec.rb @@ -266,6 +266,7 @@ allow(driver).to receive(:respond_to?).with(:driver).and_return(false) allow(driver).to receive(:respond_to?).with(:execute_cdp).and_return(false) allow(driver).to receive(:capabilities).and_return(capabilities) + allow(driver).to receive(:session_id).and_return('session-1') allow(driver).to receive(:current_url).and_return('http://127.0.0.1:3003/index.html') allow(driver).to receive(:find_elements).and_return([]) allow(driver).to receive(:manage).and_return(manage) @@ -764,6 +765,17 @@ # Readiness gate runs execute_async_script before serialize; stub it so # these unit tests exercise serialization without a real browser. allow(driver).to receive(:execute_async_script).and_return(nil) + allow(Percy).to receive(:expose_closed_shadow_roots) + end + + it 'exposes closed shadow roots before serializing' do + allow(driver).to receive(:current_url).and_return('http://main.example.com/') + allow(driver).to receive(:find_elements).and_return([]) + expect(Percy).to receive(:expose_closed_shadow_roots) + .with(driver, {disableShadowDOM: true}).ordered + expect(driver).to receive(:execute_script) + .with(/PercyDOM.serialize/).ordered.and_return({'html' => ''}) + Percy.get_serialized_dom(driver, {disableShadowDOM: true}) end it 'returns the serialized dom with cookies when no iframes present' do @@ -936,7 +948,7 @@ 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}) + Percy.get_serialized_dom(driver, {readiness: {preset: 'strict', stabilityWindowMs: 500}}) expect(driver).to have_received(:execute_async_script) do |script| # rubocop:disable RSpec/MessageSpies expect(script).to include('"preset":"strict"') expect(script).to include('"stabilityWindowMs":500') @@ -949,7 +961,7 @@ 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'}) + dom = Percy.get_serialized_dom(driver, {readiness: {preset: 'disabled'}}) expect(dom).to_not have_key('readiness_diagnostics') expect(dom['html']).to eq('') end @@ -978,7 +990,7 @@ expect(timeouts).to receive(:script_timeout=).with(10.0).ordered expect(timeouts).to receive(:script_timeout=).with(30).ordered - Percy.get_serialized_dom(driver, readiness: {timeoutMs: 8000}) + Percy.get_serialized_dom(driver, {readiness: {timeoutMs: 8000}}) end it 'proceeds when reading/setting the script timeout is unsupported' do @@ -990,7 +1002,7 @@ allow(driver).to receive(:current_url).and_return('http://main.example.com/') allow(driver).to receive(:find_elements).and_return([]) - expect { Percy.get_serialized_dom(driver, readiness: {timeoutMs: 5000}) }.to_not raise_error + expect { Percy.get_serialized_dom(driver, {readiness: {timeoutMs: 5000}}) }.to_not raise_error end it 'skips an iframe whose src cannot be resolved against the page url' do @@ -1033,76 +1045,320 @@ let(:window) { double('window') } let(:wait) { instance_double(Selenium::WebDriver::Wait) } let(:caps_firefox) { double('capabilities', browser_name: 'firefox') } + let(:inner_width) { [1024] } before(:each) do + allow(driver).to receive(:respond_to?).and_return(false) allow(driver).to receive(:manage).and_return(manage) allow(manage).to receive(:window).and_return(window) allow(window).to receive(:resize_to) allow(driver).to receive(:capabilities).and_return(caps_firefox) - allow(driver).to receive(:respond_to?).with(:driver).and_return(false) - allow(driver).to receive(:respond_to?).with(:execute_cdp).and_return(false) + allow(driver).to receive(:session_id).and_return('s-1') allow(driver).to receive(:execute_script) do |script| - {'w' => 1024, 'h' => 768} if script.include?('innerWidth') + {'w' => inner_width.first, 'h' => 768} if script.include?('innerWidth') end allow(Selenium::WebDriver::Wait).to receive(:new).and_return(wait) - allow(wait).to receive(:until) + allow(wait).to receive(:until).and_yield allow(Percy).to receive(:log) end - it 'resizes the window using resize_to for non-chrome browsers' do - expect(window).to receive(:resize_to).with(768, 1024) - Percy.change_window_dimension_and_wait(driver, 768, 1024, 1) + def chrome_driver_with_bridge(bridge, browser: 'chrome', window_double: nil) + klass = Class.new do + include Selenium::WebDriver::DriverExtensions::HasCDP + + attr_accessor :caps, :mgr + + def capabilities + caps + end + + def manage + mgr + end + + def execute_script(*) + {'w' => 375, 'h' => 812} + end + end + chromium = klass.new + chromium.instance_variable_set(:@bridge, bridge) + chromium.caps = double('capabilities', browser_name: browser) + chromium.mgr = double('manage', window: window_double || window) + chromium + end + + # Selenium declares `execute_cdp(cmd, **params)`, so on Ruby 3 a positional + # Hash raises ArgumentError and the rescue silently falls back to resize_to. + # Run the real HasCDP method so that signature is enforced. + %w[chrome MicrosoftEdge msedge].each do |browser| + it "overrides the viewport through the real execute_cdp on #{browser}" do + bridge = double('bridge') + chromium = chrome_driver_with_bridge(bridge, browser: browser) + expect(bridge).to receive(:send_command).with( + cmd: 'Emulation.setDeviceMetricsOverride', + params: {height: 812, width: 375, deviceScaleFactor: 1, mobile: false}, + ) + expect(window).to_not receive(:resize_to) + expect(Percy.change_window_dimension_and_wait(chromium, 375, 812)).to eq(:cdp) + end end - it 'dispatches a resize event after resizing for non-chrome browsers' do - expect(driver).to receive(:execute_script) - .with("window.dispatchEvent(new Event('resize'));") - Percy.change_window_dimension_and_wait(driver, 768, 1024, 1) + it 'never uses CDP on firefox even when the driver exposes it' do + allow(driver).to receive(:respond_to?).with(:execute_cdp).and_return(true) + expect(driver).to_not receive(:execute_cdp) + inner_width[0] = 768 + expect(Percy.change_window_dimension_and_wait(driver, 768, 1024)).to eq(:window) end - it 'uses execute_cdp for chrome when execute_cdp is available' do - chrome_caps = double('capabilities', browser_name: 'chrome') - allow(driver).to receive(:capabilities).and_return(chrome_caps) + it 'uses CDP for an unknown browser name when the driver exposes it' do + allow(driver).to receive(:capabilities).and_return(double('caps', browser_name: 'brave')) allow(driver).to receive(:respond_to?).with(:execute_cdp).and_return(true) expect(driver).to receive(:execute_cdp).with( 'Emulation.setDeviceMetricsOverride', - {height: 812, width: 375, deviceScaleFactor: 1, mobile: false}, + height: 812, width: 375, deviceScaleFactor: 1, mobile: false, ) - Percy.change_window_dimension_and_wait(driver, 375, 812, 1) + Percy.change_window_dimension_and_wait(driver, 375, 812) end - it 'does not call resize_to when cdp succeeds for chrome' do - chrome_caps = double('capabilities', browser_name: 'chrome') - allow(driver).to receive(:capabilities).and_return(chrome_caps) - allow(driver).to receive(:respond_to?).with(:execute_cdp).and_return(true) - allow(driver).to receive(:execute_cdp) - expect(window).to_not receive(:resize_to) - Percy.change_window_dimension_and_wait(driver, 375, 812, 1) + context 'with a BiDi session' do + let(:bidi) { double('bidi') } + + before(:each) do + caps = {web_socket_url: 'wss://127.0.0.1:9222/session/abc'} + allow(driver).to receive(:capabilities).and_return(caps) + allow(driver).to receive(:respond_to?).with(:bidi).and_return(true) + allow(driver).to receive(:respond_to?).with(:capabilities).and_return(true) + allow(driver).to receive(:bidi).and_return(bidi) + allow(driver).to receive(:window_handle).and_return('ctx-1') + end + + it 'sets the viewport with browsingContext.setViewport' do + expect(bidi).to receive(:send_cmd).with( + 'browsingContext.setViewport', + context: 'ctx-1', viewport: {width: 375, height: 812}, devicePixelRatio: 1, + ) + expect(window).to_not receive(:resize_to) + inner_width[0] = 375 + expect(Percy.change_window_dimension_and_wait(driver, 375, 812)).to eq(:bidi) + end + + it 'falls back to the window when setViewport fails' do + allow(bidi).to receive(:send_cmd).and_raise(StandardError, 'unknown command') + expect(window).to receive(:resize_to).with(1024, 812) + expect(Percy.change_window_dimension_and_wait(driver, 1024, 812)).to eq(:window) + end end - it 'falls back to resize_to when execute_cdp raises' do - chrome_caps = double('capabilities', browser_name: 'chrome') - allow(driver).to receive(:capabilities).and_return(chrome_caps) + it 'falls back to BiDi or the window when CDP raises' do + allow(driver).to receive(:capabilities).and_return(double('caps', browser_name: 'chrome')) allow(driver).to receive(:respond_to?).with(:execute_cdp).and_return(true) allow(driver).to receive(:execute_cdp).and_raise(StandardError, 'cdp error') - expect(window).to receive(:resize_to).with(375, 812) - Percy.change_window_dimension_and_wait(driver, 375, 812, 1) + expect(window).to receive(:resize_to).with(1024, 812) + expect(Percy).to receive(:log).with(/CDP viewport override failed/, 'debug') + expect(Percy.change_window_dimension_and_wait(driver, 1024, 812)).to eq(:window) end - it 'dispatches a resize event in the cdp fallback path' do - chrome_caps = double('capabilities', browser_name: 'chrome') - allow(driver).to receive(:capabilities).and_return(chrome_caps) - allow(driver).to receive(:respond_to?).with(:execute_cdp).and_return(true) - allow(driver).to receive(:execute_cdp).and_raise(StandardError, 'cdp error') - expect(driver).to receive(:execute_script) - .with("window.dispatchEvent(new Event('resize'));") - Percy.change_window_dimension_and_wait(driver, 375, 812, 1) + it 'corrects the window size by the measured border once' do + inner_width[0] = 1272 + expect(window).to receive(:resize_to).with(1280, 900).ordered + expect(window).to receive(:resize_to).with(1288, 900).ordered do + inner_width[0] = 1280 + end + expect(Percy.change_window_dimension_and_wait(driver, 1280, 900)).to eq(:window) end - it 'logs and swallows a TimeoutError when the resize event never fires' do + it 'warns once per session when the window cannot reach the width' do + inner_width[0] = 500 allow(wait).to receive(:until).and_raise(Selenium::WebDriver::Error::TimeoutError) - expect(Percy).to receive(:log).with(/Timed out waiting for window resize event/, 'debug') - expect { Percy.change_window_dimension_and_wait(driver, 768, 1024, 1) }.to_not raise_error + expect(Percy).to receive(:log).with(/webSocketUrl: true/, 'warn').once + expect(Percy).to receive(:log).with(/Timed out waiting for viewport width 375/, 'debug').twice + 2.times { Percy.change_window_dimension_and_wait(driver, 375, 900) } + end + + it 'still warns when the session id is unavailable' do + inner_width[0] = 500 + allow(driver).to receive(:session_id).and_raise(StandardError) + expect(Percy).to receive(:log).with(/firefox could not be resized/, 'warn') + Percy.change_window_dimension_and_wait(driver, 375, 900) + end + + it 'does not correct the window when innerWidth is unknown' do + allow(driver).to receive(:execute_script).and_return(nil) + expect(window).to receive(:resize_to).once + Percy.change_window_dimension_and_wait(driver, 375, 900) + end + + it 'does not shrink the window again when the OS minimum width was hit' do + inner_width[0] = 500 + expect(window).to receive(:resize_to).with(200, 900).once + Percy.change_window_dimension_and_wait(driver, 200, 900) + end + + it 'still returns the mechanism when reading the viewport fails' do + allow(driver).to receive(:capabilities).and_return(double('caps', browser_name: 'chrome')) + allow(driver).to receive(:respond_to?).with(:execute_cdp).and_return(true) + allow(driver).to receive(:execute_cdp) + allow(driver).to receive(:execute_script).and_raise(StandardError, 'unexpected alert') + expect(Percy).to receive(:log).with(/Could not read viewport size: unexpected alert/, 'debug') + .at_least(:once) + expect(Percy.change_window_dimension_and_wait(driver, 375, 900)).to eq(:cdp) + end + + context 'with exact: false' do + it 'uses CDP only for chrome, through the real execute_cdp' do + bridge = double('bridge') + chromium = chrome_driver_with_bridge(bridge) + expect(bridge).to receive(:send_command).with( + cmd: 'Emulation.setDeviceMetricsOverride', + params: {height: 812, width: 375, deviceScaleFactor: 1, mobile: false}, + ) + expect(Percy.change_window_dimension_and_wait(chromium, 375, 812, exact: false)).to eq(:cdp) + end + + it 'resizes the window for Edge and dispatches a resize event' do + bridge = double('bridge') + edge = chrome_driver_with_bridge(bridge, browser: 'MicrosoftEdge') + expect(bridge).to_not receive(:send_command) + expect(window).to receive(:resize_to).with(375, 812).once + expect(Percy.change_window_dimension_and_wait(edge, 375, 812, exact: false)).to eq(:window) + end + + it 'falls back to resize_to when execute_cdp raises' do + allow(driver).to receive(:capabilities).and_return(double('caps', browser_name: 'chrome')) + allow(driver).to receive(:respond_to?).with(:execute_cdp).and_return(true) + allow(driver).to receive(:execute_cdp).and_raise(StandardError, 'cdp error') + expect(window).to receive(:resize_to).with(375, 812) + expect(driver).to receive(:execute_script) + .with("window.dispatchEvent(new Event('resize'));") + expect(Percy).to_not receive(:log).with(anything, 'warn') + expect(Percy.change_window_dimension_and_wait(driver, 375, 812, exact: false)) + .to eq(:window) + end + end + end + + describe '.restore_viewport' do + let(:driver) { double('driver') } + let(:window) { double('window') } + + before(:each) do + allow(driver).to receive(:respond_to?).and_return(false) + allow(driver).to receive(:manage).and_return(double('manage', window: window)) + allow(Percy).to receive(:log) + end + + it 'clears the CDP override through the real execute_cdp instead of re-applying it' do + bridge = double('bridge') + chromium = Class.new { include Selenium::WebDriver::DriverExtensions::HasCDP }.new + chromium.instance_variable_set(:@bridge, bridge) + expect(bridge).to receive(:send_command) + .with(cmd: 'Emulation.clearDeviceMetricsOverride', params: {}) + Percy.restore_viewport(chromium, 1280, 900, [:cdp, nil, :cdp]) + end + + it 'resizes the window back when the window was used' do + expect(window).to receive(:resize_to).with(1280, 900) + Percy.restore_viewport(driver, 1280, 900, [:window]) + end + + it 'does nothing when nothing was resized' do + expect(window).to_not receive(:resize_to) + Percy.restore_viewport(driver, 1280, 900, []) + end + + it 'resizes the window when clearing CDP fails' do + allow(driver).to receive(:execute_cdp).and_raise(StandardError, 'gone') + expect(window).to receive(:resize_to).with(1280, 900) + Percy.restore_viewport(driver, 1280, 900, [:cdp]) + end + + context 'with BiDi' do + let(:bidi) { double('bidi') } + let(:ws) { double('ws') } + + before(:each) do + allow(driver).to receive(:bidi).and_return(bidi) + allow(driver).to receive(:window_handle).and_return('ctx-1') + end + + it 'sends an explicit null viewport over the socket' do + allow(bidi).to receive(:respond_to?).with(:ws).and_return(true) + allow(bidi).to receive(:ws).and_return(ws) + expect(ws).to receive(:send_cmd).with( + method: 'browsingContext.setViewport', + params: {context: 'ctx-1', viewport: nil, devicePixelRatio: nil}, + ).and_return({'id' => 1, 'result' => {}}) + expect(window).to_not receive(:resize_to) + Percy.restore_viewport(driver, 1280, 900, [:bidi]) + end + + it 'resizes the window when the reset returns an error' do + allow(bidi).to receive(:respond_to?).with(:ws).and_return(true) + allow(bidi).to receive(:ws).and_return(ws) + allow(ws).to receive(:send_cmd).and_return({'error' => 'x', 'message' => 'bad'}) + expect(window).to receive(:resize_to).with(1280, 900) + expect(Percy).to receive(:log).with(/Could not clear BiDi viewport override: bad/, 'debug') + Percy.restore_viewport(driver, 1280, 900, [:bidi]) + end + + it 'resizes the window when the binding has no raw socket' do + allow(bidi).to receive(:respond_to?).with(:ws).and_return(false) + expect(window).to receive(:resize_to).with(1280, 900) + Percy.restore_viewport(driver, 1280, 900, [:bidi]) + end + end + end + + describe '.exact_responsive_width?' do + after(:each) { Percy.instance_variable_set(:@cli_config, nil) } + + it 'defaults to true' do + expect(Percy.exact_responsive_width?({})).to eq(true) + end + + it 'prefers the per-snapshot option over the CLI config' do + Percy.instance_variable_set(:@cli_config, {'snapshot' => {'exactResponsiveWidth' => true}}) + expect(Percy.exact_responsive_width?({exactResponsiveWidth: false})).to eq(false) + expect(Percy.exact_responsive_width?({exact_responsive_width: false})).to eq(false) + end + + it 'reads config.snapshot.exactResponsiveWidth from the healthcheck' do + Percy.instance_variable_set(:@cli_config, {'snapshot' => {'exactResponsiveWidth' => false}}) + expect(Percy.exact_responsive_width?({})).to eq(false) + end + end + + describe '.bidi_session?' do + let(:driver) { double('driver') } + + before(:each) do + allow(driver).to receive(:respond_to?).and_return(true) + end + + it 'is false without a webSocketUrl capability' do + allow(driver).to receive(:capabilities).and_return({web_socket_url: true}) + expect(Percy.bidi_session?(driver)).to eq(false) + end + + it 'accepts the string-keyed capability' do + allow(driver).to receive(:capabilities).and_return({'webSocketUrl' => 'wss://x'}) + expect(Percy.bidi_session?(driver)).to eq(true) + end + + it 'is false when the binding has no bidi' do + allow(driver).to receive(:respond_to?).with(:bidi).and_return(false) + expect(Percy.bidi_session?(driver)).to eq(false) + end + + it 'is false when capabilities cannot be read' do + allow(driver).to receive(:capabilities).and_raise(StandardError) + expect(Percy.bidi_session?(driver)).to eq(false) + end + end + + describe '.browser_name' do + it 'returns an empty string when capabilities are unavailable' do + expect(Percy.browser_name(Object.new)).to eq('') end end @@ -1121,6 +1377,7 @@ allow(driver).to receive(:execute_script).and_return(nil) allow(Percy).to receive(:get_serialized_dom).and_return({'html' => ''}) allow(Percy).to receive(:change_window_dimension_and_wait) + allow(Percy).to receive(:restore_viewport) allow(Percy).to receive(:log) end @@ -1132,8 +1389,7 @@ allow(Percy).to receive(:get_responsive_widths).and_return( [{'width' => 375}, {'width' => 768}, {'width' => 1280}], ) - # Each of the 3 widths differs from the previous + final restore = 4 calls - expect(Percy).to receive(:change_window_dimension_and_wait).exactly(4).times + expect(Percy).to receive(:change_window_dimension_and_wait).exactly(3).times Percy.capture_responsive_dom(driver, {}) end @@ -1141,15 +1397,15 @@ allow(Percy).to receive(:get_responsive_widths).and_return( [{'width' => 375}, {'width' => 375}], ) - # 375 (first, differs from 1280) + final restore = 2; second 375 skipped - expect(Percy).to receive(:change_window_dimension_and_wait).twice + # second 375 is skipped + expect(Percy).to receive(:change_window_dimension_and_wait).once Percy.capture_responsive_dom(driver, {}) end it 'passes the requested width and window height to change_window_dimension_and_wait' do allow(Percy).to receive(:get_responsive_widths).and_return([{'width' => 390}]) expect(Percy).to receive(:change_window_dimension_and_wait) - .with(driver, 390, 900, anything) + .with(driver, 390, 900, exact: true) Percy.capture_responsive_dom(driver, {}) end @@ -1158,17 +1414,24 @@ [{'width' => 390, 'height' => 844}], ) expect(Percy).to receive(:change_window_dimension_and_wait) - .with(driver, 390, 844, anything) + .with(driver, 390, 844, exact: true) Percy.capture_responsive_dom(driver, {}) end - it 'restores original window dimensions after processing all widths' do + it 'restores the viewport with the mechanisms that were used' do allow(Percy).to receive(:get_responsive_widths).and_return([{'width' => 375}]) - expect(Percy).to receive(:change_window_dimension_and_wait) - .with(driver, 1280, 900, anything) + allow(Percy).to receive(:change_window_dimension_and_wait).and_return(:cdp) + expect(Percy).to receive(:restore_viewport).with(driver, 1280, 900, [:cdp]) Percy.capture_responsive_dom(driver, {}) end + it 'passes exact: false when exactResponsiveWidth is disabled' do + allow(Percy).to receive(:get_responsive_widths).and_return([{'width' => 375}]) + expect(Percy).to receive(:change_window_dimension_and_wait) + .with(driver, 375, 900, exact: false) + Percy.capture_responsive_dom(driver, {exactResponsiveWidth: false}) + end + # ----------------------------------------------------------------------- # Reload-page flag # ----------------------------------------------------------------------- @@ -1235,7 +1498,7 @@ it 'uses the minHeight option as the target height' do expect(Percy).to receive(:change_window_dimension_and_wait) - .with(driver, 390, 800, anything) + .with(driver, 390, 800, exact: true) Percy.capture_responsive_dom(driver, {minHeight: 800}) end @@ -1243,7 +1506,7 @@ begin Percy.instance_variable_set(:@cli_config, {'snapshot' => {'minHeight' => 700}}) expect(Percy).to receive(:change_window_dimension_and_wait) - .with(driver, 390, 700, anything) + .with(driver, 390, 700, exact: true) Percy.capture_responsive_dom(driver, {}) ensure Percy.instance_variable_set(:@cli_config, nil) @@ -1252,7 +1515,7 @@ it 'uses the current window height unchanged when minHeight is not set' do expect(Percy).to receive(:change_window_dimension_and_wait) - .with(driver, 390, 900, anything) + .with(driver, 390, 900, exact: true) Percy.capture_responsive_dom(driver, {}) end end @@ -1661,7 +1924,7 @@ def stub_web_snapshot_healthcheck .to_return(status: 200, body: '', headers: {}) allow(driver).to receive(:manage).and_return(manage) allow(manage).to receive(:all_cookies).and_return([]) - allow(driver).to receive(:respond_to?).with(:driver).and_return(false) + allow(driver).to receive(:respond_to?).and_return(false) allow(driver).to receive(:switch_to).and_return(switch_to) allow(switch_to).to receive(:default_content) allow(driver).to receive(:execute_async_script).and_return(nil) @@ -1735,3 +1998,338 @@ def stub_web_snapshot_healthcheck end end # rubocop:enable RSpec/MultipleDescribes + +RSpec.describe Percy do + before(:each) do + Percy._clear_cache! + allow(Percy).to receive(:log) + end + + describe '.expose_closed_shadow_roots' do + let(:driver) { double('driver') } + + before(:each) do + allow(driver).to receive(:respond_to?).and_return(false) + allow(driver).to receive(:capabilities).and_return(double('caps', browser_name: 'firefox')) + end + + it 'does nothing when disableShadowDOM is set' do + expect(Percy).to_not receive(:closed_shadow_classic_allowed?) + Percy.expose_closed_shadow_roots(driver, {disableShadowDOM: true}) + Percy.expose_closed_shadow_roots(driver, {'disableShadowDOM' => true}) + end + + it 'unwraps a Capybara session to the Selenium driver' do + browser = double('browser') + allow(driver).to receive(:respond_to?).with(:driver).and_return(true) + allow(driver).to receive(:driver).and_return(double('cap_driver', browser: browser)) + expect(Percy).to receive(:closed_shadow_classic_allowed?).with(browser).and_return(false) + allow(browser).to receive(:respond_to?).and_return(false) + Percy.expose_closed_shadow_roots(driver) + end + + it 'stops after CDP succeeds' do + allow(driver).to receive(:capabilities).and_return(double('caps', browser_name: 'chrome')) + allow(driver).to receive(:respond_to?).with(:execute_cdp).and_return(true) + expect(Percy).to receive(:expose_closed_shadow_roots_cdp).and_return(true) + expect(Percy).to_not receive(:closed_shadow_classic_allowed?) + Percy.expose_closed_shadow_roots(driver) + end + + it 'stops after BiDi succeeds' do + allow(Percy).to receive(:bidi_session?).and_return(true) + expect(Percy).to receive(:expose_closed_shadow_roots_bidi).and_return(true) + expect(Percy).to_not receive(:closed_shadow_classic_allowed?) + Percy.expose_closed_shadow_roots(driver) + end + + it 'falls back to classic when CDP and BiDi fail' do + allow(driver).to receive(:capabilities).and_return(double('caps', browser_name: 'chrome')) + allow(driver).to receive(:respond_to?).with(:execute_cdp).and_return(true) + allow(Percy).to receive(:bidi_session?).and_return(true) + allow(Percy).to receive(:expose_closed_shadow_roots_cdp).and_return(false) + allow(Percy).to receive(:expose_closed_shadow_roots_bidi).and_return(false) + allow(Percy).to receive(:closed_shadow_classic_allowed?).and_return(true) + expect(Percy).to receive(:expose_closed_shadow_roots_classic).with(driver) + Percy.expose_closed_shadow_roots(driver) + end + + it 'warns and continues on unexpected errors' do + allow(Percy).to receive(:closed_shadow_classic_allowed?).and_raise(StandardError, 'boom') + expect(Percy).to receive(:log).with(/Could not expose closed shadow roots: boom/, 'warn') + expect { Percy.expose_closed_shadow_roots(driver) }.to_not raise_error + end + end + + describe '.expose_closed_shadow_roots_cdp' do + let(:bridge) { double('bridge') } + let(:chromium) do + d = Class.new { include Selenium::WebDriver::DriverExtensions::HasCDP }.new + d.instance_variable_set(:@bridge, bridge) + d + end + let(:document) do + {'root' => { + 'backendNodeId' => 1, + 'children' => [ + {'backendNodeId' => 2, 'shadowRoots' => [ + {'backendNodeId' => 3, 'shadowRootType' => 'closed', 'children' => [ + {'backendNodeId' => 4, 'shadowRoots' => [ + {'backendNodeId' => 5, 'shadowRootType' => 'closed'}, + ],}, + ],}, + ],}, + {'backendNodeId' => 6, + 'shadowRoots' => [{'backendNodeId' => 7, 'shadowRootType' => 'open'}],}, + {'backendNodeId' => 8, 'contentDocument' => {}, 'shadowRoots' => [ + {'backendNodeId' => 9, 'shadowRootType' => 'closed'}, + ],}, + ], + }} + end + + def cdp(cmd, params = {}) + receive(:send_command).with(cmd: cmd, params: params) + end + + # Driven through the real HasCDP#execute_cdp so keyword arguments are enforced. + it 'maps every closed root (including nested ones) to its host via keyword CDP calls' do + expect(bridge).to cdp('DOM.enable') + expect(bridge).to cdp('DOM.getDocument', depth: -1, pierce: true).and_return(document) + [2, 3, 4, 5].each do |id| + expect(bridge).to cdp('DOM.resolveNode', backendNodeId: id) + .and_return({'object' => {'objectId' => "obj-#{id}"}}) + end + expect(bridge).to_not cdp('DOM.resolveNode', backendNodeId: 9) + [[2, 3], [4, 5]].each do |host, shadow| + expect(bridge).to cdp( + 'Runtime.callFunctionOn', + functionDeclaration: Percy::CLOSED_SHADOW_SET_FN, objectId: "obj-#{host}", + arguments: [{objectId: "obj-#{shadow}"}], + ) + end + expect(Percy.expose_closed_shadow_roots_cdp(chromium)).to eq(true) + end + + it 'skips pairs that cannot be resolved' do + allow(bridge).to cdp('DOM.enable') + allow(bridge).to cdp('DOM.getDocument', depth: -1, pierce: true).and_return(document) + allow(bridge).to receive(:send_command).with(cmd: 'DOM.resolveNode', params: anything) + .and_return({'object' => {}}) + expect(bridge).to_not receive(:send_command) + .with(cmd: 'Runtime.callFunctionOn', params: anything) + expect(Percy.expose_closed_shadow_roots_cdp(chromium)).to eq(true) + end + + it 'returns false when the document has no root' do + allow(bridge).to cdp('DOM.enable') + allow(bridge).to cdp('DOM.getDocument', depth: -1, pierce: true).and_return({}) + expect(Percy.expose_closed_shadow_roots_cdp(chromium)).to eq(false) + end + + it 'returns false and logs when CDP fails' do + allow(bridge).to receive(:send_command).and_raise(StandardError, 'no cdp') + expect(Percy).to receive(:log).with(/via CDP: no cdp/, 'debug') + expect(Percy.expose_closed_shadow_roots_cdp(chromium)).to eq(false) + end + end + + describe '.expose_closed_shadow_roots_bidi' do + let(:driver) { double('driver', window_handle: 'ctx-1') } + let(:ws) { double('ws') } + # Same signature and body as Selenium's BiDi#send_cmd(method, **params), so + # a positional Hash raises like it would against the real binding (which + # the Ruby 2.x CI rows' Selenium predates). + let(:bidi) do + Class.new do + def initialize(ws) + @ws = ws + end + + def send_cmd(method, **params) + @ws.send_cmd(method: method, params: params.compact)['result'] + end + end.new(ws) + end + + def node(id, mode = nil) + root = {'sharedId' => "#{id}-root", 'value' => {'mode' => mode}} + value = mode ? {'shadowRoot' => root} : {} + {'type' => 'node', 'sharedId' => id, 'value' => value} + end + + def success(value) + {'result' => {'type' => 'success', 'result' => {'type' => 'array', 'value' => value}}} + end + + before(:each) do + allow(driver).to receive(:bidi).and_return(bidi) + end + + it 'exposes closed roots and recurses into each one' do + calls = [] + allow(ws).to receive(:send_cmd) do |method:, params:| + calls << [method, params] + case calls.length + when 1 then success([node('h1', 'closed'), node('h2', 'open'), node('h3')]) + when 3 then success([node('h4', 'closed')]) + else success([]) + end + end + + expect(Percy.expose_closed_shadow_roots_bidi(driver)).to eq(true) + + expect(calls.map(&:first).uniq).to eq(['script.callFunction']) + first = calls[0][1] + expect(first[:target]).to eq({context: 'ctx-1'}) + expect(first[:serializationOptions]).to eq({maxDomDepth: 0, includeShadowTree: 'all'}) + expect(first[:arguments]).to eq([]) + expect(calls[1][1][:arguments]).to eq([{type: 'array', value: [ + {type: 'array', value: [{sharedId: 'h1'}, {sharedId: 'h1-root'}]}, + ],}]) + expect(calls[2][1][:arguments]).to eq([{sharedId: 'h1-root'}]) + expect(calls[4][1][:arguments]).to eq([{sharedId: 'h4-root'}]) + expect(calls.length).to eq(5) + end + + it 'stops at PERCY_CLOSED_SHADOW_MAX_HOSTS' do + allow(Percy).to receive(:closed_shadow_max_hosts).and_return(1) + calls = 0 + allow(ws).to receive(:send_cmd) do + calls += 1 + success([node('h1', 'closed')]) + end + expect(Percy.expose_closed_shadow_roots_bidi(driver)).to eq(true) + expect(calls).to eq(2) + end + + it 'returns false when the script throws' do + allow(ws).to receive(:send_cmd) + .and_return({'result' => {'type' => 'exception', 'exceptionDetails' => {}}}) + expect(Percy).to receive(:log).with(/via BiDi: script.callFunction exception/, 'debug') + expect(Percy.expose_closed_shadow_roots_bidi(driver)).to eq(false) + end + + it 'tolerates an empty result' do + allow(ws).to receive(:send_cmd).and_return({'result' => {'type' => 'success'}}) + expect(Percy.expose_closed_shadow_roots_bidi(driver)).to eq(true) + end + end + + describe '.expose_closed_shadow_roots_classic' do + let(:driver) { double('driver') } + + def host(shadow) + h = double('host') + if shadow == :none + allow(h).to receive(:shadow_root) + .and_raise(Selenium::WebDriver::Error::WebDriverError, 'no such shadow root') + else + allow(h).to receive(:shadow_root).and_return(shadow) + end + h + end + + it 'sets each closed root and walks into it; null roots (Safari) are skipped' do + root = double('root') + nested_root = double('nested_root') + closed = host(root) + plain = host(:none) + safari = host(nil) + nested = host(nested_root) + allow(driver).to receive(:execute_script) + .with(Percy::CLASSIC_SHADOW_CANDIDATES_JS, nil).and_return([closed, plain, safari]) + allow(driver).to receive(:execute_script) + .with(Percy::CLASSIC_SHADOW_CANDIDATES_JS, root).and_return([nested]) + allow(driver).to receive(:execute_script) + .with(Percy::CLASSIC_SHADOW_CANDIDATES_JS, nested_root).and_return(nil) + expect(driver).to receive(:execute_script).with(Percy::CLASSIC_SHADOW_SET_JS, closed, root) + expect(driver).to receive(:execute_script) + .with(Percy::CLASSIC_SHADOW_SET_JS, nested, nested_root) + Percy.expose_closed_shadow_roots_classic(driver) + end + + it 'stops at the host cap and logs' do + allow(Percy).to receive(:closed_shadow_max_hosts).and_return(1) + first = host(:none) + second = host(:none) + allow(driver).to receive(:execute_script).and_return([first, second]) + expect(second).to_not receive(:shadow_root) + expect(Percy).to receive(:log).with(/stopped at 1 hosts/, 'debug') + Percy.expose_closed_shadow_roots_classic(driver) + end + end + + describe '.closed_shadow_max_hosts' do + around(:each) do |example| + original = ENV['PERCY_CLOSED_SHADOW_MAX_HOSTS'] + example.run + ENV['PERCY_CLOSED_SHADOW_MAX_HOSTS'] = original + end + + it 'defaults to 200 and honours valid overrides' do + ENV.delete('PERCY_CLOSED_SHADOW_MAX_HOSTS') + expect(Percy.closed_shadow_max_hosts).to eq(200) + ENV['PERCY_CLOSED_SHADOW_MAX_HOSTS'] = '5' + expect(Percy.closed_shadow_max_hosts).to eq(5) + end + + it 'ignores invalid values' do + ENV['PERCY_CLOSED_SHADOW_MAX_HOSTS'] = '0' + expect(Percy.closed_shadow_max_hosts).to eq(200) + ENV['PERCY_CLOSED_SHADOW_MAX_HOSTS'] = 'many' + expect(Percy.closed_shadow_max_hosts).to eq(200) + end + end + + describe '.closed_shadow_classic_allowed?' do + let(:driver) { double('driver') } + + around(:each) do |example| + original = ENV['PERCY_CLOSED_SHADOW_CLASSIC'] + ENV.delete('PERCY_CLOSED_SHADOW_CLASSIC') + example.run + ENV['PERCY_CLOSED_SHADOW_CLASSIC'] = original + end + + def executor(url) + allow(Percy).to receive(:command_executor_host).and_call_original + http = double('http') + allow(http).to receive(:server_url).and_return(URI(url)) + bridge = double('bridge', http: http) + allow(driver).to receive(:respond_to?).with(:bridge, true).and_return(true) + allow(driver).to receive(:bridge).and_return(bridge) + end + + ['http://localhost:9515', 'http://127.0.0.1:4444/wd/hub', 'http://[::1]:9515'].each do |url| + it "allows local executor #{url}" do + executor(url) + expect(Percy.closed_shadow_classic_allowed?(driver)).to eq(true) + end + end + + it 'treats an unknown executor as remote' do + expect(Percy.closed_shadow_classic_allowed?(Object.new)).to eq(false) + end + + it 'treats an unreadable executor as remote' do + allow(driver).to receive(:respond_to?).with(:bridge, true).and_return(true) + allow(driver).to receive(:bridge).and_raise(StandardError) + expect(Percy.closed_shadow_classic_allowed?(driver)).to eq(false) + end + + it 'skips remote sessions and logs once at info' do + executor('https://hub-cloud.browserstack.com/wd/hub') + message = 'closed shadow roots skipped on remote session; start the session with ' \ + 'webSocketUrl: true or set PERCY_CLOSED_SHADOW_CLASSIC=true' + expect(Percy).to receive(:log).with(message).once + 2.times { expect(Percy.closed_shadow_classic_allowed?(driver)).to eq(false) } + end + + it 'allows remote sessions with PERCY_CLOSED_SHADOW_CLASSIC=true' do + ENV['PERCY_CLOSED_SHADOW_CLASSIC'] = 'true' + expect(Percy).to_not receive(:command_executor_host) + expect(Percy.closed_shadow_classic_allowed?(driver)).to eq(true) + end + end +end