From d1db59302e1624012bbe5936b4dd5e41d238324f Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Sat, 19 Sep 2026 23:49:13 +0900 Subject: [PATCH 1/9] =?UTF-8?q?chore/#153:=20=EC=8A=A4=ED=81=AC=EB=A6=B0?= =?UTF-8?q?=EC=83=B7=20=EB=B9=84=EA=B5=90=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EA=B5=AC=EC=84=B1=20=EB=B0=8F=20=ED=94=84?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=A0=8C=EB=8D=94=20=EA=B2=B0=EC=A0=95?= =?UTF-8?q?=EC=84=B1=20=ED=99=95=EB=B3=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core:designsystem에 Roborazzi + Robolectric을 붙여 @Preview 47개를 렌더링하고 골든과 비교할 수 있게 한다. 프리뷰가 picsum 등 외부 URL을 때리면 렌더 결과가 네트워크 상태에 좌우되어, 아무도 UI를 건드리지 않은 PR에서 diff가 뜬다. 프리뷰 14곳의 네트워크 URL을 PreviewImages의 로컬 리소스 URI로 바꾼다. 비율을 1:1 / 5:4 / 4:5로 나눠 둔 이유는 FeedCard 프리뷰가 비율별 레이아웃 회귀를 잡으려고 만들어졌기 때문이다. 전부 같은 이미지로 치환하면 세 프리뷰가 픽셀 단위로 같아져 검증 의도가 사라진다. ScreenshotTestApplication은 Coil의 모든 단계를 메인 디스패처에서 즉시 실행시킨다. Coil은 기본적으로 백그라운드에서 비동기로 로드하는데 Robolectric이 완료를 기다려주지 않아, 이 설정이 없으면 로컬 리소스를 써도 이미지가 빈 채로 캡처된다. 생성된 프리뷰 테스트에는 @Config를 붙일 수 없어 robolectric.properties가 유일한 배선 지점이다. LocalInspectionMode 분기는 채택하지 않았다. 생성 테스트에서 실측한 결과 false였고, 분기를 넣었다면 타지 않은 채 네트워크로 나가면서도 설정은 된 것처럼 보였을 것이다. 검증: - 동일 커밋 2회 연속 compare에서 diff 0건 - 공용 버튼 높이 50dp -> 52dp 변경 시 6개 프리뷰에서 diff 검출 Co-Authored-By: Claude Opus 5 (1M context) --- core/designsystem/build.gradle.kts | 28 +++++++++ .../core/designsystem/components/FeedCard.kt | 17 +++--- .../designsystem/preview/PreviewImages.kt | 41 +++++++++++++ .../src/main/res/drawable/preview_avatar.xml | 17 ++++++ .../main/res/drawable/preview_landscape.xml | 17 ++++++ .../main/res/drawable/preview_portrait.xml | 17 ++++++ .../src/main/res/drawable/preview_square.xml | 17 ++++++ .../designsystem/ScreenshotTestApplication.kt | 60 +++++++++++++++++++ .../src/test/resources/robolectric.properties | 4 ++ .../ui/NotificationDetailScreen.kt | 5 +- .../notification/ui/NotificationItem.kt | 5 +- .../notification/ui/NotificationScreen.kt | 5 +- gradle/libs.versions.toml | 12 ++++ 13 files changed, 231 insertions(+), 14 deletions(-) create mode 100644 core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/preview/PreviewImages.kt create mode 100644 core/designsystem/src/main/res/drawable/preview_avatar.xml create mode 100644 core/designsystem/src/main/res/drawable/preview_landscape.xml create mode 100644 core/designsystem/src/main/res/drawable/preview_portrait.xml create mode 100644 core/designsystem/src/main/res/drawable/preview_square.xml create mode 100644 core/designsystem/src/test/java/com/sseotdabwa/buyornot/core/designsystem/ScreenshotTestApplication.kt create mode 100644 core/designsystem/src/test/resources/robolectric.properties diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts index 35321e00..76a88a5b 100644 --- a/core/designsystem/build.gradle.kts +++ b/core/designsystem/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("buyornot.android.library") alias(libs.plugins.kotlin.compose) + alias(libs.plugins.roborazzi) } android { @@ -9,6 +10,26 @@ android { buildFeatures { compose = true } + + testOptions { + unitTests { + isIncludeAndroidResources = true + // Roborazzi 권고. 이게 없으면 하드웨어 가속 렌더 경로를 타지 못해 렌더 결과가 달라진다. + all { + it.systemProperties["robolectric.pixelCopyRenderMode"] = "hardware" + } + } + } +} + +// Roborazzi가 @Preview 컴포저블을 찾아 Robolectric 테스트를 생성한다. +roborazzi { + generateComposePreviewRobolectricTests { + enable = true + packages = listOf("com.sseotdabwa.buyornot.core.designsystem") + // 이 레포의 프리뷰는 전부 private fun 이다. + includePrivatePreviews = true + } } dependencies { @@ -20,4 +41,11 @@ dependencies { implementation(libs.coil.compose) implementation(libs.androidx.compose.runtime) debugImplementation(libs.androidx.compose.ui.tooling) + + testImplementation(libs.roborazzi.compose.preview.scanner.support) + testImplementation(libs.composable.preview.scanner.android) + testImplementation(libs.robolectric) + testImplementation(libs.junit) + testImplementation(platform(libs.androidx.compose.bom)) + testImplementation(libs.androidx.compose.ui.test.junit4) } diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt index 8d13477b..8d69c6ec 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt @@ -48,6 +48,7 @@ import coil.compose.AsyncImage import com.sseotdabwa.buyornot.core.designsystem.R import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotIcons import com.sseotdabwa.buyornot.core.designsystem.icon.asImageVector +import com.sseotdabwa.buyornot.core.designsystem.preview.PreviewImages import com.sseotdabwa.buyornot.core.designsystem.shape.TopArrowBubbleShape import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme import com.sseotdabwa.buyornot.core.designsystem.util.nonRippleClickable @@ -646,7 +647,7 @@ private fun FeedCardSquareInteractivePreview() { var userVotedOption by remember { mutableStateOf(null) } FeedCard( - profileImageUrl = "https://picsum.photos/seed/profile1/200/200", + profileImageUrl = PreviewImages.avatar(), nickname = "결정장애", category = "뷰티", createdAt = "10분 전", @@ -654,9 +655,9 @@ private fun FeedCardSquareInteractivePreview() { content = "이 립스틱 색상 어때요? 평소에 안 바르던 색인데 도전해볼까 고민중이에요!", productImageUrls = listOf( - "https://picsum.photos/seed/product1/800/800", - "https://picsum.photos/seed/product2/800/800", - "https://picsum.photos/seed/product3/800/800", + PreviewImages.square(), + PreviewImages.square(), + PreviewImages.square(), ), price = "35,000", imageAspectRatios = listOf(ImageAspectRatio.SQUARE), @@ -687,7 +688,7 @@ private fun FeedCardLandscapeInteractivePreview() { var userVotedOption by remember { mutableStateOf(null) } FeedCard( - profileImageUrl = "https://picsum.photos/seed/profile3/200/200", + profileImageUrl = PreviewImages.avatar(), nickname = "가로러버", category = "가전", createdAt = "1시간 전", @@ -695,7 +696,7 @@ private fun FeedCardLandscapeInteractivePreview() { content = "가로로 긴 제품은 5:4 비율로 보면 좋아요!", productImageUrls = listOf( - "https://picsum.photos/seed/product3/1000/800", + PreviewImages.landscape(), ), price = "299,000", imageAspectRatios = listOf(ImageAspectRatio.LANDSCAPE), @@ -724,7 +725,7 @@ private fun FeedCardPortraitInteractivePreview() { var userVotedOption by remember { mutableStateOf(null) } FeedCard( - profileImageUrl = "https://picsum.photos/seed/profile2/200/200", + profileImageUrl = PreviewImages.avatar(), nickname = "패션피플", category = "의류", createdAt = "2시간 전", @@ -732,7 +733,7 @@ private fun FeedCardPortraitInteractivePreview() { content = "이 원피스 4:5 비율로 보면 더 예쁜 것 같아요! 세로로 긴 옷 사진은 이 비율이 딱이에요.", productImageUrls = listOf( - "https://picsum.photos/seed/product2/800/1000", + PreviewImages.portrait(), ), price = "89,000", imageAspectRatios = listOf(ImageAspectRatio.PORTRAIT), diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/preview/PreviewImages.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/preview/PreviewImages.kt new file mode 100644 index 00000000..3c8374b0 --- /dev/null +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/preview/PreviewImages.kt @@ -0,0 +1,41 @@ +package com.sseotdabwa.buyornot.core.designsystem.preview + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.sseotdabwa.buyornot.core.designsystem.R + +/** + * 프리뷰 전용 샘플 이미지 (#153). + * + * `@Preview`가 외부 URL(picsum 등)을 때리면 스크린샷 비교 CI가 네트워크 상태에 좌우된다. + * 아무도 UI를 건드리지 않은 PR에서 diff가 뜨는 원인이 되므로, 프리뷰는 여기의 로컬 리소스 URI만 쓴다. + * + * Coil이 `android.resource://` 스킴을 지원하므로 파라미터 타입이 `String`인 채로 로컬 리소스를 넘길 수 있다. + * + * 비율을 셋으로 나눠 둔 이유: FeedCard 프리뷰가 1:1 / 5:4 / 4:5 레이아웃 회귀를 잡으려고 만들어졌다. + * 전부 같은 이미지로 치환하면 그 세 프리뷰가 픽셀 단위로 같아져 검증 의도가 사라진다. + */ +object PreviewImages { + /** 1:1 상품 이미지 */ + @Composable + fun square(): String = resourceUri(R.drawable.preview_square) + + /** 5:4 가로 상품 이미지 */ + @Composable + fun landscape(): String = resourceUri(R.drawable.preview_landscape) + + /** 4:5 세로 상품 이미지 */ + @Composable + fun portrait(): String = resourceUri(R.drawable.preview_portrait) + + /** 프로필 이미지 */ + @Composable + fun avatar(): String = resourceUri(R.drawable.preview_avatar) + + @Composable + private fun resourceUri(resId: Int): String { + val packageName = LocalContext.current.packageName + return remember(packageName, resId) { "android.resource://$packageName/$resId" } + } +} diff --git a/core/designsystem/src/main/res/drawable/preview_avatar.xml b/core/designsystem/src/main/res/drawable/preview_avatar.xml new file mode 100644 index 00000000..6da66987 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/preview_avatar.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/preview_landscape.xml b/core/designsystem/src/main/res/drawable/preview_landscape.xml new file mode 100644 index 00000000..5b2f51ac --- /dev/null +++ b/core/designsystem/src/main/res/drawable/preview_landscape.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/preview_portrait.xml b/core/designsystem/src/main/res/drawable/preview_portrait.xml new file mode 100644 index 00000000..5394dc1d --- /dev/null +++ b/core/designsystem/src/main/res/drawable/preview_portrait.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/preview_square.xml b/core/designsystem/src/main/res/drawable/preview_square.xml new file mode 100644 index 00000000..a6fc875b --- /dev/null +++ b/core/designsystem/src/main/res/drawable/preview_square.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/core/designsystem/src/test/java/com/sseotdabwa/buyornot/core/designsystem/ScreenshotTestApplication.kt b/core/designsystem/src/test/java/com/sseotdabwa/buyornot/core/designsystem/ScreenshotTestApplication.kt new file mode 100644 index 00000000..784cb8ea --- /dev/null +++ b/core/designsystem/src/test/java/com/sseotdabwa/buyornot/core/designsystem/ScreenshotTestApplication.kt @@ -0,0 +1,60 @@ +package com.sseotdabwa.buyornot.core.designsystem + +import android.app.Application +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.intercept.Interceptor +import coil.request.CachePolicy +import coil.request.ImageResult +import kotlinx.coroutines.Dispatchers + +/** + * 스크린샷 테스트용 Application (#153). + * + * 프리뷰는 [com.sseotdabwa.buyornot.core.designsystem.preview.PreviewImages]의 로컬 리소스 URI만 + * 쓰기로 했지만, 그건 사람이 지키는 규율이다. 새 프리뷰가 실수로 http URL을 들고 오면 + * 아무도 UI를 건드리지 않은 PR에서 diff가 뜨는데, 원인이 코드가 아니라 네트워크 상태라 + * 추적하기가 어렵다. + * + * 그래서 http/https 요청은 인터셉터에서 끊는다. `android.resource://`는 통과시켜 비율별 샘플 + * 이미지가 살아있게 한다. + * + * 인터셉터가 던지는 예외는 Coil이 삼켜 ErrorResult로 바꾸므로 테스트가 실패하지는 않는다. + * 실제 효과는 "네트워크에 나가지 않는다"이고, 새 프리뷰가 http URL을 들고 오면 이미지가 빈 채로 + * 렌더되어 diff로 드러난다 — 네트워크 상태와 무관하게 항상 같은 결과이므로 오탐은 아니다. + * http URL 자체를 막는 역할은 CI의 grep 가드가 맡는다. + * + * `src/test/resources/robolectric.properties`가 이 클래스를 Robolectric Application으로 지정한다. + */ +class ScreenshotTestApplication : + Application(), + ImageLoaderFactory { + override fun newImageLoader(): ImageLoader = + ImageLoader + .Builder(this) + // 캐시는 실행 간 상태를 남겨 렌더 결과를 실행 순서에 의존하게 만든다. + .diskCachePolicy(CachePolicy.DISABLED) + .memoryCachePolicy(CachePolicy.DISABLED) + // Coil은 기본적으로 백그라운드에서 비동기로 로드한다. Robolectric은 그 완료를 기다려주지 + // 않으므로 이미지가 빈 채로 캡처된다. 모든 단계를 메인 디스패처에서 즉시 실행시켜 + // 렌더 시점에 이미지가 이미 올라와 있게 만든다. + .interceptorDispatcher(Dispatchers.Main.immediate) + .fetcherDispatcher(Dispatchers.Main.immediate) + .decoderDispatcher(Dispatchers.Main.immediate) + .transformationDispatcher(Dispatchers.Main.immediate) + .components { add(NetworkImageGuard) } + .build() + + private object NetworkImageGuard : Interceptor { + override suspend fun intercept(chain: Interceptor.Chain): ImageResult { + val data = chain.request.data + if (data is String && (data.startsWith("http://") || data.startsWith("https://"))) { + error( + "스크린샷 테스트에서 네트워크 이미지 요청이 발생했습니다: $data - " + + "프리뷰는 PreviewImages의 로컬 리소스만 사용해야 합니다.", + ) + } + return chain.proceed(chain.request) + } + } +} diff --git a/core/designsystem/src/test/resources/robolectric.properties b/core/designsystem/src/test/resources/robolectric.properties new file mode 100644 index 00000000..b1e65090 --- /dev/null +++ b/core/designsystem/src/test/resources/robolectric.properties @@ -0,0 +1,4 @@ +# 스크린샷 테스트 설정 (#153). +# Roborazzi가 생성한 프리뷰 테스트는 우리가 소유한 테스트 클래스가 아니라 @Config를 붙일 수 없다. +# 모듈 단위 설정인 이 파일이 유일한 배선 지점이다. +application=com.sseotdabwa.buyornot.core.designsystem.ScreenshotTestApplication diff --git a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt index 98760e3e..60f1ed2d 100644 --- a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt +++ b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt @@ -30,6 +30,7 @@ import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotSnackBarHost import com.sseotdabwa.buyornot.core.designsystem.components.FeedCard import com.sseotdabwa.buyornot.core.designsystem.components.ImageAspectRatio import com.sseotdabwa.buyornot.core.designsystem.components.showBuyOrNotSnackBar +import com.sseotdabwa.buyornot.core.designsystem.preview.PreviewImages import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme import com.sseotdabwa.buyornot.domain.model.Author import com.sseotdabwa.buyornot.domain.model.Feed @@ -233,7 +234,7 @@ private fun NotificationDetailScreenPreview() { listOf( FeedImage( s3ObjectKey = "", - imageUrl = "https://picsum.photos/800/800", + imageUrl = PreviewImages.square(), imageWidth = 800, imageHeight = 800, ), @@ -242,7 +243,7 @@ private fun NotificationDetailScreenPreview() { Author( userId = 1L, nickname = "결정장애", - profileImage = "https://picsum.photos/200", + profileImage = PreviewImages.avatar(), ), createdAt = "2026-02-21T15:00:53.552Z", hasVoted = true, diff --git a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationItem.kt b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationItem.kt index fea98bb6..87c7e34d 100644 --- a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationItem.kt +++ b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationItem.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage +import com.sseotdabwa.buyornot.core.designsystem.preview.PreviewImages import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme @Composable @@ -95,7 +96,7 @@ private fun UnreadNotiPreview() { BuyOrNotTheme { NotificationItem( id = 1L, - imageUrl = "https://picsum.photos/200", + imageUrl = PreviewImages.square(), label = "투표 종료", message = "90% '애매하긴 해!'", time = "3일 전", @@ -111,7 +112,7 @@ private fun ReadNotiPreview() { BuyOrNotTheme { NotificationItem( id = 2L, - imageUrl = "https://picsum.photos/200", + imageUrl = PreviewImages.square(), label = "투표 종료", message = "56% '사! 가즈아!'", time = "3일 전", diff --git a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationScreen.kt b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationScreen.kt index 156669e3..2a450a46 100644 --- a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationScreen.kt +++ b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationScreen.kt @@ -39,6 +39,7 @@ import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotDividerSize import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotEmptyView import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotErrorView import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotImgs +import com.sseotdabwa.buyornot.core.designsystem.preview.PreviewImages import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme import com.sseotdabwa.buyornot.core.ui.permission.hasNotificationPermission import com.sseotdabwa.buyornot.core.ui.permission.openAppSettings @@ -326,7 +327,7 @@ private fun NotificationScreenPreview() { NotificationItem( id = 1L, feedId = 101L, - imageUrl = "https://picsum.photos/200", + imageUrl = PreviewImages.square(), title = "투표 종료", description = "78% '애매하긴 해!'", time = "2시간 전", @@ -335,7 +336,7 @@ private fun NotificationScreenPreview() { NotificationItem( id = 2L, feedId = 102L, - imageUrl = "https://picsum.photos/201", + imageUrl = PreviewImages.square(), title = "투표 종료", description = "56% '사! 가즈아!'", time = "3시간 전", diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 81dd6b8f..97963ef3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,6 +65,12 @@ firebasePerf = "2.0.2" # Code Quality ktlint = "14.2.0" +# Screenshot Test +# Roborazzi가 Robolectric 위에서 @Preview 컴포저블을 렌더링하고, ComposablePreviewScanner가 프리뷰를 찾아낸다. +roborazzi = "1.74.0" +robolectric = "4.17" +composablePreviewScanner = "0.9.3" + [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -153,6 +159,11 @@ json = { group = "org.json", name = "json", version.ref = "json" } # Performance androidx-metrics-performance = { group = "androidx.metrics", name = "metrics-performance", version.ref = "metricsPerformance" } +# Screenshot Test +roborazzi-compose-preview-scanner-support = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose-preview-scanner-support", version.ref = "roborazzi" } +composable-preview-scanner-android = { group = "io.github.sergio-sastre.ComposablePreviewScanner", name = "android", version.ref = "composablePreviewScanner" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } + # Chucker chucker = { group = "com.github.chuckerteam.chucker", name = "library", version.ref = "chucker" } chucker-no-op = { group = "com.github.chuckerteam.chucker", name = "library-no-op", version.ref = "chucker" } @@ -170,3 +181,4 @@ google-services = { id = "com.google.gms.google-services", version.ref = "google firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlytics" } firebase-appdistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" } firebase-perf = { id = "com.google.firebase.firebase-perf", version.ref = "firebasePerf" } +roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } From 044084dac07ef2647d62eaa3d63c2a75a8cf76a3 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Sun, 20 Sep 2026 00:03:44 +0900 Subject: [PATCH 2/9] =?UTF-8?q?chore/#153:=20=EC=8A=A4=ED=81=AC=EB=A6=B0?= =?UTF-8?q?=EC=83=B7=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=BB=A8?= =?UTF-8?q?=EB=B2=A4=EC=85=98=20=ED=94=8C=EB=9F=AC=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B6=94=EC=B6=9C=ED=95=98=EA=B3=A0=20cor?= =?UTF-8?q?e:testing=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모듈 7개에 같은 설정을 복붙하는 대신 buyornot.android.screenshot 하나로 켠다. 스캔 패키지는 모듈 경로에서 유도하므로 모듈이 늘어도 플러그인을 고칠 일이 없다. Roborazzi 플러그인은 preview-scanner-support / composable-preview-scanner / robolectric / junit 이 모듈에 '직접' 선언됐는지 검사한다. core:testing 을 통한 전이 의존성으로는 통과하지 못해 컨벤션 플러그인이 넷을 직접 건다. core:testing 이 실제로 공유하는 것은 ScreenshotTestApplication 하나다. SplashScreen 프리뷰는 ScreenshotTestExclude 로 뺀다. 화면 전체가 if (composition != null) 안에 있고 로티 composition 로딩이 비동기라, Robolectric 렌더 시점에는 앱 로고까지 포함해 아무것도 그려지지 않는다. 렌더 결과는 결정적이지만 빈 골든이라 검증 가치가 없고, 남겨두면 스플래시가 검증되고 있다는 착각만 남는다. 프리뷰 본문에 네트워크 URL이 들어가는 것은 CI 스크립트로 막는다. @Preview 함수 본문만 보므로 WebViewNavigation 의 실제 서비스 URL은 대상이 아니다. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/check-preview-network-urls.py | 98 +++++++++++++++++++ build-logic/convention/build.gradle.kts | 5 + .../AndroidScreenshotConventionPlugin.kt | 68 +++++++++++++ build.gradle.kts | 1 + core/designsystem/build.gradle.kts | 29 +----- .../preview/ScreenshotTestExclude.kt | 18 ++++ .../src/test/resources/robolectric.properties | 2 +- core/testing/build.gradle.kts | 18 ++++ .../testing}/ScreenshotTestApplication.kt | 6 +- feature/auth/build.gradle.kts | 1 + .../buyornot/feature/auth/ui/SplashScreen.kt | 6 ++ .../src/test/resources/robolectric.properties | 4 + gradle/libs.versions.toml | 1 + settings.gradle.kts | 1 + 14 files changed, 226 insertions(+), 32 deletions(-) create mode 100755 .github/scripts/check-preview-network-urls.py create mode 100644 build-logic/convention/src/main/kotlin/AndroidScreenshotConventionPlugin.kt create mode 100644 core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/preview/ScreenshotTestExclude.kt create mode 100644 core/testing/build.gradle.kts rename core/{designsystem/src/test/java/com/sseotdabwa/buyornot/core/designsystem => testing/src/main/java/com/sseotdabwa/buyornot/core/testing}/ScreenshotTestApplication.kt (91%) create mode 100644 feature/auth/src/test/resources/robolectric.properties diff --git a/.github/scripts/check-preview-network-urls.py b/.github/scripts/check-preview-network-urls.py new file mode 100755 index 00000000..4a1cb8ef --- /dev/null +++ b/.github/scripts/check-preview-network-urls.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""@Preview 함수 본문에 네트워크 URL이 들어가는 것을 막는다 (#153). + +프리뷰가 외부 URL을 때리면 스크린샷 비교 CI의 렌더 결과가 네트워크 상태에 좌우된다. +아무도 UI를 건드리지 않은 PR에서 diff가 떠도 원인이 코드가 아니라서 추적하기 어렵다. +프리뷰는 PreviewImages의 로컬 리소스 URI만 써야 한다. + +의도적으로 좁게 잡는다. `@Preview`가 붙은 함수의 본문만 보므로, 프로덕션 코드의 +실제 서비스 URL(WebViewNavigation의 약관 링크 등)은 대상이 아니다. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +PREVIEW_ANNOTATION = re.compile(r"^\s*@Preview\b") +FUN_START = re.compile(r"^\s*(?:private\s+|internal\s+|public\s+)?fun\s") +NETWORK_URL = re.compile(r'"https?://') + +# 스크린샷 테스트를 적용하지 않는 경로. +EXCLUDED_DIRS = ("/build/", "/src/test/", "/src/androidTest/") + + +def preview_function_bodies(lines: list[str]): + """(시작줄번호, 본문 줄들) 을 @Preview 함수마다 내놓는다. + + 중괄호 깊이로 본문 끝을 찾는다. 프리뷰는 어노테이션과 본문이 붙어 있는 단순한 + 형태라 파서를 세울 필요는 없다. + """ + i = 0 + n = len(lines) + while i < n: + if not PREVIEW_ANNOTATION.match(lines[i]): + i += 1 + continue + + # @Preview 뒤에 다른 어노테이션이나 @Composable 이 이어질 수 있다. + j = i + while j < n and not FUN_START.match(lines[j]): + j += 1 + if j >= n: + return + + start = j + depth = 0 + opened = False + while j < n: + depth += lines[j].count("{") - lines[j].count("}") + if "{" in lines[j]: + opened = True + if opened and depth <= 0: + break + j += 1 + + yield start, lines[start : j + 1] + i = j + 1 + + +def main() -> int: + root = Path(__file__).resolve().parents[2] + violations: list[str] = [] + + for path in root.rglob("*.kt"): + rel = "/" + str(path.relative_to(root)) + if any(part in rel for part in EXCLUDED_DIRS): + continue + + lines = path.read_text(encoding="utf-8").splitlines() + if not any(PREVIEW_ANNOTATION.match(line) for line in lines): + continue + + for start, body in preview_function_bodies(lines): + for offset, line in enumerate(body): + if NETWORK_URL.search(line): + violations.append( + f"{rel.lstrip('/')}:{start + offset + 1}: {line.strip()}" + ) + + if violations: + print("@Preview 본문에 네트워크 URL이 있습니다:", file=sys.stderr) + for v in violations: + print(f" {v}", file=sys.stderr) + print( + "\n프리뷰는 PreviewImages의 로컬 리소스만 사용해야 합니다.\n" + "네트워크 이미지는 렌더 결과를 네트워크 상태에 좌우되게 만들어,\n" + "UI를 건드리지 않은 PR에서도 스크린샷 diff를 만듭니다.", + file=sys.stderr, + ) + return 1 + + print("@Preview 본문에 네트워크 URL 없음") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 77a4be03..c94c806e 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.ksp.gradlePlugin) compileOnly(libs.compose.gradlePlugin) + compileOnly(libs.roborazzi.gradlePlugin) } gradlePlugin { @@ -39,6 +40,10 @@ gradlePlugin { id = "buyornot.android.feature" implementationClass = "AndroidFeatureConventionPlugin" } + register("androidScreenshot") { + id = "buyornot.android.screenshot" + implementationClass = "AndroidScreenshotConventionPlugin" + } register("kotlinLibrary") { id = "buyornot.kotlin.library" implementationClass = "KotlinLibraryConventionPlugin" diff --git a/build-logic/convention/src/main/kotlin/AndroidScreenshotConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidScreenshotConventionPlugin.kt new file mode 100644 index 00000000..86f11b89 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidScreenshotConventionPlugin.kt @@ -0,0 +1,68 @@ +import com.android.build.api.dsl.LibraryExtension +import com.github.takahirom.roborazzi.AnnotationFilter +import com.github.takahirom.roborazzi.ExperimentalRoborazziApi +import com.sseotdabwa.convention.libs +import io.github.takahirom.roborazzi.RoborazziExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies + +/** + * 스크린샷 비교 테스트를 켠다 (#153). + * + * Roborazzi가 모듈의 `@Preview` 컴포저블을 스캔해 Robolectric 테스트를 생성하고, + * `recordRoborazziDebug` / `compareRoborazziDebug` 로 골든을 기록·비교한다. + */ +class AndroidScreenshotConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("io.github.takahirom.roborazzi") + + // 스캔 범위는 모듈 경로에서 유도한다 — android namespace 와 같은 규칙이라 + // 모듈이 늘어도 플러그인을 고칠 일이 없다. (:core:designsystem -> ...core.designsystem) + val screenshotPackage = "com.sseotdabwa.buyornot" + path.replace(":", ".") + + extensions.configure { + generateComposePreviewRobolectricTests { + enable.set(true) + packages.set(listOf(screenshotPackage)) + // 이 레포의 프리뷰는 전부 private fun 이다. + includePrivatePreviews.set(true) + @OptIn(ExperimentalRoborazziApi::class) + annotationFilter.set( + AnnotationFilter.Exclude(SCREENSHOT_TEST_EXCLUDE_ANNOTATION), + ) + } + } + + extensions.configure { + testOptions { + unitTests { + isIncludeAndroidResources = true + // Roborazzi 권고. 이게 없으면 하드웨어 가속 렌더 경로를 타지 못해 + // 렌더 결과가 달라진다. + all { + it.systemProperties["robolectric.pixelCopyRenderMode"] = "hardware" + } + } + } + } + + dependencies { + add("testImplementation", project(":core:testing")) + // Roborazzi 플러그인은 아래 의존성들이 모듈에 '직접' 선언됐는지 검사한다. + // core:testing 을 통한 전이 의존성으로는 통과하지 못하므로 여기서 같이 건다. + add("testImplementation", libs.findLibrary("roborazzi-compose-preview-scanner-support").get()) + add("testImplementation", libs.findLibrary("composable-preview-scanner-android").get()) + add("testImplementation", libs.findLibrary("robolectric").get()) + add("testImplementation", libs.findLibrary("junit").get()) + } + } + } + + private companion object { + const val SCREENSHOT_TEST_EXCLUDE_ANNOTATION = + "com.sseotdabwa.buyornot.core.designsystem.preview.ScreenshotTestExclude" + } +} diff --git a/build.gradle.kts b/build.gradle.kts index cf5e4508..1a7a094e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,6 +12,7 @@ plugins { alias(libs.plugins.firebase.crashlytics) apply false alias(libs.plugins.firebase.appdistribution) apply false alias(libs.plugins.firebase.perf) apply false + alias(libs.plugins.roborazzi) apply false } apply(from = "gradle/dependencyGraph.gradle") diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts index 76a88a5b..bb906043 100644 --- a/core/designsystem/build.gradle.kts +++ b/core/designsystem/build.gradle.kts @@ -1,7 +1,7 @@ plugins { id("buyornot.android.library") + id("buyornot.android.screenshot") alias(libs.plugins.kotlin.compose) - alias(libs.plugins.roborazzi) } android { @@ -10,26 +10,6 @@ android { buildFeatures { compose = true } - - testOptions { - unitTests { - isIncludeAndroidResources = true - // Roborazzi 권고. 이게 없으면 하드웨어 가속 렌더 경로를 타지 못해 렌더 결과가 달라진다. - all { - it.systemProperties["robolectric.pixelCopyRenderMode"] = "hardware" - } - } - } -} - -// Roborazzi가 @Preview 컴포저블을 찾아 Robolectric 테스트를 생성한다. -roborazzi { - generateComposePreviewRobolectricTests { - enable = true - packages = listOf("com.sseotdabwa.buyornot.core.designsystem") - // 이 레포의 프리뷰는 전부 private fun 이다. - includePrivatePreviews = true - } } dependencies { @@ -41,11 +21,4 @@ dependencies { implementation(libs.coil.compose) implementation(libs.androidx.compose.runtime) debugImplementation(libs.androidx.compose.ui.tooling) - - testImplementation(libs.roborazzi.compose.preview.scanner.support) - testImplementation(libs.composable.preview.scanner.android) - testImplementation(libs.robolectric) - testImplementation(libs.junit) - testImplementation(platform(libs.androidx.compose.bom)) - testImplementation(libs.androidx.compose.ui.test.junit4) } diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/preview/ScreenshotTestExclude.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/preview/ScreenshotTestExclude.kt new file mode 100644 index 00000000..f65e1146 --- /dev/null +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/preview/ScreenshotTestExclude.kt @@ -0,0 +1,18 @@ +package com.sseotdabwa.buyornot.core.designsystem.preview + +/** + * 이 프리뷰를 스크린샷 비교 대상에서 뺀다 (#153). + * + * 렌더가 비결정적이어서가 아니라, **렌더해도 검증할 게 없을 때** 쓴다. + * 예를 들어 화면 전체가 비동기 로딩 결과에 걸려 있으면 Robolectric 렌더 시점에는 + * 아무것도 그려지지 않아 빈 골든이 잡힌다. 그런 프리뷰를 남겨두면 "검증되고 있다"는 + * 착각만 남으므로 명시적으로 빼고 이유를 적는다. + * + * Android Studio 프리뷰에는 영향이 없다. + */ +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.FUNCTION) +annotation class ScreenshotTestExclude( + /** 왜 뺐는지. 나중에 되살릴 수 있는지 판단하려면 이유가 남아 있어야 한다. */ + val reason: String, +) diff --git a/core/designsystem/src/test/resources/robolectric.properties b/core/designsystem/src/test/resources/robolectric.properties index b1e65090..fc93b31a 100644 --- a/core/designsystem/src/test/resources/robolectric.properties +++ b/core/designsystem/src/test/resources/robolectric.properties @@ -1,4 +1,4 @@ # 스크린샷 테스트 설정 (#153). # Roborazzi가 생성한 프리뷰 테스트는 우리가 소유한 테스트 클래스가 아니라 @Config를 붙일 수 없다. # 모듈 단위 설정인 이 파일이 유일한 배선 지점이다. -application=com.sseotdabwa.buyornot.core.designsystem.ScreenshotTestApplication +application=com.sseotdabwa.buyornot.core.testing.ScreenshotTestApplication diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts new file mode 100644 index 00000000..9dfc8f72 --- /dev/null +++ b/core/testing/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("buyornot.android.library") +} + +android { + namespace = "com.sseotdabwa.buyornot.core.testing" +} + +// 스크린샷 테스트를 켜는 모듈들이 testImplementation 하나로 필요한 걸 모두 받도록 api 로 노출한다. +dependencies { + api(libs.coil.compose) + api(libs.roborazzi.compose.preview.scanner.support) + api(libs.composable.preview.scanner.android) + api(libs.robolectric) + api(libs.junit) + api(platform(libs.androidx.compose.bom)) + api(libs.androidx.compose.ui.test.junit4) +} diff --git a/core/designsystem/src/test/java/com/sseotdabwa/buyornot/core/designsystem/ScreenshotTestApplication.kt b/core/testing/src/main/java/com/sseotdabwa/buyornot/core/testing/ScreenshotTestApplication.kt similarity index 91% rename from core/designsystem/src/test/java/com/sseotdabwa/buyornot/core/designsystem/ScreenshotTestApplication.kt rename to core/testing/src/main/java/com/sseotdabwa/buyornot/core/testing/ScreenshotTestApplication.kt index 784cb8ea..ba228078 100644 --- a/core/designsystem/src/test/java/com/sseotdabwa/buyornot/core/designsystem/ScreenshotTestApplication.kt +++ b/core/testing/src/main/java/com/sseotdabwa/buyornot/core/testing/ScreenshotTestApplication.kt @@ -1,4 +1,4 @@ -package com.sseotdabwa.buyornot.core.designsystem +package com.sseotdabwa.buyornot.core.testing import android.app.Application import coil.ImageLoader @@ -11,8 +11,8 @@ import kotlinx.coroutines.Dispatchers /** * 스크린샷 테스트용 Application (#153). * - * 프리뷰는 [com.sseotdabwa.buyornot.core.designsystem.preview.PreviewImages]의 로컬 리소스 URI만 - * 쓰기로 했지만, 그건 사람이 지키는 규율이다. 새 프리뷰가 실수로 http URL을 들고 오면 + * 프리뷰는 core:designsystem 의 PreviewImages 가 주는 로컬 리소스 URI만 쓰기로 했지만, + * 그건 사람이 지키는 규율이다. 새 프리뷰가 실수로 http URL을 들고 오면 * 아무도 UI를 건드리지 않은 PR에서 diff가 뜨는데, 원인이 코드가 아니라 네트워크 상태라 * 추적하기가 어렵다. * diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index ad2905c6..75c3ab72 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -2,6 +2,7 @@ import java.util.Properties plugins { id("buyornot.android.feature") + id("buyornot.android.screenshot") } val localProperties = diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashScreen.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashScreen.kt index 0686f12a..8e8754d1 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashScreen.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashScreen.kt @@ -29,6 +29,7 @@ import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotAlertDialog import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotIcons import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotLotties import com.sseotdabwa.buyornot.core.designsystem.icon.asImageVector +import com.sseotdabwa.buyornot.core.designsystem.preview.ScreenshotTestExclude import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme import com.sseotdabwa.buyornot.core.ui.performance.ReportScreenRendered @@ -157,6 +158,11 @@ private fun openPlayStore(context: Context) { } @Preview(name = "SplashScreen - Pixel 5", device = "id:pixel_5", showBackground = true) +@ScreenshotTestExclude( + reason = + "화면 전체가 로티 composition 로딩 결과(if (composition != null))에 걸려 있어, " + + "Robolectric 렌더 시점에는 앱 로고까지 포함해 아무것도 그려지지 않는다. 빈 골든만 남는다.", +) @Composable private fun SplashScreenPreview() { BuyOrNotTheme { diff --git a/feature/auth/src/test/resources/robolectric.properties b/feature/auth/src/test/resources/robolectric.properties new file mode 100644 index 00000000..fc93b31a --- /dev/null +++ b/feature/auth/src/test/resources/robolectric.properties @@ -0,0 +1,4 @@ +# 스크린샷 테스트 설정 (#153). +# Roborazzi가 생성한 프리뷰 테스트는 우리가 소유한 테스트 클래스가 아니라 @Config를 붙일 수 없다. +# 모듈 단위 설정인 이 파일이 유일한 배선 지점이다. +application=com.sseotdabwa.buyornot.core.testing.ScreenshotTestApplication diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97963ef3..582d94c8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -163,6 +163,7 @@ androidx-metrics-performance = { group = "androidx.metrics", name = "metrics-per roborazzi-compose-preview-scanner-support = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose-preview-scanner-support", version.ref = "roborazzi" } composable-preview-scanner-android = { group = "io.github.sergio-sastre.ComposablePreviewScanner", name = "android", version.ref = "composablePreviewScanner" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +roborazzi-gradlePlugin = { group = "io.github.takahirom.roborazzi", name = "roborazzi-gradle-plugin", version.ref = "roborazzi" } # Chucker chucker = { group = "com.github.chuckerteam.chucker", name = "library", version.ref = "chucker" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 7f1ed466..ea99aec4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,6 +33,7 @@ include(":core:datastore") include(":core:ui") include(":core:designsystem") include(":core:common") +include(":core:testing") include(":feature:auth") include(":feature:home") include(":feature:upload") From c62c1255444ace0912037771b52d6a1a5cc41efc Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Sun, 20 Sep 2026 00:14:18 +0900 Subject: [PATCH 3/9] =?UTF-8?q?chore/#153:=20=EB=82=98=EB=A8=B8=EC=A7=80?= =?UTF-8?q?=205=EA=B0=9C=20=EB=AA=A8=EB=93=88=EC=97=90=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=B0=EC=83=B7=20=EB=B9=84=EA=B5=90=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core:ui, feature:home/upload/mypage/notification 에 컨벤션 플러그인을 건다. 프리뷰 81개 중 SplashScreen 1개를 뺀 80장이 골든으로 잡힌다. ImageViewerScreen 프리뷰가 넘기던 "url1", "url2", "url3" 은 Coil이 파일 경로로 해석해 디코딩에 실패하고 이미지 영역이 빈 채로 렌더됐다. 결정적이긴 하지만 빈 골든이라 확대/축소 뷰어의 레이아웃을 전혀 검증하지 못한다. PreviewImages 로 바꿔 세 비율이 실제로 그려지게 한다. 검증: 전체 모듈 record 후 compare 2회 연속 diff 0건 Co-Authored-By: Claude Opus 5 (1M context) --- core/ui/build.gradle.kts | 1 + .../core/ui/imageviewer/ImageViewerScreen.kt | 15 +++++++++++++-- core/ui/src/test/resources/robolectric.properties | 4 ++++ feature/home/build.gradle.kts | 1 + .../src/test/resources/robolectric.properties | 4 ++++ feature/mypage/build.gradle.kts | 1 + .../src/test/resources/robolectric.properties | 4 ++++ feature/notification/build.gradle.kts | 1 + .../src/test/resources/robolectric.properties | 4 ++++ feature/upload/build.gradle.kts | 1 + .../src/test/resources/robolectric.properties | 4 ++++ 11 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 core/ui/src/test/resources/robolectric.properties create mode 100644 feature/home/src/test/resources/robolectric.properties create mode 100644 feature/mypage/src/test/resources/robolectric.properties create mode 100644 feature/notification/src/test/resources/robolectric.properties create mode 100644 feature/upload/src/test/resources/robolectric.properties diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 2edd9509..0bfb410a 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("buyornot.android.library") alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) + id("buyornot.android.screenshot") } android { diff --git a/core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/imageviewer/ImageViewerScreen.kt b/core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/imageviewer/ImageViewerScreen.kt index e157633a..d298f256 100644 --- a/core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/imageviewer/ImageViewerScreen.kt +++ b/core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/imageviewer/ImageViewerScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotIcons import com.sseotdabwa.buyornot.core.designsystem.icon.asImageVector +import com.sseotdabwa.buyornot.core.designsystem.preview.PreviewImages import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -326,7 +327,12 @@ private suspend fun animateResetZoom( private fun ImageViewerScreenPreview() { BuyOrNotTheme { ImageViewerScreen( - imageUrls = listOf("url1", "url2", "url3"), + imageUrls = + listOf( + PreviewImages.square(), + PreviewImages.landscape(), + PreviewImages.portrait(), + ), initialPage = 0, onBackClick = {}, ) @@ -338,7 +344,12 @@ private fun ImageViewerScreenPreview() { private fun ImageViewerScreenSecondPagePreview() { BuyOrNotTheme { ImageViewerScreen( - imageUrls = listOf("url1", "url2", "url3"), + imageUrls = + listOf( + PreviewImages.square(), + PreviewImages.landscape(), + PreviewImages.portrait(), + ), initialPage = 1, onBackClick = {}, ) diff --git a/core/ui/src/test/resources/robolectric.properties b/core/ui/src/test/resources/robolectric.properties new file mode 100644 index 00000000..fc93b31a --- /dev/null +++ b/core/ui/src/test/resources/robolectric.properties @@ -0,0 +1,4 @@ +# 스크린샷 테스트 설정 (#153). +# Roborazzi가 생성한 프리뷰 테스트는 우리가 소유한 테스트 클래스가 아니라 @Config를 붙일 수 없다. +# 모듈 단위 설정인 이 파일이 유일한 배선 지점이다. +application=com.sseotdabwa.buyornot.core.testing.ScreenshotTestApplication diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 8ab29cbb..80b02a72 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("buyornot.android.feature") + id("buyornot.android.screenshot") } android { diff --git a/feature/home/src/test/resources/robolectric.properties b/feature/home/src/test/resources/robolectric.properties new file mode 100644 index 00000000..fc93b31a --- /dev/null +++ b/feature/home/src/test/resources/robolectric.properties @@ -0,0 +1,4 @@ +# 스크린샷 테스트 설정 (#153). +# Roborazzi가 생성한 프리뷰 테스트는 우리가 소유한 테스트 클래스가 아니라 @Config를 붙일 수 없다. +# 모듈 단위 설정인 이 파일이 유일한 배선 지점이다. +application=com.sseotdabwa.buyornot.core.testing.ScreenshotTestApplication diff --git a/feature/mypage/build.gradle.kts b/feature/mypage/build.gradle.kts index c369b66e..b817d02b 100644 --- a/feature/mypage/build.gradle.kts +++ b/feature/mypage/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("buyornot.android.feature") + id("buyornot.android.screenshot") } android { diff --git a/feature/mypage/src/test/resources/robolectric.properties b/feature/mypage/src/test/resources/robolectric.properties new file mode 100644 index 00000000..fc93b31a --- /dev/null +++ b/feature/mypage/src/test/resources/robolectric.properties @@ -0,0 +1,4 @@ +# 스크린샷 테스트 설정 (#153). +# Roborazzi가 생성한 프리뷰 테스트는 우리가 소유한 테스트 클래스가 아니라 @Config를 붙일 수 없다. +# 모듈 단위 설정인 이 파일이 유일한 배선 지점이다. +application=com.sseotdabwa.buyornot.core.testing.ScreenshotTestApplication diff --git a/feature/notification/build.gradle.kts b/feature/notification/build.gradle.kts index 67df59b2..bbb40115 100644 --- a/feature/notification/build.gradle.kts +++ b/feature/notification/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("buyornot.android.feature") + id("buyornot.android.screenshot") } android { diff --git a/feature/notification/src/test/resources/robolectric.properties b/feature/notification/src/test/resources/robolectric.properties new file mode 100644 index 00000000..fc93b31a --- /dev/null +++ b/feature/notification/src/test/resources/robolectric.properties @@ -0,0 +1,4 @@ +# 스크린샷 테스트 설정 (#153). +# Roborazzi가 생성한 프리뷰 테스트는 우리가 소유한 테스트 클래스가 아니라 @Config를 붙일 수 없다. +# 모듈 단위 설정인 이 파일이 유일한 배선 지점이다. +application=com.sseotdabwa.buyornot.core.testing.ScreenshotTestApplication diff --git a/feature/upload/build.gradle.kts b/feature/upload/build.gradle.kts index 84fd97e5..91af4827 100644 --- a/feature/upload/build.gradle.kts +++ b/feature/upload/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("buyornot.android.feature") + id("buyornot.android.screenshot") } android { diff --git a/feature/upload/src/test/resources/robolectric.properties b/feature/upload/src/test/resources/robolectric.properties new file mode 100644 index 00000000..fc93b31a --- /dev/null +++ b/feature/upload/src/test/resources/robolectric.properties @@ -0,0 +1,4 @@ +# 스크린샷 테스트 설정 (#153). +# Roborazzi가 생성한 프리뷰 테스트는 우리가 소유한 테스트 클래스가 아니라 @Config를 붙일 수 없다. +# 모듈 단위 설정인 이 파일이 유일한 배선 지점이다. +application=com.sseotdabwa.buyornot.core.testing.ScreenshotTestApplication From 306f9015834af1556a86e1a178e3efbfb61ac917 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Sun, 20 Sep 2026 00:18:12 +0900 Subject: [PATCH 4/9] =?UTF-8?q?chore/#153:=20setup-android=20composite=20a?= =?UTF-8?q?ction=20=EC=B6=94=EC=B6=9C=20=EB=B0=8F=20=EA=B3=A8=EB=93=A0=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=9E=A1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit local.properties 작성과 keystore/google-services 디코드가 잡마다 복붙돼 있어, 시크릿이 하나 늘 때마다 모든 잡을 고쳐야 했다. 스크린샷 CI로 잡이 늘어나므로 composite action 한 곳으로 모은다. 시크릿은 run 블록에 보간하지 않고 env 로 넘겨 로그와 셸 해석에서 분리한다. screenshot-goldens 잡은 develop push 에서만 돈다. PR은 screenshot-comparison 워크플로에서 자기 코드를 직접 렌더링하므로 기록할 필요가 없다. preview-network-urls 잡은 @Preview 본문의 http URL을 막는다. 시크릿도 Gradle도 필요 없어 별도 잡으로 두어 빠르게 끝난다. Co-Authored-By: Claude Opus 5 (1M context) --- .github/actions/setup-android/action.yml | 78 +++++++++++++ .github/workflows/android-ci.yml | 137 ++++++++++++++--------- 2 files changed, 160 insertions(+), 55 deletions(-) create mode 100644 .github/actions/setup-android/action.yml diff --git a/.github/actions/setup-android/action.yml b/.github/actions/setup-android/action.yml new file mode 100644 index 00000000..16ec5e42 --- /dev/null +++ b/.github/actions/setup-android/action.yml @@ -0,0 +1,78 @@ +name: Setup Android +description: JDK / Gradle 설정과 빌드에 필요한 시크릿 파일을 준비한다. + +# 어느 잡이든 Gradle을 돌리려면 local.properties 와 keystore, google-services.json 이 있어야 한다. +# 잡마다 같은 스텝을 복붙하면 시크릿이 하나 늘 때 전부 고쳐야 하므로 여기 한 곳에 모은다. +inputs: + signed-store-password: + required: true + signed-key-alias: + required: true + signed-key-password: + required: true + debug-base-url: + required: true + release-base-url: + required: true + google-web-client-id: + required: true + kakao-native-app-key: + required: true + kakao-native-app-key-debug: + required: true + mixpanel-token: + required: true + keystore-base64: + required: true + google-services-json-base64: + required: true + +runs: + using: composite + steps: + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Create local.properties + shell: bash + env: + SIGNED_STORE_PASSWORD: ${{ inputs.signed-store-password }} + SIGNED_KEY_ALIAS: ${{ inputs.signed-key-alias }} + SIGNED_KEY_PASSWORD: ${{ inputs.signed-key-password }} + DEBUG_BASE_URL: ${{ inputs.debug-base-url }} + RELEASE_BASE_URL: ${{ inputs.release-base-url }} + GOOGLE_WEB_CLIENT_ID: ${{ inputs.google-web-client-id }} + KAKAO_NATIVE_APP_KEY: ${{ inputs.kakao-native-app-key }} + KAKAO_NATIVE_APP_KEY_DEBUG: ${{ inputs.kakao-native-app-key-debug }} + MIXPANEL_TOKEN: ${{ inputs.mixpanel-token }} + run: | + { + echo "signed.store.file=keystore.jks" + echo "signed.store.password=$SIGNED_STORE_PASSWORD" + echo "signed.key.alias=$SIGNED_KEY_ALIAS" + echo "signed.key.password=$SIGNED_KEY_PASSWORD" + echo "debug.base.url=$DEBUG_BASE_URL" + echo "release.base.url=$RELEASE_BASE_URL" + echo "google.webClientId=$GOOGLE_WEB_CLIENT_ID" + echo "kakao.nativeAppKey=$KAKAO_NATIVE_APP_KEY" + echo "kakao.nativeAppKeyDebug=$KAKAO_NATIVE_APP_KEY_DEBUG" + echo "mixpanel.token=$MIXPANEL_TOKEN" + } >> local.properties + + - name: Decode Keystore + shell: bash + env: + KEYSTORE_BASE64: ${{ inputs.keystore-base64 }} + run: echo "$KEYSTORE_BASE64" | base64 --decode > app/keystore.jks + + - name: Decode google-services.json + shell: bash + env: + GOOGLE_SERVICES_JSON_BASE64: ${{ inputs.google-services-json-base64 }} + run: echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > app/google-services.json + + - name: Configure Gradle + uses: gradle/actions/setup-gradle@v3 diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index d90cb454..1df24be3 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -27,33 +27,19 @@ jobs: # Gradle 빌드 스크립트에 토큰이 노출되지 않도록 로컬 git 설정에 남기지 않는다. persist-credentials: false - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: ./.github/actions/setup-android with: - distribution: temurin - java-version: '17' - - - name: Create local.properties - run: | - echo "signed.store.file=keystore.jks" >> local.properties - echo "signed.store.password=${{ secrets.SIGNED_STORE_PASSWORD }}" >> local.properties - echo "signed.key.alias=${{ secrets.SIGNED_KEY_ALIAS }}" >> local.properties - echo "signed.key.password=${{ secrets.SIGNED_KEY_PASSWORD }}" >> local.properties - echo "debug.base.url=${{ secrets.DEBUG_BASE_URL }}" >> local.properties - echo "release.base.url=${{ secrets.RELEASE_BASE_URL }}" >> local.properties - echo "google.webClientId=${{ secrets.GOOGLE_WEB_CLIENT_ID }}" >> local.properties - echo "kakao.nativeAppKey=${{ secrets.KAKAO_NATIVE_APP_KEY }}" >> local.properties - echo "kakao.nativeAppKeyDebug=${{ secrets.KAKAO_NATIVE_APP_KEY_DEBUG }}" >> local.properties - echo "mixpanel.token=${{ secrets.MIXPANEL_TOKEN }}" >> local.properties - - - name: Decode Keystore - run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > app/keystore.jks - - - name: Decode google-services.json - run: echo "${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}" | base64 --decode > app/google-services.json - - - name: Configure Gradle - uses: gradle/actions/setup-gradle@v3 + signed-store-password: ${{ secrets.SIGNED_STORE_PASSWORD }} + signed-key-alias: ${{ secrets.SIGNED_KEY_ALIAS }} + signed-key-password: ${{ secrets.SIGNED_KEY_PASSWORD }} + debug-base-url: ${{ secrets.DEBUG_BASE_URL }} + release-base-url: ${{ secrets.RELEASE_BASE_URL }} + google-web-client-id: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} + kakao-native-app-key: ${{ secrets.KAKAO_NATIVE_APP_KEY }} + kakao-native-app-key-debug: ${{ secrets.KAKAO_NATIVE_APP_KEY_DEBUG }} + mixpanel-token: ${{ secrets.MIXPANEL_TOKEN }} + keystore-base64: ${{ secrets.KEYSTORE_BASE64 }} + google-services-json-base64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }} - name: Run unit tests run: ./gradlew testDebugUnitTest @@ -66,6 +52,48 @@ jobs: path: '**/build/reports/tests/testDebugUnitTest/' retention-days: 7 + # PR이 비교 기준으로 삼을 골든을 기록한다 (#153). 아무것도 단정하지 않는다. + # PR은 screenshot-comparison 워크플로에서 자기 코드를 직접 렌더링하므로, 여기는 + # develop 에 커밋이 쌓일 때만 돌면 된다. + screenshot-goldens: + if: github.event_name == 'push' && github.ref == 'refs/heads/develop' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read # for clone + actions: write # for upload-artifact + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: ./.github/actions/setup-android + with: + signed-store-password: ${{ secrets.SIGNED_STORE_PASSWORD }} + signed-key-alias: ${{ secrets.SIGNED_KEY_ALIAS }} + signed-key-password: ${{ secrets.SIGNED_KEY_PASSWORD }} + debug-base-url: ${{ secrets.DEBUG_BASE_URL }} + release-base-url: ${{ secrets.RELEASE_BASE_URL }} + google-web-client-id: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} + kakao-native-app-key: ${{ secrets.KAKAO_NATIVE_APP_KEY }} + kakao-native-app-key-debug: ${{ secrets.KAKAO_NATIVE_APP_KEY_DEBUG }} + mixpanel-token: ${{ secrets.MIXPANEL_TOKEN }} + keystore-base64: ${{ secrets.KEYSTORE_BASE64 }} + google-services-json-base64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }} + + # --continue 는 한 모듈이 실패해도 나머지 골든을 기록하게 한다. + - name: Record preview screenshots + run: ./gradlew recordRoborazziDebug --continue + + - name: Upload goldens + uses: actions/upload-artifact@v4 + with: + name: screenshot-goldens + path: '**/build/outputs/roborazzi' + retention-days: 30 + ktlint: runs-on: ubuntu-latest if: github.actor != 'github-actions[bot]' @@ -79,33 +107,19 @@ jobs: with: ref: ${{ github.head_ref || github.ref }} - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: ./.github/actions/setup-android with: - distribution: temurin - java-version: '17' - - - name: Create local.properties - run: | - echo "signed.store.file=keystore.jks" >> local.properties - echo "signed.store.password=${{ secrets.SIGNED_STORE_PASSWORD }}" >> local.properties - echo "signed.key.alias=${{ secrets.SIGNED_KEY_ALIAS }}" >> local.properties - echo "signed.key.password=${{ secrets.SIGNED_KEY_PASSWORD }}" >> local.properties - echo "debug.base.url=${{ secrets.DEBUG_BASE_URL }}" >> local.properties - echo "release.base.url=${{ secrets.RELEASE_BASE_URL }}" >> local.properties - echo "google.webClientId=${{ secrets.GOOGLE_WEB_CLIENT_ID }}" >> local.properties - echo "kakao.nativeAppKey=${{ secrets.KAKAO_NATIVE_APP_KEY }}" >> local.properties - echo "kakao.nativeAppKeyDebug=${{ secrets.KAKAO_NATIVE_APP_KEY_DEBUG }}" >> local.properties - echo "mixpanel.token=${{ secrets.MIXPANEL_TOKEN }}" >> local.properties - - - name: Decode Keystore - run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > app/keystore.jks - - - name: Decode google-services.json - run: echo "${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}" | base64 --decode > app/google-services.json - - - name: Configure Gradle - uses: gradle/actions/setup-gradle@v3 + signed-store-password: ${{ secrets.SIGNED_STORE_PASSWORD }} + signed-key-alias: ${{ secrets.SIGNED_KEY_ALIAS }} + signed-key-password: ${{ secrets.SIGNED_KEY_PASSWORD }} + debug-base-url: ${{ secrets.DEBUG_BASE_URL }} + release-base-url: ${{ secrets.RELEASE_BASE_URL }} + google-web-client-id: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} + kakao-native-app-key: ${{ secrets.KAKAO_NATIVE_APP_KEY }} + kakao-native-app-key-debug: ${{ secrets.KAKAO_NATIVE_APP_KEY_DEBUG }} + mixpanel-token: ${{ secrets.MIXPANEL_TOKEN }} + keystore-base64: ${{ secrets.KEYSTORE_BASE64 }} + google-services-json-base64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }} - name: Run ktlint check id: ktlint @@ -140,6 +154,19 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Fail if ktlint had errors (internal branch only) - if: steps.ktlint.outcome == 'failure' && github.event.pull_request.head.repo.full_name == github.repository - run: exit 1 + # 프리뷰가 외부 URL을 때리면 렌더 결과가 네트워크 상태에 좌우되어, UI를 건드리지 않은 + # PR에서도 스크린샷 diff가 뜬다 (#153). 시크릿도 Gradle도 필요 없어 별도 잡으로 둔다. + preview-network-urls: + runs-on: ubuntu-latest + if: github.actor != 'github-actions[bot]' + permissions: + contents: read + concurrency: + group: preview-network-urls-${{ github.ref }} + cancel-in-progress: true + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - run: python3 .github/scripts/check-preview-network-urls.py From 495d869bde1c09dcec6d1d3f411e876e3d30d715 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Sun, 20 Sep 2026 00:22:12 +0900 Subject: [PATCH 5/9] =?UTF-8?q?chore/#153:=20=EC=8A=A4=ED=81=AC=EB=A6=B0?= =?UTF-8?q?=EC=83=B7=20=EB=B9=84=EA=B5=90/=EC=BD=94=EB=A9=98=ED=8A=B8=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compare 워크플로는 base 커밋의 골든을 받아 PR 코드를 렌더링하고 비교한다. 골든 기록 잡이 develop push 에서만 돌므로 base 커밋에 아티팩트가 없을 수 있어, base 브랜치의 최신 성공분으로 폴백한다. 리포트를 포기하는 것보다 낫다. 코멘트는 workflow_run 으로 분리한다. 코멘트에는 쓰기 권한이 필요한데 PR이 올린 코드를 빌드하는 잡에 그 토큰을 주면 fork PR에서 탈취될 수 있다. 아티팩트에서 읽은 PR 번호는 신뢰할 수 없는 입력으로 취급해 숫자만 남긴다. 컴패니언 브랜치는 head_branch 가 아니라 PR 번호로 만든다. 이 레포의 브랜치 컨벤션이 feature/#153-slug 라 '#' 와 '/' 가 섞이면 blob// URL 에서 ref 와 경로의 경계가 모호해진다. 코멘트 본문은 bash 대신 python 으로 만든다. 스크린샷 id 에 한글이 섞여 (ImageViewerScreen - 2페이지) 퍼센트 인코딩이 필요하고, roborazzi 출력 경로 밖의 파일을 거르는 검증도 셸보다 안전하다. 표는 FQN 접두사를 떼고 모듈 열을 따로 둬서 읽히게 한다. conference-app 의 컬러 스킴 압축 awk 는 이 레포에 컬러 스킴이 없어 이식하지 않았다. 검증: 실제 diff 6건으로 코멘트 본문 생성과 컴패니언 브랜치 스테이징 경로가 코멘트 URL 과 일치하는지 로컬에서 재현 확인 Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/build-screenshot-comment.py | 109 ++++++++++++++++++ .../screenshot-comparison-comment.yml | 102 ++++++++++++++++ .github/workflows/screenshot-comparison.yml | 95 +++++++++++++++ .gitignore | 4 + 4 files changed, 310 insertions(+) create mode 100644 .github/scripts/build-screenshot-comment.py create mode 100644 .github/workflows/screenshot-comparison-comment.yml create mode 100644 .github/workflows/screenshot-comparison.yml diff --git a/.github/scripts/build-screenshot-comment.py b/.github/scripts/build-screenshot-comment.py new file mode 100644 index 00000000..6bff3d9c --- /dev/null +++ b/.github/scripts/build-screenshot-comment.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""스크린샷 diff 리포트 PR 코멘트 본문을 만든다 (#153). + +Roborazzi는 렌더가 골든과 다를 때만 `*_compare.png` 를 남긴다. 따라서 그 목록이 비어 +있다는 것은 이 PR이 화면을 아무것도 바꾸지 않았다는 뜻이다. + +여기서 다루는 파일 목록은 PR이 올린 코드가 만들어낸 산출물이므로 신뢰할 수 없는 입력으로 +취급한다. roborazzi 출력 경로 밖이거나 경로 이스케이프가 섞인 것은 커밋도 링크도 하지 않는다. + +사용법: + build-screenshot-comment.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path, PurePosixPath +from urllib.parse import quote + +# 코멘트 본문은 65536자를 넘으면 API가 거부한다. 스크린샷 id가 긴 편이라 여유를 둔다. +MAX_ROWS = 50 +# 이름이 길면 표 칸을 넘치므로 이 길이마다 줄을 바꾼다. +WRAP_EVERY = 28 +# 모든 스크린샷 id가 이 접두사로 시작한다. 표에서는 잡음이라 떼어낸다. +ID_PREFIX = "com.sseotdabwa.buyornot." +ID_SUFFIX = "_compare.png" + + +def collect(diff_root: Path) -> list[PurePosixPath]: + found: list[PurePosixPath] = [] + for path in diff_root.rglob("*_compare.png"): + rel = PurePosixPath(path.relative_to(diff_root).as_posix()) + # roborazzi가 쓴 자리에서 나온 것만 받는다. + if "build/outputs/roborazzi" not in str(rel): + continue + # 경로 이스케이프는 거부한다. 심볼릭 링크도 따라가지 않는다. + if ".." in rel.parts or path.is_symlink(): + continue + found.append(rel) + # rglob 순서는 파일시스템에 따라 달라진다. 잘린 리포트가 실행마다 다른 행을 남기지 + # 않도록 정렬한다. + return sorted(found) + + +def display_name(file_name: str) -> str: + """표에 쓸 짧은 이름. 스크린샷 id는 FQN 전체라 그대로 두면 표를 못 읽는다.""" + name = file_name.removeprefix(ID_PREFIX).removesuffix(ID_SUFFIX) + return "
".join(name[i : i + WRAP_EVERY] for i in range(0, len(name), WRAP_EVERY)) + + +def module_of(rel: PurePosixPath) -> str: + """경로 앞부분이 모듈이다. core/designsystem/build/outputs/... -> core:designsystem""" + parts = str(rel).split("/build/outputs/")[0].split("/") + return ":".join(parts) + + +def build_comment(files: list[PurePosixPath], repo: str, branch: str) -> str: + lines = ["스크린샷 비교 리포트", ""] + + if not files: + lines.append("이 PR은 프리뷰 렌더를 바꾸지 않았습니다.") + return "\n".join(lines) + "\n" + + lines.append(f"프리뷰 **{len(files)}개**의 렌더가 base 와 다릅니다. 의도한 변경인지 확인해주세요.") + lines.append("") + lines.append("| 모듈 | 프리뷰 | Reference / Diff / New |") + lines.append("|---|---|---|") + + tree = f"https://github.com/{repo}/tree/{quote(branch)}" + for rel in files[:MAX_ROWS]: + # 스크린샷 id에 한글이 섞이므로 URL은 퍼센트 인코딩한다. + url = f"https://github.com/{repo}/blob/{quote(branch)}/{quote(str(rel))}" + lines.append( + f"| `{module_of(rel)}` | [{display_name(rel.name)}]({url}) | ![]({url}?raw=true) |" + ) + + if len(files) > MAX_ROWS: + lines.append("") + lines.append( + f"{len(files)}개 중 {MAX_ROWS}개만 표시했습니다. " + f"전체는 [컴패니언 브랜치]({tree})에 있습니다." + ) + + return "\n".join(lines) + "\n" + + +def main() -> int: + if len(sys.argv) != 6: + print(__doc__, file=sys.stderr) + return 2 + + diff_root, repo, branch, out_comment, out_filelist = ( + Path(sys.argv[1]), + sys.argv[2], + sys.argv[3], + Path(sys.argv[4]), + Path(sys.argv[5]), + ) + + files = collect(diff_root) if diff_root.is_dir() else [] + out_filelist.write_text("".join(f"{f}\n" for f in files), encoding="utf-8") + out_comment.write_text(build_comment(files, repo, branch), encoding="utf-8") + + print(f"diff {len(files)}건") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/screenshot-comparison-comment.yml b/.github/workflows/screenshot-comparison-comment.yml new file mode 100644 index 00000000..c4abe7cf --- /dev/null +++ b/.github/workflows/screenshot-comparison-comment.yml @@ -0,0 +1,102 @@ +name: Screenshot comparison comment + +# workflow_run 으로 도는 이유: 코멘트에는 pull-requests/contents 쓰기 권한이 필요한데, +# PR이 올린 코드를 빌드하는 워크플로에 그 토큰을 주면 fork PR에서 탈취될 수 있다. +# 여기서는 빌드하지 않고 산출물만 읽으므로 쓰기 권한과 PR 코드가 같은 잡에 놓이지 않는다. +on: + workflow_run: + workflows: + - Screenshot comparison + types: + - completed + +concurrency: + group: screenshot-comparison-comment-${{ github.event.workflow_run.id }} + cancel-in-progress: true + +permissions: {} + +jobs: + comment: + if: > + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + timeout-minutes: 10 + + permissions: + actions: read # for downloading the diff artifacts + contents: write # for pushing the diff images to the companion branch + pull-requests: write # for the comment itself + + steps: + - uses: actions/checkout@v4 + + - uses: dawidd6/action-download-artifact@v6 + with: + name: pr + run_id: ${{ github.event.workflow_run.id }} + - id: pull-request + run: | + # 이 파일은 PR이 올린 코드가 돌린 잡이 만든 것이므로 신뢰할 수 없는 입력이다. + number=$(tr -dc '0-9' < NR | head -c 10) + [ -n "$number" ] || { echo "::error::아티팩트에 PR 번호가 없습니다"; exit 1; } + echo "number=$number" >> "$GITHUB_OUTPUT" + # head_branch 를 그대로 쓰지 않는다. 이 레포의 컨벤션이 feature/#153-slug 라 + # '#' 와 '/' 가 섞여 blob// URL 에서 경계가 모호해진다. + echo "COMPANION_BRANCH=companion/pr-$number" >> "$GITHUB_ENV" + + - uses: dawidd6/action-download-artifact@v6 + with: + name: screenshot-diff-reports + run_id: ${{ github.event.workflow_run.id }} + path: screenshot-diff-reports + + # 컴패니언 브랜치를 만들기 전에 돌린다. git rm -rf 로 스크립트가 사라지기 때문이다. + - id: report + name: Build the report + run: | + python3 .github/scripts/build-screenshot-comment.py \ + screenshot-diff-reports \ + "${{ github.repository }}" \ + "$COMPANION_BRANCH" \ + comment.md \ + files.txt + echo "count=$(wc -l < files.txt | tr -d ' ')" >> "$GITHUB_OUTPUT" + + # 코멘트에 이미지를 임베드하려면 HTTP로 닿을 수 있어야 한다. orphan 브랜치라 + # develop 히스토리에 이미지가 들어가지 않는다. + # git rm 은 tracked 파일만 지우므로 방금 만든 comment.md / files.txt / + # screenshot-diff-reports 는 남는다. + - name: Push the diff images + if: steps.report.outputs.count != '0' + run: | + git checkout --orphan "$COMPANION_BRANCH" + git rm -rf --quiet . + # files.txt 의 경로는 다운로드 디렉터리 기준이다. 코멘트가 가리키는 URL과 맞추려면 + # 그 경로가 리포 루트에서 시작해야 한다. + mv screenshot-diff-reports/* . + rmdir screenshot-diff-reports + git add --pathspec-from-file=files.txt + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit --quiet -m "Screenshot diff for PR #${{ steps.pull-request.outputs.number }}" + git push --force origin "HEAD:$COMPANION_BRANCH" + + # diff가 사라진 PR에도 코멘트를 갱신해야 한다. 그러지 않으면 이미 고친 변경에 대한 + # 오래된 리포트가 마지막 말로 남는다. + - uses: peter-evans/find-comment@v3 + id: existing + with: + issue-number: ${{ steps.pull-request.outputs.number }} + comment-author: 'github-actions[bot]' + body-includes: 스크린샷 비교 리포트 + + - uses: peter-evans/create-or-update-comment@v4 + if: steps.report.outputs.count != '0' || steps.existing.outputs.comment-id != '' + with: + comment-id: ${{ steps.existing.outputs.comment-id }} + issue-number: ${{ steps.pull-request.outputs.number }} + # 긴 본문은 프로세스 인자 목록을 넘치므로 파일로 넘긴다. + body-path: comment.md + edit-mode: replace diff --git a/.github/workflows/screenshot-comparison.yml b/.github/workflows/screenshot-comparison.yml new file mode 100644 index 00000000..fbec3384 --- /dev/null +++ b/.github/workflows/screenshot-comparison.yml @@ -0,0 +1,95 @@ +name: Screenshot comparison + +on: + pull_request: + paths: + - '**/*.kt' + - '**/*.kts' + - '**/*.toml' + - '**/src/**/res/**' + - 'gradle/wrapper/**' + - '.github/actions/setup-android/**' + - '.github/workflows/screenshot-comparison.yml' + +concurrency: + group: screenshot-comparison-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + compare: + runs-on: ubuntu-latest + timeout-minutes: 30 + + permissions: + contents: read # for clone + actions: write # for upload-artifact, and for downloading the base branch artifact + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: ./.github/actions/setup-android + with: + signed-store-password: ${{ secrets.SIGNED_STORE_PASSWORD }} + signed-key-alias: ${{ secrets.SIGNED_KEY_ALIAS }} + signed-key-password: ${{ secrets.SIGNED_KEY_PASSWORD }} + debug-base-url: ${{ secrets.DEBUG_BASE_URL }} + release-base-url: ${{ secrets.RELEASE_BASE_URL }} + google-web-client-id: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} + kakao-native-app-key: ${{ secrets.KAKAO_NATIVE_APP_KEY }} + kakao-native-app-key-debug: ${{ secrets.KAKAO_NATIVE_APP_KEY_DEBUG }} + mixpanel-token: ${{ secrets.MIXPANEL_TOKEN }} + keystore-base64: ${{ secrets.KEYSTORE_BASE64 }} + google-services-json-base64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }} + + # 골든은 base 커밋 것을 쓴다. 그래야 리포트가 "이 PR이 무엇을 바꿨는지"를 보여주고, + # 그 사이 develop 이 움직인 내용까지 섞이지 않는다. + - name: Download the goldens recorded on the base commit + id: baseline + continue-on-error: true + uses: dawidd6/action-download-artifact@v6 + with: + name: screenshot-goldens + workflow: android-ci.yml + commit: ${{ github.event.pull_request.base.sha }} + if_no_artifact_found: fail + # 골든 기록 잡은 develop push 에서만 돌므로, base 커밋이 develop 의 tip 이 아니면 + # 그 커밋의 아티팩트가 없다. 리포트를 포기하는 것보다 base 브랜치의 최신 골든과 + # 비교하는 편이 낫다. + - name: Fall back to the newest goldens on the base branch + if: steps.baseline.outcome == 'failure' + uses: dawidd6/action-download-artifact@v6 + with: + name: screenshot-goldens + workflow: android-ci.yml + branch: ${{ github.base_ref }} + workflow_conclusion: success + if_no_artifact_found: fail + + # compare 는 검증이 아니라 리포트다. 스크린샷이 달라져도 이 잡은 성공으로 끝나고, + # 판단은 PR 코멘트를 보고 사람이 한다. (verify 는 골든을 레포에 커밋하는 방식과 + # 짝을 이루는데, 여기서는 아티팩트로만 두므로 개발자가 빨간불을 풀 방법이 없다.) + - name: Render this pull request and compare + run: ./gradlew compareRoborazziDebug --continue + + - name: Upload diff reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: screenshot-diff-reports + path: '**/build/outputs/roborazzi' + retention-days: 30 + + # 코멘트 워크플로는 workflow_run 으로 도는데, 그 이벤트 페이로드에는 PR 번호가 없다. + - name: Save the pull request number for the commenting workflow + run: | + mkdir -p pr + echo "${{ github.event.number }}" > pr/NR + - uses: actions/upload-artifact@v4 + with: + name: pr + path: pr/ + retention-days: 1 diff --git a/.gitignore b/.gitignore index e5b64310..832b60fd 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,7 @@ lint/tmp/ .gemini/logs/ .gemini/cache/ docs/superpowers/ + +# Roborazzi 스크린샷 (#153) +# 골든은 레포에 커밋하지 않고 GitHub Actions 아티팩트로만 보관한다. +**/build/outputs/roborazzi/ From feea9538b0ce2ee013c70fa6f8f843569b83a811 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Sun, 20 Sep 2026 00:35:58 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix/#153:=20=EA=B3=A8=EB=93=A0=20=EB=B6=80?= =?UTF-8?q?=EC=9E=AC=EB=A5=BC=20=EC=8B=A4=ED=8C=A8=EA=B0=80=20=EC=95=84?= =?UTF-8?q?=EB=8B=88=EB=9D=BC=20=EC=83=81=ED=83=9C=EB=A1=9C=20=EB=8B=A4?= =?UTF-8?q?=EB=A3=A8=EA=B3=A0=20=ED=8F=B4=EB=B0=B1=20=ED=83=90=EC=83=89?= =?UTF-8?q?=EC=9D=84=20=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 첫 실행에서 드러난 문제 세 가지를 고친다. 1. 폴백이 아티팩트를 못 찾는다 search_artifacts 없이는 "가장 최근 성공 런" 하나만 보고 거기 아티팩트가 없으면 그대로 포기한다. 골든 기록 잡은 develop push 에서만 도는데 android-ci.yml 자체는 PR 에서도 돌아 최신 성공 런이 골든 없는 런일 때가 흔하다. 2. 기준선이 없는 상태가 빨간불로 뜬다 도입 직후에는 base 어디에도 골든이 없다. 고장이 아니라 아직 기준선이 없는 것이라 if_no_artifact_found: warn 으로 바꾸고 found_artifact 로 분기해 비교를 건너뛴다. 이 상태를 "변경 없음" 으로 보고하면 거짓말이 되므로 pr/STATUS 로 구분해 코멘트가 골든이 없다고 명시한다. 3. dawidd6/action-download-artifact@v6 가 node20 이라 지원 중단 경고가 난다 v24 로 올린다. 새로 추가한 두 워크플로의 checkout / upload-artifact / peter-evans 액션도 함께 최신 메이저로 올린다. android-ci.yml 과 distribute.yml 의 기존 액션 버전은 건드리지 않았다. 서명 경로가 걸려 있어 로컬에서 검증할 수 없는 변경을 이 PR 에 섞지 않는다. Co-Authored-By: Claude Opus 5 (1M context) --- .../screenshot-comparison-comment.yml | 32 ++++++-- .github/workflows/screenshot-comparison.yml | 78 +++++++++++++------ 2 files changed, 80 insertions(+), 30 deletions(-) diff --git a/.github/workflows/screenshot-comparison-comment.yml b/.github/workflows/screenshot-comparison-comment.yml index c4abe7cf..21a1846d 100644 --- a/.github/workflows/screenshot-comparison-comment.yml +++ b/.github/workflows/screenshot-comparison-comment.yml @@ -30,9 +30,9 @@ jobs: pull-requests: write # for the comment itself steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: dawidd6/action-download-artifact@v6 + - uses: dawidd6/action-download-artifact@v24 with: name: pr run_id: ${{ github.event.workflow_run.id }} @@ -45,8 +45,12 @@ jobs: # head_branch 를 그대로 쓰지 않는다. 이 레포의 컨벤션이 feature/#153-slug 라 # '#' 와 '/' 가 섞여 blob// URL 에서 경계가 모호해진다. echo "COMPANION_BRANCH=companion/pr-$number" >> "$GITHUB_ENV" + # STATUS 는 "diff 가 없음" 과 "기준선이 없어 비교 자체를 못 함" 을 가른다. + status=$(tr -dc 'a-z-' < STATUS | head -c 20) + echo "status=$status" >> "$GITHUB_OUTPUT" - - uses: dawidd6/action-download-artifact@v6 + - uses: dawidd6/action-download-artifact@v24 + if: steps.pull-request.outputs.status == 'ok' with: name: screenshot-diff-reports run_id: ${{ github.event.workflow_run.id }} @@ -56,6 +60,19 @@ jobs: - id: report name: Build the report run: | + if [ "${{ steps.pull-request.outputs.status }}" != "ok" ]; then + # 기준선이 없는 상태를 "변경 없음" 으로 보고하면 거짓말이 된다. + { + echo "스크린샷 비교 리포트" + echo + echo "base 브랜치에 골든이 없어 이번에는 비교하지 못했습니다." + echo + echo "스크린샷 비교 CI를 막 도입해 아직 기준선이 없는 상태입니다." + echo "develop 에 머지되어 골든 기록 잡이 한 번 돌면 다음 PR부터 동작합니다." + } > comment.md + echo "count=0" >> "$GITHUB_OUTPUT" + exit 0 + fi python3 .github/scripts/build-screenshot-comment.py \ screenshot-diff-reports \ "${{ github.repository }}" \ @@ -85,15 +102,18 @@ jobs: # diff가 사라진 PR에도 코멘트를 갱신해야 한다. 그러지 않으면 이미 고친 변경에 대한 # 오래된 리포트가 마지막 말로 남는다. - - uses: peter-evans/find-comment@v3 + - uses: peter-evans/find-comment@v4 id: existing with: issue-number: ${{ steps.pull-request.outputs.number }} comment-author: 'github-actions[bot]' body-includes: 스크린샷 비교 리포트 - - uses: peter-evans/create-or-update-comment@v4 - if: steps.report.outputs.count != '0' || steps.existing.outputs.comment-id != '' + - uses: peter-evans/create-or-update-comment@v5 + if: > + steps.report.outputs.count != '0' + || steps.existing.outputs.comment-id != '' + || steps.pull-request.outputs.status != 'ok' with: comment-id: ${{ steps.existing.outputs.comment-id }} issue-number: ${{ steps.pull-request.outputs.number }} diff --git a/.github/workflows/screenshot-comparison.yml b/.github/workflows/screenshot-comparison.yml index fbec3384..4dc752d8 100644 --- a/.github/workflows/screenshot-comparison.yml +++ b/.github/workflows/screenshot-comparison.yml @@ -27,68 +27,98 @@ jobs: actions: write # for upload-artifact, and for downloading the base branch artifact steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false - - uses: ./.github/actions/setup-android - with: - signed-store-password: ${{ secrets.SIGNED_STORE_PASSWORD }} - signed-key-alias: ${{ secrets.SIGNED_KEY_ALIAS }} - signed-key-password: ${{ secrets.SIGNED_KEY_PASSWORD }} - debug-base-url: ${{ secrets.DEBUG_BASE_URL }} - release-base-url: ${{ secrets.RELEASE_BASE_URL }} - google-web-client-id: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} - kakao-native-app-key: ${{ secrets.KAKAO_NATIVE_APP_KEY }} - kakao-native-app-key-debug: ${{ secrets.KAKAO_NATIVE_APP_KEY_DEBUG }} - mixpanel-token: ${{ secrets.MIXPANEL_TOKEN }} - keystore-base64: ${{ secrets.KEYSTORE_BASE64 }} - google-services-json-base64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }} - # 골든은 base 커밋 것을 쓴다. 그래야 리포트가 "이 PR이 무엇을 바꿨는지"를 보여주고, # 그 사이 develop 이 움직인 내용까지 섞이지 않는다. - name: Download the goldens recorded on the base commit id: baseline - continue-on-error: true - uses: dawidd6/action-download-artifact@v6 + uses: dawidd6/action-download-artifact@v24 with: name: screenshot-goldens workflow: android-ci.yml commit: ${{ github.event.pull_request.base.sha }} - if_no_artifact_found: fail + if_no_artifact_found: warn + # 골든 기록 잡은 develop push 에서만 돌므로, base 커밋이 develop 의 tip 이 아니면 # 그 커밋의 아티팩트가 없다. 리포트를 포기하는 것보다 base 브랜치의 최신 골든과 # 비교하는 편이 낫다. + # + # search_artifacts 가 필요하다. 이게 없으면 "가장 최근 성공 런" 하나만 보고 + # 거기에 아티팩트가 없으면 그대로 포기한다 — 골든 기록 잡은 develop push 에서만 + # 도는데 android-ci.yml 자체는 PR 에서도 돌기 때문에 흔히 어긋난다. - name: Fall back to the newest goldens on the base branch - if: steps.baseline.outcome == 'failure' - uses: dawidd6/action-download-artifact@v6 + id: fallback + if: steps.baseline.outputs.found_artifact != 'true' + uses: dawidd6/action-download-artifact@v24 with: name: screenshot-goldens workflow: android-ci.yml branch: ${{ github.base_ref }} workflow_conclusion: success - if_no_artifact_found: fail + search_artifacts: true + if_no_artifact_found: warn + + - name: Resolve baseline availability + id: goldens + run: | + if [ "${{ steps.baseline.outputs.found_artifact }}" = "true" ] \ + || [ "${{ steps.fallback.outputs.found_artifact }}" = "true" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + # 도입 직후에는 base 브랜치 어디에도 골든이 없다. 이건 고장이 아니라 아직 + # 기준선이 없는 상태이므로, 잡을 실패시키는 대신 코멘트로 알린다. + echo "::notice::base 브랜치에 골든이 없어 비교를 건너뜁니다. develop 에 머지되어 골든 기록 잡이 한 번 돌면 다음 PR부터 동작합니다." + fi + + - uses: ./.github/actions/setup-android + if: steps.goldens.outputs.available == 'true' + with: + signed-store-password: ${{ secrets.SIGNED_STORE_PASSWORD }} + signed-key-alias: ${{ secrets.SIGNED_KEY_ALIAS }} + signed-key-password: ${{ secrets.SIGNED_KEY_PASSWORD }} + debug-base-url: ${{ secrets.DEBUG_BASE_URL }} + release-base-url: ${{ secrets.RELEASE_BASE_URL }} + google-web-client-id: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} + kakao-native-app-key: ${{ secrets.KAKAO_NATIVE_APP_KEY }} + kakao-native-app-key-debug: ${{ secrets.KAKAO_NATIVE_APP_KEY_DEBUG }} + mixpanel-token: ${{ secrets.MIXPANEL_TOKEN }} + keystore-base64: ${{ secrets.KEYSTORE_BASE64 }} + google-services-json-base64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }} # compare 는 검증이 아니라 리포트다. 스크린샷이 달라져도 이 잡은 성공으로 끝나고, # 판단은 PR 코멘트를 보고 사람이 한다. (verify 는 골든을 레포에 커밋하는 방식과 # 짝을 이루는데, 여기서는 아티팩트로만 두므로 개발자가 빨간불을 풀 방법이 없다.) - name: Render this pull request and compare + if: steps.goldens.outputs.available == 'true' run: ./gradlew compareRoborazziDebug --continue - name: Upload diff reports - if: always() - uses: actions/upload-artifact@v4 + if: always() && steps.goldens.outputs.available == 'true' + uses: actions/upload-artifact@v7 with: name: screenshot-diff-reports path: '**/build/outputs/roborazzi' retention-days: 30 # 코멘트 워크플로는 workflow_run 으로 도는데, 그 이벤트 페이로드에는 PR 번호가 없다. + # STATUS 는 "diff 가 없음" 과 "비교 자체를 못 함" 을 구분하기 위한 것이다. 둘을 같은 + # 코멘트로 보고하면 기준선이 없는 동안 "변경 없음" 이라는 거짓말을 하게 된다. - name: Save the pull request number for the commenting workflow + if: always() run: | mkdir -p pr echo "${{ github.event.number }}" > pr/NR - - uses: actions/upload-artifact@v4 + if [ "${{ steps.goldens.outputs.available }}" = "true" ]; then + echo "ok" > pr/STATUS + else + echo "no-goldens" > pr/STATUS + fi + - uses: actions/upload-artifact@v7 + if: always() with: name: pr path: pr/ From 6f7a7a95cdbae33bd062768eed8e9031dc947c14 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Sun, 20 Sep 2026 00:58:20 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix/#153:=20=EB=A1=9C=ED=8B=B0=20=ED=94=84?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=EB=A5=BC=20=EC=8A=A4=ED=81=AC=EB=A6=B0?= =?UTF-8?q?=EC=83=B7=20=EB=B9=84=EA=B5=90=EC=97=90=20=EB=90=98=EC=82=B4?= =?UTF-8?q?=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SplashScreen 프리뷰는 렌더 시점에 composition 이 null 이라 화면 전체가 빈 이미지로 잡혀 제외해 뒀다. 원인은 로티가 composition 파싱을 LottieTask.EXECUTOR 에서 돌리기 때문이다. Compose 의 idle 감지 바깥이라 테스트가 완료를 기다리지 못한다. 호출 스레드에서 즉시 실행시키면 파싱이 리컴포지션 전에 끝난다 — Coil 디스패처를 Dispatchers.Main.immediate 로 묶은 것과 같은 이유다. 교체 지점을 ScreenshotTestApplication 에 두려 했으나 동작하지 않았다. Robolectric 이 이 구성에서 Application 의 init 도 onCreate 도 호출하지 않는다. 인스턴스는 만들어져 applicationContext 로는 보이지만 생성자 블록이 돌지 않는다. Coil 설정이 동작하는 것은 ImageLoaderFactory 를 Coil 싱글턴이 지연 호출하기 때문이지 Application 생명주기 덕이 아니었다. 그래서 ComposePreviewTester 를 구현해 생성된 테스트의 진입점에 건다. 커스텀 tester 를 쓰면 스캔 옵션이 자동 적용되지 않으므로 useScanOptionParametersInTester 로 private 프리뷰 포함과 제외 어노테이션을 tester 에 전달한다. 프로덕션 코드는 바뀌지 않는다. SplashScreen.kt 의 변경은 제외 어노테이션 제거뿐이다. 검증: - SplashScreen 골든 14.8KB(빈 화면) -> 32.9KB, 로티 캐릭터와 앱 로고 렌더 확인 - record 3회 연속 SHA-256 동일, compare 3회 diff 0건 - 전체 모듈 골든 80 -> 81장, compare 2회 diff 0건 Co-Authored-By: Claude Opus 5 (1M context) --- .../AndroidScreenshotConventionPlugin.kt | 8 ++++ core/testing/build.gradle.kts | 1 + .../core/testing/ScreenshotPreviewTester.kt | 48 +++++++++++++++++++ .../buyornot/feature/auth/ui/SplashScreen.kt | 6 --- 4 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 core/testing/src/main/java/com/sseotdabwa/buyornot/core/testing/ScreenshotPreviewTester.kt diff --git a/build-logic/convention/src/main/kotlin/AndroidScreenshotConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidScreenshotConventionPlugin.kt index 86f11b89..8029699e 100644 --- a/build-logic/convention/src/main/kotlin/AndroidScreenshotConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidScreenshotConventionPlugin.kt @@ -29,6 +29,12 @@ class AndroidScreenshotConventionPlugin : Plugin { packages.set(listOf(screenshotPackage)) // 이 레포의 프리뷰는 전부 private fun 이다. includePrivatePreviews.set(true) + // 생성된 테스트에 우리 설정을 끼워 넣는 유일한 훅. Robolectric 이 + // Application 의 init/onCreate 를 호출하지 않아 거기서는 못 한다. + testerQualifiedClassName.set(SCREENSHOT_PREVIEW_TESTER) + // 커스텀 tester 를 쓰면 위 스캔 옵션(private 프리뷰 포함, 제외 어노테이션)이 + // 자동 적용되지 않는다. 이 스위치가 옵션을 tester 로 전달한다. + useScanOptionParametersInTester.set(true) @OptIn(ExperimentalRoborazziApi::class) annotationFilter.set( AnnotationFilter.Exclude(SCREENSHOT_TEST_EXCLUDE_ANNOTATION), @@ -64,5 +70,7 @@ class AndroidScreenshotConventionPlugin : Plugin { private companion object { const val SCREENSHOT_TEST_EXCLUDE_ANNOTATION = "com.sseotdabwa.buyornot.core.designsystem.preview.ScreenshotTestExclude" + const val SCREENSHOT_PREVIEW_TESTER = + "com.sseotdabwa.buyornot.core.testing.ScreenshotPreviewTester" } } diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index 9dfc8f72..a3e538e9 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -9,6 +9,7 @@ android { // 스크린샷 테스트를 켜는 모듈들이 testImplementation 하나로 필요한 걸 모두 받도록 api 로 노출한다. dependencies { api(libs.coil.compose) + api(libs.lottie.compose) api(libs.roborazzi.compose.preview.scanner.support) api(libs.composable.preview.scanner.android) api(libs.robolectric) diff --git a/core/testing/src/main/java/com/sseotdabwa/buyornot/core/testing/ScreenshotPreviewTester.kt b/core/testing/src/main/java/com/sseotdabwa/buyornot/core/testing/ScreenshotPreviewTester.kt new file mode 100644 index 00000000..8d194554 --- /dev/null +++ b/core/testing/src/main/java/com/sseotdabwa/buyornot/core/testing/ScreenshotPreviewTester.kt @@ -0,0 +1,48 @@ +package com.sseotdabwa.buyornot.core.testing + +import com.airbnb.lottie.LottieTask +import com.github.takahirom.roborazzi.AndroidComposePreviewTester +import com.github.takahirom.roborazzi.ComposePreviewTester +import com.github.takahirom.roborazzi.ExperimentalRoborazziApi +import java.util.concurrent.Executor + +private typealias AndroidPreviewTestParameter = + ComposePreviewTester.TestParameter.JUnit4TestParameter.AndroidPreviewJUnit4TestParameter + +/** + * 생성된 프리뷰 테스트에 우리 설정을 끼워 넣는 유일한 훅 (#153). + * + * `ScreenshotTestApplication` 을 쓰려 했지만 Robolectric 이 이 구성에서 Application 의 + * `init` 도 `onCreate` 도 호출하지 않는다 (인스턴스는 만들어져 `applicationContext` 로는 + * 보이지만 생성자 블록이 돌지 않는다). Coil 설정이 동작하는 것은 `ImageLoaderFactory` 를 + * Coil 싱글턴이 지연 호출하기 때문이지 Application 생명주기 덕이 아니다. + * + * 그래서 테스트 시작 지점이 필요한 설정은 여기에 둔다. + */ +@OptIn(ExperimentalRoborazziApi::class) +class ScreenshotPreviewTester : ComposePreviewTester { + private val delegate = AndroidComposePreviewTester() + + override fun options(): ComposePreviewTester.Options = delegate.options() + + override fun testParameters(): List = delegate.testParameters() + + override fun test(testParameter: AndroidPreviewTestParameter) { + installSynchronousLottieExecutor() + delegate.test(testParameter) + } + + private companion object { + /** + * 로티는 composition 파싱을 [LottieTask.EXECUTOR] 에서 돌린다. Compose 의 idle 감지 + * 바깥이라 테스트가 완료를 기다리지 못하고, `composition` 이 `null` 인 채로 캡처되어 + * 화면 전체가 빈 골든이 된다. + * + * 호출 스레드에서 즉시 실행시키면 파싱이 리컴포지션 전에 끝난다. + * Coil 디스패처를 `Dispatchers.Main.immediate` 로 묶은 것과 같은 이유다. + */ + fun installSynchronousLottieExecutor() { + LottieTask.EXECUTOR = Executor { it.run() } + } + } +} diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashScreen.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashScreen.kt index 8e8754d1..0686f12a 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashScreen.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashScreen.kt @@ -29,7 +29,6 @@ import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotAlertDialog import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotIcons import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotLotties import com.sseotdabwa.buyornot.core.designsystem.icon.asImageVector -import com.sseotdabwa.buyornot.core.designsystem.preview.ScreenshotTestExclude import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme import com.sseotdabwa.buyornot.core.ui.performance.ReportScreenRendered @@ -158,11 +157,6 @@ private fun openPlayStore(context: Context) { } @Preview(name = "SplashScreen - Pixel 5", device = "id:pixel_5", showBackground = true) -@ScreenshotTestExclude( - reason = - "화면 전체가 로티 composition 로딩 결과(if (composition != null))에 걸려 있어, " + - "Robolectric 렌더 시점에는 앱 로고까지 포함해 아무것도 그려지지 않는다. 빈 골든만 남는다.", -) @Composable private fun SplashScreenPreview() { BuyOrNotTheme { From b12231bb4cb40ba53e79abfcf1b2a758843f966b Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Sun, 20 Sep 2026 01:08:19 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix/#153:=20=EC=8A=A4=ED=81=AC=EB=A6=B0?= =?UTF-8?q?=EC=83=B7=20CI=20=EB=A6=AC=EB=B7=B0=20=EC=A7=80=EC=A0=81=203?= =?UTF-8?q?=EA=B1=B4=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 코멘트 워크플로의 동시성 키를 PR 고정 키로 바꾼다 workflow_run.id 는 실행마다 달라 같은 PR 의 코멘트 잡이 겹칠 수 있다. compare 에 cancel-in-progress 가 걸려 있어도 앞 실행이 이미 끝난 뒤라면 취소 대상이 아니다. 기준 이미지가 없을 때 compare 가 32초에 끝나는 것을 실측했으므로 겹칠 여유가 실제로 있다. 늦게 끝난 실행이 오래된 diff 로 최신 코멘트와 컴패니언 브랜치를 덮어쓴다. 2. 파일 목록을 NUL 구분으로 바꾸고 pathspec magic 해석을 막는다 스크린샷 id 에는 @Preview(name = ...) 이 들어가므로 개행을 흘려보낼 수 있다. 개행 구분 목록이면 한 경로가 여러 pathspec 으로 쪼개져 뒤쪽 줄이 ':(glob)' 같은 magic 으로 해석된다. 수집 단계에서 개행이 든 경로를 거르고, git 에는 --literal-pathspecs 와 --pathspec-file-nul 을 준다. 3. 스크린샷 잡에 실제 시크릿을 넘기지 않는다 pull_request 는 PR 이 올린 Gradle 코드를 그대로 돌리므로, 실제 서명 키와 서비스 설정을 넘기면 그 코드가 읽어 밖으로 보낼 수 있다. 스크린샷 비교는 라이브러리 모듈의 unit test 라 :app 의 서명/Firebase 경로를 타지 않는다. app/build.gradle.kts 의 signingConfigs 는 값이 없으면 경고만 내고 디버그 서명으로 넘어간다. 실측: 더미 local.properties 와 google-services.json 없는 상태로 :core:designsystem:recordRoborazziDebug 47장 정상 생성. android-ci.yml 의 unit-test/ktlint 에도 같은 노출이 이 PR 이전부터 있으나 범위를 넘으므로 건드리지 않았다. 검증: - 개행이 든 파일명이 수집 단계에서 걸러지는지 확인 - 실제 diff 6건으로 NUL 목록 생성과 git add 경로 일치 재현 - 전체 모듈 compare 2회 diff 0건, ktlint 통과 Co-Authored-By: Claude Opus 5 (1M context) --- .../last-tool-error-state.json | 7 +++ .../pre-tool-advisory-throttle.json | 10 +++++ .github/scripts/build-screenshot-comment.py | 8 +++- .../screenshot-comparison-comment.yml | 15 +++++-- .github/workflows/screenshot-comparison.yml | 44 +++++++++++++------ 5 files changed, 67 insertions(+), 17 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/.omc/state/sessions/a5f5e953-ea77-4a93-9d2d-4ff882ae319e/last-tool-error-state.json create mode 100644 .github/ISSUE_TEMPLATE/.omc/state/sessions/a5f5e953-ea77-4a93-9d2d-4ff882ae319e/pre-tool-advisory-throttle.json diff --git a/.github/ISSUE_TEMPLATE/.omc/state/sessions/a5f5e953-ea77-4a93-9d2d-4ff882ae319e/last-tool-error-state.json b/.github/ISSUE_TEMPLATE/.omc/state/sessions/a5f5e953-ea77-4a93-9d2d-4ff882ae319e/last-tool-error-state.json new file mode 100644 index 00000000..5b42a502 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/.omc/state/sessions/a5f5e953-ea77-4a93-9d2d-4ff882ae319e/last-tool-error-state.json @@ -0,0 +1,7 @@ +{ + "tool_name": "Bash", + "tool_input_preview": "{\"command\":\"python3 - <<'PY'\\nimport os\\nd=\\\"/Users/donghyeon/StudioProjects/BuyOrNot-Android/.github/ISSUE_TEMPLATE\\\"\\nfor name in sorted(os.listdir(d)):\\n p=os.path.join(d,name)\\n print(\\\"=\\\"*...", + "error": "Exit code 1\n======== '--bugFix-----작업할-것.md'\n---\nname: \"🐞 BugFix - 작업할 것\"\nabout: \"Something isn't working Template \"\ntitle: \"🐞 BugFix - \"\nlabels: 🐞 BugFix\nassignees: ''\n\n---\n\n## 🛠️ Issue Description\n[//]: # (해당 이슈에 대한 설명을 작성해주세요.)\n\n## 💭 Bug Description\n[//]: # (버그가 끼치는 영향과 문제를 설명해주세요.)\n\n### 📝 check-lists\n[//]: # (업무 체크리스트를 작성해주세요.)\n- [ ] Task1\n\n======== '--docs-----작업할-것.md'\n---\nname: \"📃 Docs - 작업할 것\"\nabout: \"Documentation writing and editing (README.md, etc.) Template \"\ntitle: \"📃 Docs -...", + "timestamp": "2026-07-21T13:57:15.499Z", + "retry_count": 1 +} \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/.omc/state/sessions/a5f5e953-ea77-4a93-9d2d-4ff882ae319e/pre-tool-advisory-throttle.json b/.github/ISSUE_TEMPLATE/.omc/state/sessions/a5f5e953-ea77-4a93-9d2d-4ff882ae319e/pre-tool-advisory-throttle.json new file mode 100644 index 00000000..d2b0d413 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/.omc/state/sessions/a5f5e953-ea77-4a93-9d2d-4ff882ae319e/pre-tool-advisory-throttle.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "entries": { + "79a93d4a2f8f50b95f852280616242fee1855dc99a3c75211917f55e72e95fae": { + "last_emitted_at_ms": 1784642204298, + "message": "Use parallel execution for independent tasks. Use run_in_background for long operations (npm install, builds, tests)." + } + }, + "updated_at": "2026-07-21T13:56:44.298Z" +} \ No newline at end of file diff --git a/.github/scripts/build-screenshot-comment.py b/.github/scripts/build-screenshot-comment.py index 6bff3d9c..e04b4efa 100644 --- a/.github/scripts/build-screenshot-comment.py +++ b/.github/scripts/build-screenshot-comment.py @@ -36,6 +36,11 @@ def collect(diff_root: Path) -> list[PurePosixPath]: # 경로 이스케이프는 거부한다. 심볼릭 링크도 따라가지 않는다. if ".." in rel.parts or path.is_symlink(): continue + # 개행이 든 경로는 로보라찌가 만들 수 없다. 프리뷰 이름(@Preview(name = ...))이 + # 스크린샷 id 에 들어가므로 여기로 개행을 흘려보낼 수 있고, 그러면 git 이 한 경로를 + # 여러 pathspec 으로 쪼개 뒤쪽 줄이 pathspec magic 으로 해석된다. + if "\n" in str(rel) or "\r" in str(rel) or "\0" in str(rel): + continue found.append(rel) # rglob 순서는 파일시스템에 따라 달라진다. 잘린 리포트가 실행마다 다른 행을 남기지 # 않도록 정렬한다. @@ -98,7 +103,8 @@ def main() -> int: ) files = collect(diff_root) if diff_root.is_dir() else [] - out_filelist.write_text("".join(f"{f}\n" for f in files), encoding="utf-8") + # git add --pathspec-file-nul 이 읽는 형식. 개행 구분이면 한 경로가 쪼개질 수 있다. + out_filelist.write_bytes(b"".join(f"{f}\0".encode() for f in files)) out_comment.write_text(build_comment(files, repo, branch), encoding="utf-8") print(f"diff {len(files)}건") diff --git a/.github/workflows/screenshot-comparison-comment.yml b/.github/workflows/screenshot-comparison-comment.yml index 21a1846d..99a6ac12 100644 --- a/.github/workflows/screenshot-comparison-comment.yml +++ b/.github/workflows/screenshot-comparison-comment.yml @@ -10,8 +10,13 @@ on: types: - completed +# workflow_run.id 는 실행마다 달라 같은 PR 의 코멘트 잡이 겹칠 수 있다. 앞선 실행이 늦게 +# 끝나면 오래된 diff 로 최신 코멘트와 컴패니언 브랜치를 덮어쓴다. PR 을 가리키는 고정 키를 +# 쓴다 (PR 번호는 잡 안에서 아티팩트를 읽어야 나오므로 여기서는 쓸 수 없다). concurrency: - group: screenshot-comparison-comment-${{ github.event.workflow_run.id }} + group: >- + screenshot-comparison-comment-${{ github.event.workflow_run.head_repository.full_name }}-${{ + github.event.workflow_run.head_branch }} cancel-in-progress: true permissions: {} @@ -79,7 +84,7 @@ jobs: "$COMPANION_BRANCH" \ comment.md \ files.txt - echo "count=$(wc -l < files.txt | tr -d ' ')" >> "$GITHUB_OUTPUT" + echo "count=$(tr -cd '\0' < files.txt | wc -c | tr -d ' ')" >> "$GITHUB_OUTPUT" # 코멘트에 이미지를 임베드하려면 HTTP로 닿을 수 있어야 한다. orphan 브랜치라 # develop 히스토리에 이미지가 들어가지 않는다. @@ -94,7 +99,11 @@ jobs: # 그 경로가 리포 루트에서 시작해야 한다. mv screenshot-diff-reports/* . rmdir screenshot-diff-reports - git add --pathspec-from-file=files.txt + # --literal-pathspecs 로 파일명 속 ':(glob)' 같은 pathspec magic 해석을 막고, + # --pathspec-file-nul 로 개행이 든 경로가 쪼개지지 않게 한다. + git --literal-pathspecs add \ + --pathspec-from-file=files.txt \ + --pathspec-file-nul git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git commit --quiet -m "Screenshot diff for PR #${{ steps.pull-request.outputs.number }}" diff --git a/.github/workflows/screenshot-comparison.yml b/.github/workflows/screenshot-comparison.yml index 4dc752d8..e51ab796 100644 --- a/.github/workflows/screenshot-comparison.yml +++ b/.github/workflows/screenshot-comparison.yml @@ -8,7 +8,6 @@ on: - '**/*.toml' - '**/src/**/res/**' - 'gradle/wrapper/**' - - '.github/actions/setup-android/**' - '.github/workflows/screenshot-comparison.yml' concurrency: @@ -74,20 +73,39 @@ jobs: echo "::notice::base 브랜치에 골든이 없어 비교를 건너뜁니다. develop 에 머지되어 골든 기록 잡이 한 번 돌면 다음 PR부터 동작합니다." fi - - uses: ./.github/actions/setup-android + # 시크릿을 넘기지 않는다. pull_request 는 PR 이 올린 Gradle 코드를 그대로 돌리므로, + # 실제 서명 키와 서비스 설정을 넘기면 그 코드가 읽어 밖으로 보낼 수 있다. + # + # 스크린샷 비교는 라이브러리 모듈의 unit test 라 :app 의 서명/Firebase 경로를 타지 않는다. + # app/build.gradle.kts 의 signingConfigs 는 값이 없으면 경고만 내고 디버그 서명으로 + # 넘어가므로, 더미 값으로 충분하다. (실측: 더미 local.properties + google-services.json + # 없음 상태로 :core:designsystem:recordRoborazziDebug 47장 정상 생성) + - name: Create a dummy local.properties + if: steps.goldens.outputs.available == 'true' + run: | + { + echo "signed.store.file=keystore.jks" + echo "signed.store.password=dummy" + echo "signed.key.alias=dummy" + echo "signed.key.password=dummy" + echo "debug.base.url=https://example.invalid/" + echo "release.base.url=https://example.invalid/" + echo "google.webClientId=dummy" + echo "kakao.nativeAppKey=dummy" + echo "kakao.nativeAppKeyDebug=dummy" + echo "mixpanel.token=dummy" + } >> local.properties + + - name: Set up JDK 17 if: steps.goldens.outputs.available == 'true' + uses: actions/setup-java@v4 with: - signed-store-password: ${{ secrets.SIGNED_STORE_PASSWORD }} - signed-key-alias: ${{ secrets.SIGNED_KEY_ALIAS }} - signed-key-password: ${{ secrets.SIGNED_KEY_PASSWORD }} - debug-base-url: ${{ secrets.DEBUG_BASE_URL }} - release-base-url: ${{ secrets.RELEASE_BASE_URL }} - google-web-client-id: ${{ secrets.GOOGLE_WEB_CLIENT_ID }} - kakao-native-app-key: ${{ secrets.KAKAO_NATIVE_APP_KEY }} - kakao-native-app-key-debug: ${{ secrets.KAKAO_NATIVE_APP_KEY_DEBUG }} - mixpanel-token: ${{ secrets.MIXPANEL_TOKEN }} - keystore-base64: ${{ secrets.KEYSTORE_BASE64 }} - google-services-json-base64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }} + distribution: temurin + java-version: '17' + + - name: Configure Gradle + if: steps.goldens.outputs.available == 'true' + uses: gradle/actions/setup-gradle@v3 # compare 는 검증이 아니라 리포트다. 스크린샷이 달라져도 이 잡은 성공으로 끝나고, # 판단은 PR 코멘트를 보고 사람이 한다. (verify 는 골든을 레포에 커밋하는 방식과 From 36392b1a090e0d7b064ab406a25453aa7a1ad46f Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Sun, 20 Sep 2026 01:08:36 +0900 Subject: [PATCH 9/9] =?UTF-8?q?chore/#153:=20=EC=8B=A4=EC=88=98=EB=A1=9C?= =?UTF-8?q?=20=EC=BB=A4=EB=B0=8B=EB=90=9C=20OMC=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 직전 커밋에서 git add .github 가 .github/ISSUE_TEMPLATE/.omc/ 아래의 세션 상태 파일까지 끌어들였다. 도구가 만드는 파일이라 레포에 들어갈 것이 아니다. 재발을 막으려 .gitignore 에 .omc/ 를 추가한다. Co-Authored-By: Claude Opus 5 (1M context) --- .../last-tool-error-state.json | 7 ------- .../pre-tool-advisory-throttle.json | 10 ---------- .gitignore | 3 +++ 3 files changed, 3 insertions(+), 17 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/.omc/state/sessions/a5f5e953-ea77-4a93-9d2d-4ff882ae319e/last-tool-error-state.json delete mode 100644 .github/ISSUE_TEMPLATE/.omc/state/sessions/a5f5e953-ea77-4a93-9d2d-4ff882ae319e/pre-tool-advisory-throttle.json diff --git a/.github/ISSUE_TEMPLATE/.omc/state/sessions/a5f5e953-ea77-4a93-9d2d-4ff882ae319e/last-tool-error-state.json b/.github/ISSUE_TEMPLATE/.omc/state/sessions/a5f5e953-ea77-4a93-9d2d-4ff882ae319e/last-tool-error-state.json deleted file mode 100644 index 5b42a502..00000000 --- a/.github/ISSUE_TEMPLATE/.omc/state/sessions/a5f5e953-ea77-4a93-9d2d-4ff882ae319e/last-tool-error-state.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "tool_name": "Bash", - "tool_input_preview": "{\"command\":\"python3 - <<'PY'\\nimport os\\nd=\\\"/Users/donghyeon/StudioProjects/BuyOrNot-Android/.github/ISSUE_TEMPLATE\\\"\\nfor name in sorted(os.listdir(d)):\\n p=os.path.join(d,name)\\n print(\\\"=\\\"*...", - "error": "Exit code 1\n======== '--bugFix-----작업할-것.md'\n---\nname: \"🐞 BugFix - 작업할 것\"\nabout: \"Something isn't working Template \"\ntitle: \"🐞 BugFix - \"\nlabels: 🐞 BugFix\nassignees: ''\n\n---\n\n## 🛠️ Issue Description\n[//]: # (해당 이슈에 대한 설명을 작성해주세요.)\n\n## 💭 Bug Description\n[//]: # (버그가 끼치는 영향과 문제를 설명해주세요.)\n\n### 📝 check-lists\n[//]: # (업무 체크리스트를 작성해주세요.)\n- [ ] Task1\n\n======== '--docs-----작업할-것.md'\n---\nname: \"📃 Docs - 작업할 것\"\nabout: \"Documentation writing and editing (README.md, etc.) Template \"\ntitle: \"📃 Docs -...", - "timestamp": "2026-07-21T13:57:15.499Z", - "retry_count": 1 -} \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/.omc/state/sessions/a5f5e953-ea77-4a93-9d2d-4ff882ae319e/pre-tool-advisory-throttle.json b/.github/ISSUE_TEMPLATE/.omc/state/sessions/a5f5e953-ea77-4a93-9d2d-4ff882ae319e/pre-tool-advisory-throttle.json deleted file mode 100644 index d2b0d413..00000000 --- a/.github/ISSUE_TEMPLATE/.omc/state/sessions/a5f5e953-ea77-4a93-9d2d-4ff882ae319e/pre-tool-advisory-throttle.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "version": 1, - "entries": { - "79a93d4a2f8f50b95f852280616242fee1855dc99a3c75211917f55e72e95fae": { - "last_emitted_at_ms": 1784642204298, - "message": "Use parallel execution for independent tasks. Use run_in_background for long operations (npm install, builds, tests)." - } - }, - "updated_at": "2026-07-21T13:56:44.298Z" -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 832b60fd..7516f98b 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,6 @@ docs/superpowers/ # Roborazzi 스크린샷 (#153) # 골든은 레포에 커밋하지 않고 GitHub Actions 아티팩트로만 보관한다. **/build/outputs/roborazzi/ + +# OMC 세션 상태 — 도구가 만드는 파일이라 커밋하지 않는다 +.omc/