Commit 7f917e98b9
Unsigned
Layout: unified · split
DomainDig/AuditFixtures.swift added +168
| @@ -0,0 +1,168 @@ | ||
| 1 | #if DEBUG | |
| 2 | import 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. | |
| 20 | enum 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 | 60 | var body: some View { |
| 61 | 61 | 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 | } | |
| 72 | 78 | } |
| 73 | 79 | |
| 74 | 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 | 157 | private var changeContentLabel: String { |
| 137 | 158 | result.changeClassification != nil ? "Impact" : "Status" |
| 138 | 159 | } |
DomainDig/DashboardView.swift +22 −10
| @@ -390,21 +390,33 @@ private struct PortfolioExpiryRow: View { | ||
| 390 | 390 | let state: PortfolioDomainStatus |
| 391 | 391 | |
| 392 | 392 | 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) | |
| 401 | 404 | } |
| 402 | Spacer() | |
| 403 | AppStatusBadgeView(model: badgeModel) | |
| 404 | 405 | } |
| 405 | 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 | 420 | private var expirySubtitle: String { |
| 409 | 421 | if let days = state.certificateDaysRemaining { |
| 410 | 422 | return "Expires in \(days) day\(days == 1 ? "" : "s")" |
DomainDig/DomainDigApp.swift +3
| @@ -31,6 +31,9 @@ struct DomainDigApp: App { | ||
| 31 | 31 | // The single place appearance is applied. Keep it that way. |
| 32 | 32 | .preferredColorScheme((AppAppearance(rawValue: appearance) ?? .system).colorScheme) |
| 33 | 33 | .task { |
| 34 | #if DEBUG | |
| 35 | viewModel.seedAuditFixturesIfRequested() | |
| 36 | #endif | |
| 34 | 37 | let _ = purchaseService.currentTier |
| 35 | 38 | let _ = cloudSyncService.status |
| 36 | 39 | let _ = localAPIService.isRunning |
DomainDig/DomainDigUI.swift +5
| @@ -245,6 +245,11 @@ struct AppStatusBadgeView: View { | ||
| 245 | 245 | } |
| 246 | 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 | 253 | .font(appDensity.font(.caption, weight: .semibold)) |
| 249 | 254 | .foregroundStyle(model.foregroundColor) |
| 250 | 255 | .padding(.horizontal, 9) |
DomainDig/DomainViewModel+Widget.swift +5
| @@ -5,6 +5,11 @@ extension DomainViewModel { | ||
| 5 | 5 | /// Publishes the current portfolio state to the App Group container so the |
| 6 | 6 | /// widget can render it, then asks WidgetKit to refresh its timelines. |
| 7 | 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 | 13 | let data = portfolioDashboardData |
| 9 | 14 | let snapshot = data.snapshot |
| 10 | 15 | |
DomainDig/DomainViewModel.swift +27
| @@ -1019,6 +1019,11 @@ final class DomainViewModel { | ||
| 1019 | 1019 | } |
| 1020 | 1020 | |
| 1021 | 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 | 1027 | DataMigrationService.migrateIfNeeded() |
| 1023 | 1028 | trackedDomains = Self.loadTrackedDomains() |
| 1024 | 1029 | history = Self.loadHistoryEntries() |
| @@ -1036,6 +1041,10 @@ final class DomainViewModel { | ||
| 1036 | 1041 | } |
| 1037 | 1042 | |
| 1038 | 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 | 1048 | recentSearches = DomainDataPortabilityService.loadRecentSearches() |
| 1040 | 1049 | savedDomains = DomainDataPortabilityService.loadSavedDomains() |
| 1041 | 1050 | trackedDomains = Self.loadTrackedDomains() |
| @@ -2614,7 +2623,25 @@ final class DomainViewModel { | ||
| 2614 | 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 | 2641 | private func persistTrackedDomains() { |
| 2642 | #if DEBUG | |
| 2643 | if auditFixturesActive { return } | |
| 2644 | #endif | |
| 2618 | 2645 | if trackedDomainsPersistenceSuspended { |
| 2619 | 2646 | trackedDomainsPersistenceDirty = true |
| 2620 | 2647 | return |
DomainDig/WatchlistView.swift +44 −21
| @@ -435,19 +435,20 @@ struct WatchlistRowView: View { | ||
| 435 | 435 | |
| 436 | 436 | var body: some View { |
| 437 | 437 | 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 | } | |
| 451 | 452 | } |
| 452 | 453 | |
| 453 | 454 | Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))") |
| @@ -460,14 +461,9 @@ struct WatchlistRowView: View { | ||
| 460 | 461 | .foregroundStyle(Color(.appTextSecondary)) |
| 461 | 462 | } |
| 462 | 463 | |
| 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 } | |
| 471 | 467 | } |
| 472 | 468 | .font(appDensity.font(.caption2)) |
| 473 | 469 | .foregroundStyle(Color(.appTextSecondary)) |
| @@ -491,6 +487,33 @@ struct WatchlistRowView: View { | ||
| 491 | 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 | 517 | private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String { |
| 495 | 518 | switch status { |
| 496 | 519 | case .available: |
DomainDigUITests/AccessibilityAuditHarness.swift +11 −2
| @@ -43,11 +43,20 @@ enum AccessibilityAuditHarness { | ||
| 43 | 43 | /// How many times to retry an audit that misses its internal deadline. |
| 44 | 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 | 52 | /// 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 { | |
| 49 | 55 | let app = XCUIApplication() |
| 50 | 56 | app.launchArguments = [forceProPlusArgument] |
| 57 | if seeded { | |
| 58 | app.launchArguments.append(seedFixturesArgument) | |
| 59 | } | |
| 51 | 60 | if let contentSizeCategory { |
| 52 | 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 | 150 | // MARK: Helpers |
| 86 | 151 | |
| 87 | 152 | private func auditRootTab(_ tab: String) throws { |