diff --git a/Examples/MultiModuleDemo/MultiModuleDemoTests/MultiModuleDemoSnapshotTests.swift b/Examples/MultiModuleDemo/MultiModuleDemoTests/MultiModuleDemoSnapshotTests.swift index e8b94b0..e161e6a 100644 --- a/Examples/MultiModuleDemo/MultiModuleDemoTests/MultiModuleDemoSnapshotTests.swift +++ b/Examples/MultiModuleDemo/MultiModuleDemoTests/MultiModuleDemoSnapshotTests.swift @@ -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 { diff --git a/README.md b/README.md index f3bfed5..9cfbc0c 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/Sources/SnapshottingTests/AllSnapshotImageNamesWriter.swift b/Sources/SnapshottingTests/AllSnapshotImageNamesWriter.swift new file mode 100644 index 0000000..e05bac8 --- /dev/null +++ b/Sources/SnapshottingTests/AllSnapshotImageNamesWriter.swift @@ -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)") + } + } +} diff --git a/Sources/SnapshottingTests/PreviewBaseTest.swift b/Sources/SnapshottingTests/PreviewBaseTest.swift index fafe941..da207f3 100644 --- a/Sources/SnapshottingTests/PreviewBaseTest.swift +++ b/Sources/SnapshottingTests/PreviewBaseTest.swift @@ -72,7 +72,7 @@ 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 @@ -80,12 +80,12 @@ open class PreviewBaseTest: XCTestCase { let count = discoveredPreview.numberOfPreviews for j in 0.. String? { + environment["SIMULATOR_DEVICE_NAME"] ?? environment["SIMULATOR_MODEL_IDENTIFIER"] + } +} diff --git a/Sources/SnapshottingTests/SnapshotPreviewDeviceFilter.swift b/Sources/SnapshottingTests/SnapshotPreviewDeviceFilter.swift new file mode 100644 index 0000000..aa86356 --- /dev/null +++ b/Sources/SnapshottingTests/SnapshotPreviewDeviceFilter.swift @@ -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 + } +} diff --git a/Sources/SnapshottingTests/SnapshotTest.swift b/Sources/SnapshottingTests/SnapshotTest.swift index 2c060b2..803e26a 100644 --- a/Sources/SnapshottingTests/SnapshotTest.swift +++ b/Sources/SnapshottingTests/SnapshotTest.swift @@ -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(), @@ -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 + } + + 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, diff --git a/Tests/SnapshottingTestsTests/AllSnapshotImageNamesTests.swift b/Tests/SnapshottingTestsTests/AllSnapshotImageNamesTests.swift new file mode 100644 index 0000000..a0e65a7 --- /dev/null +++ b/Tests/SnapshottingTestsTests/AllSnapshotImageNamesTests.swift @@ -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") + } + } +}