diff --git a/Package.resolved b/Package.resolved index cc31d4d..6feec3f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -12,9 +12,9 @@ { "identity" : "prefire", "kind" : "remoteSourceControl", - "location" : "https://github.com/BarredEwe/Prefire/", + "location" : "https://github.com/stefanceriu/Prefire", "state" : { - "revision" : "996a192263f8056375eb6809e0ff91f1ace40877" + "revision" : "982b207efafb9c6ff0c7beae38211c37b918d2b9" } }, { diff --git a/Package.swift b/Package.swift index 99e63c4..e4de95d 100644 --- a/Package.swift +++ b/Package.swift @@ -12,7 +12,7 @@ let package = Package( .package(url: "https://github.com/element-hq/compound-design-tokens.git", exact: "1.2.0"), .package(url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.9.0"), .package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols.git", from: "4.1.1"), - .package(url: "https://github.com/BarredEwe/Prefire", revision: "996a192263f8056375eb6809e0ff91f1ace40877"), + .package(url: "https://github.com/stefanceriu/Prefire", revision: "982b207efafb9c6ff0c7beae38211c37b918d2b9"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.13.0") ], targets: [ diff --git a/Sources/Compound/BaseStyles/CompoundButtonStyle.swift b/Sources/Compound/BaseStyles/CompoundButtonStyle.swift index 9c6cf8a..a4e9701 100644 --- a/Sources/Compound/BaseStyles/CompoundButtonStyle.swift +++ b/Sources/Compound/BaseStyles/CompoundButtonStyle.swift @@ -155,7 +155,6 @@ public struct CompoundButtonStyle_Previews: PreviewProvider, PrefireProvider { Header(title: "Plain") } } - .snapshotWithDefaultPrecision() } public static func states(_ size: CompoundButtonStyle.Size) -> some View { diff --git a/Sources/Compound/BaseStyles/CompoundToggleStyle.swift b/Sources/Compound/BaseStyles/CompoundToggleStyle.swift index 2faf6f6..3850928 100644 --- a/Sources/Compound/BaseStyles/CompoundToggleStyle.swift +++ b/Sources/Compound/BaseStyles/CompoundToggleStyle.swift @@ -44,7 +44,6 @@ public struct CompoundToggleStyle_Previews: PreviewProvider, PrefireProvider { states } .padding(32) - .snapshotWithDefaultPrecision() } @ViewBuilder diff --git a/Sources/Compound/Extensions/View.swift b/Sources/Compound/Extensions/View.swift deleted file mode 100644 index 795f3c1..0000000 --- a/Sources/Compound/Extensions/View.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// Copyright 2024 New Vector Ltd -// -// 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 SwiftUI -import Prefire - -extension View { - @inlinable - func snapshotWithDefaultPrecision(delay: TimeInterval = .zero) -> some View { - preference(key: DelayPreferenceKey.self, value: delay) - .preference(key: PrecisionPreferenceKey.self, value: 1.0) - .preference(key: PerceptualPrecisionPreferenceKey.self, value:0.98) - } -} diff --git a/Sources/Compound/Icons/CompoundIcon.swift b/Sources/Compound/Icons/CompoundIcon.swift index d03b222..b443e2f 100644 --- a/Sources/Compound/Icons/CompoundIcon.swift +++ b/Sources/Compound/Icons/CompoundIcon.swift @@ -168,18 +168,14 @@ struct CompoundIcon_Previews: PreviewProvider, PrefireProvider { static var previews: some View { form .previewDisplayName("Form") - .snapshotWithDefaultPrecision() buttons .padding(8) .previewLayout(.sizeThatFits) .previewDisplayName("Buttons") - .snapshotWithDefaultPrecision() accessibilityIcons .previewDisplayName("Accessibility Icons Only") - .snapshotWithDefaultPrecision() accessibilityLabels .previewDisplayName("Accessibility Labels") - .snapshotWithDefaultPrecision() } static var accessibilityIcons: some View { diff --git a/Sources/Compound/List/ListInlinePicker.swift b/Sources/Compound/List/ListInlinePicker.swift index 31181c2..4e8d7a5 100644 --- a/Sources/Compound/List/ListInlinePicker.swift +++ b/Sources/Compound/List/ListInlinePicker.swift @@ -66,7 +66,6 @@ struct ListInlinePicker_Previews: PreviewProvider, PrefireProvider { .labelsHidden() } } - .snapshotWithDefaultPrecision() } } } diff --git a/Sources/Compound/List/ListRow.swift b/Sources/Compound/List/ListRow.swift index 98cc53a..ece6e9a 100644 --- a/Sources/Compound/List/ListRow.swift +++ b/Sources/Compound/List/ListRow.swift @@ -256,7 +256,6 @@ public struct ListRow_Previews: PreviewProvider, PrefireProvider { othersSection } .compoundList() - .snapshotWithDefaultPrecision() } static var labels: some View { @@ -434,6 +433,5 @@ struct ListRowLoadingSelection_Previews: PreviewProvider, PrefireProvider { kind: .selection(isSelected: false) { }) } .compoundList() - .snapshotWithDefaultPrecision() } } diff --git a/Sources/Compound/List/ListRowAccessory.swift b/Sources/Compound/List/ListRowAccessory.swift index 5307ed0..28a5bde 100644 --- a/Sources/Compound/List/ListRowAccessory.swift +++ b/Sources/Compound/List/ListRowAccessory.swift @@ -112,7 +112,6 @@ struct ListRowAccessory_Previews: PreviewProvider, PrefireProvider { .disabled(true) } .previewDisplayName("Accessories") - .snapshotWithDefaultPrecision() } static func row(title: String, accessory: ListRowAccessory) -> some View { @@ -134,6 +133,5 @@ struct MultiUnselectedAccessory_Previews: PreviewProvider, PrefireProvider { } } .previewDisplayName("Fake circle icon") - .snapshotWithDefaultPrecision() } } diff --git a/Sources/Compound/List/ListRowButtonStyle.swift b/Sources/Compound/List/ListRowButtonStyle.swift index 35f33a7..8f99e03 100644 --- a/Sources/Compound/List/ListRowButtonStyle.swift +++ b/Sources/Compound/List/ListRowButtonStyle.swift @@ -72,6 +72,5 @@ public struct ListRowButtonStyle_Previews: PreviewProvider, PrefireProvider { .listRowInsets(EdgeInsets()) } .compoundList() - .snapshotWithDefaultPrecision() } } diff --git a/Sources/Compound/List/ListRowLabel.swift b/Sources/Compound/List/ListRowLabel.swift index 01e9bc1..e37dc4a 100644 --- a/Sources/Compound/List/ListRowLabel.swift +++ b/Sources/Compound/List/ListRowLabel.swift @@ -356,6 +356,5 @@ struct ListRowLabel_Previews: PreviewProvider, PrefireProvider { } } .compoundList() - .snapshotWithDefaultPrecision() } } diff --git a/Sources/Compound/List/ListRowTrailingSection.swift b/Sources/Compound/List/ListRowTrailingSection.swift index 84c805b..9f5d6c4 100644 --- a/Sources/Compound/List/ListRowTrailingSection.swift +++ b/Sources/Compound/List/ListRowTrailingSection.swift @@ -90,7 +90,6 @@ struct ListRowTrailingSection_Previews: PreviewProvider, PrefireProvider { details withAccessory } - .snapshotWithDefaultPrecision() } static var details: some View { diff --git a/Sources/Compound/List/ListStyles.swift b/Sources/Compound/List/ListStyles.swift index f07934d..ed84758 100644 --- a/Sources/Compound/List/ListStyles.swift +++ b/Sources/Compound/List/ListStyles.swift @@ -80,7 +80,6 @@ struct ListTextStyles_Previews: PreviewProvider, PrefireProvider { } .compoundList() .previewDisplayName("Form") - .snapshotWithDefaultPrecision() List { Section { @@ -96,6 +95,5 @@ struct ListTextStyles_Previews: PreviewProvider, PrefireProvider { } .compoundList() .previewDisplayName("List") - .snapshotWithDefaultPrecision() } } diff --git a/Sources/Compound/Text Field Styles/SearchFieldStyle.swift b/Sources/Compound/Text Field Styles/SearchFieldStyle.swift index 0b125b0..e271604 100644 --- a/Sources/Compound/Text Field Styles/SearchFieldStyle.swift +++ b/Sources/Compound/Text Field Styles/SearchFieldStyle.swift @@ -78,7 +78,6 @@ struct SearchStyle_Previews: PreviewProvider, PrefireProvider { } .tint(.compound.textActionPrimary) .previewDisplayName("List") - .snapshotWithDefaultPrecision() NavigationStack { Form { @@ -104,6 +103,5 @@ struct SearchStyle_Previews: PreviewProvider, PrefireProvider { } .tint(.compound.textActionPrimary) .previewDisplayName("Form") - .snapshotWithDefaultPrecision() } } diff --git a/Tests/.prefire.yml b/Tests/.prefire.yml deleted file mode 100644 index 830666a..0000000 --- a/Tests/.prefire.yml +++ /dev/null @@ -1,3 +0,0 @@ -test_configuration: - - simulator_device: "iPhone15" - - required_os: 17 diff --git a/Tests/CompoundTests/.prefire.yml b/Tests/CompoundTests/.prefire.yml new file mode 100644 index 0000000..3f39cdc --- /dev/null +++ b/Tests/CompoundTests/.prefire.yml @@ -0,0 +1,7 @@ +test_configuration: + - simulator_device: "iPhone15" + - required_os: 17 + - template_file_path: PreviewTests.stencil + - snapshot_devices: + - iPhone 15 + - iPad diff --git a/Tests/CompoundTests/PreviewTests.stencil b/Tests/CompoundTests/PreviewTests.stencil new file mode 100644 index 0000000..f53cca9 --- /dev/null +++ b/Tests/CompoundTests/PreviewTests.stencil @@ -0,0 +1,258 @@ +// swiftlint:disable all +// swiftformat:disable all + +import XCTest +import SwiftUI +import Prefire +@testable import SnapshotTesting +#if canImport(AccessibilitySnapshot) + import AccessibilitySnapshot +#endif +{% if argument.mainTarget %} +@testable import {{ argument.mainTarget }} +{% endif %} +{% for import in argument.imports %} +{% if import != "last" %} +import {{ import }} +{% endif %} +{% endfor %} +{% for import in argument.testableImports %} +{% if import != "last" %} +@testable import {{ import }} +{% endif %} +{% endfor %} + +class PreviewTests: XCTestCase { + private let deviceConfig: ViewImageConfig = .iPhoneX + private var simulatorDevice: String?{% if argument.simulatorDevice %} = "{{ argument.simulatorDevice|default:nil }}"{% endif %} + private var requiredOSVersion: Int?{% if argument.simulatorOSVersion %} = {{ argument.simulatorOSVersion }}{% endif %} + private let snapshotDevices: [String]{% if argument.snapshotDevices %} = {{ argument.snapshotDevices|split:"|" }}{% else %} = []{% endif %} + + + {% if argument.file %} + + private var file: StaticString { .init(stringLiteral: "{{ argument.file }}") } + {% endif %} + + override func setUp() { + super.setUp() + + checkEnvironments() + UIView.setAnimationsEnabled(false) + } + + // MARK: - PreviewProvider + + {% for type in types.types where type.implements.PrefireProvider or type.based.PrefireProvider or type|annotated:"PrefireProvider" %} + func test_{{ type.name|lowerFirstLetter|replace:"_Previews", "" }}() { + for preview in {{ type.name }}._allPreviews { + assertSnapshots(matching: preview) + } + } + {%- if not forloop.last %} + + {% endif %} + {% endfor %} + {% if argument.previewsMacros %} + // MARK: - Macros + + {{ argument.previewsMacros }} + {% endif %} + // MARK: Private + + private func assertSnapshots(matching preview: _Preview, testName: String = #function) { + guard !snapshotDevices.isEmpty else { + if let failure = assertSnapshots(matching: AnyView(preview.content), + name: preview.displayName, + isScreen: preview.layout == .device, + device: preview.device?.snapshotDevice() ?? deviceConfig, + testName: testName) { + XCTFail(failure) + } + return + } + + for deviceName in snapshotDevices { + guard var device = PreviewDevice(rawValue: deviceName).snapshotDevice() else { + fatalError("Unknown device name: \(deviceName)") + } + + // Ignore specific device safe area + device.safeArea = .zero + + // Ignore specific device display scale + let traits = UITraitCollection(displayScale: 2.0) + + if let failure = assertSnapshots(matching: AnyView(preview.content), + name: preview.displayName, + isScreen: preview.layout == .device, + device: device, + testName: testName + deviceName, + traits: traits) { + XCTFail(failure) + } + } + } + + private func assertSnapshots(matching view: AnyView, + name: String?, isScreen: Bool, + device: ViewImageConfig, + testName: String = #function, + traits: UITraitCollection = .init()) -> String? { + var delay: TimeInterval = 0 + var precision: Float = 1 + var perceptualPrecision: Float = 0.98 + + let view = view + .onPreferenceChange(DelayPreferenceKey.self) { delay = $0 } + .onPreferenceChange(PrecisionPreferenceKey.self) { precision = $0 } + .onPreferenceChange(PerceptualPrecisionPreferenceKey.self) { perceptualPrecision = $0 } + + let matchingView = isScreen ? AnyView(view) : AnyView(view + .frame(width: device.size?.width) + .fixedSize(horizontal: false, vertical: true) + ) + + let failure = verifySnapshot( + of: matchingView, + as: .prefireImage(precision: { precision }, + perceptualPrecision: { perceptualPrecision }, + duration: { delay }, + layout: isScreen ? .device(config: device) : .sizeThatFits, + traits: traits), + named: name{% if argument.file %}, + file: file{% endif %}, + testName: testName + ) + + #if canImport(AccessibilitySnapshot) + let vc = UIHostingController(rootView: matchingView) + vc.view.frame = UIScreen.main.bounds + assertSnapshot( + matching: vc, + as: .wait(for: delay, on: .accessibilityImage(showActivationPoints: .always)), + named: name.flatMap { $0 + ".accessibility" }{% if argument.file %}, + file: file{% endif %}, + testName: testName + ) + #endif + return failure + } + + /// Check environments to avoid problems with snapshots on different devices or OS. + private func checkEnvironments() { + if let simulatorDevice { + let deviceModel = ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] + guard deviceModel?.contains(simulatorDevice) ?? false else { + fatalError("Switch to using \(simulatorDevice) for these tests.") + } + } + + if let requiredOSVersion { + let osVersion = ProcessInfo().operatingSystemVersion + guard osVersion.majorVersion == requiredOSVersion else { + fatalError("Switch to iOS \(requiredOSVersion) for these tests.") + } + } + } +} + +// MARK: - SnapshotTesting + Extensions + +private extension PreviewDevice { + func snapshotDevice() -> ViewImageConfig? { + switch rawValue { + case "iPhone 15", "iPhone 14", "iPhone 13", "iPhone 12", "iPhone 11", "iPhone 10": + return .iPhoneX + case "iPhone 6", "iPhone 6s", "iPhone 7", "iPhone 8": + return .iPhone8 + case "iPhone 6 Plus", "iPhone 6s Plus", "iPhone 8 Plus": + return .iPhone8Plus + case "iPhone SE (1st generation)", "iPhone SE (2nd generation)": + return .iPhoneSe + case "iPad": + return .iPad10_2 + case "iPad Mini": + return .iPadMini + case "iPad Pro 11": + return .iPadPro11 + case "iPad Pro 12.9": + return .iPadPro12_9 + default: return nil + } + } +} + +private extension Snapshotting where Value: SwiftUI.View, Format == UIImage { + static func prefireImage( + drawHierarchyInKeyWindow: Bool = false, + precision: @escaping () -> Float, + perceptualPrecision: @escaping () -> Float, + duration: @escaping () -> TimeInterval, + layout: SwiftUISnapshotLayout = .sizeThatFits, + traits: UITraitCollection = .init() + ) -> Snapshotting { + let config: ViewImageConfig + + switch layout { + #if os(iOS) || os(tvOS) + case let .device(config: deviceConfig): + config = deviceConfig + #endif + case .sizeThatFits: + config = .init(safeArea: .zero, size: nil, traits: traits) + case let .fixed(width: width, height: height): + let size = CGSize(width: width, height: height) + config = .init(safeArea: .zero, size: size, traits: traits) + } + + return SimplySnapshotting(pathExtension: "png", diffing: .prefireImage(precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale)) + .asyncPullback { view in + var config = config + + let controller: UIViewController + + if config.size != nil { + controller = UIHostingController(rootView: view) + } else { + let hostingController = UIHostingController(rootView: view) + + let maxSize = CGSize.zero + config.size = hostingController.sizeThatFits(in: maxSize) + + controller = hostingController + } + + return Async { callback in + let strategy = snapshotView( + config: config, + drawHierarchyInKeyWindow: drawHierarchyInKeyWindow, + traits: traits, + view: controller.view, + viewController: controller + ) + + let duration = duration() + if duration != .zero { + let expectation = XCTestExpectation(description: "Wait") + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { + expectation.fulfill() + } + _ = XCTWaiter.wait(for: [expectation], timeout: duration + 1) + } + strategy.run(callback) + } + } + } +} + +private extension Diffing where Value == UIImage { + static func prefireImage(precision: @escaping () -> Float, perceptualPrecision: @escaping () -> Float, scale: CGFloat?) -> Diffing { + lazy var originalDiffing = Diffing.image(precision: precision(), perceptualPrecision: perceptualPrecision(), scale: scale) + return Diffing( + toData: { originalDiffing.toData($0) }, + fromData: { originalDiffing.fromData($0) }, + diff: { originalDiffing.diff($0, $1) } + ) + } +} diff --git a/Tests/CompoundTests/__Snapshots__ b/Tests/CompoundTests/__Snapshots__ index c8fadd1..7e4417e 160000 --- a/Tests/CompoundTests/__Snapshots__ +++ b/Tests/CompoundTests/__Snapshots__ @@ -1 +1 @@ -Subproject commit c8fadd1032c0fb51aef19ba40f1c5b4442cb089b +Subproject commit 7e4417e95c10eecbad01b60d77d0544b2bccb256