From fe5ce3e593980761696e70d8fe5ffdc3193c67b0 Mon Sep 17 00:00:00 2001 From: Michael Bodnarchuk Date: Mon, 20 Apr 2026 01:40:43 +0300 Subject: [PATCH 1/5] feat: fillField supports rich text editors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends fillField in Playwright, Puppeteer, and WebDriver helpers to detect and fill rich text editors automatically. Works with contenteditable, hidden-textarea, and iframe-based editors — verified against ProseMirror, Quill, CKEditor 4/5, TinyMCE inline/legacy, CodeMirror 5/6, Monaco, ACE, Trix, and Summernote. Detection and filling logic is centralized in lib/helper/extras/richTextEditor.js and built on new WebElement primitives (evaluate, focus, typeText, selectAllAndDelete, inIframe), so all three helpers share one implementation. Adds 48 rich text scenarios to the shared webapi.js test suite (4 scenarios × 12 editors) covering basic fill, rewriting pre-populated content, special-character preservation, and large multi-paragraph content. Co-Authored-By: Claude Opus 4.7 --- lib/element/WebElement.js | 119 ++++++++++++++++++ lib/helper/Playwright.js | 9 +- lib/helper/Puppeteer.js | 12 +- lib/helper/WebDriver.js | 6 + lib/helper/extras/richTextEditor.js | 115 +++++++++++++++++ test/data/app/controllers.php | 9 ++ test/data/app/index.php | 3 +- test/data/app/view/form/richtext.php | 25 ++++ test/data/app/view/form/richtext/ace.php | 29 +++++ .../data/app/view/form/richtext/ckeditor4.php | 32 +++++ .../data/app/view/form/richtext/ckeditor5.php | 33 +++++ .../app/view/form/richtext/codemirror5.php | 29 +++++ .../app/view/form/richtext/codemirror6.php | 34 +++++ test/data/app/view/form/richtext/monaco.php | 34 +++++ .../app/view/form/richtext/prosemirror.php | 42 +++++++ test/data/app/view/form/richtext/quill.php | 30 +++++ .../app/view/form/richtext/summernote.php | 36 ++++++ .../app/view/form/richtext/tinymce-legacy.php | 34 +++++ .../app/view/form/richtext/tinymce-modern.php | 38 ++++++ test/data/app/view/form/richtext/trix.php | 31 +++++ test/data/richtext-long.txt | 8 ++ test/helper/webapi.js | 69 ++++++++++ 22 files changed, 773 insertions(+), 4 deletions(-) create mode 100644 lib/helper/extras/richTextEditor.js create mode 100644 test/data/app/view/form/richtext.php create mode 100644 test/data/app/view/form/richtext/ace.php create mode 100644 test/data/app/view/form/richtext/ckeditor4.php create mode 100644 test/data/app/view/form/richtext/ckeditor5.php create mode 100644 test/data/app/view/form/richtext/codemirror5.php create mode 100644 test/data/app/view/form/richtext/codemirror6.php create mode 100644 test/data/app/view/form/richtext/monaco.php create mode 100644 test/data/app/view/form/richtext/prosemirror.php create mode 100644 test/data/app/view/form/richtext/quill.php create mode 100644 test/data/app/view/form/richtext/summernote.php create mode 100644 test/data/app/view/form/richtext/tinymce-legacy.php create mode 100644 test/data/app/view/form/richtext/tinymce-modern.php create mode 100644 test/data/app/view/form/richtext/trix.php create mode 100644 test/data/richtext-long.txt diff --git a/lib/element/WebElement.js b/lib/element/WebElement.js index 5cc010f60..6f747042e 100644 --- a/lib/element/WebElement.js +++ b/lib/element/WebElement.js @@ -256,6 +256,125 @@ class WebElement { } } + /** + * Run a function in the browser with this element as the first argument. + * @param {Function} fn Browser-side function. Receives the element, then extra args. + * @param {...any} args Additional arguments passed to the function + * @returns {Promise} Value returned by fn + */ + async evaluate(fn, ...args) { + switch (this.helperType) { + case 'playwright': + case 'puppeteer': + return this.element.evaluate(fn, ...args) + case 'webdriver': + return this.helper.executeScript(fn, this.element, ...args) + default: + throw new Error(`Unsupported helper type: ${this.helperType}`) + } + } + + /** + * Focus the element. + * @returns {Promise} + */ + async focus() { + switch (this.helperType) { + case 'playwright': + return this.element.focus() + case 'puppeteer': + if (this.element.focus) return this.element.focus() + return this.element.evaluate(el => el.focus()) + case 'webdriver': + return this.helper.executeScript(el => el.focus(), this.element) + default: + throw new Error(`Unsupported helper type: ${this.helperType}`) + } + } + + /** + * Type characters via the page/browser keyboard into the focused element. + * Unlike `type()`, this does not call `.fill()`/`.setValue()`, so it works + * with contenteditable nodes, iframe bodies, and editor-owned hidden textareas. + * @param {string} text Text to send + * @param {Object} [options] Options (e.g. `{ delay }`) + * @returns {Promise} + */ + async typeText(text, options = {}) { + const s = String(text) + switch (this.helperType) { + case 'playwright': + case 'puppeteer': + return this.helper.page.keyboard.type(s, options) + case 'webdriver': + return this.element.keys(s.split('')) + default: + throw new Error(`Unsupported helper type: ${this.helperType}`) + } + } + + /** + * Select all content in the focused field and delete it via keyboard input. + * Sends Ctrl+A and Meta+A (so it works across platforms) followed by Backspace. + * @returns {Promise} + */ + async selectAllAndDelete() { + switch (this.helperType) { + case 'playwright': + await this.helper.page.keyboard.press('Control+a').catch(() => {}) + await this.helper.page.keyboard.press('Meta+a').catch(() => {}) + await this.helper.page.keyboard.press('Backspace') + return + case 'puppeteer': + for (const mod of ['Control', 'Meta']) { + try { + await this.helper.page.keyboard.down(mod) + await this.helper.page.keyboard.press('KeyA') + await this.helper.page.keyboard.up(mod) + } catch (e) {} + } + await this.helper.page.keyboard.press('Backspace') + return + case 'webdriver': + await this.element.keys(['Control', 'a']) + await this.element.keys(['Meta', 'a']) + await this.element.keys(['Backspace']) + return + default: + throw new Error(`Unsupported helper type: ${this.helperType}`) + } + } + + /** + * Treat this element as an iframe; invoke `fn` with a WebElement wrapping + * the iframe body. For WebDriver this switches the browser into the frame + * for the duration of the callback and switches back on exit. + * @param {(body: WebElement) => Promise} fn + * @returns {Promise} Return value of fn + */ + async inIframe(fn) { + switch (this.helperType) { + case 'playwright': + case 'puppeteer': { + const frame = await this.element.contentFrame() + const body = await frame.$('body') + return fn(new WebElement(body, this.helper)) + } + case 'webdriver': { + const browser = this.helper.browser + await browser.switchToFrame(this.element) + try { + const body = await browser.$('body') + return await fn(new WebElement(body, this.helper)) + } finally { + await browser.switchToParentFrame() + } + } + default: + throw new Error(`Unsupported helper type: ${this.helperType}`) + } + } + /** * Find first child element matching the locator * @param {string|Object} locator Element locator diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index 1102327df..a4171e669 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -40,6 +40,7 @@ import { findReact, findVue, findByPlaywrightLocator } from './extras/Playwright import { dropFile } from './scripts/dropFile.js' import WebElement from '../element/WebElement.js' import { selectElement } from './extras/elementSelection.js' +import { fillRichEditor } from './extras/richTextEditor.js' let playwright let perfTiming @@ -2283,11 +2284,15 @@ class Playwright extends Helper { assertElementExists(els, field, 'Field') const el = selectElement(els, field, this) + await highlightActiveElement.call(this, el) + + if (await fillRichEditor(this, el, value)) { + return this._waitForAction() + } + await el.clear() if (store.debugMode) this.debugSection('Focused', await elToString(el, 1)) - await highlightActiveElement.call(this, el) - await el.type(value.toString(), { delay: this.options.pressKeyDelay }) return this._waitForAction() diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js index 26e6ddfa4..5d73b6084 100644 --- a/lib/helper/Puppeteer.js +++ b/lib/helper/Puppeteer.js @@ -45,6 +45,7 @@ import { dontSeeElementError, seeElementError, dontSeeElementInDOMError, seeElem import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js' import WebElement from '../element/WebElement.js' import { selectElement } from './extras/elementSelection.js' +import { fillRichEditor } from './extras/richTextEditor.js' let puppeteer @@ -1595,9 +1596,18 @@ class Puppeteer extends Helper { * {{ react }} */ async fillField(field, value, context = null) { - const els = await findVisibleFields.call(this, field, context) + let els = await findVisibleFields.call(this, field, context) + if (!els.length) { + els = await findFields.call(this, field, context) + } assertElementExists(els, field, 'Field') const el = selectElement(els, field, this) + + if (await fillRichEditor(this, el, value)) { + highlightActiveElement.call(this, el, await this._getContext()) + return this._waitForAction() + } + const tag = await el.getProperty('tagName').then(el => el.jsonValue()) const editable = await el.getProperty('contenteditable').then(el => el.jsonValue()) if (tag === 'INPUT' || tag === 'TEXTAREA') { diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js index 05e1df908..4f201f03b 100644 --- a/lib/helper/WebDriver.js +++ b/lib/helper/WebDriver.js @@ -42,6 +42,7 @@ import { dropFile } from './scripts/dropFile.js' import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js' import WebElement from '../element/WebElement.js' import { selectElement } from './extras/elementSelection.js' +import { fillRichEditor } from './extras/richTextEditor.js' const SHADOW = 'shadow' const webRoot = 'body' @@ -1279,6 +1280,11 @@ class WebDriver extends Helper { assertElementExists(res, field, 'Field') const elem = selectElement(res, field, this) highlightActiveElement.call(this, elem) + + if (await fillRichEditor(this, elem, value)) { + return + } + try { await elem.clearValue() } catch (err) { diff --git a/lib/helper/extras/richTextEditor.js b/lib/helper/extras/richTextEditor.js new file mode 100644 index 000000000..6cd5b89f4 --- /dev/null +++ b/lib/helper/extras/richTextEditor.js @@ -0,0 +1,115 @@ +import WebElement from '../../element/WebElement.js' + +const MARKER = 'data-codeceptjs-rte-target' + +const EDITOR = { + STANDARD: 'standard', + IFRAME: 'iframe', + CONTENTEDITABLE: 'contenteditable', + HIDDEN_TEXTAREA: 'hidden-textarea', +} + +function detectAndMark(el, opts) { + const marker = opts.marker + const kinds = opts.kinds + const CE = '[contenteditable="true"], [contenteditable=""]' + + function mark(kind, target) { + document.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker)) + if (target && target.nodeType === 1) target.setAttribute(marker, '1') + return kind + } + + if (!el || el.nodeType !== 1) return mark(kinds.STANDARD, el) + + const tag = el.tagName + if (tag === 'IFRAME') return mark(kinds.IFRAME, el) + if (el.isContentEditable) return mark(kinds.CONTENTEDITABLE, el) + + if (tag !== 'INPUT' && tag !== 'TEXTAREA') { + const iframe = el.querySelector('iframe') + if (iframe) return mark(kinds.IFRAME, iframe) + const ce = el.querySelector(CE) + if (ce) return mark(kinds.CONTENTEDITABLE, ce) + const textarea = el.querySelector('textarea') + if (textarea) return mark(kinds.HIDDEN_TEXTAREA, textarea) + } + + const style = window.getComputedStyle(el) + const hidden = + el.offsetParent === null || + (el.offsetWidth === 0 && el.offsetHeight === 0) || + style.display === 'none' || + style.visibility === 'hidden' + + if (hidden) { + let scope = el.parentElement + while (scope) { + const iframeNear = scope.querySelector('iframe') + if (iframeNear) return mark(kinds.IFRAME, iframeNear) + const ceNear = scope.querySelector(CE) + if (ceNear) return mark(kinds.CONTENTEDITABLE, ceNear) + for (const t of scope.querySelectorAll('textarea')) { + if (t !== el) return mark(kinds.HIDDEN_TEXTAREA, t) + } + if (scope === document.body) break + scope = scope.parentElement + } + } + + return mark(kinds.STANDARD, el) +} + +function selectAllInEditable(el) { + const doc = el.ownerDocument + const win = doc.defaultView + el.focus() + const range = doc.createRange() + range.selectNodeContents(el) + const sel = win.getSelection() + sel.removeAllRanges() + sel.addRange(range) +} + +function unmarkAll(marker) { + document.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker)) +} + +async function findMarked(helper) { + const root = helper.page || helper.browser + const raw = await root.$('[' + MARKER + ']') + return new WebElement(raw, helper) +} + +async function clearMarker(helper) { + if (helper.page) return helper.page.evaluate(unmarkAll, MARKER) + return helper.executeScript(unmarkAll, MARKER) +} + +export async function fillRichEditor(helper, el, value) { + const source = el instanceof WebElement ? el : new WebElement(el, helper) + const kind = await source.evaluate(detectAndMark, { marker: MARKER, kinds: EDITOR }) + if (kind === EDITOR.STANDARD) return false + + const target = await findMarked(helper) + const delay = helper.options.pressKeyDelay + + if (kind === EDITOR.IFRAME) { + await target.inIframe(async body => { + await body.click({ force: true }) + await body.evaluate(selectAllInEditable) + await body.typeText(value, { delay }) + }) + } else if (kind === EDITOR.HIDDEN_TEXTAREA) { + await target.focus() + await target.selectAllAndDelete() + await target.typeText(value, { delay }) + } else if (kind === EDITOR.CONTENTEDITABLE) { + await target.click() + await target.evaluate(selectAllInEditable) + await target.typeText(value, { delay }) + } + + await clearMarker(helper) + return true +} diff --git a/test/data/app/controllers.php b/test/data/app/controllers.php index 2dea9b435..e3025505a 100755 --- a/test/data/app/controllers.php +++ b/test/data/app/controllers.php @@ -319,3 +319,12 @@ function GET() { include __DIR__.'/view/basic_auth.php'; } } + +class richtext_submit { + function POST() { + $content = isset($_POST['content']) ? $_POST['content'] : ''; + echo 'Submitted'; + echo '
' . htmlspecialchars($content, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '
'; + echo ''; + } +} diff --git a/test/data/app/index.php b/test/data/app/index.php index 6ab0b28a6..e7a1dbb07 100755 --- a/test/data/app/index.php +++ b/test/data/app/index.php @@ -46,7 +46,8 @@ '/download' => 'download', '/basic_auth' => 'basic_auth', '/image' => 'basic_image', - '/invisible_elements' => 'invisible_elements' + '/invisible_elements' => 'invisible_elements', + '/richtext_submit' => 'richtext_submit' ); glue::stick($urls); diff --git a/test/data/app/view/form/richtext.php b/test/data/app/view/form/richtext.php new file mode 100644 index 000000000..3bb8a6939 --- /dev/null +++ b/test/data/app/view/form/richtext.php @@ -0,0 +1,25 @@ + + + + + Rich Text Editors + + +

Rich Text Editors

+

Test pages for fillField against rich text editors.

+ + + diff --git a/test/data/app/view/form/richtext/ace.php b/test/data/app/view/form/richtext/ace.php new file mode 100644 index 000000000..55b59dfba --- /dev/null +++ b/test/data/app/view/form/richtext/ace.php @@ -0,0 +1,29 @@ + + + + + + ACE + + +

ACE

+
+
+ + +
+ + + + diff --git a/test/data/app/view/form/richtext/ckeditor4.php b/test/data/app/view/form/richtext/ckeditor4.php new file mode 100644 index 000000000..c14aeb850 --- /dev/null +++ b/test/data/app/view/form/richtext/ckeditor4.php @@ -0,0 +1,32 @@ + + + + + + CKEditor 4 + + +

CKEditor 4

+
+ + + +
+ + + + diff --git a/test/data/app/view/form/richtext/ckeditor5.php b/test/data/app/view/form/richtext/ckeditor5.php new file mode 100644 index 000000000..1525aa165 --- /dev/null +++ b/test/data/app/view/form/richtext/ckeditor5.php @@ -0,0 +1,33 @@ + + + + + + CKEditor 5 + + +

CKEditor 5

+
+ + + +
+ + + + diff --git a/test/data/app/view/form/richtext/codemirror5.php b/test/data/app/view/form/richtext/codemirror5.php new file mode 100644 index 000000000..60203d86d --- /dev/null +++ b/test/data/app/view/form/richtext/codemirror5.php @@ -0,0 +1,29 @@ + + + + + + CodeMirror 5 + + + + +

CodeMirror 5

+
+ + + +
+ + + + diff --git a/test/data/app/view/form/richtext/codemirror6.php b/test/data/app/view/form/richtext/codemirror6.php new file mode 100644 index 000000000..e2ea24314 --- /dev/null +++ b/test/data/app/view/form/richtext/codemirror6.php @@ -0,0 +1,34 @@ + + + + + + CodeMirror 6 + + + +

CodeMirror 6

+
+
+ + +
+ + + + diff --git a/test/data/app/view/form/richtext/monaco.php b/test/data/app/view/form/richtext/monaco.php new file mode 100644 index 000000000..2374d0812 --- /dev/null +++ b/test/data/app/view/form/richtext/monaco.php @@ -0,0 +1,34 @@ + + + + + + Monaco + + +

Monaco

+
+
+ + +
+ + + + diff --git a/test/data/app/view/form/richtext/prosemirror.php b/test/data/app/view/form/richtext/prosemirror.php new file mode 100644 index 000000000..387d75324 --- /dev/null +++ b/test/data/app/view/form/richtext/prosemirror.php @@ -0,0 +1,42 @@ + + + + + + ProseMirror + + + +

ProseMirror

+
+
+ + +
+ + + + diff --git a/test/data/app/view/form/richtext/quill.php b/test/data/app/view/form/richtext/quill.php new file mode 100644 index 000000000..12ffb2aa8 --- /dev/null +++ b/test/data/app/view/form/richtext/quill.php @@ -0,0 +1,30 @@ + + + + + + Quill + + + +

Quill

+
+
+ + +
+ + + + diff --git a/test/data/app/view/form/richtext/summernote.php b/test/data/app/view/form/richtext/summernote.php new file mode 100644 index 000000000..0ba19c021 --- /dev/null +++ b/test/data/app/view/form/richtext/summernote.php @@ -0,0 +1,36 @@ + + + + + + Summernote + + + +

Summernote

+
+
+ + +
+ + + + + diff --git a/test/data/app/view/form/richtext/tinymce-legacy.php b/test/data/app/view/form/richtext/tinymce-legacy.php new file mode 100644 index 000000000..4ea8015fa --- /dev/null +++ b/test/data/app/view/form/richtext/tinymce-legacy.php @@ -0,0 +1,34 @@ + + + + + + TinyMCE (iframe) + + +

TinyMCE (iframe)

+
+ + + +
+ + + + diff --git a/test/data/app/view/form/richtext/tinymce-modern.php b/test/data/app/view/form/richtext/tinymce-modern.php new file mode 100644 index 000000000..731dfb6ae --- /dev/null +++ b/test/data/app/view/form/richtext/tinymce-modern.php @@ -0,0 +1,38 @@ + + + + + + TinyMCE (inline) + + + +

TinyMCE (inline / contenteditable)

+
+
+ + +
+ + + + diff --git a/test/data/app/view/form/richtext/trix.php b/test/data/app/view/form/richtext/trix.php new file mode 100644 index 000000000..5d2fb1fe5 --- /dev/null +++ b/test/data/app/view/form/richtext/trix.php @@ -0,0 +1,31 @@ + + + + + + Trix + + + +

Trix

+
+ + + + +
+ + + + diff --git a/test/data/richtext-long.txt b/test/data/richtext-long.txt new file mode 100644 index 000000000..d0c408dfa --- /dev/null +++ b/test/data/richtext-long.txt @@ -0,0 +1,8 @@ +Opening paragraph discusses the rationale for robust editor testing, covering typed input, clipboard round-trips, and multi-line content flow through the DOM. +A follow-up sentence ensures the first paragraph has enough substance for the editor to wrap, break, or reformat as needed. + +Middle paragraph exists to verify that paragraph breaks survive the fill operation intact across rich-text, code, and iframe-backed editors. +It also exercises mixed punctuation: commas, periods, semicolons, and the occasional colon turn up here to keep the tokenizer honest. + +Closing paragraph caps the fixture with a third block of prose to confirm that longer payloads get typed and submitted without truncation. +A final line restates the intent so that assertions on the last paragraph can key off distinctive phrasing. diff --git a/test/helper/webapi.js b/test/helper/webapi.js index 5a74e6d96..fd12fa869 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -828,6 +828,75 @@ export function tests() { }) }) + describe('#fillField - rich text editors', function () { + this.timeout(60000) + + const longContent = fs.readFileSync(path.join(__dirname, '../data/richtext-long.txt'), 'utf8').trim() + + const editors = [ + { name: 'ProseMirror', page: 'prosemirror', selector: '#editor' }, + { name: 'Quill', page: 'quill', selector: '#editor' }, + { name: 'CKEditor 5', page: 'ckeditor5', selector: '#editor' }, + { name: 'TinyMCE inline', page: 'tinymce-modern', selector: '#editor' }, + { name: 'CodeMirror 6', page: 'codemirror6', selector: '#editor' }, + { name: 'Trix', page: 'trix', selector: 'trix-editor' }, + { name: 'Summernote', page: 'summernote', selector: '#editor' }, + { name: 'Monaco', page: 'monaco', selector: '#editor' }, + { name: 'ACE', page: 'ace', selector: '#editor' }, + { name: 'CodeMirror 5', page: 'codemirror5', selector: '#editor' }, + { name: 'TinyMCE legacy', page: 'tinymce-legacy', selector: '#editor' }, + { name: 'CKEditor 4', page: 'ckeditor4', selector: '#editor' }, + ] + + async function open(page, initial) { + const q = initial != null ? `?initial=${encodeURIComponent(initial)}` : '' + await I.amOnPage(`/form/richtext/${page}${q}`) + await I.waitForFunction(() => window.__editorReady === true, [], 30) + } + + async function submitAndGrab() { + await I.click('#submit') + await I.waitForElement('#result', 15) + return I.grabTextFrom('#result') + } + + for (const ed of editors) { + describe(ed.name, () => { + it('submits filled value', async () => { + await open(ed.page) + await I.fillField(ed.selector, 'Hello rich text world') + expect(await submitAndGrab()).to.include('Hello rich text world') + }) + + it('rewrites pre-populated content', async () => { + await open(ed.page, 'PREVIOUSLY ENTERED DATA') + await I.fillField(ed.selector, 'fresh replacement text') + const submitted = await submitAndGrab() + expect(submitted).to.include('fresh replacement text') + expect(submitted).to.not.include('PREVIOUSLY ENTERED DATA') + }) + + it('preserves special characters', async () => { + await open(ed.page) + await I.fillField(ed.selector, 'Test: "quotes", & ampersand, 100% done!') + const submitted = await submitAndGrab() + expect(submitted).to.include('"quotes"') + expect(submitted).to.include('& ampersand') + expect(submitted).to.include('100%') + }) + + it('fills large multi-paragraph content', async () => { + await open(ed.page) + await I.fillField(ed.selector, longContent) + const submitted = await submitAndGrab() + expect(submitted).to.include('Opening paragraph') + expect(submitted).to.include('Middle paragraph') + expect(submitted).to.include('Closing paragraph') + }) + }) + } + }) + describe('#clearField', () => { it('should clear a given element', async () => { await I.amOnPage('/form/field') From e14ef4e171128b15326cf64553159fe818ad7806 Mon Sep 17 00:00:00 2001 From: Michael Bodnarchuk Date: Mon, 20 Apr 2026 02:06:22 +0300 Subject: [PATCH 2/5] fix: use browser.keys for WebDriver typeText/selectAll webdriverio keys() lives on browser, not element. Route typeText and selectAllAndDelete through helper.browser.keys() for the WebDriver path so rich-text scenarios (hidden-textarea + iframe editors) work. Co-Authored-By: Claude Opus 4.7 --- lib/element/WebElement.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/element/WebElement.js b/lib/element/WebElement.js index 6f747042e..e63936385 100644 --- a/lib/element/WebElement.js +++ b/lib/element/WebElement.js @@ -307,7 +307,7 @@ class WebElement { case 'puppeteer': return this.helper.page.keyboard.type(s, options) case 'webdriver': - return this.element.keys(s.split('')) + return this.helper.browser.keys(s) default: throw new Error(`Unsupported helper type: ${this.helperType}`) } @@ -335,11 +335,13 @@ class WebElement { } await this.helper.page.keyboard.press('Backspace') return - case 'webdriver': - await this.element.keys(['Control', 'a']) - await this.element.keys(['Meta', 'a']) - await this.element.keys(['Backspace']) + case 'webdriver': { + const b = this.helper.browser + await b.keys(['Control', 'a']).catch(() => {}) + await b.keys(['Meta', 'a']).catch(() => {}) + await b.keys(['Backspace']) return + } default: throw new Error(`Unsupported helper type: ${this.helperType}`) } From 9f5f2f4c903c48af2adb0f5f63a6a291c1ecdb08 Mon Sep 17 00:00:00 2001 From: Michael Bodnarchuk Date: Mon, 20 Apr 2026 12:04:39 +0300 Subject: [PATCH 3/5] fix: WebDriver v9 frame switching + newline handling Use switchFrame() instead of removed switchToFrame/switchToParentFrame. Split typed text on \n and emit Enter keypress (\uE007) between segments so multi-paragraph content reaches Trix and other keystroke-driven editors. Co-Authored-By: Claude Opus 4.7 --- lib/element/WebElement.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/element/WebElement.js b/lib/element/WebElement.js index e63936385..d40d312a9 100644 --- a/lib/element/WebElement.js +++ b/lib/element/WebElement.js @@ -306,8 +306,15 @@ class WebElement { case 'playwright': case 'puppeteer': return this.helper.page.keyboard.type(s, options) - case 'webdriver': - return this.helper.browser.keys(s) + case 'webdriver': { + const ENTER = '\uE007' + const parts = s.split('\n') + for (let i = 0; i < parts.length; i++) { + if (parts[i]) await this.helper.browser.keys(parts[i]) + if (i < parts.length - 1) await this.helper.browser.keys(ENTER) + } + return + } default: throw new Error(`Unsupported helper type: ${this.helperType}`) } @@ -364,12 +371,12 @@ class WebElement { } case 'webdriver': { const browser = this.helper.browser - await browser.switchToFrame(this.element) + await browser.switchFrame(this.element) try { const body = await browser.$('body') return await fn(new WebElement(body, this.helper)) } finally { - await browser.switchToParentFrame() + await browser.switchFrame(null) } } default: From 12cbb5d20f87e1b3135c7e77585efa9325a19276 Mon Sep 17 00:00:00 2001 From: Michael Bodnarchuk Date: Mon, 20 Apr 2026 15:23:22 +0300 Subject: [PATCH 4/5] fix: drop redundant iframe body click in rich editor fill selectAllInEditable already focuses the body via JS; the extra click was only there to mimic Playwright's force-click semantics. In WebDriver the click resolved viewport coords through the iframe, so CKEditor 4's parent-scope notification overlay intercepted it. Removing the click makes all 48 iframe tests pass in WebDriver without affecting Playwright or Puppeteer. Co-Authored-By: Claude Opus 4.7 --- lib/helper/extras/richTextEditor.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/helper/extras/richTextEditor.js b/lib/helper/extras/richTextEditor.js index 6cd5b89f4..6bb54b8de 100644 --- a/lib/helper/extras/richTextEditor.js +++ b/lib/helper/extras/richTextEditor.js @@ -96,7 +96,6 @@ export async function fillRichEditor(helper, el, value) { if (kind === EDITOR.IFRAME) { await target.inIframe(async body => { - await body.click({ force: true }) await body.evaluate(selectAllInEditable) await body.typeText(value, { delay }) }) From c55680aa57a362e554a837bb5623ab2d96fc687c Mon Sep 17 00:00:00 2001 From: Michael Bodnarchuk Date: Mon, 20 Apr 2026 22:10:07 +0300 Subject: [PATCH 5/5] fix: bound rich editor hidden-ancestor walk to avoid cross-form binds The hidden-element fallback in detectAndMark used to climb to document.body looking for a nearby editor, so calling fillField on an unrelated hidden input could accidentally bind it to any editor on the same page. Cap the ancestor walk at 3 levels (covers CKE4 / Summernote / TinyMCE-legacy wrapper depth of 1-2 with margin) and skip the walk entirely for since those are form-state inputs, not editor-backing elements. Co-Authored-By: Claude Opus 4.7 --- lib/helper/extras/richTextEditor.js | 33 +++++++++++++++-------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/lib/helper/extras/richTextEditor.js b/lib/helper/extras/richTextEditor.js index 6bb54b8de..6efed1ccc 100644 --- a/lib/helper/extras/richTextEditor.js +++ b/lib/helper/extras/richTextEditor.js @@ -13,6 +13,7 @@ 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)) @@ -26,7 +27,8 @@ function detectAndMark(el, opts) { if (tag === 'IFRAME') return mark(kinds.IFRAME, el) if (el.isContentEditable) return mark(kinds.CONTENTEDITABLE, el) - if (tag !== 'INPUT' && tag !== 'TEXTAREA') { + const canSearchDescendants = tag !== 'INPUT' && tag !== 'TEXTAREA' + if (canSearchDescendants) { const iframe = el.querySelector('iframe') if (iframe) return mark(kinds.IFRAME, iframe) const ce = el.querySelector(CE) @@ -36,25 +38,24 @@ function detectAndMark(el, opts) { } const style = window.getComputedStyle(el) - const hidden = + const isHidden = el.offsetParent === null || (el.offsetWidth === 0 && el.offsetHeight === 0) || style.display === 'none' || style.visibility === 'hidden' - - if (hidden) { - let scope = el.parentElement - while (scope) { - const iframeNear = scope.querySelector('iframe') - if (iframeNear) return mark(kinds.IFRAME, iframeNear) - const ceNear = scope.querySelector(CE) - if (ceNear) return mark(kinds.CONTENTEDITABLE, ceNear) - for (const t of scope.querySelectorAll('textarea')) { - if (t !== el) return mark(kinds.HIDDEN_TEXTAREA, t) - } - if (scope === document.body) break - scope = scope.parentElement - } + 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)