Skip to content

Commit f40a32c

Browse files
DavertMikDavertMikclaude
authored
fix(fillField): prevent rich-editor keystroke leak to sibling inputs (#5531)
* fix(fillField): prevent rich-editor keystroke leak to sibling inputs 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) <noreply@anthropic.com> * fix(fillField): cross-helper iframe detection + leak guard for hidden form controls - WebDriver/BiDi rejects element refs as executeScript args from inside a switched-frame context, breaking the inner-iframe focus/select calls. Run those scripts via document.querySelector on the marker instead, then send keystrokes via the already-frame-aware page keyboard. - Add EDITOR.UNREACHABLE: when the user's locator points at a display:none INPUT/TEXTAREA, throw a clear error instead of falling through. Without this, Puppeteer's lenient el.type() silently leaks to whatever has focus. - Test reads outer-input value via executeScript to dodge a pre-existing WebDriver grabValueFrom bug that drops empty strings in forEachAsync. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: DavertMik <davert@testomat.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6d8c234 commit f40a32c

10 files changed

Lines changed: 271 additions & 36 deletions

File tree

lib/helper/extras/richTextEditor.js

Lines changed: 86 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ const EDITOR = {
77
IFRAME: 'iframe',
88
CONTENTEDITABLE: 'contenteditable',
99
HIDDEN_TEXTAREA: 'hidden-textarea',
10+
UNREACHABLE: 'unreachable',
1011
}
1112

1213
function detectAndMark(el, opts) {
1314
const marker = opts.marker
1415
const kinds = opts.kinds
1516
const CE = '[contenteditable="true"], [contenteditable=""]'
16-
const MAX_HIDDEN_ASCENT = 3
1717

1818
function mark(kind, target) {
1919
document.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker))
@@ -27,38 +27,76 @@ function detectAndMark(el, opts) {
2727
if (tag === 'IFRAME') return mark(kinds.IFRAME, el)
2828
if (el.isContentEditable) return mark(kinds.CONTENTEDITABLE, el)
2929

30+
const isFormHidden = tag === 'INPUT' && el.type === 'hidden'
31+
if ((tag === 'INPUT' || tag === 'TEXTAREA') && !isFormHidden) {
32+
const style = window.getComputedStyle(el)
33+
if (style.display === 'none') return mark(kinds.UNREACHABLE, el)
34+
}
35+
3036
const canSearchDescendants = tag !== 'INPUT' && tag !== 'TEXTAREA'
3137
if (canSearchDescendants) {
3238
const iframe = el.querySelector('iframe')
3339
if (iframe) return mark(kinds.IFRAME, iframe)
3440
const ce = el.querySelector(CE)
3541
if (ce) return mark(kinds.CONTENTEDITABLE, ce)
36-
const textarea = el.querySelector('textarea')
42+
const textareas = [...el.querySelectorAll('textarea')]
43+
const focusable = textareas.find(t => window.getComputedStyle(t).display !== 'none')
44+
const textarea = focusable || textareas[0]
3745
if (textarea) return mark(kinds.HIDDEN_TEXTAREA, textarea)
3846
}
3947

40-
const style = window.getComputedStyle(el)
41-
const isHidden =
42-
el.offsetParent === null ||
43-
(el.offsetWidth === 0 && el.offsetHeight === 0) ||
44-
style.display === 'none' ||
45-
style.visibility === 'hidden'
46-
if (!isHidden) return mark(kinds.STANDARD, el)
48+
return mark(kinds.STANDARD, el)
49+
}
50+
51+
function detectInsideFrame() {
52+
const MARKER = 'data-codeceptjs-rte-target'
53+
const CE = '[contenteditable="true"], [contenteditable=""]'
54+
const CONTENTEDITABLE = 'contenteditable'
55+
const HIDDEN_TEXTAREA = 'hidden-textarea'
56+
const body = document.body
57+
document.querySelectorAll('[' + MARKER + ']').forEach(n => n.removeAttribute(MARKER))
4758

48-
const isFormHidden = tag === 'INPUT' && el.type === 'hidden'
49-
if (isFormHidden) return mark(kinds.STANDARD, el)
50-
51-
let scope = el.parentElement
52-
for (let depth = 0; scope && depth < MAX_HIDDEN_ASCENT; depth++, scope = scope.parentElement) {
53-
const iframeNear = scope.querySelector('iframe')
54-
if (iframeNear) return mark(kinds.IFRAME, iframeNear)
55-
const ceNear = scope.querySelector(CE)
56-
if (ceNear) return mark(kinds.CONTENTEDITABLE, ceNear)
57-
const textareaNear = [...scope.querySelectorAll('textarea')].find(t => t !== el)
58-
if (textareaNear) return mark(kinds.HIDDEN_TEXTAREA, textareaNear)
59+
if (body.isContentEditable) return CONTENTEDITABLE
60+
61+
const ce = body.querySelector(CE)
62+
if (ce) {
63+
ce.setAttribute(MARKER, '1')
64+
return CONTENTEDITABLE
5965
}
6066

61-
return mark(kinds.STANDARD, el)
67+
const textareas = [...body.querySelectorAll('textarea')]
68+
const focusable = textareas.find(t => window.getComputedStyle(t).display !== 'none')
69+
const textarea = focusable || textareas[0]
70+
if (textarea) {
71+
textarea.setAttribute(MARKER, '1')
72+
return HIDDEN_TEXTAREA
73+
}
74+
75+
return CONTENTEDITABLE
76+
}
77+
78+
async function evaluateInFrame(helper, body, fn) {
79+
if (body.helperType === 'webdriver') {
80+
return helper.executeScript(fn)
81+
}
82+
return body.element.evaluate(fn)
83+
}
84+
85+
function focusMarkedInFrameScript() {
86+
const el = document.querySelector('[data-codeceptjs-rte-target]') || document.body
87+
el.focus()
88+
return document.activeElement === el
89+
}
90+
91+
function selectAllInFrameScript() {
92+
const el = document.querySelector('[data-codeceptjs-rte-target]') || document.body
93+
el.focus()
94+
const range = document.createRange()
95+
range.selectNodeContents(el)
96+
const sel = window.getSelection()
97+
sel.removeAllRanges()
98+
sel.addRange(range)
99+
return document.activeElement === el
62100
}
63101

64102
function selectAllInEditable(el) {
@@ -76,6 +114,17 @@ function unmarkAll(marker) {
76114
document.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker))
77115
}
78116

117+
function isActive(el) {
118+
return el.ownerDocument.activeElement === el
119+
}
120+
121+
async function assertFocused(target) {
122+
const focused = await target.evaluate(isActive)
123+
if (!focused) {
124+
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.')
125+
}
126+
}
127+
79128
async function findMarked(helper) {
80129
const root = helper.page || helper.browser
81130
const raw = await root.$('[' + MARKER + ']')
@@ -91,22 +140,36 @@ export async function fillRichEditor(helper, el, value) {
91140
const source = el instanceof WebElement ? el : new WebElement(el, helper)
92141
const kind = await source.evaluate(detectAndMark, { marker: MARKER, kinds: EDITOR })
93142
if (kind === EDITOR.STANDARD) return false
143+
if (kind === EDITOR.UNREACHABLE) {
144+
throw new Error('fillField: cannot fill a display:none form control. Locator must point at the visible editor surface (a wrapper, iframe, or contenteditable).')
145+
}
94146

95147
const target = await findMarked(helper)
96148
const delay = helper.options.pressKeyDelay
97149

98150
if (kind === EDITOR.IFRAME) {
99151
await target.inIframe(async body => {
100-
await body.evaluate(selectAllInEditable)
101-
await body.typeText(value, { delay })
152+
const innerKind = await evaluateInFrame(helper, body, detectInsideFrame)
153+
if (innerKind === EDITOR.HIDDEN_TEXTAREA) {
154+
const focused = await evaluateInFrame(helper, body, focusMarkedInFrameScript)
155+
if (!focused) throw new Error('fillField: rich editor target inside iframe did not accept focus.')
156+
await body.selectAllAndDelete()
157+
await body.typeText(value, { delay })
158+
} else {
159+
const focused = await evaluateInFrame(helper, body, selectAllInFrameScript)
160+
if (!focused) throw new Error('fillField: rich editor target inside iframe did not accept focus.')
161+
await body.typeText(value, { delay })
162+
}
102163
})
103164
} else if (kind === EDITOR.HIDDEN_TEXTAREA) {
104165
await target.focus()
166+
await assertFocused(target)
105167
await target.selectAllAndDelete()
106168
await target.typeText(value, { delay })
107169
} else if (kind === EDITOR.CONTENTEDITABLE) {
108170
await target.click()
109171
await target.evaluate(selectAllInEditable)
172+
await assertFocused(target)
110173
await target.typeText(value, { delay })
111174
}
112175

test/data/app/view/form/richtext/ckeditor4.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
<body>
99
<h1>CKEditor 4</h1>
1010
<form id="richtext-form" method="post" action="/richtext_submit">
11-
<textarea id="editor"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
11+
<div id="editor">
12+
<textarea id="editor-inner"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
13+
</div>
1214
<input type="hidden" name="content" id="content-sync">
1315
<button type="submit" id="submit">Submit</button>
1416
</form>
1517
<script src="https://cdn.ckeditor.com/4.22.1/standard/ckeditor.js"></script>
1618
<script>
17-
CKEDITOR.replace('editor');
19+
CKEDITOR.replace('editor-inner');
1820
CKEDITOR.on('instanceReady', function(e) {
1921
window.__editor = e.editor;
2022
window.__editorContent = () => {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php $initial = isset($_GET['initial']) ? $_GET['initial'] : ''; ?>
2+
<!DOCTYPE html>
3+
<html>
4+
<head>
5+
<meta charset="UTF-8">
6+
<title>CKEditor 5 with sibling input</title>
7+
</head>
8+
<body>
9+
<h1>CKEditor 5 with sibling input</h1>
10+
<form id="richtext-form" method="post" action="/richtext_submit">
11+
<input id="outer-title" name="outer-title" placeholder="Title" type="search" autofocus>
12+
<div id="editor">
13+
<textarea id="editor-inner"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
14+
</div>
15+
<input type="hidden" name="content" id="content-sync">
16+
<button type="submit" id="submit">Submit</button>
17+
</form>
18+
<script src="https://cdn.ckeditor.com/ckeditor5/41.4.2/classic/ckeditor.js"></script>
19+
<script>
20+
ClassicEditor.create(document.querySelector('#editor-inner'), {
21+
removePlugins: ['TextTransformation']
22+
}).then(editor => {
23+
window.__editor = editor;
24+
window.__editorContent = () => {
25+
const div = document.createElement('div');
26+
div.innerHTML = editor.getData() || '';
27+
return (div.textContent || '').replace(/ /g, ' ').trim();
28+
};
29+
window.__editorReady = true;
30+
});
31+
document.getElementById('richtext-form').addEventListener('submit', function() {
32+
document.getElementById('content-sync').value = window.__editorContent ? window.__editorContent() : '';
33+
});
34+
document.getElementById('outer-title').focus();
35+
</script>
36+
</body>
37+
</html>

test/data/app/view/form/richtext/ckeditor5.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
<body>
99
<h1>CKEditor 5</h1>
1010
<form id="richtext-form" method="post" action="/richtext_submit">
11-
<textarea id="editor"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
11+
<div id="editor">
12+
<textarea id="editor-inner"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
13+
</div>
1214
<input type="hidden" name="content" id="content-sync">
1315
<button type="submit" id="submit">Submit</button>
1416
</form>
1517
<script src="https://cdn.ckeditor.com/ckeditor5/41.4.2/classic/ckeditor.js"></script>
1618
<script>
17-
ClassicEditor.create(document.querySelector('#editor'), {
19+
ClassicEditor.create(document.querySelector('#editor-inner'), {
1820
removePlugins: ['TextTransformation']
1921
}).then(editor => {
2022
window.__editor = editor;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php $initial = isset($_GET['initial']) ? $_GET['initial'] : ''; ?>
2+
<!DOCTYPE html>
3+
<html>
4+
<head>
5+
<meta charset="UTF-8">
6+
<title>CodeMirror 5 with sibling input</title>
7+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.css">
8+
<style>.CodeMirror { border: 1px solid #ccc; height: 200px; }</style>
9+
</head>
10+
<body>
11+
<h1>CodeMirror 5 with sibling input</h1>
12+
<form id="richtext-form" method="post" action="/richtext_submit">
13+
<input id="outer-title" name="outer-title" placeholder="Title" type="search" autofocus>
14+
<div id="editor">
15+
<textarea id="editor-inner"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
16+
</div>
17+
<input type="hidden" name="content" id="content-sync">
18+
<button type="submit" id="submit">Submit</button>
19+
</form>
20+
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.js"></script>
21+
<script>
22+
const editor = CodeMirror.fromTextArea(document.getElementById('editor-inner'), {});
23+
window.__editor = editor;
24+
window.__editorContent = () => editor.getValue();
25+
window.__editorReady = true;
26+
27+
document.getElementById('richtext-form').addEventListener('submit', function() {
28+
document.getElementById('content-sync').value = window.__editorContent ? window.__editorContent() : '';
29+
});
30+
document.getElementById('outer-title').focus();
31+
</script>
32+
</body>
33+
</html>

test/data/app/view/form/richtext/codemirror5.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@
1010
<body>
1111
<h1>CodeMirror 5</h1>
1212
<form id="richtext-form" method="post" action="/richtext_submit">
13-
<textarea id="editor"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
13+
<div id="editor">
14+
<textarea id="editor-inner"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
15+
</div>
1416
<input type="hidden" name="content" id="content-sync">
1517
<button type="submit" id="submit">Submit</button>
1618
</form>
1719
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.js"></script>
1820
<script>
19-
const editor = CodeMirror.fromTextArea(document.getElementById('editor'), {});
21+
const editor = CodeMirror.fromTextArea(document.getElementById('editor-inner'), {});
2022
window.__editor = editor;
2123
window.__editorContent = () => editor.getValue();
2224
window.__editorReady = true;
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php $initial = isset($_GET['initial']) ? $_GET['initial'] : ''; ?>
2+
<!DOCTYPE html>
3+
<html>
4+
<head>
5+
<meta charset="UTF-8">
6+
<title>Monaco in iframe with sibling input</title>
7+
</head>
8+
<body>
9+
<h1>Monaco in iframe with sibling input</h1>
10+
<form id="outer-form" method="post" action="/richtext_submit">
11+
<input id="outer-title" name="outer-title" placeholder="Title" type="search" autofocus>
12+
<iframe id="monaco-frame" src="/form/richtext/monaco<?php echo $initial !== '' ? '?initial=' . urlencode($initial) : ''; ?>" style="width: 100%; height: 320px; border: 1px solid #ccc;"></iframe>
13+
<input type="hidden" name="content" id="content-sync">
14+
<button type="submit" id="submit">Submit</button>
15+
</form>
16+
<script>
17+
window.__editorReady = false;
18+
const frame = document.getElementById('monaco-frame');
19+
frame.addEventListener('load', function () {
20+
const poll = setInterval(function () {
21+
try {
22+
if (frame.contentWindow && frame.contentWindow.__editorReady) {
23+
window.__editorReady = true;
24+
clearInterval(poll);
25+
}
26+
} catch (e) {}
27+
}, 100);
28+
});
29+
document.getElementById('outer-form').addEventListener('submit', function () {
30+
try {
31+
const getValue = frame.contentWindow && frame.contentWindow.__editorContent;
32+
document.getElementById('content-sync').value = getValue ? getValue() : '';
33+
} catch (e) {
34+
document.getElementById('content-sync').value = '';
35+
}
36+
});
37+
document.getElementById('outer-title').focus();
38+
</script>
39+
</body>
40+
</html>

test/data/app/view/form/richtext/summernote.php

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,23 @@
99
<body>
1010
<h1>Summernote</h1>
1111
<form id="richtext-form" method="post" action="/richtext_submit">
12-
<div id="editor"></div>
12+
<div id="editor">
13+
<div id="editor-inner"></div>
14+
</div>
1315
<input type="hidden" name="content" id="content-sync">
1416
<button type="submit" id="submit">Submit</button>
1517
</form>
1618
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
1719
<script src="https://cdn.jsdelivr.net/npm/summernote@0.9.0/dist/summernote-lite.min.js"></script>
1820
<script>
1921
$(document).ready(function() {
20-
$('#editor').summernote({ height: 150 });
22+
$('#editor-inner').summernote({ height: 150 });
2123
const initial = <?php echo json_encode($initial, JSON_UNESCAPED_UNICODE); ?>;
22-
if (initial) $('#editor').summernote('code', initial.split(/\n{2,}/).map(p => '<p>' + p.replace(/</g, '&lt;') + '</p>').join(''));
23-
window.__editor = $('#editor');
24+
if (initial) $('#editor-inner').summernote('code', initial.split(/\n{2,}/).map(p => '<p>' + p.replace(/</g, '&lt;') + '</p>').join(''));
25+
window.__editor = $('#editor-inner');
2426
window.__editorContent = () => {
2527
const div = document.createElement('div');
26-
div.innerHTML = $('#editor').summernote('code') || '';
28+
div.innerHTML = $('#editor-inner').summernote('code') || '';
2729
return (div.textContent || '').replace(/\u00a0/g, ' ').trim();
2830
};
2931
window.__editorReady = true;

test/data/app/view/form/richtext/tinymce-legacy.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@
88
<body>
99
<h1>TinyMCE (iframe)</h1>
1010
<form id="richtext-form" method="post" action="/richtext_submit">
11-
<textarea id="editor"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
11+
<div id="editor">
12+
<textarea id="editor-inner"><?php echo htmlspecialchars($initial, ENT_QUOTES | ENT_HTML5, 'UTF-8'); ?></textarea>
13+
</div>
1214
<input type="hidden" name="content" id="content-sync">
1315
<button type="submit" id="submit">Submit</button>
1416
</form>
1517
<script src="https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js" referrerpolicy="origin"></script>
1618
<script>
1719
tinymce.init({
18-
selector: '#editor',
20+
selector: '#editor-inner',
1921
license_key: 'gpl',
2022
promotion: false,
2123
setup: function(editor) {

0 commit comments

Comments
 (0)