|
| 1 | +import Foundation |
| 2 | +import Testing |
| 3 | + |
| 4 | +@testable import HudsonUI |
| 5 | + |
| 6 | +// MARK: - The regression tests for the 0.1.1 launch crash |
| 7 | +// |
| 8 | +// Hudson 0.1.0 and 0.1.1 died on launch on every Mac except the maintainer's, |
| 9 | +// inside `Bundle.module`: |
| 10 | +// |
| 11 | +// 0 libswiftCore _assertionFailure(_:_:file:line:flags:) |
| 12 | +// 1 HudsonApp closure #1 in variable initialization expression of |
| 13 | +// static NSBundle.module |
| 14 | +// 5 HudsonApp specialized static Typography.register() |
| 15 | +// 6 HudsonApp specialized static Typography.serif(_:_:) |
| 16 | +// 7 HudsonApp closure #1 in RootView.loadingPlaceholder.getter |
| 17 | +// |
| 18 | +// SwiftPM's generated accessor probes the app bundle ROOT and an absolute |
| 19 | +// `.build` path from the compiling machine, then calls `fatalError`. A packaged |
| 20 | +// .app matches neither: codesign puts the nested bundle in Contents/Resources, |
| 21 | +// and a user's Mac has no .build directory. |
| 22 | +// |
| 23 | +// These tests drive `bundledFontURLs(searchPaths:)` against the real on-disk |
| 24 | +// layouts rather than the ambient one, because the ambient layout is exactly |
| 25 | +// what hid the bug — the dev loop resolved, so nothing ever exercised the |
| 26 | +// shape a user receives. |
| 27 | + |
| 28 | +/// Builds a throwaway resource bundle holding one file named like a font. |
| 29 | +/// |
| 30 | +/// The content is not a real typeface: every assertion here is about whether |
| 31 | +/// the URL is *found*, and CoreText never sees these. Keeping it fake also |
| 32 | +/// keeps the tests independent of which faces the app happens to ship. |
| 33 | +private func makeResourceBundle( |
| 34 | + at directory: URL, fontSubdirectory: String? = nil |
| 35 | +) throws -> URL { |
| 36 | + let bundle = directory.appending(path: Typography.resourceBundleName) |
| 37 | + let fontDirectory = fontSubdirectory.map { bundle.appending(path: $0) } ?? bundle |
| 38 | + try FileManager.default.createDirectory(at: fontDirectory, withIntermediateDirectories: true) |
| 39 | + try Data("not a real typeface".utf8) |
| 40 | + .write(to: fontDirectory.appending(path: "Newsreader.ttf")) |
| 41 | + return bundle |
| 42 | +} |
| 43 | + |
| 44 | +private func makeScratchDirectory() throws -> URL { |
| 45 | + let directory = URL(fileURLWithPath: NSTemporaryDirectory()) |
| 46 | + .appending(path: "TypographyBundleTests-\(UUID().uuidString)") |
| 47 | + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) |
| 48 | + return directory |
| 49 | +} |
| 50 | + |
| 51 | +// MARK: - The shape a user actually receives |
| 52 | + |
| 53 | +/// **The regression test for the reported crash.** |
| 54 | +/// |
| 55 | +/// Reproduces a packaged `.app`: `Hudson.app/Contents/Resources/` holds the |
| 56 | +/// nested resource bundle, and `Hudson.app/` itself does not. That is the one |
| 57 | +/// arrangement `Bundle.module` cannot resolve, and the one every download has. |
| 58 | +@Test func findsFontsInAPackagedAppResourcesDirectory() throws { |
| 59 | + let scratch = try makeScratchDirectory() |
| 60 | + defer { try? FileManager.default.removeItem(at: scratch) } |
| 61 | + |
| 62 | + let appBundle = scratch.appending(path: "Hudson.app") |
| 63 | + let resources = appBundle.appending(path: "Contents/Resources") |
| 64 | + try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) |
| 65 | + _ = try makeResourceBundle(at: resources) |
| 66 | + |
| 67 | + let found = Typography.bundledFontURLs(searchPaths: [resources, appBundle]) |
| 68 | + #expect(found.count == 1) |
| 69 | + #expect(found.first?.lastPathComponent == "Newsreader.ttf") |
| 70 | +} |
| 71 | + |
| 72 | +/// The bundle root is searched too, which is the `swift run HudsonApp` layout: |
| 73 | +/// SwiftPM drops `Hudson_HudsonUI.bundle` beside the executable rather than |
| 74 | +/// inside a `Resources` directory. Both shapes have to work from one code path, |
| 75 | +/// or the dev loop and the shipped app disagree again. |
| 76 | +@Test func findsFontsBesideTheExecutable() throws { |
| 77 | + let scratch = try makeScratchDirectory() |
| 78 | + defer { try? FileManager.default.removeItem(at: scratch) } |
| 79 | + _ = try makeResourceBundle(at: scratch) |
| 80 | + |
| 81 | + #expect(Typography.bundledFontURLs(searchPaths: [scratch]).count == 1) |
| 82 | +} |
| 83 | + |
| 84 | +// MARK: - The second bug: the fonts never registered at all |
| 85 | + |
| 86 | +/// SwiftPM flattens `Resources/Fonts/*.ttf` to the bundle root, so the original |
| 87 | +/// `subdirectory: "Fonts"` matched nothing and no bundled face was ever |
| 88 | +/// registered — including on the maintainer's Mac, where the app quietly |
| 89 | +/// rendered in system fonts instead of Newsreader. |
| 90 | +@Test func findsFontsFlattenedIntoTheBundleRoot() throws { |
| 91 | + let scratch = try makeScratchDirectory() |
| 92 | + defer { try? FileManager.default.removeItem(at: scratch) } |
| 93 | + _ = try makeResourceBundle(at: scratch, fontSubdirectory: nil) |
| 94 | + |
| 95 | + let found = Typography.bundledFontURLs(searchPaths: [scratch]) |
| 96 | + #expect(found.count == 1, "a flattened bundle is the layout SwiftPM actually produces") |
| 97 | +} |
| 98 | + |
| 99 | +/// The nested layout is probed as well, so a future SwiftPM that stops |
| 100 | +/// flattening — or a deliberate move back into a folder — does not silently |
| 101 | +/// strip the typography again. |
| 102 | +@Test func findsFontsInsideANestedFontsDirectory() throws { |
| 103 | + let scratch = try makeScratchDirectory() |
| 104 | + defer { try? FileManager.default.removeItem(at: scratch) } |
| 105 | + _ = try makeResourceBundle(at: scratch, fontSubdirectory: "Fonts") |
| 106 | + |
| 107 | + #expect(Typography.bundledFontURLs(searchPaths: [scratch]).count == 1) |
| 108 | +} |
| 109 | + |
| 110 | +// MARK: - Absence must cost fidelity, never a launch |
| 111 | + |
| 112 | +/// The crash was not "the fonts are missing", it was "the app decided a missing |
| 113 | +/// bundle is fatal". A resolver that finds nothing has to return empty and let |
| 114 | +/// `resolved(_:size:weight:fallback:)` fall back to system faces. |
| 115 | +@Test func returnsEmptyRatherThanCrashingWhenNoBundleExists() throws { |
| 116 | + let scratch = try makeScratchDirectory() |
| 117 | + defer { try? FileManager.default.removeItem(at: scratch) } |
| 118 | + |
| 119 | + #expect(Typography.bundledFontURLs(searchPaths: [scratch]).isEmpty) |
| 120 | +} |
| 121 | + |
| 122 | +/// An empty search path list is the degenerate version of the same guarantee. |
| 123 | +@Test func returnsEmptyForNoSearchPathsAtAll() { |
| 124 | + #expect(Typography.bundledFontURLs(searchPaths: []).isEmpty) |
| 125 | +} |
| 126 | + |
| 127 | +/// A bundle directory that exists but holds no `.ttf` must also be survivable — |
| 128 | +/// `Bundle(url:)` succeeds on any directory, so this is a reachable state |
| 129 | +/// whenever a build drops the fonts. |
| 130 | +@Test func returnsEmptyForABundleContainingNoFonts() throws { |
| 131 | + let scratch = try makeScratchDirectory() |
| 132 | + defer { try? FileManager.default.removeItem(at: scratch) } |
| 133 | + try FileManager.default.createDirectory( |
| 134 | + at: scratch.appending(path: Typography.resourceBundleName), |
| 135 | + withIntermediateDirectories: true) |
| 136 | + |
| 137 | + #expect(Typography.bundledFontURLs(searchPaths: [scratch]).isEmpty) |
| 138 | +} |
| 139 | + |
| 140 | +// MARK: - Registration itself |
| 141 | + |
| 142 | +/// `register()` is the frame that crashed. Calling it must be safe regardless |
| 143 | +/// of what the ambient layout holds, and must stay idempotent. |
| 144 | +@Test @MainActor func registerIsSafeAndIdempotent() { |
| 145 | + Typography.register() |
| 146 | + Typography.register() |
| 147 | + // Reaching here at all is the assertion: the old implementation trapped |
| 148 | + // inside Bundle.module on any machine without the compiling .build path. |
| 149 | + #expect(Bool(true)) |
| 150 | +} |
| 151 | + |
| 152 | +/// The real ambient lookup must never trap in the environment the suite runs |
| 153 | +/// in, which is the third shipping shape (`.build/<config>` beside the |
| 154 | +/// `.xctest`). |
| 155 | +@Test func ambientSearchPathsResolveWithoutTrapping() { |
| 156 | + #expect(!Typography.resourceSearchPaths().isEmpty) |
| 157 | + _ = Typography.bundledFontURLs() |
| 158 | +} |
0 commit comments