Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand All @@ -150,14 +156,15 @@ 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
}

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)

Expand Down
92 changes: 68 additions & 24 deletions ios/Tests/GutenbergKitTests/Stores/EditorAssetLibraryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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": "<script src=\\"https://example.com/good-script.js\\"></script><script src=\\"https://blocked.com/stats.js\\"></script>",
"styles": "<link rel=\\"stylesheet\\" href=\\"https://example.com/style.css\\">",
"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
Expand Down Expand Up @@ -844,8 +901,6 @@ final class EditorAssetLibraryMockHTTPClient: EditorHTTPClientProtocol, @uncheck
var getCallCount = 0
var downloadCallCount = 0
var downloadedURLs: [URL] = []
var downloadResponse: URL?
var shouldThrowError: Error?
Comment on lines -847 to -848
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in favor of composing the urlResponseHandler for unique scenarios—e.g., URL-specific data and errors.

private let lock = NSLock()

/// URLs requested via `perform(_:)`. Use this to verify which endpoints were called.
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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)
}
}
Loading