"""Playwright implementation of `actions.Driver` — the harness side of the wrapper. Everything here is trusted harness code: the JS it evaluates only READS the page (boxes, styles, labels, hit-tests); state changes happen only through real mouse and keyboard input (`page.mouse` / `page.keyboard`) issued by `actions.Act`. """ from __future__ import annotations from miniwebgen import browser as B from miniwebgen.actions import Driver _FIND = r""" ({css, text, tag, limit}) => { const norm = s => (s || '').replace(/\s+/g, ' ').trim().toLowerCase(); let els; try { els = [...document.querySelectorAll(css || 'a,button,input,select,textarea,label,summary,option,[role],[onclick],[tabindex],h1,h2,h3,h4,h5,li,span,div,p,td,th,img')]; } catch (e) { return 'bad css selector: ' + e.message; } if (tag) els = els.filter(e => e.tagName.toLowerCase() === String(tag).toLowerCase()); const vis = el => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && s.display !== 'none' && parseFloat(s.opacity || '1') > 0.05; }; if (text) { const t = norm(text); const own = e => norm([e.innerText, e.value, e.getAttribute('aria-label'), e.getAttribute('placeholder'), e.getAttribute('title'), e.getAttribute('alt'), e.labels && e.labels[0] ? e.labels[0].innerText : ''].filter(Boolean).join(' ')); let m = els.filter(e => own(e).includes(t)); m = m.filter(e => !m.some(o => o !== e && e.contains(o))); // deepest matches const score = e => (own(e) === t ? 0 : 1); m.sort((a, b) => score(a) - score(b) || own(a).length - own(b).length); els = m; } els = els.filter(vis).concat(els.filter(e => !vis(e))); return els.slice(0, limit || 20); } """ # the text a person can read in the viewport now: rendered text nodes whose boxes intersect it, # plus the values shown in form fields (a revealed code, a computed result in an /) _VISIBLE_TEXT = r""" () => { const W = innerWidth, H = innerHeight, out = []; const inView = r => r.width > 0 && r.height > 0 && r.bottom > 0 && r.right > 0 && r.top < H && r.left < W; const shown = el => { for (let e = el; e && e !== document.body; e = e.parentElement) { const s = getComputedStyle(e); if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity || '1') < 0.05) return false; } return true; }; const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); const range = document.createRange(); let n, budget = 6000; while ((n = walk.nextNode()) && budget-- > 0) { const t = n.textContent.replace(/\s+/g, ' ').trim(); if (!t || !n.parentElement || ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(n.parentElement.tagName)) continue; range.selectNodeContents(n); if ([...range.getClientRects()].some(inView) && shown(n.parentElement)) out.push(t); } document.querySelectorAll('input:not([type=hidden]):not([type=password]),textarea,output,select').forEach(el => { const v = el.tagName === 'SELECT' ? (el.selectedOptions[0] || {}).text : el.value; if (v && inView(el.getBoundingClientRect()) && shown(el)) out.push(String(v)); }); return out.join('\n').slice(0, 60000); } """ _INFO = r""" (el) => { if (!el || !el.isConnected) return null; const txt = s => (s || '').replace(/\s+/g, ' ').trim(); const r = el.getBoundingClientRect(); const s = getComputedStyle(el); const visible = r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && s.display !== 'none' && parseFloat(s.opacity || '1') > 0.05; let label = ''; if (el.labels && el.labels.length) { const c = el.labels[0].cloneNode(true); c.querySelectorAll('select, option, input, textarea, button').forEach(n => n.remove()); label = txt(c.textContent); } if (!label) label = txt(el.getAttribute('aria-label') || el.getAttribute('title') || ''); const tag = el.tagName.toLowerCase(); const out = {tag, text: txt(el.innerText || el.value || el.getAttribute('aria-label') || el.getAttribute('alt') || '').slice(0, 200), label, name: el.getAttribute('name') || '', id: el.id || '', type: el.getAttribute('type') || '', role: el.getAttribute('role') || '', placeholder: el.getAttribute('placeholder') || '', href: el.getAttribute('href') || '', visible, checked: !!el.checked, disabled: !!el.disabled, value: ('value' in el && typeof el.value === 'string') ? el.value.slice(0, 200) : '', box: [r.x, r.y, r.width, r.height]}; if (tag === 'select') { out.options = [...el.options].map((o, i) => ({index: i, value: o.value, text: txt(o.text), selected: o.selected})); out.selected_text = el.selectedIndex >= 0 ? txt(el.options[el.selectedIndex].text) : ''; out.text = out.selected_text; } return out; } """ _HIT = r""" ([el, x, y]) => { const t = document.elementFromPoint(x, y); return !!t && (t === el || el.contains(t) || (t.tagName === 'LABEL' && t.control === el)); } """ _SCROLL_BOX = r""" (el) => { let n = el.parentElement; while (n && n !== document.body && n !== document.documentElement) { const s = getComputedStyle(n); if (/(auto|scroll|overlay)/.test(s.overflowY) && n.scrollHeight > n.clientHeight + 4) { const r = n.getBoundingClientRect(); return [r.x, r.y, r.width, r.height]; } n = n.parentElement; } return null; } """ _OUTLINE = r""" (limit) => { const txt = s => (s || '').replace(/\s+/g, ' ').trim(); const out = []; const H = innerHeight; const vis = el => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && s.display !== 'none'; }; for (const el of document.querySelectorAll('h1,h2,h3,a,button,input,select,textarea,label,[role=button],[role=tab]')) { if (!vis(el)) continue; const r = el.getBoundingClientRect(); const where = r.bottom < 0 ? 'above' : (r.top > H ? 'below' : 'on-screen'); let t = el.tagName.toLowerCase(); let desc = txt(el.innerText || el.value || el.getAttribute('aria-label') || el.getAttribute('placeholder') || '').slice(0, 60); if (t === 'select') desc = `name=${el.name || el.id || ''} selected=${txt(el.options[el.selectedIndex] ? el.options[el.selectedIndex].text : '')} (${el.options.length} options)`; if (t === 'input') desc = `type=${el.type} name=${el.name || el.id || ''} value=${txt(el.value).slice(0, 30)} ${desc}`; out.push(`${t} [${where} y=${Math.round(r.y)}] ${desc}`); if (out.length >= limit) break; } return out.join('\n'); } """ _FOCUSED = "() => { const a = document.activeElement; return (a && a !== document.body) ? a : null; }" # A unique CSS path for an element (id, then name, then an nth-of-type chain). Read-only. _CSS_PATH = r""" const cssOf = el => { const uniq = css => { try { return document.querySelectorAll(css).length === 1; } catch (e) { return false; } }; const tag = el.tagName.toLowerCase(); if (el.id && uniq('#' + CSS.escape(el.id))) return '#' + CSS.escape(el.id); const nm = el.getAttribute('name'); if (nm) { const c = `${tag}[name="${nm}"]`; if (uniq(c)) return c; } const parts = []; let n = el; while (n && n.nodeType === 1 && n !== document.body) { let p = n.tagName.toLowerCase(); const par = n.parentElement; if (par) { const sib = [...par.children].filter(c => c.tagName === n.tagName); if (sib.length > 1) p += `:nth-of-type(${sib.indexOf(n) + 1})`; } parts.unshift(p); if (n.id) { parts[0] = '#' + CSS.escape(n.id); break; } n = par; } return parts.join(' > '); }; const txt = s => (s || '').replace(/\s+/g, ' ').trim(); """ _DESCRIBE = "(el) => {" + _CSS_PATH + r""" if (!el || !el.isConnected) return null; const tag = el.tagName.toLowerCase(); let name = ''; const lb = el.getAttribute('aria-labelledby'); if (lb) name = lb.split(/\s+/).map(id => { const n = document.getElementById(id); return n ? txt(n.innerText) : ''; }).join(' '); if (!name) name = txt(el.getAttribute('aria-label') || ''); if (!name && el.labels && el.labels.length) { const c = el.labels[0].cloneNode(true); c.querySelectorAll('select, option, input, textarea, button').forEach(n => n.remove()); name = txt(c.textContent); } if (!name) name = txt(el.getAttribute('alt') || el.getAttribute('title') || el.getAttribute('placeholder') || ''); if (!name && tag !== 'select') name = txt(el.innerText || el.value || '').slice(0, 120); const t = (el.getAttribute('type') || '').toLowerCase(); const implicit = {a: el.hasAttribute('href') ? 'link' : '', button: 'button', select: el.multiple ? 'listbox' : 'combobox', textarea: 'textbox', img: 'img', h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', option: 'option', input: ({checkbox: 'checkbox', radio: 'radio', range: 'slider', submit: 'button', button: 'button'}[t] || 'textbox')}[tag] || ''; // visual label when there is no programmatic one: a short heading/label-ish sibling just before it let ctx = ''; for (let n = el; n && n !== document.body && !ctx; n = n.parentElement) { const p = n.previousElementSibling; if (p && ['LABEL','SPAN','STRONG','B','P','H2','H3','H4','H5','H6','SMALL','DIV'].includes(p.tagName) && !p.querySelector('a,button,input,select,textarea') && txt(p.innerText) && txt(p.innerText).length < 40) ctx = txt(p.innerText); if (n.parentElement && n.parentElement.children.length > 3) break; } return {css_path: cssOf(el), accessible_name: name, implicit_role: implicit, context_label: ctx}; }""" _ELEMENT_AT = "([x, y, el]) => {" + _CSS_PATH + r""" const t = document.elementFromPoint(x, y); if (!t) return null; const r = t.getBoundingClientRect(); return {tag: t.tagName.toLowerCase(), id: t.id || '', text: txt(t.innerText || t.value || '').slice(0, 80), css_path: cssOf(t), box: [r.x, r.y, r.width, r.height].map(v => Math.round(v * 10) / 10), is_target: !!el && t === el, inside_target: !!el && el.contains(t)}; }""" _SCROLL_STATE = "(el) => {" + _CSS_PATH + r""" const out = {window: [Math.round(window.scrollX), Math.round(window.scrollY)], container: null}; let n = el ? el.parentElement : null; while (n && n !== document.body && n !== document.documentElement) { const s = getComputedStyle(n); if (/(auto|scroll|overlay)/.test(s.overflowY) && n.scrollHeight > n.clientHeight + 4) { out.container = {css_path: cssOf(n), scroll_top: Math.round(n.scrollTop), scroll_left: Math.round(n.scrollLeft)}; break; } n = n.parentElement; } return out; }""" class PlaywrightDriver(Driver): def __init__(self, page, viewport): self.page = page self.pages = [page] # browser tabs, in the order they were opened self._vp = tuple(viewport) self.dialogs, self.downloads = [], [] self._watch(page) def _watch(self, page): # native confirm()/alert(): accepted, as a person clicking OK would (Playwright's default # dismisses them, which cancels deletes); the messages are kept for the attempt record page.on("dialog", lambda d: (self.dialogs.append({"type": d.type, "message": d.message[:200]}), d.accept())) page.on("download", lambda dl: self.downloads.append({"filename": dl.suggested_filename, "url": dl.url})) def new_tab(self, path): """Open `path` (same site origin) in a new tab and make it the current one.""" from urllib.parse import urlsplit u = urlsplit(self.page.url) tab = self.page.context.new_page() self._watch(tab) tab.goto(f"{u.scheme}://{u.netloc}{path}", wait_until="load", timeout=30000) self.pages.append(tab) self.page = tab tab.bring_to_front() def switch_tab(self, index): self.page = self.pages[index] self.page.bring_to_front() def choose_file(self, x, y, path, input_css=None): """Click at (x, y) — a file input or the control that opens one — and pick `path` in the file chooser (what a person does in the OS dialog). Headless Chromium does not open a chooser for some scripted drop zones; then the file goes to the control's own input, as the dialog would have done.""" try: with self.page.expect_file_chooser(timeout=4000) as fc: self.page.mouse.click(x, y) fc.value.set_files(path) except Exception: if not input_css: raise self.page.set_input_files(input_css, path, timeout=5000) def viewport(self): return self._vp def query(self, css=None, text=None, tag=None, limit=20): from miniwebgen.actions import ActionError h = self.page.evaluate_handle(_FIND, {"css": css, "text": text, "tag": tag, "limit": int(limit or 20)}) try: err = h.json_value() if not h.evaluate("x => Array.isArray(x)") else None except Exception: err = None if isinstance(err, str): raise ActionError(err) props = h.get_properties() out = [] for k in sorted(props, key=lambda k: int(k) if str(k).isdigit() else 1 << 30): el = props[k].as_element() if el is None: continue info = self.info(el) if info is not None: out.append((el, info)) return out def info(self, key): try: return key.evaluate(_INFO) except Exception: return None def hit(self, key, x, y): try: return bool(self.page.evaluate(_HIT, [key, x, y])) except Exception: return False def scroll_box(self, key): try: return key.evaluate(_SCROLL_BOX) except Exception: return None def focused(self): h = self.page.evaluate_handle(_FOCUSED) el = h.as_element() if el is None: return None info = self.info(el) return (el, info) if info else None def describe(self, key): return key.evaluate(_DESCRIBE) def element_at(self, x, y, key=None): return self.page.evaluate(_ELEMENT_AT, [x, y, key]) def scroll_state(self, key=None): return key.evaluate(_SCROLL_STATE) if key is not None else self.page.evaluate(_SCROLL_STATE, None) def screenshot(self): return self.page.screenshot(type="png", caret="initial") def click(self, x, y, button="left", clicks=1): self.page.mouse.click(x, y, button=button, click_count=clicks) def move(self, x, y, steps=1): self.page.mouse.move(x, y, steps=steps) def down(self): self.page.mouse.down() def up(self): self.page.mouse.up() def wheel(self, x, y, dx, dy): self.page.mouse.move(x, y) self.page.mouse.wheel(dx, dy) def press(self, key): self.page.keyboard.press(key) def type(self, text): self.page.keyboard.type(text, delay=35) def settle(self): B.settle(self.page) def url(self): return self.page.url def title(self): try: return self.page.title() except Exception: return "" def outline(self, limit=80): try: return self.page.evaluate(_OUTLINE, int(limit)) except Exception: return "" def open_url(self, path): from urllib.parse import urlsplit u = urlsplit(self.page.url) self.page.goto(f"{u.scheme}://{u.netloc}{path}", wait_until="load", timeout=30000) def visible_text(self): try: return self.page.evaluate(_VISIBLE_TEXT) except Exception: return ""