Files
sam-hotfix/e2e/runner/search-audit-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

295 lines
13 KiB
JavaScript

#!/usr/bin/env node
/**
* Search Audit Data Collector
* Visits all pages and collects search/filter UI detection results
* Outputs a structured JSON report
*/
const fs = require('fs');
const path = require('path');
const SAM_ROOT = path.resolve(__dirname, '..', '..');
const PW_PATH = path.join(SAM_ROOT, 'react', 'node_modules', 'playwright');
const { chromium } = require(PW_PATH);
const BASE_URL = 'https://dev.codebridge-x.com';
const AUTH = { username: 'TestUser5', password: 'password123!' };
const PAGES = [
{ l1: '회계관리', l2: '거래처관리' },
{ l1: '회계관리', l2: '입금관리' },
{ l1: '회계관리', l2: '출금관리' },
{ l1: '회계관리', l2: '어음관리' },
{ l1: '회계관리', l2: '악성채권추심관리' },
{ l1: '회계관리', l2: '입출금계좌조회' },
{ l1: '회계관리', l2: '카드내역조회' },
{ l1: '회계관리', l2: '매입관리' },
{ l1: '회계관리', l2: '매출관리' },
{ l1: '회계관리', l2: '미수금현황' },
{ l1: '회계관리', l2: '지출예상내역서' },
{ l1: '회계관리', l2: '결제내역' },
{ l1: '회계관리', l2: '거래처원장' },
{ l1: '인사관리', l2: '사원관리' },
{ l1: '인사관리', l2: '부서관리' },
{ l1: '인사관리', l2: '급여관리' },
{ l1: '인사관리', l2: '근태관리' },
{ l1: '인사관리', l2: '근태현황' },
{ l1: '인사관리', l2: '카드관리' },
{ l1: '인사관리', l2: '휴가관리' },
{ l1: '생산관리', l2: '품목관리' },
{ l1: '생산관리', l2: '생산 현황판' },
{ l1: '생산관리', l2: '작업자 화면' },
{ l1: '생산관리', l2: '작업지시 관리' },
{ l1: '생산관리', l2: '작업실적' },
{ l1: '품목관리', l2: '품목기준관리' },
{ l1: '품질관리', l2: '품질인정심사 시스템' },
{ l1: '품질관리', l2: '제품검사관리' },
{ l1: '자재관리', l2: '재고현황' },
{ l1: '자재관리', l2: '입고관리' },
{ l1: '판매관리', l2: '거래처관리' },
{ l1: '판매관리', l2: '수주관리' },
{ l1: '판매관리', l2: '단가관리' },
{ l1: '판매관리', l2: '견적관리' },
{ l1: '출고관리', l2: '출고관리' },
{ l1: '결재관리', l2: '결재함' },
{ l1: '결재관리', l2: '기안함' },
{ l1: '결재관리', l2: '참조함' },
{ l1: '게시판', l2: '자유게시판' },
{ l1: '게시판', l2: '게시판 관리' },
{ l1: '고객센터', l2: '공지사항' },
{ l1: '고객센터', l2: 'FAQ' },
{ l1: '고객센터', l2: '이벤트 게시판' },
{ l1: '설정', l2: '회사정보' },
{ l1: '설정', l2: '계정정보' },
{ l1: '설정', l2: '근태설정' },
{ l1: '설정', l2: '계좌관리' },
{ l1: '설정', l2: '알림설정' },
{ l1: '설정', l2: '권한관리' },
{ l1: '설정', l2: '팝업관리' },
{ l1: '설정', l2: '직책관리' },
{ l1: '설정', l2: '직급관리' },
{ l1: '설정', l2: '구독관리' },
{ l1: '설정', l2: '휴가정책' },
{ l1: '설정', l2: '근무일정' },
];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const AUDIT_SCRIPT = `(async()=>{
const R={p:location.pathname,url:location.href};
const ss=['input[type="search"]','input[placeholder*="검색"]','input[placeholder*="Search"]','input[role="searchbox"]','[class*="search"] input'];
let si=null;
for(const s of ss){si=document.querySelector(s);if(si)break;}
if(!si) si=Array.from(document.querySelectorAll('input[type="text"],input:not([type])')).find(i=>/검색|search|조회/i.test(i.placeholder||''));
R.hasSearch=!!si;
R.searchPlaceholder=si?(si.placeholder||'').substring(0,60):'';
R.filters=document.querySelectorAll('button[role="combobox"],select').length;
R.filterTexts=Array.from(document.querySelectorAll('button[role="combobox"]')).map(f=>(f.innerText||'').trim().substring(0,20)).filter(Boolean);
R.tabs=document.querySelectorAll('[role="tab"],[role="tablist"] button').length;
const rc=()=>document.querySelectorAll('table tbody tr,[role="row"]').length;
R.rows=rc();
R.hasTable=R.rows>0||!!document.querySelector('table,[role="grid"]');
if(si&&R.rows>0){
const ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')?.set;
if(ns)ns.call(si,'zzz_no_match_e2e');else si.value='zzz_no_match_e2e';
si.dispatchEvent(new Event('input',{bubbles:true}));
si.dispatchEvent(new Event('change',{bubbles:true}));
si.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',keyCode:13,bubbles:true}));
const sb=document.querySelector('button[class*="search"],button[aria-label*="검색"]');
if(sb)sb.click();
await new Promise(w=>setTimeout(w,1500));
R.afterRows=rc();
R.searchWorked=R.afterRows<R.rows;
if(ns)ns.call(si,'');else si.value='';
si.dispatchEvent(new Event('input',{bubbles:true}));
si.dispatchEvent(new Event('change',{bubbles:true}));
si.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',keyCode:13,bubbles:true}));
if(sb)sb.click();
await new Promise(w=>setTimeout(w,800));
}
return R;
})()`;
async function navigateViaMenu(page, level1, level2) {
// Collapse all
await page.evaluate(() => {
const btn = Array.from(document.querySelectorAll('button')).find(b => b.innerText?.trim() === '모두 접기');
if (btn) btn.click();
});
await sleep(300);
// Scroll top
await page.evaluate(() => {
const sidebar = document.querySelector('.sidebar-scroll, [data-sidebar="content"], nav');
if (sidebar) sidebar.scrollTo({ top: 0, behavior: 'instant' });
});
await sleep(300);
// Click L1
const l1Found = await page.evaluate(async ({ l1Text }) => {
const sidebar = document.querySelector('.sidebar-scroll, [data-sidebar="content"], nav');
for (let i = 0; i < 20; i++) {
const items = Array.from(document.querySelectorAll('a, button, [role="button"], [role="menuitem"], [role="treeitem"]'));
const match = items.find(el => {
const text = (el.textContent || el.innerText || '').trim();
return text && (text === l1Text || text.startsWith(l1Text));
});
if (match) { match.scrollIntoView({ behavior: 'instant', block: 'center' }); await new Promise(r => setTimeout(r, 100)); match.click(); return true; }
if (sidebar) { sidebar.scrollBy({ top: 150, behavior: 'instant' }); await new Promise(r => setTimeout(r, 100)); }
}
return false;
}, { l1Text: level1 });
if (!l1Found) return false;
await sleep(500);
if (level2) {
const l2Found = await page.evaluate(async ({ l2Text }) => {
const sidebar = document.querySelector('.sidebar-scroll, [data-sidebar="content"], nav');
for (let i = 0; i < 15; i++) {
const items = Array.from(document.querySelectorAll('a, button, [role="button"], [role="menuitem"], [role="treeitem"]'));
let match = items.find(el => (el.textContent || el.innerText || '').trim() === l2Text);
if (!match) match = items.find(el => (el.textContent || el.innerText || '').trim().includes(l2Text));
if (match) { match.scrollIntoView({ behavior: 'instant', block: 'center' }); await new Promise(r => setTimeout(r, 100)); match.click(); return true; }
if (sidebar) { sidebar.scrollBy({ top: 100, behavior: 'instant' }); await new Promise(r => setTimeout(r, 100)); }
}
return false;
}, { l2Text: level2 });
if (!l2Found) return false;
await sleep(2000);
}
return true;
}
(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_URL}/ko/login`, { waitUntil: 'domcontentloaded', timeout: 20000 });
await page.fill('#userId', AUTH.username);
await page.fill('#password', AUTH.password);
await page.click("button[type='submit']");
await sleep(3000);
console.log('Logged in');
const results = [];
let failed = 0;
for (let i = 0; i < PAGES.length; i++) {
const pg = PAGES[i];
const label = `${pg.l1} > ${pg.l2}`;
process.stdout.write(`(${i + 1}/${PAGES.length}) ${label} ... `);
try {
// Go to dashboard
await page.goto(`${BASE_URL}/dashboard`, { waitUntil: 'load', timeout: 10000 });
await sleep(1500);
// Navigate
const navOk = await navigateViaMenu(page, pg.l1, pg.l2);
if (!navOk) {
console.log('NAV FAIL');
results.push({ menu: label, error: 'navigation_failed' });
failed++;
continue;
}
// Run audit
const data = await page.evaluate(AUDIT_SCRIPT);
data.menu = label;
results.push(data);
const status = [];
if (data.hasSearch) status.push(`검색:${data.searchWorked === true ? '✅' : data.searchWorked === false ? '❌' : '⬜'}`);
else status.push('검색:없음');
if (data.filters > 0) status.push(`필터:${data.filters}`);
if (data.rows > 0) status.push(`행:${data.rows}`);
if (data.tabs > 0) status.push(`탭:${data.tabs}`);
console.log(status.join(' | '));
} catch (err) {
console.log(`ERROR: ${err.message.substring(0, 60)}`);
results.push({ menu: label, error: err.message });
failed++;
}
}
await browser.close();
// Generate report
const ts = (() => {
const n = new Date();
const p = v => v.toString().padStart(2, '0');
return `${n.getFullYear()}-${p(n.getMonth() + 1)}-${p(n.getDate())}_${p(n.getHours())}-${p(n.getMinutes())}-${p(n.getSeconds())}`;
})();
// Summary
const withSearch = results.filter(r => r.hasSearch);
const searchWorked = results.filter(r => r.searchWorked === true);
const searchFailed = results.filter(r => r.searchWorked === false);
const noSearch = results.filter(r => !r.hasSearch && !r.error);
const withFilters = results.filter(r => (r.filters || 0) > 0);
const withTable = results.filter(r => r.hasTable);
const errors = results.filter(r => r.error);
let md = `# 검색 기능 전체 탐색 감사 리포트
**실행**: ${ts} | **총 페이지**: ${PAGES.length} | **에러**: ${errors.length}
## 요약
| 항목 | 수 | 비율 |
|------|-----|------|
| 검색 입력 있음 | ${withSearch.length} | ${Math.round(withSearch.length / PAGES.length * 100)}% |
| 검색 동작 확인 | ${searchWorked.length} | ${withSearch.length > 0 ? Math.round(searchWorked.length / withSearch.length * 100) : 0}% |
| 검색 미동작 | ${searchFailed.length} | ${withSearch.length > 0 ? Math.round(searchFailed.length / withSearch.length * 100) : 0}% |
| 검색 없음 | ${noSearch.length} | ${Math.round(noSearch.length / PAGES.length * 100)}% |
| 필터 있음 | ${withFilters.length} | ${Math.round(withFilters.length / PAGES.length * 100)}% |
| 테이블 있음 | ${withTable.length} | ${Math.round(withTable.length / PAGES.length * 100)}% |
| 탐색 에러 | ${errors.length} | ${Math.round(errors.length / PAGES.length * 100)}% |
## 전체 페이지별 상세
| # | 메뉴 | URL | 검색 | 필터 | 탭 | 테이블행 | 검색동작 | 비고 |
|---|------|-----|------|------|-----|---------|---------|------|
`;
results.forEach((r, idx) => {
if (r.error) {
md += `| ${idx + 1} | ${r.menu} | - | - | - | - | - | - | ❌ ${r.error.substring(0, 40)} |\n`;
} else {
const searchIcon = r.hasSearch ? '✅' : '❌';
const workedIcon = r.searchWorked === true ? '✅ 동작' : r.searchWorked === false ? '⚠️ 미동작' : r.hasSearch ? '⬜ 테이블없음' : '-';
const rowInfo = r.hasTable ? `${r.rows}${r.afterRows !== undefined ? `${r.afterRows}` : ''}` : '없음';
const note = r.searchPlaceholder || '';
md += `| ${idx + 1} | ${r.menu} | ${(r.p || '').substring(0, 35)} | ${searchIcon} | ${r.filters || 0} | ${r.tabs || 0} | ${rowInfo} | ${workedIcon} | ${note.substring(0, 30)} |\n`;
}
});
if (searchFailed.length > 0) {
md += `\n## ⚠️ 검색 미동작 페이지 (상세)\n\n`;
searchFailed.forEach(r => {
md += `### ${r.menu}\n`;
md += `- URL: ${r.url || r.p}\n`;
md += `- 검색 placeholder: ${r.searchPlaceholder}\n`;
md += `- 검색 전 행: ${r.rows} → 검색 후 행: ${r.afterRows}\n`;
md += `- 필터: ${r.filters}개, 탭: ${r.tabs}\n\n`;
});
}
if (noSearch.length > 0) {
md += `\n## 검색 입력 없는 페이지\n\n`;
noSearch.forEach(r => {
md += `- ${r.menu} (${r.p}) - 필터: ${r.filters || 0}, 탭: ${r.tabs || 0}, 테이블: ${r.hasTable ? r.rows + '행' : '없음'}\n`;
});
}
const reportPath = path.join(SAM_ROOT, 'e2e', 'results', 'hotfix', `Search-Audit-Report_${ts}.md`);
fs.writeFileSync(reportPath, md, 'utf-8');
console.log(`\n=== 감사 완료 ===`);
console.log(`총: ${PAGES.length} | 검색있음: ${withSearch.length} | 동작: ${searchWorked.length} | 미동작: ${searchFailed.length} | 에러: ${errors.length}`);
console.log(`리포트: ${reportPath}`);
})();