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 | 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 { |