diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01f8bba0..8aa7d3cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Get pnpm store directory id: pnpm-cache @@ -49,3 +49,72 @@ jobs: env: NEXT_PUBLIC_API_URL: ${{ vars.NEXT_PUBLIC_API_URL }} run: pnpm build + + e2e: + name: E2E (Playwright) + 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: 22 + + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: ${{ runner.os }}-pnpm- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Get Playwright version + id: playwright-version + run: echo "VERSION=$(pnpm --filter piki-web exec playwright --version | awk '{print $2}')" >> $GITHUB_OUTPUT + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.VERSION }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm --filter piki-web exec playwright install --with-deps chromium + + - name: Install Playwright OS deps + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: pnpm --filter piki-web exec playwright install-deps chromium + + # NEXT_PUBLIC_API_URL 은 E2E 목 스텁 주소로 고정 — rewrites destination 이 + # 빌드 타임에 박히므로 실서버 접근을 원천 차단한다 (e2e/consts.ts 의 MOCK_API_URL) + - name: Build web + env: + NEXT_PUBLIC_API_URL: http://127.0.0.1:4010 + run: pnpm build:web + + - name: Run E2E tests + run: pnpm test:e2e + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + apps/web/playwright-report/ + apps/web/test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 96fab4fe..f3dd8fd7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,11 @@ node_modules # Testing coverage +# Playwright +test-results/ +playwright-report/ +**/playwright/.auth/ + # Turbo .turbo diff --git a/apps/web/e2e/consts.ts b/apps/web/e2e/consts.ts new file mode 100644 index 00000000..a6459178 --- /dev/null +++ b/apps/web/e2e/consts.ts @@ -0,0 +1,8 @@ +/** + * SSR 목 스텁 서버 포트. + * playwright.config.ts 가 NEXT_PUBLIC_API_URL 을 이 주소로 강제해 + * SSR(serverApi/rewrites) 요청이 실서버 대신 스텁으로 향한다. + */ +export const MOCK_API_PORT = 4010; + +export const MOCK_API_URL = `http://127.0.0.1:${MOCK_API_PORT}`; diff --git a/apps/web/e2e/fixtures/mockApiFixture.ts b/apps/web/e2e/fixtures/mockApiFixture.ts new file mode 100644 index 00000000..73588755 --- /dev/null +++ b/apps/web/e2e/fixtures/mockApiFixture.ts @@ -0,0 +1,117 @@ +import { expect, test as base } from '@playwright/test'; + +import { ENDPOINTS } from '@/consts/api'; + +import { createApiError, createApiSuccess } from '../helpers/apiResponse'; + +type MockEntryT = { + method: string; + path: string; + status: number; + body: unknown; +}; + +type ApiErrorOptionsT = { + status?: number; + detail?: string; + code?: string; +}; + +export type ApiMockT = { + get: (path: string, data: T) => void; + post: (path: string, data: T) => void; + error: (method: 'GET' | 'POST' | 'PATCH' | 'DELETE', path: string, options?: ApiErrorOptionsT) => void; +}; + +/** + * page.route 기반 API 목킹 fixture. + * + * - `**\/api/v1/**` 라우트 하나를 선점하고 pathname(쿼리스트링 무시) + method 로 매칭한다. + * - 사용 규칙: `api.get(...)` 등록 후 `page.goto(...)` 호출. + * - 목킹되지 않은 /api/v1 요청은 500 으로 실패시키고 테스트 말미에 단언한다 — 누락 목 즉시 감지. + * - SSR(serverApi·RSC 레이아웃) 요청은 브라우저 발이 아니라 여기서 못 잡는다. + * globalSetup 이 띄우는 목 스텁 서버(e2e/setup/mockApiServer.ts)가 대신 응답한다. + */ +export const test = base.extend<{ api: ApiMockT }>({ + /** 2번째 인자는 Playwright fixture 의 use — react-hooks lint 오인을 피해 provide 로 명명 */ + api: async ({ page }, provide) => { + const entries: MockEntryT[] = []; + const unmocked: string[] = []; + + /** 401 시 axios interceptor 의 토큰 갱신 요청이 실패 루프를 타지 않도록 기본 제공 */ + entries.push({ + method: 'POST', + path: ENDPOINTS.AUTH_TOKEN_REFRESH, + status: 200, + body: createApiSuccess({ access_token: null, refresh_token: null }), + }); + + /** 헤더 알림 아이콘(AlarmHeaderIcon)이 전 페이지에서 호출 — 빈 알림 기본 제공 */ + entries.push({ + method: 'GET', + path: ENDPOINTS.NOTIFICATIONS, + status: 200, + body: { + ...createApiSuccess({ items: [], unreadCount: 0 }), + pageResponse: { nextCursor: null, hasNext: false }, + }, + }); + + await page.route('**/api/v1/**', route => { + const { pathname } = new URL(route.request().url()); + const method = route.request().method(); + const entry = [...entries].reverse().find(e => e.method === method && e.path === pathname); + + if (!entry) { + unmocked.push(`${method} ${pathname}`); + return route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify( + createApiError({ + status: 500, + code: 'E2E_UNMOCKED', + detail: `목킹되지 않은 요청: ${method} ${pathname}`, + }) + ), + }); + } + + return route.fulfill({ + status: entry.status, + contentType: 'application/json', + body: JSON.stringify(entry.body), + }); + }); + + /** 알림 SSE(Next Route Handler — /api/v1 아님): 빈 스트림으로 응답해 upstream 접근 차단 */ + await page.route('**/api/notifications/subscribe', route => + route.fulfill({ status: 200, headers: { 'content-type': 'text/event-stream' }, body: '' }) + ); + + /** + * dev 전용 react-grab 오버레이(layout.tsx의 unpkg 스크립트) 차단. + * - 주입된 오버레이가 trace 스냅샷 페인트를 통째로 막아 뷰어가 빈 화면이 된다 + * - 테스트 중 외부 CDN 의존 제거 (결정성). 프로덕션 빌드(CI)에는 원래 없음 + */ + await page.route('https://unpkg.com/**', route => route.abort()); + + await provide({ + get: (path, data) => + entries.push({ method: 'GET', path, status: 200, body: createApiSuccess(data) }), + post: (path, data) => + entries.push({ method: 'POST', path, status: 200, body: createApiSuccess(data) }), + error: (method, path, options) => + entries.push({ + method, + path, + status: options?.status ?? 400, + body: createApiError(options), + }), + }); + + expect(unmocked, `목킹되지 않은 API 요청:\n${unmocked.join('\n')}`).toEqual([]); + }, +}); + +export { expect }; diff --git a/apps/web/e2e/helpers/apiResponse.ts b/apps/web/e2e/helpers/apiResponse.ts new file mode 100644 index 00000000..4c7f2afa --- /dev/null +++ b/apps/web/e2e/helpers/apiResponse.ts @@ -0,0 +1,27 @@ +import type { ApiErrorResponseT, ApiResponseT } from '@/types/api'; + +/** 팀 응답 규약 `{ status, data, detail, code }` 성공 래핑 */ +export const createApiSuccess = (data: T, status = 200): ApiResponseT => ({ + status, + data, + detail: '요청이 정상적으로 처리되었습니다.', + code: 'COMMON_SUCCESS', +}); + +type CreateApiErrorOptionsT = { + status?: number; + detail?: string; + code?: string; +}; + +/** 팀 응답 규약 에러 래핑 */ +export const createApiError = ({ + status = 400, + detail = 'E2E 목 에러 응답입니다.', + code = 'E2E_ERROR', +}: CreateApiErrorOptionsT = {}): ApiErrorResponseT => ({ + status, + data: null, + detail, + code, +}); diff --git a/apps/web/e2e/helpers/fakeJwt.ts b/apps/web/e2e/helpers/fakeJwt.ts new file mode 100644 index 00000000..56877f41 --- /dev/null +++ b/apps/web/e2e/helpers/fakeJwt.ts @@ -0,0 +1,13 @@ +const toBase64Url = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url'); + +/** + * 미들웨어(src/proxy.ts)의 isTokenValid는 서명 검증 없이 payload.exp만 확인한다. + * exp가 미래인 JWT 형태 문자열이면 미들웨어를 통과하고, + * 서버사이드 게스트 로그인(postGuestLoginServer)이 발생하지 않는다 — 네트워크 0회. + */ +export const createFakeJwt = (expiresInSeconds = 60 * 60) => + [ + toBase64Url({ alg: 'HS256', typ: 'JWT' }), + toBase64Url({ sub: 'e2e-guest', exp: Math.floor(Date.now() / 1000) + expiresInSeconds }), + 'e2e-fake-signature', + ].join('.'); diff --git a/apps/web/e2e/home.spec.ts b/apps/web/e2e/home.spec.ts new file mode 100644 index 00000000..6873d3bc --- /dev/null +++ b/apps/web/e2e/home.spec.ts @@ -0,0 +1,15 @@ +import { ENDPOINTS } from '@/consts/api'; + +import { expect, test } from './fixtures/mockApiFixture'; +import { MOCK_GUEST_ME } from './mocks/me'; +import { MOCK_TOURNAMENT_LIST } from './mocks/tournament'; + +test('홈에 진입하면 진행 중인 토너먼트 목록이 렌더링된다', async ({ page, api }) => { + api.get(ENDPOINTS.TOURNAMENTS, MOCK_TOURNAMENT_LIST); + api.get(ENDPOINTS.USER, MOCK_GUEST_ME); + + await page.goto('/home'); + + await expect(page.getByRole('heading', { name: '진행 중인 토너먼트' })).toBeVisible(); + await expect(page.getByText('E2E 토너먼트')).toBeVisible(); +}); diff --git a/apps/web/e2e/mocks/me.ts b/apps/web/e2e/mocks/me.ts new file mode 100644 index 00000000..b3990e09 --- /dev/null +++ b/apps/web/e2e/mocks/me.ts @@ -0,0 +1,10 @@ +import type { GuestUserT } from '@/types/user'; + +export const MOCK_GUEST_ME: GuestUserT = { + id: 'e2e-guest-id', + nickname: '피키게스트', + /** 외부 이미지 URL 금지 — next/image 가 서버사이드에서 fetch 해 목킹이 불가능하다 */ + profileImage: '', + identityType: 'GUEST', + email: null, +}; diff --git a/apps/web/e2e/mocks/tournament.ts b/apps/web/e2e/mocks/tournament.ts new file mode 100644 index 00000000..7a84dbd4 --- /dev/null +++ b/apps/web/e2e/mocks/tournament.ts @@ -0,0 +1,32 @@ +import type { GetTournamentPendingResponseT } from '@/app/tournament/[id]/_common/_types/tournamentResponse'; +import type { TournamentT } from '@/types/tournament'; + +export const MOCK_TOURNAMENT_LIST: TournamentT[] = [ + { + tournamentId: 1, + name: 'E2E 토너먼트', + status: 'PENDING', + createdAt: '2026-01-01T00:00:00Z', + /** 외부 이미지 URL 금지 — next/image 가 서버사이드에서 fetch 해 목킹이 불가능하다 */ + participantProfileImages: [], + }, +]; + +export const MOCK_TOURNAMENT_PENDING: GetTournamentPendingResponseT = { + tournamentId: 1, + name: 'E2E 토너먼트', + isOwner: true, + isRoot: true, + status: 'PENDING', + pending: { + ownerStarted: false, + inviteCode: 'E2ECODE', + /** 미래 시각으로 고정해 초대 만료 카운트다운이 항상 유효하도록 */ + inviteExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + /** status 가 PENDING/PROCESSING 인 아이템을 두지 않아 SSE 폴백 폴링이 돌지 않게 함 */ + items: [], + participants: [ + { userId: 'e2e-guest-id', nickname: '피키게스트', itemCount: 0, profileImage: '' }, + ], + }, +}; diff --git a/apps/web/e2e/setup/auth.setup.ts b/apps/web/e2e/setup/auth.setup.ts new file mode 100644 index 00000000..f61624e3 --- /dev/null +++ b/apps/web/e2e/setup/auth.setup.ts @@ -0,0 +1,35 @@ +import { test as setup } from '@playwright/test'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { createFakeJwt } from '../helpers/fakeJwt'; + +const AUTH_FILE = path.join(__dirname, '../../playwright/.auth/guest.json'); + +const TOKEN_TTL_SECONDS = 60 * 60; + +setup('게스트 storageState 생성', async () => { + const token = createFakeJwt(TOKEN_TTL_SECONDS); + const expires = Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS; + + const createCookie = (name: string) => ({ + name, + value: token, + domain: 'localhost', + path: '/', + expires, + httpOnly: false, + secure: false, + sameSite: 'Lax' as const, + }); + + fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true }); + fs.writeFileSync( + AUTH_FILE, + JSON.stringify( + { cookies: [createCookie('access_token'), createCookie('refresh_token')], origins: [] }, + null, + 2 + ) + ); +}); diff --git a/apps/web/e2e/setup/globalSetup.ts b/apps/web/e2e/setup/globalSetup.ts new file mode 100644 index 00000000..acb15a24 --- /dev/null +++ b/apps/web/e2e/setup/globalSetup.ts @@ -0,0 +1,16 @@ +import { MOCK_API_PORT } from '../consts'; +import { startMockApiServer } from './mockApiServer'; + +/** 테스트 러너 프로세스에서 SSR 목 스텁 서버를 띄우고, 종료 시 함께 내린다 */ +const globalSetup = async () => { + /** null 이면 다른 세션(UI 모드 등)이 띄운 기존 스텁을 재사용 — 내리지 않는다 */ + const server = await startMockApiServer(MOCK_API_PORT); + + return () => + new Promise(resolve => { + if (!server) return resolve(); + server.close(() => resolve()); + }); +}; + +export default globalSetup; diff --git a/apps/web/e2e/setup/mockApiServer.ts b/apps/web/e2e/setup/mockApiServer.ts new file mode 100644 index 00000000..3b8b947d --- /dev/null +++ b/apps/web/e2e/setup/mockApiServer.ts @@ -0,0 +1,63 @@ +import http from 'node:http'; + +import { ENDPOINTS } from '@/consts/api'; + +import { createApiError, createApiSuccess } from '../helpers/apiResponse'; +import { MOCK_GUEST_ME } from '../mocks/me'; +import { MOCK_TOURNAMENT_LIST, MOCK_TOURNAMENT_PENDING } from '../mocks/tournament'; + +/** + * SSR(serverApi·RSC 레이아웃) 발 API 요청을 받아주는 목 스텁 서버 — node:http 내장만 사용. + * + * page.route 는 브라우저 발 요청만 가로챌 수 있는데, tournament/[id]/layout.tsx 처럼 + * RSC 가 직접 await 하는 요청(접근 권한 확인)은 서버에서 나가므로 이 스텁이 응답한다. + * 목 데이터는 e2e/mocks 상수를 page.route fixture 와 공유한다 — 단일 소스. + * + * 등록되지 않은 경로는 404 에러 규약으로 응답한다 — prefetchQuery 는 조용히 실패하고 + * 클라이언트 재요청이 page.route 목으로 처리되므로 결정성이 유지된다. + */ +const SSR_MOCK_ROUTES: Record = { + [`GET ${ENDPOINTS.USER}`]: createApiSuccess(MOCK_GUEST_ME), + [`GET ${ENDPOINTS.TOURNAMENTS}`]: createApiSuccess(MOCK_TOURNAMENT_LIST), + [`GET ${ENDPOINTS.TOURNAMENT(1)}`]: createApiSuccess(MOCK_TOURNAMENT_PENDING), + [`GET ${ENDPOINTS.NOTIFICATIONS}`]: { + ...createApiSuccess({ items: [], unreadCount: 0 }), + pageResponse: { nextCursor: null, hasNext: false }, + }, +}; + +/** + * 스텁 서버를 띄운다. 이미 다른 세션(UI 모드 등)이 같은 포트에 띄워둔 경우 + * null 을 반환하고 기존 서버를 재사용한다 — UI 모드를 켜둔 채 CLI 실행 시 크래시 방지. + */ +export const startMockApiServer = (port: number) => + new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const pathname = new URL(req.url ?? '/', `http://127.0.0.1:${port}`).pathname; + const body = SSR_MOCK_ROUTES[`${req.method} ${pathname}`]; + + if (!body) { + res.writeHead(404, { 'content-type': 'application/json' }); + res.end( + JSON.stringify( + createApiError({ + status: 404, + code: 'E2E_SSR_UNMOCKED', + detail: `SSR 목 스텁에 등록되지 않은 요청: ${req.method} ${pathname}`, + }) + ) + ); + return; + } + + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }); + + server.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') resolve(null); + else reject(error); + }); + + server.listen(port, '127.0.0.1', () => resolve(server)); + }); diff --git a/apps/web/e2e/tournamentCreate.spec.ts b/apps/web/e2e/tournamentCreate.spec.ts new file mode 100644 index 00000000..4e4f333d --- /dev/null +++ b/apps/web/e2e/tournamentCreate.spec.ts @@ -0,0 +1,27 @@ +import { ENDPOINTS } from '@/consts/api'; + +import { expect, test } from './fixtures/mockApiFixture'; +import { MOCK_GUEST_ME } from './mocks/me'; +import { MOCK_TOURNAMENT_LIST, MOCK_TOURNAMENT_PENDING } from './mocks/tournament'; + +/** + * 홈 → 카드 클릭 → 준비 페이지 진입 플로우. + * tournament/[id]/layout.tsx 의 서버사이드 접근 권한 조회(getTournament 직접 await)는 + * globalSetup 의 목 스텁 서버가, 브라우저 발 요청은 page.route 목이 응답한다. + */ +test('홈에서 토너먼트 카드를 누르면 준비 페이지로 이동하고 토너먼트 이름이 렌더링된다', async ({ + page, + api, +}) => { + api.get(ENDPOINTS.TOURNAMENTS, MOCK_TOURNAMENT_LIST); + api.get(ENDPOINTS.TOURNAMENT(1), MOCK_TOURNAMENT_PENDING); + api.get(ENDPOINTS.USER, MOCK_GUEST_ME); + + await page.goto('/home'); + + /** AUTHORIZED 라우트 — 가짜 게스트 JWT(storageState)로 미들웨어 통과를 함께 검증한다 */ + await page.getByRole('link', { name: 'E2E 토너먼트' }).click(); + + await expect(page).toHaveURL('/tournament/1/create'); + await expect(page.getByText('E2E 토너먼트')).toBeVisible(); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 12707aa6..d26875a2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,9 @@ "build": "next build", "start": "next start", "lint": "eslint", - "check-types": "next typegen && tsc --noEmit" + "check-types": "next typegen && tsc --noEmit", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@microsoft/fetch-event-source": "^2.0.1", @@ -37,6 +39,7 @@ }, "devDependencies": { "@piki/typescript-config": "workspace:*", + "@playwright/test": "^1.61.1", "@svgr/webpack": "^8.1.0", "@tailwindcss/postcss": "^4.2.2", "@types/matter-js": "^0.20.2", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 00000000..426096e6 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,52 @@ +import { defineConfig, devices } from '@playwright/test'; + +import { MOCK_API_URL } from './e2e/consts'; + +const BASE_URL = 'http://localhost:3000'; + +/** + * NEXT_PUBLIC_API_URL 을 로컬 목 스텁 주소로 강제해 실서버 접근을 차단한다(결정성 보장). + * - SSR(serverApi/RSC 레이아웃) 발 요청 → globalSetup 이 띄우는 목 스텁 서버가 응답 + * - 브라우저 발 요청 → mockApiFixture 의 page.route 목이 응답 + * + * 로컬 주의: 실서버 API로 이미 떠 있는 dev 서버를 reuseExistingServer로 재사용하면 + * SSR 요청이 실서버에 닿을 수 있다. 완전 결정적 실행은 dev 서버를 내리고 + * `NEXT_PUBLIC_API_URL=http://127.0.0.1:4010 pnpm build && CI=1 pnpm test:e2e` 사용. + */ +export default defineConfig({ + testDir: './e2e', + globalSetup: './e2e/setup/globalSetup.ts', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI + ? [['list'], ['html', { open: 'never' }]] + : [['list'], ['html', { open: 'on-failure' }]], + use: { + baseURL: BASE_URL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + /** 가짜 게스트 JWT로 storageState 생성 — 브라우저·네트워크 불필요 */ + { name: 'setup', testMatch: /.*\.setup\.ts$/ }, + { + name: 'mobile-chromium', + use: { + ...devices['iPhone 14'], + /** CI에 chromium만 설치하기 위한 override — 뷰포트/UA/터치는 iPhone 14 유지 */ + browserName: 'chromium', + storageState: 'playwright/.auth/guest.json', + }, + dependencies: ['setup'], + }, + ], + webServer: { + /** CI: 워크플로우 스텝에서 미리 빌드한 뒤 start만 수행 (빌드 실패 로그 가시성) */ + command: process.env.CI ? 'pnpm start' : 'pnpm dev', + url: BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { NEXT_PUBLIC_API_URL: MOCK_API_URL }, + }, +}); diff --git a/package.json b/package.json index 0e6230bc..f5f7d21b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "install:web": "pnpm --filter piki-web install", "dev:web": "pnpm --filter piki-web dev", "build:web": "pnpm --filter piki-web build", + "test:e2e": "pnpm --filter piki-web test:e2e", "install:app": "pnpm --filter piki-app install", "dev:app": "pnpm --filter piki-app start" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23cb2ea7..8fc15025 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,13 +217,13 @@ importers: version: 2.0.1 '@next/third-parties': specifier: ^16.2.9 - version: 16.2.9(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + version: 16.2.9(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) '@piki/core': specifier: workspace:* version: link:../../packages/core '@sentry/nextjs': specifier: ^10.63.0 - version: 10.63.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.108.3(postcss@8.5.8)) + version: 10.63.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.108.3(postcss@8.5.8)) '@tanstack/react-query': specifier: ^5.96.2 version: 5.96.2(react@19.1.0) @@ -253,7 +253,7 @@ importers: version: 0.20.0 next: specifier: 16.2.0 - version: 16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.1.11(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -288,6 +288,9 @@ importers: '@piki/typescript-config': specifier: workspace:* version: link:../../packages/typescript-config + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 '@svgr/webpack': specifier: ^8.1.0 version: 8.1.0(typescript@5.9.2) @@ -1930,6 +1933,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -5303,6 +5311,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} @@ -6695,6 +6708,16 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + 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 + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -10073,9 +10096,9 @@ snapshots: '@next/swc-win32-x64-msvc@16.2.0': optional: true - '@next/third-parties@16.2.9(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + '@next/third-parties@16.2.9(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': dependencies: - next: 16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 third-party-capital: 1.0.20 @@ -10150,6 +10173,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -11425,7 +11452,7 @@ snapshots: dependencies: '@sentry/core': 10.63.0 - '@sentry/nextjs@10.63.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.108.3(postcss@8.5.8))': + '@sentry/nextjs@10.63.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.108.3(postcss@8.5.8))': dependencies: '@opentelemetry/api': 1.9.1 '@rollup/plugin-commonjs': 28.0.1(rollup@4.62.2) @@ -11438,7 +11465,7 @@ snapshots: '@sentry/react': 10.63.0(react@19.1.0) '@sentry/vercel-edge': 10.63.0 '@sentry/webpack-plugin': 5.3.0(webpack@5.108.3(postcss@8.5.8)) - next: 16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) rollup: 4.62.2 stacktrace-parser: 0.1.11 transitivePeerDependencies: @@ -13962,6 +13989,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -15189,7 +15219,7 @@ snapshots: nested-error-stacks@2.0.1: {} - next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 16.2.0 '@swc/helpers': 0.5.15 @@ -15209,6 +15239,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.2.0 '@next/swc-win32-x64-msvc': 16.2.0 '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.61.1 babel-plugin-react-compiler: 1.0.0 sharp: 0.34.5 transitivePeerDependencies: @@ -15497,6 +15528,14 @@ snapshots: pkce-challenge@5.0.1: {} + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.12