Skip to content
Open
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
71 changes: 70 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Comment on lines +112 to +120

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

failure 발생하면 디스코드로 알림 보내주면 어떨까??
이거는 근데 discord-pr-bot 쪽 워크플로우를 건드려야하는 거긴 해

5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ node_modules
# Testing
coverage

# Playwright
test-results/
playwright-report/
**/playwright/.auth/

# Turbo
.turbo

Expand Down
8 changes: 8 additions & 0 deletions apps/web/e2e/consts.ts
Original file line number Diff line number Diff line change
@@ -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}`;
117 changes: 117 additions & 0 deletions apps/web/e2e/fixtures/mockApiFixture.ts
Original file line number Diff line number Diff line change
@@ -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: <T>(path: string, data: T) => void;
post: <T>(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) }),
Comment on lines +99 to +103

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

patch, delete가 없는데 이건 e2e 테스트 생성하면서 추가하면 될까? error는 get, post, delete, patch까지 다 대응되어 있는 것 같아서!

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 };
27 changes: 27 additions & 0 deletions apps/web/e2e/helpers/apiResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { ApiErrorResponseT, ApiResponseT } from '@/types/api';

/** 팀 응답 규약 `{ status, data, detail, code }` 성공 래핑 */
export const createApiSuccess = <T>(data: T, status = 200): ApiResponseT<T> => ({
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,
});
13 changes: 13 additions & 0 deletions apps/web/e2e/helpers/fakeJwt.ts
Original file line number Diff line number Diff line change
@@ -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('.');
15 changes: 15 additions & 0 deletions apps/web/e2e/home.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
10 changes: 10 additions & 0 deletions apps/web/e2e/mocks/me.ts
Original file line number Diff line number Diff line change
@@ -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: '',
Comment on lines +6 to +7

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이게 좀 고민이 된다 ~.. 빈 문자열로 처리한 이유는 합당한 거 같은데, Next/Image에서는 src가 빈 문자열이면 에러가 발생해서 테스트하다가 ui 상에 오류가 날 수도 있겠다는 생각!

BaseImage 컴포넌트 썼으면 미리 에러 핸들링 해두어서 문제 없을텐데, 내 기억에는 Next/Image 자체를 쓴 경우도 많았어서 문제가 될까봐 마음에 좀 걸리넹

Image

이런 느낌으로 실제 image url을 사용해보면 어때?!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아님 이런건 e2e 테스트 실제로 생성하면서 작업하는 게 나을까!

identityType: 'GUEST',
email: null,
};
32 changes: 32 additions & 0 deletions apps/web/e2e/mocks/tournament.ts
Original file line number Diff line number Diff line change
@@ -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: '' },
],
},
};
35 changes: 35 additions & 0 deletions apps/web/e2e/setup/auth.setup.ts
Original file line number Diff line number Diff line change
@@ -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
)
);
});
16 changes: 16 additions & 0 deletions apps/web/e2e/setup/globalSetup.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +6 to +7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Files ==\n'
git ls-files 'apps/web/e2e/setup/*' 'apps/web/e2e/**/*mock*' 'apps/web/e2e/**/*setup*' | sort

printf '\n== Outline: globalSetup ==\n'
ast-grep outline apps/web/e2e/setup/globalSetup.ts --view expanded

printf '\n== Outline: mockApiServer ==\n'
ast-grep outline apps/web/e2e/setup/mockApiServer.ts --view expanded

printf '\n== Relevant excerpts ==\n'
sed -n '1,220p' apps/web/e2e/setup/globalSetup.ts | cat -n
printf '\n--- mockApiServer.ts ---\n'
sed -n '1,260p' apps/web/e2e/setup/mockApiServer.ts | cat -n

Repository: TeamPiKi/PiKi-Client

Length of output: 3846


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== MOCK_API_PORT references ==\n'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/.next/**' 'MOCK_API_PORT|startMockApiServer\(' apps/web

printf '\n== consts file ==\n'
sed -n '1,220p' apps/web/e2e/consts.ts | cat -n

printf '\n== e2e setup files ==\n'
sed -n '1,220p' apps/web/e2e/setup/auth.setup.ts | cat -n
printf '\n--- fixture ---\n'
sed -n '1,240p' apps/web/e2e/fixtures/mockApiFixture.ts | cat -n

printf '\n== package scripts mentioning e2e or ui mode ==\n'
sed -n '1,240p' package.json | cat -n

Repository: TeamPiKi/PiKi-Client

Length of output: 8127


기존 서버 재사용 전에 신원을 확인하세요
EADDRINUSE를 그대로 null로 처리하면 4010을 점유한 stale/unrelated 프로세스도 목 서버로 간주됩니다. 헬스체크나 식별 응답이 맞을 때만 재사용하고, 그렇지 않으면 실패시키는 편이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/e2e/setup/globalSetup.ts` around lines 6 - 7, Update the server
reuse logic around startMockApiServer so an EADDRINUSE/null result is accepted
only after a health check or identifying response confirms the process is the
expected mock API server; otherwise fail setup instead of reusing the process.
Preserve reuse for valid existing mock servers and keep the normal newly-started
server path unchanged.


return () =>
new Promise<void>(resolve => {
if (!server) return resolve();
server.close(() => resolve());
});
};

export default globalSetup;
Loading
Loading