diff --git a/Ext/Paparazzi/README.md b/Ext/Paparazzi/README.md new file mode 100644 index 000000000..5681c1698 --- /dev/null +++ b/Ext/Paparazzi/README.md @@ -0,0 +1,133 @@ +# Testify — Android Screenshot Testing — Paparazzi Extensions + +Maven Central + +**Utility library for [Paparazzi](https://github.com/cashapp/paparazzi) snapshot testing, providing factory functions, theme helpers, and multi-variant testing support for Compose UIs.** + +Paparazzi snapshot tests often repeat identical boilerplate: rule construction, theme wrapping, and manual light/dark duplication. The Testify Paparazzi extension eliminates this repetition by providing: + +- **Device presets** — Curated set of common device configurations (phone, tablet, foldable). +- **Theme helpers** — A `ThemeProvider` interface and extension functions for automatic light/dark snapshot coverage. +- **Factory functions** — `TestifyPaparazzi.component()` and `TestifyPaparazzi.screen()` replace repetitive `Paparazzi(...)` constructors. +- **Font scale testing** — Presets and helpers for verifying accessibility font sizes. +- **Locale/RTL testing** — Presets for internationalization and pseudolocalization testing. +- **Accessibility snapshots** — Pre-configured `AccessibilityRenderExtension` factory. +- **State matrix testing** — Snapshot multiple component states from a single test method. +- **ComposableSnapshotRule** — A high-level JUnit rule combining all features into one declaration. + +# Set up testify-paparazzi + +**settings.gradle** + +Ensure that `mavenCentral()` is available in `dependencyResolutionManagement`. + +**Application build.gradle** +```groovy +dependencies { + testImplementation "dev.testify:testify-paparazzi:5.0.1" + testImplementation "app.cash.paparazzi:paparazzi:2.0.0-alpha04" +} +``` + +# Write a test + +### Basic snapshot with theme + +Define a `ThemeProvider` for your app's theme: + +```kotlin +val myThemeProvider = ThemeProvider { darkTheme, content -> + MyAppTheme(darkTheme = darkTheme) { content() } +} +``` + +### Using ComposableSnapshotRule + +The highest-level API. A single rule declaration provides themed snapshots with no boilerplate: + +```kotlin +class MyComponentTest { + + @get:Rule val snapshot = ComposableSnapshotRule(themeProvider = myThemeProvider) + + @Test fun default() = snapshot.snapshot { MyComponent() } + + @Test fun darkTheme() = snapshot.snapshot(variant = ThemeVariant.DARK) { MyComponent() } + + @Test fun allThemes() = snapshot.snapshotAllThemes { MyComponent() } +} +``` + +### Using factory functions directly + +For more control, use `TestifyPaparazzi` factory functions with the snapshot extension functions: + +```kotlin +class MyComponentTest { + + @get:Rule val paparazzi = TestifyPaparazzi.component() + + @Test fun default() { + paparazzi.themedSnapshot(myThemeProvider) { MyComponent() } + } + + @Test fun allThemes() { + paparazzi.snapshotAllThemes(myThemeProvider) { MyComponent() } + } +} +``` + +### State matrix testing + +Snapshot multiple component states from a single test method: + +```kotlin +@Test fun ratingStates() { + paparazzi.snapshotStates( + variants = listOf( + StateVariant("zero_stars", 0), + StateVariant("three_stars", 3), + StateVariant("five_stars", 5), + ), + themeProvider = myThemeProvider, + ) { rating -> + RatingBar(rating = rating) + } +} +``` + +### Font scale testing + +Verify your UI at different accessibility font sizes: + +```kotlin +@Test fun largeFonts() { + paparazzi.snapshotAllFontScales { MyComponent() } +} +``` + +--- + +# License + + MIT License + + Copyright (c) 2026 ndtp + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. diff --git a/Ext/Paparazzi/build.gradle b/Ext/Paparazzi/build.gradle new file mode 100644 index 000000000..8b03599b2 --- /dev/null +++ b/Ext/Paparazzi/build.gradle @@ -0,0 +1,64 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile + +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + alias(libs.plugins.compose.compiler) + id 'org.jetbrains.dokka' + id 'maven-publish' + id 'signing' +} + +ext { + pom = [ + publishedGroupId : 'dev.testify', + artifact : 'testify-paparazzi', + libraryName : 'testify-paparazzi', + libraryDescription: 'Paparazzi snapshot testing utilities for Android Testify', + siteUrl : 'https://github.com/ndtp/android-testify', + gitUrl : 'https://github.com/ndtp/android-testify.git', + licenseName : 'The MIT License', + licenseUrl : 'https://opensource.org/licenses/MIT', + author : 'ndtp' + ] +} + +version = project.findProperty("testify_version") ?: "0.0.1-SNAPSHOT" +group = pom.publishedGroupId +archivesBaseName = pom.artifact + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +task sourcesJar(type: Jar) { + archiveClassifier.set('sources') + from sourceSets.main.allSource +} + +task javadocJar(type: Jar, dependsOn: dokkaGenerateModuleHtml) { + archiveClassifier.set('javadoc') + from dokkaGenerateModuleHtml.outputs +} + +dependencies { + compileOnly libs.paparazzi + compileOnly libs.junit4 + + compileOnly(platform(libs.androidx.compose.bom)) + compileOnly libs.androidx.compose.runtime + compileOnly libs.androidx.ui +} + +tasks.withType(KotlinJvmCompile).configureEach { + compilerOptions { + allWarningsAsErrors.set(true) + jvmTarget.set(JvmTarget.JVM_21) + } +} + +afterEvaluate { + apply from: "../../publish.build.gradle" +} diff --git a/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/AccessibilitySnapshot.kt b/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/AccessibilitySnapshot.kt new file mode 100644 index 000000000..ede20a0ed --- /dev/null +++ b/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/AccessibilitySnapshot.kt @@ -0,0 +1,48 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2026 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package dev.testify.paparazzi + +import app.cash.paparazzi.Paparazzi +import app.cash.paparazzi.accessibility.AccessibilityRenderExtension + +/** + * Creates a [Paparazzi] instance with [AccessibilityRenderExtension] pre-configured. + * + * The accessibility render extension overlays accessibility metadata (content descriptions, + * roles, and touch target sizes) on top of the rendered snapshot, making it easy to verify + * that composables are properly annotated for screen readers. + * + * @param device The device configuration to use. Defaults to [TestifyPaparazzi.defaultDevice]. + * @param theme The Android theme to apply. Defaults to [TestifyPaparazzi.defaultTheme]. + * @return A [Paparazzi] instance configured with the accessibility render extension. + */ +fun TestifyPaparazzi.accessibility( + device: DevicePreset = defaultDevice, + theme: String = defaultTheme, +): Paparazzi = Paparazzi( + deviceConfig = device.config, + theme = theme, + renderExtensions = setOf(AccessibilityRenderExtension()), +) diff --git a/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/ComposableSnapshotRule.kt b/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/ComposableSnapshotRule.kt new file mode 100644 index 000000000..92af02b5f --- /dev/null +++ b/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/ComposableSnapshotRule.kt @@ -0,0 +1,135 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2026 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package dev.testify.paparazzi + +import app.cash.paparazzi.Paparazzi +import com.android.ide.common.rendering.api.SessionParams.RenderingMode +import androidx.compose.runtime.Composable +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** + * A high-level JUnit [TestRule] that wraps [Paparazzi] and bundles an optional [ThemeProvider]. + * + * Combines Paparazzi rule lifecycle management, theme wrapping, and multi-variant snapshot + * helpers into a single rule declaration. This is the highest-level API in the library, + * reducing a typical test to: + * + * ```kotlin + * @get:Rule val snapshot = ComposableSnapshotRule(themeProvider = myThemeProvider) + * + * @Test fun myComponent() = snapshot.snapshot { MyComponent() } + * ``` + * + * @param device The [DevicePreset] to render on. Defaults to [DevicePreset.PHONE]. + * @param renderingMode The [RenderingMode] for layout sizing. Defaults to [RenderingMode.SHRINK]. + * @param theme The Android theme to apply. Defaults to [TestifyPaparazzi.defaultTheme]. + * @param themeProvider An optional [ThemeProvider] for wrapping content in the app's Compose theme. + */ +class ComposableSnapshotRule( + device: DevicePreset = DevicePreset.PHONE, + renderingMode: RenderingMode = RenderingMode.SHRINK, + theme: String = TestifyPaparazzi.defaultTheme, + val themeProvider: ThemeProvider? = null, +) : TestRule { + + private val paparazzi = Paparazzi( + deviceConfig = device.config, + theme = theme, + renderingMode = renderingMode, + ) + + override fun apply(base: Statement, description: Description): Statement = + paparazzi.apply(base, description) + + /** + * Takes a snapshot of [content], optionally wrapped in the [themeProvider]. + * + * If a [themeProvider] was supplied at construction, the content is automatically + * wrapped in the theme for the given [variant]. Otherwise, the content is rendered as-is. + * + * @param name An optional name for the snapshot file. + * @param variant The [ThemeVariant] to apply. Defaults to [ThemeVariant.LIGHT]. + * @param content The composable content to snapshot. + */ + fun snapshot( + name: String? = null, + variant: ThemeVariant = ThemeVariant.LIGHT, + content: @Composable () -> Unit, + ) { + if (themeProvider != null) { + paparazzi.themedSnapshot( + themeProvider = themeProvider, + variant = variant, + name = name, + content = content, + ) + } else { + paparazzi.snapshot(name = name, composable = content) + } + } + + /** + * Takes a snapshot of [content] for every [ThemeVariant] (light and dark). + * + * Requires a [themeProvider] to have been set at construction time. + * + * @param name An optional base name prefix for the snapshot files. + * @param content The composable content to snapshot. + * @throws IllegalArgumentException if [themeProvider] is `null`. + */ + fun snapshotAllThemes( + name: String = "", + content: @Composable () -> Unit, + ) { + requireNotNull(themeProvider) { "themeProvider must be set to use snapshotAllThemes" } + paparazzi.snapshotAllThemes( + themeProvider = themeProvider, + name = name, + content = content, + ) + } + + /** + * Takes a snapshot of [content] for each state in [variants]. + * + * If a [themeProvider] was supplied at construction, each snapshot is wrapped in the theme. + * + * @param T The type of the state value. + * @param variants The list of [StateVariant] values to iterate over. + * @param content The composable content to snapshot, parameterized by the state value. + */ + fun snapshotStates( + variants: List>, + content: @Composable (T) -> Unit, + ) { + paparazzi.snapshotStates( + variants = variants, + themeProvider = themeProvider, + content = content, + ) + } +} diff --git a/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/DevicePreset.kt b/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/DevicePreset.kt new file mode 100644 index 000000000..66e10b3b9 --- /dev/null +++ b/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/DevicePreset.kt @@ -0,0 +1,60 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2026 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package dev.testify.paparazzi + +import app.cash.paparazzi.DeviceConfig + +/** + * Curated set of common device configurations for Paparazzi snapshot tests. + * + * Wraps Paparazzi's [DeviceConfig] presets into a semantic enum, eliminating the need + * to reference specific device model constants directly in test code. + * + * @property config The underlying Paparazzi [DeviceConfig] for this preset. + */ +enum class DevicePreset(val config: DeviceConfig) { + /** Standard phone form factor (Pixel 5). */ + PHONE(DeviceConfig.PIXEL_5), + + /** Smaller phone form factor (Nexus 5). */ + PHONE_SMALL(DeviceConfig.NEXUS_5), + + /** Tablet form factor (Pixel C). */ + TABLET(DeviceConfig.PIXEL_C), + + /** Foldable form factor (Pixel Fold). */ + FOLDABLE(DeviceConfig.PIXEL_FOLD); + + companion object { + /** The default device preset used when none is specified. */ + val DEFAULT = PHONE + + /** All phone-sized presets. */ + val ALL_PHONES = listOf(PHONE, PHONE_SMALL) + + /** All available device presets. */ + val ALL = entries + } +} diff --git a/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/FontScalePreset.kt b/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/FontScalePreset.kt new file mode 100644 index 000000000..9efb2d792 --- /dev/null +++ b/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/FontScalePreset.kt @@ -0,0 +1,138 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2026 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package dev.testify.paparazzi + +import app.cash.paparazzi.Paparazzi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density + +/** + * Predefined font scale values for accessibility testing. + * + * Maps to the font size settings available in Android's accessibility options. + * Use with [snapshotWithFontScale] or [snapshotAllFontScales] to verify that UI + * components render correctly at larger text sizes. + * + * @property scale The font scale multiplier (1.0 = default system size). + */ +enum class FontScalePreset(val scale: Float) { + /** Default system font scale (1.0x). */ + DEFAULT(1.0f), + + /** Large font scale (1.3x). */ + LARGE(1.3f), + + /** Extra large font scale (1.5x). */ + EXTRA_LARGE(1.5f), + + /** Largest font scale (2.0x), matching Android's maximum accessibility setting. */ + LARGEST(2.0f); + + companion object { + /** A representative range of scales for accessibility verification: default, large, and largest. */ + val ACCESSIBILITY_RANGE = listOf(DEFAULT, LARGE, LARGEST) + + /** All available font scale presets. */ + val ALL = entries + } +} + +/** + * Takes a snapshot of [content] rendered at the given [fontScale]. + * + * Overrides the [LocalDensity] composition local to apply the specified font scale, + * simulating the user's accessibility font size setting. + * + * @param fontScale The [FontScalePreset] to apply. + * @param themeProvider An optional [ThemeProvider] to wrap the content in a theme. + * @param variant The [ThemeVariant] to apply if a [themeProvider] is given. Defaults to [ThemeVariant.LIGHT]. + * @param name An optional name for the snapshot file. Defaults to `"fontScale_{preset}"`. + * @param content The composable content to snapshot. + */ +fun Paparazzi.snapshotWithFontScale( + fontScale: FontScalePreset, + themeProvider: ThemeProvider? = null, + variant: ThemeVariant = ThemeVariant.LIGHT, + name: String? = null, + content: @Composable () -> Unit, +) { + val snapshotName = name ?: "fontScale_${fontScale.name.lowercase()}" + snapshot(name = snapshotName) { + val wrappedContent: @Composable () -> Unit = { + val currentDensity = LocalDensity.current + CompositionLocalProvider( + LocalDensity provides Density( + density = currentDensity.density, + fontScale = fontScale.scale, + ), + ) { + content() + } + } + if (themeProvider != null) { + themeProvider.Provide(darkTheme = variant == ThemeVariant.DARK) { + wrappedContent() + } + } else { + wrappedContent() + } + } +} + +/** + * Takes a snapshot of [content] at each of the given font [scales]. + * + * Each snapshot is automatically named with a font scale suffix (e.g. `"fontScale_large"`), + * enabling accessibility font-size coverage from a single call. + * + * @param scales The list of [FontScalePreset] values to iterate over. Defaults to [FontScalePreset.ACCESSIBILITY_RANGE]. + * @param themeProvider An optional [ThemeProvider] to wrap the content in a theme. + * @param variant The [ThemeVariant] to apply if a [themeProvider] is given. Defaults to [ThemeVariant.LIGHT]. + * @param name An optional base name prefix for the snapshot files. + * @param content The composable content to snapshot. + */ +fun Paparazzi.snapshotAllFontScales( + scales: List = FontScalePreset.ACCESSIBILITY_RANGE, + themeProvider: ThemeProvider? = null, + variant: ThemeVariant = ThemeVariant.LIGHT, + name: String = "", + content: @Composable () -> Unit, +) { + scales.forEach { fontScale -> + val snapshotName = buildString { + if (name.isNotEmpty()) append(name).append("_") + append("fontScale_${fontScale.name.lowercase()}") + } + snapshotWithFontScale( + fontScale = fontScale, + themeProvider = themeProvider, + variant = variant, + name = snapshotName, + content = content, + ) + } +} diff --git a/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/LocalePreset.kt b/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/LocalePreset.kt new file mode 100644 index 000000000..b4fc080bf --- /dev/null +++ b/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/LocalePreset.kt @@ -0,0 +1,78 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2026 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package dev.testify.paparazzi + +import app.cash.paparazzi.DeviceConfig + +/** + * Predefined locale configurations for internationalization snapshot testing. + * + * Each preset provides a locale qualifier string compatible with Paparazzi's [DeviceConfig] + * and an [isRtl] flag indicating right-to-left layout direction. Includes pseudolocalization + * presets for detecting i18n issues such as string truncation and hardcoded text. + * + * @property qualifiers The locale qualifier string (e.g. `"en"`, `"ar"`) for Paparazzi's [DeviceConfig]. + * @property isRtl `true` if this locale uses right-to-left layout direction. + */ +@Suppress("unused") +enum class LocalePreset(val qualifiers: String, val isRtl: Boolean = false) { + /** English (LTR). */ + ENGLISH("en"), + + /** English pseudolocale for detecting string truncation and hardcoded text. */ + ENGLISH_PSEUDO("en-rXA"), + + /** Bidi pseudolocale for detecting RTL layout issues. */ + BIDI_PSEUDO("ar-rXB"), + + /** Arabic (RTL). */ + ARABIC("ar", isRtl = true), + + /** German (LTR) — useful for testing long string expansion. */ + GERMAN("de"), + + /** Japanese (LTR) — useful for testing CJK character rendering. */ + JAPANESE("ja"); + + companion object { + /** Both pseudolocalization presets for i18n smoke testing. */ + val PSEUDOLOCALES = listOf(ENGLISH_PSEUDO, BIDI_PSEUDO) + + /** All right-to-left locale presets. */ + val RTL_SET = entries.filter { it.isRtl } + + /** A representative set covering LTR, RTL, and CJK scripts. */ + val CORE_SET = listOf(ENGLISH, ARABIC, JAPANESE) + } +} + +/** + * Returns a copy of this preset's [DeviceConfig] with the given [locale] applied. + * + * @param locale The [LocalePreset] to apply to the device configuration. + * @return A new [DeviceConfig] with the locale qualifier set. + */ +fun DevicePreset.withLocale(locale: LocalePreset): DeviceConfig = + config.copy(locale = locale.qualifiers) diff --git a/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/SnapshotExtensions.kt b/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/SnapshotExtensions.kt new file mode 100644 index 000000000..71ae5efa0 --- /dev/null +++ b/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/SnapshotExtensions.kt @@ -0,0 +1,82 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2026 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package dev.testify.paparazzi + +import app.cash.paparazzi.Paparazzi +import androidx.compose.runtime.Composable + +/** + * Takes a snapshot of [content] wrapped in the given [themeProvider]. + * + * This is a convenience extension that automatically applies the app's theme around + * the composable content before capturing. + * + * @param themeProvider The [ThemeProvider] used to wrap the content in a theme. + * @param variant The [ThemeVariant] to apply (light or dark). Defaults to [ThemeVariant.LIGHT]. + * @param name An optional name for the snapshot file. If `null`, Paparazzi uses the test method name. + * @param content The composable content to snapshot. + */ +fun Paparazzi.themedSnapshot( + themeProvider: ThemeProvider, + variant: ThemeVariant = ThemeVariant.LIGHT, + name: String? = null, + content: @Composable () -> Unit, +) { + snapshot(name = name) { + themeProvider.Provide(darkTheme = variant == ThemeVariant.DARK) { + content() + } + } +} + +/** + * Takes a snapshot of [content] for every [ThemeVariant] (light and dark). + * + * Each snapshot is automatically suffixed with the variant name (e.g. `"_light"`, `"_dark"`), + * eliminating the need to manually duplicate test methods for theme coverage. + * + * @param themeProvider The [ThemeProvider] used to wrap the content in a theme. + * @param name An optional base name prefix for the snapshot files. If empty, only the variant + * suffix is used. + * @param content The composable content to snapshot. + */ +fun Paparazzi.snapshotAllThemes( + themeProvider: ThemeProvider, + name: String = "", + content: @Composable () -> Unit, +) { + ThemeVariant.entries.forEach { variant -> + val snapshotName = buildString { + if (name.isNotEmpty()) append(name).append("_") + append(variant.name.lowercase()) + } + themedSnapshot( + themeProvider = themeProvider, + variant = variant, + name = snapshotName, + content = content, + ) + } +} diff --git a/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/StateVariant.kt b/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/StateVariant.kt new file mode 100644 index 000000000..c90a983f2 --- /dev/null +++ b/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/StateVariant.kt @@ -0,0 +1,83 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2026 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package dev.testify.paparazzi + +import app.cash.paparazzi.Paparazzi +import androidx.compose.runtime.Composable + +/** + * A named state value for use with [snapshotStates]. + * + * Pairs a human-readable [name] (used as the snapshot file suffix) with a [state] value + * that is passed to the composable under test. This replaces the pattern of writing + * near-identical test methods that differ only in their input state. + * + * Example: + * ```kotlin + * val ratingVariants = listOf( + * StateVariant("zero_stars", 0), + * StateVariant("three_stars", 3), + * StateVariant("five_stars", 5), + * ) + * ``` + * + * @param T The type of the state value. + * @property name The name used to identify this variant in snapshot file names. + * @property state The state value passed to the composable content. + */ +data class StateVariant(val name: String, val state: T) + +/** + * Takes a snapshot of [content] for each state in [variants]. + * + * Iterates over the provided [StateVariant] list, rendering and capturing a snapshot for + * each one. Each snapshot is named using the variant's [StateVariant.name]. Optionally + * wraps the content in a theme via [themeProvider]. + * + * @param T The type of the state value. + * @param variants The list of [StateVariant] values to iterate over. + * @param themeProvider An optional [ThemeProvider] to wrap the content in a theme. + * @param variant The [ThemeVariant] to apply if a [themeProvider] is given. Defaults to [ThemeVariant.LIGHT]. + * @param content The composable content to snapshot, parameterized by the state value. + */ +fun Paparazzi.snapshotStates( + variants: List>, + themeProvider: ThemeProvider? = null, + variant: ThemeVariant = ThemeVariant.LIGHT, + content: @Composable (T) -> Unit, +) { + variants.forEach { stateVariant -> + snapshot(name = stateVariant.name) { + val composable: @Composable () -> Unit = { content(stateVariant.state) } + if (themeProvider != null) { + themeProvider.Provide(darkTheme = variant == ThemeVariant.DARK) { + composable() + } + } else { + composable() + } + } + } +} diff --git a/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/TestifyPaparazzi.kt b/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/TestifyPaparazzi.kt new file mode 100644 index 000000000..4d2f9e279 --- /dev/null +++ b/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/TestifyPaparazzi.kt @@ -0,0 +1,87 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2026 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package dev.testify.paparazzi + +import app.cash.paparazzi.Paparazzi +import com.android.ide.common.rendering.api.SessionParams.RenderingMode + +/** + * Factory object for creating pre-configured [Paparazzi] instances with sensible defaults. + * + * Replaces the repetitive `Paparazzi(...)` constructor boilerplate commonly duplicated + * across test files. Provides two primary factory methods: [component] for individual + * composable components and [screen] for full-screen layouts. + * + * The [defaultTheme] and [defaultDevice] properties can be configured globally to set + * project-wide defaults. + */ +object TestifyPaparazzi { + /** The default Android theme applied to all Paparazzi instances. */ + var defaultTheme: String = "android:Theme.Material.Light.NoActionBar" + + /** The default device configuration applied to all Paparazzi instances. */ + var defaultDevice: DevicePreset = DevicePreset.PHONE + + /** + * Creates a [Paparazzi] instance configured for rendering individual components. + * + * Uses [RenderingMode.SHRINK] by default, which shrinks the rendered output to fit + * the composable's measured size. + * + * @param device The device configuration to use. + * @param theme The Android theme to apply. + * @param renderingMode The rendering mode for layout sizing. + * @return A configured [Paparazzi] instance. + */ + fun component( + device: DevicePreset = defaultDevice, + theme: String = defaultTheme, + renderingMode: RenderingMode = RenderingMode.SHRINK, + ): Paparazzi = Paparazzi( + deviceConfig = device.config, + theme = theme, + renderingMode = renderingMode, + ) + + /** + * Creates a [Paparazzi] instance configured for rendering full-screen layouts. + * + * Uses [RenderingMode.NORMAL] by default, which renders at the device's full screen size. + * + * @param device The device configuration to use. + * @param theme The Android theme to apply. + * @param renderingMode The rendering mode for layout sizing. + * @return A configured [Paparazzi] instance. + */ + fun screen( + device: DevicePreset = defaultDevice, + theme: String = defaultTheme, + renderingMode: RenderingMode = RenderingMode.NORMAL, + ): Paparazzi = Paparazzi( + deviceConfig = device.config, + theme = theme, + renderingMode = renderingMode, + ) +} diff --git a/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/ThemeVariant.kt b/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/ThemeVariant.kt new file mode 100644 index 000000000..897ba90be --- /dev/null +++ b/Ext/Paparazzi/src/main/kotlin/dev/testify/paparazzi/ThemeVariant.kt @@ -0,0 +1,63 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2026 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package dev.testify.paparazzi + +import androidx.compose.runtime.Composable + +/** + * Represents a light or dark theme variant for snapshot testing. + */ +enum class ThemeVariant { + /** Light theme variant. */ + LIGHT, + + /** Dark theme variant. */ + DARK +} + +/** + * Functional interface for providing a Compose theme wrapper around snapshot content. + * + * Consumers implement this once per project to wrap their app's theme (Material 2, Material 3, + * or custom) around composable content during snapshot tests. + * + * Example: + * ```kotlin + * val myThemeProvider = ThemeProvider { darkTheme, content -> + * MyAppTheme(darkTheme = darkTheme) { content() } + * } + * ``` + */ +fun interface ThemeProvider { + /** + * Wraps [content] in the app's theme. + * + * @param darkTheme `true` to apply the dark theme variant, `false` for light. + * @param content The composable content to render inside the theme. + */ + @Composable + @Suppress("ktlint:standard:function-naming") + fun Provide(darkTheme: Boolean, content: @Composable () -> Unit) +} diff --git a/bitrise.yml b/bitrise.yml index 7181f4003..9468b078c 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -24,6 +24,7 @@ stages: - test_accessibility_ext: {} - test_compose_ext: {} - test_fullscreen_ext: {} + - test_paparazzi_ext: {} workflows: evaluate_build: @@ -84,6 +85,13 @@ workflows: - auth_token: $GITHUB_TOKEN - status_identifier: "Fullscreen Extension" - pipeline_build_url: "$BITRISE_BUILD_URL" + - github-status@3: + run_if: .IsCI + inputs: + - set_specific_status: "pending" + - auth_token: $GITHUB_TOKEN + - status_identifier: "Paparazzi Extension" + - pipeline_build_url: "$BITRISE_BUILD_URL" meta: bitrise.io: stack: ubuntu-noble-24.04-bitrise-2025-android @@ -312,6 +320,31 @@ workflows: stack: ubuntu-noble-24.04-bitrise-2025-android machine_type_id: standard + test_paparazzi_ext: + steps: + - gradle-runner@2: + inputs: + - gradlew_path: "./gradlew" + - gradle_task: PaparazziExt:ktlintCheck + title: KtLint + - gradle-runner@2: + inputs: + - gradlew_path: "./gradlew" + - gradle_task: PaparazziExt:assemble + title: Validate build + - github-status@3: + run_if: .IsCI + inputs: + - auth_token: $GITHUB_TOKEN + - status_identifier: "Paparazzi Extension" + - pipeline_build_url: "$BITRISE_BUILD_URL" + before_run: + - _globalSetup + meta: + bitrise.io: + stack: ubuntu-noble-24.04-bitrise-2025-android + machine_type_id: standard + test_flix: summary: Run your Android unit tests and get the test report. description: The workflow will first clone your Git repository, cache your Gradle diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b40c7e2a3..bf21e8b1e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,8 @@ truth = "1.4.4" uiTestJunit4 = "1.9.0" uiautomator = "2.3.0" composeBom = "2025.08.01" +junit4 = "4.13.2" +paparazzi = "2.0.0-alpha04" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } @@ -55,6 +57,7 @@ androidx-ui = { module = "androidx.compose.ui:ui", version.ref = "uiTestJunit4" androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "uiTestJunit4" } androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "uiTestJunit4" } androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } colormath = { module = "com.github.ajalt:colormath", version.ref = "colormath" } core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtxVersion" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } @@ -65,6 +68,8 @@ material = { module = "com.google.android.material:material", version.ref = "mat mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockkAndroid" } slf4j-jdk14 = { module = "org.slf4j:slf4j-jdk14", version.ref = "slf4jJdk14" } +junit4 = { module = "junit:junit", version.ref = "junit4" } +paparazzi = { module = "app.cash.paparazzi:paparazzi", version.ref = "paparazzi" } truth = { module = "com.google.truth:truth", version.ref = "truth" } [plugins] diff --git a/settings.gradle b/settings.gradle index 070a66306..82dac2629 100644 --- a/settings.gradle +++ b/settings.gradle @@ -21,6 +21,7 @@ include ':LegacySample' include ':FlixSample' include ':FlixLibrary' include ':GmdSample' +include ':PaparazziExt' includeBuild("./Plugins/Gradle") { name = "Plugin" } include ':Library' include ':ComposeExtensions' @@ -34,3 +35,4 @@ project(':LegacySample').projectDir = new File("./Samples/Legacy") project(':FlixSample').projectDir = new File("./Samples/Flix") project(':FlixLibrary').projectDir = new File("./Samples/Flix/FlixLibrary") project(':GmdSample').projectDir = new File("./Samples/Gmd") +project(':PaparazziExt').projectDir = new File("./Ext/Paparazzi")