summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/build.yml102
-rw-r--r--DomainDig.xcodeproj/project.pbxproj120
-rw-r--r--DomainDig.xcodeproj/xcshareddata/xcschemes/DomainDig.xcscheme12
-rw-r--r--DomainDigUITests/AccessibilityAuditHarness.swift121
-rw-r--r--DomainDigUITests/AccessibilityAuditTests.swift84
5 files changed, 415 insertions, 24 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f4e7a2d..a6a2113 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,13 +1,37 @@
name: Build
-# Compile gate for the GitHub mirror. builds.sr.ht is the primary remote for
-# this project but has no macOS images, so xcodebuild cannot run there; this job
-# compiles the app on a GitHub-hosted macOS runner instead.
+# Compile gate and accessibility audit for the GitHub mirror. builds.sr.ht is the
+# primary remote for this project but has no macOS images, so xcodebuild cannot
+# run there; this job builds and tests on a GitHub-hosted macOS runner instead.
#
-# This runs `xcodebuild build`, not `test`: the project has no test target yet
-# (planned for v5.0.0 in RELEASE_ROADMAP.md). When a test target and test plan
-# exist, switch the final step to `xcodebuild test -testPlan <name>` and rename
-# this workflow — the rest of the setup already matches a test run.
+# This runs `xcodebuild test`, which also compiles the app, the widget, and the
+# share extension (the DomainDig scheme's build action pulls both in as
+# dependencies). The test target is DomainDigUITests — an accessibility audit
+# suite; see DomainDigUITests/AccessibilityAuditHarness.swift.
+#
+# The audit REPORTS but does not FAIL by default. It surfaces violations that
+# exist today, so gating on it would block every unrelated PR until the
+# accessibility pass in issue #21 completes. Findings land in the job log and in
+# the uploaded .xcresult bundle, tagged [report] or [FAIL].
+#
+# Enforcement is a committed constant, not a CI setting: widen
+# `AccessibilityAuditHarness.enforcedAuditTypes` as each phase clears a category.
+# (Env vars were tried first — neither a plain xcodebuild env var nor a
+# TEST_RUNNER_-prefixed build setting reaches the UI test process.)
+#
+# The matrix runs two simulators because audit coverage is NOT nested — each
+# runtime reports findings the other misses, in both directions. Measured on the
+# Tracked Domains screen, iOS 18.6 reported 2 issues and iOS 27.0 reported 6
+# (including contrast and element-detection issues 18.6 never raised); at
+# accessibility text sizes the Dashboard produced a hit-region finding on 18.6
+# that 27.0 did not. Testing only the newest image would leave the oldest
+# supported OS unchecked; testing only the floor would miss newer audit checks.
+#
+# Runtimes are resolved dynamically rather than pinned: the deployment target is
+# 17.6, but no 17.6 simulator runtime ships, so "floor" means the oldest
+# available runtime at or above the deployment target (18.6 at time of writing).
+# The previous selector took the first iPhone from any runtime, which could pick
+# a simulator BELOW the deployment target, where the app cannot install.
#
# pull_request only, plus manual dispatch. GitHub builds the merge result (PR
# merged into main), so a green PR validates exactly what will land on main.
@@ -31,13 +55,25 @@ concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
+env:
+ # Keep in sync with IPHONEOS_DEPLOYMENT_TARGET in DomainDig.xcodeproj.
+ DEPLOYMENT_TARGET_MAJOR: '17'
+ DEPLOYMENT_TARGET_MINOR: '6'
+
jobs:
- build:
- name: xcodebuild build
- # macos-latest still points at macOS 15, which lacks the iOS 26 SDK this app
- # targets (deployment target 26.2).
+ test:
+ name: xcodebuild test (${{ matrix.tier }})
+ # macos-latest still points at macOS 15, which lacks the iOS 26+ SDK this app
+ # is built against.
runs-on: macos-26
+ strategy:
+ fail-fast: false
+ matrix:
+ # floor = oldest runtime the app actually supports
+ # current = newest runtime available on the image
+ tier: [floor, current]
+
steps:
- uses: actions/checkout@v7
@@ -50,29 +86,51 @@ jobs:
id: sim
run: |
set -euo pipefail
- udid=$(xcrun simctl list devices available --json \
- | jq -r '[.devices[][] | select(.name | startswith("iPhone"))] | first | .udid')
- if [ -z "$udid" ] || [ "$udid" = "null" ]; then
- echo "No available iPhone simulator on this image" >&2
+ floor=$(( DEPLOYMENT_TARGET_MAJOR * 1000 + DEPLOYMENT_TARGET_MINOR ))
+
+ selected=$(xcrun simctl list devices available --json \
+ | jq -c --argjson floor "$floor" --arg tier "${{ matrix.tier }}" '
+ [ .devices | to_entries[]
+ | (.key | capture("SimRuntime\\.iOS-(?<maj>[0-9]+)-(?<min>[0-9]+)$")) as $v
+ | (($v.maj | tonumber) * 1000 + ($v.min | tonumber)) as $rank
+ | select($rank >= $floor)
+ | .value[]
+ | select(.name | startswith("iPhone"))
+ | { rank: $rank, udid: .udid, name: .name, os: "\($v.maj).\($v.min)" }
+ ]
+ | sort_by(.rank, .name)
+ | if length == 0 then empty
+ elif $tier == "floor" then .[0]
+ else .[-1] end
+ ')
+
+ if [ -z "$selected" ]; then
+ echo "::error::No iPhone simulator at or above iOS ${DEPLOYMENT_TARGET_MAJOR}.${DEPLOYMENT_TARGET_MINOR} on this image"
xcrun simctl list devices available >&2
exit 1
fi
- echo "udid=$udid" >> "$GITHUB_OUTPUT"
- - name: Build
+ label=$(echo "$selected" | jq -r '"\(.name) (iOS \(.os))"')
+ echo "Selected $label"
+ echo "udid=$(echo "$selected" | jq -r .udid)" >> "$GITHUB_OUTPUT"
+ echo "label=$label" >> "$GITHUB_OUTPUT"
+
+ - name: Test on ${{ steps.sim.outputs.label }}
run: |
set -o pipefail
- xcodebuild build \
+ xcodebuild test \
-project DomainDig.xcodeproj \
-scheme DomainDig \
-destination "id=${{ steps.sim.outputs.udid }}" \
- -resultBundlePath BuildResults.xcresult \
+ -resultBundlePath TestResults.xcresult \
CODE_SIGNING_ALLOWED=NO
- name: Upload results
- if: failure()
+ # Always upload: on success the bundle carries the accessibility burndown
+ # list, which is the reason this suite exists.
+ if: always()
uses: actions/upload-artifact@v4
with:
- name: build-results
- path: BuildResults.xcresult
+ name: test-results-${{ matrix.tier }}
+ path: TestResults.xcresult
retention-days: 7
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 8889f33..6b4c25c 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -33,6 +33,13 @@
remoteGlobalIDString = 8BDB00010000000000000006;
remoteInfo = DomainDigShareExtension;
};
+ 8BDC00010000000000000003 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 8B7800612F6090E300933221 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 8B7800682F6090E300933221;
+ remoteInfo = DomainDig;
+ };
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -61,6 +68,7 @@
8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIService.swift; sourceTree = "<group>"; };
8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
8BDB00010000000000000001 /* DomainDigShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ 8BDC00010000000000000001 /* DomainDigUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DomainDigUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -120,6 +128,11 @@
path = DomainDigShareExtension;
sourceTree = "<group>";
};
+ 8BDC0001000000000000000E /* DomainDigUITests */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = DomainDigUITests;
+ sourceTree = "<group>";
+ };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -144,6 +157,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 8BDC00010000000000000008 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -155,6 +175,7 @@
8BDA0001000000000000000D /* Shared */,
8BDA0001000000000000000E /* DomainDigWidget */,
8BDB0001000000000000000E /* DomainDigShareExtension */,
+ 8BDC0001000000000000000E /* DomainDigUITests */,
8B78006A2F6090E300933221 /* Products */,
8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */,
8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */,
@@ -172,6 +193,7 @@
8B7800692F6090E300933221 /* DomainDig.app */,
8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */,
8BDB00010000000000000001 /* DomainDigShareExtension.appex */,
+ 8BDC00010000000000000001 /* DomainDigUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
@@ -251,6 +273,29 @@
productReference = 8BDB00010000000000000001 /* DomainDigShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
+ 8BDC00010000000000000006 /* DomainDigUITests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 8BDC0001000000000000000A /* Build configuration list for PBXNativeTarget "DomainDigUITests" */;
+ buildPhases = (
+ 8BDC00010000000000000007 /* Sources */,
+ 8BDC00010000000000000008 /* Frameworks */,
+ 8BDC00010000000000000009 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 8BDC00010000000000000004 /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ 8BDC0001000000000000000E /* DomainDigUITests */,
+ );
+ name = DomainDigUITests;
+ packageProductDependencies = (
+ );
+ productName = DomainDigUITests;
+ productReference = 8BDC00010000000000000001 /* DomainDigUITests.xctest */;
+ productType = "com.apple.product-type.bundle.ui-testing";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -275,6 +320,10 @@
8BDB00010000000000000006 = {
CreatedOnToolsVersion = 26.3;
};
+ 8BDC00010000000000000006 = {
+ CreatedOnToolsVersion = 27.0;
+ TestTargetID = 8B7800682F6090E300933221;
+ };
};
};
buildConfigurationList = 8B7800642F6090E300933221 /* Build configuration list for PBXProject "DomainDig" */;
@@ -294,6 +343,7 @@
8B7800682F6090E300933221 /* DomainDig */,
8BDA00010000000000000006 /* DomainDigWidgetExtension */,
8BDB00010000000000000006 /* DomainDigShareExtension */,
+ 8BDC00010000000000000006 /* DomainDigUITests */,
);
};
/* End PBXProject section */
@@ -320,6 +370,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 8BDC00010000000000000009 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -351,6 +408,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 8BDC00010000000000000007 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -364,6 +428,11 @@
target = 8BDB00010000000000000006 /* DomainDigShareExtension */;
targetProxy = 8BDB00010000000000000003 /* PBXContainerItemProxy */;
};
+ 8BDC00010000000000000004 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 8B7800682F6090E300933221 /* DomainDig */;
+ targetProxy = 8BDC00010000000000000003 /* PBXContainerItemProxy */;
+ };
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@@ -420,7 +489,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
@@ -479,7 +548,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
@@ -680,6 +749,44 @@
};
name = Release;
};
+ 8BDC0001000000000000000B /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 43;
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
+ MARKETING_VERSION = 4.8.3;
+ PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigUITests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_TARGET_NAME = DomainDig;
+ };
+ name = Debug;
+ };
+ 8BDC0001000000000000000C /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 43;
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
+ MARKETING_VERSION = 4.8.3;
+ PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigUITests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_TARGET_NAME = DomainDig;
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -719,6 +826,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ 8BDC0001000000000000000A /* Build configuration list for PBXNativeTarget "DomainDigUITests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 8BDC0001000000000000000B /* Debug */,
+ 8BDC0001000000000000000C /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
};
rootObject = 8B7800612F6090E300933221 /* Project object */;
diff --git a/DomainDig.xcodeproj/xcshareddata/xcschemes/DomainDig.xcscheme b/DomainDig.xcodeproj/xcshareddata/xcschemes/DomainDig.xcscheme
index 8f733d7..5b0bc8e 100644
--- a/DomainDig.xcodeproj/xcshareddata/xcschemes/DomainDig.xcscheme
+++ b/DomainDig.xcodeproj/xcshareddata/xcschemes/DomainDig.xcscheme
@@ -29,6 +29,18 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
+ <Testables>
+ <TestableReference
+ skipped = "NO">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "8BDC00010000000000000006"
+ BuildableName = "DomainDigUITests.xctest"
+ BlueprintName = "DomainDigUITests"
+ ReferencedContainer = "container:DomainDig.xcodeproj">
+ </BuildableReference>
+ </TestableReference>
+ </Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
diff --git a/DomainDigUITests/AccessibilityAuditHarness.swift b/DomainDigUITests/AccessibilityAuditHarness.swift
new file mode 100644
index 0000000..80257e1
--- /dev/null
+++ b/DomainDigUITests/AccessibilityAuditHarness.swift
@@ -0,0 +1,121 @@
+import XCTest
+
+/// Shared plumbing for the accessibility audit suite.
+///
+/// `performAccessibilityAudit` checks contrast, hit-region size, clipped text at
+/// large Dynamic Type, element descriptions, and trait correctness — the same
+/// categories the accessibility pass in issue #21 works through.
+///
+/// **The suite reports by default and fails only for enforced categories.** The
+/// audit surfaces violations that exist today, so failing on everything would
+/// block unrelated PRs until the whole pass lands. `enforcedAuditTypes` below is
+/// the ratchet: widen it as each phase of #21 clears a category.
+///
+/// Two alternatives were tried and rejected:
+///
+/// - *A per-screen baseline count.* Audit coverage is not nested across OS
+/// versions — the same screen legitimately yields different counts on the
+/// floor simulator and the current one, so no single committed number is
+/// correct for both.
+/// - *An environment variable.* Neither a plain `xcodebuild` env var nor a
+/// `TEST_RUNNER_`-prefixed build setting reaches this process, so the toggle
+/// silently did nothing. A committed constant also makes "when did contrast
+/// become enforced?" answerable with `git blame` instead of CI tribal
+/// knowledge.
+@MainActor
+enum AccessibilityAuditHarness {
+ /// Launch argument that lifts feature gating so Pro-only screens are
+ /// reachable. `PurchaseService` honours this in `DEBUG` builds only.
+ private static let forceProPlusArgument = "DOMAIN_DIG_FORCE_PRO_PLUS"
+
+ /// Audit categories that fail the build. Everything else is reported only.
+ ///
+ /// Empty until the accessibility pass starts landing. Suggested ratchet,
+ /// following the phases in issue #21:
+ ///
+ /// - after phase 2 (semantic colors + light mode): `.contrast`
+ /// - after phase 3 (Dynamic Type + reflow): `.textClipped`, `.dynamicType`,
+ /// `.hitRegion`
+ /// - after phase 4 (VoiceOver): `.elementDetection`,
+ /// `.sufficientElementDescription`, `.trait`
+ static let enforcedAuditTypes: XCUIAccessibilityAuditType = []
+
+ /// Launches the app with feature gating lifted, optionally at a specific
+ /// content size category.
+ static func launch(contentSizeCategory: String? = nil) -> XCUIApplication {
+ let app = XCUIApplication()
+ app.launchArguments = [forceProPlusArgument]
+ if let contentSizeCategory {
+ app.launchArguments += ["-UIPreferredContentSizeCategoryName", contentSizeCategory]
+ }
+ app.launch()
+ return app
+ }
+
+ /// Runs a full audit and records every finding against the test.
+ ///
+ /// Findings are logged and attached to the result bundle so a CI run
+ /// produces the burndown list as an artifact rather than only a pass/fail.
+ static func audit(
+ _ app: XCUIApplication,
+ screen: String,
+ test: XCTestCase
+ ) throws {
+ var findings: [String] = []
+
+ try app.performAccessibilityAudit { issue in
+ let isEnforced = !enforcedAuditTypes.intersection(issue.auditType).isEmpty
+ let marker = isEnforced ? "FAIL" : "report"
+ findings.append("[\(marker)][\(name(for: issue.auditType))] \(issue.compactDescription)")
+ // true suppresses the finding, false reports it as a test failure.
+ return !isEnforced
+ }
+
+ let summary = findings.isEmpty
+ ? "\(screen): no accessibility findings"
+ : "\(screen): \(findings.count) finding(s)\n" + findings.sorted().map { " • \($0)" }.joined(separator: "\n")
+
+ print(summary)
+
+ let attachment = XCTAttachment(string: summary)
+ attachment.name = "a11y-audit-\(screen)"
+ attachment.lifetime = .keepAlways
+ test.add(attachment)
+ }
+
+ /// `XCUIAccessibilityAuditType` is an option set whose description is just a
+ /// raw bitmask, which makes the burndown list unreadable. Resolve it against
+ /// the named members rather than hard-coding bit positions, so this keeps
+ /// working if Apple adds audit types.
+ private static func name(for type: XCUIAccessibilityAuditType) -> String {
+ let known: [(XCUIAccessibilityAuditType, String)] = [
+ (.contrast, "contrast"),
+ (.elementDetection, "elementDetection"),
+ (.hitRegion, "hitRegion"),
+ (.sufficientElementDescription, "sufficientElementDescription"),
+ (.dynamicType, "dynamicType"),
+ (.textClipped, "textClipped"),
+ (.trait, "trait")
+ ]
+ let matched = known.filter { type.contains($0.0) }.map(\.1)
+ return matched.isEmpty ? "unknown(\(type.rawValue))" : matched.joined(separator: "+")
+ }
+}
+
+extension XCUIApplication {
+ /// Taps a root tab by its visible label.
+ ///
+ /// Falls back to a plain button query because the tab bar is only present in
+ /// the compact size class — in regular width `RootTabView` renders a
+ /// `NavigationSplitView` sidebar instead.
+ @MainActor
+ func selectRootTab(_ name: String) {
+ let tabButton = tabBars.buttons[name]
+ let element = tabButton.waitForExistence(timeout: 5) ? tabButton : buttons[name]
+ XCTAssertTrue(
+ element.waitForExistence(timeout: 5),
+ "Could not find a way to reach the \(name) screen"
+ )
+ element.tap()
+ }
+}
diff --git a/DomainDigUITests/AccessibilityAuditTests.swift b/DomainDigUITests/AccessibilityAuditTests.swift
new file mode 100644
index 0000000..63b3d02
--- /dev/null
+++ b/DomainDigUITests/AccessibilityAuditTests.swift
@@ -0,0 +1,84 @@
+import XCTest
+
+/// One accessibility audit per primary screen, plus a Dynamic Type sweep.
+///
+/// See `AccessibilityAuditHarness` for why these report rather than fail by
+/// default, and how to make them enforcing.
+@MainActor
+final class AccessibilityAuditTests: XCTestCase {
+ override func setUp() {
+ // Keep going after a failure so an enforced audit still collects and
+ // attaches every finding. With this off, XCTest aborts at the first
+ // reported issue and the burndown list is lost precisely when a category
+ // is being enforced.
+ continueAfterFailure = true
+ }
+
+ // MARK: Per-screen audits
+
+ func testInspectScreen() throws {
+ let app = AccessibilityAuditHarness.launch()
+ app.selectRootTab("Inspect")
+ try AccessibilityAuditHarness.audit(app, screen: "inspect", test: self)
+ }
+
+ func testDashboardScreen() throws {
+ let app = AccessibilityAuditHarness.launch()
+ app.selectRootTab("Dashboard")
+ try AccessibilityAuditHarness.audit(app, screen: "dashboard", test: self)
+ }
+
+ func testAuditScreen() throws {
+ let app = AccessibilityAuditHarness.launch()
+ app.selectRootTab("Audit")
+ try AccessibilityAuditHarness.audit(app, screen: "audit", test: self)
+ }
+
+ func testHistoryScreen() throws {
+ let app = AccessibilityAuditHarness.launch()
+ app.selectRootTab("History")
+ try AccessibilityAuditHarness.audit(app, screen: "history", test: self)
+ }
+
+ func testSettingsScreen() throws {
+ let app = AccessibilityAuditHarness.launch()
+ app.selectRootTab("Settings")
+ try AccessibilityAuditHarness.audit(app, screen: "settings", test: self)
+ }
+
+ func testTrackedDomainsScreen() throws {
+ let app = AccessibilityAuditHarness.launch()
+ app.selectRootTab("Settings")
+
+ let trackedDomains = app.buttons["Tracked Domains"]
+ XCTAssertTrue(
+ trackedDomains.waitForExistence(timeout: 5),
+ "Settings no longer offers a Tracked Domains row"
+ )
+ trackedDomains.tap()
+
+ try AccessibilityAuditHarness.audit(app, screen: "tracked-domains", test: self)
+ }
+
+ // MARK: Dynamic Type
+
+ /// Re-audits every root screen at the largest accessibility content size.
+ ///
+ /// This is where clipped text and fixed-height containers surface — the
+ /// `.accessibility5`-class failures that the fixed geometry in
+ /// `AppDensityMetrics` is expected to produce until phase 3 of #21 lands.
+ func testAllScreensAtLargestAccessibilitySize() throws {
+ let app = AccessibilityAuditHarness.launch(
+ contentSizeCategory: "UICTContentSizeCategoryAccessibilityXXXL"
+ )
+
+ for tab in ["Inspect", "Dashboard", "Audit", "History", "Settings"] {
+ app.selectRootTab(tab)
+ try AccessibilityAuditHarness.audit(
+ app,
+ screen: "\(tab.lowercased())-accessibilityXXXL",
+ test: self
+ )
+ }
+ }
+}