- download-verify.js: 20개 페이지 엑셀/PDF 다운로드 버튼 자동 검증 - download-debug.js: 실패 원인 심층 분석 (네트워크, Server Action 등) - 검증 결과: 1/20 PASS (생산관리 > 작업실적만 정상 동작) - 주요 실패 원인: Server Action POST 200 but no file, API 404/500 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
425 lines
19 KiB
JavaScript
425 lines
19 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 다운로드 버튼 상세 디버깅 스크립트
|
||
*
|
||
* 각 페이지의 다운로드 버튼 클릭 시 실제로 무엇이 일어나는지 추적
|
||
* - 모든 네트워크 요청 로깅
|
||
* - Blob URL 생성 감지
|
||
* - 새 탭/팝업 감지
|
||
* - Console 메시지 캡처
|
||
*/
|
||
|
||
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 RESULTS_DIR = path.join(SAM_ROOT, 'e2e', 'results', 'hotfix');
|
||
const DOWNLOAD_DIR = path.join(RESULTS_DIR, 'downloads');
|
||
|
||
const C = {
|
||
green: t => `\x1b[32m${t}\x1b[0m`,
|
||
red: t => `\x1b[31m${t}\x1b[0m`,
|
||
yellow: t => `\x1b[33m${t}\x1b[0m`,
|
||
cyan: t => `\x1b[36m${t}\x1b[0m`,
|
||
dim: t => `\x1b[2m${t}\x1b[0m`,
|
||
bold: t => `\x1b[1m${t}\x1b[0m`,
|
||
};
|
||
|
||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||
|
||
function getTimestamp() {
|
||
const n = new Date();
|
||
const pad = v => v.toString().padStart(2, '0');
|
||
return `${n.getFullYear()}-${pad(n.getMonth() + 1)}-${pad(n.getDate())}_${pad(n.getHours())}-${pad(n.getMinutes())}-${pad(n.getSeconds())}`;
|
||
}
|
||
|
||
// 실패한 페이지만 재검증 (버튼은 있는데 다운로드 실패한 것)
|
||
const DEBUG_TARGETS = [
|
||
{ id: 'acc-daily-report', level1: '회계관리', level2: '일일 일보' },
|
||
{ id: 'acc-bank-tx', level1: '회계관리', level2: '계좌입출금내역' },
|
||
{ id: 'acc-card-history', level1: '회계관리', level2: '카드사용내역' },
|
||
{ id: 'acc-tax', level1: '회계관리', level2: '세금계산서관리' },
|
||
{ id: 'acc-receivable', level1: '회계관리', level2: '미수금현황' },
|
||
{ id: 'acc-vendor-ledger', level1: '회계관리', level2: '거래처원장' },
|
||
{ id: 'material-stock', level1: '자재관리', level2: '재고현황' },
|
||
// NO_BUTTON 페이지도 재확인 - 실제 어떤 버튼들이 있는지
|
||
{ id: 'acc-purchase', level1: '회계관리', level2: '매입관리', scanOnly: true },
|
||
{ id: 'acc-expense', level1: '회계관리', level2: '지출예상내역서', scanOnly: true },
|
||
{ id: 'acc-payment', level1: '회계관리', level2: '결제내역', scanOnly: true },
|
||
{ id: 'acc-sales', level1: '회계관리', level2: '매출관리', scanOnly: true },
|
||
{ id: 'acc-withdrawal', level1: '회계관리', level2: '출금관리', scanOnly: true },
|
||
{ id: 'hr-attendance', level1: '인사관리', level2: '근태현황', scanOnly: true },
|
||
{ id: 'purchase-status', level1: '구매관리', level2: '구매현황', scanOnly: true },
|
||
{ id: 'sales-pricing', level1: '판매관리', level2: '단가관리', scanOnly: true },
|
||
{ id: 'sales-client', level1: '판매관리', level2: '거래처관리', scanOnly: true },
|
||
{ id: 'sales-quotation', level1: '판매관리', level2: '견적관리', scanOnly: true },
|
||
];
|
||
|
||
async function main() {
|
||
[DOWNLOAD_DIR].forEach(d => {
|
||
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
||
});
|
||
|
||
console.log(C.bold('\n╔══════════════════════════════════════════════════╗'));
|
||
console.log(C.bold('║ 🔍 다운로드 버튼 상세 디버깅 ║'));
|
||
console.log(C.bold('╚══════════════════════════════════════════════════╝\n'));
|
||
|
||
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',
|
||
acceptDownloads: true,
|
||
});
|
||
|
||
const page = await context.newPage();
|
||
|
||
// 로그인
|
||
console.log(C.cyan('🔐 로그인...'));
|
||
await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle', timeout: 30000 });
|
||
await page.fill('input[type="text"], input[name="username"], #username', AUTH.username);
|
||
await page.fill('input[type="password"], input[name="password"], #password', AUTH.password);
|
||
await page.click('button[type="submit"]');
|
||
await page.waitForURL('**/dashboard**', { timeout: 15000 });
|
||
console.log(C.green('✅ 로그인 성공\n'));
|
||
|
||
const allResults = [];
|
||
|
||
for (let i = 0; i < DEBUG_TARGETS.length; i++) {
|
||
const target = DEBUG_TARGETS[i];
|
||
console.log(C.bold(`\n[${ i + 1}/${DEBUG_TARGETS.length}] ${target.level1} > ${target.level2} ${target.scanOnly ? '(버튼 스캔만)' : '(다운로드 디버깅)'}`));
|
||
console.log(C.dim('─'.repeat(60)));
|
||
|
||
// 대시보드로 이동
|
||
await page.goto(`${BASE_URL}/dashboard`, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||
await sleep(1000);
|
||
|
||
// 메뉴 탐색
|
||
const navOk = await navigateViaMenu(page, target.level1, target.level2);
|
||
if (!navOk) {
|
||
console.log(C.red(' ❌ 메뉴 탐색 실패'));
|
||
allResults.push({ ...target, status: 'NAV_FAIL' });
|
||
continue;
|
||
}
|
||
await sleep(2500);
|
||
|
||
const currentUrl = page.url();
|
||
console.log(C.dim(` 📍 ${currentUrl}`));
|
||
|
||
if (target.scanOnly) {
|
||
// 버튼 스캔만
|
||
const buttons = await page.evaluate(() => {
|
||
const allBtns = Array.from(document.querySelectorAll('button, a[role="button"], [role="button"]'));
|
||
return allBtns
|
||
.filter(b => {
|
||
const rect = b.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
})
|
||
.map(b => ({
|
||
text: (b.innerText?.trim() || '').substring(0, 50),
|
||
ariaLabel: b.getAttribute('aria-label') || '',
|
||
className: (b.className || '').substring(0, 60),
|
||
tagName: b.tagName,
|
||
}))
|
||
.filter(b => b.text || b.ariaLabel);
|
||
});
|
||
|
||
// 다운로드 관련 버튼 필터
|
||
const dlKeywords = ['엑셀', 'Excel', 'excel', '다운로드', 'download', 'Download', 'PDF', 'pdf', 'Export', 'export', '내보내기', '출력', '인쇄', 'CSV', 'csv'];
|
||
const dlButtons = buttons.filter(b => {
|
||
const combined = `${b.text} ${b.ariaLabel}`;
|
||
return dlKeywords.some(kw => combined.includes(kw));
|
||
});
|
||
|
||
if (dlButtons.length > 0) {
|
||
console.log(C.yellow(` 📋 다운로드 관련 버튼 발견 ${dlButtons.length}개:`));
|
||
dlButtons.forEach(b => console.log(C.yellow(` "${b.text}" (aria: ${b.ariaLabel || 'none'})`)));
|
||
} else {
|
||
console.log(C.dim(` ℹ️ 다운로드 관련 버튼 없음`));
|
||
// 전체 버튼 목록 출력 (디버깅용)
|
||
console.log(C.dim(` 전체 버튼 ${buttons.length}개: ${buttons.slice(0, 10).map(b => `"${b.text}"`).join(', ')}${buttons.length > 10 ? '...' : ''}`));
|
||
}
|
||
|
||
allResults.push({ ...target, status: dlButtons.length > 0 ? 'HAS_BUTTON' : 'NO_BUTTON', buttons: dlButtons, allButtons: buttons.slice(0, 15) });
|
||
continue;
|
||
}
|
||
|
||
// ───── 다운로드 디버깅 (FAIL 페이지) ─────
|
||
// 1. 다운로드 버튼 찾기
|
||
const dlButtons = await page.evaluate(() => {
|
||
const kw = ['엑셀', 'Excel', 'excel', '다운로드', 'download', 'PDF', 'pdf', 'Export', 'export', '내보내기', '출력', 'CSV'];
|
||
const allBtns = Array.from(document.querySelectorAll('button, a[role="button"], [role="button"], a'));
|
||
const found = [];
|
||
for (const btn of allBtns) {
|
||
const text = btn.innerText?.trim() || '';
|
||
const ariaLabel = btn.getAttribute('aria-label') || '';
|
||
const href = btn.getAttribute('href') || '';
|
||
const combined = `${text} ${ariaLabel} ${href}`;
|
||
const rect = btn.getBoundingClientRect();
|
||
if (rect.width <= 0 || rect.height <= 0) continue;
|
||
if (kw.some(k => combined.includes(k))) {
|
||
found.push({
|
||
text: text.substring(0, 50),
|
||
ariaLabel,
|
||
href,
|
||
tagName: btn.tagName,
|
||
disabled: btn.disabled || btn.getAttribute('aria-disabled') === 'true',
|
||
index: allBtns.indexOf(btn),
|
||
});
|
||
}
|
||
}
|
||
return found;
|
||
});
|
||
|
||
if (dlButtons.length === 0) {
|
||
console.log(C.yellow(' ⚠️ 다운로드 버튼 없음'));
|
||
allResults.push({ ...target, status: 'NO_BUTTON' });
|
||
continue;
|
||
}
|
||
|
||
for (const btn of dlButtons) {
|
||
console.log(C.cyan(` 🔘 버튼: "${btn.text}" (tag: ${btn.tagName}, disabled: ${btn.disabled})`));
|
||
|
||
if (btn.disabled) {
|
||
console.log(C.yellow(` ⚠️ 버튼 비활성화 상태`));
|
||
allResults.push({ ...target, status: 'DISABLED', button: btn.text });
|
||
continue;
|
||
}
|
||
|
||
// 2. 네트워크 요청 감시 시작
|
||
const networkLogs = [];
|
||
const reqHandler = req => {
|
||
const url = req.url();
|
||
if (!url.includes('_next/static') && !url.includes('favicon') && !url.includes('chunk')) {
|
||
networkLogs.push({ type: 'request', method: req.method(), url: url.substring(0, 120) });
|
||
}
|
||
};
|
||
const resHandler = res => {
|
||
const url = res.url();
|
||
if (!url.includes('_next/static') && !url.includes('favicon') && !url.includes('chunk')) {
|
||
networkLogs.push({
|
||
type: 'response', status: res.status(), url: url.substring(0, 120),
|
||
contentType: res.headers()['content-type']?.substring(0, 50) || '',
|
||
});
|
||
}
|
||
};
|
||
|
||
page.on('request', reqHandler);
|
||
page.on('response', resHandler);
|
||
|
||
// Console 로그 감시
|
||
const consoleLogs = [];
|
||
const consoleHandler = msg => consoleLogs.push(`[${msg.type()}] ${msg.text().substring(0, 100)}`);
|
||
page.on('console', consoleHandler);
|
||
|
||
// 다운로드 이벤트 감시
|
||
let downloadEvent = null;
|
||
const downloadHandler = dl => { downloadEvent = dl; };
|
||
page.on('download', downloadHandler);
|
||
|
||
// 팝업 감시
|
||
let popupPage = null;
|
||
const popupHandler = p => { popupPage = p; };
|
||
context.on('page', popupHandler);
|
||
|
||
// 3. 버튼 클릭
|
||
await page.evaluate((idx) => {
|
||
const allBtns = Array.from(document.querySelectorAll('button, a[role="button"], [role="button"], a'));
|
||
if (allBtns[idx]) allBtns[idx].click();
|
||
}, btn.index);
|
||
|
||
// 4. 8초 대기
|
||
await sleep(8000);
|
||
|
||
// 5. 다운로드 처리
|
||
if (downloadEvent) {
|
||
const fname = downloadEvent.suggestedFilename();
|
||
const savePath = path.join(DOWNLOAD_DIR, `${target.id}_${fname}`);
|
||
await downloadEvent.saveAs(savePath);
|
||
const stat = fs.statSync(savePath);
|
||
console.log(C.green(` ✅ 다운로드 성공: ${fname} (${(stat.size / 1024).toFixed(1)}KB)`));
|
||
allResults.push({ ...target, status: 'PASS', button: btn.text, file: fname, size: stat.size });
|
||
} else if (popupPage) {
|
||
console.log(C.yellow(` 📋 새 탭/팝업 열림: ${popupPage.url().substring(0, 80)}`));
|
||
try { await popupPage.close(); } catch {}
|
||
allResults.push({ ...target, status: 'POPUP', button: btn.text, popupUrl: popupPage.url() });
|
||
} else {
|
||
// 네트워크 로그 분석
|
||
const apiCalls = networkLogs.filter(l => l.type === 'response' && !l.url.includes('_next'));
|
||
console.log(C.dim(` 네트워크 응답 ${apiCalls.length}건:`));
|
||
apiCalls.forEach(l => {
|
||
const icon = l.status >= 200 && l.status < 300 ? '🟢' : l.status >= 400 ? '🔴' : '🟡';
|
||
console.log(C.dim(` ${icon} ${l.status} ${l.url} [${l.contentType}]`));
|
||
});
|
||
|
||
// Blob URL 또는 토스트 확인
|
||
const blobCheck = await page.evaluate(() => {
|
||
const toast = document.querySelector('[class*="toast"], [class*="Toastify"], [role="alert"]');
|
||
const anchors = Array.from(document.querySelectorAll('a')).filter(a => a.href?.startsWith('blob:'));
|
||
return {
|
||
toast: toast?.innerText?.trim() || null,
|
||
blobUrls: anchors.map(a => a.href),
|
||
};
|
||
});
|
||
|
||
if (blobCheck.toast) console.log(C.yellow(` 📌 토스트: "${blobCheck.toast}"`));
|
||
if (blobCheck.blobUrls.length > 0) console.log(C.cyan(` 📎 Blob URL 발견: ${blobCheck.blobUrls.length}개`));
|
||
|
||
if (consoleLogs.length > 0) {
|
||
const interesting = consoleLogs.filter(l => !l.includes('[info]') || l.includes('download') || l.includes('export') || l.includes('error'));
|
||
if (interesting.length > 0) {
|
||
console.log(C.dim(` Console: ${interesting.slice(0, 3).join('; ')}`));
|
||
}
|
||
}
|
||
|
||
// 서버 액션 POST 확인
|
||
const serverActions = networkLogs.filter(l => l.type === 'request' && l.method === 'POST');
|
||
if (serverActions.length > 0) {
|
||
console.log(C.yellow(` 📤 POST 요청 ${serverActions.length}건:`));
|
||
serverActions.forEach(l => console.log(C.dim(` ${l.url}`)));
|
||
}
|
||
|
||
allResults.push({ ...target, status: 'NO_DOWNLOAD', button: btn.text, network: apiCalls, serverActions });
|
||
}
|
||
|
||
// 리스너 해제
|
||
page.off('request', reqHandler);
|
||
page.off('response', resHandler);
|
||
page.off('console', consoleHandler);
|
||
page.off('download', downloadHandler);
|
||
context.off('page', popupHandler);
|
||
|
||
// 모달 닫기
|
||
await page.evaluate(async () => {
|
||
for (let i = 0; i < 3; i++) {
|
||
const modal = document.querySelector("[role='dialog'], [aria-modal='true'], [class*='modal']:not([class*='tooltip'])");
|
||
if (!modal || modal.getBoundingClientRect().width === 0) break;
|
||
const closeBtn = modal.querySelector("button[class*='close'], [aria-label='닫기']")
|
||
|| Array.from(modal.querySelectorAll('button')).find(b => ['닫기', 'Close', '취소', '확인'].some(t => b.innerText?.includes(t)));
|
||
if (closeBtn) closeBtn.click();
|
||
else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true }));
|
||
await new Promise(r => setTimeout(r, 500));
|
||
}
|
||
});
|
||
await sleep(500);
|
||
}
|
||
}
|
||
|
||
// ─── 결과 요약 ─────────────────────────────────────────────
|
||
console.log(C.bold('\n══════════════════════════════════════════════════'));
|
||
console.log(C.bold('📊 다운로드 디버깅 결과 요약'));
|
||
console.log(C.bold('══════════════════════════════════════════════════\n'));
|
||
|
||
const pass = allResults.filter(r => r.status === 'PASS');
|
||
const noBtn = allResults.filter(r => r.status === 'NO_BUTTON');
|
||
const hasBtn = allResults.filter(r => r.status === 'HAS_BUTTON');
|
||
const noDl = allResults.filter(r => r.status === 'NO_DOWNLOAD');
|
||
const disabled = allResults.filter(r => r.status === 'DISABLED');
|
||
|
||
console.log(` ${C.green(`✅ 다운로드 성공: ${pass.length}개`)} ${pass.map(r => r.id).join(', ')}`);
|
||
console.log(` ${C.red(`❌ 버튼 있으나 다운로드 안됨: ${noDl.length}개`)} ${noDl.map(r => r.id).join(', ')}`);
|
||
console.log(` ${C.yellow(`⚠️ 버튼 비활성화: ${disabled.length}개`)}`);
|
||
console.log(` ${C.yellow(`🔘 다운로드 버튼 발견(스캔): ${hasBtn.length}개`)} ${hasBtn.map(r => `${r.id}(${r.buttons.map(b=>b.text).join(',')})`).join(', ')}`);
|
||
console.log(` ${C.dim(`⏭️ 버튼 없음: ${noBtn.length}개`)} ${noBtn.map(r => r.id).join(', ')}`);
|
||
|
||
// 리포트 저장
|
||
const ts = getTimestamp();
|
||
const reportLines = ['# 🔍 다운로드 디버깅 상세 리포트\n', `**실행 시간**: ${ts}\n`];
|
||
|
||
reportLines.push('## 카테고리별 결과\n');
|
||
|
||
if (pass.length > 0) {
|
||
reportLines.push('### ✅ 다운로드 성공\n');
|
||
pass.forEach(r => reportLines.push(`- **${r.level1} > ${r.level2}**: ${r.button} → ${r.file} (${(r.size/1024).toFixed(1)}KB)\n`));
|
||
}
|
||
|
||
if (noDl.length > 0) {
|
||
reportLines.push('\n### ❌ 버튼 있으나 다운로드 미동작\n');
|
||
noDl.forEach(r => {
|
||
reportLines.push(`- **${r.level1} > ${r.level2}**: "${r.button}"\n`);
|
||
if (r.network?.length > 0) {
|
||
reportLines.push(` - 네트워크 응답: ${r.network.map(n => `${n.status} ${n.url}`).join('; ')}\n`);
|
||
}
|
||
if (r.serverActions?.length > 0) {
|
||
reportLines.push(` - Server Actions: ${r.serverActions.map(s => s.url).join('; ')}\n`);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (hasBtn.length > 0) {
|
||
reportLines.push('\n### 🔘 버튼 발견 (스캔 결과)\n');
|
||
hasBtn.forEach(r => {
|
||
reportLines.push(`- **${r.level1} > ${r.level2}**: ${r.buttons.map(b => `"${b.text}"`).join(', ')}\n`);
|
||
});
|
||
}
|
||
|
||
if (noBtn.length > 0) {
|
||
reportLines.push('\n### ⏭️ 다운로드 버튼 없음\n');
|
||
noBtn.forEach(r => {
|
||
const sample = r.allButtons?.slice(0, 8).map(b => `"${b.text}"`).join(', ') || 'N/A';
|
||
reportLines.push(`- **${r.level1} > ${r.level2}**: 주요 버튼: ${sample}\n`);
|
||
});
|
||
}
|
||
|
||
const reportPath = path.join(RESULTS_DIR, `Download-Debug_${ts}.md`);
|
||
fs.writeFileSync(reportPath, reportLines.join(''), 'utf-8');
|
||
console.log(C.cyan(`\n📄 리포트: ${reportPath}`));
|
||
|
||
await browser.close();
|
||
console.log(C.dim('🔒 브라우저 닫힘\n'));
|
||
}
|
||
|
||
// ─── 사이드바 메뉴 탐색 ─────────────────────────────────────
|
||
async function navigateViaMenu(page, level1, level2) {
|
||
try {
|
||
await page.evaluate(() => {
|
||
const sidebar = document.querySelector('.sidebar-scroll, [class*="sidebar"], nav');
|
||
if (sidebar) sidebar.scrollTo({ top: 0, behavior: 'instant' });
|
||
});
|
||
await sleep(300);
|
||
|
||
for (let scroll = 0; scroll < 15; scroll++) {
|
||
const found = await page.evaluate((l1) => {
|
||
const btns = Array.from(document.querySelectorAll('button, [role="button"], a'));
|
||
const btn = btns.find(b => b.innerText?.trim().startsWith(l1));
|
||
if (btn) { btn.click(); return true; }
|
||
return false;
|
||
}, level1);
|
||
|
||
if (found) {
|
||
await sleep(500);
|
||
const nav2 = await page.evaluate((l2) => {
|
||
const items = Array.from(document.querySelectorAll('a, button'));
|
||
const item = items.find(el => el.innerText?.trim() === l2);
|
||
if (item) { item.click(); return true; }
|
||
return false;
|
||
}, level2);
|
||
if (nav2) {
|
||
await sleep(2000);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
await page.evaluate(() => {
|
||
const sidebar = document.querySelector('.sidebar-scroll, [class*="sidebar"], nav');
|
||
if (sidebar) sidebar.scrollBy({ top: 150, behavior: 'instant' });
|
||
});
|
||
await sleep(100);
|
||
}
|
||
return false;
|
||
} catch { return false; }
|
||
}
|
||
|
||
main().catch(err => {
|
||
console.error(C.red(`💥 오류: ${err.message}`));
|
||
process.exit(1);
|
||
});
|