diff --git a/.github/workflows/web-test.yml b/.github/workflows/web-test.yml index a884f0c..a2f8973 100644 --- a/.github/workflows/web-test.yml +++ b/.github/workflows/web-test.yml @@ -37,3 +37,37 @@ jobs: - name: Run unit tests run: pnpm --filter web test + + e2e: + name: E2E Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright Browsers + run: pnpm --filter web exec playwright install --with-deps + + - name: Run Playwright tests + run: pnpm --filter web test:e2e + + - name: Upload Playwright Report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: apps/web/test/e2e/.report/ + retention-days: 30 + diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 5ef6a52..89e6cca 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -39,3 +39,9 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Playwright E2E +/test/e2e/.report +/test/e2e/.results +/playwright-report +/test-results diff --git a/apps/web/package.json b/apps/web/package.json index fdf6654..6e4f3d8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,6 +9,10 @@ "test": "vitest run", "test:watch": "vitest", "test:cov": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:report": "playwright show-report test/e2e/.report", "lint": "biome lint", "lint:fix": "biome lint --write", "format": "biome format ", @@ -32,6 +36,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.10", + "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..8479748 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,61 @@ +import { defineConfig, devices } from '@playwright/test'; +import { API_BASE_URL, WEB_BASE_URL, WEB_PORT } from './test/e2e/fixtures/ports'; + +/** + * Web 단독 E2E (Playwright). 설계: test/e2e/DESIGN.md + * + * 구성: + * - globalSetup이 Mock API 서버를 :3101에 기동 (실 NestJS :3001과 충돌 회피). + * - webServer가 E2E API URL로 빌드 후 `next start`를 :3100에 기동 (dev 서버 3000과 분리). + * - 서버사이드 fetch(RSC/proxy)와 클라이언트 axios 모두 :3101 스텁이 응답. + * + * 포트는 test/e2e/fixtures/ports.ts에서 단일 관리(E2E_WEB_PORT/E2E_API_PORT로 override). + * NEXT_PUBLIC_API_URL은 빌드 시점에 클라이언트 번들로 인라인되므로 webServer가 직접 빌드한다 + * (포트만 런타임에 바꾸면 브라우저 요청은 옛 포트로 가는 함정을 방지). + */ +export default defineConfig({ + testDir: './test/e2e/tests', + outputDir: './test/e2e/.results', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [['list'], ['html', { outputFolder: './test/e2e/.report', open: 'never' }]], + + globalSetup: './test/e2e/global-setup.ts', + globalTeardown: './test/e2e/global-teardown.ts', + + use: { + baseURL: WEB_BASE_URL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + { name: 'webkit', use: { ...devices['Desktop Safari'] } }, + // 반응형 전용 스위트는 @responsive 태그로 모바일 뷰포트에서 실행 + { + name: 'mobile-chromium', + use: { ...devices['Pixel 5'] }, + grep: /@responsive/, + }, + { + name: 'mobile-webkit', + use: { ...devices['iPhone 13'] }, + grep: /@responsive/, + }, + ], + + webServer: { + // 클라이언트 번들에 E2E API URL을 인라인하기 위해 빌드부터 수행 + command: `pnpm build && pnpm start --port ${WEB_PORT}`, + url: WEB_BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + env: { + NEXT_PUBLIC_API_URL: API_BASE_URL, + }, + }, +}); diff --git a/apps/web/test/e2e/fixtures/auth.ts b/apps/web/test/e2e/fixtures/auth.ts new file mode 100644 index 0000000..5d5b994 --- /dev/null +++ b/apps/web/test/e2e/fixtures/auth.ts @@ -0,0 +1,64 @@ +/** + * 인증/세션 쿠키 헬퍼. + * + * proxy.ts는 access_token/refresh_token 쿠키로 /dashboard·/auth를 가드. + * 서버사이드 fetch(server.ts)는 전체 쿠키를 그대로 forward하므로 `e2e_scenario` + * 쿠키로 Mock 서버의 응답 시나리오까지 제어 + */ +import type { BrowserContext, Cookie } from '@playwright/test'; +import type { Scenario } from './mock-server'; + +// 쿠키 도메인용 호스트(포트 무관). baseURL/포트는 ports.ts에서 단일 관리. +const WEB_HOST = 'localhost'; + +type CookieSpec = Pick; + +function cookie(name: string, value: string): Cookie { + return { + name, + value, + domain: WEB_HOST, + path: '/', + expires: -1, + httpOnly: false, + secure: false, + sameSite: 'Lax', + }; +} + +/** 완전 인증 상태: access + refresh 둘 다 보유 */ +export function authedCookies(): CookieSpec[] { + return [ + { name: 'access_token', value: 'e2e-access' }, + { name: 'refresh_token', value: 'e2e-refresh' }, + ]; +} + +/** refresh만 보유: proxy가 서버사이드 사일런트 리프레시를 시도하는 상태 */ +export function refreshOnlyCookies(): CookieSpec[] { + return [{ name: 'refresh_token', value: 'e2e-refresh' }]; +} + +/** 만료된 refresh: 사일런트 리프레시 실패 → /auth 리다이렉트 + 쿠키 삭제 */ +export function expiredRefreshCookies(): CookieSpec[] { + return [{ name: 'refresh_token', value: 'expired-refresh' }]; +} + +/** + * 컨텍스트에 쿠키를 주입. scenario를 넘기면 Mock 서버 응답 분기용 + * `e2e_scenario` 쿠키도 함께 세팅 + */ +export async function setCookies( + context: BrowserContext, + specs: CookieSpec[], + scenario?: Scenario, +): Promise { + const cookies = specs.map((s) => cookie(s.name, s.value)); + if (scenario) cookies.push(cookie('e2e_scenario', scenario)); + await context.addCookies(cookies); +} + +/** 시나리오 쿠키만 단독 세팅 (미인증 상태 + 특정 응답 분기 검증용) */ +export async function setScenario(context: BrowserContext, scenario: Scenario): Promise { + await context.addCookies([cookie('e2e_scenario', scenario)]); +} diff --git a/apps/web/test/e2e/fixtures/data.ts b/apps/web/test/e2e/fixtures/data.ts new file mode 100644 index 0000000..1f37e33 --- /dev/null +++ b/apps/web/test/e2e/fixtures/data.ts @@ -0,0 +1,92 @@ +import type { DashboardPeriod, DashboardResponse, HeatmapResponse } from '@dookmark/analytics'; + +export function fullDashboard(period: DashboardPeriod = 30): DashboardResponse { + return { + period, + cards: { + totalBookmarks: 128, + favorites: 17, + totalVisits: 1432, + deltas: { bookmarks: 12, favorites: null, visits: -4 }, + }, + activityTrend: [ + { month: '2026-01', added: 8, visits: 40 }, + { month: '2026-02', added: 12, visits: 65 }, + { month: '2026-03', added: 6, visits: 52 }, + { month: '2026-04', added: 20, visits: 110 }, + { month: '2026-05', added: 14, visits: 98 }, + { month: '2026-06', added: 9, visits: 77 }, + ], + browserDistribution: [ + { browser: 'CHROME', count: 80, percent: 62 }, + { browser: 'FIREFOX', count: 28, percent: 22 }, + { browser: 'SAFARI', count: 14, percent: 11 }, + { browser: 'EDGE', count: 6, percent: 5 }, + ], + popularBookmarks: [ + { id: 'p1', title: 'MDN Web Docs', url: 'https://developer.mozilla.org', visits: 240 }, + { id: 'p2', title: 'Playwright', url: 'https://playwright.dev', visits: 180 }, + { id: 'p3', title: 'Next.js', url: 'https://nextjs.org', visits: 150 }, + { id: 'p4', title: 'React', url: 'https://react.dev', visits: 120 }, + { id: 'p5', title: 'TypeScript', url: 'https://www.typescriptlang.org', visits: 90 }, + ], + recentBookmarks: [ + { + id: 'r1', + title: 'Biome', + url: 'https://biomejs.dev', + createdAt: '2026-06-29T10:00:00.000Z', + isFavorite: true, + }, + { + id: 'r2', + title: 'pnpm', + url: 'https://pnpm.io', + createdAt: '2026-06-28T08:30:00.000Z', + isFavorite: false, + }, + ], + topFolders: [ + { id: 'f1', title: '개발', childCount: 42 }, + { id: 'f2', title: '디자인', childCount: 18 }, + { id: 'f3', title: '', childCount: 5 }, + ], + summary: { avgVisits: 11, totalFolders: 9 }, + }; +} + +/** 데이터가 전혀 없는(빈 상태) 대시보드 — EmptyRow 렌더 검증용 */ +export function emptyDashboard(period: DashboardPeriod = 30): DashboardResponse { + return { + period, + cards: { + totalBookmarks: 0, + favorites: 0, + totalVisits: 0, + deltas: { bookmarks: null, favorites: null, visits: null }, + }, + activityTrend: [], + browserDistribution: [], + popularBookmarks: [], + recentBookmarks: [], + topFolders: [], + summary: { avgVisits: 0, totalFolders: 0 }, + }; +} + +/** 채워진 히트맵 응답 */ +export function fullHeatmap(year: number): HeatmapResponse { + return { + year, + days: [ + { date: `${year}-01-05`, total: 4, create: 2, edit: 1, delete: 0, visit: 1 }, + { date: `${year}-03-12`, total: 9, create: 3, edit: 2, delete: 1, visit: 3 }, + { date: `${year}-06-20`, total: 6, create: 1, edit: 0, delete: 0, visit: 5 }, + ], + }; +} + +/** 빈 히트맵 응답 */ +export function emptyHeatmap(year: number): HeatmapResponse { + return { year, days: [] }; +} diff --git a/apps/web/test/e2e/fixtures/mock-server.ts b/apps/web/test/e2e/fixtures/mock-server.ts new file mode 100644 index 0000000..c9439dd --- /dev/null +++ b/apps/web/test/e2e/fixtures/mock-server.ts @@ -0,0 +1,198 @@ +/** + * 경량 Mock API 서버 (NestJS 백엔드 스텁). 기본 :3101 — 실 백엔드 :3001과 분리. + * + * 웹 앱은 두 경로로 백엔드를 호출한다: + * - 서버사이드: 대시보드 RSC(server.ts)·proxy.ts 미들웨어 → Next 서버에서 fetch + * - 클라이언트사이드: 브라우저 axios(api-client) → 로그인/회원가입 등 + * 둘 다 이 단일 스텁이 결정적으로 응답한다. 실 NestJS/DB/docker 불필요(진짜 웹 단독). + * + * 시나리오 제어: 브라우저가 `e2e_scenario` 쿠키를 세팅하면 + * - server.ts가 `cookieStore.toString()`으로 전체 쿠키를 그대로 forward → 스텁이 읽어 분기 + * - 클라이언트 요청도 withCredentials로 동일 쿠키 동봉 + */ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { emptyDashboard, emptyHeatmap, fullDashboard, fullHeatmap } from './data'; +import { API_PORT, WEB_BASE_URL } from './ports'; + +export type Scenario = 'full' | 'empty' | 'unauthorized' | 'fallback'; + +/** + * 브라우저(axios) 요청인지 서버사이드(RSC/proxy fetch) 요청인지 구분한다. + * 서버사이드 fetch는 브라우저 UA(Mozilla...)를 보내지 않으므로 이것으로 분기. + * → 'fallback' 시나리오에서 "서버는 401, 클라이언트는 200"을 모사해 + * DashboardClientFallback의 사일런트 리프레시 복구 경로를 검증한다. + */ +function isBrowserRequest(req: IncomingMessage): boolean { + return (req.headers['user-agent'] ?? '').includes('Mozilla'); +} + +function parseCookies(req: IncomingMessage): Record { + const header = req.headers.cookie ?? ''; + const out: Record = {}; + for (const part of header.split(';')) { + const idx = part.indexOf('='); + if (idx === -1) continue; + const k = part.slice(0, idx).trim(); + const v = part.slice(idx + 1).trim(); + if (k) out[k] = decodeURIComponent(v); + } + return out; +} + +function scenarioOf(req: IncomingMessage): Scenario { + const raw = parseCookies(req).e2e_scenario; + if (raw === 'empty' || raw === 'unauthorized' || raw === 'fallback') return raw; + return 'full'; +} + +/** analytics 응답의 인증 상태를 시나리오·요청출처에 따라 결정 */ +function isUnauthorized(scenario: Scenario, req: IncomingMessage): boolean { + if (scenario === 'unauthorized') return true; + // fallback: 서버사이드(RSC)만 401 → 클라이언트 폴백이 재페치로 복구 + if (scenario === 'fallback') return !isBrowserRequest(req); + return false; +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve) => { + let data = ''; + req.on('data', (chunk) => { + data += chunk; + }); + req.on('end', () => resolve(data)); + }); +} + +function json( + res: ServerResponse, + status: number, + body: unknown, + headers: Record = {}, +) { + res.writeHead(status, { 'content-type': 'application/json', ...headers }); + res.end(JSON.stringify(body)); +} + +// 로그인/리프레시 성공 시 발급하는 토큰 쿠키 (Set-Cookie). 동일 오리진이 아닐 수 있어 +// 가드 테스트는 context.addCookies로 직접 세팅하지만, 응답 형식을 사실적으로 유지한다. +const TOKEN_COOKIES = [ + 'access_token=e2e-access; Path=/; HttpOnly; SameSite=Lax', + 'refresh_token=e2e-refresh; Path=/; HttpOnly; SameSite=Lax', +]; + +function setCookies(res: ServerResponse, cookies: string[]) { + res.setHeader('set-cookie', cookies); +} + +async function handle(req: IncomingMessage, res: ServerResponse) { + const url = new URL(req.url ?? '/', 'http://localhost:3001'); + const path = url.pathname; + const method = req.method ?? 'GET'; + const scenario = scenarioOf(req); + + // CORS: 웹(:3100)과 Mock(:3101)은 cross-origin이라, 브라우저 axios 호출(withCredentials)이 + // 응답을 읽으려면 Origin 에코 + credentials 허용이 필요하다. 자격증명 모드라 '*'는 불가. + const origin = req.headers.origin; + if (origin) { + res.setHeader('access-control-allow-origin', origin); + res.setHeader('access-control-allow-credentials', 'true'); + } + // application/json POST 등은 사전(preflight) OPTIONS를 보낸다. + if (method === 'OPTIONS') { + res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS'); + res.setHeader('access-control-allow-headers', 'content-type,authorization'); + res.writeHead(204); + return res.end(); + } + + // ---- analytics (서버사이드 RSC + 클라이언트 폴백) ---- + if (path === '/analytics/dashboard') { + if (isUnauthorized(scenario, req)) return json(res, 401, { message: 'Unauthorized' }); + const period = Number(url.searchParams.get('period')) || 30; + const safePeriod = ([7, 30, 90, 365] as const).includes(period as 7) ? period : 30; + const body = + scenario === 'empty' ? emptyDashboard(safePeriod as 30) : fullDashboard(safePeriod as 30); + return json(res, 200, body); + } + + if (path === '/analytics/heatmap') { + if (isUnauthorized(scenario, req)) return json(res, 401, { message: 'Unauthorized' }); + const year = Number(url.searchParams.get('year')) || new Date().getFullYear(); + const body = scenario === 'empty' ? emptyHeatmap(year) : fullHeatmap(year); + return json(res, 200, body); + } + + // ---- auth (proxy 미들웨어 / 브라우저) ---- + if (path === '/auth/refresh' && method === 'POST') { + const token = parseCookies(req).refresh_token ?? req.headers.authorization; + if (token?.includes('expired')) return json(res, 401, { message: 'refresh expired' }); + setCookies(res, TOKEN_COOKIES); + return json(res, 200, { access_token: 'e2e-access', refresh_token: 'e2e-refresh' }); + } + + if (path === '/auth/login' && method === 'POST') { + const body = await readBody(req); + const parsed = body ? (JSON.parse(body) as { email?: string; password?: string }) : {}; + // 잘못된 자격 증명 시나리오: password === 'wrongpass' + if (parsed.password === 'wrongpass') { + return json(res, 401, { message: '이메일 또는 비밀번호가 올바르지 않습니다.' }); + } + setCookies(res, TOKEN_COOKIES); + return json(res, 200, { access_token: 'e2e-access', refresh_token: 'e2e-refresh' }); + } + + if (path === '/auth/register' && method === 'POST') { + setCookies(res, TOKEN_COOKIES); + return json(res, 200, { access_token: 'e2e-access', refresh_token: 'e2e-refresh' }); + } + + if (path === '/auth/send-code' && method === 'POST') { + return json(res, 200, { ok: true }); + } + + if (path === '/auth/verify-code' && method === 'POST') { + const body = await readBody(req); + const parsed = body ? (JSON.parse(body) as { code?: string }) : {}; + if (parsed.code === '000000') + return json(res, 400, { message: '인증 코드가 올바르지 않습니다.' }); + return json(res, 200, { ok: true }); + } + + if (path === '/auth/token-sync' && method === 'POST') { + return json(res, 200, { access_token: 'e2e-access', refresh_token: 'e2e-refresh' }); + } + + if (path === '/auth/session') { + if (scenario === 'unauthorized') return json(res, 401, { message: 'Unauthorized' }); + return json(res, 200, { ok: true }); + } + + if (path === '/auth/logout' && method === 'POST') { + setCookies(res, ['access_token=; Path=/; Max-Age=0', 'refresh_token=; Path=/; Max-Age=0']); + return json(res, 200, { ok: true }); + } + + if (path === '/auth/google') { + // OAuth 시작 → 콜백으로 리다이렉트(모의) + res.writeHead(302, { location: `${WEB_BASE_URL}/auth/callback` }); + return res.end(); + } + + json(res, 404, { message: `mock: no route for ${method} ${path}` }); +} + +export function createMockServer(): Server { + return createServer((req, res) => { + handle(req, res).catch((err) => { + json(res, 500, { message: String(err) }); + }); + }); +} + +export function startMockServer(port = API_PORT): Promise { + const server = createMockServer(); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, () => resolve(server)); + }); +} diff --git a/apps/web/test/e2e/fixtures/ports.ts b/apps/web/test/e2e/fixtures/ports.ts new file mode 100644 index 0000000..7f3f98e --- /dev/null +++ b/apps/web/test/e2e/fixtures/ports.ts @@ -0,0 +1,5 @@ +export const WEB_PORT = Number(process.env.E2E_WEB_PORT) || 3100; +export const API_PORT = Number(process.env.E2E_API_PORT) || 3101; + +export const WEB_BASE_URL = `http://localhost:${WEB_PORT}`; +export const API_BASE_URL = `http://localhost:${API_PORT}`; diff --git a/apps/web/test/e2e/global-setup.ts b/apps/web/test/e2e/global-setup.ts new file mode 100644 index 0000000..0f3019b --- /dev/null +++ b/apps/web/test/e2e/global-setup.ts @@ -0,0 +1,20 @@ +/** + * Playwright globalSetup — webServer(next start)보다 먼저 실행 + * Mock API 서버를 :3001에 띄워 두면 이후 부팅되는 Next 서버가 서버사이드 fetch로 접근 가능 + * 서버 핸들은 globalThis에 보관해 globalTeardown에서 닫는다 + */ +import type { Server } from 'node:http'; +import { startMockServer } from './fixtures/mock-server'; +import { API_BASE_URL } from './fixtures/ports'; + +declare global { + // eslint-disable-next-line no-var + var __E2E_MOCK_SERVER__: Server | undefined; +} + +export default async function globalSetup() { + const server = await startMockServer(); + globalThis.__E2E_MOCK_SERVER__ = server; + // eslint-disable-next-line no-console + console.log(`[e2e] mock API server listening on ${API_BASE_URL}`); +} diff --git a/apps/web/test/e2e/global-teardown.ts b/apps/web/test/e2e/global-teardown.ts new file mode 100644 index 0000000..783e20b --- /dev/null +++ b/apps/web/test/e2e/global-teardown.ts @@ -0,0 +1,8 @@ +/** Mock API 서버 종료 */ +export default async function globalTeardown() { + const server = globalThis.__E2E_MOCK_SERVER__; + if (server) { + await new Promise((resolve) => server.close(() => resolve())); + globalThis.__E2E_MOCK_SERVER__ = undefined; + } +} diff --git a/apps/web/test/e2e/tests/auth.spec.ts b/apps/web/test/e2e/tests/auth.spec.ts new file mode 100644 index 0000000..dce260e --- /dev/null +++ b/apps/web/test/e2e/tests/auth.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from '@playwright/test'; +import { refreshOnlyCookies, setCookies, setScenario } from '../fixtures/auth'; + +/** + * B. 인증 (/auth) — 폼 상호작용 + 다단계 회원가입 + Mock API 분기 + 미들웨어 가드. + * 훅 단위 테스트가 못 하는 실제 폼/네비게이션/Suspense 동작을 검증. + */ + +test.describe('인증 페이지', () => { + test('로그인과 회원가입간의 탭을 전환한다', async ({ page }) => { + await page.goto('/auth'); + // 기본 로그인 탭: 이메일/비밀번호 입력 + await expect(page.locator('#login-email')).toBeVisible(); + + await page.getByRole('button', { name: '회원가입' }).click(); + await expect(page.getByRole('heading', { name: '이메일 인증코드 발송' })).toBeVisible(); + + await page.getByRole('button', { name: '로그인' }).click(); + await expect(page.locator('#login-email')).toBeVisible(); + }); + + test('비밀번호 show/hide 토글이 input type을 바꾼다', async ({ page }) => { + await page.goto('/auth'); + const pw = page.locator('#login-password'); + await pw.fill('secret123'); + await expect(pw).toHaveAttribute('type', 'password'); + + // 비밀번호 입력칸 우측 토글 버튼 (form 내 type=button) + await page.locator('#login-password').locator('..').getByRole('button').click(); + await expect(pw).toHaveAttribute('type', 'text'); + }); + + test('잘못된 자격 증명은 에러 피드백을 표시한다', async ({ page }) => { + await page.goto('/auth'); + await page.locator('#login-email').fill('user@example.com'); + await page.locator('#login-password').fill('wrongpass'); // Mock이 401 반환 + // 탭 버튼도 "로그인"이라 제출 버튼은 form 내 submit으로 특정 + await page.locator('form button[type="submit"]').click(); + + await expect(page.getByText('이메일 또는 비밀번호가 올바르지 않습니다.')).toBeVisible(); + await expect(page).toHaveURL(/\/auth$/); // 페이지 머무름 + }); + + test('회원가입 1→2→3 단계가 Mock 검증을 거쳐 진행된다', async ({ page }) => { + await page.goto('/auth'); + await page.getByRole('button', { name: '회원가입' }).click(); + + // 1단계: 이메일 → 인증코드 발송 + await page.locator('#register-email').fill('newbie@example.com'); + await page.getByRole('button', { name: '인증 코드 발송' }).click(); + + // 2단계: 인증코드 확인 화면 + await expect(page.getByRole('heading', { name: '인증코드 확인' })).toBeVisible(); + await expect( + page.getByText('이메일로 인증코드가 발송되었습니다.', { exact: false }), + ).toBeVisible(); + await page.locator('#verification-code').fill('123456'); + await page.getByRole('button', { name: '인증하기' }).click(); + + // 3단계: 비밀번호 설정 화면 + await expect(page.getByRole('heading', { name: '비밀번호 설정' })).toBeVisible(); + await expect(page.locator('#register-password')).toBeVisible(); + await expect(page.locator('#confirm-password')).toBeVisible(); + }); + + test('잘못된 인증코드는 에러를 표시하고 2단계에 머문다', async ({ page }) => { + await page.goto('/auth'); + await page.getByRole('button', { name: '회원가입' }).click(); + await page.locator('#register-email').fill('newbie@example.com'); + await page.getByRole('button', { name: '인증 코드 발송' }).click(); + + await page.locator('#verification-code').fill('000000'); // Mock이 400 반환 + await page.getByRole('button', { name: '인증하기' }).click(); + + await expect(page.getByText('인증 코드가 올바르지 않습니다.')).toBeVisible(); + await expect(page.getByRole('heading', { name: '인증코드 확인' })).toBeVisible(); + }); + + test('Google 로그인은 OAuth로 이동하고 콜백 검증 실패 시 에러 화면을 보여준다', async ({ + page, + context, + }) => { + // 콜백의 /auth/session을 401로 만들어 에러 화면을 고정(성공 시 즉시 /dashboard로 튕김) + await setScenario(context, 'unauthorized'); + await page.goto('/auth'); + await page.getByRole('button', { name: 'Google 계정으로 계속하기' }).first().click(); + + await expect(page).toHaveURL(/\/auth\/callback/); + await expect(page.getByRole('heading', { name: '인증 오류' })).toBeVisible(); + }); + + test('로그인된 사용자가 /auth 접근 시 /dashboard로 리다이렉트된다', async ({ page, context }) => { + await setCookies(context, refreshOnlyCookies(), 'full'); + await page.goto('/auth'); + await expect(page).toHaveURL(/\/dashboard/); + }); +}); diff --git a/apps/web/test/e2e/tests/dashboard.spec.ts b/apps/web/test/e2e/tests/dashboard.spec.ts new file mode 100644 index 0000000..44bce77 --- /dev/null +++ b/apps/web/test/e2e/tests/dashboard.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test'; +import { authedCookies, refreshOnlyCookies, setCookies } from '../fixtures/auth'; + +/** + * C. 대시보드 (/dashboard) — 미들웨어 가드 + 서버사이드 데이터 렌더 + 차트 + 빈 상태 + 폴백. + * RSC·proxy·chart.js canvas 등 jsdom 유닛이 닿지 못하는 영역을 검증. + */ + +test.describe('대시보드', () => { + test('미인증 사용자는 /auth로 가드된다', async ({ page }) => { + await page.goto('/dashboard'); + await expect(page).toHaveURL(/\/auth$/); + }); + + test('인증 사용자는 Mock 통계·리스트를 본다', async ({ page, context }) => { + await setCookies(context, authedCookies(), 'full'); + await page.goto('/dashboard'); + + await expect(page.getByRole('heading', { name: '대시보드' })).toBeVisible(); + // 통계 카드 (fullDashboard 값) + await expect(page.getByText('전체 북마크')).toBeVisible(); + await expect(page.getByText('128')).toBeVisible(); + await expect(page.getByText('즐겨찾기')).toBeVisible(); + // 리스트 위젯 + await expect(page.getByRole('link', { name: 'MDN Web Docs' })).toBeVisible(); + await expect(page.getByText('개발')).toBeVisible(); + }); + + test('차트(canvas)가 실제로 렌더된다', async ({ page, context }) => { + await setCookies(context, authedCookies(), 'full'); + await page.goto('/dashboard'); + + // 활동 추이(Line) + 브라우저 분포(Doughnut) = canvas 2개 이상 + await expect(page.locator('canvas').first()).toBeVisible(); + expect(await page.locator('canvas').count()).toBeGreaterThanOrEqual(2); + // 잔디 히트맵 위젯 (div 기반) + await expect(page.getByText('활동 잔디')).toBeVisible(); + }); + + test('빈 데이터는 빈 상태 메시지를 보여준다', async ({ page, context }) => { + await setCookies(context, authedCookies(), 'empty'); + await page.goto('/dashboard'); + + await expect(page.getByRole('heading', { name: '대시보드' })).toBeVisible(); + await expect(page.getByText('아직 방문 기록이 없어요')).toBeVisible(); // 인기 북마크 + await expect(page.getByText('폴더가 없어요')).toBeVisible(); // 주요 폴더 + // 차트 빈 상태 힌트 + await expect(page.getByText('북마크를 저장하거나 방문하면 추이가 그려져요')).toBeVisible(); + }); + + test('기간 필터 클릭이 ?period= URL을 바꾸고 RSC가 재페치한다', async ({ page, context }) => { + await setCookies(context, authedCookies(), 'full'); + await page.goto('/dashboard'); + + // webkit은 하이드레이션 직후 첫 클릭을 삼킬 수 있어 클릭+URL확인을 재시도한다 + await expect(async () => { + await page.getByRole('link', { name: '7일' }).click(); + await expect(page).toHaveURL(/period=7/, { timeout: 2000 }); + }).toPass({ timeout: 10_000 }); + await expect(page.getByRole('heading', { name: '대시보드' })).toBeVisible(); + }); + + test('서버 페치 실패 시 클라이언트 폴백이 사일런트 리프레시로 복구한다', async ({ + page, + context, + }) => { + // fallback: 서버사이드(RSC)만 401 → 브라우저 재페치는 200 → DashboardView 복구 + await setCookies(context, authedCookies(), 'fallback'); + await page.goto('/dashboard'); + + await expect(page.getByRole('heading', { name: '대시보드' })).toBeVisible(); + await expect(page.getByText('전체 북마크')).toBeVisible(); + }); + + test('완전 인증 실패 시 폴백이 세션 만료 화면을 보여준다', async ({ page, context }) => { + await setCookies(context, authedCookies(), 'unauthorized'); + await page.goto('/dashboard'); + + await expect(page.getByText('세션이 만료되었어요.')).toBeVisible(); + await expect(page.getByRole('link', { name: '다시 로그인' })).toBeVisible(); + }); + + test('access_token 없이 refresh_token만 있으면 서버 사일런트 리프레시 후 대시보드를 렌더한다', async ({ + page, + context, + }) => { + await setCookies(context, refreshOnlyCookies(), 'full'); + await page.goto('/dashboard'); + await expect(page.getByRole('heading', { name: '대시보드' })).toBeVisible(); + }); +}); diff --git a/apps/web/test/e2e/tests/landing.spec.ts b/apps/web/test/e2e/tests/landing.spec.ts new file mode 100644 index 0000000..bdc2856 --- /dev/null +++ b/apps/web/test/e2e/tests/landing.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from '@playwright/test'; + +/** + * A. 랜딩 페이지 (/) — 정적 렌더 + 네비게이션 + 테마 지속성. + * 유닛이 못 하는 실제 브라우저 렌더·라우팅·localStorage 지속을 검증. + */ + +test.describe('랜딩 페이지', () => { + test('히어로·피처 카드·푸터가 렌더된다', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('heading', { level: 1 })).toContainText('Dookmark'); + // 피처 카드 3종 + await expect(page.getByRole('heading', { name: '실시간 동기화' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'AI 지능형 요약 및 분류' })).toBeVisible(); + await expect(page.getByRole('heading', { name: '강력한 보안 및 쿠키 세션' })).toBeVisible(); + // 푸터 + await expect(page.getByText(/© 2026 Dookmark/)).toBeVisible(); + }); + + test('"시작하기"는 대시보드로 이동을 시도하고 미인증이라 /auth로 도착한다.', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: '시작하기', exact: true }).click(); + await expect(page).toHaveURL(/\/auth$/); + }); + + test('"무료로 시작하기"도 동일하게 /auth로 도착한다.', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: '무료로 시작하기' }).click(); + await expect(page).toHaveURL(/\/auth$/); + }); + + test('"소개 더보기"는 새 탭 외부 링크다', async ({ page }) => { + await page.goto('/'); + const link = page.getByRole('link', { name: '소개 더보기' }); + await expect(link).toHaveAttribute('href', 'https://github.com'); + await expect(link).toHaveAttribute('target', '_blank'); + await expect(link).toHaveAttribute('rel', /noopener/); + }); + + test('테마 토글이 html 클래스와 localStorage에 반영되고 리로드 후 유지된다', async ({ page }) => { + await page.goto('/'); + const html = page.locator('html'); + // 기본 다크 + await expect(html).toHaveClass(/dark/); + + await page.getByRole('button', { name: 'Toggle Theme' }).click(); + await expect(html).not.toHaveClass(/dark/); + expect(await page.evaluate(() => localStorage.getItem('theme'))).toBe('light'); + + // 리로드 후에도 라이트 유지 + await page.reload(); + await expect(page.locator('html')).not.toHaveClass(/dark/); + }); +}); diff --git a/apps/web/test/e2e/tests/responsive.spec.ts b/apps/web/test/e2e/tests/responsive.spec.ts new file mode 100644 index 0000000..248b7c4 --- /dev/null +++ b/apps/web/test/e2e/tests/responsive.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from '@playwright/test'; +import { authedCookies, setCookies } from '../fixtures/auth'; + +/** + * D. 반응형 — Tailwind 브레이크포인트로 그리드 열 수가 바뀌는지 실제 레이아웃에서 검증. + * @responsive 태그라 모바일 프로젝트에서도 실행된다(데스크톱 프로젝트도 포함). + * 뷰포트를 테스트 내부에서 직접 바꿔 어느 프로젝트에서 돌든 동일하게 단언한다. + */ + +const MOBILE = { width: 390, height: 844 }; +const DESKTOP = { width: 1280, height: 900 }; + +function trackCount(template: string): number { + // grid-template-columns 계산값의 트랙 개수 + return template.trim().split(/\s+/).filter(Boolean).length; +} + +test.describe('반응형 레이아웃', () => { + test('랜딩 피처 그리드: 모바일 1열 → 데스크톱 3열 @responsive', async ({ page }) => { + await page.goto('/'); + const grid = page.locator('main .grid').last(); + + await page.setViewportSize(MOBILE); + expect(trackCount(await grid.evaluate((el) => getComputedStyle(el).gridTemplateColumns))).toBe( + 1, + ); + + await page.setViewportSize(DESKTOP); + expect(trackCount(await grid.evaluate((el) => getComputedStyle(el).gridTemplateColumns))).toBe( + 3, + ); + }); + + test('대시보드 통계 그리드: 모바일 2열 → 데스크톱 4열 @responsive', async ({ page, context }) => { + await setCookies(context, authedCookies(), 'full'); + await page.goto('/dashboard'); + await expect(page.getByRole('heading', { name: '대시보드' })).toBeVisible(); + + // 첫 번째 그리드 = 통계 카드 (grid-cols-2 lg:grid-cols-4) + const statGrid = page.locator('main .grid').first(); + + await page.setViewportSize(MOBILE); + expect( + trackCount(await statGrid.evaluate((el) => getComputedStyle(el).gridTemplateColumns)), + ).toBe(2); + + await page.setViewportSize(DESKTOP); + expect( + trackCount(await statGrid.evaluate((el) => getComputedStyle(el).gridTemplateColumns)), + ).toBe(4); + }); +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index d873ceb..4ac0fa4 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -4,7 +4,6 @@ import { defineConfig } from 'vitest/config'; const rootDir = fileURLToPath(new URL('./', import.meta.url)); -// 유닛 + 통합 테스트 (jsdom). E2E(Playwright)는 별도 e2e/ 디렉토리에서 실행한다. export default defineConfig({ plugins: [react()], resolve: { @@ -16,8 +15,7 @@ export default defineConfig({ globals: true, setupFiles: ['./test/setup.ts'], include: ['{domain,ui,app,test}/**/*.{test,spec}.{ts,tsx}'], - exclude: ['node_modules', '.next', 'e2e/**'], - // 워크스페이스 패키지(main: ./src/index.ts 원시 TS)를 Vitest가 변환하도록 inline 처리 + exclude: ['node_modules', '.next', 'test/e2e/**'], server: { deps: { inline: [/@dookmark\//], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa503b3..32a5cd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,7 +288,7 @@ importers: version: 0.468.0(react@19.2.4) next: specifier: 16.2.1 - version: 16.2.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.1(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -302,6 +302,9 @@ importers: '@biomejs/biome': specifier: ^2.4.10 version: 2.4.10 + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 '@tailwindcss/postcss': specifier: ^4 version: 4.2.2 @@ -1542,6 +1545,11 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -3371,6 +3379,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4606,6 +4619,16 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -6954,6 +6977,10 @@ snapshots: '@pkgr/core@0.2.9': {} + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -8822,6 +8849,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -9909,7 +9939,7 @@ snapshots: neo-async@2.6.2: {} - next@16.2.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.1(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.1 '@swc/helpers': 0.5.15 @@ -9928,6 +9958,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.1 '@next/swc-win32-arm64-msvc': 16.2.1 '@next/swc-win32-x64-msvc': 16.2.1 + '@playwright/test': 1.61.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -10227,6 +10258,14 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} postcss@8.4.31: