| 1 | package cdp |
| 2 | |
| 3 | // bootstrapJS installs the executor's isolated-world helper. It runs once per |
| 4 | // document in a world the page cannot reach, so refs, the take-over counter, |
| 5 | // and the agent-input window are out of reach of page script. Every later |
| 6 | // evaluate is one call into __rx. |
| 7 | const bootstrapJS = ` |
| 8 | (() => { |
| 9 | const W = {refs: new Map(), next: 1, userSeq: 0, agentUntil: 0}; |
| 10 | globalThis.__rx = W; |
| 11 | const mark = (e) => { if (e.isTrusted && Date.now() > W.agentUntil) W.userSeq++; }; |
| 12 | for (const t of ['pointerdown', 'keydown', 'wheel']) { |
| 13 | document.addEventListener(t, mark, {capture: true, passive: true}); |
| 14 | } |
| 15 | const clip = (s, n) => s.length > n ? s.slice(0, n) + '…' : s; |
| 16 | const flat = (s) => (s || '').replace(/\s+/g, ' ').trim(); |
| 17 | const named = (s) => s ? ' ' + JSON.stringify(s) : ''; |
| 18 | const ACTIONABLE = 'a[href],button,input,select,textarea,summary,[role],[tabindex],[contenteditable]'; |
| 19 | |
| 20 | W.window = (ms) => { W.agentUntil = Date.now() + ms; return W.userSeq; }; |
| 21 | W.state = () => ({userSeq: W.userSeq, url: location.href, title: document.title, ready: document.readyState}); |
| 22 | W.element = (ref) => { const el = W.refs.get(ref); return (el && el.isConnected) ? el : null; }; |
| 23 | |
| 24 | W.rect = (ref) => { |
| 25 | const el = W.element(ref); |
| 26 | if (!el) return null; |
| 27 | try { el.scrollIntoView({block: 'center', inline: 'center'}); } catch (e) {} |
| 28 | const r = el.getBoundingClientRect(); |
| 29 | const tag = el.tagName.toLowerCase(); |
| 30 | const type = flat((el.getAttribute('type') || '')).toLowerCase(); |
| 31 | if (r.width <= 0 && r.height <= 0) return {hidden: true, tag: tag, type: type}; |
| 32 | return {x: r.left + r.width / 2, y: r.top + r.height / 2, width: r.width, height: r.height, tag: tag, type: type}; |
| 33 | }; |
| 34 | |
| 35 | W.focus = (ref) => { |
| 36 | const el = W.element(ref); |
| 37 | if (!el) return {ok: false, reason: 'the ref is not on this page any more'}; |
| 38 | try { el.focus({preventScroll: false}); } catch (e) {} |
| 39 | const active = document.activeElement; |
| 40 | return {ok: active === el || el.contains(active), reason: 'the element refused focus'}; |
| 41 | }; |
| 42 | |
| 43 | W.select = (ref, wanted) => { |
| 44 | const el = W.element(ref); |
| 45 | if (!el) return {ok: false, reason: 'the ref is not on this page any more'}; |
| 46 | if (el.tagName !== 'SELECT') return {ok: false, reason: 'element ' + el.tagName.toLowerCase() + ' is not a select'}; |
| 47 | const opts = Array.from(el.options); |
| 48 | const chosen = []; |
| 49 | for (const want of wanted) { |
| 50 | const hit = opts.find((o) => o.value === want) || opts.find((o) => flat(o.label || o.textContent) === want); |
| 51 | if (!hit) return {ok: false, reason: 'no option has the value or label ' + JSON.stringify(want)}; |
| 52 | chosen.push(hit); |
| 53 | } |
| 54 | if (chosen.length > 1 && !el.multiple) return {ok: false, reason: 'this select accepts one option'}; |
| 55 | for (const o of opts) o.selected = false; |
| 56 | for (const o of chosen) o.selected = true; |
| 57 | el.dispatchEvent(new Event('input', {bubbles: true})); |
| 58 | el.dispatchEvent(new Event('change', {bubbles: true})); |
| 59 | return {ok: true, selected: chosen.map((o) => o.value)}; |
| 60 | }; |
| 61 | |
| 62 | const SKIP = new Set(['script', 'style', 'noscript', 'template', 'head', 'meta', 'link', 'title', 'svg', 'path', 'br']); |
| 63 | const ROLES = { |
| 64 | a: 'link', button: 'button', select: 'combobox', textarea: 'textbox', summary: 'disclosure', |
| 65 | h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading', |
| 66 | img: 'image', table: 'table', th: 'columnheader', td: 'cell', li: 'listitem', form: 'form', |
| 67 | nav: 'navigation', main: 'main', header: 'banner', footer: 'contentinfo', dialog: 'dialog', |
| 68 | option: 'option', label: 'label', iframe: 'iframe', |
| 69 | }; |
| 70 | const INPUT_ROLES = { |
| 71 | checkbox: 'checkbox', radio: 'radio', submit: 'button', button: 'button', reset: 'button', |
| 72 | file: 'file-input', range: 'slider', color: 'color-picker', hidden: '', |
| 73 | }; |
| 74 | |
| 75 | const typeOf = (el) => flat(el.getAttribute('type') || '').toLowerCase(); |
| 76 | |
| 77 | const roleOf = (el) => { |
| 78 | const explicit = flat(el.getAttribute('role')); |
| 79 | if (explicit) return explicit.split(' ')[0]; |
| 80 | const tag = el.tagName.toLowerCase(); |
| 81 | if (tag === 'input') { |
| 82 | const t = typeOf(el); |
| 83 | return Object.prototype.hasOwnProperty.call(INPUT_ROLES, t) ? INPUT_ROLES[t] : 'textbox'; |
| 84 | } |
| 85 | return ROLES[tag] || ''; |
| 86 | }; |
| 87 | |
| 88 | // nameOf follows the accessible-name order that matters here: an explicit |
| 89 | // label beats the placeholder a user only sees while the field is empty, and |
| 90 | // an element's own text names it only when it has nothing else to say, so a |
| 91 | // container is not named after everything inside it. |
| 92 | const nameOf = (el, allowText) => { |
| 93 | const aria = flat(el.getAttribute('aria-label')); |
| 94 | if (aria) return clip(aria, 160); |
| 95 | if (el.labels && el.labels.length) { |
| 96 | const label = flat(el.labels[0].textContent); |
| 97 | if (label) return clip(label, 160); |
| 98 | } |
| 99 | const attr = flat(el.getAttribute('alt') || el.getAttribute('title') || el.getAttribute('placeholder')); |
| 100 | if (attr) return clip(attr, 160); |
| 101 | if (!allowText) return ''; |
| 102 | return clip(flat(el.innerText || el.textContent), 160); |
| 103 | }; |
| 104 | |
| 105 | const interactive = (el) => { |
| 106 | const tag = el.tagName.toLowerCase(); |
| 107 | if (tag === 'a') return el.hasAttribute('href'); |
| 108 | if (tag === 'button' || tag === 'select' || tag === 'textarea' || tag === 'summary') return true; |
| 109 | if (tag === 'input') return typeOf(el) !== 'hidden'; |
| 110 | if (el.isContentEditable) return true; |
| 111 | if (el.hasAttribute('tabindex') || el.hasAttribute('onclick')) return true; |
| 112 | const role = flat(el.getAttribute('role')); |
| 113 | return ['button', 'link', 'checkbox', 'radio', 'tab', 'menuitem', 'switch', 'option', 'textbox', 'searchbox'].includes(role); |
| 114 | }; |
| 115 | |
| 116 | const visible = (el) => { |
| 117 | if (typeof el.checkVisibility === 'function') return el.checkVisibility({checkVisibilityCSS: true}); |
| 118 | return el.getClientRects().length > 0; |
| 119 | }; |
| 120 | |
| 121 | const attrsOf = (el) => { |
| 122 | const out = []; |
| 123 | if (el.disabled) out.push('disabled'); |
| 124 | if (el.checked) out.push('checked'); |
| 125 | if (el.required) out.push('required'); |
| 126 | if (el.getAttribute('aria-expanded') === 'true') out.push('expanded'); |
| 127 | if (el.tagName === 'A' && el.getAttribute('href')) out.push('href=' + clip(el.getAttribute('href'), 120)); |
| 128 | if ('value' in el && typeOf(el) !== 'password' && flat(el.value)) out.push('value=' + JSON.stringify(clip(flat(el.value), 80))); |
| 129 | if (typeOf(el) === 'password' && el.value) out.push('value=(hidden)'); |
| 130 | return out.length ? ' [' + out.join(' ') + ']' : ''; |
| 131 | }; |
| 132 | |
| 133 | W.snapshot = (selector, budget) => { |
| 134 | W.refs = new Map(); |
| 135 | W.next = 1; |
| 136 | let root = document.body || document.documentElement; |
| 137 | if (selector) { |
| 138 | root = document.querySelector(selector); |
| 139 | if (!root) return {error: 'no element matches the selector'}; |
| 140 | } |
| 141 | const lines = []; |
| 142 | let left = budget; |
| 143 | const pad = (d) => ' '.repeat(Math.min(d, 20)); |
| 144 | const walk = (el, depth) => { |
| 145 | if (left <= 0 || depth > 40) return; |
| 146 | for (const node of el.childNodes) { |
| 147 | if (left <= 0) return; |
| 148 | if (node.nodeType === 3) { |
| 149 | const text = flat(node.textContent); |
| 150 | if (text) { left--; lines.push(pad(depth) + '- text: ' + clip(text, 200)); } |
| 151 | continue; |
| 152 | } |
| 153 | if (node.nodeType !== 1 || SKIP.has(node.tagName.toLowerCase()) || !visible(node)) continue; |
| 154 | const role = roleOf(node); |
| 155 | const leaf = node.children.length === 0; |
| 156 | if (interactive(node)) { |
| 157 | const ref = 'e' + (W.next++); |
| 158 | W.refs.set(ref, node); |
| 159 | left--; |
| 160 | lines.push(pad(depth) + '- ' + (role || 'control') + named(nameOf(node, true)) + attrsOf(node) + ' [ref=' + ref + ']'); |
| 161 | // A control's own text is already its name; walk into it only when it |
| 162 | // wraps another control the model could act on. |
| 163 | if (!leaf && node.tagName !== 'SELECT' && node.tagName !== 'TEXTAREA' && node.querySelector(ACTIONABLE)) walk(node, depth + 1); |
| 164 | continue; |
| 165 | } |
| 166 | if (role) { |
| 167 | left--; |
| 168 | lines.push(pad(depth) + '- ' + role + named(nameOf(node, leaf))); |
| 169 | if (!leaf) walk(node, depth + 1); |
| 170 | continue; |
| 171 | } |
| 172 | walk(node, depth); |
| 173 | } |
| 174 | }; |
| 175 | walk(root, 0); |
| 176 | return { |
| 177 | url: location.href, title: document.title, tree: lines.join('\n'), |
| 178 | refs: W.next - 1, userSeq: W.userSeq, truncated: left <= 0, |
| 179 | }; |
| 180 | }; |
| 181 | })() |
| 182 | ` |
| 183 |