`` text, custom
+ widgets, shadow DOM) that a thin React a11y tree omits.
+
+ It only works for a Chromium/Electron/WebView2 window this layer owns
+ that was started with ``--remote-debugging-port`` (so touchpoint can
+ attach a CDP session and the merged window carries a ``cdp:`` id). When
+ that precondition isn't met, this **abstains** with
+ :class:`DomUnavailable` — it never falls back to a native read that
+ might look like content. Two shapes of "not readable" both abstain:
+
+ * touchpoint raises ``TouchpointError`` (its base type;
+ ``BackendUnavailableError`` — no CDP backend at all — is a subclass);
+ * the DOM walk returns *empty* — a native UIA window (no debugging
+ port), a genuinely blank page, or a window that has since vanished.
+
+ Ownership is re-checked first, exactly like every other read, so a
+ disowned handle raises :class:`UnownedWindow` (a safety error, not an
+ abstention) before any DOM work happens.
+ """
+ self._check_owned()
+ try:
+ els = _tp.elements(window_id=self._window_id, source="dom")
+ except _tp.TouchpointError as exc:
+ # Precise catch: touchpoint's own base exception, raised when the
+ # target is not a CDP-backed app / has no CDP backend. Not a
+ # blanket except — a genuine bug must still surface as a bug.
+ raise DomUnavailable(
+ f"Cannot read the DOM of window (app={self.app!r}, "
+ f"pid={self.pid}): {type(exc).__name__}: {exc}. The DOM-read "
+ f"path needs a Chromium/Electron/WebView2 target launched with "
+ f"--remote-debugging-port. Either launch it that way, or add "
+ f"ARIA roles/data-testid hooks so the accessibility-tree path "
+ f"can see the content. This is not a pass."
+ ) from exc
+ if els:
+ return els
+ raise DomUnavailable(
+ f"The live-DOM walk of window (app={self.app!r}, pid={self.pid}) "
+ f"returned nothing. This usually means the app is not CDP-backed "
+ f"(a native window with no --remote-debugging-port), the page is "
+ f"blank, or the window has closed — either way there is no DOM to "
+ f"assert on. Launch the app with remote debugging, or add "
+ f"ARIA/data-testid hooks for the a11y-tree path. Not a pass."
+ )
+
def states(self, query: str, *, role: str | None = None) -> tuple[str, ...]:
"""The element's current states as opaque strings, read fresh."""
return _states_of(self._resolve(query, role=role))
+ def read_dom_text(self, query: str, *, role: str | None = None) -> str:
+ """The element's DOM text/value, read fresh from a live-DOM walk (issue #37).
+
+ The DOM-read counterpart of :meth:`read_text`. Use it when the app is a
+ role-sparse React/div-soup Chromium UI whose accessibility tree is too
+ thin to assert on: this resolves ``query`` against the **actual DOM**
+ (unlabeled ``
`` text, custom widgets, shadow DOM) and returns the
+ element's live ``textContent``/``value``.
+
+ Read-only and additive — it does not touch the accessibility-tree path.
+ Pair it with a plain ``assert`` at the call site; on an unreadable DOM
+ it raises :class:`DomUnavailable`, which the pytest layer surfaces as an
+ abstention (CannotVerify), never a pass. A DOM that *is* readable but
+ does not contain ``query`` raises the ordinary :class:`ElementNotFound`
+ — a real "not there", the same as :meth:`read_text`, not an abstention.
+
+ Note: unlike :meth:`assert_text`, this primitive does **not** settle or
+ retry, so a React subtree that mounts a beat late may not be present on
+ the first read. Wrap it in your own retry, or use the DOM-sourced
+ settling assertions once they land (see ``planning/REACT_ROBUSTNESS.md``,
+ slice 3).
+
+ Raises:
+ DomUnavailable: the window isn't a readable CDP-backed DOM (abstention).
+ ElementNotFound: the DOM is readable but nothing matched ``query``.
+ AmbiguousElement: more than one DOM element matched.
+ UnownedWindow: the handle's PID is no longer owned (safety error).
+ """
+ el = self._match(self._dom_snapshot(), query, role=role)
+ text = _tp.get_text_content(el)
+ if text is None:
+ text = el.value if el.value is not None else ""
+ return str(text)
+
# -- Actions (always return None; success lives in the tree) -----------
def click(self, query: str, *, role: str | None = None) -> None:
diff --git a/tests/test_ui.py b/tests/test_ui.py
index 859a418..3327ad6 100644
--- a/tests/test_ui.py
+++ b/tests/test_ui.py
@@ -16,6 +16,7 @@
from dataclasses import dataclass, field
import pytest
+from touchpoint.core.exceptions import BackendUnavailableError, TouchpointError
from cyclaudes import ui
@@ -57,12 +58,27 @@ class FakeTouchpoint:
- ``set_value`` / ``close_window`` can be configured to *lie*: return
truthy success while changing nothing (the observed
``close_window: OK``-while-blocked failure).
+ - ``elements(source="dom")`` models touchpoint's CDP DOM-walk path
+ (issue #37): it serves ``dom_trees`` (the *actual DOM*, distinct from the
+ a11y ``trees``), or — when ``dom_raises`` is set — raises the real
+ ``TouchpointError``/``BackendUnavailableError`` a non-CDP target produces.
"""
+ #: The real touchpoint base exception, so ``ui._dom_snapshot`` can do a
+ #: precise ``except _tp.TouchpointError`` against the fake standing in for
+ #: the module. (Matches how the real module exposes it as ``tp.*``.)
+ TouchpointError = TouchpointError
+ BackendUnavailableError = BackendUnavailableError
+
def __init__(self):
self.wins: list[FakeWindow] = []
# window_id -> list of element spec dicts (name/role/raw_role/states/value)
self.trees: dict[str, list[dict]] = {}
+ # window_id -> DOM-walk spec dicts (the *real DOM*, source="dom").
+ self.dom_trees: dict[str, list[dict]] = {}
+ # When set, a source="dom" read raises this instead of returning — the
+ # not-CDP-backed / no-CDP-backend case touchpoint raises for.
+ self.dom_raises: Exception | None = None
self.generation = 0
self.issued_ids: set[str] = set()
self._live: dict[str, dict] = {} # latest-snapshot id -> spec
@@ -79,7 +95,34 @@ def windows(self):
self.windows_calls += 1
return list(self.wins)
- def elements(self, window_id=None, **kwargs):
+ def elements(self, window_id=None, source="full", **kwargs):
+ # DOM-walk path (issue #37): source="dom" reads the *actual DOM*, a
+ # different tree from the a11y projection, and on a non-CDP target
+ # touchpoint raises TouchpointError rather than returning.
+ if source == "dom":
+ if self.dom_raises is not None:
+ raise self.dom_raises
+ if window_id not in {w.id for w in self.wins}:
+ return []
+ self.generation += 1
+ self._live = {}
+ out = []
+ for i, spec in enumerate(self.dom_trees.get(window_id, [])):
+ el_id = f"cdp:9222:t1:dom:{self.generation * 100 + i},0"
+ self.issued_ids.add(el_id)
+ self._live[el_id] = spec
+ out.append(
+ FakeElement(
+ id=el_id,
+ name=spec.get("name", ""),
+ role=spec.get("role", "unknown"),
+ raw_role=spec.get("raw_role", ""),
+ states=list(spec.get("states", [])),
+ value=spec.get("value"),
+ )
+ )
+ return out
+
# Real touchpoint: a scoped read on a window that no longer exists
# comes back empty (verified live 2026-07-20). The fake must match, or
# it would report a live tree for a dead window and mask WindowGone.
@@ -949,3 +992,123 @@ def wipe(w):
with pytest.raises(ui.EmptyTree):
ui.reset_to_known_state(win, wipe)
+
+
+# ---------------------------------------------------------------------------
+# Issue #37: DOM-read path (read_dom_text) — reads the actual DOM, abstains
+# cleanly (never false-passes) when the target isn't a readable CDP-backed DOM
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture()
+def react_app(fake):
+ """A Chromium/Electron-style app with a *thin a11y tree* but a rich DOM.
+
+ Mirrors the #37 gap: the a11y projection (``trees``) is role-less div-soup
+ with no names to assert on, while the real DOM (``dom_trees``) carries the
+ rendered text. The window id is a ``cdp:`` id, the shape touchpoint's window
+ merge hands out for a CDP-backed app.
+ """
+ fake.wins = [FakeWindow(id="cdp:9222:t1", title="My React App", app="Electron", pid=7000)]
+ # a11y tree: unnamed generic containers — nothing a name query can bind to.
+ fake.trees["cdp:9222:t1"] = [
+ {"name": "", "role": "section", "raw_role": "generic", "states": ["visible"]},
+ {"name": "", "role": "section", "raw_role": "generic", "states": ["visible"]},
+ ]
+ # actual DOM: role-less
s whose text the a11y tree omits.
+ fake.dom_trees["cdp:9222:t1"] = [
+ {"name": "Total: 42", "role": "section", "raw_role": "div",
+ "states": ["visible"], "value": "Total: 42"},
+ {"name": "Checkout", "role": "button", "raw_role": "div",
+ "states": ["visible", "enabled"], "value": "Checkout"},
+ ]
+ return fake
+
+
+class TestDomRead:
+ def test_reads_dom_text_a_thin_ax_tree_omits(self, react_app):
+ ui.own(7000)
+ win = ui.owned_window(app="Electron", **FAST)
+ # The a11y path cannot see it — this is exactly the #37 gap.
+ with pytest.raises(ui.ElementNotFound):
+ win.read_text("Total")
+ # The DOM path reads the real rendered content.
+ assert win.read_dom_text("Total") == "Total: 42"
+ assert win.read_dom_text("Checkout") == "Checkout"
+
+ def test_abstains_when_touchpoint_raises_touchpointerror(self, react_app):
+ # Not a CDP app: touchpoint raises its own base error; we abstain.
+ react_app.dom_raises = TouchpointError("source='dom' is only supported for CDP apps")
+ ui.own(7000)
+ win = ui.owned_window(app="Electron", **FAST)
+ with pytest.raises(ui.DomUnavailable):
+ win.read_dom_text("Total")
+
+ def test_abstains_when_cdp_backend_unavailable(self, react_app):
+ # websocket-client missing entirely — BackendUnavailableError (a
+ # TouchpointError subclass). Still an abstention, never a pass.
+ react_app.dom_raises = BackendUnavailableError(
+ backend="cdp", reason="source='dom' requires a CDP backend"
+ )
+ ui.own(7000)
+ win = ui.owned_window(app="Electron", **FAST)
+ with pytest.raises(ui.DomUnavailable):
+ win.read_dom_text("Total")
+
+ def test_abstains_on_empty_dom_walk(self, fake):
+ # A native (non-CDP) window: the DOM walk returns nothing. Must abstain,
+ # not silently succeed or read something misleading.
+ fake.wins = [FakeWindow(id="w:1", title="Native", app="Notepad", pid=4242)]
+ fake.trees["w:1"] = [{"name": "Save", "role": "button", "states": ["enabled"]}]
+ # no dom_trees entry -> empty DOM walk
+ ui.own(4242)
+ win = ui.owned_window(app="Notepad", **FAST)
+ with pytest.raises(ui.DomUnavailable):
+ win.read_dom_text("Save")
+
+ def test_missing_query_in_readable_dom_is_not_found_not_abstention(self, react_app):
+ # DOM is readable but the queried element is genuinely absent: that is a
+ # real "not there" (ElementNotFound), NOT an abstention — same as the
+ # a11y read_text. A false abstention would hide a real missing element.
+ ui.own(7000)
+ win = ui.owned_window(app="Electron", **FAST)
+ with pytest.raises(ui.ElementNotFound) as exc:
+ win.read_dom_text("Nonexistent widget")
+ assert not isinstance(exc.value, ui.DomUnavailable)
+
+ def test_ambiguous_dom_match_raises_never_guesses(self, react_app):
+ react_app.dom_trees["cdp:9222:t1"] = [
+ {"name": "Item", "role": "section", "value": "Item one"},
+ {"name": "Item", "role": "section", "value": "Item two"},
+ ]
+ ui.own(7000)
+ win = ui.owned_window(app="Electron", **FAST)
+ with pytest.raises(ui.AmbiguousElement):
+ win.read_dom_text("Item")
+
+ def test_reads_fresh_every_call(self, react_app):
+ ui.own(7000)
+ win = ui.owned_window(app="Electron", **FAST)
+ assert win.read_dom_text("Total") == "Total: 42"
+ # The live DOM changed (React re-rendered); a fresh walk must reflect it,
+ # nothing cached across calls.
+ react_app.dom_trees["cdp:9222:t1"][0]["value"] = "Total: 99"
+ react_app.dom_trees["cdp:9222:t1"][0]["name"] = "Total: 99"
+ assert win.read_dom_text("Total") == "Total: 99"
+
+ def test_rechecks_ownership_and_refuses_after_disown(self, react_app):
+ ui.own(7000)
+ win = ui.owned_window(app="Electron", **FAST)
+ assert win.read_dom_text("Total") == "Total: 42"
+ ui.disown(7000)
+ # Ownership is a safety error, NOT an abstention — must fail loudly.
+ with pytest.raises(ui.UnownedWindow):
+ win.read_dom_text("Total")
+
+ def test_domunavailable_is_registered_as_an_abstention(self):
+ from cyclaudes import abstain
+
+ assert ui.DomUnavailable in ui.ABSTENTION_CONDITIONS
+ assert ui.DomUnavailable in abstain.abstention_types()
+ # Never catchable as an ordinary assertion failure.
+ assert not issubclass(ui.DomUnavailable, AssertionError)