From dd1382608898ac4fec8a1ab7e04916300aa657f1 Mon Sep 17 00:00:00 2001 From: Cameron Crow <157651944+CameronCrow@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:00:07 -0500 Subject: [PATCH] feat(#37): CDP DOM-read verification path + findings/design doc React/div-soup apps expose thin a11y trees, so structural verification has little to assert on even though the app renders fine (issue #37). Touchpoint's CDP seam can read the *actual DOM* (source="dom") for Chromium/Electron/WebView2 targets, catching role-less
text the a11y projection omits. Investigation is captured in planning/REACT_ROBUSTNESS.md: exactly what the CDP DOM walk gives us, how it decides a target is CDP-backed, why it fits the ui.py discipline (owned-only via PID, re-resolve fresh, abstain honestly), an honest list of what it does NOT solve (non-Chromium React, no-debug-port apps, canvas, native), and a scoped slice plan. Ships the one safe, small, fully-testable slice - an abstention-correct, read-only DOM-text reader: - DomUnavailable(UIError): new abstention condition, added to ABSTENTION_CONDITIONS (surfaces as CannotVerify, not pass/fail; not an AssertionError subclass). - WindowHandle.read_dom_text(query): resolves a name query against a fresh live-DOM walk of the owned window and returns its DOM text/value. Re-checks ownership first. Abstains DomUnavailable when the target isn't CDP-backed (touchpoint raises TouchpointError/BackendUnavailableError, or the walk is empty) - never false-passes. A readable DOM missing the query still raises the ordinary ElementNotFound (a real "not there"), not an abstention. - _resolve refactored to share _match(els, query, role) between the AX and DOM paths; AX behavior unchanged. Fake-driven tests (tests/test_ui.py::TestDomRead) cover: reading DOM text a thin AX tree omits, abstaining on TouchpointError / BackendUnavailableError / empty walk, ElementNotFound (not abstention) for an absent query, ambiguity, fresh-every-call, ownership re-check after disown, and abstention registration. Deferred to the live acceptance slice (needs a running React sample): proof a real CDP DOM walk returns div-soup content. Full suite green (242 passed). Co-Authored-By: Claude Opus 4.8 --- planning/REACT_ROBUSTNESS.md | 215 +++++++++++++++++++++++++++++++++++ src/cyclaudes/ui.py | 124 +++++++++++++++++++- tests/test_ui.py | 165 ++++++++++++++++++++++++++- 3 files changed, 500 insertions(+), 4 deletions(-) create mode 100644 planning/REACT_ROBUSTNESS.md diff --git a/planning/REACT_ROBUSTNESS.md b/planning/REACT_ROBUSTNESS.md new file mode 100644 index 0000000..b2b005f --- /dev/null +++ b/planning/REACT_ROBUSTNESS.md @@ -0,0 +1,215 @@ +--- +type: reference +tags: [repo/Cyclaudes, planning] +up: "[[Cyclaudes]]" +--- +# React / div-soup robustness — CDP DOM-read verification + +**Issue:** #37 — *robustness: React/div-soup apps expose thin a11y trees.* +**Status:** investigation done; one safe slice shipped (see [§6](#6-shipped-slice)); +the rest is a scoped plan, not yet built. +**Date:** 2026-07-23 + +## 1. The gap, precisely + +Cyclaudes verifies a live UI by reading its **accessibility (a11y) tree** through +touchpoint and asserting on element names/text/states (`ui.py`). Structural +verification can only assert on what the app *puts in that tree*. + +React (and other component frameworks) routinely ship **div-soup with no ARIA +roles or names** — clickable `
`s, unlabeled inputs, custom widgets that +mount but expose nothing semantic. On such a UI: + +- `WindowHandle._snapshot()` → `touchpoint.elements(window_id=…)` returns + unnamed generics or a near-empty tree; +- `_resolve(query)` finds nothing to bind a name query to → + `ElementNotFound`; +- `assert_text` / `assert_state` have nothing to bite on — *even though the app + renders fine to a human*. + +React is one of the most popular UI stacks, so "robust to React" is close to +"robust in general" for web / Electron / WebView2 targets. The 2026-07-22 LLT +dogfood (WebView2/Chromium) only worked because LLT happened to be reasonably +semantic; a role-sparse React app would give much less. + +## 2. What CDP actually gives us (verified against touchpoint source) + +Touchpoint ships a **Chrome DevTools Protocol (CDP) backend** +(`touchpoint/backends/cdp/cdp.py`, ~4200 lines) that connects over a WebSocket +to a Chromium/Electron `--remote-debugging-port`. Two distinct read paths: + +| Path | touchpoint call | CDP domain | What it reads | +|---|---|---|---| +| **CDP AX tree** | `elements(source="cdp_ax")` or a `cdp:` window id | `Accessibility.getFullAXTree` | Chromium's *in-browser a11y projection* — still thin for div-soup | +| **CDP DOM walk** | `elements(source="dom")` | `Runtime.evaluate` injecting a JS DOM walker | The **actual live DOM** | + +The **`source="dom"` path is the one that closes the #37 gap.** It injects +`_DOM_WALKER_JS` (a self-contained JS function) via `Runtime.evaluate` and walks +the real DOM from `document.body`, collecting every element that is *visible* and +either interactive or has text. Key properties, read off the walker source: + +- **Role-less `
` with text is captured.** The walker emits any node whose + `directText()` (its own text nodes, not inherited) is non-empty, or that is a + leaf with `textContent`. A `
Total: 42
` with no ARIA becomes an + element whose `name` is `"Total: 42"`. This is exactly what the a11y tree + omits. +- **Name priority:** `aria-label` > `title` > direct text > (leaf) full + `textContent`. +- **Inputs** report `value` (via `?? null`, so `""` is preserved). +- **Shadow DOM** is traversed (`node.shadowRoot.children`), so web-component + custom widgets are reachable. +- **`aria-hidden` subtrees are dropped** — consistent with a11y semantics. +- **States** are inferred (`disabled`, `checked`, `expanded`, `required`, + `readOnly`, `focused`). +- **`get_text_content(el)`** (used by `ui.read_text`) reads live + `textContent`/`value` via `Runtime.callFunctionOn` — real DOM text, not a + projection — for a DOM-sourced element id too (`_resolve_backend_node_id` + handles the `dom:` id shape). + +So: **for a Chromium-backed target, a role-sparse React app that abstains under +a11y-only reads exposes real, assertable text and structure through the DOM +walk.** That is a genuine, not speculative, capability. + +### How touchpoint decides a target is CDP-backed + +- **Discovery:** `discover_cdp_ports()` scans process command lines + (`/proc/*/cmdline` on Linux, PowerShell `Get-CimInstance` on Windows, `ps` on + macOS) for `--remote-debugging-port=N`, keeping the **main browser PID** (child + renderer/GPU processes are filtered by `--type=`). +- **`_is_cdp_app(app)` / `_is_cdp_id(window_id)`** — an id starting with `cdp:`, + or an app whose PID is in the CDP-owned PID set. +- **Window merge (critical for us):** `touchpoint.windows()` **replaces** the + native OS window of a CDP-owned PID with the CDP page-target window + (`[w for w in platform_wins if w.pid not in cdp_pids]` + the CDP windows). So + for a CDP-backed app, the window Cyclaudes resolves already carries a `cdp:` + id and the browser's main PID — no extra plumbing to "find the CDP handle". + +### ID shapes (all opaque to Cyclaudes — never parsed here) + +| Source | Example id | +|---|---| +| Windows UIA | `uia52` (churns on tree mutation) | +| CDP AX | `cdp:{port}:{targetId}:{nodeId}` | +| CDP DOM | `cdp:{port}:{targetId}:dom:{page_cx},{page_cy}` | + +Cyclaudes' contract already treats every id as an opaque handle (`ui.py` +portability rules), so these ride through unchanged. + +## 3. Does it fit the discipline? Yes — cleanly + +`ui.py` enforces: name-only API, re-resolve fresh every call, owned-only via +PID, abstain honestly (never false-pass). The DOM path fits each: + +- **Owned-only via PID.** A CDP window's `pid` is the browser main PID. + `is_owned(pid)` is set-membership **plus process ancestry** + (`_is_descendant_of_owned`), so an app Cyclaudes launched (or that re-exec'd + from a launched process) is owned; the DOM read runs `_check_owned()` first, + exactly like the AX read. A window we did *not* launch (e.g. attaching to + Cameron's already-open Chrome) is correctly **not** owned → refused. No new + ownership surface is needed. +- **Re-resolve fresh.** `get_dom_elements()` re-walks the live DOM on every call + — nothing is cached, element ids are recomputed each walk. The + "no cached ids" footgun stays impossible. +- **Abstain honestly.** When the target is not CDP-backed (no debugging port, + or `websocket-client` missing), the DOM path either raises + `touchpoint.TouchpointError` (`BackendUnavailableError` is a subclass) **or** + returns an empty list. Both must map to an **abstention**, never a pass. This + is the one piece of new wiring, and it is the shipped slice ([§6](#6-shipped-slice)). + +## 4. What CDP does **not** solve (honest limits) + +The DOM path is powerful *only for Chromium DOM that Cyclaudes owns and that was +started with remote debugging.* It does **not** help with: + +1. **Non-Chromium React.** React Native, and React rendered in a WebKit/Gecko + webview without a CDP endpoint. CDP is Chromium-only. +2. **Apps not launched with `--remote-debugging-port`.** No port ⇒ no DOM read. + Electron/Chrome need the flag at launch; WebView2 needs it via + `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS`. This is an **operational + precondition** the launcher must satisfy — see slice 4. Absent the flag, the + target stays a native UIA window and the DOM read abstains (correctly). +3. **Canvas / WebGL-rendered UIs** (Figma-style, some charting). The DOM walker + finds the `` element but no text inside it. This is Phase-4 vision + territory, not DOM. +4. **Native div-soup equivalents** — WinUI/Qt/GTK apps with unlabeled custom + controls. Not DOM at all; no CDP. +5. **Cross-origin iframes** need extra target-grafting (touchpoint's tree path + does some of this; the flat DOM walk does not fully). Out of scope for the + first slices. +6. **Async render timing.** A React tree can mount content a beat after the + window is "ready". The a11y-side answer is `wait_until_ready(signal=…)` + + settling asserts; the DOM read needs the same settle treatment (slice 3). The + shipped read-only primitive (slice 1) does **not** settle — a caller must pair + it with retry or use the slice-3 asserts once they exist. + +Where none of the above applies, the honest answer remains: **abstain, and tell +the dev to add ARIA roles / `data-testid`-style hooks** (issue #37 lead 3). +Never a silent pass. + +## 5. Scoped plan (slices → issues) + +Ordered; each is independently shippable and testable with fakes. Slice 1 is +done. + +1. **Abstention-correct DOM-text read primitive** *(SHIPPED, [§6](#6-shipped-slice))*. + `WindowHandle.read_dom_text(query)` + `DomUnavailable` abstention. Reads real + DOM text when the owned window is CDP-backed; abstains (never false-passes) + when it isn't. Read-only, additive, does not touch the AX hot path. +2. **DOM-aware element resolution polish.** Optional `role=` filtering and + ambiguity messaging parity with the AX `_resolve` (already reused via + `_match`); add `exists_dom` / `states_dom` read-only helpers if checks need + them. Small. +3. **DOM-sourced settling assertions.** `assert_text(..., source="dom")`, + `assert_exists`, `assert_state`, `assert_gone` that snapshot the DOM inside + the existing `_settle` loop (so async React renders get ret/retry) and abstain + `DomUnavailable` at the deadline when the DOM can't be read. This is where the + real check-author ergonomics land. Medium; the settle machinery already + exists. +4. **Launcher support for remote debugging.** Teach `app_session` (`pytest_ui.py`) + to inject `--remote-debugging-port` (Electron/Chrome) or the WebView2 env var, + confirm the resolved owned window is a `cdp:` window, and **abstain with + guidance** ("could not make CDP-backed; add `--remote-debugging-port` or + add ARIA hooks") when it can't. Without this, the DOM path only works for apps + a human already started with debugging on. Medium; touches process launch. +5. **Acceptance proof.** A deliberately role-sparse React sample where a11y-only + verification abstains **and** the DOM path asserts real content — plus the + mirror proof that with the debugging port *absent* the same check **abstains, + never false-passes**. Mirrors `tests/test_acceptance_phase2.py` / + `_phase4.py`. This is the criterion in issue #37's Acceptance section. + +## 6. Shipped slice + +`ui.py` now has a read-only DOM-text reader that runs through the full +discipline and abstains cleanly when the target is not CDP-backed: + +- **`DomUnavailable(UIError)`** — a new abstention condition, added to + `ABSTENTION_CONDITIONS` (so the pytest layer surfaces it as *CannotVerify*, its + own outcome and exit code, not a pass and not a fail). It is not an + `AssertionError` subclass, so a broad `except AssertionError` can't swallow it. +- **`WindowHandle.read_dom_text(query, *, role=None)`** — resolves the name query + against a **fresh live-DOM walk** of the owned window and returns the element's + DOM text/value. Ownership is re-checked first (`_check_owned`), same as every + other read. When the window is not CDP-backed — touchpoint raises + `TouchpointError`/`BackendUnavailableError`, *or* the DOM walk comes back empty + (native UIA window, blank page, or the window vanished) — it raises + `DomUnavailable` rather than inventing a result. A non-empty DOM in which the + query simply isn't present still raises the ordinary `ElementNotFound` (a real + "not there", consistent with the AX `read_text`), never an abstention. +- **`_resolve` refactor.** The name-matching body is extracted to + `WindowHandle._match(els, query, role=…)` so the AX path (`_snapshot`) and the + DOM path (`_dom_snapshot`) share identical exact→ci→substring matching and + ambiguity behavior. The AX path's observable behavior is unchanged. + +What the shipped slice deliberately does **not** do: settle/retry (slice 3), +DOM-sourced `assert_*` (slice 3), or launcher flag injection (slice 4). It is a +primitive a check pairs with a plain `assert` today; on abstention that assert's +`DomUnavailable` is caught by the abstention layer. + +**Test coverage** (`tests/test_ui.py::TestDomRead`, fake-driven): reads real DOM +text a thin AX tree omits; abstains `DomUnavailable` when touchpoint raises +`TouchpointError`; abstains `DomUnavailable` on an empty DOM walk (not-CDP +window); `ElementNotFound` (not abstention) when the DOM is non-empty but the +query is absent; re-checks ownership and raises `UnownedWindow` after `disown`; +`DomUnavailable` is registered as an abstention type. The one thing fakes cannot +prove — that a *real* CDP DOM walk returns div-soup content — is deferred to the +live acceptance proof (slice 5), which needs a running Chromium React sample. diff --git a/src/cyclaudes/ui.py b/src/cyclaudes/ui.py index 136e46e..7ad64f7 100644 --- a/src/cyclaudes/ui.py +++ b/src/cyclaudes/ui.py @@ -101,6 +101,7 @@ "ActionNotVerified", "AmbiguousElement", "AmbiguousWindow", + "DomUnavailable", "ElementNotFound", "EmptyTree", "NoOwnedWindows", @@ -194,6 +195,28 @@ class EmptyTree(UIError): """ +class DomUnavailable(UIError): + """A DOM-level read was requested but the target isn't a readable Chromium DOM. + + The DOM-read path (``read_dom_text``) closes the React/div-soup gap + (issue #37) by walking the *actual DOM* through touchpoint's CDP seam, + which sees content a thin accessibility tree omits. That only works for a + Chromium/Electron/WebView2 window this layer owns **and** that was launched + with ``--remote-debugging-port`` — so touchpoint can attach a CDP session. + + When that precondition isn't met — no CDP backend (``websocket-client`` + absent, or the app has no debugging port, so the window is a native UIA + window), or the live DOM walk comes back empty — there is nothing to read. + That is a distinct *abstention* condition, not a pass and not a failure: + "I could not read the DOM here" must never read as "the content is fine". + Hence this is in :data:`ABSTENTION_CONDITIONS` and, like the others, is + **not** an :class:`AssertionError` subclass, so a broad ``except + AssertionError`` can't swallow it. The honest fallbacks are: launch the app + with remote debugging (so the DOM becomes readable), or add ARIA + roles/``data-testid`` hooks so the a11y-tree path can see the content. + """ + + class ActionNotVerified(UIError): """An action was dispatched but its effect never appeared in the tree. @@ -210,7 +233,7 @@ class UIAssertionError(UIError, AssertionError): #: "this check failed". The pytest layer (issue #1) maps these onto its #: abstention outcome (``CannotVerify``); this module deliberately does not #: define that type itself. -ABSTENTION_CONDITIONS: tuple[type[UIError], ...] = (EmptyTree, WindowGone) +ABSTENTION_CONDITIONS: tuple[type[UIError], ...] = (EmptyTree, WindowGone, DomUnavailable) # Wire the seam (issue #3): tell the abstention plugin to treat these as # abstentions, so an empty tree or a vanished window surfaces as "cannot @@ -784,13 +807,23 @@ def _snapshot(self): ) def _resolve(self, query: str, *, role: str | None = None): - """Resolve a name query against a fresh snapshot; raise rather than guess. + """Resolve a name query against a fresh AX snapshot; raise rather than guess. Match rule: exact name, else unique case-insensitive exact, else unique case-insensitive substring. More than one candidate at the deciding stage raises :class:`AmbiguousElement`. """ - els = self._snapshot() + return self._match(self._snapshot(), query, role=role) + + def _match(self, els, query: str, *, role: str | None = None): + """Bind ``query`` to exactly one element in ``els`` or raise. + + The pure matching core shared by the AX path (:meth:`_resolve`, fed by + :meth:`_snapshot`) and the DOM path (:meth:`read_dom_text`, fed by + :meth:`_dom_snapshot`), so both apply *identical* exact→case-insensitive + →substring resolution and the same never-guess ambiguity behaviour + regardless of which tree the elements came from. + """ pool = [el for el in els if role is None or _role_matches(el, role)] exact = [el for el in pool if el.name == query] @@ -871,10 +904,95 @@ def read_text(self, query: str, *, role: str | None = None) -> str: text = el.value if el.value is not None else "" return str(text) + def _dom_snapshot(self): + """A fresh live-DOM walk of this window, or abstain if it isn't readable. + + The DOM-read counterpart of :meth:`_snapshot` (issue #37). Where + ``_snapshot`` reads the accessibility *projection*, this reads the + **actual DOM** through touchpoint's ``source="dom"`` CDP path, which + exposes role-less div-soup content (unlabeled ``
`` 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)