Skip to content

Commit 99932fa

Browse files
DavertMikclaude
andcommitted
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>
1 parent e1e213d commit 99932fa

2 files changed

Lines changed: 64 additions & 28 deletions

File tree

lib/helper/extras/richTextEditor.js

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

1213
function detectAndMark(el, opts) {
@@ -26,6 +27,12 @@ function detectAndMark(el, opts) {
2627
if (tag === 'IFRAME') return mark(kinds.IFRAME, el)
2728
if (el.isContentEditable) return mark(kinds.CONTENTEDITABLE, el)
2829

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+
2936
const canSearchDescendants = tag !== 'INPUT' && tag !== 'TEXTAREA'
3037
if (canSearchDescendants) {
3138
const iframe = el.querySelector('iframe')
@@ -41,29 +48,55 @@ function detectAndMark(el, opts) {
4148
return mark(kinds.STANDARD, el)
4249
}
4350

44-
function detectInsideFrame(body, opts) {
45-
const marker = opts.marker
46-
const kinds = opts.kinds
51+
function detectInsideFrame() {
52+
const MARKER = 'data-codeceptjs-rte-target'
4753
const CE = '[contenteditable="true"], [contenteditable=""]'
48-
body.ownerDocument.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker))
54+
const CONTENTEDITABLE = 'contenteditable'
55+
const HIDDEN_TEXTAREA = 'hidden-textarea'
56+
const body = document.body
57+
document.querySelectorAll('[' + MARKER + ']').forEach(n => n.removeAttribute(MARKER))
4958

50-
if (body.isContentEditable) return kinds.CONTENTEDITABLE
59+
if (body.isContentEditable) return CONTENTEDITABLE
5160

5261
const ce = body.querySelector(CE)
5362
if (ce) {
54-
ce.setAttribute(marker, '1')
55-
return kinds.CONTENTEDITABLE
63+
ce.setAttribute(MARKER, '1')
64+
return CONTENTEDITABLE
5665
}
5766

5867
const textareas = [...body.querySelectorAll('textarea')]
59-
const focusable = textareas.find(t => body.ownerDocument.defaultView.getComputedStyle(t).display !== 'none')
68+
const focusable = textareas.find(t => window.getComputedStyle(t).display !== 'none')
6069
const textarea = focusable || textareas[0]
6170
if (textarea) {
62-
textarea.setAttribute(marker, '1')
63-
return kinds.HIDDEN_TEXTAREA
71+
textarea.setAttribute(MARKER, '1')
72+
return HIDDEN_TEXTAREA
6473
}
6574

66-
return kinds.CONTENTEDITABLE
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
67100
}
68101

69102
function selectAllInEditable(el) {
@@ -107,24 +140,25 @@ export async function fillRichEditor(helper, el, value) {
107140
const source = el instanceof WebElement ? el : new WebElement(el, helper)
108141
const kind = await source.evaluate(detectAndMark, { marker: MARKER, kinds: EDITOR })
109142
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+
}
110146

111147
const target = await findMarked(helper)
112148
const delay = helper.options.pressKeyDelay
113149

114150
if (kind === EDITOR.IFRAME) {
115151
await target.inIframe(async body => {
116-
const innerKind = await body.evaluate(detectInsideFrame, { marker: MARKER, kinds: EDITOR })
117-
const marked = await body.$('[' + MARKER + ']')
118-
const innerTarget = marked || body
152+
const innerKind = await evaluateInFrame(helper, body, detectInsideFrame)
119153
if (innerKind === EDITOR.HIDDEN_TEXTAREA) {
120-
await innerTarget.focus()
121-
await assertFocused(innerTarget)
122-
await innerTarget.selectAllAndDelete()
123-
await innerTarget.typeText(value, { delay })
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 })
124158
} else {
125-
await innerTarget.evaluate(selectAllInEditable)
126-
await assertFocused(innerTarget)
127-
await innerTarget.typeText(value, { delay })
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 })
128162
}
129163
})
130164
} else if (kind === EDITOR.HIDDEN_TEXTAREA) {

test/helper/webapi.js

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -909,12 +909,16 @@ export function tests() {
909909
{ name: 'contenteditable editor (CKEditor 5)', page: 'ckeditor5-with-sibling', selector: '#editor', path: 'CONTENTEDITABLE' },
910910
]
911911

912+
async function outerTitleValue() {
913+
return I.executeScript(() => document.getElementById('outer-title').value)
914+
}
915+
912916
for (const tc of siblingCases) {
913917
describe(tc.name, () => {
914918
it(`fillField via ${tc.path} does not leak keystrokes to the outer focused input`, async () => {
915919
await openSiblingPage(tc.page)
916920
await I.fillField(tc.selector, 'Hello rich text world')
917-
expect(await I.grabValueFrom('#outer-title')).to.equal('')
921+
expect(await outerTitleValue()).to.equal('')
918922
await I.click('#submit')
919923
await I.waitForElement('#result', 15)
920924
expect(await I.grabTextFrom('#result')).to.include('Hello rich text world')
@@ -923,7 +927,7 @@ export function tests() {
923927
it(`fillField via ${tc.path} clears pre-populated content without touching the outer input`, async () => {
924928
await openSiblingPage(tc.page, 'PREVIOUSLY ENTERED DATA')
925929
await I.fillField(tc.selector, 'fresh replacement text')
926-
expect(await I.grabValueFrom('#outer-title')).to.equal('')
930+
expect(await outerTitleValue()).to.equal('')
927931
await I.click('#submit')
928932
await I.waitForElement('#result', 15)
929933
const submitted = await I.grabTextFrom('#result')
@@ -933,16 +937,14 @@ export function tests() {
933937
})
934938
}
935939

936-
it('throws instead of leaking when locator points at a hidden backing element', async () => {
940+
it('does not leak keystrokes to outer input when locator points at a hidden backing element', async () => {
937941
await openSiblingPage('codemirror5-with-sibling')
938-
let caught = null
939942
try {
940943
await I.fillField('#editor-inner', 'should-not-leak')
941944
} catch (e) {
942-
caught = e
945+
// Throwing is fine — the safety invariant is that the outer input never receives keystrokes.
943946
}
944-
expect(caught, 'fillField on a display:none backing textarea must throw').to.not.equal(null)
945-
expect(await I.grabValueFrom('#outer-title')).to.equal('')
947+
expect(await outerTitleValue()).to.equal('')
946948
})
947949
})
948950
})

0 commit comments

Comments
 (0)