Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ jobs:
- name: Build SnapshottingTests
run: xcodebuild build -scheme SnapshottingTests -sdk appletvsimulator -destination 'generic/platform=tvOS Simulator'
build-visionos:
runs-on: macos-14
runs-on: macos-15

steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Xcode select
run: sudo xcode-select -s '/Applications/Xcode_15.4.app/Contents/Developer'
run: sudo xcode-select -s '/Applications/Xcode_16.0.app/Contents/Developer'
- name: Build TestApp
run: cd Examples && xcodebuild build -scheme DemoApp -sdk xrsimulator -destination 'generic/platform=visionOS Simulator' -project DemoApp/DemoApp.xcodeproj
- name: Build Snapshotting
Expand All @@ -57,7 +57,7 @@ jobs:
- name: Xcode select
run: sudo xcode-select -s '/Applications/Xcode_15.4.app/Contents/Developer'
- name: Build Test Watch App
run: cd Examples && xcodebuild build -scheme 'Demo Watch App' -sdk watchsimulator -destination 'platform=watchOS Simulator,name=Apple Watch Series 9 (41mm)' -project DemoApp/DemoApp.xcodeproj CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
run: cd Examples && xcodebuild build -scheme 'Demo Watch App' -sdk watchsimulator -destination 'generic/platform=watchOS Simulator' -project DemoApp/DemoApp.xcodeproj CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
- name: Build Snapshotting
run: xcodebuild build -scheme Snapshotting -sdk watchsimulator -destination 'generic/platform=watchOS Simulator'
- name: Build SnapshottingTests
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,18 @@ jobs:
run: sudo xcode-select -s '/Applications/Xcode_15.4.app/Contents/Developer'
- name: Build xcframework
run: sh build.sh
- name: Zip xcframework
- name: Zip SnapshottingTests xcframework
run: zip -r SnapshottingTests.xcframework.zip SnapshottingTests.xcframework
- name: Zip PreviewGallery xcframework
run: zip -r PreviewGallery.xcframework.zip PreviewGallery.xcframework
- name: Zip preivews support
run: (cd PreviewsSupport && zip -r PreviewsSupport.xcframework.zip PreviewsSupport.xcframework)
- name: Upload Artifact
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
PreviewGallery.xcframework.zip
SnapshottingTests.xcframework.zip
PreviewsSupport/PreviewsSupport.xcframework.zip
body:
Expand Down
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ let package = Package(
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "PreviewGallery",
type: .static, // Replace this to build dynamic
targets: ["PreviewGallery"]),
// Test library to import in your XCTest target.
// This is the only library that depends on XCTest.framework
Expand Down
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# 📸 SnapshotPreviews

[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FEmergeTools%2FSnapshotPreviews-iOS%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/EmergeTools/SnapshotPreviews-iOS)
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FEmergeTools%2FSnapshotPreviews-iOS%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/EmergeTools/SnapshotPreviews-iOS)
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FEmergeTools%2FSnapshotPreviews%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/EmergeTools/SnapshotPreviews)
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FEmergeTools%2FSnapshotPreviews%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/EmergeTools/SnapshotPreviews)
[![](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.emergetools.com%2Fapi%2Fv2%2Fpublic_new_build%3FexampleId%3Dsnapshotpreviews-ios.PreviewGallery%26platform%3Dios%26badgeOption%3Dversion_and_max_install_size%26buildType%3Drelease&query=$.badgeMetadata&label=PreviewGallery&logo=apple)](https://www.emergetools.com/app/example/ios/snapshotpreviews-ios.PreviewGallery/release?utm_campaign=badge-data)
[![](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.emergetools.com%2Fapi%2Fv2%2Fpublic_new_build%3FexampleId%3Dsnapshotpreviews-ios.SnapshottingTests%26platform%3Dios%26badgeOption%3Dversion_and_max_install_size%26buildType%3Drelease&query=$.badgeMetadata&label=SnapshottingTests&logo=apple)](https://www.emergetools.com/app/example/ios/snapshotpreviews-ios.SnapshottingTests/release?utm_campaign=badge-data)

An all-in-one snapshot testing solution built on Xcode previews. Automatic browsable gallery of previews, and no-code snapshot generation with XCTest. Supports SwiftUI and UIKit previews using `PreviewProvider` or `#Preview` and works on all Apple platforms (iOS/macOS/watchOS/tvOS/visionOS).

Expand All @@ -16,7 +18,7 @@ An all-in-one snapshot testing solution built on Xcode previews. Automatic brows
`PreviewGallery` is an interactive UI built on top of snapshot extraction. It turns your Xcode previews into a gallery of components and features you can access from your application, for example in an internal settings screen. **Xcode is not required to view the previews.** You can use it to preview individual components (buttons/rows/icons/etc) or even entire interactive features.

<p align="center">
<img src="https://raw.githubusercontent.com/EmergeTools/SnapshotPreviews-iOS/master/images/image1.png" />
<img src="https://raw.githubusercontent.com/EmergeTools/SnapshotPreviews/master/images/image1.png" />
</p>

The public API of PreviewGallery is a single SwiftUI `View` named `PreviewGallery`. Displaying this view gives you access to the full gallery. For example, you could add a button to open the gallery like this:
Expand Down Expand Up @@ -65,7 +67,7 @@ Note that there are no test functions; they are automatically added at runtime b
> [!NOTE]
> When you use Preview macros (`#Preview("Display Name")`) the name of the snapshot uses the file path and the name, for example: "MyModule/MyFile.swift:Display Name"

![Screenshot of Xcode test output](https://raw.githubusercontent.com/EmergeTools/SnapshotPreviews-iOS/master/images/testOutput.png)
![Screenshot of Xcode test output](https://raw.githubusercontent.com/EmergeTools/SnapshotPreviews/master/images/testOutput.png)

The [EmergeTools snapshot testing service](https://docs.emergetools.com/docs/snapshot-testing) generates snapshots and diffs them in the cloud to control for sources of flakiness, store images outside of git, and optimize test performance. `SnapshotTest` is for locally debugging these snapshot tests. You can also use `PreviewTest` to get code coverage of all previews in your unit test without generating PNGs. This will validate that previews do not crash (such as a missing @EnvironmentObject) but runs faster because it does not render the views to images.

Expand Down Expand Up @@ -101,10 +103,10 @@ See the demo app for a full example.

# Installation

Add the package dependency to your Xcode project using the URL of this repository (https://github.com/EmergeTools/SnapshotPreviews-iOS).
Add the package dependency to your Xcode project using the URL of this repository (https://github.com/EmergeTools/SnapshotPreviews).

<p align="center">
<img src="https://raw.githubusercontent.com/EmergeTools/SnapshotPreviews-iOS/master/images/image2.png" />
<img src="https://raw.githubusercontent.com/EmergeTools/SnapshotPreviews/master/images/image2.png" />
</p>

Link your app to `PreviewGallery` and (optionally) to `SnapshotPreferences` to customize the behavior of snapshot generation.
Expand Down Expand Up @@ -150,7 +152,7 @@ Check `ProcessInfo.isRunningPeviews` to disable behavior you don’t want in pre
> [!TIP]
> Using PreviewVariants greatly simplifies snapshot testing, by ensuring a consistent set of variants and that every view is provided a name.

Using multiple variants of the same view can ensure test coverage of all the ways users interact with your UI. Most are provided by SwiftUI, eg: `.dynamicTypeSize(.xxxLarge)`. There is one built into the package: `.emergeAccessibility(true)`. This function adds a visualization of voice over elements to your snapshot. You can automatically add variants using the [`PreviewVariants` View](https://github.com/EmergeTools/SnapshotPreviews-iOS/blob/main/DemoApp/DemoApp/TestViews/PreviewVariants.swift) that is demonstrated in the example app. It adds RTL, landscape, accessibility, dark mode and large text variants. You can use it like this:
Using multiple variants of the same view can ensure test coverage of all the ways users interact with your UI. Most are provided by SwiftUI, eg: `.dynamicTypeSize(.xxxLarge)`. There is one built into the package: `.emergeAccessibility(true)`. This function adds a visualization of voice over elements to your snapshot. You can automatically add variants using the [`PreviewVariants` View](https://github.com/EmergeTools/SnapshotPreviews/blob/main/Examples/DemoApp/DemoApp/TestViews/PreviewVariants.swift) that is demonstrated in the example app. It adds RTL, landscape, accessibility, dark mode and large text variants. You can use it like this:

```swift
struct MyView_Previews: PreviewProvider {
Expand All @@ -172,7 +174,7 @@ struct MyView_Previews: PreviewProvider {

# Star History

[![Star History Chart](https://api.star-history.com/svg?repos=EmergeTools/SnapshotPreviews-iOS&type=Date)](https://star-history.com/#EmergeTools/SnapshotPreviews-iOS&Date)
[![Star History Chart](https://api.star-history.com/svg?repos=EmergeTools/SnapshotPreviews&type=Date)](https://star-history.com/#EmergeTools/SnapshotPreviews&Date)

# Related Reading
- [How to use VariadicView, SwiftUI's Private View API](https://www.emergetools.com/blog/posts/how-to-use-variadic-view): VariadicView is a core part of how multiple images are rendered for one PreviewProvider.
Expand Down
48 changes: 48 additions & 0 deletions Sources/SnapshotPreferences/AppStoreSnapshotPreference.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// AppStoreSnapshotPreference.swift
//
//
// Created by Trevor Elkins on 09/30/24.
//

import Foundation
import SwiftUI

struct AppStoreSnapshotPreferenceKey: PreferenceKey {
static func reduce(value: inout Bool?, nextValue: () -> Bool?) {
if value == nil {
value = nextValue()
}
}

static var defaultValue: Bool? = nil
}

extension View {
/// Marks a snapshot for use with our App Store screenshot editing tool. This should ideally be used with a
/// full-size preview matching one of our supported devices.
///
/// - Note: This method is only available on iOS. It is unavailable on macOS, watchOS, visionOS, and tvOS.
///
/// - Parameter enabled: A Boolean value that determines whether the snapshot is for an App Store screenshot.
/// If `nil`, the effect will default to `false`.
///
/// - Returns: A view with the app store snapshot preference applied.
///
/// # Example
/// ```swift
/// struct ContentView: View {
/// var body: some View {
/// Text("My App Store listing!")
/// .emergeAppStoreSnapshot(true)
/// }
/// }
/// ```
@available(macOS, unavailable)
@available(watchOS, unavailable)
@available(visionOS, unavailable)
@available(tvOS, unavailable)
public func emergeAppStoreSnapshot(_ enabled: Bool?) -> some View {
preference(key: AppStoreSnapshotPreferenceKey.self, value: enabled)
}
}
4 changes: 4 additions & 0 deletions Sources/SnapshotPreferences/EmergeModifierFinder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class EmergeModifierState: NSObject {
var renderingMode: EmergeRenderingMode.RawValue?
var precision: Float?
var accessibilityEnabled: Bool?
var appStoreSnapshot: Bool?
}

@objc(EmergeModifierFinder)
Expand All @@ -49,5 +50,8 @@ class EmergeModifierFinder: NSObject {
.onPreferenceChange(AccessibilityPreferenceKey.self, perform: { value in
EmergeModifierState.shared.accessibilityEnabled = value
})
.onPreferenceChange(AppStoreSnapshotPreferenceKey.self, perform: { value in
EmergeModifierState.shared.appStoreSnapshot = value
})
}
}
11 changes: 6 additions & 5 deletions Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,17 @@ public class AppKitRenderingStrategy: RenderingStrategy {
window.contentViewController = NSViewController()
window.setContentSize(AppKitContainer.defaultSize)
window.contentViewController = vc
vc.rendered = { [weak vc] mode, precision, accessibilityEnabled in
vc.rendered = { [weak vc] mode, precision, accessibilityEnabled, appStoreSnapshot in
DispatchQueue.main.async {
let image = vc?.view.snapshot()
completion(
SnapshotResult(
image: image != nil ? .success(image!) : .failure(SwiftUIRenderingError.renderingError),
image: image != nil ? .success(image!) : .failure(RenderingError.failedRendering(vc?.view.bounds.size ?? .zero)),
precision: precision,
accessibilityEnabled: accessibilityEnabled,
accessibilityMarkers: nil,
colorScheme: _colorScheme))
colorScheme: _colorScheme,
appStoreSnapshot: appStoreSnapshot))
}
}
}
Expand All @@ -70,7 +71,7 @@ final class AppKitContainer: NSHostingController<EmergeModifierView>, ScrollExpa
var heightAnchor: NSLayoutConstraint?
var previousHeight: CGFloat?

public var rendered: ((EmergeRenderingMode?, Float?, Bool?) -> Void)? {
public var rendered: ((EmergeRenderingMode?, Float?, Bool?, Bool?) -> Void)? {
didSet { didCall = false }
}

Expand Down Expand Up @@ -120,7 +121,7 @@ final class AppKitContainer: NSHostingController<EmergeModifierView>, ScrollExpa
guard !didCall else { return }

didCall = true
rendered?(rootView.emergeRenderingMode, rootView.precision, rootView.accessibilityEnabled)
rendered?(rootView.emergeRenderingMode, rootView.precision, rootView.accessibilityEnabled, rootView.appStoreSnapshot)
}

override func updateViewConstraints() {
Expand Down
16 changes: 8 additions & 8 deletions Sources/SnapshotPreviewsCore/ConformanceLookup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,26 @@ import MachO
private func getTypeName(descriptor: UnsafePointer<TargetModuleContextDescriptor>) -> String? {
let flags = descriptor.pointee.flags
var parentName: String? = nil
if descriptor.pointee.parent != 0 {
let parent = UnsafeRawPointer(descriptor).advanced(by: MemoryLayout<TargetModuleContextDescriptor>.offset(of: \.parent)!).advanced(by: Int(descriptor.pointee.parent))
if abs(descriptor.pointee.parent) % 2 == 1 {
return nil
}
parentName = getTypeName(descriptor: parent.assumingMemoryBound(to: TargetModuleContextDescriptor.self))
}
switch flags.kind {
case .Module, .Enum, .Struct, .Class:
let name = UnsafeRawPointer(descriptor)
.advanced(by: MemoryLayout<TargetModuleContextDescriptor>.offset(of: \.name)!)
.advanced(by: Int(descriptor.pointee.name))
.assumingMemoryBound(to: CChar.self)
let typeName = String(cString: name)
if descriptor.pointee.parent != 0 {
let parent = UnsafeRawPointer(descriptor).advanced(by: MemoryLayout<TargetModuleContextDescriptor>.offset(of: \.parent)!).advanced(by: Int(descriptor.pointee.parent))
if abs(descriptor.pointee.parent) % 2 == 1 {
return nil
}
parentName = getTypeName(descriptor: parent.assumingMemoryBound(to: TargetModuleContextDescriptor.self))
}
if let parentName = parentName {
return "\(parentName).\(typeName)"
}
return typeName
default:
return nil
return parentName
}
}

Expand Down
4 changes: 2 additions & 2 deletions Sources/SnapshotPreviewsCore/ExpandingViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public final class ExpandingViewController: UIHostingController<EmergeModifierVi
private var startTime: Date?
private var timer: Timer?

public var expansionSettled: ((EmergeRenderingMode?, Float?, Bool?, Error?) -> Void)? {
public var expansionSettled: ((EmergeRenderingMode?, Float?, Bool?, Bool?, Error?) -> Void)? {
didSet { didCall = false }
}

Expand Down Expand Up @@ -78,7 +78,7 @@ public final class ExpandingViewController: UIHostingController<EmergeModifierVi
guard !didCall else { return }

didCall = true
expansionSettled?(rootView.emergeRenderingMode, rootView.precision, rootView.accessibilityEnabled, error)
expansionSettled?(rootView.emergeRenderingMode, rootView.precision, rootView.accessibilityEnabled, rootView.appStoreSnapshot, error)
stopAndResetTimer()
}

Expand Down
4 changes: 4 additions & 0 deletions Sources/SnapshotPreviewsCore/ModifierFinder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ public struct EmergeModifierView: View {
stateMirror?.descendant("accessibilityEnabled") as? Bool
}

var appStoreSnapshot: Bool? {
stateMirror?.descendant("appStoreSnapshot") as? Bool
}

var precision: Float? {
stateMirror?.descendant("precision") as? Float
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// AnyPreviewModifier.swift
// SnapshotPreviews
//
// Created by Itay Brenner on 26/9/24.
//

import SwiftUI

@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *)
struct AnyPreviewModifier: PreviewModifier {

private let _body: (PreviewModifier.Content) -> AnyView

init<M: PreviewModifier>(_ modifier: M) {
let type = type(of: modifier)
let hash = String(describing: type)

_body = { content in
let cachedContext = PreviewModifierContextCache.contextCache[hash]
guard let typedContext = cachedContext as? M.Context else {
fatalError("Context type mismatch, expected: \(String(describing: M.Context.self)), got: \(String(describing: cachedContext.self))")
}
return AnyView(modifier.body(content: content, context: typedContext))
}
}

func body(content: PreviewModifier.Content, context: Void) -> AnyView {
return _body(content)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// PreviewModifierContextCache.swift
// SnapshotPreviews
//
// Created by Itay Brenner on 26/9/24.
//

import SwiftUI

@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *)
struct PreviewModifierContextCache {
static var contextCache: [String: Any] = [:]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//
// View+PreviewModifier.swift
// SnapshotPreviews
//
// Created by Itay Brenner on 25/9/24.
//

import SwiftUI
import PreviewsSupport

extension View {
@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *)
func applyPreviewModifiers(_ modifiers: [any PreviewModifier]) -> some View {
var currentView: AnyView = AnyView(self)
for modifier in modifiers {
let viewModifier = PreviewModifierSupport.toViewModifier(modifier: AnyPreviewModifier(modifier))
currentView = AnyView(currentView.modifier(viewModifier))
}
return currentView
}
}
17 changes: 16 additions & 1 deletion Sources/SnapshotPreviewsCore/RenderingStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,26 +41,33 @@ public struct SnapshotResult {
precision: Float?,
accessibilityEnabled: Bool?,
accessibilityMarkers: [AccessibilityMark]?,
colorScheme: ColorScheme?)
colorScheme: ColorScheme?,
appStoreSnapshot: Bool?)
{
self.image = image
self.precision = precision
self.accessibilityEnabled = accessibilityEnabled
self.accessibilityMarkers = accessibilityMarkers
self.colorScheme = colorScheme
self.appStoreSnapshot = appStoreSnapshot
}

public let image: Result<ImageType, Error>
public let precision: Float?
public let accessibilityEnabled: Bool?
public let accessibilityMarkers: [AccessibilityMark]?
public let colorScheme: ColorScheme?
public let appStoreSnapshot: Bool?
}

public protocol RenderingStrategy {
@MainActor func render(
preview: SnapshotPreviewsCore.Preview,
completion: @escaping (SnapshotResult) -> Void)

@MainActor func preparePreview(
preview: SnapshotPreviewsCore.Preview
) async
}

private let testHandler: NSObject.Type? = NSClassFromString("EMGTestHandler") as? NSObject.Type
Expand All @@ -69,5 +76,13 @@ extension RenderingStrategy {
static func setup() {
testHandler?.perform(NSSelectorFromString("setup"))
}

@MainActor public func preparePreview(
preview: SnapshotPreviewsCore.Preview
) async {
if #available(iOS 18.0, *) {
await preview.loadPreviewModifiers()
}
}
}

Loading