Skip to content

예약 테스트: 확인 버튼 선택자 수정 #40

예약 테스트: 확인 버튼 선택자 수정

예약 테스트: 확인 버튼 선택자 수정 #40

Workflow file for this run

name: App Tests (Android & iOS)
on:
push:
branches: [main, staging]
paths:
- 'fastfive-app/**'
- '.github/workflows/app-test.yml'
pull_request:
branches: [main, staging]
paths:
- 'fastfive-app/**'
workflow_dispatch:
inputs:
platform:
description: 'Test platform'
required: true
default: 'both'
type: choice
options:
- both
- android
- ios
permissions:
contents: write
pages: write
id-token: write
jobs:
test-android:
if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.platform == 'both' || github.event.inputs.platform == 'android' }}
runs-on: [self-hosted, macOS, android]
environment: ${{ github.ref_name == 'main' && 'production' || 'staging' }}
env:
TZ: Asia/Seoul
PLATFORM: android
steps:
- uses: actions/checkout@v4
- name: Set environment info
run: |
echo "Branch: ${{ github.ref_name }}"
echo "Environment: ${{ github.ref_name == 'main' && 'production' || 'staging' }}"
echo "Platform: Android"
- name: Check connected device
run: |
adb devices
adb wait-for-device
echo "Android device connected!"
- name: Check and install APK if needed
run: |
APP_PACKAGE="com.fastfive.work.staging"
APK_PATH="/Users/sbhwang602-fastfive/workspace/auto-test/cms-userapp-v3/android/app/build/outputs/apk/releaseStaging/app-releaseStaging.apk"
if adb shell pm list packages | grep -q "$APP_PACKAGE"; then
echo "App already installed, skipping installation"
else
echo "App not installed, installing from: $APK_PATH"
adb install -r "$APK_PATH"
echo "APK installed"
fi
- name: Start Appium and run tests
id: test
env:
APP_TEST_EMAIL: ${{ vars.APP_TEST_EMAIL }}
APP_TEST_PASSWORD: ${{ secrets.APP_TEST_PASSWORD }}
run: |
set -o pipefail
# Kill existing Appium process if any
pkill -f "appium" || true
sleep 2
# Start Appium
appium --address 127.0.0.1 --port 4723 --base-path /wd/hub &
APPIUM_PID=$!
sleep 10
# Create artifacts directory
mkdir -p artifacts
# Run tests
cd fastfive-app
python3 app_suite_runner.py 2>&1 | tee ../artifacts/test-output.log
TEST_EXIT_CODE=${PIPESTATUS[0]}
# Save exit code
echo $TEST_EXIT_CODE > ../artifacts/exit-code.txt
# Stop Appium
kill $APPIUM_PID 2>/dev/null || true
exit $TEST_EXIT_CODE
- name: Get test result
if: always()
id: result
run: |
if [ -f artifacts/exit-code.txt ]; then
EXIT_CODE=$(cat artifacts/exit-code.txt)
if [ "$EXIT_CODE" = "0" ]; then
echo "status=✅ 성공" >> $GITHUB_OUTPUT
echo "status_class=success" >> $GITHUB_OUTPUT
else
echo "status=❌ 실패" >> $GITHUB_OUTPUT
echo "status_class=failure" >> $GITHUB_OUTPUT
fi
else
echo "status=❌ 실패" >> $GITHUB_OUTPUT
echo "status_class=failure" >> $GITHUB_OUTPUT
fi
echo "timestamp=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
echo "run_id=${{ github.run_id }}" >> $GITHUB_OUTPUT
- name: Download previous gh-pages
if: always()
run: |
git fetch origin gh-pages:gh-pages || true
mkdir -p gh-pages-history
git checkout gh-pages -- . 2>/dev/null || true
mv app-production gh-pages-history/ 2>/dev/null || true
mv app-staging gh-pages-history/ 2>/dev/null || true
mv app-index.html gh-pages-history/ 2>/dev/null || true
git checkout ${{ github.ref_name }} -- .
- name: Prepare test report
if: always()
run: |
RUN_ID="${{ github.run_id }}"
TIMESTAMP="${{ steps.result.outputs.timestamp }}"
STATUS="${{ steps.result.outputs.status }}"
STATUS_CLASS="${{ steps.result.outputs.status_class }}"
BRANCH="${{ github.ref_name }}"
COMMIT="${{ github.sha }}"
SHORT_COMMIT="${COMMIT:0:7}"
PLATFORM_NAME="android"
if [ "$BRANCH" = "main" ]; then
ENV_NAME="app-production"
ENV_LABEL="운영"
else
ENV_NAME="app-staging"
ENV_LABEL="스테이징"
fi
mkdir -p gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}
cp -r artifacts/* gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/ 2>/dev/null || true
echo "$ENV_NAME" > gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/env.txt
echo "$BRANCH" > gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/branch.txt
echo "$TIMESTAMP" > gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/timestamp.txt
echo "$PLATFORM_NAME" > gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/platform.txt
cat > gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html << 'REPORT_EOF'
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>APP 테스트 결과 - RUN_ID_PLACEHOLDER (PLATFORM_PLACEHOLDER)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; }
.header { background: white; padding: 30px; border-radius: 10px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.header h1 { font-size: 24px; margin-bottom: 15px; }
.meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; }
.meta-item { padding: 10px; background: #f8f9fa; border-radius: 5px; }
.meta-item label { font-size: 12px; color: #666; display: block; }
.meta-item span { font-size: 14px; font-weight: 500; }
.status-badge { display: inline-block; padding: 5px 15px; border-radius: 20px; font-weight: bold; }
.status-badge.success { background: #d4edda; color: #155724; }
.status-badge.failure { background: #f8d7da; color: #721c24; }
.platform-badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; margin-left: 10px; }
.platform-badge.android { background: #a4c639; color: white; }
.platform-badge.ios { background: #007aff; color: white; }
.section { background: white; padding: 20px; border-radius: 10px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.section h2 { font-size: 18px; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #eee; }
.log-content { background: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: 5px; font-family: monospace; font-size: 13px; overflow-x: auto; white-space: pre-wrap; max-height: 500px; overflow-y: auto; }
.back-link { display: inline-block; margin-bottom: 20px; color: #007bff; text-decoration: none; }
.screenshots { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px; }
.screenshot { border: 1px solid #ddd; border-radius: 5px; overflow: hidden; }
.screenshot img { width: 100%; }
.screenshot p { padding: 10px; background: #f8f9fa; font-size: 12px; }
.test-results { border: 1px solid #eee; border-radius: 8px; overflow: hidden; }
.test-item { display: flex; align-items: center; padding: 15px; border-bottom: 1px solid #eee; gap: 15px; }
.test-item:last-child { border-bottom: none; }
.test-item.success { background: #f8fff8; }
.test-item.failure, .test-item.error { background: #fff8f8; }
.test-icon { font-size: 20px; }
.test-info { flex: 1; }
.test-name { font-weight: 600; font-size: 14px; }
.test-message { font-size: 12px; color: #c00; margin-top: 4px; word-break: break-all; }
.test-duration { font-size: 12px; color: #888; }
.test-traceback { font-size: 11px; color: #666; margin-top: 8px; background: #f8f8f8; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; max-height: 200px; overflow-y: auto; }
.test-screenshot img { max-width: 300px; border: 1px solid #ddd; border-radius: 4px; margin-top: 10px; }
</style>
</head>
<body>
<div class="container">
<a href="../../app-index.html" class="back-link">← 전체 히스토리로 돌아가기</a>
<div class="header">
<h1>📱 APP 테스트 결과 <span class="status-badge STATUS_CLASS_PLACEHOLDER">STATUS_PLACEHOLDER</span><span class="platform-badge PLATFORM_PLACEHOLDER">PLATFORM_LABEL_PLACEHOLDER</span></h1>
<div class="meta">
<div class="meta-item"><label>Run ID</label><span>RUN_ID_PLACEHOLDER</span></div>
<div class="meta-item"><label>환경</label><span>ENV_LABEL_PLACEHOLDER</span></div>
<div class="meta-item"><label>플랫폼</label><span>PLATFORM_LABEL_PLACEHOLDER</span></div>
<div class="meta-item"><label>Branch</label><span>BRANCH_PLACEHOLDER</span></div>
<div class="meta-item"><label>Commit</label><span>COMMIT_PLACEHOLDER</span></div>
<div class="meta-item"><label>실행 시간</label><span>TIMESTAMP_PLACEHOLDER</span></div>
</div>
</div>
<div class="section">
<h2>🧪 테스트별 결과</h2>
<div id="test-summary" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin-bottom:15px;"></div>
<div class="test-results" id="test-results"><p style="padding:20px;color:#666;">로딩중...</p></div>
</div>
<div class="section">
<h2>📋 테스트 로그</h2>
<pre class="log-content" id="log-content">로딩중...</pre>
</div>
<div class="section">
<h2>📸 스크린샷</h2>
<div class="screenshots" id="screenshots"></div>
</div>
</div>
<script>
fetch('test-results.json').then(r => r.json()).then(results => {
const container = document.getElementById('test-results');
const summary = document.getElementById('test-summary');
if (!results || results.length === 0) { container.innerHTML = '<p style="padding:20px;">결과 없음</p>'; return; }
var total = results.length, success = results.filter(r => r.status === 'success').length;
var failure = total - success, rate = total > 0 ? Math.round((success/total)*100) : 0;
var totalSec = results.reduce((s,r) => s + (r.duration||0), 0);
var h = Math.floor(totalSec/3600), m = Math.floor((totalSec%3600)/60), s = Math.floor(totalSec%60);
var dur = (h>0?h+'시간 ':'') + (m>0?m+'분 ':'') + s+'초';
summary.innerHTML = '<div style="padding:15px;border-radius:8px;text-align:center;background:#e3f2fd;"><div style="font-size:28px;font-weight:bold;color:#1565c0;">'+total+'</div><div style="font-size:12px;color:#666;">전체</div></div><div style="padding:15px;border-radius:8px;text-align:center;background:#e8f5e9;"><div style="font-size:28px;font-weight:bold;color:#2e7d32;">'+success+'</div><div style="font-size:12px;color:#666;">성공</div></div><div style="padding:15px;border-radius:8px;text-align:center;background:#ffebee;"><div style="font-size:28px;font-weight:bold;color:#c62828;">'+failure+'</div><div style="font-size:12px;color:#666;">실패</div></div><div style="padding:15px;border-radius:8px;text-align:center;background:#fff3e0;"><div style="font-size:28px;font-weight:bold;color:#ef6c00;">'+rate+'%</div><div style="font-size:12px;color:#666;">성공률</div></div><div style="padding:15px;border-radius:8px;text-align:center;background:#f3e5f5;"><div style="font-size:20px;font-weight:bold;color:#7b1fa2;">'+dur+'</div><div style="font-size:12px;color:#666;">수행시간</div></div>';
container.innerHTML = results.map(r => {
var icon = r.status==='success'?'✅':'❌';
var msg = r.message?'<div class="test-message">'+r.message+'</div>':'';
var trace = r.traceback?'<div class="test-traceback">'+r.traceback.replace(/</g,'&lt;')+'</div>':'';
var shot = r.screenshot?'<div class="test-screenshot"><a href="'+r.screenshot+'" target="_blank"><img src="'+r.screenshot+'"></a></div>':'';
return '<div class="test-item '+r.status+'"><span class="test-icon">'+icon+'</span><div class="test-info"><div class="test-name">'+r.name+'</div>'+msg+trace+shot+'</div><span class="test-duration">'+r.duration+'s</span></div>';
}).join('');
}).catch(() => { document.getElementById('test-results').innerHTML = '<p style="padding:20px;">결과를 불러올 수 없습니다.</p>'; });
fetch('test-output.log').then(r => r.text()).then(t => { document.getElementById('log-content').textContent = t || '로그 없음'; }).catch(() => { document.getElementById('log-content').textContent = '로그를 불러올 수 없습니다.'; });
const pngs = SCREENSHOTS_PLACEHOLDER;
const sc = document.getElementById('screenshots');
if (pngs.length === 0) { sc.innerHTML = '<p style="color:#666;">스크린샷이 없습니다.</p>'; }
else { pngs.forEach(p => { sc.innerHTML += '<div class="screenshot"><a href="'+p+'" target="_blank"><img src="'+p+'"></a><p>'+p+'</p></div>'; }); }
</script>
</body>
</html>
REPORT_EOF
SCREENSHOTS_JSON="["
FIRST=true
for png in $(ls artifacts/*.png 2>/dev/null | xargs -n1 basename 2>/dev/null); do
[ "$FIRST" = true ] && FIRST=false || SCREENSHOTS_JSON+=","
SCREENSHOTS_JSON+="\"$png\""
done
SCREENSHOTS_JSON+="]"
PLATFORM_LABEL="Android"
sed -i '' "s/RUN_ID_PLACEHOLDER/$RUN_ID/g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
sed -i '' "s/STATUS_PLACEHOLDER/$STATUS/g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
sed -i '' "s/STATUS_CLASS_PLACEHOLDER/$STATUS_CLASS/g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
sed -i '' "s/ENV_LABEL_PLACEHOLDER/$ENV_LABEL/g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
sed -i '' "s/PLATFORM_PLACEHOLDER/$PLATFORM_NAME/g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
sed -i '' "s/PLATFORM_LABEL_PLACEHOLDER/$PLATFORM_LABEL/g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
sed -i '' "s/BRANCH_PLACEHOLDER/$BRANCH/g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
sed -i '' "s/COMMIT_PLACEHOLDER/$SHORT_COMMIT/g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
sed -i '' "s/TIMESTAMP_PLACEHOLDER/$TIMESTAMP/g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
sed -i '' "s|SCREENSHOTS_PLACEHOLDER|$SCREENSHOTS_JSON|g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
- name: Generate app index page
if: always()
run: |
cd gh-pages-history
collect_history() {
local env_name=$1
local json="["
local first=true
for dir in $(ls -dt ${env_name}/*/ 2>/dev/null | head -50); do
RUN_ID=$(basename $dir)
if [ -f "$dir/exit-code.txt" ]; then
EXIT_CODE=$(cat "$dir/exit-code.txt")
if [ "$EXIT_CODE" = "0" ]; then
STATUS="✅ 성공"
STATUS_CLASS="success"
else
STATUS="❌ 실패"
STATUS_CLASS="failure"
fi
else
STATUS="❌ 실패"
STATUS_CLASS="failure"
fi
[ -f "$dir/timestamp.txt" ] && TIMESTAMP=$(cat "$dir/timestamp.txt") || TIMESTAMP="Unknown"
[ -f "$dir/platform.txt" ] && PLATFORM=$(cat "$dir/platform.txt") || PLATFORM="android"
[ "$first" = true ] && first=false || json+=","
json+="{\"run_id\":\"$RUN_ID\",\"status\":\"$STATUS\",\"status_class\":\"$STATUS_CLASS\",\"timestamp\":\"$TIMESTAMP\",\"platform\":\"$PLATFORM\"}"
done
json+="]"
echo "$json"
}
PROD_HISTORY=$(collect_history "app-production")
STAGING_HISTORY=$(collect_history "app-staging")
cat > app-index.html << 'INDEX_EOF'
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FASTFIVE APP 자동화 테스트</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; min-height: 100vh; }
.navbar { background: #1a73e8; color: white; padding: 15px 20px; display: flex; align-items: center; gap: 15px; }
.navbar h1 { font-size: 20px; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.tabs { display: flex; gap: 0; margin-bottom: 20px; background: white; border-radius: 10px; overflow: hidden; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.tab { flex: 1; padding: 15px 20px; text-align: center; cursor: pointer; border: none; background: white; font-size: 16px; font-weight: 500; transition: all 0.2s; border-bottom: 3px solid transparent; }
.tab:hover { background: #f8f9fa; }
.tab.active { border-bottom-color: #1a73e8; color: #1a73e8; background: #e8f0fe; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; margin-bottom: 30px; }
.stat-card { background: white; padding: 20px; border-radius: 10px; text-align: center; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.stat-card .number { font-size: 36px; font-weight: bold; }
.stat-card .label { color: #666; margin-top: 5px; }
.stat-card.success .number { color: #28a745; }
.stat-card.failure .number { color: #dc3545; }
.history-table { background: white; border-radius: 10px; overflow: hidden; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.history-table table { width: 100%; border-collapse: collapse; }
.history-table th, .history-table td { padding: 15px; text-align: left; border-bottom: 1px solid #eee; }
.history-table th { background: #f8f9fa; font-weight: 600; }
.history-table tr:hover { background: #f8f9fa; }
.status-badge { display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 12px; }
.status-badge.success { background: #d4edda; color: #155724; }
.status-badge.failure { background: #f8d7da; color: #721c24; }
.platform-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
.platform-badge.android { background: #a4c639; color: white; }
.platform-badge.ios { background: #007aff; color: white; }
.view-link { color: #1a73e8; text-decoration: none; }
.tab-content { display: none; }
.tab-content.active { display: block; }
</style>
</head>
<body>
<nav class="navbar"><h1>📱 FASTFIVE APP 자동화 테스트</h1></nav>
<div class="container">
<div class="tabs">
<button class="tab active" data-env="production">🔴 운영 (Production)</button>
<button class="tab" data-env="staging">🟡 스테이징 (Staging)</button>
</div>
<div id="production-content" class="tab-content active">
<div class="stats" id="production-stats"></div>
<div class="history-table"><table><thead><tr><th>Run ID</th><th>플랫폼</th><th>상태</th><th>실행 시간</th><th>상세</th></tr></thead><tbody id="production-body"></tbody></table></div>
</div>
<div id="staging-content" class="tab-content">
<div class="stats" id="staging-stats"></div>
<div class="history-table"><table><thead><tr><th>Run ID</th><th>플랫폼</th><th>상태</th><th>실행 시간</th><th>상세</th></tr></thead><tbody id="staging-body"></tbody></table></div>
</div>
</div>
<script>
const prodHistory = PRODUCTION_DATA_PLACEHOLDER;
const stagingHistory = STAGING_DATA_PLACEHOLDER;
function renderStats(h, id) {
var t=h.length, s=h.filter(x=>x.status_class==='success').length, f=t-s, r=t>0?Math.round((s/t)*100):0;
document.getElementById(id).innerHTML = '<div class="stat-card"><div class="number">'+t+'</div><div class="label">전체</div></div><div class="stat-card success"><div class="number">'+s+'</div><div class="label">성공</div></div><div class="stat-card failure"><div class="number">'+f+'</div><div class="label">실패</div></div><div class="stat-card"><div class="number">'+r+'%</div><div class="label">성공률</div></div>';
}
function renderTable(h, id, env) {
var b = document.getElementById(id);
if (h.length === 0) { b.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:40px;color:#666;">히스토리가 없습니다.</td></tr>'; return; }
b.innerHTML = h.map(x => {
var platformLabel = x.platform === 'ios' ? 'iOS' : 'Android';
var platformClass = x.platform || 'android';
return '<tr><td><code>'+x.run_id+'</code></td><td><span class="platform-badge '+platformClass+'">'+platformLabel+'</span></td><td><span class="status-badge '+x.status_class+'">'+x.status+'</span></td><td>'+x.timestamp+'</td><td><a href="app-'+env+'/'+x.run_id+'/" class="view-link">상세 보기 →</a></td></tr>';
}).join('');
}
renderStats(prodHistory, 'production-stats'); renderTable(prodHistory, 'production-body', 'production');
renderStats(stagingHistory, 'staging-stats'); renderTable(stagingHistory, 'staging-body', 'staging');
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', function() {
var e = this.dataset.env;
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(x => x.classList.remove('active'));
this.classList.add('active');
document.getElementById(e + '-content').classList.add('active');
}));
</script>
</body>
</html>
INDEX_EOF
sed -i '' "s|PRODUCTION_DATA_PLACEHOLDER|$PROD_HISTORY|g" app-index.html
sed -i '' "s|STAGING_DATA_PLACEHOLDER|$STAGING_HISTORY|g" app-index.html
- name: Deploy to GitHub Pages
if: always()
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./gh-pages-history
keep_files: true
- name: Upload artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: app-test-android-artifacts
path: artifacts
test-ios:
if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.platform == 'both' || github.event.inputs.platform == 'ios' }}
runs-on: [self-hosted, macOS, ios]
environment: ${{ github.ref_name == 'main' && 'production' || 'staging' }}
env:
TZ: Asia/Seoul
PLATFORM: ios
steps:
- uses: actions/checkout@v4
- name: Set environment info
run: |
echo "Branch: ${{ github.ref_name }}"
echo "Environment: ${{ github.ref_name == 'main' && 'production' || 'staging' }}"
echo "Platform: iOS"
- name: Check connected iOS device
run: |
xcrun xctrace list devices
echo "iOS device check completed!"
- name: Start Appium and run tests
id: test
env:
APP_IOS_TEST_EMAIL: ${{ vars.APP_IOS_TEST_EMAIL }}
APP_IOS_TEST_PASSWORD: ${{ secrets.APP_IOS_TEST_PASSWORD }}
run: |
set -o pipefail
# Kill existing Appium process if any
pkill -f "appium" || true
sleep 2
# Start Appium with XCUITest
appium --address 127.0.0.1 --port 4723 --base-path /wd/hub &
APPIUM_PID=$!
sleep 15
# Create artifacts directory
mkdir -p artifacts
# Run tests
cd fastfive-app
python3 app_suite_runner.py 2>&1 | tee ../artifacts/test-output.log
TEST_EXIT_CODE=${PIPESTATUS[0]}
# Save exit code
echo $TEST_EXIT_CODE > ../artifacts/exit-code.txt
# Stop Appium
kill $APPIUM_PID 2>/dev/null || true
exit $TEST_EXIT_CODE
- name: Get test result
if: always()
id: result
run: |
if [ -f artifacts/exit-code.txt ]; then
EXIT_CODE=$(cat artifacts/exit-code.txt)
if [ "$EXIT_CODE" = "0" ]; then
echo "status=✅ 성공" >> $GITHUB_OUTPUT
echo "status_class=success" >> $GITHUB_OUTPUT
else
echo "status=❌ 실패" >> $GITHUB_OUTPUT
echo "status_class=failure" >> $GITHUB_OUTPUT
fi
else
echo "status=❌ 실패" >> $GITHUB_OUTPUT
echo "status_class=failure" >> $GITHUB_OUTPUT
fi
echo "timestamp=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
echo "run_id=${{ github.run_id }}" >> $GITHUB_OUTPUT
- name: Download previous gh-pages
if: always()
run: |
git fetch origin gh-pages:gh-pages || true
mkdir -p gh-pages-history
git checkout gh-pages -- . 2>/dev/null || true
mv app-production gh-pages-history/ 2>/dev/null || true
mv app-staging gh-pages-history/ 2>/dev/null || true
mv app-index.html gh-pages-history/ 2>/dev/null || true
git checkout ${{ github.ref_name }} -- .
- name: Prepare test report
if: always()
run: |
RUN_ID="${{ github.run_id }}"
TIMESTAMP="${{ steps.result.outputs.timestamp }}"
STATUS="${{ steps.result.outputs.status }}"
STATUS_CLASS="${{ steps.result.outputs.status_class }}"
BRANCH="${{ github.ref_name }}"
COMMIT="${{ github.sha }}"
SHORT_COMMIT="${COMMIT:0:7}"
PLATFORM_NAME="ios"
if [ "$BRANCH" = "main" ]; then
ENV_NAME="app-production"
ENV_LABEL="운영"
else
ENV_NAME="app-staging"
ENV_LABEL="스테이징"
fi
mkdir -p gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}
cp -r artifacts/* gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/ 2>/dev/null || true
echo "$ENV_NAME" > gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/env.txt
echo "$BRANCH" > gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/branch.txt
echo "$TIMESTAMP" > gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/timestamp.txt
echo "$PLATFORM_NAME" > gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/platform.txt
cat > gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html << 'REPORT_EOF'
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>APP 테스트 결과 - RUN_ID_PLACEHOLDER (PLATFORM_PLACEHOLDER)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; }
.header { background: white; padding: 30px; border-radius: 10px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.header h1 { font-size: 24px; margin-bottom: 15px; }
.meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; }
.meta-item { padding: 10px; background: #f8f9fa; border-radius: 5px; }
.meta-item label { font-size: 12px; color: #666; display: block; }
.meta-item span { font-size: 14px; font-weight: 500; }
.status-badge { display: inline-block; padding: 5px 15px; border-radius: 20px; font-weight: bold; }
.status-badge.success { background: #d4edda; color: #155724; }
.status-badge.failure { background: #f8d7da; color: #721c24; }
.platform-badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; margin-left: 10px; }
.platform-badge.android { background: #a4c639; color: white; }
.platform-badge.ios { background: #007aff; color: white; }
.section { background: white; padding: 20px; border-radius: 10px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.section h2 { font-size: 18px; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #eee; }
.log-content { background: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: 5px; font-family: monospace; font-size: 13px; overflow-x: auto; white-space: pre-wrap; max-height: 500px; overflow-y: auto; }
.back-link { display: inline-block; margin-bottom: 20px; color: #007bff; text-decoration: none; }
.screenshots { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px; }
.screenshot { border: 1px solid #ddd; border-radius: 5px; overflow: hidden; }
.screenshot img { width: 100%; }
.screenshot p { padding: 10px; background: #f8f9fa; font-size: 12px; }
.test-results { border: 1px solid #eee; border-radius: 8px; overflow: hidden; }
.test-item { display: flex; align-items: center; padding: 15px; border-bottom: 1px solid #eee; gap: 15px; }
.test-item:last-child { border-bottom: none; }
.test-item.success { background: #f8fff8; }
.test-item.failure, .test-item.error { background: #fff8f8; }
.test-icon { font-size: 20px; }
.test-info { flex: 1; }
.test-name { font-weight: 600; font-size: 14px; }
.test-message { font-size: 12px; color: #c00; margin-top: 4px; word-break: break-all; }
.test-duration { font-size: 12px; color: #888; }
.test-traceback { font-size: 11px; color: #666; margin-top: 8px; background: #f8f8f8; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; max-height: 200px; overflow-y: auto; }
.test-screenshot img { max-width: 300px; border: 1px solid #ddd; border-radius: 4px; margin-top: 10px; }
</style>
</head>
<body>
<div class="container">
<a href="../../app-index.html" class="back-link">← 전체 히스토리로 돌아가기</a>
<div class="header">
<h1>📱 APP 테스트 결과 <span class="status-badge STATUS_CLASS_PLACEHOLDER">STATUS_PLACEHOLDER</span><span class="platform-badge PLATFORM_PLACEHOLDER">PLATFORM_LABEL_PLACEHOLDER</span></h1>
<div class="meta">
<div class="meta-item"><label>Run ID</label><span>RUN_ID_PLACEHOLDER</span></div>
<div class="meta-item"><label>환경</label><span>ENV_LABEL_PLACEHOLDER</span></div>
<div class="meta-item"><label>플랫폼</label><span>PLATFORM_LABEL_PLACEHOLDER</span></div>
<div class="meta-item"><label>Branch</label><span>BRANCH_PLACEHOLDER</span></div>
<div class="meta-item"><label>Commit</label><span>COMMIT_PLACEHOLDER</span></div>
<div class="meta-item"><label>실행 시간</label><span>TIMESTAMP_PLACEHOLDER</span></div>
</div>
</div>
<div class="section">
<h2>🧪 테스트별 결과</h2>
<div id="test-summary" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin-bottom:15px;"></div>
<div class="test-results" id="test-results"><p style="padding:20px;color:#666;">로딩중...</p></div>
</div>
<div class="section">
<h2>📋 테스트 로그</h2>
<pre class="log-content" id="log-content">로딩중...</pre>
</div>
<div class="section">
<h2>📸 스크린샷</h2>
<div class="screenshots" id="screenshots"></div>
</div>
</div>
<script>
fetch('test-results.json').then(r => r.json()).then(results => {
const container = document.getElementById('test-results');
const summary = document.getElementById('test-summary');
if (!results || results.length === 0) { container.innerHTML = '<p style="padding:20px;">결과 없음</p>'; return; }
var total = results.length, success = results.filter(r => r.status === 'success').length;
var failure = total - success, rate = total > 0 ? Math.round((success/total)*100) : 0;
var totalSec = results.reduce((s,r) => s + (r.duration||0), 0);
var h = Math.floor(totalSec/3600), m = Math.floor((totalSec%3600)/60), s = Math.floor(totalSec%60);
var dur = (h>0?h+'시간 ':'') + (m>0?m+'분 ':'') + s+'초';
summary.innerHTML = '<div style="padding:15px;border-radius:8px;text-align:center;background:#e3f2fd;"><div style="font-size:28px;font-weight:bold;color:#1565c0;">'+total+'</div><div style="font-size:12px;color:#666;">전체</div></div><div style="padding:15px;border-radius:8px;text-align:center;background:#e8f5e9;"><div style="font-size:28px;font-weight:bold;color:#2e7d32;">'+success+'</div><div style="font-size:12px;color:#666;">성공</div></div><div style="padding:15px;border-radius:8px;text-align:center;background:#ffebee;"><div style="font-size:28px;font-weight:bold;color:#c62828;">'+failure+'</div><div style="font-size:12px;color:#666;">실패</div></div><div style="padding:15px;border-radius:8px;text-align:center;background:#fff3e0;"><div style="font-size:28px;font-weight:bold;color:#ef6c00;">'+rate+'%</div><div style="font-size:12px;color:#666;">성공률</div></div><div style="padding:15px;border-radius:8px;text-align:center;background:#f3e5f5;"><div style="font-size:20px;font-weight:bold;color:#7b1fa2;">'+dur+'</div><div style="font-size:12px;color:#666;">수행시간</div></div>';
container.innerHTML = results.map(r => {
var icon = r.status==='success'?'✅':'❌';
var msg = r.message?'<div class="test-message">'+r.message+'</div>':'';
var trace = r.traceback?'<div class="test-traceback">'+r.traceback.replace(/</g,'&lt;')+'</div>':'';
var shot = r.screenshot?'<div class="test-screenshot"><a href="'+r.screenshot+'" target="_blank"><img src="'+r.screenshot+'"></a></div>':'';
return '<div class="test-item '+r.status+'"><span class="test-icon">'+icon+'</span><div class="test-info"><div class="test-name">'+r.name+'</div>'+msg+trace+shot+'</div><span class="test-duration">'+r.duration+'s</span></div>';
}).join('');
}).catch(() => { document.getElementById('test-results').innerHTML = '<p style="padding:20px;">결과를 불러올 수 없습니다.</p>'; });
fetch('test-output.log').then(r => r.text()).then(t => { document.getElementById('log-content').textContent = t || '로그 없음'; }).catch(() => { document.getElementById('log-content').textContent = '로그를 불러올 수 없습니다.'; });
const pngs = SCREENSHOTS_PLACEHOLDER;
const sc = document.getElementById('screenshots');
if (pngs.length === 0) { sc.innerHTML = '<p style="color:#666;">스크린샷이 없습니다.</p>'; }
else { pngs.forEach(p => { sc.innerHTML += '<div class="screenshot"><a href="'+p+'" target="_blank"><img src="'+p+'"></a><p>'+p+'</p></div>'; }); }
</script>
</body>
</html>
REPORT_EOF
SCREENSHOTS_JSON="["
FIRST=true
for png in $(ls artifacts/*.png 2>/dev/null | xargs -n1 basename 2>/dev/null); do
[ "$FIRST" = true ] && FIRST=false || SCREENSHOTS_JSON+=","
SCREENSHOTS_JSON+="\"$png\""
done
SCREENSHOTS_JSON+="]"
PLATFORM_LABEL="iOS"
sed -i '' "s/RUN_ID_PLACEHOLDER/$RUN_ID/g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
sed -i '' "s/STATUS_PLACEHOLDER/$STATUS/g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
sed -i '' "s/STATUS_CLASS_PLACEHOLDER/$STATUS_CLASS/g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
sed -i '' "s/ENV_LABEL_PLACEHOLDER/$ENV_LABEL/g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
sed -i '' "s/PLATFORM_PLACEHOLDER/$PLATFORM_NAME/g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
sed -i '' "s/PLATFORM_LABEL_PLACEHOLDER/$PLATFORM_LABEL/g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
sed -i '' "s/BRANCH_PLACEHOLDER/$BRANCH/g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
sed -i '' "s/COMMIT_PLACEHOLDER/$SHORT_COMMIT/g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
sed -i '' "s/TIMESTAMP_PLACEHOLDER/$TIMESTAMP/g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
sed -i '' "s|SCREENSHOTS_PLACEHOLDER|$SCREENSHOTS_JSON|g" gh-pages-history/$ENV_NAME/${RUN_ID}-${PLATFORM_NAME}/index.html
- name: Generate app index page
if: always()
run: |
cd gh-pages-history
collect_history() {
local env_name=$1
local json="["
local first=true
for dir in $(ls -dt ${env_name}/*/ 2>/dev/null | head -50); do
RUN_ID=$(basename $dir)
if [ -f "$dir/exit-code.txt" ]; then
EXIT_CODE=$(cat "$dir/exit-code.txt")
if [ "$EXIT_CODE" = "0" ]; then
STATUS="✅ 성공"
STATUS_CLASS="success"
else
STATUS="❌ 실패"
STATUS_CLASS="failure"
fi
else
STATUS="❌ 실패"
STATUS_CLASS="failure"
fi
[ -f "$dir/timestamp.txt" ] && TIMESTAMP=$(cat "$dir/timestamp.txt") || TIMESTAMP="Unknown"
[ -f "$dir/platform.txt" ] && PLATFORM=$(cat "$dir/platform.txt") || PLATFORM="android"
[ "$first" = true ] && first=false || json+=","
json+="{\"run_id\":\"$RUN_ID\",\"status\":\"$STATUS\",\"status_class\":\"$STATUS_CLASS\",\"timestamp\":\"$TIMESTAMP\",\"platform\":\"$PLATFORM\"}"
done
json+="]"
echo "$json"
}
PROD_HISTORY=$(collect_history "app-production")
STAGING_HISTORY=$(collect_history "app-staging")
cat > app-index.html << 'INDEX_EOF'
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FASTFIVE APP 자동화 테스트</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; min-height: 100vh; }
.navbar { background: #1a73e8; color: white; padding: 15px 20px; display: flex; align-items: center; gap: 15px; }
.navbar h1 { font-size: 20px; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.tabs { display: flex; gap: 0; margin-bottom: 20px; background: white; border-radius: 10px; overflow: hidden; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.tab { flex: 1; padding: 15px 20px; text-align: center; cursor: pointer; border: none; background: white; font-size: 16px; font-weight: 500; transition: all 0.2s; border-bottom: 3px solid transparent; }
.tab:hover { background: #f8f9fa; }
.tab.active { border-bottom-color: #1a73e8; color: #1a73e8; background: #e8f0fe; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; margin-bottom: 30px; }
.stat-card { background: white; padding: 20px; border-radius: 10px; text-align: center; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.stat-card .number { font-size: 36px; font-weight: bold; }
.stat-card .label { color: #666; margin-top: 5px; }
.stat-card.success .number { color: #28a745; }
.stat-card.failure .number { color: #dc3545; }
.history-table { background: white; border-radius: 10px; overflow: hidden; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.history-table table { width: 100%; border-collapse: collapse; }
.history-table th, .history-table td { padding: 15px; text-align: left; border-bottom: 1px solid #eee; }
.history-table th { background: #f8f9fa; font-weight: 600; }
.history-table tr:hover { background: #f8f9fa; }
.status-badge { display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 12px; }
.status-badge.success { background: #d4edda; color: #155724; }
.status-badge.failure { background: #f8d7da; color: #721c24; }
.platform-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
.platform-badge.android { background: #a4c639; color: white; }
.platform-badge.ios { background: #007aff; color: white; }
.view-link { color: #1a73e8; text-decoration: none; }
.tab-content { display: none; }
.tab-content.active { display: block; }
</style>
</head>
<body>
<nav class="navbar"><h1>📱 FASTFIVE APP 자동화 테스트</h1></nav>
<div class="container">
<div class="tabs">
<button class="tab active" data-env="production">🔴 운영 (Production)</button>
<button class="tab" data-env="staging">🟡 스테이징 (Staging)</button>
</div>
<div id="production-content" class="tab-content active">
<div class="stats" id="production-stats"></div>
<div class="history-table"><table><thead><tr><th>Run ID</th><th>플랫폼</th><th>상태</th><th>실행 시간</th><th>상세</th></tr></thead><tbody id="production-body"></tbody></table></div>
</div>
<div id="staging-content" class="tab-content">
<div class="stats" id="staging-stats"></div>
<div class="history-table"><table><thead><tr><th>Run ID</th><th>플랫폼</th><th>상태</th><th>실행 시간</th><th>상세</th></tr></thead><tbody id="staging-body"></tbody></table></div>
</div>
</div>
<script>
const prodHistory = PRODUCTION_DATA_PLACEHOLDER;
const stagingHistory = STAGING_DATA_PLACEHOLDER;
function renderStats(h, id) {
var t=h.length, s=h.filter(x=>x.status_class==='success').length, f=t-s, r=t>0?Math.round((s/t)*100):0;
document.getElementById(id).innerHTML = '<div class="stat-card"><div class="number">'+t+'</div><div class="label">전체</div></div><div class="stat-card success"><div class="number">'+s+'</div><div class="label">성공</div></div><div class="stat-card failure"><div class="number">'+f+'</div><div class="label">실패</div></div><div class="stat-card"><div class="number">'+r+'%</div><div class="label">성공률</div></div>';
}
function renderTable(h, id, env) {
var b = document.getElementById(id);
if (h.length === 0) { b.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:40px;color:#666;">히스토리가 없습니다.</td></tr>'; return; }
b.innerHTML = h.map(x => {
var platformLabel = x.platform === 'ios' ? 'iOS' : 'Android';
var platformClass = x.platform || 'android';
return '<tr><td><code>'+x.run_id+'</code></td><td><span class="platform-badge '+platformClass+'">'+platformLabel+'</span></td><td><span class="status-badge '+x.status_class+'">'+x.status+'</span></td><td>'+x.timestamp+'</td><td><a href="app-'+env+'/'+x.run_id+'/" class="view-link">상세 보기 →</a></td></tr>';
}).join('');
}
renderStats(prodHistory, 'production-stats'); renderTable(prodHistory, 'production-body', 'production');
renderStats(stagingHistory, 'staging-stats'); renderTable(stagingHistory, 'staging-body', 'staging');
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', function() {
var e = this.dataset.env;
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(x => x.classList.remove('active'));
this.classList.add('active');
document.getElementById(e + '-content').classList.add('active');
}));
</script>
</body>
</html>
INDEX_EOF
sed -i '' "s|PRODUCTION_DATA_PLACEHOLDER|$PROD_HISTORY|g" app-index.html
sed -i '' "s|STAGING_DATA_PLACEHOLDER|$STAGING_HISTORY|g" app-index.html
- name: Deploy to GitHub Pages
if: always()
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./gh-pages-history
keep_files: true
- name: Upload artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: app-test-ios-artifacts
path: artifacts