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>
191 lines
7.6 KiB
Python
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
|