diff --git a/Sources/SnapshotReportCore/Reporters/HTML/HTMLSnapshotReporter.swift b/Sources/SnapshotReportCore/Reporters/HTML/HTMLSnapshotReporter.swift index ca147d3..74564ca 100644 --- a/Sources/SnapshotReportCore/Reporters/HTML/HTMLSnapshotReporter.swift +++ b/Sources/SnapshotReportCore/Reporters/HTML/HTMLSnapshotReporter.swift @@ -108,16 +108,14 @@ struct HTMLRenderer { return try String(contentsOfFile: customTemplatePath, encoding: .utf8) } - if let moduleTemplateURL = Bundle.module.url(forResource: "default-report", withExtension: "stencil") { - return try String(contentsOf: moduleTemplateURL, encoding: .utf8) - } - - for candidate in Self.defaultTemplateCandidateURLs() { + for candidate in Self.environmentTemplateCandidateURLs() + 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- ") + let searchedPaths = (Self.environmentTemplateCandidateURLs() + Self.defaultTemplateCandidateURLs()) + .map(\.path) + .joined(separator: "\n- ") throw SnapshotReportError.writeFailed( """ Missing bundled template (default-report.stencil). @@ -136,6 +134,14 @@ struct HTMLRenderer { "default-report.stencil", "Contents/Resources/default-report.stencil", ] + let templateBasenames = ["default-report.stencil"] + let bundleBaseRelativeDirectories = [ + "", + "libexec", + "share", + "share/snapshot-report", + "Resources", + ] var candidateDirectories: [URL] = [] if executablePath.isEmpty == false { @@ -155,20 +161,43 @@ struct HTMLRenderer { } } - var candidates: [URL] = [] + var searchRoots: [URL] = [] for directory in uniqueDirectories { - for templateRelativePath in templateRelativePaths { - 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) + var current = directory + for _ in 0..<5 { + searchRoots.append(current) + let parent = current.deletingLastPathComponent() + if parent.path == current.path { break } + current = parent + } + } + + var seenRootPaths = Set() + var uniqueRoots: [URL] = [] + for root in searchRoots { + let standardized = root.standardizedFileURL.path + if seenRootPaths.insert(standardized).inserted { + uniqueRoots.append(root) + } + } + + var candidates: [URL] = [] + for root in uniqueRoots { + for baseRelativeDirectory in bundleBaseRelativeDirectories { + let base = baseRelativeDirectory.isEmpty + ? root + : root.appendingPathComponent(baseRelativeDirectory, isDirectory: true) + + for templateRelativePath in templateRelativePaths { + let bundledTemplate = base + .appendingPathComponent(bundleName, isDirectory: true) + .appendingPathComponent(templateRelativePath) + candidates.append(bundledTemplate) + } + + for templateBasename in templateBasenames { + candidates.append(base.appendingPathComponent(templateBasename)) + } } } @@ -179,6 +208,50 @@ struct HTMLRenderer { .appendingPathComponent("Resources/default-report.stencil") candidates.append(sourceTemplate) + var seenCandidatePaths = Set() + var uniqueCandidates: [URL] = [] + for candidate in candidates { + let standardized = candidate.standardizedFileURL.path + if seenCandidatePaths.insert(standardized).inserted { + uniqueCandidates.append(candidate) + } + } + return uniqueCandidates + } + + static func environmentTemplateCandidateURLs( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> [URL] { + let templateRelativePaths = [ + "default-report.stencil", + "Contents/Resources/default-report.stencil", + ] + let bundleName = "SnapshotReportKit_SnapshotReportCore.bundle" + var candidates: [URL] = [] + + if let templatePath = environment["SNAPSHOT_REPORT_HTML_TEMPLATE"]?.trimmingCharacters(in: .whitespacesAndNewlines), + templatePath.isEmpty == false { + candidates.append(URL(fileURLWithPath: templatePath)) + } + if let bundlePath = environment["SNAPSHOT_REPORT_RESOURCE_BUNDLE"]?.trimmingCharacters(in: .whitespacesAndNewlines), + bundlePath.isEmpty == false { + let bundleURL = URL(fileURLWithPath: bundlePath, isDirectory: true) + for templateRelativePath in templateRelativePaths { + candidates.append(bundleURL.appendingPathComponent(templateRelativePath)) + } + } + if let installRoot = environment["SNAPSHOT_REPORT_INSTALL_ROOT"]?.trimmingCharacters(in: .whitespacesAndNewlines), + installRoot.isEmpty == false { + let rootURL = URL(fileURLWithPath: installRoot, isDirectory: true) + for templateRelativePath in templateRelativePaths { + candidates.append( + rootURL + .appendingPathComponent(bundleName, isDirectory: true) + .appendingPathComponent(templateRelativePath) + ) + } + } + return candidates } @@ -209,6 +282,7 @@ struct HTMLRenderer { let orderedAttachments = sortAttachmentsForVariantDisplay(test.attachments) let attachmentsArray: [[String: Any]] = orderedAttachments.map { attachment in let fullPath = outputDirectory.appendingPathComponent(attachment.path).path + let metadata = attachmentFileMetadata(fullPath: fullPath) let textContent: String if attachment.type == .text || attachment.type == .dump { textContent = (try? String(contentsOfFile: fullPath, encoding: .utf8)) ?? "" @@ -220,7 +294,10 @@ struct HTMLRenderer { "type": attachment.type.rawValue, "path": attachment.path, "content": textContent, - "variantOrder": variantOrder(for: attachment.path) + "variantOrder": variantOrder(for: attachment.path), + "exists": metadata.exists, + "isEmpty": metadata.isEmpty, + "sizeBytes": metadata.sizeBytes ] } let failedGroups = makeFailedAttachmentGroups(for: test, outputDirectory: outputDirectory) @@ -402,6 +479,7 @@ struct HTMLRenderer { return order.map { key in let items: [[String: Any]] = grouped[key, default: []].map { attachment in let fullPath = outputDirectory.appendingPathComponent(attachment.path).path + let metadata = attachmentFileMetadata(fullPath: fullPath) let textContent: String if attachment.type == .text || attachment.type == .dump { textContent = (try? String(contentsOfFile: fullPath, encoding: .utf8)) ?? "" @@ -413,7 +491,10 @@ struct HTMLRenderer { "type": attachment.type.rawValue, "path": attachment.path, "content": textContent, - "variantOrder": variantOrder(for: attachment.path) + "variantOrder": variantOrder(for: attachment.path), + "exists": metadata.exists, + "isEmpty": metadata.isEmpty, + "sizeBytes": metadata.sizeBytes ] } return [ @@ -462,14 +543,17 @@ struct HTMLRenderer { guard let attachment else { return [ "exists": false, + "isEmpty": true, "name": label, "type": "", "path": "", - "content": "" + "content": "", + "sizeBytes": 0 ] } let fullPath = outputDirectory.appendingPathComponent(attachment.path).path + let metadata = attachmentFileMetadata(fullPath: fullPath) let textContent: String if attachment.type == .text || attachment.type == .dump { textContent = (try? String(contentsOfFile: fullPath, encoding: .utf8)) ?? "" @@ -478,14 +562,25 @@ struct HTMLRenderer { } return [ - "exists": true, + "exists": metadata.exists, + "isEmpty": metadata.isEmpty, "name": label, "type": attachment.type.rawValue, "path": attachment.path, - "content": textContent + "content": textContent, + "sizeBytes": metadata.sizeBytes ] } + private func attachmentFileMetadata(fullPath: String) -> (exists: Bool, isEmpty: Bool, sizeBytes: Int64) { + guard fileManager.fileExists(atPath: fullPath) else { + return (exists: false, isEmpty: true, sizeBytes: 0) + } + let attributes = try? fileManager.attributesOfItem(atPath: fullPath) + let size = (attributes?[.size] as? NSNumber)?.int64Value ?? 0 + return (exists: true, isEmpty: size == 0, sizeBytes: size) + } + private func passedGroupName(for attachment: SnapshotAttachment) -> String { var candidate = attachment.name.trimmingCharacters(in: .whitespacesAndNewlines) if candidate.hasPrefix("Snapshot-") { diff --git a/Sources/SnapshotReportCore/Resources/default-report.stencil b/Sources/SnapshotReportCore/Resources/default-report.stencil index 558b97c..c0cd719 100644 --- a/Sources/SnapshotReportCore/Resources/default-report.stencil +++ b/Sources/SnapshotReportCore/Resources/default-report.stencil @@ -43,6 +43,11 @@ overflow: auto; } + :focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + } + .title { font-size: 1.05rem; font-weight: 700; margin: 0 0 0.25rem; } .muted { color: var(--muted); } @@ -77,9 +82,57 @@ justify-content: space-between; gap: 1rem; margin-bottom: 1rem; + flex-wrap: wrap; + position: sticky; + top: 0; + z-index: 10; + padding: 0.6rem 0; + background: linear-gradient(180deg, rgba(16,25,34,0.98), rgba(16,25,34,0.86)); + backdrop-filter: blur(4px); } .topbar h1 { margin: 0; font-size: 1.6rem; } + .toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + align-items: center; + } + + .toolbar input[type="search"] { + min-width: 260px; + max-width: 520px; + width: min(52vw, 520px); + background: #0f1821; + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.45rem 0.65rem; + font-size: 0.85rem; + } + + .toolbar button { + background: #0f1821; + color: #d3deea; + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.42rem 0.62rem; + font-size: 0.78rem; + cursor: pointer; + } + + .toolbar button.active { + background: rgba(19, 127, 236, 0.18); + border-color: var(--primary); + color: #cfe7ff; + } + + .toolbar button:hover { border-color: #44607c; } + .toolbar-help { + color: var(--muted); + font-size: 0.72rem; + margin-left: 0.35rem; + } .summary { display: grid; @@ -119,6 +172,57 @@ background: var(--surface-soft); border-bottom: 1px solid var(--border); font-weight: 700; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + } + + .suite-actions { + display: flex; + align-items: center; + gap: 0.45rem; + font-weight: 500; + color: var(--muted); + font-size: 0.76rem; + } + + .suite-toggle { + border: 1px solid var(--border); + background: #101c28; + color: #d3deea; + border-radius: 6px; + font-size: 0.74rem; + padding: 0.24rem 0.48rem; + cursor: pointer; + } + + .suite-collapsed .suite-table-wrap { + display: none; + } + + .suite-count-chip { + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.05rem 0.42rem; + color: #bfd2e7; + background: #132233; + } + + .hidden-row { + display: none; + } + + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } table { @@ -309,6 +413,23 @@ } .attachment a { color: #7ab8ff; text-decoration: none; } + .attachment-meta { + margin-top: 0.35rem; + font-size: 0.7rem; + color: var(--muted); + font-family: "JetBrains Mono", "SFMono-Regular", monospace; + word-break: break-all; + } + + .attachment-empty { + border: 1px dashed var(--border); + border-radius: 8px; + padding: 0.65rem; + color: var(--muted); + font-size: 0.78rem; + text-align: center; + background: #0e171f; + } .reference-btn { display: inline-flex; @@ -349,7 +470,23 @@

{{ report.name }}

-
Modern Snapshot Dashboard
+
Snapshot QA Dashboard
+
+
+ + + + + + + + + + + + + +
@@ -362,8 +499,15 @@ {% for suite in suites %} -
-
{{ suite.name }}
+
+
+ {{ suite.name }} + + {{ suite.tests.count }} tests + + +
+
@@ -374,7 +518,7 @@ {% for test in suite.tests %} - + @@ -382,7 +526,7 @@ {% if test.status == "failed" %} - + +
{{ test.status }} {{ test.className }}
Details @@ -401,31 +545,31 @@ File: {{ test.failure.file }}{% if test.failure.line != "" %}:{{ test.failure.li
{{ group.groupName }}
- {% if group.snapshot.exists %} + {% if group.snapshot.exists and group.snapshot.isEmpty == false %}
Snapshot
Snapshot
{% else %} -
Snapshot unavailable
+
Snapshot unavailable{% if group.snapshot.exists and group.snapshot.isEmpty %} (empty file){% endif %}
{% endif %} - {% if group.diff.exists %} + {% if group.diff.exists and group.diff.isEmpty == false %}
Diff
Diff
{% else %} -
Diff unavailable
+
Diff unavailable{% if group.diff.exists and group.diff.isEmpty %} (empty file){% endif %}
{% endif %} - {% if group.failure.exists %} + {% if group.failure.exists and group.failure.isEmpty == false %}
Failure
Failure
{% else %} -
Failure unavailable
+
Failure unavailable{% if group.failure.exists and group.failure.isEmpty %} (empty file){% endif %}
{% endif %}
@@ -438,7 +582,7 @@ File: {{ test.failure.file }}{% if test.failure.line != "" %}:{{ test.failure.li {% endif %} {% if test.status == "passed" and test.passedGroups.count > 0 %} -
{% for group in test.passedGroups %} @@ -448,13 +592,20 @@ File: {{ test.failure.file }}{% if test.failure.line != "" %}:{{ test.failure.li {% for attachment in group.attachments %}
{{ attachment.name }} ({{ attachment.type }})
- {% if attachment.type == "png" %} + {% if attachment.exists == false %} +
Attachment missing on disk
+ {% elif attachment.isEmpty %} +
Attachment exists but file is empty
+ {% elif attachment.type == "png" %} {{ attachment.name }} {% elif attachment.type == "dump" or attachment.type == "text" %}
{% if attachment.content != "" %}{{ attachment.content }}{% else %}Attachment file path: {{ attachment.path }}{% endif %}
{% else %} Download attachment {% endif %} +
+ size={{ attachment.sizeBytes }} bytes +
{% endfor %}
@@ -467,9 +618,298 @@ File: {{ test.failure.file }}{% if test.failure.line != "" %}:{{ test.failure.li {% endfor %}
+
{% endfor %} + diff --git a/Tests/SnapshotReportCoreTests/SnapshotReportCoreTests.swift b/Tests/SnapshotReportCoreTests/SnapshotReportCoreTests.swift index afdf68e..c9fee77 100644 --- a/Tests/SnapshotReportCoreTests/SnapshotReportCoreTests.swift +++ b/Tests/SnapshotReportCoreTests/SnapshotReportCoreTests.swift @@ -380,9 +380,17 @@ func htmlRendererTemplateCandidatesIncludeResolvedSymlinkPath() throws { let expectedCFBundleLayout = cellarBin .appendingPathComponent("SnapshotReportKit_SnapshotReportCore.bundle/Contents/Resources/default-report.stencil") .path + let expectedLibexecSwiftPMLayout = temporaryRoot + .appendingPathComponent("Cellar/snapshot-report-nightly/0.1.0/libexec/SnapshotReportKit_SnapshotReportCore.bundle/default-report.stencil") + .path + let expectedLibexecCFBundleLayout = temporaryRoot + .appendingPathComponent("Cellar/snapshot-report-nightly/0.1.0/libexec/SnapshotReportKit_SnapshotReportCore.bundle/Contents/Resources/default-report.stencil") + .path #expect(candidates.contains(expectedSwiftPMLayout)) #expect(candidates.contains(expectedCFBundleLayout)) + #expect(candidates.contains(expectedLibexecSwiftPMLayout)) + #expect(candidates.contains(expectedLibexecCFBundleLayout)) } @Test @@ -390,3 +398,28 @@ func htmlRendererTemplateCandidatesIncludeSourceFallback() { let candidates = HTMLRenderer.defaultTemplateCandidateURLs(executablePath: "/tmp/snapshot-report").map(\.path) #expect(candidates.contains { $0.hasSuffix("/Sources/SnapshotReportCore/Resources/default-report.stencil") }) } + +@Test +func htmlRendererTemplateCandidatesIncludeBrewShareFallbacks() { + let executablePath = "/opt/homebrew/Cellar/snapshot-report-nightly/2026.02.20.6/bin/snapshot-report-nightly" + let candidates = HTMLRenderer.defaultTemplateCandidateURLs(executablePath: executablePath).map(\.path) + + #expect(candidates.contains("/opt/homebrew/Cellar/snapshot-report-nightly/2026.02.20.6/bin/SnapshotReportKit_SnapshotReportCore.bundle/default-report.stencil")) + #expect(candidates.contains("/opt/homebrew/Cellar/snapshot-report-nightly/2026.02.20.6/libexec/SnapshotReportKit_SnapshotReportCore.bundle/default-report.stencil")) + #expect(candidates.contains("/opt/homebrew/Cellar/snapshot-report-nightly/2026.02.20.6/share/snapshot-report/SnapshotReportKit_SnapshotReportCore.bundle/default-report.stencil")) +} + +@Test +func htmlRendererTemplateCandidatesIncludeEnvironmentOverrides() { + let candidates = HTMLRenderer.environmentTemplateCandidateURLs( + environment: [ + "SNAPSHOT_REPORT_HTML_TEMPLATE": "/tmp/custom-template.stencil", + "SNAPSHOT_REPORT_RESOURCE_BUNDLE": "/opt/homebrew/Cellar/snapshot-report-nightly/2026.02.20.6/bin/SnapshotReportKit_SnapshotReportCore.bundle", + "SNAPSHOT_REPORT_INSTALL_ROOT": "/opt/homebrew/Cellar/snapshot-report-nightly/2026.02.20.6/bin", + ] + ).map(\.path) + + #expect(candidates.contains("/tmp/custom-template.stencil")) + #expect(candidates.contains("/opt/homebrew/Cellar/snapshot-report-nightly/2026.02.20.6/bin/SnapshotReportKit_SnapshotReportCore.bundle/default-report.stencil")) + #expect(candidates.contains("/opt/homebrew/Cellar/snapshot-report-nightly/2026.02.20.6/bin/SnapshotReportKit_SnapshotReportCore.bundle/Contents/Resources/default-report.stencil")) +}