diff --git a/e2e/results/hotfix/E2E_FULL_TEST_SUMMARY_2026-02-19_09-55-59.md b/e2e/results/hotfix/E2E_FULL_TEST_SUMMARY_2026-02-19_09-55-59.md new file mode 100644 index 0000000..87242e5 --- /dev/null +++ b/e2e/results/hotfix/E2E_FULL_TEST_SUMMARY_2026-02-19_09-55-59.md @@ -0,0 +1,287 @@ +# E2E 전체 테스트 결과 요약 + +**실행 시간**: 2026-02-19_09-55-59 +**총 소요 시간**: 71.2분 +**전체 시나리오**: 184개 | **성공**: 180개 | **실패**: 4개 + +## 카테고리별 요약 +| 카테고리 | 시나리오 수 | 성공 | 실패 | 성공률 | +|---------|-----------|------|------|--------| +| 접근성 검사 | 18 | 18 | 0 | 100% | +| 기능 테스트 | 127 | 123 | 4 | 97% | +| 엣지 케이스 | 17 | 17 | 0 | 100% | +| 성능 테스트 | 17 | 17 | 0 | 100% | +| 비즈니스 워크플로우 | 5 | 5 | 0 | 100% | + +## 시나리오별 결과 +| # | 시나리오 | 결과 | 스텝 | 성공 | 실패 | 소요(초) | +|---|---------|------|------|------|------|---------| +| 1 | 접근성 검사: 회계관리 > 거래처관리 | ✅ | 4 | 3 | 0 | 10.6 | +| 2 | 접근성 검사: 회계관리 > 입금관리 | ✅ | 4 | 3 | 0 | 10.5 | +| 3 | 접근성 검사: 회계관리 > 매입관리 | ✅ | 4 | 3 | 0 | 10.4 | +| 4 | 접근성 검사: 회계관리 > 매출관리 | ✅ | 4 | 3 | 0 | 10.4 | +| 5 | 접근성 검사: 결재관리 > 결재함 | ✅ | 4 | 3 | 0 | 10.3 | +| 6 | 접근성 검사: 결재관리 > 기안함 | ✅ | 4 | 3 | 0 | 10.3 | +| 7 | 접근성 검사: 게시판 > 자유게시판 | ✅ | 4 | 3 | 0 | 10.3 | +| 8 | 접근성 검사: 인사관리 > 근태관리 | ✅ | 4 | 3 | 0 | 10.3 | +| 9 | 접근성 검사: 인사관리 > 부서관리 | ✅ | 4 | 3 | 0 | 10.3 | +| 10 | 접근성 검사: 인사관리 > 사원관리 | ✅ | 4 | 3 | 0 | 10.4 | +| 11 | 접근성 검사: 인사관리 > 급여관리 | ✅ | 4 | 3 | 0 | 10.5 | +| 12 | 접근성 검사: 자재관리 > 입고관리 | ✅ | 4 | 3 | 0 | 10.4 | +| 13 | 접근성 검사: 자재관리 > 재고현황 | ✅ | 4 | 3 | 0 | 10.4 | +| 14 | 접근성 검사: 생산관리 > 품목관리 | ✅ | 4 | 4 | 0 | 9.5 | +| 15 | 접근성 검사: 생산관리 > 작업지시 | ✅ | 4 | 3 | 0 | 10.5 | +| 16 | 접근성 검사: 판매관리 > 거래처관리 | ✅ | 4 | 3 | 0 | 10.4 | +| 17 | 접근성 검사: 판매관리 > 견적관리 | ✅ | 4 | 3 | 0 | 10.5 | +| 18 | 접근성 검사: 판매관리 > 수주관리 | ✅ | 4 | 3 | 0 | 10.4 | +| 19 | 악성채권추심관리 테스트 | ✅ | 24 | 22 | 0 | 10.4 | +| 20 | 계좌입출금내역 테스트 | ✅ | 19 | 16 | 0 | 10.2 | +| 21 | 어음관리 테스트 | ✅ | 24 | 18 | 0 | 19.6 | +| 22 | 카드사용내역 테스트 | ✅ | 19 | 16 | 0 | 10.3 | +| 23 | 회계거래처관리 테스트 | ✅ | 23 | 20 | 0 | 16.6 | +| 24 | 입금관리 테스트 | ✅ | 25 | 19 | 0 | 19.5 | +| 25 | 지출예상내역서 테스트 | ✅ | 19 | 14 | 0 | 11.7 | +| 26 | 결제내역 테스트 | ✅ | 19 | 15 | 0 | 13.4 | +| 27 | 매입관리 테스트 | ✅ | 18 | 14 | 0 | 13.6 | +| 28 | 미수금현황 테스트 | ✅ | 19 | 16 | 0 | 11.5 | +| 29 | 매출관리 테스트 | ✅ | 18 | 14 | 0 | 13.6 | +| 30 | 출금관리 테스트 | ✅ | 25 | 19 | 0 | 19.6 | +| 31 | API 건강성 감사: 회계 | ✅ | 39 | 39 | 0 | 26.4 | +| 32 | API 건강성 감사: 생산/기타 | ✅ | 35 | 35 | 0 | 28.9 | +| 33 | API 건강성 감사: 판매/인사 | ✅ | 35 | 35 | 0 | 28.8 | +| 34 | 결재함 E2E 테스트 | ✅ | 20 | 12 | 0 | 45.7 | +| 35 | 근태현황 출퇴근 테스트 | ✅ | 17 | 12 | 0 | 32.9 | +| 36 | 연속 등록 테스트: 어음관리 | ✅ | 39 | 39 | 0 | 85.8 | +| 37 | 연속 등록 테스트: 입금관리 | ✅ | 39 | 39 | 0 | 90.5 | +| 38 | 연속 등록 테스트: 자유게시판 | ✅ | 33 | 33 | 0 | 74.9 | +| 39 | 계정과목 일괄변경 버그 회귀 테스트 (BUG-SALES-20260115-001): 매출관리 | ✅ | 14 | 14 | 0 | 19.0 | +| 40 | 게시판 관리 테스트 | ✅ | 22 | 22 | 0 | 11.5 | +| 41 | 설정 - 회사정보 | ✅ | 31 | 14 | 0 | 48.0 | +| 42 | Create+Delete 테스트: 어음관리 | ✅ | 12 | 12 | 0 | 30.6 | +| 43 | Create+Delete 테스트: 입금관리 | ✅ | 12 | 12 | 0 | 28.0 | +| 44 | Create+Delete 테스트: 자유게시판 | ✅ | 12 | 12 | 0 | 27.6 | +| 45 | 모듈 간 데이터 일관성 검증 (판매↔회계, 판매↔생산) | ✅ | 15 | 15 | 0 | 31.3 | +| 46 | 이벤트 게시판 테스트 | ✅ | 19 | 14 | 0 | 13.8 | +| 47 | FAQ 테스트 | ✅ | 16 | 12 | 0 | 11.0 | +| 48 | 공지사항 테스트 | ✅ | 19 | 15 | 0 | 13.8 | +| 49 | 부서관리 테스트 | ✅ | 16 | 12 | 0 | 13.1 | +| 50 | 입금관리 테스트 | ✅ | 21 | 20 | 0 | 27.8 | +| 51 | 상세 조회 왕복 검증: 회계 | ✅ | 23 | 23 | 0 | 26.9 | +| 52 | 상세 조회 왕복 검증: 인사/게시판 | ✅ | 15 | 15 | 0 | 21.7 | +| 53 | 상세 조회 왕복 검증: 판매 | ✅ | 23 | 23 | 0 | 26.8 | +| 54 | 목록↔상세 필드별 대조 검증: 매출관리 | ❌ | 12 | 11 | 1 | 18.4 | +| 55 | 기안함 테스트 | ✅ | 17 | 15 | 0 | 11.8 | +| 56 | 엣지 케이스: 경계값 입력 검증 (회계 > 매출관리) | ✅ | 14 | 14 | 0 | 17.8 | +| 57 | 엣지 케이스: 경계값 입력 (회계 > 입금관리) | ✅ | 14 | 14 | 0 | 20.5 | +| 58 | 엣지 케이스: 경계값 입력 (인사 > 사원관리) | ✅ | 14 | 14 | 0 | 20.5 | +| 59 | 엣지 케이스: 경계값 입력 (판매 > 거래처관리) | ✅ | 14 | 14 | 0 | 20.5 | +| 60 | 엣지 케이스: 동시 액션 (인사 > 근태관리) | ✅ | 5 | 5 | 0 | 11.9 | +| 61 | 엣지 케이스: 빈 폼 제출 (회계 > 입금관리) | ✅ | 7 | 7 | 0 | 16.2 | +| 62 | 엣지 케이스: 빈 폼 제출 (게시판 > 자유게시판) | ✅ | 7 | 7 | 0 | 16.2 | +| 63 | 엣지 케이스: 빈 폼 제출 (인사 > 사원관리) | ✅ | 7 | 7 | 0 | 16.2 | +| 64 | 엣지 케이스: 빈 폼 제출 (판매 > 거래처관리) | ✅ | 7 | 7 | 0 | 16.2 | +| 65 | 엣지 케이스: 숫자 경계값 (회계 > 입금관리) | ✅ | 13 | 13 | 0 | 20.8 | +| 66 | 엣지 케이스: UI 내구성 연타 테스트 (회계 > 매출관리) | ✅ | 10 | 10 | 0 | 22.5 | +| 67 | 엣지 케이스: 삭제 버튼 연타 (게시판 > 자유게시판) | ✅ | 6 | 6 | 0 | 12.9 | +| 68 | 엣지 케이스: 저장 버튼 연타 (게시판 > 자유게시판) | ✅ | 7 | 7 | 0 | 17.0 | +| 69 | 엣지 케이스: 저장 버튼 연타 (판매 > 거래처관리) | ✅ | 7 | 7 | 0 | 17.0 | +| 70 | 엣지 케이스: 특수문자 검색 (게시판 > 자유게시판) | ✅ | 14 | 14 | 0 | 30.1 | +| 71 | 엣지 케이스: 특수문자 검색 (판매 > 거래처관리) | ✅ | 14 | 14 | 0 | 30.1 | +| 72 | 엣지 케이스: 유니코드 입력 (게시판 > 자유게시판) | ✅ | 10 | 10 | 0 | 17.7 | +| 73 | 직원 등록 테스트 | ✅ | 21 | 21 | 0 | 9.7 | +| 74 | 폼 유효성 검증 감사: 회계 (어음/입금/출금) | ✅ | 20 | 20 | 0 | 34.4 | +| 75 | 폼 유효성 검증 감사: 생산/게시판 | ✅ | 13 | 13 | 0 | 19.3 | +| 76 | 폼 유효성 검증 감사: 판매 (거래처/수주/견적) | ✅ | 20 | 20 | 0 | 34.3 | +| 77 | 자유게시판 E2E 테스트 | ✅ | 22 | 22 | 0 | 13.6 | +| 78 | Full CRUD 테스트: 어음관리 | ✅ | 20 | 20 | 0 | 39.2 | +| 79 | Full CRUD 테스트: 입금관리 | ✅ | 20 | 20 | 0 | 38.2 | +| 80 | Full CRUD 테스트: 매출관리 | ❌ | 18 | 12 | 6 | 39.4 | +| 81 | Full CRUD 테스트: 자유게시판 | ✅ | 20 | 20 | 0 | 40.0 | +| 82 | 근태관리 테스트 | ✅ | 14 | 14 | 0 | 10.3 | +| 83 | 근태현황 테스트 | ✅ | 19 | 14 | 0 | 12.0 | +| 84 | 부서관리 테스트 | ✅ | 14 | 14 | 0 | 10.2 | +| 85 | 사원관리 테스트 | ✅ | 22 | 22 | 0 | 13.5 | +| 86 | 급여관리 테스트 | ✅ | 22 | 22 | 0 | 13.3 | +| 87 | 휴가관리 테스트 | ✅ | 25 | 19 | 0 | 19.0 | +| 88 | 입력 필드 전수 테스트: 어음/입금/출금 (1/5) | ✅ | 20 | 20 | 0 | 46.9 | +| 89 | 입력 필드 전수 테스트: 거래처(회계)/악성채권 (2/5) | ✅ | 13 | 13 | 0 | 57.3 | +| 90 | 입력 필드 전수 테스트: 입고/제품검사 (5/5) | ✅ | 13 | 13 | 0 | 27.8 | +| 91 | 입력 필드 전수 테스트: 작업지시/작업실적 (4/5) | ✅ | 13 | 13 | 0 | 15.8 | +| 92 | 입력 필드 전수 테스트: 거래처(판매)/수주/견적 (3/5) | ✅ | 20 | 20 | 0 | 34.4 | +| 93 | 재고현황 테스트 | ✅ | 12 | 12 | 0 | 14.3 | +| 94 | 품목관리 테스트 | ✅ | 16 | 11 | 0 | 20.6 | +| 95 | 품목기준관리 테스트 | ✅ | 14 | 13 | 0 | 10.7 | +| 96 | 로그인 테스트 (끝판왕) | ✅ | 24 | 22 | 0 | 11.9 | +| 97 | 입고관리 테스트 | ✅ | 25 | 19 | 0 | 17.8 | +| 98 | 재고현황 테스트 | ✅ | 19 | 16 | 0 | 10.3 | +| 99 | 다중 품목 등록 + 자동계산 + 품목삭제 재계산: 매출관리 | ❌ | 22 | 21 | 1 | 33.8 | +| 100 | 페이지네이션 & 정렬 검증: 회계 | ✅ | 17 | 17 | 0 | 33.5 | +| 101 | 페이지네이션 & 정렬 검증: 인사/게시판 | ✅ | 11 | 11 | 0 | 23.9 | +| 102 | 페이지네이션 & 정렬 검증: 판매 | ✅ | 17 | 17 | 0 | 31.5 | +| 103 | PDF 다운로드 전체 검사 | ✅ | 5 | 5 | 0 | 1.2 | +| 104 | 성능 측정: 회계관리 > 거래처관리 | ✅ | 5 | 5 | 0 | 7.2 | +| 105 | 성능 측정: 회계관리 > 입금관리 | ✅ | 5 | 5 | 0 | 7.1 | +| 106 | 성능 측정: 회계관리 > 매입관리 | ✅ | 5 | 5 | 0 | 7.1 | +| 107 | 성능 측정: 회계관리 > 매출관리 | ✅ | 5 | 5 | 0 | 7.3 | +| 108 | 성능 측정: 인사관리 > 근태관리 | ✅ | 5 | 5 | 0 | 7.1 | +| 109 | 성능 측정: 인사관리 > 부서관리 | ✅ | 5 | 5 | 0 | 7.3 | +| 110 | 성능 측정: 인사관리 > 사원관리 | ✅ | 5 | 5 | 0 | 7.1 | +| 111 | 성능 측정: 인사관리 > 급여관리 | ✅ | 5 | 5 | 0 | 7.1 | +| 112 | 성능 측정: 자재관리 > 입고관리 | ✅ | 5 | 5 | 0 | 7.2 | +| 113 | 성능 측정: 자재관리 > 재고현황 | ✅ | 5 | 5 | 0 | 7.2 | +| 114 | 성능 측정: 생산관리 > 품목관리 | ✅ | 5 | 5 | 0 | 7.2 | +| 115 | 성능 측정: 생산관리 > 작업지시 | ✅ | 5 | 5 | 0 | 7.1 | +| 116 | 성능 측정: 생산관리 > 작업실적 | ✅ | 5 | 5 | 0 | 7.2 | +| 117 | 성능 측정: 판매관리 > 거래처관리 | ✅ | 5 | 5 | 0 | 7.2 | +| 118 | 성능 측정: 판매관리 > 견적관리 | ✅ | 5 | 5 | 0 | 7.3 | +| 119 | 성능 측정: 판매관리 > 수주관리 | ✅ | 5 | 5 | 0 | 7.2 | +| 120 | 성능 측정: 판매관리 > 단가관리 | ✅ | 5 | 5 | 0 | 7.2 | +| 121 | 생산 현황판 테스트 | ✅ | 12 | 10 | 0 | 12.1 | +| 122 | 생산품목관리 테스트 | ✅ | 14 | 13 | 0 | 10.7 | +| 123 | 작업지시 관리 테스트 | ✅ | 25 | 21 | 0 | 15.0 | +| 124 | 작업실적 테스트 | ✅ | 23 | 19 | 0 | 16.8 | +| 125 | 작업자 화면 테스트 | ✅ | 14 | 13 | 0 | 10.7 | +| 126 | 품질인정심사 시스템 테스트 | ✅ | 14 | 14 | 0 | 9.6 | +| 127 | 제품검사관리 테스트 | ✅ | 25 | 19 | 0 | 17.5 | +| 128 | 입고관리 테스트 | ✅ | 9 | 9 | 0 | 11.3 | +| 129 | 참조함 E2E 테스트 | ✅ | 40 | 37 | 0 | 37.3 | +| 130 | 새로고침 데이터 유지 검증: 어음관리 | ✅ | 16 | 16 | 0 | 34.2 | +| 131 | 새로고침 데이터 유지 검증: 입금관리 | ✅ | 16 | 16 | 0 | 31.6 | +| 132 | 새로고침 데이터 유지 검증: 매출관리 | ✅ | 16 | 16 | 0 | 32.9 | +| 133 | 새로고침 데이터 유지 검증: 자유게시판 | ✅ | 16 | 16 | 0 | 33.4 | +| 134 | 판매거래처관리 테스트 | ✅ | 24 | 19 | 0 | 19.0 | +| 135 | Full CRUD 테스트: 매출관리 | ❌ | 23 | 21 | 2 | 49.3 | +| 136 | 수주관리 테스트 | ✅ | 25 | 21 | 0 | 14.2 | +| 137 | 단가관리 테스트 | ✅ | 27 | 24 | 0 | 14.5 | +| 138 | 견적관리 테스트 | ✅ | 25 | 19 | 0 | 18.0 | +| 139 | 기안함 검색 버그 상세 검증 | ✅ | 11 | 11 | 0 | 27.5 | +| 140 | 급여관리 검색 버그 상세 검증 | ✅ | 10 | 10 | 0 | 27.5 | +| 141 | 검색/필터/페이지네이션 테스트: 매출관리 | ✅ | 18 | 18 | 0 | 25.9 | +| 142 | 검색 기능 동작 검증: 회계 | ✅ | 20 | 20 | 0 | 43.5 | +| 143 | 검색 기능 감사: 회계관리 (1/6) | ✅ | 20 | 20 | 0 | 43.8 | +| 144 | 검색 기능 감사: 회계관리2+인사관리 (2/6) | ✅ | 20 | 20 | 0 | 39.2 | +| 145 | 검색 기능 감사: 게시판/고객센터/설정1 (5/6) | ✅ | 20 | 20 | 0 | 36.9 | +| 146 | 검색 기능 감사: 생산/품목/품질/자재 (3/6) | ✅ | 20 | 20 | 0 | 30.2 | +| 147 | 검색 기능 감사: 판매/출고/결재 (4/6) | ✅ | 16 | 16 | 0 | 38.0 | +| 148 | 검색 기능 감사: 설정2 (6/6) | ✅ | 14 | 14 | 0 | 24.0 | +| 149 | 검색 기능 동작 검증: 인사/게시판 | ✅ | 13 | 13 | 0 | 33.8 | +| 150 | 검색 기능 동작 검증: 판매 | ✅ | 20 | 20 | 0 | 32.2 | +| 151 | 검색 옵션 전수 테스트: 회계거래처/입금/출금 (1/10) | ✅ | 11 | 11 | 0 | 81.7 | +| 152 | 검색 옵션 전수 테스트: 매입/매출/카드내역 (2/10) | ✅ | 11 | 11 | 0 | 79.3 | +| 153 | 검색 옵션 전수 테스트: 어음/추심/계좌 (3/11) | ✅ | 11 | 11 | 0 | 95.2 | +| 154 | 검색 옵션 전수 테스트: 미수금/결제/지출예상 (4/11) | ✅ | 11 | 11 | 0 | 44.4 | +| 155 | 검색 옵션 전수 테스트: 결재관리 (6/10) | ✅ | 11 | 11 | 0 | 69.4 | +| 156 | 검색 옵션 전수 테스트: 게시판/고객센터 (5/10) | ✅ | 19 | 19 | 0 | 75.1 | +| 157 | 검색 옵션 전수 테스트: 인사관리 전체 (4/10) | ✅ | 27 | 27 | 0 | 93.2 | +| 158 | 검색 옵션 전수 테스트: 생산/품목관리 (8/11) | ✅ | 19 | 19 | 0 | 52.3 | +| 159 | 검색 옵션 전수 테스트: 품질/자재관리 (9/10) | ✅ | 15 | 15 | 0 | 65.9 | +| 160 | 검색 옵션 전수 테스트: 판매관리/출고 (7/11) | ✅ | 19 | 19 | 0 | 31.4 | +| 161 | 검색 옵션 전수 테스트: 설정 (10/11) | ✅ | 19 | 19 | 0 | 26.4 | +| 162 | 계정정보 테스트 | ✅ | 16 | 14 | 0 | 11.4 | +| 163 | 근태설정 테스트 | ✅ | 16 | 13 | 0 | 10.2 | +| 164 | 계좌관리 테스트 | ✅ | 23 | 21 | 0 | 12.3 | +| 165 | 회사정보 테스트 | ✅ | 16 | 13 | 0 | 13.1 | +| 166 | 알림설정 테스트 | ✅ | 16 | 13 | 0 | 12.5 | +| 167 | 권한관리 테스트 | ✅ | 20 | 18 | 0 | 12.6 | +| 168 | 팝업관리 테스트 | ✅ | 23 | 21 | 0 | 14.0 | +| 169 | 직책관리 테스트 | ✅ | 12 | 11 | 0 | 11.0 | +| 170 | 직급관리 테스트 | ✅ | 12 | 11 | 0 | 11.2 | +| 171 | 구독관리 테스트 | ✅ | 16 | 12 | 0 | 12.7 | +| 172 | 휴가정책 테스트 | ✅ | 16 | 15 | 0 | 8.9 | +| 173 | 근무일정 테스트 | ✅ | 16 | 15 | 0 | 9.9 | +| 174 | 출고관리 테스트 | ✅ | 13 | 11 | 0 | 18.2 | +| 175 | Test bills 14 steps | ✅ | 14 | 14 | 0 | 56.0 | +| 176 | Test bills page minimal | ✅ | 3 | 3 | 0 | 7.3 | +| 177 | 거래처원장 테스트 | ✅ | 34 | 30 | 0 | 20.8 | +| 178 | 거래처관리 테스트 | ✅ | 34 | 34 | 0 | 35.5 | +| 179 | 출금관리 테스트 | ✅ | 21 | 21 | 0 | 10.0 | +| 180 | 비즈니스 워크플로우: 게시판→결재기안→결재함 흐름 | ✅ | 15 | 15 | 0 | 21.8 | +| 181 | 비즈니스 워크플로우: 사원등록→부서→근태→급여 흐름 | ✅ | 14 | 14 | 0 | 29.8 | +| 182 | 비즈니스 워크플로우: 품목→입고→재고→출고 흐름 | ✅ | 15 | 15 | 0 | 22.8 | +| 183 | 비즈니스 워크플로우: 구매→매입 흐름 | ✅ | 7 | 7 | 0 | 18.0 | +| 184 | 비즈니스 워크플로우: 거래처→단가→수주→매출 흐름 | ✅ | 15 | 15 | 0 | 19.3 | + +## 비즈니스 워크플로우 상세 + +### ✅ 비즈니스 워크플로우: 게시판→결재기안→결재함 흐름 +- 스텝: 15/15 성공 | 소요: 21.8초 +- 단계: CAPTURE_POST(✅) → CHECK_DRAFTS(✅) → CHECK_APPROVALS(✅) → CHECK_REFERENCES(✅) + +### ✅ 비즈니스 워크플로우: 사원등록→부서→근태→급여 흐름 +- 스텝: 14/14 성공 | 소요: 29.8초 +- 단계: CAPTURE_EMPLOYEE(✅) → CHECK_DEPARTMENTS(✅) → VERIFY_EMPLOYEE_ATTEND(✅) → VERIFY_EMPLOYEE_SALARY(✅) + +### ✅ 비즈니스 워크플로우: 품목→입고→재고→출고 흐름 +- 스텝: 15/15 성공 | 소요: 22.8초 +- 단계: CAPTURE_ITEM(✅) → VERIFY_ITEM_RECEIVING(✅) → VERIFY_ITEM_STOCK(✅) → CHECK_WITHDRAWAL(✅) + +### ✅ 비즈니스 워크플로우: 구매→매입 흐름 +- 스텝: 7/7 성공 | 소요: 18.0초 +- 단계: CAPTURE_VENDOR(✅) → VERIFY_VENDOR_ACC(✅) + +### ✅ 비즈니스 워크플로우: 거래처→단가→수주→매출 흐름 +- 스텝: 15/15 성공 | 소요: 19.3초 +- 단계: CAPTURE_CLIENT(✅) → CAPTURE_PRICE_ITEM(✅) → CHECK_ORDERS(✅) → CHECK_SALES(✅) + +## 성능 테스트 요약 +| 페이지 | 로드 시간 | 등급 | API 평균 | DOM 노드 | +|--------|----------|------|---------|----------| +| 성능 측정: 회계관리 > 거래처관리 | - | - | - | - | +| 성능 측정: 회계관리 > 입금관리 | - | - | - | - | +| 성능 측정: 회계관리 > 매입관리 | - | - | - | - | +| 성능 측정: 회계관리 > 매출관리 | - | - | - | - | +| 성능 측정: 인사관리 > 근태관리 | - | - | - | - | +| 성능 측정: 인사관리 > 부서관리 | - | - | - | - | +| 성능 측정: 인사관리 > 사원관리 | - | - | - | - | +| 성능 측정: 인사관리 > 급여관리 | - | - | - | - | +| 성능 측정: 자재관리 > 입고관리 | - | - | - | - | +| 성능 측정: 자재관리 > 재고현황 | - | - | - | - | +| 성능 측정: 생산관리 > 품목관리 | - | - | - | - | +| 성능 측정: 생산관리 > 작업지시 | - | - | - | - | +| 성능 측정: 생산관리 > 작업실적 | - | - | - | - | +| 성능 측정: 판매관리 > 거래처관리 | - | - | - | - | +| 성능 측정: 판매관리 > 견적관리 | - | - | - | - | +| 성능 측정: 판매관리 > 수주관리 | - | - | - | - | +| 성능 측정: 판매관리 > 단가관리 | - | - | - | - | + +## 접근성 검사 요약 +| 페이지 | 점수 | 등급 | Critical | Serious | Moderate | +|--------|------|------|----------|---------|----------| +| 접근성 검사: 회계관리 > 거래처관리 | - | - | - | - | - | +| 접근성 검사: 회계관리 > 입금관리 | - | - | - | - | - | +| 접근성 검사: 회계관리 > 매입관리 | - | - | - | - | - | +| 접근성 검사: 회계관리 > 매출관리 | - | - | - | - | - | +| 접근성 검사: 결재관리 > 결재함 | - | - | - | - | - | +| 접근성 검사: 결재관리 > 기안함 | - | - | - | - | - | +| 접근성 검사: 게시판 > 자유게시판 | - | - | - | - | - | +| 접근성 검사: 인사관리 > 근태관리 | - | - | - | - | - | +| 접근성 검사: 인사관리 > 부서관리 | - | - | - | - | - | +| 접근성 검사: 인사관리 > 사원관리 | - | - | - | - | - | +| 접근성 검사: 인사관리 > 급여관리 | - | - | - | - | - | +| 접근성 검사: 자재관리 > 입고관리 | - | - | - | - | - | +| 접근성 검사: 자재관리 > 재고현황 | - | - | - | - | - | +| 접근성 검사: 생산관리 > 품목관리 | - | - | - | - | - | +| 접근성 검사: 생산관리 > 작업지시 | - | - | - | - | - | +| 접근성 검사: 판매관리 > 거래처관리 | - | - | - | - | - | +| 접근성 검사: 판매관리 > 견적관리 | - | - | - | - | - | +| 접근성 검사: 판매관리 > 수주관리 | - | - | - | - | - | + +## 실패 시나리오 상세 + +### ❌ 목록↔상세 필드별 대조 검증: 매출관리 (detail-verify-acc-sales) +- Step 6 ([회계관리 > 매출관리] [VERIFY] 상세 페이지 필드 1:1 대조): evaluate returned ok:false + +### ❌ Full CRUD 테스트: 매출관리 (full-crud-acc-sales) +- Step 9 ([회계관리 > 매출관리] [VERIFY] 생성 데이터 확인): evaluate returned ok:false +- Step 10 ([회계관리 > 매출관리] [READ] 상세 페이지 진입): E2E_TEST_ 행 없음 +- Step 12 ([회계관리 > 매출관리] [READ] 상세 데이터 검증 (품목/수량/단가/공급가액)): evaluate returned ok:false +- Step 13 ([회계관리 > 매출관리] [UPDATE] 수정 모드 진입 + 수량 변경 + 저장): 수정 버튼 없음 +- Step 15 ([회계관리 > 매출관리] [UPDATE] 수정 내용 검증 (공급가액 1,000,000 재계산)): evaluate returned ok:false +- Step 18 ([회계관리 > 매출관리] [DELETE] 데이터 삭제): E2E_TEST_ 행 없음 + +### ❌ 다중 품목 등록 + 자동계산 + 품목삭제 재계산: 매출관리 (multi-item-acc-sales) +- Step 20 ([회계관리 > 매출관리] [VERIFY] 목록에서 합계 확인): evaluate returned ok:false + +### ❌ Full CRUD 테스트: 매출관리 (sales-management) +- Step 10 ([회계관리 > 매출관리] [VERIFY] 생성 데이터 확인 (행수 증가 + 금액 대조)): evaluate returned ok:false +- Step 23 ([회계관리 > 매출관리] [VERIFY] 삭제 확인 (행수 원복 검증)): evaluate returned ok:false diff --git a/e2e/results/hotfix/Fail-detail-verify-acc-sales_2026-02-19_09-03-47.md b/e2e/results/hotfix/Fail-detail-verify-acc-sales_2026-02-19_09-03-47.md new file mode 100644 index 0000000..5d448ac --- /dev/null +++ b/e2e/results/hotfix/Fail-detail-verify-acc-sales_2026-02-19_09-03-47.md @@ -0,0 +1,55 @@ +# ❌ E2E 테스트 실패: 목록↔상세 필드별 대조 검증: 매출관리 + +**테스트 ID**: detail-verify-acc-sales | **실행**: 2026-02-19_09-03-47 | **결과**: FAIL +**소요 시간**: 18.4초 + +## 테스트 요약 +| 전체 | 성공 | 실패 | 경고 | 성공률 | +|------|------|------|------|--------| +| 12 | 11 | 1 | 0 | 92% | + +## 실패 스텝 +| # | 스텝 | Phase | 에러 | +|---|------|-------|------| +| 6 | [회계관리 > 매출관리] [VERIFY] 상세 페이지 필드 1:1 대조 | VERIFY | evaluate returned ok:false | + +## 전체 스텝 결과 +| # | 스텝 | Phase | 상태 | 소요시간 | 비고 | +|---|------|-------|------|---------|------| +| 1 | [회계관리 > 매출관리] 페이지 로드 대기 | - | ✅ | 1006ms | Waited 1000ms | +| 2 | [회계관리 > 매출관리] 테이블 로드 대기 | - | ✅ | 0ms | Table loaded: 20 rows | +| 3 | [회계관리 > 매출관리] [CAPTURE] 첫 행 모든 셀 값 캡처 | CAPTURE | ✅ | 502ms | CAPTURE / rows:20 | +| 4 | [회계관리 > 매출관리] [READ] 첫 행 클릭 → 상세 진입 | READ | ✅ | 2514ms | READ | +| 5 | [회계관리 > 매출관리] [READ] 상세 페이지 로드 대기 | - | ✅ | 1003ms | Waited 1000ms | +| 6 | [회계관리 > 매출관리] [VERIFY] 상세 페이지 필드 1:1 대조 | VERIFY | ❌ | 1009ms | evaluate returned ok:false | +| 7 | [회계관리 > 매출관리] [VERIFY] 세금계산서/거래명세서 Switch 상태 확인 | VERIFY | ✅ | 3ms | SWITCH_VERIFY | +| 8 | [회계관리 > 매출관리] [VERIFY] 수정 모드 진입 가능 확인 | VERIFY | ✅ | 2020ms | EDIT_ACCESS | +| 9 | [회계관리 > 매출관리] [CANCEL] 취소 클릭 | CANCEL | ✅ | 2014ms | CANCEL | +| 10 | [회계관리 > 매출관리] [CANCEL] 목록 복귀 대기 | - | ✅ | 1000ms | Waited 1000ms | +| 11 | [회계관리 > 매출관리] [VERIFY] 목록 복귀 후 테이블 확인 | VERIFY | ✅ | 513ms | BACK_VERIFY / rows:2 | +| 12 | [회계관리 > 매출관리] [VERIFY] 취소 후 데이터 무변경 확인 | VERIFY | ✅ | 503ms | NO_CHANGE_VERIFY | + +## API 요약 +| 총 호출 | 성공 | 실패 | 평균 응답 | 느린 호출(>2s) | +|---------|------|------|----------|--------------| +| 9 | 9 | 0 | 119ms | 0 | + +## 페이지 건강 검사 +| 항목 | 결과 | +|------|------| +| 상태 | ✅ 정상 | +| URL | https://dev.codebridge-x.com/accounting/sales | + +## 자동 진단 +| 항목 | 내용 | +|------|------| +| 근본 원인 | **unknown** | +| 스크린샷 | diag_detail-verify-acc-sales_2026-02-19_09-03-47.png | + +### 페이지 상태 +| 항목 | 값 | +|------|----| +| DOM 노드 | 621 | +| 테이블 행 | 2 | +| API 호출 수 | 0 | +| 로딩 스피너 | No | diff --git a/e2e/results/hotfix/Fail-full-crud-acc-sales_2026-02-19_09-13-12.md b/e2e/results/hotfix/Fail-full-crud-acc-sales_2026-02-19_09-13-12.md new file mode 100644 index 0000000..1e8cc5f --- /dev/null +++ b/e2e/results/hotfix/Fail-full-crud-acc-sales_2026-02-19_09-13-12.md @@ -0,0 +1,66 @@ +# ❌ E2E 테스트 실패: Full CRUD 테스트: 매출관리 + +**테스트 ID**: full-crud-acc-sales | **실행**: 2026-02-19_09-13-12 | **결과**: FAIL +**소요 시간**: 39.4초 | **중단 사유**: critical_failure + +## 테스트 요약 +| 전체 | 성공 | 실패 | 경고 | 성공률 | +|------|------|------|------|--------| +| 18 | 12 | 6 | 0 | 67% | + +## 실패 스텝 +| # | 스텝 | Phase | 에러 | +|---|------|-------|------| +| 9 | [회계관리 > 매출관리] [VERIFY] 생성 데이터 확인 | VERIFY | evaluate returned ok:false | +| 10 | [회계관리 > 매출관리] [READ] 상세 페이지 진입 | READ | E2E_TEST_ 행 없음 | +| 12 | [회계관리 > 매출관리] [READ] 상세 데이터 검증 (품목/수량/단가/공급가액) | READ | evaluate returned ok:false | +| 13 | [회계관리 > 매출관리] [UPDATE] 수정 모드 진입 + 수량 변경 + 저장 | UPDATE | 수정 버튼 없음 | +| 15 | [회계관리 > 매출관리] [UPDATE] 수정 내용 검증 (공급가액 1,000,000 재계산) | UPDATE | evaluate returned ok:false | +| 18 | [회계관리 > 매출관리] [DELETE] 데이터 삭제 | DELETE | E2E_TEST_ 행 없음 | + +## 전체 스텝 결과 +| # | 스텝 | Phase | 상태 | 소요시간 | 비고 | +|---|------|-------|------|---------|------| +| 1 | [회계관리 > 매출관리] 페이지 로드 대기 | - | ✅ | 1002ms | Waited 1000ms | +| 2 | [회계관리 > 매출관리] 테이블 로드 대기 | - | ✅ | 2ms | Table loaded: 20 rows | +| 3 | [회계관리 > 매출관리] [CREATE] 매출 등록 버튼 클릭 | CREATE | ✅ | 2512ms | CREATE_OPEN | +| 4 | [회계관리 > 매출관리] [CREATE] 등록 폼 로드 대기 | - | ✅ | 1000ms | Waited 1000ms | +| 5 | [회계관리 > 매출관리] [CREATE] 거래처 선택 + 매출유형 + 품목 입력 + 등록 | CREATE | ✅ | 6634ms | CREATE | +| 6 | [회계관리 > 매출관리] [CREATE] 생성 후 대기 | - | ✅ | 1003ms | Waited 1000ms | +| 7 | [회계관리 > 매출관리] [CREATE] 목록 복귀 | CREATE | ✅ | 0ms | evaluate ok | +| 8 | [회계관리 > 매출관리] [CREATE] 목록 안정화 대기 | - | ✅ | 1011ms | Waited 1000ms | +| 9 | [회계관리 > 매출관리] [VERIFY] 생성 데이터 확인 | VERIFY | ❌ | 2526ms | evaluate returned ok:false | +| 10 | [회계관리 > 매출관리] [READ] 상세 페이지 진입 | READ | ❌ | 10055ms | E2E_TEST_ 행 없음 | +| 11 | [회계관리 > 매출관리] [READ] 상세 페이지 대기 | - | ✅ | 1003ms | Waited 1000ms | +| 12 | [회계관리 > 매출관리] [READ] 상세 데이터 검증 (품목/수량/단가/공급가액) | READ | ❌ | 1017ms | evaluate returned ok:false | +| 13 | [회계관리 > 매출관리] [UPDATE] 수정 모드 진입 + 수량 변경 + 저장 | UPDATE | ❌ | 1014ms | 수정 버튼 없음 | +| 14 | [회계관리 > 매출관리] [UPDATE] 저장 후 대기 | - | ✅ | 1001ms | Waited 1000ms | +| 15 | [회계관리 > 매출관리] [UPDATE] 수정 내용 검증 (공급가액 1,000,000 재계산) | UPDATE | ❌ | 1018ms | evaluate returned ok:false | +| 16 | [회계관리 > 매출관리] [UPDATE] 목록 복귀 | UPDATE | ✅ | 1ms | evaluate ok | +| 17 | [회계관리 > 매출관리] [UPDATE] 목록 안정화 대기 | - | ✅ | 1000ms | Waited 1000ms | +| 18 | [회계관리 > 매출관리] [DELETE] 데이터 삭제 | DELETE | ❌ | 1012ms | E2E_TEST_ 행 없음 | + +## API 요약 +| 총 호출 | 성공 | 실패 | 평균 응답 | 느린 호출(>2s) | +|---------|------|------|----------|--------------| +| 9 | 9 | 0 | 146ms | 0 | + +## 페이지 건강 검사 +| 항목 | 결과 | +|------|------| +| 상태 | ✅ 정상 | +| URL | https://dev.codebridge-x.com/accounting/sales | + +## 자동 진단 +| 항목 | 내용 | +|------|------| +| 근본 원인 | **unknown** | +| 스크린샷 | diag_full-crud-acc-sales_2026-02-19_09-13-11.png | + +### 페이지 상태 +| 항목 | 값 | +|------|----| +| DOM 노드 | 1533 | +| 테이블 행 | 20 | +| API 호출 수 | 0 | +| 로딩 스피너 | No | diff --git a/e2e/results/hotfix/Fail-multi-item-acc-sales_2026-02-19_09-20-42.md b/e2e/results/hotfix/Fail-multi-item-acc-sales_2026-02-19_09-20-42.md new file mode 100644 index 0000000..43d9c72 --- /dev/null +++ b/e2e/results/hotfix/Fail-multi-item-acc-sales_2026-02-19_09-20-42.md @@ -0,0 +1,65 @@ +# ❌ E2E 테스트 실패: 다중 품목 등록 + 자동계산 + 품목삭제 재계산: 매출관리 + +**테스트 ID**: multi-item-acc-sales | **실행**: 2026-02-19_09-20-42 | **결과**: FAIL +**소요 시간**: 33.8초 + +## 테스트 요약 +| 전체 | 성공 | 실패 | 경고 | 성공률 | +|------|------|------|------|--------| +| 22 | 21 | 1 | 0 | 95% | + +## 실패 스텝 +| # | 스텝 | Phase | 에러 | +|---|------|-------|------| +| 20 | [회계관리 > 매출관리] [VERIFY] 목록에서 합계 확인 | VERIFY | evaluate returned ok:false | + +## 전체 스텝 결과 +| # | 스텝 | Phase | 상태 | 소요시간 | 비고 | +|---|------|-------|------|---------|------| +| 1 | [회계관리 > 매출관리] 페이지 로드 대기 | - | ✅ | 1011ms | Waited 1000ms | +| 2 | [회계관리 > 매출관리] 테이블 로드 대기 | - | ✅ | 1ms | Table loaded: 20 rows | +| 3 | [회계관리 > 매출관리] [CREATE] 매출 등록 버튼 클릭 | CREATE | ✅ | 2516ms | CREATE_OPEN | +| 4 | [회계관리 > 매출관리] [CREATE] 등록 폼 로드 대기 | - | ✅ | 1010ms | Waited 1000ms | +| 5 | [회계관리 > 매출관리] [CREATE] 기본정보 입력 (거래처+매출유형) | CREATE | ✅ | 2625ms | BASIC_INFO | +| 6 | [회계관리 > 매출관리] [ITEM-A] 품목A 입력: 수량=3, 단가=10,000 | CREATE | ✅ | 732ms | ITEM_A | +| 7 | [회계관리 > 매출관리] [ITEM-A] 공급가액 30,000 확인 | VERIFY | ✅ | 511ms | VERIFY_ITEM_A / ⚠️ 공급가액 30,000 미감지 | +| 8 | [회계관리 > 매출관리] [ITEM-B] 품목 추가 버튼(+) 클릭 | CREATE | ✅ | 1003ms | ADD_ITEM_B | +| 9 | [회계관리 > 매출관리] [ITEM-B] 품목B 입력: 수량=5, 단가=20,000 | CREATE | ✅ | 754ms | ITEM_B | +| 10 | [회계관리 > 매출관리] [ITEM-B] 공급가액 100,000 확인 | VERIFY | ✅ | 1ms | VERIFY_ITEM_B / ⚠️ 공급가액 100,000 미감지 | +| 11 | [회계관리 > 매출관리] [ITEM-C] 품목 추가 버튼(+) 클릭 | CREATE | ✅ | 1003ms | ADD_ITEM_C | +| 12 | [회계관리 > 매출관리] [ITEM-C] 품목C 입력: 수량=1, 단가=50,000 | CREATE | ✅ | 728ms | ITEM_C | +| 13 | [회계관리 > 매출관리] [TOTAL-3] 3품목 합계 검증: 공급=180,000 부가세=18,000 합계=198,000 | VERIFY | ✅ | 512ms | TOTAL_3_ITEMS / ⚠️ 공급 180,000 미감지 / ⚠️ 부가세 18,000 미감지 / ⚠️ 합계 198,000 미감지 | +| 14 | [회계관리 > 매출관리] [DELETE-B] 품목B 삭제 | CREATE | ✅ | 2020ms | DELETE_ITEM_B | +| 15 | [회계관리 > 매출관리] [DELETE-B] 품목삭제 후 대기 | - | ✅ | 1012ms | Waited 1000ms | +| 16 | [회계관리 > 매출관리] [TOTAL-2] 재계산 검증: 공급=80,000 부가세=8,000 합계=88,000 | VERIFY | ✅ | 508ms | TOTAL_2_ITEMS / ⚠️ 공급 80,000 미감지 / ✅ 부가세 8,000 / ⚠️ 합계 88,000 미감지 | +| 17 | [회계관리 > 매출관리] [SUBMIT] 등록 클릭 | CREATE | ✅ | 3016ms | SUBMIT | +| 18 | [회계관리 > 매출관리] [SUBMIT] 등록 후 대기 + 목록 복귀 | CREATE | ✅ | 2006ms | evaluate ok | +| 19 | [회계관리 > 매출관리] [SUBMIT] 목록 안정화 대기 | - | ✅ | 1010ms | Waited 1000ms | +| 20 | [회계관리 > 매출관리] [VERIFY] 목록에서 합계 확인 | VERIFY | ❌ | 2551ms | evaluate returned ok:false | +| 21 | [회계관리 > 매출관리] [CLEANUP] 테스트 데이터 삭제 | DELETE | ✅ | 1ms | CLEANUP / E2E_TEST_ 행 없음 - 삭제 스킵 | +| 22 | [회계관리 > 매출관리] [CLEANUP] 삭제 확인 | VERIFY | ✅ | 3013ms | VERIFY_CLEANUP | + +## API 요약 +| 총 호출 | 성공 | 실패 | 평균 응답 | 느린 호출(>2s) | +|---------|------|------|----------|--------------| +| 8 | 8 | 0 | 84ms | 0 | + +## 페이지 건강 검사 +| 항목 | 결과 | +|------|------| +| 상태 | ✅ 정상 | +| URL | https://dev.codebridge-x.com/accounting/sales | + +## 자동 진단 +| 항목 | 내용 | +|------|------| +| 근본 원인 | **unknown** | +| 스크린샷 | diag_multi-item-acc-sales_2026-02-19_09-20-42.png | + +### 페이지 상태 +| 항목 | 값 | +|------|----| +| DOM 노드 | 1356 | +| 테이블 행 | 24 | +| API 호출 수 | 0 | +| 로딩 스피너 | No | diff --git a/e2e/results/hotfix/Fail-sales-management_2026-02-19_09-29-56.md b/e2e/results/hotfix/Fail-sales-management_2026-02-19_09-29-56.md new file mode 100644 index 0000000..debc66b --- /dev/null +++ b/e2e/results/hotfix/Fail-sales-management_2026-02-19_09-29-56.md @@ -0,0 +1,67 @@ +# ❌ E2E 테스트 실패: Full CRUD 테스트: 매출관리 + +**테스트 ID**: sales-management | **실행**: 2026-02-19_09-29-56 | **결과**: FAIL +**소요 시간**: 49.3초 + +## 테스트 요약 +| 전체 | 성공 | 실패 | 경고 | 성공률 | +|------|------|------|------|--------| +| 23 | 21 | 2 | 0 | 91% | + +## 실패 스텝 +| # | 스텝 | Phase | 에러 | +|---|------|-------|------| +| 10 | [회계관리 > 매출관리] [VERIFY] 생성 데이터 확인 (행수 증가 + 금액 대조) | VERIFY | evaluate returned ok:false | +| 23 | [회계관리 > 매출관리] [VERIFY] 삭제 확인 (행수 원복 검증) | VERIFY | evaluate returned ok:false | + +## 전체 스텝 결과 +| # | 스텝 | Phase | 상태 | 소요시간 | 비고 | +|---|------|-------|------|---------|------| +| 1 | [회계관리 > 매출관리] 페이지 로드 대기 | - | ✅ | 1006ms | Waited 1000ms | +| 2 | [회계관리 > 매출관리] 테이블 로드 대기 | - | ✅ | 1ms | Table loaded: 20 rows | +| 3 | [회계관리 > 매출관리] [INSPECT] UI 구조 검증 + 초기 행수 저장 | INSPECT | ✅ | 3ms | INSPECT / rows:20,cols:10 / rows:20 | +| 4 | [회계관리 > 매출관리] [CREATE] 매출 등록 버튼 클릭 | CREATE | ✅ | 320ms | Clicked button: 등록 | +| 5 | [회계관리 > 매출관리] [CREATE] 등록 폼 로드 대기 | - | ✅ | 1013ms | Waited 1000ms | +| 6 | [회계관리 > 매출관리] [CREATE] 거래처+매출유형+품목 입력 + 자동계산 검증 + 등록 | CREATE | ✅ | 10094ms | CREATE | +| 7 | [회계관리 > 매출관리] [CREATE] 등록 후 대기 | - | ✅ | 1003ms | Waited 1000ms | +| 8 | [회계관리 > 매출관리] [CREATE] 목록 복귀 | CREATE | ✅ | 1ms | evaluate ok | +| 9 | [회계관리 > 매출관리] [CREATE] 목록 안정화 대기 | - | ✅ | 1002ms | Waited 1000ms | +| 10 | [회계관리 > 매출관리] [VERIFY] 생성 데이터 확인 (행수 증가 + 금액 대조) | VERIFY | ❌ | 2534ms | evaluate returned ok:false | +| 11 | [회계관리 > 매출관리] [READ] 첫 행 클릭 → 상세 페이지 진입 | READ | ✅ | 2513ms | READ | +| 12 | [회계관리 > 매출관리] [READ] 상세 페이지 대기 | - | ✅ | 1003ms | Waited 1000ms | +| 13 | [회계관리 > 매출관리] [READ] 상세 데이터 검증 (E2E_TEST_ 품목명/적요/금액) | READ | ✅ | 2ms | READ_VERIFY | +| 14 | [회계관리 > 매출관리] [UPDATE] 수정 모드 진입 + 수량 변경(10→20) + 재계산 검증 + 저장 | UPDATE | ✅ | 7644ms | UPDATE | +| 15 | [회계관리 > 매출관리] [UPDATE] 저장 후 대기 | - | ✅ | 1003ms | Waited 1000ms | +| 16 | [회계관리 > 매출관리] [UPDATE] 수정 내용 검증 (공급가액 1,000,000 재계산 확인) | UPDATE | ✅ | 2ms | VERIFY_UPDATE | +| 17 | [회계관리 > 매출관리] [UPDATE] 목록 복귀 | UPDATE | ✅ | 1ms | evaluate ok | +| 18 | [회계관리 > 매출관리] [UPDATE] 목록 안정화 대기 | - | ✅ | 1001ms | Waited 1000ms | +| 19 | [회계관리 > 매출관리] [DELETE] 데이터 삭제 (첫 행 → 상세 → 삭제 → 확인) | DELETE | ✅ | 6532ms | DELETE | +| 20 | [회계관리 > 매출관리] [DELETE] 삭제 후 대기 | - | ✅ | 1015ms | Waited 1000ms | +| 21 | [회계관리 > 매출관리] [DELETE] 목록 복귀 | DELETE | ✅ | 1ms | evaluate ok | +| 22 | [회계관리 > 매출관리] [DELETE] 목록 안정화 대기 | - | ✅ | 1016ms | Waited 1000ms | +| 23 | [회계관리 > 매출관리] [VERIFY] 삭제 확인 (행수 원복 검증) | VERIFY | ❌ | 4051ms | evaluate returned ok:false | + +## API 요약 +| 총 호출 | 성공 | 실패 | 평균 응답 | 느린 호출(>2s) | +|---------|------|------|----------|--------------| +| 27 | 27 | 0 | 70ms | 0 | + +## 페이지 건강 검사 +| 항목 | 결과 | +|------|------| +| 상태 | ✅ 정상 | +| URL | https://dev.codebridge-x.com/accounting/sales | + +## 자동 진단 +| 항목 | 내용 | +|------|------| +| 근본 원인 | **unknown** | +| 스크린샷 | diag_sales-management_2026-02-19_09-29-56.png | + +### 페이지 상태 +| 항목 | 값 | +|------|----| +| DOM 노드 | 1290 | +| 테이블 행 | 24 | +| API 호출 수 | 0 | +| 로딩 스피너 | No | diff --git a/e2e/runner/run-all.js b/e2e/runner/run-all.js index c90b681..41eb63c 100644 --- a/e2e/runner/run-all.js +++ b/e2e/runner/run-all.js @@ -8,8 +8,10 @@ * Usage: * node e2e/runner/run-all.js # 전체 실행 * node e2e/runner/run-all.js --filter board # 파일명 필터 + * node e2e/runner/run-all.js --workflow # 워크플로우만 실행 * node e2e/runner/run-all.js --headed # headed (기본값) * node e2e/runner/run-all.js --headless # headless + * node e2e/runner/run-all.js --exclude sales # 파일명에 "sales" 포함된 것 제외 */ const fs = require('fs'); @@ -33,10 +35,15 @@ const DASHBOARD_URL = `${BASE_URL}/dashboard`; // CLI args const args = process.argv.slice(2); const HEADLESS = args.includes('--headless'); +const WORKFLOW_ONLY = args.includes('--workflow'); const FILTER = (() => { const idx = args.indexOf('--filter'); return idx >= 0 && args[idx + 1] ? args[idx + 1] : null; })(); +const EXCLUDE = (() => { + const idx = args.indexOf('--exclude'); + return idx >= 0 && args[idx + 1] ? args[idx + 1] : null; +})(); // ─── Helpers ──────────────────────────────────────────────── @@ -78,58 +85,37 @@ async function injectExecutor(page) { // ─── Menu Navigation ──────────────────────────────────────── -async function navigateViaMenu(page, level1, level2) { - // Wait for sidebar to be present AND have rendered menu items +/** + * Wait for sidebar to render with clickable menu items. + * Returns true if sidebar is ready, false otherwise. + */ +async function waitForSidebarReady(page, timeout = 8000) { try { await page.waitForFunction( () => { const sidebar = document.querySelector('.sidebar-scroll, [data-sidebar="content"]'); if (!sidebar) return false; const items = sidebar.querySelectorAll('a, button, [role="button"], [role="menuitem"]'); - return Array.from(items).filter(el => (el.textContent || '').trim().length > 1).length >= 3; + return Array.from(items).filter(el => { + const t = (el.innerText || '').trim(); + return t.length > 1 && t.length < 30; + }).length >= 3; }, null, - { timeout: 8000 } + { timeout } ); + return true; } catch (e) { - // Try reloading the page - console.log(C.yellow(` [DEBUG] sidebar check failed, reloading. URL: ${page.url()}`)); - try { - await page.reload({ waitUntil: 'load', timeout: 10000 }); - await sleep(2000); - await page.waitForFunction( - () => { - const sidebar = document.querySelector('.sidebar-scroll, [data-sidebar="content"]'); - if (!sidebar) return false; - const items = sidebar.querySelectorAll('a, button, [role="button"], [role="menuitem"]'); - return Array.from(items).filter(el => (el.textContent || '').trim().length > 1).length >= 3; - }, - null, - { timeout: 6000 } - ); - } catch (e2) { - console.log(C.red(` [DEBUG] sidebar still not rendered after reload! URL: ${page.url()}`)); - return false; - } + return false; } +} - // Collapse all open menus first to ensure clean state - await page.evaluate(() => { - const collapseBtn = Array.from(document.querySelectorAll('button, [role="button"]')) - .find(el => el.innerText?.trim() === '모두 접기'); - if (collapseBtn) collapseBtn.click(); - }); - await sleep(300); - - // Scroll sidebar to top first - await page.evaluate(() => { - const sidebar = document.querySelector('.sidebar-scroll, [data-sidebar="content"], nav'); - if (sidebar) sidebar.scrollTo({ top: 0, behavior: 'instant' }); - }); - await sleep(300); - - // Ensure sidebar is expanded (not icon-only collapsed mode) - const sidebarExpanded = await page.evaluate(() => { +/** + * Ensure sidebar is expanded (not icon-only mode). + * Checks localStorage and reloads from Node.js side if needed. + */ +async function ensureSidebarExpanded(page) { + const needsReload = await page.evaluate(() => { try { const raw = localStorage.getItem('sam-menu'); if (raw) { @@ -137,74 +123,98 @@ async function navigateViaMenu(page, level1, level2) { if (data.state && data.state.sidebarCollapsed) { data.state.sidebarCollapsed = false; localStorage.setItem('sam-menu', JSON.stringify(data)); - return false; // was collapsed, need reload + return true; // needs reload to apply } } } catch (e) {} - return true; // already expanded + return false; }); - if (!sidebarExpanded) { - await page.reload({ waitUntil: 'load', timeout: 10000 }); + + if (needsReload) { + await page.reload({ waitUntil: 'load', timeout: 12000 }); await sleep(1500); } +} - // Wait specifically for the target L1 menu item to exist in DOM (textContent for collapsed text) - let l1Ready = false; - try { - l1Ready = await page.waitForFunction( - (l1Text) => { - const items = document.querySelectorAll('a, button, [role="button"], [role="menuitem"], [role="treeitem"]'); - return Array.from(items).some(el => { - const text = (el.textContent || el.innerText || '').trim(); - return text && (text === l1Text || text.startsWith(l1Text)); - }); - }, - level1, - { timeout: 8000 } - ); - } catch (e) { - // L1 target not found after waiting - collect debug info and fail - const debugInfo = await page.evaluate(() => { - const sidebar = document.querySelector('.sidebar-scroll, [data-sidebar="content"], nav'); - const items = document.querySelectorAll('a, button, [role="button"], [role="menuitem"], [role="treeitem"]'); - const texts = []; - items.forEach(el => { - const t = (el.textContent || el.innerText || '').trim(); - if (t && t.length > 1 && t.length < 25 && !texts.includes(t)) texts.push(t); - }); - return { hasSidebar: !!sidebar, url: window.location.href, menuTexts: texts.slice(0, 25) }; - }); - console.log(C.red(` [NAV-FAIL] L1 "${level1}" not found after wait. URL: ${debugInfo.url}, sidebar: ${debugInfo.hasSidebar}`)); - console.log(C.dim(` [NAV-FAIL] Visible items: ${debugInfo.menuTexts.join(', ')}`)); - return false; +async function navigateViaMenu(page, level1, level2) { + // Phase 0: Ensure sidebar is rendered and expanded + let sidebarReady = await waitForSidebarReady(page, 6000); + if (!sidebarReady) { + console.log(C.yellow(` [NAV] sidebar not ready, reloading...`)); + try { + await page.reload({ waitUntil: 'load', timeout: 12000 }); + await sleep(2000); + } catch (e) { /* ignore */ } + sidebarReady = await waitForSidebarReady(page, 8000); + if (!sidebarReady) { + console.log(C.red(` [NAV] sidebar still not rendered after reload! URL: ${page.url()}`)); + return false; + } } - // Find and click level1 menu with scroll-based search + await ensureSidebarExpanded(page); + + // Phase 1: Collapse all open accordions, scroll to top + await page.evaluate(() => { + const collapseBtn = Array.from(document.querySelectorAll('button, [role="button"]')) + .find(el => el.innerText?.trim() === '모두 접기'); + if (collapseBtn) collapseBtn.click(); + }); + await sleep(400); + + await page.evaluate(() => { + const sidebar = document.querySelector('.sidebar-scroll, [data-sidebar="content"], nav'); + if (sidebar) sidebar.scrollTo({ top: 0, behavior: 'instant' }); + }); + await sleep(300); + + // Phase 2: Find and click L1 menu (accordion header) + // Use innerText for accurate visible-text matching (textContent includes hidden child text) const l1Found = await page.evaluate( async ({ l1Text }) => { const sidebar = document.querySelector('.sidebar-scroll, [data-sidebar="content"], nav'); - const maxAttempts = 20; + const maxScrollAttempts = 25; - for (let i = 0; i < maxAttempts; i++) { - const items = Array.from( - document.querySelectorAll('a, button, [role="button"], [role="menuitem"], [role="treeitem"]') + for (let i = 0; i < maxScrollAttempts; i++) { + // Collect only direct menu buttons/links (not their children) + const candidates = Array.from( + document.querySelectorAll( + '[data-sidebar="content"] > * > * > button, ' + + '[data-sidebar="content"] > * > * > a, ' + + '.sidebar-scroll button, .sidebar-scroll a, ' + + 'nav button, nav a, ' + + '[role="menuitem"], [role="treeitem"]' + ) ); - const match = items.find((el) => { - const text = (el.textContent || el.innerText || '').trim(); - return text && (text === l1Text || text.startsWith(l1Text)); - }); + for (const el of candidates) { + // Use innerText for accurate match (excludes hidden sub-menus) + const elText = (el.innerText || '').trim(); + // Also check only the first line (in case submenu text is appended) + const firstLine = elText.split('\n')[0].trim(); - if (match) { - match.scrollIntoView({ behavior: 'instant', block: 'center' }); - await new Promise((r) => setTimeout(r, 100)); - match.click(); - return { found: true, attempt: i }; + if (firstLine === l1Text || elText === l1Text) { + el.scrollIntoView({ behavior: 'instant', block: 'center' }); + await new Promise(r => setTimeout(r, 150)); + el.click(); + return { found: true, text: firstLine, attempt: i }; + } + } + + // Fallback: startsWith match (e.g., "회계관리 14" badge suffix) + for (const el of candidates) { + const firstLine = (el.innerText || '').split('\n')[0].trim(); + if (firstLine.startsWith(l1Text) && firstLine.length < l1Text.length + 10) { + el.scrollIntoView({ behavior: 'instant', block: 'center' }); + await new Promise(r => setTimeout(r, 150)); + el.click(); + return { found: true, text: firstLine, attempt: i, partial: true }; + } } if (sidebar) { - sidebar.scrollBy({ top: 150, behavior: 'instant' }); - await new Promise((r) => setTimeout(r, 100)); + sidebar.scrollBy({ top: 120, behavior: 'instant' }); + await new Promise(r => setTimeout(r, 120)); } } return { found: false }; @@ -213,12 +223,57 @@ async function navigateViaMenu(page, level1, level2) { ); if (!l1Found.found) { + // Collect debug info before returning + const debugTexts = await page.evaluate(() => { + const items = document.querySelectorAll('nav a, nav button, [role="menuitem"], [role="treeitem"]'); + return Array.from(items) + .map(el => (el.innerText || '').split('\n')[0].trim()) + .filter(t => t.length > 1 && t.length < 25) + .filter((t, i, arr) => arr.indexOf(t) === i) + .slice(0, 20); + }); + console.log(C.red(` [NAV] L1 "${level1}" not found.`)); + console.log(C.dim(` [NAV] Available: ${debugTexts.join(', ')}`)); return false; } - await sleep(500); // Wait for submenu to expand + await sleep(800); // Wait for accordion animation to expand - // Find and click level2 menu with scroll-based search + // Phase 3: Verify accordion expanded (L2 items should now be visible) if (level2) { + // Wait for L2 items to appear after accordion expansion + let l2Visible = false; + for (let retryWait = 0; retryWait < 3; retryWait++) { + l2Visible = await page.evaluate( + (l2Text) => { + const items = document.querySelectorAll('a, button, [role="menuitem"], [role="treeitem"]'); + return Array.from(items).some(el => { + const t = (el.innerText || '').trim(); + return t === l2Text || t.includes(l2Text); + }); + }, + level2 + ); + if (l2Visible) break; + // Accordion might not have expanded - try clicking L1 again + if (retryWait === 1) { + await page.evaluate( + async ({ l1Text }) => { + const candidates = document.querySelectorAll('nav button, nav a, [role="menuitem"], [role="treeitem"]'); + for (const el of candidates) { + const firstLine = (el.innerText || '').split('\n')[0].trim(); + if (firstLine === l1Text || firstLine.startsWith(l1Text)) { + el.click(); + break; + } + } + }, + { l1Text: level1 } + ); + } + await sleep(600); + } + + // Phase 4: Find and click L2 menu item const l2Found = await page.evaluate( async ({ l2Text }) => { const sidebar = document.querySelector('.sidebar-scroll, [data-sidebar="content"], nav'); @@ -226,28 +281,30 @@ async function navigateViaMenu(page, level1, level2) { for (let i = 0; i < maxAttempts; i++) { const items = Array.from( - document.querySelectorAll('a, button, [role="button"], [role="menuitem"], [role="treeitem"]') + document.querySelectorAll('a, button, [role="menuitem"], [role="treeitem"]') ); - // Try exact match first (textContent for collapsed sidebar support) - let match = items.find((el) => (el.textContent || el.innerText || '').trim() === l2Text); + // Exact match on innerText (most reliable) + let match = items.find(el => (el.innerText || '').trim() === l2Text); - // Try partial match if exact match fails + // Partial match fallback if (!match) { - match = items.find((el) => (el.textContent || el.innerText || '').trim().includes(l2Text)); + match = items.find(el => { + const t = (el.innerText || '').trim(); + return t.includes(l2Text) && t.length < l2Text.length + 15; + }); } if (match) { match.scrollIntoView({ behavior: 'instant', block: 'center' }); - await new Promise((r) => setTimeout(r, 100)); + await new Promise(r => setTimeout(r, 150)); match.click(); return { found: true }; } - // Scroll down a bit to find more items if (sidebar) { sidebar.scrollBy({ top: 100, behavior: 'instant' }); - await new Promise((r) => setTimeout(r, 100)); + await new Promise(r => setTimeout(r, 120)); } } return { found: false }; @@ -255,13 +312,40 @@ async function navigateViaMenu(page, level1, level2) { { l2Text: level2 } ); - if (!l2Found.found) return false; - await sleep(2000); // Wait for page load + if (!l2Found.found) { + console.log(C.red(` [NAV] L2 "${level2}" not found under "${level1}".`)); + return false; + } + await sleep(2000); // Wait for page load after L2 click } return true; } +/** + * navigateViaMenu with automatic retry (up to 2 attempts). + */ +async function navigateViaMenuWithRetry(page, level1, level2, maxRetries = 2) { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + const ok = await navigateViaMenu(page, level1, level2); + if (ok) return true; + + if (attempt < maxRetries) { + console.log(C.yellow(` [NAV] Retry ${attempt}/${maxRetries - 1} for ${level1} > ${level2}`)); + // Go back to dashboard and try again + try { + await page.goto(DASHBOARD_URL, { waitUntil: 'load', timeout: 12000 }); + await sleep(1500); + await ensureSidebarExpanded(page); + await waitForSidebarReady(page, 6000); + } catch (e) { + await sleep(2000); + } + } + } + return false; +} + // ─── Dashboard Navigation ─────────────────────────────────── async function ensureLoggedIn(page) { @@ -276,43 +360,10 @@ async function ensureLoggedIn(page) { } async function goToDashboard(page) { - // Helper: expand sidebar if collapsed, then check menu items rendered - const waitForSidebar = async (timeout = 8000) => { - // Force sidebar expanded via sam-menu localStorage - await page.evaluate(() => { - try { - const raw = localStorage.getItem('sam-menu'); - if (raw) { - const data = JSON.parse(raw); - if (data.state && data.state.sidebarCollapsed) { - data.state.sidebarCollapsed = false; - localStorage.setItem('sam-menu', JSON.stringify(data)); - // Reload needed to apply the change - window.location.reload(); - } - } - } catch (e) {} - }); - await sleep(1500); - - // Wait for sidebar menu items to have visible text (innerText works when expanded) - await page.waitForFunction(() => { - const sidebar = document.querySelector('.sidebar-scroll, [data-sidebar="content"]'); - if (!sidebar) return false; - const items = sidebar.querySelectorAll('a, button, [role="button"], [role="menuitem"]'); - const withText = Array.from(items).filter(el => { - const t = (el.innerText || el.textContent || '').trim(); - return t.length > 1; - }); - return withText.length >= 5; - }, null, { timeout }); - }; - // Force sidebar expanded state BEFORE navigation so page loads with full menu try { await page.evaluate(() => { try { - // The sidebar state is stored in 'sam-menu' localStorage key const raw = localStorage.getItem('sam-menu'); if (raw) { const data = JSON.parse(raw); @@ -330,7 +381,8 @@ async function goToDashboard(page) { await page.goto(DASHBOARD_URL, { waitUntil: 'load', timeout: 15000 }); await sleep(1000); await ensureLoggedIn(page); - await waitForSidebar(); + await ensureSidebarExpanded(page); + await waitForSidebarReady(page, 8000); return; } catch (e) { const dbgUrl = page.url(); @@ -342,7 +394,8 @@ async function goToDashboard(page) { await page.reload({ waitUntil: 'load', timeout: 10000 }); await sleep(1500); await ensureLoggedIn(page); - await waitForSidebar(); + await ensureSidebarExpanded(page); + await waitForSidebarReady(page, 8000); return; } catch (e) { const dbgUrl = page.url(); @@ -359,13 +412,321 @@ async function goToDashboard(page) { await page.click("button[type='submit']"); await sleep(3000); } catch (loginErr) { /* may already be on dashboard */ } - await waitForSidebar(); + await ensureSidebarExpanded(page); + await waitForSidebarReady(page, 8000); } catch (e) { await sleep(2000); } } -const SCENARIO_TIMEOUT = 120000; // 2 minutes per scenario +const SCENARIO_TIMEOUT = 180000; // 3 minutes per scenario (batch-create needs extra time) +const WORKFLOW_TIMEOUT = 300000; // 5 minutes for workflow scenarios (multi-module chains) +const PERFORMANCE_TIMEOUT = 120000; // 2 minutes for performance scenarios + +/** Determine timeout based on scenario category */ +function getScenarioTimeout(filename) { + if (filename.startsWith('workflow-')) return WORKFLOW_TIMEOUT; + if (filename.startsWith('perf-')) return PERFORMANCE_TIMEOUT; + return SCENARIO_TIMEOUT; +} + +/** Classify scenario into a category for summary grouping */ +function getScenarioCategory(filename) { + if (filename.startsWith('workflow-')) return 'workflow'; + if (filename.startsWith('perf-')) return 'performance'; + if (filename.startsWith('edge-')) return 'edge-case'; + if (filename.startsWith('a11y-')) return 'accessibility'; + return 'functional'; +} + +// ─── Page Health Verify ───────────────────────────────────── + +/** + * Pre-flight page health check. + * Runs AFTER menu navigation, BEFORE step execution. + * Detects page crashes, console errors, Error Boundaries, API failures. + * + * Returns: { healthy: boolean, diagnosis: { ... } } + */ +async function verifyPageHealth(page, timeout = 8000) { + const diagnosis = { + healthy: true, + url: '', + crashed: false, + errorBoundary: null, + consoleErrors: [], + apiErrors: [], + emptySelectValues: 0, + blankPage: false, + loadTimeout: false, + }; + + try { + diagnosis.url = page.url(); + + // 1. Collect console errors captured during page load + // (we attach listener before navigation in runScenario, store them on page object) + diagnosis.consoleErrors = (page.__e2e_console_errors || []).slice(-10); + + // 2. Check for Error Boundary / crash screen / blank page + const pageState = await Promise.race([ + page.evaluate(() => { + const bodyText = document.body?.innerText || ''; + + // Error Boundary patterns (React) + // NOTE: bodyText(innerText)만 사용. innerHTML은 i18n 번역 JSON에 + // "서버 오류가 발생했습니다" 등이 포함되어 false positive 발생함 + const errorBoundaryPatterns = [ + '오류가 발생했습니다', '일시적인 오류', + 'Something went wrong', 'Error boundary', + 'An error occurred', 'Unhandled Runtime Error', + 'Application error', + ]; + const foundErrorText = errorBoundaryPatterns.find(p => + bodyText.includes(p) + ); + + // Blank page detection + const hasContent = bodyText.trim().length > 50; + const hasMainContent = !!document.querySelector( + 'table, [class*="content"], main, [role="main"], [class*="page"], [class*="list"]' + ); + + // Console errors (if captured by step-executor ApiMonitor) + const apiErrors = (window.__API_ERRORS__ || []).filter(e => + e.status >= 400 || e.error + ); + + // Empty Select.Item values (the exact bug pattern) + const emptySelectItems = document.querySelectorAll( + '[role="option"][data-value=""], select option[value=""]' + ); + + // Check for React error overlay (dev mode) + const reactErrorOverlay = document.querySelector( + '[data-nextjs-dialog], #__next-build-error, [class*="nextjs-container-errors"]' + ); + + return { + foundErrorText, + hasContent, + hasMainContent, + apiErrorCount: apiErrors.length, + apiErrors: apiErrors.slice(0, 5).map(e => ({ + url: (e.url || '').substring(0, 100), + status: e.status, + method: e.method, + error: e.error, + })), + emptySelectItems: emptySelectItems.length, + reactErrorOverlay: !!reactErrorOverlay, + reactErrorMsg: reactErrorOverlay?.innerText?.substring(0, 200) || null, + }; + }), + sleep(timeout).then(() => ({ timeout: true })), + ]); + + if (pageState.timeout) { + diagnosis.healthy = false; + diagnosis.loadTimeout = true; + return diagnosis; + } + + // Error Boundary detected + if (pageState.foundErrorText) { + diagnosis.healthy = false; + diagnosis.crashed = true; + diagnosis.errorBoundary = pageState.foundErrorText; + } + + // React error overlay (dev/next.js) + if (pageState.reactErrorOverlay) { + diagnosis.healthy = false; + diagnosis.crashed = true; + diagnosis.errorBoundary = pageState.reactErrorMsg || 'React Error Overlay detected'; + } + + // Blank page + if (!pageState.hasContent && !pageState.hasMainContent) { + diagnosis.healthy = false; + diagnosis.blankPage = true; + } + + // API errors + if (pageState.apiErrorCount > 0) { + diagnosis.apiErrors = pageState.apiErrors; + // API 500 errors make the page unhealthy + if (pageState.apiErrors.some(e => e.status >= 500)) { + diagnosis.healthy = false; + } + } + + // Empty Select values (Radix UI crash pattern) + diagnosis.emptySelectValues = pageState.emptySelectItems; + if (pageState.emptySelectItems > 0) { + diagnosis.healthy = false; + } + + // Console errors make it unhealthy if they contain crash indicators + const criticalConsolePatterns = [ + 'Select.Item', 'must have a value', 'Uncaught', 'ChunkLoadError', + 'Cannot read properties of null', 'Cannot read properties of undefined', + 'Maximum update depth exceeded', 'Minified React error', + ]; + const criticalConsoleErrors = diagnosis.consoleErrors.filter(msg => + criticalConsolePatterns.some(p => msg.includes(p)) + ); + if (criticalConsoleErrors.length > 0) { + diagnosis.healthy = false; + } + } catch (err) { + diagnosis.healthy = false; + diagnosis.crashed = true; + diagnosis.errorBoundary = `Health check error: ${err.message}`; + } + + return diagnosis; +} + +/** + * Post-failure diagnosis. + * Runs AFTER a scenario fails to collect detailed root cause information. + * Captures: console errors, DOM state, API logs, screenshot. + * + * Returns: { rootCause: string, details: { ... } } + */ +async function diagnoseFail(page, result, scenarioId) { + const diag = { + rootCause: 'unknown', + consoleErrors: [], + apiErrors: [], + pageState: null, + screenshotPath: null, + recommendations: [], + }; + + try { + // 1. Capture screenshot + const ssName = `diag_${scenarioId}_${getTimestamp()}.png`; + const ssPath = path.join(SCREENSHOTS_DIR, ssName); + try { + await page.screenshot({ path: ssPath, fullPage: false, timeout: 5000 }); + diag.screenshotPath = ssPath; + } catch (e) { /* screenshot failed, continue */ } + + // 2. Collect console errors + diag.consoleErrors = (page.__e2e_console_errors || []).slice(-20); + + // 3. Collect page state & API errors + try { + const state = await Promise.race([ + page.evaluate(() => { + const bodyText = document.body?.innerText || ''; + + // Error Boundary check + const errorPatterns = [ + '오류가 발생했습니다', '일시적인 오류', + 'Something went wrong', 'Error boundary', + ]; + const errorBoundary = errorPatterns.find(p => bodyText.includes(p)); + + // API errors from step-executor monitor + const apiLogs = window.__API_LOGS__ || []; + const apiErrors = (window.__API_ERRORS__ || []).slice(0, 10).map(e => ({ + url: (e.url || '').substring(0, 120), + status: e.status, + method: e.method, + error: e.error, + })); + + // Null data detection in rendered content + const nullPatterns = bodyText.match(/null|undefined/gi) || []; + + // DOM stats + const domNodes = document.getElementsByTagName('*').length; + const tables = document.querySelectorAll('table'); + const tableRowCount = tables.length > 0 ? tables[0].querySelectorAll('tbody tr').length : 0; + const hasLoadingSpinner = !!document.querySelector( + '.loading, .spinner, [class*="skeleton"], [class*="loading"], [class*="Skeleton"]' + ); + + return { + url: window.location.href, + errorBoundary, + apiTotal: apiLogs.length, + apiErrors, + nullCount: nullPatterns.length, + domNodes, + tableRowCount, + hasLoadingSpinner, + visibleText: bodyText.substring(0, 300), + }; + }), + sleep(5000).then(() => null), + ]); + + if (state) { + diag.pageState = state; + diag.apiErrors = state.apiErrors; + + // Classify root cause + if (state.errorBoundary) { + diag.rootCause = 'page_crash'; + diag.recommendations.push('페이지 크래시 - Error Boundary 활성화됨. Console 에러 확인 필요'); + } else if (state.apiErrors.some(e => e.status >= 500)) { + diag.rootCause = 'api_server_error'; + diag.recommendations.push('백엔드 서버 에러 (5xx). 서버 로그 확인 필요'); + } else if (state.apiErrors.some(e => e.status === 401 || e.status === 403)) { + diag.rootCause = 'auth_error'; + diag.recommendations.push('인증/권한 에러. 세션 만료 가능성'); + } else if (state.hasLoadingSpinner) { + diag.rootCause = 'infinite_loading'; + diag.recommendations.push('무한 로딩 상태. API 미응답 또는 프론트엔드 상태 관리 버그'); + } else if (state.tableRowCount === 0 && state.apiTotal > 0) { + diag.rootCause = 'empty_data'; + diag.recommendations.push('API 응답은 있으나 테이블 데이터 없음. 데이터 변환 또는 필터 문제'); + } + } + } catch (e) { + diag.rootCause = 'page_unresponsive'; + diag.recommendations.push('페이지 응답 없음 (evaluate 실패). 네비게이션 에러 또는 크래시'); + } + + // 4. Console error pattern matching for specific root causes + const consoleText = diag.consoleErrors.join(' '); + if (consoleText.includes('Select.Item') || consoleText.includes('must have a value')) { + diag.rootCause = 'select_empty_value'; + diag.recommendations = ['Radix Select.Item에 빈 value 전달됨. 데이터 transform에서 null/빈값 방어 필요']; + } else if (consoleText.includes('ChunkLoadError') || consoleText.includes('Loading chunk')) { + diag.rootCause = 'chunk_load_error'; + diag.recommendations = ['JS 번들 로드 실패. 배포 상태 또는 네트워크 문제']; + } else if (consoleText.includes('Cannot read properties of null') || consoleText.includes('Cannot read properties of undefined')) { + diag.rootCause = 'null_reference'; + diag.recommendations = ['Null 참조 에러. API 응답에서 예상치 못한 null 데이터 가능성']; + } else if (consoleText.includes('Maximum update depth')) { + diag.rootCause = 'infinite_render_loop'; + diag.recommendations = ['React 무한 렌더 루프. useEffect 의존성 배열 또는 상태 업데이트 로직 확인']; + } + + // 5. Check failed step patterns for additional context + if (result.steps) { + const failedSteps = result.steps.filter(s => s.status === 'fail'); + const timeoutSteps = failedSteps.filter(s => + (s.error || s.details || '').includes('timeout') || (s.error || s.details || '').includes('Timeout') + ); + if (timeoutSteps.length > 0 && diag.rootCause === 'unknown') { + diag.rootCause = 'element_timeout'; + diag.recommendations.push('요소 대기 타임아웃. 페이지 로드 지연 또는 셀렉터 불일치'); + } + } + + } catch (err) { + diag.rootCause = 'diagnosis_error'; + diag.recommendations.push(`진단 중 에러: ${err.message}`); + } + + return diag; +} // ─── Scenario Runner ──────────────────────────────────────── @@ -387,12 +748,31 @@ async function runScenario(page, scenarioPath) { currentUrl: '', startTime: Date.now(), endTime: 0, + healthCheck: null, // Page health verification result + diagnosis: null, // Post-failure diagnosis result }; + // Attach console error listener for this scenario + page.__e2e_console_errors = []; + const consoleHandler = (msg) => { + if (msg.type() === 'error') { + page.__e2e_console_errors.push(msg.text().substring(0, 500)); + } + }; + page.on('console', consoleHandler); + + // Also capture uncaught page errors + const pageErrorHandler = (err) => { + page.__e2e_console_errors.push(`[PAGE_ERROR] ${err.message}`.substring(0, 500)); + }; + page.on('pageerror', pageErrorHandler); + if (!steps || steps.length === 0) { result.error = 'No steps defined'; result.stoppedReason = 'no_steps'; result.endTime = Date.now(); + page.removeListener('console', consoleHandler); + page.removeListener('pageerror', pageErrorHandler); return result; } @@ -405,11 +785,13 @@ async function runScenario(page, scenarioPath) { // Menu navigation if specified if (menuNavigation && menuNavigation.level1) { - const navOk = await navigateViaMenu(page, menuNavigation.level1, menuNavigation.level2); + const navOk = await navigateViaMenuWithRetry(page, menuNavigation.level1, menuNavigation.level2); if (!navOk) { result.error = `Menu navigation failed: ${menuNavigation.level1} > ${menuNavigation.level2}`; result.stoppedReason = 'navigation_failed'; result.endTime = Date.now(); + page.removeListener('console', consoleHandler); + page.removeListener('pageerror', pageErrorHandler); return result; } // Re-inject after navigation @@ -417,6 +799,50 @@ async function runScenario(page, scenarioPath) { await injectExecutor(page); } + // ─── Page Health Check (Pre-flight) ─────────────────── + const health = await verifyPageHealth(page); + result.healthCheck = health; + + if (!health.healthy) { + // Page is unhealthy - run diagnosis immediately and abort + const diag = await diagnoseFail(page, result, result.id); + result.diagnosis = diag; + + // Build descriptive error message + const reasons = []; + if (health.crashed || health.errorBoundary) + reasons.push(`페이지 크래시: ${health.errorBoundary}`); + if (health.blankPage) + reasons.push('빈 페이지 (콘텐츠 없음)'); + if (health.emptySelectValues > 0) + reasons.push(`빈 Select 값 ${health.emptySelectValues}개 감지`); + if (health.loadTimeout) + reasons.push('페이지 로드 타임아웃'); + if (health.apiErrors.length > 0) + reasons.push(`API 에러 ${health.apiErrors.length}건 (${health.apiErrors.map(e => `${e.status} ${e.method}`).join(', ')})`); + + const consoleSnippet = (health.consoleErrors || []) + .filter(msg => msg.length > 10) + .slice(0, 3) + .map(msg => msg.substring(0, 150)); + + result.error = `[HEALTH_CHECK] ${reasons.join(' | ')}`; + if (consoleSnippet.length > 0) { + result.error += ` | Console: ${consoleSnippet.join('; ')}`; + } + result.stoppedReason = 'health_check_failed'; + + console.log(C.yellow(` [HEALTH] ✘ ${reasons[0] || 'unhealthy'}`)); + if (diag.rootCause !== 'unknown') { + console.log(C.yellow(` [DIAG] root cause: ${diag.rootCause}`)); + } + + result.endTime = Date.now(); + page.removeListener('console', consoleHandler); + page.removeListener('pageerror', pageErrorHandler); + return result; + } + // Run steps in batches (handling navigation stops) let currentIndex = 0; let vars = {}; @@ -517,11 +943,33 @@ async function runScenario(page, scenarioPath) { if (result.stoppedReason === 'complete' && result.failed > 0) { result.stoppedReason = 'completed_with_failures'; } + + // ─── Post-failure Diagnosis ──────────────────────────── + if (result.failed > 0 || result.error) { + try { + const diag = await diagnoseFail(page, result, result.id); + result.diagnosis = diag; + if (diag.rootCause !== 'unknown') { + console.log(C.yellow(` [DIAG] root cause: ${diag.rootCause}`)); + } + } catch (diagErr) { + // Diagnosis itself failed, don't block + } + } } catch (err) { result.error = err.message; result.stoppedReason = 'exception'; + // Try diagnosis even on exception + try { + const diag = await diagnoseFail(page, result, result.id); + result.diagnosis = diag; + } catch (diagErr) { /* ignore */ } } + // Cleanup event listeners + page.removeListener('console', consoleHandler); + page.removeListener('pageerror', pageErrorHandler); + result.endTime = Date.now(); return result; } @@ -584,6 +1032,64 @@ ${stepsTable || '| - | (스텝 없음) | - | - | - | - |'} | ${api.total} | ${api.success} | ${api.failed} | ${api.avgResponseTime}ms | ${api.slowCalls} | `; + // Health Check section + if (result.healthCheck) { + const h = result.healthCheck; + md += '\n## 페이지 건강 검사\n'; + md += '| 항목 | 결과 |\n|------|------|\n'; + md += '| 상태 | ' + (h.healthy ? '✅ 정상' : '❌ 비정상') + ' |\n'; + md += '| URL | ' + (h.url || '-') + ' |\n'; + if (h.crashed) md += '| 크래시 | ' + (h.errorBoundary || 'Yes') + ' |\n'; + if (h.blankPage) md += '| 빈 페이지 | Yes |\n'; + if (h.loadTimeout) md += '| 로드 타임아웃 | Yes |\n'; + if (h.emptySelectValues > 0) md += '| 빈 Select 값 | ' + h.emptySelectValues + '개 |\n'; + if (h.apiErrors && h.apiErrors.length > 0) { + const apiErrStr = h.apiErrors.map(function(e) { return e.status + ' ' + e.method + ' ' + e.url; }).join(', '); + md += '| API 에러 | ' + apiErrStr + ' |\n'; + } + + if (h.consoleErrors && h.consoleErrors.length > 0) { + md += '\n### 콘솔 에러 (Health Check)\n'; + h.consoleErrors.slice(0, 5).forEach(function(err, i) { + md += (i + 1) + '. `' + err.substring(0, 200) + '`\n'; + }); + } + } + + // Diagnosis section + if (result.diagnosis) { + const d = result.diagnosis; + md += '\n## 자동 진단\n'; + md += '| 항목 | 내용 |\n|------|------|\n'; + md += '| 근본 원인 | **' + d.rootCause + '** |\n'; + if (d.screenshotPath) md += '| 스크린샷 | ' + path.basename(d.screenshotPath) + ' |\n'; + + if (d.recommendations && d.recommendations.length > 0) { + md += '\n### 권장 조치\n'; + d.recommendations.forEach(function(rec, i) { + md += (i + 1) + '. ' + rec + '\n'; + }); + } + + if (d.consoleErrors && d.consoleErrors.length > 0) { + md += '\n### 콘솔 에러 (진단)\n'; + d.consoleErrors.slice(0, 10).forEach(function(err, i) { + md += (i + 1) + '. `' + err.substring(0, 200) + '`\n'; + }); + } + + if (d.pageState) { + const ps = d.pageState; + md += '\n### 페이지 상태\n'; + md += '| 항목 | 값 |\n|------|----|\n'; + md += '| DOM 노드 | ' + (ps.domNodes || '-') + ' |\n'; + md += '| 테이블 행 | ' + (ps.tableRowCount || 0) + ' |\n'; + md += '| API 호출 수 | ' + (ps.apiTotal || 0) + ' |\n'; + md += '| 로딩 스피너 | ' + (ps.hasLoadingSpinner ? 'Yes' : 'No') + ' |\n'; + if (ps.errorBoundary) md += '| Error Boundary | ' + ps.errorBoundary + ' |\n'; + } + } + return md; } @@ -607,12 +1113,34 @@ function generateSummaryReport(allResults, totalTime, timestamp) { const passed = allResults.filter((r) => !r.error && r.failed === 0).length; const failed = allResults.length - passed; + // Categorize results + const categories = {}; + allResults.forEach((r) => { + const cat = getScenarioCategory(r.id || ''); + if (!categories[cat]) categories[cat] = []; + categories[cat].push(r); + }); + let md = `# E2E 전체 테스트 결과 요약 **실행 시간**: ${timestamp} **총 소요 시간**: ${(totalTime / 1000 / 60).toFixed(1)}분 **전체 시나리오**: ${allResults.length}개 | **성공**: ${passed}개 | **실패**: ${failed}개 +## 카테고리별 요약 +| 카테고리 | 시나리오 수 | 성공 | 실패 | 성공률 | +|---------|-----------|------|------|--------| +`; + + const catNames = { functional: '기능 테스트', workflow: '비즈니스 워크플로우', performance: '성능 테스트', 'edge-case': '엣지 케이스', accessibility: '접근성 검사' }; + for (const [cat, results] of Object.entries(categories)) { + const catPassed = results.filter(r => !r.error && r.failed === 0).length; + const catFailed = results.length - catPassed; + const rate = results.length > 0 ? Math.round((catPassed / results.length) * 100) : 0; + md += `| ${catNames[cat] || cat} | ${results.length} | ${catPassed} | ${catFailed} | ${rate}% |\n`; + } + + md += ` ## 시나리오별 결과 | # | 시나리오 | 결과 | 스텝 | 성공 | 실패 | 소요(초) | |---|---------|------|------|------|------|---------| @@ -625,6 +1153,53 @@ function generateSummaryReport(allResults, totalTime, timestamp) { md += `| ${i + 1} | ${r.name} | ${icon} | ${r.totalSteps} | ${r.passed} | ${r.failed} | ${duration} |\n`; }); + // Workflow summary section + if (categories.workflow && categories.workflow.length > 0) { + md += `\n## 비즈니스 워크플로우 상세\n`; + categories.workflow.forEach((r) => { + const hasFail = r.failed > 0 || r.error; + const icon = hasFail ? '❌' : '✅'; + const duration = ((r.endTime - r.startTime) / 1000).toFixed(1); + md += `\n### ${icon} ${r.name}\n`; + md += `- 스텝: ${r.passed}/${r.totalSteps} 성공 | 소요: ${duration}초\n`; + if (r.error) md += `- 에러: ${r.error}\n`; + const phases = r.steps.filter(s => s.phase).map(s => `${s.phase}(${s.status === 'pass' ? '✅' : '❌'})`); + if (phases.length > 0) md += `- 단계: ${phases.join(' → ')}\n`; + }); + } + + // Performance summary section + if (categories.performance && categories.performance.length > 0) { + md += `\n## 성능 테스트 요약\n`; + md += `| 페이지 | 로드 시간 | 등급 | API 평균 | DOM 노드 |\n`; + md += `|--------|----------|------|---------|----------|\n`; + categories.performance.forEach((r) => { + const perfStep = r.steps.find(s => s.phase === 'PERF_MEASURE'); + const perfData = perfStep?.details ? (() => { try { return JSON.parse(perfStep.details); } catch(e) { return null; } })() : null; + if (perfData) { + md += `| ${r.name} | ${perfData.loadTime || '-'}ms | ${perfData.grade || '-'} | ${perfData.apiAvg || '-'}ms | ${perfData.domNodes || '-'} |\n`; + } else { + md += `| ${r.name} | - | - | - | - |\n`; + } + }); + } + + // Accessibility summary section + if (categories.accessibility && categories.accessibility.length > 0) { + md += `\n## 접근성 검사 요약\n`; + md += `| 페이지 | 점수 | 등급 | Critical | Serious | Moderate |\n`; + md += `|--------|------|------|----------|---------|----------|\n`; + categories.accessibility.forEach((r) => { + const a11yStep = r.steps.find(s => s.phase === 'A11Y_AUDIT'); + const a11yData = a11yStep?.details ? (() => { try { return JSON.parse(a11yStep.details); } catch(e) { return null; } })() : null; + if (a11yData) { + md += `| ${r.name} | ${a11yData.score || '-'} | ${a11yData.grade || '-'} | ${a11yData.critical || 0} | ${a11yData.serious || 0} | ${a11yData.moderate || 0} |\n`; + } else { + md += `| ${r.name} | - | - | - | - | - |\n`; + } + }); + } + if (failed > 0) { md += `\n## 실패 시나리오 상세\n`; allResults @@ -632,6 +1207,27 @@ function generateSummaryReport(allResults, totalTime, timestamp) { .forEach((r) => { md += `\n### ❌ ${r.name} (${r.id})\n`; if (r.error) md += `- **에러**: ${r.error}\n`; + + // Diagnosis info + if (r.diagnosis && r.diagnosis.rootCause !== 'unknown') { + md += `- **진단**: ${r.diagnosis.rootCause}`; + if (r.diagnosis.recommendations && r.diagnosis.recommendations.length > 0) { + md += ` → ${r.diagnosis.recommendations[0]}`; + } + md += `\n`; + } + + // Health check info + if (r.healthCheck && !r.healthCheck.healthy) { + const h = r.healthCheck; + const issues = []; + if (h.crashed) issues.push('크래시'); + if (h.blankPage) issues.push('빈 페이지'); + if (h.loadTimeout) issues.push('로드 타임아웃'); + if (h.emptySelectValues > 0) issues.push(`빈 Select 값 ${h.emptySelectValues}개`); + if (issues.length > 0) md += `- **건강 검사**: ${issues.join(', ')}\n`; + } + const failSteps = r.steps.filter((s) => s.status === 'fail'); failSteps.forEach((s) => { md += `- Step ${s.stepId} (${s.name}): ${s.error || s.details}\n`; @@ -648,7 +1244,9 @@ async function main() { console.log(C.bold('\n=== E2E 전체 테스트 러너 ===')); console.log(`서버: ${BASE_URL}`); console.log(`모드: ${HEADLESS ? 'headless' : 'headed'}`); + if (WORKFLOW_ONLY) console.log(`카테고리: workflow only`); if (FILTER) console.log(`필터: ${FILTER}`); + if (EXCLUDE) console.log(`제외: ${EXCLUDE}`); console.log(''); // Ensure directories @@ -669,9 +1267,15 @@ async function main() { }) .sort(); + if (WORKFLOW_ONLY) { + scenarioFiles = scenarioFiles.filter((f) => f.startsWith('workflow-')); + } if (FILTER) { scenarioFiles = scenarioFiles.filter((f) => f.includes(FILTER)); } + if (EXCLUDE) { + scenarioFiles = scenarioFiles.filter((f) => !f.includes(EXCLUDE)); + } const totalScenarios = scenarioFiles.length; console.log(`시나리오: ${totalScenarios}개 발견\n`); @@ -741,11 +1345,12 @@ async function main() { process.stdout.write(`${C.dim(num)} ${file.replace('.json', '')} ... `); let result; + const timeout = getScenarioTimeout(file); try { // Wrap with timeout result = await Promise.race([ runScenario(page, scenarioPath), - sleep(SCENARIO_TIMEOUT).then(() => ({ + sleep(timeout).then(() => ({ id: file.replace('.json', ''), name: file.replace('.json', ''), steps: [], @@ -754,10 +1359,10 @@ async function main() { warned: 0, totalSteps: 0, apiSummary: null, - error: `Timeout (>${SCENARIO_TIMEOUT / 1000}s)`, + error: `Timeout (>${timeout / 1000}s)`, stoppedReason: 'timeout', currentUrl: '', - startTime: Date.now() - SCENARIO_TIMEOUT, + startTime: Date.now() - timeout, endTime: Date.now(), })), ]); diff --git a/e2e/runner/step-executor.js b/e2e/runner/step-executor.js index 7db08a6..5b18375 100644 --- a/e2e/runner/step-executor.js +++ b/e2e/runner/step-executor.js @@ -450,6 +450,24 @@ toggle_switch: 'check', capture: 'capture', screenshot: 'capture', + // Workflow (Wave 1) + save_context: 'workflow_context_save', + load_context: 'workflow_context_load', + context_save: 'workflow_context_save', + context_load: 'workflow_context_load', + verify_cross_module: 'cross_module_verify', + // Performance (Wave 2) + perf_measure: 'measure_performance', + perf_api: 'measure_api_performance', + perf_assert: 'assert_performance', + performance: 'measure_performance', + // Edge cases (Wave 3) + boundary_fill: 'fill_boundary', + rapid: 'rapid_click', + a11y_audit: 'accessibility_audit', + accessibility: 'accessibility_audit', + keyboard_nav: 'keyboard_navigate', + tab_navigate: 'keyboard_navigate', drag_start: 'noop', drag_over: 'noop', drag_end: 'noop', @@ -1523,6 +1541,258 @@ return pass(`Menu navigation: ${l1} > ${l2}`); }, + // ── Workflow group (Wave 1) ── + async workflow_context_save(action, ctx) { + const varName = action.variable || action.key; + if (!varName) return fail('workflow_context_save: variable/key required'); + let value; + if (action.selector) { + const el = findEl(action.selector, { selectors: ctx.selectors }); + value = el ? (el.value || el.innerText?.trim() || '') : null; + if (!value) return warn(`workflow_context_save: element empty or not found: ${action.selector}`); + } else if (action.value) { + value = replaceVars(action.value, ctx.variables); + } else if (action.extract === 'url_id') { + const m = window.location.href.match(/\/(\d+)(?:\?|$)/); + value = m ? m[1] : null; + if (!value) return warn('workflow_context_save: no ID found in URL'); + } else if (action.extract === 'first_row_cell') { + const rows = document.querySelectorAll('table tbody tr'); + if (rows.length > 0) { + const cellIdx = action.cellIndex ?? 1; + const cell = rows[0].querySelectorAll('td')[cellIdx]; + value = cell?.innerText?.trim() || null; + } + if (!value) return warn('workflow_context_save: no table data'); + } else { + return fail('workflow_context_save: need selector, value, or extract'); + } + if (!window.__WORKFLOW_CTX__) window.__WORKFLOW_CTX__ = {}; + window.__WORKFLOW_CTX__[varName] = value; + ctx.variables[varName] = value; + return pass(`Saved workflow ctx: ${varName}=${String(value).substring(0, 40)}`); + }, + + async workflow_context_load(action, ctx) { + const varName = action.variable || action.key; + if (!varName) return fail('workflow_context_load: variable/key required'); + const value = (window.__WORKFLOW_CTX__ && window.__WORKFLOW_CTX__[varName]) || ctx.variables[varName]; + if (value === undefined || value === null) return warn(`workflow_context_load: "${varName}" not found in context`); + ctx.variables[varName] = value; + return pass(`Loaded workflow ctx: ${varName}=${String(value).substring(0, 40)}`); + }, + + async cross_module_verify(action, ctx) { + const searchText = action.search || action.value || ctx.variables[action.variable]; + if (!searchText) return warn('cross_module_verify: no search text'); + await sleep(1500); + const pageText = document.body.innerText; + const found = pageText.includes(searchText); + if (action.expect === false) { + return found + ? fail(`cross_module_verify: "${searchText}" should NOT exist`) + : pass(`cross_module_verify: correctly absent "${searchText}"`); + } + return found + ? pass(`cross_module_verify: "${searchText}" found in target module`) + : warn(`cross_module_verify: "${searchText}" NOT found - possible data inconsistency`); + }, + + // ── Performance group (Wave 2) ── + async measure_performance(action, ctx) { + const metrics = {}; + try { + const nav = performance.getEntriesByType('navigation')[0]; + if (nav) { + metrics.domContentLoaded = Math.round(nav.domContentLoadedEventEnd - nav.startTime); + metrics.load = Math.round(nav.loadEventEnd - nav.startTime); + metrics.ttfb = Math.round(nav.responseStart - nav.requestStart); + metrics.domInteractive = Math.round(nav.domInteractive - nav.startTime); + } + const resources = performance.getEntriesByType('resource'); + metrics.resourceCount = resources.length; + metrics.totalTransferKB = Math.round(resources.reduce((s, r) => s + (r.transferSize || 0), 0) / 1024); + metrics.domNodes = document.getElementsByTagName('*').length; + if (performance.memory) { + metrics.jsHeapMB = Math.round(performance.memory.usedJSHeapSize / 1024 / 1024); + metrics.heapPct = Math.round((performance.memory.usedJSHeapSize / performance.memory.jsHeapSizeLimit) * 100); + } + } catch (e) { metrics.error = e.message; } + const varName = action.variable || 'perf_metrics'; + ctx.variables[varName] = JSON.stringify(metrics); + if (!window.__PERF_DATA__) window.__PERF_DATA__ = {}; + window.__PERF_DATA__[window.location.pathname] = metrics; + const loadTime = metrics.load || metrics.domContentLoaded || 0; + const grade = loadTime < 1000 ? 'A' : loadTime < 2000 ? 'B' : loadTime < 3000 ? 'C' : 'F'; + return pass(`Perf: load=${loadTime}ms grade=${grade} dom=${metrics.domNodes} res=${metrics.resourceCount}`); + }, + + async measure_api_performance(action, ctx) { + const logs = ApiMonitor._logs.slice(); + const apiMetrics = { + totalCalls: logs.length, + avgResponseTime: logs.length > 0 ? Math.round(logs.reduce((s, l) => s + (l.duration || 0), 0) / logs.length) : 0, + maxResponseTime: logs.length > 0 ? Math.max(...logs.map(l => l.duration || 0)) : 0, + slowCalls: logs.filter(l => l.duration > 2000).length, + failedCalls: logs.filter(l => !l.ok).length, + calls: logs.slice(-10).map(l => ({ url: l.url.split('?')[0].split('/').slice(-2).join('/'), ms: l.duration, status: l.status })), + }; + const varName = action.variable || 'api_perf'; + ctx.variables[varName] = JSON.stringify(apiMetrics); + return pass(`API: ${apiMetrics.totalCalls} calls, avg=${apiMetrics.avgResponseTime}ms, slow=${apiMetrics.slowCalls}`); + }, + + async assert_performance(action, ctx) { + const thresholds = action.thresholds || {}; + const maxPageLoad = thresholds.pageLoad || 3000; + const maxApiAvg = thresholds.apiAvg || 2000; + const maxDomNodes = thresholds.domNodes || 5000; + const issues = []; + const perfStr = ctx.variables['perf_metrics']; + if (perfStr) { + try { + const m = JSON.parse(perfStr); + const loadTime = m.load || m.domContentLoaded || 0; + if (loadTime > maxPageLoad) issues.push(`page load ${loadTime}ms > ${maxPageLoad}ms`); + if (m.domNodes > maxDomNodes) issues.push(`DOM nodes ${m.domNodes} > ${maxDomNodes}`); + } catch (e) {} + } + const apiStr = ctx.variables['api_perf']; + if (apiStr) { + try { + const a = JSON.parse(apiStr); + if (a.avgResponseTime > maxApiAvg) issues.push(`API avg ${a.avgResponseTime}ms > ${maxApiAvg}ms`); + } catch (e) {} + } + if (issues.length > 0) return warn(`Performance: ${issues.join('; ')}`); + return pass('Performance within thresholds'); + }, + + // ── Edge case group (Wave 3) ── + async fill_boundary(action, ctx) { + const el = findEl(action.target, { selectors: ctx.selectors }); + if (!el) return fail(`Input not found: ${action.target}`); + scrollIntoView(el); + el.focus(); + const boundaryType = action.boundaryType || action.boundary || 'empty'; + let value = ''; + switch (boundaryType) { + case 'empty': value = ''; break; + case 'whitespace': value = ' '; break; + case 'max_length': value = 'A'.repeat(action.maxLength || 255); break; + case 'overflow': value = 'X'.repeat((action.maxLength || 255) + 50); break; + case 'special_chars': value = ""; break; + case 'sql_injection': value = "'; DROP TABLE users; --"; break; + case 'unicode': value = '한글テスト中文🎉'; break; + case 'numeric_min': value = String(action.min ?? -999999); break; + case 'numeric_max': value = String(action.max ?? 999999999); break; + case 'negative': value = '-1'; break; + case 'zero': value = '0'; break; + case 'decimal': value = '0.123456789'; break; + default: value = action.value || ''; + } + clearInput(el); + setInputValue(el, value); + await sleep(300); + return pass(`Boundary fill [${boundaryType}]: "${value.substring(0, 30)}${value.length > 30 ? '...' : ''}"`); + }, + + async rapid_click(action, ctx) { + const el = findEl(action.target, { selectors: ctx.selectors }); + if (!el) return fail(`Element not found: ${action.target}`); + scrollIntoView(el); + const clicks = action.count || 5; + const delay = action.delay || 50; + for (let i = 0; i < clicks; i++) { + el.click(); + if (delay > 0) await sleep(delay); + } + await sleep(500); + return pass(`Rapid clicked ${clicks}x: ${action.target}`); + }, + + async accessibility_audit(action, ctx) { + const issues = { critical: [], serious: [], moderate: [], minor: [] }; + // Image alt text + document.querySelectorAll('img').forEach(img => { + if (!img.alt && !img.getAttribute('aria-label') && !img.getAttribute('aria-hidden') && img.offsetParent !== null) { + issues.critical.push({ rule: 'image-alt', wcag: '1.1.1', el: img.src?.split('/').pop()?.substring(0, 30) || 'img' }); + } + }); + // Form labels + document.querySelectorAll('input:not([type="hidden"]), select, textarea').forEach(el => { + if (el.offsetParent === null) return; + const hasLabel = el.id && document.querySelector(`label[for="${el.id}"]`); + const hasAria = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby'); + const hasPlaceholder = el.placeholder; + const hasTitle = el.title; + if (!hasLabel && !hasAria && !hasPlaceholder && !hasTitle) { + issues.critical.push({ rule: 'form-label', wcag: '1.3.1', el: `${el.tagName}[${el.type || 'text'}]` }); + } + }); + // Button names + document.querySelectorAll('button, [role="button"]').forEach(btn => { + if (btn.offsetParent === null) return; + if (!btn.innerText?.trim() && !btn.getAttribute('aria-label') && !btn.title) { + const hasSvg = btn.querySelector('svg'); + if (!hasSvg || !btn.getAttribute('aria-label')) { + issues.serious.push({ rule: 'button-name', wcag: '4.1.2' }); + } + } + }); + // Heading order + const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6')).filter(h => h.offsetParent !== null); + let prevLevel = 0; + headings.forEach(h => { + const level = parseInt(h.tagName[1]); + if (prevLevel > 0 && level > prevLevel + 1) { + issues.moderate.push({ rule: 'heading-order', wcag: '1.3.1', detail: `h${prevLevel}→h${level}` }); + } + prevLevel = level; + }); + // Page language + if (!document.documentElement.lang) { + issues.moderate.push({ rule: 'html-lang', wcag: '3.1.1' }); + } + // Score + const score = Math.max(0, 100 - (issues.critical.length * 10) - (issues.serious.length * 5) - (issues.moderate.length * 2) - (issues.minor.length * 1)); + const grade = score >= 70 ? 'PASS' : 'FAIL'; + const result = { score, grade, issues, url: window.location.href }; + ctx.variables['a11y_result'] = JSON.stringify(result); + if (!window.__A11Y_DATA__) window.__A11Y_DATA__ = {}; + window.__A11Y_DATA__[window.location.pathname] = result; + const summary = `A11y: score=${score} ${grade} (C:${issues.critical.length} S:${issues.serious.length} M:${issues.moderate.length})`; + return score >= 70 ? pass(summary) : warn(summary); + }, + + async keyboard_navigate(action, ctx) { + const maxTabs = action.count || 20; + const focusable = []; + let prevActive = document.activeElement; + document.body.focus(); + await sleep(100); + for (let i = 0; i < maxTabs; i++) { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', keyCode: 9, bubbles: true })); + document.activeElement?.dispatchEvent?.(new KeyboardEvent('keydown', { key: 'Tab', keyCode: 9, bubbles: true })); + await sleep(100); + const current = document.activeElement; + if (current && current !== document.body && current !== prevActive) { + const hasOutline = window.getComputedStyle(current).outlineStyle !== 'none' || window.getComputedStyle(current).boxShadow !== 'none'; + focusable.push({ + tag: current.tagName, + text: (current.innerText || current.value || '').substring(0, 20), + visible: current.offsetParent !== null, + focusIndicator: hasOutline, + }); + prevActive = current; + } + } + const withIndicator = focusable.filter(f => f.focusIndicator).length; + const allVisible = focusable.every(f => f.visible); + ctx.variables['keyboard_nav'] = JSON.stringify({ elements: focusable.length, withIndicator, allVisible }); + return pass(`Keyboard: ${focusable.length} focusable, ${withIndicator} with indicator, allVisible=${allVisible}`); + }, + // ── Noop ── async noop(action, ctx) { return pass('No action'); @@ -1581,6 +1851,20 @@ return lastResult; } + // ─── Step Timeout ─────────────────────────────────────── + + const STEP_TIMEOUT_MS = 60000; // 60초: 개별 스텝 최대 대기 시간 + + /** Wrap a promise with a timeout - 1분 초과 시 즉시 오류 처리 */ + function withStepTimeout(promise, timeoutMs, stepName) { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Step timeout (>${timeoutMs / 1000}s): ${stepName}`)), timeoutMs) + ), + ]); + } + // ─── Batch Runner ─────────────────────────────────────── /** @@ -1609,64 +1893,78 @@ let stepError = null; let subResults = []; - for (let j = 0; j < normalized.subActions.length; j++) { - const action = normalized.subActions[j]; - const actionType = action.type; + // Per-step timeout: use step-specific timeout or global 60s + const stepTimeoutMs = step.timeout || STEP_TIMEOUT_MS; - // Check if action requires native (screenshot without selector) - if ((actionType === 'capture' && !action.selector) || actionType === 'screenshot') { - stoppedReason = 'native_required'; - stoppedAtIndex = i; - results.push({ - stepId: normalized.stepId, - name: normalized.name, - status: 'skip', - duration: now() - stepStart, - details: 'Requires native screenshot', - error: null, - phase: normalized.phase, - }); - // Return with position info + try { + await withStepTimeout((async () => { + for (let j = 0; j < normalized.subActions.length; j++) { + const action = normalized.subActions[j]; + const actionType = action.type; + + // Check if action requires native (screenshot without selector) + if ((actionType === 'capture' && !action.selector) || actionType === 'screenshot') { + stoppedReason = 'native_required'; + stoppedAtIndex = i; + results.push({ + stepId: normalized.stepId, + name: normalized.name, + status: 'skip', + duration: now() - stepStart, + details: 'Requires native screenshot', + error: null, + phase: normalized.phase, + }); + return '__EARLY_RETURN__'; + } + + // Get handler + const handler = ActionHandlers[actionType]; + if (!handler) { + subResults.push(warn(`Unknown action type: ${actionType}`)); + continue; + } + + // Execute with retry + const result = await retryAction(handler, action, ctx); + + // Handle navigation signal + if (result.status === 'navigation') { + subResults.push(result); + stoppedReason = 'navigation'; + stoppedAtIndex = i + 1; // continue from next step + const sr = buildStepResult(normalized, subResults, stepStart); + results.push(sr); + return '__EARLY_RETURN__'; + } + + // Handle native_required signal + if (result.status === 'native_required') { + stoppedReason = 'native_required'; + stoppedAtIndex = i; + const sr = buildStepResult(normalized, subResults, stepStart); + results.push(sr); + return '__EARLY_RETURN__'; + } + + subResults.push(result); + + // Check URL change (indicates page navigation) + const currentUrl = window.location.href; + if (currentUrl !== startUrl && j < normalized.subActions.length - 1) { + // URL changed mid-step, might need re-injection + // Continue for now, check at step boundary + } + } + })(), stepTimeoutMs, normalized.name); + + // Check if early return was signaled + if (stoppedReason !== 'complete') { return buildBatchResult(results, ctx, stoppedReason, stoppedAtIndex); } - - // Get handler - const handler = ActionHandlers[actionType]; - if (!handler) { - subResults.push(warn(`Unknown action type: ${actionType}`)); - continue; - } - - // Execute with retry - const result = await retryAction(handler, action, ctx); - - // Handle navigation signal - if (result.status === 'navigation') { - subResults.push(result); - stoppedReason = 'navigation'; - stoppedAtIndex = i + 1; // continue from next step - const sr = buildStepResult(normalized, subResults, stepStart); - results.push(sr); - return buildBatchResult(results, ctx, stoppedReason, stoppedAtIndex); - } - - // Handle native_required signal - if (result.status === 'native_required') { - stoppedReason = 'native_required'; - stoppedAtIndex = i; - const sr = buildStepResult(normalized, subResults, stepStart); - results.push(sr); - return buildBatchResult(results, ctx, stoppedReason, stoppedAtIndex); - } - - subResults.push(result); - - // Check URL change (indicates page navigation) - const currentUrl = window.location.href; - if (currentUrl !== startUrl && j < normalized.subActions.length - 1) { - // URL changed mid-step, might need re-injection - // Continue for now, check at step boundary - } + } catch (timeoutErr) { + // Step exceeded timeout - record as fail and continue to next step + subResults.push(fail(timeoutErr.message)); } // Build step result