diff --git a/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift b/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift index b084eda59..51a7e908c 100644 --- a/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift +++ b/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift @@ -130,16 +130,22 @@ public actor EditorAssetLibrary { let editorRepresentation = try manifest.buildEditorRepresentation(for: self.configuration) try bundle.writeManifest(editorRepresentation: editorRepresentation) - try await withThrowingTaskGroup { group in + await withTaskGroup { group in let links = (manifest.scripts + manifest.styles).filter { self.isSupportedAsset($0) } - + for asset in links { group.addTask { - (asset, try await self.fetchAsset(url: asset, into: bundle)) + do { + try await self.fetchAsset(url: asset, into: bundle) + } catch { + // Log and continue - individual asset failures shouldn't block the editor + // This handles cases like content blockers blocking analytics scripts + log(.warn, "Failed to download asset \(asset.lastPathComponent): \(error.localizedDescription)") + } } } - - for try await _ in group { + + for await _ in group { complete += 1 await progress?(EditorProgress(completed: complete, total: links.count)) } @@ -150,6 +156,7 @@ public actor EditorAssetLibrary { /// Downloads a single asset and copies it into the temporary bundle directory. /// + @discardableResult private func fetchAsset(url: URL, into bundle: EditorAssetBundle) async throws -> URL { let tempUrl = try await logExecutionTime("Downloading \(url.lastPathComponent)") { try await httpClient.download(URLRequest(method: .GET, url: url)).0 @@ -157,7 +164,7 @@ public actor EditorAssetLibrary { let destinationPath = bundle.bundleRoot.appending(path: url.path(percentEncoded: false)) let destinationParent = destinationPath.deletingLastPathComponent() - + // Ensure the destination directory exists try FileManager.default.createDirectory(at: destinationParent, withIntermediateDirectories: true) diff --git a/ios/Tests/GutenbergKitTests/Stores/EditorAssetLibraryTests.swift b/ios/Tests/GutenbergKitTests/Stores/EditorAssetLibraryTests.swift index 0e354a441..cd18ec507 100644 --- a/ios/Tests/GutenbergKitTests/Stores/EditorAssetLibraryTests.swift +++ b/ios/Tests/GutenbergKitTests/Stores/EditorAssetLibraryTests.swift @@ -676,7 +676,12 @@ struct EditorAssetLibraryTests { """ let mockClient = EditorAssetLibraryMockHTTPClient() - mockClient.urlResponseHandler = { _ in Data(manifestJSON.utf8) } + mockClient.urlResponseHandler = { url in + if url.path.contains("editor-assets") { + return Data(manifestJSON.utf8) + } + return Data("mock content".utf8) + } let library = makeLibrary(httpClient: mockClient, cachePolicy: .ignore) @@ -769,7 +774,12 @@ struct EditorAssetLibraryTests { """ let mockClient = EditorAssetLibraryMockHTTPClient() - mockClient.urlResponseHandler = { _ in Data(manifestJSON.utf8) } + mockClient.urlResponseHandler = { url in + if url.path.contains("editor-assets") { + return Data(manifestJSON.utf8) + } + return Data("mock content".utf8) + } let library = makeLibrary(httpClient: mockClient, cachePolicy: .ignore) @@ -816,6 +826,53 @@ struct EditorAssetLibraryTests { #expect(progressTracker.count == 1) #expect(progressTracker.updates.first?.total == 1) } + + @Test("buildBundle continues when individual asset downloads fail") + func buildBundleContinuesWhenAssetDownloadsFail() async throws { + let manifestJSON = """ + { + "scripts": "", + "styles": "", + "allowed_block_types": ["core/paragraph"] + } + """ + + let mockClient = EditorAssetLibraryMockHTTPClient() + mockClient.urlResponseHandler = { url in + if url.path.contains("editor-assets") { + return Data(manifestJSON.utf8) + } + // Simulate content blocker blocking stats.js + if url.host == "blocked.com" { + throw URLError(.badURL) + } + return Data("mock content".utf8) + } + + let library = makeLibrary(httpClient: mockClient, cachePolicy: .ignore) + + let manifest = try await library.fetchManifest() + + // Should NOT throw - individual asset failures are caught and logged + let bundle = try await library.buildBundle(for: manifest) + + // Bundle should be created successfully + #expect(!bundle.id.isEmpty) + + // Verify progress was reported for all assets (including the failed one) + #expect(mockClient.downloadCallCount == 3) + + // Verify the successful assets were downloaded + let bundleRoot = await library.bundleRoot(for: bundle) + let goodScriptPath = bundleRoot.appending(path: "/good-script.js") + let stylePath = bundleRoot.appending(path: "/style.css") + #expect(FileManager.default.fileExists(at: goodScriptPath)) + #expect(FileManager.default.fileExists(at: stylePath)) + + // The failed asset should not exist + let failedScriptPath = bundleRoot.appending(path: "/stats.js") + #expect(!FileManager.default.fileExists(at: failedScriptPath)) + } } // MARK: - Progress Tracker for Tests @@ -844,8 +901,6 @@ final class EditorAssetLibraryMockHTTPClient: EditorHTTPClientProtocol, @uncheck var getCallCount = 0 var downloadCallCount = 0 var downloadedURLs: [URL] = [] - var downloadResponse: URL? - var shouldThrowError: Error? private let lock = NSLock() /// URLs requested via `perform(_:)`. Use this to verify which endpoints were called. @@ -854,8 +909,10 @@ final class EditorAssetLibraryMockHTTPClient: EditorHTTPClientProtocol, @uncheck lock.withLock { _requestedURLs } } - /// Handler for generating response data based on request URL. Defaults to returning empty data. - var urlResponseHandler: ((URL) -> Data) = { _ in Data() } + /// Handler for generating response data based on request URL. + /// Can throw to simulate failures for specific URLs. + /// Used by both `perform()` and `download()` methods. + var urlResponseHandler: ((URL) throws -> Data) = { _ in Data() } func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { let url = try #require(urlRequest.url) @@ -865,11 +922,7 @@ final class EditorAssetLibraryMockHTTPClient: EditorHTTPClientProtocol, @uncheck _requestedURLs.append(url) } - if let error = shouldThrowError { - throw error - } - - let responseData = urlResponseHandler(url) + let responseData = try urlResponseHandler(url) let response = HTTPURLResponse( url: url, @@ -889,9 +942,10 @@ final class EditorAssetLibraryMockHTTPClient: EditorHTTPClientProtocol, @uncheck downloadedURLs.append(url) } - if let error = shouldThrowError { - throw error - } + let data = try urlResponseHandler(url) + + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try data.write(to: tempURL) let response = HTTPURLResponse( url: url, @@ -900,16 +954,6 @@ final class EditorAssetLibraryMockHTTPClient: EditorHTTPClientProtocol, @uncheck headerFields: nil )! - // Create a temporary file with some content - let tempURL = - downloadResponse - ?? FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - - if downloadResponse == nil { - try Data("mock content".utf8).write(to: tempURL) - } - return (tempURL, response) } }