Skip to content
Closed
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
14 changes: 14 additions & 0 deletions simdrive/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion simdrive/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion simdrive/src/simdrive/ax.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
88 changes: 85 additions & 3 deletions simdrive/tests/test_ax_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────


Expand Down Expand Up @@ -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())

Expand Down
Loading