Tests+CI: Add regression tests for macOS NSAccessibility output

This change adds a test harness to catch regressions at a layer we can't
see with tests that just examine a tree dump of the accessibility tree:
our actual macOS NSAccessibility output. Each test launches a Ladybird
process on a fixture HTML page, connects to it as an NSAccessibility
client via PyObjC + AXUIElement, and asserts on what the AX surface
actually exposes — and on what VoiceOver would see while walking that.

This change also sets up CI to run this test harness on its tests.
This commit is contained in:
sideshowbarker authored and sideshowbarker committed 2026-10-08 13:21:14 +09:00
1 parent 61a878789d
commit ca3dd33db1
31 files changed
+5204 -8
Notes: github-actions[bot] 2026-10-08 04:22:35 +00:00

No files matched your search

+29 -8
View File
@@ -35,14 +35,6 @@ jobs:
runner_labels: '["blacksmith-16vcpu-ubuntu-2404"]'
container: *ci_container
- os_name: 'macOS'
arch: 'arm64'
build_preset: 'Sanitizer'
toolchain: 'Clang'
clang_plugins: false
runner_labels: '["blacksmith-12vcpu-macos-26"]'
container: 'null'
- os_name: 'Linux'
arch: 'arm64'
build_preset: 'Sanitizer'
@@ -93,3 +85,32 @@ jobs:
build_preset: ${{ matrix.build_preset }}
clang_plugins: ${{ matrix.clang_plugins }}
container: ${{ matrix.container }}
# The macOS Sanitizer build has a 2nd consumer: the macOS AX tests run against the app bundle the Sanitizer build
# packages. We put it outside the matrix to make those tests wait for this build alone, not for the slowest job in it.
macOS-Sanitizer:
if: github.repository == 'LadybirdBrowser/ladybird'
name: 'macOS, arm64, Sanitizer, Clang'
secrets: inherit
uses: ./.github/workflows/lagom-template.yml
with:
toolchain: 'Clang'
os_name: 'macOS'
runner_labels: '["blacksmith-12vcpu-macos-26"]'
arch: 'arm64'
build_preset: 'Sanitizer'
clang_plugins: false
# The macOS AX tests need an unlocked GUI session that Accessibility can be granted in. Blacksmith's macOS runners
# boot screen-locked — so we can't run the tests there. Thus, we instead run the tests on a GH-hosted runner.
macOS-Accessibility:
needs: macOS-Sanitizer
name: macOS, arm64, AX testing, ${{ fromJSON(matrix.runner_labels)[0] }}
strategy:
fail-fast: false
matrix:
runner_labels: ['["macos-26"]']
uses: ./.github/workflows/macos-accessibility-tests.yml
with:
runner_labels: ${{ matrix.runner_labels }}
artifact_name: ladybird-app-bundle-macOS-arm64-Sanitizer
+21
View File
@@ -181,6 +181,27 @@ jobs:
cmake --build .
cmake --install . --strip --prefix "${GITHUB_WORKSPACE}/Install"
# The macOS AX tests run against this bundle on another machine (see macos-accessibility-tests.yml). So the
# bundle has to be self-contained. To make it self-contained, this script deploys Qt into it, copies the
# libraries in, re-signs the executables with the entitlements the build used, and tars the result.
- name: Package the macOS app bundle for the accessibility tests
if: ${{ inputs.os_name == 'macOS' }}
working-directory: ${{ github.workspace }}
run: |
"${{ env.pythonLocation }}/bin/python" Meta/package-macos-app-bundle.py \
--build-dir Build --install-dir Install --output ladybird-app-bundle.tar.gz
- name: Upload the macOS app bundle
if: ${{ inputs.os_name == 'macOS' }}
uses: actions/upload-artifact@v7
with:
name: ladybird-app-bundle-${{ inputs.os_name }}-${{ inputs.arch }}-${{ inputs.build_preset }}
path: ladybird-app-bundle.tar.gz
compression-level: 0
retention-days: 3
if-no-files-found: error
- name: Save Caches
uses: ./.github/actions/cache-save
with:
@@ -0,0 +1,395 @@
name: macOS accessibility tests
# The macOS AX tests need an unlocked GUI (Aqua) login session, and an Accessibility grant for the process tree the
# runner starts. GH-hosted machines have all that, but take a very long time to build Ladybird. Blacksmith's machines
# boot screen-locked — so don't have all that — but build Ladybird quickly. So, we re-use the Ladybird we already built
# on Blacksmith: packing it with Meta/package-macos-app-bundle.py, and uploading that to a (GH-hosted) screen-unlocked
# machine that doesn't build but instead just runs the tests (which just takes literally 2 minutes or less).
on:
workflow_call:
inputs:
runner_labels:
description: 'JSON array of the labels selecting the runner, e.g. ["macos-26"]'
required: true
type: string
artifact_name:
description: 'The artifact holding ladybird-app-bundle.tar.gz, as the build job uploaded it'
required: true
type: string
jobs:
CI:
runs-on: ${{ fromJSON(inputs.runner_labels) }}
# The harness and the pip install run pull-request code, so the job's token is read-only and the checkout doesn't
# leave it behind in .git/config for them to read.
permissions:
contents: read
timeout-minutes: 30
# The bundle is the Sanitizer build's, so a report from Ladybird or one of its helpers goes to the diagnostics
# directory, which the last step uploads. The test step creates the directory before anything can write there.
env:
ASAN_OPTIONS: log_path=${{ github.workspace }}/ax-diagnostics/asan.log
UBSAN_OPTIONS: log_path=${{ github.workspace }}/ax-diagnostics/ubsan.log
steps:
# The harness, its fixtures and the diagnostic probe come from the checkout; the app comes from the build job.
# The build jobs check out the moving refs/pull/N/merge ref, for a fresher master under the code they build. This
# job only needs the harness, so it takes the default checkout: the merge commit the run was created for, which
# stays put while the build job runs — and still exists once a PR has picked up a conflict, when GH drops the
# merge ref, and a checkout of it fails.
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
# Every step below that touches the macOS Accessibility API — the session probe, the TCC grant, the preflight, the
# harness and the diagnostic probe — does it through PyObjC from the python3 on PATH. And no python3 on a hosted
# runner image ships PyObjC — Apple's /usr/bin/python3 included. actions/setup-python puts its interpreter first
# on PATH, so this is the one they all find. Quartz is for the session-state probe.
- uses: actions/setup-python@v7
with:
python-version: 3.12
- name: Install PyObjC
run: pip3 install pyobjc-framework-ApplicationServices pyobjc-framework-Cocoa pyobjc-framework-Quartz
- name: Download the packaged app bundle
uses: actions/download-artifact@v8
with:
name: ${{ inputs.artifact_name }}
path: ${{ runner.temp }}/ladybird-app-bundle
# run_tests.sh and diagnose.py both take the app from LADYBIRD_BINARY.
- name: Unpack the app bundle
working-directory: ${{ runner.temp }}/ladybird-app-bundle
run: |
tar -xzf ladybird-app-bundle.tar.gz
echo "LADYBIRD_BINARY=${PWD}/Ladybird.app/Contents/MacOS/Ladybird" >> "$GITHUB_ENV"
# Try to wake a display and clear the lock UI. And print the session state before and after — so a remedy that
# doesn't work is visible, rather than inferred. (Blacksmith's macOS runners boot with the session screen-locked
# and no active displays, which is what breaks the accessibility tests: macOS withholds a window's accessibility
# representation while the session is locked — so AXFocusedWindow answers with the application element itself and
# no AXWebArea is reachable under it. The window is real — the window server reports one on-screen — and the
# engine renders the page fine headlessly. So, the lock is the only thing in the way.)
- name: Wake the macOS session for the accessibility tests
continue-on-error: true
run: |
py=""
for candidate in python3 /usr/bin/python3 /Library/Developer/CommandLineTools/usr/bin/python3; do
if command -v "${candidate}" >/dev/null 2>&1 && "${candidate}" -c "import Quartz" >/dev/null 2>&1; then
py="${candidate}"
break
fi
done
session_state() {
if [ -z "${py}" ]; then
echo " (no python3 with Quartz on this runner; cannot read session state)"
return 0
fi
"${py}" - <<'SESSEOF'
import re
from Quartz import CGGetActiveDisplayList
from Quartz import CGSessionCopyCurrentDictionary
err, _displays, count = CGGetActiveDisplayList(16, None, None)
print(" active displays : err=%s count=%s" % (err, count))
session = CGSessionCopyCurrentDictionary() or {}
print(" screen locked : %s" % ("YES" if session.get("CGSSessionScreenIsLocked") else "no"))
print(" session dict : %s" % re.sub(r"\s+", " ", repr(dict(session)))[:300])
SESSEOF
}
echo "=== session state BEFORE ==="
session_state
# Stop the screen saver arming again mid-job, and keep the display and machine awake for the run.
defaults -currentHost write com.apple.screensaver idleTime -int 0 || true
sudo pmset -a displaysleep 0 sleep 0 disksleep 0 || true
# Dismiss the lock UI if a screen saver is what put it up. A bare killall with no match exits non-zero,
# which would abort this step under bash -e, so tolerate that.
killall ScreenSaverEngine 2>/dev/null || true
# Assert user activity and keep a display awake for the rest of the job. This wakes a slept display; on its
# own it won't clear a session lock. That's why it runs alongside the steps above rather than instead of them.
nohup caffeinate -u -d -i -t 7200 >/dev/null 2>&1 &
caffeinate -u -t 1 || true
sleep 3
echo "=== session state AFTER ==="
session_state
# Report the GUI session state before anything depends on it. Accessibility is mediated by WindowServer. So, a
# runner without one denies every grant — however the TCC database is written. And that's the difference between a
# runner which can host these tests, and one that can't. This just reports; it doesn't fail: Each probe tolerates
# a non-match — which matters under "bash -e", where a bare pgrep that finds nothing would abort the step.
- name: Report macOS GUI session state
continue-on-error: true
run: |
echo "launchctl managername : $(launchctl managername 2>/dev/null || echo unknown)"
if pgrep -x WindowServer >/dev/null 2>&1; then
echo "WindowServer : running"
else
echo "WindowServer : absent"
fi
if pgrep -x loginwindow >/dev/null 2>&1; then
echo "loginwindow : running"
else
echo "loginwindow : absent"
fi
echo "console user : $(stat -f%Su /dev/console 2>/dev/null || echo unknown)"
# TCC attributes an Accessibility request to the responsible process — the top-level launcher that owns the
# process tree, not the process calling the API — so this names the binary a grant must cover.
/usr/bin/python3 - << 'RESPEOF'
import ctypes, os, subprocess
libc = ctypes.CDLL("/usr/lib/libSystem.B.dylib")
try:
fn = libc.responsibility_get_pid_responsible_for_pid
fn.argtypes, fn.restype = [ctypes.c_int], ctypes.c_int
responsible = fn(os.getpid())
except AttributeError:
responsible = -1
if responsible <= 0:
print("responsible process : unavailable")
else:
exe = subprocess.run(["ps", "-p", str(responsible), "-o", "comm="],
capture_output=True, text=True).stdout.strip()
print("responsible process : {} (pid {})".format(exe or "unknown", responsible))
RESPEOF
- name: Check macOS SIP/TCC state
run: |
csrutil status || true
SYSTEM_TCC=/Library/Application\ Support/com.apple.TCC/TCC.db
if [ -f "$SYSTEM_TCC" ]; then
sudo ls -l "$SYSTEM_TCC" || true
echo "--- existing kTCCServiceAccessibility entries ---"
sudo sqlite3 "$SYSTEM_TCC" \
"SELECT service, client, client_type, auth_value FROM access WHERE service = 'kTCCServiceAccessibility';" \
2>&1 || echo "(could not query)"
else
echo "(system TCC.db doesn't exist)"
fi
# We only do this on non-self-hosted runners, which are fresh VMs. This rewrites the system TCC DB. And that's not
# something we want to do on a self-hosted machine. A self-hosted machine gets its grant from whoever set it up.
- name: Grant Accessibility to the responsible process
if: ${{ !contains(inputs.runner_labels, 'self-hosted') }}
run: |
if ! csrutil status | grep -qi "disabled"; then
echo "::warning::SIP is enabled on this runner. Can't write to TCC.db."
exit 0
fi
# Grant Accessibility by writing the system TCC database directly: tccutil is broken here and MDM/PPPC
# profiles are off-limits — so direct SQL is the only lever.
#
# The client that has to be granted is the *responsible* process — not the process that calls the API. TCC
# walks up from the requesting process to the top-level launcher owning the tree — and attributes the request
# to *that* binary. (It's the same rule that makes you grant Accessibility to your terminal app — rather than
# to each shell script you want to run.) For a workflow step, the responsible process is whatever agent the
# runner provider used to start the runner. So, it differs between providers — even on a byte-identical image.
# And granting bash or python3 on its own accomplishes nothing.
#
# So, resolve it at run time — instead of hard-coding any provider's agent path. And grant the rest of the
# ancestry and the interpreters alongside it — so the grant holds however the tree gets attributed.
#
# tccd must be restarted afterwards. It caches the database and watches only MDMOverrides.plist, not TCC.db.
# So, a direct write stays invisible until the daemons are bounced. 'launchctl kickstart -k' does that
# reliably; 'killall tccd' does not dependably relaunch the root-owned system instance.
#
# Columns: client_type=1 (path), auth_value=2 (allowed), auth_reason=4, auth_version=1, csreq=<blob>.
# The rest default (boot_uuid defaults to 'UNUSED'). INSERT OR REPLACE keeps it idempotent.
SYSTEM_TCC=/Library/Application\ Support/com.apple.TCC/TCC.db
CLIENTS=$(mktemp "${RUNNER_TEMP:-/tmp}/tcc-clients.XXXXXX")
REQ_BIN=$(mktemp "${RUNNER_TEMP:-/tmp}/tcc-csreq.XXXXXX")
# The responsible process, then every ancestor up to launchd — so the grant covers whichever of them the
# kernel attributes this tree to.
/usr/bin/python3 - >> "$CLIENTS" << 'ANCESTRYEOF'
import ctypes, os, subprocess
def executable(pid):
out = subprocess.run(["ps", "-p", str(pid), "-o", "comm="], capture_output=True, text=True)
return out.stdout.strip()
def parent(pid):
out = subprocess.run(["ps", "-p", str(pid), "-o", "ppid="], capture_output=True, text=True)
text = out.stdout.strip()
return int(text) if text.isdigit() else 0
libc = ctypes.CDLL("/usr/lib/libSystem.B.dylib")
try:
fn = libc.responsibility_get_pid_responsible_for_pid
fn.argtypes, fn.restype = [ctypes.c_int], ctypes.c_int
responsible = fn(os.getpid())
except AttributeError:
responsible = -1
if responsible > 0:
print(executable(responsible))
pid = os.getppid()
while pid > 1:
print(executable(pid))
pid = parent(pid)
ANCESTRYEOF
# The preflight and run_tests.sh both use the active python3, which is the actions/setup-python one, and is
# where we install PyObjC. So, it needs the grant as much as the system interpreters do. TCC records a client
# by the path it was launched from — which for a symlinked Homebrew bash or a PATH-resolved python3 is not the
# same as its realpath. So, record both forms.
for client in "$(command -v python3 || true)" "${BASH:-$(command -v bash || true)}" \
/bin/bash /usr/bin/python3 /Library/Developer/CommandLineTools/usr/bin/python3; do
[ -n "$client" ] || continue
echo "$client" >> "$CLIENTS"
/usr/bin/python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$client" >> "$CLIENTS" 2>/dev/null || true
done
sort -u "$CLIENTS" -o "$CLIENTS"
# Read the list on fd 3: sudo below would otherwise consume the loop's stdin and cut it short.
while IFS= read -r client <&3; do
[ -n "$client" ] || continue
[ -e "$client" ] || continue
# codesign prints the designated requirement as 'designated => ...', or as a commented '# designated
# => cdhash H"..."' for ad-hoc-signed binaries (Homebrew's bash, the downloaded Python). Accept both,
# then compile it to the binary csreq blob that tccd stores and matches against. A client with no
# derivable requirement still gets a row, with a NULL csreq, which is what the runner image's own
# pre-provisioned rows carry.
req=$(codesign -d -r- "$client" 2>/dev/null | sed -n 's/^#* *designated => //p')
csreq_sql=NULL
csreq_note="none"
if [ -n "$req" ] && printf '%s' "$req" | /usr/bin/csreq -r- -b "$REQ_BIN" 2>/dev/null; then
csreq_sql="readfile('$REQ_BIN')"
csreq_note="compiled"
fi
echo "=== Granting Accessibility to $client (csreq: $csreq_note) ==="
sudo sqlite3 "$SYSTEM_TCC" \
"INSERT OR REPLACE INTO access (service, client, client_type, auth_value, auth_reason, auth_version, csreq) VALUES ('kTCCServiceAccessibility', '$client', 1, 2, 4, 1, $csreq_sql);"
done 3< "$CLIENTS"
rm -f "$CLIENTS" "$REQ_BIN"
echo "=== Accessibility entries after grant ==="
sudo sqlite3 "$SYSTEM_TCC" \
"SELECT client, auth_value, CASE WHEN csreq IS NULL THEN 'NULL' ELSE 'len=' || length(csreq) END FROM access WHERE service = 'kTCCServiceAccessibility' ORDER BY client;"
# Bounce both the system and per-user tccd, so the next AX request reads these rows fresh (see note above).
sudo launchctl kickstart -k system/com.apple.tccd.system || true
launchctl kickstart -k "gui/$(id -u)/com.apple.tccd" || true
- name: Verify macOS GUI session + Accessibility (AX preflight)
run: |
fail=0
session=$(launchctl managername 2>/dev/null || echo "unknown")
echo "launchctl managername : ${session}"
if [ "${session}" != "Aqua" ]; then
echo "::error title=AX preflight: no GUI session::The accessibility tests need a GUI (Aqua) login session, but this runner is in the '${session}' session. Fix: Run as a LaunchAgent under an auto-logged-in user (not a root LaunchDaemon), with screen-lock and display-sleep disabled."
fail=1
fi
# An Aqua session type is not the same as a live GUI. Accessibility is mediated by WindowServer — so a runner
# without one denies every request, no matter how the TCC DB is written. Report that here — so a failure below
# says which of the two is missing. Each probe tolerates a non-match: this runs under "bash -e", where a bare
# pgrep that finds nothing would abort the step.
if pgrep -x WindowServer >/dev/null 2>&1; then window_server=running; else window_server=absent; fi
if pgrep -x loginwindow >/dev/null 2>&1; then login_window=running; else login_window=absent; fi
console_user=$(stat -f%Su /dev/console 2>/dev/null || echo "unknown")
echo "WindowServer : ${window_server}"
echo "loginwindow : ${login_window}"
echo "console user : ${console_user}"
py=""
for candidate in python3 /usr/bin/python3 /Library/Developer/CommandLineTools/usr/bin/python3; do
if command -v "${candidate}" >/dev/null 2>&1 && "${candidate}" -c "import ApplicationServices" >/dev/null 2>&1; then
py="${candidate}"
break
fi
done
if [ -n "${py}" ]; then
echo "probed interpreter : ${py}"
echo "responsible shell : ${BASH:-unknown}"
trusted=$("${py}" -c "from ApplicationServices import AXIsProcessTrusted as t; print('yes' if t() else 'no')" 2>/dev/null || echo "error")
echo "AXIsProcessTrusted : ${trusted}"
if [ "${trusted}" != "yes" ]; then
if [ "${window_server}" != "running" ]; then
echo "::error title=AX preflight: no WindowServer::AXIsProcessTrusted() returned '${trusted}' and WindowServer is not running, so this runner has no live GUI session. Accessibility grants are inert without one, whatever the TCC database holds, so this is a runner property and not something the workflow can grant its way out of."
else
echo "::error title=AX preflight: no Accessibility access::AXIsProcessTrusted() returned '${trusted}' while WindowServer is running, so the TCC grant itself did not take. Fix: Restore the Accessibility grant for Python on this runner."
fi
fail=1
fi
else
echo "::error title=AX preflight: no PyObjC::No python3 with PyObjC found, so the accessibility tests cannot run on this runner."
fail=1
fi
if [ "${fail}" -ne 0 ]; then
echo "AX preflight FAILED: this runner isn't set up to run the macOS accessibility tests (see the annotated errors above)."
exit 1
fi
echo "AX preflight OK: GUI (Aqua) session and Accessibility access are both present."
# When Ladybird never surfaces a populated AXWebArea, every test-class run waits out its full startup timeout. At
# the 60s default, that's 20 classes times 60s: 1200s. That's past this step's timeout. And unittest only prints
# tracebacks in its end-of-run summary. So, the harness's diagnosis (the AX surface dump plus Ladybird's stderr)
# would be generated but then thrown away. Thus, we cap the per-class wait — so the whole suite stays inside the
# timeout, and that summary always prints. Once warm, the Sanitizer bundle populates the web area in about 5s, and
# the warm-up launch in run_tests.sh eats the cold first-launch cost. So, when the tests pass, this costs nothing.
- name: Test - macOS AX testing
timeout-minutes: 10
working-directory: ${{ github.workspace }}
run: |
mkdir -p ax-diagnostics
Tests/LibWeb/AccessibilityBridgeMac/run_tests.sh
env:
LADYBIRD_AX_STARTUP_TIMEOUT: 20
# Diagnostic probe for the runner-dependent failure mode of the step above: if Ladybird stays alive but never
# surfaces a populated AXWebArea, and its stderr stays empty. A healthy Release Ladybird also writes nothing to
# stderr (NSLog and os_log only reach the unified log). So, the probe collects the evidence saying what the app's
# actually doing. For details on what it collects, see the Tests/LibWeb/AccessibilityBridgeMac/diagnose.py source.
- name: Diagnose - macOS AX testing environment
if: ${{ !cancelled() }}
continue-on-error: true
working-directory: ${{ github.workspace }}
env:
LADYBIRD_AX_DIAG_DIR: ${{ github.workspace }}/ax-diagnostics
LADYBIRD_AX_DIAG_WAIT: 25
run: |
py=""
for candidate in python3 /usr/bin/python3 /Library/Developer/CommandLineTools/usr/bin/python3; do
if command -v "${candidate}" >/dev/null 2>&1 && "${candidate}" -c "import ApplicationServices" >/dev/null 2>&1; then
py="${candidate}"
break
fi
done
if [ -z "${py}" ]; then
echo "no python3 with PyObjC on this runner; skipping the probe"
exit 0
fi
"${py}" Tests/LibWeb/AccessibilityBridgeMac/diagnose.py
# The probe shuts its own Ladybird down; this catches anything the test step or the probe left behind.
pkill -9 -f "${RUNNER_TEMP}/ladybird-app-bundle/" 2>/dev/null || true
# A sanitizer report from any process the two steps above launched is in ax-diagnostics/ (the job's ASAN_OPTIONS
# and UBSAN_OPTIONS). So, the upload below keeps it. Print it here too — as the build jobs do.
- name: Sanitizer Output
if: ${{ !cancelled() }}
working-directory: ${{ github.workspace }}
run: |
log_output=$(find ax-diagnostics \( -name 'asan.log.*' -o -name 'ubsan.log.*' \) -exec cat {} \; 2>/dev/null)
if [ -z "$log_output" ]; then
echo "No sanitizer issues found."
else
echo "$log_output"
echo "Sanitizer errors happened while running the accessibility tests; see the Test step above."
fi
- name: Upload macOS AX diagnostics
if: ${{ !cancelled() }}
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: ax-diagnostics-${{ fromJSON(inputs.runner_labels)[0] }}
path: ax-diagnostics/
retention-days: 0
if-no-files-found: ignore
+351
View File
@@ -0,0 +1,351 @@
#!/usr/bin/env python3
#
# Copyright (c) 2026-present, the Ladybird developers.
#
# SPDX-License-Identifier: BSD-2-Clause
"""Pack an installed Ladybird.app into a tarball such that it's runnable on a machine other than the one which built it.
Deploy the Qt frameworks and plugins the app uses into the bundle (with Qt's own macdeployqt); give every executable
bundle-relative rpaths; drop absolute ones; copy in the libraries the bundle actually links (resolving each @rpath
reference the way dyld does); re-sign the executables with the entitlements the build gave them; check that nothing
still points outside the bundle; and tar the result.
CI invokes this on the macOS Sanitizer build machine to produce a Ladybird with which we can run the macOS AX tests,
but on a different machine — without needing to re-build Ladybird on that other machine.
Otherwise, without this, the bundle runs only on the machine which built it. That's because "cmake --install" on its own
leaves every binary with only the absolute rpaths it was built with (the vcpkg library directory, the build's lib/ — and
for a sanitizer build, the compiler's runtime directory); leaves behind the dylibs it links from those directories; and
"--strip" rewrites every executable after the build signed it — invalidating signatures.
Meta/package-macos-app-bundle.py --build-dir Build/release --install-dir /tmp/Install --output /tmp/Ladybird.tar.gz
"""
from __future__ import annotations
import argparse
import functools
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Iterable
from typing import Optional
MACH_O_MAGICS = (
b"\xcf\xfa\xed\xfe", # MH_MAGIC_64 (little endian)
b"\xce\xfa\xed\xfe", # MH_MAGIC
b"\xca\xfe\xba\xbe", # FAT_MAGIC
b"\xbe\xba\xfe\xca", # FAT_CIGAM
)
# Libraries dyld finds on every macOS; nothing to copy.
SYSTEM_LIBRARY_PREFIXES = ("/usr/lib/", "/System/Library/")
# The rpaths every executable in the bundle gets: the lagom libraries "cmake --install" lays down and the libraries
# this script copies live in Contents/lib, and the install puts libssl and libcrypto in Contents/Frameworks.
BUNDLE_RPATHS = ("@executable_path/../lib", "@executable_path/../Frameworks")
def is_mach_o(path: Path) -> bool:
if path.is_symlink() or not path.is_file():
return False
with path.open("rb") as file:
return file.read(4) in MACH_O_MAGICS
def run(*command: str) -> str:
return subprocess.run(command, check=True, capture_output=True, text=True).stdout
def linked_libraries(binary: Path) -> list[str]:
"""The install names of every library `binary` links, as `otool -L` prints them."""
names = []
for line in run("otool", "-L", str(binary)).splitlines()[1:]:
match = re.match(r"\s+(\S+) \(", line)
if match:
names.append(match.group(1))
# A dylib's own install name leads its list; a library never links itself. Compare the name itself: macdeployqt
# copies a library under its real file name but leaves its install name, which can carry a shorter version.
return [name for name in names if name != install_name(binary)]
def install_name(binary: Path) -> Optional[str]:
"""The LC_ID_DYLIB of `binary`, or None for an executable."""
lines = run("otool", "-D", str(binary)).splitlines()
return lines[1].strip() if len(lines) > 1 else None
@functools.lru_cache(maxsize=None)
def rpath_entries(binary: Path) -> list[str]:
"""The LC_RPATH entries of `binary`, in load order."""
entries = []
in_rpath_command = False
for line in run("otool", "-l", str(binary)).splitlines():
if line.strip() == "cmd LC_RPATH":
in_rpath_command = True
elif in_rpath_command and line.strip().startswith("path "):
entries.append(line.strip()[len("path ") :].split(" (offset")[0])
in_rpath_command = False
return entries
def mach_o_files(*directories: Path) -> list[Path]:
files = []
for directory in directories:
if directory.is_dir():
files.extend(path for path in sorted(directory.rglob("*")) if is_mach_o(path))
return files
def find_library(relative: str, search_directories: Iterable[Path]) -> Optional[Path]:
for directory in search_directories:
candidate = directory / relative
if candidate.exists():
return candidate
return None
def expand(binary: Path, app: Path, reference: str) -> Path:
"""Expand dyld's @executable_path and @loader_path in `reference`, for a library that `binary` loads."""
expanded = reference.replace("@executable_path", str(app / "Contents" / "MacOS"), 1)
expanded = expanded.replace("@loader_path", str(binary.parent), 1)
return Path(os.path.normpath(expanded))
def resolve_in_bundle(binary: Path, app: Path, name: str) -> Optional[Path]:
"""Where dyld finds the library `name` for `binary` inside the bundle, or None if it wouldn't.
An @rpath reference is resolved the way dyld resolves it, against the rpath stack of the process: the executable's
own entries, then those of each dylib on the load chain. An executable in Contents/MacOS is its own process, so it
gets only its own rpaths — a helper inherits nothing from the main executable. A dylib is loaded by one of those
executables, every one of which carries BUNDLE_RPATHS once fix_rpaths() has run, so it resolves against those plus
its own. An absolute rpath never counts, and neither does an absolute reference outside the bundle: both name the
build tree, which the machine that unpacks the tarball doesn't have."""
contents = app / "Contents"
if not name.startswith("@rpath/"):
candidate = expand(binary, app, name)
return candidate if candidate.exists() and candidate.is_relative_to(contents) else None
rpaths = [entry for entry in rpath_entries(binary) if not entry.startswith("/")]
if binary.parent != contents / "MacOS":
rpaths = [*BUNDLE_RPATHS, *rpaths]
for entry in rpaths:
candidate = expand(binary, app, entry) / name[len("@rpath/") :]
if candidate.exists():
return candidate
return None
def bundle_binaries(app: Path) -> list[Path]:
contents = app / "Contents"
return mach_o_files(contents / "MacOS", contents / "lib", contents / "Frameworks", contents / "PlugIns")
def find_macdeployqt(explicit: Optional[Path]) -> Path:
"""The macdeployqt to run: the one given, else the one on PATH, else the one in Homebrew's Qt."""
if explicit is not None:
if not explicit.is_file():
sys.exit(f"error: {explicit} isn't a file")
return explicit
if found := shutil.which("macdeployqt"):
return Path(found)
if brew := shutil.which("brew"):
prefix = subprocess.run([brew, "--prefix", "qt"], capture_output=True, text=True)
candidate = Path(prefix.stdout.strip()) / "bin" / "macdeployqt"
if prefix.returncode == 0 and candidate.is_file():
return candidate
sys.exit("error: no macdeployqt found; pass --macdeployqt, or put Qt's bin directory on PATH")
def deploy_qt(app: Path, macdeployqt: Path) -> None:
"""Copy the Qt frameworks the main executable links, and the Cocoa platform plugin it loads at run time, into the
bundle.
The main executable links Qt by the absolute install names of the Qt it was built against (Homebrew's, on CI), and
loads the Cocoa platform plugin from that Qt's plugin directory at startup — neither of which the machine that
unpacks the tarball has. macdeployqt copies the frameworks into Contents/Frameworks, rewrites every reference
among them to @rpath and @executable_path, and writes the qt.conf that points Qt at Contents/PlugIns. Left to
itself it would also copy every Qt plugin, and with them QtQuick, QtPdf, SDL, and a dozen more libraries none of
which the app loads; so it copies none, and this copies the one plugin Qt can't start without, then has macdeployqt
point that plugin at the copied frameworks too. macdeployqt would also strip the binaries, which the install
already did, and which would invalidate the signatures again; resign() re-signs everything it touched."""
run(str(macdeployqt), str(app), "-no-plugins", "-no-strip", "-verbose=1")
plugins_directory = Path(run(str(macdeployqt.parent / "qtpaths"), "--plugin-dir").strip())
platform_plugin = plugins_directory / "platforms" / "libqcocoa.dylib"
if not platform_plugin.is_file():
sys.exit(f"error: no Cocoa platform plugin at {platform_plugin}")
deployed_plugin = app / "Contents" / "PlugIns" / "platforms" / platform_plugin.name
deployed_plugin.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(platform_plugin, deployed_plugin)
run(str(macdeployqt), str(app), "-no-plugins", "-no-strip", "-verbose=1", f"-executable={deployed_plugin}")
qt_conf = app / "Contents" / "Resources" / "qt.conf"
if not qt_conf.is_file():
qt_conf.parent.mkdir(parents=True, exist_ok=True)
qt_conf.write_text("[Paths]\nPlugins = PlugIns\n")
rpath_entries.cache_clear()
def fix_rpaths(app: Path) -> list[Path]:
"""Give every executable the bundle's relative rpaths, and strip the build tree's absolute ones from every binary.
"cmake --install" leaves each binary with only the absolute rpaths it was built with, which is why the installed
bundle runs on the machine that built it and nowhere else. dyld resolves @rpath against the rpaths of the process's
executable and of the dylibs on its load chain, so a helper process sees nothing of the main executable's rpaths:
every executable needs the relative entries itself. The absolute entries go, too — so that a bundle missing a
library fails on the machine that built it, rather than only on the one that unpacks the tarball. Returns the
binaries install_name_tool rewrote; their signatures are stale until resign() runs."""
executables_directory = app / "Contents" / "MacOS"
rewritten = []
for binary in bundle_binaries(app):
entries = rpath_entries(binary)
absolute = [entry for entry in entries if entry.startswith("/")]
missing = (
[entry for entry in BUNDLE_RPATHS if entry not in entries] if binary.parent == executables_directory else []
)
if not absolute and not missing:
continue
# One -delete_rpath removes one entry, and the build leaves duplicates, so delete each occurrence in turn.
for entry in absolute:
run("install_name_tool", "-delete_rpath", entry, str(binary))
if missing:
run(
"install_name_tool", *(argument for entry in missing for argument in ("-add_rpath", entry)), str(binary)
)
rewritten.append(binary)
rpath_entries.cache_clear()
return rewritten
def copy_linked_libraries(app: Path, search_directories: list[Path]) -> list[str]:
"""Copy every library the bundle links through @rpath but doesn't carry into Contents/lib, transitively."""
library_directory = app / "Contents" / "lib"
library_directory.mkdir(parents=True, exist_ok=True)
copied = []
pending = bundle_binaries(app)
seen = {binary.resolve() for binary in pending}
while pending:
binary = pending.pop()
for name in linked_libraries(binary):
if name.startswith(SYSTEM_LIBRARY_PREFIXES):
continue
library = resolve_in_bundle(binary, app, name)
if library is None:
if not name.startswith("@rpath/"):
sys.exit(f"error: {binary.relative_to(app)} links {name}, which names nothing inside the bundle")
relative = name[len("@rpath/") :]
source = find_library(relative, search_directories)
if source is None:
sys.exit(f"error: {binary.relative_to(app)} links {name}, and no search directory has it")
# Contents/lib is on every executable's rpath (fix_rpaths), so a copied library resolves from there.
# Dereference vcpkg's version symlinks: the bundle carries the file under the name that is linked.
library = library_directory / relative
library.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source.resolve(), library)
copied.append(relative)
if library.resolve() not in seen and is_mach_o(library.resolve()):
seen.add(library.resolve())
pending.append(library.resolve())
return copied
def resign(app: Path, build_directory: Path) -> list[str]:
"""Re-sign every library and plugin in the bundle ad hoc (install_name_tool and macdeployqt rewrote most of them,
which invalidated their signatures), then the helpers ad hoc with the entitlements the build gave them (see
ladybird_helper_process), then the bundle as a whole, which signs the main executable and reseals the bundle over
everything copied in."""
executables_directory = app / "Contents" / "MacOS"
main_executable = executables_directory / "Ladybird"
resigned = []
for library in bundle_binaries(app):
if library.parent != executables_directory:
subprocess.run(["codesign", "--sign", "-", "--force", str(library)], check=True, capture_output=True)
for executable in mach_o_files(executables_directory):
if executable == main_executable:
continue
command = ["codesign", "--sign", "-", "--force"]
entitlements = build_directory / "Services" / executable.name / f"{executable.name}.entitlements"
if entitlements.is_file():
command += ["--options", "runtime", "--entitlements", str(entitlements)]
subprocess.run([*command, str(executable)], check=True, capture_output=True)
resigned.append(executable.name)
subprocess.run(["codesign", "--sign", "-", "--force", str(app)], check=True, capture_output=True)
resigned.append(app.name)
return resigned
def verify(app: Path) -> None:
"""Fail if any binary still has a build-tree rpath, any executable lacks the bundle's, any signature is invalid, or
any library reference still points outside the bundle."""
executables_directory = app / "Contents" / "MacOS"
for binary in bundle_binaries(app):
entries = rpath_entries(binary)
if any(entry.startswith("/") for entry in entries):
sys.exit(f"error: {binary.relative_to(app)} still has an absolute rpath: {entries}")
if binary.parent == executables_directory and any(entry not in entries for entry in BUNDLE_RPATHS):
sys.exit(f"error: {binary.relative_to(app)} lacks the bundle rpaths: {entries}")
result = subprocess.run(["codesign", "--verify", str(binary)], capture_output=True, text=True)
if result.returncode != 0:
sys.exit(f"error: {binary.relative_to(app)} has an invalid signature: {result.stderr.strip()}")
for name in linked_libraries(binary):
if name.startswith(SYSTEM_LIBRARY_PREFIXES):
continue
if resolve_in_bundle(binary, app, name) is None:
sys.exit(f"error: {binary.relative_to(app)} still links {name}, which isn't in the bundle")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--build-dir", required=True, type=Path, help="the CMake build directory the app was built in")
parser.add_argument(
"--install-dir", required=True, type=Path, help="the prefix `cmake --install --strip` installed into"
)
parser.add_argument("--output", required=True, type=Path, help="the .tar.gz to write, holding Ladybird.app")
parser.add_argument(
"--macdeployqt", type=Path, help="the macdeployqt to deploy Qt with (default: PATH, then Homebrew's Qt)"
)
args = parser.parse_args()
app = args.install_dir / "bundle" / "Ladybird.app"
built_app_binary = args.build_dir / "bin" / "Ladybird.app" / "Contents" / "MacOS" / "Ladybird"
if not (app / "Contents" / "MacOS" / "Ladybird").is_file():
sys.exit(f"error: no installed app bundle at {app}")
if not built_app_binary.is_file():
sys.exit(f"error: no built app binary at {built_app_binary}")
# The build tree's binaries carry absolute rpaths (the vcpkg library directory, the build's own lib/, and for a
# sanitizer build the compiler's runtime directory); those are the directories dyld searched when the app ran from
# the build tree, so they are where its libraries are.
search_directories = [Path(entry) for entry in rpath_entries(built_app_binary) if entry.startswith("/")]
search_directories = [directory for directory in search_directories if directory.is_dir()]
if not search_directories:
sys.exit(f"error: {built_app_binary} has no absolute rpath entries to search")
# Qt first, so that the passes below see its frameworks and plugins as part of the bundle. Then rpaths, so the
# libraries the install laid down in Contents/lib count as found and only the vcpkg ones get copied; then rpaths
# again for what was copied, since vcpkg's dylibs can carry absolute rpaths of their own.
deploy_qt(app, find_macdeployqt(args.macdeployqt))
rewritten = fix_rpaths(app)
copied = copy_linked_libraries(app, search_directories)
rewritten += fix_rpaths(app)
resigned = resign(app, args.build_dir)
verify(app)
args.output.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(["tar", "-czf", str(args.output), "-C", str(app.parent), app.name], check=True)
size = os.path.getsize(args.output) / (1024 * 1024)
print(f"rewrote the rpaths of {len(rewritten)} binaries")
print(f"copied {len(copied)} libraries into {app.relative_to(args.install_dir)}/Contents/lib")
print(f"re-signed: {' '.join(resigned)}")
print(f"wrote {args.output} ({size:.0f} MiB)")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,7 @@
<!DOCTYPE html>
<html>
<body style="font: 16px sans-serif; margin: 40px;">
<h1>The <abbr title="Cascading Style Sheets">CSS</abbr> Working Group</h1>
<p>This wiki documents <abbr title="World Wide Web Consortium">W3C</abbr> work on <abbr title="Cascading Style Sheets">CSS</abbr>.</p>
</body>
</html>
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<body style="font: 16px sans-serif; margin: 40px;">
<div style="display: contents;"><button>Contents button</button></div>
<div style="display: contents;" role="group" aria-label="Contents group">
<button>First grouped button</button>
<button>Second grouped button</button>
<span style="display: none;">Hidden text that must not widen the group</span>
</div>
<p>Visible paragraph.</p>
</body>
</html>
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<body style="font: 16px sans-serif; margin: 40px;">
<h1>Mutating page</h1>
<p><a href="#target">A focusable link</a></p>
<p id="status">Tick 0</p>
<script>
let n = 0;
setInterval(() => {
document.getElementById("status").textContent = "Tick " + (++n);
}, 300);
</script>
</body>
</html>
@@ -0,0 +1,8 @@
<!DOCTYPE html>
<html>
<body style="font: 16px sans-serif; margin: 40px;">
<p>Plain ASCII opening paragraph.</p>
<p>Café au lait 😀 with an emoji in the middle.</p>
<p>Ends with a party popper 🎉</p>
</body>
</html>
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<body style="font: 16px sans-serif; margin: 40px;">
<table>
<thead>
<tr><th scope="col">Alpha</th><th scope="col">Beta</th><th scope="col">Gamma</th></tr>
</thead>
<tbody>
<tr><td colspan="2">Wide cell</td><td>After the wide cell</td></tr>
<tr><td>One</td><td>Two</td><td>Three</td></tr>
</tbody>
</table>
</body>
</html>
@@ -0,0 +1,28 @@
if (NOT BUILD_TESTING)
return()
endif()
if (NOT ENABLE_GUI_TARGETS)
# These tests run the ladybird binary, which is only built with the GUI targets.
return()
endif()
if (NOT CMAKE_SYSTEM_NAME STREQUAL "Darwin")
return()
endif()
find_package(Python3 REQUIRED)
set(LADYBIRD_A11Y_BRIDGE_MAC_ENV
"LADYBIRD_BINARY=$<TARGET_FILE:ladybird>"
)
add_test(
NAME LibWebAccessibilityBridgeMacLayer1
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/run_tests.sh
)
set_tests_properties(LibWebAccessibilityBridgeMacLayer1 PROPERTIES
ENVIRONMENT "${LADYBIRD_A11Y_BRIDGE_MAC_ENV}"
TIMEOUT 600
TIMEOUT_SIGNAL_NAME SIGTERM
)
@@ -0,0 +1,61 @@
# Regression tests for the macOS NSAccessibility surface
These tests exercise Ladybird’s macOS accessibility implementation end-to-end. Each test launches a real Ladybird process on a fixture HTML page, connects to it as an NSAccessibility client via PyObjC + AXUIElement, and asserts on what the AX surface actually exposes — and what VoiceOver would see when walking that surface.
## What’s here
- `harness/`: Python helpers: launch Ladybird with a fixture, walk the NSAccessibility tree, and query role/value/title/attributes uniformly.
- `tests/`: Test files grouped by behavior category. Assert directly on the AX surface for a given fixture.
- `run_tests.sh`: local runner.
Fixtures are reused from the `Tests/LibWeb/AccessibilityBridge/input/` directory rather than duplicated. The fixtures are pure HTML — they exercise role/name/structure invariants that are equally meaningful on any platform.
## Why this exists
We have tree-dump tests under `Tests/LibWeb/Accessibility/` that compare the pre-IPC accessibility data model against expectation files. Those catch regressions in the data model — anything observable in the serialized `AccessibilityNodeData`. They do *not* catch regressions in:
1. What the macOS `LadybirdAccessibilityElement` wrapper exposes via NSAccessibility (role mapping, attribute advertisement, focus state, parameterized-attribute responses).
2. What `accessibilityFocusedUIElement` returns at runtime as state changes.
3. Whether parameterized attributes (text markers, search predicates) return advancing/stable values vs. always-the-same lies.
Those are exactly the properties VoiceOver relies on. Bugs there manifest as "VoiceOver re-reads the same paragraph forever / says the wrong thing / pauses unexpectedly" — which are impossible to spot in a tree dump. So, spotting regressions for those are what these tests are for.
## Layer 2 (VoiceOver speech capture)
Not implemented. VoiceOver is a closed binary; there is no public hook for “what was just synthesized”. In contrast, the corresponding Linux harness has a “Layer 2” because Orca is Python and we can monkey-patch `orca.speech.speak`. For macOS, the practical alternative is to walk the same NSAccessibility surface VoiceOver walks, applying the same role-to-utterance heuristics. That’s what these tests do — Layer 1 with VoiceOver-style traversal, not actual VoiceOver output.
## Running the tests
The tests are wired into ctest:
```
ctest --test-dir Build/release -R AccessibilityBridgeMac --output-on-failure
```
The runner is also usable directly:
```
./Tests/LibWeb/AccessibilityBridgeMac/run_tests.sh
```
Set `LADYBIRD_BINARY=/path/to/Ladybird.app/Contents/MacOS/Ladybird` if your build isn’t at the default `Build/release/bin/Ladybird.app/Contents/MacOS/Ladybird`.
CI runs it that way: the macOS Sanitizer build job packs its `Ladybird.app` with `Meta/package-macos-app-bundle.py` into a self-contained tarball, and `.github/workflows/macos-accessibility-tests.yml` unpacks that on a runner with an unlocked GUI session and points `LADYBIRD_BINARY` at it — so the runner that hosts the tests never builds.
## Prerequisites
- macOS (Darwin).
- A Release build of Ladybird (default at `Build/release/bin/Ladybird.app/...`; override with `LADYBIRD_BINARY`).
- PyObjC frameworks (`pyobjc-framework-ApplicationServices`, `pyobjc-framework-Cocoa`). macOS’s system Python at `/usr/bin/python3` ships with PyObjC preinstalled; **Homebrew’s `python3` typically does not**. The runner auto-detects a Python that has the modules, probing in this order: the `PYTHON` env var, then `python3` on PATH, then `/usr/bin/python3`, then `/Library/Developer/CommandLineTools/usr/bin/python3`. If your `python3` on PATH is Homebrew and lacks PyObjC, either set `PYTHON=/usr/bin/python3` or install PyObjC into your active Python: `python3 -m pip install --user pyobjc-framework-ApplicationServices pyobjc-framework-Cocoa`.
- **Accessibility permission** for the calling shell/IDE. Grant via System Settings → Privacy & Security → Accessibility. Without it, every AXUIElement read returns `kAXErrorAPIDisabled` — and the harness fails fast with a clear message.
## Flakiness budget
Tests wait up to 60 seconds for Ladybird to start and for its AXWebArea to populate (children present). Anything slower is flakiness and should be investigated — not patched around with longer timeouts.
## Adding a new test
1. Drop an HTML fixture in `Tests/LibWeb/AccessibilityBridge/input/` (the cross-platform fixtures directory). Keep it minimal — one behavior per page when possible.
2. Add a `TestCase` subclass in `tests/test_<category>.py`. Subclass `AccessibilityBridgeMacTestCase`, set `FIXTURE = "<file>.html"`, and add `test_*` methods.
For the full shape, see any existing test file.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,39 @@
"""Accessibility-bridge test harness (macOS): launch Ladybird + drive it via NSAccessibility."""
from .ladybird import DEFAULT_BINARY
from .ladybird import FIXTURE_DIR
from .ladybird import LadybirdContext
from .ladybird import ladybird_for_fixture
from .ladybird import wait_for
from .notifications import NotificationCollector
from .testcase import AccessibilityBridgeMacTestCase
from .tree import dump_subtree
from .tree import find_all_by_role
from .tree import find_first_by_role
from .tree import get_attribute_names
from .tree import get_parameterized_attribute_names
from .tree import get_parameterized_attribute_value
from .tree import role_path
from .tree import supports_attribute
from .tree import wait_for_descendant_by_role
from .tree import walk
__all__ = [
"AccessibilityBridgeMacTestCase",
"DEFAULT_BINARY",
"FIXTURE_DIR",
"LadybirdContext",
"NotificationCollector",
"dump_subtree",
"find_all_by_role",
"find_first_by_role",
"get_attribute_names",
"get_parameterized_attribute_names",
"get_parameterized_attribute_value",
"ladybird_for_fixture",
"role_path",
"supports_attribute",
"wait_for",
"wait_for_descendant_by_role",
"walk",
]
@@ -0,0 +1,357 @@
"""Launch Ladybird on a fixture page and connect to it via NSAccessibility/AXUIElement."""
from __future__ import annotations
import json
import os
import pathlib
import shutil
import subprocess
import tempfile
import time
from contextlib import contextmanager
from typing import Callable
from typing import Optional
from ApplicationServices import AXIsProcessTrusted
from ApplicationServices import AXUIElementCopyAttributeValue
from ApplicationServices import AXUIElementCreateApplication
from ApplicationServices import kAXChildrenAttribute
from ApplicationServices import kAXFocusedWindowAttribute
from ApplicationServices import kAXMainWindowAttribute
from ApplicationServices import kAXRoleAttribute
from ApplicationServices import kAXTitleAttribute
from ApplicationServices import kAXWindowsAttribute
REPO_ROOT = pathlib.Path(__file__).resolve().parents[4]
DEFAULT_BINARY = REPO_ROOT / "Build" / "release" / "bin" / "Ladybird.app" / "Contents" / "MacOS" / "Ladybird"
FIXTURE_DIR = pathlib.Path(__file__).resolve().parents[2] / "AccessibilityBridge" / "input"
def _ax_attr(elem, key):
"""Read one AX attribute. Returns None on any error (element gone, attribute unsupported, etc.)."""
if elem is None:
return None
try:
err, value = AXUIElementCopyAttributeValue(elem, key, None)
except Exception:
return None
return None if err != 0 else value
def wait_for(
predicate: Callable[[], bool],
*,
timeout: float = 15.0,
interval: float = 0.1,
description: str = "condition",
) -> bool:
"""Polls predicate() until it returns truthy or timeout expires.
Returns whatever the final predicate call returned (truthy = success). The test file is responsible for asserting on
the return value when it needs to fail the test on timeout; this helper never raises on timeout by itself — so
callers can build their own error messages."""
deadline = time.monotonic() + timeout
result = False
while time.monotonic() < deadline:
result = predicate()
if result:
return result
time.sleep(interval)
return result
def _make_profile_directory() -> str:
"""A self-contained profile for one launch, marked past its first run.
Ladybird shows about:welcome ahead of any launch URL until the profile has completed its first run
(Application::initialize), and a --temporary-profile never has — so the test page would open as a second tab behind
the welcome page. A --profile-path profile whose Settings.json already says the first run is complete opens the
test page as the only tab. And it isolates the launch the same way --temporary-profile does: without a profile of
its own, the launch hands its URL to any browser already running on the default profile and exits, and the tests
would read (and write) whoever's real profile. The caller removes the directory when it stops the browser.
"""
root = tempfile.mkdtemp(prefix="ladybird-ax-profile-")
config = os.path.join(root, "config")
os.makedirs(config, mode=0o700)
with open(os.path.join(config, "Settings.json"), "w", encoding="utf-8") as f:
json.dump({"firstRunComplete": True}, f)
return root
class LadybirdContext:
"""A running Ladybird process wrapped with NSAccessibility/AXUIElement discovery helpers.
One context per test class; a new Ladybird process is spawned per context — so state from a previous test cannot
leak. Use via AccessibilityBridgeMacTestCase or the ladybird_for_fixture context manager."""
def __init__(self, url: str, binary: Optional[pathlib.Path] = None, *, wait_for_web_area: bool = True):
self._url = url
self._wait_for_web_area = wait_for_web_area
if binary is not None:
self._binary = pathlib.Path(binary)
elif os.environ.get("LADYBIRD_BINARY"):
self._binary = pathlib.Path(os.environ["LADYBIRD_BINARY"])
else:
self._binary = DEFAULT_BINARY
self._process: Optional[subprocess.Popen] = None
self._app = None
self._web = None
self._stderr_file: Optional[tempfile.NamedTemporaryFile] = None
self._profile_directory: Optional[str] = None
def __enter__(self) -> "LadybirdContext":
self.start()
return self
def __exit__(self, *exc_info) -> None:
self.stop()
def start(self) -> None:
# Guard both entry paths — the 'with' block, and setUpClass's direct start() call: If resource acquisition
# raises after the temp file or process is created, neither __exit__ nor tearDownClass runs — so, clean up here.
try:
self._start_impl()
except BaseException:
self.stop()
raise
def _start_impl(self) -> None:
"""Spawns Ladybird and waits for its AXWebArea to become populated."""
if not self._binary.exists():
raise FileNotFoundError(
f"Ladybird binary not found at {self._binary}. Build it with 'ninja -C Build/release ladybird'."
)
# macOS gates AXUIElement reads behind the Accessibility permission. If the test runner doesn't have it, every
# AX call silently returns kAXErrorAPIDisabled and the harness hangs indefinitely — so fail loud; fail early.
if not AXIsProcessTrusted():
raise RuntimeError(
"The test runner is not trusted for Accessibility (kAXErrorAPIDisabled). "
"Grant the calling shell/IDE Accessibility permission in System Settings → "
"Privacy & Security → Accessibility, then re-run."
)
env = os.environ.copy()
# Send stderr to a regular file rather than subprocess.PIPE: Ladybird debug builds (ENABLE_ALL_THE_DEBUG_MACROS)
# produce huge stderr volumes (PROMISE_DEBUG, etc.). A pipe's kernel buffer is ~64 KB; once it fills, every
# dbgln in WebContent blocks on write — which can wedge the engine before AX registration completes. A regular
# file never backpressures the writer, and we still get the bytes for diagnostics on failure.
self._stderr_file = tempfile.NamedTemporaryFile(prefix="ladybird-ax-stderr-", suffix=".log", delete=False)
# --force-cpu-painting: skip the GPU/Metal path. Per Services/WebContent/main.cpp the CPU backend is what other
# test harnesses use ("the GPU backend is not deterministic"), and these AX tests don't depend on rendering at
# all — they inspect the NSAccessibility surface, which is independent of which painter ran.
self._profile_directory = _make_profile_directory()
# A data directory of its own, so the launch stays out of the user's crash-report store: Ladybird offers the
# newest pending report from that store in a popover on every launch, and a crash here would land in it.
env["XDG_DATA_HOME"] = os.path.join(self._profile_directory, "xdg-data")
self._process = subprocess.Popen(
[str(self._binary), "--force-cpu-painting", "--profile-path", self._profile_directory, self._url],
env=env,
stdout=subprocess.DEVNULL,
stderr=self._stderr_file,
)
self._app = AXUIElementCreateApplication(self._process.pid)
startup_timeout = float(os.environ.get("LADYBIRD_AX_STARTUP_TIMEOUT", "60"))
# A test that decides for itself when an assistive technology first reads the page waits for nothing here: Any
# accessibility query into the window turns Qt's accessibility on, and with it the fetch of the page's tree.
if not self._wait_for_web_area:
return
def _web_appeared() -> bool:
web = self._find_web_area()
if web is None:
return False
# Wait for the AXWebArea's children to populate — not just the AXWebArea itself. The web area can show up on
# the AX surface before Ladybird has populated it with children — and, when tests run back-to-back, that
# racy empty state can leak into tests as flaky "expected X in the tree" failures.
children = _ax_attr(web, kAXChildrenAttribute)
if not children:
return False
self._web = web
return True
if not wait_for(_web_appeared, timeout=startup_timeout, description="Ladybird AXWebArea to populate"):
# Read the stderr file *before* stop() — stop() unlinks it.
stderr = ""
if self._stderr_file is not None:
try:
with open(self._stderr_file.name, "rb") as f:
stderr = f.read().decode(errors="replace")
except Exception:
pass
ax_state = self._describe_ax_state()
# Startup failures (Compositor/sandbox/IPC) log near the top of stderr, so keep the head too.
if len(stderr) <= 6000:
stderr_excerpt = stderr
else:
stderr_excerpt = stderr[:3000] + "\n...[truncated]...\n" + stderr[-3000:]
raise RuntimeError(
f"Ladybird did not register an AXWebArea on NSAccessibility within {startup_timeout:g}s.\n"
f"URL: {self._url}\nBinary: {self._binary}\n"
f"AX surface at timeout:\n{ax_state}\n"
f"Stderr:\n{stderr_excerpt}"
)
def stop(self) -> None:
if self._process is None:
self._cleanup_stderr_file()
self._cleanup_profile_directory()
return
try:
self._process.terminate()
try:
self._process.wait(timeout=5)
except subprocess.TimeoutExpired:
self._process.kill()
self._process.wait(timeout=5)
finally:
self._process = None
self._app = None
self._web = None
self._cleanup_stderr_file()
self._cleanup_profile_directory()
def _cleanup_stderr_file(self) -> None:
if self._stderr_file is None:
return
try:
self._stderr_file.close()
except Exception:
pass
try:
os.unlink(self._stderr_file.name)
except Exception:
pass
self._stderr_file = None
def _cleanup_profile_directory(self) -> None:
if self._profile_directory is None:
return
shutil.rmtree(self._profile_directory, ignore_errors=True)
self._profile_directory = None
def window(self):
"""The application's focused window, or else its main window. AppKit answers this without the web view."""
return _ax_attr(self._app, kAXFocusedWindowAttribute) or _ax_attr(self._app, kAXMainWindowAttribute)
def _find_web_area(self):
"""Walk from the application's focused/main window down to the document root, or None until the tree is there.
Qt's element for the web view is itself an AXWebArea (WebContentViewAccessibility.mm swizzles its role), with
the overlay's AXScrollArea inside it, and the document root inside that. Qt's element is there from the start;
the root appears once the tree has arrived, so the root, and only the root, is what this returns."""
window = self.window()
if window is None:
return None
outer = _find_by_role(window, "AXWebArea")
if outer is None:
return None
for child in _ax_attr(outer, kAXChildrenAttribute) or []:
if (root := _find_by_role(child, "AXWebArea")) is not None:
return root
return None
def _describe_ax_state(self) -> str:
"""Best-effort snapshot of what IS on the AX surface, to diagnose a missing AXWebArea on timeout."""
lines = []
if self._process is not None:
code = self._process.poll()
state = "alive" if code is None else f"exited with code {code}"
lines.append(f"process: {state} (pid {self._process.pid})")
lines.append(f"app AXRole: {_ax_attr(self._app, kAXRoleAttribute)!r}")
windows = _ax_attr(self._app, kAXWindowsAttribute) or []
focused = _ax_attr(self._app, kAXFocusedWindowAttribute)
main = _ax_attr(self._app, kAXMainWindowAttribute)
focused_str = "yes" if focused else "no"
main_str = "yes" if main else "no"
lines.append(f"windows: {len(windows)}; focused: {focused_str}; main: {main_str}")
window = focused or main or (windows[0] if windows else None)
if window is None:
lines.append("no window on the AX surface to descend into")
else:
lines.append("window subtree (AXRole, depth<=4):")
lines.extend(" " + line for line in _describe_subtree(window, max_depth=4))
return "\n".join(lines)
@property
def app(self):
"""The application AXUIElement. Raises if start() hasn't completed."""
if self._app is None:
raise RuntimeError("Ladybird context not started. Call start() or use as context manager.")
return self._app
@property
def web(self):
"""The AXWebArea (the document root in NSAccessibility terms). Raises if not yet present."""
if self._web is None:
raise RuntimeError("AXWebArea not present in Ladybird tree (start() may not have completed)")
return self._web
@property
def pid(self) -> int:
"""The Ladybird process id. Raises if start() hasn't completed."""
if self._process is None:
raise RuntimeError("Ladybird context not started. Call start() or use as context manager.")
return self._process.pid
def find_web_area(self):
"""A fresh lookup of the AXWebArea from the application's window, bypassing the handle start() cached.
The cached handle is what every test normally wants. This is for tests that check whether an element handle
survives a tree update: they need a second, independent lookup to compare it against."""
return self._find_web_area()
def _find_by_role(root, role_name: str, depth: int = 0):
"""Pre-order DFS for the first descendant with matching AXRole. Depth-capped to defeat pathological trees."""
if root is None or depth > 30:
return None
if _ax_attr(root, kAXRoleAttribute) == role_name:
return root
for child in _ax_attr(root, kAXChildrenAttribute) or []:
found = _find_by_role(child, role_name, depth + 1)
if found is not None:
return found
return None
def _describe_subtree(root, *, max_depth: int, depth: int = 0) -> list[str]:
"""Shallow AXRole/AXTitle dump of an AX subtree, depth-capped, for timeout diagnostics."""
if root is None:
return []
role = _ax_attr(root, kAXRoleAttribute)
title = _ax_attr(root, kAXTitleAttribute)
label = (" " * depth) + str(role) + (f" {title!r}" if title else "")
children = _ax_attr(root, kAXChildrenAttribute) or []
if depth >= max_depth:
if children:
return [label, (" " * (depth + 1)) + f"... {len(children)} children (depth-capped)"]
return [label]
out = [label]
for child in children:
out.extend(_describe_subtree(child, max_depth=max_depth, depth=depth + 1))
return out
@contextmanager
def ladybird_for_fixture(fixture_name: str, binary: Optional[pathlib.Path] = None):
"""Context manager spawning Ladybird on input/<fixture_name>."""
fixture_path = (FIXTURE_DIR / fixture_name).resolve()
if not fixture_path.exists():
raise FileNotFoundError(f"fixture HTML not found: {fixture_path}")
url = fixture_path.as_uri()
ctx = LadybirdContext(url, binary=binary)
try:
ctx.start()
yield ctx
finally:
ctx.stop()
@@ -0,0 +1,82 @@
"""AXObserver-based notification capture for the macOS accessibility-bridge tests."""
from __future__ import annotations
import time
from typing import List
from typing import Optional
from typing import Tuple
import objc
from ApplicationServices import AXObserverAddNotification
from ApplicationServices import AXObserverCreate
from ApplicationServices import AXObserverGetRunLoopSource
from ApplicationServices import AXObserverRemoveNotification
from CoreFoundation import CFRunLoopAddSource
from CoreFoundation import CFRunLoopGetCurrent
from CoreFoundation import CFRunLoopRemoveSource
from CoreFoundation import CFRunLoopRunInMode
from CoreFoundation import kCFRunLoopDefaultMode
class NotificationCollector:
"""Records the NSAccessibility notifications an application posts, as (notification name, element) pairs.
AXObserver callbacks are delivered on the current thread's run loop, so nothing arrives until the caller pumps it:
collect(seconds) runs the loop for that long and returns what came in. Register on the application element to see
every notification the app posts, or on a specific element to see only that element's."""
def __init__(self, pid: int, element, notifications: List[str]):
self._element = element
self._notifications = list(notifications)
self.events: List[Tuple[str, object]] = []
# PyObjC passes a callable to a C function-pointer parameter only once callbackFor() wraps it for that function.
@objc.callbackFor(AXObserverCreate)
def _callback(_observer, elem, notification, _refcon):
self.events.append((str(notification), elem))
self._callback = _callback
err, observer = AXObserverCreate(pid, _callback, None)
if err != 0 or observer is None:
raise RuntimeError(f"AXObserverCreate failed with error {err}")
self._observer = observer
for name in self._notifications:
err = AXObserverAddNotification(self._observer, element, name, None)
if err != 0:
raise RuntimeError(f"AXObserverAddNotification({name!r}) failed with error {err}")
self._source = AXObserverGetRunLoopSource(self._observer)
CFRunLoopAddSource(CFRunLoopGetCurrent(), self._source, kCFRunLoopDefaultMode)
def collect(self, seconds: float) -> List[Tuple[str, object]]:
"""Pumps the run loop for "seconds" and returns every event received so far."""
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
CFRunLoopRunInMode(kCFRunLoopDefaultMode, min(0.1, max(0.0, deadline - time.monotonic())), False)
return list(self.events)
def names(self) -> List[str]:
return [name for name, _elem in self.events]
def elements_for(self, notification: str) -> List[object]:
return [elem for name, elem in self.events if name == notification]
def clear(self) -> None:
self.events.clear()
def close(self) -> None:
observer: Optional[object] = getattr(self, "_observer", None)
if observer is None:
return
for name in self._notifications:
try:
AXObserverRemoveNotification(observer, self._element, name)
except Exception:
pass
try:
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), self._source, kCFRunLoopDefaultMode)
except Exception:
pass
self._observer = None
@@ -0,0 +1,47 @@
"""unittest base class that launches Ladybird on a fixture page once per test case."""
from __future__ import annotations
import unittest
from .ladybird import FIXTURE_DIR
from .ladybird import LadybirdContext
class AccessibilityBridgeMacTestCase(unittest.TestCase):
"""Subclass this and set FIXTURE = "<name>.html" (a file under AccessibilityBridge/input/).
Ladybird is launched once per TestCase subclass (setUpClass) and reused across every test method in that class.
Each test method gets self.app (the Ladybird app AXUIElement) and self.web (the AXWebArea) already populated."""
FIXTURE: str = ""
@classmethod
def setUpClass(cls):
super().setUpClass()
if not cls.FIXTURE:
raise unittest.SkipTest(f"{cls.__name__} didn't set FIXTURE")
fixture_path = (FIXTURE_DIR / cls.FIXTURE).resolve()
if not fixture_path.exists():
raise unittest.SkipTest(f"fixture not found: {fixture_path}")
cls._ctx = LadybirdContext(fixture_path.as_uri())
cls._ctx.start()
@classmethod
def tearDownClass(cls):
ctx = getattr(cls, "_ctx", None)
if ctx is not None:
ctx.stop()
super().tearDownClass()
@property
def ctx(self) -> LadybirdContext:
return type(self)._ctx
@property
def app(self):
return self.ctx.app
@property
def web(self):
return self.ctx.web
@@ -0,0 +1,215 @@
"""NSAccessibility tree walking and query helpers."""
from __future__ import annotations
import time
from typing import Callable
from typing import List
from typing import Optional
from ApplicationServices import AXUIElementCopyAttributeNames
from ApplicationServices import AXUIElementCopyAttributeValue
from ApplicationServices import AXUIElementCopyParameterizedAttributeNames
from ApplicationServices import AXUIElementCopyParameterizedAttributeValue
from ApplicationServices import kAXChildrenAttribute
from ApplicationServices import kAXRoleAttribute
from ApplicationServices import kAXSubroleAttribute
from ApplicationServices import kAXTitleAttribute
from ApplicationServices import kAXValueAttribute
def _ax_attr(elem, key):
"""Read one AX attribute. Returns None on any error."""
if elem is None:
return None
try:
err, value = AXUIElementCopyAttributeValue(elem, key, None)
except Exception:
return None
return None if err != 0 else value
def walk(
root,
visit: Callable[[object, int], None],
*,
max_depth: int = 30,
) -> None:
"""Pre-order DFS of the AX subtree rooted at "root".
visit(obj, depth) is called once per node. Depth capped at max_depth — to prevent runaway walks over pathological
trees."""
def _walk(obj, depth: int) -> None:
if obj is None or depth > max_depth:
return
visit(obj, depth)
for child in _ax_attr(obj, kAXChildrenAttribute) or []:
_walk(child, depth + 1)
_walk(root, 0)
def find_first_by_role(
root,
role_name: str,
*,
pred: Optional[Callable[[object], bool]] = None,
):
"""Returns the first descendant (pre-order) whose AXRole matches.
role_name examples: "AXWebArea", "AXHeading", "AXLink", "AXStaticText", "AXGroup", "AXButton"."""
found: List[object] = []
def _visit(obj, _depth: int) -> None:
if found:
return
if _ax_attr(obj, kAXRoleAttribute) == role_name and (pred is None or pred(obj)):
found.append(obj)
walk(root, _visit)
return found[0] if found else None
def find_all_by_role(
root,
role_name: str,
*,
pred: Optional[Callable[[object], bool]] = None,
) -> List[object]:
"""Returns every descendant whose AXRole matches, in pre-order."""
result: List[object] = []
def _visit(obj, _depth: int) -> None:
if _ax_attr(obj, kAXRoleAttribute) == role_name and (pred is None or pred(obj)):
result.append(obj)
walk(root, _visit)
return result
def wait_for_descendant_by_role(
root,
role_name: str,
*,
title: Optional[str] = None,
value: Optional[str] = None,
pred: Optional[Callable[[object], bool]] = None,
timeout: float = 5.0,
interval: float = 0.1,
):
"""Polling variant of find_first_by_role — handles trees that are still populating.
Right after Ladybird launches, the AXWebArea can be visible before all of its descendants are exposed. A plain
find_first_by_role call walks the current state once and misses nodes that arrive a moment later — which manifests
as flaky "expected foo in the tree" failures when test cases run back-to-back. This helper polls find_first_by_role
until either it returns a match, or else the timeout expires.
"title", "value", and "pred" compose with the role match: if multiple are given, the node must match all of them."""
def combined(obj) -> bool:
if title is not None:
try:
if _ax_attr(obj, kAXTitleAttribute) != title:
return False
except Exception:
return False
if value is not None:
try:
if _ax_attr(obj, kAXValueAttribute) != value:
return False
except Exception:
return False
return pred is None or pred(obj)
deadline = time.monotonic() + timeout
while True:
hit = find_first_by_role(root, role_name, pred=combined)
if hit is not None:
return hit
if time.monotonic() >= deadline:
return None
time.sleep(interval)
def role_path(obj) -> str:
"""Return a slash-separated path of role names from app to obj, for diagnostics."""
parts: List[str] = []
cur = obj
while cur is not None:
try:
parts.append(_ax_attr(cur, kAXRoleAttribute) or "?")
except Exception:
parts.append("?")
try:
from ApplicationServices import kAXParentAttribute
parent = _ax_attr(cur, kAXParentAttribute)
except Exception:
parent = None
if parent is None or parent == cur:
break
cur = parent
return "/".join(reversed(parts))
def dump_subtree(root, *, max_depth: int = 10) -> str:
"""Return a human-readable indented dump of the subtree. Diagnostics only."""
lines: List[str] = []
def _visit(obj, depth: int) -> None:
role = _ax_attr(obj, kAXRoleAttribute) or "?"
sub = _ax_attr(obj, kAXSubroleAttribute)
title = _ax_attr(obj, kAXTitleAttribute)
value = _ax_attr(obj, kAXValueAttribute)
indent = " " * depth
bits = [role]
if sub:
bits.append(f"sub={sub}")
if title:
bits.append(f"title={title!r}")
if isinstance(value, str) and value:
bits.append(f"value={value!r}")
lines.append(indent + " ".join(bits))
walk(root, _visit, max_depth=max_depth)
return "\n".join(lines)
def get_attribute_names(obj) -> List[str]:
"""Return the list of attribute names this element advertises."""
if obj is None:
return []
try:
err, names = AXUIElementCopyAttributeNames(obj, None)
except Exception:
return []
return list(names) if err == 0 and names else []
def get_parameterized_attribute_names(obj) -> List[str]:
"""Return the list of parameterized attribute names this element advertises."""
if obj is None:
return []
try:
err, names = AXUIElementCopyParameterizedAttributeNames(obj, None)
except Exception:
return []
return list(names) if err == 0 and names else []
def supports_attribute(obj, attribute_name: str) -> bool:
"""True if "obj" advertises the named NSAccessibility attribute."""
return attribute_name in get_attribute_names(obj)
def get_parameterized_attribute_value(obj, attribute_name: str, parameter):
"""Read a parameterized attribute. Returns None on any error."""
if obj is None:
return None
try:
err, value = AXUIElementCopyParameterizedAttributeValue(obj, attribute_name, parameter, None)
except Exception:
return None
return None if err != 0 else value
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
#
# Runs the macOS accessibility-bridge regression tests against NSAccessibility/AXUIElement.
#
# Requires: PyObjC frameworks (pyobjc-framework-ApplicationServices, pyobjc-framework-Cocoa) — usually pre-installed in
# macOS's system Python. The shell/IDE running this script needs Accessibility permission granted in System Settings →
# Privacy & Security → Accessibility.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
# The harness reads LADYBIRD_BINARY as the path to the Ladybird executable.
# Default to Build/release/bin/Ladybird.app/Contents/MacOS/Ladybird for local runs.
# ctest sets this explicitly via $<TARGET_FILE_DIR:ladybird>.
: "${LADYBIRD_BINARY:=${REPO_ROOT}/Build/release/bin/Ladybird.app/Contents/MacOS/Ladybird}"
export LADYBIRD_BINARY
if [[ ! -x "${LADYBIRD_BINARY}" ]]; then
echo "error: Ladybird binary not found at ${LADYBIRD_BINARY}" >&2
echo " build it with 'ninja -C Build/release ladybird'," >&2
echo " or set LADYBIRD_BINARY to the path of your Ladybird executable." >&2
exit 1
fi
# The harness uses PyObjC's ApplicationServices module. The system /usr/bin/python3 (Apple-supplied) ships with PyObjC
# preinstalled; Homebrew's python3 typically does not. Pick whichever python3 on the system has it. Honor a user-
# supplied PYTHON env var first, then probe a short list of candidates in order.
candidates=()
if [[ -n "${PYTHON:-}" ]]; then
candidates+=("${PYTHON}")
fi
candidates+=("python3" "/usr/bin/python3" "/Library/Developer/CommandLineTools/usr/bin/python3")
PYTHON_BIN=""
for cand in "${candidates[@]}"; do
if command -v "${cand}" >/dev/null 2>&1; then
if "${cand}" -c "import ApplicationServices" 2>/dev/null; then
PYTHON_BIN="${cand}"
break
fi
fi
done
if [[ -z "${PYTHON_BIN}" ]]; then
echo "error: no python3 on PATH has the PyObjC ApplicationServices module." >&2
echo " The harness uses PyObjC to drive the macOS NSAccessibility / AXUIElement APIs." >&2
echo >&2
echo " Options:" >&2
echo " (1) macOS's system Python ships with PyObjC preinstalled. If you have" >&2
echo " /usr/bin/python3, set PYTHON=/usr/bin/python3 and re-run." >&2
echo " (2) Or pip-install PyObjC into whichever python3 is on PATH:" >&2
echo " python3 -m pip install --user pyobjc-framework-ApplicationServices pyobjc-framework-Cocoa" >&2
exit 1
fi
cd "${SCRIPT_DIR}"
# Before we run the test classes, launch Ladybird once — to warm things up. After this, each test class launches its own
# Ladybird and waits up to LADYBIRD_AX_STARTUP_TIMEOUT for the web area to populate. Without this warm-up, the first
# test-class launch takes too long — several times as long as subsequent launches for the rest. (On a GH-hosted runner,
# the first one exceeded 20s — while subsequent ones took just ~5s each.) This warm-up eats that extra time up front.
LADYBIRD_AX_STARTUP_TIMEOUT="${LADYBIRD_AX_WARMUP_TIMEOUT:-120}" "${PYTHON_BIN}" - << 'WARMUPEOF'
import time
from harness.ladybird import ladybird_for_fixture
started = time.monotonic()
with ladybird_for_fixture("tree_shape.html"):
print(f"warm-up launch: Ladybird populated its web area in {time.monotonic() - started:.1f}s", flush=True)
WARMUPEOF
exec "${PYTHON_BIN}" -m unittest discover -s tests -t . -v "$@"
@@ -0,0 +1 @@
@@ -0,0 +1,97 @@
"""display:contents elements: which ones the tree keeps, and the frame a kept one reports.
A display:contents element has no layout box of its own; its children are laid out in its parent's box. WebContent
still includes it under the usual rules (Element::exclude_from_accessibility_tree() exempts it from the no-layout-node
exclusion), and serializes its bounds as the union of its children's boxes (AccessibilityTreeNode.cpp:
accessibility_bounds()). So an unnamed display:contents div collapses out of the macOS tree like any unnamed div, while
a display:contents element with a role and a name is exposed with a frame VoiceOver can point at."""
from __future__ import annotations
import pathlib
import re
import sys
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from ApplicationServices import kAXChildrenAttribute # noqa: E402
from ApplicationServices import kAXParentAttribute # noqa: E402
from ApplicationServices import kAXPositionAttribute # noqa: E402
from ApplicationServices import kAXRoleAttribute # noqa: E402
from ApplicationServices import kAXSizeAttribute # noqa: E402
from ApplicationServices import kAXTitleAttribute # noqa: E402
from harness import AccessibilityBridgeMacTestCase # noqa: E402
from harness import find_all_by_role # noqa: E402
from harness import walk # noqa: E402
from harness.ladybird import _ax_attr # noqa: E402
def _rect(obj):
"""(x, y, width, height) of obj in screen points, read from its AXPosition and AXSize.
Both are AXValueRefs, which PyObjC can't unwrap; str() of one is a stable diagnostic of the form
"<AXValue 0x...> {value = x:N y:M type = kAXValueCGPointType}", as test_text_markers.py reads too."""
position = re.search(r"x:(-?\d+\.?\d*)\s+y:(-?\d+\.?\d*)", str(_ax_attr(obj, kAXPositionAttribute)))
size = re.search(r"w:(-?\d+\.?\d*)\s+h:(-?\d+\.?\d*)", str(_ax_attr(obj, kAXSizeAttribute)))
if position is None or size is None:
return None
return tuple(float(v) for v in (position.group(1), position.group(2), size.group(1), size.group(2)))
class DisplayContentsTests(AccessibilityBridgeMacTestCase):
FIXTURE = "display_contents.html"
def _button_titled(self, title):
for button in find_all_by_role(self.web, "AXButton"):
if _ax_attr(button, kAXTitleAttribute) == title:
return button
self.fail(f"no AXButton titled {title!r}")
def _group_titled(self, title):
found = None
def visit(obj, _depth):
nonlocal found
if (
found is None
and _ax_attr(obj, kAXRoleAttribute) == "AXGroup"
and _ax_attr(obj, kAXTitleAttribute) == title
):
found = obj
walk(self.web, visit)
self.assertIsNotNone(found, f"no AXGroup titled {title!r}")
return found
def test_unnamed_display_contents_div_collapses(self):
"""The button inside an unnamed display:contents div hangs off the AXWebArea, not off an untitled AXGroup —
the div is a nameless generic, and the wrapper ignores those whether or not they have a box."""
button = self._button_titled("Contents button")
parent = _ax_attr(button, kAXParentAttribute)
self.assertIsNotNone(parent)
self.assertEqual(_ax_attr(parent, kAXRoleAttribute), "AXWebArea")
def test_named_display_contents_group_is_exposed_with_its_children(self):
"""A display:contents element with role=group and a name is in the tree, and its buttons are its children."""
group = self._group_titled("Contents group")
child_titles = [_ax_attr(child, kAXTitleAttribute) for child in _ax_attr(group, kAXChildrenAttribute) or []]
self.assertEqual(child_titles, ["First grouped button", "Second grouped button"])
def test_named_display_contents_group_frame_spans_its_children(self):
"""The group has no box of its own, so its frame is the union of its children's frames — not an empty rect
at the origin, which would put VoiceOver's cursor nowhere."""
group = self._group_titled("Contents group")
first = _rect(self._button_titled("First grouped button"))
second = _rect(self._button_titled("Second grouped button"))
self.assertIsNotNone(first)
self.assertIsNotNone(second)
left = min(first[0], second[0])
top = min(first[1], second[1])
right = max(first[0] + first[2], second[0] + second[2])
bottom = max(first[1] + first[3], second[1] + second[3])
self.assertEqual(_rect(group), (left, top, right - left, bottom - top))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,372 @@
"""Lifecycle invariants — page-load focus, AXWebArea presence, basic tree shape."""
from __future__ import annotations
import http.server
import pathlib
import sys
import threading
import time
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from AppKit import NSRunningApplication # noqa: E402
from AppKit import NSWorkspace # noqa: E402
from ApplicationServices import AXUIElementCreateApplication # noqa: E402
from ApplicationServices import AXUIElementSetAttributeValue # noqa: E402
from ApplicationServices import kAXChildrenAttribute # noqa: E402
from ApplicationServices import kAXFocusedAttribute # noqa: E402
from ApplicationServices import kAXFocusedUIElementAttribute # noqa: E402
from ApplicationServices import kAXFrontmostAttribute # noqa: E402
from ApplicationServices import kAXRoleAttribute # noqa: E402
from ApplicationServices import kAXTitleAttribute # noqa: E402
from CoreFoundation import CFRunLoopRunInMode # noqa: E402
from CoreFoundation import kCFRunLoopDefaultMode # noqa: E402
from harness import FIXTURE_DIR # noqa: E402
from harness import AccessibilityBridgeMacTestCase # noqa: E402
from harness import LadybirdContext # noqa: E402
from harness import NotificationCollector # noqa: E402
from harness import find_first_by_role # noqa: E402
from harness import wait_for # noqa: E402
from harness import wait_for_descendant_by_role # noqa: E402
from harness.ladybird import _ax_attr # noqa: E402
from harness.ladybird import _find_by_role # noqa: E402
class WebAreaPresenceTests(AccessibilityBridgeMacTestCase):
"""The AXWebArea exists and has children after page load."""
FIXTURE = "roles.html"
def test_web_area_exists(self):
"""The AXWebArea (document root) is reachable from the application."""
self.assertIsNotNone(self.web)
self.assertEqual(_ax_attr(self.web, kAXRoleAttribute), "AXWebArea")
def test_web_area_has_children(self):
"""The AXWebArea has at least one child accessibility element."""
children = _ax_attr(self.web, kAXChildrenAttribute) or []
self.assertGreater(
len(children),
0,
"AXWebArea has no children — accessibility tree did not populate",
)
class FocusLeavesEveryElementTests(AccessibilityBridgeMacTestCase):
"""Focus leaving every element lands on the AXWebArea — not on the body's group.
The page's link takes focus (AXFocused=YES moves DOM focus there, and the web view takes first responder with it)
and drops it again half a second later (focus_blur.html). Once it has, the application's AXFocusedUIElement is the
AXWebArea, and every AXFocusedUIElementChanged after the link's names the web area — AppKit posts that one itself
once it's told the focus changed, as it does for WebKit — and none names the body, the element activeElement falls
back to, which isn't focused. Whatever precedes the link's isn't the blur's doing."""
FIXTURE = "focus_blur.html"
def test_blur_lands_on_the_web_area(self):
collector = NotificationCollector(self.ctx.pid, self.app, ["AXFocusedUIElementChanged"])
try:
# Let anything still in flight from the initial load land before the experiment starts.
collector.collect(1.0)
collector.clear()
link = wait_for_descendant_by_role(self.web, "AXLink")
self.assertIsNotNone(link, "focus_blur.html must expose an AXLink")
err = AXUIElementSetAttributeValue(link, kAXFocusedAttribute, True)
self.assertEqual(err, 0, "setting AXFocused on the link failed")
def link_focused():
collector.collect(0.1)
return bool(_ax_attr(link, kAXFocusedAttribute))
self.assertTrue(
wait_for(link_focused, description="the link to take focus"), "the link never reported AXFocused"
)
self.assertTrue(
wait_for(lambda: not link_focused(), description="the link to drop focus"),
"the link kept AXFocused; the fixture's blur never ran",
)
# Give the blur's notifications time to arrive.
collector.collect(1.0)
finally:
collector.close()
roles = [_ax_attr(elem, kAXRoleAttribute) for elem in collector.elements_for("AXFocusedUIElementChanged")]
self.assertIn("AXLink", roles, f"no AXFocusedUIElementChanged for the link; notifications were for {roles}")
after_link = roles[len(roles) - roles[::-1].index("AXLink") :]
self.assertEqual(
[role for role in after_link if role != "AXWebArea"],
[],
"AXFocusedUIElementChanged named something other than the web area after the link's; "
f"notifications were for {roles}",
)
focused = _ax_attr(self.app, kAXFocusedUIElementAttribute)
self.assertIsNotNone(focused, "the application reports no focused UI element after the blur")
self.assertEqual(
_ax_attr(focused, kAXRoleAttribute),
"AXWebArea",
f"after the blur the focused UI element is {_ax_attr(focused, kAXRoleAttribute)}, not the AXWebArea",
)
class FocusIntoIframeTests(AccessibilityBridgeMacTestCase):
"""Focus moving into a same-process iframe lands on the iframe element — never back on the AXWebArea.
iframe_focus.html hands the focus from its top link on into a srcdoc iframe's link. The tree covers the top-level
document only, so the iframe document's own focus reports (its focused link, then its root) name nodes the tree
doesn't have; Document::set_active_element() leaves the reporting to the top-level document, which focuses the
iframe element in the same pass. So every AXFocusedUIElementChanged after the link's names the iframe element,
and it is the application's AXFocusedUIElement: VoiceOver isn't sent back to the top of the page."""
FIXTURE = "iframe_focus.html"
def test_focus_into_iframe_lands_on_the_iframe_element(self):
collector = NotificationCollector(self.ctx.pid, self.app, ["AXFocusedUIElementChanged"])
try:
# Let anything still in flight from the initial load land before the experiment starts.
collector.collect(1.0)
collector.clear()
link = wait_for_descendant_by_role(self.web, "AXLink", title="Top link")
self.assertIsNotNone(link, "iframe_focus.html must expose its top link")
err = AXUIElementSetAttributeValue(link, kAXFocusedAttribute, True)
self.assertEqual(err, 0, "setting AXFocused on the link failed")
def link_focused():
collector.collect(0.1)
return bool(_ax_attr(link, kAXFocusedAttribute))
self.assertTrue(
wait_for(link_focused, description="the link to take focus"), "the link never reported AXFocused"
)
self.assertTrue(
wait_for(lambda: not link_focused(), description="the link to hand the focus on"),
"the link kept AXFocused; the fixture never moved the focus into the iframe",
)
# Give the iframe's focus reports time to arrive.
collector.collect(1.0)
finally:
collector.close()
roles = [_ax_attr(elem, kAXRoleAttribute) for elem in collector.elements_for("AXFocusedUIElementChanged")]
self.assertIn("AXLink", roles, f"no AXFocusedUIElementChanged for the link; notifications were for {roles}")
after_link = roles[len(roles) - roles[::-1].index("AXLink") :]
self.assertNotIn(
"AXWebArea",
after_link,
"AXFocusedUIElementChanged named the web area after the link's: the iframe's focus reports pulled the "
f"focus off every node; notifications were for {roles}",
)
focused = _ax_attr(self.app, kAXFocusedUIElementAttribute)
self.assertIsNotNone(focused, "the application reports no focused UI element after the focus moved")
self.assertEqual(
(_ax_attr(focused, kAXRoleAttribute), _ax_attr(focused, kAXTitleAttribute)),
("AXGroup", "Frame"),
"after the focus moved into the iframe, the focused UI element isn't the iframe element",
)
def _bring_to_front(app) -> bool:
"""Makes an application the frontmost one, as VoiceOver does with the app its user works in. macOS can turn that
down (while someone's typing into another app, e.g.), so this asks again every second, for up to 10s. Returns
whether the application came to the front."""
for _ in range(10):
AXUIElementSetAttributeValue(app, kAXFrontmostAttribute, True)
if wait_for(lambda: bool(_ax_attr(app, kAXFrontmostAttribute)), timeout=1.0, description="the app in front"):
return True
return False
def _frontmost_app():
"""The frontmost application's AXUIElement, or None."""
# NSWorkspace learns of activations through the run loop, so let that run first.
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.05, False)
running = NSWorkspace.sharedWorkspace().frontmostApplication()
return AXUIElementCreateApplication(running.processIdentifier()) if running is not None else None
class _FixtureServer(http.server.ThreadingHTTPServer):
"""Serves the fixture directory on 127.0.0.1, and records the load late_reader.html reports to /loaded."""
def __init__(self):
self.loaded = threading.Event()
loaded = self.loaded
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(FIXTURE_DIR), **kwargs)
def log_message(self, *args):
pass
def do_GET(self):
if self.path == "/loaded":
loaded.set()
self.send_response(204)
self.end_headers()
return
super().do_GET()
super().__init__(("127.0.0.1", 0), Handler)
threading.Thread(target=self.serve_forever, daemon=True).start()
def url(self, fixture: str) -> str:
return f"http://127.0.0.1:{self.server_address[1]}/{fixture}"
def close(self) -> None:
self.shutdown()
self.server_close()
class _ReaderArrivesAfterLoad(unittest.TestCase):
"""Launches Ladybird on late_reader.html, and waits for the load to finish without touching the app's accessibility.
A web view fetches its tree only once Qt's accessibility is active, which the first accessibility query into a Qt
window turns on (-[QNSView activateQtAccessibility]) — any query, the window's title included. So these tests make
none until the page has loaded: They serve the fixture over HTTP and wait for the load it reports to the server.
The first query a test then makes is the one that turns Qt's accessibility on, as an assistive technology started
after the load would.
Ladybird has to be in the background, as on a runner, where nothing brings a launched app to the front: a web view
that holds the keyboard focus in the key window reports the focus to a reader that turns up. Qt activates the app
on launch on some machines, so the launch hands the front back to whichever app had it."""
@classmethod
def setUpClass(cls):
super().setUpClass()
if NSWorkspace.sharedWorkspace().isVoiceOverEnabled():
raise unittest.SkipTest("VoiceOver is on, so the web view fetches its tree while the page loads")
previous = _frontmost_app()
cls._server = _FixtureServer()
cls._ctx = LadybirdContext(cls._server.url("late_reader.html"), wait_for_web_area=False)
cls._ctx.start()
if not cls._server.loaded.wait(timeout=30):
cls._ctx.stop()
cls._server.close()
raise RuntimeError("late_reader.html never reported its load; the page didn't load")
# The load event has fired; give WebContent's load-finish report to the UI a moment to land too.
time.sleep(0.5)
# NSRunningApplication answers this without an accessibility query into Ladybird; it learns of activations
# through the run loop, so that runs first.
running = NSRunningApplication.runningApplicationWithProcessIdentifier_(cls._ctx.pid)
def ladybird_is_active():
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.05, False)
return running is not None and running.isActive()
if ladybird_is_active() and previous is not None:
_bring_to_front(previous)
if not wait_for(lambda: not ladybird_is_active(), description="Ladybird to leave the front"):
cls._ctx.stop()
cls._server.close()
raise unittest.SkipTest("Ladybird started as the active app, and stayed there")
@classmethod
def tearDownClass(cls):
ctx = getattr(cls, "_ctx", None)
if ctx is not None:
ctx.stop()
server = getattr(cls, "_server", None)
if server is not None:
server.close()
super().tearDownClass()
@property
def ctx(self) -> LadybirdContext:
return type(self)._ctx
def populated_web_area(self):
"""The document root once it has children, else None."""
web = self.ctx.find_web_area()
if web is None or not (_ax_attr(web, kAXChildrenAttribute) or []):
return None
return web
class ReaderArrivingAfterLoadTests(_ReaderArrivesAfterLoad):
"""An assistive technology that first reads a page after its load gets the tree — but no load announcement.
Nobody was reading while the page loaded, so the web view didn't fetch its tree: The first read turns Qt's
accessibility on, which fetches it. No AXLoadComplete or AXFocusedUIElementChanged comes with that tree: The page
doesn't pull a reader that turns up later away from wherever it is, and the keyboard focus stays where the browser
put it."""
def test_first_read_after_load_fetches_the_tree(self):
app = self.ctx.app
collector = NotificationCollector(self.ctx.pid, app, ["AXLoadComplete", "AXFocusedUIElementChanged"])
try:
web = wait_for(self.populated_web_area, description="the page's tree")
self.assertIsNotNone(web, "reading into the page never brought its tree")
self.assertIsNotNone(
find_first_by_role(web, "AXHeading"), "the page's tree arrived without the page's heading"
)
collector.collect(1.0)
finally:
collector.close()
self.assertEqual(collector.names(), [], "the first read into a loaded page announced a load or moved the focus")
class ReaderMovingFocusIntoLoadedPageTests(_ReaderArrivesAfterLoad):
"""An assistive technology that arrives after the load, and moves the focus into the page, hears where the page's
focus is: on the text field the page focused while it loaded.
The reader brings Ladybird to the front first, as VoiceOver does with the app its user works in: A web view reports
the focus only while it's the key window's first responder, as in Chrome. It then focuses the web view's element
(the AXWebArea Qt's element for the widget presents as), which gives the widget the keyboard focus. The first query
into the window had turned Qt's accessibility on, and the tree that fetched arrives without a focused element to
report until it's there; once it is, the view has AppKit look the focus up again and post AXFocusedUIElementChanged
for the text field."""
def test_focus_moved_into_the_page_lands_on_the_page_focus(self):
app = self.ctx.app
# Qt's element for the web view: the first AXWebArea under the window (the document root is the one inside it).
view = _find_by_role(self.ctx.window(), "AXWebArea")
self.assertIsNotNone(view, "no web view (AXWebArea) in the window")
# Hand the front back to whichever app had it, so that the next test's Ladybird doesn't start as the active app.
previous = _frontmost_app()
if previous is not None:
self.addCleanup(_bring_to_front, previous)
collector = NotificationCollector(self.ctx.pid, app, ["AXLoadComplete", "AXFocusedUIElementChanged"])
try:
if not _bring_to_front(app):
self.skipTest("macOS didn't let the test bring Ladybird to the front")
collector.collect(0.5)
# The window that became key may have put the focus in the web view already, which makes this a no-op. It's
# for a window that hands the focus to its address bar instead.
err = AXUIElementSetAttributeValue(view, kAXFocusedAttribute, True)
self.assertEqual(err, 0, "setting AXFocused on the web view failed")
def field_focused():
collector.collect(0.1)
focused = _ax_attr(app, kAXFocusedUIElementAttribute)
return _ax_attr(focused, kAXTitleAttribute) == "Name"
self.assertTrue(
wait_for(field_focused, description="the page's text field to be the focused element"),
"the page's focused text field never became the application's focused element",
)
collector.collect(1.0)
finally:
collector.close()
self.assertNotIn("AXLoadComplete", collector.names(), "moving the focus into a loaded page announced a load")
focus_changes = [
(_ax_attr(elem, kAXRoleAttribute), _ax_attr(elem, kAXTitleAttribute))
for elem in collector.elements_for("AXFocusedUIElementChanged")
]
self.assertTrue(focus_changes, "no AXFocusedUIElementChanged arrived")
self.assertEqual(
focus_changes[-1],
("AXTextField", "Name"),
f"the last AXFocusedUIElementChanged didn't name the page's text field; they named {focus_changes}",
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,582 @@
"""Regression tests — one per fixed bug, named after the symptom.
When you fix a bug in this area of the code, add a regression test (class) here that would have caught it. Use the
docstring for the class to describe the symptom + root cause + fix. Cite commit hashes, if you have them."""
from __future__ import annotations
import pathlib
import re
import sys
import time
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from ApplicationServices import AXUIElementCopyAttributeValue # noqa: E402
from ApplicationServices import AXUIElementIsAttributeSettable # noqa: E402
from ApplicationServices import AXUIElementSetAttributeValue # noqa: E402
from ApplicationServices import kAXFocusedAttribute # noqa: E402
from ApplicationServices import kAXParentAttribute # noqa: E402
from ApplicationServices import kAXPositionAttribute # noqa: E402
from ApplicationServices import kAXRoleAttribute # noqa: E402
from ApplicationServices import kAXSizeAttribute # noqa: E402
from ApplicationServices import kAXValueAttribute # noqa: E402
from harness import AccessibilityBridgeMacTestCase # noqa: E402
from harness import NotificationCollector # noqa: E402
from harness import find_all_by_role # noqa: E402
from harness import get_attribute_names # noqa: E402
from harness import get_parameterized_attribute_value # noqa: E402
from harness import wait_for # noqa: E402
from harness import wait_for_descendant_by_role # noqa: E402
from harness import walk # noqa: E402
def _ax_attr(elem, key):
if elem is None:
return None
err, value = AXUIElementCopyAttributeValue(elem, key, None)
return None if err != 0 else value
class SelectedTextMarkerRangeLoopRegressionTests(AccessibilityBridgeMacTestCase):
"""Symptom: with VoiceOver auto-read on a multi-paragraph page, VO reads the first paragraph, then immediately
re-reads it again and again — never advancing.
Root cause: Our code tried adding an AXSelectedTextMarkerRange getter on the AXWebArea that always returned a fixed
zero-width range pinned to the start of the first text leaf — with no setter to accept VoiceOver's cursor writes.
VoiceOver's continuous-read consults that attribute after each chunk to confirm cursor advancement, gets back the
same start anchor every time, concludes either "the page reset" or "the cursor never advanced", and then re-reads
the current chunk — forever.
Fix: Don't advertise the attribute at all unless we actually track cursor state and accept writes. So, test: if the
attribute *is* advertised, it must be settable. A non-settable, fixed-value advertisement is the failure mode."""
FIXTURE = "paragraphs.html"
def test_axselectedtextmarkerrange_is_settable_if_advertised(self):
"""If AXSelectedTextMarkerRange is on the attribute list, it must be settable (have a setter that accepts
VoiceOver's cursor writes)."""
attr_names = get_attribute_names(self.web)
if "AXSelectedTextMarkerRange" not in attr_names:
# OK — not advertised at all. That's the V1 fix state.
return
err, settable = AXUIElementIsAttributeSettable(self.web, "AXSelectedTextMarkerRange", None)
self.assertEqual(err, 0, "AXUIElementIsAttributeSettable returned non-zero err")
self.assertTrue(
settable,
"AXSelectedTextMarkerRange is advertised on the AXWebArea but is *not* settable. "
"VoiceOver's continuous-read writes to this attribute to advance its cursor; without "
"a setter, every read returns the same fixed value, and VO+A enters an infinite "
"re-read loop on the first paragraph.",
)
def test_axvisiblecharacterrange_is_not_a_lie(self):
"""If AXVisibleCharacterRange is advertised on the AXWebArea, it must report a length consistent with the actual
text content — not a lie like (0, 1) that claims one character of the entire document is visible."""
attr_names = get_attribute_names(self.web)
if "AXVisibleCharacterRange" not in attr_names:
return
rng = _ax_attr(self.web, "AXVisibleCharacterRange")
# The CFRange comes back as an opaque AXValue. Its repr looks like:
# <AXValue> {value = location:0 length:NNN type = kAXValueCFRangeType}
# We don't have a clean unwrap helper here — so do a string-pattern check.
rng_str = str(rng)
self.assertIn("kAXValueCFRangeType", rng_str)
# paragraphs.html has well over 100 characters of body text; a length of 0 or 1 is a lie. Use a stringly-typed
# check: the value should not be 'length:0' or 'length:1'.
self.assertNotIn("length:0 ", rng_str, f"AXVisibleCharacterRange reports length 0: {rng_str}")
self.assertNotIn("length:1 ", rng_str, f"AXVisibleCharacterRange reports length 1: {rng_str}")
class AccessibilityFocusedUIElementStableTests(AccessibilityBridgeMacTestCase):
"""Symptom: VoiceOver's accessibilityFocusedUIElement appears to bounce or reset on every read after page load —
causing AT clients to perceive repeated page-load events.
Defense: read the application's accessibilityFocusedUIElement repeatedly — and assert it stays stable across calls
(no value changes when nothing the test does could cause focus to move)."""
FIXTURE = "paragraphs.html"
def test_focused_ui_element_is_stable_across_reads(self):
"""Reading the application's AXFocusedUIElement five times in quick succession should return the same element
each time. If it bounces, downstream VO behavior is unpredictable."""
from ApplicationServices import kAXFocusedUIElementAttribute
first = _ax_attr(self.app, kAXFocusedUIElementAttribute)
# No focused element at all likely means the page has no focusable elements; that's fine, not a regression.
if first is None:
return
for _ in range(4):
now = _ax_attr(self.app, kAXFocusedUIElementAttribute)
# PyObjC AXUIElement equality is by identity — but two AXUIElements referring to the same backing object
# compare equal. So plain == is the right check.
self.assertEqual(
now,
first,
"accessibilityFocusedUIElement bounces across reads — VoiceOver will see this as "
"constant focus changes and may misbehave",
)
class ParagraphTraversalAdvancesTests(AccessibilityBridgeMacTestCase):
"""Symptom-level defense: VO+A on a multi-paragraph page must advance forward through paragraphs rather than looping
on the first one. This test simulates VO's forward navigation by calling AXUIElementsForSearchPredicate from each
paragraph-text leaf and asserting the search advances to a different element."""
FIXTURE = "paragraphs.html"
def test_search_predicate_forward_advances(self):
"""Find each AXStaticText under the AXWebArea, ask for "next" via the search predicate, and assert that the
result is *not* the same element."""
seen_advances = 0
loops_detected = []
text_leaves = []
def collect_text(obj, _depth):
if _ax_attr(obj, kAXRoleAttribute) == "AXStaticText":
text_leaves.append(obj)
walk(self.web, collect_text)
self.assertGreater(len(text_leaves), 1, "expected multiple AXStaticText leaves in paragraphs.html")
forward_pred = {
"AXDirection": "AXDirectionNext",
"AXResultsLimit": 1,
"AXImmediateDescendantsOnly": False,
}
for leaf in text_leaves[:5]: # cap at 5 — don't hammer
forward_pred_with_start = dict(forward_pred)
forward_pred_with_start["AXStartElement"] = leaf
result = get_parameterized_attribute_value(
self.web, "AXUIElementsForSearchPredicate", forward_pred_with_start
)
if not result:
continue # last leaf: no next; that's fine
next_element = result[0]
value_before = _ax_attr(leaf, kAXValueAttribute)
value_after = _ax_attr(next_element, kAXValueAttribute)
if next_element == leaf:
loops_detected.append(repr(value_before))
elif value_before != value_after:
seen_advances += 1
else:
# Same value but different element — fine, two leaves can have the same text.
seen_advances += 1
self.assertEqual(loops_detected, [], f"search predicate returned starting element as 'next': {loops_detected}")
self.assertGreater(seen_advances, 0, "search predicate never advanced past any leaf")
class TextLeafFocusActionDoesNotPromoteInlineAncestorTests(AccessibilityBridgeMacTestCase):
"""Symptom: VoiceOver auto-read pauses on each abbr (and some other element cases). VoiceOver reads the abbr's text,
then interrupts itself to announce, "Foo bar baz, group, inside heading level 1" — where "Foo bar baz" is the abbr's
title attribute exposed as a "selectable group" name.
Root cause: When VO set focus on an AXStaticText leaf via accessibilitySetValue:NSAccessibilityFocusedAttribute=YES,
our perform_accessibility_action handler walked up to the leaf's DOM parent (e.g., an abbr element), force-added
tabindex=-1 on it, and called run_focusing_steps. The abbr then got DOM focus — so include_in_accessibility_tree
returned true, and the abbr appeared as a separate AX-tree node. Its empty role mapped to NSAccessibilityGroupRole;
its accessible name picked up the title attribute. So VoiceOver received the focus-changed notification and then
unexpectedly announced the abbr content as a "selectable group".
Fix: In the focus-action branch of perform_accessibility_action, return early when the original target is a
non-element (text node) and we walked up to find an element."""
FIXTURE = "abbreviations.html"
def test_focus_action_on_abbr_text_leaf_does_not_create_inline_group(self):
"""Find the AXStaticText whose value is "CSS" (the abbr text), snapshot its parent-chain roles, send the focus
action on it, wait for the debounced tree refresh, and verify no new AXGroup with the abbr's title appeared in
the chain."""
import time
from ApplicationServices import AXUIElementSetAttributeValue
from ApplicationServices import kAXFocusedAttribute
from ApplicationServices import kAXParentAttribute
from ApplicationServices import kAXTitleAttribute
# The fixture has <abbr title="Cascading Style Sheets">CSS</abbr> inside the H1. Find that specific text leaf
# rather than just any AXStaticText.
abbr_leaf = None
def find_abbr_text(obj, _depth):
nonlocal abbr_leaf
if (
abbr_leaf is None
and _ax_attr(obj, kAXRoleAttribute) == "AXStaticText"
and _ax_attr(obj, kAXValueAttribute) == "CSS"
):
abbr_leaf = obj
walk(self.web, find_abbr_text)
self.assertIsNotNone(abbr_leaf, "abbreviations.html should expose AXStaticText with value 'CSS'")
def parent_chain(elem, n=6):
chain = []
cur = elem
for _ in range(n):
if cur is None:
break
chain.append((_ax_attr(cur, kAXRoleAttribute), _ax_attr(cur, kAXTitleAttribute)))
cur = _ax_attr(cur, kAXParentAttribute)
return chain
# Snapshot the chain before the focus action (used in failure messages for diagnostics).
before_chain = parent_chain(abbr_leaf) # noqa: F841
err = AXUIElementSetAttributeValue(abbr_leaf, kAXFocusedAttribute, True)
self.assertEqual(err, 0)
# WebContent debounces accessibility tree updates ~200 ms; allow extra slack.
time.sleep(1.5)
# Re-acquire the leaf — element handles can become stale across tree refreshes.
new_leaf = None
def find_abbr_text_after(obj, _depth):
nonlocal new_leaf
if (
new_leaf is None
and _ax_attr(obj, kAXRoleAttribute) == "AXStaticText"
and _ax_attr(obj, kAXValueAttribute) == "CSS"
):
new_leaf = obj
walk(self.web, find_abbr_text_after)
self.assertIsNotNone(new_leaf, "AXStaticText 'CSS' disappeared after focus action")
after_chain = parent_chain(new_leaf)
# Specifically: no new AXGroup should appear in the chain whose title is the abbr's title attribute.
for role, title in after_chain:
self.assertFalse(
role == "AXGroup" and title == "Cascading Style Sheets",
f"focus action on the abbr text leaf created a new AXGroup with title='Cascading "
f"Style Sheets' (the abbr's title attribute). Parent chain after: {after_chain}. "
"This is the exact failure mode behind VoiceOver interrupting auto-read on each abbr.",
)
def _static_text_values(root):
"""Every AXStaticText value under root, in tree order."""
values = []
def visit(obj, _depth):
if _ax_attr(obj, kAXRoleAttribute) == "AXStaticText":
value = _ax_attr(obj, kAXValueAttribute)
if isinstance(value, str):
values.append(value)
walk(root, visit)
return values
def _parse_cf_range(value):
"""Unwrap an AXValue CFRange into (location, length). PyObjC has no clean unwrapper for this opaque CFType, but
its description is stable: "<AXValue 0x...> {value = location:2 length:1 type = kAXValueCFRangeType}"."""
import re
m = re.search(r"location:(\d+)\s+length:(\d+)", str(value))
if m is None:
return None
return int(m.group(1)), int(m.group(2))
class EndTextMarkerOffsetIsInCodePointsTests(AccessibilityBridgeMacTestCase):
"""Symptom: on a page whose text ends with a non-ASCII character, VoiceOver's cursor could be placed past the end
of the document, and reading backward from the end announced phantom characters before reaching the last real one.
Root cause: AXEndTextMarker put the last text leaf's UTF-8 byte length in the marker's offset, while every other
marker the wrapper hands out (AXNextTextMarkerForTextMarker, AXTextMarkerRangeForUIElement) counts code points. A
leaf ending in an emoji (4 bytes, 1 code point) so got an end marker 3 positions past its last character.
Fix: build the end marker from codePointCount(), like the other markers."""
FIXTURE = "nonbmp_text.html"
def test_steps_back_from_end_marker_to_its_leaf_start_equal_the_leaf_code_point_count(self):
"""Walking AXPreviousTextMarkerForTextMarker from AXEndTextMarker stays inside the last leaf for exactly as
many steps as that leaf has code points: the end marker sits at offset N, each step moves to N-1, and the step
from offset 0 crosses into the previous leaf. A byte-based end offset takes extra steps for each multi-byte
character in the leaf."""
end = _ax_attr(self.web, "AXEndTextMarker")
self.assertIsNotNone(end, "AXWebArea must expose AXEndTextMarker")
end_leaf = get_parameterized_attribute_value(self.web, "AXUIElementForTextMarker", end)
self.assertIsNotNone(end_leaf, "AXUIElementForTextMarker(AXEndTextMarker) returned nil")
self.assertEqual(_ax_attr(end_leaf, kAXRoleAttribute), "AXStaticText")
leaf_value = _ax_attr(end_leaf, kAXValueAttribute)
self.assertIsInstance(leaf_value, str)
self.assertTrue(
any(ord(ch) > 0xFFFF for ch in leaf_value),
f"fixture's last leaf must end in a non-BMP character to make the units observable: {leaf_value!r}",
)
steps = 0
marker = end
# Bound the walk generously above the byte length so a runaway loop fails the test rather than hanging it.
limit = 4 * len(leaf_value) + 16
while steps <= limit:
prev = get_parameterized_attribute_value(self.web, "AXPreviousTextMarkerForTextMarker", marker)
if prev is None:
break
leaf = get_parameterized_attribute_value(self.web, "AXUIElementForTextMarker", prev)
if leaf != end_leaf:
break
steps += 1
marker = prev
self.assertEqual(
steps,
len(leaf_value),
f"AXEndTextMarker is {steps} steps from the start of its leaf, but the leaf has {len(leaf_value)} code "
f"points ({len(leaf_value.encode('utf-8'))} UTF-8 bytes): {leaf_value!r}",
)
class LengthForTextMarkerRangeIsInCodePointsTests(AccessibilityBridgeMacTestCase):
"""Symptom: for text containing characters outside the Basic Multilingual Plane, AXLengthForTextMarkerRange
disagreed with the number of AXNextTextMarkerForTextMarker steps that cover the same range, so VoiceOver's
character-by-character navigation and its range bookkeeping fell out of step by one per such character.
Root cause: the wrapper answered AXLengthForTextMarkerRange with the NSString's length, which counts UTF-16 code
units, while markers step and slice by code point.
Fix: count the code points appended to the range's string, and report that."""
FIXTURE = "nonbmp_text.html"
def test_length_equals_the_code_point_count_of_the_range_string(self):
leaves = find_all_by_role(self.web, "AXStaticText")
checked = 0
for leaf in leaves:
leaf_value = _ax_attr(leaf, kAXValueAttribute)
if not isinstance(leaf_value, str) or not any(ord(ch) > 0xFFFF for ch in leaf_value):
continue
marker_range = get_parameterized_attribute_value(leaf, "AXTextMarkerRangeForUIElement", leaf)
self.assertIsNotNone(marker_range, f"AXTextMarkerRangeForUIElement returned nil for {leaf_value!r}")
text = get_parameterized_attribute_value(self.web, "AXStringForTextMarkerRange", marker_range)
length = get_parameterized_attribute_value(self.web, "AXLengthForTextMarkerRange", marker_range)
self.assertEqual(text, leaf_value)
self.assertEqual(
length,
len(text),
f"AXLengthForTextMarkerRange={length!r} but the range's string has {len(text)} code points "
f"({len(text.encode('utf-16-le')) // 2} UTF-16 code units): {text!r}",
)
checked += 1
self.assertGreater(checked, 0, "fixture must contain at least one text leaf with a non-BMP character")
class ColspanCellColumnIndexTests(AccessibilityBridgeMacTestCase):
"""Symptom: in a table whose row starts with a cell spanning two columns, VoiceOver announced the next cell as
column 2 rather than column 3, and its AXColumnIndexRange overlapped the spanning cell's.
Root cause: the wrapper computed a cell's column index by counting its preceding siblings in the row, one column
each, ignoring their colspan.
Fix: use the colspan-aware column index WebContent already serializes for every table cell, and keep the sibling
count only as a fallback for a cell that comes without one."""
FIXTURE = "table_colspan.html"
def _cell_with_text(self, text):
cells = find_all_by_role(self.web, "AXCell")
self.assertGreater(len(cells), 0, "expected AXCell elements in table_colspan.html")
for cell in cells:
if text in _static_text_values(cell):
return cell
self.fail(f"no AXCell contains the text {text!r}")
def test_cell_after_a_two_column_cell_starts_at_column_two(self):
after = self._cell_with_text("After the wide cell")
rng = _parse_cf_range(_ax_attr(after, "AXColumnIndexRange"))
self.assertIsNotNone(rng, "AXColumnIndexRange must be a CFRange AXValue")
self.assertEqual(rng, (2, 1), f"cell after a colspan=2 cell should occupy column 2 alone, got {rng}")
def test_two_column_cell_spans_columns_zero_and_one(self):
wide = self._cell_with_text("Wide cell")
rng = _parse_cf_range(_ax_attr(wide, "AXColumnIndexRange"))
self.assertIsNotNone(rng, "AXColumnIndexRange must be a CFRange AXValue")
self.assertEqual(rng, (0, 2), f"a colspan=2 cell in the first column should report (0, 2), got {rng}")
def test_plain_row_cells_count_up_from_zero(self):
for text, expected in (("One", 0), ("Two", 1), ("Three", 2)):
rng = _parse_cf_range(_ax_attr(self._cell_with_text(text), "AXColumnIndexRange"))
self.assertEqual(rng, (expected, 1), f"cell {text!r} should be at column {expected}, got {rng}")
class ColspanTableColumnCountTests(AccessibilityBridgeMacTestCase):
"""Symptom (latent, found by review): a table whose every row holds a spanning cell reported fewer columns thru
AXColumnCount than it has — two for a three-column table whose rows each hold two cells.
Root cause: the wrapper answered AXColumnCount with the widest row's cell count, one column per cell, ignoring
colspan.
Fix: answer with the colspan-aware column count WebContent serializes for every table, and keep the cell count only
as a fallback for a table that comes without one."""
FIXTURE = "table_colspan_every_row.html"
def test_column_count_counts_spanned_columns(self):
tables = find_all_by_role(self.web, "AXTable")
self.assertEqual(len(tables), 1, "the fixture holds one table")
self.assertEqual(_ax_attr(tables[0], "AXColumnCount"), 3)
# Control: the row count has nothing to do with colspan.
self.assertEqual(_ax_attr(tables[0], "AXRowCount"), 2)
def _wait_for_a_mutation_push(ctx, timeout=10.0, pump=None):
"""Waits until the page's ticking paragraph reads something other than its initial "Tick 0" in a fresh lookup of
the AXWebArea, which proves at least one post-load tree update reached the wrapper. "pump" is called on every poll
so a caller with an AXObserver can keep its run loop turning meanwhile."""
def ticked():
if pump is not None:
pump()
web = ctx.find_web_area()
if web is None:
return False
return any(value.startswith("Tick ") and value != "Tick 0" for value in _static_text_values(web))
return wait_for(ticked, timeout=timeout, interval=0.2, description="a mutation-driven tree update")
def _rect(obj):
"""(x, y, width, height) of obj in screen points, read from its AXPosition and AXSize (as test_display_contents.py
reads them)."""
position = re.search(r"x:(-?\d+\.?\d*)\s+y:(-?\d+\.?\d*)", str(_ax_attr(obj, kAXPositionAttribute)))
size = re.search(r"w:(-?\d+\.?\d*)\s+h:(-?\d+\.?\d*)", str(_ax_attr(obj, kAXSizeAttribute)))
if position is None or size is None:
return None
return tuple(float(v) for v in (position.group(1), position.group(2), size.group(1), size.group(2)))
class TextLeafFrameIsItsOwnBoxTests(AccessibilityBridgeMacTestCase):
"""Symptom: VoiceOver's cursor outline for a short static text spanned its whole paragraph; every AXStaticText
reported its parent element's AXPosition and AXSize.
Root cause: A text node has no layout box of its own, so the tree handed a text leaf the bounding client rect of
its parent element.
Fix: A text leaf's bounds are the union of its rendered lines, in the viewport space every element's box is in —
the box Gecko, WebKit and Blink give a text leaf."""
FIXTURE = "paragraphs.html"
def test_short_static_text_is_narrower_than_its_paragraph(self):
leaves = find_all_by_role(self.web, "AXStaticText")
plain = next(
(leaf for leaf in leaves if str(_ax_attr(leaf, kAXValueAttribute)).startswith("Plain paragraph")), None
)
self.assertIsNotNone(plain, "expected the 'Plain paragraph' AXStaticText")
parent = _ax_attr(plain, kAXParentAttribute)
self.assertIsNotNone(parent)
leaf_rect = _rect(plain)
parent_rect = _rect(parent)
self.assertIsNotNone(leaf_rect, "AXStaticText must have an AXPosition and an AXSize")
self.assertIsNotNone(parent_rect, "its parent must have an AXPosition and an AXSize")
self.assertLess(leaf_rect[2], parent_rect[2], f"leaf {leaf_rect} is as wide as its parent {parent_rect}")
self.assertGreaterEqual(
leaf_rect[0], parent_rect[0], f"leaf {leaf_rect} starts left of its parent {parent_rect}"
)
self.assertLessEqual(
leaf_rect[1] + leaf_rect[3],
parent_rect[1] + parent_rect[3],
f"leaf {leaf_rect} ends below its parent {parent_rect}",
)
class TreeUpdateDoesNotRefocusDocumentTests(AccessibilityBridgeMacTestCase):
"""Symptom: while VoiceOver was reading a page, or the user was typing into a form field on it, any DOM change made
VoiceOver jump back to the top of the document and re-announce the page as newly loaded.
Root cause: the tree-received handler treated every tree the same way, whether it answered the load-time request or
a push after a DOM mutation. Each one took keyboard first-responder status, posted AXFocusedUIElementChanged on
the AXWebArea, posted AXLoadComplete, and refreshed AppKit's focus cache.
Fix: gate that block on a per-page flag that the load-finish path resets, so only the first tree for a page takes
focus and announces the load; later pushes post only AXLayoutChanged."""
FIXTURE = "mutations.html"
def test_mutation_pushes_post_no_load_complete_and_no_root_focus_change(self):
collector = NotificationCollector(
self.ctx.pid, self.app, ["AXLoadComplete", "AXFocusedUIElementChanged", "AXLayoutChanged"]
)
try:
# Let anything still in flight from the initial load land before the experiment starts.
collector.collect(1.0)
collector.clear()
link = wait_for_descendant_by_role(self.web, "AXLink")
self.assertIsNotNone(link, "mutations.html must expose an AXLink")
err = AXUIElementSetAttributeValue(link, kAXFocusedAttribute, True)
self.assertEqual(err, 0, "setting AXFocused on the link failed")
self.assertTrue(
_wait_for_a_mutation_push(self.ctx, pump=lambda: collector.collect(0.1)),
"no mutation-driven tree update arrived within 10s",
)
# Several more pushes: the page mutates every 300 ms and WebContent debounces by 200 ms.
collector.collect(3.0)
finally:
collector.close()
names = collector.names()
# Control: the pushes are observable at all. Every tree update posts AXLayoutChanged, gated or not.
self.assertIn("AXLayoutChanged", names, f"no AXLayoutChanged seen; notifications received: {names}")
self.assertNotIn(
"AXLoadComplete",
names,
"a DOM-mutation tree update re-announced the page as loaded (AXLoadComplete); VoiceOver treats that as a "
f"new page and jumps to the top. Notifications received: {names}",
)
for elem in collector.elements_for("AXFocusedUIElementChanged"):
self.assertNotEqual(
_ax_attr(elem, kAXRoleAttribute),
"AXWebArea",
"a DOM-mutation tree update posted AXFocusedUIElementChanged on the AXWebArea, pulling VoiceOver's "
f"cursor back to the document root. Notifications received: {names}",
)
class TreeUpdatesUnderContinuousMutationTests(AccessibilityBridgeMacTestCase):
"""A page that mutates faster than WebContent's tree-update interval still gets its tree updated.
ticker.html rewrites its status paragraph every 50 ms. WebContent coalesces mutations into one tree update per
200 ms (PageClient::schedule_accessibility_tree_update); a timer that every mutation restarted would never fire for
this page, and the AT would keep the first tree for good."""
FIXTURE = "ticker.html"
def _status_text(self):
def is_status(obj):
value = _ax_attr(obj, kAXValueAttribute)
return isinstance(value, str) and value.startswith("Tick ")
status = wait_for_descendant_by_role(self.web, "AXStaticText", pred=is_status)
self.assertIsNotNone(status, "ticker.html must expose its status text")
return _ax_attr(status, kAXValueAttribute)
def test_status_text_keeps_changing(self):
initial = self._status_text()
# An AT's action on the page is what turns the mutation-driven tree updates on (Page::accessibility_interested),
# so focus the page's link first, as VoiceOver does when it lands on a page.
link = wait_for_descendant_by_role(self.web, "AXLink")
self.assertIsNotNone(link, "ticker.html must expose its link")
err = AXUIElementSetAttributeValue(link, kAXFocusedAttribute, True)
self.assertEqual(err, 0, "setting AXFocused on the link failed")
self.assertTrue(
wait_for(lambda: self._status_text() != initial, timeout=5.0),
f"the tree never updated after the AT's action: the status text still reads {initial!r}",
)
# And the updates have to keep coming: a timer that every mutation restarted would fire once at most.
first = self._status_text()
time.sleep(1.5)
second = self._status_text()
self.assertNotEqual(first, second, f"the tree stopped updating: the status text still reads {first!r}")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,253 @@
"""Role-mapping invariants — what HTML/ARIA roles end up as on the macOS side.
The macOS wrapper's aria_role_to_ns_role + aria_role_to_ns_subrole pair (LadybirdAccessibilityElement.mm) is the
authority being defended here. Notable platform-specific behaviors:
* Headings are AXHeading with the level on AXValue (not a separate attribute).
* <li> maps to AXGroup — not a list-item role.
* All landmarks (nav/main/header/footer/aside/named-<section>) are AXGroup + AXLandmark* subrole. They share a common
role and differ only in subrole + role-description.
* Document is AXWebArea with AXRoleDescription "HTML content"."""
from __future__ import annotations
import pathlib
import sys
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from ApplicationServices import kAXRoleAttribute # noqa: E402
from ApplicationServices import kAXRoleDescriptionAttribute # noqa: E402
from ApplicationServices import kAXSubroleAttribute # noqa: E402
from ApplicationServices import kAXTitleAttribute # noqa: E402
from ApplicationServices import kAXValueAttribute # noqa: E402
from harness import AccessibilityBridgeMacTestCase # noqa: E402
from harness import find_all_by_role # noqa: E402
from harness import find_first_by_role # noqa: E402
from harness import wait_for_descendant_by_role # noqa: E402
from harness import walk # noqa: E402
from harness.ladybird import _ax_attr # noqa: E402
def _find_by_subrole(root, subrole_name):
"""Return all descendants with the given AXSubrole. Convenience helper for landmark searches."""
out = []
def visit(obj, _depth):
if _ax_attr(obj, kAXSubroleAttribute) == subrole_name:
out.append(obj)
walk(root, visit)
return out
class BasicRoleTests(AccessibilityBridgeMacTestCase):
"""The straightforward role mappings: <h1>, <button>, <a>, <img>, <ul>, <li>."""
FIXTURE = "roles.html"
def test_3_headings_present_with_correct_levels(self):
"""<h1>..<h3> all map to AXHeading; AXValue carries the heading level (1, 2, 3)."""
headings = find_all_by_role(self.web, "AXHeading")
self.assertGreaterEqual(len(headings), 3, f"expected at least 3 headings, got {len(headings)}")
by_level = {}
for h in headings:
level = _ax_attr(h, kAXValueAttribute)
title = _ax_attr(h, kAXTitleAttribute)
self.assertIsNotNone(level, f"heading {title!r} has no AXValue (no heading level)")
by_level[level] = title
self.assertEqual(by_level.get(1), "First heading")
self.assertEqual(by_level.get(2), "Second heading")
self.assertEqual(by_level.get(3), "Third heading")
def test_button_role(self):
"""<button> maps to AXButton; the button name is on AXTitle."""
btn = find_first_by_role(self.web, "AXButton")
self.assertIsNotNone(btn, "expected an AXButton in the tree")
self.assertEqual(_ax_attr(btn, kAXTitleAttribute), "A button")
def test_link_role(self):
"""<a href> maps to AXLink. The fixture has 2 links (a nav link + a standalone link)."""
links = find_all_by_role(self.web, "AXLink")
names = [_ax_attr(link, kAXTitleAttribute) for link in links]
self.assertIn("Standalone link", names, f"expected 'Standalone link' in {names}")
self.assertIn("Nav link", names, f"expected 'Nav link' in {names}")
def test_image_role(self):
"""<img alt> maps to AXImage; the alt text is the AXTitle."""
img = find_first_by_role(self.web, "AXImage")
self.assertIsNotNone(img, "expected an AXImage in the tree")
self.assertEqual(_ax_attr(img, kAXTitleAttribute), "An image")
def test_list_role(self):
"""<ul> maps to AXList."""
lst = find_first_by_role(self.web, "AXList")
self.assertIsNotNone(lst, "expected an AXList in the tree")
def test_listitem_maps_to_axgroup(self):
"""<li> maps to AXGroup (NOT a list-item role)
macOS NSAccessibility doesn't have a dedicated list-item role; the wrapper deliberately routes <li> through
AXGroup."""
ul = find_first_by_role(self.web, "AXList")
self.assertIsNotNone(ul, "test prerequisite: AXList must exist")
# The list's children should be AXGroup elements (the list items).
from ApplicationServices import kAXChildrenAttribute
children = _ax_attr(ul, kAXChildrenAttribute) or []
self.assertGreater(len(children), 0, "AXList has no children. list-item leak?")
roles = [_ax_attr(c, kAXRoleAttribute) for c in children]
self.assertEqual(
set(roles),
{"AXGroup"},
f"expected all <li> children to map to AXGroup, got {roles}",
)
class LandmarkSubroleTests(AccessibilityBridgeMacTestCase):
"""All landmarks land at AXRole=AXGroup; what distinguishes them is AXSubrole.
These tests defend the AXLandmark* subrole namespace from drift. VoiceOver's rotor uses these to populate the
"Landmarks" submenu. A wrong subrole means the landmark vanishes from VO navigation."""
FIXTURE = "roles.html"
def test_landmark_navigation(self):
"""<nav> → AXGroup with AXSubrole=AXLandmarkNavigation."""
navs = _find_by_subrole(self.web, "AXLandmarkNavigation")
self.assertGreaterEqual(len(navs), 1, "expected at least one AXLandmarkNavigation")
# Sanity: role is AXGroup
self.assertEqual(_ax_attr(navs[0], kAXRoleAttribute), "AXGroup")
def test_landmark_main(self):
"""<main> → AXGroup with AXSubrole=AXLandmarkMain."""
mains = _find_by_subrole(self.web, "AXLandmarkMain")
self.assertEqual(len(mains), 1, f"expected exactly one AXLandmarkMain, got {len(mains)}")
def test_landmark_banner(self):
"""Top-level <header> → AXGroup with AXSubrole=AXLandmarkBanner.
(HTMLElement.cpp's role-default logic only assigns 'banner' to a top-level <header> — one nested in
article/aside/main/nav/section becomes 'sectionheader' instead, which has no landmark subrole.)"""
banners = _find_by_subrole(self.web, "AXLandmarkBanner")
self.assertGreaterEqual(len(banners), 1, "expected an AXLandmarkBanner from the top-level <header>")
def test_landmark_contentinfo(self):
"""Top-level <footer> → AXGroup with AXSubrole=AXLandmarkContentInfo."""
footers = _find_by_subrole(self.web, "AXLandmarkContentInfo")
self.assertGreaterEqual(len(footers), 1, "expected an AXLandmarkContentInfo from the top-level <footer>")
def test_landmark_complementary_for_aside(self):
"""<aside> (no sectioning ancestor that demotes it to generic) → AXLandmarkComplementary.
roles.html nests <aside> inside <main>. <main> is *not* in HTMLElement.cpp's article/aside/nav/section demotion
list. So the aside keeps its 'complementary' default role and gets the AXLandmarkComplementary subrole."""
asides = _find_by_subrole(self.web, "AXLandmarkComplementary")
self.assertGreaterEqual(len(asides), 1, "expected an AXLandmarkComplementary from <aside>")
def test_landmark_region_for_named_section(self):
"""<section aria-label=...> → AXGroup with AXSubrole=AXLandmarkRegion.
An *unnamed* <section> becomes role=generic (per HTMLElement.cpp) and is filtered out of the macOS tree — so
this only fires for the named one."""
regions = _find_by_subrole(self.web, "AXLandmarkRegion")
self.assertGreaterEqual(len(regions), 1, "expected an AXLandmarkRegion from the named <section>")
def test_unnamed_section_does_not_create_landmark(self):
"""The <section> with no aria-label or no inner heading is role=generic and ignored — must *not* produce a
landmark. Only a named <section> becomes an AXLandmarkRegion, so every region must carry a title; a
title-less region would mean an unnamed section leaked into the tree."""
regions = _find_by_subrole(self.web, "AXLandmarkRegion")
for region in regions:
title = _ax_attr(region, kAXTitleAttribute)
self.assertTrue(
title,
f"every AXLandmarkRegion must come from a named <section>; found one without a title: {title!r}",
)
class RoleDescriptionTests(AccessibilityBridgeMacTestCase):
"""AXRoleDescription is what VoiceOver speaks aloud as the "type" of an element.
The wrapper customizes a handful of these — landmarks plus heading and document — because
NSAccessibilityRoleDescription doesn't recognize the AXLandmark* subrole strings. Without these custom descriptions,
VoiceOver would speak "group" for every landmark."""
FIXTURE = "roles.html"
def test_document_role_description_is_html_content(self):
"""The AXWebArea reports AXRoleDescription="HTML content"."""
self.assertEqual(_ax_attr(self.web, kAXRoleDescriptionAttribute), "HTML content")
def test_heading_role_description_is_heading(self):
"""An AXHeading reports AXRoleDescription="heading" (the wrapper bypasses the OS default)."""
h = find_first_by_role(self.web, "AXHeading")
self.assertIsNotNone(h, "test prerequisite: an AXHeading must exist")
self.assertEqual(_ax_attr(h, kAXRoleDescriptionAttribute), "heading")
def test_landmark_role_descriptions(self):
"""Each AXLandmark* subrole has its own custom AXRoleDescription that VO can speak."""
cases = [
("AXLandmarkNavigation", "navigation"),
("AXLandmarkMain", "main"),
("AXLandmarkBanner", "banner"),
("AXLandmarkContentInfo", "content information"),
("AXLandmarkComplementary", "complementary"),
("AXLandmarkRegion", "region"),
]
for subrole, expected_desc in cases:
with self.subTest(subrole=subrole):
hits = _find_by_subrole(self.web, subrole)
if not hits:
self.skipTest(f"no {subrole} in this fixture")
actual = _ax_attr(hits[0], kAXRoleDescriptionAttribute)
self.assertEqual(actual, expected_desc, f"AXRoleDescription for {subrole}")
class PasswordFieldTests(AccessibilityBridgeMacTestCase):
"""<input type=password> is an AXTextField with the AXSecureTextField subrole, and its value masked.
HTML-AAM gives a password input no ARIA role, so the tree's role is empty; the macOS bridge exposes it as a text
field with the secure subrole, as WebKit, Blink and Gecko do. Its value is the mask the field renders (one U+25CF
per character), never the password."""
FIXTURE = "password.html"
def test_password_field_is_a_secure_text_field(self):
password = wait_for_descendant_by_role(self.web, "AXTextField", title="Password")
self.assertIsNotNone(password, "the password input isn't exposed as an AXTextField")
self.assertEqual(_ax_attr(password, kAXSubroleAttribute), "AXSecureTextField")
self.assertEqual(_ax_attr(password, kAXRoleDescriptionAttribute), "secure text field")
# Control: the text input beside it is a plain text field.
username = wait_for_descendant_by_role(self.web, "AXTextField", title="Username")
self.assertIsNotNone(username, "the text input isn't exposed as an AXTextField")
self.assertIsNone(_ax_attr(username, kAXSubroleAttribute))
def test_password_field_value_is_masked(self):
password = wait_for_descendant_by_role(self.web, "AXTextField", title="Password")
self.assertIsNotNone(password, "the password input isn't exposed as an AXTextField")
self.assertEqual(_ax_attr(password, kAXValueAttribute), chr(0x25CF) * 7)
# Control: the text input beside it reads its value in the clear.
username = wait_for_descendant_by_role(self.web, "AXTextField", title="Username")
self.assertIsNotNone(username, "the text input isn't exposed as an AXTextField")
self.assertEqual(_ax_attr(username, kAXValueAttribute), "ada")
def test_unnamed_password_field_is_exposed(self):
"""A password input nothing names is in the tree too — Element::include_in_accessibility_tree() keeps it, since
the empty role HTML-AAM gives it would otherwise leave it out — as a secure text field with its value masked."""
unnamed = wait_for_descendant_by_role(
self.web, "AXTextField", pred=lambda obj: not _ax_attr(obj, kAXTitleAttribute)
)
self.assertIsNotNone(unnamed, "an unnamed password input isn't exposed as an AXTextField")
self.assertEqual(_ax_attr(unnamed, kAXSubroleAttribute), "AXSecureTextField")
self.assertEqual(_ax_attr(unnamed, kAXValueAttribute), chr(0x25CF) * 6)
# Control: the named password input and the text input are their own nodes beside it.
self.assertEqual(len(find_all_by_role(self.web, "AXTextField")), 3)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,160 @@
"""AXUIElementsForSearchPredicate / AXUIElementCountForSearchPredicate.
VoiceOver uses these (parameterized) attributes for forward/backward navigation, "Find Next" rotor, and similar.
Notable wrapper behaviors this file defends:
* AXDirection accepts "AXDirectionNext" or "AXDirectionPrevious"
* AXStartElement anchors the walk; without one, a forward walk starts at the beginning and a backward one at the end
* AXResultsLimit caps the result count (negative or absent = unlimited)
* Forward walks past a container element skip its descendants (avoids re-traversing them)
* AXSearchKey is *not yet honored* — every search returns the same flat list regardless. We test the current behavior so
a future change (filtering by element type) trips a visible test.
* AXUIElementCountForSearchPredicate returns an NSNumber count, not an array."""
from __future__ import annotations
import pathlib
import sys
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from harness import AccessibilityBridgeMacTestCase # noqa: E402
from harness import find_all_by_role # noqa: E402
from harness import get_parameterized_attribute_value # noqa: E402
def _make_pred(*, direction="AXDirectionNext", start=None, limit=-1, immediate=False):
"""Build the dictionary parameter that the search-predicate APIs accept."""
pred = {
"AXDirection": direction,
"AXResultsLimit": limit,
"AXImmediateDescendantsOnly": immediate,
}
if start is not None:
pred["AXStartElement"] = start
return pred
class SearchPredicateForwardTests(AccessibilityBridgeMacTestCase):
"""Forward walks: AXDirectionNext."""
FIXTURE = "roles.html"
def test_forward_from_first_text_returns_a_list(self):
"""AXUIElementsForSearchPredicate with direction=Next and a starting AXStaticText returns a non-empty list of
subsequent elements."""
leaves = find_all_by_role(self.web, "AXStaticText")
self.assertGreater(len(leaves), 0, "fixture must have AXStaticText leaves")
pred = _make_pred(direction="AXDirectionNext", start=leaves[0], limit=-1)
results = get_parameterized_attribute_value(self.web, "AXUIElementsForSearchPredicate", pred)
self.assertIsNotNone(results)
self.assertGreater(
len(results), 0, f"expected at least one element after the first text leaf, got {len(results)}"
)
def test_forward_with_limit_caps_count(self):
"""AXResultsLimit=3 returns at most 3 results."""
leaves = find_all_by_role(self.web, "AXStaticText")
self.assertGreater(len(leaves), 0)
pred = _make_pred(direction="AXDirectionNext", start=leaves[0], limit=3)
results = get_parameterized_attribute_value(self.web, "AXUIElementsForSearchPredicate", pred)
self.assertIsNotNone(results)
self.assertLessEqual(len(results), 3, f"limit=3 but got {len(results)} results")
def test_forward_with_limit_1_returns_one(self):
"""AXResultsLimit=1 returns at most 1 result. This is the canonical "next element" query VoiceOver uses."""
leaves = find_all_by_role(self.web, "AXStaticText")
self.assertGreater(len(leaves), 0)
pred = _make_pred(direction="AXDirectionNext", start=leaves[0], limit=1)
results = get_parameterized_attribute_value(self.web, "AXUIElementsForSearchPredicate", pred)
self.assertIsNotNone(results)
self.assertLessEqual(len(results), 1)
# If the start was the last element in the document, results is allowed to be empty.
class SearchPredicateBackwardTests(AccessibilityBridgeMacTestCase):
"""Backward walks: AXDirectionPrevious."""
FIXTURE = "roles.html"
def test_previous_from_later_text_returns_earlier_element(self):
"""AXDirectionPrevious from a non-first AXStaticText returns elements that occur earlier in the document
order — and never returns the start element itself."""
leaves = find_all_by_role(self.web, "AXStaticText")
self.assertGreaterEqual(len(leaves), 2, "fixture needs ≥2 AXStaticText leaves for a backward walk")
start = leaves[-1]
pred = _make_pred(direction="AXDirectionPrevious", start=start, limit=-1)
results = get_parameterized_attribute_value(self.web, "AXUIElementsForSearchPredicate", pred)
self.assertIsNotNone(results)
self.assertGreater(len(results), 0, "expected previous elements before the last text leaf")
self.assertNotIn(start, results, "previous-search returned the start element itself")
def test_previous_without_a_start_element_walks_from_the_end(self):
"""AXDirectionPrevious with no AXStartElement starts past the last element, so its first result is the
document's last element — the mirror of a forward walk with no start, which begins at the first. (Blink's
OneShotAccessibilityTreeSearch and WebKit's AXSearchManager start such a search from the end too.)"""
everything = get_parameterized_attribute_value(
self.web, "AXUIElementsForSearchPredicate", _make_pred(direction="AXDirectionNext", limit=-1)
)
self.assertIsNotNone(everything)
self.assertGreater(len(everything), 1, "fixture needs ≥2 elements for a backward walk")
results = get_parameterized_attribute_value(
self.web, "AXUIElementsForSearchPredicate", _make_pred(direction="AXDirectionPrevious", limit=1)
)
self.assertIsNotNone(results)
self.assertEqual(len(results), 1, "a backward walk with no start element found nothing")
self.assertEqual(results[0], everything[-1], "a backward walk with no start element must begin at the end")
count = get_parameterized_attribute_value(
self.web, "AXUIElementCountForSearchPredicate", _make_pred(direction="AXDirectionPrevious", limit=-1)
)
self.assertEqual(int(count), len(everything), "an unlimited backward walk must cover every element")
class SearchPredicateCountTests(AccessibilityBridgeMacTestCase):
"""AXUIElementCountForSearchPredicate — same params, returns a count instead of a list."""
FIXTURE = "roles.html"
def test_count_matches_results_length(self):
"""For the same predicate, AXUIElementCountForSearchPredicate's count equals the length of
AXUIElementsForSearchPredicate's array. The wrapper computes both from the same code path."""
leaves = find_all_by_role(self.web, "AXStaticText")
self.assertGreater(len(leaves), 0)
pred = _make_pred(direction="AXDirectionNext", start=leaves[0], limit=5)
results = get_parameterized_attribute_value(self.web, "AXUIElementsForSearchPredicate", pred)
count = get_parameterized_attribute_value(self.web, "AXUIElementCountForSearchPredicate", pred)
self.assertIsNotNone(results)
self.assertIsNotNone(count)
self.assertEqual(int(count), len(results))
class SearchPredicateMalformedInputTests(AccessibilityBridgeMacTestCase):
"""Defending the wrapper's input-validation paths: non-dictionary parameters return empty/0."""
FIXTURE = "roles.html"
def test_non_dict_parameter_returns_empty_list(self):
"""A string parameter (instead of a dict) returns an empty array — not a crash, not nil."""
results = get_parameterized_attribute_value(self.web, "AXUIElementsForSearchPredicate", "not a dict")
# An empty NSArray marshals to a Python list of length 0.
self.assertIsNotNone(results)
self.assertEqual(len(results), 0)
def test_non_dict_parameter_returns_zero_count(self):
"""A non-dict parameter on the count variant returns 0."""
count = get_parameterized_attribute_value(self.web, "AXUIElementCountForSearchPredicate", "not a dict")
self.assertIsNotNone(count)
self.assertEqual(int(count), 0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,194 @@
"""State + action invariants — AXEnabled, AXFocused, accessibilityActionNames.
The macOS surface is narrower than AT-SPI2 here: no platform-level "focusable" bit (focus is binary AXFocused), and no
"sensitive" state. The wrapper exposes:
* AXEnabled ← *not* data->is_disabled
* AXFocused ← data->is_focused (or view-is-first-responder, for the document)
* AXPress action ← for button/link/checkbox/radio/menuitem/tab roles only"""
from __future__ import annotations
import pathlib
import sys
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from ApplicationServices import AXUIElementCopyActionNames # noqa: E402
from ApplicationServices import kAXEnabledAttribute # noqa: E402
from ApplicationServices import kAXFocusedAttribute # noqa: E402
from ApplicationServices import kAXValueAttribute # noqa: E402
from harness import AccessibilityBridgeMacTestCase # noqa: E402
from harness import wait_for_descendant_by_role # noqa: E402
from harness.ladybird import _ax_attr # noqa: E402
def _action_names(elem):
"""Return the list of action names this element advertises. Empty list on any error."""
if elem is None:
return []
try:
err, names = AXUIElementCopyActionNames(elem, None)
except Exception:
return []
return list(names) if err == 0 and names else []
class EnabledStateTests(AccessibilityBridgeMacTestCase):
"""AXEnabled tracks the wrapper's `!data->is_disabled` rule."""
FIXTURE = "states.html"
def test_normal_button_is_enabled(self):
"""A plain <button> reports AXEnabled=YES."""
btn = wait_for_descendant_by_role(self.web, "AXButton", title="Normal button")
self.assertIsNotNone(btn, "expected 'Normal button' in the tree")
self.assertEqual(_ax_attr(btn, kAXEnabledAttribute), True)
def test_disabled_button_is_not_enabled(self):
"""<button disabled> reports AXEnabled=NO. This is the canary for the wrapper's
`data->is_disabled ? @NO : @YES` translation."""
btn = wait_for_descendant_by_role(self.web, "AXButton", title="Disabled button")
self.assertIsNotNone(btn, "expected 'Disabled button' in the tree")
self.assertEqual(_ax_attr(btn, kAXEnabledAttribute), False)
def test_link_is_enabled(self):
"""<a href> reports AXEnabled=YES. (Links don't currently track is_disabled in WebContent.)"""
link = wait_for_descendant_by_role(self.web, "AXLink")
self.assertIsNotNone(link, "expected an AXLink in the tree")
self.assertEqual(_ax_attr(link, kAXEnabledAttribute), True)
def test_text_input_is_enabled(self):
"""<input type=text> reports AXEnabled=YES."""
entry = wait_for_descendant_by_role(self.web, "AXTextField", title="Text field")
self.assertIsNotNone(entry, "expected the 'Text field' input in the tree")
self.assertEqual(_ax_attr(entry, kAXEnabledAttribute), True)
def test_checkbox_is_enabled(self):
"""<input type=checkbox> reports AXEnabled=YES."""
cb = wait_for_descendant_by_role(self.web, "AXCheckBox", title="Checkbox")
self.assertIsNotNone(cb, "expected the 'Checkbox' input in the tree")
self.assertEqual(_ax_attr(cb, kAXEnabledAttribute), True)
class FocusedStateTests(AccessibilityBridgeMacTestCase):
"""AXFocused on individual elements tracks data->is_focused; the document is special-cased to mirror
view-is-first-responder."""
FIXTURE = "states.html"
def test_normal_button_is_not_focused_initially(self):
"""A page-load with no autofocus should have AXFocused=NO on every interactive element. Specifically
defending the regression where every read of AXFocused returned YES (used to bounce VoiceOver)."""
btn = wait_for_descendant_by_role(self.web, "AXButton", title="Normal button")
self.assertIsNotNone(btn)
# Could be either False or NO (False); the wrapper returns @YES/@NO which PyObjC marshals to True/False.
self.assertEqual(_ax_attr(btn, kAXFocusedAttribute), False)
class ActionNameTests(AccessibilityBridgeMacTestCase):
"""accessibilityActionNames — read externally via AXUIElementCopyActionNames.
Wrapper rule: only button/link/checkbox/radio/menuitem/tab advertise AXPress; everything else returns an empty
action list. Disabled-ness does *not* remove the action from the list. VoiceOver shows greyed-out actions on
disabled controls — so users know what *would* be available."""
FIXTURE = "states.html"
def test_button_advertises_press_action(self):
"""A button exposes AXPress — VoiceOver's VO+Space activation needs this."""
btn = wait_for_descendant_by_role(self.web, "AXButton", title="Normal button")
self.assertIsNotNone(btn)
actions = _action_names(btn)
self.assertIn("AXPress", actions, f"expected AXPress in button actions, got {actions}")
def test_link_advertises_press_action(self):
"""A link exposes AXPress."""
link = wait_for_descendant_by_role(self.web, "AXLink")
self.assertIsNotNone(link)
actions = _action_names(link)
self.assertIn("AXPress", actions, f"expected AXPress in link actions, got {actions}")
def test_checkbox_advertises_press_action(self):
"""Checkboxes expose AXPress (toggling them is a press, not a value-set)."""
cb = wait_for_descendant_by_role(self.web, "AXCheckBox", title="Checkbox")
self.assertIsNotNone(cb)
actions = _action_names(cb)
self.assertIn("AXPress", actions, f"expected AXPress in checkbox actions, got {actions}")
def test_disabled_button_still_advertises_press_action(self):
"""A disabled button's action list is unchanged. Apple's design pattern is that AXEnabled tells AT clients the
action is currently unavailable; the action list itself stays stable – so AT can still describe what *would*
happen."""
btn = wait_for_descendant_by_role(self.web, "AXButton", title="Disabled button")
self.assertIsNotNone(btn, "expected 'Disabled button' in the tree")
actions = _action_names(btn)
self.assertIn("AXPress", actions, f"disabled button stripped AXPress: got {actions}")
def test_text_input_does_not_advertise_press_action(self):
"""<input type=text> is not press-able — no AXPress in its action list. VoiceOver drives text fields via
text-marker navigation, not actions."""
entry = wait_for_descendant_by_role(self.web, "AXTextField", title="Text field")
self.assertIsNotNone(entry)
actions = _action_names(entry)
self.assertNotIn(
"AXPress", actions, f"text field shouldn't advertise AXPress (it's not a button/link), got {actions}"
)
class CheckedValueTests(AccessibilityBridgeMacTestCase):
"""A checkable control's AXValue is its checked state as 0, 1 or 2 — what WebKit, Blink and Gecko expose, and what
VoiceOver reads as the state."""
FIXTURE = "states.html"
def _checkbox(self, title):
cb = wait_for_descendant_by_role(self.web, "AXCheckBox", title=title)
self.assertIsNotNone(cb, f"expected the {title!r} checkbox in the tree")
return cb
def test_unchecked_checkbox_value_is_0(self):
self.assertEqual(_ax_attr(self._checkbox("Checkbox"), kAXValueAttribute), 0)
def test_checked_checkbox_value_is_1(self):
self.assertEqual(_ax_attr(self._checkbox("Checked checkbox"), kAXValueAttribute), 1)
def test_mixed_checkbox_value_is_2(self):
self.assertEqual(_ax_attr(self._checkbox("Mixed checkbox"), kAXValueAttribute), 2)
def test_unchecked_radio_value_is_0(self):
radio = wait_for_descendant_by_role(self.web, "AXRadioButton", title="Radio")
self.assertIsNotNone(radio, "expected the 'Radio' input in the tree")
self.assertEqual(_ax_attr(radio, kAXValueAttribute), 0)
def test_text_input_value_is_not_a_checked_state(self):
entry = wait_for_descendant_by_role(self.web, "AXTextField", title="Text field")
self.assertIsNotNone(entry, "expected the 'Text field' input in the tree")
self.assertIn(_ax_attr(entry, kAXValueAttribute), (None, ""))
class SelectedStateTests(AccessibilityBridgeMacTestCase):
"""AXSelected follows the node's selection, as WebKit, Blink and Gecko report it; a tab, an AXRadioButton, carries
the selection as its AXValue too, as Blink's AXValue does for a tab."""
FIXTURE = "states.html"
def _tab(self, title):
tab = wait_for_descendant_by_role(self.web, "AXRadioButton", title=title)
self.assertIsNotNone(tab, f"expected the {title!r} tab in the tree")
return tab
def test_selected_tab_is_selected(self):
tab = self._tab("Selected tab")
self.assertEqual(_ax_attr(tab, "AXSelected"), True)
self.assertEqual(_ax_attr(tab, kAXValueAttribute), 1)
def test_other_tab_is_not_selected(self):
tab = self._tab("Other tab")
self.assertEqual(_ax_attr(tab, "AXSelected"), False)
self.assertEqual(_ax_attr(tab, kAXValueAttribute), 0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,119 @@
"""ARIA grid and treegrid: tables to VoiceOver, with their rows counted across rowgroups.
The wrapper gives table, grid, and treegrid NSAccessibilityTableRole alike (is_table_container_role() in
LadybirdAccessibilityElement.mm), as WebKit and Blink both do — so a row's AXIndex and a cell's AXRowIndexRange come
from the enclosing grid, not from a table element that isn't there."""
from __future__ import annotations
import pathlib
import re
import sys
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from ApplicationServices import kAXRoleAttribute # noqa: E402
from ApplicationServices import kAXValueAttribute # noqa: E402
from harness import AccessibilityBridgeMacTestCase # noqa: E402
from harness import find_all_by_role # noqa: E402
from harness import walk # noqa: E402
from harness.ladybird import _ax_attr # noqa: E402
def _static_text_values(root):
"""Every AXStaticText value under root, in tree order."""
values = []
def visit(obj, _depth):
if _ax_attr(obj, kAXRoleAttribute) == "AXStaticText":
value = _ax_attr(obj, kAXValueAttribute)
if isinstance(value, str):
values.append(value)
walk(root, visit)
return values
def _parse_cf_range(value):
"""Unwrap an AXValue CFRange into (location, length) from its description."""
m = re.search(r"location:(\d+)\s+length:(\d+)", str(value))
if m is None:
return None
return int(m.group(1)), int(m.group(2))
class AriaGridTests(AccessibilityBridgeMacTestCase):
FIXTURE = "grid.html"
def _table_containing(self, text):
tables = find_all_by_role(self.web, "AXTable")
for table in tables:
if text in _static_text_values(table):
return table
self.fail(f"no AXTable contains the text {text!r}; found {len(tables)} AXTable elements")
def _cell_with_text(self, text):
for cell in find_all_by_role(self.web, "AXCell"):
if text in _static_text_values(cell):
return cell
self.fail(f"no AXCell contains the text {text!r}")
def test_grid_and_treegrid_are_tables(self):
"""A role=grid and a role=treegrid are both AXTable, like a table element."""
self.assertIsNotNone(self._table_containing("Alice"))
self.assertIsNotNone(self._table_containing("main.cpp"))
def test_grid_rows_are_counted_across_rowgroups(self):
"""The grid's header row and its two body rows, in two rowgroups, are three rows of one table."""
grid = self._table_containing("Alice")
rows = _ax_attr(grid, "AXRows") or []
self.assertEqual(len(rows), 3, f"expected the grid's three rows, got {len(rows)}")
self.assertEqual(_ax_attr(grid, "AXRowCount"), 3)
self.assertEqual(_ax_attr(grid, "AXColumnCount"), 2)
def test_gridcell_row_index_comes_from_the_grid(self):
"""A gridcell's AXRowIndexRange is its row's index within the grid — the header row is row 0."""
self.assertEqual(_parse_cf_range(_ax_attr(self._cell_with_text("Alice"), "AXRowIndexRange")), (1, 1))
self.assertEqual(_parse_cf_range(_ax_attr(self._cell_with_text("Bob"), "AXRowIndexRange")), (2, 1))
self.assertEqual(_parse_cf_range(_ax_attr(self._cell_with_text("25"), "AXColumnIndexRange")), (1, 1))
def test_treegrid_cell_row_index_comes_from_the_treegrid(self):
"""A treegrid's rows are its direct children; the second row's cell is in row 1."""
self.assertEqual(_parse_cf_range(_ax_attr(self._cell_with_text("main.cpp"), "AXRowIndexRange")), (1, 1))
class TreegridRowLevelTests(AccessibilityBridgeMacTestCase):
"""A row's aria-level is its depth: AXDisclosureLevel, 0-based as WebKit and Blink convert it, and never its
AXValue, which carries a heading's level only."""
FIXTURE = "grid.html"
def _row_containing(self, text):
for row in find_all_by_role(self.web, "AXRow"):
if text in _static_text_values(row):
return row
self.fail(f"no AXRow contains the text {text!r}")
def test_row_level_is_its_disclosure_level_not_its_value(self):
child = self._row_containing("main.cpp")
self.assertIsNone(_ax_attr(child, kAXValueAttribute), "the row's aria-level leaked into its AXValue")
self.assertEqual(_ax_attr(child, "AXDisclosureLevel"), 1)
self.assertEqual(_ax_attr(self._row_containing("src"), "AXDisclosureLevel"), 0)
class HeaderCellTests(AccessibilityBridgeMacTestCase):
"""A th is a cell to VoiceOver (AXCell, as WebKit, Blink and Gecko expose column and row headers), so table
navigation reaches it and its text can name the column."""
FIXTURE = "tables.html"
def test_header_cells_are_cells(self):
texts = set()
for cell in find_all_by_role(self.web, "AXCell"):
texts.update(_static_text_values(cell))
self.assertTrue({"Name", "Age"} <= texts, f"the th cells aren't AXCell; AXCell texts: {sorted(texts)}")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,258 @@
"""Text-marker parameterized attributes — the wrapper's AXNextTextMarkerForTextMarker,
AXPreviousTextMarkerForTextMarker, AXUIElementForTextMarker, AXTextMarkerRangeForUIElement, AXStringForTextMarkerRange,
AXLengthForTextMarkerRange, AXAttributedStringForTextMarkerRange, AXTextMarkerForPosition.
This is the macOS analogue of AT-SPI2's Text interface; Safari uses the same model. There is no equivalent in the Linux
harness because AT-SPI2 uses integer character offsets, not opaque cursor markers.
The wrapper's text-marker payload is a 12-byte struct (8 bytes node ID + 4 bytes offset). These tests don't construct
markers themselves — they consume markers the wrapper hands out and verify round-trips and forward/back walks."""
from __future__ import annotations
import pathlib
import sys
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from ApplicationServices import AXValueCreate # noqa: E402
from ApplicationServices import kAXPositionAttribute # noqa: E402
from ApplicationServices import kAXRoleAttribute # noqa: E402
from ApplicationServices import kAXValueAttribute # noqa: E402
from ApplicationServices import kAXValueCGPointType # noqa: E402
from harness import AccessibilityBridgeMacTestCase # noqa: E402
from harness import find_all_by_role # noqa: E402
from harness import get_parameterized_attribute_value # noqa: E402
from harness.ladybird import _ax_attr # noqa: E402
def _start_marker(web):
return _ax_attr(web, "AXStartTextMarker")
def _end_marker(web):
return _ax_attr(web, "AXEndTextMarker")
class DocumentMarkerEndpointsTests(AccessibilityBridgeMacTestCase):
"""AXStartTextMarker/AXEndTextMarker are advertised only on the AXWebArea."""
FIXTURE = "paragraphs.html"
def test_axstart_textmarker_present_on_document(self):
"""The web area exposes AXStartTextMarker as a non-nil value. VoiceOver reads this on AXLoadComplete to anchor
its initial cursor."""
marker = _start_marker(self.web)
self.assertIsNotNone(marker, "AXWebArea must expose a non-nil AXStartTextMarker")
def test_axend_textmarker_present_on_document(self):
"""The web area exposes AXEndTextMarker as a non-nil value."""
marker = _end_marker(self.web)
self.assertIsNotNone(marker, "AXWebArea must expose a non-nil AXEndTextMarker")
def test_start_and_end_markers_are_distinct(self):
"""Start and end refer to different positions in a multi-paragraph document."""
start = _start_marker(self.web)
end = _end_marker(self.web)
self.assertIsNotNone(start)
self.assertIsNotNone(end)
# Each read hands back its own AXTextMarkerRef, and repr() of a CF object carries the object's address — so a
# comparison of the markers themselves can't fail. Compare what they resolve to instead: paragraphs.html has
# several paragraphs, so the start and end markers land in different text leaves.
start_leaf = get_parameterized_attribute_value(self.web, "AXUIElementForTextMarker", start)
end_leaf = get_parameterized_attribute_value(self.web, "AXUIElementForTextMarker", end)
self.assertIsNotNone(start_leaf, "AXUIElementForTextMarker returned nil for AXStartTextMarker")
self.assertIsNotNone(end_leaf, "AXUIElementForTextMarker returned nil for AXEndTextMarker")
self.assertNotEqual(start_leaf, end_leaf, "AXStartTextMarker and AXEndTextMarker resolve to the same leaf")
class TextMarkerRangeForElementTests(AccessibilityBridgeMacTestCase):
"""AXTextMarkerRangeForUIElement returns the marker range covering a UI element's text content."""
FIXTURE = "paragraphs.html"
def test_marker_range_for_static_text_string_round_trips(self):
"""AXTextMarkerRangeForUIElement(AXStaticText) → AXStringForTextMarkerRange(range) returns the leaf's text.
Concretely: pick a known AXStaticText leaf, ask the wrapper for its marker range, then ask for the string that
range covers — and then assert the string equals the leaf's AXValue."""
leaves = find_all_by_role(self.web, "AXStaticText")
self.assertGreater(len(leaves), 0, "expected at least one AXStaticText leaf")
leaf = leaves[0]
leaf_value = _ax_attr(leaf, kAXValueAttribute)
self.assertIsInstance(leaf_value, str, f"leaf has non-string AXValue: {leaf_value!r}")
self.assertGreater(len(leaf_value), 0, "leaf has empty AXValue")
marker_range = get_parameterized_attribute_value(leaf, "AXTextMarkerRangeForUIElement", leaf)
self.assertIsNotNone(marker_range, "AXTextMarkerRangeForUIElement returned nil for a static text leaf")
# Read the string the marker range covers (asked via the document — text-marker params live there).
text = get_parameterized_attribute_value(self.web, "AXStringForTextMarkerRange", marker_range)
self.assertEqual(
text,
leaf_value,
f"AXStringForTextMarkerRange != AXValue. range-text={text!r} value={leaf_value!r}",
)
def test_length_matches_string_length(self):
"""AXLengthForTextMarkerRange equals the length of AXStringForTextMarkerRange. The wrapper counts the code
points it appends while building that string — so this is guarding against the two queries diverging if
either's plumbing changes."""
leaves = find_all_by_role(self.web, "AXStaticText")
self.assertGreater(len(leaves), 0)
leaf = leaves[0]
marker_range = get_parameterized_attribute_value(leaf, "AXTextMarkerRangeForUIElement", leaf)
self.assertIsNotNone(marker_range)
text = get_parameterized_attribute_value(self.web, "AXStringForTextMarkerRange", marker_range)
length = get_parameterized_attribute_value(self.web, "AXLengthForTextMarkerRange", marker_range)
self.assertEqual(length, len(text), f"AXLength={length!r} but len(AXString)={len(text)}")
def test_attributed_string_returns_attributed_string(self):
"""AXAttributedStringForTextMarkerRange returns an NSAttributedString whose plain-text contents match
AXStringForTextMarkerRange. The wrapper currently only includes the bare string (no attributes) — but the return
type must be an NSAttributedString. If it were a plain NSString, VoiceOver's attribute walk would
fail-shaped."""
from Foundation import NSAttributedString
leaves = find_all_by_role(self.web, "AXStaticText")
self.assertGreater(len(leaves), 0)
leaf = leaves[0]
marker_range = get_parameterized_attribute_value(leaf, "AXTextMarkerRangeForUIElement", leaf)
self.assertIsNotNone(marker_range)
attributed = get_parameterized_attribute_value(self.web, "AXAttributedStringForTextMarkerRange", marker_range)
self.assertIsInstance(attributed, NSAttributedString)
plain = get_parameterized_attribute_value(self.web, "AXStringForTextMarkerRange", marker_range)
self.assertEqual(str(attributed.string()), plain)
class TextMarkerWalkTests(AccessibilityBridgeMacTestCase):
"""AXNextTextMarkerForTextMarker and AXPreviousTextMarkerForTextMarker walk forward and backward through the
document one character at a time, crossing leaf boundaries when needed."""
FIXTURE = "paragraphs.html"
def _round_trip_leaf_check(self, marker):
"""Helper: AXUIElementForTextMarker(marker) should be an AXStaticText leaf."""
elem = get_parameterized_attribute_value(self.web, "AXUIElementForTextMarker", marker)
self.assertIsNotNone(elem)
self.assertEqual(_ax_attr(elem, kAXRoleAttribute), "AXStaticText")
return elem
def test_next_advances_within_leaf(self):
"""AXStartTextMarker → AXNextTextMarkerForTextMarker should yield a marker that's still in the same first leaf
(paragraphs.html's first paragraph has many characters)."""
start = _start_marker(self.web)
self.assertIsNotNone(start)
first_elem = self._round_trip_leaf_check(start)
nxt = get_parameterized_attribute_value(self.web, "AXNextTextMarkerForTextMarker", start)
self.assertIsNotNone(nxt, "AXNextTextMarkerForTextMarker returned nil at the document start")
nxt_elem = self._round_trip_leaf_check(nxt)
# Both markers should resolve to the same leaf if we advanced within it. (For very short leaves we might cross a
# boundary on the first step; guard for that.)
if first_elem != nxt_elem:
# We crossed a boundary on a single step. That's fine — walking still works — but we wanted to test the
# within-leaf case, so skip rather than fail.
self.skipTest("first AXStaticText leaf is too short to test within-leaf advance")
def test_next_then_previous_round_trips(self):
"""A "right then left" walk from AXStart should land back at AXStart (same leaf, same offset)."""
start = _start_marker(self.web)
self.assertIsNotNone(start)
nxt = get_parameterized_attribute_value(self.web, "AXNextTextMarkerForTextMarker", start)
self.assertIsNotNone(nxt)
prev = get_parameterized_attribute_value(self.web, "AXPreviousTextMarkerForTextMarker", nxt)
self.assertIsNotNone(prev)
# Comparing AXTextMarkerRefs by identity isn't reliable across CFBridgingRelease, so check equivalence via what
# the markers point to: same leaf element + same string-to-end length.
leaf_after_round_trip = get_parameterized_attribute_value(self.web, "AXUIElementForTextMarker", prev)
leaf_at_start = get_parameterized_attribute_value(self.web, "AXUIElementForTextMarker", start)
self.assertEqual(leaf_after_round_trip, leaf_at_start)
def test_walk_eventually_crosses_leaf_boundary(self):
"""Walking forward enough times crosses from one AXStaticText leaf to the next; paragraphs.html has
multiple paragraphs, each its own leaf."""
leaves = find_all_by_role(self.web, "AXStaticText")
self.assertGreaterEqual(len(leaves), 2, "fixture must have at least 2 text leaves to test boundary crossing")
first_leaf_value = _ax_attr(leaves[0], kAXValueAttribute) or ""
first_leaf_len = len(first_leaf_value)
# Cap the walk to first_leaf_len + a few.
max_steps = first_leaf_len + 10
marker = _start_marker(self.web)
self.assertIsNotNone(marker)
starting_leaf = get_parameterized_attribute_value(self.web, "AXUIElementForTextMarker", marker)
crossed = False
for _ in range(max_steps):
nxt = get_parameterized_attribute_value(self.web, "AXNextTextMarkerForTextMarker", marker)
if nxt is None:
break
marker = nxt
now_leaf = get_parameterized_attribute_value(self.web, "AXUIElementForTextMarker", marker)
if now_leaf and now_leaf != starting_leaf:
crossed = True
break
self.assertTrue(
crossed,
f"walked {max_steps} AXNextTextMarkerForTextMarker steps without crossing a leaf boundary",
)
class TextMarkerForPositionTests(AccessibilityBridgeMacTestCase):
"""AXTextMarkerForPosition — given a screen point, return a marker for the closest text content."""
FIXTURE = "paragraphs.html"
def test_marker_for_static_text_position_resolves_to_text(self):
"""Take an AXStaticText's AXPosition (its top-left screen point), pass it to AXTextMarkerForPosition, and verify
the returned marker resolves to a static-text leaf — ideally the same one.
The wrapper's AXTextMarkerForPosition does a hit test on the WebContent at the given point; if the hit lands on
a text leaf, it returns a marker for that leaf — otherwise it walks down to the first text descendant. Either
way, the returned marker should map back to *some* AXStaticText."""
import re
leaves = find_all_by_role(self.web, "AXStaticText")
self.assertGreater(len(leaves), 0)
leaf = leaves[0]
pos_val = _ax_attr(leaf, kAXPositionAttribute)
self.assertIsNotNone(pos_val, "AXStaticText must have an AXPosition")
# AXPosition is an AXValueRef (not an NSValue). PyObjC has no clean unwrapper for this opaque CFType, but
# str(AXValueRef) yields a stable diagnostic of the form
# "<AXValue 0x...> {value = x:N y:M type = kAXValueCGPointType}"
# — same approach test_regressions.py uses for AXVisibleCharacterRange CFRange unwrap.
m = re.search(r"x:(-?\d+\.?\d*)\s+y:(-?\d+\.?\d*)", str(pos_val))
self.assertIsNotNone(m, f"could not parse AXPosition: {pos_val!r}")
x, y = float(m.group(1)), float(m.group(2))
# Nudge a few pixels into the rect so we land *on* glyphs — not at the very edge, where hit-testing might pick a
# neighboring element. The point goes over as an AXValue: the AX API rejects an NSValue parameter with
# kAXErrorIllegalArgument before it reaches the app, while an AXValue point arrives at the app's wrapper as an
# NSValue in Cocoa screen coordinates, which is what its hit test converts.
nudged = AXValueCreate(kAXValueCGPointType, (x + 5, y + 5))
marker = get_parameterized_attribute_value(self.web, "AXTextMarkerForPosition", nudged)
self.assertIsNotNone(marker, "AXTextMarkerForPosition returned nil for a point inside a text leaf")
elem = get_parameterized_attribute_value(self.web, "AXUIElementForTextMarker", marker)
self.assertIsNotNone(elem)
self.assertEqual(_ax_attr(elem, kAXRoleAttribute), "AXStaticText")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,214 @@
"""Tree-shape invariants: what gets exposed, what's hidden, and how ancestry bubbles.
These defend the cross-platform decisions WebContent makes when serializing the accessibility tree — plus the macOS
wrapper's ignored-role filtering (LadybirdAccessibilityElement.mm: is_ignored_role + accessibilityIsIgnored). They
mirror the Linux AccessibilityBridge/test_tree_shape.py suite, but use NSAccessibility role/value reads instead of
AT-SPI2 Text-interface walks. Because <p> and unnamed <div> are *ignored* on macOS (their text leaves bubble up via
accessibilityParent's walk-up-past-ignored loop), text-content checks here walk AXStaticText leaves — rather than
querying paragraph nodes."""
from __future__ import annotations
import pathlib
import sys
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from ApplicationServices import kAXChildrenAttribute # noqa: E402
from ApplicationServices import kAXParentAttribute # noqa: E402
from ApplicationServices import kAXRoleAttribute # noqa: E402
from ApplicationServices import kAXSubroleAttribute # noqa: E402
from ApplicationServices import kAXTitleAttribute # noqa: E402
from ApplicationServices import kAXValueAttribute # noqa: E402
from harness import AccessibilityBridgeMacTestCase # noqa: E402
from harness import walk # noqa: E402
from harness.ladybird import _ax_attr # noqa: E402
class TreeShapeTests(AccessibilityBridgeMacTestCase):
FIXTURE = "tree_shape.html"
def _all_text(self):
"""Return every AXStaticText leaf's value, anywhere in the document subtree.
On macOS the WebContent serializer + wrapper's is_ignored_role pair collapses unnamed paragraphs and unnamed
generic divs out of the tree — so text content lives only in AXStaticText leaves (mapped from "text leaf"
roles)."""
out = []
def visit(obj, _depth):
if _ax_attr(obj, kAXRoleAttribute) != "AXStaticText":
return
value = _ax_attr(obj, kAXValueAttribute)
if isinstance(value, str) and value:
out.append(value)
walk(self.web, visit)
return out
def test_title_text_not_in_tree(self):
"""Title/head text children are not exposed — <title> isn't part of the body's accessibility tree."""
joined = " ".join(self._all_text())
self.assertNotIn("Title text that should *not* appear", joined)
def test_script_children_not_in_tree(self):
"""<script> elements and their text are excluded."""
joined = " ".join(self._all_text())
self.assertNotIn("no script children either", joined)
def test_style_children_not_in_tree(self):
"""<style> elements and their text are excluded."""
joined = " ".join(self._all_text())
self.assertNotIn("p { color: black", joined)
def test_display_none_not_in_tree(self):
"""Display:none elements must not be exposed."""
joined = " ".join(self._all_text())
self.assertNotIn("Display none", joined)
def test_visibility_hidden_not_in_tree(self):
"""Visibility:hidden elements must not be exposed."""
joined = " ".join(self._all_text())
self.assertNotIn("Visibility hidden", joined)
def test_aria_hidden_not_in_tree(self):
"""aria-hidden='true' elements must not be exposed."""
joined = " ".join(self._all_text())
self.assertNotIn("Aria-hidden", joined)
def test_html_hidden_attribute_not_in_tree(self):
"""HTML "hidden" attribute — element must not be exposed."""
joined = " ".join(self._all_text())
self.assertNotIn("HTML hidden attribute", joined)
def test_unnamed_div_paragraph_text_is_reachable(self):
"""An unnamed div with a paragraph inside it is collapsed (the div *and* the paragraph are ignored on macOS),
but the text inside still shows up as a top-level AXStaticText leaf — so AT clients can read it."""
joined = " ".join(self._all_text())
self.assertIn("Paragraph inside an unnamed div", joined)
def test_unnamed_div_does_not_create_intermediate_group(self):
"""When the wrapper collapses an unnamed <div> + unnamed <p>, the AXStaticText leaf's nearest non-ignored
ancestor is a *named* container or AXWebArea — never an unnamed AXGroup. Otherwise VoiceOver would announce
spurious "group" wrappers around plain text."""
target = None
def visit(obj, _depth):
nonlocal target
if target is not None:
return
if (
_ax_attr(obj, kAXRoleAttribute) == "AXStaticText"
and isinstance(_ax_attr(obj, kAXValueAttribute), str)
and "Paragraph inside an unnamed div" in _ax_attr(obj, kAXValueAttribute)
):
target = obj
walk(self.web, visit)
self.assertIsNotNone(target, "expected AXStaticText with the unnamed-div paragraph text")
parent = _ax_attr(target, kAXParentAttribute)
self.assertIsNotNone(parent, "AXStaticText with no AXParent")
parent_role = _ax_attr(parent, kAXRoleAttribute)
parent_title = _ax_attr(parent, kAXTitleAttribute)
# Acceptable parents: AXWebArea (the document), or a named container (any role with a non-empty title).
# Unacceptable: an AXGroup with no title — that would be the unnamed div leaking into the AX tree.
if parent_role == "AXGroup":
self.assertTrue(
isinstance(parent_title, str) and parent_title,
f"unnamed div leaked into the AX tree as an AXGroup with no title — "
f"parent_role={parent_role!r}, parent_title={parent_title!r}",
)
def test_named_div_is_exposed(self):
"""A <div aria-label="named-div"> *is* exposed (as AXGroup with the name on AXTitle/AXDescription).
The wrapper's is_ignored_role pair only ignores generic+empty-name and paragraph+empty-name; a named generic is
a real container in the tree."""
named = None
def visit(obj, _depth):
nonlocal named
if named is not None:
return
if _ax_attr(obj, kAXTitleAttribute) == "named-div":
named = obj
walk(self.web, visit)
self.assertIsNotNone(named, "named generic (<div aria-label=named-div>) must be exposed")
def test_role_presentation_promotes_children(self):
"""role='none'/'presentation' element is hidden, children are promoted — the <p> inside should still be in
the tree as an AXStaticText leaf."""
joined = " ".join(self._all_text())
self.assertIn("Presentational role", joined)
def test_visible_paragraph_does_appear(self):
"""Sanity: visible, non-hidden paragraph *is* exposed (as AXStaticText, since paragraphs themselves are
ignored on macOS)."""
joined = " ".join(self._all_text())
self.assertIn("Visible paragraph that *should* appear", joined)
def test_web_area_is_the_document_root(self):
"""The document root is an AXWebArea — that's how the wrapper maps WebContent's "document" role."""
self.assertEqual(_ax_attr(self.web, kAXRoleAttribute), "AXWebArea")
def test_web_area_parent_is_not_null(self):
"""The AXWebArea has a non-null AXParent (the WebContentView NSView). A null parent would mean VoiceOver
couldn't walk up out of the web content — and from that NSView VoiceOver finds the window and back up to the
application."""
parent = _ax_attr(self.web, kAXParentAttribute)
self.assertIsNotNone(parent, "AXWebArea's parent must not be null")
def test_no_ignored_role_node_is_an_accessibility_element(self):
"""Defense in depth: while walking, every visited element should be a real accessibility element. If an
ignored-role node (unnamed paragraph/unnamed generic) ever showed up here, isAccessibilityIgnored regressed —
because find_all_by_role / walk only see things AXUIElementCopyAttributeValue surfaces."""
offenders = []
def visit(obj, _depth):
role = _ax_attr(obj, kAXRoleAttribute)
subrole = _ax_attr(obj, kAXSubroleAttribute)
title = _ax_attr(obj, kAXTitleAttribute)
# An AXGroup with no title that has only AXStaticText children is suspicious — would suggest an
# unnamed-paragraph or unnamed-div node leaked through.
if role == "AXGroup" and not title and not subrole:
children = _ax_attr(obj, kAXChildrenAttribute) or []
if children and all(_ax_attr(c, kAXRoleAttribute) == "AXStaticText" for c in children):
offenders.append(role)
walk(self.web, visit)
# Landmark groups carry an AXSubrole (with empty AXTitle), so they are filtered out above; anything left
# is an unnamed generic that leaked through isAccessibilityIgnored.
self.assertEqual(offenders, [], f"ignored AXGroups leaked into the tree: {offenders}")
class WhitespaceTextTests(AccessibilityBridgeMacTestCase):
"""Whitespace-only text is never an AXStaticText of its own — VoiceOver has nothing to stop on there.
inline_whitespace.html puts a rendered space between two links and between two styled words. The space keeps the
words apart in the text VoiceOver reads (the text markers walk every text leaf), but it isn't an element of the
tree, as WebKit ignores every whitespace-only RenderText (LadybirdAccessibilityElement.mm: is_ignored_role)."""
FIXTURE = "inline_whitespace.html"
def test_no_whitespace_only_static_text(self):
values = []
def visit(obj, _depth):
if _ax_attr(obj, kAXRoleAttribute) == "AXStaticText":
values.append(_ax_attr(obj, kAXValueAttribute))
walk(self.web, visit)
self.assertIn("bold", values, f"the paragraph's text isn't exposed; static texts: {values!r}")
self.assertEqual(
[value for value in values if isinstance(value, str) and value.strip() == ""],
[],
f"whitespace-only static text in the tree: {values!r}",
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,127 @@
"""Page zoom: the frames the bridge reports follow the zoomed page.
WebContent lays the page out in CSS pixels whatever the zoom level, and folds the zoom into its device scale (the way
Gecko does: nsPresContext::SetFullZoom() refreshes the ratio LocalAccessible::Bounds() converts with), so the bounds it
serializes stay in CSS pixels while the view draws every one of them zoom_level() points wide. The macOS bridge scales them
on the way out (WebContentViewAccessibility.mm: accessibilityScreenRectForViewRect:), so after a Zoom In, VoiceOver's
cursor lands on the text where it's drawn, not where it was at 100%."""
from __future__ import annotations
import pathlib
import re
import sys
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from ApplicationServices import AXUIElementPerformAction # noqa: E402
from ApplicationServices import kAXChildrenAttribute # noqa: E402
from ApplicationServices import kAXMenuBarAttribute # noqa: E402
from ApplicationServices import kAXPositionAttribute # noqa: E402
from ApplicationServices import kAXPressAction # noqa: E402
from ApplicationServices import kAXSizeAttribute # noqa: E402
from ApplicationServices import kAXTitleAttribute # noqa: E402
from ApplicationServices import kAXValueAttribute # noqa: E402
from harness import AccessibilityBridgeMacTestCase # noqa: E402
from harness import find_all_by_role # noqa: E402
from harness import wait_for # noqa: E402
from harness.ladybird import _ax_attr # noqa: E402
# ViewImplementation::ZOOM_STEP: one Zoom In takes the page from 100% to 110%.
ZOOM_IN_FACTOR = 1.1
PLAIN_PARAGRAPH = "Plain paragraph with no inline objects."
def _rect(obj):
"""(x, y, width, height) of obj in screen points, read from its AXPosition and AXSize.
Both are AXValueRefs, which PyObjC can't unwrap; str() of one is a stable diagnostic of the form
"<AXValue 0x...> {value = x:N y:M type = kAXValueCGPointType}", as test_display_contents.py reads too."""
position = re.search(r"x:(-?\d+\.?\d*)\s+y:(-?\d+\.?\d*)", str(_ax_attr(obj, kAXPositionAttribute)))
size = re.search(r"w:(-?\d+\.?\d*)\s+h:(-?\d+\.?\d*)", str(_ax_attr(obj, kAXSizeAttribute)))
if position is None or size is None:
return None
return tuple(float(v) for v in (position.group(1), position.group(2), size.group(1), size.group(2)))
class ZoomTests(AccessibilityBridgeMacTestCase):
FIXTURE = "paragraphs.html"
def _menu_item(self, *titles):
"""The AXMenuItem at this path of titles under the menu bar, e.g. ("View", "Zoom", "Zoom In")."""
menu_bar = _ax_attr(self.app, kAXMenuBarAttribute)
self.assertIsNotNone(menu_bar, "the application has no AXMenuBar")
candidates = _ax_attr(menu_bar, kAXChildrenAttribute) or []
item = None
for depth, title in enumerate(titles):
if depth:
# An item that opens a submenu has that AXMenu as its one child; the submenu's items are its children.
candidates = [
child
for menu in _ax_attr(item, kAXChildrenAttribute) or []
for child in _ax_attr(menu, kAXChildrenAttribute) or []
]
item = next(
(candidate for candidate in candidates if _ax_attr(candidate, kAXTitleAttribute) == title), None
)
self.assertIsNotNone(item, f"no {title!r} item under {' > '.join(titles[:depth]) or 'the menu bar'}")
return item
def _press_menu_item(self, *titles):
error = AXUIElementPerformAction(self._menu_item(*titles), kAXPressAction)
self.assertEqual(error, 0, f"AXPress on {' > '.join(titles)} failed with AXError {error}")
def _plain_paragraph_rect(self):
for leaf in find_all_by_role(self.web, "AXStaticText"):
if _ax_attr(leaf, kAXValueAttribute) == PLAIN_PARAGRAPH:
return _rect(leaf)
return None
def test_frames_scale_with_the_page_zoom(self):
"""After View > Zoom > Zoom In, the plain paragraph's text is 1.1 times as wide and as tall, and 1.1 times as far
from the web area's top-left corner, as it was at 100% — the CSS-pixel bounds haven't moved, the view draws
them zoomed, and the bridge reports them where they're drawn. Reset Zoom puts the frame back."""
web_area = _rect(self.web)
before = self._plain_paragraph_rect()
self.assertIsNotNone(web_area, "no frame for the AXWebArea")
self.assertIsNotNone(before, "no AXStaticText for the plain paragraph")
self._press_menu_item("View", "Zoom", "Zoom In")
try:
changed = wait_for(
lambda: self._plain_paragraph_rect() not in (None, before),
description="the plain paragraph's frame to change after Zoom In",
)
self.assertTrue(changed, f"the plain paragraph's frame stayed at {before} after Zoom In")
after = self._plain_paragraph_rect()
self.assertIsNotNone(after, "the plain paragraph's frame went missing after Zoom In")
for axis, offset_index, size_index in (("x", 0, 2), ("y", 1, 3)):
offset_before = before[offset_index] - web_area[offset_index]
offset_after = after[offset_index] - web_area[offset_index]
self.assertAlmostEqual(
offset_after,
offset_before * ZOOM_IN_FACTOR,
delta=1.0,
msg=f"{axis} offset from the web area: {offset_before} at 100%, {offset_after} after Zoom In",
)
self.assertAlmostEqual(
after[size_index],
before[size_index] * ZOOM_IN_FACTOR,
delta=1.0,
msg=f"size along {axis}: {before[size_index]} at 100%, {after[size_index]} after Zoom In",
)
finally:
self._press_menu_item("View", "Zoom", "Reset Zoom")
restored = wait_for(
lambda: self._plain_paragraph_rect() == before,
description="the plain paragraph's frame to return after Reset Zoom",
)
self.assertTrue(
restored, f"the plain paragraph's frame is {self._plain_paragraph_rect()} after Reset Zoom, not {before}"
)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -98,3 +98,4 @@ add_subdirectory("test-web")
target_link_libraries(TestInlineStyle PRIVATE LibCore LibGC LibGfx LibJS LibURL)
add_subdirectory("AccessibilityBridge")
add_subdirectory("AccessibilityBridgeMac")
+3
View File
@@ -27,4 +27,7 @@ exclude = [
"**/.*",
"**/lit.cfg.py",
"Tests/LibWeb/Text/input/wpt-import",
"Tests/LibWeb/AccessibilityBridge",
"Tests/LibWeb/AccessibilityBridgeMac",
"UI/Qt/OrcaScripts",
]