Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 30 additions & 13 deletions .github/workflows/screenshot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ jobs:
screenshot-test:
name: Validate Compose Preview Screenshots
runs-on: ubuntu-latest
# baseline 을 가진 모듈의 정본. 종전에는 실행 태스크·아티팩트 경로·실패 안내가 각자
# 목록을 들고 있어, 모듈을 하나 늘리면 세 곳을 모두 고쳐야 했다 — 하나라도 빠지면
# 그 모듈은 «초록이지만 돌지 않는» 상태가 된다. 여기 한 줄만 늘리면 셋 다 따라온다.
env:
SCREENSHOT_MODULES: >-
:core:ui
:feature:home:presentation
:feature:receiver:presentation
:feature:onboarding:presentation
:feature:afternote:presentation
:feature:mindrecord:presentation
steps:
- name: Clone repo
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -56,13 +67,9 @@ jobs:
-w /workspace \
-e KAKAO_NATIVE_APP_KEY \
-e GOOGLE_WEB_CLIENT_ID \
-e SCREENSHOT_MODULES \
afternote-screenshot:latest \
./gradlew :core:ui:validateScreenshotTest \
:feature:home:presentation:validateScreenshotTest \
:feature:receiver:presentation:validateScreenshotTest \
:feature:onboarding:presentation:validateScreenshotTest \
:feature:afternote:presentation:validateScreenshotTest \
--parallel --build-cache
sh -c './gradlew $(for m in $SCREENSHOT_MODULES; do printf "%s:validateScreenshotTest " "$m"; done) --parallel --build-cache'

# 컨테이너가 root 로 gradle 을 실행해 workspace · ~/.gradle 산출물이 root 소유가 된다.
# job 종료 시 'Post Restore Gradle cache'(캐시 저장)의 hashFiles/tar 가 runner 권한이라
Expand All @@ -71,18 +78,27 @@ jobs:
if: always()
run: sudo chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" "$HOME/.gradle"

# 아티팩트 경로도 같은 목록에서 만든다 — 여기만 빠지면 CI 가 빨개졌을 때 무엇이 어떻게
# 그려졌는지 볼 방법이 없다.
- name: Resolve screenshot result paths
id: screenshot-paths
if: failure()
run: |
{
echo 'list<<EOF'
for m in $SCREENSHOT_MODULES; do
printf '%s/build/outputs/screenshotTest-results/\n' "$(echo "${m#:}" | tr ':' '/')"
done
echo 'EOF'
} >> "$GITHUB_OUTPUT"

# 실패 시 diff/rendered 이미지를 artifact 로 업로드 — 본인이 다운로드해 시각 차이 확인 가능
- name: Upload screenshot diff on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: screenshot-test-results
path: |
core/ui/build/outputs/screenshotTest-results/
feature/home/presentation/build/outputs/screenshotTest-results/
feature/receiver/presentation/build/outputs/screenshotTest-results/
feature/onboarding/presentation/build/outputs/screenshotTest-results/
feature/afternote/presentation/build/outputs/screenshotTest-results/
path: ${{ steps.screenshot-paths.outputs.list }}
retention-days: 7

# 실패 시 PR 에 artifact URL + 조치 가이드 자동 코멘트.
Expand All @@ -93,6 +109,7 @@ jobs:
with:
script: |
const marker = '<!-- screenshot-fail-bot -->';
const modules = process.env.SCREENSHOT_MODULES.trim().split(/\s+/);
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = [
marker,
Expand All @@ -103,7 +120,7 @@ jobs:
'🔍 **확인 위치** (artifact zip 내부):',
'- diff PNG: `preview/debug/diffs/...`',
'- rendered PNG: `preview/debug/rendered/...` (CI 환경에서 새로 그린 결과)',
'- baseline PNG: repo `<module>/src/screenshotTestDebug/reference/...` (`:core:ui` · `:feature:home:presentation` · `:feature:receiver:presentation` · `:feature:onboarding:presentation` · `:feature:afternote:presentation`)',
`- baseline PNG: repo \`<module>/src/screenshotTestDebug/reference/...\` (${modules.map(m => `\`${m}\``).join(' · ')})`,
'',
'📝 **조치 가이드**:',
'- 의도된 시각 변경 → `docker build -t afternote-screenshot:latest -f Dockerfile.screenshot .` 후 `docker run --rm -v "$PWD":/workspace -w /workspace afternote-screenshot:latest ./gradlew :<module>:updateScreenshotTest` 로 baseline 갱신 + commit/push',
Expand Down
7 changes: 7 additions & 0 deletions feature/mindrecord/presentation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ plugins {
id("afternote.android.library.compose")
id("afternote.android.hilt")
id("afternote.android.navigation")
alias(libs.plugins.compose.screenshot)
id("afternote.kover")
}

Expand All @@ -11,6 +12,9 @@ android {

// Robolectric 이 컴파일된 리소스로 화면을 띄운다 (#729).
testOptions.unitTests.isIncludeAndroidResources = true

// 스크롤이 없는 화면은 세로가 모자라면 그대로 잘린다 — 좁은 화면 회귀를 CI 가 잡게 한다 (#1131).
experimentalProperties["android.experimental.enableScreenshotTest"] = true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] 소스셋은 켜졌지만 CI 는 이 모듈의 validateScreenshotTest 를 돌지 않는다. .github/workflows/screenshot.yml:60-64 가 모듈을 하드코딩한다:

./gradlew :core:ui:validateScreenshotTest \
          :feature:home:presentation:validateScreenshotTest \
          :feature:receiver:presentation:validateScreenshotTest \
          :feature:onboarding:presentation:validateScreenshotTest \
          :feature:afternote:presentation:validateScreenshotTest \
          --parallel --build-cache

실측 두 가지:

  • 이 PR 의 Screenshot / Validate Compose Preview Screenshots 잡(run 32860511624) 로그에 :feature:mindrecord:presentation 태스크는 한 줄도 없다. 초록인 것은 통과해서가 아니라 돌지 않아서다.
  • .github/ 전체에서 mindrecord 문자열이 0건이다.

그러니 지금 상태로 들어가면 baseline 6장은 저장소에만 있고 아무도 보지 않는 파일이 된다. 좁은 화면 잘림을 잡겠다는 이 PR 의 목적이 CI 에서 실현되지 않는다.

세 곳을 함께 고쳐야 한다:

  • screenshot.yml:60-64 — 태스크 목록에 :feature:mindrecord:presentation:validateScreenshotTest 추가
  • screenshot.yml:81-85 — 실패 시 렌더 PNG 를 받는 아티팩트 업로드 경로(feature/mindrecord/presentation/build/outputs/screenshotTest-results/). 이게 빠지면 CI 가 빨개졌을 때 무엇이 어떻게 그려졌는지 볼 방법이 없다.
  • screenshot.yml:106 — 실패 안내 문구의 모듈 목록

덧붙여, 모듈이 하나 늘면 이 잡의 벽시계 시간도 늘어난다(현재 7분대). --parallel 이 걸려 있어 치명적이지는 않겠지만, 이 PR 에서 실제 증가폭을 한 번 확인해 두면 다음 모듈을 붙일 때 근거가 된다.

}

dependencies {
Expand All @@ -37,6 +41,9 @@ dependencies {
// 수신자 기록 본문 시트도 JVM 에서 실제로 렌더해 확인한다 (#618).
// 카드 미리보기의 대체 문자 제거도 HtmlCompat 파싱 결과라 실제 파서로 확인한다 (#549).
testImplementation(libs.robolectric)
screenshotTestImplementation(libs.screenshot.validation.api)
screenshotTestImplementation(libs.androidx.compose.ui.tooling)

testImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.test.manifest)
implementation(libs.compose.rich.editor)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@ private fun MemorySpaceUiState.statusText(): String? =
}
}

/** 상태 없는 본문. screenshotTest 가 이 자리에서 좁은 화면 회귀를 잡는다 (#1131). */
@Composable
private fun MemorySpaceContent(
internal fun MemorySpaceContent(
onBackClick: () -> Unit,
memories: List<MemoryItem>,
modifier: Modifier = Modifier,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.afternote.feature.mindrecord.presentation

/**
* 좁은 화면 baseline 이 쓰는 기기 스펙 — 국내 보급형(720×1600 물리) 기준 (#1131).
*
* 기본 `@Preview` 는 411×914dp 급으로 렌더된다. 이 앱은 고정 dp 리터럴이 많고 반응형 API 는
* 거의 안 쓰므로, 스크롤이 없는 화면은 세로가 모자라면 그대로 잘린다. 실기 QA 가 돌던
* 에뮬레이터는 그보다도 넓어(446×967dp) 잘림이 구조적으로 안 잡혔다 (#1129).
*
* dpi 320(xhdpi)은 실기기의 리소스 버킷을 맞추기 위한 값이다 — 같은 360×800dp 라도 160dpi 면
* 다른 drawable/dimen 이 선택돼 기준으로 쓸 수 없다. `feature:onboarding`·`feature:receiver`
* 의 같은 상수와 값을 맞춘다.
*/
internal const val COMPACT_DEVICE_SPEC = "spec:width=360dp,height=800dp,dpi=320"
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package com.afternote.feature.mindrecord.presentation.screen.memoryspace

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.afternote.core.ui.theme.AfternoteTheme
import com.afternote.feature.mindrecord.presentation.COMPACT_DEVICE_SPEC
import com.afternote.feature.mindrecord.presentation.R
import com.afternote.feature.mindrecord.presentation.model.memoryspace.MemoryItem
import com.android.tools.screenshot.PreviewTest

/**
* 추억 공간은 `verticalScroll`·`Lazy*`·`Pager` 가 없다 — 세로가 모자라면 그대로 잘린다 (#1131).
*
* 상태 셋(기록 있음·0건·조회 실패)을 기본 크기와 좁은 화면([COMPACT_DEVICE_SPEC]) 양쪽에서
* 고정한다. 0건과 실패는 문구뿐 아니라 **재시도 버튼 유무**로 갈리므로 따로 둔다.
*
* 문구는 하드코딩하지 않고 리소스에서 읽는다 — 잡으려는 것이 «세로가 모자라 잘리는가» 인데
* 문구가 다르면 줄바꿈과 세로 점유가 달라져 감시 대상 자체가 어긋난다. 리소스를 부르면
* 문구가 바뀔 때 baseline 이 빨개져 «갱신하라» 고 알려 준다 (#1131 리뷰).
*/
@PreviewTest
@Preview(showBackground = true, backgroundColor = 0xFFF5F5F5)
@Composable
internal fun memorySpaceScreenScreenshot() {
AfternoteTheme {
MemorySpaceContent(
onBackClick = {},
modifier = Modifier.fillMaxSize(),
memories = previewMemories(),
)
}
}

@PreviewTest
@Preview(showBackground = true, backgroundColor = 0xFFF5F5F5, device = COMPACT_DEVICE_SPEC)
@Composable
internal fun memorySpaceScreenCompactScreenshot() {
AfternoteTheme {
MemorySpaceContent(
onBackClick = {},
modifier = Modifier.fillMaxSize(),
memories = previewMemories(),
)
}
}

@PreviewTest
@Preview(showBackground = true, backgroundColor = 0xFFF5F5F5)
@Composable
internal fun memorySpaceScreenEmptyScreenshot() {
AfternoteTheme {
MemorySpaceContent(
onBackClick = {},
modifier = Modifier.fillMaxSize(),
memories = emptyList(),
statusText = stringResource(R.string.mindrecord_memory_space_empty),
)
}
}

@PreviewTest
@Preview(showBackground = true, backgroundColor = 0xFFF5F5F5, device = COMPACT_DEVICE_SPEC)
@Composable
internal fun memorySpaceScreenEmptyCompactScreenshot() {
AfternoteTheme {
MemorySpaceContent(
onBackClick = {},
modifier = Modifier.fillMaxSize(),
memories = emptyList(),
statusText = stringResource(R.string.mindrecord_memory_space_empty),
)
}
}

/** 조회 실패 — 0건과 달리 재시도 버튼이 함께 그려진다. */
@PreviewTest
@Preview(showBackground = true, backgroundColor = 0xFFF5F5F5)
@Composable
internal fun memorySpaceScreenErrorScreenshot() {
AfternoteTheme {
MemorySpaceContent(
onBackClick = {},
modifier = Modifier.fillMaxSize(),
memories = emptyList(),
statusText = stringResource(R.string.mindrecord_error_memory_space_failed),
onRetryClick = {},
)
}
}

@PreviewTest
@Preview(showBackground = true, backgroundColor = 0xFFF5F5F5, device = COMPACT_DEVICE_SPEC)
@Composable
internal fun memorySpaceScreenErrorCompactScreenshot() {
AfternoteTheme {
MemorySpaceContent(
onBackClick = {},
modifier = Modifier.fillMaxSize(),
memories = emptyList(),
statusText = stringResource(R.string.mindrecord_error_memory_space_failed),
onRetryClick = {},
)
}
}

/**
* 네트워크 이미지는 렌더되지 않으므로 baseline 에는 자리와 텍스트만 남는다 — 잘림을 보는
* 데에는 그것으로 충분하다.
*/
private fun previewMemories(): List<MemoryItem> =
listOf(
MemoryItem(1L, "https://example.com/1.jpg", "기억 1", "2024.11.11", "미리보기", listOf("태그")),
MemoryItem(2L, "https://example.com/2.jpg", "기억 2", "2024.11.12", "미리보기", emptyList()),
MemoryItem(3L, "https://example.com/3.jpg", "기억 3", "2024.11.13", "미리보기", emptyList()),
MemoryItem(4L, "https://example.com/4.jpg", "기억 4", "2024.11.14", "미리보기", emptyList()),
)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading