Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/web-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

6 changes: 6 additions & 0 deletions apps/web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 ",
Expand All @@ -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",
Expand Down
61 changes: 61 additions & 0 deletions apps/web/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
},
});
64 changes: 64 additions & 0 deletions apps/web/test/e2e/fixtures/auth.ts
Original file line number Diff line number Diff line change
@@ -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<Cookie, 'name' | 'value'>;

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<void> {
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<void> {
await context.addCookies([cookie('e2e_scenario', scenario)]);
}
92 changes: 92 additions & 0 deletions apps/web/test/e2e/fixtures/data.ts
Original file line number Diff line number Diff line change
@@ -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: [] };
}
Loading
Loading