예약 테스트: 확인 버튼 선택자 수정 #40
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: | |
| 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,'<')+'</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,'<')+'</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 |