From e1e213de5a0e699469da6814dc5d97e5d58456d2 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 22 Apr 2026 12:44:33 +0300 Subject: [PATCH 1/2] fix(fillField): prevent rich-editor keystroke leak to sibling inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IFRAME branch typed via the root-page keyboard against an iframe body that's not contenteditable (Monaco-style editors), so keystrokes landed on whatever the outer document had focused. Detection also climbed the DOM when the matched element looked hidden, which could pick up unrelated editors elsewhere on the page. Now: detection only walks down from the user's locator, the IFRAME branch re-detects the real input surface inside the iframe, and every focus/click is verified against document.activeElement before typing — a failed focus throws instead of leaking. Backing-textarea fixtures (TinyMCE legacy, CKEditor 4/5, CodeMirror 5, Summernote) wrapped so #editor is the visible container. Adds sibling-input regression coverage for IFRAME, CONTENTEDITABLE and HIDDEN_TEXTAREA paths plus a negative test for hidden backing locators. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/helper/extras/richTextEditor.js | 77 +++++++++++++------ .../data/app/view/form/richtext/ckeditor4.php | 6 +- .../form/richtext/ckeditor5-with-sibling.php | 37 +++++++++ .../data/app/view/form/richtext/ckeditor5.php | 6 +- .../richtext/codemirror5-with-sibling.php | 33 ++++++++ .../app/view/form/richtext/codemirror5.php | 6 +- .../form/richtext/monaco-with-sibling.php | 40 ++++++++++ .../app/view/form/richtext/summernote.php | 12 +-- .../app/view/form/richtext/tinymce-legacy.php | 6 +- test/helper/webapi.js | 50 ++++++++++++ 10 files changed, 236 insertions(+), 37 deletions(-) create mode 100644 test/data/app/view/form/richtext/ckeditor5-with-sibling.php create mode 100644 test/data/app/view/form/richtext/codemirror5-with-sibling.php create mode 100644 test/data/app/view/form/richtext/monaco-with-sibling.php diff --git a/lib/helper/extras/richTextEditor.js b/lib/helper/extras/richTextEditor.js index 6efed1ccc..11a16e42f 100644 --- a/lib/helper/extras/richTextEditor.js +++ b/lib/helper/extras/richTextEditor.js @@ -13,7 +13,6 @@ function detectAndMark(el, opts) { const marker = opts.marker const kinds = opts.kinds const CE = '[contenteditable="true"], [contenteditable=""]' - const MAX_HIDDEN_ASCENT = 3 function mark(kind, target) { document.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker)) @@ -33,32 +32,38 @@ function detectAndMark(el, opts) { if (iframe) return mark(kinds.IFRAME, iframe) const ce = el.querySelector(CE) if (ce) return mark(kinds.CONTENTEDITABLE, ce) - const textarea = el.querySelector('textarea') + const textareas = [...el.querySelectorAll('textarea')] + const focusable = textareas.find(t => window.getComputedStyle(t).display !== 'none') + const textarea = focusable || textareas[0] if (textarea) return mark(kinds.HIDDEN_TEXTAREA, textarea) } - const style = window.getComputedStyle(el) - const isHidden = - el.offsetParent === null || - (el.offsetWidth === 0 && el.offsetHeight === 0) || - style.display === 'none' || - style.visibility === 'hidden' - if (!isHidden) return mark(kinds.STANDARD, el) - - const isFormHidden = tag === 'INPUT' && el.type === 'hidden' - if (isFormHidden) return mark(kinds.STANDARD, el) - - let scope = el.parentElement - for (let depth = 0; scope && depth < MAX_HIDDEN_ASCENT; depth++, scope = scope.parentElement) { - const iframeNear = scope.querySelector('iframe') - if (iframeNear) return mark(kinds.IFRAME, iframeNear) - const ceNear = scope.querySelector(CE) - if (ceNear) return mark(kinds.CONTENTEDITABLE, ceNear) - const textareaNear = [...scope.querySelectorAll('textarea')].find(t => t !== el) - if (textareaNear) return mark(kinds.HIDDEN_TEXTAREA, textareaNear) + return mark(kinds.STANDARD, el) +} + +function detectInsideFrame(body, opts) { + const marker = opts.marker + const kinds = opts.kinds + const CE = '[contenteditable="true"], [contenteditable=""]' + body.ownerDocument.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker)) + + if (body.isContentEditable) return kinds.CONTENTEDITABLE + + const ce = body.querySelector(CE) + if (ce) { + ce.setAttribute(marker, '1') + return kinds.CONTENTEDITABLE } - return mark(kinds.STANDARD, el) + const textareas = [...body.querySelectorAll('textarea')] + const focusable = textareas.find(t => body.ownerDocument.defaultView.getComputedStyle(t).display !== 'none') + const textarea = focusable || textareas[0] + if (textarea) { + textarea.setAttribute(marker, '1') + return kinds.HIDDEN_TEXTAREA + } + + return kinds.CONTENTEDITABLE } function selectAllInEditable(el) { @@ -76,6 +81,17 @@ function unmarkAll(marker) { document.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker)) } +function isActive(el) { + return el.ownerDocument.activeElement === el +} + +async function assertFocused(target) { + const focused = await target.evaluate(isActive) + if (!focused) { + throw new Error('fillField: rich editor target did not accept focus. Locator must point at the visible editor surface (a wrapper, iframe, or contenteditable) — not a hidden backing element.') + } +} + async function findMarked(helper) { const root = helper.page || helper.browser const raw = await root.$('[' + MARKER + ']') @@ -97,16 +113,29 @@ export async function fillRichEditor(helper, el, value) { if (kind === EDITOR.IFRAME) { await target.inIframe(async body => { - await body.evaluate(selectAllInEditable) - await body.typeText(value, { delay }) + const innerKind = await body.evaluate(detectInsideFrame, { marker: MARKER, kinds: EDITOR }) + const marked = await body.$('[' + MARKER + ']') + const innerTarget = marked || body + if (innerKind === EDITOR.HIDDEN_TEXTAREA) { + await innerTarget.focus() + await assertFocused(innerTarget) + await innerTarget.selectAllAndDelete() + await innerTarget.typeText(value, { delay }) + } else { + await innerTarget.evaluate(selectAllInEditable) + await assertFocused(innerTarget) + await innerTarget.typeText(value, { delay }) + } }) } else if (kind === EDITOR.HIDDEN_TEXTAREA) { await target.focus() + await assertFocused(target) await target.selectAllAndDelete() await target.typeText(value, { delay }) } else if (kind === EDITOR.CONTENTEDITABLE) { await target.click() await target.evaluate(selectAllInEditable) + await assertFocused(target) await target.typeText(value, { delay }) } diff --git a/test/data/app/view/form/richtext/ckeditor4.php b/test/data/app/view/form/richtext/ckeditor4.php index c14aeb850..bd85beb01 100644 --- a/test/data/app/view/form/richtext/ckeditor4.php +++ b/test/data/app/view/form/richtext/ckeditor4.php @@ -8,13 +8,15 @@

CKEditor 4

- +
+ +
+ + + diff --git a/test/data/app/view/form/richtext/ckeditor5.php b/test/data/app/view/form/richtext/ckeditor5.php index 1525aa165..ef78c5815 100644 --- a/test/data/app/view/form/richtext/ckeditor5.php +++ b/test/data/app/view/form/richtext/ckeditor5.php @@ -8,13 +8,15 @@

CKEditor 5

- +
+ +
+ + + diff --git a/test/data/app/view/form/richtext/codemirror5.php b/test/data/app/view/form/richtext/codemirror5.php index 60203d86d..afde670c7 100644 --- a/test/data/app/view/form/richtext/codemirror5.php +++ b/test/data/app/view/form/richtext/codemirror5.php @@ -10,13 +10,15 @@

CodeMirror 5

- +
+ +
+ + diff --git a/test/data/app/view/form/richtext/summernote.php b/test/data/app/view/form/richtext/summernote.php index 0ba19c021..3cc3d48d0 100644 --- a/test/data/app/view/form/richtext/summernote.php +++ b/test/data/app/view/form/richtext/summernote.php @@ -9,7 +9,9 @@

Summernote

-
+
+
+
@@ -17,13 +19,13 @@