- 시나리오 품질 감사 리포트 추가 (8개 이슈 유형, 68개 시나리오 분석) - CRUD 수정 스크립트 6개 추가 (DELETE/UPDATE/CREATE 액션 정합성 강화) - 최종 테스트 결과: 68/68 (100%) PASS, 19.6분 소요 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
411 lines
12 KiB
JavaScript
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();
|