feat(ai-character): Mock Preview 모드 구현
This commit is contained in:
@@ -1 +1,2 @@
|
||||
VITE_API_BASE_URL=https://test-character-admin.sodalive.net
|
||||
VITE_API_MODE=server
|
||||
|
||||
33
README.md
33
README.md
@@ -20,17 +20,28 @@ Vite mode별 API base URL은 아래 파일에 둡니다.
|
||||
|
||||
- `.env.development`: `https://test-character-admin.sodalive.net`
|
||||
- `.env.production`: `https://character-admin.sodalive.net`
|
||||
- `server mode`: `VITE_API_MODE=server`이며 기본 개발 서버가 실제 개발 API를 사용합니다.
|
||||
- `mock mode`: `VITE_API_MODE=mock`이며 개발 전용 browser MSW fixture로 제공 계약 범위만 미리 봅니다.
|
||||
- `mock data reset`: mock data는 browser storage에 영구 저장하지 않고 새 mock store/session이 시작될 때 seed 기준으로 초기화됩니다.
|
||||
- `production`: `VITE_API_MODE=mock`은 production build에서 거부됩니다.
|
||||
- `no-auto-fallback`: server mode의 404 또는 network error를 mock mode로 자동 전환하지 않습니다.
|
||||
|
||||
## Scripts
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run build:dev
|
||||
npm run build:prod
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run test
|
||||
npm run test:run
|
||||
npm run e2e
|
||||
npm run build
|
||||
```
|
||||
- `npm run dev (VITE_API_MODE=server vite --host 127.0.0.1 --port 8888 --strictPort)`: 실제 개발 API를 쓰는 기본 개발 서버입니다.
|
||||
- `npm run dev:mock (VITE_API_MODE=mock vite --host 127.0.0.1 --port 8889 --strictPort)`: 개발 전용 mock preview 서버입니다.
|
||||
- `npm run build:dev`: development mode build입니다.
|
||||
- `npm run build:prod`: production mode build입니다.
|
||||
- `npm run build`: `build:prod` 별칭입니다.
|
||||
- `npm run typecheck`: TypeScript project reference typecheck입니다.
|
||||
- `npm run lint`: ESLint 검사입니다.
|
||||
- `npm run test`: Vitest watch입니다.
|
||||
- `npm run test:run`: Vitest 단발 실행입니다.
|
||||
- `npm run e2e (VITE_API_MODE=server playwright test)`: server mode Playwright입니다. 대상 spec은 `playwright.config.ts`의 server mode `testMatch`가 제한하며, 추가 file filter를 넘기면 교집합만 실행합니다.
|
||||
- `npm run e2e:mock (VITE_API_MODE=mock playwright test)`: mock mode Playwright입니다. 대상 spec은 `playwright.config.ts`의 mock mode `testMatch`가 제한하며, 추가 file filter를 넘기면 교집합만 실행합니다.
|
||||
|
||||
## Mock Preview Ownership
|
||||
|
||||
- Phase 2 mock preview는 auth shell과 제공 계약 기반 공통 fixture까지만 포함합니다.
|
||||
- 후속 도메인 Phase는 자기 domain handler, fixture, mock E2E를 같은 Phase에서 추가하고 검증합니다.
|
||||
- mock mode 통과는 최종 UI 확인 증거이며 실제 server integration 완료 증거가 아닙니다.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | Phase 0~1 완료, 신규 Phase 2 Mock Preview 착수 전 |
|
||||
| 상태 | Phase 0~2 완료, Phase 3 Character workspace 착수 전 |
|
||||
| 최초 작성일 | 2026-07-25 |
|
||||
| 재작성일 | 2026-07-26 |
|
||||
| 요구사항 기준 | [prd.md](./prd.md) |
|
||||
@@ -277,7 +277,7 @@ npm run build:prod
|
||||
|
||||
**Goal 실행 `P0-R1`:** Phase 0 테스트 격리와 루트 셸·Playwright 증거 정합성을 복구하고 회귀를 방지한다.
|
||||
|
||||
- 연결 리뷰: `REV-P0-001`, `REV-P0-002`, `REV-P0-003`
|
||||
- 연결 리뷰: [Phase 0·1 리뷰](./reviews/review-phase-0-1.md) — `REV-P0-001`, `REV-P0-002`, `REV-P0-003`
|
||||
- 시작 조건: review finding을 보존하고 기존 완료 체크를 되돌리지 않는다.
|
||||
- 완료 증거: stubbed global 복원 회귀 test, root `main`과 대표 content assertion, 현재 Playwright mode URL 전략과 실행 가능한 agent-guide 문서 경로 정정 기록, P0-GATE 통과.
|
||||
- 범위 밖: Phase 1 인증 동작 전면 변경, test framework 교체.
|
||||
@@ -485,7 +485,7 @@ npm run build
|
||||
|
||||
**Goal 실행 `P1-R2`:** image 10MB 표현을 백엔드와 동일한 exact byte 계약으로 확정한다.
|
||||
|
||||
- 연결 리뷰: `REV-P1-010`
|
||||
- 연결 리뷰: [Phase 0·1 리뷰](./reviews/review-phase-0-1.md) — `REV-P1-010`
|
||||
- 상태: 완료.
|
||||
- 결정: 백엔드 exact byte를 `10,485,760 bytes`로 확정했다. `10,485,760 bytes`는 허용하고 `10,485,761 bytes`부터 거부한다.
|
||||
- 완료 증거: PRD, API 계약, `IMAGE_MAX_BYTES`, `limit-1/limit/limit+1` 경계 테스트를 같은 값으로 정렬했다.
|
||||
@@ -494,7 +494,7 @@ npm run build
|
||||
|
||||
**Goal 실행 `P1-R3`:** Phase 1 인증의 보호 경계, 오류 피드백, logout 단일 요청 계약을 복구한다.
|
||||
|
||||
- 연결 리뷰: `REV-P1-002`, `REV-P1-006`, `REV-P1-007`
|
||||
- 연결 리뷰: [Phase 0·1 리뷰](./reviews/review-phase-0-1.md) — `REV-P1-002`, `REV-P1-006`, `REV-P1-007`
|
||||
- 완료 증거: stale ADMIN probe pending·403 동안 보호 shell 비노출, probe `size=20`, 서버 한국어 message 우선 표시, logout in-flight 중 API 1회 호출.
|
||||
- 범위 밖: 인증 방식 또는 token 저장 방식 교체, Phase 3 권한 기능.
|
||||
|
||||
@@ -506,7 +506,7 @@ npm run build
|
||||
|
||||
**Goal 실행 `P1-R4`:** Phase 1 파일·미디어 입력의 crop 결과와 audio 형식 조합 계약을 복구한다.
|
||||
|
||||
- 연결 리뷰: `REV-P1-003`, `REV-P1-004`
|
||||
- 연결 리뷰: [Phase 0·1 리뷰](./reviews/review-phase-0-1.md) — `REV-P1-003`, `REV-P1-004`
|
||||
- 완료 증거: source crop rectangle 기반 출력, 1200×600 → 1:1 no-upscale 600×600과 상·하단 alpha 유지 test, 자유 비율, pointer drag, 확장자↔MIME 조합 table test.
|
||||
- 범위 밖: 외부 crop library, 서버 파일 변환 구현.
|
||||
|
||||
@@ -520,7 +520,7 @@ npm run build
|
||||
|
||||
**Goal 실행 `P1-R5`:** Phase 1 공유 UI의 modal focus, 필드 오류 상태, keyboard event 경계를 복구한다.
|
||||
|
||||
- 연결 리뷰: `REV-P1-005`, `REV-P1-008`, `REV-P1-009`
|
||||
- 연결 리뷰: [Phase 0·1 리뷰](./reviews/review-phase-0-1.md) — `REV-P1-005`, `REV-P1-008`, `REV-P1-009`
|
||||
- 완료 증거: 세 modal의 focus trap과 trigger focus return, FileField `aria-invalid`, audio descendant control Enter·Space 격리.
|
||||
- 범위 밖: 공유 UI 전체 교체, 시각 디자인 재작업.
|
||||
|
||||
@@ -566,7 +566,7 @@ npm run build
|
||||
|
||||
**Goal 실행 `P1-R1`:** Phase 1 완료 판정을 재현 가능한 산출물·통합·Gate 증거로 복구한다.
|
||||
|
||||
- 연결 리뷰: `REV-P1-001`
|
||||
- 연결 리뷰: [Phase 0·1 리뷰](./reviews/review-phase-0-1.md) — `REV-P1-001`
|
||||
- 상태: 완료.
|
||||
- 완료한 부분: P1-GATE 명령은 `src/app/App.test.tsx`를 포함하도록 보정했고, P1-R2/R3/R4/R5의 자동·E2E 검증 기록을 누적했다.
|
||||
- 결정: `PageHeader`는 구현·소비가 없고 현재 필요한 반복 소비 컴포넌트가 아니므로 Phase 1 shared 산출물에서 제거한다. 필요하다고 판단되는 도메인 Phase에서 추가한다.
|
||||
@@ -582,20 +582,23 @@ npm run build
|
||||
|
||||
- **시작 조건:** `P1-GATE` 완료.
|
||||
- **완료 조건:** `P2-T1`~`P2-T3`, `P2-GATE` 완료. mock/server 분리와 production 차단이 자동 test와 E2E로 고정됨.
|
||||
- **현재 상태:** Phase 1 완료 후 새로 추가된 독립 후속 Phase. Phase 0·1의 완료 이력은 변경하지 않는다.
|
||||
- **현재 상태:** 완료. `P2-T1`~`P2-T3`, `P2-GATE`, `P2-R1`~`P2-R16`의 구현·수정·검증이 끝났으며 Phase 3 진행 가능.
|
||||
|
||||
**요구사항:** `MOCK-001~009`, PRD `11.1~11.2`, `12`, `13`.
|
||||
|
||||
**주요 Files:**
|
||||
|
||||
- Modify: `package.json`, `.env.example`, `src/shared/config/env.ts`, `src/shared/config/env.test.ts`
|
||||
- Modify: `src/main.tsx`, `src/app/providers.tsx`
|
||||
- Create: `src/shared/mocks/{browser,handlers,fixtures,store}.ts`
|
||||
- Create: `src/shared/mocks/__tests__/{mode-boundary,handlers,store}.test.ts`
|
||||
- Modify: `src/app/App.tsx`, `src/main.tsx`, `vite.config.ts`, `playwright.config.ts`
|
||||
- Modify: `src/app/App.test.tsx`, `src/app/admin-pages.tsx`, `src/app/browser-location.ts`
|
||||
- Create: `src/app/protected-admin-shell.tsx`
|
||||
- Create: `src/shared/mocks/{browser,handlers,contract}.ts`
|
||||
- Create: `src/shared/mocks/__tests__/{mode-boundary,auth-handlers,mock-preview-docs,production-graph}.test.ts`
|
||||
- Create: `src/shared/mocks/browser.test.ts`
|
||||
- Create: `src/shared/ui/mock-mode-banner.tsx`
|
||||
- Create: `src/shared/ui/__tests__/mock-mode-banner.test.tsx`
|
||||
- Create: `public/mockServiceWorker.js`
|
||||
- Create: `tests/e2e/mock-preview-shell.spec.ts`
|
||||
- Create: `tests/e2e/{mock-mode-boundary,mock-preview-shell,server-mode-boundary}.spec.ts`
|
||||
- Modify when implementation is complete: `README.md`, `docs/agent-guide/{environment,scripts}.md`
|
||||
|
||||
### Task 2.1 explicit API mode·production 경계
|
||||
@@ -606,13 +609,27 @@ npm run build
|
||||
- **완료 증거:** env/script/bootstrap focused test, `dev` server 기본값, `dev:mock` explicit mode, production mock 거부와 404 no-fallback test.
|
||||
- **범위 밖:** Character 이후 도메인 fixture와 실제 backend endpoint 구현.
|
||||
|
||||
- [ ] `VITE_API_MODE=server | mock`을 검증하고 누락 기본값은 `server`로 고정하는 실패 test를 작성한다.
|
||||
- [ ] `npm run dev:mock`만 Vite mock mode와 browser worker를 시작하고 기본 `npm run dev`는 실제 `VITE_API_BASE_URL`을 사용하는 script test를 작성한다.
|
||||
- [ ] Playwright가 같은 spec을 explicit mode로 실행할 수 있도록 `e2e:mock`과 server mode 명령을 정의하고 각 webServer 환경을 test한다.
|
||||
- [ ] `import.meta.env.DEV && apiMode === "mock"`일 때만 browser module을 dynamic import하고 worker 준비 후 React를 mount한다.
|
||||
- [ ] production mode에서 `mock`을 설정하면 build/start 전에 설명 가능한 오류로 거부하고 worker·fixture가 production graph에서 실행되지 않는 test를 작성한다.
|
||||
- [ ] `server` mode의 404·network error가 browser mock을 시작하거나 응답을 교체하지 않는 test를 작성한다.
|
||||
- [ ] worker의 unhandled request 정책은 error로 두어 누락된 handler가 실제 backend로 조용히 통과하지 않게 한다.
|
||||
- [x] `VITE_API_MODE=server | mock`을 검증하고 누락 기본값은 `server`로 고정하는 실패 test를 작성한다.
|
||||
- [x] `npm run dev:mock`만 Vite mock mode와 browser worker를 시작하고 기본 `npm run dev`는 실제 `VITE_API_BASE_URL`을 사용하는 script test를 작성한다.
|
||||
- [x] Playwright가 같은 spec을 explicit mode로 실행할 수 있도록 `e2e:mock`과 server mode 명령을 정의하고 각 webServer 환경을 test한다.
|
||||
- [x] `import.meta.env.DEV && apiMode === "mock"`일 때만 browser module을 dynamic import하고 worker 준비 후 React를 mount한다.
|
||||
- [x] production mode에서 `mock`을 설정하면 build/start 전에 설명 가능한 오류로 거부하고 worker·fixture가 production graph에서 실행되지 않는 test를 작성한다.
|
||||
- [x] `server` mode의 404·network error가 browser mock을 시작하거나 응답을 교체하지 않는 test를 작성한다.
|
||||
- [x] worker의 unhandled request 정책은 error로 두어 누락된 handler가 실제 backend로 조용히 통과하지 않게 한다.
|
||||
|
||||
**P2-T1 구현 검증 기록 (2026-07-27):**
|
||||
|
||||
- RED: `npm run test:run -- src/shared/config/env.test.ts src/shared/mocks/__tests__/mode-boundary.test.ts`는 mode/default/script 미구현으로 2 files 중 5 tests가 기대대로 실패했고, `VITE_API_MODE=mock npm run e2e -- tests/e2e/mock-mode-boundary.spec.ts`는 worker 미등록과 unhandled 정책 미구현으로 8 tests가 실패했다.
|
||||
- GREEN focused: `npm run test:run -- src/shared/config/env.test.ts src/shared/mocks/browser.test.ts src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/api/__tests__/client.test.ts`는 4 files / 18 tests 통과, `npm run e2e:mock -- tests/e2e/mock-mode-boundary.spec.ts`는 4 browser projects 통과, `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts`는 4 browser projects 통과했다.
|
||||
- Production boundary: `VITE_API_MODE=mock npm run build:prod`는 guard 추가 전에는 부당하게 성공했고, guard 추가 후 `VITE_API_MODE=mock is only available during development` 오류로 기대대로 거부됐다.
|
||||
- Quality: `npm run typecheck`, `npm run lint`, `npm run test:run`(31 files / 117 tests), `npm run build:dev`, `npm run build:prod`, `git diff --check`를 성공했다. Playwright로 `http://127.0.0.1:8888/login`과 `http://127.0.0.1:8889/login`을 직접 열어 server mode는 worker registration 0건, mock mode는 `/mockServiceWorker.js` controller 등록을 확인했고, QA용 dev server 포트 `8888`·`8889`가 비었음을 확인했다.
|
||||
|
||||
**P2-T1 리뷰 보강 기록 (2026-07-27):**
|
||||
|
||||
- RED: `npm run test:run -- src/shared/mocks/__tests__/production-graph.test.ts src/shared/mocks/__tests__/mode-boundary.test.ts`는 production build 산출물에 `mockServiceWorker.js`가 복사되어 실패했고, `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts`는 404·network error 후 no-fallback 증거가 없어 8 tests가 실패했다.
|
||||
- GREEN: production mode에서 `publicDir` 복사를 끄고 production graph test를 실제 `NODE_ENV=production` build 조건으로 맞춘 뒤 같은 focused unit은 2 files / 3 tests 통과했다. server mode E2E는 404·network error 요청 후에도 browser MSW registration 0건임을 확인해 4 browser projects / 12 tests 통과했다.
|
||||
- Re-review 보강: production graph test가 output file path까지 검사하도록 보강했고, server 404·network error는 오류 alert 표시 후 worker registration 0건을 확인하도록 `App`의 route error 표시 조건을 수정했다. `npm run test:run -- src/shared/mocks/__tests__/production-graph.test.ts src/shared/mocks/__tests__/mode-boundary.test.ts src/app/App.test.tsx`는 3 files / 16 tests 통과했고, `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts`는 4 browser projects / 12 tests 통과했다.
|
||||
- Guide sync: `VITE_API_MODE`, `dev:mock`, `e2e:mock`, production mock 거부 기준을 `docs/agent-guide/environment.md`와 `docs/agent-guide/scripts.md`에 반영했다.
|
||||
|
||||
### Task 2.2 공통 preview session·fixture store
|
||||
|
||||
@@ -622,12 +639,18 @@ npm run build
|
||||
- **완료 증거:** auth handler/store/banner focused test와 login → protected shell mock preview E2E 기록.
|
||||
- **범위 밖:** 도메인별 endpoint handler와 계약 미제공 fixture.
|
||||
|
||||
- [ ] fixture는 정규화 `ApiResponse<T>`와 실제 endpoint·header·body contract를 사용하고 별도 mock DTO를 만들지 않는다.
|
||||
- [ ] ADMIN login·logout, 401·403 fixture와 새로고침 시 seed로 초기화되는 in-memory store를 만든다.
|
||||
- [ ] mock mutation은 같은 store의 목록·상세 응답을 갱신하고 browser storage에 domain fixture를 영구 저장하지 않는다.
|
||||
- [ ] mock mode임을 지속적으로 표시하는 접근 가능한 banner와 server mode에서 banner가 없는 test를 작성한다.
|
||||
- [ ] JWT·password·signed URL·multipart body가 mock logger와 storage에 기록되지 않는 test를 작성한다.
|
||||
- [ ] handler와 fixture가 `src/shared/test/server.ts`의 Node test lifecycle을 변경하지 않고 필요한 contract factory만 공유하게 한다.
|
||||
- [x] fixture는 정규화 `ApiResponse<T>`와 실제 endpoint·header·body contract를 사용하고 별도 mock DTO를 만들지 않는다.
|
||||
- [x] ADMIN login·logout, 401·403 fixture와 새로고침 시 seed로 초기화되는 in-memory store를 만든다.
|
||||
- [x] mock mutation은 같은 store의 목록·상세 응답을 갱신하고 browser storage에 domain fixture를 영구 저장하지 않는다.
|
||||
- [x] mock mode임을 지속적으로 표시하는 접근 가능한 banner와 server mode에서 banner가 없는 test를 작성한다.
|
||||
- [x] JWT·password·signed URL·multipart body가 mock logger와 storage에 기록되지 않는 test를 작성한다.
|
||||
- [x] handler와 fixture가 `src/shared/test/server.ts`의 Node test lifecycle을 변경하지 않고 필요한 contract factory만 공유하게 한다.
|
||||
|
||||
**P2-T2 실행 기록 (2026-07-27):**
|
||||
|
||||
- RED: `npm run test:run -- src/shared/mocks/__tests__/auth-handlers.test.ts src/shared/ui/__tests__/mock-mode-banner.test.tsx src/app/App.test.tsx src/shared/mocks/browser.test.ts`는 `@/shared/mocks/handlers`·`@/shared/ui/mock-mode-banner` 미구현, App mock banner 부재, browser worker handler 미등록 기대 실패로 4 files 중 5 failures가 발생했다. `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts --project=chromium`은 mock login handler 부재로 `/login`에 머물러 기대대로 실패했다.
|
||||
- GREEN: `createMockHandlers(createMockStore())`가 `POST /admin/member/login`, `POST /member/logout`, `GET /api/v2/admin/ai-characters?page=0&size=20`을 정규화 envelope로 처리하고 logout mutation 후 같은 store에서 token을 폐기하며 새 store 생성 시 seed로 초기화한다. 리뷰 보강으로 logout 후 재login 시 같은 store에서 ADMIN token이 다시 활성화되도록 고정했다. 같은 focused unit 명령은 4 files / 25 tests 통과했고, `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts`는 4 browser projects / 8 tests 통과했다.
|
||||
- Sensitive data/storage: handler는 logger를 추가하지 않았고, focused test에서 login password/JWT 요청 후 `localStorage`, mock `sessionStorage`, cookie, IndexedDB, console log/warn/error에 fixture domain data와 민감값을 기록하지 않음을 확인했다. `src/shared/test/server.ts` lifecycle은 변경하지 않았다.
|
||||
|
||||
### Task 2.3 Mock Preview 반응형·문서화
|
||||
|
||||
@@ -637,10 +660,15 @@ npm run build
|
||||
- **완료 증거:** 320px·desktop mock shell E2E, axe 결과, README와 environment/scripts 가이드의 실제 명령 동기화 기록.
|
||||
- **범위 밖:** 도메인별 최종 UI와 server integration 완료 주장.
|
||||
|
||||
- [ ] mock banner가 320px·200% zoom에서 핵심 control을 가리지 않고 axe critical·serious 위반이 없는지 E2E로 확인한다.
|
||||
- [ ] README에 `npm run dev`와 `npm run dev:mock`, mode 차이, mock data reset, production 금지와 no-auto-fallback을 기록한다.
|
||||
- [ ] 구현이 완료된 뒤 `docs/agent-guide/environment.md`와 `scripts.md`에 실제 환경 변수와 명령을 추가한다.
|
||||
- [ ] 후속 도메인 Phase가 handler·fixture·mock E2E를 소유한다는 규칙을 문서화한다.
|
||||
- [x] mock banner가 320px·200% zoom에서 핵심 control을 가리지 않고 axe critical·serious 위반이 없는지 E2E로 확인한다.
|
||||
- [x] README에 `npm run dev`와 `npm run dev:mock`, mode 차이, mock data reset, production 금지와 no-auto-fallback을 기록한다.
|
||||
- [x] 구현이 완료된 뒤 `docs/agent-guide/environment.md`와 `scripts.md`에 실제 환경 변수와 명령을 추가한다.
|
||||
- [x] 후속 도메인 Phase가 handler·fixture·mock E2E를 소유한다는 규칙을 문서화한다.
|
||||
|
||||
**P2-T3 실행 기록 (2026-07-27):**
|
||||
|
||||
- RED: `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts src/shared/mocks/__tests__/mode-boundary.test.ts`는 README에 실제 `dev`/`dev:mock`/`e2e`/`e2e:mock` script, mode 차이, mock data reset, production 금지, no-auto-fallback 기록이 없고 `docs/agent-guide/environment.md`와 `scripts.md`에 reset·domain handler/fixture/mock E2E 소유 규칙이 없어 2 tests가 기대대로 실패했다. `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts --project=chromium`은 새 320px·200% zoom 및 axe 확인을 포함해 5 tests가 통과해 기존 mock shell UI는 문서 보강 전에도 요구 접근성 동작을 만족함을 확인했다.
|
||||
- GREEN: README에 `npm run dev`/`dev:mock`/`e2e`/`e2e:mock`의 실제 명령, server mode와 mock mode 차이, mock data reset, production mock 거부, no-auto-fallback, 후속 도메인 handler·fixture·mock E2E 소유 규칙을 추가했다. `docs/agent-guide/environment.md`에는 `VITE_API_MODE=server | mock`, reset, production 금지, no-auto-fallback을 동기화하고 `docs/agent-guide/scripts.md`에는 실제 script와 domain ownership rule을 동기화했다. 같은 focused unit 명령은 2 files / 4 tests 통과했고, `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts --project=chromium`은 5 tests 통과했다. 최종 확인으로 `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts`는 4 browser projects / 20 tests 통과, `npm run typecheck`, `npm run lint`, `git diff --check`도 통과했다.
|
||||
|
||||
### Phase 2 Gate
|
||||
|
||||
@@ -661,6 +689,644 @@ npm run build:prod
|
||||
|
||||
**Expected:** `npm run dev:mock`에서는 ADMIN login → protected shell과 mock banner가 동작하고 실제 backend 요청은 0건이다. 기본 server mode는 실제 API 오류를 그대로 처리하며 production build는 browser mock을 활성화하지 않는다.
|
||||
|
||||
**P2-GATE 실행 기록 (2026-07-27):**
|
||||
|
||||
- `npm run test:run -- src/shared/config src/shared/mocks src/shared/ui/__tests__/mock-mode-banner.test.tsx` — 7 files / 20 tests 통과.
|
||||
- `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts` — 4 browser projects / 20 tests 통과. mock login → protected shell, logout 후 재login, 320px·200% zoom banner/control, axe critical·serious 0을 확인했다.
|
||||
- `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — 모두 exit 0.
|
||||
- LSP diagnostics: `src/shared/mocks/__tests__/mock-preview-docs.test.ts` clean, `tests/e2e` directory 6 files / 0 diagnostics. 단일 `mock-preview-shell.spec.ts` LSP 호출은 3초 fresh diagnostics timeout이었으나 directory diagnostics와 typecheck/lint로 보완했다.
|
||||
- 판정: Phase 2 mock/server mode 경계, auth preview, production build 경계, mock preview 반응형·접근성·문서화 완료. mock 통과는 후속 도메인 server integration 완료로 간주하지 않는다.
|
||||
|
||||
### Task R2.1 — 보호 route 검증 실패 시 fail-closed 복구
|
||||
|
||||
**Goal 실행 `P2-R1`:** Phase 2 no-auto-fallback 오류 UI가 Phase 1의 보호 shell 비노출 경계를 우회하지 않게 하고 회귀를 방지한다.
|
||||
|
||||
- 연결 리뷰: [Phase 2 리뷰](./reviews/review-phase-2.md) — `REV-P2-001`
|
||||
- 시작 조건:
|
||||
- 저장된 ADMIN session으로 `/ai-characters`에 진입한 뒤 probe가 404 또는 network error로 실패할 때 보호 shell이 노출되는 현재 동작을 실패 test로 재현한다.
|
||||
- 완료 증거:
|
||||
- probe 성공 전과 401·403·404·network error에서 보호 shell 비노출
|
||||
- 404·network error는 mock fallback 없이 보호 shell 밖의 오류 UI로 표시
|
||||
- focused App test, server mode boundary E2E, auth E2E와 P2-GATE 실행 기록
|
||||
- 범위 밖:
|
||||
- 인증 방식, session 저장 방식 또는 API client 전면 교체
|
||||
- Phase 3 Character 화면 구현
|
||||
|
||||
- [x] 404·network error와 이전 오류 뒤 새 session probe pending에서 보호 shell이 노출되는 실패 test를 추가한다.
|
||||
- [x] 성공한 현재 token probe만 보호 shell을 열고 실패 오류는 shell 밖에서 표시하는 최소 상태 전이를 구현한다.
|
||||
- [x] `src/app/App.test.tsx`, `tests/e2e/server-mode-boundary.spec.ts`, 기존 auth E2E와 P2-GATE를 실행한다.
|
||||
- [x] 결과를 plan-task.md 검증 기록과 [Phase 2 리뷰 문서](./reviews/review-phase-2.md)에 누적한다.
|
||||
|
||||
**P2-R1 수정 검증 기록 (2026-07-27):**
|
||||
|
||||
- RED: `npm run test:run -- src/app/App.test.tsx src/shared/mocks/__tests__/auth-handlers.test.ts` — 404·network error에서 보호 shell `banner/main`이 렌더되어 App test 2건이 기대대로 실패했다.
|
||||
- GREEN focused: `npm run test:run -- src/app/App.test.tsx src/shared/mocks/__tests__/auth-handlers.test.ts src/shared/mocks/browser.test.ts` — 3 files / 27 tests 통과.
|
||||
- Surface: `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts` — 4 browser projects / 12 tests 통과. 404·network error 후 mock worker 0건, 보호 shell `main` 0건, logout button 0건을 확인했다.
|
||||
- Regression: `npm run e2e -- tests/e2e/auth.spec.ts tests/e2e/accessibility-shell.spec.ts` — 4 browser projects / 16 tests 통과.
|
||||
- Reviewer blocker 보강: 같은 token 재사용 logout → login 뒤 probe 실패 시 이전 검증 token이 남는 회귀를 추가로 확인했다. RED `npm run test:run -- src/app/App.test.tsx` — 1 test failed. GREEN 같은 command — 1 file / 19 tests 통과. 보강 후 `npm run test:run -- src/app/App.test.tsx src/shared/mocks/__tests__/auth-handlers.test.ts src/shared/mocks/browser.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 4 files / 31 tests 통과, `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts` — 12 tests 통과, `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts` — 20 tests 통과.
|
||||
|
||||
#### create_goal objective 초안 — P2-R1
|
||||
|
||||
- objective: P2-R1의 확정 review 항목 REV-P2-001을 수정하고 Phase 1 보호 route 경계의 회귀를 방지한다. plan-task.md에 추가된 Task R2.1만 수행한다.
|
||||
- 완료 조건: 404·network error를 포함한 probe 비성공 상태에서 보호 shell 비노출, 독립 오류 UI, focused test, server/auth E2E, P2-GATE와 누적 기록이 모두 확인된다.
|
||||
- 금지 조건: 인증·session 아키텍처 교체나 Phase 3 Character 구현을 포함하지 않는다.
|
||||
- 중단 조건: 보호 route 확인 endpoint 자체가 변경됐다는 외부 계약이 확인되면 계약 문서를 먼저 갱신한다.
|
||||
|
||||
### Task R2.2 — Mock Preview API origin·JWT 오류 계약 복구
|
||||
|
||||
**Goal 실행 `P2-R2`:** browser mock handler를 설정된 API base URL에만 결합하고 invalid·revoked JWT logout을 계약 status로 반환한다.
|
||||
|
||||
- 연결 리뷰: [Phase 2 리뷰](./reviews/review-phase-2.md) — `REV-P2-002`, `REV-P2-003`
|
||||
- 시작 조건:
|
||||
- 잘못된 origin의 login이 200을 반환하고 invalid·revoked JWT logout이 200을 반환하는 현재 동작을 각각 실패 test로 재현한다.
|
||||
- 완료 증거:
|
||||
- 설정된 `VITE_API_BASE_URL`의 정확한 URL·method만 handler가 처리하고 다른 origin은 `onUnhandledRequest: "error"` 경계에 남음
|
||||
- invalid·revoked JWT logout은 API Contract §1.2의 401 오류 envelope를 반환
|
||||
- 정상 login → logout → login 흐름과 403 fixture 회귀 없음
|
||||
- focused handler test, mock preview E2E와 P2-GATE 실행 기록
|
||||
- 범위 밖:
|
||||
- 계약 미제공 endpoint·DTO·회원 role 정책 추정
|
||||
- 도메인별 fixture 선행 구현
|
||||
|
||||
- [x] wrong-origin login과 invalid·두 번째 logout의 현재 200 응답을 실패 test로 고정한다.
|
||||
- [x] handler 생성 시 API base URL을 주입해 production endpoint URL에만 매칭한다.
|
||||
- [x] token access 상태를 확인한 뒤 logout store를 변경하고 invalid·revoked token에 401을 반환한다.
|
||||
- [x] auth handler focused test, mock preview E2E와 P2-GATE를 실행한다.
|
||||
- [x] 결과를 plan-task.md 검증 기록과 [Phase 2 리뷰 문서](./reviews/review-phase-2.md)에 누적한다.
|
||||
|
||||
**P2-R2 수정 검증 기록 (2026-07-27):**
|
||||
|
||||
- RED: `npm run test:run -- src/app/App.test.tsx src/shared/mocks/__tests__/auth-handlers.test.ts` — wrong-origin login이 200, invalid JWT logout이 200으로 응답해 auth handler test 2건이 기대대로 실패했다.
|
||||
- GREEN focused: `npm run test:run -- src/app/App.test.tsx src/shared/mocks/__tests__/auth-handlers.test.ts src/shared/mocks/browser.test.ts` — 3 files / 27 tests 통과. `createMockHandlers(store, apiBaseUrl)` exact origin, invalid·revoked JWT 401, browser worker runtime env 주입을 확인했다.
|
||||
- Surface: `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts` — 4 browser projects / 20 tests 통과. 정상 mock login → protected shell, logout 후 재login, 320px·200% zoom, axe critical·serious 0건을 확인했다.
|
||||
- P2 focused Gate: `npm run test:run -- src/shared/config src/shared/mocks src/shared/ui/__tests__/mock-mode-banner.test.tsx` — 7 files / 23 tests 통과.
|
||||
|
||||
#### create_goal objective 초안 — P2-R2
|
||||
|
||||
- objective: P2-R2의 확정 review 항목 REV-P2-002와 REV-P2-003을 수정하고 mock/production API 계약 경계를 일치시킨다. plan-task.md에 추가된 Task R2.2만 수행한다.
|
||||
- 완료 조건: exact API origin, invalid·revoked JWT 401, 기존 정상 auth preview, focused test, mock E2E, P2-GATE와 누적 기록이 모두 확인된다.
|
||||
- 금지 조건: 계약 미제공 endpoint·DTO·회원 role 의미를 추정하거나 도메인 fixture를 추가하지 않는다.
|
||||
- 중단 조건: logout의 non-ADMIN token 정책이 현재 계약만으로 판정되지 않으면 해당 경로는 확장하지 않고 백엔드 확인 항목으로 기록한다.
|
||||
|
||||
### Task R2.3 — Phase 번호 표시·구현 문서 정합성 복구
|
||||
|
||||
**Goal 실행 `P2-R3`:** Phase 2 완료 뒤 남은 사용자 표시와 plan의 Files·Progress를 실제 구현에 맞게 정렬한다.
|
||||
|
||||
- 연결 리뷰: [Phase 2 리뷰](./reviews/review-phase-2.md) — `REV-P2-004`, `REV-P2-005`
|
||||
- 시작 조건:
|
||||
- 보호 shell 빈 상태가 Character 연결 시점을 Phase 2로 표시하고, Phase 2 주요 Files와 하단 Progress가 실제 변경 경로·완료 기록과 다른 상태를 확인한다.
|
||||
- 완료 증거:
|
||||
- 빈 상태 문구와 관련 test가 Character 구현 Phase 3을 가리킴
|
||||
- Phase 2 주요 Files가 실제 수정·생성·test 경로와 일치함
|
||||
- 하단 검증 기록에 Phase 2 구현의 무엇을/왜/실제 명령·결과/남은 항목이 누적됨
|
||||
- 문서 test, App focused test와 `git diff --check` 실행 기록
|
||||
- 범위 밖:
|
||||
- Phase 3 Character 기능 구현
|
||||
- 기존 Phase 2 구현·Gate 기록 삭제 또는 덮어쓰기
|
||||
|
||||
- [x] 현재 Phase 2 문구와 Files·Progress 불일치를 test와 파일 존재 검사로 고정한다.
|
||||
- [x] 사용자 표시를 Phase 3으로 바꾸고 Phase 2 주요 Files를 실제 경로로 갱신한다.
|
||||
- [x] Phase 2 구현·Gate 검증을 하단 Progress에 기존 기록을 보존한 채 누적한다.
|
||||
- [x] 문서/App focused test와 `git diff --check`를 실행한다.
|
||||
- [x] 결과를 plan-task.md 검증 기록과 [Phase 2 리뷰 문서](./reviews/review-phase-2.md)에 누적한다.
|
||||
|
||||
**P2-R3 수정 검증 기록 (2026-07-27):**
|
||||
|
||||
- RED: `npm run test:run -- src/app/App.test.tsx src/shared/mocks/__tests__/mock-preview-docs.test.ts` — Phase 3 placeholder와 실제 Phase 2 Files·Progress 기록이 없어 2 files 중 3 tests가 기대대로 실패했다.
|
||||
- GREEN focused: 같은 command — 2 files / 21 tests 통과.
|
||||
- Final Gate: 1차 `npm run test:run` — 34 files / 135 tests 통과. Reviewer blocker 보강 후 최종 `npm run test:run` — 34 files / 136 tests 통과. `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod`, `git diff --check` 모두 성공.
|
||||
- Production guard: `VITE_API_MODE=mock npm run build:prod` — 기대대로 `VITE_API_MODE=mock is only available during development` 오류로 거부됐다.
|
||||
- LSP diagnostics: `src/app` directory 3 files / 0 diagnostics, `src/shared/mocks` directory 8 files / 0 diagnostics. `App.tsx` 단일 fresh diagnostics는 timeout이었고 directory diagnostics와 typecheck로 보완 확인했다.
|
||||
|
||||
#### create_goal objective 초안 — P2-R3
|
||||
|
||||
- objective: P2-R3의 확정 review 항목 REV-P2-004와 REV-P2-005를 수정하고 Phase 번호 표시와 계획 문서 추적성을 복구한다. plan-task.md에 추가된 Task R2.3만 수행한다.
|
||||
- 완료 조건: 사용자 문구, 실제 Files, 하단 Progress, focused test와 문서 검증 기록이 서로 일치한다.
|
||||
- 금지 조건: Phase 3 기능 구현이나 기존 완료 기록 삭제·덮어쓰기를 수행하지 않는다.
|
||||
- 중단 조건: Phase 번호가 다시 변경되면 현재 기록을 지우지 않고 새 결정 기록을 먼저 추가한다.
|
||||
|
||||
### Task R2.4 — 동일 세션 재진입 fail-closed 복구
|
||||
|
||||
**Goal 실행 `P2-R4`:** 최초 보호 route 검증 성공 뒤 같은 session으로 route를 이탈·재진입해도 새 probe 성공 전에는 보호 shell을 다시 열지 않는다.
|
||||
|
||||
- 연결 리뷰: [Phase 2 리뷰](./reviews/review-phase-2.md) — `REV-P2-006`
|
||||
- 시작 조건:
|
||||
- 같은 `AuthSessionRecord`로 `/ai-characters` 성공 → `/login` 이탈 → `/ai-characters` 재진입 뒤 probe 실패 시 보호 shell이 노출되는 현재 동작을 실패 test로 재현한다.
|
||||
- 완료 증거:
|
||||
- 최초 성공 뒤 동일 session 재진입 pending·404·network·403에서 보호 shell 비노출
|
||||
- focused App test, server boundary E2E와 P2 Gate 실행 기록
|
||||
- 범위 밖:
|
||||
- 인증/session 저장 방식 전면 교체
|
||||
- Phase 3 Character 화면 구현
|
||||
|
||||
- [x] 같은 session route 이탈·재진입 뒤 pending·404 실패에서 보호 shell이 노출되는 실패 test를 추가한다.
|
||||
- [x] route 이탈 또는 새 보호 route probe 시작 시 이전 검증 상태가 새 진입을 열지 못하게 최소 상태 전이를 구현한다.
|
||||
- [x] focused App test와 server boundary E2E를 실행한다.
|
||||
- [x] 결과를 plan-task.md 검증 기록과 [Phase 2 리뷰 문서](./reviews/review-phase-2.md)에 누적한다.
|
||||
|
||||
**P2-R4 수정 검증 기록 (2026-07-27):**
|
||||
|
||||
- RED: `npm run test:run -- src/app/App.test.tsx` — 동일 session `/login` 이탈 후 `/ai-characters` 재진입 404에서 보호 `main`이 남아 1 test가 기대대로 실패했다.
|
||||
- GREEN focused: 같은 command — 1 file / 20 tests 통과. route visit key로 이전 보호 route 검증을 새 진입에 재사용하지 않게 했다.
|
||||
- Refactor guard: `ProtectedAdminShell`을 `src/app/protected-admin-shell.tsx`로 분리해 `App.tsx`를 153 pure LOC로 낮췄고, App focused test와 lint가 통과했다.
|
||||
|
||||
### Task R2.5 — Auth fixture status·media type 계약 복구
|
||||
|
||||
**Goal 실행 `P2-R5`:** mock auth fixture가 API Contract의 비ADMIN status와 login media type 경계를 완화하지 않게 한다.
|
||||
|
||||
- 연결 리뷰: [Phase 2 리뷰](./reviews/review-phase-2.md) — `REV-P2-007`
|
||||
- 시작 조건:
|
||||
- 비ADMIN logout 401과 `text/plain` login 200을 실패 test로 재현한다.
|
||||
- 완료 증거:
|
||||
- 비ADMIN logout 403, invalid·revoked token logout 401 유지
|
||||
- login은 `application/json`만 허용하고 지원하지 않는 media type은 415 오류 envelope 반환
|
||||
- 정상 login/logout 회귀 없음
|
||||
- 범위 밖:
|
||||
- 제공되지 않은 login credential 정책과 회원 role 의미 확장
|
||||
|
||||
- [x] 비ADMIN logout 403과 non-JSON login 415 실패 test를 추가한다.
|
||||
- [x] logout token access 분기와 login media type 검증을 계약 status에 맞춘다.
|
||||
- [x] auth handler focused test와 mock auth preview 회귀를 실행한다.
|
||||
- [x] 결과를 plan-task.md 검증 기록과 [Phase 2 리뷰 문서](./reviews/review-phase-2.md)에 누적한다.
|
||||
|
||||
**P2-R5 수정 검증 기록 (2026-07-27):**
|
||||
|
||||
- RED: `npm run test:run -- src/shared/mocks/__tests__/auth-handlers.test.ts` — 비ADMIN logout이 401, `text/plain` login이 200으로 응답해 2 tests가 기대대로 실패했다.
|
||||
- GREEN focused: 같은 command — 1 file / 10 tests 통과. 비ADMIN logout은 403, invalid·revoked logout은 401 유지, unsupported media type login은 415로 고정했다.
|
||||
|
||||
### Task R2.6 — 모든 mock route state의 지속 안내 복구
|
||||
|
||||
**Goal 실행 `P2-R6`:** mock mode의 login·성공 shell·403·보호 오류 상태에서 Mock Preview 안내를 지속 표시한다.
|
||||
|
||||
- 연결 리뷰: [Phase 2 리뷰](./reviews/review-phase-2.md) — `REV-P2-008`
|
||||
- 시작 조건:
|
||||
- mock mode 403과 404/network 보호 오류 화면에서 banner가 사라지는 현재 동작을 실패 test로 재현한다.
|
||||
- 완료 증거:
|
||||
- mock mode login·성공 shell·403·404/network에서 banner 표시
|
||||
- server mode에서는 banner 미표시 유지
|
||||
- 접근성·320px 회귀 없음
|
||||
- 범위 밖:
|
||||
- 오류 화면 디자인 개편
|
||||
|
||||
- [x] 403과 보호 오류 화면의 mock banner 실패 test를 추가한다.
|
||||
- [x] Mock Preview 안내를 모든 mock route state의 공통 상위 경계에 한 번만 배치한다.
|
||||
- [x] focused App/banner test와 mock preview E2E를 실행한다.
|
||||
- [x] 결과를 plan-task.md 검증 기록과 [Phase 2 리뷰 문서](./reviews/review-phase-2.md)에 누적한다.
|
||||
|
||||
**P2-R6 수정 검증 기록 (2026-07-27):**
|
||||
|
||||
- RED: `npm run test:run -- src/app/App.test.tsx` — mock mode access denied와 보호 route 오류 화면에서 `Mock Preview` status가 없어 2 tests가 기대대로 실패했다.
|
||||
- GREEN focused: 같은 command — 1 file / 22 tests 통과. login·성공 shell·403·보호 오류가 공통 `RouteFrame`의 banner를 사용한다.
|
||||
|
||||
### Task R2.7 — Mode별 bare E2E 실행 경계 복구
|
||||
|
||||
**Goal 실행 `P2-R7`:** bare `npm run e2e`와 `npm run e2e:mock`이 각 mode에 유효한 spec만 수집·실행하게 한다.
|
||||
|
||||
- 연결 리뷰: [Phase 2 리뷰](./reviews/review-phase-2.md) — `REV-P2-009`
|
||||
- 시작 조건:
|
||||
- 두 bare E2E 명령의 `--list`가 같은 spec 집합을 수집하고 mock bare 실행이 실패하는 현재 동작을 실패 test로 재현한다.
|
||||
- 완료 증거:
|
||||
- bare `npm run e2e`와 `npm run e2e:mock`이 각 mode 유효 spec만 실행해 exit 0
|
||||
- 목록·실행 contract test와 scripts 문서가 일치
|
||||
- 범위 밖:
|
||||
- Playwright config 복제와 CI 전면 재구성
|
||||
|
||||
- [x] package script 또는 Playwright 설정의 mode별 spec 경계 실패 test를 추가한다.
|
||||
- [x] 가장 작은 script/config 변경으로 server 전용·mock 전용 spec 수집을 분리한다.
|
||||
- [x] `--list`, bare E2E 실행과 docs sync test를 실행한다.
|
||||
- [x] 결과를 plan-task.md 검증 기록과 [Phase 2 리뷰 문서](./reviews/review-phase-2.md)에 누적한다.
|
||||
|
||||
**P2-R7 수정 검증 기록 (2026-07-27):**
|
||||
|
||||
- RED: `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts` — bare `e2e` script가 mode별 spec 경계를 명시하지 않아 1 test가 기대대로 실패했다.
|
||||
- GREEN focused: `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 2 files / 5 tests 통과.
|
||||
- 목록 검증: `npm run e2e -- --list` — server 4 files / 32 tests, `npm run e2e:mock -- --list` — mock 2 files / 24 tests로 분리됐다.
|
||||
- Surface: `npm run e2e` — 32 tests 통과, `npm run e2e:mock` — 24 tests 통과.
|
||||
|
||||
**P2-R4~P2-R7 통합 검증 기록 (2026-07-27):**
|
||||
|
||||
- `npm run test:run -- src/app/App.test.tsx src/shared/mocks/__tests__/auth-handlers.test.ts src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 4 files / 37 tests 통과.
|
||||
- `npm run test:run` — 34 files / 141 tests 통과.
|
||||
- `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — 모두 exit 0.
|
||||
- `VITE_API_MODE=mock npm run build:prod` — 기대대로 `VITE_API_MODE=mock is only available during development`로 exit 1.
|
||||
- `test ! -e dist/mockServiceWorker.js`, `git diff --check HEAD` — 모두 exit 0.
|
||||
- LSP diagnostics: `src/app` directory 4 files / 0 diagnostics, `src/shared/mocks` directory 8 files / 0 diagnostics.
|
||||
|
||||
### Task R2.8 — Phase 2 완료 문서 추적성 복구
|
||||
|
||||
**Goal 실행 `P2-R8`:** `P2-R4`~`P2-R7` 이후의 실제 변경 경로·검증 범위와 Phase 2 plan/review 메타데이터를 일치시킨다.
|
||||
|
||||
- 연결 리뷰: [Phase 2 리뷰](./reviews/review-phase-2.md) — `REV-P2-010`
|
||||
- 시작 조건:
|
||||
- 3차 review 반영 전 `git diff HEAD --name-only`은 37개 경로인데 review 상단과 종료 판정은 36개로 기록된 상태였음을 확인한다.
|
||||
- Phase 2 `주요 Files`가 `src/app/App.test.tsx`, `src/app/admin-pages.tsx`, `src/app/browser-location.ts`, `src/app/protected-admin-shell.tsx`를 포함하지 않는 상태를 확인한다.
|
||||
- 완료 증거:
|
||||
- Phase 2 `주요 Files`가 `P2-R4`~`P2-R7`까지의 실제 코드·test 경로와 일치함
|
||||
- review 대상·working tree 경로 수·종료 판정과 3차 재검증 기록이 현재 범위와 일치함
|
||||
- 완료된 회귀 Task의 Files·Interfaces와 검증 근거가 독립 실행자가 추측하지 않을 수준으로 기록됨
|
||||
- 문서 대체 검증과 `git diff --check` 결과가 plan/review에 누적됨
|
||||
- 범위 밖:
|
||||
- 애플리케이션 코드·test·설정 변경
|
||||
- 기존 완료 체크와 과거 검증 기록 삭제 또는 덮어쓰기
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/20260725_AI캐릭터관리자웹/plan-task.md`
|
||||
- Modify: `docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md`
|
||||
- Test: 없음 — 이 Task는 완료 문서의 소유 경로와 검증 메타데이터만 정정한다.
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `git diff HEAD --name-only`, `P2-R4`~`P2-R7` 구현·검증 기록, `REV-P2-010`
|
||||
- Produces: Phase 3 실행자와 reviewer가 사용할 최신 Phase 2 Files·review 범위·검증 기준
|
||||
|
||||
**TDD 예외 사유:** 실행 동작을 변경하지 않는 문서 정합성 수정이므로 실패 test를 추가하면 제품 동작과 무관한 문자열 고정 test만 늘어난다.
|
||||
|
||||
**대체 검증 방법:** 현재 변경 경로와 Phase 2 Files·review 범위를 직접 대조하고, review ID·goal 연결과 Markdown diff를 명령으로 검사한다.
|
||||
|
||||
- [x] Phase 2 `주요 Files`에 `P2-R4`~`P2-R7`의 실제 코드·test 경로를 추가한다.
|
||||
- [x] review 상단의 대상·working tree 기준과 종료 판정을 3차 재검증 범위에 맞춘다.
|
||||
- [x] 완료된 회귀 Task의 Files·Interfaces·검증 근거 누락을 기존 기록을 보존한 채 보완한다.
|
||||
- [x] 아래 대체 검증을 실행하고 실제 결과를 plan/review에 누적한다.
|
||||
|
||||
**P2-R8 수정 검증 기록 (2026-07-27):**
|
||||
|
||||
- 대체 RED: `git diff HEAD --name-only`로 기존 tracked 변경 37개 경로를 확인했고, `rg -n 'P2-R(10|[1-9])|REV-P2-0(0[1-9]|1[0-3])|주요 Files|기준 commit 또는 working tree' docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md`에서 `REV-P2-010`과 `P2-R8`이 문서 추적성 미충족 상태로 남아 있음을 확인했다.
|
||||
- GREEN: Phase 2 `주요 Files`에 `src/app/App.test.tsx`, `src/app/admin-pages.tsx`, `src/app/browser-location.ts`, `src/app/protected-admin-shell.tsx`를 추가하고, review 상단·요약·종료 판정·수정 후 검증 기록을 `P2-R8` 완료와 `P2-R9`~`P2-R10` 잔여 상태로 정렬했다.
|
||||
- 검증: `git diff --check -- docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md` — exit 0.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `git diff HEAD --name-only`, `rg -n 'P2-R(10|[1-9])|REV-P2-0(0[1-9]|1[0-3])|주요 Files|기준 commit 또는 working tree' docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md`, `git diff --check -- docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md`
|
||||
- **기대 결과:** 실제 변경 경로와 문서의 소유 Files·review 범위 차이 0건, review ID와 goal 연결 누락 0건, whitespace 오류 0건.
|
||||
- **수동 확인:** review 상단·발견 요약·plan 전환·종료 판정·수정 후 검증 기록이 모두 `P2-R1`~`P2-R10`의 현재 상태를 같은 의미로 표시한다.
|
||||
|
||||
### Task R2.9 — Mock Preview 모바일 메뉴의 반응형·inert 경계 복구
|
||||
|
||||
**Goal 실행 `P2-R9`:** Mock Preview 모바일 메뉴가 열린 상태에서도 banner를 배경 inert 경계에 포함하고 desktop breakpoint 전환 시 숨은 overlay와 inert 상태를 함께 해제한다.
|
||||
|
||||
- 연결 리뷰: [Phase 2 리뷰](./reviews/review-phase-2.md) — `REV-P2-011`, `REV-P2-012`
|
||||
- 시작 조건:
|
||||
- 320px에서 모바일 메뉴를 열면 Mock Preview banner의 `closest('[inert]')`가 `null`인 현재 동작을 browser 재현으로 확인한다.
|
||||
- 같은 상태에서 viewport를 1,200px로 넓히면 overlay는 `display:none`이지만 `main`의 inert ancestor와 `aria-hidden=true`가 남는 현재 동작을 browser 재현으로 확인한다.
|
||||
- 완료 증거:
|
||||
- 모바일 메뉴가 열린 동안 banner와 shell 본문이 같은 background inert·`aria-hidden` 경계에 포함됨
|
||||
- viewport가 `lg` 이상으로 바뀌면 mobile menu state와 inert·`aria-hidden`이 해제되고 desktop navigation·logout·main을 keyboard와 pointer로 사용할 수 있음
|
||||
- breakpoint 자동 종료 시 숨겨진 mobile trigger로 focus를 복귀하지 않음
|
||||
- App focused test, mock responsive E2E, 기존 accessibility shell E2E와 P2 Gate 실행 기록
|
||||
- 범위 밖:
|
||||
- Admin shell navigation 구조 전면 교체
|
||||
- Phase 3 Character UI와 새로운 breakpoint 체계 도입
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/app/App.tsx`
|
||||
- Modify: `src/app/protected-admin-shell.tsx`
|
||||
- Test: `src/app/App.test.tsx`
|
||||
- Test: `tests/e2e/mock-preview-shell.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `MockModeBanner({ apiMode }: { readonly apiMode: ApiMode })`, `ProtectedAdminShell({ routeError }: { readonly routeError: string | null })`, Tailwind `lg=1024px` shell breakpoint
|
||||
- Produces: `ProtectedAdminShell({ routeError, apiMode }: { readonly routeError: string | null; readonly apiMode: ApiMode })`와 mobile overlay 표시 여부·background `inert`·`aria-hidden`이 항상 같은 상태인 composition
|
||||
|
||||
**TDD 절차:**
|
||||
|
||||
- [x] **RED: 실패 테스트 작성/실패 확인** — `src/app/App.test.tsx`에 mock banner가 열린 menu의 inert background에 포함되는 test와 `lg` 전환 시 menu state가 닫히는 test를 추가하고, `npm run test:run -- src/app/App.test.tsx src/shared/ui/__tests__/mock-mode-banner.test.tsx`가 두 assertion에서 실패하는지 확인한다.
|
||||
- [x] **GREEN: 최소 구현/통과 확인** — banner와 shell을 하나의 background inert 경계로 조합하고 native viewport change에서 mobile state만 닫는 최소 구현으로 같은 명령을 통과시킨다.
|
||||
- [x] **REFACTOR: 정리/회귀 확인** — focus 복귀 조건과 breakpoint 이름을 정리한 뒤 App focused test, mock preview E2E, 기존 accessibility shell E2E와 P2 focused Gate를 실행한다.
|
||||
- [x] TDD 단계와 아래 검증 기준의 실제 결과를 plan/review에 누적한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `npm run test:run -- src/app/App.test.tsx src/shared/ui/__tests__/mock-mode-banner.test.tsx`, `npm run test:run -- src/shared/config src/shared/mocks src/shared/ui/__tests__/mock-mode-banner.test.tsx`, `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts`, `npm run e2e -- tests/e2e/accessibility-shell.spec.ts`, `npm run typecheck`, `npm run lint`
|
||||
- **기대 결과:** focused unit 2 files / 26 tests 이상, P2 focused 7 files / 25 tests 이상, mock preview 4 projects / 24 tests 이상, accessibility shell 4 projects / 12 tests, type·lint 오류 0건; 1,024px·1,200px 전환 뒤 hidden mobile overlay와 inert background 잔존 0건.
|
||||
- **수동 확인:** 320px에서 메뉴를 열면 banner와 본문이 보조기기 탐색에서 제외되고 menu만 탐색 가능하며, 열린 상태로 1,024px와 1,200px로 넓히면 desktop navigation·logout·main이 즉시 다시 동작한다.
|
||||
|
||||
### Task R2.10 — 보호 route 오류의 fail-closed 재시도 복구
|
||||
|
||||
**Goal 실행 `P2-R10`:** 보호 route probe의 404·network 오류 화면에서 보호 shell을 열지 않은 채 사용자가 명시적으로 재시도할 수 있게 한다.
|
||||
|
||||
- 연결 리뷰: [Phase 2 리뷰](./reviews/review-phase-2.md) — `REV-P2-013`
|
||||
- 시작 조건:
|
||||
- `P2-R9` 완료.
|
||||
- 저장된 ADMIN session의 probe가 404 또는 network error로 실패하면 오류 alert만 있고 재시도·이동 control이 없는 현재 동작을 확인한다.
|
||||
- 완료 증거:
|
||||
- 404·network error 화면에 keyboard로 사용할 수 있는 명시적 재시도 control 제공
|
||||
- 재시도 중과 재실패 상태에서 보호 shell·navigation·logout 비노출 유지
|
||||
- 재시도 probe가 성공한 뒤에만 현재 session·route visit의 보호 shell 표시
|
||||
- server가 제공한 정상 오류 envelope의 한국어 message는 보존하고 network·invalid response에는 안전한 공통 안내 사용
|
||||
- App focused test, server boundary E2E와 P2 Gate 실행 기록
|
||||
- 범위 밖:
|
||||
- 자동 retry·자동 mock fallback
|
||||
- Phase 3 Character 목록 오류 UI와 API client 전면 교체
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/app/App.tsx`
|
||||
- Test: `src/app/App.test.tsx`
|
||||
- Test: `tests/e2e/server-mode-boundary.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `ProtectedRouteErrorPage({ message }: { readonly message: string })`, `BrowserLocationSnapshot.visitKey`, `ApiError.message`
|
||||
- Produces: `ProtectedRouteErrorPage({ message, onRetry }: { readonly message: string; readonly onRetry: () => void })`와 수동 retry마다 새 probe attempt를 식별하는 App state
|
||||
|
||||
**TDD 절차:**
|
||||
|
||||
- [x] **RED: 실패 테스트 작성/실패 확인** — `src/app/App.test.tsx`에 404 → retry 성공과 network → retry 실패의 fail-closed test를 추가하고 `npm run test:run -- src/app/App.test.tsx`가 retry control 부재로 두 test에서 실패하는지 확인한다.
|
||||
- [x] **GREEN: 최소 구현/통과 확인** — 오류 page에 retry button과 현재 session·route에 귀속된 새 probe attempt만 추가해 같은 명령을 통과시킨다.
|
||||
- [x] **REFACTOR: 정리/회귀 확인** — 오류 message·attempt state 이름을 정리한 뒤 App focused test, server boundary E2E, P2 focused Gate와 전체 unit을 실행한다.
|
||||
- [x] TDD 단계와 아래 검증 기준의 실제 결과를 plan/review에 누적한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `npm run test:run -- src/app/App.test.tsx`, `npm run test:run -- src/shared/config src/shared/mocks src/shared/ui/__tests__/mock-mode-banner.test.tsx`, `npm run test:run`, `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts`, `npm run typecheck`, `npm run lint`
|
||||
- **기대 결과:** App 1 file / 26 tests 이상, P2 focused 7 files / 25 tests 이상, 전체 34 files / 145 tests 이상, server boundary 4 projects / 16 tests 이상, type·lint 오류 0건; retry 성공 전 보호 `main`·logout button 0건.
|
||||
- **수동 확인:** 404와 offline 상태에서 retry button의 accessible name·focus indicator를 확인하고, 실패 중 shell이 보이지 않으며 연결 복구 후 한 번의 수동 retry로 shell이 열린다.
|
||||
|
||||
### Task R2.11 — Phase 2 working tree 전체 경로 집계 복구
|
||||
|
||||
**Goal 실행 `P2-R11`:** tracked diff와 untracked 파일을 함께 집계해 Phase 2 review 범위와 완료 문서가 실제 working tree 전체를 누락 없이 표시하게 한다.
|
||||
|
||||
- 연결 리뷰: [Phase 2 리뷰](./reviews/review-phase-2.md) — `REV-P2-014`
|
||||
- 시작 조건:
|
||||
- `P2-R8` 완료.
|
||||
- `git diff HEAD --name-only | wc -l`은 37을 반환하지만 `git status --porcelain=v1 | wc -l`은 38을 반환하고 `git ls-files --others --exclude-standard`에 `src/app/protected-admin-shell.tsx`가 존재함을 확인한다.
|
||||
- 완료 증거:
|
||||
- review의 현재 working tree 범위를 tracked 37개와 untracked 1개를 포함한 38개 변경 항목으로 기록
|
||||
- `P2-R8` 대체 검증과 후속 reviewer 명령이 untracked 파일을 빠뜨리지 않는 `git status --short --untracked-files=all` 기준을 사용
|
||||
- plan/review의 현재 범위·종료 판정·검증 기록과 실제 working tree 항목 차이 0건
|
||||
- 범위 밖:
|
||||
- 애플리케이션 코드·test·설정 변경
|
||||
- 과거 시점의 37개 tracked diff 실행 결과 삭제 또는 덮어쓰기
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/20260725_AI캐릭터관리자웹/plan-task.md`
|
||||
- Modify: `docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md`
|
||||
- Test: 없음 — working tree 집계 명령과 완료 문서 메타데이터만 정정한다.
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `git diff HEAD --name-only`, `git status --short --untracked-files=all`, `git ls-files --others --exclude-standard`, `REV-P2-014`
|
||||
- Produces: tracked·untracked를 모두 포함하는 Phase 2 working tree 범위와 reviewer 검증 기준
|
||||
|
||||
**TDD 예외 사유:** 실행 동작을 변경하지 않는 문서·검증 명령 정정이므로 제품 test가 아닌 실제 Git 상태 대조가 실패·성공 증거다.
|
||||
|
||||
**대체 검증 방법:** tracked diff 수, 전체 status 항목 수와 untracked 목록을 각각 수집해 합계와 문서 범위를 대조한다.
|
||||
|
||||
- [x] `P2-R8`의 대체 검증 기준에 `git status --short --untracked-files=all`과 untracked 확인 명령을 추가한다.
|
||||
- [x] review 상단·4차 검증·종료 판정의 현재 working tree 범위를 tracked 37개 + untracked 1개 = 38개 항목으로 정렬한다.
|
||||
- [x] 과거 37개 tracked diff 기록은 당시 실행 결과로 보존하고 현재 전체 범위와 구분한다.
|
||||
- [x] 아래 대체 검증과 문서 diff 검사를 실행하고 결과를 plan/review에 누적한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `git diff HEAD --name-only | wc -l`, `git status --short --untracked-files=all | wc -l`, `git ls-files --others --exclude-standard`, `git diff --check -- docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md`
|
||||
- **기대 결과:** 현재 기준 tracked diff 37개, untracked `src/app/protected-admin-shell.tsx` 1개, working tree 전체 38개 변경 항목, 현재 문서 범위 누락과 whitespace 오류 0건.
|
||||
- **수동 확인:** review 상단·4차 검증·발견 요약·plan 전환·종료 판정이 `P2-R1`~`P2-R13`의 현재 상태와 38개 working tree 항목을 같은 의미로 표시한다.
|
||||
|
||||
### Task R2.12 — 보호 route probe 대기 상태의 접근 가능한 피드백 복구
|
||||
|
||||
**Goal 실행 `P2-R12`:** 최초 보호 route 확인과 수동 retry가 진행되는 동안 보호 shell을 숨긴 채 사용자와 보조기기에 명시적인 loading 상태를 제공한다.
|
||||
|
||||
- 연결 리뷰: [Phase 2 리뷰](./reviews/review-phase-2.md) — `REV-P2-015`
|
||||
- 시작 조건:
|
||||
- `P2-R10` 완료.
|
||||
- retry 응답을 350ms 지연하면 `#root` child 0개, `main`·`status`·`alert` 0개이고 active element가 `BODY`인 현재 Chromium 동작을 확인한다.
|
||||
- 완료 증거:
|
||||
- 최초 probe와 retry pending 모두 `RouteFrame` 안에 지속적으로 보이는 한국어 loading 상태와 `role="status"` 제공
|
||||
- pending 동안 보호 `main`·navigation·logout 비노출과 mock mode banner 지속 표시 유지
|
||||
- 성공·404·network·401·403의 기존 fail-closed 분기와 수동 retry 동작 회귀 없음
|
||||
- App focused test, 지연된 server boundary E2E, P2 focused Gate와 전체 unit 실행 기록
|
||||
- 범위 밖:
|
||||
- 보호 shell skeleton 선노출
|
||||
- 자동 retry·자동 mock fallback과 전역 router 도입
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/app/App.tsx`
|
||||
- Test: `src/app/App.test.tsx`
|
||||
- Test: `tests/e2e/server-mode-boundary.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `PageState({ state: "loading", title, description })`, `RouteFrame({ apiMode, children })`, `ProtectedRouteVerification`
|
||||
- Produces: 현재 session·route visit·retry attempt가 미검증인 동안 렌더되는 `RouteFrame` + accessible loading `PageState`
|
||||
|
||||
**TDD 절차:**
|
||||
|
||||
- [x] **RED: 실패 테스트 작성/실패 확인** — `src/app/App.test.tsx`에 최초 probe pending 전용 test를 추가하고 기존 404 retry test에 retry pending assertion을 보강해 두 pending 시나리오가 `role="status"`를 제공하면서 보호 shell을 숨기는지 확인한다. `npm run test:run -- src/app/App.test.tsx`가 status 부재로 실패하는지 확인한다.
|
||||
- [x] **GREEN: 최소 구현/통과 확인** — 기존 `PageState` loading variant를 미검증 branch에 조합하는 최소 구현으로 같은 명령을 통과시킨다.
|
||||
- [x] **REFACTOR: 정리/회귀 확인** — loading copy와 branch 이름을 정리하고 기존 404 retry E2E에 350ms pending status assertion을 추가한 뒤 focused·전체 회귀를 실행한다.
|
||||
- [x] TDD 단계와 아래 검증 기준의 실제 결과를 plan/review에 누적한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `npm run test:run -- src/app/App.test.tsx`, `VITE_API_MODE=server npx playwright test tests/e2e/server-mode-boundary.spec.ts`, `npm run test:run -- src/shared/config src/shared/mocks src/shared/ui/__tests__/mock-mode-banner.test.tsx`, `npm run test:run`, `npm run typecheck`, `npm run lint`
|
||||
- **기대 결과:** App 1 file / 27 tests, server boundary 4 projects / 12 tests 이상, P2 focused 7 files / 25 tests 이상, 전체 34 files / 147 tests 이상, type·lint 오류 0건; 최초 pending 전용 test 1건과 기존 retry test 보강으로 두 pending 시나리오에서 visible `role="status"` 1건과 보호 `main`·logout 0건.
|
||||
- **수동 확인:** 375px server mode에서 최초 진입과 retry 응답을 각각 350ms 이상 지연해 한국어 loading 안내가 보이고 빈 root가 발생하지 않으며, 성공 뒤에만 shell이 열린다.
|
||||
|
||||
### Task R2.13 — Mode별 focused E2E 필터와 network retry 증거 복구
|
||||
|
||||
**Goal 실행 `P2-R13`:** bare mode 분리를 유지하면서 CLI spec 필터가 실제로 focused 실행되게 하고 network retry 실패를 server boundary E2E로 고정한다.
|
||||
|
||||
- 연결 리뷰: [Phase 2 리뷰](./reviews/review-phase-2.md) — `REV-P2-016`
|
||||
- 시작 조건:
|
||||
- `P2-R12` 완료.
|
||||
- `npm run e2e -- --list tests/e2e/server-mode-boundary.spec.ts`가 4 files / 32 tests를 수집하지만 직접 `VITE_API_MODE=server npx playwright test tests/e2e/server-mode-boundary.spec.ts --list`는 1 file / 12 tests만 수집함을 확인한다.
|
||||
- `P2-R10` 검증 기준은 server boundary 16 tests 이상을 요구하지만 현재 spec은 3 scenarios / 12 tests이고 network error test는 retry를 실행하지 않음을 확인한다.
|
||||
- 완료 증거:
|
||||
- bare `npm run e2e`는 server 4개 spec만, bare `npm run e2e:mock`은 mock 2개 spec만 실행
|
||||
- 두 npm script 뒤에 전달한 file filter가 고정 spec 목록과 합쳐지지 않고 해당 file만 수집
|
||||
- server boundary에 network error → 수동 retry → network 재실패의 retry 유지·shell 비노출·worker 0건 E2E 추가
|
||||
- mode boundary contract, focused list/실행, bare mode E2E, P2 Gate와 문서 동기화 기록
|
||||
- 범위 밖:
|
||||
- Playwright config 복제
|
||||
- project matrix·worker 수·CI 전체 구조 변경
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `package.json`
|
||||
- Modify: `playwright.config.ts`
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/agent-guide/scripts.md`
|
||||
- Test: `src/shared/mocks/__tests__/mode-boundary.test.ts`
|
||||
- Test: `src/shared/mocks/__tests__/mock-preview-docs.test.ts`
|
||||
- Test: `tests/e2e/server-mode-boundary.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `VITE_API_MODE=server | mock`, Playwright `testMatch`, npm argument forwarding, 현재 server 4개·mock 2개 spec allowlist
|
||||
- Produces: file 목록을 내장하지 않는 `e2e`·`e2e:mock` scripts와 server `testMatch=["**/server-mode-boundary.spec.ts", "**/smoke.spec.ts", "**/auth.spec.ts", "**/accessibility-shell.spec.ts"]`, mock `testMatch=["**/mock-preview-shell.spec.ts", "**/mock-mode-boundary.spec.ts"]`; CLI file filter와 mode allowlist의 교집합 실행 계약
|
||||
|
||||
**TDD 절차:**
|
||||
|
||||
- [x] **RED: 실패 테스트 작성/실패 확인** — mode boundary test가 npm scripts의 고정 spec 목록 제거와 config의 mode별 exact allowlist를 요구하게 하고 `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts`가 현재 script/config로 실패하는지 확인한다.
|
||||
- [x] **RED: surface 실패 확인** — network 재실패 retry E2E를 추가하고 현재 구현에서 동작을 확인하되, `npm run e2e -- --list tests/e2e/server-mode-boundary.spec.ts`가 불필요한 4 files를 수집해 focused list assertion을 실패시키는지 확인한다.
|
||||
- [x] **GREEN: 최소 구현/통과 확인** — script는 각각 `VITE_API_MODE=server playwright test`, `VITE_API_MODE=mock playwright test`만 유지하고 config의 `apiMode`별 `testMatch`에 Interfaces의 exact glob allowlist를 옮겨 contract·focused list·network retry E2E를 통과시킨다.
|
||||
- [x] **REFACTOR: 정리/회귀 확인** — mode allowlist 상수와 README/scripts 문구를 정리한 뒤 focused·bare E2E와 P2 Gate를 실행한다.
|
||||
- [x] TDD 단계와 아래 검증 기준의 실제 결과를 plan/review에 누적한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts`, `npm run e2e -- --list tests/e2e/server-mode-boundary.spec.ts`, `npm run e2e:mock -- --list tests/e2e/mock-preview-shell.spec.ts`, `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts`, `npm run e2e`, `npm run e2e:mock`, `npm run test:run -- src/shared/config src/shared/mocks src/shared/ui/__tests__/mock-mode-banner.test.tsx`, `npm run typecheck`, `npm run lint`
|
||||
- **기대 결과:** focused server boundary 1 file / 16 tests 이상, focused mock preview 1 file / 24 tests, bare server 4 files / 36 tests 이상, bare mock 2 files / 28 tests, P2 focused 7 files / 25 tests 이상, type·lint 오류 0건; network retry 재실패 뒤 retry button 1건, 보호 `main`·logout·mock worker 0건.
|
||||
- **수동 확인:** 없음 — mode/file 수집 목록, network retry와 worker 경계는 Playwright list·실행 결과로 결정적으로 검증한다.
|
||||
|
||||
### Task R2.14 — P2-R12 test 증거 정합성 복구
|
||||
|
||||
**Goal 실행 `P2-R14`:** P2-R12의 실제 test case 구조·실행 수를 plan/review의 완료 증거와 사실대로 정렬한다.
|
||||
|
||||
- 연결 리뷰: [Phase 2 리뷰](./reviews/review-phase-2.md) — `REV-P2-017`
|
||||
- 시작 조건:
|
||||
- `P2-R11`~`P2-R13` 구현·검증 완료.
|
||||
- `rg -c '^test\(' src/app/App.test.tsx`와 focused 실행이 모두 27 tests를 반환한다.
|
||||
- 최초 pending은 전용 test 1건, retry pending은 기존 404 retry test의 assertion 보강이지만 `P2-R12` 체크와 수정 기록은 두 신규 test·28 tests 이상으로 표시한다.
|
||||
- 완료 증거:
|
||||
- P2-R12의 현재 완료 증거가 최초 pending 전용 test 1건 추가 + 기존 retry test 보강 + App 27 tests로 일치
|
||||
- 이전 “두 신규 test·28 tests 이상” 표현과 정정 사유를 새 Progress에 보존하고 실제 실행 결과를 덮어쓰지 않음
|
||||
- plan/review 검색·diff 검사와 App focused test 결과를 수정 검증 기록에 누적
|
||||
- 범위 밖:
|
||||
- 애플리케이션 코드·test·설정 변경
|
||||
- 같은 retry pending 동작을 중복 검증하는 test case 추가
|
||||
- 기존 실행 이력 삭제 또는 제품 동작 완료 주장 변경
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/20260725_AI캐릭터관리자웹/plan-task.md`
|
||||
- Modify: `docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md`
|
||||
- Test: 없음 — 구현·test 동작은 바꾸지 않고 완료 문서의 사실관계와 현재 상태만 정정한다.
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `REV-P2-017`, `src/app/App.test.tsx`의 27 test declarations, `P2-R12` 완료 기록
|
||||
- Produces: actual test case와 검증 시나리오를 구분한 P2-R12 완료 증거와 정정 기록
|
||||
|
||||
**TDD 예외 사유:** 제품 코드와 test를 변경하지 않는 문서 정정이며, 새 test case를 추가하면 이미 존재하는 retry pending assertion을 중복하게 된다. 실제 test 선언·focused 결과와 문서 문자열 대조를 실패·성공 증거로 사용한다.
|
||||
|
||||
**대체 검증 방법:** App test 선언 수·두 pending 시나리오의 위치·focused 실행 수를 plan/review 문구와 대조하고, stale 표현 검색과 문서 diff 검사를 실행한다.
|
||||
|
||||
- [x] `rg -c '^test\(' src/app/App.test.tsx`와 두 pending test 위치를 수집해 27 tests 구조를 재확인한다.
|
||||
- [x] P2-R12 체크·기대 결과·수정 검증 기록을 최초 전용 test 1건 + 기존 retry test 보강 + App 27 tests로 정정하고 이전 표현·정정 사유를 새 기록에 남긴다.
|
||||
- [x] review 상단·발견 요약·종료 판정을 `REV-P2-017` 수정 완료와 `P2-R14` 완료 상태로 갱신한다.
|
||||
- [x] 아래 대체 검증과 문서 diff 검사를 실행하고 실제 결과를 plan/review에 누적한다.
|
||||
|
||||
**P2-R14 수정 검증 기록 (2026-07-27):**
|
||||
|
||||
- 대체 RED: `rg -c '^test\(' src/app/App.test.tsx` — 27건. `rg -n 'initial protected route probe is pending|retries a protected route 404' src/app/App.test.tsx` — 최초 pending 전용 test 1건과 기존 404 retry test 1건을 확인했다. 기존 P2-R12 기록은 두 신규 test·App 28 tests 이상으로 표시돼 실제 구조와 불일치했다.
|
||||
- GREEN: P2-R12 TDD 절차·기대 결과와 P2-R11~P2-R13 수정 검증 기록을 최초 pending 전용 test 1건 + 기존 retry test 보강 + App 27 tests로 정정했다. 이전 잘못된 표현과 정정 사유는 이 P2-R14 기록과 review `REV-P2-017`에 보존했다.
|
||||
- Focused: `npm run test:run -- src/app/App.test.tsx` — 1 file / 27 tests 통과.
|
||||
- 검증: `rg -n '^[[:space:]]+- P2-R12 RED:.*신규 pending status tests 2건' docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md` — no matches. `git diff --check -- docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md` — exit 0.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `rg -c '^test\(' src/app/App.test.tsx`, `rg -n 'initial protected route probe is pending|retries a protected route 404' src/app/App.test.tsx`, `npm run test:run -- src/app/App.test.tsx`, `rg -n '^[[:space:]]+- P2-R12 RED:.*신규 pending status tests 2건' docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md`, `git diff --check -- docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md`
|
||||
- **기대 결과:** App test 선언·focused 실행 27건, 최초 pending 전용 test와 기존 retry test 각 1건 확인, 정정 대상인 잘못된 RED 기록 검색 결과 0건, 문서 whitespace 오류 0건.
|
||||
- **수동 확인:** plan/review가 test case 수와 검증 시나리오 수를 구분하고 P2-R12의 실제 완료 증거를 같은 의미로 표시한다.
|
||||
|
||||
### Task R2.15 — review plan 전환 절의 현재 수정 상태 정합성 복구
|
||||
|
||||
**Goal 실행 `P2-R15`:** Phase 2 review의 plan·goal 전환 절이 `P2-R8`~`P2-R13`의 현재 수정 완료 상태를 요약·상세·종료 판정과 일치하게 표시하도록 정정한다.
|
||||
|
||||
- 연결 리뷰: [Phase 2 리뷰](./reviews/review-phase-2.md) — `REV-P2-018`
|
||||
- 시작 조건:
|
||||
- review 요약·상세·종료 판정은 `REV-P2-010`~`REV-P2-016`과 `P2-R8`~`P2-R13`을 수정 완료로 표시한다.
|
||||
- 같은 review의 `7. 확정 항목의 plan·goal 전환`은 3차·4차 회귀 Task를 여전히 “아직 수정하지 않았다”고 표시하는 모순을 확인한다.
|
||||
- 완료 증거:
|
||||
- review §7의 `P2-R8`~`P2-R13` 전환 설명이 각 Task의 실제 수정 완료 상태와 일치함
|
||||
- 과거 시점의 발견·수정 전 검증 기록은 삭제하거나 현재 상태처럼 다시 쓰지 않고 그대로 보존함
|
||||
- stale 현재 상태 표현 검색 결과 0건과 문서 whitespace 오류 0건
|
||||
- 범위 밖:
|
||||
- 애플리케이션 코드·test·설정 변경
|
||||
- 과거 검증 기록과 당시의 남은 항목 삭제 또는 덮어쓰기
|
||||
- `P2-R8`~`P2-R13` 구현·검증의 재수행
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/20260725_AI캐릭터관리자웹/plan-task.md`
|
||||
- Modify: `docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md`
|
||||
- Test: 없음 — 완료된 review의 현재 상태 문구만 정정하는 문서 Task다.
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `REV-P2-010`~`REV-P2-016`, `P2-R8`~`P2-R13` 수정 검증 기록, review §5·§6·§8
|
||||
- Produces: review §7과 요약·상세·종료 판정이 같은 현재 상태를 표시하는 Phase 2 인수인계 문서
|
||||
|
||||
**TDD 예외 사유:** 제품 동작이나 실행 가능한 계약을 바꾸지 않는 문서 상태 정정이므로 새 제품 test를 추가하지 않는다.
|
||||
|
||||
**대체 검증 방법:** review §5·§6·§7·§8의 상태를 직접 대조하고 stale 현재 상태 문자열 검색과 Markdown diff 검사를 실행한다.
|
||||
|
||||
- [x] review §7의 3차 회귀 Task 설명을 `P2-R8`~`P2-R10` 수정 완료 상태로 정정한다.
|
||||
- [x] review §7의 4차 회귀 Task 설명을 `P2-R11`~`P2-R13` 수정 완료 상태로 정정한다.
|
||||
- [x] 과거 시점의 수정 전 기록과 남은 항목이 보존됐는지 확인한다.
|
||||
- [x] 아래 대체 검증을 실행하고 실제 결과를 plan/review에 누적한다.
|
||||
|
||||
**P2-R15 수정 검증 기록 (2026-07-27):**
|
||||
|
||||
- 대체 RED: `rg -n '3차 재검증의 새 확정 4건.*아직 수정하지 않았다|4차 재검증의 새 확정 3건.*아직 수정하지 않았다' docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md` — 2건. review §7이 완료된 `P2-R8`~`P2-R13`을 여전히 미수정 현재 상태로 표시했다.
|
||||
- GREEN: review §7의 3차·4차 회귀 Task 설명을 2026-07-27 수정·검증 완료 상태로 정정하고, review의 `REV-P2-018` 상태·발견 요약·종료 판정을 수정 완료로 맞췄다. §9의 당시 남은 항목과 과거 검증 기록은 보존했다.
|
||||
- 검증: 같은 stale 현재 상태 검색 — no matches. `git diff --check -- docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md` — exit 0.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `rg -n '3차 재검증의 새 확정 4건.*아직 수정하지 않았다|4차 재검증의 새 확정 3건.*아직 수정하지 않았다' docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md`, `git diff --check -- docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md`
|
||||
- **기대 결과:** stale 현재 상태 검색 결과 0건, 문서 whitespace 오류 0건, review §5·§6·§7·§8의 `P2-R8`~`P2-R13` 상태 의미 차이 0건.
|
||||
- **수동 확인:** review §9의 당시 남은 항목 기록은 이력으로 보존되고 §7의 현재 전환 상태만 수정 완료로 정정됐는지 확인한다.
|
||||
|
||||
### Task R2.16 — Phase 2 현재 상태·하단 Progress 정합성 복구
|
||||
|
||||
**Goal 실행 `P2-R16`:** Phase 2 상단 현재 상태와 하단 최신 Progress가 `P2-R15`까지의 실제 수정 완료 및 Phase 3 진행 가능 상태를 일치하게 표시하도록 정정한다.
|
||||
|
||||
- 연결 리뷰: [Phase 2 리뷰](./reviews/review-phase-2.md) — `REV-P2-019`
|
||||
- 시작 조건:
|
||||
- `P2-R15` Task 본문과 review는 수정 완료 및 열린 확정 항목 없음을 표시한다.
|
||||
- Phase 2 상단 현재 상태는 최초 추가 당시 설명만 유지하고, 하단 최신 Progress는 `P2-R15` 수정 필요 상태로 끝나는 모순을 확인한다.
|
||||
- 완료 증거:
|
||||
- Phase 2 상단 현재 상태가 `P2-T1`~`P2-T3`, `P2-GATE`, `P2-R1`~`P2-R16` 완료와 Phase 3 진행 가능 상태를 명시함
|
||||
- 6차 재검증의 당시 남은 항목은 이력으로 보존하고 하단에 `P2-R16` 수정 검증을 누적함
|
||||
- 최신 Progress, review 요약·종료 판정과 Phase 2 상단 상태의 의미 차이 0건
|
||||
- 범위 밖:
|
||||
- 애플리케이션 코드·test·설정 변경
|
||||
- 과거 검증 기록과 당시 남은 항목 삭제 또는 덮어쓰기
|
||||
- Phase 3 기능 구현
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/20260725_AI캐릭터관리자웹/plan-task.md`
|
||||
- Modify: `docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md`
|
||||
- Test: 없음 — 완료된 Phase의 현재 상태와 누적 Progress만 정정하는 문서 Task다.
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `REV-P2-019`, `P2-R15` 수정 검증, Phase 2 상단 현재 상태와 하단 Progress
|
||||
- Produces: Phase 3 시작 조건 판정에 사용할 일관된 Phase 2 완료 상태
|
||||
|
||||
**TDD 예외 사유:** 제품 동작이나 실행 가능한 계약을 바꾸지 않는 문서 상태 정정이므로 새 제품 test를 추가하지 않는다.
|
||||
|
||||
**대체 검증 방법:** 완료 상태 exact 문자열과 최신 Progress를 검색하고, 과거 6차 기록 보존 및 Markdown diff를 확인한다.
|
||||
|
||||
- [x] Phase 2 상단 현재 상태를 실제 완료 범위와 Phase 3 진행 가능 상태로 정정한다.
|
||||
- [x] 6차 재검증의 당시 남은 항목을 보존하고 하단에 `P2-R16` 수정 검증을 누적한다.
|
||||
- [x] review의 `REV-P2-019` 상태·plan 전환·종료 판정을 수정 완료로 갱신한다.
|
||||
- [x] 아래 대체 검증과 Phase 2 전체 Gate를 실행하고 실제 결과를 plan/review에 누적한다.
|
||||
|
||||
**P2-R16 수정 검증 기록 (2026-07-27):**
|
||||
|
||||
- 대체 RED: Phase 2 완료 상태 exact 검색과 `tail` 기반 최신 Progress 검색은 모두 no match, exit 1이었다. 상단은 최초 추가 설명에 머물고 문서 끝은 `P2-R15` 수정 필요 상태였다.
|
||||
- GREEN: Phase 2 상단에 `P2-R16`까지 완료 및 Phase 3 진행 가능 상태를 명시하고, 6차 당시 남은 항목은 보존한 채 하단에 `P2-R16` 수정 검증을 누적했다. review의 `REV-P2-019`와 종료 판정도 수정 완료로 정렬했다.
|
||||
- Phase 2 Gate: `npm run test:run` — 34 files / 147 tests 통과. `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — 모두 exit 0, build는 각각 160 modules 변환. `VITE_API_MODE=mock npm run build:prod` — 기대한 guard로 exit 1.
|
||||
- E2E: `npm run e2e` — 4 projects / 36 tests 통과. `npm run e2e:mock` — 4 projects / 28 tests 통과.
|
||||
- Production: `dist/mockServiceWorker.js` 없음, production JS의 `startMockWorker`·`mockServiceWorker.js` 검색은 기대한 no-match exit 1.
|
||||
- 문서 검증: Phase 2 완료 상태와 최신 Progress exact 검색, 과거 6차 `P2-R15` 남은 항목 보존 검색, plan/review `git diff --check`를 통과했다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** Phase 2 완료 상태 exact 검색, `tail` 기반 최신 Progress 확인, 과거 `P2-R15` 남은 항목 보존 검색, `git diff --check`, Phase 2 전체 Gate
|
||||
- **기대 결과:** 현재 상태·최신 Progress 의미 차이 0건, 과거 기록 보존, 문서 whitespace 오류 0건, Phase 2 자동 Gate 0 failure/0 error
|
||||
- **수동 확인:** Phase 3 실행자가 과거 6차 기록을 열린 현재 Task로 오해하지 않고 `P2-GATE`와 모든 회귀 수정 완료를 확인할 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3. Character workspace vertical slice
|
||||
@@ -1626,3 +2292,174 @@ assert_no_match "externalCharacterId|SUNDAY|MONDAY|TUESDAY|WEDNESDAY|THURSDAY|FR
|
||||
- `git diff --check -- <수정 문서 7개>` — 성공.
|
||||
- 애플리케이션 test/build는 구현 코드와 설정을 변경하지 않았고 신규 명령도 아직 계획 상태이므로 실행하지 않는다.
|
||||
- 남은 항목: Phase 2 구현 시 실제 `dev:mock`·`e2e:mock` 명령과 환경 변수를 만든 후 README와 `docs/agent-guide/{environment,scripts}.md`를 실체에 맞게 갱신한다. 계약 미제공 도메인은 backend 계약 수신 전 fixture를 만들지 않는다.
|
||||
|
||||
### Phase 2 코드 리뷰·QA — 2026-07-27
|
||||
|
||||
- 무엇을: Phase 2 staged 구현 28개 경로를 PRD `MOCK-001~009`, API Contract §1·§3, `P2-T1~P2-GATE`와 대조하고 확정 문제 5건을 `P2-R1~P2-R3`으로 전환했다.
|
||||
- 왜: 자동 검증 통과와 별개로 보호 route fail-closed, exact API origin, invalid·revoked JWT status, Phase 번호 표시와 완료 문서 추적성이 실제 계약과 일치하는지 독립적으로 판정하기 위해서다.
|
||||
- 어떻게:
|
||||
- `npm run test:run` — 성공, 34 files / 130 tests passed.
|
||||
- `npm run test:run -- src/shared/config src/shared/mocks src/shared/ui/__tests__/mock-mode-banner.test.tsx` — 성공, 7 files / 20 tests passed.
|
||||
- `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod`, `git diff --check`, `git diff --cached --check` — 모두 성공.
|
||||
- `VITE_API_MODE=mock npm run build:prod` — 기대한 거부, exit 1과 `VITE_API_MODE=mock is only available during development` 확인.
|
||||
- `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts` — 최초 sandbox listen EPERM, 권한 허용 후 성공, 4 projects / 20 tests passed.
|
||||
- `npm run e2e:mock -- tests/e2e/mock-mode-boundary.spec.ts` — 성공, 4 projects / 4 tests passed.
|
||||
- `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts` — 성공, 4 projects / 12 tests passed. 이 통과 과정에서 404·network error alert가 보호 shell 내부에서 렌더되는 회귀를 별도 확정했다.
|
||||
- `npm run e2e -- tests/e2e/smoke.spec.ts tests/e2e/auth.spec.ts tests/e2e/accessibility-shell.spec.ts` — 성공, 4 projects / 20 tests passed.
|
||||
- Vite SSR로 `createMockHandlers(createMockStore())`를 실행한 재현 — wrong-origin login 200, invalid JWT logout 200, 정상 logout 200, revoked JWT 재logout 200을 확인했다. Vite HMR WebSocket은 sandbox listen EPERM 경고가 있었으나 MSW request 재현 command는 exit 0으로 완료됐다.
|
||||
- 남은 항목: `REV-P2-001~005`를 수정하는 `P2-R1~P2-R3`. 기존 Phase 2 완료 체크와 검증 기록은 되돌리지 않는다.
|
||||
|
||||
### Phase 2 구현·Gate 완료 기록 — 2026-07-27
|
||||
|
||||
- 무엇을: explicit `server | mock` mode, 개발 전용 browser MSW bootstrap, auth preview fixture, mock banner, no-auto-fallback E2E, production mock 차단과 실행 문서를 구현했다.
|
||||
- 왜: backend endpoint 구현 전에도 제공 API Contract 범위의 최종 UI를 mock mode에서 확인하되, 기본 server mode와 production build가 mock으로 자동 대체되지 않게 하기 위해서다.
|
||||
- 어떻게:
|
||||
- P2-T1 focused unit·boundary E2E, P2-T2 auth handler/banner/mock preview E2E, P2-T3 docs/accessibility E2E와 P2-GATE를 2026-07-27 본문 기록대로 실행했다.
|
||||
- P2-GATE 기준 `npm run test:run -- src/shared/config src/shared/mocks src/shared/ui/__tests__/mock-mode-banner.test.tsx`는 7 files / 20 tests 통과했다.
|
||||
- `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts`는 4 browser projects / 20 tests 통과했고 mock login, protected shell, logout 후 재login, 320px·200% zoom, axe critical·serious 0건을 확인했다.
|
||||
- `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod`는 모두 exit 0이었다.
|
||||
- Phase 2 코드 리뷰에서 열린 `REV-P2-001~005`는 이 완료 기록을 삭제하지 않고 `P2-R1~P2-R3` 회귀 Task로 별도 추적한다.
|
||||
- 남은 항목: `P2-R1~P2-R3` 수정 완료 전에는 Phase 2 리뷰를 닫지 않는다. mock 통과는 후속 도메인 server integration 완료로 간주하지 않는다.
|
||||
|
||||
### Phase 2 3차 코드 리뷰·QA — 2026-07-27
|
||||
|
||||
- 무엇을: `P2-R4`~`P2-R7` 수정 뒤의 현재 working tree를 재검토해 `REV-P2-010` Low 1건과 `REV-P2-011~013` Medium 3건을 확정하고 `P2-R8`~`P2-R10` 회귀 Task로 전환했다.
|
||||
- 왜: 완료 문서가 최종 변경 경로를 추적하는지, Mock Preview banner 이동 뒤 mobile menu의 background inert·responsive breakpoint 전환과 보호 route 오류 recovery가 유지되는지 확인하기 위해서다.
|
||||
- 어떻게:
|
||||
- `npm run test:run -- src/shared/config src/shared/mocks src/shared/ui/__tests__/mock-mode-banner.test.tsx` — 7 files / 25 tests 통과.
|
||||
- `npm run test:run` — 34 files / 141 tests 통과.
|
||||
- `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — 모두 exit 0.
|
||||
- `VITE_API_MODE=mock npm run build:prod` — 기대대로 exit 1. production 산출물의 worker 파일·mock bootstrap 문자열은 0건.
|
||||
- `npm run e2e` — 4 projects / 32 tests 통과. `npm run e2e:mock` — 4 projects / 24 tests 통과.
|
||||
- Chromium one-off — login과 Character probe 응답이 모두 `fromServiceWorker=true`임을 확인했다. 320px menu open에서는 banner가 inert 경계 밖에 있었고, 1,200px 전환 뒤 overlay는 숨겨졌지만 main inert·`aria-hidden=true`가 남았다.
|
||||
- 코드·test 대조 — 보호 route 오류 page의 interactive recovery control 0건과 App/server boundary retry test 0건을 확인했다.
|
||||
- `git diff HEAD --name-only | wc -l` — 37개 경로, `git diff --check HEAD` — exit 0.
|
||||
- 문서 반영 검증 — `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts`는 1 file / 3 tests 통과, review 상세 ID 13개·Phase 2 회귀 Task 10개를 확인했고 stale 3차 범위 문자열과 문서 whitespace 오류는 0건이었다.
|
||||
- 변경 범위: [Phase 2 리뷰](./reviews/review-phase-2.md)에 3차 근거·발견·판정을 누적하고 이 문서에 `P2-R8`~`P2-R10`만 추가했다. 애플리케이션 코드·test·설정은 변경하지 않았다.
|
||||
- 남은 항목: `P2-R8` 문서 추적성, `P2-R9` mobile menu 반응형·inert, `P2-R10` 보호 route 오류 retry 복구. 세 goal의 대체 검증 또는 RED/GREEN/REFACTOR, 관련 E2E와 P2 Gate가 끝나기 전에는 Phase 2 리뷰를 닫지 않는다.
|
||||
|
||||
### P2-R9 수정 검증 — 2026-07-27
|
||||
|
||||
- 무엇을: `REV-P2-011`, `REV-P2-012`를 `P2-R9` 범위에서 수정했다. Mock Preview 성공 shell의 banner를 `ProtectedAdminShell` background inert container 안으로 옮기고, native `matchMedia('(min-width: 1024px)')` change에서 mobile menu state와 inert·`aria-hidden`을 해제하되 숨겨진 mobile trigger로 focus를 복귀하지 않게 했다.
|
||||
- 왜: mobile menu가 열린 상태에서 background 전체가 같은 접근성 차단 경계에 속해야 하며, `lg` 이상 viewport로 전환될 때 보이는 desktop navigation·logout·main이 즉시 다시 조작 가능해야 하기 때문이다.
|
||||
- 어떻게:
|
||||
- RED unit: `npm run test:run -- src/app/App.test.tsx src/shared/ui/__tests__/mock-mode-banner.test.tsx` — 2 files 중 `App.test.tsx` 2 tests가 기대대로 실패했다. 실패 핵심은 `mock banner inert background not found`와 `모바일 주 메뉴` 잔존이다.
|
||||
- RED e2e: `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts` — 4 browser projects에서 신규 mock shell test가 모두 기대대로 실패했다. 실패 핵심은 320px open state의 `bannerInert=false`, `bannerHidden=false`, `mainInert=true` 불일치다.
|
||||
- GREEN unit: 같은 focused unit command — 2 files / 26 tests 통과.
|
||||
- GREEN e2e: 같은 mock e2e command — 28 tests 통과. 320px open state에서 banner·main이 같은 inert/`aria-hidden` 경계에 있고 1,024px·1,200px 전환 뒤 mobile menu가 제거되며 desktop navigation·logout이 보이는 것을 확인했다.
|
||||
- Regression: `npm run e2e -- tests/e2e/accessibility-shell.spec.ts` — 32 tests 통과. `npm run typecheck`, `npm run lint` — 모두 exit 0.
|
||||
- LSP diagnostics: `src/app/App.tsx`, `src/app/protected-admin-shell.tsx`, `src/app/App.test.tsx`, `tests/e2e/mock-preview-shell.spec.ts` 모두 0 diagnostics.
|
||||
- P2 focused Gate: `npm run test:run -- src/shared/config src/shared/mocks src/shared/ui/__tests__/mock-mode-banner.test.tsx` — P2-R9 문서 기록 반영 전에는 docs contract 1 test가 실패했다. 기록 반영 후 재실행해 7 files / 25 tests 통과했다.
|
||||
- 남은 항목: `P2-R10` 보호 route 오류 retry 복구. mock 통과는 후속 도메인 server integration 완료로 간주하지 않는다.
|
||||
|
||||
### P2-R10 수정 검증 — 2026-07-27
|
||||
|
||||
- 무엇을: `REV-P2-013`을 `P2-R10` 범위에서 수정했다. 보호 route 오류 page에 native retry button을 추가하고, 현재 session·route visit·retry attempt가 모두 일치할 때만 오류나 검증 성공을 사용하도록 했다. 정상 `ApiError`의 서버 message는 유지하고 network 오류에는 안전한 공통 안내를 표시한다.
|
||||
- 왜: 404·network 오류 뒤에도 사용자가 browser refresh 없이 복구할 수 있어야 하며, 현재 수동 retry가 성공하기 전에는 보호 shell·navigation·logout을 계속 숨겨야 하기 때문이다.
|
||||
- 어떻게:
|
||||
- RED unit: `npm run test:run -- src/app/App.test.tsx` — 1 file / 26 tests 중 신규 2 tests가 `보호 route 다시 시도` button 부재로 기대대로 실패했고 24 tests는 통과했다.
|
||||
- RED E2E: `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts` — 32 tests 중 신규 retry 시나리오가 4 browser project에서 button 부재로 기대대로 실패했고 28 tests는 통과했다.
|
||||
- GREEN unit: 같은 App command — 1 file / 26 tests 통과. 404 `없습니다.` 보존, network 재실패의 공통 안내·retry 유지, pending 중 shell·logout 비노출과 성공 뒤 shell 표시를 확인했다.
|
||||
- GREEN E2E: 같은 server command — 4 projects / 32 tests 통과. 명시적 retry 전후 browser MSW worker 0건과 성공 전 shell·logout 0건을 확인했다.
|
||||
- Regression: `npm run test:run -- src/shared/config src/shared/mocks src/shared/ui/__tests__/mock-mode-banner.test.tsx` — 7 files / 25 tests 통과. `npm run test:run` — 34 files / 145 tests 통과.
|
||||
- Static/build: `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — 모두 exit 0. no-excuse 검사도 변경 TS/TSX 3 files / 위반 0건이었다.
|
||||
- LSP diagnostics: `src/app` 4 files, `tests/e2e` 6 files에서 diagnostics 0건.
|
||||
- 수동·시각 확인: server mode 실제 브라우저 375px·768px·1,280px에서 button 높이 44px, `:focus-visible=true`, 한국어 clipping·비정상 줄바꿈 0건을 확인했다. retry 성공 뒤 shell·logout 표시와 mock worker 0건을 확인했고 기능 무결성·CJK 정밀 검토가 모두 PASS였다.
|
||||
- Diff: `git diff --check -- src/app/App.tsx src/app/App.test.tsx tests/e2e/server-mode-boundary.spec.ts docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md` — exit 0.
|
||||
- 남은 항목: `P2-R10` 범위 없음. mock 통과는 후속 도메인 server integration 완료로 간주하지 않는다.
|
||||
|
||||
### Phase 2 4차 코드 리뷰·QA — 2026-07-27
|
||||
|
||||
- 무엇을: `P2-R8`~`P2-R10` 반영 뒤 working tree 전체 집계, 보호 route pending UI와 mode별 focused E2E 증거를 재검토해 `REV-P2-014` Low 1건과 `REV-P2-015~016` Medium 2건을 확정하고 `P2-R11`~`P2-R13`으로 전환했다.
|
||||
- 왜: 완료 판정이 untracked 파일, 300ms 이상 retry 대기 상태와 Task가 지정한 단일 E2E spec의 실제 수집 범위를 빠뜨리지 않는지 확인하기 위해서다.
|
||||
- 어떻게:
|
||||
- `npm run test:run -- src/app/App.test.tsx src/shared/ui/__tests__/mock-mode-banner.test.tsx` — 2 files / 28 tests 통과. `npm run test:run` — 34 files / 145 tests 통과.
|
||||
- `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — 모두 exit 0, build는 각 160 modules 변환.
|
||||
- `VITE_API_MODE=mock npm run build:prod` — 기대대로 exit 1. production worker 파일과 mock bootstrap 문자열은 0건.
|
||||
- `npm run e2e` — 4 projects / 32 tests 통과. `npm run e2e:mock` — 4 projects / 28 tests 통과.
|
||||
- `git diff HEAD --name-only | wc -l` — tracked 37개, `git status --porcelain=v1 | wc -l` — 전체 38개, `git ls-files --others --exclude-standard` — `src/app/protected-admin-shell.tsx` 1개.
|
||||
- `npm run e2e -- --list tests/e2e/server-mode-boundary.spec.ts` — 4 files / 32 tests, 직접 `VITE_API_MODE=server npx playwright test tests/e2e/server-mode-boundary.spec.ts --list` — 1 file / 12 tests. 고정 script가 focused filter를 무효화하고 `P2-R10` 기대 16 tests 미달을 가리는 것을 확인했다.
|
||||
- Chromium one-off — 404 retry 성공 응답을 350ms 지연했을 때 `#root` child·`main`·`status`·`alert` 0개, active element `BODY`; 응답 뒤 shell·logout 표시를 확인했다. 최초 sandbox local listen·browser launch 실패는 권한 허용 재실행으로 보완했다.
|
||||
- 문서 반영 검증 — `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts`는 1 file / 3 tests 통과. review 상세 ID 16개, Phase 2 회귀 Task 13개와 문서 whitespace 오류 0건을 확인했다.
|
||||
- 변경 범위: [Phase 2 리뷰](./reviews/review-phase-2.md)에 4차 근거·발견·판정을 누적하고 이 문서에 `P2-R11`~`P2-R13`만 추가했다. 애플리케이션 코드·test·설정은 변경하지 않았다.
|
||||
- 남은 항목: `P2-R11` working tree 추적성, `P2-R12` 보호 route pending 피드백, `P2-R13` focused E2E filter·network retry 증거. 세 goal의 대체 검증 또는 RED/GREEN/REFACTOR와 관련 Gate가 끝나기 전에는 Phase 2 리뷰를 닫지 않는다.
|
||||
|
||||
### P2-R11~P2-R13 수정 검증 — 2026-07-27
|
||||
|
||||
- 무엇을: `REV-P2-014~016`을 `P2-R11`~`P2-R13` 범위에서 수정했다. working tree 범위는 tracked 37개 + untracked `src/app/protected-admin-shell.tsx` 1개로 구분해 기록하고, 보호 route pending에는 기존 `PageState` loading status를 표시했다. E2E script의 spec allowlist는 `playwright.config.ts` `testMatch`로 옮기고 network retry 재실패 E2E를 추가했다.
|
||||
- 왜: 완료 증거가 untracked 파일, 접근 가능한 300ms 이상 pending feedback, focused E2E file filter와 실제 network retry 경계를 빠뜨리지 않게 하기 위해서다.
|
||||
- 어떻게:
|
||||
- P2-R11 대체 검증: `git diff HEAD --name-only | wc -l` — tracked 37개, `git status --short --untracked-files=all | wc -l` — 전체 38개, `git ls-files --others --exclude-standard` — `src/app/protected-admin-shell.tsx` 1개.
|
||||
- P2-R12 RED: `npm run test:run -- src/app/App.test.tsx` — 최초 pending 전용 test 1건과 기존 404 retry test의 pending assertion이 `role="status"` 부재로 기대대로 실패했다.
|
||||
- P2-R12 GREEN: 같은 command — 1 file / 27 tests 통과. 최초 probe와 retry pending 중 `role="status"`, 보호 `main`·logout 0건을 확인했다.
|
||||
- P2-R13 RED: `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts` — npm script와 Playwright config contract 2 tests가 기대대로 실패했다.
|
||||
- P2-R13 GREEN focused: `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 2 files / 6 tests 통과. `npm run e2e -- --list tests/e2e/server-mode-boundary.spec.ts` — 1 file / 16 tests, `npm run e2e:mock -- --list tests/e2e/mock-preview-shell.spec.ts` — 1 file / 24 tests.
|
||||
- Surface: `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts` — 16 tests 통과. `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts` — 24 tests 통과.
|
||||
- LSP diagnostics: `src/app/App.tsx`, `src/app/App.test.tsx`, `playwright.config.ts`, `src/shared/mocks/__tests__/mode-boundary.test.ts` 0 diagnostics. `tests/e2e/server-mode-boundary.spec.ts` 단일 fresh diagnostics는 timeout이었고 focused E2E와 typecheck로 보완한다.
|
||||
- Final Gate: `npm run test:run` — 34 files / 147 tests 통과. `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — 모두 exit 0. `npm run e2e` — 36 tests 통과, `npm run e2e:mock` — 28 tests 통과.
|
||||
- Diff: `git diff --check -- docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md tests/e2e/server-mode-boundary.spec.ts package.json playwright.config.ts README.md docs/agent-guide/scripts.md src/app/App.tsx src/app/App.test.tsx src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts` — exit 0.
|
||||
- 남은 항목: `P2-R11`~`P2-R13` 범위 없음. mock 통과는 후속 도메인 server integration 완료로 간주하지 않는다.
|
||||
|
||||
### Phase 2 5차 코드 리뷰·QA — 2026-07-27
|
||||
|
||||
- 무엇을: `P2-R11`~`P2-R13` 반영 뒤 실제 test case 구조·실행 수와 plan/review의 완료 상태를 대조해 `REV-P2-017` Low 1건을 확정하고 `P2-R14` 회귀 Task로 전환했다.
|
||||
- 왜: 동작 검증 통과와 별개로 TDD 완료 체크·검증 수치가 실제 구현과 같은 사실을 표시하는지 확인하기 위해서다.
|
||||
- 어떻게:
|
||||
- `npm run test:run -- src/app/App.test.tsx` — 1 file / 27 tests 통과. `rg -c '^test\(' src/app/App.test.tsx` — 27건.
|
||||
- 코드 대조 — 최초 probe pending 전용 test 1건을 추가했고 retry pending은 기존 404 retry test에 assertion을 보강했음을 확인했다. `P2-R12`의 두 신규 test·App 28 tests 이상 완료 체크와 불일치한다.
|
||||
- `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 2 files / 6 tests 통과. `npm run test:run` — 34 files / 147 tests 통과.
|
||||
- `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — 모두 exit 0. `VITE_API_MODE=mock npm run build:prod` — 기대한 production guard로 exit 1.
|
||||
- focused E2E list는 server 1 file / 16 tests, mock 1 file / 24 tests. 최초 sandbox listen `EPERM` 뒤 허용된 로컬 실행에서 server boundary 16, mock preview 24, bare server 36, bare mock 28 tests가 모두 통과했다.
|
||||
- working tree는 tracked diff 37개 + untracked `src/app/protected-admin-shell.tsx` 1개 = 전체 38개 항목으로 유지됐다.
|
||||
- 변경 범위: [Phase 2 리뷰](./reviews/review-phase-2.md)에 `REV-P2-017`과 5차 근거·판정을 누적하고 이 문서에 `P2-R14`만 추가했다. 애플리케이션 코드·test·설정은 변경하지 않았다.
|
||||
- 남은 항목: `P2-R14` 범위 없음. mock 통과는 후속 도메인 server integration 완료로 간주하지 않는다.
|
||||
|
||||
### P2-R14 수정 검증 — 2026-07-27
|
||||
|
||||
- 무엇을: `REV-P2-017`을 `P2-R14` 범위에서 수정했다. P2-R12 완료 증거를 실제 App test 구조인 최초 pending 전용 test 1건 + 기존 404 retry test의 pending assertion 보강 + App 27 tests로 정정했다.
|
||||
- 왜: 문서가 “두 신규 test·28 tests 이상”을 완료 조건처럼 표시하면 후속 reviewer가 실제 test case 수와 검증 시나리오 수를 혼동하기 때문이다.
|
||||
- 어떻게:
|
||||
- 대체 RED: `rg -c '^test\(' src/app/App.test.tsx` — 27건. `rg -n 'initial protected route probe is pending|retries a protected route 404' src/app/App.test.tsx` — 최초 pending 전용 test와 기존 404 retry test 위치를 확인했다.
|
||||
- GREEN docs: P2-R12 TDD 절차·기대 결과, P2-R11~P2-R13 수정 검증 기록, review 요약·종료 판정을 실제 구조와 일치시켰다.
|
||||
- Focused: `npm run test:run -- src/app/App.test.tsx` — 1 file / 27 tests 통과.
|
||||
- 문서 검증: stale `신규 pending status tests 2건` 검색 결과 0건, `git diff --check -- docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md` — exit 0.
|
||||
- 남은 항목: Phase 2 review의 열린 확정 항목 없음. 애플리케이션 코드·test·설정은 변경하지 않았다.
|
||||
|
||||
### Phase 2 6차 코드 리뷰·QA — 2026-07-27
|
||||
|
||||
- 무엇을: `P2-R14` 반영 뒤 Phase 2 구현·test·production·mode별 E2E와 review 현재 상태를 다시 대조해 `REV-P2-018` Low 1건을 확정하고 `P2-R15`로 전환했다.
|
||||
- 왜: review 요약·상세·종료 판정과 plan 전환 절이 완료된 회귀 Task의 현재 상태를 같은 의미로 표시하는지 확인하기 위해서다.
|
||||
- 어떻게:
|
||||
- `npm run test:run` — 34 files / 147 tests 통과.
|
||||
- `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — 모두 exit 0, build는 각각 160 modules 변환.
|
||||
- `VITE_API_MODE=mock npm run build:prod` — 기대한 production guard로 exit 1.
|
||||
- `npm run e2e` — 최초 sandbox listen `EPERM` 뒤 로컬 실행 권한으로 재실행해 4 projects / 36 tests 통과.
|
||||
- `npm run e2e:mock` — 최초 sandbox listen `EPERM` 뒤 로컬 실행 권한으로 재실행해 4 projects / 28 tests 통과.
|
||||
- Chromium one-off — login·보호 route 응답 `fromServiceWorker=true`, 320px menu·logout 높이 60px, mobile link 선택 뒤 menu·background inert 잔존 0건을 확인했다.
|
||||
- `git diff --check HEAD` — review 문서 반영 전 exit 0. review §7의 `P2-R8`~`P2-R13` “아직 수정하지 않았다” 2건과 §5·§6·§8의 수정 완료 상태가 모순됨을 확인했다.
|
||||
- 문서 반영 후 `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 1 file / 3 tests 통과, plan/review 대상 `git diff --check` — exit 0.
|
||||
- 변경 범위: 이 review 문서와 `plan-task.md`에 `REV-P2-018`, `P2-R15` 판정·후속 계획만 추가했다. 애플리케이션 코드·test·설정은 변경하지 않았다.
|
||||
- 남은 항목: `P2-R15`에서 review §7의 현재 상태 문구를 정정하고 문서 검색·diff 검증을 누적해야 한다.
|
||||
|
||||
### Phase 2 7차 코드 리뷰·QA — 2026-07-27
|
||||
|
||||
- 무엇을: `P2-R15` 반영 뒤 Phase 2 상단 현재 상태, Task 본문, review 종료 판정과 하단 최신 Progress를 대조해 `REV-P2-019` Low 1건을 확정하고 `P2-R16`으로 전환했다.
|
||||
- 왜: 완료된 회귀 Task의 inline 기록뿐 아니라 Phase 현재 상태와 하단 누적 Progress도 Phase 3 실행자가 같은 완료 상태로 해석할 수 있어야 하기 때문이다.
|
||||
- 어떻게:
|
||||
- 대체 RED 완료 상태 검색 — Phase 2 상단의 `P2-R15`까지 완료·Phase 3 진행 가능 exact 상태가 없어 exit 1.
|
||||
- 대체 RED 최신 Progress 검색 — 문서 끝이 6차 재검증의 `P2-R15` 수정 필요 상태로 끝나 현재 완료 기록이 없어 exit 1.
|
||||
- 코드 회귀 기준은 6차 재검증 직후 독립 확인에서 unit 147 tests, server E2E 36 tests, mock E2E 28 tests, typecheck·lint·dev/prod build 통과와 production mock guard 거부를 확인했다.
|
||||
- 변경 범위: 이 review 문서와 `plan-task.md`에 `REV-P2-019`, `P2-R16` 판정·후속 계획만 추가했다. 애플리케이션 코드·test·설정은 변경하지 않았다.
|
||||
- 남은 항목: `P2-R16`에서 Phase 2 현재 상태와 최신 Progress를 정정하고 문서·Phase 2 Gate 검증을 누적해야 한다.
|
||||
|
||||
### P2-R16 수정 검증 — 2026-07-27
|
||||
|
||||
- 무엇을: `REV-P2-019`를 수정했다. Phase 2 상단 현재 상태를 `P2-R16`까지 완료 및 Phase 3 진행 가능으로 갱신하고, 6차 당시 남은 항목을 보존한 채 최신 수정 검증을 누적했다.
|
||||
- 왜: Phase 3 실행자가 완료된 `P2-R15`를 열린 선행 작업으로 오해하지 않고 Phase 2의 실제 완료 상태를 단일하게 판정할 수 있어야 하기 때문이다.
|
||||
- 어떻게:
|
||||
- 대체 GREEN: Phase 2 완료 상태 exact 검색과 최신 Progress 검색이 각각 1건 이상 일치했다. 6차 당시 `P2-R15` 남은 항목도 이력으로 보존됐다.
|
||||
- Full unit/static/build: `npm run test:run` — 34 files / 147 tests 통과. `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — 모두 exit 0, build는 각각 160 modules 변환.
|
||||
- Production boundary: `VITE_API_MODE=mock npm run build:prod` — 기대대로 exit 1. production worker 파일과 mock bootstrap 문자열은 0건.
|
||||
- E2E: `npm run e2e` — 4 projects / 36 tests 통과. `npm run e2e:mock` — 4 projects / 28 tests 통과.
|
||||
- Diff: `git diff --check -- docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md` — exit 0.
|
||||
- 남은 항목: Phase 2 review의 열린 확정 항목 없음. `P2-GATE`와 모든 회귀 수정이 완료돼 Phase 3 진행 가능. mock 통과는 Phase 3의 실제 server integration 완료로 간주하지 않는다.
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
리뷰 시작 시 워킹 트리는 사용자 작업을 포함해 이미 변경 상태였다. 기존 변경은 리뷰 기준선으로 보존했으며, 코드와 기존 문서는 수정하지 않았다. 이 파일만 신규 리뷰 산출물로 작성했다.
|
||||
|
||||
리뷰 파일 위치는 같은 PRD 디렉터리를 기본값으로 삼는 일반 규칙보다 구체적인 사용자 지시를 적용해 docs/20260725_AI캐릭터관리자웹/review 아래로 정했다.
|
||||
리뷰 파일은 현재 저장 규칙에 따라 `docs/20260725_AI캐릭터관리자웹/reviews/` 아래에 둔다.
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
1544
docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md
Normal file
1544
docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,10 @@
|
||||
- PRD를 작성·변경할 때는 [PRD 작성 및 유지보수 규칙](./prd.md)과 [PRD 샘플](../sample/sample-prd.md)을 따른다.
|
||||
- 구현 항목은 기능/작업 단위로 분리해 체크박스(`- [ ]`) 목록으로 작성한다.
|
||||
- 구현 완료 시마다 체크박스를 `- [x]`로 갱신하고, 각 항목이 정상 구현되었는지 확인한다.
|
||||
- `plan-task.md`의 각 Task는 TDD 적용 여부를 명시한다. 테스트 가능한 구현 Task에는 `TDD 절차`를 두고 `RED: 실패 테스트 작성/실패 확인`, `GREEN: 최소 구현/통과 확인`, `REFACTOR: 정리/회귀 확인` 순서를 적는다.
|
||||
- 실패 테스트 작성이 현실적으로 불가능한 Task는 TDD 절차를 아무 표시 없이 생략하지 말고 같은 Task에 `TDD 예외 사유`와 `대체 검증 방법`을 구체적으로 기록한다.
|
||||
- 각 Task에는 `실행 명령`, `기대 결과`, `수동 확인`을 포함한 검증 기준을 작성한다. 수동 확인이 불필요하면 `없음`과 그 사유를 적는다.
|
||||
- 각 Phase의 Gate에도 통합 검증을 위한 실행 명령, 기대 결과, 수동 확인 항목을 작성한다.
|
||||
- 작업 도중 범위가 변경되면 계획 문서의 체크박스 항목을 먼저 업데이트한 뒤 구현을 진행한다.
|
||||
- 모든 구현이 끝난 후 결과 보고 시 계획 문서 맨 아래에 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 기록한다.
|
||||
- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰지 않고 누적한다(예: `1차 구현`, `2차 수정`).
|
||||
@@ -14,4 +18,5 @@
|
||||
- 단계별 `어떻게`에는 실제 실행한 검증 명령과 결과(성공/실패/불가 사유)를 함께 기록한다.
|
||||
- 기존 기록 정정이 필요하면 원문을 지우지 말고 `정정` 항목을 추가해 사유와 변경 내용을 남긴다.
|
||||
- goal 기능으로 실행할 구현 계획은 [Goal 실행형 구현 계획 규칙](./goal-plan.md)과 [Goal 실행형 계획 샘플](../sample/sample-plan-task.md)을 따른다.
|
||||
- 완료된 Phase 또는 Task의 코드 리뷰·QA 결과 문서는 해당 `prd.md`와 같은 디렉터리에 두고, 상세 형식과 후속 처리에는 [코드 리뷰 및 QA 기록 규칙](./review.md)을 따른다.
|
||||
- 완료된 Phase 또는 Task의 코드 리뷰·QA 결과 문서는 해당 `prd.md`·`plan-task.md` 디렉터리 아래 `reviews/`에 모아 둔다. 기능 문서 디렉터리 바로 아래나 단수형 `review/`에는 두지 않는다.
|
||||
- 리뷰 문서의 상세 형식, 파일명과 참조 방법은 [코드 리뷰 및 QA 기록 규칙](./review.md)을 따른다.
|
||||
|
||||
@@ -2,4 +2,9 @@
|
||||
|
||||
- 개발 서버 API: `VITE_API_BASE_URL=https://test-character-admin.sodalive.net`
|
||||
- 프로덕션 서버 API: `VITE_API_BASE_URL=https://character-admin.sodalive.net`
|
||||
- API mode: `VITE_API_MODE=server | mock`. 누락 시 `server`이며, `mock`은 개발 환경에서만 허용한다.
|
||||
- Vite mode별 파일은 `.env.development`, `.env.production`을 사용한다.
|
||||
- 기본 `npm run dev`는 `server` mode로 실제 개발 API를 사용하고, `npm run dev:mock`만 browser MSW를 시작한다.
|
||||
- production build에서 `VITE_API_MODE=mock`은 시작 전에 오류로 거부한다.
|
||||
- mock data reset: mock data는 browser storage에 영구 저장하지 않고 새 mock store/session이 시작될 때 seed 기준으로 초기화한다.
|
||||
- no-auto-fallback: server mode의 404 또는 network error를 mock mode로 자동 전환하지 않는다.
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
- 하나 이상의 Task
|
||||
- Task 전체 완료 조건
|
||||
- 자동·수동 검증 방법과 Phase Gate
|
||||
- 각 Task의 TDD 절차 또는 TDD 예외 사유와 대체 검증 방법
|
||||
- 각 Task와 Phase Gate의 실행 명령, 기대 결과, 수동 확인 항목
|
||||
|
||||
## 4. Task와 goal 작성 규칙
|
||||
|
||||
@@ -43,8 +45,17 @@
|
||||
- 모든 Task에는 고유 Goal ID, 한 문장 objective, 시작 조건, 완료 증거와 범위 밖을 둔다.
|
||||
- Goal ID는 `P<Phase>-T<Task>`를 사용한다. Phase Gate는 `P<Phase>-GATE`, 완료 범위의 회귀 수정은 `P<Phase>-R<번호>`를 사용한다.
|
||||
- Task는 독립 reviewer가 이웃 Task와 별도로 승인·거절할 수 있고, 자체 test cycle로 검증할 수 있는 최소 결과 단위로 나눈다.
|
||||
- Task마다 생성·수정·test 파일의 정확한 경로를 기록한다. 선행 Task contract를 소비하거나 후속 Task에 제공하면 `Interfaces`에 정확한 type·function·component를 기록한다.
|
||||
- 구현 체크박스는 실패 test 작성 → 의도한 실패 확인 → 최소 구현 → focused test 성공 → 관련 품질 검증 → Progress 기록 순서를 포함한다.
|
||||
- Task마다 생성·수정·test 파일의 정확한 경로를 기록한다. TDD 예외 Task에 test 파일이 없으면 `Test: 없음`과 사유를 적는다. 선행 Task contract를 소비하거나 후속 Task에 제공하면 `Interfaces`에 정확한 type·function·component를 기록한다.
|
||||
- 모든 구현 Task의 `TDD 절차`에는 다음 순서와 확인 내용을 명시한다.
|
||||
- `RED: 실패 테스트 작성/실패 확인` — 검증할 동작과 실패 테스트 파일, 실행 명령, 의도한 실패 결과를 적는다.
|
||||
- `GREEN: 최소 구현/통과 확인` — 최소 구현 범위와 동일한 테스트 명령의 통과 결과를 적는다.
|
||||
- `REFACTOR: 정리/회귀 확인` — 동작을 바꾸지 않는 정리 범위와 focused·관련 회귀 테스트 결과를 적는다.
|
||||
- 실패 테스트 작성이 현실적으로 불가능한 문서화, 조사, 외부 의존 작업 등은 같은 Task에 `TDD 예외 사유`와 `대체 검증 방법`을 명시한다. 단순히 `해당 없음`만 적거나 산출물과 무관한 테스트를 만드는 것으로 대체하지 않는다.
|
||||
- 각 Task의 `검증 기준`에는 다음을 포함한다.
|
||||
- `실행 명령`: focused test, 관련 회귀 test, typecheck·lint 또는 TDD 예외의 대체 검증 등 실제 실행할 명령
|
||||
- `기대 결과`: 종료 코드, 통과할 test 수, 예상 출력 또는 상태 변화
|
||||
- `수동 확인`: 사용자가 확인할 화면·동작·문서 항목. 불필요하면 `없음`과 사유를 기록한다.
|
||||
- 구현 체크박스 마지막에는 검증 결과와 `Progress` 기록을 포함한다.
|
||||
- “적절히 처리”, “나중에 구현”, “위와 동일”처럼 실행자가 다시 추측해야 하는 표현을 사용하지 않는다.
|
||||
|
||||
## 5. 완료와 차단 판정
|
||||
@@ -52,6 +63,8 @@
|
||||
- 동시에 하나의 미완료 goal만 운용한다. 활성 goal이 있으면 새 goal을 만들지 않고 같은 Task를 이어서 수행한다.
|
||||
- 사용자가 명시적으로 요청하지 않으면 token budget을 설정하지 않는다.
|
||||
- 코드 작성이나 일부 test만 끝난 상태는 완료가 아니다. 체크박스, 완료 증거, 실제 검증과 Progress 기록까지 충족한 뒤에만 goal을 `complete`로 갱신한다.
|
||||
- 구현 Task는 RED/GREEN/REFACTOR 각 단계의 결과가 없으면 완료할 수 없다.
|
||||
- TDD 예외 Task는 예외 사유와 대체 검증 결과가 없으면 완료할 수 없다.
|
||||
- Phase의 모든 활성 Task goal을 완료한 뒤 Phase Gate를 별도 goal로 실행한다.
|
||||
- 외부 계약이나 권한 같은 동일 차단 사유가 최초 시도와 자동 후속을 포함해 3회 연속 반복되고, 문서화·독립 작업 등 의미 있는 진전도 불가능할 때만 goal을 `blocked`로 갱신한다.
|
||||
- 계약이 없어 안전하게 구현할 수 없으면 추정하지 않는다. 담당 주체·영향·재개 조건을 기록하고 PRD 결정 기록 → API Contract → `plan-task.md` 순서로 제외 또는 후속 결정을 반영한다.
|
||||
@@ -62,6 +75,7 @@
|
||||
- 범위나 구현 방식이 바뀌면 코드를 수정하기 전에 관련 체크박스, Files, Interfaces, 완료 증거와 Decision Log를 갱신한다.
|
||||
- Progress와 Decision Log의 기존 기록은 삭제하거나 덮어쓰지 않는다. 정정은 날짜·사유와 함께 새 기록으로 추가한다.
|
||||
- 실행한 명령만 기록하고 성공/실패, exit code, test 수 또는 불가 사유를 남긴다.
|
||||
- 구현 Task의 Progress에는 RED의 의도한 실패, GREEN의 통과, REFACTOR의 회귀 확인 결과를 구분해 기록한다. TDD 예외 Task는 대체 검증의 실제 결과를 기록한다.
|
||||
- 구현 중 발견한 범위 내 문제는 `발견된 문제`에 기록한다. 완료 범위의 상세 리뷰·QA는 [코드 리뷰 및 QA 기록 규칙](./review.md)에 따라 별도 review 문서로 관리한다.
|
||||
- Phase 완료 후 현재 상태 표와 체크박스를 갱신하고 Phase Gate의 최신 증거를 Progress에 누적한다.
|
||||
|
||||
@@ -75,6 +89,7 @@ goal을 만들기 전에 다음을 확인한다.
|
||||
- Files와 Interfaces의 이름이 앞뒤 Task에서 일치한다.
|
||||
- 외부 의존과 안전한 기본값이 구분돼 있다.
|
||||
- backend 구현 전 UI preview가 필요하면 제공 계약 기반 explicit mock mode와 실제 server integration을 별도 Task·Gate·Progress로 구분하고 404 자동 fallback을 금지한다.
|
||||
- 실제 검증 명령과 Expected가 구체적이다.
|
||||
- 각 구현 Task에 RED/GREEN/REFACTOR 절차가 있고, 예외 Task에는 예외 사유와 대체 검증 방법이 있다.
|
||||
- 각 Task와 Phase Gate의 실행 명령, 기대 결과, 수동 확인 항목이 구체적이다.
|
||||
- placeholder, 미정 값, 추정 계약이 없다.
|
||||
- 변경 금지 항목과 중단 규칙이 명시돼 있다.
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
## 2. 기준 문서와 템플릿
|
||||
|
||||
- 리뷰 전에 대상 기능 디렉터리의 `prd.md`, `api-contract.md`, `plan-task.md`와 관련 구현·test를 읽는다.
|
||||
- 리뷰 문서는 대상 `prd.md`와 같은 디렉터리에 만든다.
|
||||
- 대상 `prd.md`와 `plan-task.md`가 있는 기능 문서 디렉터리 아래 `reviews/`를 만들고 모든 리뷰 문서를 그 안에 둔다.
|
||||
- 리뷰 문서를 기능 문서 디렉터리 바로 아래나 단수형 `review/`에 두지 않는다. 여러 Phase·Task 리뷰가 생겨도 같은 `reviews/`에 누적한다.
|
||||
- [코드 리뷰 보고서 샘플](../sample/sample-review.md)을 원본 템플릿으로 사용하고, section·필드·상태 의미를 임의로 축소하지 않는다.
|
||||
- 실제 리뷰 문서 파일명은 범위가 드러나게 작성한다. 예: `review-phase-0-1.md`, `review-auth.md`.
|
||||
- `prd.md`나 `plan-task.md`에서 리뷰 문서를 참조할 때는 `./reviews/<리뷰 파일명>.md` 상대 링크를 사용한다.
|
||||
|
||||
## 3. 리뷰 수행 원칙
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# 실행 스크립트
|
||||
|
||||
- 개발 서버: `npm run dev` (`http://127.0.0.1:8888`)
|
||||
- Mock preview 개발 서버: `npm run dev:mock` (`http://127.0.0.1:8889`)
|
||||
- 개발 서버용 빌드: `npm run build:dev`
|
||||
- 프로덕션 서버용 빌드: `npm run build:prod`
|
||||
- 기본 프로덕션 빌드: `npm run build`
|
||||
@@ -8,5 +9,7 @@
|
||||
- 린트: `npm run lint`
|
||||
- Vitest watch: `npm run test`
|
||||
- Vitest 단발 실행: `npm run test:run`
|
||||
- Playwright E2E: `npm run e2e`
|
||||
- Playwright E2E(server mode): `npm run e2e`는 `playwright.config.ts`의 server mode `testMatch`에 있는 spec만 실행하며, 추가 file filter를 넘기면 교집합만 실행한다.
|
||||
- Playwright E2E(mock mode): `npm run e2e:mock`은 `playwright.config.ts`의 mock mode `testMatch`에 있는 spec만 실행하며, 추가 file filter를 넘기면 교집합만 실행한다.
|
||||
- Mock preview domain rule: 후속 도메인 Phase는 자기 handler, fixture, mock E2E를 같은 Phase에서 소유하고 추가한다.
|
||||
- Phase 0 Gate 기준: `npm ci`, `npx playwright install chromium webkit`, `npm run typecheck`, `npm run lint`, `npm run test:run -- src/app/App.test.tsx src/shared/config/env.test.ts`, `npm run e2e -- tests/e2e/smoke.spec.ts`, `npm run build:dev`, `npm run build:prod`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Goal 실행형 구현 계획 샘플
|
||||
|
||||
> 이 문서는 goal 기능으로 구현 계획을 실행하기 위한 템플릿이다. 실제 `plan-task.md`를 만들 때 `<...>` placeholder를 모두 구체적인 값으로 교체한다. Phase는 결과와 의존성을 묶고, `create_goal`에는 Task 또는 Phase Gate 하나만 등록한다.
|
||||
> 이 문서는 goal 기능으로 구현 계획을 실행하기 위한 템플릿이다. 실제 `plan-task.md`를 만들 때 `<...>` placeholder를 모두 구체적인 값으로 교체한다. Phase는 결과와 의존성을 묶고, `create_goal`에는 Task 또는 Phase Gate 하나만 등록한다. 각 구현 Task에는 RED/GREEN/REFACTOR 절차를, 테스트가 현실적으로 불가능한 Task에는 TDD 예외 사유와 대체 검증 방법을 적고, 모든 Task와 Phase Gate에 실행 명령·기대 결과·수동 확인을 둔다.
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
@@ -49,7 +49,7 @@
|
||||
- 의존성: 실제 소비 Task에서 필요한 최소 dependency만 추가한다.
|
||||
- 계약: 제공되지 않은 endpoint, DTO, enum, 오류 status/key와 validation 상한을 추정하지 않는다.
|
||||
- backend 구현 전 UI 확인이 필요하면 제공 계약 기반 explicit mock mode를 사용하고 실제 404 자동 fallback·production mock을 금지하며 mock/server 완료 증거를 분리한다.
|
||||
- 구현: 모든 기능은 가장 작은 실패 test를 먼저 만들고 최소 구현으로 통과시킨다.
|
||||
- 구현: 모든 기능은 `RED: 실패 테스트 작성/실패 확인` → `GREEN: 최소 구현/통과 확인` → `REFACTOR: 정리/회귀 확인` 순서로 진행한다. 실패 테스트가 현실적으로 불가능하면 Task에 TDD 예외 사유와 대체 검증 방법을 먼저 확정한다.
|
||||
|
||||
## Phase 1
|
||||
|
||||
@@ -80,11 +80,19 @@
|
||||
- Consumes: `<선행 Task가 제공하는 type/function/component contract>`
|
||||
- Produces: `<후속 Task가 사용할 정확한 type/function/component contract>`
|
||||
|
||||
- [ ] 가장 작은 실패 test를 작성한다.
|
||||
- [ ] `<focused test 명령>`을 실행해 의도한 assertion 실패를 확인한다.
|
||||
- [ ] test를 통과시키는 최소 구현을 작성한다.
|
||||
- [ ] `<focused test 명령>`을 다시 실행해 성공을 확인한다.
|
||||
- [ ] 관련 typecheck·lint를 실행하고 실제 결과를 Progress에 기록한다.
|
||||
**TDD 절차:**
|
||||
|
||||
- [ ] **RED: 실패 테스트 작성/실패 확인** — `<정확한 test 파일 경로>`에 `<검증할 동작>`의 가장 작은 실패 test를 작성하고 `<focused test 명령>` 실행 시 `<의도한 assertion 메시지>`로 실패하는지 확인한다.
|
||||
- [ ] **GREEN: 최소 구현/통과 확인** — `<정확한 구현 파일 경로>`에 test를 통과시키는 최소 구현만 작성하고 같은 명령이 `exit 0`, `<N개 test 통과>`인지 확인한다.
|
||||
- [ ] **REFACTOR: 정리/회귀 확인** — 중복·이름·구조만 정리한 뒤 `<focused test 명령>`과 `<관련 회귀 test 명령>`이 모두 `exit 0`인지 확인한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `<focused test 명령>`, `<관련 회귀 test 명령>`, `<typecheck 명령>`, `<lint 명령>`
|
||||
- **기대 결과:** 모든 명령 `exit 0`, `<focused N개·회귀 N개 test>` 통과, type·lint 오류 0건.
|
||||
- **수동 확인:** `<viewport>`에서 `<사용자 동작>` 시 `<관찰 가능한 상태 변화>`가 발생하고 금지 동작은 발생하지 않는다.
|
||||
|
||||
- [ ] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
|
||||
|
||||
#### Task 1.2 `<두 번째 독립 결과>`
|
||||
|
||||
@@ -105,10 +113,19 @@
|
||||
- Consumes: `<P1-T1이 제공한 정확한 contract>`
|
||||
- Produces: `<Phase 2 또는 Gate가 사용할 정확한 contract>`
|
||||
|
||||
- [ ] 가장 작은 실패 test를 작성하고 의도한 실패를 확인한다.
|
||||
- [ ] 최소 구현으로 focused test를 통과시킨다.
|
||||
- [ ] 오류·loading·empty·success와 접근성 상태를 검증한다.
|
||||
- [ ] 관련 test·typecheck·lint 결과를 Progress에 기록한다.
|
||||
**TDD 절차:**
|
||||
|
||||
- [ ] **RED: 실패 테스트 작성/실패 확인** — `<정확한 test 파일 경로>`에 `<오류·loading·empty·success 중 이 Task가 소유한 상태>`와 `<사용자 action>`의 실패 test를 작성하고 `<focused test 명령>`이 의도한 이유로 실패하는지 확인한다.
|
||||
- [ ] **GREEN: 최소 구현/통과 확인** — 필요한 상태와 action만 최소 구현하고 같은 명령이 `exit 0`, `<N개 test 통과>`인지 확인한다.
|
||||
- [ ] **REFACTOR: 정리/회귀 확인** — 상태 분기와 접근성 이름을 정리한 뒤 `<focused test 명령>`과 `<P1-T1 관련 회귀 test 명령>`이 모두 통과하는지 확인한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `<focused UI test 명령>`, `<P1-T1 관련 회귀 test 명령>`, `<typecheck 명령>`, `<lint 명령>`
|
||||
- **기대 결과:** 모든 명령 `exit 0`, `<상태·action별 N개 test>` 통과, type·lint 오류 0건.
|
||||
- **수동 확인:** `<지원 viewport>`에서 오류·loading·empty·success 상태, keyboard focus 순서와 accessible name을 확인한다.
|
||||
|
||||
- [ ] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
|
||||
|
||||
### 완료 조건
|
||||
|
||||
@@ -126,6 +143,8 @@
|
||||
- **완료 증거:** 아래 자동·수동 검증 통과와 Progress 기록.
|
||||
- **범위 밖:** Gate 통과를 위한 test 삭제·완화와 관련 없는 기능 수정.
|
||||
|
||||
**실행 명령:**
|
||||
|
||||
```bash
|
||||
<focused unit/integration test 명령>
|
||||
<Phase 전용 E2E 명령>
|
||||
@@ -134,9 +153,9 @@
|
||||
<build 명령>
|
||||
```
|
||||
|
||||
**Expected:** `<0 exit code, test 수, 사용자가 완료할 흐름, 금지 요청 0회 등 관찰 가능한 결과>`
|
||||
**기대 결과:** `<모든 명령 exit 0, test 수, 사용자가 완료할 흐름, 금지 요청 0회 등 관찰 가능한 결과>`
|
||||
|
||||
수동 검증:
|
||||
**수동 확인:**
|
||||
|
||||
- [ ] `<viewport와 사용자 흐름>`
|
||||
- [ ] `<keyboard·focus·zoom·접근성 검사>`
|
||||
@@ -160,11 +179,41 @@
|
||||
- **완료 증거:** 계약 제공 또는 제외 결정이 기준 문서에 일치하고 구현 map이 기록됨.
|
||||
- **범위 밖:** 계약을 추정한 production adapter와 실제 기능 구현.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `<대상 prd.md 경로>`
|
||||
- Modify: `<대상 api-contract.md 경로>`
|
||||
- Modify: `<대상 plan-task.md 경로>`
|
||||
- Test: 없음 — 이 Task의 산출물은 실행 코드가 아니라 확정된 계약과 구현 map이다.
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `<PRD 요구사항 ID와 외부에서 제공한 API 계약>`
|
||||
- Produces: `<P2-T2가 사용할 endpoint·DTO·상태/action·component/file map>`
|
||||
|
||||
**TDD 예외 사유:** 이 Task는 실행 가능한 동작을 구현하지 않고 외부 근거로 계약과 책임 경계를 확정한다. 계약 확정 전에 실패 테스트를 만들면 제공되지 않은 endpoint·DTO를 추정하게 되므로 산출물을 올바르게 검증할 수 없다.
|
||||
|
||||
**대체 검증 방법:** PRD·API Contract·plan의 요구사항과 이름을 상호 대조하고, 금지된 미정 표현과 Markdown 변경 오류를 명령으로 검사한 뒤 문서의 추적성을 수동 확인한다.
|
||||
|
||||
- [ ] 필요한 endpoint·DTO·오류·pagination 계약을 확인한다.
|
||||
- [ ] loading·empty·error·success·read-only·viewport 상태와 action을 inventory한다.
|
||||
- [ ] 계약이 없으면 담당 주체·영향·재개 조건과 제외/후속 결정을 문서화한다.
|
||||
- [ ] Page·feature·shared component와 test file 책임을 확정한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:**
|
||||
|
||||
```bash
|
||||
! rg --pcre2 -n '^(?!\s*!?\s*rg\b).*(?:TBD|TODO|적절히 처리|나중에 구현|위와 동일)' <대상 prd.md 경로> <대상 api-contract.md 경로> <대상 plan-task.md 경로>
|
||||
git diff --check -- <대상 prd.md 경로> <대상 api-contract.md 경로> <대상 plan-task.md 경로>
|
||||
```
|
||||
|
||||
- **기대 결과:** 두 명령 모두 출력 없이 `exit 0`; 모든 요구사항 ID와 계약 이름이 세 문서에서 일치한다.
|
||||
- **수동 확인:** endpoint·DTO·오류·pagination 및 상태/action 각각에 근거 또는 담당 주체·영향·재개 조건이 있고, P2-T2의 Files와 Interfaces가 구현 결정을 내릴 만큼 구체적이다.
|
||||
|
||||
- [ ] 대체 검증의 실제 결과를 Progress에 기록한다.
|
||||
|
||||
#### Task 2.2 `<Phase 2의 독립 구현 결과>`
|
||||
|
||||
**Goal 실행 `P2-T2`:** `<사용자가 직접 확인할 수 있는 흐름을 한 문장으로 작성한다.>`
|
||||
@@ -179,11 +228,24 @@
|
||||
- Modify: `<정확한 파일 경로>`
|
||||
- Test: `<정확한 test 파일 경로>`
|
||||
|
||||
- [ ] contract와 serializer의 실패 test를 먼저 작성한다.
|
||||
- [ ] UI 상태와 사용자 action의 실패 test를 먼저 작성한다.
|
||||
- [ ] 최소 구현으로 focused test를 통과시킨다.
|
||||
- [ ] 관련 integration/E2E와 공통 품질 명령을 실행한다.
|
||||
- [ ] 실제 결과와 남은 항목을 Progress에 기록한다.
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `<P2-T1에서 확정한 endpoint·DTO·상태/action contract>`
|
||||
- Produces: `<P2-GATE가 검증할 adapter·component·사용자 흐름 contract>`
|
||||
|
||||
**TDD 절차:**
|
||||
|
||||
- [ ] **RED: 실패 테스트 작성/실패 확인** — `<contract test 파일>`과 `<UI test 파일>`에 serializer, 상태와 사용자 action의 가장 작은 실패 test를 작성하고 `<focused test 명령>`이 `<의도한 실패 이유>`로 실패하는지 확인한다.
|
||||
- [ ] **GREEN: 최소 구현/통과 확인** — P2-T1의 확정 계약만 사용하는 최소 adapter·UI를 구현하고 같은 명령이 `exit 0`, `<N개 test 통과>`인지 확인한다.
|
||||
- [ ] **REFACTOR: 정리/회귀 확인** — contract 변환과 UI 상태 책임을 정리한 뒤 `<focused test 명령>`과 `<관련 integration/E2E 명령>`이 모두 통과하는지 확인한다.
|
||||
|
||||
**검증 기준:**
|
||||
|
||||
- **실행 명령:** `<focused contract/UI test 명령>`, `<관련 integration/E2E 명령>`, `<typecheck 명령>`, `<lint 명령>`, `<build 명령>`
|
||||
- **기대 결과:** 모든 명령 `exit 0`, `<contract/UI/E2E별 N개 test>` 통과, type·lint·build 오류 0건, 금지된 request 0회.
|
||||
- **수동 확인:** `<지원 viewport>`에서 success·loading·empty·error·read-only 흐름과 keyboard·focus 동작을 확인하고 실제 request payload가 API Contract와 일치한다.
|
||||
|
||||
- [ ] TDD 단계와 검증 기준의 실제 결과 및 남은 항목을 Progress에 기록한다.
|
||||
|
||||
### 완료 조건
|
||||
|
||||
@@ -198,16 +260,24 @@
|
||||
**Goal 실행 `P2-GATE`:** Phase 2의 contract, 사용자 흐름과 회귀 방지를 최종 판정한다.
|
||||
|
||||
- **시작 조건:** Phase 2의 모든 활성 Task goal 완료.
|
||||
- **완료 증거:** 아래 명령과 Expected 통과, Progress에 실제 결과 누적.
|
||||
- **완료 증거:** 아래 실행 명령, 기대 결과와 수동 확인을 모두 통과하고 Progress에 실제 결과 누적.
|
||||
- **범위 밖:** 실패와 무관한 다음 Phase 구현.
|
||||
|
||||
**실행 명령:**
|
||||
|
||||
```bash
|
||||
<Phase 2 focused test 명령>
|
||||
<Phase 2 E2E 명령>
|
||||
<typecheck·lint·build 명령>
|
||||
```
|
||||
|
||||
**Expected:** `<사용자 journey, 오류 처리, request payload와 금지 동작을 포함한 최종 결과>`
|
||||
**기대 결과:** `<모든 명령 exit 0, test 수, 사용자 journey, 오류 처리, request payload와 금지 동작을 포함한 최종 결과>`
|
||||
|
||||
**수동 확인:**
|
||||
|
||||
- [ ] `<지원 viewport에서 success·loading·empty·error·read-only 흐름>`
|
||||
- [ ] `<keyboard·focus·zoom·접근성 동작>`
|
||||
- [ ] `<API Contract와 실제 request·response 및 production mock 미사용>`
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
@@ -252,6 +322,11 @@ P1-T1 → P1-T2 → P1-GATE → P2-T1 → P2-T2 → P2-GATE
|
||||
- 상태: 진행 중 / 완료 / 차단 감사 중 / 차단
|
||||
- 무엇을: `<이번 실행에서 완료한 체크박스와 산출물>`
|
||||
- 왜: `<Task objective와 요구사항 근거>`
|
||||
- TDD: `<구현 Task는 RED/GREEN/REFACTOR만, 예외 Task는 예외 항목만 남긴다.>`
|
||||
- RED: `<실패 test 명령>` — `<의도한 실패, exit code와 assertion>`
|
||||
- GREEN: `<같은 focused test 명령>` — `<성공, exit code와 test 수>`
|
||||
- REFACTOR: `<focused·관련 회귀 test 명령>` — `<성공/실패, exit code와 test 수>`
|
||||
- 예외 Task: `<TDD 예외 사유와 대체 검증 결과. 구현 Task에서는 이 행을 삭제한다.>`
|
||||
- 어떻게:
|
||||
- `<실행 명령>` — `<성공/실패, exit code, test 수와 핵심 결과>`
|
||||
- `<수동 검증>` — `<성공/실패/불가 사유>`
|
||||
@@ -276,7 +351,7 @@ P1-T1 → P1-T2 → P1-GATE → P2-T1 → P2-T2 → P2-GATE
|
||||
- 구현 중 발견한 범위 내 문제는 근거와 재현 방법을 기록하고 해당 Task에서 처리한다.
|
||||
- 완료된 범위의 회귀는 기존 Task를 다시 열지 않고 별도 회귀 수정 Task와 goal을 만든다.
|
||||
- 범위 밖 문제는 임의로 수정하지 않고 사용자에게 보고하거나 후속 Task로 결정한다.
|
||||
- 상세 코드 리뷰 결과가 필요하면 `sample-review.md` 형식의 별도 review 문서를 사용한다.
|
||||
- 상세 코드 리뷰 결과가 필요하면 기능 문서 디렉터리의 `reviews/` 아래에 `sample-review.md` 형식의 별도 review 문서를 만든다.
|
||||
|
||||
## 최종 보고 형식
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 코드 리뷰 보고서 샘플
|
||||
|
||||
> 이 문서는 완료된 Phase를 다시 검토할 때 사용하는 템플릿이다. 리뷰에서 발견한 후보를 먼저 검증하고, **확정**된 항목만 `plan-task.md`의 회귀 수정 Task와 goal로 전환한다. 기존 완료 체크박스와 검증 기록은 삭제하거나 되돌리지 않는다.
|
||||
> 이 문서는 완료된 Phase를 다시 검토할 때 사용하는 템플릿이다. 실제 리뷰 문서는 대상 `prd.md`·`plan-task.md` 디렉터리 아래 `reviews/`에 둔다. 리뷰에서 발견한 후보를 먼저 검증하고, **확정**된 항목만 `plan-task.md`의 회귀 수정 Task와 goal로 전환한다. 기존 완료 체크박스와 검증 기록은 삭제하거나 되돌리지 않는다.
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
|
||||
11
package.json
11
package.json
@@ -4,7 +4,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 8888 --strictPort",
|
||||
"dev": "VITE_API_MODE=server vite --host 127.0.0.1 --port 8888 --strictPort",
|
||||
"dev:mock": "VITE_API_MODE=mock vite --host 127.0.0.1 --port 8889 --strictPort",
|
||||
"build": "npm run build:prod",
|
||||
"build:dev": "tsc -b && vite build --mode development",
|
||||
"build:prod": "tsc -b && vite build --mode production",
|
||||
@@ -12,7 +13,8 @@
|
||||
"lint": "eslint .",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"e2e": "playwright test"
|
||||
"e2e": "VITE_API_MODE=server playwright test",
|
||||
"e2e:mock": "VITE_API_MODE=mock playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
@@ -43,5 +45,10 @@
|
||||
"typescript-eslint": "8.65.0",
|
||||
"vite": "8.1.5",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const apiMode = process.env.VITE_API_MODE === "mock" ? "mock" : "server";
|
||||
const webServerPort = apiMode === "mock" ? 8889 : 8888;
|
||||
const serverTestMatch = [
|
||||
"**/server-mode-boundary.spec.ts",
|
||||
"**/smoke.spec.ts",
|
||||
"**/auth.spec.ts",
|
||||
"**/accessibility-shell.spec.ts",
|
||||
] as const;
|
||||
const mockTestMatch = ["**/mock-preview-shell.spec.ts", "**/mock-mode-boundary.spec.ts"] as const;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
webServer: {
|
||||
command: "npm run dev",
|
||||
url: "http://127.0.0.1:8888",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
command: apiMode === "mock" ? "npm run dev:mock" : "npm run dev",
|
||||
env: { VITE_API_MODE: apiMode },
|
||||
url: `http://127.0.0.1:${webServerPort}`,
|
||||
reuseExistingServer: !process.env.CI && apiMode === "server",
|
||||
},
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:8888",
|
||||
baseURL: `http://127.0.0.1:${webServerPort}`,
|
||||
},
|
||||
testMatch: apiMode === "mock" ? [...mockTestMatch] : [...serverTestMatch],
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
|
||||
|
||||
361
public/mockServiceWorker.js
Normal file
361
public/mockServiceWorker.js
Normal file
@@ -0,0 +1,361 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.15.0'
|
||||
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Omit the body of server-sent event stream responses.
|
||||
// Cloning such responses would prevent client-side stream cancelations
|
||||
// from reaching the original stream (a teed stream only cancels its
|
||||
// source once both of its branches cancel) and would buffer the
|
||||
// entire stream into the unconsumed clone indefinitely.
|
||||
const isEventStreamResponse = response.headers
|
||||
.get('content-type')
|
||||
?.toLowerCase()
|
||||
.startsWith('text/event-stream')
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = isEventStreamResponse ? null : response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: response.type,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
body: responseClone ? responseClone.body : null,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone && responseClone.body
|
||||
? [serializedRequest.body, responseClone.body]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,74 @@ function useAiCharactersResponse(status: 200 | 401 | 403 = 200, onRequest: (requ
|
||||
);
|
||||
}
|
||||
|
||||
function useAiCharactersFailure(status: 404 | "network") {
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => {
|
||||
if (status === "network") {
|
||||
return HttpResponse.error();
|
||||
}
|
||||
|
||||
return HttpResponse.json({ success: false, message: "없습니다.", data: null, errorProperty: null }, { status });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function installDesktopMediaQuery() {
|
||||
const desktopQuery = "(min-width: 1024px)";
|
||||
let matches = false;
|
||||
const listeners = new Set<(event: Event) => void>();
|
||||
const mediaQueryList = {
|
||||
get matches() {
|
||||
return matches;
|
||||
},
|
||||
media: desktopQuery,
|
||||
onchange: null,
|
||||
addEventListener: (type: string, listener: EventListenerOrEventListenerObject | null) => {
|
||||
if (type !== "change" || listener === null || typeof listener !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
listeners.add(listener);
|
||||
},
|
||||
removeEventListener: (type: string, listener: EventListenerOrEventListenerObject | null) => {
|
||||
if (type !== "change" || listener === null || typeof listener !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
listeners.delete(listener);
|
||||
},
|
||||
dispatchEvent: (event: Event) => {
|
||||
listeners.forEach((listener) => listener(event));
|
||||
return true;
|
||||
},
|
||||
addListener: (listener: (event: Event) => void) => {
|
||||
listeners.add(listener);
|
||||
},
|
||||
removeListener: (listener: (event: Event) => void) => {
|
||||
listeners.delete(listener);
|
||||
},
|
||||
} satisfies MediaQueryList;
|
||||
vi.stubGlobal("matchMedia", (query: string) => {
|
||||
expect(query).toBe(desktopQuery);
|
||||
return mediaQueryList;
|
||||
});
|
||||
|
||||
return {
|
||||
setDesktopMatch: () => {
|
||||
matches = true;
|
||||
mediaQueryList.dispatchEvent(new Event("change"));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function requireElement(element: Element | null, name: string): Element {
|
||||
if (element === null) {
|
||||
throw new Error(`${name} not found`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
});
|
||||
@@ -56,10 +124,10 @@ test("redirects an unauthenticated direct visit to /ai-characters without exposi
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.queryByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Phase 3에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/login"));
|
||||
expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Phase 3에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders the existing login page at /login", () => {
|
||||
@@ -71,6 +139,30 @@ test("renders the existing login page at /login", () => {
|
||||
expect(screen.getByRole("button", { name: "로그인" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows the mock mode banner on the login page only in mock mode", () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_MODE", "mock");
|
||||
window.history.pushState({}, "", "/login");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
|
||||
});
|
||||
|
||||
test("does not show the mock mode banner on the login page in server mode", () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_MODE", "server");
|
||||
window.history.pushState({}, "", "/login");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("navigates to /ai-characters after a successful login", async () => {
|
||||
window.history.pushState({}, "", "/login");
|
||||
useAiCharactersResponse();
|
||||
@@ -111,10 +203,94 @@ test("renders the protected admin shell for an existing ADMIN session", async ()
|
||||
expect(screen.getByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "데스크톱 주 메뉴" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "브레드크럼" })).toHaveTextContent("AI 캐릭터");
|
||||
expect(screen.getByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Phase 3에서 AI 캐릭터 목록이 연결됩니다.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("루나")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows an accessible status while the initial protected route probe is pending", async () => {
|
||||
// Given
|
||||
saveAdminSession();
|
||||
let resolveProbeReady: (finishProbe: () => void) => void = () => undefined;
|
||||
const probeReady = new Promise<() => void>((resolve) => {
|
||||
resolveProbeReady = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () =>
|
||||
new Promise((resolve) => {
|
||||
resolveProbeReady(() => resolve(HttpResponse.json({ success: true, message: null, data: null, errorProperty: null })));
|
||||
}),
|
||||
),
|
||||
);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
const finishProbe = await probeReady;
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
|
||||
finishProbe();
|
||||
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows the mock mode banner in the protected admin shell only in mock mode", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_MODE", "mock");
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
|
||||
});
|
||||
|
||||
test("keeps the mock mode banner inside the inert background while the mobile menu is open", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_MODE", "mock");
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
await screen.findByRole("main", { name: "AI 캐릭터 관리" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "모바일 메뉴 열기" }));
|
||||
|
||||
// Then
|
||||
const banner = requireElement(screen.getByText("Mock Preview").closest("[role='status']"), "mock banner");
|
||||
const inertBackground = requireElement(banner.closest("[inert]"), "mock banner inert background");
|
||||
expect(inertBackground).toHaveAttribute("aria-hidden", "true");
|
||||
expect(screen.queryByRole("status", { name: "Mock Preview" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("closes the mobile menu at the lg breakpoint without restoring focus to the hidden trigger", async () => {
|
||||
// Given
|
||||
const desktopMediaQuery = installDesktopMediaQuery();
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
await screen.findByRole("main", { name: "AI 캐릭터 관리" });
|
||||
const menuButton = screen.getByRole("button", { name: "모바일 메뉴 열기" });
|
||||
fireEvent.click(menuButton);
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "모바일 메뉴 닫기" })).toHaveFocus());
|
||||
desktopMediaQuery.setDesktopMatch();
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(screen.queryByRole("navigation", { name: "모바일 주 메뉴" })).not.toBeInTheDocument());
|
||||
expect(menuButton).not.toHaveFocus();
|
||||
expect(screen.getByRole("navigation", { name: "데스크톱 주 메뉴" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "로그아웃" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("composes Task 1.5 shared empty state in the real admin shell without domain list data", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
@@ -123,7 +299,7 @@ test("composes Task 1.5 shared empty state in the real admin shell without domai
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Phase 2에서 AI 캐릭터 목록이 연결됩니다.");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Phase 3에서 AI 캐릭터 목록이 연결됩니다.");
|
||||
expect(screen.queryByText("루나")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -138,7 +314,7 @@ test("clears the session and routes to login when the protected route request re
|
||||
expect(authSessionStorage.read()).toBeNull();
|
||||
expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("세션이 만료되었습니다. 다시 로그인하세요.");
|
||||
expect(screen.queryByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Phase 3에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("routes to access denied without clearing the session when the protected route request returns 403", async () => {
|
||||
@@ -154,6 +330,184 @@ test("routes to access denied without clearing the session when the protected ro
|
||||
expect(screen.queryByText("루나")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps the mock mode banner visible on the access denied page", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_MODE", "mock");
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse(403);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
|
||||
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
|
||||
});
|
||||
|
||||
test("keeps the protected shell hidden when the protected route request returns 404", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersFailure(404);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
|
||||
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps the mock mode banner visible on protected route errors", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_MODE", "mock");
|
||||
saveAdminSession();
|
||||
useAiCharactersFailure(404);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
// When
|
||||
render(<App />);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
|
||||
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps the protected shell hidden when the protected route request has a network error", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersFailure("network");
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("보호 route 확인에 실패했습니다.");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("retries a protected route 404 and reveals the shell only after the current retry succeeds", async () => {
|
||||
// Given
|
||||
saveAdminSession();
|
||||
let requestCount = 0;
|
||||
let resolveRetryReady: (finishRetry: () => void) => void = () => undefined;
|
||||
const retryReady = new Promise<() => void>((resolve) => {
|
||||
resolveRetryReady = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return HttpResponse.json({ success: false, message: "없습니다.", data: null, errorProperty: null }, { status: 404 });
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
resolveRetryReady(() => resolve(HttpResponse.json({ success: true, message: null, data: null, errorProperty: null })));
|
||||
});
|
||||
}),
|
||||
);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
render(<App />);
|
||||
const alert = await screen.findByRole("alert");
|
||||
const retryButton = screen.getByRole("button", { name: "보호 route 다시 시도" });
|
||||
retryButton.focus();
|
||||
expect(alert).toHaveTextContent("없습니다.");
|
||||
expect(retryButton).toHaveFocus();
|
||||
expect(requestCount).toBe(1);
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
|
||||
// When
|
||||
fireEvent.click(retryButton);
|
||||
const finishRetry = await retryReady;
|
||||
|
||||
// Then
|
||||
expect(requestCount).toBe(2);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("보호 route 확인 중");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
|
||||
finishRetry();
|
||||
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "로그아웃" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps retry available and the protected shell hidden when a network retry fails", async () => {
|
||||
// Given
|
||||
saveAdminSession();
|
||||
let requestCount = 0;
|
||||
server.use(
|
||||
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () => {
|
||||
requestCount += 1;
|
||||
return HttpResponse.error();
|
||||
}),
|
||||
);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
render(<App />);
|
||||
const retryButton = await screen.findByRole("button", { name: "보호 route 다시 시도" });
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("보호 route 확인에 실패했습니다.");
|
||||
expect(requestCount).toBe(1);
|
||||
|
||||
// When
|
||||
fireEvent.click(retryButton);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(requestCount).toBe(2));
|
||||
expect(await screen.findByRole("button", { name: "보호 route 다시 시도" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("보호 route 확인에 실패했습니다.");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "로그아웃" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clears a previous protected route verification before reusing the same token after login", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
server.use(
|
||||
http.post(`${apiBaseUrl}/member/logout`, () => HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null })),
|
||||
http.post(`${apiBaseUrl}/admin/member/login`, () =>
|
||||
HttpResponse.json({
|
||||
success: true,
|
||||
message: null,
|
||||
data: { token: "admin-token", role: "ADMIN" },
|
||||
errorProperty: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "로그아웃" }));
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/login"));
|
||||
useAiCharactersFailure(404);
|
||||
fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } });
|
||||
fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters"));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clears a previous protected route verification before the same session re-enters the route", async () => {
|
||||
saveAdminSession();
|
||||
useAiCharactersResponse();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
|
||||
window.history.pushState({}, "", "/login");
|
||||
fireEvent.popState(window);
|
||||
await waitFor(() => expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument());
|
||||
useAiCharactersFailure(404);
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
fireEvent.popState(window);
|
||||
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("없습니다.");
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps the protected shell hidden while a stale ADMIN probe is pending and then denied", async () => {
|
||||
saveAdminSession();
|
||||
window.history.pushState({}, "", "/ai-characters");
|
||||
@@ -180,7 +534,7 @@ test("keeps the protected shell hidden while a stale ADMIN probe is pending and
|
||||
|
||||
const triggerDenyProbe = await denyProbeReady;
|
||||
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Phase 3에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
|
||||
triggerDenyProbe();
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
|
||||
|
||||
254
src/app/App.tsx
254
src/app/App.tsx
@@ -1,161 +1,66 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { LoginPage } from "@/features/auth/pages/LoginPage";
|
||||
import { AuthSessionProvider } from "@/features/auth/model/auth-session";
|
||||
import { useAuthSession } from "@/features/auth/model/auth-session-context";
|
||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||
import { AccessDeniedPage, AiCharactersPage } from "@/app/admin-pages";
|
||||
import { AccessDeniedPage } from "@/app/admin-pages";
|
||||
import { routePaths } from "@/app/route-paths";
|
||||
import { navigateTo, replaceWith, useBrowserLocation } from "@/app/browser-location";
|
||||
import { AccessDeniedError } from "@/shared/api/api-error";
|
||||
import { ProtectedAdminShell } from "@/app/protected-admin-shell";
|
||||
import { AccessDeniedError, ApiError } from "@/shared/api/api-error";
|
||||
import { createApiClient } from "@/shared/api/client";
|
||||
import type { ApiMode } from "@/shared/config/env";
|
||||
import { getRuntimeEnv } from "@/shared/config/env";
|
||||
import { MockModeBanner } from "@/shared/ui/mock-mode-banner";
|
||||
import { PageState } from "@/shared/ui/page-state";
|
||||
const aiCharactersRouteResponseSchema = z.unknown();
|
||||
const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인하세요.";
|
||||
const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
|
||||
|
||||
function NavLink() {
|
||||
type ProtectedRouteError = {
|
||||
readonly message: string;
|
||||
readonly session: NonNullable<ReturnType<typeof useAuthSession>["session"]>;
|
||||
readonly routeVisitKey: number;
|
||||
readonly protectedRouteRetryKey: number;
|
||||
};
|
||||
|
||||
type ProtectedRouteVerification = {
|
||||
readonly session: ProtectedRouteError["session"];
|
||||
readonly routeVisitKey: number;
|
||||
readonly protectedRouteRetryKey: number;
|
||||
};
|
||||
|
||||
function ProtectedRouteErrorPage({ message, onRetry }: { readonly message: string; readonly onRetry: () => void }) {
|
||||
return (
|
||||
<a
|
||||
className="rounded-md px-3 py-2 text-sm font-semibold text-accent-foreground hover:bg-accent"
|
||||
href={routePaths.aiCharacters}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateTo(routePaths.aiCharacters);
|
||||
}}
|
||||
>
|
||||
AI 캐릭터
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ProtectedAdminShell({ routeError }: { readonly routeError: string | null }) {
|
||||
const auth = useAuthSession();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const mobileMenuRef = useRef<HTMLElement>(null);
|
||||
const shouldRestoreMenuFocusRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobileMenuOpen || !shouldRestoreMenuFocusRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
shouldRestoreMenuFocusRef.current = false;
|
||||
menuButtonRef.current?.focus();
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobileMenuOpen) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
closeButtonRef.current?.focus();
|
||||
|
||||
function closeOnEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
shouldRestoreMenuFocusRef.current = true;
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
|
||||
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
function keepFocusInMobileMenu(event: React.KeyboardEvent<HTMLElement>) {
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusableElements = Array.from(mobileMenuRef.current?.querySelectorAll<HTMLElement>(focusableSelector) ?? []);
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements.at(-1);
|
||||
|
||||
if (firstElement === undefined || lastElement === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.shiftKey && document.activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.shiftKey && document.activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeMobileMenu() {
|
||||
shouldRestoreMenuFocusRef.current = true;
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] bg-background text-foreground">
|
||||
<div aria-hidden={isMobileMenuOpen} className="contents" inert={isMobileMenuOpen}>
|
||||
<a className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-modal focus:rounded-md focus:bg-card focus:px-4 focus:py-2 focus:text-link" href="#app-main">
|
||||
본문으로 건너뛰기
|
||||
</a>
|
||||
<aside className="hidden w-60 shrink-0 border-r border-border bg-card p-4 lg:block">
|
||||
<p className="mb-4 text-xs font-semibold text-info">AI CHARACTER ADMIN</p>
|
||||
<nav aria-label="데스크톱 주 메뉴" className="flex flex-col gap-2">
|
||||
<NavLink />
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex min-h-14 items-center justify-between gap-3 border-b border-border bg-card px-4" role="banner">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<main className="flex min-h-[100dvh] items-center justify-center bg-background px-4 text-foreground">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">
|
||||
{message}
|
||||
</p>
|
||||
<button
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold lg:hidden"
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
ref={menuButtonRef}
|
||||
className="min-h-11 rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]"
|
||||
onClick={onRetry}
|
||||
type="button"
|
||||
>
|
||||
모바일 메뉴 열기
|
||||
보호 route 다시 시도
|
||||
</button>
|
||||
<nav aria-label="브레드크럼" className="text-sm text-muted-foreground">
|
||||
<ol className="flex items-center gap-2">
|
||||
<li>홈</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li className="font-semibold text-foreground">AI 캐릭터</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={() => void auth.logout()} type="button">
|
||||
로그아웃
|
||||
</button>
|
||||
</header>
|
||||
<main aria-label="AI 캐릭터 관리" className="min-h-0 flex-1 overflow-auto p-4" id="app-main">
|
||||
<AiCharactersPage routeError={routeError} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{isMobileMenuOpen ? (
|
||||
<div className="fixed inset-0 z-overlay bg-background/80 lg:hidden">
|
||||
<nav
|
||||
aria-label="모바일 주 메뉴"
|
||||
className="flex min-h-[100dvh] w-[min(15rem,50vw)] flex-col gap-3 border-r border-border bg-card p-4"
|
||||
onKeyDown={keepFocusInMobileMenu}
|
||||
ref={mobileMenuRef}
|
||||
>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold" onClick={closeMobileMenu} ref={closeButtonRef} type="button">
|
||||
모바일 메뉴 닫기
|
||||
</button>
|
||||
<NavLink />
|
||||
</nav>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
function RouteFrame({ apiMode, children }: { readonly apiMode: ApiMode; readonly children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<MockModeBanner apiMode={apiMode} />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShell({ apiMode }: { readonly apiMode: ApiMode }) {
|
||||
const auth = useAuthSession();
|
||||
const protectedRouteApiClient = useMemo(
|
||||
() =>
|
||||
@@ -167,22 +72,25 @@ function AppShell() {
|
||||
[auth],
|
||||
);
|
||||
const location = useBrowserLocation();
|
||||
const [routeError, setRouteError] = useState<string | null>(null);
|
||||
const [verifiedProtectedRouteToken, setVerifiedProtectedRouteToken] = useState<string | null>(null);
|
||||
const [routeError, setRouteError] = useState<ProtectedRouteError | null>(null);
|
||||
const [verifiedProtectedRouteSession, setVerifiedProtectedRouteSession] = useState<ProtectedRouteVerification | null>(null);
|
||||
const [protectedRouteRetryKey, setProtectedRouteRetryKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (location !== routePaths.login && auth.session === null) {
|
||||
if (location.path !== routePaths.login && auth.session === null) {
|
||||
replaceWith(routePaths.login);
|
||||
}
|
||||
}, [auth.session, location]);
|
||||
}, [auth.session, location.path]);
|
||||
|
||||
useEffect(() => {
|
||||
if (location !== routePaths.aiCharacters || auth.session === null) {
|
||||
if (location.path !== routePaths.aiCharacters || auth.session === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let isCurrent = true;
|
||||
const sessionToken = auth.session.token;
|
||||
const session = auth.session;
|
||||
const routeVisitKey = location.visitKey;
|
||||
const currentProtectedRouteRetryKey = protectedRouteRetryKey;
|
||||
void protectedRouteApiClient
|
||||
.request({
|
||||
path: "/api/v2/admin/ai-characters?page=0&size=20",
|
||||
@@ -192,7 +100,7 @@ function AppShell() {
|
||||
.then(() => {
|
||||
if (isCurrent) {
|
||||
setRouteError(null);
|
||||
setVerifiedProtectedRouteToken(sessionToken);
|
||||
setVerifiedProtectedRouteSession({ session, routeVisitKey, protectedRouteRetryKey: currentProtectedRouteRetryKey });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -205,16 +113,22 @@ function AppShell() {
|
||||
return;
|
||||
}
|
||||
|
||||
setRouteError("보호 route 확인에 실패했습니다.");
|
||||
setRouteError({
|
||||
message: error instanceof ApiError ? error.message : "보호 route 확인에 실패했습니다.",
|
||||
session,
|
||||
routeVisitKey,
|
||||
protectedRouteRetryKey: currentProtectedRouteRetryKey,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [auth.session, location, protectedRouteApiClient]);
|
||||
}, [auth.session, location.path, location.visitKey, protectedRouteApiClient, protectedRouteRetryKey]);
|
||||
|
||||
if (location === routePaths.login) {
|
||||
if (location.path === routePaths.login) {
|
||||
return (
|
||||
<RouteFrame apiMode={apiMode}>
|
||||
<LoginPage
|
||||
notice={auth.loginNotice}
|
||||
onSubmit={async (credentials) => {
|
||||
@@ -222,6 +136,7 @@ function AppShell() {
|
||||
navigateTo(routePaths.aiCharacters);
|
||||
}}
|
||||
/>
|
||||
</RouteFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -229,18 +144,53 @@ function AppShell() {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (location === routePaths.accessDenied) {
|
||||
return <AccessDeniedPage />;
|
||||
if (location.path === routePaths.accessDenied) {
|
||||
return (
|
||||
<RouteFrame apiMode={apiMode}>
|
||||
<AccessDeniedPage />
|
||||
</RouteFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (location === routePaths.aiCharacters && verifiedProtectedRouteToken !== auth.session.token) {
|
||||
return null;
|
||||
const currentRouteError =
|
||||
routeError?.session === auth.session &&
|
||||
routeError.routeVisitKey === location.visitKey &&
|
||||
routeError.protectedRouteRetryKey === protectedRouteRetryKey
|
||||
? routeError.message
|
||||
: null;
|
||||
|
||||
if (
|
||||
location.path === routePaths.aiCharacters &&
|
||||
(verifiedProtectedRouteSession?.session !== auth.session ||
|
||||
verifiedProtectedRouteSession.routeVisitKey !== location.visitKey ||
|
||||
verifiedProtectedRouteSession.protectedRouteRetryKey !== protectedRouteRetryKey)
|
||||
) {
|
||||
return currentRouteError === null ? (
|
||||
<RouteFrame apiMode={apiMode}>
|
||||
<main className="min-h-[100dvh] bg-background p-4 text-foreground">
|
||||
<PageState state="loading" title="보호 route 확인 중" description="관리자 권한을 확인하는 동안 잠시 기다려 주세요." />
|
||||
</main>
|
||||
</RouteFrame>
|
||||
) : (
|
||||
<RouteFrame apiMode={apiMode}>
|
||||
<ProtectedRouteErrorPage
|
||||
message={currentRouteError}
|
||||
onRetry={() => {
|
||||
setRouteError(null);
|
||||
setProtectedRouteRetryKey((retryKey) => retryKey + 1);
|
||||
}}
|
||||
/>
|
||||
</RouteFrame>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProtectedAdminShell routeError={routeError} />;
|
||||
return (
|
||||
<ProtectedAdminShell apiMode={apiMode} routeError={currentRouteError} />
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const runtimeEnv = getRuntimeEnv();
|
||||
const apiClient = useMemo(
|
||||
() =>
|
||||
createApiClient({
|
||||
@@ -257,7 +207,7 @@ export function App() {
|
||||
|
||||
return (
|
||||
<AuthSessionProvider apiClient={apiClient} onNavigateLogin={replaceWith}>
|
||||
<AppShell />
|
||||
<AppShell apiMode={runtimeEnv.apiMode} />
|
||||
</AuthSessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,14 +24,14 @@ export function AiCharactersPage({ routeError }: { readonly routeError: string |
|
||||
<h1 className="text-2xl font-bold leading-tight" id="ai-characters-title">
|
||||
AI 캐릭터
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">캐릭터 목록과 생성 흐름은 Phase 2에서 연결합니다.</p>
|
||||
<p className="text-sm text-muted-foreground">캐릭터 목록과 생성 흐름은 Phase 3에서 연결합니다.</p>
|
||||
</div>
|
||||
{routeError === null ? null : (
|
||||
<p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">
|
||||
{routeError}
|
||||
</p>
|
||||
)}
|
||||
<PageState description="현재 route는 보호 shell과 권한 처리를 검증하는 명시적 빈 상태입니다." state="empty" title="Phase 2에서 AI 캐릭터 목록이 연결됩니다." />
|
||||
<PageState description="현재 route는 보호 shell과 권한 처리를 검증하는 명시적 빈 상태입니다." state="empty" title="Phase 3에서 AI 캐릭터 목록이 연결됩니다." />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,25 @@ import { useSyncExternalStore } from "react";
|
||||
|
||||
import { routePaths, type RoutePath } from "@/app/route-paths";
|
||||
|
||||
function subscribe(onStoreChange: () => void): () => void {
|
||||
window.addEventListener("popstate", onStoreChange);
|
||||
export type BrowserLocationSnapshot = {
|
||||
readonly path: RoutePath;
|
||||
readonly visitKey: number;
|
||||
};
|
||||
|
||||
return () => window.removeEventListener("popstate", onStoreChange);
|
||||
let currentSnapshot: BrowserLocationSnapshot = { path: readRoutePath(), visitKey: 0 };
|
||||
|
||||
function subscribe(onStoreChange: () => void): () => void {
|
||||
function handlePopState() {
|
||||
currentSnapshot = { path: readRoutePath(), visitKey: currentSnapshot.visitKey + 1 };
|
||||
onStoreChange();
|
||||
}
|
||||
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}
|
||||
|
||||
function getSnapshot(): RoutePath {
|
||||
function readRoutePath(): RoutePath {
|
||||
const path = window.location.pathname;
|
||||
|
||||
if (path === routePaths.login || path === routePaths.aiCharacters || path === routePaths.accessDenied) {
|
||||
@@ -18,7 +30,16 @@ function getSnapshot(): RoutePath {
|
||||
return routePaths.aiCharacters;
|
||||
}
|
||||
|
||||
export function useBrowserLocation(): RoutePath {
|
||||
function getSnapshot(): BrowserLocationSnapshot {
|
||||
const path = readRoutePath();
|
||||
if (currentSnapshot.path !== path) {
|
||||
currentSnapshot = { path, visitKey: currentSnapshot.visitKey + 1 };
|
||||
}
|
||||
|
||||
return currentSnapshot;
|
||||
}
|
||||
|
||||
export function useBrowserLocation(): BrowserLocationSnapshot {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
|
||||
176
src/app/protected-admin-shell.tsx
Normal file
176
src/app/protected-admin-shell.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { navigateTo } from "@/app/browser-location";
|
||||
import { AiCharactersPage } from "@/app/admin-pages";
|
||||
import { routePaths } from "@/app/route-paths";
|
||||
import { useAuthSession } from "@/features/auth/model/auth-session-context";
|
||||
import type { ApiMode } from "@/shared/config/env";
|
||||
import { MockModeBanner } from "@/shared/ui/mock-mode-banner";
|
||||
|
||||
const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
|
||||
|
||||
function NavLink() {
|
||||
return (
|
||||
<a
|
||||
className="rounded-md px-3 py-2 text-sm font-semibold text-accent-foreground hover:bg-accent"
|
||||
href={routePaths.aiCharacters}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateTo(routePaths.aiCharacters);
|
||||
}}
|
||||
>
|
||||
AI 캐릭터
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProtectedAdminShell({ apiMode, routeError }: { readonly apiMode: ApiMode; readonly routeError: string | null }) {
|
||||
const auth = useAuthSession();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const mobileMenuRef = useRef<HTMLElement>(null);
|
||||
const shouldRestoreMenuFocusRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.matchMedia === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const desktopMediaQuery = window.matchMedia("(min-width: 1024px)");
|
||||
|
||||
function closeOnDesktopMatch() {
|
||||
if (!desktopMediaQuery.matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
shouldRestoreMenuFocusRef.current = false;
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
|
||||
closeOnDesktopMatch();
|
||||
desktopMediaQuery.addEventListener("change", closeOnDesktopMatch);
|
||||
|
||||
return () => desktopMediaQuery.removeEventListener("change", closeOnDesktopMatch);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobileMenuOpen || !shouldRestoreMenuFocusRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
shouldRestoreMenuFocusRef.current = false;
|
||||
menuButtonRef.current?.focus();
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobileMenuOpen) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
closeButtonRef.current?.focus();
|
||||
|
||||
function closeOnEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
shouldRestoreMenuFocusRef.current = true;
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
|
||||
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
function keepFocusInMobileMenu(event: React.KeyboardEvent<HTMLElement>) {
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusableElements = Array.from(mobileMenuRef.current?.querySelectorAll<HTMLElement>(focusableSelector) ?? []);
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements.at(-1);
|
||||
|
||||
if (firstElement === undefined || lastElement === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.shiftKey && document.activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.shiftKey && document.activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeMobileMenu() {
|
||||
shouldRestoreMenuFocusRef.current = true;
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[100dvh] bg-background text-foreground">
|
||||
<div aria-hidden={isMobileMenuOpen} className="flex min-h-[100dvh] flex-col" inert={isMobileMenuOpen}>
|
||||
<MockModeBanner apiMode={apiMode} />
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<a className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-modal focus:rounded-md focus:bg-card focus:px-4 focus:py-2 focus:text-link" href="#app-main">
|
||||
본문으로 건너뛰기
|
||||
</a>
|
||||
<aside className="hidden w-60 shrink-0 border-r border-border bg-card p-4 lg:block">
|
||||
<p className="mb-4 text-xs font-semibold text-info">AI CHARACTER ADMIN</p>
|
||||
<nav aria-label="데스크톱 주 메뉴" className="flex flex-col gap-2">
|
||||
<NavLink />
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex min-h-14 items-center justify-between gap-3 border-b border-border bg-card px-4" role="banner">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold lg:hidden"
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
ref={menuButtonRef}
|
||||
type="button"
|
||||
>
|
||||
모바일 메뉴 열기
|
||||
</button>
|
||||
<nav aria-label="브레드크럼" className="text-sm text-muted-foreground">
|
||||
<ol className="flex items-center gap-2">
|
||||
<li>홈</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li className="font-semibold text-foreground">AI 캐릭터</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={() => void auth.logout()} type="button">
|
||||
로그아웃
|
||||
</button>
|
||||
</header>
|
||||
<main aria-label="AI 캐릭터 관리" className="min-h-0 flex-1 overflow-auto p-4" id="app-main">
|
||||
<AiCharactersPage routeError={routeError} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isMobileMenuOpen ? (
|
||||
<div className="fixed inset-0 z-overlay bg-background/80 lg:hidden">
|
||||
<nav
|
||||
aria-label="모바일 주 메뉴"
|
||||
className="flex min-h-[100dvh] w-[min(15rem,50vw)] flex-col gap-3 border-r border-border bg-card p-4"
|
||||
onKeyDown={keepFocusInMobileMenu}
|
||||
ref={mobileMenuRef}
|
||||
>
|
||||
<button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold" onClick={closeMobileMenu} ref={closeButtonRef} type="button">
|
||||
모바일 메뉴 닫기
|
||||
</button>
|
||||
<NavLink />
|
||||
</nav>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/main.tsx
21
src/main.tsx
@@ -7,18 +7,27 @@ import { queryClient } from "@/shared/api/query-client";
|
||||
import "@/styles/globals.css";
|
||||
import { getRuntimeEnv } from "@/shared/config/env";
|
||||
|
||||
getRuntimeEnv();
|
||||
const runtimeEnv = getRuntimeEnv();
|
||||
|
||||
const root = document.getElementById("root");
|
||||
async function bootstrap(): Promise<void> {
|
||||
if (import.meta.env.DEV && runtimeEnv.apiMode === "mock") {
|
||||
const { startMockWorker } = await import("@/shared/mocks/browser");
|
||||
await startMockWorker();
|
||||
}
|
||||
|
||||
if (!root) {
|
||||
const root = document.getElementById("root");
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Root element #root was not found");
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
|
||||
@@ -114,4 +114,21 @@ describe("API client", () => {
|
||||
// Then
|
||||
await expect(request).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
|
||||
test("surfaces a network failure without a mock response", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const { client } = createTestClient();
|
||||
server.use(http.get(`${apiBaseUrl}/network-error`, () => HttpResponse.error()));
|
||||
|
||||
// When
|
||||
const request = client.request({
|
||||
path: "/network-error",
|
||||
responseSchema: valueSchema,
|
||||
authentication: "none",
|
||||
});
|
||||
|
||||
// Then
|
||||
await expect(request).rejects.toBeInstanceOf(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,10 +7,56 @@ describe("getRuntimeEnv", () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test("returns the configured API base URL", () => {
|
||||
test("defaults API mode to server when VITE_API_MODE is unset", () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com");
|
||||
|
||||
expect(getRuntimeEnv()).toEqual({ apiBaseUrl: "https://api.example.com" });
|
||||
// When
|
||||
const environment = getRuntimeEnv();
|
||||
|
||||
// Then
|
||||
expect(environment).toEqual({ apiBaseUrl: "https://api.example.com", apiMode: "server" });
|
||||
});
|
||||
|
||||
test("accepts explicit mock API mode in development", () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com");
|
||||
vi.stubEnv("VITE_API_MODE", "mock");
|
||||
|
||||
// When
|
||||
const environment = getRuntimeEnv();
|
||||
|
||||
// Then
|
||||
expect(environment.apiMode).toBe("mock");
|
||||
});
|
||||
|
||||
test("blocks startup when VITE_API_MODE is not server or mock", () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com");
|
||||
vi.stubEnv("VITE_API_MODE", "preview");
|
||||
|
||||
// When
|
||||
const getEnvironment = () => getRuntimeEnv();
|
||||
|
||||
// Then
|
||||
expect(getEnvironment).toThrow("VITE_API_MODE must be either server or mock");
|
||||
});
|
||||
|
||||
test("blocks mock API mode outside development before bootstrap", () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com");
|
||||
vi.stubEnv("VITE_API_MODE", "mock");
|
||||
|
||||
// When
|
||||
const getEnvironment = () =>
|
||||
getRuntimeEnv({
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
apiMode: "mock",
|
||||
isDevelopment: false,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(getEnvironment).toThrow("VITE_API_MODE=mock is only available during development");
|
||||
});
|
||||
|
||||
test("blocks startup when VITE_API_BASE_URL is missing", () => {
|
||||
|
||||
@@ -1,23 +1,53 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const apiModeSchema = z.enum(["server", "mock"]);
|
||||
|
||||
export type ApiMode = z.infer<typeof apiModeSchema>;
|
||||
|
||||
export type RuntimeEnv = {
|
||||
apiBaseUrl: string;
|
||||
readonly apiBaseUrl: string;
|
||||
readonly apiMode: ApiMode;
|
||||
};
|
||||
|
||||
export function getRuntimeEnv(): RuntimeEnv {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
|
||||
type RuntimeEnvInput = {
|
||||
readonly apiBaseUrl: string | undefined;
|
||||
readonly apiMode: string | undefined;
|
||||
readonly isDevelopment: boolean;
|
||||
};
|
||||
|
||||
class RuntimeEnvError extends Error {
|
||||
override readonly name = "RuntimeEnvError";
|
||||
}
|
||||
|
||||
export function getRuntimeEnv(
|
||||
input: RuntimeEnvInput = {
|
||||
apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
|
||||
apiMode: import.meta.env.VITE_API_MODE,
|
||||
isDevelopment: import.meta.env.DEV,
|
||||
},
|
||||
): RuntimeEnv {
|
||||
const apiBaseUrl = input.apiBaseUrl;
|
||||
const apiModeResult = apiModeSchema.safeParse(input.apiMode ?? "server");
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
throw new Error("VITE_API_BASE_URL is required");
|
||||
throw new RuntimeEnvError("VITE_API_BASE_URL is required");
|
||||
}
|
||||
if (!apiModeResult.success) {
|
||||
throw new RuntimeEnvError("VITE_API_MODE must be either server or mock");
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(apiBaseUrl);
|
||||
} catch {
|
||||
throw new Error("VITE_API_BASE_URL must be a valid http(s) URL");
|
||||
throw new RuntimeEnvError("VITE_API_BASE_URL must be a valid http(s) URL");
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("VITE_API_BASE_URL must be a valid http(s) URL");
|
||||
throw new RuntimeEnvError("VITE_API_BASE_URL must be a valid http(s) URL");
|
||||
}
|
||||
if (!input.isDevelopment && apiModeResult.data === "mock") {
|
||||
throw new RuntimeEnvError("VITE_API_MODE=mock is only available during development");
|
||||
}
|
||||
|
||||
return { apiBaseUrl };
|
||||
return { apiBaseUrl, apiMode: apiModeResult.data };
|
||||
}
|
||||
|
||||
247
src/shared/mocks/__tests__/auth-handlers.test.ts
Normal file
247
src/shared/mocks/__tests__/auth-handlers.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { z } from "zod";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { login, logout } from "@/features/auth/api/auth-api";
|
||||
import { createApiClient } from "@/shared/api/client";
|
||||
import { createApiResponseSchema } from "@/shared/api/types";
|
||||
import { createMockHandlers, createMockStore } from "@/shared/mocks/handlers";
|
||||
import { server } from "@/shared/test/server";
|
||||
|
||||
const apiBaseUrl = "https://api.example.com";
|
||||
const adminToken = "mock-admin-jwt";
|
||||
|
||||
const aiCharactersPreviewSchema = z.object({
|
||||
totalCount: z.number(),
|
||||
page: z.literal(0),
|
||||
size: z.literal(20),
|
||||
hasNext: z.boolean(),
|
||||
items: z.array(z.unknown()),
|
||||
});
|
||||
|
||||
function createClient(token: string | null = adminToken) {
|
||||
return createApiClient({
|
||||
getToken: () => token,
|
||||
clearSession: vi.fn(),
|
||||
onAuthExpired: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
function useMockHandlers(store = createMockStore()) {
|
||||
server.use(...createMockHandlers(store, apiBaseUrl));
|
||||
return store;
|
||||
}
|
||||
|
||||
describe("mock auth handlers", () => {
|
||||
test("use the production admin login endpoint, request body, headers, and response envelope", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
const session = await login(createClient("caller-token"), { email: "admin@test.com", password: "password" });
|
||||
const invalidBodyResponse = await fetch(`${apiBaseUrl}/admin/member/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "admin@test.com" }),
|
||||
});
|
||||
const authorizationResponse = await fetch(`${apiBaseUrl}/admin/member/login`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${adminToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "admin@test.com", password: "password" }),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(session).toEqual({ token: adminToken, role: "ADMIN" });
|
||||
await expect(invalidBodyResponse.json()).resolves.toEqual({
|
||||
success: false,
|
||||
message: "잘못된 요청입니다.",
|
||||
data: null,
|
||||
errorProperty: null,
|
||||
});
|
||||
expect(invalidBodyResponse.status).toBe(400);
|
||||
expect(authorizationResponse.status).toBe(400);
|
||||
});
|
||||
|
||||
test("does not handle login requests from a different API origin", async () => {
|
||||
// Given
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
const request = fetch("https://wrong-origin.example/admin/member/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "admin@test.com", password: "password" }),
|
||||
});
|
||||
|
||||
// Then
|
||||
await expect(request).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("requires Bearer and no body for production logout", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
const missingBearerResponse = await fetch(`${apiBaseUrl}/member/logout`, { method: "POST" });
|
||||
const bodyResponse = await fetch(`${apiBaseUrl}/member/logout`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(missingBearerResponse.status).toBe(401);
|
||||
expect(bodyResponse.status).toBe(400);
|
||||
});
|
||||
|
||||
test("returns 403 when logout uses a non-ADMIN Bearer token", async () => {
|
||||
// Given
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
const response = await fetch(`${apiBaseUrl}/member/logout`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer mock-member-jwt" },
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(response.status).toBe(403);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: false,
|
||||
message: "접근 권한이 없습니다.",
|
||||
data: null,
|
||||
errorProperty: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 415 when login uses a non-JSON media type", async () => {
|
||||
// Given
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
const response = await fetch(`${apiBaseUrl}/admin/member/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: JSON.stringify({ email: "admin@test.com", password: "password" }),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(response.status).toBe(415);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: false,
|
||||
message: "지원하지 않는 미디어 타입입니다.",
|
||||
data: null,
|
||||
errorProperty: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 401 when logout uses an invalid or already revoked token", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
const invalidTokenResponse = await fetch(`${apiBaseUrl}/member/logout`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer invalid-token" },
|
||||
});
|
||||
await logout(createClient(adminToken));
|
||||
const revokedTokenResponse = await fetch(`${apiBaseUrl}/member/logout`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(invalidTokenResponse.status).toBe(401);
|
||||
expect(revokedTokenResponse.status).toBe(401);
|
||||
});
|
||||
|
||||
test("resets the in-memory auth store to seed when a fresh mock store is created", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
useMockHandlers();
|
||||
await logout(createClient(adminToken));
|
||||
|
||||
// When
|
||||
const staleStoreResponse = await fetch(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
useMockHandlers(createMockStore());
|
||||
const freshStoreResponse = await fetch(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const parsedFreshResponse = createApiResponseSchema(aiCharactersPreviewSchema).parse(await freshStoreResponse.json());
|
||||
|
||||
// Then
|
||||
expect(staleStoreResponse.status).toBe(401);
|
||||
expect(freshStoreResponse.status).toBe(200);
|
||||
expect(parsedFreshResponse).toEqual({
|
||||
success: true,
|
||||
message: null,
|
||||
data: { totalCount: 0, page: 0, size: 20, hasNext: false, items: [] },
|
||||
errorProperty: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("reactivates the admin preview token after logout and login in the same store", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
useMockHandlers();
|
||||
await login(createClient("caller-token"), { email: "admin@test.com", password: "password" });
|
||||
await logout(createClient(adminToken));
|
||||
|
||||
// When
|
||||
await login(createClient("caller-token"), { email: "admin@test.com", password: "password" });
|
||||
const response = await fetch(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("returns the contract 403 fixture for a non-ADMIN Bearer token", async () => {
|
||||
// Given
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
const response = await fetch(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, {
|
||||
headers: { Authorization: "Bearer mock-member-jwt" },
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(response.status).toBe(403);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: false,
|
||||
message: "접근 권한이 없습니다.",
|
||||
data: null,
|
||||
errorProperty: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps mock fixture state out of browser persistent storage and logs", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||
const indexedDbOpen = vi.fn();
|
||||
const consoleLog = vi.spyOn(console, "log");
|
||||
const consoleWarn = vi.spyOn(console, "warn");
|
||||
const consoleError = vi.spyOn(console, "error");
|
||||
vi.stubGlobal("indexedDB", { open: indexedDbOpen });
|
||||
const cookieBefore = document.cookie;
|
||||
useMockHandlers();
|
||||
|
||||
// When
|
||||
await login(createClient("caller-token"), { email: "admin@test.com", password: "password" });
|
||||
await logout(createClient(adminToken));
|
||||
|
||||
// Then
|
||||
expect(localStorage).toHaveLength(0);
|
||||
expect(sessionStorage).toHaveLength(0);
|
||||
expect(indexedDbOpen).not.toHaveBeenCalled();
|
||||
expect(document.cookie).toBe(cookieBefore);
|
||||
expect(consoleLog).not.toHaveBeenCalled();
|
||||
expect(consoleWarn).not.toHaveBeenCalled();
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
66
src/shared/mocks/__tests__/mock-preview-docs.test.ts
Normal file
66
src/shared/mocks/__tests__/mock-preview-docs.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import packageJson from "../../../../package.json";
|
||||
|
||||
const rootDir = process.cwd();
|
||||
|
||||
function projectFile(path: string): string {
|
||||
const absolutePath = join(rootDir, path);
|
||||
|
||||
expect(existsSync(absolutePath)).toBe(true);
|
||||
|
||||
return readFileSync(absolutePath, "utf8");
|
||||
}
|
||||
|
||||
function expectContainsEvery(source: string, tokens: readonly string[]): void {
|
||||
for (const token of tokens) {
|
||||
expect(source).toContain(token);
|
||||
}
|
||||
}
|
||||
|
||||
describe("mock preview documentation", () => {
|
||||
test("documents the actual npm scripts and mode boundary in README", () => {
|
||||
// Given
|
||||
const readme = projectFile("README.md");
|
||||
|
||||
// When
|
||||
const actualScripts = [
|
||||
`npm run dev (${packageJson.scripts.dev})`,
|
||||
`npm run dev:mock (${packageJson.scripts["dev:mock"]})`,
|
||||
`npm run e2e (${packageJson.scripts.e2e})`,
|
||||
`npm run e2e:mock (${packageJson.scripts["e2e:mock"]})`,
|
||||
];
|
||||
|
||||
// Then
|
||||
expectContainsEvery(readme, actualScripts);
|
||||
expectContainsEvery(readme, ["server mode", "mock mode", "VITE_API_MODE=server", "VITE_API_MODE=mock"]);
|
||||
expectContainsEvery(readme, ["mock data reset", "production", "no-auto-fallback"]);
|
||||
});
|
||||
|
||||
test("keeps agent environment and script guides synced with mock preview ownership rules", () => {
|
||||
// Given
|
||||
const environment = projectFile("docs/agent-guide/environment.md");
|
||||
const scripts = projectFile("docs/agent-guide/scripts.md");
|
||||
|
||||
// When, Then
|
||||
expectContainsEvery(environment, ["VITE_API_MODE=server | mock", "npm run dev", "npm run dev:mock"]);
|
||||
expectContainsEvery(environment, ["mock data reset", "production", "no-auto-fallback"]);
|
||||
expectContainsEvery(scripts, ["npm run dev", "npm run dev:mock", "npm run e2e", "npm run e2e:mock"]);
|
||||
expectContainsEvery(scripts, ["handler", "fixture", "mock E2E"]);
|
||||
});
|
||||
|
||||
test("keeps the Phase 2 plan files and progress synced with the implementation", () => {
|
||||
// Given
|
||||
const plan = projectFile("docs/20260725_AI캐릭터관리자웹/plan-task.md");
|
||||
|
||||
// When, Then
|
||||
expectContainsEvery(plan, [
|
||||
"Modify: `src/app/App.tsx`, `src/main.tsx`, `vite.config.ts`, `playwright.config.ts`",
|
||||
"Create: `src/shared/mocks/{browser,handlers,contract}.ts`",
|
||||
"Create: `src/shared/mocks/__tests__/{mode-boundary,auth-handlers,mock-preview-docs,production-graph}.test.ts`",
|
||||
"Create: `tests/e2e/{mock-mode-boundary,mock-preview-shell,server-mode-boundary}.spec.ts`",
|
||||
"### Phase 2 구현·Gate 완료 기록 — 2026-07-27",
|
||||
]);
|
||||
});
|
||||
});
|
||||
58
src/shared/mocks/__tests__/mode-boundary.test.ts
Normal file
58
src/shared/mocks/__tests__/mode-boundary.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import packageJson from "../../../../package.json";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const playwrightConfig = readFileSync("playwright.config.ts", "utf8");
|
||||
|
||||
describe("mock API mode scripts", () => {
|
||||
test("keeps the default development server in server mode", () => {
|
||||
// Given
|
||||
const developmentScript = packageJson.scripts.dev;
|
||||
|
||||
// When
|
||||
const startsServerMode = developmentScript === "VITE_API_MODE=server vite --host 127.0.0.1 --port 8888 --strictPort";
|
||||
|
||||
// Then
|
||||
expect(startsServerMode).toBe(true);
|
||||
});
|
||||
|
||||
test("provides explicit development and Playwright mock mode commands", () => {
|
||||
// Given
|
||||
const expectedDevelopmentMockScript = "VITE_API_MODE=mock vite --host 127.0.0.1 --port 8889 --strictPort";
|
||||
const expectedE2eServerScript = "VITE_API_MODE=server playwright test";
|
||||
const expectedE2eMockScript = "VITE_API_MODE=mock playwright test";
|
||||
|
||||
// When
|
||||
const developmentMockScript = packageJson.scripts["dev:mock"];
|
||||
const e2eServerScript = packageJson.scripts.e2e;
|
||||
const e2eMockScript = packageJson.scripts["e2e:mock"];
|
||||
|
||||
// Then
|
||||
expect(developmentMockScript).toBe(expectedDevelopmentMockScript);
|
||||
expect(e2eServerScript).toBe(expectedE2eServerScript);
|
||||
expect(e2eMockScript).toBe(expectedE2eMockScript);
|
||||
});
|
||||
|
||||
test("keeps mode-specific E2E spec allowlists in Playwright config", () => {
|
||||
// Given
|
||||
const expectedServerSpecs = [
|
||||
"**/server-mode-boundary.spec.ts",
|
||||
"**/smoke.spec.ts",
|
||||
"**/auth.spec.ts",
|
||||
"**/accessibility-shell.spec.ts",
|
||||
];
|
||||
const expectedMockSpecs = ["**/mock-preview-shell.spec.ts", "**/mock-mode-boundary.spec.ts"];
|
||||
|
||||
// When, Then
|
||||
expectContainsEvery(playwrightConfig, expectedServerSpecs);
|
||||
expectContainsEvery(playwrightConfig, expectedMockSpecs);
|
||||
expect(playwrightConfig).toContain("testMatch");
|
||||
});
|
||||
});
|
||||
|
||||
function expectContainsEvery(source: string, tokens: readonly string[]): void {
|
||||
for (const token of tokens) {
|
||||
expect(source).toContain(token);
|
||||
}
|
||||
}
|
||||
46
src/shared/mocks/__tests__/production-graph.test.ts
Normal file
46
src/shared/mocks/__tests__/production-graph.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { build } from "vite";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
describe("production mock graph", () => {
|
||||
test("excludes the browser mock module from the production bundle", async () => {
|
||||
// Given
|
||||
const outDir = mkdtempSync(join(tmpdir(), "ai-character-admin-prod-"));
|
||||
const previousNodeEnv = process.env.NODE_ENV;
|
||||
|
||||
try {
|
||||
process.env.NODE_ENV = "production";
|
||||
|
||||
// When
|
||||
await build({
|
||||
build: { emptyOutDir: true, outDir },
|
||||
configFile: "vite.config.ts",
|
||||
logLevel: "silent",
|
||||
mode: "production",
|
||||
});
|
||||
const outputFiles = collectFiles(outDir);
|
||||
const output = outputFiles
|
||||
.filter((filePath) => filePath.endsWith(".js"))
|
||||
.map((filePath) => readFileSync(filePath, "utf8"))
|
||||
.join("\n");
|
||||
|
||||
// Then
|
||||
expect(outputFiles.some((filePath) => filePath.endsWith("mockServiceWorker.js"))).toBe(false);
|
||||
expect(output).not.toContain("mockServiceWorker.js");
|
||||
expect(output).not.toContain("startMockWorker");
|
||||
} finally {
|
||||
process.env.NODE_ENV = previousNodeEnv;
|
||||
rmSync(outDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function collectFiles(directory: string): readonly string[] {
|
||||
return readdirSync(directory).flatMap((entry) => {
|
||||
const path = join(directory, entry);
|
||||
|
||||
return statSync(path).isDirectory() ? collectFiles(path) : [path];
|
||||
});
|
||||
}
|
||||
29
src/shared/mocks/browser.test.ts
Normal file
29
src/shared/mocks/browser.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
const { setupWorkerMock, workerStart } = vi.hoisted(() => {
|
||||
const workerStart = vi.fn();
|
||||
const setupWorkerMock = vi.fn(() => ({ start: workerStart }));
|
||||
|
||||
return { setupWorkerMock, workerStart };
|
||||
});
|
||||
|
||||
vi.mock("msw/browser", () => ({
|
||||
setupWorker: setupWorkerMock,
|
||||
}));
|
||||
|
||||
import { startMockWorker } from "./browser";
|
||||
|
||||
describe("startMockWorker", () => {
|
||||
test("starts browser MSW with an error policy for unhandled requests", async () => {
|
||||
// Given
|
||||
vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com");
|
||||
const startOptions = { onUnhandledRequest: "error" };
|
||||
|
||||
// When
|
||||
await startMockWorker();
|
||||
|
||||
// Then
|
||||
expect(setupWorkerMock).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything());
|
||||
expect(workerStart).toHaveBeenCalledWith(startOptions);
|
||||
});
|
||||
});
|
||||
11
src/shared/mocks/browser.ts
Normal file
11
src/shared/mocks/browser.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { setupWorker } from "msw/browser";
|
||||
|
||||
import { getRuntimeEnv } from "@/shared/config/env";
|
||||
import { createMockHandlers, createMockStore } from "@/shared/mocks/handlers";
|
||||
|
||||
let worker: ReturnType<typeof setupWorker> | null = null;
|
||||
|
||||
export async function startMockWorker(): Promise<void> {
|
||||
worker ??= setupWorker(...createMockHandlers(createMockStore(), getRuntimeEnv().apiBaseUrl));
|
||||
await worker.start({ onUnhandledRequest: "error" });
|
||||
}
|
||||
9
src/shared/mocks/contract.ts
Normal file
9
src/shared/mocks/contract.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ApiErrorResponse, ApiSuccessResponse } from "@/shared/api/types";
|
||||
|
||||
export function ok<Data>(data: Data): ApiSuccessResponse<Data> {
|
||||
return { success: true, message: null, data, errorProperty: null };
|
||||
}
|
||||
|
||||
export function error(message: string): ApiErrorResponse {
|
||||
return { success: false, message, data: null, errorProperty: null };
|
||||
}
|
||||
152
src/shared/mocks/handlers.ts
Normal file
152
src/shared/mocks/handlers.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import type { RequestHandler } from "msw";
|
||||
import { z } from "zod";
|
||||
|
||||
import { error, ok } from "@/shared/mocks/contract";
|
||||
|
||||
const adminToken = "mock-admin-jwt";
|
||||
const memberToken = "mock-member-jwt";
|
||||
const invalidRequestMessage = "잘못된 요청입니다.";
|
||||
const missingCredentialMessage = "인증 정보가 없습니다.";
|
||||
const accessDeniedMessage = "접근 권한이 없습니다.";
|
||||
const unsupportedMediaTypeMessage = "지원하지 않는 미디어 타입입니다.";
|
||||
|
||||
const loginRequestSchema = z.strictObject({
|
||||
email: z.email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
const aiCharactersPreview = {
|
||||
totalCount: 0,
|
||||
page: 0,
|
||||
size: 20,
|
||||
hasNext: false,
|
||||
items: [],
|
||||
} as const;
|
||||
|
||||
class MockStore {
|
||||
readonly #revokedTokens = new Set<string>();
|
||||
|
||||
activate(token: string): void {
|
||||
this.#revokedTokens.delete(token);
|
||||
}
|
||||
|
||||
revoke(token: string): void {
|
||||
this.#revokedTokens.add(token);
|
||||
}
|
||||
|
||||
getTokenAccess(token: string): "admin" | "denied" | "unauthorized" {
|
||||
if (token === adminToken && !this.#revokedTokens.has(token)) {
|
||||
return "admin";
|
||||
}
|
||||
if (token === memberToken) {
|
||||
return "denied";
|
||||
}
|
||||
|
||||
return "unauthorized";
|
||||
}
|
||||
}
|
||||
|
||||
export type MockFixtureStore = MockStore;
|
||||
|
||||
export function createMockStore(): MockFixtureStore {
|
||||
return new MockStore();
|
||||
}
|
||||
|
||||
function getBearerToken(request: Request): string | null {
|
||||
const authorization = request.headers.get("Authorization");
|
||||
const prefix = "Bearer ";
|
||||
|
||||
return authorization?.startsWith(prefix) ? authorization.slice(prefix.length) : null;
|
||||
}
|
||||
|
||||
function endpointUrl(apiBaseUrl: string, path: string): string {
|
||||
return new URL(path, apiBaseUrl).toString();
|
||||
}
|
||||
|
||||
function accessResponse(store: MockFixtureStore, request: Request): Response | null {
|
||||
const token = getBearerToken(request);
|
||||
|
||||
if (token === null) {
|
||||
return HttpResponse.json(error(missingCredentialMessage), { status: 401 });
|
||||
}
|
||||
|
||||
const tokenAccess = store.getTokenAccess(token);
|
||||
|
||||
if (tokenAccess === "admin") {
|
||||
return null;
|
||||
}
|
||||
if (tokenAccess === "denied") {
|
||||
return HttpResponse.json(error(accessDeniedMessage), { status: 403 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(error(missingCredentialMessage), { status: 401 });
|
||||
}
|
||||
|
||||
async function parseLoginRequest(request: Request): Promise<boolean> {
|
||||
if (request.headers.has("Authorization")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return loginRequestSchema.safeParse(await request.json()).success;
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof SyntaxError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw parseError;
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockHandlers(
|
||||
store: MockFixtureStore,
|
||||
apiBaseUrl: string,
|
||||
): readonly RequestHandler[] {
|
||||
return [
|
||||
http.post(endpointUrl(apiBaseUrl, "/admin/member/login"), async ({ request }) => {
|
||||
if (request.headers.get("Content-Type")?.toLowerCase().split(";")[0]?.trim() !== "application/json") {
|
||||
return HttpResponse.json(error(unsupportedMediaTypeMessage), { status: 415 });
|
||||
}
|
||||
if (!(await parseLoginRequest(request))) {
|
||||
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
store.activate(adminToken);
|
||||
return HttpResponse.json(ok({ token: adminToken, role: "ADMIN" }));
|
||||
}),
|
||||
http.post(endpointUrl(apiBaseUrl, "/member/logout"), async ({ request }) => {
|
||||
const token = getBearerToken(request);
|
||||
|
||||
if (token === null) {
|
||||
return HttpResponse.json(error(missingCredentialMessage), { status: 401 });
|
||||
}
|
||||
const tokenAccess = store.getTokenAccess(token);
|
||||
if (tokenAccess === "denied") {
|
||||
return HttpResponse.json(error(accessDeniedMessage), { status: 403 });
|
||||
}
|
||||
if (tokenAccess !== "admin") {
|
||||
return HttpResponse.json(error(missingCredentialMessage), { status: 401 });
|
||||
}
|
||||
if ((await request.text()) !== "") {
|
||||
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
store.revoke(token);
|
||||
return HttpResponse.json(ok({}));
|
||||
}),
|
||||
http.get(endpointUrl(apiBaseUrl, "/api/v2/admin/ai-characters"), ({ request }) => {
|
||||
const deniedResponse = accessResponse(store, request);
|
||||
if (deniedResponse !== null) {
|
||||
return deniedResponse;
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.get("page") !== "0" || url.searchParams.get("size") !== "20") {
|
||||
return HttpResponse.json(error(invalidRequestMessage), { status: 400 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(ok(aiCharactersPreview));
|
||||
}),
|
||||
];
|
||||
}
|
||||
22
src/shared/ui/__tests__/mock-mode-banner.test.tsx
Normal file
22
src/shared/ui/__tests__/mock-mode-banner.test.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { MockModeBanner } from "@/shared/ui/mock-mode-banner";
|
||||
|
||||
describe("MockModeBanner", () => {
|
||||
test("shows an accessible persistent banner in mock mode", () => {
|
||||
// Given, When
|
||||
render(<MockModeBanner apiMode="mock" />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview");
|
||||
});
|
||||
|
||||
test("does not render in server mode", () => {
|
||||
// Given, When
|
||||
render(<MockModeBanner apiMode="server" />);
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
18
src/shared/ui/mock-mode-banner.tsx
Normal file
18
src/shared/ui/mock-mode-banner.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ApiMode } from "@/shared/config/env";
|
||||
|
||||
export function MockModeBanner({ apiMode }: { readonly apiMode: ApiMode }) {
|
||||
if (apiMode === "server") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Mock Preview"
|
||||
className="sticky top-0 z-sticky border-b border-border bg-warning-surface px-4 py-2 text-sm font-semibold text-warning"
|
||||
role="status"
|
||||
>
|
||||
<span className="mr-2">Mock Preview</span>
|
||||
<span>개발용 fixture로 표시 중입니다. 실제 서버 연동 완료가 아닙니다.</span>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
20
tests/e2e/mock-mode-boundary.spec.ts
Normal file
20
tests/e2e/mock-mode-boundary.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("registers a browser MSW worker in explicit mock mode", async ({ page }) => {
|
||||
// Given
|
||||
const expectedWorkerRegistration = true;
|
||||
|
||||
// When
|
||||
await page.goto("/");
|
||||
|
||||
// Then
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(async () => {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
|
||||
return registration.active?.scriptURL.endsWith("/mockServiceWorker.js") ?? false;
|
||||
}),
|
||||
)
|
||||
.toBe(expectedWorkerRegistration);
|
||||
});
|
||||
170
tests/e2e/mock-preview-shell.spec.ts
Normal file
170
tests/e2e/mock-preview-shell.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
async function loginThroughMockMode(page: Page): Promise<void> {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("이메일").fill("admin@test.com");
|
||||
await page.getByLabel("비밀번호").fill("password");
|
||||
await page.getByRole("button", { name: "로그인" }).click();
|
||||
await expect(page).toHaveURL(/\/ai-characters$/);
|
||||
}
|
||||
|
||||
async function zoomTo200Percent(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.zoom = "2";
|
||||
});
|
||||
}
|
||||
|
||||
async function expectNoHorizontalOverflow(page: Page): Promise<void> {
|
||||
const hasHorizontalOverflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
);
|
||||
|
||||
expect(hasHorizontalOverflow).toBe(false);
|
||||
}
|
||||
|
||||
async function expectBannerDoesNotOverlap(banner: Locator, control: Locator): Promise<void> {
|
||||
const bannerBox = await banner.boundingBox();
|
||||
const controlBox = await control.boundingBox();
|
||||
|
||||
expect(bannerBox).not.toBeNull();
|
||||
expect(controlBox).not.toBeNull();
|
||||
|
||||
if (bannerBox === null || controlBox === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(bannerBox.y + bannerBox.height).toBeLessThanOrEqual(controlBox.y);
|
||||
}
|
||||
|
||||
test("logs in through mock mode and opens the protected shell without backend fallback", async ({ page }) => {
|
||||
// Given
|
||||
const interceptedApiContractRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.hostname !== "127.0.0.1" && url.pathname !== "/mockServiceWorker.js") {
|
||||
interceptedApiContractRequests.push(`${request.method()} ${url.pathname}${url.search}`);
|
||||
}
|
||||
});
|
||||
|
||||
// When
|
||||
await loginThroughMockMode(page);
|
||||
|
||||
// Then
|
||||
await expect(page.getByRole("status", { name: /Mock Preview/ })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible();
|
||||
expect(interceptedApiContractRequests).toEqual([
|
||||
"POST /admin/member/login",
|
||||
"GET /api/v2/admin/ai-characters?page=0&size=20",
|
||||
]);
|
||||
});
|
||||
|
||||
test("logs out and logs in again with a fresh mock preview session", async ({ page }) => {
|
||||
// Given
|
||||
await loginThroughMockMode(page);
|
||||
await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible();
|
||||
|
||||
// When
|
||||
await page.getByRole("button", { name: "로그아웃" }).click();
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
await page.getByLabel("이메일").fill("admin@test.com");
|
||||
await page.getByLabel("비밀번호").fill("password");
|
||||
await page.getByRole("button", { name: "로그인" }).click();
|
||||
|
||||
// Then
|
||||
await expect(page).toHaveURL(/\/ai-characters$/);
|
||||
await expect(page.getByRole("heading", { name: "AI 캐릭터", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps the mock login banner and core controls usable at 320px and 200 percent zoom", async ({ page }) => {
|
||||
// Given
|
||||
await page.setViewportSize({ width: 320, height: 640 });
|
||||
await page.goto("/login");
|
||||
|
||||
// When
|
||||
await zoomTo200Percent(page);
|
||||
|
||||
// Then
|
||||
const banner = page.getByRole("status", { name: /Mock Preview/ });
|
||||
const email = page.getByLabel("이메일");
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(email).toBeVisible();
|
||||
await expect(page.getByLabel("비밀번호")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "로그인" })).toBeVisible();
|
||||
await expectBannerDoesNotOverlap(banner, email);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test("keeps the mock protected banner and shell controls usable at 320px and 200 percent zoom", async ({ page }) => {
|
||||
// Given
|
||||
await page.setViewportSize({ width: 320, height: 640 });
|
||||
await loginThroughMockMode(page);
|
||||
|
||||
// When
|
||||
await zoomTo200Percent(page);
|
||||
|
||||
// Then
|
||||
const banner = page.getByRole("status", { name: /Mock Preview/ });
|
||||
const menuButton = page.getByRole("button", { name: "모바일 메뉴 열기" });
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(menuButton).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "로그아웃" })).toBeVisible();
|
||||
await expectBannerDoesNotOverlap(banner, menuButton);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test("keeps the mock banner in the mobile menu background and clears inert state at desktop widths", async ({ page }) => {
|
||||
// Given
|
||||
await loginThroughMockMode(page);
|
||||
|
||||
for (const desktopWidth of [1024, 1200]) {
|
||||
await page.setViewportSize({ width: 320, height: 640 });
|
||||
await page.getByRole("button", { name: "모바일 메뉴 열기" }).click();
|
||||
|
||||
const openState = await page.evaluate(() => {
|
||||
const banner = document.querySelector("[aria-label='Mock Preview']");
|
||||
const main = document.querySelector("main[aria-label='AI 캐릭터 관리']");
|
||||
|
||||
return {
|
||||
bannerInert: banner?.closest("[inert]") !== null,
|
||||
bannerHidden: banner?.closest("[aria-hidden='true']") !== null,
|
||||
mainInert: main?.closest("[inert]") !== null,
|
||||
};
|
||||
});
|
||||
expect(openState).toEqual({ bannerHidden: true, bannerInert: true, mainInert: true });
|
||||
|
||||
// When
|
||||
await page.setViewportSize({ width: desktopWidth, height: 800 });
|
||||
|
||||
// Then
|
||||
await expect(page.getByRole("navigation", { name: "모바일 주 메뉴" })).toBeHidden();
|
||||
await expect(page.getByRole("navigation", { name: "데스크톱 주 메뉴" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "로그아웃" })).toBeVisible();
|
||||
const desktopState = await page.evaluate(() => {
|
||||
const banner = document.querySelector("[aria-label='Mock Preview']");
|
||||
const main = document.querySelector("main[aria-label='AI 캐릭터 관리']");
|
||||
|
||||
return {
|
||||
bannerInert: banner?.closest("[inert]") !== null,
|
||||
bannerHidden: banner?.closest("[aria-hidden='true']") !== null,
|
||||
mainInert: main?.closest("[inert]") !== null,
|
||||
};
|
||||
});
|
||||
expect(desktopState).toEqual({ bannerHidden: false, bannerInert: false, mainInert: false });
|
||||
}
|
||||
});
|
||||
|
||||
test("has no critical or serious axe violations in mock preview mode", async ({ page }) => {
|
||||
// Given
|
||||
await loginThroughMockMode(page);
|
||||
|
||||
// When
|
||||
const results = await new AxeBuilder({ page }).analyze();
|
||||
const blockingViolations = results.violations.filter(
|
||||
(violation) => violation.impact === "critical" || violation.impact === "serious",
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(blockingViolations).toEqual([]);
|
||||
});
|
||||
135
tests/e2e/server-mode-boundary.spec.ts
Normal file
135
tests/e2e/server-mode-boundary.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
const apiBaseUrl = "https://test-character-admin.sodalive.net";
|
||||
const authSession = JSON.stringify({ token: "admin-token", role: "ADMIN" });
|
||||
|
||||
async function hasMockWorker(page: Page): Promise<boolean> {
|
||||
return page.evaluate(async () => {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
|
||||
return registrations.some((registration) =>
|
||||
registration.active?.scriptURL.endsWith("/mockServiceWorker.js"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("keeps server mode requests outside browser MSW", async ({ page }) => {
|
||||
// Given
|
||||
await page.goto("/");
|
||||
|
||||
// When
|
||||
const registeredMockWorker = await hasMockWorker(page);
|
||||
|
||||
// Then
|
||||
expect(registeredMockWorker).toBe(false);
|
||||
});
|
||||
|
||||
test("retries a server 404 without falling back to browser MSW", async ({ page }) => {
|
||||
// Given
|
||||
await page.addInitScript((session) => {
|
||||
sessionStorage.setItem("ai-character-admin-auth-session", session);
|
||||
}, authSession);
|
||||
const protectedRouteUrl = `${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`;
|
||||
let retryRequestCount = 0;
|
||||
let releaseRetryResponse: () => void = () => undefined;
|
||||
const retryResponseGate = new Promise<void>((resolve) => {
|
||||
releaseRetryResponse = resolve;
|
||||
});
|
||||
await page.route(protectedRouteUrl, async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
json: { success: false, message: "없습니다.", data: null, errorProperty: null },
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
await page.goto("/ai-characters");
|
||||
const retryButton = page.getByRole("button", { name: "보호 route 다시 시도" });
|
||||
await expect(retryButton).toBeVisible();
|
||||
await expect(page.getByRole("alert")).toContainText("없습니다.");
|
||||
await expect(page.getByRole("main", { name: "AI 캐릭터 관리" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "로그아웃" })).toHaveCount(0);
|
||||
await page.unroute(protectedRouteUrl);
|
||||
await page.route(protectedRouteUrl, async (route) => {
|
||||
retryRequestCount += 1;
|
||||
await retryResponseGate;
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
json: { success: true, message: null, data: null, errorProperty: null },
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
// When
|
||||
await retryButton.click();
|
||||
|
||||
// Then
|
||||
await expect.poll(() => retryRequestCount).toBe(1);
|
||||
await expect(page.getByRole("status")).toContainText("보호 route 확인 중");
|
||||
await expect(page.getByRole("main", { name: "AI 캐릭터 관리" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "로그아웃" })).toHaveCount(0);
|
||||
releaseRetryResponse();
|
||||
await expect(page.getByRole("main", { name: "AI 캐릭터 관리" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "로그아웃" })).toBeVisible();
|
||||
expect(await hasMockWorker(page)).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps a network error from falling back to browser MSW", async ({ page }) => {
|
||||
// Given
|
||||
await page.addInitScript((session) => {
|
||||
sessionStorage.setItem("ai-character-admin-auth-session", session);
|
||||
}, authSession);
|
||||
await page.route(`${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`, async (route) => {
|
||||
await route.abort("failed");
|
||||
});
|
||||
|
||||
// When
|
||||
await page.goto("/ai-characters");
|
||||
|
||||
// Then
|
||||
await expect(page.getByRole("alert")).toContainText("보호 route 확인에 실패했습니다.");
|
||||
await expect(page.getByRole("main", { name: "AI 캐릭터 관리" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "로그아웃" })).toHaveCount(0);
|
||||
expect(await hasMockWorker(page)).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps retry available after a server network retry fails", async ({ page }) => {
|
||||
// Given
|
||||
await page.addInitScript((session) => {
|
||||
sessionStorage.setItem("ai-character-admin-auth-session", session);
|
||||
}, authSession);
|
||||
const protectedRouteUrl = `${apiBaseUrl}/api/v2/admin/ai-characters?page=0&size=20`;
|
||||
let retryRequestCount = 0;
|
||||
let releaseRetryFailure: () => void = () => undefined;
|
||||
const retryFailureGate = new Promise<void>((resolve) => {
|
||||
releaseRetryFailure = resolve;
|
||||
});
|
||||
await page.route(protectedRouteUrl, async (route) => {
|
||||
await route.abort("failed");
|
||||
});
|
||||
await page.goto("/ai-characters");
|
||||
const retryButton = page.getByRole("button", { name: "보호 route 다시 시도" });
|
||||
await expect(retryButton).toBeVisible();
|
||||
await expect(page.getByRole("alert")).toContainText("보호 route 확인에 실패했습니다.");
|
||||
await page.unroute(protectedRouteUrl);
|
||||
await page.route(protectedRouteUrl, async (route) => {
|
||||
retryRequestCount += 1;
|
||||
await retryFailureGate;
|
||||
await route.abort("failed");
|
||||
});
|
||||
|
||||
// When
|
||||
await retryButton.click();
|
||||
|
||||
// Then
|
||||
await expect.poll(() => retryRequestCount).toBe(1);
|
||||
await expect(page.getByRole("status")).toContainText("보호 route 확인 중");
|
||||
await expect(page.getByRole("main", { name: "AI 캐릭터 관리" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "로그아웃" })).toHaveCount(0);
|
||||
releaseRetryFailure();
|
||||
await expect(page.getByRole("button", { name: "보호 route 다시 시도" })).toBeVisible();
|
||||
await expect(page.getByRole("alert")).toContainText("보호 route 확인에 실패했습니다.");
|
||||
await expect(page.getByRole("main", { name: "AI 캐릭터 관리" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "로그아웃" })).toHaveCount(0);
|
||||
expect(await hasMockWorker(page)).toBe(false);
|
||||
});
|
||||
@@ -1,9 +1,18 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { loadEnv } from "vite";
|
||||
import { configDefaults, defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => {
|
||||
const environment = loadEnv(mode, process.cwd(), "VITE_");
|
||||
|
||||
if (mode === "production" && environment.VITE_API_MODE === "mock") {
|
||||
throw new Error("VITE_API_MODE=mock is only available during development");
|
||||
}
|
||||
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
publicDir: mode === "production" ? false : "public",
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 8888,
|
||||
@@ -20,4 +29,5 @@ export default defineConfig({
|
||||
globals: true,
|
||||
exclude: [...configDefaults.exclude, "tests/e2e/**"],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user