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