Skip to content
Open
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)")
}
}
}
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
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
}
}
49 changes: 48 additions & 1 deletion 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,50 @@ 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 👏


let baseFileName = SnapshotCIExportCoordinator.sanitize(rawBaseFileName)
imageNames.append("\(baseFileName).png")
}
}

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
160 changes: 160 additions & 0 deletions Tests/SnapshottingTestsTests/AllSnapshotImageNamesTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import Foundation
import SwiftUI
@testable import SnapshotPreviewsCore
@testable import SnapshottingTests
import XCTest

@MainActor
final class AllSnapshotImageNamesTests: XCTestCase {
private var tempDir: URL!

override func setUp() {
super.setUp()
tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("AllSnapshotImageNamesTests-\(UUID().uuidString)")
}

override func tearDown() {
try? FileManager.default.removeItem(at: tempDir)
super.tearDown()
}

func testCurrentDestinationDeviceNamePrefersSimulatorDeviceName() {
let deviceName = SnapshotPreviewDestination.currentDeviceName(
environment: [
"SIMULATOR_DEVICE_NAME": "iPhone 15",
"SIMULATOR_MODEL_IDENTIFIER": "iPhone16,1",
]
)

XCTAssertEqual(deviceName, "iPhone 15")
}

func testCurrentDestinationDeviceNameFallsBackToSimulatorModelIdentifier() {
let deviceName = SnapshotPreviewDestination.currentDeviceName(
environment: ["SIMULATOR_MODEL_IDENTIFIER": "iPhone16,1"]
)

XCTAssertEqual(deviceName, "iPhone16,1")
}

func testDiscoveredPreviewDeviceFilterIncludesUndeclaredAndMatchingDevices() {
let preview = DiscoveredPreview(
typeName: "Module.TestView_Previews",
displayName: "Test View",
devices: ["", "iPhone 15", "iPhone 14"],
orientations: ["portrait", "portrait", "portrait"],
numberOfPreviews: 3
)

XCTAssertTrue(
SnapshotPreviewDeviceFilter.shouldInclude(
discoveredPreview: preview,
index: 0,
currentDestinationDeviceName: "iPhone 15"
)
)
XCTAssertTrue(
SnapshotPreviewDeviceFilter.shouldInclude(
discoveredPreview: preview,
index: 1,
currentDestinationDeviceName: "iPhone 15"
)
)
XCTAssertFalse(
SnapshotPreviewDeviceFilter.shouldInclude(
discoveredPreview: preview,
index: 2,
currentDestinationDeviceName: "iPhone 15"
)
)
}

func testWriterReplacesStaleOutputWithSortedDeduplicatedNames() throws {
let outputURL = tempDir.appendingPathComponent("all-image-names.txt")
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
try "stale.png\n".write(to: outputURL, atomically: true, encoding: .utf8)

let writer = try XCTUnwrap(
AllSnapshotImageNamesWriter.createFromEnvironment(
environment: [AllSnapshotImageNamesWriter.envKey: outputURL.path]
)
)

writer.write(imageNames: ["Beta.png", "Alpha.png", "Beta.png"])

XCTAssertEqual(try readAllSnapshotImageNamesFile(at: outputURL), "Alpha.png\nBeta.png\n")
}

func testWriterWritesEmptyFileWhenThereAreNoImageNames() throws {
let outputURL = tempDir.appendingPathComponent("all-image-names.txt")
let writer = AllSnapshotImageNamesWriter(outputURL: outputURL)

writer.write(imageNames: [])

XCTAssertEqual(try readAllSnapshotImageNamesFile(at: outputURL), "")
}

func testLogicalImageNamesAreSanitizedPngNamesAndPreserveDuplicateOrdinalsAfterDeviceFiltering() {
let previewType = PreviewType(
typeName: "TestModule.AllSnapshotImageNamesTestProvider",
previewProvider: AllSnapshotImageNamesTestProvider.self
)
let resolver = SnapshotTest.FileNameResolver(previews: [previewType])

let imageNames = SnapshotTest.logicalImageNames(
previews: [previewType],
fileNameResolver: resolver,
environment: ["SIMULATOR_DEVICE_NAME": "iPhone 14"]
)

XCTAssertEqual(
imageNames,
[
"All_Snapshot_Image_Names_Test_Provider_Duplicate_1.png",
"All_Snapshot_Image_Names_Test_Provider_Other.png",
]
)
}

func testLogicalImageNamesUsePreviewTypeDeviceWhenDestinationMatches() {
let previewType = PreviewType(
typeName: "TestModule.AllSnapshotImageNamesTestProvider",
previewProvider: AllSnapshotImageNamesTestProvider.self
)
let resolver = SnapshotTest.FileNameResolver(previews: [previewType])

let imageNames = SnapshotTest.logicalImageNames(
previews: [previewType],
fileNameResolver: resolver,
environment: ["SIMULATOR_DEVICE_NAME": "iPhone 15"]
)

XCTAssertEqual(
imageNames,
[
"All_Snapshot_Image_Names_Test_Provider_Duplicate_1.png",
"All_Snapshot_Image_Names_Test_Provider_Duplicate_2.png",
"All_Snapshot_Image_Names_Test_Provider_Other.png",
]
)
}

private func readAllSnapshotImageNamesFile(at url: URL) throws -> String {
try String(contentsOf: url, encoding: .utf8)
}
}

private struct AllSnapshotImageNamesTestProvider: PreviewProvider {
static var previews: some View {
Group {
Text("One")
.previewDisplayName("Duplicate")
Text("Two")
.previewDisplayName("Duplicate")
.previewDevice("iPhone 15")
Text("Three")
.previewDisplayName("Other")
}
}
}
Loading