diff --git a/Package.swift b/Package.swift index 07ae4efeb1..e57e92efef 100644 --- a/Package.swift +++ b/Package.swift @@ -74,6 +74,7 @@ let package = Package( "RevenueCatUI", "Nimble", .product(name: "SnapshotTesting", package: "swift-snapshot-testing") - ]) + ], + resources: [.copy("Resources/image.png")]) ] ) diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index ed8ca14997..00a72bb57c 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -207,6 +207,8 @@ 4F15B4A12A6774C9005BEFE8 /* CustomerInfo+NonSubscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F15B4A02A6774C9005BEFE8 /* CustomerInfo+NonSubscriptions.swift */; }; 4F15B4A22A678A9C005BEFE8 /* MockStoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FFD2502922DBED00A9A878 /* MockStoreTransaction.swift */; }; 4F15B4A32A678B81005BEFE8 /* MockStoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FFD2502922DBED00A9A878 /* MockStoreTransaction.swift */; }; + 4F1E84012A6062C1000AF177 /* ImageSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FCEEA622A37A2E9002C2112 /* ImageSnapshot.swift */; }; + 4F1E84022A6062C9000AF177 /* ImageSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FCEEA622A37A2E9002C2112 /* ImageSnapshot.swift */; }; 4F2017D52A15587F0061F6EF /* OfflineStoreKitIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2017D42A15587F0061F6EF /* OfflineStoreKitIntegrationTests.swift */; }; 4F2018732A15797D0061F6EF /* TestLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57057FF728B0048900995F21 /* TestLogHandler.swift */; }; 4F2F2EFF2A3CDAA800652B24 /* FileHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2F2EFE2A3CDAA800652B24 /* FileHandler.swift */; }; @@ -3638,6 +3640,7 @@ 5733B1A427FF9F8300EC2045 /* NetworkErrorTests.swift in Sources */, 351B517026D44E8D00BD2BD7 /* MockDateProvider.swift in Sources */, 4F2F2F142A3CEAB500652B24 /* FileHandlerTests.swift in Sources */, + 4F1E84012A6062C1000AF177 /* ImageSnapshot.swift in Sources */, 57FDAAC028493C13009A48F1 /* MockSandboxEnvironmentDetector.swift in Sources */, 5766AAD1283E981700FA6091 /* PurchasesPurchasingTests.swift in Sources */, 351B515E26D44B9900BD2BD7 /* MockPurchasesDelegate.swift in Sources */, @@ -3682,6 +3685,7 @@ 4FDF10F02A7262D8004F3680 /* SK2ProductFetcher.swift in Sources */, 575A8EE22922C56300936709 /* AsyncTestHelpers.swift in Sources */, 2DA85A8B26DEA7DD00F1136D /* MockProductsRequestFactory.swift in Sources */, + 4F1E84022A6062C9000AF177 /* ImageSnapshot.swift in Sources */, 2D3BFAD326DEA47100370B11 /* MockSKProductDiscount.swift in Sources */, 4FDF10ED2A726291004F3680 /* SK1ProductFetcher.swift in Sources */, 4F83F6B72A5DB782003F90A5 /* CurrentTestCaseTracker.swift in Sources */, diff --git a/RevenueCatUI/Helpers/DebugErrorView.swift b/RevenueCatUI/Helpers/DebugErrorView.swift new file mode 100644 index 0000000000..9f50e20774 --- /dev/null +++ b/RevenueCatUI/Helpers/DebugErrorView.swift @@ -0,0 +1,42 @@ +// +// DebugErrorView.swift +// +// +// Created by Nacho Soto on 7/13/23. +// + +import Foundation +import SwiftUI + +/// A view that displays an error in debug builds +@available(iOS 16.0, macOS 13.0, tvOS 16.0, *) +struct DebugErrorView: View { + + private let description: String + + init(_ error: Error) { + self.init((error as NSError).localizedDescription) + } + + init(_ description: String) { + self.description = description + } + + var body: some View { + #if DEBUG + Text(self.description) + .background( + Color.red + .edgesIgnoringSafeArea(.all) + ) + #else + // Fix-me: implement a proper production error screen + // appropriate for each case + EmptyView() + .onAppear { + Logger.warning("Couldn't load paywall: \(self.description)") + } + #endif + } + +} diff --git a/RevenueCatUI/Modifiers/FitToAspectRatio.swift b/RevenueCatUI/Modifiers/FitToAspectRatio.swift new file mode 100644 index 0000000000..c8db43e3b9 --- /dev/null +++ b/RevenueCatUI/Modifiers/FitToAspectRatio.swift @@ -0,0 +1,36 @@ +// +// FitToAspectRatio.swift +// +// +// Created by Nacho Soto on 7/13/23. +// + +import Foundation +import SwiftUI + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, *) +struct FitToAspectRatio: ViewModifier { + + let aspectRatio: Double + let contentMode: SwiftUI.ContentMode + + func body(content: Content) -> some View { + Color.clear + .aspectRatio(self.aspectRatio, contentMode: .fit) + .overlay( + content.aspectRatio(nil, contentMode: self.contentMode) + ) + } + +} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, *) +extension Image { + + func fitToAspect(_ aspectRatio: Double, contentMode: SwiftUI.ContentMode) -> some View { + self.resizable() + .scaledToFill() + .modifier(FitToAspectRatio(aspectRatio: aspectRatio, contentMode: contentMode)) + } + +} diff --git a/RevenueCatUI/Resources/Assets.xcassets/Contents.json b/RevenueCatUI/Resources/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596a7..0000000000 --- a/RevenueCatUI/Resources/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/RevenueCatUI/Resources/Assets.xcassets/image.imageset/Contents.json b/RevenueCatUI/Resources/Assets.xcassets/image.imageset/Contents.json deleted file mode 100644 index 1617b063f4..0000000000 --- a/RevenueCatUI/Resources/Assets.xcassets/image.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "Screenshot 2023-07-11 at 15.19.17.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/RevenueCatUI/Templates/Example1Template.swift b/RevenueCatUI/Templates/Example1Template.swift index 64d4caa9dc..4ebc888acb 100644 --- a/RevenueCatUI/Templates/Example1Template.swift +++ b/RevenueCatUI/Templates/Example1Template.swift @@ -9,22 +9,24 @@ struct Example1Template: TemplateViewType { init( packages: [Package], localization: PaywallData.LocalizedConfiguration, - configuration: PaywallData.Configuration + paywall: PaywallData ) { // Fix-me: move this logic out to be used by all templates if packages.isEmpty { self.data = .failure(.noPackages) } else { - let packages = Self.filter(packages: packages, with: configuration.packages) + let allPackages = paywall.config.packages + let packages = Self.filter(packages: packages, with: allPackages) if let package = packages.first { self.data = .success(.init( package: package, localization: localization.processVariables(with: package), - configuration: configuration + configuration: paywall.config, + headerImageURL: paywall.headerImageURL )) } else { - self.data = .failure(.couldNotFindAnyPackages(expectedTypes: configuration.packages)) + self.data = .failure(.couldNotFindAnyPackages(expectedTypes: allPackages)) } } } @@ -35,19 +37,7 @@ struct Example1Template: TemplateViewType { case let .success(data): Example1TemplateContent(data: data) case let .failure(error): - #if DEBUG - // Fix-me: implement a proper production error screen - EmptyView() - .onAppear { - Logger.warning("Couldn't load paywall: \(error.description)") - } - #else - Text(error.description) - .background( - Color.red - .edgesIgnoringSafeArea(.all) - ) - #endif + DebugErrorView(error) } } @@ -71,17 +61,25 @@ private struct Example1TemplateContent: View { @ViewBuilder private var content: some View { VStack { - Image("image", bundle: .module) - .resizable() - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fill) - .edgesIgnoringSafeArea(.top) - .padding(.bottom) - .mask(alignment: .top) { - Circle() - .offset(y: -160) - .scale(2.5) + AsyncImage(url: self.data.headerImageURL) { phase in + if let image = phase.image { + image + .fitToAspect(Self.imageAspectRatio, contentMode: .fill) + .edgesIgnoringSafeArea(.top) + } else if let error = phase.error { + DebugErrorView("Error loading image from '\(self.data.headerImageURL)': \(error)") + } else { + Rectangle() + .hidden() } + } + .frame(maxWidth: .infinity) + .aspectRatio(Self.imageAspectRatio, contentMode: .fit) + .clipShape( + Circle() + .offset(y: -100) + .scale(3.0) + ) Spacer() @@ -133,6 +131,8 @@ private struct Example1TemplateContent: View { .controlSize(.large) } + private static let imageAspectRatio = 0.7 + } // MARK: - @@ -144,6 +144,7 @@ private extension Example1TemplateContent { let package: Package let localization: ProcessedLocalizedConfiguration let configuration: PaywallData.Configuration + let headerImageURL: URL } enum Error: Swift.Error { @@ -173,9 +174,15 @@ private extension Example1TemplateContent { } @available(iOS 16.0, macOS 13.0, tvOS 16.0, *) -extension Example1TemplateContent.Error { +extension Example1TemplateContent.Error: CustomNSError { + + var errorUserInfo: [String: Any] { + return [ + NSLocalizedDescriptionKey: self.description + ] + } - var description: String { + private var description: String { switch self { case .noPackages: return "Attempted to display paywall with no packages." diff --git a/RevenueCatUI/Templates/TemplateViewType.swift b/RevenueCatUI/Templates/TemplateViewType.swift index 819c3e2429..df882f8303 100644 --- a/RevenueCatUI/Templates/TemplateViewType.swift +++ b/RevenueCatUI/Templates/TemplateViewType.swift @@ -7,7 +7,7 @@ protocol TemplateViewType: SwiftUI.View { init( packages: [Package], localization: PaywallData.LocalizedConfiguration, - configuration: PaywallData.Configuration + paywall: PaywallData ) } @@ -22,7 +22,7 @@ extension PaywallData { Example1Template( packages: offering.availablePackages, localization: self.localizedConfiguration, - configuration: self.config + paywall: self ) } } diff --git a/RevenueCatUI/TestData.swift b/RevenueCatUI/TestData.swift index deb3e282f8..245b809972 100644 --- a/RevenueCatUI/TestData.swift +++ b/RevenueCatUI/TestData.swift @@ -65,16 +65,20 @@ internal enum TestData { static let paywallWithIntroOffer = PaywallData( template: .example1, config: .init( - packages: [.monthly] + packages: [.monthly], + headerImageName: Self.paywallHeaderImageName ), - localization: Self.localization + localization: Self.localization, + assetBaseURL: Self.paywallAssetBaseURL ) static let paywallWithNoIntroOffer = PaywallData( template: .example1, config: .init( - packages: [.annual] + packages: [.annual], + headerImageName: Self.paywallHeaderImageName ), - localization: Self.localization + localization: Self.localization, + assetBaseURL: Self.paywallAssetBaseURL ) static let offeringWithIntroOffer = Offering( @@ -103,6 +107,8 @@ internal enum TestData { ) private static let offeringIdentifier = "offering" + private static let paywallHeaderImageName = "cd84ac55_paywl0884b9ceb4_header_1689214657.jpg" + private static let paywallAssetBaseURL = URL(string: "https://d2ban7feka8lu3.cloudfront.net")! } diff --git a/Sources/Paywalls/PaywallData.swift b/Sources/Paywalls/PaywallData.swift index 04f718b331..510859e276 100644 --- a/Sources/Paywalls/PaywallData.swift +++ b/Sources/Paywalls/PaywallData.swift @@ -23,6 +23,9 @@ public struct PaywallData { /// Generic configuration for any paywall. public var config: Configuration + /// The base remote URL where assets for this paywall are stored. + public var assetBaseURL: URL + fileprivate var defaultLocaleIdentifier: String fileprivate var localization: [String: LocalizedConfiguration] @@ -121,15 +124,30 @@ extension PaywallData { /// The list of package types this paywall will display public var packages: [PackageType] + /// The name for the header image asset. + public var headerImageName: String + // swiftlint:disable:next missing_docs - public init(packages: [PackageType]) { + public init(packages: [PackageType], headerImageName: String) { self.packages = packages + self.headerImageName = headerImageName } } } +// MARK: - Extensions + +public extension PaywallData { + + /// The remote URL to load the header image asset. + var headerImageURL: URL { + return self.assetBaseURL.appendingPathComponent(self.config.headerImageName) + } + +} + // MARK: - Constructors extension PaywallData { @@ -138,19 +156,22 @@ extension PaywallData { template: PaywallTemplate, config: Configuration, defaultLocale: String, - localization: [String: LocalizedConfiguration] + localization: [String: LocalizedConfiguration], + assetBaseURL: URL ) { self.template = template self.config = config self.defaultLocaleIdentifier = defaultLocale self.localization = localization + self.assetBaseURL = assetBaseURL } /// Creates a test ``PaywallData`` with one localization public init( template: PaywallTemplate, config: Configuration, - localization: LocalizedConfiguration + localization: LocalizedConfiguration, + assetBaseURL: URL ) { let locale = Locale.current.identifier @@ -158,7 +179,8 @@ extension PaywallData { template: template, config: config, defaultLocale: locale, - localization: [locale: localization] + localization: [locale: localization], + assetBaseURL: assetBaseURL ) } @@ -178,7 +200,16 @@ extension PaywallData.LocalizedConfiguration: Codable { } } -extension PaywallData.Configuration: Codable {} + +extension PaywallData.Configuration: Codable { + + private enum CodingKeys: String, CodingKey { + case packages + case headerImageName = "headerImage" + } + +} + extension PaywallData: Codable { // Note: these are camel case but converted by the decoder @@ -187,6 +218,7 @@ extension PaywallData: Codable { case defaultLocaleIdentifier = "defaultLocale" case config case localization = "localizedStrings" + case assetBaseURL = "assetBaseUrl" } } @@ -201,4 +233,11 @@ extension PaywallData: Equatable {} extension PaywallData.LocalizedConfiguration: Sendable {} extension PaywallData.Configuration: Sendable {} + +#if swift(>=5.7) extension PaywallData: Sendable {} +#else +// `@unchecked` because: +// - `URL` is not `Sendable` until Swift 5.7 +extension PaywallData: @unchecked Sendable {} +#endif diff --git a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift index 7ddd7542ae..f0df4994f1 100644 --- a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift +++ b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift @@ -14,15 +14,18 @@ func checkPaywallData(_ data: PaywallData) { let locale: Locale = data.defaultLocale let _: PaywallData.LocalizedConfiguration? = data.config(for: locale) let localization: PaywallData.LocalizedConfiguration = data.localizedConfiguration + let assetBaseURL: URL = data.assetBaseURL let _: PaywallData = .init(template: template, config: config, - localization: localization) + localization: localization, + assetBaseURL: assetBaseURL) } func checkPaywallConfiguration(_ config: PaywallData.Configuration) { - let _: PaywallData.Configuration = .init(packages: [.monthly, .annual]) + let _: PaywallData.Configuration = .init(packages: [.monthly, .annual], headerImageName: "") let _: [PackageType] = config.packages + let _: String = config.headerImageName } func checkPaywallLocalizedConfig(_ config: PaywallData.LocalizedConfiguration) { diff --git a/Tests/RevenueCatUITests/Helpers/SnapshotTesting+Extensions.swift b/Tests/RevenueCatUITests/Helpers/SnapshotTesting+Extensions.swift new file mode 120000 index 0000000000..0543c25d96 --- /dev/null +++ b/Tests/RevenueCatUITests/Helpers/SnapshotTesting+Extensions.swift @@ -0,0 +1 @@ +../../UnitTests/TestHelpers/SnapshotTesting+Extensions.swift \ No newline at end of file diff --git a/Tests/RevenueCatUITests/PaywallViewTests.swift b/Tests/RevenueCatUITests/PaywallViewTests.swift index bc7007c664..fb81768bc4 100644 --- a/Tests/RevenueCatUITests/PaywallViewTests.swift +++ b/Tests/RevenueCatUITests/PaywallViewTests.swift @@ -1,4 +1,5 @@ import Nimble +import RevenueCat @testable import RevenueCatUI import SnapshotTesting import XCTest @@ -15,21 +16,29 @@ class PaywallViewTests: TestCase { func testSamplePaywall() { let offering = TestData.offeringWithNoIntroOffer - let view = PaywallView(offering: offering, paywall: offering.paywall!) - .frame(width: Self.size.width, height: Self.size.height) - - expect(view).to(haveValidSnapshot(as: .image)) + let view = PaywallView(offering: offering, paywall: offering.paywall!.withLocalImage) + view.snapshot(size: Self.size) } func testSamplePaywallWithIntroOffer() { let offering = TestData.offeringWithIntroOffer - let view = PaywallView(offering: offering, paywall: offering.paywall!) - .frame(width: Self.size.width, height: Self.size.height) - - expect(view).to(haveValidSnapshot(as: .image)) + let view = PaywallView(offering: offering, paywall: offering.paywall!.withLocalImage) + view.snapshot(size: Self.size) } private static let size: CGSize = .init(width: 460, height: 950) } + +private extension PaywallData { + + var withLocalImage: Self { + var copy = self + copy.assetBaseURL = URL(fileURLWithPath: Bundle.module.bundlePath) + copy.config.headerImageName = "image.png" + + return copy + } + +} diff --git a/RevenueCatUI/Resources/Assets.xcassets/image.imageset/Screenshot 2023-07-11 at 15.19.17.png b/Tests/RevenueCatUITests/Resources/image.png similarity index 100% rename from RevenueCatUI/Resources/Assets.xcassets/image.imageset/Screenshot 2023-07-11 at 15.19.17.png rename to Tests/RevenueCatUITests/Resources/image.png diff --git a/Tests/UnitTests/Networking/Responses/Fixtures/Offerings.json b/Tests/UnitTests/Networking/Responses/Fixtures/Offerings.json index 3e2fb9e9e0..cebc63cb99 100644 --- a/Tests/UnitTests/Networking/Responses/Fixtures/Offerings.json +++ b/Tests/UnitTests/Networking/Responses/Fixtures/Offerings.json @@ -60,8 +60,10 @@ }, "default_locale": "en_US", "config": { - "packages": ["$rc_monthly", "$rc_annual"] - } + "packages": ["$rc_monthly", "$rc_annual"], + "header_image": "asset_name" + }, + "asset_base_url": "https://rc-paywalls.s3.amazonaws.com" } }, { diff --git a/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-Sample1.json b/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-Sample1.json index 6f7a703660..9b5010816c 100644 --- a/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-Sample1.json +++ b/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-Sample1.json @@ -20,6 +20,8 @@ }, "default_locale": "en_US", "config": { - "packages": ["$rc_monthly", "$rc_annual"] - } + "packages": ["$rc_monthly", "$rc_annual"], + "header_image": "asset_name.png" + }, + "asset_base_url": "https://rc-paywalls.s3.amazonaws.com" } diff --git a/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_and_default_locale.json b/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_and_default_locale.json index 9c99fd6e1d..c9f68c1d80 100644 --- a/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_and_default_locale.json +++ b/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_and_default_locale.json @@ -12,6 +12,8 @@ }, "default_locale": "es_ES", "config": { - "packages": ["$rc_monthly", "$rc_annual"] - } + "packages": ["$rc_monthly", "$rc_annual"], + "header_image": "asset_name" + }, + "asset_base_url": "https://rc-paywalls.s3.amazonaws.com" } diff --git a/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_locale.json b/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_locale.json index a158c67cd6..a2f0fa98e5 100644 --- a/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_locale.json +++ b/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_locale.json @@ -12,6 +12,8 @@ }, "default_locale": "es_ES", "config": { - "packages": ["$rc_monthly", "$rc_annual"] - } + "packages": ["$rc_monthly", "$rc_annual"], + "header_image": "asset_name" + }, + "asset_base_url": "https://rc-paywalls.s3.amazonaws.com" } diff --git a/Tests/UnitTests/Networking/Responses/OfferingsDecodingTests.swift b/Tests/UnitTests/Networking/Responses/OfferingsDecodingTests.swift index 70a315be02..2fdc868382 100644 --- a/Tests/UnitTests/Networking/Responses/OfferingsDecodingTests.swift +++ b/Tests/UnitTests/Networking/Responses/OfferingsDecodingTests.swift @@ -110,7 +110,10 @@ class OfferingsDecodingTests: BaseHTTPResponseTest { let paywall = try XCTUnwrap(offering.paywall) expect(paywall.template) == .example1 expect(paywall.defaultLocale) == Locale(identifier: "en_US") + try expect(paywall.assetBaseURL) == XCTUnwrap(URL(string: "https://rc-paywalls.s3.amazonaws.com")) + expect(paywall.config.packages) == [.monthly, .annual] + expect(paywall.config.headerImageName) == "asset_name" let enConfig = try XCTUnwrap(paywall.config(for: Locale(identifier: "en_US"))) expect(enConfig.title) == "Paywall" diff --git a/Tests/UnitTests/Networking/Responses/PaywallDataTests.swift b/Tests/UnitTests/Networking/Responses/PaywallDataTests.swift index d6ccd8f913..b801e08ef3 100644 --- a/Tests/UnitTests/Networking/Responses/PaywallDataTests.swift +++ b/Tests/UnitTests/Networking/Responses/PaywallDataTests.swift @@ -31,7 +31,11 @@ class PaywallDataTests: BaseHTTPResponseTest { expect(paywall.template) == .example1 expect(paywall.defaultLocale) == Locale(identifier: Self.defaultLocale) + expect(paywall.assetBaseURL) == URL(string: "https://rc-paywalls.s3.amazonaws.com")! expect(paywall.config.packages) == [.monthly, .annual] + expect(paywall.config.headerImageName) == "asset_name.png" + + expect(paywall.headerImageURL) == URL(string: "https://rc-paywalls.s3.amazonaws.com/asset_name.png")! let enConfig = try XCTUnwrap(paywall.config(for: Locale(identifier: "en_US"))) expect(enConfig.title) == "Paywall" diff --git a/Tests/UnitTests/TestHelpers/SnapshotTesting+Extensions.swift b/Tests/UnitTests/TestHelpers/SnapshotTesting+Extensions.swift index bf469054df..c6d2267125 100644 --- a/Tests/UnitTests/TestHelpers/SnapshotTesting+Extensions.swift +++ b/Tests/UnitTests/TestHelpers/SnapshotTesting+Extensions.swift @@ -12,7 +12,9 @@ // Created by Nacho Soto on 3/4/22. import Foundation +import Nimble import SnapshotTesting +import SwiftUI @testable import RevenueCat @@ -41,6 +43,54 @@ extension Snapshotting where Value == Encodable, Format == String { } +// MARK: - Image Snapshoting + +#if !os(watchOS) && swift(>=5.8) +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +extension SwiftUI.View { + + func snapshot( + size: CGSize, + file: StaticString = #file, + line: UInt = #line + ) { + // Add test view to the hierarchy to make sure async rendering takes place. + // The tested view is `controller.view` instead of `self` to keep it in memory + // while rendering happens + let controller = UIViewController() + let window = UIWindow() + window.rootViewController = controller + + controller.view.addSubview( + self + .frame(width: size.width, height: size.height) + .asUIView(container: controller, size: size) + ) + controller.view.backgroundColor = .white + + expect( + file: file, line: line, + controller.view + ).toEventually( + haveValidSnapshot( + as: .image(size: size), + named: "1", // Force each retry to end in `.1.png` + file: file, + line: line + ), + timeout: timeout, + pollInterval: pollInterval + ) + } + +} +#endif + +private let timeout: DispatchTimeInterval = .seconds(1) +private let pollInterval: DispatchTimeInterval = .milliseconds(100) + +// MARK: - Private + private extension Encodable { func asFormattedString(backwardsCompatible: Bool) throws -> String { @@ -76,3 +126,47 @@ private let outputFormatting: JSONEncoder.OutputFormatting = { return result }() + +// MARK: - SwiftUIContainerView + +#if !os(watchOS) +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +@available(watchOS, unavailable) +private final class SwiftUIContainerView: UIView { + + private let controller: UIHostingController + + init(container: UIViewController, view: V, size: CGSize) { + self.controller = UIHostingController(rootView: view) + self.controller.view.backgroundColor = nil + + super.init(frame: .init(origin: .zero, size: size)) + + container.addChild(self.controller) + self.addSubview(self.controller.view) + self.controller.didMove(toParent: container) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + self.controller.view.frame = self.bounds + } + +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) +private extension SwiftUI.View { + + func asUIView(container: UIViewController, size: CGSize) -> SwiftUIContainerView { + return SwiftUIContainerView(container: container, view: self, size: size) + } + +} + +#endif