Maintenance: Align the Playwright drivers with Selenium and add an on-demand Firefox lane.
Approved-by: Florian Liebe <fl@zammad.com>
This commit is contained in:
@@ -34,6 +34,11 @@
|
||||
# here instead of at the server's version handshake.
|
||||
PLAYWRIGHT_VERSION: '1.63.0'
|
||||
|
||||
.variables_playwright_firefox:
|
||||
extends: .variables_playwright
|
||||
variables:
|
||||
SELENIUM_BROWSER: 'playwright_firefox'
|
||||
|
||||
.variables_proxy:
|
||||
variables:
|
||||
CI_PROXY_URL: 'ci-service-proxy:3128'
|
||||
|
||||
@@ -45,3 +45,12 @@
|
||||
- !reference [.services, postgresql]
|
||||
- !reference [.services, playwright]
|
||||
- !reference [.services, redis]
|
||||
|
||||
.template_browser-core_capybara_playwright_firefox:
|
||||
extends:
|
||||
- .template_browser-core_capybara
|
||||
- .variables_playwright_firefox
|
||||
services:
|
||||
- !reference [.services, postgresql]
|
||||
- !reference [.services, playwright]
|
||||
- !reference [.services, redis]
|
||||
|
||||
@@ -20,3 +20,13 @@ capybara:playwright:
|
||||
- !reference [.rule_fragments, then_manual_in_mr]
|
||||
allow_failure: true
|
||||
parallel: 16
|
||||
|
||||
# Evaluation lane for Playwright's Firefox, not decided yet whether it stays -
|
||||
# only started on demand and never gates.
|
||||
capybara:firefox:playwright:
|
||||
extends:
|
||||
- .template_browser-core_capybara_playwright_firefox
|
||||
rules:
|
||||
- !reference [.rule_fragments, variable_filters]
|
||||
- !reference [.rule_fragments, then_manual]
|
||||
parallel: 16
|
||||
|
||||
@@ -133,6 +133,12 @@ Copyright: Faruk Ates (https://twitter.com/KuraFire)
|
||||
Richard Herrera (https://twitter.com/doctyper)
|
||||
License: MIT license & BSD license
|
||||
-----------------------------------------------------------------------------
|
||||
playwright_visible_text.js
|
||||
Source: https://github.com/SeleniumHQ/selenium
|
||||
Copyright: 2011-2026 Software Freedom Conservancy
|
||||
2004-2011 Selenium committers
|
||||
License: Apache License, Version 2.0
|
||||
-----------------------------------------------------------------------------
|
||||
qrcodegen.js
|
||||
Source: https://www.nayuki.io/page/qr-code-generator-library
|
||||
Copyright: 2022, Project Nayuki
|
||||
|
||||
@@ -19,6 +19,13 @@ pnpm playwright:install
|
||||
|
||||
Without it, the first driver call fails with `Executable doesn't exist ... chrome-headless-shell`.
|
||||
|
||||
The Firefox driver (`SELENIUM_BROWSER=playwright_firefox`) uses Playwright's own Firefox build, which this script does
|
||||
not download. Install it once as well if you want to use that driver:
|
||||
|
||||
```sh
|
||||
pnpm exec playwright install firefox
|
||||
```
|
||||
|
||||
### In the Devcontainer
|
||||
|
||||
There is no Playwright service container - the browser is launched inside the devcontainer itself, so the download
|
||||
@@ -61,7 +68,9 @@ SELENIUM_BROWSER_HEADLESS=1 SELENIUM_BROWSER=playwright bundle exec rspec spec/s
|
||||
In the devcontainer there is no display to show the browser on, so the variable is required there. The `with-selenium`
|
||||
stack already sets it for the whole container; the default stack does not.
|
||||
|
||||
Only Chromium is available. Examples tagged with `mobile_user_agent` automatically use the mobile variant of the driver.
|
||||
`SELENIUM_BROWSER=playwright` runs Chromium, `SELENIUM_BROWSER=playwright_firefox` runs Playwright's Firefox build
|
||||
(see the one-time setup above). Examples tagged with `mobile_user_agent` automatically use the mobile variant of the
|
||||
driver.
|
||||
|
||||
## Traces
|
||||
|
||||
|
||||
@@ -186,8 +186,11 @@ module BrowserTestHelper # rubocop:disable Metrics/ModuleLength
|
||||
script_timeout_retried = true
|
||||
retry
|
||||
end
|
||||
rescue Selenium::WebDriver::Error::TimeoutError, Selenium::WebDriver::Error::JavascriptError
|
||||
rescue Selenium::WebDriver::Error::TimeoutError, Selenium::WebDriver::Error::JavascriptError, Playwright::Error
|
||||
nil # Page may navigate away mid-check (e.g. SAML redirect), making App undefined.
|
||||
# Playwright::Error is the equivalent of both Selenium classes here: the
|
||||
# guard above cannot prevent this, as the document can be replaced between
|
||||
# it and the check below, and Playwright::TimeoutError subclasses it.
|
||||
end
|
||||
|
||||
# Waits until all pending Core Workflow requests of the legacy frontend are
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
# THROWAWAY PILOT: registers :zammad_playwright (+ _mobile) alongside the
|
||||
# Selenium drivers. Select via SELENIUM_BROWSER=playwright.
|
||||
# THROWAWAY PILOT: registers :zammad_playwright and :zammad_playwright_firefox
|
||||
# (each with a _mobile twin) alongside the Selenium drivers. Select via
|
||||
# SELENIUM_BROWSER=playwright or SELENIUM_BROWSER=playwright_firefox.
|
||||
#
|
||||
# Local (non-CI) runs launch the `playwright` package installed by `pnpm
|
||||
# install` (tracked in package.json, pinned to Playwright::
|
||||
# COMPATIBLE_PLAYWRIGHT_VERSION so it stays in lockstep with the gem). The
|
||||
# package ships no browsers, so a one-time `pnpm playwright:install` is
|
||||
# required as well - without it the first driver call fails with
|
||||
# "Executable doesn't exist ... chrome-headless-shell". CI instead sets
|
||||
# "Executable doesn't exist ... chrome-headless-shell". That script installs
|
||||
# Chromium only; the Firefox driver additionally needs a one-time
|
||||
# `pnpm exec playwright install firefox`. CI instead sets
|
||||
# PLAYWRIGHT_SERVER_URL to talk to the pre-built zammad-playwright service
|
||||
# container, so neither the local package nor its browsers are needed there.
|
||||
|
||||
@@ -37,6 +40,20 @@ Capybara.register_driver(:zammad_playwright_mobile) do |app|
|
||||
build_playwright_driver(app, user_agent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Mobile Safari/537.36')
|
||||
end
|
||||
|
||||
# EVALUATION: is Playwright's Firefox a viable replacement for the Selenium
|
||||
# Firefox lane? Note it is Playwright's own patched Gecko build, not the
|
||||
# branded Firefox the Selenium lane drives.
|
||||
Capybara.register_driver(:zammad_playwright_firefox) do |app|
|
||||
build_playwright_driver(app, browser_type: :firefox)
|
||||
end
|
||||
|
||||
Capybara.register_driver(:zammad_playwright_firefox_mobile) do |app|
|
||||
# Same UA string as :zammad_firefox_mobile. Needed because driven_by.rb
|
||||
# appends '_mobile' for mobile_user_agent examples, so every browser here
|
||||
# must have a twin or those examples raise Capybara::DriverNotFoundError.
|
||||
build_playwright_driver(app, browser_type: :firefox, user_agent: 'Mozilla/5.0 (Android 13; Mobile; rv:109.0) Gecko/112.0 Firefox/112.0')
|
||||
end
|
||||
|
||||
# The playwright npm package must stay in lockstep with the
|
||||
# playwright-ruby-client gem, or the two protocol sides drift apart silently.
|
||||
# Local runs and CI are provisioned from separate pins (package.json vs the
|
||||
@@ -72,18 +89,20 @@ PLAYWRIGHT_LAUNCH_OPTIONS = { args: ['--ignore-certificate-errors'] }.freeze
|
||||
# `connect_to_browser_server` has no launch-option parameter, but the server
|
||||
# reads them from the endpoint's `launch-options` query (or an equivalent
|
||||
# header). It only honors args when started with `--unsafe`, see services.yml.
|
||||
def playwright_server_endpoint_url
|
||||
url = ENV['PLAYWRIGHT_SERVER_URL']
|
||||
def playwright_server_endpoint_url(launch_options)
|
||||
url = ENV['PLAYWRIGHT_SERVER_URL']
|
||||
return url if launch_options.blank?
|
||||
|
||||
separator = url.include?('?') ? '&' : '?'
|
||||
|
||||
"#{url}#{separator}launch-options=#{CGI.escape(PLAYWRIGHT_LAUNCH_OPTIONS.to_json)}"
|
||||
"#{url}#{separator}launch-options=#{CGI.escape(launch_options.to_json)}"
|
||||
end
|
||||
|
||||
def build_playwright_driver(app, user_agent: nil)
|
||||
def build_playwright_driver(app, user_agent: nil, browser_type: :chromium)
|
||||
assert_playwright_version_lockstep
|
||||
|
||||
options = {
|
||||
browser_type: :chromium,
|
||||
browser_type: browser_type,
|
||||
# The app is served over HTTPS by the :puma_wrapper Capybara server with
|
||||
# self-signed certs from the 'localhost' gem. ignoreHTTPSErrors is part of
|
||||
# the gem's NEW_PAGE_PARAMS (page_options.rb) and is forwarded to
|
||||
@@ -97,21 +116,26 @@ def build_playwright_driver(app, user_agent: nil)
|
||||
# permissions granted, so programmatic clipboard writes (e.g. copy-to-
|
||||
# clipboard buttons) silently fail without this, breaking any spec that
|
||||
# later reads the OS clipboard back (e.g. via a pasted keystroke).
|
||||
permissions: %w[clipboard-read clipboard-write],
|
||||
# Chromium only - Firefox rejects these permission names outright.
|
||||
permissions: browser_type == :chromium ? %w[clipboard-read clipboard-write] : [],
|
||||
}
|
||||
|
||||
# The launch arguments are Chromium flags, so only Chromium gets them - and in
|
||||
# both modes, otherwise local and CI browsers would launch differently.
|
||||
launch_options = browser_type == :chromium ? PLAYWRIGHT_LAUNCH_OPTIONS : {}
|
||||
|
||||
if ENV['PLAYWRIGHT_SERVER_URL'].present?
|
||||
# Remote browser server (CI): `playwright run-server` in the
|
||||
# zammad-playwright service container, which launches a browser per
|
||||
# connection with the launch options passed below; headless is its
|
||||
# default. The context options above are applied client-side.
|
||||
options[:browser_server_endpoint_url] = playwright_server_endpoint_url
|
||||
options[:browser_server_endpoint_url] = playwright_server_endpoint_url(launch_options)
|
||||
else
|
||||
# Local subprocess: the driver launches the browser itself, so the same
|
||||
# launch options are passed straight through (BrowserOptions::LAUNCH_PARAMS).
|
||||
options[:playwright_cli_executable_path] = PLAYWRIGHT_CLI_PATH
|
||||
options[:headless] = ENV['SELENIUM_BROWSER_HEADLESS'].present?
|
||||
options.merge!(PLAYWRIGHT_LAUNCH_OPTIONS)
|
||||
options.merge!(launch_options)
|
||||
end
|
||||
|
||||
options[:userAgent] = user_agent if user_agent.present?
|
||||
|
||||
@@ -15,21 +15,41 @@ if Gem.loaded_specs['capybara-playwright-driver'].version != Gem::Version.new('0
|
||||
"#{__FILE__} against the new version, then adjust the version pin."
|
||||
end
|
||||
|
||||
module ZammadCapybaraPlaywrightNodePatches
|
||||
# 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.
|
||||
# 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
|
||||
@@ -73,20 +93,19 @@ module ZammadCapybaraPlaywrightNodePatches
|
||||
}
|
||||
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}
|
||||
#{IS_VISIBLE_JS}
|
||||
if (!isVisible(el)) {
|
||||
return '';
|
||||
}
|
||||
if (el.nodeName == 'TEXTAREA') {
|
||||
return el.textContent;
|
||||
}
|
||||
if (el instanceof SVGElement) {
|
||||
return el.textContent.replace(/\\n+/g, '\\n');
|
||||
}
|
||||
return el.innerText;
|
||||
return (#{WEBDRIVER_TEXT_JS})(el);
|
||||
}
|
||||
JS
|
||||
|
||||
@@ -113,16 +132,77 @@ module ZammadCapybaraPlaywrightNodePatches
|
||||
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
|
||||
text = read_in_one_round_trip(VISIBLE_TEXT_JS)
|
||||
text.to_s.scrub.gsub(%r{\A[[:space:]&&[^\u00a0]]+}, '')
|
||||
.gsub(%r{[[:space:]&&[^\u00a0]]+\z}, '')
|
||||
.tr("\u00a0", ' ')
|
||||
read_in_one_round_trip(VISIBLE_TEXT_JS).to_s.scrub
|
||||
end
|
||||
|
||||
def all_text
|
||||
@@ -150,3 +230,19 @@ module ZammadCapybaraPlaywrightNodePatches
|
||||
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)
|
||||
|
||||
353
spec/support/capybara/playwright_visible_text.js
Normal file
353
spec/support/capybara/playwright_visible_text.js
Normal file
@@ -0,0 +1,353 @@
|
||||
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
//
|
||||
// The WebDriver "Get Element Text" algorithm - bot.dom.getVisibleText from
|
||||
// Selenium's javascript/atoms/dom.js, which the WebDriver spec defines element
|
||||
// text by. Ported so the Playwright drivers read text the way the Selenium
|
||||
// drivers do: innerText differs in where it puts line breaks (a <p> boundary
|
||||
// is two there), and specs asserting exact multi-line text are written against
|
||||
// this algorithm.
|
||||
//
|
||||
// Ported from https://github.com/SeleniumHQ/selenium/blob/trunk/javascript/atoms/dom.js
|
||||
// Copyright 2011-2026 Software Freedom Conservancy
|
||||
// Copyright 2004-2011 Selenium committers
|
||||
// Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Not ported: image maps.
|
||||
|
||||
;(root) => {
|
||||
const styles = new Map()
|
||||
const style = (el) => {
|
||||
if (!styles.has(el)) styles.set(el, window.getComputedStyle(el))
|
||||
return styles.get(el)
|
||||
}
|
||||
|
||||
const INLINE_DISPLAY_BOXES = [
|
||||
'inline',
|
||||
'inline-block',
|
||||
'inline-table',
|
||||
'none',
|
||||
'table-cell',
|
||||
'table-column',
|
||||
'table-column-group',
|
||||
]
|
||||
|
||||
const isElement = (node, tagName) =>
|
||||
!!node &&
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
(!tagName || node.tagName.toUpperCase() === tagName)
|
||||
|
||||
const parentInComposedDom = (node) => {
|
||||
const parent = node.assignedSlot || node.parentNode
|
||||
return parent instanceof ShadowRoot ? parent.host : parent
|
||||
}
|
||||
|
||||
const displayed = (node) => {
|
||||
if (isElement(node)) {
|
||||
const s = style(node)
|
||||
if (s.display === 'none' || s.contentVisibility === 'hidden') return false
|
||||
}
|
||||
|
||||
const parent = parentInComposedDom(node)
|
||||
|
||||
if (
|
||||
parent &&
|
||||
(parent.nodeType === Node.DOCUMENT_NODE || parent.nodeType === Node.DOCUMENT_FRAGMENT_NODE)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (isElement(parent, 'DETAILS') && !parent.open && !isElement(node, 'SUMMARY')) return false
|
||||
|
||||
return !!parent && displayed(parent)
|
||||
}
|
||||
|
||||
const opacity = (el) => {
|
||||
let value = Number(style(el).opacity || 1)
|
||||
const parent = el.parentElement
|
||||
if (parent) value *= opacity(parent)
|
||||
return value
|
||||
}
|
||||
|
||||
const htmlElem = document.documentElement
|
||||
const bodyElem = document.body
|
||||
|
||||
const clientRect = (el) => {
|
||||
if (el === htmlElem) {
|
||||
const width = htmlElem.clientWidth
|
||||
const height = htmlElem.clientHeight
|
||||
return { left: 0, top: 0, right: width, bottom: height, width, height }
|
||||
}
|
||||
const r = el.getBoundingClientRect()
|
||||
return {
|
||||
left: r.left,
|
||||
top: r.top,
|
||||
right: r.right,
|
||||
bottom: r.bottom,
|
||||
width: r.right - r.left,
|
||||
height: r.bottom - r.top,
|
||||
}
|
||||
}
|
||||
|
||||
// bot.dom.getOverflowState, including its use of the x overflow style for
|
||||
// the vertical unscrollable check and of scrollHeight for the horizontal
|
||||
// fixed-position check - matching Selenium is the point, not correcting it.
|
||||
const overflowState = (elem) => {
|
||||
const region = clientRect(elem)
|
||||
const htmlOverflowStyle = style(htmlElem).overflow
|
||||
let treatAsFixedPosition = false
|
||||
|
||||
const overflowParent = (e) => {
|
||||
const { position } = style(e)
|
||||
if (position === 'fixed') {
|
||||
treatAsFixedPosition = true
|
||||
return e === htmlElem ? null : htmlElem
|
||||
}
|
||||
const canBeOverflowed = (container) => {
|
||||
if (container === htmlElem) return true
|
||||
const { display } = style(container)
|
||||
if (display.startsWith('inline') || display === 'contents') return false
|
||||
if (position === 'absolute' && style(container).position === 'static') return false
|
||||
return true
|
||||
}
|
||||
let parent = e.parentElement
|
||||
while (parent && !canBeOverflowed(parent)) parent = parent.parentElement
|
||||
return parent
|
||||
}
|
||||
|
||||
const overflowStyles = (e) => {
|
||||
let overflowElem = e
|
||||
if (htmlOverflowStyle === 'visible') {
|
||||
if (e === htmlElem && bodyElem) overflowElem = bodyElem
|
||||
else if (e === bodyElem) return { x: 'visible', y: 'visible' }
|
||||
}
|
||||
const overflow = { x: style(overflowElem).overflowX, y: style(overflowElem).overflowY }
|
||||
if (e === htmlElem) {
|
||||
overflow.x = overflow.x === 'visible' ? 'auto' : overflow.x
|
||||
overflow.y = overflow.y === 'visible' ? 'auto' : overflow.y
|
||||
}
|
||||
return overflow
|
||||
}
|
||||
|
||||
const scroll = (e) =>
|
||||
e === htmlElem
|
||||
? { x: window.scrollX, y: window.scrollY }
|
||||
: { x: e.scrollLeft, y: e.scrollTop }
|
||||
|
||||
for (let container = overflowParent(elem); container; container = overflowParent(container)) {
|
||||
const containerOverflow = overflowStyles(container)
|
||||
if (containerOverflow.x === 'visible' && containerOverflow.y === 'visible') continue
|
||||
|
||||
const containerRect = clientRect(container)
|
||||
if (containerRect.width === 0 || containerRect.height === 0) return 'hidden'
|
||||
|
||||
const underflowsX = region.right < containerRect.left
|
||||
const underflowsY = region.bottom < containerRect.top
|
||||
if (
|
||||
(underflowsX && containerOverflow.x === 'hidden') ||
|
||||
(underflowsY && containerOverflow.y === 'hidden')
|
||||
) {
|
||||
return 'hidden'
|
||||
} else if (
|
||||
(underflowsX && containerOverflow.x !== 'visible') ||
|
||||
(underflowsY && containerOverflow.y !== 'visible')
|
||||
) {
|
||||
const containerScroll = scroll(container)
|
||||
const unscrollableX = region.right < containerRect.left - containerScroll.x
|
||||
const unscrollableY = region.bottom < containerRect.top - containerScroll.y
|
||||
if (
|
||||
(unscrollableX && containerOverflow.x !== 'visible') ||
|
||||
(unscrollableY && containerOverflow.x !== 'visible')
|
||||
) {
|
||||
return 'hidden'
|
||||
}
|
||||
return overflowState(container) === 'hidden' ? 'hidden' : 'scroll'
|
||||
}
|
||||
|
||||
const overflowsX = region.left >= containerRect.left + containerRect.width
|
||||
const overflowsY = region.top >= containerRect.top + containerRect.height
|
||||
if (
|
||||
(overflowsX && containerOverflow.x === 'hidden') ||
|
||||
(overflowsY && containerOverflow.y === 'hidden')
|
||||
) {
|
||||
return 'hidden'
|
||||
} else if (
|
||||
(overflowsX && containerOverflow.x !== 'visible') ||
|
||||
(overflowsY && containerOverflow.y !== 'visible')
|
||||
) {
|
||||
if (treatAsFixedPosition) {
|
||||
const docScroll = scroll(container)
|
||||
if (
|
||||
region.left >= htmlElem.scrollWidth - docScroll.x ||
|
||||
region.right >= htmlElem.scrollHeight - docScroll.y
|
||||
) {
|
||||
return 'hidden'
|
||||
}
|
||||
}
|
||||
return overflowState(container) === 'hidden' ? 'hidden' : 'scroll'
|
||||
}
|
||||
}
|
||||
|
||||
return 'none'
|
||||
}
|
||||
|
||||
const positiveSize = (el) => {
|
||||
const rect = clientRect(el)
|
||||
if (rect.height > 0 && rect.width > 0) return true
|
||||
|
||||
if (isElement(el, 'PATH') && (rect.height > 0 || rect.width > 0)) {
|
||||
const { strokeWidth } = style(el)
|
||||
return !!strokeWidth && parseInt(strokeWidth, 10) > 0
|
||||
}
|
||||
|
||||
const { visibility } = style(el)
|
||||
if (visibility === 'collapse' || visibility === 'hidden') return false
|
||||
if (!displayed(el)) return false
|
||||
|
||||
return (
|
||||
style(el).overflow !== 'hidden' &&
|
||||
Array.from(el.childNodes).some((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.nodeValue
|
||||
return !(/^[\s]*$/.test(text) && /[\n\r\t]/.test(text))
|
||||
}
|
||||
return isElement(node) && positiveSize(node)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const isShown = (el, ignoreOpacity = false) => {
|
||||
if (isElement(el, 'BODY')) return true
|
||||
|
||||
if (isElement(el, 'OPTION') || isElement(el, 'OPTGROUP')) {
|
||||
const select = el.closest('select')
|
||||
return !!select && isShown(select, true)
|
||||
}
|
||||
|
||||
if (isElement(el, 'INPUT') && el.type.toLowerCase() === 'hidden') return false
|
||||
if (isElement(el, 'NOSCRIPT')) return false
|
||||
|
||||
const { visibility } = style(el)
|
||||
if (visibility === 'collapse' || visibility === 'hidden') return false
|
||||
|
||||
if (!displayed(el)) return false
|
||||
if (!ignoreOpacity && opacity(el) === 0) return false
|
||||
if (!positiveSize(el)) return false
|
||||
|
||||
const hiddenByOverflow = (e) =>
|
||||
overflowState(e) === 'hidden' &&
|
||||
Array.from(e.childNodes).every(
|
||||
(node) => !isElement(node) || hiddenByOverflow(node) || !positiveSize(node),
|
||||
)
|
||||
|
||||
return !hiddenByOverflow(el)
|
||||
}
|
||||
|
||||
const capitalizePatterns = (() => {
|
||||
const letter = '\\p{L}\\u24B6-\\u24E9'
|
||||
const wordCharacter = "'_\\p{M}\\p{N}" + letter
|
||||
return [
|
||||
new RegExp('(^|[^' + wordCharacter + '])([' + letter + '])', 'gu'),
|
||||
new RegExp('(^|[^' + wordCharacter + '])([_*])([' + letter + '])', 'gu'),
|
||||
]
|
||||
})()
|
||||
|
||||
const appendTextNode = (textNode, lines, whitespace, textTransform) => {
|
||||
let text = textNode.nodeValue.replace(/[\u200b\u200e\u200f]/g, '')
|
||||
|
||||
text = text.replace(/(\r\n|\r|\n)/g, '\n')
|
||||
if (whitespace === 'normal' || whitespace === 'nowrap') text = text.replace(/\n/g, ' ')
|
||||
|
||||
if (whitespace === 'pre' || whitespace === 'pre-wrap') {
|
||||
text = text.replace(/[ \f\t\v\u2028\u2029]/g, '\xa0')
|
||||
} else {
|
||||
text = text.replace(/[\ \f\t\v\u2028\u2029]+/g, ' ')
|
||||
}
|
||||
|
||||
if (textTransform === 'capitalize') {
|
||||
text = text.replace(capitalizePatterns[0], (...m) => m[1] + m[2].toUpperCase())
|
||||
text = text.replace(capitalizePatterns[1], (...m) => m[1] + m[2] + m[3].toUpperCase())
|
||||
} else if (textTransform === 'uppercase') {
|
||||
text = text.toUpperCase()
|
||||
} else if (textTransform === 'lowercase') {
|
||||
text = text.toLowerCase()
|
||||
}
|
||||
|
||||
const currLine = lines.pop() || ''
|
||||
if (currLine.endsWith(' ') && text.startsWith(' ')) text = text.substr(1)
|
||||
lines.push(currLine + text)
|
||||
}
|
||||
|
||||
const isEmptyOrWhitespace = (str) => /^[\s\xa0]*$/.test(str)
|
||||
|
||||
const isDistributed = (node) =>
|
||||
(node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE) &&
|
||||
node.assignedSlot != null
|
||||
|
||||
const appendNode = (node, lines, shown, whitespace, textTransform) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
if (shown) appendTextNode(node, lines, whitespace, textTransform)
|
||||
return
|
||||
}
|
||||
if (!isElement(node)) return
|
||||
|
||||
if (isElement(node, 'SLOT') && node.getRootNode() instanceof ShadowRoot) {
|
||||
const assigned = node.assignedNodes()
|
||||
const children = assigned.length > 0 ? assigned : Array.from(node.childNodes)
|
||||
children.forEach((child) => appendNode(child, lines, shown, whitespace, textTransform))
|
||||
return
|
||||
}
|
||||
|
||||
appendElement(node, lines)
|
||||
}
|
||||
|
||||
const appendElement = (el, lines) => {
|
||||
const currLine = () => lines[lines.length - 1] || ''
|
||||
|
||||
if (el.shadowRoot) {
|
||||
const s = style(el)
|
||||
el.shadowRoot.childNodes.forEach((node) =>
|
||||
appendNode(node, lines, true, s.whiteSpace, s.textTransform),
|
||||
)
|
||||
}
|
||||
|
||||
if (isElement(el, 'BR')) {
|
||||
lines.push('')
|
||||
return
|
||||
}
|
||||
|
||||
const isTD = isElement(el, 'TD')
|
||||
const { display } = style(el)
|
||||
const isBlock = !isTD && !INLINE_DISPLAY_BOXES.includes(display)
|
||||
|
||||
const previous = el.previousElementSibling
|
||||
const previousDisplay = previous ? style(previous).display : ''
|
||||
const float = style(el).cssFloat || style(el).float
|
||||
const runIntoThis = previousDisplay === 'run-in' && float === 'none'
|
||||
|
||||
if (isBlock && !runIntoThis && !isEmptyOrWhitespace(currLine())) lines.push('')
|
||||
|
||||
const shown = isShown(el)
|
||||
const { whiteSpace: whitespace, textTransform } = shown
|
||||
? style(el)
|
||||
: { whiteSpace: null, textTransform: null }
|
||||
|
||||
el.childNodes.forEach((node) => {
|
||||
if (!isDistributed(node)) appendNode(node, lines, shown, whitespace, textTransform)
|
||||
})
|
||||
|
||||
const line = currLine()
|
||||
|
||||
if ((isTD || display === 'table-cell') && line && !line.endsWith(' ')) {
|
||||
lines[lines.length - 1] += ' '
|
||||
}
|
||||
|
||||
if (isBlock && display !== 'run-in' && !isEmptyOrWhitespace(line)) lines.push('')
|
||||
}
|
||||
|
||||
const trim = (str) => str.replace(/^[^\S\xa0]+|[^\S\xa0]+$/g, '')
|
||||
|
||||
const lines = []
|
||||
appendElement(root, lines)
|
||||
|
||||
return trim(lines.map(trim).join('\n')).replace(/\xa0/g, ' ')
|
||||
}
|
||||
@@ -64,12 +64,6 @@ RSpec.describe 'Mobile > Ticket > Article actions', app: :mobile, authenticated_
|
||||
60 # both sources combined (forwarding a phone article)
|
||||
end
|
||||
|
||||
# FIXME: The Playwright driver reads the editor text with extra blank lines between paragraphs,
|
||||
# unlike the Selenium drivers, so the exact text checks fail.
|
||||
before do
|
||||
skip 'Skipping due to differing editor text in the Playwright driver' if %i[zammad_playwright zammad_playwright_mobile].include?(Capybara.current_driver)
|
||||
end
|
||||
|
||||
# we test article creation mostly on the backend because Node.js doesn't support prose-mirror
|
||||
context 'when article was created as email' do
|
||||
let(:signature) { create(:signature, active: true, body: "\#{user.firstname}<br>Signature!") }
|
||||
|
||||
Reference in New Issue
Block a user