diff --git a/.env.example b/.env.example index 37ff389..ef3ee24 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,2 @@ VITE_API_BASE_URL=https://test-character-admin.sodalive.net +VITE_API_MODE=server diff --git a/README.md b/README.md index c79acc6..4e7769d 100644 --- a/README.md +++ b/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 완료 증거가 아닙니다. diff --git a/docs/20260725_AI캐릭터관리자웹/plan-task.md b/docs/20260725_AI캐릭터관리자웹/plan-task.md index c6b9db8..13aa963 100644 --- a/docs/20260725_AI캐릭터관리자웹/plan-task.md +++ b/docs/20260725_AI캐릭터관리자웹/plan-task.md @@ -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`와 실제 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`와 실제 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 완료로 간주하지 않는다. diff --git a/docs/20260725_AI캐릭터관리자웹/review/review-phase-0-1.md b/docs/20260725_AI캐릭터관리자웹/reviews/review-phase-0-1.md similarity index 99% rename from docs/20260725_AI캐릭터관리자웹/review/review-phase-0-1.md rename to docs/20260725_AI캐릭터관리자웹/reviews/review-phase-0-1.md index 270cd58..ae2b695 100644 --- a/docs/20260725_AI캐릭터관리자웹/review/review-phase-0-1.md +++ b/docs/20260725_AI캐릭터관리자웹/reviews/review-phase-0-1.md @@ -15,7 +15,7 @@ 리뷰 시작 시 워킹 트리는 사용자 작업을 포함해 이미 변경 상태였다. 기존 변경은 리뷰 기준선으로 보존했으며, 코드와 기존 문서는 수정하지 않았다. 이 파일만 신규 리뷰 산출물로 작성했다. -리뷰 파일 위치는 같은 PRD 디렉터리를 기본값으로 삼는 일반 규칙보다 구체적인 사용자 지시를 적용해 docs/20260725_AI캐릭터관리자웹/review 아래로 정했다. +리뷰 파일은 현재 저장 규칙에 따라 `docs/20260725_AI캐릭터관리자웹/reviews/` 아래에 둔다. ## 2. 리뷰 목적과 범위 diff --git a/docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md b/docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md new file mode 100644 index 0000000..9b0befd --- /dev/null +++ b/docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md @@ -0,0 +1,1544 @@ +# AI 캐릭터 관리자 웹 Phase 2 코드 리뷰·QA 리포트 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +| --- | --- | +| 리뷰 대상 | `plan-task.md` Phase 2 / `P2-T1`~`P2-T3`, `P2-GATE`, `P2-R1`~`P2-R16`과 현재 working tree 구현 | +| 기준 commit 또는 working tree | `b4841ff579a271771dc787b7c91b7ca2d24f5e85` 기준, 5차 재검증 시 tracked diff 37개 + untracked 1개 = working tree 38개 변경 항목 | +| 리뷰 일자 | 2026-07-27 | +| 리뷰어 | Codex | +| 기준 문서 | `docs/20260725_AI캐릭터관리자웹/prd.md`, `api-contract.md`, `plan-task.md` | +| 리뷰 기준 | `docs/agent-guide/review.md`, `docs/sample/sample-review.md` | +| 리뷰 상태 | 7차 독립 재검증 후 `P2-R16` 수정 완료 | +| 최종 결론 | Phase 2 구현·review·plan 정합성 충족, Phase 3 진행 가능 | + +리뷰 시작 시 Phase 2 구현은 모두 index에 staged된 상태였다. 기존 staged 변경은 수정하지 않았고, 이 리뷰에서 별도 review 문서를 만들고 확정 항목을 `plan-task.md`의 신규 회귀 Task로만 전환했다. + +2026-07-27 2차 재검증에서는 `P2-R1`~`P2-R3` 수정이 반영된 현재 working tree를 독립 검토했다. 애플리케이션 코드·test·설정·`plan-task.md`는 변경하지 않았고, 아래 `REV-P2-006`~`REV-P2-009`와 실제 검증 결과만 이 문서에 누적했다. + +2026-07-27 3차 재검증에서는 `P2-R4`~`P2-R7` 수정 뒤의 문서 추적성, mobile menu의 mock banner·responsive inert 경계와 보호 route 오류 복구 UI를 다시 확인했다. `REV-P2-010`~`REV-P2-013`을 확정하고 `plan-task.md`에 `P2-R8`~`P2-R10` 회귀 Task로 전환했으며, 애플리케이션 코드·test·설정은 변경하지 않았다. + +2026-07-27 `P2-R10`에서는 보호 route 오류 page에 fail-closed 수동 retry를 추가하고 App unit, server boundary E2E, P2 focused Gate, 전체 unit·정적 검사·build와 실제 브라우저 시각 QA를 완료했다. `REV-P2-013`을 수정 완료로 판정해 3차 재검증의 남은 항목을 닫았다. + +2026-07-27 4차 재검증에서는 `P2-R8`~`P2-R10` 반영 뒤 working tree 전체 집계, 보호 route pending UI와 npm script의 focused E2E 필터를 독립 확인했다. `REV-P2-014`~`REV-P2-016`을 확정하고 `plan-task.md`에 `P2-R11`~`P2-R13` 회귀 Task로 전환했으며, 애플리케이션 코드·test·설정은 변경하지 않았다. + +2026-07-27 `P2-R11`~`P2-R13`에서는 untracked 포함 working tree 추적성, 보호 route pending loading status와 mode별 focused E2E filter를 수정했다. `REV-P2-014`~`REV-P2-016`을 수정 완료로 판정했다. + +2026-07-27 5차 재검증에서는 `P2-R11`~`P2-R13` 구현과 최신 실행 결과를 다시 대조했다. 제품 동작과 mode 경계 회귀는 통과했지만 `P2-R12`의 test 구조·개수 기록이 실제와 다른 `REV-P2-017` Low 1건을 확정하고 `plan-task.md`의 `P2-R14` 회귀 Task로 전환했다. 애플리케이션 코드·test·설정은 변경하지 않았다. + +2026-07-27 `P2-R14`에서는 `P2-R12` 완료 증거를 실제 App test 구조인 최초 pending 전용 test 1건 + 기존 404 retry test 보강 + 27 tests로 정정했다. 문서 검색·diff 검증과 App focused test를 완료해 `REV-P2-017`을 수정 완료로 판정했다. + +2026-07-27 6차 재검증에서는 `P2-R14` 반영 뒤 구현·test·production·mode별 E2E와 review의 현재 상태를 다시 대조했다. 제품 동작과 mode 경계는 통과했지만 review §7이 이미 수정 완료된 `P2-R8`~`P2-R13`을 여전히 “아직 수정하지 않았다”고 표시하는 `REV-P2-018` Low 1건을 확정하고 `plan-task.md`의 `P2-R15` 회귀 Task로 전환했다. 이후 `P2-R15`에서 review §7의 현재 상태 문구를 수정 완료 상태로 정정했다. 애플리케이션 코드·test·설정은 변경하지 않았다. + +2026-07-27 7차 재검증에서는 `P2-R15` 반영 뒤 Phase 2 상단 현재 상태와 하단 최신 Progress를 Task 본문·review 종료 판정과 대조했다. `P2-R15`는 수정 완료지만 plan의 현재 상태는 최초 추가 당시 설명에 머물고, 최신 Progress는 여전히 `P2-R15` 수정 필요 상태로 끝나는 `REV-P2-019` Low 1건을 확정해 `P2-R16`으로 전환했다. 이후 `P2-R16`에서 Phase 2 현재 상태와 최신 Progress를 완료 상태로 정렬하고 전체 Gate를 재검증했다. 애플리케이션 코드·test·설정은 변경하지 않았다. + +## 2. 리뷰 목적과 범위 + +### 2.1 목적 + +- Phase 2 완료 체크와 Gate 기록이 PRD `MOCK-001~009`, API Contract §1·§3, 실제 코드·test 결과와 일치하는지 확인한다. +- mock/server 명시적 경계, production 차단, no-auto-fallback, auth fixture와 지속 안내가 실제 브라우저에서도 유지되는지 확인한다. +- 자동화가 통과하더라도 보호 route, exact API origin, JWT 오류 status와 문서 추적성에 남은 공백을 독립적으로 판정한다. + +### 2.2 포함 범위 + +- 코드·설정: `.env.example`, `package.json`, `playwright.config.ts`, `vite.config.ts`, `src/main.tsx`, `src/app`, `src/shared/config`, `src/shared/mocks`, `src/shared/ui/mock-mode-banner.tsx` +- 테스트: Phase 2 unit/contract test, mock/server boundary E2E, 기존 auth·accessibility·smoke 회귀 E2E +- 문서: README, agent environment/scripts 가이드, PRD `MOCK-001~009`, API Contract §1·§3, plan `P2-T1~P2-GATE` +- 런타임 검증: Chromium, WebKit, Mobile Chrome, Mobile Safari의 mock login/shell, 320px·200% zoom, axe, worker 등록, server 404·network error + +### 2.3 제외 범위 + +- Phase 3 이후 Character·Audio·Series·Community·FanTalk·Comments 구현 +- 실제 백엔드 내부 구현과 운영 데이터 +- 계약 미제공 endpoint·DTO·오류의 추정 +- 확정 문제의 코드·test 수정 +- 실제 보조기기 수동 테스트와 디자인 정성 평가 + +## 3. 판정 기준 + +### 3.1 심각도 + +| 심각도 | 기준 | +| --- | --- | +| Blocker | Phase 완료 판정이나 출고 판단을 무효화하거나 핵심 보호 경계를 전혀 신뢰할 수 없게 만드는 문제 | +| High | 확정 요구사항·API Contract·보호 route를 위반하거나 mock 검증이 잘못된 production 경계를 통과시키는 문제 | +| Medium | 제한된 조건에서 발생하는 기능·접근성·복구 문제 | +| Low | 사용자 안내, 문서 정합성 또는 검증 추적성을 약화하는 문제 | + +### 3.2 상태 + +| 상태 | 의미 | 후속 처리 | +| --- | --- | --- | +| 후보 | 근거를 발견했지만 아직 재현·판정하지 않음 | 검증 후 상태 변경 | +| 확정 | 코드·test·문서 또는 실행으로 문제가 확인됨 | 회귀 수정 Task·goal 후보 | +| 오탐 | 요구사항이나 실행 결과상 문제가 아님 | 근거를 남기고 종료 | +| 보류 | 외부 계약·환경·제품 결정이 필요함 | 담당 주체와 재개 조건 기록 | +| 수정 완료 | 수정과 관련 검증이 완료됨 | 실제 명령과 결과 연결 | + +## 4. 검토한 근거 + +### 4.1 문서와 코드 + +- 요구사항: PRD `AUTH-003`, `MOCK-001~009`, 성공 기준의 mock/server/production 항목 +- API Contract: §1.2 오류 status, §1.5 Mock Preview 계약 경계, §3.1~3.3 인증 +- 계획: `P2-T1`, `P2-T2`, `P2-T3`, `P2-GATE`, Phase 1 `P1-R3` +- 코드: `src/main.tsx`, `src/app/App.tsx`, `src/app/admin-pages.tsx`, `src/shared/config/env.ts`, `src/shared/mocks`, `vite.config.ts`, `playwright.config.ts` +- 테스트: `src/app/App.test.tsx`, `src/shared/mocks`, `tests/e2e/mock-mode-boundary.spec.ts`, `mock-preview-shell.spec.ts`, `server-mode-boundary.spec.ts`, 기존 auth/accessibility/smoke spec + +### 4.2 실행 환경 + +| 항목 | 값 | +| --- | --- | +| OS | macOS 26.0, Darwin 25.0.0 x86_64 | +| Node.js | v24.12.0 | +| npm | 11.7.0 | +| Playwright | 1.61.1 | +| 브라우저 프로젝트 | Chromium, WebKit, Mobile Chrome, Mobile Safari | +| server mode | `VITE_API_MODE=server`, `http://127.0.0.1:8888` | +| mock mode | `VITE_API_MODE=mock`, `http://127.0.0.1:8889` | + +민감정보는 실행 환경과 기록에 포함하지 않았다. + +### 4.3 실행한 자동 검증 + +| 명령 | 종료 | 결과와 핵심 증거 | +| --- | ---: | --- | +| `npm run test:run` | 0 | 34 files / 130 tests 통과 | +| `npm run test:run -- src/shared/config src/shared/mocks src/shared/ui/__tests__/mock-mode-banner.test.tsx` | 0 | Phase 2 focused 7 files / 20 tests 통과 | +| `npm run typecheck` | 0 | TypeScript 오류 0건 | +| `npm run lint` | 0 | ESLint 오류 0건 | +| `npm run build:dev` | 0 | 159 modules, JS 294.26 kB, gzip 88.61 kB | +| `npm run build:prod` | 0 | 159 modules, JS 294.25 kB, gzip 88.60 kB | +| `VITE_API_MODE=mock npm run build:prod` | 1 | 기대한 production 거부. `VITE_API_MODE=mock is only available during development` 확인 | +| `test ! -e dist/mockServiceWorker.js && ! rg -l -e 'startMockWorker' -e 'mockServiceWorker\.js' dist` | 0 | production 산출물에 worker 파일·bootstrap 문자열 없음 | +| `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts` | 1 | 최초 sandbox에서 `127.0.0.1:8889` listen `EPERM`, 실행 불가 사유 확인 | +| 같은 mock preview E2E를 로컬 listen 권한으로 재실행 | 0 | 4 projects / 20 tests 통과. login, logout 후 재login, 320px·200% zoom, axe critical·serious 0건 | +| `npm run e2e:mock -- tests/e2e/mock-mode-boundary.spec.ts` | 0 | 4 projects / 4 tests 통과, explicit mock worker controller 확인 | +| `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts` | 0 | 4 projects / 12 tests 통과, server mode worker 0건과 404·network no-fallback 확인 | +| `npm run e2e -- tests/e2e/smoke.spec.ts tests/e2e/auth.spec.ts tests/e2e/accessibility-shell.spec.ts` | 0 | 4 projects / 20 tests 통과 | +| `git diff --check && git diff --cached --check` | 0 | whitespace 오류 0건 | + +자동화는 모두 최종 통과했다. 아래 확정 항목은 현재 test가 잘못된 결과를 성공 조건으로 삼거나, 필요한 음성 조건을 검사하지 않아 통과 결과만으로 발견하지 못했다. + +### 4.4 Mock handler 런타임 재현 + +Vite SSR로 실제 `src/shared/mocks/handlers.ts`를 로드하고 MSW Node server에 연결해 wrong origin과 logout token 상태를 확인했다. + +```bash +node --input-type=module -e 'import { createServer as createViteServer } from "vite"; import { setupServer } from "msw/node"; const vite = await createViteServer({ appType: "custom", server: { middlewareMode: true, hmr: false } }); try { const { createMockHandlers, createMockStore } = await vite.ssrLoadModule("/src/shared/mocks/handlers.ts"); const mockServer = setupServer(...createMockHandlers(createMockStore())); mockServer.listen({ onUnhandledRequest: "error" }); try { const request = (path, token) => fetch(`https://wrong-origin.example${path}`, token ? { method: "POST", headers: { Authorization: `Bearer ${token}` } } : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "admin@test.com", password: "password" }) }); const wrongOriginLogin = await request("/admin/member/login"); const invalidLogout = await request("/member/logout", "invalid-token"); const firstLogout = await request("/member/logout", "mock-admin-jwt"); const revokedLogout = await request("/member/logout", "mock-admin-jwt"); console.log(JSON.stringify({ wrongOriginLogin: wrongOriginLogin.status, invalidLogout: invalidLogout.status, firstLogout: firstLogout.status, revokedLogout: revokedLogout.status })); } finally { mockServer.close(); } } finally { await vite.close(); }' +``` + +결과: + +```json +{"wrongOriginLogin":200,"invalidLogout":200,"firstLogout":200,"revokedLogout":200} +``` + +명령은 exit 0으로 끝났다. sandbox가 Vite HMR WebSocket `0.0.0.0:24678` listen을 `EPERM`으로 거부했다는 경고가 있었지만, MSW가 처리한 네 HTTP status 재현은 완료됐다. + +### 4.5 2차 독립 재검증 — 2026-07-27 + +| 명령 또는 검증 | 종료 | 결과와 핵심 증거 | +| --- | ---: | --- | +| `npm run test:run -- src/shared/config src/shared/mocks src/shared/ui/__tests__/mock-mode-banner.test.tsx` | 0 | 7 files / 23 tests 통과 | +| `npm run test:run` | 0 | 34 files / 136 tests 통과 | +| `npm run typecheck` | 0 | TypeScript 오류 0건 | +| `npm run lint` | 0 | ESLint 오류 0건 | +| `npm run build:dev` | 0 | 159 modules, JS 294.66 kB, gzip 88.66 kB | +| `npm run build:prod` | 0 | 159 modules, JS 294.65 kB, gzip 88.66 kB | +| `VITE_API_MODE=mock npm run build:prod` | 1 | 기대한 production guard. `VITE_API_MODE=mock is only available during development` 확인 | +| `test ! -e dist/mockServiceWorker.js` | 0 | production 산출물에 worker 파일 없음 | +| `rg -l -e startMockWorker -e 'mockServiceWorker\.js' dist` | 1 | 기대한 no-match. production JS에 mock bootstrap 문자열 없음 | +| `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts tests/e2e/mock-mode-boundary.spec.ts` | 0 | 4 projects / 24 tests 통과 | +| `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts tests/e2e/smoke.spec.ts tests/e2e/auth.spec.ts tests/e2e/accessibility-shell.spec.ts` | 0 | 4 projects / 32 tests 통과 | +| `npm run e2e:mock` | 1 | 56 tests 중 36 통과 / 20 실패. mock mode에서 server 전용·기존 shell spec까지 함께 수집됨 | +| `npm run e2e -- --list` | 0 | server 명령이 mock 전용 spec을 포함한 6 files / 56 tests를 수집 | +| `npm run e2e:mock -- --list` | 0 | mock 명령도 같은 6 files / 56 tests를 수집 | +| `git diff --check HEAD` | 0 | whitespace 오류 0건 | + +표준 focused·전체 unit, typecheck, lint, build와 명시적으로 분리한 E2E 묶음은 통과했다. 그러나 bare E2E script는 mode별 test 선택 경계가 없어 실패했으며, 현재 자동화에 없는 아래 세 런타임 경계도 별도 재현됐다. + +### 4.6 2차 런타임 재현 + +#### 동일 세션 보호 route 재진입 + +로컬 server mode를 열고 Playwright one-off script에서 같은 `AuthSessionRecord`를 유지한 채 다음 순서로 실행했다. + +1. `/ai-characters` 최초 진입의 StrictMode 2회 probe를 모두 200으로 응답한다. +2. SPA history로 `/login`에 이동하되 session을 지우지 않는다. +3. SPA history로 `/ai-characters`에 재진입하고 다음 probe를 404로 응답한다. +4. 오류 alert가 표시된 뒤 보호 `main`과 로그아웃 버튼 수를 확인한다. + +결과: + +```json +{"firstEntry":{"probes":2,"protectedMainCount":1},"probes":3,"path":"/ai-characters","protectedMainCount":1,"logoutCount":1,"alert":"보호 route 확인에 실패했습니다."} +``` + +#### Mock Preview 접근 거부 화면 + +mock mode에서 `sessionStorage`에 `{ token: "mock-member-jwt", role: "ADMIN" }`을 저장하고 `/ai-characters`에 진입했다. 403 처리 후 결과는 다음과 같았다. + +```json +{"path":"/access-denied","mockBannerCount":0,"heading":"접근 권한이 없습니다"} +``` + +#### Auth handler status·media type + +Vite SSR로 현재 `createMockHandlers(createMockStore(), "https://api.example.com")`를 MSW Node server에 연결해 비ADMIN logout과 `text/plain` login을 호출했다. + +```json +{"memberLogout":401,"textPlainLogin":200} +``` + +API Contract §1.2의 비ADMIN JWT 403과 지원하지 않는 media type 415에 모두 어긋난다. + +### 4.7 3차 독립 재검증 — 2026-07-27 + +| 명령 또는 검증 | 종료 | 결과와 핵심 증거 | +| --- | ---: | --- | +| `npm run test:run -- src/shared/config src/shared/mocks src/shared/ui/__tests__/mock-mode-banner.test.tsx` | 0 | 7 files / 25 tests 통과 | +| `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts` | 0 | 문서 반영 후 1 file / 3 tests 통과 | +| `npm run test:run` | 0 | 34 files / 141 tests 통과 | +| `npm run typecheck` | 0 | TypeScript 오류 0건 | +| `npm run lint` | 0 | ESLint 오류 0건 | +| `npm run build:dev`, `npm run build:prod` | 0 | development·production build 모두 160 modules 변환 성공 | +| `VITE_API_MODE=mock npm run build:prod` | 1 | 기대한 production guard. `VITE_API_MODE=mock is only available during development` 확인 | +| `test ! -e dist/mockServiceWorker.js` | 0 | production 산출물에 worker 파일 없음 | +| `rg -l -e startMockWorker -e 'mockServiceWorker\.js' dist` | 1 | 기대한 no-match. production JS에 mock bootstrap 문자열 없음 | +| `npm run e2e` | 0 | server mode 4 projects / 32 tests 통과 | +| `npm run e2e:mock` | 0 | mock mode 4 projects / 24 tests 통과 | +| `git diff HEAD --name-only \| wc -l` | 0 | 현재 변경 37개 경로. review 상단·종료 판정의 36개 기록과 불일치 확인 | +| `git diff --check HEAD` | 0 | whitespace 오류 0건 | + +Mock Preview를 320px에서 열어 ADMIN login 후 mobile menu를 열고 1,200px로 viewport를 바꾸는 Chromium one-off 검증 결과는 다음과 같았다. + +```json +{ + "responses": [ + { "path": "/admin/member/login", "fromServiceWorker": true }, + { "path": "/api/v2/admin/ai-characters?page=0&size=20", "fromServiceWorker": true } + ], + "before": { + "bannerInsideInert": false, + "bannerAriaHidden": null, + "mainInsideInert": true, + "overlayDisplay": "block" + }, + "after1200px": { + "bannerInsideInert": false, + "bannerAriaHidden": null, + "mainInsideInert": true, + "mainInertAncestor": "true", + "overlayDisplay": "none" + } +} +``` + +두 API 응답은 실제로 Service Worker에서 반환되어 backend 요청 0건 경계는 확인됐다. 반면 menu open 상태에서 banner는 background inert 경계 밖에 남았고, desktop 전환 후 overlay만 숨겨진 채 main의 `inert`·`aria-hidden`이 유지됐다. + +추가 코드·test 대조에서 `ProtectedRouteErrorPage`는 404·network probe 실패 시 alert만 렌더하고 retry·login 이동 control을 제공하지 않으며, 기존 App/server boundary test도 shell 비노출만 확인한다는 복구 공백을 확인했다. + +### 4.8 4차 독립 재검증 — 2026-07-27 + +| 명령 또는 검증 | 종료 | 결과와 핵심 증거 | +| --- | ---: | --- | +| `npm run test:run -- src/app/App.test.tsx src/shared/ui/__tests__/mock-mode-banner.test.tsx` | 0 | 2 files / 28 tests 통과 | +| `npm run test:run` | 0 | 34 files / 145 tests 통과 | +| `npm run typecheck`, `npm run lint` | 0 | TypeScript·ESLint 오류 0건 | +| `npm run build:dev`, `npm run build:prod` | 0 | development·production 모두 160 modules 변환 성공 | +| `VITE_API_MODE=mock npm run build:prod` | 1 | 기대한 production guard. `VITE_API_MODE=mock is only available during development` 확인 | +| `test ! -e dist/mockServiceWorker.js` | 0 | production 산출물에 worker 파일 없음 | +| `rg -l -e startMockWorker -e 'mockServiceWorker\.js' dist` | 1 | 기대한 no-match. production JS에 mock bootstrap 문자열 없음 | +| `npm run e2e` | 0 | server mode 4 projects / 32 tests 통과 | +| `npm run e2e:mock` | 0 | mock mode 4 projects / 28 tests 통과 | +| `git diff HEAD --name-only \| wc -l` | 0 | tracked diff 37개 경로 | +| `git status --porcelain=v1 \| wc -l` | 0 | working tree 38개 변경 항목 | +| `git ls-files --others --exclude-standard` | 0 | `src/app/protected-admin-shell.tsx` 1개가 untracked임을 확인 | +| `npm run e2e -- --list tests/e2e/server-mode-boundary.spec.ts` | 0 | file filter와 script 고정 목록이 합쳐져 4 files / 32 tests 수집 | +| `VITE_API_MODE=server npx playwright test tests/e2e/server-mode-boundary.spec.ts --list` | 0 | 실제 boundary spec은 1 file / 12 tests이며 `P2-R10` 기대 16 tests에 미달 | + +보호 route retry pending은 sandbox의 local listen·Chromium launch가 각각 `EPERM`·permission denied로 한 차례 실패한 뒤 허용된 로컬 실행으로 재검증했다. 404 화면에서 retry를 누르고 성공 응답을 350ms 지연한 결과는 다음과 같았다. + +```json +{ + "retryRequests": 1, + "pending": { + "rootText": "", + "rootChildCount": 0, + "mainCount": 0, + "statusCount": 0, + "alertCount": 0, + "activeElement": "BODY" + }, + "complete": { + "mainCount": 1, + "logout": true + } +} +``` + +`P2-R9`의 banner inert·desktop breakpoint 수정과 `P2-R10`의 fail-closed retry 성공 경계는 unit과 4-browser E2E에서 유지됐다. 반면 untracked 파일을 제외한 범위 집계, pending 중 빈 root, hard-coded npm E2E script가 focused filter를 무효화해 network retry E2E 공백을 가리는 세 문제를 추가로 확정했다. + +### 4.9 5차 독립 재검증 — 2026-07-27 + +| 명령 또는 검증 | 종료 | 결과와 핵심 증거 | +| --- | ---: | --- | +| `npm run test:run -- src/app/App.test.tsx` | 0 | 1 file / 27 tests 통과. 최초 probe pending 전용 test 1건과 기존 404 retry test의 pending assertion을 확인 | +| `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts` | 0 | 2 files / 6 tests 통과 | +| `npm run test:run` | 0 | 34 files / 147 tests 통과 | +| `npm run typecheck`, `npm run lint` | 0 | TypeScript·ESLint 오류 0건 | +| `npm run build:dev`, `npm run build:prod` | 0 | development·production 각각 160 modules 변환 성공 | +| `VITE_API_MODE=mock npm run build:prod` | 1 | 기대한 production guard. `VITE_API_MODE=mock is only available during development` 확인 | +| `npm run e2e -- --list tests/e2e/server-mode-boundary.spec.ts` | 0 | 1 file / 16 tests 수집 | +| `npm run e2e:mock -- --list tests/e2e/mock-preview-shell.spec.ts` | 0 | 1 file / 24 tests 수집 | +| `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts` | 0 | 4 projects / 16 tests 통과. retry pending status와 network 재실패 경계 유지 | +| `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts` | 0 | 4 projects / 24 tests 통과 | +| `npm run e2e`, `npm run e2e:mock` | 0 | bare server 36 tests, bare mock 28 tests 통과 | +| `git diff HEAD --name-only \| wc -l` | 0 | tracked diff 37개 경로 | +| `git status --short --untracked-files=all \| wc -l` | 0 | working tree 38개 변경 항목 | +| `git ls-files --others --exclude-standard` | 0 | untracked `src/app/protected-admin-shell.tsx` 1개 확인 | + +구현 대조 결과, 보호 route 최초 probe와 retry pending 동작은 각각 unit assertion과 server boundary E2E로 유지됐다. 그러나 `P2-R12`는 “두 test 추가”와 App 28 tests 이상을 완료 조건으로 체크한 반면 실제 App test는 27건이고, 구조도 최초 pending 전용 test 1건 추가 + 기존 retry test 보강이다. 이 완료 증거 불일치를 `REV-P2-017`로 확정했다. + +### 4.10 6차 독립 재검증 — 2026-07-27 + +| 명령 또는 검증 | 종료 | 결과와 핵심 증거 | +| --- | ---: | --- | +| `npm run test:run` | 0 | 34 files / 147 tests 통과 | +| `npm run typecheck`, `npm run lint` | 0 | TypeScript·ESLint 오류 0건 | +| `npm run build:dev`, `npm run build:prod` | 0 | development·production 각각 160 modules 변환 성공 | +| `VITE_API_MODE=mock npm run build:prod` | 1 | 기대한 production guard. `VITE_API_MODE=mock is only available during development` 확인 | +| `npm run e2e` | 0 | 최초 sandbox listen `EPERM` 뒤 로컬 실행 권한으로 재실행해 4 projects / 36 tests 통과 | +| `npm run e2e:mock` | 0 | 최초 sandbox listen `EPERM` 뒤 로컬 실행 권한으로 재실행해 4 projects / 28 tests 통과 | +| `git diff --check HEAD` | 0 | review 문서 반영 전 whitespace 오류 0건 | +| review 현재 상태 대조 | 0 | §5·§6·§8은 `P2-R8`~`P2-R13` 수정 완료, §7은 같은 Task를 “아직 수정하지 않았다”고 표시하는 모순 2건 확인 | +| `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts` | 0 | review·plan 반영 후 1 file / 3 tests 통과 | +| plan/review 대상 `git diff --check` | 0 | review·plan 반영 후 whitespace 오류 0건 | + +Chromium one-off 검증에서는 mock login과 보호 route 응답이 모두 `fromServiceWorker=true`였고, 320px의 mobile menu·logout button 높이가 각각 60px이었다. 열린 mobile menu에서 `AI 캐릭터` link를 선택한 뒤 menu와 background `inert` 잔존은 0건이었다. 새 제품 동작 회귀는 재현되지 않았다. + +### 4.11 7차 독립 재검증 — 2026-07-27 + +| 명령 또는 검증 | 종료 | 결과와 핵심 증거 | +| --- | ---: | --- | +| Phase 2 완료 상태 exact 검색 | 1 | 상단 현재 상태에 `P2-R15` 완료와 Phase 3 진행 가능 판정이 없음 | +| `tail -n 16 plan-task.md` 최신 Progress 검색 | 1 | 문서 끝이 6차 재검증의 `P2-R15` 수정 필요 상태로 끝남 | +| `P2-R15` Task·review 상태 대조 | 0 | Task 체크·inline 검증과 review §5·§6·§7·§8·§9는 수정 완료 및 열린 항목 없음을 표시 | +| `git diff --check HEAD` | 0 | review 반영 전 working tree whitespace 오류 0건 | + +제품 코드와 실행 설정의 새 변경은 없다. 완료 상태의 source인 Phase 상단과 최신 누적 Progress가 소비자인 Phase 3 시작 판정으로 전파되지 않은 문서 정합성 문제를 `REV-P2-019`로 확정했다. + +## 5. 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +| --- | --- | --- | --- | --- | --- | +| REV-P2-001 | High | 수정 완료 | 404·network probe 실패 뒤 미검증 보호 shell을 렌더링 | R2.1 | P2-R1 | +| REV-P2-002 | High | 수정 완료 | mock handler가 API base URL이 아닌 모든 origin을 허용 | R2.2 | P2-R2 | +| REV-P2-003 | High | 수정 완료 | invalid·revoked JWT logout을 성공 응답으로 정규화 | R2.2 | P2-R2 | +| REV-P2-004 | Low | 수정 완료 | 보호 shell 빈 상태가 Character 연결 시점을 완료된 Phase 2로 표시 | R2.3 | P2-R3 | +| REV-P2-005 | Low | 수정 완료 | Phase 2 Files·하단 Progress가 실제 구현과 불일치 | R2.3 | P2-R3 | +| REV-P2-006 | High | 수정 완료 | 한 번 검증된 동일 세션의 재진입 probe 실패가 보호 shell을 다시 노출 | R2.4 | P2-R4 | +| REV-P2-007 | High | 수정 완료 | auth fixture가 비ADMIN status와 login media type 계약을 완화 | R2.5 | P2-R5 | +| REV-P2-008 | Medium | 수정 완료 | Mock Preview 403·보호 오류 화면에서 지속 안내가 사라짐 | R2.6 | P2-R6 | +| REV-P2-009 | Medium | 수정 완료 | bare server·mock E2E 명령이 반대 mode spec까지 함께 수집 | R2.7 | P2-R7 | +| REV-P2-010 | Low | 수정 완료 | Phase 2 완료 문서가 P2-R4~P2-R7 변경과 불일치 | R2.8 | P2-R8 | +| REV-P2-011 | Medium | 수정 완료 | 열린 mobile menu가 desktop 전환 후 숨은 overlay와 inert shell을 남김 | R2.9 | P2-R9 | +| REV-P2-012 | Medium | 수정 완료 | 열린 mobile menu의 background inert 경계에서 Mock Preview banner가 제외됨 | R2.9 | P2-R9 | +| REV-P2-013 | Medium | 수정 완료 | 보호 route 오류 화면에 fail-closed 재시도·복구 경로가 없음 | R2.10 | P2-R10 | +| REV-P2-014 | Low | 수정 완료 | working tree 범위 집계가 untracked 보호 shell을 제외 | R2.11 | P2-R11 | +| REV-P2-015 | Medium | 수정 완료 | 보호 route probe pending 중 root가 비어 진행 상태를 알리지 않음 | R2.12 | P2-R12 | +| REV-P2-016 | Medium | 수정 완료 | 고정 E2E script가 focused 필터와 network retry 증거 공백을 가림 | R2.13 | P2-R13 | +| REV-P2-017 | Low | 수정 완료 | P2-R12 test 증거가 실제 test 구조·실행 수와 불일치 | R2.14 | P2-R14 | +| REV-P2-018 | Low | 수정 완료 | plan 전환 절이 완료된 P2-R8~P2-R13을 미수정으로 표시 | R2.15 | P2-R15 | +| REV-P2-019 | Low | 수정 완료 | Phase 2 현재 상태·최신 Progress가 P2-R15 완료와 불일치 | R2.16 | P2-R16 | + +1차 확정 5건, 2차 확정 4건, 3차 확정 4건, 4차 확정 3건, 5차·6차·7차 확정 Low 각 1건은 2026-07-27 후속 수정으로 완료됐다. 오탐·보류 항목은 없다. + +## 6. 발견 사항 상세 + +### REV-P2-001 — 404·network probe 실패 뒤 미검증 보호 shell을 렌더링 + +- **심각도:** High +- **상태:** 수정 완료 +- **관련 요구사항:** `AUTH-003`, `MOCK-003` +- **관련 계약:** API Contract §3.3, Phase 1 `P1-R3` +- **소유 Task:** 신규 R2.1, goal `P2-R1` + +**관찰 내용** + +저장된 ADMIN session으로 `/ai-characters`에 진입해 현재 DB 권한 확인용 목록 probe가 404 또는 network error로 실패하면 `routeError`가 설정된다. 렌더 guard는 `routeError === null`일 때만 shell을 숨기므로, probe가 성공하지 않았는데도 sidebar·header·logout·보호 `main`을 포함한 `ProtectedAdminShell`을 렌더한다. + +**근거** + +- 코드: `src/app/App.tsx:183-218`은 비401·비403 실패를 `routeError`로 저장하고, `src/app/App.tsx:243-251`은 오류가 있으면 token 검증 성공 여부와 무관하게 shell을 반환한다. +- 코드: `src/app/admin-pages.tsx:19-35`의 alert는 `ProtectedAdminShell` 내부 `main`에서만 렌더된다. +- 테스트: `tests/e2e/server-mode-boundary.spec.ts:28-63`은 404·network error에서 이 내부 alert가 보이는 것을 성공 조건으로 사용하며 실제 12 tests가 통과했다. +- 회귀 기준: `src/app/App.test.tsx:196-227`과 plan `P1-R3`은 stale ADMIN probe pending·403 동안 보호 shell 비노출을 고정한다. + +**재현 또는 검증 절차** + +1. `sessionStorage`에 `{ token: "admin-token", role: "ADMIN" }` session을 둔다. +2. `GET /api/v2/admin/ai-characters?page=0&size=20`을 404로 응답하거나 network abort한다. +3. `/ai-characters`에 진입하면 `보호 route 확인에 실패했습니다.` alert와 함께 보호 shell `main`이 렌더된다. +4. 요구되는 결과는 probe 성공 전에는 보호 shell을 숨기고, no-fallback 오류를 shell 밖의 독립 오류 UI로 표시하는 것이다. + +**영향** + +현재 DB role을 확인할 수 없는 실패 상태를 ADMIN으로 간주해 fail-closed 보호 경계가 깨진다. 현재 Phase에는 실제 도메인 데이터·mutation이 없지만 Phase 3부터 같은 shell 아래 기능이 추가되면 일시적 404·network failure가 보호 navigation과 action surface 노출로 이어질 수 있다. + +**권장 조치** + +현재 token의 probe 성공 여부와 route 오류 표시 상태를 분리한다. 성공한 현재 token만 `ProtectedAdminShell`을 열고, 404·network 오류는 shell 밖에서 표시한다. 404·network와 이전 오류 뒤 새 session probe pending을 App test에 추가하고 server boundary E2E가 worker 0건과 보호 shell 비노출을 함께 확인하게 한다. + +**판정 기록** + +- 2026-07-27 — 코드 분기와 4-browser server boundary E2E를 대조해 보호 route 계약 회귀로 확정했다. + +### REV-P2-002 — mock handler가 API base URL이 아닌 모든 origin을 허용 + +- **심각도:** High +- **상태:** 수정 완료 +- **관련 요구사항:** `MOCK-002` +- **관련 계약:** API Contract §1.5 +- **소유 Task:** 신규 R2.2, goal `P2-R2` + +**관찰 내용** + +login, logout, AI Character probe handler가 모두 `*/...` wildcard URL을 사용한다. 따라서 앱의 `VITE_API_BASE_URL`이 잘못되거나 request가 다른 origin으로 나가도 mock mode는 같은 성공 fixture를 반환한다. + +**근거** + +- 코드: `src/shared/mocks/handlers.ts:97-120`의 세 handler가 exact API base URL 대신 `*/admin/member/login`, `*/member/logout`, `*/api/v2/admin/ai-characters`에 매칭된다. +- 계약: API Contract §1.5는 mock mode도 production과 같은 URL을 사용하도록 요구한다. +- 실행: `https://wrong-origin.example/admin/member/login`에 유효 body를 전송한 런타임 재현이 200을 반환했다. +- 테스트 공백: auth handler test는 `https://api.example.com` 한 origin만 사용하고 wrong origin이 unhandled인지 확인하지 않는다. + +**재현 또는 검증 절차** + +1. `createMockHandlers(createMockStore())`를 MSW server에 등록한다. +2. 설정된 API base와 무관한 `https://wrong-origin.example/admin/member/login`에 유효 JSON을 보낸다. +3. 실제 결과는 200 ADMIN fixture다. +4. 요구되는 결과는 handler 비매칭이며 browser mock mode에서는 `onUnhandledRequest: "error"` 경계가 잘못된 URL을 드러내는 것이다. + +**영향** + +잘못된 API host, base URL 또는 URL 조합 회귀가 mock happy path에서 숨겨진다. mock Gate가 실제 server integration 완료 증거는 아니더라도, 동일 production URL 경계를 검증한다는 `MOCK-002`의 신뢰성이 사라진다. + +**권장 조치** + +`createMockHandlers`에 검증된 API base URL을 주입하고 `new URL(path, apiBaseUrl)`의 exact URL로 handler를 등록한다. wrong-origin request가 handler에 잡히지 않는 focused test를 추가한다. + +**판정 기록** + +- 2026-07-27 — wildcard 코드와 wrong-origin 200 런타임 결과로 API Contract 위반을 확정했다. + +### REV-P2-003 — invalid·revoked JWT logout을 성공 응답으로 정규화 + +- **심각도:** High +- **상태:** 수정 완료 +- **관련 요구사항:** `MOCK-002`, `AUTH-013` +- **관련 계약:** API Contract §1.2, §3.2~3.3 +- **소유 Task:** 신규 R2.2, goal `P2-R2` + +**관찰 내용** + +logout handler는 Bearer 문자열 존재와 body 없음만 검사한 뒤 token 유효성·폐기 상태를 확인하지 않고 store에 추가하고 200을 반환한다. 임의 token과 이미 revoke된 admin token의 두 번째 logout도 성공한다. + +**근거** + +- 코드: `src/shared/mocks/handlers.ts:107-118`은 `getTokenAccess`를 호출하지 않고 모든 non-empty token을 revoke한 뒤 `ok({})`를 반환한다. +- 계약: API Contract §1.2는 잘못됨·만료·폐기 JWT를 401로 규정한다. +- 실행: invalid token logout과 정상 logout 뒤 같은 admin token의 재logout이 모두 200이었다. +- 테스트 공백: `src/shared/mocks/__tests__/auth-handlers.test.ts:65-82`는 Bearer 없음과 body 있음만 확인한다. + +**재현 또는 검증 절차** + +1. 새 mock store에서 `Bearer invalid-token`으로 `POST /member/logout`을 호출한다. +2. `Bearer mock-admin-jwt`로 정상 logout한 뒤 같은 token으로 다시 호출한다. +3. 실제 결과는 두 요청 모두 200 success envelope다. +4. 요구되는 결과는 invalid·revoked JWT에 401 error envelope를 반환하는 것이다. + +**영향** + +mock preview에서 서버 logout 실패 경고와 폐기 token 계약을 검증할 수 없고, 실제 서버에서 발생할 401을 성공으로 오인한다. `AUTH-013`의 로컬 session 제거는 유지되더라도 실패 피드백 QA가 왜곡된다. + +**권장 조치** + +store mutation 전에 token 상태를 판정하고 invalid·revoked token은 API Contract의 401 envelope로 반환한다. 정상 login → logout → login 회귀와 invalid·double logout 경계 test를 함께 둔다. 공통 logout의 다른 회원 role 의미는 제공 계약 이상으로 추정하지 않는다. + +**판정 기록** + +- 2026-07-27 — invalid·revoked token 200을 런타임으로 재현해 오류 status 계약 위반을 확정했다. + +### REV-P2-004 — 보호 shell 빈 상태가 Character 연결 시점을 완료된 Phase 2로 표시 + +- **심각도:** Low +- **상태:** 수정 완료 +- **관련 요구사항:** Phase 지도와 사용자 상태 안내 +- **관련 계약:** 없음 +- **소유 Task:** 신규 R2.3, goal `P2-R3` + +**관찰 내용** + +현재 plan에서 Character workspace는 Phase 3이지만 보호 shell의 설명과 빈 상태 제목은 모두 “Phase 2에서 AI 캐릭터 목록이 연결됩니다.”라고 표시한다. Phase 2를 완료한 현재 화면에서도 다음 작업 시점을 잘못 안내한다. + +**근거** + +- 코드: `src/app/admin-pages.tsx:27,34`의 사용자 표시가 Phase 2를 가리킨다. +- 문서: `plan-task.md` Phase 지도와 `## Phase 3. Character workspace vertical slice`는 Character 목록/workspace를 Phase 3 소유로 둔다. +- 테스트: `src/app/App.test.tsx`도 기존 Phase 2 문구를 assertion해 잘못된 표시를 고정한다. + +**재현 또는 검증 절차** + +1. ADMIN으로 `/ai-characters` 보호 shell을 연다. +2. 설명과 empty state 제목에서 Phase 2 안내를 확인한다. +3. 실제 결과는 완료된 Phase 2를 다음 연결 시점으로 표시한다. +4. 요구되는 결과는 현재 계획의 Phase 3 안내다. + +**영향** + +기능 자체는 막지 않지만 운영자·reviewer가 Phase 2 완료 상태와 Character 구현 범위를 잘못 이해할 수 있다. + +**권장 조치** + +두 사용자 문구와 관련 App test를 Phase 3으로 갱신한다. Phase 3 기능 자체는 이 수정에 포함하지 않는다. + +**판정 기록** + +- 2026-07-27 — 실제 UI 문자열과 Phase 지도를 대조해 문서·사용자 안내 불일치로 확정했다. + +### REV-P2-005 — Phase 2 Files·하단 Progress가 실제 구현과 불일치 + +- **심각도:** Low +- **상태:** 수정 완료 +- **관련 요구사항:** `plan-task.md` 구현 완료 정의의 문서·구현 차이 0건, Goal 실행형 계획 유지보수 규칙 +- **관련 계약:** 없음 +- **소유 Task:** 신규 R2.3, goal `P2-R3` + +**관찰 내용** + +Phase 2 주요 Files는 존재하지 않는 `src/app/providers.tsx`, `fixtures.ts`, `store.ts`, `handlers.test.ts`, `store.test.ts`를 완료 산출물처럼 유지하고, 실제 `App.tsx`, `vite.config.ts`, `playwright.config.ts`, `contract.ts`, auth/docs/production test와 두 boundary E2E를 기록하지 않는다. 또한 리뷰 시작 시 하단 `## 7. 검증 기록`의 마지막 항목은 Phase 2 착수 전 계획 추가 기록이었고, 완료한 Phase 2 구현의 무엇을/왜/실제 명령·결과가 누적되지 않았다. + +**근거** + +- 문서: `plan-task.md` Phase 2 `주요 Files`와 실제 staged 28개 경로가 일치하지 않는다. +- 파일 검사: `src/app/providers.tsx`, `src/shared/mocks/fixtures.ts`, `store.ts`, `__tests__/handlers.test.ts`, `store.test.ts`는 존재하지 않는다. +- 실제 구현: `src/app/App.tsx`, `vite.config.ts`, `playwright.config.ts`, `src/shared/mocks/contract.ts`, 세부 contract/docs/production test와 `mock-mode-boundary.spec.ts`, `server-mode-boundary.spec.ts`가 Phase 2 변경에 포함된다. +- 문서: Task·Gate별 실행 기록은 Phase 본문에 있으나 하단 Progress에는 구현 완료 기록이 없었다. 이 리뷰 기록은 새로 누적했지만 실제 Phase 2 구현 Progress와 Files 정정은 아직 수행하지 않았다. + +**재현 또는 검증 절차** + +1. Phase 2 `주요 Files`의 각 경로 존재 여부를 검사한다. +2. `git diff --cached --name-status`의 실제 구현 경로와 대조한다. +3. `plan-task.md` 하단 `## 7. 검증 기록`의 마지막 구현 기록을 확인한다. +4. 실제 결과는 Files와 Progress 모두 현재 Phase 2 구현과 다르다. + +**영향** + +독립 reviewer가 어떤 파일이 Phase 2 소유인지, 어떤 최종 명령으로 완료했는지 plan의 표준 위치에서 재현하기 어렵다. 이후 goal 실행자가 존재하지 않는 파일을 전제로 작업할 수 있다. + +**권장 조치** + +Phase 2 주요 Files를 실제 최소 구조로 갱신하고, 하단 Progress에 기존 본문 기록을 지우지 않은 채 무엇을/왜/실제 명령·결과/남은 항목을 누적한다. 사용자 Phase 문구 정정과 함께 문서/App focused test를 실행한다. + +**판정 기록** + +- 2026-07-27 — 파일 존재 검사, staged name-status와 하단 Progress를 교차 확인해 문서 정합성 문제로 확정했다. + +### REV-P2-006 — 한 번 검증된 동일 세션의 재진입 probe 실패가 보호 shell을 다시 노출 + +- **심각도:** High +- **상태:** 수정 완료 +- **관련 요구사항:** `AUTH-003`, `MOCK-003` +- **관련 계약:** API Contract §3.3, 기존 `P2-R1` +- **소유 Task:** R2.4, goal `P2-R4` + +**관찰 내용** + +같은 session 객체가 한 번 보호 route probe를 통과하면 `verifiedProtectedRouteSession`이 유지된다. session을 지우지 않고 `/login`을 거쳐 `/ai-characters`에 다시 들어올 때 새 probe가 시작돼도 이전 검증 값과 현재 session이 이미 같으므로 보호 shell을 즉시 렌더한다. 두 번째 probe가 404 또는 network error로 실패해도 검증 값은 무효화되지 않아 오류 alert를 보호 shell 내부에 표시한다. + +**근거** + +- 코드: `src/app/App.tsx:189-190`이 오류와 검증 session을 component state로 보관한다. +- 코드: `src/app/App.tsx:198-233`은 새 probe 시작 시 기존 `verifiedProtectedRouteSession`을 무효화하지 않는다. +- 코드: `src/app/App.tsx:258-264`은 이전 검증 session과 현재 session의 객체 동일성만으로 shell 렌더를 허용한다. +- 테스트 공백: `src/app/App.test.tsx:208-294`는 최초 probe 실패와 logout → 새 session 재로그인은 다루지만, 성공한 동일 session의 route 이탈 → 재진입 실패는 다루지 않는다. +- 런타임: 최초 probe 2회 200 뒤 동일 session 재진입 probe를 404로 응답했을 때 `protectedMainCount=1`, `logoutCount=1`이었다. + +**재현 또는 검증 절차** + +1. ADMIN session으로 `/ai-characters` probe를 성공시켜 보호 shell을 연다. +2. session을 유지한 채 SPA navigation으로 `/login`에 이동한다. +3. `/ai-characters`로 다시 이동하고 새 probe를 404 또는 network error로 실패시킨다. +4. 실제 결과는 `보호 route 확인에 실패했습니다.` alert와 보호 `main`·로그아웃 버튼이 함께 남는다. +5. 요구되는 결과는 새 probe 성공 전과 실패 후에 보호 shell이 노출되지 않는 것이다. + +**영향** + +현재 DB ADMIN 여부를 다시 확인하지 못한 상태에서도 이전 성공을 재사용해 보호 navigation과 action surface를 노출한다. Phase 3 이후 mutation UI가 같은 shell에 추가되면 `P2-R1`의 fail-closed 보장이 route 재진입 경로에서 다시 깨진다. + +**권장 조치** + +검증 상태를 session 객체뿐 아니라 현재 probe 시도에 귀속하고, route 재진입 시 이전 성공을 먼저 무효화한다. 성공 → route 이탈 → 동일 session 재진입 pending·404·network·403을 하나의 회귀 test cycle로 고정한다. + +**판정 기록** + +- 2026-07-27 — 코드 상태 전이와 Chromium one-off Playwright 재현 결과로 확정했다. +- 2026-07-27 — `P2-R4`에서 route visit key 회귀 test와 server E2E로 수정 완료를 확인했다. + +### REV-P2-007 — auth fixture가 비ADMIN status와 login media type 계약을 완화 + +- **심각도:** High +- **상태:** 수정 완료 +- **관련 요구사항:** `AUTH-006`, `AUTH-008`, `MOCK-002` +- **관련 계약:** API Contract §1.2, §3.1~3.3 +- **소유 Task:** R2.5, goal `P2-R5` + +**관찰 내용** + +logout handler는 `getTokenAccess(token) !== "admin"`인 모든 token을 401로 합쳐 이미 `denied`로 분류한 비ADMIN token도 403 대신 401을 반환한다. login handler는 request의 `Content-Type`을 확인하지 않고 `request.json()` 결과만 파싱해 `text/plain` JSON body도 200 ADMIN 응답으로 처리한다. + +**근거** + +- 코드: `src/shared/mocks/handlers.ts:37-45`는 `memberToken`을 `denied`로 분류한다. +- 코드: `src/shared/mocks/handlers.ts:114-125`는 logout에서 `denied`와 `unauthorized`를 모두 401로 반환한다. +- 코드: `src/shared/mocks/handlers.ts:85-99`는 login Authorization과 JSON shape만 검사하고 media type을 확인하지 않는다. +- 계약: API Contract §1.2는 JWT role 비ADMIN을 403, 지원하지 않는 media type을 415로 규정한다. §3.1은 login `Content-Type: application/json`을 요구한다. +- 테스트 공백: `auth-handlers.test.ts`의 403 test는 Character probe만 확인하고 비ADMIN logout·잘못된 login media type을 확인하지 않는다. +- 런타임: 현재 handler는 비ADMIN logout 401, `text/plain` login 200을 반환했다. + +**재현 또는 검증 절차** + +1. `Bearer mock-member-jwt`로 `POST /member/logout`을 body 없이 호출한다. +2. `Content-Type: text/plain`과 유효한 JSON 문자열 body로 `POST /admin/member/login`을 호출한다. +3. 실제 결과는 각각 401과 200이다. +4. 요구되는 결과는 API Contract에 따른 403과 415 오류 envelope다. + +**영향** + +mock mode가 실제 서버보다 넓은 요청을 성공시키고 오류 status를 다르게 반환한다. auth serializer·권한 처리 회귀가 mock happy path에서 숨겨져 `MOCK-002`의 production 동일 계약 검증 신뢰성이 낮아진다. + +**권장 조치** + +logout에서도 공통 token access 분기를 재사용해 `denied`와 `unauthorized`를 구분하고, login JSON 파싱 전에 media type을 검증한다. 비ADMIN logout 403과 `text/plain`·누락·잘못된 media type 경계 test를 추가한다. + +**판정 기록** + +- 2026-07-27 — API Contract와 handler 분기를 대조하고 MSW Node runtime status를 재현해 확정했다. +- 2026-07-27 — `P2-R5`에서 비ADMIN logout 403과 non-JSON login 415 test로 수정 완료를 확인했다. + +### REV-P2-008 — Mock Preview 403·보호 오류 화면에서 지속 안내가 사라짐 + +- **심각도:** Medium +- **상태:** 수정 완료 +- **관련 요구사항:** `MOCK-008` +- **관련 계약:** API Contract §1.5 +- **소유 Task:** R2.6, goal `P2-R6` + +**관찰 내용** + +mock banner는 login과 검증 성공 후 `ProtectedAdminShell`에만 배치된다. 보호 probe가 403으로 전환한 `AccessDeniedPage`와 404·network error를 표시하는 `ProtectedRouteErrorPage`에는 banner가 없어 mock mode 화면이 실제 서버 화면처럼 보인다. + +**근거** + +- 코드: `src/app/App.tsx:235-247`, `264`는 login과 보호 shell에 `apiMode`를 전달한다. +- 코드: `src/app/App.tsx:254-261`은 접근 거부·보호 오류 화면을 banner 없이 직접 반환한다. +- 요구사항: `MOCK-008`은 mock mode 화면에 실제 서버가 아니라는 지속적으로 보이는 안내를 요구한다. +- 테스트 공백: App·mock E2E는 login과 성공 shell의 banner만 확인하고 403·404·network 상태는 확인하지 않는다. +- 런타임: mock mode에서 stale ADMIN 역할을 나타내는 `mock-member-jwt`로 진입한 `/access-denied`의 `mockBannerCount`가 0이었다. + +**재현 또는 검증 절차** + +1. mock mode에서 `sessionStorage`에 `{ token: "mock-member-jwt", role: "ADMIN" }`을 저장한다. +2. `/ai-characters`에 진입해 mock handler의 403 응답을 받는다. +3. `/access-denied` heading은 보이지만 `Mock Preview` status는 존재하지 않는다. +4. 요구되는 결과는 보호 shell을 노출하지 않으면서도 mock mode 안내는 지속되는 것이다. + +**영향** + +권한·연결 오류를 QA하는 사용자가 현재 응답이 mock fixture에서 나온 것인지 실제 서버에서 나온 것인지 구분하기 어렵다. 성공 화면보다 오류 화면에서 환경 혼동 가능성이 커진다. + +**권장 조치** + +Mock Preview 안내를 route별 page 내부가 아니라 모든 mock route state를 감싸는 공통 상위 경계에 한 번만 배치하고, login·성공 shell·403·404/network 상태 test를 추가한다. + +**판정 기록** + +- 2026-07-27 — 코드 composition과 Chromium mock runtime 결과로 확정했다. +- 2026-07-27 — `P2-R6`에서 mock 403·보호 오류 banner test와 mock E2E로 수정 완료를 확인했다. + +### REV-P2-009 — bare server·mock E2E 명령이 반대 mode spec까지 함께 수집 + +- **심각도:** Medium +- **상태:** 수정 완료 +- **관련 요구사항:** `MOCK-001`, Phase 2 `P2-T1`, 실행 스크립트·README 동기화 +- **관련 계약:** 없음 +- **소유 Task:** R2.7, goal `P2-R7` + +**관찰 내용** + +`npm run e2e`와 `npm run e2e:mock`은 환경 변수와 web server만 바꾸고 동일한 `tests/e2e` 전체를 실행한다. server 명령도 mock worker 등록을 요구하는 spec을, mock 명령도 server worker 0건과 page-level route interception을 전제로 한 spec을 함께 수집한다. + +**근거** + +- 설정: `package.json:16-17`의 두 script는 spec filter 없이 `playwright test`를 실행한다. +- 설정: `playwright.config.ts:3-22`는 web server mode만 고르고 mode별 `testMatch`, `testIgnore`, project dependency를 정의하지 않는다. +- 문서: README와 `docs/agent-guide/scripts.md`는 bare 두 명령을 각각 server mode와 mock mode Playwright로 안내한다. +- 실행: 두 `--list` 명령이 모두 같은 6 files / 56 tests를 수집했다. +- 실행: bare `npm run e2e:mock`은 server 전용·기존 shell spec 충돌로 36 통과 / 20 실패했다. +- 대조: mode에 맞는 spec을 명시한 mock 24 tests와 server 32 tests는 각각 모두 통과했다. + +**재현 또는 검증 절차** + +1. `npm run e2e -- --list`와 `npm run e2e:mock -- --list`를 실행한다. +2. 두 명령이 모두 `mock-*.spec.ts`와 `server-mode-boundary.spec.ts`를 포함한 같은 56 tests를 출력한다. +3. `npm run e2e:mock`을 bare로 실행하면 20 tests가 실패한다. +4. 요구되는 결과는 문서의 각 bare 명령이 자기 mode에 유효한 spec 집합만 실행해 exit 0을 반환하는 것이다. + +**영향** + +표준 QA 명령이 mode별로 독립 재현되지 않고, 사용자는 계획에 숨겨진 spec 경로를 알아야만 통과 결과를 얻는다. CI나 후속 Phase가 bare script를 사용하면 정상 변경도 실패로 판정한다. + +**권장 조치** + +현재 단일 Playwright config 안에서 `apiMode`에 따라 가장 작은 `testMatch` 또는 `testIgnore` 경계를 두고, bare 두 명령 자체를 contract test와 Gate에 포함한다. 별도 config 복제는 필요할 때까지 만들지 않는다. + +**판정 기록** + +- 2026-07-27 — package/config 추적, 동일 test 목록과 bare mock 실행 실패로 확정했다. +- 2026-07-27 — `P2-R7`에서 mode별 script contract, `--list`, bare E2E 실행으로 수정 완료를 확인했다. + +### REV-P2-010 — Phase 2 완료 문서가 P2-R4~P2-R7 변경과 불일치 + +- **심각도:** Low +- **상태:** 수정 완료 +- **관련 요구사항:** `plan-task.md` 구현 완료 정의의 문서·구현 차이 0건, Goal 실행형 계획 유지보수 규칙 +- **관련 계약:** 없음 +- **소유 Task:** R2.8, goal `P2-R8` + +**관찰 내용** + +`P2-R4`~`P2-R7`에서 새 코드·test 경로와 검증이 추가됐지만 Phase 2 `주요 Files`, review 상단 범위·working tree 경로 수와 종료 판정은 이전 상태를 유지한다. review는 코드·문서 정합성을 충족으로 판정하지만 현재 표준 위치만으로는 최종 변경 범위를 재현할 수 없다. + +**근거** + +- 문서: `plan-task.md:589-600`의 `주요 Files`에 `src/app/App.test.tsx`, `src/app/admin-pages.tsx`, `src/app/browser-location.ts`, `src/app/protected-admin-shell.tsx`가 없다. +- 문서: `plan-task.md:829`는 `P2-R4`에서 `src/app/protected-admin-shell.tsx`를 새로 분리했다고 기록한다. +- 문서: 3차 반영 전 review 상단은 `P2-R3`까지만 대상으로 적고 변경 36개 경로라고 기록했으나 상세와 종료 판정은 `P2-R7`까지 완료됐다고 적었다. +- 실행: `git diff HEAD --name-only | wc -l`은 37개 경로를 반환했다. +- 규칙: `docs/agent-guide/goal-plan.md`는 Task별 정확한 Files·Interfaces와 범위 변경 전 plan 갱신을 요구한다. + +**재현 또는 검증 절차** + +1. `git diff HEAD --name-only`로 현재 변경 경로를 수집한다. +2. Phase 2 `주요 Files` 및 `P2-R4`~`P2-R7` 기록과 대조한다. +3. review 상단의 대상·경로 수와 `## 8. 리뷰 종료 판정`을 대조한다. +4. 실제 결과는 코드·test 경로 누락, 대상 범위와 경로 수 불일치, 정합성 충족 판정의 상충이다. + +**영향** + +독립 실행자와 reviewer가 최종 Phase 2 소유 파일과 최신 Gate 근거를 표준 위치에서 재현할 수 없다. 기능 동작에는 직접 영향이 없지만 완료 문서의 추적성과 종료 판정 신뢰성이 낮아진다. + +**권장 조치** + +문서 전용 `P2-R8`에서 Phase 2 Files, 완료 Task의 Files·Interfaces, review 범위·경로 수와 3차 검증 기록을 현재 working tree에 맞춰 누적 정정한다. 기존 완료 체크와 과거 수치는 삭제하지 않는다. + +**판정 기록** + +- 2026-07-27 — 현재 diff 37개 경로, plan Files와 review 메타데이터를 교차 확인해 문서 추적성 문제로 확정했다. + +### REV-P2-011 — 열린 mobile menu가 desktop 전환 후 숨은 overlay와 inert shell을 남김 + +- **심각도:** Medium +- **상태:** 수정 완료 +- **관련 요구사항:** PRD §10.6 반응형 원칙, §10.7 접근성 최소 기준, `P2-T3` +- **관련 계약:** 없음 +- **소유 Task:** R2.9, goal `P2-R9` + +**관찰 내용** + +320px에서 mobile menu를 연 뒤 viewport를 `lg` 이상으로 넓히면 overlay는 `lg:hidden`으로 사라지지만 `isMobileMenuOpen`은 계속 `true`다. 따라서 화면에 다시 나타난 desktop shell은 `inert`와 `aria-hidden=true`를 유지하고 navigation·logout·main을 조작할 수 없다. + +**근거** + +- 코드: `src/app/protected-admin-shell.tsx:93`은 `isMobileMenuOpen`만으로 shell background에 `inert`와 `aria-hidden`을 적용한다. +- 코드: `src/app/protected-admin-shell.tsx:133`은 overlay를 CSS `lg:hidden`으로만 숨기며 breakpoint 전환 시 state를 닫는 처리가 없다. +- 테스트 공백: `tests/e2e/accessibility-shell.spec.ts`와 `mock-preview-shell.spec.ts`는 고정 320px menu 동작만 확인하고 열린 상태의 1,024px·1,200px 전환을 검증하지 않는다. +- 런타임: 320px menu open에서 overlay `display=block`, main inert를 확인한 뒤 1,200px에서 overlay `display=none`, main inert ancestor의 `aria-hidden=true`가 그대로 남았다. + +**재현 또는 검증 절차** + +1. Mock Preview ADMIN shell을 320px viewport로 열고 mobile menu를 연다. +2. menu를 닫지 않고 viewport를 1,200px로 변경한다. +3. overlay의 computed `display`와 `main.closest('[inert]')`를 확인한다. +4. 실제 결과는 overlay `display:none`, main의 inert ancestor `aria-hidden=true`다. 요구되는 결과는 breakpoint 전환과 함께 menu state·inert·aria-hidden을 해제하는 것이다. + +**영향** + +좁은 desktop 창을 넓히거나 responsive QA 중 breakpoint를 넘으면 보이는 desktop shell 전체가 pointer·keyboard·보조기기 조작 불가 상태가 된다. 다시 좁혀 menu를 닫기 전에는 화면 안에서 복구할 수 없다. + +**권장 조치** + +native viewport query를 사용해 `lg` 진입 시 mobile menu state를 닫고, breakpoint 자동 종료에서는 숨겨진 trigger로 focus를 복귀하지 않는다. 320px open → 1,024px·1,200px 전환 회귀 test와 desktop control 조작 E2E를 추가한다. + +**판정 기록** + +- 2026-07-27 — Chromium viewport 전환에서 hidden overlay와 inert shell 잔존을 직접 재현해 확정했다. +- 2026-07-27 — `P2-R9`에서 `matchMedia('(min-width: 1024px)')` change로 menu state를 닫고 focus 복귀를 차단했다. App focused unit과 mock e2e가 1,024px·1,200px 전환 뒤 mobile overlay·inert·`aria-hidden` 잔존 0건을 확인해 수정 완료로 판정했다. + +### REV-P2-012 — 열린 mobile menu의 background inert 경계에서 Mock Preview banner가 제외됨 + +- **심각도:** Medium +- **상태:** 수정 완료 +- **관련 요구사항:** PRD §10.7 접근성 최소 기준, `MOCK-008`, `P2-R6` +- **관련 계약:** API Contract §1.5 +- **소유 Task:** R2.9, goal `P2-R9` + +**관찰 내용** + +`P2-R6`에서 Mock Preview banner를 `RouteFrame`으로 올린 뒤 banner는 `ProtectedAdminShell`이 menu open 시 적용하는 background inert·`aria-hidden` 경계의 형제가 됐다. menu가 열려도 banner는 accessibility tree에 남아 overlay navigation과 background 상태가 동시에 노출된다. + +**근거** + +- 코드: `src/app/App.tsx:42-48`의 `RouteFrame`은 banner와 `ProtectedAdminShell`을 형제로 렌더한다. +- 코드: `src/app/protected-admin-shell.tsx:93`의 inert·`aria-hidden`은 shell 내부 background에만 적용된다. +- 테스트 공백: App/mock E2E는 banner 표시와 menu focus containment을 각각 확인하지만 menu open 상태의 banner inert 귀속은 확인하지 않는다. +- 런타임: 320px menu open에서 main은 inert 내부였지만 banner의 `closest('[inert]')`는 `null`, `aria-hidden`도 `null`이었다. + +**재현 또는 검증 절차** + +1. mock mode ADMIN shell에서 mobile menu를 연다. +2. Mock Preview banner와 main 각각의 `closest('[inert]')`를 확인한다. +3. 실제 결과는 main만 inert background에 포함되고 banner는 포함되지 않는다. +4. 요구되는 결과는 시각적 banner를 유지하되 열린 navigation 외 background 전체가 같은 inert·`aria-hidden` 경계에 포함되는 것이다. + +**영향** + +키보드 focus는 menu 안에 머물지만 screen reader virtual cursor는 background banner를 계속 탐색할 수 있어 overlay 맥락과 background 차단 의미가 불일치한다. + +**권장 조치** + +성공 shell에서는 banner와 shell 본문을 같은 background inert container로 조합하고, login·403·보호 오류 화면의 공통 banner 배치는 유지한다. menu open 상태의 banner role 미노출과 menu close 후 복원을 unit/E2E로 고정한다. + +**판정 기록** + +- 2026-07-27 — DOM inert 귀속과 aria-hidden 값을 Chromium에서 확인해 접근성 경계 회귀로 확정했다. +- 2026-07-27 — `P2-R9`에서 성공 shell의 `MockModeBanner`를 `ProtectedAdminShell` background inert container 안으로 이동했다. 320px menu open 상태의 unit/e2e가 banner와 main의 같은 inert·`aria-hidden` 경계를 확인해 수정 완료로 판정했다. + +### REV-P2-013 — 보호 route 오류 화면에 fail-closed 재시도·복구 경로가 없음 + +- **심각도:** Medium +- **상태:** 수정 완료 +- **관련 요구사항:** PRD §10.5 오류 화면 상태, `MOCK-003`, `P2-R1` +- **관련 계약:** API Contract §1.2, §1.5 +- **소유 Task:** R2.10, goal `P2-R10` + +**관찰 내용** + +저장된 ADMIN session의 보호 route probe가 404 또는 network error로 실패하면 shell 밖 오류 alert만 표시된다. 오류 page에는 재시도, 로그인 이동 또는 session 종료 control이 없어 일시적인 연결 실패가 복구돼도 사용자는 browser refresh·주소 이동에 의존해야 한다. + +**근거** + +- 코드: `src/app/App.tsx:32-40`의 `ProtectedRouteErrorPage`는 message를 담은 `p[role=alert]`만 렌더한다. +- 코드: `src/app/App.tsx:138-143`은 미검증 상태에서 이 page만 반환하며 retry attempt를 시작할 action을 전달하지 않는다. +- 요구사항: PRD §10.5는 오류 상태에 서버의 한국어 message와 재시도를 요구하고 `MOCK-003`은 server mode 오류를 실제 오류 UI로 처리하도록 한다. +- 테스트 공백: `src/app/App.test.tsx:224-263`과 `tests/e2e/server-mode-boundary.spec.ts:28-68`은 alert와 shell·logout 비노출만 확인하고 연결 복구 뒤 사용자 retry를 검증하지 않는다. + +**재현 또는 검증 절차** + +1. ADMIN session으로 `/ai-characters`에 진입하고 probe를 404 또는 network error로 실패시킨다. +2. `보호 route 확인에 실패했습니다.` alert가 표시된 뒤 button·link를 확인한다. +3. 실제 결과는 interactive recovery control 0건이다. +4. 요구되는 결과는 보호 shell을 계속 숨긴 채 사용자가 명시적으로 probe를 재시도하고, 성공한 현재 attempt 뒤에만 shell을 여는 것이다. + +**영향** + +일시적인 network 장애에서도 앱 내부 복구가 불가능하다. 사용자가 browser refresh를 알지 못하면 보호 화면 진입을 완료할 수 없고, 오류 상태의 접근 가능한 recovery 기준도 충족하지 못한다. + +**권장 조치** + +자동 retry나 mock fallback을 추가하지 않고 오류 page에 keyboard-accessible retry button을 제공한다. 404 → retry 성공, network → retry 실패·성공을 test하고 모든 비성공 attempt에서 shell·logout 비노출을 유지한다. 정상 오류 envelope의 한국어 message는 보존하고 network·invalid response에는 공통 안내를 사용한다. + +**판정 기록** + +- 2026-07-27 — 오류 page 코드와 App/server boundary test를 PRD 오류 상태 기준에 대조해 복구 경로 누락으로 확정했다. +- 2026-07-27 — `P2-R10`에서 retry attempt state와 native retry button을 추가했다. 404 → retry 성공, network → retry 실패 unit과 server mode E2E가 성공 전 shell·logout 비노출, 서버 message 보존, mock worker 0건을 확인해 수정 완료로 판정했다. + +### REV-P2-014 — working tree 범위 집계가 untracked 보호 shell을 제외 + +- **심각도:** Low +- **상태:** 수정 완료 +- **관련 요구사항:** Goal 실행형 계획 유지보수 규칙, 리뷰 범위 전체 확인 +- **관련 계약:** 없음 +- **소유 Task:** R2.11, goal `P2-R11` + +**관찰 내용** + +`P2-R8`은 `git diff HEAD --name-only`의 37개 결과를 현재 working tree 전체 범위로 사용해 review를 종료했다. 하지만 새 `src/app/protected-admin-shell.tsx`는 untracked라 이 명령에 포함되지 않으며 실제 status에는 38개 변경 항목이 있다. + +**근거** + +- 실행: `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`를 반환했다. +- 문서: `plan-task.md:954-960`의 `P2-R8` 검증은 `git diff HEAD --name-only`만 사용하고 untracked 확인 명령이 없다. +- 문서: 4차 반영 전 review 상단과 종료 판정은 37개를 현재 working tree 전체 범위로 기록했다. + +**재현 또는 검증 절차** + +1. `git diff HEAD --name-only | wc -l`로 tracked diff를 센다. +2. `git status --porcelain=v1 | wc -l`과 `git ls-files --others --exclude-standard`를 실행한다. +3. 실제 결과는 tracked 37개, untracked 1개, working tree 전체 38개 항목이다. +4. 요구되는 결과는 review 범위와 대체 검증이 tracked·untracked를 구분해 전체 38개를 추적하는 것이다. + +**영향** + +코드 동작에는 직접 영향이 없지만 새 파일이 review 대상·diff 감사에서 빠질 수 있다. 문서 정합성 복구를 완료했다는 `P2-R8`의 검증 방법과 종료 판정 신뢰성이 다시 낮아진다. + +**권장 조치** + +문서 전용 `P2-R11`에서 과거 37개 tracked 결과는 보존하고 현재 범위를 tracked 37개 + untracked 1개로 명시한다. 후속 검증은 `git status --short --untracked-files=all`과 untracked 목록을 함께 사용한다. + +**판정 기록** + +- 2026-07-27 — tracked diff, porcelain status와 untracked 목록의 수치 차이를 직접 확인해 문서 범위 누락으로 확정했다. +- 2026-07-27 — `P2-R11`에서 현재 범위를 tracked 37개 + untracked 1개 = 38개로 정정하고 `git status --short --untracked-files=all`과 untracked 목록 검증을 누적해 수정 완료로 판정했다. + +### REV-P2-015 — 보호 route probe pending 중 root가 비어 진행 상태를 알리지 않음 + +- **심각도:** Medium +- **상태:** 수정 완료 +- **관련 요구사항:** PRD §10.5 loading·300ms 비동기 피드백, §10.7 비동기 상태 live region, `P2-R10` +- **관련 계약:** API Contract §1.5 +- **소유 Task:** R2.12, goal `P2-R12` + +**관찰 내용** + +최초 보호 route probe 또는 오류 화면의 수동 retry가 pending이면 미검증 branch가 `null`을 반환한다. 350ms 이상 응답이 지연돼도 화면과 접근성 tree에 loading 안내가 없고, retry button을 누른 keyboard focus는 제거된 element에서 `BODY`로 이동한다. + +**근거** + +- 코드: `src/app/App.tsx:161-167`은 현재 attempt가 미검증이고 error가 없으면 `null`을 반환한다. +- 코드: `src/app/App.tsx:171-173`은 retry click에서 error를 먼저 지우고 retry key를 증가시켜 같은 빈 branch로 전환한다. +- 테스트 공백: `src/app/App.test.tsx:397-400`은 retry pending의 shell·logout 비노출만 확인하고 visible status·loading feedback은 확인하지 않는다. +- 요구사항: PRD §10.5는 첫 loading 상태와 300ms 이상 작업의 시각 피드백을, §10.7은 비동기 결과·오류의 적절한 live region을 요구한다. +- 런타임: retry 성공 응답을 350ms 지연했을 때 `#root` child 0개, `main`·`status`·`alert` 0개, active element `BODY`를 Chromium에서 확인했다. + +**재현 또는 검증 절차** + +1. ADMIN session의 probe를 404로 응답해 retry button을 표시한다. +2. 다음 성공 응답을 350ms 이상 지연하고 retry button을 누른다. +3. pending 동안 `#root`, landmark, `role=status|alert`와 active element를 확인한다. +4. 실제 결과는 빈 root와 `BODY` focus다. 요구되는 결과는 보호 shell은 숨기되 `RouteFrame` 안의 지속적인 한국어 loading status를 제공하는 것이다. + +**영향** + +느린 네트워크에서 사용자는 retry가 시작됐는지, 앱이 멈췄는지 구분할 수 없다. 보조기기 사용자에게는 비동기 진행 상태가 전혀 전달되지 않으며 초기 보호 route 진입도 같은 빈 화면을 공유한다. + +**권장 조치** + +새 loading 시스템을 만들지 않고 기존 `PageState` loading variant를 미검증 branch에 조합한다. 최초 probe와 retry pending unit test, 350ms 지연 server boundary E2E로 visible status 1건과 보호 shell·logout 0건을 함께 고정한다. + +**판정 기록** + +- 2026-07-27 — 코드 branch와 350ms Chromium 지연 재현을 PRD 비동기 상태 기준에 대조해 확정했다. +- 2026-07-27 — `P2-R12`에서 미검증 branch에 기존 `PageState` loading status를 표시하고 App focused test와 server boundary E2E로 pending 중 status 1건, 보호 shell·logout 0건을 확인해 수정 완료로 판정했다. + +### REV-P2-016 — 고정 E2E script가 focused 필터와 network retry 증거 공백을 가림 + +- **심각도:** Medium +- **상태:** 수정 완료 +- **관련 요구사항:** `P2-R7`, `P2-R10` 완료 증거, Goal 실행형 계획 검증 규칙 +- **관련 계약:** API Contract §1.5 +- **소유 Task:** R2.13, goal `P2-R13` + +**관찰 내용** + +`e2e`·`e2e:mock` script가 mode별 모든 spec 경로를 명령 자체에 고정한다. 따라서 plan에 기록된 `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts`는 focused 실행이 아니라 고정 4개 spec을 모두 실행한다. 이 32 tests 통과가 `P2-R10`의 server boundary 기대 16 tests 미달과 network retry E2E 부재를 가렸다. + +**근거** + +- 설정: `package.json:16-17`은 server 4개, mock 2개 spec을 script 인수로 고정한다. +- 실행: `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만 수집했다. +- 계획: `plan-task.md`의 `P2-R10` 기대 결과는 server boundary 4 projects / 16 tests 이상이다. +- 테스트 공백: `tests/e2e/server-mode-boundary.spec.ts:76-93`은 최초 network error만 확인하고 retry button을 누르지 않는다. network retry 재실패는 `src/app/App.test.tsx:405-430`에만 있다. +- 기록: `P2-R10` RED/GREEN E2E는 focused 명령이 32 tests를 실행한 결과를 boundary E2E 증거로 사용했다. + +**재현 또는 검증 절차** + +1. npm script를 통한 server boundary `--list`와 직접 Playwright boundary `--list`를 각각 실행한다. +2. 전자는 4 files / 32 tests, 후자는 1 file / 12 tests임을 확인한다. +3. boundary spec에서 network error test가 retry를 수행하는지 확인한다. +4. 요구되는 결과는 bare mode allowlist를 유지하면서 CLI file filter는 1개 file만 수집하고, boundary spec이 network retry 재실패까지 포함해 16 tests 이상을 실행하는 것이다. + +**영향** + +focused 회귀 명령이 느리고 범위를 오해하게 하며, 무관한 spec 통과 수가 Task별 완료 기준 미달을 숨긴다. network retry의 실제 browser no-fallback·fail-closed 경계가 unit test에만 의존한다. + +**권장 조치** + +script에는 mode env와 `playwright test`만 두고 mode별 exact allowlist는 단일 `playwright.config.ts`의 `testMatch`로 이동해 CLI filter와 교집합으로 동작하게 한다. network error → retry → network 재실패 E2E를 추가하고 focused 16, bare server 36, bare mock 28 tests를 각각 검증한다. + +**판정 기록** + +- 2026-07-27 — npm/direct list 결과와 boundary spec 시나리오 수를 `P2-R10` 기대 결과에 대조해 검증 증거 회귀로 확정했다. +- 2026-07-27 — `P2-R13`에서 npm scripts를 bare `playwright test`로 되돌리고 mode별 allowlist를 `playwright.config.ts` `testMatch`로 이동했다. focused list 1 file / 16 tests, network retry 재실패 E2E 16 tests 통과를 확인해 수정 완료로 판정했다. + +### REV-P2-017 — P2-R12 test 증거가 실제 test 구조·실행 수와 불일치 + +- **심각도:** Low +- **상태:** 수정 완료 +- **관련 요구사항:** Goal 실행형 계획의 완료 증거·Progress 정합성 규칙, 리뷰 종료 조건 +- **관련 계약:** 없음 +- **소유 Task:** R2.14, goal `P2-R14` + +**관찰 내용** + +`P2-R12`의 완료 체크는 최초 probe pending과 retry pending “두 test를 추가”했고 App test가 28건 이상이어야 한다고 기록한다. 실제 파일은 최초 pending 전용 test 1건을 추가하고 기존 404 retry test에 pending assertion을 보강한 27 tests 구조이며, 수정 검증 기록도 이를 “신규 pending status tests 2건”이라고 잘못 설명한다. + +**근거** + +- 코드: `src/app/App.test.tsx`의 `test(` 선언은 27건이다. +- 코드: 최초 pending은 `shows an accessible status while the initial protected route probe is pending` 전용 test로 추가됐고, retry pending은 기존 `retries a protected route 404 and reveals the shell only after the current retry succeeds` test 안에 assertion이 추가됐다. +- 실행: `npm run test:run -- src/app/App.test.tsx`는 1 file / 27 tests를 통과했다. +- 계획: `plan-task.md`의 `P2-R12` TDD 절차는 두 test 추가를 완료로 체크하고 기대 결과를 App 28 tests 이상으로 둔다. +- 기록: plan/review의 `P2-R11~P2-R13 수정 검증`은 “신규 pending status tests 2건”과 GREEN 27 tests를 동시에 기록한다. + +**재현 또는 검증 절차** + +1. `rg -c '^test\(' src/app/App.test.tsx`를 실행해 27을 확인한다. +2. `rg -n 'initial protected route probe is pending|retries a protected route 404' src/app/App.test.tsx`로 최초 전용 test 1건과 기존 retry test 보강 구조를 확인한다. +3. `P2-R12`의 TDD 체크·기대 결과와 `P2-R11~P2-R13 수정 검증`의 RED/GREEN 기록을 대조한다. + +**영향** + +제품 동작과 접근성 feedback은 unit·E2E로 유지되지만, 완료하지 않은 수치 기준을 완료로 체크하고 test case와 검증 시나리오를 혼용해 후속 reviewer가 TDD 증거를 오해하게 된다. + +**권장 조치** + +중복 retry test를 추가하지 않는다. `P2-R14` 문서 전용 정정으로 실제 구조를 “최초 전용 test 1건 추가 + 기존 retry test 보강, App 27 tests”로 명시하고, 이전 잘못된 표현과 정정 사유를 새 Progress에 보존한 뒤 문서 diff·검색 검증을 누적한다. + +**판정 기록** + +- 2026-07-27 — 현재 App test 선언 27건, test 이름·assertion 위치, focused 실행 27 tests와 plan/review의 28 tests·두 신규 test 주장을 직접 대조해 확정했다. +- 2026-07-27 — `P2-R14`에서 plan/review의 P2-R12 완료 증거를 최초 pending 전용 test 1건 + 기존 404 retry test 보강 + App 27 tests로 정정했다. App focused test와 stale 표현 검색·문서 diff 검사를 통과해 수정 완료로 판정했다. +- 2026-07-27 — 제품 동작은 server boundary 16 tests와 full unit 147 tests에서 유지됨을 확인해 심각도를 Low로 판정하고 문서 전용 `P2-R14`로 전환했다. + +### REV-P2-018 — plan 전환 절이 완료된 P2-R8~P2-R13을 미수정으로 표시 + +- **심각도:** Low +- **상태:** 수정 완료 +- **관련 요구사항:** Goal 실행형 계획의 완료 증거·Progress 정합성 규칙, 리뷰 종료 조건 +- **관련 계약:** 없음 +- **소유 Task:** R2.15, goal `P2-R15` + +**관찰 내용** + +review 요약과 각 발견 상세, 종료 판정은 `REV-P2-010`~`REV-P2-016` 및 `P2-R8`~`P2-R13`을 모두 수정 완료로 표시한다. 그러나 같은 문서의 §7은 3차 회귀 Task `P2-R8`~`P2-R10`과 4차 회귀 Task `P2-R11`~`P2-R13`을 각각 “아직 수정하지 않았다”고 현재형으로 표시한다. + +**근거** + +- 문서: review §5의 `REV-P2-010`~`REV-P2-016` 상태는 모두 `수정 완료`다. +- 문서: review §6의 각 판정 기록과 §9의 `P2-R9`, `P2-R10`, `P2-R11~P2-R13` 수정 검증은 실제 수정·검증 완료를 기록한다. +- 문서: review §7은 3차·4차 확정 항목을 현재도 “아직 수정하지 않았다”고 표시한다. +- 문서: review §8은 열린 확정 항목 없음과 코드·문서 정합성 충족을 결론으로 표시한다. + +**재현 또는 검증 절차** + +1. `rg -n '아직 수정하지 않았다|REV-P2-01[0-6]|P2-R(8|9|10|11|12|13)' docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md`를 실행한다. +2. §5·§6·§8의 수정 완료 상태와 §7의 미수정 표현 두 곳을 대조한다. +3. §9의 과거 시점 “남은 항목”은 이력 보존 대상이고, §7은 현재 plan 전환 상태를 설명하는 절임을 구분한다. + +**영향** + +제품 동작과 검증 결과에는 영향이 없지만, Phase 3 실행자나 reviewer가 `P2-R8`~`P2-R13`을 다시 실행해야 하는 열린 Task로 오해할 수 있고 리뷰 종료 판정의 신뢰도가 낮아진다. + +**권장 조치** + +`P2-R15` 문서 전용 정정으로 review §7의 두 현재 상태 문장만 실제 수정 완료 상태로 바꾼다. §9의 당시 남은 항목과 과거 검증 기록은 삭제하지 않고, stale 문자열 검색과 문서 diff 검사를 누적한다. + +**판정 기록** + +- 2026-07-27 — review §5·§6·§7·§8과 실제 `P2-R8`~`P2-R13` 수정 검증 기록을 대조해 현재 상태 모순 2건을 확인하고 Low로 확정했다. +- 2026-07-27 — 제품 코드·test·mode 경계는 full unit 147, server E2E 36, mock E2E 28 tests에서 유지돼 문서 전용 `P2-R15`로 전환했다. +- 2026-07-27 — `P2-R15`에서 review §7의 3차·4차 회귀 Task 설명을 수정 완료 상태로 정정했다. 과거 검증 기록은 보존하고 stale 현재 상태 검색과 문서 diff 검사를 통과해 수정 완료로 판정했다. + +### REV-P2-019 — Phase 2 현재 상태·최신 Progress가 P2-R15 완료와 불일치 + +- **심각도:** Low +- **상태:** 수정 완료 +- **관련 요구사항:** Goal 실행형 계획의 현재 상태·Progress 누적 규칙, 리뷰 종료 조건 +- **관련 계약:** 없음 +- **소유 Task:** R2.16, goal `P2-R16` + +**관찰 내용** + +`P2-R15` Task 본문과 review는 수정 완료 및 열린 확정 항목 없음을 표시한다. 그러나 plan의 Phase 2 상단 현재 상태는 최초 추가 당시 설명에 머물고, 하단 최신 Progress는 6차 재검증의 `P2-R15` 수정 필요 상태로 끝난다. + +**근거** + +- 문서: plan Phase 2 상단 현재 상태는 완료 범위와 Phase 3 진행 가능 여부를 표시하지 않는다. +- 문서: plan의 `P2-R15` 체크와 inline 수정 검증은 모두 완료됐다. +- 문서: plan 하단 마지막 Progress는 `P2-R15`를 남은 항목으로 표시한다. +- 문서: review §5·§6·§7·§8·§9는 `REV-P2-018`, `P2-R15` 수정 완료와 열린 항목 없음을 표시한다. + +**재현 또는 검증 절차** + +1. Phase 2 완료 상태 exact 검색을 실행해 no match와 exit 1을 확인한다. +2. `tail -n 16 docs/20260725_AI캐릭터관리자웹/plan-task.md`에서 마지막 Progress가 `P2-R15` 수정 필요 상태인지 확인한다. +3. 같은 문서의 `P2-R15` 체크·inline 검증 및 review 종료 판정과 대조한다. + +**영향** + +제품 동작에는 영향이 없지만 Phase 3 실행자가 완료된 `P2-R15`를 열린 선행 작업으로 오해하거나 Phase 2 완료 여부를 다시 판정해야 한다. + +**권장 조치** + +`P2-R16` 문서 전용 정정으로 Phase 2 상단 현재 상태를 실제 완료 범위와 Phase 3 진행 가능 상태로 갱신하고, 6차 당시 남은 항목은 보존한 채 하단에 최신 수정 검증을 누적한다. + +**판정 기록** + +- 2026-07-27 — Phase 2 상단 상태, `P2-R15` Task·review 완료 상태와 하단 최신 Progress를 대조해 현재 상태 모순을 Low로 확정했다. +- 2026-07-27 — 제품 코드·test·설정 변경이 없는 문서 정합성 문제이므로 문서 전용 `P2-R16`으로 전환했다. +- 2026-07-27 — `P2-R16`에서 Phase 2 상단 현재 상태와 하단 최신 Progress를 실제 완료 상태로 정렬하고 문서 검색·전체 Phase 2 Gate를 통과해 수정 완료로 판정했다. + +## 7. 확정 항목의 plan·goal 전환 + +1차 확정 5건은 기존 완료 체크를 되돌리지 않고 `plan-task.md`의 Phase 2에 다음 회귀 수정 Task로 추가했다. + +### Task R2.1 — 보호 route 검증 실패 시 fail-closed 복구 + +- 연결 review: `REV-P2-001` +- goal: `P2-R1` +- 핵심 완료 증거: 404·network·새 session pending에서 보호 shell 비노출, shell 밖 오류 UI, App/server/auth E2E와 P2-GATE +- 범위 밖: 인증/session 아키텍처 교체, Phase 3 구현 + +### Task R2.2 — Mock Preview API origin·JWT 오류 계약 복구 + +- 연결 review: `REV-P2-002`, `REV-P2-003` +- goal: `P2-R2` +- 핵심 완료 증거: exact API origin, invalid·revoked JWT 401, 정상 auth preview 회귀 없음, focused handler test·mock E2E·P2-GATE +- 범위 밖: 계약 미제공 DTO·role 의미 추정과 도메인 fixture + +### Task R2.3 — Phase 번호 표시·구현 문서 정합성 복구 + +- 연결 review: `REV-P2-004`, `REV-P2-005` +- goal: `P2-R3` +- 핵심 완료 증거: Phase 3 사용자 문구, 실제 Files, 하단 Phase 2 Progress, 문서/App focused test와 diff check +- 범위 밖: Phase 3 기능 구현과 기존 완료 기록 삭제 + +2차 재검증의 새 확정 4건도 `plan-task.md`에 `R2.4`~`R2.7` 회귀 Task로 전환했고 2026-07-27 수정·검증을 완료했다. + +### Task R2.4 — 동일 세션 재진입 fail-closed 복구 + +- 연결 review: `REV-P2-006` +- goal: `P2-R4` +- 핵심 완료 증거: 최초 성공 뒤 동일 session 재진입 pending·404·network·403에서 보호 shell 비노출, focused App test·server boundary E2E·P2 Gate +- 범위 밖: 인증/session 저장 방식 전면 교체, Phase 3 구현 + +### Task R2.5 — Auth fixture status·media type 계약 복구 + +- 연결 review: `REV-P2-007` +- goal: `P2-R5` +- 핵심 완료 증거: 비ADMIN logout 403, invalid·revoked 401 유지, non-JSON login 415, 정상 login/logout 회귀 없음 +- 범위 밖: 제공되지 않은 login credential 정책과 회원 role 의미 확장 + +### Task R2.6 — 모든 mock route state의 지속 안내 복구 + +- 연결 review: `REV-P2-008` +- goal: `P2-R6` +- 핵심 완료 증거: login·성공 shell·403·404/network에서 banner 표시, server mode 미표시, 접근성·320px 회귀 없음 +- 범위 밖: 오류 화면 디자인 개편 + +### Task R2.7 — Mode별 bare E2E 실행 경계 복구 + +- 연결 review: `REV-P2-009` +- goal: `P2-R7` +- 핵심 완료 증거: bare `npm run e2e`와 `npm run e2e:mock`이 각 mode 유효 spec만 실행해 exit 0, 목록·실행 contract test +- 범위 밖: Playwright config 복제와 CI 전면 재구성 + +3차 재검증의 새 확정 4건은 `plan-task.md`에 `R2.8`~`R2.10` 회귀 Task로 전환했으며 2026-07-27 수정·검증을 완료했다. + +### Task R2.8 — Phase 2 완료 문서 추적성 복구 + +- 연결 review: `REV-P2-010` +- goal: `P2-R8` +- 핵심 완료 증거: Phase 2 Files, 완료 Task Files·Interfaces, review 범위·경로 수·종료 판정과 최신 검증 기록 일치 +- 범위 밖: 애플리케이션 코드·test·설정 변경과 기존 기록 삭제 + +### Task R2.9 — Mock Preview 모바일 메뉴의 반응형·inert 경계 복구 + +- 연결 review: `REV-P2-011`, `REV-P2-012` +- goal: `P2-R9` +- 핵심 완료 증거: menu open background에 banner 포함, `lg` 전환 시 menu·inert·aria-hidden 해제, hidden trigger focus 복귀 없음, App/mock/accessibility E2E와 P2 Gate +- 범위 밖: Admin shell 전면 교체, Phase 3 UI와 신규 breakpoint 체계 + +### Task R2.10 — 보호 route 오류의 fail-closed 재시도 복구 + +- 연결 review: `REV-P2-013` +- goal: `P2-R10` +- 핵심 완료 증거: 404·network 오류의 수동 retry, 재시도 비성공 중 shell·logout 비노출, 성공한 현재 attempt 뒤 shell 표시, App/server boundary E2E와 P2 Gate +- 범위 밖: 자동 retry·mock fallback, Phase 3 목록 오류 UI와 API client 전면 교체 + +4차 재검증의 새 확정 3건은 `plan-task.md`에 `R2.11`~`R2.13` 회귀 Task로 전환했으며 2026-07-27 수정·검증을 완료했다. + +### Task R2.11 — Phase 2 working tree 전체 경로 집계 복구 + +- 연결 review: `REV-P2-014` +- goal: `P2-R11` +- 핵심 완료 증거: tracked 37개 + untracked 1개 = working tree 38개 항목과 plan/review 현재 범위 일치, untracked 포함 검증 명령 +- 범위 밖: 애플리케이션 코드·test·설정 변경과 과거 tracked 실행 기록 삭제 + +### Task R2.12 — 보호 route probe 대기 상태의 접근 가능한 피드백 복구 + +- 연결 review: `REV-P2-015` +- goal: `P2-R12` +- 핵심 완료 증거: 최초·retry pending의 visible live status, 빈 root 0건, 보호 shell·logout 비노출, App/server boundary E2E와 P2 Gate +- 범위 밖: 보호 shell skeleton 선노출, 자동 retry·mock fallback과 router 전면 교체 + +### Task R2.13 — Mode별 focused E2E 필터와 network retry 증거 복구 + +- 연결 review: `REV-P2-016` +- goal: `P2-R13` +- 핵심 완료 증거: bare mode allowlist 유지, CLI file filter 1 file 수집, network retry 재실패 E2E, focused·bare E2E와 P2 Gate +- 범위 밖: Playwright config 복제와 project matrix·CI 전면 재구성 + +5차 재검증의 새 확정 Low 1건은 `plan-task.md`에 `R2.14` 회귀 Task로 전환했으며 2026-07-27 수정·검증을 완료했다. + +### Task R2.14 — P2-R12 test 증거 정합성 복구 + +- 연결 review: `REV-P2-017` +- goal: `P2-R14` +- 핵심 완료 증거: 최초 pending 전용 test 1건 + 기존 retry test 보강 + App 27 tests라는 실제 구조와 plan/review 기록 일치, 문서 diff·검색 검증 +- 범위 밖: 애플리케이션 코드·test·설정 변경, 중복 retry test 추가와 기존 실행 이력 삭제 + +6차 재검증의 새 확정 Low 1건은 `plan-task.md`에 `R2.15` 회귀 Task로 전환했으며 2026-07-27 수정·검증을 완료했다. + +### Task R2.15 — review plan 전환 절의 현재 수정 상태 정합성 복구 + +- 연결 review: `REV-P2-018` +- goal: `P2-R15` +- 핵심 완료 증거: review §7의 `P2-R8`~`P2-R13` 상태가 §5·§6·§8과 일치, 과거 이력 보존, stale 현재 상태 검색·문서 diff 검사 +- 범위 밖: 애플리케이션 코드·test·설정 변경, 과거 남은 항목과 검증 기록 삭제 + +7차 재검증의 새 확정 Low 1건은 `plan-task.md`에 `R2.16` 회귀 Task로 전환했으며 2026-07-27 수정·검증을 완료했다. + +### Task R2.16 — Phase 2 현재 상태·하단 Progress 정합성 복구 + +- 연결 review: `REV-P2-019` +- goal: `P2-R16` +- 핵심 완료 증거: Phase 2 상단 완료 상태, 과거 6차 기록 보존, 최신 Progress의 `P2-R15`·`P2-R16` 완료와 Phase 3 진행 가능 판정 일치 +- 범위 밖: 애플리케이션 코드·test·설정 변경, 과거 기록 삭제와 Phase 3 구현 + +### create_goal objective 초안 + +```text +[P2-R1|P2-R2|P2-R3]의 연결된 확정 review 항목을 수정하고 회귀를 방지한다. +plan-task.md에 추가된 해당 회귀 수정 Task 하나만 수행한다. +실패 재현, 최소 수정, focused test, 관련 E2E, P2-GATE와 누적 기록이 모두 끝나기 전에는 complete로 표시하지 않는다. +관련 없는 리팩터링, Phase 3 기능 구현과 계약 추정은 범위 밖이다. +``` + +2차 초안 objective: + +```text +[P2-R4|P2-R5|P2-R6|P2-R7]의 연결 review 항목 하나를 수정하고 회귀를 방지한다. +plan-task.md에 신규 회귀 Task로 전환된 해당 범위만 수행한다. +실패 재현, 최소 수정, focused test, 관련 mode E2E와 P2-GATE 기록이 끝나기 전에는 complete로 표시하지 않는다. +Phase 3 기능, 계약 추정과 관련 없는 리팩터링은 범위 밖이다. +``` + +3차 초안 objective: + +```text +[P2-R8|P2-R9|P2-R10]의 연결 review 항목을 수정하고 Phase 2 완료 문서, mobile menu 접근성 회귀 또는 보호 route 오류 복구를 완료한다. +plan-task.md에 신규 회귀 Task로 전환된 해당 범위만 수행한다. +TDD 예외 대체 검증 또는 RED/GREEN/REFACTOR, focused test, 관련 mode E2E와 P2-GATE 기록이 끝나기 전에는 complete로 표시하지 않는다. +Phase 3 기능, 기존 기록 삭제와 Admin shell 전면 재구성은 범위 밖이다. +``` + +4차 초안 objective: + +```text +[P2-R11|P2-R12|P2-R13]의 연결 review 항목 하나를 수정하고 Phase 2 working tree 추적성, 보호 route pending 피드백 또는 focused E2E 증거를 복구한다. +plan-task.md에 신규 회귀 Task로 전환된 해당 범위만 수행한다. +TDD 예외 대체 검증 또는 RED/GREEN/REFACTOR, focused test, 관련 mode E2E와 P2-GATE 기록이 끝나기 전에는 complete로 표시하지 않는다. +Phase 3 기능, 자동 fallback과 Playwright config 복제·CI 전면 재구성은 범위 밖이다. +``` + +5차 초안 objective: + +```text +P2-R14의 연결 review 항목을 수정하고 P2-R12 test 증거의 문서 정합성을 복구한다. +plan-task.md에 신규 회귀 Task로 전환된 문서 정정 범위만 수행한다. +실제 App test 구조·focused 결과 대조, plan/review 정정 기록, 문서 검색·diff 검증이 끝나기 전에는 complete로 표시하지 않는다. +애플리케이션 코드·test·설정 변경, 중복 test 추가와 기존 실행 이력 삭제는 범위 밖이다. +``` + +6차 초안 objective: + +```text +P2-R15의 연결 review 항목을 수정하고 review plan 전환 절의 현재 상태 정합성을 복구한다. +plan-task.md에 신규 회귀 Task로 전환된 문서 정정 범위만 수행한다. +review §5·§6·§7·§8 대조, 과거 이력 보존, stale 문자열 검색과 문서 diff 검증이 끝나기 전에는 complete로 표시하지 않는다. +애플리케이션 코드·test·설정 변경과 과거 검증 기록 삭제는 범위 밖이다. +``` + +7차 초안 objective: + +```text +P2-R16의 연결 review 항목을 수정하고 Phase 2 현재 상태와 하단 최신 Progress의 정합성을 복구한다. +plan-task.md에 신규 회귀 Task로 전환된 문서 정정 범위만 수행한다. +완료 상태 exact 검색, 최신 Progress 대조, 과거 이력 보존, 문서 diff와 Phase 2 Gate 검증이 끝나기 전에는 complete로 표시하지 않는다. +애플리케이션 코드·test·설정 변경과 Phase 3 구현은 범위 밖이다. +``` + +## 8. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +| --- | --- | --- | +| 리뷰 범위 전체 확인 | 충족 | Phase 2 tracked diff 37개 + untracked 1개 = working tree 38개 항목, 관련 문서·unit·E2E·build와 mock browser runtime 경계를 대조 | +| 후보 항목 판정 완료 | 충족 | 1~7차 확정 19건 모두 수정 완료, 후보·오탐·보류 0건 | +| 확정 항목 plan 반영 | 충족 | `REV-P2-019`를 `P2-R16` 회귀 Task로 전환하고 수정 완료함 | +| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 항목 없음 | +| 검증 명령과 결과 기록 | 충족 | focused unit, focused server/mock list, server boundary 16, mock preview 24, typecheck/lint, working tree 집계와 문서 diff 검사를 기록 | +| Blocker·High 확정 이슈 0건 | 충족 | `REV-P2-006`, `REV-P2-007` 수정 완료 | +| Medium 확정 이슈 0건 | 충족 | `REV-P2-015~016` 수정 완료 | +| Low 확정 이슈 0건 | 충족 | `REV-P2-019`, `P2-R16` 수정 완료 | +| 코드와 문서 정합성 | 충족 | Phase 2 상단 현재 상태·하단 최신 Progress와 review 종료 판정 일치 | + +**최종 결론:** Phase 2 구현·review·plan 정합성 충족, Phase 3 진행 가능 + +**남은 항목:** 현재 review의 열린 확정 항목 없음. mock 통과는 Phase 3의 실제 server integration 완료로 간주하지 않는다. + +## 9. 수정 후 검증 기록 + +기존 기록을 삭제하거나 덮어쓰지 않고 차수별로 누적한다. + +### 수정 검증 미수행 — 2026-07-27 + +- 무엇을: Phase 2 구현과 기존 test·설정은 수정하지 않고, 확정 5건의 review 문서와 `P2-R1~P2-R3` 회귀 Task만 작성했다. +- 왜: 이번 요청은 코드 리뷰·검증·기록 범위이며 확정 문제의 구현 수정은 별도 goal로 분리해야 한다. +- 어떻게: + - 수정 전 기준 검증은 4절의 unit, typecheck, lint, build, production guard, mock/server E2E와 handler 런타임 재현으로 완료했다. + - 수정 후 focused test와 P2-GATE는 아직 실행 대상이 아니다. +- 남은 항목: `REV-P2-001~005`, `P2-R1~P2-R3` 실행. + +### P2-R1~P2-R3 수정 검증 — 2026-07-27 + +- 무엇을: `REV-P2-001~005`를 `P2-R1~P2-R3` 범위에서 수정했다. 보호 route 404·network error는 shell 밖 오류 UI로 fail-closed 처리하고, mock handler는 exact API origin에만 매칭하며 invalid·revoked JWT logout은 401로 반환한다. Phase 2 placeholder 문구와 Files·Progress 기록도 실제 구현에 맞췄다. +- 왜: Phase 2 mock/server 경계가 실제 API 계약과 Phase 1 보호 route 경계를 우회하지 않게 하고, 완료 기록이 재현 가능한 파일·명령과 일치하게 하기 위해서다. +- 어떻게: + - RED: `npm run test:run -- src/app/App.test.tsx src/shared/mocks/__tests__/auth-handlers.test.ts` — App 2건, auth handler 2건이 기대대로 실패했다. + - 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 기록 불일치로 3 tests가 기대대로 실패했다. + - 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 통과. + - GREEN docs/App: `npm run test:run -- src/app/App.test.tsx src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 2 files / 21 tests 통과. + - 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 통과. + - Surface: `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts` — 4 projects / 12 tests 통과. `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts` — 4 projects / 20 tests 통과. `npm run e2e -- tests/e2e/auth.spec.ts tests/e2e/accessibility-shell.spec.ts` — 4 projects / 16 tests 통과. + - Final Gate: `npm run test:run` — 34 files / 135 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로 보완했다. +- 남은 항목: 현재 리뷰의 열린 Blocker·High·Low 항목 없음. Phase 3 이후 server integration은 별도 Phase 증거로 기록한다. + +### Reviewer gate 보강 — 2026-07-27 + +- 무엇을: reviewer gate에서 같은 ADMIN token을 재사용하는 logout → login 뒤 probe 실패 시 이전 `verifiedProtectedRouteToken`이 남아 보호 shell을 열 수 있다는 P2-R1 blocker를 확인하고 수정했다. +- 왜: mock preview는 logout 후 재login 때 같은 `mock-admin-jwt`를 재활성화하므로, fresh session 테스트만으로는 현재 session의 probe 성공 여부를 충분히 증명하지 못하기 때문이다. +- 어떻게: + - RED: `npm run test:run -- src/app/App.test.tsx` — `clears a previous protected route verification before reusing the same token after login` 1건이 보호 shell `main` 노출로 기대대로 실패했다. + - GREEN: 같은 command — 1 file / 19 tests 통과. session이 `null`이 되면 route error와 verified token을 초기화하도록 수정했다. + - Focused regression: `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 통과. + - Surface regression: `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts` — 4 projects / 12 tests 통과. `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts` — 4 projects / 20 tests 통과. +- Reviewer 재검토: 동일 reviewer가 delta를 재검토해 PASS를 반환했다. 세션 객체 기반 검증과 세션별 오류 귀속이 동일 token 재로그인 경로를 차단하고, 신규 blocker 없음으로 판정했다. +- 남은 항목: reviewer gate 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` 성공, `VITE_API_MODE=mock npm run build:prod` production guard 거부를 확인했다. + +### 2차 독립 재검증 — 2026-07-27 + +- 무엇을: `P2-R1~P2-R3` 수정이 반영된 현재 working tree를 다시 검토해 `REV-P2-006~009` High 2건·Medium 2건을 확정했다. 기존 `REV-P2-001~005`의 상세 상태는 1차 수정 검증 기록에 맞춰 `수정 완료`로 정정했다. +- 왜: 기존 reviewer gate가 logout → 새 session 재로그인과 지정 spec Gate만 다뤘기 때문에, 동일 session route 재진입·mock 오류 화면·auth media/status·bare mode 명령 경계가 검증되지 않았다. +- 어떻게: + - Focused: `npm run test:run -- src/shared/config src/shared/mocks src/shared/ui/__tests__/mock-mode-banner.test.tsx` — 7 files / 23 tests 통과. + - Full unit: `npm run test:run` — 34 files / 136 tests 통과. + - Static/build: `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod`, `git diff --check HEAD` — 모두 exit 0. + - Production boundary: `VITE_API_MODE=mock npm run build:prod` — 기대대로 exit 1 거부. worker 파일과 mock bootstrap 문자열은 production 산출물에서 0건. + - Filtered mock E2E: `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts tests/e2e/mock-mode-boundary.spec.ts` — 4 projects / 24 tests 통과. + - Filtered server regression: `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts tests/e2e/smoke.spec.ts tests/e2e/auth.spec.ts tests/e2e/accessibility-shell.spec.ts` — 4 projects / 32 tests 통과. + - Bare mock E2E: `npm run e2e:mock` — 56 tests 중 36 통과 / 20 실패. 두 mode의 `--list`는 동일한 6 files / 56 tests를 수집했다. + - Runtime: 동일 session 재진입 404에서 보호 `main`·logout 각 1건, mock 403 화면 banner 0건, 비ADMIN logout 401, `text/plain` login 200을 확인했다. +- 변경 범위: 이 review 문서만 누적 갱신했다. 코드·test·설정·`plan-task.md`는 변경하지 않았다. +- 남은 항목: `REV-P2-006~009`를 `P2-R4~P2-R7`로 전환하고 별도 수정 goal에서 처리해야 한다. + +### P2-R4~P2-R7 수정 검증 — 2026-07-27 + +- 무엇을: `REV-P2-006~009`를 `plan-task.md`의 `R2.4`~`R2.7`로 전환하고 수정했다. 동일 session route 재진입은 visit key로 fail-closed 처리하고, auth fixture는 비ADMIN logout 403·unsupported media type 415를 반환한다. Mock Preview banner는 모든 mock route state의 공통 frame에 배치했고, bare E2E script는 mode별 유효 spec만 실행한다. +- 왜: Phase 2 보호 route와 mock/server 경계가 성공 path뿐 아니라 재진입·오류·표준 실행 명령에서도 같은 계약을 유지해야 하기 때문이다. +- 어떻게: + - RED: `npm run test:run -- src/app/App.test.tsx` — 동일 session 재진입 404에서 보호 `main`이 남아 1 test가 실패했다. + - RED: `npm run test:run -- src/shared/mocks/__tests__/auth-handlers.test.ts` — 비ADMIN logout 401과 `text/plain` login 200으로 2 tests가 실패했다. + - RED: `npm run test:run -- src/app/App.test.tsx` — mock 403·보호 오류 화면의 `Mock Preview` status 부재로 2 tests가 실패했다. + - 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/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 통과. + - Full unit/static/build: `npm run test:run` — 34 files / 141 tests 통과. `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` 모두 exit 0. + - E2E list: `npm run e2e -- --list` — server 4 files / 32 tests, `npm run e2e:mock -- --list` — mock 2 files / 24 tests. + - Bare E2E: `npm run e2e` — 32 tests 통과, `npm run e2e:mock` — 24 tests 통과. + - Production/diff: `VITE_API_MODE=mock npm run build:prod` — 기대대로 exit 1, `test ! -e dist/mockServiceWorker.js`, `git diff --check HEAD` — exit 0. + - LSP/size: `src/app` 4 files / 0 diagnostics, `src/shared/mocks` 8 files / 0 diagnostics. `App.tsx`는 `ProtectedAdminShell` 분리 후 153 pure LOC, 새 shell은 131 pure LOC다. +- 남은 항목: reviewer gate blocker 없음. mock 통과는 후속 도메인 server integration 완료로 간주하지 않는다. + +### 3차 독립 재검증 — 2026-07-27 + +- 무엇을: `P2-R4`~`P2-R7` 수정 뒤 Phase 2 문서 추적성, actual Service Worker 응답, mobile menu open 상태의 background inert·desktop breakpoint 전환과 보호 route 오류 recovery를 재검토했다. +- 왜: 고정 viewport와 개별 banner/menu·오류 alert test만으로는 공통 `RouteFrame` 이동 뒤의 accessibility composition, responsive state 전환과 사용자의 fail-closed 복구를 증명하지 못하기 때문이다. +- 어떻게: + - `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 파일·bootstrap 문자열 0건. + - `npm run e2e` — 4 projects / 32 tests 통과. `npm run e2e:mock` — 4 projects / 24 tests 통과. + - Chromium one-off — login·AI Character probe 응답 `fromServiceWorker=true`; 320px menu open에서 banner inert 귀속 없음, 1,200px 전환 뒤 overlay `display:none`과 main inert·`aria-hidden=true` 잔존을 확인. + - 코드·test 대조 — `ProtectedRouteErrorPage`의 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건. +- 변경 범위: 이 review 문서와 `plan-task.md`에 `REV-P2-010~013`, `P2-R8`~`P2-R10` 판정·후속 계획만 추가했다. 애플리케이션 코드·test·설정은 변경하지 않았다. +- 남은 항목: `P2-R8`~`P2-R10`을 별도 goal로 실행하고 수정 후 검증 기록을 누적해야 한다. + +### P2-R9 수정 검증 — 2026-07-27 + +- 무엇을: `REV-P2-011`, `REV-P2-012`를 `P2-R9` 범위에서 수정했다. Mock Preview 성공 shell의 banner를 mobile menu background inert container 안으로 이동하고, `lg=1024px` viewport match에서 mobile menu state를 닫아 hidden overlay와 inert shell 잔존을 제거했다. +- 왜: mobile overlay가 열렸을 때 background banner가 보조기기 탐색에 남지 않아야 하며, responsive 전환 뒤 보이는 desktop shell이 pointer·keyboard·screen reader에서 즉시 사용 가능해야 하기 때문이다. +- 어떻게: + - RED unit: `npm run test:run -- src/app/App.test.tsx src/shared/ui/__tests__/mock-mode-banner.test.tsx` — 신규 App tests 2건이 기대대로 실패했다. 실패 핵심은 `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가 모두 기대대로 실패했다. 실패 핵심은 open state의 `bannerInert=false`, `bannerHidden=false`다. + - GREEN unit: 같은 focused unit command — 2 files / 26 tests 통과. + - GREEN e2e: 같은 mock e2e command — 28 tests 통과. 320px open state의 banner/main inert 경계와 1,024px·1,200px desktop 전환 뒤 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 통과했다. +- 남은 항목: `REV-P2-013`, `P2-R10` 보호 route 오류 retry 복구. + +### P2-R10 수정 검증 — 2026-07-27 + +- 무엇을: `REV-P2-013`을 `P2-R10` 범위에서 수정했다. 보호 route 오류 page에 native retry button을 추가하고 현재 session·route visit·retry attempt에 귀속된 결과만 사용해 성공 전 shell을 fail-closed로 유지했다. 정상 `ApiError`의 서버 message와 network 오류의 안전한 공통 안내를 구분했다. +- 왜: 일시적인 404·network 오류가 복구된 뒤 browser refresh 없이 재진입할 수 있어야 하고, stale 또는 실패한 retry가 보호 shell을 열어서는 안 되기 때문이다. +- 어떻게: + - 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. +- 남은 항목: 현재 리뷰의 열린 확정 항목 없음. mock 통과는 후속 도메인 server integration 완료로 간주하지 않는다. + +### 4차 독립 재검증 — 2026-07-27 + +- 무엇을: `P2-R8`~`P2-R10` 반영 뒤 working tree 전체 집계, 보호 route 최초·retry pending 상태와 mode별 focused E2E 실행 증거를 재검토했다. +- 왜: 통과한 자동화가 untracked 파일을 포함한 전체 범위, 300ms 이상 비동기 피드백과 Task가 지정한 단일 E2E spec 실행을 실제로 증명하는지 확인하기 위해서다. +- 어떻게: + - Focused unit: `npm run test:run -- src/app/App.test.tsx src/shared/ui/__tests__/mock-mode-banner.test.tsx` — 2 files / 28 tests 통과. + - Full unit/static/build: `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 변환. + - Production boundary: `VITE_API_MODE=mock npm run build:prod` — 기대대로 exit 1. `test ! -e dist/mockServiceWorker.js` — exit 0, production JS mock bootstrap 문자열 검색 — 기대한 no-match exit 1. + - Bare E2E: `npm run e2e` — 4 projects / 32 tests 통과. `npm run e2e:mock` — 4 projects / 28 tests 통과. + - Working tree: `git diff HEAD --name-only | wc -l` — tracked 37개, `git status --porcelain=v1 | wc -l` — 전체 38개, untracked `src/app/protected-admin-shell.tsx` 1개 확인. + - Focused list 대조: npm script 경유 server boundary list는 4 files / 32 tests, 직접 Playwright list는 1 file / 12 tests. `P2-R10` 기대 16 tests와 network retry E2E가 충족되지 않았다. + - Chromium one-off: local listen·browser sandbox 실패 뒤 권한 허용 재실행. 404 retry 성공 응답을 350ms 지연했을 때 `#root` child, `main`, `status`, `alert`가 모두 0개이고 active element가 `BODY`였으며 응답 뒤 shell·logout은 표시됐다. + - 문서 반영 검증: `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건 확인. +- 변경 범위: 이 review 문서와 `plan-task.md`에 `REV-P2-014~016`, `P2-R11`~`P2-R13` 판정·후속 계획만 추가했다. 애플리케이션 코드·test·설정은 변경하지 않았다. +- 남은 항목: `P2-R11`~`P2-R13`을 별도 goal로 실행하고 수정 후 검증 기록을 누적해야 한다. + +### 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. +- 남은 항목: Phase 2 review의 열린 확정 항목 없음. mock 통과는 후속 도메인 server integration 완료로 간주하지 않는다. + +### 5차 독립 재검증 — 2026-07-27 + +- 무엇을: `P2-R11`~`P2-R13` 반영 뒤 구현·test·plan·review의 완료 증거를 다시 대조해 `REV-P2-017` Low 1건을 확정하고 `P2-R14`로 전환했다. +- 왜: 자동 검증 통과뿐 아니라 완료 체크의 test case 수·구조와 review의 현재 수정 상태가 실제 결과와 일치하는지 확인하기 위해서다. +- 어떻게: + - Focused unit: `npm run test:run -- src/app/App.test.tsx` — 1 file / 27 tests 통과. `rg -c '^test\(' src/app/App.test.tsx` — 27건. 최초 pending 전용 test 1건과 기존 retry test의 pending assertion 보강을 확인했다. + - Mode/docs unit: `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 통과. + - 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과 `VITE_API_MODE=mock is only available during development` 확인. production 산출물의 worker 파일·mock bootstrap 문자열은 0건. + - Focused list/E2E: server boundary list 1 file / 16 tests, mock preview list 1 file / 24 tests. 최초 sandbox listen `EPERM` 뒤 허용된 로컬 실행으로 server boundary 16 tests, mock preview 24 tests 통과. + - Bare E2E: `npm run e2e` — 36 tests, `npm run e2e:mock` — 28 tests 통과. + - Working tree: tracked diff 37개, untracked `src/app/protected-admin-shell.tsx` 1개, 전체 status 38개 항목을 재확인했다. + - 문서 대조: `P2-R12`의 “두 test 추가”·App 28 tests 이상 완료 체크와 실제 27 tests 구조가 불일치함을 확인했다. +- 변경 범위: 이 review 문서와 `plan-task.md`에 `REV-P2-017`, `P2-R14` 판정·후속 계획만 추가했다. 애플리케이션 코드·test·설정은 변경하지 않았다. +- 남은 항목: `P2-R14`에서 실제 test 구조·개수를 plan/review에 정정하고 문서 검색·diff 검증을 누적해야 한다. + +### 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 case 수와 검증 시나리오 수를 혼동하면 후속 reviewer가 P2-R12의 실제 회귀 고정 범위를 잘못 판단할 수 있기 때문이다. +- 어떻게: + - 대체 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건을 확인했다. + - GREEN docs: plan/review의 P2-R12 TDD 절차·기대 결과·수정 검증 기록과 종료 판정을 실제 test 구조와 일치시켰다. + - 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·설정은 변경하지 않았다. + +### 6차 독립 재검증 — 2026-07-27 + +- 무엇을: `P2-R14` 반영 뒤 구현·test·production·mode별 E2E와 review 현재 상태를 대조해 `REV-P2-018` Low 1건을 확정했다. +- 왜: 완료된 `P2-R8`~`P2-R13`의 상태가 review 모든 현재 상태 절에서 같은 의미로 표시되는지 확인하기 위해서다. +- 어떻게: + - `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` — 기대대로 exit 1 거부. + - sandbox listen `EPERM` 후 로컬 실행 권한으로 `npm run e2e` 36 tests, `npm run e2e:mock` 28 tests 통과. + - Chromium one-off — mock API 응답 `fromServiceWorker=true`, 320px mobile menu·logout 60px, menu link 선택 뒤 menu·inert 잔존 0건. + - 문서 대조 — review §7의 stale “아직 수정하지 않았다” 현재 상태 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 현재 상태를 정정하고 stale 문자열 검색·문서 diff 검증을 누적해야 한다. + +### P2-R15 수정 검증 — 2026-07-27 + +- 무엇을: `REV-P2-018`을 `P2-R15` 범위에서 수정했다. review §7의 `P2-R8`~`P2-R10`, `P2-R11`~`P2-R13` 전환 설명을 현재 수정 완료 상태로 정정했다. +- 왜: review 요약·상세·종료 판정은 이미 수정 완료로 표시하는데 plan 전환 절만 미수정 현재형으로 남아 Phase 3 실행자가 열린 Task로 오해할 수 있었기 때문이다. +- 어떻게: + - 대체 RED: review §7 stale 현재 상태 검색 — 2건이 기존 stale 현재 상태로 확인됐다. + - GREEN docs: review §7의 두 문장을 수정 완료 상태로 정정하고, `REV-P2-018` 상태·발견 요약·종료 판정을 수정 완료로 맞췄다. §9의 당시 남은 항목과 과거 검증 기록은 삭제하지 않았다. + - 검증: review §7 stale 현재 상태 검색 — no matches. `git diff --check -- docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2.md` — exit 0. +- 남은 항목: 현재 review의 열린 확정 항목 없음. 애플리케이션 코드·test·설정은 변경하지 않았다. + +### 7차 독립 재검증 — 2026-07-27 + +- 무엇을: `P2-R15` 반영 뒤 Phase 2 상단 현재 상태, Task 본문, review 종료 판정과 하단 최신 Progress를 대조해 `REV-P2-019` Low 1건을 확정했다. +- 왜: Phase 3 시작 판정은 과거 검증 이력이 아니라 plan의 현재 상태와 최신 누적 Progress를 소비하므로 두 위치가 완료 사실을 함께 표시해야 하기 때문이다. +- 어떻게: + - 대체 RED 완료 상태 검색 — 기대한 Phase 2 완료·Phase 3 진행 가능 exact 상태가 없어 exit 1. + - 대체 RED 최신 Progress 검색 — 문서 끝에 `P2-R15` 수정 완료와 열린 항목 없음 기록이 없어 exit 1. + - `P2-R15` Task 체크·inline 검증과 review §5·§6·§7·§8·§9는 수정 완료임을 확인했다. + - `git diff --check HEAD` — review 반영 전 exit 0. +- 변경 범위: 이 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`를 `P2-R16` 범위에서 수정했다. Phase 2 상단 현재 상태와 하단 최신 Progress를 `P2-R16`까지 완료 및 Phase 3 진행 가능 상태로 정렬했다. +- 왜: 완료 상태를 소비하는 Phase 3 실행자가 과거 6차 남은 항목을 현재 열린 Task로 오해하지 않도록 현재 상태와 누적 이력을 구분해야 하기 때문이다. +- 어떻게: + - 대체 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` — 기대한 guard로 exit 1. production worker 파일과 mock bootstrap 문자열은 0건. + - E2E: `npm run e2e` — 4 projects / 36 tests 통과. `npm run e2e:mock` — 4 projects / 28 tests 통과. + - Diff: plan/review 대상 `git diff --check` — exit 0. +- 남은 항목: 현재 review의 열린 확정 항목 없음. `P2-GATE`와 모든 Phase 2 회귀 수정이 완료돼 Phase 3 진행 가능. mock 통과는 Phase 3의 실제 server integration 완료로 간주하지 않는다. diff --git a/docs/agent-guide/documentation.md b/docs/agent-guide/documentation.md index 6c88c98..8f70fcc 100644 --- a/docs/agent-guide/documentation.md +++ b/docs/agent-guide/documentation.md @@ -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)을 따른다. diff --git a/docs/agent-guide/environment.md b/docs/agent-guide/environment.md index f9d7f03..c23657c 100644 --- a/docs/agent-guide/environment.md +++ b/docs/agent-guide/environment.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로 자동 전환하지 않는다. diff --git a/docs/agent-guide/goal-plan.md b/docs/agent-guide/goal-plan.md index dc3fc57..b3db4ee 100644 --- a/docs/agent-guide/goal-plan.md +++ b/docs/agent-guide/goal-plan.md @@ -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-T`를 사용한다. Phase Gate는 `P-GATE`, 완료 범위의 회귀 수정은 `P-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, 미정 값, 추정 계약이 없다. - 변경 금지 항목과 중단 규칙이 명시돼 있다. diff --git a/docs/agent-guide/review.md b/docs/agent-guide/review.md index 29904be..168905b 100644 --- a/docs/agent-guide/review.md +++ b/docs/agent-guide/review.md @@ -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. 리뷰 수행 원칙 diff --git a/docs/agent-guide/scripts.md b/docs/agent-guide/scripts.md index aeae21e..ba786f6 100644 --- a/docs/agent-guide/scripts.md +++ b/docs/agent-guide/scripts.md @@ -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` diff --git a/docs/sample/sample-plan-task.md b/docs/sample/sample-plan-task.md index 4ad895e..20dddfc 100644 --- a/docs/sample/sample-plan-task.md +++ b/docs/sample/sample-plan-task.md @@ -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를 작성한다. -- [ ] ``을 실행해 의도한 assertion 실패를 확인한다. -- [ ] test를 통과시키는 최소 구현을 작성한다. -- [ ] ``을 다시 실행해 성공을 확인한다. -- [ ] 관련 typecheck·lint를 실행하고 실제 결과를 Progress에 기록한다. +**TDD 절차:** + +- [ ] **RED: 실패 테스트 작성/실패 확인** — `<정확한 test 파일 경로>`에 `<검증할 동작>`의 가장 작은 실패 test를 작성하고 `` 실행 시 `<의도한 assertion 메시지>`로 실패하는지 확인한다. +- [ ] **GREEN: 최소 구현/통과 확인** — `<정확한 구현 파일 경로>`에 test를 통과시키는 최소 구현만 작성하고 같은 명령이 `exit 0`, ``인지 확인한다. +- [ ] **REFACTOR: 정리/회귀 확인** — 중복·이름·구조만 정리한 뒤 ``과 `<관련 회귀 test 명령>`이 모두 `exit 0`인지 확인한다. + +**검증 기준:** + +- **실행 명령:** ``, `<관련 회귀 test 명령>`, ``, `` +- **기대 결과:** 모든 명령 `exit 0`, `` 통과, type·lint 오류 0건. +- **수동 확인:** ``에서 `<사용자 동작>` 시 `<관찰 가능한 상태 변화>`가 발생하고 금지 동작은 발생하지 않는다. + +- [ ] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다. #### Task 1.2 `<두 번째 독립 결과>` @@ -105,10 +113,19 @@ - Consumes: `` - Produces: `` -- [ ] 가장 작은 실패 test를 작성하고 의도한 실패를 확인한다. -- [ ] 최소 구현으로 focused test를 통과시킨다. -- [ ] 오류·loading·empty·success와 접근성 상태를 검증한다. -- [ ] 관련 test·typecheck·lint 결과를 Progress에 기록한다. +**TDD 절차:** + +- [ ] **RED: 실패 테스트 작성/실패 확인** — `<정확한 test 파일 경로>`에 `<오류·loading·empty·success 중 이 Task가 소유한 상태>`와 `<사용자 action>`의 실패 test를 작성하고 ``이 의도한 이유로 실패하는지 확인한다. +- [ ] **GREEN: 최소 구현/통과 확인** — 필요한 상태와 action만 최소 구현하고 같은 명령이 `exit 0`, ``인지 확인한다. +- [ ] **REFACTOR: 정리/회귀 확인** — 상태 분기와 접근성 이름을 정리한 뒤 ``과 ``이 모두 통과하는지 확인한다. + +**검증 기준:** + +- **실행 명령:** ``, ``, ``, `` +- **기대 결과:** 모든 명령 `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 @@ -134,9 +153,9 @@ ``` -**Expected:** `<0 exit code, test 수, 사용자가 완료할 흐름, 금지 요청 0회 등 관찰 가능한 결과>` +**기대 결과:** `<모든 명령 exit 0, test 수, 사용자가 완료할 흐름, 금지 요청 0회 등 관찰 가능한 결과>` -수동 검증: +**수동 확인:** - [ ] `` - [ ] `` @@ -160,11 +179,41 @@ - **완료 증거:** 계약 제공 또는 제외 결정이 기준 문서에 일치하고 구현 map이 기록됨. - **범위 밖:** 계약을 추정한 production adapter와 실제 기능 구현. +**Files:** + +- Modify: `<대상 prd.md 경로>` +- Modify: `<대상 api-contract.md 경로>` +- Modify: `<대상 plan-task.md 경로>` +- Test: 없음 — 이 Task의 산출물은 실행 코드가 아니라 확정된 계약과 구현 map이다. + +**Interfaces:** + +- Consumes: `` +- Produces: `` + +**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 `` **Goal 실행 `P2-T2`:** `<사용자가 직접 확인할 수 있는 흐름을 한 문장으로 작성한다.>` @@ -179,11 +228,24 @@ - Modify: `<정확한 파일 경로>` - Test: `<정확한 test 파일 경로>` -- [ ] contract와 serializer의 실패 test를 먼저 작성한다. -- [ ] UI 상태와 사용자 action의 실패 test를 먼저 작성한다. -- [ ] 최소 구현으로 focused test를 통과시킨다. -- [ ] 관련 integration/E2E와 공통 품질 명령을 실행한다. -- [ ] 실제 결과와 남은 항목을 Progress에 기록한다. +**Interfaces:** + +- Consumes: `` +- Produces: `` + +**TDD 절차:** + +- [ ] **RED: 실패 테스트 작성/실패 확인** — ``과 ``에 serializer, 상태와 사용자 action의 가장 작은 실패 test를 작성하고 ``이 `<의도한 실패 이유>`로 실패하는지 확인한다. +- [ ] **GREEN: 최소 구현/통과 확인** — P2-T1의 확정 계약만 사용하는 최소 adapter·UI를 구현하고 같은 명령이 `exit 0`, ``인지 확인한다. +- [ ] **REFACTOR: 정리/회귀 확인** — contract 변환과 UI 상태 책임을 정리한 뒤 ``과 `<관련 integration/E2E 명령>`이 모두 통과하는지 확인한다. + +**검증 기준:** + +- **실행 명령:** ``, `<관련 integration/E2E 명령>`, ``, ``, `` +- **기대 결과:** 모든 명령 `exit 0`, `` 통과, 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 ``` -**Expected:** `<사용자 journey, 오류 처리, request payload와 금지 동작을 포함한 최종 결과>` +**기대 결과:** `<모든 명령 exit 0, test 수, 사용자 journey, 오류 처리, request payload와 금지 동작을 포함한 최종 결과>` + +**수동 확인:** + +- [ ] `<지원 viewport에서 success·loading·empty·error·read-only 흐름>` +- [ ] `` +- [ ] `` ## 실행 순서와 의존성 @@ -252,6 +322,11 @@ P1-T1 → P1-T2 → P1-GATE → P2-T1 → P2-T2 → P2-GATE - 상태: 진행 중 / 완료 / 차단 감사 중 / 차단 - 무엇을: `<이번 실행에서 완료한 체크박스와 산출물>` - 왜: `` +- TDD: `<구현 Task는 RED/GREEN/REFACTOR만, 예외 Task는 예외 항목만 남긴다.>` + - RED: `<실패 test 명령>` — `<의도한 실패, exit code와 assertion>` + - GREEN: `<같은 focused test 명령>` — `<성공, exit code와 test 수>` + - REFACTOR: `` — `<성공/실패, exit code와 test 수>` + - 예외 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 문서를 만든다. ## 최종 보고 형식 diff --git a/docs/sample/sample-review.md b/docs/sample/sample-review.md index 3fd1f9b..e35064c 100644 --- a/docs/sample/sample-review.md +++ b/docs/sample/sample-review.md @@ -1,6 +1,6 @@ # 코드 리뷰 보고서 샘플 -> 이 문서는 완료된 Phase를 다시 검토할 때 사용하는 템플릿이다. 리뷰에서 발견한 후보를 먼저 검증하고, **확정**된 항목만 `plan-task.md`의 회귀 수정 Task와 goal로 전환한다. 기존 완료 체크박스와 검증 기록은 삭제하거나 되돌리지 않는다. +> 이 문서는 완료된 Phase를 다시 검토할 때 사용하는 템플릿이다. 실제 리뷰 문서는 대상 `prd.md`·`plan-task.md` 디렉터리 아래 `reviews/`에 둔다. 리뷰에서 발견한 후보를 먼저 검증하고, **확정**된 항목만 `plan-task.md`의 회귀 수정 Task와 goal로 전환한다. 기존 완료 체크박스와 검증 기록은 삭제하거나 되돌리지 않는다. ## 1. 리뷰 정보 diff --git a/package.json b/package.json index 4156241..843b17e 100644 --- a/package.json +++ b/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" + ] } } diff --git a/playwright.config.ts b/playwright.config.ts index f0faed4..18462c0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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"] } }, diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 0000000..0c970ef --- /dev/null +++ b/public/mockServiceWorker.js @@ -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} + */ +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} + */ +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} transferrables + * @returns {Promise} + */ +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, + } +} diff --git a/src/app/App.test.tsx b/src/app/App.test.tsx index 170691c..3924488 100644 --- a/src/app/App.test.tsx +++ b/src/app/App.test.tsx @@ -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(); - 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(); + + // 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(); + + // 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(); + 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(); + + // 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(); + 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(); + 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(); 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(); + + // 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(); + + 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(); + + // 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(); + + 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(); + 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(); + 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(); + + 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(); + + 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")); diff --git a/src/app/App.tsx b/src/app/App.tsx index d99af47..793063d 100644 --- a/src/app/App.tsx +++ b/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["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 ( - { - event.preventDefault(); - navigateTo(routePaths.aiCharacters); - }} - > - AI 캐릭터 - - ); -} - -function ProtectedAdminShell({ routeError }: { readonly routeError: string | null }) { - const auth = useAuthSession(); - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - const menuButtonRef = useRef(null); - const closeButtonRef = useRef(null); - const mobileMenuRef = useRef(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) { - if (event.key !== "Tab") { - return; - } - - const focusableElements = Array.from(mobileMenuRef.current?.querySelectorAll(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 ( -
-
- - 본문으로 건너뛰기 - - -
-
-
- - -
- -
-
- -
-
+
+
+

+ {message} +

+
- {isMobileMenuOpen ? ( -
- -
- ) : null} -
+ ); } -function AppShell() { +function RouteFrame({ apiMode, children }: { readonly apiMode: ApiMode; readonly children: ReactNode }) { + return ( + <> + + {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(null); - const [verifiedProtectedRouteToken, setVerifiedProtectedRouteToken] = useState(null); + const [routeError, setRouteError] = useState(null); + const [verifiedProtectedRouteSession, setVerifiedProtectedRouteSession] = useState(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,23 +113,30 @@ 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 ( - { - await auth.login(credentials); - navigateTo(routePaths.aiCharacters); - }} - /> + + { + await auth.login(credentials); + navigateTo(routePaths.aiCharacters); + }} + /> + ); } @@ -229,18 +144,53 @@ function AppShell() { return null; } - if (location === routePaths.accessDenied) { - return ; + if (location.path === routePaths.accessDenied) { + return ( + + + + ); } - 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 ? ( + +
+ +
+
+ ) : ( + + { + setRouteError(null); + setProtectedRouteRetryKey((retryKey) => retryKey + 1); + }} + /> + + ); } - return ; + return ( + + ); } export function App() { + const runtimeEnv = getRuntimeEnv(); const apiClient = useMemo( () => createApiClient({ @@ -257,7 +207,7 @@ export function App() { return ( - + ); } diff --git a/src/app/admin-pages.tsx b/src/app/admin-pages.tsx index 1500b8f..fe7454f 100644 --- a/src/app/admin-pages.tsx +++ b/src/app/admin-pages.tsx @@ -24,14 +24,14 @@ export function AiCharactersPage({ routeError }: { readonly routeError: string |

AI 캐릭터

-

캐릭터 목록과 생성 흐름은 Phase 2에서 연결합니다.

+

캐릭터 목록과 생성 흐름은 Phase 3에서 연결합니다.

{routeError === null ? null : (

{routeError}

)} - + ); } diff --git a/src/app/browser-location.ts b/src/app/browser-location.ts index 35e2275..e7c4333 100644 --- a/src/app/browser-location.ts +++ b/src/app/browser-location.ts @@ -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); } diff --git a/src/app/protected-admin-shell.tsx b/src/app/protected-admin-shell.tsx new file mode 100644 index 0000000..2575f57 --- /dev/null +++ b/src/app/protected-admin-shell.tsx @@ -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 ( + { + event.preventDefault(); + navigateTo(routePaths.aiCharacters); + }} + > + AI 캐릭터 + + ); +} + +export function ProtectedAdminShell({ apiMode, routeError }: { readonly apiMode: ApiMode; readonly routeError: string | null }) { + const auth = useAuthSession(); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const menuButtonRef = useRef(null); + const closeButtonRef = useRef(null); + const mobileMenuRef = useRef(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) { + if (event.key !== "Tab") { + return; + } + + const focusableElements = Array.from(mobileMenuRef.current?.querySelectorAll(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 ( +
+
+ +
+ + 본문으로 건너뛰기 + + +
+
+
+ + +
+ +
+
+ +
+
+
+
+ {isMobileMenuOpen ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/src/main.tsx b/src/main.tsx index 8fa1208..a372b9a 100644 --- a/src/main.tsx +++ b/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 { + if (import.meta.env.DEV && runtimeEnv.apiMode === "mock") { + const { startMockWorker } = await import("@/shared/mocks/browser"); + await startMockWorker(); + } -if (!root) { - throw new Error("Root element #root was not found"); + const root = document.getElementById("root"); + + if (!root) { + throw new Error("Root element #root was not found"); + } + + createRoot(root).render( + + + + + , + ); } -createRoot(root).render( - - - - - , -); +void bootstrap(); diff --git a/src/shared/api/__tests__/client.test.ts b/src/shared/api/__tests__/client.test.ts index e5d5e4c..ee25080 100644 --- a/src/shared/api/__tests__/client.test.ts +++ b/src/shared/api/__tests__/client.test.ts @@ -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); + }); }); diff --git a/src/shared/config/env.test.ts b/src/shared/config/env.test.ts index ac3d372..4f26cba 100644 --- a/src/shared/config/env.test.ts +++ b/src/shared/config/env.test.ts @@ -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", () => { diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts index ea3edda..0bf80b1 100644 --- a/src/shared/config/env.ts +++ b/src/shared/config/env.ts @@ -1,23 +1,53 @@ +import { z } from "zod"; + +const apiModeSchema = z.enum(["server", "mock"]); + +export type ApiMode = z.infer; + 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 }; } diff --git a/src/shared/mocks/__tests__/auth-handlers.test.ts b/src/shared/mocks/__tests__/auth-handlers.test.ts new file mode 100644 index 0000000..4a72aed --- /dev/null +++ b/src/shared/mocks/__tests__/auth-handlers.test.ts @@ -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(); + }); +}); diff --git a/src/shared/mocks/__tests__/mock-preview-docs.test.ts b/src/shared/mocks/__tests__/mock-preview-docs.test.ts new file mode 100644 index 0000000..852573e --- /dev/null +++ b/src/shared/mocks/__tests__/mock-preview-docs.test.ts @@ -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", + ]); + }); +}); diff --git a/src/shared/mocks/__tests__/mode-boundary.test.ts b/src/shared/mocks/__tests__/mode-boundary.test.ts new file mode 100644 index 0000000..7684e0a --- /dev/null +++ b/src/shared/mocks/__tests__/mode-boundary.test.ts @@ -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); + } +} diff --git a/src/shared/mocks/__tests__/production-graph.test.ts b/src/shared/mocks/__tests__/production-graph.test.ts new file mode 100644 index 0000000..1da5828 --- /dev/null +++ b/src/shared/mocks/__tests__/production-graph.test.ts @@ -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]; + }); +} diff --git a/src/shared/mocks/browser.test.ts b/src/shared/mocks/browser.test.ts new file mode 100644 index 0000000..b7d458b --- /dev/null +++ b/src/shared/mocks/browser.test.ts @@ -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); + }); +}); diff --git a/src/shared/mocks/browser.ts b/src/shared/mocks/browser.ts new file mode 100644 index 0000000..6ec6df9 --- /dev/null +++ b/src/shared/mocks/browser.ts @@ -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 | null = null; + +export async function startMockWorker(): Promise { + worker ??= setupWorker(...createMockHandlers(createMockStore(), getRuntimeEnv().apiBaseUrl)); + await worker.start({ onUnhandledRequest: "error" }); +} diff --git a/src/shared/mocks/contract.ts b/src/shared/mocks/contract.ts new file mode 100644 index 0000000..55987ab --- /dev/null +++ b/src/shared/mocks/contract.ts @@ -0,0 +1,9 @@ +import type { ApiErrorResponse, ApiSuccessResponse } from "@/shared/api/types"; + +export function ok(data: Data): ApiSuccessResponse { + return { success: true, message: null, data, errorProperty: null }; +} + +export function error(message: string): ApiErrorResponse { + return { success: false, message, data: null, errorProperty: null }; +} diff --git a/src/shared/mocks/handlers.ts b/src/shared/mocks/handlers.ts new file mode 100644 index 0000000..4aa05e4 --- /dev/null +++ b/src/shared/mocks/handlers.ts @@ -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(); + + 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 { + 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)); + }), + ]; +} diff --git a/src/shared/ui/__tests__/mock-mode-banner.test.tsx b/src/shared/ui/__tests__/mock-mode-banner.test.tsx new file mode 100644 index 0000000..9a633d2 --- /dev/null +++ b/src/shared/ui/__tests__/mock-mode-banner.test.tsx @@ -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(); + + // Then + expect(screen.getByRole("status", { name: "Mock Preview" })).toHaveTextContent("Mock Preview"); + }); + + test("does not render in server mode", () => { + // Given, When + render(); + + // Then + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); +}); diff --git a/src/shared/ui/mock-mode-banner.tsx b/src/shared/ui/mock-mode-banner.tsx new file mode 100644 index 0000000..e5814b9 --- /dev/null +++ b/src/shared/ui/mock-mode-banner.tsx @@ -0,0 +1,18 @@ +import type { ApiMode } from "@/shared/config/env"; + +export function MockModeBanner({ apiMode }: { readonly apiMode: ApiMode }) { + if (apiMode === "server") { + return null; + } + + return ( + + ); +} diff --git a/tests/e2e/mock-mode-boundary.spec.ts b/tests/e2e/mock-mode-boundary.spec.ts new file mode 100644 index 0000000..abe1fe2 --- /dev/null +++ b/tests/e2e/mock-mode-boundary.spec.ts @@ -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); +}); diff --git a/tests/e2e/mock-preview-shell.spec.ts b/tests/e2e/mock-preview-shell.spec.ts new file mode 100644 index 0000000..55020ad --- /dev/null +++ b/tests/e2e/mock-preview-shell.spec.ts @@ -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 { + 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 { + await page.evaluate(() => { + document.documentElement.style.zoom = "2"; + }); +} + +async function expectNoHorizontalOverflow(page: Page): Promise { + const hasHorizontalOverflow = await page.evaluate( + () => document.documentElement.scrollWidth > document.documentElement.clientWidth, + ); + + expect(hasHorizontalOverflow).toBe(false); +} + +async function expectBannerDoesNotOverlap(banner: Locator, control: Locator): Promise { + 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([]); +}); diff --git a/tests/e2e/server-mode-boundary.spec.ts b/tests/e2e/server-mode-boundary.spec.ts new file mode 100644 index 0000000..7880588 --- /dev/null +++ b/tests/e2e/server-mode-boundary.spec.ts @@ -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 { + 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((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((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); +}); diff --git a/vite.config.ts b/vite.config.ts index 36570b2..f794826 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,23 +1,33 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +import { loadEnv } from "vite"; import { configDefaults, defineConfig } from "vitest/config"; -export default defineConfig({ - plugins: [react(), tailwindcss()], - server: { - host: "127.0.0.1", - port: 8888, - strictPort: true, - }, - resolve: { - alias: { - "@": new URL("./src", import.meta.url).pathname, +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, + strictPort: true, }, - }, - test: { - environment: "jsdom", - setupFiles: ["./src/shared/test/setup.ts"], - globals: true, - exclude: [...configDefaults.exclude, "tests/e2e/**"], - }, + resolve: { + alias: { + "@": new URL("./src", import.meta.url).pathname, + }, + }, + test: { + environment: "jsdom", + setupFiles: ["./src/shared/test/setup.ts"], + globals: true, + exclude: [...configDefaults.exclude, "tests/e2e/**"], + }, + }; });