From 63336ac96890a01376b3e759daffce7b7420276f Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 4 Jun 2026 13:26:12 -0400 Subject: [PATCH 1/2] Deepen Firefox address-field navigation (resilient subtree search) Mozilla restructures Firefox's a11y tree between releases; FF151 inserted a .urlbar-input-container wrapper that broke the rigid direct-child path behind NVDA+A (Address not found), and also silently broke the notification domain-tagging path in event_alert. - B: add findInSubtree(anchor, predicate) + byIA2Attribute() to shared -- an anchored descendant search that tolerates wrapper elements between a stable anchor and the target, where searchObject's direct-child path could not. - A: extract addressField()/getURL() in firefox.py as the single home for locating the URL-bearing element. FF>=133 unified to anchor #urlbar then findInSubtree(#urlbar-input), subsuming both .urlbar-input-box (133-150) and .urlbar-input-container (151+). Rewire script_url AND event_alert to it. - C: searchObject now logs which milestone missed and the ids/classes present at that level, so the next restructure self-reports from a user's NVDA log. Establish a tests/ harness (plain pytest, NVDA runtime faked via sys.modules) with FF151/pre-151/pre-133 fixtures, anchored by a regression test for the exact FF151 break. Add CONTEXT.md with 'address field' as the first term. --- CONTEXT.md | 36 ++++++ addon/appModules/firefox.py | 56 ++++++--- addon/appModules/shared/__init__.py | 58 ++++++++- tests/conftest.py | 175 ++++++++++++++++++++++++++++ tests/fakes.py | 143 +++++++++++++++++++++++ tests/test_addressField.py | 45 +++++++ tests/test_findInSubtree.py | 74 ++++++++++++ tests/test_searchObject_logging.py | 36 ++++++ 8 files changed, 607 insertions(+), 16 deletions(-) create mode 100644 CONTEXT.md create mode 100644 tests/conftest.py create mode 100644 tests/fakes.py create mode 100644 tests/test_addressField.py create mode 100644 tests/test_findInSubtree.py create mode 100644 tests/test_searchObject_logging.py diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..a46f71f --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,36 @@ +# CONTEXT — Mozilla Apps Enhancements + +Domain glossary for this NVDA add-on. The add-on reads Firefox and Thunderbird +UI by walking their IAccessible2 (a11y) trees. Those trees are **not** a stable +interface — Mozilla restructures them between releases — so the recurring domain +problem is "navigate an a11y subtree whose shape changed." + +Use these terms (not synonyms) in code, tests, issues, and proposals. + +## Glossary + +### address field +The element in Firefox's toolbar that bears the page URL — the `#urlbar-input` +combobox. "Find the address field" is a single piece of version/DOM knowledge +with one home: `addressField(foreground, ffVersion)` in +`addon/appModules/firefox.py` (with `getURL()` as a thin reader over it). Both +`script_url` (the NVDA+A "read address bar" command) and `event_alert` (which +tags notifications with the page domain) locate it through that function — they +must not re-encode the path themselves. + +From Firefox 133 on, the address field is located resiliently: anchor on the +stable `#urlbar`, then `findInSubtree` for `#urlbar-input` at any depth. This +survives Mozilla inserting wrapper elements between `#urlbar` and the input — +e.g. `.urlbar-input-box` (FF133–150) and `.urlbar-input-container` (introduced +in FF151, the change that broke NVDA+A and prompted this work). Pre-133 Firefox +trees genuinely differ and keep explicit version paths. + +### anchored descendant search +The resilient navigation primitive: `findInSubtree(anchor, predicate)` in +`addon/appModules/shared/__init__.py`. Given a **stable anchor** object and a +predicate, it returns the first descendant (depth-first) that matches, at any +depth — tolerating wrapper elements inserted between anchor and target. Contrast +with `searchObject(path)`, the rigid sibling primitive that matches a fixed +sequence of **direct-child** milestones and fails the moment a wrapper appears. +Build predicates with `byIA2Attribute(key, value)` (exact match — so searching +for id `urlbar-input` never matches `urlbar-input-container`). diff --git a/addon/appModules/firefox.py b/addon/appModules/firefox.py index 5eb0030..61ff890 100644 --- a/addon/appModules/firefox.py +++ b/addon/appModules/firefox.py @@ -45,6 +45,42 @@ addonHandler.initTranslation() +def addressField(foreground, ffVersion): + """Locate Firefox's address field -- the object bearing the page URL. + + This is the single home for "how do I find the URL in this Firefox version's + a11y tree". Firefox restructures that subtree between releases; rather than + spread the knowledge across callers, both script_url and event_alert come + here. + + From FF133 on the subtree is navigated resiliently: anchor on the stable + #urlbar, then search its descendants for #urlbar-input. That single path + survives Mozilla inserting wrapper elements -- the .urlbar-input-box of + FF133-150 and the .urlbar-input-container introduced in FF151 are both simply + traversed. The genuinely different pre-133 trees keep their explicit paths: + those a11y layouts can't be verified now and don't share the #urlbar anchor. + """ + if ffVersion >= 133: + urlbar = shared.searchObject((("id", "nav-bar"), ("id", "urlbar")), foreground) + return shared.findInSubtree(urlbar, shared.byIA2Attribute("id", "urlbar-input")) + if ffVersion < 70: + path = (("id", "nav-bar"), ("id", "urlbar"), ("id", "identity-box")) + elif ffVersion < 76: + path = (("id", "nav-bar"), ("id", "identity-box"), ("id", "identity-icon")) + elif ffVersion < 87: + path = (("id", "nav-bar"), ("id", "identity-box")) + else: # FF 87..132 + path = (("id", "nav-bar"), ("class", "urlbar-input-box"), ("id", "urlbar-input")) + return shared.searchObject(path, foreground) + +def getURL(foreground, ffVersion): + """Return the page URL string from the address field, or None. A thin reader + over addressField for callers that just want the value.""" + field = addressField(foreground, ffVersion) + if not field: + return None + return field.value if getattr(field, "value", None) else None + class AppModule(AppModule): #TRANSLATORS: category for Firefox input gestures @@ -77,10 +113,10 @@ def event_alert(self, obj, nextHandler): return alertText = shared.getAlertText(obj) # Appends the domain of the page where it was when the alert originated. - path = (("id", "nav-bar"), ("id", "urlbar"), ("class", "urlbar-input textbox-input",)) - url = shared.searchObject(path) - if url and url.value: - url = url.value if "://" in url.value else "None://"+url.value + ffVersion = int(self.productVersion.split(".")[0]) + url = getURL(api.getForegroundObject(), ffVersion) + if url: + url = url if "://" in url else "None://"+url domain = urlparse(url).hostname if url else "" if domain and domain not in alertText: alertText = "%s\n\n%s" % (alertText, domain) shared.notificationsDialog.registerFirefoxNotification((datetime.now(), alertText)) @@ -123,17 +159,7 @@ def script_url(self, gesture): ui.message(_("Not available here")) return ffVersion = int(self.productVersion.split(".")[0]) - if ffVersion < 70: - path = (("id", "nav-bar"), ("id", "urlbar"), ("id", "identity-box",)) - elif ffVersion < 76: - path = (("id", "nav-bar"), ("id", "identity-box"), ("id", "identity-icon")) - elif ffVersion < 87: - path = (("id", "nav-bar"), ("id", "identity-box")) - elif ffVersion < 133: - path = (("id", "nav-bar"), ("class" ,"urlbar-input-box"), ("id","urlbar-input")) - else: - path = (("id","nav-bar"),("id","urlbar"),("class","urlbar-input-box")) - secInfoButton = shared.searchObject(path) + secInfoButton = addressField(api.getForegroundObject(), ffVersion) if secInfoButton: securInfo = secInfoButton.description # This has changed in FF 57. Keeping this line for compatibility with earlier versions. try: # This one is for FF 57 and later. diff --git a/addon/appModules/shared/__init__.py b/addon/appModules/shared/__init__.py index 06b9802..055f7e8 100644 --- a/addon/appModules/shared/__init__.py +++ b/addon/appModules/shared/__init__.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from gui import guiHelper +from logHandler import log from NVDAObjects.IAccessible import IAccessible from threading import Timer import addonHandler @@ -81,11 +82,32 @@ def getAlertText(alertPopup): def searchObject(path, startAtObject=None): obj = startAtObject if startAtObject else api.getForegroundObject() for milestone in path: - obj = searchAmongTheChildren(milestone, obj) + parent = obj + obj = searchAmongTheChildren(milestone, parent) if not obj: + # Mozilla restructures these a11y trees between releases; when a + # milestone goes missing the only previous signal was a generic + # "not found" spoken to the user. Record which milestone missed and + # what was actually there, so the next restructure self-reports from + # a user's NVDA log instead of needing a hand-injected probe. + _logSearchMiss(milestone, parent) return return obj +def _logSearchMiss(milestone, parent): + key, value = milestone + present = [] + obj = parent.firstChild if parent else None + while obj: + attributes = getattr(obj, "IA2Attributes", None) + if attributes: + identifier = attributes.get("id") or attributes.get("class") or attributes.get("tag") + if identifier: + present.append(identifier) + obj = obj.next + log.info("searchObject: milestone %s=%r not found; present at this level: %s" % ( + key, value, ", ".join(present) if present else "(none)")) + def searchAmongTheChildren(id, into): if not into: return(None) @@ -101,6 +123,40 @@ def searchAmongTheChildren(id, into): obj = obj.next return(obj) +def byIA2Attribute(key, value): + """Build a predicate for findInSubtree that matches an object whose + IA2Attribute `key` equals `value` exactly. Tolerates objects with no + IA2Attributes. Exact (not prefix) match -- so searching for id + "urlbar-input" never accidentally matches "urlbar-input-container".""" + def predicate(obj): + attributes = getattr(obj, "IA2Attributes", None) + if not attributes or key not in attributes: + return False + return attributes[key] == value + return predicate + +def findInSubtree(anchor, predicate): + """Anchored descendant search: starting from a stable `anchor` object, + return the first descendant (depth-first, pre-order) for which `predicate` + is true, or None. Unlike searchObject's direct-child milestone walk, this + tolerates any number of wrapper elements between the anchor and the target + -- the resilience the rigid path lacks (an inserted wrapper kills the + rigid path; here it is simply traversed).""" + if not anchor: + return None + obj = anchor.firstChild + while obj: + try: + if predicate(obj): + return obj + except (AttributeError, KeyError): + pass + found = findInSubtree(obj, predicate) + if found: + return found + obj = obj.next + return None + class TabPanel(wx.Panel): def __init__(self, parent, lbLabel): diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a4a8120 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,175 @@ +# Test harness for the Mozilla Apps Enhancements add-on. +# +# The add-on's modules import the NVDA runtime (api, controlTypes, wx, gui, ...) +# at import time, and shared/__init__.py even instantiates a wx.Dialog. None of +# that exists outside NVDA. So before importing anything from the add-on we +# register permissive fake modules in sys.modules. The pieces under test -- +# the a11y-tree search helpers -- only touch plain object attributes +# (.firstChild / .next / .IA2Attributes / .value), so faking the runtime is +# enough to exercise them against the fake trees in fakes.py. + +import builtins +import os +import sys +import types + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +APPMODULES = os.path.join(REPO_ROOT, "addon", "appModules") + +# The add-on calls the translation lookup `_` (and occasionally `pgettext`) at +# import time; NVDA installs them as builtins via addonHandler.initTranslation(). +builtins._ = lambda s: s +builtins.pgettext = lambda context, s: s + + +class _Anything: + """A permissive stand-in: any attribute access, call, or iteration is benign. + + Returned for every attribute the add-on reads off a faked runtime module. It + is callable (so `wx.TextCtrl(...)` works), attribute-transparent (so + `speech.speech.SpeechMode` works), and iterates empty (so + `next(filter(..., addonHandler.getRunningAddons()))` raises StopIteration). + """ + + def __init__(self, *args, **kwargs): + pass + + def __call__(self, *args, **kwargs): + return _Anything() + + def __getattr__(self, name): + return _Anything() + + def __iter__(self): + return iter(()) + + def __bool__(self): + return True + + # wx style flags get OR'd together (wx.TE_MULTILINE | wx.TE_READONLY). + def __or__(self, other): + return _Anything() + + def __ror__(self, other): + return _Anything() + + def __and__(self, other): + return _Anything() + + def __rand__(self, other): + return _Anything() + + +class _Base: + """A real, subclassable base for runtime classes the add-on inherits from + (wx.Panel, wx.Dialog, appModuleHandler.AppModule). Instances answer any + unknown attribute with _Anything so partially-constructed widgets don't + explode during a faked __init__.""" + + def __init__(self, *args, **kwargs): + pass + + def __getattr__(self, name): + return _Anything() + + +class _LogRecorder: + """Captures log calls so the observability tests (candidate C) can assert + on what searchObject reported when a milestone went missing.""" + + def __init__(self): + self.records = [] + + def _record(self, level, msg, *args): + try: + rendered = msg % args if args else msg + except Exception: + rendered = msg + self.records.append((level, rendered)) + + def info(self, msg, *args, **kwargs): + self._record("info", msg, *args) + + def debug(self, msg, *args, **kwargs): + self._record("debug", msg, *args) + + def warning(self, msg, *args, **kwargs): + self._record("warning", msg, *args) + + def error(self, msg, *args, **kwargs): + self._record("error", msg, *args) + + def clear(self): + self.records.clear() + + +class _PermModule(types.ModuleType): + """A module whose every unknown attribute resolves to _Anything.""" + + def __getattr__(self, name): + return _Anything() + + +def _register(name, **attrs): + module = _PermModule(name) + for key, value in attrs.items(): + setattr(module, key, value) + sys.modules[name] = module + return module + + +# Shared singletons exposed to tests. +log_recorder = _LogRecorder() + + +def _install_fake_runtime(): + # Heavy GUI / runtime modules: permissive fakes. + _register("gui") + _register("speech") + _register("ui") + _register("api") + _register("controlTypes") + _register("winUser") + _register("globalCommands") + + # wx: Panel and Dialog must be real classes (they get subclassed); the rest + # (BoxSizer, ListBox, Button, constants, ...) come through __getattr__. + _register("wx", Panel=_Base, Dialog=_Base) + + # NVDAObjects.IAccessible[.mozilla]: nested package of permissive fakes. + _register("NVDAObjects") + _register("NVDAObjects.IAccessible") + _register("NVDAObjects.IAccessible.mozilla") + + # addonHandler: initTranslation is a no-op; no DeveloperToolkit running, so + # firefox.py falls through to the core AppModule import. + _register( + "addonHandler", + initTranslation=lambda: None, + getRunningAddons=lambda: [], + ) + + # scriptHandler: a real pass-through decorator keeps the @script-decorated + # methods callable instead of replacing them with a fake. + _register( + "scriptHandler", + script=lambda *a, **k: (lambda f: f), + getLastScriptRepeatCount=lambda: 0, + ) + + # appModuleHandler: provides the base AppModule that firefox.py subclasses + # (nvdaBuiltin isn't registered, so the ModuleNotFoundError fallback fires). + _register("appModuleHandler", AppModule=_Base) + + # logHandler.log records into the shared recorder for candidate-C assertions. + _register("logHandler", log=log_recorder) + + # Make `appModules` a namespace package pointing at the real add-on source, + # so `import appModules.shared` works and firefox.py's `from . import shared` + # resolves correctly. + appmodules = types.ModuleType("appModules") + appmodules.__path__ = [APPMODULES] + sys.modules["appModules"] = appmodules + + +_install_fake_runtime() diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..f9a71dd --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,143 @@ +# Lightweight fake NVDAObject trees for exercising the a11y-tree search helpers. +# +# A real NVDAObject exposes a sprawling COM-backed interface; the search helpers +# only ever touch .firstChild, .next, .IA2Attributes, .value, .name and .role. +# FakeObj implements exactly that surface over a plain list of children, so a +# test fixture is just a literal tree -- no NVDA, no Firefox, no COM. + +_MISSING = object() + + +class FakeObj: + """A stand-in NVDAObject. + + ia2: dict of IA2Attributes (id / class / tag). Pass ia2=None to model a + node that has *no* IA2Attributes attribute at all -- the helpers must + tolerate that (real trees contain such nodes). + """ + + def __init__(self, ia2=_MISSING, value=None, name=None, role=None, children=None): + if ia2 is _MISSING: + ia2 = {} + if ia2 is not None: + self.IA2Attributes = ia2 + # else: deliberately leave IA2Attributes unset. + self.value = value + self.name = name + self.role = role + self.parent = None + self._children = list(children or []) + self.next = None + previous = None + for child in self._children: + child.parent = self + if previous is not None: + previous.next = child + previous = child + + @property + def firstChild(self): + return self._children[0] if self._children else None + + @property + def children(self): + return list(self._children) + + @property + def recursiveDescendants(self): + for child in self._children: + yield child + yield from child.recursiveDescendants + + def __repr__(self): + ident = getattr(self, "IA2Attributes", {}) + label = ident.get("id") or ident.get("class") or ident.get("tag") or "?" + return "" % label + + +# The URL that lives on the address field across all fixtures. +URL = "docs.google.com/document/d/abc123/edit" + + +def firefox151_tree(): + """Ground-truth FF151 urlbar subtree (from the live NVDA log in the handoff). + + #urlbar-input now sits under a new `.urlbar-input-container` wrapper; the old + `.urlbar-input-box` is gone. This is the exact shape that broke NVDA+A. + """ + urlbar_input = FakeObj( + ia2={"id": "urlbar-input", "class": "urlbar-input textbox-input"}, + value=URL, + name=URL, + role="COMBOBOX", + children=[FakeObj(ia2={}, name=URL, role="STATICTEXT")], + ) + urlbar = FakeObj( + ia2={"id": "urlbar", "tag": "moz-urlbar"}, + role="GROUPING", + children=[ + FakeObj(ia2={"class": "urlbar-background"}, role="SECTION"), + FakeObj( + ia2={"class": "urlbar-input-container"}, + role="SECTION", + children=[ + FakeObj(ia2={"id": "trust-icon-container", "class": "secure"}, role="BUTTON"), + FakeObj(ia2={"id": "identity-permission-box"}, role="BUTTON"), + urlbar_input, + FakeObj(ia2={"class": "textbox-contextmenu"}, role="POPUPMENU"), + FakeObj(ia2={"id": "star-button-box", "class": "urlbar-page-action"}, role="BUTTON"), + ], + ), + ], + ) + nav_bar = FakeObj(ia2={"id": "nav-bar"}, role="TOOLBAR", children=[urlbar]) + foreground = FakeObj(ia2={"id": "main-window"}, children=[nav_bar]) + return foreground, urlbar, urlbar_input + + +def firefox_pre151_tree(): + """Pre-FF151 (e.g. FF140) shape: #urlbar-input nested under the old + `.urlbar-input-box` wrapper, itself under #urlbar. The anchored descendant + search must find #urlbar-input through this different wrapper too.""" + urlbar_input = FakeObj( + ia2={"id": "urlbar-input", "class": "urlbar-input textbox-input"}, + value=URL, + name=URL, + role="COMBOBOX", + ) + urlbar = FakeObj( + ia2={"id": "urlbar", "tag": "moz-urlbar"}, + role="GROUPING", + children=[ + FakeObj(ia2={"class": "urlbar-background"}, role="SECTION"), + FakeObj( + ia2={"class": "urlbar-input-box"}, + value=URL, + role="SECTION", + children=[urlbar_input], + ), + ], + ) + nav_bar = FakeObj(ia2={"id": "nav-bar"}, role="TOOLBAR", children=[urlbar]) + foreground = FakeObj(ia2={"id": "main-window"}, children=[nav_bar]) + return foreground, urlbar, urlbar_input + + +def firefox_pre133_tree(): + """FF 87..132 shape: `.urlbar-input-box` is a direct child of #nav-bar (not + under #urlbar), with #urlbar-input inside it. Exercises the preserved legacy + path in addressField().""" + urlbar_input = FakeObj( + ia2={"id": "urlbar-input", "class": "urlbar-input textbox-input"}, + value=URL, + name=URL, + role="COMBOBOX", + ) + input_box = FakeObj( + ia2={"class": "urlbar-input-box"}, + role="SECTION", + children=[urlbar_input], + ) + nav_bar = FakeObj(ia2={"id": "nav-bar"}, role="TOOLBAR", children=[input_box]) + foreground = FakeObj(ia2={"id": "main-window"}, children=[nav_bar]) + return foreground, input_box, urlbar_input diff --git a/tests/test_addressField.py b/tests/test_addressField.py new file mode 100644 index 0000000..9e49351 --- /dev/null +++ b/tests/test_addressField.py @@ -0,0 +1,45 @@ +# Candidate A: a single "address field" locator in firefox.py that both +# script_url and event_alert call, so the URL-finding knowledge lives in one +# place. The headline regression: the FF151 tree (where .urlbar-input-box is +# gone and #urlbar-input sits under .urlbar-input-container) must resolve to the +# URL -- the exact bug that started this work. + +import appModules.firefox as firefox +from fakes import URL, firefox151_tree, firefox_pre151_tree, firefox_pre133_tree + + +def test_addressField_resolves_url_on_ff151(): + foreground, urlbar, urlbar_input = firefox151_tree() + field = firefox.addressField(foreground, 151) + assert field is urlbar_input + assert field.value == URL + + +def test_getURL_returns_the_url_string_on_ff151(): + foreground, urlbar, urlbar_input = firefox151_tree() + assert firefox.getURL(foreground, 151) == URL + + +def test_addressField_resolves_url_pre151(): + # FF140-style: same anchored search reaches #urlbar-input through the old + # .urlbar-input-box wrapper. + foreground, urlbar, urlbar_input = firefox_pre151_tree() + field = firefox.addressField(foreground, 140) + assert field is urlbar_input + assert firefox.getURL(foreground, 140) == URL + + +def test_addressField_uses_legacy_path_pre133(): + # FF120-style: .urlbar-input-box is a direct child of #nav-bar, not under + # #urlbar. The preserved legacy branch still finds #urlbar-input. + foreground, input_box, urlbar_input = firefox_pre133_tree() + field = firefox.addressField(foreground, 120) + assert field is urlbar_input + assert firefox.getURL(foreground, 120) == URL + + +def test_addressField_returns_none_when_absent(): + from fakes import FakeObj + empty = FakeObj(ia2={"id": "main-window"}) + assert firefox.addressField(empty, 151) is None + assert firefox.getURL(empty, 151) is None diff --git a/tests/test_findInSubtree.py b/tests/test_findInSubtree.py new file mode 100644 index 0000000..86a1b4c --- /dev/null +++ b/tests/test_findInSubtree.py @@ -0,0 +1,74 @@ +# Candidate B: anchored descendant search. These walk fake trees only -- the +# whole point is that the primitive tolerates wrapper elements inserted between +# a stable anchor and the target, which a direct-child path cannot. + +import appModules.shared as shared +from fakes import FakeObj, firefox151_tree, firefox_pre151_tree + + +def test_finds_descendant_through_inserted_wrapper_ff151(): + # The regression that started this: #urlbar-input sits under the *new* + # .urlbar-input-container wrapper. Anchoring on the stable #urlbar and + # searching descendants must still reach it. + foreground, urlbar, urlbar_input = firefox151_tree() + found = shared.findInSubtree(urlbar, shared.byIA2Attribute("id", "urlbar-input")) + assert found is urlbar_input + + +def test_finds_descendant_through_old_wrapper_pre151(): + # Same predicate, different wrapper (.urlbar-input-box). One primitive, + # both DOM shapes -- that is the resilience. + foreground, urlbar, urlbar_input = firefox_pre151_tree() + found = shared.findInSubtree(urlbar, shared.byIA2Attribute("id", "urlbar-input")) + assert found is urlbar_input + + +def test_returns_none_when_no_descendant_matches(): + foreground, urlbar, urlbar_input = firefox151_tree() + found = shared.findInSubtree(urlbar, shared.byIA2Attribute("id", "does-not-exist")) + assert found is None + + +def test_returns_none_for_missing_anchor(): + assert shared.findInSubtree(None, shared.byIA2Attribute("id", "anything")) is None + + +def test_tolerates_nodes_without_ia2attributes(): + # Real trees contain nodes that expose no IA2Attributes at all; the search + # must skip them rather than crash. + target = FakeObj(ia2={"id": "target"}, value="hit") + bare = FakeObj(ia2=None) # no IA2Attributes attribute + anchor = FakeObj(ia2={"id": "anchor"}, children=[bare, target]) + found = shared.findInSubtree(anchor, shared.byIA2Attribute("id", "target")) + assert found is target + + +def test_searches_descendants_not_the_anchor_itself(): + # The anchor matching the predicate must not short-circuit the search; we + # want a descendant. + anchor = FakeObj(ia2={"id": "urlbar-input"}) + assert shared.findInSubtree(anchor, shared.byIA2Attribute("id", "urlbar-input")) is None + + +def test_exact_match_does_not_confuse_container_with_input(): + # re.match-style prefix matching would wrongly return .urlbar-input-container + # (visited first, depth-first) when searching for id "urlbar-input". The + # predicate must match exactly. + foreground, urlbar, urlbar_input = firefox151_tree() + container = urlbar.firstChild.next # .urlbar-input-container + assert "urlbar-input" in container.IA2Attributes.get("class", "") + found = shared.findInSubtree(urlbar, shared.byIA2Attribute("id", "urlbar-input")) + assert found is urlbar_input + + +def test_depth_first_preorder_returns_first_match(): + deep = FakeObj(ia2={"id": "match", "class": "deep"}) + shallow = FakeObj(ia2={"id": "match", "class": "shallow"}) + anchor = FakeObj( + ia2={"id": "anchor"}, + children=[FakeObj(ia2={"class": "wrapper"}, children=[deep]), shallow], + ) + # Pre-order visits the first branch (and its descendant) before the second + # sibling, so the deep match under the first child wins. + found = shared.findInSubtree(anchor, shared.byIA2Attribute("id", "match")) + assert found is deep diff --git a/tests/test_searchObject_logging.py b/tests/test_searchObject_logging.py new file mode 100644 index 0000000..2262110 --- /dev/null +++ b/tests/test_searchObject_logging.py @@ -0,0 +1,36 @@ +# Candidate C: when searchObject misses a milestone, it should log which +# milestone failed and what ids/classes were actually present at that level -- +# exactly the manual probe we hand-injected to diagnose the FF151 break. + +import appModules.shared as shared +from conftest import log_recorder +from fakes import firefox151_tree + + +def setup_function(_): + log_recorder.clear() + + +def test_logs_the_missing_milestone_and_present_siblings(): + # Search the *old* (<151) path against the FF151 tree: it gets through + # nav-bar -> urlbar, then misses on .urlbar-input-box (now -container). + foreground, urlbar, urlbar_input = firefox151_tree() + old_path = (("id", "nav-bar"), ("id", "urlbar"), ("class", "urlbar-input-box")) + result = shared.searchObject(old_path, startAtObject=foreground) + assert result is None + + messages = [msg for _level, msg in log_recorder.records] + assert messages, "expected a diagnostic log line on miss" + blob = "\n".join(messages) + # Names the milestone that missed... + assert "urlbar-input-box" in blob + # ...and reports what was actually there (the new wrapper). + assert "urlbar-input-container" in blob + + +def test_no_log_on_success(): + foreground, urlbar, urlbar_input = firefox151_tree() + good_path = (("id", "nav-bar"), ("id", "urlbar")) + result = shared.searchObject(good_path, startAtObject=foreground) + assert result is urlbar + assert log_recorder.records == [] From f9c1bb54502bcb0911da64660a1c40572e9dac06 Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 4 Jun 2026 13:32:12 -0400 Subject: [PATCH 2/2] Apply resilient subtree search to Thunderbird recipients The Thunderbird message-header address fields had the same brittleness as the Firefox urlbar: a rigid sender path up to five levels deep (headerSenderToolbarContainer -> expandedfromRow -> multi-recipient-row -> recipients-list -> fromRecipient0) plus 3-level To/CC paths, all matched direct-child-only -- one inserted wrapper away from breaking exactly like FF151. Extract messageHeaderRecipients() in thunderbird.py: anchor on the message header landmark, find #fromRecipient0 at any depth, and each recipient row by id then its .recipients-list within -- the second adapter for findInSubtree, which is what proves the shared seam. Add byIA2Class() (CSS class-token membership) to shared, since recipient lists carry multiple class tokens that a whole-string match would miss. Tests cover sender/To/CC location, survival of an inserted wrapper (the Thunderbird analogue of the FF151 break), class-token matching, and the no-CC / sender-only headers. Document 'message header recipients' in CONTEXT.md as the second adapter. --- CONTEXT.md | 17 +++++++- addon/appModules/shared/__init__.py | 13 ++++++ addon/appModules/thunderbird.py | 44 ++++++++++++--------- tests/conftest.py | 45 ++++++++++++++++++++- tests/fakes.py | 59 ++++++++++++++++++++++++++++ tests/test_thunderbird_recipients.py | 44 +++++++++++++++++++++ 6 files changed, 200 insertions(+), 22 deletions(-) create mode 100644 tests/test_thunderbird_recipients.py diff --git a/CONTEXT.md b/CONTEXT.md index a46f71f..7900eec 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -33,4 +33,19 @@ depth — tolerating wrapper elements inserted between anchor and target. Contra with `searchObject(path)`, the rigid sibling primitive that matches a fixed sequence of **direct-child** milestones and fails the moment a wrapper appears. Build predicates with `byIA2Attribute(key, value)` (exact match — so searching -for id `urlbar-input` never matches `urlbar-input-container`). +for id `urlbar-input` never matches `urlbar-input-container`) or +`byIA2Class(value)` (CSS class-token membership, since an IA2 `class` attribute +can hold several space-separated tokens). + +Two adapters use this primitive: the Firefox **address field** above, and the +Thunderbird **message header recipients** below. + +### message header recipients +The sender, To and CC address fields in a Thunderbird message header, located by +`messageHeaderRecipients(messageHeader)` in `addon/appModules/thunderbird.py`. +The message header (a LANDMARK) is the stable anchor; the sender's +`#fromRecipient0` is found at any depth, and each recipient row (`#expandedtoRow` +/ `#expandedccRow`) then its `.recipients-list` within. This replaced rigid +direct-child paths up to five levels deep — the same brittleness class as the +Firefox address field, and the second adapter that proves the +[anchored descendant search](#anchored-descendant-search) seam. diff --git a/addon/appModules/shared/__init__.py b/addon/appModules/shared/__init__.py index 055f7e8..95945a0 100644 --- a/addon/appModules/shared/__init__.py +++ b/addon/appModules/shared/__init__.py @@ -135,6 +135,19 @@ def predicate(obj): return attributes[key] == value return predicate +def byIA2Class(value): + """Build a predicate for findInSubtree that matches an object whose IA2 + 'class' attribute contains the CSS class token `value`. IA2 class attributes + can hold several space-separated tokens (e.g. "urlbar-input textbox-input"), + so this matches token membership rather than the whole string. Tolerates + objects with no IA2Attributes.""" + def predicate(obj): + attributes = getattr(obj, "IA2Attributes", None) + if not attributes: + return False + return value in attributes.get("class", "").split() + return predicate + def findInSubtree(anchor, predicate): """Anchored descendant search: starting from a stable `anchor` object, return the first descendant (depth-first, pre-order) for which `predicate` diff --git a/addon/appModules/thunderbird.py b/addon/appModules/thunderbird.py index cdbd386..341899d 100644 --- a/addon/appModules/thunderbird.py +++ b/addon/appModules/thunderbird.py @@ -42,6 +42,30 @@ addonHandler.initTranslation() +def messageHeaderRecipients(messageHeader): + """Locate the address fields in a Thunderbird message header: the sender + plus the To and CC recipients, as a flat list of objects (sender first). + + This is the Thunderbird application of the same anchored descendant search + that locates Firefox's address field. The message header is the stable + anchor; the sender's #fromRecipient0 is found at any depth (collapsing the + old headerSenderToolbarContainer -> expandedfromRow -> multi-recipient-row + -> recipients-list -> fromRecipient0 path), and each recipient row is found + by id, then its .recipients-list within. Wrapper elements Thunderbird may + insert between those landmarks are simply traversed rather than breaking a + rigid direct-child path.""" + sender = shared.findInSubtree(messageHeader, shared.byIA2Attribute('id', 'fromRecipient0')) + addresses = [sender] + toRow = shared.findInSubtree(messageHeader, shared.byIA2Attribute('id', 'expandedtoRow')) + toRecipients = shared.findInSubtree(toRow, shared.byIA2Class('recipients-list')) + if toRecipients: + addresses = addresses + toRecipients.children + ccRow = shared.findInSubtree(messageHeader, shared.byIA2Attribute('id', 'expandedccRow')) + ccRecipients = shared.findInSubtree(ccRow, shared.byIA2Class('recipients-list')) + if ccRecipients: + addresses = addresses + ccRecipients.children + return addresses + class AppModule(thunderbird.AppModule): #TRANSLATORS: category for Thunderbird input gestures @@ -272,25 +296,7 @@ def addressField(self, index, rightClick): except StopIteration: ui.message(_("Not found")) return - sender = shared.searchObject(( - ('id', 'headerSenderToolbarContainer'), - ('id', 'expandedfromRow'), - ('class', 'multi-recipient-row'), - ('class', 'recipients-list'), - ('id', 'fromRecipient0')), - messageHeader) - toRecipients = shared.searchObject(( - ('id', 'expandedtoRow'), - ('id', 'expandedtoBox'), - ('class', 'recipients-list')), - messageHeader) - addresses = [sender]+toRecipients.children if toRecipients else [sender] - ccRecipients = shared.searchObject(( - ('id', 'expandedccRow'), - ('id', 'expandedccBox'), - ('class', 'recipients-list')), - messageHeader) - addresses = addresses+ccRecipients.children if ccRecipients else addresses + addresses = messageHeaderRecipients(messageHeader) if addresses[0]: self.messageHeadersCache[(url, doc.IA2UniqueID)] = addresses try: o = addresses[index] diff --git a/tests/conftest.py b/tests/conftest.py index a4a8120..958e96a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -59,6 +59,13 @@ def __and__(self, other): def __rand__(self, other): return _Anything() + # config.conf.spec['thunderbird'] = confspec (item assignment on a fake). + def __getitem__(self, key): + return _Anything() + + def __setitem__(self, key, value): + pass + class _Base: """A real, subclassable base for runtime classes the add-on inherits from @@ -115,6 +122,13 @@ def _register(name, **attrs): for key, value in attrs.items(): setattr(module, key, value) sys.modules[name] = module + # Bind the submodule onto its parent package so `from pkg import sub` + # resolves to it instead of the parent's permissive __getattr__ fallback. + if "." in name: + parent_name, _, child = name.rpartition(".") + parent = sys.modules.get(parent_name) + if parent is not None: + setattr(parent, child, module) return module @@ -137,9 +151,36 @@ def _install_fake_runtime(): _register("wx", Panel=_Base, Dialog=_Base) # NVDAObjects.IAccessible[.mozilla]: nested package of permissive fakes. + # IAccessible / Dialog / BrokenFocusedState are real classes because + # thunderbird.py subclasses them (ThreadTree, Tab, QuickFilter). _register("NVDAObjects") - _register("NVDAObjects.IAccessible") - _register("NVDAObjects.IAccessible.mozilla") + _register("NVDAObjects.IAccessible", IAccessible=_Base) + _register( + "NVDAObjects.IAccessible.mozilla", + IAccessible=_Base, + Dialog=_Base, + BrokenFocusedState=_Base, + ) + + # Thunderbird's extra runtime surface. + _register("config") + _register("globalVars") + _register("treeInterceptorHandler") + _register("keyboardHandler") + _register("tones") + _register("comtypes", COMError=type("COMError", (Exception,), {})) + _register("comtypes.gen") + _register("comtypes.gen.ISimpleDOM") + _register("gui.settingsDialogs", SettingsPanel=_Base) + + # nvdaBuiltin app-module bases: thunderbird.py subclasses + # thunderbird.AppModule (a hard import, no fallback). Registering firefox + # here too just means firefox.py takes its nvdaBuiltin branch instead of the + # appModuleHandler fallback -- harmless for the tests. + _register("nvdaBuiltin") + _register("nvdaBuiltin.appModules") + _register("nvdaBuiltin.appModules.firefox", AppModule=_Base) + _register("nvdaBuiltin.appModules.thunderbird", AppModule=_Base) # addonHandler: initTranslation is a no-op; no DeveloperToolkit running, so # firefox.py falls through to the core AppModule import. diff --git a/tests/fakes.py b/tests/fakes.py index f9a71dd..8a35ea9 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -141,3 +141,62 @@ def firefox_pre133_tree(): nav_bar = FakeObj(ia2={"id": "nav-bar"}, role="TOOLBAR", children=[input_box]) foreground = FakeObj(ia2={"id": "main-window"}, children=[nav_bar]) return foreground, input_box, urlbar_input + + +def thunderbird_message_header(include_cc=True, extra_wrappers=False): + """A Thunderbird message-header subtree (a LANDMARK) holding sender, To and + CC address fields, mirroring the rigid paths the add-on used to walk: + + headerSenderToolbarContainer > expandedfromRow > .multi-recipient-row + > .recipients-list > #fromRecipient0 + #expandedtoRow > #expandedtoBox > .recipients-list > #toRecipient* + #expandedccRow > #expandedccBox > .recipients-list > #ccRecipient0 + + extra_wrappers=True inserts an additional wrapper element on the sender and + To branches -- the Thunderbird analogue of the FF151 .urlbar-input-container + insertion -- so a test can prove the anchored search survives it. + + Returns (message_header, sender, [to_recipients], cc_recipient_or_None). + """ + def recipient(rid, address): + return FakeObj(ia2={"id": rid}, name=address) + + sender = recipient("fromRecipient0", "alice@example.com") + sender_list = FakeObj(ia2={"class": "recipients-list"}, children=[sender]) + if extra_wrappers: + sender_list = FakeObj(ia2={"class": "new-sender-wrapper"}, children=[sender_list]) + multi = FakeObj(ia2={"class": "multi-recipient-row"}, children=[sender_list]) + from_row = FakeObj(ia2={"id": "expandedfromRow"}, children=[multi]) + sender_container = FakeObj(ia2={"id": "headerSenderToolbarContainer"}, children=[from_row]) + + to0 = recipient("toRecipient0", "bob@example.com") + to1 = recipient("toRecipient1", "carol@example.com") + # Multiple class tokens: exact-match would miss this; token-match must hit. + to_list = FakeObj(ia2={"class": "recipients-list address-container"}, children=[to0, to1]) + to_inner = FakeObj(ia2={"id": "expandedtoBox"}, children=[to_list]) + if extra_wrappers: + to_inner = FakeObj(ia2={"class": "to-wrapper"}, children=[to_inner]) + to_row = FakeObj(ia2={"id": "expandedtoRow"}, children=[to_inner]) + + children = [sender_container, to_row] + cc0 = None + if include_cc: + cc0 = recipient("ccRecipient0", "dan@example.com") + cc_list = FakeObj(ia2={"class": "recipients-list"}, children=[cc0]) + cc_inner = FakeObj(ia2={"id": "expandedccBox"}, children=[cc_list]) + cc_row = FakeObj(ia2={"id": "expandedccRow"}, children=[cc_inner]) + children.append(cc_row) + + message_header = FakeObj(ia2={"id": "messageHeader"}, role="LANDMARK", children=children) + return message_header, sender, [to0, to1], cc0 + + +def thunderbird_sender_only_header(): + """Message header with neither To nor CC rows (e.g. a draft) -- the locator + must still return just the sender.""" + sender = FakeObj(ia2={"id": "fromRecipient0"}, name="alice@example.com") + sender_list = FakeObj(ia2={"class": "recipients-list"}, children=[sender]) + from_row = FakeObj(ia2={"id": "expandedfromRow"}, children=[sender_list]) + container = FakeObj(ia2={"id": "headerSenderToolbarContainer"}, children=[from_row]) + message_header = FakeObj(ia2={"id": "messageHeader"}, role="LANDMARK", children=[container]) + return message_header, sender diff --git a/tests/test_thunderbird_recipients.py b/tests/test_thunderbird_recipients.py new file mode 100644 index 0000000..f2fca9f --- /dev/null +++ b/tests/test_thunderbird_recipients.py @@ -0,0 +1,44 @@ +# The Thunderbird application of the same anchored descendant search that +# locates Firefox's address field. messageHeaderRecipients() collapses the old +# 5-level sender path (and the 3-level To/CC paths) to an anchor + predicate, so +# wrapper elements Thunderbird may insert are traversed rather than fatal. + +import appModules.thunderbird as thunderbird +from fakes import thunderbird_message_header, thunderbird_sender_only_header + + +def test_locates_sender_to_and_cc(): + header, sender, to_recipients, cc = thunderbird_message_header() + addresses = thunderbird.messageHeaderRecipients(header) + assert addresses == [sender] + to_recipients + [cc] + + +def test_survives_inserted_wrappers(): + # extra_wrappers inserts a div between the landmarks and the targets -- the + # Thunderbird analogue of the FF151 .urlbar-input-container break. The rigid + # direct-child path would miss; the anchored search must not. + header, sender, to_recipients, cc = thunderbird_message_header(extra_wrappers=True) + addresses = thunderbird.messageHeaderRecipients(header) + assert addresses == [sender] + to_recipients + [cc] + + +def test_to_recipients_matched_by_class_token_not_exact_string(): + # The To recipients-list carries multiple class tokens + # ("recipients-list address-container"); a whole-string match would miss it. + header, sender, to_recipients, cc = thunderbird_message_header() + addresses = thunderbird.messageHeaderRecipients(header) + assert to_recipients[0] in addresses + assert to_recipients[1] in addresses + + +def test_header_without_cc(): + header, sender, to_recipients, cc = thunderbird_message_header(include_cc=False) + assert cc is None + addresses = thunderbird.messageHeaderRecipients(header) + assert addresses == [sender] + to_recipients + + +def test_sender_only_header(): + header, sender = thunderbird_sender_only_header() + addresses = thunderbird.messageHeaderRecipients(header) + assert addresses == [sender]