Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ae6f188
feat: StorageScrapFragment Compose 마이그레이션 및 ViewModel 테스트 추가
unam98 Jun 24, 2026
cab9c8a
ci: PR CI에 unit test 실행 단계 추가 및 트리거 확대
unam98 Jun 24, 2026
2321b18
fix: CI JDK 버전을 21로 맞춤 (gradle-daemon-jvm.properties와 일치)
unam98 Jun 24, 2026
a807d66
fix: CodeRabbit이 발견한 Compose 포팅 회귀 2건 수정
unam98 Jun 25, 2026
8d786bc
test: StorageScrapScreen Compose UI test 3건 추가
unam98 Jun 25, 2026
135edb2
ci: Compose UI test(androidTest)를 에뮬레이터로 자동 실행하는 잡 추가
unam98 Jun 25, 2026
aa42315
fix: 스크랩 해제(하트탭) 시 누락된 로딩 피드백 복구
unam98 Jun 25, 2026
bd95fd5
refactor: 로딩 상태 결합 로직을 순수 함수로 분리 + 회귀 방지 테스트 추가
unam98 Jun 25, 2026
d47f44c
fix: CI 에뮬레이터 잡을 macos-latest(Apple Silicon)에 맞춰 arm64-v8a로 수정
unam98 Jun 25, 2026
f41f46b
fix: 안드로이드 에뮬레이터 CI 잡을 ubuntu-latest+KVM으로 전환
unam98 Jun 25, 2026
fe30826
fix: ExampleInstrumentedTest의 하드코딩된 패키지명 오타 수정
unam98 Jun 25, 2026
f780884
ci: 테스트별 성공/실패를 PR 체크에서 바로 확인하도록 test-reporter 추가
unam98 Jun 25, 2026
a8c2938
fix: test-reporter 체크런 생성에 필요한 actions:read 권한 추가
unam98 Jun 25, 2026
484725a
fix: dorny/test-reporter를 v3로 올려 체크런 대신 잡 서머리에만 찍히던 문제 해결
unam98 Jun 25, 2026
5f5a0fd
ci: dorny/test-reporter 대신 자체 스크립트로 테스트 결과를 PR 코멘트에 게시
unam98 Jun 25, 2026
dfdfd45
fix: 테스트 결과 코멘트에서 testcase의 classname으로 그루핑하도록 수정
unam98 Jun 25, 2026
b9a66c4
feat: 테스트 결과 코멘트의 각 테스트에 고정 앵커 추가
unam98 Jun 25, 2026
15c93d5
ci: 테스트 실패 시에만 PR 코멘트 게시, 통과 시 코멘트 삭제
unam98 Jun 26, 2026
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
48 changes: 48 additions & 0 deletions .github/scripts/post_test_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Parse JUnit XML test results and print failed tests to stdout.

Prints nothing when all tests pass. Prints only failed tests when failures exist,
so the caller can decide whether to post a PR comment based on whether stdout is non-empty.
"""
import glob
import sys
import xml.etree.ElementTree as ET


def main() -> None:
title = sys.argv[1]
patterns = sys.argv[2:]

paths = []
for pattern in patterns:
paths.extend(sorted(glob.glob(pattern, recursive=True)))

if not paths:
return

failed_by_class: dict[str, list[str]] = {}
total = failed = 0

for path in paths:
root = ET.parse(path).getroot()
for case in root.findall("testcase"):
total += 1
if case.find("failure") is not None or case.find("error") is not None:
failed += 1
classname = case.get("classname", root.get("name", path))
failed_by_class.setdefault(classname, []).append(
f"- ❌ {case.get('name')}"
)

if failed == 0:
return

print(f"## ❌ {title} — {total}개 중 {failed}개 실패\n")
for classname in sorted(failed_by_class):
print(f"### {classname}")
print("\n".join(failed_by_class[classname]))
print()


if __name__ == "__main__":
main()
168 changes: 163 additions & 5 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@ on:
branches: [ develop ]

pull_request:
branches: [ develop ]


permissions:
contents: read
actions: read
checks: write
pull-requests: write

defaults:
run:
shell: bash
Expand All @@ -20,10 +25,10 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

- name: set up JDK 17
- name: set up JDK 21
uses: actions/setup-java@v4

@coderabbitai coderabbitai Bot Jun 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

git ls-files .github/workflows/CI.yml app/build.gradle

Repository: Runnect/Runnect-Android

Length of output: 203


🏁 Script executed:

sed -n '1,140p' .github/workflows/CI.yml

Repository: Runnect/Runnect-Android

Length of output: 4364


🏁 Script executed:

sed -n '1,220p' app/build.gradle

Repository: Runnect/Runnect-Android

Length of output: 6577


Pin actions/setup-java to a commit SHA

actions/setup-java@v4 is still a mutable tag. Pin it to the exact commit SHA your workflow allows instead of using a floating version.

🧰 Tools
🪛 zizmor (1.26.1)

[error] 23-23: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/CI.yml at line 23, The workflow uses a floating tag for
actions/setup-java, which should be pinned to an immutable commit SHA. Update
the setup-java step in the CI workflow to use the exact approved SHA instead of
actions/setup-java@v4. Keep the change scoped to the actions/setup-java
reference so the workflow remains functionally the same while using a fixed
revision.

Source: Linters/SAST tools

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

좋은 제안입니다. CI 워크플로 전체에 영향 주는 변경이라 이번 PR 범위 밖으로 보고 별도 이슈로 분리하겠습니다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@unam98 이해했습니다. 이번 PR 범위에서는 분리하는 판단이 합리적입니다.

원하시면 제가 후속 GitHub 이슈로 정리해 드릴게요. actions/setup-java를 고정 SHA로 pinning 하는 작업 범위와 수용 기준까지 포함해서 작성할 수 있습니다.

(•ᵕ•)🐇

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

네, 부탁드립니다. 이슈로 열어주세요.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="Runnect/Runnect-Android"
requester="unam98"
pr_url="https://github.com/Runnect/Runnect-Android/pull/392"
comment_url="https://github.com/Runnect/Runnect-Android/pull/392#discussion_r3469158477"

title="Pin actions/setup-java to an immutable commit SHA in CI workflow"

body_file="$(mktemp)"
cat > "$body_file" <<EOF
## Summary
Update the CI workflow to pin \`actions/setup-java\` to an immutable commit SHA instead of using the floating tag \`actions/setup-java@v4\`.

## Why
The current workflow uses a mutable action reference in \`.github/workflows/CI.yml\`. Pinning the action to an exact commit SHA improves supply-chain security and aligns the workflow with stricter CI/SAST policies that require immutable action references.

## Affected area
- \`.github/workflows/CI.yml\`

## Required change
Replace the current \`uses: actions/setup-java@v4\` reference with the exact approved commit SHA for the same action revision, keeping the step behavior otherwise unchanged.

## Acceptance criteria
- \`.github/workflows/CI.yml\` no longer uses a floating tag for \`actions/setup-java\`
- The workflow references \`actions/setup-java\` using a full commit SHA
- The JDK configuration remains functionally the same (Java 21, Temurin, Gradle cache)
- CI continues to run successfully after the change

## References
- Follow-up from PR `#392`: $pr_url
- Review discussion: $comment_url

## Requested by
- @$requester
EOF

issue_url="$(
  gh issue create \
    --repo "$repo" \
    --title "$title" \
    --body-file "$body_file" \
    --assignee "$requester"
)"

echo "$issue_url"

Length of output: 1806



@unam98 후속 이슈 생성해두었습니다: #393

필요하시면 이어서 이 PR의 해당 리뷰 코멘트도 제가 정리해서 마무리 방향 제안드릴게요.

with:
java-version: 17
java-version: 21
distribution: temurin
cache: gradle

Expand Down Expand Up @@ -99,5 +104,158 @@ jobs:
- name: Change gradlew permissions
run: chmod +x ./gradlew

- name: Build
- name: Run unit tests
run: ./gradlew testDebugUnitTest --stacktrace

- name: Comment unit test failures on PR
if: always() && github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
MARKER="<!-- unit-test-report -->"
REPORT="$(python3 .github/scripts/post_test_report.py 'Unit Test Results' 'app/build/test-results/testDebugUnitTest/*.xml')"
existing_id=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" | head -1)
if [ -n "$REPORT" ]; then
BODY="$MARKER"$'\n'"$REPORT"
if [ -n "$existing_id" ]; then
gh api "repos/$REPO/issues/comments/$existing_id" -X PATCH -f body="$BODY"
else
gh api "repos/$REPO/issues/$PR_NUMBER/comments" -X POST -f body="$BODY"
fi
elif [ -n "$existing_id" ]; then
gh api "repos/$REPO/issues/comments/$existing_id" -X DELETE
fi

- name: Build
run: ./gradlew assembleDebug --stacktrace

android-test:
name: Run Compose UI Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: set up JDK 21
uses: actions/setup-java@v4
with:
java-version: 21
distribution: temurin
cache: gradle

- name: Write compile time google-services.json file
env:
GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
run: echo $GOOGLE_SERVICES_JSON > app/google-services.json

- name: Touch local properties
run: touch local.properties

- name: Access NAVER_CLIENT_ID
env:
NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }}
run: echo "NAVER_CLIENT_ID = \"$NAVER_CLIENT_ID\"" >> local.properties

- name: Access RUNNECT_NODE_URL
env:
RUNNECT_NODE_URL: ${{ secrets.RUNNECT_NODE_URL }}
run: echo "RUNNECT_NODE_URL=\"$RUNNECT_NODE_URL\"" >> local.properties

- name: Access RUNNECT_DEV_URL
env:
RUNNECT_DEV_URL: ${{ secrets.RUNNECT_DEV_URL }}
run: echo "RUNNECT_DEV_URL=\"$RUNNECT_DEV_URL\"" >> local.properties

- name: Access RUNNECT_PROD_URL
env:
RUNNECT_PROD_URL: ${{ secrets.RUNNECT_PROD_URL }}
run: echo "RUNNECT_PROD_URL=\"$RUNNECT_PROD_URL\"" >> local.properties

- name: Access TMAP_BASE_URL
env:
TMAP_BASE_URL: ${{ secrets.TMAP_BASE_URL }}
run: echo "TMAP_BASE_URL=\"$TMAP_BASE_URL\"" >> local.properties

- name: Access TMAP_API_KEY
env:
TMAP_API_KEY: ${{ secrets.TMAP_API_KEY }}
run: echo "TMAP_API_KEY=\"$TMAP_API_KEY\"" >> local.properties

- name: Access GOOGLE_CLIENT_ID
env:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
run: echo "GOOGLE_CLIENT_ID=\"$GOOGLE_CLIENT_ID\"" >> local.properties

- name: Access REMOTE_KEY_APP_VERSION
env:
REMOTE_KEY_APP_VERSION: ${{ secrets.REMOTE_KEY_APP_VERSION }}
run: echo "REMOTE_KEY_APP_VERSION=\"$REMOTE_KEY_APP_VERSION\"" >> local.properties

- name: Access KAKAO_CHANNEL_ID
env:
KAKAO_CHANNEL_ID: ${{ secrets.KAKAO_CHANNEL_ID }}
run: echo "KAKAO_CHANNEL_ID=\"$KAKAO_CHANNEL_ID\"" >> local.properties

- name: Access REMOTE_KEY_FORCE_UPDATE
env:
REMOTE_KEY_FORCE_UPDATE: ${{ secrets.REMOTE_KEY_FORCE_UPDATE }}
run: echo "REMOTE_KEY_FORCE_UPDATE=\"$REMOTE_KEY_FORCE_UPDATE\"" >> local.properties

- name: Add kakao_strings.xml
env:
KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }}
KAKAO_REDIRECTION_SCHEME: ${{ secrets.KAKAO_REDIRECTION_SCHEME }}
run: |
echo '<?xml version="1.0" encoding="utf-8"?>' > app/src/main/res/values/kakao_strings.xml
echo '<resources>' >> app/src/main/res/values/kakao_strings.xml
echo ' <string name="kakao_native_app_key">$KAKAO_NATIVE_APP_KEY</string>' >> app/src/main/res/values/kakao_strings.xml
echo ' <string name="kakao_redirection_scheme">$KAKAO_REDIRECTION_SCHEME</string>' >> app/src/main/res/values/kakao_strings.xml
echo '</resources>' >> app/src/main/res/values/kakao_strings.xml

- name: Change gradlew permissions
run: chmod +x ./gradlew

- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: Run Compose UI tests on emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: google_apis
arch: x86_64
profile: pixel_6
script: ./gradlew connectedDebugAndroidTest --stacktrace

- name: Comment Compose UI test failures on PR
if: always() && github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
MARKER="<!-- compose-ui-test-report -->"
REPORT="$(python3 .github/scripts/post_test_report.py 'Compose UI Test Results' 'app/build/outputs/androidTest-results/connected/**/*.xml')"
existing_id=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" | head -1)
if [ -n "$REPORT" ]; then
BODY="$MARKER"$'\n'"$REPORT"
if [ -n "$existing_id" ]; then
gh api "repos/$REPO/issues/comments/$existing_id" -X PATCH -f body="$BODY"
else
gh api "repos/$REPO/issues/$PR_NUMBER/comments" -X POST -f body="$BODY"
fi
elif [ -n "$existing_id" ]; then
gh api "repos/$REPO/issues/comments/$existing_id" -X DELETE
fi

- name: Upload instrumented test results
if: always()
uses: actions/upload-artifact@v4
with:
name: android-test-results
path: app/build/outputs/androidTest-results/connected/**
7 changes: 7 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ android {
}
}

tasks.withType(Test).configureEach {
testLogging {
events "passed", "skipped", "failed"
}
}

tasks.register('renameReleaseBundle') {
def vName = libs.versions.versionName.get()
def bundlePath = layout.buildDirectory.dir("outputs/bundle/release")
Expand Down Expand Up @@ -191,6 +197,7 @@ dependencies {
testImplementation libs.mockk
testImplementation libs.turbine
testImplementation libs.kotlinx.coroutines.test
testImplementation libs.androidx.core.testing
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espresso.core
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.runnect", appContext.packageName)
assertEquals("com.runnect.runnect", appContext.packageName)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.runnect.runnect.presentation.storage

import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasProgressBarRangeInfo
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.runnect.runnect.domain.entity.MyScrapCourse
import com.runnect.runnect.presentation.ui.theme.RunnectTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test

class StorageScrapScreenTest {

@get:Rule
val composeTestRule = createComposeRule()

private fun course(title: String) = MyScrapCourse(
courseId = 1,
id = 1,
publicCourseId = 10,
image = null,
city = "서울",
region = "강남",
title = title
)

@Test
fun `카드탭_시_onScrapItemClick이_해당_코스로_호출된다`() {
val testCourse = course(title = "테스트 코스")
var clicked: MyScrapCourse? = null

composeTestRule.setContent {
RunnectTheme {
StorageScrapScreen(
state = StorageScrapUiState(courses = listOf(testCourse)),
onRefresh = {},
onScrapItemClick = { clicked = it },
onHeartClick = {},
onGoToScrapClick = {},
onErrorShown = {}
)
}
}

composeTestRule.onNodeWithText("테스트 코스").performClick()

assertEquals(testCourse, clicked)
}

@Test
fun `로딩_중에는_스크랩이_없어도_빈_화면이_보이지_않는다`() {
composeTestRule.setContent {
RunnectTheme {
StorageScrapScreen(
state = StorageScrapUiState(courses = emptyList(), isLoading = true),
onRefresh = {},
onScrapItemClick = {},
onHeartClick = {},
onGoToScrapClick = {},
onErrorShown = {}
)
}
}

composeTestRule.onNodeWithText("아직 스크랩한 코스가 없어요", substring = true)
.assertDoesNotExist()
}

@Test
fun `로딩이_끝나고_스크랩이_없으면_빈_화면이_보인다`() {
composeTestRule.setContent {
RunnectTheme {
StorageScrapScreen(
state = StorageScrapUiState(courses = emptyList(), isLoading = false),
onRefresh = {},
onScrapItemClick = {},
onHeartClick = {},
onGoToScrapClick = {},
onErrorShown = {}
)
}
}

composeTestRule.onNodeWithText("아직 스크랩한 코스가 없어요", substring = true)
.assertIsDisplayed()
}

@Test
fun `isLoading이_true이면_로딩_인디케이터가_보인다`() {
composeTestRule.setContent {
RunnectTheme {
StorageScrapScreen(
state = StorageScrapUiState(courses = listOf(course("코스")), isLoading = true),
onRefresh = {},
onScrapItemClick = {},
onHeartClick = {},
onGoToScrapClick = {},
onErrorShown = {}
)
}
}

composeTestRule.onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate))
.assertIsDisplayed()
}
}
Loading
Loading