From 6d9b5457d8713c3061e4d4980017393ad83f50b9 Mon Sep 17 00:00:00 2001 From: Bryan Date: Mon, 2 Nov 2026 06:51:25 -0600 Subject: [PATCH] test(example): add XCUITest accessibility checks Four UI tests run the example on an iOS simulator and read the real accessibility tree: the invisible page control announces the position, only the visible page is exposed (the flip layers are hidden), headings are single elements, and turning the page updates both. A script adds the test target to the generated iOS project, since example/ios is not versioned. XCUITest cannot fire the increment action or read the header trait, so those two are not covered. Co-Authored-By: Claude Sonnet 5.5 --- CONTRIBUTING.md | 7 +++ example/package.json | 3 +- example/uitests/A11yUITests.swift | 69 +++++++++++++++++++++++++++ example/uitests/add-ui-test-target.rb | 36 ++++++++++++++ example/uitests/run.sh | 24 ++++++++++ 5 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 example/uitests/A11yUITests.swift create mode 100644 example/uitests/add-ui-test-target.rb create mode 100755 example/uitests/run.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70dc7cf..d70112f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,13 @@ npm run format:check # check formatting with Prettier npm test # run the unit tests (node:test) ``` +The accessibility checks run as XCUITests against the example app on an iOS simulator (see +[`example/uitests/run.sh`](./example/uitests/run.sh) for the setup): + +```sh +cd example && npm run test:a11y -- +``` + ## Branching model Two permanent branches, both protected: diff --git a/example/package.json b/example/package.json index f9b6ed6..28802d5 100644 --- a/example/package.json +++ b/example/package.json @@ -5,7 +5,8 @@ "scripts": { "start": "expo start", "ios": "expo run:ios", - "android": "expo run:android" + "android": "expo run:android", + "test:a11y": "sh uitests/run.sh" }, "dependencies": { "@react-native-async-storage/async-storage": "^2.1.0", diff --git a/example/uitests/A11yUITests.swift b/example/uitests/A11yUITests.swift new file mode 100644 index 0000000..de57fac --- /dev/null +++ b/example/uitests/A11yUITests.swift @@ -0,0 +1,69 @@ +import XCTest + +/// Checks what VoiceOver sees in the example app: the invisible page control, +/// the pages hidden behind the flip animation and the headings. +/// +/// Runs on a phone-sized simulator, where the demo document has several pages and a +/// single page is shown at a time. XCUITest does not expose accessibility traits, so +/// the heading check is structural (see `testHeadingIsASingleAccessibilityElement`). +final class A11yUITests: XCTestCase { + private var app: XCUIApplication! + + private let firstHeading = "1. What this document shows" + private let laterHeading = "6. Long content to paginate" + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication(bundleIdentifier: "com.example.rnddoc") + app.launch() + XCTAssertTrue(element(label: firstHeading).waitForExistence(timeout: 90), "document did not render") + } + + private func element(label: String) -> XCUIElement { + app.descendants(matching: .any) + .matching(NSPredicate(format: "label == %@", label)) + .firstMatch + } + + private var pageControl: XCUIElement { element(label: "Document pages") } + + func testPageControlAnnouncesTheCurrentPage() throws { + XCTAssertTrue(pageControl.exists, "the invisible page control is missing") + let value = try XCTUnwrap(pageControl.value as? String) + XCTAssertNotNil( + value.range(of: #"^Page 1 of \d+$"#, options: .regularExpression), + "unexpected spoken position: \(value)" + ) + XCTAssertGreaterThanOrEqual(pageControl.frame.height, 44, "the control is too small to focus") + } + + func testOnlyTheVisiblePageIsInTheAccessibilityTree() { + XCTAssertTrue(element(label: firstHeading).exists) + XCTAssertFalse( + element(label: laterHeading).exists, + "text from a page hidden behind the flip animation is exposed to screen readers" + ) + } + + /// XCUITest cannot read the `header` trait, so this checks the structure it produces: + /// a heading is an accessible container that swallows its text, while a paragraph + /// keeps its own static text element. + func testHeadingIsASingleAccessibilityElement() { + let heading = element(label: firstHeading) + XCTAssertEqual(heading.descendants(matching: .staticText).count, 0) + } + + /// XCUITest can only `adjust` sliders and pickers, so the `increment` action itself + /// cannot be fired here; this turns the page with a real drag instead and checks + /// that the spoken position and the exposed content follow. + func testTurningThePageUpdatesTheControlAndTheTree() throws { + let from = app.coordinate(withNormalizedOffset: CGVector(dx: 0.85, dy: 0.5)) + let to = app.coordinate(withNormalizedOffset: CGVector(dx: 0.15, dy: 0.5)) + from.press(forDuration: 0.1, thenDragTo: to) + + let turned = NSPredicate(format: "value == %@", "Page 2 of 4") + expectation(for: turned, evaluatedWith: pageControl) + waitForExpectations(timeout: 15) + XCTAssertFalse(element(label: firstHeading).exists, "page 1 is still exposed after turning") + } +} diff --git a/example/uitests/add-ui-test-target.rb b/example/uitests/add-ui-test-target.rb new file mode 100644 index 0000000..3a453af --- /dev/null +++ b/example/uitests/add-ui-test-target.rb @@ -0,0 +1,36 @@ +# Adds the accessibility UI test target to the generated iOS project (example/ios is +# produced by `expo prebuild` and is not versioned). Safe to run more than once. +require 'xcodeproj' + +TARGET_NAME = 'rndexampleA11yUITests' +ROOT = File.expand_path('..', __dir__) +PROJECT_PATH = File.join(ROOT, 'ios', 'rndexample.xcodeproj') + +project = Xcodeproj::Project.open(PROJECT_PATH) +app_target = project.targets.find { |t| t.name == 'rndexample' } or abort('app target not found') +target = project.targets.find { |t| t.name == TARGET_NAME } + +unless target + target = project.new_target(:ui_test_bundle, TARGET_NAME, :ios, '15.1') + target.add_dependency(app_target) + group = project.main_group.new_group(TARGET_NAME, File.join(ROOT, 'uitests')) + ref = group.new_file('A11yUITests.swift') + target.add_file_references([ref]) + target.build_configurations.each do |config| + config.build_settings['PRODUCT_NAME'] = '$(TARGET_NAME)' + config.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = 'com.example.rnddoc.a11yuitests' + config.build_settings['TEST_TARGET_NAME'] = 'rndexample' + config.build_settings['GENERATE_INFOPLIST_FILE'] = 'YES' + config.build_settings['SWIFT_VERSION'] = '5.0' + config.build_settings['TARGETED_DEVICE_FAMILY'] = '1,2' + config.build_settings['CODE_SIGN_STYLE'] = 'Automatic' + end + project.save +end + +scheme = Xcodeproj::XCScheme.new +scheme.add_build_target(app_target) +scheme.add_test_target(target) +scheme.set_launch_target(app_target) +scheme.save_as(PROJECT_PATH, TARGET_NAME, true) +puts "Configured #{TARGET_NAME}" diff --git a/example/uitests/run.sh b/example/uitests/run.sh new file mode 100755 index 0000000..82d8e66 --- /dev/null +++ b/example/uitests/run.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Runs the accessibility UI tests on an iOS simulator. +# +# Usage: npm run test:a11y -- +# +# Requirements: the example app was built once for the simulator (`npm run ios`) and Metro is +# serving it on port 8090 (`npx expo start --port 8090 --localhost`), with the simulator +# pointed at it: `xcrun simctl spawn defaults write com.example.rnddoc RCT_jsLocation localhost:8090`. +# Use a phone-sized simulator: the demo document has several pages there and shows one at a time. +set -eu + +UDID="${1:?usage: npm run test:a11y -- }" +cd "$(dirname "$0")/.." + +export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 +ruby uitests/add-ui-test-target.rb +cd ios +xcodebuild test \ + -workspace rndexample.xcworkspace \ + -scheme rndexampleA11yUITests \ + -sdk iphonesimulator \ + -destination "id=$UDID" \ + -configuration Debug \ + -derivedDataPath build