Files
sam-hotfix/e2e/runner/search-bug-collector.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

365 lines
17 KiB
JavaScript

/**
* 검색 버그 상세 검증 수집기
* 급여관리 + 기안함 페이지의 검색 기능을 직접 Playwright로 테스트하고
* 실제 JSON 결과를 캡처한다.
*/
const SAM_ROOT = require('path').resolve(__dirname, '..', '..');
const PW_PATH = require('path').join(SAM_ROOT, 'react', 'node_modules', 'playwright');
const { chromium } = require(PW_PATH);
const fs = require('fs');
const path = require('path');
const BASE = 'https://dev.codebridge-x.com';
const PAGES = [
{ name: '급여관리', level1: '인사관리', level2: '급여관리', expectedUrl: '/hr/salary-management' },
{ name: '기안함', level1: '결재관리', level2: '기안함', expectedUrl: '/approval/draft' },
];
const TESTS = [
{
name: '사전조사: UI 구조 분석',
script: `(async()=>{
const R={};
const si=['input[type="search"]','input[placeholder*="검색"]','input[role="searchbox"]','[class*="search"] input','[class*="Search"] input'];
R.searchInputs=[];
for(const s of si){const els=document.querySelectorAll(s);els.forEach(e=>{R.searchInputs.push({sel:s,ph:e.placeholder||'',type:e.type,cls:e.className?.substring(0,60)||''})});}
const rows=document.querySelectorAll('table tbody tr');
R.rowCount=rows.length;
R.sampleData=Array.from(rows).slice(0,3).map(r=>Array.from(r.querySelectorAll('td')).map(td=>td.innerText?.trim().substring(0,25)));
const btns=Array.from(document.querySelectorAll('button')).filter(b=>['검색','조회','Search','초기화'].some(t=>b.innerText?.includes(t)));
R.searchButtons=btns.map(b=>b.innerText?.trim().substring(0,20));
R.filters=document.querySelectorAll('select,[role="combobox"]').length;
return R;
})()`
},
{
name: '테스트1: nonsense 검색 (input 이벤트)',
script: `(async()=>{
const R={test:'nonsense_input'};
const si=['input[type="search"]','input[placeholder*="검색"]','input[role="searchbox"]','[class*="search"] input'];
let el=null;for(const s of si){el=document.querySelector(s);if(el)break;}
if(!el)return {error:'검색 입력란 없음'};
R.rowsBefore=document.querySelectorAll('table tbody tr').length;
R.placeholder=el.placeholder;
const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;
el.focus();
if(ns)ns.call(el,'zzz_no_match_bug');else el.value='zzz_no_match_bug';
el.dispatchEvent(new Event('input',{bubbles:true}));
el.dispatchEvent(new Event('change',{bubbles:true}));
await new Promise(r=>setTimeout(r,2000));
R.rowsAfter=document.querySelectorAll('table tbody tr').length;
R.filtered=R.rowsBefore!==R.rowsAfter;
R.verdict=R.filtered?'PASS':'FAIL: 행수불변('+R.rowsBefore+'→'+R.rowsAfter+')';
return R;
})()`
},
{
name: '테스트2: Enter 키 검색',
script: `(async()=>{
const R={test:'enter_key'};
const si=['input[type="search"]','input[placeholder*="검색"]','input[role="searchbox"]','[class*="search"] input'];
let el=null;for(const s of si){el=document.querySelector(s);if(el)break;}
if(!el)return {error:'검색 입력란 없음'};
R.rowsBefore=document.querySelectorAll('table tbody tr').length;
R.currentValue=el.value;
el.focus();
el.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));
el.dispatchEvent(new KeyboardEvent('keypress',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));
el.dispatchEvent(new KeyboardEvent('keyup',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));
const form=el.closest('form');
if(form)form.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));
await new Promise(r=>setTimeout(r,2000));
R.rowsAfter=document.querySelectorAll('table tbody tr').length;
R.filtered=R.rowsBefore!==R.rowsAfter;
R.verdict=R.filtered?'PASS':'FAIL: Enter후 행수불변('+R.rowsBefore+'→'+R.rowsAfter+')';
return R;
})()`
},
{
name: '테스트3: 검색 버튼 클릭',
script: `(async()=>{
const R={test:'button_click'};
const btns=Array.from(document.querySelectorAll('button'));
const searchBtn=btns.find(b=>['검색','조회','Search'].some(t=>b.innerText?.trim()===t));
const iconBtn=!searchBtn?document.querySelector('button svg[class*="search"],button svg[class*="Search"]')?.closest('button'):null;
const btn=searchBtn||iconBtn;
R.buttonFound=!!btn;
R.buttonText=btn?.innerText?.trim().substring(0,20)||'none';
if(btn){
R.rowsBefore=document.querySelectorAll('table tbody tr').length;
btn.click();
await new Promise(r=>setTimeout(r,2000));
R.rowsAfter=document.querySelectorAll('table tbody tr').length;
R.filtered=R.rowsBefore!==R.rowsAfter;
R.verdict=R.filtered?'PASS':'FAIL: 버튼클릭후 행수불변('+R.rowsBefore+'→'+R.rowsAfter+')';
}else{R.verdict='SKIP: 검색 버튼 없음';}
return R;
})()`
},
{
name: '테스트4: React onChange 직접 호출',
script: `(async()=>{
const R={test:'react_onChange'};
const si=['input[type="search"]','input[placeholder*="검색"]','input[role="searchbox"]','[class*="search"] input'];
let el=null;for(const s of si){el=document.querySelector(s);if(el)break;}
if(!el)return {error:'검색 입력란 없음'};
// clear first
const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;
if(ns)ns.call(el,'');
el.dispatchEvent(new Event('input',{bubbles:true}));
await new Promise(r=>setTimeout(r,1000));
R.rowsAfterClear=document.querySelectorAll('table tbody tr').length;
// search
if(ns)ns.call(el,'zzz_react_test');
const rk=Object.keys(el).find(k=>k.startsWith('__reactProps$'));
R.hasReactProps=!!rk;
R.hasOnChange=!!(rk&&el[rk]?.onChange);
if(rk&&el[rk]?.onChange){
el[rk].onChange({target:el,currentTarget:el});
}else{
el.dispatchEvent(new Event('input',{bubbles:true}));
}
await new Promise(r=>setTimeout(r,2000));
R.rowsAfter=document.querySelectorAll('table tbody tr').length;
R.filtered=R.rowsAfterClear!==R.rowsAfter;
R.verdict=R.filtered?'PASS':'FAIL: React onChange후 행수불변('+R.rowsAfterClear+'→'+R.rowsAfter+')';
return R;
})()`
},
{
name: '검색 초기화',
script: `(async()=>{
const si=['input[type="search"]','input[placeholder*="검색"]','input[role="searchbox"]','[class*="search"] input'];
let el=null;for(const s of si){el=document.querySelector(s);if(el)break;}
if(!el)return {msg:'no search input'};
const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;
if(ns)ns.call(el,'');else el.value='';
const rk=Object.keys(el).find(k=>k.startsWith('__reactProps$'));
if(rk&&el[rk]?.onChange)el[rk].onChange({target:el,currentTarget:el});
el.dispatchEvent(new Event('input',{bubbles:true}));
el.dispatchEvent(new Event('change',{bubbles:true}));
await new Promise(r=>setTimeout(r,1500));
return {cleared:true,rows:document.querySelectorAll('table tbody tr').length};
})()`
},
{
name: '테스트5: API 호출 모니터링',
script: `(async()=>{
const R={test:'api_monitor'};
const captured=[];
const origFetch=window.fetch;
window.fetch=async function(...args){
const url=typeof args[0]==='string'?args[0]:args[0]?.url||'';
const method=args[1]?.method||'GET';
captured.push({url:url.substring(0,120),method,time:Date.now()});
return origFetch.apply(this,args);
};
const si=['input[type="search"]','input[placeholder*="검색"]','input[role="searchbox"]','[class*="search"] input'];
let el=null;for(const s of si){el=document.querySelector(s);if(el)break;}
if(!el){window.fetch=origFetch;return {error:'검색 입력란 없음'};}
const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;
captured.length=0;
if(ns)ns.call(el,'api_monitor_query');
el.dispatchEvent(new Event('input',{bubbles:true}));
el.dispatchEvent(new Event('change',{bubbles:true}));
el.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));
await new Promise(r=>setTimeout(r,3000));
window.fetch=origFetch;
R.allCalls=captured.filter(c=>!c.url.includes('hot-update')&&!c.url.includes('sockjs')&&!c.url.includes('_next'));
R.apiCount=R.allCalls.length;
R.verdict=R.apiCount>0?'API '+R.apiCount+'건 호출됨':'FAIL: 검색시 API 호출 없음';
return R;
})()`
},
{
name: '테스트6: 실존 데이터 검색',
script: `(async()=>{
const R={test:'real_data'};
// clear first
const si=['input[type="search"]','input[placeholder*="검색"]','input[role="searchbox"]','[class*="search"] input'];
let el=null;for(const s of si){el=document.querySelector(s);if(el)break;}
if(!el)return {error:'검색 입력란 없음'};
const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;
if(ns)ns.call(el,'');
const rk=Object.keys(el).find(k=>k.startsWith('__reactProps$'));
if(rk&&el[rk]?.onChange)el[rk].onChange({target:el,currentTarget:el});
el.dispatchEvent(new Event('input',{bubbles:true}));
await new Promise(r=>setTimeout(r,1500));
const rows=document.querySelectorAll('table tbody tr');
R.totalRows=rows.length;
if(!rows.length)return {...R,error:'행 없음'};
// extract search term from first row
const cells=Array.from(rows[0].querySelectorAll('td'));
R.firstRowData=cells.map(c=>c.innerText?.trim().substring(0,30));
let term='';
for(const c of cells){
const t=c.innerText?.trim();
if(t&&t.length>=2&&t.length<15&&!/^[\\d,.\\/\\-]+$/.test(t)&&!t.includes('원')){term=t.substring(0,Math.min(t.length,6));break;}
}
R.searchTerm=term;
if(!term)return {...R,error:'검색어 추출실패'};
if(ns)ns.call(el,term);
if(rk&&el[rk]?.onChange)el[rk].onChange({target:el,currentTarget:el});
el.dispatchEvent(new Event('input',{bubbles:true}));
el.dispatchEvent(new Event('change',{bubbles:true}));
el.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}));
await new Promise(r=>setTimeout(r,2000));
R.rowsAfter=document.querySelectorAll('table tbody tr').length;
R.filtered=R.totalRows!==R.rowsAfter;
R.verdict=R.filtered?'PASS: 실존데이터 검색동작('+R.totalRows+'→'+R.rowsAfter+')':'FAIL: 실존데이터 검색에도 행수불변('+R.totalRows+'→'+R.rowsAfter+')';
return R;
})()`
},
];
async function navigateViaMenu(page, level1, level2) {
await page.evaluate(async ({l1, l2}) => {
const sidebar = document.querySelector('.sidebar-scroll, nav, [class*="sidebar"], [class*="Sidebar"]');
if (sidebar) sidebar.scrollTo({ top: 0, behavior: 'instant' });
await new Promise(r => setTimeout(r, 300));
for (let i = 0; i < 20; i++) {
const btns = Array.from(document.querySelectorAll('button, [role="button"], a'));
const l1btn = btns.find(b => b.innerText?.trim().startsWith(l1));
if (l1btn) {
l1btn.click();
await new Promise(r => setTimeout(r, 600));
const links = Array.from(document.querySelectorAll('a, button'));
const l2link = links.find(el => el.innerText?.trim() === l2);
if (l2link) {
l2link.click();
await new Promise(r => setTimeout(r, 2000));
return true;
}
}
if (sidebar) sidebar.scrollBy({ top: 150, behavior: 'instant' });
await new Promise(r => setTimeout(r, 150));
}
return false;
}, { l1: level1, l2: level2 });
}
(async () => {
const browser = await chromium.launch({
headless: false,
args: ['--window-position=1920,0', '--window-size=1920,1080']
});
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 }, locale: 'ko-KR' });
const page = await context.newPage();
// Login
await page.goto(`${BASE}/ko/login`, { waitUntil: 'networkidle', timeout: 30000 });
await page.fill('input[type="text"], input[name="username"], #username', 'TestUser5');
await page.fill('input[type="password"]', 'password123!');
await page.click('button[type="submit"]');
await page.waitForTimeout(3000);
console.log('Login OK');
const allResults = {};
for (const pg of PAGES) {
console.log(`\n${'='.repeat(60)}`);
console.log(`PAGE: ${pg.name} (${pg.level1} > ${pg.level2})`);
console.log('='.repeat(60));
// Navigate to dashboard first
await page.goto(`${BASE}/ko/dashboard`, { waitUntil: 'networkidle', timeout: 15000 });
await page.waitForTimeout(1000);
// Navigate via menu
await navigateViaMenu(page, pg.level1, pg.level2);
await page.waitForTimeout(2000);
const pageResults = { name: pg.name, url: page.url(), tests: [] };
for (const test of TESTS) {
try {
const result = await page.evaluate(test.script);
pageResults.tests.push({ name: test.name, result });
const verdict = result?.verdict || result?.msg || result?.error || 'OK';
const isFail = typeof verdict === 'string' && verdict.startsWith('FAIL');
console.log(` ${isFail ? '❌' : '✅'} ${test.name}: ${verdict}`);
if (result?.rowsBefore !== undefined) {
console.log(` rows: ${result.rowsBefore}${result.rowsAfter}`);
}
if (result?.allCalls) {
console.log(` API calls: ${result.allCalls.map(c => c.method + ' ' + c.url).join('\n ')}`);
}
if (result?.searchInputs) {
console.log(` inputs: ${JSON.stringify(result.searchInputs)}`);
console.log(` rows: ${result.rowCount}, buttons: ${JSON.stringify(result.searchButtons)}`);
if (result.sampleData) {
result.sampleData.forEach((row, i) => console.log(` row${i}: ${row.join(' | ')}`));
}
}
} catch (err) {
pageResults.tests.push({ name: test.name, error: err.message });
console.log(` ⚠️ ${test.name}: ERROR - ${err.message}`);
}
}
allResults[pg.name] = pageResults;
}
// Generate report
const now = new Date();
const pad = n => n.toString().padStart(2, '0');
const ts = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
let md = `# 검색 버그 상세 검증 리포트\n\n`;
md += `**실행**: ${ts} | **대상**: 급여관리, 기안함\n\n`;
for (const [pageName, data] of Object.entries(allResults)) {
md += `## ${pageName}\n\n`;
md += `**URL**: ${data.url}\n\n`;
md += `| # | 테스트 | 결과 | 상세 |\n`;
md += `|---|--------|------|------|\n`;
data.tests.forEach((t, i) => {
const r = t.result || {};
const verdict = r.verdict || r.msg || r.error || t.error || 'OK';
const isFail = typeof verdict === 'string' && verdict.includes('FAIL');
const icon = t.error ? '⚠️' : isFail ? '❌' : '✅';
let detail = '';
if (r.rowsBefore !== undefined) detail = `${r.rowsBefore}${r.rowsAfter}`;
else if (r.rowCount !== undefined) detail = `${r.rowCount}행, 입력${r.searchInputs?.length||0}개, 필터${r.filters||0}`;
else if (r.apiCount !== undefined) detail = `API ${r.apiCount}`;
else if (r.searchTerm) detail = `검색어: "${r.searchTerm}"`;
else if (r.cleared) detail = `${r.rows}`;
md += `| ${i+1} | ${t.name} | ${icon} ${verdict.substring(0,50)} | ${detail} |\n`;
});
md += `\n`;
// Raw data section
md += `<details><summary>원시 데이터</summary>\n\n`;
md += `\`\`\`json\n${JSON.stringify(data, null, 2).substring(0, 5000)}\n\`\`\`\n\n`;
md += `</details>\n\n`;
}
// Bug summary
md += `## 버그 요약\n\n`;
for (const [pageName, data] of Object.entries(allResults)) {
const fails = data.tests.filter(t => t.result?.verdict?.includes?.('FAIL'));
if (fails.length > 0) {
md += `### BUG: ${pageName} 검색 기능 미동작\n\n`;
md += `- **위치**: ${data.url}\n`;
md += `- **심각도**: HIGH\n`;
md += `- **증상**: 검색 입력란이 존재하지만 어떤 방식으로도 테이블 필터링이 동작하지 않음\n`;
md += `- **테스트 방법**: input이벤트, Enter키, 버튼클릭, React onChange — 모두 실패\n`;
md += `- **실패 항목**:\n`;
fails.forEach(f => {
md += ` - ${f.name}: ${f.result.verdict}\n`;
});
md += `\n`;
}
}
const reportPath = path.join('C:/Users/codeb/sam/e2e/results/hotfix', `Search-Bug-Detail_${ts}.md`);
fs.writeFileSync(reportPath, md, 'utf8');
console.log(`\nReport: ${reportPath}`);
await browser.close();
console.log('Done.');
})();