Profile
Back to NewsBack
GitHub Trending 8 min
Reader Mode
BarredEwe/Prefire: 🔥 A library based on Xcode Preview, for easy generation: Playbook view, Snapshot and Accessibility tests. SwiftUI and UIKit supported!

BarredEwe/Prefire: 🔥 A library based on Xcode Preview, for easy generation: Playbook view, Snapshot and Accessibility tests. SwiftUI and UIKit supported!

11 hours ago

!Prefire

Release Platform Swift6 Swift Package Manager Swift Package Manager

🔥 What is Prefire?

Prefire transforms your #Preview blocks into:

  • ✅ Snapshot tests
  • ✅ Playbook views
  • ✅ Visual flows with states and user stories
  • ✅ Living documentation — fully automated

🚀 Key Features

Playbook
  • 🧠 Smart Preview Parsing — including #Preview, @Previewable
  • 📸 Snapshot Testing — automatic test generation from previews (iOS, tvOS, macOS)
  • 📚 Playbook View — auto-generated interactive component catalog (iOS)
  • 🏃 Flow-aware — build user stories from multiple preview steps
  • 🧩 UIKit + AppKit Support — support for UIView/UIViewController and NSView/NSViewController
  • ⚙️ SPM + Xcode Plugins — works in CLI, Xcode build phases, or CI
  • 🧠 Fast Caching — fingerprint-based AST and body caching avoids redundant work
  • ✍️ Stencil Templates — customize output with your own templates

Why Prefire?

  • 🔥 Save Time - Generate tests and documentation automatically
  • 🔥 Stay Consistent - Keep previews and tests always in sync
  • 🔥 Improve Quality - Catch visual regressions before users do
  • 🔥 Boost Collaboration - Share living documentation with your team


⚡️ Quick Start

📦 Example project available at: Prefire Example

1. Add Prefire to Your Project

// Package.swift
dependencies: [
    .package(url: "https://github.com/BarredEwe/Prefire.git", from: "5.4.0")
    .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.0"),
],
.testTarget(
    dependencies: [
      .product(name: "Prefire", package: "Prefire"),
      .product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
    ],
    plugins: [
        .plugin(name: "PrefireTestsPlugin", package: "Prefire")
    ]
)

2. Write #Preview

#Preview {
    Button("Submit")
}

3. Run tests

Just run the test target 🚀 — Prefire will auto-generate snapshots based on your previews.

💡 If your test target is empty, Prefire will still generate files and snapshot code during build.

📦 Installation

Supports:

  • ✅ SPM Plugin (Package.swift)
  • ✅ Xcode Build Tool Plugin
  • ✅ CLI (brew install prefire)
  • ✅ Mint (Beta) (mint install BarredEwe/Prefire)
  • ✅ GitHub Actions / CI
See detailed setup in the Installation guide

Mint

Install the bundled CLI via Mint:

mint install BarredEwe/Prefire

The installed prefire wrapper ships the PrefireBinary artifactbundle as an embedded SwiftPM resource, so it is self-contained — no external binary path is required. To override the path of the embedded binary (e.g. for local development against an in-progress build), set PREFIRE_BINARY_PATH to the absolute path of the prefire executable.

🧠 How It Works

🔍 1. Parses all source files

  • Finds all #Preview and PreviewProvider blocks
  • Supports modifiers: .prefireEnabled(), .prefireIgnored()

📂 2. Caches Types and PreviewBodies

  • Based on file modification date + SHA-256 of inputs
  • Avoids re-parsing if nothing changed

🔢 3. Generates Snapshot Tests

  • Uses Stencil templates
  • Respects .prefire.yml configuration

📘 4. Generates Playbook View

  • Groups by UserStory, State
  • Outputs PreviewModels.generated.swift

🛠 Advanced Usage

To generate tests and playbook, simply mark your preview using the PrefireProvider protocol:
struct Text_Previews: PreviewProvider, PrefireProvider {
    static var previews: some View { ... }
}
If you use the #Preview macro, 🔥Prefire will automatically find it!

If you don't need it, mark view - .prefireIgnored():

#Preview {
    Text("")
        .prefireIgnored()
}

If you want to disable the automatic get of all previews, use the setting preview_default_enabled: false. Then to include preview in the test, you need to call the .prefireEnabled():

#Preview {
    Text("")
        .prefireEnabled()
}

Playbook (Demo) View

To use Playbook, simply use PlaybookView
  • If you want to see a list of all the Views, use isComponent: true
  • If you want to sort by UserStory, use isComponent: false
import Prefire

struct ContentView: View { var body: some View { PlaybookView(isComponent: true, previewModels: PreviewModels.models) } }

Snapshot tests

Just run generated tests 🚀 All tests will be generated in the DerivedData folder.

Plugin PrefireTestsPlugin will handle everything for you 🛠️

For detailed instruction, check out swift-snapshot-testing or examine an example project.


API

Prefire provide new commands for previews:
  • You can set the delay, precision and perceptualPrecision parameters for the snapshot:
.snapshot(delay: 0.3, precision: 0.95, perceptualPrecision: 0.98)
static var previews: some View {
        TestView()
            .snapshot(delay: 0.3, precision: 0.95, perceptualPrecision: 0.98)
    }
  • Instead of guessing a delay, you can wait until the preview stops changing:
.snapshot(waitForIdle: true)
    .snapshot(waitForIdle: true, timeout: 2.0)

Prefire renders the preview, compares the frames it would capture every 50 ms, and takes the snapshot once nothing changed for 300 ms. A preview that never settles fails with a message instead of hanging, after timeout (5 seconds by default).

Put the modifier on content that is there from the first render: one inside content that only shows up later, such as an if isLoaded branch or a List row, is not seen.

Waiting for idle only sees what is drawn: a preview that shows the same frame for longer while it loads, or runs a Core Animation animation such as a ProgressView spinner, looks idle. Use .snapshot(waitUntil:) for those, or a delay as a floor: Prefire spends it before it starts comparing frames, and does not wait it out a second time when capturing. Both modifiers can be combined, since they configure different things:

static var previews: some View {
        TestView()
            .snapshot(delay: 0.1, precision: 0.95)
            .snapshot(waitForIdle: true)
    }

Set snapshot_wait_for_idle: true in .prefire.yml to make it the default for every preview, and opt single previews out with .snapshot(waitForIdle: false).

  • When the preview itself knows when it is ready, wait for a condition:
.snapshot(waitUntil: { viewModel.isLoaded })
    .snapshot(waitUntil: { viewModel.isLoaded }, timeout: 2.0)

The condition is checked on the main thread while the preview keeps rendering, and the test fails with the call site of the modifier when it is still false after timeout.

A helper wrapping the modifier can pass its own call site on, so a timeout names the preview that uses it:

extension View {
        func waitingUntilLoaded(_ viewModel: ViewModel, fileID: String = #fileID, line: UInt = #line) -> some View {
            snapshot(waitUntil: { viewModel.isLoaded }, fileID: fileID, line: line)
        }
    }

When several views of a preview wait, a condition is waited for over idle, every condition has to be met, .snapshot(waitForIdle: false) of one view does not cancel the wait of another, and the longest timeout applies.

The preview is hosted once, waited on and captured in the same window, so state it reached in .onAppear or .task is kept. Accessibility snapshots are the exception: they render the preview anew and only honor delay.

Waiting runs the main run loop in place, so snapshots that wait have to be taken from a synchronous test method, as the generated tests do. From async code .task never runs, and the timeout message says so.

  • Function for connecting preview together in one Flow:
.previewUserStory(.auth)
static var previews: some View {
        PrefireView()
            .previewUserStory(.auth)
    }

static var previews: some View { AuthView() .previewUserStory(.auth) }

For example Authorization flow: LoginView, OTPView and PincodeView


  • If a preview contains more than one View, you can mark State for these views.
.previewState(.loading)
static var previews: some View {
        TestView("Default")

TestView("Loading") .previewState(.loading) }


  • Prefire supports native SwiftUI parameterized previews:
#Preview("TextView", traits: .sizeThatFitsLayout, arguments: ["- A", "- B", "- C"]) { suffix in
        Text("1 \(suffix)")
    }

Prefire expands each argument into its own snapshot and Playbook preview.


🧰 API Summary

| Feature | Modifier | |--------|----------| | Include in snapshot | .prefireEnabled() | | Exclude from snapshot | .prefireIgnored() | | Group in a flow | .previewUserStory(.auth) | | Mark a UI state | .previewState(.error) | | Customize snapshot | .snapshot(delay: 0.3, precision: 0.95) | | Wait until the preview settles | .snapshot(waitForIdle: true, timeout: 2.0) | | Wait for a condition | .snapshot(waitUntil: { viewModel.isLoaded }) | | Parameterized preview | #Preview(..., arguments: values) |


💡 Advanced: CLI Usage

# Generate snapshot tests
prefire tests

Generate playbook models

prefire playbook

Run prefire tests --help or prefire playbook --help for more options.


🗂 Configuration: .prefire.yml

See detailed configuration in the Configuration guide

test_configuration:
  target: MyApp

playbook_configuration: preview_default_enabled: true


Distribution

When preparing for distribution, you may want to exclude your PreviewProvider and mock data from release builds. This can be achieved by wrapping them in #if DEBUG compiler directives. Alternatively, you can pass a compiler flag to exclude PreviewModels from release builds.

To exclude PreviewModels using Swift Package Manager, pass the PLAYBOOK_DISABLED swift setting in the package that links PrefirePlaybookPlugin:

swiftSettings: [
    .define("PLAYBOOK_DISABLED", .when(configuration: .release)),
]

If you are using Xcode, you can pass the compiler flag in the Xcode build settings:

SWIFT_ACTIVE_COMPILATION_CONDITIONS = PLAYBOOK_DISABLED;

🧠 Internal Architecture

  • PrefireCore — AST + preview parsing, caching, logic
  • PrefireGenerator — handles stencil templating + snapshot generation
  • PrefireCacheManager — unifies caching for Types and Previews
  • PrefireTestsPlugin / PrefirePlaybookPlugin — SPM/Xcode integrations
  • prefire — CLI entry point, calls shared generator code

Requirements

  • Swift 6.0 or higher
  • Xcode 16.0 or higher
  • iOS 14 or higher; macOS 13 or higher (macOS: snapshot tests only, see Configuration)

Troubleshooting

NavigationView in Preview not supported for Playbook
  • Consider using other views or layouts for your Playbook needs.
Running Prefire via CI
  • To run Prefire via Continuous Integration (CI), you need to configure permissions:
defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidatation -bool YES

Xcode is unable to generate tests in a custom path.

  • To resolve this, you’ll need to disable the sandbox for file generation by running the following command in your terminal:
defaults write com.apple.dt.Xcode IDEPackageSupportDisablePluginExecutionSandbox -bool YES

🐱 Also by the author

Mewmori

Mewmori is a pixel cat that lives on your Mac desktop. He reads what's on screen, says something about it, and remembers you between sessions. A 2B model runs on the machine through llama.cpp, so screen text, chats and memory stay on your Mac.

Swift, AppKit + SwiftUI, 29 MB. Free; only skins are paid.


🤝 Contributing

We welcome contributions! Please follow these steps:
  1. Fork the repository
  2. Create a feature branch
  3. Submit a Pull Request

📄 License

Prefire is released under the Apache License 2.0. See LICENSE for details.
Chat with me