Skip to content

Commit 95366e6

Browse files
authored
Add Paparazzi snapshot testing pipeline (#36)
* Add Paparazzi snapshot testing pipeline Apply the Paparazzi plugin (2.0.0-alpha02, the line that supports this repo's Kotlin 2.2 / AGP 8.13 toolchain) and add snapshot tests for the AI compose components. PaparazziTest is the shared base: it renders inside MaterialTheme and Surface (the module has no ChatTheme), enables LocalInspectionMode, and provides a no-op ActivityResultRegistryOwner so the composer's photo-picker launcher renders without a host activity. Tests cover AITypingIndicator, ChatComposer, and SpeechToTextButton, producing 9 golden images. Preview content for each state is extracted into shared internal composables that both the @Preview functions and the snapshot tests call, so they cannot drift apart. Snapshot verification runs on CI through the unit test job, which now runs verifyPaparazziDebug (a superset that also runs the regular unit tests). * Add StreamingText snapshot and render full text on first non-animated frame StreamingText revealed its text through a LaunchedEffect, so the first composition showed empty text before the effect ran. When animate is false, initialize the displayed text to the full text so it appears immediately, matching the documented behavior and letting it render in a single Paparazzi frame. Add the StreamingText snapshot test and its shared preview content function. * Verify snapshots through testCoverage instead of verifyPaparazziDebug Align with the other Stream repos: the convention plugin registers a testCoverage task that runs verifyPaparazziDebug for modules with the Paparazzi plugin. Opt the module into coverage.includedModules and apply the Paparazzi plugin first so the convention detects it, then run testCoverage on CI.
1 parent 944293b commit 95366e6

23 files changed

Lines changed: 378 additions & 42 deletions

.github/workflows/ci.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,14 @@ jobs:
6464

6565
- uses: GetStream/android-ci-actions/actions/gradle-cache@main
6666

67-
- name: Run unit tests
68-
run: ./gradlew testDebugUnitTest --stacktrace
67+
- name: Run unit tests and verify snapshots
68+
run: ./gradlew testCoverage --stacktrace
6969

7070
- name: Unit tests results
7171
uses: actions/upload-artifact@v7
7272
if: failure()
7373
with:
7474
name: unit-tests-results
75-
path: ./**/build/reports/tests/**
75+
path: |
76+
./**/build/reports/tests/**
77+
./**/build/paparazzi/**

build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ plugins {
1111

1212
streamProject {
1313
repositoryName.set("stream-chat-android-ai")
14+
coverage {
15+
includedModules.set(setOf("stream-chat-android-ai-compose"))
16+
}
1417
publishing {
1518
description.set("Official AI components for Stream Android Chat SDK")
1619
}

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ streamChatAndroid = "7.0.0"
2020
retrofit = "2.11.0"
2121
okhttp = "4.12.0"
2222
moshi = "1.15.1"
23+
paparazzi = "2.0.0-alpha02"
2324

2425
[libraries]
2526
detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-formatting", version.ref = "detekt" }
@@ -60,6 +61,7 @@ arturbosch-detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt"
6061
stream-project = { id = "io.getstream.project", version.ref = "streamConventions" }
6162
stream-android-library = { id = "io.getstream.android.library", version.ref = "streamConventions" }
6263
stream-android-application = { id = "io.getstream.android.application", version.ref = "streamConventions" }
64+
paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" }
6365

6466
[bundles]
6567
stream-chat = [

stream-chat-android-ai-compose/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
22

33
plugins {
4+
alias(libs.plugins.paparazzi)
45
alias(libs.plugins.stream.android.library)
56
alias(libs.plugins.kotlin.android)
67
alias(libs.plugins.kotlin.compose)

stream-chat-android-ai-compose/src/main/kotlin/io/getstream/chat/android/ai/compose/ui/component/AITypingIndicator.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,13 @@ private const val ANIMATION_DOUBLE = 2f
165165
private const val SMOOTHSTEP_FACTOR_1 = 3f
166166
private const val SMOOTHSTEP_FACTOR_2 = 2f
167167

168+
@Composable
169+
internal fun AITypingIndicatorWithLabel() {
170+
AITypingIndicator(label = { Text(text = "Thinking") })
171+
}
172+
168173
@Preview(showBackground = true)
169174
@Composable
170-
private fun AITypingIndicatorPreview() {
171-
AITypingIndicator(label = { Text("Thinking") })
175+
private fun AITypingIndicatorWithLabelPreview() {
176+
AITypingIndicatorWithLabel()
172177
}

stream-chat-android-ai-compose/src/main/kotlin/io/getstream/chat/android/ai/compose/ui/component/ChatComposer.kt

Lines changed: 56 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -441,68 +441,93 @@ private fun Context.openSettings() {
441441
startActivity(intent)
442442
}
443443

444+
@Composable
445+
internal fun ChatComposerEmpty() {
446+
ChatComposer(
447+
onSendClick = {},
448+
onStopClick = {},
449+
isGenerating = false,
450+
)
451+
}
452+
453+
@Composable
454+
internal fun ChatComposerFilled() {
455+
ChatComposer(
456+
messageData = MessageData(text = "What is Stream Chat?"),
457+
onSendClick = {},
458+
onStopClick = {},
459+
isGenerating = false,
460+
)
461+
}
462+
463+
@Composable
464+
internal fun ChatComposerLongFilled() {
465+
ChatComposer(
466+
messageData = MessageData(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit."),
467+
onSendClick = {},
468+
onStopClick = {},
469+
isGenerating = false,
470+
)
471+
}
472+
473+
@Composable
474+
internal fun ChatComposerWithAttachments() {
475+
ChatComposer(
476+
messageData = MessageData(
477+
text = "What is Stream Chat?",
478+
attachments = setOf("1".toUri(), "2".toUri(), "3".toUri()),
479+
),
480+
onSendClick = {},
481+
onStopClick = {},
482+
isGenerating = false,
483+
)
484+
}
485+
486+
@Composable
487+
internal fun ChatComposerGenerating() {
488+
ChatComposer(
489+
onSendClick = {},
490+
onStopClick = {},
491+
isGenerating = true,
492+
)
493+
}
494+
444495
@Preview(showBackground = true)
445496
@Composable
446497
private fun ChatComposerEmptyPreview() {
447498
MaterialTheme {
448-
ChatComposer(
449-
onSendClick = {},
450-
onStopClick = {},
451-
isGenerating = false,
452-
)
499+
ChatComposerEmpty()
453500
}
454501
}
455502

456503
@Preview(showBackground = true)
457504
@Composable
458505
private fun ChatComposerFilledPreview() {
459506
MaterialTheme {
460-
ChatComposer(
461-
messageData = MessageData(text = "What is Stream Chat?"),
462-
onSendClick = {},
463-
onStopClick = {},
464-
isGenerating = false,
465-
)
507+
ChatComposerFilled()
466508
}
467509
}
468510

469511
@Preview(showBackground = true)
470512
@Composable
471513
private fun ChatComposerLongFilledPreview() {
472514
MaterialTheme {
473-
ChatComposer(
474-
messageData = MessageData(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit."),
475-
onSendClick = {},
476-
onStopClick = {},
477-
isGenerating = false,
478-
)
515+
ChatComposerLongFilled()
479516
}
480517
}
481518

482519
@Preview(showBackground = true)
483520
@Composable
484521
private fun ChatComposerWithAttachmentsPreview() {
485522
MaterialTheme {
486-
ChatComposer(
487-
messageData = MessageData(
488-
text = "What is Stream Chat?",
489-
attachments = setOf("1".toUri(), "2".toUri(), "3".toUri()),
490-
),
491-
onSendClick = {},
492-
onStopClick = {},
493-
isGenerating = false,
494-
)
523+
ChatComposerWithAttachments()
495524
}
496525
}
497526

498527
@Preview(showBackground = true)
499528
@Composable
500529
private fun ChatComposerGeneratingPreview() {
501530
MaterialTheme {
502-
ChatComposer(
503-
onSendClick = {},
504-
onStopClick = {},
505-
isGenerating = true,
506-
)
531+
ChatComposerGenerating()
507532
}
508533
}

stream-chat-android-ai-compose/src/main/kotlin/io/getstream/chat/android/ai/compose/ui/component/SpeechToTextButton.kt

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -280,16 +280,14 @@ private fun VoiceRecordingBars(
280280
}
281281
}
282282

283-
@Preview(showBackground = true)
284283
@Composable
285-
private fun SpeechToTextButtonIdlePreview() {
284+
internal fun SpeechToTextButtonIdle() {
286285
val state = rememberSpeechToTextButtonState { }
287286
SpeechToTextButton(state = state)
288287
}
289288

290-
@Preview(showBackground = true)
291289
@Composable
292-
private fun SpeechToTextButtonRecordingPreview() {
290+
internal fun SpeechToTextButtonRecording() {
293291
val state = remember {
294292
SpeechToTextButtonState(
295293
helper = object : SpeechRecognizerHelper {
@@ -300,3 +298,15 @@ private fun SpeechToTextButtonRecordingPreview() {
300298
}
301299
SpeechToTextButton(state = state)
302300
}
301+
302+
@Preview(showBackground = true)
303+
@Composable
304+
private fun SpeechToTextButtonIdlePreview() {
305+
SpeechToTextButtonIdle()
306+
}
307+
308+
@Preview(showBackground = true)
309+
@Composable
310+
private fun SpeechToTextButtonRecordingPreview() {
311+
SpeechToTextButtonRecording()
312+
}

stream-chat-android-ai-compose/src/main/kotlin/io/getstream/chat/android/ai/compose/ui/component/StreamingText.kt

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import androidx.compose.runtime.getValue
2222
import androidx.compose.runtime.mutableStateOf
2323
import androidx.compose.runtime.remember
2424
import androidx.compose.runtime.setValue
25+
import androidx.compose.ui.tooling.preview.Preview
2526
import io.getstream.chat.android.ai.compose.ui.component.internal.RichText
2627
import kotlinx.coroutines.delay
2728

@@ -52,8 +53,9 @@ public fun StreamingText(
5253
RichText(text = displayedText)
5354
},
5455
) {
55-
// Track the displayed text for animation
56-
var displayedText by remember { mutableStateOf("") }
56+
// Track the displayed text for animation. When not animating, start with the full text so
57+
// the first frame shows it immediately instead of flashing empty before the effect runs.
58+
var displayedText by remember { mutableStateOf(if (animate) "" else text) }
5759
// Track the previous full text to detect new text vs continuation
5860
var previousText by remember { mutableStateOf("") }
5961
// Track previous animate value to detect transition from true to false
@@ -181,3 +183,17 @@ private fun splitIntoWords(text: String): List<String> {
181183

182184
// Regex to match whitespace or non-whitespace sequences for chunking.
183185
private val WordSplitRegex = Regex("""(\s+|\S+)""")
186+
187+
@Composable
188+
internal fun StreamingTextRendered() {
189+
StreamingText(
190+
text = "Hello, I am the Stream AI assistant. How can I help you today?",
191+
animate = false,
192+
)
193+
}
194+
195+
@Preview(showBackground = true)
196+
@Composable
197+
private fun StreamingTextPreview() {
198+
StreamingTextRendered()
199+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-chat-android-ai/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.chat.android.ai.compose.ui
18+
19+
import androidx.activity.compose.LocalActivityResultRegistryOwner
20+
import androidx.activity.result.ActivityResultRegistry
21+
import androidx.activity.result.ActivityResultRegistryOwner
22+
import androidx.activity.result.contract.ActivityResultContract
23+
import androidx.compose.material3.MaterialTheme
24+
import androidx.compose.material3.Surface
25+
import androidx.compose.runtime.Composable
26+
import androidx.compose.runtime.CompositionLocalProvider
27+
import androidx.compose.ui.platform.LocalInspectionMode
28+
import androidx.core.app.ActivityOptionsCompat
29+
import app.cash.paparazzi.Paparazzi
30+
31+
/**
32+
* Base for Paparazzi snapshot tests of the AI compose components.
33+
*
34+
* Implementers expose a [Paparazzi] rule and call [snapshot] to render a component inside the
35+
* shared test environment: [MaterialTheme] (the module has no `ChatTheme`), with
36+
* [LocalInspectionMode] enabled and a no-op [ActivityResultRegistryOwner] so components that use
37+
* `rememberLauncherForActivityResult` (such as the composer) render without a host activity.
38+
*/
39+
internal interface PaparazziTest {
40+
41+
val paparazzi: Paparazzi
42+
43+
fun snapshot(name: String? = null, composable: @Composable () -> Unit) {
44+
paparazzi.snapshot(name) {
45+
CompositionLocalProvider(
46+
LocalInspectionMode provides true,
47+
LocalActivityResultRegistryOwner provides NoOpResultRegistryOwner,
48+
) {
49+
MaterialTheme {
50+
Surface {
51+
composable()
52+
}
53+
}
54+
}
55+
}
56+
}
57+
}
58+
59+
private val NoOpResultRegistryOwner = object : ActivityResultRegistryOwner {
60+
override val activityResultRegistry = object : ActivityResultRegistry() {
61+
override fun <I, O> onLaunch(
62+
requestCode: Int,
63+
contract: ActivityResultContract<I, O>,
64+
input: I,
65+
options: ActivityOptionsCompat?,
66+
) {
67+
// No-op: snapshots never launch activities.
68+
}
69+
}
70+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-chat-android-ai/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.chat.android.ai.compose.ui.component
18+
19+
import app.cash.paparazzi.DeviceConfig
20+
import app.cash.paparazzi.Paparazzi
21+
import com.android.ide.common.rendering.api.SessionParams.RenderingMode
22+
import io.getstream.chat.android.ai.compose.ui.PaparazziTest
23+
import org.junit.Rule
24+
import org.junit.Test
25+
26+
internal class AITypingIndicatorTest : PaparazziTest {
27+
28+
@get:Rule
29+
override val paparazzi = Paparazzi(
30+
deviceConfig = DeviceConfig.PIXEL_5,
31+
renderingMode = RenderingMode.SHRINK,
32+
)
33+
34+
@Test
35+
fun default() {
36+
snapshot {
37+
AITypingIndicator()
38+
}
39+
}
40+
41+
@Test
42+
fun `with label`() {
43+
snapshot {
44+
AITypingIndicatorWithLabel()
45+
}
46+
}
47+
}

0 commit comments

Comments
 (0)