Skip to content

Commit 15a2d2b

Browse files
Tests: Surface Ladybird’s AX state when the macOS AX tests time out
When Ladybird never registers an AXWebArea, the harness raised a bare timeout with no indication of why — whether the helper process died, never opened a window, or built a tree without the expected web area. Dump what is on the AX surface into the timeout error — process liveness, window count, and a depth-capped AXRole/AXTitle tree — and keep the head of Ladybird’s stderr, where startup failures log.
1 parent ee963a6 commit 15a2d2b

1 file changed

Lines changed: 51 additions & 1 deletion

File tree

Tests/LibWeb/AccessibilityBridgeMac/harness/ladybird.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from ApplicationServices import kAXFocusedWindowAttribute
2020
from ApplicationServices import kAXMainWindowAttribute
2121
from ApplicationServices import kAXRoleAttribute
22+
from ApplicationServices import kAXTitleAttribute
23+
from ApplicationServices import kAXWindowsAttribute
2224

2325
REPO_ROOT = pathlib.Path(__file__).resolve().parents[4]
2426

@@ -146,10 +148,18 @@ def _web_appeared() -> bool:
146148
stderr = f.read().decode(errors="replace")
147149
except Exception:
148150
pass
151+
ax_state = self._describe_ax_state()
152+
# Startup failures (Compositor/sandbox/IPC) log near the top of stderr, so keep the head too.
153+
if len(stderr) <= 6000:
154+
stderr_excerpt = stderr
155+
else:
156+
stderr_excerpt = stderr[:3000] + "\n...[truncated]...\n" + stderr[-3000:]
149157
self.stop()
150158
raise RuntimeError(
151159
f"Ladybird did not register an AXWebArea on NSAccessibility within {startup_timeout:g}s.\n"
152-
f"URL: {self._url}\nBinary: {self._binary}\nStderr: {stderr[-2000:]}"
160+
f"URL: {self._url}\nBinary: {self._binary}\n"
161+
f"AX surface at timeout:\n{ax_state}\n"
162+
f"Stderr:\n{stderr_excerpt}"
153163
)
154164

155165
def stop(self) -> None:
@@ -189,6 +199,28 @@ def _find_web_area(self):
189199
return None
190200
return _find_by_role(window, "AXWebArea")
191201

202+
def _describe_ax_state(self) -> str:
203+
"""Best-effort snapshot of what IS on the AX surface, to diagnose a missing AXWebArea on timeout."""
204+
lines = []
205+
if self._process is not None:
206+
code = self._process.poll()
207+
state = "alive" if code is None else f"exited with code {code}"
208+
lines.append(f"process: {state} (pid {self._process.pid})")
209+
lines.append(f"app AXRole: {_ax_attr(self._app, kAXRoleAttribute)!r}")
210+
windows = _ax_attr(self._app, kAXWindowsAttribute) or []
211+
focused = _ax_attr(self._app, kAXFocusedWindowAttribute)
212+
main = _ax_attr(self._app, kAXMainWindowAttribute)
213+
focused_str = "yes" if focused else "no"
214+
main_str = "yes" if main else "no"
215+
lines.append(f"windows: {len(windows)}; focused: {focused_str}; main: {main_str}")
216+
window = focused or main or (windows[0] if windows else None)
217+
if window is None:
218+
lines.append("no window on the AX surface to descend into")
219+
else:
220+
lines.append("window subtree (AXRole, depth<=4):")
221+
lines.extend(" " + line for line in _describe_subtree(window, max_depth=4))
222+
return "\n".join(lines)
223+
192224
@property
193225
def app(self):
194226
"""The application AXUIElement. Raises if start() hasn't completed."""
@@ -217,6 +249,24 @@ def _find_by_role(root, role_name: str, depth: int = 0):
217249
return None
218250

219251

252+
def _describe_subtree(root, *, max_depth: int, depth: int = 0) -> list[str]:
253+
"""Shallow AXRole/AXTitle dump of an AX subtree, depth-capped, for timeout diagnostics."""
254+
if root is None:
255+
return []
256+
role = _ax_attr(root, kAXRoleAttribute)
257+
title = _ax_attr(root, kAXTitleAttribute)
258+
label = (" " * depth) + str(role) + (f" {title!r}" if title else "")
259+
children = _ax_attr(root, kAXChildrenAttribute) or []
260+
if depth >= max_depth:
261+
if children:
262+
return [label, (" " * (depth + 1)) + f"... {len(children)} children (depth-capped)"]
263+
return [label]
264+
out = [label]
265+
for child in children:
266+
out.extend(_describe_subtree(child, max_depth=max_depth, depth=depth + 1))
267+
return out
268+
269+
220270
@contextmanager
221271
def ladybird_for_fixture(fixture_name: str, binary: Optional[pathlib.Path] = None):
222272
"""Context manager spawning Ladybird on input/<fixture_name>."""

0 commit comments

Comments
 (0)