diff options
| -rw-r--r-- | DomainDig/AuditFixtures.swift | 168 | ||||
| -rw-r--r-- | DomainDig/BatchResultsView.swift | 41 | ||||
| -rw-r--r-- | DomainDig/DashboardView.swift | 32 | ||||
| -rw-r--r-- | DomainDig/DomainDigApp.swift | 3 | ||||
| -rw-r--r-- | DomainDig/DomainDigUI.swift | 5 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel+Widget.swift | 5 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 27 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 65 | ||||
| -rw-r--r-- | DomainDigUITests/AccessibilityAuditHarness.swift | 13 | ||||
| -rw-r--r-- | DomainDigUITests/AccessibilityAuditTests.swift | 65 |
10 files changed, 381 insertions, 43 deletions
diff --git a/DomainDig/AuditFixtures.swift b/DomainDig/AuditFixtures.swift new file mode 100644 index 0000000..0a9655b --- /dev/null +++ b/DomainDig/AuditFixtures.swift @@ -0,0 +1,168 @@ +#if DEBUG +import Foundation + +/// Deterministic in-memory fixtures for the accessibility audit suite. +/// +/// The dense rows (`WatchlistRowView`, `BatchResultRowView`) and the Dashboard +/// portfolio sections never render on a fresh simulator, so five phases of row +/// treatment shipped unverified by the automated audit. Driving the add-domain +/// UI instead was tried and rejected: typing raises the keyboard, which then +/// follows the audit onto later screens, and the added domains persist across +/// runs, contaminating every other test's baseline. +/// +/// These are activated by the `DOMAIN_DIG_SEED_FIXTURES` launch argument +/// (DEBUG builds only, same pattern as `DOMAIN_DIG_FORCE_PRO_PLUS`) and are +/// **never persisted** — see `seedAuditFixturesIfRequested()`. +/// +/// The set is chosen to exercise every row path: healthy/warning/critical +/// certificate badges, pinned, noted, changed, monitoring on/off, a +/// stress-length domain name, and every batch status including failure. +enum AuditFixtures { + static let launchArgument = "DOMAIN_DIG_SEED_FIXTURES" + + static var requested: Bool { + ProcessInfo.processInfo.arguments.contains(launchArgument) + } + + /// Relative to launch so the recency-gated Dashboard sections (Recent + /// Activity, Attention Required — both keyed to the last 24h) actually + /// render. Label text varies run to run ("2 hr. ago"); the audit measures + /// layout and traits, not string equality, so coverage wins. + private static let now = Date() + + static var trackedDomains: [TrackedDomain] { + [ + TrackedDomain( + domain: "healthy.example", + createdAt: now.addingTimeInterval(-86_400 * 30), + updatedAt: now.addingTimeInterval(-3_600), + isPinned: true, + monitoringEnabled: true, + lastKnownAvailability: .registered, + certificateWarningLevel: .none, + certificateDaysRemaining: 240, + lastMonitoredAt: now.addingTimeInterval(-1_800) + ), + TrackedDomain( + domain: "expiring.example", + createdAt: now.addingTimeInterval(-86_400 * 90), + updatedAt: now.addingTimeInterval(-7_200), + monitoringEnabled: true, + lastKnownAvailability: .registered, + lastChangeSummary: DomainChangeSummary( + hasChanges: true, + changedSections: ["ssl"], + message: "Certificate is approaching expiry", + severity: .medium, + impactClassification: .warning, + generatedAt: now.addingTimeInterval(-7_200) + ), + lastChangeSeverity: .medium, + certificateWarningLevel: .warning, + certificateDaysRemaining: 12, + lastMonitoredAt: now.addingTimeInterval(-7_200), + lastAlertAt: now.addingTimeInterval(-7_000) + ), + TrackedDomain( + domain: "broken.example", + createdAt: now.addingTimeInterval(-86_400 * 7), + updatedAt: now.addingTimeInterval(-600), + note: "Production incident follow-up: certificate replaced?", + monitoringEnabled: false, + lastKnownAvailability: .registered, + lastChangeSummary: DomainChangeSummary( + hasChanges: true, + changedSections: ["ssl", "dns"], + message: "TLS validation failed and NS records changed", + severity: .high, + impactClassification: .critical, + generatedAt: now.addingTimeInterval(-600) + ), + lastChangeSeverity: .high, + certificateWarningLevel: .critical, + certificateDaysRemaining: -3 + ), + TrackedDomain( + domain: "very-long-subdomain.observability.internal.staging.example", + createdAt: now.addingTimeInterval(-86_400), + updatedAt: now.addingTimeInterval(-60), + monitoringEnabled: true, + lastKnownAvailability: .unknown + ) + ] + } + + static var batchResults: [BatchLookupResult] { + [ + BatchLookupResult( + domain: "healthy.example", + historyEntryID: nil, + resultSource: .live, + availability: .registered, + primaryIP: "203.0.113.10", + quickStatus: "Stable", + summaryMessage: nil, + changeSeverity: nil, + changeClassification: nil, + certificateWarningLevel: .none, + riskScore: 12, + riskLevel: .low, + timestamp: now.addingTimeInterval(-120), + status: .completed, + errorMessage: nil + ), + BatchLookupResult( + domain: "expiring.example", + historyEntryID: nil, + resultSource: .cached, + availability: .registered, + primaryIP: "203.0.113.11", + quickStatus: "Changed", + summaryMessage: "Certificate is approaching expiry", + changeSeverity: .medium, + changeClassification: .warning, + certificateWarningLevel: .warning, + riskScore: 41, + riskLevel: .medium, + timestamp: now.addingTimeInterval(-3_600), + status: .completed, + errorMessage: nil + ), + BatchLookupResult( + domain: "broken.example", + historyEntryID: nil, + resultSource: .live, + availability: .registered, + primaryIP: "2001:db8::1f3:44", + quickStatus: "Changed", + summaryMessage: "TLS validation failed", + changeSeverity: .high, + changeClassification: .critical, + certificateWarningLevel: .critical, + riskScore: 78, + riskLevel: .high, + timestamp: now.addingTimeInterval(-60), + status: .completed, + errorMessage: nil + ), + BatchLookupResult( + domain: "unreachable.example", + historyEntryID: nil, + resultSource: .live, + availability: nil, + primaryIP: nil, + quickStatus: "Failed", + summaryMessage: nil, + changeSeverity: nil, + changeClassification: nil, + certificateWarningLevel: .none, + riskScore: nil, + riskLevel: nil, + timestamp: now, + status: .failed, + errorMessage: "The lookup timed out before any records were returned" + ) + ] + } +} +#endif diff --git a/DomainDig/BatchResultsView.swift b/DomainDig/BatchResultsView.swift index aeae48f..1636a16 100644 --- a/DomainDig/BatchResultsView.swift +++ b/DomainDig/BatchResultsView.swift @@ -59,16 +59,22 @@ struct BatchResultRowView: View { var body: some View { VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(result.domain) - .font(appDensity.font(.callout)) - .foregroundStyle(.primary) - .lineLimit(1) - Spacer(minLength: 8) - Text(result.resultSource.label.lowercased()) - .font(appDensity.font(.caption2)) - .foregroundStyle(Color(.appTextSecondary)) - AppStatusBadgeView(model: quickStatusBadge) + // Same reflow as WatchlistRowView: wide while it fits, stacked at + // accessibility sizes so the badge cannot letter-wrap vertically. + ViewThatFits(in: .horizontal) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + domainTitle + Spacer(minLength: 8) + sourceLabel + AppStatusBadgeView(model: quickStatusBadge) + } + VStack(alignment: .leading, spacing: 6) { + domainTitle + HStack(spacing: 8) { + AppStatusBadgeView(model: quickStatusBadge) + sourceLabel + } + } } HStack(spacing: 10) { @@ -133,6 +139,21 @@ struct BatchResultRowView: View { )) } + private var domainTitle: some View { + Text(result.domain) + .font(appDensity.font(.callout)) + .foregroundStyle(.primary) + .lineLimit(3) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + + private var sourceLabel: some View { + Text(result.resultSource.label.lowercased()) + .font(appDensity.font(.caption2)) + .foregroundStyle(Color(.appTextSecondary)) + } + private var changeContentLabel: String { result.changeClassification != nil ? "Impact" : "Status" } diff --git a/DomainDig/DashboardView.swift b/DomainDig/DashboardView.swift index d86e591..3111d75 100644 --- a/DomainDig/DashboardView.swift +++ b/DomainDig/DashboardView.swift @@ -390,21 +390,33 @@ private struct PortfolioExpiryRow: View { let state: PortfolioDomainStatus var body: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(state.trackedDomain.domain) - .font(appDensity.font(.callout)) - .foregroundStyle(.primary) - Text(expirySubtitle) - .font(appDensity.font(.caption)) - .foregroundStyle(Color(.appTextSecondary)) + // Wide while it fits; stacked at accessibility sizes so the badge does + // not letter-wrap beside a long domain. + ViewThatFits(in: .horizontal) { + HStack { + expiryText + Spacer() + AppStatusBadgeView(model: badgeModel) + } + VStack(alignment: .leading, spacing: 6) { + expiryText + AppStatusBadgeView(model: badgeModel) } - Spacer() - AppStatusBadgeView(model: badgeModel) } .padding(.vertical, 4) } + private var expiryText: some View { + VStack(alignment: .leading, spacing: 4) { + Text(state.trackedDomain.domain) + .font(appDensity.font(.callout)) + .foregroundStyle(.primary) + Text(expirySubtitle) + .font(appDensity.font(.caption)) + .foregroundStyle(Color(.appTextSecondary)) + } + } + private var expirySubtitle: String { if let days = state.certificateDaysRemaining { return "Expires in \(days) day\(days == 1 ? "" : "s")" diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift index d392dde..914dbc8 100644 --- a/DomainDig/DomainDigApp.swift +++ b/DomainDig/DomainDigApp.swift @@ -31,6 +31,9 @@ struct DomainDigApp: App { // The single place appearance is applied. Keep it that way. .preferredColorScheme((AppAppearance(rawValue: appearance) ?? .system).colorScheme) .task { + #if DEBUG + viewModel.seedAuditFixturesIfRequested() + #endif let _ = purchaseService.currentTier let _ = cloudSyncService.status let _ = localAPIService.isRunning diff --git a/DomainDig/DomainDigUI.swift b/DomainDig/DomainDigUI.swift index a50f766..67c30b4 100644 --- a/DomainDig/DomainDigUI.swift +++ b/DomainDig/DomainDigUI.swift @@ -245,6 +245,11 @@ struct AppStatusBadgeView: View { } Text(model.title) } + // Never compress. Squeezed beside a long domain at accessibility sizes, + // the capsule otherwise wraps one character per line into a + // screen-height pill. Taking natural width instead forces the row's + // ViewThatFits onto its stacked layout, which is the intended fallback. + .fixedSize() .font(appDensity.font(.caption, weight: .semibold)) .foregroundStyle(model.foregroundColor) .padding(.horizontal, 9) diff --git a/DomainDig/DomainViewModel+Widget.swift b/DomainDig/DomainViewModel+Widget.swift index 59b28e0..69640d8 100644 --- a/DomainDig/DomainViewModel+Widget.swift +++ b/DomainDig/DomainViewModel+Widget.swift @@ -5,6 +5,11 @@ extension DomainViewModel { /// Publishes the current portfolio state to the App Group container so the /// widget can render it, then asks WidgetKit to refresh its timelines. func refreshWidgetData() { + #if DEBUG + // Fixture sessions must not write fixture domains into the shared + // widget store — it is an App Group file that outlives the launch. + if auditFixturesActive { return } + #endif let data = portfolioDashboardData let snapshot = data.snapshot diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index de17cbc..b5f51c3 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -1019,6 +1019,11 @@ final class DomainViewModel { } func refreshMonitoringState() { + #if DEBUG + // Runs right after fixture seeding in the app task (and again on every + // scene activation); the disk reload below would wipe the fixtures. + if auditFixturesActive { return } + #endif DataMigrationService.migrateIfNeeded() trackedDomains = Self.loadTrackedDomains() history = Self.loadHistoryEntries() @@ -1036,6 +1041,10 @@ final class DomainViewModel { } func refreshPersistedData() { + #if DEBUG + // A reload from disk would silently replace the in-memory fixtures. + if auditFixturesActive { return } + #endif recentSearches = DomainDataPortabilityService.loadRecentSearches() savedDomains = DomainDataPortabilityService.loadSavedDomains() trackedDomains = Self.loadTrackedDomains() @@ -2614,7 +2623,25 @@ final class DomainViewModel { persistHistory() } + #if DEBUG + /// True when this session was launched with `DOMAIN_DIG_SEED_FIXTURES`. + /// Blocks tracked-domain persistence, widget-store writes, and persisted-data + /// reloads so fixture data stays strictly in-memory — the audit suite relies + /// on every launch starting from the same state. + private(set) var auditFixturesActive = false + + func seedAuditFixturesIfRequested() { + guard AuditFixtures.requested, !auditFixturesActive else { return } + auditFixturesActive = true + trackedDomains = AuditFixtures.trackedDomains + batchResults = AuditFixtures.batchResults + } + #endif + private func persistTrackedDomains() { + #if DEBUG + if auditFixturesActive { return } + #endif if trackedDomainsPersistenceSuspended { trackedDomainsPersistenceDirty = true return diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index db79b0b..eb934e6 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -435,19 +435,20 @@ struct WatchlistRowView: View { var body: some View { VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - if trackedDomain.isPinned { - Image(systemName: "pin.fill") - .font(.caption2) - .foregroundStyle(Color(.statusWarning)) - } - Text(trackedDomain.domain) - .font(appDensity.font(.callout)) - .foregroundStyle(.primary) - .lineLimit(2) - .multilineTextAlignment(.leading) - Spacer(minLength: 8) - statusBadge + // Side-by-side while it fits; at accessibility sizes the badge drops + // below the domain instead of squeezing it into "hea lt…" while the + // badge letter-wraps down the screen. ViewThatFits picks the wide + // layout whenever it genuinely fits, so default sizes keep density. + ViewThatFits(in: .horizontal) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + domainTitle + Spacer(minLength: 8) + statusBadge + } + VStack(alignment: .leading, spacing: 6) { + domainTitle + statusBadge + } } Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))") @@ -460,14 +461,9 @@ struct WatchlistRowView: View { .foregroundStyle(Color(.appTextSecondary)) } - HStack(spacing: 8) { - Text(trackedDomain.monitoringEnabled ? "Monitoring on" : "Monitoring off") - if let lastMonitoredAt = trackedDomain.lastMonitoredAt { - Text("Checked \(lastMonitoredAt.formatted(date: .omitted, time: .shortened))") - } - if let lastAlertAt = trackedDomain.lastAlertAt { - Text("Alert \(lastAlertAt.formatted(date: .omitted, time: .shortened))") - } + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { monitoringMetadata } + VStack(alignment: .leading, spacing: 2) { monitoringMetadata } } .font(appDensity.font(.caption2)) .foregroundStyle(Color(.appTextSecondary)) @@ -491,6 +487,33 @@ struct WatchlistRowView: View { .modifier(WatchlistRowAccessibility(trackedDomain: trackedDomain, isRefreshing: isRefreshing)) } + @ViewBuilder + private var monitoringMetadata: some View { + Text(trackedDomain.monitoringEnabled ? "Monitoring on" : "Monitoring off") + if let lastMonitoredAt = trackedDomain.lastMonitoredAt { + Text("Checked \(lastMonitoredAt.formatted(date: .omitted, time: .shortened))") + } + if let lastAlertAt = trackedDomain.lastAlertAt { + Text("Alert \(lastAlertAt.formatted(date: .omitted, time: .shortened))") + } + } + + private var domainTitle: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + if trackedDomain.isPinned { + Image(systemName: "pin.fill") + .font(.caption2) + .foregroundStyle(Color(.statusWarning)) + } + Text(trackedDomain.domain) + .font(appDensity.font(.callout)) + .foregroundStyle(.primary) + .lineLimit(3) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + } + private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String { switch status { case .available: diff --git a/DomainDigUITests/AccessibilityAuditHarness.swift b/DomainDigUITests/AccessibilityAuditHarness.swift index 08ddedd..e025dcd 100644 --- a/DomainDigUITests/AccessibilityAuditHarness.swift +++ b/DomainDigUITests/AccessibilityAuditHarness.swift @@ -43,11 +43,20 @@ enum AccessibilityAuditHarness { /// How many times to retry an audit that misses its internal deadline. private static let auditAttempts = 3 + /// Launch argument that seeds deterministic in-memory tracked domains and + /// batch results (DEBUG builds only; never persisted). Without it the dense + /// rows and portfolio sections render nothing, which is how five phases of + /// row treatment went unmeasured. + private static let seedFixturesArgument = "DOMAIN_DIG_SEED_FIXTURES" + /// Launches the app with feature gating lifted, optionally at a specific - /// content size category. - static func launch(contentSizeCategory: String? = nil) -> XCUIApplication { + /// content size category and with the audit fixtures seeded. + static func launch(contentSizeCategory: String? = nil, seeded: Bool = false) -> XCUIApplication { let app = XCUIApplication() app.launchArguments = [forceProPlusArgument] + if seeded { + app.launchArguments.append(seedFixturesArgument) + } if let contentSizeCategory { app.launchArguments += ["-UIPreferredContentSizeCategoryName", contentSizeCategory] } diff --git a/DomainDigUITests/AccessibilityAuditTests.swift b/DomainDigUITests/AccessibilityAuditTests.swift index 6a90bac..0863d32 100644 --- a/DomainDigUITests/AccessibilityAuditTests.swift +++ b/DomainDigUITests/AccessibilityAuditTests.swift @@ -82,6 +82,71 @@ final class AccessibilityAuditTests: XCTestCase { ) } + // MARK: Seeded audits — dense rows that never render on an empty simulator + + /// Dashboard with a populated portfolio: summary tiles, quick filters, + /// activity/attention/expiry rows, and the grouped portfolio list. + func testSeededDashboard() throws { + let app = AccessibilityAuditHarness.launch(seeded: true) + app.selectRootTab("Dashboard") + let audited = try AccessibilityAuditHarness.audit(app, screen: "seeded-dashboard", test: self) + try XCTSkipUnless(audited, "Audit did not complete in time for seeded Dashboard") + } + + /// The watchlist's dense rows (up to nine text elements each). + func testSeededTrackedDomains() throws { + let app = AccessibilityAuditHarness.launch(seeded: true) + app.selectRootTab("Settings") + let trackedDomains = app.buttons["Tracked Domains"] + XCTAssertTrue(trackedDomains.waitForExistence(timeout: 5)) + trackedDomains.tap() + let audited = try AccessibilityAuditHarness.audit(app, screen: "seeded-tracked-domains", test: self) + try XCTSkipUnless(audited, "Audit did not complete in time for seeded Tracked Domains") + } + + /// Batch result rows on the Inspect tab, including a failed lookup. + func testSeededBatchResults() throws { + let app = AccessibilityAuditHarness.launch(seeded: true) + app.selectRootTab("Inspect") + let audited = try AccessibilityAuditHarness.audit(app, screen: "seeded-batch", test: self) + try XCTSkipUnless(audited, "Audit did not complete in time for seeded batch results") + } + + /// The seeded screens again at the largest accessibility size — the case the + /// deferred ViewThatFits work exists for. + func testSeededScreensAtLargestAccessibilitySize() throws { + let app = AccessibilityAuditHarness.launch( + contentSizeCategory: "UICTContentSizeCategoryAccessibilityXXXL", + seeded: true + ) + + var unaudited: [String] = [] + + app.selectRootTab("Dashboard") + if try !AccessibilityAuditHarness.audit(app, screen: "seeded-dashboard-accessibilityXXXL", test: self) { + unaudited.append("Dashboard") + } + + app.selectRootTab("Inspect") + if try !AccessibilityAuditHarness.audit(app, screen: "seeded-batch-accessibilityXXXL", test: self) { + unaudited.append("Inspect batch") + } + + app.selectRootTab("Settings") + let trackedDomains = app.buttons["Tracked Domains"] + if trackedDomains.waitForExistence(timeout: 5) { + trackedDomains.tap() + if try !AccessibilityAuditHarness.audit(app, screen: "seeded-tracked-domains-accessibilityXXXL", test: self) { + unaudited.append("Tracked Domains") + } + } + + try XCTSkipUnless( + unaudited.isEmpty, + "Audit did not complete in time for: \(unaudited.joined(separator: ", "))" + ) + } + // MARK: Helpers private func auditRootTab(_ tab: String) throws { |
