🔥 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
- 🧠 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/UIViewControllerandNSView/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
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
#PreviewandPreviewProviderblocks - 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
Stenciltemplates - Respects
.prefire.ymlconfiguration
📘 4. Generates Playbook View
- Groups by
UserStory,State - Outputs
PreviewModels.generated.swift
🛠 Advanced Usage
To generate tests and playbook, simply mark your preview using thePrefireProvider 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 usePlaybookView
- 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 markStatefor 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, logicPrefireGenerator— handles stencil templating + snapshot generationPrefireCacheManager— unifies caching forTypesandPreviewsPrefireTestsPlugin/PrefirePlaybookPlugin— SPM/Xcode integrationsprefire— 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.
- 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 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:- Fork the repository
- Create a feature branch
- Submit a Pull Request