Files
sam-hotfix/e2e/runner/gen-edge-cases.js
김보곤 67d0a4c2fd feat: E2E 시나리오 생성기 및 감사 스크립트 17종 추가
- gen-*.js: 시나리오 자동 생성기 12종 (CRUD, edge, a11y, perf 등)
- search-*.js: 검색/버튼 감사 수집기 3종
- revert-hard-actions.js: 하드 액션 복원 유틸
- _gen_writer.py: 생성기 보조 스크립트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:59:15 +09:00

890 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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=true;`,
`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=true;`,
`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=true;`,
`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=true;`,
`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=true;`,
`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=true;`,
`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();