
Short answer: Audit landmark announcements in Safari because VoiceOver reads the same WebKit accessibility tree Safari renders. Run an osascript loop that advances the VoiceOver cursor through each of the eight WAI-ARIA 1.2 landmark roles, captures content of cursor, writes a TSV, and joins it against your expectations file to emit a WCAG 1.3.6-linked pass/fail CSV. VoiceOver needs a logged-in GUI session, so run this on a hosted Mac you can pin to one OS. The right Figment tier is the M-series plan from $95/month (pricing); exact chip, RAM and disk are confirmed in the quote. The $29 virtual Intel macOS plan runs real macOS and is suitable for Intel Xcode 26-or-earlier toolchains, but its interactive path is the browser-console desktop terminal, not inbound SSH.
VoiceOver on macOS reads the accessibility tree WebKit builds, which is the same engine that renders Safari. That makes Safari the reference pairing for VoiceOver audits on macOS.
Chrome on macOS uses a different accessibility bridge. Landmark order, role phrasing, and compound names (role + accessible name) can differ between the two. The WAI-ARIA Authoring Practices Group documents VoiceOver + Safari behavior separately from NVDA/JAWS patterns for that reason. If you audit only in Chrome, you can ship an announcement order that confuses VoiceOver users on macOS and iOS. Running the audit in Safari on a real Mac is the only way to capture the spoken strings those users actually hear.
WAI-ARIA 1.2 defines eight landmark roles. Apple's platform accessibility documentation lives at developer.apple.com/documentation/accessibility.
| Role | HTML implicit source | Typical VoiceOver phrasing (Safari) |
|---|---|---|
banner | <header> (top-level) | "banner" |
navigation | <nav> | "navigation" + accessible name if present |
main | <main> | "main" |
complementary | <aside> | "complementary" |
contentinfo | <footer> (top-level) | "content information" |
form | <form> with accessible name | "form" + accessible name |
region | <section> with accessible name | accessible name + "region" |
search | <search> or role="search" | "search" |
Exact phrasing varies between macOS releases, so treat this column as the expectation you confirm against your pinned baseline, not as a spec guarantee. VoiceOver's Web Rotor (VO + U) lists landmarks; the audit below walks them directly.
Open VoiceOver Utility once on the target Mac and tick General → Allow VoiceOver to be controlled with AppleScript. Without that setting, tell application "VoiceOver" calls are refused. You must also grant the controlling process Accessibility permission in System Settings → Privacy & Security → Accessibility.
With those prerequisites in place, you can start and stop VoiceOver from a shell:
# Start VoiceOver (requires AppleScript control to be allowed in VO Utility)
osascript -e 'tell application "VoiceOver" to activate'
# Let VoiceOver finish initializing before driving the cursor
sleep 2
# ... audit script runs here ...
osascript -e 'tell application "VoiceOver" to quit'
VoiceOver cannot run in a headless session; the Mac must be logged in with a visible desktop. On a hosted Mac that means the CI agent must run as a Launch Agent in the logged-in user session, not as a Launch Daemon.
Save the following as scripts/vo-landmark-audit.applescript. Before running it unattended, confirm the "next landmark" key binding in VoiceOver Help on your target macOS version — the default rotor- or commander-assigned shortcut has changed between releases.
-- vo-landmark-audit.applescript
-- Usage: osascript vo-landmark-audit.applescript <url> <out.tsv>
on run argv
set targetURL to item 1 of argv
set outFile to item 2 of argv
tell application "Safari"
activate
open location targetURL
delay 3
end tell
tell application "VoiceOver" to activate
delay 2
set rows to {"index" & tab & "spoken_text"}
set seen to {}
repeat with i from 1 to 20
tell application "VoiceOver"
try
set spokenText to (get content of cursor)
on error
set spokenText to "(no cursor content)"
end try
end tell
if seen contains spokenText then exit repeat
set end of seen to spokenText
set end of rows to (i as string) & tab & spokenText
-- Advance cursor to next landmark. Verify this key combo in VO Help
-- for your installed macOS before relying on it in CI.
tell application "System Events"
key code 37 using {control down, option down, command down} -- VO+Cmd+L
end tell
delay 0.4
end repeat
set outContent to ""
repeat with r in rows
set outContent to outContent & r & linefeed
end repeat
set fileRef to open for access (POSIX file outFile) with write permission
set eof fileRef to 0
write outContent to fileRef
close access fileRef
tell application "VoiceOver" to quit
return "Audit written to " & outFile
end run
Run it:
osascript scripts/vo-landmark-audit.applescript \
"https://your-staging-url.example.com" \
"/tmp/vo-audit-$(date +%Y%m%d).tsv"
Example output:
index spoken_text
1 banner
2 navigation, Main Navigation
3 main, Article content
4 complementary, Related articles
5 content information
This is an unexecuted example, not a benchmark — a failed osascript call returns non-zero and your CI step must stop, not record the failure as a pass.
Create audit-expectations.tsv mapping an expected spoken substring to a WCAG criterion:
expected_contains wcag_criterion notes
banner 1.3.1 Top-level header must announce as banner
navigation 1.3.1 nav must announce as navigation
main 1.3.1 Single main landmark required
complementary 1.3.1 aside must map to complementary
content information 1.3.1 footer maps to content information
search 1.3.1 search landmark announced
Then join against the audit:
#!/usr/bin/env python3
# scripts/vo-verdict.py <audit.tsv> <expectations.tsv> <output.csv>
import csv, sys
audit_file, expect_file, out_file = sys.argv[1:4]
with open(audit_file) as f:
audit_rows = list(csv.DictReader(f, delimiter='\t'))
with open(expect_file) as f:
expectations = list(csv.DictReader(f, delimiter='\t'))
results = []
for exp in expectations:
keyword = exp['expected_contains'].lower()
matches = [r for r in audit_rows if keyword in r['spoken_text'].lower()]
results.append({
'criterion': exp['wcag_criterion'],
'expected_contains': exp['expected_contains'],
'actual_spoken': matches[0]['spoken_text'] if matches else '(not found)',
'verdict': 'PASS' if matches else 'FAIL',
'notes': exp['notes'],
})
with open(out_file, 'w', newline='') as f:
writer = csv.DictWriter(f, fieldnames=list(results[0].keys()))
writer.writeheader()
writer.writerows(results)
failures = [r for r in results if r['verdict'] == 'FAIL']
print(f"Audit complete: {len(results) - len(failures)} PASS, {len(failures)} FAIL")
sys.exit(1 if failures else 0)
The CSV is your evidence file: one row per landmark requirement, with the actual spoken string next to the expected substring.
VoiceOver phrasing drifts between macOS versions, so a nightly accessibility baseline needs a machine that stays on one OS until you choose to upgrade. On an M-series Figment host where SSH access is configured in the quote, the orchestrator step is:
ssh ci@mac-host "osascript ~/project/scripts/vo-landmark-audit.applescript \
'https://staging.example.com' '/tmp/vo-audit.tsv' && \
python3 ~/project/scripts/vo-verdict.py \
/tmp/vo-audit.tsv ~/project/audit-expectations.tsv /tmp/vo-results.csv"
scp ci@mac-host:/tmp/vo-results.csv \
./audit-evidence/vo-results-$(date +%Y%m%d).csv
If you are evaluating on the $29 virtual Intel macOS plan, run the same two scripts in the VM's browser-console desktop terminal and copy the CSV out via your artifact store — the VM does not expose inbound SSH. VoiceOver itself is available on that macOS install; use the M-series tier when you need Apple-silicon phrasing parity with your users' devices or when you need SSH-driven orchestration.
For a pinned accessibility baseline, the useful Figment tier is the M-series plan from $95/month at /mac-hosting; the exact chip, RAM, disk and remote-access method are quoted per request while supply is confirmed. The Managed Build Mac at $249/month adds CI setup, backups, monitoring, and 30 minutes of monthly support if you want Figment to wire the nightly audit job and keep the Launch Agent healthy.
If you only need a single M4/16GB/256GB Mac and are comparing entry prices, CloudMac publishes $99.99/month and a $999/year prepaid option — that annual price is $83.25 effective per month, below twelve months at the Figment $95 entry. Figment's difference on these audit workloads is the managed CI work in the $249 tier and the ability to pin an OS image for comparable nightly runs; match the plan to whether you want a bare host or the audit pipeline maintained for you.
Start a configuration request at /mac-hosting/start.
Related guides: