Files
sam-hotfix/e2e/runner/download-debug.js
김보곤 9a0d3aa46d test: 다운로드 기능 검증 스크립트 및 결과 리포트 추가
- 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>
2026-03-04 21:01:15 +09:00

425 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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);
});