Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions lib/element/WebElement.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>} 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<void>}
*/
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<void>}
*/
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<void>}
*/
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<any>} fn
* @returns {Promise<any>} 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
Expand Down
9 changes: 7 additions & 2 deletions lib/helper/Playwright.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
12 changes: 11 additions & 1 deletion lib/helper/Puppeteer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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') {
Expand Down
6 changes: 6 additions & 0 deletions lib/helper/WebDriver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down
115 changes: 115 additions & 0 deletions lib/helper/extras/richTextEditor.js
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks too broad for hidden elements: it climbs up to document.body and can accidentally bind an unrelated hidden field to any editor on the page

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
}
9 changes: 9 additions & 0 deletions test/data/app/controllers.php
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,12 @@ function GET() {
include __DIR__.'/view/basic_auth.php';
}
}

class richtext_submit {
function POST() {
$content = isset($_POST['content']) ? $_POST['content'] : '';
echo '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Submitted</title></head><body>';
echo '<pre id="result">' . htmlspecialchars($content, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '</pre>';
echo '</body></html>';
}
}
3 changes: 2 additions & 1 deletion test/data/app/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
25 changes: 25 additions & 0 deletions test/data/app/view/form/richtext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Rich Text Editors</title>
</head>
<body>
<h1>Rich Text Editors</h1>
<p>Test pages for fillField against rich text editors.</p>
<ul>
<li><a href="/form/richtext/prosemirror">ProseMirror</a> — contenteditable</li>
<li><a href="/form/richtext/quill">Quill</a> — contenteditable (.ql-editor)</li>
<li><a href="/form/richtext/ckeditor5">CKEditor 5</a> — contenteditable (.ck-editor__editable)</li>
<li><a href="/form/richtext/ckeditor4">CKEditor 4</a> — iframe</li>
<li><a href="/form/richtext/tinymce-modern">TinyMCE (inline)</a> — contenteditable</li>
<li><a href="/form/richtext/tinymce-legacy">TinyMCE (iframe)</a> — iframe</li>
<li><a href="/form/richtext/monaco">Monaco</a> — hidden textarea</li>
<li><a href="/form/richtext/ace">ACE</a> — hidden textarea</li>
<li><a href="/form/richtext/codemirror5">CodeMirror 5</a> — hidden textarea</li>
<li><a href="/form/richtext/codemirror6">CodeMirror 6</a> — contenteditable (.cm-content)</li>
<li><a href="/form/richtext/trix">Trix</a> — contenteditable (trix-editor)</li>
<li><a href="/form/richtext/summernote">Summernote</a> — contenteditable (.note-editable)</li>
</ul>
</body>
</html>
Loading