Selenium Tests #86
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: Selenium Tests | |
| on: | |
| workflow_dispatch: | |
| # push: | |
| # branches: [main, staging] | |
| # pull_request: | |
| # branches: [main, staging] | |
| permissions: | |
| contents: write | |
| pages: write | |
| id-token: write | |
| jobs: | |
| test: | |
| runs-on: ubuntu-latest | |
| environment: ${{ github.ref_name == 'main' && 'production' || 'staging' }} | |
| env: | |
| TZ: Asia/Seoul | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set environment info | |
| run: | | |
| echo "Branch: ${{ github.ref_name }}" | |
| echo "Environment: ${{ github.ref_name == 'main' && 'production' || 'staging' }}" | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| - name: Install Chrome | |
| uses: browser-actions/setup-chrome@v1 | |
| with: | |
| chrome-version: stable | |
| - name: Install Allure CLI | |
| run: | | |
| curl -o allure-2.24.0.tgz -Ls https://github.com/allure-framework/allure2/releases/download/2.24.0/allure-2.24.0.tgz | |
| tar -zxvf allure-2.24.0.tgz | |
| echo "$PWD/allure-2.24.0/bin" >> $GITHUB_PATH | |
| - name: Install dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install -r fastfive-auto-web-dev/requirements.txt | |
| pip install allure-pytest | |
| - name: Check service URL response | |
| run: | | |
| curl -I -L "${AUTO_TEST_SERVICE_URL:-https://members-staging.slowfive.com/}" | |
| - name: Install screen recording dependencies | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y xvfb ffmpeg | |
| - name: Run Selenium test suite with recording | |
| id: test | |
| env: | |
| AUTO_TEST_SERVICE_URL: ${{ vars.AUTO_TEST_SERVICE_URL }} | |
| AUTO_TEST_EMAIL: ${{ vars.AUTO_TEST_EMAIL }} | |
| AUTO_TEST_PASSWORD: ${{ secrets.AUTO_TEST_PASSWORD }} | |
| AUTO_TEST_WAIT_SECONDS: "60" | |
| run: | | |
| mkdir -p artifacts allure-results | |
| # xvfb-run으로 가상 디스플레이에서 테스트 실행 및 녹화 | |
| xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" bash -c " | |
| # 녹화 시작 | |
| /usr/bin/ffmpeg -y -f x11grab -video_size 1920x1080 -i \$DISPLAY -codec:v libx264 -pix_fmt yuv420p -r 15 artifacts/test-recording.mp4 2>artifacts/ffmpeg.log & | |
| FFMPEG_PID=\$! | |
| echo \"Recording started with PID \$FFMPEG_PID on display \$DISPLAY\" | |
| sleep 2 | |
| # 테스트 실행 | |
| python fastfive-auto-web-dev/suite_runner.py 2>&1 | tee artifacts/test-output.log | |
| TEST_EXIT_CODE=\${PIPESTATUS[0]} | |
| # 테스트 결과 저장 | |
| echo \$TEST_EXIT_CODE > artifacts/exit-code.txt | |
| # 녹화 중지 | |
| echo \"Stopping recording...\" | |
| kill -INT \$FFMPEG_PID 2>/dev/null || true | |
| sleep 3 | |
| kill -9 \$FFMPEG_PID 2>/dev/null || true | |
| echo \"Recording files:\" | |
| ls -la artifacts/ | |
| exit \$TEST_EXIT_CODE | |
| " && echo "test_result=success" >> $GITHUB_OUTPUT || echo "test_result=failure" >> $GITHUB_OUTPUT | |
| - 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 production gh-pages-history/ 2>/dev/null || true | |
| mv staging gh-pages-history/ 2>/dev/null || true | |
| mv index.html gh-pages-history/ 2>/dev/null || true | |
| # 불필요한 폴더 삭제 | |
| rm -rf gh-pages-history/widgets gh-pages-history/data gh-pages-history/export gh-pages-history/history gh-pages-history/plugin 2>/dev/null || true | |
| rm -rf widgets data export history plugin 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}" | |
| # 환경 결정 (main -> production, 나머지 -> staging) | |
| if [ "$BRANCH" = "main" ]; then | |
| ENV_NAME="production" | |
| ENV_LABEL="운영" | |
| else | |
| ENV_NAME="staging" | |
| ENV_LABEL="스테이징" | |
| fi | |
| # 서비스 URL 가져오기 | |
| SERVICE_URL="${{ vars.AUTO_TEST_SERVICE_URL }}" | |
| if [ -z "$SERVICE_URL" ]; then | |
| SERVICE_URL="https://members-staging.slowfive.com/" | |
| fi | |
| # 환경별 히스토리 폴더 생성 | |
| mkdir -p gh-pages-history/$ENV_NAME/$RUN_ID | |
| # 아티팩트 복사 | |
| cp -r artifacts/* gh-pages-history/$ENV_NAME/$RUN_ID/ 2>/dev/null || true | |
| # 환경 정보 저장 | |
| echo "$ENV_NAME" > gh-pages-history/$ENV_NAME/$RUN_ID/env.txt | |
| echo "$BRANCH" > gh-pages-history/$ENV_NAME/$RUN_ID/branch.txt | |
| echo "$TIMESTAMP" > gh-pages-history/$ENV_NAME/$RUN_ID/timestamp.txt | |
| # 개별 테스트 리포트 HTML 생성 | |
| cat > gh-pages-history/$ENV_NAME/$RUN_ID/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>테스트 결과 - RUN_ID_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; } | |
| .status-badge.error { background: #f8d7da; color: #721c24; } | |
| .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; } | |
| video { width: 100%; max-height: 600px; background: #000; border-radius: 5px; } | |
| .log-content { background: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: 5px; font-family: 'Monaco', 'Consolas', 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; } | |
| .back-link:hover { text-decoration: underline; } | |
| .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; white-space: nowrap; } | |
| .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 { margin-top: 10px; } | |
| .test-screenshot img { max-width: 300px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer; } | |
| .test-screenshot img:hover { border-color: #007bff; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <a href="../../" class="back-link">← 전체 히스토리로 돌아가기</a> | |
| <div class="header"> | |
| <h1>테스트 결과 <span class="status-badge STATUS_CLASS_PLACEHOLDER">STATUS_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>테스트 URL</label><span><a href="SERVICE_URL_PLACEHOLDER" target="_blank" style="color:#007bff;text-decoration:none;">SERVICE_URL_PLACEHOLDER</a></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> | |
| <video controls> | |
| <source src="test-recording.mp4" type="video/mp4"> | |
| 영상을 재생할 수 없습니다. | |
| </video> | |
| </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 summaryContainer = document.getElementById('test-summary'); | |
| if (!results || results.length === 0) { | |
| container.innerHTML = '<p style="padding:20px;color:#666;">테스트 결과가 없습니다.</p>'; | |
| return; | |
| } | |
| // 총 수행 결과 계산 | |
| var total = results.length; | |
| var success = results.filter(function(r) { return r.status === 'success'; }).length; | |
| var failure = total - success; | |
| var totalSeconds = results.reduce(function(sum, r) { return sum + (r.duration || 0); }, 0); | |
| var rate = total > 0 ? Math.round((success / total) * 100) : 0; | |
| // 시/분/초 형식으로 변환 | |
| function formatDuration(seconds) { | |
| var h = Math.floor(seconds / 3600); | |
| var m = Math.floor((seconds % 3600) / 60); | |
| var s = Math.floor(seconds % 60); | |
| var parts = []; | |
| if (h > 0) parts.push(h + '시간'); | |
| if (m > 0) parts.push(m + '분'); | |
| parts.push(s + '초'); | |
| return parts.join(' '); | |
| } | |
| var durationText = formatDuration(totalSeconds); | |
| // 요약 카드 표시 | |
| summaryContainer.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;">' + durationText + '</div><div style="font-size:12px;color:#666;">총 수행시간</div></div>'; | |
| // 테스트 목록 표시 | |
| container.innerHTML = results.map(function(r) { | |
| var icon = r.status === 'success' ? '✅' : '❌'; | |
| var msgHtml = r.message ? '<div class="test-message">' + r.message + '</div>' : ''; | |
| var traceHtml = r.traceback ? '<div class="test-traceback">' + r.traceback.replace(/</g, '<').replace(/>/g, '>') + '</div>' : ''; | |
| var screenshotHtml = r.screenshot ? '<div class="test-screenshot"><a href="' + r.screenshot + '" target="_blank"><img src="' + r.screenshot + '" alt="실패 스크린샷"></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>' + | |
| msgHtml + | |
| traceHtml + | |
| screenshotHtml + | |
| '</div>' + | |
| '<span class="test-duration">' + r.duration + 's</span>' + | |
| '</div>'; | |
| }).join(''); | |
| }).catch(function() { | |
| document.getElementById('test-results').innerHTML = '<p style="padding:20px;color:#666;">테스트 결과를 불러올 수 없습니다.</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 container = document.getElementById('screenshots'); | |
| if (pngs.length === 0) { | |
| container.innerHTML = '<p style="color:#666;">스크린샷이 없습니다.</p>'; | |
| } else { | |
| pngs.forEach(png => { | |
| const div = document.createElement('div'); | |
| div.className = 'screenshot'; | |
| div.innerHTML = '<a href="' + png + '" target="_blank"><img src="' + png + '"></a><p>' + png + '</p>'; | |
| container.appendChild(div); | |
| }); | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| REPORT_EOF | |
| # 스크린샷 목록 생성 (artifacts 폴더의 모든 PNG 파일) | |
| SCREENSHOTS_JSON="[" | |
| FIRST_PNG=true | |
| for png in $(ls artifacts/*.png 2>/dev/null | xargs -n1 basename 2>/dev/null); do | |
| if [ "$FIRST_PNG" = true ]; then | |
| FIRST_PNG=false | |
| else | |
| SCREENSHOTS_JSON+="," | |
| fi | |
| SCREENSHOTS_JSON+="\"$png\"" | |
| done | |
| SCREENSHOTS_JSON+="]" | |
| # 플레이스홀더 치환 | |
| sed -i "s/RUN_ID_PLACEHOLDER/$RUN_ID/g" gh-pages-history/$ENV_NAME/$RUN_ID/index.html | |
| sed -i "s/STATUS_PLACEHOLDER/$STATUS/g" gh-pages-history/$ENV_NAME/$RUN_ID/index.html | |
| sed -i "s/STATUS_CLASS_PLACEHOLDER/$STATUS_CLASS/g" gh-pages-history/$ENV_NAME/$RUN_ID/index.html | |
| sed -i "s/ENV_LABEL_PLACEHOLDER/$ENV_LABEL/g" gh-pages-history/$ENV_NAME/$RUN_ID/index.html | |
| sed -i "s/BRANCH_PLACEHOLDER/$BRANCH/g" gh-pages-history/$ENV_NAME/$RUN_ID/index.html | |
| sed -i "s/COMMIT_PLACEHOLDER/$SHORT_COMMIT/g" gh-pages-history/$ENV_NAME/$RUN_ID/index.html | |
| sed -i "s/TIMESTAMP_PLACEHOLDER/$TIMESTAMP/g" gh-pages-history/$ENV_NAME/$RUN_ID/index.html | |
| sed -i "s|SERVICE_URL_PLACEHOLDER|$SERVICE_URL|g" gh-pages-history/$ENV_NAME/$RUN_ID/index.html | |
| sed -i "s|SCREENSHOTS_PLACEHOLDER|$SCREENSHOTS_JSON|g" gh-pages-history/$ENV_NAME/$RUN_ID/index.html | |
| - name: Generate index page | |
| if: always() | |
| run: | | |
| cd gh-pages-history | |
| # 환경별 히스토리 데이터 수집 함수 | |
| collect_history() { | |
| local env_name=$1 | |
| local json="[" | |
| local first=true | |
| # 제외할 폴더 목록 | |
| local exclude_dirs="widgets|plugin|data|export|history" | |
| for dir in $(ls -dt ${env_name}/*/ 2>/dev/null | head -50); do | |
| RUN_ID=$(basename $dir) | |
| # 제외할 폴더는 스킵 | |
| if echo "$RUN_ID" | grep -qE "^($exclude_dirs)$"; then | |
| continue | |
| fi | |
| 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 | |
| # timestamp.txt 파일이 있으면 사용, 없으면 폴더 수정 시간 사용 | |
| if [ -f "$dir/timestamp.txt" ]; then | |
| TIMESTAMP=$(cat "$dir/timestamp.txt") | |
| else | |
| TIMESTAMP=$(stat -c %y "$dir" 2>/dev/null | cut -d'.' -f1 || date -r "$dir" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "Unknown") | |
| fi | |
| if [ "$first" = true ]; then | |
| first=false | |
| else | |
| json+="," | |
| fi | |
| json+="{\"run_id\":\"$RUN_ID\",\"status\":\"$STATUS\",\"status_class\":\"$STATUS_CLASS\",\"timestamp\":\"$TIMESTAMP\",\"env\":\"$env_name\"}" | |
| done | |
| json+="]" | |
| echo "$json" | |
| } | |
| # 각 환경별 히스토리 수집 | |
| PROD_HISTORY=$(collect_history "production") | |
| STAGING_HISTORY=$(collect_history "staging") | |
| # 메인 인덱스 페이지 생성 | |
| cat > 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 WEB 자동화 테스트</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: #24292e; color: white; padding: 15px 20px; display: flex; align-items: center; gap: 15px; } | |
| .navbar .logo { width: 36px; height: 36px; background: #2d2d2d; border-radius: 6px; display: flex; align-items: center; justify-content: center; } | |
| .navbar .logo svg { width: 20px; height: 24px; } | |
| .navbar h1 { font-size: 20px; margin: 0; } | |
| .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: #007bff; color: #007bff; background: #f0f7ff; } | |
| .tab.production { border-left: 4px solid #dc3545; } | |
| .tab.staging { border-left: 4px solid #ffc107; } | |
| .tab.production.active { border-bottom-color: #dc3545; color: #dc3545; background: #fff5f5; } | |
| .tab.staging.active { border-bottom-color: #ffc107; color: #856404; background: #fffdf0; } | |
| .env-indicator { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 8px; } | |
| .env-indicator.production { background: #dc3545; } | |
| .env-indicator.staging { background: #ffc107; } | |
| .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; font-weight: 500; } | |
| .status-badge.success { background: #d4edda; color: #155724; } | |
| .status-badge.failure { background: #f8d7da; color: #721c24; } | |
| .view-link { color: #007bff; text-decoration: none; } | |
| .view-link:hover { text-decoration: underline; } | |
| .empty { text-align: center; padding: 40px; color: #666; } | |
| .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: #f0f7ff; border-color: #007bff; } | |
| .pagination button:disabled { opacity: 0.5; cursor: not-allowed; } | |
| .pagination button.active { background: #007bff; color: white; border-color: #007bff; } | |
| .pagination .page-info { padding: 8px 12px; color: #666; font-size: 14px; } | |
| </style> | |
| </head> | |
| <body> | |
| <nav class="navbar"> | |
| <h1>💻 FASTFIVE WEB 자동화 테스트</h1> | |
| </nav> | |
| <div class="container"> | |
| <div class="tabs"> | |
| <button class="tab production active" data-env="production"> | |
| <span class="env-indicator production"></span>운영 (Production) | |
| </button> | |
| <button class="tab staging" data-env="staging"> | |
| <span class="env-indicator staging"></span>스테이징 (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> | |
| </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> | |
| </tr> | |
| </thead> | |
| <tbody id="staging-body"></tbody> | |
| </table> | |
| </div> | |
| <div class="pagination" id="staging-pagination"></div> | |
| </div> | |
| </div> | |
| <script> | |
| const productionHistory = PRODUCTION_DATA_PLACEHOLDER; | |
| const stagingHistory = STAGING_DATA_PLACEHOLDER; | |
| const PAGE_SIZE = 10; | |
| let prodPage = 1, stagingPage = 1; | |
| function renderStats(history, elementId) { | |
| const total = history.length; | |
| const success = history.filter(h => h.status_class === 'success').length; | |
| const failure = total - success; | |
| const rate = total > 0 ? Math.round((success / total) * 100) : 0; | |
| document.getElementById(elementId).innerHTML = | |
| '<div class="stat-card"><div class="number">' + total + '</div><div class="label">전체 테스트</div></div>' + | |
| '<div class="stat-card success"><div class="number">' + success + '</div><div class="label">성공</div></div>' + | |
| '<div class="stat-card failure"><div class="number">' + failure + '</div><div class="label">실패</div></div>' + | |
| '<div class="stat-card"><div class="number">' + rate + '%</div><div class="label">성공률</div></div>'; | |
| } | |
| function renderTable(history, elementId, envName, page) { | |
| const tbody = document.getElementById(elementId); | |
| if (history.length === 0) { | |
| tbody.innerHTML = '<tr><td colspan="4" class="empty">테스트 히스토리가 없습니다.</td></tr>'; | |
| return; | |
| } | |
| var start = (page - 1) * PAGE_SIZE; | |
| var end = start + PAGE_SIZE; | |
| var pageData = history.slice(start, end); | |
| tbody.innerHTML = pageData.map(function(h) { | |
| return '<tr>' + | |
| '<td><code>' + h.run_id + '</code></td>' + | |
| '<td><span class="status-badge ' + h.status_class + '">' + h.status + '</span></td>' + | |
| '<td>' + h.timestamp + '</td>' + | |
| '<td><a href="' + envName + '/' + h.run_id + '/" class="view-link">상세 보기 →</a></td>' + | |
| '</tr>'; | |
| }).join(''); | |
| } | |
| function renderPagination(history, paginationId, env, currentPage) { | |
| var totalPages = Math.ceil(history.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(productionHistory, 'production-body', 'production', prodPage); | |
| renderPagination(productionHistory, 'production-pagination', 'production', prodPage); | |
| } else { | |
| stagingPage = page; | |
| renderTable(stagingHistory, 'staging-body', 'staging', stagingPage); | |
| renderPagination(stagingHistory, 'staging-pagination', 'staging', stagingPage); | |
| } | |
| } | |
| // 초기 렌더링 | |
| renderStats(productionHistory, 'production-stats'); | |
| renderTable(productionHistory, 'production-body', 'production', prodPage); | |
| renderPagination(productionHistory, 'production-pagination', 'production', prodPage); | |
| renderStats(stagingHistory, 'staging-stats'); | |
| renderTable(stagingHistory, 'staging-body', 'staging', stagingPage); | |
| renderPagination(stagingHistory, 'staging-pagination', 'staging', stagingPage); | |
| // 탭 전환 | |
| document.querySelectorAll('.tab').forEach(function(tab) { | |
| tab.addEventListener('click', function() { | |
| var env = this.getAttribute('data-env'); | |
| document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); }); | |
| document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); }); | |
| this.classList.add('active'); | |
| document.getElementById(env + '-content').classList.add('active'); | |
| }); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| INDEX_EOF | |
| # 히스토리 데이터 삽입 | |
| sed -i "s|PRODUCTION_DATA_PLACEHOLDER|$PROD_HISTORY|g" index.html | |
| sed -i "s|STAGING_DATA_PLACEHOLDER|$STAGING_HISTORY|g" 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 debug artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: selenium-debug | |
| path: artifacts | |
| - name: Send Slack notification | |
| if: always() | |
| env: | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| run: | | |
| if [ -z "$SLACK_WEBHOOK_URL" ]; then | |
| echo "SLACK_WEBHOOK_URL is not set, skipping notification" | |
| exit 0 | |
| fi | |
| # 테스트 결과 정보 | |
| STATUS="${{ steps.result.outputs.status }}" | |
| STATUS_CLASS="${{ steps.result.outputs.status_class }}" | |
| TIMESTAMP="${{ steps.result.outputs.timestamp }}" | |
| RUN_ID="${{ github.run_id }}" | |
| BRANCH="${{ github.ref_name }}" | |
| COMMIT="${{ github.sha }}" | |
| SHORT_COMMIT="${COMMIT:0:7}" | |
| # 환경 결정 | |
| if [ "$BRANCH" = "main" ]; then | |
| ENV_NAME="production" | |
| ENV_LABEL="🔴 운영" | |
| else | |
| ENV_NAME="staging" | |
| ENV_LABEL="🟡 스테이징" | |
| fi | |
| # 결과에 따른 색상 및 이모지 | |
| if [ "$STATUS_CLASS" = "success" ]; then | |
| COLOR="#36a64f" | |
| EMOJI="✅" | |
| RESULT_TEXT="성공" | |
| else | |
| COLOR="#dc3545" | |
| EMOJI="❌" | |
| RESULT_TEXT="실패" | |
| fi | |
| # 테스트 결과 상세 (test-results.json에서 읽기) | |
| TOTAL=0 | |
| SUCCESS=0 | |
| FAILURE=0 | |
| FAILED_TESTS="" | |
| if [ -f "artifacts/test-results.json" ]; then | |
| TOTAL=$(cat artifacts/test-results.json | python3 -c "import sys,json; data=json.load(sys.stdin); print(len(data))" 2>/dev/null || echo "0") | |
| SUCCESS=$(cat artifacts/test-results.json | python3 -c "import sys,json; data=json.load(sys.stdin); print(len([x for x in data if x.get('status')=='success']))" 2>/dev/null || echo "0") | |
| FAILURE=$((TOTAL - SUCCESS)) | |
| # 실패한 테스트 목록 | |
| FAILED_TESTS=$(cat artifacts/test-results.json | python3 -c "import sys,json; data=json.load(sys.stdin); failed=[x['name'] for x in data if x.get('status')!='success']; print(' / '.join(failed[:3]) if failed else '')" 2>/dev/null || echo "") | |
| fi | |
| # 리포트 URL | |
| REPORT_URL="https://fastfive-dev.github.io/auto-test/${ENV_NAME}/${RUN_ID}/" | |
| ACTIONS_URL="https://github.com/fastfive-dev/auto-test/actions/runs/${RUN_ID}" | |
| # 실패한 테스트 필드 (있는 경우만) | |
| FAILED_FIELD="" | |
| if [ -n "$FAILED_TESTS" ]; then | |
| FAILED_TESTS_ESCAPED=$(echo "$FAILED_TESTS" | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g') | |
| FAILED_FIELD=',{"title":"실패한 테스트","value":"'"$FAILED_TESTS_ESCAPED"'","short":false}' | |
| fi | |
| # Slack 메시지 전송 | |
| curl -X POST -H 'Content-type: application/json' \ | |
| --data '{ | |
| "attachments": [ | |
| { | |
| "color": "'"$COLOR"'", | |
| "blocks": [ | |
| { | |
| "type": "header", | |
| "text": { | |
| "type": "plain_text", | |
| "text": "'"$EMOJI"' WEB 자동화 테스트 '"$RESULT_TEXT"'", | |
| "emoji": true | |
| } | |
| }, | |
| { | |
| "type": "section", | |
| "fields": [ | |
| {"type": "mrkdwn", "text": "*환경:*\n'"$ENV_LABEL"'"}, | |
| {"type": "mrkdwn", "text": "*브랜치:*\n`'"$BRANCH"'`"}, | |
| {"type": "mrkdwn", "text": "*테스트 결과:*\n성공 '"$SUCCESS"' / 실패 '"$FAILURE"' / 총 '"$TOTAL"'"}, | |
| {"type": "mrkdwn", "text": "*커밋:*\n`'"$SHORT_COMMIT"'`"}, | |
| {"type": "mrkdwn", "text": "*실행 시간:*\n'"$TIMESTAMP"'"} | |
| ] | |
| }, | |
| { | |
| "type": "actions", | |
| "elements": [ | |
| { | |
| "type": "button", | |
| "text": {"type": "plain_text", "text": "📊 상세 리포트", "emoji": true}, | |
| "url": "'"$REPORT_URL"'" | |
| }, | |
| { | |
| "type": "button", | |
| "text": {"type": "plain_text", "text": "🔧 Actions 로그", "emoji": true}, | |
| "url": "'"$ACTIONS_URL"'" | |
| } | |
| ] | |
| } | |
| ] | |
| } | |
| ] | |
| }' \ | |
| "$SLACK_WEBHOOK_URL" | |
| echo "Slack notification sent!" |