diff --git a/lib/element/WebElement.js b/lib/element/WebElement.js index 5cc010f60..d40d312a9 100644 --- a/lib/element/WebElement.js +++ b/lib/element/WebElement.js @@ -256,6 +256,134 @@ 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': { + 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}`) + } + } + + /** + * 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': { + 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}`) + } + } + + /** + * 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.switchFrame(this.element) + try { + const body = await browser.$('body') + return await fn(new WebElement(body, this.helper)) + } finally { + await browser.switchFrame(null) + } + } + 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..6efed1ccc --- /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=""]' + const MAX_HIDDEN_ASCENT = 3 + + 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) + + 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) + 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 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 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.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')