- 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>
295 lines
13 KiB
JavaScript
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}`);
|
|
})();
|