Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/flow/openadapt_flow/qualify/campaign.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 25 additions & 3 deletions packages/flow/openadapt_flow/qualify/faults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down
96 changes: 96 additions & 0 deletions packages/flow/tests/test_fault_driver_role_name.py
Original file line number Diff line number Diff line change
@@ -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,<form><input name=a>" + "<button>Create</button>" * buttons
)
html += "<button>Cancel</button></form>"
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"
Loading