diff --git a/Example/AccessibilitySnapshot.xcodeproj/project.pbxproj b/Example/AccessibilitySnapshot.xcodeproj/project.pbxproj index 7fc5c49c..64a4316f 100644 --- a/Example/AccessibilitySnapshot.xcodeproj/project.pbxproj +++ b/Example/AccessibilitySnapshot.xcodeproj/project.pbxproj @@ -55,6 +55,7 @@ 83A295842AC22D9D00DFBE4F /* UserInputLabelsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A295832AC22D9D00DFBE4F /* UserInputLabelsViewController.swift */; }; 83A295862AC22EEE00DFBE4F /* UserInputLabelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A295852AC22EEE00DFBE4F /* UserInputLabelsTests.swift */; }; C47F6C5316FB0C043BEB59F3 /* Pods_AccessibilitySnapshotDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6A886964D2E787399E137105 /* Pods_AccessibilitySnapshotDemo.framework */; }; + D2F76EED2945C879000A453F /* HitTargetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F76EEC2945C879000A453F /* HitTargetTests.swift */; }; D38F6F4E508A3D067D677F69 /* Pods_UnitTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0BFCB4FD6BC17AB232B26E72 /* Pods_UnitTests.framework */; }; /* End PBXBuildFile section */ @@ -141,6 +142,7 @@ 88C33CBF672C290CE1EE86AF /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = ""; }; C78F90CE7A2A315AADF80144 /* Pods-SnapshotTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SnapshotTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SnapshotTests/Pods-SnapshotTests.debug.xcconfig"; sourceTree = ""; }; CCFF2A604706B71DC0CBD38B /* Pods-AccessibilitySnapshotDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AccessibilitySnapshotDemo.release.xcconfig"; path = "Pods/Target Support Files/Pods-AccessibilitySnapshotDemo/Pods-AccessibilitySnapshotDemo.release.xcconfig"; sourceTree = ""; }; + D2F76EEC2945C879000A453F /* HitTargetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTargetTests.swift; sourceTree = ""; }; DBA3D7413A13111BA7DE4750 /* Pods-UnitTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-UnitTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-UnitTests/Pods-UnitTests.release.xcconfig"; sourceTree = ""; }; DCFAC4866DBB341F92D0A40D /* Pods-SnapshotTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SnapshotTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-SnapshotTests/Pods-SnapshotTests.release.xcconfig"; sourceTree = ""; }; ED63B7AD78B189E8940B6C80 /* Pods-AccessibilitySnapshotDemo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AccessibilitySnapshotDemo.debug.xcconfig"; path = "Pods/Target Support Files/Pods-AccessibilitySnapshotDemo/Pods-AccessibilitySnapshotDemo.debug.xcconfig"; sourceTree = ""; }; @@ -302,6 +304,7 @@ 3D9334932A8B2E520078A142 /* ImpreciseObjectiveCTests.m */, 3DBAC28622406EBB00EF4D0A /* AccessibilityContainersTests.swift */, 607FACEB1AFB9204008FA782 /* AccessibilityPropertiesTests.swift */, + D2F76EEC2945C879000A453F /* HitTargetTests.swift */, 3D39BFAF2239BC42009C3EF4 /* ActivationPointTests.swift */, 3DEBF24F22101EE40065424F /* DefaultControlsTests.swift */, 3DF46503220D8C500048D446 /* ElementOrderTests.swift */, @@ -651,6 +654,7 @@ 3D39BFB02239BC42009C3EF4 /* ActivationPointTests.swift in Sources */, 3DC2C67921F4478A003184E4 /* LayoutTests.swift in Sources */, 83A295862AC22EEE00DFBE4F /* UserInputLabelsTests.swift in Sources */, + D2F76EED2945C879000A453F /* HitTargetTests.swift in Sources */, 3DF46504220D8C500048D446 /* ElementOrderTests.swift in Sources */, 3DEBF25022101EE40065424F /* DefaultControlsTests.swift in Sources */, ); diff --git a/Example/AccessibilitySnapshot.xcodeproj/xcshareddata/xcbaselines/607FACE41AFB9204008FA782.xcbaseline/EDA548A6-E453-4CBA-9366-15D413356833.plist b/Example/AccessibilitySnapshot.xcodeproj/xcshareddata/xcbaselines/607FACE41AFB9204008FA782.xcbaseline/EDA548A6-E453-4CBA-9366-15D413356833.plist new file mode 100644 index 00000000..7c3b775d --- /dev/null +++ b/Example/AccessibilitySnapshot.xcodeproj/xcshareddata/xcbaselines/607FACE41AFB9204008FA782.xcbaseline/EDA548A6-E453-4CBA-9366-15D413356833.plist @@ -0,0 +1,22 @@ + + + + + classNames + + HitTargetTests + + testPerformance() + + com.apple.XCTPerformanceMetric_WallClockTime + + baselineAverage + 48.792810 + baselineIntegrationDisplayName + Local Baseline + + + + + + diff --git a/Example/AccessibilitySnapshot.xcodeproj/xcshareddata/xcbaselines/607FACE41AFB9204008FA782.xcbaseline/Info.plist b/Example/AccessibilitySnapshot.xcodeproj/xcshareddata/xcbaselines/607FACE41AFB9204008FA782.xcbaseline/Info.plist new file mode 100644 index 00000000..26da784d --- /dev/null +++ b/Example/AccessibilitySnapshot.xcodeproj/xcshareddata/xcbaselines/607FACE41AFB9204008FA782.xcbaseline/Info.plist @@ -0,0 +1,40 @@ + + + + + runDestinationsByUUID + + EDA548A6-E453-4CBA-9366-15D413356833 + + localComputer + + busSpeedInMHz + 0 + cpuCount + 1 + cpuKind + Apple M1 Max + cpuSpeedInMHz + 0 + logicalCPUCoresPerPackage + 10 + modelCode + MacBookPro18,2 + physicalCPUCoresPerPackage + 10 + platformIdentifier + com.apple.platform.macosx + + targetArchitecture + arm64 + targetDevice + + modelCode + iPhone13,3 + platformIdentifier + com.apple.platform.iphonesimulator + + + + + diff --git a/Example/AccessibilitySnapshot.xcodeproj/xcshareddata/xcschemes/AccessibilitySnapshotDemo.xcscheme b/Example/AccessibilitySnapshot.xcodeproj/xcshareddata/xcschemes/AccessibilitySnapshotDemo.xcscheme index 0cfdb074..3257b9d3 100644 --- a/Example/AccessibilitySnapshot.xcodeproj/xcshareddata/xcschemes/AccessibilitySnapshotDemo.xcscheme +++ b/Example/AccessibilitySnapshot.xcodeproj/xcshareddata/xcschemes/AccessibilitySnapshotDemo.xcscheme @@ -76,6 +76,9 @@ + + diff --git a/Example/SnapshotTests/HitTargetTests.swift b/Example/SnapshotTests/HitTargetTests.swift new file mode 100644 index 00000000..26aa9b07 --- /dev/null +++ b/Example/SnapshotTests/HitTargetTests.swift @@ -0,0 +1,83 @@ +// +// Copyright 2023 Block Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AccessibilitySnapshot +import FBSnapshotTestCase +import Paralayout + +@testable import AccessibilitySnapshotDemo + +final class HitTargetTests: SnapshotTestCase { + + func testButtonHitTarget() { + let buttonTraitsViewController = ButtonAccessibilityTraitsViewController() + buttonTraitsViewController.view.frame = UIScreen.main.bounds + SnapshotVerifyWithHitTargets(buttonTraitsViewController.view) + } + + @available(iOS 14, *) + func testTableHitTarget() throws { + try XCTSkipUnless( + ProcessInfo().operatingSystemVersion.majorVersion >= 14, + "This test only supports iOS 14 and later" + ) + + let viewController = TableViewController() + viewController.view.frame = UIScreen.main.bounds + SnapshotVerifyWithHitTargets(viewController.view) + } + + func testPerformance() throws { + let buttonTraitsViewController = ButtonAccessibilityTraitsViewController() + buttonTraitsViewController.view.frame = UIScreen.main.bounds + + measure { + do { + _ = try HitTargetSnapshotUtility.generateSnapshotImage( + for: buttonTraitsViewController.view, + useMonochromeSnapshot: true, + viewRenderingMode: .drawHierarchyInRect + ) + } catch { + XCTFail("Utility should not fail to generate snapshot image") + } + } + } + +} + +// MARK: - + +@available(iOS 14, *) +private final class TableViewController: UITableViewController { + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return 3 + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell() + + var config = cell.defaultContentConfiguration() + config.text = "Hello World" + cell.contentConfiguration = config + + cell.accessoryView = UISwitch() + + return cell + } + +} diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_13_7_375x812@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_13_7_375x812@3x.png new file mode 100644 index 00000000..60f0fc19 Binary files /dev/null and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_13_7_375x812@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_14_5_390x844@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_14_5_390x844@3x.png new file mode 100644 index 00000000..cea6f761 Binary files /dev/null and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_14_5_390x844@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_16_4_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_16_4_393x852@3x.png new file mode 100644 index 00000000..5a99db81 Binary files /dev/null and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testButtonHitTarget_16_4_393x852@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testTableHitTargetAndReturnError__14_5_390x844@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testTableHitTargetAndReturnError__14_5_390x844@3x.png new file mode 100644 index 00000000..df227dc8 Binary files /dev/null and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testTableHitTargetAndReturnError__14_5_390x844@3x.png differ diff --git a/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testTableHitTargetAndReturnError__16_4_393x852@3x.png b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testTableHitTargetAndReturnError__16_4_393x852@3x.png new file mode 100644 index 00000000..182bf3e3 Binary files /dev/null and b/Example/SnapshotTests/ReferenceImages/_64/SnapshotTests.HitTargetTests/testTableHitTargetAndReturnError__16_4_393x852@3x.png differ diff --git a/Example/SnapshotTests/SnapshotTestingTests.swift b/Example/SnapshotTests/SnapshotTestingTests.swift index f5ac7797..bf27cd67 100644 --- a/Example/SnapshotTests/SnapshotTestingTests.swift +++ b/Example/SnapshotTests/SnapshotTestingTests.swift @@ -138,6 +138,12 @@ final class SnapshotTestingTests: XCTestCase { assertSnapshot(matching: viewController, as: .imageWithSmartInvert, named: nameForDevice()) } + func testHitTargets() { + let viewController = ButtonAccessibilityTraitsViewController() + viewController.view.frame = UIScreen.main.bounds + assertSnapshot(matching: viewController, as: .imageWithHitTargets(), named: nameForDevice()) + } + // MARK: - Private Methods private func nameForDevice(baseName: String? = nil) -> String { diff --git a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.375x812-13-7-3x.png b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.375x812-13-7-3x.png new file mode 100644 index 00000000..60f0fc19 Binary files /dev/null and b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.375x812-13-7-3x.png differ diff --git a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.390x844-14-5-3x.png b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.390x844-14-5-3x.png new file mode 100644 index 00000000..cea6f761 Binary files /dev/null and b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.390x844-14-5-3x.png differ diff --git a/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.393x852-16-4-3x.png b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.393x852-16-4-3x.png new file mode 100644 index 00000000..d0e11f0e Binary files /dev/null and b/Example/SnapshotTests/__Snapshots__/SnapshotTestingTests/testHitTargets.393x852-16-4-3x.png differ diff --git a/Sources/AccessibilitySnapshot/Core/Swift/Classes/AccessibilitySnapshotView.swift b/Sources/AccessibilitySnapshot/Core/Swift/Classes/AccessibilitySnapshotView.swift index 82d27c31..4c14efd7 100644 --- a/Sources/AccessibilitySnapshot/Core/Swift/Classes/AccessibilitySnapshotView.swift +++ b/Sources/AccessibilitySnapshot/Core/Swift/Classes/AccessibilitySnapshotView.swift @@ -1,5 +1,5 @@ // -// Copyright 2019 Square Inc. +// Copyright 2023 Block Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -14,7 +14,6 @@ // limitations under the License. // -import CoreImage import UIKit public enum ActivationPointDisplayMode { @@ -786,161 +785,6 @@ private extension AccessibilitySnapshotView { // MARK: - -private extension UIView { - - func renderToImage( - monochrome: Bool, - viewRenderingMode: AccessibilitySnapshotView.ViewRenderingMode - ) throws -> UIImage { - let renderer = UIGraphicsImageRenderer(bounds: bounds) - - var error: Error? - - let snapshot = renderer.image { context in - switch viewRenderingMode { - case .drawHierarchyInRect: - if bounds.width > UIView.tileSideLength || bounds.height > UIView.tileSideLength { - drawTiledHierarchySnapshots(in: context, error: &error) - } else { - drawHierarchy(in: bounds, afterScreenUpdates: true) - } - - case .renderLayerInContext: - layer.render(in: context.cgContext) - } - } - - if let error = error { - throw error - } - - if monochrome { - return try monochromeSnapshot(for: snapshot) ?? snapshot - - } else { - return snapshot - } - } - - private func monochromeSnapshot(for snapshot: UIImage) throws -> UIImage? { - if ProcessInfo().operatingSystemVersion.majorVersion == 13 { - // On iOS 13, the image filter silently fails for large images, "successfully" producing a blank output - // image. From testing, the maximum support size is 1365x1365 pt. Exceeding that in either dimension will - // result in a blank image. - let maximumSize = CGSize(width: 1365, height: 1365) - if snapshot.size.width > maximumSize.width || snapshot.size.height > maximumSize.height { - throw AccessibilitySnapshotView.Error.containedViewExceedsMaximumSize( - viewSize: snapshot.size, - maximumSize: maximumSize - ) - } - } - - guard let inputImage = CIImage(image: snapshot) else { - return nil - } - - let monochromeFilter = CIFilter( - name: "CIColorControls", - parameters: [ - kCIInputImageKey: inputImage, - kCIInputSaturationKey: 0, - ] - )! - - let context = CIContext() - - guard - let outputImage = monochromeFilter.outputImage, - let cgImage = context.createCGImage(outputImage, from: outputImage.extent) - else { - return nil - } - - return UIImage(cgImage: cgImage) - } - - private func drawTiledHierarchySnapshots(in context: UIGraphicsImageRendererContext, error: inout Error?) { - guard CATransform3DIsIdentity(layer.transform) else { - error = AccessibilitySnapshotView.Error.containedViewHasUnsupportedTransform(transform: layer.transform) - return - } - - let originalSafeArea = bounds.inset(by: safeAreaInsets) - - let originalSuperview = superview - let originalOrigin = frame.origin - let originalAutoresizingMask = autoresizingMask - defer { - originalSuperview?.addSubview(self) - frame.origin = originalOrigin - autoresizingMask = originalAutoresizingMask - } - - let frameView = UIView(frame: frame) - originalSuperview?.addSubview(frameView) - defer { - frameView.removeFromSuperview() - } - - autoresizingMask = [] - frame.origin = .zero - - let containerViewController = UIViewController() - let containerView = containerViewController.view! - containerView.frame = frame - containerView.autoresizingMask = [] - containerView.addSubview(self) - frameView.addSubview(containerView) - - // Run the run loop for one cycle so that the safe area changes caused by restructuring the view hierarhcy are - // propogated. Then calculate the required additional safe area insets to create the equivalent original safe - // area. This new change will be propogated automatically when we draw the hierarchy for the first time. - RunLoop.current.run(until: Date()) - let currentSafeArea = containerView.convert(bounds.inset(by: safeAreaInsets), from: self) - containerViewController.additionalSafeAreaInsets = UIEdgeInsets( - top: originalSafeArea.minY - currentSafeArea.minY, - left: originalSafeArea.minX - currentSafeArea.minX, - bottom: currentSafeArea.maxY - originalSafeArea.maxY, - right: currentSafeArea.maxX - originalSafeArea.maxX - ) - - let bounds = self.bounds - var tileRect: CGRect = .zero - - while tileRect.minY < bounds.maxY { - tileRect.origin.x = bounds.minX - tileRect.size.height = min(tileRect.minY + UIView.tileSideLength, bounds.maxY) - tileRect.minY - - while tileRect.minX < bounds.maxX { - tileRect.size.width = min(tileRect.minX + UIView.tileSideLength, bounds.maxX) - tileRect.minX - frameView.frame.size = tileRect.size - - // Move the origin of the `frameView` and `containerView` such that the frame is over the right area of - // the snapshotted view, but the snapshotted view stays fixed relative to the `frameView`'s superview - // (so the view's position on screen doesn't change). - frameView.frame.origin = CGPoint(x: tileRect.minX, y: tileRect.minY) - containerView.frame.origin = CGPoint(x: -tileRect.minX, y: -tileRect.minY) - - UIGraphicsImageRenderer(bounds: frameView.bounds) - .image { _ in - frameView.drawHierarchy(in: frameView.bounds, afterScreenUpdates: true) - } - .draw(at: tileRect.origin) - - tileRect.origin.x += UIView.tileSideLength - } - - tileRect.origin.y += UIView.tileSideLength - } - } - - private static let tileSideLength: CGFloat = 2000 - -} - -// MARK: - - private extension Bundle { private final class Sentinel {} diff --git a/Sources/AccessibilitySnapshot/Core/Swift/Classes/HitTargetSnapshotView.swift b/Sources/AccessibilitySnapshot/Core/Swift/Classes/HitTargetSnapshotView.swift new file mode 100644 index 00000000..a245c482 --- /dev/null +++ b/Sources/AccessibilitySnapshot/Core/Swift/Classes/HitTargetSnapshotView.swift @@ -0,0 +1,141 @@ +// +// Copyright 2023 Block Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import CoreImage +import UIKit + +public enum HitTargetSnapshotUtility { + + /// Generates an image of the provided `view` with hit target regions highlighted. + /// + /// The hit target regions are highlighted using the following rules: + /// + /// * Regions that hit test to the base view (`view`) will not be highlighted. + /// * Regions that hit test to `nil` will be darkened. + /// * Regions that hit test to another view will be highlighted using one of the specified `colors`. + /// + /// - parameter view: The base view to be tested against. + /// - parameter useMonochromeSnapshot: Whether or not the snapshot of the `view` should be monochrome. Using a + /// monochrome snapshot makes it more clear where the highlighted elements are, but may make it difficult to + /// read certain views. + /// - parameter viewRenderingMode: The rendering method to use when snapshotting the `view`. + /// - parameter colors: An array of colors to use for the highlighted regions. These colors will be used in order, + /// repeating through the array as necessary and avoiding adjacent regions using the same color when possible. + public static func generateSnapshotImage( + for view: UIView, + useMonochromeSnapshot: Bool, + viewRenderingMode: AccessibilitySnapshotView.ViewRenderingMode, + colors: [UIColor] = AccessibilitySnapshotView.defaultMarkerColors + ) throws -> UIImage { + let colors = colors.map { $0.withAlphaComponent(0.2) } + + let bounds = view.bounds + let renderer = UIGraphicsImageRenderer(bounds: bounds) + + let viewImage = try view.renderToImage( + monochrome: useMonochromeSnapshot, + viewRenderingMode: viewRenderingMode + ) + + return renderer.image { context in + viewImage.draw(in: bounds) + + var viewToColorMap: [UIView: UIColor] = [:] + let pixelWidth: CGFloat = 1 / UIScreen.main.scale + + func drawScanLine( + for hitView: UIView?, + startingAtX: CGFloat, + endingAtX: CGFloat, + y: CGFloat, + lineHeight: CGFloat + ) { + // Only draw hit areas for views other than the base view we're testing. + guard hitView !== view else { + return + } + + let color: UIColor + if let hitView = hitView, let existingColor = viewToColorMap[hitView] { + color = existingColor + } else if let hitView = hitView { + // As a future enhancement, this could be smarter about checking above/left colors to make sure they + // aren't the same. + color = colors[viewToColorMap.count % colors.count] + viewToColorMap[hitView] = color + } else { + color = .lightGray + } + + context.cgContext.setFillColor(color.cgColor) + context.cgContext.beginPath() + context.cgContext.addRect( + CGRect( + x: startingAtX, + y: y, + width: (endingAtX - startingAtX), + height: lineHeight + ) + ) + context.cgContext.drawPath(using: .fill) + } + + let touchOffset = pixelWidth / 2 + + // Step through every pixel along the Y axis. + for y in stride(from: bounds.minY, to: bounds.maxY, by: pixelWidth) { + var lastHit: (CGFloat, UIView?)? = nil + + // Step through every pixel along the X axis. + for x in stride(from: bounds.minX, to: bounds.maxX, by: pixelWidth) { + let hitView = view.hitTest(CGPoint(x: x + touchOffset, y: y + touchOffset), with: nil) + + if let lastHit = lastHit, hitView == lastHit.1 { + // We're still hitting the same view. Keep scanning. + continue + + } else if let previousHit = lastHit { + // We've moved on to a new view, so draw the scan line for the previous view. + drawScanLine( + for: previousHit.1, + startingAtX: previousHit.0, + endingAtX: x, + y: y, + lineHeight: pixelWidth + ) + lastHit = (x, hitView) + + } else { + // We've started a new view's region. + lastHit = (x, hitView) + } + } + + // Finish the scan line if necessary. + if let lastHit = lastHit, let lastHitView = lastHit.1 { + drawScanLine( + for: lastHitView, + startingAtX: lastHit.0, + endingAtX: bounds.maxX, + y: y, + lineHeight: pixelWidth + ) + } + } + } + } + +} diff --git a/Sources/AccessibilitySnapshot/Core/Swift/Classes/UIView+ImageRendering.swift b/Sources/AccessibilitySnapshot/Core/Swift/Classes/UIView+ImageRendering.swift new file mode 100644 index 00000000..e5ee10a1 --- /dev/null +++ b/Sources/AccessibilitySnapshot/Core/Swift/Classes/UIView+ImageRendering.swift @@ -0,0 +1,171 @@ +// +// Copyright 2023 Block Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import CoreImage +import UIKit + +extension UIView { + + func renderToImage( + monochrome: Bool, + viewRenderingMode: AccessibilitySnapshotView.ViewRenderingMode + ) throws -> UIImage { + let renderer = UIGraphicsImageRenderer(bounds: bounds) + + var error: Error? + + let snapshot = renderer.image { context in + switch viewRenderingMode { + case .drawHierarchyInRect: + if bounds.width > UIView.tileSideLength || bounds.height > UIView.tileSideLength { + drawTiledHierarchySnapshots(in: context, error: &error) + } else { + drawHierarchy(in: bounds, afterScreenUpdates: true) + } + + case .renderLayerInContext: + layer.render(in: context.cgContext) + } + } + + if let error = error { + throw error + } + + if monochrome { + return try monochromeSnapshot(for: snapshot) ?? snapshot + + } else { + return snapshot + } + } + + private func monochromeSnapshot(for snapshot: UIImage) throws -> UIImage? { + if ProcessInfo().operatingSystemVersion.majorVersion == 13 { + // On iOS 13, the image filter silently fails for large images, "successfully" producing a blank output + // image. From testing, the maximum support size is 1365x1365 pt. Exceeding that in either dimension will + // result in a blank image. + let maximumSize = CGSize(width: 1365, height: 1365) + if snapshot.size.width > maximumSize.width || snapshot.size.height > maximumSize.height { + throw AccessibilitySnapshotView.Error.containedViewExceedsMaximumSize( + viewSize: snapshot.size, + maximumSize: maximumSize + ) + } + } + + guard let inputImage = CIImage(image: snapshot) else { + return nil + } + + let monochromeFilter = CIFilter( + name: "CIColorControls", + parameters: [ + kCIInputImageKey: inputImage, + kCIInputSaturationKey: 0, + ] + )! + + let context = CIContext() + + guard + let outputImage = monochromeFilter.outputImage, + let cgImage = context.createCGImage(outputImage, from: outputImage.extent) + else { + return nil + } + + return UIImage(cgImage: cgImage) + } + + private func drawTiledHierarchySnapshots(in context: UIGraphicsImageRendererContext, error: inout Error?) { + guard CATransform3DIsIdentity(layer.transform) else { + error = AccessibilitySnapshotView.Error.containedViewHasUnsupportedTransform(transform: layer.transform) + return + } + + let originalSafeArea = bounds.inset(by: safeAreaInsets) + + let originalSuperview = superview + let originalOrigin = frame.origin + let originalAutoresizingMask = autoresizingMask + defer { + originalSuperview?.addSubview(self) + frame.origin = originalOrigin + autoresizingMask = originalAutoresizingMask + } + + let frameView = UIView(frame: frame) + originalSuperview?.addSubview(frameView) + defer { + frameView.removeFromSuperview() + } + + autoresizingMask = [] + frame.origin = .zero + + let containerViewController = UIViewController() + let containerView = containerViewController.view! + containerView.frame = frame + containerView.autoresizingMask = [] + containerView.addSubview(self) + frameView.addSubview(containerView) + + // Run the run loop for one cycle so that the safe area changes caused by restructuring the view hierarhcy are + // propogated. Then calculate the required additional safe area insets to create the equivalent original safe + // area. This new change will be propogated automatically when we draw the hierarchy for the first time. + RunLoop.current.run(until: Date()) + let currentSafeArea = containerView.convert(bounds.inset(by: safeAreaInsets), from: self) + containerViewController.additionalSafeAreaInsets = UIEdgeInsets( + top: originalSafeArea.minY - currentSafeArea.minY, + left: originalSafeArea.minX - currentSafeArea.minX, + bottom: currentSafeArea.maxY - originalSafeArea.maxY, + right: currentSafeArea.maxX - originalSafeArea.maxX + ) + + let bounds = self.bounds + var tileRect: CGRect = .zero + + while tileRect.minY < bounds.maxY { + tileRect.origin.x = bounds.minX + tileRect.size.height = min(tileRect.minY + UIView.tileSideLength, bounds.maxY) - tileRect.minY + + while tileRect.minX < bounds.maxX { + tileRect.size.width = min(tileRect.minX + UIView.tileSideLength, bounds.maxX) - tileRect.minX + frameView.frame.size = tileRect.size + + // Move the origin of the `frameView` and `containerView` such that the frame is over the right area of + // the snapshotted view, but the snapshotted view stays fixed relative to the `frameView`'s superview + // (so the view's position on screen doesn't change). + frameView.frame.origin = CGPoint(x: tileRect.minX, y: tileRect.minY) + containerView.frame.origin = CGPoint(x: -tileRect.minX, y: -tileRect.minY) + + UIGraphicsImageRenderer(bounds: frameView.bounds) + .image { _ in + frameView.drawHierarchy(in: frameView.bounds, afterScreenUpdates: true) + } + .draw(at: tileRect.origin) + + tileRect.origin.x += UIView.tileSideLength + } + + tileRect.origin.y += UIView.tileSideLength + } + } + + private static let tileSideLength: CGFloat = 2000 + +} diff --git a/Sources/AccessibilitySnapshot/SnapshotTesting/SnapshotTesting+Accessibility.swift b/Sources/AccessibilitySnapshot/SnapshotTesting/SnapshotTesting+Accessibility.swift index ffb49ee0..b4296a1f 100644 --- a/Sources/AccessibilitySnapshot/SnapshotTesting/SnapshotTesting+Accessibility.swift +++ b/Sources/AccessibilitySnapshot/SnapshotTesting/SnapshotTesting+Accessibility.swift @@ -138,6 +138,78 @@ extension Snapshotting where Value == UIView, Format == UIImage { } } + /// Snapshots the view with hit target regions highlighted. + /// + /// The hit target regions are highlighted using the following rules: + /// + /// * Regions that hit test to the base view will not be highlighted. + /// * Regions that hit test to `nil` will be darkened. + /// * Regions that hit test to another view will be highlighted using one of the specified `colors`. + /// + /// - parameter useMonochromeSnapshot: Whether or not the snapshot of the view should be monochrome. Using a + /// monochrome snapshot makes it more clear where the highlighted elements are, but may make it difficult to + /// read certain views. + /// - parameter drawHierarchyInKeyWindow: Whether or not to draw the view hierachy in the key window, rather than + /// rendering the view's layer. This enables the rendering of `UIAppearance` and `UIVisualEffect`s. + /// - parameter colors: An array of colors to use for the highlighted regions. These colors will be used in order, + /// repeating through the array as necessary and avoiding adjacent regions using the same color when possible. + /// - parameter file: The file in which errors should be attributed. + /// - parameter line: The line in which errors should be attributed. + public static func imageWithHitTargets( + useMonochromeSnapshot: Bool = true, + drawHierarchyInKeyWindow: Bool = false, + colors: [UIColor] = AccessibilitySnapshotView.defaultMarkerColors, + file: StaticString = #file, + line: UInt = #line + ) -> Snapshotting { + return Snapshotting.image.pullback { view in + // Some implementations of hit testing rely on the window, so install the view in a window if needed. + let requiresWindow = (view.window == nil && !(view is UIWindow)) + if requiresWindow { + let window = UIApplication.shared.firstKeyWindow ?? UIWindow(frame: UIScreen.main.bounds) + window.addSubview(view) + } + + view.layoutIfNeeded() + + do { + let image = try HitTargetSnapshotUtility.generateSnapshotImage( + for: view, + useMonochromeSnapshot: useMonochromeSnapshot, + viewRenderingMode: (drawHierarchyInKeyWindow ? .drawHierarchyInRect : .renderLayerInContext), + colors: colors + ) + + if requiresWindow { + view.removeFromSuperview() + } + + return image + } catch AccessibilitySnapshotView.Error.containedViewExceedsMaximumSize { + fatalError( + """ + View is too large to render monochrome snapshot. Try setting useMonochromeSnapshot to false or \ + use a different iOS version. In particular, this is known to fail on iOS 13, but was fixed in \ + iOS 14. + """, + file: file, + line: line + ) + } catch AccessibilitySnapshotView.Error.containedViewHasUnsupportedTransform { + fatalError( + """ + View has an unsupported transform for the specified snapshot parameters. Try using an identity \ + transform or changing the view rendering mode to render the layer in the graphics context. + """, + file: file, + line: line + ) + } catch { + fatalError("Failed to render snapshot image", file: file, line: line) + } + } + } + // MARK: - Internal Properties internal static var isRunningInHostApplication: Bool { @@ -199,4 +271,41 @@ extension Snapshotting where Value == UIViewController, Format == UIImage { } } + /// Snapshots the view controller with hit target regions highlighted. + /// + /// The hit target regions are highlighted using the following rules: + /// + /// * Regions that hit test to the base view (the view controller's `view`) will not be highlighted. + /// * Regions that hit test to `nil` will be darkened. + /// * Regions that hit test to another view will be highlighted using one of the specified `colors`. + /// + /// - parameter useMonochromeSnapshot: Whether or not the snapshot of the view should be monochrome. Using a + /// monochrome snapshot makes it more clear where the highlighted elements are, but may make it difficult to + /// read certain views. + /// - parameter drawHierarchyInKeyWindow: Whether or not to draw the view hierachy in the key window, rather than + /// rendering the view's layer. This enables the rendering of `UIAppearance` and `UIVisualEffect`s. + /// - parameter colors: An array of colors to use for the highlighted regions. These colors will be used in order, + /// repeating through the array as necessary and avoiding adjacent regions using the same color when possible. + /// - parameter file: The file in which errors should be attributed. + /// - parameter line: The line in which errors should be attributed. + public static func imageWithHitTargets( + useMonochromeSnapshot: Bool = true, + drawHierarchyInKeyWindow: Bool = false, + colors: [UIColor] = AccessibilitySnapshotView.defaultMarkerColors, + file: StaticString = #file, + line: UInt = #line + ) -> Snapshotting { + return Snapshotting + .imageWithHitTargets( + useMonochromeSnapshot: useMonochromeSnapshot, + drawHierarchyInKeyWindow: drawHierarchyInKeyWindow, + colors: colors, + file: file, + line: line + ) + .pullback { viewController in + viewController.view + } + } + } diff --git a/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/ObjC/include/FBSnapshotTestCase_Accessibility.h b/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/ObjC/include/FBSnapshotTestCase_Accessibility.h index f49e87af..ccfe8329 100644 --- a/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/ObjC/include/FBSnapshotTestCase_Accessibility.h +++ b/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/ObjC/include/FBSnapshotTestCase_Accessibility.h @@ -61,3 +61,19 @@ XCTFail("%@", errorDescription);\ }\ } + +#define SnapshotVerifyWithHitTargets(view__, identifier__, useMonochromeSnapshot__)\ + {\ + _Pragma("clang diagnostic push")\ + _Pragma("clang diagnostic ignored \"-Wundeclared-selector\"")\ + SEL selector = @selector(snapshotVerifyWithHitTargets:identifier:useMonochromeSnapshot:perPixelTolerance:overallTolerance:);\ + _Pragma("clang diagnostic pop")\ + typedef NSString * (*SnapshotMethod)(id, SEL, UIView *, NSString *, BOOL, CGFloat, CGFloat);\ + SnapshotMethod snapshotVerifyWithHitTargets = (SnapshotMethod)[self methodForSelector:selector];\ + NSString *errorDescription = snapshotVerifyWithInvertedColors(self, selector, view__, identifier__ ?: @"", useMonochromeSnapshot__, 0, 0);\ + if (errorDescription == nil) {\ + XCTAssertTrue(YES);\ + } else {\ + XCTFail("%@", errorDescription);\ + }\ + } diff --git a/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/ObjC/include/FBSnapshotTestCase_ImpreciseAccessibility.h b/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/ObjC/include/FBSnapshotTestCase_ImpreciseAccessibility.h index 5ca1d138..d164f83b 100644 --- a/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/ObjC/include/FBSnapshotTestCase_ImpreciseAccessibility.h +++ b/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/ObjC/include/FBSnapshotTestCase_ImpreciseAccessibility.h @@ -61,3 +61,19 @@ XCTFail("%@", errorDescription);\ }\ } + +#define SnapshotImpreciseVerifyWithHitTargets(view__, identifier__, useMonochromeSnapshot__, perPixelTolerance__, overallTolerance__)\ + {\ + _Pragma("clang diagnostic push")\ + _Pragma("clang diagnostic ignored \"-Wundeclared-selector\"")\ + SEL selector = @selector(snapshotVerifyWithHitTargets:identifier:useMonochromeSnapshot:perPixelTolerance:overallTolerance:);\ + _Pragma("clang diagnostic pop")\ + typedef NSString * (*SnapshotMethod)(id, SEL, UIView *, NSString *, BOOL, CGFloat, CGFloat);\ + SnapshotMethod snapshotVerifyWithHitTargets = (SnapshotMethod)[self methodForSelector:selector];\ + NSString *errorDescription = snapshotVerifyWithInvertedColors(self, selector, view__, identifier__ ?: @"", useMonochromeSnapshot__, perPixelTolerance__, overallTolerance__);\ + if (errorDescription == nil) {\ + XCTAssertTrue(YES);\ + } else {\ + XCTFail("%@", errorDescription);\ + }\ + } diff --git a/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+Accessibility.swift b/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+Accessibility.swift index 9e12b53e..1148c1fa 100644 --- a/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+Accessibility.swift +++ b/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+Accessibility.swift @@ -49,7 +49,8 @@ extension FBSnapshotTestCase { /// order, repeating through the array as necessary. /// - parameter suffixes: NSOrderedSet object containing strings that are appended to the reference images /// directory. Defaults to `FBSnapshotTestCaseDefaultSuffixes()`. - /// - parameter showUserInputLabels: Controls when to show elements' accessibility user input labels (used by Voice Control). + /// - parameter showUserInputLabels: Controls when to show elements' accessibility user input labels (used by Voice + /// Control). /// - parameter file: The file in which the test result should be attributed. /// - parameter line: The line in which the test result should be attributed. public func SnapshotVerifyAccessibility( @@ -94,14 +95,14 @@ extension FBSnapshotTestCase { /// Snapshots the `view` simulating the way it will appear with Smart Invert Colors enabled. /// - /// When `recordMode` is true, records a snapshot of the view. When `recordMode` is false, performs a comparison with the - /// existing snapshot. + /// When `recordMode` is true, records a snapshot of the view. When `recordMode` is false, performs a comparison + /// with the existing snapshot. /// /// - parameter view: The view that will be snapshotted. - /// - parameter identifier: An optional identifier included in the snapshot name, for use when there are multiple snapshot tests - /// in a given test method. Defaults to no identifier. - /// - parameter suffixes: NSOrderedSet object containing strings that are appended to the reference images directory. - /// Defaults to `FBSnapshotTestCaseDefaultSuffixes()`. + /// - parameter identifier: An optional identifier included in the snapshot name, for use when there are multiple + /// snapshot tests in a given test method. Defaults to no identifier. + /// - parameter suffixes: NSOrderedSet object containing strings that are appended to the reference images + /// directory. Defaults to `FBSnapshotTestCaseDefaultSuffixes()`. /// - parameter file: The file in which the test result should be attributed. /// - parameter line: The line in which the test result should be attributed. public func SnapshotVerifyWithInvertedColors( @@ -147,6 +148,48 @@ extension FBSnapshotTestCase { } } + /// Snapshots the `view` with hit target regions highlighted. + /// + /// The hit target regions are highlighted using the following rules: + /// + /// * Regions that hit test to the base view (`view`) will not be highlighted. + /// * Regions that hit test to `nil` will be darkened. + /// * Regions that hit test to another view will be highlighted using one of the specified `colors`. + /// + /// - parameter view: The view to be snapshotted. + /// - parameter identifier: An optional identifier included in the snapshot name, for use when there are multiple\ + /// snapshot tests in a given test method. Defaults to no identifier. + /// - parameter useMonochromeSnapshot: Whether or not the snapshot of the `view` should be monochrome. Using a + /// monochrome snapshot makes it more clear where the highlighted elements are, but may make it difficult to + /// read certain views. + /// - parameter colors: An array of colors to use for the highlighted regions. These colors will be used in order, + /// repeating through the array as necessary and avoiding adjacent regions using the same color when possible. + /// - parameter suffixes: NSOrderedSet object containing strings that are appended to the reference images + /// directory. Defaults to `FBSnapshotTestCaseDefaultSuffixes()`. + /// - parameter file: The file in which the test result should be attributed. + /// - parameter line: The line in which the test result should be attributed. + public func SnapshotVerifyWithHitTargets( + _ view: UIView, + identifier: String = "", + useMonochromeSnapshot: Bool = true, + colors: [UIColor] = AccessibilitySnapshotView.defaultMarkerColors, + suffixes: NSOrderedSet = FBSnapshotTestCaseDefaultSuffixes(), + file: StaticString = #file, + line: UInt = #line + ) { + SnapshotImpreciseVerifyWithHitTargets( + view, + identifier: identifier, + useMonochromeSnapshot: useMonochromeSnapshot, + colors: colors, + suffixes: suffixes, + perPixelTolerance: 0, + overallTolerance: 0, + file: file, + line: line + ) + } + // MARK: - Internal Properties var isRunningInHostApplication: Bool { diff --git a/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+ImpreciseAccessibility.swift b/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+ImpreciseAccessibility.swift index 2e3183c4..4d71deb4 100644 --- a/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+ImpreciseAccessibility.swift +++ b/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+ImpreciseAccessibility.swift @@ -179,4 +179,78 @@ extension FBSnapshotTestCase { } } + /// Snapshots the `view` with hit target regions highlighted. + /// + /// The hit target regions are highlighted using the following rules: + /// + /// * Regions that hit test to the base view (`view`) will not be highlighted. + /// * Regions that hit test to `nil` will be darkened. + /// * Regions that hit test to another view will be highlighted using one of the specified `colors`. + /// + /// - parameter view: The view to be snapshotted. + /// - parameter identifier: An optional identifier included in the snapshot name, for use when there are multiple\ + /// snapshot tests in a given test method. Defaults to no identifier. + /// - parameter useMonochromeSnapshot: Whether or not the snapshot of the `view` should be monochrome. Using a + /// monochrome snapshot makes it more clear where the highlighted elements are, but may make it difficult to + /// read certain views. + /// - parameter colors: An array of colors to use for the highlighted regions. These colors will be used in order, + /// repeating through the array as necessary and avoiding adjacent regions using the same color when possible. + /// - parameter suffixes: NSOrderedSet object containing strings that are appended to the reference images + /// directory. Defaults to `FBSnapshotTestCaseDefaultSuffixes()`. + /// - parameter perPixelTolerance: The amount the RGBA components of a pixel can differ for the pixel to still be + /// considered "unchanged". Value must be in the range `[0,1]`, where `0` means no difference allowed and `1` means + /// any two colors are considered identical. + /// - parameter overallTolerance: The portion of pixels that are allowed to have changed (as defined by the + /// per-pixel tolerance) for the image to still considered "unchanged" overall. Value must be in the range `[0,1]`, + /// where `0` means no pixels may change and `1` means all pixels may change. + /// - parameter file: The file in which the test result should be attributed. + /// - parameter line: The line in which the test result should be attributed. + public func SnapshotImpreciseVerifyWithHitTargets( + _ view: UIView, + identifier: String = "", + useMonochromeSnapshot: Bool = true, + colors: [UIColor] = AccessibilitySnapshotView.defaultMarkerColors, + suffixes: NSOrderedSet = FBSnapshotTestCaseDefaultSuffixes(), + perPixelTolerance: CGFloat = 0, + overallTolerance: CGFloat = 0, + file: StaticString = #file, + line: UInt = #line + ) { + // Some implementations of hit testing rely on the window, so install the view in a window if needed. + let requiresWindow = (view.window == nil && !(view is UIWindow)) + if requiresWindow { + let window = UIApplication.shared.firstKeyWindow ?? UIWindow(frame: UIScreen.main.bounds) + window.addSubview(view) + } + + view.layoutIfNeeded() + + let image: UIImage + do { + image = try HitTargetSnapshotUtility.generateSnapshotImage( + for: view, + useMonochromeSnapshot: useMonochromeSnapshot, + viewRenderingMode: (usesDrawViewHierarchyInRect ? .drawHierarchyInRect : .renderLayerInContext), + colors: colors + ) + } catch { + XCTFail(ErrorMessageFactory.errorMessageForAccessibilityParsingError(error), file: file, line: line) + return + } + + FBSnapshotVerifyView( + UIImageView(image: image), + identifier: identifier, + suffixes: suffixes, + perPixelTolerance: perPixelTolerance, + overallTolerance: overallTolerance, + file: file, + line: line + ) + + if requiresWindow { + view.removeFromSuperview() + } + } + } diff --git a/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+ObjCSupport.swift b/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+ObjCSupport.swift index bc509700..ae589e4b 100644 --- a/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+ObjCSupport.swift +++ b/Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+ObjCSupport.swift @@ -1,5 +1,5 @@ // -// Copyright 2019 Square Inc. +// Copyright 2023 Block Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -161,4 +161,49 @@ extension FBSnapshotTestCase { return errorDescription } + @objc(snapshotVerifyWithHitTargets:identifier:useMonochromeSnapshot:perPixelTolerance:overallTolerance:) + private func snapshotVerifyWithHitTargets( + _ view: UIView, + identifier: String, + useMonochromeSnapshot: Bool, + perPixelTolerance: CGFloat, + overallTolerance: CGFloat + ) -> String? { + // Some implementations of hit testing rely on the window, so install the view in a window if needed. + let requiresWindow = (view.window == nil && !(view is UIWindow)) + if requiresWindow { + let window = UIApplication.shared.firstKeyWindow ?? UIWindow(frame: UIScreen.main.bounds) + window.addSubview(view) + } + + view.layoutIfNeeded() + + let image: UIImage + do { + image = try HitTargetSnapshotUtility.generateSnapshotImage( + for: view, + useMonochromeSnapshot: useMonochromeSnapshot, + viewRenderingMode: (usesDrawViewHierarchyInRect ? .drawHierarchyInRect : .renderLayerInContext) + ) + } catch { + return ErrorMessageFactory.errorMessageForAccessibilityParsingError(error) + } + + let errorDescription = snapshotVerifyViewOrLayer( + UIImageView(image: image), + identifier: identifier, + suffixes: FBSnapshotTestCaseDefaultSuffixes(), + perPixelTolerance: perPixelTolerance, + overallTolerance: overallTolerance, + defaultReferenceDirectory: FB_REFERENCE_IMAGE_DIR, + defaultImageDiffDirectory: IMAGE_DIFF_DIR + ) + + if requiresWindow { + view.removeFromSuperview() + } + + return errorDescription + } + } diff --git a/Sources/AccessibilitySnapshotCore.xcodeproj/project.pbxproj b/Sources/AccessibilitySnapshotCore.xcodeproj/project.pbxproj index 2d85e92d..836cdb96 100644 --- a/Sources/AccessibilitySnapshotCore.xcodeproj/project.pbxproj +++ b/Sources/AccessibilitySnapshotCore.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 3D9D41882B355E970033460C /* HitTargetSnapshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9D41862B355E970033460C /* HitTargetSnapshotView.swift */; }; + 3D9D41892B355E970033460C /* UIView+ImageRendering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9D41872B355E970033460C /* UIView+ImageRendering.swift */; }; 8342DC102A8E441B00810258 /* AccessibilitySnapshotView+PillsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8342DC0F2A8E441B00810258 /* AccessibilitySnapshotView+PillsView.swift */; }; EF28C2D92A55CA3A00D78303 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EF4D51DF2A55A4A400FF0E46 /* Images.xcassets */; }; EF28C2DA2A55CA4600D78303 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = EF4D51DC2A55A4A400FF0E46 /* Localizable.strings */; }; @@ -34,6 +36,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 3D9D41862B355E970033460C /* HitTargetSnapshotView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HitTargetSnapshotView.swift; sourceTree = ""; }; + 3D9D41872B355E970033460C /* UIView+ImageRendering.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+ImageRendering.swift"; sourceTree = ""; }; 8342DC0F2A8E441B00810258 /* AccessibilitySnapshotView+PillsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AccessibilitySnapshotView+PillsView.swift"; sourceTree = ""; }; EF28C2D52A55CA2700D78303 /* AccessibilitySnapshot.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AccessibilitySnapshot.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; EF4D51D52A55A4A400FF0E46 /* UIView+InvertColorsSnapshotting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+InvertColorsSnapshotting.swift"; sourceTree = ""; }; @@ -102,6 +106,8 @@ EF4D51D42A55A4A400FF0E46 /* Classes */ = { isa = PBXGroup; children = ( + 3D9D41862B355E970033460C /* HitTargetSnapshotView.swift */, + 3D9D41872B355E970033460C /* UIView+ImageRendering.swift */, 8342DC0F2A8E441B00810258 /* AccessibilitySnapshotView+PillsView.swift */, EF4D51D52A55A4A400FF0E46 /* UIView+InvertColorsSnapshotting.swift */, EF4D51D62A55A4A400FF0E46 /* UIAccessibility+SnapshotAdditions.swift */, @@ -279,10 +285,12 @@ buildActionMask = 2147483647; files = ( EF4D51FF2A55A68500FF0E46 /* AccessibilityHierarchyParser.swift in Sources */, + 3D9D41892B355E970033460C /* UIView+ImageRendering.swift in Sources */, EF4D52012A55A68500FF0E46 /* UIView+InvertColorsSnapshotting.swift in Sources */, EF4D51FE2A55A68500FF0E46 /* UIAccessibility+SnapshotAdditions.swift in Sources */, EF4D51FD2A55A68500FF0E46 /* String+Localization.swift in Sources */, 8342DC102A8E441B00810258 /* AccessibilitySnapshotView+PillsView.swift in Sources */, + 3D9D41882B355E970033460C /* HitTargetSnapshotView.swift in Sources */, EF4D52002A55A68500FF0E46 /* UIApplication+FirstKeyWindow.swift in Sources */, EF4D52042A55A69A00FF0E46 /* ASAccessibilityEnabler.m in Sources */, EF4D52022A55A68500FF0E46 /* AccessibilitySnapshotView.swift in Sources */,