Files
zammad/spec/support/capybara/playwright_driver_patches.rb
2026-10-05 21:24:38 +02:00

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)