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 {
59 59
60 var body: some View { 60 var body: some View {
61 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { 61 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
62 HStack(alignment: .firstTextBaseline, spacing: 8) { 62 // Same reflow as WatchlistRowView: wide while it fits, stacked at
63 Text(result.domain) 63 // accessibility sizes so the badge cannot letter-wrap vertically.
64 .font(appDensity.font(.callout)) 64 ViewThatFits(in: .horizontal) {
65 .foregroundStyle(.primary) 65 HStack(alignment: .firstTextBaseline, spacing: 8) {
66 .lineLimit(1) 66 domainTitle
67 Spacer(minLength: 8) 67 Spacer(minLength: 8)
68 Text(result.resultSource.label.lowercased()) 68 sourceLabel
69 .font(appDensity.font(.caption2)) 69 AppStatusBadgeView(model: quickStatusBadge)
70 .foregroundStyle(Color(.appTextSecondary)) 70 }
71 AppStatusBadgeView(model: quickStatusBadge) 71 VStack(alignment: .leading, spacing: 6) {
72 domainTitle
73 HStack(spacing: 8) {
74 AppStatusBadgeView(model: quickStatusBadge)
75 sourceLabel
76 }
77 }
72 } 78 }
73 79
74 HStack(spacing: 10) { 80 HStack(spacing: 10) {
@@ -133,6 +139,21 @@ struct BatchResultRowView: View {
133 )) 139 ))
134 } 140 }
135 141
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
136 private var changeContentLabel: String { 157 private var changeContentLabel: String {
137 result.changeClassification != nil ? "Impact" : "Status" 158 result.changeClassification != nil ? "Impact" : "Status"
138 } 159 }
DomainDig/DashboardView.swift +22 −10
@@ -390,21 +390,33 @@ private struct PortfolioExpiryRow: View {
390 let state: PortfolioDomainStatus 390 let state: PortfolioDomainStatus
391 391
392 var body: some View { 392 var body: some View {
393 HStack { 393 // Wide while it fits; stacked at accessibility sizes so the badge does
394 VStack(alignment: .leading, spacing: 4) { 394 // not letter-wrap beside a long domain.
395 Text(state.trackedDomain.domain) 395 ViewThatFits(in: .horizontal) {
396 .font(appDensity.font(.callout)) 396 HStack {
397 .foregroundStyle(.primary) 397 expiryText
398 Text(expirySubtitle) 398 Spacer()
399 .font(appDensity.font(.caption)) 399 AppStatusBadgeView(model: badgeModel)
400 .foregroundStyle(Color(.appTextSecondary)) 400 }
401 VStack(alignment: .leading, spacing: 6) {
402 expiryText
403 AppStatusBadgeView(model: badgeModel)
401 } 404 }
402 Spacer()
403 AppStatusBadgeView(model: badgeModel)
404 } 405 }
405 .padding(.vertical, 4) 406 .padding(.vertical, 4)
406 } 407 }
407 408
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
408 private var expirySubtitle: String { 420 private var expirySubtitle: String {
409 if let days = state.certificateDaysRemaining { 421 if let days = state.certificateDaysRemaining {
410 return "Expires in \(days) day\(days == 1 ? "" : "s")" 422 return "Expires in \(days) day\(days == 1 ? "" : "s")"
DomainDig/DomainDigApp.swift +3
@@ -31,6 +31,9 @@ struct DomainDigApp: App {
31 // The single place appearance is applied. Keep it that way. 31 // The single place appearance is applied. Keep it that way.
32 .preferredColorScheme((AppAppearance(rawValue: appearance) ?? .system).colorScheme) 32 .preferredColorScheme((AppAppearance(rawValue: appearance) ?? .system).colorScheme)
33 .task { 33 .task {
34 #if DEBUG
35 viewModel.seedAuditFixturesIfRequested()
36 #endif
34 let _ = purchaseService.currentTier 37 let _ = purchaseService.currentTier
35 let _ = cloudSyncService.status 38 let _ = cloudSyncService.status
36 let _ = localAPIService.isRunning 39 let _ = localAPIService.isRunning
DomainDig/DomainDigUI.swift +5
@@ -245,6 +245,11 @@ struct AppStatusBadgeView: View {
245 } 245 }
246 Text(model.title) 246 Text(model.title)
247 } 247 }
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()
248 .font(appDensity.font(.caption, weight: .semibold)) 253 .font(appDensity.font(.caption, weight: .semibold))
249 .foregroundStyle(model.foregroundColor) 254 .foregroundStyle(model.foregroundColor)
250 .padding(.horizontal, 9) 255 .padding(.horizontal, 9)
DomainDig/DomainViewModel+Widget.swift +5
@@ -5,6 +5,11 @@ extension DomainViewModel {
5 /// Publishes the current portfolio state to the App Group container so the 5 /// Publishes the current portfolio state to the App Group container so the
6 /// widget can render it, then asks WidgetKit to refresh its timelines. 6 /// widget can render it, then asks WidgetKit to refresh its timelines.
7 func refreshWidgetData() { 7 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
8 let data = portfolioDashboardData 13 let data = portfolioDashboardData
9 let snapshot = data.snapshot 14 let snapshot = data.snapshot
10 15
DomainDig/DomainViewModel.swift +27
@@ -1019,6 +1019,11 @@ final class DomainViewModel {
1019 } 1019 }
1020 1020
1021 func refreshMonitoringState() { 1021 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
1022 DataMigrationService.migrateIfNeeded() 1027 DataMigrationService.migrateIfNeeded()
1023 trackedDomains = Self.loadTrackedDomains() 1028 trackedDomains = Self.loadTrackedDomains()
1024 history = Self.loadHistoryEntries() 1029 history = Self.loadHistoryEntries()
@@ -1036,6 +1041,10 @@ final class DomainViewModel {
1036 } 1041 }
1037 1042
1038 func refreshPersistedData() { 1043 func refreshPersistedData() {
1044 #if DEBUG
1045 // A reload from disk would silently replace the in-memory fixtures.
1046 if auditFixturesActive { return }
1047 #endif
1039 recentSearches = DomainDataPortabilityService.loadRecentSearches() 1048 recentSearches = DomainDataPortabilityService.loadRecentSearches()
1040 savedDomains = DomainDataPortabilityService.loadSavedDomains() 1049 savedDomains = DomainDataPortabilityService.loadSavedDomains()
1041 trackedDomains = Self.loadTrackedDomains() 1050 trackedDomains = Self.loadTrackedDomains()
@@ -2614,7 +2623,25 @@ final class DomainViewModel {
2614 persistHistory() 2623 persistHistory()
2615 } 2624 }
2616 2625
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
2617 private func persistTrackedDomains() { 2641 private func persistTrackedDomains() {
2642 #if DEBUG
2643 if auditFixturesActive { return }
2644 #endif
2618 if trackedDomainsPersistenceSuspended { 2645 if trackedDomainsPersistenceSuspended {
2619 trackedDomainsPersistenceDirty = true 2646 trackedDomainsPersistenceDirty = true
2620 return 2647 return
DomainDig/WatchlistView.swift +44 −21
@@ -435,19 +435,20 @@ struct WatchlistRowView: View {
435 435
436 var body: some View { 436 var body: some View {
437 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { 437 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
438 HStack(alignment: .firstTextBaseline, spacing: 8) { 438 // Side-by-side while it fits; at accessibility sizes the badge drops
439 if trackedDomain.isPinned { 439 // below the domain instead of squeezing it into "hea lt…" while the
440 Image(systemName: "pin.fill") 440 // badge letter-wraps down the screen. ViewThatFits picks the wide
441 .font(.caption2) 441 // layout whenever it genuinely fits, so default sizes keep density.
442 .foregroundStyle(Color(.statusWarning)) 442 ViewThatFits(in: .horizontal) {
443 } 443 HStack(alignment: .firstTextBaseline, spacing: 8) {
444 Text(trackedDomain.domain) 444 domainTitle
445 .font(appDensity.font(.callout)) 445 Spacer(minLength: 8)
446 .foregroundStyle(.primary) 446 statusBadge
447 .lineLimit(2) 447 }
448 .multilineTextAlignment(.leading) 448 VStack(alignment: .leading, spacing: 6) {
449 Spacer(minLength: 8) 449 domainTitle
450 statusBadge 450 statusBadge
451 }
451 } 452 }
452 453
453 Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))") 454 Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
@@ -460,14 +461,9 @@ struct WatchlistRowView: View {
460 .foregroundStyle(Color(.appTextSecondary)) 461 .foregroundStyle(Color(.appTextSecondary))
461 } 462 }
462 463
463 HStack(spacing: 8) { 464 ViewThatFits(in: .horizontal) {
464 Text(trackedDomain.monitoringEnabled ? "Monitoring on" : "Monitoring off") 465 HStack(spacing: 8) { monitoringMetadata }
465 if let lastMonitoredAt = trackedDomain.lastMonitoredAt { 466 VStack(alignment: .leading, spacing: 2) { monitoringMetadata }
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 }
471 } 467 }
472 .font(appDensity.font(.caption2)) 468 .font(appDensity.font(.caption2))
473 .foregroundStyle(Color(.appTextSecondary)) 469 .foregroundStyle(Color(.appTextSecondary))
@@ -491,6 +487,33 @@ struct WatchlistRowView: View {
491 .modifier(WatchlistRowAccessibility(trackedDomain: trackedDomain, isRefreshing: isRefreshing)) 487 .modifier(WatchlistRowAccessibility(trackedDomain: trackedDomain, isRefreshing: isRefreshing))
492 } 488 }
493 489
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
494 private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String { 517 private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
495 switch status { 518 switch status {
496 case .available: 519 case .available:
DomainDigUITests/AccessibilityAuditHarness.swift +11 −2
@@ -43,11 +43,20 @@ enum AccessibilityAuditHarness {
43 /// How many times to retry an audit that misses its internal deadline. 43 /// How many times to retry an audit that misses its internal deadline.
44 private static let auditAttempts = 3 44 private static let auditAttempts = 3
45 45
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
46 /// Launches the app with feature gating lifted, optionally at a specific 52 /// Launches the app with feature gating lifted, optionally at a specific
47 /// content size category. 53 /// content size category and with the audit fixtures seeded.
48 static func launch(contentSizeCategory: String? = nil) -> XCUIApplication { 54 static func launch(contentSizeCategory: String? = nil, seeded: Bool = false) -> XCUIApplication {
49 let app = XCUIApplication() 55 let app = XCUIApplication()
50 app.launchArguments = [forceProPlusArgument] 56 app.launchArguments = [forceProPlusArgument]
57 if seeded {
58 app.launchArguments.append(seedFixturesArgument)
59 }
51 if let contentSizeCategory { 60 if let contentSizeCategory {
52 app.launchArguments += ["-UIPreferredContentSizeCategoryName", contentSizeCategory] 61 app.launchArguments += ["-UIPreferredContentSizeCategoryName", contentSizeCategory]
53 } 62 }
DomainDigUITests/AccessibilityAuditTests.swift +65
@@ -82,6 +82,71 @@ final class AccessibilityAuditTests: XCTestCase {
82 ) 82 )
83 } 83 }
84 84
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
85 // MARK: Helpers 150 // MARK: Helpers
86 151
87 private func auditRootTab(_ tab: String) throws { 152 private func auditRootTab(_ tab: String) throws {