From 7399831717b8f506e703e09eba70df4e0718a704 Mon Sep 17 00:00:00 2001 From: Oscar Costoya Vidal Date: Fri, 20 Feb 2026 18:32:39 +0100 Subject: [PATCH 1/2] fix: avoid Bundle.module crash in brew-installed html generation --- .../Reporters/HTML/HTMLSnapshotReporter.swift | 78 +++++++++++++++++-- .../SnapshotReportCoreTests.swift | 32 ++++++++ 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/Sources/SnapshotReportCore/Reporters/HTML/HTMLSnapshotReporter.swift b/Sources/SnapshotReportCore/Reporters/HTML/HTMLSnapshotReporter.swift index 64cab64..4e73288 100644 --- a/Sources/SnapshotReportCore/Reporters/HTML/HTMLSnapshotReporter.swift +++ b/Sources/SnapshotReportCore/Reporters/HTML/HTMLSnapshotReporter.swift @@ -97,18 +97,80 @@ struct HTMLRenderer { } private func renderTemplate(report: SnapshotReport, outputDirectory: URL, customTemplatePath: String?) throws -> String { - let template: String + let template = try loadTemplate(customTemplatePath: customTemplatePath) + + let environment = Environment(trimBehaviour: .smart) + return try environment.renderTemplate(string: template, context: makeContext(report: report, outputDirectory: outputDirectory)) + } + + private func loadTemplate(customTemplatePath: String?) throws -> String { if let customTemplatePath { - template = try String(contentsOfFile: customTemplatePath, encoding: .utf8) - } else { - guard let resourceURL = Bundle.module.url(forResource: "default-report", withExtension: "stencil") else { - throw SnapshotReportError.writeFailed("Missing bundled template") + return try String(contentsOfFile: customTemplatePath, encoding: .utf8) + } + + for candidate in Self.defaultTemplateCandidateURLs() { + guard fileManager.fileExists(atPath: candidate.path) else { continue } + return try String(contentsOf: candidate, encoding: .utf8) + } + + let searchedPaths = Self.defaultTemplateCandidateURLs().map(\.path).joined(separator: "\n- ") + throw SnapshotReportError.writeFailed( + """ + Missing bundled template (default-report.stencil). + Searched: + - \(searchedPaths) + Provide --html-template or reinstall snapshot-report. + """ + ) + } + + static func defaultTemplateCandidateURLs( + executablePath: String = CommandLine.arguments.first ?? "" + ) -> [URL] { + let bundleName = "SnapshotReportKit_SnapshotReportCore.bundle" + let templateRelativePath = "Contents/Resources/default-report.stencil" + var candidateDirectories: [URL] = [] + + if executablePath.isEmpty == false { + let providedExecURL = URL(fileURLWithPath: executablePath) + candidateDirectories.append(providedExecURL.deletingLastPathComponent()) + + let resolvedExecURL = providedExecURL.resolvingSymlinksInPath() + candidateDirectories.append(resolvedExecURL.deletingLastPathComponent()) + } + + var seenDirectoryPaths = Set() + var uniqueDirectories: [URL] = [] + for directory in candidateDirectories { + let standardized = directory.standardizedFileURL.path + if seenDirectoryPaths.insert(standardized).inserted { + uniqueDirectories.append(directory) } - template = try String(contentsOf: resourceURL, encoding: .utf8) } - let environment = Environment(trimBehaviour: .smart) - return try environment.renderTemplate(string: template, context: makeContext(report: report, outputDirectory: outputDirectory)) + var candidates: [URL] = [] + for directory in uniqueDirectories { + let bundleInExecutableDirectory = directory + .appendingPathComponent(bundleName, isDirectory: true) + .appendingPathComponent(templateRelativePath) + candidates.append(bundleInExecutableDirectory) + + let bundleInLibexecDirectory = directory + .appendingPathComponent("..", isDirectory: true) + .appendingPathComponent("libexec", isDirectory: true) + .appendingPathComponent(bundleName, isDirectory: true) + .appendingPathComponent(templateRelativePath) + candidates.append(bundleInLibexecDirectory) + } + + let sourceTemplate = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Resources/default-report.stencil") + candidates.append(sourceTemplate) + + return candidates } private func makeContext(report: SnapshotReport, outputDirectory: URL) -> [String: Any] { diff --git a/Tests/SnapshotReportCoreTests/SnapshotReportCoreTests.swift b/Tests/SnapshotReportCoreTests/SnapshotReportCoreTests.swift index bc01637..e577166 100644 --- a/Tests/SnapshotReportCoreTests/SnapshotReportCoreTests.swift +++ b/Tests/SnapshotReportCoreTests/SnapshotReportCoreTests.swift @@ -354,3 +354,35 @@ func htmlReporterRendersFailedDetailsWithSnapshotDiffFailureOrder() throws { return } } + +@Test +func htmlRendererTemplateCandidatesIncludeResolvedSymlinkPath() throws { + let temporaryRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("SnapshotReportCoreTests-template-candidates-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + + let cellarBin = temporaryRoot + .appendingPathComponent("Cellar/snapshot-report-nightly/0.1.0/bin", isDirectory: true) + try FileManager.default.createDirectory(at: cellarBin, withIntermediateDirectories: true) + + let cellarExecutable = cellarBin.appendingPathComponent("snapshot-report-nightly") + try Data().write(to: cellarExecutable) + + let linkedBin = temporaryRoot.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: linkedBin, withIntermediateDirectories: true) + let linkedExecutable = linkedBin.appendingPathComponent("snapshot-report-nightly") + try FileManager.default.createSymbolicLink(atPath: linkedExecutable.path, withDestinationPath: cellarExecutable.path) + + let candidates = HTMLRenderer.defaultTemplateCandidateURLs(executablePath: linkedExecutable.path).map(\.path) + let expected = cellarBin + .appendingPathComponent("SnapshotReportKit_SnapshotReportCore.bundle/Contents/Resources/default-report.stencil") + .path + + #expect(candidates.contains(expected)) +} + +@Test +func htmlRendererTemplateCandidatesIncludeSourceFallback() { + let candidates = HTMLRenderer.defaultTemplateCandidateURLs(executablePath: "/tmp/snapshot-report").map(\.path) + #expect(candidates.contains { $0.hasSuffix("/Sources/SnapshotReportCore/Resources/default-report.stencil") }) +} From b3e43a474efb105be64213ec1e1accb090614dd8 Mon Sep 17 00:00:00 2001 From: Oscar Costoya Vidal Date: Fri, 20 Feb 2026 18:44:09 +0100 Subject: [PATCH 2/2] test: cover CLI verbose diagnostics and logging behavior --- Package.swift | 3 +- Sources/snapshot-report/CLI/main.swift | 45 ++++++++++-- .../SnapshotReportCLITests.swift | 69 +++++++++++++++++++ 3 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 Tests/SnapshotReportCoreTests/SnapshotReportCLITests.swift diff --git a/Package.swift b/Package.swift index 632300f..9ad4f6a 100644 --- a/Package.swift +++ b/Package.swift @@ -56,7 +56,8 @@ let package = Package( name: "SnapshotReportCoreTests", dependencies: [ "SnapshotReportCore", - "SnapshotReportTesting" + "SnapshotReportTesting", + "snapshot-report" ] ) ] diff --git a/Sources/snapshot-report/CLI/main.swift b/Sources/snapshot-report/CLI/main.swift index ac16aaf..4afa11a 100644 --- a/Sources/snapshot-report/CLI/main.swift +++ b/Sources/snapshot-report/CLI/main.swift @@ -68,7 +68,7 @@ struct CLI { CLIUI.success("Generated report \(options.formats.map(\.rawValue).joined(separator: ", ")) at \(options.outputDirectory.path)") } - private static func parse(arguments: [String]) throws -> Options { + static func parse(arguments: [String]) throws -> Options { var inputs: [URL] = [] var inputDirectories: [URL] = [] var xcresultInputs: [URL] = [] @@ -209,7 +209,7 @@ struct CLI { ) } - private struct Options { + struct Options { let inputs: [URL] let inputDirectories: [URL] let xcresultInputs: [URL] @@ -432,10 +432,10 @@ private final class ReportWriteState: @unchecked Sendable { } } -private enum CLIUI { - private static let lock = NSLock() +enum CLIUI { private static let prefix = "[snapshot-report]" private static let state = VerboseState() + private static let writerState = LogWriterState() static func setVerbose(_ value: Bool) { state.set(value) @@ -470,14 +470,22 @@ private enum CLIUI { } static func log(_ message: String) { - lock.lock() - print(message) - lock.unlock() + let currentWriter = writerState.get() + currentWriter(message) } private static func verboseEnabled() -> Bool { state.get() } + + static func setWriterForTesting(_ writer: @escaping @Sendable (String) -> Void) { + writerState.set(writer) + } + + static func resetForTesting() { + writerState.reset() + state.set(false) + } } private final class VerboseState: @unchecked Sendable { @@ -497,6 +505,29 @@ private final class VerboseState: @unchecked Sendable { } } +private final class LogWriterState: @unchecked Sendable { + private let lock = NSLock() + private var writer: @Sendable (String) -> Void = { message in print(message) } + + func set(_ newWriter: @escaping @Sendable (String) -> Void) { + lock.lock() + writer = newWriter + lock.unlock() + } + + func get() -> @Sendable (String) -> Void { + lock.lock() + defer { lock.unlock() } + return writer + } + + func reset() { + lock.lock() + writer = { message in print(message) } + lock.unlock() + } +} + private func _resolveOnPATH(_ name: String) -> String? { let process = Process() let pipe = Pipe() diff --git a/Tests/SnapshotReportCoreTests/SnapshotReportCLITests.swift b/Tests/SnapshotReportCoreTests/SnapshotReportCLITests.swift new file mode 100644 index 0000000..8b1ab9d --- /dev/null +++ b/Tests/SnapshotReportCoreTests/SnapshotReportCLITests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing +@testable import snapshot_report + +private final class TestLogStore: @unchecked Sendable { + private let lock = NSLock() + private var lines: [String] = [] + + func append(_ line: String) { + lock.lock() + lines.append(line) + lock.unlock() + } + + func snapshot() -> [String] { + lock.lock() + defer { lock.unlock() } + return lines + } + + func removeAll() { + lock.lock() + lines.removeAll() + lock.unlock() + } +} + +@Test +func verboseFlagAndDiagnosticLogs() throws { + let verboseOptions = try CLI.parse(arguments: ["--input", "/tmp/report.json", "--verbose"]) + #expect(verboseOptions.verbose) + + let defaultOptions = try CLI.parse(arguments: ["--input", "/tmp/report.json"]) + #expect(defaultOptions.verbose == false) + + let store = TestLogStore() + + CLIUI.resetForTesting() + CLIUI.setWriterForTesting { line in + store.append(line) + } + defer { CLIUI.resetForTesting() } + + CLIUI.setVerbose(false) + CLIUI.step("Resolving input files") + CLIUI.debug("Merged report: 2 tests (2 passed, 0 failed, 0 skipped)") + CLIUI.success("Generated report html at /tmp/output") + + let nonVerboseLines = store.snapshot() + #expect(nonVerboseLines.count == 1) + #expect(nonVerboseLines[0] == "[snapshot-report] success: Generated report html at /tmp/output") + + store.removeAll() + + CLIUI.setVerbose(true) + CLIUI.header("snapshot-report") + CLIUI.step("Resolving input files") + CLIUI.progress("XCResult 1/1: SnapshotTests.xcresult") + CLIUI.debug("Total processing time: 0.123s") + CLIUI.success("Generated report html at /tmp/output") + + #expect(store.snapshot() == [ + "[snapshot-report] snapshot-report", + "[snapshot-report] step: Resolving input files", + "[snapshot-report] progress: XCResult 1/1: SnapshotTests.xcresult", + "[snapshot-report] debug: Total processing time: 0.123s", + "[snapshot-report] success: Generated report html at /tmp/output", + ]) +}