diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..f5469d35 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 0, + workers: 1, + reporter: "html", + timeout: 60_000, + expect: { timeout: 10_000 }, + + use: { + baseURL: "http://localhost:9771", + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "on-first-retry", + actionTimeout: 15_000, + }, + + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1024, height: 800 }, + }, + }, + ], +}); diff --git a/frontend/tests/e2e/pop-cart-outbound.spec.ts b/frontend/tests/e2e/pop-cart-outbound.spec.ts new file mode 100644 index 00000000..2fb0072a --- /dev/null +++ b/frontend/tests/e2e/pop-cart-outbound.spec.ts @@ -0,0 +1,541 @@ +import { test, expect, Page } from "@playwright/test"; + +// ============================================ +// 판매출고 POP 화면 (4578) 브라우저 테스트 +// ============================================ +// +// 테스트 흐름: +// 1. 로그인 -> POP 화면 접속 +// 2. 화면 렌더링 확인 +// 3. 거래처 검색(모달) -> 거래처 선택 -> 카드 로드 +// 4. 카드 컴포넌트 표시 확인 +// 5. 수량 입력 + 담기 동작 +// 6. 장바구니 취소 동작 +// ============================================ + +const TEST_CONFIG = { + screenId: 4578, + loginUrl: "/login", + screenUrl: "/pop/screens/4578", + credentials: { + userId: "topseal_admin", + password: "qlalfqjsgh11", + }, + // 테스트 거래처 (DB에 미리 삽입됨) + testCustomer: { + code: "T-CUST-001", + name: "(T)테스트거래처", + }, +}; + +// 로그인 헬퍼 +async function login(page: Page) { + await page.goto(TEST_CONFIG.loginUrl); + await page.waitForLoadState("networkidle"); + + // 로그인 폼 입력 + await page.locator("#userId").fill(TEST_CONFIG.credentials.userId); + await page.locator("#password").fill(TEST_CONFIG.credentials.password); + await page.locator('button[type="submit"]').click(); + + // 로그인 성공 대기 (리다이렉트) + await page.waitForURL((url) => !url.pathname.includes("/login"), { + timeout: 15_000, + }); +} + +// POP 화면 이동 헬퍼 +async function navigateToPopScreen(page: Page) { + await page.goto(TEST_CONFIG.screenUrl); + await page.waitForLoadState("networkidle"); + + // 로딩 스피너가 사라질 때까지 대기 + await page + .locator(".animate-spin") + .waitFor({ state: "hidden", timeout: 15_000 }) + .catch(() => { + /* 이미 로드 완료 */ + }); +} + +// 거래처 선택 헬퍼: 모달 검색으로 테스트 거래처 선택 +async function selectTestCustomer(page: Page) { + // comp_4 (pop-search modal) - 거래처 검색 클릭 + // 모달형 검색은 role="button"인 div, placeholder="거래처 검색" 텍스트 포함 + const searchTrigger = page.locator( + '[role="button"]:has-text("거래처 검색"), [role="button"]:has-text("선택...")' + ).first(); + + const isTriggerVisible = await searchTrigger + .isVisible({ timeout: 5_000 }) + .catch(() => false); + + if (!isTriggerVisible) { + // 대안: placeholder를 포함하는 input/button 찾기 + const altTrigger = page + .locator('text="거래처 검색"') + .first(); + if (await altTrigger.isVisible().catch(() => false)) { + await altTrigger.click(); + } else { + console.log("WARN: 거래처 검색 트리거를 찾을 수 없음"); + return false; + } + } else { + await searchTrigger.click(); + } + + // 모달 다이얼로그가 열릴 때까지 대기 + const dialog = page.locator('[role="dialog"]'); + await dialog.waitFor({ state: "visible", timeout: 10_000 }); + + // 필터 탭("가나다" 등)이 활성화되어 있으면 비활성화 + // "(T)테스트거래처"는 "("로 시작하므로 한글 초성 필터에서 제외됨 + const filterTabButton = dialog.locator('button:has-text("가나다")'); + if (await filterTabButton.isVisible({ timeout: 2_000 }).catch(() => false)) { + // 활성 상태(variant=default) 확인 후 클릭해서 토글 off + await filterTabButton.click(); + await page.waitForTimeout(300); + } + + // 모달에서 테스트 거래처 검색 + const modalSearchInput = dialog.locator('input[type="text"]').first(); + if (await modalSearchInput.isVisible({ timeout: 3_000 }).catch(() => false)) { + await modalSearchInput.fill("테스트거래처"); + await page.waitForTimeout(800); // 디바운스 대기 + } + + // "(T)테스트거래처" 항목 클릭 - 아이콘 뷰에서는 truncated text 내 span + const customerItem = dialog + .locator(`text=${TEST_CONFIG.testCustomer.name}`) + .first(); + let found = await customerItem + .isVisible({ timeout: 5_000 }) + .catch(() => false); + + // 아이콘 뷰에서 truncate 때문에 정확한 텍스트 매칭 실패 시 role=button으로 시도 + if (!found) { + // 부분 텍스트 매칭 시도 + const partialMatch = dialog.locator('text=테스트거래처').first(); + found = await partialMatch.isVisible({ timeout: 2_000 }).catch(() => false); + if (found) { + await partialMatch.click(); + await dialog.waitFor({ state: "hidden", timeout: 5_000 }).catch(() => {}); + await page.waitForTimeout(1_500); + return true; + } + + // role=button 중 텍스트 포함된 것 시도 + const iconBtn = dialog.locator('[role="button"]').filter({ hasText: /테스트/ }).first(); + found = await iconBtn.isVisible({ timeout: 2_000 }).catch(() => false); + if (found) { + await iconBtn.click(); + await dialog.waitFor({ state: "hidden", timeout: 5_000 }).catch(() => {}); + await page.waitForTimeout(1_500); + return true; + } + } + + if (found) { + await customerItem.click(); + // 모달 닫힘 + 데이터 로딩 대기 + await dialog + .waitFor({ state: "hidden", timeout: 5_000 }) + .catch(() => {}); + await page.waitForTimeout(1_500); // API 응답 + 렌더링 대기 + return true; + } + + console.log("WARN: 테스트 거래처를 모달에서 찾을 수 없음"); + return false; +} + +// ============================================ +// 테스트 시작 +// ============================================ + +test.describe("판매출고 POP 화면 (pop-cart-outbound)", () => { + test.beforeEach(async ({ page }) => { + await login(page); + }); + + // ------------------------------------------- + // 시나리오 1: 화면 접속 + 렌더링 확인 + // ------------------------------------------- + test("화면 접속 시 POP 화면이 정상 렌더링된다", async ({ page }) => { + await navigateToPopScreen(page); + + // 에러 화면이 아닌지 확인 + const errorText = page.getByText("화면을 찾을 수 없습니다"); + await expect(errorText).not.toBeVisible(); + + // 빈 화면이 아닌지 확인 + const emptyText = page.getByText("화면이 비어있습니다"); + await expect(emptyText).not.toBeVisible(); + + // 페이지가 정상 로드됨 + await expect(page).toHaveURL(/\/pop\/screens\/4578/); + + // 스크린샷 + await page.screenshot({ + path: "tests/e2e/screenshots/01-screen-loaded.png", + fullPage: true, + }); + }); + + // ------------------------------------------- + // 시나리오 2: 카트 아웃바운드 컴포넌트 확인 + // ------------------------------------------- + test("pop-cart-outbound 컴포넌트가 화면에 표시된다", async ({ page }) => { + await navigateToPopScreen(page); + + // requireFilter 설정으로 "거래처를 선택하면" 메시지 표시됨 + const hasEmptyState = await page + .locator("text=거래처를 선택하면") + .isVisible({ timeout: 5_000 }) + .catch(() => false); + + const hasHeader = await page + .locator("text=출고 대상 품목") + .first() + .isVisible() + .catch(() => false); + + const hasCards = await page + .locator("div.rounded-xl.border") + .first() + .isVisible() + .catch(() => false); + + // 셋 중 하나는 있어야 함 (컴포넌트가 렌더링됨) + expect(hasHeader || hasEmptyState || hasCards).toBeTruthy(); + + await page.screenshot({ + path: "tests/e2e/screenshots/02-cart-outbound-visible.png", + fullPage: true, + }); + }); + + // ------------------------------------------- + // 시나리오 3: 검색 컴포넌트 확인 + // ------------------------------------------- + test("검색/스캔 컴포넌트가 화면에 표시된다", async ({ page }) => { + await navigateToPopScreen(page); + + // input 또는 검색 관련 요소가 있는지 확인 + const searchInputs = page.locator( + 'input[type="text"], input[placeholder*="검색"], input[placeholder*="스캔"], [role="button"]:has-text("거래처"), [role="button"]:has-text("선택")' + ); + const searchCount = await searchInputs.count(); + + // 최소 1개 이상의 검색/입력 요소가 있어야 함 + expect(searchCount).toBeGreaterThanOrEqual(1); + + await page.screenshot({ + path: "tests/e2e/screenshots/03-search-components.png", + fullPage: true, + }); + }); + + // ------------------------------------------- + // 시나리오 4: 거래처 선택 -> 카드 로드 + // ------------------------------------------- + test("거래처 선택 시 출고 대상 품목 카드가 표시된다", async ({ page }) => { + await navigateToPopScreen(page); + + // 거래처 선택 전: 빈 상태 메시지 확인 + const emptyMsg = page.locator("text=거래처를 선택하면"); + const hasEmptyMsg = await emptyMsg + .isVisible({ timeout: 3_000 }) + .catch(() => false); + if (hasEmptyMsg) { + console.log("INFO: requireFilter 동작 확인 - 거래처 미선택 시 빈 상태 메시지 표시"); + } + + await page.screenshot({ + path: "tests/e2e/screenshots/04a-before-customer-select.png", + fullPage: true, + }); + + // 거래처 선택 + const selected = await selectTestCustomer(page); + if (!selected) { + test.skip(true, "테스트 거래처를 선택할 수 없음"); + return; + } + + // 카드가 로딩되는지 확인 + // 테스트 데이터: "(T)테스트 알루미늄 프로파일", "(T)테스트 스틸바 SB-200" + const card = page + .locator("text=테스트 알루미늄, text=테스트 스틸바, text=T-ITEM") + .first(); + const hasCards = await card + .isVisible({ timeout: 8_000 }) + .catch(() => false); + + if (hasCards) { + console.log("SUCCESS: 거래처 선택 후 품목 카드 로딩됨"); + } else { + // 카드가 안 보일 수 있음 - 스크린샷으로 디버그 + console.log("WARN: 거래처 선택 후 카드가 즉시 보이지 않음 - 스크린샷 확인 필요"); + } + + await page.screenshot({ + path: "tests/e2e/screenshots/04b-after-customer-select.png", + fullPage: true, + }); + }); + + // ------------------------------------------- + // 시나리오 5: 카드에 담기 버튼 존재 확인 + // ------------------------------------------- + test("품목 카드에 담기 버튼이 있다", async ({ page }) => { + await navigateToPopScreen(page); + + // 먼저 거래처 선택으로 카드 로드 + const selected = await selectTestCustomer(page); + if (!selected) { + test.skip(true, "테스트 거래처를 선택할 수 없음 - 담기 버튼 테스트 스킵"); + return; + } + + // 담기 버튼 확인 + const addButton = page.getByRole("button", { name: /담기/ }).first(); + const hasData = await addButton + .isVisible({ timeout: 5_000 }) + .catch(() => false); + + if (hasData) { + await expect(addButton).toBeVisible(); + await expect(addButton).toBeEnabled(); + console.log("SUCCESS: 담기 버튼 확인됨"); + } else { + console.log("INFO: 품목 데이터 없음 - 빈 상태 확인"); + test.skip(true, "테스트 데이터 없음 - 담기 버튼 테스트 스킵"); + } + + await page.screenshot({ + path: "tests/e2e/screenshots/05-add-button.png", + fullPage: true, + }); + }); + + // ------------------------------------------- + // 시나리오 6: 수량 입력 모달 열기 + // ------------------------------------------- + test("수량 버튼 클릭 시 숫자 입력 모달이 열린다", async ({ page }) => { + await navigateToPopScreen(page); + + // 거래처 선택 + const selected = await selectTestCustomer(page); + if (!selected) { + test.skip(true, "테스트 거래처를 선택할 수 없음"); + return; + } + + // 수량 버튼 찾기 (숫자 + 단위 표시된 버튼) + const qtyButton = page + .locator("button") + .filter({ hasText: /\d+.*?(EA|kg|개|톤|m|L)/i }) + .first(); + const hasQtyButton = await qtyButton + .isVisible({ timeout: 5_000 }) + .catch(() => false); + + if (!hasQtyButton) { + test.skip(true, "수량 버튼을 찾을 수 없음"); + return; + } + + // 수량 버튼 클릭 + await qtyButton.click(); + + // 모달이 열렸는지 확인 (숫자 키패드 또는 확인 버튼) + const confirmButton = page.getByRole("button", { name: /확인/ }); + await expect(confirmButton).toBeVisible({ timeout: 5_000 }); + + // 키패드 숫자 버튼이 있는지 + const numberButton = page + .getByRole("button", { name: /^[0-9]$/ }) + .first(); + await expect(numberButton).toBeVisible(); + + await page.screenshot({ + path: "tests/e2e/screenshots/06-number-modal.png", + fullPage: true, + }); + }); + + // ------------------------------------------- + // 시나리오 7: 수량 입력 후 담기 전체 흐름 + // ------------------------------------------- + test("수량 입력 -> 담기 -> 장바구니 저장 흐름이 동작한다", async ({ + page, + }) => { + await navigateToPopScreen(page); + + // 거래처 선택 + const selected = await selectTestCustomer(page); + if (!selected) { + test.skip(true, "테스트 거래처를 선택할 수 없음"); + return; + } + + // 1. 담기 버튼이 있는지 확인 + const addButton = page.getByRole("button", { name: /담기/ }).first(); + const hasData = await addButton + .isVisible({ timeout: 5_000 }) + .catch(() => false); + + if (!hasData) { + test.skip(true, "테스트 데이터 없음 - 담기 흐름 테스트 스킵"); + return; + } + + // 2. 수량 버튼 클릭 -> 모달 열기 + const qtyButton = page + .locator("button") + .filter({ hasText: /\d+.*?(EA|kg|개|톤|m|L)/i }) + .first(); + if (await qtyButton.isVisible()) { + await qtyButton.click(); + + // 3. 숫자 입력 (1, 0 -> 10) + const numBtn1 = page.getByRole("button", { name: "1" }); + const numBtn0 = page.getByRole("button", { name: "0" }); + if (await numBtn1.isVisible({ timeout: 3_000 }).catch(() => false)) { + await numBtn1.click(); + await numBtn0.click(); + } + + // 4. 확인 클릭 + const confirmBtn = page.getByRole("button", { name: /확인/ }); + if (await confirmBtn.isVisible()) { + await confirmBtn.click(); + // 모달 닫힘 대기 + await confirmBtn + .waitFor({ state: "hidden", timeout: 5_000 }) + .catch(() => {}); + } + } + + // 5. 담기 버튼 클릭 + await addButton.click(); + + // 6. 담기 후 상태 변화 확인 (버튼이 "취소"로 바뀌거나 카드 스타일 변경) + await page.waitForTimeout(1_000); // API 호출 대기 + + const cancelButton = page.getByRole("button", { name: /취소/ }).first(); + const hasCancelButton = await cancelButton + .isVisible() + .catch(() => false); + + // 담기 성공: 취소 버튼이 나타남 + if (hasCancelButton) { + console.log("SUCCESS: 담기 성공 - 취소 버튼 표시됨"); + } else { + console.log( + "INFO: 취소 버튼이 안 보임 - 담기 결과 추가 확인 필요" + ); + } + + await page.screenshot({ + path: "tests/e2e/screenshots/07-after-add-to-cart.png", + fullPage: true, + }); + }); + + // ------------------------------------------- + // 시나리오 8: 장바구니 취소 동작 + // ------------------------------------------- + test("담기 후 취소 버튼으로 장바구니에서 제거된다", async ({ page }) => { + await navigateToPopScreen(page); + + // 거래처 선택 + const selected = await selectTestCustomer(page); + if (!selected) { + test.skip(true, "테스트 거래처를 선택할 수 없음"); + return; + } + + // 이미 담긴 항목이 있는지 확인 (취소 버튼 존재 여부) + const cancelButton = page.getByRole("button", { name: /취소/ }).first(); + const hasCancelButton = await cancelButton + .isVisible({ timeout: 3_000 }) + .catch(() => false); + + if (!hasCancelButton) { + // 먼저 담기를 수행 + const addButton = page.getByRole("button", { name: /담기/ }).first(); + const hasAddButton = await addButton + .isVisible({ timeout: 3_000 }) + .catch(() => false); + if (!hasAddButton) { + test.skip(true, "테스트 데이터 없음 - 취소 테스트 스킵"); + return; + } + await addButton.click(); + await page.waitForTimeout(1_000); + } + + // 취소 버튼 클릭 + const cancelBtn = page.getByRole("button", { name: /취소/ }).first(); + if (await cancelBtn.isVisible()) { + await cancelBtn.click(); + await page.waitForTimeout(1_000); + + // 취소 후: 담기 버튼이 다시 나타남 + const addButtonAfter = page + .getByRole("button", { name: /담기/ }) + .first(); + const hasAddAfterCancel = await addButtonAfter + .isVisible() + .catch(() => false); + + if (hasAddAfterCancel) { + console.log("SUCCESS: 취소 성공 - 담기 버튼 다시 표시됨"); + } + } + + await page.screenshot({ + path: "tests/e2e/screenshots/08-after-cancel.png", + fullPage: true, + }); + }); + + // ------------------------------------------- + // 시나리오 9: 반응형 모드 확인 (태블릿 가로) + // ------------------------------------------- + test("태블릿 가로 모드에서 정상 렌더링된다", async ({ page }) => { + // 태블릿 가로 (1024x800) + await page.setViewportSize({ width: 1024, height: 800 }); + await navigateToPopScreen(page); + + await page.waitForTimeout(1_000); + + await page.screenshot({ + path: "tests/e2e/screenshots/09-tablet-landscape.png", + fullPage: true, + }); + }); + + // ------------------------------------------- + // 시나리오 10: 반응형 모드 확인 (모바일 세로) + // ------------------------------------------- + test("모바일 세로 모드에서 정상 렌더링된다", async ({ page }) => { + // 모바일 세로 (375x667) + await page.setViewportSize({ width: 375, height: 667 }); + await navigateToPopScreen(page); + + await page.waitForTimeout(1_000); + + // 화면이 깨지지 않고 렌더링되는지 확인 + const errorText = page.getByText("화면을 찾을 수 없습니다"); + await expect(errorText).not.toBeVisible(); + + await page.screenshot({ + path: "tests/e2e/screenshots/10-mobile-portrait.png", + fullPage: true, + }); + }); +});