diff --git a/.gitignore b/.gitignore index 307285b..448be13 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ dist/ build/ *.tsbuildinfo .DS_Store +test-results/ +playwright-report/ +playwright/.cache/ diff --git a/e2e/member-domains.spec.ts b/e2e/member-domains.spec.ts new file mode 100644 index 0000000..89859d9 --- /dev/null +++ b/e2e/member-domains.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from "@playwright/test"; + +// 일반 구성원 관점의 핵심 도메인: 근무(휴가 신청 검증) + 인센티브 게이지. +// ?as=user 로 로드하면 앱 전체가 비관리자(김주임)로 동작. + +test("근무 페이지 + 휴가 신청 모달 검증", async ({ page }) => { + await page.goto("/attendance?as=user"); + await expect(page.getByRole("heading", { name: "근무" })).toBeVisible(); + await expect(page.getByText("이번 달 출근일")).toBeVisible(); + // 휴가/공가 탭 → 휴가 신청 모달 + await page.getByRole("button", { name: "휴가/공가" }).click(); + await page.getByRole("button", { name: "휴가 신청" }).click(); + const submit = page.getByRole("button", { name: "신청", exact: true }); + // 시작일 없으면 신청 비활성 + await expect(submit).toBeDisabled(); + await page.locator('.fixed input[type="date"]').first().fill("2026-09-10"); + await expect(submit).toBeEnabled(); + // 제출하지 않고 닫기(영속 데이터 오염 방지) + await page.getByRole("dialog").getByRole("button", { name: "취소" }).click(); +}); + +test("인센티브 페이지 게이지 렌더", async ({ page }) => { + const errs: string[] = []; + page.on("pageerror", (e) => errs.push(e.message)); + await page.goto("/incentive?as=user"); + await expect(page.getByRole("heading", { name: "내 인센티브" })).toBeVisible(); + // 게이지/할당량 섹션 렌더(연도 선택 존재) + await expect(page.locator("select").first()).toBeVisible(); + expect(errs, errs.join("\n")).toHaveLength(0); +}); + +test("대시보드 인센티브 게이지 + 내 이슈 보드", async ({ page }) => { + await page.goto("/?as=user"); + await expect(page.getByText(/안녕하세요/)).toBeVisible(); + await expect(page.getByText("내 이슈")).toBeVisible(); +}); diff --git a/e2e/project-flow.spec.ts b/e2e/project-flow.spec.ts new file mode 100644 index 0000000..5e36a4a --- /dev/null +++ b/e2e/project-flow.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from "@playwright/test"; + +// 실제 프로젝트 상세 워크플로(개요/작업자/타임라인 칸반/메일 탭 + 작업 생성→상세 모달). +const API = "http://localhost:8380/api"; +const stamp = Date.now().toString().slice(-6); +let projectId = ""; + +test.beforeAll(async () => { + const r = await fetch(`${API}/projects`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: `E2E흐름-${stamp}`, clientDomain: "meditech.com" }), + }); + const p = await r.json(); + projectId = p.id; + // 작업자 1명 추가(담당자 옵션) + await fetch(`${API}/projects/${projectId}/members`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ memberEmail: "park@special-partners.com", portion: 100, role: "작업자" }), + }); +}); + +test.afterAll(async () => { + if (projectId) await fetch(`${API}/projects/${projectId}`, { method: "DELETE" }).catch(() => {}); +}); + +test("프로젝트 상세 — 개요 렌더", async ({ page }) => { + await page.goto(`/projects/${projectId}`); + await expect(page.getByRole("heading", { name: new RegExp(`E2E흐름-${stamp}`) })).toBeVisible(); + await expect(page.getByText("컨설팅 종류")).toBeVisible(); + await expect(page.getByPlaceholder(/주의할 점/)).toBeVisible(); +}); + +test("탭 이동 — 작업자/메일", async ({ page }) => { + await page.goto(`/projects/${projectId}`); + await page.getByRole("button", { name: "작업자", exact: true }).click(); + await expect(page.getByText(/기여도\(portion\)/)).toBeVisible(); + await expect(page.getByText("park@special-partners.com")).toBeVisible(); + await page.getByRole("button", { name: "메일", exact: true }).click(); + // 로컬은 Google Workspace 연동 미설정 → 안내 empty state가 정상 + await expect(page.getByText("메일 연동이 설정되지 않았습니다")).toBeVisible(); +}); + +test("타임라인 — 작업 추가 → 상세 모달 → 칸반 카드", async ({ page }) => { + const errs: string[] = []; + page.on("pageerror", (e) => errs.push(e.message)); + await page.goto(`/projects/${projectId}`); + await page.getByRole("button", { name: "타임라인", exact: true }).click(); + await page.getByRole("button", { name: "작업 추가" }).click(); + const title = `E2E작업-${stamp}`; + await page.getByPlaceholder("예: 기술문서 초안 작성").fill(title); + await page.getByRole("button", { name: "추가", exact: true }).click(); + // 생성 후 상세 모달이 열림(댓글 입력창으로 확인) + await expect(page.getByPlaceholder("댓글 입력…")).toBeVisible(); + // 모달 닫기 → 칸반 카드에 작업명 표시 + await page.keyboard.press("Escape"); + await expect(page.getByPlaceholder("댓글 입력…")).toBeHidden(); + await expect(page.getByText(title).first()).toBeVisible(); + expect(errs, errs.join("\n")).toHaveLength(0); +}); diff --git a/e2e/spin.spec.ts b/e2e/spin.spec.ts new file mode 100644 index 0000000..a570110 --- /dev/null +++ b/e2e/spin.spec.ts @@ -0,0 +1,102 @@ +import { test, expect, Page } from "@playwright/test"; + +// 로컬 DEV_AUTH: 기본 로드는 super-admin(관리자), '/?as=user' 로드는 비관리자(김주임). +const API = "http://localhost:8380/api"; +const stamp = Date.now().toString().slice(-6); + +// 콘솔/페이지 에러를 모아 테스트에서 검증(치명적 JS 에러 감지). +function trackErrors(page: Page): string[] { + const errs: string[] = []; + page.on("pageerror", (e) => errs.push("pageerror: " + e.message)); + page.on("console", (m) => { if (m.type() === "error") errs.push("console: " + m.text()); }); + return errs; +} + +test.afterAll(async ({ request }) => { + // E2E로 만든 프로젝트/일정 정리 + const projs = await (await fetch(`${API}/projects`)).json().catch(() => []); + for (const p of Array.isArray(projs) ? projs : []) { + if (typeof p.name === "string" && p.name.includes("E2E")) { + await fetch(`${API}/projects/${p.id}`, { method: "DELETE" }).catch(() => {}); + } + } + const evs = await (await fetch(`${API}/calendar/events`)).json().catch(() => []); + for (const e of Array.isArray(evs) ? evs : []) { + if (typeof e.title === "string" && e.title.includes("E2E")) { + await fetch(`${API}/calendar/events/${e.id}`, { method: "DELETE" }).catch(() => {}); + } + } +}); + +test.describe("관리자(super-admin)", () => { + test("대시보드 + 사이드바 네비", async ({ page }) => { + const errs = trackErrors(page); + await page.goto("/"); + await expect(page.getByText(/안녕하세요/)).toBeVisible(); + await expect(page.getByText("내 이슈")).toBeVisible(); + // 사이드바: 일반 + 관리자 메뉴 + await expect(page.getByRole("link", { name: "프로젝트", exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "캘린더" })).toBeVisible(); + await expect(page.getByRole("link", { name: "프로젝트 관리" })).toBeVisible(); + await expect(page.getByRole("link", { name: "회계" })).toBeVisible(); + expect(errs, errs.join("\n")).toHaveLength(0); + }); + + test("쪽지함 명칭", async ({ page }) => { + await page.goto("/inbox"); + await expect(page.getByRole("heading", { name: "쪽지함" })).toBeVisible(); + }); + + test("캘린더 페이지 + 일정 생성", async ({ page }) => { + const errs = trackErrors(page); + await page.goto("/calendar"); + await expect(page.getByRole("heading", { name: "캘린더" })).toBeVisible(); + await page.getByRole("button", { name: "일정 추가" }).click(); + const title = `E2E일정-${stamp}`; + await page.getByPlaceholder("예: 고객 미팅").fill(title); + // 분류/시작일은 기본값(기타/오늘) 사용 + await page.getByRole("button", { name: "추가", exact: true }).click(); + await expect(page.getByTitle(new RegExp(title))).toBeVisible(); + expect(errs, errs.join("\n")).toHaveLength(0); + }); + + test("프로젝트 생성(모달) → 목록 반영", async ({ page }) => { + await page.goto("/admin/projects"); + await expect(page.getByRole("heading", { name: "프로젝트 관리" })).toBeVisible(); + await page.getByRole("button", { name: "프로젝트 생성" }).click(); + const name = `E2E프로젝트-${stamp}`; + await page.getByPlaceholder("예: CardioScan FDA 510(k)").fill(name); + // 첫 번째 select = 업체: 첫 실제 항목 선택(생성 버튼 활성 조건) + await page.locator(".fixed select").first().selectOption({ index: 1 }); + await page.getByRole("button", { name: "생성", exact: true }).click(); + await expect(page.getByRole("link", { name: new RegExp(name) })).toBeVisible(); + }); + + test("검증: 프로젝트명 없으면 생성 비활성", async ({ page }) => { + await page.goto("/admin/projects"); + await page.getByRole("button", { name: "프로젝트 생성" }).click(); + await expect(page.getByRole("button", { name: "생성", exact: true })).toBeDisabled(); + }); +}); + +test.describe("일반 구성원(?as=user)", () => { + test("관리자 메뉴 숨김", async ({ page }) => { + await page.goto("/?as=user"); + await expect(page.getByText(/안녕하세요/)).toBeVisible(); + await expect(page.getByRole("link", { name: "캘린더" })).toBeVisible(); + await expect(page.getByRole("link", { name: "프로젝트 관리" })).toHaveCount(0); + await expect(page.getByRole("link", { name: "회계" })).toHaveCount(0); + await expect(page.getByRole("link", { name: "승인 관리" })).toHaveCount(0); + }); + + test("관리자 라우트 접근 시 대시보드로 리다이렉트", async ({ page }) => { + await page.goto("/admin/accounting?as=user"); + await expect(page).toHaveURL(/\/$|\/\?/); // '/' 로 리다이렉트 + await expect(page.getByText(/안녕하세요/)).toBeVisible(); + }); + + test("내 프로필 접근", async ({ page }) => { + await page.goto("/profile?as=user"); + await expect(page.getByRole("heading", { name: "내 프로필" })).toBeVisible(); + }); +}); diff --git a/package-lock.json b/package-lock.json index ec48816..a6d12d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "recharts": "^2.12.7" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -867,6 +868,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@remix-run/router": { "version": "1.23.3", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", @@ -2645,6 +2662,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/package.json b/package.json index 6a07aaa..e01f986 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "typecheck": "tsc -b --noEmit" + "typecheck": "tsc -b --noEmit", + "test:e2e": "playwright test" }, "dependencies": { "@dnd-kit/core": "^6.1.0", @@ -22,6 +23,7 @@ "recharts": "^2.12.7" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..4cae720 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "@playwright/test"; + +// 로컬 스택(docker: frontend 5380 + backend 8380, DEV_AUTH)에 대한 E2E. +// 스택은 이미 떠 있다고 가정(docker compose up -d). +export default defineConfig({ + testDir: "./e2e", + timeout: 30_000, + expect: { timeout: 10_000 }, + fullyParallel: false, + workers: 1, + retries: 0, + reporter: [["list"]], + use: { + baseURL: "http://localhost:5380", + headless: true, + viewport: { width: 1280, height: 900 }, + actionTimeout: 10_000, + ignoreHTTPSErrors: true, + }, +});