Skip to content

Commit fe5ce3e

Browse files
DavertMikclaude
andcommitted
feat: fillField supports rich text editors
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 <noreply@anthropic.com>
1 parent 49a3de5 commit fe5ce3e

22 files changed

Lines changed: 773 additions & 4 deletions

lib/element/WebElement.js

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,125 @@ class WebElement {
256256
}
257257
}
258258

259+
/**
260+
* Run a function in the browser with this element as the first argument.
261+
* @param {Function} fn Browser-side function. Receives the element, then extra args.
262+
* @param {...any} args Additional arguments passed to the function
263+
* @returns {Promise<any>} Value returned by fn
264+
*/
265+
async evaluate(fn, ...args) {
266+
switch (this.helperType) {
267+
case 'playwright':
268+
case 'puppeteer':
269+
return this.element.evaluate(fn, ...args)
270+
case 'webdriver':
271+
return this.helper.executeScript(fn, this.element, ...args)
272+
default:
273+
throw new Error(`Unsupported helper type: ${this.helperType}`)
274+
}
275+
}
276+
277+
/**
278+
* Focus the element.
279+
* @returns {Promise<void>}
280+
*/
281+
async focus() {
282+
switch (this.helperType) {
283+
case 'playwright':
284+
return this.element.focus()
285+
case 'puppeteer':
286+
if (this.element.focus) return this.element.focus()
287+
return this.element.evaluate(el => el.focus())
288+
case 'webdriver':
289+
return this.helper.executeScript(el => el.focus(), this.element)
290+
default:
291+
throw new Error(`Unsupported helper type: ${this.helperType}`)
292+
}
293+
}
294+
295+
/**
296+
* Type characters via the page/browser keyboard into the focused element.
297+
* Unlike `type()`, this does not call `.fill()`/`.setValue()`, so it works
298+
* with contenteditable nodes, iframe bodies, and editor-owned hidden textareas.
299+
* @param {string} text Text to send
300+
* @param {Object} [options] Options (e.g. `{ delay }`)
301+
* @returns {Promise<void>}
302+
*/
303+
async typeText(text, options = {}) {
304+
const s = String(text)
305+
switch (this.helperType) {
306+
case 'playwright':
307+
case 'puppeteer':
308+
return this.helper.page.keyboard.type(s, options)
309+
case 'webdriver':
310+
return this.element.keys(s.split(''))
311+
default:
312+
throw new Error(`Unsupported helper type: ${this.helperType}`)
313+
}
314+
}
315+
316+
/**
317+
* Select all content in the focused field and delete it via keyboard input.
318+
* Sends Ctrl+A and Meta+A (so it works across platforms) followed by Backspace.
319+
* @returns {Promise<void>}
320+
*/
321+
async selectAllAndDelete() {
322+
switch (this.helperType) {
323+
case 'playwright':
324+
await this.helper.page.keyboard.press('Control+a').catch(() => {})
325+
await this.helper.page.keyboard.press('Meta+a').catch(() => {})
326+
await this.helper.page.keyboard.press('Backspace')
327+
return
328+
case 'puppeteer':
329+
for (const mod of ['Control', 'Meta']) {
330+
try {
331+
await this.helper.page.keyboard.down(mod)
332+
await this.helper.page.keyboard.press('KeyA')
333+
await this.helper.page.keyboard.up(mod)
334+
} catch (e) {}
335+
}
336+
await this.helper.page.keyboard.press('Backspace')
337+
return
338+
case 'webdriver':
339+
await this.element.keys(['Control', 'a'])
340+
await this.element.keys(['Meta', 'a'])
341+
await this.element.keys(['Backspace'])
342+
return
343+
default:
344+
throw new Error(`Unsupported helper type: ${this.helperType}`)
345+
}
346+
}
347+
348+
/**
349+
* Treat this element as an iframe; invoke `fn` with a WebElement wrapping
350+
* the iframe body. For WebDriver this switches the browser into the frame
351+
* for the duration of the callback and switches back on exit.
352+
* @param {(body: WebElement) => Promise<any>} fn
353+
* @returns {Promise<any>} Return value of fn
354+
*/
355+
async inIframe(fn) {
356+
switch (this.helperType) {
357+
case 'playwright':
358+
case 'puppeteer': {
359+
const frame = await this.element.contentFrame()
360+
const body = await frame.$('body')
361+
return fn(new WebElement(body, this.helper))
362+
}
363+
case 'webdriver': {
364+
const browser = this.helper.browser
365+
await browser.switchToFrame(this.element)
366+
try {
367+
const body = await browser.$('body')
368+
return await fn(new WebElement(body, this.helper))
369+
} finally {
370+
await browser.switchToParentFrame()
371+
}
372+
}
373+
default:
374+
throw new Error(`Unsupported helper type: ${this.helperType}`)
375+
}
376+
}
377+
259378
/**
260379
* Find first child element matching the locator
261380
* @param {string|Object} locator Element locator

lib/helper/Playwright.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { findReact, findVue, findByPlaywrightLocator } from './extras/Playwright
4040
import { dropFile } from './scripts/dropFile.js'
4141
import WebElement from '../element/WebElement.js'
4242
import { selectElement } from './extras/elementSelection.js'
43+
import { fillRichEditor } from './extras/richTextEditor.js'
4344

4445
let playwright
4546
let perfTiming
@@ -2283,11 +2284,15 @@ class Playwright extends Helper {
22832284
assertElementExists(els, field, 'Field')
22842285
const el = selectElement(els, field, this)
22852286

2287+
await highlightActiveElement.call(this, el)
2288+
2289+
if (await fillRichEditor(this, el, value)) {
2290+
return this._waitForAction()
2291+
}
2292+
22862293
await el.clear()
22872294
if (store.debugMode) this.debugSection('Focused', await elToString(el, 1))
22882295

2289-
await highlightActiveElement.call(this, el)
2290-
22912296
await el.type(value.toString(), { delay: this.options.pressKeyDelay })
22922297

22932298
return this._waitForAction()

lib/helper/Puppeteer.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { dontSeeElementError, seeElementError, dontSeeElementInDOMError, seeElem
4545
import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js'
4646
import WebElement from '../element/WebElement.js'
4747
import { selectElement } from './extras/elementSelection.js'
48+
import { fillRichEditor } from './extras/richTextEditor.js'
4849

4950
let puppeteer
5051

@@ -1595,9 +1596,18 @@ class Puppeteer extends Helper {
15951596
* {{ react }}
15961597
*/
15971598
async fillField(field, value, context = null) {
1598-
const els = await findVisibleFields.call(this, field, context)
1599+
let els = await findVisibleFields.call(this, field, context)
1600+
if (!els.length) {
1601+
els = await findFields.call(this, field, context)
1602+
}
15991603
assertElementExists(els, field, 'Field')
16001604
const el = selectElement(els, field, this)
1605+
1606+
if (await fillRichEditor(this, el, value)) {
1607+
highlightActiveElement.call(this, el, await this._getContext())
1608+
return this._waitForAction()
1609+
}
1610+
16011611
const tag = await el.getProperty('tagName').then(el => el.jsonValue())
16021612
const editable = await el.getProperty('contenteditable').then(el => el.jsonValue())
16031613
if (tag === 'INPUT' || tag === 'TEXTAREA') {

lib/helper/WebDriver.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { dropFile } from './scripts/dropFile.js'
4242
import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js'
4343
import WebElement from '../element/WebElement.js'
4444
import { selectElement } from './extras/elementSelection.js'
45+
import { fillRichEditor } from './extras/richTextEditor.js'
4546

4647
const SHADOW = 'shadow'
4748
const webRoot = 'body'
@@ -1279,6 +1280,11 @@ class WebDriver extends Helper {
12791280
assertElementExists(res, field, 'Field')
12801281
const elem = selectElement(res, field, this)
12811282
highlightActiveElement.call(this, elem)
1283+
1284+
if (await fillRichEditor(this, elem, value)) {
1285+
return
1286+
}
1287+
12821288
try {
12831289
await elem.clearValue()
12841290
} catch (err) {
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import WebElement from '../../element/WebElement.js'
2+
3+
const MARKER = 'data-codeceptjs-rte-target'
4+
5+
const EDITOR = {
6+
STANDARD: 'standard',
7+
IFRAME: 'iframe',
8+
CONTENTEDITABLE: 'contenteditable',
9+
HIDDEN_TEXTAREA: 'hidden-textarea',
10+
}
11+
12+
function detectAndMark(el, opts) {
13+
const marker = opts.marker
14+
const kinds = opts.kinds
15+
const CE = '[contenteditable="true"], [contenteditable=""]'
16+
17+
function mark(kind, target) {
18+
document.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker))
19+
if (target && target.nodeType === 1) target.setAttribute(marker, '1')
20+
return kind
21+
}
22+
23+
if (!el || el.nodeType !== 1) return mark(kinds.STANDARD, el)
24+
25+
const tag = el.tagName
26+
if (tag === 'IFRAME') return mark(kinds.IFRAME, el)
27+
if (el.isContentEditable) return mark(kinds.CONTENTEDITABLE, el)
28+
29+
if (tag !== 'INPUT' && tag !== 'TEXTAREA') {
30+
const iframe = el.querySelector('iframe')
31+
if (iframe) return mark(kinds.IFRAME, iframe)
32+
const ce = el.querySelector(CE)
33+
if (ce) return mark(kinds.CONTENTEDITABLE, ce)
34+
const textarea = el.querySelector('textarea')
35+
if (textarea) return mark(kinds.HIDDEN_TEXTAREA, textarea)
36+
}
37+
38+
const style = window.getComputedStyle(el)
39+
const hidden =
40+
el.offsetParent === null ||
41+
(el.offsetWidth === 0 && el.offsetHeight === 0) ||
42+
style.display === 'none' ||
43+
style.visibility === 'hidden'
44+
45+
if (hidden) {
46+
let scope = el.parentElement
47+
while (scope) {
48+
const iframeNear = scope.querySelector('iframe')
49+
if (iframeNear) return mark(kinds.IFRAME, iframeNear)
50+
const ceNear = scope.querySelector(CE)
51+
if (ceNear) return mark(kinds.CONTENTEDITABLE, ceNear)
52+
for (const t of scope.querySelectorAll('textarea')) {
53+
if (t !== el) return mark(kinds.HIDDEN_TEXTAREA, t)
54+
}
55+
if (scope === document.body) break
56+
scope = scope.parentElement
57+
}
58+
}
59+
60+
return mark(kinds.STANDARD, el)
61+
}
62+
63+
function selectAllInEditable(el) {
64+
const doc = el.ownerDocument
65+
const win = doc.defaultView
66+
el.focus()
67+
const range = doc.createRange()
68+
range.selectNodeContents(el)
69+
const sel = win.getSelection()
70+
sel.removeAllRanges()
71+
sel.addRange(range)
72+
}
73+
74+
function unmarkAll(marker) {
75+
document.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker))
76+
}
77+
78+
async function findMarked(helper) {
79+
const root = helper.page || helper.browser
80+
const raw = await root.$('[' + MARKER + ']')
81+
return new WebElement(raw, helper)
82+
}
83+
84+
async function clearMarker(helper) {
85+
if (helper.page) return helper.page.evaluate(unmarkAll, MARKER)
86+
return helper.executeScript(unmarkAll, MARKER)
87+
}
88+
89+
export async function fillRichEditor(helper, el, value) {
90+
const source = el instanceof WebElement ? el : new WebElement(el, helper)
91+
const kind = await source.evaluate(detectAndMark, { marker: MARKER, kinds: EDITOR })
92+
if (kind === EDITOR.STANDARD) return false
93+
94+
const target = await findMarked(helper)
95+
const delay = helper.options.pressKeyDelay
96+
97+
if (kind === EDITOR.IFRAME) {
98+
await target.inIframe(async body => {
99+
await body.click({ force: true })
100+
await body.evaluate(selectAllInEditable)
101+
await body.typeText(value, { delay })
102+
})
103+
} else if (kind === EDITOR.HIDDEN_TEXTAREA) {
104+
await target.focus()
105+
await target.selectAllAndDelete()
106+
await target.typeText(value, { delay })
107+
} else if (kind === EDITOR.CONTENTEDITABLE) {
108+
await target.click()
109+
await target.evaluate(selectAllInEditable)
110+
await target.typeText(value, { delay })
111+
}
112+
113+
await clearMarker(helper)
114+
return true
115+
}

test/data/app/controllers.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,3 +319,12 @@ function GET() {
319319
include __DIR__.'/view/basic_auth.php';
320320
}
321321
}
322+
323+
class richtext_submit {
324+
function POST() {
325+
$content = isset($_POST['content']) ? $_POST['content'] : '';
326+
echo '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Submitted</title></head><body>';
327+
echo '<pre id="result">' . htmlspecialchars($content, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '</pre>';
328+
echo '</body></html>';
329+
}
330+
}

test/data/app/index.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646
'/download' => 'download',
4747
'/basic_auth' => 'basic_auth',
4848
'/image' => 'basic_image',
49-
'/invisible_elements' => 'invisible_elements'
49+
'/invisible_elements' => 'invisible_elements',
50+
'/richtext_submit' => 'richtext_submit'
5051
);
5152

5253
glue::stick($urls);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Rich Text Editors</title>
6+
</head>
7+
<body>
8+
<h1>Rich Text Editors</h1>
9+
<p>Test pages for fillField against rich text editors.</p>
10+
<ul>
11+
<li><a href="/form/richtext/prosemirror">ProseMirror</a> — contenteditable</li>
12+
<li><a href="/form/richtext/quill">Quill</a> — contenteditable (.ql-editor)</li>
13+
<li><a href="/form/richtext/ckeditor5">CKEditor 5</a> — contenteditable (.ck-editor__editable)</li>
14+
<li><a href="/form/richtext/ckeditor4">CKEditor 4</a> — iframe</li>
15+
<li><a href="/form/richtext/tinymce-modern">TinyMCE (inline)</a> — contenteditable</li>
16+
<li><a href="/form/richtext/tinymce-legacy">TinyMCE (iframe)</a> — iframe</li>
17+
<li><a href="/form/richtext/monaco">Monaco</a> — hidden textarea</li>
18+
<li><a href="/form/richtext/ace">ACE</a> — hidden textarea</li>
19+
<li><a href="/form/richtext/codemirror5">CodeMirror 5</a> — hidden textarea</li>
20+
<li><a href="/form/richtext/codemirror6">CodeMirror 6</a> — contenteditable (.cm-content)</li>
21+
<li><a href="/form/richtext/trix">Trix</a> — contenteditable (trix-editor)</li>
22+
<li><a href="/form/richtext/summernote">Summernote</a> — contenteditable (.note-editable)</li>
23+
</ul>
24+
</body>
25+
</html>
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php $initial = isset($_GET['initial']) ? $_GET['initial'] : ''; ?>
2+
<!DOCTYPE html>
3+
<html>
4+
<head>
5+
<meta charset="UTF-8">
6+
<title>ACE</title>
7+
</head>
8+
<body>
9+
<h1>ACE</h1>
10+
<form id="richtext-form" method="post" action="/richtext_submit">
11+
<div id="editor" style="height: 300px; border: 1px solid #ccc;"></div>
12+
<input type="hidden" name="content" id="content-sync">
13+
<button type="submit" id="submit">Submit</button>
14+
</form>
15+
<script src="https://cdn.jsdelivr.net/npm/ace-builds@1.32.7/src-noconflict/ace.js"></script>
16+
<script>
17+
const editor = ace.edit('editor');
18+
const initial = <?php echo json_encode($initial, JSON_UNESCAPED_UNICODE); ?>;
19+
editor.setValue(initial, -1);
20+
window.__editor = editor;
21+
window.__editorContent = () => editor.getValue();
22+
window.__editorReady = true;
23+
24+
document.getElementById('richtext-form').addEventListener('submit', function() {
25+
document.getElementById('content-sync').value = window.__editorContent ? window.__editorContent() : '';
26+
});
27+
</script>
28+
</body>
29+
</html>

0 commit comments

Comments
 (0)