Unable to find Bundle in package target tests when package depends on another package containing resources accessed via Bundle.module

Versions

Xcode: 12.2/12.3
iOS: 13+

Problem

I'm using local Swift packages to organise code within my workspace. This has been working fine until I tried adding snapshot tests to my feature packages, which depend on another package containing resources.

My workspace is currently structured like this (where -> means depends on):

MyApp (Xcode Project) -> Login (Package) -> UILibrary (Package)

When adding snapshot tests to my UILibrary, those snapshot tests work fine, and everything loads correctly. However, when I add snapshot tests to my Login package (which depends on UILibrary) the tests reach a fatal error when the UILibrary is trying to load the Color assets which uses the generated Bundle.module extension generated by Xcode.

fatalError("unable to find bundle named UILibrary_UILibrary")

Does anyone have any ideas on how to fix/workaround this? I'm currently facing re-organising my code as Xcode frameworks instead of Swift packages, as I've not had this issue on previous projects.

It looks like a similar issue to this: SwiftUI Previewer crashes while in swift package that depends on another's packages Bundle.module reference

Further details

Things I have tried:

  • Adding UILibrary as a dependency to my LoginTests – still can't load.
  • Using something like Bundle(for: ClassForBundle.self) instead of Bundle.module in UILibrary – still can't load.

My Login Package.swift file looks like this:

let package = Package(
	name: "Login",
	platforms: [.iOS(.v13)],
	products: [
		.library(name: "Login", type: .dynamic, targets: ["Login"])
	],
	dependencies: [
		.package(path: "../Shared/UILibrary"),
		.package(name: "SnapshotTesting", url: "https://github.com/pointfreeco/swift-snapshot-testing.git", from: "1.8.1"),
	],
	targets: [
		.target(name: "Login", dependencies: ["UILibrary"]),
		.testTarget(name: "LoginTests", dependencies: ["Login", "SnapshotTesting"])
	]
)

My UILibrary Package.swift file looks like this:

let package = Package(
	name: "UILibrary",
	platforms: [.iOS(.v13)],
	products: [
		.library(name: "UILibrary", type: .dynamic, targets: ["UILibrary"])
	],
	dependencies: [
		.package(name: "SnapshotTesting", url: "https://github.com/pointfreeco/swift-snapshot-testing.git", from: "1.8.1"),
	],
	targets: [
		.target(name: "UILibrary", dependencies: [], resources: [.process("Resources")]),
		.testTarget(name: "UILibraryTests", dependencies: ["UILibrary", "SnapshotTesting"])
	]
)

Note: Resources contains my colors & image asset catalogs so they are processed as part of the UILibrary target.

1 Like

The Apple bug report I filed (FB8880328) went from "Recent Similar Reports: None" to "Recent Similar Reports: Less than 10". So it seems it is not gaining traction internally over at Apple. If you haven't already, filing a bug report would help bubble this up to get it fixed. https://feedbackassistant.apple.com/

I've worked around the colors issue with the following code.

import Foundation
import SwiftUI
import UIKit

public extension Color {
    
    // Commenting out color assets out for now due to Swift package inability to reference them across bundles in UI previews and tests
    static let greenDarkest = Color(hex: "#008C21")//Color("GreenDarkest", bundle: .module)

    // This color has a dark variant, so use UIColor's trait response before wrapping into Color.
    static let redDark = Color(redDarkUIColor) //) Color("RedDark", bundle: .module)
    
    private static let redDarkUIColor = UIColor { trait in
        if trait.userInterfaceStyle == .dark {
            return UIColor(Color(hex: "#D3041F"))
        }
        return UIColor(Color(hex: "#B10420"))
    }
}

extension Color {
    init(hex: String) {
        let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
        var int: UInt64 = 0
        Scanner(string: hex).scanHexInt64(&int)
        let a, r, g, b: UInt64
        switch hex.count {
        case 3: // RGB (12-bit)
            (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
        case 6: // RGB (24-bit)
            (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
        case 8: // ARGB (32-bit)
            (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
        default:
            (a, r, g, b) = (1, 1, 1, 0)
        }

        self.init(
            .sRGB,
            red: Double(r) / 255,
            green: Double(g) / 255,
            blue:  Double(b) / 255,
            opacity: Double(a) / 255
        )
    }
}

Not sure how to workaround for images.

1 Like

I found workaround for all assets. Posted it here:


Please be careful when determining exact bundleNameIOS and bundleNameMacOs for your packages
1 Like

Thanks for that. Unfortunately I need images, colors and fonts so that won't work for me but I appreciate the help. I'll file a bug report later this evening with an example project so hopefully will help prioritise it.

1 Like

Thanks for that, it does work for me but with some changes to maintain existing behaviour whilst adding the local fallbacks. See below:

private class CurrentBundleFinder {}

extension Foundation.Bundle {

	static var myModule: Bundle = {

		let bundleName = "UILibrary_UILibrary"
		let localBundleName = "LocalPackages_UILibrary"

		let candidates = [
			/* Bundle should be present here when the package is linked into an App. */
			Bundle.main.resourceURL,

			/* Bundle should be present here when the package is linked into a framework. */
			Bundle(for: CurrentBundleFinder.self).resourceURL,

			/* For command-line tools. */
			Bundle.main.bundleURL,

			/* Bundle should be present here when running previews from a different package (this is the path to "…/Debug-iphonesimulator/"). */
			Bundle(for: CurrentBundleFinder.self).resourceURL?.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent(),
			Bundle(for: CurrentBundleFinder.self).resourceURL?.deletingLastPathComponent().deletingLastPathComponent(),
		]

		for candidate in candidates {
			let bundlePath = candidate?.appendingPathComponent(bundleName + ".bundle")
			if let bundle = bundlePath.flatMap(Bundle.init(url:)) {
				return bundle
			}

			let localBundlePath = candidate?.appendingPathComponent(localBundleName + ".bundle")
			if let bundle = bundlePath.flatMap(Bundle.init(url:)) {
				return bundle
			}
		}

		fatalError("unable to find bundle")

	}()

}

The problem is public packages are using Bundle.module (in my case SwiftGen) so it's going to be difficult to merge in a workaround like this into something I don't have much control over. I can obviously raise a PR, but I doubt it would be approved as the problem is with Bundle.module behaviour rather than the library itself.

I really appreciate you sharing this solution though – it's given me some food for thought! :slight_smile:

Edit: For the SwiftGen issue above I'm going to look at using a custom template which generates this workaround automatically, which should in theory avoid the need of having to raise a PR to modify SwiftGen itself.

I also use SwiftGen. Just write script for fixing result swift file

swiftgen config run --config swiftgen.yml

sed -i '' "s/Bundle.module/Bundle.myModule/" "Modules/CoreLib/Sources/Resources/XCAssets+Generated.swift"
sed -i '' "s/Bundle.module/Bundle.myModule/" "Modules/CoreLib/Sources/Resources/Strings+Generated.swift"

"sed -i" is just replaces Bundle.module with Bundle.myModule
Also, you can use your own templace for codogeneration. But just replace one with another is simpler (its just temporary workaround anyway)

1 Like

Ahh nice idea! Thanks for sharing I'll give that a go, seems much simpler than using a custom template :ok_hand:

1 Like

Thanks @Nekitosss that script worked perfectly! I can leave that in and just wait for more updates on this and hope for a fix sometime soon :slight_smile:

Not that this (hacky) workaround can be done in a much simpler way.

All SwiftGen templates that use Bundle information support a bundle template parameter. You can read more about it here (for strings for example), and how to set them here.

For example, change your config to something like this:

strings:
  inputs:
    - en.lproj/Localizable.strings
    - en.lproj/Localizable.stringsdict
  outputs:
    templateName: structured-swift5
    output: Strings.swift
    params:
      bundle: Bundle.myModule
Terms of Service

Privacy Policy

Cookie Policy