diff --git a/.editorconfig b/.editorconfig
index 410ff6c9e..08891d83f 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -1,9 +1,8 @@
-# editorconfig.org
-
root = true
[*]
indent_style = space
-indent_size = 2
-trim_trailing_whitespace = true
+indent_size = 4
+end_of_line = lf
insert_final_newline = true
+trim_trailing_whitespace = true
\ No newline at end of file
diff --git a/.swift-format b/.swift-format
new file mode 100644
index 000000000..f8153414d
--- /dev/null
+++ b/.swift-format
@@ -0,0 +1,62 @@
+{
+ "version" : 1,
+ "indentation" : {
+ "spaces" : 4
+ },
+ "tabWidth" : 4,
+ "fileScopedDeclarationPrivacy" : {
+ "accessLevel" : "private"
+ },
+ "spacesAroundRangeFormationOperators" : false,
+ "indentConditionalCompilationBlocks" : false,
+ "indentSwitchCaseLabels" : false,
+ "lineBreakAroundMultilineExpressionChainComponents" : false,
+ "lineBreakBeforeControlFlowKeywords" : false,
+ "lineBreakBeforeEachArgument" : true,
+ "lineBreakBeforeEachGenericRequirement" : true,
+ "lineLength" : 120,
+ "maximumBlankLines" : 1,
+ "respectsExistingLineBreaks" : true,
+ "prioritizeKeepingFunctionOutputTogether" : true,
+ "rules" : {
+ "AllPublicDeclarationsHaveDocumentation" : false,
+ "AlwaysUseLiteralForEmptyCollectionInit" : false,
+ "AlwaysUseLowerCamelCase" : false,
+ "AmbiguousTrailingClosureOverload" : true,
+ "BeginDocumentationCommentWithOneLineSummary" : false,
+ "DoNotUseSemicolons" : true,
+ "DontRepeatTypeInStaticProperties" : true,
+ "FileScopedDeclarationPrivacy" : true,
+ "FullyIndirectEnum" : true,
+ "GroupNumericLiterals" : true,
+ "IdentifiersMustBeASCII" : true,
+ "NeverForceUnwrap" : false,
+ "NeverUseForceTry" : false,
+ "NeverUseImplicitlyUnwrappedOptionals" : false,
+ "NoAccessLevelOnExtensionDeclaration" : true,
+ "NoAssignmentInExpressions" : true,
+ "NoBlockComments" : true,
+ "NoCasesWithOnlyFallthrough" : true,
+ "NoEmptyTrailingClosureParentheses" : true,
+ "NoLabelsInCasePatterns" : true,
+ "NoLeadingUnderscores" : false,
+ "NoParensAroundConditions" : true,
+ "NoVoidReturnOnFunctionSignature" : true,
+ "OmitExplicitReturns" : true,
+ "OneCasePerLine" : true,
+ "OneVariableDeclarationPerLine" : true,
+ "OnlyOneTrailingClosureArgument" : true,
+ "OrderedImports" : true,
+ "ReplaceForEachWithForLoop" : true,
+ "ReturnVoidInsteadOfEmptyTuple" : true,
+ "UseEarlyExits" : false,
+ "UseExplicitNilCheckInConditions" : false,
+ "UseLetInEveryBoundCaseVariable" : false,
+ "UseShorthandTypeNames" : true,
+ "UseSingleLinePropertyGetter" : false,
+ "UseSynthesizedInitializer" : false,
+ "UseTripleSlashForDocumentationComments" : true,
+ "UseWhereClausesInForLoops" : false,
+ "ValidateDocumentationComments" : false
+ }
+}
\ No newline at end of file
diff --git a/Makefile b/Makefile
index c28ea7f2c..00d94b9a6 100644
--- a/Makefile
+++ b/Makefile
@@ -32,6 +32,6 @@ format:
--ignore-unparsable-files \
--in-place \
--recursive \
- ./Package.swift ./Sources ./Tests
+ Package.swift ./Tests $(find ./Sources -name "*.swift" \ -not -path "./Sources/Deprecated/*")
test-all: test-linux test-macos test-ios
diff --git a/Package.resolved b/Package.resolved
index bf61dcf39..106bfb945 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -1,4 +1,5 @@
{
+ "originHash" : "fbc61be280c96a8a35bc87505ffcc9ac4f8153be541ef6f7906c6f61b079f514",
"pins" : [
{
"identity" : "swift-custom-dump",
@@ -14,8 +15,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax",
"state" : {
- "revision" : "0687f71944021d616d34d922343dcef086855920",
- "version" : "600.0.1"
+ "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2",
+ "version" : "601.0.1"
}
},
{
@@ -28,5 +29,5 @@
}
}
],
- "version" : 2
+ "version" : 3
}
diff --git a/Package.swift b/Package.swift
index c7a6f7e66..bba2e83de 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,69 +1,135 @@
-// swift-tools-version:5.9
+// swift-tools-version: 6.1
import PackageDescription
let package = Package(
- name: "swift-snapshot-testing",
- platforms: [
- .iOS(.v13),
- .macOS(.v10_15),
- .tvOS(.v13),
- .watchOS(.v6),
- ],
- products: [
- .library(
- name: "SnapshotTesting",
- targets: ["SnapshotTesting"]
- ),
- .library(
- name: "InlineSnapshotTesting",
- targets: ["InlineSnapshotTesting"]
- ),
- .library(
- name: "SnapshotTestingCustomDump",
- targets: ["SnapshotTestingCustomDump"]
- ),
- ],
- dependencies: [
- .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.3"),
- .package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"602.0.0"),
- ],
- targets: [
- .target(
- name: "SnapshotTesting"
- ),
- .testTarget(
- name: "SnapshotTestingTests",
- dependencies: [
- "SnapshotTesting"
- ],
- exclude: [
- "__Fixtures__",
- "__Snapshots__",
- ]
- ),
- .target(
- name: "InlineSnapshotTesting",
- dependencies: [
- "SnapshotTesting",
- "SnapshotTestingCustomDump",
- .product(name: "SwiftParser", package: "swift-syntax"),
- .product(name: "SwiftSyntax", package: "swift-syntax"),
- .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
- ]
- ),
- .testTarget(
- name: "InlineSnapshotTestingTests",
- dependencies: [
- "InlineSnapshotTesting"
- ]
- ),
- .target(
- name: "SnapshotTestingCustomDump",
- dependencies: [
- "SnapshotTesting",
- .product(name: "CustomDump", package: "swift-custom-dump"),
- ]
- ),
- ]
+ name: "swift-snapshot-testing",
+ platforms: [
+ .iOS(.v13),
+ .macOS(.v10_15),
+ .tvOS(.v13),
+ .watchOS(.v6),
+ ],
+ products: [
+ .library(
+ name: "XCSnapshotTesting",
+ targets: ["XCSnapshotTesting"]
+ ),
+ .library(
+ name: "SnapshotTesting",
+ targets: ["SnapshotTesting"]
+ ),
+ .library(
+ name: "SnapshotTestingCustomDump",
+ targets: ["SnapshotTestingCustomDump"]
+ ),
+ .library(
+ name: "InlineSnapshotTesting",
+ targets: ["InlineSnapshotTesting"]
+ ),
+ ],
+ dependencies: [
+ .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.3"),
+ .package(url: "https://github.com/swiftlang/swift-syntax", "601.0.0"..<"602.0.0"),
+ ],
+ targets: [
+ /* DEPRECATED TARGETS */
+ .target(
+ name: "_SnapshotTesting",
+ path: "Sources/Deprecated/SnapshotTesting",
+ swiftSettings: [.swiftLanguageMode(.v5)]
+ ),
+ .target(
+ name: "_SnapshotTestingCustomDump",
+ dependencies: [
+ "_SnapshotTesting",
+ .product(name: "CustomDump", package: "swift-custom-dump"),
+ ],
+ path: "Sources/Deprecated/SnapshotTestingCustomDump",
+ swiftSettings: [.swiftLanguageMode(.v5)]
+ ),
+ .target(
+ name: "_InlineSnapshotTesting",
+ dependencies: [
+ "_SnapshotTesting",
+ "_SnapshotTestingCustomDump",
+ .product(name: "SwiftParser", package: "swift-syntax"),
+ .product(name: "SwiftSyntax", package: "swift-syntax"),
+ .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
+ ],
+ path: "Sources/Deprecated/InlineSnapshotTesting",
+ swiftSettings: [.swiftLanguageMode(.v5)]
+ ),
+ /* TARGETS */
+ .target(
+ name: "XCSnapshotTesting",
+ dependencies: ["_SnapshotTesting"]
+ ),
+ .target(
+ name: "SnapshotTesting",
+ dependencies: ["XCSnapshotTesting"]
+ ),
+ .target(
+ name: "SnapshotTestingCustomDump",
+ dependencies: [
+ "XCSnapshotTesting",
+ .product(name: "CustomDump", package: "swift-custom-dump"),
+ "_SnapshotTestingCustomDump",
+ ]
+ ),
+ .target(
+ name: "InlineSnapshotTesting",
+ dependencies: [
+ "SnapshotTesting",
+ "SnapshotTestingCustomDump",
+ .product(name: "SwiftParser", package: "swift-syntax"),
+ .product(name: "SwiftSyntax", package: "swift-syntax"),
+ .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
+ "_InlineSnapshotTesting",
+ ]
+ ),
+ /* DEPRECATED TESTS */
+ .testTarget(
+ name: "_SnapshotTestingTests",
+ dependencies: ["XCSnapshotTesting", "SnapshotTesting"],
+ path: "Tests/Deprecated/SnapshotTestingTests",
+ exclude: [
+ "__Fixtures__",
+ "__Snapshots__",
+ ],
+ swiftSettings: [.swiftLanguageMode(.v5)]
+ ),
+ .testTarget(
+ name: "_InlineSnapshotTestingTests",
+ dependencies: ["InlineSnapshotTesting"],
+ path: "Tests/Deprecated/InlineSnapshotTestingTests",
+ swiftSettings: [.swiftLanguageMode(.v5)]
+ ),
+ /* TESTS */
+ .testTarget(
+ name: "XCSnapshotTestingTests",
+ dependencies: ["XCSnapshotTesting"],
+ exclude: [
+ "__Fixtures__",
+ "__Snapshots__",
+ ]
+ ),
+ .testTarget(
+ name: "SnapshotTestingTests",
+ dependencies: ["SnapshotTesting"],
+ exclude: ["__Snapshots__"]
+ ),
+ .testTarget(
+ name: "SnapshotTestingCustomDumpTests",
+ dependencies: [
+ "SnapshotTestingCustomDump"
+ ]
+ ),
+ .testTarget(
+ name: "InlineSnapshotTestingTests",
+ dependencies: [
+ "InlineSnapshotTesting"
+ ]
+ ),
+ ]
)
diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift
deleted file mode 100644
index 68cfa74ce..000000000
--- a/Package@swift-6.0.swift
+++ /dev/null
@@ -1,70 +0,0 @@
-// swift-tools-version:6.0
-
-import PackageDescription
-
-let package = Package(
- name: "swift-snapshot-testing",
- platforms: [
- .iOS(.v13),
- .macOS(.v10_15),
- .tvOS(.v13),
- .watchOS(.v6),
- ],
- products: [
- .library(
- name: "SnapshotTesting",
- targets: ["SnapshotTesting"]
- ),
- .library(
- name: "InlineSnapshotTesting",
- targets: ["InlineSnapshotTesting"]
- ),
- .library(
- name: "SnapshotTestingCustomDump",
- targets: ["SnapshotTestingCustomDump"]
- ),
- ],
- dependencies: [
- .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.3"),
- .package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"602.0.0"),
- ],
- targets: [
- .target(
- name: "SnapshotTesting"
- ),
- .testTarget(
- name: "SnapshotTestingTests",
- dependencies: [
- "SnapshotTesting"
- ],
- exclude: [
- "__Fixtures__",
- "__Snapshots__",
- ]
- ),
- .target(
- name: "InlineSnapshotTesting",
- dependencies: [
- "SnapshotTesting",
- "SnapshotTestingCustomDump",
- .product(name: "SwiftParser", package: "swift-syntax"),
- .product(name: "SwiftSyntax", package: "swift-syntax"),
- .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
- ]
- ),
- .testTarget(
- name: "InlineSnapshotTestingTests",
- dependencies: [
- "InlineSnapshotTesting"
- ]
- ),
- .target(
- name: "SnapshotTestingCustomDump",
- dependencies: [
- "SnapshotTesting",
- .product(name: "CustomDump", package: "swift-custom-dump"),
- ]
- ),
- ],
- swiftLanguageModes: [.v5]
-)
diff --git a/README.md b/README.md
index c26595221..3ac58046a 100644
--- a/README.md
+++ b/README.md
@@ -9,192 +9,74 @@ Delightful Swift snapshot testing.
## Usage
-Once [installed](#installation), _no additional configuration is required_. You can import the
-`SnapshotTesting` module and call the `assertSnapshot` function.
+Once [installed](#installation), no additional configuration is required. You can import the `SnapshotTesting` module and call the `assert` function when using Swift Testing.
-``` swift
+```swift
import SnapshotTesting
-import Testing
@MainActor
-struct MyViewControllerTests {
- @Test func myViewController() {
- let vc = MyViewController()
-
- assertSnapshot(of: vc, as: .image)
- }
-}
-```
-
-When an assertion first runs, a snapshot is automatically recorded to disk and the test will fail,
-printing out the file path of any newly-recorded reference.
-
-> ❌ failed - No reference was found on disk. Automatically recorded snapshot: …
->
-> open "…/MyAppTests/\_\_Snapshots\_\_/MyViewControllerTests/testMyViewController.png"
->
-> Re-run "testMyViewController" to test against the newly-recorded snapshot.
-
-Repeat test runs will load this reference and compare it with the runtime value. If they don't
-match, the test will fail and describe the difference. Failures can be inspected from Xcode's Report
-Navigator or by inspecting the file URLs of the failure.
-
-You can record a new reference by customizing snapshots inline with the assertion, or using the
-`withSnapshotTesting` tool:
-
-```swift
-// Record just this one snapshot
-assertSnapshot(of: vc, as: .image, record: .all)
-
-// Record all snapshots in a scope:
-withSnapshotTesting(record: .all) {
- assertSnapshot(of: vc1, as: .image)
- assertSnapshot(of: vc2, as: .image)
- assertSnapshot(of: vc3, as: .image)
-}
-
-// Record all snapshot failures in a Swift Testing suite:
-@Suite(.snapshots(record: .failed))
-struct FeatureTests {}
-
-// Record all snapshot failures in an 'XCTestCase' subclass:
-class FeatureTests: XCTestCase {
- override func invokeTest() {
- withSnapshotTesting(record: .failed) {
- super.invokeTest()
+final class MyViewControllerTests: XCTestCase {
+ func testMyViewController() async throws {
+ let vc = MyViewController()
+ try await assert(of: vc, as: .image)
}
- }
}
```
-## Snapshot Anything
-
-While most snapshot testing libraries in the Swift community are limited to `UIImage`s of `UIView`s,
-SnapshotTesting can work with _any_ format of _any_ value on _any_ Swift platform!
-
-The `assertSnapshot` function accepts a value and any snapshot strategy that value supports. This
-means that a view or view controller can be tested against an image representation _and_ against a
-textual representation of its properties and subview hierarchy.
-
-``` swift
-assertSnapshot(of: vc, as: .image)
-assertSnapshot(of: vc, as: .recursiveDescription)
-```
-
-View testing is highly configurable. You can override trait collections (for specific size classes
-and content size categories) and generate device-agnostic snapshots, all from a single simulator.
-
-``` swift
-assertSnapshot(of: vc, as: .image(on: .iPhoneSe))
-assertSnapshot(of: vc, as: .recursiveDescription(on: .iPhoneSe))
-
-assertSnapshot(of: vc, as: .image(on: .iPhoneSe(.landscape)))
-assertSnapshot(of: vc, as: .recursiveDescription(on: .iPhoneSe(.landscape)))
-
-assertSnapshot(of: vc, as: .image(on: .iPhoneX))
-assertSnapshot(of: vc, as: .recursiveDescription(on: .iPhoneX))
+> When an assertion runs for the first time, a snapshot is automatically recorded to disk, and the test will fail, printing the file path of the newly recorded reference.
-assertSnapshot(of: vc, as: .image(on: .iPadMini(.portrait)))
-assertSnapshot(of: vc, as: .recursiveDescription(on: .iPadMini(.portrait)))
-```
+> Repeat test runs will load this reference and compare it with the runtime value. If they don't match, the test will fail and describe the difference.
-> **Warning**
-> Snapshots must be compared using the exact same simulator that originally took the reference to
-> avoid discrepancies between images.
+You can record a new reference by customizing snapshots inline with the assertion or using the `withTestingEnvironment` method.
-Better yet, SnapshotTesting isn't limited to views and view controllers! There are a number of
-available snapshot strategies to choose from.
+## Snapshot Anything
-For example, you can snapshot test URL requests (_e.g._, those that your API client prepares).
+SnapshotTesting isn't limited to `UIView`s and `UIViewController`s. You can snapshot test any value on any Swift platform!
-``` swift
-assertSnapshot(of: urlRequest, as: .raw)
-// POST http://localhost:8080/account
-// Cookie: pf_session={"userId":"1"}
-//
-// email=blob%40pointfree.co&name=Blob
+```swift
+try await assert(of: user, as: .json)
+try await assert(of: user, as: .plist)
+try await assert(of: user, as: .customDump)
```
-And you can snapshot test `Encodable` values against their JSON _and_ property list representations.
-
-``` swift
-assertSnapshot(of: user, as: .json)
-// {
-// "bio" : "Blobbed around the world.",
-// "id" : 1,
-// "name" : "Blobby"
-// }
-
-assertSnapshot(of: user, as: .plist)
-//
-//
-//
-//
-// bio
-// Blobbed around the world.
-// id
-// 1
-// name
-// Blobby
-//
-//
-```
+## Documentation
-In fact, _any_ value can be snapshot-tested by default using its
-[mirror](https://developer.apple.com/documentation/swift/mirror)!
+The latest documentation is available for both main components of the framework:
-``` swift
-assertSnapshot(of: user, as: .dump)
-// ▿ User
-// - bio: "Blobbed around the world."
-// - id: 1
-// - name: "Blobby"
-```
+- For **XCSnapshotTesting** (the core snapshot testing functionality):
+ [XCSnapshotTesting Documentation](https://swiftpackageindex.com/pointfreeco/swift-snapshot-testing/main/documentation/xcsnapshottesting)
-If your data can be represented as an image, text, or data, you can write a snapshot test for it!
+- For **SnapshotTesting** (the Swift Testing integration and utilities):
+ [SnapshotTesting Documentation](https://swiftpackageindex.com/pointfreeco/swift-snapshot-testing/main/documentation/snapshottesting)
-## Documentation
-
-The latest documentation is available
-[here](https://swiftpackageindex.com/pointfreeco/swift-snapshot-testing/main/documentation/snapshottesting).
+These documents provide detailed information on how to use each component effectively in your testing workflows.
## Installation
### Xcode
-> **Warning**
-> By default, Xcode will try to add the SnapshotTesting package to your project's main
-> application/framework target. Please ensure that SnapshotTesting is added to a _test_ target
-> instead, as documented in the last step, below.
-
- 1. From the **File** menu, navigate through **Swift Packages** and select
- **Add Package Dependency…**.
- 2. Enter package repository URL: `https://github.com/pointfreeco/swift-snapshot-testing`.
- 3. Confirm the version and let Xcode resolve the package.
- 4. On the final dialog, update SnapshotTesting's **Add to Target** column to a test target that
- will contain snapshot tests (if you have more than one test target, you can later add
- SnapshotTesting to them by manually linking the library in its build phase).
+1. From the **File** menu, navigate to **Swift Packages** and select **Add Package Dependency…**.
+2. Enter the package repository URL: `https://github.com/pointfreeco/swift-snapshot-testing`.
+3. Confirm the version and let Xcode resolve the package.
+4. Ensure SnapshotTesting is added to a test target.
### Swift Package Manager
-If you want to use SnapshotTesting in any other project that uses
-[SwiftPM](https://swift.org/package-manager/), add the package as a dependency in `Package.swift`:
+Add the package as a dependency in `Package.swift`:
```swift
dependencies: [
.package(
url: "https://github.com/pointfreeco/swift-snapshot-testing",
- from: "1.12.0"
+ from: "2.0.0"
),
]
```
-Next, add `SnapshotTesting` as a dependency of your test target:
+Next, add `SnapshotTesting` to your test target:
```swift
targets: [
- .target(name: "MyApp"),
.testTarget(
name: "MyAppTests",
dependencies: [
@@ -207,76 +89,58 @@ targets: [
## Features
- - [**Dozens of snapshot strategies**][available-strategies]. Snapshot
- testing isn't just for `UIView`s and `CALayer`s. Write snapshots against _any_ value.
- - [**Write your own snapshot strategies**][defining-strategies].
- If you can convert it to an image, string, data, or your own diffable format, you can snapshot
- test it! Build your own snapshot strategies from scratch or transform existing ones.
- - **No configuration required.** Don't fuss with scheme settings and environment variables.
- Snapshots are automatically saved alongside your tests.
- - **More hands-off.** New snapshots are recorded whether `isRecording` mode is `true` or not.
- - **Subclass-free.** Assert from any XCTest case or Quick spec.
- - **Device-agnostic snapshots.** Render views and view controllers for specific devices and trait
- collections from a single simulator.
- - **First-class Xcode support.** Image differences are captured as XCTest attachments. Text
- differences are rendered in inline error messages.
- - **Supports any platform that supports Swift.** Write snapshot tests for iOS, Linux, macOS, and
- tvOS.
- - **SceneKit, SpriteKit, and WebKit support.** Most snapshot testing libraries don't support these
- view subclasses.
- - **`Codable` support**. Snapshot encodable data structures into their JSON and property list
- representations.
- - **Custom diff tool integration**. Configure failure messages to print diff commands for
- [Kaleidoscope](https://kaleidoscope.app) or your diff tool of choice.
- ``` swift
- SnapshotTesting.diffToolCommand = { "ksdiff \($0) \($1)" }
- ```
-
-[available-strategies]: https://swiftpackageindex.com/pointfreeco/swift-snapshot-testing/main/documentation/snapshottesting/snapshotting
-[defining-strategies]: https://swiftpackageindex.com/pointfreeco/swift-snapshot-testing/main/documentation/snapshottesting/customstrategies
-
-## Plug-ins
-
- - [AccessibilitySnapshot](https://github.com/cashapp/AccessibilitySnapshot) adds easy regression
- testing for iOS accessibility.
-
- - [AccessibilitySnapshotColorBlindness](https://github.com/Sherlouk/AccessibilitySnapshotColorBlindness)
- adds snapshot strategies for color blindness simulation on iOS views, view controllers and images.
-
- - [GRDBSnapshotTesting](https://github.com/SebastianOsinski/GRDBSnapshotTesting) adds snapshot
- strategy for testing SQLite database migrations made with [GRDB](https://github.com/groue/GRDB.swift).
-
- - [Nimble-SnapshotTesting](https://github.com/tahirmt/Nimble-SnapshotTesting) adds
- [Nimble](https://github.com/Quick/Nimble) matchers for SnapshotTesting to be used by Swift
- Package Manager.
-
- - [Prefire](https://github.com/BarredEwe/Prefire) generating Snapshot Tests via
- [Swift Package Plugins](https://github.com/apple/swift-package-manager/blob/main/Documentation/Plugins.md)
- using SwiftUI `Preview`
-
- - [PreviewSnapshots](https://github.com/doordash-oss/swiftui-preview-snapshots) share `View`
- configurations between SwiftUI Previews and snapshot tests and generate several snapshots with a
- single test assertion.
-
- - [swift-html](https://github.com/pointfreeco/swift-html) is a Swift DSL for type-safe,
- extensible, and transformable HTML documents and includes an `HtmlSnapshotTesting` module to
- snapshot test its HTML documents.
-
- - [swift-snapshot-testing-nimble](https://github.com/Killectro/swift-snapshot-testing-nimble) adds
- [Nimble](https://github.com/Quick/Nimble) matchers for SnapshotTesting.
-
- - [swift-snapshot-testing-stitch](https://github.com/Sherlouk/swift-snapshot-testing-stitch/) adds
- the ability to stitch multiple UIView's or UIViewController's together in a single test.
-
- - [SnapshotTestingDump](https://github.com/tahirmt/swift-snapshot-testing-dump) Adds support to
- use [swift-custom-dump](https://github.com/pointfreeco/swift-custom-dump/) by using `customDump`
- strategy for `Any`
-
- - [SnapshotTestingHEIC](https://github.com/alexey1312/SnapshotTestingHEIC) adds image support
- using the HEIC storage format which reduces file sizes in comparison to PNG.
-
- - [SnapshotVision](https://github.com/gregersson/swift-snapshot-testing-vision) adds snapshot
- strategy for text recognition on views and images. Uses Apples Vision framework.
+- **Versatile Snapshot Strategies**: Test any value, not just UI components.
+- **Custom Snapshot Strategies**: Create your own snapshot strategies.
+- **No Configuration Required**: Snapshots are saved alongside your tests automatically.
+- **Device-Agnostic Snapshots**: Render views for specific devices from a single simulator.
+- **Xcode Integration**: Image differences are captured as XCTest attachments.
+- **Cross-Platform Support**: Supports iOS, macOS, tvOS, and more.
+- **SceneKit, SpriteKit, and WebKit Support**: Test these specialized views.
+- **Codable Support**: Snapshot encodable data structures into JSON and property list representations.
+- **Custom Diff Tool Integration**: Configure failure messages to print diff commands for tools like Kaleidoscope.
+
+## Plugins
+
+- [AccessibilitySnapshot](https://github.com/cashapp/AccessibilitySnapshot) adds easy regression
+ testing for iOS accessibility.
+
+- [AccessibilitySnapshotColorBlindness](https://github.com/Sherlouk/AccessibilitySnapshotColorBlindness)
+ adds snapshot strategies for color blindness simulation on iOS views, view controllers and images.
+
+- [GRDBSnapshotTesting](https://github.com/SebastianOsinski/GRDBSnapshotTesting) adds snapshot
+ strategy for testing SQLite database migrations made with [GRDB](https://github.com/groue/GRDB.swift).
+
+- [Nimble-SnapshotTesting](https://github.com/tahirmt/Nimble-SnapshotTesting) adds
+ [Nimble](https://github.com/Quick/Nimble) matchers for SnapshotTesting to be used by Swift
+ Package Manager.
+
+- [Prefire](https://github.com/BarredEwe/Prefire) generating Snapshot Tests via
+ [Swift Package Plugins](https://github.com/apple/swift-package-manager/blob/main/Documentation/Plugins.md)
+ using SwiftUI `Preview`
+
+- [PreviewSnapshots](https://github.com/doordash-oss/swiftui-preview-snapshots) share `View`
+ configurations between SwiftUI Previews and snapshot tests and generate several snapshots with a
+ single test assertion.
+
+- [swift-html](https://github.com/pointfreeco/swift-html) is a Swift DSL for type-safe,
+ extensible, and transformable HTML documents and includes an `HtmlSnapshotTesting` module to
+ snapshot test its HTML documents.
+
+- [swift-snapshot-testing-nimble](https://github.com/Killectro/swift-snapshot-testing-nimble) adds
+ [Nimble](https://github.com/Quick/Nimble) matchers for SnapshotTesting.
+
+- [swift-snapshot-testing-stitch](https://github.com/Sherlouk/swift-snapshot-testing-stitch/) adds
+ the ability to stitch multiple UIView's or UIViewController's together in a single test.
+
+- [SnapshotTestingDump](https://github.com/tahirmt/swift-snapshot-testing-dump) Adds support to
+ use [swift-custom-dump](https://github.com/pointfreeco/swift-custom-dump/) by using `customDump`
+ strategy for `Any`
+
+- [SnapshotTestingHEIC](https://github.com/alexey1312/SnapshotTestingHEIC) adds image support
+using the HEIC storage format which reduces file sizes in comparison to PNG.
+
+- [SnapshotVision](https://github.com/gregersson/swift-snapshot-testing-vision) adds snapshot
+ strategy for text recognition on views and images. Uses Apples Vision framework.
Have you written your own SnapshotTesting plug-in?
[Add it here](https://github.com/pointfreeco/swift-snapshot-testing/edit/master/README.md) and
@@ -284,13 +148,13 @@ submit a pull request!
## Related Tools
- - [`iOSSnapshotTestCase`](https://github.com/uber/ios-snapshot-test-case/) helped introduce screen
+- [`iOSSnapshotTestCase`](https://github.com/uber/ios-snapshot-test-case/) helped introduce screen
shot testing to a broad audience in the iOS community. Experience with it inspired the creation
of this library.
- - [Jest](https://jestjs.io) brought generalized snapshot testing to the JavaScript community with
- a polished user experience. Several features of this library (diffing, automatically capturing
- new snapshots) were directly influenced.
+- [Jest](https://jestjs.io) brought generalized snapshot testing to the JavaScript community with
+ a polished user experience. Several features of this library (diffing, automatically capturing
+ new snapshots) were directly influenced.
## Learn More
diff --git a/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift b/Sources/Deprecated/InlineSnapshotTesting/AssertInlineSnapshot.swift
similarity index 97%
rename from Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift
rename to Sources/Deprecated/InlineSnapshotTesting/AssertInlineSnapshot.swift
index facf79d4b..88a14ebc6 100644
--- a/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift
+++ b/Sources/Deprecated/InlineSnapshotTesting/AssertInlineSnapshot.swift
@@ -1,7 +1,7 @@
import Foundation
-#if canImport(SwiftSyntax509)
- @_spi(Internals) import SnapshotTesting
+#if canImport(SwiftSyntax601)
+ @_spi(Internals) import _SnapshotTesting
import SwiftParser
import SwiftSyntax
import SwiftSyntaxBuilder
@@ -33,6 +33,7 @@ import Foundation
/// function was called.
/// - column: The column on which failure occurred. Defaults to the column on which this
/// function was called.
+ @available(*, deprecated, renamed: "assertInline")
public func assertInlineSnapshot(
of value: @autoclosure () throws -> Value?,
as snapshotting: Snapshotting,
@@ -216,6 +217,7 @@ import Foundation
/// Provide this structure when defining custom snapshot functions that call
/// ``assertInlineSnapshot(of:as:message:record:timeout:syntaxDescriptor:matches:file:function:line:column:)``
/// under the hood.
+@available(*, deprecated, renamed: "SnapshotClosureDescriptor")
public struct InlineSnapshotSyntaxDescriptor: Hashable {
/// The default label describing an inline snapshot.
public static let defaultTrailingClosureLabel = "matches"
@@ -270,7 +272,7 @@ public struct InlineSnapshotSyntaxDescriptor: Hashable {
self.trailingClosureOffset = trailingClosureOffset
}
- #if canImport(SwiftSyntax509)
+ #if canImport(SwiftSyntax601)
/// Generates a test failure immediately and unconditionally at the described trailing closure.
///
/// This method will attempt to locate the line of the trailing closure described by this type
@@ -333,7 +335,8 @@ public struct InlineSnapshotSyntaxDescriptor: Hashable {
// MARK: - Private
-#if canImport(SwiftSyntax509)
+#if canImport(SwiftSyntax601)
+ @available(*, deprecated)
private let installTestObserver: Void = {
final class InlineSnapshotObserver: NSObject, XCTestObservation {
func testBundleDidFinish(_ testBundle: Bundle) {
@@ -343,12 +346,13 @@ public struct InlineSnapshotSyntaxDescriptor: Hashable {
if Thread.isMainThread {
XCTestObservationCenter.shared.addTestObserver(InlineSnapshotObserver())
} else {
- DispatchQueue.main.sync {
- XCTestObservationCenter.shared.addTestObserver(InlineSnapshotObserver())
- }
+ DispatchQueue.main.sync {
+ XCTestObservationCenter.shared.addTestObserver(InlineSnapshotObserver())
+ }
}
}()
+ @available(*, deprecated)
@_spi(Internals) public struct File: Hashable {
public let path: StaticString
public static func == (lhs: Self, rhs: Self) -> Bool {
@@ -359,6 +363,7 @@ public struct InlineSnapshotSyntaxDescriptor: Hashable {
}
}
+ @available(*, deprecated)
@_spi(Internals) public struct InlineSnapshot: Hashable {
public var expected: String?
public var actual: String?
@@ -369,14 +374,17 @@ public struct InlineSnapshotSyntaxDescriptor: Hashable {
public var column: UInt
}
+ @available(*, deprecated)
@_spi(Internals) public var inlineSnapshotState: [File: [InlineSnapshot]] = [:]
+ @available(*, deprecated)
private struct TestSource {
let source: String
let sourceFile: SourceFileSyntax
let sourceLocationConverter: SourceLocationConverter
}
+ @available(*, deprecated)
private func testSource(file: File) throws -> TestSource {
guard let testSource = testSourceCache[file]
else {
@@ -395,8 +403,10 @@ public struct InlineSnapshotSyntaxDescriptor: Hashable {
return testSource
}
+ @available(*, deprecated)
private var testSourceCache: [File: TestSource] = [:]
+ @available(*, deprecated)
private func writeInlineSnapshots() {
defer { inlineSnapshotState.removeAll() }
for (file, snapshots) in inlineSnapshotState {
@@ -425,6 +435,7 @@ public struct InlineSnapshotSyntaxDescriptor: Hashable {
}
}
+ @available(*, deprecated)
private final class SnapshotRewriter: SyntaxRewriter {
let file: File
var function: String?
@@ -659,6 +670,7 @@ public struct InlineSnapshotSyntaxDescriptor: Hashable {
}
}
+ @available(*, deprecated)
private final class SnapshotVisitor: SyntaxVisitor {
let functionCallColumn: Int
let functionCallLine: Int
@@ -736,6 +748,7 @@ public struct InlineSnapshotSyntaxDescriptor: Hashable {
}
}
+ @available(*, deprecated)
extension String {
fileprivate func indenting(by count: Int) -> String {
self.indenting(with: String(repeating: " ", count: count))
diff --git a/Sources/Deprecated/InlineSnapshotTesting/Exports.swift b/Sources/Deprecated/InlineSnapshotTesting/Exports.swift
new file mode 100644
index 000000000..5374a3efc
--- /dev/null
+++ b/Sources/Deprecated/InlineSnapshotTesting/Exports.swift
@@ -0,0 +1 @@
+@_exported import _SnapshotTesting
diff --git a/Sources/SnapshotTesting/AssertSnapshot.swift b/Sources/Deprecated/SnapshotTesting/AssertSnapshot.swift
similarity index 95%
rename from Sources/SnapshotTesting/AssertSnapshot.swift
rename to Sources/Deprecated/SnapshotTesting/AssertSnapshot.swift
index 11e761422..9a562e846 100644
--- a/Sources/SnapshotTesting/AssertSnapshot.swift
+++ b/Sources/Deprecated/SnapshotTesting/AssertSnapshot.swift
@@ -6,12 +6,7 @@ import XCTest
/// Enhances failure messages with a command line diff tool expression that can be copied and pasted
/// into a terminal.
-@available(
- *,
- deprecated,
- message:
- "Use 'withSnapshotTesting' to customize the diff tool. See the documentation for more information."
-)
+@available(*, deprecated, renamed: "withTestingEnvironment(diffTool:operation:)")
public var diffTool: SnapshotTestingConfiguration.DiffTool {
get {
_diffTool
@@ -19,6 +14,7 @@ public var diffTool: SnapshotTestingConfiguration.DiffTool {
set { _diffTool = newValue }
}
+@available(*, deprecated, renamed: "withTestingEnvironment(diffTool:operation:)")
@_spi(Internals)
public var _diffTool: SnapshotTestingConfiguration.DiffTool {
get {
@@ -38,20 +34,18 @@ public var _diffTool: SnapshotTestingConfiguration.DiffTool {
}
}
+@available(*, deprecated, renamed: "withTestingEnvironment(diffTool:operation:)")
@_spi(Internals)
public var __diffTool: SnapshotTestingConfiguration.DiffTool = .default
/// Whether or not to record all new references.
-@available(
- *, deprecated,
- message:
- "Use 'withSnapshotTesting' to customize the record mode. See the documentation for more information."
-)
+@available(*, deprecated, renamed: "withTestingEnvironment(record:operation:)")
public var isRecording: Bool {
get { SnapshotTestingConfiguration.current?.record ?? _record == .all }
set { _record = newValue ? .all : .missing }
}
+@available(*, deprecated, renamed: "withTestingEnvironment(record:operation:)")
@_spi(Internals)
public var _record: SnapshotTestingConfiguration.Record {
get {
@@ -71,6 +65,7 @@ public var _record: SnapshotTestingConfiguration.Record {
}
}
+@available(*, deprecated, renamed: "withTestingEnvironment(record:operation:)")
@_spi(Internals)
public var __record: SnapshotTestingConfiguration.Record = {
if let value = ProcessInfo.processInfo.environment["SNAPSHOT_TESTING_RECORD"],
@@ -99,6 +94,9 @@ public var __record: SnapshotTestingConfiguration.Record = {
/// function was called.
/// - column: The column on which failure occurred. Defaults to the column on which this function
/// was called.
+@available(
+ *, deprecated, renamed: "assert(of:as:named:record:timeout:fileID:file:testName:line:column:)"
+)
public func assertSnapshot(
of value: @autoclosure () throws -> Value,
as snapshotting: Snapshotting,
@@ -151,6 +149,7 @@ public func assertSnapshot(
/// function was called.
/// - column: The column on which failure occurred. Defaults to the column on which this function
/// was called.
+@available(*, deprecated, renamed: "assert(of:as:record:timeout:fileID:file:testName:line:column:)")
public func assertSnapshots(
of value: @autoclosure () throws -> Value,
as strategies: [String: Snapshotting],
@@ -195,6 +194,7 @@ public func assertSnapshots(
/// function was called.
/// - column: The column on which failure occurred. Defaults to the column on which this function
/// was called.
+@available(*, deprecated, renamed: "assert(of:as:record:timeout:fileID:file:testName:line:column:)")
public func assertSnapshots(
of value: @autoclosure () throws -> Value,
as strategies: [Snapshotting],
@@ -272,6 +272,10 @@ public func assertSnapshots(
/// - line: The line number on which failure occurred. Defaults to the line number on which this
/// function was called.
/// - Returns: A failure message or, if the value matches, nil.
+@available(
+ *, deprecated,
+ renamed: "verify(of:as:named:record:snapshotDirectory:timeout:fileID:file:testName:line:column:)"
+)
public func verifySnapshot(
of value: @autoclosure () throws -> Value,
as snapshotting: Snapshotting,
diff --git a/Sources/SnapshotTesting/Async.swift b/Sources/Deprecated/SnapshotTesting/Async.swift
similarity index 97%
rename from Sources/SnapshotTesting/Async.swift
rename to Sources/Deprecated/SnapshotTesting/Async.swift
index 2c16adda5..1357b8dfb 100644
--- a/Sources/SnapshotTesting/Async.swift
+++ b/Sources/Deprecated/SnapshotTesting/Async.swift
@@ -13,6 +13,7 @@
/// }
/// }
/// ```
+@available(*, deprecated, renamed: "Sync")
public struct Async {
public let run: (@escaping (Value) -> Void) -> Void
diff --git a/Sources/SnapshotTesting/Common/Internal.swift b/Sources/Deprecated/SnapshotTesting/Common/Internal.swift
similarity index 100%
rename from Sources/SnapshotTesting/Common/Internal.swift
rename to Sources/Deprecated/SnapshotTesting/Common/Internal.swift
diff --git a/Sources/SnapshotTesting/Common/PlistEncoder.swift b/Sources/Deprecated/SnapshotTesting/Common/PlistEncoder.swift
similarity index 100%
rename from Sources/SnapshotTesting/Common/PlistEncoder.swift
rename to Sources/Deprecated/SnapshotTesting/Common/PlistEncoder.swift
diff --git a/Sources/SnapshotTesting/Common/String+SpecialCharacters.swift b/Sources/Deprecated/SnapshotTesting/Common/String+SpecialCharacters.swift
similarity index 100%
rename from Sources/SnapshotTesting/Common/String+SpecialCharacters.swift
rename to Sources/Deprecated/SnapshotTesting/Common/String+SpecialCharacters.swift
diff --git a/Sources/SnapshotTesting/Common/View.swift b/Sources/Deprecated/SnapshotTesting/Common/View.swift
similarity index 99%
rename from Sources/SnapshotTesting/Common/View.swift
rename to Sources/Deprecated/SnapshotTesting/Common/View.swift
index 753153749..07754da41 100644
--- a/Sources/SnapshotTesting/Common/View.swift
+++ b/Sources/Deprecated/SnapshotTesting/Common/View.swift
@@ -12,6 +12,7 @@
#endif
#if os(iOS) || os(tvOS)
+ @available(*, deprecated, renamed: "LayoutConfiguration")
public struct ViewImageConfig: Sendable {
public enum Orientation {
case landscape
@@ -502,6 +503,7 @@
#endif
}
+ @available(*, deprecated)
extension UITraitCollection {
#if os(iOS)
public static func iPhoneSe(_ orientation: ViewImageConfig.Orientation)
@@ -808,6 +810,7 @@
}
#endif
+ @available(*, deprecated)
func addImagesForRenderedViews(_ view: View) -> [Async] {
return view.snapshot
.map { async in
@@ -830,6 +833,7 @@
?? view.subviews.flatMap(addImagesForRenderedViews)
}
+ @available(*, deprecated)
extension View {
var snapshot: Async? {
func inWindow(_ perform: () -> T) -> T {
@@ -926,6 +930,7 @@
}
}
+ @available(*, deprecated)
func prepareView(
config: ViewImageConfig,
drawHierarchyInKeyWindow: Bool,
@@ -965,6 +970,7 @@
return dispose
}
+ @available(*, deprecated)
func snapshotView(
config: ViewImageConfig,
drawHierarchyInKeyWindow: Bool,
@@ -1084,6 +1090,7 @@
return window
}
+ @available(*, deprecated)
private final class Window: UIWindow {
var config: ViewImageConfig
@@ -1136,6 +1143,7 @@
#endif
#endif
+@available(*, deprecated)
extension Array {
func sequence() -> Async<[A]> where Element == Async {
guard !self.isEmpty else { return Async(value: []) }
diff --git a/Sources/SnapshotTesting/Common/XCTAttachment.swift b/Sources/Deprecated/SnapshotTesting/Common/XCTAttachment.swift
similarity index 76%
rename from Sources/SnapshotTesting/Common/XCTAttachment.swift
rename to Sources/Deprecated/SnapshotTesting/Common/XCTAttachment.swift
index 117dd26c0..f17d254f1 100644
--- a/Sources/SnapshotTesting/Common/XCTAttachment.swift
+++ b/Sources/Deprecated/SnapshotTesting/Common/XCTAttachment.swift
@@ -1,6 +1,7 @@
#if os(Linux) || os(Android) || os(Windows)
import Foundation
+ @available(*, deprecated, message: "Not available anymore")
public struct XCTAttachment {
public init(data: Data) {}
public init(data: Data, uniformTypeIdentifier: String) {}
diff --git a/Sources/SnapshotTesting/Diff.swift b/Sources/Deprecated/SnapshotTesting/Diff.swift
similarity index 100%
rename from Sources/SnapshotTesting/Diff.swift
rename to Sources/Deprecated/SnapshotTesting/Diff.swift
diff --git a/Sources/SnapshotTesting/Diffing.swift b/Sources/Deprecated/SnapshotTesting/Diffing.swift
similarity index 94%
rename from Sources/SnapshotTesting/Diffing.swift
rename to Sources/Deprecated/SnapshotTesting/Diffing.swift
index c189578ec..9ebc15ad5 100644
--- a/Sources/SnapshotTesting/Diffing.swift
+++ b/Sources/Deprecated/SnapshotTesting/Diffing.swift
@@ -2,6 +2,7 @@ import Foundation
import XCTest
/// The ability to compare `Value`s and convert them to and from `Data`.
+@available(*, deprecated, renamed: "DiffAttachmentGenerator")
public struct Diffing {
/// Converts a value _to_ data.
public var toData: (Value) -> Data
diff --git a/Sources/SnapshotTesting/Extensions/Wait.swift b/Sources/Deprecated/SnapshotTesting/Extensions/Wait.swift
similarity index 97%
rename from Sources/SnapshotTesting/Extensions/Wait.swift
rename to Sources/Deprecated/SnapshotTesting/Extensions/Wait.swift
index f15c7da19..f300c48f7 100644
--- a/Sources/SnapshotTesting/Extensions/Wait.swift
+++ b/Sources/Deprecated/SnapshotTesting/Extensions/Wait.swift
@@ -1,6 +1,7 @@
import Foundation
import XCTest
+@available(*, deprecated)
extension Snapshotting {
/// Transforms an existing snapshot strategy into one that waits for some amount of time before
/// taking the snapshot. This can be useful for waiting for animations to complete or for UIKit
diff --git a/Sources/SnapshotTesting/Internal/Deprecations.swift b/Sources/Deprecated/SnapshotTesting/Internal/Deprecations.swift
similarity index 100%
rename from Sources/SnapshotTesting/Internal/Deprecations.swift
rename to Sources/Deprecated/SnapshotTesting/Internal/Deprecations.swift
diff --git a/Sources/SnapshotTesting/Internal/RecordIssue.swift b/Sources/Deprecated/SnapshotTesting/Internal/RecordIssue.swift
similarity index 89%
rename from Sources/SnapshotTesting/Internal/RecordIssue.swift
rename to Sources/Deprecated/SnapshotTesting/Internal/RecordIssue.swift
index 214761180..0d68f0c37 100644
--- a/Sources/SnapshotTesting/Internal/RecordIssue.swift
+++ b/Sources/Deprecated/SnapshotTesting/Internal/RecordIssue.swift
@@ -13,6 +13,7 @@ var isSwiftTesting: Bool {
}
@_spi(Internals)
+@available(*, deprecated, renamed: "TestingSystem.shared.record(_:fileID:filePath:line:column:)")
public func recordIssue(
_ message: @autoclosure () -> String,
fileID: StaticString,
diff --git a/Sources/SnapshotTesting/SnapshotTestingConfiguration.swift b/Sources/Deprecated/SnapshotTesting/SnapshotTestingConfiguration.swift
similarity index 91%
rename from Sources/SnapshotTesting/SnapshotTestingConfiguration.swift
rename to Sources/Deprecated/SnapshotTesting/SnapshotTestingConfiguration.swift
index de8908173..1b78c4720 100644
--- a/Sources/SnapshotTesting/SnapshotTestingConfiguration.swift
+++ b/Sources/Deprecated/SnapshotTesting/SnapshotTestingConfiguration.swift
@@ -23,6 +23,7 @@
/// - record: The record mode to use while asserting snapshots.
/// - diffTool: The diff tool to use while asserting snapshots.
/// - operation: The operation to perform.
+@available(*, deprecated, renamed: "withTestingEnvironment(record:diffTool:operation:)")
public func withSnapshotTesting(
record: SnapshotTestingConfiguration.Record? = nil,
diffTool: SnapshotTestingConfiguration.DiffTool? = nil,
@@ -32,7 +33,7 @@ public func withSnapshotTesting(
SnapshotTestingConfiguration(
record: record ?? SnapshotTestingConfiguration.current?.record ?? _record,
diffTool: diffTool ?? SnapshotTestingConfiguration.current?.diffTool
- ?? SnapshotTesting._diffTool
+ ?? _SnapshotTesting._diffTool
)
) {
try operation()
@@ -42,6 +43,7 @@ public func withSnapshotTesting(
/// Customizes `assertSnapshot` for the duration of an asynchronous operation.
///
/// See ``withSnapshotTesting(record:diffTool:operation:)-2kuyr`` for more information.
+@available(*, deprecated, renamed: "withTestingEnvironment(record:diffTool:operation:)")
public func withSnapshotTesting(
record: SnapshotTestingConfiguration.Record? = nil,
diffTool: SnapshotTestingConfiguration.DiffTool? = nil,
@@ -58,6 +60,7 @@ public func withSnapshotTesting(
}
/// The configuration for a snapshot test.
+@available(*, deprecated, message: "Migrate to withTestingEnvironment(operation:)")
public struct SnapshotTestingConfiguration: Sendable {
@_spi(Internals)
@TaskLocal public static var current: Self?
@@ -85,6 +88,7 @@ public struct SnapshotTestingConfiguration: Sendable {
/// There are 4 primary strategies for recording: ``Record-swift.struct/all``,
/// ``Record-swift.struct/missing``, ``Record-swift.struct/never`` and
/// ``Record-swift.struct/failed``
+ @available(*, deprecated, renamed: "RecordMode")
public struct Record: Equatable, Sendable {
private let storage: Storage
@@ -153,6 +157,7 @@ public struct SnapshotTestingConfiguration: Sendable {
/// command for opening [Kaleidoscope](https://kaleidoscope.app), and
/// ``DiffTool-swift.struct/default`` for simply printing the two URLs to the test failure
/// message.
+ @available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
public struct DiffTool: Sendable, ExpressibleByStringLiteral {
var tool: @Sendable (_ currentFilePath: String, _ failedFilePath: String) -> String
@@ -192,31 +197,7 @@ public struct SnapshotTestingConfiguration: Sendable {
}
}
-@available(
- iOS,
- deprecated: 9999,
- message: "Use '.all' instead of 'true', and '.missing' instead of 'false'."
-)
-@available(
- macOS,
- deprecated: 9999,
- message: "Use '.all' instead of 'true', and '.missing' instead of 'false'."
-)
-@available(
- tvOS,
- deprecated: 9999,
- message: "Use '.all' instead of 'true', and '.missing' instead of 'false'."
-)
-@available(
- watchOS,
- deprecated: 9999,
- message: "Use '.all' instead of 'true', and '.missing' instead of 'false'."
-)
-@available(
- visionOS,
- deprecated: 9999,
- message: "Use '.all' instead of 'true', and '.missing' instead of 'false'."
-)
+@available(*, deprecated)
extension SnapshotTestingConfiguration.Record: ExpressibleByBooleanLiteral {
public init(booleanLiteral value: BooleanLiteralType) {
self = value ? .all : .missing
diff --git a/Sources/SnapshotTesting/SnapshotsTestTrait.swift b/Sources/Deprecated/SnapshotTesting/SnapshotsTestTrait.swift
similarity index 81%
rename from Sources/SnapshotTesting/SnapshotsTestTrait.swift
rename to Sources/Deprecated/SnapshotTesting/SnapshotsTestTrait.swift
index 9fa4f9a53..e20a27363 100644
--- a/Sources/SnapshotTesting/SnapshotsTestTrait.swift
+++ b/Sources/Deprecated/SnapshotTesting/SnapshotsTestTrait.swift
@@ -2,13 +2,16 @@
import Testing
/// A type representing the configuration of snapshot testing.
+ @available(*, deprecated, message: "Migrate to new the SnapshotTesting API")
public struct _SnapshotsTestTrait: SuiteTrait, TestTrait {
public let isRecursive = true
let configuration: SnapshotTestingConfiguration
}
+ @available(*, deprecated)
extension Trait where Self == _SnapshotsTestTrait {
/// Configure snapshot testing in a suite or test.
+ @available(*, deprecated, message: "Replace with .record(..) or .diffTool(..)")
public static var snapshots: Self {
snapshots()
}
@@ -18,6 +21,7 @@
/// - Parameters:
/// - record: The record mode of the test.
/// - diffTool: The diff tool to use in failure messages.
+ @available(*, deprecated, message: "Replace with .record(..) or .diffTool(..)")
public static func snapshots(
record: SnapshotTestingConfiguration.Record? = nil,
diffTool: SnapshotTestingConfiguration.DiffTool? = nil
@@ -33,6 +37,7 @@
/// Configure snapshot testing in a suite or test.
///
/// - Parameter configuration: The configuration to use.
+ @available(*, deprecated, message: "Replace with .record(..) or .diffTool(..)")
public static func snapshots(
_ configuration: SnapshotTestingConfiguration
) -> Self {
@@ -41,6 +46,7 @@
}
#if compiler(>=6.1)
+ @available(*, deprecated)
extension _SnapshotsTestTrait: TestScoping {
public func provideScope(
for test: Test,
diff --git a/Sources/SnapshotTesting/Snapshotting.swift b/Sources/Deprecated/SnapshotTesting/Snapshotting.swift
similarity index 97%
rename from Sources/SnapshotTesting/Snapshotting.swift
rename to Sources/Deprecated/SnapshotTesting/Snapshotting.swift
index 4e984f783..43a27a947 100644
--- a/Sources/SnapshotTesting/Snapshotting.swift
+++ b/Sources/Deprecated/SnapshotTesting/Snapshotting.swift
@@ -3,6 +3,7 @@ import XCTest
/// A type representing the ability to transform a snapshottable value into a diffable format (like
/// text or an image) for snapshot testing.
+@available(*, deprecated, renamed: "Snapshot")
public struct Snapshotting {
/// The path extension applied to references saved to disk.
public var pathExtension: String?
@@ -109,9 +110,12 @@ public struct Snapshotting {
}
/// A snapshot strategy where the type being snapshot is also a diffable type.
+@available(*, deprecated, renamed: "IdentitySyncSnapshot")
public typealias SimplySnapshotting = Snapshotting
+@available(*, deprecated)
extension Snapshotting where Value == Format {
+
public init(pathExtension: String?, diffing: Diffing) {
self.init(
pathExtension: pathExtension,
diff --git a/Sources/SnapshotTesting/Snapshotting/Any.swift b/Sources/Deprecated/SnapshotTesting/Snapshotting/Any.swift
similarity index 93%
rename from Sources/SnapshotTesting/Snapshotting/Any.swift
rename to Sources/Deprecated/SnapshotTesting/Snapshotting/Any.swift
index eaa2e3a60..1ee5e2f63 100644
--- a/Sources/SnapshotTesting/Snapshotting/Any.swift
+++ b/Sources/Deprecated/SnapshotTesting/Snapshotting/Any.swift
@@ -1,5 +1,6 @@
import Foundation
+@available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
extension Snapshotting where Format == String {
/// A snapshot strategy that captures a value's textual description from `String`'s
/// `init(describing:)` initializer.
@@ -18,6 +19,7 @@ extension Snapshotting where Format == String {
}
}
+@available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
extension Snapshotting where Format == String {
/// A snapshot strategy for comparing any structure based on a sanitized text dump.
///
@@ -67,6 +69,7 @@ extension Snapshotting where Format == String {
}
@available(macOS 10.13, watchOS 4.0, tvOS 11.0, *)
+@available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
extension Snapshotting where Format == String {
/// A snapshot strategy for comparing any structure based on their JSON representation.
public static var json: Snapshotting {
@@ -86,6 +89,7 @@ extension Snapshotting where Format == String {
}
}
+@available(*, deprecated)
private func snap(
_ value: T,
name: String? = nil,
@@ -121,7 +125,7 @@ private func snap(
return "\(indentation)- \(name.map { "\($0): " } ?? "")\(value.snapshotDescription)\n"
case (let value as CustomStringConvertible, _):
description = value.description
- case let (value as AnyObject, .class?):
+ case (let value as AnyObject, .class?):
let objectID = ObjectIdentifier(value)
if visitedValues.contains(objectID) {
return "\(indentation)\(bullet) \(name ?? "value") (circular reference detected)\n"
@@ -149,6 +153,7 @@ private func snap(
return lines.joined()
}
+@available(*, deprecated)
private func sort(_ children: Mirror.Children, visitedValues: Set)
-> Mirror.Children
{
@@ -164,6 +169,7 @@ private func sort(_ children: Mirror.Children, visitedValues: Set Snapshotting
diff --git a/Sources/SnapshotTesting/Snapshotting/SpriteKit.swift b/Sources/Deprecated/SnapshotTesting/Snapshotting/SpriteKit.swift
similarity index 90%
rename from Sources/SnapshotTesting/Snapshotting/SpriteKit.swift
rename to Sources/Deprecated/SnapshotTesting/Snapshotting/SpriteKit.swift
index ad515050a..ae827f4f5 100644
--- a/Sources/SnapshotTesting/Snapshotting/SpriteKit.swift
+++ b/Sources/Deprecated/SnapshotTesting/Snapshotting/SpriteKit.swift
@@ -7,6 +7,7 @@
#endif
#if os(macOS)
+ @available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
extension Snapshotting where Value == SKScene, Format == NSImage {
/// A snapshot strategy for comparing SpriteKit scenes based on pixel equality.
///
@@ -24,6 +25,7 @@
}
}
#elseif os(iOS) || os(tvOS)
+ @available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
extension Snapshotting where Value == SKScene, Format == UIImage {
/// A snapshot strategy for comparing SpriteKit scenes based on pixel equality.
///
@@ -42,6 +44,7 @@
}
#endif
+ @available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
extension Snapshotting where Value == SKScene, Format == Image {
fileprivate static func skScene(precision: Float, perceptualPrecision: Float, size: CGSize)
-> Snapshotting
diff --git a/Sources/SnapshotTesting/Snapshotting/String.swift b/Sources/Deprecated/SnapshotTesting/Snapshotting/String.swift
similarity index 83%
rename from Sources/SnapshotTesting/Snapshotting/String.swift
rename to Sources/Deprecated/SnapshotTesting/Snapshotting/String.swift
index 44aeab0b4..ff07d71fd 100644
--- a/Sources/SnapshotTesting/Snapshotting/String.swift
+++ b/Sources/Deprecated/SnapshotTesting/Snapshotting/String.swift
@@ -1,11 +1,13 @@
import Foundation
import XCTest
+@available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
extension Snapshotting where Value == String, Format == String {
/// A snapshot strategy for comparing strings based on equality.
public static let lines = Snapshotting(pathExtension: "txt", diffing: .lines)
}
+@available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
extension Diffing where Value == String {
/// A line-diffing strategy for UTF-8 text.
public static let lines = Diffing(
@@ -14,7 +16,7 @@ extension Diffing where Value == String {
) { old, new in
guard old != new else { return nil }
let hunks = chunk(
- diff: SnapshotTesting.diff(
+ diff: _SnapshotTesting.diff(
old.split(separator: "\n", omittingEmptySubsequences: false).map(String.init),
new.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
))
diff --git a/Sources/SnapshotTesting/Snapshotting/SwiftUIView.swift b/Sources/Deprecated/SnapshotTesting/Snapshotting/SwiftUIView.swift
similarity index 93%
rename from Sources/SnapshotTesting/Snapshotting/SwiftUIView.swift
rename to Sources/Deprecated/SnapshotTesting/Snapshotting/SwiftUIView.swift
index 8d85e1f0b..2118dd74c 100644
--- a/Sources/SnapshotTesting/Snapshotting/SwiftUIView.swift
+++ b/Sources/Deprecated/SnapshotTesting/Snapshotting/SwiftUIView.swift
@@ -3,6 +3,7 @@
import SwiftUI
/// The size constraint for a snapshot (similar to `PreviewLayout`).
+ @available(*, deprecated, renamed: "LayoutConfiguration")
public enum SwiftUISnapshotLayout {
#if os(iOS) || os(tvOS)
/// Center the view in a device container described by`config`.
@@ -16,6 +17,7 @@
#if os(iOS) || os(tvOS)
@available(iOS 13.0, tvOS 13.0, *)
+ @available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
extension Snapshotting where Value: SwiftUI.View, Format == UIImage {
/// A snapshot strategy for comparing SwiftUI Views based on pixel equality.
@@ -49,12 +51,12 @@
switch layout {
#if os(iOS) || os(tvOS)
- case let .device(config: deviceConfig):
+ case .device(config: let deviceConfig):
config = deviceConfig
#endif
case .sizeThatFits:
config = .init(safeArea: .zero, size: nil, traits: traits)
- case let .fixed(width: width, height: height):
+ case .fixed(let width, let height):
let size = CGSize(width: width, height: height)
config = .init(safeArea: .zero, size: size, traits: traits)
}
diff --git a/Sources/SnapshotTesting/Snapshotting/UIBezierPath.swift b/Sources/Deprecated/SnapshotTesting/Snapshotting/UIBezierPath.swift
similarity index 93%
rename from Sources/SnapshotTesting/Snapshotting/UIBezierPath.swift
rename to Sources/Deprecated/SnapshotTesting/Snapshotting/UIBezierPath.swift
index 6b48d622d..73cdd18f9 100644
--- a/Sources/SnapshotTesting/Snapshotting/UIBezierPath.swift
+++ b/Sources/Deprecated/SnapshotTesting/Snapshotting/UIBezierPath.swift
@@ -1,6 +1,7 @@
#if os(iOS) || os(tvOS)
import UIKit
+ @available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
extension Snapshotting where Value == UIBezierPath, Format == UIImage {
/// A snapshot strategy for comparing bezier paths based on pixel equality.
public static var image: Snapshotting {
@@ -38,6 +39,7 @@
}
@available(iOS 11.0, tvOS 11.0, *)
+ @available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
extension Snapshotting where Value == UIBezierPath, Format == String {
/// A snapshot strategy for comparing bezier paths based on pixel equality.
public static var elementsDescription: Snapshotting {
diff --git a/Sources/SnapshotTesting/Snapshotting/UIImage.swift b/Sources/Deprecated/SnapshotTesting/Snapshotting/UIImage.swift
similarity index 98%
rename from Sources/SnapshotTesting/Snapshotting/UIImage.swift
rename to Sources/Deprecated/SnapshotTesting/Snapshotting/UIImage.swift
index 3d1bb5319..e51bc46f6 100644
--- a/Sources/SnapshotTesting/Snapshotting/UIImage.swift
+++ b/Sources/Deprecated/SnapshotTesting/Snapshotting/UIImage.swift
@@ -2,6 +2,7 @@
import UIKit
import XCTest
+ @available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
extension Diffing where Value == UIImage {
/// A pixel-diffing strategy for UIImage's which requires a 100% match.
public static let image = Diffing.image()
@@ -35,7 +36,7 @@
let message = compare(
old, new, precision: precision, perceptualPrecision: perceptualPrecision)
else { return nil }
- let difference = SnapshotTesting.diff(old, new)
+ let difference = _SnapshotTesting.diff(old, new)
let oldAttachment = XCTAttachment(image: old)
oldAttachment.name = "reference"
let isEmptyImage = new.size == .zero
@@ -62,6 +63,7 @@
}
}
+ @available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
extension Snapshotting where Value == UIImage, Format == UIImage {
/// A snapshot strategy for comparing images based on pixel equality.
public static var image: Snapshotting {
diff --git a/Sources/SnapshotTesting/Snapshotting/UIView.swift b/Sources/Deprecated/SnapshotTesting/Snapshotting/UIView.swift
similarity index 95%
rename from Sources/SnapshotTesting/Snapshotting/UIView.swift
rename to Sources/Deprecated/SnapshotTesting/Snapshotting/UIView.swift
index 7244f67d1..f43c1d58e 100644
--- a/Sources/SnapshotTesting/Snapshotting/UIView.swift
+++ b/Sources/Deprecated/SnapshotTesting/Snapshotting/UIView.swift
@@ -1,6 +1,7 @@
#if os(iOS) || os(tvOS)
import UIKit
+ @available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
extension Snapshotting where Value == UIView, Format == UIImage {
/// A snapshot strategy for comparing views based on pixel equality.
public static var image: Snapshotting {
@@ -44,6 +45,7 @@
}
}
+ @available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
extension Snapshotting where Value == UIView, Format == String {
/// A snapshot strategy for comparing views based on a recursive description of their properties
/// and hierarchies.
diff --git a/Sources/SnapshotTesting/Snapshotting/UIViewController.swift b/Sources/Deprecated/SnapshotTesting/Snapshotting/UIViewController.swift
similarity index 97%
rename from Sources/SnapshotTesting/Snapshotting/UIViewController.swift
rename to Sources/Deprecated/SnapshotTesting/Snapshotting/UIViewController.swift
index 7b86e51aa..c276679c0 100644
--- a/Sources/SnapshotTesting/Snapshotting/UIViewController.swift
+++ b/Sources/Deprecated/SnapshotTesting/Snapshotting/UIViewController.swift
@@ -1,6 +1,7 @@
#if os(iOS) || os(tvOS)
import UIKit
+ @available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
extension Snapshotting where Value == UIViewController, Format == UIImage {
/// A snapshot strategy for comparing view controller views based on pixel equality.
public static var image: Snapshotting {
@@ -83,6 +84,7 @@
}
}
+ @available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
extension Snapshotting where Value == UIViewController, Format == String {
/// A snapshot strategy for comparing view controllers based on their embedded controller
/// hierarchy.
diff --git a/Sources/SnapshotTesting/Snapshotting/URLRequest.swift b/Sources/Deprecated/SnapshotTesting/Snapshotting/URLRequest.swift
similarity index 98%
rename from Sources/SnapshotTesting/Snapshotting/URLRequest.swift
rename to Sources/Deprecated/SnapshotTesting/Snapshotting/URLRequest.swift
index c2699405f..dfcbc31d1 100644
--- a/Sources/SnapshotTesting/Snapshotting/URLRequest.swift
+++ b/Sources/Deprecated/SnapshotTesting/Snapshotting/URLRequest.swift
@@ -5,6 +5,7 @@
import FoundationNetworking
#endif
+ @available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
extension Snapshotting where Value == URLRequest, Format == String {
/// A snapshot strategy for comparing requests based on raw equality.
///
diff --git a/Sources/Deprecated/SnapshotTestingCustomDump/CustomDump.swift b/Sources/Deprecated/SnapshotTestingCustomDump/CustomDump.swift
new file mode 100644
index 000000000..02acb16f8
--- /dev/null
+++ b/Sources/Deprecated/SnapshotTestingCustomDump/CustomDump.swift
@@ -0,0 +1,25 @@
+import CustomDump
+import _SnapshotTesting
+
+@available(*, deprecated, message: "Migrate to the new SnapshotTesting API")
+extension Snapshotting where Format == String {
+ /// A snapshot strategy for comparing any structure based on a
+ /// [custom dump](https://github.com/pointfreeco/swift-custom-dump).
+ ///
+ /// ```swift
+ /// assertSnapshot(of: user, as: .customDump)
+ /// ```
+ ///
+ /// Records:
+ ///
+ /// ```
+ /// User(
+ /// bio: "Blobbed around the world.",
+ /// id: 1,
+ /// name: "Blobby"
+ /// )
+ /// ```
+ public static var customDump: Snapshotting {
+ SimplySnapshotting.lines.pullback(String.init(customDumping:))
+ }
+}
diff --git a/Sources/InlineSnapshotTesting/AssertInline.swift b/Sources/InlineSnapshotTesting/AssertInline.swift
new file mode 100644
index 000000000..32ae03a26
--- /dev/null
+++ b/Sources/InlineSnapshotTesting/AssertInline.swift
@@ -0,0 +1,216 @@
+import Foundation
+@_spi(Internals) import XCSnapshotTesting
+
+#if canImport(SwiftSyntax601)
+@_spi(Internals) import XCSnapshotTesting
+import SwiftParser
+import SwiftSyntax
+import SwiftSyntaxBuilder
+
+/// Asserts that a given value matches an inline string snapshot using the specified snapshot testing strategy.
+///
+/// This function compares the output of a value—evaluated lazily—with an inline snapshot string, which is stored directly in your test source code.
+/// If the output does not match the inline snapshot, the test will fail and optionally provide a descriptive message.
+/// You can optionally record new snapshots, customize serialization, and specify the snapshot comparison strategy.
+///
+/// - Parameters:
+/// - value: A closure that returns the value to compare against the snapshot. This is evaluated only when the assertion runs.
+/// - snapshot: The snapshot testing strategy to use for serialization and comparison.
+/// - message: An optional closure that returns a description for test results. Defaults to an empty string.
+/// - record: An optional mode indicating whether to record a new reference snapshot. If `nil`, recording is determined automatically.
+/// - timeout: The number of seconds to wait for the snapshot operation to complete. Defaults to 5.
+/// - name: An optional name to distinguish this snapshot from others in the same test.
+/// - serialization: The strategy used to serialize the snapshot data. Defaults to `DataSerialization()`.
+/// - closureDescriptor: An optional descriptor describing the inline snapshot’s location. Typically not needed unless implementing custom helpers.
+/// - expected: An optional closure that returns a previously generated snapshot value. When omitted, the expected value will be populated inline at the call site.
+/// - isolation: Optionally specify an actor for input evaluation, supporting thread/actor isolation. Defaults to current context.
+/// - fileID: The file ID in which the assertion was called. Defaults to the file ID of the test case.
+/// - filePath: The file path in which the assertion was called. Defaults to the file path of the test case.
+/// - function: The function name in which the assertion was called. Defaults to the test method name.
+/// - line: The line number on which the assertion was called. Defaults to the line number of the call site.
+/// - column: The column on which the assertion was called. Defaults to the column number of the call site.
+/// - Throws: Rethrows any error thrown by the value provider or snapshot strategy.
+/// - Important: When using the Swift Testing framework, you must explicitly set the @Suite(.finalizeSnapshots) trait to ensure inline snapshots are written correctly.
+/// - SeeAlso:
+public func assertInline(
+ of value: @autoclosure @Sendable () async throws -> Input,
+ as snapshot: AsyncSnapshot,
+ message: @autoclosure @escaping @Sendable () -> String = "",
+ record: RecordMode? = nil,
+ timeout: TimeInterval = 5,
+ name: String? = nil,
+ serialization: DataSerialization = DataSerialization(),
+ closureDescriptor: SnapshotClosureDescriptor = SnapshotClosureDescriptor(),
+ matches expected: (@Sendable () -> Output.RawValue)? = nil,
+ isolation: isolated Actor? = #isolation,
+ fileID: StaticString = #fileID,
+ file filePath: StaticString = #filePath,
+ function: StaticString = #function,
+ line: UInt = #line,
+ column: UInt = #column
+) async throws {
+ let engine = SnapshotInlineEngine>(
+ expected: expected,
+ message: message,
+ closureDescriptor: closureDescriptor
+ )
+
+ let tester = SnapshotTester(
+ engine: engine,
+ record: record,
+ timeout: timeout,
+ name: name,
+ serialization: serialization,
+ fileID: fileID,
+ filePath: filePath,
+ function: function,
+ line: line,
+ column: column
+ )
+
+ guard let failure = try await tester(value(), for: snapshot) else {
+ return
+ }
+
+ switch failure.reason {
+ case .doesNotMatch:
+ try closureDescriptor.fail(
+ failure.message,
+ fileID: fileID,
+ file: filePath,
+ line: line,
+ column: column
+ )
+ default:
+ try TestingSystem.shared.record(
+ message: failure.message,
+ fileID: fileID,
+ filePath: filePath,
+ line: line,
+ column: column
+ )
+ }
+}
+
+/// Asserts that a value matches an inline string snapshot using a snapshot testing strategy.
+///
+/// This function compares the output of a value—evaluated lazily—with an inline snapshot string
+/// stored directly in your test source code. If the output does not match the inline snapshot,
+/// the test will fail and optionally provide a descriptive message. You can optionally record new
+/// snapshots, customize serialization, and specify the snapshot comparison strategy.
+///
+/// - Parameters:
+/// - value: A closure that returns the value to compare against the snapshot. This is evaluated only when the assertion runs.
+/// - snapshot: The snapshot testing strategy to use for serialization and comparison.
+/// - message: An optional closure that returns a description for test results. Defaults to an empty string.
+/// - record: An optional mode indicating whether to record a new reference snapshot. If `nil`, recording is determined automatically.
+/// - timeout: The number of seconds to wait for the snapshot operation to complete. Defaults to 5.
+/// - name: An optional name to distinguish this snapshot from others in the same test.
+/// - serialization: The strategy used to serialize the snapshot data. Defaults to `DataSerialization()`.
+/// - closureDescriptor: An optional descriptor for the inline snapshot’s location. Typically not needed unless implementing custom helpers.
+/// - expected: An optional closure that returns a previously generated snapshot value. When omitted, the expected value will be populated inline at the call site.
+/// - fileID: The file ID in which the assertion was called. Defaults to the file ID of the test case.
+/// - filePath: The file path in which the assertion was called. Defaults to the file path of the test case.
+/// - function: The function name in which the assertion was called. Defaults to the test method name.
+/// - line: The line number on which the assertion was called. Defaults to the line number of the call site.
+/// - column: The column on which the assertion was called. Defaults to the column number of the call site.
+/// - Throws: Rethrows any error thrown by the value provider or snapshot strategy.
+/// - Important: When using the Swift Testing framework, you must explicitly set the @Suite(.finalizeSnapshots) trait to ensure inline snapshots are written correctly.
+/// - SeeAlso:
+public func assertInline(
+ of value: @autoclosure @Sendable () throws -> Input,
+ as snapshot: SyncSnapshot,
+ message: @autoclosure @escaping @Sendable () -> String = "",
+ record: RecordMode? = nil,
+ timeout: TimeInterval = 5,
+ name: String? = nil,
+ serialization: DataSerialization = DataSerialization(),
+ closureDescriptor: SnapshotClosureDescriptor = SnapshotClosureDescriptor(),
+ matches expected: (@Sendable () -> Output.RawValue)? = nil,
+ fileID: StaticString = #fileID,
+ file filePath: StaticString = #filePath,
+ function: StaticString = #function,
+ line: UInt = #line,
+ column: UInt = #column
+) throws {
+ let engine = SnapshotInlineEngine>(
+ expected: expected,
+ message: message,
+ closureDescriptor: closureDescriptor
+ )
+
+ let tester = SnapshotTester(
+ engine: engine,
+ record: record,
+ timeout: timeout,
+ name: name,
+ serialization: serialization,
+ fileID: fileID,
+ filePath: filePath,
+ function: function,
+ line: line,
+ column: column
+ )
+
+ guard let failure = try tester(value(), for: snapshot) else {
+ return
+ }
+
+ switch failure.reason {
+ case .doesNotMatch:
+ try closureDescriptor.fail(
+ failure.message,
+ fileID: fileID,
+ file: filePath,
+ line: line,
+ column: column
+ )
+ default:
+ try TestingSystem.shared.record(
+ message: failure.message,
+ fileID: fileID,
+ filePath: filePath,
+ line: line,
+ column: column
+ )
+ }
+}
+#else
+@available(*, unavailable, message: "'assertInline' requires 'swift-syntax' >= 509.0.0")
+public func assertInline(
+ of value: @autoclosure @Sendable () throws -> Input,
+ as snapshot: AsyncSnapshot,
+ message: @autoclosure @escaping @Sendable () -> String = "",
+ record: RecordMode? = nil,
+ timeout: TimeInterval = 5,
+ serialization: DataSerialization = DataSerialization(),
+ closureDescriptor: SnapshotClosureDescriptor = SnapshotClosureDescriptor(),
+ matches expected: (@Sendable () -> Output.RawValue)? = nil,
+ fileID: StaticString = #fileID,
+ file filePath: StaticString = #filePath,
+ function: StaticString = #function,
+ line: UInt = #line,
+ column: UInt = #column
+) async throws {
+ fatalError()
+}
+
+@available(*, unavailable, message: "'assertInline' requires 'swift-syntax' >= 509.0.0")
+public func assertInline(
+ of value: @autoclosure @Sendable () throws -> Input,
+ as snapshot: SyncSnapshot,
+ message: @autoclosure @escaping @Sendable () -> String = "",
+ record: RecordMode? = nil,
+ timeout: TimeInterval = 5,
+ serialization: DataSerialization = DataSerialization(),
+ closureDescriptor: SnapshotClosureDescriptor = SnapshotClosureDescriptor(),
+ matches expected: (@Sendable () -> Output.RawValue)? = nil,
+ fileID: StaticString = #fileID,
+ file filePath: StaticString = #filePath,
+ function: StaticString = #function,
+ line: UInt = #line,
+ column: UInt = #column
+) throws {
+ fatalError()
+}
+#endif
diff --git a/Sources/InlineSnapshotTesting/Exports.swift b/Sources/InlineSnapshotTesting/Exports.swift
index 44c34dda7..877ad117c 100644
--- a/Sources/InlineSnapshotTesting/Exports.swift
+++ b/Sources/InlineSnapshotTesting/Exports.swift
@@ -1 +1,5 @@
-@_exported import SnapshotTesting
+@_exported import XCSnapshotTesting
+
+#if !os(visionOS)
+@_exported import _InlineSnapshotTesting
+#endif
diff --git a/Sources/InlineSnapshotTesting/Extensions/String+.swift b/Sources/InlineSnapshotTesting/Extensions/String+.swift
new file mode 100644
index 000000000..1d487e579
--- /dev/null
+++ b/Sources/InlineSnapshotTesting/Extensions/String+.swift
@@ -0,0 +1,30 @@
+import Foundation
+
+extension String {
+
+ func indenting(by count: Int) -> String {
+ self.indenting(with: String(repeating: " ", count: count))
+ }
+
+ func indenting(with prefix: String) -> String {
+ guard !prefix.isEmpty else { return self }
+ return self.replacingOccurrences(
+ of: #"([^\n]+)"#,
+ with: "\(prefix)$1",
+ options: .regularExpression
+ )
+ }
+
+ func hashCount(isMultiline: Bool) -> Int {
+ let (quote, offset) = isMultiline ? ("\"\"\"", 2) : ("\"", 0)
+ var substring = self[...]
+ var hashCount = self.contains(#"\"#) ? 1 : 0
+ let pattern = "(\(quote)[#]*)"
+ while let range = substring.range(of: pattern, options: .regularExpression) {
+ let count = substring.distance(from: range.lowerBound, to: range.upperBound) - offset
+ hashCount = max(count, hashCount)
+ substring = substring[range.upperBound...]
+ }
+ return hashCount
+ }
+}
diff --git a/Sources/InlineSnapshotTesting/SnapshotInlineEngine.swift b/Sources/InlineSnapshotTesting/SnapshotInlineEngine.swift
new file mode 100644
index 000000000..b9916b78e
--- /dev/null
+++ b/Sources/InlineSnapshotTesting/SnapshotInlineEngine.swift
@@ -0,0 +1,183 @@
+#if canImport(SwiftSyntax601)
+import SwiftSyntax
+import Foundation
+@_spi(Internals) import XCSnapshotTesting
+
+struct SnapshotInlineEngine: SnapshotEngine where Executor.Output: BytesRepresentable {
+
+ let expected: (@Sendable () -> Executor.Output.RawValue)?
+ let message: @Sendable () -> String
+ let closureDescriptor: SnapshotClosureDescriptor
+
+ init(
+ expected: (@Sendable () -> Executor.Output.RawValue)?,
+ message: @Sendable @escaping () -> String,
+ closureDescriptor: SnapshotClosureDescriptor
+ ) {
+ SnapshotInlineObservation.shared.registerIfNeeded()
+ if TestingSystem.shared.isSwiftTestingRunning {
+ precondition(
+ TestingSystem.shared.isSwiftTestingCompletionAttached,
+ "To run InlineSnapshotTesting on Swift Testing, you need to add @Suite(.finalizeSnapshots)"
+ )
+ }
+ self.expected = expected
+ self.message = message
+ self.closureDescriptor = closureDescriptor
+ }
+
+ func sourceURL(
+ for filePath: StaticString,
+ using tester: SnapshotTester>
+ ) throws -> URL {
+ try InlineSnapshotManager.current.registerTestSource(.init(path: filePath))
+
+ return URL(
+ fileURLWithPath: String(describing: filePath),
+ isDirectory: false
+ )
+ }
+
+ func temporaryURL(
+ for filePath: StaticString,
+ using tester: SnapshotTester>
+ ) throws -> URL? {
+ nil
+ }
+
+ func contentExists(
+ at url: URL
+ ) -> Bool {
+ expected != nil || InlineSnapshotManager.current.recordExists(at: url)
+ }
+
+ func loadSnapshot(
+ from url: URL,
+ using tester: SnapshotTester>
+ ) throws -> Executor.Output {
+ if InlineSnapshotManager.current.recordExists(at: url) {
+ let snapshot = try InlineSnapshotManager.current.record(at: url)
+ return try tester.serialization.deserialize(Executor.Output.self, from: snapshot.diffable)
+ } else if let expected {
+ return Executor.Output(rawValue: expected())
+ } else {
+ throw URLError(.fileDoesNotExist)
+ }
+ }
+
+ func perform(
+ _ operation: SnapshotPerformOperation,
+ contents: Data,
+ to url: URL,
+ using tester: SnapshotTester>
+ ) throws {
+ InlineSnapshotManager.current.write(
+ InlineSnapshot(
+ reference: (try? InlineSnapshotManager.current.record(at: url))?.diffable,
+ diffable: contents,
+ wasRecording: operation == .write,
+ closureDescriptor: closureDescriptor,
+ function: String(describing: tester.function),
+ line: tester.line,
+ column: tester.column
+ ),
+ to: url
+ )
+ }
+
+ func generateFailureMessage(
+ for context: SnapshotFailContext,
+ using tester: SnapshotTester>
+ ) -> String {
+ switch context.reason {
+ case .missing:
+ return missing(context)
+ case .doesNotMatch:
+ return doesNotMatch(context)
+ case .allRecordMode:
+ return allRecordMode(context)
+ case .timeout:
+ return timeout(context, timeout: tester.timeout)
+ }
+ }
+}
+
+extension SnapshotInlineEngine {
+
+ fileprivate func missing(_ context: SnapshotFailContext) -> String {
+ let name = String(describing: context.function)
+
+ guard context.didWriteNewSnapshot else {
+ return
+ "No reference was found on disk. New snapshot was not recorded because recording is disabled"
+ }
+
+ let failure: String
+ if closureDescriptor.trailingClosureLabel
+ == SnapshotClosureDescriptor.defaultTrailingClosureLabel
+ {
+ failure = "Automatically recorded a new snapshot."
+ } else {
+ failure = """
+ Automatically recorded a new snapshot for "\(closureDescriptor.trailingClosureLabel)".
+ """
+ }
+
+ return """
+ No reference was found on disk. \(failure):
+
+ Re-run "\(name)" to assert against the newly-recorded snapshot.
+ """
+ }
+
+ fileprivate func doesNotMatch(_ context: SnapshotFailContext) -> String {
+ var message: String = message()
+
+ if message.isEmpty {
+ message += "Snapshot does not match reference. Difference: …"
+ }
+
+ if let additionalInformation = context.additionalInformation {
+ message += "\n\n" + additionalInformation.indenting(by: 2)
+ }
+
+ if context.didWriteNewSnapshot {
+ message += "\n\nA new snapshot was automatically recorded."
+ }
+
+ return message
+ }
+
+ fileprivate func allRecordMode(_ context: SnapshotFailContext) -> String {
+ let name = String(describing: context.function)
+
+ let failure: String
+
+ if closureDescriptor.trailingClosureLabel
+ == SnapshotClosureDescriptor.defaultTrailingClosureLabel
+ {
+ failure = "Automatically recorded a new snapshot."
+ } else {
+ failure = """
+ Automatically recorded a new snapshot for "\(closureDescriptor.trailingClosureLabel)".
+ """
+ }
+
+ return """
+ \(failure)
+
+ Turn record mode off and re-run "\(name)" to assert against the newly-recorded snapshot
+ """
+ }
+
+ fileprivate func timeout(_ context: SnapshotFailContext, timeout: TimeInterval) -> String {
+ """
+ Exceeded timeout of \(timeout) seconds waiting for snapshot.
+
+ This can happen when an asynchronously loaded value (like a network response) has not \
+ loaded. If a timeout is unavoidable, consider setting the "timeout" parameter of
+ "assertInline" to a higher value.
+ """
+ }
+}
+#endif
diff --git a/Sources/InlineSnapshotTesting/SnapshotInlineObservation.swift b/Sources/InlineSnapshotTesting/SnapshotInlineObservation.swift
new file mode 100644
index 000000000..bc3509884
--- /dev/null
+++ b/Sources/InlineSnapshotTesting/SnapshotInlineObservation.swift
@@ -0,0 +1,49 @@
+import Foundation
+@_spi(Internals) import XCSnapshotTesting
+
+final class SnapshotInlineObservation: @unchecked Sendable {
+
+ static let shared = SnapshotInlineObservation()
+
+ private let lock = NSLock()
+
+ private var _isObserving = false
+ private var _xcObserver: NSObjectProtocol?
+ private var _swiftTestingObserver: NSObjectProtocol?
+
+ func registerIfNeeded() {
+ lock.withLock {
+ guard !_isObserving else {
+ return
+ }
+
+ _isObserving = true
+
+ _xcObserver = NotificationCenter.default.addObserver(
+ forName: XCTestBundleDidFinishNotification,
+ object: nil,
+ queue: .current,
+ using: { [weak self] in self?.xcTestDidFinish($0) }
+ )
+
+ _swiftTestingObserver = NotificationCenter.default.addObserver(
+ forName: SwiftTestingDidFinishNotification,
+ object: nil,
+ queue: .current,
+ using: { [weak self] in self?.swiftTestDidFinish($0) }
+ )
+ }
+ }
+
+ private func xcTestDidFinish(_ notification: Notification) {
+ InlineSnapshotManager.current.writeInlineSnapshots()
+ }
+
+ private func swiftTestDidFinish(_ notification: Notification) {
+ guard let testName = notification.userInfo?[kTestFileName] as? String else {
+ return
+ }
+
+ InlineSnapshotManager.current.writeInlineSnapshots(for: testName)
+ }
+}
diff --git a/Sources/InlineSnapshotTesting/SnapshotURL.swift b/Sources/InlineSnapshotTesting/SnapshotURL.swift
new file mode 100644
index 000000000..9355d055b
--- /dev/null
+++ b/Sources/InlineSnapshotTesting/SnapshotURL.swift
@@ -0,0 +1,16 @@
+struct SnapshotURL: Sendable, Hashable {
+
+ let path: StaticString
+
+ init(path: StaticString) {
+ self.path = path
+ }
+
+ static func == (lhs: Self, rhs: Self) -> Bool {
+ String(describing: lhs.path) == String(describing: rhs.path)
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(String(describing: path))
+ }
+}
diff --git a/Sources/InlineSnapshotTesting/SwiftSyntax/InlineSnapshot.swift b/Sources/InlineSnapshotTesting/SwiftSyntax/InlineSnapshot.swift
new file mode 100644
index 000000000..f6b70f868
--- /dev/null
+++ b/Sources/InlineSnapshotTesting/SwiftSyntax/InlineSnapshot.swift
@@ -0,0 +1,33 @@
+#if canImport(SwiftSyntax601)
+import SwiftSyntax
+import Foundation
+
+public struct InlineSnapshot: Sendable, Hashable {
+
+ public let reference: Data?
+ public let diffable: Data
+ public let wasRecording: Bool
+ public let closureDescriptor: SnapshotClosureDescriptor
+ public let function: String
+ public let line: UInt
+ public let column: UInt
+
+ public init(
+ reference: Data?,
+ diffable: Data,
+ wasRecording: Bool,
+ closureDescriptor: SnapshotClosureDescriptor,
+ function: String,
+ line: UInt,
+ column: UInt
+ ) {
+ self.reference = reference
+ self.diffable = diffable
+ self.wasRecording = wasRecording
+ self.closureDescriptor = closureDescriptor
+ self.function = function
+ self.line = line
+ self.column = column
+ }
+}
+#endif
diff --git a/Sources/InlineSnapshotTesting/SwiftSyntax/InlineSnapshotManager.swift b/Sources/InlineSnapshotTesting/SwiftSyntax/InlineSnapshotManager.swift
new file mode 100644
index 000000000..7cbedcad9
--- /dev/null
+++ b/Sources/InlineSnapshotTesting/SwiftSyntax/InlineSnapshotManager.swift
@@ -0,0 +1,203 @@
+#if canImport(SwiftSyntax601)
+import Foundation
+import SwiftSyntax
+import SwiftParser
+
+final class InlineSnapshotManager: @unchecked Sendable {
+
+ static var current: InlineSnapshotManager {
+ local ?? shared
+ }
+
+ @TaskLocal
+ fileprivate static var local: InlineSnapshotManager?
+
+ private static let shared = InlineSnapshotManager()
+
+ private let lock = NSLock()
+
+ private var _snapshots: [URL: InlineSnapshot] = [:]
+ private var _testSourceCache: [SnapshotURL: TestSource] = [:]
+
+ fileprivate init() {}
+
+ subscript(_ url: SnapshotURL) -> TestSource? {
+ lock.withLock {
+ _testSourceCache[url]
+ }
+ }
+
+ func registerTestSource(_ url: SnapshotURL) throws {
+ try lock.withLock {
+ if _testSourceCache[url] != nil {
+ return
+ }
+
+ let path = String(describing: url.path)
+
+ let source = try String(contentsOfFile: path)
+
+ let sourceFile = Parser.parse(source: source)
+
+ let sourceLocationConverter = SourceLocationConverter(
+ fileName: path,
+ tree: sourceFile
+ )
+
+ let testSource = TestSource(
+ source: source,
+ sourceFile: sourceFile,
+ sourceLocationConverter: sourceLocationConverter
+ )
+
+ _testSourceCache[url] = testSource
+ }
+ }
+
+ func write(_ snapshot: InlineSnapshot, to url: URL) {
+ lock.withLock {
+ _snapshots[url] = snapshot
+ }
+ }
+
+ func record(at url: URL) throws -> InlineSnapshot {
+ try lock.withLock {
+ guard let snapshot = _snapshots[url] else {
+ throw URLError(.fileDoesNotExist)
+ }
+
+ return snapshot
+ }
+ }
+
+ func recordExists(
+ at url: URL
+ ) -> Bool {
+ lock.withLock {
+ _snapshots[url] != nil
+ }
+ }
+
+ func writeInlineSnapshots() {
+ lock.withLock {
+ while let (url, testSource) = _testSourceCache.popFirst() {
+ _writeInlineSnapshots(
+ _records(for: url.path),
+ at: url,
+ testSource: testSource
+ )
+ }
+ }
+ }
+
+ func writeInlineSnapshots(for testName: String) {
+ lock.withLock {
+ for (snapshotURL, testSource) in _testSourceCache {
+ let url = URL(
+ fileURLWithPath: String(describing: snapshotURL.path)
+ )
+
+ guard url.lastPathComponent == testName else {
+ continue
+ }
+
+ _testSourceCache[snapshotURL] = nil
+
+ return _writeInlineSnapshots(
+ _records(for: snapshotURL.path),
+ at: snapshotURL,
+ testSource: testSource
+ )
+ }
+ }
+ }
+
+ func records(for filePath: StaticString) -> [InlineSnapshot] {
+ lock.withLock {
+ _records(for: filePath)
+ }
+ }
+
+ // MARK: - Unsafe methods
+
+ private func _records(for filePath: StaticString) -> [InlineSnapshot] {
+ let url = URL(fileURLWithPath: String(describing: filePath))
+
+ var records = [InlineSnapshot]()
+ for (snapshotURL, snapshot) in _snapshots
+ where snapshotURL.absoluteString.starts(with: url.absoluteString) {
+ records.append(snapshot)
+ }
+ return records
+ }
+
+ private func _writeInlineSnapshots(
+ _ snapshots: [InlineSnapshot],
+ at url: SnapshotURL,
+ testSource: TestSource
+ ) {
+ for snapshot in snapshots {
+ let line = snapshot.line
+
+ let snapshotRewriter = SnapshotRewriter(
+ file: url,
+ snapshots: snapshots.sorted {
+ $0.line != $1.line
+ ? $0.line < $1.line
+ : $0.closureDescriptor.trailingClosureOffset
+ < $1.closureDescriptor.trailingClosureOffset
+ },
+ sourceLocationConverter: testSource.sourceLocationConverter
+ )
+
+ let updatedSource = snapshotRewriter.visit(testSource.sourceFile).description
+
+ if testSource.source != updatedSource {
+ do {
+ try updatedSource.write(
+ toFile: String(describing: url.path),
+ atomically: true,
+ encoding: .utf8
+ )
+ } catch {
+ fatalError("Threw error: \(error)", file: url.path, line: line)
+ }
+ }
+ }
+ }
+
+ deinit {
+ writeInlineSnapshots()
+ }
+}
+
+@_spi(Internals)
+public func withInlineSnapshotManager(
+ _ operation: () async throws -> R,
+ isolation: isolated Actor? = #isolation,
+ file: String = #file,
+ line: UInt = #line
+) async rethrows -> R {
+ try await InlineSnapshotManager.$local.withValue(
+ InlineSnapshotManager(),
+ operation: operation,
+ isolation: isolation,
+ file: file,
+ line: line
+ )
+}
+
+@_spi(Internals)
+public func withInlineSnapshotManager(
+ _ operation: () throws -> R,
+ file: String = #file,
+ line: UInt = #line
+) rethrows -> R {
+ try InlineSnapshotManager.$local.withValue(
+ InlineSnapshotManager(),
+ operation: operation,
+ file: file,
+ line: line
+ )
+}
+#endif
diff --git a/Sources/InlineSnapshotTesting/SwiftSyntax/SnapshotClosureDescriptor.swift b/Sources/InlineSnapshotTesting/SwiftSyntax/SnapshotClosureDescriptor.swift
new file mode 100644
index 000000000..a05091d33
--- /dev/null
+++ b/Sources/InlineSnapshotTesting/SwiftSyntax/SnapshotClosureDescriptor.swift
@@ -0,0 +1,140 @@
+@_spi(Internals) import XCSnapshotTesting
+
+/// A structure that describes the location of an inline snapshot.
+///
+/// Provide this structure when defining custom snapshot functions that call
+/// ``InlineSnapshotTesting/assertInline(of:as:message:record:timeout:name:serialization:closureDescriptor:matches:fileID:file:function:line:column:)``
+/// under the hood.
+/// A descriptor for the trailing closure used to supply an inline snapshot in a custom snapshot assertion.
+///
+/// Use this type when implementing custom snapshot assertion utilities that ultimately delegate to
+/// ``InlineSnapshotTesting/assertInline(of:as:message:record:timeout:name:serialization:closureDescriptor:matches:fileID:file:function:line:column:)``.
+/// It describes the structure of the inline snapshot trailing closure, supporting both current and deprecated closure labels,
+/// as well as more advanced cases where multiple trailing closures may exist.
+///
+/// This type enables tools and assertion infrastructure to accurately associate test failures or snapshot updates
+/// with the correct closure in the source code, even in the presence of multiple or labeled trailing closures.
+///
+/// For example:
+/// - A function with a single trailing closure for the snapshot uses the default offset (0) and label ("matches").
+/// - A function with an additional preceding trailing closure requires a higher offset and possibly a custom label.
+///
+/// Deprecated closure labels are supported for migration scenarios, allowing detection and management of legacy call sites.
+///
+/// The type is `Sendable` and `Hashable`, making it suitable for concurrent and collection-based use.
+public struct SnapshotClosureDescriptor: Sendable, Hashable {
+
+ /// The default label describing an inline snapshot.
+ public static let defaultTrailingClosureLabel = "matches"
+
+ /// A list of trailing closure labels from deprecated interfaces.
+ ///
+ /// Useful for providing migration paths for custom snapshot functions.
+ public var deprecatedTrailingClosureLabels: [String]
+
+ /// The label of the trailing closure that returns the inline snapshot.
+ public var trailingClosureLabel: String
+
+ /// The offset of the trailing closure that returns the inline snapshot, relative to the first
+ /// trailing closure.
+ ///
+ /// For example, a helper function with a few parameters and a single trailing closure has a
+ /// trailing closure offset of 0:
+ ///
+ /// ```swift
+ /// customInlineSnapshot(of: value, "Should match") {
+ /// // Inline snapshot...
+ /// }
+ /// ```
+ ///
+ /// While a helper function with a trailing closure preceding the snapshot closure has an offset
+ /// of 1:
+ ///
+ /// ```swift
+ /// customInlineSnapshot("Should match") {
+ /// // Some other parameter...
+ /// } matches: {
+ /// // Inline snapshot...
+ /// }
+ /// ```
+ public var trailingClosureOffset: Int
+
+ /// Initializes an inline snapshot syntax descriptor.
+ ///
+ /// - Parameters:
+ /// - deprecatedTrailingClosureLabels: An array of deprecated labels to consider for the inline
+ /// snapshot.
+ /// - trailingClosureLabel: The label of the trailing closure that returns the inline snapshot.
+ /// - trailingClosureOffset: The offset of the trailing closure that returns the inline
+ /// snapshot, relative to the first trailing closure.
+ public init(
+ deprecatedTrailingClosureLabels: [String] = [],
+ trailingClosureLabel: String = Self.defaultTrailingClosureLabel,
+ trailingClosureOffset: Int = 0
+ ) {
+ self.deprecatedTrailingClosureLabels = deprecatedTrailingClosureLabels
+ self.trailingClosureLabel = trailingClosureLabel
+ self.trailingClosureOffset = trailingClosureOffset
+ }
+
+ #if canImport(SwiftSyntax601)
+ /// Generates a test failure immediately and unconditionally at the described trailing closure.
+ ///
+ /// This method will attempt to locate the line of the trailing closure described by this type
+ /// and call `XCTFail` with it. If the trailing closure cannot be located, the failure will be
+ /// associated with the given line, instead.
+ ///
+ /// - Parameters:
+ /// - message: An optional description of the assertion, for inclusion in test results.
+ /// - fileID: The file ID in which failure occurred. Defaults to the file ID of the test case
+ /// in which this function was called.
+ /// - filePath: The file in which failure occurred. Defaults to the file path of the test case in
+ /// which this function was called.
+ /// - line: The line number on which failure occurred. Defaults to the line number on which
+ /// this function was called.
+ /// - column: The column on which failure occurred. Defaults to the column on which this
+ /// function was called.
+ public func fail(
+ _ message: @autoclosure () -> String,
+ fileID: StaticString,
+ file filePath: StaticString,
+ line: UInt,
+ column: UInt
+ ) throws {
+ var trailingClosureLine: Int?
+ if let testSource = InlineSnapshotManager.current[SnapshotURL(path: filePath)] {
+ let visitor = SnapshotVisitor(
+ functionCallLine: Int(line),
+ functionCallColumn: Int(column),
+ sourceLocationConverter: testSource.sourceLocationConverter,
+ closureDescriptor: self
+ )
+ visitor.walk(testSource.sourceFile)
+ trailingClosureLine = visitor.trailingClosureLine
+ }
+
+ try TestingSystem.shared.record(
+ message: message(),
+ fileID: fileID,
+ filePath: filePath,
+ line: trailingClosureLine.map(UInt.init) ?? line,
+ column: column
+ )
+ }
+
+ func contains(_ label: String) -> Bool {
+ self.trailingClosureLabel == label || self.deprecatedTrailingClosureLabels.contains(label)
+ }
+ #else
+ @available(*, unavailable, message: "'assertInline' requires 'swift-syntax' >= 509.0.0")
+ public func fail(
+ _ message: @autoclosure () -> String = "",
+ fileID: StaticString,
+ file filePath: StaticString,
+ line: UInt,
+ column: UInt
+ ) {
+ fatalError()
+ }
+ #endif
+}
diff --git a/Sources/InlineSnapshotTesting/SwiftSyntax/SnapshotRewriter.swift b/Sources/InlineSnapshotTesting/SwiftSyntax/SnapshotRewriter.swift
new file mode 100644
index 000000000..3342f516c
--- /dev/null
+++ b/Sources/InlineSnapshotTesting/SwiftSyntax/SnapshotRewriter.swift
@@ -0,0 +1,238 @@
+#if canImport(SwiftSyntax601)
+import SwiftSyntax
+
+final class SnapshotRewriter: SyntaxRewriter {
+
+ let file: SnapshotURL
+ var function: String?
+ let indent: String
+ let line: UInt?
+ var newRecordings: [(snapshot: InlineSnapshot, line: UInt)] = []
+ var snapshots: [InlineSnapshot]
+ let sourceLocationConverter: SourceLocationConverter
+ let wasRecording: Bool
+
+ init(
+ file: SnapshotURL,
+ snapshots: [InlineSnapshot],
+ sourceLocationConverter: SourceLocationConverter
+ ) {
+ self.file = file
+ self.line = snapshots.first?.line
+ self.wasRecording = snapshots.contains(where: \.wasRecording)
+ self.indent = String(
+ sourceLocationConverter.sourceLines
+ .first { $0.first?.isWhitespace == true && $0.contains { !$0.isWhitespace } }?
+ .prefix { $0.isWhitespace }
+ ?? " "
+ )
+ self.snapshots = snapshots
+ self.sourceLocationConverter = sourceLocationConverter
+ }
+
+ override func visit(_ functionCallExpr: FunctionCallExprSyntax) -> ExprSyntax {
+ let location = functionCallExpr.calledExpression
+ .endLocation(converter: self.sourceLocationConverter, afterTrailingTrivia: true)
+ let snapshots = self.snapshots.prefix { snapshot in
+ Int(snapshot.line) == location.line && Int(snapshot.column) == location.column
+ }
+
+ guard !snapshots.isEmpty
+ else { return super.visit(functionCallExpr) }
+
+ defer { self.snapshots.removeFirst(snapshots.count) }
+
+ var functionCallExpr = functionCallExpr
+ for snapshot in snapshots {
+ guard snapshot.reference != snapshot.diffable, snapshot.wasRecording else { continue }
+
+ let diffable = String(data: snapshot.diffable, encoding: .utf8)
+
+ self.function =
+ self.function
+ ?? functionCallExpr.calledExpression.as(DeclReferenceExprSyntax.self)?.baseName.text
+
+ let leadingTrivia = String(
+ self.sourceLocationConverter.sourceLines[Int(snapshot.line) - 1]
+ .prefix(while: { $0 == " " || $0 == "\t" })
+ )
+ let delimiter = String(
+ repeating: "#",
+ count: (diffable ?? "").hashCount(isMultiline: true)
+ )
+ let leadingIndent = leadingTrivia + self.indent
+ let snapshotLabel = TokenSyntax(
+ stringLiteral: snapshot.closureDescriptor.trailingClosureLabel
+ )
+ let snapshotClosure = diffable.map { actual in
+ ClosureExprSyntax(
+ leftBrace: .leftBraceToken(trailingTrivia: .newline),
+ statements: CodeBlockItemListSyntax {
+ StringLiteralExprSyntax(
+ leadingTrivia: Trivia(stringLiteral: leadingIndent),
+ openingPounds: .rawStringPoundDelimiter(delimiter),
+ openingQuote: .multilineStringQuoteToken(trailingTrivia: .newline),
+ segments: [
+ .stringSegment(
+ StringSegmentSyntax(
+ content: .stringSegment(
+ actual
+ .replacingOccurrences(of: "\r", with: #"\\#(delimiter)r"#)
+ .indenting(with: leadingIndent)
+ )
+ )
+ )
+ ],
+ closingQuote: .multilineStringQuoteToken(
+ leadingTrivia: .newline + Trivia(stringLiteral: leadingIndent)
+ ),
+ closingPounds: .rawStringPoundDelimiter(delimiter)
+ )
+ },
+ rightBrace: .rightBraceToken(
+ leadingTrivia: .newline + Trivia(stringLiteral: leadingTrivia)
+ )
+ )
+ }
+
+ let arguments = functionCallExpr.arguments
+ let firstTrailingClosureOffset =
+ arguments
+ .enumerated()
+ .reversed()
+ .prefix(while: { $0.element.expression.is(ClosureExprSyntax.self) })
+ .last?
+ .offset ?? arguments.count
+
+ let trailingClosureOffset =
+ firstTrailingClosureOffset
+ + snapshot.closureDescriptor.trailingClosureOffset
+
+ let centeredTrailingClosureOffset = trailingClosureOffset - arguments.count
+
+ switch centeredTrailingClosureOffset {
+ case ..<0:
+ let index = arguments.index(arguments.startIndex, offsetBy: trailingClosureOffset)
+ if let snapshotClosure {
+ functionCallExpr.arguments[index].label = snapshotLabel
+ functionCallExpr.arguments[index].expression = ExprSyntax(snapshotClosure)
+ } else {
+ functionCallExpr.arguments.remove(at: index)
+ }
+
+ case 0:
+ functionCallExpr.rightParen?.trailingTrivia = .space
+ let trailingClosureTrivia = functionCallExpr.trailingClosure?.trailingTrivia
+ if let snapshotClosure {
+ // FIXME: ?? multipleTrailingClosures.removeFirst()
+ functionCallExpr.trailingClosure =
+ if let trailingClosureTrivia, trailingClosureTrivia.count > 0 {
+ snapshotClosure.with(
+ \.trailingTrivia,
+ snapshotClosure.trailingTrivia + trailingClosureTrivia
+ )
+ } else {
+ snapshotClosure
+ }
+ } else if !functionCallExpr.additionalTrailingClosures.isEmpty {
+ let additionalTrailingClosure = functionCallExpr.additionalTrailingClosures.remove(
+ at: functionCallExpr.additionalTrailingClosures.startIndex
+ )
+ functionCallExpr.trailingClosure =
+ if let trailingClosureTrivia, trailingClosureTrivia.count > 0 {
+ additionalTrailingClosure.closure.with(
+ \.trailingTrivia,
+ additionalTrailingClosure.closure.trailingTrivia + trailingClosureTrivia
+ )
+ } else {
+ additionalTrailingClosure.closure
+ }
+ } else {
+ functionCallExpr.rightParen?.trailingTrivia = ""
+ functionCallExpr.trailingClosure = nil
+ }
+
+ case 1...:
+ var newElement: MultipleTrailingClosureElementSyntax? {
+ snapshotClosure.map { snapshotClosure in
+ MultipleTrailingClosureElementSyntax(
+ label: snapshotLabel,
+ closure: snapshotClosure.with(
+ \.leadingTrivia,
+ snapshotClosure.leadingTrivia + .space
+ )
+ )
+ }
+ }
+
+ if !functionCallExpr.additionalTrailingClosures.isEmpty,
+ let endIndex = functionCallExpr.additionalTrailingClosures.index(
+ functionCallExpr.additionalTrailingClosures.endIndex,
+ offsetBy: -1,
+ limitedBy: functionCallExpr.additionalTrailingClosures.startIndex
+ ),
+ let index = functionCallExpr.additionalTrailingClosures.index(
+ functionCallExpr.additionalTrailingClosures.startIndex,
+ offsetBy: centeredTrailingClosureOffset - 1,
+ limitedBy: endIndex
+ )
+ {
+ if snapshot.closureDescriptor.contains(
+ functionCallExpr.additionalTrailingClosures[index].label.text
+ ) {
+ if let snapshotClosure {
+ functionCallExpr.additionalTrailingClosures[index].label = snapshotLabel
+ let trailingTrivia = functionCallExpr.additionalTrailingClosures[index].closure
+ .trailingTrivia
+ functionCallExpr.additionalTrailingClosures[index].closure =
+ if trailingTrivia.count > 0 {
+ snapshotClosure.with(
+ \.trailingTrivia,
+ snapshotClosure.trailingTrivia + trailingTrivia
+ )
+ } else {
+ snapshotClosure
+ }
+ } else {
+ functionCallExpr.additionalTrailingClosures.remove(at: index)
+ }
+ } else if let newElement {
+ functionCallExpr.additionalTrailingClosures.insert(
+ newElement.with(\.trailingTrivia, .space),
+ at: index
+ )
+ }
+ } else if centeredTrailingClosureOffset >= 1, let newElement {
+ if let index = functionCallExpr.additionalTrailingClosures.index(
+ functionCallExpr.additionalTrailingClosures.endIndex,
+ offsetBy: -1,
+ limitedBy: functionCallExpr.additionalTrailingClosures.startIndex
+ ) {
+ functionCallExpr.additionalTrailingClosures[index].trailingTrivia = .space
+ } else {
+ functionCallExpr.trailingClosure?.trailingTrivia = .space
+ }
+ functionCallExpr.additionalTrailingClosures.append(newElement)
+ } else {
+ fatalError()
+ }
+
+ default:
+ fatalError()
+ }
+ }
+
+ if functionCallExpr.arguments.isEmpty,
+ functionCallExpr.trailingClosure != nil,
+ functionCallExpr.leftParen != nil,
+ functionCallExpr.rightParen != nil
+ {
+ functionCallExpr.leftParen = nil
+ functionCallExpr.rightParen = nil
+ functionCallExpr.calledExpression.trailingTrivia = .space
+ }
+
+ return ExprSyntax(functionCallExpr)
+ }
+}
+#endif
diff --git a/Sources/InlineSnapshotTesting/SwiftSyntax/SnapshotVisitor.swift b/Sources/InlineSnapshotTesting/SwiftSyntax/SnapshotVisitor.swift
new file mode 100644
index 000000000..d90eac737
--- /dev/null
+++ b/Sources/InlineSnapshotTesting/SwiftSyntax/SnapshotVisitor.swift
@@ -0,0 +1,81 @@
+#if canImport(SwiftSyntax601)
+import SwiftSyntax
+
+final class SnapshotVisitor: SyntaxVisitor {
+
+ let functionCallColumn: Int
+ let functionCallLine: Int
+ let sourceLocationConverter: SourceLocationConverter
+ let closureDescriptor: SnapshotClosureDescriptor
+ var trailingClosureLine: Int?
+
+ init(
+ functionCallLine: Int,
+ functionCallColumn: Int,
+ sourceLocationConverter: SourceLocationConverter,
+ closureDescriptor: SnapshotClosureDescriptor
+ ) {
+ self.functionCallColumn = functionCallColumn
+ self.functionCallLine = functionCallLine
+ self.sourceLocationConverter = sourceLocationConverter
+ self.closureDescriptor = closureDescriptor
+ super.init(viewMode: .all)
+ }
+
+ override func visit(_ functionCallExpr: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind {
+ let location = functionCallExpr.calledExpression.endLocation(
+ converter: self.sourceLocationConverter,
+ afterTrailingTrivia: true
+ )
+
+ guard
+ self.functionCallLine == location.line,
+ self.functionCallColumn == location.column
+ else { return .visitChildren }
+
+ let arguments = functionCallExpr.arguments
+ let firstTrailingClosureOffset =
+ arguments
+ .enumerated()
+ .reversed()
+ .prefix(while: { $0.element.expression.is(ClosureExprSyntax.self) })
+ .last?
+ .offset ?? arguments.count
+
+ let trailingClosureOffset =
+ firstTrailingClosureOffset
+ + self.closureDescriptor.trailingClosureOffset
+
+ let centeredTrailingClosureOffset = trailingClosureOffset - arguments.count
+
+ switch centeredTrailingClosureOffset {
+ case ..<0:
+ let index = arguments.index(arguments.startIndex, offsetBy: trailingClosureOffset)
+ self.trailingClosureLine =
+ arguments[index]
+ .startLocation(converter: self.sourceLocationConverter)
+ .line
+
+ case 0:
+ self.trailingClosureLine = functionCallExpr.trailingClosure.map {
+ $0.startLocation(converter: self.sourceLocationConverter).line
+ }
+
+ case 1...:
+ let index = functionCallExpr.additionalTrailingClosures.index(
+ functionCallExpr.additionalTrailingClosures.startIndex,
+ offsetBy: centeredTrailingClosureOffset - 1
+ )
+ if centeredTrailingClosureOffset - 1 < functionCallExpr.additionalTrailingClosures.count {
+ self.trailingClosureLine =
+ functionCallExpr.additionalTrailingClosures[index]
+ .startLocation(converter: self.sourceLocationConverter)
+ .line
+ }
+ default:
+ break
+ }
+ return .skipChildren
+ }
+}
+#endif
diff --git a/Sources/InlineSnapshotTesting/SwiftSyntax/TestSource.swift b/Sources/InlineSnapshotTesting/SwiftSyntax/TestSource.swift
new file mode 100644
index 000000000..3227f6d0d
--- /dev/null
+++ b/Sources/InlineSnapshotTesting/SwiftSyntax/TestSource.swift
@@ -0,0 +1,9 @@
+#if canImport(SwiftSyntax601)
+import SwiftSyntax
+
+struct TestSource {
+ let source: String
+ let sourceFile: SourceFileSyntax
+ let sourceLocationConverter: SourceLocationConverter
+}
+#endif
diff --git a/Sources/SnapshotTesting/Exports.swift b/Sources/SnapshotTesting/Exports.swift
new file mode 100644
index 000000000..f90e2c7bc
--- /dev/null
+++ b/Sources/SnapshotTesting/Exports.swift
@@ -0,0 +1 @@
+@_exported import XCSnapshotTesting
diff --git a/Sources/SnapshotTesting/TestCompletionNotifier.swift b/Sources/SnapshotTesting/TestCompletionNotifier.swift
new file mode 100644
index 000000000..d842df976
--- /dev/null
+++ b/Sources/SnapshotTesting/TestCompletionNotifier.swift
@@ -0,0 +1,100 @@
+import Foundation
+import Testing
+@_spi(Internals) import XCSnapshotTesting
+
+public struct FinalizeSnapshotsSuiteTrait: SuiteTrait {
+
+ public let isRecursive = false
+
+ public func scopeProvider(
+ for test: Test,
+ testCase: Test.Case?
+ ) -> TestScopeProvider? {
+ TestScopeProvider()
+ }
+}
+
+extension Trait where Self == FinalizeSnapshotsSuiteTrait {
+
+ /// A suite trait that finalizes all snapshot tests after the suite completes execution.
+ ///
+ /// Use this trait to automatically trigger snapshot finalization logic—
+ /// such as emitting notifications or performing cleanup—after all tests
+ /// in a suite have finished running. This is useful for ensuring that any
+ /// resources or state associated with snapshot testing are properly handled
+ /// after tests conclude.
+ ///
+ /// Apply this trait to your test suite using the `@Suite(.finalizeSnapshots)`
+ /// macro.
+ ///
+ /// Example:
+ /// ```swift
+ /// @Suite(.finalizeSnapshots)
+ /// struct MySnapshotTests { ... }
+ /// ```
+ ///
+ /// - Note: This trait is non-recursive and will only apply to the suite where it is specified.
+ public static var finalizeSnapshots: Self {
+ .init()
+ }
+}
+
+extension FinalizeSnapshotsSuiteTrait {
+
+ public struct TestScopeProvider: TestScoping {
+
+ public func provideScope(
+ for test: Test,
+ testCase: Test.Case?,
+ performing function: @Sendable () async throws -> Void
+ ) async throws {
+ try await withTestCompletionTracking(
+ for: test,
+ operation: function
+ )
+ }
+ }
+}
+
+private func withTestCompletionTracking(
+ for test: Test,
+ operation: () async throws -> R,
+ isolation: isolated Actor? = #isolation,
+ file: String = #file,
+ line: UInt = #line
+) async throws -> R {
+ precondition(test.isSuite)
+
+ if TestCompletionNotifier.current != nil {
+ return try await operation()
+ }
+
+ return try await TestCompletionNotifier.$current.withValue(
+ TestCompletionNotifier(test.sourceLocation),
+ operation: operation,
+ isolation: isolation,
+ file: file,
+ line: line
+ )
+}
+
+final class TestCompletionNotifier: Sendable {
+
+ @TaskLocal static var current: TestCompletionNotifier?
+
+ private let sourceLocation: SourceLocation
+
+ fileprivate init(_ sourceLocation: SourceLocation) {
+ self.sourceLocation = sourceLocation
+ }
+
+ deinit {
+ NotificationCenter.default.post(
+ name: SwiftTestingDidFinishNotification,
+ object: nil,
+ userInfo: [
+ kTestFileName: sourceLocation.fileName
+ ]
+ )
+ }
+}
diff --git a/Sources/SnapshotTesting/TestingSystem+SwiftTestingSystem.swift b/Sources/SnapshotTesting/TestingSystem+SwiftTestingSystem.swift
new file mode 100644
index 000000000..6504a780d
--- /dev/null
+++ b/Sources/SnapshotTesting/TestingSystem+SwiftTestingSystem.swift
@@ -0,0 +1,112 @@
+import Foundation
+import Testing
+@_spi(Internals) import XCSnapshotTesting
+
+extension TestingSystem: SwiftTestingSystem {
+
+ public var environment: TestingSystemEnvironment? {
+ Test.current?.traits.mapIntoTestingEnvironment()
+ }
+
+ public var isRunning: Bool {
+ Test.current != nil
+ }
+
+ public var isTestCompletionAttached: Bool {
+ isRunning && TestCompletionNotifier.current != nil
+ }
+
+ public func add(
+ _ name: String,
+ attachments: [SnapshotAttachment],
+ fileID: StaticString,
+ filePath: StaticString,
+ line: UInt,
+ column: UInt
+ ) {
+ #if swift(>=6.2)
+ for attachment in attachments {
+ guard let payload = attachment.payload else {
+ continue
+ }
+
+ let attachmentName: String
+ if let name = attachment.name, !name.isEmpty {
+ attachmentName = name
+ } else {
+ attachmentName = attachment.uniformTypeIdentifier
+ }
+
+ Attachment.record(
+ payload,
+ named: attachmentName,
+ sourceLocation: SourceLocation(
+ fileID: String(describing: fileID),
+ filePath: String(describing: filePath),
+ line: Int(line),
+ column: Int(column)
+ )
+ )
+ }
+ #endif
+ }
+
+ public func record(
+ message: String,
+ fileID: StaticString,
+ filePath: StaticString,
+ line: UInt,
+ column: UInt
+ ) {
+ Issue.record(
+ Comment(rawValue: message),
+ sourceLocation: SourceLocation(
+ fileID: String(describing: fileID),
+ filePath: String(describing: filePath),
+ line: Int(line),
+ column: Int(column)
+ )
+ )
+ }
+}
+
+extension Array where Element == any Trait {
+
+ func mapIntoTestingEnvironment() -> TestingSystemEnvironment {
+ var environment = TestingSystemEnvironment()
+
+ for trait in reversed() {
+ switch trait {
+ case let recordTrait as RecordTrait:
+ if environment.recordMode == nil {
+ environment.recordMode = recordTrait.recordMode
+ }
+ case let diffToolTrait as DiffToolTrait:
+ if environment.diffTool == nil {
+ environment.diffTool = diffToolTrait.diffTool
+ }
+ case let maxConcurrentTestsTrait as MaxConcurrentTestsTrait:
+ if environment.maxConcurrentTests == nil {
+ environment.maxConcurrentTests = maxConcurrentTestsTrait.maxConcurrentTests
+ }
+ case let platformTrait as PlatformTrait:
+ if environment.platform == nil {
+ environment.platform = platformTrait.platform
+ }
+ default:
+ continue
+ }
+
+ guard
+ environment.diffTool != nil,
+ environment.recordMode != nil,
+ environment.maxConcurrentTests != nil,
+ environment.platform != nil
+ else { continue }
+
+ break
+ }
+
+ return environment
+ }
+}
diff --git a/Sources/SnapshotTesting/Traits/DiffToolTrait.swift b/Sources/SnapshotTesting/Traits/DiffToolTrait.swift
new file mode 100644
index 000000000..baa21fe35
--- /dev/null
+++ b/Sources/SnapshotTesting/Traits/DiffToolTrait.swift
@@ -0,0 +1,29 @@
+import Foundation
+import Testing
+@_spi(Internals) import XCSnapshotTesting
+
+public struct DiffToolTrait: SuiteTrait, TestTrait {
+ public let isRecursive = true
+ let diffTool: DiffTool
+}
+
+extension Trait where Self == DiffToolTrait {
+
+ /// Returns a trait that specifies a custom diff tool for snapshot comparisons.
+ ///
+ /// Use this method to override the default diff tool used by snapshot testing frameworks
+ /// when comparing reference images or files. This is useful for customizing how differences are presented,
+ /// such as by launching a specific visual diff application or using a particular command-line utility.
+ ///
+ /// - Parameter diffTool: The `DiffTool` instance to use for diffing snapshots within the scope of this trait.
+ /// - Returns: A `DiffToolTrait` that can be applied at the suite or test level.
+ ///
+ /// Example usage:
+ /// ```swift
+ /// @Suite(.diffTool(.ksdiff))
+ /// struct MySnapshotTests { ... }
+ /// ```
+ public static func diffTool(_ diffTool: DiffTool) -> Self {
+ .init(diffTool: diffTool)
+ }
+}
diff --git a/Sources/SnapshotTesting/Traits/MaxConcurrentTestsTrait.swift b/Sources/SnapshotTesting/Traits/MaxConcurrentTestsTrait.swift
new file mode 100644
index 000000000..0a6151939
--- /dev/null
+++ b/Sources/SnapshotTesting/Traits/MaxConcurrentTestsTrait.swift
@@ -0,0 +1,28 @@
+import Foundation
+import Testing
+@_spi(Internals) import XCSnapshotTesting
+
+public struct MaxConcurrentTestsTrait: SuiteTrait, TestTrait {
+ public let isRecursive = true
+ let maxConcurrentTests: Int
+}
+
+extension Trait where Self == MaxConcurrentTestsTrait {
+
+ /// Limits the maximum number of tests that can execute concurrently within a suite or for an individual test.
+ ///
+ /// Use this trait to control the level of parallelism during test execution, which is helpful for tests that are not thread-safe
+ /// or when you want to limit resource consumption. When applied to a suite, the limit is enforced recursively for all contained tests and nested suites.
+ ///
+ /// - Parameter maxConcurrentTests: The maximum number of tests allowed to execute simultaneously. Must be greater than zero.
+ /// - Returns: A trait that constrains the test or suite's concurrency.
+ ///
+ /// Example:
+ /// ```swift
+ /// @Suite(.maxConcurrentTests(2))
+ /// struct MySuite { ... }
+ /// ```
+ public static func maxConcurrentTests(_ maxConcurrentTests: Int) -> Self {
+ .init(maxConcurrentTests: maxConcurrentTests)
+ }
+}
diff --git a/Sources/SnapshotTesting/Traits/PlatformTrait.swift b/Sources/SnapshotTesting/Traits/PlatformTrait.swift
new file mode 100644
index 000000000..3c114ff51
--- /dev/null
+++ b/Sources/SnapshotTesting/Traits/PlatformTrait.swift
@@ -0,0 +1,30 @@
+import Foundation
+import Testing
+@_spi(Internals) import XCSnapshotTesting
+
+public struct PlatformTrait: SuiteTrait, TestTrait {
+ public let isRecursive = true
+ let platform: String
+}
+
+extension Trait where Self == PlatformTrait {
+
+ /// Adds a platform trait to a test or suite, allowing it to be conditionally
+ /// included or configured based on the specified platform identifier.
+ ///
+ /// You can use this trait to annotate tests for filtering or to provide specialized
+ /// behaviors on certain platforms (such as "iOS", "macOS", "watchOS", or "visionOS").
+ ///
+ /// Example usage:
+ ///
+ /// @Suite(.platform("iOS"))
+ ///
+ /// - Parameter platform: An optional string specifying the platform identifier.
+ ///
+ /// - Returns: A `PlatformTrait` configured with the given platform.
+ ///
+ /// - Note: Setting this value to `nil` or an empty string (e.g., `.platform(nil)` or `.platform("")`) makes all snapshots for this suite or test shared between platforms, removing any platform-specific distinction.
+ public static func platform(_ platform: String?) -> Self {
+ .init(platform: platform ?? "")
+ }
+}
diff --git a/Sources/SnapshotTesting/Traits/RecordTrait.swift b/Sources/SnapshotTesting/Traits/RecordTrait.swift
new file mode 100644
index 000000000..355ff9c8d
--- /dev/null
+++ b/Sources/SnapshotTesting/Traits/RecordTrait.swift
@@ -0,0 +1,40 @@
+import Foundation
+import Testing
+@_spi(Internals) import XCSnapshotTesting
+
+public struct RecordTrait: SuiteTrait, TestTrait {
+ public let isRecursive = true
+ let recordMode: RecordMode
+}
+
+extension Trait where Self == RecordTrait {
+
+ /// Adds record mode configuration to a suite or test.
+ ///
+ /// Use this trait to specify the snapshot recording mode for the test or suite.
+ /// This is typically used in snapshot testing to control whether expected
+ /// snapshots are created or updated.
+ ///
+ /// The default value is `.missing`, which means a snapshot will only be recorded if no file is found (i.e., a snapshot is missing).
+ ///
+ /// ## Examples
+ ///
+ /// ```swift
+ /// @Test(.record(.all))
+ /// func testRecordAllSnapshots() async throws {
+ /// // Test code here
+ /// }
+ ///
+ /// @Suite(.record(.never))
+ /// struct NoSnapshotRecordingTests {
+ /// // Suite contents here
+ /// }
+ /// ```
+ ///
+ /// - Parameter recordMode: The `RecordMode` value to use when running tests.
+ /// This controls whether snapshots should be recorded (created/updated) or not.
+ /// - Returns: A `RecordTrait` configured with the given `recordMode`.
+ public static func record(_ recordMode: RecordMode) -> Self {
+ .init(recordMode: recordMode)
+ }
+}
diff --git a/Sources/SnapshotTestingCustomDump/CustomDump.swift b/Sources/SnapshotTestingCustomDump/CustomDump.swift
index afdeabcf0..179e34bef 100644
--- a/Sources/SnapshotTestingCustomDump/CustomDump.swift
+++ b/Sources/SnapshotTestingCustomDump/CustomDump.swift
@@ -1,24 +1,31 @@
import CustomDump
-import SnapshotTesting
+import XCSnapshotTesting
-extension Snapshotting where Format == String {
- /// A snapshot strategy for comparing any structure based on a
- /// [custom dump](https://github.com/pointfreeco/swift-custom-dump).
- ///
- /// ```swift
- /// assertSnapshot(of: user, as: .customDump)
- /// ```
- ///
- /// Records:
- ///
- /// ```
- /// User(
- /// bio: "Blobbed around the world.",
- /// id: 1,
- /// name: "Blobby"
- /// )
- /// ```
- public static var customDump: Snapshotting {
- SimplySnapshotting.lines.pullback(String.init(customDumping:))
- }
+#if !os(visionOS)
+@_exported import _SnapshotTestingCustomDump
+#endif
+
+extension SyncSnapshot where Output == StringBytes {
+
+ /// A snapshot strategy for comparing any structure based on a
+ /// [custom dump](https://github.com/pointfreeco/swift-custom-dump).
+ ///
+ /// ```swift
+ /// try assert(of: user, as: .customDump)
+ /// ```
+ ///
+ /// Records:
+ ///
+ /// ```
+ /// User(
+ /// bio: "Blobbed around the world.",
+ /// id: 1,
+ /// name: "Blobby"
+ /// )
+ /// ```
+ public static var customDump: SyncSnapshot {
+ IdentitySyncSnapshot.lines.pullback {
+ String(customDumping: $0)
+ }
+ }
}
diff --git a/Sources/XCSnapshotTesting/Assert/Assert.swift b/Sources/XCSnapshotTesting/Assert/Assert.swift
new file mode 100644
index 000000000..281b9e5be
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Assert/Assert.swift
@@ -0,0 +1,560 @@
+import Foundation
+
+// MARK: - Assert snapshot
+
+/// Validates a single snapshot of an asynchronously computed input value using a specified snapshot strategy.
+///
+/// This function serializes and compares the provided input value against a previously recorded snapshot,
+/// using the supplied `AsyncSnapshot` strategy and serialization configuration. Designed for use in
+/// snapshot and regression testing, it ensures that the current representation of the input matches the stored
+/// reference, or updates the reference if recording is enabled.
+///
+/// - Parameters:
+/// - input: An autoclosure that asynchronously produces the input value to be tested. The closure is executed during the assertion.
+/// - snapshot: An `AsyncSnapshot` describing how the input should be converted to bytes and compared (e.g., image rendering, text output).
+/// - serialization: Controls output transformation details, such as image encoding or precision. Defaults to `.init()`.
+/// - name: An optional identifier for the snapshot. Useful to differentiate multiple assertions in a single test method.
+/// - recording: Optionally override the snapshot recording mode for this assertion (e.g., `.always` to force update, `.never` to only compare).
+/// - snapshotDirectory: Optionally specify a custom directory for stored snapshots, overriding the default.
+/// - timeout: Time in seconds before the assertion fails for taking too long. Defaults to zero (no timeout).
+/// - isolation: Optionally specify an actor for input evaluation, supporting thread/actor isolation. Defaults to current context.
+/// - fileID: The unique identifier for the file in which the assertion appears. Supplied automatically by the compiler.
+/// - filePath: The file path where the assertion is called. Supplied automatically by the compiler.
+/// - testName: The name of the test function calling the assertion. Supplied automatically by the compiler.
+/// - line: The source line number where the assertion is called. Supplied automatically by the compiler.
+/// - column: The source column where the assertion is called. Supplied automatically by the compiler.
+///
+/// - Throws: An error if input evaluation, snapshotting, or comparison fails, or if recording fails in recording mode.
+///
+/// - Important: For multiple assertions within the same test (e.g., in a loop), supply unique `name` values to
+/// avoid snapshot counting issues. Automatic snapshot naming relies on the call site position.
+///
+/// - Note: The recording mode falls back to session-level or environment settings if not provided.
+/// Use testing traits, or ``withTestingEnvironment(record:diffTool:maxConcurrentTests:platform:operation:file:line:)``
+/// to control globally.
+///
+/// - Example:
+/// ```swift
+/// try await assert(
+/// of: view,
+/// as: .image(layout: .device(.iPhone15ProMax)),
+/// named: "light_mode"
+/// )
+/// ```
+public func assert(
+ of input: @Sendable @autoclosure () async throws -> Input,
+ as snapshot: AsyncSnapshot,
+ serialization: DataSerialization = DataSerialization(),
+ named name: String? = nil,
+ record recording: RecordMode? = nil,
+ snapshotDirectory: String? = nil,
+ timeout: TimeInterval = .zero,
+ isolation: isolated Actor? = #isolation,
+ fileID: StaticString = #fileID,
+ file filePath: StaticString = #filePath,
+ testName: StaticString = #function,
+ line: UInt = #line,
+ column: UInt = #column
+) async throws {
+ let failure = try await verify(
+ of: await input(),
+ as: snapshot,
+ serialization: serialization,
+ named: name,
+ record: recording,
+ snapshotDirectory: snapshotDirectory,
+ timeout: timeout,
+ isolation: isolation,
+ fileID: fileID,
+ file: filePath,
+ testName: testName,
+ line: line,
+ column: column
+ )
+
+ guard let message = failure else { return }
+
+ try TestingSystem.shared.record(
+ message: message,
+ fileID: fileID,
+ filePath: filePath,
+ line: line,
+ column: column
+ )
+}
+
+/// Asserts that an asynchronously produced value matches multiple named snapshots, each using a distinct strategy.
+///
+/// For each entry in the provided dictionary, this function generates an input value asynchronously and compares it
+/// against a previously recorded reference snapshot using the corresponding `AsyncSnapshot` strategy. Each assertion
+/// uses the key as a unique snapshot name. If in recording mode, the reference is updated instead. Failures are
+/// reported using the testing system.
+///
+/// - Parameters:
+/// - input: An autoclosure that asynchronously produces the value to be snapshotted and compared. Evaluated once per strategy.
+/// - strategies: A dictionary mapping unique snapshot names to `AsyncSnapshot` strategies, allowing different configurations or formats per assertion.
+/// - serialization: Settings for how output is serialized (e.g., image encoding, text precision). Defaults to `.init()`.
+/// - recording: Optionally override the recording mode for all snapshots in this assertion (e.g., `.always`, `.never`).
+/// - snapshotDirectory: Optionally specify a custom directory for storing or comparing snapshots, overriding the default.
+/// - timeout: Maximum seconds to wait for input evaluation per assertion. Defaults to `.zero`.
+/// - isolation: Optionally specify an actor context for input evaluation, supporting actor/thread isolation. Defaults to current context.
+/// - fileID: The unique identifier of the source file. Provided automatically by the compiler.
+/// - filePath: The path to the source file. Provided automatically.
+/// - testName: The name of the test function. Provided automatically.
+/// - line: The line number of the assertion in the source file. Provided automatically.
+/// - column: The column number of the assertion in the source file. Provided automatically.
+///
+/// - Throws: An error if input evaluation, snapshotting, or comparison fails, or if writing fails in recording mode.
+///
+/// - Important: Each snapshot uses the dictionary key as its unique name. If you need to assert multiple snapshots within a loop,
+/// provide unique keys to prevent snapshot overwrites or counting issues.
+///
+/// - Example:
+/// ```swift
+/// try await assert(
+/// of: view,
+/// as: [
+/// "light": .image(layout: .device(.iPhone15ProMax)),
+/// "dark": .image(layout: .device(.iPhone15ProMaxDark))
+/// ]
+/// )
+/// ```
+public func assert(
+ of input: @Sendable @autoclosure () async throws -> Input,
+ as strategies: [String: AsyncSnapshot],
+ serialization: DataSerialization = DataSerialization(),
+ record recording: RecordMode? = nil,
+ snapshotDirectory: String? = nil,
+ timeout: TimeInterval = .zero,
+ isolation: isolated Actor? = #isolation,
+ fileID: StaticString = #fileID,
+ file filePath: StaticString = #filePath,
+ testName: StaticString = #function,
+ line: UInt = #line,
+ column: UInt = #column
+) async throws {
+ for (name, configuration) in strategies {
+ try await assert(
+ of: await input(),
+ as: configuration,
+ serialization: serialization,
+ named: name,
+ record: recording,
+ snapshotDirectory: snapshotDirectory,
+ timeout: timeout,
+ isolation: isolation,
+ fileID: fileID,
+ file: filePath,
+ testName: testName,
+ line: line,
+ column: column
+ )
+ }
+}
+
+/// Asserts that an asynchronously produced value matches multiple snapshots, each using a different strategy from the provided array.
+///
+/// For each strategy in the array, this function generates an input value asynchronously and compares it against a previously recorded
+/// reference snapshot using the corresponding `AsyncSnapshot` strategy. Each assertion is uniquely named using a combination of the
+/// test function and the zero-based index (e.g., `testName().1@1`, `testName().1@2`, ...), ensuring distinct snapshot identities per call site.
+///
+/// - Parameters:
+/// - input: An autoclosure that asynchronously produces the value to be snapshotted and compared. Evaluated once per strategy.
+/// - strategies: An array of `AsyncSnapshot` strategies to apply to the input. Each entry results in a separate snapshot assertion.
+/// - serialization: Settings for how output is serialized (e.g., image encoding, text precision). Defaults to `.init()`.
+/// - recording: Optionally override the recording mode for all snapshots in this assertion (e.g., `.always`, `.never`).
+/// - snapshotDirectory: Optionally specify a custom directory for storing or comparing snapshots, overriding the default.
+/// - timeout: Maximum seconds to wait for input evaluation per assertion. Defaults to `.zero`.
+/// - isolation: Optionally specify an actor context for input evaluation, supporting actor/thread isolation. Defaults to current context.
+/// - fileID: The unique identifier of the source file. Provided automatically by the compiler.
+/// - filePath: The path to the source file. Provided automatically.
+/// - testName: The name of the test function. Provided automatically.
+/// - line: The line number of the assertion in the source file. Provided automatically.
+/// - column: The column number of the assertion in the source file. Provided automatically.
+///
+/// - Throws: An error if input evaluation, snapshotting, or comparison fails, or if writing fails in recording mode.
+///
+/// - Important: Each snapshot uses a unique name based on the test function and its index (e.g., `testName().1@1`). This allows safe use in loops or repeated calls without naming collisions.
+/// If you require meaningful snapshot names, prefer the dictionary overload.
+///
+/// - Example:
+/// ```swift
+/// let strategies = [
+/// .image(layout: .device(.iPhone15ProMax)),
+/// .image(precision: 0.95)
+/// ]
+/// try await assert(
+/// of: view,
+/// as: strategies
+/// )
+/// ```
+public func assert(
+ of input: @Sendable @autoclosure () async throws -> Input,
+ as strategies: [AsyncSnapshot],
+ serialization: DataSerialization = DataSerialization(),
+ record recording: RecordMode? = nil,
+ snapshotDirectory: String? = nil,
+ timeout: TimeInterval = .zero,
+ isolation: isolated Actor? = #isolation,
+ fileID: StaticString = #fileID,
+ file filePath: StaticString = #filePath,
+ testName: StaticString = #function,
+ line: UInt = #line,
+ column: UInt = #column
+) async throws {
+ let uniqueID = TestingSession.shared.forLoop(
+ fileID: fileID,
+ filePath: filePath,
+ function: String(describing: testName),
+ line: line,
+ column: column
+ )
+
+ for (index, strategy) in strategies.enumerated() {
+ try await assert(
+ of: await input(),
+ as: strategy,
+ serialization: serialization,
+ named: "\(uniqueID)@\(index + 1)",
+ record: recording,
+ snapshotDirectory: snapshotDirectory,
+ timeout: timeout,
+ isolation: isolation,
+ fileID: fileID,
+ file: filePath,
+ testName: testName,
+ line: line,
+ column: column
+ )
+ }
+}
+
+// MARK: - Sync snapshot
+
+/// Asserts that a synchronously produced value matches a previously recorded snapshot using the specified strategy.
+///
+/// This function serializes and compares the given input value against a stored reference using the provided `SyncSnapshot` strategy
+/// and serialization configuration. It is designed for snapshot or regression testing, ensuring the current value matches the reference,
+/// or updates the reference if recording is enabled. Failures are reported using the testing system.
+///
+/// - Parameters:
+/// - input: An autoclosure producing the value to be tested. Evaluated during the assertion.
+/// - snapshot: The `SyncSnapshot` strategy describing how to serialize and compare the input (e.g., as an image, as text).
+/// - serialization: Settings for output transformation, such as image encoding or precision. Defaults to `.init()`.
+/// - name: An optional identifier for the snapshot. Useful for disambiguating multiple assertions in a single test method.
+/// - recording: Optionally override the recording mode for this assertion (e.g., `.always` to update, `.never` to only compare).
+/// - snapshotDirectory: Optionally specify a custom directory for stored snapshots, overriding the default.
+/// - timeout: Time in seconds before the assertion fails if too long. Defaults to 5 seconds.
+/// - fileID: The unique identifier of the source file. Provided automatically by the compiler.
+/// - filePath: The path to the source file. Provided automatically.
+/// - testName: The name of the test function. Provided automatically.
+/// - line: The line number of the assertion call. Provided automatically.
+/// - column: The column number of the assertion call. Provided automatically.
+///
+/// - Throws: An error if input evaluation, snapshotting, or comparison fails, or if recording fails when enabled.
+///
+/// - Important: For multiple assertions within the same test (such as in a loop), supply unique `name` values to avoid snapshot overwrites or collisions. Automatic naming uses the call site location.
+///
+/// - Note: If `recording` is not provided, global or session-level settings apply. Use test traits or `withTestingEnvironment(record:operation:)` to control recording globally.
+///
+/// - Example:
+/// ```swift
+/// try assert(
+/// of: renderedView,
+/// as: .image(layout: .device(.iPadPro)),
+/// named: "dark_mode"
+/// )
+/// ```
+public func assert(
+ of input: @autoclosure () throws -> Input,
+ as snapshot: SyncSnapshot,
+ serialization: DataSerialization = DataSerialization(),
+ named name: String? = nil,
+ record recording: RecordMode? = nil,
+ snapshotDirectory: String? = nil,
+ timeout: TimeInterval = 5,
+ fileID: StaticString = #fileID,
+ file filePath: StaticString = #filePath,
+ testName: StaticString = #function,
+ line: UInt = #line,
+ column: UInt = #column
+) throws {
+ let failure = try verify(
+ of: input(),
+ as: snapshot,
+ serialization: serialization,
+ named: name,
+ record: recording,
+ snapshotDirectory: snapshotDirectory,
+ timeout: timeout,
+ fileID: fileID,
+ file: filePath,
+ testName: testName,
+ line: line,
+ column: column
+ )
+
+ guard let message = failure else { return }
+
+ try TestingSystem.shared.record(
+ message: message,
+ fileID: fileID,
+ filePath: filePath,
+ line: line,
+ column: column
+ )
+}
+
+/// Asserts that a synchronously produced value matches multiple named snapshots, each using a distinct strategy.
+///
+/// For each entry in the provided dictionary, this function compares the input value against a previously recorded reference snapshot
+/// using the corresponding `SyncSnapshot` strategy. Each assertion uses the dictionary key as a unique snapshot name. If in recording mode,
+/// the reference is updated instead. Failures are reported using the testing system.
+///
+/// - Parameters:
+/// - input: The value to be snapshotted and compared. Used for all strategies.
+/// - strategies: A dictionary mapping unique snapshot names to `SyncSnapshot` strategies, allowing different configurations or formats per assertion.
+/// - serialization: Settings for how output is serialized (e.g., image encoding, text precision). Defaults to `.init()`.
+/// - recording: Optionally override the recording mode for all snapshots in this assertion (e.g., `.always`, `.never`).
+/// - snapshotDirectory: Optionally specify a custom directory for storing or comparing snapshots, overriding the default.
+/// - timeout: Maximum seconds to wait for each assertion. Defaults to 5.
+/// - fileID: The unique identifier of the source file. Provided automatically by the compiler.
+/// - filePath: The path to the source file. Provided automatically.
+/// - testName: The name of the test function. Provided automatically.
+/// - line: The line number of the assertion in the source file. Provided automatically.
+/// - column: The column number of the assertion in the source file. Provided automatically.
+///
+/// - Throws: An error if snapshotting or comparison fails, or if writing fails in recording mode.
+///
+/// - Important: Each snapshot uses the dictionary key as its unique name. If you need to assert multiple snapshots within a loop, provide unique keys to prevent snapshot overwrites or counting issues.
+///
+/// - Example:
+/// ```swift
+/// try assert(
+/// of: renderedView,
+/// as: [
+/// "light": .image(layout: .device(.iPadPro)),
+/// "dark": .image(layout: .device(.iPadProDark))
+/// ]
+/// )
+/// ```
+public func assert(
+ of input: Input,
+ as strategies: [String: SyncSnapshot],
+ serialization: DataSerialization = DataSerialization(),
+ record recording: RecordMode? = nil,
+ snapshotDirectory: String? = nil,
+ timeout: TimeInterval = 5,
+ fileID: StaticString = #fileID,
+ file filePath: StaticString = #filePath,
+ testName: StaticString = #function,
+ line: UInt = #line,
+ column: UInt = #column
+) throws {
+ for (name, configuration) in strategies {
+ try? assert(
+ of: input,
+ as: configuration,
+ serialization: serialization,
+ named: name,
+ record: recording,
+ snapshotDirectory: snapshotDirectory,
+ timeout: timeout,
+ fileID: fileID,
+ file: filePath,
+ testName: testName,
+ line: line,
+ column: column
+ )
+ }
+}
+
+/// Asserts that a synchronously produced value matches multiple snapshots, each using a different strategy from the provided array.
+///
+/// For each strategy in the array, this function compares the input value against a previously recorded reference snapshot using the
+/// corresponding `SyncSnapshot` strategy. Each assertion is uniquely named using a combination of the test function and the index
+/// (e.g., `testName().1@1`, `testName().1@2`, ...), ensuring distinct snapshot identities per call site.
+///
+/// - Parameters:
+/// - input: The value to be snapshotted and compared. Used for all strategies.
+/// - strategies: An array of `SyncSnapshot` strategies to apply to the input. Each entry results in a separate snapshot assertion.
+/// - serialization: Settings for how output is serialized (e.g., image encoding, text precision). Defaults to `.init()`.
+/// - recording: Optionally override the recording mode for all snapshots in this assertion (e.g., `.always`, `.never`).
+/// - snapshotDirectory: Optionally specify a custom directory for storing or comparing snapshots, overriding the default.
+/// - timeout: Maximum seconds to wait for each assertion. Defaults to 5.
+/// - fileID: The unique identifier of the source file. Provided automatically by the compiler.
+/// - filePath: The path to the source file. Provided automatically.
+/// - testName: The name of the test function. Provided automatically.
+/// - line: The line number of the assertion in the source file. Provided automatically.
+/// - column: The column number of the assertion in the source file. Provided automatically.
+///
+/// - Throws: An error if snapshotting or comparison fails, or if writing fails in recording mode.
+///
+/// - Important: Each snapshot uses a unique name based on the test function and its index (e.g., `testName().1@1`). This allows safe use in loops or repeated calls without naming collisions. If you require meaningful snapshot names, prefer the dictionary overload.
+///
+/// - Example:
+/// ```swift
+/// let strategies = [
+/// .image(layout: .device(.iPadPro)),
+/// .image(precision: 0.95)
+/// ]
+/// try await assert(
+/// of: renderedView,
+/// as: strategies
+/// )
+/// ```
+public func assert(
+ of input: Input,
+ as strategies: [SyncSnapshot],
+ serialization: DataSerialization = DataSerialization(),
+ record recording: RecordMode? = nil,
+ snapshotDirectory: String? = nil,
+ timeout: TimeInterval = 5,
+ fileID: StaticString = #fileID,
+ file filePath: StaticString = #filePath,
+ testName: StaticString = #function,
+ line: UInt = #line,
+ column: UInt = #column
+) async throws {
+ for strategy in strategies {
+ try? assert(
+ of: input,
+ as: strategy,
+ serialization: serialization,
+ record: recording,
+ snapshotDirectory: snapshotDirectory,
+ timeout: timeout,
+ fileID: fileID,
+ file: filePath,
+ testName: testName,
+ line: line,
+ column: column
+ )
+ }
+}
+
+/// Validates an asynchronously produced value against a stored snapshot using a specified strategy and serialization configuration.
+///
+/// This function evaluates the provided input value asynchronously, serializes it using the supplied `AsyncSnapshot` strategy and `DataSerialization`,
+/// and compares it to a previously recorded snapshot on disk. If the assertion fails, it returns a descriptive failure message;
+/// otherwise, it returns `nil`. Supports optional snapshot recording, custom directories, timeouts, and actor isolation.
+///
+/// - Parameters:
+/// - input: An autoclosure that asynchronously produces the value to snapshot and compare. Executed at assertion time.
+/// - snapshot: An `AsyncSnapshot` describing how to serialize and compare the input (e.g., image rendering, text output).
+/// - serialization: Settings for output transformation and encoding. Defaults to `.init()`.
+/// - name: An optional identifier for the snapshot, used to disambiguate multiple assertions within a test.
+/// - recording: Optionally override the recording mode for this assertion (e.g., `.always` to update, `.never` to only compare).
+/// - snapshotDirectory: Optionally specify a directory for storing or comparing snapshots, overriding the default.
+/// - timeout: Maximum seconds to wait for input evaluation or snapshotting. Defaults to `.zero`.
+/// - isolation: Optionally specify an actor for input evaluation, supporting thread/actor isolation. Defaults to current context.
+/// - fileID: The unique identifier of the source file. Provided automatically by the compiler.
+/// - filePath: The path to the source file. Provided automatically.
+/// - testName: The name of the test function. Provided automatically.
+/// - line: The line number of the assertion in the source file. Provided automatically.
+/// - column: The column number of the assertion in the source file. Provided automatically.
+///
+/// - Returns: An optional failure message string if the assertion fails; otherwise, `nil`.
+///
+/// - Throws: An error if input evaluation, snapshotting, comparison, or recording fails.
+///
+/// - Important: For multiple assertions within the same test (e.g., in a loop), use unique `name` values.
+/// - Note: Recording mode falls back to environment or session-level settings if not specified.
+public func verify(
+ of input: @Sendable @autoclosure () async throws -> Input,
+ as snapshot: AsyncSnapshot,
+ serialization: DataSerialization = DataSerialization(),
+ named name: String? = nil,
+ record recording: RecordMode? = nil,
+ snapshotDirectory: String? = nil,
+ timeout: TimeInterval = .zero,
+ isolation: isolated Actor? = #isolation,
+ fileID: StaticString = #fileID,
+ file filePath: StaticString = #filePath,
+ testName: StaticString = #function,
+ line: UInt = #line,
+ column: UInt = #column
+) async throws -> String? {
+ let engine = SnapshotFileEngine>(
+ sourceURL: snapshotDirectory.map {
+ URL(fileURLWithPath: $0, isDirectory: true)
+ }
+ )
+
+ let tester = SnapshotTester(
+ engine: engine,
+ record: recording,
+ timeout: timeout,
+ name: name,
+ serialization: serialization,
+ fileID: fileID,
+ filePath: filePath,
+ function: testName,
+ line: line,
+ column: column
+ )
+
+ return try await tester(input(), for: snapshot)?.message
+}
+
+/// Validates a synchronously produced value against a stored snapshot using a specified strategy and serialization configuration.
+///
+/// This function evaluates the provided input value, serializes it using the supplied `SyncSnapshot` strategy and `DataSerialization`,
+/// and compares it to a previously recorded snapshot on disk. If the assertion fails, it returns a descriptive failure message;
+/// otherwise, it returns `nil`. Supports optional snapshot recording, custom directories, and timeouts.
+///
+/// - Parameters:
+/// - input: An autoclosure that produces the value to snapshot and compare. Executed at assertion time.
+/// - snapshot: A `SyncSnapshot` describing how to serialize and compare the input (e.g., image rendering, text output).
+/// - serialization: Settings for output transformation and encoding. Defaults to `.init()`.
+/// - name: An optional identifier for the snapshot, used to disambiguate multiple assertions within a test.
+/// - recording: Optionally override the recording mode for this assertion (e.g., `.always` to update, `.never` to only compare).
+/// - snapshotDirectory: Optionally specify a directory for storing or comparing snapshots, overriding the default.
+/// - timeout: Maximum seconds to wait for input evaluation or snapshotting. Defaults to 5 seconds.
+/// - fileID: The unique identifier of the source file. Provided automatically by the compiler.
+/// - filePath: The path to the source file. Provided automatically.
+/// - testName: The name of the test function. Provided automatically.
+/// - line: The line number of the assertion in the source file. Provided automatically.
+/// - column: The column number of the assertion in the source file. Provided automatically.
+///
+/// - Returns: An optional failure message string if the assertion fails; otherwise, `nil`.
+///
+/// - Throws: An error if input evaluation, snapshotting, comparison, or recording fails.
+///
+/// - Important: For multiple assertions within the same test (e.g., in a loop), use unique `name` values.
+/// - Note: Recording mode falls back to environment or session-level settings if not specified.
+public func verify(
+ of input: @autoclosure () throws -> Input,
+ as snapshot: SyncSnapshot,
+ serialization: DataSerialization = DataSerialization(),
+ named name: String? = nil,
+ record recording: RecordMode? = nil,
+ snapshotDirectory: String? = nil,
+ timeout: TimeInterval = 5,
+ fileID: StaticString = #fileID,
+ file filePath: StaticString = #filePath,
+ testName: StaticString = #function,
+ line: UInt = #line,
+ column: UInt = #column
+) throws -> String? {
+ let engine = SnapshotFileEngine>(
+ sourceURL: snapshotDirectory.map {
+ URL(fileURLWithPath: $0, isDirectory: true)
+ }
+ )
+
+ let tester = SnapshotTester(
+ engine: engine,
+ record: recording,
+ timeout: timeout,
+ name: name,
+ serialization: serialization,
+ fileID: fileID,
+ filePath: filePath,
+ function: testName,
+ line: line,
+ column: column
+ )
+
+ return try tester(input(), for: snapshot)?.message
+}
diff --git a/Sources/XCSnapshotTesting/Assert/CleanCounterBetweenTestCases.swift b/Sources/XCSnapshotTesting/Assert/CleanCounterBetweenTestCases.swift
new file mode 100644
index 000000000..49250e1c8
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Assert/CleanCounterBetweenTestCases.swift
@@ -0,0 +1,44 @@
+#if canImport(XCTest)
+import XCTest
+
+final class CleanCounterBetweenTestCases: NSObject, XCTestObservation {
+
+ @MainActor
+ private static var registered = false
+
+ fileprivate static func registerIfNeeded() {
+ performOnMainThread {
+ guard !registered else { return }
+ registered = true
+ XCTestObservationCenter.shared.addTestObserver(CleanCounterBetweenTestCases())
+ }
+ }
+
+ func testBundleDidFinish(_ testBundle: Bundle) {
+ NotificationCenter.default.post(
+ name: XCTestBundleDidFinishNotification,
+ object: nil
+ )
+ }
+}
+
+extension XCTestCase {
+
+ static func registerObserverIfNeeded() {
+ CleanCounterBetweenTestCases.registerIfNeeded()
+ }
+}
+#endif
+
+@_spi(Internals)
+public let XCTestBundleDidFinishNotification = Notification.Name(
+ "XCTestBundleDidFinishNotification"
+)
+
+@_spi(Internals)
+public let SwiftTestingDidFinishNotification = Notification.Name(
+ "SwiftTestingDidFinishNotification"
+)
+
+@_spi(Internals)
+public let kTestFileName = "kTestFileName"
diff --git a/Sources/XCSnapshotTesting/Assert/RecordMode.swift b/Sources/XCSnapshotTesting/Assert/RecordMode.swift
new file mode 100644
index 000000000..7b179a9aa
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Assert/RecordMode.swift
@@ -0,0 +1,19 @@
+import Foundation
+
+/// Specifies the recording behavior for snapshots during testing.
+///
+/// Adjusts whether and how snapshot files are created or updated during test execution.
+public enum RecordMode: Int16, Sendable {
+
+ /// Never records new snapshots. Tests fail if results differ from existing ones; no files are updated.
+ case never
+
+ /// Records only if a snapshot is missing. Existing snapshots are not replaced, even if mismatches occur.
+ case missing
+
+ /// Records snapshots only when tests fail due to a mismatch. Useful for automatically updating failing snapshots.
+ case failed
+
+ /// Always records snapshots, overwriting any existing files. Use to intentionally update all snapshots after UI changes.
+ case all
+}
diff --git a/Sources/XCSnapshotTesting/Assert/SnapshotAttachment.swift b/Sources/XCSnapshotTesting/Assert/SnapshotAttachment.swift
new file mode 100644
index 000000000..af3ec1bcc
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Assert/SnapshotAttachment.swift
@@ -0,0 +1,112 @@
+import Foundation
+
+#if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS)
+import UIKit
+#elseif os(macOS)
+import AppKit
+#endif
+
+/// A structure representing a snapshot attachment, including its uniform type identifier, name, and payload data.
+///
+/// This type is designed to encapsulate information about a snapshot, including its type, optional name, and binary payload.
+/// It provides multiple initializers to create instances from different data types, such as `Data`, `String`, or platform-specific image types.
+public struct SnapshotAttachment: Sendable {
+
+ /// The uniform type identifier (UTI) for the snapshot attachment.
+ ///
+ /// This identifies the type of data contained in the payload. Defaults to `"public.data"` if not provided.
+ public let uniformTypeIdentifier: String
+
+ /// The optional name of the snapshot attachment.
+ ///
+ /// This can be used to provide a human-readable identifier for the attachment.
+ public var name: String?
+
+ /// The binary payload data of the snapshot.
+ ///
+ /// This represents the actual data stored in the attachment. May be `nil` if no data is provided.
+ public let payload: Data?
+
+ /// Initializes a `SnapshotAttachment` with a custom uniform type identifier, name, and payload.
+ ///
+ /// - Parameters:
+ /// - identifier: The uniform type identifier for the attachment. Defaults to `"public.data"` if `nil`.
+ /// - name: An optional name for the attachment.
+ /// - payload: The binary data payload. Defaults to `nil` if not provided.
+ public init(uniformTypeIdentifier identifier: String?, name: String?, payload: Data?) {
+ self.uniformTypeIdentifier = identifier ?? "public.data"
+ self.name = name
+ self.payload = payload
+ }
+
+ /// Initializes a `SnapshotAttachment` with raw data, using a default uniform type identifier.
+ ///
+ /// - Parameters:
+ /// - payload: The binary data to use as the payload.
+ ///
+ /// - SeeAlso: Uses `"public.data"` as the uniform type identifier.
+ public init(data payload: Data) {
+ self.init(
+ data: payload,
+ uniformTypeIdentifier: "public.data"
+ )
+ }
+
+ /// Initializes a `SnapshotAttachment` with raw data and a specified uniform type identifier.
+ ///
+ /// - Parameters:
+ /// - payload: The binary data to use as the payload.
+ /// - identifier: The uniform type identifier for the attachment.
+ public init(data payload: Data, uniformTypeIdentifier identifier: String) {
+ self.init(
+ uniformTypeIdentifier: identifier,
+ name: "",
+ payload: payload
+ )
+ }
+
+ /// Initializes a `SnapshotAttachment` from a string, converting it to UTF-8 data.
+ ///
+ /// - Parameter:
+ /// - string: The string to use as the payload. Uses `"public.plain-text"` as the uniform type identifier.
+ public init(string: String) {
+ self.init(
+ data: Data(string.utf8),
+ uniformTypeIdentifier: "public.plain-text"
+ )
+ }
+
+ #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS)
+ /// Initializes a `SnapshotAttachment` from a platform-specific image (iOS/tvOS/visionOS/watchOS).
+ ///
+ /// - Parameter:
+ /// - image: The `UIImage` to convert to PNG data. Returns `nil` if conversion fails.
+ public init?(image: UIImage) {
+ guard let payload = image.pngData() else {
+ return nil
+ }
+
+ self.init(
+ uniformTypeIdentifier: "public.png",
+ name: "",
+ payload: payload
+ )
+ }
+ #elseif os(macOS)
+ /// Initializes a `SnapshotAttachment` from a platform-specific image (macOS).
+ ///
+ /// - Parameter:
+ /// - image: The `NSImage` to convert to PNG data. Returns `nil` if conversion fails.
+ public init?(image: NSImage) {
+ guard let payload = image.pngData() else {
+ return nil
+ }
+
+ self.init(
+ uniformTypeIdentifier: "public.png",
+ name: "",
+ payload: payload
+ )
+ }
+ #endif
+}
diff --git a/Sources/XCSnapshotTesting/Assert/TestingSession.swift b/Sources/XCSnapshotTesting/Assert/TestingSession.swift
new file mode 100644
index 000000000..b5be22619
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Assert/TestingSession.swift
@@ -0,0 +1,124 @@
+import Foundation
+import Testing
+
+final class TestingSession: Sendable {
+
+ static let shared = TestingSession()
+
+ // MARK: - Private properties
+
+ private let testCounter = TestCounter()
+ private let forLoopCounter = TestCounter()
+
+ private init() {}
+
+ func functionPosition(
+ fileID: StaticString,
+ filePath: StaticString,
+ function: String,
+ line: UInt,
+ column: UInt
+ ) -> Int {
+ testCounter(
+ fileID: fileID,
+ filePath: filePath,
+ function: function,
+ line: line,
+ column: column
+ )
+ }
+
+ func forLoop(
+ fileID: StaticString,
+ filePath: StaticString,
+ function: String,
+ line: UInt,
+ column: UInt
+ ) -> Int {
+ forLoopCounter(
+ fileID: fileID,
+ filePath: filePath,
+ function: function,
+ line: line,
+ column: column
+ )
+ }
+}
+
+extension TestingSession {
+
+ fileprivate final class TestCounter: @unchecked Sendable {
+
+ // MARK: - Private properties
+
+ private let lock = NSLock()
+
+ // MARK: - Unsafe properties
+
+ private var _registry: [TestLocation: [TestPosition]] = [:]
+
+ init() {}
+
+ func callAsFunction(
+ fileID: StaticString,
+ filePath: StaticString,
+ function: String,
+ line: UInt,
+ column: UInt
+ ) -> Int {
+ let key = TestLocation(
+ fileID: fileID,
+ filePath: filePath,
+ function: function
+ )
+
+ let position = TestPosition(
+ line: line,
+ column: column
+ )
+
+ return lock.withLock {
+ var items = _registry[key, default: []]
+
+ if let index = items.firstIndex(of: position) {
+ return index + 1
+ }
+
+ items.append(position)
+ _registry[key] = items
+ return items.count
+ }
+ }
+ }
+}
+
+extension TestingSession.TestCounter {
+
+ fileprivate struct TestLocation: Hashable {
+
+ private let fileID: String
+ private let filePath: String
+ private let function: String
+
+ init(
+ fileID: StaticString,
+ filePath: StaticString,
+ function: String
+ ) {
+ self.fileID = String(describing: fileID)
+ self.filePath = String(describing: filePath)
+ self.function = function
+ }
+ }
+
+ fileprivate struct TestPosition: Hashable {
+
+ private let line: UInt
+ private let column: UInt
+
+ init(line: UInt, column: UInt) {
+ self.line = line
+ self.column = column
+ }
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Async.swift b/Sources/XCSnapshotTesting/Async.swift
new file mode 100644
index 000000000..9f7394bf9
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Async.swift
@@ -0,0 +1,127 @@
+import Foundation
+
+/// A wrapper for asynchronous operations that allows composing and transforming asynchronous workflows with input and output values.
+///
+/// `Async` provides a functional interface for executing asynchronous tasks, with support for mapping outputs, pulling back inputs, and inserting delays.
+///
+/// - Parameters:
+/// - Input: The type of input value the async operation accepts.
+/// - Output: The type of output value the async operation produces.
+public struct Async: SnapshotExecutor {
+
+ fileprivate let block: @Sendable (Input) async throws -> Output
+
+ /// Initializes an `Async` instance with a specific input type and block.
+ ///
+ /// - Parameters:
+ /// - inputType: The type of input value. This is inferred from the block if not explicitly provided.
+ /// - block: An asynchronous closure that takes an input value and returns an output value, potentially throwing an error.
+ public init(
+ _ inputType: Input.Type = Input.self,
+ _ block: @escaping @Sendable (Input) async throws -> Output
+ ) {
+ self.block = block
+ }
+
+ /// Executes the asynchronous operation with the provided input value.
+ ///
+ /// - Parameter input: The input value to pass to the asynchronous operation.
+ /// - Returns: The output value produced by the operation.
+ /// - Throws: Any error thrown by the asynchronous operation.
+ public func callAsFunction(_ input: Input) async throws -> Output {
+ try await block(input)
+ }
+}
+
+extension Async {
+
+ /// Transforms the output of the asynchronous operation using a new closure.
+ ///
+ /// - Parameter block: A closure that takes the original output and returns a new value of type `NewOutput`.
+ /// - Returns: A new `Async` instance that applies this transformation.
+ public func map(
+ _ block: @escaping @Sendable (Output) async throws -> NewOutput
+ ) -> Async {
+ .init { input in
+ let output = try await self(input)
+ return try await block(output)
+ }
+ }
+
+ /// Transforms the input of the asynchronous operation using a new closure.
+ ///
+ /// - Parameter block: A closure that takes a new input value and returns a value of type `Input` to pass to this operation.
+ /// - Returns: A new `Async` instance that applies this input transformation.
+ public func pullback(
+ _ block: @escaping @Sendable (NewInput) async throws -> Input
+ ) -> Async {
+ .init { newInput in
+ let input = try await block(newInput)
+ return try await self(input)
+ }
+ }
+}
+
+extension Async {
+
+ /// Adds a delay before completing the asynchronous operation.
+ ///
+ /// - Parameter duration: The duration of the delay in nanoseconds.
+ /// - Returns: A new `Async` instance with the delay applied.
+ public func sleep(nanoseconds duration: UInt64) -> Async {
+ guard duration > .zero else {
+ return self
+ }
+
+ return map {
+ try await Task.sleep(nanoseconds: duration)
+ return $0
+ }
+ }
+
+ /// Adds a delay until a specific deadline before completing the asynchronous operation.
+ ///
+ /// - Parameters:
+ /// - deadline: The instant at which the delay should end.
+ /// - tolerance: The allowed tolerance for the deadline.
+ /// - clock: The clock to use for measuring time.
+ /// - Returns: A new `Async` instance with the delay applied.
+ @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
+ public func sleep(
+ until deadline: C.Instant,
+ tolerance: C.Instant.Duration? = nil,
+ clock: C = .continuous
+ ) -> Async where C: Clock {
+ map {
+ try await Task.sleep(
+ until: deadline,
+ tolerance: tolerance,
+ clock: clock
+ )
+ return $0
+ }
+ }
+
+ /// Adds a delay for a specific duration before completing the asynchronous operation.
+ ///
+ /// - Parameters:
+ /// - duration: The duration of the delay.
+ /// - tolerance: The allowed tolerance for the duration.
+ /// - clock: The clock to use for measuring time.
+ /// - Returns: A new `Async` instance with the delay applied.
+ @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
+ public func sleep(
+ for duration: C.Instant.Duration,
+ tolerance: C.Instant.Duration? = nil,
+ clock: C = .continuous
+ ) -> Async where C: Clock {
+ map {
+ try await Task.sleep(
+ for: duration,
+ tolerance: tolerance,
+ clock: clock
+ )
+ return $0
+ }
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Async/AsyncLock.swift b/Sources/XCSnapshotTesting/Async/AsyncLock.swift
new file mode 100644
index 000000000..255570b7d
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Async/AsyncLock.swift
@@ -0,0 +1,62 @@
+import Foundation
+
+actor AsyncLock {
+
+ private var isLocked = false
+ private var pendingOperations = [AsyncOperation]()
+
+ init() {}
+
+ func withLock(_ block: @Sendable () async throws -> Value) async throws -> Value {
+ try await lock()
+ defer { unlock() }
+
+ return try await block()
+ }
+
+ func withLockVoid(_ block: @Sendable () async throws -> Void) async throws {
+ try await lock()
+ defer { unlock() }
+
+ try await block()
+ }
+
+ func lock() async throws {
+ guard isLocked else {
+ isLocked = true
+ return
+ }
+
+ let operation = AsyncOperation()
+
+ try await withTaskCancellationHandler { [weak operation] in
+ try await withUnsafeThrowingContinuation {
+ guard let operation else {
+ return
+ }
+
+ operation.schedule($0)
+ pendingOperations.insert(operation, at: .zero)
+ }
+ } onCancel: {
+ operation.cancelled()
+ }
+ }
+
+ func unlock() {
+ guard isLocked else {
+ return
+ }
+
+ let continuation = pendingOperations.popLast()
+ isLocked = !pendingOperations.isEmpty
+
+ continuation?.resume()
+ }
+
+ deinit {
+ for operation in pendingOperations {
+ operation.dispose()
+ }
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Async/AsyncOperation.swift b/Sources/XCSnapshotTesting/Async/AsyncOperation.swift
new file mode 100644
index 000000000..7abb40165
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Async/AsyncOperation.swift
@@ -0,0 +1,45 @@
+import Foundation
+
+final class AsyncOperation: @unchecked Sendable {
+
+ private enum State {
+ case idle
+ case scheduled(UnsafeContinuation)
+ case cancelled
+ }
+
+ private let lock = NSLock()
+ private var _state: State = .idle
+
+ init() {}
+
+ func schedule(_ continuation: UnsafeContinuation) {
+ lock.withLock {
+ if case .idle = _state {
+ _state = .scheduled(continuation)
+ }
+ }
+ }
+
+ func resume() {
+ lock.withLock {
+ if case .scheduled(let continuation) = _state {
+ continuation.resume()
+ }
+ }
+ }
+
+ func cancelled() {
+ lock.withLock {
+ _state = .cancelled
+ }
+ }
+
+ func dispose() {
+ lock.withLock {
+ if case .scheduled(let continuation) = _state {
+ continuation.resume(throwing: CancellationError())
+ }
+ }
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Async/AsyncSignal.swift b/Sources/XCSnapshotTesting/Async/AsyncSignal.swift
new file mode 100644
index 000000000..869e58996
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Async/AsyncSignal.swift
@@ -0,0 +1,50 @@
+import Foundation
+
+actor AsyncSignal {
+
+ private var isLocked: Bool
+ private var pendingOperations = [AsyncOperation]()
+
+ init(_ locked: Bool = true) {
+ isLocked = locked
+ }
+
+ func lock() {
+ isLocked = true
+ }
+
+ func wait() async throws {
+ guard isLocked else {
+ return
+ }
+
+ let operation = AsyncOperation()
+
+ try await withTaskCancellationHandler { [weak operation] in
+ try await withUnsafeThrowingContinuation {
+ guard let operation else {
+ return
+ }
+
+ operation.schedule($0)
+ pendingOperations.insert(operation, at: .zero)
+ }
+ } onCancel: {
+ operation.cancelled()
+ }
+ }
+
+ func signal() {
+ isLocked = false
+
+ while let operation = pendingOperations.popLast() {
+ operation.resume()
+ }
+ }
+
+ deinit {
+ for operation in pendingOperations {
+ operation.dispose()
+ }
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Diff/Defaults/DataDiffAttachmentGenerator.swift b/Sources/XCSnapshotTesting/Diff/Defaults/DataDiffAttachmentGenerator.swift
new file mode 100644
index 000000000..2b1ec8ea6
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Diff/Defaults/DataDiffAttachmentGenerator.swift
@@ -0,0 +1,70 @@
+import Foundation
+
+/// A diff attachment generator that compares two data blobs (typically images or binary snapshots) and generates an attachment describing any differences.
+///
+/// `DataDiffAttachmentGenerator` is designed for use in snapshot or data comparison tests. When invoked, it compares the raw values of two `DataBytes` inputs (such as reference and generated images).
+///
+/// If the data do not match, it returns a `DiffAttachment` containing a message indicating the mismatch. If the data are identical, it returns `nil`.
+///
+/// This type is intended for use with snapshot testing frameworks and utilities that display or log detailed differences between binary artifacts such as images.
+///
+/// Usage:
+/// ```swift
+/// let diff = DataDiffAttachmentGenerator()
+/// let result = diff(from: referenceData, with: newData)
+/// // Use `result` to inspect or report differences.
+/// ```
+///
+/// - Note: This implementation does not attempt to visualize binary differences; it only reports on mismatches and provides a simple message.
+/// - SeeAlso: `DiffAttachment`, `DiffAttachmentGenerator`
+public struct DataDiffAttachmentGenerator: DiffAttachmentGenerator {
+
+ public init() {}
+
+ /// Compares two data blobs and generates a diff attachment describing any differences.
+ ///
+ /// - Parameters:
+ /// - reference: The reference data against which to compare (typically the baseline or "golden" data).
+ /// - diffable: The data to compare against the reference (typically the newly generated data).
+ /// - Returns: A `DiffAttachment` containing a message describing the difference if the data do not match, or `nil` if the data are identical.
+ ///
+ /// This method is intended for snapshot or binary comparison testing, where two data blobs—such as images or other binary artifacts—need to be compared for equality.
+ /// When the data differ, the returned `DiffAttachment` includes a brief message describing the mismatch.
+ /// No visual diff or binary details are attached; the result is meant for simple reporting of mismatches.
+ public func callAsFunction(
+ from reference: DataBytes,
+ with diffable: DataBytes
+ ) -> DiffAttachment? {
+ guard reference.rawValue != diffable.rawValue else {
+ return nil
+ }
+
+ let message =
+ reference.rawValue.count == diffable.rawValue.count
+ ? "Expected data to match"
+ : "Expected \(diffable.rawValue) to match \(reference.rawValue)"
+
+ return DiffAttachment(
+ message: message,
+ attachments: []
+ )
+ }
+}
+
+extension DiffAttachmentGenerator where Self == DataDiffAttachmentGenerator {
+
+ /// A convenience static property for accessing the standard data diff attachment generator.
+ ///
+ /// Use this property to obtain a `DataDiffAttachmentGenerator`, which compares two data blobs (such as images or binary files) and generates a diff attachment if they do not match.
+ ///
+ /// Example usage:
+ /// ```swift
+ /// let generator = DiffAttachmentGenerator.data
+ /// let diff = generator(from: referenceData, with: newData)
+ /// ```
+ ///
+ /// - Returns: A `DataDiffAttachmentGenerator` instance for performing data comparisons in tests.
+ public static var data: Self {
+ DataDiffAttachmentGenerator()
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Diff/Defaults/ImageDiffAttachmentGenerator.swift b/Sources/XCSnapshotTesting/Diff/Defaults/ImageDiffAttachmentGenerator.swift
new file mode 100644
index 000000000..a8abb63ce
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Diff/Defaults/ImageDiffAttachmentGenerator.swift
@@ -0,0 +1,101 @@
+#if os(iOS) || os(tvOS) || os(watchOS)
+import UIKit
+#elseif os(macOS)
+@preconcurrency import AppKit
+#endif
+
+#if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) || os(visionOS)
+/// `ImageDiffAttachmentGenerator` is a utility for visual snapshot testing that highlights and reports
+/// differences between two images, usually representing the expected (reference) and actual (diffable) output
+/// from a UI or graphics test.
+///
+/// This generator compares images at the pixel level and, if differences are detected beyond the specified
+/// thresholds, produces both textual descriptions of the differences and attachments that visualize them
+/// (such as a difference image and annotated copies of the input images).
+///
+/// - Parameters:
+/// - precision: The strictness of the pixel-wise comparison. A value of `1.0` requires identical pixels,
+/// while lower values allow for small variations.
+/// - perceptualPrecision: The threshold for color difference when comparing images perceptually. This allows
+/// for tolerant or "fuzzy" comparisons that are less strict about exact color matching, useful
+/// when minor rendering differences are acceptable.
+///
+/// On difference detection, the generator outputs a `DiffAttachment` which includes:
+/// - A message describing the type and severity of the difference.
+/// - Attachments: Visual representations of the reference image, the diffable image (or a placeholder if empty),
+/// and a difference image highlighting discrepancies.
+///
+/// This type is commonly used in snapshot and UI regression testing suites to make visual regressions
+/// easy to spot and diagnose during continuous integration or local development.
+public struct ImageDiffAttachmentGenerator: DiffAttachmentGenerator {
+
+ private let precision: Float
+ private let perceptualPrecision: Float
+
+ /// Initializes a new `ImageDiffAttachmentGenerator` with the specified comparison thresholds.
+ ///
+ /// - Parameters:
+ /// - precision: The pixel-wise comparison threshold. A value of `1.0` requires exact pixel matches,
+ /// while lower values allow minor variations, making the comparison less strict.
+ /// - perceptualPrecision: The color difference threshold for perceptual (fuzzy) image comparisons.
+ /// Lower values make the comparison more tolerant of small color variations, which
+ /// is useful for ignoring minor rendering differences that are not visually significant.
+ ///
+ /// Use this initializer to specify how strict or lenient the image difference detection should be,
+ /// enabling customized snapshot test sensitivity.
+ public init(
+ precision: Float,
+ perceptualPrecision: Float
+ ) {
+ self.precision = precision
+ self.perceptualPrecision = perceptualPrecision
+ }
+
+ /// Compares two images and generates a `DiffAttachment` if significant visual differences are detected.
+ ///
+ /// This function performs a pixel-wise and perceptual comparison between a reference image and a diffable (test) image.
+ /// If the differences between the two images exceed the configured `precision` or `perceptualPrecision` thresholds,
+ /// the function produces a descriptive message and attachments, including:
+ /// - The original reference image.
+ /// - The diffable (test) image or, if it is empty, a placeholder.
+ /// - A difference image that highlights detected discrepancies.
+ ///
+ /// Use this function in visual regression or snapshot testing to identify and visualize unintended UI changes.
+ ///
+ /// - Parameters:
+ /// - reference: The known-correct (reference) image to compare against.
+ /// - diffable: The image under test, to be compared to the reference.
+ /// - Returns: A `DiffAttachment` containing a textual difference summary and visual attachments if a significant
+ /// difference is found; otherwise, returns `nil` if the images are considered equivalent.
+ public func callAsFunction(
+ from reference: ImageBytes,
+ with diffable: ImageBytes
+ ) -> DiffAttachment? {
+ performOnMainThread {
+ guard
+ let message = reference.rawValue.compare(
+ diffable.rawValue,
+ precision: precision,
+ perceptualPrecision: perceptualPrecision
+ )
+ else { return nil }
+
+ let difference = reference.rawValue.substract(diffable.rawValue)
+ var oldAttachment = SnapshotAttachment(image: reference.rawValue)
+ oldAttachment?.name = "reference"
+ let isEmptyImage = diffable.rawValue.size == .zero
+ var newAttachment = SnapshotAttachment(
+ image: isEmptyImage ? SDKImage.empty : diffable.rawValue
+ )
+ newAttachment?.name = "failure"
+ var differenceAttachment = SnapshotAttachment(image: difference)
+ differenceAttachment?.name = "difference"
+
+ return DiffAttachment(
+ message: message,
+ attachments: [oldAttachment, newAttachment, differenceAttachment].compactMap(\.self)
+ )
+ }
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Diff/Defaults/StringDiffAttachmentGenerator.swift b/Sources/XCSnapshotTesting/Diff/Defaults/StringDiffAttachmentGenerator.swift
new file mode 100644
index 000000000..71614bb9b
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Diff/Defaults/StringDiffAttachmentGenerator.swift
@@ -0,0 +1,79 @@
+import Foundation
+
+/// A diff attachment generator that compares two UTF-8 strings line-by-line and produces a patch-style text diff
+/// as an `SnapshotAttachment`. Calculates differences between collections using the Longest Common
+/// Subsequence (LCS) algorithm.
+///
+/// `StringDiffAttachmentGenerator` implements the `DiffAttachmentGenerator` protocol, generating
+/// a unified diff between a reference and a diffable string, each represented as a `StringBytes` value.
+/// If the contents are identical, it returns `nil`. Otherwise, it produces a patch-like message
+/// and an attachment with UTI "public.patch-file" that can be used to present rich diffs in test failures.
+///
+/// Typical usage is through `DiffAttachmentGenerator.lines`, which instantiates this type.
+///
+/// - Warning: The generator operates on lines, splitting both reference and diffable values on newlines,
+/// and therefore only highlights differences at the line level. It does not perform word- or character-level diffs.
+///
+/// - Note: This is particularly useful for snapshot and golden file tests where readable, actionable diffs
+/// are important to developers.
+public struct StringDiffAttachmentGenerator: DiffAttachmentGenerator {
+
+ /// Creates a new instance of `StringDiffAttachmentGenerator`.
+ ///
+ /// Use this initializer to construct a diff generator that produces unified
+ /// patch-style attachments for line-by-line differences between two strings.
+ public init() {}
+
+ /// Compares two UTF-8 string values line-by-line and generates a unified patch-style diff attachment if differences are found.
+ ///
+ /// This function splits both the `reference` and `diffable` string values into lines,
+ /// computes their differences using the Longest Common Subsequence (LCS) algorithm,
+ /// and constructs a patch-formatted message representing the changes. If the strings are identical,
+ /// the function returns `nil`. Otherwise, it returns a `DiffAttachment` containing a summary message and
+ /// a patch file attachment suitable for rich diff presentation.
+ ///
+ /// - Parameters:
+ /// - reference: The baseline value, typically representing the expected or golden UTF-8 string.
+ /// - diffable: The actual UTF-8 string to compare against the reference.
+ /// - Returns: A `DiffAttachment` with a patch-style message and attachment, or `nil` if no differences exist.
+ public func callAsFunction(
+ from reference: StringBytes,
+ with diffable: StringBytes
+ ) -> DiffAttachment? {
+ guard reference.rawValue != diffable.rawValue else {
+ return nil
+ }
+
+ let hunks = reference.rawValue
+ .split(separator: "\n", omittingEmptySubsequences: false)
+ .map(String.init)
+ .diffing(
+ diffable.rawValue
+ .split(separator: "\n", omittingEmptySubsequences: false)
+ .map(String.init)
+ )
+ .groupping()
+
+ let failure =
+ hunks
+ .flatMap { [$0.patchMarker] + $0.lines }
+ .joined(separator: "\n")
+
+ let attachment = SnapshotAttachment(
+ data: Data(failure.utf8),
+ uniformTypeIdentifier: "public.patch-file"
+ )
+
+ return DiffAttachment(
+ message: failure,
+ attachments: [attachment]
+ )
+ }
+}
+
+extension DiffAttachmentGenerator where Self == StringDiffAttachmentGenerator {
+ /// A line-diffing strategy for UTF-8 text.
+ public static var lines: Self {
+ StringDiffAttachmentGenerator()
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Diff/DiffAttachmentGenerator.swift b/Sources/XCSnapshotTesting/Diff/DiffAttachmentGenerator.swift
new file mode 100644
index 000000000..f01887793
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Diff/DiffAttachmentGenerator.swift
@@ -0,0 +1,118 @@
+import Foundation
+
+/// Container for messages and attachments generated during snapshot comparisons.
+///
+/// `DiffAttachment` provides a structured way to report differences discovered during snapshot testing.
+/// It encapsulates both a textual message describing what was found (for example, "2% pixel mismatch")
+/// and any visual assets (such as images, diffs, or annotated screenshots) that help illustrate the
+/// discrepancies. This enables richer, more actionable feedback in snapshot test results, making it
+/// easier to understand and investigate test failures.
+///
+/// Typical uses include:
+/// - Reporting pixel-level differences in image snapshots.
+/// - Attaching diffed images or highlight overlays for visual comparison.
+/// - Supplying serialized data (as files or text) showing the before and after state.
+///
+/// Attachments are platform-dependent and may be rendered or linked in test reports, depending on
+/// tooling support.
+///
+/// Example usage:
+/// ```swift
+/// let attachment = DiffAttachment(
+/// message: "Found color shift in bottom-right quadrant",
+/// attachments: [SnapshotAttachment(image: diffImage)]
+/// )
+/// ```
+public struct DiffAttachment: Sendable {
+
+ /// Message describing the outcome of the snapshot comparison, summarizing key detected differences.
+ ///
+ /// This textual message provides actionable context for test failures, such as:
+ /// - The nature or extent of detected changes (e.g., "8% pixel mismatch in bottom-right region").
+ /// - Additional hints or suggestions for investigation.
+ /// - Summaries of numerical, visual, or structural differences.
+ ///
+ /// The message should be concise yet descriptive, enabling developers to quickly understand
+ /// what changed and why the test failed, even without reviewing the attached visual artifacts.
+ ///
+ /// Example: "Image differs: 1,304 pixels modified (3% of total)."
+ public let message: String
+
+ /// Collection of visual attachments illustrating the differences.
+ ///
+ /// This array contains instances of `SnapshotAttachment` that visually represent the discrepancies
+ /// between the reference and test values. Typical attachments may include images highlighting
+ /// regions that differ, diff overlays, annotated screenshots, or other files that provide
+ /// additional context for the detected changes.
+ ///
+ /// Attachments support richer test reporting by enabling quick visual inspection of what
+ /// changed, helping developers understand and investigate snapshot test failures more efficiently.
+ ///
+ /// Example: `[SnapshotAttachment(image: diffImage), SnapshotAttachment(image: croppedFailureRegion)]`
+ public let attachments: [SnapshotAttachment]
+
+ /// Initializes a new `DiffAttachment` with a descriptive message and a collection of visual attachments.
+ ///
+ /// - Parameters:
+ /// - message: A concise, human-readable description summarizing the key differences found during the comparison. This message should help developers quickly understand the nature or extent of the discrepancies.
+ /// - attachments: An array of `SnapshotAttachment` instances that visually represent the detected differences (such as diff images, overlays, or annotated screenshots). These attachments provide additional context to aid investigation of test failures.
+ public init(message: String, attachments: [SnapshotAttachment]) {
+ self.message = message
+ self.attachments = attachments
+ }
+}
+
+/// A protocol for generating detailed difference reports between two values during snapshot testing.
+///
+/// `DiffAttachmentGenerator` enables the creation of both textual summaries and visual artifacts
+/// when comparing a stored reference value (e.g., a previously recorded snapshot) to a newly produced value.
+/// Conformers implement logic to highlight and explain significant discrepancies, aiding in the diagnosis
+/// of test failures.
+///
+/// Typical usages include generating:
+/// - Human-readable messages summarizing differences (e.g., percentage of pixels mismatched).
+/// - Visual attachments such as diff images, overlays, or annotated comparisons for richer test reports.
+///
+/// Implementations should return `nil` when the two values are considered equivalent within the comparison criteria,
+/// ensuring attachments are only created for meaningful changes.
+///
+/// ## Example
+/// ```swift
+/// struct ImageDiffGenerator: DiffAttachmentGenerator {
+/// func callAsFunction(from reference: ImageBytes, with diffable: ImageBytes) -> DiffAttachment? {
+/// // Produce diff image, compare bytes, etc.
+/// }
+/// }
+/// ```
+///
+/// - Note: The generator must be safe for concurrent use (`Sendable`) and should avoid blocking operations.
+public protocol DiffAttachmentGenerator: Sendable {
+
+ /// The type of values to compare during snapshot testing.
+ ///
+ /// `Value` represents the data being subjected to comparison by the generator. This can be any type
+ /// that is `Sendable`, such as images, serialized data, or complex domain-specific structures.
+ /// Conformers specify the actual type used for their comparison logic (e.g., `ImageBytes`, `UIView`, etc.).
+ ///
+ /// Implementations use `Value` to define the kinds of values their diffing logic handles. The generator
+ /// will analyze two instances of this type—the reference (such as a previously-approved snapshot) and
+ /// the new value (such as fresh test output)—and report differences via a `DiffAttachment`.
+ ///
+ /// - Note: `Value` must conform to `Sendable` to ensure thread-safe use during parallelized test execution.
+ associatedtype Value: Sendable
+
+ /// Compares a reference value and a newly produced value, generating a detailed difference report if discrepancies are found.
+ ///
+ /// Implementers should analyze the two values and, if significant differences exist, return a `DiffAttachment` that summarizes the key findings and provides visual context (such as diff images or annotated overlays). If the values are considered equivalent according to the generator's criteria (for example, pixel-for-pixel identical for image data), the function must return `nil`, indicating no actionable differences.
+ ///
+ /// - Parameters:
+ /// - reference: The stored reference value, typically representing an approved or baseline snapshot.
+ /// - diffable: The new value to compare against the reference, produced during the current test run.
+ /// - Returns: A `DiffAttachment` encapsulating a human-readable message and any relevant visual attachments if significant differences are found, or `nil` if the values are considered equivalent.
+ ///
+ /// - Note: This method should be fast and thread-safe. It must not block on I/O or lengthy computations, and must be safe for concurrent use across multiple test invocations.
+ func callAsFunction(
+ from reference: Value,
+ with diffable: Value
+ ) -> DiffAttachment?
+}
diff --git a/Sources/XCSnapshotTesting/Diff/DiffHunk.swift b/Sources/XCSnapshotTesting/Diff/DiffHunk.swift
new file mode 100644
index 000000000..9aad97277
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Diff/DiffHunk.swift
@@ -0,0 +1,53 @@
+import Foundation
+
+// MARK: - Formatted Change Representation
+/// Represents a group of changes (hunk) in patch format.
+struct DiffHunk {
+ /// Start index in the first collection.
+ let firstStart: Int
+
+ /// Number of lines in the first collection.
+ let firstLength: Int
+
+ /// Start index in the second collection.
+ let secondStart: Int
+
+ /// Number of lines in the second collection.
+ let secondLength: Int
+
+ /// Formatted lines with change indicators.
+ let lines: [String]
+
+ /// Generates the hunk header in patch format.
+ var patchMarker: String {
+ let firstMarker = "−\(firstStart + 1),\(firstLength)"
+ let secondMarker = "+\(secondStart + 1),\(secondLength)"
+ return "@@ \(firstMarker) \(secondMarker) @@"
+ }
+
+ /// Combines two hunks into one.
+ static func + (lhs: DiffHunk, rhs: DiffHunk) -> DiffHunk {
+ DiffHunk(
+ firstStart: lhs.firstStart,
+ firstLength: lhs.firstLength + rhs.firstLength,
+ secondStart: lhs.secondStart,
+ secondLength: lhs.secondLength + rhs.secondLength,
+ lines: lhs.lines + rhs.lines
+ )
+ }
+
+ /// Default initializer with default values.
+ init(
+ firstStart: Int = 0,
+ firstLength: Int = 0,
+ secondStart: Int = 0,
+ secondLength: Int = 0,
+ lines: [String] = []
+ ) {
+ self.firstStart = firstStart
+ self.firstLength = firstLength
+ self.secondStart = secondStart
+ self.secondLength = secondLength
+ self.lines = lines
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Diff/DiffTool.swift b/Sources/XCSnapshotTesting/Diff/DiffTool.swift
new file mode 100644
index 000000000..fdb68ad2b
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Diff/DiffTool.swift
@@ -0,0 +1,162 @@
+import Foundation
+
+/// A formatter for generating snapshot comparison messages in the console or for use with external diff tools.
+///
+/// `DiffTool` provides a flexible way to format output displayed in the Xcode console when a snapshot comparison fails.
+/// This output may consist of:
+/// - Human-readable error messages (e.g., `.default`)
+/// - Shell commands to launch external comparison tools (e.g., `.ksdiff` for Kaleidoscope)
+///
+/// The generated output is **not automatically executed** by the library. Developers or continuous integration systems
+/// may process it manually, such as by copying and pasting commands into a terminal or automating with scripts.
+///
+/// ## Usage
+/// You can use one of the built-in presets, such as `.default` or `.ksdiff`, or provide a custom formatter,
+/// either as a closure or string literal. For example:
+///
+/// ```swift
+/// let customTool: DiffTool = { ref, actual in
+/// "diff \"\(ref)\" \"\(actual)\""
+/// }
+///
+/// let literalTool: DiffTool = "open -a Kaleidoscope"
+/// ```
+///
+/// To configure the diff tool for tests, use `withTestingEnvironment(diffTool: ...)` or specify it through Swift Testing traits.
+///
+/// ## Thread Safety
+/// `DiffTool` is `Sendable` and safe to use from concurrent test runners.
+///
+/// ## Configuration via Swift Testing Traits
+/// The diff tool can also be configured using Swift Testing traits, allowing you to set it at a project or target level
+/// without modifying individual test files.
+///
+/// ## See Also
+/// - ``withTestingEnvironment(record:diffTool:maxConcurrentTests:platform:operation:file:line:)``
+/// - ``DiffTool/ksdiff``
+/// - ``DiffTool/default``
+///
+/// - Note: The formatting closure is always given two absolute file paths: the reference (current/expected) file, and the failed (actual/current) file.
+public struct DiffTool: Sendable, ExpressibleByStringLiteral {
+
+ /// Formats output for [Kaleidoscope](http://kaleidoscope.app).
+ ///
+ /// Generates a shell command that can be executed externally to open Kaleidoscope,
+ /// comparing the two file paths provided.
+ ///
+ /// - Example output:
+ /// ```bash
+ /// ksdiff "/path/reference-file.png" "/path/failed-file.png"
+ /// ```
+ ///
+ /// - WARNING: Requires Kaleidoscope to be installed and the `ksdiff` command-line tool to be available.
+ /// - Parameters:
+ /// - currentFilePath: The path to the reference (expected) file.
+ /// - failedFilePath: The path to the failed (actual) file.
+ /// - Returns: A shell command string that can be executed to launch Kaleidoscope for file comparison.
+ public static let ksdiff = Self {
+ "ksdiff \"\($0)\" \"\($1)\""
+ }
+
+ /// Default format (human-readable error in console).
+ ///
+ /// Generates a message guiding developers to configure an advanced diff tool for more interactive file comparison.
+ ///
+ /// - Output Example:
+ /// ```plaintext
+ /// @−
+ /// "/path/to/reference.png"
+ /// @+
+ /// "/path/to/failed.png"
+ ///
+ /// To configure output for a custom diff tool, use 'withTestingEnvironment'. For example:
+ ///
+ /// withTestingEnvironment(diffTool: .ksdiff) {
+ /// // ...
+ /// }
+ /// ```
+ ///
+ /// - Use Cases:
+ /// - Suitable for CI environments or when no external diff tool is available.
+ /// - Provides clear next steps for developers to configure more advanced comparison tools.
+ /// - SeeAlso: ``DiffTool/ksdiff``, ``withTestingEnvironment(record:diffTool:maxConcurrentTests:platform:operation:file:line:)``
+ public static let `default` = Self {
+ """
+ @−
+ "\($0)"
+ @+
+ "\($1)"
+
+ To configure output for a custom diff tool, use 'withTestingEnvironment'. For example:
+
+ withTestingEnvironment(diffTool: .ksdiff) {
+ // ...
+ }
+ """
+ }
+
+ private var tool: @Sendable (_ currentFilePath: String, _ failedFilePath: String) -> String
+
+ /// Initializes a new `DiffTool` with a custom formatting closure.
+ ///
+ /// Use this initializer to define how snapshot difference output is generated. The closure receives the absolute
+ /// file paths of the reference ("current") and failed ("actual") files, and returns a string that will be shown
+ /// in the console or passed to external processes.
+ ///
+ /// Example usage:
+ /// ```swift
+ /// let customDiff = DiffTool { ref, actual in
+ /// "diff \"\(ref)\" \"\(actual)\""
+ /// }
+ /// ```
+ ///
+ /// - Parameter tool:
+ /// A closure that formats the output for failed snapshot comparisons.
+ /// - `currentFilePath`: The absolute path to the reference (expected) file.
+ /// - `failedFilePath`: The absolute path to the failed (actual) file.
+ /// - Returns: The formatted string to display or process.
+ ///
+ /// - Note: The closure must be `Sendable` to ensure thread safety during concurrent test execution.
+ public init(
+ _ tool: @escaping @Sendable (_ currentFilePath: String, _ failedFilePath: String) -> String
+ ) {
+ self.tool = tool
+ }
+
+ /// Initializes the tool from a string literal.
+ ///
+ /// - Parameter value: Text or command formatted with file paths. `$0` and `$1` are replaced by the reference and failed file paths, respectively. If `$0` or `$0` aren't provided, it'll be added at the end of string separated with space.
+ ///
+ /// - Example: `DiffTool("open -a Kaleidoscope $0 $1")` generates `open -a Kaleidoscope /path1 /path2`.
+ ///
+ /// - Note: The string is evaluated as a command to be executed externally. Ensure proper formatting for your shell or environment.
+ public init(stringLiteral value: StringLiteralType) {
+ self.tool = {
+ if value.contains("$0") || value.contains("$1") {
+ return
+ value
+ .replacingOccurrences(of: "$0", with: $0)
+ .replacingOccurrences(of: "$1", with: $1)
+ } else {
+ return "\(value) \($0) \($1)"
+ }
+ }
+ }
+
+ /// Generates the comparison output.
+ ///
+ /// - Parameters:
+ /// - currentFilePath: Path to the reference file.
+ /// - failedFilePath: Path to the compared file.
+ /// - Returns: A string representing the formatted comparison output, which may include:
+ /// - Human-readable error messages (e.g., for `.default`)
+ /// - Shell commands for external diff tools (e.g., `.ksdiff`)
+ /// - Custom-format strings defined by the user
+ ///
+ /// - Note: The output is intended for display in the Xcode console or Terminal. It can be
+ /// manually executed or processed by scripts, but the library itself does not
+ /// execute the generated commands.
+ public func callAsFunction(currentFilePath: String, failedFilePath: String) -> String {
+ self.tool(currentFilePath, failedFilePath)
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Diff/Difference.swift b/Sources/XCSnapshotTesting/Diff/Difference.swift
new file mode 100644
index 000000000..a52394aba
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Diff/Difference.swift
@@ -0,0 +1,189 @@
+import Foundation
+
+// MARK: - Difference Structure
+/// Represents a difference between two collections of elements.
+struct Difference {
+ /// Origin of elements in the comparison
+ enum Origin {
+ case first // Element unique to the first collection
+ case second // Element unique to the second collection
+ case common // Element present in both collections
+ }
+
+ /// Elements involved in the difference
+ let elements: [Element]
+ /// Origin of elements (first collection, second collection, or common)
+ let origin: Origin
+}
+
+extension Array where Element: Hashable {
+
+ // MARK: - Main Comparison Function
+ /// Calculates differences between collections using the Longest Common Subsequence (LCS) algorithm.
+ /// - Parameters:
+ /// - first: First collection to compare
+ /// - second: Second collection to compare
+ /// - Returns: List of identified differences
+ func diffing(_ other: [Element]) -> [Difference] {
+ // 1. Maps element indices from first collection
+ var elementIndices = [Element: [Int]]()
+ for (index, element) in enumerated() {
+ elementIndices[element, default: []].append(index)
+ }
+
+ // 2. Finds Longest Common Subsequence (LCS)
+ var longestSubsequence = (
+ overlap: [Int: Int](), // Overlap table
+ firstIndex: 0, // Start index in first collection
+ secondIndex: 0, // Start index in second collection
+ length: 0 // Subsequence length
+ )
+
+ // Iterates through second collection to find matches
+ for pair in other.enumerated() {
+ guard let indices = elementIndices[pair.element] else { continue }
+
+ for firstIndex in indices {
+ let currentLength = (longestSubsequence.overlap[firstIndex - 1] ?? 0) + 1
+ var newOverlap = longestSubsequence.overlap
+ newOverlap[firstIndex] = currentLength
+
+ // Updates longest subsequence found
+ if currentLength > longestSubsequence.length {
+ longestSubsequence.overlap = newOverlap
+ longestSubsequence.firstIndex = firstIndex - currentLength + 1
+ longestSubsequence.secondIndex = pair.offset - currentLength + 1
+ longestSubsequence.length = currentLength
+ }
+ }
+ }
+
+ // 3. No common subsequence case
+ guard longestSubsequence.length > 0 else {
+ return [
+ Difference(elements: self, origin: .first),
+ Difference(elements: other, origin: .second),
+ ].filter { !$0.elements.isEmpty }
+ }
+
+ // 4. Splits collections into parts for recursive analysis
+ let (firstPart, secondPart) = (
+ Array(self.prefix(upTo: longestSubsequence.firstIndex)),
+ Array(other.prefix(upTo: longestSubsequence.secondIndex))
+ )
+
+ let (firstRemainder, secondRemainder) = (
+ Array(self.suffix(from: longestSubsequence.firstIndex + longestSubsequence.length)),
+ Array(other.suffix(from: longestSubsequence.secondIndex + longestSubsequence.length))
+ )
+
+ // 5. Combines results from analyzed parts recursively
+ return firstPart.diffing(secondPart)
+ + [
+ Difference(
+ elements: Array(
+ self[
+ longestSubsequence.firstIndex..] {
+
+ func groupping(context: Int = 4) -> [DiffHunk] {
+ let figureSpace = "\u{2007}" // Figure space (for alignment)
+
+ // Processes each difference and groups into hunks
+ let (finalHunk, hunks) = reduce(into: (current: DiffHunk(), hunks: [DiffHunk]())) {
+ state,
+ diff in
+
+ let count = diff.elements.count
+
+ switch diff.origin {
+ // Case: Common elements with large context
+ case .common where count > context * 2:
+ let prefixLines = diff.elements.prefix(context).map(addPrefix(figureSpace))
+ let suffixLines = diff.elements.suffix(context).map(addPrefix(figureSpace))
+
+ let newHunk =
+ state.current
+ + DiffHunk(
+ firstLength: context,
+ secondLength: context,
+ lines: prefixLines
+ )
+
+ state.current = DiffHunk(
+ firstStart: state.current.firstStart + state.current.firstLength + count - context,
+ firstLength: context,
+ secondStart: state.current.secondStart + state.current.secondLength + count - context,
+ secondLength: context,
+ lines: suffixLines
+ )
+
+ // Adds previous hunk if it contains changes
+ if newHunk.lines.contains(where: { $0.hasPrefix("−") || $0.hasPrefix("+") }) {
+ state.hunks.append(newHunk)
+ }
+
+ // Case: Common elements with empty hunk
+ case .common where state.current.lines.isEmpty:
+ let suffixLines = diff.elements.suffix(context).map(addPrefix(figureSpace))
+ state.current =
+ state.current
+ + DiffHunk(
+ firstStart: count - suffixLines.count,
+ firstLength: suffixLines.count,
+ secondStart: count - suffixLines.count,
+ secondLength: suffixLines.count,
+ lines: suffixLines
+ )
+
+ // Case: Normal common elements
+ case .common:
+ let lines = diff.elements.map(addPrefix(figureSpace))
+ state.current =
+ state.current
+ + DiffHunk(
+ firstLength: count,
+ secondLength: count,
+ lines: lines
+ )
+
+ // Case: Removals (first collection elements)
+ case .first:
+ state.current =
+ state.current
+ + DiffHunk(
+ firstLength: count,
+ lines: diff.elements.map(addPrefix("−"))
+ )
+
+ // Case: Additions (second collection elements)
+ case .second:
+ state.current =
+ state.current
+ + DiffHunk(
+ secondLength: count,
+ lines: diff.elements.map(addPrefix("+"))
+ )
+ }
+ }
+
+ // Returns accumulated hunks + final hunk (if valid)
+ return finalHunk.lines.isEmpty ? hunks : hunks + [finalHunk]
+ }
+}
+
+// MARK: - Contextual Change Grouping
+/// Helper function to add line prefixes
+private func addPrefix(_ prefix: String) -> (String) -> String {
+ { "\(prefix)\($0)\($0.hasSuffix(" ") ? "¬" : "")" }
+}
diff --git a/Sources/XCSnapshotTesting/Environment/Defaults/DiffToolEnvironmentKey.swift b/Sources/XCSnapshotTesting/Environment/Defaults/DiffToolEnvironmentKey.swift
new file mode 100644
index 000000000..256d6bc9e
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Environment/Defaults/DiffToolEnvironmentKey.swift
@@ -0,0 +1,46 @@
+import Foundation
+
+private struct DiffToolEnvironmentKey: SnapshotEnvironmentKey {
+
+ static var defaultValue: DiffTool {
+ TestingSystem.shared.environment?.diffTool ?? .default
+ }
+}
+
+extension SnapshotEnvironmentValues {
+
+ /// The `diffTool` property provides access to the current diff tool configuration within the snapshot testing environment.
+ ///
+ /// This property allows you to specify or retrieve the tool used for visual comparisons when snapshot tests fail.
+ /// It can be accessed globally through `SnapshotEnvironment.current.diffTool`.
+ ///
+ /// The value defaults to `.default`, which provides a basic textual comparison output in the Xcode console.
+ /// You can change this value using:
+ /// - `withTestingEnvironment(diffTool:operation:)`
+ /// - Swift Testing framework attributes
+ ///
+ /// Available diff tools include:
+ /// - `.default`: Basic console output with instructions for configuring advanced diff tools
+ /// - `.ksdiff`: Launches Kaleidoscope for visual comparison (requires Kaleidoscope installation)
+ ///
+ /// ```swift
+ /// // Accessing the current diff tool
+ /// let currentTool = SnapshotEnvironment.current.diffTool
+ ///
+ /// // Configuring a custom diff tool
+ /// withTestingEnvironment {
+ /// $0.diffTool = .ksdiff
+ /// } operation: {
+ /// // Your testing code here
+ /// }
+ /// ```
+ ///
+ /// - Note: The diff tool is only used when snapshot comparisons fail and a visual diff is needed.
+ /// - SeeAlso:
+ /// - ``DiffTool``
+ /// - ``withTestingEnvironment(record:diffTool:maxConcurrentTests:platform:operation:file:line:)``
+ public var diffTool: DiffTool {
+ get { self[DiffToolEnvironmentKey.self] }
+ set { self[DiffToolEnvironmentKey.self] = newValue }
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Environment/Defaults/DisableInconsistentTraitsCheckEnvironmentKey.swift b/Sources/XCSnapshotTesting/Environment/Defaults/DisableInconsistentTraitsCheckEnvironmentKey.swift
new file mode 100644
index 000000000..8f95c3948
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Environment/Defaults/DisableInconsistentTraitsCheckEnvironmentKey.swift
@@ -0,0 +1,13 @@
+import Foundation
+
+private struct DisableInconsistentTraitsCheckerEnvironmentKey: SnapshotEnvironmentKey {
+ static let defaultValue = false
+}
+
+extension SnapshotEnvironmentValues {
+
+ var disableInconsistentTraitsChecker: Bool {
+ get { self[DisableInconsistentTraitsCheckerEnvironmentKey.self] }
+ set { self[DisableInconsistentTraitsCheckerEnvironmentKey.self] = newValue }
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Environment/Defaults/IncludeMajorPlatformVersionInPathEnvironmentKey.swift b/Sources/XCSnapshotTesting/Environment/Defaults/IncludeMajorPlatformVersionInPathEnvironmentKey.swift
new file mode 100644
index 000000000..7f02dca0a
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Environment/Defaults/IncludeMajorPlatformVersionInPathEnvironmentKey.swift
@@ -0,0 +1,75 @@
+import Foundation
+
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+#elseif os(watchOS)
+import WatchKit
+#endif
+
+private struct IncludeMajorPlatformVersionInPathEnvironmentKey: SnapshotEnvironmentKey {
+ static let defaultValue = false
+}
+
+extension SnapshotEnvironmentValues {
+
+ /// A Boolean value that determines whether the major platform version is included in the path during snapshot testing.
+ ///
+ /// When `true`, the snapshot paths will include the major version of the platform (e.g., "v14" for iOS 14).
+ /// This is useful for ensuring snapshots are version-specific and avoid conflicts between different platform versions.
+ ///
+ /// - Note: The value is retrieved from the `SnapshotEnvironmentValues`:
+ /// - `SnapshotEnvironment.current.includeMajorPlatformVersionInPath`
+ /// - Or set within a testing environment closure:
+ ///
+ /// withTestingEnvironment {
+ /// $0.includeMajorPlatformVersionInPath = true
+ /// } operation: { ... }
+ ///
+ /// Platform version retrieval is handled differently across platforms:
+ /// - **macOS**: Parses the OS version, handling macOS 10.x specially.
+ /// - **iOS/tvOS/visionOS**: Uses `UIDevice.current.systemVersion` on the main thread.
+ /// - **watchOS**: Uses `WKInterfaceDevice.current().systemVersion` on the main thread.
+ ///
+ /// The final version string is formatted as "v\{MajorVersion}".
+ ///
+ /// ```swift
+ /// // Enable versioned paths
+ /// withTestingEnvironment {
+ /// $0.includeMajorPlatformVersionInPath = true
+ /// } operation: {
+ /// // Your testing code here
+ /// }
+ /// ```
+ public var includeMajorPlatformVersionInPath: Bool {
+ get { self[IncludeMajorPlatformVersionInPathEnvironmentKey.self] }
+ set { self[IncludeMajorPlatformVersionInPathEnvironmentKey.self] = newValue }
+ }
+
+ var platformVersion: String? {
+ guard includeMajorPlatformVersionInPath, !platform.isEmpty else {
+ return nil
+ }
+
+ let majorVersion: String?
+ #if os(macOS)
+ if ProcessInfo.processInfo.operatingSystemVersion.majorVersion == 10 {
+ let systemVersion = ProcessInfo.processInfo.operatingSystemVersion
+ majorVersion = "\(systemVersion.majorVersion).\(systemVersion.minorVersion)"
+ } else {
+ majorVersion = String(ProcessInfo.processInfo.operatingSystemVersion.majorVersion)
+ }
+ #elseif os(iOS) || os(tvOS) || os(visionOS)
+ majorVersion = performOnMainThread {
+ UIDevice.current.systemVersion.split(separator: ".").first.map(String.init)
+ }
+ #elseif os(watchOS)
+ majorVersion = performOnMainThread {
+ WKInterfaceDevice.current().systemVersion.split(separator: ".").first.map(String.init)
+ }
+ #else
+ majorVersion = String(ProcessInfo.processInfo.operatingSystemVersion.majorVersion)
+ #endif
+
+ return majorVersion.map { "v\($0)" }
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Environment/Defaults/MaxConcurrentTestsEnvironmentKey.swift b/Sources/XCSnapshotTesting/Environment/Defaults/MaxConcurrentTestsEnvironmentKey.swift
new file mode 100644
index 000000000..6c2f2a30e
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Environment/Defaults/MaxConcurrentTestsEnvironmentKey.swift
@@ -0,0 +1,42 @@
+import Foundation
+
+private struct MaxConcurrentTestsEnvironmentKey: SnapshotEnvironmentKey {
+
+ static var defaultValue: Int {
+ TestingSystem.shared.environment?.maxConcurrentTests ?? 3
+ }
+}
+
+extension SnapshotEnvironmentValues {
+
+ /// The `maxConcurrentTests` property defines the maximum number of concurrent tests allowed during UI testing.
+ ///
+ /// This property helps prevent device overload and potential capture errors caused by stress on UIKit, SwiftUI, and AppKit frameworks.
+ /// It limits the number of `UIWindow` (iOS, tvOS, visionOS) or `NSWindow` (macOS) instances allocated simultaneously during testing.
+ ///
+ /// The value can be accessed via `SnapshotEnvironment.current.maxConcurrentTests`.
+ ///
+ /// Default value is `3`. You can modify this value through:
+ /// - `withTestingEnvironment(maxConcurrentTests:operation:)`
+ /// - Swift Testing framework attributes
+ ///
+ /// ```swift
+ /// // Increase concurrent tests limit
+ /// withTestingEnvironment {
+ /// $0.maxConcurrentTests = 5
+ /// } operation: {
+ /// // Your testing code here
+ /// }
+ /// ```
+ ///
+ /// - Note: This property is specifically applicable to UI tests and helps maintain testing stability on resource-constrained devices.
+ /// - SeeAlso:
+ /// - ``withTestingEnvironment(record:diffTool:maxConcurrentTests:platform:operation:file:line:)``
+ public var maxConcurrentTests: Int {
+ get { self[MaxConcurrentTestsEnvironmentKey.self] }
+ set {
+ precondition(newValue >= 1)
+ self[MaxConcurrentTestsEnvironmentKey.self] = newValue
+ }
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Environment/Defaults/PlatformEnvironmentKey.swift b/Sources/XCSnapshotTesting/Environment/Defaults/PlatformEnvironmentKey.swift
new file mode 100644
index 000000000..afb22f5d1
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Environment/Defaults/PlatformEnvironmentKey.swift
@@ -0,0 +1,79 @@
+import Foundation
+
+#if os(iOS)
+import UIKit
+#endif
+
+private struct PlatformEnvironmentKey: SnapshotEnvironmentKey {
+
+ static var defaultValue: String {
+ TestingSystem.shared.environment?.platform ?? operatingSystemName()
+ }
+
+ private static func operatingSystemName() -> String {
+ #if os(macOS)
+ return "macOS"
+ #elseif os(iOS)
+ #if targetEnvironment(macCatalyst)
+ return "macCatalyst"
+ #else
+ return performOnMainThread {
+ if UIDevice.current.userInterfaceIdiom == .pad {
+ return "iPadOS"
+ } else {
+ return "iOS"
+ }
+ }
+ #endif
+ #elseif os(tvOS)
+ return "tvOS"
+ #elseif os(watchOS)
+ return "watchOS"
+ #elseif os(visionOS)
+ return "visionOS"
+ #elseif os(Android)
+ return "android"
+ #elseif os(Windows)
+ return "windows"
+ #elseif os(Linux)
+ return "linux"
+ #elseif os(WASI)
+ return "wasi"
+ #else
+ return "unknown"
+ #endif
+ }
+}
+
+extension SnapshotEnvironmentValues {
+
+ /// The platform name used in snapshot URLs to distinguish test outputs.
+ ///
+ /// This property helps differentiate snapshots across platforms with different UI frameworks (like UIKit and SwiftUI on iOS vs. AppKit on macOS).
+ /// It ensures that platform-specific UI layouts do not interfere with each other during testing.
+ ///
+ /// The value defaults to the current platform (e.g., "iOS", "macOS") unless explicitly configured through:
+ /// - `withTestingEnvironment(platform:operation:)`
+ /// - Swift Testing framework traits
+ ///
+ /// Setting this to an empty string will make snapshots share the same output path without platform-specific distinction.
+ ///
+ /// Accessed via `SnapshotEnvironment.current.platform`.
+ ///
+ /// ```swift
+ /// // Customize platform name for snapshots
+ /// withTestingEnvironment {
+ /// $0.platform = "iOS-Simulator"
+ /// } operation: {
+ /// // Your testing code here
+ /// }
+ /// ```
+ ///
+ /// - Note: This is particularly useful for UI snapshot testing where different platforms may have different default layouts and behaviors.
+ /// - SeeAlso:
+ /// - ``withTestingEnvironment(record:diffTool:maxConcurrentTests:platform:operation:file:line:)``
+ public var platform: String {
+ get { self[PlatformEnvironmentKey.self] }
+ set { self[PlatformEnvironmentKey.self] = newValue }
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Environment/Defaults/RecordEnvironmentKey.swift b/Sources/XCSnapshotTesting/Environment/Defaults/RecordEnvironmentKey.swift
new file mode 100644
index 000000000..1d7fa7686
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Environment/Defaults/RecordEnvironmentKey.swift
@@ -0,0 +1,21 @@
+import Foundation
+
+private struct RecordEnvironmentKey: SnapshotEnvironmentKey {
+
+ static var defaultValue: RecordMode {
+ TestingSystem.shared.environment?.recordMode ?? .missing
+ }
+}
+
+extension SnapshotEnvironmentValues {
+
+ /// The current record mode for snapshot testing.
+ ///
+ /// This key is used to store and retrieve the ``RecordMode`` value within the environment
+ /// of a snapshot test. The default value is determined by the testing system's environment,
+ /// falling back to `.missing` if no environment is available.
+ public var recordMode: RecordMode {
+ get { self[RecordEnvironmentKey.self] }
+ set { self[RecordEnvironmentKey.self] = newValue }
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Environment/Defaults/TraitsEnvironmentKey.swift b/Sources/XCSnapshotTesting/Environment/Defaults/TraitsEnvironmentKey.swift
new file mode 100644
index 000000000..ed2ceec02
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Environment/Defaults/TraitsEnvironmentKey.swift
@@ -0,0 +1,37 @@
+import Foundation
+
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+
+private struct TraitsEnvironmentKey: SnapshotEnvironmentKey {
+ static let defaultValue = Traits()
+}
+
+extension SnapshotEnvironmentValues {
+
+ /// Provides access to the `Traits` configuration for snapshot testing.
+ ///
+ /// This property allows you to customize the appearance and behavior of
+ /// UI elements during snapshot testing by specifying traits like
+ /// accessibility features, color schemes, or device characteristics.
+ ///
+ /// Example:
+ /// ```swift
+ /// // Configure traits for snapshot testing
+ /// withTestingEnvironment {
+ /// $0.traits = .init(preferredContentSizeCategory: .extraLarge)
+ /// } operation: {
+ /// // Your testing code here
+ /// }
+ /// ```
+ ///
+ /// - Note: Traits can be combined to simulate various UI conditions during testing.
+ /// - SeeAlso:
+ /// - ``Traits``
+ /// - ``withTestingEnvironment(_:operation:file:line:)``
+ public var traits: Traits {
+ get { self[TraitsEnvironmentKey.self] }
+ set { self[TraitsEnvironmentKey.self] = newValue }
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Environment/Defaults/WebViewToleranceEnvironmentKey.swift b/Sources/XCSnapshotTesting/Environment/Defaults/WebViewToleranceEnvironmentKey.swift
new file mode 100644
index 000000000..4d7822a8a
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Environment/Defaults/WebViewToleranceEnvironmentKey.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+#if os(iOS) || os(visionOS) || os(macOS) || os(visionOS)
+private struct WebViewToleranceEnvironmentKey: SnapshotEnvironmentKey {
+ static let defaultValue: TimeInterval = 2.5
+}
+
+extension SnapshotEnvironmentValues {
+
+ /// The maximum time (in seconds) to wait for a web view to load before taking a snapshot.
+ ///
+ /// This property configures the timeout duration for web view loading during snapshot operations.
+ /// It helps prevent tests from failing due to network latency or complex web content loading.
+ ///
+ /// - Default: 2.5 seconds
+ /// - Available on: iOS, iPadOS, macOS, visionOS
+ ///
+ /// ```swift
+ /// // Increase web view loading timeout
+ /// withTestingEnvironment {
+ /// $0.webViewTolerance = 5.0
+ /// } operation: {
+ /// // Your web view testing code here
+ /// }
+ /// ```
+ ///
+ /// - Note: This setting is particularly useful when testing views containing `WKWebView` or similar web-rendering components.
+ /// - SeeAlso: ``withTestingEnvironment(_:operation:file:line:)``
+ public var webViewTolerance: TimeInterval {
+ get { self[WebViewToleranceEnvironmentKey.self] }
+ set { self[WebViewToleranceEnvironmentKey.self] = newValue }
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Environment/SnapshotEnvironment.swift b/Sources/XCSnapshotTesting/Environment/SnapshotEnvironment.swift
new file mode 100644
index 000000000..344e9fb68
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Environment/SnapshotEnvironment.swift
@@ -0,0 +1,67 @@
+import Foundation
+
+/// A dynamic environment container for accessing snapshot testing configuration values.
+///
+/// `SnapshotEnvironment` provides a convenient way to access configuration values using Swift's `@dynamicMemberLookup` feature.
+/// It allows you to retrieve values from the underlying `SnapshotEnvironmentValues` using dynamic key path lookups.
+///
+/// ```swift
+/// struct MyEnvironmentKey: SnapshotEnvironmentKey {
+/// typealias Value = Int
+/// static let defaultValue = 42
+/// }
+///
+/// extension SnapshotEnvironmentValues {
+/// var myEnvironment: Int {
+/// get { self[MyEnvironmentKey.self] }
+/// set { self[MyEnvironmentKey.self] = newValue }
+/// }
+/// }
+///
+/// let value = SnapshotEnvironment.current.myEnvironment // results 42
+/// ```
+@dynamicMemberLookup
+public struct SnapshotEnvironment: Sendable {
+
+ /// The current snapshot environment instance available in the testing context.
+ public static let current = SnapshotEnvironment()
+
+ fileprivate init() {}
+
+ /// Provides dynamic member lookup access to values stored in `SnapshotEnvironmentValues`.
+ ///
+ /// This subscript allows you to retrieve configuration values from the snapshot testing environment
+ /// using key paths to properties defined in `SnapshotEnvironmentValues`.
+ ///
+ /// - Parameter keyPath: A key path to the value in `SnapshotEnvironmentValues`.
+ /// - Returns: The value associated with the given key path.
+ ///
+ /// Example usage:
+ /// ```swift
+ /// let currentDiffTool = SnapshotEnvironment.current.diffTool
+ /// let currentRecordMode = SnapshotEnvironment.current.recordMode
+ /// ```
+ ///
+ /// - Note: This subscript provides type-safe access to environment values and is made possible
+ /// by Swift's `@dynamicMemberLookup` feature.
+ public subscript(dynamicMember keyPath: KeyPath) -> Value {
+ (SnapshotEnvironmentValues.current ?? SnapshotEnvironmentValues())[keyPath: keyPath]
+ }
+
+ /// Accesses configuration values stored in `SnapshotEnvironmentValues` using keys that conform to `SnapshotEnvironmentKey`.
+ ///
+ /// This subscript allows you to retrieve values from the snapshot testing environment configuration.
+ /// Each value is associated with a specific key type that conforms to `SnapshotEnvironmentKey`.
+ ///
+ /// - Parameter key: The type of key identifying the configuration value to access.
+ /// - Returns: The value associated with the provided key.
+ ///
+ /// Example usage:
+ /// ```swift
+ /// let diffTool = SnapshotEnvironment.current[DiffToolKey.self]
+ /// print(diffTool(currentFilePath: "file://old.png", failedFilePath: "file://new.png"))
+ /// ```
+ public subscript(_ key: Key.Type) -> Key.Value {
+ (SnapshotEnvironmentValues.current ?? SnapshotEnvironmentValues())[key]
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Environment/SnapshotEnvironmentKey.swift b/Sources/XCSnapshotTesting/Environment/SnapshotEnvironmentKey.swift
new file mode 100644
index 000000000..a00a86e57
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Environment/SnapshotEnvironmentKey.swift
@@ -0,0 +1,20 @@
+import Foundation
+
+/// A protocol for defining keys that can be used with `SnapshotEnvironmentValues`.
+///
+/// Conform to this protocol to create custom keys for storing values in the snapshot testing environment.
+/// Each key must specify an associated value type and provide a default value.
+///
+/// ```swift
+/// struct MyEnvironmentKey: SnapshotEnvironmentKey {
+/// typealias Value = Int
+/// static let defaultValue = 42
+/// }
+/// ```
+public protocol SnapshotEnvironmentKey {
+ /// The type of value associated with the key.
+ associatedtype Value: Sendable
+
+ /// The default value for the key if no value has been explicitly set.
+ static var defaultValue: Value { get }
+}
diff --git a/Sources/XCSnapshotTesting/Environment/SnapshotEnvironmentValues.swift b/Sources/XCSnapshotTesting/Environment/SnapshotEnvironmentValues.swift
new file mode 100644
index 000000000..bfdec28eb
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Environment/SnapshotEnvironmentValues.swift
@@ -0,0 +1,312 @@
+import Foundation
+
+/// A collection of values that configure the snapshot testing environment.
+///
+/// `SnapshotEnvironmentValues` provides a type-safe way to store and retrieve configuration values for snapshot testing.
+/// It uses a subscript syntax with keys conforming to `SnapshotEnvironmentKey` to access and modify environment settings.
+///
+/// ```swift
+/// struct MyEnvironmentKey: SnapshotEnvironmentKey {
+/// typealias Value = Int
+/// static let defaultValue = 42
+/// }
+///
+/// try await withTestingEnvironment {
+/// $0[MyEnvironmentKey.self] = 100
+/// } operation: {
+/// print(SnapshotEnvironment.current[MyEnvironmentKey.self]) // prints 100
+/// }
+/// ```
+public struct SnapshotEnvironmentValues: Sendable {
+
+ @TaskLocal static var current: SnapshotEnvironmentValues?
+
+ private var values: [ObjectIdentifier: Sendable] = [:]
+
+ init() {}
+
+ /// Accesses or sets a configuration value associated with a specific key.
+ ///
+ /// This subscript provides type-safe access to configuration values stored in `SnapshotEnvironmentValues`.
+ /// It uses keys conforming to `SnapshotEnvironmentKey` to retrieve or modify values.
+ ///
+ /// - Parameter key: The type of key identifying the configuration value.
+ /// - Returns: The stored value for the provided key. If no value is set, returns the key's default value.
+ public subscript(_ key: Key.Type) -> Key.Value {
+ get {
+ values[ObjectIdentifier(key)] as? Key.Value ?? Key.defaultValue
+ }
+ set {
+ values[ObjectIdentifier(key)] = newValue
+ }
+ }
+}
+
+/// Temporarily modifies the testing environment for the duration of the specified operation.
+///
+/// This function allows you to safely mutate the testing environment within a defined scope. After the operation completes, the environment is restored to its original state.
+///
+/// - Parameters:
+/// - mutating: A closure that takes a mutable reference to `SnapshotEnvironmentValues` and modifies it as needed.
+/// - operation: The asynchronous operation to perform within the modified environment.
+/// - isolation: An optional actor used to isolate the operation. Defaults to the current isolation context.
+/// - file: The file name for diagnostic purposes. Defaults to the current file.
+/// - line: The line number for diagnostic purposes. Defaults to the current line.
+///
+/// - Returns: The result of the asynchronous operation.
+///
+/// - Throws: Any error thrown by the operation closure.
+///
+/// - Note: The changes made to the environment are only in effect for the duration of the operation.
+public func withTestingEnvironment(
+ _ mutating: @Sendable (inout SnapshotEnvironmentValues) -> Void,
+ operation: () async throws -> R,
+ isolation: isolated Actor? = #isolation,
+ file: String = #file,
+ line: UInt = #line
+) async rethrows -> R {
+ var argumentValues = SnapshotEnvironmentValues.current ?? SnapshotEnvironmentValues()
+ mutating(&argumentValues)
+ return try await SnapshotEnvironmentValues.$current.withValue(
+ argumentValues,
+ operation: operation,
+ isolation: isolation,
+ file: file,
+ line: line
+ )
+}
+
+/// Temporarily modifies the testing environment for the duration of the specified operation.
+///
+/// This function allows you to safely mutate the testing environment within a defined scope. After the operation completes, the environment is restored to its original state.
+///
+/// - Parameters:
+/// - mutating: A closure that takes a mutable reference to `SnapshotEnvironmentValues` and modifies it as needed.
+/// - operation: The synchronous operation to perform within the modified environment.
+/// - file: The file name for diagnostic purposes. Defaults to the current file.
+/// - line: The line number for diagnostic purposes. Defaults to the current line.
+///
+/// - Returns: The result of the synchronous operation.
+///
+/// - Throws: Any error thrown by the operation closure.
+///
+/// - Note: The changes made to the environment are only in effect for the duration of the operation.
+public func withTestingEnvironment(
+ _ mutating: @Sendable (inout SnapshotEnvironmentValues) -> Void,
+ operation: () throws -> R,
+ file: String = #file,
+ line: UInt = #line
+) rethrows -> R {
+ var argumentValues = SnapshotEnvironmentValues.current ?? SnapshotEnvironmentValues()
+ mutating(&argumentValues)
+ return try SnapshotEnvironmentValues.$current.withValue(
+ argumentValues,
+ operation: operation,
+ file: file,
+ line: line
+ )
+}
+
+/// Temporarily modifies the testing environment with specified parameters and executes an asynchronous operation within this modified context.
+///
+/// This function allows granular control over the testing environment for the duration of the specified asynchronous operation. It combines direct parameter customization (`record`, `diffTool`, etc.) with a closure for additional environment modifications.
+///
+/// - Parameters:
+/// - record: Optionally sets the recording mode for the operation.
+/// - diffTool: Optionally specifies a custom diff tool for the operation.
+/// - maxConcurrentTests: Optionally limits the number of concurrent tests.
+/// - platform: Optionally overrides the platform identifier for snapshot paths.
+/// - mutating: A closure that can further modify the `SnapshotEnvironmentValues`.
+/// - operation: The asynchronous operation to perform within the modified environment.
+/// - isolation: An optional actor used to isolate the operation. Defaults to the current isolation context.
+/// - file: The file name for diagnostic purposes. Defaults to the current file.
+/// - line: The line number for diagnostic purposes. Defaults to the current line.
+///
+/// - Returns: The result of the asynchronous operation.
+/// - Throws: Any error thrown by the operation closure.
+///
+/// - Note: The environment changes are only effective for the duration of the operation.
+public func withTestingEnvironment(
+ record: RecordMode? = nil,
+ diffTool: DiffTool? = nil,
+ maxConcurrentTests: Int? = nil,
+ platform: String? = nil,
+ environment mutating: (@Sendable (inout SnapshotEnvironmentValues) -> Void),
+ operation: () async throws -> R,
+ isolation: isolated Actor? = #isolation,
+ file: String = #file,
+ line: UInt = #line
+) async rethrows -> R {
+ try await withTestingEnvironment(
+ {
+ mutatingEnvironmentValues(
+ record: record,
+ diffTool: diffTool,
+ maxConcurrentTests: maxConcurrentTests,
+ platform: platform,
+ mutating: &$0
+ )
+ mutating(&$0)
+ },
+ operation: operation,
+ isolation: isolation,
+ file: file,
+ line: line
+ )
+}
+
+/// Temporarily modifies the testing environment with specified parameters and executes an asynchronous operation within this modified context.
+///
+/// This function allows quick configuration of common testing environment parameters for the duration of the specified asynchronous operation.
+///
+/// - Parameters:
+/// - record: Optionally sets the recording mode for the operation.
+/// - diffTool: Optionally specifies a custom diff tool for the operation.
+/// - maxConcurrentTests: Optionally limits the number of concurrent tests.
+/// - platform: Optionally overrides the platform identifier for snapshot paths.
+/// - operation: The asynchronous operation to perform within the modified environment.
+/// - isolation: An optional actor used to isolate the operation. Defaults to the current isolation context.
+/// - file: The file name for diagnostic purposes. Defaults to the current file.
+/// - line: The line number for diagnostic purposes. Defaults to the current line.
+///
+/// - Returns: The result of the asynchronous operation.
+/// - Throws: Any error thrown by the operation closure.
+///
+/// - Note: The environment changes are only effective for the duration of the operation.
+public func withTestingEnvironment(
+ record: RecordMode? = nil,
+ diffTool: DiffTool? = nil,
+ maxConcurrentTests: Int? = nil,
+ platform: String? = nil,
+ operation: () async throws -> R,
+ isolation: isolated Actor? = #isolation,
+ file: String = #file,
+ line: UInt = #line
+) async rethrows -> R {
+ try await withTestingEnvironment(
+ {
+ mutatingEnvironmentValues(
+ record: record,
+ diffTool: diffTool,
+ maxConcurrentTests: maxConcurrentTests,
+ platform: platform,
+ mutating: &$0
+ )
+ },
+ operation: operation,
+ isolation: isolation,
+ file: file,
+ line: line
+ )
+}
+
+/// Temporarily modifies the testing environment with specified parameters and executes a synchronous operation within this modified context.
+///
+/// This function allows granular control over the testing environment for the duration of the specified synchronous operation. It combines direct parameter customization (`record`, `diffTool`, etc.) with a closure for additional environment modifications.
+///
+/// - Parameters:
+/// - record: Optionally sets the recording mode for the operation.
+/// - diffTool: Optionally specifies a custom diff tool for the operation.
+/// - maxConcurrentTests: Optionally limits the number of concurrent tests.
+/// - platform: Optionally overrides the platform identifier for snapshot paths.
+/// - mutating: A closure that can further modify the `SnapshotEnvironmentValues`.
+/// - operation: The synchronous operation to perform within the modified environment.
+/// - file: The file name for diagnostic purposes. Defaults to the current file.
+/// - line: The line number for diagnostic purposes. Defaults to the current line.
+///
+/// - Returns: The result of the synchronous operation.
+/// - Throws: Any error thrown by the operation closure.
+///
+/// - Note: The environment changes are only effective for the duration of the operation.
+public func withTestingEnvironment(
+ record: RecordMode? = nil,
+ diffTool: DiffTool? = nil,
+ maxConcurrentTests: Int? = nil,
+ platform: String? = nil,
+ environment mutating: (@Sendable (inout SnapshotEnvironmentValues) -> Void),
+ operation: () throws -> R,
+ file: String = #file,
+ line: UInt = #line
+) rethrows -> R {
+ try withTestingEnvironment(
+ {
+ mutatingEnvironmentValues(
+ record: record,
+ diffTool: diffTool,
+ maxConcurrentTests: maxConcurrentTests,
+ platform: platform,
+ mutating: &$0
+ )
+ mutating(&$0)
+ },
+ operation: operation,
+ file: file,
+ line: line
+ )
+}
+
+/// Temporarily modifies the testing environment with specified parameters and executes a synchronous operation within this modified context.
+///
+/// This function allows quick configuration of common testing environment parameters for the duration of the specified synchronous operation.
+///
+/// - Parameters:
+/// - record: Optionally sets the recording mode for the operation.
+/// - diffTool: Optionally specifies a custom diff tool for the operation.
+/// - maxConcurrentTests: Optionally limits the number of concurrent tests.
+/// - platform: Optionally overrides the platform identifier for snapshot paths.
+/// - operation: The synchronous operation to perform within the modified environment.
+/// - file: The file name for diagnostic purposes. Defaults to the current file.
+/// - line: The line number for diagnostic purposes. Defaults to the current line.
+///
+/// - Returns: The result of the synchronous operation.
+/// - Throws: Any error thrown by the operation closure.
+///
+/// - Note: The environment changes are only effective for the duration of the operation.
+public func withTestingEnvironment(
+ record: RecordMode? = nil,
+ diffTool: DiffTool? = nil,
+ maxConcurrentTests: Int? = nil,
+ platform: String? = nil,
+ operation: () throws -> R,
+ file: String = #file,
+ line: UInt = #line
+) rethrows -> R {
+ try withTestingEnvironment(
+ {
+ mutatingEnvironmentValues(
+ record: record,
+ diffTool: diffTool,
+ maxConcurrentTests: maxConcurrentTests,
+ platform: platform,
+ mutating: &$0
+ )
+ },
+ operation: operation,
+ file: file,
+ line: line
+ )
+}
+
+private func mutatingEnvironmentValues(
+ record: RecordMode?,
+ diffTool: DiffTool?,
+ maxConcurrentTests: Int?,
+ platform: String?,
+ mutating environment: inout SnapshotEnvironmentValues
+) {
+ if let record {
+ environment.recordMode = record
+ }
+
+ if let diffTool {
+ environment.diffTool = diffTool
+ }
+
+ if let maxConcurrentTests {
+ environment.maxConcurrentTests = maxConcurrentTests
+ }
+
+ if let platform {
+ environment.platform = platform
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Exports.swift b/Sources/XCSnapshotTesting/Exports.swift
new file mode 100644
index 000000000..212156ccf
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Exports.swift
@@ -0,0 +1,3 @@
+#if !os(visionOS)
+@_exported import _SnapshotTesting
+#endif
diff --git a/Sources/XCSnapshotTesting/Extensions/CGImage+Extension.swift b/Sources/XCSnapshotTesting/Extensions/CGImage+Extension.swift
new file mode 100644
index 000000000..cec2b9eeb
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Extensions/CGImage+Extension.swift
@@ -0,0 +1,25 @@
+#if os(iOS) || os(tvOS) || os(macOS) || os(watchOS) || os(visionOS)
+import CoreGraphics
+
+extension CGImage {
+
+ func context(with data: UnsafeMutableRawPointer? = nil) -> CGContext? {
+ let bytesPerRow = self.width * ImageContext.bytesPerPixel
+ guard
+ let colorSpace = ImageContext.colorSpace,
+ let context = CGContext(
+ data: data,
+ width: self.width,
+ height: self.height,
+ bitsPerComponent: ImageContext.bitsPerComponent,
+ bytesPerRow: bytesPerRow,
+ space: colorSpace,
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ )
+ else { return nil }
+
+ context.draw(self, in: CGRect(x: 0, y: 0, width: self.width, height: self.height))
+ return context
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Extensions/CGSize+Extension.swift b/Sources/XCSnapshotTesting/Extensions/CGSize+Extension.swift
new file mode 100644
index 000000000..53ce68543
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Extensions/CGSize+Extension.swift
@@ -0,0 +1,92 @@
+#if os(iOS) || os(tvOS) || os(watchOS) || os(macOS) || os(visionOS)
+import CoreGraphics
+
+extension CGSize {
+
+ func scaleThatFits(_ size: CGSize) -> CGFloat {
+ if size.width <= width && size.height <= height {
+ return 1
+ }
+
+ let scaleWidth = width / size.width
+ let scaleHeight = height / size.height
+
+ return min(scaleWidth, scaleHeight)
+ }
+
+ func scaleToFit(_ size: CGSize) -> CGSize {
+ let scale = scaleThatFits(size)
+
+ return CGSize(
+ width: size.width * scale,
+ height: size.height * scale
+ )
+ }
+}
+
+extension CGRect {
+
+ func scale(by scale: CGFloat) -> CGRect {
+ guard scale != 1 else {
+ return self
+ }
+
+ let center = CGPoint(x: midX, y: midY)
+
+ let scaledSize = CGSize(
+ width: width * scale,
+ height: height * scale
+ )
+
+ return .init(
+ x: center.x - scaledSize.width / 2,
+ y: center.y - scaledSize.height / 2,
+ width: scaledSize.width,
+ height: scaledSize.height
+ )
+ }
+}
+
+extension CGSize {
+
+ /// 440 x 956
+ static let screen6_9 = CGSize(width: 440, height: 956)
+
+ /// 430 x 932
+ static let screen6_7v2 = CGSize(width: 430, height: 932)
+
+ /// 428 x 926
+ static let screen6_7v1 = CGSize(width: 428, height: 926)
+
+ /// 414 x 896
+ static let screen6_5 = CGSize(width: 414, height: 896)
+
+ /// 402 x 874
+ static let screen6_3 = CGSize(width: 402, height: 874)
+
+ /// 414 x 896
+ static let screen6_1v3 = CGSize(width: 414, height: 896)
+
+ /// 393 x 852
+ static let screen6_1v2 = CGSize(width: 393, height: 852)
+
+ /// 390 x 844
+ static let screen6_1v1 = CGSize(width: 390, height: 844)
+
+ /// 375 x 812
+ static let screen5_8 = CGSize(width: 375, height: 812)
+
+ /// 414 x 736
+ static let screen5_5 = CGSize(width: 414, height: 736)
+
+ /// 375 x 812
+ static let screen5_4 = CGSize(width: 375, height: 812)
+
+ /// 375 x 667
+ static let screen4_7 = CGSize(width: 375, height: 667)
+
+ func reflected() -> CGSize {
+ .init(width: height, height: width)
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Extensions/CIImage+Extension.swift b/Sources/XCSnapshotTesting/Extensions/CIImage+Extension.swift
new file mode 100644
index 000000000..c10acf18d
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Extensions/CIImage+Extension.swift
@@ -0,0 +1,201 @@
+#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS)
+import Accelerate.vImage
+import CoreImage.CIKernel
+import MetalPerformanceShaders
+@preconcurrency import Metal.MTLDevice
+
+extension CIImage {
+
+ func perceptuallyCompare(
+ _ newValue: CIImage,
+ pixelPrecision: Float,
+ perceptualPrecision: Float
+ ) -> String? {
+ // Calculate the deltaE values. Each pixel is a value between 0-100.
+ // 0 means no difference, 100 means completely opposite.
+ let deltaOutputImage = self.applyingLabDeltaE(newValue)
+ // Setting the working color space and output color space to NSNull disables color management. This is appropriate when the output
+ // of the operations is computational instead of an image intended to be displayed.
+ let context = CIContext(options: [.workingColorSpace: NSNull(), .outputColorSpace: NSNull()])
+ let deltaThreshold = (1 - perceptualPrecision) * 100
+ let actualPixelPrecision: Float
+ var maximumDeltaE: Float = 0
+
+ // Metal is supported by all iOS/tvOS devices (2013 models or later) and Macs (2012 models or later).
+ // Older devices do not support iOS/tvOS 13 and macOS 10.15 which are the minimum versions of swift-snapshot-testing.
+ // However, some virtualized hardware do not have GPUs and therefore do not support Metal.
+ // In this case, macOS falls back to a CPU-based OpenGL ES renderer that silently fails when a Metal command is issued.
+ // We need to check for Metal device support and fallback to CPU based vImage buffer iteration.
+ if ThresholdImageProcessorKernel.isSupported {
+ // Fast path - Metal processing
+ guard
+ let thresholdOutputImage = try? deltaOutputImage.applyingThreshold(deltaThreshold),
+ let averagePixel = thresholdOutputImage.applyingAreaAverage().renderSingleValue(
+ in: context
+ )
+ else {
+ return "Newly-taken snapshot's data could not be processed."
+ }
+ actualPixelPrecision = 1 - averagePixel
+ if actualPixelPrecision < pixelPrecision {
+ maximumDeltaE = deltaOutputImage.applyingAreaMaximum().renderSingleValue(in: context) ?? 0
+ }
+ } else {
+ // Slow path - CPU based vImage buffer iteration
+ guard let buffer = deltaOutputImage.render(in: context) else {
+ return "Newly-taken snapshot could not be processed."
+ }
+ defer { buffer.free() }
+ var failingPixelCount: Int = 0
+ // rowBytes must be a multiple of 8, so vImage_Buffer pads the end of each row with bytes to meet the multiple of 0 requirement.
+ // We must do 2D iteration of the vImage_Buffer in order to avoid loading the padding garbage bytes at the end of each row.
+ //
+ // NB: We are purposely using a verbose 'while' loop instead of a 'for in' loop. When the
+ // compiler doesn't have optimizations enabled, like in test targets, a `while` loop is
+ // significantly faster than a `for` loop for iterating through the elements of a memory
+ // buffer. Details can be found in [SR-6983](https://github.com/apple/swift/issues/49531)
+ let componentStride = MemoryLayout.stride
+ var line = 0
+ while line < buffer.height {
+ defer { line += 1 }
+ let lineOffset = buffer.rowBytes * line
+ var column = 0
+ while column < buffer.width {
+ defer { column += 1 }
+ let byteOffset = lineOffset + column * componentStride
+ let deltaE = buffer.data.load(fromByteOffset: byteOffset, as: Float.self)
+ if deltaE > deltaThreshold {
+ failingPixelCount += 1
+ if deltaE > maximumDeltaE {
+ maximumDeltaE = deltaE
+ }
+ }
+ }
+ }
+ let failingPixelPercent =
+ Float(failingPixelCount)
+ / Float(deltaOutputImage.extent.width * deltaOutputImage.extent.height)
+ actualPixelPrecision = 1 - failingPixelPercent
+ }
+
+ guard actualPixelPrecision < pixelPrecision else { return nil }
+ // The actual perceptual precision is the perceptual precision of the pixel with the highest DeltaE.
+ // DeltaE is in a 0-100 scale, so we need to divide by 100 to transform it to a percentage.
+ let minimumPerceptualPrecision = 1 - min(maximumDeltaE / 100, 1)
+ return """
+ The percentage of pixels that match \(actualPixelPrecision) is less than required \(pixelPrecision)
+ The lowest perceptual color precision \(minimumPerceptualPrecision) is less than required \(perceptualPrecision)
+ """
+ }
+}
+
+extension CIImage {
+
+ fileprivate func applyingLabDeltaE(_ other: CIImage) -> CIImage {
+ applyingFilter("CILabDeltaE", parameters: ["inputImage2": other])
+ }
+
+ fileprivate func applyingThreshold(_ threshold: Float) throws -> CIImage {
+ try ThresholdImageProcessorKernel.apply(
+ withExtent: extent,
+ inputs: [self],
+ arguments: [ThresholdImageProcessorKernel.inputThresholdKey: threshold]
+ )
+ }
+
+ fileprivate func applyingAreaAverage() -> CIImage {
+ applyingFilter("CIAreaAverage", parameters: [kCIInputExtentKey: extent])
+ }
+
+ fileprivate func applyingAreaMaximum() -> CIImage {
+ applyingFilter("CIAreaMaximum", parameters: [kCIInputExtentKey: extent])
+ }
+
+ fileprivate func renderSingleValue(in context: CIContext) -> Float? {
+ guard let buffer = render(in: context) else { return nil }
+ defer { buffer.free() }
+ return buffer.data.load(fromByteOffset: 0, as: Float.self)
+ }
+
+ fileprivate func render(in context: CIContext, format: CIFormat = CIFormat.Rh) -> vImage_Buffer? {
+ // Some hardware configurations (virtualized CPU renderers) do not support 32-bit float output formats,
+ // so use a compatible 16-bit float format and convert the output value to 32-bit floats.
+ guard
+ var buffer16 = try? vImage_Buffer(
+ width: Int(extent.width),
+ height: Int(extent.height),
+ bitsPerPixel: 16
+ )
+ else { return nil }
+ defer { buffer16.free() }
+ context.render(
+ self,
+ toBitmap: buffer16.data,
+ rowBytes: buffer16.rowBytes,
+ bounds: extent,
+ format: format,
+ colorSpace: nil
+ )
+ guard
+ var buffer32 = try? vImage_Buffer(
+ width: Int(buffer16.width),
+ height: Int(buffer16.height),
+ bitsPerPixel: 32
+ ),
+ vImageConvert_Planar16FtoPlanarF(&buffer16, &buffer32, 0) == kvImageNoError
+ else { return nil }
+ return buffer32
+ }
+}
+
+// Copied from https://developer.apple.com/documentation/coreimage/ciimageprocessorkernel
+private final class ThresholdImageProcessorKernel: CIImageProcessorKernel {
+ static let inputThresholdKey = "thresholdValue"
+ static let device = MTLCreateSystemDefaultDevice()
+
+ static var isSupported: Bool {
+ guard let device = device else {
+ return false
+ }
+
+ #if targetEnvironment(simulator)
+ guard #available(iOS 14.0, tvOS 14.0, *) else {
+ // The MPSSupportsMTLDevice method throws an exception on iOS/tvOS simulators < 14.0
+ return false
+ }
+ #endif
+
+ return MPSSupportsMTLDevice(device)
+ }
+
+ override class func process(
+ with inputs: [CIImageProcessorInput]?,
+ arguments: [String: Any]?,
+ output: CIImageProcessorOutput
+ ) throws {
+ guard
+ let device = device,
+ let commandBuffer = output.metalCommandBuffer,
+ let input = inputs?.first,
+ let sourceTexture = input.metalTexture,
+ let destinationTexture = output.metalTexture,
+ let thresholdValue = arguments?[inputThresholdKey] as? Float
+ else {
+ return
+ }
+
+ let threshold = MPSImageThresholdBinary(
+ device: device,
+ thresholdValue: thresholdValue,
+ maximumValue: 1.0,
+ linearGrayColorTransform: nil
+ )
+
+ threshold.encode(
+ commandBuffer: commandBuffer,
+ sourceTexture: sourceTexture,
+ destinationTexture: destinationTexture
+ )
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Extensions/NSLayoutConstraint+Extension.swift b/Sources/XCSnapshotTesting/Extensions/NSLayoutConstraint+Extension.swift
new file mode 100644
index 000000000..a7fabf451
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Extensions/NSLayoutConstraint+Extension.swift
@@ -0,0 +1,26 @@
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+#elseif os(macOS)
+import AppKit
+#endif
+
+#if os(tvOS) || os(macOS) || os(iOS) || os(visionOS)
+extension NSLayoutConstraint {
+
+ func storing(in constraints: inout [NSLayoutConstraint]) -> NSLayoutConstraint {
+ constraints.append(self)
+ return self
+ }
+}
+
+extension NSLayoutConstraint {
+
+ static func activate(
+ _ constraints: [NSLayoutConstraint],
+ storingAt store: inout [NSLayoutConstraint]
+ ) {
+ activate(constraints)
+ store.append(contentsOf: constraints)
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Extensions/ProcessInfo+Extension.swift b/Sources/XCSnapshotTesting/Extensions/ProcessInfo+Extension.swift
new file mode 100644
index 000000000..26cb92555
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Extensions/ProcessInfo+Extension.swift
@@ -0,0 +1,19 @@
+import Foundation
+
+extension ProcessInfo {
+
+ static var artifactsDirectory: URL {
+ let env = ProcessInfo.processInfo.environment
+
+ return URL(
+ fileURLWithPath: env["SNAPSHOT_ARTIFACTS"] ?? NSTemporaryDirectory(),
+ isDirectory: true
+ )
+ }
+
+ static var isXcode: Bool {
+ ProcessInfo.processInfo.environment.keys.contains(
+ "__XCODE_BUILT_PRODUCTS_DIR_PATHS"
+ )
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Extensions/String+Extension.swift b/Sources/XCSnapshotTesting/Extensions/String+Extension.swift
new file mode 100644
index 000000000..d07a42c44
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Extensions/String+Extension.swift
@@ -0,0 +1,53 @@
+import Foundation
+
+#if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) || os(visionOS)
+import CoreServices
+import UniformTypeIdentifiers
+#endif
+
+extension String {
+
+ func sanitizingPointersReferences() -> String {
+ replacingOccurrences(
+ of: ":?\\s*0x[\\da-f]+(\\s*)",
+ with: "$1",
+ options: .regularExpression
+ )
+ }
+
+ func sanitizingPathComponent() -> String {
+ // see for ressoning on charachrer sets https://superuser.com/a/358861
+ let invalidCharacters = CharacterSet(charactersIn: "\\/:*?\"<>|")
+ .union(.newlines)
+ .union(.illegalCharacters)
+ .union(.controlCharacters)
+
+ return
+ self
+ // Only applies to functions without parameters
+ .replacingOccurrences(of: "()", with: "")
+ .components(separatedBy: invalidCharacters)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .joined(separator: "")
+ }
+
+ #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) || os(visionOS)
+ func uniformTypeIdentifier() -> String? {
+ #if os(visionOS)
+ return UTType(filenameExtension: self)?.identifier
+ #else
+ if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) {
+ return UTType(filenameExtension: self)?.identifier
+ }
+
+ let unmanagedString = UTTypeCreatePreferredIdentifierForTag(
+ kUTTagClassFilenameExtension as CFString,
+ self as CFString,
+ nil
+ )
+
+ return unmanagedString?.takeRetainedValue() as String?
+ #endif
+ }
+ #endif
+}
diff --git a/Sources/XCSnapshotTesting/Extensions/Task+Extension.swift b/Sources/XCSnapshotTesting/Extensions/Task+Extension.swift
new file mode 100644
index 000000000..9be92f426
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Extensions/Task+Extension.swift
@@ -0,0 +1,49 @@
+import Foundation
+
+extension Task where Failure == Error {
+
+ public static func timeout(
+ _ timeout: TimeInterval,
+ execute closure: @Sendable @escaping () async throws -> Success
+ ) async throws -> Success {
+ try await withUnsafeThrowingContinuation { continuation in
+ let regularTask = Task {
+ let result: Result
+
+ do {
+ result = .success(try await closure())
+ } catch {
+ result = .failure(error)
+ }
+
+ do {
+ try Task.checkCancellation()
+ } catch {
+ continuation.resume(throwing: error)
+ return
+ }
+
+ continuation.resume(with: result)
+ }
+
+ let timeoutTask = Task {
+ try await Task.sleep(
+ nanoseconds: UInt64(timeout) * 1_000_000_000
+ )
+ continuation.resume(throwing: TaskTimeout())
+ }
+
+ Task {
+ _ = try? await timeoutTask.value
+ timeoutTask.cancel()
+ }
+
+ Task {
+ _ = await regularTask.value
+ timeoutTask.cancel()
+ }
+ }
+ }
+}
+
+public struct TaskTimeout: Error {}
diff --git a/Sources/XCSnapshotTesting/Extensions/UIApplication+Extension.swift b/Sources/XCSnapshotTesting/Extensions/UIApplication+Extension.swift
new file mode 100644
index 000000000..361fca2f4
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Extensions/UIApplication+Extension.swift
@@ -0,0 +1,81 @@
+#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
+import UIKit
+#elseif os(macOS)
+@preconcurrency import AppKit
+#endif
+
+#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS)
+@MainActor
+extension SDKApplication {
+
+ static var sharedIfAvailable: SDKApplication? {
+ let sharedSelector = NSSelectorFromString("sharedApplication")
+ guard SDKApplication.responds(to: sharedSelector) else {
+ return nil
+ }
+
+ let shared = SDKApplication.perform(sharedSelector)
+ return shared?.takeUnretainedValue() as! SDKApplication?
+ }
+
+ #if os(iOS) || os(tvOS) || os(visionOS)
+ func windowScenes(for role: UISceneSession.Role) -> [UIWindowScene] {
+ connectedScenes.lazy
+ .filter { $0.session.role == role }
+ .compactMap { $0 as? UIWindowScene }
+ }
+ #endif
+}
+
+#if os(iOS) || os(tvOS) || os(visionOS)
+extension [UIWindowScene] {
+
+ @MainActor
+ var keyWindows: [SDKWindow] {
+ self.lazy
+ .filter { $0.session.role == .windowApplication }
+ .reduce([]) {
+ $0 + $1.windows.filter(\.isKeyWindow)
+ }
+ }
+}
+#endif
+
+@MainActor
+private var kUIApplicationLock = 0
+
+@MainActor
+extension SDKApplication {
+
+ private var lock: AsyncLock {
+ if let lock = objc_getAssociatedObject(self, &kUIApplicationLock) as? AsyncLock {
+ return lock
+ }
+
+ let lock = AsyncLock()
+ objc_setAssociatedObject(self, &kUIApplicationLock, lock, .OBJC_ASSOCIATION_RETAIN)
+ return lock
+ }
+
+ fileprivate func withLock(
+ _ body: @Sendable () async throws -> Value
+ ) async throws -> Value {
+ try await lock.withLock(body)
+ }
+}
+
+extension AsyncSnapshot {
+
+ func withLock() -> AsyncSnapshot<
+ Input, Output
+ > where Executor == Async {
+ map { executor in
+ Async(Input.self) { application in
+ try await application.withLock {
+ try await executor(application)
+ }
+ }
+ }
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Extensions/UIImage+Extension.swift b/Sources/XCSnapshotTesting/Extensions/UIImage+Extension.swift
new file mode 100644
index 000000000..106c19bf7
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Extensions/UIImage+Extension.swift
@@ -0,0 +1,199 @@
+#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
+import UIKit
+#elseif os(macOS)
+@preconcurrency import AppKit
+#endif
+
+#if os(iOS) || os(tvOS) || os(visionOS)
+typealias SDKImage = UIKit.UIImage
+typealias SDKLabel = UIKit.UILabel
+typealias SDKView = UIKit.UIView
+typealias SDKViewController = UIKit.UIViewController
+typealias SDKApplication = UIKit.UIApplication
+typealias SDKWindow = UIKit.UIWindow
+#elseif os(watchOS)
+typealias SDKImage = UIKit.UIImage
+#elseif os(macOS)
+typealias SDKImage = AppKit.NSImage
+typealias SDKLabel = AppKit.NSText
+typealias SDKView = AppKit.NSView
+typealias SDKViewController = AppKit.NSViewController
+typealias SDKApplication = AppKit.NSApplication
+typealias SDKWindow = AppKit.NSWindow
+#endif
+
+#if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || os(macOS)
+extension SDKImage {
+
+ #if os(macOS)
+ var scale: CGFloat {
+ 1.0
+ }
+
+ var cgImage: CGImage? {
+ guard
+ let pngData = pngData(),
+ let dataProvider = CGDataProvider(data: pngData as CFData)
+ else { return nil }
+
+ return CGImage(
+ pngDataProviderSource: dataProvider,
+ decode: nil,
+ shouldInterpolate: true,
+ intent: .defaultIntent
+ )
+ }
+
+ func pngData() -> Data? {
+ performOnMainThread {
+ guard
+ let bitmapRep = NSBitmapImageRep(
+ bitmapDataPlanes: nil,
+ pixelsWide: Int(size.width),
+ pixelsHigh: Int(size.height),
+ bitsPerSample: 8,
+ samplesPerPixel: 4,
+ hasAlpha: true,
+ isPlanar: false,
+ colorSpaceName: .calibratedRGB,
+ bytesPerRow: 0,
+ bitsPerPixel: 0
+ ),
+ let context = NSGraphicsContext(bitmapImageRep: bitmapRep)
+ else { return nil }
+
+ NSGraphicsContext.saveGraphicsState()
+ NSGraphicsContext.current = context
+ draw(in: NSRect(origin: .zero, size: size))
+ NSGraphicsContext.restoreGraphicsState()
+
+ return bitmapRep.representation(using: .png, properties: [:])
+ }
+ }
+ #endif
+
+ /// Used when the image size has no width or no height to generated the default empty image
+ @MainActor
+ static var empty: SDKImage {
+ #if os(iOS) || os(tvOS) || os(macOS)
+ let label = SDKLabel(frame: CGRect(x: 0, y: 0, width: 400, height: 80))
+ let text =
+ "Error: No image could be generated for this view as its size was zero. Please set an explicit size in the test."
+ label.backgroundColor = .red
+ #if os(macOS)
+ label.string = text
+ label.alignment = .center
+ label.isVerticallyResizable = true
+ #else
+ label.text = text
+ label.textAlignment = .center
+ label.numberOfLines = 3
+ #endif
+ return label.asImage()
+ #else
+ return SDKImage()
+ #endif
+ }
+
+ @MainActor
+ func substract(_ image: SDKImage) -> SDKImage {
+ #if os(macOS)
+ guard let lhsImage = cgImage, let rhsImage = image.cgImage else {
+ return SDKImage()
+ }
+
+ let oldCiImage = CIImage(cgImage: lhsImage)
+ let newCiImage = CIImage(cgImage: rhsImage)
+ let differenceFilter = CIFilter(name: "CIDifferenceBlendMode")!
+ differenceFilter.setValue(oldCiImage, forKey: kCIInputImageKey)
+ differenceFilter.setValue(newCiImage, forKey: kCIInputBackgroundImageKey)
+ let maxSize = CGSize(
+ width: max(size.width, image.size.width),
+ height: max(size.height, image.size.height)
+ )
+ let rep = NSCIImageRep(ciImage: differenceFilter.outputImage!)
+ let difference = NSImage(size: maxSize)
+ difference.addRepresentation(rep)
+ return difference
+ #else
+ let width = max(self.size.width, image.size.width)
+ let height = max(self.size.height, image.size.height)
+ let scale = max(self.scale, image.scale)
+ let size = CGSize(width: width, height: height)
+ UIGraphicsBeginImageContextWithOptions(CGSize(width: width, height: height), true, scale)
+ image.draw(in: .init(origin: .zero, size: size))
+ self.draw(in: .init(origin: .zero, size: size), blendMode: .difference, alpha: 1)
+ let differenceImage = UIGraphicsGetImageFromCurrentImageContext()!
+ UIGraphicsEndImageContext()
+ return differenceImage
+ #endif
+ }
+
+ @MainActor
+ func compare(_ newValue: SDKImage, precision: Float, perceptualPrecision: Float) -> String? {
+ guard let oldCgImage = self.cgImage else {
+ return "Reference image could not be loaded."
+ }
+ guard let newCgImage = newValue.cgImage else {
+ return "Newly-taken snapshot could not be loaded."
+ }
+ guard newCgImage.width != 0, newCgImage.height != 0 else {
+ return "Newly-taken snapshot is empty."
+ }
+ guard oldCgImage.width == newCgImage.width, oldCgImage.height == newCgImage.height else {
+ return "Newly-taken snapshot@\(newValue.size) does not match reference@\(self.size)."
+ }
+ let pixelCount = oldCgImage.width * oldCgImage.height
+ let byteCount = ImageContext.bytesPerPixel * pixelCount
+ var oldBytes = [UInt8](repeating: 0, count: byteCount)
+ guard let oldData = oldCgImage.context(with: &oldBytes)?.data else {
+ return "Reference image's data could not be loaded."
+ }
+ if let newContext = newCgImage.context(), let newData = newContext.data {
+ if memcmp(oldData, newData, byteCount) == 0 { return nil }
+ }
+ var newerBytes = [UInt8](repeating: 0, count: byteCount)
+ guard
+ let pngData = newValue.pngData(),
+ let newerCgImage = SDKImage(data: pngData)?.cgImage,
+ let newerContext = newerCgImage.context(with: &newerBytes),
+ let newerData = newerContext.data
+ else {
+ return "Newly-taken snapshot's data could not be loaded."
+ }
+ if memcmp(oldData, newerData, byteCount) == 0 { return nil }
+ if precision >= 1, perceptualPrecision >= 1 {
+ return "Newly-taken snapshot does not match reference."
+ }
+ #if os(iOS) || os(tvOS) || os(macOS)
+ if perceptualPrecision < 1, #available(iOS 11.0, tvOS 11.0, *) {
+ return CIImage(cgImage: oldCgImage).perceptuallyCompare(
+ CIImage(cgImage: newCgImage),
+ pixelPrecision: precision,
+ perceptualPrecision: perceptualPrecision
+ )
+ }
+ #endif
+
+ let byteCountThreshold = Int((1 - precision) * Float(byteCount))
+ var differentByteCount = 0
+ // NB: We are purposely using a verbose 'while' loop instead of a 'for in' loop. When the
+ // compiler doesn't have optimizations enabled, like in test targets, a `while` loop is
+ // significantly faster than a `for` loop for iterating through the elements of a memory
+ // buffer. Details can be found in [SR-6983](https://github.com/apple/swift/issues/49531)
+ var index = 0
+ while index < byteCount {
+ defer { index += 1 }
+ if oldBytes[index] != newerBytes[index] {
+ differentByteCount += 1
+ }
+ }
+ if differentByteCount > byteCountThreshold {
+ let actualPrecision = 1 - Float(differentByteCount) / Float(byteCount)
+ return "Actual image precision \(actualPrecision) is less than required \(precision)"
+ }
+
+ return nil
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Extensions/UIView+Extension.swift b/Sources/XCSnapshotTesting/Extensions/UIView+Extension.swift
new file mode 100644
index 000000000..50dbd56db
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Extensions/UIView+Extension.swift
@@ -0,0 +1,175 @@
+#if os(macOS)
+import AppKit
+#elseif os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+#endif
+
+#if os(macOS) || os(iOS) || os(visionOS)
+import WebKit
+#endif
+
+#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS)
+
+@MainActor
+extension SDKView {
+
+ func asImage() -> SDKImage {
+ #if os(macOS)
+ guard let rep = bitmapImageRepForCachingDisplay(in: bounds) else {
+ return SDKImage()
+ }
+
+ cacheDisplay(in: bounds, to: rep)
+
+ let image = SDKImage(size: bounds.size)
+ image.addRepresentation(rep)
+
+ return image
+ #else
+ let renderer = UIGraphicsImageRenderer(bounds: bounds)
+ return renderer.image { rendererContext in
+ layer.render(in: rendererContext.cgContext)
+ }
+ #endif
+ }
+
+ func withController() -> SDKViewController {
+ UIViewHostingController(self)
+ }
+
+ func waitLoadingStateIfNeeded(tolerance: TimeInterval) async {
+ #if os(iOS) || os(macOS) || os(visionOS)
+ if let webView = self as? WKWebView {
+ try? await webView.waitLoadingState(tolerance: tolerance)
+ return
+ }
+
+ for subview in subviews {
+ await subview.waitLoadingStateIfNeeded(tolerance: tolerance)
+ }
+ #endif
+ }
+
+ func recursiveNeedsLayout() {
+ guard window != nil else {
+ return
+ }
+
+ invalidateIntrinsicContentSize()
+ #if os(macOS)
+ needsUpdateConstraints = true
+ needsLayout = true
+ #else
+ setNeedsUpdateConstraints()
+ setNeedsLayout()
+ #endif
+
+ switch self {
+ #if os(macOS)
+ #else
+ case is UITableView, is UICollectionView:
+ break
+ #endif
+ default:
+ for view in subviews {
+ view.recursiveNeedsLayout()
+ }
+ }
+ }
+}
+
+@MainActor
+private class UIViewHostingController: SDKViewController {
+
+ private let contentView: SDKView
+
+ init(_ contentView: SDKView) {
+ self.contentView = contentView
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ nil
+ }
+
+ override func loadView() {
+ view = contentView
+ }
+}
+
+@MainActor
+private var kUIViewLock = 0
+
+@MainActor
+extension SDKView {
+
+ private var lock: AsyncLock {
+ if let lock = objc_getAssociatedObject(self, &kUIViewLock) as? AsyncLock {
+ return lock
+ }
+
+ let lock = AsyncLock()
+ objc_setAssociatedObject(self, &kUIViewLock, lock, .OBJC_ASSOCIATION_RETAIN)
+ return lock
+ }
+
+ fileprivate func withLock(
+ _ body: @Sendable () async throws -> Value
+ ) async throws -> Value {
+ try await lock.withLock(body)
+ }
+}
+
+extension Snapshot {
+
+ func withLock() -> AsyncSnapshot
+ where Executor == Async {
+ map { executor in
+ Async(Input.self) { view in
+ try await view.withLock {
+ try await executor(view)
+ }
+ }
+ }
+ }
+}
+#endif
+
+#if os(iOS) || os(tvOS) || os(visionOS)
+@MainActor
+private var kUIViewTraits = 0
+
+extension SDKView {
+
+ private var traits: Traits? {
+ get { objc_getAssociatedObject(self, &kUIViewTraits) as? Traits }
+ set {
+ objc_setAssociatedObject(
+ self,
+ &kUIViewTraits,
+ newValue,
+ .OBJC_ASSOCIATION_RETAIN
+ )
+ }
+ }
+
+ func inconsistentTraitsChecker(for traits: Traits) {
+ defer { self.traits = traits }
+ self.traits?.inconsistentTraitsChecker(self, to: traits)
+ }
+}
+
+extension Snapshot {
+
+ func inconsistentTraitsChecker(
+ _ traits: Traits
+ ) -> AsyncSnapshot where Executor == Async {
+ map { executor in
+ Async(Input.self) { @MainActor in
+ $0.inconsistentTraitsChecker(for: traits)
+ return try await executor($0)
+ }
+ }
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Extensions/UIViewController+Extension.swift b/Sources/XCSnapshotTesting/Extensions/UIViewController+Extension.swift
new file mode 100644
index 000000000..422359f3b
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Extensions/UIViewController+Extension.swift
@@ -0,0 +1,84 @@
+#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
+import UIKit
+#elseif os(macOS)
+@preconcurrency import AppKit
+#endif
+
+#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS)
+@MainActor
+private var kUIViewControllerLock = 0
+
+@MainActor
+extension SDKViewController {
+
+ private var lock: AsyncLock {
+ if let lock = objc_getAssociatedObject(self, &kUIViewControllerLock) as? AsyncLock {
+ return lock
+ }
+
+ let lock = AsyncLock()
+ objc_setAssociatedObject(self, &kUIViewControllerLock, lock, .OBJC_ASSOCIATION_RETAIN)
+ return lock
+ }
+
+ fileprivate func withLock(
+ _ body: @Sendable () async throws -> Value
+ ) async throws -> Value {
+ try await lock.withLock(body)
+ }
+}
+
+extension Snapshot {
+
+ func withLock() -> AsyncSnapshot<
+ Input, Output
+ > where Executor == Async {
+ map { executor in
+ Async(Input.self) { view in
+ try await view.withLock {
+ try await executor(view)
+ }
+ }
+ }
+ }
+}
+#endif
+
+#if os(iOS) || os(tvOS) || os(visionOS)
+@MainActor
+private var kUIViewControllerTraits = 0
+
+extension SDKViewController {
+
+ private var traits: Traits? {
+ get { objc_getAssociatedObject(self, &kUIViewControllerTraits) as? Traits }
+ set {
+ objc_setAssociatedObject(
+ self,
+ &kUIViewControllerTraits,
+ newValue,
+ .OBJC_ASSOCIATION_RETAIN
+ )
+ }
+ }
+
+ func inconsistentTraitsChecker(for traits: Traits) {
+ defer { self.traits = traits }
+ self.traits?.inconsistentTraitsChecker(self, to: traits)
+ }
+}
+
+extension Snapshot {
+
+ func inconsistentTraitsChecker(
+ _ traits: Traits
+ ) -> AsyncSnapshot where Executor == Async {
+ map { executor in
+ Async(Input.self) { @MainActor in
+ $0.inconsistentTraitsChecker(for: traits)
+ return try await executor($0)
+ }
+ }
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Extensions/UIWindow+Extension.swift b/Sources/XCSnapshotTesting/Extensions/UIWindow+Extension.swift
new file mode 100644
index 000000000..b71a738c9
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Extensions/UIWindow+Extension.swift
@@ -0,0 +1,64 @@
+#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
+import UIKit
+#elseif os(macOS)
+@preconcurrency import AppKit
+#endif
+
+#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS)
+@MainActor
+extension SDKWindow {
+
+ @discardableResult
+ func removeRootViewController() -> SDKViewController? {
+ #if os(macOS)
+ if let contentViewController {
+ for presentedViewController in contentViewController.presentedViewControllers ?? [] {
+ contentViewController.dismiss(presentedViewController)
+ }
+
+ contentViewController.view.removeFromSuperview()
+ self.contentViewController = nil
+ return contentViewController
+ }
+ #else
+ if let rootViewController {
+ // Allow the view controller to be deallocated
+ rootViewController.dismiss(animated: false) {
+ // Remove the root view in case its still showing
+ rootViewController.view.removeFromSuperview()
+ }
+
+ self.rootViewController = nil
+
+ for subview in subviews {
+ subview.removeFromSuperview()
+ }
+
+ return rootViewController
+ }
+ #endif
+ return nil
+ }
+
+ @discardableResult
+ func switchRoot(
+ _ viewController: SDKViewController
+ ) -> SDKViewController? {
+ #if os(macOS)
+ let previousRootViewController = removeRootViewController()
+ contentViewController = viewController
+ #else
+ let previousRootViewController = removeRootViewController()
+ rootViewController = viewController
+
+ #if !os(tvOS) && !os(visionOS)
+ viewController.setNeedsStatusBarAppearanceUpdate()
+ #endif
+ setNeedsLayout()
+ layoutIfNeeded()
+ safeAreaInsetsDidChange()
+ #endif
+ return previousRootViewController
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Extensions/WKWebView+Extension.swift b/Sources/XCSnapshotTesting/Extensions/WKWebView+Extension.swift
new file mode 100644
index 000000000..6e3ca1d9e
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Extensions/WKWebView+Extension.swift
@@ -0,0 +1,12 @@
+#if os(iOS) || os(macOS) || os(visionOS)
+import WebKit
+
+extension WKWebView {
+
+ func waitLoadingState(tolerance: TimeInterval) async throws {
+ repeat {
+ try await Task.sleep(nanoseconds: UInt64(tolerance * 1_000_000_000))
+ } while isLoading
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/Snapshot+Any.swift b/Sources/XCSnapshotTesting/Methods/Snapshot+Any.swift
new file mode 100644
index 000000000..bd24e7355
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/Snapshot+Any.swift
@@ -0,0 +1,68 @@
+import Foundation
+
+extension SyncSnapshot where Output == StringBytes {
+ /// A snapshot strategy that captures a value's textual description from `String`'s
+ /// `init(describing:)` initializer.
+ ///
+ /// ``` swift
+ /// try assert(of: user, as: .description)
+ /// ```
+ ///
+ /// Records:
+ ///
+ /// ```
+ /// User(bio: "Blobbed around the world.", id: 1, name: "Blobby")
+ /// ```
+ public static var description: SyncSnapshot {
+ IdentitySyncSnapshot.lines.pullback {
+ String(describing: $0)
+ }
+ }
+}
+
+extension SyncSnapshot where Output == StringBytes {
+ /// A snapshot strategy for comparing any structure based on their JSON representation.
+ ///
+ /// This strategy serializes the input value into a JSON-formatted string using
+ /// `JSONSerialization` with the following options:
+ /// - `.prettyPrinted` for human-readable formatting
+ /// - `.sortedKeys` to ensure consistent key ordering
+ /// - `.fragmentsAllowed` for partial JSON outputs
+ ///
+ /// The result is a `StringBytes` snapshot containing the JSON-encoded data.
+ ///
+ /// - Available on: macOS 10.13+, watchOS 4.0+, tvOS 11.0+
+ /// - Example: `assert(of: user, as: .json)`
+ public static var json: SyncSnapshot {
+ let options: JSONSerialization.WritingOptions = [
+ .prettyPrinted,
+ .sortedKeys,
+ .fragmentsAllowed,
+ ]
+
+ let snapshot = IdentitySyncSnapshot.lines.pullback { (data: Input) in
+ try String(
+ decoding: JSONSerialization.data(
+ withJSONObject: data,
+ options: options
+ ),
+ as: UTF8.self
+ )
+ }
+
+ return .init(
+ pathExtension: "json",
+ attachmentGenerator: snapshot.attachmentGenerator,
+ executor: snapshot.executor
+ )
+ }
+}
+
+private let snapshotDateFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
+ formatter.calendar = Calendar(identifier: .gregorian)
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(abbreviation: "UTC")
+ return formatter
+}()
diff --git a/Sources/XCSnapshotTesting/Methods/Snapshot+CaseIterable.swift b/Sources/XCSnapshotTesting/Methods/Snapshot+CaseIterable.swift
new file mode 100644
index 000000000..6ec1dd540
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/Snapshot+CaseIterable.swift
@@ -0,0 +1,71 @@
+extension SyncSnapshot where Input: CaseIterable & Sendable, Output == StringBytes, Input.AllCases: Sendable {
+ /// A strategy for snapshot the output for every input of a function. The format of the
+ /// snapshot is a comma-separated value (CSV) file that shows the mapping of inputs to outputs.
+ ///
+ /// - Parameter witness: A snapshot value on the output of the function to be snapshot.
+ /// - Returns: A snapshot strategy on functions `(Value) -> A` that feeds every possible input
+ /// into the function and records the output into a CSV file.
+ ///
+ /// ```swift
+ /// enum Direction: String, CaseIterable {
+ /// case up, down, left, right
+ /// var rotatedLeft: Direction {
+ /// switch self {
+ /// case .up: return .left
+ /// case .down: return .right
+ /// case .left: return .down
+ /// case .right: return .up
+ /// }
+ /// }
+ /// }
+ ///
+ /// try assert(
+ /// of: \Direction.rotatedLeft,
+ /// as: .func(into: .description)
+ /// )
+ /// ```
+ ///
+ /// Records:
+ ///
+ /// ```csv
+ /// "up","left"
+ /// "down","right"
+ /// "left","down"
+ /// "right","up"
+ /// ```
+ public static func `func`(
+ into witness: SyncSnapshot
+ ) -> SyncSnapshot<@Sendable (Input) -> A, Output> {
+ let snapshot = IdentitySyncSnapshot.lines.map { executor in
+ executor.pullback { (f: @escaping @Sendable (Input) -> A, continuation) in
+ Input.allCases.map { input in
+ Sync { (f: @escaping @Sendable (Input) -> A, continuation) in
+ witness.executor(f(input)) { result in
+ switch result {
+ case .success(let output):
+ continuation.resume(returning: (input, output))
+ case .failure(let error):
+ continuation.resume(throwing: error)
+ }
+ }
+ }
+ }
+ .sequence()
+ .map {
+ $0
+ .map { "\"\($0)\",\"\($1)\"" }
+ .joined(separator: "\n")
+ }
+ .callAsFunction(f) {
+ continuation.resume(with: $0)
+ }
+ }
+ }
+
+ return .init(
+ pathExtension: "csv",
+ attachmentGenerator: snapshot.attachmentGenerator,
+ executor: snapshot.executor
+ )
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Methods/Snapshot+Encodable.swift b/Sources/XCSnapshotTesting/Methods/Snapshot+Encodable.swift
new file mode 100644
index 000000000..098cd6271
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/Snapshot+Encodable.swift
@@ -0,0 +1,91 @@
+import Foundation
+
+extension SyncSnapshot where Input: Encodable & Sendable, Output == StringBytes {
+ /// A snapshot strategy for comparing encodable structures based on their JSON representation.
+ ///
+ /// ```swift
+ /// try assert(of: user, as: .json)
+ /// ```
+ ///
+ /// Records:
+ ///
+ /// ```json
+ /// {
+ /// "bio" : "Blobbed around the world.",
+ /// "id" : 1,
+ /// "name" : "Blobby"
+ /// }
+ /// ```
+ public static var json: SyncSnapshot {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ return .json(encoder)
+ }
+
+ /// A snapshot strategy for comparing encodable structures based on their JSON representation.
+ ///
+ /// - Parameter encoder: A JSON encoder.
+ public static func json(_ encoder: JSONEncoder) -> SyncSnapshot {
+ let snapshot = IdentitySyncSnapshot.lines.pullback { (encodable: Input) in
+ try String(
+ decoding: encoder.encode(encodable),
+ as: UTF8.self
+ )
+ }
+
+ return .init(
+ pathExtension: "json",
+ attachmentGenerator: snapshot.attachmentGenerator,
+ executor: snapshot.executor
+ )
+ }
+
+ /// A snapshot strategy for comparing encodable structures based on their property list
+ /// representation.
+ ///
+ /// ```swift
+ /// try assert(of: user, as: .plist)
+ /// ```
+ ///
+ /// Records:
+ ///
+ /// ```xml
+ ///
+ ///
+ ///
+ ///
+ /// bio
+ /// Blobbed around the world.
+ /// id
+ /// 1
+ /// name
+ /// Blobby
+ ///
+ ///
+ /// ```
+ public static var plist: SyncSnapshot {
+ let encoder = Foundation.PropertyListEncoder()
+ encoder.outputFormat = .xml
+ return .plist(encoder)
+ }
+
+ /// A snapshot strategy for comparing encodable structures based on their property list
+ /// representation.
+ ///
+ /// - Parameter encoder: A property list encoder.
+ public static func plist(_ encoder: Foundation.PropertyListEncoder) -> SyncSnapshot {
+ let snapshot = IdentitySyncSnapshot.lines.pullback { (encodable: Input) in
+ try String(
+ decoding: encoder.encode(encodable),
+ as: UTF8.self
+ )
+ }
+
+ return .init(
+ pathExtension: "plist",
+ attachmentGenerator: snapshot.attachmentGenerator,
+ executor: snapshot.executor
+ )
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Methods/Snapshot+URLRequest.swift b/Sources/XCSnapshotTesting/Methods/Snapshot+URLRequest.swift
new file mode 100644
index 000000000..4b2616c9d
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/Snapshot+URLRequest.swift
@@ -0,0 +1,136 @@
+#if !os(WASI)
+import Foundation
+
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+extension SyncSnapshot where Input == URLRequest, Output == StringBytes {
+ /// A snapshot strategy for comparing requests based on raw equality.
+ ///
+ /// ``` swift
+ /// try assert(of: request, as: .raw)
+ /// ```
+ ///
+ /// Records:
+ ///
+ /// ```
+ /// POST http://localhost:8080/account
+ /// Cookie: pf_session={"userId":"1"}
+ ///
+ /// email=blob%40pointfree.co&name=Blob
+ /// ```
+ public static var raw: SyncSnapshot {
+ .raw(pretty: false)
+ }
+
+ /// A snapshot strategy for comparing requests based on raw equality.
+ ///
+ /// - Parameter pretty: Attempts to pretty print the body of the request (supports JSON).
+ public static func raw(pretty: Bool) -> SyncSnapshot {
+ IdentitySyncSnapshot.lines.pullback { (request: URLRequest) in
+ let method =
+ "\(request.httpMethod ?? "GET") \(request.url?.sortingQueryItems()?.absoluteString ?? "(null)")"
+
+ let headers = (request.allHTTPHeaderFields ?? [:])
+ .map { key, value in "\(key): \(value)" }
+ .sorted()
+
+ let body: [String]
+ do {
+ if pretty, #available(iOS 11.0, macOS 10.13, tvOS 11.0, watchOS 4.0, *) {
+ body =
+ try request.httpBody
+ .map { try JSONSerialization.jsonObject(with: $0, options: []) }
+ .map {
+ try JSONSerialization.data(
+ withJSONObject: $0,
+ options: [.prettyPrinted, .sortedKeys]
+ )
+ }
+ .map {
+ ["\n\(String(decoding: $0, as: UTF8.self))"]
+ } ?? []
+ } else {
+ throw NSError(domain: "co.pointfree.Never", code: 1, userInfo: nil)
+ }
+ } catch {
+ body =
+ request.httpBody.map {
+ ["\n\(String(decoding: $0, as: UTF8.self))"]
+ } ?? []
+ }
+
+ return ([method] + headers + body).joined(separator: "\n")
+ }
+ }
+
+ /// A snapshot strategy for comparing requests based on a cURL representation.
+ ///
+ // ``` swift
+ // assert(of: request, as: .curl)
+ // ```
+ //
+ // Records:
+ //
+ // ```
+ // curl \
+ // --request POST \
+ // --header "Accept: text/html" \
+ // --data 'pricing[billing]=monthly&pricing[lane]=individual' \
+ // "https://www.pointfree.co/subscribe"
+ // ```
+ public static var curl: SyncSnapshot {
+ IdentitySyncSnapshot.lines.pullback { (request: URLRequest) in
+ var components = ["curl"]
+
+ // HTTP Method
+ let httpMethod = request.httpMethod!
+ switch httpMethod {
+ case "GET": break
+ case "HEAD": components.append("--head")
+ default: components.append("--request \(httpMethod)")
+ }
+
+ // Headers
+ if let headers = request.allHTTPHeaderFields {
+ for field in headers.keys.sorted() where field != "Cookie" {
+ let escapedValue = headers[field]!.replacingOccurrences(of: "\"", with: "\\\"")
+ components.append("--header \"\(field): \(escapedValue)\"")
+ }
+ }
+
+ // Body
+ if let httpBodyData = request.httpBody,
+ let httpBody = String(data: httpBodyData, encoding: .utf8)
+ {
+ var escapedBody = httpBody.replacingOccurrences(of: "\\\"", with: "\\\\\"")
+ escapedBody = escapedBody.replacingOccurrences(of: "\"", with: "\\\"")
+
+ components.append("--data \"\(escapedBody)\"")
+ }
+
+ // Cookies
+ if let cookie = request.allHTTPHeaderFields?["Cookie"] {
+ let escapedValue = cookie.replacingOccurrences(of: "\"", with: "\\\"")
+ components.append("--cookie \"\(escapedValue)\"")
+ }
+
+ // URL
+ components.append("\"\(request.url!.sortingQueryItems()!.absoluteString)\"")
+
+ return components.joined(separator: " \\\n\t")
+ }
+ }
+}
+
+extension URL {
+
+ fileprivate func sortingQueryItems() -> URL? {
+ var components = URLComponents(url: self, resolvingAgainstBaseURL: false)
+ let sortedQueryItems = components?.queryItems?.sorted { $0.name < $1.name }
+ components?.queryItems = sortedQueryItems
+ return components?.url
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Core/DeviceOrientation.swift b/Sources/XCSnapshotTesting/Methods/UI/Core/DeviceOrientation.swift
new file mode 100644
index 000000000..e3e58a708
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Core/DeviceOrientation.swift
@@ -0,0 +1,73 @@
+import Foundation
+
+/// Represents the orientation of a device, either landscape or portrait.
+///
+/// - Note: This type is `Sendable` to allow safe sharing across concurrent operations.
+public enum DeviceOrientation: Sendable {
+
+ /// A landscape orientation with an extended layout ratio.
+ public static let landscape: Self = .landscape(.extended)
+
+ /// A portrait orientation with an extended layout ratio.
+ public static let portrait: Self = .portrait(.extended)
+
+ /// A device orientation in landscape mode, with a specific layout ratio.
+ case landscape(DeviceLayoutRatio)
+
+ /// A device orientation in portrait mode, with a specific layout ratio.
+ case portrait(DeviceLayoutRatio)
+}
+
+/// Represents a device's layout ratio, with predefined values for compact, medium, regular, and extended.
+/// The raw value is a Double between 0 and 1, representing the ratio.
+/// - SeeAlso: DeviceOrientation
+///
+/// - Note: This type is `Sendable` to allow safe sharing across concurrent operations.
+public struct DeviceLayoutRatio: Sendable, RawRepresentable, ExpressibleByFloatLiteral, Hashable, Comparable {
+
+ /// A compact layout ratio (1/3).
+ public static let compact: Self = .init(rawValue: 1 / 3)
+
+ /// A medium layout ratio (0.5).
+ public static let medium: Self = .init(rawValue: 0.5)
+
+ /// A regular layout ratio (2/3).
+ public static let regular: Self = .init(rawValue: 2 / 3)
+
+ /// An extended layout ratio (1).
+ public static let extended: Self = .init(rawValue: 1)
+
+ /// The raw value representing the layout ratio, between 0 and 1.
+ public let rawValue: Double
+
+ /// Initializes a `DeviceLayoutRatio` with a given raw value.
+ /// - Parameter rawValue: A Double between 0 and 1.
+ public init(rawValue: Double) {
+ precondition(rawValue >= 0 && rawValue <= 1, "Raw value must be between 0 and 1")
+ self.rawValue = rawValue
+ }
+
+ /// Initializes a `DeviceLayoutRatio` with a float literal.
+ /// - Parameter value: The float value to use as the raw value.
+ public init(floatLiteral value: Double) {
+ self.init(rawValue: value)
+ }
+
+ /// Compares two `DeviceLayoutRatio` instances.
+ /// - Parameters:
+ /// - lhs: The left-hand side instance.
+ /// - rhs: The right-hand side instance.
+ /// - Returns: `true` if lhs is less than rhs, otherwise `false`.
+ public static func < (lhs: Self, rhs: Self) -> Bool {
+ lhs.rawValue < rhs.rawValue
+ }
+
+ /// Compares two `DeviceLayoutRatio` instances.
+ /// - Parameters:
+ /// - lhs: The left-hand side instance.
+ /// - rhs: The right-hand side instance.
+ /// - Returns: `true` if lhs is greater than rhs, otherwise `false`.
+ public static func > (lhs: Self, rhs: Self) -> Bool {
+ lhs.rawValue > rhs.rawValue
+ }
+}
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Core/ImageContext.swift b/Sources/XCSnapshotTesting/Methods/UI/Core/ImageContext.swift
new file mode 100644
index 000000000..d5b85dee4
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Core/ImageContext.swift
@@ -0,0 +1,9 @@
+#if os(macOS) || os(iOS) || os(tvOS) || os(visionOS) || os(watchOS)
+import CoreGraphics
+
+enum ImageContext {
+ static let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)
+ static let bitsPerComponent = 8
+ static let bytesPerPixel = 4
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Core/LayoutConfiguration.swift b/Sources/XCSnapshotTesting/Methods/UI/Core/LayoutConfiguration.swift
new file mode 100644
index 000000000..375bafc23
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Core/LayoutConfiguration.swift
@@ -0,0 +1,884 @@
+#if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS)
+import UIKit
+#elseif os(macOS)
+@preconcurrency import AppKit
+#endif
+
+#if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || os(macOS)
+/// Layout configuration for rendering elements in snapshot tests.
+///
+/// `LayoutConfiguration` defines properties like safe area margins, element size, and device traits
+/// (e.g., orientation, screen size) to simulate different display scenarios.
+///
+/// This struct helps create consistent rendering conditions for snapshot tests by specifying layout properties
+/// that mimic different devices and orientations. However, it's important to note that this configuration
+/// only simulates visual conditions and does not actually execute tests on the specified platform. Due to
+/// inherent differences in layout engines across platforms (iOS, macOS, tvOS), test results should be
+/// validated on the target platform to account for framework-specific behaviors (UIKit, SwiftUI, AppKit).
+///
+/// - Note: This struct encapsulates screen size, safe area insets, and device-specific traits to enable
+/// responsive UI layouts across iOS, iPadOS, and tvOS. It provides a way to standardize testing conditions
+/// while acknowledging that final visual verification should occur on the actual target platform.
+/// - SeeAlso: `LayoutConfiguration.iPadPro12_9`, `LayoutConfiguration.iPhone13`, `LayoutConfiguration.tv`
+public struct LayoutConfiguration: Sendable {
+
+ // MARK: - Internal static methods
+
+ #if os(macOS)
+ static func resolve(_ snapshotLayout: SnapshotLayout) -> LayoutConfiguration {
+ switch snapshotLayout {
+ case .sizeThatFits:
+ return .init(safeArea: .init(), size: nil)
+ case .fixed(let width, let height):
+ let size = CGSize(width: width, height: height)
+ return .init(safeArea: .init(), size: size)
+ }
+ }
+ #elseif os(iOS) || os(tvOS) || os(visionOS)
+ static func resolve(
+ _ snapshotLayout: SnapshotLayout,
+ with traits: Traits
+ ) -> LayoutConfiguration {
+ switch snapshotLayout {
+ case .device(let deviceConfig):
+ return .init(
+ safeArea: deviceConfig.safeArea,
+ size: deviceConfig.size,
+ traits: deviceConfig.traits.merging(traits)
+ )
+ case .sizeThatFits:
+ return .init(safeArea: .zero, size: nil, traits: traits)
+ case .fixed(let width, let height):
+ let size = CGSize(width: width, height: height)
+ return .init(safeArea: .zero, size: size, traits: traits)
+ }
+ }
+ #else
+ static func resolve(_ snapshotLayout: SnapshotLayout) -> LayoutConfiguration {
+ switch snapshotLayout {
+ case .sizeThatFits:
+ return .init(size: nil)
+ case .fixed(let width, let height):
+ let size = CGSize(width: width, height: height)
+ return .init(size: size)
+ }
+ }
+ #endif
+
+ // MARK: - Public properties
+
+ #if os(macOS)
+ /// Margins for safe area layout (e.g., device notches or status bars).
+ ///
+ /// Default value: `.zero` (no additional margins).
+ public let safeArea: NSEdgeInsets
+ #elseif !os(watchOS)
+ /// Margins for safe area layout (e.g., device notches or status bars).
+ ///
+ /// Default value: `.zero` (no additional margins).
+ public let safeArea: UIEdgeInsets
+ #endif
+
+ /// Size of the element to render.
+ ///
+ /// When `nil`, the element uses its intrinsic content size.
+ public let size: CGSize?
+
+ #if os(iOS) || os(tvOS) || os(visionOS)
+ /// Collection of UI traits like orientation, device size, and dark mode.
+ ///
+ /// Default value: `Traits()` (system default values).
+ public let traits: Traits
+ #endif
+
+ // MARK: - Inits
+ #if os(macOS)
+ /// Initializes a `LayoutConfiguration` with the specified safe area and size.
+ ///
+ /// - Parameters:
+ /// - safeArea: The edge insets representing the safe area of the layout.
+ /// - size: The dimensions of the layout's viewing area.
+ public init(
+ safeArea: NSEdgeInsets = .init(),
+ size: CGSize? = nil
+ ) {
+ self.safeArea = safeArea
+ self.size = size
+ }
+ #elseif os(iOS) || os(tvOS) || os(visionOS)
+ /// Initializes a layout configuration with default or custom values.
+ ///
+ /// - Parameters:
+ /// - safeArea: Margins for safe area (e.g., iPhone notch spacing).
+ /// - size: Desired element size (optional).
+ /// - traits: UI characteristics (e.g., portrait/landscape orientation).
+ public init(
+ safeArea: UIEdgeInsets = .zero,
+ size: CGSize? = nil,
+ traits: Traits = .init()
+ ) {
+ self.safeArea = safeArea
+ self.size = size
+ self.traits = traits
+ }
+ #else
+ /// Initializes a layout configuration with default values.
+ ///
+ /// - Parameters:
+ /// - size: Desired element size (optional).
+ public init(
+ size: CGSize? = nil
+ ) {
+ self.size = size
+ }
+ #endif
+}
+
+#if os(iOS) || os(tvOS) || os(visionOS)
+
+extension LayoutConfiguration {
+
+ fileprivate struct EdgeInsets {
+
+ static var zero: Self {
+ .init(
+ top: .zero,
+ left: .zero,
+ bottom: .zero,
+ right: .zero
+ )
+ }
+
+ let top: CGFloat?
+ let left: CGFloat?
+ let bottom: CGFloat?
+ let right: CGFloat?
+
+ init(
+ top: CGFloat? = nil,
+ left: CGFloat? = nil,
+ bottom: CGFloat? = nil,
+ right: CGFloat? = nil
+ ) {
+ self.top = top
+ self.left = left
+ self.bottom = bottom
+ self.right = right
+ }
+ }
+}
+
+// MARK: - iPhone 16
+extension LayoutConfiguration {
+
+ public static let iPhone16ProMax: LayoutConfiguration = .iPhone16ProMax(.portrait)
+
+ public static func iPhone16ProMax(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_9,
+ portraitSafeArea: EdgeInsets(top: 62, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone16ProMax
+ )
+ }
+
+ public static let iPhone16Pro: LayoutConfiguration = .iPhone16Pro(.portrait)
+
+ public static func iPhone16Pro(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_3,
+ portraitSafeArea: EdgeInsets(top: 62, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone16Pro
+ )
+ }
+
+ public static let iPhone16Plus: LayoutConfiguration = .iPhone16Plus(.portrait)
+
+ public static func iPhone16Plus(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_7v2,
+ portraitSafeArea: EdgeInsets(top: 59, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone16Plus
+ )
+ }
+
+ public static let iPhone16: LayoutConfiguration = .iPhone16(.portrait)
+
+ public static func iPhone16(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_1v2,
+ portraitSafeArea: EdgeInsets(top: 59, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone16
+ )
+ }
+}
+
+// MARK: - iPhone 15
+extension LayoutConfiguration {
+
+ public static let iPhone15ProMax: LayoutConfiguration = .iPhone15ProMax(.portrait)
+
+ public static func iPhone15ProMax(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_7v2,
+ portraitSafeArea: EdgeInsets(top: 59, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone15ProMax
+ )
+ }
+
+ public static let iPhone15Pro: LayoutConfiguration = .iPhone15Pro(.portrait)
+
+ public static func iPhone15Pro(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_1v2,
+ portraitSafeArea: EdgeInsets(top: 59, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone15Pro
+ )
+ }
+
+ public static let iPhone15Plus: LayoutConfiguration = .iPhone15Plus(.portrait)
+
+ public static func iPhone15Plus(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_7v2,
+ portraitSafeArea: EdgeInsets(top: 59, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone15Plus
+ )
+ }
+
+ public static let iPhone15: LayoutConfiguration = .iPhone15(.portrait)
+
+ public static func iPhone15(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_1v2,
+ portraitSafeArea: EdgeInsets(top: 59, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone15
+ )
+ }
+}
+
+// MARK: - iPhone 14
+extension LayoutConfiguration {
+
+ public static let iPhone14ProMax: LayoutConfiguration = .iPhone14ProMax(.portrait)
+
+ public static func iPhone14ProMax(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_7v2,
+ portraitSafeArea: EdgeInsets(top: 59, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone14ProMax
+ )
+ }
+
+ public static let iPhone14Pro: LayoutConfiguration = .iPhone14Pro(.portrait)
+
+ public static func iPhone14Pro(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_1v2,
+ portraitSafeArea: EdgeInsets(top: 59, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone14Pro
+ )
+ }
+
+ public static let iPhone14Plus: LayoutConfiguration = .iPhone14Plus(.portrait)
+
+ public static func iPhone14Plus(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_7v1,
+ portraitSafeArea: EdgeInsets(top: 47, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone14Plus
+ )
+ }
+
+ public static let iPhone14: LayoutConfiguration = .iPhone14(.portrait)
+
+ public static func iPhone14(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_1v1,
+ portraitSafeArea: EdgeInsets(top: 47, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone14
+ )
+ }
+}
+
+// MARK: - iPhone 13
+extension LayoutConfiguration {
+
+ public static let iPhone13ProMax: LayoutConfiguration = .iPhone13ProMax(.portrait)
+
+ public static func iPhone13ProMax(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_7v1,
+ portraitSafeArea: EdgeInsets(top: 47, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone13ProMax
+ )
+ }
+
+ public static let iPhone13Pro: LayoutConfiguration = .iPhone13Pro(.portrait)
+
+ public static func iPhone13Pro(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_1v1,
+ portraitSafeArea: EdgeInsets(top: 47, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone13Pro
+ )
+ }
+
+ public static let iPhone13: LayoutConfiguration = .iPhone13(.portrait)
+
+ public static func iPhone13(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_1v1,
+ portraitSafeArea: EdgeInsets(top: 47, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone13
+ )
+ }
+
+ public static let iPhone13Mini: LayoutConfiguration = .iPhone13Mini(.portrait)
+
+ public static func iPhone13Mini(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen5_4,
+ portraitSafeArea: EdgeInsets(top: 50, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone13Mini
+ )
+ }
+}
+
+// MARK: - iPhone 12
+extension LayoutConfiguration {
+
+ public static let iPhone12ProMax: LayoutConfiguration = .iPhone12ProMax(.portrait)
+
+ public static func iPhone12ProMax(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_7v1,
+ portraitSafeArea: EdgeInsets(top: 50, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone12ProMax
+ )
+ }
+
+ public static let iPhone12Pro: LayoutConfiguration = .iPhone12Pro(.portrait)
+
+ public static func iPhone12Pro(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_1v1,
+ portraitSafeArea: EdgeInsets(top: 50, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone12Pro
+ )
+ }
+
+ public static let iPhone12: LayoutConfiguration = .iPhone12(.portrait)
+
+ public static func iPhone12(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_1v1,
+ portraitSafeArea: EdgeInsets(top: 47, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone12
+ )
+ }
+
+ public static let iPhone12Mini: LayoutConfiguration = .iPhone12Mini(.portrait)
+
+ public static func iPhone12Mini(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen5_4,
+ portraitSafeArea: EdgeInsets(top: 50, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone12Mini
+ )
+ }
+}
+
+// MARK: - iPhone 11
+extension LayoutConfiguration {
+
+ public static let iPhone11ProMax: LayoutConfiguration = .iPhone11ProMax(.portrait)
+
+ public static func iPhone11ProMax(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_5,
+ portraitSafeArea: EdgeInsets(top: 47, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone11ProMax
+ )
+ }
+
+ public static let iPhone11Pro: LayoutConfiguration = .iPhone11Pro(.portrait)
+
+ public static func iPhone11Pro(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen5_8,
+ portraitSafeArea: EdgeInsets(top: 47, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone11Pro
+ )
+ }
+
+ public static let iPhone11: LayoutConfiguration = .iPhone11(.portrait)
+
+ public static func iPhone11(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_1v3,
+ portraitSafeArea: EdgeInsets(top: 47, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 2,
+ deviceInterfaceSizeClass: .iPhone11ProMax
+ )
+ }
+}
+
+// MARK: - iPhone X
+extension LayoutConfiguration {
+
+ public static let iPhoneXR: LayoutConfiguration = .iPhoneXR(.portrait)
+
+ public static func iPhoneXR(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_1v3,
+ portraitSafeArea: EdgeInsets(top: 44, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhoneXR
+ )
+ }
+
+ public static let iPhoneXSMax: LayoutConfiguration = .iPhoneXSMax(.portrait)
+
+ public static func iPhoneXSMax(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen6_5,
+ portraitSafeArea: EdgeInsets(top: 44, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhoneXSMax
+ )
+ }
+
+ public static let iPhoneXS: LayoutConfiguration = .iPhoneXS(.portrait)
+
+ public static func iPhoneXS(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen5_8,
+ portraitSafeArea: EdgeInsets(top: 44, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhoneXS
+ )
+ }
+
+ public static let iPhoneX: LayoutConfiguration = .iPhoneX(.portrait)
+
+ public static func iPhoneX(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen5_8,
+ portraitSafeArea: EdgeInsets(top: 44, bottom: 34, right: 0),
+ orientation: orientation,
+ displayScale: 2,
+ deviceInterfaceSizeClass: .iPhoneX
+ )
+ }
+}
+
+// MARK: - iPhone 8
+extension LayoutConfiguration {
+
+ public static let iPhone8: LayoutConfiguration = .iPhone8(.portrait)
+
+ public static func iPhone8(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen4_7,
+ portraitSafeArea: EdgeInsets(top: 20),
+ landscapeSafeArea: EdgeInsets.zero,
+ orientation: orientation,
+ displayScale: 2,
+ deviceInterfaceSizeClass: .iPhone8
+ )
+ }
+
+ public static let iPhone8Plus: LayoutConfiguration = .iPhone8Plus(.portrait)
+
+ public static func iPhone8Plus(_ orientation: DeviceOrientation) -> LayoutConfiguration {
+ iPhone(
+ size: .screen5_5,
+ portraitSafeArea: EdgeInsets(top: 20),
+ landscapeSafeArea: EdgeInsets.zero,
+ orientation: orientation,
+ displayScale: 3,
+ deviceInterfaceSizeClass: .iPhone8Plus
+ )
+ }
+}
+
+// MARK: - iPhone SE
+extension LayoutConfiguration {
+
+ public static let iPhoneSE: LayoutConfiguration = .iPhoneSE(.portrait)
+
+ public static func iPhoneSE(
+ _ orientation: DeviceOrientation
+ ) -> LayoutConfiguration {
+ iPhone(
+ size: .screen4_7,
+ portraitSafeArea: EdgeInsets(top: 20),
+ landscapeSafeArea: .zero,
+ orientation: orientation,
+ displayScale: 2,
+ deviceInterfaceSizeClass: .iPhoneSE
+ )
+ }
+}
+
+// MARK: - iPhone
+extension LayoutConfiguration {
+
+ fileprivate static func iPhone(
+ size: CGSize,
+ portraitSafeArea: EdgeInsets,
+ landscapeSafeArea: EdgeInsets = .init(bottom: 21),
+ orientation: DeviceOrientation,
+ displayScale: CGFloat,
+ deviceInterfaceSizeClass: DeviceDynamicInterfaceSizeClass
+ ) -> LayoutConfiguration {
+ let safeArea: UIEdgeInsets
+ let screenSize: CGSize
+
+ switch orientation {
+ case .portrait(let ratio):
+ precondition(ratio == .extended)
+
+ safeArea = .init(
+ top: portraitSafeArea.top ?? .zero,
+ left: portraitSafeArea.left ?? .zero,
+ bottom: portraitSafeArea.bottom ?? .zero,
+ right: portraitSafeArea.right ?? .zero
+ )
+ screenSize = size
+ case .landscape(let ratio):
+ precondition(ratio == .extended)
+
+ safeArea = UIEdgeInsets(
+ top: landscapeSafeArea.top ?? .zero,
+ left: landscapeSafeArea.left ?? portraitSafeArea.top ?? .zero,
+ bottom: landscapeSafeArea.bottom ?? 21,
+ right: landscapeSafeArea.right ?? portraitSafeArea.top ?? .zero
+ )
+ screenSize = size.reflected()
+ }
+
+ return .init(
+ safeArea: safeArea,
+ size: screenSize,
+ traits: .iOS(
+ displayScale: displayScale,
+ size: screenSize,
+ deviceInterfaceSizeClass: deviceInterfaceSizeClass
+ )
+ )
+ }
+}
+
+// MARK: - iPad Pro
+extension LayoutConfiguration {
+
+ public static let iPadPro12_9 = iPadPro12_9(.landscape)
+
+ public static func iPadPro12_9(
+ _ orientation: DeviceOrientation
+ ) -> LayoutConfiguration {
+ iPad(
+ orientation: orientation,
+ size: CGSize(width: 1_024, height: 1_366),
+ displayScale: 2,
+ deviceInterfaceSizeClass: .iPadOS
+ )
+ }
+
+ public static let iPadPro11 = iPadPro11(.landscape)
+
+ public static func iPadPro11(
+ _ orientation: DeviceOrientation
+ ) -> LayoutConfiguration {
+ iPad(
+ orientation: orientation,
+ size: CGSize(width: 834, height: 1_194),
+ displayScale: 2,
+ deviceInterfaceSizeClass: .iPadOS
+ )
+ }
+
+ public static let iPadPro10_5 = iPadPro10_5(.landscape)
+
+ public static func iPadPro10_5(
+ _ orientation: DeviceOrientation
+ ) -> LayoutConfiguration {
+ iPad(
+ orientation: orientation,
+ size: CGSize(width: 834, height: 1_194),
+ displayScale: 2,
+ deviceInterfaceSizeClass: .iPadOS
+ )
+ }
+
+ public static let iPadPro9_7 = iPadPro9_7(.landscape)
+
+ public static func iPadPro9_7(
+ _ orientation: DeviceOrientation
+ ) -> LayoutConfiguration {
+ iPad(
+ orientation: orientation,
+ size: CGSize(width: 768, height: 1_024),
+ displayScale: 2,
+ deviceInterfaceSizeClass: .iPadOS
+ )
+ }
+}
+
+// MARK: - iPad Air
+extension LayoutConfiguration {
+
+ public static let iPadAir13 = iPadAir13(.landscape)
+
+ public static func iPadAir13(
+ _ orientation: DeviceOrientation
+ ) -> LayoutConfiguration {
+ iPad(
+ orientation: orientation,
+ size: CGSize(width: 1_024, height: 1_366),
+ displayScale: 2,
+ deviceInterfaceSizeClass: .iPadOS
+ )
+ }
+
+ public static let iPadAir11 = iPadAir11(.landscape)
+
+ public static func iPadAir11(
+ _ orientation: DeviceOrientation
+ ) -> LayoutConfiguration {
+ iPad(
+ orientation: orientation,
+ size: CGSize(width: 820, height: 1_180),
+ displayScale: 2,
+ deviceInterfaceSizeClass: .iPadOS
+ )
+ }
+
+ public static let iPadAir10_9 = iPadAir10_9(.landscape)
+
+ public static func iPadAir10_9(
+ _ orientation: DeviceOrientation
+ ) -> LayoutConfiguration {
+ iPad(
+ orientation: orientation,
+ size: CGSize(width: 820, height: 1_180),
+ displayScale: 2,
+ deviceInterfaceSizeClass: .iPadOS
+ )
+ }
+
+ public static let iPadAir10_5 = iPadAir10_5(.landscape)
+
+ public static func iPadAir10_5(
+ _ orientation: DeviceOrientation
+ ) -> LayoutConfiguration {
+ iPad(
+ orientation: orientation,
+ size: CGSize(width: 820, height: 1_180),
+ displayScale: 2,
+ deviceInterfaceSizeClass: .iPadOS
+ )
+ }
+
+ public static let iPadAir9_7 = iPadAir9_7(.landscape)
+
+ public static func iPadAir9_7(
+ _ orientation: DeviceOrientation
+ ) -> LayoutConfiguration {
+ iPad(
+ orientation: orientation,
+ size: CGSize(width: 768, height: 1_024),
+ displayScale: 2,
+ deviceInterfaceSizeClass: .iPadOS
+ )
+ }
+}
+
+// MARK: - iPad
+extension LayoutConfiguration {
+
+ public static let iPad11 = iPad11(.landscape)
+
+ public static func iPad11(
+ _ orientation: DeviceOrientation
+ ) -> LayoutConfiguration {
+ iPad(
+ orientation: orientation,
+ size: CGSize(width: 820, height: 1_180),
+ displayScale: 2,
+ deviceInterfaceSizeClass: .iPadOS
+ )
+ }
+
+ public static let iPad10_2 = iPad10_2(.landscape)
+
+ public static func iPad10_2(
+ _ orientation: DeviceOrientation
+ ) -> LayoutConfiguration {
+ iPad(
+ orientation: orientation,
+ size: CGSize(width: 810, height: 1_080),
+ displayScale: 2,
+ deviceInterfaceSizeClass: .iPadOS
+ )
+ }
+
+ public static let iPad9_7 = iPad9_7(.landscape)
+
+ public static func iPad9_7(
+ _ orientation: DeviceOrientation
+ ) -> LayoutConfiguration {
+ iPad(
+ orientation: orientation,
+ size: CGSize(width: 768, height: 1_024),
+ displayScale: 2,
+ deviceInterfaceSizeClass: .iPadOS
+ )
+ }
+}
+
+// MARK: - iPad Mini
+extension LayoutConfiguration {
+
+ public static let iPadMini8_3 = iPadMini8_3(.landscape)
+
+ public static func iPadMini8_3(
+ _ orientation: DeviceOrientation
+ ) -> LayoutConfiguration {
+ iPad(
+ orientation: orientation,
+ size: CGSize(width: 744, height: 1_133),
+ displayScale: 2,
+ deviceInterfaceSizeClass: .iPadOS
+ )
+ }
+
+ public static let iPadMini7_9 = iPadMini7_9(.landscape)
+
+ public static func iPadMini7_9(
+ _ orientation: DeviceOrientation
+ ) -> LayoutConfiguration {
+ iPad(
+ orientation: orientation,
+ size: CGSize(width: 768, height: 1_024),
+ displayScale: 2,
+ deviceInterfaceSizeClass: .iPadOS
+ )
+ }
+}
+
+// MARK: - iPad Method
+extension LayoutConfiguration {
+
+ fileprivate static func iPad(
+ orientation: DeviceOrientation,
+ size: CGSize,
+ displayScale: CGFloat,
+ deviceInterfaceSizeClass: DeviceDynamicInterfaceSizeClass
+ ) -> LayoutConfiguration {
+ var deviceSize: CGSize
+ let deviceRatio: DeviceLayoutRatio
+
+ switch orientation {
+ case .landscape(let ratio):
+ deviceRatio = ratio
+ deviceSize = size.reflected()
+ case .portrait(let ratio):
+ deviceRatio = ratio
+ deviceSize = size
+ }
+
+ deviceSize.width *= deviceRatio.rawValue
+
+ return LayoutConfiguration(
+ safeArea: UIEdgeInsets(top: 24, left: .zero, bottom: 20, right: .zero),
+ size: deviceSize,
+ traits: .iPadOS(
+ displayScale: displayScale,
+ size: deviceSize,
+ deviceInterfaceSizeClass: deviceInterfaceSizeClass
+ )
+ )
+ }
+}
+
+extension LayoutConfiguration {
+
+ public static let tv = tvOS(
+ displayScale: 1,
+ deviceInterfaceSizeClass: .tvOS
+ )
+
+ public static let tv4K = tvOS(
+ displayScale: 2,
+ deviceInterfaceSizeClass: .tvOS
+ )
+
+ private static func tvOS(
+ displayScale: CGFloat,
+ deviceInterfaceSizeClass: DeviceDynamicInterfaceSizeClass
+ ) -> LayoutConfiguration {
+ let size = CGSize(width: 1920, height: 1080)
+
+ return LayoutConfiguration(
+ safeArea: .init(top: 60, left: 80, bottom: 60, right: 80),
+ size: size,
+ traits: .tvOS(
+ displayScale: displayScale,
+ size: size,
+ deviceInterfaceSizeClass: deviceInterfaceSizeClass
+ )
+ )
+ }
+}
+#endif
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Core/SizeListener.swift b/Sources/XCSnapshotTesting/Methods/UI/Core/SizeListener.swift
new file mode 100644
index 000000000..dbdc6a0e6
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Core/SizeListener.swift
@@ -0,0 +1,139 @@
+#if canImport(SwiftUI)
+import SwiftUI
+
+#if os(iOS) || os(tvOS) || os(watchOS)
+import UIKit
+#elseif os(macOS)
+@preconcurrency import AppKit
+#endif
+
+#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS)
+@MainActor
+protocol SizeListenerDelegate: AnyObject {
+
+ func viewDidUpdateSize(_ id: ObjectIdentifier, size: CGSize)
+}
+
+@MainActor
+class SizeListener {
+
+ var id: ObjectIdentifier {
+ ObjectIdentifier(self)
+ }
+
+ weak var delegate: SizeListenerDelegate? {
+ willSet { updateSize(size) }
+ }
+
+ private(set) var size: CGSize = .zero
+ fileprivate weak var owningView: SDKView?
+
+ init() {}
+
+ fileprivate func updateSize(_ size: CGSize) {
+ guard self.size != size else {
+ return
+ }
+
+ self.size = size
+ delegate?.viewDidUpdateSize(id, size: size)
+ }
+
+ func dispose() {
+ owningView?.removeFromSuperview()
+ }
+}
+
+// MARK: - UIView Extensions
+
+@MainActor
+private class UIViewSizeListener: SDKView {
+
+ let listener: SizeListener
+
+ init(listener: SizeListener) {
+ self.listener = listener
+ super.init(frame: .zero)
+ listener.owningView = self
+ }
+
+ required init?(coder: NSCoder) {
+ nil
+ }
+
+ #if os(macOS)
+ override func layout() {
+ super.layout()
+ guard window != nil, let superview else {
+ return
+ }
+
+ listener.updateSize(superview.bounds.size)
+ }
+ #else
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ listener.updateSize(bounds.size)
+ }
+ #endif
+}
+
+@MainActor
+extension SDKView {
+
+ func addSizeListener(_ listener: SizeListener) {
+ let view = UIViewSizeListener(listener: listener)
+ view.translatesAutoresizingMaskIntoConstraints = false
+ #if os(macOS)
+ addSubview(view, positioned: .below, relativeTo: subviews.first)
+ #else
+ insertSubview(view, at: .zero)
+ #endif
+ if #available(macOS 11, *) {
+ NSLayoutConstraint.activate([
+ view.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor),
+ view.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor),
+ view.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor),
+ view.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor),
+ ])
+ } else {
+ NSLayoutConstraint.activate([
+ view.topAnchor.constraint(equalTo: topAnchor),
+ view.bottomAnchor.constraint(equalTo: bottomAnchor),
+ view.leadingAnchor.constraint(equalTo: leadingAnchor),
+ view.trailingAnchor.constraint(equalTo: trailingAnchor),
+ ])
+ }
+ }
+}
+
+// MARK: - SwiftUI Extensions
+
+private struct ViewSizeListener: ViewModifier {
+
+ let listener: SizeListener
+
+ func body(content: Content) -> some View {
+ content
+ .background(
+ GeometryReader { proxy -> Color in
+ let size = proxy.size
+
+ Task { @MainActor in
+ listener.updateSize(size)
+ }
+
+ return Color.black.opacity(.zero)
+ }
+ )
+ }
+}
+
+extension View {
+
+ func sizeListener(_ listener: SizeListener) -> some View {
+ modifier(ViewSizeListener(listener: listener))
+ }
+}
+#endif
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Core/SnapshotLayout.swift b/Sources/XCSnapshotTesting/Methods/UI/Core/SnapshotLayout.swift
new file mode 100644
index 000000000..d3e79695a
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Core/SnapshotLayout.swift
@@ -0,0 +1,39 @@
+#if os(macOS) || os(iOS) || os(tvOS) || os(visionOS) || os(watchOS)
+import CoreGraphics
+
+/// Defines how a UI component's layout is configured during snapshot testing.
+public enum SnapshotLayout {
+
+ #if os(iOS) || os(tvOS) || os(visionOS)
+ /// Renders the component using a specific device configuration.
+ ///
+ /// - Parameter configuration: Layout configuration defining safe area margins, size,
+ /// and UI traits (e.g., orientation).
+ ///
+ /// Example:
+ /// ```swift
+ /// let layout = .device(.iPhone15ProMax)
+ /// ```
+ case device(LayoutConfiguration)
+ #endif
+
+ /// Renders the component with an explicit fixed size.
+ ///
+ /// Useful for ensuring test consistency across devices or configurations.
+ ///
+ /// - Parameters:
+ /// - width: Width in points.
+ /// - height: Height in points.
+ ///
+ /// Example:
+ /// ```swift
+ /// let layout = .fixed(width: 375, height: 812) // iPhone 12 dimensions
+ /// ```
+ case fixed(width: CGFloat, height: CGFloat)
+
+ /// Renders the component using its natural intrinsic content size.
+ ///
+ /// Ideal for content-adaptive components like labels or dynamic collections.
+ case sizeThatFits
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Core/SnapshotUIController.swift b/Sources/XCSnapshotTesting/Methods/UI/Core/SnapshotUIController.swift
new file mode 100644
index 000000000..92779d8e1
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Core/SnapshotUIController.swift
@@ -0,0 +1,472 @@
+#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
+import UIKit
+import SwiftUI
+import SceneKit
+import SpriteKit
+#elseif os(macOS)
+@preconcurrency import AppKit
+import SwiftUI
+import SceneKit
+import SpriteKit
+#endif
+
+#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS)
+@MainActor
+class SnapshotUIController: SDKViewController {
+
+ // MARK: - Internal properties
+
+ #if os(iOS) || os(tvOS) || os(visionOS)
+ override var shouldAutomaticallyForwardAppearanceMethods: Bool {
+ true
+ }
+ #endif
+
+ var configuration: LayoutConfiguration {
+ snapshotView.configuration
+ }
+
+ private let snapshotView: SnapshotView
+
+ // MARK: - Private properties
+
+ private var childConstraints = [NSLayoutConstraint]()
+ private let childController: SDKViewController
+ private let childSizeListener: SizeListener
+
+ private var isWaitingSnapshotSignal = false
+ private let snapshotSignal = AsyncSignal()
+
+ // MARK: - Inits
+
+ init(_ view: SDKView, with configuration: LayoutConfiguration) {
+ let sizeListener = SizeListener()
+ view.addSizeListener(sizeListener)
+ self.childController = view.withController()
+ self.childSizeListener = sizeListener
+ self.snapshotView = .init(configuration: configuration)
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ init(_ viewController: SDKViewController, with configuration: LayoutConfiguration) {
+ let sizeListener = SizeListener()
+ viewController.view.addSizeListener(sizeListener)
+ self.childController = viewController
+ self.childSizeListener = sizeListener
+ self.snapshotView = .init(configuration: configuration)
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ init(_ content: Content, with configuration: LayoutConfiguration) {
+ func size(_ keyPath: KeyPath) -> CGFloat? {
+ guard let size = configuration.size else {
+ return nil
+ }
+
+ let value = size[keyPath: keyPath]
+ return value == .zero ? nil : value
+ }
+
+ let sizeListener = SizeListener()
+ let rootView =
+ content
+ .frame(width: size(\.width), height: size(\.height))
+ .sizeListener(sizeListener)
+ #if os(macOS)
+ let viewController = NSHostingController(rootView: rootView)
+ #else
+ let viewController = UIHostingController(rootView: rootView)
+ #endif
+ self.childController = viewController
+ self.childSizeListener = sizeListener
+ self.snapshotView = .init(configuration: configuration)
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ nil
+ }
+
+ // MARK: - Super methods
+
+ override func loadView() {
+ view = snapshotView
+ snapshotView.translatesAutoresizingMaskIntoConstraints = false
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ attachChild()
+ #if !os(macOS)
+ configuration.traits.commit(in: self)
+ #endif
+ }
+
+ #if os(macOS)
+ override func viewDidLayout() {
+ super.viewDidLayout()
+ if isWaitingSnapshotSignal {
+ isWaitingSnapshotSignal = false
+
+ Task {
+ await snapshotSignal.signal()
+ }
+ }
+ }
+
+ override func viewWillAppear() {
+ super.viewWillAppear()
+
+ let trackingArea = NSTrackingArea(
+ rect: view.bounds,
+ options: [.mouseEnteredAndExited, .activeInKeyWindow],
+ owner: view
+ )
+
+ view.addTrackingArea(trackingArea)
+ }
+
+ override func viewWillDisappear() {
+ super.viewWillDisappear()
+
+ for trackingArea in view.trackingAreas {
+ view.removeTrackingArea(trackingArea)
+ }
+ }
+ #else
+ override func viewDidLayoutSubviews() {
+ super.viewDidLayoutSubviews()
+
+ if isWaitingSnapshotSignal {
+ isWaitingSnapshotSignal = false
+
+ Task {
+ await snapshotSignal.signal()
+ }
+ }
+ }
+ #if os(iOS)
+ override func shouldAutomaticallyForwardRotationMethods() -> Bool {
+ true
+ }
+ #endif
+ #endif
+
+ // MARK: - Internal methods
+
+ func layoutIfNeeded() {
+ #if os(macOS)
+ let view = childController.view
+ #else
+ let view: UIView = childController.view ?? view
+ #endif
+
+ let size = view.frame.size
+ if size.height == .zero || size.width == .zero {
+ #if os(macOS)
+ view.needsLayout = true
+ view.layoutSubtreeIfNeeded()
+ #else
+ view.setNeedsLayout()
+ view.layoutIfNeeded()
+ #endif
+ }
+ }
+
+ func snapshot() async throws -> SDKImage {
+ #if !os(macOS)
+ let traits = configuration.traits
+ #endif
+
+ isWaitingSnapshotSignal = true
+
+ #if os(macOS)
+ childController.view.needsLayout = true
+ view.needsLayout = true
+ view.layoutSubtreeIfNeeded()
+ #else
+ view.recursiveNeedsLayout()
+ view.layoutIfNeeded()
+ #endif
+
+ try await snapshotSignal.wait()
+ await snapshotSignal.lock()
+
+ if let sceneView = childController.view as? SCNView {
+ return sceneView.snapshot()
+ }
+
+ if let skView = childController.view as? SKView,
+ let scene = skView.scene,
+ let image = skView.texture(from: scene)?.cgImage()
+ {
+ #if os(macOS)
+ return .init(
+ cgImage: image,
+ size: CGSize(
+ width: image.width,
+ height: image.height
+ )
+ )
+ #else
+ return .init(cgImage: image)
+ #endif
+ }
+
+ #if os(macOS)
+ return try snapshot(view)
+ #else
+ return try snapshot(view, with: traits)
+ #endif
+ }
+
+ enum DescriptorMethod: String {
+ #if os(macOS)
+ case subtreeDescription = "_subtreeDescription"
+ #else
+ case hierarchy = "_printHierarchy"
+ case recursiveDescription = "recursiveDescription"
+ #endif
+
+ @MainActor
+ fileprivate func callAsFunction(_ viewController: SDKViewController) -> String {
+ let reference: NSObject
+
+ switch self {
+ #if os(macOS)
+ case .subtreeDescription:
+ reference = viewController.view
+ #else
+ case .hierarchy:
+ reference = viewController
+ case .recursiveDescription:
+ reference = viewController.view
+ #endif
+ }
+
+ return
+ (reference
+ .perform(Selector(rawValue))
+ .retain()
+ .takeUnretainedValue() as! String).sanitizingPointersReferences()
+ }
+ }
+
+ func descriptor(_ method: DescriptorMethod) async throws -> String {
+ isWaitingSnapshotSignal = true
+
+ #if os(macOS)
+ childController.view.needsLayout = true
+ view.needsLayout = true
+ view.layoutSubtreeIfNeeded()
+ #else
+ childController.view.setNeedsLayout()
+ view.setNeedsLayout()
+ view.layoutIfNeeded()
+ #endif
+
+ try await snapshotSignal.wait()
+ await snapshotSignal.lock()
+
+ return method(childController)
+ }
+
+ func detachChild() {
+ defer { snapshotView.dispose() }
+
+ NSLayoutConstraint.deactivate(childConstraints)
+ #if !os(macOS)
+ childController.additionalSafeAreaInsets = .zero
+ #endif
+
+ #if !os(macOS)
+ childController.willMove(toParent: nil)
+ #endif
+ childController.view.removeFromSuperview()
+ childController.removeFromParent()
+ }
+
+ // MARK: - Private methods
+
+ private func attachChild() {
+ addChild(childController)
+ snapshotView.add(childController.view, with: childSizeListener)
+ #if !os(macOS)
+ childController.didMove(toParent: self)
+ #endif
+
+ let heightAnchor = childController.view.heightAnchor.constraint(
+ equalTo: view.heightAnchor,
+ multiplier: 1
+ )
+
+ let widthAnchor = childController.view.widthAnchor.constraint(
+ equalTo: view.widthAnchor,
+ multiplier: 1
+ )
+
+ #if os(macOS)
+ heightAnchor.priority = .fittingSizeCompression
+ widthAnchor.priority = .fittingSizeCompression
+ #else
+ heightAnchor.priority = .fittingSizeLevel
+ widthAnchor.priority = .fittingSizeLevel
+ #endif
+
+ NSLayoutConstraint.activate(
+ [
+ heightAnchor,
+ widthAnchor,
+ ],
+ storingAt: &childConstraints
+ )
+
+ setupSizeConstraints()
+
+ #if !os(macOS)
+ childController.additionalSafeAreaInsets = configuration.safeArea
+ #endif
+ }
+
+ private func setupSizeConstraints() {
+ let size = configuration.size ?? .zero
+
+ if size.height > .zero {
+ childController.view.setContentHuggingPriority(.defaultLow, for: .vertical)
+ childController.view.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
+
+ let heightAnchor = childController.view.heightAnchor.constraint(
+ equalToConstant: size.height
+ )
+
+ heightAnchor.priority = .required
+
+ NSLayoutConstraint.activate(
+ [
+ heightAnchor
+ ],
+ storingAt: &childConstraints
+ )
+ } else {
+ childController.view.setContentHuggingPriority(.required, for: .vertical)
+ childController.view.setContentCompressionResistancePriority(.required, for: .vertical)
+ }
+
+ if size.width > .zero {
+ childController.view.setContentHuggingPriority(.defaultLow, for: .horizontal)
+ childController.view.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
+
+ let widthAnchor = childController.view.widthAnchor.constraint(
+ equalToConstant: size.width
+ )
+
+ widthAnchor.priority = .required
+
+ NSLayoutConstraint.activate(
+ [
+ widthAnchor
+ ],
+ storingAt: &childConstraints
+ )
+ } else {
+ childController.view.setContentHuggingPriority(.required, for: .horizontal)
+ childController.view.setContentCompressionResistancePriority(.required, for: .horizontal)
+ }
+ }
+
+ #if os(macOS)
+ private func snapshot(_ view: SDKView) throws -> SDKImage {
+ let bounds = snapshotView.calculateContentFrame()
+ guard let rep = view.bitmapImageRepForCachingDisplay(in: bounds) else {
+ throw RenderingError()
+ }
+
+ view.cacheDisplay(in: bounds, to: rep)
+
+ let snapshot = NSImage(size: rep.size)
+ snapshot.addRepresentation(rep)
+ return snapshot
+ }
+ #else
+ private func snapshot(
+ _ view: SDKView,
+ with traits: Traits
+ ) throws -> SDKImage {
+ let bounds = snapshotView.calculateContentFrame()
+
+ let format = UIGraphicsImageRendererFormat(
+ for: traits()
+ )
+
+ #if os(visionOS)
+ format.scale = traits.displayScale
+ #else
+ format.scale = view.window?.screen.scale ?? traits.displayScale
+ #endif
+
+ let renderer = UIGraphicsImageRenderer(
+ bounds: bounds,
+ format: format
+ )
+
+ return renderer.image {
+ view.layer.render(in: $0.cgContext)
+ }
+ }
+ #endif
+}
+#endif
+
+#if os(macOS)
+import CoreGraphics
+func CGContextCreateBitmapContext(size: CGSize, opaque: Bool, scale: CGFloat) -> CGContext? {
+ var scale = scale
+
+ if scale == .zero {
+ // Match `UIGraphicsBeginImageContextWithOptions`, reset to the scale factor of the device’s main screen if scale is 0.
+ scale = NSScreen.main?.backingScaleFactor ?? 1
+ }
+
+ let width = ceil(size.width * scale)
+ let height = ceil(size.height * scale)
+
+ guard width >= 1, height >= 1 else {
+ return nil
+ }
+
+ guard let space = NSScreen.main?.colorSpace?.cgColorSpace else {
+ return nil
+ }
+ // kCGImageAlphaNone is not supported in CGBitmapContextCreate.
+ // Check #3330 for more detail about why this bitmap is choosen.
+ // From v5.17.0, use runtime detection of bitmap info instead of hardcode.
+ // However, macOS's runtime detection will also call this function, cause recursive, so still hardcode here
+ let bitmapInfo: CGBitmapInfo
+ if !opaque {
+ // [NSImage imageWithSize:flipped:drawingHandler:] returns float(16-bits) RGBA8888 on alpha image, which we don't need
+ bitmapInfo = [
+ .byteOrderDefault, .init(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
+ ]
+ } else {
+ bitmapInfo = [.byteOrderDefault, .init(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue)]
+ }
+
+ guard
+ let context = CGContext(
+ data: nil,
+ width: Int(width),
+ height: Int(height),
+ bitsPerComponent: 8,
+ bytesPerRow: .zero,
+ space: space,
+ bitmapInfo: bitmapInfo.rawValue
+ )
+ else { return nil }
+
+ context.scaleBy(x: scale, y: scale)
+
+ return context
+}
+
+struct RenderingError: Error {}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Core/SnapshotView.swift b/Sources/XCSnapshotTesting/Methods/UI/Core/SnapshotView.swift
new file mode 100644
index 000000000..5793b9422
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Core/SnapshotView.swift
@@ -0,0 +1,172 @@
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+#elseif os(macOS)
+import AppKit
+#endif
+
+#if os(iOS) || os(tvOS) || os(visionOS) || os(macOS)
+class SnapshotView: SDKView {
+
+ let configuration: LayoutConfiguration
+
+ private var sizableViews: [ObjectIdentifier: (SDKView, SizeListener)] = [:]
+
+ init(configuration: LayoutConfiguration) {
+ self.configuration = configuration
+ super.init(frame: .zero)
+ #if os(macOS)
+ wantsLayer = true
+ #endif
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ #if os(macOS)
+ override func layout() {
+ super.layout()
+ self.updateTransformations()
+ }
+ #else
+ override func safeAreaInsetsDidChange() {
+ super.safeAreaInsetsDidChange()
+ self.updateTransformations()
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ self.updateTransformations()
+ }
+ #endif
+
+ func dispose() {
+ defer { sizableViews = [:] }
+
+ for (transformableView, sizeListener) in sizableViews.values {
+ sizeListener.dispose()
+ transformableView.removeFromSuperview()
+ }
+ }
+
+ func add(_ view: SDKView, with sizeListener: SizeListener) {
+ defer { sizeListener.delegate = self }
+
+ let transformableView = SDKView()
+ let containerView = SDKView()
+
+ view.translatesAutoresizingMaskIntoConstraints = false
+ transformableView.translatesAutoresizingMaskIntoConstraints = false
+ containerView.translatesAutoresizingMaskIntoConstraints = false
+
+ containerView.addSubview(view)
+ transformableView.addSubview(containerView)
+
+ super.addSubview(transformableView)
+
+ NSLayoutConstraint.activate([
+ transformableView.topAnchor.constraint(equalTo: topAnchor),
+ transformableView.leadingAnchor.constraint(equalTo: leadingAnchor),
+ transformableView.bottomAnchor.constraint(equalTo: bottomAnchor),
+ transformableView.trailingAnchor.constraint(equalTo: trailingAnchor),
+ ])
+
+ NSLayoutConstraint.activate([
+ view.centerXAnchor.constraint(equalTo: transformableView.centerXAnchor),
+ view.centerYAnchor.constraint(equalTo: transformableView.centerYAnchor),
+ ])
+
+ NSLayoutConstraint.activate([
+ view.topAnchor.constraint(equalTo: containerView.topAnchor),
+ view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
+ view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
+ view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
+ ])
+
+ sizableViews[sizeListener.id] = (transformableView, sizeListener)
+ }
+
+ func calculateContentFrame() -> CGRect {
+ var contentSize = sizableViews.values.reduce(CGSize.zero) {
+ CGSize(
+ width: max($0.width, $1.1.size.width),
+ height: max($0.height, $1.1.size.height)
+ )
+ }
+
+ let safeArea = configuration.safeArea
+
+ contentSize.width += safeArea.left + safeArea.right
+ contentSize.height += safeArea.top + safeArea.bottom
+
+ let scale = self.scale(for: contentSize)
+
+ contentSize.width *= scale
+ contentSize.height *= scale
+
+ return CGRect(
+ x: bounds.midX - contentSize.width / 2,
+ y: bounds.midY - contentSize.height / 2,
+ width: contentSize.width,
+ height: contentSize.height
+ )
+ }
+
+ private func updateTransformations() {
+ for (transformableView, sizeListener) in sizableViews.values {
+ downscale(transformableView, with: sizeListener.size)
+ }
+ }
+
+ private func downscale(_ transformableView: SDKView, with size: CGSize) {
+ let safeArea = configuration.safeArea
+ let scale = scale(
+ for: CGSize(
+ width: size.width + safeArea.left + safeArea.right,
+ height: size.height + safeArea.top + safeArea.bottom
+ )
+ )
+ #if os(macOS)
+ self.layer?.contentsScale = scale
+ #else
+ transformableView.transform = CGAffineTransform(scaleX: scale, y: scale)
+ #endif
+ transformableView.recursiveNeedsLayout()
+ #if os(macOS)
+ needsLayout = true
+ #else
+ transformableView.layoutIfNeeded()
+ #endif
+ }
+
+ private func scale(for size: CGSize) -> CGFloat {
+ guard frame.size.height > .zero && frame.size.width > .zero else {
+ return 1
+ }
+
+ let proposedSize: CGSize
+
+ if #available(macOS 11, *) {
+ proposedSize = CGSize(
+ width: frame.size.width - (safeAreaInsets.left + safeAreaInsets.right),
+ height: frame.size.height - (safeAreaInsets.top + safeAreaInsets.bottom)
+ )
+ } else {
+ proposedSize = frame.size
+ }
+
+ return proposedSize.scaleThatFits(size)
+ }
+}
+
+extension SnapshotView: SizeListenerDelegate {
+
+ func viewDidUpdateSize(_ id: ObjectIdentifier, size: CGSize) {
+ guard let (transformableView, _) = sizableViews[id] else {
+ return
+ }
+
+ downscale(transformableView, with: size)
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Core/ViewOperationPayload.swift b/Sources/XCSnapshotTesting/Methods/UI/Core/ViewOperationPayload.swift
new file mode 100644
index 000000000..412a3b70e
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Core/ViewOperationPayload.swift
@@ -0,0 +1,109 @@
+#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
+import UIKit
+#elseif os(macOS)
+@preconcurrency import AppKit
+#endif
+
+#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS)
+@MainActor
+struct ViewOperationPayload {
+ let previousRootViewController: SDKViewController?
+ let window: SDKWindow
+ let input: SnapshotUIController
+}
+
+extension Async where Output == SnapshotUIController {
+
+ func connectToWindow(
+ _ configuration: SnapshotWindowConfiguration
+ ) -> Async<
+ Input, ViewOperationPayload
+ > {
+ map { @MainActor in
+ ViewOperationPayload(
+ previousRootViewController: configuration.window.switchRoot($0),
+ window: configuration.window,
+ input: $0
+ )
+ }
+ }
+}
+
+extension Async where Output == ViewOperationPayload {
+
+ func waitLoadingStateIfNeeded(tolerance: TimeInterval) -> Async {
+ map {
+ await $0.input.view.waitLoadingStateIfNeeded(tolerance: tolerance)
+ return $0
+ }
+ }
+
+ func layoutIfNeeded() -> Async {
+ map { @MainActor in
+ $0.input.layoutIfNeeded()
+ return $0
+ }
+ }
+
+ func snapshot(
+ _ executor: Sync
+ ) -> Async {
+ map { @MainActor payload in
+ let image = try await executor(
+ payload.input.snapshot()
+ )
+
+ payload.window.removeRootViewController()
+
+ #if os(macOS)
+ payload.window.contentViewController = payload.previousRootViewController
+ #else
+ payload.window.rootViewController = payload.previousRootViewController
+ #endif
+
+ payload.input.detachChild()
+
+ if !payload.window.isKeyWindow {
+ #if os(macOS)
+ payload.window.close()
+ #else
+ payload.window.isHidden = true
+ #endif
+ }
+
+ return image
+ }
+ }
+
+ func descriptor(
+ _ executor: Sync,
+ method: SnapshotUIController.DescriptorMethod
+ ) -> Async {
+ map { @MainActor payload in
+ let string = try await executor(
+ payload.input.descriptor(method)
+ )
+
+ payload.window.removeRootViewController()
+
+ #if os(macOS)
+ payload.window.contentViewController = payload.previousRootViewController
+ #else
+ payload.window.rootViewController = payload.previousRootViewController
+ #endif
+
+ payload.input.detachChild()
+
+ if !payload.window.isKeyWindow {
+ #if os(macOS)
+ payload.window.close()
+ #else
+ payload.window.isHidden = true
+ #endif
+ }
+
+ return string
+ }
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Snapshot+CALayer.swift b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+CALayer.swift
new file mode 100644
index 000000000..c4f6ad6da
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+CALayer.swift
@@ -0,0 +1,83 @@
+#if os(macOS)
+import AppKit
+import Cocoa
+@preconcurrency import QuartzCore
+
+extension SyncSnapshot where Input: CALayer, Output == ImageBytes {
+ /// A snapshot strategy for comparing layers based on pixel equality.
+ ///
+ /// ``` swift
+ /// // Match reference perfectly.
+ /// assert(of: layer, as: .image)
+ ///
+ /// // Allow for a 1% pixel difference.
+ /// assert(of: layer, as: .image(precision: 0.99))
+ /// ```
+ public static var image: SyncSnapshot {
+ .image(precision: 1)
+ }
+
+ /// A snapshot strategy for comparing layers based on pixel equality.
+ ///
+ /// - Parameters:
+ /// - precision: The percentage of pixels that must match.
+ /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a
+ /// match. 98-99% mimics
+ /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the
+ /// human eye.
+ public static func image(
+ precision: Float,
+ perceptualPrecision: Float = 1
+ ) -> SyncSnapshot {
+ IdentitySyncSnapshot.image(
+ precision: precision,
+ perceptualPrecision: perceptualPrecision
+ ).pullback { layer in
+ let image = NSImage(size: layer.bounds.size)
+ image.lockFocus()
+ let context = NSGraphicsContext.current!.cgContext
+ layer.setNeedsLayout()
+ layer.layoutIfNeeded()
+ layer.render(in: context)
+ image.unlockFocus()
+ return image
+ }
+ }
+}
+#elseif os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+
+extension SyncSnapshot where Input: CALayer, Output == ImageBytes {
+ /// A snapshot strategy for comparing layers based on pixel equality.
+ public static var image: SyncSnapshot {
+ .image()
+ }
+
+ /// A snapshot strategy for comparing layers based on pixel equality.
+ ///
+ /// - Parameters:
+ /// - precision: The percentage of pixels that must match.
+ /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a
+ /// match. 98-99% mimics
+ /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the
+ /// human eye.
+ /// - traits: A trait collection override.
+ public static func image(
+ precision: Float = 1,
+ perceptualPrecision: Float = 1,
+ traits: UITraitCollection = .init()
+ ) -> SyncSnapshot {
+ IdentitySyncSnapshot.image(
+ precision: precision,
+ perceptualPrecision: perceptualPrecision
+ ).pullback { layer in
+ let renderer = UIGraphicsImageRenderer(bounds: layer.bounds, format: .init(for: traits))
+ return renderer.image { ctx in
+ layer.setNeedsLayout()
+ layer.layoutIfNeeded()
+ layer.render(in: ctx.cgContext)
+ }
+ }
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Snapshot+CGPath.swift b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+CGPath.swift
new file mode 100644
index 000000000..97ae8f9c2
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+CGPath.swift
@@ -0,0 +1,181 @@
+#if os(macOS)
+import AppKit
+import Cocoa
+@preconcurrency import CoreGraphics
+#elseif os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+#endif
+
+#if os(macOS)
+extension SyncSnapshot where Input: CGPath, Output == ImageBytes {
+ /// A snapshot strategy for comparing bezier paths based on pixel equality.
+ public static var image: SyncSnapshot {
+ .image()
+ }
+
+ /// A snapshot strategy for comparing bezier paths based on pixel equality.
+ ///
+ /// ``` swift
+ /// // Match reference perfectly.
+ /// assert(of: path, as: .image)
+ ///
+ /// // Allow for a 1% pixel difference.
+ /// assert(of: path, as: .image(precision: 0.99))
+ /// ```
+ ///
+ /// - Parameters:
+ /// - precision: The percentage of pixels that must match.
+ /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a
+ /// match. 98-99% mimics
+ /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the
+ /// human eye.
+ /// - drawingMode: The mode used to render the path, defined by `CGPathDrawingMode`. Determines whether the path is stroked, filled, or both.
+ public static func image(
+ precision: Float = 1,
+ perceptualPrecision: Float = 1,
+ drawingMode: CGPathDrawingMode = .eoFill
+ ) -> SyncSnapshot {
+ IdentitySyncSnapshot.image(
+ precision: precision,
+ perceptualPrecision: perceptualPrecision
+ ).pullback { path in
+ let bounds = path.boundingBoxOfPath
+ var transform = CGAffineTransform(
+ translationX: -bounds.origin.x,
+ y: -bounds.origin.y
+ )
+
+ let path = path.copy(using: &transform)!
+
+ let image = NSImage(size: bounds.size, flipped: false) { destRect in
+ guard let context = NSGraphicsContext.current else {
+ return false
+ }
+
+ context.imageInterpolation = .high
+ context.cgContext.addPath(path)
+ context.cgContext.drawPath(using: drawingMode)
+ return true
+ }
+
+ return image
+ }
+ }
+}
+#elseif os(iOS) || os(tvOS) || os(visionOS)
+extension SyncSnapshot where Input: CGPath, Output == ImageBytes {
+ /// A snapshot strategy for comparing bezier paths based on pixel equality.
+ public static var image: SyncSnapshot {
+ .image()
+ }
+
+ /// A snapshot strategy for comparing bezier paths based on pixel equality.
+ ///
+ /// - Parameters:
+ /// - precision: The percentage of pixels that must match.
+ /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a
+ /// match. 98-99% mimics
+ /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the
+ /// human eye.
+ /// - scale: The scale factor for the rendered image.
+ /// - drawingMode: The mode used to render the path, defined by `CGPathDrawingMode`. Determines whether the path is stroked, filled, or both.
+ public static func image(
+ precision: Float = 1,
+ perceptualPrecision: Float = 1,
+ scale: CGFloat = 1,
+ drawingMode: CGPathDrawingMode = .eoFill
+ ) -> SyncSnapshot {
+ IdentitySyncSnapshot.image(
+ precision: precision,
+ perceptualPrecision: perceptualPrecision
+ ).pullback { path in
+ let bounds = path.boundingBoxOfPath
+ let format: UIGraphicsImageRendererFormat
+ if #available(iOS 11.0, tvOS 11.0, *) {
+ format = UIGraphicsImageRendererFormat.preferred()
+ } else {
+ format = UIGraphicsImageRendererFormat.default()
+ }
+ format.scale = scale
+ let renderer = UIGraphicsImageRenderer(bounds: bounds, format: format)
+ return renderer.image { ctx in
+ let cgContext = ctx.cgContext
+ cgContext.addPath(path)
+ cgContext.drawPath(using: drawingMode)
+ }
+ }
+ }
+}
+#endif
+
+#if os(macOS) || os(iOS) || os(tvOS) || os(visionOS)
+extension SyncSnapshot where Input: CGPath, Output == StringBytes {
+ /// A snapshot strategy for comparing bezier paths based on element descriptions.
+ public static var elementsDescription: SyncSnapshot {
+ .elementsDescription(numberFormatter: defaultNumberFormatter)
+ }
+
+ /// A snapshot strategy for comparing bezier paths based on element descriptions.
+ ///
+ /// - Parameter numberFormatter: The number formatter used for formatting points.
+ public static func elementsDescription(
+ numberFormatter: NumberFormatter
+ ) -> SyncSnapshot<
+ Input, Output
+ > {
+ let namesByType: [CGPathElementType: String] = [
+ .moveToPoint: "MoveTo",
+ .addLineToPoint: "LineTo",
+ .addQuadCurveToPoint: "QuadCurveTo",
+ .addCurveToPoint: "CurveTo",
+ .closeSubpath: "Close",
+ ]
+
+ let numberOfPointsByType: [CGPathElementType: Int] = [
+ .moveToPoint: 1,
+ .addLineToPoint: 1,
+ .addQuadCurveToPoint: 2,
+ .addCurveToPoint: 3,
+ .closeSubpath: 0,
+ ]
+
+ return IdentitySyncSnapshot.lines.pullback { path in
+ var string: String = ""
+
+ path.applyWithBlock { elementPointer in
+ let element = elementPointer.pointee
+ let name = namesByType[element.type] ?? "Unknown"
+
+ if element.type == .moveToPoint && !string.isEmpty {
+ string += "\n"
+ }
+
+ string += name
+
+ if let numberOfPoints = numberOfPointsByType[element.type] {
+ let points = UnsafeBufferPointer(start: element.points, count: numberOfPoints)
+ string +=
+ " "
+ + points.map { point in
+ let x = numberFormatter.string(from: point.x as NSNumber)!
+ let y = numberFormatter.string(from: point.y as NSNumber)!
+ return "(\(x), \(y))"
+ }.joined(separator: " ")
+ }
+
+ string += "\n"
+ }
+
+ return string
+ }
+ }
+}
+
+private let defaultNumberFormatter: NumberFormatter = {
+ let numberFormatter = NumberFormatter()
+ numberFormatter.decimalSeparator = "."
+ numberFormatter.minimumFractionDigits = 1
+ numberFormatter.maximumFractionDigits = 3
+ return numberFormatter
+}()
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Snapshot+NSBezierPath.swift b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+NSBezierPath.swift
new file mode 100644
index 000000000..305d39938
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+NSBezierPath.swift
@@ -0,0 +1,130 @@
+#if os(macOS)
+@preconcurrency import AppKit
+import Cocoa
+
+extension SyncSnapshot where Input: NSBezierPath, Output == ImageBytes {
+ /// A snapshot strategy for comparing bezier paths based on pixel equality.
+ public static var image: SyncSnapshot {
+ .image()
+ }
+
+ /// A snapshot strategy for comparing bezier paths based on pixel equality.
+ ///
+ ///``` swift
+ /// // Match reference perfectly.
+ /// assert(of: path, as: .image)
+ ///
+ /// // Allow for a 1% pixel difference.
+ /// assert(of: path, as: .image(precision: 0.99))
+ /// ```
+ ///
+ /// - Parameters:
+ /// - precision: The percentage of pixels that must match.
+ /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a
+ /// match. 98-99% mimics
+ /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the
+ /// human eye.
+ public static func image(
+ precision: Float = 1,
+ perceptualPrecision: Float = 1
+ ) -> SyncSnapshot {
+ IdentitySyncSnapshot.image(
+ precision: precision,
+ perceptualPrecision: perceptualPrecision
+ ).map {
+ $0.pullback { path in
+ // Move path info frame:
+ let bounds = path.bounds
+ let transform = AffineTransform(
+ translationByX: -bounds.origin.x,
+ byY: -bounds.origin.y
+ )
+ path.transform(using: transform)
+
+ let image = NSImage(size: bounds.size, flipped: false) { destRect in
+ guard let context = NSGraphicsContext.current else {
+ return false
+ }
+
+ context.imageInterpolation = .high
+ path.fill()
+ return true
+ }
+
+ return image
+ }
+ }
+ }
+}
+
+extension SyncSnapshot where Input: NSBezierPath, Output == StringBytes {
+ /// A snapshot strategy for comparing bezier paths based on pixel equality.
+ @available(macOS 11.0, *)
+ public static var elementsDescription: SyncSnapshot {
+ .elementsDescription(numberFormatter: defaultNumberFormatter)
+ }
+
+ /// A snapshot strategy for comparing bezier paths based on pixel equality.
+ ///
+ /// - Parameter numberFormatter: The number formatter used for formatting points.
+ @available(macOS 11.0, *)
+ public static func elementsDescription(
+ numberFormatter: NumberFormatter
+ ) -> SyncSnapshot<
+ Input, Output
+ > {
+ let namesByType: [NSBezierPath.ElementType: String] = [
+ .moveTo: "MoveTo",
+ .lineTo: "LineTo",
+ .curveTo: "CurveTo",
+ .closePath: "Close",
+ ]
+
+ let numberOfPointsByType: [NSBezierPath.ElementType: Int] = [
+ .moveTo: 1,
+ .lineTo: 1,
+ .curveTo: 3,
+ .closePath: 0,
+ ]
+
+ return IdentitySyncSnapshot.lines.pullback { path in
+ var string: String = ""
+
+ var elementPoints = [CGPoint](repeating: .zero, count: 3)
+ for elementIndex in 0.. {
+ .image()
+ }
+
+ /// Creates a custom image snapshot configuration for `NSView`.
+ ///
+ /// This configuration allows you to capture `NSView` instances with custom settings for
+ /// layout, comparison precision, and other visual traits. It renders the view in isolation,
+ /// making it ideal for component testing.
+ ///
+ /// - Parameters:
+ /// - precision: Pixel tolerance for comparison (1 = perfect match, lower values allow more variation).
+ /// - perceptualPrecision: Tolerance for color and tonal differences (values closer to 1 require more exact matches).
+ /// - layout: Specifies how the view should be sized during rendering (e.g., specific device simulation).
+ /// - delay: Delay before capturing the image (useful for waiting for animations or dynamic content).
+ /// - application: The `NSApplication` instance to render the windows.
+ ///
+ /// - Example:
+ /// ```swift
+ /// let view = NSView()
+ /// view.backgroundColor = .red
+ /// view.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
+ ///
+ /// try await assert(
+ /// of: view,
+ /// as: .image(
+ /// layout: .device(.iPhone15Pro),
+ /// precision: 0.98
+ /// )
+ /// )
+ /// ```
+ public static func image(
+ precision: Float = 1,
+ perceptualPrecision: Float = 1,
+ layout: SnapshotLayout = .sizeThatFits,
+ delay: Double = .zero,
+ application: NSApplication? = nil
+ ) -> AsyncSnapshot {
+ let config = LayoutConfiguration.resolve(layout)
+
+ return IdentitySyncSnapshot.image(
+ precision: precision,
+ perceptualPrecision: perceptualPrecision
+ )
+ .withWindow(
+ sessionRole: .windowApplication,
+ application: application,
+ operation: { windowConfiguration, executor in
+ Async(Input.self) { @MainActor in
+ SnapshotUIController($0, with: config)
+ }
+ .connectToWindow(windowConfiguration)
+ .layoutIfNeeded()
+ .sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ .waitLoadingStateIfNeeded(tolerance: SnapshotEnvironment.current.webViewTolerance)
+ .snapshot(executor)
+ }
+ )
+ #if !os(macOS)
+ .inconsistentTraitsChecker(config.traits)
+ #endif
+ .withLock()
+ }
+}
+
+extension AsyncSnapshot where Input: NSView, Output == StringBytes {
+
+ /// A snapshot strategy for comparing views based on a recursive description of their properties
+ /// and hierarchies.
+ ///
+ /// This strategy captures a text-based representation of the view's hierarchy, including property values like frames, opacity, and layer information.
+ /// It's useful for verifying the structural integrity of complex views without relying on visual image comparisons.
+ ///
+ /// Example usage:
+ /// ```swift
+ /// let view = NSView()
+ /// view.backgroundColor = .red
+ /// view.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
+ ///
+ /// try await assert(of: view, as: .recursiveDescription)
+ /// ```
+ ///
+ /// Recorded snapshot:
+ ///
+ /// ```
+ /// >
+ /// ```
+ public static var recursiveDescription: AsyncSnapshot {
+ .recursiveDescription()
+ }
+
+ /// Creates a custom snapshot configuration for comparing views based on a recursive description of their properties and hierarchies.
+ ///
+ /// - Parameters:
+ /// - layout: Specifies how the view should be sized during rendering.
+ /// - delay: Delay before capturing the description (useful for waiting for animations or dynamic content).
+ /// - application: The `NSApplication` instance to render the windows.
+ ///
+ /// Example usage with custom layout:
+ /// ```swift
+ /// let view = NSView()
+ /// view.backgroundColor = .blue
+ /// view.frame = CGRect(x: 0, y: 0, width: 200, height: 300)
+ ///
+ /// try await assert(
+ /// of: view,
+ /// as: .recursiveDescription(
+ /// layout: .fixed(width: 200, height: 300)
+ /// )
+ /// )
+ /// ```
+ public static func recursiveDescription(
+ layout: SnapshotLayout = .sizeThatFits,
+ delay: Double = .zero,
+ application: NSApplication? = nil
+ ) -> AsyncSnapshot {
+ let config = LayoutConfiguration.resolve(layout)
+
+ return IdentitySyncSnapshot.lines
+ .withWindow(
+ sessionRole: .windowApplication,
+ application: application,
+ operation: { windowConfiguration, executor in
+ Async(Input.self) { @MainActor in
+ SnapshotUIController($0, with: config)
+ }
+ .connectToWindow(windowConfiguration)
+ .layoutIfNeeded()
+ .sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ .waitLoadingStateIfNeeded(tolerance: SnapshotEnvironment.current.webViewTolerance)
+ .descriptor(executor, method: .subtreeDescription)
+ }
+ )
+ #if !os(macOS)
+ .inconsistentTraitsChecker(config.traits)
+ #endif
+ .withLock()
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Snapshot+NSViewController.swift b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+NSViewController.swift
new file mode 100644
index 000000000..bd90e1743
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+NSViewController.swift
@@ -0,0 +1,152 @@
+#if os(macOS)
+import AppKit
+import Cocoa
+
+extension AsyncSnapshot where Input: NSViewController & Sendable, Output == ImageBytes {
+
+ /// Default configuration for capturing `NSViewController` as image snapshots.
+ ///
+ /// This configuration provides a basic setup for capturing view controllers with default settings.
+ /// It renders the view controller's view with default sizing and comparison precision.
+ ///
+ /// - Note: This configuration is suitable for simple view controllers where custom sizing or
+ /// comparison settings are not required.
+ public static var image: AsyncSnapshot {
+ .image()
+ }
+
+ /// Creates a custom image snapshot configuration for `NSViewController`.
+ ///
+ /// This configuration allows you to capture `NSViewController` instances with custom settings for
+ /// layout, comparison precision, and other visual traits. It renders the view controller's view
+ /// in isolation, making it ideal for screen testing.
+ ///
+ /// - Parameters:
+ /// - precision: Pixel tolerance for comparison (1 = perfect match, lower values allow more variation).
+ /// - perceptualPrecision: Tolerance for color and tonal differences (values closer to 1 require more exact matches).
+ /// - layout: Specifies how the view should be sized during rendering (e.g., specific device simulation).
+ /// - delay: Delay before capturing the image (useful for waiting for animations or dynamic content).
+ /// - application: The `NSApplication` instance to render the windows.
+ ///
+ /// Example usage:
+ /// ```swift
+ /// let viewController = NSViewController()
+ /// viewController.view.backgroundColor = .red
+ /// viewController.view.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
+ ///
+ /// try await assert(
+ /// of: viewController,
+ /// as: .image(
+ /// layout: .device(.iPhone15Pro),
+ /// precision: 0.98
+ /// )
+ /// )
+ /// ```
+ public static func image(
+ precision: Float = 1,
+ perceptualPrecision: Float = 1,
+ layout: SnapshotLayout = .sizeThatFits,
+ delay: Double = .zero,
+ application: NSApplication? = nil
+ ) -> AsyncSnapshot {
+ let config = LayoutConfiguration.resolve(layout)
+
+ return IdentitySyncSnapshot.image(
+ precision: precision,
+ perceptualPrecision: perceptualPrecision
+ )
+ .withWindow(
+ sessionRole: .windowApplication,
+ application: application,
+ operation: { windowConfiguration, executor in
+ Async(Input.self) { @MainActor in
+ SnapshotUIController($0, with: config)
+ }
+ .connectToWindow(windowConfiguration)
+ .layoutIfNeeded()
+ .sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ .waitLoadingStateIfNeeded(tolerance: SnapshotEnvironment.current.webViewTolerance)
+ .snapshot(executor)
+ }
+ )
+ #if !os(macOS)
+ .inconsistentTraitsChecker(config.traits)
+ #endif
+ .withLock()
+ }
+}
+
+extension Snapshot where Input: NSViewController, Output == StringBytes {
+
+ /// A snapshot strategy for comparing view controller views based on a recursive description of their properties and hierarchies.
+ ///
+ /// This strategy captures a text-based representation of the view hierarchy, including property values like frames, opacity, and layer information.
+ /// It's useful for verifying the structural integrity of complex views within view controllers without relying on visual image comparisons.
+ ///
+ /// Example usage:
+ /// ```swift
+ /// let viewController = NSViewController()
+ /// viewController.view.backgroundColor = .red
+ /// viewController.view.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
+ ///
+ /// try await assert(of: viewController, as: .recursiveDescription)
+ /// ```
+ ///
+ /// Recorded snapshot:
+ ///
+ /// ```
+ /// >
+ /// ```
+ public static var recursiveDescription: AsyncSnapshot {
+ .recursiveDescription()
+ }
+
+ /// Creates a custom snapshot configuration for comparing view controller views based on a recursive description of their properties and hierarchies.
+ ///
+ /// - Parameters:
+ /// - layout: Specifies how the view should be sized during rendering.
+ /// - delay: Delay before capturing the recursive description (useful for waiting for animations or dynamic content).
+ /// - application: The `NSApplication` instance to render the windows.
+ ///
+ /// Example usage with custom layout:
+ /// ```swift
+ /// let viewController = NSViewController()
+ /// viewController.view.backgroundColor = .green
+ /// viewController.view.frame = CGRect(x: 0, y: 0, width: 300, height: 400)
+ ///
+ /// try await assert(
+ /// of: viewController,
+ /// as: .recursiveDescription(
+ /// layout: .fixed(width: 300, height: 400)
+ /// )
+ /// )
+ /// ```
+ public static func recursiveDescription(
+ layout: SnapshotLayout = .sizeThatFits,
+ delay: Double = .zero,
+ application: NSApplication? = nil
+ ) -> AsyncSnapshot {
+ let config = LayoutConfiguration.resolve(layout)
+
+ return IdentitySyncSnapshot.lines
+ .withWindow(
+ sessionRole: .windowApplication,
+ application: application,
+ operation: { windowConfiguration, executor in
+ Async(Input.self) { @MainActor in
+ SnapshotUIController($0, with: config)
+ }
+ .connectToWindow(windowConfiguration)
+ .layoutIfNeeded()
+ .sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ .waitLoadingStateIfNeeded(tolerance: SnapshotEnvironment.current.webViewTolerance)
+ .descriptor(executor, method: .subtreeDescription)
+ }
+ )
+ #if !os(macOS)
+ .inconsistentTraitsChecker(config.traits)
+ #endif
+ .withLock()
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Snapshot+SceneKit.swift b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+SceneKit.swift
new file mode 100644
index 000000000..184837da2
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+SceneKit.swift
@@ -0,0 +1,109 @@
+#if os(iOS) || os(macOS) || os(tvOS) || os(visionOS)
+import SceneKit
+#if os(macOS)
+import Cocoa
+#elseif os(iOS) || os(tvOS)
+import UIKit
+#endif
+
+#if os(macOS)
+extension AsyncSnapshot where Input: SCNScene & Sendable, Output == ImageBytes {
+ /// A snapshot strategy for comparing SceneKit scenes based on pixel equality.
+ ///
+ /// - Parameters:
+ /// - precision: The percentage of pixels that must match.
+ /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a
+ /// match. 98-99% mimics
+ /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the
+ /// human eye.
+ /// - size: The size of the scene.
+ /// - delay: Delay before capturing the image (useful for waiting for animations or dynamic content).
+ /// - application: The `NSApplication` instance to render the windows.
+ public static func image(
+ precision: Float = 1,
+ perceptualPrecision: Float = 1,
+ size: CGSize,
+ delay: Double = .zero,
+ application: NSApplication? = nil
+ ) -> AsyncSnapshot {
+ .scnScene(
+ sessionRole: .windowApplication,
+ precision: precision,
+ perceptualPrecision: perceptualPrecision,
+ size: size,
+ delay: delay,
+ application: application
+ )
+ }
+}
+#elseif os(iOS) || os(tvOS) || os(visionOS)
+extension Snapshot where Input: SCNScene & Sendable, Output == ImageBytes {
+ /// A snapshot strategy for comparing SceneKit scenes based on pixel equality.
+ ///
+ /// - Parameters:
+ /// - sessionRole: Defines the role of the UI session (default is `.windowApplication`).
+ /// - precision: The percentage of pixels that must match.
+ /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a
+ /// match. 98-99% mimics
+ /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the
+ /// human eye.
+ /// - size: The size of the scene.
+ /// - delay: Delay before capturing the image (useful for waiting for animations or dynamic content).
+ /// - application: The `UIApplication` instance to render the windows.
+ public static func image(
+ sessionRole: UISceneSession.Role = .windowApplication,
+ precision: Float = 1,
+ perceptualPrecision: Float = 1,
+ size: CGSize,
+ delay: Double = .zero,
+ application: UIKit.UIApplication? = nil
+ ) -> AsyncSnapshot {
+ .scnScene(
+ sessionRole: sessionRole,
+ precision: precision,
+ perceptualPrecision: perceptualPrecision,
+ size: size,
+ delay: delay,
+ application: application
+ )
+ }
+}
+#endif
+
+extension Snapshot where Input: SCNScene & Sendable, Output == ImageBytes {
+
+ fileprivate static func scnScene(
+ sessionRole: UISceneSession.Role,
+ precision: Float,
+ perceptualPrecision: Float,
+ size: CGSize,
+ delay: Double = .zero,
+ application: SDKApplication?
+ ) -> AsyncSnapshot {
+ #if os(macOS)
+ let snapshot = AsyncSnapshot.image(
+ precision: precision,
+ perceptualPrecision: perceptualPrecision,
+ layout: .fixed(width: size.width, height: size.height),
+ delay: delay,
+ application: application
+ )
+ #else
+ let snapshot = AsyncSnapshot.image(
+ sessionRole: sessionRole,
+ precision: precision,
+ perceptualPrecision: perceptualPrecision,
+ layout: .fixed(width: size.width, height: size.height),
+ delay: delay,
+ application: application
+ )
+ #endif
+
+ return snapshot.pullback { @MainActor scene in
+ let view = SCNView(frame: .init(origin: .zero, size: size))
+ view.scene = scene
+ return view
+ }
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Snapshot+SpriteKit.swift b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+SpriteKit.swift
new file mode 100644
index 000000000..926674ec8
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+SpriteKit.swift
@@ -0,0 +1,108 @@
+#if os(iOS) || os(macOS) || os(tvOS) || os(visionOS)
+import SpriteKit
+#if os(macOS)
+import Cocoa
+#elseif os(iOS) || os(tvOS)
+import UIKit
+#endif
+
+#if os(macOS)
+extension AsyncSnapshot where Input: SKScene & Sendable, Output == ImageBytes {
+ /// A snapshot strategy for comparing SpriteKit scenes based on pixel equality.
+ ///
+ /// - Parameters:
+ /// - precision: The percentage of pixels that must match.
+ /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a
+ /// match. 98-99% mimics
+ /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the
+ /// human eye.
+ /// - size: The size of the scene.
+ /// - delay: Delay before capturing the image (useful for waiting for animations or dynamic content).
+ /// - application: The `NSApplication` instance to render the windows.
+ public static func image(
+ precision: Float = 1,
+ perceptualPrecision: Float = 1,
+ size: CGSize,
+ delay: Double = .zero,
+ application: NSApplication? = nil
+ ) -> AsyncSnapshot {
+ .skScene(
+ sessionRole: .windowApplication,
+ precision: precision,
+ perceptualPrecision: perceptualPrecision,
+ size: size,
+ delay: delay,
+ application: application
+ )
+ }
+}
+#elseif os(iOS) || os(tvOS) || os(visionOS)
+extension AsyncSnapshot where Input: SKScene, Output == ImageBytes {
+ /// A snapshot strategy for comparing SpriteKit scenes based on pixel equality.
+ ///
+ /// - Parameters:
+ /// - sessionRole: The role of the UI session (default is `.windowApplication`, appropriate for most UI testing scenarios).
+ /// - precision: The percentage of pixels that must match.
+ /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a
+ /// match. 98-99% mimics
+ /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the
+ /// human eye.
+ /// - size: The size of the scene.
+ /// - delay: Delay before capturing the image (useful for waiting for animations or dynamic content).
+ /// - application: The `UIApplication` instance to render the windows.
+ public static func image(
+ sessionRole: UISceneSession.Role = .windowApplication,
+ precision: Float = 1,
+ perceptualPrecision: Float = 1,
+ size: CGSize,
+ delay: Double = .zero,
+ application: UIKit.UIApplication? = nil
+ ) -> AsyncSnapshot {
+ .skScene(
+ sessionRole: sessionRole,
+ precision: precision,
+ perceptualPrecision: perceptualPrecision,
+ size: size,
+ delay: delay,
+ application: application
+ )
+ }
+}
+#endif
+
+extension AsyncSnapshot where Input: SKScene, Output == ImageBytes {
+
+ fileprivate static func skScene(
+ sessionRole: UISceneSession.Role,
+ precision: Float,
+ perceptualPrecision: Float,
+ size: CGSize,
+ delay: Double,
+ application: SDKApplication?
+ ) -> AsyncSnapshot {
+ #if os(macOS)
+ let snapshot = AsyncSnapshot.image(
+ precision: precision,
+ perceptualPrecision: perceptualPrecision,
+ layout: .fixed(width: size.width, height: size.height),
+ delay: delay,
+ application: application
+ )
+ #else
+ let snapshot = AsyncSnapshot.image(
+ sessionRole: sessionRole,
+ precision: precision,
+ perceptualPrecision: perceptualPrecision,
+ layout: .fixed(width: size.width, height: size.height),
+ delay: delay,
+ application: application
+ )
+ #endif
+ return snapshot.pullback { @MainActor scene in
+ let view = SKView(frame: .init(origin: .zero, size: size))
+ view.presentScene(scene)
+ return view
+ }
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Snapshot+UIApplication.swift b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+UIApplication.swift
new file mode 100644
index 000000000..e72e4baad
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+UIApplication.swift
@@ -0,0 +1,94 @@
+#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
+import UIKit
+#elseif os(macOS)
+@preconcurrency import AppKit
+#endif
+
+#if os(iOS) || os(tvOS) || os(visionOS)
+extension Snapshot where Input: UIKit.UIApplication, Output == ImageBytes {
+
+ /// Default configuration for capturing `UIApplication` as image snapshots.
+ ///
+ /// This configuration is particularly useful when used with any UI testing framework to capture the current UI state after performing user interactions.
+ /// It allows you to drive UI interactions using the UI testing framework functions and take snapshots of the rendered UI at strategic moments.
+ ///
+ /// Notes:
+ /// - Uses default values for precision (`precision: 1`) and other parameters.
+ /// - Captures the current screen rendered in `UIWindow`.
+ /// - Useful for end-to-end testing where you need to verify the visual state after a series of actions.
+ ///
+ /// Example usage with XCUITest (renamed on Xcode 16.3 as XCUIAutomation):
+ /// ```swift
+ /// let app = XCUIApplication()
+ /// app.launch()
+ ///
+ /// // Perform UI interactions
+ /// app.buttons["Login"].tap()
+ ///
+ /// // Capture snapshot of the current UI state
+ /// try assert(of: UIApplication.shared, as: .image)
+ /// ```
+ public static var image: AsyncSnapshot {
+ .image()
+ }
+
+ /// Creates a custom image snapshot configuration for `UIApplication`.
+ ///
+ /// This configuration is designed for use with any UI testing framework, allowing you to capture the current UI state after performing user interactions.
+ /// It captures the contents of the application's `UIWindow`, making it ideal for verifying visual changes after specific user actions or workflow steps.
+ ///
+ /// - Parameters:
+ /// - precision: Pixel tolerance for comparison (1 = perfect match, 0.95 = 5% variation allowed).
+ /// - perceptualPrecision: Color/tonal tolerance for perceptual comparison (values closer to 1 require more exact color matching).
+ /// - delay: Delay before capturing the image (useful for waiting for animations or network responses).
+ /// - sessionRole: The role of the UI session (default is `.windowApplication`, appropriate for most UI testing scenarios).
+ ///
+ /// Example usage with XCUITest (renamed on Xcode 16.3 as XCUIAutomation):
+ /// ```swift
+ /// let app = XCUIApplication()
+ /// app.launch()
+ ///
+ /// // Perform UI interactions
+ /// app.buttons["Submit"].tap()
+ ///
+ /// // Capture snapshot with custom precision
+ /// try assert(
+ /// of: UIApplication.shared,
+ /// as: .image(
+ /// precision: 0.98,
+ /// delay: 2.0
+ /// )
+ /// )
+ /// ```
+ public static func image(
+ precision: Float = 1,
+ perceptualPrecision: Float = 1,
+ delay: Double = .zero,
+ sessionRole: UISceneSession.Role = .windowApplication
+ ) -> AsyncSnapshot {
+ IdentitySyncSnapshot.image(
+ precision: precision,
+ perceptualPrecision: perceptualPrecision
+ )
+ .withApplication(sessionRole: sessionRole) { window, executor in
+ Async(Input.self) { _ in window }
+ .sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ .map { @MainActor window in
+ let renderer = UIGraphicsImageRenderer(
+ bounds: window.bounds,
+ format: .init(for: window.traitCollection)
+ )
+
+ let image = try await executor(
+ renderer.image {
+ window.layer.render(in: $0.cgContext)
+ }
+ )
+
+ return image
+ }
+ }
+ .withLock()
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Snapshot+UIBezierPath.swift b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+UIBezierPath.swift
new file mode 100644
index 000000000..1f5e83670
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+UIBezierPath.swift
@@ -0,0 +1,65 @@
+#if os(iOS) || os(tvOS) || os(visionOS)
+@preconcurrency import UIKit
+
+extension SyncSnapshot where Input: UIBezierPath, Output == ImageBytes {
+ /// A snapshot strategy for comparing bezier paths based on pixel equality.
+ public static var image: SyncSnapshot {
+ .image()
+ }
+
+ /// A snapshot strategy for comparing bezier paths based on pixel equality.
+ ///
+ /// - Parameters:
+ /// - precision: The percentage of pixels that must match.
+ /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a
+ /// match. 98-99% mimics
+ /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the
+ /// human eye.
+ /// - scale: The scale factor for the rendered image.
+ public static func image(
+ precision: Float = 1,
+ perceptualPrecision: Float = 1,
+ scale: CGFloat = 1
+ ) -> SyncSnapshot {
+ IdentitySyncSnapshot.image(
+ precision: precision,
+ perceptualPrecision: perceptualPrecision
+ ).pullback { path in
+ let bounds = path.bounds
+ let format: UIGraphicsImageRendererFormat
+ if #available(iOS 11.0, tvOS 11.0, *) {
+ format = UIGraphicsImageRendererFormat.preferred()
+ } else {
+ format = UIGraphicsImageRendererFormat.default()
+ }
+ format.scale = scale
+ let renderer = UIGraphicsImageRenderer(bounds: bounds, format: format)
+ return renderer.image { ctx in
+ path.fill()
+ }
+ }
+ }
+}
+
+extension SyncSnapshot where Input: UIBezierPath, Output == StringBytes {
+ /// A snapshot strategy for comparing bezier paths based on pixel equality.
+ public static var elementsDescription: SyncSnapshot {
+ SyncSnapshot.elementsDescription.pullback {
+ $0.cgPath
+ }
+ }
+
+ /// A snapshot strategy for comparing bezier paths based on pixel equality.
+ ///
+ /// - Parameter numberFormatter: The number formatter used for formatting points.
+ public static func elementsDescription(
+ numberFormatter: NumberFormatter
+ ) -> SyncSnapshot<
+ Input, Output
+ > {
+ SyncSnapshot.elementsDescription(
+ numberFormatter: numberFormatter
+ ).pullback { path in path.cgPath }
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Snapshot+UIView.swift b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+UIView.swift
new file mode 100644
index 000000000..31ea018af
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+UIView.swift
@@ -0,0 +1,169 @@
+#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
+import UIKit
+#elseif os(macOS)
+@preconcurrency import AppKit
+#endif
+
+#if os(iOS) || os(tvOS) || os(visionOS)
+extension AsyncSnapshot where Input: UIKit.UIView, Output == ImageBytes {
+
+ /// Default configuration for capturing `UIView` as image snapshots.
+ ///
+ /// This configuration provides a basic setup for capturing views with default settings.
+ /// It uses the view's intrinsic content size and default comparison precision.
+ ///
+ /// - Note: This configuration is suitable for simple views where custom sizing or
+ /// comparison settings are not required.
+ public static var image: AsyncSnapshot {
+ .image()
+ }
+
+ /// Creates a custom image snapshot configuration for `UIView`.
+ ///
+ /// This configuration allows you to capture `UIView` instances with custom settings for
+ /// layout, comparison precision, and other visual traits. It renders the view in isolation,
+ /// making it ideal for component testing.
+ ///
+ /// - Parameters:
+ /// - sessionRole: Defines the role of the UI session (default is `.windowApplication`).
+ /// - precision: Pixel tolerance for comparison (1 = perfect match, lower values allow more variation).
+ /// - perceptualPrecision: Tolerance for color and tonal differences (values closer to 1 require more exact matches).
+ /// - layout: Specifies how the view should be sized during rendering (e.g., specific device simulation).
+ /// - traits: Collection of UI traits (e.g., accessibility features, display characteristics).
+ /// - delay: Delay before capturing the image (useful for waiting for animations or dynamic content).
+ /// - application: The `UIApplication` instance to render the windows.
+ ///
+ /// - Example:
+ /// ```swift
+ /// let view = UIView()
+ /// view.backgroundColor = .red
+ /// view.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
+ ///
+ /// try await assert(
+ /// of: view,
+ /// as: .image(
+ /// layout: .device(.iPhone15Pro),
+ /// precision: 0.98
+ /// )
+ /// )
+ /// ```
+ public static func image(
+ sessionRole: UISceneSession.Role = .windowApplication,
+ precision: Float = 1,
+ perceptualPrecision: Float = 1,
+ layout: SnapshotLayout = .sizeThatFits,
+ traits: Traits = .init(),
+ delay: Double = .zero,
+ application: UIKit.UIApplication? = nil
+ ) -> AsyncSnapshot {
+ let config = LayoutConfiguration.resolve(
+ layout,
+ with: SnapshotEnvironment.current.traits.merging(traits)
+ )
+
+ return IdentitySyncSnapshot.image(
+ precision: precision,
+ perceptualPrecision: perceptualPrecision
+ )
+ .withWindow(
+ sessionRole: sessionRole,
+ application: application,
+ operation: { windowConfiguration, executor in
+ Async(Input.self) { @MainActor in
+ SnapshotUIController($0, with: config)
+ }
+ .connectToWindow(windowConfiguration)
+ .layoutIfNeeded()
+ .sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ #if !os(tvOS)
+ .waitLoadingStateIfNeeded(tolerance: SnapshotEnvironment.current.webViewTolerance)
+ #endif
+ .snapshot(executor)
+ }
+ )
+ .inconsistentTraitsChecker(config.traits)
+ .withLock()
+ }
+}
+
+extension AsyncSnapshot where Input: UIKit.UIView, Output == StringBytes {
+
+ /// A snapshot strategy for comparing views based on a recursive description of their properties
+ /// and hierarchies.
+ ///
+ /// This strategy captures a text-based representation of the view's hierarchy, including property values like frames, opacity, and layer information.
+ /// It's useful for verifying the structural integrity of complex views without relying on visual image comparisons.
+ ///
+ /// Example usage:
+ /// ```swift
+ /// let view = UIView()
+ /// view.backgroundColor = .red
+ /// view.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
+ ///
+ /// try await assert(of: view, as: .recursiveDescription)
+ /// ```
+ ///
+ /// Recorded snapshot:
+ ///
+ /// ```
+ /// >
+ /// ```
+ public static var recursiveDescription: AsyncSnapshot {
+ Snapshot.recursiveDescription()
+ }
+
+ /// Creates a custom snapshot configuration for comparing views based on a recursive description of their properties and hierarchies.
+ ///
+ /// - Parameters:
+ /// - sessionRole: Defines the role of the UI session (default is `.windowApplication`).
+ /// - layout: Specifies how the view should be sized during rendering.
+ /// - traits: Collection of UI traits (e.g., accessibility features, display characteristics).
+ /// - delay: Delay before capturing the description (useful for waiting for animations or dynamic content).
+ /// - application: The `UIApplication` instance to render the windows.
+ ///
+ /// Example usage with custom layout:
+ /// ```swift
+ /// let view = UIView()
+ /// view.backgroundColor = .blue
+ /// view.frame = CGRect(x: 0, y: 0, width: 200, height: 300)
+ ///
+ /// try await assert(
+ /// of: view,
+ /// as: .recursiveDescription(
+ /// layout: .fixed(width: 200, height: 300)
+ /// )
+ /// )
+ /// ```
+ public static func recursiveDescription(
+ sessionRole: UISceneSession.Role = .windowApplication,
+ layout: SnapshotLayout = .sizeThatFits,
+ traits: Traits = .init(),
+ delay: Double = .zero,
+ application: UIKit.UIApplication? = nil
+ ) -> AsyncSnapshot {
+ let config = LayoutConfiguration.resolve(
+ layout,
+ with: SnapshotEnvironment.current.traits.merging(traits)
+ )
+
+ return IdentitySyncSnapshot.lines
+ .withWindow(
+ sessionRole: sessionRole,
+ application: application,
+ operation: { windowConfiguration, executor in
+ Async(Input.self) { @MainActor in
+ SnapshotUIController($0, with: config)
+ }
+ .connectToWindow(windowConfiguration)
+ .layoutIfNeeded()
+ .sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ #if !os(tvOS)
+ .waitLoadingStateIfNeeded(tolerance: SnapshotEnvironment.current.webViewTolerance)
+ #endif
+ .descriptor(executor, method: .recursiveDescription)
+ }
+ )
+ .withLock()
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Snapshot+UIViewController.swift b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+UIViewController.swift
new file mode 100644
index 000000000..dd2df9ae3
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+UIViewController.swift
@@ -0,0 +1,251 @@
+#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
+import UIKit
+#elseif os(macOS)
+@preconcurrency import AppKit
+#endif
+
+#if os(iOS) || os(tvOS) || os(visionOS)
+extension AsyncSnapshot where Input: UIKit.UIViewController, Output == ImageBytes {
+
+ /// Default configuration for capturing `UIViewController` as image snapshots.
+ ///
+ /// This configuration provides a basic setup for capturing view controllers with default settings.
+ /// It renders the view controller's view with default sizing and comparison precision.
+ ///
+ /// - Note: This configuration is suitable for simple view controllers where custom sizing or
+ /// comparison settings are not required.
+ public static var image: AsyncSnapshot {
+ .image()
+ }
+
+ /// Creates a custom image snapshot configuration for `UIViewController`.
+ ///
+ /// This configuration allows you to capture `UIViewController` instances with custom settings for
+ /// layout, comparison precision, and other visual traits. It renders the view controller's view
+ /// in isolation, making it ideal for screen testing.
+ ///
+ /// - Parameters:
+ /// - sessionRole: Defines the role of the UI session (default is `.windowApplication`).
+ /// - precision: Pixel tolerance for comparison (1 = perfect match, lower values allow more variation).
+ /// - perceptualPrecision: Tolerance for color and tonal differences (values closer to 1 require more exact matches).
+ /// - layout: Specifies how the view should be sized during rendering (e.g., specific device simulation).
+ /// - traits: Collection of UI traits (e.g., accessibility features, display characteristics).
+ /// - delay: Delay before capturing the image (useful for waiting for animations or dynamic content).
+ /// - application: The `UIApplication` instance to render the windows.
+ ///
+ /// Example usage:
+ /// ```swift
+ /// let viewController = UIViewController()
+ /// viewController.view.backgroundColor = .red
+ /// viewController.view.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
+ ///
+ /// try await assert(
+ /// of: viewController,
+ /// as: .image(
+ /// layout: .device(.iPhone15Pro),
+ /// precision: 0.98
+ /// )
+ /// )
+ /// ```
+ public static func image(
+ sessionRole: UISceneSession.Role = .windowApplication,
+ precision: Float = 1,
+ perceptualPrecision: Float = 1,
+ layout: SnapshotLayout = .sizeThatFits,
+ traits: Traits = .init(),
+ delay: Double = .zero,
+ application: UIKit.UIApplication? = nil
+ ) -> AsyncSnapshot {
+ let config = LayoutConfiguration.resolve(
+ layout,
+ with: SnapshotEnvironment.current.traits.merging(traits)
+ )
+
+ return IdentitySyncSnapshot.image(
+ precision: precision,
+ perceptualPrecision: perceptualPrecision
+ )
+ .withWindow(
+ sessionRole: sessionRole,
+ application: application,
+ operation: { windowConfiguration, executor in
+ Async(Input.self) { @MainActor in
+ SnapshotUIController($0, with: config)
+ }
+ .connectToWindow(windowConfiguration)
+ .layoutIfNeeded()
+ .sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ #if !os(tvOS)
+ .waitLoadingStateIfNeeded(tolerance: SnapshotEnvironment.current.webViewTolerance)
+ #endif
+ .snapshot(executor)
+ }
+ )
+ .inconsistentTraitsChecker(config.traits)
+ .withLock()
+ }
+}
+
+extension AsyncSnapshot where Input: UIKit.UIViewController, Output == StringBytes {
+
+ /// A snapshot strategy for comparing view controllers based on their embedded controller hierarchy.
+ ///
+ /// This strategy captures a text-based representation of the view controller hierarchy, including states like "appeared" or "disappeared."
+ /// It's useful for verifying the structural integrity of complex view controller hierarchies without relying on visual image comparisons.
+ ///
+ /// Example usage:
+ /// ```swift
+ /// let tabBarController = UITabBarController()
+ /// tabBarController.viewControllers = [UIViewController(), UIViewController()]
+ ///
+ /// try await assert(of: tabBarController, as: .hierarchy)
+ /// ```
+ ///
+ /// Recorded snapshot:
+ ///
+ /// ```
+ /// , state: appeared, view:
+ /// | , state: appeared, view:
+ /// | | , state: appeared, view:
+ /// | , state: disappeared, view: not in the window
+ /// | | , state: disappeared, view: (view not loaded)
+ /// ```
+ public static var hierarchy: AsyncSnapshot {
+ Snapshot.hierarchy()
+ }
+
+ /// A snapshot strategy for comparing view controller views based on a recursive description of their properties and hierarchies.
+ ///
+ /// This strategy captures a text-based representation of the view hierarchy, including property values like frames, opacity, and layer information.
+ /// It's useful for verifying the structural integrity of complex views within view controllers without relying on visual image comparisons.
+ ///
+ /// Example usage:
+ /// ```swift
+ /// let viewController = UIViewController()
+ /// viewController.view.backgroundColor = .red
+ /// viewController.view.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
+ ///
+ /// try await assert(of: viewController, as: .recursiveDescription)
+ /// ```
+ ///
+ /// Recorded snapshot:
+ ///
+ /// ```
+ /// >
+ /// ```
+ public static var recursiveDescription: AsyncSnapshot {
+ Snapshot.recursiveDescription()
+ }
+
+ /// Creates a custom snapshot configuration for comparing view controllers based on their embedded controller hierarchy.
+ ///
+ /// - Parameters:
+ /// - sessionRole: Defines the role of the UI session (default is `.windowApplication`).
+ /// - layout: Specifies how the view should be sized during rendering.
+ /// - traits: Collection of UI traits (e.g., accessibility features, display characteristics).
+ /// - delay: Delay before capturing the hierarchy description (useful for waiting for animations or dynamic content).
+ /// - application: The `UIApplication` instance to render the windows.
+ ///
+ /// Example usage with custom layout:
+ /// ```swift
+ /// let viewController = UIViewController()
+ /// viewController.view.backgroundColor = .blue
+ /// viewController.view.frame = CGRect(x: 0, y: 0, width: 200, height: 300)
+ ///
+ /// try await assert(
+ /// of: viewController,
+ /// as: .hierarchy(
+ /// layout: .fixed(width: 200, height: 300)
+ /// )
+ /// )
+ /// ```
+ public static func hierarchy(
+ sessionRole: UISceneSession.Role = .windowApplication,
+ layout: SnapshotLayout = .sizeThatFits,
+ traits: Traits = .init(),
+ delay: Double = .zero,
+ application: UIKit.UIApplication? = nil
+ ) -> AsyncSnapshot {
+ descriptor(
+ method: .hierarchy,
+ sessionRole: sessionRole,
+ layout: layout,
+ traits: traits,
+ delay: delay,
+ application: application
+ )
+ }
+
+ /// Creates a custom snapshot configuration for comparing view controller views based on a recursive description of their properties and hierarchies.
+ ///
+ /// - Parameters:
+ /// - sessionRole: Defines the role of the UI session (default is `.windowApplication`).
+ /// - layout: Specifies how the view should be sized during rendering.
+ /// - traits: Collection of UI traits (e.g., accessibility features, display characteristics).
+ /// - delay: Delay before capturing the recursive description (useful for waiting for animations or dynamic content).
+ /// - application: The `UIApplication` instance to render the windows.
+ ///
+ /// Example usage with custom layout:
+ /// ```swift
+ /// let viewController = UIViewController()
+ /// viewController.view.backgroundColor = .green
+ /// viewController.view.frame = CGRect(x: 0, y: 0, width: 300, height: 400)
+ ///
+ /// try await assert(
+ /// of: viewController,
+ /// as: .recursiveDescription(
+ /// layout: .fixed(width: 300, height: 400)
+ /// )
+ /// )
+ /// ```
+ public static func recursiveDescription(
+ sessionRole: UISceneSession.Role = .windowApplication,
+ layout: SnapshotLayout = .sizeThatFits,
+ traits: Traits = .init(),
+ delay: Double = .zero,
+ application: UIKit.UIApplication? = nil
+ ) -> AsyncSnapshot {
+ descriptor(
+ method: .recursiveDescription,
+ sessionRole: sessionRole,
+ layout: layout,
+ traits: traits,
+ delay: delay,
+ application: application
+ )
+ }
+
+ private static func descriptor(
+ method: SnapshotUIController.DescriptorMethod,
+ sessionRole: UISceneSession.Role,
+ layout: SnapshotLayout,
+ traits: Traits,
+ delay: Double,
+ application: UIKit.UIApplication?
+ ) -> AsyncSnapshot {
+ let config = LayoutConfiguration.resolve(
+ layout,
+ with: SnapshotEnvironment.current.traits.merging(traits)
+ )
+
+ return IdentitySyncSnapshot.lines
+ .withWindow(
+ sessionRole: sessionRole,
+ application: application,
+ operation: { windowConfiguration, executor in
+ Async(Input.self) { @MainActor in
+ SnapshotUIController($0, with: config)
+ }
+ .connectToWindow(windowConfiguration)
+ .layoutIfNeeded()
+ .sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ #if !os(tvOS)
+ .waitLoadingStateIfNeeded(tolerance: SnapshotEnvironment.current.webViewTolerance)
+ #endif
+ .descriptor(executor, method: method)
+ }
+ )
+ .withLock()
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Snapshot+View.swift b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+View.swift
new file mode 100644
index 000000000..5542e06ad
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Snapshot+View.swift
@@ -0,0 +1,230 @@
+#if canImport(SwiftUI)
+@preconcurrency import SwiftUI
+
+#if os(macOS)
+extension AsyncSnapshot where Input: SwiftUI.View & Sendable, Output == ImageBytes {
+
+ /// Default configuration for capturing SwiftUI `View` as image snapshots.
+ ///
+ /// This configuration provides a basic setup for capturing SwiftUI views with default settings.
+ /// It renders the view in its initial state with default sizing and comparison precision.
+ ///
+ /// - Note: This configuration is suitable for simple views where custom sizing or
+ /// comparison settings are not required.
+ public static var image: AsyncSnapshot {
+ .image()
+ }
+
+ /// Creates a custom image snapshot configuration for SwiftUI `View`.
+ ///
+ /// This configuration allows you to capture SwiftUI `View` instances with custom settings for
+ /// layout, comparison precision, and other visual traits. It renders the view in isolation,
+ /// making it ideal for component testing.
+ ///
+ /// - Parameters:
+ /// - precision: Pixel tolerance for comparison (1 = perfect match, lower values allow more variation).
+ /// - perceptualPrecision: Tolerance for color and tonal differences (values closer to 1 require more exact matches).
+ /// - layout: Specifies how the view should be sized during rendering (e.g., specific device simulation).
+ /// - delay: Delay before capturing the image (useful for waiting for animations or dynamic content).
+ /// - application: The `NSApplication` instance to render the windows.
+ ///
+ /// Example usage:
+ /// ```swift
+ /// struct MyView: View {
+ /// var body: some View {
+ /// Text("Hello, World!")
+ /// .padding()
+ /// .background(Color.blue)
+ /// }
+ /// }
+ ///
+ /// try assert(
+ /// of: MyView(),
+ /// as: .image(
+ /// layout: .device(.iPhone15Pro),
+ /// precision: 0.98
+ /// )
+ /// )
+ /// ```
+ public static func image(
+ precision: Float = 1,
+ perceptualPrecision: Float = 1,
+ layout: SnapshotLayout = .sizeThatFits,
+ delay: Double = .zero,
+ application: NSApplication? = nil
+ ) -> AsyncSnapshot {
+ let config = LayoutConfiguration.resolve(layout)
+
+ return IdentitySyncSnapshot.image(
+ precision: precision,
+ perceptualPrecision: perceptualPrecision
+ )
+ .withWindow(
+ sessionRole: .windowApplication,
+ application: application,
+ operation: { windowConfiguration, executor in
+ Async(Input.self) { @MainActor in
+ SnapshotUIController($0, with: config)
+ }
+ .connectToWindow(windowConfiguration)
+ .layoutIfNeeded()
+ .sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ .waitLoadingStateIfNeeded(tolerance: SnapshotEnvironment.current.webViewTolerance)
+ .snapshot(executor)
+ }
+ )
+ }
+}
+#elseif os(iOS) || os(tvOS) || os(visionOS)
+extension Snapshot where Input: SwiftUI.View & Sendable, Output == ImageBytes {
+
+ /// Default configuration for capturing SwiftUI `View` as image snapshots.
+ ///
+ /// This configuration provides a basic setup for capturing SwiftUI views with default settings.
+ /// It renders the view in its initial state with default sizing and comparison precision.
+ ///
+ /// - Note: This configuration is suitable for simple views where custom sizing or
+ /// comparison settings are not required.
+ public static var image: AsyncSnapshot {
+ .image()
+ }
+
+ /// Creates a custom image snapshot configuration for SwiftUI `View`.
+ ///
+ /// This configuration allows you to capture SwiftUI `View` instances with custom settings for
+ /// layout, comparison precision, and other visual traits. It renders the view in isolation,
+ /// making it ideal for component testing.
+ ///
+ /// - Parameters:
+ /// - sessionRole: Defines the role of the UI session (default is `.windowApplication`).
+ /// - precision: Pixel tolerance for comparison (1 = perfect match, lower values allow more variation).
+ /// - perceptualPrecision: Tolerance for color and tonal differences (values closer to 1 require more exact matches).
+ /// - layout: Specifies how the view should be sized during rendering (e.g., specific device simulation).
+ /// - traits: Collection of UI traits (e.g., accessibility features, display characteristics).
+ /// - delay: Delay before capturing the image (useful for waiting for animations or dynamic content).
+ /// - application: The `UIApplication` instance to render the windows.
+ ///
+ /// Example usage:
+ /// ```swift
+ /// struct MyView: View {
+ /// var body: some View {
+ /// Text("Hello, World!")
+ /// .padding()
+ /// .background(Color.blue)
+ /// }
+ /// }
+ ///
+ /// try assert(
+ /// of: MyView(),
+ /// as: .image(
+ /// layout: .device(.iPhone15Pro),
+ /// precision: 0.98
+ /// )
+ /// )
+ /// ```
+ public static func image(
+ sessionRole: UISceneSession.Role = .windowApplication,
+ precision: Float = 1,
+ perceptualPrecision: Float = 1,
+ layout: SnapshotLayout = .sizeThatFits,
+ traits: Traits = .init(),
+ delay: Double = .zero,
+ application: UIKit.UIApplication? = nil
+ ) -> AsyncSnapshot {
+ let config = LayoutConfiguration.resolve(
+ layout,
+ with: SnapshotEnvironment.current.traits.merging(traits)
+ )
+
+ return IdentitySyncSnapshot.image(
+ precision: precision,
+ perceptualPrecision: perceptualPrecision
+ )
+ .withWindow(
+ sessionRole: sessionRole,
+ application: application,
+ operation: { windowConfiguration, executor in
+ Async(Input.self) { @MainActor in
+ SnapshotUIController($0, with: config)
+ }
+ .connectToWindow(windowConfiguration)
+ .layoutIfNeeded()
+ .sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ #if !os(tvOS)
+ .waitLoadingStateIfNeeded(tolerance: SnapshotEnvironment.current.webViewTolerance)
+ #endif
+ .snapshot(executor)
+ }
+ )
+ }
+}
+#elseif os(watchOS)
+@available(watchOS, introduced: 9.0)
+extension Snapshot where Input: SwiftUI.View & Sendable, Output == ImageBytes {
+
+ /// Default configuration for capturing SwiftUI `View` as image snapshots on watchOS.
+ ///
+ /// This configuration provides a basic setup for capturing SwiftUI views with default settings.
+ /// It renders the view in its initial state with default sizing and comparison precision.
+ ///
+ /// - Note: This configuration is suitable for simple watchOS views where custom sizing or
+ /// comparison settings are not required.
+ public static var image: AsyncSnapshot {
+ .image()
+ }
+
+ /// Creates a custom image snapshot configuration for SwiftUI `View` on watchOS.
+ ///
+ /// This configuration allows you to capture SwiftUI `View` instances with custom settings for
+ /// layout and comparison precision. It renders the view in isolation, making it ideal for component testing.
+ ///
+ /// - Parameters:
+ /// - precision: Pixel tolerance for comparison (1 = perfect match, lower values allow more variation).
+ /// - scale: The scale factor for the rendered image.
+ /// - layout: Specifies how the view should be sized during rendering.
+ ///
+ /// Example usage:
+ /// ```swift
+ /// struct MyWatchView: View {
+ /// var body: some View {
+ /// Text("Hello, watch!")
+ /// .padding()
+ /// .background(Color.green)
+ /// }
+ /// }
+ ///
+ /// try assert(
+ /// of: MyWatchView(),
+ /// as: .image(
+ /// precision: 0.98,
+ /// scale: 2.0
+ /// )
+ /// )
+ /// ```
+ public static func image(
+ precision: Float = 1,
+ scale: CGFloat = 1,
+ layout: SnapshotLayout = .sizeThatFits
+ ) -> AsyncSnapshot {
+ let config = LayoutConfiguration.resolve(layout)
+
+ return IdentitySyncSnapshot.image(
+ precision: precision
+ ).map { executor in
+ Async(Input.self) { @MainActor view in
+ let renderer = ImageRenderer(content: view)
+ renderer.scale = scale
+
+ if let size = config.size {
+ renderer.proposedSize = .init(size)
+ }
+
+ return try await executor(
+ renderer.uiImage ?? UIImage()
+ )
+ }
+ }
+ }
+}
+#endif
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Traits/InterfaceSizeClassTraitKey.swift b/Sources/XCSnapshotTesting/Methods/UI/Traits/InterfaceSizeClassTraitKey.swift
new file mode 100644
index 000000000..54738402f
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Traits/InterfaceSizeClassTraitKey.swift
@@ -0,0 +1,321 @@
+#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
+import UIKit
+#elseif os(macOS)
+@preconcurrency import AppKit
+#endif
+
+#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
+/// A size class that describes the horizontal or vertical space available for a user interface.
+///
+/// `InterfaceSizeClass` helps determine how UI elements should layout based on the available space.
+/// Commonly used to differentiate between compact and regular layouts for different device sizes and orientations.
+public enum InterfaceSizeClass: Sendable, Hashable {
+ /// The size class is not specified.
+ case unspecified
+ /// The size class is regular (typically for larger screens or orientations).
+ case regular
+ /// The size class is compact (typically for smaller screens or orientations).
+ case compact
+}
+
+/// A structure representing the horizontal and vertical interface size classes for a device.
+///
+/// `DeviceInterfaceSizeClass` combines horizontal and vertical size classes to fully describe the layout environment of a device.
+/// This helps in creating adaptive UIs that respond appropriately to different screen sizes and orientations.
+public struct DeviceInterfaceSizeClass: Sendable, Hashable {
+
+ /// The horizontal interface size class.
+ public let horizontal: InterfaceSizeClass
+
+ /// The vertical interface size class.
+ public let vertical: InterfaceSizeClass
+
+ /// Initializes a `DeviceInterfaceSizeClass` with the specified horizontal and vertical size classes.
+ ///
+ /// - Parameters:
+ /// - horizontal: The horizontal interface size class.
+ /// - vertical: The vertical interface size class.
+ public init(horizontal: InterfaceSizeClass, vertical: InterfaceSizeClass) {
+ self.horizontal = horizontal
+ self.vertical = vertical
+ }
+}
+
+/// A struct that dynamically determines the device interface size class based on a given size.
+///
+/// `DeviceDynamicInterfaceSizeClass` allows you to define how interface size classes should be determined
+/// for different device sizes. This is useful for adaptive UI layouts that need to respond to different
+/// screen sizes and orientations.
+///
+/// You can create a dynamic size class provider using either a closure or a constant value.
+///
+/// Example usage with a closure:
+/// ```swift
+/// let dynamicSizeClass = DeviceDynamicInterfaceSizeClass { size in
+/// if size.width < 768 {
+/// return .compact
+/// } else {
+/// return .regular
+/// }
+/// }
+/// let sizeClass = dynamicSizeClass(CGSize(width: 500, height: 300)) // Returns .compact
+/// ```
+///
+/// Example usage with a constant:
+/// ```swift
+/// let constantSizeClass = DeviceDynamicInterfaceSizeClass(constant: .regular)
+/// let sizeClass = constantSizeClass(CGSize(width: 1000, height: 800)) // Returns .regular
+/// ```
+public struct DeviceDynamicInterfaceSizeClass: Sendable {
+
+ private let provider: @Sendable (CGSize) -> DeviceInterfaceSizeClass
+
+ /// Creates a `DeviceDynamicInterfaceSizeClass` instance using a provider closure.
+ ///
+ /// - Parameter provider: A closure that takes a size and returns the corresponding interface size class.
+ public init(provider: @escaping @Sendable (CGSize) -> DeviceInterfaceSizeClass) {
+ self.provider = provider
+ }
+
+ /// Creates a `DeviceDynamicInterfaceSizeClass` instance with a constant value.
+ ///
+ /// - Parameter constant: The interface size class to always return.
+ public init(constant: DeviceInterfaceSizeClass) {
+ self.init(provider: { _ in constant })
+ }
+
+ /// Evaluates the interface size class for the given size.
+ ///
+ /// - Parameter size: The size to evaluate.
+ /// - Returns: The interface size class corresponding to the provided size.
+ public func callAsFunction(_ size: CGSize) -> DeviceInterfaceSizeClass {
+ provider(size)
+ }
+}
+
+// MARK: - iPhone
+extension DeviceDynamicInterfaceSizeClass {
+
+ // MARK: - iPhone 16
+
+ public static let iPhone16ProMax = withRegularLandscape
+
+ public static let iPhone16Pro = withCompactLandscape
+
+ public static let iPhone16Plus = withRegularLandscape
+
+ public static let iPhone16 = withCompactLandscape
+
+ public static let iPhone16e = withCompactLandscape
+
+ // MARK: - iPhone 15
+
+ public static let iPhone15ProMax = withRegularLandscape
+
+ public static let iPhone15Pro = withCompactLandscape
+
+ public static let iPhone15Plus = withRegularLandscape
+
+ public static let iPhone15 = withCompactLandscape
+
+ // MARK: - iPhone 14
+
+ public static let iPhone14ProMax = withRegularLandscape
+
+ public static let iPhone14Pro = withCompactLandscape
+
+ public static let iPhone14Plus = withRegularLandscape
+
+ public static let iPhone14 = withCompactLandscape
+
+ // MARK: - iPhone 13
+
+ public static let iPhone13ProMax = withRegularLandscape
+
+ public static let iPhone13Pro = withCompactLandscape
+
+ public static let iPhone13 = withCompactLandscape
+
+ public static let iPhone13Mini = withCompactLandscape
+
+ // MARK: - iPhone 12
+
+ public static let iPhone12ProMax = withRegularLandscape
+
+ public static let iPhone12Pro = withCompactLandscape
+
+ public static let iPhone12 = withCompactLandscape
+
+ public static let iPhone12Mini = withCompactLandscape
+
+ // MARK: - iPhone 11
+
+ public static let iPhone11ProMax = withRegularLandscape
+
+ public static let iPhone11Pro = withCompactLandscape
+
+ public static let iPhone11 = withRegularLandscape
+
+ // MARK: - iPhone XS
+
+ public static let iPhoneXSMax = withRegularLandscape
+
+ public static let iPhoneXS = withCompactLandscape
+
+ // MARK: - iPhone XR
+
+ public static let iPhoneXR = withRegularLandscape
+
+ // MARK: - iPhone X
+
+ public static let iPhoneX = withCompactLandscape
+
+ // MARK: - iPhone 8
+
+ public static let iPhone8Plus = withRegularLandscape
+
+ public static let iPhone8 = withCompactLandscape
+
+ // MARK: - iPhone SE
+
+ public static let iPhoneSE = withCompactLandscape
+
+ // MARK: - iPhone Private Methods
+
+ private static let withRegularLandscape = iOS(
+ landscape: .init(horizontal: .regular, vertical: .compact)
+ )
+
+ private static let withCompactLandscape = iOS(
+ landscape: .init(horizontal: .compact, vertical: .compact)
+ )
+
+ private static func iOS(
+ landscape: @autoclosure @escaping @Sendable () -> DeviceInterfaceSizeClass
+ ) -> DeviceDynamicInterfaceSizeClass {
+ DeviceDynamicInterfaceSizeClass {
+ if $0.width > $0.height {
+ return landscape()
+ }
+
+ return .init(
+ horizontal: .compact,
+ vertical: .regular
+ )
+ }
+ }
+}
+
+// MARK: - iPads
+extension DeviceDynamicInterfaceSizeClass {
+
+ public static let iPadOS = DeviceDynamicInterfaceSizeClass { size in
+ let horizontal: InterfaceSizeClass
+ let vertical: InterfaceSizeClass
+
+ if size.width >= size.height * 0.75 {
+ horizontal = .regular
+ } else {
+ horizontal = .compact
+ }
+
+ if size.height > size.width * 0.5 {
+ vertical = .regular
+ } else {
+ vertical = .compact
+ }
+
+ return .init(horizontal: horizontal, vertical: vertical)
+ }
+}
+
+// MARK: - Regular Sizes
+extension DeviceDynamicInterfaceSizeClass {
+
+ public static let macOS = DeviceDynamicInterfaceSizeClass(
+ constant: DeviceInterfaceSizeClass(horizontal: .regular, vertical: .regular)
+ )
+
+ public static let tvOS = DeviceDynamicInterfaceSizeClass(
+ constant: DeviceInterfaceSizeClass(horizontal: .regular, vertical: .regular)
+ )
+
+ public static let visionOS = DeviceDynamicInterfaceSizeClass(
+ constant: DeviceInterfaceSizeClass(horizontal: .regular, vertical: .regular)
+ )
+}
+
+// MARK: - WatchOS
+extension DeviceDynamicInterfaceSizeClass {
+
+ public static let watchOS = DeviceDynamicInterfaceSizeClass(
+ constant: DeviceInterfaceSizeClass(horizontal: .compact, vertical: .compact)
+ )
+}
+
+#if os(iOS) || os(tvOS) || os(visionOS)
+private struct DeviceInterfaceSizeClassTraitKey: TraitKey {
+
+ static let defaultValue = DeviceInterfaceSizeClass(
+ horizontal: .unspecified,
+ vertical: .unspecified
+ )
+
+ @available(iOS 17, tvOS 17, *)
+ static func apply(_ value: Value, to traitsOverrides: inout UITraitOverrides) {
+ traitsOverrides.verticalSizeClass = .init(value.vertical)
+ traitsOverrides.horizontalSizeClass = .init(value.horizontal)
+ }
+
+ static func apply(_ value: Value, to traitCollection: inout UITraitCollection) {
+ #if os(visionOS)
+ traitCollection = traitCollection.modifyingTraits {
+ $0.verticalSizeClass = .init(value.vertical)
+ $0.horizontalSizeClass = .init(value.horizontal)
+ }
+ #else
+ if #available(iOS 17, tvOS 17, *) {
+ traitCollection = traitCollection.modifyingTraits {
+ $0.verticalSizeClass = .init(value.vertical)
+ $0.horizontalSizeClass = .init(value.horizontal)
+ }
+ } else {
+ traitCollection = .init(traitsFrom: [
+ .init(verticalSizeClass: .init(value.vertical)),
+ .init(horizontalSizeClass: .init(value.horizontal)),
+ ])
+ }
+ #endif
+ }
+}
+
+extension UIUserInterfaceSizeClass {
+
+ fileprivate init(_ interfaceSizeClass: InterfaceSizeClass) {
+ switch interfaceSizeClass {
+ case .unspecified:
+ self = .unspecified
+ case .regular:
+ self = .regular
+ case .compact:
+ self = .compact
+ }
+ }
+}
+
+extension Traits {
+
+ /// Specifies the size class for the device interface.
+ public var deviceInterfaceSizeClass: DeviceInterfaceSizeClass {
+ get { self[DeviceInterfaceSizeClassTraitKey.self] }
+ set { self[DeviceInterfaceSizeClassTraitKey.self] = newValue }
+ }
+
+ /// Creates a `Traits` instance with the specified device interface size class.
+ public init(deviceInterfaceSizeClass: DeviceInterfaceSizeClass) {
+ self.init()
+ self.deviceInterfaceSizeClass = deviceInterfaceSizeClass
+ }
+}
+#endif
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/AccessibilityContrastTraitKey.swift b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/AccessibilityContrastTraitKey.swift
new file mode 100644
index 000000000..ce5d7f4be
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/AccessibilityContrastTraitKey.swift
@@ -0,0 +1,50 @@
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+
+private struct AccessibilityContrastTraitKey: TraitKey {
+
+ static let defaultValue = UIAccessibilityContrast.unspecified
+
+ @available(iOS 17, tvOS 17, *)
+ static func apply(
+ _ value: Value,
+ to traitsOverrides: inout UITraitOverrides
+ ) {
+ traitsOverrides.accessibilityContrast = value
+ }
+
+ static func apply(_ value: Value, to traitCollection: inout UITraitCollection) {
+ #if os(visionOS)
+ traitCollection = traitCollection.modifyingTraits {
+ $0.accessibilityContrast = value
+ }
+ #else
+ if #available(iOS 17, tvOS 17, *) {
+ traitCollection = traitCollection.modifyingTraits {
+ $0.accessibilityContrast = value
+ }
+ } else {
+ traitCollection = .init(traitsFrom: [
+ traitCollection,
+ UITraitCollection(accessibilityContrast: value),
+ ])
+ }
+ #endif
+ }
+}
+
+extension Traits {
+
+ /// Specifies the accessibility contrast setting.
+ public var accessibilityContrast: UIAccessibilityContrast {
+ get { self[AccessibilityContrastTraitKey.self] }
+ set { self[AccessibilityContrastTraitKey.self] = newValue }
+ }
+
+ /// Creates a `Traits` instance with the specified accessibility contrast.
+ public init(accessibilityContrast: UIAccessibilityContrast) {
+ self.init()
+ self.accessibilityContrast = accessibilityContrast
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/ContentSizeCategoryTraitKey.swift b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/ContentSizeCategoryTraitKey.swift
new file mode 100644
index 000000000..700c29093
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/ContentSizeCategoryTraitKey.swift
@@ -0,0 +1,50 @@
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+
+private struct ContentSizeCategoryTraitKey: TraitKey {
+
+ static let defaultValue = UIContentSizeCategory.unspecified
+
+ @available(iOS 17, tvOS 17, *)
+ static func apply(
+ _ value: Value,
+ to traitsOverrides: inout UITraitOverrides
+ ) {
+ traitsOverrides.preferredContentSizeCategory = value
+ }
+
+ static func apply(_ value: Value, to traitCollection: inout UITraitCollection) {
+ #if os(visionOS)
+ traitCollection = traitCollection.modifyingTraits {
+ $0.preferredContentSizeCategory = value
+ }
+ #else
+ if #available(iOS 17, tvOS 17, *) {
+ traitCollection = traitCollection.modifyingTraits {
+ $0.preferredContentSizeCategory = value
+ }
+ } else {
+ traitCollection = .init(traitsFrom: [
+ traitCollection,
+ UITraitCollection(preferredContentSizeCategory: value),
+ ])
+ }
+ #endif
+ }
+}
+
+extension Traits {
+
+ /// Specifies the preferred content size category for accessibility.
+ public var preferredContentSizeCategory: UIContentSizeCategory {
+ get { self[ContentSizeCategoryTraitKey.self] }
+ set { self[ContentSizeCategoryTraitKey.self] = newValue }
+ }
+
+ /// Creates a `Traits` instance with the specified preferred content size category.
+ public init(preferredContentSizeCategory: UIContentSizeCategory) {
+ self.init()
+ self.preferredContentSizeCategory = preferredContentSizeCategory
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/DisplayGamutTraitKey.swift b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/DisplayGamutTraitKey.swift
new file mode 100644
index 000000000..e4c7b8a4b
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/DisplayGamutTraitKey.swift
@@ -0,0 +1,50 @@
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+
+private struct DisplayGamutTraitKey: TraitKey {
+
+ static let defaultValue = UIDisplayGamut.unspecified
+
+ @available(iOS 17, tvOS 17, *)
+ static func apply(
+ _ value: Value,
+ to traitsOverrides: inout UITraitOverrides
+ ) {
+ traitsOverrides.displayGamut = value
+ }
+
+ static func apply(_ value: Value, to traitCollection: inout UITraitCollection) {
+ #if os(visionOS)
+ traitCollection = traitCollection.modifyingTraits {
+ $0.displayGamut = value
+ }
+ #else
+ if #available(iOS 17, tvOS 17, *) {
+ traitCollection = traitCollection.modifyingTraits {
+ $0.displayGamut = value
+ }
+ } else {
+ traitCollection = .init(traitsFrom: [
+ traitCollection,
+ UITraitCollection(displayGamut: value),
+ ])
+ }
+ #endif
+ }
+}
+
+extension Traits {
+
+ /// Specifies the display gamut (color range) of the screen.
+ public var displayGamut: UIDisplayGamut {
+ get { self[DisplayGamutTraitKey.self] }
+ set { self[DisplayGamutTraitKey.self] = newValue }
+ }
+
+ /// Creates a `Traits` instance with the specified display gamut.
+ public init(displayGamut: UIDisplayGamut) {
+ self.init()
+ self.displayGamut = displayGamut
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/DisplayScaleTraitKey.swift b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/DisplayScaleTraitKey.swift
new file mode 100644
index 000000000..d3de14150
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/DisplayScaleTraitKey.swift
@@ -0,0 +1,50 @@
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+
+private struct DisplayScaleTraitKey: TraitKey {
+
+ static let defaultValue: CGFloat = 1
+
+ @available(iOS 17, tvOS 17, *)
+ static func apply(
+ _ value: Value,
+ to traitsOverrides: inout UITraitOverrides
+ ) {
+ traitsOverrides.displayScale = value
+ }
+
+ static func apply(_ value: Value, to traitCollection: inout UITraitCollection) {
+ #if os(visionOS)
+ traitCollection = traitCollection.modifyingTraits {
+ $0.displayScale = value
+ }
+ #else
+ if #available(iOS 17, tvOS 17, *) {
+ traitCollection = traitCollection.modifyingTraits {
+ $0.displayScale = value
+ }
+ } else {
+ traitCollection = .init(traitsFrom: [
+ traitCollection,
+ UITraitCollection(displayScale: value),
+ ])
+ }
+ #endif
+ }
+}
+
+extension Traits {
+
+ /// Specifies the display scale (screen resolution) of the device.
+ public var displayScale: CGFloat {
+ get { self[DisplayScaleTraitKey.self] }
+ set { self[DisplayScaleTraitKey.self] = newValue }
+ }
+
+ /// Creates a `Traits` instance with the specified display scale.
+ public init(displayScale: CGFloat) {
+ self.init()
+ self.displayScale = displayScale
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/ForceTouchCapabilityTraitKey.swift b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/ForceTouchCapabilityTraitKey.swift
new file mode 100644
index 000000000..25e5735a2
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/ForceTouchCapabilityTraitKey.swift
@@ -0,0 +1,50 @@
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+
+private struct ForceTouchCapabilityTraitKey: TraitKey {
+
+ static let defaultValue = UIForceTouchCapability.unknown
+
+ @available(iOS 17, tvOS 17, *)
+ static func apply(
+ _ value: Value,
+ to traitsOverrides: inout UITraitOverrides
+ ) {
+ traitsOverrides.forceTouchCapability = value
+ }
+
+ static func apply(_ value: Value, to traitCollection: inout UITraitCollection) {
+ #if os(visionOS)
+ traitCollection = traitCollection.modifyingTraits {
+ $0.forceTouchCapability = value
+ }
+ #else
+ if #available(iOS 17, tvOS 17, *) {
+ traitCollection = traitCollection.modifyingTraits {
+ $0.forceTouchCapability = value
+ }
+ } else {
+ traitCollection = .init(traitsFrom: [
+ traitCollection,
+ UITraitCollection(forceTouchCapability: value),
+ ])
+ }
+ #endif
+ }
+}
+
+extension Traits {
+
+ /// Specifies the force touch capability of the device.
+ public var forceTouchCapability: UIForceTouchCapability {
+ get { self[ForceTouchCapabilityTraitKey.self] }
+ set { self[ForceTouchCapabilityTraitKey.self] = newValue }
+ }
+
+ /// Creates a `Traits` instance with the specified force touch capability.
+ public init(forceTouchCapability: UIForceTouchCapability) {
+ self.init()
+ self.forceTouchCapability = forceTouchCapability
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/ImageDynamicRangeTraitKey.swift b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/ImageDynamicRangeTraitKey.swift
new file mode 100644
index 000000000..274e09a6e
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/ImageDynamicRangeTraitKey.swift
@@ -0,0 +1,38 @@
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+
+@available(iOS 17, tvOS 17, *)
+private struct ImageDynamicRangeTraitKey: TraitKey {
+
+ static let defaultValue = UIImage.DynamicRange.unspecified
+
+ static func apply(
+ _ value: Value,
+ to traitsOverrides: inout UITraitOverrides
+ ) {
+ traitsOverrides.imageDynamicRange = value
+ }
+
+ static func apply(_ value: Value, to traitCollection: inout UITraitCollection) {
+ traitCollection = traitCollection.modifyingTraits {
+ $0.imageDynamicRange = value
+ }
+ }
+}
+
+@available(iOS 17, tvOS 17, *)
+extension Traits {
+
+ /// Specifies the dynamic range of images displayed.
+ public var imageDynamicRange: UIImage.DynamicRange {
+ get { self[ImageDynamicRangeTraitKey.self] }
+ set { self[ImageDynamicRangeTraitKey.self] = newValue }
+ }
+
+ /// Creates a `Traits` instance with the specified image dynamic range.
+ public init(imageDynamicRange: UIImage.DynamicRange) {
+ self.init()
+ self.imageDynamicRange = imageDynamicRange
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/LayoutDirectionTraitKey.swift b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/LayoutDirectionTraitKey.swift
new file mode 100644
index 000000000..2222681c0
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/LayoutDirectionTraitKey.swift
@@ -0,0 +1,50 @@
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+
+private struct LayoutDirectionTraitKey: TraitKey {
+
+ static let defaultValue = UITraitEnvironmentLayoutDirection.unspecified
+
+ @available(iOS 17, tvOS 17, *)
+ static func apply(
+ _ value: Value,
+ to traitsOverrides: inout UITraitOverrides
+ ) {
+ traitsOverrides.layoutDirection = value
+ }
+
+ static func apply(_ value: Value, to traitCollection: inout UITraitCollection) {
+ #if os(visionOS)
+ traitCollection = traitCollection.modifyingTraits {
+ $0.layoutDirection = value
+ }
+ #else
+ if #available(iOS 17, tvOS 17, *) {
+ traitCollection = traitCollection.modifyingTraits {
+ $0.layoutDirection = value
+ }
+ } else {
+ traitCollection = .init(traitsFrom: [
+ traitCollection,
+ UITraitCollection(layoutDirection: value),
+ ])
+ }
+ #endif
+ }
+}
+
+extension Traits {
+
+ /// Specifies the layout direction for the UI.
+ public var layoutDirection: UITraitEnvironmentLayoutDirection {
+ get { self[LayoutDirectionTraitKey.self] }
+ set { self[LayoutDirectionTraitKey.self] = newValue }
+ }
+
+ /// Creates a `Traits` instance with the specified layout direction.
+ public init(layoutDirection: UITraitEnvironmentLayoutDirection) {
+ self.init()
+ self.layoutDirection = layoutDirection
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/LegibilityWeightTraitKey.swift b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/LegibilityWeightTraitKey.swift
new file mode 100644
index 000000000..4f73ce5e9
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/LegibilityWeightTraitKey.swift
@@ -0,0 +1,50 @@
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+
+private struct LegibilityWeightTraitKey: TraitKey {
+
+ static let defaultValue = UILegibilityWeight.unspecified
+
+ @available(iOS 17, tvOS 17, *)
+ static func apply(
+ _ value: Value,
+ to traitsOverrides: inout UITraitOverrides
+ ) {
+ traitsOverrides.legibilityWeight = value
+ }
+
+ static func apply(_ value: Value, to traitCollection: inout UITraitCollection) {
+ #if os(visionOS)
+ traitCollection = traitCollection.modifyingTraits {
+ $0.legibilityWeight = value
+ }
+ #else
+ if #available(iOS 17, tvOS 17, *) {
+ traitCollection = traitCollection.modifyingTraits {
+ $0.legibilityWeight = value
+ }
+ } else {
+ traitCollection = .init(traitsFrom: [
+ traitCollection,
+ UITraitCollection(legibilityWeight: value),
+ ])
+ }
+ #endif
+ }
+}
+
+extension Traits {
+
+ /// Specifies the legibility weight for text.
+ public var legibilityWeight: UILegibilityWeight {
+ get { self[LegibilityWeightTraitKey.self] }
+ set { self[LegibilityWeightTraitKey.self] = newValue }
+ }
+
+ /// Creates a `Traits` instance with the specified legibility weight.
+ public init(legibilityWeight: UILegibilityWeight) {
+ self.init()
+ self.legibilityWeight = legibilityWeight
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/ListEnvironmentTraitKey.swift b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/ListEnvironmentTraitKey.swift
new file mode 100644
index 000000000..3cf87c00c
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/ListEnvironmentTraitKey.swift
@@ -0,0 +1,38 @@
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+
+@available(iOS 18, tvOS 18, *)
+private struct ListEnvironmentTraitKey: TraitKey {
+
+ static let defaultValue = UIListEnvironment.unspecified
+
+ static func apply(
+ _ value: Value,
+ to traitsOverrides: inout UITraitOverrides
+ ) {
+ traitsOverrides.listEnvironment = value
+ }
+
+ static func apply(_ value: Value, to traitCollection: inout UITraitCollection) {
+ traitCollection = traitCollection.modifyingTraits {
+ $0.listEnvironment = value
+ }
+ }
+}
+
+@available(iOS 18, tvOS 18, *)
+extension Traits {
+
+ /// Specifies the list environment characteristics.
+ public var listEnvironment: UIListEnvironment {
+ get { self[ListEnvironmentTraitKey.self] }
+ set { self[ListEnvironmentTraitKey.self] = newValue }
+ }
+
+ /// Creates a `Traits` instance with the specified list environment.
+ public init(listEnvironment: UIListEnvironment) {
+ self.init()
+ self.listEnvironment = listEnvironment
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/SceneCaptureStateTraitKey.swift b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/SceneCaptureStateTraitKey.swift
new file mode 100644
index 000000000..313699c0e
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/SceneCaptureStateTraitKey.swift
@@ -0,0 +1,38 @@
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+
+@available(iOS 17, tvOS 17, *)
+private struct SceneCaptureStateTraitKey: TraitKey {
+
+ static let defaultValue = UISceneCaptureState.unspecified
+
+ static func apply(
+ _ value: Value,
+ to traitsOverrides: inout UITraitOverrides
+ ) {
+ traitsOverrides.sceneCaptureState = value
+ }
+
+ static func apply(_ value: Value, to traitCollection: inout UITraitCollection) {
+ traitCollection = traitCollection.modifyingTraits {
+ $0.sceneCaptureState = value
+ }
+ }
+}
+
+@available(iOS 17, tvOS 17, *)
+extension Traits {
+
+ /// Specifies the scene capture state.
+ public var sceneCaptureState: UISceneCaptureState {
+ get { self[SceneCaptureStateTraitKey.self] }
+ set { self[SceneCaptureStateTraitKey.self] = newValue }
+ }
+
+ /// Creates a `Traits` instance with the specified scene capture state.
+ public init(sceneCaptureState: UISceneCaptureState) {
+ self.init()
+ self.sceneCaptureState = sceneCaptureState
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/ToolbarItemPresentationSizeTraitKey.swift b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/ToolbarItemPresentationSizeTraitKey.swift
new file mode 100644
index 000000000..3deebb43c
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/ToolbarItemPresentationSizeTraitKey.swift
@@ -0,0 +1,52 @@
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+
+@available(macCatalyst 16, *)
+private struct ToolbarItemPresentationSizeTraitKey: TraitKey {
+
+ static let defaultValue = UINSToolbarItemPresentationSize.unspecified
+
+ @available(iOS 17, tvOS 17, *)
+ static func apply(
+ _ value: Value,
+ to traitsOverrides: inout UITraitOverrides
+ ) {
+ traitsOverrides.toolbarItemPresentationSize = value
+ }
+
+ static func apply(_ value: Value, to traitCollection: inout UITraitCollection) {
+ #if os(visionOS)
+ traitCollection = traitCollection.modifyingTraits {
+ $0.toolbarItemPresentationSize = value
+ }
+ #else
+ if #available(iOS 17, tvOS 17, *) {
+ traitCollection = traitCollection.modifyingTraits {
+ $0.toolbarItemPresentationSize = value
+ }
+ } else {
+ traitCollection = .init(traitsFrom: [
+ traitCollection,
+ UITraitCollection(toolbarItemPresentationSize: value),
+ ])
+ }
+ #endif
+ }
+}
+
+@available(macCatalyst 16, *)
+extension Traits {
+
+ /// Specifies the presentation size for toolbar items.
+ public var toolbarItemPresentationSize: UINSToolbarItemPresentationSize {
+ get { self[ToolbarItemPresentationSizeTraitKey.self] }
+ set { self[ToolbarItemPresentationSizeTraitKey.self] = newValue }
+ }
+
+ /// Creates a `Traits` instance with the specified toolbar item presentation size.
+ public init(toolbarItemPresentationSize: UINSToolbarItemPresentationSize) {
+ self.init()
+ self.toolbarItemPresentationSize = toolbarItemPresentationSize
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/TypesettingLanguageTraitKey.swift b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/TypesettingLanguageTraitKey.swift
new file mode 100644
index 000000000..9d2f720d8
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/TypesettingLanguageTraitKey.swift
@@ -0,0 +1,38 @@
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+
+@available(iOS 17, tvOS 17, *)
+private struct TypesettingLanguageTraitKey: TraitKey {
+
+ static let defaultValue: Locale.Language? = nil
+
+ static func apply(
+ _ value: Value,
+ to traitsOverrides: inout UITraitOverrides
+ ) {
+ traitsOverrides.typesettingLanguage = value
+ }
+
+ static func apply(_ value: Value, to traitCollection: inout UITraitCollection) {
+ traitCollection = traitCollection.modifyingTraits {
+ $0.typesettingLanguage = value
+ }
+ }
+}
+
+@available(iOS 17, tvOS 17, *)
+extension Traits {
+
+ /// Specifies the language used for typesetting.
+ public var typesettingLanguage: Locale.Language? {
+ get { self[TypesettingLanguageTraitKey.self] }
+ set { self[TypesettingLanguageTraitKey.self] = newValue }
+ }
+
+ /// Creates a `Traits` instance with the specified typesetting language.
+ public init(typesettingLanguage: Locale.Language?) {
+ self.init()
+ self.typesettingLanguage = typesettingLanguage
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/UserInterfaceActiveAppearanceTraitKey.swift b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/UserInterfaceActiveAppearanceTraitKey.swift
new file mode 100644
index 000000000..a111b4139
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/UserInterfaceActiveAppearanceTraitKey.swift
@@ -0,0 +1,52 @@
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+
+@available(iOS 14, tvOS 14, *)
+private struct UserInterfaceActiveAppearanceTraitKey: TraitKey {
+
+ static let defaultValue = UIUserInterfaceActiveAppearance.unspecified
+
+ @available(iOS 17, tvOS 17, *)
+ static func apply(
+ _ value: Value,
+ to traitsOverrides: inout UITraitOverrides
+ ) {
+ traitsOverrides.activeAppearance = value
+ }
+
+ static func apply(_ value: Value, to traitCollection: inout UITraitCollection) {
+ #if os(visionOS)
+ traitCollection = traitCollection.modifyingTraits {
+ $0.activeAppearance = value
+ }
+ #else
+ if #available(iOS 17, tvOS 17, *) {
+ traitCollection = traitCollection.modifyingTraits {
+ $0.activeAppearance = value
+ }
+ } else {
+ traitCollection = .init(traitsFrom: [
+ traitCollection,
+ UITraitCollection(activeAppearance: value),
+ ])
+ }
+ #endif
+ }
+}
+
+@available(iOS 14, tvOS 14, *)
+extension Traits {
+
+ /// Specifies the active appearance of the interface.
+ public var activeAppearance: UIUserInterfaceActiveAppearance {
+ get { self[UserInterfaceActiveAppearanceTraitKey.self] }
+ set { self[UserInterfaceActiveAppearanceTraitKey.self] = newValue }
+ }
+
+ /// Creates a `Traits` instance with the specified active appearance.
+ public init(activeAppearance: UIUserInterfaceActiveAppearance) {
+ self.init()
+ self.activeAppearance = activeAppearance
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/UserInterfaceIdiomTraitKey.swift b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/UserInterfaceIdiomTraitKey.swift
new file mode 100644
index 000000000..7fd78ff6c
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/UserInterfaceIdiomTraitKey.swift
@@ -0,0 +1,50 @@
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+
+private struct UserInterfaceIdiomTraitKey: TraitKey {
+
+ static let defaultValue = UIUserInterfaceIdiom.unspecified
+
+ @available(iOS 17, tvOS 17, *)
+ static func apply(
+ _ value: Value,
+ to traitsOverrides: inout UITraitOverrides
+ ) {
+ traitsOverrides.userInterfaceIdiom = value
+ }
+
+ static func apply(_ value: Value, to traitCollection: inout UITraitCollection) {
+ #if os(visionOS)
+ traitCollection = traitCollection.modifyingTraits {
+ $0.userInterfaceIdiom = value
+ }
+ #else
+ if #available(iOS 17, tvOS 17, *) {
+ traitCollection = traitCollection.modifyingTraits {
+ $0.userInterfaceIdiom = value
+ }
+ } else {
+ traitCollection = .init(traitsFrom: [
+ traitCollection,
+ UITraitCollection(userInterfaceIdiom: value),
+ ])
+ }
+ #endif
+ }
+}
+
+extension Traits {
+
+ /// Specifies the user interface idiom (e.g., phone, pad, mac).
+ public var userInterfaceIdiom: UIUserInterfaceIdiom {
+ get { self[UserInterfaceIdiomTraitKey.self] }
+ set { self[UserInterfaceIdiomTraitKey.self] = newValue }
+ }
+
+ /// Creates a `Traits` instance with the specified user interface idiom.
+ public init(userInterfaceIdiom: UIUserInterfaceIdiom) {
+ self.init()
+ self.userInterfaceIdiom = userInterfaceIdiom
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/UserInterfaceLevelTraitKey.swift b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/UserInterfaceLevelTraitKey.swift
new file mode 100644
index 000000000..531e07e25
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/UserInterfaceLevelTraitKey.swift
@@ -0,0 +1,50 @@
+#if os(iOS) || os(visionOS)
+import UIKit
+
+private struct UserInterfaceLevelTraitKey: TraitKey {
+
+ static let defaultValue = UIUserInterfaceLevel.unspecified
+
+ @available(iOS 17, tvOS 17, *)
+ static func apply(
+ _ value: Value,
+ to traitsOverrides: inout UITraitOverrides
+ ) {
+ traitsOverrides.userInterfaceLevel = value
+ }
+
+ static func apply(_ value: Value, to traitCollection: inout UITraitCollection) {
+ #if os(visionOS)
+ traitCollection = traitCollection.modifyingTraits {
+ $0.userInterfaceLevel = value
+ }
+ #else
+ if #available(iOS 17, tvOS 17, *) {
+ traitCollection = traitCollection.modifyingTraits {
+ $0.userInterfaceLevel = value
+ }
+ } else {
+ traitCollection = .init(traitsFrom: [
+ traitCollection,
+ UITraitCollection(userInterfaceLevel: value),
+ ])
+ }
+ #endif
+ }
+}
+
+extension Traits {
+
+ /// Specifies the user interface level (e.g., normal, elevated).
+ public var userInterfaceLevel: UIUserInterfaceLevel {
+ get { self[UserInterfaceLevelTraitKey.self] }
+ set { self[UserInterfaceLevelTraitKey.self] = newValue }
+ }
+
+ /// Creates a `Traits` instance with the specified user interface level.
+ public init(userInterfaceLevel: UIUserInterfaceLevel) {
+ self.init()
+ self.userInterfaceLevel = userInterfaceLevel
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/UserInterfaceStyleTraitKey.swift b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/UserInterfaceStyleTraitKey.swift
new file mode 100644
index 000000000..0663b94ff
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Traits/Trait Keys/UserInterfaceStyleTraitKey.swift
@@ -0,0 +1,50 @@
+#if os(iOS) || os(tvOS) || os(visionOS)
+import UIKit
+
+private struct UserInterfaceStyleTraitKey: TraitKey {
+
+ static let defaultValue = UIUserInterfaceStyle.unspecified
+
+ @available(iOS 17, tvOS 17, *)
+ static func apply(
+ _ value: Value,
+ to traitsOverrides: inout UITraitOverrides
+ ) {
+ traitsOverrides.userInterfaceStyle = value
+ }
+
+ static func apply(_ value: Value, to traitCollection: inout UITraitCollection) {
+ #if os(visionOS)
+ traitCollection = traitCollection.modifyingTraits {
+ $0.userInterfaceStyle = value
+ }
+ #else
+ if #available(iOS 17, tvOS 17, *) {
+ traitCollection = traitCollection.modifyingTraits {
+ $0.userInterfaceStyle = value
+ }
+ } else {
+ traitCollection = .init(traitsFrom: [
+ traitCollection,
+ UITraitCollection(userInterfaceStyle: value),
+ ])
+ }
+ #endif
+ }
+}
+
+extension Traits {
+
+ /// Specifies the user interface style (light, dark).
+ public var userInterfaceStyle: UIUserInterfaceStyle {
+ get { self[UserInterfaceStyleTraitKey.self] }
+ set { self[UserInterfaceStyleTraitKey.self] = newValue }
+ }
+
+ /// Creates a `Traits` instance with the specified user interface style.
+ public init(userInterfaceStyle: UIUserInterfaceStyle) {
+ self.init()
+ self.userInterfaceStyle = userInterfaceStyle
+ }
+}
+#endif
diff --git a/Sources/XCSnapshotTesting/Methods/UI/Traits/Traits.swift b/Sources/XCSnapshotTesting/Methods/UI/Traits/Traits.swift
new file mode 100644
index 000000000..8bfa27ad0
--- /dev/null
+++ b/Sources/XCSnapshotTesting/Methods/UI/Traits/Traits.swift
@@ -0,0 +1,294 @@
+#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
+import UIKit
+#elseif os(macOS)
+@preconcurrency import AppKit
+#endif
+
+#if os(iOS) || os(tvOS) || os(visionOS)
+protocol TraitKey: Sendable, Hashable {
+
+ associatedtype Value: Hashable & Sendable
+
+ static var defaultValue: Value { get }
+
+ @available(iOS 17, tvOS 17, *)
+ @MainActor
+ static func apply(_ value: Value, to traitsOverrides: inout UITraitOverrides)
+
+ @MainActor
+ static func apply(_ value: Value, to traitCollection: inout UITraitCollection)
+}
+
+/// A set of characteristics that describe the environment in which UI elements are displayed during snapshot testing.
+///
+/// `Traits` allows you to customize the appearance and behavior of UI elements by specifying various display and accessibility characteristics.
+/// These traits help ensure that your UI renders correctly across different devices and configurations during testing.
+///
+/// ```swift
+/// let traits = Traits(preferredContentSizeCategory: .extraLarge)
+/// SnapshotEnvironment.current.traits = traits
+/// ```
+public struct Traits: Sendable, Hashable {
+
+ private var traits = [ObjectIdentifier: Storage]()
+
+ /// Creates a default `Traits` instance with no specific characteristics set.
+ public init() {}
+
+ /// Creates a `Traits` instance from a collection of trait dictionaries.
+ public init(traitsFrom traitCollection: [Traits]) {
+ self.init(
+ traitCollection.lazy.map(\.traits).reduce([:]) {
+ $0.merging($1, uniquingKeysWith: { $1 })
+ }
+ )
+ }
+
+ private init(_ traits: [ObjectIdentifier: Storage]) {
+ self.traits = traits
+ }
+
+ subscript(_ key: Key.Type) -> Key.Value {
+ get {
+ let id = ObjectIdentifier(key)
+
+ guard let storage = traits[id] else {
+ return key.defaultValue
+ }
+
+ return storage.value as! Key.Value
+ }
+ set {
+ let id = ObjectIdentifier(key)
+ traits[id, default: .init(key)].value = newValue
+ }
+ }
+
+ /// Combines this traits instance with another, returning a new instance that merges the properties of both.
+ public func merging(_ traits: Traits) -> Traits {
+ .init(traitsFrom: [self, traits])
+ }
+
+ /// Returns a `UITraitCollection` representation of these traits.
+ public func callAsFunction() -> UITraitCollection {
+ performOnMainThread {
+ traits.reduce(into: UITraitCollection()) {
+ $1.value.apply(to: &$0)
+ }
+ }
+ }
+
+ @MainActor
+ func commit(in viewController: UIViewController) {
+ #if !os(visionOS)
+ var pendingTraitCollection: UITraitCollection?
+ #endif
+
+ for (_, trait) in traits {
+ #if os(visionOS)
+ trait.apply(to: &viewController.traitOverrides)
+ #else
+ if #available(iOS 17, tvOS 17, *) {
+ trait.apply(to: &viewController.traitOverrides)
+ } else {
+ var traitCollection = pendingTraitCollection ?? UITraitCollection()
+ trait.apply(to: &traitCollection)
+ pendingTraitCollection = traitCollection
+ }
+ #endif
+ }
+
+ #if !os(visionOS)
+ guard let pendingTraitCollection else {
+ return
+ }
+
+ for childViewController in viewController.children {
+ viewController.setOverrideTraitCollection(
+ pendingTraitCollection,
+ forChild: childViewController
+ )
+ }
+ #endif
+ }
+}
+
+private struct Storage: Sendable, Hashable {
+
+ var value: any Hashable & Sendable
+
+ private let mutating: @MainActor (Self, inout Any) -> Void
+ private let asserting: @Sendable (Self, Self) -> Bool
+ private let hashing: @Sendable (Self, inout Hasher) -> Void
+
+ init(_ key: Key.Type) {
+ value = Key.defaultValue
+ mutating = {
+ #if os(visionOS)
+ if var traitsOverrides = $1 as? UITraitOverrides {
+ key.apply($0.value as! Key.Value, to: &traitsOverrides)
+ $1 = traitsOverrides
+ } else {
+ var traitCollection = $1 as! UITraitCollection
+ key.apply($0.value as! Key.Value, to: &traitCollection)
+ $1 = traitCollection
+ }
+ #else
+ if #available(iOS 17, tvOS 17, *), var traitsOverrides = $1 as? UITraitOverrides {
+ key.apply($0.value as! Key.Value, to: &traitsOverrides)
+ $1 = traitsOverrides
+ } else {
+ var traitCollection = $1 as! UITraitCollection
+ key.apply($0.value as! Key.Value, to: &traitCollection)
+ $1 = traitCollection
+ }
+ #endif
+ }
+ asserting = {
+ if let lhs = $0.value as? Key.Value, let rhs = $1.value as? Key.Value {
+ return lhs == rhs
+ } else {
+ return false
+ }
+ }
+ hashing = {
+ let value = $0.value as! Key.Value
+ value.hash(into: &$1)
+ }
+ }
+
+ static func == (_ lhs: Self, _ rhs: Self) -> Bool {
+ lhs.asserting(lhs, rhs)
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hashing(self, &hasher)
+ }
+
+ @available(iOS 17, tvOS 17, *)
+ @MainActor
+ func apply(to traitsOverrides: inout UITraitOverrides) {
+ var reference = traitsOverrides as Any
+ mutating(self, &reference)
+ traitsOverrides = reference as! UITraitOverrides
+ }
+
+ @MainActor
+ func apply(to traitCollection: inout UITraitCollection) {
+ var reference = traitCollection as Any
+ mutating(self, &reference)
+ traitCollection = reference as! UITraitCollection
+ }
+}
+
+extension Traits {
+
+ func inconsistentTraitsChecker