"""Generic control primitives (ws-level, no tab knowledge). Radio/switch list + set with verify-then-fallback: synthetic click, verify state flipped, else trusted real click, verify again. Tab modules build their flows on these; toggles.py adds addressing. """ import time from approvals import cdp_evaluate from hatch_menu.mouse import real_click JS_LIST_RADIOS = """(() => { const d = document.querySelector('[role="dialog"]'); if (!d) return null; return Array.from(d.querySelectorAll('input[type="radio"]')).map(r => { let head = '', el = r.parentElement, depth = 0; while (el && el !== d && depth < 6) { const h = el.querySelector('h1,h2,h3,h4'); if (h && (h.innerText || '').trim()) { head = h.innerText.trim().slice(0, 60); break; } el = el.parentElement; depth += 1; } const lab = r.closest('label'); return {value: r.value, checked: !!r.checked, heading: head, aria: r.getAttribute('aria-label') || '', label: lab ? (lab.innerText || '').trim().slice(0, 80) : ''}; }); })()""" JS_CLICK_RADIO = """((heading, value) => { const d = document.querySelector('[role="dialog"]'); if (!d) return 'NO_DIALOG'; const radios = Array.from(d.querySelectorAll('input[type="radio"]')); const headOf = (r) => { let el = r.parentElement, depth = 0; while (el && el !== d && depth < 6) { const h = el.querySelector('h1,h2,h3,h4'); if (h && (h.innerText || '').trim()) return h.innerText.trim().toLowerCase(); el = el.parentElement; depth += 1; } return ''; }; const t = radios.find(r => headOf(r) === heading.toLowerCase() && r.value === value); if (!t) return 'NO_MATCH'; t.click(); return 'CLICKED'; })('%s', '%s')""" JS_RADIO_RECT = """((heading, value) => { const d = document.querySelector('[role="dialog"]'); if (!d) return null; const radios = Array.from(d.querySelectorAll('input[type="radio"]')); const headOf = (r) => { let el = r.parentElement, depth = 0; while (el && el !== d && depth < 6) { const h = el.querySelector('h1,h2,h3,h4'); if (h && (h.innerText || '').trim()) return h.innerText.trim().toLowerCase(); el = el.parentElement; depth += 1; } return ''; }; const t = radios.find(r => headOf(r) === heading.toLowerCase() && r.value === value); if (!t) return null; const r = t.getBoundingClientRect(); return {x: r.x + r.width / 2, y: r.y + r.height / 2}; })('%s', '%s')""" JS_CLICK_RADIO_ARIA = """((name) => { const d = document.querySelector('[role="dialog"]'); if (!d) return 'NO_DIALOG'; const n = name.toLowerCase(); const t = Array.from(d.querySelectorAll('input[type="radio"]')) .find(r => (r.getAttribute('aria-label') || '').toLowerCase() === n || (r.value || '').toLowerCase() === n); if (!t) return 'NO_MATCH'; t.click(); return 'CLICKED'; })('%s')""" JS_RADIO_ARIA_RECT = """((name) => { const d = document.querySelector('[role="dialog"]'); if (!d) return null; const n = name.toLowerCase(); const t = Array.from(d.querySelectorAll('input[type="radio"]')) .find(r => (r.getAttribute('aria-label') || '').toLowerCase() === n || (r.value || '').toLowerCase() === n); if (!t) return null; const r = t.getBoundingClientRect(); return {x: r.x + r.width / 2, y: r.y + r.height / 2}; })('%s')""" JS_LIST_SWITCHES = """(() => { const d = document.querySelector('[role="dialog"]'); if (!d) return null; return Array.from(d.querySelectorAll('[role="switch"]')).map(s => { let el = s.parentElement, label = '', depth = 0; while (el && el !== d && depth < 6) { const t = (el.innerText || '').trim().replace(/\\s+/g, ' '); if (t && t.length < 250) { label = t; break; } el = el.parentElement; depth += 1; } return {label: label.slice(0, 200), aria: s.getAttribute('aria-label') || '', checked: s.getAttribute('aria-checked') === 'true'}; }); })()""" JS_CLICK_SWITCH = """((label) => { const d = document.querySelector('[role="dialog"]'); if (!d) return 'NO_DIALOG'; const rowOf = (s) => { let el = s.parentElement, depth = 0; while (el && el !== d && depth < 6) { const t = (el.innerText || '').trim(); if (t && t.length < 250) return t.toLowerCase(); el = el.parentElement; depth += 1; } return ''; }; const hits = Array.from(d.querySelectorAll('[role="switch"]')) .filter(s => rowOf(s).includes(label.toLowerCase()) || (s.getAttribute('aria-label') || '').toLowerCase() .includes(label.toLowerCase())); if (!hits.length) return 'NO_MATCH'; if (hits.length > 1) return 'AMBIGUOUS'; hits[0].click(); return 'CLICKED'; })('%s')""" JS_SWITCH_RECT = """((label) => { const d = document.querySelector('[role="dialog"]'); if (!d) return null; const rowOf = (s) => { let el = s.parentElement, depth = 0; while (el && el !== d && depth < 6) { const t = (el.innerText || '').trim(); if (t && t.length < 250) return t.toLowerCase(); el = el.parentElement; depth += 1; } return ''; }; const hits = Array.from(d.querySelectorAll('[role="switch"]')) .filter(s => rowOf(s).includes(label.toLowerCase()) || (s.getAttribute('aria-label') || '').toLowerCase() .includes(label.toLowerCase())); if (hits.length !== 1) return null; const r = hits[0].getBoundingClientRect(); return {x: r.x + r.width / 2, y: r.y + r.height / 2}; })('%s')""" def _eval(ws, js, timeout=8.0): try: return cdp_evaluate(ws, js, timeout=timeout) except Exception: return None VERIFY_TRIES = 10 VERIFY_PAUSE = 1.5 def _poll(check, tries=VERIFY_TRIES, pause=VERIFY_PAUSE): """Poll a state check until true. Fast exit; tolerates slow commits.""" for _ in range(tries): try: if check(): return True except Exception: pass time.sleep(pause) return False def list_radios(ws): """All dialog radios with heading/label/value/checked (or []).""" rows = _eval(ws, JS_LIST_RADIOS) return rows if isinstance(rows, list) else [] def list_switches(ws): """All dialog switches with row label + checked (or []).""" rows = _eval(ws, JS_LIST_SWITCHES) return rows if isinstance(rows, list) else [] def radio_state(ws, heading, value): """Checked state of one heading-grouped radio, None if absent.""" for r in list_radios(ws): if (r.get("heading") or "").lower() == heading.lower() \ and r.get("value") == value: return bool(r.get("checked")) return None def set_radio_by_heading(ws, heading, value): """Set a heading-grouped radio; poll, else trusted click, poll.""" if _eval(ws, JS_CLICK_RADIO % (heading, value)) == "CLICKED" \ and _poll(lambda: radio_state(ws, heading, value) is True): return True rect = _eval(ws, JS_RADIO_RECT % (heading, value)) if not rect or "x" not in rect: return False try: real_click(ws, rect["x"], rect["y"]) except Exception: return False return _poll(lambda: radio_state(ws, heading, value) is True) def radio_aria_state(ws, name): """Checked state of one aria-labeled radio, None if absent.""" n = name.lower() for r in list_radios(ws): if (r.get("aria") or "").lower() == n \ or (r.get("value") or "").lower() == n: return bool(r.get("checked")) return None def set_radio_by_aria(ws, name): """Set an aria-labeled radio; poll, else trusted click, poll.""" if _eval(ws, JS_CLICK_RADIO_ARIA % name) == "CLICKED" \ and _poll(lambda: radio_aria_state(ws, name) is True): return True rect = _eval(ws, JS_RADIO_ARIA_RECT % name) if not rect or "x" not in rect: return False try: real_click(ws, rect["x"], rect["y"]) except Exception: return False return _poll(lambda: radio_aria_state(ws, name) is True) def switch_state(ws, label): """Checked state of one label-matched switch, None if not unique.""" matches = [s for s in list_switches(ws) if label.lower() in (s.get("label") or "").lower() or label.lower() in (s.get("aria") or "").lower()] if len(matches) != 1: return None return bool(matches[0].get("checked")) def set_switch(ws, label, on): """Set a switch by row-label/aria match; no-op when already there.""" state = switch_state(ws, label) if state is None: return False if state == bool(on): return True if _eval(ws, JS_CLICK_SWITCH % label) == "CLICKED" \ and _poll(lambda: switch_state(ws, label) is bool(on)): return True rect = _eval(ws, JS_SWITCH_RECT % label) if not rect or "x" not in rect: return False try: real_click(ws, rect["x"], rect["y"]) except Exception: return False return _poll(lambda: switch_state(ws, label) is bool(on))