#!/usr/bin/env node /** * 검색 기능 실제 동작 테스트 시나리오 생성기 * * 기존 search-options는 검색 UI 존재 여부만 감사. * 이 테스트는 실제로 검색어를 입력하고 결과가 필터링되는지 검증. * * 흐름: 기존 데이터 캡처 → 검색어 입력 → 필터링 확인 → 초기화 → 원복 확인 * * Usage: node e2e/runner/gen-search-function.js * * Output: * e2e/scenarios/search-function-acc.json (어음/입금/거래처) * e2e/scenarios/search-function-sales.json (거래처/수주/견적) * e2e/scenarios/search-function-hr-board.json (사원/자유게시판) */ const fs = require('fs'); const path = require('path'); const SCENARIOS_DIR = path.resolve(__dirname, '..', 'scenarios'); const H = `const w=ms=>new Promise(r=>setTimeout(r,ms));`; // ════════════════════════════════════════════════════════════════ // 검색 기능 테스트 스크립트 (모든 페이지 공용) // ════════════════════════════════════════════════════════════════ // STEP 1: 검색 전 상태 캡처 + 검색어 추출 const CAPTURE_AND_SEARCH = [ `(async()=>{`, H, `const R={phase:'SEARCH'};`, // 1. 현재 테이블 상태 캡처 `const rows0=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null);`, `R.initialRowCount=rows0.length;`, `if(rows0.length===0){R.warn='테이블에 데이터 없음 - 검색 테스트 불가';R.ok=true;return JSON.stringify(R);}`, // 2. 첫 행에서 검색 키워드 추출 (2번째 셀 = 보통 이름/제목) `const cells=rows0[0].querySelectorAll('td');`, `let keyword='';`, `for(let i=1;i=2&&t.length<=20&&!/^\\d+$/.test(t)&&!/^\\d{4}[-/]/.test(t)){keyword=t;break;}`, `}`, `if(!keyword&&cells[0]){keyword=cells[0]?.innerText?.trim().substring(0,10);}`, `R.keyword=keyword;`, `if(!keyword||keyword.length<2){R.warn='검색 가능한 키워드 추출 실패';R.ok=true;return JSON.stringify(R);}`, // 3. 검색 입력란 찾기 `const searchInput=document.querySelector('input[placeholder*="검색"]')||document.querySelector('input[type="search"]')||document.querySelector('input[role="searchbox"]');`, `R.hasSearchInput=!!searchInput;`, `if(!searchInput){R.warn='검색 입력란 없음';R.ok=true;return JSON.stringify(R);}`, `R.placeholder=searchInput.placeholder||'';`, // 4. 검색어 입력 `searchInput.focus();await w(200);`, `const nativeSetter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`, `if(nativeSetter)nativeSetter.call(searchInput,keyword);else searchInput.value=keyword;`, `searchInput.dispatchEvent(new Event('input',{bubbles:true}));`, `searchInput.dispatchEvent(new Event('change',{bubbles:true}));`, // 5. Enter 키로 검색 트리거 `searchInput.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));`, `searchInput.dispatchEvent(new KeyboardEvent('keyup',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));`, `await w(2000);`, // 6. 필터링 결과 확인 `const rows1=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null);`, `R.afterSearchRowCount=rows1.length;`, // 7. 결과 행이 모두 키워드를 포함하는지 확인 `const matchingRows=rows1.filter(r=>r.innerText?.includes(keyword));`, `R.matchingRowCount=matchingRows.length;`, `R.allMatch=rows1.length>0&&matchingRows.length===rows1.length;`, // 8. 필터링 분석 `R.filtered=R.afterSearchRowCount<=R.initialRowCount;`, `if(R.afterSearchRowCount===R.initialRowCount&&R.initialRowCount>1){`, ` R.filterWorked=R.allMatch;`, ` if(!R.allMatch)R.info='검색 후 행 수 동일 - 모든 행이 키워드 포함 또는 검색 미동작';`, `}else if(R.afterSearchRowCount{`, H, `const R={phase:'CLEAR'};`, // 1. 검색 입력란 찾기 `const searchInput=document.querySelector('input[placeholder*="검색"]')||document.querySelector('input[type="search"]')||document.querySelector('input[role="searchbox"]');`, `if(!searchInput){R.warn='검색 입력란 없음';R.ok=true;return JSON.stringify(R);}`, // 2. 현재 행 수 기록 `const rowsBefore=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null).length;`, `R.beforeClearRows=rowsBefore;`, // 3. 검색어 지우기 `const nativeSetter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;`, `if(nativeSetter)nativeSetter.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(2000);`, // 4. 초기화 버튼이 있으면 클릭 `const resetBtn=Array.from(document.querySelectorAll('button')).find(b=>/초기화|리셋|전체|Reset|Clear/i.test(b.innerText?.trim())&&b.offsetParent!==null);`, `if(resetBtn){resetBtn.click();await w(1500);R.resetBtnClicked=true;}`, // 5. 원복 확인 `const rowsAfter=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null).length;`, `R.afterClearRows=rowsAfter;`, `R.restored=rowsAfter>=rowsBefore;`, `R.searchInputValue=searchInput.value;`, `R.inputCleared=searchInput.value===''||searchInput.value.length===0;`, `if(!R.restored&&rowsAfter{`, H, `const R={phase:'DROPDOWN_FILTER'};`, // 1. 현재 행 수 `const rows0=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null);`, `R.initialRows=rows0.length;`, // 2. 드롭다운(combobox) 찾기 `const combos=Array.from(document.querySelectorAll('button[role="combobox"]')).filter(b=>{`, ` const inTable=b.closest('table');`, ` const inModal=b.closest('[role="dialog"],[aria-modal="true"]');`, ` return b.offsetParent!==null&&!inTable&&!inModal;`, `});`, `R.comboCount=combos.length;`, `if(combos.length===0){R.warn='필터용 드롭다운 없음';R.ok=true;return JSON.stringify(R);}`, // 3. 첫 번째 드롭다운 클릭 → 두 번째 옵션 선택 (첫 번째는 보통 "전체") `const targetCombo=combos[0];`, `R.comboLabel=targetCombo.closest('[class*=field],[class*=Field],[class*=form-item]')?.querySelector('label')?.innerText?.trim()||'';`, `R.comboCurrentValue=targetCombo.innerText?.trim().substring(0,30);`, `targetCombo.click();await w(600);`, `const listbox=document.querySelector('[role="listbox"]');`, `if(!listbox){R.warn='드롭다운 열림 실패';R.ok=true;return JSON.stringify(R);}`, `const options=Array.from(listbox.querySelectorAll('[role="option"]'));`, `R.optionCount=options.length;`, `R.optionTexts=options.slice(0,5).map(o=>o.innerText?.trim().substring(0,20));`, // 4. 두 번째 옵션 선택 (첫 번째가 "전체"일 수 있으므로) `const targetOpt=options.length>1?options[1]:options[0];`, `if(!targetOpt){document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));R.warn='선택 가능한 옵션 없음';R.ok=true;return JSON.stringify(R);}`, `R.selectedOption=targetOpt.innerText?.trim().substring(0,20);`, `targetOpt.click();await w(2000);`, // 5. 필터 결과 확인 `const rows1=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null);`, `R.afterFilterRows=rows1.length;`, `R.filterChanged=R.afterFilterRows!==R.initialRows;`, `if(!R.filterChanged&&R.initialRows>1)R.info='드롭다운 선택 후 행 수 변화 없음 (해당 필터의 모든 데이터일 수 있음)';`, // 6. 원복: 첫 번째 옵션("전체")으로 복원 `targetCombo.click();await w(600);`, `const listbox2=document.querySelector('[role="listbox"]');`, `if(listbox2){`, ` const allOpt=Array.from(listbox2.querySelectorAll('[role="option"]')).find(o=>/전체|All|선택/i.test(o.innerText?.trim()));`, ` if(allOpt){allOpt.click();await w(1500);R.restored=true;}`, ` else{const firstOpt=listbox2.querySelector('[role="option"]');if(firstOpt){firstOpt.click();await w(1500);R.restored=true;}}`, `}`, `if(!listbox2){document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await w(500);}`, // 7. 원복 확인 `const rows2=Array.from(document.querySelectorAll('table tbody tr')).filter(r=>r.offsetParent!==null);`, `R.afterRestoreRows=rows2.length;`, `R.ok=R.filterChanged!==undefined?true:true;`, `return JSON.stringify(R);`, `})()`, ].join(''); // ════════════════════════════════════════════════════════════════ // PAGE GROUPS // ════════════════════════════════════════════════════════════════ const GROUPS = [ { id: 'search-function-acc', name: '검색 기능 동작 검증: 회계', pages: [ { level1: '회계관리', level2: '어음관리' }, { level1: '회계관리', level2: '입금관리' }, { level1: '회계관리', level2: '거래처관리' }, ], }, { id: 'search-function-sales', name: '검색 기능 동작 검증: 판매', pages: [ { level1: '판매관리', level2: '거래처관리' }, { level1: '판매관리', level2: '수주관리' }, { level1: '판매관리', level2: '견적관리' }, ], }, { id: 'search-function-hr-board', name: '검색 기능 동작 검증: 인사/게시판', pages: [ { level1: '인사관리', level2: '사원관리' }, { level1: '게시판', level2: '자유게시판' }, ], }, ]; // ════════════════════════════════════════════════════════════════ function generateScenario(group) { const steps = []; let id = 1; for (let pi = 0; pi < group.pages.length; pi++) { const page = group.pages[pi]; const pfx = `[${page.level1} > ${page.level2}]`; if (pi > 0) { steps.push({ id: id++, name: `${pfx} 메뉴 이동`, action: 'menu_navigate', level1: page.level1, level2: page.level2, timeout: 10000 }); } steps.push({ id: id++, name: `${pfx} 페이지 로드 대기`, action: 'wait', timeout: 3000 }); steps.push({ id: id++, name: `${pfx} 테이블 로드 대기`, action: 'wait_for_table', timeout: 5000 }); // 텍스트 검색 테스트 steps.push({ id: id++, name: `${pfx} 텍스트 검색 테스트`, action: 'evaluate', script: CAPTURE_AND_SEARCH, timeout: 15000, phase: 'SEARCH' }); // 검색 초기화 테스트 steps.push({ id: id++, name: `${pfx} 검색 초기화 확인`, action: 'evaluate', script: CLEAR_AND_VERIFY, timeout: 10000, phase: 'CLEAR' }); // 안정화 대기 steps.push({ id: id++, name: `${pfx} 초기화 후 안정화`, action: 'wait', timeout: 2000 }); // 드롭다운 필터 테스트 steps.push({ id: id++, name: `${pfx} 드롭다운 필터 테스트`, action: 'evaluate', script: DROPDOWN_FILTER_TEST, timeout: 20000, phase: 'FILTER' }); } return { id: group.id, name: group.name, version: '1.0.0', auth: { role: 'admin' }, menuNavigation: group.pages[0], screenshotPolicy: { captureOnFail: true, captureOnPass: false }, steps, }; } function main() { if (!fs.existsSync(SCENARIOS_DIR)) fs.mkdirSync(SCENARIOS_DIR, { recursive: true }); for (const group of GROUPS) { const scenario = generateScenario(group); 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)`); } console.log(`\n Generated ${GROUPS.length} scenarios\n Run: node e2e/runner/run-all.js --filter search-function-`); } main();