krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.2: DomainDigUITests/AccessibilityMetadataTests.swift · raw
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}