|
| 1 | +import WebElement from '../../element/WebElement.js' |
| 2 | + |
| 3 | +const MARKER = 'data-codeceptjs-combobox-target' |
| 4 | +const PREVIOUS = 'data-codeceptjs-combobox-previous' |
| 5 | +const OPEN_TIMEOUT = 1000 |
| 6 | +const POLL_INTERVAL = 50 |
| 7 | + |
| 8 | +function locateInput(el, opts) { |
| 9 | + const marker = opts.marker |
| 10 | + const previous = opts.previous |
| 11 | + const doc = el.ownerDocument |
| 12 | + const NON_TEXT = ['checkbox', 'radio', 'button', 'submit', 'reset', 'image', 'file', 'range', 'color', 'hidden'] |
| 13 | + const SELECTOR = 'input, textarea, [contenteditable="true"], [contenteditable=""]' |
| 14 | + |
| 15 | + function isEditable(node) { |
| 16 | + if (!node || node.nodeType !== 1) return false |
| 17 | + if (node.isContentEditable) return true |
| 18 | + const t = node.tagName |
| 19 | + if (t !== 'INPUT' && t !== 'TEXTAREA') return false |
| 20 | + if (node.disabled || node.readOnly) return false |
| 21 | + return t !== 'INPUT' || NON_TEXT.indexOf((node.type || 'text').toLowerCase()) === -1 |
| 22 | + } |
| 23 | + |
| 24 | + function isVisible(node) { |
| 25 | + return node.getClientRects().length > 0 |
| 26 | + } |
| 27 | + |
| 28 | + function mark(node) { |
| 29 | + doc.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker)) |
| 30 | + node.setAttribute(marker, '1') |
| 31 | + let name = node.tagName.toLowerCase() |
| 32 | + if (node.id) name += '#' + node.id |
| 33 | + if (node.placeholder) name += '[placeholder="' + node.placeholder + '"]' |
| 34 | + return name |
| 35 | + } |
| 36 | + |
| 37 | + function pick(root) { |
| 38 | + const active = doc.activeElement |
| 39 | + if (active && active !== el && !active.hasAttribute(previous) && root.contains(active) && isEditable(active)) return active |
| 40 | + const nodes = root.querySelectorAll(SELECTOR) |
| 41 | + for (let i = 0; i < nodes.length; i++) { |
| 42 | + if (isEditable(nodes[i]) && isVisible(nodes[i])) return nodes[i] |
| 43 | + } |
| 44 | + return null |
| 45 | + } |
| 46 | + |
| 47 | + if (opts.tagActive) { |
| 48 | + doc.querySelectorAll('[' + previous + ']').forEach(n => n.removeAttribute(previous)) |
| 49 | + if (doc.activeElement && doc.activeElement.nodeType === 1) doc.activeElement.setAttribute(previous, '1') |
| 50 | + } |
| 51 | + |
| 52 | + if (isEditable(el)) return mark(el) |
| 53 | + |
| 54 | + const inner = pick(el) |
| 55 | + if (inner) return mark(inner) |
| 56 | + |
| 57 | + const roots = [] |
| 58 | + const controlled = el.getAttribute('aria-controls') || el.getAttribute('aria-owns') |
| 59 | + if (controlled && doc.getElementById(controlled)) roots.push(doc.getElementById(controlled)) |
| 60 | + doc.querySelectorAll('[role="dialog"], [role="listbox"]').forEach(n => { |
| 61 | + if (isVisible(n)) roots.push(n) |
| 62 | + }) |
| 63 | + |
| 64 | + for (let i = 0; i < roots.length; i++) { |
| 65 | + const found = pick(roots[i]) |
| 66 | + if (found) return mark(found) |
| 67 | + } |
| 68 | + |
| 69 | + if (!opts.allowActive) return null |
| 70 | + |
| 71 | + let active = doc.activeElement |
| 72 | + while (active && active.shadowRoot && active.shadowRoot.activeElement) active = active.shadowRoot.activeElement |
| 73 | + if (active && active !== el && !active.hasAttribute(previous) && isEditable(active) && isVisible(active)) return mark(active) |
| 74 | + |
| 75 | + return null |
| 76 | +} |
| 77 | + |
| 78 | +function readValue(el) { |
| 79 | + return el.value === undefined ? el.textContent : el.value |
| 80 | +} |
| 81 | + |
| 82 | +function isActive(el) { |
| 83 | + return el.ownerDocument.activeElement === el |
| 84 | +} |
| 85 | + |
| 86 | +function unmarkAll(markers) { |
| 87 | + markers.forEach(marker => document.querySelectorAll('[' + marker + ']').forEach(n => n.removeAttribute(marker))) |
| 88 | +} |
| 89 | + |
| 90 | +async function findMarked(helper) { |
| 91 | + const root = helper.page || helper.browser |
| 92 | + const raw = await root.$('[' + MARKER + ']') |
| 93 | + return new WebElement(raw, helper) |
| 94 | +} |
| 95 | + |
| 96 | +async function clearMarker(helper) { |
| 97 | + if (helper.page) return helper.page.evaluate(unmarkAll, [MARKER, PREVIOUS]) |
| 98 | + return helper.executeScript(unmarkAll, [MARKER, PREVIOUS]) |
| 99 | +} |
| 100 | + |
| 101 | +export async function fillComboBox(helper, el, value) { |
| 102 | + const trigger = el instanceof WebElement ? el : new WebElement(el, helper) |
| 103 | + const options = { marker: MARKER, previous: PREVIOUS } |
| 104 | + let found = await trigger.evaluate(locateInput, { ...options, tagActive: true }) |
| 105 | + |
| 106 | + if (!found) { |
| 107 | + if ((await trigger.getAttribute('aria-expanded')) !== 'true') { |
| 108 | + helper.debugSection('ComboBox', 'Expanding combobox') |
| 109 | + await trigger.click() |
| 110 | + } |
| 111 | + const deadline = Date.now() + OPEN_TIMEOUT |
| 112 | + while (!found && Date.now() < deadline) { |
| 113 | + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL)) |
| 114 | + found = await trigger.evaluate(locateInput, { ...options, allowActive: true }) |
| 115 | + } |
| 116 | + } |
| 117 | + |
| 118 | + if (!found) { |
| 119 | + await clearMarker(helper) |
| 120 | + throw new Error('fillField: combobox exposes no text input to type into. Use I.selectOption() to pick one of its options.') |
| 121 | + } |
| 122 | + |
| 123 | + const target = await findMarked(helper) |
| 124 | + helper.debugSection('ComboBox', `Typing into ${found}`) |
| 125 | + |
| 126 | + await target.focus() |
| 127 | + if (!(await target.evaluate(isActive))) await target.click() |
| 128 | + if (!(await target.evaluate(isActive))) { |
| 129 | + throw new Error(`fillField: combobox input ${found} did not accept focus.`) |
| 130 | + } |
| 131 | + |
| 132 | + if (await target.evaluate(readValue)) await target.selectAllAndDelete() |
| 133 | + await target.typeText(value, { delay: helper.options.pressKeyDelay }) |
| 134 | + |
| 135 | + await clearMarker(helper) |
| 136 | +} |
0 commit comments