feat: 재시도 로직 전역 설정 추가

- 재시도 전략: immediate, linear, exponential, conditional
- 재시도 가능 오류: timeout, not found, network, 5xx 등
- 재시도 전 액션: waitForPageLoad, scrollIntoView, closeOverlays 등
- 스텝별 재시도 설정: click(3회), fill(2회), navigate(3회)
- Circuit Breaker: 5회 연속 실패 시 차단
- 시나리오 전체 재시도 지원

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
light
2026-01-31 11:47:47 +09:00
parent 374fc056c0
commit b1f5d67584

286
_global-retry-config.json Normal file
View File

@@ -0,0 +1,286 @@
{
"$schema": "E2E Retry Logic Configuration",
"version": "1.0.0",
"description": "테스트 실패 시 자동 재시도 및 복구 로직 설정",
"lastUpdated": "2026-01-31",
"retry": {
"enabled": true,
"maxRetries": 3,
"defaultDelay": 1000,
"backoffMultiplier": 2,
"maxDelay": 10000
},
"retryStrategies": {
"immediate": {
"description": "즉시 재시도",
"delay": 0,
"maxRetries": 2,
"useCase": "단순 타이밍 이슈"
},
"linear": {
"description": "일정 간격 재시도",
"delay": 1000,
"maxRetries": 3,
"useCase": "일반적인 실패"
},
"exponential": {
"description": "지수 백오프 재시도",
"initialDelay": 1000,
"multiplier": 2,
"maxDelay": 10000,
"maxRetries": 5,
"useCase": "서버 부하, 네트워크 이슈"
},
"conditional": {
"description": "조건부 재시도",
"conditions": ["errorType", "elementState", "networkStatus"],
"maxRetries": 3,
"useCase": "특정 조건에서만 재시도"
}
},
"retryableErrors": {
"timeout": {
"pattern": ["timeout", "Timeout", "TIMEOUT", "시간 초과"],
"strategy": "exponential",
"maxRetries": 3,
"description": "타임아웃 오류"
},
"elementNotFound": {
"pattern": ["not found", "cannot find", "no such element", "찾을 수 없"],
"strategy": "linear",
"maxRetries": 2,
"preRetryAction": "waitForPageLoad",
"description": "요소를 찾을 수 없음"
},
"elementNotVisible": {
"pattern": ["not visible", "not displayed", "hidden", "표시되지 않"],
"strategy": "linear",
"maxRetries": 2,
"preRetryAction": "scrollIntoView",
"description": "요소가 보이지 않음"
},
"elementNotClickable": {
"pattern": ["not clickable", "intercepted", "obscured", "클릭할 수 없"],
"strategy": "linear",
"maxRetries": 2,
"preRetryAction": "closeOverlays",
"description": "클릭 불가 상태"
},
"networkError": {
"pattern": ["network", "Network", "ERR_", "net::"],
"strategy": "exponential",
"maxRetries": 5,
"preRetryAction": "waitForNetwork",
"description": "네트워크 오류"
},
"serverError": {
"pattern": ["500", "502", "503", "504", "서버 오류"],
"strategy": "exponential",
"maxRetries": 3,
"preRetryAction": "waitAndRefresh",
"description": "서버 오류"
},
"staleElement": {
"pattern": ["stale", "detached", "removed from DOM"],
"strategy": "immediate",
"maxRetries": 2,
"preRetryAction": "refindElement",
"description": "오래된 요소 참조"
},
"sessionExpired": {
"pattern": ["session", "login", "unauthorized", "401", "세션 만료"],
"strategy": "conditional",
"maxRetries": 1,
"preRetryAction": "reAuthenticate",
"description": "세션 만료"
}
},
"nonRetryableErrors": {
"description": "재시도하지 않는 오류 유형",
"patterns": [
"assertion failed",
"test data not found",
"validation error",
"business logic error",
"permission denied",
"403"
]
},
"preRetryActions": {
"waitForPageLoad": {
"description": "페이지 로드 대기",
"script": "(async () => { await new Promise(r => setTimeout(r, 2000)); const loading = document.querySelector('.loading, .spinner'); return !loading || loading.offsetParent === null; })()"
},
"scrollIntoView": {
"description": "요소를 뷰포트로 스크롤",
"script": "(selector) => { const el = document.querySelector(selector); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); return true; } return false; }"
},
"closeOverlays": {
"description": "오버레이/모달 닫기",
"script": "(async () => { const overlays = document.querySelectorAll('[class*=\"overlay\"], [class*=\"modal\"]:not([style*=\"display: none\"]), [role=\"dialog\"]'); overlays.forEach(o => { const closeBtn = o.querySelector('[class*=\"close\"], [aria-label*=\"닫기\"]'); if (closeBtn) closeBtn.click(); }); await new Promise(r => setTimeout(r, 500)); return true; })()"
},
"waitForNetwork": {
"description": "네트워크 안정화 대기",
"script": "(async () => { await new Promise(r => setTimeout(r, 3000)); return navigator.onLine; })()"
},
"waitAndRefresh": {
"description": "대기 후 페이지 새로고침",
"script": "(async () => { await new Promise(r => setTimeout(r, 5000)); window.location.reload(); await new Promise(r => setTimeout(r, 3000)); return true; })()"
},
"refindElement": {
"description": "요소 다시 찾기",
"script": "(selector) => { return !!document.querySelector(selector); }"
},
"reAuthenticate": {
"description": "재인증",
"action": "navigateToLogin",
"requiresCredentials": true
},
"clearCache": {
"description": "브라우저 캐시 정리",
"script": "(() => { if (window.caches) { caches.keys().then(names => names.forEach(name => caches.delete(name))); } sessionStorage.clear(); return true; })()"
}
},
"scripts": {
"retryWithBackoff": "(async function(fn, options = {}) { const { maxRetries = 3, initialDelay = 1000, multiplier = 2, maxDelay = 10000 } = options; let lastError; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { lastError = error; if (attempt < maxRetries) { const delay = Math.min(initialDelay * Math.pow(multiplier, attempt - 1), maxDelay); console.log(`Retry ${attempt}/${maxRetries} after ${delay}ms`); await new Promise(r => setTimeout(r, delay)); } } } throw lastError; })",
"retryUntilSuccess": "(async function(fn, options = {}) { const { maxRetries = 3, delay = 1000, condition = () => true } = options; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const result = await fn(); if (condition(result)) return { success: true, result, attempts: attempt }; } catch (error) { if (attempt === maxRetries) return { success: false, error: error.message, attempts: attempt }; } await new Promise(r => setTimeout(r, delay)); } return { success: false, error: 'Max retries exceeded', attempts: maxRetries }; })",
"waitForCondition": "(async function(conditionFn, options = {}) { const { timeout = 10000, interval = 100 } = options; const start = Date.now(); while (Date.now() - start < timeout) { try { if (await conditionFn()) return true; } catch (e) {} await new Promise(r => setTimeout(r, interval)); } return false; })",
"isRetryableError": "(function(error) { const retryablePatterns = ['timeout', 'not found', 'not visible', 'network', '500', '502', '503', 'stale']; const errorStr = String(error).toLowerCase(); return retryablePatterns.some(p => errorStr.includes(p.toLowerCase())); })",
"getRetryStrategy": "(function(error) { const errorStr = String(error).toLowerCase(); if (errorStr.includes('timeout') || errorStr.includes('network')) return 'exponential'; if (errorStr.includes('stale')) return 'immediate'; if (errorStr.includes('not found') || errorStr.includes('not visible')) return 'linear'; return 'linear'; })",
"executeWithRetry": "(async function(action, selector, options = {}) { const { maxRetries = 3, delay = 1000, preRetry = null } = options; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const element = document.querySelector(selector); if (!element) throw new Error('Element not found: ' + selector); if (action === 'click') element.click(); else if (action === 'focus') element.focus(); return { success: true, attempts: attempt }; } catch (error) { console.log(`Attempt ${attempt} failed: ${error.message}`); if (attempt < maxRetries) { if (preRetry) await preRetry(); await new Promise(r => setTimeout(r, delay)); } else { return { success: false, error: error.message, attempts: attempt }; } } } })"
},
"stepRetry": {
"enabled": true,
"defaultMaxRetries": 2,
"stepTypeConfig": {
"click": {
"maxRetries": 3,
"delay": 500,
"preRetryActions": ["closeOverlays", "scrollIntoView"]
},
"fill": {
"maxRetries": 2,
"delay": 300,
"preRetryActions": ["waitForPageLoad"]
},
"navigate": {
"maxRetries": 3,
"delay": 2000,
"preRetryActions": ["waitForNetwork"]
},
"waitFor": {
"maxRetries": 5,
"delay": 1000,
"preRetryActions": ["waitForPageLoad"]
},
"assert": {
"maxRetries": 2,
"delay": 500,
"preRetryActions": []
}
}
},
"scenarioRetry": {
"enabled": true,
"maxRetries": 2,
"retryOnFailure": true,
"retryOnError": true,
"freshStart": true,
"resetState": {
"clearCookies": false,
"clearStorage": false,
"reloadPage": true
}
},
"circuitBreaker": {
"enabled": true,
"description": "연속 실패 시 중단",
"failureThreshold": 5,
"resetTimeout": 30000,
"states": {
"closed": "정상 동작",
"open": "요청 차단",
"halfOpen": "테스트 요청 허용"
}
},
"reporting": {
"logRetries": true,
"includeInReport": true,
"format": {
"retryLog": {
"timestamp": "시도 시간",
"attempt": "시도 횟수",
"error": "오류 내용",
"action": "재시도 전 액션",
"result": "결과"
}
},
"template": {
"header": "## 🔄 재시도 로그",
"sections": {
"summary": "### 요약",
"details": "### 상세 로그"
}
}
},
"hooks": {
"beforeRetry": {
"description": "재시도 전 실행",
"actions": ["logAttempt", "executePreRetryAction"]
},
"afterRetry": {
"description": "재시도 후 실행",
"actions": ["logResult", "updateMetrics"]
},
"onMaxRetriesExceeded": {
"description": "최대 재시도 초과 시",
"actions": ["captureScreenshot", "logFinalError", "reportFailure"]
}
},
"metrics": {
"track": true,
"collect": [
"totalRetries",
"successAfterRetry",
"failureAfterRetry",
"averageRetriesPerStep",
"mostRetriedSteps",
"retrySuccessRate"
]
},
"integration": {
"withAPI": {
"retryOn5xx": true,
"retryOnTimeout": true,
"maxRetries": 3
},
"withVisual": {
"retryOnCaptureFail": true,
"maxRetries": 2
},
"withCRUD": {
"retryCreate": true,
"retryUpdate": true,
"retryDelete": false,
"maxRetries": 2
}
}
}