├── .gitignore ├── LICENSE ├── Media └── snapshot.jpeg ├── Package.swift ├── README.md └── Sources └── SwiftSnapshotTesting ├── Encoders └── EuclideanDistance.swift ├── Extensions ├── CoreGraphics │ ├── CGPoint+Extensions.swift │ ├── CGSize+Extensions.swift │ └── CoreGraphics+Extensions.swift ├── Foundation │ └── Data+Compression.swift ├── Metal │ └── MTLSize+Extensions.swift ├── Swift │ └── String+Extensions.swift └── XCTest │ └── XCUIApplication+Extensions.swift ├── Shaders ├── Macros.h └── Shaders.metal └── SnapshotTestCase.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | *.DS_Store 92 | .swiftpm 93 | Package.resolved 94 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Eugene Bokhan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Media/snapshot.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eugenebokhan/swift-snapshot-testing/b2ecbe0e6e25c9582ffba6a7474cd9bf50a6624a/Media/snapshot.jpeg -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SwiftSnapshotTesting", 7 | platforms: [ 8 | .iOS(.v12), 9 | .macOS(.v10_14) 10 | ], 11 | products: [ 12 | .library(name: "SwiftSnapshotTesting", 13 | targets: ["SwiftSnapshotTesting"]), 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/s1ddok/Alloy.git", 17 | .upToNextMajor(from: "0.17.0")), 18 | .package(url: "https://github.com/devicekit/DeviceKit.git", 19 | .upToNextMajor(from: "4.2.1")), 20 | .package(url: "https://github.com/eugenebokhan/ResourcesBridge.git", 21 | .upToNextMajor(from: "0.0.4")) 22 | ], 23 | targets: [ 24 | .target(name: "SwiftSnapshotTesting", 25 | dependencies: [ 26 | "Alloy", 27 | "DeviceKit", 28 | "ResourcesBridge" 29 | ], 30 | resources: [.process("Shaders/Shaders.metal")]) 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftSnapshotTesting 2 | 3 |

4 | 5 |

6 | 7 | This project's purpose is to simplify UI testing on iOS. 8 | 9 | `SwiftSnapshotTesting` helps to check application's UI with a few lines of code. No need to manually manage reference images any more. 10 | 11 | This framework is able to: 12 | * take a screenshot of a full screen, screen without status bar or any `XCUIElement` individually 13 | * record the screenshot on your Mac 14 | * compare and highlight the difference between new screenshots and previously recorded ones using [`Metal`](https://developer.apple.com/metal/) 15 | 16 | Internally `SwiftSnapshotTesting` operates with [`MTLTextures`](https://developer.apple.com/documentation/metal/mtltexture) during the snapshot comparison. Also it uses [`Resources Bridge Monitor`](ResourcesBridgeMonitor/) app to read and write files on Mac. 17 | 18 | ⚠️ Currently this project is in early alfa stage and it's a subject for improvements. 19 | 20 | ## Requirements 21 | 22 | * Swift `5.2` 23 | * iOS `11.0` 24 | 25 | ## Install via [SwiftPM](https://swift.org/package-manager/) 26 | 27 | ```swift 28 | .package(url: "https://github.com/eugenebokhan/SwiftSnapshotTesting.git", 29 | .upToNextMinor(from: "0.1.6")) 30 | ``` 31 | 32 | ## How To Use 33 | 34 | * Create a subclass of `SnapshotTestCase` 35 | 36 | ```Swift 37 | class MyCoolUITest: SnapshotTestCase { ... 38 | ``` 39 | 40 | * Choose folder on your Mac to store the reference snapshots by overriding `snapshotsReferencesFolder` variable 41 | 42 | ```Swift 43 | override var snapshotsReferencesFolder: String { 44 | "/Path-To-Snapshots-Folder/" 45 | } 46 | ``` 47 | 48 | 49 | * Assert UI element 50 | 51 | ```Swift 52 | assert(element: XCUIElement, 53 | testName: String, 54 | ignore rects: Set, 55 | configuration: Configuration, 56 | recording: Bool) throws 57 | ``` 58 | 59 | * `element` - element to compare. 60 | * `testName` - name of the test. It will be used in the name of the reference image file 61 | * `rects` - rects (possible subviews' frames) to ignore. 62 | * `configuration` current test configuration. 63 | * `recording` - by setting `true` this argument you will record the reference snapshot. By setting `false` you will compare the element with previously recorded snapshot. 64 | 65 | 66 | * Assert screenshot 67 | 68 | ```Swift 69 | assert(screenshot: XCUIScreenshot, 70 | testName: String, 71 | ignore ignorables: Set, 72 | configuration: Configuration, 73 | recording: Bool) throws 74 | ``` 75 | 76 | * `screenshot` - screenshot to test. 77 | * `ignorables` - UI elements to ignore. `Ignorable` can be `XCUIElement`, custom `CGRect` or predefined `.statusBar`. 78 | 79 | ## Info.plist configuration 80 | 81 | In order for `SwiftSnapshotTesting` to work when running on iOS 14, you will have to include two keys in your app's Info.plist file. 82 | The keys are `Privacy - Local Network Usage Description` (`NSLocalNetworkUsageDescription`) and `Bonjour services` (`NSBonjourServices`). 83 | For the privacy key, include a human-readable description of what benefit the user gets by allowing your app to access devices on the local network. 84 | The Bonjour services key is an array of service types that your app will browse for. For `SwiftSnapshotTesting`, he value of this key should be `_ResourcesBridge._tcp`. 85 | 86 | **If you do not configure the above keys properly, then `SwiftSnapshotTesting` won't work on real devices.** 87 | 88 | # XCTAttachment 89 | 90 | After each assertion test `SnapshotTestCase` provides an attachment containing per-pixel L2 distance between snapshot and the corresponding reference and `MTLTexture` with highlighted difference. You are able to look at the diff using [`MTLTextureViewer`](https://github.com/eugenebokhan/MTLTextureViewer/). 91 | 92 | # Example 93 | 94 | Your can find a small [example](https://github.com/eugenebokhan/ImageFlip/blob/master/ImageFlipUITests/ImageFlipUITests.swift) of usage of `SwiftSnapshotTesting` in the [`ImageFlip`](https://github.com/eugenebokhan/ImageFlip/) repo. 95 | 96 | # [License](LICENSE) 97 | 98 | MIT 99 | -------------------------------------------------------------------------------- /Sources/SwiftSnapshotTesting/Encoders/EuclideanDistance.swift: -------------------------------------------------------------------------------- 1 | import Alloy 2 | 3 | final class EuclideanDistance { 4 | 5 | // MARK: - Properties 6 | 7 | let pipelineState: MTLComputePipelineState 8 | 9 | // MARK: - Life Cycle 10 | 11 | convenience init(context: MTLContext, 12 | scalarType: MTLPixelFormat.ScalarType = .half) throws { 13 | try self.init(library: context.library(for: Self.self), 14 | scalarType: scalarType) 15 | } 16 | 17 | init(library: MTLLibrary, 18 | scalarType: MTLPixelFormat.ScalarType = .half) throws { 19 | let functionName = Self.functionName + "_" + scalarType.rawValue 20 | self.pipelineState = try library.computePipelineState(function: functionName) 21 | } 22 | 23 | // MARK: - Encode 24 | 25 | func callAsFunction(textureOne: MTLTexture, 26 | textureTwo: MTLTexture, 27 | threshold: Float, 28 | resultBuffer: MTLBuffer, 29 | in commandBuffer: MTLCommandBuffer) { 30 | self.encode(textureOne: textureOne, 31 | textureTwo: textureTwo, 32 | threshold: threshold, 33 | resultBuffer: resultBuffer, 34 | in: commandBuffer) 35 | } 36 | 37 | func callAsFunction(textureOne: MTLTexture, 38 | textureTwo: MTLTexture, 39 | threshold: Float, 40 | resultBuffer: MTLBuffer, 41 | using encoder: MTLComputeCommandEncoder) { 42 | self.encode(textureOne: textureOne, 43 | textureTwo: textureTwo, 44 | threshold: threshold, 45 | resultBuffer: resultBuffer, 46 | using: encoder) 47 | } 48 | 49 | func encode(textureOne: MTLTexture, 50 | textureTwo: MTLTexture, 51 | threshold: Float, 52 | resultBuffer: MTLBuffer, 53 | in commandBuffer: MTLCommandBuffer) { 54 | commandBuffer.compute { encoder in 55 | encoder.label = "Euclidean Distance" 56 | self.encode(textureOne: textureOne, 57 | textureTwo: textureTwo, 58 | threshold: threshold, 59 | resultBuffer: resultBuffer, 60 | using: encoder) 61 | } 62 | } 63 | 64 | func encode(textureOne: MTLTexture, 65 | textureTwo: MTLTexture, 66 | threshold: Float, 67 | resultBuffer: MTLBuffer, 68 | using encoder: MTLComputeCommandEncoder) { 69 | let threadgroupSize = MTLSize(width: 8, height: 8, depth: 1).clamped(to: textureOne.size) 70 | let blockSizeWidth = (textureOne.width + threadgroupSize.width - 1) 71 | / threadgroupSize.width 72 | let blockSizeHeight = (textureOne.height + threadgroupSize.height - 1) 73 | / threadgroupSize.height 74 | let blockSize = BlockSize(width: blockSizeWidth, 75 | height: blockSizeHeight) 76 | 77 | encoder.setTextures(textureOne, 78 | textureTwo) 79 | encoder.setValue(blockSize, at: 0) 80 | encoder.setValue(threshold, at: 1) 81 | encoder.setBuffer(resultBuffer, 82 | offset: 0, 83 | index: 2) 84 | 85 | let threadgroupMemoryLength = threadgroupSize.width 86 | * threadgroupSize.height 87 | * 4 88 | * MemoryLayout.stride 89 | 90 | encoder.setThreadgroupMemoryLength(threadgroupMemoryLength, 91 | index: 0) 92 | encoder.dispatch2d(state: self.pipelineState, 93 | covering: .one, 94 | threadgroupSize: threadgroupSize) 95 | } 96 | 97 | static let functionName = "euclideanDistance" 98 | } 99 | -------------------------------------------------------------------------------- /Sources/SwiftSnapshotTesting/Extensions/CoreGraphics/CGPoint+Extensions.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | 3 | extension CGPoint: Hashable { 4 | public func hash(into hasher: inout Hasher) { 5 | hasher.combine(self.x) 6 | hasher.combine(self.y) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/SwiftSnapshotTesting/Extensions/CoreGraphics/CGSize+Extensions.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | 3 | extension CGSize: Hashable { 4 | public func hash(into hasher: inout Hasher) { 5 | hasher.combine(self.width) 6 | hasher.combine(self.height) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/SwiftSnapshotTesting/Extensions/CoreGraphics/CoreGraphics+Extensions.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | 3 | extension CGRect { 4 | func normalized(reference: CGRect) -> CGRect { 5 | return .init(x: min(self.origin.x / reference.width, 1), 6 | y: min(self.origin.y / reference.height, 1), 7 | width: min(self.size.width / reference.width, 1), 8 | height: min(self.size.height / reference.height, 1)) 9 | } 10 | } 11 | 12 | extension CGRect: Hashable { 13 | public func hash(into hasher: inout Hasher) { 14 | hasher.combine(self.origin) 15 | hasher.combine(self.size) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/SwiftSnapshotTesting/Extensions/Foundation/Data+Compression.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Compression 3 | 4 | extension Data { 5 | 6 | enum Error: Swift.Error { 7 | case compressionError 8 | } 9 | 10 | // Always returns the compressed version of self, even if it's 11 | // bigger than self. 12 | func compressed() -> Data { 13 | guard !isEmpty else { return self } 14 | // very small amounts of data become larger when compressed; 15 | // setting a floor of 10 seems to accomodate that properly. 16 | var targetBufferSize = Swift.max(count / 8, 10) 17 | while true { 18 | var result = Data(count: targetBufferSize) 19 | let resultCount = self.compress(into: &result) 20 | if resultCount == 0 { 21 | targetBufferSize *= 2 22 | continue 23 | } 24 | return result.prefix(resultCount) 25 | } 26 | } 27 | 28 | private func compress(into dest: inout Data) -> Int { 29 | let destSize = dest.count 30 | let srcSize = count 31 | 32 | return self.withUnsafeBytes { source in 33 | return dest.withUnsafeMutableBytes { dest in 34 | return compression_encode_buffer( 35 | dest.baseAddress!.assumingMemoryBound(to: UInt8.self), 36 | destSize, 37 | source.baseAddress!.assumingMemoryBound(to: UInt8.self), 38 | srcSize, 39 | nil, 40 | COMPRESSION_LZFSE 41 | ) 42 | } 43 | } 44 | } 45 | 46 | func decompressed() throws -> Data { 47 | guard !isEmpty else { return self } 48 | var targetBufferSize = count * 8 49 | while true { 50 | var result = Data(count: targetBufferSize) 51 | let resultCount = self.decompress(into: &result) 52 | if resultCount == 0 { throw Error.compressionError } 53 | if resultCount == targetBufferSize { 54 | targetBufferSize *= 2 55 | continue 56 | } 57 | return result.prefix(resultCount) 58 | } 59 | } 60 | 61 | private func decompress(into dest: inout Data) -> Int { 62 | let destSize = dest.count 63 | let srcSize = count 64 | 65 | return self.withUnsafeBytes { source in 66 | return dest.withUnsafeMutableBytes { dest in 67 | return compression_decode_buffer( 68 | dest.baseAddress!.assumingMemoryBound(to: UInt8.self), 69 | destSize, 70 | source.baseAddress!.assumingMemoryBound(to: UInt8.self), 71 | srcSize, 72 | nil, 73 | COMPRESSION_LZFSE 74 | ) 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/SwiftSnapshotTesting/Extensions/Metal/MTLSize+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Metal 2 | 3 | extension MTLSize: Equatable { 4 | public static func == (lhs: MTLSize, rhs: MTLSize) -> Bool { 5 | return lhs.width == rhs.width 6 | && lhs.height == rhs.height 7 | && lhs.depth == rhs.depth 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/SwiftSnapshotTesting/Extensions/Swift/String+Extensions.swift: -------------------------------------------------------------------------------- 1 | extension String { 2 | var sanitizedPathComponent: String { 3 | self.replacingOccurrences(of: "\\W+", with: "-", options: .regularExpression) 4 | .replacingOccurrences(of: "^-|-$", with: "", options: .regularExpression) 5 | } 6 | init(_ staticString: StaticString) { 7 | self = staticString.withUTF8Buffer { 8 | String(decoding: $0, as: UTF8.self) 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/SwiftSnapshotTesting/Extensions/XCTest/XCUIApplication+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | extension XCUIApplication { 5 | 6 | static let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") 7 | 8 | } 9 | -------------------------------------------------------------------------------- /Sources/SwiftSnapshotTesting/Shaders/Macros.h: -------------------------------------------------------------------------------- 1 | #ifndef Macros_h 2 | #define Macros_h 3 | 4 | // MARK: - Generate Template Kernels 5 | 6 | #define generateKernel(functionName, scalarType, outerArgs, innerArgs) \ 7 | kernel void functionName##_##scalarType outerArgs { \ 8 | functionName innerArgs; \ 9 | } 10 | 11 | #define generateKernels(functionName) \ 12 | generateKernel(functionName, float, outerArguments(float), innerArguments); \ 13 | generateKernel(functionName, half, outerArguments(half), innerArguments); \ 14 | generateKernel(functionName, int, outerArguments(int), innerArguments); \ 15 | generateKernel(functionName, short, outerArguments(short), innerArguments); \ 16 | generateKernel(functionName, uint, outerArguments(uint), innerArguments); \ 17 | generateKernel(functionName, ushort, outerArguments(ushort), innerArguments); 18 | 19 | // MARK: - Check Position 20 | 21 | #define checkPosition(position, textureSize, deviceSupportsNonuniformThreadgroups) \ 22 | if (!deviceSupportsNonuniformThreadgroups) { \ 23 | if (position.x >= textureSize.x || position.y >= textureSize.y) { \ 24 | return; \ 25 | } \ 26 | } 27 | 28 | 29 | #endif /* Macros_h */ 30 | -------------------------------------------------------------------------------- /Sources/SwiftSnapshotTesting/Shaders/Shaders.metal: -------------------------------------------------------------------------------- 1 | #include 2 | #include "Macros.h" 3 | 4 | using namespace metal; 5 | 6 | constant bool deviceSupportsNonuniformThreadgroups [[ function_constant(0) ]]; 7 | 8 | struct BlockSize { 9 | ushort width; 10 | ushort height; 11 | }; 12 | 13 | // MARK: - Euclidean Distance 14 | 15 | float euclideanDistance(float4 firstValue, 16 | float4 secondValue, 17 | float threshold) { 18 | const float4 diff = firstValue - secondValue; 19 | if (abs(diff.r) > threshold || 20 | abs(diff.g) > threshold || 21 | abs(diff.b) > threshold || 22 | abs(diff.a) > threshold) { 23 | return sqrt(dot(pow(diff, 2.0f), 1.0f)); 24 | } else { 25 | return 0.0f; 26 | } 27 | } 28 | 29 | template 30 | void euclideanDistance(texture2d textureOne, 31 | texture2d textureTwo, 32 | constant BlockSize& inputBlockSize, 33 | constant float& threshold, 34 | device float& result, 35 | threadgroup float* sharedMemory, 36 | const ushort index, 37 | const ushort2 position, 38 | const ushort2 threadsPerThreadgroup) { 39 | const ushort2 textureSize = ushort2(textureOne.get_width(), 40 | textureOne.get_height()); 41 | 42 | ushort2 originalBlockSize = ushort2(inputBlockSize.width, 43 | inputBlockSize.height); 44 | const ushort2 blockStartPosition = position * originalBlockSize; 45 | 46 | ushort2 blockSize = originalBlockSize; 47 | if (position.x == threadsPerThreadgroup.x || position.y == threadsPerThreadgroup.y) { 48 | const ushort2 readTerritory = blockStartPosition + originalBlockSize; 49 | blockSize = originalBlockSize - (readTerritory - textureSize); 50 | } 51 | 52 | float euclideanDistanceSumInBlock = 0.0f; 53 | 54 | for (ushort x = 0; x < blockSize.x; x++) { 55 | for (ushort y = 0; y < blockSize.y; y++) { 56 | const ushort2 readPosition = blockStartPosition + ushort2(x, y); 57 | const float4 textureOneValue = float4(textureOne.read(readPosition)); 58 | const float4 textureTwoValue = float4(textureTwo.read(readPosition)); 59 | euclideanDistanceSumInBlock += euclideanDistance(textureOneValue, 60 | textureTwoValue, 61 | threshold); 62 | } 63 | } 64 | 65 | sharedMemory[index] = euclideanDistanceSumInBlock; 66 | 67 | threadgroup_barrier(mem_flags::mem_threadgroup); 68 | 69 | if (index == 0) { 70 | float totalEuclideanDistanceSum = sharedMemory[0]; 71 | const ushort threadsInThreadgroup = threadsPerThreadgroup.x * threadsPerThreadgroup.y; 72 | for (ushort i = 1; i < threadsInThreadgroup; i++) { 73 | totalEuclideanDistanceSum += sharedMemory[i]; 74 | } 75 | 76 | result = totalEuclideanDistanceSum; 77 | } 78 | 79 | } 80 | 81 | #define outerArguments(T) \ 82 | (texture2d textureOne [[ texture(0) ]], \ 83 | texture2d textureTwo [[ texture(1) ]], \ 84 | constant BlockSize& inputBlockSize [[ buffer(0) ]], \ 85 | constant float& threshold [[ buffer(1) ]], \ 86 | device float& result [[ buffer(2) ]], \ 87 | threadgroup float* sharedMemory [[ threadgroup(0) ]], \ 88 | const ushort index [[ thread_index_in_threadgroup ]], \ 89 | const ushort2 position [[ thread_position_in_grid ]], \ 90 | const ushort2 threadsPerThreadgroup [[ threads_per_threadgroup ]]) 91 | 92 | #define innerArguments \ 93 | (textureOne, \ 94 | textureTwo, \ 95 | inputBlockSize, \ 96 | threshold, \ 97 | result, \ 98 | sharedMemory, \ 99 | index, \ 100 | position, \ 101 | threadsPerThreadgroup) 102 | 103 | generateKernels(euclideanDistance) 104 | 105 | #undef outerArguments 106 | #undef innerArguments 107 | -------------------------------------------------------------------------------- /Sources/SwiftSnapshotTesting/SnapshotTestCase.swift: -------------------------------------------------------------------------------- 1 | import Alloy 2 | import DeviceKit 3 | #if !targetEnvironment(simulator) 4 | import ResourcesBridge 5 | #endif 6 | import XCTest 7 | 8 | open class SnapshotTestCase: XCTestCase { 9 | 10 | /// Snapshot configuration. 11 | public struct Configuration { 12 | 13 | /// The way of comparing snapshots with reference images. 14 | public enum ComparisonPolicy { 15 | /// Per-pixel L2 distance. 16 | case eucledean(Float) 17 | 18 | fileprivate var assertionThreshold: Float { 19 | switch self { 20 | case .eucledean(_): return 0 21 | } 22 | } 23 | fileprivate var threshold: Float { 24 | switch self { 25 | case let .eucledean(threshold): return threshold 26 | } 27 | } 28 | } 29 | 30 | /// The way of comparing snapshots with reference images. 31 | public var comparisonPolicy: ComparisonPolicy 32 | /// The color of the highligted difference in the test attachment. 33 | public var diffHighlightColor: SIMD4 34 | 35 | public init(comparisonPolicy: ComparisonPolicy = .eucledean(0.0), 36 | diffHighlightColor: SIMD4 = .init(1, 0, 0, 1)) { 37 | self.comparisonPolicy = comparisonPolicy 38 | self.diffHighlightColor = diffHighlightColor 39 | } 40 | 41 | public static let `default` = Configuration() 42 | } 43 | 44 | /// Element to ignore while asserting snapshot. 45 | public enum Ignorable: Hashable { 46 | case element(XCUIElement) 47 | case rect(CGRect) 48 | case statusBar 49 | 50 | fileprivate var ignoringFrame: CGRect { 51 | switch self { 52 | case let .element(element): return element.frame 53 | case let .rect(rect): return rect 54 | case .statusBar: return XCUIApplication.springboard.statusBars.firstMatch.frame 55 | } 56 | } 57 | } 58 | 59 | public enum Error: Swift.Error { 60 | case resourceSendingFailed 61 | case cgImageCreeationFailed 62 | case recordModeIsOn 63 | 64 | var localizedDescription: String { 65 | switch self { 66 | case .resourceSendingFailed: 67 | return "Failed to send reference snapshot." 68 | case .cgImageCreeationFailed: 69 | return "Failed to create a `CGImage` of current snapshot." 70 | case .recordModeIsOn: 71 | return "Recording mode is on." 72 | } 73 | } 74 | } 75 | 76 | // MARK: - Public Properties 77 | 78 | /// The directory in Mac to save reference snapshots. 79 | open var snapshotsReferencesFolder: String { "/" } 80 | 81 | // MARK: - Private Properties 82 | 83 | private let context = try! MTLContext() 84 | private lazy var rendererRect = try! RectangleRenderer(context: self.context) 85 | private lazy var textureCopy = try! TextureCopy(context: self.context) 86 | private lazy var textureDifference = try! TextureDifferenceHighlight(context: self.context) 87 | private lazy var l2Distance = try! EuclideanDistance(context: self.context) 88 | private let encoder = JSONEncoder() 89 | private let decoder = JSONDecoder() 90 | #if !targetEnvironment(simulator) 91 | private let bridge = try! ResourcesBridge() 92 | #endif 93 | 94 | private lazy var rectsPassDescriptor: MTLRenderPassDescriptor = { 95 | let descriptor = MTLRenderPassDescriptor() 96 | descriptor.colorAttachments[0].loadAction = .load 97 | descriptor.colorAttachments[0].storeAction = .store 98 | return descriptor 99 | }() 100 | 101 | // MARK: - Public 102 | 103 | /// Default test name used to give a snapshpot a unique name. 104 | /// - Parameters: 105 | /// - funcName: Current test function. 106 | /// - line: The line of the assertion call. 107 | /// - Returns: String including function name, line and device description. 108 | public func testName(funcName: String = #function, 109 | line: Int = #line) -> String { 110 | return "\(funcName)-\(line)" 111 | } 112 | 113 | /// Test `XCUIElement`. 114 | /// - Parameters: 115 | /// - element: Element to test. 116 | /// - testName: Current test's name. 117 | /// - rects: Rects (possible subviews' frames) to ignore. 118 | /// - configuration: Current test configuration. 119 | /// - recording: Recording mode. 120 | /// - Throws: Error on test fail. 121 | public func assert(element: XCUIElement, 122 | testName: String, 123 | ignore rects: Set = [], 124 | configuration: Configuration = .default, 125 | recording: Bool = false) throws { 126 | XCTAssert(element.exists) 127 | XCTAssert(element.isHittable) 128 | 129 | let screenshot = XCUIApplication().screenshot() 130 | guard let cgImage = screenshot.image.cgImage 131 | else { throw Error.cgImageCreeationFailed } 132 | 133 | let appFrame = XCUIApplication().frame 134 | let elementFrame = element.frame 135 | 136 | let origin = MTLOrigin(x: .init(elementFrame.origin.x / appFrame.width * .init(cgImage.width)), 137 | y: .init(elementFrame.origin.y / appFrame.height * .init(cgImage.height)), 138 | z: .zero) 139 | let size = MTLSize(width: .init(elementFrame.size.width / appFrame.width * .init(cgImage.width)), 140 | height: .init(elementFrame.size.height / appFrame.height * .init(cgImage.height)), 141 | depth: 1) 142 | let region = MTLRegion(origin: origin, 143 | size: size) 144 | 145 | let screenTexture = try self.context.texture(from: cgImage) 146 | let elementTexture = try self.context.texture(width: region.size.width, 147 | height: region.size.height, 148 | pixelFormat: .bgra8Unorm, 149 | usage: [.shaderRead, .shaderWrite]) 150 | try self.context.scheduleAndWait { commandBuffer in 151 | self.textureCopy.copy(region: region, 152 | from: screenTexture, 153 | to: .zero, 154 | of: elementTexture, 155 | in: commandBuffer) 156 | } 157 | 158 | try self.assert(texture: elementTexture, 159 | testName: testName, 160 | ignore: rects, 161 | configuration: configuration, 162 | recording: recording) 163 | } 164 | 165 | /// Test `XCUIScreenshot`. 166 | /// - Parameters: 167 | /// - screenshot: Screenshot to test. 168 | /// - testName: Current test's name. 169 | /// - ignorables: UI elements to ignore. 170 | /// - configuration: Current test configurartion. 171 | /// - recording: Recording mode. 172 | /// - Throws: Error on test fail. 173 | public func assert(screenshot: XCUIScreenshot, 174 | testName: String, 175 | ignore ignorables: Set = [.statusBar], 176 | configuration: Configuration = .default, 177 | recording: Bool = false) throws { 178 | guard let cgImage = screenshot.image.cgImage 179 | else { throw Error.cgImageCreeationFailed } 180 | let screenTexture = try self.context.texture(from: cgImage, 181 | srgb: false) 182 | 183 | try self.assert(texture: screenTexture, 184 | testName: testName, 185 | ignore: .init(ignorables.map { $0.ignoringFrame }), 186 | configuration: configuration, 187 | recording: recording) 188 | } 189 | 190 | /// Test `MTLTexture`. 191 | /// 192 | /// This is a basic assertion function of this framework. Every element and screenshot is converted to texture and passed to this func. 193 | /// 194 | /// - Parameters: 195 | /// - texture: Texture to test. 196 | /// - testName: Current test's name. 197 | /// - rects: Rects to ignore. 198 | /// - configuration: Current test configurartion. 199 | /// - recording: Recording mode. 200 | /// - Throws: Error on test fail. 201 | public func assert(texture: MTLTexture, 202 | testName: String, 203 | ignore rects: Set = [], 204 | configuration: Configuration = .default, 205 | recording: Bool = false) throws { 206 | 207 | #if !targetEnvironment(simulator) 208 | try self.bridge.waitForConnection() 209 | #endif 210 | 211 | let fileExtension = ".compressedTexture" 212 | let referenceScreenshotPath = self.snapshotsReferencesFolder 213 | + "\(testName)-\(Device.current.description)".sanitizedPathComponent 214 | + fileExtension 215 | 216 | if !rects.isEmpty { 217 | let scale = UIScreen.main.scale 218 | self.rectsPassDescriptor.colorAttachments[0].texture = texture 219 | let referenceSize = CGSize(width: CGFloat(texture.width) / scale, 220 | height: CGFloat(texture.height) / scale) 221 | let referenceRect = CGRect(origin: .zero, size: referenceSize) 222 | self.rendererRect.color = .init(0, 0, 0, 1) 223 | try self.context.scheduleAndWait { commandBuffer in 224 | rects.forEach { rect in 225 | self.rendererRect.normalizedRect = rect.normalized(reference: referenceRect) 226 | self.rendererRect(renderPassDescriptor: self.rectsPassDescriptor, 227 | commandBuffer: commandBuffer) 228 | } 229 | } 230 | } 231 | 232 | if recording { 233 | let textureData = try self.encoder.encode(texture.codable()).compressed() 234 | 235 | #if targetEnvironment(simulator) 236 | let referenceURL = URL(fileURLWithPath: referenceScreenshotPath) 237 | let referenceFolder = referenceURL.deletingLastPathComponent() 238 | var isDirectory: ObjCBool = true 239 | if !FileManager.default.fileExists(atPath: referenceFolder.path, 240 | isDirectory: &isDirectory) { 241 | try FileManager.default.createDirectory(at: referenceFolder, 242 | withIntermediateDirectories: true, 243 | attributes: nil) 244 | } 245 | 246 | if FileManager.default.fileExists(atPath: referenceScreenshotPath) { 247 | try FileManager.default.removeItem(atPath: referenceScreenshotPath) 248 | } 249 | 250 | try textureData.write(to: referenceURL) 251 | #else 252 | try self.bridge.writeResource(textureData, 253 | at: referenceScreenshotPath) { progress in 254 | #if DEBUG 255 | print("Sending reference: \(progress)") 256 | #endif 257 | } 258 | #endif 259 | 260 | XCTFail(""" 261 | \(Error.recordModeIsOn.localizedDescription) 262 | Turn recording mode off and re-run the \(testName) test with the newly-recorded reference. 263 | """) 264 | } else { 265 | let data: Data 266 | #if targetEnvironment(simulator) 267 | data = try Data(contentsOf: URL(fileURLWithPath: referenceScreenshotPath)) 268 | #else 269 | data = try self.bridge.readResource(from: referenceScreenshotPath) { progress in 270 | #if DEBUG 271 | print("Receiving: \(progress)") 272 | #endif 273 | } 274 | #endif 275 | 276 | let referenceTexture = try self.decoder 277 | .decode(MTLTextureCodableBox.self, 278 | from: data.decompressed()) 279 | .texture(device: self.context.device) 280 | 281 | XCTAssertEqual(texture.size, referenceTexture.size) 282 | 283 | let differenceTexture = try texture.matchingTexture() 284 | 285 | let distanceResultBuffer = try self.context.buffer(for: Float.self, 286 | options: .storageModeShared) 287 | 288 | try self.context.scheduleAndWait { commandBuffer in 289 | self.l2Distance(textureOne: texture, 290 | textureTwo: referenceTexture, 291 | threshold: configuration.comparisonPolicy.threshold, 292 | resultBuffer: distanceResultBuffer, 293 | in: commandBuffer) 294 | self.textureDifference(sourceOne: texture, 295 | sourceTwo: referenceTexture, 296 | destination: differenceTexture, 297 | color: configuration.diffHighlightColor, 298 | threshold: configuration.comparisonPolicy.threshold, 299 | in: commandBuffer) 300 | } 301 | 302 | let distance = distanceResultBuffer.pointer(of: Float.self)?.pointee ?? .zero 303 | let differenceImage = try differenceTexture.image() 304 | 305 | let distanceAttachment = XCTAttachment(string: "L2 distance: \(distance)") 306 | distanceAttachment.name = "L2 distance of snapshot \(testName)" 307 | distanceAttachment.lifetime = .keepAlways 308 | self.add(distanceAttachment) 309 | 310 | let differenceAttachment = XCTAttachment(image: differenceImage) 311 | differenceAttachment.name = "Difference of snapshot \(testName)" 312 | differenceAttachment.lifetime = .keepAlways 313 | self.add(differenceAttachment) 314 | 315 | XCTAssertLessThanOrEqual(distance, configuration.comparisonPolicy.assertionThreshold) 316 | } 317 | } 318 | } 319 | --------------------------------------------------------------------------------