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>
890 lines
43 KiB
JavaScript
890 lines
43 KiB
JavaScript
#!/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: '특수문자 검색: <script>', action: 'evaluate', script: fillSearchScript("<script>alert('xss')</script>"), 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();
|