Files
sam-hotfix/e2e/runner/gen-search-function.js
김보곤 48eba1e716 refactor: E2E 시나리오 생성기 8종 품질 개선 (false positive 제거 + flaky 패턴 수정)
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>
2026-02-19 21:54:57 +09:00

253 lines
13 KiB
JavaScript

#!/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<cells.length&&i<5;i++){`,
` const t=cells[i]?.innerText?.trim();`,
` if(t&&t.length>=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<R.initialRowCount){`,
` R.filterWorked=true;`,
` R.info='검색 필터링 동작 확인 ('+R.initialRowCount+'→'+R.afterSearchRowCount+'행)';`,
`}else if(R.afterSearchRowCount===0){`,
` R.filterWorked=false;`,
` R.warn='⚠️ 검색 후 결과 0건 - 기존 데이터의 키워드인데 결과 없음';`,
`}`,
`R.ok=R.filterWorked!==false;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
// STEP 2: 검색 초기화 + 원복 확인
const CLEAR_AND_VERIFY = [
`(async()=>{`, 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<rowsBefore)R.warn='⚠️ 검색 초기화 후 행 수가 줄었음 ('+rowsBefore+'→'+rowsAfter+')';`,
`R.ok=R.inputCleared!==false;`,
`return JSON.stringify(R);`,
`})()`,
].join('');
// STEP 3: 드롭다운 필터 테스트 (combobox 선택 → 필터 확인 → 원복)
const DROPDOWN_FILTER_TEST = [
`(async()=>{`, 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();