mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 00:47:01 +01:00
Some checks are pending
A Flutter/Material TextField publishes its label as `hint` and leaves text and content-desc empty, so every field on such a form arrived as an anonymous EditText. The workout app's manual-workout form is nine of them: the only way to fill it was by screen position, which is precisely the mis-tap-writes-the-wrong-field failure this tool exists to prevent. `hint` ranks below text and content-desc but above resource-id. Material clears the hint once content arrives, so a filled field still answers to its real text and a caller can tell an empty field from a completed one. The app-side alternative does not work and is documented as a trap: wrapping the field in Semantics(label:) emits a second labelled node at the same rect, and MergeSemantics collapses them by dropping the hint from Android's tree, which leaves the form MORE anonymous than before. Widget tests cannot catch that -- they assert on Flutter's SemanticsNode while this tool reads Android's AccessibilityNodeInfo, so nine of them passed green on the build whose on-device labels had just gone empty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWq9MRemX9ZWvNmnhNWUP2
140 lines
4.7 KiB
Python
140 lines
4.7 KiB
Python
"""The element and error types the UI driver works in, and the XML parser.
|
|
|
|
Split out of :mod:`python_pkg.android_ui.driver` to keep it under the 250-line
|
|
cap. ``AndroidUi`` stays there because that is the name the tests patch;
|
|
everything here is re-imported by ``driver`` so the package's public re-exports
|
|
in ``__init__`` are unchanged.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import logging
|
|
import re
|
|
|
|
from defusedxml.ElementTree import ParseError, fromstring
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_BOUNDS_RE = re.compile(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]")
|
|
|
|
|
|
class UiAutomationError(RuntimeError):
|
|
"""Base class for every failure this package reports."""
|
|
|
|
|
|
class ElementNotFoundError(UiAutomationError):
|
|
"""No element matched the query."""
|
|
|
|
|
|
class AmbiguousElementError(UiAutomationError):
|
|
"""More than one element matched, so acting would be a coin flip."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UiElement:
|
|
"""One node from the accessibility tree."""
|
|
|
|
text: str
|
|
content_desc: str
|
|
resource_id: str
|
|
class_name: str
|
|
bounds: tuple[int, int, int, int]
|
|
enabled: bool
|
|
focused: bool
|
|
# Material/Flutter text fields publish their label as `hint` and leave
|
|
# text/content-desc empty, so without this an entire form is anonymous
|
|
# edit boxes and the only way to fill it is by screen position.
|
|
hint: str = ""
|
|
|
|
@property
|
|
def label(self) -> str:
|
|
"""The best human-facing name for this element.
|
|
|
|
``hint`` ranks below the others but above nothing: it is a field's
|
|
placeholder, which Material clears once the field has content, so it
|
|
names an EMPTY field and defers to real text as soon as one is typed.
|
|
"""
|
|
return self.text or self.content_desc or self.hint or self.resource_id
|
|
|
|
@property
|
|
def center(self) -> tuple[int, int]:
|
|
"""Tap point: the centre of the element's CURRENT bounds."""
|
|
left, top, right, bottom = self.bounds
|
|
return ((left + right) // 2, (top + bottom) // 2)
|
|
|
|
def matches(self, query: str, *, exact: bool = False) -> bool:
|
|
"""Return True if ``query`` names this element."""
|
|
haystacks = (self.text, self.content_desc, self.hint, self.resource_id)
|
|
if exact:
|
|
return any(h == query for h in haystacks)
|
|
return any(query.lower() in h.lower() for h in haystacks if h)
|
|
|
|
def __str__(self) -> str:
|
|
"""Return a one-line description naming the element and its centre."""
|
|
cls = self.class_name.rsplit(".", 1)[-1]
|
|
x, y = self.center
|
|
return f"{self.label!r} <{cls}> at ({x},{y})"
|
|
|
|
|
|
def _escape(text: str) -> str:
|
|
r"""Escape text for ``adb shell input text``.
|
|
|
|
Backslash is escaped FIRST and excluded from the loop below. Escaping it
|
|
inside the loop double-escapes every backslash the loop itself just added
|
|
(``&`` -> ``\\&`` -> ``\\\\&``), which silently types the wrong password.
|
|
"""
|
|
out = text.replace("\\", "\\\\")
|
|
out = out.replace("%", "%%").replace(" ", "%s")
|
|
for char in "()<>|;&*~\"'`$":
|
|
out = out.replace(char, "\\" + char)
|
|
return out
|
|
|
|
|
|
def _parse_tree(xml: str) -> list[UiElement]:
|
|
"""Return every labelled node in an accessibility-tree dump."""
|
|
try:
|
|
root = fromstring(xml)
|
|
except ParseError as exc:
|
|
_logger.warning(
|
|
"UI dump is not valid XML (%s) — treating it as an empty screen; "
|
|
"this is usually a snapshot taken mid-animation",
|
|
exc,
|
|
)
|
|
return []
|
|
|
|
elements: list[UiElement] = []
|
|
for node in root.iter("node"):
|
|
bounds = _BOUNDS_RE.match(node.get("bounds", ""))
|
|
if bounds is None:
|
|
continue
|
|
text = node.get("text", "")
|
|
desc = node.get("content-desc", "")
|
|
hint = node.get("hint", "")
|
|
res = node.get("resource-id", "")
|
|
cls = node.get("class", "")
|
|
# An EMPTY text field has no text, content-desc or resource-id, so a
|
|
# "must be labelled" filter drops it -- and an empty field is exactly
|
|
# the thing a caller needs to find in order to type into it. Keep every
|
|
# editable node regardless of label.
|
|
if not (text or desc or hint or res or cls.endswith("EditText")):
|
|
continue
|
|
elements.append(
|
|
UiElement(
|
|
text=text,
|
|
content_desc=desc,
|
|
hint=hint,
|
|
resource_id=res,
|
|
class_name=cls,
|
|
bounds=(
|
|
int(bounds.group(1)),
|
|
int(bounds.group(2)),
|
|
int(bounds.group(3)),
|
|
int(bounds.group(4)),
|
|
),
|
|
enabled=node.get("enabled") == "true",
|
|
focused=node.get("focused") == "true",
|
|
)
|
|
)
|
|
return elements
|