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/scripts/build-screenshot-comment.py b/.github/scripts/build-screenshot-comment.py new file mode 100644 index 00000000..e04b4efa --- /dev/null +++ b/.github/scripts/build-screenshot-comment.py @@ -0,0 +1,115 @@ +#!/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 + # 개행이 든 경로는 로보라찌가 만들 수 없다. 프리뷰 이름(@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 순서는 파일시스템에 따라 달라진다. 잘린 리포트가 실행마다 다른 행을 남기지 + # 않도록 정렬한다. + 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 [] + # 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)}건") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) 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/.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 diff --git a/.github/workflows/screenshot-comparison-comment.yml b/.github/workflows/screenshot-comparison-comment.yml new file mode 100644 index 00000000..99a6ac12 --- /dev/null +++ b/.github/workflows/screenshot-comparison-comment.yml @@ -0,0 +1,131 @@ +name: Screenshot comparison comment + +# workflow_run 으로 도는 이유: 코멘트에는 pull-requests/contents 쓰기 권한이 필요한데, +# PR이 올린 코드를 빌드하는 워크플로에 그 토큰을 주면 fork PR에서 탈취될 수 있다. +# 여기서는 빌드하지 않고 산출물만 읽으므로 쓰기 권한과 PR 코드가 같은 잡에 놓이지 않는다. +on: + workflow_run: + workflows: + - Screenshot comparison + types: + - completed + +# workflow_run.id 는 실행마다 달라 같은 PR 의 코멘트 잡이 겹칠 수 있다. 앞선 실행이 늦게 +# 끝나면 오래된 diff 로 최신 코멘트와 컴패니언 브랜치를 덮어쓴다. PR 을 가리키는 고정 키를 +# 쓴다 (PR 번호는 잡 안에서 아티팩트를 읽어야 나오므로 여기서는 쓸 수 없다). +concurrency: + group: >- + screenshot-comparison-comment-${{ github.event.workflow_run.head_repository.full_name }}-${{ + github.event.workflow_run.head_branch }} + 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@v7 + + - uses: dawidd6/action-download-artifact@v24 + 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" + # STATUS 는 "diff 가 없음" 과 "기준선이 없어 비교 자체를 못 함" 을 가른다. + status=$(tr -dc 'a-z-' < STATUS | head -c 20) + echo "status=$status" >> "$GITHUB_OUTPUT" + + - 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 }} + path: screenshot-diff-reports + + # 컴패니언 브랜치를 만들기 전에 돌린다. git rm -rf 로 스크립트가 사라지기 때문이다. + - 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 }}" \ + "$COMPANION_BRANCH" \ + comment.md \ + files.txt + echo "count=$(tr -cd '\0' < files.txt | wc -c | 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 + # --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 }}" + git push --force origin "HEAD:$COMPANION_BRANCH" + + # diff가 사라진 PR에도 코멘트를 갱신해야 한다. 그러지 않으면 이미 고친 변경에 대한 + # 오래된 리포트가 마지막 말로 남는다. + - 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@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 }} + # 긴 본문은 프로세스 인자 목록을 넘치므로 파일로 넘긴다. + 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..e51ab796 --- /dev/null +++ b/.github/workflows/screenshot-comparison.yml @@ -0,0 +1,143 @@ +name: Screenshot comparison + +on: + pull_request: + paths: + - '**/*.kt' + - '**/*.kts' + - '**/*.toml' + - '**/src/**/res/**' + - 'gradle/wrapper/**' + - '.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@v7 + with: + persist-credentials: false + + # 골든은 base 커밋 것을 쓴다. 그래야 리포트가 "이 PR이 무엇을 바꿨는지"를 보여주고, + # 그 사이 develop 이 움직인 내용까지 섞이지 않는다. + - name: Download the goldens recorded on the base commit + id: baseline + 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: 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 + 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 + 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 + + # 시크릿을 넘기지 않는다. 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: + distribution: temurin + java-version: '17' + + - name: Configure Gradle + if: steps.goldens.outputs.available == 'true' + uses: gradle/actions/setup-gradle@v3 + + # 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() && 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 + 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/ + retention-days: 1 diff --git a/.gitignore b/.gitignore index e5b64310..7516f98b 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,10 @@ lint/tmp/ .gemini/logs/ .gemini/cache/ docs/superpowers/ + +# Roborazzi 스크린샷 (#153) +# 골든은 레포에 커밋하지 않고 GitHub Actions 아티팩트로만 보관한다. +**/build/outputs/roborazzi/ + +# OMC 세션 상태 — 도구가 만드는 파일이라 커밋하지 않는다 +.omc/ 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..8029699e --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidScreenshotConventionPlugin.kt @@ -0,0 +1,76 @@ +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) + // 생성된 테스트에 우리 설정을 끼워 넣는 유일한 훅. 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), + ) + } + } + + 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" + const val SCREENSHOT_PREVIEW_TESTER = + "com.sseotdabwa.buyornot.core.testing.ScreenshotPreviewTester" + } +} 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 35321e00..bb906043 100644 --- a/core/designsystem/build.gradle.kts +++ b/core/designsystem/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("buyornot.android.library") + id("buyornot.android.screenshot") alias(libs.plugins.kotlin.compose) } 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/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/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/resources/robolectric.properties b/core/designsystem/src/test/resources/robolectric.properties new file mode 100644 index 00000000..fc93b31a --- /dev/null +++ b/core/designsystem/src/test/resources/robolectric.properties @@ -0,0 +1,4 @@ +# 스크린샷 테스트 설정 (#153). +# Roborazzi가 생성한 프리뷰 테스트는 우리가 소유한 테스트 클래스가 아니라 @Config를 붙일 수 없다. +# 모듈 단위 설정인 이 파일이 유일한 배선 지점이다. +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..a3e538e9 --- /dev/null +++ b/core/testing/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("buyornot.android.library") +} + +android { + namespace = "com.sseotdabwa.buyornot.core.testing" +} + +// 스크린샷 테스트를 켜는 모듈들이 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) + api(libs.junit) + api(platform(libs.androidx.compose.bom)) + api(libs.androidx.compose.ui.test.junit4) +} 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/core/testing/src/main/java/com/sseotdabwa/buyornot/core/testing/ScreenshotTestApplication.kt b/core/testing/src/main/java/com/sseotdabwa/buyornot/core/testing/ScreenshotTestApplication.kt new file mode 100644 index 00000000..ba228078 --- /dev/null +++ b/core/testing/src/main/java/com/sseotdabwa/buyornot/core/testing/ScreenshotTestApplication.kt @@ -0,0 +1,60 @@ +package com.sseotdabwa.buyornot.core.testing + +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). + * + * 프리뷰는 core:designsystem 의 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/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/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/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/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/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/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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 81dd6b8f..582d94c8 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,12 @@ 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" } +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" } chucker-no-op = { group = "com.github.chuckerteam.chucker", name = "library-no-op", version.ref = "chucker" } @@ -170,3 +182,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" } 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")