aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--DomainDig/AuditFixtures.swift168
-rw-r--r--DomainDig/BatchResultsView.swift41
-rw-r--r--DomainDig/DashboardView.swift32
-rw-r--r--DomainDig/DomainDigApp.swift3
-rw-r--r--DomainDig/DomainDigUI.swift5
-rw-r--r--DomainDig/DomainViewModel+Widget.swift5
-rw-r--r--DomainDig/DomainViewModel.swift27
-rw-r--r--DomainDig/WatchlistView.swift65
-rw-r--r--DomainDigUITests/AccessibilityAuditHarness.swift13
-rw-r--r--DomainDigUITests/AccessibilityAuditTests.swift65
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 {