diff --git a/simdrive/CHANGELOG.md b/simdrive/CHANGELOG.md index 8454c8ec..e2c650c5 100644 --- a/simdrive/CHANGELOG.md +++ b/simdrive/CHANGELOG.md @@ -13,6 +13,20 @@ filter catch what slips through. --> +## [1.0.0b13] — 2026-06-23 + +### Changed — internal hardening for the iOS-26 content-group path (no behavior change) + +- **`perform_accessibility_action` does less work on the not-found path.** When + a requested action isn't found, the tool now resolves the iOS content group a + single time and reuses it for both the carrier search and the WKWebView + boundary hint, instead of resolving it twice (each resolve re-runs a position- + probe and a full tree-walk). Same results, fewer redundant probes. +- **Test hardening only — no user-facing change.** Added direct coverage for the + `AXFrame` → rectangle parser (CGRect-struct, tuple-fallback, and malformed + inputs) and pinned the content-group position-probe's "stop at the nearest + group container" walk so the behavior can't silently regress. + ## [1.0.0b12] — 2026-06-23 ### Fixed — host-AX `set_text` / `perform_accessibility_action` now reach app content on iOS 26 diff --git a/simdrive/pyproject.toml b/simdrive/pyproject.toml index 93503c13..e2d1244c 100644 --- a/simdrive/pyproject.toml +++ b/simdrive/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "simdrive" -version = "1.0.0b12" +version = "1.0.0b13" description = "SimDrive — reproduce and validate iOS bugs in 60 seconds with Claude. MCP-native iOS automation; 32 vision-first tools; record once, replay free in CI. 14-day trial, then $29/mo Pro." readme = "README.md" license = "Elastic-2.0" diff --git a/simdrive/src/simdrive/ax.py b/simdrive/src/simdrive/ax.py index bdeb2821..e09faa87 100644 --- a/simdrive/src/simdrive/ax.py +++ b/simdrive/src/simdrive/ax.py @@ -511,11 +511,14 @@ def perform_action( # Explicit target didn't carry it — widen to the whole window. carrier = _find_action_carrier(window, name) + group = None if carrier is None: # iOS 26: the plain window walk can bottom out at an opaque, childless # ``iOSContentGroup`` AXGroup. Resolve the content group via the # heuristic/position-probe and DFS its subtree — this is what surfaces # the real custom-action carriers (e.g. the reader's "Where am I?"). + # Resolve ONCE here and reuse below for the WKWebView hint (each resolve + # re-fires a position-probe + full DFS, so don't repeat it). group = _resolve_content_group(window) if group is not None and group is not window: carrier = _find_action_carrier(group, name) @@ -527,7 +530,7 @@ def perform_action( # Keep it accurate — only when zero actions exist window-wide. iOS 26: # also probe the resolved content-group subtree (the window walk alone # can't see past an opaque ``iOSContentGroup``) before concluding zero. - group = _resolve_content_group(window) + # ``group`` was already resolved on the not-found path above; reuse it. has_actions = _window_has_any_custom_action(window) or ( group is not None and group is not window diff --git a/simdrive/tests/test_ax_module.py b/simdrive/tests/test_ax_module.py index 27c78f55..40907802 100644 --- a/simdrive/tests/test_ax_module.py +++ b/simdrive/tests/test_ax_module.py @@ -183,6 +183,81 @@ def _attr(e, a): monkeypatch.setattr(ax, "_action_names", lambda e: e.actions) +# ── _frame: AXFrame → CGRect parsing (real impl, not monkeypatched) ─────────── +# +# Every content-group test monkeypatches ``ax._frame`` away, so neither its +# CGRect-struct path nor its documented fallbacks are exercised there. These +# drive the REAL ``_frame`` against a stubbed ApplicationServices module. + + +def _stub_app_services(monkeypatch, *, copy_ret, getvalue_ret): + """Install an ApplicationServices stub for the lazy import inside _frame. + + ``copy_ret`` is the ``(err, val)`` AXUIElementCopyAttributeValue returns; + ``getvalue_ret`` is the ``(ok, rect)`` AXValueGetValue returns. + """ + monkeypatch.setitem( + __import__("sys").modules, + "ApplicationServices", + types.SimpleNamespace( + AXUIElementCopyAttributeValue=lambda elem, attr, _none: copy_ret, + AXValueGetValue=lambda val, kind, _none: getvalue_ret, + kAXValueCGRectType=4, + ), + ) + + +class _CGPoint: + def __init__(self, x, y): + self.x, self.y = x, y + + +class _CGSize: + def __init__(self, w, h): + self.width, self.height = w, h + + +class _CGRect: + def __init__(self, x, y, w, h): + self.origin = _CGPoint(x, y) + self.size = _CGSize(w, h) + + +def test_frame_parses_cgrect_struct(monkeypatch): + rect = _CGRect(10.0, 20.0, 390.0, 844.0) + _stub_app_services(monkeypatch, copy_ret=(0, object()), getvalue_ret=(True, rect)) + assert ax._frame(object()) == {"x": 10.0, "y": 20.0, "width": 390.0, "height": 844.0} + + +def test_frame_falls_back_to_4tuple(monkeypatch): + # Some pyobjc versions return AXValueGetValue's rect as an (x, y, w, h) tuple + # rather than a struct with .origin/.size — the documented AttributeError path. + _stub_app_services( + monkeypatch, copy_ret=(0, object()), getvalue_ret=(True, (1.0, 2.0, 3.0, 4.0)) + ) + assert ax._frame(object()) == {"x": 1.0, "y": 2.0, "width": 3.0, "height": 4.0} + + +def test_frame_none_when_attribute_read_fails(monkeypatch): + # Non-zero err from AXUIElementCopyAttributeValue → None. + _stub_app_services(monkeypatch, copy_ret=(-25204, None), getvalue_ret=(False, None)) + assert ax._frame(object()) is None + + +def test_frame_none_when_value_unwrap_fails(monkeypatch): + # AXValueGetValue reports ok=False (value isn't a CGRect) → None. + _stub_app_services(monkeypatch, copy_ret=(0, object()), getvalue_ret=(False, None)) + assert ax._frame(object()) is None + + +def test_frame_none_when_rect_malformed(monkeypatch): + # A rect that is neither a struct (no .origin) nor a 4-tuple → None fallback. + _stub_app_services( + monkeypatch, copy_ret=(0, object()), getvalue_ret=(True, (1.0, 2.0)) + ) + assert ax._frame(object()) is None + + # ── aspect-ratio heuristic ──────────────────────────────────────────────────── @@ -210,13 +285,20 @@ def test_find_ios_content_group_none_when_no_portrait_child(monkeypatch): def test_position_probe_hits_window_centre(monkeypatch): - # The probe hit-tests the window centre and walks up to a group container — - # this is the path that resolves real content when AXChildren is empty. + # The probe hit-tests the window centre and walks up to the NEAREST group + # container — this is the path that resolves real content when AXChildren is + # empty. The hit element's ancestor chain is + # real(AXStaticText) -> container(AXGroup) -> outer(AXWindow), so the up-walk + # MUST stop at ``container`` (the nearest AXGroup). This pins the + # stop-at-AXGroup semantics: if AXGroup were dropped from the stop-set, the + # walk would over-climb past ``container`` up to ``outer`` and this test + # would fail (mutation guard). + window = _Node(role="AXWindow", frame={"x": 100, "y": 100, "width": 400, "height": 800}) real = _Node(role="AXStaticText", desc="hello") container = _Node(role="AXGroup", children=[real, _Node(role="AXTextField")]) real.parent_container = container + container.parent_container = window # nearest-group walk must NOT reach here - window = _Node(role="AXWindow", frame={"x": 100, "y": 100, "width": 400, "height": 800}) _wire_fakes(monkeypatch) monkeypatch.setattr(ax, "_app_element", lambda: object())