@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
@@ -10,483 +10,189 @@ ViewModel tests with Turbine and TestDispatcher, and screenshot tests with
10
10
  Roborazzi/Paparazzi. Targets 2024-2025 testing libraries and Kotlin coroutines
11
11
  test APIs.
12
12
 
13
+ Full, copyable code for every section below lives in
14
+ [references/patterns.md](references/patterns.md). Load it when you need a
15
+ complete example; this guide keeps the decisions and short snippets.
16
+
13
17
  ## Contents
14
18
 
15
19
  - [ComposeTestRule Setup](#composetestrule-setup)
16
20
  - [Finding Nodes](#finding-nodes)
21
+ - [Test Tags from a Generated Registry](#test-tags-from-a-generated-registry)
17
22
  - [Performing Actions](#performing-actions)
18
23
  - [Assertions](#assertions)
19
24
  - [Testing State Changes](#testing-state-changes)
25
+ - [ViewModel Test Base Class](#viewmodel-test-base-class)
20
26
  - [ViewModel Testing with Turbine](#viewmodel-testing-with-turbine)
21
27
  - [TestDispatcher and runTest](#testdispatcher-and-runtest)
22
28
  - [Screenshot Testing](#screenshot-testing)
29
+ - [Compose Preview Screenshot Testing](#compose-preview-screenshot-testing)
23
30
  - [Do's and Don'ts](#dos-and-donts)
24
31
  - [Troubleshooting](#troubleshooting)
25
32
  - [Review Checklist](#review-checklist)
26
33
 
27
34
  ## ComposeTestRule Setup
28
35
 
29
- ### Unit Tests (No Activity)
36
+ Use `createComposeRule()` for pure composable tests with no activity. Use
37
+ `createAndroidComposeRule<MainActivity>()` for instrumented tests, and pair it
38
+ with `HiltAndroidRule` (ordered rules, `hiltRule.inject()` in `@Before`) when
39
+ the screen needs injected dependencies.
30
40
 
31
41
  ```kotlin
32
- class ButtonComponentTest {
33
-
34
- @get:Rule
35
- val composeTestRule = createComposeRule()
36
-
37
- @Test
38
- fun primaryButton_displaysLabel() {
39
- composeTestRule.setContent {
40
- AppTheme {
41
- PrimaryButton(label = "Book Flight", onClick = {})
42
- }
43
- }
44
-
45
- composeTestRule
46
- .onNodeWithText("Book Flight")
47
- .assertIsDisplayed()
48
- }
49
- }
42
+ @get:Rule
43
+ val composeTestRule = createComposeRule()
44
+ // composeTestRule.setContent { AppTheme { PrimaryButton(label = "Book", onClick = {}) } }
50
45
  ```
51
46
 
52
- ### Instrumented Tests (With Activity)
53
-
54
- ```kotlin
55
- @HiltAndroidTest
56
- class HomeScreenTest {
57
-
58
- @get:Rule(order = 0)
59
- val hiltRule = HiltAndroidRule(this)
60
-
61
- @get:Rule(order = 1)
62
- val composeTestRule = createAndroidComposeRule<MainActivity>()
63
-
64
- @Before
65
- fun setup() {
66
- hiltRule.inject()
67
- }
68
-
69
- @Test
70
- fun homeScreen_showsTitle() {
71
- composeTestRule
72
- .onNodeWithText("Home")
73
- .assertIsDisplayed()
74
- }
75
- }
76
- ```
47
+ Full unit and instrumented (Hilt) setups: references/patterns.md#composetestrule-setup
77
48
 
78
49
  ## Finding Nodes
79
50
 
80
- ### Semantic Matchers
51
+ Select nodes by semantics, never by implementation details. Set
52
+ `Modifier.testTag("tag")` on key composables so tests find them reliably.
81
53
 
82
54
  ```kotlin
83
- // By text content
84
55
  composeTestRule.onNodeWithText("Book Flight")
85
- composeTestRule.onNodeWithText("book flight", ignoreCase = true)
86
- composeTestRule.onNodeWithText("Flight", substring = true)
87
-
88
- // By test tag (set via Modifier.testTag("tag"))
89
56
  composeTestRule.onNodeWithTag("flight_list")
90
-
91
- // By content description (accessibility)
92
57
  composeTestRule.onNodeWithContentDescription("Search flights")
93
-
94
- // Multiple nodes
95
- composeTestRule.onAllNodesWithText("Select")
96
- composeTestRule.onAllNodesWithTag("flight_card")
97
-
98
- // Combined matchers
99
- composeTestRule.onNode(
100
- hasText("Book") and hasClickAction()
101
- )
102
-
103
- // Parent/child traversal
104
- composeTestRule
105
- .onNodeWithTag("flight_card")
106
- .onChildren()
107
- .filterToOne(hasText("IST"))
108
58
  ```
109
59
 
110
- ### Setting Test Tags
60
+ Combined matchers (`hasText and hasClickAction`), `onAllNodesWith*`, parent/child
61
+ traversal, and tag placement: references/patterns.md#finding-nodes
111
62
 
112
- ```kotlin
113
- @Composable
114
- fun FlightCard(flight: Flight, modifier: Modifier = Modifier) {
115
- Card(
116
- modifier = modifier.testTag("flight_card_${flight.id}"),
117
- ) {
118
- Text(
119
- text = flight.origin,
120
- modifier = Modifier.testTag("flight_origin"),
121
- )
122
- }
123
- }
124
- ```
63
+ ## Test Tags from a Generated Registry
125
64
 
126
- ## Performing Actions
65
+ Do not scatter tag strings as literals. A production rename then silently orphans
66
+ every test that still passes the old string, and nothing fails to compile. Drive
67
+ the `Modifier.testTag` and the matching `onNodeWithTag` from one generated
68
+ constant so a rename is a compile error. The registry is generated from the
69
+ shared identifier source; the codegen side lives in the
70
+ `android-design-tokens-codegen` skill.
127
71
 
128
72
  ```kotlin
129
- // Click
130
- composeTestRule.onNodeWithText("Book").performClick()
131
-
132
- // Text input
133
- composeTestRule.onNodeWithTag("search_field").performTextInput("Istanbul")
134
-
135
- // Clear and replace text
136
- composeTestRule.onNodeWithTag("search_field").performTextClearance()
137
- composeTestRule.onNodeWithTag("search_field").performTextReplacement("Ankara")
73
+ Modifier.testTag(TestTags.FlightCard)
74
+ composeTestRule.onNodeWithTag(TestTags.FlightCard).assertIsDisplayed()
75
+ ```
138
76
 
139
- // Scroll to node (in scrollable container)
140
- composeTestRule.onNodeWithText("Last Item").performScrollTo()
77
+ Full generated-registry and usage example: references/patterns.md#test-tags-from-a-generated-registry
141
78
 
142
- // Scroll to index in LazyColumn
143
- composeTestRule.onNodeWithTag("flight_list").performScrollToIndex(15)
79
+ ## Performing Actions
144
80
 
145
- // Scroll to key in LazyColumn
146
- composeTestRule.onNodeWithTag("flight_list").performScrollToKey("flight-42")
81
+ Actions run on a resolved node: `performClick()`, `performTextInput(text)`,
82
+ `performTextClearance()` / `performTextReplacement(text)`, `performScrollTo()`,
83
+ `performScrollToIndex(i)` / `performScrollToKey(k)`, and gestures via
84
+ `performTouchInput { swipeLeft(); longClick() }`.
147
85
 
148
- // Swipe gestures
149
- composeTestRule.onNodeWithTag("card").performTouchInput {
150
- swipeLeft()
151
- swipeRight()
152
- swipeUp()
153
- swipeDown()
154
- }
155
-
156
- // Long click
157
- composeTestRule.onNodeWithTag("item").performTouchInput {
158
- longClick()
159
- }
160
- ```
86
+ Full action reference: references/patterns.md#performing-actions
161
87
 
162
88
  ## Assertions
163
89
 
164
- ```kotlin
165
- // Existence and visibility
166
- composeTestRule.onNodeWithText("Title").assertIsDisplayed()
167
- composeTestRule.onNodeWithText("Title").assertExists()
168
- composeTestRule.onNodeWithText("Hidden").assertDoesNotExist()
169
- composeTestRule.onNodeWithText("Hidden").assertIsNotDisplayed()
90
+ Assert through the UI semantics, not internal state:
91
+ `assertIsDisplayed()` / `assertExists()` / `assertDoesNotExist()`,
92
+ `assertTextEquals(...)` / `assertTextContains(...)`,
93
+ `assertIsEnabled()`, `assertIsSelected()`, `assertIsOn()`,
94
+ `assertCountEquals(n)`, `assertIsFocused()`.
170
95
 
171
- // Text content
172
- composeTestRule.onNodeWithTag("price").assertTextEquals("$299")
173
- composeTestRule.onNodeWithTag("price").assertTextContains("299")
96
+ Full assertion reference: references/patterns.md#assertions
174
97
 
175
- // Enabled/disabled state
176
- composeTestRule.onNodeWithText("Submit").assertIsEnabled()
177
- composeTestRule.onNodeWithText("Submit").assertIsNotEnabled()
98
+ ## Testing State Changes
178
99
 
179
- // Selection state
180
- composeTestRule.onNodeWithText("Economy").assertIsSelected()
181
- composeTestRule.onNodeWithText("Business").assertIsNotSelected()
100
+ Drive the UI, then assert the visible result. For async updates use
101
+ `waitUntil(timeoutMillis) { ... }` on fetched semantics nodes instead of
102
+ `Thread.sleep`.
182
103
 
183
- // Toggle state
184
- composeTestRule.onNodeWithTag("wifi_toggle").assertIsOn()
185
- composeTestRule.onNodeWithTag("wifi_toggle").assertIsOff()
104
+ Full counter and async-filter examples: references/patterns.md#testing-state-changes
186
105
 
187
- // Count assertions
188
- composeTestRule.onAllNodesWithTag("flight_card").assertCountEquals(5)
106
+ ## ViewModel Test Base Class
189
107
 
190
- // Focused state
191
- composeTestRule.onNodeWithTag("search_field").assertIsFocused()
192
- ```
108
+ Centralize the coroutine setup in one base class every ViewModel test extends:
109
+ `Dispatchers.setMain(UnconfinedTestDispatcher())` in `@Before`, and
110
+ `Dispatchers.resetMain()` plus `unmockkAll()` in `@After`. Add a
111
+ `SavedStateHandle` helper so tests inject route arguments the same way. For a
112
+ type-safe `@Serializable` route, back the handle with the `navigation-testing`
113
+ artifact and read it via `savedStateHandle.toRoute<Route>()`.
193
114
 
194
- ## Testing State Changes
115
+ MockK inline mocking (`mockkObject`, `mockkStatic`, mocking `final` classes)
116
+ loads a Byte Buddy agent at runtime. On JDK 21+ dynamic agent attachment is
117
+ restricted, so self-attach fails with an opaque initialization error. Attach the
118
+ agent explicitly with a `-javaagent` JVM arg on the `Test` task instead of
119
+ relying on self-attach.
195
120
 
196
- ```kotlin
197
- @Test
198
- fun counter_incrementsOnClick() {
199
- composeTestRule.setContent {
200
- var count by remember { mutableIntStateOf(0) }
201
- Column {
202
- Text(text = "Count: $count", modifier = Modifier.testTag("count"))
203
- Button(onClick = { count++ }) {
204
- Text("Increment")
205
- }
206
- }
207
- }
208
-
209
- composeTestRule.onNodeWithTag("count").assertTextEquals("Count: 0")
210
- composeTestRule.onNodeWithText("Increment").performClick()
211
- composeTestRule.onNodeWithTag("count").assertTextEquals("Count: 1")
212
- }
213
-
214
- @Test
215
- fun searchField_filtersResults() {
216
- composeTestRule.setContent {
217
- AppTheme {
218
- SearchScreen(viewModel = fakeViewModel)
219
- }
220
- }
221
-
222
- composeTestRule.onNodeWithTag("search_input").performTextInput("Istanbul")
223
-
224
- // Wait for async results
225
- composeTestRule.waitUntil(timeoutMillis = 5_000) {
226
- composeTestRule
227
- .onAllNodesWithTag("search_result")
228
- .fetchSemanticsNodes()
229
- .isNotEmpty()
230
- }
231
-
232
- composeTestRule
233
- .onAllNodesWithTag("search_result")
234
- .assertCountEquals(3)
235
- }
236
- ```
121
+ Full base class, route injection, and the `-javaagent` Gradle wiring:
122
+ references/patterns.md#viewmodel-test-base-class
237
123
 
238
124
  ## ViewModel Testing with Turbine
239
125
 
240
- Turbine provides `test {}` extension on Flow for asserting emissions.
126
+ Turbine's `test {}` extension on a `Flow`/`StateFlow` asserts emissions one at a
127
+ time (`awaitItem()`, `skipItems(n)`, `cancelAndIgnoreRemainingEvents()`). Back
128
+ the ViewModel with a fake repository rather than a mocking framework.
241
129
 
242
130
  ```kotlin
243
- // build.gradle.kts
244
- // testImplementation(libs.turbine)
245
-
246
- class HomeViewModelTest {
247
-
248
- private val fakeRepository = FakeFlightRepository()
249
- private lateinit var viewModel: HomeViewModel
250
-
251
- @Before
252
- fun setup() {
253
- viewModel = HomeViewModel(
254
- getFlightsUseCase = GetFlightsUseCase(fakeRepository),
255
- )
256
- }
257
-
258
- @Test
259
- fun `loadFlights emits loading then success`() = runTest {
260
- fakeRepository.setFlights(listOf(testFlight))
261
-
262
- viewModel.uiState.test {
263
- // Initial state
264
- assertThat(awaitItem()).isEqualTo(HomeUiState.Loading)
265
-
266
- // Trigger load
267
- viewModel.loadFlights("IST", "JFK")
268
-
269
- // Success state
270
- val success = awaitItem()
271
- assertThat(success).isInstanceOf(HomeUiState.Success::class.java)
272
- assertThat((success as HomeUiState.Success).flights).hasSize(1)
273
-
274
- cancelAndIgnoreRemainingEvents()
275
- }
276
- }
277
-
278
- @Test
279
- fun `loadFlights emits error on failure`() = runTest {
280
- fakeRepository.setShouldFail(true)
281
-
282
- viewModel.uiState.test {
283
- skipItems(1) // skip Loading
284
-
285
- viewModel.loadFlights("IST", "JFK")
286
-
287
- val error = awaitItem()
288
- assertThat(error).isInstanceOf(HomeUiState.Error::class.java)
289
-
290
- cancelAndIgnoreRemainingEvents()
291
- }
292
- }
131
+ viewModel.uiState.test {
132
+ assertThat(awaitItem()).isEqualTo(HomeUiState.Loading)
133
+ viewModel.loadFlights("IST", "JFK")
134
+ assertThat(awaitItem()).isInstanceOf(HomeUiState.Success::class.java)
135
+ cancelAndIgnoreRemainingEvents()
293
136
  }
294
137
  ```
295
138
 
296
- ### Fake Repository
297
-
298
- ```kotlin
299
- class FakeFlightRepository : FlightRepository {
300
- private val flights = MutableStateFlow<List<Flight>>(emptyList())
301
- private var shouldFail = false
302
-
303
- fun setFlights(list: List<Flight>) { flights.value = list }
304
- fun setShouldFail(fail: Boolean) { shouldFail = fail }
305
-
306
- override fun getFlights(origin: String, destination: String): Flow<List<Flight>> =
307
- if (shouldFail) flow { throw IOException("Network error") }
308
- else flights
309
-
310
- override fun searchFlights(query: String, date: LocalDate): Flow<List<Flight>> =
311
- flights.map { it.filter { f -> f.origin.code == query } }
312
-
313
- override suspend fun bookFlight(flightId: String): Result<Booking> =
314
- if (shouldFail) Result.failure(IOException("Booking failed"))
315
- else Result.success(Booking(id = "B1", confirmationCode = "CONF", status = BookingStatus.CONFIRMED))
316
- }
317
- ```
139
+ Full ViewModel test and fake repository: references/patterns.md#viewmodel-testing-with-turbine
318
140
 
319
141
  ## TestDispatcher and runTest
320
142
 
321
- Use `runTest` with `StandardTestDispatcher` or `UnconfinedTestDispatcher` to
322
- control coroutine execution in tests.
323
-
324
- ```kotlin
325
- class FlightRepositoryTest {
326
-
327
- private val testDispatcher = StandardTestDispatcher()
328
-
329
- @Before
330
- fun setup() {
331
- Dispatchers.setMain(testDispatcher)
332
- }
333
-
334
- @After
335
- fun tearDown() {
336
- Dispatchers.resetMain()
337
- }
338
-
339
- @Test
340
- fun `repository returns cached data`() = runTest {
341
- val repo = FlightRepositoryImpl(
342
- remoteDataSource = fakeRemote,
343
- localDataSource = fakeLocal,
344
- flightMapper = FlightMapper(),
345
- )
346
-
347
- val result = repo.getFlights("IST", "JFK").first()
348
- assertThat(result).hasSize(2)
349
- }
350
- }
351
- ```
352
-
353
- ### TestDispatcher Comparison
143
+ Run coroutine tests inside `runTest`. Set `Dispatchers.setMain(testDispatcher)`
144
+ in `@Before` and `Dispatchers.resetMain()` in `@After`. Pick one dispatcher per
145
+ test; do not mix them.
354
146
 
355
147
  | Dispatcher | Behavior | Use For |
356
148
  |------------|----------|---------|
357
149
  | `StandardTestDispatcher` | Pauses coroutines; advance manually with `advanceUntilIdle()` | Precise control over execution order |
358
150
  | `UnconfinedTestDispatcher` | Runs coroutines eagerly | Simple tests where order does not matter |
359
151
 
360
- ```kotlin
361
- @Test
362
- fun `standard dispatcher requires manual advance`() = runTest(StandardTestDispatcher()) {
363
- var value = 0
364
- launch { value = 1 }
365
- assertThat(value).isEqualTo(0) // not yet executed
366
- advanceUntilIdle()
367
- assertThat(value).isEqualTo(1) // now executed
368
- }
369
-
370
- @Test
371
- fun `unconfined dispatcher runs eagerly`() = runTest(UnconfinedTestDispatcher()) {
372
- var value = 0
373
- launch { value = 1 }
374
- assertThat(value).isEqualTo(1) // already executed
375
- }
376
- ```
377
-
378
- ### Injectable DispatcherProvider for Tests
379
-
380
- ```kotlin
381
- class TestDispatcherProvider(
382
- testDispatcher: TestDispatcher = StandardTestDispatcher(),
383
- ) : DispatcherProvider {
384
- override val main: CoroutineDispatcher = testDispatcher
385
- override val io: CoroutineDispatcher = testDispatcher
386
- override val default: CoroutineDispatcher = testDispatcher
387
- }
388
- ```
152
+ Dispatcher behavior examples and an injectable `TestDispatcherProvider`:
153
+ references/patterns.md#testdispatcher-and-runtest
389
154
 
390
155
  ## Screenshot Testing
391
156
 
392
- ### Roborazzi (JVM-based, no device required)
157
+ Both frameworks render on the JVM with no device. Roborazzi uses Robolectric and
158
+ captures via `captureRoboImage()`; Paparazzi uses Layoutlib and captures via
159
+ `paparazzi.snapshot { ... }`. Screenshot both light and dark themes and commit
160
+ the baselines.
393
161
 
394
- ```kotlin
395
- // build.gradle.kts
396
- // testImplementation(libs.roborazzi)
397
- // testImplementation(libs.roborazzi.compose)
398
-
399
- @RunWith(RobolectricTestRunner::class)
400
- @GraphicsMode(GraphicsMode.Mode.NATIVE)
401
- @Config(sdk = [34])
402
- class FlightCardScreenshotTest {
403
-
404
- @get:Rule
405
- val composeTestRule = createComposeRule()
406
-
407
- @get:Rule
408
- val roborazziRule = RoborazziRule(
409
- options = RoborazziRule.Options(
410
- captureType = RoborazziRule.CaptureType.LastImage(),
411
- ),
412
- )
413
-
414
- @Test
415
- fun flightCard_default() {
416
- composeTestRule.setContent {
417
- AppTheme {
418
- FlightCard(flight = previewFlight)
419
- }
420
- }
421
-
422
- composeTestRule
423
- .onNodeWithTag("flight_card")
424
- .captureRoboImage()
425
- }
426
-
427
- @Test
428
- fun flightCard_dark() {
429
- composeTestRule.setContent {
430
- AppTheme(darkTheme = true) {
431
- FlightCard(flight = previewFlight)
432
- }
433
- }
434
-
435
- composeTestRule
436
- .onNodeWithTag("flight_card")
437
- .captureRoboImage()
438
- }
439
- }
162
+ ```bash
163
+ ./gradlew recordRoborazziDebug # or recordPaparazziDebug
164
+ ./gradlew verifyRoborazziDebug # or verifyPaparazziDebug
440
165
  ```
441
166
 
442
- ### Paparazzi (Layoutlib-based, no device required)
167
+ Full Roborazzi and Paparazzi test classes: references/patterns.md#screenshot-testing
443
168
 
444
- ```kotlin
445
- // build.gradle.kts
446
- // testImplementation(libs.paparazzi)
447
-
448
- class FlightCardPaparazziTest {
449
-
450
- @get:Rule
451
- val paparazzi = Paparazzi(
452
- deviceConfig = DeviceConfig.PIXEL_6,
453
- theme = "android:Theme.Material3.Light",
454
- )
455
-
456
- @Test
457
- fun flightCard_snapshot() {
458
- paparazzi.snapshot {
459
- AppTheme {
460
- FlightCard(flight = previewFlight)
461
- }
462
- }
463
- }
464
- }
465
- ```
169
+ ## Compose Preview Screenshot Testing
466
170
 
467
- ### Gradle Commands
171
+ AGP's first-party Compose Preview screenshot-testing plugin
172
+ (`com.android.compose.screenshot`) renders existing `@Preview` composables and
173
+ diffs them against committed baselines, alongside Paparazzi/Roborazzi rather than
174
+ replacing them. It is still alpha -- pin the plugin version and expect API
175
+ changes. Enable it with `android.experimental.enableScreenshotTest=true`, mark
176
+ previews with `@PreviewTest` under the `screenshotTest` source set, set
177
+ `imageDifferenceThreshold` in the `screenshotTests` DSL, and keep per-flavor
178
+ baselines under `src/<variant>ScreenshotTest/reference/`.
468
179
 
469
180
  ```bash
470
- # Roborazzi: record baselines
471
- ./gradlew recordRoborazziDebug
472
-
473
- # Roborazzi: verify against baselines
474
- ./gradlew verifyRoborazziDebug
475
-
476
- # Paparazzi: record baselines
477
- ./gradlew recordPaparazziDebug
478
-
479
- # Paparazzi: verify against baselines
480
- ./gradlew verifyPaparazziDebug
181
+ ./gradlew updateDebugScreenshotTest # record/update baselines
182
+ ./gradlew validateDebugScreenshotTest # validate against baselines
481
183
  ```
482
184
 
185
+ Full plugin config, `@PreviewTest` previews, and threshold DSL:
186
+ references/patterns.md#compose-preview-screenshot-testing
187
+
483
188
  ## Do's and Don'ts
484
189
 
485
190
  ### Do's
486
- - Use `Modifier.testTag()` on key composables for reliable node selection
191
+ - Drive `testTag`/`onNodeWithTag` from a generated registry, not string literals
487
192
  - Use `waitUntil` for async UI updates instead of `Thread.sleep`
488
193
  - Use Turbine `test {}` for StateFlow/Flow assertions in ViewModel tests
489
- - Use `Dispatchers.setMain(testDispatcher)` in `@Before` and `resetMain()` in `@After`
194
+ - Extend one base class that swaps the main dispatcher and calls `resetMain()` + `unmockkAll()`
195
+ - Attach the Byte Buddy agent via `-javaagent` when MockK does inline mocking on JDK 21+
490
196
  - Use fake repositories/data sources instead of mocking frameworks for data layer tests
491
197
  - Use `createComposeRule()` (no activity) for pure composable tests
492
198
  - Screenshot test both light and dark themes
@@ -494,6 +200,7 @@ class FlightCardPaparazziTest {
494
200
  ### Don'ts
495
201
  - Do not use `Thread.sleep()` in Compose tests -- use `waitUntil` or `advanceUntilIdle()`
496
202
  - Do not find nodes by implementation details (view IDs, class names)
203
+ - Do not hardcode `testTag` strings as literals in both production and tests
497
204
  - Do not test internal state directly -- test through the UI semantics
498
205
  - Do not use `Mockito.mock()` on final Kotlin classes without mockito-inline
499
206
  - Do not forget `cancelAndIgnoreRemainingEvents()` in Turbine tests
@@ -512,15 +219,18 @@ class FlightCardPaparazziTest {
512
219
  | Paparazzi `ClassNotFoundException` | Incompatible AGP version | Check Paparazzi compatibility matrix |
513
220
  | Tests pass locally but fail on CI | Locale/timezone differences | Set locale and timezone in test setup or Gradle |
514
221
  | `Dispatchers.Main` crash in unit test | Missing `setMain` | Add `Dispatchers.setMain(testDispatcher)` in `@Before` |
222
+ | MockK inline mock init fails on JDK 21+ | Byte Buddy self-attach restricted | Attach the agent via `-javaagent` on the `Test` task |
223
+ | `@PreviewTest` previews not collected | `screenshotTest` source set or flag missing | Set `enableScreenshotTest=true`, place previews under `src/screenshotTest` |
515
224
 
516
225
  ## Review Checklist
517
226
 
518
- - [ ] Key composables have `Modifier.testTag()` for test discoverability
227
+ - [ ] Key composables have `Modifier.testTag()` from a generated registry, not literals
519
228
  - [ ] No `Thread.sleep()` -- uses `waitUntil` or coroutine test APIs
520
229
  - [ ] ViewModel tests use Turbine `test {}` for Flow assertions
521
- - [ ] Tests set and reset `Dispatchers.Main` with TestDispatcher
230
+ - [ ] ViewModel tests extend a base class that swaps/resets `Dispatchers.Main` and calls `unmockkAll()`
231
+ - [ ] MockK inline mocking has the `-javaagent` Byte Buddy wiring for JDK 21+
522
232
  - [ ] Fake implementations used for repositories and data sources
523
- - [ ] Screenshot tests cover light and dark themes
233
+ - [ ] Screenshot tests cover light and dark themes (Paparazzi/Roborazzi and/or AGP `@PreviewTest`)
524
234
  - [ ] Screenshot baselines are committed to version control
525
235
  - [ ] Each test method tests one behavior/scenario
526
236
  - [ ] Test names follow `function_scenario_expected` convention