chore: archive tools/ui_inspector snapshot before migration removal.
Keep the last in-template copy on main so history can restore it; product continues in jiangchang-ui-recorder. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
13
tools/ui_inspector/locators/__init__.py
Normal file
13
tools/ui_inspector/locators/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Locator compilers + validators + element catalog."""
|
||||
|
||||
from .browser import compile_browser_locators
|
||||
from .catalog import ElementCatalogService
|
||||
from .validator import validate_locator_against_rows
|
||||
from .windows import compile_windows_locators
|
||||
|
||||
__all__ = [
|
||||
"compile_browser_locators",
|
||||
"compile_windows_locators",
|
||||
"validate_locator_against_rows",
|
||||
"ElementCatalogService",
|
||||
]
|
||||
74
tools/ui_inspector/locators/base.py
Normal file
74
tools/ui_inspector/locators/base.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Shared locator compiler helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from models.locator import LocatorCandidate
|
||||
|
||||
|
||||
def rank_and_select_primary(
|
||||
candidates: list[LocatorCandidate],
|
||||
) -> tuple[Optional[LocatorCandidate], list[LocatorCandidate]]:
|
||||
"""
|
||||
Pick production primary only if unique and not coordinate-based.
|
||||
Fallback list excludes primary.
|
||||
"""
|
||||
ranked = sorted(
|
||||
candidates,
|
||||
key=lambda c: (
|
||||
0 if c.production_eligible else 1,
|
||||
0 if c.unique else 1,
|
||||
0 if c.uses_coordinates else 0,
|
||||
1 if c.uses_coordinates else 0,
|
||||
1 if c.uses_index else 0,
|
||||
-float(c.stability_score or 0.0),
|
||||
),
|
||||
)
|
||||
primary: Optional[LocatorCandidate] = None
|
||||
for c in ranked:
|
||||
if c.production_eligible and not c.uses_coordinates:
|
||||
primary = c
|
||||
break
|
||||
fallbacks = [c for c in ranked if c is not primary]
|
||||
return primary, fallbacks
|
||||
|
||||
|
||||
def count_matches(
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
properties: dict[str, Any],
|
||||
scope: Optional[dict[str, Any]] = None,
|
||||
) -> int:
|
||||
"""Count rows matching structured properties (snapshot / live tree)."""
|
||||
props = {k: v for k, v in (properties or {}).items() if v not in (None, "")}
|
||||
if not props:
|
||||
return 0
|
||||
scope = scope or {}
|
||||
n = 0
|
||||
for r in rows or []:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
if scope.get("window_class"):
|
||||
# optional soft filter if row carries window info
|
||||
rc = r.get("window_class") or r.get("window_class_name")
|
||||
if rc and rc != scope.get("window_class"):
|
||||
continue
|
||||
ok = True
|
||||
for k, v in props.items():
|
||||
if k == "control_type":
|
||||
rv = r.get("control_type") or r.get("role") or ""
|
||||
elif k == "role":
|
||||
rv = r.get("role") or r.get("control_type") or ""
|
||||
elif k == "text":
|
||||
rv = r.get("text") or r.get("value") or r.get("name") or ""
|
||||
elif k == "test_id":
|
||||
rv = r.get("test_id") or r.get("automation_id") or ""
|
||||
else:
|
||||
rv = r.get(k)
|
||||
if str(rv or "") != str(v):
|
||||
ok = False
|
||||
break
|
||||
if ok:
|
||||
n += 1
|
||||
return n
|
||||
190
tools/ui_inspector/locators/browser.py
Normal file
190
tools/ui_inspector/locators/browser.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Browser locator compiler (role / test id / label / placeholder / text / css id)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from models.locator import LocatorCandidate
|
||||
|
||||
from .base import count_matches, rank_and_select_primary
|
||||
|
||||
|
||||
def _expr(strategy: str, props: dict[str, Any]) -> str:
|
||||
parts = [f"{k}={v!r}" for k, v in props.items() if v not in (None, "")]
|
||||
return f"{strategy}:" + ",".join(parts)
|
||||
|
||||
|
||||
def compile_browser_locators(
|
||||
element: dict[str, Any],
|
||||
*,
|
||||
rows: Optional[list[dict[str, Any]]] = None,
|
||||
frame: Optional[dict[str, Any]] = None,
|
||||
) -> list[LocatorCandidate]:
|
||||
"""
|
||||
Generate ordered Playwright-oriented locator candidates.
|
||||
|
||||
Priority roughly: role+name → test id → label → placeholder → text → css id → scoped.
|
||||
Coordinates are never emitted as production candidates.
|
||||
"""
|
||||
el = element or {}
|
||||
rows = list(rows or [])
|
||||
scope = {"frame_url": (frame or {}).get("url") or "", "frame_name": (frame or {}).get("name") or ""}
|
||||
cands: list[LocatorCandidate] = []
|
||||
|
||||
role = str(el.get("role") or el.get("control_type") or "").strip()
|
||||
name = str(el.get("name") or "").strip()
|
||||
text = str(el.get("text") or el.get("value") or "").strip()
|
||||
is_editable = bool(el.get("contenteditable"))
|
||||
if is_editable:
|
||||
# Draft.js/contenteditable often exposes current user text as its
|
||||
# accessible name. That value is payload, never element identity.
|
||||
current_value = str(el.get("value") or "").strip()
|
||||
if name == current_value or name == text:
|
||||
name = ""
|
||||
text = ""
|
||||
test_id = str(el.get("test_id") or el.get("data_testid") or el.get("automation_id") or "").strip()
|
||||
label = str(el.get("label") or "").strip()
|
||||
placeholder = str(el.get("placeholder") or "").strip()
|
||||
name_attr = str(el.get("name_attr") or "").strip()
|
||||
href = str(el.get("href") or "").strip()
|
||||
css_id = str(el.get("id") or el.get("css_id") or "").strip()
|
||||
css_path = str(el.get("css") or el.get("css_path") or "").strip()
|
||||
stable_css = str(el.get("stable_css") or "").strip()
|
||||
semantic_role = str(el.get("semantic_role") or "").strip()
|
||||
semantic_name = str(el.get("semantic_name") or "").strip()
|
||||
# Prefer existing primary_locator from page_dump if present
|
||||
existing = el.get("primary_locator") or {}
|
||||
if isinstance(existing, dict) and existing.get("expr"):
|
||||
props = dict(existing.get("properties") or {})
|
||||
if not props and existing.get("strategy"):
|
||||
props = {"expr": existing.get("expr")}
|
||||
c = LocatorCandidate(
|
||||
strategy=str(existing.get("strategy") or "primary"),
|
||||
scope=scope,
|
||||
properties=props or {"expression": existing.get("expr")},
|
||||
expression=str(existing.get("expr") or ""),
|
||||
stability_score=0.85,
|
||||
reasons=["from_page_dump_primary_locator"],
|
||||
)
|
||||
if rows and props:
|
||||
c.mark_counts(count_matches(rows, properties=props, scope=scope))
|
||||
c.validation = "snapshot_only"
|
||||
elif rows and existing.get("expr"):
|
||||
# Cannot count without structured props — leave unique unknown
|
||||
c.validation = "snapshot_only"
|
||||
cands.append(c)
|
||||
|
||||
for pw in el.get("playwright_candidates") or []:
|
||||
if not isinstance(pw, dict):
|
||||
continue
|
||||
props = dict(pw.get("properties") or {})
|
||||
expr = str(pw.get("expr") or pw.get("expression") or "")
|
||||
strat = str(pw.get("strategy") or "playwright")
|
||||
c = LocatorCandidate(
|
||||
strategy=strat,
|
||||
scope=scope,
|
||||
properties=props or {"expression": expr},
|
||||
expression=expr,
|
||||
stability_score=float(pw.get("score") or 0.7),
|
||||
uses_index=bool(pw.get("uses_index")),
|
||||
uses_coordinates=bool(pw.get("uses_coordinates")),
|
||||
reasons=["from_playwright_candidates"],
|
||||
)
|
||||
if rows and props:
|
||||
c.mark_counts(count_matches(rows, properties=props, scope=scope))
|
||||
c.validation = "snapshot_only"
|
||||
cands.append(c)
|
||||
|
||||
def add(strategy: str, props: dict[str, Any], score: float, reason: str) -> None:
|
||||
if not props or not any(str(v).strip() for v in props.values()):
|
||||
return
|
||||
c = LocatorCandidate(
|
||||
strategy=strategy,
|
||||
scope=dict(scope),
|
||||
properties=dict(props),
|
||||
expression=_expr(strategy, props),
|
||||
stability_score=score,
|
||||
reasons=[reason],
|
||||
)
|
||||
if rows:
|
||||
c.mark_counts(count_matches(rows, properties=props, scope=scope))
|
||||
c.validation = "snapshot_only"
|
||||
cands.append(c)
|
||||
|
||||
if role and name:
|
||||
add("role", {"role": role, "name": name}, 0.95, "role_name")
|
||||
elif role and is_editable:
|
||||
add("role", {"role": role}, 0.78, "editable_role")
|
||||
if semantic_role and semantic_name:
|
||||
add(
|
||||
"semantic_role",
|
||||
{"role": semantic_role, "name": semantic_name},
|
||||
0.86,
|
||||
"semantic_target_inferred_from_form_scope",
|
||||
)
|
||||
if test_id:
|
||||
add("test_id", {"test_id": test_id}, 0.93, "test_id")
|
||||
if label:
|
||||
add("label", {"label": label}, 0.88, "label")
|
||||
if placeholder:
|
||||
add("placeholder", {"placeholder": placeholder}, 0.84, "placeholder")
|
||||
if name_attr:
|
||||
add("name_attr", {"name_attr": name_attr}, 0.89, "html_name_attribute")
|
||||
if href and role.lower() == "link":
|
||||
add("href", {"href": href}, 0.91, "link_destination")
|
||||
if text and len(text) <= 80:
|
||||
add("text", {"text": text}, 0.75, "visible_text")
|
||||
if css_id and not css_id[0].isdigit():
|
||||
add("css_id", {"id": css_id}, 0.8, "css_id")
|
||||
if stable_css:
|
||||
c = LocatorCandidate(
|
||||
strategy="stable_css",
|
||||
scope=dict(scope),
|
||||
properties={"stable_css": stable_css},
|
||||
expression=f"css:{stable_css}",
|
||||
stability_score=0.78 if ("#" in stable_css or "data-testid" in stable_css) else 0.62,
|
||||
reasons=["stable_attribute_selector", "live_unique_required"],
|
||||
)
|
||||
c.production_eligible = False
|
||||
cands.append(c)
|
||||
if css_path:
|
||||
# Full/scoped CSS is a last-resort executable locator. It only becomes
|
||||
# production eligible after live validation proves it is unique.
|
||||
c = LocatorCandidate(
|
||||
strategy="css",
|
||||
scope=dict(scope),
|
||||
properties={"css": css_path},
|
||||
expression=f"css:{css_path}",
|
||||
stability_score=0.48 if "#" in css_path else 0.32,
|
||||
reasons=["event_dom_path", "live_unique_required"],
|
||||
)
|
||||
c.production_eligible = False
|
||||
cands.append(c)
|
||||
|
||||
# Never add coordinate locator as candidate for production
|
||||
if el.get("bounds") or el.get("rect"):
|
||||
c = LocatorCandidate(
|
||||
strategy="bounding_rect",
|
||||
scope=dict(scope),
|
||||
properties={"bounds": el.get("bounds") or el.get("rect")},
|
||||
expression="coordinates:(evidence_only)",
|
||||
stability_score=0.05,
|
||||
uses_coordinates=True,
|
||||
production_eligible=False,
|
||||
reasons=["coordinates_evidence_only"],
|
||||
)
|
||||
c.unique = False
|
||||
c.ambiguous = True
|
||||
cands.append(c)
|
||||
|
||||
# Re-validate production_eligible after counts
|
||||
for c in cands:
|
||||
if c.actual_count is not None:
|
||||
c.mark_counts(int(c.actual_count), expected=int(c.expected_count or 1))
|
||||
elif c.uses_coordinates:
|
||||
c.production_eligible = False
|
||||
|
||||
primary, _ = rank_and_select_primary(cands)
|
||||
# Ensure ambiguous never becomes primary via side effect — rank handles it.
|
||||
del primary
|
||||
return cands
|
||||
294
tools/ui_inspector/locators/catalog.py
Normal file
294
tools/ui_inspector/locators/catalog.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""Element Catalog: merge observations, locator history, parameterization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from models.element import ElementCatalog, ElementRef, element_fingerprint
|
||||
from models.locator import LocatorCandidate
|
||||
from models.serialize import to_dict, write_model_json
|
||||
from redaction import redact_element_fields
|
||||
from core.policies.element_identity import element_identity_key
|
||||
|
||||
from .base import rank_and_select_primary
|
||||
from .browser import compile_browser_locators
|
||||
from .validator import validate_locator_against_rows
|
||||
from .windows import compile_windows_locators
|
||||
|
||||
_DYNAMIC_VALUE_HINTS = re.compile(
|
||||
r"(联系人|搜索|search|contact|user|chat|title|name)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def suggest_logical_name(element: dict[str, Any]) -> str:
|
||||
aid = str(element.get("automation_id") or "").strip()
|
||||
name = str(element.get("name") or "").strip()
|
||||
role = str(element.get("control_type") or element.get("role") or "el").strip()
|
||||
text = str(element.get("text") or "").strip()
|
||||
semantic_name = str(element.get("semantic_name") or "").strip()
|
||||
if element.get("contenteditable"):
|
||||
base = aid or str(element.get("placeholder") or element.get("label") or "") or role
|
||||
else:
|
||||
base = aid or semantic_name or name or (text if 0 < len(text) <= 48 else "")
|
||||
if not base:
|
||||
for ancestor in element.get("parent_chain") or []:
|
||||
ancestor_id = str((ancestor or {}).get("id") or "").strip()
|
||||
ancestor_text = str((ancestor or {}).get("text") or "").strip()
|
||||
if ancestor_id:
|
||||
base = f"{ancestor_id}_{role}"
|
||||
break
|
||||
if ancestor_text and len(ancestor_text) <= 32:
|
||||
base = f"{ancestor_text}_{role}"
|
||||
break
|
||||
base = base or role
|
||||
base = re.sub(r"[^\w\u4e00-\u9fff]+", "_", base, flags=re.UNICODE).strip("_")
|
||||
base = base[:48] or "element"
|
||||
return base.lower() if base.isascii() else base
|
||||
|
||||
|
||||
def _maybe_parameterize(value: str, *, field: str) -> tuple[str, dict[str, str]]:
|
||||
"""Replace likely dynamic business values with ${param} placeholders."""
|
||||
v = (value or "").strip()
|
||||
if not v or len(v) > 64:
|
||||
return v, {}
|
||||
if field in {"value", "text", "name"} and _DYNAMIC_VALUE_HINTS.search(field):
|
||||
return "${value}", {"value": v}
|
||||
# Contact-like short Chinese/ASCII names in value when name is generic
|
||||
if field == "value" and 1 < len(v) <= 32 and not v.startswith("${"):
|
||||
# Only parameterize when looks like a person/search term (letters/CJK, not chrome)
|
||||
if re.fullmatch(r"[\w\u4e00-\u9fff \-_.@]+", v, flags=re.UNICODE):
|
||||
chrome = {"关闭", "最小化", "最大化", "取消", "确定", "发送", "搜索"}
|
||||
if v not in chrome:
|
||||
return "${contact_name}", {"contact_name": v}
|
||||
return v, {}
|
||||
|
||||
|
||||
class ElementCatalogService:
|
||||
def __init__(self, flow_dir: Path, *, flow_id: str = "") -> None:
|
||||
self.flow_dir = Path(flow_dir)
|
||||
self.elements_dir = self.flow_dir / "elements"
|
||||
self.elements_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.catalog_path = self.flow_dir / "element_catalog.json"
|
||||
self.catalog = ElementCatalog(flow_id=flow_id)
|
||||
self._by_fp: dict[str, ElementRef] = {}
|
||||
self._by_identity: dict[str, ElementRef] = {}
|
||||
self._by_name: dict[str, ElementRef] = {}
|
||||
|
||||
def load(self) -> None:
|
||||
if not self.catalog_path.exists():
|
||||
return
|
||||
import json
|
||||
from models.serialize import from_dict
|
||||
|
||||
data = json.loads(self.catalog_path.read_text(encoding="utf-8"))
|
||||
self.catalog.flow_id = data.get("flow_id") or self.catalog.flow_id
|
||||
for item in data.get("elements") or []:
|
||||
# Reconstruct LocatorCandidate lists lightly
|
||||
locs = []
|
||||
for lc in item.get("locator_candidates") or []:
|
||||
locs.append(from_dict(LocatorCandidate, lc))
|
||||
primary = None
|
||||
if item.get("primary_locator"):
|
||||
primary = from_dict(LocatorCandidate, item["primary_locator"])
|
||||
fallbacks = [
|
||||
from_dict(LocatorCandidate, x) for x in (item.get("fallback_locators") or [])
|
||||
]
|
||||
el = ElementRef(
|
||||
id=item.get("id") or "",
|
||||
logical_name=item.get("logical_name") or "",
|
||||
surface=item.get("surface") or "windows",
|
||||
backend=item.get("backend") or "",
|
||||
app=item.get("app") or {},
|
||||
window_scope=item.get("window_scope") or {},
|
||||
element_scope=item.get("element_scope") or {},
|
||||
role=item.get("role") or "",
|
||||
control_type=item.get("control_type") or "",
|
||||
name=item.get("name") or "",
|
||||
value=item.get("value") or "",
|
||||
automation_id=item.get("automation_id") or "",
|
||||
class_name=item.get("class_name") or "",
|
||||
framework_id=item.get("framework_id") or "",
|
||||
states=item.get("states") or {},
|
||||
bounds=item.get("bounds") or {},
|
||||
actions=list(item.get("actions") or []),
|
||||
parent_chain=list(item.get("parent_chain") or []),
|
||||
raw=item.get("raw") or {},
|
||||
fingerprint=item.get("fingerprint") or "",
|
||||
sensitive=bool(item.get("sensitive")),
|
||||
locator_candidates=locs,
|
||||
primary_locator=primary,
|
||||
fallback_locators=fallbacks,
|
||||
expected_count=int(item.get("expected_count") or 1),
|
||||
verified_count=item.get("verified_count"),
|
||||
verification_level=item.get("verification_level") or "none",
|
||||
stability_score=float(item.get("stability_score") or 0.0),
|
||||
verified_at=item.get("verified_at"),
|
||||
first_seen=item.get("first_seen") or "",
|
||||
last_seen=item.get("last_seen") or "",
|
||||
observation_count=int(item.get("observation_count") or 1),
|
||||
locator_history=list(item.get("locator_history") or []),
|
||||
parameterized=dict(item.get("parameterized") or {}),
|
||||
)
|
||||
if el.fingerprint:
|
||||
self._by_fp[el.fingerprint] = el
|
||||
identity = str((el.raw or {}).get("_identity_key") or "")
|
||||
if not identity:
|
||||
identity = element_identity_key(
|
||||
el.raw or to_dict(el),
|
||||
surface=el.surface,
|
||||
page_url=str((el.element_scope or {}).get("page_url") or ""),
|
||||
frame_name=str((el.element_scope or {}).get("frame_name") or ""),
|
||||
)
|
||||
self._by_identity[identity] = el
|
||||
if el.logical_name:
|
||||
self._by_name[el.logical_name] = el
|
||||
self.catalog.elements.append(el)
|
||||
|
||||
def save(self) -> None:
|
||||
from datetime import datetime
|
||||
|
||||
self.catalog.updated_at = datetime.now().astimezone().isoformat()
|
||||
write_model_json(self.catalog_path, self.catalog)
|
||||
for el in self.catalog.elements:
|
||||
write_model_json(self.elements_dir / f"{el.id}.json", el)
|
||||
|
||||
def upsert_from_snapshot(
|
||||
self,
|
||||
element: dict[str, Any],
|
||||
*,
|
||||
surface: str,
|
||||
backend: str = "",
|
||||
window_scope: Optional[dict[str, Any]] = None,
|
||||
rows: Optional[list[dict[str, Any]]] = None,
|
||||
logical_name: Optional[str] = None,
|
||||
frame: Optional[dict[str, Any]] = None,
|
||||
live_counts: Optional[dict[str, int]] = None,
|
||||
) -> ElementRef:
|
||||
cleaned, sensitive = redact_element_fields(dict(element or {}))
|
||||
win = dict(window_scope or {})
|
||||
value = str(cleaned.get("value") or "")
|
||||
is_editable = bool(cleaned.get("contenteditable"))
|
||||
param_value, params = _maybe_parameterize(value, field="value")
|
||||
if params:
|
||||
cleaned["value"] = param_value
|
||||
|
||||
fp = element_fingerprint(
|
||||
surface=surface,
|
||||
role=str(cleaned.get("control_type") or cleaned.get("role") or ""),
|
||||
name=str(cleaned.get("name") or ""),
|
||||
automation_id=str(cleaned.get("automation_id") or ""),
|
||||
class_name=str(cleaned.get("class_name") or ""),
|
||||
value=str(cleaned.get("value") or ""),
|
||||
window_scope=win,
|
||||
dom_fingerprint=str(cleaned.get("fingerprint") or cleaned.get("css") or ""),
|
||||
page_scope=str((frame or {}).get("url") or win.get("url") or ""),
|
||||
contenteditable=is_editable,
|
||||
)
|
||||
|
||||
identity = element_identity_key(
|
||||
cleaned,
|
||||
surface=surface,
|
||||
page_url=str((frame or {}).get("url") or win.get("url") or ""),
|
||||
frame_name=str((frame or {}).get("name") or ""),
|
||||
)
|
||||
cleaned["_identity_key"] = identity
|
||||
|
||||
existing = self._by_identity.get(identity) or self._by_fp.get(fp)
|
||||
if existing is None and logical_name and logical_name in self._by_name:
|
||||
existing = self._by_name[logical_name]
|
||||
|
||||
if surface == "browser":
|
||||
cands = compile_browser_locators(cleaned, rows=rows, frame=frame)
|
||||
else:
|
||||
cands = compile_windows_locators(cleaned, rows=rows, window_scope=win)
|
||||
|
||||
for c in cands:
|
||||
if rows is not None and not c.uses_coordinates:
|
||||
validate_locator_against_rows(c, rows, level="snapshot_only")
|
||||
if live_counts is not None and c.strategy in live_counts:
|
||||
c.mark_counts(int(live_counts[c.strategy]))
|
||||
c.validation = "live"
|
||||
|
||||
primary, fallbacks = rank_and_select_primary(cands)
|
||||
# Ambiguous / coordinates never primary
|
||||
if primary is not None and (primary.ambiguous or not primary.production_eligible):
|
||||
primary = None
|
||||
|
||||
lname = logical_name or (existing.logical_name if existing else "") or suggest_logical_name(
|
||||
cleaned
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Merge observation; keep locator history
|
||||
if primary is not None:
|
||||
old = existing.primary_locator
|
||||
if old is not None and to_dict(old) != to_dict(primary):
|
||||
existing.locator_history.append(
|
||||
{"replaced_at": existing.last_seen, "locator": to_dict(old)}
|
||||
)
|
||||
existing.primary_locator = primary
|
||||
existing.locator_candidates = cands
|
||||
existing.fallback_locators = fallbacks
|
||||
existing.touch()
|
||||
existing.raw = cleaned
|
||||
existing.bounds = dict(cleaned.get("bounds") or cleaned.get("rect") or existing.bounds or {})
|
||||
if params:
|
||||
existing.parameterized.update(params)
|
||||
existing.sensitive = existing.sensitive or sensitive
|
||||
existing.verification_level = "live" if live_counts else "snapshot_only"
|
||||
self._by_fp[fp] = existing
|
||||
self._by_identity[identity] = existing
|
||||
if primary and primary.actual_count is not None:
|
||||
existing.verified_count = primary.actual_count
|
||||
self.save()
|
||||
return existing
|
||||
|
||||
el = ElementRef(
|
||||
logical_name=lname,
|
||||
surface=surface,
|
||||
backend=backend,
|
||||
window_scope=win,
|
||||
element_scope={
|
||||
"page_url": str((frame or {}).get("url") or win.get("url") or ""),
|
||||
"frame_name": str((frame or {}).get("name") or ""),
|
||||
},
|
||||
role=str(cleaned.get("role") or ""),
|
||||
control_type=str(cleaned.get("control_type") or ""),
|
||||
name=str(cleaned.get("name") or ""),
|
||||
value=str(cleaned.get("value") or ""),
|
||||
automation_id=str(cleaned.get("automation_id") or ""),
|
||||
class_name=str(cleaned.get("class_name") or ""),
|
||||
framework_id=str(cleaned.get("framework_id") or ""),
|
||||
states={
|
||||
"is_enabled": cleaned.get("is_enabled"),
|
||||
"is_offscreen": cleaned.get("is_offscreen"),
|
||||
},
|
||||
bounds=dict(cleaned.get("bounds") or cleaned.get("rect") or {}),
|
||||
parent_chain=list(cleaned.get("parent_chain") or []),
|
||||
raw=cleaned,
|
||||
fingerprint=fp,
|
||||
sensitive=sensitive,
|
||||
locator_candidates=cands,
|
||||
primary_locator=primary,
|
||||
fallback_locators=fallbacks,
|
||||
verification_level="live" if live_counts else "snapshot_only",
|
||||
verified_count=(primary.actual_count if primary else None),
|
||||
parameterized=params,
|
||||
stability_score=float(primary.stability_score) if primary else 0.0,
|
||||
)
|
||||
# Ensure unique logical_name
|
||||
base = el.logical_name
|
||||
i = 2
|
||||
while el.logical_name in self._by_name:
|
||||
el.logical_name = f"{base}_{i}"
|
||||
i += 1
|
||||
|
||||
self.catalog.elements.append(el)
|
||||
self._by_fp[fp] = el
|
||||
self._by_identity[identity] = el
|
||||
self._by_name[el.logical_name] = el
|
||||
self.save()
|
||||
return el
|
||||
50
tools/ui_inspector/locators/validator.py
Normal file
50
tools/ui_inspector/locators/validator.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Locator uniqueness validation against snapshot / live tree rows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from models.locator import LocatorCandidate
|
||||
|
||||
from .base import count_matches
|
||||
|
||||
|
||||
def validate_locator_against_rows(
|
||||
candidate: LocatorCandidate,
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
level: str = "snapshot_only",
|
||||
expected_count: int = 1,
|
||||
) -> LocatorCandidate:
|
||||
"""
|
||||
Validate structured locator properties against a row list.
|
||||
|
||||
Levels supported in this phase: snapshot_only | live_tree
|
||||
"""
|
||||
actual = count_matches(
|
||||
rows,
|
||||
properties=candidate.properties,
|
||||
scope=candidate.scope,
|
||||
)
|
||||
candidate.mark_counts(actual, expected=expected_count)
|
||||
candidate.validation = level
|
||||
if not candidate.unique:
|
||||
candidate.reasons = list(candidate.reasons or [])
|
||||
if "ambiguous_match_count" not in candidate.reasons:
|
||||
candidate.reasons.append("ambiguous_match_count")
|
||||
candidate.production_eligible = False
|
||||
if candidate.uses_coordinates:
|
||||
candidate.production_eligible = False
|
||||
if "coordinates_never_primary" not in (candidate.reasons or []):
|
||||
candidate.reasons = list(candidate.reasons or []) + ["coordinates_never_primary"]
|
||||
return candidate
|
||||
|
||||
|
||||
def choose_production_primary(
|
||||
candidates: list[LocatorCandidate],
|
||||
) -> Optional[LocatorCandidate]:
|
||||
"""Return first production-eligible unique non-coordinate locator, else None."""
|
||||
for c in candidates:
|
||||
if c.production_eligible and c.unique and not c.uses_coordinates:
|
||||
return c
|
||||
return None
|
||||
180
tools/ui_inspector/locators/windows.py
Normal file
180
tools/ui_inspector/locators/windows.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Windows UIA locator compiler (Name ≠ Value; no coordinate primary)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from models.locator import LocatorCandidate
|
||||
|
||||
from .base import count_matches, rank_and_select_primary
|
||||
|
||||
|
||||
def _expr(strategy: str, props: dict[str, Any]) -> str:
|
||||
parts = [f"{k}={v!r}" for k, v in props.items() if v not in (None, "")]
|
||||
return f"{strategy}:" + ",".join(parts)
|
||||
|
||||
|
||||
def compile_windows_locators(
|
||||
element: dict[str, Any],
|
||||
*,
|
||||
rows: Optional[list[dict[str, Any]]] = None,
|
||||
window_scope: Optional[dict[str, Any]] = None,
|
||||
) -> list[LocatorCandidate]:
|
||||
"""
|
||||
Priority:
|
||||
1. Window scope + AutomationId + ControlType
|
||||
2. Window scope + stable Name + ControlType
|
||||
3. Window scope + Name + Value + ControlType
|
||||
4. Business parent + child attrs
|
||||
5. Visible Value + ControlType
|
||||
6. Relative ancestor scope
|
||||
7. Index with business reason (low score)
|
||||
8. BoundingRectangle evidence only
|
||||
"""
|
||||
el = element or {}
|
||||
rows = list(rows or [])
|
||||
win = dict(window_scope or el.get("window_scope") or {})
|
||||
scope = {
|
||||
"window_class": win.get("class_name") or win.get("window_class") or "",
|
||||
"window_title": win.get("title") or win.get("window_title") or "",
|
||||
"process_name": win.get("process_name") or "",
|
||||
}
|
||||
cands: list[LocatorCandidate] = []
|
||||
|
||||
aid = str(el.get("automation_id") or "").strip()
|
||||
name = str(el.get("name") or "").strip()
|
||||
value = str(el.get("value") or "").strip()
|
||||
ctype = str(el.get("control_type") or el.get("role") or "").strip()
|
||||
class_name = str(el.get("class_name") or "").strip()
|
||||
parent_chain = list(el.get("parent_chain") or [])
|
||||
|
||||
def add(
|
||||
strategy: str,
|
||||
props: dict[str, Any],
|
||||
score: float,
|
||||
reason: str,
|
||||
*,
|
||||
uses_index: bool = False,
|
||||
uses_coordinates: bool = False,
|
||||
) -> None:
|
||||
if not props or not any(str(v).strip() for v in props.values() if not isinstance(v, (dict, list))):
|
||||
return
|
||||
c = LocatorCandidate(
|
||||
strategy=strategy,
|
||||
scope=dict(scope),
|
||||
properties=dict(props),
|
||||
expression=_expr(strategy, props),
|
||||
stability_score=score,
|
||||
uses_index=uses_index,
|
||||
uses_coordinates=uses_coordinates,
|
||||
reasons=[reason],
|
||||
)
|
||||
if rows:
|
||||
c.mark_counts(count_matches(rows, properties=props, scope=scope))
|
||||
c.validation = "snapshot_only"
|
||||
elif uses_coordinates:
|
||||
c.production_eligible = False
|
||||
c.unique = False
|
||||
c.ambiguous = True
|
||||
cands.append(c)
|
||||
|
||||
if aid and ctype:
|
||||
add(
|
||||
"window_automation_id_type",
|
||||
{"automation_id": aid, "control_type": ctype},
|
||||
0.96,
|
||||
"automation_id_control_type",
|
||||
)
|
||||
elif aid:
|
||||
add("window_automation_id", {"automation_id": aid}, 0.9, "automation_id")
|
||||
|
||||
if name and ctype and name.lower() not in {"item", "pane", "unknown"}:
|
||||
add(
|
||||
"window_name_type",
|
||||
{"name": name, "control_type": ctype},
|
||||
0.88,
|
||||
"stable_name_control_type",
|
||||
)
|
||||
|
||||
if name and value and ctype:
|
||||
add(
|
||||
"window_name_value_type",
|
||||
{"name": name, "value": value, "control_type": ctype},
|
||||
0.86,
|
||||
"name_value_control_type",
|
||||
)
|
||||
|
||||
# Business parent + child
|
||||
if parent_chain:
|
||||
parent = parent_chain[0] if isinstance(parent_chain[0], dict) else {}
|
||||
parent_name = str(parent.get("name") or parent.get("automation_id") or "").strip()
|
||||
if parent_name and (aid or name or value):
|
||||
props: dict[str, Any] = {"parent_name": parent_name}
|
||||
if aid:
|
||||
props["automation_id"] = aid
|
||||
if name:
|
||||
props["name"] = name
|
||||
if value:
|
||||
props["value"] = value
|
||||
if ctype:
|
||||
props["control_type"] = ctype
|
||||
add("parent_scoped", props, 0.82, "business_parent_scope")
|
||||
|
||||
if value and ctype and len(value) <= 64:
|
||||
add(
|
||||
"value_type",
|
||||
{"value": value, "control_type": ctype},
|
||||
0.78,
|
||||
"visible_value_control_type",
|
||||
)
|
||||
|
||||
if class_name and ctype:
|
||||
add(
|
||||
"class_type",
|
||||
{"class_name": class_name, "control_type": ctype},
|
||||
0.55,
|
||||
"class_name_control_type",
|
||||
)
|
||||
|
||||
# Index only with explicit business reason — low score, not auto primary
|
||||
idx = el.get("index")
|
||||
if idx is not None and (aid or name):
|
||||
props = {"index": int(idx)}
|
||||
if aid:
|
||||
props["automation_id"] = aid
|
||||
if name:
|
||||
props["name"] = name
|
||||
if ctype:
|
||||
props["control_type"] = ctype
|
||||
add(
|
||||
"indexed",
|
||||
props,
|
||||
0.35,
|
||||
"index_requires_business_justification",
|
||||
uses_index=True,
|
||||
)
|
||||
|
||||
bounds = el.get("bounds") or el.get("rect") or el.get("bounding_rectangle")
|
||||
if bounds:
|
||||
c = LocatorCandidate(
|
||||
strategy="bounding_rectangle",
|
||||
scope=dict(scope),
|
||||
properties={"bounds": bounds},
|
||||
expression="coordinates:(evidence_only)",
|
||||
stability_score=0.05,
|
||||
uses_coordinates=True,
|
||||
production_eligible=False,
|
||||
unique=False,
|
||||
ambiguous=True,
|
||||
reasons=["coordinates_never_primary"],
|
||||
)
|
||||
cands.append(c)
|
||||
|
||||
for c in cands:
|
||||
if c.actual_count is not None:
|
||||
c.mark_counts(int(c.actual_count), expected=int(c.expected_count or 1))
|
||||
if c.uses_coordinates:
|
||||
c.production_eligible = False
|
||||
|
||||
rank_and_select_primary(cands)
|
||||
return cands
|
||||
Reference in New Issue
Block a user