App Tests (Android & iOS) #44
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: App Tests (Android & iOS) | |
| on: | |
| 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 on port 4723 if any | |
| lsof -ti:4723 | xargs kill -9 2>/dev/null || true | |
| sleep 2 | |
| # Start Appium for Android on port 4723 | |
| 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,'<')+'</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; } | |
| .pagination { display: flex; justify-content: center; align-items: center; gap: 5px; margin-top: 20px; padding: 15px; } | |
| .pagination button { padding: 8px 12px; border: 1px solid #ddd; background: white; border-radius: 5px; cursor: pointer; font-size: 14px; transition: all 0.2s; } | |
| .pagination button:hover:not(:disabled) { background: #e8f0fe; border-color: #1a73e8; } | |
| .pagination button:disabled { opacity: 0.5; cursor: not-allowed; } | |
| .pagination button.active { background: #1a73e8; color: white; border-color: #1a73e8; } | |
| .pagination .page-info { padding: 8px 12px; color: #666; font-size: 14px; } | |
| </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 class="pagination" id="production-pagination"></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 class="pagination" id="staging-pagination"></div> | |
| </div> | |
| </div> | |
| <script> | |
| const prodHistory = PRODUCTION_DATA_PLACEHOLDER; | |
| const stagingHistory = STAGING_DATA_PLACEHOLDER; | |
| const PAGE_SIZE = 10; | |
| let prodPage = 1, stagingPage = 1; | |
| 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, page) { | |
| 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; } | |
| var start = (page - 1) * PAGE_SIZE; | |
| var end = start + PAGE_SIZE; | |
| var pageData = h.slice(start, end); | |
| b.innerHTML = pageData.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(''); | |
| } | |
| function renderPagination(h, paginationId, env, currentPage) { | |
| var totalPages = Math.ceil(h.length / PAGE_SIZE); | |
| if (totalPages <= 1) { document.getElementById(paginationId).innerHTML = ''; return; } | |
| var html = '<button onclick="goToPage(\''+env+'\', 1)" '+(currentPage===1?'disabled':'')+'>≪ 처음</button>'; | |
| html += '<button onclick="goToPage(\''+env+'\', '+(currentPage-1)+')" '+(currentPage===1?'disabled':'')+'>← 이전</button>'; | |
| var startPage = Math.max(1, currentPage - 2); | |
| var endPage = Math.min(totalPages, startPage + 4); | |
| if (endPage - startPage < 4) startPage = Math.max(1, endPage - 4); | |
| for (var i = startPage; i <= endPage; i++) { | |
| html += '<button onclick="goToPage(\''+env+'\', '+i+')" class="'+(i===currentPage?'active':'')+'">'+i+'</button>'; | |
| } | |
| html += '<button onclick="goToPage(\''+env+'\', '+(currentPage+1)+')" '+(currentPage===totalPages?'disabled':'')+'>다음 →</button>'; | |
| html += '<button onclick="goToPage(\''+env+'\', '+totalPages+')" '+(currentPage===totalPages?'disabled':'')+'>끝 ≫</button>'; | |
| html += '<span class="page-info">'+currentPage+' / '+totalPages+' 페이지</span>'; | |
| document.getElementById(paginationId).innerHTML = html; | |
| } | |
| function goToPage(env, page) { | |
| if (env === 'production') { | |
| prodPage = page; | |
| renderTable(prodHistory, 'production-body', 'production', prodPage); | |
| renderPagination(prodHistory, 'production-pagination', 'production', prodPage); | |
| } else { | |
| stagingPage = page; | |
| renderTable(stagingHistory, 'staging-body', 'staging', stagingPage); | |
| renderPagination(stagingHistory, 'staging-pagination', 'staging', stagingPage); | |
| } | |
| } | |
| renderStats(prodHistory, 'production-stats'); | |
| renderTable(prodHistory, 'production-body', 'production', prodPage); | |
| renderPagination(prodHistory, 'production-pagination', 'production', prodPage); | |
| renderStats(stagingHistory, 'staging-stats'); | |
| renderTable(stagingHistory, 'staging-body', 'staging', stagingPage); | |
| renderPagination(stagingHistory, 'staging-pagination', 'staging', stagingPage); | |
| 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 | |
| APPIUM_PORT: 4724 | |
| APPIUM_URL: http://127.0.0.1:4724/wd/hub | |
| 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 on port 4724 if any | |
| lsof -ti:4724 | xargs kill -9 2>/dev/null || true | |
| sleep 2 | |
| # Start Appium with XCUITest on port 4724 | |
| appium --address 127.0.0.1 --port 4724 --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,'<')+'</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; } | |
| .pagination { display: flex; justify-content: center; align-items: center; gap: 5px; margin-top: 20px; padding: 15px; } | |
| .pagination button { padding: 8px 12px; border: 1px solid #ddd; background: white; border-radius: 5px; cursor: pointer; font-size: 14px; transition: all 0.2s; } | |
| .pagination button:hover:not(:disabled) { background: #e8f0fe; border-color: #1a73e8; } | |
| .pagination button:disabled { opacity: 0.5; cursor: not-allowed; } | |
| .pagination button.active { background: #1a73e8; color: white; border-color: #1a73e8; } | |
| .pagination .page-info { padding: 8px 12px; color: #666; font-size: 14px; } | |
| </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 class="pagination" id="production-pagination"></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 class="pagination" id="staging-pagination"></div> | |
| </div> | |
| </div> | |
| <script> | |
| const prodHistory = PRODUCTION_DATA_PLACEHOLDER; | |
| const stagingHistory = STAGING_DATA_PLACEHOLDER; | |
| const PAGE_SIZE = 10; | |
| let prodPage = 1, stagingPage = 1; | |
| 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, page) { | |
| 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; } | |
| var start = (page - 1) * PAGE_SIZE; | |
| var end = start + PAGE_SIZE; | |
| var pageData = h.slice(start, end); | |
| b.innerHTML = pageData.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(''); | |
| } | |
| function renderPagination(h, paginationId, env, currentPage) { | |
| var totalPages = Math.ceil(h.length / PAGE_SIZE); | |
| if (totalPages <= 1) { document.getElementById(paginationId).innerHTML = ''; return; } | |
| var html = '<button onclick="goToPage(\''+env+'\', 1)" '+(currentPage===1?'disabled':'')+'>≪ 처음</button>'; | |
| html += '<button onclick="goToPage(\''+env+'\', '+(currentPage-1)+')" '+(currentPage===1?'disabled':'')+'>← 이전</button>'; | |
| var startPage = Math.max(1, currentPage - 2); | |
| var endPage = Math.min(totalPages, startPage + 4); | |
| if (endPage - startPage < 4) startPage = Math.max(1, endPage - 4); | |
| for (var i = startPage; i <= endPage; i++) { | |
| html += '<button onclick="goToPage(\''+env+'\', '+i+')" class="'+(i===currentPage?'active':'')+'">'+i+'</button>'; | |
| } | |
| html += '<button onclick="goToPage(\''+env+'\', '+(currentPage+1)+')" '+(currentPage===totalPages?'disabled':'')+'>다음 →</button>'; | |
| html += '<button onclick="goToPage(\''+env+'\', '+totalPages+')" '+(currentPage===totalPages?'disabled':'')+'>끝 ≫</button>'; | |
| html += '<span class="page-info">'+currentPage+' / '+totalPages+' 페이지</span>'; | |
| document.getElementById(paginationId).innerHTML = html; | |
| } | |
| function goToPage(env, page) { | |
| if (env === 'production') { | |
| prodPage = page; | |
| renderTable(prodHistory, 'production-body', 'production', prodPage); | |
| renderPagination(prodHistory, 'production-pagination', 'production', prodPage); | |
| } else { | |
| stagingPage = page; | |
| renderTable(stagingHistory, 'staging-body', 'staging', stagingPage); | |
| renderPagination(stagingHistory, 'staging-pagination', 'staging', stagingPage); | |
| } | |
| } | |
| renderStats(prodHistory, 'production-stats'); | |
| renderTable(prodHistory, 'production-body', 'production', prodPage); | |
| renderPagination(prodHistory, 'production-pagination', 'production', prodPage); | |
| renderStats(stagingHistory, 'staging-stats'); | |
| renderTable(stagingHistory, 'staging-body', 'staging', stagingPage); | |
| renderPagination(stagingHistory, 'staging-pagination', 'staging', stagingPage); | |
| 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 |