2026-10-06 19:33:17 +00:00
|
|
|
"""Settings dialog navigation: open, tabs, rows, back, text.
|
|
|
|
|
|
|
|
|
|
Open is retried (single-shot opens flake ~1/4 live): each attempt
|
|
|
|
|
re-Escapes and re-drives the dock menu from scratch. Tab clicks are
|
|
|
|
|
idempotent (re-clicking the active tab is a harmless no-op), so goto
|
|
|
|
|
always clicks and reports the click result instead of guessing which
|
|
|
|
|
tab is active.
|
|
|
|
|
"""
|
2026-10-07 00:25:51 +00:00
|
|
|
import json
|
2026-10-06 19:33:17 +00:00
|
|
|
import time
|
|
|
|
|
|
|
|
|
|
from approvals import cdp_evaluate
|
|
|
|
|
from hatch_menu.mouse import escape, real_click
|
|
|
|
|
|
|
|
|
|
TAB_NAMES = ["General", "Connectors", "Wallet", "Secure store",
|
|
|
|
|
"Permissions", "Messaging channels", "Devices",
|
|
|
|
|
"Data controls", "Help & support", "Legal info"]
|
|
|
|
|
TABS = TAB_NAMES # legacy alias
|
|
|
|
|
|
2026-10-07 00:25:51 +00:00
|
|
|
# Tab rail buttons carry bare tab names and live outside any nav
|
|
|
|
|
# landmark, so row matchers exclude them by exact text (live 2026-10-06).
|
|
|
|
|
_JS_TABS = json.dumps(TAB_NAMES)
|
|
|
|
|
|
2026-10-06 19:33:17 +00:00
|
|
|
DOCK_MORE_TESTID = "hatch-dock-more"
|
|
|
|
|
|
|
|
|
|
JS_DOCK_RECT = ("(() => { const b = document.querySelector("
|
|
|
|
|
"'[data-testid=\"hatch-dock-more\"]'); if (!b) return null;"
|
|
|
|
|
" const r = b.getBoundingClientRect();"
|
|
|
|
|
" return {x: r.x + r.width/2, y: r.y + r.height/2}; })()")
|
|
|
|
|
|
|
|
|
|
JS_CLICK_SETTINGS_ITEM = ("(() => { const it = Array.from(document."
|
|
|
|
|
"querySelectorAll('[role=\"menuitem\"]')).find(el => el.getAttribute("
|
|
|
|
|
"'data-pel-click') === 'settings_nav_click');"
|
|
|
|
|
" if (!it) return 'NO_ITEM'; it.click(); return 'CLICKED'; })()")
|
|
|
|
|
|
|
|
|
|
JS_DIALOG_PRESENT = ("(() => !!document.querySelector('[role=\"dialog\"]'))()")
|
|
|
|
|
|
|
|
|
|
JS_DIALOG_TEXT = ("(() => { const d = document.querySelector("
|
|
|
|
|
"'[role=\"dialog\"]'); return d ? d.innerText : null; })()")
|
|
|
|
|
|
|
|
|
|
JS_GOTO_TAB_TMPL = ("(() => { const b = Array.from(document."
|
|
|
|
|
"querySelectorAll('[role=\"dialog\"] button')).find(x => "
|
|
|
|
|
"(x.innerText||'').trim() === '%s');"
|
|
|
|
|
" if (!b) return 'NO_TAB'; b.click(); return 'CLICKED'; })()")
|
|
|
|
|
|
|
|
|
|
JS_CLICK_ROW_TMPL = ("((name) => {"
|
|
|
|
|
" const d = document.querySelector('[role=\"dialog\"]');"
|
|
|
|
|
" if (!d) return 'NO_DIALOG';"
|
2026-10-07 00:25:51 +00:00
|
|
|
" const TABS = " + _JS_TABS + ";"
|
2026-10-06 19:33:17 +00:00
|
|
|
" const inNav = (el) => !!el.closest("
|
|
|
|
|
"'nav, [role=\"tablist\"], [role=\"navigation\"]');"
|
|
|
|
|
" const els = Array.from(d.querySelectorAll("
|
|
|
|
|
"'button, [role=\"button\"], a')).filter(e => !inNav(e));"
|
2026-10-07 00:25:51 +00:00
|
|
|
" const t = els.find(e => {"
|
|
|
|
|
" const txt = (e.innerText || '').trim();"
|
|
|
|
|
" return !TABS.includes(txt) && txt.toLowerCase()"
|
|
|
|
|
".startsWith(name.toLowerCase()); });"
|
2026-10-06 19:33:17 +00:00
|
|
|
" if (!t) return 'NO_ROW'; t.click(); return 'CLICKED'; })('%s')")
|
|
|
|
|
|
|
|
|
|
JS_DESCRIBE_ROWS = ("(() => {"
|
|
|
|
|
" const d = document.querySelector('[role=\"dialog\"]');"
|
|
|
|
|
" if (!d) return null;"
|
2026-10-07 00:25:51 +00:00
|
|
|
" const TABS = " + _JS_TABS + ";"
|
2026-10-06 19:33:17 +00:00
|
|
|
" const inNav = (el) => !!el.closest("
|
|
|
|
|
"'nav, [role=\"tablist\"], [role=\"navigation\"]');"
|
|
|
|
|
" return Array.from(d.querySelectorAll("
|
|
|
|
|
"'button, [role=\"button\"], a')).filter(e => !inNav(e))"
|
2026-10-07 00:25:51 +00:00
|
|
|
".filter(e => !TABS.includes((e.innerText || '').trim()))"
|
2026-10-06 19:33:17 +00:00
|
|
|
".map(e => { const lines = (e.innerText || '').trim().split('\\n');"
|
|
|
|
|
" return {name: (lines[0] || '').slice(0, 80),"
|
|
|
|
|
" detail: lines.slice(1).join(' / ').slice(0, 120)}; }); })()")
|
|
|
|
|
|
|
|
|
|
JS_GO_BACK = ("(() => { const d = document.querySelector("
|
|
|
|
|
"'[role=\"dialog\"]'); if (!d) return 'NO_DIALOG';"
|
|
|
|
|
" const b = Array.from(d.querySelectorAll('button')).find("
|
|
|
|
|
"x => (x.getAttribute('aria-label') || '') === 'Go back');"
|
|
|
|
|
" if (!b) return 'NO_BACK'; b.click(); return 'CLICKED'; })()")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _eval(ws, js, timeout=8.0):
|
|
|
|
|
try:
|
|
|
|
|
return cdp_evaluate(ws, js, timeout=timeout)
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def dialog_present(ws):
|
|
|
|
|
"""True when a dialog is currently open."""
|
|
|
|
|
return bool(_eval(ws, JS_DIALOG_PRESENT, timeout=5.0))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def dialog_text(ws, timeout=8.0, limit=4000):
|
|
|
|
|
"""Inner text of the open dialog, or None."""
|
|
|
|
|
try:
|
|
|
|
|
text = cdp_evaluate(ws, JS_DIALOG_TEXT, timeout=timeout)
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
2026-10-07 00:25:51 +00:00
|
|
|
if not isinstance(text, str) or not text:
|
2026-10-06 19:33:17 +00:00
|
|
|
return None
|
|
|
|
|
return text[:limit]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def open_settings(ws, tries=3):
|
|
|
|
|
"""Open the Settings dialog via the dock menu. True when open."""
|
|
|
|
|
for _ in range(tries):
|
|
|
|
|
escape(ws)
|
|
|
|
|
time.sleep(0.5)
|
|
|
|
|
rect = _eval(ws, JS_DOCK_RECT, timeout=5.0)
|
|
|
|
|
if not rect or "x" not in rect:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
real_click(ws, rect["x"], rect["y"])
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
time.sleep(1.0)
|
|
|
|
|
if _eval(ws, JS_CLICK_SETTINGS_ITEM,
|
|
|
|
|
timeout=5.0) != "CLICKED":
|
|
|
|
|
continue
|
|
|
|
|
time.sleep(1.5)
|
|
|
|
|
if dialog_present(ws):
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def goto_tab(ws, name, timeout=5.0):
|
|
|
|
|
"""Click a Settings tab by visible name. True when clicked."""
|
|
|
|
|
if _eval(ws, JS_GOTO_TAB_TMPL % name, timeout=timeout) != "CLICKED":
|
|
|
|
|
return False
|
|
|
|
|
time.sleep(0.8)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def click_row(ws, name, tab=None, timeout=8.0):
|
|
|
|
|
"""Click a content row; verify the drill/expand opened. Bool."""
|
|
|
|
|
if tab is not None and not goto_tab(ws, tab, timeout=timeout):
|
|
|
|
|
return False
|
|
|
|
|
if _eval(ws, JS_CLICK_ROW_TMPL % name, timeout=timeout) != "CLICKED":
|
|
|
|
|
return False
|
|
|
|
|
time.sleep(1.0)
|
|
|
|
|
text = dialog_text(ws, timeout=timeout) or ""
|
|
|
|
|
return name.lower() in text.lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def describe_rows(ws, tab=None, timeout=8.0):
|
|
|
|
|
"""Inventory rows (name/detail) on a tab. [] when unreadable."""
|
|
|
|
|
if tab is not None and not goto_tab(ws, tab, timeout=timeout):
|
|
|
|
|
return []
|
2026-10-07 00:25:51 +00:00
|
|
|
rows = _eval(ws, JS_DESCRIBE_ROWS, timeout=timeout)
|
|
|
|
|
return rows if isinstance(rows, list) else []
|
2026-10-06 19:33:17 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def go_back(ws):
|
|
|
|
|
"""Click the sub-page Go back button. True when clicked."""
|
|
|
|
|
if _eval(ws, JS_GO_BACK, timeout=5.0) != "CLICKED":
|
|
|
|
|
return False
|
|
|
|
|
time.sleep(0.8)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def close_settings(ws):
|
|
|
|
|
"""Dismiss settings/popovers. Never raises."""
|
|
|
|
|
escape(ws)
|