#!/usr/bin/env node /** * 엣지 케이스 테스트 시나리오 생성기 * * 폼 제출 빈 값 검증, 경계값 입력, 특수문자 검색, 연타 클릭(디바운스) 등 * 15개 시나리오를 자동 생성한다. * * Usage: node e2e/runner/gen-edge-cases.js * * Output: * e2e/scenarios/edge-*.json (15 files) * * Run: * node e2e/runner/run-all.js --filter edge */ const fs = require('fs'); const path = require('path'); const SCENARIOS_DIR = path.resolve(__dirname, '..', 'scenarios'); // ════════════════════════════════════════════════════════════════ // Reusable evaluate script builders // ════════════════════════════════════════════════════════════════ const H = `const w=ms=>new Promise(r=>setTimeout(r,ms));`; // Open registration form (find and click 등록/추가/작성 button) const OPEN_FORM_SCRIPT = [ `(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(''); // Try empty submit and check validation const EMPTY_SUBMIT_SCRIPT = [ `(async()=>{`, H, `const R={phase:'EMPTY_SUBMIT_CHECK'};`, `await w(500);`, `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;`, `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);`, `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(Boolean);}`, `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(Boolean);}`, `const invalidFields=document.querySelectorAll('[aria-invalid="true"]');`, `R.ariaInvalidCount=invalidFields.length;`, `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;`, `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);}`, `R.totalValidationSignals=R.newToasts+R.newErrors+R.ariaInvalidCount+R.redBorderCount+(R.hasValidationDialog?1:0);`, `R.validationTriggered=R.totalValidationSignals>0;`, `R.ok=R.validationTriggered;`, `R.info=R.validationTriggered?'✅ 유효성 검사 정상 동작':'❌ 유효성 검사 미감지 - 빈 폼 제출 시 에러 메시지 없음';`, `return JSON.stringify(R);`, `})()`, ].join(''); // Close form / modal const CLOSE_FORM_SCRIPT = [ `(async()=>{`, H, `const R={phase:'CLOSE_FORM'};`, // Close alert 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 in new/edit mode, 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 any remaining modal `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(''); // Check boundary fill results (after fill_boundary steps) function boundaryCheckScript(phase) { return [ `(async()=>{`, H, `const R={phase:'${phase}'};`, `await w(1000);`, `const hasError=document.querySelector('[class*="error"],[class*="Error"],[role="alert"],.text-red-500,.text-destructive');`, `const errorTexts=Array.from(document.querySelectorAll('[class*="error"],[class*="Error"],[role="alert"],.text-red-500,.text-destructive')).map(e=>e.innerText?.trim()).filter(Boolean);`, `R.errorDetected=!!hasError||errorTexts.length>0;`, `R.errorMessages=errorTexts.slice(0,5);`, `const inputs=Array.from(document.querySelectorAll('input:not([type="hidden"]),textarea'));`, `R.inputCount=inputs.length;`, `const overflowInputs=inputs.filter(el=>{`, ` const max=el.maxLength;`, ` return max>0&&el.value.length>max;`, `});`, `R.overflowCount=overflowInputs.length;`, `const truncatedInputs=inputs.filter(el=>{`, ` const max=el.maxLength;`, ` return max>0&&el.value.length===max;`, `});`, `R.truncatedCount=truncatedInputs.length;`, `R.ok=true;`, `R.info=R.errorDetected?'✅ 경계값 입력 시 에러/경고 감지':'⚠️ 경계값 입력 시 에러 미감지 (정상 가능)';`, `return JSON.stringify(R);`, `})()`, ].join(''); } // Check search result after special char search function searchResultCheckScript(phase) { return [ `(async()=>{`, H, `const R={phase:'${phase}'};`, `await w(2000);`, `const rows=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null);`, `R.rowCount=rows.length;`, `const hasError=document.querySelector('[class*="error"],[class*="Error"],[role="alert"]');`, `R.hasError=!!hasError;`, `if(hasError){R.errorText=hasError.innerText?.trim().substring(0,80);}`, `const noDataMsg=document.body.innerText.includes('데이터가 없습니다')||document.body.innerText.includes('검색 결과가 없습니다')||document.body.innerText.includes('No data');`, `R.noDataMessage=noDataMsg;`, `R.pageStable=!document.querySelector('.loading,.spinner,[class*="skeleton"]');`, `R.ok=!R.hasError;`, `R.info=R.hasError?'❌ 특수문자 검색 시 에러 발생':'✅ 특수문자 검색 시 에러 없음 (정상)';`, `return JSON.stringify(R);`, `})()`, ].join(''); } // Clear search input const CLEAR_SEARCH_SCRIPT = [ `(async()=>{`, H, `const R={phase:'CLEAR_SEARCH'};`, `const searchInput=document.querySelector('input[placeholder*="검색"]')||document.querySelector('input[type="search"]')||document.querySelector('input[role="searchbox"]');`, `if(searchInput){`, ` const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`, ` if(ns)ns.call(searchInput,'');else searchInput.value='';`, ` searchInput.dispatchEvent(new Event('input',{bubbles:true}));`, ` searchInput.dispatchEvent(new Event('change',{bubbles:true}));`, ` searchInput.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));`, ` await w(1500);`, ` R.cleared=true;`, `}else{R.cleared=false;R.warn='검색 입력란 없음';}`, `R.ok=true;return JSON.stringify(R);`, `})()`, ].join(''); // Rapid click result check function rapidClickCheckScript(phase) { return [ `(async()=>{`, H, `const R={phase:'${phase}'};`, `await w(2000);`, `const dialogs=document.querySelectorAll('[role="dialog"],[role="alertdialog"],[aria-modal="true"]');`, `const visibleDialogs=Array.from(dialogs).filter(d=>d.offsetParent!==null);`, `R.dialogCount=visibleDialogs.length;`, `const toasts=document.querySelectorAll('[data-sonner-toast],[role="status"],[class*="toast"],[class*="Toast"]');`, `R.toastCount=toasts.length;`, `if(toasts.length>0){R.toastTexts=Array.from(toasts).map(t=>t.innerText?.trim().substring(0,80)).filter(Boolean);}`, `const hasError=document.querySelector('[class*="error"],[class*="Error"],[role="alert"]');`, `R.hasError=!!hasError;`, `R.url=location.pathname+location.search;`, `R.pageStable=!document.querySelector('.loading,.spinner,[class*="skeleton"]');`, `R.ok=R.dialogCount<=1&&!R.hasError;`, `R.info=R.dialogCount<=1&&!R.hasError?'✅ 연타 클릭 후 정상 상태':'❌ 연타 클릭 후 비정상 상태 (다중 모달/에러)';`, `return JSON.stringify(R);`, `})()`, ].join(''); } // Fill search input with value function fillSearchScript(value) { return [ `(async()=>{`, H, `const R={phase:'FILL_SEARCH'};`, `const searchInput=document.querySelector('input[placeholder*="검색"]')||document.querySelector('input[type="search"]')||document.querySelector('input[role="searchbox"]');`, `if(!searchInput){R.err='검색 입력란 없음';R.ok=true;return JSON.stringify(R);}`, `searchInput.focus();await w(200);`, `const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`, `if(ns)ns.call(searchInput,${JSON.stringify(value)});else searchInput.value=${JSON.stringify(value)};`, `searchInput.dispatchEvent(new Event('input',{bubbles:true}));`, `searchInput.dispatchEvent(new Event('change',{bubbles:true}));`, `searchInput.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));`, `await w(2000);`, `R.searchValue=${JSON.stringify(value)};R.ok=true;return JSON.stringify(R);`, `})()`, ].join(''); } // Find first visible input target selector const FIND_INPUT_SCRIPT = [ `(async()=>{`, H, `const R={phase:'FIND_INPUTS'};`, `await w(1000);`, `const inputs=Array.from(document.querySelectorAll('input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]),textarea')).filter(el=>el.offsetParent!==null&&!el.disabled&&!el.readOnly);`, `R.inputCount=inputs.length;`, `R.inputSelectors=inputs.slice(0,5).map((el,i)=>{`, ` const tag=el.tagName.toLowerCase();`, ` const name=el.name||'';const placeholder=el.placeholder||'';const type=el.type||'text';`, ` let sel='';`, ` if(el.id)sel='#'+el.id;`, ` else if(name)sel=tag+'[name="'+name+'"]';`, ` else if(placeholder)sel=tag+'[placeholder*="'+placeholder.substring(0,15)+'"]';`, ` else sel=tag+':nth-of-type('+(i+1)+')';`, ` return{index:i,selector:sel,name,placeholder:placeholder.substring(0,20),type};`, `});`, `R.ok=true;return JSON.stringify(R);`, `})()`, ].join(''); // ════════════════════════════════════════════════════════════════ // Scenario Builders // ════════════════════════════════════════════════════════════════ function emptySubmitScenario(id, name, level1, level2) { const steps = []; let sid = 1; steps.push({ id: sid++, name: '페이지 로드 대기', action: 'wait', timeout: 3000 }); steps.push({ id: sid++, name: '테이블 로드 대기', action: 'wait_for_table', timeout: 5000 }); steps.push({ id: sid++, name: '등록 폼 열기', action: 'evaluate', script: OPEN_FORM_SCRIPT, timeout: 15000, phase: 'OPEN_FORM' }); steps.push({ id: sid++, name: '폼 렌더링 대기', action: 'wait', timeout: 2000 }); steps.push({ id: sid++, name: '빈 상태로 저장 클릭', action: 'evaluate', script: EMPTY_SUBMIT_SCRIPT, timeout: 15000, phase: 'EMPTY_SUBMIT_CHECK' }); steps.push({ id: sid++, name: '결과 확인 대기', action: 'wait', timeout: 1000 }); steps.push({ id: sid++, name: '폼/모달 닫기', action: 'evaluate', script: CLOSE_FORM_SCRIPT, timeout: 10000, phase: 'CLOSE_FORM' }); return { id, name, version: '1.0.0', auth: { role: 'admin' }, menuNavigation: { level1, level2 }, screenshotPolicy: { captureOnFail: true, captureOnPass: false }, steps, }; } function boundaryInputScenario(id, name, level1, level2) { const steps = []; let sid = 1; steps.push({ id: sid++, name: '페이지 로드 대기', action: 'wait', timeout: 3000 }); steps.push({ id: sid++, name: '테이블 로드 대기', action: 'wait_for_table', timeout: 5000 }); steps.push({ id: sid++, name: '등록 폼 열기', action: 'evaluate', script: OPEN_FORM_SCRIPT, timeout: 15000, phase: 'OPEN_FORM' }); steps.push({ id: sid++, name: '폼 렌더링 대기', action: 'wait', timeout: 2000 }); steps.push({ id: sid++, name: '입력 필드 탐색', action: 'evaluate', script: FIND_INPUT_SCRIPT, timeout: 10000, phase: 'FIND_INPUTS' }); // Boundary test: max_length steps.push({ id: sid++, name: '경계값: 최대 길이 입력', action: 'fill_boundary', target: 'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([disabled]):not([readonly])', boundaryType: 'max_length', maxLength: 255, timeout: 5000 }); steps.push({ id: sid++, name: '최대 길이 결과 확인', action: 'evaluate', script: boundaryCheckScript('MAX_LENGTH_CHECK'), timeout: 5000, phase: 'MAX_LENGTH_CHECK' }); // Boundary test: overflow steps.push({ id: sid++, name: '경계값: 오버플로우 입력', action: 'fill_boundary', target: 'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([disabled]):not([readonly])', boundaryType: 'overflow', maxLength: 255, timeout: 5000 }); steps.push({ id: sid++, name: '오버플로우 결과 확인', action: 'evaluate', script: boundaryCheckScript('OVERFLOW_CHECK'), timeout: 5000, phase: 'OVERFLOW_CHECK' }); // Boundary test: special_chars steps.push({ id: sid++, name: '경계값: 특수문자(XSS) 입력', action: 'fill_boundary', target: 'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([disabled]):not([readonly])', boundaryType: 'special_chars', timeout: 5000 }); steps.push({ id: sid++, name: '특수문자 결과 확인', action: 'evaluate', script: boundaryCheckScript('SPECIAL_CHARS_CHECK'), timeout: 5000, phase: 'SPECIAL_CHARS_CHECK' }); // Boundary test: whitespace steps.push({ id: sid++, name: '경계값: 공백만 입력', action: 'fill_boundary', target: 'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([disabled]):not([readonly])', boundaryType: 'whitespace', timeout: 5000 }); // Try submitting with boundary values steps.push({ id: sid++, name: '경계값 상태로 저장 시도', action: 'evaluate', script: EMPTY_SUBMIT_SCRIPT, timeout: 15000, phase: 'BOUNDARY_SUBMIT_CHECK' }); steps.push({ id: sid++, name: '폼/모달 닫기', action: 'evaluate', script: CLOSE_FORM_SCRIPT, timeout: 10000, phase: 'CLOSE_FORM' }); return { id, name, version: '1.0.0', auth: { role: 'admin' }, menuNavigation: { level1, level2 }, screenshotPolicy: { captureOnFail: true, captureOnPass: false }, steps, }; } function specialCharsSearchScenario(id, name, level1, level2) { const steps = []; let sid = 1; steps.push({ id: sid++, name: '페이지 로드 대기', action: 'wait', timeout: 3000 }); steps.push({ id: sid++, name: '테이블 로드 대기', action: 'wait_for_table', timeout: 5000 }); // Test 1: special_chars steps.push({ id: sid++, name: '특수문자 검색: "), timeout: 10000, phase: 'SEARCH_XSS' }); steps.push({ id: sid++, name: '특수문자 검색 결과 확인', action: 'evaluate', script: searchResultCheckScript('SEARCH_XSS_CHECK'), timeout: 10000, phase: 'SEARCH_XSS_CHECK' }); steps.push({ id: sid++, name: '검색 초기화', action: 'evaluate', script: CLEAR_SEARCH_SCRIPT, timeout: 5000 }); // Test 2: SQL injection steps.push({ id: sid++, name: 'SQL 인젝션 검색', action: 'evaluate', script: fillSearchScript("'; DROP TABLE users; --"), timeout: 10000, phase: 'SEARCH_SQL' }); steps.push({ id: sid++, name: 'SQL 인젝션 검색 결과 확인', action: 'evaluate', script: searchResultCheckScript('SEARCH_SQL_CHECK'), timeout: 10000, phase: 'SEARCH_SQL_CHECK' }); steps.push({ id: sid++, name: '검색 초기화', action: 'evaluate', script: CLEAR_SEARCH_SCRIPT, timeout: 5000 }); // Test 3: Unicode / emoji steps.push({ id: sid++, name: '유니코드/이모지 검색', action: 'evaluate', script: fillSearchScript('한글テスト🎉中文العربية'), timeout: 10000, phase: 'SEARCH_UNICODE' }); steps.push({ id: sid++, name: '유니코드 검색 결과 확인', action: 'evaluate', script: searchResultCheckScript('SEARCH_UNICODE_CHECK'), timeout: 10000, phase: 'SEARCH_UNICODE_CHECK' }); steps.push({ id: sid++, name: '검색 초기화', action: 'evaluate', script: CLEAR_SEARCH_SCRIPT, timeout: 5000 }); // Test 4: Very long string steps.push({ id: sid++, name: '초장문 검색', action: 'evaluate', script: fillSearchScript('A'.repeat(500)), timeout: 10000, phase: 'SEARCH_LONG' }); steps.push({ id: sid++, name: '초장문 검색 결과 확인', action: 'evaluate', script: searchResultCheckScript('SEARCH_LONG_CHECK'), timeout: 10000, phase: 'SEARCH_LONG_CHECK' }); steps.push({ id: sid++, name: '검색 초기화', action: 'evaluate', script: CLEAR_SEARCH_SCRIPT, timeout: 5000 }); return { id, name, version: '1.0.0', auth: { role: 'admin' }, menuNavigation: { level1, level2 }, screenshotPolicy: { captureOnFail: true, captureOnPass: false }, steps, }; } function rapidClickSaveScenario(id, name, level1, level2) { const steps = []; let sid = 1; steps.push({ id: sid++, name: '페이지 로드 대기', action: 'wait', timeout: 3000 }); steps.push({ id: sid++, name: '테이블 로드 대기', action: 'wait_for_table', timeout: 5000 }); steps.push({ id: sid++, name: '등록 폼 열기', action: 'evaluate', script: OPEN_FORM_SCRIPT, timeout: 15000, phase: 'OPEN_FORM' }); steps.push({ id: sid++, name: '폼 렌더링 대기', action: 'wait', timeout: 2000 }); // Rapid click the save/submit button steps.push({ id: sid++, name: '저장 버튼 연타 (5회, 50ms 간격)', action: 'rapid_click', target: 'button', // rapid_click will find the first visible button; we use evaluate to find save button first timeout: 10000, }); // Actually, we need to target the save button specifically. Let's use evaluate to find it and rapid-click. // Override the previous step with evaluate-based rapid click. steps.pop(); steps.push({ id: sid++, name: '저장 버튼 연타 (5회, 50ms 간격)', action: 'evaluate', script: [ `(async()=>{`, H, `const R={phase:'RAPID_CLICK_SAVE'};`, `const btn=Array.from(document.querySelectorAll('button')).find(b=>{`, ` const t=b.innerText?.trim()||'';`, ` return(/저장|등록|확인|제출/.test(t))&&b.offsetParent!==null&&!b.disabled;`, `});`, `if(!btn){R.err='저장/등록 버튼 없음';R.ok=true;return JSON.stringify(R);}`, `R.btnText=btn.innerText?.trim();`, `let clickCount=0;`, `for(let i=0;i<5;i++){btn.click();clickCount++;await w(50);}`, `R.clickCount=clickCount;`, `await w(2000);`, `R.ok=true;return JSON.stringify(R);`, `})()`, ].join(''), timeout: 15000, phase: 'RAPID_CLICK_SAVE', }); steps.push({ id: sid++, name: '연타 후 상태 확인', action: 'evaluate', script: rapidClickCheckScript('RAPID_CLICK_RESULT'), timeout: 10000, phase: 'RAPID_CLICK_RESULT' }); steps.push({ id: sid++, name: '폼/모달 닫기', action: 'evaluate', script: CLOSE_FORM_SCRIPT, timeout: 10000, phase: 'CLOSE_FORM' }); return { id, name, version: '1.0.0', auth: { role: 'admin' }, menuNavigation: { level1, level2 }, screenshotPolicy: { captureOnFail: true, captureOnPass: false }, steps, }; } function rapidClickDeleteScenario(id, name, level1, level2) { const steps = []; let sid = 1; steps.push({ id: sid++, name: '페이지 로드 대기', action: 'wait', timeout: 3000 }); steps.push({ id: sid++, name: '테이블 로드 대기', action: 'wait_for_table', timeout: 5000 }); steps.push({ id: sid++, name: '첫 번째 행 클릭', action: 'click_first_row', timeout: 5000 }); steps.push({ id: sid++, name: '상세 페이지 대기', action: 'wait', timeout: 2000 }); // Find and rapid-click delete button steps.push({ id: sid++, name: '삭제 버튼 연타 (5회, 50ms 간격)', action: 'evaluate', script: [ `(async()=>{`, H, `const R={phase:'RAPID_CLICK_DELETE'};`, `const btn=Array.from(document.querySelectorAll('button')).find(b=>{`, ` const t=b.innerText?.trim()||'';`, ` return(/삭제|제거|Delete/.test(t))&&b.offsetParent!==null&&!b.disabled;`, `});`, `if(!btn){R.err='삭제 버튼 없음';R.ok=true;return JSON.stringify(R);}`, `R.btnText=btn.innerText?.trim();`, `let clickCount=0;`, `for(let i=0;i<5;i++){btn.click();clickCount++;await w(50);}`, `R.clickCount=clickCount;`, `await w(2000);`, // Check if confirm dialog appeared `const dialogs=document.querySelectorAll('[role="alertdialog"],[role="dialog"]');`, `const visDialog=Array.from(dialogs).find(d=>d.offsetParent!==null);`, `R.confirmDialogShown=!!visDialog;`, `if(visDialog){R.dialogText=visDialog.innerText?.trim().substring(0,100);}`, // Cancel the deletion (do NOT actually delete) `if(visDialog){`, ` const cancelBtn=Array.from(visDialog.querySelectorAll('button')).find(b=>/취소|Cancel|아니오|닫기/.test(b.innerText?.trim()));`, ` if(cancelBtn){cancelBtn.click();await w(500);R.cancelled=true;}`, ` else{document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(500);R.cancelled=true;}`, `}`, `R.ok=true;return JSON.stringify(R);`, `})()`, ].join(''), timeout: 15000, phase: 'RAPID_CLICK_DELETE', }); steps.push({ id: sid++, name: '연타 후 상태 확인', action: 'evaluate', script: rapidClickCheckScript('RAPID_DELETE_RESULT'), timeout: 10000, phase: 'RAPID_DELETE_RESULT' }); return { id, name, version: '1.0.0', auth: { role: 'admin' }, menuNavigation: { level1, level2 }, screenshotPolicy: { captureOnFail: true, captureOnPass: false }, steps, }; } function numericBoundaryScenario(id, name, level1, level2) { const steps = []; let sid = 1; steps.push({ id: sid++, name: '페이지 로드 대기', action: 'wait', timeout: 3000 }); steps.push({ id: sid++, name: '테이블 로드 대기', action: 'wait_for_table', timeout: 5000 }); steps.push({ id: sid++, name: '등록 폼 열기', action: 'evaluate', script: OPEN_FORM_SCRIPT, timeout: 15000, phase: 'OPEN_FORM' }); steps.push({ id: sid++, name: '폼 렌더링 대기', action: 'wait', timeout: 2000 }); steps.push({ id: sid++, name: '입력 필드 탐색', action: 'evaluate', script: FIND_INPUT_SCRIPT, timeout: 10000, phase: 'FIND_INPUTS' }); // Find numeric inputs and test boundary values steps.push({ id: sid++, name: '숫자 필드 탐색 및 0 입력', action: 'evaluate', script: [ `(async()=>{`, H, `const R={phase:'NUMERIC_ZERO'};`, `const numInputs=Array.from(document.querySelectorAll('input[type="number"],input[inputmode="numeric"],input[name*="amount"],input[name*="price"],input[name*="qty"],input[name*="quantity"],input[placeholder*="금액"],input[placeholder*="수량"]')).filter(el=>el.offsetParent!==null&&!el.disabled&&!el.readOnly);`, `if(numInputs.length===0){`, ` const allInputs=Array.from(document.querySelectorAll('input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"])')).filter(el=>el.offsetParent!==null&&!el.disabled&&!el.readOnly);`, ` R.warn='숫자 전용 필드 없음 - 일반 입력 필드 '+allInputs.length+'개 발견';R.ok=true;return JSON.stringify(R);`, `}`, `R.numericFieldCount=numInputs.length;`, // Fill first numeric input with 0 `const target=numInputs[0];`, `target.focus();`, `const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`, `if(ns)ns.call(target,'0');else target.value='0';`, `target.dispatchEvent(new Event('input',{bubbles:true}));`, `target.dispatchEvent(new Event('change',{bubbles:true}));`, `await w(500);`, `R.valueSet='0';R.ok=true;return JSON.stringify(R);`, `})()`, ].join(''), timeout: 10000, phase: 'NUMERIC_ZERO', }); steps.push({ id: sid++, name: '0 입력 결과 확인', action: 'evaluate', script: boundaryCheckScript('NUMERIC_ZERO_CHECK'), timeout: 5000, phase: 'NUMERIC_ZERO_CHECK' }); // Negative value steps.push({ id: sid++, name: '숫자 필드에 음수 입력', action: 'evaluate', script: [ `(async()=>{`, H, `const R={phase:'NUMERIC_NEGATIVE'};`, `const numInputs=Array.from(document.querySelectorAll('input[type="number"],input[inputmode="numeric"],input[name*="amount"],input[name*="price"],input[name*="qty"],input[name*="quantity"],input[placeholder*="금액"],input[placeholder*="수량"]')).filter(el=>el.offsetParent!==null&&!el.disabled&&!el.readOnly);`, `if(numInputs.length===0){R.warn='숫자 필드 없음';R.ok=true;return JSON.stringify(R);}`, `const target=numInputs[0];target.focus();`, `const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`, `if(ns)ns.call(target,'-999999');else target.value='-999999';`, `target.dispatchEvent(new Event('input',{bubbles:true}));`, `target.dispatchEvent(new Event('change',{bubbles:true}));`, `await w(500);`, `R.valueSet='-999999';R.ok=true;return JSON.stringify(R);`, `})()`, ].join(''), timeout: 10000, phase: 'NUMERIC_NEGATIVE', }); steps.push({ id: sid++, name: '음수 입력 결과 확인', action: 'evaluate', script: boundaryCheckScript('NUMERIC_NEGATIVE_CHECK'), timeout: 5000, phase: 'NUMERIC_NEGATIVE_CHECK' }); // Max numeric value steps.push({ id: sid++, name: '숫자 필드에 최대값 입력', action: 'evaluate', script: [ `(async()=>{`, H, `const R={phase:'NUMERIC_MAX'};`, `const numInputs=Array.from(document.querySelectorAll('input[type="number"],input[inputmode="numeric"],input[name*="amount"],input[name*="price"],input[name*="qty"],input[name*="quantity"],input[placeholder*="금액"],input[placeholder*="수량"]')).filter(el=>el.offsetParent!==null&&!el.disabled&&!el.readOnly);`, `if(numInputs.length===0){R.warn='숫자 필드 없음';R.ok=true;return JSON.stringify(R);}`, `const target=numInputs[0];target.focus();`, `const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`, `if(ns)ns.call(target,'99999999999999');else target.value='99999999999999';`, `target.dispatchEvent(new Event('input',{bubbles:true}));`, `target.dispatchEvent(new Event('change',{bubbles:true}));`, `await w(500);`, `R.valueSet='99999999999999';R.ok=true;return JSON.stringify(R);`, `})()`, ].join(''), timeout: 10000, phase: 'NUMERIC_MAX', }); steps.push({ id: sid++, name: '최대값 입력 결과 확인', action: 'evaluate', script: boundaryCheckScript('NUMERIC_MAX_CHECK'), timeout: 5000, phase: 'NUMERIC_MAX_CHECK' }); // Try submit steps.push({ id: sid++, name: '경계값 상태로 저장 시도', action: 'evaluate', script: EMPTY_SUBMIT_SCRIPT, timeout: 15000, phase: 'NUMERIC_SUBMIT_CHECK' }); steps.push({ id: sid++, name: '폼/모달 닫기', action: 'evaluate', script: CLOSE_FORM_SCRIPT, timeout: 10000, phase: 'CLOSE_FORM' }); return { id, name, version: '1.0.0', auth: { role: 'admin' }, menuNavigation: { level1, level2 }, screenshotPolicy: { captureOnFail: true, captureOnPass: false }, steps, }; } function unicodeInputScenario(id, name, level1, level2) { const steps = []; let sid = 1; steps.push({ id: sid++, name: '페이지 로드 대기', action: 'wait', timeout: 3000 }); steps.push({ id: sid++, name: '테이블 로드 대기', action: 'wait_for_table', timeout: 5000 }); steps.push({ id: sid++, name: '등록 폼 열기', action: 'evaluate', script: OPEN_FORM_SCRIPT, timeout: 15000, phase: 'OPEN_FORM' }); steps.push({ id: sid++, name: '폼 렌더링 대기', action: 'wait', timeout: 2000 }); // Unicode: emoji steps.push({ id: sid++, name: '유니코드: 이모지 입력', action: 'fill_boundary', target: 'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([disabled]):not([readonly]), textarea', boundaryType: 'unicode', timeout: 5000 }); steps.push({ id: sid++, name: '이모지 입력 결과 확인', action: 'evaluate', script: boundaryCheckScript('EMOJI_CHECK'), timeout: 5000, phase: 'EMOJI_CHECK' }); // Unicode: custom CJK + RTL steps.push({ id: sid++, name: '유니코드: CJK + RTL 입력', action: 'evaluate', script: [ `(async()=>{`, H, `const R={phase:'CJK_RTL_INPUT'};`, `const inputs=Array.from(document.querySelectorAll('input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]),textarea')).filter(el=>el.offsetParent!==null&&!el.disabled&&!el.readOnly);`, `if(inputs.length===0){R.err='입력 필드 없음';R.ok=true;return JSON.stringify(R);}`, `const target=inputs[0];target.focus();`, `const val='漢字ひらがなカタカナ한국어العربيةעברית';`, `const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`, `if(ns)ns.call(target,val);else target.value=val;`, `target.dispatchEvent(new Event('input',{bubbles:true}));`, `target.dispatchEvent(new Event('change',{bubbles:true}));`, `await w(500);`, `R.valueSet=val;R.currentValue=target.value;R.matches=target.value===val;`, `R.ok=true;R.info=R.matches?'✅ CJK/RTL 유니코드 정상 입력':'⚠️ CJK/RTL 유니코드 입력 값 불일치';`, `return JSON.stringify(R);`, `})()`, ].join(''), timeout: 10000, phase: 'CJK_RTL_INPUT', }); // Unicode: zero-width characters steps.push({ id: sid++, name: '유니코드: 제로폭 문자 입력', action: 'evaluate', script: [ `(async()=>{`, H, `const R={phase:'ZERO_WIDTH_INPUT'};`, `const inputs=Array.from(document.querySelectorAll('input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]),textarea')).filter(el=>el.offsetParent!==null&&!el.disabled&&!el.readOnly);`, `if(inputs.length===0){R.err='입력 필드 없음';R.ok=true;return JSON.stringify(R);}`, `const target=inputs[0];target.focus();`, `const val='test\\u200B\\u200C\\u200D\\uFEFF\\u00ADtext';`, `const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`, `if(ns)ns.call(target,val);else target.value=val;`, `target.dispatchEvent(new Event('input',{bubbles:true}));`, `target.dispatchEvent(new Event('change',{bubbles:true}));`, `await w(500);`, `R.valueLength=target.value.length;R.visibleLength=target.value.replace(/[\\u200B\\u200C\\u200D\\uFEFF\\u00AD]/g,'').length;`, `R.ok=true;R.info='제로폭 문자 포함 입력 완료 (전체: '+R.valueLength+', 가시: '+R.visibleLength+')';`, `return JSON.stringify(R);`, `})()`, ].join(''), timeout: 10000, phase: 'ZERO_WIDTH_INPUT', }); steps.push({ id: sid++, name: '유니코드 상태로 저장 시도', action: 'evaluate', script: EMPTY_SUBMIT_SCRIPT, timeout: 15000, phase: 'UNICODE_SUBMIT_CHECK' }); steps.push({ id: sid++, name: '폼/모달 닫기', action: 'evaluate', script: CLOSE_FORM_SCRIPT, timeout: 10000, phase: 'CLOSE_FORM' }); return { id, name, version: '1.0.0', auth: { role: 'admin' }, menuNavigation: { level1, level2 }, screenshotPolicy: { captureOnFail: true, captureOnPass: false }, steps, }; } function concurrentActionScenario(id, name, level1, level2) { const steps = []; let sid = 1; steps.push({ id: sid++, name: '페이지 로드 대기', action: 'wait', timeout: 3000 }); steps.push({ id: sid++, name: '테이블 로드 대기', action: 'wait_for_table', timeout: 5000 }); // Rapid tab/filter switching steps.push({ id: sid++, name: '탭/필터 빠른 전환 테스트', action: 'evaluate', script: [ `(async()=>{`, H, `const R={phase:'RAPID_TAB_SWITCH'};`, `const tabs=Array.from(document.querySelectorAll('[role="tab"],button[class*="tab"],a[class*="tab"]')).filter(el=>el.offsetParent!==null);`, `R.tabCount=tabs.length;`, `if(tabs.length<2){`, ` const buttons=Array.from(document.querySelectorAll('button')).filter(b=>b.offsetParent!==null&&!b.disabled);`, ` R.fallbackButtonCount=buttons.length;`, ` if(buttons.length>=2){`, ` for(let i=0;i<3;i++){buttons[0].click();await w(100);buttons[1].click();await w(100);}`, ` R.rapidSwitchCount=6;`, ` }else{R.warn='탭/버튼 2개 미만';R.ok=true;return JSON.stringify(R);}`, `}else{`, ` for(let i=0;i<3;i++){tabs[0].click();await w(100);tabs[1%tabs.length].click();await w(100);}`, ` R.rapidSwitchCount=6;`, `}`, `await w(2000);`, `const hasError=document.querySelector('[class*="error"],[class*="Error"],[role="alert"]');`, `R.hasError=!!hasError;`, `R.pageStable=!document.querySelector('.loading,.spinner,[class*="skeleton"]');`, `R.ok=!R.hasError;`, `R.info=R.hasError?'❌ 빠른 전환 후 에러 발생':'✅ 빠른 전환 후 정상 상태';`, `return JSON.stringify(R);`, `})()`, ].join(''), timeout: 15000, phase: 'RAPID_TAB_SWITCH', }); // Rapid navigation clicks steps.push({ id: sid++, name: '페이지네이션 빠른 클릭', action: 'evaluate', script: [ `(async()=>{`, H, `const R={phase:'RAPID_PAGINATION'};`, `const pageButtons=Array.from(document.querySelectorAll('[class*="pagination"] button,[class*="Pagination"] button,nav button')).filter(el=>el.offsetParent!==null&&!el.disabled);`, `R.paginationButtonCount=pageButtons.length;`, `if(pageButtons.length<2){R.warn='페이지네이션 버튼 부족';R.ok=true;return JSON.stringify(R);}`, `const nextBtn=pageButtons.find(b=>/다음|next|>|›|»/.test(b.innerText?.trim()||b.getAttribute('aria-label')||''));`, `const prevBtn=pageButtons.find(b=>/이전|prev|<|‹|«/.test(b.innerText?.trim()||b.getAttribute('aria-label')||''));`, `if(nextBtn&&prevBtn){`, ` for(let i=0;i<3;i++){nextBtn.click();await w(50);prevBtn.click();await w(50);}`, ` R.rapidNavCount=6;`, `}else if(pageButtons.length>=2){`, ` for(let i=0;i<3;i++){pageButtons[0].click();await w(50);pageButtons[1].click();await w(50);}`, ` R.rapidNavCount=6;`, `}`, `await w(2000);`, `const hasError=document.querySelector('[class*="error"],[class*="Error"],[role="alert"]');`, `R.hasError=!!hasError;`, `R.pageStable=!document.querySelector('.loading,.spinner,[class*="skeleton"]');`, `R.ok=!R.hasError;`, `R.info=R.hasError?'❌ 빠른 페이지 전환 후 에러':'✅ 빠른 페이지 전환 후 정상';`, `return JSON.stringify(R);`, `})()`, ].join(''), timeout: 15000, phase: 'RAPID_PAGINATION', }); // Multiple button rapid clicks steps.push({ id: sid++, name: '다중 버튼 동시 클릭 시뮬레이션', action: 'evaluate', script: [ `(async()=>{`, H, `const R={phase:'MULTI_BUTTON_CLICK'};`, `const buttons=Array.from(document.querySelectorAll('button')).filter(b=>b.offsetParent!==null&&!b.disabled);`, `R.totalButtons=buttons.length;`, `if(buttons.length<3){R.warn='버튼 3개 미만';R.ok=true;return JSON.stringify(R);}`, // Click first 3 buttons rapidly `const clickTargets=buttons.slice(0,3);`, `for(const btn of clickTargets){btn.click();await w(30);}`, `await w(2000);`, `const hasError=document.querySelector('[class*="error"],[class*="Error"],[role="alert"]');`, `R.hasError=!!hasError;`, `R.pageStable=!document.querySelector('.loading,.spinner,[class*="skeleton"]');`, // Close any opened modals `const modal=document.querySelector('[role="dialog"],[aria-modal="true"]');`, `if(modal&&modal.offsetParent!==null){`, ` const closeBtn=modal.querySelector('button[class*="close"],[aria-label="닫기"]')||Array.from(modal.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);}`, `}`, `R.ok=!R.hasError;`, `R.info=R.hasError?'❌ 다중 버튼 클릭 후 에러':'✅ 다중 버튼 클릭 후 정상';`, `return JSON.stringify(R);`, `})()`, ].join(''), timeout: 15000, phase: 'MULTI_BUTTON_CLICK', }); return { id, name, version: '1.0.0', auth: { role: 'admin' }, menuNavigation: { level1, level2 }, screenshotPolicy: { captureOnFail: true, captureOnPass: false }, steps, }; } // ════════════════════════════════════════════════════════════════ // All 15 Edge Case Scenarios // ════════════════════════════════════════════════════════════════ function generateAllScenarios() { return [ // 1-4: Empty submit tests emptySubmitScenario( 'edge-empty-submit-sales', '엣지 케이스: 빈 폼 제출 (판매 > 거래처관리)', '판매관리', '거래처관리' ), emptySubmitScenario( 'edge-empty-submit-accounting', '엣지 케이스: 빈 폼 제출 (회계 > 입금관리)', '회계관리', '입금관리' ), emptySubmitScenario( 'edge-empty-submit-hr', '엣지 케이스: 빈 폼 제출 (인사 > 사원관리)', '인사관리', '사원관리' ), emptySubmitScenario( 'edge-empty-submit-board', '엣지 케이스: 빈 폼 제출 (게시판 > 자유게시판)', '게시판', '자유게시판' ), // 5-7: Boundary input tests boundaryInputScenario( 'edge-boundary-input-sales', '엣지 케이스: 경계값 입력 (판매 > 거래처관리)', '판매관리', '거래처관리' ), boundaryInputScenario( 'edge-boundary-input-accounting', '엣지 케이스: 경계값 입력 (회계 > 입금관리)', '회계관리', '입금관리' ), boundaryInputScenario( 'edge-boundary-input-hr', '엣지 케이스: 경계값 입력 (인사 > 사원관리)', '인사관리', '사원관리' ), // 8-9: Special chars search tests specialCharsSearchScenario( 'edge-special-chars-search', '엣지 케이스: 특수문자 검색 (판매 > 거래처관리)', '판매관리', '거래처관리' ), specialCharsSearchScenario( 'edge-special-chars-board', '엣지 케이스: 특수문자 검색 (게시판 > 자유게시판)', '게시판', '자유게시판' ), // 10-11: Rapid click save tests rapidClickSaveScenario( 'edge-rapid-click-save-sales', '엣지 케이스: 저장 버튼 연타 (판매 > 거래처관리)', '판매관리', '거래처관리' ), rapidClickSaveScenario( 'edge-rapid-click-save-board', '엣지 케이스: 저장 버튼 연타 (게시판 > 자유게시판)', '게시판', '자유게시판' ), // 12: Rapid click delete test rapidClickDeleteScenario( 'edge-rapid-click-delete', '엣지 케이스: 삭제 버튼 연타 (게시판 > 자유게시판)', '게시판', '자유게시판' ), // 13: Numeric boundary test numericBoundaryScenario( 'edge-numeric-boundary-accounting', '엣지 케이스: 숫자 경계값 (회계 > 입금관리)', '회계관리', '입금관리' ), // 14: Unicode input test unicodeInputScenario( 'edge-unicode-input-board', '엣지 케이스: 유니코드 입력 (게시판 > 자유게시판)', '게시판', '자유게시판' ), // 15: Concurrent action test concurrentActionScenario( 'edge-concurrent-action-hr', '엣지 케이스: 동시 액션 (인사 > 근태관리)', '인사관리', '근태관리' ), ]; } // ════════════════════════════════════════════════════════════════ // Main // ════════════════════════════════════════════════════════════════ function main() { if (!fs.existsSync(SCENARIOS_DIR)) fs.mkdirSync(SCENARIOS_DIR, { recursive: true }); const scenarios = generateAllScenarios(); let count = 0; console.log('\n Edge Case Scenario Generator'); console.log(' ═══════════════════════════════════════\n'); for (const scenario of scenarios) { const fp = path.join(SCENARIOS_DIR, `${scenario.id}.json`); fs.writeFileSync(fp, JSON.stringify(scenario, null, 2), 'utf-8'); console.log(` ✓ ${scenario.id}.json (${scenario.steps.length} steps)`); count++; } console.log(`\n ═══════════════════════════════════════`); console.log(` Generated ${count} edge case scenarios`); console.log(` Run: node e2e/runner/run-all.js --filter edge\n`); } main();