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>
This commit is contained in:
54
e2e/results/hotfix/Download-Debug_2026-03-04_20-21-21.md
Normal file
54
e2e/results/hotfix/Download-Debug_2026-03-04_20-21-21.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 🔍 다운로드 디버깅 상세 리포트
|
||||
**실행 시간**: 2026-03-04_20-21-21
|
||||
## 카테고리별 결과
|
||||
|
||||
### ❌ 버튼 있으나 다운로드 미동작
|
||||
- **회계관리 > 일일 일보**: "엑셀"
|
||||
- 네트워크 응답: 404 https://dev.codebridge-x.com/api/proxy/daily-report/export?date=2026-03-04
|
||||
- **회계관리 > 계좌입출금내역**: "엑셀 다운로드"
|
||||
- 네트워크 응답: 200 https://dev.codebridge-x.com/accounting/bank-transactions
|
||||
- Server Actions: https://dev.codebridge-x.com/accounting/bank-transactions
|
||||
- **회계관리 > 카드사용내역**: "엑셀 다운로드"
|
||||
- **회계관리 > 세금계산서관리**: "엑셀 다운로드"
|
||||
- 네트워크 응답: 200 https://dev.codebridge-x.com/accounting/tax-invoices
|
||||
- Server Actions: https://dev.codebridge-x.com/accounting/tax-invoices
|
||||
- **회계관리 > 미수금현황**: "엑셀 다운로드"
|
||||
- 네트워크 응답: 200 https://dev.codebridge-x.com/accounting/receivables-status
|
||||
- Server Actions: https://dev.codebridge-x.com/accounting/receivables-status
|
||||
- **회계관리 > 거래처원장**: "엑셀 다운로드"
|
||||
- 네트워크 응답: 200 https://dev.codebridge-x.com/accounting/vendor-ledger
|
||||
- Server Actions: https://dev.codebridge-x.com/accounting/vendor-ledger
|
||||
- **자재관리 > 재고현황**: "엑셀 다운로드"
|
||||
- 네트워크 응답: 200 https://dev.codebridge-x.com/api/proxy/stocks?start_date=2026-03-01&end_date=2026-03-04&size=1000&per_page=1000&page=1
|
||||
|
||||
### ⏭️ 다운로드 버튼 없음
|
||||
- **회계관리 > 매입관리**: 주요 버튼: "홍킬동
|
||||
|
||||
개발중인 메뉴", "모두 접기", "시스템 대시보드", "품질관리", "품목관리", "결재관리", "기준정보 관리", "게시판"
|
||||
- **회계관리 > 지출예상내역서**: 주요 버튼: "홍킬동
|
||||
|
||||
개발중인 메뉴", "모두 접기", "시스템 대시보드", "품질관리", "품목관리", "결재관리", "기준정보 관리", "게시판"
|
||||
- **회계관리 > 결제내역**: 주요 버튼: "홍킬동
|
||||
|
||||
개발중인 메뉴", "모두 접기", "시스템 대시보드", "품질관리", "품목관리", "결재관리", "기준정보 관리", "게시판"
|
||||
- **회계관리 > 매출관리**: 주요 버튼: "홍킬동
|
||||
|
||||
개발중인 메뉴", "모두 접기", "시스템 대시보드", "품질관리", "품목관리", "결재관리", "기준정보 관리", "게시판"
|
||||
- **회계관리 > 출금관리**: 주요 버튼: "홍킬동
|
||||
|
||||
개발중인 메뉴", "모두 접기", "시스템 대시보드", "품질관리", "품목관리", "결재관리", "기준정보 관리", "게시판"
|
||||
- **인사관리 > 근태현황**: 주요 버튼: "홍킬동
|
||||
|
||||
개발중인 메뉴", "모두 접기", "시스템 대시보드", "품질관리", "품목관리", "결재관리", "기준정보 관리", "게시판"
|
||||
- **구매관리 > 구매현황**: 주요 버튼: "홍킬동
|
||||
|
||||
개발중인 메뉴", "모두 접기", "시스템 대시보드", "품질관리", "품목관리", "결재관리", "기준정보 관리", "게시판"
|
||||
- **판매관리 > 단가관리**: 주요 버튼: "홍킬동
|
||||
|
||||
개발중인 메뉴", "모두 접기", "시스템 대시보드", "품질관리", "품목관리", "결재관리", "기준정보 관리", "게시판"
|
||||
- **판매관리 > 거래처관리**: 주요 버튼: "홍킬동
|
||||
|
||||
개발중인 메뉴", "모두 접기", "시스템 대시보드", "품질관리", "품목관리", "결재관리", "기준정보 관리", "게시판"
|
||||
- **판매관리 > 견적관리**: 주요 버튼: "홍킬동
|
||||
|
||||
개발중인 메뉴", "모두 접기", "시스템 대시보드", "품질관리", "품목관리", "결재관리", "기준정보 관리", "게시판"
|
||||
131
e2e/results/hotfix/Download-Verify_2026-03-04_20-16-16.md
Normal file
131
e2e/results/hotfix/Download-Verify_2026-03-04_20-16-16.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# 📥 다운로드 기능 검증 리포트
|
||||
|
||||
**실행 시간**: 2026-03-04 20-16-16 | **소요**: 245.5초
|
||||
|
||||
## 📊 요약
|
||||
|
||||
| 항목 | 결과 |
|
||||
|------|------|
|
||||
| 전체 페이지 | 20개 |
|
||||
| ✅ PASS | 1개 |
|
||||
| ⚠️ PARTIAL | 0개 |
|
||||
| ❌ FAIL | 9개 |
|
||||
| ⏭️ SKIP/ERROR | 10개 |
|
||||
| 다운로드 시도 | 10건 |
|
||||
| 다운로드 성공 | 1건 |
|
||||
| 다운로드 실패 | 9건 |
|
||||
|
||||
## 📋 페이지별 결과
|
||||
|
||||
| # | 페이지 | 상태 | 다운로드 | 비고 |
|
||||
|---|--------|------|---------|------|
|
||||
| 1 | 회계관리 > 매입관리 | ⏭️ NO_BUTTON | - | 다운로드 버튼 없음 (검색: 엑셀) |
|
||||
| 2 | 회계관리 > 일일 일보 | ❌ FAIL | 0/1 | API 응답 404 |
|
||||
| 3 | 회계관리 > 계좌입출금내역 | ❌ FAIL | 0/1 | 다운로드 이벤트 및 API 응답 없음 (10초 타임아웃) |
|
||||
| 4 | 회계관리 > 카드사용내역 | ❌ FAIL | 0/1 | 다운로드 이벤트 및 API 응답 없음 (10초 타임아웃) |
|
||||
| 5 | 회계관리 > 지출예상내역서 | ⏭️ NO_BUTTON | - | 다운로드 버튼 없음 (검색: 엑셀, PDF) |
|
||||
| 6 | 회계관리 > 세금계산서관리 | ❌ FAIL | 0/1 | 다운로드 이벤트 및 API 응답 없음 (10초 타임아웃) |
|
||||
| 7 | 회계관리 > 결제내역 | ⏭️ NO_BUTTON | - | 다운로드 버튼 없음 (검색: 엑셀, Excel, 다운로드) |
|
||||
| 8 | 회계관리 > 매출관리 | ⏭️ NO_BUTTON | - | 다운로드 버튼 없음 (검색: 엑셀) |
|
||||
| 9 | 회계관리 > 미수금현황 | ❌ FAIL | 0/1 | 다운로드 이벤트 및 API 응답 없음 (10초 타임아웃) |
|
||||
| 10 | 회계관리 > 출금관리 | ⏭️ NO_BUTTON | - | 다운로드 버튼 없음 (검색: 엑셀, 다운로드) |
|
||||
| 11 | 회계관리 > 거래처원장 | ❌ FAIL | 0/1 | 다운로드 이벤트 및 API 응답 없음 (10초 타임아웃) |
|
||||
| 12 | 결재관리 > 결재함 | ❌ FAIL | 0/1 | API 응답 500 |
|
||||
| 13 | 결재관리 > 참조함 | ❌ FAIL | 0/1 | API 응답 500 |
|
||||
| 14 | 인사관리 > 근태현황 | ⏭️ NO_BUTTON | - | 다운로드 버튼 없음 (검색: 엑셀) |
|
||||
| 15 | 자재관리 > 재고현황 | ❌ FAIL | 0/1 | 다운로드 이벤트 및 API 응답 없음 (10초 타임아웃) |
|
||||
| 16 | 구매관리 > 구매현황 | ⏭️ NO_BUTTON | - | 다운로드 버튼 없음 (검색: 엑셀, Excel) |
|
||||
| 17 | 생산관리 > 작업실적 | ✅ PASS | 1/1 | |
|
||||
| 18 | 판매관리 > 단가관리 | ⏭️ NO_BUTTON | - | 다운로드 버튼 없음 (검색: 엑셀) |
|
||||
| 19 | 판매관리 > 거래처관리 | ⏭️ NO_BUTTON | - | 다운로드 버튼 없음 (검색: 엑셀) |
|
||||
| 20 | 판매관리 > 견적관리 | ⏭️ NO_BUTTON | - | 다운로드 버튼 없음 (검색: PDF, 출력) |
|
||||
|
||||
## 📂 다운로드 상세 결과
|
||||
|
||||
### 회계관리 > 일일 일보
|
||||
|
||||
| 버튼 | 유형 | 결과 | 파일명 | 크기 | 비고 |
|
||||
|------|------|------|--------|------|------|
|
||||
| 엑셀 | excel | ❌ | - | - | API 응답 404 |
|
||||
|
||||
### 회계관리 > 계좌입출금내역
|
||||
|
||||
| 버튼 | 유형 | 결과 | 파일명 | 크기 | 비고 |
|
||||
|------|------|------|--------|------|------|
|
||||
| 엑셀 다운로드 | excel | ❌ | - | - | 다운로드 이벤트 및 API 응답 없음 (10초 타임아웃) |
|
||||
|
||||
### 회계관리 > 카드사용내역
|
||||
|
||||
| 버튼 | 유형 | 결과 | 파일명 | 크기 | 비고 |
|
||||
|------|------|------|--------|------|------|
|
||||
| 엑셀 다운로드 | excel | ❌ | - | - | 다운로드 이벤트 및 API 응답 없음 (10초 타임아웃) |
|
||||
|
||||
### 회계관리 > 세금계산서관리
|
||||
|
||||
| 버튼 | 유형 | 결과 | 파일명 | 크기 | 비고 |
|
||||
|------|------|------|--------|------|------|
|
||||
| 엑셀 다운로드 | excel | ❌ | - | - | 다운로드 이벤트 및 API 응답 없음 (10초 타임아웃) |
|
||||
|
||||
### 회계관리 > 미수금현황
|
||||
|
||||
| 버튼 | 유형 | 결과 | 파일명 | 크기 | 비고 |
|
||||
|------|------|------|--------|------|------|
|
||||
| 엑셀 다운로드 | excel | ❌ | - | - | 다운로드 이벤트 및 API 응답 없음 (10초 타임아웃) |
|
||||
|
||||
### 회계관리 > 거래처원장
|
||||
|
||||
| 버튼 | 유형 | 결과 | 파일명 | 크기 | 비고 |
|
||||
|------|------|------|--------|------|------|
|
||||
| 엑셀 다운로드 | excel | ❌ | - | - | 다운로드 이벤트 및 API 응답 없음 (10초 타임아웃) |
|
||||
|
||||
### 결재관리 > 결재함
|
||||
|
||||
| 버튼 | 유형 | 결과 | 파일명 | 크기 | 비고 |
|
||||
|------|------|------|--------|------|------|
|
||||
| PDF | pdf | ❌ | - | - | API 응답 500 |
|
||||
|
||||
### 결재관리 > 참조함
|
||||
|
||||
| 버튼 | 유형 | 결과 | 파일명 | 크기 | 비고 |
|
||||
|------|------|------|--------|------|------|
|
||||
| PDF | pdf | ❌ | - | - | API 응답 500 |
|
||||
|
||||
### 자재관리 > 재고현황
|
||||
|
||||
| 버튼 | 유형 | 결과 | 파일명 | 크기 | 비고 |
|
||||
|------|------|------|--------|------|------|
|
||||
| 엑셀 다운로드 | excel | ❌ | - | - | 다운로드 이벤트 및 API 응답 없음 (10초 타임아웃) |
|
||||
|
||||
### 생산관리 > 작업실적
|
||||
|
||||
| 버튼 | 유형 | 결과 | 파일명 | 크기 | 비고 |
|
||||
|------|------|------|--------|------|------|
|
||||
| 엑셀 다운로드 | excel | ✅ | 작업실적_20260304_201548.xlsx | 17.2KB | |
|
||||
|
||||
## 📁 다운로드된 파일 목록
|
||||
|
||||
| # | 파일명 | 크기 |
|
||||
|---|--------|------|
|
||||
| 1 | 작업실적_20260304_201548.xlsx | 17.2KB |
|
||||
|
||||
## ❌ 실패/이슈 항목
|
||||
|
||||
- **회계관리 > 매입관리**: NO_BUTTON - 다운로드 버튼 없음 (검색: 엑셀)
|
||||
- **회계관리 > 일일 일보**: FAIL - 엑셀: API 응답 404
|
||||
- **회계관리 > 계좌입출금내역**: FAIL - 엑셀 다운로드: 다운로드 이벤트 및 API 응답 없음 (10초 타임아웃)
|
||||
- **회계관리 > 카드사용내역**: FAIL - 엑셀 다운로드: 다운로드 이벤트 및 API 응답 없음 (10초 타임아웃)
|
||||
- **회계관리 > 지출예상내역서**: NO_BUTTON - 다운로드 버튼 없음 (검색: 엑셀, PDF)
|
||||
- **회계관리 > 세금계산서관리**: FAIL - 엑셀 다운로드: 다운로드 이벤트 및 API 응답 없음 (10초 타임아웃)
|
||||
- **회계관리 > 결제내역**: NO_BUTTON - 다운로드 버튼 없음 (검색: 엑셀, Excel, 다운로드)
|
||||
- **회계관리 > 매출관리**: NO_BUTTON - 다운로드 버튼 없음 (검색: 엑셀)
|
||||
- **회계관리 > 미수금현황**: FAIL - 엑셀 다운로드: 다운로드 이벤트 및 API 응답 없음 (10초 타임아웃)
|
||||
- **회계관리 > 출금관리**: NO_BUTTON - 다운로드 버튼 없음 (검색: 엑셀, 다운로드)
|
||||
- **회계관리 > 거래처원장**: FAIL - 엑셀 다운로드: 다운로드 이벤트 및 API 응답 없음 (10초 타임아웃)
|
||||
- **결재관리 > 결재함**: FAIL - PDF: API 응답 500
|
||||
- **결재관리 > 참조함**: FAIL - PDF: API 응답 500
|
||||
- **인사관리 > 근태현황**: NO_BUTTON - 다운로드 버튼 없음 (검색: 엑셀)
|
||||
- **자재관리 > 재고현황**: FAIL - 엑셀 다운로드: 다운로드 이벤트 및 API 응답 없음 (10초 타임아웃)
|
||||
- **구매관리 > 구매현황**: NO_BUTTON - 다운로드 버튼 없음 (검색: 엑셀, Excel)
|
||||
- **판매관리 > 단가관리**: NO_BUTTON - 다운로드 버튼 없음 (검색: 엑셀)
|
||||
- **판매관리 > 거래처관리**: NO_BUTTON - 다운로드 버튼 없음 (검색: 엑셀)
|
||||
- **판매관리 > 견적관리**: NO_BUTTON - 다운로드 버튼 없음 (검색: PDF, 출력)
|
||||
Binary file not shown.
424
e2e/runner/download-debug.js
Normal file
424
e2e/runner/download-debug.js
Normal file
@@ -0,0 +1,424 @@
|
||||
#!/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);
|
||||
});
|
||||
553
e2e/runner/download-verify.js
Normal file
553
e2e/runner/download-verify.js
Normal file
@@ -0,0 +1,553 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 다운로드 기능 전용 검증 스크립트
|
||||
*
|
||||
* 엑셀/PDF 다운로드 버튼이 있는 모든 페이지를 순회하며
|
||||
* 실제 다운로드가 동작하는지 검증합니다.
|
||||
*
|
||||
* Usage:
|
||||
* node e2e/runner/download-verify.js # 전체 실행
|
||||
* node e2e/runner/download-verify.js --headless # 백그라운드 실행
|
||||
*/
|
||||
|
||||
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 args = process.argv.slice(2);
|
||||
const HEADLESS = args.includes('--headless');
|
||||
|
||||
// ─── 다운로드 테스트 대상 페이지 정의 ─────────────────────────
|
||||
const DOWNLOAD_TARGETS = [
|
||||
// 회계관리
|
||||
{ id: 'acc-purchase', level1: '회계관리', level2: '매입관리', url: '/accounting/purchase-accounting', buttons: ['엑셀'] },
|
||||
{ id: 'acc-daily-report', level1: '회계관리', level2: '일일 일보', url: '/accounting/daily-report', buttons: ['엑셀', 'Excel', '다운로드', 'PDF', '인쇄'] },
|
||||
{ id: 'acc-bank-tx', level1: '회계관리', level2: '계좌입출금내역', url: '/accounting/bank-transactions', buttons: ['엑셀'] },
|
||||
{ id: 'acc-card-history', level1: '회계관리', level2: '카드사용내역', url: '/accounting/card-transactions', buttons: ['엑셀'] },
|
||||
{ id: 'acc-expense', level1: '회계관리', level2: '지출예상내역서', url: '/accounting/expected-expenses', buttons: ['엑셀', 'PDF'] },
|
||||
{ id: 'acc-tax', level1: '회계관리', level2: '세금계산서관리', url: '/accounting/tax-invoice', buttons: ['엑셀', 'Excel'] },
|
||||
{ id: 'acc-payment', level1: '회계관리', level2: '결제내역', url: '/payment-history', buttons: ['엑셀', 'Excel', '다운로드'] },
|
||||
{ id: 'acc-sales', level1: '회계관리', level2: '매출관리', url: '/accounting/sales-accounting', buttons: ['엑셀'] },
|
||||
{ id: 'acc-receivable', level1: '회계관리', level2: '미수금현황', url: '/accounting/receivables-status', buttons: ['엑셀', '다운로드', '내보내기'] },
|
||||
{ id: 'acc-withdrawal', level1: '회계관리', level2: '출금관리', url: '/ko/accounting/withdrawals', buttons: ['엑셀', '다운로드'] },
|
||||
{ id: 'acc-vendor-ledger', level1: '회계관리', level2: '거래처원장', url: '/ko/accounting/vendor-ledger', buttons: ['엑셀', 'PDF'] },
|
||||
// 결재관리
|
||||
{ id: 'approval-box', level1: '결재관리', level2: '결재함', url: '/ko/approval/inbox', buttons: ['PDF'], needsDetail: true },
|
||||
{ id: 'reference-box', level1: '결재관리', level2: '참조함', url: '/ko/approval/reference', buttons: ['PDF'], needsDetail: true },
|
||||
// 인사관리
|
||||
{ id: 'hr-attendance', level1: '인사관리', level2: '근태현황', url: '/hr/attendance', buttons: ['엑셀'] },
|
||||
// 자재관리
|
||||
{ id: 'material-stock', level1: '자재관리', level2: '재고현황', url: '/material/stock-status', buttons: ['엑셀', '다운로드'] },
|
||||
// 구매관리
|
||||
{ id: 'purchase-status', level1: '구매관리', level2: '구매현황', url: '/purchase/status', buttons: ['엑셀', 'Excel'] },
|
||||
// 생산관리
|
||||
{ id: 'prod-work-result', level1: '생산관리', level2: '작업실적', url: '/production/work-results', buttons: ['엑셀', 'Excel', '다운로드'] },
|
||||
// 판매관리
|
||||
{ id: 'sales-pricing', level1: '판매관리', level2: '단가관리', url: '/sales/pricing-management', buttons: ['엑셀'] },
|
||||
{ id: 'sales-client', level1: '판매관리', level2: '거래처관리', url: '/sales/client-management-sales-admin', buttons: ['엑셀'] },
|
||||
{ id: 'sales-quotation', level1: '판매관리', level2: '견적관리', url: '/sales/quote-management', buttons: ['PDF', '출력'] },
|
||||
];
|
||||
|
||||
// ─── Color helpers ──────────────────────────────────────────
|
||||
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 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())}`;
|
||||
}
|
||||
|
||||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
// ─── Main ──────────────────────────────────────────────────
|
||||
async function main() {
|
||||
// 디렉토리 준비
|
||||
[RESULTS_DIR, 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'));
|
||||
console.log(` 대상: ${DOWNLOAD_TARGETS.length}개 페이지`);
|
||||
console.log(` 모드: ${HEADLESS ? 'Headless' : 'Headed'}`);
|
||||
console.log(` 저장: ${DOWNLOAD_DIR}\n`);
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: HEADLESS,
|
||||
args: HEADLESS ? [] : ['--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 results = [];
|
||||
const startTime = Date.now();
|
||||
|
||||
for (let i = 0; i < DOWNLOAD_TARGETS.length; i++) {
|
||||
const target = DOWNLOAD_TARGETS[i];
|
||||
const progress = `[${i + 1}/${DOWNLOAD_TARGETS.length}]`;
|
||||
console.log(C.bold(`${progress} ${target.level1} > ${target.level2}`));
|
||||
|
||||
const pageResult = {
|
||||
id: target.id,
|
||||
page: `${target.level1} > ${target.level2}`,
|
||||
url: target.url,
|
||||
downloads: [],
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
try {
|
||||
// 대시보드로 이동
|
||||
await page.goto(`${BASE_URL}/dashboard`, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
await sleep(1000);
|
||||
|
||||
// 사이드바 메뉴 탐색
|
||||
const navSuccess = await navigateViaMenu(page, target.level1, target.level2);
|
||||
if (!navSuccess) {
|
||||
pageResult.status = 'NAV_FAIL';
|
||||
pageResult.error = '사이드바 메뉴 탐색 실패';
|
||||
console.log(C.red(` ❌ 메뉴 탐색 실패: ${target.level1} > ${target.level2}`));
|
||||
results.push(pageResult);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 페이지 로드 대기
|
||||
await sleep(2000);
|
||||
|
||||
// URL 확인
|
||||
const currentUrl = page.url();
|
||||
console.log(C.dim(` 📍 URL: ${currentUrl}`));
|
||||
|
||||
// 결재함/참조함은 상세 페이지 진입 필요
|
||||
if (target.needsDetail) {
|
||||
const detailOpened = await openFirstDetail(page);
|
||||
if (!detailOpened) {
|
||||
pageResult.status = 'NO_DETAIL';
|
||||
pageResult.error = '상세 진입할 데이터 없음';
|
||||
console.log(C.yellow(` ⚠️ 상세 진입할 데이터 없음 (PDF 버튼은 상세 모달에서만 가능)`));
|
||||
results.push(pageResult);
|
||||
continue;
|
||||
}
|
||||
await sleep(1500);
|
||||
}
|
||||
|
||||
// 다운로드 버튼 찾기 및 실행
|
||||
const foundButtons = await findDownloadButtons(page, target.buttons);
|
||||
|
||||
if (foundButtons.length === 0) {
|
||||
pageResult.status = 'NO_BUTTON';
|
||||
pageResult.error = `다운로드 버튼 없음 (검색: ${target.buttons.join(', ')})`;
|
||||
console.log(C.yellow(` ⚠️ 다운로드 버튼을 찾지 못함`));
|
||||
results.push(pageResult);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(C.dim(` 📋 발견된 버튼: ${foundButtons.map(b => b.text).join(', ')}`));
|
||||
|
||||
// 각 버튼 클릭하여 다운로드 테스트
|
||||
for (const btn of foundButtons) {
|
||||
const dlResult = await testDownloadButton(page, btn, target);
|
||||
pageResult.downloads.push(dlResult);
|
||||
|
||||
if (dlResult.success) {
|
||||
console.log(C.green(` ✅ ${btn.text}: 다운로드 성공 (${dlResult.fileName || dlResult.type})`));
|
||||
} else {
|
||||
console.log(C.red(` ❌ ${btn.text}: ${dlResult.error}`));
|
||||
}
|
||||
|
||||
await sleep(1000);
|
||||
|
||||
// 모달이 열려있으면 닫기
|
||||
await closeAllModals(page);
|
||||
}
|
||||
|
||||
const allSuccess = pageResult.downloads.length > 0 && pageResult.downloads.every(d => d.success);
|
||||
const someSuccess = pageResult.downloads.some(d => d.success);
|
||||
pageResult.status = allSuccess ? 'PASS' : someSuccess ? 'PARTIAL' : 'FAIL';
|
||||
|
||||
} catch (err) {
|
||||
pageResult.status = 'ERROR';
|
||||
pageResult.error = err.message;
|
||||
console.log(C.red(` ❌ 오류: ${err.message}`));
|
||||
}
|
||||
|
||||
results.push(pageResult);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
|
||||
// 결과 요약
|
||||
const passed = results.filter(r => r.status === 'PASS').length;
|
||||
const partial = results.filter(r => r.status === 'PARTIAL').length;
|
||||
const failed = results.filter(r => r.status === 'FAIL').length;
|
||||
const errors = results.filter(r => ['ERROR', 'NAV_FAIL', 'NO_BUTTON', 'NO_DETAIL'].includes(r.status)).length;
|
||||
|
||||
console.log(C.bold('══════════════════════════════════════════════════'));
|
||||
console.log(C.bold('📊 다운로드 검증 결과 요약'));
|
||||
console.log(C.bold('══════════════════════════════════════════════════'));
|
||||
console.log(` 전체: ${results.length}개 페이지 | 소요: ${elapsed}초`);
|
||||
console.log(` ${C.green(`✅ PASS: ${passed}`)} | ${C.yellow(`⚠️ PARTIAL: ${partial}`)} | ${C.red(`❌ FAIL: ${failed}`)} | ${C.dim(`⏭️ SKIP/ERROR: ${errors}`)}`);
|
||||
|
||||
// 개별 다운로드 통계
|
||||
const allDownloads = results.flatMap(r => r.downloads);
|
||||
const dlSuccess = allDownloads.filter(d => d.success).length;
|
||||
const dlFail = allDownloads.filter(d => !d.success).length;
|
||||
console.log(` 다운로드 시도: ${allDownloads.length}건 (성공: ${dlSuccess}, 실패: ${dlFail})`);
|
||||
|
||||
// 다운로드된 파일 목록
|
||||
const downloadedFiles = allDownloads.filter(d => d.success && d.fileName);
|
||||
if (downloadedFiles.length > 0) {
|
||||
console.log(`\n 📂 다운로드된 파일:`);
|
||||
downloadedFiles.forEach(d => {
|
||||
console.log(` ${d.fileName} (${d.fileSize ? (d.fileSize / 1024).toFixed(1) + 'KB' : '?'})`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// 실패/에러 목록
|
||||
const failedResults = results.filter(r => !['PASS', 'PARTIAL'].includes(r.status));
|
||||
if (failedResults.length > 0) {
|
||||
console.log(C.red(' ❌ 실패/스킵 목록:'));
|
||||
failedResults.forEach(r => {
|
||||
console.log(C.red(` ${r.page}: ${r.error || r.status}`));
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// 리포트 저장
|
||||
const ts = getTimestamp();
|
||||
const reportPath = path.join(RESULTS_DIR, `Download-Verify_${ts}.md`);
|
||||
const report = generateReport(results, ts, elapsed, downloadedFiles);
|
||||
fs.writeFileSync(reportPath, report, 'utf-8');
|
||||
console.log(C.cyan(`📄 리포트 저장: ${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);
|
||||
|
||||
// level1 메뉴 찾아서 클릭
|
||||
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);
|
||||
|
||||
// level2 메뉴 클릭
|
||||
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 (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 첫 번째 데이터 상세 열기 (결재함/참조함) ─────────────────
|
||||
async function openFirstDetail(page) {
|
||||
try {
|
||||
// 테이블에서 첫 번째 행 클릭
|
||||
const clicked = await page.evaluate(() => {
|
||||
const row = document.querySelector('table tbody tr, [class*="table"] [class*="row"]:not(:first-child)');
|
||||
if (row) { row.click(); return true; }
|
||||
// 카드/리스트 아이템 시도
|
||||
const card = document.querySelector('[class*="card"]:not([class*="header"]), [class*="list-item"]');
|
||||
if (card) { card.click(); return true; }
|
||||
return false;
|
||||
});
|
||||
return clicked;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 다운로드 버튼 찾기 ──────────────────────────────────────
|
||||
async function findDownloadButtons(page, keywords) {
|
||||
return await page.evaluate((kw) => {
|
||||
const allBtns = Array.from(document.querySelectorAll('button, a[role="button"], [role="button"]'));
|
||||
const found = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const btn of allBtns) {
|
||||
const text = btn.innerText?.trim() || '';
|
||||
const ariaLabel = btn.getAttribute('aria-label') || '';
|
||||
const title = btn.getAttribute('title') || '';
|
||||
const combined = `${text} ${ariaLabel} ${title}`;
|
||||
|
||||
if (!text && !ariaLabel && !title) continue;
|
||||
|
||||
for (const keyword of kw) {
|
||||
if (combined.includes(keyword)) {
|
||||
const key = text || ariaLabel || title;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
// 버튼이 보이는지 확인
|
||||
const rect = btn.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
found.push({
|
||||
text: key,
|
||||
keyword,
|
||||
index: allBtns.indexOf(btn),
|
||||
type: keyword.includes('PDF') || keyword.includes('pdf') ? 'pdf' : 'excel',
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}, keywords);
|
||||
}
|
||||
|
||||
// ─── 다운로드 버튼 테스트 ────────────────────────────────────
|
||||
async function testDownloadButton(page, btn, target) {
|
||||
const result = {
|
||||
buttonText: btn.text,
|
||||
type: btn.type,
|
||||
success: false,
|
||||
fileName: null,
|
||||
fileSize: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
try {
|
||||
// 다운로드 이벤트 대기 설정 (10초 타임아웃)
|
||||
const downloadPromise = page.waitForEvent('download', { timeout: 10000 }).catch(() => null);
|
||||
|
||||
// API 응답 감시 (export, download, pdf 패턴)
|
||||
const apiPatterns = ['/export', '/download', '/pdf', '.xlsx', '.csv', '.pdf'];
|
||||
const responsePromise = page.waitForResponse(
|
||||
resp => apiPatterns.some(p => resp.url().includes(p)),
|
||||
{ timeout: 10000 }
|
||||
).catch(() => null);
|
||||
|
||||
// 버튼 클릭
|
||||
await page.evaluate((btnIndex) => {
|
||||
const allBtns = Array.from(document.querySelectorAll('button, a[role="button"], [role="button"]'));
|
||||
if (allBtns[btnIndex]) allBtns[btnIndex].click();
|
||||
}, btn.index);
|
||||
|
||||
// 다운로드 또는 API 응답 대기
|
||||
const [download, response] = await Promise.all([downloadPromise, responsePromise]);
|
||||
|
||||
if (download) {
|
||||
// 실제 파일 다운로드 발생
|
||||
const suggestedName = download.suggestedFilename();
|
||||
const savePath = path.join(DOWNLOAD_DIR, `${target.id}_${suggestedName}`);
|
||||
await download.saveAs(savePath);
|
||||
|
||||
const stat = fs.statSync(savePath);
|
||||
result.success = true;
|
||||
result.fileName = suggestedName;
|
||||
result.fileSize = stat.size;
|
||||
result.savedPath = savePath;
|
||||
} else if (response) {
|
||||
// API 응답은 있으나 브라우저 다운로드 이벤트는 없음
|
||||
const status = response.status();
|
||||
const contentType = response.headers()['content-type'] || '';
|
||||
|
||||
if (status >= 200 && status < 300) {
|
||||
// Blob 다운로드 등 JS에서 처리하는 경우
|
||||
result.success = true;
|
||||
result.fileName = `API_${status}_${contentType.split(';')[0]}`;
|
||||
result.apiStatus = status;
|
||||
result.contentType = contentType;
|
||||
} else {
|
||||
result.error = `API 응답 ${status}`;
|
||||
}
|
||||
} else {
|
||||
// 둘 다 없음 - 알림/인쇄 팝업일 수 있음
|
||||
// 2초 더 대기 후 토스트나 상태 변화 확인
|
||||
await sleep(2000);
|
||||
const toastOrChange = await page.evaluate(() => {
|
||||
const toast = document.querySelector('[class*="toast"], [class*="Toastify"], [role="alert"]');
|
||||
return toast ? toast.innerText?.trim() : null;
|
||||
});
|
||||
|
||||
if (toastOrChange) {
|
||||
result.error = `다운로드 미감지, 토스트: "${toastOrChange}"`;
|
||||
} else {
|
||||
result.error = '다운로드 이벤트 및 API 응답 없음 (10초 타임아웃)';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
result.error = e.message;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── 모달 닫기 ──────────────────────────────────────────────
|
||||
async function closeAllModals(page) {
|
||||
try {
|
||||
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']), [class*='Modal']");
|
||||
if (!modal || modal.getBoundingClientRect().width === 0) break;
|
||||
|
||||
const closeBtn = modal.querySelector("button[class*='close'], [aria-label='닫기'], [aria-label='Close']")
|
||||
|| Array.from(modal.querySelectorAll('button')).find(b =>
|
||||
['닫기', 'Close', '취소', 'Cancel', '확인'].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));
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ─── 리포트 생성 ────────────────────────────────────────────
|
||||
function generateReport(results, ts, elapsed, downloadedFiles) {
|
||||
const passed = results.filter(r => r.status === 'PASS').length;
|
||||
const partial = results.filter(r => r.status === 'PARTIAL').length;
|
||||
const failed = results.filter(r => r.status === 'FAIL').length;
|
||||
const errors = results.filter(r => !['PASS', 'PARTIAL', 'FAIL'].includes(r.status)).length;
|
||||
const allDownloads = results.flatMap(r => r.downloads);
|
||||
const dlSuccess = allDownloads.filter(d => d.success).length;
|
||||
const dlFail = allDownloads.filter(d => !d.success).length;
|
||||
|
||||
let md = `# 📥 다운로드 기능 검증 리포트
|
||||
|
||||
**실행 시간**: ${ts.replace('_', ' ')} | **소요**: ${elapsed}초
|
||||
|
||||
## 📊 요약
|
||||
|
||||
| 항목 | 결과 |
|
||||
|------|------|
|
||||
| 전체 페이지 | ${results.length}개 |
|
||||
| ✅ PASS | ${passed}개 |
|
||||
| ⚠️ PARTIAL | ${partial}개 |
|
||||
| ❌ FAIL | ${failed}개 |
|
||||
| ⏭️ SKIP/ERROR | ${errors}개 |
|
||||
| 다운로드 시도 | ${allDownloads.length}건 |
|
||||
| 다운로드 성공 | ${dlSuccess}건 |
|
||||
| 다운로드 실패 | ${dlFail}건 |
|
||||
|
||||
## 📋 페이지별 결과
|
||||
|
||||
| # | 페이지 | 상태 | 다운로드 | 비고 |
|
||||
|---|--------|------|---------|------|
|
||||
`;
|
||||
|
||||
results.forEach((r, i) => {
|
||||
const statusIcon = r.status === 'PASS' ? '✅' : r.status === 'PARTIAL' ? '⚠️' : r.status === 'FAIL' ? '❌' : '⏭️';
|
||||
const dlCount = r.downloads.length;
|
||||
const dlOk = r.downloads.filter(d => d.success).length;
|
||||
const dlInfo = dlCount > 0 ? `${dlOk}/${dlCount}` : '-';
|
||||
const note = r.error || r.downloads.filter(d => !d.success).map(d => d.error).join('; ') || '';
|
||||
md += `| ${i + 1} | ${r.page} | ${statusIcon} ${r.status} | ${dlInfo} | ${note.substring(0, 60)} |\n`;
|
||||
});
|
||||
|
||||
// 다운로드 상세
|
||||
md += `\n## 📂 다운로드 상세 결과\n\n`;
|
||||
|
||||
for (const r of results) {
|
||||
if (r.downloads.length === 0) continue;
|
||||
md += `### ${r.page}\n\n`;
|
||||
md += `| 버튼 | 유형 | 결과 | 파일명 | 크기 | 비고 |\n`;
|
||||
md += `|------|------|------|--------|------|------|\n`;
|
||||
|
||||
for (const d of r.downloads) {
|
||||
const icon = d.success ? '✅' : '❌';
|
||||
const size = d.fileSize ? `${(d.fileSize / 1024).toFixed(1)}KB` : '-';
|
||||
const note = d.error || '';
|
||||
md += `| ${d.buttonText} | ${d.type} | ${icon} | ${d.fileName || '-'} | ${size} | ${note} |\n`;
|
||||
}
|
||||
md += '\n';
|
||||
}
|
||||
|
||||
// 다운로드된 파일 목록
|
||||
if (downloadedFiles.length > 0) {
|
||||
md += `## 📁 다운로드된 파일 목록\n\n`;
|
||||
md += `| # | 파일명 | 크기 |\n`;
|
||||
md += `|---|--------|------|\n`;
|
||||
downloadedFiles.forEach((d, i) => {
|
||||
const size = d.fileSize ? `${(d.fileSize / 1024).toFixed(1)}KB` : '-';
|
||||
md += `| ${i + 1} | ${d.fileName} | ${size} |\n`;
|
||||
});
|
||||
md += '\n';
|
||||
}
|
||||
|
||||
// 실패 항목 상세
|
||||
const failItems = results.filter(r => !['PASS'].includes(r.status));
|
||||
if (failItems.length > 0) {
|
||||
md += `## ❌ 실패/이슈 항목\n\n`;
|
||||
for (const r of failItems) {
|
||||
md += `- **${r.page}**: ${r.status} - ${r.error || r.downloads.filter(d => !d.success).map(d => `${d.buttonText}: ${d.error}`).join(', ')}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
// ─── Run ────────────────────────────────────────────────────
|
||||
main().catch(err => {
|
||||
console.error(C.red(`\n💥 치명적 오류: ${err.message}`));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user