From 72725752d0b701d25a1b19ac97acef63fd278217 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sun, 25 Oct 2026 10:01:19 -0400 Subject: [PATCH] qualify: find the save control by role and name when it has no selector The web fault driver and the qualification precondition required a CSS selector for the save control, which the recorder captures only for a unique #id. React apps (Medplum) give a save button its role and accessible name instead. Both now take role and name when they find exactly one element; no match or several matches is no target, and the fault declines. Co-Authored-By: Claude Opus 5.5 --- .../flow/openadapt_flow/qualify/campaign.py | 9 +- .../flow/openadapt_flow/qualify/faults.py | 28 +++++- .../flow/tests/test_fault_driver_role_name.py | 96 +++++++++++++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 packages/flow/tests/test_fault_driver_role_name.py diff --git a/packages/flow/openadapt_flow/qualify/campaign.py b/packages/flow/openadapt_flow/qualify/campaign.py index d07913cad..0c8f7d44f 100644 --- a/packages/flow/openadapt_flow/qualify/campaign.py +++ b/packages/flow/openadapt_flow/qualify/campaign.py @@ -339,12 +339,15 @@ def check_ready( problems.append( f"save step {save.id} has an unconfirmed placeholder record check" ) + structural = save.anchor.structural if save.anchor is not None else None if save.anchor is not None and ( - save.anchor.structural is None or not save.anchor.structural.selector + structural is None + or not (structural.selector or (structural.role and structural.name)) ): problems.append( - f"save step {save.id} has no structural selector: the web fault " - "driver needs it for the ambiguity and identity faults" + f"save step {save.id} has no structural locator (a selector, or a " + "role and a name): the web fault driver needs it for the ambiguity " + "and identity faults" ) return problems diff --git a/packages/flow/openadapt_flow/qualify/faults.py b/packages/flow/openadapt_flow/qualify/faults.py index 47ec24dd8..f1be7cc00 100644 --- a/packages/flow/openadapt_flow/qualify/faults.py +++ b/packages/flow/openadapt_flow/qualify/faults.py @@ -139,13 +139,26 @@ def mutation_bytes(kind: str) -> bytes: # -- page helpers --------------------------------------------------------- def _save_target(self, context: QualificationFaultContext) -> Any: + """The save control, found by the bundle's structural locator. + + A CSS selector or, when the control has none (common in React apps, + which seldom give a save button an ``id``), its ARIA role and + accessible name. Either must find exactly one element: no match or + several matches is no target, and the fault declines. + """ + step = self._steps.get(context.step_id) locator = ( step.anchor.structural if step is not None and step.anchor is not None else None ) - if locator is None or not locator.selector: + by_role = ( + locator is not None + and not locator.selector + and bool(locator.role and locator.name) + ) + if locator is None or not (locator.selector or by_role): return None scoped = getattr(context.backend, "_locator_with_scope", None) if callable(scoped): @@ -155,14 +168,23 @@ def _save_target(self, context: QualificationFaultContext) -> Any: _scope, target = resolved if target.count() == 1: return target.first + if by_role: + return None except Exception: # noqa: BLE001 - fall back to a frame search pass page = getattr(context.backend, "page", None) found = [] for frame in list(getattr(page, "frames", None) or []): try: - loc = frame.locator(locator.selector) - if loc.count() == 1: + if by_role: + loc = frame.get_by_role(locator.role, name=locator.name, exact=True) + count = loc.count() + if count > 1: + return None + else: + loc = frame.locator(locator.selector) + count = loc.count() + if count == 1: found.append(loc.first) except Exception: # noqa: BLE001 - detached frames are skipped continue diff --git a/packages/flow/tests/test_fault_driver_role_name.py b/packages/flow/tests/test_fault_driver_role_name.py new file mode 100644 index 000000000..8c3a77047 --- /dev/null +++ b/packages/flow/tests/test_fault_driver_role_name.py @@ -0,0 +1,96 @@ +"""The web fault driver finds a save control by role and name. + +A React app seldom gives its save button an ``id``, so the recorder keeps the +button's role and accessible name instead of a CSS selector. The fault driver +takes that locator when it finds exactly one element, and declines when it +finds none or several. The qualification precondition accepts it too. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from openadapt_flow.ir import ActionKind, Anchor, Step, StructuralLocator, Workflow +from openadapt_flow.qualify import WebFaultDriver +from openadapt_flow.qualify.campaign import apply_verify_config, check_ready +from tests.test_qualify_declarative import _config + + +def _workflow(structural: StructuralLocator) -> Workflow: + save = Step( + id="s3", + intent="Create patient", + action=ActionKind.CLICK, + anchor=Anchor( + template="templates/s3.png", + region=(10, 10, 80, 20), + click_point=(20, 20), + structural=structural, + context_text="Create New Patient", + ), + identity_armed=True, + risk="irreversible", + ) + return Workflow( + name="register", + steps=[ + Step(id="s1", intent="email", action=ActionKind.TYPE, param="email"), + Step(id="s2", intent="name", action=ActionKind.TYPE, param="lname"), + save, + ], + params={"email": "a@example.test", "lname": "Doe"}, + ) + + +BY_ROLE = StructuralLocator(role="button", name="Create") + + +def test_the_precondition_accepts_a_role_and_a_name(): + workflow = _workflow(BY_ROLE) + config = _config() + save, _ = apply_verify_config(workflow, config) + assert check_ready(workflow, config, save, dict(workflow.params)) == [] + + +def test_the_precondition_refuses_a_role_alone(): + workflow = _workflow(StructuralLocator(role="button")) + config = _config() + save, _ = apply_verify_config(workflow, config) + problems = " | ".join(check_ready(workflow, config, save, dict(workflow.params))) + assert "structural locator" in problems + + +@pytest.fixture +def page_with(): + pytest.importorskip("playwright") + from openadapt_flow.backends.playwright_backend import PlaywrightBackend + + opened = [] + + def open_page(buttons: int): + html = ( + "data:text/html,
" + "" * buttons + ) + html += "
" + try: + backend, close = PlaywrightBackend.launch(html) + except Exception as exc: # noqa: BLE001 - no browser on this machine + pytest.skip(f"chromium unavailable: {exc}") + opened.append(close) + return backend + + yield open_page + for close in opened: + close() + + +@pytest.mark.parametrize("buttons, found", [(1, True), (0, False), (2, False)]) +def test_the_driver_needs_exactly_one_match(page_with, buttons, found): + backend = page_with(buttons) + driver = WebFaultDriver(_workflow(BY_ROLE)) + target = driver._save_target(SimpleNamespace(step_id="s3", backend=backend)) + assert (target is not None) is found + if found: + assert target.inner_text() == "Create"