Module: Percy
- Defined in:
- lib/percy.rb,
lib/version.rb
Constant Summary collapse
- CLIENT_INFO =
"percy-selenium-ruby/#{VERSION}".freeze
- ENV_INFO =
"selenium/#{Selenium::WebDriver::VERSION} ruby/#{RUBY_VERSION}".freeze
- SESSION_TYPE_AUTOMATE =
'automate'.freeze
- SESSION_TYPE_WEB =
'web'.freeze
- PERCY_DEBUG =
ENV['PERCY_LOGLEVEL'] == 'debug'
- PERCY_SERVER_ADDRESS =
ENV['PERCY_SERVER_ADDRESS'] || 'http://localhost:5338'
- LABEL =
"[\u001b[35m" + (PERCY_DEBUG ? 'percy:ruby' : 'percy') + "\u001b[39m]"
- RESPONSIVE_CAPTURE_SLEEP_TIME =
ENV['RESPONSIVE_CAPTURE_SLEEP_TIME'] || ENV['RESONSIVE_CAPTURE_SLEEP_TIME']
- VERSION =
'1.1.3-beta.0'.freeze
Class Method Summary collapse
- ._clear_cache! ⇒ Object
- .capture_responsive_dom(driver, options, percy_dom_script: nil) ⇒ Object
- .change_window_dimension_and_wait(driver, width, height, resize_count) ⇒ Object
- .create_region(bounding_box: nil, element_xpath: nil, element_css: nil, padding: nil, algorithm: 'ignore', diff_sensitivity: nil, image_ignore_threshold: nil, carousels_enabled: nil, banners_enabled: nil, ads_enabled: nil, diff_ignore_threshold: nil) ⇒ Object
- .fetch(url, data = nil) ⇒ Object
- .fetch_percy_dom ⇒ Object
- .get_browser_instance(driver) ⇒ Object
- .get_driver_metadata(driver) ⇒ Object
- .get_element_ids(elements) ⇒ Object
- .get_origin(url) ⇒ Object
- .get_responsive_widths(widths = []) ⇒ Object
- .get_serialized_dom(driver, options, percy_dom_script: nil) ⇒ Object
- .log(msg, lvl = 'info') ⇒ Object
- .percy_enabled? ⇒ Boolean
- .percy_screenshot(driver, name, options = {}) ⇒ Object
- .process_frame(driver, frame_element, options, percy_dom_script) ⇒ Object
- .resolve_readiness_config(options) ⇒ Object
- .responsive_capture_min_height? ⇒ Boolean
- .responsive_capture_reload_page? ⇒ Boolean
- .responsive_snapshot_capture?(options) ⇒ Boolean
- .snapshot(driver, name, options = {}) ⇒ Object
- .unsupported_iframe_src?(src) ⇒ Boolean
-
.wait_for_ready(driver, options) ⇒ Object
Readiness gate: runs PercyDOM.waitForReady via execute_async_script BEFORE serialize.
Class Method Details
._clear_cache! ⇒ Object
549 550 551 552 553 554 555 |
# File 'lib/percy.rb', line 549 def self._clear_cache! @percy_dom = nil @percy_enabled = nil @cli_config = nil @session_type = nil Cache.clear_cache! end |
.capture_responsive_dom(driver, options, percy_dom_script: nil) ⇒ Object
340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 |
# File 'lib/percy.rb', line 340 def self.capture_responsive_dom(driver, , percy_dom_script: nil) widths = get_responsive_widths([:widths] || []) dom_snapshots = [] window_size = get_browser_instance(driver).window.size current_width = window_size.width current_height = window_size.height last_window_width = current_width last_window_height = current_height resize_count = 0 driver.execute_script('PercyDOM.waitForResize()') target_height = current_height if responsive_capture_min_height? min = [:minHeight] || @cli_config&.dig('snapshot', 'minHeight') if min target_height = min else log('PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT is enabled but no minHeight value ' \ 'was provided in options or CLI config; using current window height', 'debug',) end end begin widths.each do |width_dict| width = width_dict['width'] 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) last_window_width = width last_window_height = height end if responsive_capture_reload_page? log("Reloading page for width: #{width}", 'debug') begin driver.navigate.refresh rescue StandardError begin driver.driver.browser.navigate.refresh rescue StandardError => e log("Failed to refresh page: #{e}", 'debug') end end 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 dom_snapshot = get_serialized_dom(driver, , percy_dom_script: percy_dom_script) dom_snapshot['width'] = width dom_snapshots << dom_snapshot end ensure change_window_dimension_and_wait(driver, current_width, current_height, resize_count + 1) end dom_snapshots end |
.change_window_dimension_and_wait(driver, width, height, resize_count) ⇒ Object
312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 |
# File 'lib/percy.rb', line 312 def self.change_window_dimension_and_wait(driver, width, height, resize_count) 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'));") 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 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') rescue Selenium::WebDriver::Error::TimeoutError log("Timed out waiting for window resize event for width #{width}", 'debug') end end |
.create_region(bounding_box: nil, element_xpath: nil, element_css: nil, padding: nil, algorithm: 'ignore', diff_sensitivity: nil, image_ignore_threshold: nil, carousels_enabled: nil, banners_enabled: nil, ads_enabled: nil, diff_ignore_threshold: nil) ⇒ Object
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
# File 'lib/percy.rb', line 33 def self.create_region( bounding_box: nil, element_xpath: nil, element_css: nil, padding: nil, algorithm: 'ignore', diff_sensitivity: nil, image_ignore_threshold: nil, carousels_enabled: nil, banners_enabled: nil, ads_enabled: nil, diff_ignore_threshold: nil ) element_selector = {} element_selector[:boundingBox] = bounding_box if bounding_box element_selector[:elementXpath] = element_xpath if element_xpath element_selector[:elementCSS] = element_css if element_css region = { algorithm: algorithm, elementSelector: element_selector, } region[:padding] = padding if padding if %w[standard intelliignore].include?(algorithm) configuration = { diffSensitivity: diff_sensitivity, imageIgnoreThreshold: image_ignore_threshold, carouselsEnabled: carousels_enabled, bannersEnabled: , adsEnabled: ads_enabled, }.compact region[:configuration] = configuration unless configuration.empty? end assertion = {} assertion[:diffIgnoreThreshold] = diff_ignore_threshold unless diff_ignore_threshold.nil? region[:assertion] = assertion unless assertion.empty? region end |
.fetch(url, data = nil) ⇒ Object
469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 |
# File 'lib/percy.rb', line 469 def self.fetch(url, data = nil) uri = URI("#{PERCY_SERVER_ADDRESS}/#{url}") response = if data http = Net::HTTP.new(uri.host, uri.port) http.read_timeout = 600 # seconds request = Net::HTTP::Post.new(uri.path) request.body = data.to_json http.request(request) else Net::HTTP.get_response(uri) end unless response.is_a? Net::HTTPSuccess raise StandardError, "Failed with HTTP error code: #{response.code}" end response end |
.fetch_percy_dom ⇒ Object
447 448 449 450 451 452 |
# File 'lib/percy.rb', line 447 def self.fetch_percy_dom return @percy_dom unless @percy_dom.nil? response = fetch('percy/dom.js') @percy_dom = response.body end |
.get_browser_instance(driver) ⇒ Object
111 112 113 114 115 116 117 |
# File 'lib/percy.rb', line 111 def self.get_browser_instance(driver) if driver.respond_to?(:driver) && driver.driver.respond_to?(:browser) return driver.driver.browser.manage end driver.manage end |
.get_driver_metadata(driver) ⇒ Object
541 542 543 |
# File 'lib/percy.rb', line 541 def self.(driver) DriverMetaData.new(driver) end |
.get_element_ids(elements) ⇒ Object
545 546 547 |
# File 'lib/percy.rb', line 545 def self.get_element_ids(elements) elements.map(&:id) end |
.get_origin(url) ⇒ Object
233 234 235 236 237 238 239 240 241 |
# File 'lib/percy.rb', line 233 def self.get_origin(url) uri = URI.parse(url) raise URI::InvalidURIError, "no host in #{url}" if uri.host.nil? netloc = uri.host.to_s default_ports = {'http' => 80, 'https' => 443} netloc += ":#{uri.port}" if uri.port && uri.port != default_ports[uri.scheme] "#{uri.scheme}://#{netloc}" end |
.get_responsive_widths(widths = []) ⇒ Object
292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 |
# File 'lib/percy.rb', line 292 def self.get_responsive_widths(widths = []) begin widths_list = widths.is_a?(Array) ? widths : [] query_param = widths_list.any? ? "?widths=#{widths_list.join(',')}" : '' response = fetch("percy/widths-config#{query_param}") data = JSON.parse(response.body) widths_data = data['widths'] unless widths_data.is_a?(Array) msg = 'Update Percy CLI to the latest version to use responsiveSnapshotCapture' raise StandardError, msg end widths_data rescue StandardError => e log("Failed to get responsive widths: #{e}.", 'debug') raise StandardError, 'Update Percy CLI to the latest version to use ' \ 'responsiveSnapshotCapture' end end |
.get_serialized_dom(driver, options, percy_dom_script: nil) ⇒ Object
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 |
# File 'lib/percy.rb', line 180 def self.get_serialized_dom(driver, , percy_dom_script: nil) # Readiness gate before serialize. Graceful on old CLI. readiness_diagnostics = wait_for_ready(driver, ) # Strip `readiness` from forwarded serialize args -- it's consumed by # wait_for_ready upstream, not a PercyDOM.serialize argument. = .reject { |k, _| k.to_s == 'readiness' } dom_snapshot = driver.execute_script("return PercyDOM.serialize(#{.to_json})") # `!nil?` preserves legitimate falsy returns like {} ("gate ran, no # notable diagnostics"). if !readiness_diagnostics.nil? && 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') : [] if iframes.any? processed_frames = [] iframes.each do |frame| frame_src = frame.attribute('src') next if unsupported_iframe_src?(frame_src) begin frame_origin = get_origin(URI.join(driver.current_url, frame_src).to_s) rescue StandardError => e log("Skipping iframe \"#{frame_src}\": #{e}", 'debug') next end next if frame_origin == page_origin result = process_frame(driver, frame, , percy_dom_script) processed_frames << result if result end dom_snapshot['corsIframes'] = processed_frames if processed_frames.any? end rescue StandardError => e log("Failed to process cross-origin iframes: #{e}", 'debug') begin driver.switch_to.default_content rescue StandardError nil end end dom_snapshot['cookies'] = get_browser_instance(driver). dom_snapshot end |
.log(msg, lvl = 'info') ⇒ Object
454 455 456 457 458 459 460 461 462 463 464 465 466 467 |
# File 'lib/percy.rb', line 454 def self.log(msg, lvl = 'info') msg = "#{LABEL} #{msg}" begin fetch('percy/log', {message: msg, level: lvl}) rescue StandardError => e if PERCY_DEBUG puts "Sending log to CLI Failed #{e}" end ensure if lvl != 'debug' || PERCY_DEBUG puts msg end end end |
.percy_enabled? ⇒ Boolean
412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 |
# File 'lib/percy.rb', line 412 def self.percy_enabled? return @percy_enabled unless @percy_enabled.nil? begin response = fetch('percy/healthcheck') version = response['x-percy-core-version'] if version.nil? log('You may be using @percy/agent ' \ 'which is no longer supported by this SDK. ' \ 'Please uninstall @percy/agent and install @percy/cli instead. ' \ 'https://www.browserstack.com/docs/percy/migration/migrate-to-cli') @percy_enabled = false return false end if version.split('.')[0] != '1' log("Unsupported Percy CLI version, #{version}") @percy_enabled = false return false end response_body = JSON.parse(response.body) @cli_config = response_body['config'] @session_type = response_body['type'] @percy_enabled = true true rescue StandardError => e log('Percy is not running, disabling snapshots') log(e, 'debug') @percy_enabled = false false end end |
.percy_screenshot(driver, name, options = {}) ⇒ Object
489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 |
# File 'lib/percy.rb', line 489 def self.percy_screenshot(driver, name, = {}) return unless percy_enabled? unless @session_type == SESSION_TYPE_AUTOMATE raise StandardError, 'Invalid function call - percy_screenshot(). ' \ 'Please use percy_snapshot() function for taking screenshot. ' \ 'percy_screenshot() should be used only while using Percy with Automate. ' \ 'For more information on usage of percy_snapshot(), ' \ 'refer doc for your language https://www.browserstack.com/docs/percy/integrate/overview' end begin = .dup = (driver) if .key?(:ignoreRegionSeleniumElements) [:ignore_region_selenium_elements] = .delete(:ignoreRegionSeleniumElements) end if .key?(:considerRegionSeleniumElements) [:consider_region_selenium_elements] = .delete(:considerRegionSeleniumElements) end ignore_region_elements = get_element_ids(.delete(:ignore_region_selenium_elements) || []) consider_region_elements = get_element_ids(.delete(:consider_region_selenium_elements) || []) [:ignore_region_elements] = ignore_region_elements [:consider_region_elements] = consider_region_elements response = fetch('percy/automateScreenshot', client_info: CLIENT_INFO, environment_info: ENV_INFO, sessionId: .session_id, commandExecutorUrl: .command_executor_url, capabilities: .capabilities, snapshotName: name, options: ,) body = JSON.parse(response.body) unless body['success'] raise StandardError, body['error'] end body['data'] rescue StandardError => e log("Could not take Screenshot '#{name}'") log(e, 'debug') end end |
.process_frame(driver, frame_element, options, percy_dom_script) ⇒ Object
243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 |
# File 'lib/percy.rb', line 243 def self.process_frame(driver, frame_element, , percy_dom_script) frame_url = frame_element.attribute('src') || 'unknown-src' iframe_snapshot = nil begin driver.switch_to.frame(frame_element) begin driver.execute_script(percy_dom_script) = .merge('enableJavaScript' => true) iframe_snapshot = driver.execute_script("return PercyDOM.serialize(#{.to_json})") rescue StandardError => e log("Failed to process cross-origin frame #{frame_url}: #{e}", 'debug') ensure begin driver.switch_to.default_content rescue StandardError begin driver.switch_to.parent_frame rescue StandardError nil end end end rescue StandardError => e log("Failed to switch to frame #{frame_url}: #{e}", 'debug') begin driver.switch_to.default_content rescue StandardError nil end return nil end return nil if iframe_snapshot.nil? percy_element_id = frame_element.attribute('data-percy-element-id') unless percy_element_id log("Skipping frame #{frame_url}: no matching percyElementId found", 'debug') return nil end { 'iframeData' => {'percyElementId' => percy_element_id}, 'iframeSnapshot' => iframe_snapshot, 'frameUrl' => frame_url, } end |
.resolve_readiness_config(options) ⇒ Object
122 123 124 125 126 127 128 129 130 131 |
# File 'lib/percy.rb', line 122 def self.resolve_readiness_config() global = @cli_config&.dig('snapshot', 'readiness') global = {} unless global.is_a?(Hash) per_snapshot = [:readiness] || ['readiness'] per_snapshot = {} unless per_snapshot.is_a?(Hash) # Normalise symbol keys to strings so the merge collapses :preset and 'preset'. [global, per_snapshot].each_with_object({}) do |hash, merged| hash.each { |k, v| merged[k.to_s] = v } end end |
.responsive_capture_min_height? ⇒ Boolean
27 28 29 30 31 |
# File 'lib/percy.rb', line 27 def self.responsive_capture_min_height? val = ENV['PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT'] || ENV['PERCY_RESONSIVE_CAPTURE_MIN_HEIGHT'] || 'false' val.casecmp('true') == 0 end |
.responsive_capture_reload_page? ⇒ Boolean
21 22 23 24 25 |
# File 'lib/percy.rb', line 21 def self.responsive_capture_reload_page? val = ENV['PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE'] || ENV['PERCY_RESONSIVE_CAPTURE_RELOAD_PAGE'] || 'false' val.casecmp('true') == 0 end |
.responsive_snapshot_capture?(options) ⇒ Boolean
404 405 406 407 408 409 410 |
# File 'lib/percy.rb', line 404 def self.responsive_snapshot_capture?() return false if @cli_config&.dig('percy', 'deferUploads') [:responsive_snapshot_capture] || [:responsiveSnapshotCapture] || @cli_config&.dig('snapshot', 'responsiveSnapshotCapture') end |
.snapshot(driver, name, options = {}) ⇒ Object
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'lib/percy.rb', line 69 def self.snapshot(driver, name, = {}) return unless percy_enabled? if @session_type == SESSION_TYPE_AUTOMATE raise StandardError, 'Invalid function call - percy_snapshot(). ' \ 'Please use percy_screenshot() function while using Percy with Automate. ' \ 'For more information on usage of percy_screenshot(), ' \ 'refer https://www.browserstack.com/docs/percy/integrate/functional-and-visual' end begin percy_dom_script = fetch_percy_dom driver.execute_script(percy_dom_script) dom_snapshot = if responsive_snapshot_capture?() capture_responsive_dom(driver, , percy_dom_script: percy_dom_script) else get_serialized_dom(driver, , percy_dom_script: percy_dom_script) end # Strip `readiness` before POSTing -- SDK-local config that the CLI # already has via healthcheck. = .reject { |k, _| k.to_s == 'readiness' } response = fetch('percy/snapshot', name: name, url: driver.current_url, dom_snapshot: dom_snapshot, client_info: CLIENT_INFO, environment_info: ENV_INFO, **,) body = JSON.parse(response.body) unless body['success'] raise StandardError, body['error'] end body['data'] rescue StandardError => e log("Could not take DOM snapshot '#{name}'") log(e, 'debug') end end |
.unsupported_iframe_src?(src) ⇒ Boolean
228 229 230 231 |
# File 'lib/percy.rb', line 228 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:') end |
.wait_for_ready(driver, options) ⇒ Object
Readiness gate: 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.
136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 |
# File 'lib/percy.rb', line 136 def self.wait_for_ready(driver, ) readiness_config = resolve_readiness_config() return nil if readiness_config['preset'] == 'disabled' # Match the driver's async-script timeout to readiness.timeoutMs so a # higher user-configured timeout isn't silently capped by Selenium's # default (~30s) firing ScriptTimeoutException before the in-page # Promise resolves. timeout_ms = readiness_config['timeoutMs'] previous_timeout = nil if timeout_ms.is_a?(Numeric) && timeout_ms > 0 begin previous_timeout = driver.manage.timeouts.script_timeout driver.manage.timeouts.script_timeout = (timeout_ms / 1000.0) + 2 rescue StandardError previous_timeout = nil # best-effort; older Selenium / unsupported end end 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 ensure if previous_timeout begin driver.manage.timeouts.script_timeout = previous_timeout rescue StandardError # best-effort end end end end |