249 lines
10 KiB
Ruby
249 lines
10 KiB
Ruby
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
|
|
|
# THROWAWAY PILOT: targeted compensations for capybara-playwright-driver gaps,
|
|
# each aligning the Playwright driver with the Selenium drivers' behavior so
|
|
# specs can stay driver-agnostic. Kept in one place on purpose - candidates
|
|
# for upstreaming, not a pattern to extend casually.
|
|
|
|
require 'capybara/playwright'
|
|
|
|
# The patches below reach into gem internals, so they are written against this
|
|
# exact version. On an upgrade: check whether they were fixed upstream, drop
|
|
# what is obsolete, then bump the pin here.
|
|
if Gem.loaded_specs['capybara-playwright-driver'].version != Gem::Version.new('0.5.12')
|
|
raise 'capybara-playwright-driver was updated - re-evaluate the patches in ' \
|
|
"#{__FILE__} against the new version, then adjust the version pin."
|
|
end
|
|
|
|
# Messages the browser reports when the element or its document went away
|
|
# mid-operation. The gem translates most of them into StaleReferenceError,
|
|
# which is one of the driver's invalid_element_errors and therefore makes
|
|
# Capybara reload the node and retry - but it misses three cases:
|
|
#
|
|
# - its Firefox pattern expects the channel name `content::page`, while the
|
|
# browser actually reports `content::85/124/3` and the like, so the error
|
|
# falls through untranslated;
|
|
# - page-level finds run through the gem's #assert_page_alive, which retries a
|
|
# few of them but raises the raw error once its retries are used up, so a
|
|
# navigation during e.g. a `have_css?` wait can abort the wait instead of
|
|
# retrying it;
|
|
# - a few node methods evaluate in the page without going through the
|
|
# translating wrapper at all (see the patches below).
|
|
module ZammadCapybaraPlaywrightStaleErrors
|
|
MESSAGES = Regexp.union(
|
|
'Execution context was destroyed, most likely because of a navigation',
|
|
'Element is not attached to the DOM',
|
|
'Cannot find context with specified id',
|
|
'Unable to adopt element handle from a different document',
|
|
%r{exception while running method "adoptNode"},
|
|
).freeze
|
|
|
|
# Re-raises as StaleReferenceError when the message says the element or its
|
|
# document went away, and re-raises unchanged otherwise.
|
|
def self.translate(error)
|
|
raise error if !MESSAGES.match?(error.message)
|
|
|
|
raise Capybara::Playwright::Node::StaleReferenceError, error
|
|
end
|
|
end
|
|
|
|
# The JS the read overrides below run in the page. Kept in their own module so
|
|
# the patch module stays about behaviour.
|
|
module ZammadCapybaraPlaywrightReadScripts
|
|
STALE_GUARD_JS = <<~JS.freeze
|
|
if (!el.isConnected) throw new Error('Element is not attached to the DOM');
|
|
JS
|
|
|
|
# The gem's #visible? visibility walk, verbatim (non-interpolating heredoc:
|
|
# `${map_name}` is a JS template literal).
|
|
IS_VISIBLE_JS = <<~'JS'.freeze
|
|
function isVisible(el) {
|
|
if (el.tagName == 'AREA'){
|
|
const map_name = document.evaluate('./ancestor::map/@name', el, null, XPathResult.STRING_TYPE, null).stringValue;
|
|
el = document.querySelector(`img[usemap='#${map_name}']`);
|
|
if (!el){
|
|
return false;
|
|
}
|
|
}
|
|
var forced_visible = false;
|
|
while (el) {
|
|
const style = window.getComputedStyle(el);
|
|
if (style.visibility == 'visible')
|
|
forced_visible = true;
|
|
if ((style.display == 'none') ||
|
|
((style.visibility == 'hidden') && !forced_visible) ||
|
|
(parseFloat(style.opacity) == 0)) {
|
|
return false;
|
|
}
|
|
var parent = el.parentElement;
|
|
if (parent && (parent.tagName == 'DETAILS') && !parent.open && (el.tagName != 'SUMMARY')) {
|
|
return false;
|
|
}
|
|
el = parent;
|
|
}
|
|
return true;
|
|
}
|
|
JS
|
|
|
|
VISIBLE_JS = <<~JS.freeze
|
|
(el) => {
|
|
#{STALE_GUARD_JS}
|
|
#{IS_VISIBLE_JS}
|
|
return isVisible(el);
|
|
}
|
|
JS
|
|
|
|
# The gem reads innerText, which differs from the WebDriver element text the
|
|
# Selenium drivers return - most visibly in line breaks, a <p> boundary is
|
|
# two there - so exact multi-line expectations only held under Selenium.
|
|
# Uses Selenium's own algorithm instead, see playwright_visible_text.js.
|
|
# The formatter guards the file's leading parenthesis with a semicolon.
|
|
WEBDRIVER_TEXT_JS = File.read(File.join(__dir__, 'playwright_visible_text.js'))
|
|
.lines.drop_while { |line| line.start_with?('//') || line.strip.empty? }
|
|
.join.delete_prefix(';').freeze
|
|
|
|
VISIBLE_TEXT_JS = <<~JS.freeze
|
|
(el) => {
|
|
#{STALE_GUARD_JS}
|
|
return (#{WEBDRIVER_TEXT_JS})(el);
|
|
}
|
|
JS
|
|
|
|
ALL_TEXT_JS = <<~JS.freeze
|
|
(el) => {
|
|
#{STALE_GUARD_JS}
|
|
return el.textContent;
|
|
}
|
|
JS
|
|
|
|
# Property first, attribute as fallback - same decision the gem makes in
|
|
# Ruby (`property(name) || attribute(name)`, where objects and functions
|
|
# are discarded), expressed with JS falsiness matching Ruby's.
|
|
PROPERTY_OR_ATTRIBUTE_JS = <<~JS.freeze
|
|
(el, name) => {
|
|
#{STALE_GUARD_JS}
|
|
let value = el[name];
|
|
if (typeof value === 'object' || typeof value === 'function') {
|
|
value = null;
|
|
}
|
|
if (value === null || value === undefined || value === false) {
|
|
return el.getAttribute(name);
|
|
}
|
|
return value;
|
|
}
|
|
JS
|
|
end
|
|
|
|
module ZammadCapybaraPlaywrightNodePatches
|
|
include ZammadCapybaraPlaywrightReadScripts
|
|
|
|
# Most node operations are wrapped in the gem's #assert_element_not_stale,
|
|
# which translates raw Playwright errors for detached/navigated-away
|
|
# elements into StaleReferenceError - a member of the driver's
|
|
# invalid_element_errors, which Capybara's synchronize machinery rescues,
|
|
# reloads the node for and retries (just like Selenium's
|
|
# StaleElementReferenceError). Its own patterns miss the Firefox message
|
|
# (see above), so route what it re-raises through the broader translation.
|
|
def assert_element_not_stale(&)
|
|
super
|
|
rescue ::Playwright::Error => e
|
|
ZammadCapybaraPlaywrightStaleErrors.translate(e)
|
|
end
|
|
|
|
# These read methods evaluate in the page without that wrapper, and
|
|
# Capybara already has a rescue waiting for each of them - but only for the
|
|
# translated error:
|
|
#
|
|
# - #== is called per candidate node while filtering a query
|
|
# (Queries::SelectorQuery#matches_filters?), which rescues
|
|
# invalid_element_errors and drops the node from the result set;
|
|
# - #tag_name is called by Node::Element#inspect, which rescues them into
|
|
# "Obsolete #<Capybara::Node::Element>". Untranslated it raises *while
|
|
# Capybara builds a failure message*, replacing the real failure with a
|
|
# navigation error - the reason a stale <select> reports
|
|
# "Execution context was destroyed" instead of what did not match;
|
|
# - the predicates back Capybara's own #disabled?/#readonly? etc., where a
|
|
# stale node means the enclosing synchronize should retry.
|
|
#
|
|
# Translated rather than wrapped in #assert_element_not_stale, because that
|
|
# wrapper spends a liveness round trip per call and these run per candidate
|
|
# node per query. Selenium raises its own StaleElementReferenceError here,
|
|
# which is what those rescues were written for (#== not even that: it
|
|
# compares element ids client-side without touching the browser).
|
|
#
|
|
# Still unguarded, but with no observed failures: the mutating #set, #drop,
|
|
# #scroll_by, #trigger, #unselect_option and #shadow_root.
|
|
%i[== tag_name disabled? readonly? multiple?].each do |method_name|
|
|
define_method(method_name) do |*args|
|
|
super(*args)
|
|
rescue ::Playwright::Error => e
|
|
ZammadCapybaraPlaywrightStaleErrors.translate(e)
|
|
end
|
|
end
|
|
|
|
# Capybara reads - #text, #[], #visible? - run hundreds of times per example,
|
|
# and the gem spends 2-4 browser round trips on each one: #assert_element_
|
|
# not_stale fires a bare `enabled?` liveness probe before every read,
|
|
# #visible_text additionally calls #visible? (another probe + evaluate)
|
|
# before extracting the text, and #[] splits property and attribute lookup
|
|
# over three calls. Selenium needs one WebDriver command for each of these;
|
|
# measured on one desktop example the difference is 7,319 vs 1,195 round
|
|
# trips, and in CI every round trip crosses the Docker network to the
|
|
# browser service container. Fold each read into a single evaluate.
|
|
#
|
|
# The staleness contract stays intact: for a detached element the JS throws
|
|
# the exact message the gem's own rescue translates into
|
|
# StaleReferenceError, so Capybara's synchronize machinery reloads the node
|
|
# and retries the same as before.
|
|
def visible?
|
|
read_in_one_round_trip(VISIBLE_JS)
|
|
end
|
|
|
|
# Already trimmed and with non-breaking spaces replaced, as Selenium returns
|
|
# it - trimming again would drop a trailing space Selenium keeps.
|
|
def visible_text
|
|
read_in_one_round_trip(VISIBLE_TEXT_JS).to_s.scrub
|
|
end
|
|
|
|
def all_text
|
|
text = read_in_one_round_trip(ALL_TEXT_JS)
|
|
text.to_s.gsub(%r{[\u200b\u200e\u200f]}, '')
|
|
.gsub(%r{[\ \n\f\t\v\u2028\u2029]+}, ' ')
|
|
.gsub(%r{\A[[:space:]&&[^\u00a0]]+}, '')
|
|
.gsub(%r{[[:space:]&&[^\u00a0]]+\z}, '')
|
|
.tr("\u00a0", ' ')
|
|
end
|
|
|
|
def [](name)
|
|
read_in_one_round_trip(PROPERTY_OR_ATTRIBUTE_JS, arg: name.to_s)
|
|
end
|
|
|
|
private
|
|
|
|
def read_in_one_round_trip(script, arg: nil)
|
|
@element.evaluate(script, arg: arg)
|
|
rescue ::Playwright::Error => e
|
|
# Route the error through the gem's translation table. Its leading
|
|
# `enabled?` probe runs only on this error path, never on the hot path.
|
|
assert_element_not_stale { raise e }
|
|
end
|
|
end
|
|
|
|
Capybara::Playwright::Node.prepend(ZammadCapybaraPlaywrightNodePatches)
|
|
|
|
module ZammadCapybaraPlaywrightBrowserPatches
|
|
# Page-level finds use the gem's #assert_page_alive, which retries a few
|
|
# staleness errors but raises the raw Playwright::Error once its retries are
|
|
# used up, so a navigation while Capybara waits for a selector can abort the
|
|
# wait instead of being retried.
|
|
%i[find_css find_xpath].each do |method_name|
|
|
define_method(method_name) do |*args, **options, &block|
|
|
super(*args, **options, &block)
|
|
rescue ::Playwright::Error => e
|
|
ZammadCapybaraPlaywrightStaleErrors.translate(e)
|
|
end
|
|
end
|
|
end
|
|
|
|
Capybara::Playwright::Browser.prepend(ZammadCapybaraPlaywrightBrowserPatches)
|