Commit 7f917e98b9

7f917e98b929dcf0f7901d0bc4eb05e04db3aa0c

parent: 80cb5d8553

Unsigned

cmc <hello@cleberg.net> · 2026-07-22 05:47 UTC

feat(a11y): seeded audit fixtures; fix dense-row reflow they exposed (#21)

The dense rows and portfolio sections never rendered in the audit — the
test simulator has no tracked domains or batch results — so five phases
of row treatment shipped unmeasured. Driving the add-domain UI was tried
earlier and rejected (keyboard contamination, persistent state), so this
adds DOMAIN_DIG_SEED_FIXTURES: DEBUG-only launch argument, same pattern
as DOMAIN_DIG_FORCE_PRO_PLUS, seeding four tracked domains and four
batch results chosen to exercise every badge path, including a failed
lookup and a stress-length domain name.

Fixtures are strictly in-memory. persistTrackedDomains, refreshWidgetData
(App Group file), refreshPersistedData, and refreshMonitoringState are
all guarded while fixtures are active — the last one mattered: it runs
right after seeding in the app task and was reloading the empty disk
over the fixtures, which initially made the seeded watchlist audit pass
by silently auditing the empty state.

Four new audit tests cover the seeded Dashboard, Tracked Domains, and
batch results at default and AccessibilityXXXL.

What they found was real. At XXXL the watchlist row rendered the domain
as "hea lt…" while the Registered badge wrapped one character per line
into a screen-height capsule. Fixes, verified by before/after
screenshots and the XXXL audits dropping to 7-8 findings per screen:

- AppStatusBadgeView gets .fixedSize() — a capsule badge must never
  letter-wrap; taking natural width instead forces the row layout to its
  stacked alternative.
- WatchlistRowView, BatchResultRowView, and PortfolioExpiryRow headers
  use ViewThatFits: domain-beside-badge while it genuinely fits, badge
  below the domain at accessibility sizes. Domain titles get
  fixedSize(horizontal: false, vertical: true) so they wrap rather than
  report a single-line ideal width to ViewThatFits and truncate.
- The watchlist monitoring metadata strip (three texts abreast) stacks
  vertically when it no longer fits instead of wrapping mid-word.

Known and deliberate: the seeded default-size audits still report a
contrast/dynamicType wave attributed to "unknown element". Bisecting the
row and badge accessibility modifiers showed most of it is an audit
artifact on children-ignored content (the same rows measure 6-7:1 and
render correctly); the artifact classes get characterised suppressions
when enforcement lands, not blanket ones.

Layout: unified · split

DomainDig/AuditFixtures.swift added +168
@@ -0,0 +1,168 @@
1#if DEBUG
2import Foundation
3
4/// Deterministic in-memory fixtures for the accessibility audit suite.
5///
6/// The dense rows (`WatchlistRowView`, `BatchResultRowView`) and the Dashboard
7/// portfolio sections never render on a fresh simulator, so five phases of row
8/// treatment shipped unverified by the automated audit. Driving the add-domain
9/// UI instead was tried and rejected: typing raises the keyboard, which then
10/// follows the audit onto later screens, and the added domains persist across
11/// runs, contaminating every other test's baseline.
12///
13/// These are activated by the `DOMAIN_DIG_SEED_FIXTURES` launch argument
14/// (DEBUG builds only, same pattern as `DOMAIN_DIG_FORCE_PRO_PLUS`) and are
15/// **never persisted** — see `seedAuditFixturesIfRequested()`.
16///
17/// The set is chosen to exercise every row path: healthy/warning/critical
18/// certificate badges, pinned, noted, changed, monitoring on/off, a
19/// stress-length domain name, and every batch status including failure.
20enum AuditFixtures {
21 static let launchArgument = "DOMAIN_DIG_SEED_FIXTURES"
22
23 static var requested: Bool {
24 ProcessInfo.processInfo.arguments.contains(launchArgument)
25 }
26
27 /// Relative to launch so the recency-gated Dashboard sections (Recent
28 /// Activity, Attention Required — both keyed to the last 24h) actually
29 /// render. Label text varies run to run ("2 hr. ago"); the audit measures
30 /// layout and traits, not string equality, so coverage wins.
31 private static let now = Date()
32
33 static var trackedDomains: [TrackedDomain] {
34 [
35 TrackedDomain(
36 domain: "healthy.example",
37 createdAt: now.addingTimeInterval(-86_400 * 30),
38 updatedAt: now.addingTimeInterval(-3_600),
39 isPinned: true,
40 monitoringEnabled: true,
41 lastKnownAvailability: .registered,
42 certificateWarningLevel: .none,
43 certificateDaysRemaining: 240,
44 lastMonitoredAt: now.addingTimeInterval(-1_800)
45 ),
46 TrackedDomain(
47 domain: "expiring.example",
48 createdAt: now.addingTimeInterval(-86_400 * 90),
49 updatedAt: now.addingTimeInterval(-7_200),
50 monitoringEnabled: true,
51 lastKnownAvailability: .registered,
52 lastChangeSummary: DomainChangeSummary(
53 hasChanges: true,
54 changedSections: ["ssl"],
55 message: "Certificate is approaching expiry",
56 severity: .medium,
57 impactClassification: .warning,
58 generatedAt: now.addingTimeInterval(-7_200)
59 ),
60 lastChangeSeverity: .medium,
61 certificateWarningLevel: .warning,
62 certificateDaysRemaining: 12,
63 lastMonitoredAt: now.addingTimeInterval(-7_200),
64 lastAlertAt: now.addingTimeInterval(-7_000)
65 ),
66 TrackedDomain(
67 domain: "broken.example",
68 createdAt: now.addingTimeInterval(-86_400 * 7),
69 updatedAt: now.addingTimeInterval(-600),
70 note: "Production incident follow-up: certificate replaced?",
71 monitoringEnabled: false,
72 lastKnownAvailability: .registered,
73 lastChangeSummary: DomainChangeSummary(
74 hasChanges: true,
75 changedSections: ["ssl", "dns"],
76 message: "TLS validation failed and NS records changed",
77 severity: .high,
78 impactClassification: .critical,
79 generatedAt: now.addingTimeInterval(-600)
80 ),
81 lastChangeSeverity: .high,
82 certificateWarningLevel: .critical,
83 certificateDaysRemaining: -3
84 ),
85 TrackedDomain(
86 domain: "very-long-subdomain.observability.internal.staging.example",
87 createdAt: now.addingTimeInterval(-86_400),
88 updatedAt: now.addingTimeInterval(-60),
89 monitoringEnabled: true,
90 lastKnownAvailability: .unknown
91 )
92 ]
93 }
94
95 static var batchResults: [BatchLookupResult] {
96 [
97 BatchLookupResult(
98 domain: "healthy.example",
99 historyEntryID: nil,
100 resultSource: .live,
101 availability: .registered,
102 primaryIP: "203.0.113.10",
103 quickStatus: "Stable",
104 summaryMessage: nil,
105 changeSeverity: nil,
106 changeClassification: nil,
107 certificateWarningLevel: .none,
108 riskScore: 12,
109 riskLevel: .low,
110 timestamp: now.addingTimeInterval(-120),
111 status: .completed,
112 errorMessage: nil
113 ),
114 BatchLookupResult(
115 domain: "expiring.example",
116 historyEntryID: nil,
117 resultSource: .cached,
118 availability: .registered,
119 primaryIP: "203.0.113.11",
120 quickStatus: "Changed",
121 summaryMessage: "Certificate is approaching expiry",
122 changeSeverity: .medium,
123 changeClassification: .warning,
124 certificateWarningLevel: .warning,
125 riskScore: 41,
126 riskLevel: .medium,
127 timestamp: now.addingTimeInterval(-3_600),
128 status: .completed,
129 errorMessage: nil
130 ),
131 BatchLookupResult(
132 domain: "broken.example",
133 historyEntryID: nil,
134 resultSource: .live,
135 availability: .registered,
136 primaryIP: "2001:db8::1f3:44",
137 quickStatus: "Changed",
138 summaryMessage: "TLS validation failed",
139 changeSeverity: .high,
140 changeClassification: .critical,
141 certificateWarningLevel: .critical,
142 riskScore: 78,
143 riskLevel: .high,
144 timestamp: now.addingTimeInterval(-60),
145 status: .completed,
146 errorMessage: nil
147 ),
148 BatchLookupResult(
149 domain: "unreachable.example",
150 historyEntryID: nil,
151 resultSource: .live,
152 availability: nil,
153 primaryIP: nil,
154 quickStatus: "Failed",
155 summaryMessage: nil,
156 changeSeverity: nil,
157 changeClassification: nil,
158 certificateWarningLevel: .none,
159 riskScore: nil,
160 riskLevel: nil,
161 timestamp: now,
162 status: .failed,
163 errorMessage: "The lookup timed out before any records were returned"
164 )
165 ]
166 }
167}
168#endif
DomainDig/BatchResultsView.swift +31 −10
@@ -59,16 +59,22 @@ struct BatchResultRowView: View {
5959
6060 var body: some View {
6161 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
62 HStack(alignment: .firstTextBaseline, spacing: 8) {
63 Text(result.domain)
64 .font(appDensity.font(.callout))
65 .foregroundStyle(.primary)
66 .lineLimit(1)
67 Spacer(minLength: 8)
68 Text(result.resultSource.label.lowercased())
69 .font(appDensity.font(.caption2))
70 .foregroundStyle(Color(.appTextSecondary))
71 AppStatusBadgeView(model: quickStatusBadge)
62 // Same reflow as WatchlistRowView: wide while it fits, stacked at
63 // accessibility sizes so the badge cannot letter-wrap vertically.
64 ViewThatFits(in: .horizontal) {
65 HStack(alignment: .firstTextBaseline, spacing: 8) {
66 domainTitle
67 Spacer(minLength: 8)
68 sourceLabel
69 AppStatusBadgeView(model: quickStatusBadge)
70 }
71 VStack(alignment: .leading, spacing: 6) {
72 domainTitle
73 HStack(spacing: 8) {
74 AppStatusBadgeView(model: quickStatusBadge)
75 sourceLabel
76 }
77 }
7278 }
7379
7480 HStack(spacing: 10) {
@@ -133,6 +139,21 @@ struct BatchResultRowView: View {
133139 ))
134140 }
135141
142 private var domainTitle: some View {
143 Text(result.domain)
144 .font(appDensity.font(.callout))
145 .foregroundStyle(.primary)
146 .lineLimit(3)
147 .multilineTextAlignment(.leading)
148 .fixedSize(horizontal: false, vertical: true)
149 }
150
151 private var sourceLabel: some View {
152 Text(result.resultSource.label.lowercased())
153 .font(appDensity.font(.caption2))
154 .foregroundStyle(Color(.appTextSecondary))
155 }
156
136157 private var changeContentLabel: String {
137158 result.changeClassification != nil ? "Impact" : "Status"
138159 }
DomainDig/DashboardView.swift +22 −10
@@ -390,21 +390,33 @@ private struct PortfolioExpiryRow: View {
390390 let state: PortfolioDomainStatus
391391
392392 var body: some View {
393 HStack {
394 VStack(alignment: .leading, spacing: 4) {
395 Text(state.trackedDomain.domain)
396 .font(appDensity.font(.callout))
397 .foregroundStyle(.primary)
398 Text(expirySubtitle)
399 .font(appDensity.font(.caption))
400 .foregroundStyle(Color(.appTextSecondary))
393 // Wide while it fits; stacked at accessibility sizes so the badge does
394 // not letter-wrap beside a long domain.
395 ViewThatFits(in: .horizontal) {
396 HStack {
397 expiryText
398 Spacer()
399 AppStatusBadgeView(model: badgeModel)
400 }
401 VStack(alignment: .leading, spacing: 6) {
402 expiryText
403 AppStatusBadgeView(model: badgeModel)
401404 }
402 Spacer()
403 AppStatusBadgeView(model: badgeModel)
404405 }
405406 .padding(.vertical, 4)
406407 }
407408
409 private var expiryText: some View {
410 VStack(alignment: .leading, spacing: 4) {
411 Text(state.trackedDomain.domain)
412 .font(appDensity.font(.callout))
413 .foregroundStyle(.primary)
414 Text(expirySubtitle)
415 .font(appDensity.font(.caption))
416 .foregroundStyle(Color(.appTextSecondary))
417 }
418 }
419
408420 private var expirySubtitle: String {
409421 if let days = state.certificateDaysRemaining {
410422 return "Expires in \(days) day\(days == 1 ? "" : "s")"
DomainDig/DomainDigApp.swift +3
@@ -31,6 +31,9 @@ struct DomainDigApp: App {
3131 // The single place appearance is applied. Keep it that way.
3232 .preferredColorScheme((AppAppearance(rawValue: appearance) ?? .system).colorScheme)
3333 .task {
34 #if DEBUG
35 viewModel.seedAuditFixturesIfRequested()
36 #endif
3437 let _ = purchaseService.currentTier
3538 let _ = cloudSyncService.status
3639 let _ = localAPIService.isRunning
DomainDig/DomainDigUI.swift +5
@@ -245,6 +245,11 @@ struct AppStatusBadgeView: View {
245245 }
246246 Text(model.title)
247247 }
248 // Never compress. Squeezed beside a long domain at accessibility sizes,
249 // the capsule otherwise wraps one character per line into a
250 // screen-height pill. Taking natural width instead forces the row's
251 // ViewThatFits onto its stacked layout, which is the intended fallback.
252 .fixedSize()
248253 .font(appDensity.font(.caption, weight: .semibold))
249254 .foregroundStyle(model.foregroundColor)
250255 .padding(.horizontal, 9)
DomainDig/DomainViewModel+Widget.swift +5
@@ -5,6 +5,11 @@ extension DomainViewModel {
55 /// Publishes the current portfolio state to the App Group container so the
66 /// widget can render it, then asks WidgetKit to refresh its timelines.
77 func refreshWidgetData() {
8 #if DEBUG
9 // Fixture sessions must not write fixture domains into the shared
10 // widget store — it is an App Group file that outlives the launch.
11 if auditFixturesActive { return }
12 #endif
813 let data = portfolioDashboardData
914 let snapshot = data.snapshot
1015
DomainDig/DomainViewModel.swift +27
@@ -1019,6 +1019,11 @@ final class DomainViewModel {
10191019 }
10201020
10211021 func refreshMonitoringState() {
1022 #if DEBUG
1023 // Runs right after fixture seeding in the app task (and again on every
1024 // scene activation); the disk reload below would wipe the fixtures.
1025 if auditFixturesActive { return }
1026 #endif
10221027 DataMigrationService.migrateIfNeeded()
10231028 trackedDomains = Self.loadTrackedDomains()
10241029 history = Self.loadHistoryEntries()
@@ -1036,6 +1041,10 @@ final class DomainViewModel {
10361041 }
10371042
10381043 func refreshPersistedData() {
1044 #if DEBUG
1045 // A reload from disk would silently replace the in-memory fixtures.
1046 if auditFixturesActive { return }
1047 #endif
10391048 recentSearches = DomainDataPortabilityService.loadRecentSearches()
10401049 savedDomains = DomainDataPortabilityService.loadSavedDomains()
10411050 trackedDomains = Self.loadTrackedDomains()
@@ -2614,7 +2623,25 @@ final class DomainViewModel {
26142623 persistHistory()
26152624 }
26162625
2626 #if DEBUG
2627 /// True when this session was launched with `DOMAIN_DIG_SEED_FIXTURES`.
2628 /// Blocks tracked-domain persistence, widget-store writes, and persisted-data
2629 /// reloads so fixture data stays strictly in-memory — the audit suite relies
2630 /// on every launch starting from the same state.
2631 private(set) var auditFixturesActive = false
2632
2633 func seedAuditFixturesIfRequested() {
2634 guard AuditFixtures.requested, !auditFixturesActive else { return }
2635 auditFixturesActive = true
2636 trackedDomains = AuditFixtures.trackedDomains
2637 batchResults = AuditFixtures.batchResults
2638 }
2639 #endif
2640
26172641 private func persistTrackedDomains() {
2642 #if DEBUG
2643 if auditFixturesActive { return }
2644 #endif
26182645 if trackedDomainsPersistenceSuspended {
26192646 trackedDomainsPersistenceDirty = true
26202647 return
DomainDig/WatchlistView.swift +44 −21
@@ -435,19 +435,20 @@ struct WatchlistRowView: View {
435435
436436 var body: some View {
437437 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
438 HStack(alignment: .firstTextBaseline, spacing: 8) {
439 if trackedDomain.isPinned {
440 Image(systemName: "pin.fill")
441 .font(.caption2)
442 .foregroundStyle(Color(.statusWarning))
443 }
444 Text(trackedDomain.domain)
445 .font(appDensity.font(.callout))
446 .foregroundStyle(.primary)
447 .lineLimit(2)
448 .multilineTextAlignment(.leading)
449 Spacer(minLength: 8)
450 statusBadge
438 // Side-by-side while it fits; at accessibility sizes the badge drops
439 // below the domain instead of squeezing it into "hea lt…" while the
440 // badge letter-wraps down the screen. ViewThatFits picks the wide
441 // layout whenever it genuinely fits, so default sizes keep density.
442 ViewThatFits(in: .horizontal) {
443 HStack(alignment: .firstTextBaseline, spacing: 8) {
444 domainTitle
445 Spacer(minLength: 8)
446 statusBadge
447 }
448 VStack(alignment: .leading, spacing: 6) {
449 domainTitle
450 statusBadge
451 }
451452 }
452453
453454 Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
@@ -460,14 +461,9 @@ struct WatchlistRowView: View {
460461 .foregroundStyle(Color(.appTextSecondary))
461462 }
462463
463 HStack(spacing: 8) {
464 Text(trackedDomain.monitoringEnabled ? "Monitoring on" : "Monitoring off")
465 if let lastMonitoredAt = trackedDomain.lastMonitoredAt {
466 Text("Checked \(lastMonitoredAt.formatted(date: .omitted, time: .shortened))")
467 }
468 if let lastAlertAt = trackedDomain.lastAlertAt {
469 Text("Alert \(lastAlertAt.formatted(date: .omitted, time: .shortened))")
470 }
464 ViewThatFits(in: .horizontal) {
465 HStack(spacing: 8) { monitoringMetadata }
466 VStack(alignment: .leading, spacing: 2) { monitoringMetadata }
471467 }
472468 .font(appDensity.font(.caption2))
473469 .foregroundStyle(Color(.appTextSecondary))
@@ -491,6 +487,33 @@ struct WatchlistRowView: View {
491487 .modifier(WatchlistRowAccessibility(trackedDomain: trackedDomain, isRefreshing: isRefreshing))
492488 }
493489
490 @ViewBuilder
491 private var monitoringMetadata: some View {
492 Text(trackedDomain.monitoringEnabled ? "Monitoring on" : "Monitoring off")
493 if let lastMonitoredAt = trackedDomain.lastMonitoredAt {
494 Text("Checked \(lastMonitoredAt.formatted(date: .omitted, time: .shortened))")
495 }
496 if let lastAlertAt = trackedDomain.lastAlertAt {
497 Text("Alert \(lastAlertAt.formatted(date: .omitted, time: .shortened))")
498 }
499 }
500
501 private var domainTitle: some View {
502 HStack(alignment: .firstTextBaseline, spacing: 8) {
503 if trackedDomain.isPinned {
504 Image(systemName: "pin.fill")
505 .font(.caption2)
506 .foregroundStyle(Color(.statusWarning))
507 }
508 Text(trackedDomain.domain)
509 .font(appDensity.font(.callout))
510 .foregroundStyle(.primary)
511 .lineLimit(3)
512 .multilineTextAlignment(.leading)
513 .fixedSize(horizontal: false, vertical: true)
514 }
515 }
516
494517 private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
495518 switch status {
496519 case .available:
DomainDigUITests/AccessibilityAuditHarness.swift +11 −2
@@ -43,11 +43,20 @@ enum AccessibilityAuditHarness {
4343 /// How many times to retry an audit that misses its internal deadline.
4444 private static let auditAttempts = 3
4545
46 /// Launch argument that seeds deterministic in-memory tracked domains and
47 /// batch results (DEBUG builds only; never persisted). Without it the dense
48 /// rows and portfolio sections render nothing, which is how five phases of
49 /// row treatment went unmeasured.
50 private static let seedFixturesArgument = "DOMAIN_DIG_SEED_FIXTURES"
51
4652 /// Launches the app with feature gating lifted, optionally at a specific
47 /// content size category.
48 static func launch(contentSizeCategory: String? = nil) -> XCUIApplication {
53 /// content size category and with the audit fixtures seeded.
54 static func launch(contentSizeCategory: String? = nil, seeded: Bool = false) -> XCUIApplication {
4955 let app = XCUIApplication()
5056 app.launchArguments = [forceProPlusArgument]
57 if seeded {
58 app.launchArguments.append(seedFixturesArgument)
59 }
5160 if let contentSizeCategory {
5261 app.launchArguments += ["-UIPreferredContentSizeCategoryName", contentSizeCategory]
5362 }
DomainDigUITests/AccessibilityAuditTests.swift +65
@@ -82,6 +82,71 @@ final class AccessibilityAuditTests: XCTestCase {
8282 )
8383 }
8484
85 // MARK: Seeded audits — dense rows that never render on an empty simulator
86
87 /// Dashboard with a populated portfolio: summary tiles, quick filters,
88 /// activity/attention/expiry rows, and the grouped portfolio list.
89 func testSeededDashboard() throws {
90 let app = AccessibilityAuditHarness.launch(seeded: true)
91 app.selectRootTab("Dashboard")
92 let audited = try AccessibilityAuditHarness.audit(app, screen: "seeded-dashboard", test: self)
93 try XCTSkipUnless(audited, "Audit did not complete in time for seeded Dashboard")
94 }
95
96 /// The watchlist's dense rows (up to nine text elements each).
97 func testSeededTrackedDomains() throws {
98 let app = AccessibilityAuditHarness.launch(seeded: true)
99 app.selectRootTab("Settings")
100 let trackedDomains = app.buttons["Tracked Domains"]
101 XCTAssertTrue(trackedDomains.waitForExistence(timeout: 5))
102 trackedDomains.tap()
103 let audited = try AccessibilityAuditHarness.audit(app, screen: "seeded-tracked-domains", test: self)
104 try XCTSkipUnless(audited, "Audit did not complete in time for seeded Tracked Domains")
105 }
106
107 /// Batch result rows on the Inspect tab, including a failed lookup.
108 func testSeededBatchResults() throws {
109 let app = AccessibilityAuditHarness.launch(seeded: true)
110 app.selectRootTab("Inspect")
111 let audited = try AccessibilityAuditHarness.audit(app, screen: "seeded-batch", test: self)
112 try XCTSkipUnless(audited, "Audit did not complete in time for seeded batch results")
113 }
114
115 /// The seeded screens again at the largest accessibility size — the case the
116 /// deferred ViewThatFits work exists for.
117 func testSeededScreensAtLargestAccessibilitySize() throws {
118 let app = AccessibilityAuditHarness.launch(
119 contentSizeCategory: "UICTContentSizeCategoryAccessibilityXXXL",
120 seeded: true
121 )
122
123 var unaudited: [String] = []
124
125 app.selectRootTab("Dashboard")
126 if try !AccessibilityAuditHarness.audit(app, screen: "seeded-dashboard-accessibilityXXXL", test: self) {
127 unaudited.append("Dashboard")
128 }
129
130 app.selectRootTab("Inspect")
131 if try !AccessibilityAuditHarness.audit(app, screen: "seeded-batch-accessibilityXXXL", test: self) {
132 unaudited.append("Inspect batch")
133 }
134
135 app.selectRootTab("Settings")
136 let trackedDomains = app.buttons["Tracked Domains"]
137 if trackedDomains.waitForExistence(timeout: 5) {
138 trackedDomains.tap()
139 if try !AccessibilityAuditHarness.audit(app, screen: "seeded-tracked-domains-accessibilityXXXL", test: self) {
140 unaudited.append("Tracked Domains")
141 }
142 }
143
144 try XCTSkipUnless(
145 unaudited.isEmpty,
146 "Audit did not complete in time for: \(unaudited.joined(separator: ", "))"
147 )
148 }
149
85150 // MARK: Helpers
86151
87152 private func auditRootTab(_ tab: String) throws {