
Run two complementary checks on every release: a short XCUITest that calls the documented performAccessibilityAudit() on your main window, and a manual Tab-walk that records the focus-order CSV reviewers diff against a checked-in baseline. Host the work on a Figment M-series Mac from $95/month when you are on current Xcode and want ARM-native builds; the virtual Intel macOS plan at $29/month (2 vCPU, 8 GiB RAM, 80 GiB disk) is a lower-cost option when your project still builds under Xcode 26 or earlier and ships an x86_64 slice. Exact chip, RAM and macOS image are confirmed in the quote — request a configuration before you commit.
WCAG 2.4.3 Focus Order is a Level A success criterion: when a view supports sequential navigation and the sequence affects meaning or operation, focusable components must receive focus in an order that preserves meaning and operability. The WCAG 2 text is written for web pages, but accessibility teams apply the same criterion to native macOS apps because keyboard users navigate both the same way.
Three failure modes show up repeatedly in macOS reviews. Controls are skipped entirely because the view paints its own focus ring but exposes no accessibility element. Tab order contradicts the visible layout because view ordering in a stack does not match the geometry. A focused element returns focus to itself, trapping the keyboard user in a two-element loop. The automated audit below catches the first class on its own; the manual Tab-walk catches the second and third and gives reviewers a stable artifact to diff.
macOS limits Tab focus to text fields and lists unless Keyboard navigation is turned on in System Settings. Set it once from Terminal on whichever Mac you run the audit on:
defaults write NSGlobalDomain AppleKeyboardUIMode -int 3
defaults read NSGlobalDomain AppleKeyboardUIMode
# Expected: 3
On a dedicated CI Mac, leave it at 3 permanently. On a shared workstation, revert to -int 2 after a run so the standard macOS behaviour returns. The app under test must be relaunched for the change to take effect in that process.
The XCUITest below uses public API only. typeKey(_:modifierFlags:) with XCUIKeyboardKey.tab is documented on macOS. XCUIApplication.performAccessibilityAudit() is available on macOS 14 and later and checks every element on screen for missing labels, low contrast, action issues and parent-child problems; the textClipped, trait and dynamicType audit types are iOS-only, so if you narrow the scope use .all.subtracting([.textClipped, .trait, .dynamicType]) on macOS.
// AccessibilityAuditTests.swift
import XCTest
final class AccessibilityAuditTests: XCTestCase {
// Populate this list from the manual worksheet below with the accessibility
// identifiers your window exposes, in the Tab order the design requires.
// Leave empty on first run; the Tab-order check will be skipped.
let expectedFocusOrder: [String] = [
// "searchField",
// "filterButton",
// "sortDateButton",
]
func testElementLevelAudit() throws {
let app = XCUIApplication()
app.launch()
try app.performAccessibilityAudit()
}
func testTabThroughExpectedOrder() throws {
guard !expectedFocusOrder.isEmpty else {
throw XCTSkip("expectedFocusOrder is empty; populate it from the manual worksheet first")
}
let app = XCUIApplication()
app.launch()
let window = app.windows.firstMatch
XCTAssertTrue(window.waitForExistence(timeout: 5), "Main window did not appear")
for identifier in expectedFocusOrder {
app.typeKey(XCUIKeyboardKey.tab, modifierFlags: [])
let target = window.descendants(matching: .any).matching(identifier: identifier).firstMatch
XCTAssertTrue(target.waitForExistence(timeout: 2),
"After Tab, expected '\(identifier)' to be present in the window")
}
}
}
This is an unexecuted example to adapt to your own project. testElementLevelAudit fails on any element defect Apple's audit recognises — missing labels, insufficient contrast, elements without reachable actions — which is the class of problem tooling can decide without a human. testTabThroughExpectedOrder walks Tab the number of times you listed and checks each identifier is present when it is that element's turn. It deliberately does not query focus directly: a reliable macOS focus check requires an undocumented KVC on hasKeyboardFocus, and pinning CI to it is fragile across Xcode releases. The manual worksheet below carries the precise focus check, and the automated test enforces the ordered list you derived from it.
Produce the baseline the first time by hand. The worksheet takes a focused engineer roughly five minutes per window and produces a diff-friendly CSV reviewers keep in the repository.
Launch the app under test on the Mac with full keyboard access enabled.
Click once in the title bar to focus the window. If some controls still refuse Tab on your macOS version, press Fn+Ctrl+F7 to toggle keyboard access on so Tab moves between all controls.
Create baseline-focus-order.csv with the header:
step,label,role,identifier,notes
Press Tab. Record the step number, the VoiceOver label you hear (turn VoiceOver on with Cmd+F5 if you cannot read the focus ring), the control type (button, textField, checkBox, popupButton, tab, menuItem, list) and, if your team sets accessibility identifiers, the identifier. Leave notes empty.
Repeat until the ring returns to the first control or leaves the window. If the same control is focused twice in a row before the cycle closes, mark that row's notes column WCAG-2.4.3-FOCUS-TRAP and stop.
Count the focusable controls visible in the window by eye. If that number is larger than the row count, the missing controls are probably failing both SC 2.4.3 and SC 2.1.1 Keyboard. Add a row with notes = WCAG-2.1.1-UNREACHABLE for each one and link the bug.
Commit the CSV next to the UI test target. Copy the identifier column into expectedFocusOrder in the Swift test so the automated check enforces the agreed order on every pull request.
Keep each CSV short. A dialog with twelve focusable controls produces twelve rows; a settings window with a sidebar and three panes is three CSVs, one per pane. Reviewers can diff a twelve-row CSV instantly and reason about what a reordered line means for a keyboard user.
A clean pass looks like this:
step,label,role,identifier,notes
1,"Search",textField,searchField,
2,"Filter",button,filterButton,
3,"Sort by Date",button,sortDateButton,
4,"First result",row,result-0,
A trap looks like this:
3,"Filter",button,filterButton,
4,"Filter",button,filterButton,WCAG-2.4.3-FOCUS-TRAP
Typical defects the worksheet surfaces: a custom view that paints a focus ring but exposes no accessibility element (missing from the CSV entirely); a modal that returns focus to an unrelated control on dismiss (the identifier after the modal does not match the one before it); toolbar items reachable only via Fn+Ctrl+F7 on older macOS versions (missing from the Tab-only walk by design, worth a separate audit row).
Run the tests from the command line and export anything the accessibility audit attached to the result bundle:
xcodebuild test \
-scheme "$YOUR_SCHEME" \
-destination 'platform=macOS' \
-resultBundlePath Build/Results.xcresult
xcrun xcresulttool export attachments \
--path Build/Results.xcresult \
--output-path artifacts/
For the focus-order diff, compare the committed CSV against the one produced by the latest worksheet pass (either regenerated by hand after an intentional UI change or, if your team automates it further, by a helper that walks expectedFocusOrder and captures the labels):
diff artifacts/focus-order.csv baseline-focus-order.csv
Any line difference — a new control, a renamed label, a reordered stop — fails the step. Update the baseline deliberately in the same commit that changes the UI so reviewers see the focus-order diff next to the code change. Treat an unexplained diff the same as a failing unit test: block the pull request until the change is justified.
The audit runs on any macOS host with Xcode and the test target's build toolchain. The Figment M-series Mac from $95/month is the direct choice when you need current Xcode, ARM-native builds, and the current performAccessibilityAudit() surface. State your minimum RAM, macOS version and Xcode release in the quote so the configured machine matches your toolchain; the "from $95" figure is the entry price for the M-series tier, not a guaranteed M4 or 16 GB configuration.
The $29/month virtual Intel macOS plan runs the same audit when your project still builds under Xcode 26 or earlier. Per Apple's porting guide, Xcode 27 itself requires Apple silicon, so pick the M-series tier for Xcode 27 projects or for any ARM-only runtime work. Intel Xcode can cross-compile universal and arm64 slices, and the VM runs macOS directly; you reach it through the browser console and run xcodebuild and the test suite in its desktop terminal. Install Xcode from the Apple Developer site on either plan — the Apple ID and licence agreements are separate from the hosting cost.
Start a Figment Mac configuration when you are ready to attach the audit to a hosted runner.
Related testing guides: