Skip to content

Commit 4326bcc

Browse files
author
DavertMik
committed
Merge branch '4.x' of github.com:codeceptjs/CodeceptJS into 4.x
2 parents 1ae964b + 6ca126f commit 4326bcc

14 files changed

Lines changed: 427 additions & 37 deletions

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

lib/locator.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -591,13 +591,24 @@ Locator.clickable = {
591591
`.//*[@title = ${literal}]`,
592592
`.//*[@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id ]`,
593593
`.//*[@role='button'][normalize-space(.)=${literal}]`,
594+
`.//*[@role='tab' or @role='link' or @role='menuitem' or @role='menuitemcheckbox' or @role='menuitemradio' or @role='option' or @role='treeitem'][contains(normalize-space(string(.)), ${literal})]`,
594595
]),
595596

596597
/**
597598
* @param {string} literal
598599
* @returns {string}
599600
*/
600-
self: literal => `./self::*[contains(normalize-space(string(.)), ${literal}) or contains(normalize-space(@value), ${literal})]`,
601+
self: literal => {
602+
// Narrowest-match: prefer the deepest descendant whose string-value contains the literal.
603+
// Falling back to `self` without the `not(descendant...)` guard would match a container
604+
// whose concatenated text happens to include the literal (e.g. a <ul role="tablist"> whose
605+
// tab labels all sit in its string-value) and click the container itself.
606+
const narrowest = `contains(normalize-space(string(.)), ${literal}) and not(.//*[contains(normalize-space(string(.)), ${literal})])`
607+
return xpathLocator.combine([
608+
`.//*[${narrowest}]`,
609+
`./self::*[${narrowest} or contains(normalize-space(@value), ${literal})]`,
610+
])
611+
},
601612
}
602613

603614
Locator.field = {

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;

0 commit comments

Comments
 (0)