diff --git a/packages/flow/openadapt_flow/compiler/compile.py b/packages/flow/openadapt_flow/compiler/compile.py index 9788f05ea..ff9876006 100644 --- a/packages/flow/openadapt_flow/compiler/compile.py +++ b/packages/flow/openadapt_flow/compiler/compile.py @@ -1204,10 +1204,15 @@ def ident(o: dict) -> tuple: and isinstance(name, str) and name.strip() ) - # An id or testid check also binds the element's role and name when it had - # them, so an id that moved to another element can't pass: the generated-id - # patterns are only a hint, and no list keeps up with every framework. - pair: dict[str, Any] = {"focus_role": role, "focus_name": name} if named else {} + # Every check needs a role and a non-empty name. An id or testid check + # binds them too, so an id that moved to another element can't pass: the + # generated-id patterns are only a hint, and no list keeps up with every + # framework. An element with no name (a bare tabindex div, a host whose + # closed shadow root holds the real focus) gets no check, and the step + # stays vacuous. + if not named: + return None + pair: dict[str, Any] = {"focus_role": role, "focus_name": name} if isinstance(testid, str) and testid.strip() and after.get("testid_count") == 1: return Postcondition( kind=PostconditionKind.FOCUS_ON, diff --git a/packages/flow/openadapt_flow/ir.py b/packages/flow/openadapt_flow/ir.py index acecc5108..5e9df14e6 100644 --- a/packages/flow/openadapt_flow/ir.py +++ b/packages/flow/openadapt_flow/ir.py @@ -640,15 +640,25 @@ def _serialize_compatible(self, handler: Any) -> dict[str, Any]: def focus_identity_is_specific(pc: "Postcondition") -> bool: """Whether a FOCUS_ON postcondition names one element, not a kind of element. - A ``testid`` or an ``id`` with a value, or a role with a non-empty - accessible name. A tag alone, or a role with no name, names no element: - such a postcondition asserts nothing and never passes. + A role with a non-empty accessible name, alone or with a ``testid`` or an + ``id``. A tag, a role with no name, or an id or testid without a role and + name names no element: such a postcondition asserts nothing and never + passes. """ if pc.kind is not PostconditionKind.FOCUS_ON: return False if pc.focus_by in ("testid", "id"): - return bool(pc.focus_value and pc.focus_value.strip()) + # An id or testid names one element only together with its role and + # name: an id alone can move to another element between renders. + return bool( + pc.focus_value + and pc.focus_value.strip() + and pc.focus_role + and pc.focus_role.strip() + and pc.focus_name + and pc.focus_name.strip() + ) if pc.focus_by == "role_name": return bool( pc.focus_role diff --git a/packages/flow/tests/test_focus_on_postcondition.py b/packages/flow/tests/test_focus_on_postcondition.py index 1635b1ffb..c5e591eea 100644 --- a/packages/flow/tests/test_focus_on_postcondition.py +++ b/packages/flow/tests/test_focus_on_postcondition.py @@ -405,3 +405,48 @@ def test_an_unregistered_component_keeps_its_check_and_a_registered_one_does_not assert backend.focus_identity() is None finally: close() + + +# -- follow-up: no check for an element without a name ------------------------------------ + + +def test_an_id_or_testid_without_a_role_and_name_is_not_a_check(): + for after in ( + _focus(tag="div", role=None, name=None, id="pane-3", id_count=1), + _focus(tag="div", role="generic", name=None, testid="pane", testid_count=1), + ): + assert _focus_postcondition(BEFORE, after) is None + bare = Postcondition( + kind=PostconditionKind.FOCUS_ON, focus_by="id", focus_value="pane-3" + ) + assert not focus_identity_is_specific(bare) + assert is_vacuous(_key_step([bare])) is True + + +def test_a_plain_host_with_a_closed_shadow_root_gets_no_check(): + backend, close = _launch( + "
" + ) + try: + backend.page.evaluate("window._root.querySelectorAll('button')[1].focus()") + seen = backend.focus_identity() + # The page sees the host, which has an id but no role and name. + assert seen is not None and seen["id"] == "host" + assert _focus_postcondition(BEFORE, seen) is None + finally: + close() + + +def test_an_unnamed_div_whose_id_shifts_gets_no_check(): + backend, close = _launch( + "
3
4
" + ) + try: + backend.page.locator("#pane-3").focus() + seen = backend.focus_identity() + assert seen is not None and seen["id"] == "pane-3" + assert _focus_postcondition(BEFORE, seen) is None + finally: + close()