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:
Dominik Klein
2026-10-05 21:24:38 +02:00
parent c45ccd7e7e
commit 48efde6c79
10 changed files with 558 additions and 49 deletions

View File

@@ -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'

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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?

View File

@@ -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)

View 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, ' ')
}

View File

@@ -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!") }