Files
sam-hotfix/e2e/runner/fix-scenario-quality.js
김보곤 f27fa72c64 test: E2E 시나리오 품질 감사 및 CRUD 강화 - 68/68 PASS (2026-02-11)
- 시나리오 품질 감사 리포트 추가 (8개 이슈 유형, 68개 시나리오 분석)
- CRUD 수정 스크립트 6개 추가 (DELETE/UPDATE/CREATE 액션 정합성 강화)
- 최종 테스트 결과: 68/68 (100%) PASS, 19.6분 소요

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:43:40 +09:00

411 lines
12 KiB
JavaScript

/**
* fix-scenario-quality.js
*
* E2E 시나리오 JSON 파일의 3가지 품질 이슈를 일괄 수정하는 스크립트.
*
* Issue 1: DELETE phase에서 verify_element을 사용하는 스텝
* - 첫 번째 DELETE 스텝 (삭제 버튼 클릭): verify_element → click
* - 두 번째 DELETE 스텝 (삭제 확인): verify_element → click_dialog_confirm (target 제거)
*
* Issue 2: UPDATE phase에서 input/textarea를 대상으로 click_if_exists를 사용하는 스텝
* - click_if_exists → fill 로 변경하고 testData.update에서 매칭되는 value 추가
* - 단, button을 대상으로 하는 스텝은 변경하지 않음
*
* Issue 3: fields 배열을 가진 스텝의 action이 fill_form이 아닌 경우
* - action을 fill_form으로 변경
*
* Usage: node e2e/runner/fix-scenario-quality.js [--dry-run]
*/
const fs = require('fs');
const path = require('path');
const SCENARIOS_DIR = path.join(__dirname, '..', 'scenarios');
const DRY_RUN = process.argv.includes('--dry-run');
// 스킵할 파일/디렉토리 패턴
const SKIP_PATTERNS = [
'_backup_before_enhance',
'_global-',
'_templates',
'pdf-download-test.json',
'.claude'
];
// Issue 2 (UPDATE fill) 적용에서 제외할 시나리오
// settings-* 시나리오는 시스템 설정 UI 검증용이므로 fill로 변경하면 잘못된 값 입력 위험
const SKIP_UPDATE_FILL_PATTERNS = [
'settings-account',
'settings-attendance',
'settings-company',
'settings-notification',
'settings-vacation-policy'
];
// 변경 추적 로그
const changeLog = [];
function shouldSkipFile(filePath) {
const rel = path.relative(SCENARIOS_DIR, filePath);
return SKIP_PATTERNS.some(p => rel.includes(p));
}
/**
* target 문자열이 input 또는 textarea를 가리키는지 확인
* button 대상은 제외
*/
function isInputOrTextareaTarget(target) {
if (!target || typeof target !== 'string') return false;
const lower = target.toLowerCase();
// button을 대상으로 하는 경우는 제외
if (lower.includes('button:') || lower.includes('button[')) return false;
// input 또는 textarea를 대상으로 하는 경우
return (
lower.includes('input[') ||
lower.includes('input:') ||
lower.includes('textarea[') ||
lower.includes('textarea:') ||
lower.includes('select[') ||
lower.includes('select:')
);
}
/**
* target 문자열에서 필드명 힌트를 추출
* 예: "textarea[name*='memo'], input[placeholder*='메모']" → ['memo', '메모']
*/
function extractFieldHints(target) {
if (!target) return [];
const hints = [];
// name*='xxx' 패턴
const nameMatches = target.matchAll(/name\*?=\s*['"]([^'"]+)['"]/g);
for (const m of nameMatches) {
hints.push(m[1].toLowerCase());
}
// placeholder*='xxx' 패턴
const phMatches = target.matchAll(/placeholder\*?=\s*['"]([^'"]+)['"]/g);
for (const m of phMatches) {
hints.push(m[1].toLowerCase());
}
return hints;
}
/**
* testData.update 객체에서 필드 힌트와 매칭되는 값을 찾음
*/
function findUpdateValue(updateData, fieldHints) {
if (!updateData || typeof updateData !== 'object') return null;
for (const hint of fieldHints) {
// 정확한 키 매칭
for (const [key, val] of Object.entries(updateData)) {
if (typeof val === 'string' && key.toLowerCase().includes(hint)) {
return val;
}
}
// 한글 → 영문 매핑
const koToEn = {
'메모': 'memo',
'금액': 'amount',
'사유': 'reason',
'수량': 'quantity',
'위치': 'location',
'거래처명': 'name',
'대표': 'representative',
'전화': 'phone',
'팩스': 'fax',
'이메일': 'email'
};
const enKey = koToEn[hint];
if (enKey) {
for (const [key, val] of Object.entries(updateData)) {
if (typeof val === 'string' && key.toLowerCase().includes(enKey)) {
return val;
}
}
}
// 역매핑: 영문 힌트 → testData 키에서 한글 포함 매칭
for (const [ko, en] of Object.entries(koToEn)) {
if (hint.includes(en)) {
for (const [key, val] of Object.entries(updateData)) {
if (typeof val === 'string' && (key.toLowerCase().includes(en) || key.toLowerCase().includes(ko))) {
return val;
}
}
}
}
}
return null;
}
/**
* 필드 힌트에서 사람이 읽을 수 있는 필드명 추출
*/
function humanFieldName(fieldHints) {
if (fieldHints.length === 0) return 'field';
// 한글이 있으면 한글 우선
const ko = fieldHints.find(h => /[-]/.test(h));
if (ko) return ko;
return fieldHints[0];
}
/**
* Issue 1: DELETE phase에서 verify_element → click / click_dialog_confirm
*/
function fixDeleteSteps(scenario, fileName) {
const steps = scenario.steps;
if (!steps || !Array.isArray(steps)) return;
// DELETE phase 스텝 중 action이 verify_element인 것만 수집
const deleteVerifySteps = steps.filter(
s => s.phase === 'DELETE' && s.action === 'verify_element'
);
if (deleteVerifySteps.length === 0) return;
// DELETE phase의 verify_element 스텝을 순서대로 처리
let deleteIndex = 0;
for (const step of deleteVerifySteps) {
const stepName = (step.name || '').toLowerCase();
const isConfirmStep =
stepName.includes('확인') ||
stepName.includes('confirm') ||
stepName.includes('필수 검증 #6') ||
stepName.includes('필수검증 #6');
// 첫 번째 verify_element은 click (삭제 버튼 클릭)
// 두 번째 이후 또는 이름에 "확인"이 포함되면 click_dialog_confirm
if (deleteIndex === 0 && !isConfirmStep) {
// 첫 번째: 삭제 버튼 클릭 → click
changeLog.push({
file: fileName,
stepId: step.id,
stepName: step.name,
issue: 'Issue 1a',
before: `action: "verify_element", target: "${step.target}"`,
after: `action: "click", target: "${step.target}"`
});
step.action = 'click';
// target은 유지
} else {
// 두 번째: 삭제 확인 → click_dialog_confirm (target 제거)
const oldTarget = step.target;
changeLog.push({
file: fileName,
stepId: step.id,
stepName: step.name,
issue: 'Issue 1b',
before: `action: "verify_element", target: "${oldTarget}"`,
after: `action: "click_dialog_confirm" (target removed)`
});
step.action = 'click_dialog_confirm';
delete step.target;
}
deleteIndex++;
}
}
/**
* Issue 2: UPDATE phase에서 input/textarea 대상의 click_if_exists → fill
*/
function fixUpdateSteps(scenario, fileName) {
const steps = scenario.steps;
if (!steps || !Array.isArray(steps)) return;
// settings-* 시나리오는 Issue 2 적용 제외
if (SKIP_UPDATE_FILL_PATTERNS.some(p => fileName.includes(p))) return;
const updateData = scenario.testData?.update || null;
for (const step of steps) {
if (step.phase !== 'UPDATE') continue;
if (step.action !== 'click_if_exists') continue;
if (!isInputOrTextareaTarget(step.target)) continue;
// 이미 value가 있는 경우는 스킵
if (step.value) continue;
// "필드 확인" 이름은 존재 확인용이므로 스킵
if (step.name && step.name.includes('필드 확인')) continue;
const fieldHints = extractFieldHints(step.target);
let value = findUpdateValue(updateData, fieldHints);
if (!value) {
// testData에 매칭 없으면, 필드 타입에 따라 적절한 값 생성
const target = (step.target || '').toLowerCase();
if (target.includes("type='number'") || target.includes("type=\"number\"") ||
fieldHints.some(h => ['quantity', 'qty', '수량', 'amount', '금액', 'count'].includes(h))) {
value = '200';
} else if (target.includes("type='time'") || target.includes("type=\"time\"")) {
value = '18:00';
} else if (target.includes("type='tel'")) {
value = '010-9999-8888';
} else {
const fieldName = humanFieldName(fieldHints);
value = `E2E_수정_${fieldName}`;
}
}
changeLog.push({
file: fileName,
stepId: step.id,
stepName: step.name,
issue: 'Issue 2',
before: `action: "click_if_exists", target: "${step.target}" (no value)`,
after: `action: "fill", target: "${step.target}", value: "${value}"`
});
step.action = 'fill';
step.value = value;
}
}
/**
* Issue 3: fields 배열이 있는데 action이 fill_form이 아닌 스텝 수정
*/
function fixFieldsAction(scenario, fileName) {
const steps = scenario.steps;
if (!steps || !Array.isArray(steps)) return;
for (const step of steps) {
// step 자체에 fields 배열이 있어야 함 (expect.fields나 form.fields는 제외)
if (!Array.isArray(step.fields)) continue;
if (step.action === 'fill_form') continue;
const oldAction = step.action;
changeLog.push({
file: fileName,
stepId: step.id,
stepName: step.name,
issue: 'Issue 3',
before: `action: "${oldAction}" with fields array`,
after: `action: "fill_form" with fields array`
});
step.action = 'fill_form';
}
}
/**
* 메인 실행
*/
function main() {
console.log('='.repeat(70));
console.log(' E2E Scenario Quality Fix Script');
console.log(' ' + (DRY_RUN ? '[DRY RUN - no files will be modified]' : '[LIVE MODE - files will be modified]'));
console.log('='.repeat(70));
console.log();
// 시나리오 파일 목록 수집
const files = fs.readdirSync(SCENARIOS_DIR)
.filter(f => f.endsWith('.json'))
.filter(f => !SKIP_PATTERNS.some(p => f.includes(p)))
.sort();
console.log(`Found ${files.length} scenario files to process.\n`);
let filesModified = 0;
let filesSkipped = 0;
for (const file of files) {
const filePath = path.join(SCENARIOS_DIR, file);
if (shouldSkipFile(filePath)) {
filesSkipped++;
continue;
}
let content;
try {
content = fs.readFileSync(filePath, 'utf-8');
} catch (err) {
console.log(` [ERROR] Failed to read ${file}: ${err.message}`);
continue;
}
let scenario;
try {
scenario = JSON.parse(content);
} catch (err) {
console.log(` [ERROR] Failed to parse ${file}: ${err.message}`);
continue;
}
const beforeCount = changeLog.length;
// 3가지 이슈 수정 적용
fixDeleteSteps(scenario, file);
fixUpdateSteps(scenario, file);
fixFieldsAction(scenario, file);
const changesInFile = changeLog.length - beforeCount;
if (changesInFile > 0) {
filesModified++;
console.log(` [FIX] ${file} - ${changesInFile} change(s)`);
if (!DRY_RUN) {
const output = JSON.stringify(scenario, null, 2);
fs.writeFileSync(filePath, output + '\n', 'utf-8');
}
}
}
// 요약 출력
console.log();
console.log('='.repeat(70));
console.log(' SUMMARY');
console.log('='.repeat(70));
console.log();
console.log(` Total files scanned: ${files.length}`);
console.log(` Files modified: ${filesModified}`);
console.log(` Files skipped: ${filesSkipped}`);
console.log(` Total changes: ${changeLog.length}`);
console.log();
// 이슈 유형별 통계
const issueStats = {};
for (const change of changeLog) {
issueStats[change.issue] = (issueStats[change.issue] || 0) + 1;
}
console.log(' Changes by issue type:');
console.log(' ----------------------');
if (issueStats['Issue 1a']) {
console.log(` Issue 1a (DELETE verify_element → click): ${issueStats['Issue 1a']}`);
}
if (issueStats['Issue 1b']) {
console.log(` Issue 1b (DELETE verify_element → click_dialog_confirm): ${issueStats['Issue 1b']}`);
}
if (issueStats['Issue 2']) {
console.log(` Issue 2 (UPDATE click_if_exists → fill): ${issueStats['Issue 2']}`);
}
if (issueStats['Issue 3']) {
console.log(` Issue 3 (fields array action → fill_form): ${issueStats['Issue 3']}`);
}
console.log();
// 상세 변경 로그
if (changeLog.length > 0) {
console.log(' Detailed changes:');
console.log(' -----------------');
for (const change of changeLog) {
console.log(` [${change.issue}] ${change.file} (step ${change.stepId}: "${change.stepName}")`);
console.log(` Before: ${change.before}`);
console.log(` After: ${change.after}`);
console.log();
}
} else {
console.log(' No changes needed - all scenarios are clean!');
}
if (DRY_RUN && changeLog.length > 0) {
console.log(' *** DRY RUN: No files were modified. Run without --dry-run to apply changes. ***');
console.log();
}
}
main();