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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ pnpm test:e2e:headed # 브라우저 표시

- **IMPORTANT:** `src/components/ui/`는 shadcn/ui 생성 파일 - 직접 수정 금지
- **IMPORTANT:** API 타입은 `src/types/api/`에 정의, 다른 곳에 중복 정의 금지
- **IMPORTANT:** 불필요한 배럴 파일(`index.ts`) 생성 금지 - 각 모듈을 직접 import할 것. 배럴 파일은 번들 사이즈 증가, 순환 참조, tree-shaking 방해의 원인이 됨
- 환경변수는 `scripts/ensure-env.sh`로 관리됨 - `.env` 직접 수정 금지
- 페이지네이션은 0-based index 사용 (백엔드 API 규격)

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.18
1.0.19
161 changes: 161 additions & 0 deletions e2e/pages/banner-detail.page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { type Page } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
import { BasePage } from './base.page';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

export class BannerDetailPage extends BasePage {
constructor(page: Page) {
super(page);
}

// Locators

readonly pageTitle = () => this.page.getByRole('heading', { level: 1 });

readonly backButton = () =>
this.page.locator('main button').first();

readonly saveButton = () =>
this.page.getByRole('button', { name: /등록|저장/ });

readonly deleteButton = () => this.page.getByRole('button', { name: '삭제' });

readonly loadingState = () => this.page.getByText('로딩 중...');

readonly nameInput = () => this.page.getByPlaceholder('배너명을 입력하세요');

readonly bannerTypeSelect = () =>
this.page.locator('button[role="combobox"]').filter({ hasText: /^\s*(설문조사|큐레이션|광고|제휴|기타|배너 타입 선택)\s*$/ });

readonly isActiveSwitch = () => this.page.locator('button#isActive');

readonly descriptionAInput = () => this.page.getByPlaceholder('첫 번째 줄 설명');

readonly descriptionBInput = () => this.page.getByPlaceholder('두 번째 줄 설명');

readonly textPositionSelect = () =>
this.page.locator('button[role="combobox"]').filter({ hasText: /좌측 상단|좌측 하단|우측 상단|우측 하단|중앙|텍스트 위치 선택/ });

readonly nameFontColorInput = () => this.page.locator('input#nameFontColor');

readonly descriptionFontColorInput = () => this.page.locator('input#descriptionFontColor');

readonly targetUrlInput = () => this.page.locator('input#targetUrl');

readonly isExternalUrlCheckbox = () => this.page.locator('button#isExternalUrl');

/** CURATION 타입에서만 표시 */
readonly curationSelect = () =>
this.page.locator('button[role="combobox"]').filter({ hasText: /큐레이션을 선택하세요/ });

readonly isAlwaysVisibleCheckbox = () => this.page.locator('button#isAlwaysVisible');

readonly startDateInput = () => this.page.locator('input#startDate');

readonly endDateInput = () => this.page.locator('input#endDate');

readonly imageFileInput = () => this.page.locator('input[type="file"][accept="image/*"]');

readonly uploadedImage = () => this.page.locator('img[alt="업로드된 이미지"]');

readonly deleteDialog = () => this.page.getByRole('alertdialog');

readonly confirmDeleteButton = () => this.deleteDialog().getByRole('button', { name: '삭제' });

// Actions

async gotoNew() {
await this.page.goto('/banners/new');
await this.waitForLoadingComplete();
}

async gotoDetail(id: number) {
await this.page.goto(`/banners/${id}`);
await this.waitForLoadingComplete();
}

async waitForLoadingComplete() {
await this.loadingState().waitFor({ state: 'hidden', timeout: 15000 }).catch(() => {});
}

async goBack() {
await this.backButton().click();
}

async clickSave() {
await this.saveButton().click();
}

async clickDelete() {
await this.deleteButton().click();
}

async confirmDelete() {
await this.deleteDialog().waitFor({ state: 'visible', timeout: 5000 });
await this.confirmDeleteButton().click();
}

async selectBannerType(type: '설문조사' | '큐레이션' | '광고' | '제휴' | '기타') {
await this.bannerTypeSelect().click();
await this.page.getByRole('option', { name: type, exact: true }).click();
}

async selectTextPosition(position: '좌측 상단' | '좌측 하단' | '우측 상단' | '우측 하단' | '중앙') {
await this.textPositionSelect().click();
await this.page.getByRole('option', { name: position, exact: true }).click();
}

async fillBasicInfo(data: {
name: string;
bannerType?: '설문조사' | '큐레이션' | '광고' | '제휴' | '기타';
descriptionA?: string;
descriptionB?: string;
}) {
await this.nameInput().fill(data.name);
if (data.bannerType) {
await this.selectBannerType(data.bannerType);
}
if (data.descriptionA) {
await this.descriptionAInput().fill(data.descriptionA);
}
if (data.descriptionB) {
await this.descriptionBInput().fill(data.descriptionB);
}
}

async updateName(name: string) {
await this.nameInput().fill(name);
}

async checkAlwaysVisible() {
const isChecked = await this.isAlwaysVisibleCheckbox().getAttribute('data-state') === 'checked';
if (!isChecked) {
await this.isAlwaysVisibleCheckbox().click();
}
}

async setTargetUrl(url: string, isExternal = false) {
if (isExternal) {
const isChecked = await this.isExternalUrlCheckbox().getAttribute('data-state') === 'checked';
if (!isChecked) {
await this.isExternalUrlCheckbox().click();
}
}
await this.targetUrlInput().fill(url);
}

async uploadTestImage() {
const testImagePath = path.resolve(__dirname, '../fixtures/test-image.png');
await this.imageFileInput().setInputFiles(testImagePath);
await this.uploadedImage().waitFor({ state: 'visible', timeout: 10000 });
}

async ensureImage() {
const hasImage = await this.uploadedImage().isVisible().catch(() => false);
if (!hasImage) {
await this.uploadTestImage();
}
}
}
186 changes: 186 additions & 0 deletions e2e/pages/banner-list.page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { type Page } from '@playwright/test';
import { BasePage } from './base.page';

/**
* 배너 목록 페이지 Page Object
*
* 실제 UI 구조:
* - 테이블 형태로 배너 목록 표시
* - 검색 + 배너 타입 필터
* - 행 클릭 시 상세 페이지로 이동
* - 순서 변경 모드에서 드래그 앤 드롭으로 순서 변경
*/
export class BannerListPage extends BasePage {
constructor(page: Page) {
super(page);
}

/* ============================================
* Locators
* ============================================ */

/** 페이지 제목 */
readonly pageTitle = () => this.page.getByRole('heading', { name: '배너 관리' });

/** 검색 입력 필드 */
readonly searchInput = () => this.page.getByPlaceholder('배너명으로 검색...');

/** 검색 버튼 */
readonly searchButton = () => this.page.getByRole('button', { name: '검색' });

/** 배너 등록 버튼 */
readonly createButton = () => this.page.getByRole('button', { name: '배너 등록' });

/** 배너 타입 필터 드롭다운 트리거 */
readonly typeFilterTrigger = () =>
this.page.locator('button[role="combobox"]').filter({ hasText: /전체|설문조사|큐레이션|광고|제휴|기타/ });

/** 테이블 */
readonly table = () => this.page.locator('table');

/** 테이블 행들 (헤더 제외) */
readonly tableRows = () => this.page.locator('tbody tr');

/** 클릭 가능한 테이블 행 (cursor-pointer) */
readonly clickableRows = () => this.page.locator('tbody tr.cursor-pointer');

/** 로딩 상태 */
readonly loadingState = () => this.page.getByText('로딩 중...');

/** 검색 결과 없음 메시지 */
readonly noResultMessage = () => this.page.getByText('검색 결과가 없습니다.');

/** 특정 배너명을 포함한 행 */
readonly rowWithName = (name: string) =>
this.page.locator('tbody tr').filter({ hasText: name });

/** 순서 변경 버튼 */
readonly reorderButton = () => this.page.getByRole('button', { name: /순서 변경/ });

/** 순서 변경 모드 안내 배너 */
readonly reorderModeBanner = () => this.page.getByText('순서 변경 모드');

/** 드래그 핸들 (순서 변경 모드에서만 표시) */
readonly dragHandles = () => this.page.locator('tbody tr td:last-child svg');

/* ============================================
* Actions
* ============================================ */

/**
* 페이지로 이동
*/
async goto() {
await this.page.goto('/banners');
await this.waitForLoadingComplete();
}

/**
* 로딩 완료 대기
*/
async waitForLoadingComplete() {
await this.loadingState().waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
}

/**
* 검색 수행 (Enter 키)
*/
async search(keyword: string) {
await this.searchInput().fill(keyword);
const responsePromise = this.page.waitForResponse(
(resp) => resp.url().includes('/banners') && resp.status() === 200,
{ timeout: 10000 }
).catch(() => {});
await this.searchInput().press('Enter');
await responsePromise;
await this.waitForLoadingComplete();
}

/**
* 검색 버튼 클릭으로 검색 수행
*/
async searchWithButton(keyword: string) {
await this.searchInput().fill(keyword);
const responsePromise = this.page.waitForResponse(
(resp) => resp.url().includes('/banners') && resp.status() === 200,
{ timeout: 10000 }
).catch(() => {});
await this.searchButton().click();
await responsePromise;
await this.waitForLoadingComplete();
}

/**
* 배너 타입 필터 선택
*/
async selectTypeFilter(type: '전체' | '설문조사' | '큐레이션' | '광고' | '제휴' | '기타') {
await this.typeFilterTrigger().click();
const responsePromise = this.page.waitForResponse(
(resp) => resp.url().includes('/banners') && resp.status() === 200,
{ timeout: 10000 }
).catch(() => {});
await this.page.getByRole('option', { name: type, exact: true }).click();
await responsePromise;
await this.waitForLoadingComplete();
}

/**
* 배너 등록 버튼 클릭
*/
async clickCreate() {
await this.createButton().click();
}

/**
* 첫 번째 행 클릭
*/
async clickFirstRow() {
await this.clickableRows().first().click();
}

/**
* 특정 배너명 행 클릭
*/
async clickRowByName(name: string) {
await this.rowWithName(name).click();
}

/**
* 테이블 행 개수 반환
*/
async getRowCount() {
const noResult = await this.noResultMessage().isVisible().catch(() => false);
if (noResult) return 0;
return await this.clickableRows().count();
}

/**
* 특정 배너가 목록에 있는지 확인
*/
async expectBannerExists(name: string) {
await this.rowWithName(name).waitFor({ state: 'visible' });
}

/**
* 순서 변경 모드 진입
*/
async enterReorderMode() {
await this.reorderButton().click();
await this.reorderModeBanner().waitFor({ state: 'visible', timeout: 5000 });
}

/**
* 순서 변경 모드 종료
*/
async exitReorderMode() {
await this.reorderButton().click();
await this.reorderModeBanner().waitFor({ state: 'hidden', timeout: 5000 });
}

/**
* 드래그 핸들 개수 확인
*/
async getDragHandleCount() {
return await this.dragHandles().count();
}
}
2 changes: 2 additions & 0 deletions e2e/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ export { WhiskyListPage } from './whisky-list.page';
export { WhiskyDetailPage } from './whisky-detail.page';
export { CurationListPage } from './curation-list.page';
export { CurationDetailPage } from './curation-detail.page';
export { BannerListPage } from './banner-list.page';
export { BannerDetailPage } from './banner-detail.page';
Loading
Loading