Commit ea57052e99
Unsigned
Layout: unified · split
.github/workflows/build.yml +30 −11
| @@ -20,18 +20,28 @@ name: Build | |||
| 20 | # TEST_RUNNER_-prefixed build setting reaches the UI test process.) | 20 | # TEST_RUNNER_-prefixed build setting reaches the UI test process.) |
| 21 | # | 21 | # |
| 22 | # The matrix runs two simulators because audit coverage is NOT nested — each | 22 | # The matrix runs two simulators because audit coverage is NOT nested — each |
| 23 | # runtime reports findings the other misses, in both directions. Measured on the | 23 | # runtime reports findings the other misses, in both directions. Measured |
| 24 | # Tracked Domains screen, iOS 18.6 reported 2 issues and iOS 27.0 reported 6 | 24 | # locally on the Tracked Domains screen, iOS 18.6 reported 2 issues and iOS 27.0 |
| 25 | # (including contrast and element-detection issues 18.6 never raised); at | 25 | # reported 6 (including contrast and element-detection issues 18.6 never |
| 26 | # accessibility text sizes the Dashboard produced a hit-region finding on 18.6 | 26 | # raised); at accessibility text sizes the Dashboard produced a hit-region |
| 27 | # that 27.0 did not. Testing only the newest image would leave the oldest | 27 | # finding on 18.6 that 27.0 did not. |
| 28 | # supported OS unchecked; testing only the floor would miss newer audit checks. | ||
| 29 | # | 28 | # |
| 30 | # Runtimes are resolved dynamically rather than pinned: the deployment target is | 29 | # CAVEAT — this image cannot test the real support floor. The app's deployment |
| 31 | # 17.6, but no 17.6 simulator runtime ships, so "floor" means the oldest | 30 | # target is 17.6, but the macos-26 runner ships only iOS 26.x simulator |
| 32 | # available runtime at or above the deployment target (18.6 at time of writing). | 31 | # runtimes, so "floor" resolves to ~26.2 here rather than an 18.x image. CI |
| 33 | # The previous selector took the first iPhone from any runtime, which could pick | 32 | # therefore compares two 26.x runtimes; genuine oldest-supported-OS coverage has |
| 34 | # a simulator BELOW the deployment target, where the app cannot install. | 33 | # to come from a local run or a self-hosted runner with older runtimes |
| 34 | # installed. The "Select simulator" step emits a warning annotation when the | ||
| 35 | # resolved floor sits well above the deployment target, so this gap stays | ||
| 36 | # visible instead of being silently assumed away. | ||
| 37 | # | ||
| 38 | # Installing an older runtime in CI (xcodebuild -downloadPlatform iOS | ||
| 39 | # -buildVersion 18.6) is possible but costs several GB and minutes per job; not | ||
| 40 | # done by default. | ||
| 41 | # | ||
| 42 | # Runtimes are resolved dynamically rather than pinned. The previous selector | ||
| 43 | # took the first iPhone from any runtime, which could pick a simulator BELOW the | ||
| 44 | # deployment target, where the app cannot install. | ||
| 35 | # | 45 | # |
| 36 | # pull_request only, plus manual dispatch. GitHub builds the merge result (PR | 46 | # pull_request only, plus manual dispatch. GitHub builds the merge result (PR |
| 37 | # merged into main), so a green PR validates exactly what will land on main. | 47 | # merged into main), so a green PR validates exactly what will land on main. |
| @@ -115,6 +125,15 @@ jobs: | |||
| 115 | echo "udid=$(echo "$selected" | jq -r .udid)" >> "$GITHUB_OUTPUT" | 125 | echo "udid=$(echo "$selected" | jq -r .udid)" >> "$GITHUB_OUTPUT" |
| 116 | echo "label=$label" >> "$GITHUB_OUTPUT" | 126 | echo "label=$label" >> "$GITHUB_OUTPUT" |
| 117 | 127 | ||
| 128 | # Surface the floor-coverage gap rather than letting the matrix imply | ||
| 129 | # coverage it does not have. One major version of slack is tolerated. | ||
| 130 | if [ "${{ matrix.tier }}" = "floor" ]; then | ||
| 131 | rank=$(echo "$selected" | jq -r .rank) | ||
| 132 | if [ "$rank" -ge $(( (DEPLOYMENT_TARGET_MAJOR + 1) * 1000 )) ]; then | ||
| 133 | echo "::warning::Floor tier resolved to $label, well above the ${DEPLOYMENT_TARGET_MAJOR}.${DEPLOYMENT_TARGET_MINOR} deployment target. This image has no older runtime, so the oldest supported OS is NOT covered by this run." | ||
| 134 | fi | ||
| 135 | fi | ||
| 136 | |||
| 118 | - name: Test on ${{ steps.sim.outputs.label }} | 137 | - name: Test on ${{ steps.sim.outputs.label }} |
| 119 | run: | | 138 | run: | |
| 120 | set -o pipefail | 139 | set -o pipefail |
DomainDigUITests/AccessibilityAuditHarness.swift +54 −7
| @@ -40,6 +40,9 @@ enum AccessibilityAuditHarness { | |||
| 40 | /// `.sufficientElementDescription`, `.trait` | 40 | /// `.sufficientElementDescription`, `.trait` |
| 41 | static let enforcedAuditTypes: XCUIAccessibilityAuditType = [] | 41 | static let enforcedAuditTypes: XCUIAccessibilityAuditType = [] |
| 42 | 42 | ||
| 43 | /// How many times to retry an audit that misses its internal deadline. | ||
| 44 | private static let auditAttempts = 3 | ||
| 45 | |||
| 43 | /// Launches the app with feature gating lifted, optionally at a specific | 46 | /// Launches the app with feature gating lifted, optionally at a specific |
| 44 | /// content size category. | 47 | /// content size category. |
| 45 | static func launch(contentSizeCategory: String? = nil) -> XCUIApplication { | 48 | static func launch(contentSizeCategory: String? = nil) -> XCUIApplication { |
| @@ -56,19 +59,53 @@ enum AccessibilityAuditHarness { | |||
| 56 | /// | 59 | /// |
| 57 | /// Findings are logged and attached to the result bundle so a CI run | 60 | /// Findings are logged and attached to the result bundle so a CI run |
| 58 | /// produces the burndown list as an artifact rather than only a pass/fail. | 61 | /// produces the burndown list as an artifact rather than only a pass/fail. |
| 62 | /// | ||
| 63 | /// Returns `false` if the audit could not complete, leaving the screen | ||
| 64 | /// unaudited. Callers turn that into an `XCTSkip` — reporting a pass would | ||
| 65 | /// claim coverage that did not happen. | ||
| 66 | @discardableResult | ||
| 59 | static func audit( | 67 | static func audit( |
| 60 | _ app: XCUIApplication, | 68 | _ app: XCUIApplication, |
| 61 | screen: String, | 69 | screen: String, |
| 62 | test: XCTestCase | 70 | test: XCTestCase |
| 63 | ) throws { | 71 | ) throws -> Bool { |
| 64 | var findings: [String] = [] | 72 | var findings: [String] = [] |
| 73 | var timeout: Error? | ||
| 74 | |||
| 75 | // The audit traverses the whole element tree and has its own internal | ||
| 76 | // deadline, which slower CI runners miss on the denser screens. That is a | ||
| 77 | // tooling timeout, not an app defect, so retry before giving up. | ||
| 78 | // | ||
| 79 | // Only the timeout is retried. If a category is enforced and the audit | ||
| 80 | // reports findings before timing out, those failures are already recorded | ||
| 81 | // and a retry would duplicate them — accepted, because the alternative is | ||
| 82 | // losing the run to an infrastructure hiccup. | ||
| 83 | for attempt in 1...auditAttempts { | ||
| 84 | findings.removeAll() | ||
| 85 | timeout = nil | ||
| 86 | do { | ||
| 87 | try app.performAccessibilityAudit { issue in | ||
| 88 | let isEnforced = !enforcedAuditTypes.intersection(issue.auditType).isEmpty | ||
| 89 | let marker = isEnforced ? "FAIL" : "report" | ||
| 90 | findings.append("[\(marker)][\(name(for: issue.auditType))] \(issue.compactDescription)") | ||
| 91 | // true suppresses the finding, false reports it as a test failure. | ||
| 92 | return !isEnforced | ||
| 93 | } | ||
| 94 | break | ||
| 95 | } catch let error as NSError where error.isAccessibilityAuditTimeout { | ||
| 96 | timeout = error | ||
| 97 | print("\(screen): audit timed out (attempt \(attempt) of \(auditAttempts))") | ||
| 98 | } | ||
| 99 | } | ||
| 65 | 100 | ||
| 66 | try app.performAccessibilityAudit { issue in | 101 | if timeout != nil { |
| 67 | let isEnforced = !enforcedAuditTypes.intersection(issue.auditType).isEmpty | 102 | let message = "\(screen): audit did not complete in time after \(auditAttempts) attempts — screen NOT audited" |
| 68 | let marker = isEnforced ? "FAIL" : "report" | 103 | print(message) |
| 69 | findings.append("[\(marker)][\(name(for: issue.auditType))] \(issue.compactDescription)") | 104 | let attachment = XCTAttachment(string: message) |
| 70 | // true suppresses the finding, false reports it as a test failure. | 105 | attachment.name = "a11y-audit-\(screen)-timeout" |
| 71 | return !isEnforced | 106 | attachment.lifetime = .keepAlways |
| 107 | test.add(attachment) | ||
| 108 | return false | ||
| 72 | } | 109 | } |
| 73 | 110 | ||
| 74 | let summary = findings.isEmpty | 111 | let summary = findings.isEmpty |
| @@ -81,6 +118,8 @@ enum AccessibilityAuditHarness { | |||
| 81 | attachment.name = "a11y-audit-\(screen)" | 118 | attachment.name = "a11y-audit-\(screen)" |
| 82 | attachment.lifetime = .keepAlways | 119 | attachment.lifetime = .keepAlways |
| 83 | test.add(attachment) | 120 | test.add(attachment) |
| 121 | |||
| 122 | return true | ||
| 84 | } | 123 | } |
| 85 | 124 | ||
| 86 | /// `XCUIAccessibilityAuditType` is an option set whose description is just a | 125 | /// `XCUIAccessibilityAuditType` is an option set whose description is just a |
| @@ -102,6 +141,14 @@ enum AccessibilityAuditHarness { | |||
| 102 | } | 141 | } |
| 103 | } | 142 | } |
| 104 | 143 | ||
| 144 | private extension NSError { | ||
| 145 | /// `Audit failed to complete in time` — the audit's own deadline, raised by | ||
| 146 | /// XCTest rather than by anything wrong with the app. | ||
| 147 | var isAccessibilityAuditTimeout: Bool { | ||
| 148 | domain == "com.apple.xcode.xctest.accessibilityAudit" && code == -56 | ||
| 149 | } | ||
| 150 | } | ||
| 151 | |||
| 105 | extension XCUIApplication { | 152 | extension XCUIApplication { |
| 106 | /// Taps a root tab by its visible label. | 153 | /// Taps a root tab by its visible label. |
| 107 | /// | 154 | /// |
DomainDigUITests/AccessibilityAuditTests.swift +30 −21
| @@ -17,33 +17,23 @@ final class AccessibilityAuditTests: XCTestCase { | |||
| 17 | // MARK: Per-screen audits | 17 | // MARK: Per-screen audits |
| 18 | 18 | ||
| 19 | func testInspectScreen() throws { | 19 | func testInspectScreen() throws { |
| 20 | let app = AccessibilityAuditHarness.launch() | 20 | try auditRootTab("Inspect") |
| 21 | app.selectRootTab("Inspect") | ||
| 22 | try AccessibilityAuditHarness.audit(app, screen: "inspect", test: self) | ||
| 23 | } | 21 | } |
| 24 | 22 | ||
| 25 | func testDashboardScreen() throws { | 23 | func testDashboardScreen() throws { |
| 26 | let app = AccessibilityAuditHarness.launch() | 24 | try auditRootTab("Dashboard") |
| 27 | app.selectRootTab("Dashboard") | ||
| 28 | try AccessibilityAuditHarness.audit(app, screen: "dashboard", test: self) | ||
| 29 | } | 25 | } |
| 30 | 26 | ||
| 31 | func testAuditScreen() throws { | 27 | func testAuditScreen() throws { |
| 32 | let app = AccessibilityAuditHarness.launch() | 28 | try auditRootTab("Audit") |
| 33 | app.selectRootTab("Audit") | ||
| 34 | try AccessibilityAuditHarness.audit(app, screen: "audit", test: self) | ||
| 35 | } | 29 | } |
| 36 | 30 | ||
| 37 | func testHistoryScreen() throws { | 31 | func testHistoryScreen() throws { |
| 38 | let app = AccessibilityAuditHarness.launch() | 32 | try auditRootTab("History") |
| 39 | app.selectRootTab("History") | ||
| 40 | try AccessibilityAuditHarness.audit(app, screen: "history", test: self) | ||
| 41 | } | 33 | } |
| 42 | 34 | ||
| 43 | func testSettingsScreen() throws { | 35 | func testSettingsScreen() throws { |
| 44 | let app = AccessibilityAuditHarness.launch() | 36 | try auditRootTab("Settings") |
| 45 | app.selectRootTab("Settings") | ||
| 46 | try AccessibilityAuditHarness.audit(app, screen: "settings", test: self) | ||
| 47 | } | 37 | } |
| 48 | 38 | ||
| 49 | func testTrackedDomainsScreen() throws { | 39 | func testTrackedDomainsScreen() throws { |
| @@ -57,7 +47,8 @@ final class AccessibilityAuditTests: XCTestCase { | |||
| 57 | ) | 47 | ) |
| 58 | trackedDomains.tap() | 48 | trackedDomains.tap() |
| 59 | 49 | ||
| 60 | try AccessibilityAuditHarness.audit(app, screen: "tracked-domains", test: self) | 50 | let audited = try AccessibilityAuditHarness.audit(app, screen: "tracked-domains", test: self) |
| 51 | try XCTSkipUnless(audited, "Tracked Domains audit did not complete in time") | ||
| 61 | } | 52 | } |
| 62 | 53 | ||
| 63 | // MARK: Dynamic Type | 54 | // MARK: Dynamic Type |
| @@ -67,18 +58,36 @@ final class AccessibilityAuditTests: XCTestCase { | |||
| 67 | /// This is where clipped text and fixed-height containers surface — the | 58 | /// This is where clipped text and fixed-height containers surface — the |
| 68 | /// `.accessibility5`-class failures that the fixed geometry in | 59 | /// `.accessibility5`-class failures that the fixed geometry in |
| 69 | /// `AppDensityMetrics` is expected to produce until phase 3 of #21 lands. | 60 | /// `AppDensityMetrics` is expected to produce until phase 3 of #21 lands. |
| 61 | /// | ||
| 62 | /// Every screen is attempted even if an earlier one times out, so one slow | ||
| 63 | /// screen cannot silently drop the rest; the skip is reported at the end. | ||
| 70 | func testAllScreensAtLargestAccessibilitySize() throws { | 64 | func testAllScreensAtLargestAccessibilitySize() throws { |
| 71 | let app = AccessibilityAuditHarness.launch( | 65 | let app = AccessibilityAuditHarness.launch( |
| 72 | contentSizeCategory: "UICTContentSizeCategoryAccessibilityXXXL" | 66 | contentSizeCategory: "UICTContentSizeCategoryAccessibilityXXXL" |
| 73 | ) | 67 | ) |
| 74 | 68 | ||
| 69 | var unaudited: [String] = [] | ||
| 70 | |||
| 75 | for tab in ["Inspect", "Dashboard", "Audit", "History", "Settings"] { | 71 | for tab in ["Inspect", "Dashboard", "Audit", "History", "Settings"] { |
| 76 | app.selectRootTab(tab) | 72 | app.selectRootTab(tab) |
| 77 | try AccessibilityAuditHarness.audit( | 73 | let screen = "\(tab.lowercased())-accessibilityXXXL" |
| 78 | app, | 74 | if try !AccessibilityAuditHarness.audit(app, screen: screen, test: self) { |
| 79 | screen: "\(tab.lowercased())-accessibilityXXXL", | 75 | unaudited.append(tab) |
| 80 | test: self | 76 | } |
| 81 | ) | ||
| 82 | } | 77 | } |
| 78 | |||
| 79 | try XCTSkipUnless( | ||
| 80 | unaudited.isEmpty, | ||
| 81 | "Audit did not complete in time for: \(unaudited.joined(separator: ", "))" | ||
| 82 | ) | ||
| 83 | } | ||
| 84 | |||
| 85 | // MARK: Helpers | ||
| 86 | |||
| 87 | private func auditRootTab(_ tab: String) throws { | ||
| 88 | let app = AccessibilityAuditHarness.launch() | ||
| 89 | app.selectRootTab(tab) | ||
| 90 | let audited = try AccessibilityAuditHarness.audit(app, screen: tab.lowercased(), test: self) | ||
| 91 | try XCTSkipUnless(audited, "Audit did not complete in time for \(tab)") | ||
| 83 | } | 92 | } |
| 84 | } | 93 | } |