mirror of
https://github.com/LadybirdBrowser/ladybird.git
synced 2026-10-28 09:44:53 +00:00
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:
1 parent
61a878789d
commit
ca3dd33db1
31 files changed
+5204
-8
Notes:
github-actions[bot]
2026-10-08 04:22:35 +00:00
Author: https://github.com/sideshowbarker Commit: https://github.com/LadybirdBrowser/ladybird/commit/ca3dd33db18 Pull-request: https://github.com/LadybirdBrowser/ladybird/pull/9156 Reviewed-by: https://github.com/cqundefine
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
Executable
+351
@@ -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
@@ -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()
|
||||
@@ -98,3 +98,4 @@ add_subdirectory("test-web")
|
||||
target_link_libraries(TestInlineStyle PRIVATE LibCore LibGC LibGfx LibJS LibURL)
|
||||
|
||||
add_subdirectory("AccessibilityBridge")
|
||||
add_subdirectory("AccessibilityBridgeMac")
|
||||
@@ -27,4 +27,7 @@ exclude = [
|
||||
"**/.*",
|
||||
"**/lit.cfg.py",
|
||||
"Tests/LibWeb/Text/input/wpt-import",
|
||||
"Tests/LibWeb/AccessibilityBridge",
|
||||
"Tests/LibWeb/AccessibilityBridgeMac",
|
||||
"UI/Qt/OrcaScripts",
|
||||
]
|
||||
Reference in new issue
Block a user