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>
256 lines
11 KiB
JavaScript
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();
|