├── .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 |
--------------------------------------------------------------------------------