Skip to content
Merged
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
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,45 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## BOM [3.2.0-paparazzialpha05] - TBD

Adds support for androidx's `@PreviewWrapper`: a preview that declares a `PreviewWrapperProvider` —
directly or through a custom preview annotation — is now snapshot wrapped exactly as Android Studio
renders it, with no change to your `decorate { }`. The BOM re-pins the set; `annotations` is functionally
unchanged and is not re-released.

> ⚠️ Consumers must move to **Compose BOM 2026.08.00** (`androidx.compose.ui:ui-tooling-preview`
> **1.12.0**), where `@PreviewWrapper` may also be placed on custom preview annotations — 1.11.x
> declares the annotation but targets functions only. `framework` depends on the Compose BOM itself, so
> the whole 2026.08.00 platform (Material3 1.4.0 included) reaches your unit-test classpath
> transitively. Nothing else about the API changed, and previews without `@PreviewWrapper` render
> exactly as before.

### framework
- Added automatic `@PreviewWrapper` support. The wrapper is resolved once per preview at test-class
construction (the annotation has `BINARY` retention, so it is read from the class files) and applied
**innermost**, inside `decorate` and the font-scale density — a wrapper reading `MaterialTheme`
therefore follows the UI-mode axis. A fresh wrapper instance is created per snapshot, mirroring Studio.
Wrappers inherited from custom preview annotations are followed recursively; a function-level
`@PreviewWrapper` wins over an inherited one, and two different inherited wrappers are a configuration
error.
- Added public `PreviewWrappers` (`Disabled` / `Enabled(locate)`) and `PreviewFunction`, plus the
`previewWrappers(...)` config-DSL entry (both `@ExperimentalSnapshotsApi`).
`previewWrappers(PreviewWrappers.Disabled)` is the kill switch;
`PreviewWrappers.Enabled { component -> … }` overrides the automatic preview lookup.
- Fail-fast validation extends to wrappers: a preview whose declaration site cannot be located, an
ambiguous inherited wrapper, or a wrapper class that is missing / abstract / not a
`PreviewWrapperProvider` / without a public zero-argument constructor throws `SnapshotConfigException`
at construction, naming the preview and the remedy.
- Added `org.ow2.asm:asm` as a new runtime dependency (class-file reading); it reaches your unit-test
classpath transitively.

### paparazzi
- No API or behavioral change; re-released at `3.2.0-paparazzialpha05` against the new `framework`.

### annotations
- No changes; stays at `3.1.0`.

## BOM [3.1.0-paparazzialpha05] - 2026-07-10

Dependency and build-toolchain update. No new features and no public API or behavioral changes to the
Expand Down
86 changes: 86 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ The framework is designed with extensibility in mind, using a modular architectu
- Font scale testing
- Per-preview exceptions without touching the library: inline `extraMetadata` tokens and named `profile`s (narrow **or** widen a single axis for one
preview), plus a standing `exclude` predicate for axis couplings
- Automatic androidx `@PreviewWrapper` support, including wrappers inherited from custom preview annotations
- Fail-fast validation: untagged, over-restricted or name-colliding previews throw `SnapshotConfigException` at test-class construction
- Integration with popular testing tools:
- Paparazzi for snapshot generation
Expand Down Expand Up @@ -80,6 +81,10 @@ ksp(libs.showkase.processor)
> `framework` depends on `annotations`, and `paparazzi` depends on `framework`. The BOM pins a
> compatible set of all three, so you never specify their versions explicitly.

> `framework` applies androidx `@PreviewWrapper` automatically, which requires **Compose BOM 2026.08.00**
> (`androidx.compose.ui:ui-tooling-preview` 1.12.0) on your unit-test classpath. See
> [Preview wrappers](#preview-wrappers).

### Paparazzi
If you use `paparazzi` artifact, you will need the following additional configuration:

Expand Down Expand Up @@ -217,6 +222,7 @@ The DSL surface:
| `decorate { uiMode, content -> }` | config | yes | Wrapper around every preview (your theme), keyed off the `UiMode`. |
| `before { context -> }` | config | no | Setup before each snapshot, given the prepared `Context`. |
| `variants { }` | config | yes | Declares the matrix. |
| `previewWrappers(PreviewWrappers)` | config | no | How androidx `@PreviewWrapper` is handled (default: `Enabled()`, see below). |
| `components()` | variants | — | Enable Component-tagged previews. |
| `screens(vararg DeviceConfig)` | variants | — | Enable Screen-tagged previews on the given devices. |
| `uiModes(vararg UiMode)` | variants | yes (≥1) | UI modes every preview is rendered in. |
Expand Down Expand Up @@ -306,6 +312,86 @@ A profile body (`ProfileOverrideScope`) may override any subset of `devices(...)
to the class config. A preview may reference **at most one** profile, but may still add inline tokens on top of it — an inline token wins over the
profile on the same axis.

### Preview wrappers

androidx's `@PreviewWrapper(SomeWrapper::class)` (from `androidx.compose.ui:ui-tooling-preview`) lets a preview declare the composable that should
wrap it — typically a theme, a locale or a fake dependency provider. Android Studio applies it when rendering the preview, and **so does this
framework**: no configuration and no change to `decorate { }` are needed. This requires **Compose BOM 2026.08.00** (`ui-tooling-preview` 1.12.0),
where `@PreviewWrapper` may also be placed on custom preview annotations; 1.11.x targets functions only.

```kotlin
class FrameWrapper : PreviewWrapperProvider {

@Composable
override fun Wrap(content: @Composable () -> Unit) {
Box(modifier = Modifier.border(4.dp, MaterialTheme.colorScheme.primary).padding(8.dp)) { content() }
}
}

@Preview
@PreviewWrapper(FrameWrapper::class)
@ShowkaseComposable(name = "ArticleCard", group = "Cards", extraMetadata = [PreviewSnapshotKind.Component])
@Composable
fun ArticleCardPreview() { ArticleCard(title = "Wrapped", body = "…") }
```

The wrapper is applied **innermost** — the composition is `decorate → font-scale density → Wrap → preview` — so your theme still sits
outside it and a wrapper reading `MaterialTheme.colorScheme` follows the UI-mode axis. A fresh wrapper instance is created for every snapshot,
exactly as Studio does per render.

`@PreviewWrapper` on a **custom preview annotation** is inherited by every preview using it, following nested annotations recursively:

```kotlin
@PreviewWrapper(FrameWrapper::class)
annotation class FramedPreview

@Preview
@FramedPreview // the preview is wrapped in FrameWrapper
@ShowkaseComposable(name = "PriceTag", group = "Cards", extraMetadata = [PreviewSnapshotKind.Component])
@Composable
fun PriceTagPreview() { PriceTag(price = "$19.99") }
```

A `@PreviewWrapper` **on the function wins** over one inherited from an annotation; two *different* inherited wrappers are a configuration error
(declare the wrapper on the function to resolve it).

Because the annotation has `BINARY` retention it is invisible to reflection, so the framework reads it from the compiled class files at test-class
construction, mapping each Showkase component back to its preview function. Resolution is **strict**: any preview it cannot map — annotated or
not — and any unusable wrapper class (not on the test classpath, not a `PreviewWrapperProvider`, abstract, or without a public zero-argument
constructor) throws `SnapshotConfigException` naming the preview and the problem.

`previewWrappers(...)` configures the whole mechanism:

```kotlin
class SampleSnapshotTests : PaparazziSnapshotTests({
previews(Showkase.getMetadata())
decorate { uiMode, content -> SnapshotsSampleTheme(darkTheme = uiMode == UiMode.DARK) { content() } }

// Ignore @PreviewWrapper entirely — annotated previews render unwrapped:
previewWrappers(PreviewWrappers.Disabled)

// …or keep it on and help the mapper with the previews it cannot follow:
previewWrappers(
PreviewWrappers.Enabled { component ->
if (component.componentName == "Exotic") PreviewFunction("com.app.ui.PreviewsKt", "ExoticPreview") else null
},
)

variants { components(); uiModes(UiMode.LIGHT, UiMode.DARK); fontScales(FontScale.NORMAL) }
})
```

`locate` is consulted once per preview before the automatic mapping; returning `null` falls through to it, and a returned `PreviewFunction` (JVM
binary class name + Kotlin function name) is used as the declaration site whose annotations are read. The last `previewWrappers(...)` call wins.

**Troubleshooting**

- *"no generated Showkase host could be found for componentKey …"* / *"the generated Showkase host(s) … contain no call to function …"* — the
mapping from the Showkase component back to its preview function failed, so the annotation could not be read. Point the framework at the
declaration site with `previewWrappers(PreviewWrappers.Enabled { … })`, or turn it off with `previewWrappers(PreviewWrappers.Disabled)`.
- *"class … has class file major version …, which the bundled ASM cannot read"* — your class files are newer than the bundled ASM supports.
Add a newer `org.ow2.asm:asm` test dependency (Gradle resolves to the highest version), or use `previewWrappers(PreviewWrappers.Disabled)`.

### Scoping previews

`previews(...)` takes a `ShowkaseElementsMetadata`. Pass the whole set (`Showkase.getMetadata()`) only if every discovered preview carries a
Expand Down
34 changes: 34 additions & 0 deletions framework/api/framework.api
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,39 @@ public final class io/github/ackeecz/snapshots/framework/FontScale : java/lang/E
public static fun values ()[Lio/github/ackeecz/snapshots/framework/FontScale;
}

public final class io/github/ackeecz/snapshots/framework/PreviewFunction {
public static final field $stable I
public fun <init> (Ljava/lang/String;Ljava/lang/String;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lio/github/ackeecz/snapshots/framework/PreviewFunction;
public static synthetic fun copy$default (Lio/github/ackeecz/snapshots/framework/PreviewFunction;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/github/ackeecz/snapshots/framework/PreviewFunction;
public fun equals (Ljava/lang/Object;)Z
public final fun getClassName ()Ljava/lang/String;
public final fun getFunctionName ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public abstract interface class io/github/ackeecz/snapshots/framework/PreviewWrappers {
}

public final class io/github/ackeecz/snapshots/framework/PreviewWrappers$Disabled : io/github/ackeecz/snapshots/framework/PreviewWrappers {
public static final field $stable I
public static final field INSTANCE Lio/github/ackeecz/snapshots/framework/PreviewWrappers$Disabled;
public fun equals (Ljava/lang/Object;)Z
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class io/github/ackeecz/snapshots/framework/PreviewWrappers$Enabled : io/github/ackeecz/snapshots/framework/PreviewWrappers {
public static final field $stable I
public fun <init> ()V
public fun <init> (Lkotlin/jvm/functions/Function1;)V
public synthetic fun <init> (Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getLocate ()Lkotlin/jvm/functions/Function1;
}

public final class io/github/ackeecz/snapshots/framework/SnapshotConfigException : java/lang/RuntimeException {
public static final field $stable I
public fun <init> (Ljava/lang/String;)V
Expand Down Expand Up @@ -118,6 +151,7 @@ public abstract interface class io/github/ackeecz/snapshots/framework/dsl/Profil
public abstract interface class io/github/ackeecz/snapshots/framework/dsl/SnapshotConfigScope {
public abstract fun before (Lkotlin/jvm/functions/Function1;)V
public abstract fun decorate (Lkotlin/jvm/functions/Function4;)V
public abstract fun previewWrappers (Lio/github/ackeecz/snapshots/framework/PreviewWrappers;)V
public abstract fun previews (Lcom/airbnb/android/showkase/models/ShowkaseElementsMetadata;)V
public abstract fun variants (Lkotlin/jvm/functions/Function1;)V
}
Expand Down
3 changes: 3 additions & 0 deletions framework/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ dependencies {

implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)

implementation(libs.asm)

implementation(libs.kotest.runner.junit5)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ import io.kotest.core.spec.style.FunSpec
* `PaparazziSnapshotTests` in the `:paparazzi` module is the ready-to-use, Paparazzi-backed subclass.
*/
@ExperimentalSnapshotsApi
abstract class AckeeSnapshotTests(
abstract class AckeeSnapshotTests internal constructor(
engineFactory: () -> SnapshotEngine,
config: SnapshotConfigScope.() -> Unit,
resolver: SnapshotResolver,
) : FunSpec({
val snapshotConfig = SnapshotConfigScopeImpl().apply(config).build()
val resolved = SnapshotResolver().resolve(snapshotConfig)
val resolved = resolver.resolve(snapshotConfig)
resolved.groupBy { Triple(it.variant.kind, it.variant.device, it.variant.uiMode) }.forEach { (group, snapshots) ->
val (kind, device, uiMode) = group
context("${snapshotTargetLabel(device)}_$uiMode") {
Expand All @@ -47,7 +48,13 @@ abstract class AckeeSnapshotTests(
}
}
}
})
}) {

constructor(
engineFactory: () -> SnapshotEngine,
config: SnapshotConfigScope.() -> Unit,
) : this(engineFactory, config, SnapshotResolver())
}

private fun takeSnapshot(
snapshotConfig: SnapshotConfig,
Expand All @@ -61,6 +68,9 @@ private fun takeSnapshot(
if (snapshotConfig.before !== NO_OP_BEFORE) {
snapshotConfig.before(engine.context)
}
// A fresh wrapper per snapshot, mirroring Studio's per-render instantiation. It is created outside the
// composable lambda so instantiation failures surface as the test's failure, not as a composition error.
val wrapper = snapshot.wrapper?.create()
engine.snapshot(snapshot.name) {
snapshotConfig.decorate(uiMode) {
CompositionLocalProvider(
Expand All @@ -69,7 +79,11 @@ private fun takeSnapshot(
fontScale = snapshot.variant.fontScale.scale,
),
) {
snapshot.content()
if (wrapper == null) {
snapshot.content()
} else {
wrapper.Wrap { snapshot.content() }
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.github.ackeecz.snapshots.framework

import io.github.ackeecz.snapshots.annotations.ExperimentalSnapshotsApi

/**
* A preview function's location: its JVM binary [className] (`com.app.ui.PreviewsKt`, `com.app.ui.Outer$Inner`)
* and its Kotlin [functionName].
*/
@ExperimentalSnapshotsApi
data class PreviewFunction(val className: String, val functionName: String)
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.github.ackeecz.snapshots.framework

import com.airbnb.android.showkase.models.ShowkaseBrowserComponent
import io.github.ackeecz.snapshots.annotations.ExperimentalSnapshotsApi

/**
* How the framework treats androidx's `@PreviewWrapper` on the discovered previews.
* Defaults to [Enabled] without a locator.
*/
@ExperimentalSnapshotsApi
sealed interface PreviewWrappers {

/** Never resolve or apply wrappers; annotated previews render unwrapped. */
data object Disabled : PreviewWrappers

/**
* Resolve `@PreviewWrapper` — declared directly on the preview function or inherited from a custom preview
* annotation — and apply it innermost around the preview.
*
* [locate] is consulted once per preview before the automatic Showkase-based mapping: a non-null result is used
* as the preview's declaration site (its annotation is still read there), null falls through to the automatic
* mapping. Use it for previews the automatic mapping cannot follow.
*/
class Enabled(val locate: ((ShowkaseBrowserComponent) -> PreviewFunction?)? = null) : PreviewWrappers
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package io.github.ackeecz.snapshots.framework

import androidx.compose.runtime.Composable
import io.github.ackeecz.snapshots.framework.wrapper.PreviewWrapperFactory

internal data class ResolvedSnapshot(
val name: String,
val variant: SnapshotVariant,
val content: @Composable () -> Unit,
val wrapper: PreviewWrapperFactory?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ internal data class SnapshotConfig(
val profiles: Map<String, ProfileOverride> = emptyMap(),
val before: (Context) -> Unit = NO_OP_BEFORE,
val decorate: @Composable (UiMode, @Composable () -> Unit) -> Unit = PASSTHROUGH_DECORATE,
val previewWrappers: PreviewWrappers = PreviewWrappers.Enabled(),
)

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
package io.github.ackeecz.snapshots.framework

internal class SnapshotResolver {
import io.github.ackeecz.snapshots.framework.wrapper.BytecodePreviewWrapperResolver
import io.github.ackeecz.snapshots.framework.wrapper.PreviewWrapperResolver
import io.github.ackeecz.snapshots.framework.wrapper.WrapperResolution

internal class SnapshotResolver(
private val wrapperResolverFactory: (PreviewWrappers.Enabled) -> PreviewWrapperResolver =
::BytecodePreviewWrapperResolver,
) {

private val previewIdentifier = PreviewIdentifier()
private val extraMetadataParser = ExtraMetadataParser()

fun resolve(config: SnapshotConfig): List<ResolvedSnapshot> {
val errors = mutableListOf<String>()
val wrapperResolver = (config.previewWrappers as? PreviewWrappers.Enabled)?.let(wrapperResolverFactory)
val resolvedSnapshots = previewIdentifier.assignIds(config.previews).flatMap { preview ->
val parsedMetadata = extraMetadataParser.parse(preview.component.extraMetadata, config.profiles.keys)
if (parsedMetadata.kind == null) {
Expand All @@ -21,11 +29,20 @@ internal class SnapshotResolver {
return@flatMap emptyList()
}

val wrapper = when (val resolution = wrapperResolver?.resolve(preview.component)) {
is WrapperResolution.Wrapped -> resolution.factory
is WrapperResolution.Failed -> {
errors += "Preview '${preview.id}' ${resolution.reason}"
null
}
WrapperResolution.Unwrapped, null -> null
}
variants.map { variant ->
ResolvedSnapshot(
name = variantName(preview.id, variant),
variant = variant,
content = preview.component.component,
wrapper = wrapper,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import androidx.compose.runtime.Composable
import com.airbnb.android.showkase.models.ShowkaseElementsMetadata
import io.github.ackeecz.snapshots.annotations.ExperimentalSnapshotsApi
import io.github.ackeecz.snapshots.framework.PreviewWrappers
import io.github.ackeecz.snapshots.framework.UiMode

/**
Expand All @@ -30,6 +31,12 @@ interface SnapshotConfigScope {
*/
fun decorate(block: @Composable (UiMode, @Composable () -> Unit) -> Unit)

/**
* Optional. Configures how androidx's `@PreviewWrapper` is handled on the discovered previews; defaults to
* [PreviewWrappers.Enabled] without a locator. The last call wins.
*/
fun previewWrappers(wrappers: PreviewWrappers)

/** Declares the variant matrix — kinds, devices, UI modes, font scales, excludes and profiles. Required. */
fun variants(block: VariantsScope.() -> Unit)
}
Loading