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
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ let package = Package(
name: "SnapshotReportCoreTests",
dependencies: [
"SnapshotReportCore",
"SnapshotReportTesting"
"SnapshotReportTesting",
"snapshot-report"
]
)
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> 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<String>()
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] {
Expand Down
45 changes: 38 additions & 7 deletions Sources/snapshot-report/CLI/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -209,7 +209,7 @@ struct CLI {
)
}

private struct Options {
struct Options {
let inputs: [URL]
let inputDirectories: [URL]
let xcresultInputs: [URL]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Expand Down
69 changes: 69 additions & 0 deletions Tests/SnapshotReportCoreTests/SnapshotReportCLITests.swift
Original file line number Diff line number Diff line change
@@ -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",
])
}
32 changes: 32 additions & 0 deletions Tests/SnapshotReportCoreTests/SnapshotReportCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") })
}