Files
skill-template/tools/ui_inspector/locators/browser.py
chendelian 633ecff4e2 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>
2026-08-07 11:19:14 +08:00

191 lines
7.6 KiB
Python

"""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