Commit 7315ae3da6

7315ae3da604debfacfed9715a04dc91139a31e0

parent: 655d11b59a

Unsigned

cmc <hello@cleberg.net> · 2026-07-24 00:20 UTC

test(a11y): Phase 6 verification — metadata assertions, middle-band sweep, 27.0 fix

Executes the Phase-6 manual runbook against the simulator, converting the
mechanically-checkable parts into permanent coverage and reporting the rest
honestly by tier.

- Fix an enforced `.dynamicType` failure surfaced by `audit-a11y.sh current`
  on iOS 27.0: the Settings `Section("Services")` system header (app sets no
  font; 18.6 floor and 26.x CI are clean). Narrow, proven `noiseReason`
  carve-out scoped to dynamicType on the exact Settings header titles. Delta:
  current FAIL -> SUCCEEDED, finding still prints as `[noise: …]`.
- AccessibilityMetadataTests: assert the icon-only control labels and the dense
  Watchlist/Batch row label+value contracts (green on 18.6 and 27.0). These
  were one-time manual VoiceOver checks; now they gate.
- Middle-band Dynamic Type sweep at AccessibilityL across the seeded screens.
  Found no band-exclusive third bug (recorded), retained as regression
  insurance for a band that historically shipped two.
- AccessibilityScreenshotTests: best-effort, non-gating capture utility used to
  produce the cross-runtime Light/Dark/AXXXL screenshots (simctl appearance
  does not propagate headlessly; driven through the in-app picker instead).
- Docs/ACCESSIBILITY_VERIFICATION_RESULTS.md: full pass/fail/not-executable
  matrix + 15 screenshots. Notable positive result: Differentiate Without Color
  IS verifiable via the global com.apple.Accessibility defaults domain.

Layout: unified · split

Docs/ACCESSIBILITY_VERIFICATION_RESULTS.md added +292
@@ -0,0 +1,292 @@
1# Accessibility Verification — Results (issue #21, Phase 6)
2
3Execution of `ACCESSIBILITY_VERIFICATION.md` against the iOS Simulator. The
4runbook was written for a human on a physical device; a meaningful fraction is
5beyond a simulator. Every item below is sorted into one of three tiers and
6treated accordingly:
7
8- **Tier 1 — executed here.** Appearance, Increase Contrast, Dynamic Type
9 (including a middle-band sweep), the audit suite, accessibility **metadata**
10 (labels/values/traits, now permanent XCUITest assertions), and the
11 Liquid-Glass-vs-classic cross-runtime comparison.
12- **Tier 2 — attempted, reported honestly.** Differentiate Without Color,
13 Reduce Motion, Reduce Transparency. One of the three turned out to be fully
14 toggleable and is now **verified**; the mechanism is documented for the rest.
15- **Tier 3 — requires a physical device.** VoiceOver speech, the rotor,
16 announcements, Voice Control, Screen Curtain. Not attempted, not faked.
17
18**"Verified" vs "verified by construction."** A ✅ **Pass** means the behaviour
19was *observed* (a screenshot, an assertion, or a finding-count delta). *Verified
20by construction* (⚙️) means the code compiles and the pattern is right but the
21runtime behaviour was not observed here — it is **not** counted as a pass.
22
23## Environment
24
25| Role | Simulator | Runtime | Design language |
26| --- | --- | --- | --- |
27| Floor | iPhone 16 | iOS 18.6 | Classic chrome |
28| Liquid Glass (mid) | iPhone 17 | iOS 26.5 | Liquid Glass |
29| Current | iPhone 17e | iOS 27.0 | Liquid Glass |
30
31Deployment target is 17.6; no 17.6/17.5 runtime is usable (the app cannot
32install below the floor), so 18.6 is the practical floor, per the runbook.
33
34Seed data via `DOMAIN_DIG_SEED_FIXTURES` + `DOMAIN_DIG_FORCE_PRO_PLUS` (DEBUG,
35in-memory, never persisted). **Note on tooling:** `simctl launch` with the seed
36argument did *not* populate the fixtures, and `simctl ui appearance` did not
37propagate to a headless-booted simulator. Both were worked around by driving
38everything through **XCUITest** (which seeds reliably) and switching appearance
39through the app's own Settings → Display picker. This is why the screenshots and
40metadata checks are committed as tests rather than shell scripts — see
41`DomainDigUITests/AccessibilityScreenshotTests.swift` and
42`AccessibilityMetadataTests.swift`.
43
44---
45
46## Headline outcomes
47
481. **A real enforced failure was found and fixed.** `Scripts/audit-a11y.sh
49 current` (iOS 27.0) failed an **enforced** `.dynamicType` finding on the
50 Settings `Section("Services")` header — a system-rendered header the app sets
51 no font on, present only on 27.0 (18.6 floor and the 26.x runtime CI uses are
52 clean). Resolved with a narrow, proven `noiseReason(for:)` carve-out. Delta:
53 `current` went **FAIL → SUCCEEDED**, the finding still printed as
54 `[noise: …]`. See §5 and the fix note below.
552. **The highest-value metadata checks are now permanent tests.** The dense-row
56 label/value contracts and the icon-only control labels are asserted
57 mechanically in `AccessibilityMetadataTests` (green on 18.6 and 27.0),
58 shrinking the manual runbook.
593. **Differentiate Without Color is fully verifiable in the simulator** via the
60 *global* `com.apple.Accessibility` defaults domain — the runbook and task
61 both assumed this might not be reachable. It is. Captured proof:
62 `lg-dashboard-light-differentiate.png`.
634. **The middle-band Dynamic Type sweep found no third bug** exclusive to that
64 band (negative result), but is retained as regression insurance for a band
65 that historically shipped two.
66
67---
68
69## 1. Baseline visual — Light, Dark, System `[P1][P2]`
70
71Evidence: `classic-dashboard-{light,dark}.png`, `lg-dashboard-{light,dark}.png`,
72`classic-batch-{light,dark}.png`, `lg-batch-{light,dark}.png`. Appearance driven
73through Settings → Display.
74
75| Item | Result | Notes |
76| --- | --- | --- |
77| System follows device | ⚙️ By construction | `simctl ui appearance` does not propagate headlessly; Light/Dark set via the in-app picker instead, which drives the same single `@AppStorage` path. |
78| Light / Dark overrides hold | ✅ Pass | Both captured on both runtimes; the picker override renders correctly. |
79| Accent blue everywhere, no cyan | ✅ Pass | Tab-bar selection, `•All`, links, selected quick-filter chip all blue. No cyan observed. |
80| Warning reads orange, not olive | ✅ Pass | Dashboard "Warning" tile and batch "Warning" badge are clearly orange in both schemes. |
81| Selected tile is blue-tinted, not lavender | ✅ Pass | "Total Domains" tile is a soft blue surface in light and dark. |
82| Prominent buttons: white label on blue fill | ⚙️ By construction | Run/Run Batch are disabled in the seeded state (no typed domain), so the enabled `.borderedProminent` fill was not captured; palette values are audited. |
83| Secondary text legible in Light | ✅ Pass | "QUICK FILTERS", timestamps, "No recent portfolio changes" read clearly on the light card (this is the `AppTextSecondary` fix). |
84| Badges pair icon + text + colour | ✅ Pass | Batch badges show lock/triangle/octagon/x + word + colour. |
85| Repeat on floor runtime | ✅ Pass | Classic-chrome (18.6) captures match; see cross-runtime §8. |
86
87## 2. Dynamic Type & reflow — up to Accessibility 5 `[P3]`
88
89Evidence: `*-dashboard-axxxl.png`, `*-batch-axxxl.png`, `*-watchlist-axxxl.png`
90(both runtimes), plus the automated `…AccessibilityXXXL` and new
91`…AccessibilityL` audit sweeps.
92
93| Item | Result | Notes |
94| --- | --- | --- |
95| Body text + tile numbers scale | ✅ Pass | Dashboard "4 / 2 / 1" and all labels scale at AXXXL. |
96| No clipped headings (wrap, not "…") | ✅ Pass | "Batch Results", "Total Domains" wrap onto multiple lines; audit reports no *named* `textClipped` on empty-state headings. |
97| No card needs horizontal scrolling | ✅ Pass | Cards reflow vertically at AXXXL; no hidden horizontal gesture. |
98| Dense rows readable at AX5 | ⚠️ Known-deferred | Watchlist/batch rows become very tall and wrap; readable, no horizontal badge overlap. This is the deferred `ViewThatFits` case, **not** a P3 regression (see `lg-watchlist-axxxl.png`). |
99| Tap targets ≥ 44×44 | ⚙️ By construction | `AppLayout.minimumTapTarget` floor is enforced in code and the audit reports no named `hitRegion` findings; not separately measured here. |
100| Bold Text | ❌ Not executed | Not exposed by `simctl`; same class as the Tier-2 settings. Requires device or Settings-app automation. |
101| Widget clamps at AX1 | ❌ Not executed | Widgets do not render in the audit simulator or these captures — Tier 3-adjacent (needs Home Screen). |
102| Repeat spot-checks on floor | ✅ Pass | Classic AXXXL captures match. |
103
104**Middle-band sweep (added).** `testSeededScreensAtIntermediateAccessibilitySize`
105audits the seeded screens at `AccessibilityL`. Result: the `textClipped` findings
106it surfaced on the Risk badges (`Risk 12 Low`, `Risk 41 Medium`) are **also
107present at the default size** and absent at XXXL, so they are pre-existing
108reportOnly seeded-row findings, **not** a middle-band-exclusive third bug. No new
109gap found. The sweep is retained as regression insurance for a band that
110historically shipped two escaped bugs.
111
112## 3. VoiceOver `[P4]` — Tier 3, requires a physical device
113
114iOS VoiceOver **speech** does not run in the Simulator; macOS VoiceOver reading
115the simulator window is not equivalent and is not accepted as evidence. What the
116simulator *can* assert is the underlying **metadata**, which is now covered by
117`AccessibilityMetadataTests` (green on 18.6 and 27.0):
118
119| Runbook item | Metadata coverage | Result |
120| --- | --- | --- |
121| §3a Dashboard refresh → "Refresh all tracked domains" | asserted | ✅ Pass |
122| §3a Watchlist add → "Add domain"; filter → "Filter and sort" | asserted | ✅ Pass |
123| §3a Workflows create → "Create workflow" | asserted | ✅ Pass |
124| §3a History filter → "Filter" | ⚙️ By construction | Menu is gated behind non-empty history; not seedable. Label exists at `HistoryView.swift:109`. |
125| §3a Inspect clear/actions/export, Timeline grouping, Workflow export/re-run/shared | ⚙️ By construction | Reachable only after a live lookup / on populated workflow runs; labels verified in source (see `ACCESSIBILITY.md` map). |
126| §3d Watchlist row: label = domain, value = availability | asserted | ✅ Pass (`healthy.example`→"Registered"; long domain→"Unknown") |
127| §3d Batch row: label = domain, value = "status, availability" | asserted | ✅ Pass (`broken.example`→"Critical, Registered"; `unreachable.example`→"Failed, Unknown") |
128| §3c Badge reads as one word | asserted (folded into row value) | ✅ Pass |
129| §3b Save/Pin selected-state; §3b audit checklist; picker selected | ⚙️ By construction | All live behind a completed live inspection, seeded audits, or a multi-step gated flow — non-deterministic in CI. Traits verified in source (`ContentView.swift:565–567, 1372–1374`; `AuditViews.swift:264–265`; `WorkflowsView.swift:647`). |
130| §3e speech style, §3f announcements, §3g widget speech, §3h Screen Curtain | ❌ Requires device | VoiceOver speech / rotor / announcements — Tier 3. |
131
132**Rotor / custom-content ordering:** not observable from XCUITest at all —
133`.accessibilityCustomContent` does not surface as a queryable element property.
134Verified by construction (the `WatchlistRowAccessibility` /
135`BatchRowAccessibility` modifiers) and deferred to the device pass.
136
137## 4. Voice Control (WCAG 2.5.3) `[P4]` — Tier 3, requires a physical device
138
139Voice Control does not run in the Simulator. Label-in-name is partially
140*inferable* — every asserted `accessibilityLabel` in §3 preserves the control's
141visible text — but "say the printed word and it activates" must be confirmed on
142hardware.
143
144| Item | Result |
145| --- | --- |
146| "Tap Run / Track / Note / Compare / Cancel / Save" by printed word | ❌ Requires device |
147| "Show numbers" overlays on icon-only controls | ❌ Requires device |
148| No control reachable only by a differing name | ⚙️ By construction (labels preserve visible text) |
149
150## 5. Colour & contrast settings `[P1][P2][P5]`
151
152### 5a. Increase Contrast — Tier 1
153
154`simctl ui <udid> increase_contrast enabled` works. The palette carries HC
155variants; the audit's `.contrast` category stays report-only by design (see
156`ACCESSIBILITY.md`).
157
158| Item | Result | Notes |
159| --- | --- | --- |
160| Status/accent shift to HC variants, nothing unreadable | ⚙️ By construction | HC toggles via `simctl`, but the effect is a colour-value swap not reliably distinguishable in a downscaled screenshot; palette HC variants are defined and audited. |
161| Marginal Settings headers clear | ⚙️ By construction | The documented light 21→18 contrast measurement; unchanged this pass. |
162
163### 5b. Differentiate Without Color — **Tier 2, VERIFIED** ✅
164
165The runbook and task both flagged this as possibly un-toggleable in a simulator.
166It **is** toggleable: `simctl ui` does not expose it, and a `defaults write` to
167the app's *own* (sandboxed) container does not reach it — but a write to the
168**global** `com.apple.Accessibility` domain does, and XCUITest-launched apps read
169it via `UIAccessibility`:
170
171```sh
172xcrun simctl spawn <udid> defaults write com.apple.Accessibility DifferentiateWithoutColor -bool true
173```
174
175Captured proof — `lg-dashboard-light-differentiate.png` vs `lg-dashboard-light.png`:
176
177| Item | Result | Observed |
178| --- | --- | --- |
179| Summary tiles gain per-filter symbols | ✅ Pass | Dot → grid (All), checkmark (Healthy), triangle (Warning), octagon (Critical), refresh (Changed), wifi-slash (Unreachable). |
180| Selected quick-filter chip gains checkmark + border | ✅ Pass | "All" chip shows ✓ and a border; selection no longer fill-colour only. |
181| Inspect data-row warning/failure symbol | ⚙️ By construction | `LabeledValueRow` is behind a live lookup; the Dashboard payoff above exercises the same `accessibilityDifferentiateWithoutColor` path. |
182| Turning it off removes the extras | ✅ Pass | The default set of screenshots (setting off) shows plain dots / no chip checkmark. |
183| Widget uses symbols regardless | ⚙️ By construction | Widget does not render in these captures. |
184
185### 5c. Smart Invert — ❌ Not executed
186
187Not exposed by `simctl`; not in the global-domain set that worked for DWC.
188Requires the Settings app / device.
189
190## 6. Motion & transparency `[P5]` — Tier 2
191
192Both settings **can be written** to the global `com.apple.Accessibility` domain
193(`ReduceMotionEnabled`, `ReduceTransparencyEnabled`) — the same mechanism proven
194to reach the app for DWC. But their *effects* are not screenshot-capturable:
195
196| Item | Result | Notes |
197| --- | --- | --- |
198| §6a Reduce Motion: copy-check swap, section expand, timeline scroll, list reorder become instant | ⚙️ By construction | Effect is animation *timing*; a still frame cannot show "instant vs animated". Toggle mechanism confirmed; five sites guarded via `accessibilityReduceMotion` in code. |
199| §6b Reduce Transparency: Data Management toast is opaque | ⚙️ By construction | The only translucency swap is a **transient** toast behind a multi-step clear; not captured. `accessibilityReduceTransparency` swap verified in source. |
200| §6b iOS 26+ system chrome still reads acceptably | ✅ Pass | Liquid Glass nav/tab bars legible in all captures (app cannot declare that translucency itself). |
201
202**Net:** the Tier-2 toggle method (global accessibility defaults + XCUITest
203launch) is now known to work — DWC is fully verified with it. Motion and
204Transparency remain verified-by-construction because their effects are timing /
205transient, but the manual device pass for them is now optional rather than
206blocked: the same `defaults write` unblocks a scripted check with a screen
207recording.
208
209## 7. iPad — Full Keyboard Access & split layout `[verification]`
210
211| Item | Result |
212| --- | --- |
213| Tab focus order, focus ring, sidebar/detail reachability, tab→detail update | ❌ Requires device | Full Keyboard Access is not exposed by `simctl`; keyboard-focus traversal is a hardware/Settings behaviour. The `NavigationSplitView` layout itself renders (regular width) but focus order was not exercised. |
214
215## 8. Cross-runtime sign-off `[cross-runtime]`
216
217Captured the same seeded screens on **classic chrome (18.6)** and **Liquid Glass
218(26.5)** in Light, Dark, and AXXXL. The audit was run on **18.6 and 27.0**.
219
220- The semantic palette resolves correctly on both design languages: warning
221 orange, critical red, positive green, blue accent, blue-tinted selected tile.
222 No Liquid-Glass-only palette regression observed.
223- The only structural difference is expected: Liquid Glass renders translucent,
224 rounded nav/tab chrome; classic renders flatter, opaque chrome. Legibility
225 holds in both.
226- **Audit coverage is genuinely not nested:** 18.6 passed clean; 27.0 surfaced
227 the extra `Section` header `.dynamicType` finding that 18.6/26.x do not (now
228 carved out). This confirms the repo's rationale for a per-runtime local run.
229
230---
231
232## Filled sign-off
233
234| Pass | 26+/27 (Liquid Glass) | Floor (classic 18.6) | Notes |
235| --- | --- | --- | --- |
236| 1 Baseline visual | ✅ | ✅ | Light/Dark captured both; System by-construction |
237| 2 Dynamic Type / reflow | ✅ | ✅ | AXXXL + middle-band L; dense rows known-deferred |
238| 3 VoiceOver | metadata ✅ / speech ❌ device | metadata ✅ | Speech/rotor Tier 3 |
239| 4 Voice Control | ❌ device | ❌ device | Labels preserve visible text (by construction) |
240| 5 Colour & contrast | 5b DWC ✅ / 5a,5c ⚙️/❌ | 5b DWC ✅ | DWC verified via global defaults |
241| 6 Motion & transparency | ⚙️ | ⚙️ | Effects not screenshot-capturable |
242| 7 iPad keyboard | n/a | ❌ device | FKA not in simulator |
243
244## Tier 3 — the residual physical-device pass
245
246The human pass now shrinks to exactly these, all requiring hardware:
247
248- **VoiceOver:** §3a controls only reachable after a live lookup (Inspect
249 clear/actions/export, Timeline grouping, Workflow export/re-run/shared);
250 §3b Save/Pin/audit-checklist/picker selected-state *spoken*; §3c one-word
251 badge *spoken*; §3d More Content rotor order; §3e technical-string speech;
252 §3f completion announcements; §3g widget speech; §3h Screen Curtain journey.
253- **Voice Control:** §4 in full.
254- **Bold Text** (§2), **Smart Invert** (§5c), and the **iPad Full Keyboard
255 Access** pass (§7) — Settings toggles not exposed to `simctl` and not in the
256 global accessibility domain.
257- **Widget** at AX sizes and under VoiceOver (§2, §3g) — needs the Home Screen.
258- **Reduce Motion / Reduce Transparency** *effect* confirmation (§6) — optional;
259 the toggle is now scriptable, but observing instant-animation / opaque-toast
260 needs a screen recording.
261
262## Changes made this pass
263
264| Change | File | Evidence |
265| --- | --- | --- |
266| Fixed enforced 27.0 Settings `.dynamicType` failure | `DomainDigUITests/AccessibilityAuditHarness.swift` | `audit-a11y.sh current` FAIL → SUCCEEDED; finding prints as `[noise: iOS-rendered Settings section header …]` |
267| New metadata assertions (icon labels, dense-row label/value) | `DomainDigUITests/AccessibilityMetadataTests.swift` | 4 tests green on 18.6 + 27.0 |
268| Middle-band Dynamic Type sweep (`AccessibilityL`) | `DomainDigUITests/AccessibilityAuditTests.swift` | Passes; negative result recorded above |
269| Screenshot-capture utility (best-effort, non-gating) | `DomainDigUITests/AccessibilityScreenshotTests.swift` | 15 screenshots in `Docs/a11y-screenshots/` |
270
271### On the suppression (not a ratchet weakening)
272
273The Settings finding is on a plain `Section("Services")` (`ContentView.swift`
274~2768) whose font the app never sets — the scaling is UIKit's system header. It
275appears **only** on iOS 27.0 (the 18.6 floor and the 26.x runtime CI runs are
276clean; `ACCESSIBILITY.md` already records this asymmetry as "dynamicType finding
27718.6 missed"). The carve-out is scoped to `.dynamicType` on the exact Settings
278section-header titles, so a real regression on app-controlled text still
279enforces — matching the existing "system field placeholder" and system-header
280contrast carve-outs. The proof lives inline in `noiseReason(for:)`.
281
282## Screenshot index (`Docs/a11y-screenshots/`)
283
284| File | Runtime | Screen | Config |
285| --- | --- | --- | --- |
286| `classic-dashboard-light.png` / `-dark.png` | 18.6 | Dashboard | Light / Dark |
287| `classic-batch-light.png` / `-dark.png` | 18.6 | Batch results | Light / Dark |
288| `classic-{dashboard,watchlist,batch}-axxxl.png` | 18.6 | — | AccessibilityXXXL |
289| `lg-dashboard-light.png` / `-dark.png` | 26.5 | Dashboard | Light / Dark |
290| `lg-batch-light.png` / `-dark.png` | 26.5 | Batch results | Light / Dark |
291| `lg-{dashboard,watchlist,batch}-axxxl.png` | 26.5 | — | AccessibilityXXXL |
292| `lg-dashboard-light-differentiate.png` | 26.5 | Dashboard | Light + Differentiate Without Color |
Docs/a11y-screenshots/classic-batch-axxxl.png added

Binary file not shown.

Docs/a11y-screenshots/classic-batch-dark.png added

Binary file not shown.

Docs/a11y-screenshots/classic-batch-light.png added

Binary file not shown.

Docs/a11y-screenshots/classic-dashboard-axxxl.png added

Binary file not shown.

Docs/a11y-screenshots/classic-dashboard-dark.png added

Binary file not shown.

Docs/a11y-screenshots/classic-dashboard-light.png added

Binary file not shown.

Docs/a11y-screenshots/classic-watchlist-axxxl.png added

Binary file not shown.

Docs/a11y-screenshots/lg-batch-axxxl.png added

Binary file not shown.

Docs/a11y-screenshots/lg-batch-dark.png added

Binary file not shown.

Docs/a11y-screenshots/lg-batch-light.png added

Binary file not shown.

Docs/a11y-screenshots/lg-dashboard-axxxl.png added

Binary file not shown.

Docs/a11y-screenshots/lg-dashboard-dark.png added

Binary file not shown.

Docs/a11y-screenshots/lg-dashboard-light-differentiate.png added

Binary file not shown.

Docs/a11y-screenshots/lg-dashboard-light.png added

Binary file not shown.

Docs/a11y-screenshots/lg-watchlist-axxxl.png added

Binary file not shown.

DomainDigUITests/AccessibilityAuditHarness.swift +32
@@ -201,9 +201,41 @@ enum AccessibilityAuditHarness {
201201 return "unattributed, audit artifact on ignored/link content"
202202 }
203203
204 // iOS-27-only Settings `Section` header dynamicType finding. On iOS 27.0
205 // (and only there) the audit reports "font sizes partially unsupported"
206 // against a Settings section header — the same system-rendered headers
207 // already carved out for the contrast near-miss above. Proof it is a
208 // system-chrome artifact, not an app defect:
209 // • Each is a plain `Section("Services")` etc. (ContentView.swift ~2768);
210 // the app sets no font, so the scaling is UIKit's `.footnote` header.
211 // • Version-specific: absent on the iOS 18.6 floor and on the 26.x
212 // runtime CI runs (both audit clean); it surfaces only under 27.0.
213 // ACCESSIBILITY.md's coverage table records the same asymmetry
214 // ("dynamicType finding 18.6 missed").
215 // • Attribution is unstable run-to-run across the header set
216 // (Tier/Preferences/Services), exactly like the documented contrast
217 // flip — so it lands on whichever header the traversal reaches first.
218 // Overriding every Section header with a custom scaling `Text` to chase
219 // this was rejected for the contrast case (ACCESSIBILITY.md) for trading
220 // platform convention for nothing; the same holds here. Scoped to
221 // dynamicType on the exact Settings header titles so a real regression
222 // on app-controlled text still enforces.
223 if issue.auditType.contains(.dynamicType),
224 let label = issue.element?.label,
225 settingsSectionHeaders.contains(label) {
226 return "iOS-rendered Settings section header, app sets no font (27.0-only)"
227 }
228
204229 return nil
205230 }
206231
232 /// The Settings screen's `Section(_:)` header titles. UIKit renders these;
233 /// the app passes only a string literal. Used to scope the section-header
234 /// dynamicType carve-out narrowly (see `noiseReason`).
235 private static let settingsSectionHeaders: Set<String> = [
236 "Tier", "Preferences", "Services", "Data", "About"
237 ]
238
207239 /// `XCUIAccessibilityAuditType` is an option set whose description is just a
208240 /// raw bitmask, which makes the burndown list unreadable. Resolve it against
209241 /// the named members rather than hard-coding bit positions, so this keeps
DomainDigUITests/AccessibilityAuditTests.swift +42
@@ -112,6 +112,48 @@ final class AccessibilityAuditTests: XCTestCase {
112112 try XCTSkipUnless(audited, "Audit did not complete in time for seeded batch results")
113113 }
114114
115 /// The seeded screens at an **intermediate** accessibility size.
116 ///
117 /// The default/`AccessibilityXXXL` pair brackets the range but skips the
118 /// middle band, and two production layout bugs lived exactly there — a
119 /// bordered button letter-wrapping vertically at a merely-large size, which
120 /// neither endpoint reproduced. `AccessibilityL` samples that band across the
121 /// dense seeded screens so a regression in it cannot slip between the two
122 /// existing test points. Reports rather than gates, matching the other
123 /// seeded audits.
124 func testSeededScreensAtIntermediateAccessibilitySize() throws {
125 let app = AccessibilityAuditHarness.launch(
126 contentSizeCategory: "UICTContentSizeCategoryAccessibilityL",
127 seeded: true
128 )
129
130 var unaudited: [String] = []
131
132 app.selectRootTab("Dashboard")
133 if try !AccessibilityAuditHarness.audit(app, screen: "seeded-dashboard-accessibilityL", test: self, reportOnly: true) {
134 unaudited.append("Dashboard")
135 }
136
137 app.selectRootTab("Inspect")
138 if try !AccessibilityAuditHarness.audit(app, screen: "seeded-batch-accessibilityL", test: self, reportOnly: true) {
139 unaudited.append("Inspect batch")
140 }
141
142 app.selectRootTab("Settings")
143 let trackedDomains = app.buttons["Tracked Domains"]
144 if trackedDomains.waitForExistence(timeout: 5) {
145 trackedDomains.tap()
146 if try !AccessibilityAuditHarness.audit(app, screen: "seeded-tracked-domains-accessibilityL", test: self, reportOnly: true) {
147 unaudited.append("Tracked Domains")
148 }
149 }
150
151 try XCTSkipUnless(
152 unaudited.isEmpty,
153 "Audit did not complete in time for: \(unaudited.joined(separator: ", "))"
154 )
155 }
156
115157 /// The seeded screens again at the largest accessibility size — the case the
116158 /// deferred ViewThatFits work exists for.
117159 func testSeededScreensAtLargestAccessibilitySize() throws {
DomainDigUITests/AccessibilityMetadataTests.swift added +160
@@ -0,0 +1,160 @@
1import XCTest
2
3/// Mechanical assertions for the accessibility **metadata** the manual runbook
4/// (issue #21, Phase 6) checks by hand: icon-only control labels, dense-row
5/// label/value pairs, and toggle selected-state.
6///
7/// `performAccessibilityAudit` (see `AccessibilityAuditTests`) validates
8/// contrast, hit-region, clipping, and trait *correctness*, but it does not
9/// assert that a specific control carries a specific spoken label — that a
10/// refresh button says "Refresh all tracked domains" rather than "arrow
11/// clockwise". Those strings were one-time manual VoiceOver checks; this file
12/// converts the ones reachable without a live network lookup into permanent
13/// regression coverage, so a relabel or a lost `.accessibilityValue` fails CI.
14///
15/// What is deliberately **not** here, and why:
16/// - Inspect toolbar Clear/Actions/Export, the bookmark (Save) toggle, and the
17/// Timeline grouping control only appear after a completed lookup, which needs
18/// the network — non-deterministic in CI. They stay in the manual pass.
19/// - VoiceOver speech, the More Content rotor, and custom-content ordering are
20/// not observable from XCUITest at all (the rotor is a VoiceOver feature, not
21/// an element property). `.accessibilityCustomContent` does not surface as a
22/// queryable value here, so the row assertions cover label + value only.
23@MainActor
24final class AccessibilityMetadataTests: XCTestCase {
25 override func setUp() {
26 continueAfterFailure = true
27 }
28
29 // MARK: Icon-only control labels (runbook §3a)
30
31 /// Every icon-only control reachable from the seeded launch state must
32 /// announce a purpose, never a raw SF Symbol name.
33 func testIconOnlyControlLabels() {
34 let app = AccessibilityAuditHarness.launch(seeded: true)
35
36 // Dashboard refresh.
37 app.selectRootTab("Dashboard")
38 XCTAssertTrue(
39 app.buttons["Refresh all tracked domains"].waitForExistence(timeout: 5),
40 "Dashboard refresh lost its 'Refresh all tracked domains' label"
41 )
42
43 // Watchlist (Tracked Domains) add + filter.
44 openTrackedDomains(app)
45 XCTAssertTrue(
46 app.buttons["Add domain"].waitForExistence(timeout: 5),
47 "Watchlist add-domain lost its 'Add domain' label"
48 )
49 XCTAssertTrue(
50 app.buttons["Filter and sort"].exists,
51 "Watchlist filter lost its 'Filter and sort' label"
52 )
53
54 // History's "Filter" menu (HistoryView.swift:109) is gated behind a
55 // non-empty history, which the seed fixtures do not populate, so it is
56 // not reachable here — it stays a verified-by-construction item in the
57 // results matrix rather than a flaky assertion.
58 }
59
60 /// Workflows is Pro-gated; the seed harness forces Pro so its create button
61 /// is reachable.
62 func testWorkflowsCreateLabel() {
63 let app = AccessibilityAuditHarness.launch(seeded: true)
64 app.selectRootTab("Settings")
65 let workflows = app.buttons["Workflows"]
66 XCTAssertTrue(workflows.waitForExistence(timeout: 5), "Settings no longer offers Workflows")
67 workflows.tap()
68 XCTAssertTrue(
69 app.buttons["Create workflow"].waitForExistence(timeout: 5),
70 "Workflows create lost its 'Create workflow' label"
71 )
72 }
73
74 // MARK: Dense rows — label is the domain, value is the status (runbook §3d)
75
76 /// The watchlist's dense rows collapse to a single VoiceOver element whose
77 /// label is the domain and whose value is availability. The badge title is
78 /// folded into that value (children: .ignore), which is the §3c "one word"
79 /// contract.
80 func testWatchlistRowLabelAndValue() {
81 let app = AccessibilityAuditHarness.launch(seeded: true)
82 openTrackedDomains(app)
83
84 assertElement(in: app, label: "healthy.example", value: "Registered")
85 // The stress-length fixture with no known availability.
86 assertElement(
87 in: app,
88 label: "very-long-subdomain.observability.internal.staging.example",
89 value: "Unknown"
90 )
91 }
92
93 /// Batch result rows: domain as label, "<status>, <availability>" as value —
94 /// including the failed lookup, whose badge reads "Failed".
95 func testBatchRowLabelAndValue() {
96 let app = AccessibilityAuditHarness.launch(seeded: true)
97 app.selectRootTab("Inspect")
98
99 assertElement(in: app, label: "broken.example", value: "Critical, Registered")
100 assertElement(in: app, label: "unreachable.example", value: "Failed, Unknown")
101 }
102
103 // Toggle selected-state (runbook §3b) is intentionally not asserted here.
104 // The bookmark ("Save domain") and Pin ("Pin domain") toggles both live in
105 // the Inspect result's Domain section, reachable only after a completed live
106 // lookup — non-deterministic in CI. The watchlist's own pin is a swipe/menu
107 // action that carries no `.isSelected` trait, and the Audit checklist and
108 // picker rows need seeded audits / a multi-step gated flow the fixtures do
109 // not provide. These remain in the manual pass; the results matrix records
110 // each as verified-by-construction with its source line.
111
112 // MARK: Helpers
113
114 private func openTrackedDomains(_ app: XCUIApplication) {
115 app.selectRootTab("Settings")
116 let trackedDomains = app.buttons["Tracked Domains"]
117 XCTAssertTrue(trackedDomains.waitForExistence(timeout: 5), "Settings no longer offers Tracked Domains")
118 trackedDomains.tap()
119 }
120
121 /// A `children: .ignore` row can surface as a button, cell, or other-element
122 /// depending on its container; match on label across the likely types.
123 private func firstElement(in app: XCUIApplication, label: String) -> XCUIElement? {
124 let predicate = NSPredicate(format: "label == %@", label)
125 for query in [app.buttons, app.cells, app.otherElements, app.staticTexts] {
126 let match = query.matching(predicate).firstMatch
127 if match.exists { return match }
128 }
129 return nil
130 }
131
132 private func assertElement(
133 in app: XCUIApplication,
134 label: String,
135 value: String,
136 file: StaticString = #filePath,
137 line: UInt = #line
138 ) {
139 // Wait for the row to appear at all.
140 let predicate = NSPredicate(format: "label == %@", label)
141 let anyMatch = app.descendants(matching: .any).matching(predicate).firstMatch
142 XCTAssertTrue(
143 anyMatch.waitForExistence(timeout: 8),
144 "No accessibility element labelled \(label)",
145 file: file,
146 line: line
147 )
148 guard let element = firstElement(in: app, label: label) else {
149 XCTFail("Element \(label) exists but not as a queryable button/cell/other", file: file, line: line)
150 return
151 }
152 XCTAssertEqual(
153 element.value as? String,
154 value,
155 "Element \(label) reported value \(String(describing: element.value)); expected \(value)",
156 file: file,
157 line: line
158 )
159 }
160}
DomainDigUITests/AccessibilityScreenshotTests.swift added +105
@@ -0,0 +1,105 @@
1import XCTest
2
3/// Captures the Phase-6 visual-pass evidence as result-bundle attachments:
4/// the seeded dense screens in Light and Dark, and again at an accessibility
5/// text size. Driven from XCUITest (not `simctl`) for two reasons proven during
6/// this pass: the seed fixtures only populate under the test harness's launch,
7/// and `xcrun simctl ui appearance` does not propagate to a headless-booted
8/// simulator — so appearance is switched through the app's own Settings →
9/// Display picker, exercising the real code path.
10///
11/// **This is a capture utility, not a pass/fail test.** Every step is
12/// best-effort and never asserts: a control it cannot reach on a given runtime
13/// simply yields no screenshot, so adding it to the audit suite can never gate
14/// CI. Run with an explicit `-resultBundlePath` and export the attachments.
15@MainActor
16final class AccessibilityScreenshotTests: XCTestCase {
17 override func setUp() {
18 continueAfterFailure = true
19 }
20
21 /// Dashboard, Watchlist, and batch results in Light then Dark.
22 func testLightAndDarkScreens() {
23 let app = AccessibilityAuditHarness.launch(seeded: true)
24
25 for appearance in ["Light", "Dark"] {
26 guard setAppearance(app, to: appearance) else { continue }
27 captureSeededScreens(app, suffix: appearance.lowercased())
28 }
29 }
30
31 /// The same seeded screens at the largest accessibility text size, where
32 /// reflow and clipping surface. Launched fresh with the content-size arg.
33 func testAccessibilityTextSizeScreens() {
34 let app = AccessibilityAuditHarness.launch(
35 contentSizeCategory: "UICTContentSizeCategoryAccessibilityXXXL",
36 seeded: true
37 )
38 captureSeededScreens(app, suffix: "axxxl")
39 }
40
41 // MARK: Capture (best-effort)
42
43 private func captureSeededScreens(_ app: XCUIApplication, suffix: String) {
44 app.selectRootTab("Dashboard")
45 _ = app.buttons["Refresh all tracked domains"].waitForExistence(timeout: 5)
46 attach(app, name: "dashboard-\(suffix)")
47
48 app.selectRootTab("Settings")
49 let trackedDomains = app.buttons["Tracked Domains"]
50 if trackedDomains.waitForExistence(timeout: 5) {
51 trackedDomains.tap()
52 _ = element(app, labelled: "healthy.example").waitForExistence(timeout: 5)
53 attach(app, name: "watchlist-\(suffix)")
54 }
55
56 app.selectRootTab("Inspect")
57 _ = element(app, labelled: "broken.example").waitForExistence(timeout: 5)
58 attach(app, name: "batch-\(suffix)")
59 }
60
61 private func element(_ app: XCUIApplication, labelled label: String) -> XCUIElement {
62 app.descendants(matching: .any)
63 .matching(NSPredicate(format: "label == %@", label))
64 .firstMatch
65 }
66
67 private func attach(_ app: XCUIApplication, name: String) {
68 let attachment = XCTAttachment(screenshot: app.screenshot())
69 attachment.name = name
70 attachment.lifetime = .keepAlways
71 add(attachment)
72 }
73
74 // MARK: Appearance (best-effort; returns whether it switched)
75
76 /// Drives Settings → Display → Appearance to the given option. The `Picker`
77 /// renders as an inline `.menu` whose trigger is labelled
78 /// "Appearance, <current value>". Returns `false` (rather than failing) if
79 /// any step is unreachable on this runtime.
80 private func setAppearance(_ app: XCUIApplication, to option: String) -> Bool {
81 // A prior capture may have left the Settings tab on a pushed view
82 // (Tracked Domains). Re-selecting the active tab pops it back to root.
83 app.selectRootTab("Settings")
84 let display = app.buttons["Display"]
85 if !display.waitForExistence(timeout: 2) {
86 app.selectRootTab("Settings")
87 }
88 guard display.waitForExistence(timeout: 5) else { return false }
89 display.tap()
90
91 let trigger = app.buttons
92 .matching(NSPredicate(format: "label BEGINSWITH %@", "Appearance"))
93 .firstMatch
94 guard trigger.waitForExistence(timeout: 5) else { return false }
95 trigger.tap()
96
97 // The popped menu exposes System / Light / Dark as buttons (or menu
98 // items on some runtimes).
99 let asButton = app.buttons[option]
100 let choice = asButton.waitForExistence(timeout: 3) ? asButton : app.menuItems[option]
101 guard choice.waitForExistence(timeout: 3) else { return false }
102 choice.tap()
103 return true
104 }
105}