Files
sam-hotfix/e2e/runner/gen-form-validation.js
김보곤 48eba1e716 refactor: E2E 시나리오 생성기 8종 품질 개선 (false positive 제거 + flaky 패턴 수정)
Phase 1: R.ok=true 무조건 반환 → 조건부 검증으로 교체 (36개 시나리오 영향)
- gen-edge-cases.js: R.ok=R.validationTriggered, R.ok=R.allConsistent 등
- gen-pagination-sort.js: R.ok=R.sortWorked!==false
- gen-search-function.js: R.ok=R.searchWorked!==false
- gen-form-validation.js: R.ok=R.validationTriggered||R.hasValidation
- gen-batch-create.js: R.ok=R.created!==false
- gen-reload-persist.js: R.ok=R.persisted!==false
- gen-detail-roundtrip.js: R.ok=R.matched!==false
- gen-business-workflow.js: R.ok=!R.error&&R.phaseCompleted!==false

Phase 2: rows[0] 맹목적 접근 → E2E_TEST_ 스마트 타겟팅 추가
- gen-detail-roundtrip.js, gen-business-workflow.js에 testRow 탐색 패턴 적용

결과: 184 시나리오 중 9개 정당한 FAIL 노출 (실제 버그 5건 발견)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:54:57 +09:00

256 lines
11 KiB
JavaScript

#!/usr/bin/env node
/**
* 폼 유효성 검증(Form Validation) 감사 시나리오 생성기
*
* 각 페이지에서 필수 필드를 채우지 않고 등록/저장 버튼 클릭 시
* 에러 메시지(토스트, 인라인, 다이얼로그)가 표시되는지 감사.
*
* ⚠️ 감사(Audit) 모드: 항상 PASS, 결과에 발견된 유효성 검증 정보를 기록.
* 유효성 검증이 없는 경우 경고(warn)로 표시.
*
* Usage:
* node e2e/runner/gen-form-validation.js
*
* Output:
* e2e/scenarios/form-validation-acc.json (회계: 어음/입금/출금)
* e2e/scenarios/form-validation-sales.json (판매: 거래처/수주/견적)
* e2e/scenarios/form-validation-misc.json (생산/게시판)
*/
const fs = require('fs');
const path = require('path');
const SCENARIOS_DIR = path.resolve(__dirname, '..', 'scenarios');
// ════════════════════════════════════════════════════════════════
// COMMON SCRIPTS
// ════════════════════════════════════════════════════════════════
const H = `const w=ms=>new Promise(r=>setTimeout(r,ms));`;
// 등록 폼 열기 (기존 input-fields 패턴 재사용)
const OPEN_FORM = [
`(async()=>{`, H,
`const R={phase:'OPEN_FORM'};`,
`const priorities=['등록','추가','작성','글쓰기','신규'];`,
`const exclude=['신규업체','신규거래'];`,
`let btn=null;`,
`for(const kw of priorities){`,
` btn=Array.from(document.querySelectorAll('button')).find(b=>{`,
` const t=b.innerText?.trim()||'';`,
` if(exclude.some(e=>t.includes(e)))return false;`,
` return t.includes(kw)&&b.offsetParent!==null&&!b.disabled;`,
` });if(btn)break;`,
`}`,
`if(!btn){btn=Array.from(document.querySelectorAll('button')).find(b=>b.innerText?.trim()?.startsWith('+')&&b.offsetParent!==null);}`,
`if(!btn){R.err='등록 버튼 없음';R.ok=true;return JSON.stringify(R);}`,
`R.btnText=btn.innerText?.trim();`,
`btn.click();await w(2500);`,
`R.url=location.pathname+location.search;R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
// 빈 폼 제출 + 유효성 검증 감사
const SUBMIT_EMPTY_AND_AUDIT = [
`(async()=>{`, H,
`const R={phase:'VALIDATION_AUDIT'};`,
// Before state
`const beforeToasts=document.querySelectorAll('[data-sonner-toast],[role="status"],[class*="toast"],[class*="Toast"]').length;`,
`const beforeErrors=document.querySelectorAll('[class*="error"],[class*="Error"],[class*="destructive"],[role="alert"]').length;`,
// Find and click submit button (without filling any fields)
`const submitBtn=Array.from(document.querySelectorAll('button')).find(b=>{`,
` const t=b.innerText?.trim()||'';`,
` return(/등록|저장|확인|제출/.test(t))&&b.offsetParent!==null&&!b.disabled;`,
`});`,
`if(!submitBtn){R.err='등록/저장 버튼 없음';R.ok=true;return JSON.stringify(R);}`,
`R.submitBtnText=submitBtn.innerText?.trim();`,
`submitBtn.click();await w(2000);`,
// After state - check for validation indicators
// 1. Toast messages
`const toasts=document.querySelectorAll('[data-sonner-toast],[role="status"],[class*="toast"],[class*="Toast"],[class*="Toaster"] [data-content]');`,
`R.toastCount=toasts.length;`,
`R.newToasts=toasts.length-beforeToasts;`,
`if(toasts.length>0){R.toastTexts=Array.from(toasts).map(t=>t.innerText?.trim().substring(0,80)).filter(t=>t);}`,
// 2. Inline error messages
`const errors=document.querySelectorAll('[class*="error"],[class*="Error"],[class*="destructive"],[role="alert"],[class*="invalid"]');`,
`R.errorCount=errors.length;`,
`R.newErrors=errors.length-beforeErrors;`,
`if(errors.length>0){R.errorTexts=Array.from(errors).slice(0,5).map(e=>e.innerText?.trim().substring(0,60)).filter(t=>t);}`,
// 3. Required field indicators (aria-invalid, :invalid pseudo)
`const invalidFields=document.querySelectorAll('[aria-invalid="true"]');`,
`R.ariaInvalidCount=invalidFields.length;`,
// 4. Red border fields (visual)
`const redBorders=Array.from(document.querySelectorAll('input,textarea,select,[role="combobox"]')).filter(el=>{`,
` const cs=getComputedStyle(el);`,
` return cs.borderColor?.includes('rgb(239')||cs.borderColor?.includes('rgb(220')||cs.borderColor?.includes('rgb(248')||cs.outlineColor?.includes('rgb(239');`,
`});`,
`R.redBorderCount=redBorders.length;`,
// 5. Dialog/alert for validation
`const dialogs=document.querySelectorAll('[role="alertdialog"],[role="dialog"]');`,
`const validationDialog=Array.from(dialogs).find(d=>d.offsetParent!==null);`,
`R.hasValidationDialog=!!validationDialog;`,
`if(validationDialog){R.dialogText=validationDialog.innerText?.trim().substring(0,100);}`,
// 6. URL changed? (might indicate submission succeeded without validation)
`R.urlAfterSubmit=location.pathname+location.search;`,
`R.urlChanged=R.urlAfterSubmit!==location.pathname+location.search;`,
// Summary
`R.totalValidationSignals=R.newToasts+R.newErrors+R.ariaInvalidCount+R.redBorderCount+(R.hasValidationDialog?1:0);`,
`R.hasValidation=R.totalValidationSignals>0;`,
`if(!R.hasValidation)R.warn='❌ 유효성 검증 미감지 - 빈 폼 제출 시 에러 메시지 없음';`,
`R.ok=R.hasValidation;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
// 모달/폼 닫기
const CLOSE_FORM = [
`(async()=>{`, H,
`const R={phase:'CLOSE_FORM'};`,
// Close any validation dialog first
`const dlg=document.querySelector('[role="alertdialog"],[role="dialog"]');`,
`if(dlg&&dlg.offsetParent!==null){`,
` const closeBtn=dlg.querySelector('button[class*="close"]')||Array.from(dlg.querySelectorAll('button')).find(b=>/닫기|확인|취소|Close/.test(b.innerText?.trim()));`,
` if(closeBtn){closeBtn.click();await w(500);}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(500);}`,
`}`,
// If on form page (mode=new/edit), go back
`if(location.search.includes('mode=new')||location.search.includes('mode=edit')){`,
` const backBtn=Array.from(document.querySelectorAll('button,a')).find(b=>/목록|취소|뒤로/.test(b.innerText?.trim()));`,
` if(backBtn){backBtn.click();await w(2000);}`,
` else{history.back();await w(2000);}`,
`}`,
// Close modal if still open
`const modal=document.querySelector('[role="dialog"],[aria-modal="true"],[class*="modal"]:not([class*="tooltip"])');`,
`if(modal&&modal.offsetParent!==null){`,
` const xBtn=modal.querySelector('button[class*="close"],[aria-label="닫기"],[aria-label="Close"]')||Array.from(modal.querySelectorAll('button')).find(b=>/닫기|취소|Close/.test(b.innerText?.trim()));`,
` if(xBtn){xBtn.click();await w(500);}`,
` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(500);}`,
`}`,
`R.url=location.pathname+location.search;R.ok=true;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
// ════════════════════════════════════════════════════════════════
// PAGE GROUPS
// ════════════════════════════════════════════════════════════════
const GROUPS = [
{
id: 'form-validation-acc',
name: '폼 유효성 검증 감사: 회계 (어음/입금/출금)',
pages: [
{ level1: '회계관리', level2: '어음관리' },
{ level1: '회계관리', level2: '입금관리' },
{ level1: '회계관리', level2: '출금관리' },
],
},
{
id: 'form-validation-sales',
name: '폼 유효성 검증 감사: 판매 (거래처/수주/견적)',
pages: [
{ level1: '판매관리', level2: '거래처관리' },
{ level1: '판매관리', level2: '수주관리' },
{ level1: '판매관리', level2: '견적관리' },
],
},
{
id: 'form-validation-misc',
name: '폼 유효성 검증 감사: 생산/게시판',
pages: [
{ level1: '생산관리', level2: '작업지시 관리' },
{ level1: '게시판', level2: '자유게시판' },
],
},
];
// ════════════════════════════════════════════════════════════════
// SCENARIO GENERATOR
// ════════════════════════════════════════════════════════════════
function generateScenario(group) {
const steps = [];
let id = 1;
for (let pi = 0; pi < group.pages.length; pi++) {
const page = group.pages[pi];
const pfx = `[${page.level1} > ${page.level2}]`;
// Menu navigate (except first page which uses scenario menuNavigation)
if (pi > 0) {
steps.push({
id: id++,
name: `${pfx} 메뉴 이동`,
action: 'menu_navigate',
level1: page.level1,
level2: page.level2,
timeout: 10000,
});
}
// Page load
steps.push({ id: id++, name: `${pfx} 페이지 로드 대기`, action: 'wait', timeout: 3000 });
steps.push({ id: id++, name: `${pfx} 테이블 로드 대기`, action: 'wait_for_table', timeout: 5000 });
// Open form
steps.push({
id: id++,
name: `${pfx} 등록 폼 열기`,
action: 'evaluate',
script: OPEN_FORM,
timeout: 15000,
});
// Wait for form render
steps.push({ id: id++, name: `${pfx} 폼 렌더링 대기`, action: 'wait', timeout: 2000 });
// Submit empty + audit validation
steps.push({
id: id++,
name: `${pfx} 빈 폼 제출 유효성 감사`,
action: 'evaluate',
script: SUBMIT_EMPTY_AND_AUDIT,
timeout: 15000,
phase: 'VALIDATE',
});
// Close form
steps.push({
id: id++,
name: `${pfx} 폼 닫기`,
action: 'evaluate',
script: CLOSE_FORM,
timeout: 10000,
});
}
return {
id: group.id,
name: group.name,
version: '1.0.0',
auth: { role: 'admin' },
menuNavigation: group.pages[0], // First page
screenshotPolicy: { captureOnFail: true, captureOnPass: false },
steps,
};
}
// ════════════════════════════════════════════════════════════════
// MAIN
// ════════════════════════════════════════════════════════════════
function main() {
if (!fs.existsSync(SCENARIOS_DIR)) fs.mkdirSync(SCENARIOS_DIR, { recursive: true });
const generated = [];
for (const group of GROUPS) {
const scenario = generateScenario(group);
const filePath = path.join(SCENARIOS_DIR, `${scenario.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(scenario, null, 2), 'utf-8');
generated.push({ id: scenario.id, steps: scenario.steps.length });
console.log(` ${scenario.id}.json (${scenario.steps.length} steps)`);
}
console.log(`\n Generated ${generated.length} scenarios`);
console.log(`\n Run: node e2e/runner/run-all.js --filter form-validation`);
}
main();