#!/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();