Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 108 additions & 31 deletions Classes/Swift/Extensions/GTXToolKit+Validation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public extension GTXToolKit {
///
/// This method performs GTX accessibility validation on a view and saves the results
/// to an aggregated YAML file along with screenshots of any failing elements.
/// Uses GTXAggregator to properly collect results from multiple snapshots into a single file.
///
/// - Parameters:
/// - view: The view to validate for accessibility
Expand Down Expand Up @@ -54,47 +55,123 @@ public extension GTXToolKit {
moduleName: moduleName
)

// Log validation details
print("📸 Running GTX accessibility validation...")
print(" Test: \(testName)")
print(" Module: \(moduleName)")
print(" Screenshot: \(screenshotPath)")
print(" YAML Report: \(yamlPath)")
// Extract just the screenshot filename (not full path) for YAML reference
let screenshotFilename = (screenshotPath as NSString).lastPathComponent

print("📸 Running GTX accessibility validation for: \(moduleName)")

// Ensure view is fully laid out before validation
view.setNeedsLayout()
view.layoutIfNeeded()

// Disable verbose GTX logging (those [GTX_LOG Error] messages)
GTXLogger.default().setLogLevel(.warning)

// Perform GTX accessibility validation
// This calls the existing verifyAccessibility function from GTXiLib
let accessibilityError = verifyAccessibility(
checking: view,
toolkit: self,
style: .yaml,
record: yamlPath,
showPassingSummary: true,
saveScreenshot: true,
screenshotPath: screenshotPath
)
let result = self.resultFromCheckingAllElements(fromRootElements: [view])

// Handle recording mode: log issues but don't fail the test
if recordingMode, let error = accessibilityError {
print("⚠️ Accessibility issues found (recording mode - test will not fail):")
print(" \(error)")
print(" Results saved to: \(yamlPath)")
return nil // Don't fail in recording mode
}
// Build element ordering for consistent IDs
let elementOrdering = buildElementOrdering(from: view)
let elementTextMap = buildElementTextMap(from: view)

// In normal mode, return any error to fail the test
if let error = accessibilityError {
print("❌ Accessibility validation failed:")
print(" \(error)")
print(" See report: \(yamlPath)")
// Format the result with metadata
let formattedResult: GTXFormattedResult
if result.allChecksPassed() {
formattedResult = GTXFormattedResult(
elementCount: 0,
totalCheckFailures: 0,
totalChecksPassed: result.elementsScanned,
formattedMessage: "All GTX checks passed",
hasFailures: false,
elements: []
)
} else {
print("✅ Accessibility validation passed")
let rawError = result.aggregatedError().localizedDescription
formattedResult = formatGTXResultWithMetadataForAggregation(
fromString: rawError,
elementsScanned: result.elementsScanned,
elementTextMap: elementTextMap,
elementOrdering: elementOrdering
)
}

// Generate screenshot if there are failures
if formattedResult.hasFailures {
let failingElements = extractFailingElements(from: view, using: elementTextMap, result: result)
if let screenshot = createScreenshotWithOverlays(view: view, failingElements: failingElements, elementOrdering: elementOrdering) {
let parentDir = (screenshotPath as NSString).deletingLastPathComponent
if !FileManager.default.fileExists(atPath: parentDir) {
try? FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
}

if let imageData = screenshot.pngData() {
try? imageData.write(to: URL(fileURLWithPath: screenshotPath))
print("📸 GTX screenshot with numbered overlays saved to: \(screenshotPath)")
}
}
}

// Initialize aggregator and check what's saved
let aggregator = GTXAggregator(aggregatedYAMLPath: yamlPath)

// In recording mode, just add and save without comparing
if recordingMode {
aggregator.addTestResult(testName, result: formattedResult, screenshotName: screenshotFilename)
do {
try aggregator.save()
print("📝 Results recorded to: \(yamlPath)")
} catch {
print("⚠️ Failed to save aggregated YAML: \(error)")
return "Failed to save aggregated YAML: \(error)"
}
return nil
}

return accessibilityError
// Normal mode: Compare NEW result with saved test case
// First, temporarily add to create a comparable state
aggregator.addTestResult(testName, result: formattedResult, screenshotName: screenshotFilename)
let (contentMatches, diff) = aggregator.compareTestCase(testName)

if contentMatches {
// Test case matches - don't write anything, just pass
print("✅ Accessibility validation matches saved snapshot")
return nil
} else {
// Check if this is a new test (file or test case doesn't exist)
let isNewTest = diff?.contains("does not exist") ?? false ||
diff?.contains("not found in saved YAML") ?? false

// Save the new/updated content
do {
try aggregator.save()

if isNewTest {
// New test - save and fail (standard snapshot behavior)
print("📝 New accessibility snapshot recorded to: \(yamlPath)")
print(" No reference was found. Automatically saved a new reference.")
print(" Re-run tests to compare against this reference.")
return "Recorded new accessibility snapshot"
} else {
// Existing test with differences - fail
print("❌ Accessibility validation failed: Results differ from saved snapshot")
print(" Updated report saved to: \(yamlPath)")

// Print the diff to help debug
if let diff = diff {
print(diff)
}

print("\n Review the differences and either:")
print(" 1. Fix the accessibility issues, or")
print(" 2. Run tests in recording mode to accept the new results")

return "Accessibility snapshot mismatch - see \(yamlPath)"
}
} catch {
print("⚠️ Failed to save aggregated YAML: \(error)")
return "Failed to save aggregated YAML: \(error)"
}
}
}

/// Convenience method without custom managers
Expand All @@ -115,4 +192,4 @@ public extension GTXToolKit {
directoryManager: nil
)
}
}
}
2 changes: 1 addition & 1 deletion Classes/Swift/Protocols/GTXSnapshotProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,6 @@ public struct DefaultGTXSnapshotDirectoryManager: GTXSnapshotDirectoryManaging {
.replacingOccurrences(of: ".", with: "_")
.replacingOccurrences(of: "/", with: "_")

return (directory as NSString).appendingPathComponent("\(cleanModuleName)_aggregated.yaml")
return (directory as NSString).appendingPathComponent("\(cleanModuleName)_gtx_accessibility.yml")
}
}
136 changes: 122 additions & 14 deletions Classes/XCTest/GTXAccessibilityChecks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,45 @@ import XCTest

// MARK: - Models

/// Represents a single accessibility check result
public struct GTXCheckResult {
public let name: String
public let passed: Bool
public let reason: String?

public init(name: String, passed: Bool, reason: String?) {
self.name = name
self.passed = passed
self.reason = reason
}
}

/// Represents a UI element with its accessibility check results
public struct GTXElementResult {
public let id: Int
public let view: String
public let text: String?
public let baseClass: String?
public let frameRect: String?
public let elementSize: String?
public let accessibilityFrame: String?
public let checks: [GTXCheckResult]

public init(id: Int, view: String, text: String?, baseClass: String?,
frameRect: String?, elementSize: String?, accessibilityFrame: String?,
checks: [GTXCheckResult]) {
self.id = id
self.view = view
self.text = text
self.baseClass = baseClass
self.frameRect = frameRect
self.elementSize = elementSize
self.accessibilityFrame = accessibilityFrame
self.checks = checks
}
}

// Legacy private types for backward compatibility
private struct GTXCheck {
let name: String
let passed: Bool
Expand Down Expand Up @@ -57,10 +96,19 @@ public struct GTXFormattedResult {
public let formattedMessage: String
public let hasFailures: Bool

/// Raw parsed elements (for advanced use cases)
public let elements: [(view: String, baseClass: String?, frameRect: String?,
elementSize: String?, accessibilityFrame: String?,
checks: [(name: String, passed: Bool, reason: String?)])]
/// Parsed elements with their accessibility check results
public let elements: [GTXElementResult]

public init(elementCount: Int, totalCheckFailures: Int, totalChecksPassed: Int,
formattedMessage: String, hasFailures: Bool,
elements: [GTXElementResult]) {
self.elementCount = elementCount
self.totalCheckFailures = totalCheckFailures
self.totalChecksPassed = totalChecksPassed
self.formattedMessage = formattedMessage
self.hasFailures = hasFailures
self.elements = elements
}
}

/// Verify accessibility and optionally save a YAML report with numbered screenshot.
Expand Down Expand Up @@ -241,25 +289,27 @@ public func formatGTXResult(fromString raw: String,
message = formatCompact(grouped, totalChecks: pairs.count)
}

// Convert to public element format
let publicElements = grouped.map { g in
(
// Convert to GTXElementResult format
let elementResults = grouped.map { g in
GTXElementResult(
id: 0,
view: g.elem.view,
text: nil,
baseClass: g.elem.baseClass,
frameRect: g.elem.frameRect,
elementSize: g.elem.elementSize,
accessibilityFrame: g.elem.accessibilityFrame,
checks: g.checks.map { (name: $0.name, passed: $0.passed, reason: $0.reason) }
checks: g.checks.map { GTXCheckResult(name: $0.name, passed: $0.passed, reason: $0.reason) }
)
}

return GTXFormattedResult(
elementCount: grouped.count,
totalCheckFailures: pairs.count,
totalChecksPassed: 0, // Only failures in error string parsing
totalChecksPassed: 0,
formattedMessage: message,
hasFailures: true,
elements: publicElements
elements: elementResults
)
}

Expand Down Expand Up @@ -538,7 +588,7 @@ private struct ElementMetadata {

/// Build deterministic element ordering based on DFS traversal of view hierarchy
/// This ensures consistent numbering between YAML and screenshot overlays
private func buildElementOrdering(from rootView: UIView) -> [String: Int] {
internal func buildElementOrdering(from rootView: UIView) -> [String: Int] {
var ordering: [String: Int] = [:]
var counter = 1

Expand Down Expand Up @@ -604,7 +654,7 @@ private func buildElementMetadataMap(from rootView: UIView, ordering: [String: I
return metadataMap
}

private func buildElementTextMap(from rootView: UIView) -> [String: String] {
internal func buildElementTextMap(from rootView: UIView) -> [String: String] {
var textMap: [String: String] = [:]

func traverse(_ view: UIView) {
Expand All @@ -628,7 +678,7 @@ private func buildElementTextMap(from rootView: UIView) -> [String: String] {
return textMap
}

private func extractFailingElements(from rootView: UIView, using textMap: [String: String], result: GTXResult) -> [Any] {
internal func extractFailingElements(from rootView: UIView, using textMap: [String: String], result: GTXResult) -> [Any] {
var failingElements: [Any] = []
var elementAddresses: [String] = []

Expand Down Expand Up @@ -658,7 +708,7 @@ private func extractFailingElements(from rootView: UIView, using textMap: [Strin
return failingElements
}

private func createScreenshotWithOverlays(view: UIView, failingElements: [Any], elementOrdering: [String: Int]) -> UIImage? {
internal func createScreenshotWithOverlays(view: UIView, failingElements: [Any], elementOrdering: [String: Int]) -> UIImage? {
// Ensure the view has been laid out
view.layoutIfNeeded()

Expand Down Expand Up @@ -1107,6 +1157,64 @@ private func saveGTXResultAsYAML(_ result: GTXFormattedResultWithText, to path:
}
}

// MARK: - Aggregation Helper

/// Format GTX result with metadata for use with GTXAggregator
/// Returns GTXFormattedResult with properly typed GTXElementResult structs
internal func formatGTXResultWithMetadataForAggregation(
fromString raw: String,
elementsScanned: Int = 0,
elementTextMap: [String: String] = [:],
elementOrdering: [String: Int] = [:]
) -> GTXFormattedResult {
let elements = parseGTXElementsWithText(from: raw, elementTextMap: elementTextMap, elementOrdering: elementOrdering)

guard !elements.isEmpty else {
return GTXFormattedResult(
elementCount: 0,
totalCheckFailures: 0,
totalChecksPassed: elementsScanned,
formattedMessage: "No GTX failures",
hasFailures: false,
elements: []
)
}

let totalCheckFailures = elements.reduce(0) { $0 + $1.checks.count }

// Convert GTXElementWithText to GTXElementResult structs
let elementResults: [GTXElementResult] = elements.map { element in
GTXElementResult(
id: element.id,
view: element.view,
text: element.text,
baseClass: element.baseClass,
frameRect: element.frameRect,
elementSize: element.elementSize,
accessibilityFrame: element.accessibilityFrame,
checks: element.checks.map { check in
GTXCheckResult(name: check.name, passed: check.passed, reason: check.reason)
}
)
}

let message = formatCompactWithText(elements.map { ($0, $0.checks) }, totalChecks: totalCheckFailures)

// Calculate totalChecksPassed: total elements scanned minus failing elements
// The aggregator uses: elementsScanned = elementCount + totalChecksPassed
// So: totalChecksPassed = elementsScanned - elementCount (NOT totalCheckFailures!)
let totalChecksPassed = elementsScanned - elements.count

return GTXFormattedResult(
elementCount: elements.count,
totalCheckFailures: totalCheckFailures,
totalChecksPassed: totalChecksPassed,
formattedMessage: message,
hasFailures: true,
elements: elementResults
)
}

// MARK: - Test Helper

private func fail(_ message: String, file: String, line: UInt) {
Expand Down
Loading