- 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>
365 lines
17 KiB
JavaScript
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.');
|
|
})();
|