@mmerterden/multi-agent-pipeline 20.1.0 → 20.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/docs/facts.json +6 -6
  3. package/manifest.json +55 -34
  4. package/package.json +1 -1
  5. package/pipeline/multi-agent-refs/features/usage-reporting.md +7 -3
  6. package/pipeline/schemas/prefs.schema.json +4 -0
  7. package/pipeline/scripts/usage-register.mjs +2 -0
  8. package/pipeline/skills/.skill-manifest.json +36 -20
  9. package/pipeline/skills/.skills-index.json +75 -9
  10. package/pipeline/skills/shared/README.md +13 -7
  11. package/pipeline/skills/shared/external/android-architecture/SKILL.md +71 -0
  12. package/pipeline/skills/shared/external/android-architecture/references/patterns.md +142 -0
  13. package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +314 -0
  14. package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +432 -0
  15. package/pipeline/skills/shared/external/android-datastore/SKILL.md +236 -0
  16. package/pipeline/skills/shared/external/android-datastore/references/patterns.md +297 -0
  17. package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +249 -0
  18. package/pipeline/skills/shared/external/android-design-tokens-codegen/references/patterns.md +270 -0
  19. package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +62 -0
  20. package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +255 -0
  21. package/pipeline/skills/shared/external/android-mvi-viewmodel/references/patterns.md +257 -0
  22. package/pipeline/skills/shared/external/android-performance/SKILL.md +86 -602
  23. package/pipeline/skills/shared/external/android-performance/references/patterns.md +659 -0
  24. package/pipeline/skills/shared/external/android-security/SKILL.md +117 -430
  25. package/pipeline/skills/shared/external/android-security/references/patterns.md +690 -0
  26. package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md +1 -1
  27. package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +35 -733
  28. package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +299 -0
  29. package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +255 -0
  30. package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +167 -0
  31. package/pipeline/skills/shared/external/app-intents/SKILL.md +39 -174
  32. package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +178 -0
  33. package/pipeline/skills/shared/external/compose-components/SKILL.md +48 -0
  34. package/pipeline/skills/shared/external/compose-components/references/patterns.md +200 -0
  35. package/pipeline/skills/shared/external/compose-navigation/SKILL.md +66 -3
  36. package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +191 -0
  37. package/pipeline/skills/shared/external/compose-testing/SKILL.md +107 -397
  38. package/pipeline/skills/shared/external/compose-testing/references/patterns.md +631 -0
  39. package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +121 -449
  40. package/pipeline/skills/shared/external/gradle-kotlin-dsl/references/patterns.md +715 -0
  41. package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +143 -0
  42. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +27 -102
  43. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +42 -0
  44. package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +94 -383
  45. package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +640 -0
  46. package/pipeline/skills/shared/external/room-database/SKILL.md +101 -440
  47. package/pipeline/skills/shared/external/room-database/references/patterns.md +614 -0
  48. package/pipeline/skills/shared/external/storekit/SKILL.md +69 -343
  49. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +371 -0
  50. package/pipeline/skills/shared/external/widgetkit/SKILL.md +25 -101
  51. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +107 -0
  52. package/pipeline/skills/skills-index.md +8 -2
@@ -0,0 +1,631 @@
1
+ # Compose Testing Patterns
2
+
3
+ Full code examples for the Compose testing guide. Load this when you need a
4
+ complete, copyable example for a specific area: rule setup, node selection,
5
+ actions, assertions, state changes, ViewModel/Turbine tests, coroutine test
6
+ dispatchers, or screenshot tests. The top-level SKILL.md carries the guidance
7
+ and short snippets; this file carries the long examples.
8
+
9
+ ## Contents
10
+
11
+ - [ComposeTestRule Setup](#composetestrule-setup)
12
+ - [Finding Nodes](#finding-nodes)
13
+ - [Test Tags from a Generated Registry](#test-tags-from-a-generated-registry)
14
+ - [Performing Actions](#performing-actions)
15
+ - [Assertions](#assertions)
16
+ - [Testing State Changes](#testing-state-changes)
17
+ - [ViewModel Test Base Class](#viewmodel-test-base-class)
18
+ - [ViewModel Testing with Turbine](#viewmodel-testing-with-turbine)
19
+ - [TestDispatcher and runTest](#testdispatcher-and-runtest)
20
+ - [Screenshot Testing](#screenshot-testing)
21
+ - [Compose Preview Screenshot Testing](#compose-preview-screenshot-testing)
22
+
23
+ ## ComposeTestRule Setup
24
+
25
+ ### Unit Tests (No Activity)
26
+
27
+ ```kotlin
28
+ class ButtonComponentTest {
29
+
30
+ @get:Rule
31
+ val composeTestRule = createComposeRule()
32
+
33
+ @Test
34
+ fun primaryButton_displaysLabel() {
35
+ composeTestRule.setContent {
36
+ AppTheme {
37
+ PrimaryButton(label = "Book Flight", onClick = {})
38
+ }
39
+ }
40
+
41
+ composeTestRule
42
+ .onNodeWithText("Book Flight")
43
+ .assertIsDisplayed()
44
+ }
45
+ }
46
+ ```
47
+
48
+ ### Instrumented Tests (With Activity)
49
+
50
+ ```kotlin
51
+ @HiltAndroidTest
52
+ class HomeScreenTest {
53
+
54
+ @get:Rule(order = 0)
55
+ val hiltRule = HiltAndroidRule(this)
56
+
57
+ @get:Rule(order = 1)
58
+ val composeTestRule = createAndroidComposeRule<MainActivity>()
59
+
60
+ @Before
61
+ fun setup() {
62
+ hiltRule.inject()
63
+ }
64
+
65
+ @Test
66
+ fun homeScreen_showsTitle() {
67
+ composeTestRule
68
+ .onNodeWithText("Home")
69
+ .assertIsDisplayed()
70
+ }
71
+ }
72
+ ```
73
+
74
+ ## Finding Nodes
75
+
76
+ ### Semantic Matchers
77
+
78
+ ```kotlin
79
+ // By text content
80
+ composeTestRule.onNodeWithText("Book Flight")
81
+ composeTestRule.onNodeWithText("book flight", ignoreCase = true)
82
+ composeTestRule.onNodeWithText("Flight", substring = true)
83
+
84
+ // By test tag (set via Modifier.testTag("tag"))
85
+ composeTestRule.onNodeWithTag("flight_list")
86
+
87
+ // By content description (accessibility)
88
+ composeTestRule.onNodeWithContentDescription("Search flights")
89
+
90
+ // Multiple nodes
91
+ composeTestRule.onAllNodesWithText("Select")
92
+ composeTestRule.onAllNodesWithTag("flight_card")
93
+
94
+ // Combined matchers
95
+ composeTestRule.onNode(
96
+ hasText("Book") and hasClickAction()
97
+ )
98
+
99
+ // Parent/child traversal
100
+ composeTestRule
101
+ .onNodeWithTag("flight_card")
102
+ .onChildren()
103
+ .filterToOne(hasText("IST"))
104
+ ```
105
+
106
+ ### Setting Test Tags
107
+
108
+ ```kotlin
109
+ @Composable
110
+ fun FlightCard(flight: Flight, modifier: Modifier = Modifier) {
111
+ Card(
112
+ modifier = modifier.testTag("flight_card_${flight.id}"),
113
+ ) {
114
+ Text(
115
+ text = flight.origin,
116
+ modifier = Modifier.testTag("flight_origin"),
117
+ )
118
+ }
119
+ }
120
+ ```
121
+
122
+ ## Test Tags from a Generated Registry
123
+
124
+ String-literal tags drift: a rename in production silently orphans every test
125
+ that still passes the old string, and nothing fails to compile. Drive both the
126
+ `Modifier.testTag` in production and the `onNodeWithTag` in the test from one
127
+ generated constant. The registry is generated from the shared identifier source;
128
+ the codegen side lives in the `android-design-tokens-codegen` skill.
129
+
130
+ ```kotlin
131
+ // Generated -- do not edit by hand
132
+ object TestTags {
133
+ const val FlightCard = "flight_card"
134
+ const val FlightOrigin = "flight_origin"
135
+ const val SearchField = "search_field"
136
+ }
137
+ ```
138
+
139
+ ```kotlin
140
+ // Production
141
+ Card(modifier = Modifier.testTag(TestTags.FlightCard)) { /* ... */ }
142
+
143
+ // Test -- same constant, so a rename is a compile error, not a silent miss
144
+ composeTestRule.onNodeWithTag(TestTags.FlightCard).assertIsDisplayed()
145
+ composeTestRule.onNodeWithTag(TestTags.SearchField).performTextInput("Istanbul")
146
+ ```
147
+
148
+ ## Performing Actions
149
+
150
+ ```kotlin
151
+ // Click
152
+ composeTestRule.onNodeWithText("Book").performClick()
153
+
154
+ // Text input
155
+ composeTestRule.onNodeWithTag("search_field").performTextInput("Istanbul")
156
+
157
+ // Clear and replace text
158
+ composeTestRule.onNodeWithTag("search_field").performTextClearance()
159
+ composeTestRule.onNodeWithTag("search_field").performTextReplacement("Ankara")
160
+
161
+ // Scroll to node (in scrollable container)
162
+ composeTestRule.onNodeWithText("Last Item").performScrollTo()
163
+
164
+ // Scroll to index in LazyColumn
165
+ composeTestRule.onNodeWithTag("flight_list").performScrollToIndex(15)
166
+
167
+ // Scroll to key in LazyColumn
168
+ composeTestRule.onNodeWithTag("flight_list").performScrollToKey("flight-42")
169
+
170
+ // Swipe gestures
171
+ composeTestRule.onNodeWithTag("card").performTouchInput {
172
+ swipeLeft()
173
+ swipeRight()
174
+ swipeUp()
175
+ swipeDown()
176
+ }
177
+
178
+ // Long click
179
+ composeTestRule.onNodeWithTag("item").performTouchInput {
180
+ longClick()
181
+ }
182
+ ```
183
+
184
+ ## Assertions
185
+
186
+ ```kotlin
187
+ // Existence and visibility
188
+ composeTestRule.onNodeWithText("Title").assertIsDisplayed()
189
+ composeTestRule.onNodeWithText("Title").assertExists()
190
+ composeTestRule.onNodeWithText("Hidden").assertDoesNotExist()
191
+ composeTestRule.onNodeWithText("Hidden").assertIsNotDisplayed()
192
+
193
+ // Text content
194
+ composeTestRule.onNodeWithTag("price").assertTextEquals("$299")
195
+ composeTestRule.onNodeWithTag("price").assertTextContains("299")
196
+
197
+ // Enabled/disabled state
198
+ composeTestRule.onNodeWithText("Submit").assertIsEnabled()
199
+ composeTestRule.onNodeWithText("Submit").assertIsNotEnabled()
200
+
201
+ // Selection state
202
+ composeTestRule.onNodeWithText("Economy").assertIsSelected()
203
+ composeTestRule.onNodeWithText("Business").assertIsNotSelected()
204
+
205
+ // Toggle state
206
+ composeTestRule.onNodeWithTag("wifi_toggle").assertIsOn()
207
+ composeTestRule.onNodeWithTag("wifi_toggle").assertIsOff()
208
+
209
+ // Count assertions
210
+ composeTestRule.onAllNodesWithTag("flight_card").assertCountEquals(5)
211
+
212
+ // Focused state
213
+ composeTestRule.onNodeWithTag("search_field").assertIsFocused()
214
+ ```
215
+
216
+ ## Testing State Changes
217
+
218
+ ```kotlin
219
+ @Test
220
+ fun counter_incrementsOnClick() {
221
+ composeTestRule.setContent {
222
+ var count by remember { mutableIntStateOf(0) }
223
+ Column {
224
+ Text(text = "Count: $count", modifier = Modifier.testTag("count"))
225
+ Button(onClick = { count++ }) {
226
+ Text("Increment")
227
+ }
228
+ }
229
+ }
230
+
231
+ composeTestRule.onNodeWithTag("count").assertTextEquals("Count: 0")
232
+ composeTestRule.onNodeWithText("Increment").performClick()
233
+ composeTestRule.onNodeWithTag("count").assertTextEquals("Count: 1")
234
+ }
235
+
236
+ @Test
237
+ fun searchField_filtersResults() {
238
+ composeTestRule.setContent {
239
+ AppTheme {
240
+ SearchScreen(viewModel = fakeViewModel)
241
+ }
242
+ }
243
+
244
+ composeTestRule.onNodeWithTag("search_input").performTextInput("Istanbul")
245
+
246
+ // Wait for async results
247
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
248
+ composeTestRule
249
+ .onAllNodesWithTag("search_result")
250
+ .fetchSemanticsNodes()
251
+ .isNotEmpty()
252
+ }
253
+
254
+ composeTestRule
255
+ .onAllNodesWithTag("search_result")
256
+ .assertCountEquals(3)
257
+ }
258
+ ```
259
+
260
+ ## ViewModel Test Base Class
261
+
262
+ Centralize the coroutine main-dispatcher swap, teardown, and route/state
263
+ injection so every ViewModel test starts from the same clean setup. Use
264
+ `UnconfinedTestDispatcher()` as the main dispatcher (eager execution, no manual
265
+ `advanceUntilIdle()` for simple flows), reset it in teardown, and `unmockkAll()`
266
+ so MockK state never leaks between tests.
267
+
268
+ ```kotlin
269
+ @OptIn(ExperimentalCoroutinesApi::class)
270
+ abstract class ViewModelTest {
271
+
272
+ @Before
273
+ fun setUpMainDispatcher() {
274
+ Dispatchers.setMain(UnconfinedTestDispatcher())
275
+ }
276
+
277
+ @After
278
+ fun tearDownMainDispatcher() {
279
+ Dispatchers.resetMain()
280
+ unmockkAll()
281
+ }
282
+
283
+ protected fun savedStateHandleOf(vararg args: Pair<String, Any?>): SavedStateHandle =
284
+ SavedStateHandle(args.toMap())
285
+ }
286
+ ```
287
+
288
+ For a type-safe `@Serializable` route, back the handle with the
289
+ `navigation-testing` artifact, whose `SavedStateHandle` builder decodes a route
290
+ object; the ViewModel then reads it via `savedStateHandle.toRoute<Route>()`.
291
+
292
+ ```kotlin
293
+ class DetailViewModelTest : ViewModelTest() {
294
+
295
+ @Test
296
+ fun `loads item named in the route`() = runTest {
297
+ val viewModel = DetailViewModel(
298
+ savedStateHandle = savedStateHandleOf("itemId" to "42"),
299
+ repository = FakeRepository(),
300
+ )
301
+ assertThat(viewModel.itemId).isEqualTo("42")
302
+ }
303
+ }
304
+ ```
305
+
306
+ ### MockK inline mocking on modern JDKs
307
+
308
+ MockK's inline mocking (`mockkObject`, `mockkStatic`, mocking `final` classes)
309
+ loads a Byte Buddy agent at runtime. On JDK 21+ dynamic agent attachment is
310
+ restricted and self-attach fails with a MockK/Byte Buddy initialization error --
311
+ a common, hard-to-diagnose test failure. Attach the agent explicitly via
312
+ `-javaagent` instead of relying on self-attach.
313
+
314
+ ```kotlin
315
+ // build.gradle.kts
316
+ val mockkAgent: Configuration by configurations.creating
317
+
318
+ dependencies {
319
+ testImplementation(libs.mockk)
320
+ mockkAgent(libs.bytebuddy.agent)
321
+ }
322
+
323
+ tasks.withType<Test>().configureEach {
324
+ val agentJar = mockkAgent.singleFile
325
+ jvmArgs("-javaagent:${agentJar.absolutePath}")
326
+ }
327
+ ```
328
+
329
+ ## ViewModel Testing with Turbine
330
+
331
+ Turbine provides `test {}` extension on Flow for asserting emissions.
332
+
333
+ ```kotlin
334
+ // build.gradle.kts
335
+ // testImplementation(libs.turbine)
336
+
337
+ class HomeViewModelTest {
338
+
339
+ private val fakeRepository = FakeFlightRepository()
340
+ private lateinit var viewModel: HomeViewModel
341
+
342
+ @Before
343
+ fun setup() {
344
+ viewModel = HomeViewModel(
345
+ getFlightsUseCase = GetFlightsUseCase(fakeRepository),
346
+ )
347
+ }
348
+
349
+ @Test
350
+ fun `loadFlights emits loading then success`() = runTest {
351
+ fakeRepository.setFlights(listOf(testFlight))
352
+
353
+ viewModel.uiState.test {
354
+ // Initial state
355
+ assertThat(awaitItem()).isEqualTo(HomeUiState.Loading)
356
+
357
+ // Trigger load
358
+ viewModel.loadFlights("IST", "JFK")
359
+
360
+ // Success state
361
+ val success = awaitItem()
362
+ assertThat(success).isInstanceOf(HomeUiState.Success::class.java)
363
+ assertThat((success as HomeUiState.Success).flights).hasSize(1)
364
+
365
+ cancelAndIgnoreRemainingEvents()
366
+ }
367
+ }
368
+
369
+ @Test
370
+ fun `loadFlights emits error on failure`() = runTest {
371
+ fakeRepository.setShouldFail(true)
372
+
373
+ viewModel.uiState.test {
374
+ skipItems(1) // skip Loading
375
+
376
+ viewModel.loadFlights("IST", "JFK")
377
+
378
+ val error = awaitItem()
379
+ assertThat(error).isInstanceOf(HomeUiState.Error::class.java)
380
+
381
+ cancelAndIgnoreRemainingEvents()
382
+ }
383
+ }
384
+ }
385
+ ```
386
+
387
+ ### Fake Repository
388
+
389
+ ```kotlin
390
+ class FakeFlightRepository : FlightRepository {
391
+ private val flights = MutableStateFlow<List<Flight>>(emptyList())
392
+ private var shouldFail = false
393
+
394
+ fun setFlights(list: List<Flight>) { flights.value = list }
395
+ fun setShouldFail(fail: Boolean) { shouldFail = fail }
396
+
397
+ override fun getFlights(origin: String, destination: String): Flow<List<Flight>> =
398
+ if (shouldFail) flow { throw IOException("Network error") }
399
+ else flights
400
+
401
+ override fun searchFlights(query: String, date: LocalDate): Flow<List<Flight>> =
402
+ flights.map { it.filter { f -> f.origin.code == query } }
403
+
404
+ override suspend fun bookFlight(flightId: String): Result<Booking> =
405
+ if (shouldFail) Result.failure(IOException("Booking failed"))
406
+ else Result.success(Booking(id = "B1", confirmationCode = "CONF", status = BookingStatus.CONFIRMED))
407
+ }
408
+ ```
409
+
410
+ ## TestDispatcher and runTest
411
+
412
+ Use `runTest` with `StandardTestDispatcher` or `UnconfinedTestDispatcher` to
413
+ control coroutine execution in tests.
414
+
415
+ ```kotlin
416
+ class FlightRepositoryTest {
417
+
418
+ private val testDispatcher = StandardTestDispatcher()
419
+
420
+ @Before
421
+ fun setup() {
422
+ Dispatchers.setMain(testDispatcher)
423
+ }
424
+
425
+ @After
426
+ fun tearDown() {
427
+ Dispatchers.resetMain()
428
+ }
429
+
430
+ @Test
431
+ fun `repository returns cached data`() = runTest {
432
+ val repo = FlightRepositoryImpl(
433
+ remoteDataSource = fakeRemote,
434
+ localDataSource = fakeLocal,
435
+ flightMapper = FlightMapper(),
436
+ )
437
+
438
+ val result = repo.getFlights("IST", "JFK").first()
439
+ assertThat(result).hasSize(2)
440
+ }
441
+ }
442
+ ```
443
+
444
+ ### Dispatcher Behavior Examples
445
+
446
+ ```kotlin
447
+ @Test
448
+ fun `standard dispatcher requires manual advance`() = runTest(StandardTestDispatcher()) {
449
+ var value = 0
450
+ launch { value = 1 }
451
+ assertThat(value).isEqualTo(0) // not yet executed
452
+ advanceUntilIdle()
453
+ assertThat(value).isEqualTo(1) // now executed
454
+ }
455
+
456
+ @Test
457
+ fun `unconfined dispatcher runs eagerly`() = runTest(UnconfinedTestDispatcher()) {
458
+ var value = 0
459
+ launch { value = 1 }
460
+ assertThat(value).isEqualTo(1) // already executed
461
+ }
462
+ ```
463
+
464
+ ### Injectable DispatcherProvider for Tests
465
+
466
+ ```kotlin
467
+ class TestDispatcherProvider(
468
+ testDispatcher: TestDispatcher = StandardTestDispatcher(),
469
+ ) : DispatcherProvider {
470
+ override val main: CoroutineDispatcher = testDispatcher
471
+ override val io: CoroutineDispatcher = testDispatcher
472
+ override val default: CoroutineDispatcher = testDispatcher
473
+ }
474
+ ```
475
+
476
+ ## Screenshot Testing
477
+
478
+ ### Roborazzi (JVM-based, no device required)
479
+
480
+ ```kotlin
481
+ // build.gradle.kts
482
+ // testImplementation(libs.roborazzi)
483
+ // testImplementation(libs.roborazzi.compose)
484
+
485
+ @RunWith(RobolectricTestRunner::class)
486
+ @GraphicsMode(GraphicsMode.Mode.NATIVE)
487
+ @Config(sdk = [34])
488
+ class FlightCardScreenshotTest {
489
+
490
+ @get:Rule
491
+ val composeTestRule = createComposeRule()
492
+
493
+ @get:Rule
494
+ val roborazziRule = RoborazziRule(
495
+ options = RoborazziRule.Options(
496
+ captureType = RoborazziRule.CaptureType.LastImage(),
497
+ ),
498
+ )
499
+
500
+ @Test
501
+ fun flightCard_default() {
502
+ composeTestRule.setContent {
503
+ AppTheme {
504
+ FlightCard(flight = previewFlight)
505
+ }
506
+ }
507
+
508
+ composeTestRule
509
+ .onNodeWithTag("flight_card")
510
+ .captureRoboImage()
511
+ }
512
+
513
+ @Test
514
+ fun flightCard_dark() {
515
+ composeTestRule.setContent {
516
+ AppTheme(darkTheme = true) {
517
+ FlightCard(flight = previewFlight)
518
+ }
519
+ }
520
+
521
+ composeTestRule
522
+ .onNodeWithTag("flight_card")
523
+ .captureRoboImage()
524
+ }
525
+ }
526
+ ```
527
+
528
+ ### Paparazzi (Layoutlib-based, no device required)
529
+
530
+ ```kotlin
531
+ // build.gradle.kts
532
+ // testImplementation(libs.paparazzi)
533
+
534
+ class FlightCardPaparazziTest {
535
+
536
+ @get:Rule
537
+ val paparazzi = Paparazzi(
538
+ deviceConfig = DeviceConfig.PIXEL_6,
539
+ theme = "android:Theme.Material3.Light",
540
+ )
541
+
542
+ @Test
543
+ fun flightCard_snapshot() {
544
+ paparazzi.snapshot {
545
+ AppTheme {
546
+ FlightCard(flight = previewFlight)
547
+ }
548
+ }
549
+ }
550
+ }
551
+ ```
552
+
553
+ ### Gradle Commands
554
+
555
+ ```bash
556
+ # Roborazzi: record baselines
557
+ ./gradlew recordRoborazziDebug
558
+
559
+ # Roborazzi: verify against baselines
560
+ ./gradlew verifyRoborazziDebug
561
+
562
+ # Paparazzi: record baselines
563
+ ./gradlew recordPaparazziDebug
564
+
565
+ # Paparazzi: verify against baselines
566
+ ./gradlew verifyPaparazziDebug
567
+ ```
568
+
569
+ ## Compose Preview Screenshot Testing
570
+
571
+ AGP ships a first-party Compose Preview screenshot-testing plugin that renders
572
+ existing `@Preview` composables and diffs them against committed baselines,
573
+ alongside (not instead of) Paparazzi/Roborazzi. It is still alpha, so pin the
574
+ plugin version and expect API changes.
575
+
576
+ Enable it and set the image-difference threshold:
577
+
578
+ ```properties
579
+ # gradle.properties
580
+ android.experimental.enableScreenshotTest=true
581
+ ```
582
+
583
+ ```kotlin
584
+ // build.gradle.kts
585
+ plugins {
586
+ id("com.android.compose.screenshot")
587
+ }
588
+
589
+ android {
590
+ testOptions {
591
+ screenshotTests {
592
+ imageDifferenceThreshold = 0.001f // fraction of pixels allowed to differ
593
+ }
594
+ }
595
+ }
596
+ ```
597
+
598
+ Mark previews under the `screenshotTest` source set with `@PreviewTest`:
599
+
600
+ ```kotlin
601
+ // src/screenshotTest/kotlin/...
602
+ class FlightCardScreenshots {
603
+
604
+ @PreviewTest
605
+ @Preview(showBackground = true)
606
+ @Composable
607
+ fun cardLight() {
608
+ AppTheme { FlightCard(flight = previewFlight) }
609
+ }
610
+
611
+ @PreviewTest
612
+ @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
613
+ @Composable
614
+ fun cardDark() {
615
+ AppTheme(darkTheme = true) { FlightCard(flight = previewFlight) }
616
+ }
617
+ }
618
+ ```
619
+
620
+ Reference images live per variant under
621
+ `src/<variant>ScreenshotTest/reference/` (for example
622
+ `src/freeDebugScreenshotTest/reference/`), so each flavor keeps its own
623
+ baselines. Record and validate:
624
+
625
+ ```bash
626
+ # Record/update baselines for the variant
627
+ ./gradlew updateDebugScreenshotTest
628
+
629
+ # Validate against committed baselines
630
+ ./gradlew validateDebugScreenshotTest
631
+ ```