diff --git a/Sources/BuilderIO/ComponentRegistry/BuilderComponentProtocol.swift b/Sources/BuilderIO/ComponentRegistry/BuilderComponentProtocol.swift index c28d5ec..dc20b98 100644 --- a/Sources/BuilderIO/ComponentRegistry/BuilderComponentProtocol.swift +++ b/Sources/BuilderIO/ComponentRegistry/BuilderComponentProtocol.swift @@ -14,6 +14,10 @@ extension BuilderViewProtocol { func getFinalStyle(responsiveStyles: BuilderBlockResponsiveStyles?) -> [String: String] { return CSSStyleUtil.getFinalStyle(responsiveStyles: responsiveStyles) } + + func codeBindings() -> [String: String]? { + return nil + } } struct BuilderEmptyView: BuilderViewProtocol { diff --git a/Sources/BuilderIO/Components/BuilderBlock.swift b/Sources/BuilderIO/Components/BuilderBlock.swift index 3e8d3fe..20e5840 100644 --- a/Sources/BuilderIO/Components/BuilderBlock.swift +++ b/Sources/BuilderIO/Components/BuilderBlock.swift @@ -1,8 +1,6 @@ import SwiftUI -//BuilderBlock forms the out layout container for all components mimicking Blocks from response. As blocks can have layout direction of either horizontal or vertical a check is made and layout selected. - -//BuilderBlock forms the out layout container for all components mimicking Blocks from response. As blocks can have layout direction of either horizontal or vertical a check is made and layout selected. +//BuilderBlock forms the outer layout container for all components mimicking Blocks from response. As blocks can have layout direction of either horizontal or vertical a check is made and layout selected. enum BuilderLayoutDirection { case horizontal @@ -33,11 +31,11 @@ struct BuilderBlock: View { if builderLayoutDirection == .parentLayout { blockContent() } else if builderLayoutDirection == .horizontal { - HStack(spacing: spacing) { // Adjust alignment and spacing as needed + LazyHStack(spacing: spacing) { // Adjust alignment and spacing as needed blockContent() } } else { // Default to column - VStack(spacing: spacing) { // Adjust alignment and spacing as needed + LazyVStack(spacing: spacing) { // Adjust alignment and spacing as needed blockContent() } } diff --git a/Sources/BuilderIO/Components/BuilderColumns.swift b/Sources/BuilderIO/Components/BuilderColumns.swift index b807e93..043a2e9 100644 --- a/Sources/BuilderIO/Components/BuilderColumns.swift +++ b/Sources/BuilderIO/Components/BuilderColumns.swift @@ -32,7 +32,7 @@ struct BuilderColumns: BuilderViewProtocol { let elementData = try JSONEncoder().encode(anyCodableElement) // Decode that Data into a BuilderContentData instance - let column = try decoder.decode(BuilderContentData.self, from: elementData) + var column = try decoder.decode(BuilderContentData.self, from: elementData) decodedColumns.append(column) } catch { // Handle error for a specific element if it can't be decoded @@ -41,6 +41,17 @@ struct BuilderColumns: BuilderViewProtocol { // or simply skip this element, as we are doing here. } } + + if let stateBoundObjectModel = block.stateBoundObjectModel { + for columnIndex in decodedColumns.indices { + for blockIndex in decodedColumns[columnIndex].blocks.indices { + decodedColumns[columnIndex].blocks[blockIndex] + .propagateStateBoundObjectModel( + stateBoundObjectModel, stateRepeatCollectionKey: block.stateRepeatCollectionKey) + } + } + } + self.columns = decodedColumns } else { diff --git a/Sources/BuilderIO/Components/BuilderImage.swift b/Sources/BuilderIO/Components/BuilderImage.swift index b4c05d8..89e4a69 100644 --- a/Sources/BuilderIO/Components/BuilderImage.swift +++ b/Sources/BuilderIO/Components/BuilderImage.swift @@ -12,7 +12,7 @@ struct BuilderImage: BuilderViewProtocol { var contentMode: ContentMode = .fit var fitContent: Bool = false - @State private var imageLoadedSuccessfully: Bool = false + @State private var builderImageLoader: BuilderImageLoader = BuilderImageLoader() init(block: BuilderBlockModel) { self.block = block @@ -30,16 +30,27 @@ struct BuilderImage: BuilderViewProtocol { (block.component?.options?.dictionaryValue?["fitContent"]?.boolValue ?? false) && !(block.children?.isEmpty ?? true) + if let imageBinding = block.codeBindings(for: "image")?.stringValue { + self.imageURL = URL( + string: imageBinding) + } + } var body: some View { - - AsyncImage(url: imageURL) { phase in - switch phase { - case .empty: - ProgressView() - case .success(let image): + Group { + switch builderImageLoader.imageStatus { + case .loading: + Rectangle() + .fill(Color.clear) + .aspectRatio(self.aspectRatio ?? 1, contentMode: self.contentMode) + .overlay(ProgressView()) + case .error: + Rectangle() + .fill(Color.clear) + case .loaded(let uiImage): if fitContent { + // Content fits over the image, image acts as background Group { if let children = children, !children.isEmpty { VStack(spacing: 0) { @@ -50,20 +61,22 @@ struct BuilderImage: BuilderViewProtocol { .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(0) .background( - image.resizable() + Image(uiImage: uiImage) // Use UIImage directly + .resizable() .aspectRatio(self.aspectRatio ?? 1, contentMode: self.contentMode) .clipped() ) } else { - EmptyView() + EmptyView() // No children, so nothing to show if fitContent is true without children } } - } else { + // Image fills the space, children overlay it Rectangle().fill(Color.clear) .aspectRatio(self.aspectRatio ?? 1, contentMode: self.contentMode) .background( - image.resizable() + Image(uiImage: uiImage) // Use UIImage directly + .resizable() .aspectRatio(contentMode: self.contentMode) .clipped() ) @@ -80,12 +93,12 @@ struct BuilderImage: BuilderViewProtocol { ) } - case .failure: - EmptyView() - @unknown default: - EmptyView() } } + .task { + try? await Task.sleep(nanoseconds: 10_000_000) + await builderImageLoader.loadImage(from: imageURL) + } } } diff --git a/Sources/BuilderIO/Components/BuilderImageLoader.swift b/Sources/BuilderIO/Components/BuilderImageLoader.swift new file mode 100644 index 0000000..7568627 --- /dev/null +++ b/Sources/BuilderIO/Components/BuilderImageLoader.swift @@ -0,0 +1,87 @@ +import SwiftUI +import UIKit // Required for UIImage and URLSession + +enum ImageStatus { + case loading + case loaded(UIImage) + case error +} + +@MainActor +@Observable +class BuilderImageLoader { + + // MARK: - Static Cache Properties + // Static a single, shared cache across all uses of BuilderImageLoader. + private static let imageCache = NSCache() + private static let cacheDuration: TimeInterval = 5 * 60 // Cache duration in seconds (5 minutes) + + public var imageStatus: ImageStatus = .loading + + private class CachedImageEntry { + let image: UIImage + let timestamp: Date + + init(image: UIImage, timestamp: Date) { + self.image = image + self.timestamp = timestamp + } + + /// Checks if the cached image is still valid based on the cache duration. + func isValid(for duration: TimeInterval) -> Bool { + return Date().timeIntervalSince(timestamp) < duration + } + } + + func loadImage(from url: URL?) async { + guard let url = url else { + print("BuilderImageLoader: Invalid URL provided.") + imageStatus = .error + return + } + + let cacheKey = url.absoluteString as NSString + + // 1. Check if the image is in the static cache and is still valid + if let cachedEntry = BuilderImageLoader.imageCache.object(forKey: cacheKey), + cachedEntry.isValid(for: BuilderImageLoader.cacheDuration) + { + print("BuilderImageLoader: Image loaded from static cache for URL: \(url)") + imageStatus = .loaded(cachedEntry.image) + return + } + + // 2. If not in cache or expired, download the image + print("BuilderImageLoader: Downloading image for URL: \(url)") + do { + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) + else { + print("BuilderImageLoader: Server error or invalid response for URL: \(url)") + imageStatus = .error + return + } + + guard let image = UIImage(data: data) else { + print("BuilderImageLoader: Could not create image from data for URL: \(url)") + imageStatus = .error + return + } + + // 3. Cache the newly downloaded image in the static cache + let newCacheEntry = CachedImageEntry(image: image, timestamp: Date()) + BuilderImageLoader.imageCache.setObject(newCacheEntry, forKey: cacheKey) + print("BuilderImageLoader: Image downloaded and cached in static cache for URL: \(url)") + + imageStatus = .loaded(image) + + } catch { + print( + "BuilderImageLoader: Failed to download image from URL: \(url), Error: \(error.localizedDescription)" + ) + imageStatus = .error + } + } +} diff --git a/Sources/BuilderIO/Components/BuilderText.swift b/Sources/BuilderIO/Components/BuilderText.swift index 86b9b7e..a3d3226 100644 --- a/Sources/BuilderIO/Components/BuilderText.swift +++ b/Sources/BuilderIO/Components/BuilderText.swift @@ -13,6 +13,10 @@ struct BuilderText: BuilderViewProtocol { self.block = block self.text = block.component?.options?.dictionaryValue?["text"]?.stringValue ?? "" self.responsiveStyles = getFinalStyle(responsiveStyles: block.responsiveStyles) + + if let textBinding = block.codeBindings(for: "text") { + self.text = textBinding.stringValue + } } var body: some View { diff --git a/Sources/BuilderIO/ExportedView/BuilderIOContentView.swift b/Sources/BuilderIO/ExportedView/BuilderIOContentView.swift index b1113f6..c87ca06 100644 --- a/Sources/BuilderIO/ExportedView/BuilderIOContentView.swift +++ b/Sources/BuilderIO/ExportedView/BuilderIOContentView.swift @@ -6,18 +6,18 @@ public struct BuilderIOContentView: View { let model: String let url: String? - @StateObject private var viewModel: BuilderIOViewModel + @State private var viewModel: BuilderIOViewModel public init(model: String) { self.model = model self.url = nil - _viewModel = StateObject(wrappedValue: BuilderIOViewModel()) + _viewModel = State(wrappedValue: BuilderIOViewModel()) } init(url: String, model: String = "page") { self.url = url self.model = model - _viewModel = StateObject(wrappedValue: BuilderIOViewModel()) + _viewModel = State(wrappedValue: BuilderIOViewModel()) } public var body: some View { @@ -29,12 +29,14 @@ public struct BuilderIOContentView: View { .foregroundColor(.red) } else if let builderContent = viewModel.builderContent { - let builderBlockView = BuilderBlock(blocks: builderContent.data.blocks) - .onAppear { - if !BuilderContentAPI.isPreviewing() { - viewModel.sendTrackingPixel() - } + let builderBlockView = BuilderBlock( + blocks: builderContent.data.blocks, builderLayoutDirection: .vertical + ) + .onAppear { + if !BuilderContentAPI.isPreviewing() { + viewModel.sendTrackingPixel() } + } // Conditionally apply ScrollView and refreshable if url != nil && !url!.isEmpty { diff --git a/Sources/BuilderIO/ExportedView/BuilderIOViewModel.swift b/Sources/BuilderIO/ExportedView/BuilderIOViewModel.swift index a48bd38..b5df53b 100644 --- a/Sources/BuilderIO/ExportedView/BuilderIOViewModel.swift +++ b/Sources/BuilderIO/ExportedView/BuilderIOViewModel.swift @@ -1,10 +1,46 @@ -import SwiftUI +import Foundation +import Network +import Observation // Import the Observation framework +/// A view model for fetching and managing Builder.io content. +/// +/// Uses the new iOS 17 @Observable macro for simplified state management. +@Observable @MainActor -public final class BuilderIOViewModel: ObservableObject { - @Published public var builderContent: BuilderContent? - @Published public var isLoading: Bool = false - @Published public var errorMessage: String? +public final class BuilderIOViewModel { + public var builderContent: BuilderContent? + public var isLoading: Bool = false + public var errorMessage: String? + public var stateModel: StateModel = StateModel() + + public var isNetworkAvailable: Bool = false + private let networkMonitor = NWPathMonitor() + private let networkQueue = DispatchQueue(label: "NetworkMonitorQueue") + + /// Initializes the BuilderIOViewModel. + public init() { + startNetworkMonitoring() + } + + private func startNetworkMonitoring() { + networkMonitor.pathUpdateHandler = { [weak self] path in + Task { @MainActor in // Ensure UI updates are on the main actor + self?.isNetworkAvailable = path.status == .satisfied + if !self!.isNetworkAvailable { + print("Network is not available.") + // Optionally set an error message or notify the user + } else { + print("Network is available.") + } + } + } + networkMonitor.start(queue: networkQueue) + } + + /// Stops network monitoring when the ViewModel is deinitialized. + deinit { + networkMonitor.cancel() + } /// Fetches the Builder.io page content for a given URL. /// Manages loading, content, and error states. @@ -14,17 +50,67 @@ public final class BuilderIOViewModel: ObservableObject { errorMessage = nil // Clear any previous error builderContent = nil // Clear previous content while loading new + guard isNetworkAvailable else { + errorMessage = "Network is not available. Please check your connection." + isLoading = false + return + } + do { // Await the content fetching let result = await BuilderIOManager.shared.fetchBuilderContent(model: model, url: url) switch result { - case .success(let fetchedContent): - self.builderContent = fetchedContent + if fetchedContent.data.httpRequests != nil { + self.stateModel.apiResponses = try await fetchParallelAPIData( + urls: fetchedContent.data.httpRequests!) + } + + if self.stateModel.apiResponses.isEmpty { + self.builderContent = fetchedContent + } else { + // Further logic for content binding/loops can go here if needed. + var contentBlocks = fetchedContent.data.blocks ?? [] + //First expand model to cover repeatable data. + var newContentBlocks: [BuilderBlockModel] = [] + + for contentBlock in contentBlocks { + if let repeatModel = contentBlock.repeat, + let collectionName = repeatModel["collection"] as? String + { + if let collectionModel = self.stateModel.getCollectionFromStateData( + keyString: collectionName), + collectionModel.count > 0 + { + for i in 0.. [String: AnyCodable] { + isLoading = true + errorMessage = nil // Clear any previous error + + // 1. Check network availability + guard isNetworkAvailable else { + errorMessage = "Network is not available. Cannot fetch parallel API data." + isLoading = false + return [:] + } + + var mergedResults: [String: AnyCodable] = [:] + + // Use withTaskGroup for concurrent execution and collection of results + // The Task will now return (originalKey, apiResult) + await withTaskGroup(of: (String, AnyCodable?).self) { group in + for (key, urlString) in urls { // Iterate over key-value pairs + group.addTask { + do { + let apiResult = try await BuilderContentAPI.getDataFromBoundAPI(url: urlString) + // Return the original key along with the API result + return (key, apiResult) + } catch { + print( + "Error fetching data from \(urlString) for key \(key): \(error.localizedDescription)") + // Return the original key even if the call failed + return (key, nil) + } + } + } + + // Collect results as they complete + for await (originalKey, apiResult) in group { // Await the (key, result) tuple + if let resultDict = apiResult { + + mergedResults[originalKey] = resultDict + + } + } + } + + isLoading = false + return mergedResults // Return the merged dictionary + } + } diff --git a/Sources/BuilderIO/Models/StateData.swift b/Sources/BuilderIO/Models/StateData.swift new file mode 100644 index 0000000..ea31435 --- /dev/null +++ b/Sources/BuilderIO/Models/StateData.swift @@ -0,0 +1,60 @@ +import Foundation + +public class StateModel: Codable { + public var apiResponses: [String: AnyCodable] = [:] + + func findCollection(keys: [String], currentData: [String: AnyCodable]) -> [AnyCodable]? { + + if keys.count == 1 { + return currentData[keys[0]]?.arrayValue + } + + let nextLevelObject = currentData[keys[0]]?.dictionaryValue + + var nextKeys = Array(keys.dropFirst()) + + if let nextLevelObject = nextLevelObject { + return findCollection(keys: nextKeys, currentData: nextLevelObject) + } + + return nil + + } + + func getCollectionFromStateData(keyString: String) -> [AnyCodable]? { + + var keys = Array( + keyString.replacingOccurrences(of: "@", with: "").components(separatedBy: ".").dropFirst()) + + return findCollection(keys: keys, currentData: apiResponses) + + } + + func findValue(keys: [String], currentData: [String: AnyCodable]) -> AnyCodable? { + + if keys.count == 1 { + return currentData[keys[0]] + } + + let nextLevelObject = currentData[keys[0]]?.dictionaryValue + + var nextKeys = Array(keys.dropFirst()) + + if let nextLevelObject = nextLevelObject { + return findValue(keys: nextKeys, currentData: nextLevelObject) + } + + return nil + + } + + func getValueFromStateData(keyString: String) -> AnyCodable? { + + var keys = Array( + keyString.replacingOccurrences(of: "@", with: "").components(separatedBy: ".").dropFirst()) + + return findValue(keys: keys, currentData: apiResponses) + + } + +} diff --git a/Sources/BuilderIO/Schemas/BuilderBlockModel.swift b/Sources/BuilderIO/Schemas/BuilderBlockModel.swift index ac4641f..32cb161 100644 --- a/Sources/BuilderIO/Schemas/BuilderBlockModel.swift +++ b/Sources/BuilderIO/Schemas/BuilderBlockModel.swift @@ -8,10 +8,84 @@ public struct BuilderBlockModel: Codable, Identifiable { public var children: [BuilderBlockModel]? = [] public var component: BuilderBlockComponent? = nil public var responsiveStyles: BuilderBlockResponsiveStyles? = BuilderBlockResponsiveStyles() // for inner style of the component - public var actions: AnyCodable? = nil // Replaced JSON? with AnyCodable?, default to nil - public var code: AnyCodable? = nil // Replaced JSON? with AnyCodable?, default to nil - public var meta: AnyCodable? = nil // Replaced JSON? with AnyCodable?, default to nil + public var actions: AnyCodable? = nil + public var code: AnyCodable? = nil + public var meta: AnyCodable? = nil public var linkUrl: String? = nil + public var `repeat`: [String: String]? = [:] + + public var stateBoundObjectModel: StateModel? = nil + public var stateRepeatCollectionKey: StateRepeatCollectionKey? = nil + +} + +public struct StateRepeatCollectionKey: Codable { + public var index: Int + public var collection: String + +} + +extension BuilderBlockModel { + /// Recursively sets the `stateBoundObjectModel` for this block and all its children. + public mutating func propagateStateBoundObjectModel( + _ model: StateModel?, stateRepeatCollectionKey: StateRepeatCollectionKey? = nil + ) { + self.stateBoundObjectModel = model + self.stateRepeatCollectionKey = stateRepeatCollectionKey + + if var children = self.children { + for index in children.indices { + children[index].propagateStateBoundObjectModel( + model, stateRepeatCollectionKey: stateRepeatCollectionKey) + } + self.children = children + } + } + + public func codeBindings(for key: String) -> AnyCodable? { + guard let code = code, + let codeConfig = code.dictionaryValue, + let bindingsConfig = codeConfig["bindings"], + let bindings = bindingsConfig.dictionaryValue + else { + return nil + } + + if let stateModel = stateBoundObjectModel { + + //binding is in a list + if let stateRepeatCollectionKey = stateRepeatCollectionKey { + let collection = stateModel.getCollectionFromStateData( + keyString: stateRepeatCollectionKey.collection) + + let model = collection?[stateRepeatCollectionKey.index].dictionaryValue + + for (bindingKey, value) in bindings { + let lastComponent = bindingKey.split(separator: ".").last.map(String.init) + if key == lastComponent { + if let lookupKey = value.stringValue { + + return model?[lookupKey.split(separator: ".").last.map(String.init) ?? ""] + } + + } + } + } else { + + for (bindingKey, value) in bindings { + let lastComponent = bindingKey.split(separator: ".").last.map(String.init) + if key == lastComponent { + return stateModel.getValueFromStateData( + keyString: value.stringValue ?? "") + } + + } + } + + } + + return nil + } } diff --git a/Sources/BuilderIO/Schemas/BuilderContent.swift b/Sources/BuilderIO/Schemas/BuilderContent.swift index 8dacfb9..3c16512 100644 --- a/Sources/BuilderIO/Schemas/BuilderContent.swift +++ b/Sources/BuilderIO/Schemas/BuilderContent.swift @@ -16,24 +16,32 @@ public struct BuilderContentData: Codable, Identifiable { // Add Identifiable a public var width: Double? = nil // Optional width for the content block + public var httpRequests: [String: String]? = nil + // Custom init(from decoder:) - required because of 'id' public init(from decoder: Decoder) throws { self.id = UUID() // Generate a UUID when decoded let container = try decoder.container(keyedBy: CodingKeys.self) self.blocks = try container.decodeIfPresent([BuilderBlockModel].self, forKey: .blocks) ?? [] self.width = try container.decodeIfPresent(Double.self, forKey: .width) ?? nil + self.httpRequests = + try container.decodeIfPresent([String: String].self, forKey: .httpRequests) ?? nil } // Custom init for manual creation - public init(blocks: [BuilderBlockModel], width: Double? = nil) { + public init( + blocks: [BuilderBlockModel], width: Double? = nil, httpsRequests: [String: String]? = nil + ) { self.id = UUID() self.blocks = blocks self.width = width + self.httpRequests = httpsRequests } enum CodingKeys: String, CodingKey { case blocks case width + case httpRequests } // Equatable conformance (synthesized if all properties are Equatable) diff --git a/Sources/BuilderIO/Services/BuilderContentAPI.swift b/Sources/BuilderIO/Services/BuilderContentAPI.swift index 519b367..eae807b 100644 --- a/Sources/BuilderIO/Services/BuilderContentAPI.swift +++ b/Sources/BuilderIO/Services/BuilderContentAPI.swift @@ -158,4 +158,58 @@ public struct BuilderContentAPI { } } + public static func getDataFromBoundAPI(url: String) async throws -> AnyCodable { + // 1. Validate URL + guard let url = URL(string: url) else { + throw APIError.invalidURL + } + + // 2. Perform the network request + // The `data(from:)` method throws errors directly, + // so we use `try` and handle them in a `do-catch` block or let them propagate. + let (data, response) = try await URLSession.shared.data(from: url) + + // 3. Validate HTTP Response + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse(statusCode: 0) // Should ideally not happen with URLSession + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw APIError.invalidResponse(statusCode: httpResponse.statusCode) + } + + // 4. Check for Data + guard !data.isEmpty else { + throw APIError.noData + } + + // 5. Decode JSON using JSONDecoder + let decoder = JSONDecoder() + do { + let jsonResult = try decoder.decode(AnyCodable.self, from: data) + return jsonResult + } catch { + throw APIError.decodingFailed(error) + } + } +} + +enum APIError: Error, LocalizedError { + case invalidURL + case requestFailed(Error) + case invalidResponse(statusCode: Int) + case noData + case decodingFailed(Error) + + var errorDescription: String? { + switch self { + case .invalidURL: return "The provided URL is invalid." + case .requestFailed(let error): return "Network request failed: \(error.localizedDescription)" + case .invalidResponse(let statusCode): + return "Invalid server response with status code: \(statusCode)" + case .noData: return "No data received from the API." + case .decodingFailed(let error): + return "Failed to decode API response: \(error.localizedDescription)" + } + } } diff --git a/Tests/BuilderIOTests/BuilderIOPageViewTests.swift b/Tests/BuilderIOTests/BuilderIOPageViewTests.swift index f30be1b..965f60e 100644 --- a/Tests/BuilderIOTests/BuilderIOPageViewTests.swift +++ b/Tests/BuilderIOTests/BuilderIOPageViewTests.swift @@ -1,77 +1,77 @@ -import XCTest import BuilderIO import SnapshotTesting -import UIKit import SwiftUI +import UIKit +import XCTest @MainActor class BuilderIOPageViewTests: XCTestCase { - + static let record = false - + override func setUpWithError() throws { BuilderIOManager.configure(apiKey: "UNITTESTINGAPIKEY", customNavigationScheme: "builderio") - + continueAfterFailure = false } - + override func tearDownWithError() throws { // Clear all mocks after each test print(" 🚨 Tests deregistered") - + BuilderIOMockManager.shared.clearAllMocks() try super.tearDownWithError() } - - + func testTextView() throws { BuilderIOMockManager.shared.registerMock(for: "/text", with: "text", statusCode: 200) - + let hostingController = makeHostingController(for: "/text", width: 375, height: 812) - -       let expectation = XCTestExpectation(description: "Wait for view to render") - + + let expectation = XCTestExpectation(description: "Wait for view to render") + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - + assertSnapshot(matching: hostingController, as: .image, record: BuilderIOPageViewTests.record) expectation.fulfill() } - - + wait(for: [expectation], timeout: 3) } - + func testLayoutsView() throws { BuilderIOMockManager.shared.registerMock(for: "/layout", with: "layout", statusCode: 200) - + let hostingController = makeHostingController(for: "/layout", width: 375, height: 812) - -       let expectation = XCTestExpectation(description: "Wait for view to render") - + + let expectation = XCTestExpectation(description: "Wait for view to render") + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - + assertSnapshot(matching: hostingController, as: .image, record: BuilderIOPageViewTests.record) expectation.fulfill() } - - + wait(for: [expectation], timeout: 3) } - + /// - Returns: A UIHostingController wrapping the SwiftUI Text view. - func makeHostingController(for url: String, width: CGFloat, height: CGFloat) -> UIHostingController { - let view = BuilderIOPage(url: url, onClickEventHandler: { event in - print("Handle Event Action") - }) - + func makeHostingController(for url: String, width: CGFloat, height: CGFloat) + -> UIHostingController + { + let view = BuilderIOPage( + url: url, + onClickEventHandler: { event in + print("Handle Event Action") + }) + let hostingController = UIHostingController(rootView: view.frame(width: 375, height: 812)) hostingController.view.frame = CGRect(x: 0, y: 0, width: 375, height: 812) - + let window = UIWindow(frame: hostingController.view.frame) window.rootViewController = hostingController window.makeKeyAndVisible() - - + return hostingController } } diff --git a/Tests/BuilderIOTests/Helper/BuilderIOMockManager.swift b/Tests/BuilderIOTests/Helper/BuilderIOMockManager.swift index f8cc5ad..d028044 100644 --- a/Tests/BuilderIOTests/Helper/BuilderIOMockManager.swift +++ b/Tests/BuilderIOTests/Helper/BuilderIOMockManager.swift @@ -126,6 +126,12 @@ class BuilderIOMockManager { "https://pngimg.com/uploads/macbook/small/macbook_PNG65.png", "extension": "png", ], + [ + "name": "gloss", + "url": + "https://cdn.builder.io/api/v1/image/assets%2F89d6bbb44070475d9580fd22f21ef8f1%2F4c0ff0da35b24347bb87443f268979bd?width=2000", + "extension": "jpg", + ], ] func registerImageMock(statusCode: Int = 200) { diff --git a/Tests/BuilderIOTests/Resources/gloss.jpg b/Tests/BuilderIOTests/Resources/gloss.jpg new file mode 100644 index 0000000..bd6be69 Binary files /dev/null and b/Tests/BuilderIOTests/Resources/gloss.jpg differ diff --git a/Tests/BuilderIOTests/__Snapshots__/BuilderIOPageViewTests/testLayoutsView.1.png b/Tests/BuilderIOTests/__Snapshots__/BuilderIOPageViewTests/testLayoutsView.1.png index 965f881..692338a 100644 Binary files a/Tests/BuilderIOTests/__Snapshots__/BuilderIOPageViewTests/testLayoutsView.1.png and b/Tests/BuilderIOTests/__Snapshots__/BuilderIOPageViewTests/testLayoutsView.1.png differ diff --git a/Tests/BuilderIOTests/__Snapshots__/BuilderIOPageViewTests/testTextView.1.png b/Tests/BuilderIOTests/__Snapshots__/BuilderIOPageViewTests/testTextView.1.png index 806a3de..61d1ef8 100644 Binary files a/Tests/BuilderIOTests/__Snapshots__/BuilderIOPageViewTests/testTextView.1.png and b/Tests/BuilderIOTests/__Snapshots__/BuilderIOPageViewTests/testTextView.1.png differ