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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ final class MultiModuleDemoSnapshotSingleModuleAllowTests: SnapshotTest {
override class func snapshotPreviewModules() -> [String]? {
return ["ModuleA"]
}

override class func excludedSnapshotPreviews() -> [String]? {
return ["ModuleA/ModuleAViews.swift:ModuleA Button"]
}
}

final class MultiModuleDemoSnapshotMultipleModuleAllowTests: SnapshotTest {
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,19 @@ xcodebuild test \
-destination 'platform=iOS Simulator,name=iPhone 15 Pro'
```

### Environment variables

SnapshotPreviews supports these test-runner environment variables:

| Variable | Description |
| --- | --- |
| `TEST_RUNNER_SNAPSHOTS_EXPORT_DIR` | Writes rendered snapshot PNGs and JSON sidecars to the given directory instead of attaching PNGs to the `.xcresult` bundle. |
| `TEST_RUNNER_SNAPSHOTS_ALL_IMAGE_NAMES_FILE` | Writes all discovered logical `.png` image names to the given file, then returns without rendering previews. Used to support selective testing workflows. |

These modes are mutually exclusive. If `TEST_RUNNER_SNAPSHOTS_ALL_IMAGE_NAMES_FILE` is set, SnapshotPreviews writes image names only and does not render or export snapshot images.

> [!NOTE]
> The `TEST_RUNNER_` prefix is how Xcode forwards an environment variable from `xcodebuild` into the test runner process. Inside the runner the variable is read as `SNAPSHOTS_EXPORT_DIR`.
> The `TEST_RUNNER_` prefix is how Xcode forwards an environment variable from `xcodebuild` into the test runner process. Inside the runner, SnapshotPreviews reads the variable without that prefix.

For every rendered preview, two files are written:

Expand Down
59 changes: 59 additions & 0 deletions Sources/SnapshottingTests/AllSnapshotImageNamesWriter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Foundation

final class AllSnapshotImageNamesWriter {
static let envKey = "SNAPSHOTS_ALL_IMAGE_NAMES_FILE"

private let outputURL: URL

static func createFromEnvironment(
environment: [String: String] = ProcessInfo.processInfo.environment,
fileManager: FileManager = .default
) -> AllSnapshotImageNamesWriter? {
guard let outputPath = environment[envKey] else {
return nil
}

let trimmed = outputPath.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
preconditionFailure("\(envKey) is set but empty. Provide a valid file path.")
}

let outputURL: URL
if trimmed.hasPrefix("/") {
outputURL = URL(fileURLWithPath: trimmed).standardizedFileURL
} else {
outputURL = URL(fileURLWithPath: fileManager.currentDirectoryPath)
.appendingPathComponent(trimmed)
.standardizedFileURL
}

return Self(outputURL: outputURL, fileManager: fileManager)
}

init(outputURL: URL, fileManager: FileManager = .default) {
self.outputURL = outputURL

do {
try fileManager.createDirectory(
at: outputURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
} catch {
preconditionFailure("Failed to create all snapshot image names directory at \(outputURL.deletingLastPathComponent().path): \(error)")
}
}

func write(imageNames: [String]) {
let sortedImageNames = Set(imageNames).sorted()
let contents = sortedImageNames.isEmpty ? "" : "\(sortedImageNames.joined(separator: "\n"))\n"
guard let data = contents.data(using: .utf8) else {
preconditionFailure("Failed to encode all snapshot image names file at \(outputURL.path)")
}

do {
try data.write(to: outputURL, options: .atomic)
} catch {
preconditionFailure("Failed to write all snapshot image names file at \(outputURL.path): \(error)")
}
}
}
35 changes: 35 additions & 0 deletions Sources/SnapshottingTests/FileNameUtils.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// FileNameUtils.swift
// SnapshotPreviews
//
// Created by Cameron Cooke on 08/06/2026.
//

import Foundation

enum FileNameUtils {

/// Accepts a pre-sanitized value, then converts it into a safe image filename with a .png extension.
static func imageFileName(from value: String) -> String {
"\(sanitize(value)).png"
}

private static func sanitize(_ value: String) -> String {
var result = ""
var lastWasUnderscore = false

for c in value {
if c.isLetter || c.isNumber || c == "." || c == "-" || c == "_" {
result.append(c)
lastWasUnderscore = false
} else if !lastWasUnderscore {
result.append("_")
lastWasUnderscore = true
}
}

result = result.trimmingCharacters(in: CharacterSet(charactersIn: "_.-"))

return result.isEmpty ? "snapshot" : result
}
}
14 changes: 7 additions & 7 deletions Sources/SnapshottingTests/PreviewBaseTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,20 +72,20 @@ open class PreviewBaseTest: XCTestCase {
previews = []
var i = 0

let currentDeviceName = ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] ?? ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"]
let currentDeviceName = SnapshotPreviewDestination.currentDeviceName()

for discoveredPreview in discoveredPreviews {
let typeName = discoveredPreview.typeName
let displayName = discoveredPreview.displayName ?? typeName
let count = discoveredPreview.numberOfPreviews

for j in 0..<count {
// Filter out device specific previews whose device name doesn't match the currently selected one
if currentDeviceName != nil {
let specifiedPreviewDevice = discoveredPreview.devices[j]
guard specifiedPreviewDevice.isEmpty || specifiedPreviewDevice == currentDeviceName else {
continue
}
if !SnapshotPreviewDeviceFilter.shouldInclude(
discoveredPreview: discoveredPreview,
index: j,
currentDestinationDeviceName: currentDeviceName
) {
continue
}

let orientation = discoveredPreview.orientations[j]
Expand Down
32 changes: 8 additions & 24 deletions Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import SnapshotSharedModels
// MARK: - Snapshot Context

struct SnapshotContext: Sendable {
let baseFileName: String
let imageFileName: String

var sidecarFileName: String {
"\(String(imageFileName.dropLast(".png".count))).json"
}

let testName: String
let typeName: String
let typeDisplayName: String
Expand Down Expand Up @@ -219,27 +224,6 @@ final class SnapshotCIExportCoordinator: NSObject, XCTestObservation {
}
}

// MARK: - Filename Sanitization

static func sanitize(_ raw: String) -> String {
var result = ""
var lastWasUnderscore = false

for c in raw {
if c.isLetter || c.isNumber || c == "." || c == "-" || c == "_" {
result.append(c)
lastWasUnderscore = false
} else if !lastWasUnderscore {
result.append("_")
lastWasUnderscore = true
}
}

result = result.trimmingCharacters(in: CharacterSet(charactersIn: "_.-"))

return result.isEmpty ? "snapshot" : result
}

// MARK: - Export

static func canonicalGroup(
Expand Down Expand Up @@ -286,8 +270,8 @@ final class SnapshotCIExportCoordinator: NSObject, XCTestObservation {
result: SnapshotResult,
context: SnapshotContext
) {
let pngFileName = "\(context.baseFileName).png"
let jsonFileName = "\(context.baseFileName).json"
let pngFileName = context.imageFileName
let jsonFileName = context.sidecarFileName

let displayName = Self.canonicalDisplayName(for: context)
let group = Self.canonicalGroup(
Expand Down
9 changes: 9 additions & 0 deletions Sources/SnapshottingTests/SnapshotPreviewDestination.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

struct SnapshotPreviewDestination {
static func currentDeviceName(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> String? {
environment["SIMULATOR_DEVICE_NAME"] ?? environment["SIMULATOR_MODEL_IDENTIFIER"]
}
}
25 changes: 25 additions & 0 deletions Sources/SnapshottingTests/SnapshotPreviewDeviceFilter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
struct SnapshotPreviewDeviceFilter {
static func shouldInclude(
discoveredPreview: DiscoveredPreview,
index: Int,
currentDestinationDeviceName: String?
) -> Bool {
let requestedDevice = discoveredPreview.devices.indices.contains(index) ? discoveredPreview.devices[index] : nil
return shouldInclude(requestedDeviceName: requestedDevice, currentDestinationDeviceName: currentDestinationDeviceName)
}

static func shouldInclude(
requestedDeviceName: String?,
currentDestinationDeviceName: String?
) -> Bool {
guard let currentDestinationDeviceName else {
return true
}

guard let requestedDeviceName, !requestedDeviceName.isEmpty else {
return true
}

return requestedDeviceName == currentDestinationDeviceName
}
}
54 changes: 50 additions & 4 deletions Sources/SnapshottingTests/SnapshotTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -222,13 +222,19 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters {
#endif
private static var renderingStrategy: RenderingStrategy? = nil
@MainActor private static var ciExportCoordinator: SnapshotCIExportCoordinator?
@MainActor private static var allSnapshotImageNamesWriter: AllSnapshotImageNamesWriter?

static private var previews: [SnapshotPreviewsCore.PreviewType] = []
static private var fileNameResolver = FileNameResolver(previews: [])

@MainActor
override class func discoverPreviews() -> [DiscoveredPreview] {
ciExportCoordinator = SnapshotCIExportCoordinator.createFromEnvironment()
allSnapshotImageNamesWriter = AllSnapshotImageNamesWriter.createFromEnvironment()
if allSnapshotImageNamesWriter == nil {
ciExportCoordinator = SnapshotCIExportCoordinator.createFromEnvironment()
} else {
ciExportCoordinator = nil
}

previews = FindPreviews.findPreviews(
included: Self.snapshotPreviews(),
Expand All @@ -237,9 +243,49 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters {
excludedModules: Self.excludedSnapshotPreviewModules()
)
fileNameResolver = FileNameResolver(previews: previews)

if let allSnapshotImageNamesWriter {
allSnapshotImageNamesWriter.write(
imageNames: logicalImageNames(previews: previews, fileNameResolver: fileNameResolver)
)
return []
}

return previews.map { DiscoveredPreview.from(previewType: $0) }
}

static func logicalImageNames(
previews: [SnapshotPreviewsCore.PreviewType],
fileNameResolver: FileNameResolver,
environment: [String: String] = ProcessInfo.processInfo.environment
) -> [String] {
let currentDeviceName = SnapshotPreviewDestination.currentDeviceName(environment: environment)
var imageNames: [String] = []

for previewType in previews {
for previewIndex in previewType.previews.indices {
let requestedDeviceName = previewType.previews[previewIndex].device?.rawValue
guard SnapshotPreviewDeviceFilter.shouldInclude(
requestedDeviceName: requestedDeviceName,
currentDestinationDeviceName: currentDeviceName
) else {
continue
}

guard let rawBaseFileName = fileNameResolver.rawBaseFileName(
typeName: previewType.typeName,
previewIndex: previewIndex
) else {
continue
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, im assuming this exactly matches the image_file_name value generated for a given snapshot 👏


imageNames.append(FileNameUtils.imageFileName(from: rawBaseFileName))
}
}

return imageNames
}

/// Tests a specific preview by rendering it and generating a snapshot. Subclasses should NOT override this method.
///
/// This method renders the specified preview using the appropriate rendering strategy,
Expand Down Expand Up @@ -288,11 +334,11 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters {
return
}

let baseFileName = SnapshotCIExportCoordinator.sanitize(rawBaseFileName)
let imageFileName = FileNameUtils.imageFileName(from: rawBaseFileName)
if let coordinator = Self.ciExportCoordinator {
let colorSchemeValue = result.colorScheme.flatMap { $0.stringValue }
let context = SnapshotContext(
baseFileName: baseFileName,
imageFileName: imageFileName,
testName: name,
typeName: previewType.typeName,
typeDisplayName: previewType.displayName,
Expand All @@ -312,7 +358,7 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters {
} else {
do {
let attachment = try XCTAttachment(image: result.image.get())
attachment.name = baseFileName
attachment.name = String(imageFileName.dropLast(".png".count))
attachment.lifetime = .keepAlways
add(attachment)
} catch {
Expand Down
Loading
Loading