Skip to content

Selenium Tests

Selenium Tests #86

Workflow file for this run

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, '&lt;').replace(/>/g, '&gt;') + '</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!"