krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v5.0.1: 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}